From c1a4895303dcfadc0cf540799a08cc5d1852623d Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Tue, 17 Mar 2026 02:34:15 -0700 Subject: [PATCH] feat(foundry): implement provider credential management (Claude, Codex) Add credential extraction, injection, and UI for managing Claude and Codex OAuth credentials in sandbox environments. Credentials are stored per-user in the user actor, injected on task owner swap, and periodically re-extracted to capture token refreshes. Frontend account settings show provider sign-in status. Changes: - User actor: new userProviderCredentials table with upsert/get actions - Task workspace: extract/inject provider credentials, integrate with owner swap and polling - App snapshot: include provider credential status (anthropic/openai booleans) - Frontend: new Providers section in account settings Co-Authored-By: Claude Haiku 4.5 --- .../src/actors/organization/app-shell.ts | 13 +- .../backend/src/actors/task/workspace.ts | 103 ++++++++- .../backend/src/actors/user/actions/user.ts | 45 +++- .../backend/src/actors/user/db/migrations.ts | 12 + .../backend/src/actors/user/db/schema.ts | 8 + foundry/packages/client/src/backend-client.ts | 1 + foundry/packages/client/src/mock-app.ts | 12 + .../client/src/mock/backend-client.ts | 1 + .../packages/client/src/remote/app-client.ts | 1 + foundry/packages/frontend/src/app/router.tsx | 6 +- .../src/components/mock-onboarding.tsx | 71 +++++- foundry/packages/frontend/src/lib/mock-app.ts | 1 + foundry/packages/shared/src/app-shell.ts | 13 +- .../specs/foundry-provider-credentials.md | 205 ++++++++++++++++++ 14 files changed, 481 insertions(+), 11 deletions(-) create mode 100644 research/specs/foundry-provider-credentials.md diff --git a/foundry/packages/backend/src/actors/organization/app-shell.ts b/foundry/packages/backend/src/actors/organization/app-shell.ts index ed1005a..66a9779 100644 --- a/foundry/packages/backend/src/actors/organization/app-shell.ts +++ b/foundry/packages/backend/src/actors/organization/app-shell.ts @@ -12,7 +12,7 @@ import type { } from "@sandbox-agent/foundry-shared"; import { DEFAULT_WORKSPACE_MODEL_ID } from "@sandbox-agent/foundry-shared"; import { getActorRuntimeContext } from "../context.js"; -import { getOrCreateGithubData, getOrCreateOrganization, selfOrganization } from "../handles.js"; +import { getOrCreateGithubData, getOrCreateOrganization, getOrCreateUser, selfOrganization } from "../handles.js"; import { GitHubAppError } from "../../services/app-github.js"; import { getBetterAuthService } from "../../services/better-auth.js"; import { repoLabelFromRemote } from "../../services/repo.js"; @@ -289,6 +289,16 @@ export async function buildAppSnapshot(c: any, sessionId: string, allowOrganizat } : null; + let providerCredentials = { anthropic: false, openai: false }; + if (user?.id) { + try { + const userActor = await getOrCreateUser(c, user.id); + providerCredentials = await userActor.getProviderCredentialStatus(); + } catch (error) { + logger.warn({ error, sessionId }, "build_app_snapshot_provider_credentials_failed"); + } + } + const activeOrganizationId = currentUser && currentSessionState?.activeOrganizationId && @@ -313,6 +323,7 @@ export async function buildAppSnapshot(c: any, sessionId: string, allowOrganizat skippedAt: profile?.starterRepoSkippedAt ?? null, }, }, + providerCredentials, users: currentUser ? [currentUser] : [], organizations, }; diff --git a/foundry/packages/backend/src/actors/task/workspace.ts b/foundry/packages/backend/src/actors/task/workspace.ts index 5c49a4d..24d319c 100644 --- a/foundry/packages/backend/src/actors/task/workspace.ts +++ b/foundry/packages/backend/src/actors/task/workspace.ts @@ -201,6 +201,70 @@ async function injectGitCredentials(sandbox: any, login: string, email: string, } } +/** + * Provider credential files: well-known paths where CLI tools store auth tokens. + */ +const PROVIDER_CREDENTIAL_FILES = [ + { provider: "anthropic", filePath: ".claude/.credentials.json" }, + { provider: "openai", filePath: ".codex/auth.json" }, +] as const; + +/** + * Inject provider credentials (Claude, Codex) into the sandbox filesystem. + * Called before agent sessions start so credentials are on disk when the agent reads them. + */ +async function injectProviderCredentials(sandbox: any, credentials: Array<{ provider: string; credentialFileJson: string; filePath: string }>): Promise { + for (const cred of credentials) { + const fullPath = `/home/user/${cred.filePath}`; + const dir = dirname(fullPath); + const script = [ + "set -euo pipefail", + `mkdir -p ${JSON.stringify(dir)}`, + `cat > ${JSON.stringify(fullPath)} << 'CRED_EOF'\n${cred.credentialFileJson}\nCRED_EOF`, + `chmod 600 ${JSON.stringify(fullPath)}`, + ].join(" && "); + + const result = await sandbox.runProcess({ + command: "bash", + args: ["-lc", script], + cwd: "/", + timeoutMs: 10_000, + }); + if ((result.exitCode ?? 0) !== 0) { + logActorWarning("task", "provider credential injection failed", { + provider: cred.provider, + exitCode: result.exitCode, + output: [result.stdout, result.stderr].filter(Boolean).join(""), + }); + } + } +} + +/** + * Extract provider credentials from the sandbox filesystem. + * Used to capture token refreshes and persist them to the user actor. + */ +async function extractProviderCredentials(sandbox: any): Promise> { + const results: Array<{ provider: string; credentialFileJson: string; filePath: string }> = []; + for (const file of PROVIDER_CREDENTIAL_FILES) { + const fullPath = `/home/user/${file.filePath}`; + const result = await sandbox.runProcess({ + command: "cat", + args: [fullPath], + cwd: "/", + timeoutMs: 5_000, + }); + if ((result.exitCode ?? 0) === 0 && result.stdout?.trim()) { + results.push({ + provider: file.provider, + credentialFileJson: result.stdout.trim(), + filePath: file.filePath, + }); + } + } + return results; +} + /** * Resolves the current user's GitHub identity from their auth session. * Returns null if the session is invalid or the user has no GitHub account. @@ -263,7 +327,7 @@ async function resolveGithubIdentity(authSessionId: string): Promise<{ /** * Check if the task owner needs to swap, and if so, update the owner record - * and inject new git credentials into the sandbox. + * and inject new git credentials and provider credentials into the sandbox. * Returns true if an owner swap occurred. */ async function maybeSwapTaskOwner(c: any, authSessionId: string | null | undefined, sandbox: any | null): Promise { @@ -290,6 +354,19 @@ async function maybeSwapTaskOwner(c: any, authSessionId: string | null | undefin if (sandbox) { await injectGitCredentials(sandbox, identity.login, identity.email, identity.accessToken); + + // Inject provider credentials (Claude, Codex) from the new owner's user actor. + try { + const user = await getOrCreateUser(c, identity.userId); + const credentials = await user.getProviderCredentials(); + if (credentials.length > 0) { + await injectProviderCredentials(sandbox, credentials); + } + } catch (error) { + logActorWarning("task", "provider credential injection on owner swap failed", { + error: error instanceof Error ? error.message : String(error), + }); + } } return true; @@ -1199,6 +1276,30 @@ export async function refreshWorkspaceDerivedState(c: any): Promise { const gitState = await collectWorkspaceGitState(c, record); await writeCachedGitState(c, gitState); await broadcastTaskUpdate(c); + + // Extract provider credentials from the sandbox and persist to the task owner's user actor. + // This captures token refreshes performed by the agent (e.g. Claude CLI refreshing its OAuth token). + try { + const owner = await readTaskOwner(c); + if (owner?.primaryUserId && record.activeSandboxId) { + const runtime = await getTaskSandboxRuntime(c, record); + const extracted = await extractProviderCredentials(runtime.sandbox); + if (extracted.length > 0) { + const user = await getOrCreateUser(c, owner.primaryUserId); + for (const cred of extracted) { + await user.upsertProviderCredential({ + provider: cred.provider, + credentialFileJson: cred.credentialFileJson, + filePath: cred.filePath, + }); + } + } + } + } catch (error) { + logActorWarning("task", "provider credential extraction failed", { + error: error instanceof Error ? error.message : String(error), + }); + } } export async function refreshWorkspaceSessionTranscript(c: any, sessionId: string): Promise { diff --git a/foundry/packages/backend/src/actors/user/actions/user.ts b/foundry/packages/backend/src/actors/user/actions/user.ts index f251c95..e2cc023 100644 --- a/foundry/packages/backend/src/actors/user/actions/user.ts +++ b/foundry/packages/backend/src/actors/user/actions/user.ts @@ -1,6 +1,6 @@ import { eq, and } from "drizzle-orm"; import { DEFAULT_WORKSPACE_MODEL_ID } from "@sandbox-agent/foundry-shared"; -import { authAccounts, authSessions, authUsers, sessionState, userProfiles, userTaskState } from "../db/schema.js"; +import { authAccounts, authSessions, authUsers, sessionState, userProfiles, userProviderCredentials, userTaskState } from "../db/schema.js"; import { materializeRow } from "../query-helpers.js"; export const userActions = { @@ -43,6 +43,49 @@ export const userActions = { }; }, + // --- Provider credential actions --- + + async getProviderCredentialStatus(c) { + const rows = await c.db.select({ provider: userProviderCredentials.provider }).from(userProviderCredentials).all(); + const providers = new Set(rows.map((row: any) => row.provider)); + return { + anthropic: providers.has("anthropic"), + openai: providers.has("openai"), + }; + }, + + async getProviderCredentials(c) { + return await c.db.select().from(userProviderCredentials).all(); + }, + + async upsertProviderCredential( + c, + input: { + provider: string; + credentialFileJson: string; + filePath: string; + }, + ) { + const now = Date.now(); + await c.db + .insert(userProviderCredentials) + .values({ + provider: input.provider, + credentialFileJson: input.credentialFileJson, + filePath: input.filePath, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: userProviderCredentials.provider, + set: { + credentialFileJson: input.credentialFileJson, + filePath: input.filePath, + updatedAt: now, + }, + }) + .run(); + }, + // --- Mutation actions (migrated from queue) --- async upsertProfile( diff --git a/foundry/packages/backend/src/actors/user/db/migrations.ts b/foundry/packages/backend/src/actors/user/db/migrations.ts index da92bdc..26778ac 100644 --- a/foundry/packages/backend/src/actors/user/db/migrations.ts +++ b/foundry/packages/backend/src/actors/user/db/migrations.ts @@ -16,6 +16,12 @@ const journal = { tag: "0001_user_task_state", breakpoints: true, }, + { + idx: 2, + when: 1773619200000, + tag: "0002_user_provider_credentials", + breakpoints: true, + }, ], } as const; @@ -101,6 +107,12 @@ CREATE TABLE \`session_state\` ( \`draft_updated_at\` integer, \`updated_at\` integer NOT NULL, PRIMARY KEY(\`task_id\`, \`session_id\`) +);`, + m0002: `CREATE TABLE \`user_provider_credentials\` ( + \`provider\` text PRIMARY KEY NOT NULL, + \`credential_file_json\` text NOT NULL, + \`file_path\` text NOT NULL, + \`updated_at\` integer NOT NULL );`, } as const, }; diff --git a/foundry/packages/backend/src/actors/user/db/schema.ts b/foundry/packages/backend/src/actors/user/db/schema.ts index 6a87a11..d4001b7 100644 --- a/foundry/packages/backend/src/actors/user/db/schema.ts +++ b/foundry/packages/backend/src/actors/user/db/schema.ts @@ -93,6 +93,14 @@ export const sessionState = sqliteTable("session_state", { updatedAt: integer("updated_at").notNull(), }); +/** Custom Foundry table — not part of Better Auth. Stores provider credentials (Claude, Codex) extracted from sandbox filesystems. */ +export const userProviderCredentials = sqliteTable("user_provider_credentials", { + provider: text("provider").notNull().primaryKey(), // "anthropic" | "openai" + credentialFileJson: text("credential_file_json").notNull(), // raw file contents to write back + filePath: text("file_path").notNull(), // e.g. ".claude/.credentials.json" + updatedAt: integer("updated_at").notNull(), +}); + /** Custom Foundry table — not part of Better Auth. Stores per-user task/session UI state. */ export const userTaskState = sqliteTable( "user_task_state", diff --git a/foundry/packages/client/src/backend-client.ts b/foundry/packages/client/src/backend-client.ts index c2222cc..09bfaed 100644 --- a/foundry/packages/client/src/backend-client.ts +++ b/foundry/packages/client/src/backend-client.ts @@ -404,6 +404,7 @@ function signedOutAppSnapshot(): FoundryAppSnapshot { skippedAt: null, }, }, + providerCredentials: { anthropic: false, openai: false }, users: [], organizations: [], }; diff --git a/foundry/packages/client/src/mock-app.ts b/foundry/packages/client/src/mock-app.ts index 00fd9ca..e03df97 100644 --- a/foundry/packages/client/src/mock-app.ts +++ b/foundry/packages/client/src/mock-app.ts @@ -96,6 +96,10 @@ export interface MockFoundryAppSnapshot { skippedAt: number | null; }; }; + providerCredentials: { + anthropic: boolean; + openai: boolean; + }; users: MockFoundryUser[]; organizations: MockFoundryOrganization[]; } @@ -229,6 +233,10 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot { skippedAt: null, }, }, + providerCredentials: { + anthropic: false, + openai: false, + }, users: [ { id: "user-nathan", @@ -405,6 +413,10 @@ function parseStoredSnapshot(): MockFoundryAppSnapshot | null { skippedAt: parsed.onboarding?.starterRepo?.skippedAt ?? null, }, }, + providerCredentials: { + anthropic: parsed.providerCredentials?.anthropic ?? false, + openai: parsed.providerCredentials?.openai ?? false, + }, organizations: (parsed.organizations ?? []).map((organization: MockFoundryOrganization & { repoImportStatus?: string }) => ({ ...organization, github: { diff --git a/foundry/packages/client/src/mock/backend-client.ts b/foundry/packages/client/src/mock/backend-client.ts index 191f68c..8eae0c2 100644 --- a/foundry/packages/client/src/mock/backend-client.ts +++ b/foundry/packages/client/src/mock/backend-client.ts @@ -78,6 +78,7 @@ function unsupportedAppSnapshot(): FoundryAppSnapshot { skippedAt: null, }, }, + providerCredentials: { anthropic: false, openai: false }, users: [], organizations: [], }; diff --git a/foundry/packages/client/src/remote/app-client.ts b/foundry/packages/client/src/remote/app-client.ts index f1cb908..f00b7fd 100644 --- a/foundry/packages/client/src/remote/app-client.ts +++ b/foundry/packages/client/src/remote/app-client.ts @@ -20,6 +20,7 @@ class RemoteFoundryAppStore implements FoundryAppClient { skippedAt: null, }, }, + providerCredentials: { anthropic: false, openai: false }, users: [], organizations: [], }; diff --git a/foundry/packages/frontend/src/app/router.tsx b/foundry/packages/frontend/src/app/router.tsx index dd22724..56d974a 100644 --- a/foundry/packages/frontend/src/app/router.tsx +++ b/foundry/packages/frontend/src/app/router.tsx @@ -29,6 +29,9 @@ const signInRoute = createRoute({ getParentRoute: () => rootRoute, path: "/signin", component: SignInRoute, + validateSearch: (search: Record): { error?: string } => ({ + error: typeof search.error === "string" ? search.error : undefined, + }), }); const accountRoute = createRoute({ @@ -150,6 +153,7 @@ function IndexRoute() { function SignInRoute() { const snapshot = useMockAppSnapshot(); + const { error } = signInRoute.useSearch(); if (!isMockFrontendClient && isAppSnapshotBootstrapping(snapshot)) { return ; } @@ -157,7 +161,7 @@ function SignInRoute() { return ; } - return ; + return ; } function AccountRoute() { diff --git a/foundry/packages/frontend/src/components/mock-onboarding.tsx b/foundry/packages/frontend/src/components/mock-onboarding.tsx index 4528695..3be821b 100644 --- a/foundry/packages/frontend/src/components/mock-onboarding.tsx +++ b/foundry/packages/frontend/src/components/mock-onboarding.tsx @@ -188,10 +188,16 @@ function MemberRow({ member }: { member: FoundryOrganizationMember }) { ); } -export function MockSignInPage() { +const AUTH_ERROR_MESSAGES: Record = { + please_restart_the_process: "Sign-in failed. Please try again.", + state_mismatch: "Sign-in session expired. Please try again.", +}; + +export function MockSignInPage({ error }: { error?: string }) { const client = useMockAppClient(); const navigate = useNavigate(); const t = useFoundryTokens(); + const errorMessage = error ? (AUTH_ERROR_MESSAGES[error] ?? `Sign-in error: ${error}`) : undefined; return (
+ {errorMessage && ( +

+ {errorMessage} +

+ )} + {/* GitHub sign-in button */} + ) + } + /> + ))} + + ); +} + function AppearanceSection() { const { colorMode, setColorMode } = useColorMode(); const t = useFoundryTokens(); diff --git a/foundry/packages/frontend/src/lib/mock-app.ts b/foundry/packages/frontend/src/lib/mock-app.ts index 09b23ad..22adb40 100644 --- a/foundry/packages/frontend/src/lib/mock-app.ts +++ b/foundry/packages/frontend/src/lib/mock-app.ts @@ -32,6 +32,7 @@ const EMPTY_APP_SNAPSHOT: FoundryAppSnapshot = { skippedAt: null, }, }, + providerCredentials: { anthropic: false, openai: false }, users: [], organizations: [], }; diff --git a/foundry/packages/shared/src/app-shell.ts b/foundry/packages/shared/src/app-shell.ts index fa1e969..8146e28 100644 --- a/foundry/packages/shared/src/app-shell.ts +++ b/foundry/packages/shared/src/app-shell.ts @@ -4,12 +4,7 @@ export type FoundryBillingPlanId = "free" | "team"; export type FoundryBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel"; export type FoundryGithubInstallationStatus = "connected" | "install_required" | "reconnect_required"; export type FoundryGithubSyncStatus = "pending" | "syncing" | "synced" | "error"; -export type FoundryGithubSyncPhase = - | "discovering_repositories" - | "syncing_repositories" - | "syncing_branches" - | "syncing_members" - | "syncing_pull_requests"; +export type FoundryGithubSyncPhase = "discovering_repositories" | "syncing_repositories" | "syncing_branches" | "syncing_members" | "syncing_pull_requests"; export type FoundryOrganizationKind = "personal" | "organization"; export type FoundryStarterRepoStatus = "pending" | "starred" | "skipped"; @@ -85,6 +80,11 @@ export interface FoundryOrganization { repoCatalog: string[]; } +export interface FoundryProviderCredentialStatus { + anthropic: boolean; + openai: boolean; +} + export interface FoundryAppSnapshot { auth: { status: "signed_out" | "signed_in"; @@ -100,6 +100,7 @@ export interface FoundryAppSnapshot { skippedAt: number | null; }; }; + providerCredentials: FoundryProviderCredentialStatus; users: FoundryUser[]; organizations: FoundryOrganization[]; } diff --git a/research/specs/foundry-provider-credentials.md b/research/specs/foundry-provider-credentials.md new file mode 100644 index 0000000..59db290 --- /dev/null +++ b/research/specs/foundry-provider-credentials.md @@ -0,0 +1,205 @@ +# Spec: Foundry Provider Credential Management + +## Overview + +Allow Foundry users to sign in to Claude and Codex with their own accounts. Credentials are extracted from the sandbox filesystem, stored in the user actor, and re-populated into sandboxes on task ownership change. + +## Supported Providers + +- **Claude** (Anthropic) - OAuth via `claude /login` +- **Codex** (OpenAI) - OAuth via Codex CLI login + +## Credential Files + +Each provider's CLI writes credentials to a well-known path: + +| Provider | File Path (in sandbox) | Key Fields | +|----------|----------------------|------------| +| Claude | `~/.claude/.credentials.json` | `claudeAiOauth.accessToken`, `claudeAiOauth.expiresAt` | +| Codex | `~/.codex/auth.json` | `tokens.access_token` or `OPENAI_API_KEY` | + +## Architecture + +``` +User Actor Sandbox ++--------------------------+ +---------------------------+ +| userProviderCredentials | | ~/.claude/.credentials.json| +| - provider |-->| ~/.codex/auth.json | +| - credentialFileJson | +---------------------------+ +| - updatedAt | | ++--------------------------+ | poll interval + ^ | (extract & store) + | v + +--- periodic sync --------+ +``` + +Credentials are stored **outside the sandbox** in the user actor. They are written into the sandbox before the agent session starts, and periodically re-extracted to capture token refreshes. + +## Flows + +### 1. Sign-In Flow (First Time) + +1. User opens **Settings** screen (separate from task view). +2. Settings shows Claude and Codex sign-in status (signed in / not signed in). +3. User clicks **[Sign in to Claude]** or **[Sign in to Codex]**. +4. Button opens a terminal in the active sandbox and auto-runs the `terminal-auth` command from `authMethods._meta["terminal-auth"]` (discovered during ACP `initialize`). +5. User completes OAuth flow in browser. CLI writes credentials to disk and exits. +6. On process exit (code 0), extract credentials from sandbox filesystem and persist to user actor. +7. Settings UI updates to show "Signed in". + +**Fallback:** If process exits non-zero or user closes terminal, show "Sign in" button again. + +### 2. Auth Error Detection + +1. User sends a message to a task. +2. `maybeSwapTaskOwner` runs, writes credential files to sandbox. +3. Agent's `newSession` or `prompt` call proceeds. +4. If agent returns `auth_required` error: + - Surface "Sign in required" in the task UI. + - Show buttons: **[Sign in to Claude]** / **[Sign in to Codex]** (depending on which agent errored). + - Same terminal flow as above. +5. After sign-in completes, automatically retry the failed operation. + +### 3. Credential Population on Task Ownership Change + +Extends the existing `maybeSwapTaskOwner` in `task/workspace.ts`: + +1. User sends message to task. +2. `maybeSwapTaskOwner` detects owner change (or first message with no owner). +3. Existing: inject git credentials via `injectGitCredentials`. +4. **New:** inject provider credentials via `injectProviderCredentials`: + - Read stored credentials from user actor. + - Write `~/.claude/.credentials.json` and `~/.codex/auth.json` into sandbox filesystem. +5. Await completion of both injections. +6. Send prompt to agent. + +This runs before `newSession` / `sendPrompt`, so credentials are on disk when the agent reads them. + +### 4. Credential Polling (Sync from Sandbox) + +Similar to git status polling: + +1. On a poll interval (e.g. 30s), read credential files from the sandbox filesystem. +2. Compare with stored credentials in user actor. +3. If changed (e.g. token refreshed by the agent), update the user actor. +4. This keeps stored credentials fresh for repopulation into other sandboxes. + +## Data Model + +### User Actor: New Table `userProviderCredentials` + +```sql +CREATE TABLE userProviderCredentials ( + provider TEXT PRIMARY KEY, -- "anthropic" | "openai" + credentialFileJson TEXT NOT NULL, -- raw file contents to write back + filePath TEXT NOT NULL, -- e.g. ".claude/.credentials.json" + updatedAt INTEGER NOT NULL +); +``` + +We store the raw file JSON rather than individual fields. This avoids needing to understand every field the CLI writes, and means we can write back exactly what was extracted. The file path is stored so we know where to write it in the sandbox. + +### User Actor: New Queue + +- `user.command.provider_credentials.upsert` - update provider credentials + +### User Actor: New Action + +- `getProviderCredentialStatus()` - returns `{ anthropic: boolean, openai: boolean }` for the settings UI + +## Implementation Changes + +### Backend + +| File | Change | +|------|--------| +| `actors/user/db/schema.ts` | Add `userProviderCredentials` table | +| `actors/user/workflow.ts` | Add `user.command.provider_credentials.upsert` queue handler | +| `actors/user/actions/user.ts` | Add `getProviderCredentialStatus` action, extend `getAppAuthState` to include provider credential status | +| `actors/task/workspace.ts` | Add `injectProviderCredentials` function, call it from `maybeSwapTaskOwner`. Also handle first-message case (no owner change but credentials need populating). | +| `actors/task/workspace.ts` | Add credential polling logic (similar to git status poll) to periodically extract credential files from sandbox and update user actor. | +| `actors/organization/actions/tasks.ts` | Add action for triggering terminal-auth command in sandbox | + +### Frontend + +| File | Change | +|------|--------| +| Settings screen | Show Claude/Codex sign-in status with **[Sign in]** buttons | +| Task view | Handle `auth_required` errors from agent, show sign-in prompt with buttons | +| Terminal integration | Open sandbox terminal and auto-run the terminal-auth command when sign-in button clicked | + +### SDK (not required for initial implementation) + +The `terminal-auth` command metadata comes from the ACP adapter's `initialize` response. The current SDK skips interactive auth methods in `autoAuthenticate`. For the Foundry, we don't need to change the SDK since we handle auth at a higher level (UI + sandbox terminal). + +## Credential Injection Implementation + +```typescript +async function injectProviderCredentials( + sandbox: Sandbox, + credentials: Array<{ provider: string; credentialFileJson: string; filePath: string }> +): Promise { + for (const cred of credentials) { + const fullPath = `/home/user/${cred.filePath}`; + const dir = path.dirname(fullPath); + const script = [ + `mkdir -p ${JSON.stringify(dir)}`, + `cat > ${JSON.stringify(fullPath)} << 'CRED_EOF'\n${cred.credentialFileJson}\nCRED_EOF`, + `chmod 600 ${JSON.stringify(fullPath)}`, + ].join(" && "); + + await sandbox.runProcess({ + command: "bash", + args: ["-lc", script], + cwd: "/", + timeoutMs: 10_000, + }); + } +} +``` + +## Credential Extraction Implementation + +```typescript +async function extractProviderCredentials( + sandbox: Sandbox +): Promise> { + const files = [ + { provider: "anthropic", filePath: ".claude/.credentials.json" }, + { provider: "openai", filePath: ".codex/auth.json" }, + ]; + + const results = []; + for (const file of files) { + const fullPath = `/home/user/${file.filePath}`; + const result = await sandbox.runProcess({ + command: "cat", + args: [fullPath], + cwd: "/", + timeoutMs: 5_000, + }); + if (result.exitCode === 0 && result.stdout.trim()) { + results.push({ + provider: file.provider, + credentialFileJson: result.stdout.trim(), + filePath: file.filePath, + }); + } + } + return results; +} +``` + +## Security Considerations + +- Credential files written with `chmod 600` (owner-only read/write in sandbox). +- Credentials stored in user actor's SQLite (same security model as GitHub OAuth tokens in `authAccounts`). +- Credentials never sent to the frontend. Only boolean status (signed in / not signed in) exposed to UI. +- On owner swap, old credentials are overwritten (same as git credential swap). + +## Out of Scope + +- Token refresh handling: the agent adapters (Claude/Codex) handle their own token refresh internally. We just re-extract periodically to capture refreshed tokens. +- Other providers beyond Claude and Codex. +- API key entry via UI (users sign in via CLI, not by pasting keys). +- Changes to the Sandbox Agent SDK's `autoAuthenticate` function.