Skip to content

Commit e0a2b97

Browse files
sweetmantechclaude
andcommitted
refactor(sandbox): SRP/KISS extractions + Tier 1 correctness fixes
Addresses review feedback on PR #522 and the "missing from open-agents" audit: User-flagged review comments: - SRP: extract `buildSource` to lib/sandbox/buildSource.ts - YAGNI: drop `isNewBranch` from POST /api/sandbox — chat never sets it (note: docs PR #192 still documents it; will open follow-up docs PR to drop from sandbox.json) - SRP: extract `isoToEpochMs` to lib/sandbox/isoToEpochMs.ts - SRP: extract `buildLifecycle` to lib/sandbox/buildLifecycle.ts - SRP: extract `isSandboxActive` to lib/sandbox/isSandboxActive.ts - KISS: rename lib/supabase/sessions/updateSessionSandboxState.ts -> updateSession.ts, generalize signature to (id, TablesUpdate<"sessions">) Tier 1 correctness gaps from the open-agents comparison: 1. GitHub URL validation via parseGitHubRepoUrl in validateCreateSandboxBody — bad URLs now return a clean 400 instead of falling through to a confusing 502 from the sandbox provider 2. Service GitHub token plumbed into connectSandbox options via new lib/github/getServiceGithubToken.ts — private repos can now clone 3. snapshot_url + snapshot_created_at cleared on fresh provision so GET /api/sandbox/status no longer surfaces stale snapshot URLs from prior runs TDD red -> green: - 5 new unit-test files for the extracted helpers (buildSource, isoToEpochMs, buildLifecycle, isSandboxActive, getServiceGithubToken) - updateSession.test.ts replaces updateSessionSandboxState.test.ts - Updated validator + handler tests for the contract changes (drop isNewBranch, add bad-URL 400 cases, assert githubToken plumbing, assert snapshot_url/snapshot_created_at: null in update payload) - Confirmed RED before each implementation - Suite: 2499 -> 2516 (+17 net new tests), pnpm lint:check clean Files net delta: -241 / +70 lines (extractions + handler shrinks) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f77e5c3 commit e0a2b97

19 files changed

Lines changed: 407 additions & 241 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import{describe,it,expect,beforeEach,afterEach}from"vitest";
2+
import{getServiceGithubToken}from"@/lib/github/getServiceGithubToken";
3+
4+
constORIGINAL=process.env.GITHUB_TOKEN;
5+
6+
beforeEach(()=>{
7+
deleteprocess.env.GITHUB_TOKEN;
8+
});
9+
10+
afterEach(()=>{
11+
if(ORIGINAL===undefined){
12+
deleteprocess.env.GITHUB_TOKEN;
13+
}else{
14+
process.env.GITHUB_TOKEN=ORIGINAL;
15+
}
16+
});
17+
18+
describe("getServiceGithubToken",()=>{
19+
it("returns undefined when GITHUB_TOKEN is unset",()=>{
20+
expect(getServiceGithubToken()).toBeUndefined();
21+
});
22+
23+
it("returns undefined when GITHUB_TOKEN is the empty string",()=>{
24+
process.env.GITHUB_TOKEN="";
25+
expect(getServiceGithubToken()).toBeUndefined();
26+
});
27+
28+
it("returns the token when set",()=>{
29+
process.env.GITHUB_TOKEN="ghs_secret";
30+
expect(getServiceGithubToken()).toBe("ghs_secret");
31+
});
32+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Returns the service-account GitHub token used for cloning private
3+
* repositories into sandboxes. Returns undefined when the env var is
4+
* unset or empty so callers can fall back to public-repo behavior
5+
* without crashing.
6+
*
7+
* @returns The token string, or undefined.
8+
*/
9+
exportfunctiongetServiceGithubToken(): string|undefined{
10+
consttoken=process.env.GITHUB_TOKEN;
11+
returntoken&&token.length>0 ? token : undefined;
12+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import{describe,it,expect}from"vitest";
2+
import{buildLifecycle}from"@/lib/sandbox/buildLifecycle";
3+
4+
constISO="2030-01-01T00:00:00.000Z";
5+
constEPOCH=Date.parse(ISO);
6+
7+
describe("buildLifecycle",()=>{
8+
it("converts every ISO timestamp on the row to epoch ms and sets serverTime",()=>{
9+
constrow={
10+
lifecycle_state: "active",
11+
last_activity_at: ISO,
12+
hibernate_after: ISO,
13+
sandbox_expires_at: ISO,
14+
}asany;
15+
16+
constresult=buildLifecycle(row);
17+
18+
expect(result).toEqual({
19+
serverTime: expect.any(Number),
20+
state: "active",
21+
lastActivityAt: EPOCH,
22+
hibernateAfter: EPOCH,
23+
sandboxExpiresAt: EPOCH,
24+
});
25+
});
26+
27+
it("preserves null fields and a null lifecycle_state as-is",()=>{
28+
constrow={
29+
lifecycle_state: null,
30+
last_activity_at: null,
31+
hibernate_after: null,
32+
sandbox_expires_at: null,
33+
}asany;
34+
35+
constresult=buildLifecycle(row);
36+
37+
expect(result.state).toBeNull();
38+
expect(result.lastActivityAt).toBeNull();
39+
expect(result.hibernateAfter).toBeNull();
40+
expect(result.sandboxExpiresAt).toBeNull();
41+
});
42+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import{describe,it,expect}from"vitest";
2+
import{buildSource}from"@/lib/sandbox/buildSource";
3+
4+
describe("buildSource",()=>{
5+
it("returns repo + branch when branch is provided",()=>{
6+
expect(buildSource({repoUrl: "https://github.com/o/r",branch: "main"})).toEqual({
7+
repo: "https://github.com/o/r",
8+
branch: "main",
9+
});
10+
});
11+
12+
it("omits branch when not provided",()=>{
13+
expect(buildSource({repoUrl: "https://github.com/o/r"})).toEqual({
14+
repo: "https://github.com/o/r",
15+
});
16+
});
17+
});

‎lib/sandbox/__tests__/createSandboxHandler.test.ts‎

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createSandboxHandler } from "@/lib/sandbox/createSandboxHandler";
55
import{validateCreateSandboxBody}from"@/lib/sandbox/validateCreateSandboxBody";
66
import{selectSessions}from"@/lib/supabase/sessions/selectSessions";
77
import{connectSandbox}from"@/lib/sandbox/factory";
8-
import{updateSessionSandboxState}from"@/lib/supabase/sessions/updateSessionSandboxState";
8+
import{updateSession}from"@/lib/supabase/sessions/updateSession";
99

1010
vi.mock("@/lib/networking/getCorsHeaders",()=>({
1111
getCorsHeaders: ()=>({"Access-Control-Allow-Origin": "*"}),
@@ -19,8 +19,11 @@ vi.mock("@/lib/supabase/sessions/selectSessions", () => ({
1919
vi.mock("@/lib/sandbox/factory",()=>({
2020
connectSandbox: vi.fn(),
2121
}));
22-
vi.mock("@/lib/supabase/sessions/updateSessionSandboxState",()=>({
23-
updateSessionSandboxState: vi.fn(),
22+
vi.mock("@/lib/supabase/sessions/updateSession",()=>({
23+
updateSession: vi.fn(),
24+
}));
25+
vi.mock("@/lib/github/getServiceGithubToken",()=>({
26+
getServiceGithubToken: vi.fn(()=>"ghs_test_token"),
2427
}));
2528

2629
constACCOUNT_ID="acc-1";
@@ -53,7 +56,7 @@ describe("createSandboxHandler", () => {
5356
vi.mocked(connectSandbox).mockResolvedValue(
5457
fakeSandbox()asunknownasAwaited<ReturnType<typeofconnectSandbox>>,
5558
);
56-
vi.mocked(updateSessionSandboxState).mockResolvedValue({}asany);
59+
vi.mocked(updateSession).mockResolvedValue({}asany);
5760
});
5861

5962
it("short-circuits with the validator's response on validation failure",async()=>{
@@ -108,17 +111,29 @@ describe("createSandboxHandler", () => {
108111
expect(typeofbody.timing.readyMs).toBe("number");
109112
});
110113

111-
it("persists sandbox state to the session row when sessionId is provided",async()=>{
114+
it("persists sandbox state and clears stale snapshot fields on the session row",async()=>{
112115
awaitcreateSandboxHandler(makeReq());
113116

114-
expect(updateSessionSandboxState).toHaveBeenCalledWith(
117+
expect(updateSession).toHaveBeenCalledWith(
118+
"sess-1",
115119
expect.objectContaining({
116-
id: "sess-1",
117-
sandboxState: {type: "vercel",sandboxName: "session-sess-1"},
120+
sandbox_state: {type: "vercel",sandboxName: "session-sess-1"},
121+
lifecycle_state: "active",
122+
snapshot_url: null,
123+
snapshot_created_at: null,
118124
}),
119125
);
120126
});
121127

128+
it("plumbs the service github token into connectSandbox options",async()=>{
129+
awaitcreateSandboxHandler(makeReq());
130+
131+
constarg=vi.mocked(connectSandbox).mock.calls[0]?.[0];
132+
expect(arg).toBeDefined();
133+
if(!arg||!("options"inarg))thrownewError("expected new-API config shape");
134+
expect(arg.options?.githubToken).toBe("ghs_test_token");
135+
});
136+
122137
it("skips the session-row write when no sessionId is provided",async()=>{
123138
vi.mocked(validateCreateSandboxBody).mockResolvedValueOnce({
124139
body: {repoUrl: "https://github.com/o/r",branch: "main"},
@@ -128,29 +143,7 @@ describe("createSandboxHandler", () => {
128143
constres=awaitcreateSandboxHandler(makeReq());
129144

130145
expect(res.status).toBe(200);
131-
expect(updateSessionSandboxState).not.toHaveBeenCalled();
146+
expect(updateSession).not.toHaveBeenCalled();
132147
expect(selectSessions).not.toHaveBeenCalled();
133148
});
134-
135-
it("uses isNewBranch=true to flip source.branch into source.newBranch",async()=>{
136-
vi.mocked(validateCreateSandboxBody).mockResolvedValueOnce({
137-
body: {
138-
repoUrl: "https://github.com/o/r",
139-
sessionId: "sess-1",
140-
branch: "feat/x",
141-
isNewBranch: true,
142-
},
143-
auth: {accountId: ACCOUNT_ID,orgId: null,authToken: "k"},
144-
});
145-
146-
awaitcreateSandboxHandler(makeReq());
147-
148-
constarg=vi.mocked(connectSandbox).mock.calls[0]?.[0];
149-
expect(arg).toBeDefined();
150-
if(!arg||!("state"inarg))thrownewError("expected new-API config shape");
151-
152-
constsource=(arg.stateasany).source;
153-
expect(source.newBranch).toBe("feat/x");
154-
expect(source.branch).toBeUndefined();
155-
});
156149
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import{describe,it,expect}from"vitest";
2+
import{isSandboxActive}from"@/lib/sandbox/isSandboxActive";
3+
4+
constFAR_FUTURE="2099-01-01T00:00:00.000Z";
5+
constFAR_PAST="2000-01-01T00:00:00.000Z";
6+
7+
constbaseRow={
8+
sandbox_state: nullasunknown,
9+
sandbox_expires_at: nullasstring|null,
10+
};
11+
12+
describe("isSandboxActive",()=>{
13+
it("returns false when sandbox_state has no runtime metadata",()=>{
14+
expect(isSandboxActive({ ...baseRow,sandbox_state: {type: "vercel"}}asany)).toBe(false);
15+
});
16+
17+
it("returns false when sandbox_state is null",()=>{
18+
expect(isSandboxActive({ ...baseRow}asany)).toBe(false);
19+
});
20+
21+
it("returns true with a runtime sandboxName and a far-future expiry",()=>{
22+
expect(
23+
isSandboxActive({
24+
...baseRow,
25+
sandbox_state: {type: "vercel",sandboxName: "session-x"},
26+
sandbox_expires_at: FAR_FUTURE,
27+
}asany),
28+
).toBe(true);
29+
});
30+
31+
it("returns false when expiry is in the past",()=>{
32+
expect(
33+
isSandboxActive({
34+
...baseRow,
35+
sandbox_state: {type: "vercel",sandboxName: "session-x"},
36+
sandbox_expires_at: FAR_PAST,
37+
}asany),
38+
).toBe(false);
39+
});
40+
41+
it("returns true when sandboxName is set but expiry is null (no expiry to compare against)",()=>{
42+
expect(
43+
isSandboxActive({
44+
...baseRow,
45+
sandbox_state: {type: "vercel",sandboxName: "session-x"},
46+
sandbox_expires_at: null,
47+
}asany),
48+
).toBe(true);
49+
});
50+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import{describe,it,expect}from"vitest";
2+
import{isoToEpochMs}from"@/lib/sandbox/isoToEpochMs";
3+
4+
describe("isoToEpochMs",()=>{
5+
it("returns null for null input",()=>{
6+
expect(isoToEpochMs(null)).toBeNull();
7+
});
8+
9+
it("returns null for an empty string",()=>{
10+
expect(isoToEpochMs("")).toBeNull();
11+
});
12+
13+
it("returns null for an unparseable string",()=>{
14+
expect(isoToEpochMs("not-a-date")).toBeNull();
15+
});
16+
17+
it("converts a valid ISO string to epoch milliseconds",()=>{
18+
expect(isoToEpochMs("2030-01-01T00:00:00.000Z")).toBe(Date.parse("2030-01-01T00:00:00.000Z"));
19+
});
20+
});

‎lib/sandbox/__tests__/validateCreateSandboxBody.test.ts‎

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@ describe("validateCreateSandboxBody", () => {
6565
expect((resultasNextResponse).status).toBe(400);
6666
});
6767

68+
it("returns 400 when repoUrl is not a valid GitHub repository URL",async()=>{
69+
constresult=awaitvalidateCreateSandboxBody(makeReq({repoUrl: "https://gitlab.com/o/r"}));
70+
71+
expect(result).toBeInstanceOf(NextResponse);
72+
expect((resultasNextResponse).status).toBe(400);
73+
});
74+
75+
it("returns 400 when repoUrl is not a URL at all",async()=>{
76+
constresult=awaitvalidateCreateSandboxBody(makeReq({repoUrl: "x"}));
77+
78+
expect(result).toBeInstanceOf(NextResponse);
79+
expect((resultasNextResponse).status).toBe(400);
80+
});
81+
6882
it("returns the validated body + auth on a minimal happy path",async()=>{
6983
constresult=awaitvalidateCreateSandboxBody(makeReq({repoUrl: "https://github.com/o/r"}));
7084

@@ -73,31 +87,21 @@ describe("validateCreateSandboxBody", () => {
7387
expect(result.body.repoUrl).toBe("https://github.com/o/r");
7488
expect(result.body.sessionId).toBeUndefined();
7589
expect(result.body.branch).toBeUndefined();
76-
expect(result.body.isNewBranch).toBeUndefined();
7790
expect(result.auth.accountId).toBe(ACCOUNT_ID);
7891
});
7992

80-
it("accepts a full request with sessionId, branch, isNewBranch",async()=>{
93+
it("accepts a full request with sessionId and branch",async()=>{
8194
constresult=awaitvalidateCreateSandboxBody(
8295
makeReq({
8396
repoUrl: "https://github.com/o/r",
8497
sessionId: "sess-1",
8598
branch: "feat/x",
86-
isNewBranch: true,
8799
}),
88100
);
89101

90102
expect(result).not.toBeInstanceOf(NextResponse);
91103
if(resultinstanceofNextResponse)return;
92104
expect(result.body.sessionId).toBe("sess-1");
93105
expect(result.body.branch).toBe("feat/x");
94-
expect(result.body.isNewBranch).toBe(true);
95-
});
96-
97-
it("returns 400 when isNewBranch is the wrong type",async()=>{
98-
constresult=awaitvalidateCreateSandboxBody(makeReq({repoUrl: "x",isNewBranch: "yes"}));
99-
100-
expect(result).toBeInstanceOf(NextResponse);
101-
expect((resultasNextResponse).status).toBe(400);
102106
});
103107
});

‎lib/sandbox/buildLifecycle.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import{isoToEpochMs}from"@/lib/sandbox/isoToEpochMs";
2+
importtype{Tables}from"@/types/database.types";
3+
4+
/**
5+
* Projects the lifecycle-relevant columns of a `sessions` row into the
6+
* docs-spec lifecycle envelope used by GET /api/sandbox/status.
7+
*
8+
* @param row - The `sessions` row.
9+
* @returns The lifecycle envelope: serverTime, state, and three epoch-ms timestamps.
10+
*/
11+
exportfunctionbuildLifecycle(row: Tables<"sessions">){
12+
return{
13+
serverTime: Date.now(),
14+
state: row.lifecycle_state,
15+
lastActivityAt: isoToEpochMs(row.last_activity_at),
16+
hibernateAfter: isoToEpochMs(row.hibernate_after),
17+
sandboxExpiresAt: isoToEpochMs(row.sandbox_expires_at),
18+
};
19+
}

‎lib/sandbox/buildSource.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
exportinterfaceSandboxSource{
2+
repo: string;
3+
branch?: string;
4+
}
5+
6+
/**
7+
* Builds the `source` shape that `connectSandbox` consumes.
8+
*
9+
* @param input - The repo URL and optional branch to check out.
10+
* @returns A `{repo, branch?}` source descriptor.
11+
*/
12+
exportfunctionbuildSource({
13+
repoUrl,
14+
branch,
15+
}: {
16+
repoUrl: string;
17+
branch?: string;
18+
}): SandboxSource{
19+
returnbranch ? {repo: repoUrl, branch } : {repo: repoUrl};
20+
}

0 commit comments

Comments
 (0)