From 20e6e08a42240aef0b36ecf627cbcde921912071 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 13 Jun 2026 14:24:31 -0600 Subject: auth: flatten [auth.] config + ${...} substitution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the auth schema: infer `type` from keys, replace key/key_env_var with a single `key` (resolved via substitution), flatten the exchange to flat `exchange_*` keys, and drop the auth-level headers tables in favor of reusing the provider's `extra_headers` on the auth HTTP calls. Add `${sibling}` and `${env:VAR}` substitution over auth values (GitHub Enterprise = set `domain`). Restore api_key-empty → provider-drop. Update default config, docs, and tests. --- docs/oauth-provider-auth.md | 31 ++++- libpanto/src/auth.zig | 42 ++++--- src/auth_manager.zig | 12 +- src/config_file.zig | 276 +++++++++++++++++++++++++++++--------------- src/luarocks_runtime.zig | 52 ++++----- src/subcommand.zig | 14 ++- 6 files changed, 270 insertions(+), 157 deletions(-) diff --git a/docs/oauth-provider-auth.md b/docs/oauth-provider-auth.md index 9f5c7fe..96312e1 100644 --- a/docs/oauth-provider-auth.md +++ b/docs/oauth-provider-auth.md @@ -501,10 +501,28 @@ None of these are required for Copilot or Codex auth correctness. The first pass landed as a clean break (no compatibility shim): every networked provider must name an `[auth.]` session via `auth = ""`; provider-level `api_key`/`api_key_env_var` are gone. Resolved decisions for the -open questions above: TOML uses `key_env_var`; `auth` is required for networked -providers; Codex device auth is modelled as a `dialect = "codex"` variant of -the generic `oauth_device` type; keychain storage is deferred (file-backed -`$PANTO_HOME/auth/.json`, owner-only `0600`). +open questions above: `auth` is required for networked providers; Codex device +auth is a `dialect = "codex"` variant of the generic `oauth_device` type; +keychain storage is deferred (file-backed `$PANTO_HOME/auth/.json`, +owner-only `0600`). + +The `[auth.]` schema is deliberately flat and minimal: + +- **`type` is inferred** — `key` ⇒ `api_key`, `client_id` ⇒ `oauth_device` + (explicit `type` still works). +- **`${...}` substitution** on every value — `${env:VAR}` reads the + environment, `${sibling}` reads another key in the same section. The common + api-key session is one line: `key = "${env:OPENAI_API_KEY}"`. An unset + `${env:VAR}` resolves to empty, and a provider whose `api_key` resolves empty + is **dropped** (reproducing "export your key or the provider disappears"). + GitHub Enterprise is a one-liner: set `domain = "company.ghe.com"` and the + `${domain}`-templated URLs follow. +- **The exchange is flat** — `exchange_url`, `exchange_method`, + `exchange_token_path`, `exchange_expires_path`, `exchange_base_url_path` (no + `[auth..exchange]` sub-table); presence of `exchange_url` enables it. +- **No auth-level headers** — the provider's `extra_headers` (the client + identity, e.g. Copilot's editor headers) are reused on the auth HTTP calls + (device/poll/exchange) as well as the model request, so they're written once. ### What core owns (libpanto) @@ -519,8 +537,9 @@ the generic `oauth_device` type; keychain storage is deferred (file-backed ### What the CLI owns (`src/`) - `config_file.zig` parses `[auth.]` and the `auth = ""` reference, - resolving `api_key` sessions eagerly (literal/env). Providers always survive - resolution; an unresolved session fails the request with a clear error. + runs `${...}`/`${env:VAR}` substitution, infers `type`, and resolves + `api_key` sessions eagerly. A provider whose `api_key` resolves empty is + dropped; OAuth providers always survive (resolved at turn time). - `auth_manager.zig` resolves the active provider's auth before each turn (load → login → refresh → exchange → build credential), patching the live `ProviderConfig` (bearer, dynamic `base_url`, merged headers). diff --git a/libpanto/src/auth.zig b/libpanto/src/auth.zig index 9bf37b4..5b65bf4 100644 --- a/libpanto/src/auth.zig +++ b/libpanto/src/auth.zig @@ -43,10 +43,12 @@ pub const DeviceDialect = enum { token, codex }; /// Wire encoding for the `token` dialect's device-code / poll request bodies. pub const TokenRequestFormat = enum { form, json }; -/// Static API-key auth. `key` (a literal) wins over `key_env_var`. +/// Static API-key auth. `key` is the resolved credential — a literal, or the +/// result of `${env:VAR}`-style substitution performed by the embedder. An +/// absent/empty key means unresolved; providers using such a session are +/// omitted from the active config (export the key and they reappear). pub const ApiKeyAuth = struct { key: ?[]const u8 = null, - key_env_var: ?[]const u8 = null, }; /// A secondary token exchange run after the durable OAuth token is obtained @@ -64,8 +66,6 @@ pub const ExchangeConfig = struct { expires_at_json_path: ?[]const u8 = null, /// Dotted JSON path to a dynamic `base_url` in the exchange response. base_url_json_path: ?[]const u8 = null, - /// Headers sent with the exchange request (e.g. Copilot editor identity). - headers: []const Header = &.{}, pub const BearerSource = enum { oauth_access_token }; }; @@ -93,8 +93,6 @@ pub const OAuthDeviceAuth = struct { /// e.g. `https://api.openai.com/auth`. When set, the resolver extracts an /// account id and emits it as a header (see provider wiring). account_id_jwt_claim: ?[]const u8 = null, - /// Headers sent with the device-code / poll / token requests. - headers: []const Header = &.{}, /// Optional post-login token exchange (Copilot). exchange: ?ExchangeConfig = null, }; @@ -365,11 +363,14 @@ fn encodeBody( } } -/// Request a device/user code. Allocates the result into `arena`. +/// Request a device/user code. `headers` are the client-identity headers +/// (the provider's `extra_headers`) sent on every auth HTTP call. Allocates +/// the result into `arena`. fn requestDeviceAuth( arena: std.mem.Allocator, client: *std.http.Client, oauth: OAuthDeviceAuth, + headers: []const Header, ) !DeviceAuth { const enc = switch (oauth.dialect) { // GitHub: `client_id` (+ optional scope), in the configured encoding. @@ -383,7 +384,7 @@ fn requestDeviceAuth( }; const resp = try http.request(arena, client, .POST, oauth.device_code_url, .{ - .headers = oauth.headers, + .headers = headers, .body = enc.body, .content_type = enc.content_type, }); @@ -429,6 +430,7 @@ fn pollOnce( client: *std.http.Client, oauth: OAuthDeviceAuth, da: DeviceAuth, + headers: []const Header, ) !PollResult { switch (oauth.dialect) { .token => { @@ -438,7 +440,7 @@ fn pollOnce( .{ "grant_type", "urn:ietf:params:oauth:grant-type:device_code" }, }); const resp = try http.request(arena, client, .POST, oauth.token_url, .{ - .headers = oauth.headers, + .headers = headers, .body = enc.body, .content_type = enc.content_type, }); @@ -460,7 +462,7 @@ fn pollOnce( .{ "user_code", da.user_code }, }); const resp = try http.request(arena, client, .POST, poll_url, .{ - .headers = oauth.headers, + .headers = headers, .body = enc.body, .content_type = enc.content_type, }); @@ -472,7 +474,7 @@ fn pollOnce( const code = http.jsonStringAtPath(parsed.value, "authorization_code") orelse return .pending; const verifier = http.jsonStringAtPath(parsed.value, "code_verifier") orelse return AuthError.AuthFlowFailed; // Exchange the authorization code + server-generated PKCE verifier. - const tokens = try exchangeCode(arena, client, oauth, code, verifier); + const tokens = try exchangeCode(arena, client, oauth, code, verifier, headers); return .{ .done = tokens }; }, } @@ -485,6 +487,7 @@ fn exchangeCode( oauth: OAuthDeviceAuth, code: []const u8, verifier: []const u8, + headers: []const Header, ) !OAuthTokens { const redirect = oauth.redirect_uri orelse "https://auth.openai.com/deviceauth/callback"; const enc = try encodeBody(arena, .form, &.{ @@ -495,7 +498,7 @@ fn exchangeCode( .{ "redirect_uri", redirect }, }); const resp = try http.request(arena, client, .POST, oauth.token_url, .{ - .headers = oauth.headers, + .headers = headers, .body = enc.body, .content_type = enc.content_type, }); @@ -511,6 +514,7 @@ pub fn refreshTokens( client: *std.http.Client, oauth: OAuthDeviceAuth, refresh_token: []const u8, + headers: []const Header, ) !OAuthTokens { const enc = try encodeBody(arena, .form, &.{ .{ "grant_type", "refresh_token" }, @@ -518,7 +522,7 @@ pub fn refreshTokens( .{ "client_id", oauth.client_id }, }); const resp = try http.request(arena, client, .POST, oauth.token_url, .{ - .headers = oauth.headers, + .headers = headers, .body = enc.body, .content_type = enc.content_type, }); @@ -549,8 +553,9 @@ pub fn login( client: *std.http.Client, oauth: OAuthDeviceAuth, presenter: Presenter, + headers: []const Header, ) !OAuthTokens { - const da = try requestDeviceAuth(arena, client, oauth); + const da = try requestDeviceAuth(arena, client, oauth, headers); presenter.deviceCode(.{ .verification_uri = da.verification_uri, .user_code = da.user_code, @@ -562,7 +567,7 @@ pub fn login( const deadline_ns = start_ns + 15 * std.time.ns_per_min; while (true) { io.sleep(.fromSeconds(@intCast(da.interval_secs)), .real) catch {}; - const result = pollOnce(arena, client, oauth, da) catch |err| switch (err) { + const result = pollOnce(arena, client, oauth, da, headers) catch |err| switch (err) { // A transient transport hiccup mid-poll: keep waiting. error.AuthFlowFailed => return err, else => return err, @@ -587,11 +592,12 @@ pub fn runExchange( client: *std.http.Client, exchange: ExchangeConfig, bearer: []const u8, + headers: []const Header, ) !TokenSet.ExchangeToken { const auth_value = try std.fmt.allocPrint(arena, "Bearer {s}", .{bearer}); var hdrs: std.ArrayList(Header) = .empty; try hdrs.append(arena, .{ .name = "authorization", .value = auth_value }); - for (exchange.headers) |h| try hdrs.append(arena, h); + for (headers) |h| try hdrs.append(arena, h); const method: http.Method = if (std.ascii.eqlIgnoreCase(exchange.method, "POST")) .POST else .GET; const resp = try http.request(arena, client, method, exchange.url, .{ .headers = hdrs.items }); @@ -781,9 +787,9 @@ test "token storage: save, load, delete round-trip" { } test "AuthConfig: api_key variant" { - const a: AuthConfig = .{ .api_key = .{ .key_env_var = "OPENAI_API_KEY" } }; + const a: AuthConfig = .{ .api_key = .{ .key = "sk-resolved" } }; try t.expectEqual(AuthType.api_key, @as(AuthType, a)); - try t.expectEqualStrings("OPENAI_API_KEY", a.api_key.key_env_var.?); + try t.expectEqualStrings("sk-resolved", a.api_key.key.?); } test "AuthConfig: oauth_device defaults" { diff --git a/src/auth_manager.zig b/src/auth_manager.zig index 87b20ae..e26e28a 100644 --- a/src/auth_manager.zig +++ b/src/auth_manager.zig @@ -95,7 +95,10 @@ pub const AuthManager = struct { .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); + // The provider's identity headers ride on every auth HTTP call + // (exchange/device/poll), the same way they ride on the model + // request. + const cred = try self.resolveOAuth(arena, oauth, prov.auth_name, prov.extra_headers, force, presenter); try patchCredential(live, prov, cred, arena); }, } @@ -109,6 +112,7 @@ pub const AuthManager = struct { arena: Allocator, oauth: panto.OAuthDeviceAuth, name: []const u8, + headers: []const panto.Header, force: bool, presenter: ?panto.Presenter, ) anyerror!panto.ResolvedCredential { @@ -120,7 +124,7 @@ pub const AuthManager = struct { // 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 toks = try panto.oauthLogin(arena, self.io, self.client, oauth, p, headers); const fresh = try panto.tokensToTokenSet(arena, oauth, toks, now); try self.saveToken(name, fresh); break :blk fresh; @@ -128,7 +132,7 @@ pub const AuthManager = struct { // 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.?); + const toks = try panto.refreshTokens(arena, self.client, oauth, ts.refresh_token.?, headers); 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. @@ -144,7 +148,7 @@ pub const AuthManager = struct { 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); + const ex = try panto.runExchange(arena, self.client, exchange, access, headers); ts.exchange = ex; try self.saveToken(name, ts); } diff --git a/src/config_file.zig b/src/config_file.zig index cac003a..1823177 100644 --- a/src/config_file.zig +++ b/src/config_file.zig @@ -21,12 +21,13 @@ //! After merge, the document is resolved into a `Config`: //! //! - Every `auth.` becomes a `ResolvedAuth` — a named auth session -//! (`api_key` or `oauth_device`). For `api_key`, the key is resolved -//! eagerly from the literal `key` (preferred) or the `key_env_var` -//! environment variable; an absent env var leaves the session -//! **unresolved** (its first request fails with a clear error) but never -//! drops it. OAuth sessions carry their flow config and are resolved at -//! turn time. +//! (`api_key` or `oauth_device`; `type` is inferred from the keys present +//! when omitted). All values support `${...}` substitution: `${env:VAR}` +//! reads the environment, `${sibling}` reads another key in the same +//! section. For `api_key`, `key` is resolved eagerly (usually +//! `"${env:VAR}"`); an empty result leaves the session **unresolved**, and +//! any provider using it is dropped. OAuth sessions carry their flow config +//! and are resolved at turn time. //! - Every `providers.` becomes a `Provider`. A networked provider //! **must** name its auth session via `auth = ""` (clean break: //! provider-level `api_key`/`api_key_env_var` are no longer accepted). @@ -95,6 +96,14 @@ pub const Provider = struct { } }; +/// Look up a resolved auth session by name in a slice. Returns null if absent. +fn authByName(auths: []const ResolvedAuth, name: []const u8) ?*const ResolvedAuth { + for (auths) |*a| { + if (std.mem.eql(u8, a.name, name)) return a; + } + return null; +} + /// One resolved named auth session (`auth.`). The `config` carries the /// auth-type-specific settings (borrowed from the Config auth arena). /// `resolved_api_key` is set eagerly for `api_key` sessions whose key is @@ -303,8 +312,8 @@ pub const Error = error{ InvalidModelRef, UnknownDefaultProvider, PolicyConflict, - /// An `[auth.]` block is malformed (missing `type`, unknown type, - /// or a required field for its type is absent). + /// An `[auth.]` block is malformed (unknown/uninferable type, or a + /// required field for its type is absent or substitutes to empty). InvalidAuth, /// A networked provider is missing `auth = ""`. MissingProviderAuth, @@ -545,14 +554,27 @@ fn resolve( // Every provider must name an auth session that exists. for (providers.items) |p| { - var found = false; - for (auths.items) |a| { - if (std.mem.eql(u8, a.name, p.auth_name)) { - found = true; - break; + if (authByName(auths.items, p.auth_name) == null) return error.UnknownProviderAuth; + } + + // Drop any provider whose `api_key` session did not resolve (an absent + // `${env:VAR}` reads as empty) — reproducing the "export your key or the + // provider disappears" behavior for the default OpenAI/Anthropic entries. + // OAuth providers always survive (resolved at turn time / via login). This + // is an in-place, allocation-free compaction so a mid-loop failure can't + // leave two slices owning the same strings. + { + var w: usize = 0; + for (providers.items) |p| { + const a = authByName(auths.items, p.auth_name).?; + if (a.config == .api_key and a.resolved_api_key == null) { + p.deinit(allocator); + } else { + providers.items[w] = p; + w += 1; } } - if (!found) return error.UnknownProviderAuth; + providers.shrinkRetainingCapacity(w); } // Tool / extension policies. @@ -688,9 +710,13 @@ fn resolveProvider( // Auth resolution // =========================================================================== -/// Parse one `auth.` block into a `ResolvedAuth`. All strings are -/// duped into `aa` (the Config auth arena). For `api_key` the credential is -/// resolved eagerly (literal `key`, else `key_env_var` from the environment). +/// Parse one `auth.` block into a `ResolvedAuth`. All strings are duped +/// into `aa` (the Config auth arena) after `${...}` substitution. +/// +/// `type` is inferred when omitted: a `client_id` means `oauth_device`, a +/// `key` means `api_key`. For `api_key`, the credential is resolved eagerly +/// from `key` (which is usually `"${env:VAR}"`); an empty result leaves the +/// session unresolved (providers using it are later dropped). fn resolveAuth( aa: Allocator, environ_map: *const std.process.Environ.Map, @@ -699,26 +725,23 @@ fn resolveAuth( ) Error!ResolvedAuth { if (val.* != .table) return error.InvalidAuth; - const type_str = (val.get("type") orelse return error.InvalidAuth).asString() orelse + const auth_type: panto.AuthType = blk: { + if (tomlStr(val, "type")) |ts| { + break :blk std.meta.stringToEnum(panto.AuthType, ts) orelse return error.InvalidAuth; + } + if (val.get("client_id") != null) break :blk .oauth_device; + if (val.get("key") != null) break :blk .api_key; return error.InvalidAuth; - const auth_type = std.meta.stringToEnum(panto.AuthType, type_str) orelse return error.InvalidAuth; + }; switch (auth_type) { .api_key => { - const key: ?[]const u8 = if (tomlStr(val, "key")) |s| try aa.dupe(u8, s) else null; - const key_env_var: ?[]const u8 = if (tomlStr(val, "key_env_var")) |s| try aa.dupe(u8, s) else null; - // Eagerly resolve: literal wins over env var. - const resolved: ?[]const u8 = blk: { - if (key) |k| break :blk k; - if (key_env_var) |ev| { - if (environ_map.get(ev)) |actual| break :blk try aa.dupe(u8, actual); - } - break :blk null; - }; + // `key` after substitution; empty/absent => unresolved. + const key = try substField(aa, environ_map, val, "key"); return .{ .name = try aa.dupe(u8, name), - .config = .{ .api_key = .{ .key = key, .key_env_var = key_env_var } }, - .resolved_api_key = resolved, + .config = .{ .api_key = .{ .key = key } }, + .resolved_api_key = key, }; }, .oauth_device => { @@ -732,17 +755,16 @@ fn resolveAuth( }; const oauth: panto.OAuthDeviceAuth = .{ .dialect = dialect, - .client_id = try aaDupReq(aa, val, "client_id"), - .device_code_url = try aaDupReq(aa, val, "device_code_url"), - .token_url = try aaDupReq(aa, val, "token_url"), - .device_poll_url = try aaDupOpt(aa, val, "device_poll_url"), - .verification_url = try aaDupOpt(aa, val, "verification_url"), - .scope = try aaDupOpt(aa, val, "scope"), + .client_id = (try substField(aa, environ_map, val, "client_id")) orelse return error.InvalidAuth, + .device_code_url = (try substField(aa, environ_map, val, "device_code_url")) orelse return error.InvalidAuth, + .token_url = (try substField(aa, environ_map, val, "token_url")) orelse return error.InvalidAuth, + .device_poll_url = try substField(aa, environ_map, val, "device_poll_url"), + .verification_url = try substField(aa, environ_map, val, "verification_url"), + .scope = try substField(aa, environ_map, val, "scope"), .token_request_format = fmt, - .redirect_uri = try aaDupOpt(aa, val, "redirect_uri"), - .account_id_jwt_claim = try aaDupOpt(aa, val, "account_id_jwt_claim"), - .headers = try parseHeaders(aa, val.get("headers")), - .exchange = try parseExchange(aa, val.get("exchange")), + .redirect_uri = try substField(aa, environ_map, val, "redirect_uri"), + .account_id_jwt_claim = try substField(aa, environ_map, val, "account_id_jwt_claim"), + .exchange = try parseExchangeFlat(aa, environ_map, val), }; // codex dialect needs a distinct poll endpoint. if (dialect == .codex and oauth.device_poll_url == null) return error.InvalidAuth; @@ -755,20 +777,73 @@ fn resolveAuth( } } -/// Parse an optional `[auth..exchange]` sub-table. -fn parseExchange(aa: Allocator, val: ?*const toml.Value) Error!?panto.ExchangeConfig { - const v = val orelse return null; - if (v.* != .table) return error.InvalidAuth; +/// Build the optional secondary exchange from flat `exchange_*` keys. Returns +/// null when `exchange_url` is absent. +fn parseExchangeFlat( + aa: Allocator, + environ_map: *const std.process.Environ.Map, + val: *const toml.Value, +) Error!?panto.ExchangeConfig { + const url = (try substField(aa, environ_map, val, "exchange_url")) orelse return null; return .{ - .method = if (tomlStr(v, "method")) |s| try aa.dupe(u8, s) else "GET", - .url = try aaDupReq(aa, v, "url"), - .token_json_path = if (tomlStr(v, "token_json_path")) |s| try aa.dupe(u8, s) else "token", - .expires_at_json_path = try aaDupOpt(aa, v, "expires_at_json_path"), - .base_url_json_path = try aaDupOpt(aa, v, "base_url_json_path"), - .headers = try parseHeaders(aa, v.get("headers")), + .url = url, + .method = (try substField(aa, environ_map, val, "exchange_method")) orelse "GET", + .token_json_path = (try substField(aa, environ_map, val, "exchange_token_path")) orelse "token", + .expires_at_json_path = try substField(aa, environ_map, val, "exchange_expires_path"), + .base_url_json_path = try substField(aa, environ_map, val, "exchange_base_url_path"), }; } +/// Read a string field from an auth table, run `${...}` substitution, and dupe +/// the result into `aa`. Returns null when the key is absent or substitutes to +/// the empty string (so an unset `${env:VAR}` reads as "unresolved"). +fn substField( + aa: Allocator, + environ_map: *const std.process.Environ.Map, + table: *const toml.Value, + key: []const u8, +) Error!?[]const u8 { + const raw = tomlStr(table, key) orelse return null; + const v = try substitute(aa, environ_map, table, raw); + return if (v.len == 0) null else v; +} + +/// Substitute `${name}` placeholders in `raw`. `${env:VAR}` reads the +/// environment; any other `${name}` reads the raw value of sibling key `name` +/// in the same auth `table`. An unresolved reference (missing env var or +/// sibling key) substitutes to the empty string. Result is owned by `aa`. +fn substitute( + aa: Allocator, + environ_map: *const std.process.Environ.Map, + table: *const toml.Value, + raw: []const u8, +) Error![]const u8 { + if (std.mem.indexOf(u8, raw, "${") == null) return aa.dupe(u8, raw); + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(aa); + var i: usize = 0; + while (i < raw.len) { + if (raw[i] == '$' and i + 1 < raw.len and raw[i + 1] == '{') { + const close = std.mem.indexOfScalarPos(u8, raw, i + 2, '}') orelse { + // Unterminated `${` — emit the remainder literally. + try out.appendSlice(aa, raw[i..]); + break; + }; + const ref = raw[i + 2 .. close]; + const value: []const u8 = if (std.mem.startsWith(u8, ref, "env:")) + (environ_map.get(ref[4..]) orelse "") + else + (tomlStr(table, ref) orelse ""); + try out.appendSlice(aa, value); + i = close + 1; + } else { + try out.append(aa, raw[i]); + i += 1; + } + } + return out.toOwnedSlice(aa); +} + /// Parse a `[...headers]` table of string keys/values into `[]panto.Header`, /// allocated in `aa`. A null/absent table yields an empty slice. fn parseHeaders(aa: Allocator, val: ?*const toml.Value) Error![]panto.Header { @@ -786,19 +861,6 @@ fn parseHeaders(aa: Allocator, val: ?*const toml.Value) Error![]panto.Header { return list.toOwnedSlice(aa); } -/// Read a required string field, duping into `aa`. Errors `InvalidAuth` if -/// absent or non-string. -fn aaDupReq(aa: Allocator, val: *const toml.Value, key: []const u8) Error![]const u8 { - const s = tomlStr(val, key) orelse return error.InvalidAuth; - return aa.dupe(u8, s); -} - -/// Read an optional string field, duping into `aa` (null if absent). -fn aaDupOpt(aa: Allocator, val: *const toml.Value, key: []const u8) Error!?[]const u8 { - const s = tomlStr(val, key) orelse return null; - return try aa.dupe(u8, s); -} - /// Borrow a string field from a table, or null if absent/non-string. fn tomlStr(val: *const toml.Value, key: []const u8) ?[]const u8 { const v = val.get(key) orelse return null; @@ -951,11 +1013,10 @@ const anthropic_env_src = \\auth = "anthropic_api" \\ \\[auth.anthropic_api] - \\type = "api_key" - \\key_env_var = "ANTHROPIC_API_KEY" + \\key = "${env:ANTHROPIC_API_KEY}" ; -test "resolve: provider survives with unresolved auth when env var is absent" { +test "resolve: api_key provider is dropped when its ${env} key is unset" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); @@ -965,16 +1026,14 @@ test "resolve: provider survives with unresolved auth when env var is absent" { var cfg = try resolve(a, &env, doc.root); defer cfg.deinit(); - // Clean break: the provider stays selectable even with no key resolved. - try testing.expectEqual(@as(usize, 1), cfg.providers.len); + // Unresolved api_key ⇒ the provider disappears (export the key to get it + // back). The auth session itself still exists. + try testing.expectEqual(@as(usize, 0), cfg.providers.len); + try testing.expect(cfg.provider("anthropic") == null); const auth = cfg.auth("anthropic_api").?; try testing.expect(auth.resolved_api_key == null); - - // Its built config carries an empty api_key placeholder. - var models = models_toml.ModelRegistry.init(a); - defer models.deinit(); - const pc = try buildProviderConfig(&cfg, &models, .{ .provider = "anthropic", .model = "x" }); - try testing.expectEqualStrings("", pc.anthropic_messages.api_key); + // `type` was inferred from the presence of `key`. + try testing.expectEqual(panto.AuthType.api_key, @as(panto.AuthType, auth.config)); } test "resolve: api_key session resolves when env var is set" { @@ -1024,11 +1083,11 @@ test "resolve: provider naming an unknown auth session is an error" { try testing.expectError(error.UnknownProviderAuth, resolve(a, &env, doc.root)); } -test "resolve: auth literal key wins over key_env_var" { +test "resolve: ${env:VAR} and ${sibling} substitution in auth values" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); - try env.put("KEY_FROM_ENV", "env-value"); + try env.put("MY_KEY", "sk-from-env"); const src = \\[providers.openai] @@ -1037,16 +1096,46 @@ test "resolve: auth literal key wins over key_env_var" { \\auth = "k" \\ \\[auth.k] - \\type = "api_key" - \\key = "inline-value" - \\key_env_var = "KEY_FROM_ENV" + \\key = "${env:MY_KEY}" + \\ + \\[providers.gh] + \\style = "openai_chat" + \\base_url = "https://example.com" + \\auth = "gh" + \\ + \\[auth.gh] + \\client_id = "abc" + \\domain = "github.com" + \\device_code_url = "https://${domain}/login/device/code" + \\token_url = "https://${domain}/login/oauth/access_token" ; const doc = try parseDoc(a, src); defer doc.deinit(); var cfg = try resolve(a, &env, doc.root); defer cfg.deinit(); - try testing.expectEqualStrings("inline-value", cfg.auth("k").?.resolved_api_key.?); + // env substitution resolves the literal key. + try testing.expectEqualStrings("sk-from-env", cfg.auth("k").?.resolved_api_key.?); + // sibling-key substitution templates the device URLs (type inferred from + // client_id). + const oauth = cfg.auth("gh").?.config.oauth_device; + try testing.expectEqualStrings("https://github.com/login/device/code", oauth.device_code_url); + try testing.expectEqualStrings("https://github.com/login/oauth/access_token", oauth.token_url); +} + +test "resolve: literal key resolves without substitution" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + const src = + \\[auth.k] + \\key = "sk-literal" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + var cfg = try resolve(a, &env, doc.root); + defer cfg.deinit(); + try testing.expectEqualStrings("sk-literal", cfg.auth("k").?.resolved_api_key.?); } test "resolve: invalid auth type is an error" { @@ -1068,33 +1157,28 @@ test "resolve: oauth_device (token dialect) with exchange parses" { defer env.deinit(); const src = \\[auth.github_copilot] - \\type = "oauth_device" - \\dialect = "token" \\client_id = "Iv1.b507a08c87ecfe98" - \\device_code_url = "https://github.com/login/device/code" - \\token_url = "https://github.com/login/oauth/access_token" + \\domain = "github.com" + \\device_code_url = "https://${domain}/login/device/code" + \\token_url = "https://${domain}/login/oauth/access_token" \\scope = "read:user" - \\ - \\[auth.github_copilot.exchange] - \\url = "https://api.github.com/copilot_internal/v2/token" - \\token_json_path = "token" - \\expires_at_json_path = "expires_at" - \\base_url_json_path = "endpoints.api" - \\ - \\[auth.github_copilot.exchange.headers] - \\Copilot-Integration-Id = "vscode-chat" + \\exchange_url = "https://api.${domain}/copilot_internal/v2/token" + \\exchange_expires_path = "expires_at" + \\exchange_base_url_path = "endpoints.api" ; const doc = try parseDoc(a, src); defer doc.deinit(); var cfg = try resolve(a, &env, doc.root); defer cfg.deinit(); const oauth = cfg.auth("github_copilot").?.config.oauth_device; + // type + dialect inferred/defaulted. try testing.expectEqual(panto.DeviceDialect.token, oauth.dialect); try testing.expectEqualStrings("Iv1.b507a08c87ecfe98", oauth.client_id); + try testing.expectEqualStrings("https://github.com/login/device/code", oauth.device_code_url); try testing.expect(oauth.exchange != null); + try testing.expectEqualStrings("https://api.github.com/copilot_internal/v2/token", oauth.exchange.?.url); + try testing.expectEqualStrings("token", oauth.exchange.?.token_json_path); // default try testing.expectEqualStrings("endpoints.api", oauth.exchange.?.base_url_json_path.?); - try testing.expectEqual(@as(usize, 1), oauth.exchange.?.headers.len); - try testing.expectEqualStrings("Copilot-Integration-Id", oauth.exchange.?.headers[0].name); } test "resolve: oauth_device codex dialect requires a poll url" { diff --git a/src/luarocks_runtime.zig b/src/luarocks_runtime.zig index 3cfd02f..f0438d4 100644 --- a/src/luarocks_runtime.zig +++ b/src/luarocks_runtime.zig @@ -282,9 +282,12 @@ const default_base_config = \\# replace the lower one wholesale. \\# \\# Every networked provider names an `[auth.]` session via - \\# `auth = ""`. An api_key session whose env var is missing at - \\# runtime stays selectable, but its first request fails with a clear - \\# auth error. + \\# `auth = ""`. An api_key session whose `${env:VAR}` is unset + \\# resolves to empty, and a provider with an empty key is dropped — so + \\# these defaults disappear unless you've exported the matching key. + \\# + \\# Values support `${...}` substitution: `${env:VAR}` reads the + \\# environment, `${other_key}` reads a sibling key in the same section. \\ \\[providers.openai] \\style = "openai_chat" @@ -292,8 +295,7 @@ const default_base_config = \\auth = "openai_api" \\ \\[auth.openai_api] - \\type = "api_key" - \\key_env_var = "OPENAI_API_KEY" + \\key = "${env:OPENAI_API_KEY}" \\ \\[providers.anthropic] \\style = "anthropic_messages" @@ -301,8 +303,7 @@ const default_base_config = \\auth = "anthropic_api" \\ \\[auth.anthropic_api] - \\type = "api_key" - \\key_env_var = "ANTHROPIC_API_KEY" + \\key = "${env:ANTHROPIC_API_KEY}" \\ \\# Pick the default model with `:`. The alias is \\# resolved against models.toml; an unknown alias is used verbatim as @@ -312,40 +313,30 @@ const default_base_config = \\# model = "anthropic:claude-sonnet-4-20250514" \\ \\# GitHub Copilot subscription (OAuth device login). Run a turn against - \\# `copilot:` and panto will guide you through device login. + \\# `copilot:` and panto guides you through device login. The + \\# provider's extra_headers (the editor identity) are reused on the auth + \\# calls too. For GitHub Enterprise, set `domain = "company.ghe.com"`. \\# \\# [providers.copilot] \\# style = "openai_chat" - \\# base_url = "https://api.individual.githubcopilot.com" + \\# base_url = "https://api.individual.githubcopilot.com" # fallback; the exchange overrides it \\# auth = "github_copilot" \\# \\# [providers.copilot.extra_headers] - \\# User-Agent = "GitHubCopilotChat/0.26.7" - \\# Editor-Version = "vscode/1.99.0" - \\# Editor-Plugin-Version = "copilot-chat/0.26.7" + \\# User-Agent = "GitHubCopilotChat/0.35.0" + \\# Editor-Version = "vscode/1.107.0" + \\# Editor-Plugin-Version = "copilot-chat/0.35.0" \\# Copilot-Integration-Id = "vscode-chat" - \\# X-Initiator = "user" \\# \\# [auth.github_copilot] - \\# type = "oauth_device" - \\# dialect = "token" \\# client_id = "Iv1.b507a08c87ecfe98" - \\# device_code_url = "https://github.com/login/device/code" - \\# token_url = "https://github.com/login/oauth/access_token" + \\# domain = "github.com" + \\# device_code_url = "https://${domain}/login/device/code" + \\# token_url = "https://${domain}/login/oauth/access_token" \\# scope = "read:user" - \\# - \\# [auth.github_copilot.exchange] - \\# method = "GET" - \\# url = "https://api.github.com/copilot_internal/v2/token" - \\# token_json_path = "token" - \\# expires_at_json_path = "expires_at" - \\# base_url_json_path = "endpoints.api" - \\# - \\# [auth.github_copilot.exchange.headers] - \\# User-Agent = "GitHubCopilotChat/0.26.7" - \\# Editor-Version = "vscode/1.99.0" - \\# Editor-Plugin-Version = "copilot-chat/0.26.7" - \\# Copilot-Integration-Id = "vscode-chat" + \\# exchange_url = "https://api.${domain}/copilot_internal/v2/token" + \\# exchange_expires_path = "expires_at" + \\# exchange_base_url_path = "endpoints.api" \\ \\# OpenAI Codex via a ChatGPT subscription (device login). Codex speaks the \\# OpenAI Responses API, not Chat Completions, so style = "openai_responses". @@ -362,7 +353,6 @@ const default_base_config = \\# originator = "codex_cli_rs" \\# \\# [auth.openai_codex] - \\# type = "oauth_device" \\# dialect = "codex" \\# client_id = "app_EMoamEEZ73f0CkXaXp7hrann" \\# device_code_url = "https://auth.openai.com/api/accounts/deviceauth/usercode" diff --git a/src/subcommand.zig b/src/subcommand.zig index b145d4b..bb46941 100644 --- a/src/subcommand.zig +++ b/src/subcommand.zig @@ -491,12 +491,22 @@ fn authLogin( defer panto.deinit(); const client = panto.httpClient(); + // The auth HTTP calls carry the identity headers of a provider that uses + // this session (e.g. Copilot's editor headers). 1:1 in practice; if no + // provider references it yet, send none. + const headers: []const panto.Header = blk: { + for (cfg.providers) |p| { + if (std.mem.eql(u8, p.auth_name, name)) break :blk p.extra_headers; + } + break :blk &.{}; + }; + 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| { + const toks = panto.oauthLogin(aa, io, client, oauth, presenter.presenter(), headers) catch |err| { try out.print("login failed: {t}\n", .{err}); return; }; @@ -508,7 +518,7 @@ fn authLogin( // 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: { + ts.exchange = panto.runExchange(aa, client, exchange, access, headers) catch |err| blk: { try out.print("note: token exchange failed ({t}); will retry on first use\n", .{err}); break :blk null; }; -- cgit v1.3