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. --- src/config_file.zig | 276 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 180 insertions(+), 96 deletions(-) (limited to 'src/config_file.zig') 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" { -- cgit v1.3