Skip to content

Commit dbc2ff0

Browse files
authored
Improve Foundry auth and task flows (#240)
1 parent d75e8c3 commit dbc2ff0

26 files changed

Lines changed: 621 additions & 137 deletions

File tree

foundry/CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,11 @@ For all Rivet/RivetKit implementation:
164164
- Integration tests use `setupTest()` from `rivetkit/test` and are gated behind `HF_ENABLE_ACTOR_INTEGRATION_TESTS=1`.
165165
- 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.
166166
- E2E tests should talk to the backend over HTTP (default `http://127.0.0.1:7741/api/rivet`) and use real GitHub repos/PRs.
167+
- For Foundry live verification, use `rivet-dev/sandbox-agent-testing` as the default testing repo unless the task explicitly says otherwise.
167168
- Secrets (e.g. `OPENAI_API_KEY`, `GITHUB_TOKEN`/`GH_TOKEN`) must be provided via environment variables, never hardcoded in the repo.
169+
- `~/misc/env.txt` and `~/misc/the-foundry.env` contain the expected local OpenAI + GitHub OAuth/App config for dev.
170+
- 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.
171+
- 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.
168172
- Treat client E2E tests in `packages/client/test` as the primary end-to-end source of truth for product behavior.
169173
- Keep backend tests small and targeted. Only retain backend-only tests for invariants or persistence rules that are not well-covered through client E2E.
170174
- 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.

foundry/compose.dev.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,21 @@ services:
2222
# Support either GITHUB_TOKEN or GITHUB_PAT in local env files.
2323
GITHUB_TOKEN: "${GITHUB_TOKEN:-${GITHUB_PAT:-}}"
2424
GH_TOKEN: "${GH_TOKEN:-${GITHUB_TOKEN:-${GITHUB_PAT:-}}}"
25+
APP_URL: "${APP_URL:-}"
26+
BETTER_AUTH_URL: "${BETTER_AUTH_URL:-}"
27+
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:-}"
28+
GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID:-}"
29+
GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET:-}"
30+
GITHUB_REDIRECT_URI: "${GITHUB_REDIRECT_URI:-}"
31+
GITHUB_APP_ID: "${GITHUB_APP_ID:-}"
32+
GITHUB_APP_CLIENT_ID: "${GITHUB_APP_CLIENT_ID:-}"
33+
GITHUB_APP_CLIENT_SECRET: "${GITHUB_APP_CLIENT_SECRET:-}"
34+
GITHUB_APP_PRIVATE_KEY: "${GITHUB_APP_PRIVATE_KEY:-}"
35+
GITHUB_WEBHOOK_SECRET: "${GITHUB_WEBHOOK_SECRET:-${GITHUB_APP_WEBHOOK_SECRET:-}}"
36+
STRIPE_PUBLISHABLE_KEY: "${STRIPE_PUBLISHABLE_KEY:-}"
37+
STRIPE_SECRET_KEY: "${STRIPE_SECRET_KEY:-}"
38+
STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:-}"
39+
STRIPE_PRICE_TEAM: "${STRIPE_PRICE_TEAM:-}"
2540
DAYTONA_ENDPOINT: "${DAYTONA_ENDPOINT:-}"
2641
DAYTONA_API_KEY: "${DAYTONA_API_KEY:-}"
2742
HF_DAYTONA_ENDPOINT: "${HF_DAYTONA_ENDPOINT:-}"

foundry/packages/backend/src/actors/project-pr-sync/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getActorRuntimeContext } from "../context.js";
44
import { getProject, selfProjectPrSync } from "../handles.js";
55
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
66
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
7+
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
78

89
export interface ProjectPrSyncInput {
910
workspaceId: string;
@@ -31,7 +32,8 @@ const CONTROL = {
3132

3233
async function pollPrs(c: { state: ProjectPrSyncState }): Promise<void> {
3334
const { driver } = getActorRuntimeContext();
34-
const items = await driver.github.listPullRequests(c.state.repoPath);
35+
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
36+
const items = await driver.github.listPullRequests(c.state.repoPath, { githubToken: auth?.githubToken ?? null });
3537
const parent = getProject(c, c.state.workspaceId, c.state.repoId);
3638
await parent.applyPrSyncResult({ items, at: Date.now() });
3739
}

foundry/packages/backend/src/actors/project/actions.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getActorRuntimeContext } from "../context.js";
77
import { getTask, getOrCreateTask, getOrCreateHistory, getOrCreateProjectBranchSync, getOrCreateProjectPrSync, selfProject } from "../handles.js";
88
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
99
import { foundryRepoClonePath } from "../../services/foundry-paths.js";
10+
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
1011
import { expectQueueResponse } from "../../services/queue.js";
1112
import { withRepoGitLock } from "../../services/repo-git-lock.js";
1213
import { branches, taskIndex, prCache, repoMeta } from "./db/schema.js";
@@ -112,7 +113,8 @@ export function projectWorkflowQueueName(name: ProjectQueueName): ProjectQueueNa
112113
async function ensureLocalClone(c: any, remoteUrl: string): Promise<string> {
113114
const { config, driver } = getActorRuntimeContext();
114115
const localPath = foundryRepoClonePath(config, c.state.workspaceId, c.state.repoId);
115-
await driver.git.ensureCloned(remoteUrl, localPath);
116+
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
117+
await driver.git.ensureCloned(remoteUrl, localPath, { githubToken: auth?.githubToken ?? null });
116118
c.state.localPath = localPath;
117119
return localPath;
118120
}
@@ -301,6 +303,26 @@ async function enrichTaskRecord(c: any, record: TaskRecord): Promise<TaskRecord>
301303
};
302304
}
303305

306+
async function reinsertTaskIndexRow(c: any, taskId: string, branchName: string | null, updatedAt: number): Promise<void> {
307+
const now = Date.now();
308+
await c.db
309+
.insert(taskIndex)
310+
.values({
311+
taskId,
312+
branchName,
313+
createdAt: updatedAt || now,
314+
updatedAt: now,
315+
})
316+
.onConflictDoUpdate({
317+
target: taskIndex.taskId,
318+
set: {
319+
branchName,
320+
updatedAt: now,
321+
},
322+
})
323+
.run();
324+
}
325+
304326
async function ensureProjectMutation(c: any, cmd: EnsureProjectCommand): Promise<EnsureProjectResult> {
305327
c.state.remoteUrl = cmd.remoteUrl;
306328
const localPath = await ensureLocalClone(c, cmd.remoteUrl);
@@ -454,9 +476,10 @@ async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand
454476
let headSha = "";
455477
let trackedInStack = false;
456478
let parentBranch: string | null = null;
479+
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
457480

458481
await withRepoGitLock(localPath, async () => {
459-
await driver.git.fetch(localPath);
482+
await driver.git.fetch(localPath, { githubToken: auth?.githubToken ?? null });
460483
const baseRef = await driver.git.remoteDefaultBaseRef(localPath);
461484
const normalizedBase = normalizeBaseBranchName(baseRef);
462485

@@ -467,8 +490,8 @@ async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand
467490
throw new Error(`Remote branch not found: ${branchName}`);
468491
}
469492
} else {
470-
await driver.git.ensureRemoteBranch(localPath, branchName);
471-
await driver.git.fetch(localPath);
493+
await driver.git.ensureRemoteBranch(localPath, branchName, { githubToken: auth?.githubToken ?? null });
494+
await driver.git.fetch(localPath, { githubToken: auth?.githubToken ?? null });
472495
try {
473496
headSha = await driver.git.revParse(localPath, `origin/${branchName}`);
474497
} catch {
@@ -947,7 +970,17 @@ export const projectActions = {
947970

948971
const row = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).where(eq(taskIndex.taskId, cmd.taskId)).get();
949972
if (!row) {
950-
throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`);
973+
try {
974+
const h = getTask(c, c.state.workspaceId, c.state.repoId, cmd.taskId);
975+
const record = await h.get();
976+
await reinsertTaskIndexRow(c, cmd.taskId, record.branchName ?? null, record.updatedAt ?? Date.now());
977+
return await enrichTaskRecord(c, record);
978+
} catch (error) {
979+
if (isStaleTaskReferenceError(error)) {
980+
throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`);
981+
}
982+
throw error;
983+
}
951984
}
952985

953986
try {

foundry/packages/backend/src/actors/task/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,14 @@ export const task = actor({
142142

143143
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
144144
const self = selfTask(c);
145-
await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
145+
const result = await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
146146
wait: true,
147147
timeout: 30 * 60_000,
148148
});
149+
const response = expectQueueResponse<{ ok: boolean; error?: string }>(result);
150+
if (!response.ok) {
151+
throw new Error(response.error ?? "task provisioning failed");
152+
}
149153
return { ok: true };
150154
},
151155

foundry/packages/backend/src/actors/task/workbench.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
import { basename } from "node:path";
33
import { asc, eq } from "drizzle-orm";
44
import { getActorRuntimeContext } from "../context.js";
5-
import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, getSandboxInstance } from "../handles.js";
5+
import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, getSandboxInstance, selfTask } from "../handles.js";
6+
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
67
import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js";
78
import { getCurrentRecord } from "./workflow/common.js";
89

@@ -547,7 +548,26 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
547548
}
548549

549550
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
550-
const record = await ensureWorkbenchSeeded(c);
551+
let record = await ensureWorkbenchSeeded(c);
552+
if (!record.activeSandboxId) {
553+
const providerId = record.providerId ?? c.state.providerId ?? getActorRuntimeContext().providers.defaultProviderId();
554+
await selfTask(c).provision({ providerId });
555+
record = await ensureWorkbenchSeeded(c);
556+
}
557+
558+
if (record.activeSessionId) {
559+
const existingSessions = await listSessionMetaRows(c);
560+
if (existingSessions.length === 0) {
561+
await ensureSessionMeta(c, {
562+
sessionId: record.activeSessionId,
563+
model: model ?? defaultModelForAgent(record.agentType),
564+
sessionName: "Session 1",
565+
});
566+
await notifyWorkbenchUpdated(c);
567+
return { tabId: record.activeSessionId };
568+
}
569+
}
570+
551571
if (!record.activeSandboxId) {
552572
throw new Error("cannot create session without an active sandbox");
553573
}
@@ -783,7 +803,10 @@ export async function publishWorkbenchPr(c: any): Promise<void> {
783803
throw new Error("cannot publish PR without a branch");
784804
}
785805
const { driver } = getActorRuntimeContext();
786-
const created = await driver.github.createPr(c.state.repoLocalPath, record.branchName, record.title ?? c.state.task);
806+
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
807+
const created = await driver.github.createPr(c.state.repoLocalPath, record.branchName, record.title ?? c.state.task, undefined, {
808+
githubToken: auth?.githubToken ?? null,
809+
});
787810
await c.db
788811
.update(taskTable)
789812
.set({

foundry/packages/backend/src/actors/task/workflow/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,10 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
104104
await msg.complete({ ok: true });
105105
} catch (error) {
106106
await loopCtx.step("init-failed-v2", async () => initFailedActivity(loopCtx, error));
107-
await msg.complete({ ok: false });
107+
await msg.complete({
108+
ok: false,
109+
error: resolveErrorMessage(error),
110+
});
108111
}
109112
},
110113

foundry/packages/backend/src/actors/task/workflow/init.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// @ts-nocheck
22
import { desc, eq } from "drizzle-orm";
33
import { resolveCreateFlowDecision } from "../../../services/create-flow.js";
4+
import { resolveWorkspaceGithubAuth } from "../../../services/github-auth.js";
45
import { getActorRuntimeContext } from "../../context.js";
56
import { getOrCreateTaskStatusSync, getOrCreateHistory, getOrCreateProject, getOrCreateSandboxInstance, getSandboxInstance, selfTask } from "../../handles.js";
67
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
@@ -150,8 +151,9 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
150151
}
151152

152153
const { driver } = getActorRuntimeContext();
154+
const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId);
153155
try {
154-
await driver.git.fetch(loopCtx.state.repoLocalPath);
156+
await driver.git.fetch(loopCtx.state.repoLocalPath, { githubToken: auth?.githubToken ?? null });
155157
} catch (error) {
156158
logActorWarning("task.init", "fetch before naming failed", {
157159
workspaceId: loopCtx.state.workspaceId,
@@ -160,7 +162,9 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
160162
error: resolveErrorMessage(error),
161163
});
162164
}
163-
const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath)).map((branch: any) => branch.branchName);
165+
const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath, { githubToken: auth?.githubToken ?? null })).map(
166+
(branch: any) => branch.branchName,
167+
);
164168

165169
const project = await getOrCreateProject(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, loopCtx.state.repoRemote);
166170
const reservedBranches = await project.listReservedBranches({});
@@ -274,13 +278,15 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
274278
});
275279

276280
try {
281+
const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId);
277282
const sandbox = await withActivityTimeout(timeoutMs, "createSandbox", async () =>
278283
provider.createSandbox({
279284
workspaceId: loopCtx.state.workspaceId,
280285
repoId: loopCtx.state.repoId,
281286
repoRemote: loopCtx.state.repoRemote,
282287
branchName: loopCtx.state.branchName,
283288
taskId: loopCtx.state.taskId,
289+
githubToken: auth?.githubToken ?? null,
284290
debug: (message, context) => debugInit(loopCtx, message, context),
285291
}),
286292
);

foundry/packages/backend/src/actors/task/workflow/status-sync.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { eq } from "drizzle-orm";
33
import { getActorRuntimeContext } from "../../context.js";
44
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
5+
import { resolveWorkspaceGithubAuth } from "../../../services/github-auth.js";
56
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
67
import { TASK_ROW_ID, appendHistory, resolveErrorDetail } from "./common.js";
78
import { pushActiveBranchActivity } from "./push.js";
@@ -77,8 +78,10 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
7778

7879
if (self && self.prSubmitted) return;
7980

81+
const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId);
82+
8083
try {
81-
await driver.git.fetch(loopCtx.state.repoLocalPath);
84+
await driver.git.fetch(loopCtx.state.repoLocalPath, { githubToken: auth?.githubToken ?? null });
8285
} catch (error) {
8386
logActorWarning("task.status-sync", "fetch before PR submit failed", {
8487
workspaceId: loopCtx.state.workspaceId,
@@ -98,7 +101,9 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
98101
historyKind: "task.push.auto",
99102
});
100103

101-
const pr = await driver.github.createPr(loopCtx.state.repoLocalPath, loopCtx.state.branchName, loopCtx.state.title);
104+
const pr = await driver.github.createPr(loopCtx.state.repoLocalPath, loopCtx.state.branchName, loopCtx.state.title, undefined, {
105+
githubToken: auth?.githubToken ?? null,
106+
});
102107

103108
await db.update(taskTable).set({ prSubmitted: 1, updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run();
104109

foundry/packages/backend/src/actors/workspace/actions.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { getActorRuntimeContext } from "../context.js";
3434
import { getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
3535
import { logActorWarning, resolveErrorMessage } from "../logging.js";
3636
import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js";
37+
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
3738
import { taskLookup, repos, providerProfiles } from "./db/schema.js";
3839
import { agentTypeForModel } from "../task/workbench.js";
3940
import { expectQueueResponse } from "../../services/queue.js";
@@ -213,7 +214,8 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord>
213214
}
214215

215216
const { driver } = getActorRuntimeContext();
216-
await driver.git.validateRemote(remoteUrl);
217+
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
218+
await driver.git.validateRemote(remoteUrl, { githubToken: auth?.githubToken ?? null });
217219

218220
const repoId = repoIdFromRemote(remoteUrl);
219221
const now = Date.now();
@@ -439,7 +441,7 @@ export const workspaceActions = {
439441
c.broadcast("workbenchUpdated", { at: Date.now() });
440442
},
441443

442-
async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string }> {
444+
async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string; tabId?: string }> {
443445
const created = await workspaceActions.createTask(c, {
444446
workspaceId: c.state.workspaceId,
445447
repoId: input.repoId,
@@ -448,7 +450,12 @@ export const workspaceActions = {
448450
...(input.branch ? { explicitBranchName: input.branch } : {}),
449451
...(input.model ? { agentType: agentTypeForModel(input.model) } : {}),
450452
});
451-
return { taskId: created.taskId };
453+
const task = await requireWorkbenchTask(c, created.taskId);
454+
const snapshot = await task.getWorkbench({});
455+
return {
456+
taskId: created.taskId,
457+
tabId: snapshot.tabs[0]?.id,
458+
};
452459
},
453460

454461
async markWorkbenchUnread(c: any, input: TaskWorkbenchSelectInput): Promise<void> {

0 commit comments

Comments
 (0)