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 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-17 02:34:15 -07:00
parent 3895e34bdb
commit c1a4895303
14 changed files with 481 additions and 11 deletions

View file

@ -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,
};

View file

@ -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<void> {
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<Array<{ provider: string; credentialFileJson: string; filePath: string }>> {
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<boolean> {
@ -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<void> {
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<void> {

View file

@ -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(

View file

@ -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,
};

View file

@ -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",

View file

@ -404,6 +404,7 @@ function signedOutAppSnapshot(): FoundryAppSnapshot {
skippedAt: null,
},
},
providerCredentials: { anthropic: false, openai: false },
users: [],
organizations: [],
};

View file

@ -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: {

View file

@ -78,6 +78,7 @@ function unsupportedAppSnapshot(): FoundryAppSnapshot {
skippedAt: null,
},
},
providerCredentials: { anthropic: false, openai: false },
users: [],
organizations: [],
};

View file

@ -20,6 +20,7 @@ class RemoteFoundryAppStore implements FoundryAppClient {
skippedAt: null,
},
},
providerCredentials: { anthropic: false, openai: false },
users: [],
organizations: [],
};

View file

@ -29,6 +29,9 @@ const signInRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/signin",
component: SignInRoute,
validateSearch: (search: Record<string, unknown>): { 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 <AppLoadingScreen label="Restoring Foundry session..." />;
}
@ -157,7 +161,7 @@ function SignInRoute() {
return <IndexRoute />;
}
return <MockSignInPage />;
return <MockSignInPage error={error} />;
}
function AccountRoute() {

View file

@ -188,10 +188,16 @@ function MemberRow({ member }: { member: FoundryOrganizationMember }) {
);
}
export function MockSignInPage() {
const AUTH_ERROR_MESSAGES: Record<string, string> = {
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 (
<div
@ -253,6 +259,25 @@ export function MockSignInPage() {
Connect your GitHub account to get started.
</p>
{errorMessage && (
<p
style={{
fontSize: "13px",
fontWeight: 500,
color: "#f85149",
margin: "0 0 16px 0",
lineHeight: 1.5,
padding: "10px 14px",
background: "rgba(248, 81, 73, 0.1)",
borderRadius: "8px",
width: "100%",
textAlign: "center",
}}
>
{errorMessage}
</p>
)}
{/* GitHub sign-in button */}
<button
type="button"
@ -1172,6 +1197,8 @@ export function MockAccountSettingsPage() {
</div>
</SettingsContentSection>
<ProviderCredentialsSection />
<SettingsContentSection title="Sessions" description="Manage your active sessions across devices.">
<SettingsRow label="Current session" description="This device — signed in via GitHub OAuth." />
</SettingsContentSection>
@ -1222,6 +1249,48 @@ export function MockAccountSettingsPage() {
);
}
function ProviderCredentialsSection() {
const snapshot = useMockAppSnapshot();
const t = useFoundryTokens();
const providerCredentials = snapshot.providerCredentials ?? { anthropic: false, openai: false };
const providers = [
{
key: "anthropic" as const,
label: "Claude",
description: "Anthropic's Claude AI assistant.",
signedIn: providerCredentials.anthropic,
},
{
key: "openai" as const,
label: "Codex",
description: "OpenAI's Codex coding agent.",
signedIn: providerCredentials.openai,
},
];
return (
<SettingsContentSection title="Provider Credentials" description="Sign in to AI providers to use them in your tasks.">
{providers.map((provider) => (
<SettingsRow
key={provider.key}
label={provider.label}
description={provider.signedIn ? "Signed in" : "Not signed in"}
action={
provider.signedIn ? (
<span style={{ ...badgeStyle(t, "rgba(52, 211, 153, 0.12)", "rgb(52, 211, 153)"), fontSize: "10px" }}>Connected</span>
) : (
<button type="button" style={secondaryButtonStyle(t)}>
Sign in
</button>
)
}
/>
))}
</SettingsContentSection>
);
}
function AppearanceSection() {
const { colorMode, setColorMode } = useColorMode();
const t = useFoundryTokens();

View file

@ -32,6 +32,7 @@ const EMPTY_APP_SNAPSHOT: FoundryAppSnapshot = {
skippedAt: null,
},
},
providerCredentials: { anthropic: false, openai: false },
users: [],
organizations: [],
};

View file

@ -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[];
}