Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion foundry/packages/backend/src/actors/organization/app-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 &&
Expand All @@ -313,6 +323,7 @@ export async function buildAppSnapshot(c: any, sessionId: string, allowOrganizat
skippedAt: profile?.starterRepoSkippedAt ?? null,
},
},
providerCredentials,
users: currentUser ? [currentUser] : [],
organizations,
};
Expand Down
103 changes: 102 additions & 1 deletion foundry/packages/backend/src/actors/task/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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> {
Expand All @@ -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;
Expand Down Expand Up @@ -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> {
Expand Down
45 changes: 44 additions & 1 deletion foundry/packages/backend/src/actors/user/actions/user.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions foundry/packages/backend/src/actors/user/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
};
8 changes: 8 additions & 0 deletions foundry/packages/backend/src/actors/user/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions foundry/packages/client/src/backend-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ function signedOutAppSnapshot(): FoundryAppSnapshot {
skippedAt: null,
},
},
providerCredentials: { anthropic: false, openai: false },
users: [],
organizations: [],
};
Expand Down
12 changes: 12 additions & 0 deletions foundry/packages/client/src/mock-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ export interface MockFoundryAppSnapshot {
skippedAt: number | null;
};
};
providerCredentials: {
anthropic: boolean;
openai: boolean;
};
users: MockFoundryUser[];
organizations: MockFoundryOrganization[];
}
Expand Down Expand Up @@ -229,6 +233,10 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
skippedAt: null,
},
},
providerCredentials: {
anthropic: false,
openai: false,
},
users: [
{
id: "user-nathan",
Expand Down Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions foundry/packages/client/src/mock/backend-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ function unsupportedAppSnapshot(): FoundryAppSnapshot {
skippedAt: null,
},
},
providerCredentials: { anthropic: false, openai: false },
users: [],
organizations: [],
};
Expand Down
1 change: 1 addition & 0 deletions foundry/packages/client/src/remote/app-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class RemoteFoundryAppStore implements FoundryAppClient {
skippedAt: null,
},
},
providerCredentials: { anthropic: false, openai: false },
users: [],
organizations: [],
};
Expand Down
6 changes: 5 additions & 1 deletion foundry/packages/frontend/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -150,14 +153,15 @@ function IndexRoute() {

function SignInRoute() {
const snapshot = useMockAppSnapshot();
const { error } = signInRoute.useSearch();
if (!isMockFrontendClient && isAppSnapshotBootstrapping(snapshot)) {
return <AppLoadingScreen label="Restoring Foundry session..." />;
}
if (snapshot.auth.status === "signed_in") {
return <IndexRoute />;
}

return <MockSignInPage />;
return <MockSignInPage error={error} />;
}

function AccountRoute() {
Expand Down
Loading
Loading