From 5a318d2b78297734c402c155674d9d76dc266eae Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 21 Oct 2026 09:16:55 -0500 Subject: [PATCH 1/2] agent: launches get one env policy, no API key unless named Linux and Windows inherited fizzy's whole environment, so a stray ANTHROPIC_API_KEY could bill per token instead of the plan login. env.zig strips every *_API_KEY (and ANTHROPIC_AUTH_TOKEN) from the child's environment; "Use API key from environment" (off by default) forwards only the variables named in "api_key_vars". The chat's start note says "plan login" or "API key: forwarded". Co-Authored-By: Claude Sonnet 5.5 --- build.zig | 7 ++++ src/AcpClient.zig | 21 +++++++++- src/Agent.zig | 15 +++++-- src/Settings.zig | 12 ++++++ src/env.zig | 103 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 src/env.zig diff --git a/build.zig b/build.zig index fc5fb16..f9989f1 100644 --- a/build.zig +++ b/build.zig @@ -33,9 +33,16 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .root_source_file = b.path("src/permission.zig"), }) }); + // The environment an agent is launched with: no API key unless named. + const env_tests = b.addTest(.{ .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/env.zig"), + }) }); const test_step = b.step("test", "Run the chat's tests"); test_step.dependOn(&b.addRunArtifact(auth_tests).step); test_step.dependOn(&b.addRunArtifact(tests).step); test_step.dependOn(&b.addRunArtifact(transcript_tests).step); test_step.dependOn(&b.addRunArtifact(permission_tests).step); + test_step.dependOn(&b.addRunArtifact(env_tests).step); } diff --git a/src/AcpClient.zig b/src/AcpClient.zig index a2a17bc..7ab51a5 100644 --- a/src/AcpClient.zig +++ b/src/AcpClient.zig @@ -10,6 +10,8 @@ //! person's unsaved edits are read and an edit is one change they can undo. The agent gets fizzy's //! own tools through `fizzy-mcp` (the agent plugin's), named in `session/new`. const std = @import("std"); +const builtin = @import("builtin"); +const core = @import("core"); const sdk = @import("fizzy_sdk"); const dvui = @import("dvui"); const api = @import("api.zig"); @@ -18,6 +20,7 @@ const auth = @import("auth.zig"); const permission = @import("permission.zig"); const RequestId = permission.RequestId; const Agent = @import("Agent.zig"); +const env = @import("env.zig"); const Settings = @import("Settings.zig"); const json = std.json; @@ -55,12 +58,14 @@ const Launch = struct { arena: std.heap.ArenaAllocator, argv: []const []const u8 = &.{}, cwd: []const u8 = "", + /// The API key variables to forward to the agent (`env.child`); empty: none. + forward: []const u8 = "", thread: ?std.Thread = null, done: std.atomic.Value(bool) = .init(false), result: Agent.StartError!*Agent = error.SpawnFailed, fn run(self: *Launch, gpa: std.mem.Allocator) void { - self.result = Agent.start(gpa, dvui.io, self.argv, self.cwd, wake); + self.result = Agent.start(gpa, dvui.io, self.argv, self.cwd, self.forward, wake); self.done.store(true, .release); wake(); } @@ -443,6 +448,7 @@ fn connect(s: *Session) void { return fail(s, "Could not read the agent command.", .{}); }; launch.cwd = arena.dupe(u8, workDir(s)) catch "/"; + launch.forward = arena.dupe(u8, if (settings.use_api_key.get()) settings.api_key_vars.get() else "") catch ""; launch.thread = std.Thread.spawn(.{}, Launch.run, .{ launch, s.gpa }) catch { launch.abandon(s.gpa); return fail(s, "Could not start the agent: no thread for it.", .{}); @@ -453,10 +459,21 @@ fn connect(s: *Session) void { s.state = .starting; // Picking a session back up says so when it is open ("Picked up where you left off"); only a // new one is worth announcing as it starts. - if (s.resume_id == null) note(s, "Starting `{s}`...", .{line}); + if (s.resume_id == null) note(s, "Starting `{s}` ({s})...", .{ line, authStatus(s, launch.forward) }); setBusy(s, true); } +/// How the agent signs in, for the line that says it starts: "plan login", or the API key +/// variables forwarded (names only). macOS forwards none (`Agent.spawn`). +fn authStatus(s: *Session, forward: []const u8) []const u8 { + if (builtin.os.tag == .macos) return "plan login"; + var environ_map = core.platform.processEnviron().createMap(s.gpa) catch return "plan login"; + defer environ_map.deinit(); + return env.status(&auth_status_buf, &environ_map, forward); +} + +var auth_status_buf: [256]u8 = undefined; + /// The agent's launch finished on its thread: on to `initialize`, or say why it did not start. fn launched(s: *Session) void { const launch = s.launch orelse return; diff --git a/src/Agent.zig b/src/Agent.zig index 8a48a63..a027438 100644 --- a/src/Agent.zig +++ b/src/Agent.zig @@ -13,6 +13,7 @@ const std = @import("std"); const builtin = @import("builtin"); const core = @import("core"); const acp = @import("acp.zig"); +const env = @import("env.zig"); const Io = std.Io; gpa: std.mem.Allocator, @@ -32,7 +33,8 @@ stderr_len: usize = 0, pub const StartError = error{ NoCommand, SpawnFailed, OutOfMemory }; /// Spawn `argv` in `cwd` and start reading it. The caller owns the result and calls `stop`. -pub fn start(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: []const u8, wake: *const fn () void) StartError!*Agent { +/// `forward` names the API key variables the agent may inherit (`env.child`); none by default. +pub fn start(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: []const u8, forward: []const u8, wake: *const fn () void) StartError!*Agent { if (argv.len == 0) return error.NoCommand; const resolved = resolveExecutable(gpa, io, argv[0]); defer if (resolved) |r| gpa.free(r); @@ -41,7 +43,7 @@ pub fn start(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: []co @memcpy(full, argv); if (resolved) |r| full[0] = r; - const child = spawn(gpa, io, full, cwd) catch return error.SpawnFailed; + const child = spawn(gpa, io, full, cwd, forward) catch return error.SpawnFailed; const self = try gpa.create(Agent); self.* = .{ .gpa = gpa, .io = io, .wake = wake, .child = child }; self.reader = std.Thread.spawn(.{}, readStdout, .{self}) catch null; @@ -53,7 +55,9 @@ pub fn start(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: []co return self; } -fn spawn(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: []const u8) !std.process.Child { +/// macOS has its own allowlisted environment (`core.darwin_spawn`), which holds no API key and +/// has no way to add one yet; everywhere else `env.child` builds it. +fn spawn(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: []const u8, forward: []const u8) !std.process.Child { if (comptime builtin.target.os.tag == .macos) { return core.darwin_spawn.spawn(gpa, .{ .argv = argv, @@ -63,8 +67,13 @@ fn spawn(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: []const .stderr = .pipe, }, core.shell_env.path(gpa, io)); } + var environ_map = try core.platform.processEnviron().createMap(gpa); + defer environ_map.deinit(); + var child_env = try env.child(gpa, &environ_map, forward); + defer child_env.deinit(); return std.process.spawn(io, .{ .argv = argv, + .environ_map = &child_env, .cwd = .{ .path = cwd }, .stdin = .pipe, .stdout = .pipe, diff --git a/src/Settings.zig b/src/Settings.zig index 6c574fd..c7291f5 100644 --- a/src/Settings.zig +++ b/src/Settings.zig @@ -12,3 +12,15 @@ mcp_command: settings.Value([]const u8, .{ .description = "fizzy-mcp, which hands the agent fizzy's tools. Empty: the one the agent " ++ "plugin installs beside itself.", }) = .init(""), + +use_api_key: settings.Value(bool, .{ + .description = "Use an API key from fizzy's environment. Off: the agent signs in with your " ++ + "plan and no API key variable reaches it, so nothing is billed per token. On: the " ++ + "variables named below are forwarded to the agent, and only those. Not available on " ++ + "macOS yet.", +}) = .init(false), + +api_key_vars: settings.Value([]const u8, .{ + .description = "The environment variables forwarded when \"Use API key from environment\" is " ++ + "on, separated by commas. Only names are stored here, never a key.", +}) = .init("ANTHROPIC_API_KEY,OPENAI_API_KEY,CODEX_API_KEY,GEMINI_API_KEY,GOOGLE_API_KEY"), diff --git a/src/env.zig b/src/env.zig new file mode 100644 index 0000000..c7164c8 --- /dev/null +++ b/src/env.zig @@ -0,0 +1,103 @@ +//! The environment an agent is launched with: one policy on every OS. A variable that carries an +//! API key never reaches the agent by default, so a stray `ANTHROPIC_API_KEY` in fizzy's own +//! environment cannot bill the person per token in place of their plan login. They opt in, by name +//! (Settings > Chat), and only the names they list are forwarded. Values are never read into a +//! message, a log or a setting: they go from this process's environment to the child's and nowhere +//! else. +const std = @import("std"); +const Map = std.process.Environ.Map; + +/// Names stripped whatever their spelling elsewhere; any name ending `_API_KEY` is stripped too. +const secret_names = [_][]const u8{ "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY" }; + +/// True for a variable that carries a credential the agent would bill with. Case-insensitive, +/// since Windows names are. +pub fn isSecret(name: []const u8) bool { + for (secret_names) |s| if (std.ascii.eqlIgnoreCase(name, s)) return true; + return name.len >= "_API_KEY".len and + std.ascii.eqlIgnoreCase(name[name.len - "_API_KEY".len ..], "_API_KEY"); +} + +/// The names in a comma, semicolon or space separated setting. +pub fn names(list: []const u8) std.mem.TokenIterator(u8, .any) { + return std.mem.tokenizeAny(u8, list, ", ;\t"); +} + +/// A copy of `source` without its secret variables, plus the ones named in `forward` that +/// `source` has. The caller calls `deinit` on the result. +pub fn child(gpa: std.mem.Allocator, source: *const Map, forward: []const u8) !Map { + var out = Map.init(gpa); + errdefer out.deinit(); + var it = source.iterator(); + while (it.next()) |e| { + if (!isSecret(e.key_ptr.*)) try out.put(e.key_ptr.*, e.value_ptr.*); + } + var wanted = names(forward); + while (wanted.next()) |name| { + if (source.get(name)) |value| try out.put(name, value); + } + return out; +} + +/// What the chat says about how the agent signs in: "plan login", or "API key: A, B forwarded" +/// naming the variables `source` has among `forward`. Only names, never values. +pub fn status(buf: []u8, source: *const Map, forward: []const u8) []const u8 { + var w: std.Io.Writer = .fixed(buf); + var n: usize = 0; + var wanted = names(forward); + while (wanted.next()) |name| { + if (source.get(name) == null) continue; + w.writeAll(if (n == 0) "API key: " else ", ") catch break; + w.writeAll(name) catch break; + n += 1; + } + if (n == 0) return "plan login"; + w.writeAll(" forwarded") catch {}; + return w.buffered(); +} + +test "no API key reaches the child by default" { + const gpa = std.testing.allocator; + var source = Map.init(gpa); + defer source.deinit(); + try source.put("PATH", "/bin"); + for ([_][]const u8{ + "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "CODEX_API_KEY", "GEMINI_API_KEY", + "GOOGLE_API_KEY", "MISTRAL_API_KEY", "anthropic_api_key", "ANTHROPIC_AUTH_TOKEN", + }) |k| try source.put(k, "secret"); + + var out = try child(gpa, &source, ""); + defer out.deinit(); + try std.testing.expectEqual(@as(usize, 1), out.count()); + try std.testing.expectEqualStrings("/bin", out.get("PATH").?); + var it = out.iterator(); + while (it.next()) |e| try std.testing.expect(!isSecret(e.key_ptr.*)); +} + +test "only the named variables are forwarded" { + const gpa = std.testing.allocator; + var source = Map.init(gpa); + defer source.deinit(); + try source.put("PATH", "/bin"); + try source.put("ANTHROPIC_API_KEY", "a"); + try source.put("OPENAI_API_KEY", "b"); + + var out = try child(gpa, &source, "ANTHROPIC_API_KEY, GEMINI_API_KEY"); + defer out.deinit(); + try std.testing.expectEqual(@as(usize, 2), out.count()); + try std.testing.expect(out.get("ANTHROPIC_API_KEY") != null); + try std.testing.expect(out.get("OPENAI_API_KEY") == null); +} + +test "status names the variables forwarded, never a value" { + const gpa = std.testing.allocator; + var source = Map.init(gpa); + defer source.deinit(); + try source.put("ANTHROPIC_API_KEY", "sk-secret"); + var buf: [128]u8 = undefined; + try std.testing.expectEqualStrings("plan login", status(&buf, &source, "")); + try std.testing.expectEqualStrings("plan login", status(&buf, &source, "OPENAI_API_KEY")); + const s = status(&buf, &source, "ANTHROPIC_API_KEY,OPENAI_API_KEY"); + try std.testing.expectEqualStrings("API key: ANTHROPIC_API_KEY forwarded", s); + try std.testing.expect(std.mem.indexOf(u8, s, "sk-secret") == null); +} From e3472df76af1dc1a7c58045516be3f588e9714ae Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 21 Oct 2026 09:32:50 -0500 Subject: [PATCH 2/2] agent: env policy review fixes Policy struct carries the setting so "off" forwards nothing by construction; env.spawn is the one place environ_map is set, with a test that spawns a child and checks its environment; status allocates instead of truncating; no file-scope buffer. Co-Authored-By: Claude Sonnet 5.5 --- src/AcpClient.zig | 22 +++---- src/Agent.zig | 21 ++----- src/env.zig | 148 ++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 141 insertions(+), 50 deletions(-) diff --git a/src/AcpClient.zig b/src/AcpClient.zig index 7ab51a5..fb51a3d 100644 --- a/src/AcpClient.zig +++ b/src/AcpClient.zig @@ -58,14 +58,14 @@ const Launch = struct { arena: std.heap.ArenaAllocator, argv: []const []const u8 = &.{}, cwd: []const u8 = "", - /// The API key variables to forward to the agent (`env.child`); empty: none. - forward: []const u8 = "", + /// Which API key variables the agent may inherit (`env.child`); none by default. + policy: env.Policy = .{}, thread: ?std.Thread = null, done: std.atomic.Value(bool) = .init(false), result: Agent.StartError!*Agent = error.SpawnFailed, fn run(self: *Launch, gpa: std.mem.Allocator) void { - self.result = Agent.start(gpa, dvui.io, self.argv, self.cwd, self.forward, wake); + self.result = Agent.start(gpa, dvui.io, self.argv, self.cwd, self.policy, wake); self.done.store(true, .release); wake(); } @@ -448,7 +448,10 @@ fn connect(s: *Session) void { return fail(s, "Could not read the agent command.", .{}); }; launch.cwd = arena.dupe(u8, workDir(s)) catch "/"; - launch.forward = arena.dupe(u8, if (settings.use_api_key.get()) settings.api_key_vars.get() else "") catch ""; + launch.policy = .{ + .use_api_key = settings.use_api_key.get(), + .vars = arena.dupe(u8, settings.api_key_vars.get()) catch "", + }; launch.thread = std.Thread.spawn(.{}, Launch.run, .{ launch, s.gpa }) catch { launch.abandon(s.gpa); return fail(s, "Could not start the agent: no thread for it.", .{}); @@ -459,21 +462,20 @@ fn connect(s: *Session) void { s.state = .starting; // Picking a session back up says so when it is open ("Picked up where you left off"); only a // new one is worth announcing as it starts. - if (s.resume_id == null) note(s, "Starting `{s}` ({s})...", .{ line, authStatus(s, launch.forward) }); + if (s.resume_id == null) note(s, "Starting `{s}` ({s})...", .{ line, authStatus(launch.policy) }); setBusy(s, true); } /// How the agent signs in, for the line that says it starts: "plan login", or the API key /// variables forwarded (names only). macOS forwards none (`Agent.spawn`). -fn authStatus(s: *Session, forward: []const u8) []const u8 { +fn authStatus(policy: env.Policy) []const u8 { if (builtin.os.tag == .macos) return "plan login"; - var environ_map = core.platform.processEnviron().createMap(s.gpa) catch return "plan login"; + const gpa = sdk.host().arena(); + var environ_map = core.platform.processEnviron().createMap(gpa) catch return "plan login"; defer environ_map.deinit(); - return env.status(&auth_status_buf, &environ_map, forward); + return env.status(gpa, &environ_map, policy) catch "plan login"; } -var auth_status_buf: [256]u8 = undefined; - /// The agent's launch finished on its thread: on to `initialize`, or say why it did not start. fn launched(s: *Session) void { const launch = s.launch orelse return; diff --git a/src/Agent.zig b/src/Agent.zig index a027438..3b52321 100644 --- a/src/Agent.zig +++ b/src/Agent.zig @@ -33,8 +33,8 @@ stderr_len: usize = 0, pub const StartError = error{ NoCommand, SpawnFailed, OutOfMemory }; /// Spawn `argv` in `cwd` and start reading it. The caller owns the result and calls `stop`. -/// `forward` names the API key variables the agent may inherit (`env.child`); none by default. -pub fn start(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: []const u8, forward: []const u8, wake: *const fn () void) StartError!*Agent { +/// `policy` says which API key variables the agent may inherit (`env.child`); none by default. +pub fn start(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: []const u8, policy: env.Policy, wake: *const fn () void) StartError!*Agent { if (argv.len == 0) return error.NoCommand; const resolved = resolveExecutable(gpa, io, argv[0]); defer if (resolved) |r| gpa.free(r); @@ -43,7 +43,7 @@ pub fn start(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: []co @memcpy(full, argv); if (resolved) |r| full[0] = r; - const child = spawn(gpa, io, full, cwd, forward) catch return error.SpawnFailed; + const child = spawn(gpa, io, full, cwd, policy) catch return error.SpawnFailed; const self = try gpa.create(Agent); self.* = .{ .gpa = gpa, .io = io, .wake = wake, .child = child }; self.reader = std.Thread.spawn(.{}, readStdout, .{self}) catch null; @@ -57,7 +57,7 @@ pub fn start(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: []co /// macOS has its own allowlisted environment (`core.darwin_spawn`), which holds no API key and /// has no way to add one yet; everywhere else `env.child` builds it. -fn spawn(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: []const u8, forward: []const u8) !std.process.Child { +fn spawn(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: []const u8, policy: env.Policy) !std.process.Child { if (comptime builtin.target.os.tag == .macos) { return core.darwin_spawn.spawn(gpa, .{ .argv = argv, @@ -69,18 +69,7 @@ fn spawn(gpa: std.mem.Allocator, io: Io, argv: []const []const u8, cwd: []const } var environ_map = try core.platform.processEnviron().createMap(gpa); defer environ_map.deinit(); - var child_env = try env.child(gpa, &environ_map, forward); - defer child_env.deinit(); - return std.process.spawn(io, .{ - .argv = argv, - .environ_map = &child_env, - .cwd = .{ .path = cwd }, - .stdin = .pipe, - .stdout = .pipe, - .stderr = .pipe, - // Windows only: a console agent from a windowed fizzy would otherwise pop up a console. - .create_no_window = true, - }); + return env.spawn(gpa, io, &environ_map, policy, argv, cwd); } /// Write one message (a whole line, newline included). UI thread only. diff --git a/src/env.zig b/src/env.zig index c7164c8..3a60894 100644 --- a/src/env.zig +++ b/src/env.zig @@ -4,12 +4,32 @@ //! (Settings > Chat), and only the names they list are forwarded. Values are never read into a //! message, a log or a setting: they go from this process's environment to the child's and nowhere //! else. +//! +//! The filter is by name: `*_API_KEY` and `ANTHROPIC_AUTH_TOKEN`. A credential under another name +//! (`CLAUDE_CODE_OAUTH_TOKEN`, `AWS_BEARER_TOKEN_BEDROCK`, `*_APIKEY`) still passes, and the +//! opt-in list may name any variable, `ANTHROPIC_AUTH_TOKEN` included. const std = @import("std"); +const builtin = @import("builtin"); const Map = std.process.Environ.Map; +const separators = ", ;\t"; + /// Names stripped whatever their spelling elsewhere; any name ending `_API_KEY` is stripped too. const secret_names = [_][]const u8{ "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY" }; +/// What the person chose in Settings > Chat. +pub const Policy = struct { + /// "Use API key from environment": off, nothing is forwarded whatever `vars` says. + use_api_key: bool = false, + /// The variables to forward when it is on, separated by commas. + vars: []const u8 = "", + + /// The names to forward: none unless `use_api_key`. + fn forwarded(self: Policy) std.mem.TokenIterator(u8, .any) { + return std.mem.tokenizeAny(u8, if (self.use_api_key) self.vars else "", separators); + } +}; + /// True for a variable that carries a credential the agent would bill with. Case-insensitive, /// since Windows names are. pub fn isSecret(name: []const u8) bool { @@ -18,21 +38,17 @@ pub fn isSecret(name: []const u8) bool { std.ascii.eqlIgnoreCase(name[name.len - "_API_KEY".len ..], "_API_KEY"); } -/// The names in a comma, semicolon or space separated setting. -pub fn names(list: []const u8) std.mem.TokenIterator(u8, .any) { - return std.mem.tokenizeAny(u8, list, ", ;\t"); -} - -/// A copy of `source` without its secret variables, plus the ones named in `forward` that -/// `source` has. The caller calls `deinit` on the result. -pub fn child(gpa: std.mem.Allocator, source: *const Map, forward: []const u8) !Map { +/// A copy of `source` without its secret variables, plus the ones the policy names that `source` +/// has. `Map` compares names the way the platform does (ignoring case on Windows). The caller +/// calls `deinit` on the result. +pub fn child(gpa: std.mem.Allocator, source: *const Map, policy: Policy) !Map { var out = Map.init(gpa); errdefer out.deinit(); var it = source.iterator(); while (it.next()) |e| { if (!isSecret(e.key_ptr.*)) try out.put(e.key_ptr.*, e.value_ptr.*); } - var wanted = names(forward); + var wanted = policy.forwarded(); while (wanted.next()) |name| { if (source.get(name)) |value| try out.put(name, value); } @@ -40,20 +56,39 @@ pub fn child(gpa: std.mem.Allocator, source: *const Map, forward: []const u8) !M } /// What the chat says about how the agent signs in: "plan login", or "API key: A, B forwarded" -/// naming the variables `source` has among `forward`. Only names, never values. -pub fn status(buf: []u8, source: *const Map, forward: []const u8) []const u8 { - var w: std.Io.Writer = .fixed(buf); +/// naming the variables `source` has among those the policy forwards. Only names, never values. +/// The caller frees the result with `gpa`, except "plan login", which is a literal. +pub fn status(gpa: std.mem.Allocator, source: *const Map, policy: Policy) std.mem.Allocator.Error![]const u8 { + var w: std.Io.Writer.Allocating = .init(gpa); + defer w.deinit(); var n: usize = 0; - var wanted = names(forward); + var wanted = policy.forwarded(); while (wanted.next()) |name| { if (source.get(name) == null) continue; - w.writeAll(if (n == 0) "API key: " else ", ") catch break; - w.writeAll(name) catch break; + w.writer.writeAll(if (n == 0) "API key: " else ", ") catch return error.OutOfMemory; + w.writer.writeAll(name) catch return error.OutOfMemory; n += 1; } if (n == 0) return "plan login"; - w.writeAll(" forwarded") catch {}; - return w.buffered(); + w.writer.writeAll(" forwarded") catch return error.OutOfMemory; + return w.toOwnedSlice(); +} + +/// Spawn `argv` in `cwd` with stdin, stdout and stderr piped and the environment `child` builds +/// from `source`: the one place the agent's environment is set. +pub fn spawn(gpa: std.mem.Allocator, io: std.Io, source: *const Map, policy: Policy, argv: []const []const u8, cwd: []const u8) !std.process.Child { + var child_env = try child(gpa, source, policy); + defer child_env.deinit(); + return std.process.spawn(io, .{ + .argv = argv, + .cwd = .{ .path = cwd }, + .environ_map = &child_env, + .stdin = .pipe, + .stdout = .pipe, + .stderr = .pipe, + // Windows only: a console agent from a windowed fizzy would otherwise pop up a console. + .create_no_window = true, + }); } test "no API key reaches the child by default" { @@ -66,7 +101,7 @@ test "no API key reaches the child by default" { "GOOGLE_API_KEY", "MISTRAL_API_KEY", "anthropic_api_key", "ANTHROPIC_AUTH_TOKEN", }) |k| try source.put(k, "secret"); - var out = try child(gpa, &source, ""); + var out = try child(gpa, &source, .{}); defer out.deinit(); try std.testing.expectEqual(@as(usize, 1), out.count()); try std.testing.expectEqualStrings("/bin", out.get("PATH").?); @@ -74,6 +109,16 @@ test "no API key reaches the child by default" { while (it.next()) |e| try std.testing.expect(!isSecret(e.key_ptr.*)); } +test "names listed while the setting is off forward nothing" { + const gpa = std.testing.allocator; + var source = Map.init(gpa); + defer source.deinit(); + try source.put("ANTHROPIC_API_KEY", "a"); + var out = try child(gpa, &source, .{ .use_api_key = false, .vars = "ANTHROPIC_API_KEY" }); + defer out.deinit(); + try std.testing.expectEqual(@as(usize, 0), out.count()); +} + test "only the named variables are forwarded" { const gpa = std.testing.allocator; var source = Map.init(gpa); @@ -82,22 +127,77 @@ test "only the named variables are forwarded" { try source.put("ANTHROPIC_API_KEY", "a"); try source.put("OPENAI_API_KEY", "b"); - var out = try child(gpa, &source, "ANTHROPIC_API_KEY, GEMINI_API_KEY"); + var out = try child(gpa, &source, .{ .use_api_key = true, .vars = "ANTHROPIC_API_KEY, GEMINI_API_KEY" }); defer out.deinit(); try std.testing.expectEqual(@as(usize, 2), out.count()); try std.testing.expect(out.get("ANTHROPIC_API_KEY") != null); try std.testing.expect(out.get("OPENAI_API_KEY") == null); } +test "a name is found in whatever case the platform's environment uses" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + var source = Map.init(gpa); + defer source.deinit(); + try source.put("Anthropic_Api_Key", "a"); + var out = try child(gpa, &source, .{ .use_api_key = true, .vars = "ANTHROPIC_API_KEY" }); + defer out.deinit(); + try std.testing.expectEqual(@as(usize, 1), out.count()); +} + test "status names the variables forwarded, never a value" { const gpa = std.testing.allocator; var source = Map.init(gpa); defer source.deinit(); try source.put("ANTHROPIC_API_KEY", "sk-secret"); - var buf: [128]u8 = undefined; - try std.testing.expectEqualStrings("plan login", status(&buf, &source, "")); - try std.testing.expectEqualStrings("plan login", status(&buf, &source, "OPENAI_API_KEY")); - const s = status(&buf, &source, "ANTHROPIC_API_KEY,OPENAI_API_KEY"); + try std.testing.expectEqualStrings("plan login", try status(gpa, &source, .{})); + try std.testing.expectEqualStrings("plan login", try status(gpa, &source, .{ .use_api_key = true, .vars = "OPENAI_API_KEY" })); + try std.testing.expectEqualStrings("plan login", try status(gpa, &source, .{ .vars = "ANTHROPIC_API_KEY" })); + const s = try status(gpa, &source, .{ .use_api_key = true, .vars = "ANTHROPIC_API_KEY,OPENAI_API_KEY" }); + defer gpa.free(s); try std.testing.expectEqualStrings("API key: ANTHROPIC_API_KEY forwarded", s); - try std.testing.expect(std.mem.indexOf(u8, s, "sk-secret") == null); +} + +test "status is not cut short by a long list" { + const gpa = std.testing.allocator; + var source = Map.init(gpa); + defer source.deinit(); + var vars: std.Io.Writer.Allocating = .init(gpa); + defer vars.deinit(); + for (0..40) |i| { + var name: [40]u8 = undefined; + const k = try std.fmt.bufPrint(&name, "SOME_LONG_VENDOR_{d}_API_KEY", .{i}); + try source.put(k, "x"); + try vars.writer.print("{s},", .{k}); + } + const s = try status(gpa, &source, .{ .use_api_key = true, .vars = vars.written() }); + defer gpa.free(s); + try std.testing.expect(std.mem.endsWith(u8, s, " forwarded")); +} + +test "spawn gives the child the built environment, not the parent's" { + const gpa = std.testing.allocator; + var source = try std.testing.environ.createMap(gpa); + defer source.deinit(); + try source.put("CHAT_TEST_MARKER", "present"); + try source.put("CHAT_TEST_API_KEY", "must-not-reach-child"); + try source.put("CHAT_TEST_FORWARDED_API_KEY", "does-reach-child"); + + const argv: []const []const u8 = if (builtin.os.tag == .windows) + &.{ "cmd.exe", "/c", "set" } + else + &.{"env"}; + const policy: Policy = .{ .use_api_key = true, .vars = "CHAT_TEST_FORWARDED_API_KEY" }; + var proc = try spawn(gpa, std.testing.io, &source, policy, argv, "."); + var buf: [4096]u8 = undefined; + var r = proc.stdout.?.readerStreaming(std.testing.io, &buf); + const out = try r.interface.allocRemaining(gpa, .limited(1 << 20)); + defer gpa.free(out); + _ = try proc.wait(std.testing.io); + + // The marker exists only in `source`, not in this process's environment: seeing it in the + // child shows `environ_map` was passed. + try std.testing.expect(std.mem.indexOf(u8, out, "CHAT_TEST_MARKER=present") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "must-not-reach-child") == null); + try std.testing.expect(std.mem.indexOf(u8, out, "CHAT_TEST_FORWARDED_API_KEY=does-reach-child") != null); }