diff --git a/foundry/packages/backend/src/actors/organization/app-shell.ts b/foundry/packages/backend/src/actors/organization/app-shell.ts index ed1005a1..66a97796 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 5c49a4db..24d319c1 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 f251c958..e2cc0232 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 da92bdcd..26778acb 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 6a87a11e..d4001b7f 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 c2222cc9..09bfaedd 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 00fd9cae..e03df975 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 191f68c2..8eae0c2f 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 f1cb9089..f00b7fd8 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 dd227247..56d974a7 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 45286958..3be821b9 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 09b23ad5..22adb405 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 fa1e9697..8146e281 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 00000000..59db290f --- /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.