Skip to content

Commit ffb9f10

Browse files
committed
fix(foundry): fix runner version
1 parent f25a92a commit ffb9f10

10 files changed

Lines changed: 114 additions & 6 deletions

File tree

foundry/CLAUDE.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,26 @@ Local Docker sandboxes use the `rivetdev/sandbox-agent:foundry-base-latest` imag
7272
- The image must be built with `--platform linux/amd64`. The Rust build is memory-intensive; Docker Desktop needs at least 8GB RAM allocated.
7373
- When updating the base image contents (new system packages, agent versions), rebuild and push with the publish script, then update the `foundry-base-latest` tag.
7474

75+
## Production GitHub App + OAuth App
76+
77+
Foundry uses two separate GitHub entities in production:
78+
79+
- **OAuth App** (`GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`) — handles "Sign in with GitHub" via Better Auth. This is a standard OAuth App.
80+
- **GitHub App** (`GITHUB_APP_ID` / `GITHUB_APP_CLIENT_ID` / `GITHUB_APP_CLIENT_SECRET` / `GITHUB_APP_PRIVATE_KEY`) — handles webhooks, installation tokens for repo access, and GitHub API sync (repos, PRs). Must be manually installed on each org.
81+
82+
Key env vars and where they connect:
83+
84+
- `GITHUB_REDIRECT_URI` — OAuth callback, must point to `https://api.sandboxagent.dev/v1/auth/callback/github`
85+
- `GITHUB_WEBHOOK_SECRET` — must match the secret configured on the GitHub App's Webhook settings page exactly. Mismatches cause silent 500s on webhook delivery (signature verification fails inside the actor, surfaced as a generic RivetKit `internal_error`).
86+
- `BETTER_AUTH_URL` — must be the **API** URL (`https://api.sandboxagent.dev`), not the frontend URL. Better Auth uses this internally for sign-out and session management calls.
87+
- `APP_URL` — the **frontend** URL (`https://foundry.sandboxagent.dev`).
88+
89+
Troubleshooting:
90+
91+
- **"GitHub App not installed"** — The GitHub App must be manually installed on each org. Sign-in does not auto-install it. Go to the GitHub App settings → Install App tab. The sign-in flow can only detect existing installations, not create them.
92+
- **Webhooks not arriving** — Check the GitHub App → Advanced tab for delivery history. If deliveries show 500, the webhook secret likely doesn't match `GITHUB_WEBHOOK_SECRET`. Test with: `echo -n '{"test":true}' | openssl dgst -sha256 -hmac "$SECRET"` and curl the endpoint with the computed signature.
93+
- **Deleting all actors wipes GitHub App installation state.** After a full actor reset, you must trigger a webhook (e.g. redeliver from GitHub App Advanced tab, or re-install the app) to repopulate installation records.
94+
7595
## Railway Logs
7696

7797
- Production Foundry Railway logs can be read from a linked checkout with `railway logs --deployment --lines 200` or `railway logs <deployment-id> --deployment --lines 200`.

foundry/docker/backend.Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ RUN pnpm --filter @sandbox-agent/foundry-backend deploy --prod /out
1919
FROM oven/bun:1.2 AS runtime
2020
ENV NODE_ENV=production
2121
ENV HOME=/home/task
22+
ENV RIVET_RUNNER_VERSION_FILE=/etc/foundry/rivet-runner-version
2223
WORKDIR /app
2324
RUN apt-get update \
2425
&& apt-get install -y --no-install-recommends \
@@ -31,6 +32,8 @@ RUN addgroup --system --gid 1001 task \
3132
&& adduser --system --uid 1001 --home /home/task --ingroup task task \
3233
&& mkdir -p /home/task \
3334
&& chown -R task:task /home/task /app
35+
RUN mkdir -p /etc/foundry \
36+
&& date +%s > /etc/foundry/rivet-runner-version
3437
COPY --from=build /out ./
3538
USER task
3639
EXPOSE 7741

foundry/docker/backend.dev.Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ RUN curl -fsSL "https://releases.rivet.dev/sandbox-agent/${SANDBOX_AGENT_VERSION
2121

2222
ENV PATH="/root/.local/bin:${PATH}"
2323
ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent"
24+
ENV RIVET_RUNNER_VERSION_FILE=/etc/foundry/rivet-runner-version
25+
RUN mkdir -p /etc/foundry \
26+
&& date +%s > /etc/foundry/rivet-runner-version
2427

2528
WORKDIR /app
2629

foundry/docker/backend.preview.Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ RUN curl -fsSL "https://releases.rivet.dev/sandbox-agent/${SANDBOX_AGENT_VERSION
2020

2121
ENV PATH="/root/.local/bin:${PATH}"
2222
ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent"
23+
ENV RIVET_RUNNER_VERSION_FILE=/etc/foundry/rivet-runner-version
24+
RUN mkdir -p /etc/foundry \
25+
&& date +%s > /etc/foundry/rivet-runner-version
2326

2427
WORKDIR /workspace/quebec
2528

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,15 @@ import { auditLog } from "./audit-log/index.js";
66
import { taskSandbox } from "./sandbox/index.js";
77
import { organization } from "./organization/index.js";
88
import { logger } from "../logging.js";
9+
import { resolveRunnerVersion } from "../config/runner-version.js";
910

10-
const RUNNER_VERSION = Math.floor(Date.now() / 1000);
11+
const runnerVersion = resolveRunnerVersion();
1112

1213
export const registry = setup({
1314
serverless: {
1415
basePath: "/v1/rivet",
1516
},
16-
runner: {
17-
version: RUNNER_VERSION,
18-
},
17+
runner: { version: runnerVersion },
1918
logging: {
2019
baseLogger: logger,
2120
},

foundry/packages/backend/src/actors/logging.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ export function resolveErrorStack(error: unknown): string | undefined {
2222
return undefined;
2323
}
2424

25+
export function logActorInfo(scope: string, message: string, context?: Record<string, unknown>): void {
26+
logger.info(
27+
{
28+
scope,
29+
...(context ?? {}),
30+
},
31+
message,
32+
);
33+
}
34+
2535
export function logActorWarning(scope: string, message: string, context?: Record<string, unknown>): void {
2636
logger.warn(
2737
{

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from "@sandbox-agent/foundry-shared";
1111
import { getActorRuntimeContext } from "../context.js";
1212
import { getOrCreateOrganization, getOrCreateTaskSandbox, getOrCreateUser, getTaskSandbox, selfTask } from "../handles.js";
13-
import { logActorWarning, resolveErrorMessage } from "../logging.js";
13+
import { logActorInfo, logActorWarning, resolveErrorMessage } from "../logging.js";
1414
import { SANDBOX_REPO_CWD } from "../sandbox/index.js";
1515
import { resolveSandboxProviderId } from "../../sandbox-config.js";
1616
import { getBetterAuthService } from "../../services/better-auth.js";
@@ -636,11 +636,17 @@ async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { ski
636636
// If the repo was already prepared and the caller allows skipping fetch, just return.
637637
// The clone, fetch, and checkout already happened on a prior call.
638638
if (opts?.skipFetchIfPrepared && sandboxRepoPrepared) {
639+
logActorInfo("task.sandbox", "ensureSandboxRepo skipped (already prepared)");
639640
return;
640641
}
641642

643+
const repoStart = performance.now();
644+
645+
const t0 = performance.now();
642646
const auth = await resolveOrganizationGithubAuth(c, c.state.organizationId);
643647
const metadata = await getRepositoryMetadata(c);
648+
logActorInfo("task.sandbox", "resolveAuth+metadata", { durationMs: Math.round(performance.now() - t0) });
649+
644650
const baseRef = metadata.defaultBranch ?? "main";
645651
const sandboxRepoRoot = dirname(SANDBOX_REPO_CWD);
646652
const script = [
@@ -657,6 +663,8 @@ async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { ski
657663
)}; else target_ref=${JSON.stringify(baseRef)}; fi`,
658664
`git checkout -B ${JSON.stringify(record.branchName)} \"$target_ref\"`,
659665
];
666+
667+
const t1 = performance.now();
660668
const result = await sandbox.runProcess({
661669
command: "bash",
662670
args: ["-lc", script.join("; ")],
@@ -669,6 +677,11 @@ async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { ski
669677
: undefined,
670678
timeoutMs: 5 * 60_000,
671679
});
680+
logActorInfo("task.sandbox", "git clone/fetch/checkout", {
681+
branch: record.branchName,
682+
repo: metadata.remoteUrl,
683+
durationMs: Math.round(performance.now() - t1),
684+
});
672685

673686
if ((result.exitCode ?? 0) !== 0) {
674687
throw new Error(`sandbox repo preparation failed (${result.exitCode ?? 1}): ${[result.stdout, result.stderr].filter(Boolean).join("")}`);
@@ -677,10 +690,13 @@ async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { ski
677690
// On first repo preparation, inject the task owner's git credentials into the sandbox
678691
// so that push/commit operations are authenticated and attributed to the correct user.
679692
if (!sandboxRepoPrepared && opts?.authSessionId) {
693+
const t2 = performance.now();
680694
await maybeSwapTaskOwner(c, opts.authSessionId, sandbox);
695+
logActorInfo("task.sandbox", "maybeSwapTaskOwner", { durationMs: Math.round(performance.now() - t2) });
681696
}
682697

683698
sandboxRepoPrepared = true;
699+
logActorInfo("task.sandbox", "ensureSandboxRepo complete", { totalDurationMs: Math.round(performance.now() - repoStart) });
684700
}
685701

686702
async function executeInSandbox(
@@ -1264,6 +1280,7 @@ export async function createWorkspaceSession(c: any, model?: string, authSession
12641280
}
12651281

12661282
export async function ensureWorkspaceSession(c: any, sessionId: string, model?: string, authSessionId?: string): Promise<void> {
1283+
const ensureStart = performance.now();
12671284
const meta = await readSessionMeta(c, sessionId);
12681285
if (!meta || meta.closed) {
12691286
return;
@@ -1283,10 +1300,18 @@ export async function ensureWorkspaceSession(c: any, sessionId: string, model?:
12831300
});
12841301

12851302
try {
1303+
const t0 = performance.now();
12861304
const runtime = await getTaskSandboxRuntime(c, record);
1305+
logActorInfo("task.session", "getTaskSandboxRuntime", { sessionId, durationMs: Math.round(performance.now() - t0) });
1306+
1307+
const t1 = performance.now();
12871308
await ensureSandboxRepo(c, runtime.sandbox, record);
1309+
logActorInfo("task.session", "ensureSandboxRepo", { sessionId, durationMs: Math.round(performance.now() - t1) });
1310+
12881311
const resolvedModel = model ?? meta.model ?? (await resolveDefaultModel(c, authSessionId));
12891312
const resolvedAgent = await resolveSandboxAgentForModel(c, resolvedModel);
1313+
1314+
const t2 = performance.now();
12901315
await runtime.sandbox.createSession({
12911316
id: meta.sandboxSessionId ?? sessionId,
12921317
agent: resolvedAgent,
@@ -1295,12 +1320,14 @@ export async function ensureWorkspaceSession(c: any, sessionId: string, model?:
12951320
cwd: runtime.cwd,
12961321
},
12971322
});
1323+
logActorInfo("task.session", "createSession", { sessionId, agent: resolvedAgent, model: resolvedModel, durationMs: Math.round(performance.now() - t2) });
12981324

12991325
await updateSessionMeta(c, sessionId, {
13001326
sandboxSessionId: meta.sandboxSessionId ?? sessionId,
13011327
status: "ready",
13021328
errorMessage: null,
13031329
});
1330+
logActorInfo("task.session", "ensureWorkspaceSession complete", { sessionId, totalDurationMs: Math.round(performance.now() - ensureStart) });
13041331
fireRefreshSessionTranscript(c, meta.sandboxSessionId ?? sessionId);
13051332
} catch (error) {
13061333
await updateSessionMeta(c, sessionId, {
@@ -1415,12 +1442,19 @@ export async function changeWorkspaceModel(c: any, sessionId: string, model: str
14151442
}
14161443

14171444
export async function sendWorkspaceMessage(c: any, sessionId: string, text: string, attachments: Array<any>, authSessionId?: string): Promise<void> {
1445+
const sendStart = performance.now();
14181446
const meta = requireSendableSessionMeta(await readSessionMeta(c, sessionId), sessionId);
14191447
const record = await ensureWorkspaceSeeded(c);
1448+
1449+
const t0 = performance.now();
14201450
const runtime = await getTaskSandboxRuntime(c, record);
1451+
logActorInfo("task.message", "getTaskSandboxRuntime", { sessionId, durationMs: Math.round(performance.now() - t0) });
1452+
1453+
const t1 = performance.now();
14211454
// Skip git fetch on subsequent messages — the repo was already prepared during session
14221455
// creation. This avoids a 5-30s network round-trip to GitHub on every prompt.
14231456
await ensureSandboxRepo(c, runtime.sandbox, record, { skipFetchIfPrepared: true, authSessionId });
1457+
logActorInfo("task.message", "ensureSandboxRepo", { sessionId, durationMs: Math.round(performance.now() - t1) });
14241458

14251459
// Check if the task owner needs to swap. If a different user is sending this message,
14261460
// update the owner record and inject their git credentials into the sandbox.
@@ -1450,10 +1484,12 @@ export async function sendWorkspaceMessage(c: any, sessionId: string, text: stri
14501484
await syncWorkspaceSessionStatus(c, meta.sandboxSessionId, "running", Date.now());
14511485

14521486
try {
1487+
const t2 = performance.now();
14531488
await runtime.sandbox.sendPrompt({
14541489
sessionId: meta.sandboxSessionId,
14551490
prompt: prompt.join("\n\n"),
14561491
});
1492+
logActorInfo("task.message", "sendPrompt", { sessionId, durationMs: Math.round(performance.now() - t2) });
14571493
await syncWorkspaceSessionStatus(c, meta.sandboxSessionId, "idle", Date.now());
14581494
} catch (error) {
14591495
await updateSessionMeta(c, sessionId, {
@@ -1463,6 +1499,7 @@ export async function sendWorkspaceMessage(c: any, sessionId: string, text: stri
14631499
await syncWorkspaceSessionStatus(c, meta.sandboxSessionId, "error", Date.now());
14641500
throw error;
14651501
}
1502+
logActorInfo("task.message", "sendWorkspaceMessage complete", { sessionId, totalDurationMs: Math.round(performance.now() - sendStart) });
14661503
}
14671504

14681505
export async function stopWorkspaceSession(c: any, sessionId: string): Promise<void> {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { readFileSync } from "node:fs";
2+
3+
function parseRunnerVersion(rawValue: string | undefined): number | undefined {
4+
const value = rawValue?.trim();
5+
if (!value) {
6+
return undefined;
7+
}
8+
9+
const parsed = Number.parseInt(value, 10);
10+
if (Number.isNaN(parsed)) {
11+
return undefined;
12+
}
13+
14+
return parsed;
15+
}
16+
17+
export function resolveRunnerVersion(): number | undefined {
18+
const envVersion = parseRunnerVersion(process.env.RIVET_RUNNER_VERSION);
19+
if (envVersion !== undefined) {
20+
return envVersion;
21+
}
22+
23+
const versionFilePath = process.env.RIVET_RUNNER_VERSION_FILE;
24+
if (!versionFilePath) {
25+
return undefined;
26+
}
27+
28+
try {
29+
return parseRunnerVersion(readFileSync(versionFilePath, "utf8"));
30+
} catch {
31+
return undefined;
32+
}
33+
}

foundry/packages/frontend/src/components/mock-layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ function toTaskModel(
187187
diffs: detail?.diffs ?? {},
188188
fileTree: detail?.fileTree ?? [],
189189
minutesUsed: detail?.minutesUsed ?? 0,
190+
sandboxes: detail?.sandboxes ?? [],
190191
activeSandboxId: detail?.activeSandboxId ?? null,
191192
primaryUserLogin: detail?.primaryUserLogin ?? summary.primaryUserLogin ?? null,
192193
primaryUserAvatarUrl: detail?.primaryUserAvatarUrl ?? summary.primaryUserAvatarUrl ?? null,

justfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,4 +186,3 @@ foundry-format:
186186
[group('foundry')]
187187
foundry-docker-build tag='foundry:local':
188188
docker build -f foundry/docker/backend.Dockerfile -t {{tag}} .
189-

0 commit comments

Comments
 (0)