From 63b62e180d49853fa73699e055b364d8662456b9 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 11 Mar 2026 18:12:55 -0700 Subject: [PATCH] Improve Foundry auth and task flows --- foundry/CLAUDE.md | 4 + foundry/compose.dev.yaml | 15 +++ .../src/actors/project-pr-sync/index.ts | 4 +- .../backend/src/actors/project/actions.ts | 43 +++++- .../packages/backend/src/actors/task/index.ts | 6 +- .../backend/src/actors/task/workbench.ts | 29 ++++- .../backend/src/actors/task/workflow/index.ts | 5 +- .../backend/src/actors/task/workflow/init.ts | 10 +- .../src/actors/task/workflow/status-sync.ts | 9 +- .../backend/src/actors/workspace/actions.ts | 13 +- .../backend/src/actors/workspace/app-shell.ts | 64 +++++++-- .../src/actors/workspace/db/migrations.ts | 4 +- foundry/packages/backend/src/driver.ts | 22 ++-- foundry/packages/backend/src/index.ts | 122 ++++++++++++------ .../backend/src/integrations/git/index.ts | 93 ++++++++++--- .../backend/src/integrations/github/index.ts | 57 ++++++-- .../backend/src/providers/daytona/index.ts | 38 +++++- .../backend/src/providers/local/index.ts | 2 +- .../src/providers/provider-api/index.ts | 1 + .../backend/src/services/app-github.ts | 14 +- .../backend/src/services/github-auth.ts | 30 +++++ foundry/packages/frontend/src/app/router.tsx | 3 +- .../frontend/src/components/mock-layout.tsx | 86 +++++++++++- .../src/components/mock-layout/sidebar.tsx | 55 +++++++- .../packages/frontend/src/lib/workbench.ts | 25 +++- foundry/research/friction/general.mdx | 4 +- 26 files changed, 621 insertions(+), 137 deletions(-) create mode 100644 foundry/packages/backend/src/services/github-auth.ts diff --git a/foundry/CLAUDE.md b/foundry/CLAUDE.md index ee214a9..d25fbfa 100644 --- a/foundry/CLAUDE.md +++ b/foundry/CLAUDE.md @@ -164,7 +164,11 @@ For all Rivet/RivetKit implementation: - Integration tests use `setupTest()` from `rivetkit/test` and are gated behind `HF_ENABLE_ACTOR_INTEGRATION_TESTS=1`. - End-to-end testing must run against the dev backend started via `docker compose -f compose.dev.yaml up` (host -> container). Do not run E2E against an in-process test runtime. - E2E tests should talk to the backend over HTTP (default `http://127.0.0.1:7741/api/rivet`) and use real GitHub repos/PRs. + - For Foundry live verification, use `rivet-dev/sandbox-agent-testing` as the default testing repo unless the task explicitly says otherwise. - Secrets (e.g. `OPENAI_API_KEY`, `GITHUB_TOKEN`/`GH_TOKEN`) must be provided via environment variables, never hardcoded in the repo. + - `~/misc/env.txt` and `~/misc/the-foundry.env` contain the expected local OpenAI + GitHub OAuth/App config for dev. + - Do not assume `gh auth token` is sufficient for Foundry task provisioning against private repos. Sandbox/bootstrap git clone, push, and PR flows require a repo-capable `GITHUB_TOKEN`/`GH_TOKEN` in the backend container. + - Preferred product behavior for org workspaces is to mint a GitHub App installation token from the workspace installation and inject it into backend/sandbox git operations. Do not rely on an operator's ambient CLI auth as the long-term solution. - Treat client E2E tests in `packages/client/test` as the primary end-to-end source of truth for product behavior. - Keep backend tests small and targeted. Only retain backend-only tests for invariants or persistence rules that are not well-covered through client E2E. - Do not keep large browser E2E suites around in a broken state. If a frontend browser E2E is not maintained and producing signal, remove it until it can be replaced with a reliable test. diff --git a/foundry/compose.dev.yaml b/foundry/compose.dev.yaml index 43ec998..d2b604e 100644 --- a/foundry/compose.dev.yaml +++ b/foundry/compose.dev.yaml @@ -22,6 +22,21 @@ services: # Support either GITHUB_TOKEN or GITHUB_PAT in local env files. GITHUB_TOKEN: "${GITHUB_TOKEN:-${GITHUB_PAT:-}}" GH_TOKEN: "${GH_TOKEN:-${GITHUB_TOKEN:-${GITHUB_PAT:-}}}" + APP_URL: "${APP_URL:-}" + BETTER_AUTH_URL: "${BETTER_AUTH_URL:-}" + BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:-}" + GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID:-}" + GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET:-}" + GITHUB_REDIRECT_URI: "${GITHUB_REDIRECT_URI:-}" + GITHUB_APP_ID: "${GITHUB_APP_ID:-}" + GITHUB_APP_CLIENT_ID: "${GITHUB_APP_CLIENT_ID:-}" + GITHUB_APP_CLIENT_SECRET: "${GITHUB_APP_CLIENT_SECRET:-}" + GITHUB_APP_PRIVATE_KEY: "${GITHUB_APP_PRIVATE_KEY:-}" + GITHUB_WEBHOOK_SECRET: "${GITHUB_WEBHOOK_SECRET:-${GITHUB_APP_WEBHOOK_SECRET:-}}" + STRIPE_PUBLISHABLE_KEY: "${STRIPE_PUBLISHABLE_KEY:-}" + STRIPE_SECRET_KEY: "${STRIPE_SECRET_KEY:-}" + STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:-}" + STRIPE_PRICE_TEAM: "${STRIPE_PRICE_TEAM:-}" DAYTONA_ENDPOINT: "${DAYTONA_ENDPOINT:-}" DAYTONA_API_KEY: "${DAYTONA_API_KEY:-}" HF_DAYTONA_ENDPOINT: "${HF_DAYTONA_ENDPOINT:-}" diff --git a/foundry/packages/backend/src/actors/project-pr-sync/index.ts b/foundry/packages/backend/src/actors/project-pr-sync/index.ts index d79ded6..f46fd98 100644 --- a/foundry/packages/backend/src/actors/project-pr-sync/index.ts +++ b/foundry/packages/backend/src/actors/project-pr-sync/index.ts @@ -4,6 +4,7 @@ import { getActorRuntimeContext } from "../context.js"; import { getProject, selfProjectPrSync } from "../handles.js"; import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js"; import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js"; +import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js"; export interface ProjectPrSyncInput { workspaceId: string; @@ -31,7 +32,8 @@ const CONTROL = { async function pollPrs(c: { state: ProjectPrSyncState }): Promise { const { driver } = getActorRuntimeContext(); - const items = await driver.github.listPullRequests(c.state.repoPath); + const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId); + const items = await driver.github.listPullRequests(c.state.repoPath, { githubToken: auth?.githubToken ?? null }); const parent = getProject(c, c.state.workspaceId, c.state.repoId); await parent.applyPrSyncResult({ items, at: Date.now() }); } diff --git a/foundry/packages/backend/src/actors/project/actions.ts b/foundry/packages/backend/src/actors/project/actions.ts index 23d41a8..5ae47e6 100644 --- a/foundry/packages/backend/src/actors/project/actions.ts +++ b/foundry/packages/backend/src/actors/project/actions.ts @@ -7,6 +7,7 @@ import { getActorRuntimeContext } from "../context.js"; import { getTask, getOrCreateTask, getOrCreateHistory, getOrCreateProjectBranchSync, getOrCreateProjectPrSync, selfProject } from "../handles.js"; import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js"; import { foundryRepoClonePath } from "../../services/foundry-paths.js"; +import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js"; import { expectQueueResponse } from "../../services/queue.js"; import { withRepoGitLock } from "../../services/repo-git-lock.js"; import { branches, taskIndex, prCache, repoMeta } from "./db/schema.js"; @@ -112,7 +113,8 @@ export function projectWorkflowQueueName(name: ProjectQueueName): ProjectQueueNa async function ensureLocalClone(c: any, remoteUrl: string): Promise { const { config, driver } = getActorRuntimeContext(); const localPath = foundryRepoClonePath(config, c.state.workspaceId, c.state.repoId); - await driver.git.ensureCloned(remoteUrl, localPath); + const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId); + await driver.git.ensureCloned(remoteUrl, localPath, { githubToken: auth?.githubToken ?? null }); c.state.localPath = localPath; return localPath; } @@ -301,6 +303,26 @@ async function enrichTaskRecord(c: any, record: TaskRecord): Promise }; } +async function reinsertTaskIndexRow(c: any, taskId: string, branchName: string | null, updatedAt: number): Promise { + const now = Date.now(); + await c.db + .insert(taskIndex) + .values({ + taskId, + branchName, + createdAt: updatedAt || now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: taskIndex.taskId, + set: { + branchName, + updatedAt: now, + }, + }) + .run(); +} + async function ensureProjectMutation(c: any, cmd: EnsureProjectCommand): Promise { c.state.remoteUrl = cmd.remoteUrl; const localPath = await ensureLocalClone(c, cmd.remoteUrl); @@ -454,9 +476,10 @@ async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand let headSha = ""; let trackedInStack = false; let parentBranch: string | null = null; + const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId); await withRepoGitLock(localPath, async () => { - await driver.git.fetch(localPath); + await driver.git.fetch(localPath, { githubToken: auth?.githubToken ?? null }); const baseRef = await driver.git.remoteDefaultBaseRef(localPath); const normalizedBase = normalizeBaseBranchName(baseRef); @@ -467,8 +490,8 @@ async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand throw new Error(`Remote branch not found: ${branchName}`); } } else { - await driver.git.ensureRemoteBranch(localPath, branchName); - await driver.git.fetch(localPath); + await driver.git.ensureRemoteBranch(localPath, branchName, { githubToken: auth?.githubToken ?? null }); + await driver.git.fetch(localPath, { githubToken: auth?.githubToken ?? null }); try { headSha = await driver.git.revParse(localPath, `origin/${branchName}`); } catch { @@ -947,7 +970,17 @@ export const projectActions = { const row = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).where(eq(taskIndex.taskId, cmd.taskId)).get(); if (!row) { - throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`); + try { + const h = getTask(c, c.state.workspaceId, c.state.repoId, cmd.taskId); + const record = await h.get(); + await reinsertTaskIndexRow(c, cmd.taskId, record.branchName ?? null, record.updatedAt ?? Date.now()); + return await enrichTaskRecord(c, record); + } catch (error) { + if (isStaleTaskReferenceError(error)) { + throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`); + } + throw error; + } } try { diff --git a/foundry/packages/backend/src/actors/task/index.ts b/foundry/packages/backend/src/actors/task/index.ts index 2b2684d..242650b 100644 --- a/foundry/packages/backend/src/actors/task/index.ts +++ b/foundry/packages/backend/src/actors/task/index.ts @@ -142,10 +142,14 @@ export const task = actor({ async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> { const self = selfTask(c); - await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, { + const result = await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, { wait: true, timeout: 30 * 60_000, }); + const response = expectQueueResponse<{ ok: boolean; error?: string }>(result); + if (!response.ok) { + throw new Error(response.error ?? "task provisioning failed"); + } return { ok: true }; }, diff --git a/foundry/packages/backend/src/actors/task/workbench.ts b/foundry/packages/backend/src/actors/task/workbench.ts index 1a6c2ef..06a8805 100644 --- a/foundry/packages/backend/src/actors/task/workbench.ts +++ b/foundry/packages/backend/src/actors/task/workbench.ts @@ -2,7 +2,8 @@ import { basename } from "node:path"; import { asc, eq } from "drizzle-orm"; import { getActorRuntimeContext } from "../context.js"; -import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, getSandboxInstance } from "../handles.js"; +import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, getSandboxInstance, selfTask } from "../handles.js"; +import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js"; import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js"; import { getCurrentRecord } from "./workflow/common.js"; @@ -547,7 +548,26 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise { - const record = await ensureWorkbenchSeeded(c); + let record = await ensureWorkbenchSeeded(c); + if (!record.activeSandboxId) { + const providerId = record.providerId ?? c.state.providerId ?? getActorRuntimeContext().providers.defaultProviderId(); + await selfTask(c).provision({ providerId }); + record = await ensureWorkbenchSeeded(c); + } + + if (record.activeSessionId) { + const existingSessions = await listSessionMetaRows(c); + if (existingSessions.length === 0) { + await ensureSessionMeta(c, { + sessionId: record.activeSessionId, + model: model ?? defaultModelForAgent(record.agentType), + sessionName: "Session 1", + }); + await notifyWorkbenchUpdated(c); + return { tabId: record.activeSessionId }; + } + } + if (!record.activeSandboxId) { throw new Error("cannot create session without an active sandbox"); } @@ -783,7 +803,10 @@ export async function publishWorkbenchPr(c: any): Promise { throw new Error("cannot publish PR without a branch"); } const { driver } = getActorRuntimeContext(); - const created = await driver.github.createPr(c.state.repoLocalPath, record.branchName, record.title ?? c.state.task); + const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId); + const created = await driver.github.createPr(c.state.repoLocalPath, record.branchName, record.title ?? c.state.task, undefined, { + githubToken: auth?.githubToken ?? null, + }); await c.db .update(taskTable) .set({ diff --git a/foundry/packages/backend/src/actors/task/workflow/index.ts b/foundry/packages/backend/src/actors/task/workflow/index.ts index c1208db..e2da35c 100644 --- a/foundry/packages/backend/src/actors/task/workflow/index.ts +++ b/foundry/packages/backend/src/actors/task/workflow/index.ts @@ -104,7 +104,10 @@ const commandHandlers: Record = { await msg.complete({ ok: true }); } catch (error) { await loopCtx.step("init-failed-v2", async () => initFailedActivity(loopCtx, error)); - await msg.complete({ ok: false }); + await msg.complete({ + ok: false, + error: resolveErrorMessage(error), + }); } }, diff --git a/foundry/packages/backend/src/actors/task/workflow/init.ts b/foundry/packages/backend/src/actors/task/workflow/init.ts index 9e8b54f..922f5d9 100644 --- a/foundry/packages/backend/src/actors/task/workflow/init.ts +++ b/foundry/packages/backend/src/actors/task/workflow/init.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { desc, eq } from "drizzle-orm"; import { resolveCreateFlowDecision } from "../../../services/create-flow.js"; +import { resolveWorkspaceGithubAuth } from "../../../services/github-auth.js"; import { getActorRuntimeContext } from "../../context.js"; import { getOrCreateTaskStatusSync, getOrCreateHistory, getOrCreateProject, getOrCreateSandboxInstance, getSandboxInstance, selfTask } from "../../handles.js"; import { logActorWarning, resolveErrorMessage } from "../../logging.js"; @@ -150,8 +151,9 @@ export async function initEnsureNameActivity(loopCtx: any): Promise { } const { driver } = getActorRuntimeContext(); + const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId); try { - await driver.git.fetch(loopCtx.state.repoLocalPath); + await driver.git.fetch(loopCtx.state.repoLocalPath, { githubToken: auth?.githubToken ?? null }); } catch (error) { logActorWarning("task.init", "fetch before naming failed", { workspaceId: loopCtx.state.workspaceId, @@ -160,7 +162,9 @@ export async function initEnsureNameActivity(loopCtx: any): Promise { error: resolveErrorMessage(error), }); } - const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath)).map((branch: any) => branch.branchName); + const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath, { githubToken: auth?.githubToken ?? null })).map( + (branch: any) => branch.branchName, + ); const project = await getOrCreateProject(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, loopCtx.state.repoRemote); const reservedBranches = await project.listReservedBranches({}); @@ -274,6 +278,7 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis }); try { + const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId); const sandbox = await withActivityTimeout(timeoutMs, "createSandbox", async () => provider.createSandbox({ workspaceId: loopCtx.state.workspaceId, @@ -281,6 +286,7 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis repoRemote: loopCtx.state.repoRemote, branchName: loopCtx.state.branchName, taskId: loopCtx.state.taskId, + githubToken: auth?.githubToken ?? null, debug: (message, context) => debugInit(loopCtx, message, context), }), ); diff --git a/foundry/packages/backend/src/actors/task/workflow/status-sync.ts b/foundry/packages/backend/src/actors/task/workflow/status-sync.ts index 676b481..ea3b0c8 100644 --- a/foundry/packages/backend/src/actors/task/workflow/status-sync.ts +++ b/foundry/packages/backend/src/actors/task/workflow/status-sync.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { getActorRuntimeContext } from "../../context.js"; import { logActorWarning, resolveErrorMessage } from "../../logging.js"; +import { resolveWorkspaceGithubAuth } from "../../../services/github-auth.js"; import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js"; import { TASK_ROW_ID, appendHistory, resolveErrorDetail } from "./common.js"; import { pushActiveBranchActivity } from "./push.js"; @@ -77,8 +78,10 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise { if (self && self.prSubmitted) return; + const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId); + try { - await driver.git.fetch(loopCtx.state.repoLocalPath); + await driver.git.fetch(loopCtx.state.repoLocalPath, { githubToken: auth?.githubToken ?? null }); } catch (error) { logActorWarning("task.status-sync", "fetch before PR submit failed", { workspaceId: loopCtx.state.workspaceId, @@ -98,7 +101,9 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise { historyKind: "task.push.auto", }); - const pr = await driver.github.createPr(loopCtx.state.repoLocalPath, loopCtx.state.branchName, loopCtx.state.title); + const pr = await driver.github.createPr(loopCtx.state.repoLocalPath, loopCtx.state.branchName, loopCtx.state.title, undefined, { + githubToken: auth?.githubToken ?? null, + }); await db.update(taskTable).set({ prSubmitted: 1, updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run(); diff --git a/foundry/packages/backend/src/actors/workspace/actions.ts b/foundry/packages/backend/src/actors/workspace/actions.ts index 22bfe5a..584b045 100644 --- a/foundry/packages/backend/src/actors/workspace/actions.ts +++ b/foundry/packages/backend/src/actors/workspace/actions.ts @@ -34,6 +34,7 @@ import { getActorRuntimeContext } from "../context.js"; import { getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js"; import { logActorWarning, resolveErrorMessage } from "../logging.js"; import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js"; +import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js"; import { taskLookup, repos, providerProfiles } from "./db/schema.js"; import { agentTypeForModel } from "../task/workbench.js"; import { expectQueueResponse } from "../../services/queue.js"; @@ -213,7 +214,8 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise } const { driver } = getActorRuntimeContext(); - await driver.git.validateRemote(remoteUrl); + const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId); + await driver.git.validateRemote(remoteUrl, { githubToken: auth?.githubToken ?? null }); const repoId = repoIdFromRemote(remoteUrl); const now = Date.now(); @@ -439,7 +441,7 @@ export const workspaceActions = { c.broadcast("workbenchUpdated", { at: Date.now() }); }, - async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string }> { + async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string; tabId?: string }> { const created = await workspaceActions.createTask(c, { workspaceId: c.state.workspaceId, repoId: input.repoId, @@ -448,7 +450,12 @@ export const workspaceActions = { ...(input.branch ? { explicitBranchName: input.branch } : {}), ...(input.model ? { agentType: agentTypeForModel(input.model) } : {}), }); - return { taskId: created.taskId }; + const task = await requireWorkbenchTask(c, created.taskId); + const snapshot = await task.getWorkbench({}); + return { + taskId: created.taskId, + tabId: snapshot.tabs[0]?.id, + }; }, async markWorkbenchUnread(c: any, input: TaskWorkbenchSelectInput): Promise { diff --git a/foundry/packages/backend/src/actors/workspace/app-shell.ts b/foundry/packages/backend/src/actors/workspace/app-shell.ts index 8d99086..aff0fe1 100644 --- a/foundry/packages/backend/src/actors/workspace/app-shell.ts +++ b/foundry/packages/backend/src/actors/workspace/app-shell.ts @@ -56,6 +56,10 @@ function splitScopes(value: string): string[] { .filter((entry) => entry.length > 0); } +function hasRepoScope(scopes: string[]): boolean { + return scopes.some((scope) => scope === "repo" || scope.startsWith("repo:")); +} + function parseEligibleOrganizationIds(value: string): string[] { try { const parsed = JSON.parse(value); @@ -568,6 +572,32 @@ export const workspaceAppActions = { return await buildAppSnapshot(c, input.sessionId); }, + async resolveAppGithubToken( + c: any, + input: { organizationId: string; requireRepoScope?: boolean }, + ): Promise<{ accessToken: string; scopes: string[] } | null> { + assertAppWorkspace(c); + const rows = await c.db.select().from(appSessions).orderBy(desc(appSessions.updatedAt)).all(); + + for (const row of rows) { + if (row.activeOrganizationId !== input.organizationId || !row.githubAccessToken) { + continue; + } + + const scopes = splitScopes(row.githubScope); + if (input.requireRepoScope !== false && !hasRepoScope(scopes)) { + continue; + } + + return { + accessToken: row.githubAccessToken, + scopes, + }; + } + + return null; + }, + async startAppGithubAuth(c: any, input: { sessionId: string }): Promise<{ url: string }> { assertAppWorkspace(c); const { appShell } = getActorRuntimeContext(); @@ -702,18 +732,34 @@ export const workspaceAppActions = { }); try { - const repositories = - organization.snapshot.kind === "personal" - ? await appShell.github.listUserRepositories(session.githubAccessToken) - : organization.githubInstallationId - ? await appShell.github.listInstallationRepositories(organization.githubInstallationId) - : (() => { - throw new GitHubAppError("GitHub App installation required before importing repositories", 400); - })(); + let repositories; + let installationStatus = organization.snapshot.github.installationStatus; + + if (organization.snapshot.kind === "personal") { + repositories = await appShell.github.listUserRepositories(session.githubAccessToken); + installationStatus = "connected"; + } else if (organization.githubInstallationId) { + try { + repositories = await appShell.github.listInstallationRepositories(organization.githubInstallationId); + } catch (error) { + if (!(error instanceof GitHubAppError) || (error.status !== 403 && error.status !== 404)) { + throw error; + } + repositories = (await appShell.github.listUserRepositories(session.githubAccessToken)).filter((repository) => + repository.fullName.startsWith(`${organization.githubLogin}/`), + ); + installationStatus = "reconnect_required"; + } + } else { + repositories = (await appShell.github.listUserRepositories(session.githubAccessToken)).filter((repository) => + repository.fullName.startsWith(`${organization.githubLogin}/`), + ); + installationStatus = "reconnect_required"; + } await workspace.applyOrganizationSyncCompleted({ repositories, - installationStatus: organization.snapshot.kind === "personal" ? "connected" : organization.snapshot.github.installationStatus, + installationStatus, lastSyncLabel: repositories.length > 0 ? "Synced just now" : "No repositories available", }); } catch (error) { diff --git a/foundry/packages/backend/src/actors/workspace/db/migrations.ts b/foundry/packages/backend/src/actors/workspace/db/migrations.ts index a6596f7..5aa7f6d 100644 --- a/foundry/packages/backend/src/actors/workspace/db/migrations.ts +++ b/foundry/packages/backend/src/actors/workspace/db/migrations.ts @@ -181,9 +181,7 @@ SET \`github_sync_status\` = CASE ELSE 'pending' END; `, - m0010: `ALTER TABLE \`app_sessions\` ADD COLUMN \`starter_repo_status\` text NOT NULL DEFAULT 'pending'; -ALTER TABLE \`app_sessions\` ADD COLUMN \`starter_repo_starred_at\` integer; -ALTER TABLE \`app_sessions\` ADD COLUMN \`starter_repo_skipped_at\` integer; + m0010: `-- no-op: starter_repo_* columns are already present in m0007 app_sessions `, } as const, }; diff --git a/foundry/packages/backend/src/driver.ts b/foundry/packages/backend/src/driver.ts index def33cf..4e1d248 100644 --- a/foundry/packages/backend/src/driver.ts +++ b/foundry/packages/backend/src/driver.ts @@ -40,13 +40,13 @@ import { SandboxAgentClient } from "./integrations/sandbox-agent/client.js"; import { DaytonaClient } from "./integrations/daytona/client.js"; export interface GitDriver { - validateRemote(remoteUrl: string): Promise; - ensureCloned(remoteUrl: string, targetPath: string): Promise; - fetch(repoPath: string): Promise; - listRemoteBranches(repoPath: string): Promise; + validateRemote(remoteUrl: string, options?: { githubToken?: string | null }): Promise; + ensureCloned(remoteUrl: string, targetPath: string, options?: { githubToken?: string | null }): Promise; + fetch(repoPath: string, options?: { githubToken?: string | null }): Promise; + listRemoteBranches(repoPath: string, options?: { githubToken?: string | null }): Promise; remoteDefaultBaseRef(repoPath: string): Promise; revParse(repoPath: string, ref: string): Promise; - ensureRemoteBranch(repoPath: string, branchName: string): Promise; + ensureRemoteBranch(repoPath: string, branchName: string, options?: { githubToken?: string | null }): Promise; diffStatForBranch(repoPath: string, branchName: string): Promise; conflictsWithMain(repoPath: string, branchName: string): Promise; } @@ -68,9 +68,15 @@ export interface StackDriver { } export interface GithubDriver { - listPullRequests(repoPath: string): Promise; - createPr(repoPath: string, headBranch: string, title: string, body?: string): Promise<{ number: number; url: string }>; - starRepository(repoFullName: string): Promise; + listPullRequests(repoPath: string, options?: { githubToken?: string | null }): Promise; + createPr( + repoPath: string, + headBranch: string, + title: string, + body?: string, + options?: { githubToken?: string | null }, + ): Promise<{ number: number; url: string }>; + starRepository(repoFullName: string, options?: { githubToken?: string | null }): Promise; } export interface SandboxAgentClientLike { diff --git a/foundry/packages/backend/src/index.ts b/foundry/packages/backend/src/index.ts index 66643bc..d214daf 100644 --- a/foundry/packages/backend/src/index.ts +++ b/foundry/packages/backend/src/index.ts @@ -17,6 +17,28 @@ export interface BackendStartOptions { port?: number; } +function isRetryableAppActorError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("Actor not ready") || message.includes("socket connection was closed unexpectedly"); +} + +async function withRetries(run: () => Promise, attempts = 20, delayMs = 250): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await run(); + } catch (error) { + lastError = error; + if (!isRetryableAppActorError(error) || attempt === attempts) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + export async function startBackend(options: BackendStartOptions = {}): Promise { // sandbox-agent agent plugins vary on which env var they read for OpenAI/Codex auth. // Normalize to keep local dev + docker-compose simple. @@ -48,9 +70,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { try { - // RivetKit serverless handler is configured with basePath `/api/rivet` by default. - return await inner.fetch(c.req.raw); + // Proxy /api/rivet traffic to the long-lived RivetKit manager rather than + // invoking RivetKit's serverless entrypoints in-process. + const requestUrl = new URL(c.req.url); + const managerPath = requestUrl.pathname.replace(/^\/api\/rivet(?=\/|$)/, "") || "/"; + const targetUrl = new URL(`${managerPath}${requestUrl.search}`, managerOrigin); + return await fetch(new Request(targetUrl, c.req.raw)); } catch (err) { if (err instanceof URIError) { return c.text("Bad Request: Malformed URI", 400); @@ -109,27 +135,32 @@ export async function startBackend(options: BackendStartOptions = {}): Promise - await actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), { - createWithInput: APP_SHELL_WORKSPACE_ID, - }); + await withRetries( + async () => + await actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), { + createWithInput: APP_SHELL_WORKSPACE_ID, + }), + ); + + const appWorkspaceAction = async (run: (workspace: any) => Promise): Promise => await withRetries(async () => await run(await appWorkspace())); const resolveSessionId = async (c: any): Promise => { const requested = c.req.header("x-foundry-session"); - const { sessionId } = await (await appWorkspace()).ensureAppSession({ - requestedSessionId: requested ?? null, - }); + const { sessionId } = await appWorkspaceAction( + async (workspace) => await workspace.ensureAppSession(requested && requested.trim().length > 0 ? { requestedSessionId: requested } : {}), + ); c.header("x-foundry-session", sessionId); return sessionId; }; app.get("/api/rivet/app/snapshot", async (c) => { const sessionId = await resolveSessionId(c); - return c.json(await (await appWorkspace()).getAppSnapshot({ sessionId })); + return c.json(await appWorkspaceAction(async (workspace) => await workspace.getAppSnapshot({ sessionId }))); }); app.get("/api/rivet/app/auth/github/start", async (c) => { const sessionId = await resolveSessionId(c); - const result = await (await appWorkspace()).startAppGithubAuth({ sessionId }); + const result = await appWorkspaceAction(async (workspace) => await workspace.startAppGithubAuth({ sessionId })); return Response.redirect(result.url, 302); }); @@ -139,38 +170,44 @@ export async function startBackend(options: BackendStartOptions = {}): Promise await workspace.completeAppGithubAuth({ code, state })); c.header("x-foundry-session", result.sessionId); return Response.redirect(result.redirectTo, 302); }); app.post("/api/rivet/app/sign-out", async (c) => { const sessionId = await resolveSessionId(c); - return c.json(await (await appWorkspace()).signOutApp({ sessionId })); + return c.json(await appWorkspaceAction(async (workspace) => await workspace.signOutApp({ sessionId }))); }); app.post("/api/rivet/app/onboarding/starter-repo/skip", async (c) => { const sessionId = await resolveSessionId(c); - return c.json(await (await appWorkspace()).skipAppStarterRepo({ sessionId })); + return c.json(await appWorkspaceAction(async (workspace) => await workspace.skipAppStarterRepo({ sessionId }))); }); app.post("/api/rivet/app/organizations/:organizationId/starter-repo/star", async (c) => { const sessionId = await resolveSessionId(c); return c.json( - await (await appWorkspace()).starAppStarterRepo({ - sessionId, - organizationId: c.req.param("organizationId"), - }), + await appWorkspaceAction( + async (workspace) => + await workspace.starAppStarterRepo({ + sessionId, + organizationId: c.req.param("organizationId"), + }), + ), ); }); app.post("/api/rivet/app/organizations/:organizationId/select", async (c) => { const sessionId = await resolveSessionId(c); return c.json( - await (await appWorkspace()).selectAppOrganization({ - sessionId, - organizationId: c.req.param("organizationId"), - }), + await appWorkspaceAction( + async (workspace) => + await workspace.selectAppOrganization({ + sessionId, + organizationId: c.req.param("organizationId"), + }), + ), ); }); @@ -178,33 +215,42 @@ export async function startBackend(options: BackendStartOptions = {}): Promise + await workspace.updateAppOrganizationProfile({ + sessionId, + organizationId: c.req.param("organizationId"), + displayName: typeof body?.displayName === "string" ? body.displayName : "", + slug: typeof body?.slug === "string" ? body.slug : "", + primaryDomain: typeof body?.primaryDomain === "string" ? body.primaryDomain : "", + }), + ), ); }); app.post("/api/rivet/app/organizations/:organizationId/import", async (c) => { const sessionId = await resolveSessionId(c); return c.json( - await (await appWorkspace()).triggerAppRepoImport({ - sessionId, - organizationId: c.req.param("organizationId"), - }), + await appWorkspaceAction( + async (workspace) => + await workspace.triggerAppRepoImport({ + sessionId, + organizationId: c.req.param("organizationId"), + }), + ), ); }); app.post("/api/rivet/app/organizations/:organizationId/reconnect", async (c) => { const sessionId = await resolveSessionId(c); return c.json( - await (await appWorkspace()).beginAppGithubInstall({ - sessionId, - organizationId: c.req.param("organizationId"), - }), + await appWorkspaceAction( + async (workspace) => + await workspace.beginAppGithubInstall({ + sessionId, + organizationId: c.req.param("organizationId"), + }), + ), ); }); diff --git a/foundry/packages/backend/src/integrations/git/index.ts b/foundry/packages/backend/src/integrations/git/index.ts index 38e7b0b..1b478c4 100644 --- a/foundry/packages/backend/src/integrations/git/index.ts +++ b/foundry/packages/backend/src/integrations/git/index.ts @@ -10,8 +10,12 @@ const DEFAULT_GIT_VALIDATE_REMOTE_TIMEOUT_MS = 15_000; const DEFAULT_GIT_FETCH_TIMEOUT_MS = 2 * 60_000; const DEFAULT_GIT_CLONE_TIMEOUT_MS = 5 * 60_000; -function resolveGithubToken(): string | null { - const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? process.env.HF_GITHUB_TOKEN ?? process.env.HF_GH_TOKEN ?? null; +interface GitAuthOptions { + githubToken?: string | null; +} + +function resolveGithubToken(options?: GitAuthOptions): string | null { + const token = options?.githubToken ?? process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? process.env.HF_GITHUB_TOKEN ?? process.env.HF_GH_TOKEN ?? null; if (!token) return null; const trimmed = token.trim(); return trimmed.length > 0 ? trimmed : null; @@ -47,30 +51,45 @@ function ensureAskpassScript(): string { return path; } -function gitEnv(): Record { +function gitEnv(options?: GitAuthOptions): Record { const env: Record = { ...(process.env as Record) }; env.GIT_TERMINAL_PROMPT = "0"; - const token = resolveGithubToken(); + const token = resolveGithubToken(options); if (token) { env.GIT_ASKPASS = ensureAskpassScript(); // Some tooling expects these vars; keep them aligned. - env.GITHUB_TOKEN = env.GITHUB_TOKEN || token; - env.GH_TOKEN = env.GH_TOKEN || token; + env.GITHUB_TOKEN = token; + env.GH_TOKEN = token; } return env; } +async function configureGithubAuth(repoPath: string, options?: GitAuthOptions): Promise { + const token = resolveGithubToken(options); + if (!token) { + return; + } + + const authHeader = Buffer.from(`x-access-token:${token}`, "utf8").toString("base64"); + await execFileAsync("git", ["-C", repoPath, "config", "--local", "credential.helper", ""], { + env: gitEnv(options), + }); + await execFileAsync("git", ["-C", repoPath, "config", "--local", "http.https://github.com/.extraheader", `AUTHORIZATION: basic ${authHeader}`], { + env: gitEnv(options), + }); +} + export interface BranchSnapshot { branchName: string; commitSha: string; } -export async function fetch(repoPath: string): Promise { +export async function fetch(repoPath: string, options?: GitAuthOptions): Promise { await execFileAsync("git", ["-C", repoPath, "fetch", "--prune"], { timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS, - env: gitEnv(), + env: gitEnv(options), }); } @@ -79,7 +98,7 @@ export async function revParse(repoPath: string, ref: string): Promise { return stdout.trim(); } -export async function validateRemote(remoteUrl: string): Promise { +export async function validateRemote(remoteUrl: string, options?: GitAuthOptions): Promise { const remote = remoteUrl.trim(); if (!remote) { throw new Error("remoteUrl is required"); @@ -91,7 +110,7 @@ export async function validateRemote(remoteUrl: string): Promise { cwd: tmpdir(), maxBuffer: 1024 * 1024, timeout: DEFAULT_GIT_VALIDATE_REMOTE_TIMEOUT_MS, - env: gitEnv(), + env: gitEnv(options), }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); @@ -103,7 +122,7 @@ function isGitRepo(path: string): boolean { return existsSync(resolve(path, ".git")); } -export async function ensureCloned(remoteUrl: string, targetPath: string): Promise { +export async function ensureCloned(remoteUrl: string, targetPath: string, options?: GitAuthOptions): Promise { const remote = remoteUrl.trim(); if (!remote) { throw new Error("remoteUrl is required"); @@ -118,9 +137,10 @@ export async function ensureCloned(remoteUrl: string, targetPath: string): Promi await execFileAsync("git", ["-C", targetPath, "remote", "set-url", "origin", remote], { maxBuffer: 1024 * 1024, timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS, - env: gitEnv(), + env: gitEnv(options), }); - await fetch(targetPath); + await configureGithubAuth(targetPath, options); + await fetch(targetPath, options); return; } @@ -128,9 +148,40 @@ export async function ensureCloned(remoteUrl: string, targetPath: string): Promi await execFileAsync("git", ["clone", remote, targetPath], { maxBuffer: 1024 * 1024 * 8, timeout: DEFAULT_GIT_CLONE_TIMEOUT_MS, + env: gitEnv(options), + }); + await configureGithubAuth(targetPath, options); + await fetch(targetPath, options); + await ensureLocalBaseBranch(targetPath); +} + +async function hasLocalBranches(repoPath: string): Promise { + try { + const { stdout } = await execFileAsync("git", ["-C", repoPath, "for-each-ref", "--format=%(refname:short)", "refs/heads"], { + env: gitEnv(), + }); + return stdout + .split("\n") + .map((line) => line.trim()) + .some(Boolean); + } catch { + return false; + } +} + +async function ensureLocalBaseBranch(repoPath: string): Promise { + if (await hasLocalBranches(repoPath)) { + return; + } + + const baseRef = await remoteDefaultBaseRef(repoPath); + const localBranch = baseRef.replace(/^origin\//, ""); + + await execFileAsync("git", ["-C", repoPath, "checkout", "-B", localBranch, baseRef], { + maxBuffer: 1024 * 1024, + timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS, env: gitEnv(), }); - await fetch(targetPath); } export async function remoteDefaultBaseRef(repoPath: string): Promise { @@ -157,10 +208,11 @@ export async function remoteDefaultBaseRef(repoPath: string): Promise { return "origin/main"; } -export async function listRemoteBranches(repoPath: string): Promise { +export async function listRemoteBranches(repoPath: string, options?: GitAuthOptions): Promise { + await fetch(repoPath, options); const { stdout } = await execFileAsync("git", ["-C", repoPath, "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/remotes/origin"], { maxBuffer: 1024 * 1024, - env: gitEnv(), + env: gitEnv(options), }); return stdout @@ -185,8 +237,9 @@ async function remoteBranchExists(repoPath: string, branchName: string): Promise } } -export async function ensureRemoteBranch(repoPath: string, branchName: string): Promise { - await fetch(repoPath); +export async function ensureRemoteBranch(repoPath: string, branchName: string, options?: GitAuthOptions): Promise { + await fetch(repoPath, options); + await ensureLocalBaseBranch(repoPath); if (await remoteBranchExists(repoPath, branchName)) { return; } @@ -194,9 +247,9 @@ export async function ensureRemoteBranch(repoPath: string, branchName: string): const baseRef = await remoteDefaultBaseRef(repoPath); await execFileAsync("git", ["-C", repoPath, "push", "origin", `${baseRef}:refs/heads/${branchName}`], { maxBuffer: 1024 * 1024 * 2, - env: gitEnv(), + env: gitEnv(options), }); - await fetch(repoPath); + await fetch(repoPath, options); } export async function diffStatForBranch(repoPath: string, branchName: string): Promise { diff --git a/foundry/packages/backend/src/integrations/github/index.ts b/foundry/packages/backend/src/integrations/github/index.ts index 48e1262..536c9db 100644 --- a/foundry/packages/backend/src/integrations/github/index.ts +++ b/foundry/packages/backend/src/integrations/github/index.ts @@ -3,6 +3,20 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); +interface GithubAuthOptions { + githubToken?: string | null; +} + +function ghEnv(options?: GithubAuthOptions): Record { + const env: Record = { ...(process.env as Record) }; + const token = options?.githubToken?.trim(); + if (token) { + env.GH_TOKEN = token; + env.GITHUB_TOKEN = token; + } + return env; +} + export interface PullRequestSnapshot { number: number; headRefName: string; @@ -117,9 +131,13 @@ function snapshotFromGhItem(item: GhPrListItem): PullRequestSnapshot { const PR_JSON_FIELDS = "number,headRefName,state,title,url,author,isDraft,statusCheckRollup,reviews"; -export async function listPullRequests(repoPath: string): Promise { +export async function listPullRequests(repoPath: string, options?: GithubAuthOptions): Promise { try { - const { stdout } = await execFileAsync("gh", ["pr", "list", "--json", PR_JSON_FIELDS, "--limit", "200"], { maxBuffer: 1024 * 1024 * 4, cwd: repoPath }); + const { stdout } = await execFileAsync("gh", ["pr", "list", "--json", PR_JSON_FIELDS, "--limit", "200"], { + maxBuffer: 1024 * 1024 * 4, + cwd: repoPath, + env: ghEnv(options), + }); const parsed = JSON.parse(stdout) as GhPrListItem[]; @@ -134,9 +152,13 @@ export async function listPullRequests(repoPath: string): Promise { +export async function getPrInfo(repoPath: string, branchName: string, options?: GithubAuthOptions): Promise { try { - const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", PR_JSON_FIELDS], { maxBuffer: 1024 * 1024 * 4, cwd: repoPath }); + const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", PR_JSON_FIELDS], { + maxBuffer: 1024 * 1024 * 4, + cwd: repoPath, + env: ghEnv(options), + }); const item = JSON.parse(stdout) as GhPrListItem; return snapshotFromGhItem(item); @@ -145,7 +167,13 @@ export async function getPrInfo(repoPath: string, branchName: string): Promise

{ +export async function createPr( + repoPath: string, + headBranch: string, + title: string, + body?: string, + options?: GithubAuthOptions, +): Promise<{ number: number; url: string }> { const args = ["pr", "create", "--title", title, "--head", headBranch]; if (body) { args.push("--body", body); @@ -156,6 +184,7 @@ export async function createPr(repoPath: string, headBranch: string, title: stri const { stdout } = await execFileAsync("gh", args, { maxBuffer: 1024 * 1024, cwd: repoPath, + env: ghEnv(options), }); // gh pr create outputs the PR URL on success @@ -167,10 +196,11 @@ export async function createPr(repoPath: string, headBranch: string, title: stri return { number, url }; } -export async function starRepository(repoFullName: string): Promise { +export async function starRepository(repoFullName: string, options?: GithubAuthOptions): Promise { try { await execFileAsync("gh", ["api", "--method", "PUT", `user/starred/${repoFullName}`], { maxBuffer: 1024 * 1024, + env: ghEnv(options), }); } catch (error) { const message = @@ -179,16 +209,17 @@ export async function starRepository(repoFullName: string): Promise { } } -export async function getAllowedMergeMethod(repoPath: string): Promise<"squash" | "rebase" | "merge"> { +export async function getAllowedMergeMethod(repoPath: string, options?: GithubAuthOptions): Promise<"squash" | "rebase" | "merge"> { try { // Get the repo owner/name from gh - const { stdout: repoJson } = await execFileAsync("gh", ["repo", "view", "--json", "owner,name"], { cwd: repoPath }); + const { stdout: repoJson } = await execFileAsync("gh", ["repo", "view", "--json", "owner,name"], { cwd: repoPath, env: ghEnv(options) }); const repo = JSON.parse(repoJson) as { owner: { login: string }; name: string }; const repoFullName = `${repo.owner.login}/${repo.name}`; const { stdout } = await execFileAsync("gh", ["api", `repos/${repoFullName}`, "--jq", ".allow_squash_merge, .allow_rebase_merge, .allow_merge_commit"], { maxBuffer: 1024 * 1024, cwd: repoPath, + env: ghEnv(options), }); const lines = stdout.trim().split("\n"); @@ -205,14 +236,14 @@ export async function getAllowedMergeMethod(repoPath: string): Promise<"squash" } } -export async function mergePr(repoPath: string, prNumber: number): Promise { - const method = await getAllowedMergeMethod(repoPath); - await execFileAsync("gh", ["pr", "merge", String(prNumber), `--${method}`, "--delete-branch"], { cwd: repoPath }); +export async function mergePr(repoPath: string, prNumber: number, options?: GithubAuthOptions): Promise { + const method = await getAllowedMergeMethod(repoPath, options); + await execFileAsync("gh", ["pr", "merge", String(prNumber), `--${method}`, "--delete-branch"], { cwd: repoPath, env: ghEnv(options) }); } -export async function isPrMerged(repoPath: string, branchName: string): Promise { +export async function isPrMerged(repoPath: string, branchName: string, options?: GithubAuthOptions): Promise { try { - const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", "state"], { cwd: repoPath }); + const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", "state"], { cwd: repoPath, env: ghEnv(options) }); const parsed = JSON.parse(stdout) as { state: string }; return parsed.state.toUpperCase() === "MERGED"; } catch { diff --git a/foundry/packages/backend/src/providers/daytona/index.ts b/foundry/packages/backend/src/providers/daytona/index.ts index c15170d..8166668 100644 --- a/foundry/packages/backend/src/providers/daytona/index.ts +++ b/foundry/packages/backend/src/providers/daytona/index.ts @@ -145,6 +145,18 @@ export class DaytonaProvider implements SandboxProvider { return envVars; } + private buildShellExports(extra: Record = {}): string[] { + const merged = { + ...this.buildEnvVars(), + ...extra, + }; + + return Object.entries(merged).map(([key, value]) => { + const encoded = Buffer.from(value, "utf8").toString("base64"); + return `export ${key}="$(printf %s ${JSON.stringify(encoded)} | base64 -d)"`; + }); + } + private buildSnapshotImage() { // Use Daytona image build + snapshot caching so base tooling (git + sandbox-agent) // is prepared once and reused for subsequent sandboxes. @@ -240,13 +252,18 @@ export class DaytonaProvider implements SandboxProvider { "set -euo pipefail", "export GIT_TERMINAL_PROMPT=0", "export GIT_ASKPASS=/bin/echo", + `TOKEN=${JSON.stringify(req.githubToken ?? "")}`, + 'if [ -z "$TOKEN" ]; then TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}"; fi', + "GIT_AUTH_ARGS=()", + `if [ -n "$TOKEN" ] && [[ "${req.repoRemote}" == https://github.com/* ]]; then AUTH_HEADER="$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\\n')"; GIT_AUTH_ARGS=(-c "http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_HEADER"); fi`, `rm -rf "${repoDir}"`, `mkdir -p "${repoDir}"`, `rmdir "${repoDir}"`, - // Clone without embedding credentials. Auth for pushing is configured by the agent at runtime. - `git clone "${req.repoRemote}" "${repoDir}"`, + // Foundry test repos can be private, so clone/fetch must use the sandbox's GitHub token when available. + `git "\${GIT_AUTH_ARGS[@]}" clone "${req.repoRemote}" "${repoDir}"`, `cd "${repoDir}"`, - `git fetch origin --prune`, + `if [ -n "$TOKEN" ] && [[ "${req.repoRemote}" == https://github.com/* ]]; then git config --local credential.helper ""; git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic $AUTH_HEADER"; fi`, + `git "\${GIT_AUTH_ARGS[@]}" fetch origin --prune`, // The task branch may not exist remotely yet (agent push creates it). Base off current branch (default branch). `if git show-ref --verify --quiet "refs/remotes/origin/${req.branchName}"; then git checkout -B "${req.branchName}" "origin/${req.branchName}"; else git checkout -B "${req.branchName}" "$(git branch --show-current 2>/dev/null || echo main)"; fi`, `git config user.email "foundry@local" >/dev/null 2>&1 || true`, @@ -337,6 +354,9 @@ export class DaytonaProvider implements SandboxProvider { async ensureSandboxAgent(req: EnsureAgentRequest): Promise { const client = this.requireClient(); const acpRequestTimeoutMs = this.getAcpRequestTimeoutMs(); + const sandboxAgentExports = this.buildShellExports({ + SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS: acpRequestTimeoutMs.toString(), + }); await this.ensureStarted(req.sandboxId); @@ -387,7 +407,17 @@ export class DaytonaProvider implements SandboxProvider { [ "bash", "-lc", - `'set -euo pipefail; export PATH="$HOME/.local/bin:$PATH"; command -v sandbox-agent >/dev/null 2>&1; if pgrep -x sandbox-agent >/dev/null; then exit 0; fi; nohup env SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=${acpRequestTimeoutMs} sandbox-agent server --no-token --host 0.0.0.0 --port ${DaytonaProvider.SANDBOX_AGENT_PORT} >/tmp/sandbox-agent.log 2>&1 &'`, + JSON.stringify( + [ + "set -euo pipefail", + 'export PATH="$HOME/.local/bin:$PATH"', + ...sandboxAgentExports, + "command -v sandbox-agent >/dev/null 2>&1", + "if pgrep -x sandbox-agent >/dev/null; then exit 0; fi", + 'rm -f "$HOME/.codex/auth.json" "$HOME/.config/codex/auth.json"', + `nohup sandbox-agent server --no-token --host 0.0.0.0 --port ${DaytonaProvider.SANDBOX_AGENT_PORT} >/tmp/sandbox-agent.log 2>&1 &`, + ].join("; "), + ), ].join(" "), "start sandbox-agent", ); diff --git a/foundry/packages/backend/src/providers/local/index.ts b/foundry/packages/backend/src/providers/local/index.ts index 9945216..f18313a 100644 --- a/foundry/packages/backend/src/providers/local/index.ts +++ b/foundry/packages/backend/src/providers/local/index.ts @@ -149,7 +149,7 @@ export class LocalProvider implements SandboxProvider { const sandboxId = req.taskId || `local-${randomUUID()}`; const repoDir = this.repoDir(req.workspaceId, sandboxId); mkdirSync(dirname(repoDir), { recursive: true }); - await this.git.ensureCloned(req.repoRemote, repoDir); + await this.git.ensureCloned(req.repoRemote, repoDir, { githubToken: req.githubToken }); await checkoutBranch(repoDir, req.branchName, this.git); return this.sandboxHandle(req.workspaceId, sandboxId, repoDir); } diff --git a/foundry/packages/backend/src/providers/provider-api/index.ts b/foundry/packages/backend/src/providers/provider-api/index.ts index c772b46..a15109d 100644 --- a/foundry/packages/backend/src/providers/provider-api/index.ts +++ b/foundry/packages/backend/src/providers/provider-api/index.ts @@ -11,6 +11,7 @@ export interface CreateSandboxRequest { repoRemote: string; branchName: string; taskId: string; + githubToken?: string | null; debug?: (message: string, context?: Record) => void; options?: Record; } diff --git a/foundry/packages/backend/src/services/app-github.ts b/foundry/packages/backend/src/services/app-github.ts index c18254e..476562a 100644 --- a/foundry/packages/backend/src/services/app-github.ts +++ b/foundry/packages/backend/src/services/app-github.ts @@ -73,6 +73,14 @@ export interface GitHubAppClientOptions { webhookSecret?: string; } +function normalizePem(value: string | undefined): string | undefined { + if (!value) { + return value; + } + + return value.includes("\\n") ? value.replace(/\\n/g, "\n") : value; +} + export class GitHubAppClient { private readonly apiBaseUrl: string; private readonly authBaseUrl: string; @@ -90,7 +98,7 @@ export class GitHubAppClient { this.clientSecret = options.clientSecret ?? process.env.GITHUB_CLIENT_SECRET; this.redirectUri = options.redirectUri ?? process.env.GITHUB_REDIRECT_URI; this.appId = options.appId ?? process.env.GITHUB_APP_ID; - this.appPrivateKey = options.appPrivateKey ?? process.env.GITHUB_APP_PRIVATE_KEY; + this.appPrivateKey = normalizePem(options.appPrivateKey ?? process.env.GITHUB_APP_PRIVATE_KEY); this.webhookSecret = options.webhookSecret ?? process.env.GITHUB_WEBHOOK_SECRET; } @@ -143,7 +151,7 @@ export class GitHubAppClient { const url = new URL(`${this.authBaseUrl}/login/oauth/authorize`); url.searchParams.set("client_id", this.clientId); url.searchParams.set("redirect_uri", this.redirectUri); - url.searchParams.set("scope", "read:user user:email read:org"); + url.searchParams.set("scope", "read:user user:email read:org repo"); url.searchParams.set("state", state); return url.toString(); } @@ -273,7 +281,7 @@ export class GitHubAppClient { full_name: string; clone_url: string; private: boolean; - }>("/user/repos?per_page=100&affiliation=owner&sort=updated", accessToken); + }>("/user/repos?per_page=100&affiliation=owner,collaborator,organization_member&sort=updated", accessToken); return repositories.map((repository) => ({ fullName: repository.full_name, diff --git a/foundry/packages/backend/src/services/github-auth.ts b/foundry/packages/backend/src/services/github-auth.ts new file mode 100644 index 0000000..8249927 --- /dev/null +++ b/foundry/packages/backend/src/services/github-auth.ts @@ -0,0 +1,30 @@ +import { getOrCreateWorkspace } from "../actors/handles.js"; +import { APP_SHELL_WORKSPACE_ID } from "../actors/workspace/app-shell.js"; + +export interface ResolvedGithubAuth { + githubToken: string; + scopes: string[]; +} + +export async function resolveWorkspaceGithubAuth(c: any, workspaceId: string): Promise { + if (!workspaceId || workspaceId === APP_SHELL_WORKSPACE_ID) { + return null; + } + + try { + const appWorkspace = await getOrCreateWorkspace(c, APP_SHELL_WORKSPACE_ID); + const resolved = await appWorkspace.resolveAppGithubToken({ + organizationId: workspaceId, + requireRepoScope: true, + }); + if (!resolved?.accessToken) { + return null; + } + return { + githubToken: resolved.accessToken, + scopes: Array.isArray(resolved.scopes) ? resolved.scopes : [], + }; + } catch { + return null; + } +} diff --git a/foundry/packages/frontend/src/app/router.tsx b/foundry/packages/frontend/src/app/router.tsx index 1d6cd64..834ec71 100644 --- a/foundry/packages/frontend/src/app/router.tsx +++ b/foundry/packages/frontend/src/app/router.tsx @@ -12,7 +12,7 @@ import { } from "../components/mock-onboarding"; import { defaultWorkspaceId, isMockFrontendClient } from "../lib/env"; import { activeMockOrganization, getMockOrganizationById, isAppSnapshotBootstrapping, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; -import { taskWorkbenchClient } from "../lib/workbench"; +import { getTaskWorkbenchClient } from "../lib/workbench"; const rootRoute = createRootRoute({ component: RootLayout, @@ -304,6 +304,7 @@ function AppWorkspaceGate({ workspaceId, children }: { workspaceId: string; chil } function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) { + const taskWorkbenchClient = getTaskWorkbenchClient(workspaceId); useEffect(() => { setFrontendErrorContext({ workspaceId, diff --git a/foundry/packages/frontend/src/components/mock-layout.tsx b/foundry/packages/frontend/src/components/mock-layout.tsx index e43b0b3..e68fdda 100644 --- a/foundry/packages/frontend/src/components/mock-layout.tsx +++ b/foundry/packages/frontend/src/components/mock-layout.tsx @@ -25,7 +25,7 @@ import { type ModelId, } from "./mock-layout/view-model"; import { activeMockOrganization, useMockAppSnapshot } from "../lib/mock-app"; -import { taskWorkbenchClient } from "../lib/workbench"; +import { getTaskWorkbenchClient } from "../lib/workbench"; function firstAgentTabId(task: Task): string | null { return task.tabs[0]?.id ?? null; @@ -61,6 +61,7 @@ function sanitizeActiveTabId(task: Task, tabId: string | null | undefined, openD } const TranscriptPanel = memo(function TranscriptPanel({ + taskWorkbenchClient, task, activeTabId, lastAgentTabId, @@ -70,6 +71,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ onSetLastAgentTabId, onSetOpenDiffs, }: { + taskWorkbenchClient: ReturnType; task: Task; activeTabId: string | null; lastAgentTabId: string | null; @@ -858,6 +860,7 @@ function MockWorkspaceOrgBar() { export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: MockLayoutProps) { const navigate = useNavigate(); + const taskWorkbenchClient = useMemo(() => getTaskWorkbenchClient(workspaceId), [workspaceId]); const viewModel = useSyncExternalStore( taskWorkbenchClient.subscribe.bind(taskWorkbenchClient), taskWorkbenchClient.getSnapshot.bind(taskWorkbenchClient), @@ -887,10 +890,12 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M const [activeTabIdByTask, setActiveTabIdByTask] = useState>({}); const [lastAgentTabIdByTask, setLastAgentTabIdByTask] = useState>({}); const [openDiffsByTask, setOpenDiffsByTask] = useState>({}); + const [selectedNewTaskRepoId, setSelectedNewTaskRepoId] = useState(""); const [leftWidth, setLeftWidth] = useState(() => readStoredWidth(LEFT_WIDTH_STORAGE_KEY, LEFT_SIDEBAR_DEFAULT_WIDTH)); const [rightWidth, setRightWidth] = useState(() => readStoredWidth(RIGHT_WIDTH_STORAGE_KEY, RIGHT_SIDEBAR_DEFAULT_WIDTH)); const leftWidthRef = useRef(leftWidth); const rightWidthRef = useRef(rightWidth); + const autoCreatingSessionForTaskRef = useRef>(new Set()); useEffect(() => { leftWidthRef.current = leftWidth; @@ -1001,9 +1006,49 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M }); }, [activeTask, lastAgentTabIdByTask, selectedSessionId, syncRouteSession]); + useEffect(() => { + if (selectedNewTaskRepoId && viewModel.repos.some((repo) => repo.id === selectedNewTaskRepoId)) { + return; + } + + const fallbackRepoId = + activeTask?.repoId && viewModel.repos.some((repo) => repo.id === activeTask.repoId) ? activeTask.repoId : (viewModel.repos[0]?.id ?? ""); + if (fallbackRepoId !== selectedNewTaskRepoId) { + setSelectedNewTaskRepoId(fallbackRepoId); + } + }, [activeTask?.repoId, selectedNewTaskRepoId, viewModel.repos]); + + useEffect(() => { + if (!activeTask) { + return; + } + if (activeTask.tabs.length > 0) { + autoCreatingSessionForTaskRef.current.delete(activeTask.id); + return; + } + if (selectedSessionId) { + return; + } + if (autoCreatingSessionForTaskRef.current.has(activeTask.id)) { + return; + } + + autoCreatingSessionForTaskRef.current.add(activeTask.id); + void (async () => { + try { + const { tabId } = await taskWorkbenchClient.addTab({ taskId: activeTask.id }); + syncRouteSession(activeTask.id, tabId, true); + } catch (error) { + console.error("failed to auto-create workbench session", error); + } finally { + autoCreatingSessionForTaskRef.current.delete(activeTask.id); + } + })(); + }, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]); + const createTask = useCallback(() => { void (async () => { - const repoId = activeTask?.repoId ?? viewModel.repos[0]?.id ?? ""; + const repoId = selectedNewTaskRepoId; if (!repoId) { throw new Error("Cannot create a task without an available repo"); } @@ -1023,7 +1068,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M search: { sessionId: tabId ?? undefined }, }); })(); - }, [activeTask?.repoId, navigate, viewModel.repos, workspaceId]); + }, [navigate, selectedNewTaskRepoId, workspaceId]); const openDiffTab = useCallback( (path: string) => { @@ -1158,9 +1203,12 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M

Create your first task

- {viewModel.repos.length > 0 - ? "Start from the sidebar to create a task on the first available repo." - : "No repos are available in this workspace yet."} + {viewModel.repos.length > 0 ? "Choose a repo, then create a task." : "No repos are available in this workspace yet."}

+ {viewModel.repos.length > 0 ? ( + + ) : null}