mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-20 05:04:49 +00:00
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:
parent
3895e34bdb
commit
c1a4895303
14 changed files with 481 additions and 11 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue