diff --git a/README.md b/README.md index b2ed77ac8..1337ba794 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ To use an AI Gateway API key instead: fx setup ``` +Embedding hosts that inject provider authentication at the network boundary can set `FX_AUTH_MODE=host-managed`. In this mode, fx does not read, refresh, or write local model-provider credentials and does not add authentication-owned headers to Gateway, Codex, or Grok requests. The host must authenticate those forwarded requests. + Run fx from a project: ```bash diff --git a/src/acp/prompt.zig b/src/acp/prompt.zig index 1efa283ef..38588fa3c 100644 --- a/src/acp/prompt.zig +++ b/src/acp/prompt.zig @@ -826,7 +826,12 @@ pub fn handlePrompt( ); if (comptime @import("builtin").os.tag != .wasi) { if (state.cfg.provider_set.select(session.provider).deferred_usage != null) { - if (session.credential_source) |source| { + if (session.credential_source == .host_managed) { + session.session_rt.usage.replaceHostManagedReconciliationAuthority( + alloc, + session.provider, + ); + } else if (session.credential_source) |source| { session.session_rt.usage.replaceProviderReconciliationCredential( alloc, session.provider, diff --git a/src/acp/server.zig b/src/acp/server.zig index b39cca77b..3fd6b5e61 100644 --- a/src/acp/server.zig +++ b/src/acp/server.zig @@ -395,6 +395,19 @@ pub fn selectCredentialForProvider( state: *ServerState, provider: model_provider.ProviderId, ) !bool { + if (state.cfg.auth_mode == .host_managed) { + state.credential_source = .host_managed; + state.credential_refresh_after_ms = null; + state.account_id = null; + state.gateway_team = null; + if (state.active_session) |*active| { + active.credential_source = .host_managed; + active.credential_refresh_after_ms = null; + active.api_key = &.{}; + active.account_id = null; + } + return true; + } const now_ms = io_mod.milliTimestamp(); if (state.active_session) |active| { if (credentialMatchesProvider(active.credential_source, provider) and @@ -1703,21 +1716,24 @@ fn loadConfiguredStartupState(state: *const ServerState, alloc: Allocator) !app_ } if (state.cfg.home_override) |home_dir| { if (state.cfg.workspace_root_override) |workspace_root| { - return app_lifecycle.loadEmbeddedStartupState( + var startup = try app_lifecycle.loadEmbeddedStartupState( alloc, home_dir, workspace_root, state.cfg.default_model, state.cfg.default_agent_step_limit, ); + startup.auth_mode = state.cfg.auth_mode; + return startup; } } - return app_lifecycle.loadStartupState( + return app_lifecycle.loadStartupStateWithAuthMode( alloc, state.cfg.gateway_provider.oauth_transport, state.cfg.secret_store, state.cfg.default_model, state.cfg.default_agent_step_limit, + state.cfg.auth_mode, ); } @@ -1785,33 +1801,53 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message state.gateway_source_preference = startup.credential_source_preference; state.configured_model = try alloc.dupe(u8, startup.configured_model); - var startup_credential = startup.takeCredential(); - defer if (startup_credential) |*credential| credential.deinit(alloc); - var routed_credential: ?credentials.Credential = null; - defer if (routed_credential) |*credential| credential.deinit(alloc); - const startup_matches_model = if (startup_credential) |credential| - credentialMatchesProvider(credential.source, state.provider) - else - false; - const startup_credential_is_final = startup_matches_model and - !credentials.sourceRefreshable(startup_credential.?.source); - const credential: *credentials.Credential = if (state.provider == .gateway and state.cfg.credential_override != null) override: { - routed_credential = .{ - .token = try alloc.dupe(u8, state.cfg.credential_override.?), - .source = .ai_gateway_api_key, + if (state.cfg.auth_mode == .host_managed) { + state.api_key = &.{}; + state.credential_source = .host_managed; + state.credential_refresh_after_ms = null; + state.account_id = null; + state.gateway_team = null; + } else { + var startup_credential = startup.takeCredential(); + defer if (startup_credential) |*credential| credential.deinit(alloc); + var routed_credential: ?credentials.Credential = null; + defer if (routed_credential) |*credential| credential.deinit(alloc); + const startup_matches_model = if (startup_credential) |credential| + credentialMatchesProvider(credential.source, state.provider) + else + false; + const startup_credential_is_final = startup_matches_model and + !credentials.sourceRefreshable(startup_credential.?.source); + const credential: *credentials.Credential = if (state.provider == .gateway and state.cfg.credential_override != null) override: { + routed_credential = .{ + .token = try alloc.dupe(u8, state.cfg.credential_override.?), + .source = .ai_gateway_api_key, + }; + break :override &routed_credential.?; + } else if (startup_credential_is_final) + &startup_credential.? + else routed: { + routed_credential = try auth_runtime.prepareCredential( + alloc, + state.cfg.gateway_provider.oauth_transport, + state.cfg.secret_store, + state.provider, + if (state.provider == .gateway) startup.credential_source_preference else null, + ); + if (routed_credential == null) { + return state.writer.writeError(alloc, msg.id, .{ + .code = ErrorCode.invalid_request, + .message = if (state.provider == .codex) + credentials.missing_chatgpt_credential_message + else if (state.provider == .grok) + credentials.missing_grok_credential_message + else + credentials.missing_credential_message, + }); + } + break :routed &routed_credential.?; }; - break :override &routed_credential.?; - } else if (startup_credential_is_final) - &startup_credential.? - else routed: { - routed_credential = try auth_runtime.prepareCredential( - alloc, - state.cfg.gateway_provider.oauth_transport, - state.cfg.secret_store, - state.provider, - if (state.provider == .gateway) startup.credential_source_preference else null, - ); - if (routed_credential == null) { + if (credential.token.len == 0) { return state.writer.writeError(alloc, msg.id, .{ .code = ErrorCode.invalid_request, .message = if (state.provider == .codex) @@ -1822,20 +1858,8 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message credentials.missing_credential_message, }); } - break :routed &routed_credential.?; - }; - if (credential.token.len == 0) { - return state.writer.writeError(alloc, msg.id, .{ - .code = ErrorCode.invalid_request, - .message = if (state.provider == .codex) - credentials.missing_chatgpt_credential_message - else if (state.provider == .grok) - credentials.missing_grok_credential_message - else - credentials.missing_credential_message, - }); + adoptServerCredential(state, credential); } - adoptServerCredential(state, credential); state.permission_mode = startup.permission_mode; state.permission_rules = startup.takePermissionRules(); @@ -1870,12 +1894,15 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message state.alloc, startup_catalog, .{ - .access = credentials.catalogAccessForCredentialAndAccount( - state.credential_source, - state.api_key, - state.gateway_team, - state.account_id, - ), + .access = if (state.cfg.auth_mode == .host_managed) + .host_managed + else + credentials.catalogAccessForCredentialAndAccount( + state.credential_source, + state.api_key, + state.gateway_team, + state.account_id, + ), .endpoint = state.cfg.gateway_models_path, .cancel_flag = &catalog_cancel_flag, }, @@ -2094,7 +2121,9 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me .message = "Subscription provider switching is unavailable in this WASM runtime", }); } - var staged_credential = if (target == .gateway and state.cfg.credential_override != null) + var staged_credential: ?credentials.Credential = if (state.cfg.auth_mode == .host_managed) + null + else if (target == .gateway and state.cfg.credential_override != null) credentials.Credential{ .token = try alloc.dupe(u8, state.cfg.credential_override.?), .source = .ai_gateway_api_key, @@ -2117,24 +2146,27 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me credentials.missing_credential_message, }); }; - defer staged_credential.deinit(alloc); - if (!model_provider.authorizesCredential(target, staged_credential.source)) { + defer if (staged_credential) |*credential| credential.deinit(alloc); + if (staged_credential) |credential| if (!model_provider.authorizesCredential(target, credential.source)) { return state.writer.writeError(alloc, msg.id, .{ .code = ErrorCode.invalid_request, .message = "Credential cannot authorize the selected provider", }); - } + }; const catalog_provider = catalogProviderFor(state, target) orelse return state.writer.writeError(alloc, msg.id, .{ .code = ErrorCode.invalid_request, .message = "Selected provider is unavailable in this host", }); - const access = credentials.catalogAccessForCredentialAndAccount( - staged_credential.source, - staged_credential.token, - staged_credential.gatewayTeam(), - staged_credential.accountId(), - ); + const access: credentials.CatalogAccess = if (state.cfg.auth_mode == .host_managed) + .host_managed + else + credentials.catalogAccessForCredentialAndAccount( + staged_credential.?.source, + staged_credential.?.token, + staged_credential.?.gatewayTeam(), + staged_credential.?.accountId(), + ); const fetched = try catalog_provider.fetch(alloc, .{ .access = access, .endpoint = state.cfg.gateway_models_path, @@ -2186,7 +2218,14 @@ fn handleSetConfigOption(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Me }); }; state.capability_resolver.adoptOwnedCatalog(alloc, &catalog); - adoptServerCredential(state, &staged_credential); + if (staged_credential) |*credential| { + adoptServerCredential(state, credential); + } else { + state.credential_source = .host_managed; + session.credential_source = .host_managed; + session.api_key = &.{}; + session.account_id = null; + } } } else if (std.mem.eql(u8, config_id, "mode")) { if (state.active_session) |*session| { diff --git a/src/builtins/gateway.zig b/src/builtins/gateway.zig index fb4708a52..bc8be0885 100644 --- a/src/builtins/gateway.zig +++ b/src/builtins/gateway.zig @@ -527,7 +527,8 @@ fn streamAgentCompletion( alloc: Allocator, request: agent_stream_provider_contract.ModelRequest, ) anyerror!agent_stream_provider_contract.Result { - if (request.credential.source == .chatgpt_subscription or request.credential.source == .grok_subscription) { + const credential_source = request.credential.credentialSource(); + if (credential_source == .chatgpt_subscription or credential_source == .grok_subscription) { return agent_stream_provider_contract.failResult( error.SubscriptionCredentialCannotAuthorizeGateway, ); @@ -537,8 +538,8 @@ fn streamAgentCompletion( defer if (request.prepared_request_body == null) alloc.free(payload); var events = request.events; const stream_request = gateway_client.StreamRequest{ - .api_key = request.credential.secret, - .team = request.credential.tenant, + .api_key = request.credential.secret(), + .team = request.credential.tenant(), .session_id = request.session_id, .model = request.model, .retry_count = request.retry_count, @@ -618,17 +619,17 @@ fn gatewayUsageReference( completion: shared_types.ModelCompletion, ) ?agent_stream_provider_contract.DeferredUsageReference { const generation_id = completion.generation_id orelse return null; - const source = request.credential.source orelse return null; + const source = request.credential.credentialSource() orelse return null; return .{ .provider = .gateway, .generation_id = generation_id, .scope = gateway_client.generationBaseUrl(), - .tenant = request.credential.tenant, - .account_id = request.credential.account_id, + .tenant = request.credential.tenant(), + .account_id = request.credential.accountId(), .credential_source = source, .credential_identity = credential_authority.derive( source, - request.credential.account_id, + request.credential.accountId(), ), }; } @@ -908,7 +909,7 @@ fn executeWebSearchProvider( progress_ctx: ?*anyopaque, ) !Response { return executeGatewayWorker(alloc, .{ - .api_key = inputs.api_key, + .api_key = if (inputs.credential_source == .host_managed) null else inputs.api_key, .credential_source = inputs.credential_source, .team = inputs.gateway_team, .model = inputs.worker_model, @@ -986,7 +987,7 @@ pub const StreamFn = *const fn ( var default_stream_ctx: u8 = 0; pub const GatewayWorkerConfig = struct { - api_key: []const u8, + api_key: ?[]const u8, credential_source: ?shared_types.CredentialSource = null, team: ?[]const u8 = null, model: []const u8, @@ -1014,7 +1015,9 @@ pub fn executeGatewayWorker( on_progress: ?ProgressFn, progress_ctx: ?*anyopaque, ) !Response { - if (config.api_key.len == 0 or config.model.len == 0 or config.chat_url.len == 0) { + if ((config.api_key == null and config.credential_source != .host_managed) or + config.model.len == 0 or config.chat_url.len == 0) + { return error.MissingGatewaySearchConfiguration; } if (request.cancel_flag.load(.seq_cst)) return error.Cancelled; @@ -1044,7 +1047,7 @@ pub fn executeGatewayWorker( var stream = config.stream_fn( config.stream_ctx, alloc, - config.api_key, + config.api_key orelse "", config.team, config.model, @max(config.retry_count, 1), @@ -1074,11 +1077,18 @@ pub fn executeGatewayWorker( } if (!builtin.is_test and stream.status == .ok and std.meta.activeTag(usage_outcome) == .deferred) { if (config.usage) |ledger| { - ledger.startDeferredReconciliation( - config.usage_allocator, - usage_outcome.deferred, - config.api_key, - ); + if (config.api_key) |api_key| { + ledger.startDeferredReconciliation( + config.usage_allocator, + usage_outcome.deferred, + api_key, + ); + } else if (config.credential_source == .host_managed) { + ledger.startHostManagedDeferredReconciliation( + config.usage_allocator, + usage_outcome.deferred, + ); + } } } if (stream.status != .ok) return error.GatewayRequestFailed; @@ -1179,7 +1189,7 @@ fn streamGatewayWorker( return gateway_client.streamGatewayProviderToolCompletionBounded( alloc, .{ - .api_key = api_key, + .api_key = if (api_key.len > 0) api_key else null, .team = team, .model = model, .retry_count = request_retry_count, diff --git a/src/builtins/gateway/permission_reviewer.zig b/src/builtins/gateway/permission_reviewer.zig index 64f6bf076..3e7738d4c 100644 --- a/src/builtins/gateway/permission_reviewer.zig +++ b/src/builtins/gateway/permission_reviewer.zig @@ -29,7 +29,7 @@ const StreamFn = *const fn ( var default_stream_ctx: u8 = 0; const GatewayConfig = struct { - api_key: []const u8, + api_key: ?[]const u8, credential_source: ?types.CredentialSource = null, team: ?[]const u8 = null, chat_url: []const u8, @@ -49,7 +49,7 @@ fn reviewGateway( request: permission_auto_classifier.ReviewRequest, ) anyerror!permission_auto_classifier.ParseOutcome { return reviewGatewayConfig(.{ - .api_key = input.credential, + .api_key = if (input.credential_source == .host_managed) null else input.credential, .credential_source = input.credential_source, .team = input.tenant, .chat_url = input.endpoint, @@ -123,7 +123,7 @@ fn sendGatewayReview( .{ model, single_transport_attempt }, ); if (cancel_flag.load(.seq_cst)) return .cancelled; - if (config.api_key.len == 0 or config.chat_url.len == 0) { + if ((config.api_key == null and config.credential_source != .host_managed) or config.chat_url.len == 0) { debug_trace.logf("permission", "event=auto_review_transport result=permanent_failure reason=missing_gateway_config", .{}); return .permanent_failure; } @@ -140,7 +140,7 @@ fn sendGatewayReview( var stream = config.stream_fn( config.stream_ctx, alloc, - config.api_key, + config.api_key orelse "", config.team, model, single_transport_attempt, @@ -182,11 +182,18 @@ fn sendGatewayReview( return .permanent_failure; }; if (stream.status == .ok and std.meta.activeTag(usage_outcome) == .deferred) if (config.usage) |ledger| { - ledger.startDeferredReconciliation( - config.usage_allocator, - usage_outcome.deferred, - config.api_key, - ); + if (config.api_key) |api_key| { + ledger.startDeferredReconciliation( + config.usage_allocator, + usage_outcome.deferred, + api_key, + ); + } else if (config.credential_source == .host_managed) { + ledger.startHostManagedDeferredReconciliation( + config.usage_allocator, + usage_outcome.deferred, + ); + } }; if (cancel_flag.load(.seq_cst)) { @@ -310,7 +317,7 @@ fn streamGatewayReviewer( return gateway_client.streamGatewayRequiredToolCompletionBounded( alloc, .{ - .api_key = api_key, + .api_key = if (api_key.len > 0) api_key else null, .team = team, .model = model, .retry_count = retry_count, diff --git a/src/core/agent/runtime/context_compaction.zig b/src/core/agent/runtime/context_compaction.zig index aefb10345..1e620fdaa 100644 --- a/src/core/agent/runtime/context_compaction.zig +++ b/src/core/agent/runtime/context_compaction.zig @@ -297,16 +297,20 @@ fn runSummaryCall( .clock = .awake, .raw = .fromMilliseconds(provider_timeout_ms), }); + const credential: agent_stream_provider.CredentialLease = if (request.credential_source == .host_managed) + .host_managed + else + .{ .direct = .{ + .secret_bytes = request.api_key, + .source = request.credential_source, + .account_id = request.account_id, + .tenant_context = request.gateway_team, + } }; var streamed = try runtime_gateway_step.streamModelCompletion( request.stream_provider, alloc, .{ - .credential = .{ - .secret = request.api_key, - .source = request.credential_source, - .account_id = request.account_id, - .tenant = request.gateway_team, - }, + .credential = credential, .session_id = request.session_id, .model = request.model, .retry_count = request.retry_count, @@ -414,6 +418,8 @@ const FakeProvider = struct { saw_only_summary_prompt: bool = true, max_output_tokens: ?u32 = null, observed_model: ?[]const u8 = null, + observed_credential_source: ?types.CredentialSource = null, + observed_secret: ?[]const u8 = null, fn provider(self: *FakeProvider) agent_stream_provider.Provider { return .{ .context = self, .stream_fn = stream }; @@ -447,6 +453,8 @@ const FakeProvider = struct { std.mem.startsWith(u8, system, "Summarize only conversation goals"); self.max_output_tokens = request.max_output_tokens; self.observed_model = request.model; + self.observed_credential_source = request.credential.credentialSource(); + self.observed_secret = request.credential.secret(); try request.admission.admit(); request.delivery.markPossiblySent(); request.events.emit(.{ .content_delta = self.response }); @@ -466,6 +474,35 @@ test "compaction result exposes only caller-consumed state" { try std.testing.expect(!@hasField(Result, "usage")); } +test "host-managed compaction carries authority without secret bytes" { + const alloc = std.testing.allocator; + const messages = [_]types.ChatMessage{ + .{ .role = .user, .content = "Preserve this decision." }, + .{ .role = .assistant, .content = "Decision preserved." }, + .{ .role = .user, .content = "Continue." }, + }; + var provider = FakeProvider{ .response = "Preserve the decision." }; + var cancel = std.atomic.Value(bool).init(false); + var result = try compact(alloc, &messages, .{ + .stream_provider = provider.provider(), + .model = "provider/compactor", + .api_key = "", + .credential_source = .host_managed, + .retry_count = 0, + .cancel_flag = &cancel, + .accepted_tokens = 256, + .generation_tokens = 128, + .trace_ctx = .{}, + }); + defer result.deinit(alloc); + + try std.testing.expectEqual( + types.CredentialSource.host_managed, + provider.observed_credential_source.?, + ); + try std.testing.expect(provider.observed_secret == null); +} + test "semantic compaction summarizes once while runtime truth remains authoritative" { const alloc = std.testing.allocator; const calls = [_]types.ToolCall{.{ diff --git a/src/core/agent/runtime/gateway_step.zig b/src/core/agent/runtime/gateway_step.zig index 6fd65e040..5eb4dc51e 100644 --- a/src/core/agent/runtime/gateway_step.zig +++ b/src/core/agent/runtime/gateway_step.zig @@ -85,11 +85,18 @@ pub fn streamModelCompletion( ); if (comptime @import("builtin").os.tag != .wasi) { if (std.meta.activeTag(completed.usage) == .deferred) if (usage) |ledger| { - ledger.startDeferredReconciliation( - usage_allocator, - completed.usage.deferred, - request.credential.secret, - ); + if (request.credential.secret()) |credential| { + ledger.startDeferredReconciliation( + usage_allocator, + completed.usage.deferred, + credential, + ); + } else if (request.credential.credentialSource() == .host_managed) { + ledger.startHostManagedDeferredReconciliation( + usage_allocator, + completed.usage.deferred, + ); + } }; } }, @@ -269,7 +276,7 @@ test "provider preflight failure does not reserve usage" { agent_stream_provider.unavailable_provider, alloc, .{ - .credential = .{ .secret = "test-key" }, + .credential = .{ .direct = .{ .secret_bytes = "test-key" } }, .model = "test/model", .retry_count = 1, .messages = &.{}, @@ -337,7 +344,7 @@ test "caller admission publishes before provider attempt is admitted" { .{ .context = &provider, .stream_fn = Provider.stream }, alloc, .{ - .credential = .{ .secret = "test-key" }, + .credential = .{ .direct = .{ .secret_bytes = "test-key" } }, .model = "test/model", .retry_count = 1, .messages = &.{}, @@ -405,7 +412,7 @@ test "caller admission failure settles usage and prevents request open" { .{ .context = &provider, .stream_fn = Provider.stream }, alloc, .{ - .credential = .{ .secret = "test-key" }, + .credential = .{ .direct = .{ .secret_bytes = "test-key" } }, .model = "test/model", .retry_count = 1, .messages = &.{}, @@ -462,7 +469,7 @@ test "possibly sent gateway failure marks billing incomplete" { .{ .stream_fn = Gateway.stream }, alloc, .{ - .credential = .{ .secret = "test-key" }, + .credential = .{ .direct = .{ .secret_bytes = "test-key" } }, .model = "test/model", .retry_count = 1, .messages = &.{}, @@ -542,11 +549,11 @@ test "provider-local exact usage reaches session accounting" { provider, alloc, .{ - .credential = .{ - .secret = "subscription-token", + .credential = .{ .direct = .{ + .secret_bytes = "subscription-token", .source = .chatgpt_subscription, .account_id = "acct_test", - }, + } }, .session_id = "session-test", .model = "gpt-test", .retry_count = 1, diff --git a/src/core/agent/runtime/image_provider.zig b/src/core/agent/runtime/image_provider.zig index a686721d8..43fcfda9e 100644 --- a/src/core/agent/runtime/image_provider.zig +++ b/src/core/agent/runtime/image_provider.zig @@ -59,11 +59,14 @@ pub fn inspect( request.stream_provider, alloc, .{ - .credential = .{ - .secret = request.api_key, - .source = request.credential_source, - .tenant = request.gateway_team, - }, + .credential = if (request.credential_source == .host_managed) + .host_managed + else + .{ .direct = .{ + .secret_bytes = request.api_key, + .source = request.credential_source orelse .ai_gateway_api_key, + .tenant_context = request.gateway_team, + } }, .session_id = request.session_id, .model = model, .retry_count = request.retry_count, diff --git a/src/core/agent/runtime/orchestrator.zig b/src/core/agent/runtime/orchestrator.zig index 1ebc3f02c..514817138 100644 --- a/src/core/agent/runtime/orchestrator.zig +++ b/src/core/agent/runtime/orchestrator.zig @@ -4142,12 +4142,13 @@ fn activeCredentialLease( secret_value: []const u8, job: QueuedPrompt, ) types.CredentialLease { - return .{ - .secret = secret_value, + if (job.credential_source == .host_managed) return .host_managed; + return .{ .direct = .{ + .secret_bytes = secret_value, .source = job.credential_source, .account_id = job.account_id, - .tenant = job.gateway_team, - }; + .tenant_context = job.gateway_team, + } }; } fn appendTrustedPermissionFeedback( @@ -5356,12 +5357,15 @@ fn processQueuedPromptLoop( .pending_status = &pending_auto_retry_status, }; var model_request = agent_stream_provider.ModelRequest{ - .credential = .{ - .secret = active_api_key, - .source = job.credential_source, - .account_id = job.account_id, - .tenant = job.gateway_team, - }, + .credential = if (job.credential_source == .host_managed) + .host_managed + else + .{ .direct = .{ + .secret_bytes = active_api_key, + .source = job.credential_source orelse .ai_gateway_api_key, + .account_id = job.account_id, + .tenant_context = job.gateway_team, + } }, .session_id = lifecycle.scope.session_id, .model = gateway_model, .retry_count = config.gateway_retry_count, @@ -5768,7 +5772,7 @@ fn processQueuedPromptLoop( auth_retry_used = true; var replay_delivery = runtime_gateway_step.DeliveryCertainty.init(); var replay_evidence: runtime_gateway_step.AttemptEvidence = .{}; - model_request.credential.secret = active_api_key; + model_request.credential.direct.secret_bytes = active_api_key; model_request.delivery = &replay_delivery; model_request.attempt_evidence = &replay_evidence; stream_result = try runtime_gateway_step.streamModelCompletion( diff --git a/src/core/agent/runtime/tests/support.zig b/src/core/agent/runtime/tests/support.zig index 2fb6385c4..dd08d4151 100644 --- a/src/core/agent/runtime/tests/support.zig +++ b/src/core/agent/runtime/tests/support.zig @@ -262,7 +262,7 @@ pub const FakeGateway = struct { defer if (request.prepared_request_body == null) alloc.free(payload); try self.request_bodies.append(self.alloc, try self.alloc.dupe(u8, payload)); try self.request_models.append(self.alloc, try self.alloc.dupe(u8, request.model)); - try self.request_api_keys.append(self.alloc, try self.alloc.dupe(u8, request.credential.secret)); + try self.request_api_keys.append(self.alloc, try self.alloc.dupe(u8, request.credential.secret() orelse "")); const session_id = if (request.session_id) |id| try self.alloc.dupe(u8, id) else null; errdefer if (session_id) |id| self.alloc.free(id); try self.request_session_ids.append(self.alloc, session_id); @@ -1120,7 +1120,7 @@ pub const FakeAgentRuntimeDeps = struct { if (self.last_permission_credential) |value| self.alloc.free(value); self.last_permission_credential = try self.alloc.dupe( u8, - review_turn.credential.secret, + review_turn.credential.secret() orelse "", ); if (self.last_permission_arguments) |value| self.alloc.free(value); self.last_permission_arguments = try self.alloc.dupe(u8, call.arguments_json); @@ -1409,7 +1409,7 @@ pub const FakeAgentRuntimeDeps = struct { if (self.last_executed_arguments) |value| self.alloc.free(value); self.last_executed_arguments = try self.alloc.dupe(u8, call.arguments_json); if (self.last_execute_credential) |value| self.alloc.free(value); - self.last_execute_credential = try self.alloc.dupe(u8, request.credential.secret); + self.last_execute_credential = try self.alloc.dupe(u8, request.credential.secret() orelse ""); if (self.last_execute_root_user_intent_context) |value| self.alloc.free(value); self.last_execute_root_user_intent_context = try self.alloc.dupe( u8, diff --git a/src/core/agent/runtime/tool_contracts.zig b/src/core/agent/runtime/tool_contracts.zig index 7c7957771..e95c36022 100644 --- a/src/core/agent/runtime/tool_contracts.zig +++ b/src/core/agent/runtime/tool_contracts.zig @@ -131,7 +131,7 @@ pub const ToolExecutionRequest = struct { result_allocator: Allocator, call: ToolCall, authority: command_admission.ToolExecutionAuthority, - credential: types.CredentialLease = .{}, + credential: types.CredentialLease = .{ .direct = .{} }, /// Action-scoped root mode sampled before permission admission. Direct /// callers without a sampled mode retain their execution context value. permission_mode: ?types.PermissionMode = null, diff --git a/src/core/agent/stream_provider.zig b/src/core/agent/stream_provider.zig index 0384460bc..c80ad75e3 100644 --- a/src/core/agent/stream_provider.zig +++ b/src/core/agent/stream_provider.zig @@ -148,6 +148,16 @@ pub const ToolSelection = struct { } }; +pub const CredentialLease = types.CredentialLease; + +test "host-managed credential lease exposes no secret or account metadata" { + const lease: CredentialLease = .host_managed; + try std.testing.expect(lease.secret() == null); + try std.testing.expect(lease.accountId() == null); + try std.testing.expect(lease.tenant() == null); + try std.testing.expectEqual(types.CredentialSource.host_managed, lease.credentialSource().?); +} + /// Pure provider input used by request serializers and permission reviewers. /// Every slice and JSON value is borrowed for the call. pub const RequestData = struct { @@ -406,7 +416,7 @@ test "stream provider accepts one typed request and emits ordered neutral events .context = &fake, .stream_fn = Fake.stream, }).stream(std.testing.allocator, .{ - .credential = .{ .secret = "key" }, + .credential = .{ .direct = .{ .secret_bytes = "key" } }, .model = "model", .retry_count = 1, .messages = &.{}, diff --git a/src/core/app/app_agent_runtime.zig b/src/core/app/app_agent_runtime.zig index 251b5e888..99b74031c 100644 --- a/src/core/app/app_agent_runtime.zig +++ b/src/core/app/app_agent_runtime.zig @@ -764,17 +764,18 @@ pub fn Runtime(comptime App: type) type { gateway_retry_count: usize, gateway_chat_url: []const u8, ) void { - if (credential.secret.len == 0) return; - ctx.api_key = credential.secret; - ctx.credential_source = credential.source; - ctx.account_id = credential.account_id; - ctx.gateway_team = credential.tenant; + const credential_secret = credential.secret() orelse return; + const credential_source = credential.credentialSource(); + ctx.api_key = credential_secret; + ctx.credential_source = credential_source; + ctx.account_id = credential.accountId(); + ctx.gateway_team = credential.tenant(); if (comptime @hasField(App, "web_search_runtime") and @hasField(App, "session")) { if (ctx.provider_capabilities.fx_search) { app.web_search_runtime.configure(.{ - .api_key = credential.secret, - .credential_source = credential.source, - .gateway_team = credential.tenant, + .api_key = credential_secret, + .credential_source = credential_source, + .gateway_team = credential.tenant(), .worker_model = provider_runtime.model(app), .gateway_retry_count = gateway_retry_count, .gateway_chat_url = gateway_chat_url, diff --git a/src/core/app/app_auth_runtime.zig b/src/core/app/app_auth_runtime.zig index 3fbc5777a..b0119c348 100644 --- a/src/core/app/app_auth_runtime.zig +++ b/src/core/app/app_auth_runtime.zig @@ -49,6 +49,27 @@ fn selectCatalogModel( return if (entries.len > 0) entries[0].id else null; } +fn optionalGatewayApiKey(credential: anytype) ?[]const u8 { + if (comptime @typeInfo(@TypeOf(credential.api_key)) == .optional) { + return credential.api_key; + } + return credential.api_key; +} + +fn gatewayCredentialSource(credential: anytype) ?credentials.Source { + if (comptime @hasField(@TypeOf(credential), "source")) { + return credential.source; + } + return null; +} + +fn hostManagesAuth(app: anytype) bool { + if (comptime @hasDecl(@TypeOf(app.auth), "isHostManaged")) { + return app.auth.isHostManaged(); + } + return false; +} + const TeamCatalogValidation = union(enum) { rejected, accepted: ?[]u8, @@ -113,6 +134,10 @@ pub fn Runtime(comptime App: type) type { } pub fn runLoginCommand(app: *App) !void { + if (hostManagesAuth(app)) { + try writeAuthNotice(app, .{ .topic = "auth", .tone = .neutral, .body = credentials.host_managed_auth_message }); + return; + } if (comptime !oauthAuthEnabled(App)) { try app.writeDomainNotice(.{ .topic = "auth", @@ -143,6 +168,10 @@ pub fn Runtime(comptime App: type) type { } pub fn runLogoutCommand(app: *App, target: []const u8) !void { + if (hostManagesAuth(app)) { + try writeAuthNotice(app, .{ .topic = "auth", .tone = .neutral, .body = credentials.host_managed_auth_message }); + return; + } if (comptime !oauthAuthEnabled(App)) { try app.writeDomainNotice(.{ .topic = "auth", @@ -240,6 +269,10 @@ pub fn Runtime(comptime App: type) type { } pub fn openSetupHub(app: *App) !void { + if (hostManagesAuth(app)) { + try writeAuthNotice(app, .{ .topic = "auth", .tone = .neutral, .body = credentials.host_managed_auth_message }); + return; + } if (comptime !runtime_profile.allows(App, .native_auth)) { try app.writeDomainNotice(.{ .topic = "auth", @@ -835,55 +868,61 @@ pub fn Runtime(comptime App: type) type { }; defer settings.deinit(app.alloc); - var credential = (auth_runtime.prepareCredential( - app.alloc, - app.auth.oauthTransport(), - app.auth.secretStore(), - target, - if (target == .gateway) settings.credential_source else null, - ) catch |err| { - debug_trace.logf("provider", "credential preparation failed provider={t} err={s}", .{ target, @errorName(err) }); - try app.writeDomainNotice(.{ - .topic = "provider", - .tone = .@"error", - .body = providerFailureMessage( - intent, - "Could not prepare the target provider credential. The current provider is unchanged.", - "Subscription sign-in completed, but its credential could not be prepared. The current provider is unchanged.", - ), - }, true); - return; - }) orelse { - if (target == .codex and allow_login) { - try beginCodexSignInForProviderSwitch(app); + var credential: ?credentials.Credential = null; + defer if (credential) |*value| value.deinit(app.alloc); + if (!hostManagesAuth(app)) { + credential = (auth_runtime.prepareCredential( + app.alloc, + app.auth.oauthTransport(), + app.auth.secretStore(), + target, + if (target == .gateway) settings.credential_source else null, + ) catch |err| { + debug_trace.logf("provider", "credential preparation failed provider={t} err={s}", .{ target, @errorName(err) }); + try app.writeDomainNotice(.{ + .topic = "provider", + .tone = .@"error", + .body = providerFailureMessage( + intent, + "Could not prepare the target provider credential. The current provider is unchanged.", + "Subscription sign-in completed, but its credential could not be prepared. The current provider is unchanged.", + ), + }, true); return; - } - if (target == .grok and allow_login) { - try beginGrokSignInForProviderSwitch(app); + }) orelse { + if (target == .codex and allow_login) { + try beginCodexSignInForProviderSwitch(app); + return; + } + if (target == .grok and allow_login) { + try beginGrokSignInForProviderSwitch(app); + return; + } + try app.writeDomainNotice(.{ + .topic = "provider", + .tone = .warning, + .body = if (intent == .post_oauth) + "Subscription sign-in completed, but its saved credential is unavailable. The current provider is unchanged." + else if (target == .codex) + "Run fx login codex, then try switching again." + else if (target == .grok) + "Run fx login grok, then try switching again." + else + credentials.missing_interactive_credential_message, + }, true); return; - } - try app.writeDomainNotice(.{ - .topic = "provider", - .tone = .warning, - .body = if (intent == .post_oauth) - "Subscription sign-in completed, but its saved credential is unavailable. The current provider is unchanged." - else if (target == .codex) - "Run fx login codex, then try switching again." - else if (target == .grok) - "Run fx login grok, then try switching again." - else - credentials.missing_interactive_credential_message, - }, true); - return; - }; - defer credential.deinit(app.alloc); + }; + } - const access = credentials.catalogAccessForCredentialAndAccount( - credential.source, - credential.token, - credential.gatewayTeam(), - credential.accountId(), - ); + const access: credentials.CatalogAccess = if (hostManagesAuth(app)) + .host_managed + else + credentials.catalogAccessForCredentialAndAccount( + credential.?.source, + credential.?.token, + credential.?.gatewayTeam(), + credential.?.accountId(), + ); const fetched = app.fetchProviderCatalog(target, access) catch |err| { debug_trace.logf("provider", "catalog preparation failed provider={t} err={s}", .{ target, @errorName(err) }); try app.writeDomainNotice(.{ @@ -955,7 +994,7 @@ pub fn Runtime(comptime App: type) type { app.model_cache.adoptOwnedCatalog(access, &catalog); app.provider_selection.adoptOwned(target, &owned_model); - _ = app.auth.adoptCredential(app.alloc, &credential); + if (credential) |*value| _ = app.auth.adoptCredential(app.alloc, value); reconcileGatewayCredential(app); const body = try std.fmt.allocPrint( @@ -1266,6 +1305,7 @@ pub fn Runtime(comptime App: type) type { .chatgpt_subscription => "Run /login and reconnect Codex to repair this source.", .grok_subscription => "Run /login and reconnect Grok to repair this source.", .vercel_oidc_token, .ai_gateway_api_key, .stored_key => "Run /setup to repair this source.", + .host_managed => credentials.host_managed_auth_message, }, }, ); @@ -1294,6 +1334,15 @@ pub fn Runtime(comptime App: type) type { @hasField(@TypeOf(app.session), "usage")) { if (app.auth.gatewayCredential()) |credential| { + if (gatewayCredentialSource(credential) == .host_managed) { + if (comptime @hasDecl(@TypeOf(app.session.usage), "replaceHostManagedReconciliationAuthority")) { + app.session.usage.replaceHostManagedReconciliationAuthority( + app.alloc, + provider_runtime.provider(app), + ); + } + return; + } const subscription = if (comptime @hasField(@TypeOf(credential), "source")) credential.source == .chatgpt_subscription or credential.source == .grok_subscription else @@ -1305,18 +1354,22 @@ pub fn Runtime(comptime App: type) type { @TypeOf(app.session.usage), "replaceProviderReconciliationCredential", )) { - app.session.usage.replaceProviderReconciliationCredential( - app.alloc, - .gateway, - credential.source, - null, - credential.api_key, - ); + if (optionalGatewayApiKey(credential)) |api_key| { + app.session.usage.replaceProviderReconciliationCredential( + app.alloc, + .gateway, + credential.source, + null, + api_key, + ); + } } else { - app.session.usage.replaceReconciliationCredential( - app.alloc, - credential.api_key, - ); + if (optionalGatewayApiKey(credential)) |api_key| { + app.session.usage.replaceReconciliationCredential( + app.alloc, + api_key, + ); + } } } } else { diff --git a/src/core/app/app_bootstrap_runtime.zig b/src/core/app/app_bootstrap_runtime.zig index cdd082058..7462ecf44 100644 --- a/src/core/app/app_bootstrap_runtime.zig +++ b/src/core/app/app_bootstrap_runtime.zig @@ -201,6 +201,10 @@ pub fn Runtime(comptime App: type) type { app.secretStore() else host.unavailable_secret_store, + .auth_mode = if (comptime @hasDecl(@TypeOf(app.auth), "authMode")) + app.auth.authMode() + else + .local, .resize_handler = resize_handler, .fx_version = App.app_version, }); diff --git a/src/core/app/app_callbacks.zig b/src/core/app/app_callbacks.zig index a32c20981..b4adf1401 100644 --- a/src/core/app/app_callbacks.zig +++ b/src/core/app/app_callbacks.zig @@ -952,6 +952,7 @@ pub fn Bindings(comptime App: type) type { .chatgpt_subscription => "Reconnect Codex through /login to repair this source.", .grok_subscription => "Reconnect Grok through /login to repair this source.", .vercel_oidc_token, .ai_gateway_api_key, .stored_key => "Run /setup to repair this source.", + .host_managed => credentials.host_managed_auth_message, }, }, ) diff --git a/src/core/app/app_entry_runtime.zig b/src/core/app/app_entry_runtime.zig index 52a0f3d88..823fe78d3 100644 --- a/src/core/app/app_entry_runtime.zig +++ b/src/core/app/app_entry_runtime.zig @@ -5,6 +5,7 @@ const app_session_runtime = @import("app_session_runtime.zig"); const auto_upgrade = @import("../upgrade/auto_upgrade.zig"); const acp_runner = @import("../cli/acp_runner.zig"); const cli_surface = @import("../cli/cli_surface.zig"); +const credentials = @import("../auth/credentials.zig"); const process_provider = @import("../execution/process_provider.zig"); const gateway_provider = @import("../gateway/gateway_provider.zig"); const provider_set = @import("../gateway/provider_set.zig"); @@ -66,6 +67,7 @@ pub const Config = struct { version: []const u8 = "", revision: []const u8 = "", build_channel: update_target.Channel = .stable, + auth_mode: credentials.AuthMode = .local, command_catalog: command_specs.TopLevelRegistry, default_model: []const u8, default_agent_step_limit: usize, @@ -160,7 +162,7 @@ fn runWithDeps(comptime App: type, alloc: Allocator, args: []const [:0]const u8, .exit => |code| return .{ .exit = code }, } - return runInteractiveWithDeps(App, false, alloc, &launch, deps); + return runInteractiveWithDeps(App, false, alloc, &launch, cfg.auth_mode, deps); } pub fn runBeforeInteractive(alloc: Allocator, args: []const [:0]const u8, cfg: Config) !BeforeInteractiveResult { @@ -219,23 +221,23 @@ fn benchEnabled() bool { return io_mod.getenv("FX_BENCH") != null; } -pub fn runInteractive(comptime App: type, alloc: Allocator, launch: *cli_surface.InteractiveLaunch) !RunOutcome { - return runInteractiveWithDeps(App, false, alloc, launch, .{}); +pub fn runInteractive(comptime App: type, alloc: Allocator, launch: *cli_surface.InteractiveLaunch, auth_mode: credentials.AuthMode) !RunOutcome { + return runInteractiveWithDeps(App, false, alloc, launch, auth_mode, .{}); } /// Runs the interactive product without native CLI dispatch, process replacement, /// or a worker thread. Single-threaded hosts must arrange cooperative prompt work. -pub fn runInteractiveCooperative(comptime App: type, alloc: Allocator, launch: *cli_surface.InteractiveLaunch) !RunOutcome { - return runInteractiveWithDeps(App, true, alloc, launch, .{}); +pub fn runInteractiveCooperative(comptime App: type, alloc: Allocator, launch: *cli_surface.InteractiveLaunch, auth_mode: credentials.AuthMode) !RunOutcome { + return runInteractiveWithDeps(App, true, alloc, launch, auth_mode, .{}); } fn unavailableCliDispatch(_: ?*anyopaque, _: Allocator, _: []const [:0]const u8, _: cli_surface.Config) anyerror!cli_surface.RunResult { return error.UnknownCliCommand; } -fn runInteractiveWithDeps(comptime App: type, comptime cooperative: bool, alloc: Allocator, launch: *cli_surface.InteractiveLaunch, deps: RunDeps) !RunOutcome { +fn runInteractiveWithDeps(comptime App: type, comptime cooperative: bool, alloc: Allocator, launch: *cli_surface.InteractiveLaunch, auth_mode: credentials.AuthMode, deps: RunDeps) !RunOutcome { const resume_requested = launch.requested_resume != null; - var app = App.init(alloc, launch) catch |err| { + var app = App.init(alloc, launch, auth_mode) catch |err| { switch (err) { error.NotATerminal => { writeStderr(deps, "fx requires an interactive terminal (TTY).\n"); @@ -409,6 +411,7 @@ fn cliSurfaceConfig(cfg: Config) cli_surface.Config { .version = cfg.version, .revision = cfg.revision, .build_channel = cfg.build_channel, + .auth_mode = cfg.auth_mode, .command_catalog = cfg.command_catalog, .default_model = cfg.default_model, .default_agent_step_limit = cfg.default_agent_step_limit, @@ -731,7 +734,7 @@ const TestApp = struct { requested_resume: ?cli_surface.ResumeTarget = null, terminal_released: bool = false, - fn init(_: Allocator, launch: *cli_surface.InteractiveLaunch) !TestApp { + fn init(_: Allocator, launch: *cli_surface.InteractiveLaunch, _: credentials.AuthMode) !TestApp { appendInitEvent(launch); if (active_capture.?.init_error) |err| return err; diff --git a/src/core/app/app_lifecycle.zig b/src/core/app/app_lifecycle.zig index 953392862..86afefd77 100644 --- a/src/core/app/app_lifecycle.zig +++ b/src/core/app/app_lifecycle.zig @@ -116,6 +116,7 @@ pub const StartupState = struct { workspace_root: []u8 = &.{}, workspace_access: workspace_access.WorkspaceAccess = .{}, credential: ?credentials.Credential = null, + auth_mode: credentials.AuthMode = .local, credential_source_preference: ?credentials.Source = null, credential_onboarding_skipped: bool = false, stored_key_status: credentials.StoredKeyReadStatus = .not_attempted, @@ -185,6 +186,7 @@ pub const StartupState = struct { } pub fn modelCatalogAccess(self: *const StartupState) credentials.CatalogAccess { + if (self.auth_mode == .host_managed) return .host_managed; const access = credentials.catalogAccessAt(self.credential, io_mod.milliTimestamp()); return if (self.credential_source_preference == null) access @@ -256,6 +258,7 @@ pub const BootstrapConfig = struct { default_model: []const u8, default_agent_step_limit: usize, secret_store: host.SecretStore, + auth_mode: credentials.AuthMode = .local, resize_handler: ResizeHandler, fx_version: []const u8 = "", }; @@ -266,14 +269,32 @@ pub fn loadStartupState( secret_store: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize, +) !StartupState { + return loadStartupStateWithAuthMode( + alloc, + transport, + secret_store, + default_model, + default_agent_step_limit, + .local, + ); +} + +pub fn loadStartupStateWithAuthMode( + alloc: Allocator, + transport: oauth_transport.Provider, + secret_store: host.SecretStore, + default_model: []const u8, + default_agent_step_limit: usize, + auth_mode: credentials.AuthMode, ) !StartupState { const workspace_root = try io_mod.realpathAlloc(alloc, "."); - return loadStartupStateFromOwnedWorkspace(alloc, transport, secret_store, workspace_root, default_model, default_agent_step_limit, null, .refresh_if_needed); + return loadStartupStateFromOwnedWorkspace(alloc, transport, secret_store, workspace_root, default_model, default_agent_step_limit, auth_mode, null, .refresh_if_needed); } pub fn loadStartupStateWithoutCredentials(alloc: Allocator, default_model: []const u8, default_agent_step_limit: usize) !StartupState { const workspace_root = try io_mod.realpathAlloc(alloc, "."); - return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, host.unavailable_secret_store, workspace_root, default_model, default_agent_step_limit, null, null); + return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, host.unavailable_secret_store, workspace_root, default_model, default_agent_step_limit, .local, null, null); } pub fn loadEmbeddedStartupState( @@ -291,6 +312,7 @@ pub fn loadEmbeddedStartupState( owned_workspace_root, default_model, default_agent_step_limit, + .local, home_dir, null, ); @@ -325,9 +347,25 @@ pub fn loadCatalogStartupState( secret_store: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize, +) !StartupState { + return loadCatalogStartupStateWithAuthMode( + alloc, + secret_store, + default_model, + default_agent_step_limit, + .local, + ); +} + +pub fn loadCatalogStartupStateWithAuthMode( + alloc: Allocator, + secret_store: host.SecretStore, + default_model: []const u8, + default_agent_step_limit: usize, + auth_mode: credentials.AuthMode, ) !StartupState { const workspace_root = try io_mod.realpathAlloc(alloc, "."); - return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, secret_store, workspace_root, default_model, default_agent_step_limit, null, .stored); + return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, secret_store, workspace_root, default_model, default_agent_step_limit, auth_mode, null, .stored); } pub fn loadStartupStatus( @@ -335,6 +373,22 @@ pub fn loadStartupStatus( secret_store: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize, +) !StartupStatus { + return loadStartupStatusWithAuthMode( + alloc, + secret_store, + default_model, + default_agent_step_limit, + .local, + ); +} + +pub fn loadStartupStatusWithAuthMode( + alloc: Allocator, + secret_store: host.SecretStore, + default_model: []const u8, + default_agent_step_limit: usize, + auth_mode: credentials.AuthMode, ) !StartupStatus { const workspace_root = try io_mod.realpathAlloc(alloc, "."); errdefer alloc.free(workspace_root); @@ -348,12 +402,20 @@ pub fn loadStartupStatus( const selected_model = try loadStartupStatusModel(alloc, configured_selection.model, null); errdefer if (selected_model.owned) |model| alloc.free(model); - var auth_status = try auth_runtime.loadStatusSnapshotForProvider( - alloc, - secret_store, - configured_selection.provider, - settings.credential_source, - ); + var auth_status = if (auth_mode == .host_managed) + auth_runtime.StatusSnapshot{ + .active_source = .host_managed, + .gateway_connected = true, + .chatgpt_connected = true, + .grok_connected = true, + } + else + try auth_runtime.loadStatusSnapshotForProvider( + alloc, + secret_store, + configured_selection.provider, + settings.credential_source, + ); errdefer auth_status.deinit(alloc); const result = StartupStatus{ @@ -388,7 +450,7 @@ pub fn applyWorkspaceLaunch( fn loadStartupStateForWorkspace(alloc: Allocator, workspace_root: []const u8, default_model: []const u8, default_agent_step_limit: usize) !StartupState { const owned_workspace_root = try alloc.dupe(u8, workspace_root); - return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, host.unavailable_secret_store, owned_workspace_root, default_model, default_agent_step_limit, null, null); + return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, host.unavailable_secret_store, owned_workspace_root, default_model, default_agent_step_limit, .local, null, null); } const CredentialLoadMode = credentials.LoadMode; @@ -400,10 +462,14 @@ fn loadStartupStateFromOwnedWorkspace( owned_workspace_root: []u8, default_model: []const u8, default_agent_step_limit: usize, + auth_mode: credentials.AuthMode, profile_home: ?[]const u8, credential_mode: ?CredentialLoadMode, ) !StartupState { - var state = StartupState{ .agent_step_limit = default_agent_step_limit }; + var state = StartupState{ + .agent_step_limit = default_agent_step_limit, + .auth_mode = auth_mode, + }; errdefer state.deinit(alloc); state.workspace_root = owned_workspace_root; @@ -434,18 +500,20 @@ fn loadStartupStateFromOwnedWorkspace( state.prompt_history_enabled = settings.prompt_history_enabled orelse true; state.prompt_history_store_allowed = detailed.prompt_history_store_allowed; state.credential_source_preference = settings.credential_source; - if (credential_mode) |mode| { - const resolution = try credentials.resolveForProvider( - alloc, - transport, - secret_store, - mode, - state.provider, - settings.credential_source, - ); - state.credential = resolution.credential; - state.stored_key_status = resolution.stored_key_status; - state.fx_login_status = resolution.fx_login_status; + if (auth_mode == .local) { + if (credential_mode) |mode| { + const resolution = try credentials.resolveForProvider( + alloc, + transport, + secret_store, + mode, + state.provider, + settings.credential_source, + ); + state.credential = resolution.credential; + state.stored_key_status = resolution.stored_key_status; + state.fx_login_status = resolution.fx_login_status; + } } state.permission_mode = loadPermissionMode(settings.permission_mode); state.yolo_acknowledged = settings.yolo_acknowledged orelse false; @@ -520,15 +588,16 @@ pub fn bootstrapInteractiveApp(cfg: BootstrapConfig) !StartupState { cfg.shell.layout = minimalLayout(); try cfg.shell.initBacking(cfg.alloc); - var state = try loadCatalogStartupState( + var state = try loadCatalogStartupStateWithAuthMode( cfg.alloc, cfg.secret_store, cfg.default_model, cfg.default_agent_step_limit, + cfg.auth_mode, ); errdefer state.deinit(cfg.alloc); - state.credential_onboarding_skipped = credentialOnboardingDisabled(); + state.credential_onboarding_skipped = cfg.auth_mode == .host_managed or credentialOnboardingDisabled(); errdefer shutdownInteractiveShell( cfg.terminal, @@ -1911,6 +1980,28 @@ test "loadStartupState applies core env overrides" { try std.testing.expectEqual(@as(usize, 37), state.agent_step_limit); } +test "host-managed startup skips every local credential source" { + var env = try TestEnv.install(std.testing.allocator, &.{ + .{ .key = "AI_GATEWAY_API_KEY", .value = "must-not-load" }, + }); + defer env.deinit(); + + var state = try loadStartupStateWithAuthMode( + std.testing.allocator, + oauth_transport.unavailable_provider, + host.unavailable_secret_store, + "default-model", + 12, + .host_managed, + ); + defer state.deinit(std.testing.allocator); + + try std.testing.expectEqual(credentials.AuthMode.host_managed, state.auth_mode); + try std.testing.expect(state.credential == null); + try std.testing.expect(state.apiKey() == null); + try std.testing.expectEqual(credentials.CatalogAccess.host_managed, state.modelCatalogAccess()); +} + test "loadStartupState defaults fast mode on only for the compiled Gateway default and requires bound explicit preferences" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); diff --git a/src/core/app/model_cache_runtime.zig b/src/core/app/model_cache_runtime.zig index 6bdc33bf7..fe745e815 100644 --- a/src/core/app/model_cache_runtime.zig +++ b/src/core/app/model_cache_runtime.zig @@ -39,7 +39,7 @@ const OwnedCatalogAccess = struct { fn init(alloc: Allocator, access: credentials.CatalogAccess) !OwnedCatalogAccess { return switch (access) { - .public_only => .{ .access = access }, + .public_only, .host_managed => .{ .access = access }, .authenticated => |authenticated| blk: { const credential = try alloc.dupe(u8, authenticated.credential); errdefer secret.zeroAndFree(alloc, credential); @@ -72,7 +72,7 @@ const OwnedCatalogAccess = struct { fn deinit(self: *OwnedCatalogAccess, alloc: Allocator) void { switch (self.access) { - .public_only => {}, + .public_only, .host_managed => {}, .authenticated => |access| { secret.zeroAndFree(alloc, @constCast(access.credential)); if (access.team_context) |team| alloc.free(@constCast(team)); diff --git a/src/core/auth/auth_runtime.zig b/src/core/auth/auth_runtime.zig index 837e9d5d6..3b0e0db0d 100644 --- a/src/core/auth/auth_runtime.zig +++ b/src/core/auth/auth_runtime.zig @@ -1005,7 +1005,7 @@ pub const View = struct { }; pub const GatewayCredential = struct { - api_key: []const u8, + api_key: ?[]const u8, gateway_team: ?[]const u8, source: credentials.Source, }; @@ -1016,6 +1016,7 @@ pub const Runtime = struct { api_key_validator: api_key_validator.Provider = api_key_validator.unavailable_provider, oauth_transport: oauth_transport.Provider = oauth_transport.unavailable_provider, secret_store: host.SecretStore = host.unavailable_secret_store, + auth_mode: credentials.AuthMode = .local, selected_credential: ?credentials.Credential = null, credential_refresh_failure_source: ?credentials.Source = null, source_inventory: SourceSet = .empty, @@ -1044,11 +1045,21 @@ pub const Runtime = struct { validator: api_key_validator.Provider, transport: oauth_transport.Provider, secret_store: host.SecretStore, + ) Self { + return initWithMode(validator, transport, secret_store, .local); + } + + pub fn initWithMode( + validator: api_key_validator.Provider, + transport: oauth_transport.Provider, + secret_store: host.SecretStore, + auth_mode: credentials.AuthMode, ) Self { return .{ .api_key_validator = validator, .oauth_transport = transport, .secret_store = secret_store, + .auth_mode = auth_mode, }; } @@ -1059,9 +1070,19 @@ pub const Runtime = struct { validator: api_key_validator.Provider, transport: oauth_transport.Provider, secret_store: host.SecretStore, + ) void { + initIntoWithMode(storage, validator, transport, secret_store, .local); + } + + pub fn initIntoWithMode( + storage: *Self, + validator: api_key_validator.Provider, + transport: oauth_transport.Provider, + secret_store: host.SecretStore, + auth_mode: credentials.AuthMode, ) void { comptime { - if (std.meta.fields(Self).len != 26) { + if (std.meta.fields(Self).len != 27) { @compileError("update Runtime.initInto for the changed field set"); } } @@ -1069,6 +1090,7 @@ pub const Runtime = struct { storage.api_key_validator = validator; storage.oauth_transport = transport; storage.secret_store = secret_store; + storage.auth_mode = auth_mode; storage.selected_credential = null; storage.credential_refresh_failure_source = null; storage.source_inventory = .empty; @@ -1113,6 +1135,11 @@ pub const Runtime = struct { } fn gatewayCredentialAt(self: *const Self, now_ms: i64) ?GatewayCredential { + if (self.auth_mode == .host_managed) return .{ + .api_key = null, + .gateway_team = null, + .source = .host_managed, + }; const credential = self.selected_credential orelse return null; if (credential.needsRefreshAt(now_ms)) return null; if (credential.source == .fx_login) { @@ -1131,6 +1158,14 @@ pub const Runtime = struct { return credential.api_key; } + pub fn isHostManaged(self: *const Self) bool { + return self.auth_mode == .host_managed; + } + + pub fn authMode(self: *const Self) credentials.AuthMode { + return self.auth_mode; + } + pub fn oauthTransport(self: *const Self) oauth_transport.Provider { return self.oauth_transport; } @@ -1140,6 +1175,7 @@ pub const Runtime = struct { } pub fn modelCatalogAccess(self: *const Self) credentials.CatalogAccess { + if (self.auth_mode == .host_managed) return .host_managed; if (self.credential_refresh_failure_source) |source| { return credentials.catalogAccessAfterRefreshFailure(source); } @@ -1152,11 +1188,13 @@ pub const Runtime = struct { } pub fn credentialSource(self: *const Self) ?credentials.Source { + if (self.auth_mode == .host_managed) return .host_managed; const credential = self.selected_credential orelse return null; return credential.source; } pub fn accountId(self: *const Self) ?[]const u8 { + if (self.auth_mode == .host_managed) return null; const credential = self.selected_credential orelse return null; return credential.accountId(); } @@ -1180,6 +1218,12 @@ pub const Runtime = struct { } fn statusSnapshotAt(self: *const Self, now_ms: i64) StatusSnapshot { + if (self.auth_mode == .host_managed) return .{ + .active_source = .host_managed, + .gateway_connected = true, + .chatgpt_connected = true, + .grok_connected = true, + }; const gateway_connected = self.source_inventory.contains(.vercel_oidc_token) or self.source_inventory.contains(.ai_gateway_api_key) or self.source_inventory.contains(.fx_login) or @@ -1233,6 +1277,10 @@ pub const Runtime = struct { } pub fn refreshSourceInventory(self: *Self, alloc: Allocator) !void { + if (self.auth_mode == .host_managed) { + self.source_inventory = .empty; + return; + } try self.refreshSourceInventoryWithProbe(alloc, self, probeCredentialSource); } @@ -1292,6 +1340,7 @@ pub const Runtime = struct { } pub fn refreshChatGptSourceInventory(self: *Self, alloc: Allocator) !void { + if (self.auth_mode == .host_managed) return; if (try credentials.sourceExists(alloc, self.secret_store, .chatgpt_subscription)) { self.source_inventory.insert(.chatgpt_subscription); } else if (self.credentialSource() != .chatgpt_subscription) { @@ -1300,6 +1349,7 @@ pub const Runtime = struct { } pub fn refreshGrokSourceInventory(self: *Self, alloc: Allocator) !void { + if (self.auth_mode == .host_managed) return; if (try credentials.sourceExists(alloc, self.secret_store, .grok_subscription)) { self.source_inventory.insert(.grok_subscription); } else if (self.credentialSource() != .grok_subscription) { @@ -1952,6 +2002,7 @@ pub const Runtime = struct { alloc: Allocator, provider: model_provider.ProviderId, ) !?bool { + if (self.auth_mode == .host_managed) return false; return switch (provider) { .codex => if (self.credentialSource() == .chatgpt_subscription) false @@ -2154,6 +2205,33 @@ test "auth in-place initialization preserves empty runtime state" { try std.testing.expect(runtime.api_key_input.items.len == 0); } +test "host-managed runtime exposes authority without local credential state" { + var runtime = Runtime.initWithMode( + api_key_validator.unavailable_provider, + oauth_transport.unavailable_provider, + host.unavailable_secret_store, + .host_managed, + ); + defer runtime.deinit(std.testing.allocator); + + try std.testing.expect(runtime.isHostManaged()); + try std.testing.expect(runtime.apiKey() == null); + try std.testing.expect(runtime.accountId() == null); + try std.testing.expect(runtime.gatewayTeam() == null); + try std.testing.expectEqual(credentials.Source.host_managed, runtime.credentialSource().?); + try std.testing.expectEqual(credentials.Source.host_managed, runtime.gatewayCredential().?.source); + try std.testing.expect(runtime.gatewayCredential().?.api_key == null); + try std.testing.expectEqual(credentials.CatalogAccess.host_managed, runtime.modelCatalogAccess()); + const status = runtime.statusSnapshot(); + try std.testing.expectEqual(credentials.Source.host_managed, status.active_source.?); + try std.testing.expect(status.gateway_connected); + try std.testing.expect(status.chatgpt_connected); + try std.testing.expect(status.grok_connected); + try std.testing.expect(!status.refreshable()); + try runtime.refreshSourceInventory(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 0), runtime.source_inventory.count()); +} + fn probeCredentialSource(raw_context: ?*anyopaque, _: Allocator, source: credentials.Source) !bool { const self: *Runtime = @ptrCast(@alignCast(raw_context.?)); return sourcePresenceAvailable(credentials.sourcePresence(self.secret_store, source), .fail); @@ -2546,7 +2624,7 @@ test "auth runtime exposes one current Gateway credential for prompt admission" _ = runtime.adoptCredential(alloc, &credential); const gateway_credential = runtime.gatewayCredential().?; - try std.testing.expectEqualStrings("token-a", gateway_credential.api_key); + try std.testing.expectEqualStrings("token-a", gateway_credential.api_key.?); try std.testing.expectEqualStrings("team_123", gateway_credential.gateway_team.?); try std.testing.expectEqual(credentials.Source.fx_login, gateway_credential.source); } @@ -2590,7 +2668,7 @@ test "auth runtime withholds an fx credential across its expiry boundary" { refreshed.refresh_after_ms = 140_000; try std.testing.expect(runtime.adoptCredential(alloc, &refreshed)); try std.testing.expect(!runtime.credentialNeedsRefreshAt(40_000)); - try std.testing.expectEqualStrings("stale-token", runtime.gatewayCredentialAt(40_000).?.api_key); + try std.testing.expectEqualStrings("stale-token", runtime.gatewayCredentialAt(40_000).?.api_key.?); } test "auth runtime view preserves missing and loaded states" { diff --git a/src/core/auth/credential_authority.zig b/src/core/auth/credential_authority.zig index a031d3c42..0b536d9f6 100644 --- a/src/core/auth/credential_authority.zig +++ b/src/core/auth/credential_authority.zig @@ -25,6 +25,7 @@ pub fn derive( .ai_gateway_api_key, .fx_login, .stored_key, + .host_managed, => hash.update("\x00slot\x00"), .chatgpt_subscription, .grok_subscription, @@ -60,4 +61,5 @@ test "credential authority uses non-secret Gateway credential slots" { try std.testing.expect(!api_key.eql(stored_key)); try std.testing.expect(derive(.vercel_oidc_token, null) != null); try std.testing.expect(derive(.fx_login, null) != null); + try std.testing.expect(derive(.host_managed, null) != null); } diff --git a/src/core/auth/credentials.zig b/src/core/auth/credentials.zig index 7cb04d3aa..800d208bb 100644 --- a/src/core/auth/credentials.zig +++ b/src/core/auth/credentials.zig @@ -17,6 +17,20 @@ const types = @import("../shared/types.zig"); pub const Source = types.CredentialSource; +pub const AuthMode = enum { + local, + host_managed, +}; + +pub const AuthModeError = error{InvalidAuthMode}; + +pub fn parseAuthMode(value: ?[]const u8) AuthModeError!AuthMode { + const raw = value orelse return .local; + if (std.mem.eql(u8, raw, "local")) return .local; + if (std.mem.eql(u8, raw, "host-managed")) return .host_managed; + return error.InvalidAuthMode; +} + pub const CatalogPublicOnly = union(enum) { no_credential, fx_login_team_required, @@ -79,11 +93,13 @@ pub const CatalogAccess = union(enum) { account_id: ?[]const u8 = null, authority: CatalogAuthority = .automatic, }, + host_managed, pub fn credentialSource(self: CatalogAccess) ?Source { return switch (self) { .public_only => |access| access.credentialSource(), .authenticated => |access| access.source.credentialSource(), + .host_managed => .host_managed, }; } @@ -95,7 +111,7 @@ pub const CatalogAccess = union(enum) { pub fn publicOnly(self: CatalogAccess) ?CatalogPublicOnly { return switch (self) { .public_only => |access| access, - .authenticated => null, + .authenticated, .host_managed => null, }; } @@ -112,6 +128,7 @@ pub const CatalogAccess = union(enum) { .authenticated_credential_rejected = access.source.credentialSource(), }, }, + .host_managed => null, }; } @@ -125,6 +142,7 @@ pub const CatalogAccess = union(enum) { .account_id = access.account_id, .authority = .explicit, } }, + .host_managed => .host_managed, }; } @@ -132,6 +150,7 @@ pub const CatalogAccess = union(enum) { return switch (self) { .public_only => null, .authenticated => |access| access.credential, + .host_managed => null, }; } @@ -139,6 +158,7 @@ pub const CatalogAccess = union(enum) { const team = switch (self) { .public_only => return null, .authenticated => |access| access.team_context orelse return null, + .host_managed => return null, }; return if (team.len > 0) team else null; } @@ -147,6 +167,7 @@ pub const CatalogAccess = union(enum) { const account_id = switch (self) { .public_only => return null, .authenticated => |access| access.account_id orelse return null, + .host_managed => return null, }; return if (account_id.len > 0) account_id else null; } @@ -191,12 +212,14 @@ pub fn catalogAccessForCredentialAndAccount( account_id: ?[]const u8, ) CatalogAccess { const selected_source = source orelse return .{ .public_only = .no_credential }; + if (selected_source == .host_managed) return .host_managed; const authenticated_source: CatalogAuthenticatedSource = switch (selected_source) { .vercel_oidc_token => .vercel_oidc_token, .ai_gateway_api_key => .ai_gateway_api_key, .stored_key => .stored_key, .chatgpt_subscription => .chatgpt_subscription, .grok_subscription => .grok_subscription, + .host_managed => unreachable, .fx_login => blk: { const team = team_context orelse return .{ .public_only = .fx_login_team_required }; @@ -232,6 +255,7 @@ pub const missing_chatgpt_interactive_credential_message = "Codex needs a subscr pub const missing_grok_credential_message = "fx needs a Grok subscription login for this model. Run fx login grok."; pub const missing_grok_interactive_credential_message = "Grok needs a subscription login. Run /login, open Connections, then choose Grok subscription."; pub const unreadable_store_message = "fx could not read the stored API key from " ++ stored_key_backend_label ++ ". A key may be saved but unreadable. Set FX_TRACE_LOG for the failing step, or set AI_GATEWAY_API_KEY."; +pub const host_managed_auth_message = "Authentication is managed by the host."; test "public credential guidance spells fx lowercase" { try std.testing.expect(std.mem.startsWith(u8, missing_credential_message, "fx needs")); @@ -239,6 +263,24 @@ test "public credential guidance spells fx lowercase" { try std.testing.expect(std.mem.startsWith(u8, unreadable_store_message, "fx could")); } +test "auth mode accepts only local and host-managed process values" { + try std.testing.expectEqual(AuthMode.local, try parseAuthMode(null)); + try std.testing.expectEqual(AuthMode.local, try parseAuthMode("local")); + try std.testing.expectEqual(AuthMode.host_managed, try parseAuthMode("host-managed")); + try std.testing.expectError(error.InvalidAuthMode, parseAuthMode("host_managed")); + try std.testing.expectError(error.InvalidAuthMode, parseAuthMode("")); +} + +test "host-managed catalog access is authenticated without local headers" { + const access: CatalogAccess = .host_managed; + try std.testing.expect(access.authorizationCredential() == null); + try std.testing.expect(access.accountId() == null); + try std.testing.expect(access.teamContext() == null); + try std.testing.expectEqual(Source.host_managed, access.credentialSource().?); + try std.testing.expect(access.publicOnlyReason() == null); + try std.testing.expect(access.publicFallbackAfterRejection() == null); +} + pub const Credential = struct { token: []u8, source: Source, @@ -479,6 +521,7 @@ pub fn loadSource( .stored_key => loadStoredKeyCredential(alloc, secret_store), .chatgpt_subscription => loadChatGptCredential(alloc, transport, .if_needed), .grok_subscription => loadGrokCredential(alloc, transport, .if_needed), + .host_managed => null, }; } @@ -519,6 +562,7 @@ pub fn sourceExists( }, }; }, + .host_managed => false, }; } @@ -542,6 +586,7 @@ pub fn sourcePresence( secret_store.presence(), .chatgpt_subscription => chatgpt_session.presence(), .grok_subscription => grok_session.presence(), + .host_managed => .missing, }; } @@ -755,6 +800,7 @@ pub fn sourceLabel(source: Source) []const u8 { .stored_key => "stored API key (" ++ stored_key_backend_label ++ ")", .chatgpt_subscription => "Codex subscription", .grok_subscription => "Grok subscription", + .host_managed => "host managed", }; } diff --git a/src/core/cli/acp_runner.zig b/src/core/cli/acp_runner.zig index 4c93b3dcc..3949ab417 100644 --- a/src/core/cli/acp_runner.zig +++ b/src/core/cli/acp_runner.zig @@ -4,6 +4,7 @@ const process_provider = @import("../execution/process_provider.zig"); const gateway_provider = @import("../gateway/gateway_provider.zig"); const provider_set = @import("../gateway/provider_set.zig"); const host = @import("../hosts/host.zig"); +const credentials = @import("../auth/credentials.zig"); const mode_registry = @import("../modes/mode_registry.zig"); const prompt_policy = @import("../config/prompt_policy.zig"); const context_contract = @import("../workspace/context_contract.zig"); @@ -11,6 +12,7 @@ const context_contract = @import("../workspace/context_contract.zig"); const Allocator = std.mem.Allocator; pub const Config = struct { + auth_mode: credentials.AuthMode = .local, default_model: []const u8, default_agent_step_limit: usize, gateway_retry_count: usize, diff --git a/src/core/cli/cli_ask.zig b/src/core/cli/cli_ask.zig index c13eb394f..d4e6deabf 100644 --- a/src/core/cli/cli_ask.zig +++ b/src/core/cli/cli_ask.zig @@ -214,6 +214,7 @@ const headless_interrupt = if (supports_headless_interrupt) struct { }; pub const Config = struct { + auth_mode: credentials.AuthMode = .local, command_usage: []const u8, default_model: []const u8, default_agent_step_limit: usize, @@ -389,6 +390,7 @@ const NotifyAttentionFn = *const fn (?*anyopaque) void; const PermissionApprovalPromptFn = *const fn (?*anyopaque, ?*anyopaque, WriteFn, []const u8, ?*anyopaque, NotifyAttentionFn) anyerror!PermissionApprovalPromptResult; const IsTtyFn = *const fn (?*anyopaque) bool; const LoadStartupStateFn = *const fn (Allocator, oauth_transport.Provider, host.SecretStore, []const u8, usize) anyerror!app_lifecycle.StartupState; +const LoadStartupStateWithAuthModeFn = *const fn (Allocator, oauth_transport.Provider, host.SecretStore, []const u8, usize, credentials.AuthMode) anyerror!app_lifecycle.StartupState; const InitializeSessionStoresFn = *const fn (*AskContext) anyerror!void; const LoadSkillsFn = *const fn ( Allocator, @@ -411,6 +413,7 @@ const RunDeps = struct { stdout_is_tty: IsTtyFn = realStdoutIsTty, stderr_is_tty: IsTtyFn = realStderrIsTty, load_startup_state: LoadStartupStateFn = loadStartupStateDefault, + load_startup_state_with_auth_mode: LoadStartupStateWithAuthModeFn = app_lifecycle.loadStartupStateWithAuthMode, initialize_session_stores: InitializeSessionStoresFn = initializeSessionStoresDefault, load_skills: LoadSkillsFn = app_runtime_setup.loadSkills, context_registry: context_contract.Registry, @@ -1068,6 +1071,7 @@ const AskContext = struct { return permission_auto_classifier.Classifier.disabled(); return permission_auto_classifier.Classifier.withProvider(provider, .{ .credential = self.api_key, + .credential_source = self.credential_source, .account_id = self.account_id, .tenant = self.gateway_team, .endpoint = self.cfg.gateway_chat_url, @@ -1419,13 +1423,23 @@ fn runPromptInternal(alloc: Allocator, prompt: []const u8, permission_override: defer alloc.free(owned_prompt); try checkHeadlessCancellation(options.deps); - var startup = try options.deps.load_startup_state( - alloc, - cfg.gateway_provider.oauth_transport, - cfg.secret_store, - cfg.default_model, - cfg.default_agent_step_limit, - ); + var startup = if (cfg.auth_mode == .host_managed) + try options.deps.load_startup_state_with_auth_mode( + alloc, + cfg.gateway_provider.oauth_transport, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + cfg.auth_mode, + ) + else + try options.deps.load_startup_state( + alloc, + cfg.gateway_provider.oauth_transport, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + ); defer startup.deinit(alloc); try checkHeadlessCancellation(options.deps); @@ -1459,7 +1473,9 @@ fn runPromptInternal(alloc: Allocator, prompt: []const u8, permission_override: ); try checkHeadlessCancellation(options.deps); - if (!options.continue_recovery and options.resume_target == null and startup.credential == null) { + if (cfg.auth_mode == .local and + !options.continue_recovery and options.resume_target == null and startup.credential == null) + { return missingCredentialResult(alloc, options, startup.provider); } @@ -1539,47 +1555,62 @@ fn runPromptInternal(alloc: Allocator, prompt: []const u8, permission_override: var routed_credential: ?credentials.Credential = null; defer if (routed_credential) |*credential| credential.deinit(alloc); - const startup_matches_final_model = if (startup.credential) |credential| - model_provider.authorizesCredential(ctx.provider, credential.source) - else - false; - const startup_credential_is_final = startup_matches_final_model and - !credentials.sourceRefreshable(startup.credential.?.source); - const credential: *const credentials.Credential = if (startup_credential_is_final) - &startup.credential.? - else routed: { - routed_credential = try auth_runtime.prepareCredential( - alloc, - cfg.gateway_provider.oauth_transport, - cfg.secret_store, - ctx.provider, - if (ctx.provider == .gateway) startup.credential_source_preference else null, - ); - if (routed_credential == null) { - return missingCredentialResult(alloc, options, ctx.provider); + if (cfg.auth_mode == .host_managed) { + ctx.api_key = ""; + ctx.gateway_team = null; + ctx.credential_source = .host_managed; + ctx.account_id = null; + ctx.model_catalog_access = .host_managed; + if (comptime @import("builtin").os.tag != .wasi) { + if (ctx.cfg.provider_set.select(ctx.provider).deferred_usage != null) { + ctx.session.usage.replaceHostManagedReconciliationAuthority( + ctx.alloc, + ctx.provider, + ); + } } - break :routed &routed_credential.?; - }; - const api_key = credential.token; - ctx.api_key = api_key; - ctx.gateway_team = credential.gatewayTeam(); - ctx.credential_source = credential.source; - ctx.account_id = credential.accountId(); - ctx.model_catalog_access = credentials.catalogAccessForCredentialAndAccount( - credential.source, - api_key, - credential.gatewayTeam(), - credential.accountId(), - ); - if (comptime @import("builtin").os.tag != .wasi) { - if (ctx.cfg.provider_set.select(ctx.provider).deferred_usage != null) { - ctx.session.usage.replaceProviderReconciliationCredential( + } else { + const startup_matches_final_model = if (startup.credential) |credential| + model_provider.authorizesCredential(ctx.provider, credential.source) + else + false; + const startup_credential_is_final = startup_matches_final_model and + !credentials.sourceRefreshable(startup.credential.?.source); + const credential: *const credentials.Credential = if (startup_credential_is_final) + &startup.credential.? + else routed: { + routed_credential = try auth_runtime.prepareCredential( alloc, + cfg.gateway_provider.oauth_transport, + cfg.secret_store, ctx.provider, - credential.source, - credential.accountId(), - credential.token, + if (ctx.provider == .gateway) startup.credential_source_preference else null, ); + if (routed_credential == null) { + return missingCredentialResult(alloc, options, ctx.provider); + } + break :routed &routed_credential.?; + }; + ctx.api_key = credential.token; + ctx.gateway_team = credential.gatewayTeam(); + ctx.credential_source = credential.source; + ctx.account_id = credential.accountId(); + ctx.model_catalog_access = credentials.catalogAccessForCredentialAndAccount( + credential.source, + credential.token, + credential.gatewayTeam(), + credential.accountId(), + ); + if (comptime @import("builtin").os.tag != .wasi) { + if (ctx.cfg.provider_set.select(ctx.provider).deferred_usage != null) { + ctx.session.usage.replaceProviderReconciliationCredential( + alloc, + ctx.provider, + credential.source, + credential.accountId(), + credential.token, + ); + } } } @@ -1736,10 +1767,10 @@ fn runPromptInternal(alloc: Allocator, prompt: []const u8, permission_override: .images = current_images, .authorized_image_catalog = authorized_image_catalog, .model = @constCast(ctx.model), - .api_key = api_key, - .gateway_team = if (credential.gatewayTeam()) |team| @constCast(team) else null, - .credential_source = credential.source, - .account_id = if (credential.accountId()) |account_id| @constCast(account_id) else null, + .api_key = @constCast(ctx.api_key), + .gateway_team = if (ctx.gateway_team) |team| @constCast(team) else null, + .credential_source = ctx.credential_source, + .account_id = if (ctx.account_id) |account_id| @constCast(account_id) else null, .provider = ctx.provider, .permission_mode = ctx.permission_mode, .history = context_history, diff --git a/src/core/cli/cli_surface.zig b/src/core/cli/cli_surface.zig index ba6e202f8..f96c2d266 100644 --- a/src/core/cli/cli_surface.zig +++ b/src/core/cli/cli_surface.zig @@ -152,6 +152,7 @@ pub const Config = struct { version: []const u8 = "", revision: []const u8 = "", build_channel: update_target.Channel = .stable, + auth_mode: credentials.AuthMode = .local, command_catalog: CommandCatalog, default_model: []const u8, default_agent_step_limit: usize, @@ -294,6 +295,9 @@ const WriteFn = *const fn (?*anyopaque, []const u8) anyerror!void; const LoadStartupStateFn = *const fn (Allocator, oauth_transport.Provider, host.SecretStore, []const u8, usize) anyerror!app_lifecycle.StartupState; const LoadStartupStateWithoutCredentialsFn = *const fn (Allocator, []const u8, usize) anyerror!app_lifecycle.StartupState; const LoadStartupStatusFn = *const fn (Allocator, host.SecretStore, []const u8, usize) anyerror!app_lifecycle.StartupStatus; +const LoadStartupStateWithAuthModeFn = *const fn (Allocator, oauth_transport.Provider, host.SecretStore, []const u8, usize, credentials.AuthMode) anyerror!app_lifecycle.StartupState; +const LoadCatalogStartupStateWithAuthModeFn = *const fn (Allocator, host.SecretStore, []const u8, usize, credentials.AuthMode) anyerror!app_lifecycle.StartupState; +const LoadStartupStatusWithAuthModeFn = *const fn (Allocator, host.SecretStore, []const u8, usize, credentials.AuthMode) anyerror!app_lifecycle.StartupStatus; const GetenvFn = *const fn (?*anyopaque, []const u8) ?[]const u8; const EnvironMapFn = *const fn (?*anyopaque) ?*const std.process.Environ.Map; const SelfExePathFn = *const fn (?*anyopaque, Allocator) anyerror![]u8; @@ -310,6 +314,9 @@ const RunDeps = struct { load_startup_state: LoadStartupStateFn = app_lifecycle.loadStartupState, load_startup_state_without_credentials: LoadStartupStateWithoutCredentialsFn = app_lifecycle.loadStartupStateWithoutCredentials, load_startup_status: LoadStartupStatusFn = app_lifecycle.loadStartupStatus, + load_startup_state_with_auth_mode: LoadStartupStateWithAuthModeFn = app_lifecycle.loadStartupStateWithAuthMode, + load_catalog_startup_state_with_auth_mode: LoadCatalogStartupStateWithAuthModeFn = app_lifecycle.loadCatalogStartupStateWithAuthMode, + load_startup_status_with_auth_mode: LoadStartupStatusWithAuthModeFn = app_lifecycle.loadStartupStatusWithAuthMode, getenv: GetenvFn = getenvDefault, environ_map: EnvironMapFn = environMapDefault, self_exe_path: SelfExePathFn = selfExePathDefault, @@ -641,6 +648,11 @@ fn writeProviderActivationError( try writeStderr(deps, message); } +fn writeHostManagedAuthResult(deps: RunDeps) !void { + try writeStdout(deps, credentials.host_managed_auth_message); + try writeStdout(deps, "\n"); +} + fn activateProviderSelection( alloc: Allocator, cfg: Config, @@ -659,17 +671,22 @@ fn activateProviderSelection( defer settings.deinit(alloc); const preferred_source = exact_source orelse settings.credential_source; - var prepared_credential = try auth_runtime.prepareCredential( - alloc, - cfg.gateway_provider.oauth_transport, - cfg.secret_store, - target, - preferred_source, - ); + var prepared_credential = if (cfg.auth_mode == .host_managed) + null + else + try auth_runtime.prepareCredential( + alloc, + cfg.gateway_provider.oauth_transport, + cfg.secret_store, + target, + preferred_source, + ); defer if (prepared_credential) |*credential| credential.deinit(alloc); const already_selected = (settings.provider orelse .gateway) == target; - if (caller == .provider_command and already_selected and prepared_credential != null) { + if (caller == .provider_command and already_selected and + (cfg.auth_mode == .host_managed or prepared_credential != null)) + { try writeStdout(deps, switch (target) { .gateway => "Gateway is already selected.\n", .codex => "Codex is already selected.\n", @@ -679,7 +696,7 @@ fn activateProviderSelection( } var performed_login: ?model_provider.ProviderId = null; - if (prepared_credential == null and target == .codex and caller == .provider_command) { + if (cfg.auth_mode == .local and prepared_credential == null and target == .codex and caller == .provider_command) { chatgpt_oauth.runLogin(alloc, cfg.gateway_provider.oauth_transport, cfg.url_opener) catch |err| { debug_trace.logf("auth", "provider selection Codex login failed err={s}", .{@errorName(err)}); try writeProviderActivationError(alloc, deps, caller, "Codex login failed"); @@ -694,7 +711,7 @@ fn activateProviderSelection( preferred_source, ); } - if (prepared_credential == null and target == .grok and caller == .provider_command) { + if (cfg.auth_mode == .local and prepared_credential == null and target == .grok and caller == .provider_command) { grok_oauth.runLogin(alloc, cfg.gateway_provider.oauth_transport, cfg.url_opener) catch |err| { debug_trace.logf("auth", "provider selection Grok login failed err={s}", .{@errorName(err)}); try writeProviderActivationError(alloc, deps, caller, "Grok login failed"); @@ -710,7 +727,11 @@ fn activateProviderSelection( ); } - const credential = if (prepared_credential) |*value| value else { + const credential = if (cfg.auth_mode == .host_managed) + null + else if (prepared_credential) |*value| + value + else { try writeProviderActivationError( alloc, deps, @@ -732,10 +753,13 @@ fn activateProviderSelection( return false; }; const fetch_result = model_catalog.fetchWithPublicFallback(catalog_provider, alloc, .{ - .access = credentials.catalogAccessAt( - credential.*, - io_mod.milliTimestamp(), - ).withExplicitAuthority(), + .access = if (cfg.auth_mode == .host_managed) + .host_managed + else + credentials.catalogAccessAt( + credential.?.*, + io_mod.milliTimestamp(), + ).withExplicitAuthority(), .endpoint = cfg.models_path, .view = .picker, }); @@ -884,6 +908,7 @@ fn runNonInteractiveWithDeps( return .handled_failure; }; try cfg.acp_runner.run(alloc, .{ + .auth_mode = cfg.auth_mode, .default_model = cfg.default_model, .default_agent_step_limit = cfg.default_agent_step_limit, .gateway_retry_count = cfg.gateway_retry_count, @@ -919,6 +944,10 @@ fn runNonInteractiveWithDeps( try writeStderr(deps, "usage: fx login [vercel|codex|grok]\n"); return .handled_failure; }; + if (cfg.auth_mode == .host_managed) { + try writeHostManagedAuthResult(deps); + return .handled_success; + } // Preserve the original `fx login` behavior for scripts and users. const login_provider = maybe_login_provider orelse .gateway; switch (login_provider) { @@ -990,6 +1019,10 @@ fn runNonInteractiveWithDeps( try writeStderr(deps, "usage: fx logout [vercel|codex|grok]\n"); return .handled_failure; }; + if (cfg.auth_mode == .host_managed) { + try writeHostManagedAuthResult(deps); + return .handled_success; + } // Preserve the original `fx logout` behavior for scripts and users. const login_provider = maybe_login_provider orelse .gateway; if (login_provider == .codex) { @@ -1079,6 +1112,10 @@ fn runNonInteractiveWithDeps( try writeStderr(deps, "usage: fx teams\n"); return .handled_failure; } + if (cfg.auth_mode == .host_managed) { + try writeHostManagedAuthResult(deps); + return .handled_success; + } var validation_context = CliTeamValidationContext{ .alloc = alloc, .cfg = &cfg }; login_flow.runTeams( alloc, @@ -1129,6 +1166,10 @@ fn runNonInteractiveWithDeps( try writeTopLevelUsage(cfg.command_catalog, deps, .setup); return .handled_failure; } + if (cfg.auth_mode == .host_managed) { + try writeHostManagedAuthResult(deps); + return .handled_success; + } return if (try runPasteSetup(alloc, cfg.secret_store, deps)) .handled_success else .handled_failure; }, .status => |rest| { @@ -1136,12 +1177,21 @@ fn runNonInteractiveWithDeps( try writeUsageOrJsonError(alloc, cfg.command_catalog, deps, .status, "status", err, rest); return .handled_failure; }; - var startup = try deps.load_startup_status( - alloc, - cfg.secret_store, - cfg.default_model, - cfg.default_agent_step_limit, - ); + var startup = if (cfg.auth_mode == .host_managed) + try deps.load_startup_status_with_auth_mode( + alloc, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + cfg.auth_mode, + ) + else + try deps.load_startup_status( + alloc, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + ); defer startup.deinit(alloc); try writeConfigDiagnostics(alloc, deps, startup.config_diagnostics); var mcp_inspection = try cfg.inspect_mcp_local_config( @@ -1197,13 +1247,22 @@ fn runNonInteractiveWithDeps( return .handled_failure; }; - var startup = try deps.load_startup_state( - alloc, - cfg.gateway_provider.oauth_transport, - cfg.secret_store, - cfg.default_model, - cfg.default_agent_step_limit, - ); + var startup = if (cfg.auth_mode == .host_managed) + try deps.load_catalog_startup_state_with_auth_mode( + alloc, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + cfg.auth_mode, + ) + else + try deps.load_startup_state( + alloc, + cfg.gateway_provider.oauth_transport, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + ); defer startup.deinit(alloc); try writeConfigDiagnostics(alloc, deps, startup.config_diagnostics); @@ -1555,13 +1614,23 @@ fn runNonInteractiveWithDeps( try writeUsageOrJsonError(alloc, cfg.command_catalog, deps, .credits, "credits", err, rest); return .handled_failure; }; - var startup = try deps.load_startup_state( - alloc, - cfg.gateway_provider.oauth_transport, - cfg.secret_store, - cfg.default_model, - cfg.default_agent_step_limit, - ); + var startup = if (cfg.auth_mode == .host_managed) + try deps.load_startup_state_with_auth_mode( + alloc, + cfg.gateway_provider.oauth_transport, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + cfg.auth_mode, + ) + else + try deps.load_startup_state( + alloc, + cfg.gateway_provider.oauth_transport, + cfg.secret_store, + cfg.default_model, + cfg.default_agent_step_limit, + ); defer startup.deinit(alloc); try writeConfigDiagnostics(alloc, deps, startup.config_diagnostics); @@ -1569,7 +1638,12 @@ fn runNonInteractiveWithDeps( gateway_provider.unavailable_credits_provider; var snapshot = credits.fetch(alloc, .{ .credential = startup.apiKey(), - .credential_source = if (startup.credential) |credential| credential.source else null, + .credential_source = if (startup.auth_mode == .host_managed) + .host_managed + else if (startup.credential) |credential| + credential.source + else + null, .tenant = startup.gatewayTeam(), }); defer snapshot.deinit(alloc); @@ -3066,6 +3140,7 @@ test "session recovery boundary failures keep stable text and json guidance" { fn workflowConfig(cfg: Config) @import("cli_ask.zig").Config { return .{ + .auth_mode = cfg.auth_mode, .command_usage = command_specs.topLevelUsage(cfg.command_catalog, .ask), .default_model = cfg.default_model, .default_agent_step_limit = cfg.default_agent_step_limit, diff --git a/src/core/config/model_provider.zig b/src/core/config/model_provider.zig index 93b0168a7..219438bbb 100644 --- a/src/core/config/model_provider.zig +++ b/src/core/config/model_provider.zig @@ -21,6 +21,7 @@ pub fn parse(value: []const u8) ?ProviderId { pub fn authorizesCredential(provider: ProviderId, source: ?types.CredentialSource) bool { const selected = source orelse return false; + if (selected == .host_managed) return true; return switch (provider) { .gateway => selected != .chatgpt_subscription and selected != .grok_subscription, .codex => selected == .chatgpt_subscription, diff --git a/src/core/permissions/auto_classifier.zig b/src/core/permissions/auto_classifier.zig index 176e77efe..523bf55c1 100644 --- a/src/core/permissions/auto_classifier.zig +++ b/src/core/permissions/auto_classifier.zig @@ -268,7 +268,7 @@ pub const ReviewTurnContext = struct { pending_assistant: types.ChatMessage, target_call_id: []const u8, origin: ReviewOrigin, - credential: types.CredentialLease = .{}, + credential: types.CredentialLease = .{ .direct = .{} }, /// Canonical root-user context for contextual security review. Assistant, /// tool, repository, attachment, and permission-feedback text never become /// authority. diff --git a/src/core/session/generation_usage_provider.zig b/src/core/session/generation_usage_provider.zig index 19ad021b1..216231968 100644 --- a/src/core/session/generation_usage_provider.zig +++ b/src/core/session/generation_usage_provider.zig @@ -4,7 +4,7 @@ const model_provider = @import("../config/model_provider.zig"); const Allocator = std.mem.Allocator; pub const LookupInput = struct { - credential: []const u8, + credential: ?[]const u8, tenant: ?[]const u8, origin: []const u8, generation_id: []const u8, @@ -112,7 +112,8 @@ test "generation usage lookup dispatches through the injected provider" { const self: *@This() = @ptrCast(@alignCast(raw_context.?)); self.calls += 1; self.saw_expected_input = - std.mem.eql(u8, "credential", input.credential) and + input.credential != null and + std.mem.eql(u8, "credential", input.credential.?) and std.mem.eql(u8, "generation", input.generation_id); const id = try alloc.dupe(u8, input.generation_id); errdefer alloc.free(id); @@ -150,6 +151,24 @@ test "generation usage lookup dispatches through the injected provider" { try std.testing.expectEqualStrings("provider/model", outcome.found.model); } +test "generation usage lookup can defer authentication to the host" { + const Fake = struct { + fn lookup(_: ?*anyopaque, _: Allocator, input: LookupInput) LookupError!LookupOutcome { + if (input.credential != null) return error.Unavailable; + return .preserve_pending; + } + }; + var cancel = std.atomic.Value(bool).init(false); + const outcome = try (Provider{ .lookup_fn = Fake.lookup }).lookup(std.testing.allocator, .{ + .credential = null, + .tenant = null, + .origin = "https://ai-gateway.vercel.sh", + .generation_id = "gen_01ARZ3NDEKTSV4RRFFQ69G5FAV", + .cancel_flag = &cancel, + }); + try std.testing.expectEqual(LookupOutcome.preserve_pending, outcome); +} + test "generation usage providers are selected by provider identity" { const routes = Set.gatewayOnly(unavailable_provider); try std.testing.expect(routes.select(.gateway) != null); diff --git a/src/core/session/session_codec.zig b/src/core/session/session_codec.zig index 00dd6da43..dd27ed6ba 100644 --- a/src/core/session/session_codec.zig +++ b/src/core/session/session_codec.zig @@ -1061,7 +1061,7 @@ fn parseTurnAuthority(alloc: Allocator, value: std.json.Value) !TurnAuthority { errdefer alloc.free(model); const credential_source = if (object.get("credential_source")) |source| switch (source) { .null => null, - .string => |text| types.parseCredentialSource(text) orelse return error.InvalidDurableField, + .string => |text| types.parseRuntimeCredentialSource(text) orelse return error.InvalidDurableField, else => return error.InvalidDurableField, } else return error.InvalidSessionFormat; const credential_identity = if (object.get("credential_identity")) |identity| switch (identity) { diff --git a/src/core/session/session_usage.zig b/src/core/session/session_usage.zig index 37fb44fb9..af545d545 100644 --- a/src/core/session/session_usage.zig +++ b/src/core/session/session_usage.zig @@ -1766,6 +1766,23 @@ pub const Usage = struct { ); } + pub fn startHostManagedDeferredReconciliation( + self: *Usage, + alloc: Allocator, + reference: stream_provider.DeferredUsageReference, + ) void { + self.startReconciliationWithCredential( + alloc, + null, + .{ + .provider = reference.provider, + .credential_identity = reference.credential_identity, + }, + false, + null, + ); + } + /// Installs the host's authoritative credential regardless of the prior key. pub fn replaceReconciliationCredential( self: *Usage, @@ -1823,6 +1840,23 @@ pub const Usage = struct { ); } + pub fn replaceHostManagedReconciliationAuthority( + self: *Usage, + alloc: Allocator, + provider: model_provider.ProviderId, + ) void { + self.startReconciliationWithCredential( + alloc, + null, + .{ + .provider = provider, + .credential_identity = credential_authority.derive(.host_managed, null), + }, + true, + null, + ); + } + /// Replaces a producer's key only while that key is still authoritative. pub fn refreshReconciliationCredential( self: *Usage, @@ -1859,13 +1893,16 @@ pub const Usage = struct { fn startReconciliationWithCredential( self: *Usage, alloc: Allocator, - api_key: []const u8, + credential: ?[]const u8, authority: ReconciliationAuthority, replace_existing: bool, expected_api_key: ?[]const u8, ) void { - if (api_key.len == 0) return; - const key_digest = reconciliationKeyDigest(api_key); + if (credential) |api_key| if (api_key.len == 0) return; + const key_digest = if (credential) |api_key| + reconciliationKeyDigest(api_key) + else + hostManagedReconciliationDigest(); const expected_digest = if (expected_api_key) |expected| reconciliationKeyDigest(expected) else @@ -1916,21 +1953,24 @@ pub const Usage = struct { self.mutex.unlock(io_mod.getIo()); if (!still_has_pending) return; - const api_key_copy = alloc.dupe(u8, api_key) catch |err| { - debug_trace.logf( - "session", - "usage reconciliation start failed reason={s}", - .{@errorName(err)}, - ); - return; - }; + const credential_copy = if (credential) |api_key| + alloc.dupe(u8, api_key) catch |err| { + debug_trace.logf( + "session", + "usage reconciliation start failed reason={s}", + .{@errorName(err)}, + ); + return; + } + else + null; self.reconciliation_cancel.store(false, .seq_cst); self.reconciliation_done.store(false, .seq_cst); self.reconciliation_key_digest = key_digest; self.reconciliation_thread = std.Thread.spawn( .{}, reconciliationThreadMain, - .{ self, alloc, api_key_copy, authority, self.generation_usage_providers }, + .{ self, alloc, credential_copy, authority, self.generation_usage_providers }, ) catch |err| { self.reconciliation_done.store(true, .seq_cst); debug_trace.logf( @@ -1938,7 +1978,7 @@ pub const Usage = struct { "usage reconciliation start failed reason={s}", .{@errorName(err)}, ); - secret.zeroAndFree(alloc, api_key_copy); + if (credential_copy) |api_key| secret.zeroAndFree(alloc, api_key); return; }; } @@ -2991,18 +3031,18 @@ fn writeOptionalU64(writer: *std.Io.Writer, value: ?u64) !void { fn reconciliationThreadMain( usage: *Usage, alloc: Allocator, - api_key: []u8, + credential: ?[]u8, authority: ReconciliationAuthority, providers: generation_usage.Set, ) void { - defer secret.zeroAndFree(alloc, api_key); + defer if (credential) |api_key| secret.zeroAndFree(alloc, api_key); defer usage.reconciliation_done.store(true, .seq_cst); var observed_epoch = usage.reconciliation_work_epoch.load(.seq_cst); while (!usage.reconciliation_cancel.load(.seq_cst)) { reconcilePendingBlocking( usage, alloc, - api_key, + credential, &usage.reconciliation_cancel, authority, providers, @@ -3029,16 +3069,21 @@ fn reconciliationKeyDigest(api_key: []const u8) [Sha256.digest_length]u8 { return digest; } +fn hostManagedReconciliationDigest() [Sha256.digest_length]u8 { + return reconciliationKeyDigest("fx-host-managed-auth-v1"); +} + fn reconcilePendingBlocking( usage: *Usage, alloc: Allocator, - api_key: []const u8, + credential: ?[]const u8, cancel_flag: *std.atomic.Value(bool), authority: ReconciliationAuthority, providers: generation_usage.Set, max_attempts: usize, ) void { - if (api_key.len == 0 or cancel_flag.load(.seq_cst)) return; + if (credential) |api_key| if (api_key.len == 0) return; + if (cancel_flag.load(.seq_cst)) return; var attempt: usize = 0; while (attempt < max_attempts and !cancel_flag.load(.seq_cst)) : (attempt += 1) { var current = usage.snapshot(alloc) catch |err| { @@ -3068,7 +3113,7 @@ fn reconcilePendingBlocking( continue; }; var outcome = provider.lookup(alloc, .{ - .credential = api_key, + .credential = credential, .tenant = pending.team, .origin = pending.origin, .generation_id = pending.id, @@ -3473,7 +3518,7 @@ fn parseCredentialSourceOptional(value: ?std.json.Value) !?types.CredentialSourc const actual = value orelse return error.InvalidUsageSnapshot; return switch (actual) { .null => null, - .string => |text| types.parseCredentialSource(text) orelse return error.InvalidUsageSnapshot, + .string => |text| types.parseRuntimeCredentialSource(text) orelse return error.InvalidUsageSnapshot, else => error.InvalidUsageSnapshot, }; } @@ -3562,6 +3607,34 @@ test "usage snapshot JSON round trips" { try std.testing.expectEqual(snapshot.wall_duration_ms, decoded.wall_duration_ms); } +test "host-managed deferred usage authority round trips" { + const alloc = std.testing.allocator; + var usage = Usage.initFresh(); + defer usage.deinit(alloc); + const identity = credential_authority.derive(.host_managed, null).?; + const observation = try InvocationObservation.begin(&usage); + try observation.complete(alloc, .{}, .{ .deferred = .{ + .provider = .gateway, + .generation_id = "gen_01ARZ3NDEKTSV4RRFFQ69G5FAV", + .scope = "https://ai-gateway.vercel.sh", + .credential_source = .host_managed, + .credential_identity = identity, + } }); + + var snapshot = try usage.snapshot(alloc); + defer snapshot.deinit(alloc); + var encoded: std.Io.Writer.Allocating = .init(alloc); + defer encoded.deinit(); + try writeSnapshot(&encoded.writer, snapshot); + var parsed = try std.json.parseFromSlice(std.json.Value, alloc, encoded.written(), .{}); + defer parsed.deinit(); + var decoded = try parseSnapshotValue(alloc, parsed.value); + defer decoded.deinit(alloc); + + try std.testing.expectEqual(types.CredentialSource.host_managed, decoded.pending[0].credential_source.?); + try std.testing.expect(decoded.pending[0].credential_identity.?.eql(identity)); +} + test "profile recovery hint follows unresolved durable usage" { const alloc = std.testing.allocator; var usage = Usage.initFresh(); @@ -4635,7 +4708,8 @@ const TestGenerationUsageProvider = struct { const self: *@This() = @ptrCast(@alignCast(raw_context.?)); self.calls += 1; self.saw_expected_input = - std.mem.eql(u8, input.credential, "credential") and + input.credential != null and + std.mem.eql(u8, input.credential.?, "credential") and std.mem.eql( u8, input.generation_id, @@ -5794,3 +5868,17 @@ test "resumed provider reconciliation uses Gateway credential slot identity" { try std.testing.expect(usage.reconciliation_authority == null); try std.testing.expect(usage.reconciliation_credential_blocked); } + +test "host-managed reconciliation records authority without credential bytes" { + var usage = Usage.initFresh(); + defer usage.deinit(std.testing.allocator); + + usage.replaceHostManagedReconciliationAuthority(std.testing.allocator, .gateway); + + try std.testing.expect(usage.reconciliation_key_digest != null); + try std.testing.expectEqual(model_provider.ProviderId.gateway, usage.reconciliation_authority.?.provider); + try std.testing.expect(usage.reconciliation_authority.?.credential_identity.?.eql( + credential_authority.derive(.host_managed, null).?, + )); + try std.testing.expect(!usage.reconciliation_credential_blocked); +} diff --git a/src/core/shared/types.zig b/src/core/shared/types.zig index 77e159274..5e1dcc96b 100644 --- a/src/core/shared/types.zig +++ b/src/core/shared/types.zig @@ -94,26 +94,90 @@ pub const CredentialSource = enum { stored_key, chatgpt_subscription, grok_subscription, + host_managed, }; -pub const CredentialLease = struct { - secret: []const u8 = "", +pub const DirectCredentialLease = struct { + secret_bytes: []const u8 = "", source: ?CredentialSource = null, account_id: ?[]const u8 = null, - tenant: ?[]const u8 = null, + tenant_context: ?[]const u8 = null, }; +/// Borrowed authorization for one provider request. Host-managed requests +/// cannot carry credential or account bytes into the embedded runtime. +pub const CredentialLease = union(enum) { + direct: DirectCredentialLease, + host_managed, + + pub fn secret(self: CredentialLease) ?[]const u8 { + return switch (self) { + .direct => |direct| if (direct.secret_bytes.len > 0) direct.secret_bytes else null, + .host_managed => null, + }; + } + + pub fn credentialSource(self: CredentialLease) ?CredentialSource { + return switch (self) { + .direct => |direct| direct.source, + .host_managed => .host_managed, + }; + } + + pub fn accountId(self: CredentialLease) ?[]const u8 { + return switch (self) { + .direct => |direct| direct.account_id, + .host_managed => null, + }; + } + + pub fn tenant(self: CredentialLease) ?[]const u8 { + return switch (self) { + .direct => |direct| direct.tenant_context, + .host_managed => null, + }; + } +}; + +test "host-managed credential lease carries no local authority bytes" { + const lease: CredentialLease = .host_managed; + try std.testing.expect(lease.secret() == null); + try std.testing.expect(lease.accountId() == null); + try std.testing.expect(lease.tenant() == null); + try std.testing.expectEqual(CredentialSource.host_managed, lease.credentialSource().?); +} + +test "empty direct credential lease preserves absent authority" { + const lease = CredentialLease{ .direct = .{} }; + try std.testing.expect(lease.secret() == null); + try std.testing.expect(lease.credentialSource() == null); +} + pub fn parseCredentialSource(text: []const u8) ?CredentialSource { + const source = parseRuntimeCredentialSource(text) orelse return null; + return if (source == .host_managed) null else source; +} + +pub fn parseRuntimeCredentialSource(text: []const u8) ?CredentialSource { return std.meta.stringToEnum(CredentialSource, text); } test "credential source round trips through its persisted name" { for (std.meta.tags(CredentialSource)) |source| { + if (source == .host_managed) continue; try std.testing.expectEqual(source, parseCredentialSource(@tagName(source)).?); } try std.testing.expect(parseCredentialSource("keychain") == null); } +test "host-managed authority is runtime-only and cannot be persisted" { + try std.testing.expect(parseCredentialSource("host_managed") == null); + try std.testing.expectEqual( + CredentialSource.host_managed, + parseRuntimeCredentialSource("host_managed").?, + ); +} + pub const TurnPresentationOutcome = enum { completed, interrupted, diff --git a/src/core/tooling/tool_runtime.zig b/src/core/tooling/tool_runtime.zig index f8f04e4ec..60afc01e0 100644 --- a/src/core/tooling/tool_runtime.zig +++ b/src/core/tooling/tool_runtime.zig @@ -275,6 +275,7 @@ pub const Context = struct { return permission_auto_classifier.Classifier.disabled(); return permission_auto_classifier.Classifier.withProvider(provider, .{ .credential = self.api_key, + .credential_source = self.credential_source, .account_id = self.account_id, .tenant = self.gateway_team, .endpoint = self.gateway_chat_url, @@ -6715,8 +6716,8 @@ const VisionGatewayFixture = struct { const payload = try test_builtin_gateway.buildAgentRequest(self.alloc, request.data()); defer self.alloc.free(payload); try self.payloads.append(self.alloc, try self.alloc.dupe(u8, payload)); - self.last_api_key = request.credential.secret; - self.last_team = request.credential.tenant; + self.last_api_key = request.credential.secret() orelse ""; + self.last_team = request.credential.tenant(); self.last_model = request.model; self.last_retry_count = request.retry_count; if (self.cancel_after_call == self.call_count) request.cancel_flag.store(true, .seq_cst); @@ -6725,6 +6726,8 @@ const VisionGatewayFixture = struct { try request.admission.admit(); request.delivery.markPossiblySent(); if (response.status != .ok) return .{ .failed = .{ .kind = .provider_error } }; + const credential_source = request.credential.credentialSource() orelse + return error.MissingCredentialSource; return .{ .completed = .{ .completion = .{ .content = response.content, @@ -6736,11 +6739,11 @@ const VisionGatewayFixture = struct { .provider = .gateway, .generation_id = response.generation_id orelse "gen_test", .scope = "https://ai-gateway.vercel.sh", - .tenant = request.credential.tenant, - .credential_source = request.credential.source orelse .ai_gateway_api_key, + .tenant = request.credential.tenant(), + .credential_source = credential_source, .credential_identity = credential_authority.derive( - request.credential.source orelse .ai_gateway_api_key, - request.credential.account_id, + credential_source, + request.credential.accountId(), ), } }, } }; diff --git a/src/gateway/client.zig b/src/gateway/client.zig index b6f5765c1..9835a0297 100644 --- a/src/gateway/client.zig +++ b/src/gateway/client.zig @@ -262,7 +262,7 @@ pub fn fetchGatewayGetResult(alloc: std.mem.Allocator, api_key: ?[]const u8, pat pub fn fetchGatewayGenerationResult( alloc: std.mem.Allocator, - api_key: []const u8, + api_key: ?[]const u8, gateway_team: ?[]const u8, gateway_origin: []const u8, generation_id: []const u8, @@ -290,7 +290,7 @@ pub fn fetchGatewayGenerationResult( const GenerationLookupOperation = struct { alloc: std.mem.Allocator, - api_key: []const u8, + api_key: ?[]const u8, gateway_team: ?[]const u8, gateway_origin: []const u8, generation_id: []const u8, @@ -315,23 +315,23 @@ const GenerationLookupOperation = struct { .io = io_mod.getIo(), }; defer client.deinit(); - const auth_header = try std.fmt.allocPrint( - self.alloc, - "Bearer {s}", - .{self.api_key}, - ); - defer secret.zeroAndFree(self.alloc, auth_header); + var auth_header: ?[]u8 = null; + defer if (auth_header) |value| secret.zeroAndFree(self.alloc, value); + var headers: std.http.Client.Request.Headers = .{ + .accept_encoding = .omit, + .user_agent = .{ .override = user_agent }, + }; + if (self.api_key) |api_key| { + auth_header = try std.fmt.allocPrint(self.alloc, "Bearer {s}", .{api_key}); + headers.authorization = .{ .override = auth_header.? }; + } var extra_headers_buf: [1]std.http.Header = undefined; const extra_headers = gatewayModelCatalogExtraHeaders( &extra_headers_buf, self.gateway_team, ); var req = try client.request(.GET, uri, .{ - .headers = .{ - .authorization = .{ .override = auth_header }, - .accept_encoding = .omit, - .user_agent = .{ .override = user_agent }, - }, + .headers = headers, .extra_headers = extra_headers, .redirect_behavior = .unhandled, }); @@ -1137,7 +1137,7 @@ test "connection setup policy bounds retry by deadline attempts and delivery" { } pub const StreamRequest = struct { - api_key: []const u8, + api_key: ?[]const u8, model: []const u8, retry_count: usize, chat_url: []const u8, @@ -1391,8 +1391,17 @@ fn streamGatewayCompletionCoreWithOptions( const request_url = try resolveE2eGatewayUrl(e2e_gateway_chat_url_env, request.chat_url); const uri = try std.Uri.parse(request_url); - const auth_header = try std.fmt.allocPrint(alloc, "Bearer {s}", .{request.api_key}); - defer alloc.free(auth_header); + var auth_header: ?[]u8 = null; + defer if (auth_header) |value| secret.zeroAndFree(alloc, value); + var request_headers: std.http.Client.Request.Headers = .{ + .content_type = .{ .override = "application/json" }, + .accept_encoding = .omit, + .user_agent = .{ .override = user_agent }, + }; + if (request.api_key) |api_key| { + auth_header = try std.fmt.allocPrint(alloc, "Bearer {s}", .{api_key}); + request_headers.authorization = .{ .override = auth_header.? }; + } var extra_headers_buf: [9]std.http.Header = undefined; const extra_headers = gatewayExtraHeaders( @@ -1426,12 +1435,7 @@ fn streamGatewayCompletionCoreWithOptions( debug_trace.eventf("gateway", "before_http_open_connect", trace_ctx, "attempt={d} attempt_limit={d} retries_used={d}", .{ attempt + 1, retry_count, attempt }); debug_trace.eventf("gateway", "before_request_open", trace_ctx, "attempt={d} attempt_limit={d} retries_used={d} payload_bytes={d}", .{ attempt + 1, retry_count, attempt, payload.len }); var req = openGatewayRequestBounded(&client, uri, .{ - .headers = .{ - .content_type = .{ .override = "application/json" }, - .authorization = .{ .override = auth_header }, - .accept_encoding = .omit, - .user_agent = .{ .override = user_agent }, - }, + .headers = request_headers, .extra_headers = extra_headers, .keep_alive = false, .redirect_behavior = .unhandled, @@ -7870,3 +7874,43 @@ test "gateway chat request sends fx user agent and attribution headers" { try std.testing.expectEqualStrings("session_wire_123", fixture.capturedHeaderValue("x-session-affinity").?); try std.testing.expect(std.mem.find(u8, fixture.capturedHeaderValue("user-agent").?, "zig") == null); } + +test "host-managed Gateway chat omits authentication-owned headers" { + var fixture = try LoopbackGatewayFixture.init(.success_capture, 0); + defer fixture.deinit(); + try fixture.start(); + try std.testing.expect(fixture.waitForAcceptStart(5000)); + + const url = try std.fmt.allocPrint(std.testing.allocator, "http://127.0.0.1:{d}/chat", .{fixture.port()}); + defer std.testing.allocator.free(url); + + const Noop = struct { + fn onChunk(_: *anyopaque, _: []const u8) void {} + }; + var callback_ctx: u8 = 0; + var cancel_flag = std.atomic.Value(bool).init(false); + var result = try streamGatewayCompletionCore( + std.testing.allocator, + .{ + .api_key = null, + .model = "test/model", + .retry_count = 1, + .chat_url = url, + .payload = "{}", + .team = null, + }, + @ptrCast(&callback_ctx), + Noop.onChunk, + null, + &cancel_flag, + null, + false, + ); + defer result.deinit(std.testing.allocator); + fixture.deinit(); + + if (fixture.failure) |err| return err; + try std.testing.expect(fixture.capturedHeaderValue("authorization") == null); + try std.testing.expect(fixture.capturedHeaderValue(vercel_ai_gateway_team_header) == null); + try std.testing.expectEqualStrings(user_agent, fixture.capturedHeaderValue("user-agent").?); +} diff --git a/src/gateway/host_stream_provider.zig b/src/gateway/host_stream_provider.zig index 2d08553a3..8894e0bd6 100644 --- a/src/gateway/host_stream_provider.zig +++ b/src/gateway/host_stream_provider.zig @@ -73,15 +73,17 @@ fn stream(raw: ?*anyopaque, alloc: Allocator, request: stream_provider.ModelRequ const payload = request.prepared_request_body orelse try context.build_fn(alloc, request.data()); defer if (request.prepared_request_body == null) alloc.free(payload); - const auth = try std.fmt.allocPrint(alloc, "Bearer {s}", .{request.credential.secret}); - defer alloc.free(auth); + const auth = if (request.credential.secret()) |credential| + try std.fmt.allocPrint(alloc, "Bearer {s}", .{credential}) + else + null; + defer if (auth) |value| alloc.free(value); const Header = struct { name: []const u8, value: []const u8 }; var headers: std.ArrayList(Header) = .empty; defer headers.deinit(alloc); try headers.appendSlice(alloc, &.{ .{ .name = "content-type", .value = "application/json" }, - .{ .name = "authorization", .value = auth }, .{ .name = "HTTP-Referer", .value = "https://github.com/vercel-labs/fx" }, .{ .name = "X-Title", .value = "fx" }, .{ .name = "ai-gateway-protocol-version", .value = "0.0.1" }, @@ -89,7 +91,8 @@ fn stream(raw: ?*anyopaque, alloc: Allocator, request: stream_provider.ModelRequ .{ .name = "ai-language-model-id", .value = request.model }, .{ .name = "ai-language-model-streaming", .value = "true" }, }); - if (request.credential.tenant) |team| if (team.len > 0) try headers.append(alloc, .{ .name = "x-vercel-ai-gateway-team", .value = team }); + if (auth) |value| try headers.append(alloc, .{ .name = "authorization", .value = value }); + if (request.credential.tenant()) |team| if (team.len > 0) try headers.append(alloc, .{ .name = "x-vercel-ai-gateway-team", .value = team }); if (request.session_id) |session_id| if (session_id.len > 0) try headers.appendSlice(alloc, &.{ .{ .name = "x-session-id", .value = session_id }, .{ .name = "x-session-affinity", .value = session_id }, @@ -191,17 +194,17 @@ fn gatewayUsageReference( completion: @import("../core/shared/types.zig").ModelCompletion, ) ?stream_provider.DeferredUsageReference { const generation_id = completion.generation_id orelse return null; - const source = request.credential.source orelse return null; + const source = request.credential.credentialSource() orelse return null; return .{ .provider = .gateway, .generation_id = generation_id, .scope = gateway_client.generationBaseUrl(), - .tenant = request.credential.tenant, - .account_id = request.credential.account_id, + .tenant = request.credential.tenant(), + .account_id = request.credential.accountId(), .credential_source = source, .credential_identity = credential_authority.derive( source, - request.credential.account_id, + request.credential.accountId(), ), }; } diff --git a/src/gateway/openai_codex.zig b/src/gateway/openai_codex.zig index 8914a65f8..343ce1069 100644 --- a/src/gateway/openai_codex.zig +++ b/src/gateway/openai_codex.zig @@ -143,7 +143,9 @@ fn streamCompletion( request: stream_provider.ModelRequest, ) !stream_provider.Result { if (request.cancel_flag.load(.seq_cst)) return stream_provider.failResult(error.Cancelled); - if (request.credential.source != .chatgpt_subscription) { + if (request.credential.credentialSource() != .chatgpt_subscription and + request.credential.credentialSource() != .host_managed) + { return stream_provider.failResult(error.CodexSubscriptionCredentialRequired); } try validateModel(request.model); @@ -199,17 +201,20 @@ const OpenedRequest = struct { const OpenRequestOperation = struct { client: *std.http.Client, uri: std.Uri, - auth_header: []const u8, + auth_header: ?[]const u8, extra_headers: []const std.http.Header, pub fn run(self: *@This()) !OpenedRequest { + var headers: std.http.Client.Request.Headers = .{ + .content_type = .{ .override = "application/json" }, + .accept_encoding = .omit, + .user_agent = .{ .override = gateway_client.user_agent }, + }; + if (self.auth_header) |authorization| { + headers.authorization = .{ .override = authorization }; + } return .{ .request = try self.client.request(.POST, self.uri, .{ - .headers = .{ - .content_type = .{ .override = "application/json" }, - .authorization = .{ .override = self.auth_header }, - .accept_encoding = .omit, - .user_agent = .{ .override = gateway_client.user_agent }, - }, + .headers = headers, .extra_headers = self.extra_headers, .keep_alive = false, .redirect_behavior = .unhandled, @@ -217,19 +222,47 @@ const OpenRequestOperation = struct { } }; +const RequestAuthHeaders = struct { + authorization: ?[]u8 = null, + account_id: ?[]u8 = null, + + fn deinit(self: *RequestAuthHeaders, alloc: Allocator) void { + if (self.authorization) |value| secret.zeroAndFree(alloc, value); + if (self.account_id) |value| alloc.free(value); + self.* = .{}; + } +}; + +fn requestAuthHeaders(alloc: Allocator, auth: stream_provider.CredentialLease) !RequestAuthHeaders { + return switch (auth) { + .host_managed => .{}, + .direct => |direct| blk: { + const authorization = try std.fmt.allocPrint(alloc, "Bearer {s}", .{direct.secret_bytes}); + errdefer secret.zeroAndFree(alloc, authorization); + const account_id = if (direct.account_id) |account| + try alloc.dupe(u8, account) + else + try chatgpt_oauth.extractAccountId(alloc, direct.secret_bytes); + errdefer alloc.free(account_id); + if (!types.validCredentialAccountId(account_id)) { + return error.InvalidChatGptSubscriptionAccount; + } + break :blk .{ + .authorization = authorization, + .account_id = account_id, + }; + }, + }; +} + pub fn streamPrepared( alloc: Allocator, request: stream_provider.ModelRequest, payload: []const u8, ) !stream_provider.Result { if (request.cancel_flag.load(.seq_cst)) return stream_provider.failResult(error.Cancelled); - const account_id = try chatgpt_oauth.extractAccountId(alloc, request.credential.secret); - defer alloc.free(account_id); - if (!types.validCredentialAccountId(account_id)) { - return stream_provider.failResult(error.InvalidChatGptSubscriptionAccount); - } - const auth_header = try std.fmt.allocPrint(alloc, "Bearer {s}", .{request.credential.secret}); - defer secret.zeroAndFree(alloc, auth_header); + var auth_headers = try requestAuthHeaders(alloc, request.credential); + defer auth_headers.deinit(alloc); const request_endpoint = if (io_mod.getenv(e2e_endpoint_env)) |override| endpoint: { if (!gateway_client.isLoopbackHttpUrl(override)) { return stream_provider.failResult(error.InvalidE2EOpenAICodexEndpoint); @@ -240,8 +273,10 @@ pub fn streamPrepared( var extra_headers_buf: [7]std.http.Header = undefined; var extra_count: usize = 0; - extra_headers_buf[extra_count] = .{ .name = "chatgpt-account-id", .value = account_id }; - extra_count += 1; + if (auth_headers.account_id) |account_id| { + extra_headers_buf[extra_count] = .{ .name = "chatgpt-account-id", .value = account_id }; + extra_count += 1; + } extra_headers_buf[extra_count] = .{ .name = "originator", .value = "fx" }; extra_count += 1; extra_headers_buf[extra_count] = .{ .name = "OpenAI-Beta", .value = "responses=experimental" }; @@ -260,7 +295,7 @@ pub fn streamPrepared( var open_operation = OpenRequestOperation{ .client = &client, .uri = uri, - .auth_header = auth_header, + .auth_header = auth_headers.authorization, .extra_headers = extra_headers_buf[0..extra_count], }; const connect_deadline = std.Io.Clock.Timestamp.fromNow(io_mod.getIo(), .{ @@ -699,7 +734,7 @@ test "OpenAI Codex rejects a wrong-origin credential before network I/O" { try std.testing.expectError( error.CodexSubscriptionCredentialRequired, agent_stream_provider.stream(std.testing.allocator, .{ - .credential = .{ .secret = "gateway-key", .source = .ai_gateway_api_key }, + .credential = .{ .direct = .{ .secret_bytes = "gateway-key", .source = .ai_gateway_api_key } }, .model = "gpt-5.6-sol", .retry_count = 1, .messages = &.{}, @@ -718,6 +753,14 @@ test "OpenAI Codex rejects a wrong-origin credential before network I/O" { try std.testing.expectEqual(stream_provider.DeliveryCertainty.State.definitely_unsent, delivery.load()); } +test "host-managed Codex request auth omits bearer and account headers" { + var headers = try requestAuthHeaders(std.testing.allocator, .host_managed); + defer headers.deinit(std.testing.allocator); + + try std.testing.expect(headers.authorization == null); + try std.testing.expect(headers.account_id == null); +} + test "OpenAI Codex SSE maps text reasoning tools and usage" { const sse_text = "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"reasoning\"}}\n\n" ++ diff --git a/src/gateway/openai_codex_models.zig b/src/gateway/openai_codex_models.zig index b6992e190..832f06a4e 100644 --- a/src/gateway/openai_codex_models.zig +++ b/src/gateway/openai_codex_models.zig @@ -1,5 +1,6 @@ const std = @import("std"); const chatgpt_oauth = @import("../core/auth/chatgpt_oauth.zig"); +const credentials = @import("../core/auth/credentials.zig"); const model_catalog = @import("../core/gateway/model_catalog.zig"); const gateway_provider = @import("../core/gateway/gateway_provider.zig"); const io_mod = @import("../core/shared/io.zig"); @@ -58,16 +59,17 @@ fn fetchCatalogForProvider( alloc: std.mem.Allocator, input: model_catalog.FetchInput, ) std.mem.Allocator.Error!model_catalog.ProviderResult { - if (input.access.credentialSource() != .chatgpt_subscription) { + const request_auth = catalogRequestAuth(input.access) orelse return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; - } - const credential = input.access.authorizationCredential() orelse - return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; - const account_id = chatgpt_oauth.extractAccountId(alloc, credential) catch |err| { - if (err == error.OutOfMemory) return error.OutOfMemory; - return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; - }; - defer alloc.free(account_id); + var owned_account_id: ?[]u8 = null; + defer if (owned_account_id) |account| alloc.free(account); + const account_id = request_auth.account_id orelse if (request_auth.credential) |credential| account: { + owned_account_id = chatgpt_oauth.extractAccountId(alloc, credential) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; + }; + break :account owned_account_id.?; + } else null; const request_url = modelsUrl(alloc) catch |err| { if (err == error.OutOfMemory) return error.OutOfMemory; return .{ .failure = .{ .category = .runtime } }; @@ -79,7 +81,7 @@ fn fetchCatalogForProvider( var operation = FetchOperation{ .alloc = alloc, .url = request_url, - .credential = credential, + .credential = request_auth.credential, .account_id = account_id, }; var response = gateway_client.runBoundedHttpOperation( @@ -120,6 +122,25 @@ fn fetchCatalogForProvider( return .{ .catalog = catalog }; } +const CatalogRequestAuth = struct { + credential: ?[]const u8 = null, + account_id: ?[]const u8 = null, +}; + +fn catalogRequestAuth(access: credentials.CatalogAccess) ?CatalogRequestAuth { + return switch (access) { + .host_managed => .{}, + .public_only => null, + .authenticated => |authenticated| if (authenticated.source == .chatgpt_subscription) + .{ + .credential = authenticated.credential, + .account_id = authenticated.account_id, + } + else + null, + }; +} + const FetchResponse = struct { status: std.http.Status, body: []u8, @@ -133,30 +154,40 @@ const FetchResponse = struct { const FetchOperation = struct { alloc: std.mem.Allocator, url: []const u8, - credential: []const u8, - account_id: []const u8, + credential: ?[]const u8, + account_id: ?[]const u8, pub fn run(self: *@This()) !FetchResponse { var client: std.http.Client = .{ .allocator = self.alloc, .io = io_mod.getIo() }; defer client.deinit(); - const auth_header = try std.fmt.allocPrint(self.alloc, "Bearer {s}", .{self.credential}); - defer secret.zeroAndFree(self.alloc, auth_header); + var auth_header: ?[]u8 = null; + defer if (auth_header) |value| secret.zeroAndFree(self.alloc, value); + var headers: std.http.Client.Request.Headers = .{ + .user_agent = .{ .override = gateway_client.user_agent }, + .accept_encoding = .omit, + }; + if (self.credential) |credential| { + auth_header = try std.fmt.allocPrint(self.alloc, "Bearer {s}", .{credential}); + headers.authorization = .{ .override = auth_header.? }; + } const body_buffer = try self.alloc.alloc(u8, max_catalog_bytes + 1); defer secret.zeroAndFree(self.alloc, body_buffer); var response_writer = std.Io.Writer.fixed(body_buffer); + var extra_headers: [3]std.http.Header = undefined; + var extra_len: usize = 0; + if (self.account_id) |account_id| { + extra_headers[extra_len] = .{ .name = "chatgpt-account-id", .value = account_id }; + extra_len += 1; + } + extra_headers[extra_len] = .{ .name = "originator", .value = "fx" }; + extra_len += 1; + extra_headers[extra_len] = .{ .name = "accept", .value = "application/json" }; + extra_len += 1; const result = client.fetch(.{ .location = .{ .url = self.url }, .method = .GET, - .headers = .{ - .authorization = .{ .override = auth_header }, - .user_agent = .{ .override = gateway_client.user_agent }, - .accept_encoding = .omit, - }, - .extra_headers = &.{ - .{ .name = "chatgpt-account-id", .value = self.account_id }, - .{ .name = "originator", .value = "fx" }, - .{ .name = "accept", .value = "application/json" }, - }, + .headers = headers, + .extra_headers = extra_headers[0..extra_len], .response_writer = &response_writer, .redirect_behavior = .unhandled, }) catch |err| switch (err) { @@ -324,3 +355,9 @@ test "Codex catalog URL uses the live-validated protocol compatibility version" try std.testing.expect(std.mem.find(u8, url, "client_version=0.148.0") != null); try std.testing.expect(std.mem.find(u8, url, "client_version=0.0.4") == null); } + +test "host-managed Codex catalog auth carries no local headers" { + const auth = catalogRequestAuth(.host_managed) orelse return error.TestExpectedHostManagedCatalogAuth; + try std.testing.expect(auth.credential == null); + try std.testing.expect(auth.account_id == null); +} diff --git a/src/gateway/responses_permission_reviewer.zig b/src/gateway/responses_permission_reviewer.zig index 3b2b41a34..0604d9dac 100644 --- a/src/gateway/responses_permission_reviewer.zig +++ b/src/gateway/responses_permission_reviewer.zig @@ -40,16 +40,15 @@ pub fn review( request: permission_auto_classifier.ReviewRequest, adapter: Adapter, ) !permission_auto_classifier.ParseOutcome { - if (input.credential.len == 0) { - return .{ .invalid = .provider_context_missing }; + if (reviewInputFailure(input, adapter.require_account)) |reason| { + return .{ .invalid = reason }; } - if (adapter.require_account and input.account_id == null) { - return .{ .invalid = .provider_context_missing }; + if (input.credential_source != .host_managed) { + adapter.validate_fn(alloc, input) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + return .{ .invalid = .provider_failed }; + }; } - adapter.validate_fn(alloc, input) catch |err| { - if (err == error.OutOfMemory) return error.OutOfMemory; - return .{ .invalid = .provider_failed }; - }; var runtime = Runtime{ .input = input, .adapter = adapter }; return permission_auto_classifier.Reviewer.withTransportModel( .{ @@ -63,6 +62,16 @@ pub fn review( ).review(alloc, request); } +fn reviewInputFailure( + input: permission_auto_classifier.ProviderInput, + require_account: bool, +) ?permission_auto_classifier.InvalidReason { + if (input.credential_source == .host_managed) return null; + if (input.credential.len == 0) return .provider_context_missing; + if (require_account and input.account_id == null) return .provider_context_missing; + return null; +} + fn buildReviewPayload( raw: *anyopaque, alloc: Allocator, @@ -126,6 +135,12 @@ pub fn buildPayloadForTest( fn validateUnavailable(_: Allocator, _: permission_auto_classifier.ProviderInput) !void {} +test "host-managed permission review accepts absent local credential metadata" { + try std.testing.expect(reviewInputFailure(.{ + .credential_source = .host_managed, + }, true) == null); +} + const OwnedResult = struct { result: stream_provider.Result, }; @@ -175,12 +190,15 @@ fn sendReview( }; var callback_context: u8 = 0; var result = runtime.adapter.send_fn(alloc, .{ - .credential = .{ - .secret = runtime.input.credential, - .source = runtime.adapter.source, - .account_id = runtime.input.account_id, - .tenant = runtime.input.tenant, - }, + .credential = if (runtime.input.credential_source == .host_managed) + .host_managed + else + .{ .direct = .{ + .secret_bytes = runtime.input.credential, + .source = runtime.adapter.source, + .account_id = runtime.input.account_id, + .tenant_context = runtime.input.tenant, + } }, .model = model, .retry_count = 1, .messages = &.{}, diff --git a/src/gateway/xai_grok.zig b/src/gateway/xai_grok.zig index ca9b4e630..5450d6333 100644 --- a/src/gateway/xai_grok.zig +++ b/src/gateway/xai_grok.zig @@ -133,13 +133,17 @@ fn streamCompletion( request: stream_provider.ModelRequest, ) !stream_provider.Result { if (request.cancel_flag.load(.seq_cst)) return stream_provider.failResult(error.Cancelled); - if (request.credential.source != .grok_subscription) { + if (request.credential.credentialSource() != .grok_subscription and + request.credential.credentialSource() != .host_managed) + { return stream_provider.failResult(error.GrokSubscriptionCredentialRequired); } - const account_id = request.credential.account_id orelse - return stream_provider.failResult(error.GrokSubscriptionAccountRequired); - if (!grok_session.validAccountId(account_id)) { - return stream_provider.failResult(error.InvalidGrokSubscriptionAccount); + if (request.credential.credentialSource() != .host_managed) { + const account_id = request.credential.accountId() orelse + return stream_provider.failResult(error.GrokSubscriptionAccountRequired); + if (!grok_session.validAccountId(account_id)) { + return stream_provider.failResult(error.InvalidGrokSubscriptionAccount); + } } try validateModel(request.model); const payload = request.prepared_request_body orelse @@ -182,17 +186,20 @@ const OpenedRequest = struct { const OpenRequestOperation = struct { client: *std.http.Client, uri: std.Uri, - auth_header: []const u8, + auth_header: ?[]const u8, extra_headers: []const std.http.Header, pub fn run(self: *@This()) !OpenedRequest { + var headers: std.http.Client.Request.Headers = .{ + .content_type = .{ .override = "application/json" }, + .accept_encoding = .omit, + .user_agent = .{ .override = gateway_client.user_agent }, + }; + if (self.auth_header) |authorization| { + headers.authorization = .{ .override = authorization }; + } return .{ .request = try self.client.request(.POST, self.uri, .{ - .headers = .{ - .content_type = .{ .override = "application/json" }, - .authorization = .{ .override = self.auth_header }, - .accept_encoding = .omit, - .user_agent = .{ .override = gateway_client.user_agent }, - }, + .headers = headers, .extra_headers = self.extra_headers, .keep_alive = false, .redirect_behavior = .unhandled, @@ -200,15 +207,36 @@ const OpenRequestOperation = struct { } }; +const RequestAuthHeaders = struct { + authorization: ?[]u8 = null, + account_id: ?[]const u8 = null, + include_subscription_headers: bool = false, + + fn deinit(self: *RequestAuthHeaders, alloc: Allocator) void { + if (self.authorization) |value| secret.zeroAndFree(alloc, value); + self.* = .{}; + } +}; + +fn requestAuthHeaders(alloc: Allocator, auth: stream_provider.CredentialLease) !RequestAuthHeaders { + return switch (auth) { + .host_managed => .{}, + .direct => |direct| .{ + .authorization = try std.fmt.allocPrint(alloc, "Bearer {s}", .{direct.secret_bytes}), + .account_id = direct.account_id, + .include_subscription_headers = true, + }, + }; +} + pub fn streamPrepared( alloc: Allocator, request: stream_provider.ModelRequest, payload: []const u8, ) !stream_provider.Result { if (request.cancel_flag.load(.seq_cst)) return stream_provider.failResult(error.Cancelled); - const account_id = request.credential.account_id.?; - const auth_header = try std.fmt.allocPrint(alloc, "Bearer {s}", .{request.credential.secret}); - defer secret.zeroAndFree(alloc, auth_header); + var auth_headers = try requestAuthHeaders(alloc, request.credential); + defer auth_headers.deinit(alloc); const request_endpoint = if (io_mod.getenv(e2e_endpoint_env)) |override| endpoint: { if (!gateway_client.isLoopbackHttpUrl(override)) { return stream_provider.failResult(error.InvalidE2EXaiGrokEndpoint); @@ -221,18 +249,22 @@ pub fn streamPrepared( var extra_count: usize = 0; extra_headers_buf[extra_count] = .{ .name = "accept", .value = "text/event-stream" }; extra_count += 1; - extra_headers_buf[extra_count] = .{ .name = "X-XAI-Token-Auth", .value = "xai-grok-cli" }; - extra_count += 1; - extra_headers_buf[extra_count] = .{ .name = "x-authenticateresponse", .value = "authenticate-response" }; - extra_count += 1; + if (auth_headers.include_subscription_headers) { + extra_headers_buf[extra_count] = .{ .name = "X-XAI-Token-Auth", .value = "xai-grok-cli" }; + extra_count += 1; + extra_headers_buf[extra_count] = .{ .name = "x-authenticateresponse", .value = "authenticate-response" }; + extra_count += 1; + } extra_headers_buf[extra_count] = .{ .name = "x-grok-client-version", .value = proxy_compatibility_version }; extra_count += 1; extra_headers_buf[extra_count] = .{ .name = "x-grok-client-identifier", .value = "fx" }; extra_count += 1; extra_headers_buf[extra_count] = .{ .name = "x-grok-model-override", .value = request.model }; extra_count += 1; - extra_headers_buf[extra_count] = .{ .name = "x-grok-user-id", .value = account_id }; - extra_count += 1; + if (auth_headers.account_id) |account_id| { + extra_headers_buf[extra_count] = .{ .name = "x-grok-user-id", .value = account_id }; + extra_count += 1; + } if (request.session_id) |session_id| if (session_id.len > 0) { extra_headers_buf[extra_count] = .{ .name = "x-grok-conv-id", .value = session_id }; extra_count += 1; @@ -243,7 +275,7 @@ pub fn streamPrepared( var open_operation = OpenRequestOperation{ .client = &client, .uri = uri, - .auth_header = auth_header, + .auth_header = auth_headers.authorization, .extra_headers = extra_headers_buf[0..extra_count], }; var connect_deadline = std.Io.Clock.Timestamp.fromNow(io_mod.getIo(), .{ @@ -660,11 +692,11 @@ fn testModelRequest( callback_context: *u8, ) stream_provider.ModelRequest { return .{ - .credential = .{ - .secret = secret_value, + .credential = .{ .direct = .{ + .secret_bytes = secret_value, .source = source, .account_id = account_id, - }, + } }, .model = "grok-4.20", .retry_count = 1, .messages = &.{}, @@ -971,6 +1003,15 @@ fn ignoreTestChunk(_: *anyopaque, _: []const u8) void {} fn ignoreTestEvent(_: *anyopaque, _: stream_provider.Event) void {} fn admitTestRequest(_: *anyopaque) !void {} +test "host-managed Grok request auth omits bearer and subscription headers" { + var headers = try requestAuthHeaders(std.testing.allocator, .host_managed); + defer headers.deinit(std.testing.allocator); + + try std.testing.expect(headers.authorization == null); + try std.testing.expect(headers.account_id == null); + try std.testing.expect(!headers.include_subscription_headers); +} + test "xAI Grok error-body reader accepts the exact bound and replaces one beyond" { inline for (.{ TestResponseMode.error_body_exact, TestResponseMode.error_body_excess }) |mode| { var fixture = try TestResponseFixture.init(mode); diff --git a/src/gateway/xai_grok_models.zig b/src/gateway/xai_grok_models.zig index 3394a6f70..0ec969c97 100644 --- a/src/gateway/xai_grok_models.zig +++ b/src/gateway/xai_grok_models.zig @@ -58,15 +58,12 @@ fn fetchCatalogForProvider( alloc: std.mem.Allocator, input: model_catalog.FetchInput, ) std.mem.Allocator.Error!model_catalog.ProviderResult { - if (input.access.credentialSource() != .grok_subscription) { - return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; - } - const credential = input.access.authorizationCredential() orelse - return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; - const account_id = input.access.accountId() orelse - return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; - if (!grok_session.validAccountId(account_id)) { + const request_auth = catalogRequestAuth(input.access) orelse return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; + if (request_auth.account_id) |account_id| { + if (!grok_session.validAccountId(account_id)) { + return .{ .failure = .{ .category = .authentication, .http_status = .unauthorized } }; + } } const request_url = modelsUrl(alloc) catch |err| { if (err == error.OutOfMemory) return error.OutOfMemory; @@ -88,8 +85,9 @@ fn fetchCatalogForProvider( var response = fetchCatalogResponse( alloc, request_url, - credential, - account_id, + request_auth.credential, + request_auth.account_id, + request_auth.include_subscription_headers, cancel_flag, deadline, ) catch |err| { @@ -103,8 +101,9 @@ fn fetchCatalogForProvider( var modalities_response = fetchCatalogResponse( alloc, modalities_url, - credential, + request_auth.credential, null, + false, cancel_flag, deadline, ) catch |err| { @@ -122,6 +121,28 @@ fn fetchCatalogForProvider( return .{ .catalog = catalog }; } +const CatalogRequestAuth = struct { + credential: ?[]const u8 = null, + account_id: ?[]const u8 = null, + include_subscription_headers: bool = false, +}; + +fn catalogRequestAuth(access: credentials.CatalogAccess) ?CatalogRequestAuth { + return switch (access) { + .host_managed => .{}, + .public_only => null, + .authenticated => |authenticated| if (authenticated.source == .grok_subscription and + authenticated.account_id != null) + .{ + .credential = authenticated.credential, + .account_id = authenticated.account_id, + .include_subscription_headers = true, + } + else + null, + }; +} + fn catalogFetchFailure(err: anyerror) model_catalog.Failure { if (err == error.Cancelled) return .{ .category = .cancellation }; if (err == error.GrokModelCatalogTooLarge) return .{ .category = .malformed_response }; @@ -141,14 +162,23 @@ const FetchResponse = struct { const FetchOperation = struct { alloc: std.mem.Allocator, url: []const u8, - credential: []const u8, + credential: ?[]const u8, account_id: ?[]const u8, + include_subscription_headers: bool, pub fn run(self: *@This()) !FetchResponse { var client: std.http.Client = .{ .allocator = self.alloc, .io = io_mod.getIo() }; defer client.deinit(); - const auth_header = try std.fmt.allocPrint(self.alloc, "Bearer {s}", .{self.credential}); - defer secret.zeroAndFree(self.alloc, auth_header); + var auth_header: ?[]u8 = null; + defer if (auth_header) |value| secret.zeroAndFree(self.alloc, value); + var headers: std.http.Client.Request.Headers = .{ + .user_agent = .{ .override = gateway_client.user_agent }, + .accept_encoding = .omit, + }; + if (self.credential) |credential| { + auth_header = try std.fmt.allocPrint(self.alloc, "Bearer {s}", .{credential}); + headers.authorization = .{ .override = auth_header.? }; + } const body_buffer = try self.alloc.alloc(u8, max_catalog_bytes + 1); defer secret.zeroAndFree(self.alloc, body_buffer); var response_writer = std.Io.Writer.fixed(body_buffer); @@ -156,20 +186,18 @@ const FetchOperation = struct { var extra_headers_len: usize = 0; extra_headers_buffer[extra_headers_len] = .{ .name = "accept", .value = "application/json" }; extra_headers_len += 1; - if (self.account_id) |account_id| { + if (self.include_subscription_headers) { extra_headers_buffer[extra_headers_len] = .{ .name = "X-XAI-Token-Auth", .value = "xai-grok-cli" }; extra_headers_len += 1; + } + if (self.account_id) |account_id| { extra_headers_buffer[extra_headers_len] = .{ .name = "x-userid", .value = account_id }; extra_headers_len += 1; } const result = client.fetch(.{ .location = .{ .url = self.url }, .method = .GET, - .headers = .{ - .authorization = .{ .override = auth_header }, - .user_agent = .{ .override = gateway_client.user_agent }, - .accept_encoding = .omit, - }, + .headers = headers, .extra_headers = extra_headers_buffer[0..extra_headers_len], .response_writer = &response_writer, .redirect_behavior = .unhandled, @@ -189,8 +217,9 @@ const FetchOperation = struct { fn fetchCatalogResponse( alloc: std.mem.Allocator, url: []const u8, - credential: []const u8, + credential: ?[]const u8, account_id: ?[]const u8, + include_subscription_headers: bool, cancel_flag: *std.atomic.Value(bool), deadline: std.Io.Clock.Timestamp, ) !FetchResponse { @@ -199,6 +228,7 @@ fn fetchCatalogResponse( .url = url, .credential = credential, .account_id = account_id, + .include_subscription_headers = include_subscription_headers, }; return gateway_client.runBoundedHttpOperation( FetchResponse, @@ -645,6 +675,13 @@ test "Grok catalog fixture cleanup joins without a client" { try std.testing.expect(fixture.failure == null); } +test "host-managed Grok catalog auth carries no local headers" { + const auth = catalogRequestAuth(.host_managed) orelse return error.TestExpectedHostManagedCatalogAuth; + try std.testing.expect(auth.credential == null); + try std.testing.expect(auth.account_id == null); + try std.testing.expect(!auth.include_subscription_headers); +} + var stable_catalog_test_environ: ?*std.process.Environ.Map = null; fn stableCatalogTestEnviron() !*const std.process.Environ.Map { @@ -723,6 +760,7 @@ fn fetchCatalogFixture(body: []const u8) !FetchResponse { .url = url, .credential = "grok-test-token", .account_id = "acct_test", + .include_subscription_headers = true, }; const result = operation.run(); fixture.deinit(); diff --git a/src/main.zig b/src/main.zig index af05b461f..10caca01b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -602,7 +602,11 @@ const App = struct { return null; } - pub fn init(alloc: Allocator, launch: *cli_surface.InteractiveLaunch) !Self { + pub fn init( + alloc: Allocator, + launch: *cli_surface.InteractiveLaunch, + auth_mode: credentials.AuthMode, + ) !Self { var app = Self{ .alloc = alloc, .auth = undefined, @@ -619,11 +623,12 @@ const App = struct { else shell_process_provider.provider, }; - auth_runtime.Runtime.initInto( + auth_runtime.Runtime.initIntoWithMode( &app.auth, app_api_key_validator, app_oauth_transport, app_secret_store, + auth_mode, ); usage_dashboard_runtime.Runtime.initInto(&app.usage_dashboard, std.heap.c_allocator); app_session_runtime.Persistence.initInto(&app.session_persistence); @@ -1411,7 +1416,10 @@ const App = struct { errdefer std.heap.c_allocator.free(model_copy); const gateway_credential = self.auth.gatewayCredential() orelse return error.MissingApiKey; - const api_key_copy = try std.heap.c_allocator.dupe(u8, gateway_credential.api_key); + const api_key_copy = if (gateway_credential.api_key) |api_key| + try std.heap.c_allocator.dupe(u8, api_key) + else + @constCast(&[_]u8{}); errdefer secret.zeroAndFree(std.heap.c_allocator, api_key_copy); const gateway_team_copy = if (gateway_credential.gateway_team) |team| @@ -1514,7 +1522,10 @@ const App = struct { const model = try std.heap.c_allocator.dupe(u8, selection.model); errdefer std.heap.c_allocator.free(model); const credential = self.auth.gatewayCredential() orelse return error.MissingApiKey; - const api_key = try std.heap.c_allocator.dupe(u8, credential.api_key); + const api_key = if (credential.api_key) |value| + try std.heap.c_allocator.dupe(u8, value) + else + @constCast(&[_]u8{}); errdefer secret.zeroAndFree(std.heap.c_allocator, api_key); const gateway_team = if (credential.gateway_team) |team| try std.heap.c_allocator.dupe(u8, team) @@ -3218,7 +3229,7 @@ pub fn runWasmTerminal(init: std.process.Init) !void { }, }; defer launch.deinit(alloc); - const outcome = try app_entry_runtime.runInteractiveCooperative(App, alloc, &launch); + const outcome = try app_entry_runtime.runInteractiveCooperative(App, alloc, &launch, .local); switch (outcome) { .returned => {}, .exit => |code| if (code != 0) return error.WasmTerminalExited, @@ -3355,12 +3366,16 @@ fn runNonBenchmark(raw_args: []const [*:0]const u8, raw_env: RawEnviron, cli_arg io_mod.setRawEnviron(raw_env); const alloc = processAllocator(); + const auth_mode = credentials.parseAuthMode(rawEnvValue(raw_env, "FX_AUTH_MODE")) catch { + try writeStderrFast("fx: FX_AUTH_MODE must be local or host-managed\n"); + exitFast(1); + }; const cfg = if (cli_args.len == 0) - emptyEntryConfig() + emptyEntryConfig(auth_mode) else if (needsFullEntryConfig(cli_args)) - fullEntryConfig() + fullEntryConfig(auth_mode) else - localEntryConfig(); + localEntryConfig(auth_mode); var early_threaded: ?std.Io.Threaded = null; defer if (early_threaded) |*threaded| threaded.deinit(); @@ -3390,7 +3405,7 @@ fn runNonBenchmark(raw_args: []const [*:0]const u8, raw_env: RawEnviron, cli_arg defer owned_launch.deinit(alloc); defer debug_trace.shutdown(); - const outcome = try app_entry_runtime.runInteractive(App, alloc, &owned_launch); + const outcome = try app_entry_runtime.runInteractive(App, alloc, &owned_launch, auth_mode); switch (outcome) { .returned => return, .exit => |code| std.process.exit(code), @@ -3704,11 +3719,12 @@ test "native app preserves the built-in tool set without workspace metadata" { try std.testing.expectEqual(builtin_tools.advertisement_set.order.len, advertised.order.len); } -fn fullEntryConfig() app_entry_runtime.Config { +fn fullEntryConfig(auth_mode: credentials.AuthMode) app_entry_runtime.Config { return .{ .version = version, .revision = build_options.git_commit, .build_channel = compiled_update_channel, + .auth_mode = auth_mode, .command_catalog = builtin_commands.top_level_registry, .default_model = builtin_gateway.default_model, .default_agent_step_limit = default_max_agent_steps, @@ -3742,11 +3758,12 @@ fn fullEntryConfig() app_entry_runtime.Config { }; } -fn localEntryConfig() app_entry_runtime.Config { +fn localEntryConfig(auth_mode: credentials.AuthMode) app_entry_runtime.Config { return .{ .version = version, .revision = build_options.git_commit, .build_channel = compiled_update_channel, + .auth_mode = auth_mode, .command_catalog = builtin_commands.top_level_registry, .default_model = builtin_gateway.default_model, .default_agent_step_limit = default_max_agent_steps, @@ -3780,11 +3797,12 @@ fn localEntryConfig() app_entry_runtime.Config { }; } -fn emptyEntryConfig() app_entry_runtime.Config { +fn emptyEntryConfig(auth_mode: credentials.AuthMode) app_entry_runtime.Config { return .{ .version = version, .revision = build_options.git_commit, .build_channel = compiled_update_channel, + .auth_mode = auth_mode, .command_catalog = builtin_commands.top_level_registry, .default_model = "", .default_agent_step_limit = 0, diff --git a/src/ui/footer/model_menu_presentation.zig b/src/ui/footer/model_menu_presentation.zig index cc881b286..00d0fa73b 100644 --- a/src/ui/footer/model_menu_presentation.zig +++ b/src/ui/footer/model_menu_presentation.zig @@ -419,6 +419,7 @@ fn loadedCatalogStatusText(state: model_cache_runtime.ModelMenuCatalogState) ?[] .stored_key => "Gateway catalog: authenticated with the stored API key.", .chatgpt_subscription => "Codex catalog: authenticated with a subscription.", .grok_subscription => "Grok catalog: authenticated with a subscription.", + .host_managed => "Provider catalog: authentication is managed by the host.", }; } return null; diff --git a/tests/e2e/acp.test.ts b/tests/e2e/acp.test.ts index 7795f1bba..0487bc0e9 100644 --- a/tests/e2e/acp.test.ts +++ b/tests/e2e/acp.test.ts @@ -1047,6 +1047,42 @@ describe("acp: model-independent", () => { if (client) await client.close(); }); + test( + "host-managed ACP sessions stream without local credentials", + async () => { + const root = createIsolatedRoot("fx-acp-host-managed-"); + const gateway = startFakeGateway([finalText("ACP_HOST_MANAGED_OK")]); + try { + client = await AcpClient.create({ + cwd: root.workspace, + env: { + ...fakeGatewayEnv(root, gateway), + AI_GATEWAY_API_KEY: undefined, + VERCEL_OIDC_TOKEN: undefined, + FX_AUTH_MODE: "host-managed", + }, + }); + await client.request("initialize", { protocolVersion: 1 }, 1); + await client.request("session/new", { mcpServers: [] }, 2); + await client.readLine(); + const result = await runPrompt(client, "Reply once.", TIMEOUT); + + expect(result.promptResult.result.stopReason).toBe("end_turn"); + expect(JSON.stringify(result.messages)).toContain("ACP_HOST_MANAGED_OK"); + expect(gateway.requests.length).toBe(1); + expect(gateway.requests[0]!.headers.get("authorization")).toBeNull(); + expect(gateway.requests[0]!.headers.get("x-vercel-ai-gateway-team")).toBeNull(); + expect(existsSync(join(root.home, ".fx", "auth.json"))).toBe(false); + expect(client.stderr).toBe(""); + } finally { + await client?.close(); + gateway.stop(); + rmSync(root.root, { recursive: true, force: true }); + } + }, + TIMEOUT, + ); + test( "active ACP session uses typed MCP Resources Prompts and Completion state", async () => { diff --git a/tests/e2e/ci-shard-weights.json b/tests/e2e/ci-shard-weights.json index 33971837c..978b04724 100644 --- a/tests/e2e/ci-shard-weights.json +++ b/tests/e2e/ci-shard-weights.json @@ -22,7 +22,7 @@ { "file": "terminal-host.test.ts", "weight": 273 }, { "file": "tmux-helpers.test.ts", "weight": 2 }, { "file": "tui-agent.test.ts", "weight": 1 }, - { "file": "tui-auth-source-selection.test.ts", "weight": 49 }, + { "file": "tui-auth-source-selection.test.ts", "weight": 51 }, { "file": "tui-command-permissions.test.ts", "weight": 153 }, { "file": "tui-composer-edit-contracts.test.ts", "weight": 168 }, { "file": "tui-cost.test.ts", "weight": 16 }, diff --git a/tests/e2e/tui-auth-source-selection.test.ts b/tests/e2e/tui-auth-source-selection.test.ts index 259230b0a..dac6ab7cc 100644 --- a/tests/e2e/tui-auth-source-selection.test.ts +++ b/tests/e2e/tui-auth-source-selection.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; import { spawn as nodeSpawn } from "node:child_process"; import { createHash } from "node:crypto"; import { @@ -2332,7 +2332,9 @@ test( expect(result.code, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(0); expect(result.stdout).toContain("Selected Vercel team: Vercel Labs (vercel-labs)."); expect(savedCredentialSource(home)).toBe("fx_login"); - const persisted = JSON.parse(readFileSync(join(home, ".fx", "auth.json"), "utf8")) as { + const persisted = JSON.parse( + readFileSync(join(home, ".fx", "auth.json"), "utf8"), + ) as { team_id?: string; team_slug?: string; }; @@ -2382,7 +2384,9 @@ test( expect(result.stdout).not.toContain("Selected Vercel team"); expect(result.stderr).toContain("selected team could not access AI Gateway"); expect(savedCredentialSource(home)).toBeUndefined(); - const persisted = JSON.parse(readFileSync(join(home, ".fx", "auth.json"), "utf8")) as { + const persisted = JSON.parse( + readFileSync(join(home, ".fx", "auth.json"), "utf8"), + ) as { team_id?: string; }; expect(persisted.team_id).toBe("team_old"); @@ -4758,3 +4762,251 @@ for (const scenario of [ 60_000, ); } + +type HostManagedCapturedRequest = { + path: string; + headers: Headers; +}; + +describe("host-managed authentication", () => { + let hostRoot = ""; + let hostHome = ""; + let hostWorkspace = ""; + let hostRequests: HostManagedCapturedRequest[] = []; + let hostServer: ReturnType; + let hostBaseUrl = ""; + let codexUnauthorizedResponses = 0; + + beforeAll(() => { + hostRoot = mkdtempSync(join(tmpdir(), "fx-host-managed-auth-")); + hostHome = join(hostRoot, "home"); + hostWorkspace = join(hostRoot, "workspace"); + mkdirSync(hostHome, { recursive: true }); + mkdirSync(hostWorkspace, { recursive: true }); + hostServer = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const path = new URL(request.url).pathname; + hostRequests.push({ path, headers: new Headers(request.headers) }); + if (path === "/gateway/models") { + return Response.json({ + data: [{ id: "test/gateway-model", type: "language", tags: ["tool-use"] }], + }); + } + if (path === "/gateway/responses") { + return fakeGatewayFinalText("GATEWAY_HOST_MANAGED_OK"); + } + if (path === "/codex/models") { + return Response.json({ models: [{ + slug: "gpt-5.4-mini", + visibility: "list", + supported_in_api: true, + priority: 1, + supported_reasoning_levels: [{ effort: "low" }], + additional_speed_tiers: [], + input_modalities: ["text"], + context_window: 272000, + }] }); + } + if (path === "/codex/responses") { + if (codexUnauthorizedResponses > 0) { + codexUnauthorizedResponses -= 1; + return Response.json({ error: { message: "host rejected request" } }, { status: 401 }); + } + return new Response( + 'data: {"type":"response.output_text.delta","delta":"CODEX_HOST_MANAGED_OK"}\n\n' + + 'data: {"type":"response.completed","response":{"id":"resp_codex_host","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}}\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); + } + if (path === "/grok/models") { + return Response.json({ data: [{ + id: "grok-4.20", + model: "grok-4.20", + api_backend: "responses", + context_window: 1000000, + supports_reasoning_effort: false, + reasoning_efforts: [], + }] }); + } + if (path === "/grok/modalities") { + return Response.json({ models: [{ + id: "grok-4.20", + input_modalities: ["text"], + output_modalities: ["text"], + }] }); + } + if (path === "/grok/responses") { + return new Response( + 'data: {"type":"response.output_text.delta","delta":"GROK_HOST_MANAGED_OK"}\n\n' + + 'data: {"type":"response.completed","response":{"id":"resp_grok_host","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}}\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); + } + return new Response("not found", { status: 404 }); + }, + }); + hostBaseUrl = `http://127.0.0.1:${hostServer.port}`; + }); + + afterAll(() => { + hostServer.stop(true); + rmSync(hostRoot, { recursive: true, force: true }); + }); + + function hostManagedEnv(): Record { + return { + HOME: hostHome, + AI_GATEWAY_API_KEY: undefined, + VERCEL_OIDC_TOKEN: undefined, + FX_AUTH_MODE: "host-managed", + FX_AUTO_UPGRADE: "0", + FX_DISABLE_KEYCHAIN: "1", + FX_SKIP_ONBOARDING: "1", + FX_SOUND: "0", + FX_E2E_GATEWAY_MODELS_URL: `${hostBaseUrl}/gateway/models`, + FX_E2E_GATEWAY_CHAT_URL: `${hostBaseUrl}/gateway/responses`, + FX_E2E_OPENAI_CODEX_MODELS_URL: `${hostBaseUrl}/codex/models`, + FX_E2E_OPENAI_CODEX_RESPONSES_URL: `${hostBaseUrl}/codex/responses`, + FX_E2E_XAI_GROK_MODELS_URL: `${hostBaseUrl}/grok/models`, + FX_E2E_XAI_GROK_MODALITIES_URL: `${hostBaseUrl}/grok/modalities`, + FX_E2E_XAI_GROK_RESPONSES_URL: `${hostBaseUrl}/grok/responses`, + }; + } + + test("runs Gateway Codex and Grok without local authentication headers", async () => { + const childEnv = hostManagedEnv(); + const status = await runFx(["status", "--json"], { cwd: hostWorkspace, env: childEnv }); + expect(status.code).toBe(0); + expect(status.stderr).toBe(""); + expect(JSON.parse(status.stdout).auth).toBe("host managed"); + + for (const command of [["login"], ["logout"], ["setup"], ["teams"]]) { + const result = await runFx(command, { cwd: hostWorkspace, env: childEnv }); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe("Authentication is managed by the host.\n"); + } + expect(existsSync(join(hostHome, ".fx", "auth.json"))).toBe(false); + + for (const [provider, marker] of [ + ["gateway", "GATEWAY_HOST_MANAGED_OK"], + ["codex", "CODEX_HOST_MANAGED_OK"], + ["grok", "GROK_HOST_MANAGED_OK"], + ] as const) { + const selected = await runFx(["provider", provider], { + cwd: hostWorkspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(selected.code).toBe(0); + expect(selected.stderr).toBe(""); + + const models = await runFx(["models", "--json"], { + cwd: hostWorkspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(models.code).toBe(0); + expect(models.stderr).toBe(""); + + const asked = await runFx(["ask", "--json", "--no-save", "Reply once."], { + cwd: hostWorkspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(asked.code).toBe(0); + expect(asked.stderr).toBe(""); + expect(asked.stdout).toContain(marker); + } + + expect(hostRequests.length).toBeGreaterThan(0); + for (const request of hostRequests) { + expect(request.headers.get("authorization"), request.path).toBeNull(); + expect(request.headers.get("x-vercel-ai-gateway-team"), request.path).toBeNull(); + expect(request.headers.get("chatgpt-account-id"), request.path).toBeNull(); + expect(request.headers.get("x-xai-token-auth"), request.path).toBeNull(); + expect(request.headers.get("x-authenticateresponse"), request.path).toBeNull(); + expect(request.headers.get("x-grok-user-id"), request.path).toBeNull(); + expect(request.headers.get("x-userid"), request.path).toBeNull(); + } + expect(existsSync(join(hostHome, ".fx", "auth.json"))).toBe(false); + }, TIMEOUT); + + test("rejects malformed auth mode before provider I/O", async () => { + const before = hostRequests.length; + const result = await runFx(["ask", "--json", "--no-save", "Do nothing."], { + cwd: hostWorkspace, + env: { ...hostManagedEnv(), FX_AUTH_MODE: "host_managed" }, + timeoutMs: TIMEOUT, + }); + expect(result.code).toBe(1); + expect(result.stderr).toContain("FX_AUTH_MODE must be local or host-managed"); + expect(hostRequests.length).toBe(before); + }, TIMEOUT); + + test("final provider 401 does not enter local refresh or replay", async () => { + const childEnv = hostManagedEnv(); + const selected = await runFx(["provider", "codex"], { + cwd: hostWorkspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(selected.code).toBe(0); + + const before = hostRequests.filter((request) => request.path === "/codex/responses").length; + codexUnauthorizedResponses = 1; + const asked = await runFx(["ask", "--json", "--no-save", "Reply once."], { + cwd: hostWorkspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(asked.code).toBe(1); + const after = hostRequests.filter((request) => request.path === "/codex/responses").length; + expect(after - before).toBe(1); + expect(existsSync(join(hostHome, ".fx", "auth.json"))).toBe(false); + }, TIMEOUT); + + test("interactive host-managed session streams through the same authority", async () => { + const childEnv = hostManagedEnv(); + const selected = await runFx(["provider", "gateway"], { + cwd: hostWorkspace, + env: childEnv, + timeoutMs: TIMEOUT, + }); + expect(selected.code).toBe(0); + + const hostStderrPath = join(hostRoot, "tui.stderr"); + const tracePath = join(hostRoot, "tui.trace"); + const before = hostRequests.length; + const hostSession = await TmuxSession.create({ + cwd: hostWorkspace, + env: { + ...childEnv, + FX_TRACE_LOG: tracePath, + FX_TRACE_SCOPES: "auth,session,worker,gateway", + }, + stderrPath: hostStderrPath, + isolated: true, + }); + try { + await hostSession.waitForComposer(TIMEOUT); + await hostSession.sendText("Reply once."); + const pane = await hostSession.waitForText("GATEWAY_HOST_MANAGED_OK", TIMEOUT); + expect(pane).toContain("GATEWAY_HOST_MANAGED_OK"); + } catch (error) { + const trace = existsSync(tracePath) ? readFileSync(tracePath, "utf8") : ""; + throw new Error(`${String(error)}\ntrace:\n${trace}`); + } finally { + await hostSession.kill(); + } + + expect(readFileSync(hostStderrPath, "utf8")).toBe(""); + expect(hostRequests.length).toBeGreaterThan(before); + for (const request of hostRequests.slice(before)) { + expect(request.headers.get("authorization"), request.path).toBeNull(); + expect(request.headers.get("x-vercel-ai-gateway-team"), request.path).toBeNull(); + } + }, TIMEOUT * 2); +});