summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/auth_manager.zig291
-rw-r--r--src/config_file.zig12
-rw-r--r--src/main.zig2
-rw-r--r--src/subcommand.zig207
4 files changed, 512 insertions, 0 deletions
diff --git a/src/auth_manager.zig b/src/auth_manager.zig
new file mode 100644
index 0000000..263ec54
--- /dev/null
+++ b/src/auth_manager.zig
@@ -0,0 +1,291 @@
+//! Turn-time auth resolution for the panto CLI.
+//!
+//! Before each turn the active provider's named auth session is resolved into
+//! a request-ready credential and patched into the live `panto.Config`:
+//!
+//! - `api_key` sessions are already resolved at config load (literal/env);
+//! nothing to do here.
+//! - `oauth_device` sessions are loaded from `$PANTO_HOME/auth/<name>.json`,
+//! refreshed if the access token is near expiry, run through the optional
+//! secondary exchange (Copilot) if that token is stale, then turned into a
+//! `ResolvedCredential` (bearer + dynamic base_url + auth-derived headers).
+//! If no token is stored and a `Presenter` is available, an interactive
+//! device login runs first.
+//!
+//! The mechanism (HTTP flows, JWT parsing, token storage) lives in libpanto's
+//! `auth` module; this is the embedder-side composition: it owns where tokens
+//! live, the live-config patching, and the credential's lifetime.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const Io = std.Io;
+const panto = @import("panto");
+const config_file = @import("config_file.zig");
+
+/// Seconds of slack before an access/exchange token's expiry at which we
+/// proactively refresh. Generous enough to cover a slow turn.
+pub const refresh_margin_secs: i64 = 120;
+
+pub const Error = error{
+ /// The provider's auth session names an unknown `[auth.<name>]`.
+ UnknownAuth,
+ /// An OAuth session has no stored token and no way to log in now.
+ LoginRequired,
+} || panto.AuthError || Allocator.Error || error{Unexpected};
+
+/// Owns the credential allocations for the *current* turn. Resolving a new
+/// turn resets the arena, so the live config must be re-patched each turn
+/// (which the resolve path does).
+pub const AuthManager = struct {
+ gpa: Allocator,
+ io: Io,
+ client: *std.http.Client,
+ /// `$PANTO_HOME/auth`. Borrowed for the manager's lifetime.
+ auth_dir: []const u8,
+ file_cfg: *const config_file.Config,
+ /// Arena for the resolved credential strings the live config borrows.
+ cred_arena: std.heap.ArenaAllocator,
+
+ pub fn init(
+ gpa: Allocator,
+ io: Io,
+ client: *std.http.Client,
+ auth_dir: []const u8,
+ file_cfg: *const config_file.Config,
+ ) AuthManager {
+ return .{
+ .gpa = gpa,
+ .io = io,
+ .client = client,
+ .auth_dir = auth_dir,
+ .file_cfg = file_cfg,
+ .cred_arena = std.heap.ArenaAllocator.init(gpa),
+ };
+ }
+
+ pub fn deinit(self: *AuthManager) void {
+ self.cred_arena.deinit();
+ }
+
+ /// Current wall-clock time in unix seconds.
+ fn nowUnix(self: *AuthManager) i64 {
+ const ns = std.Io.Clock.now(.real, self.io).nanoseconds;
+ return @intCast(@divFloor(ns, std.time.ns_per_s));
+ }
+
+ /// Resolve the auth for `provider_name` and patch `live` in place
+ /// (api_key, base_url override, merged extra_headers). `force` skips the
+ /// freshness check and forces a refresh/exchange (used after a 401/403).
+ /// `presenter` enables interactive login when no token is stored.
+ ///
+ /// For `api_key` providers this is a no-op (the key is already in `live`).
+ pub fn resolveInto(
+ self: *AuthManager,
+ live: *panto.Config,
+ provider_name: []const u8,
+ force: bool,
+ presenter: ?panto.Presenter,
+ ) anyerror!void {
+ const prov = self.file_cfg.provider(provider_name) orelse return;
+ const auth = self.file_cfg.auth(prov.auth_name) orelse return Error.UnknownAuth;
+
+ switch (auth.config) {
+ // Already baked into `live` by buildProviderConfig.
+ .api_key => return,
+ .oauth_device => |oauth| {
+ _ = self.cred_arena.reset(.retain_capacity);
+ const arena = self.cred_arena.allocator();
+ const cred = try self.resolveOAuth(arena, oauth, prov.auth_name, force, presenter);
+ try patchCredential(live, prov, cred, arena);
+ },
+ }
+ }
+
+ /// Compose the OAuth lifecycle: load → (login) → (refresh) → (exchange) →
+ /// build credential. Persists the token after any mutation. `arena` owns
+ /// the returned credential's strings.
+ fn resolveOAuth(
+ self: *AuthManager,
+ arena: Allocator,
+ oauth: panto.OAuthDeviceAuth,
+ name: []const u8,
+ force: bool,
+ presenter: ?panto.Presenter,
+ ) anyerror!panto.ResolvedCredential {
+ const now = self.nowUnix();
+
+ var loaded = self.loadToken(name);
+ defer if (loaded) |*l| l.deinit();
+
+ // Working token set; strings borrow from `loaded` or `arena`.
+ var ts: panto.TokenSet = if (loaded) |l| l.value else blk: {
+ const p = presenter orelse return Error.LoginRequired;
+ const toks = try panto.oauthLogin(arena, self.io, self.client, oauth, p);
+ const fresh = try panto.tokensToTokenSet(arena, oauth, toks, now);
+ try self.saveToken(name, fresh);
+ break :blk fresh;
+ };
+
+ // Refresh the access token if it's near expiry (or forced).
+ if ((force or panto.needsRefresh(ts, now, refresh_margin_secs)) and ts.refresh_token != null) {
+ const toks = try panto.refreshTokens(arena, self.client, oauth, ts.refresh_token.?);
+ var refreshed = try panto.tokensToTokenSet(arena, oauth, toks, now);
+ // Refresh responses may omit the refresh token / id_token; keep
+ // the prior values so the session stays renewable.
+ if (refreshed.refresh_token == null) refreshed.refresh_token = ts.refresh_token;
+ if (refreshed.account_id == null) refreshed.account_id = ts.account_id;
+ // The secondary exchange is recomputed below; drop the stale one.
+ refreshed.exchange = null;
+ ts = refreshed;
+ try self.saveToken(name, ts);
+ }
+
+ // Run the secondary exchange (Copilot) if its token is stale (or forced).
+ if (oauth.exchange) |exchange| {
+ if (force or panto.needsExchange(oauth, ts, now, refresh_margin_secs)) {
+ const access = ts.access_token orelse return Error.LoginRequired;
+ const ex = try panto.runExchange(arena, self.client, exchange, access);
+ ts.exchange = ex;
+ try self.saveToken(name, ts);
+ }
+ }
+
+ return try panto.buildCredential(arena, oauth, ts);
+ }
+
+ fn loadToken(self: *AuthManager, name: []const u8) ?panto.ParsedTokenSet {
+ return (panto.loadTokenSet(self.gpa, self.io, self.auth_dir, name) catch return null);
+ }
+
+ fn saveToken(self: *AuthManager, name: []const u8, ts: panto.TokenSet) !void {
+ try panto.saveTokenSet(self.gpa, self.io, self.auth_dir, name, ts);
+ }
+};
+
+/// Patch a resolved credential into the live provider config: set the bearer/
+/// key, override base_url when the credential supplies one, and merge the
+/// provider's static `extra_headers` with the auth-derived ones (into `arena`).
+fn patchCredential(
+ live: *panto.Config,
+ prov: *const config_file.Provider,
+ cred: panto.ResolvedCredential,
+ arena: Allocator,
+) !void {
+ // Combine provider-static headers (stable, owned by the file config) with
+ // the auth-derived headers (owned by `arena`).
+ const merged = try arena.alloc(panto.Header, prov.extra_headers.len + cred.extra_headers.len);
+ @memcpy(merged[0..prov.extra_headers.len], prov.extra_headers);
+ @memcpy(merged[prov.extra_headers.len..], cred.extra_headers);
+
+ const base_url = cred.base_url_override orelse prov.base_url;
+ switch (live.provider) {
+ .openai_chat => |*c| {
+ c.api_key = cred.api_key;
+ c.base_url = base_url;
+ c.extra_headers = merged;
+ },
+ .anthropic_messages => |*c| {
+ c.api_key = cred.api_key;
+ c.base_url = base_url;
+ c.extra_headers = merged;
+ },
+ }
+}
+
+const t = std.testing;
+
+test "patchCredential: injects key, base_url override, merged headers" {
+ var aa = std.heap.ArenaAllocator.init(t.allocator);
+ defer aa.deinit();
+ const arena = aa.allocator();
+
+ const prov: config_file.Provider = .{
+ .name = "copilot",
+ .style = .openai_chat,
+ .base_url = "https://placeholder",
+ .auth_name = "github_copilot",
+ .extra_headers = &.{.{ .name = "Copilot-Integration-Id", .value = "vscode-chat" }},
+ };
+ var live: panto.Config = .{ .provider = .{ .openai_chat = .{
+ .api_key = "",
+ .base_url = "https://placeholder",
+ .model = "gpt-4o",
+ } } };
+ const cred: panto.ResolvedCredential = .{
+ .api_key = "exchanged-token",
+ .base_url_override = "https://api.individual.githubcopilot.com",
+ .extra_headers = &.{.{ .name = "chatgpt-account-id", .value = "acct" }},
+ };
+ try patchCredential(&live, &prov, cred, arena);
+
+ try t.expectEqualStrings("exchanged-token", live.provider.openai_chat.api_key);
+ try t.expectEqualStrings("https://api.individual.githubcopilot.com", live.provider.openai_chat.base_url);
+ try t.expectEqual(@as(usize, 2), live.provider.openai_chat.extra_headers.len);
+ try t.expectEqualStrings("Copilot-Integration-Id", live.provider.openai_chat.extra_headers[0].name);
+ try t.expectEqualStrings("chatgpt-account-id", live.provider.openai_chat.extra_headers[1].name);
+}
+
+test "resolveInto: api_key auth is a no-op" {
+ const a = t.allocator;
+ var env = std.process.Environ.Map.init(a);
+ defer env.deinit();
+ try env.put("OPENAI_API_KEY", "sk-live");
+
+ const src =
+ \\[providers.openai]
+ \\style = "openai_chat"
+ \\base_url = "https://api.openai.com/v1"
+ \\auth = "openai_api"
+ \\
+ \\[auth.openai_api]
+ \\type = "api_key"
+ \\key_env_var = "OPENAI_API_KEY"
+ ;
+ var cfg = try config_file.loadFromString(a, &env, src);
+ defer cfg.deinit();
+
+ var live: panto.Config = .{ .provider = .{ .openai_chat = .{
+ .api_key = "sk-live",
+ .base_url = "https://api.openai.com/v1",
+ .model = "gpt-4o",
+ } } };
+
+ var client: std.http.Client = undefined; // unused on the api_key path
+ var mgr = AuthManager.init(a, t.io, &client, "/nonexistent", &cfg);
+ defer mgr.deinit();
+ try mgr.resolveInto(&live, "openai", false, null);
+ // Unchanged.
+ try t.expectEqualStrings("sk-live", live.provider.openai_chat.api_key);
+}
+
+test "resolveInto: oauth with no token and no presenter requires login" {
+ const a = t.allocator;
+ var env = std.process.Environ.Map.init(a);
+ defer env.deinit();
+
+ const src =
+ \\[providers.copilot]
+ \\style = "openai_chat"
+ \\base_url = "https://api.individual.githubcopilot.com"
+ \\auth = "github_copilot"
+ \\
+ \\[auth.github_copilot]
+ \\type = "oauth_device"
+ \\client_id = "Iv1.x"
+ \\device_code_url = "https://github.com/login/device/code"
+ \\token_url = "https://github.com/login/oauth/access_token"
+ ;
+ var cfg = try config_file.loadFromString(a, &env, src);
+ defer cfg.deinit();
+
+ var live: panto.Config = .{ .provider = .{ .openai_chat = .{
+ .api_key = "",
+ .base_url = "https://api.individual.githubcopilot.com",
+ .model = "gpt-4o",
+ } } };
+
+ var client: std.http.Client = undefined;
+ var mgr = AuthManager.init(a, t.io, &client, "/nonexistent/auth", &cfg);
+ defer mgr.deinit();
+ try t.expectError(Error.LoginRequired, mgr.resolveInto(&live, "copilot", false, null));
+}
diff --git a/src/config_file.zig b/src/config_file.zig
index 19e4bb1..1277661 100644
--- a/src/config_file.zig
+++ b/src/config_file.zig
@@ -398,6 +398,18 @@ pub fn userConfigPath(allocator: Allocator, environ_map: *const std.process.Envi
return error.NoHomeDirectory;
}
+/// Resolve a `Config` from a single in-memory TOML string. Convenience for
+/// tests and tooling that already hold the document text.
+pub fn loadFromString(
+ allocator: Allocator,
+ environ_map: *const std.process.Environ.Map,
+ source: []const u8,
+) Error!Config {
+ const doc = parseDoc(allocator, source) catch return error.InvalidConfigToml;
+ defer doc.deinit();
+ return resolve(allocator, environ_map, doc.root);
+}
+
/// Load from explicit layer paths, lowest precedence first. Useful for
/// tests. Missing files are skipped.
pub fn loadFromPaths(
diff --git a/src/main.zig b/src/main.zig
index e867c0e..dc611b4 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -11,6 +11,7 @@ const subcommand = @import("subcommand.zig");
const session_paths = @import("session_paths.zig");
const models_toml = @import("models_toml.zig");
const config_file = @import("config_file.zig");
+const auth_manager = @import("auth_manager.zig");
const glob = @import("glob.zig");
const system_prompt = @import("system_prompt.zig");
const command = @import("command.zig");
@@ -54,6 +55,7 @@ test {
_ = subcommand;
_ = models_toml;
_ = config_file;
+ _ = auth_manager;
_ = glob;
_ = system_prompt;
_ = command;
diff --git a/src/subcommand.zig b/src/subcommand.zig
index 21d2065..b145d4b 100644
--- a/src/subcommand.zig
+++ b/src/subcommand.zig
@@ -24,6 +24,8 @@ const lua_bridge = @import("lua_bridge.zig");
const luarocks_runtime = @import("luarocks_runtime.zig");
const self_exe = @import("self_exe.zig");
const session_paths = @import("session_paths.zig");
+const config_file = @import("config_file.zig");
+const panto_home = @import("panto_home.zig");
const panto = @import("panto");
const c = lua_bridge.c;
@@ -78,6 +80,10 @@ pub fn dispatch(
try runSessionsSubcommand(allocator, io, environ_map);
return .done;
}
+ if (std.mem.eql(u8, sub, "auth")) {
+ try runAuthSubcommand(allocator, io, environ_map, &it);
+ return .done;
+ }
if (std.mem.eql(u8, sub, "--help") or std.mem.eql(u8, sub, "-h") or std.mem.eql(u8, sub, "help")) {
try printHelp(io);
return .done;
@@ -97,6 +103,11 @@ fn printHelp(io: Io) !void {
\\ panto --resume Resume the most recent conversation in this directory.
\\ panto --resume <id> Resume the conversation whose id begins with <id>.
\\ panto sessions List saved sessions for this directory.
+ \\ panto auth status Show configured auth sessions and login state.
+ \\ panto auth login <name>
+ \\ Log in to an OAuth auth session (device flow).
+ \\ panto auth logout <name>
+ \\ Forget a stored OAuth token.
\\ panto bootstrap [--force]
\\ Run the luarocks bootstrap and exit.
\\ panto lua [args...] Drop into the embedded Lua interpreter.
@@ -313,6 +324,202 @@ fn trimCreated(iso: []const u8) []const u8 {
}
// ---------------------------------------------------------------------------
+// `panto auth`
+// ---------------------------------------------------------------------------
+
+/// Line-based device-code presenter for the `panto auth login` flow (the TUI
+/// is not running here). Prints the verification URL + user code to stdout.
+const CliPresenter = struct {
+ io: Io,
+
+ fn deviceCode(ptr: *anyopaque, prompt: panto.DeviceCodePrompt) void {
+ const self: *CliPresenter = @ptrCast(@alignCast(ptr));
+ var buf: [1024]u8 = undefined;
+ var fw = std.Io.File.stdout().writer(self.io, &buf);
+ const w = &fw.interface;
+ w.print(
+ "\nTo authorize, open this URL in a browser:\n {s}\n\nand enter the code:\n {s}\n\n",
+ .{ prompt.verification_uri, prompt.user_code },
+ ) catch {};
+ fw.flush() catch {};
+ }
+
+ fn status(ptr: *anyopaque, msg: []const u8) void {
+ const self: *CliPresenter = @ptrCast(@alignCast(ptr));
+ var buf: [256]u8 = undefined;
+ var fw = std.Io.File.stdout().writer(self.io, &buf);
+ const w = &fw.interface;
+ w.print("{s}\n", .{msg}) catch {};
+ fw.flush() catch {};
+ }
+
+ const vtable: panto.Presenter.VTable = .{
+ .on_device_code = deviceCode,
+ .on_status = status,
+ };
+
+ fn presenter(self: *CliPresenter) panto.Presenter {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+};
+
+fn nowUnix(io: Io) i64 {
+ const ns = std.Io.Clock.now(.real, io).nanoseconds;
+ return @intCast(@divFloor(ns, std.time.ns_per_s));
+}
+
+/// `panto auth [status|login <name>|logout <name>]`.
+fn runAuthSubcommand(
+ allocator: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+ it: *std.process.Args.Iterator,
+) !void {
+ var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const cwd_n = try std.process.currentPath(io, &cwd_buf);
+ const cwd = cwd_buf[0..cwd_n];
+
+ var cfg = config_file.load(allocator, io, environ_map, cwd) catch |err| {
+ std.log.err("panto auth: failed to load config ({t})", .{err});
+ return err;
+ };
+ defer cfg.deinit();
+
+ var layout = try panto_home.resolve(allocator, environ_map);
+ defer layout.deinit();
+ const auth_dir = layout.auth_dir;
+
+ var out_buf: [4096]u8 = undefined;
+ var out_file = std.Io.File.stdout().writer(io, &out_buf);
+ const out = &out_file.interface;
+
+ const action = it.next() orelse "status";
+
+ if (std.mem.eql(u8, action, "status")) {
+ try authStatus(allocator, io, &cfg, auth_dir, out);
+ try out_file.flush();
+ return;
+ }
+ if (std.mem.eql(u8, action, "logout")) {
+ const name = it.next() orelse {
+ try out.writeAll("usage: panto auth logout <name>\n");
+ try out_file.flush();
+ return;
+ };
+ const removed = try panto.deleteTokenSet(allocator, io, auth_dir, name);
+ if (removed) {
+ try out.print("logged out of '{s}'\n", .{name});
+ } else {
+ try out.print("no stored token for '{s}'\n", .{name});
+ }
+ try out_file.flush();
+ return;
+ }
+ if (std.mem.eql(u8, action, "login")) {
+ const name = it.next() orelse {
+ try out.writeAll("usage: panto auth login <name>\n");
+ try out_file.flush();
+ return;
+ };
+ try authLogin(allocator, io, &cfg, auth_dir, name, out);
+ try out_file.flush();
+ return;
+ }
+
+ try out.print("unknown auth action '{s}' (try: status, login, logout)\n", .{action});
+ try out_file.flush();
+}
+
+fn authStatus(
+ allocator: Allocator,
+ io: Io,
+ cfg: *const config_file.Config,
+ auth_dir: []const u8,
+ out: *std.Io.Writer,
+) !void {
+ if (cfg.auths.len == 0) {
+ try out.writeAll("no auth sessions configured\n");
+ return;
+ }
+ const now = nowUnix(io);
+ for (cfg.auths) |a| {
+ switch (a.config) {
+ .api_key => {
+ const state = if (a.resolved_api_key != null) "resolved" else "unresolved (key/env missing)";
+ try out.print("{s} api_key {s}\n", .{ a.name, state });
+ },
+ .oauth_device => {
+ var loaded = panto.loadTokenSet(allocator, io, auth_dir, a.name) catch null;
+ defer if (loaded) |*l| l.deinit();
+ if (loaded) |l| {
+ const ts = l.value;
+ if (ts.expires_at) |exp| {
+ const mins = @divFloor(exp - now, 60);
+ try out.print("{s} oauth_device logged in (access expires in ~{d}m)\n", .{ a.name, mins });
+ } else {
+ try out.print("{s} oauth_device logged in\n", .{a.name});
+ }
+ } else {
+ try out.print("{s} oauth_device not logged in (run: panto auth login {s})\n", .{ a.name, a.name });
+ }
+ },
+ }
+ }
+}
+
+fn authLogin(
+ allocator: Allocator,
+ io: Io,
+ cfg: *const config_file.Config,
+ auth_dir: []const u8,
+ name: []const u8,
+ out: *std.Io.Writer,
+) !void {
+ const a = cfg.auth(name) orelse {
+ try out.print("no auth session named '{s}' in config\n", .{name});
+ return;
+ };
+ const oauth = switch (a.config) {
+ .oauth_device => |o| o,
+ .api_key => {
+ try out.print("'{s}' is an api_key session; nothing to log in to\n", .{name});
+ return;
+ },
+ };
+
+ panto.init(allocator, io);
+ defer panto.deinit();
+ const client = panto.httpClient();
+
+ var arena = std.heap.ArenaAllocator.init(allocator);
+ defer arena.deinit();
+ const aa = arena.allocator();
+
+ var presenter = CliPresenter{ .io = io };
+ const toks = panto.oauthLogin(aa, io, client, oauth, presenter.presenter()) catch |err| {
+ try out.print("login failed: {t}\n", .{err});
+ return;
+ };
+
+ const now = nowUnix(io);
+ var ts = try panto.tokensToTokenSet(aa, oauth, toks, now);
+
+ // Run the secondary exchange now (if configured) so the first turn is
+ // immediately usable and we surface any exchange error during login.
+ if (oauth.exchange) |exchange| {
+ if (ts.access_token) |access| {
+ ts.exchange = panto.runExchange(aa, client, exchange, access) catch |err| blk: {
+ try out.print("note: token exchange failed ({t}); will retry on first use\n", .{err});
+ break :blk null;
+ };
+ }
+ }
+
+ try panto.saveTokenSet(allocator, io, auth_dir, name, ts);
+ try out.print("\nauthorized — '{s}' is ready to use.\n", .{name});
+}
+
+// ---------------------------------------------------------------------------
// `panto lua` argv plumbing — sketched against the older Args API for
// reference (kept here so the design notes survive the implementation).
// ---------------------------------------------------------------------------