diff --git a/build.zig b/build.zig index bb67a80..a0d5599 100644 --- a/build.zig +++ b/build.zig @@ -21,7 +21,14 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .root_source_file = b.path("src/Transcript.zig"), }) }); + // Signing in to an agent: its methods, the requests, and what happens next. + const auth_tests = b.addTest(.{ .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/auth.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); } diff --git a/src/AcpClient.zig b/src/AcpClient.zig index ca6d086..2956743 100644 --- a/src/AcpClient.zig +++ b/src/AcpClient.zig @@ -14,6 +14,7 @@ const sdk = @import("fizzy_sdk"); const dvui = @import("dvui"); const api = @import("api.zig"); const acp = @import("acp.zig"); +const auth = @import("auth.zig"); const Agent = @import("Agent.zig"); const Settings = @import("Settings.zig"); const json = std.json; @@ -23,8 +24,8 @@ pub var settings: *const Settings = undefined; var sessions: std.AutoArrayHashMapUnmanaged(api.ChatId, *Session) = .empty; -const State = enum { idle, starting, ready, prompting, stopped }; -const Pending = enum { initialize, session_new, session_resume, session_load, prompt, set_option }; +const State = enum { idle, starting, ready, prompting, stopped, signing_in }; +const Pending = enum { initialize, session_new, session_resume, session_load, prompt, set_option, authenticate, logout }; /// One block of a prompt, as ACP writes it: text, an image's bytes, or a link to a file. const Content = struct { @@ -82,9 +83,15 @@ const Session = struct { pending: std.AutoHashMapUnmanaged(i64, Pending) = .empty, /// What the person sent before the agent was ready: sent once the session opens. queued: ?Queued = null, + /// The prompt the agent has, kept until it is taken: one refused for a sign-in is sent again + /// after it. + in_flight: ?Queued = null, + /// The command line the agent was started with: a terminal sign-in runs it again. Owned. + command_line: ?[]u8 = null, /// The chat's project folder, where the agent works; empty: the one fizzy has open. folder: ?[]u8 = null, has_mcp: bool = false, + mcp_noted: bool = false, /// The agent session this chat had (what it saved with `chat.set_resume`), to pick up again /// rather than start over: the agent keeps its own memory of the conversation. resume_id: ?[]u8 = null, @@ -95,6 +102,14 @@ const Session = struct { /// What `initialize` said the agent is called (`agentInfo`): the label its edits carry beside /// the person's (`sdk.Author`). Owned; null until it says. label: ?[]u8 = null, + /// The agent's ways to sign in (`initialize`'s `authMethods`), in `auth_arena`, and whether it + /// takes `logout`. + methods: []const auth.Method = &.{}, + auth_arena: ?std.heap.ArenaAllocator = null, + can_logout: bool = false, + /// A sign-in under way, and the question showing its methods. + sign_in: auth.Flow = .{}, + sign_in_question: ?api.QuestionId = null, /// A `session/load` is replaying the conversation: the chat has it already, so the replay is /// not shown again. replaying: bool = false, @@ -108,9 +123,11 @@ const Session = struct { fn deinit(self: *Session) void { self.disconnect(); if (self.queued) |*q| q.arena.deinit(); + if (self.command_line) |c| self.gpa.free(c); if (self.resume_id) |r| self.gpa.free(r); if (self.folder) |f| self.gpa.free(f); if (self.label) |l| self.gpa.free(l); + if (self.auth_arena) |*a| a.deinit(); self.pending.deinit(self.gpa); var it = self.tools.keyIterator(); while (it.next()) |k| self.gpa.free(k.*); @@ -129,6 +146,11 @@ const Session = struct { var it = self.requests.valueIterator(); while (it.next()) |r| r.deinit(self.gpa); self.requests.clearRetainingCapacity(); + if (self.in_flight) |*q| q.arena.deinit(); + self.in_flight = null; + // A sign-in is for the agent that asked for it. + self.sign_in = .{}; + self.sign_in_question = null; if (self.state != .idle) self.state = .stopped; } }; @@ -167,6 +189,7 @@ pub fn register(host: *sdk.Host, owner: *sdk.Plugin) !void { .{ "chat_resume", api.Resume, onResume }, .{ "chat_setting", api.Choose, onSetting }, .{ "chat_new", api.NewChat, onNew }, + .{ "terminal_done", auth.Exited, onTerminalDone }, }) |entry| try host.registerCommand(.{ .id = client_id ++ "." ++ entry[0], .owner = owner, @@ -174,6 +197,13 @@ pub fn register(host: *sdk.Host, owner: *sdk.Plugin) !void { .visibility = .internal, .runWith = Handler(entry[1], entry[2]).run, }); + try host.registerCommand(.{ + .id = client_id ++ ".sign_out", + .owner = owner, + .title = "Chat: Sign Out of the Agent", + .run = signOut, + .isEnabled = canSignOut, + }); _ = call("chat.register", api.Register{ .client = client_id, .title = "Agents" }); } @@ -236,7 +266,8 @@ fn onSend(a: api.Send) void { s.queued = q; switch (s.state) { .ready => sendQueued(s), - .prompting => {}, + // Sent once signed in and the session opens. + .prompting, .signing_in => {}, .starting, .idle, .stopped => if (s.agent == null and s.launch == null) connect(s), } } @@ -319,6 +350,10 @@ fn applyPreferred(s: *Session, options: ?json.Value) void { fn onAnswer(a: api.Answer) void { const s = sessions.get(a.chat) orelse return; + if (s.sign_in_question == a.question) { + s.sign_in_question = null; + return signIn(s, s.sign_in.chose(s.methods, a.option)); + } const rid = (s.requests.fetchRemove(a.question) orelse return).value; defer rid.deinit(s.gpa); write(s, acp.writeResult, .{ rid.value(), .{ .outcome = .{ .outcome = "selected", .optionId = a.option } } }); @@ -326,6 +361,13 @@ fn onAnswer(a: api.Answer) void { fn onCancel(a: api.ChatOnly) void { const s = sessions.get(a.chat) orelse return; + // Stop while signing in (a browser left unanswered, a terminal left open): the agent stops + // too, and the next message starts it again. + if (s.state == .signing_in) { + note(s, "Stopped signing in.", .{}); + s.disconnect(); + return setBusy(s, false); + } cancel(s); } @@ -398,6 +440,7 @@ fn wake() void { fn connect(s: *Session) void { s.disconnect(); + s.mcp_noted = false; const line = std.mem.trim(u8, settings.agent_command.get(), " \t"); if (line.len == 0) return fail(s, "No agent to run. Set one in Settings > Chat: a command line for an agent " ++ "that speaks the Agent Client Protocol, such as `npx -y @agentclientprotocol/claude-agent-acp`.", .{}); @@ -414,6 +457,8 @@ fn connect(s: *Session) void { return fail(s, "Could not start the agent: no thread for it.", .{}); }; s.launch = launch; + if (s.command_line) |c| s.gpa.free(c); + s.command_line = s.gpa.dupe(u8, line) catch null; 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. @@ -439,8 +484,7 @@ fn launched(s: *Session) void { }; request(s, .initialize, "initialize", .{ .protocolVersion = acp.protocol_version, - // Files go through fizzy's open documents (`readFile`, `writeFile`). Terminals come later. - .clientCapabilities = .{ .fs = .{ .readTextFile = true, .writeTextFile = true }, .terminal = false }, + .clientCapabilities = auth.clientCapabilities(sdk.host().hasCommand(auth.terminal_run)), .clientInfo = .{ .name = "fizzy", .title = "Fizzy", .version = "0.1.0" }, }); } @@ -471,8 +515,10 @@ fn openSession(s: *Session) void { servers = arena.dupe(Server, &.{.{ .name = "fizzy", .command = mcp, .args = args, .env = &.{} }}) catch &.{}; s.has_mcp = true; } - } else note(s, "fizzy-mcp was not found, so the agent cannot see or drive fizzy. Install the agent plugin, " ++ + } else if (!s.mcp_noted) note(s, "fizzy-mcp was not found, so the agent cannot see or drive fizzy. Install the agent plugin, " ++ "or name fizzy-mcp in Settings > Chat.", .{}); + // Said once: a session started again after signing in need not say it twice. + s.mcp_noted = true; const cwd = workDir(s); // Pick up the session this chat had, when the agent can: continuing it as it is if it can, // else loading it (its history replays, and is not shown twice). @@ -498,12 +544,19 @@ fn sessionOpen(s: *Session, id: []const u8, how: []const u8) void { } fn sendQueued(s: *Session) void { - var q = s.queued orelse return; + const q = s.queued orelse return; s.queued = null; - defer q.arena.deinit(); + if (s.in_flight) |*old| old.arena.deinit(); + s.in_flight = q; prompt(s, q.content); } +/// The agent took the prompt (or refused it for good): it is not sent again. +fn dropInFlight(s: *Session) void { + if (s.in_flight) |*q| q.arena.deinit(); + s.in_flight = null; +} + /// The setting's fizzy-mcp, else the one beside the agent plugin. fn findMcp(arena: std.mem.Allocator) ?[]const u8 { const set = std.mem.trim(u8, settings.mcp_command.get(), " \t"); @@ -584,7 +637,7 @@ fn handle(s: *Session, msg: acp.Inbound) void { switch (msg) { .response => |r| { const what = (s.pending.fetchRemove(r.id) orelse return).value; - if (r.err) |err| return refused(s, what, err); + if (r.err) |err| return refused(s, what, err, r.code); answered(s, what, r.result orelse .null); }, .notification => |n| if (std.mem.eql(u8, n.method, "session/update")) update(s, n.params), @@ -609,6 +662,10 @@ fn answered(s: *Session, what: Pending, result: json.Value) void { if (s.label) |l| s.gpa.free(l); s.label = s.gpa.dupe(u8, name) catch null; } + if (s.auth_arena) |*old| old.deinit(); + s.auth_arena = .init(s.gpa); + s.methods = auth.methods(s.auth_arena.?.allocator(), result) catch &.{}; + s.can_logout = auth.canLogout(result); openSession(s); }, .session_new => { @@ -623,7 +680,13 @@ fn answered(s: *Session, what: Pending, result: json.Value) void { publishSettings(s, field(result, "configOptions")); }, .set_option => publishSettings(s, field(result, "configOptions")), + .authenticate => { + note(s, "Signed in to {s}.", .{agentName(s)}); + signIn(s, s.sign_in.authenticated(true)); + }, + .logout => note(s, "Signed out of {s}. Its next session asks you to sign in again.", .{agentName(s)}), .prompt => { + dropInFlight(s); s.state = .ready; s.turn = null; if (str(field(result, "stopReason"))) |reason| { @@ -634,7 +697,24 @@ fn answered(s: *Session, what: Pending, result: json.Value) void { } } -fn refused(s: *Session, what: Pending, err: []const u8) void { +fn refused(s: *Session, what: Pending, err: []const u8, code: i64) void { + // A session start (or, after a sign-out, a prompt) the agent wants a sign-in for: its ways in, + // as buttons. + switch (what) { + .session_new, .session_resume, .session_load, .prompt => if (s.sign_in.refused(code, s.methods) == .choose) { + s.replaying = false; + if (what == .prompt) { + s.turn = null; + // Sent again once signed in, unless the person has written something since. + if (s.queued == null) { + s.queued = s.in_flight; + s.in_flight = null; + } else dropInFlight(s); + } + return signIn(s, .choose); + }, + else => {}, + } switch (what) { .session_resume, .session_load => { // The agent no longer has it (another machine, cleared, too old): start over, and say @@ -646,20 +726,125 @@ fn refused(s: *Session, what: Pending, err: []const u8) void { openSession(s); }, .initialize, .session_new => { - // An agent that needs signing in says so here: its own CLI does that, once. + // Not a sign-in this chat can offer: a refusal of `initialize` (its methods come in + // its answer), or an agent with no methods. note(s, "The agent refused to start: {s}", .{err}); s.disconnect(); setBusy(s, false); }, .prompt => { + dropInFlight(s); s.state = .ready; note(s, "{s}", .{err}); setBusy(s, false); }, .set_option => note(s, "The agent did not take that setting: {s}", .{err}), + .authenticate => { + note(s, "{s} did not sign in: {s}", .{ agentName(s), err }); + signIn(s, s.sign_in.authenticated(false)); + }, + .logout => note(s, "{s} did not sign out: {s}", .{ agentName(s), err }), } } +// ---- signing in ----------------------------------------------------------------------------- + +fn agentName(s: *const Session) []const u8 { + return s.label orelse "The agent"; +} + +/// Carry out what the sign-in flow says comes next. +fn signIn(s: *Session, action: auth.Flow.Action) void { + switch (action) { + .none, .give_up => {}, + .choose => { + s.state = .signing_in; + // Not busy while the person chooses (which also closes what was asked before). + setBusy(s, false); + const arena = sdk.host().arena(); + var options: std.ArrayListUnmanaged(api.Question.Option) = .empty; + var text: std.Io.Writer.Allocating = .init(arena); + text.writer.print("{s} needs you to sign in.", .{agentName(s)}) catch {}; + // A terminal method only while a terminal can be opened for it. + const terminal = sdk.host().hasCommand(auth.terminal_run); + for (s.methods) |m| { + if (m.kind == .terminal and !terminal) continue; + options.append(arena, .{ .id = m.id, .label = m.name, .kind = .allow }) catch {}; + if (m.description.len > 0) text.writer.print("\n{s}: {s}", .{ m.name, m.description }) catch {}; + } + s.sign_in_question = askSignIn(s, text.written(), options.items); + if (s.sign_in_question == null) { + note(s, "{s} needs you to sign in, in a way this chat cannot offer here. Sign in with its own CLI, then send again.", .{agentName(s)}); + s.disconnect(); + } + }, + .authenticate => |id| { + note(s, "Waiting for {s} to sign in...", .{agentName(s)}); + setBusy(s, true); + request(s, .authenticate, "authenticate", auth.Authenticate{ .methodId = id }); + }, + .run_terminal => |m| runTerminal(s, m), + // A prompt refused after a sign-out has its session still: it is ready again. + .retry => if (s.session_id != null) { + s.state = .ready; + if (s.queued != null) sendQueued(s) else { + note(s, "Send your message again.", .{}); + setBusy(s, false); + } + } else openSession(s), + .reconnect => connect(s), + } +} + +/// The methods as buttons in the conversation; null when there are none, or they could not be +/// shown. +fn askSignIn(s: *Session, text: []const u8, options: []const api.Question.Option) ?api.QuestionId { + if (options.len == 0) return null; + const msg = post(s, .other, &.{}) orelse return null; + const r = call("chat.ask", api.Ask{ .chat = s.chat, .message = msg, .question = .{ .text = text, .options = options } }) orelse return null; + return (api.parse(api.Asked, sdk.host().arena(), r) catch return null).question; +} + +/// A terminal method: the agent's own command with the method's arguments and environment, in a +/// terminal the person types into. Its exit comes back as `agent.terminal_done`. +fn runTerminal(s: *Session, m: auth.Method) void { + const arena = sdk.host().arena(); + const command = acp.splitCommand(arena, s.command_line orelse "") catch &.{}; + const argv = auth.terminalArgv(arena, command, m) catch &.{}; + const run: auth.TerminalRun = .{ + .title = std.fmt.allocPrint(arena, "Sign in: {s}", .{m.name}) catch m.name, + .argv = argv, + .env = m.env, + .cwd = workDir(s), + .done = client_id ++ ".terminal_done", + .tag = s.chat, + }; + if (command.len == 0 or call(auth.terminal_run, run) == null) { + note(s, "Could not open a terminal to sign in to {s}.", .{agentName(s)}); + return signIn(s, s.sign_in.exited(-1)); + } + note(s, "Sign in to {s} in the terminal. The chat starts it again when that finishes.", .{agentName(s)}); + setBusy(s, true); +} + +fn onTerminalDone(a: auth.Exited) void { + const s = sessions.get(a.tag) orelse return; + if (a.code != 0) note(s, "The sign-in ended without signing in (exit {d}).", .{a.code}); + signIn(s, s.sign_in.exited(a.code)); +} + +/// Whether an agent running in some chat takes `logout`. +fn canSignOut(_: *anyopaque) bool { + for (sessions.values()) |s| if (s.agent != null and s.can_logout) return true; + return false; +} + +/// Sign out of every running agent that takes it: signing out is the agent's, not one +/// conversation's, and each chat runs its own agent. +fn signOut(_: *anyopaque) anyerror!void { + for (sessions.values()) |s| if (s.agent != null and s.can_logout) request(s, .logout, "logout", auth.Logout{}); +} + /// `session/update`: what the agent is saying, thinking and doing. fn update(s: *Session, params: json.Value) void { // A loaded session replays its history first: the chat shows it already. diff --git a/src/acp.zig b/src/acp.zig index d9ee229..a94c86a 100644 --- a/src/acp.zig +++ b/src/acp.zig @@ -49,7 +49,8 @@ pub const method_not_found = -32601; /// One message from the agent, by what it is. pub const Inbound = union(enum) { /// An answer to one of ours. - response: struct { id: i64, result: ?json.Value, err: ?[]const u8 }, + /// `code` is the error's (`auth.auth_required`, say), 0 with a result. + response: struct { id: i64, result: ?json.Value, err: ?[]const u8, code: i64 = 0 }, /// The agent asks something of us, and waits for `writeResult` / `writeError`. request: struct { id: json.Value, method: []const u8, params: json.Value }, /// The agent tells us something (`session/update`). @@ -78,7 +79,8 @@ pub const Inbound = union(enum) { }; if (o.get("error")) |e| { const message: []const u8 = if (e == .object) if (e.object.get("message")) |m| (if (m == .string) m.string else "error") else "error" else "error"; - return .{ .response = .{ .id = n, .result = null, .err = message } }; + const code: i64 = if (e == .object) if (e.object.get("code")) |c| (if (c == .integer) c.integer else 0) else 0 else 0; + return .{ .response = .{ .id = n, .result = null, .err = message, .code = code } }; } return .{ .response = .{ .id = n, .result = o.get("result") orelse .null, .err = null } }; } @@ -168,6 +170,7 @@ test "messages read as what they are" { try std.testing.expectEqual(@as(i64, 1), r.response.id); const e = Inbound.classify(try parse(a, "{\"jsonrpc\":\"2.0\",\"id\":2,\"error\":{\"code\":-1,\"message\":\"no\"}}")); try std.testing.expectEqualStrings("no", e.response.err.?); + try std.testing.expectEqual(@as(i64, -1), e.response.code); const q = Inbound.classify(try parse(a, "{\"jsonrpc\":\"2.0\",\"id\":\"p1\",\"method\":\"session/request_permission\",\"params\":{}}")); try std.testing.expectEqualStrings("session/request_permission", q.request.method); const n = Inbound.classify(try parse(a, "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{}}")); diff --git a/src/auth.zig b/src/auth.zig new file mode 100644 index 0000000..4b67a9c --- /dev/null +++ b/src/auth.zig @@ -0,0 +1,300 @@ +//! Signing in to an agent over ACP (agentclientprotocol.com/protocol/authentication), std-only. +//! The agent names its ways in (`authMethods`, in its `initialize` answer); a session start it +//! refuses with `auth_required` shows them to the person as buttons. An `agent` method is the +//! agent's own flow (it opens a browser, say), started with `authenticate`; a `terminal` method +//! is the agent's command run again, with the method's arguments and environment, in a terminal +//! the person types into, never sent to `authenticate`. Either way the agent keeps the +//! credentials: the chat never sees a token. `Flow` is what happens next at each step, so +//! `AcpClient.zig` only carries it out. +const std = @import("std"); +const json = std.json; + +/// The error code an agent refuses with until it is signed in. +pub const auth_required: i64 = -32000; + +/// One way in, as the agent advertised it. +pub const Method = struct { + id: []const u8, + name: []const u8, + description: []const u8 = "", + kind: Kind = .agent, + /// A terminal method's arguments, after the agent's own command line. + args: []const []const u8 = &.{}, + /// A terminal method's environment, over the one the agent runs with. + env: []const Env = &.{}, + + pub const Kind = enum { agent, terminal }; +}; + +pub const Env = struct { name: []const u8, value: []const u8 }; + +/// The `authMethods` of an `initialize` answer, in `arena`. A method of a type this chat does not +/// know is left out: it could not carry it out. +pub fn methods(arena: std.mem.Allocator, init_result: json.Value) ![]const Method { + const list = field(init_result, "authMethods") orelse return &.{}; + if (list != .array) return &.{}; + var out: std.ArrayListUnmanaged(Method) = .empty; + for (list.array.items) |m| { + const id = str(field(m, "id")) orelse continue; + // No type is an `agent` method. + const kind = std.meta.stringToEnum(Method.Kind, str(field(m, "type")) orelse "agent") orelse continue; + var method: Method = .{ + .id = try arena.dupe(u8, id), + .name = try arena.dupe(u8, str(field(m, "name")) orelse id), + .description = try arena.dupe(u8, str(field(m, "description")) orelse ""), + .kind = kind, + }; + if (kind == .terminal) { + var args: std.ArrayListUnmanaged([]const u8) = .empty; + if (field(m, "args")) |a| if (a == .array) for (a.array.items) |arg| { + if (arg == .string) try args.append(arena, try arena.dupe(u8, arg.string)); + }; + var env: std.ArrayListUnmanaged(Env) = .empty; + if (field(m, "env")) |e| if (e == .object) { + var it = e.object.iterator(); + while (it.next()) |kv| if (kv.value_ptr.* == .string) try env.append(arena, .{ + .name = try arena.dupe(u8, kv.key_ptr.*), + .value = try arena.dupe(u8, kv.value_ptr.string), + }); + }; + method.args = args.items; + method.env = env.items; + } + try out.append(arena, method); + } + return out.items; +} + +/// Whether the agent takes `logout` (`agentCapabilities.auth.logout` present and not null). +pub fn canLogout(init_result: json.Value) bool { + const v = field(field(field(init_result, "agentCapabilities"), "auth"), "logout") orelse return false; + return v != .null; +} + +pub fn find(list: []const Method, id: []const u8) ?Method { + for (list) |m| if (std.mem.eql(u8, m.id, id)) return m; + return null; +} + +/// A terminal method's command line: the agent's own, then the method's arguments. The method +/// never names a program; the agent is the one the person configured. +pub fn terminalArgv(arena: std.mem.Allocator, command: []const []const u8, m: Method) ![]const []const u8 { + return std.mem.concat(arena, []const u8, &.{ command, m.args }); +} + +// ---- what goes on the wire ------------------------------------------------------------------ + +/// `initialize`'s `clientCapabilities`. Files go through fizzy's open documents; agents' own +/// shell commands stay theirs (`terminal/*` is not offered). `auth.terminal`: a terminal method +/// can be run here (some plugin offers `terminal_run`), so the agent may offer one. +pub const ClientCapabilities = struct { + fs: struct { readTextFile: bool = true, writeTextFile: bool = true } = .{}, + terminal: bool = false, + auth: struct { terminal: bool = false } = .{}, +}; + +pub fn clientCapabilities(terminal: bool) ClientCapabilities { + return .{ .auth = .{ .terminal = terminal } }; +} + +/// `authenticate`'s params. Only ever an `agent` method's id. +pub const Authenticate = struct { methodId: []const u8 }; +/// `logout`'s params. +pub const Logout = struct {}; + +/// The seam a terminal plugin fills: `terminal_run` runs `argv` in a terminal the person types +/// into, in `cwd`, over the app's environment plus `env`, titled `title`; when it exits it calls +/// command `done` with `Exited{ .tag = tag, .code = … }`. A chat advertises terminal sign-in only +/// while some plugin registers it. +pub const terminal_run = "terminal.run"; +pub const TerminalRun = struct { + title: []const u8, + argv: []const []const u8, + env: []const Env = &.{}, + cwd: []const u8 = "", + done: []const u8, + tag: u64 = 0, +}; +pub const Exited = struct { tag: u64 = 0, code: i32 }; + +// ---- what happens next ---------------------------------------------------------------------- + +/// One sign-in, from the refusal that needs it to the session it lets start. +pub const Flow = struct { + step: Step = .none, + + pub const Step = enum { none, choosing, authenticating, in_terminal }; + + pub const Action = union(enum) { + /// Nothing to do. + none, + /// Not something signing in fixes: say why it refused. + give_up, + /// Show the methods as buttons. + choose, + /// Send `authenticate { methodId }` and wait. + authenticate: []const u8, + /// Run the method in a terminal, and wait for it to exit. + run_terminal: Method, + /// Signed in through the agent: start the session again. + retry, + /// Signed in through a terminal: the running agent did not see it; start it again. + reconnect, + }; + + /// A session start (or a prompt) refused with `code`. + pub fn refused(self: *Flow, code: i64, list: []const Method) Action { + if (code != auth_required or list.len == 0) return .give_up; + self.step = .choosing; + return .choose; + } + + /// The person picked method `id`. + pub fn chose(self: *Flow, list: []const Method, id: []const u8) Action { + if (self.step != .choosing) return .none; + const m = find(list, id) orelse return .none; + switch (m.kind) { + .agent => { + self.step = .authenticating; + return .{ .authenticate = m.id }; + }, + .terminal => { + self.step = .in_terminal; + return .{ .run_terminal = m }; + }, + } + } + + /// `authenticate` answered: signed in, or not. + pub fn authenticated(self: *Flow, ok: bool) Action { + if (self.step != .authenticating) return .none; + if (!ok) { + self.step = .choosing; + return .choose; + } + self.step = .none; + return .retry; + } + + /// The terminal method exited with `code`. + pub fn exited(self: *Flow, code: i32) Action { + if (self.step != .in_terminal) return .none; + if (code != 0) { + self.step = .choosing; + return .choose; + } + self.step = .none; + return .reconnect; + } +}; + +fn field(v: ?json.Value, name: []const u8) ?json.Value { + const o = v orelse return null; + if (o != .object) return null; + return o.object.get(name); +} + +fn str(v: ?json.Value) ?[]const u8 { + const s = v orelse return null; + return if (s == .string) s.string else null; +} + +// ---- tests ---------------------------------------------------------------------------------- + +const testing = std.testing; +const acp = @import("acp.zig"); + +const gemini_like = + \\{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"auth":{"logout":{}}}, + \\ "authMethods":[ + \\ {"id":"oauth-personal","name":"Log in with Google","description":"Opens a browser"}, + \\ {"id":"api-key","name":"Use an API key","type":"agent"}, + \\ {"id":"cli-login","name":"Log in in a terminal","type":"terminal","args":["--login"],"env":{"LOGIN_MODE":"interactive"}}, + \\ {"id":"later","name":"Some future kind","type":"something_else"} + \\ ]} +; + +test "an agent's methods read from its initialize answer, unknown kinds left out" { + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const v = try json.parseFromSliceLeaky(json.Value, a, gemini_like, .{}); + const list = try methods(a, v); + try testing.expectEqual(@as(usize, 3), list.len); + try testing.expectEqual(Method.Kind.agent, list[0].kind); + try testing.expectEqualStrings("Opens a browser", list[0].description); + try testing.expectEqual(Method.Kind.agent, list[1].kind); + const t = list[2]; + try testing.expectEqual(Method.Kind.terminal, t.kind); + try testing.expectEqualStrings("--login", t.args[0]); + try testing.expectEqualStrings("LOGIN_MODE", t.env[0].name); + try testing.expectEqualStrings("interactive", t.env[0].value); + try testing.expect(canLogout(v)); + + const argv = try terminalArgv(a, &.{ "npx", "-y", "agent-acp" }, t); + try testing.expectEqual(@as(usize, 4), argv.len); + try testing.expectEqualStrings("--login", argv[3]); + + const bare = try json.parseFromSliceLeaky(json.Value, a, "{\"agentCapabilities\":{\"auth\":{\"logout\":null}}}", .{}); + try testing.expectEqual(@as(usize, 0), (try methods(a, bare)).len); + try testing.expect(!canLogout(bare)); +} + +test "initialize offers terminal sign-in only when it can run one; authenticate and logout" { + var out: std.Io.Writer.Allocating = .init(testing.allocator); + defer out.deinit(); + try acp.writeRequest(&out.writer, 1, "initialize", .{ .protocolVersion = acp.protocol_version, .clientCapabilities = clientCapabilities(true) }); + try testing.expect(std.mem.indexOf(u8, out.written(), "\"auth\":{\"terminal\":true}") != null); + try testing.expect(std.mem.indexOf(u8, out.written(), "\"readTextFile\":true") != null); + out.clearRetainingCapacity(); + try acp.writeRequest(&out.writer, 2, "authenticate", Authenticate{ .methodId = "oauth-personal" }); + try testing.expectEqualStrings("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"authenticate\",\"params\":{\"methodId\":\"oauth-personal\"}}\n", out.written()); + out.clearRetainingCapacity(); + try acp.writeRequest(&out.writer, 3, "logout", Logout{}); + try testing.expectEqualStrings("{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"logout\",\"params\":{}}\n", out.written()); +} + +test "auth_required shows the methods; an agent method authenticates, then the session starts again" { + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const list = try methods(a, try json.parseFromSliceLeaky(json.Value, a, gemini_like, .{})); + + // The refusal as it comes off the wire. + const refusal = acp.Inbound.classify(try json.parseFromSliceLeaky(json.Value, a, + \\{"jsonrpc":"2.0","id":2,"error":{"code":-32000,"message":"Authentication required"}} + , .{})); + try testing.expectEqual(auth_required, refusal.response.code); + + var flow: Flow = .{}; + try testing.expect(flow.refused(refusal.response.code, list) == .choose); + // A method it never offered does nothing. + try testing.expect(flow.chose(list, "nope") == .none); + try testing.expectEqualStrings("oauth-personal", flow.chose(list, "oauth-personal").authenticate); + // Pressing again while it signs in does nothing. + try testing.expect(flow.chose(list, "api-key") == .none); + // Refused: the methods again; then it works, and the session starts again. + try testing.expect(flow.authenticated(false) == .choose); + try testing.expectEqualStrings("api-key", flow.chose(list, "api-key").authenticate); + try testing.expect(flow.authenticated(true) == .retry); + try testing.expect(flow.step == .none); + + // Any other refusal is not a sign-in's to fix, nor is auth_required with no methods. + try testing.expect(flow.refused(-32603, list) == .give_up); + try testing.expect(flow.refused(auth_required, &.{}) == .give_up); +} + +test "a terminal method is run, never authenticated, and its exit decides" { + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const list = try methods(a, try json.parseFromSliceLeaky(json.Value, a, gemini_like, .{})); + var flow: Flow = .{}; + _ = flow.refused(auth_required, list); + const run = flow.chose(list, "cli-login"); + try testing.expectEqualStrings("cli-login", run.run_terminal.id); + try testing.expect(flow.authenticated(true) == .none); + try testing.expect(flow.exited(1) == .choose); + _ = flow.chose(list, "cli-login"); + try testing.expect(flow.exited(0) == .reconnect); +}