From c107840c198c49b9aa0e17190012b734d267fbb5 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 21 May 2026 15:18:09 +0700 Subject: [PATCH 01/21] feat(sessions): enhance session patching with sandbox state management - Added logic to handle archiving and unarchiving of sessions, ensuring proper sandbox state checks. - Introduced `stopSandboxOnArchive` function to manage sandbox teardown for archived sessions. - Updated `patchSessionByIdHandler` to include conditions for unarchiving sessions based on sandbox state. - Implemented CORS headers in response for error handling during sandbox operations. --- lib/sessions/patchSessionByIdHandler.ts | 18 +++++++++++++ lib/sessions/stopSandboxOnArchive.ts | 35 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 lib/sessions/stopSandboxOnArchive.ts diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts index c93913667..f7ccd893e 100644 --- a/lib/sessions/patchSessionByIdHandler.ts +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -1,6 +1,9 @@ +import { after } from "next/server"; import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { hasRuntimeSandboxState } from "@/lib/sandbox/hasRuntimeSandboxState"; import { validatePatchSessionBody } from "@/lib/sessions/validatePatchSessionBody"; +import { stopSandboxOnArchive } from "@/lib/sessions/stopSandboxOnArchive"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; import { updateSession } from "@/lib/supabase/sessions/updateSession"; import { toSessionResponse } from "@/lib/sessions/toSessionResponse"; @@ -56,11 +59,22 @@ export async function patchSessionByIdHandler( ); } + const shouldArchive = body.status === "archived" && row.status !== "archived"; + const shouldUnarchive = body.status === "running" && row.status === "archived"; + + if (shouldUnarchive && !row.snapshot_url && hasRuntimeSandboxState(row.sandbox_state)) { + return NextResponse.json( + { status: "error", error: "Sandbox is still being paused, try again in a few seconds." }, + { status: 409, headers: getCorsHeaders() }, + ); + } + const updates = { ...(body.title !== undefined && { title: body.title }), ...(body.status !== undefined && { status: body.status }), ...(body.linesAdded !== undefined && { lines_added: body.linesAdded }), ...(body.linesRemoved !== undefined && { lines_removed: body.linesRemoved }), + ...(shouldUnarchive && { lifecycle_state: null, lifecycle_error: null }), }; if (Object.keys(updates).length === 0) { @@ -79,6 +93,10 @@ export async function patchSessionByIdHandler( ); } + if (shouldArchive) { + after(() => stopSandboxOnArchive(row)); + } + return NextResponse.json( { session: toSessionResponse(updated) }, { status: 200, headers: getCorsHeaders() }, diff --git a/lib/sessions/stopSandboxOnArchive.ts b/lib/sessions/stopSandboxOnArchive.ts new file mode 100644 index 000000000..51e83d92b --- /dev/null +++ b/lib/sessions/stopSandboxOnArchive.ts @@ -0,0 +1,35 @@ +import { connectSandbox, type SandboxState } from "@/lib/sandbox/factory"; +import { clearSandboxState } from "@/lib/sandbox/clearSandboxState"; +import { hasRuntimeSandboxState } from "@/lib/sandbox/hasRuntimeSandboxState"; +import { updateSession } from "@/lib/supabase/sessions/updateSession"; +import type { Tables } from "@/types/database.types"; +import type { Json } from "@/types/database.types"; + +/** + * Fire-and-forget sandbox teardown for newly-archived sessions. + * + * Stops the running sandbox and marks the lifecycle as "archived" so + * the auto-hibernate workflow does not attempt to re-evaluate it. + * Must be scheduled via `after()` from the caller so the HTTP + * response is not blocked. + * + * No-ops when the session has no runtime sandbox (nothing to stop). + * + * @param session - The session row as it existed before archiving. + */ +export async function stopSandboxOnArchive(session: Tables<"sessions">): Promise { + if (!hasRuntimeSandboxState(session.sandbox_state)) return; + + try { + const sandbox = await connectSandbox(session.sandbox_state as unknown as SandboxState); + await sandbox.stop(); + const cleared = clearSandboxState(session.sandbox_state); + await updateSession(session.id, { + sandbox_state: cleared as unknown as Json, + lifecycle_state: "archived", + lifecycle_run_id: null, + }); + } catch (error) { + console.error(`[stopSandboxOnArchive] failed for session ${session.id}:`, error); + } +} From 5c682b0d3e4171a55cbcbb8af7c65fc84a016f32 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 21 May 2026 15:25:09 +0700 Subject: [PATCH 02/21] test(sessions): enhance session route tests with sandbox management mocks - Added mocks for `next/server` and `stopSandboxOnArchive` to improve test isolation and control over sandbox state during session route tests. - Updated session test suite to utilize these mocks for better reliability and clarity in testing session behavior. --- app/api/sessions/[sessionId]/__tests__/route.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts index 87bbe4bcd..2eeb9f0cb 100644 --- a/app/api/sessions/[sessionId]/__tests__/route.test.ts +++ b/app/api/sessions/[sessionId]/__tests__/route.test.ts @@ -5,6 +5,15 @@ import type { Tables } from "@/types/database.types"; type SessionRow = Tables<"sessions">; +vi.mock("next/server", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, after: vi.fn() }; +}); + +vi.mock("@/lib/sessions/stopSandboxOnArchive", () => ({ + stopSandboxOnArchive: vi.fn(), +})); + vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ selectSessions: vi.fn(), })); From a496aaf48024ca2c68d7bfd509f55d24045f26d0 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 21 May 2026 15:27:44 +0700 Subject: [PATCH 03/21] refactor(tests): simplify mock implementation for session route tests - Updated the mock for `next/server` in the session route tests to use a more concise syntax, improving readability and maintainability of the test code. --- app/api/sessions/[sessionId]/__tests__/route.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts index 2eeb9f0cb..e9e8c5864 100644 --- a/app/api/sessions/[sessionId]/__tests__/route.test.ts +++ b/app/api/sessions/[sessionId]/__tests__/route.test.ts @@ -5,7 +5,7 @@ import type { Tables } from "@/types/database.types"; type SessionRow = Tables<"sessions">; -vi.mock("next/server", async (importOriginal) => { +vi.mock("next/server", async importOriginal => { const actual = await importOriginal(); return { ...actual, after: vi.fn() }; }); From cc4834298ee1640ddda4953e8a6f6758aae7dcac Mon Sep 17 00:00:00 2001 From: john Date: Thu, 21 May 2026 15:50:24 +0700 Subject: [PATCH 04/21] fix(sessions): improve error handling in stopSandboxOnArchive function - Added error logging for sandbox stop failures, providing clearer insights into issues during session archiving. - Updated error message for state clearing failures to enhance clarity in debugging. --- lib/sessions/stopSandboxOnArchive.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/sessions/stopSandboxOnArchive.ts b/lib/sessions/stopSandboxOnArchive.ts index 51e83d92b..08fd22296 100644 --- a/lib/sessions/stopSandboxOnArchive.ts +++ b/lib/sessions/stopSandboxOnArchive.ts @@ -23,6 +23,11 @@ export async function stopSandboxOnArchive(session: Tables<"sessions">): Promise try { const sandbox = await connectSandbox(session.sandbox_state as unknown as SandboxState); await sandbox.stop(); + } catch (error) { + console.error(`[stopSandboxOnArchive] stop failed for session ${session.id}:`, error); + } + + try { const cleared = clearSandboxState(session.sandbox_state); await updateSession(session.id, { sandbox_state: cleared as unknown as Json, @@ -30,6 +35,6 @@ export async function stopSandboxOnArchive(session: Tables<"sessions">): Promise lifecycle_run_id: null, }); } catch (error) { - console.error(`[stopSandboxOnArchive] failed for session ${session.id}:`, error); + console.error(`[stopSandboxOnArchive] state clear failed for session ${session.id}:`, error); } } From 2c110cd99827b8f950183717acc6aff9c22e2a87 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 21 May 2026 16:01:24 +0700 Subject: [PATCH 05/21] fix(sessions): enhance error handling in stopSandboxOnArchive function - Added a return statement in the error handling block to prevent further execution after a sandbox stop failure, improving robustness during session archiving. --- lib/sessions/stopSandboxOnArchive.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/sessions/stopSandboxOnArchive.ts b/lib/sessions/stopSandboxOnArchive.ts index 08fd22296..6cef5eefc 100644 --- a/lib/sessions/stopSandboxOnArchive.ts +++ b/lib/sessions/stopSandboxOnArchive.ts @@ -25,6 +25,7 @@ export async function stopSandboxOnArchive(session: Tables<"sessions">): Promise await sandbox.stop(); } catch (error) { console.error(`[stopSandboxOnArchive] stop failed for session ${session.id}:`, error); + return; } try { From b16d18555ede0260250d26c3bccacad01c3bd9f6 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 21 May 2026 16:12:52 +0700 Subject: [PATCH 06/21] fix(sessions): refine unarchive condition for sandbox state management - Updated the unarchive condition in `patchSessionByIdHandler` to include a check for the sandbox's lifecycle state, ensuring that the sandbox is not in a hibernated state before allowing unarchiving. This enhances error handling during session management. --- lib/sessions/patchSessionByIdHandler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts index f7ccd893e..557281ef7 100644 --- a/lib/sessions/patchSessionByIdHandler.ts +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -62,7 +62,10 @@ export async function patchSessionByIdHandler( const shouldArchive = body.status === "archived" && row.status !== "archived"; const shouldUnarchive = body.status === "running" && row.status === "archived"; - if (shouldUnarchive && !row.snapshot_url && hasRuntimeSandboxState(row.sandbox_state)) { + const isSandboxPausing = + hasRuntimeSandboxState(row.sandbox_state) && row.lifecycle_state !== "hibernated"; + + if (shouldUnarchive && !row.snapshot_url && isSandboxPausing) { return NextResponse.json( { status: "error", error: "Sandbox is still being paused, try again in a few seconds." }, { status: 409, headers: getCorsHeaders() }, From 0cdec951f433411ff8ac0a18c4aa17d16baebb9f Mon Sep 17 00:00:00 2001 From: john Date: Thu, 21 May 2026 16:21:49 +0700 Subject: [PATCH 07/21] fix(sessions): further refine unarchive condition for sandbox state management - Enhanced the unarchive condition in `patchSessionByIdHandler` to ensure the sandbox's lifecycle state is not "archived" in addition to not being "hibernated". This improves the accuracy of session management during state transitions. --- lib/sessions/patchSessionByIdHandler.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts index 557281ef7..321a58f4f 100644 --- a/lib/sessions/patchSessionByIdHandler.ts +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -63,7 +63,9 @@ export async function patchSessionByIdHandler( const shouldUnarchive = body.status === "running" && row.status === "archived"; const isSandboxPausing = - hasRuntimeSandboxState(row.sandbox_state) && row.lifecycle_state !== "hibernated"; + hasRuntimeSandboxState(row.sandbox_state) && + row.lifecycle_state !== "hibernated" && + row.lifecycle_state !== "archived"; if (shouldUnarchive && !row.snapshot_url && isSandboxPausing) { return NextResponse.json( From 04790ee1ac07f25f6ae7ecff5dc6c8006099dab8 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 21 May 2026 16:34:25 +0700 Subject: [PATCH 08/21] fix(sessions): add session status check before stopping sandbox on archive - Introduced a check in the `stopSandboxOnArchive` function to ensure the session's status is not "archived" before proceeding with sandbox state clearing. This enhances session management accuracy during archiving operations. --- lib/sessions/stopSandboxOnArchive.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/sessions/stopSandboxOnArchive.ts b/lib/sessions/stopSandboxOnArchive.ts index 6cef5eefc..fc757373e 100644 --- a/lib/sessions/stopSandboxOnArchive.ts +++ b/lib/sessions/stopSandboxOnArchive.ts @@ -1,6 +1,7 @@ import { connectSandbox, type SandboxState } from "@/lib/sandbox/factory"; import { clearSandboxState } from "@/lib/sandbox/clearSandboxState"; import { hasRuntimeSandboxState } from "@/lib/sandbox/hasRuntimeSandboxState"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; import { updateSession } from "@/lib/supabase/sessions/updateSession"; import type { Tables } from "@/types/database.types"; import type { Json } from "@/types/database.types"; @@ -29,6 +30,9 @@ export async function stopSandboxOnArchive(session: Tables<"sessions">): Promise } try { + const rows = await selectSessions({ id: session.id }); + if (rows?.[0]?.status !== "archived") return; + const cleared = clearSandboxState(session.sandbox_state); await updateSession(session.id, { sandbox_state: cleared as unknown as Json, From cd8782bc488cc7476ef768e0e47af1a55d7e543e Mon Sep 17 00:00:00 2001 From: john Date: Thu, 21 May 2026 16:42:48 +0700 Subject: [PATCH 09/21] fix(sessions): optimize session state update in stopSandboxOnArchive - Refined the logic in the `stopSandboxOnArchive` function to check if the session is still archived before updating its lifecycle state. This change improves the accuracy of session state management during archiving operations. --- lib/sessions/stopSandboxOnArchive.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/sessions/stopSandboxOnArchive.ts b/lib/sessions/stopSandboxOnArchive.ts index fc757373e..0006f029e 100644 --- a/lib/sessions/stopSandboxOnArchive.ts +++ b/lib/sessions/stopSandboxOnArchive.ts @@ -31,13 +31,12 @@ export async function stopSandboxOnArchive(session: Tables<"sessions">): Promise try { const rows = await selectSessions({ id: session.id }); - if (rows?.[0]?.status !== "archived") return; - + const isStillArchived = rows?.[0]?.status === "archived"; const cleared = clearSandboxState(session.sandbox_state); + await updateSession(session.id, { sandbox_state: cleared as unknown as Json, - lifecycle_state: "archived", - lifecycle_run_id: null, + ...(isStillArchived && { lifecycle_state: "archived", lifecycle_run_id: null }), }); } catch (error) { console.error(`[stopSandboxOnArchive] state clear failed for session ${session.id}:`, error); From 8b1e349e0f328aaa66907b9ff9eec9bd22843680 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 27 May 2026 01:33:43 +0700 Subject: [PATCH 10/21] Enhance session patching and sandbox state management - Updated the PATCH /api/sessions/[sessionId] handler to include additional lifecycle properties when archiving a session, ensuring proper state management. - Refined the hasRuntimeSandboxState function to improve checks for sandbox state validity, including handling of expiresAt and sandboxName. - Enhanced unit tests for hasRuntimeSandboxState to cover new scenarios, ensuring accurate state validation. - Improved the stopSandboxOnArchive function to handle errors more gracefully and ensure that sandbox state is cleared appropriately when archiving sessions. All changes are accompanied by passing tests, maintaining code integrity and functionality. --- .../[sessionId]/__tests__/route.test.ts | 6 +++ .../__tests__/hasRuntimeSandboxState.test.ts | 18 ++++++-- lib/sandbox/hasRuntimeSandboxState.ts | 33 +++++++------- lib/sessions/patchSessionByIdHandler.ts | 8 ++++ lib/sessions/stopSandboxOnArchive.ts | 44 ++++++++++++++----- 5 files changed, 80 insertions(+), 29 deletions(-) diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts index e9e8c5864..45625d091 100644 --- a/app/api/sessions/[sessionId]/__tests__/route.test.ts +++ b/app/api/sessions/[sessionId]/__tests__/route.test.ts @@ -361,6 +361,12 @@ describe("PATCH /api/sessions/[sessionId]", () => { expect(updateSession).toHaveBeenCalledWith("sess_1", { title: "Renamed session", status: "archived", + lifecycle_state: "archived", + lifecycle_run_id: null, + snapshot_url: null, + snapshot_created_at: null, + sandbox_expires_at: null, + hibernate_after: null, }); }); diff --git a/lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts b/lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts index 6b4f18966..3ea0cd2a4 100644 --- a/lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts +++ b/lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "vitest"; import { hasRuntimeSandboxState } from "@/lib/sandbox/hasRuntimeSandboxState"; +const EXPIRES_AT = 4_102_444_800_000; + describe("hasRuntimeSandboxState", () => { it("returns false for null", () => { expect(hasRuntimeSandboxState(null)).toBe(false); @@ -20,11 +22,21 @@ describe("hasRuntimeSandboxState", () => { expect(hasRuntimeSandboxState({ type: "vercel" })).toBe(false); }); - it("returns true when sandboxName is set", () => { - expect(hasRuntimeSandboxState({ type: "vercel", sandboxName: "session-x" })).toBe(true); + it("returns false when sandboxName is set but expiresAt is absent (expired/cleared state)", () => { + expect(hasRuntimeSandboxState({ type: "vercel", sandboxName: "session-x" })).toBe(false); }); it("returns false when sandboxName is the empty string", () => { - expect(hasRuntimeSandboxState({ type: "vercel", sandboxName: "" })).toBe(false); + expect(hasRuntimeSandboxState({ type: "vercel", sandboxName: "", expiresAt: EXPIRES_AT })).toBe(false); + }); + + it("returns true when sandboxName and expiresAt are both present", () => { + expect( + hasRuntimeSandboxState({ type: "vercel", sandboxName: "session-x", expiresAt: EXPIRES_AT }), + ).toBe(true); + }); + + it("returns false when expiresAt is present but sandboxName is absent", () => { + expect(hasRuntimeSandboxState({ type: "vercel", expiresAt: EXPIRES_AT })).toBe(false); }); }); diff --git a/lib/sandbox/hasRuntimeSandboxState.ts b/lib/sandbox/hasRuntimeSandboxState.ts index 5dc7e4171..2546e51d4 100644 --- a/lib/sandbox/hasRuntimeSandboxState.ts +++ b/lib/sandbox/hasRuntimeSandboxState.ts @@ -1,24 +1,27 @@ +import { getResumableSandboxName } from "@/lib/sandbox/getResumableSandboxName"; +import { getStateExpiresAt } from "@/lib/sandbox/getStateExpiresAt"; + /** - * Returns true when `sandbox_state` carries actual runtime metadata - * (i.e. a sandbox has been provisioned and bound to the session) rather - * than the type-only stub written at session creation. + * Returns true when `sandbox_state` carries live runtime metadata — + * i.e. a sandbox has been provisioned, is not yet expired, and has a + * resumable name. This mirrors open-agents semantics exactly: * - * `POST /api/sessions` (api PR #515) inserts `sandbox_state` as - * `{ type: "vercel" }` — a type discriminator with no runtime data. - * Callers must NOT treat this stub as evidence of a live sandbox; doing - * so causes `GET /api/sandbox/status` to report `"active"` immediately - * after session creation, which defeats the chat loading-state UX. + * expiresAt defined → sandbox was started (not a type-only stub or expired entry) + * resumable name → sandbox can be operated on * - * Runtime presence is currently keyed off a non-empty `sandboxName` — - * `POST /api/sandbox` writes this via `getSessionSandboxName(sessionId)` - * and the abstraction's `connectSandbox(...).getState()` preserves it. + * `POST /api/sessions` inserts `{ type: "vercel" }` (no expiresAt), so + * the creation stub correctly returns false. + * + * Expired state (sandboxName present but expiresAt absent/stripped) also + * returns false, preventing the 409 unarchive guard from firing on inert + * rows and stopping `stopSandboxOnArchive` from attempting to stop an + * already-gone sandbox. * * @param state - The persisted `sandbox_state` JSON column value. - * @returns true when the state has real runtime metadata; false for - * null/undefined, scalars, the empty type stub, or empty sandboxName. + * @returns true when the state describes a live, operable sandbox. */ export function hasRuntimeSandboxState(state: unknown): boolean { if (!state || typeof state !== "object") return false; - const candidate = state as { sandboxName?: unknown }; - return typeof candidate.sandboxName === "string" && candidate.sandboxName.length > 0; + if (getStateExpiresAt(state) === undefined) return false; + return getResumableSandboxName(state) !== null; } diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts index 321a58f4f..4049be424 100644 --- a/lib/sessions/patchSessionByIdHandler.ts +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -79,6 +79,14 @@ export async function patchSessionByIdHandler( ...(body.status !== undefined && { status: body.status }), ...(body.linesAdded !== undefined && { lines_added: body.linesAdded }), ...(body.linesRemoved !== undefined && { lines_removed: body.linesRemoved }), + ...(shouldArchive && { + lifecycle_state: "archived", + lifecycle_run_id: null, + snapshot_url: null, + snapshot_created_at: null, + sandbox_expires_at: null, + hibernate_after: null, + }), ...(shouldUnarchive && { lifecycle_state: null, lifecycle_error: null }), }; diff --git a/lib/sessions/stopSandboxOnArchive.ts b/lib/sessions/stopSandboxOnArchive.ts index 0006f029e..874ba8bac 100644 --- a/lib/sessions/stopSandboxOnArchive.ts +++ b/lib/sessions/stopSandboxOnArchive.ts @@ -9,36 +9,58 @@ import type { Json } from "@/types/database.types"; /** * Fire-and-forget sandbox teardown for newly-archived sessions. * - * Stops the running sandbox and marks the lifecycle as "archived" so - * the auto-hibernate workflow does not attempt to re-evaluate it. - * Must be scheduled via `after()` from the caller so the HTTP - * response is not blocked. + * Stops the running sandbox then clears its runtime state so the + * auto-hibernate workflow ignores the row. When the stop fails, persists + * a `lifecycle_error` and — if the row has no snapshot to fall back to — + * clears the runtime sandbox state so future unarchive attempts are not + * blocked forever by the 409 guard. * - * No-ops when the session has no runtime sandbox (nothing to stop). + * Must be scheduled via `after()` so the HTTP response is not blocked. + * No-ops immediately when the session has no runtime sandbox. * * @param session - The session row as it existed before archiving. */ export async function stopSandboxOnArchive(session: Tables<"sessions">): Promise { if (!hasRuntimeSandboxState(session.sandbox_state)) return; + let stopError: unknown; + try { const sandbox = await connectSandbox(session.sandbox_state as unknown as SandboxState); await sandbox.stop(); } catch (error) { + stopError = error; console.error(`[stopSandboxOnArchive] stop failed for session ${session.id}:`, error); - return; } try { const rows = await selectSessions({ id: session.id }); - const isStillArchived = rows?.[0]?.status === "archived"; - const cleared = clearSandboxState(session.sandbox_state); + const current = rows?.[0] ?? null; + + if (!current || current.status !== "archived") return; + + if (stopError !== undefined) { + const message = stopError instanceof Error ? stopError.message : String(stopError); + const shouldClearState = + !current.snapshot_url && hasRuntimeSandboxState(current.sandbox_state); + + await updateSession(session.id, { + lifecycle_error: `Archive finalization failed: ${message}`, + lifecycle_state: "archived", + lifecycle_run_id: null, + sandbox_expires_at: null, + hibernate_after: null, + ...(shouldClearState && { + sandbox_state: clearSandboxState(current.sandbox_state) as unknown as Json, + }), + }); + return; + } await updateSession(session.id, { - sandbox_state: cleared as unknown as Json, - ...(isStillArchived && { lifecycle_state: "archived", lifecycle_run_id: null }), + sandbox_state: clearSandboxState(session.sandbox_state) as unknown as Json, }); } catch (error) { - console.error(`[stopSandboxOnArchive] state clear failed for session ${session.id}:`, error); + console.error(`[stopSandboxOnArchive] state update failed for session ${session.id}:`, error); } } From 5a2ce3cba5914f9779af9851a3ca7b2087f98e79 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 27 May 2026 02:21:39 +0700 Subject: [PATCH 11/21] Refactor file path resolution in agent tools - Replaced `path` module usage with `resolveSandboxPath` and `joinSandboxPath` in multiple tools (bashTool, editFileTool, globTool, grepTool, readFileTool, skillTool, writeFileTool) to streamline path handling within the sandbox environment. - Updated `toDisplayPath` to utilize `resolveSandboxPath` and `isPathWithinSandboxDirectory` for improved path validation. - Enhanced `discoverSkills` and `findSkillFile` to use `joinSandboxPath` for directory handling, ensuring consistency across skill discovery processes. - Adjusted `recordCreditDeduction` to use `randomUUID` instead of `nanoid` for generating unique event IDs, aligning with modern practices. These changes improve code maintainability and ensure consistent path resolution across the application. --- lib/agent/tools/bashTool.ts | 8 +-- lib/agent/tools/editFileTool.ts | 6 +- lib/agent/tools/globTool.ts | 6 +- lib/agent/tools/grepTool.ts | 6 +- lib/agent/tools/readFileTool.ts | 6 +- lib/agent/tools/skillTool.ts | 4 +- lib/agent/tools/toDisplayPath.ts | 20 +++--- lib/agent/tools/writeFileTool.ts | 8 +-- lib/credits/recordCreditDeduction.ts | 4 +- .../__tests__/fixtures/runtimeSandboxState.ts | 10 +++ .../getSandboxReconnectHandler.test.ts | 3 +- .../__tests__/getSandboxStatusHandler.test.ts | 13 ++-- .../__tests__/hasRuntimeSandboxState.test.ts | 4 +- lib/sandbox/__tests__/isSandboxActive.test.ts | 9 +-- lib/sandbox/sandboxPaths.ts | 61 +++++++++++++++++++ lib/skills/discoverSkills.ts | 7 ++- lib/skills/findSkillFile.ts | 6 +- 17 files changed, 121 insertions(+), 60 deletions(-) create mode 100644 lib/sandbox/__tests__/fixtures/runtimeSandboxState.ts create mode 100644 lib/sandbox/sandboxPaths.ts diff --git a/lib/agent/tools/bashTool.ts b/lib/agent/tools/bashTool.ts index 479a608db..ed355ef13 100644 --- a/lib/agent/tools/bashTool.ts +++ b/lib/agent/tools/bashTool.ts @@ -1,8 +1,8 @@ import { tool } from "ai"; import { z } from "zod"; -import * as path from "path"; import { buildRecoupExecEnv } from "@/lib/agent/tools/buildRecoupExecEnv"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; +import { resolveSandboxPath } from "@/lib/sandbox/sandboxPaths"; const TIMEOUT_MS = 120_000; @@ -64,11 +64,7 @@ IMPORTANT: execute: async ({ command, cwd, detached }, { experimental_context, abortSignal }) => { const sandbox = await getSandbox(experimental_context, "bash"); const workingDirectory = sandbox.workingDirectory; - const workingDir = cwd - ? path.isAbsolute(cwd) - ? cwd - : path.resolve(workingDirectory, cwd) - : workingDirectory; + const workingDir = cwd ? resolveSandboxPath(workingDirectory, cwd) : workingDirectory; if (detached) { if (!sandbox.execDetached) { diff --git a/lib/agent/tools/editFileTool.ts b/lib/agent/tools/editFileTool.ts index d8274c0bc..e71f0de7c 100644 --- a/lib/agent/tools/editFileTool.ts +++ b/lib/agent/tools/editFileTool.ts @@ -1,8 +1,8 @@ import { tool } from "ai"; import { z } from "zod"; -import * as path from "path"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; +import { resolveSandboxPath } from "@/lib/sandbox/sandboxPaths"; const editInputSchema = z.object({ filePath: z.string().describe("Workspace-relative path to the file to edit (e.g., src/auth.ts)"), @@ -57,9 +57,7 @@ IMPORTANT: return { success: false, error: "oldString and newString must be different" }; } - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.resolve(workingDirectory, filePath); + const absolutePath = resolveSandboxPath(workingDirectory, filePath); const content = await sandbox.readFile(absolutePath, "utf-8"); if (!content.includes(oldString)) { diff --git a/lib/agent/tools/globTool.ts b/lib/agent/tools/globTool.ts index d1de234d2..6de071899 100644 --- a/lib/agent/tools/globTool.ts +++ b/lib/agent/tools/globTool.ts @@ -1,7 +1,7 @@ import { tool } from "ai"; import { z } from "zod"; -import * as path from "path"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; +import { joinSandboxPath, resolveSandboxPath } from "@/lib/sandbox/sandboxPaths"; import { shellEscape } from "@/lib/agent/tools/shellEscape"; import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; @@ -62,7 +62,7 @@ IMPORTANT: try { let searchDir: string; if (basePath) { - searchDir = path.isAbsolute(basePath) ? basePath : path.resolve(workingDirectory, basePath); + searchDir = resolveSandboxPath(workingDirectory, basePath); } else { searchDir = workingDirectory; } @@ -78,7 +78,7 @@ IMPORTANT: literalPrefix.push(part); } if (literalPrefix.length > 0) { - searchDir = path.join(searchDir, ...literalPrefix); + searchDir = joinSandboxPath(searchDir, ...literalPrefix); } const remainingDirSegments = patternParts.slice( diff --git a/lib/agent/tools/grepTool.ts b/lib/agent/tools/grepTool.ts index f172f61af..5d48a323a 100644 --- a/lib/agent/tools/grepTool.ts +++ b/lib/agent/tools/grepTool.ts @@ -1,7 +1,7 @@ import { tool } from "ai"; import { z } from "zod"; -import * as path from "path"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; +import { resolveSandboxPath } from "@/lib/sandbox/sandboxPaths"; import { shellEscape } from "@/lib/agent/tools/shellEscape"; import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; @@ -62,9 +62,7 @@ IMPORTANT: const workingDirectory = sandbox.workingDirectory; try { - const absolutePath = path.isAbsolute(searchPath) - ? searchPath - : path.resolve(workingDirectory, searchPath); + const absolutePath = resolveSandboxPath(workingDirectory, searchPath); const args: string[] = ["grep", "-rn"]; if (!caseSensitive) args.push("-i"); diff --git a/lib/agent/tools/readFileTool.ts b/lib/agent/tools/readFileTool.ts index f5a486a64..e2c9258f8 100644 --- a/lib/agent/tools/readFileTool.ts +++ b/lib/agent/tools/readFileTool.ts @@ -1,8 +1,8 @@ import { tool } from "ai"; import { z } from "zod"; -import * as path from "path"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; +import { resolveSandboxPath } from "@/lib/sandbox/sandboxPaths"; const readInputSchema = z.object({ filePath: z.string().describe("Workspace-relative path to the file to read (e.g., src/index.ts)"), @@ -35,9 +35,7 @@ IMPORTANT: const workingDirectory = sandbox.workingDirectory; try { - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.resolve(workingDirectory, filePath); + const absolutePath = resolveSandboxPath(workingDirectory, filePath); const stats = await sandbox.stat(absolutePath); if (stats.isDirectory()) { diff --git a/lib/agent/tools/skillTool.ts b/lib/agent/tools/skillTool.ts index 8c74f35d1..598f10ce4 100644 --- a/lib/agent/tools/skillTool.ts +++ b/lib/agent/tools/skillTool.ts @@ -1,7 +1,7 @@ -import * as path from "path"; import { tool } from "ai"; import { z } from "zod"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; +import { joinSandboxPath } from "@/lib/sandbox/sandboxPaths"; import { extractSkillBody } from "@/lib/skills/extractSkillBody"; import { getSkills } from "@/lib/skills/getSkills"; import { injectSkillDirectory } from "@/lib/skills/injectSkillDirectory"; @@ -64,7 +64,7 @@ Important: }; } - const skillFilePath = path.join(found.path, found.filename); + const skillFilePath = joinSandboxPath(found.path, found.filename); let fileContent: string; try { fileContent = await sandbox.readFile(skillFilePath, "utf-8"); diff --git a/lib/agent/tools/toDisplayPath.ts b/lib/agent/tools/toDisplayPath.ts index 827c391af..c977fc563 100644 --- a/lib/agent/tools/toDisplayPath.ts +++ b/lib/agent/tools/toDisplayPath.ts @@ -1,10 +1,8 @@ -import * as path from "path"; - -function isPathWithinDirectory(filePath: string, directory: string): boolean { - const resolvedPath = path.resolve(filePath); - const resolvedDir = path.resolve(directory); - return resolvedPath.startsWith(resolvedDir + path.sep) || resolvedPath === resolvedDir; -} +import { + isPathWithinSandboxDirectory, + relativeSandboxPath, + resolveSandboxPath, +} from "@/lib/sandbox/sandboxPaths"; /** * Convert an absolute (or relative-to-workingDirectory) path into a compact @@ -19,15 +17,13 @@ function isPathWithinDirectory(filePath: string, directory: string): boolean { * @param workingDirectory - The sandbox's working directory (always absolute). */ export function toDisplayPath(filePath: string, workingDirectory: string): string { - const absolutePath = path.isAbsolute(filePath) - ? path.resolve(filePath) - : path.resolve(workingDirectory, filePath); + const absolutePath = resolveSandboxPath(workingDirectory, filePath); - if (!isPathWithinDirectory(absolutePath, workingDirectory)) { + if (!isPathWithinSandboxDirectory(absolutePath, workingDirectory)) { return absolutePath.replace(/\\/g, "/"); } - const relativePath = path.relative(workingDirectory, absolutePath); + const relativePath = relativeSandboxPath(workingDirectory, absolutePath); if (relativePath === "") return "."; return relativePath.replace(/\\/g, "/"); diff --git a/lib/agent/tools/writeFileTool.ts b/lib/agent/tools/writeFileTool.ts index c8e59e3c3..c00cabd20 100644 --- a/lib/agent/tools/writeFileTool.ts +++ b/lib/agent/tools/writeFileTool.ts @@ -1,8 +1,8 @@ import { tool } from "ai"; import { z } from "zod"; -import * as path from "path"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; +import { dirnameSandboxPath, resolveSandboxPath } from "@/lib/sandbox/sandboxPaths"; const writeInputSchema = z.object({ filePath: z @@ -44,10 +44,8 @@ IMPORTANT: const workingDirectory = sandbox.workingDirectory; try { - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.resolve(workingDirectory, filePath); - const dir = path.dirname(absolutePath); + const absolutePath = resolveSandboxPath(workingDirectory, filePath); + const dir = dirnameSandboxPath(absolutePath, workingDirectory); await sandbox.mkdir(dir, { recursive: true }); await sandbox.writeFile(absolutePath, content, "utf-8"); const stats = await sandbox.stat(absolutePath); diff --git a/lib/credits/recordCreditDeduction.ts b/lib/credits/recordCreditDeduction.ts index 3bf61ecce..21c6cd28d 100644 --- a/lib/credits/recordCreditDeduction.ts +++ b/lib/credits/recordCreditDeduction.ts @@ -1,4 +1,4 @@ -import { nanoid } from "nanoid"; +import { randomUUID } from "node:crypto"; import { deductCreditsWithAudit } from "@/lib/supabase/credits_usage/deductCreditsWithAudit"; interface RecordCreditDeductionParams { @@ -40,7 +40,7 @@ export const recordCreditDeduction = async ( const result = await deductCreditsWithAudit({ accountId: params.accountId, cents: params.creditsToDeduct, - eventId: nanoid(), + eventId: randomUUID(), event: { source: params.source, agent_type: params.agentType ?? "main", diff --git a/lib/sandbox/__tests__/fixtures/runtimeSandboxState.ts b/lib/sandbox/__tests__/fixtures/runtimeSandboxState.ts new file mode 100644 index 000000000..c621a7b61 --- /dev/null +++ b/lib/sandbox/__tests__/fixtures/runtimeSandboxState.ts @@ -0,0 +1,10 @@ +/** Epoch ms used in tests for a live sandbox row (`sandbox_state.expiresAt`). */ +export const RUNTIME_EXPIRES_AT = 4_102_444_800_000; + +/** Minimal live runtime `sandbox_state` blob matching open-agents semantics. */ +export function runtimeSandboxState( + sandboxName = "session-sess-1", + expiresAt = RUNTIME_EXPIRES_AT, +): { type: string; sandboxName: string; expiresAt: number } { + return { type: "vercel", sandboxName, expiresAt }; +} diff --git a/lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts b/lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts index f1afbf14f..28be2c57b 100644 --- a/lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts +++ b/lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts @@ -6,6 +6,7 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; import { connectSandbox } from "@/lib/sandbox/factory"; import { updateSession } from "@/lib/supabase/sessions/updateSession"; +import { runtimeSandboxState } from "@/lib/sandbox/__tests__/fixtures/runtimeSandboxState"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), @@ -24,7 +25,7 @@ vi.mock("@/lib/supabase/sessions/updateSession", () => ({ })); const ACCOUNT_ID = "acc-1"; -const RUNTIME_STATE = { type: "vercel", sandboxName: "session-sess-1" }; +const RUNTIME_STATE = runtimeSandboxState(); const baseRow = { id: "sess-1", diff --git a/lib/sandbox/__tests__/getSandboxStatusHandler.test.ts b/lib/sandbox/__tests__/getSandboxStatusHandler.test.ts index 4a6e2e581..d6d8d3702 100644 --- a/lib/sandbox/__tests__/getSandboxStatusHandler.test.ts +++ b/lib/sandbox/__tests__/getSandboxStatusHandler.test.ts @@ -5,6 +5,7 @@ import { getSandboxStatusHandler } from "@/lib/sandbox/getSandboxStatusHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; import { updateSession } from "@/lib/supabase/sessions/updateSession"; +import { runtimeSandboxState } from "@/lib/sandbox/__tests__/fixtures/runtimeSandboxState"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), @@ -91,7 +92,7 @@ describe("getSandboxStatusHandler", () => { vi.mocked(selectSessions).mockResolvedValue([ { ...baseRow, - sandbox_state: { type: "vercel", sandboxName: "session-sess-1" }, + sandbox_state: runtimeSandboxState(), lifecycle_state: "active", lifecycle_version: 3, sandbox_expires_at: FAR_FUTURE, @@ -166,11 +167,11 @@ describe("getSandboxStatusHandler", () => { expect(body.status).toBe("no_sandbox"); }); - it("returns status='active' once sandboxName is set on the state, even without explicit expiry", async () => { + it("returns status='active' once runtime metadata is on the state, even when sandbox_expires_at is null", async () => { vi.mocked(selectSessions).mockResolvedValue([ { ...baseRow, - sandbox_state: { type: "vercel", sandboxName: "session-sess-1" }, + sandbox_state: runtimeSandboxState(), sandbox_expires_at: null, } as any, ]); @@ -202,7 +203,7 @@ describe("getSandboxStatusHandler", () => { ]); vi.mocked(updateSession).mockResolvedValueOnce({ ...baseRow, - sandbox_state: { type: "vercel", sandboxName: "session-sess-1" }, + sandbox_state: runtimeSandboxState(), lifecycle_state: "active", lifecycle_error: null, sandbox_expires_at: FAR_FUTURE, @@ -247,7 +248,7 @@ describe("getSandboxStatusHandler", () => { vi.mocked(selectSessions).mockResolvedValue([ { ...baseRow, - sandbox_state: { type: "vercel", sandboxName: "session-sess-1" }, + sandbox_state: runtimeSandboxState(), lifecycle_state: "hibernated", snapshot_url: null, } as any, @@ -263,7 +264,7 @@ describe("getSandboxStatusHandler", () => { vi.mocked(selectSessions).mockResolvedValue([ { ...baseRow, - sandbox_state: { type: "vercel", sandboxName: "session-sess-1" }, + sandbox_state: runtimeSandboxState(), lifecycle_state: "active", sandbox_expires_at: FAR_FUTURE, snapshot_url: null, diff --git a/lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts b/lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts index 3ea0cd2a4..b52d12a78 100644 --- a/lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts +++ b/lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts @@ -27,7 +27,9 @@ describe("hasRuntimeSandboxState", () => { }); it("returns false when sandboxName is the empty string", () => { - expect(hasRuntimeSandboxState({ type: "vercel", sandboxName: "", expiresAt: EXPIRES_AT })).toBe(false); + expect(hasRuntimeSandboxState({ type: "vercel", sandboxName: "", expiresAt: EXPIRES_AT })).toBe( + false, + ); }); it("returns true when sandboxName and expiresAt are both present", () => { diff --git a/lib/sandbox/__tests__/isSandboxActive.test.ts b/lib/sandbox/__tests__/isSandboxActive.test.ts index c8f08a1f3..d71e719c5 100644 --- a/lib/sandbox/__tests__/isSandboxActive.test.ts +++ b/lib/sandbox/__tests__/isSandboxActive.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { isSandboxActive } from "@/lib/sandbox/isSandboxActive"; +import { runtimeSandboxState } from "@/lib/sandbox/__tests__/fixtures/runtimeSandboxState"; const FAR_FUTURE = "2099-01-01T00:00:00.000Z"; const FAR_PAST = "2000-01-01T00:00:00.000Z"; @@ -22,7 +23,7 @@ describe("isSandboxActive", () => { expect( isSandboxActive({ ...baseRow, - sandbox_state: { type: "vercel", sandboxName: "session-x" }, + sandbox_state: runtimeSandboxState("session-x"), sandbox_expires_at: FAR_FUTURE, } as any), ).toBe(true); @@ -32,17 +33,17 @@ describe("isSandboxActive", () => { expect( isSandboxActive({ ...baseRow, - sandbox_state: { type: "vercel", sandboxName: "session-x" }, + sandbox_state: runtimeSandboxState("session-x"), sandbox_expires_at: FAR_PAST, } as any), ).toBe(false); }); - it("returns true when sandboxName is set but expiry is null (no expiry to compare against)", () => { + it("returns true when runtime state is live but sandbox_expires_at column is null", () => { expect( isSandboxActive({ ...baseRow, - sandbox_state: { type: "vercel", sandboxName: "session-x" }, + sandbox_state: runtimeSandboxState("session-x"), sandbox_expires_at: null, } as any), ).toBe(true); diff --git a/lib/sandbox/sandboxPaths.ts b/lib/sandbox/sandboxPaths.ts new file mode 100644 index 000000000..ceb7b0cd6 --- /dev/null +++ b/lib/sandbox/sandboxPaths.ts @@ -0,0 +1,61 @@ +import * as path from "path"; + +/** Remote sandboxes use POSIX paths even when the API process runs on Windows. */ +export function isPosixSandboxPath(directory: string): boolean { + return directory.startsWith("/"); +} + +function toPosixSegment(segment: string): string { + return segment.replace(/\\/g, "/"); +} + +/** + * Resolve a workspace-relative or absolute path inside a sandbox working directory. + */ +export function resolveSandboxPath(workingDirectory: string, filePath: string): string { + if (isPosixSandboxPath(workingDirectory)) { + const normalized = toPosixSegment(filePath); + if (normalized.startsWith("/")) { + return path.posix.normalize(normalized); + } + return path.posix.resolve(workingDirectory, normalized); + } + + return path.isAbsolute(filePath) + ? path.resolve(filePath) + : path.resolve(workingDirectory, filePath); +} + +/** Join path segments, preserving POSIX semantics for sandbox paths. */ +export function joinSandboxPath(...segments: string[]): string { + if (segments.some(isPosixSandboxPath)) { + return path.posix.join(...segments.map(toPosixSegment)); + } + return path.join(...segments); +} + +export function dirnameSandboxPath(filePath: string, workingDirectory: string): string { + if (isPosixSandboxPath(workingDirectory) || isPosixSandboxPath(filePath)) { + return path.posix.dirname(toPosixSegment(filePath)); + } + return path.dirname(filePath); +} + +export function isPathWithinSandboxDirectory(filePath: string, directory: string): boolean { + if (isPosixSandboxPath(directory)) { + const resolvedPath = path.posix.normalize(toPosixSegment(filePath)); + const resolvedDir = path.posix.normalize(directory); + return resolvedPath === resolvedDir || resolvedPath.startsWith(`${resolvedDir}/`); + } + + const resolvedPath = path.resolve(filePath); + const resolvedDir = path.resolve(directory); + return resolvedPath.startsWith(resolvedDir + path.sep) || resolvedPath === resolvedDir; +} + +export function relativeSandboxPath(from: string, to: string): string { + if (isPosixSandboxPath(from)) { + return path.posix.relative(from, toPosixSegment(to)); + } + return path.relative(from, to); +} diff --git a/lib/skills/discoverSkills.ts b/lib/skills/discoverSkills.ts index 9ae0ced67..3ae16c212 100644 --- a/lib/skills/discoverSkills.ts +++ b/lib/skills/discoverSkills.ts @@ -1,5 +1,6 @@ -import * as path from "path"; +import path from "path"; import type { Sandbox } from "@/lib/sandbox/interface"; +import { joinSandboxPath } from "@/lib/sandbox/sandboxPaths"; import { findSkillFile } from "@/lib/skills/findSkillFile"; import { parseSkillFrontmatter } from "@/lib/skills/parseSkillFrontmatter"; import { frontmatterToOptions, type SkillMetadata } from "@/lib/skills/skillTypes"; @@ -49,7 +50,7 @@ export async function discoverSkills( for (const entry of entries) { if (!entry.isDirectory()) continue; - const skillDir = path.join(dir, entry.name); + const skillDir = joinSandboxPath(dir, entry.name); const skillFile = await findSkillFile(sandbox, skillDir); if (!skillFile) continue; @@ -79,7 +80,7 @@ export async function discoverSkills( name: frontmatter.name, description: frontmatter.description, path: skillDir, - filename: path.basename(skillFile), + filename: path.posix.basename(skillFile), options: frontmatterToOptions(frontmatter), }); } diff --git a/lib/skills/findSkillFile.ts b/lib/skills/findSkillFile.ts index a81b9e415..07336e364 100644 --- a/lib/skills/findSkillFile.ts +++ b/lib/skills/findSkillFile.ts @@ -1,5 +1,5 @@ -import * as path from "path"; import type { Sandbox } from "@/lib/sandbox/interface"; +import { joinSandboxPath } from "@/lib/sandbox/sandboxPaths"; /** * Locate the SKILL.md file inside a candidate skill directory. Prefers @@ -15,8 +15,8 @@ import type { Sandbox } from "@/lib/sandbox/interface"; * @param skillDir - Absolute path to the candidate skill directory. */ export async function findSkillFile(sandbox: Sandbox, skillDir: string): Promise { - const uppercase = path.join(skillDir, "SKILL.md"); - const lowercase = path.join(skillDir, "skill.md"); + const uppercase = joinSandboxPath(skillDir, "SKILL.md"); + const lowercase = joinSandboxPath(skillDir, "skill.md"); try { await sandbox.access(uppercase); From 7fefdaa67d51191a25bb0eff0bee73e0a695767b Mon Sep 17 00:00:00 2001 From: john Date: Wed, 27 May 2026 14:51:24 +0700 Subject: [PATCH 12/21] Refactor credit deduction and path handling - Replaced `randomUUID` with `nanoid` in `recordCreditDeduction` for generating unique event IDs, aligning with modern practices. - Enhanced `isPathWithinSandboxDirectory` to avoid false negatives when checking paths, ensuring accurate path validation. - Updated `discoverSkills` to normalize file paths by replacing backslashes with forward slashes, improving cross-platform compatibility. These changes improve code consistency and maintainability across the application. --- lib/credits/recordCreditDeduction.ts | 4 ++-- lib/sandbox/sandboxPaths.ts | 4 +++- lib/skills/discoverSkills.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/credits/recordCreditDeduction.ts b/lib/credits/recordCreditDeduction.ts index 21c6cd28d..3bf61ecce 100644 --- a/lib/credits/recordCreditDeduction.ts +++ b/lib/credits/recordCreditDeduction.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { nanoid } from "nanoid"; import { deductCreditsWithAudit } from "@/lib/supabase/credits_usage/deductCreditsWithAudit"; interface RecordCreditDeductionParams { @@ -40,7 +40,7 @@ export const recordCreditDeduction = async ( const result = await deductCreditsWithAudit({ accountId: params.accountId, cents: params.creditsToDeduct, - eventId: randomUUID(), + eventId: nanoid(), event: { source: params.source, agent_type: params.agentType ?? "main", diff --git a/lib/sandbox/sandboxPaths.ts b/lib/sandbox/sandboxPaths.ts index ceb7b0cd6..65efa5421 100644 --- a/lib/sandbox/sandboxPaths.ts +++ b/lib/sandbox/sandboxPaths.ts @@ -45,7 +45,9 @@ export function isPathWithinSandboxDirectory(filePath: string, directory: string if (isPosixSandboxPath(directory)) { const resolvedPath = path.posix.normalize(toPosixSegment(filePath)); const resolvedDir = path.posix.normalize(directory); - return resolvedPath === resolvedDir || resolvedPath.startsWith(`${resolvedDir}/`); + // Avoid the "//child" false-negative when resolvedDir is the root "/". + const dirPrefix = resolvedDir.endsWith("/") ? resolvedDir : `${resolvedDir}/`; + return resolvedPath === resolvedDir || resolvedPath.startsWith(dirPrefix); } const resolvedPath = path.resolve(filePath); diff --git a/lib/skills/discoverSkills.ts b/lib/skills/discoverSkills.ts index 3ae16c212..4d0f471b6 100644 --- a/lib/skills/discoverSkills.ts +++ b/lib/skills/discoverSkills.ts @@ -80,7 +80,7 @@ export async function discoverSkills( name: frontmatter.name, description: frontmatter.description, path: skillDir, - filename: path.posix.basename(skillFile), + filename: path.basename(skillFile.replace(/\\/g, "/")), options: frontmatterToOptions(frontmatter), }); } From 925c0112f805f0bed1fef7d8976de7fd730864a7 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 27 May 2026 14:59:56 +0700 Subject: [PATCH 13/21] Refactor sandbox path handling in agent tools - Updated imports in multiple tools (bashTool, editFileTool, globTool, grepTool, readFileTool, skillTool, writeFileTool) to use the new `resolveSandboxPath` and `joinSandboxPath` functions, enhancing path resolution consistency. - Removed the deprecated `sandboxPaths` module, consolidating path-related functions into dedicated files for better organization. - Introduced new utility functions: `dirnameSandboxPath`, `isPathWithinSandboxDirectory`, and `relativeSandboxPath` to streamline path operations and improve maintainability. These changes enhance the clarity and efficiency of path management within the sandbox environment. --- lib/agent/tools/bashTool.ts | 2 +- lib/agent/tools/editFileTool.ts | 2 +- lib/agent/tools/globTool.ts | 3 +- lib/agent/tools/grepTool.ts | 2 +- lib/agent/tools/readFileTool.ts | 2 +- lib/agent/tools/skillTool.ts | 2 +- lib/agent/tools/toDisplayPath.ts | 8 +-- lib/agent/tools/writeFileTool.ts | 3 +- lib/sandbox/dirnameSandboxPath.ts | 10 ++++ lib/sandbox/isPathWithinSandboxDirectory.ts | 17 ++++++ lib/sandbox/isPosixSandboxPath.ts | 4 ++ lib/sandbox/joinSandboxPath.ts | 11 ++++ lib/sandbox/relativeSandboxPath.ts | 10 ++++ lib/sandbox/resolveSandboxPath.ts | 20 +++++++ lib/sandbox/sandboxPaths.ts | 63 --------------------- lib/sandbox/toPosixSegment.ts | 4 ++ lib/skills/discoverSkills.ts | 2 +- lib/skills/findSkillFile.ts | 2 +- 18 files changed, 90 insertions(+), 77 deletions(-) create mode 100644 lib/sandbox/dirnameSandboxPath.ts create mode 100644 lib/sandbox/isPathWithinSandboxDirectory.ts create mode 100644 lib/sandbox/isPosixSandboxPath.ts create mode 100644 lib/sandbox/joinSandboxPath.ts create mode 100644 lib/sandbox/relativeSandboxPath.ts create mode 100644 lib/sandbox/resolveSandboxPath.ts delete mode 100644 lib/sandbox/sandboxPaths.ts create mode 100644 lib/sandbox/toPosixSegment.ts diff --git a/lib/agent/tools/bashTool.ts b/lib/agent/tools/bashTool.ts index ed355ef13..455f4d1cb 100644 --- a/lib/agent/tools/bashTool.ts +++ b/lib/agent/tools/bashTool.ts @@ -2,7 +2,7 @@ import { tool } from "ai"; import { z } from "zod"; import { buildRecoupExecEnv } from "@/lib/agent/tools/buildRecoupExecEnv"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; -import { resolveSandboxPath } from "@/lib/sandbox/sandboxPaths"; +import { resolveSandboxPath } from "@/lib/sandbox/resolveSandboxPath"; const TIMEOUT_MS = 120_000; diff --git a/lib/agent/tools/editFileTool.ts b/lib/agent/tools/editFileTool.ts index e71f0de7c..99cce0b46 100644 --- a/lib/agent/tools/editFileTool.ts +++ b/lib/agent/tools/editFileTool.ts @@ -2,7 +2,7 @@ import { tool } from "ai"; import { z } from "zod"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; -import { resolveSandboxPath } from "@/lib/sandbox/sandboxPaths"; +import { resolveSandboxPath } from "@/lib/sandbox/resolveSandboxPath"; const editInputSchema = z.object({ filePath: z.string().describe("Workspace-relative path to the file to edit (e.g., src/auth.ts)"), diff --git a/lib/agent/tools/globTool.ts b/lib/agent/tools/globTool.ts index 6de071899..c89191f48 100644 --- a/lib/agent/tools/globTool.ts +++ b/lib/agent/tools/globTool.ts @@ -1,7 +1,8 @@ import { tool } from "ai"; import { z } from "zod"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; -import { joinSandboxPath, resolveSandboxPath } from "@/lib/sandbox/sandboxPaths"; +import { joinSandboxPath } from "@/lib/sandbox/joinSandboxPath"; +import { resolveSandboxPath } from "@/lib/sandbox/resolveSandboxPath"; import { shellEscape } from "@/lib/agent/tools/shellEscape"; import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; diff --git a/lib/agent/tools/grepTool.ts b/lib/agent/tools/grepTool.ts index 5d48a323a..45d0bc336 100644 --- a/lib/agent/tools/grepTool.ts +++ b/lib/agent/tools/grepTool.ts @@ -1,7 +1,7 @@ import { tool } from "ai"; import { z } from "zod"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; -import { resolveSandboxPath } from "@/lib/sandbox/sandboxPaths"; +import { resolveSandboxPath } from "@/lib/sandbox/resolveSandboxPath"; import { shellEscape } from "@/lib/agent/tools/shellEscape"; import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; diff --git a/lib/agent/tools/readFileTool.ts b/lib/agent/tools/readFileTool.ts index e2c9258f8..aacfcbedd 100644 --- a/lib/agent/tools/readFileTool.ts +++ b/lib/agent/tools/readFileTool.ts @@ -2,7 +2,7 @@ import { tool } from "ai"; import { z } from "zod"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; -import { resolveSandboxPath } from "@/lib/sandbox/sandboxPaths"; +import { resolveSandboxPath } from "@/lib/sandbox/resolveSandboxPath"; const readInputSchema = z.object({ filePath: z.string().describe("Workspace-relative path to the file to read (e.g., src/index.ts)"), diff --git a/lib/agent/tools/skillTool.ts b/lib/agent/tools/skillTool.ts index 598f10ce4..2b8dca92f 100644 --- a/lib/agent/tools/skillTool.ts +++ b/lib/agent/tools/skillTool.ts @@ -1,7 +1,7 @@ import { tool } from "ai"; import { z } from "zod"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; -import { joinSandboxPath } from "@/lib/sandbox/sandboxPaths"; +import { joinSandboxPath } from "@/lib/sandbox/joinSandboxPath"; import { extractSkillBody } from "@/lib/skills/extractSkillBody"; import { getSkills } from "@/lib/skills/getSkills"; import { injectSkillDirectory } from "@/lib/skills/injectSkillDirectory"; diff --git a/lib/agent/tools/toDisplayPath.ts b/lib/agent/tools/toDisplayPath.ts index c977fc563..1f6bda679 100644 --- a/lib/agent/tools/toDisplayPath.ts +++ b/lib/agent/tools/toDisplayPath.ts @@ -1,8 +1,6 @@ -import { - isPathWithinSandboxDirectory, - relativeSandboxPath, - resolveSandboxPath, -} from "@/lib/sandbox/sandboxPaths"; +import { isPathWithinSandboxDirectory } from "@/lib/sandbox/isPathWithinSandboxDirectory"; +import { relativeSandboxPath } from "@/lib/sandbox/relativeSandboxPath"; +import { resolveSandboxPath } from "@/lib/sandbox/resolveSandboxPath"; /** * Convert an absolute (or relative-to-workingDirectory) path into a compact diff --git a/lib/agent/tools/writeFileTool.ts b/lib/agent/tools/writeFileTool.ts index c00cabd20..665588e02 100644 --- a/lib/agent/tools/writeFileTool.ts +++ b/lib/agent/tools/writeFileTool.ts @@ -2,7 +2,8 @@ import { tool } from "ai"; import { z } from "zod"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; -import { dirnameSandboxPath, resolveSandboxPath } from "@/lib/sandbox/sandboxPaths"; +import { dirnameSandboxPath } from "@/lib/sandbox/dirnameSandboxPath"; +import { resolveSandboxPath } from "@/lib/sandbox/resolveSandboxPath"; const writeInputSchema = z.object({ filePath: z diff --git a/lib/sandbox/dirnameSandboxPath.ts b/lib/sandbox/dirnameSandboxPath.ts new file mode 100644 index 000000000..8d6438ec4 --- /dev/null +++ b/lib/sandbox/dirnameSandboxPath.ts @@ -0,0 +1,10 @@ +import * as path from "path"; +import { isPosixSandboxPath } from "@/lib/sandbox/isPosixSandboxPath"; +import { toPosixSegment } from "@/lib/sandbox/toPosixSegment"; + +export function dirnameSandboxPath(filePath: string, workingDirectory: string): string { + if (isPosixSandboxPath(workingDirectory) || isPosixSandboxPath(filePath)) { + return path.posix.dirname(toPosixSegment(filePath)); + } + return path.dirname(filePath); +} diff --git a/lib/sandbox/isPathWithinSandboxDirectory.ts b/lib/sandbox/isPathWithinSandboxDirectory.ts new file mode 100644 index 000000000..1eca2241b --- /dev/null +++ b/lib/sandbox/isPathWithinSandboxDirectory.ts @@ -0,0 +1,17 @@ +import * as path from "path"; +import { isPosixSandboxPath } from "@/lib/sandbox/isPosixSandboxPath"; +import { toPosixSegment } from "@/lib/sandbox/toPosixSegment"; + +export function isPathWithinSandboxDirectory(filePath: string, directory: string): boolean { + if (isPosixSandboxPath(directory)) { + const resolvedPath = path.posix.normalize(toPosixSegment(filePath)); + const resolvedDir = path.posix.normalize(directory); + // Avoid the "//child" false-negative when resolvedDir is the root "/". + const dirPrefix = resolvedDir.endsWith("/") ? resolvedDir : `${resolvedDir}/`; + return resolvedPath === resolvedDir || resolvedPath.startsWith(dirPrefix); + } + + const resolvedPath = path.resolve(filePath); + const resolvedDir = path.resolve(directory); + return resolvedPath.startsWith(resolvedDir + path.sep) || resolvedPath === resolvedDir; +} diff --git a/lib/sandbox/isPosixSandboxPath.ts b/lib/sandbox/isPosixSandboxPath.ts new file mode 100644 index 000000000..a03ffed47 --- /dev/null +++ b/lib/sandbox/isPosixSandboxPath.ts @@ -0,0 +1,4 @@ +/** Remote sandboxes use POSIX paths even when the API process runs on Windows. */ +export function isPosixSandboxPath(directory: string): boolean { + return directory.startsWith("/"); +} diff --git a/lib/sandbox/joinSandboxPath.ts b/lib/sandbox/joinSandboxPath.ts new file mode 100644 index 000000000..8eb8e6d14 --- /dev/null +++ b/lib/sandbox/joinSandboxPath.ts @@ -0,0 +1,11 @@ +import * as path from "path"; +import { isPosixSandboxPath } from "@/lib/sandbox/isPosixSandboxPath"; +import { toPosixSegment } from "@/lib/sandbox/toPosixSegment"; + +/** Join path segments, preserving POSIX semantics for sandbox paths. */ +export function joinSandboxPath(...segments: string[]): string { + if (segments.some(isPosixSandboxPath)) { + return path.posix.join(...segments.map(toPosixSegment)); + } + return path.join(...segments); +} diff --git a/lib/sandbox/relativeSandboxPath.ts b/lib/sandbox/relativeSandboxPath.ts new file mode 100644 index 000000000..da032d095 --- /dev/null +++ b/lib/sandbox/relativeSandboxPath.ts @@ -0,0 +1,10 @@ +import * as path from "path"; +import { isPosixSandboxPath } from "@/lib/sandbox/isPosixSandboxPath"; +import { toPosixSegment } from "@/lib/sandbox/toPosixSegment"; + +export function relativeSandboxPath(from: string, to: string): string { + if (isPosixSandboxPath(from)) { + return path.posix.relative(from, toPosixSegment(to)); + } + return path.relative(from, to); +} diff --git a/lib/sandbox/resolveSandboxPath.ts b/lib/sandbox/resolveSandboxPath.ts new file mode 100644 index 000000000..6906d096b --- /dev/null +++ b/lib/sandbox/resolveSandboxPath.ts @@ -0,0 +1,20 @@ +import * as path from "path"; +import { isPosixSandboxPath } from "@/lib/sandbox/isPosixSandboxPath"; +import { toPosixSegment } from "@/lib/sandbox/toPosixSegment"; + +/** + * Resolve a workspace-relative or absolute path inside a sandbox working directory. + */ +export function resolveSandboxPath(workingDirectory: string, filePath: string): string { + if (isPosixSandboxPath(workingDirectory)) { + const normalized = toPosixSegment(filePath); + if (normalized.startsWith("/")) { + return path.posix.normalize(normalized); + } + return path.posix.resolve(workingDirectory, normalized); + } + + return path.isAbsolute(filePath) + ? path.resolve(filePath) + : path.resolve(workingDirectory, filePath); +} diff --git a/lib/sandbox/sandboxPaths.ts b/lib/sandbox/sandboxPaths.ts deleted file mode 100644 index 65efa5421..000000000 --- a/lib/sandbox/sandboxPaths.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as path from "path"; - -/** Remote sandboxes use POSIX paths even when the API process runs on Windows. */ -export function isPosixSandboxPath(directory: string): boolean { - return directory.startsWith("/"); -} - -function toPosixSegment(segment: string): string { - return segment.replace(/\\/g, "/"); -} - -/** - * Resolve a workspace-relative or absolute path inside a sandbox working directory. - */ -export function resolveSandboxPath(workingDirectory: string, filePath: string): string { - if (isPosixSandboxPath(workingDirectory)) { - const normalized = toPosixSegment(filePath); - if (normalized.startsWith("/")) { - return path.posix.normalize(normalized); - } - return path.posix.resolve(workingDirectory, normalized); - } - - return path.isAbsolute(filePath) - ? path.resolve(filePath) - : path.resolve(workingDirectory, filePath); -} - -/** Join path segments, preserving POSIX semantics for sandbox paths. */ -export function joinSandboxPath(...segments: string[]): string { - if (segments.some(isPosixSandboxPath)) { - return path.posix.join(...segments.map(toPosixSegment)); - } - return path.join(...segments); -} - -export function dirnameSandboxPath(filePath: string, workingDirectory: string): string { - if (isPosixSandboxPath(workingDirectory) || isPosixSandboxPath(filePath)) { - return path.posix.dirname(toPosixSegment(filePath)); - } - return path.dirname(filePath); -} - -export function isPathWithinSandboxDirectory(filePath: string, directory: string): boolean { - if (isPosixSandboxPath(directory)) { - const resolvedPath = path.posix.normalize(toPosixSegment(filePath)); - const resolvedDir = path.posix.normalize(directory); - // Avoid the "//child" false-negative when resolvedDir is the root "/". - const dirPrefix = resolvedDir.endsWith("/") ? resolvedDir : `${resolvedDir}/`; - return resolvedPath === resolvedDir || resolvedPath.startsWith(dirPrefix); - } - - const resolvedPath = path.resolve(filePath); - const resolvedDir = path.resolve(directory); - return resolvedPath.startsWith(resolvedDir + path.sep) || resolvedPath === resolvedDir; -} - -export function relativeSandboxPath(from: string, to: string): string { - if (isPosixSandboxPath(from)) { - return path.posix.relative(from, toPosixSegment(to)); - } - return path.relative(from, to); -} diff --git a/lib/sandbox/toPosixSegment.ts b/lib/sandbox/toPosixSegment.ts new file mode 100644 index 000000000..004bc624d --- /dev/null +++ b/lib/sandbox/toPosixSegment.ts @@ -0,0 +1,4 @@ +/** Normalize native path separators to POSIX forward slashes. */ +export function toPosixSegment(segment: string): string { + return segment.replace(/\\/g, "/"); +} diff --git a/lib/skills/discoverSkills.ts b/lib/skills/discoverSkills.ts index 4d0f471b6..62489a0d7 100644 --- a/lib/skills/discoverSkills.ts +++ b/lib/skills/discoverSkills.ts @@ -1,6 +1,6 @@ import path from "path"; import type { Sandbox } from "@/lib/sandbox/interface"; -import { joinSandboxPath } from "@/lib/sandbox/sandboxPaths"; +import { joinSandboxPath } from "@/lib/sandbox/joinSandboxPath"; import { findSkillFile } from "@/lib/skills/findSkillFile"; import { parseSkillFrontmatter } from "@/lib/skills/parseSkillFrontmatter"; import { frontmatterToOptions, type SkillMetadata } from "@/lib/skills/skillTypes"; diff --git a/lib/skills/findSkillFile.ts b/lib/skills/findSkillFile.ts index 07336e364..09c45a58e 100644 --- a/lib/skills/findSkillFile.ts +++ b/lib/skills/findSkillFile.ts @@ -1,5 +1,5 @@ import type { Sandbox } from "@/lib/sandbox/interface"; -import { joinSandboxPath } from "@/lib/sandbox/sandboxPaths"; +import { joinSandboxPath } from "@/lib/sandbox/joinSandboxPath"; /** * Locate the SKILL.md file inside a candidate skill directory. Prefers From a141f48dcdbb902db2b54f56297ea36ecc68d9c8 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 27 May 2026 15:20:51 +0700 Subject: [PATCH 14/21] Enhance path comparison in isPathWithinSandboxDirectory for Windows compatibility - Updated the isPathWithinSandboxDirectory function to convert both the resolved file path and directory to lowercase before comparison, ensuring accurate path validation on Windows systems, which are case-insensitive. - This change prevents false negatives when checking if a file path is within a specified sandbox directory. These modifications improve the reliability of path handling in the sandbox environment. --- lib/sandbox/isPathWithinSandboxDirectory.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/sandbox/isPathWithinSandboxDirectory.ts b/lib/sandbox/isPathWithinSandboxDirectory.ts index 1eca2241b..c15f3ecad 100644 --- a/lib/sandbox/isPathWithinSandboxDirectory.ts +++ b/lib/sandbox/isPathWithinSandboxDirectory.ts @@ -11,7 +11,8 @@ export function isPathWithinSandboxDirectory(filePath: string, directory: string return resolvedPath === resolvedDir || resolvedPath.startsWith(dirPrefix); } - const resolvedPath = path.resolve(filePath); - const resolvedDir = path.resolve(directory); + // Windows paths are case-insensitive; lowercase both before comparing. + const resolvedPath = path.resolve(filePath).toLowerCase(); + const resolvedDir = path.resolve(directory).toLowerCase(); return resolvedPath.startsWith(resolvedDir + path.sep) || resolvedPath === resolvedDir; } From 8ca5866b43eee1b68489372de22f8329ba254cd3 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 28 May 2026 00:58:07 +0700 Subject: [PATCH 15/21] refactor(agent/tools): revert sandbox path helpers to native path module Per KISS: path resolution in agent tools is unrelated to PATCH /api/sessions/{sessionId}. Restored path.isAbsolute/path.resolve/path.join in all 8 tools, removing resolveSandboxPath, joinSandboxPath, and dirnameSandboxPath imports that were scope creep. --- lib/agent/tools/bashTool.ts | 8 ++++++-- lib/agent/tools/editFileTool.ts | 6 ++++-- lib/agent/tools/globTool.ts | 7 +++---- lib/agent/tools/grepTool.ts | 6 ++++-- lib/agent/tools/readFileTool.ts | 6 ++++-- lib/agent/tools/skillTool.ts | 4 ++-- lib/agent/tools/toDisplayPath.ts | 18 ++++++++++++------ lib/agent/tools/writeFileTool.ts | 9 +++++---- 8 files changed, 40 insertions(+), 24 deletions(-) diff --git a/lib/agent/tools/bashTool.ts b/lib/agent/tools/bashTool.ts index 455f4d1cb..479a608db 100644 --- a/lib/agent/tools/bashTool.ts +++ b/lib/agent/tools/bashTool.ts @@ -1,8 +1,8 @@ import { tool } from "ai"; import { z } from "zod"; +import * as path from "path"; import { buildRecoupExecEnv } from "@/lib/agent/tools/buildRecoupExecEnv"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; -import { resolveSandboxPath } from "@/lib/sandbox/resolveSandboxPath"; const TIMEOUT_MS = 120_000; @@ -64,7 +64,11 @@ IMPORTANT: execute: async ({ command, cwd, detached }, { experimental_context, abortSignal }) => { const sandbox = await getSandbox(experimental_context, "bash"); const workingDirectory = sandbox.workingDirectory; - const workingDir = cwd ? resolveSandboxPath(workingDirectory, cwd) : workingDirectory; + const workingDir = cwd + ? path.isAbsolute(cwd) + ? cwd + : path.resolve(workingDirectory, cwd) + : workingDirectory; if (detached) { if (!sandbox.execDetached) { diff --git a/lib/agent/tools/editFileTool.ts b/lib/agent/tools/editFileTool.ts index 99cce0b46..d8274c0bc 100644 --- a/lib/agent/tools/editFileTool.ts +++ b/lib/agent/tools/editFileTool.ts @@ -1,8 +1,8 @@ import { tool } from "ai"; import { z } from "zod"; +import * as path from "path"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; -import { resolveSandboxPath } from "@/lib/sandbox/resolveSandboxPath"; const editInputSchema = z.object({ filePath: z.string().describe("Workspace-relative path to the file to edit (e.g., src/auth.ts)"), @@ -57,7 +57,9 @@ IMPORTANT: return { success: false, error: "oldString and newString must be different" }; } - const absolutePath = resolveSandboxPath(workingDirectory, filePath); + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(workingDirectory, filePath); const content = await sandbox.readFile(absolutePath, "utf-8"); if (!content.includes(oldString)) { diff --git a/lib/agent/tools/globTool.ts b/lib/agent/tools/globTool.ts index c89191f48..d1de234d2 100644 --- a/lib/agent/tools/globTool.ts +++ b/lib/agent/tools/globTool.ts @@ -1,8 +1,7 @@ import { tool } from "ai"; import { z } from "zod"; +import * as path from "path"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; -import { joinSandboxPath } from "@/lib/sandbox/joinSandboxPath"; -import { resolveSandboxPath } from "@/lib/sandbox/resolveSandboxPath"; import { shellEscape } from "@/lib/agent/tools/shellEscape"; import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; @@ -63,7 +62,7 @@ IMPORTANT: try { let searchDir: string; if (basePath) { - searchDir = resolveSandboxPath(workingDirectory, basePath); + searchDir = path.isAbsolute(basePath) ? basePath : path.resolve(workingDirectory, basePath); } else { searchDir = workingDirectory; } @@ -79,7 +78,7 @@ IMPORTANT: literalPrefix.push(part); } if (literalPrefix.length > 0) { - searchDir = joinSandboxPath(searchDir, ...literalPrefix); + searchDir = path.join(searchDir, ...literalPrefix); } const remainingDirSegments = patternParts.slice( diff --git a/lib/agent/tools/grepTool.ts b/lib/agent/tools/grepTool.ts index 45d0bc336..f172f61af 100644 --- a/lib/agent/tools/grepTool.ts +++ b/lib/agent/tools/grepTool.ts @@ -1,7 +1,7 @@ import { tool } from "ai"; import { z } from "zod"; +import * as path from "path"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; -import { resolveSandboxPath } from "@/lib/sandbox/resolveSandboxPath"; import { shellEscape } from "@/lib/agent/tools/shellEscape"; import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; @@ -62,7 +62,9 @@ IMPORTANT: const workingDirectory = sandbox.workingDirectory; try { - const absolutePath = resolveSandboxPath(workingDirectory, searchPath); + const absolutePath = path.isAbsolute(searchPath) + ? searchPath + : path.resolve(workingDirectory, searchPath); const args: string[] = ["grep", "-rn"]; if (!caseSensitive) args.push("-i"); diff --git a/lib/agent/tools/readFileTool.ts b/lib/agent/tools/readFileTool.ts index aacfcbedd..f5a486a64 100644 --- a/lib/agent/tools/readFileTool.ts +++ b/lib/agent/tools/readFileTool.ts @@ -1,8 +1,8 @@ import { tool } from "ai"; import { z } from "zod"; +import * as path from "path"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; -import { resolveSandboxPath } from "@/lib/sandbox/resolveSandboxPath"; const readInputSchema = z.object({ filePath: z.string().describe("Workspace-relative path to the file to read (e.g., src/index.ts)"), @@ -35,7 +35,9 @@ IMPORTANT: const workingDirectory = sandbox.workingDirectory; try { - const absolutePath = resolveSandboxPath(workingDirectory, filePath); + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(workingDirectory, filePath); const stats = await sandbox.stat(absolutePath); if (stats.isDirectory()) { diff --git a/lib/agent/tools/skillTool.ts b/lib/agent/tools/skillTool.ts index 2b8dca92f..8c74f35d1 100644 --- a/lib/agent/tools/skillTool.ts +++ b/lib/agent/tools/skillTool.ts @@ -1,7 +1,7 @@ +import * as path from "path"; import { tool } from "ai"; import { z } from "zod"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; -import { joinSandboxPath } from "@/lib/sandbox/joinSandboxPath"; import { extractSkillBody } from "@/lib/skills/extractSkillBody"; import { getSkills } from "@/lib/skills/getSkills"; import { injectSkillDirectory } from "@/lib/skills/injectSkillDirectory"; @@ -64,7 +64,7 @@ Important: }; } - const skillFilePath = joinSandboxPath(found.path, found.filename); + const skillFilePath = path.join(found.path, found.filename); let fileContent: string; try { fileContent = await sandbox.readFile(skillFilePath, "utf-8"); diff --git a/lib/agent/tools/toDisplayPath.ts b/lib/agent/tools/toDisplayPath.ts index 1f6bda679..827c391af 100644 --- a/lib/agent/tools/toDisplayPath.ts +++ b/lib/agent/tools/toDisplayPath.ts @@ -1,6 +1,10 @@ -import { isPathWithinSandboxDirectory } from "@/lib/sandbox/isPathWithinSandboxDirectory"; -import { relativeSandboxPath } from "@/lib/sandbox/relativeSandboxPath"; -import { resolveSandboxPath } from "@/lib/sandbox/resolveSandboxPath"; +import * as path from "path"; + +function isPathWithinDirectory(filePath: string, directory: string): boolean { + const resolvedPath = path.resolve(filePath); + const resolvedDir = path.resolve(directory); + return resolvedPath.startsWith(resolvedDir + path.sep) || resolvedPath === resolvedDir; +} /** * Convert an absolute (or relative-to-workingDirectory) path into a compact @@ -15,13 +19,15 @@ import { resolveSandboxPath } from "@/lib/sandbox/resolveSandboxPath"; * @param workingDirectory - The sandbox's working directory (always absolute). */ export function toDisplayPath(filePath: string, workingDirectory: string): string { - const absolutePath = resolveSandboxPath(workingDirectory, filePath); + const absolutePath = path.isAbsolute(filePath) + ? path.resolve(filePath) + : path.resolve(workingDirectory, filePath); - if (!isPathWithinSandboxDirectory(absolutePath, workingDirectory)) { + if (!isPathWithinDirectory(absolutePath, workingDirectory)) { return absolutePath.replace(/\\/g, "/"); } - const relativePath = relativeSandboxPath(workingDirectory, absolutePath); + const relativePath = path.relative(workingDirectory, absolutePath); if (relativePath === "") return "."; return relativePath.replace(/\\/g, "/"); diff --git a/lib/agent/tools/writeFileTool.ts b/lib/agent/tools/writeFileTool.ts index 665588e02..c8e59e3c3 100644 --- a/lib/agent/tools/writeFileTool.ts +++ b/lib/agent/tools/writeFileTool.ts @@ -1,9 +1,8 @@ import { tool } from "ai"; import { z } from "zod"; +import * as path from "path"; import { getSandbox } from "@/lib/agent/tools/getSandbox"; import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; -import { dirnameSandboxPath } from "@/lib/sandbox/dirnameSandboxPath"; -import { resolveSandboxPath } from "@/lib/sandbox/resolveSandboxPath"; const writeInputSchema = z.object({ filePath: z @@ -45,8 +44,10 @@ IMPORTANT: const workingDirectory = sandbox.workingDirectory; try { - const absolutePath = resolveSandboxPath(workingDirectory, filePath); - const dir = dirnameSandboxPath(absolutePath, workingDirectory); + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(workingDirectory, filePath); + const dir = path.dirname(absolutePath); await sandbox.mkdir(dir, { recursive: true }); await sandbox.writeFile(absolutePath, content, "utf-8"); const stats = await sandbox.stat(absolutePath); From eec2fff54d30694a9db45a278443a52a21475b08 Mon Sep 17 00:00:00 2001 From: ahmednahima0-beep Date: Thu, 28 May 2026 01:17:17 +0700 Subject: [PATCH 16/21] chore(pr-578): remove all scope creep PR now only modifies the 3 files directly tied to PATCH /api/sessions/{sessionId}: - lib/sessions/patchSessionByIdHandler.ts - lib/sessions/stopSandboxOnArchive.ts - app/api/sessions/[sessionId]/__tests__/route.test.ts Deleted: dirnameSandboxPath, isPathWithinSandboxDirectory, isPosixSandboxPath, joinSandboxPath, relativeSandboxPath, resolveSandboxPath, toPosixSegment. Reverted: hasRuntimeSandboxState.ts, discoverSkills.ts, findSkillFile.ts, and all sandbox test files to origin/test. --- .../__tests__/fixtures/runtimeSandboxState.ts | 10 ------ .../getSandboxReconnectHandler.test.ts | 3 +- .../__tests__/getSandboxStatusHandler.test.ts | 13 ++++---- .../__tests__/hasRuntimeSandboxState.test.ts | 20 ++--------- lib/sandbox/__tests__/isSandboxActive.test.ts | 9 +++-- lib/sandbox/dirnameSandboxPath.ts | 10 ------ lib/sandbox/hasRuntimeSandboxState.ts | 33 +++++++++---------- lib/sandbox/isPathWithinSandboxDirectory.ts | 18 ---------- lib/sandbox/isPosixSandboxPath.ts | 4 --- lib/sandbox/joinSandboxPath.ts | 11 ------- lib/sandbox/relativeSandboxPath.ts | 10 ------ lib/sandbox/resolveSandboxPath.ts | 20 ----------- lib/sandbox/toPosixSegment.ts | 4 --- lib/skills/discoverSkills.ts | 7 ++-- lib/skills/findSkillFile.ts | 6 ++-- 15 files changed, 35 insertions(+), 143 deletions(-) delete mode 100644 lib/sandbox/__tests__/fixtures/runtimeSandboxState.ts delete mode 100644 lib/sandbox/dirnameSandboxPath.ts delete mode 100644 lib/sandbox/isPathWithinSandboxDirectory.ts delete mode 100644 lib/sandbox/isPosixSandboxPath.ts delete mode 100644 lib/sandbox/joinSandboxPath.ts delete mode 100644 lib/sandbox/relativeSandboxPath.ts delete mode 100644 lib/sandbox/resolveSandboxPath.ts delete mode 100644 lib/sandbox/toPosixSegment.ts diff --git a/lib/sandbox/__tests__/fixtures/runtimeSandboxState.ts b/lib/sandbox/__tests__/fixtures/runtimeSandboxState.ts deleted file mode 100644 index c621a7b61..000000000 --- a/lib/sandbox/__tests__/fixtures/runtimeSandboxState.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Epoch ms used in tests for a live sandbox row (`sandbox_state.expiresAt`). */ -export const RUNTIME_EXPIRES_AT = 4_102_444_800_000; - -/** Minimal live runtime `sandbox_state` blob matching open-agents semantics. */ -export function runtimeSandboxState( - sandboxName = "session-sess-1", - expiresAt = RUNTIME_EXPIRES_AT, -): { type: string; sandboxName: string; expiresAt: number } { - return { type: "vercel", sandboxName, expiresAt }; -} diff --git a/lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts b/lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts index 28be2c57b..f1afbf14f 100644 --- a/lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts +++ b/lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts @@ -6,7 +6,6 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; import { connectSandbox } from "@/lib/sandbox/factory"; import { updateSession } from "@/lib/supabase/sessions/updateSession"; -import { runtimeSandboxState } from "@/lib/sandbox/__tests__/fixtures/runtimeSandboxState"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), @@ -25,7 +24,7 @@ vi.mock("@/lib/supabase/sessions/updateSession", () => ({ })); const ACCOUNT_ID = "acc-1"; -const RUNTIME_STATE = runtimeSandboxState(); +const RUNTIME_STATE = { type: "vercel", sandboxName: "session-sess-1" }; const baseRow = { id: "sess-1", diff --git a/lib/sandbox/__tests__/getSandboxStatusHandler.test.ts b/lib/sandbox/__tests__/getSandboxStatusHandler.test.ts index d6d8d3702..4a6e2e581 100644 --- a/lib/sandbox/__tests__/getSandboxStatusHandler.test.ts +++ b/lib/sandbox/__tests__/getSandboxStatusHandler.test.ts @@ -5,7 +5,6 @@ import { getSandboxStatusHandler } from "@/lib/sandbox/getSandboxStatusHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; import { updateSession } from "@/lib/supabase/sessions/updateSession"; -import { runtimeSandboxState } from "@/lib/sandbox/__tests__/fixtures/runtimeSandboxState"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), @@ -92,7 +91,7 @@ describe("getSandboxStatusHandler", () => { vi.mocked(selectSessions).mockResolvedValue([ { ...baseRow, - sandbox_state: runtimeSandboxState(), + sandbox_state: { type: "vercel", sandboxName: "session-sess-1" }, lifecycle_state: "active", lifecycle_version: 3, sandbox_expires_at: FAR_FUTURE, @@ -167,11 +166,11 @@ describe("getSandboxStatusHandler", () => { expect(body.status).toBe("no_sandbox"); }); - it("returns status='active' once runtime metadata is on the state, even when sandbox_expires_at is null", async () => { + it("returns status='active' once sandboxName is set on the state, even without explicit expiry", async () => { vi.mocked(selectSessions).mockResolvedValue([ { ...baseRow, - sandbox_state: runtimeSandboxState(), + sandbox_state: { type: "vercel", sandboxName: "session-sess-1" }, sandbox_expires_at: null, } as any, ]); @@ -203,7 +202,7 @@ describe("getSandboxStatusHandler", () => { ]); vi.mocked(updateSession).mockResolvedValueOnce({ ...baseRow, - sandbox_state: runtimeSandboxState(), + sandbox_state: { type: "vercel", sandboxName: "session-sess-1" }, lifecycle_state: "active", lifecycle_error: null, sandbox_expires_at: FAR_FUTURE, @@ -248,7 +247,7 @@ describe("getSandboxStatusHandler", () => { vi.mocked(selectSessions).mockResolvedValue([ { ...baseRow, - sandbox_state: runtimeSandboxState(), + sandbox_state: { type: "vercel", sandboxName: "session-sess-1" }, lifecycle_state: "hibernated", snapshot_url: null, } as any, @@ -264,7 +263,7 @@ describe("getSandboxStatusHandler", () => { vi.mocked(selectSessions).mockResolvedValue([ { ...baseRow, - sandbox_state: runtimeSandboxState(), + sandbox_state: { type: "vercel", sandboxName: "session-sess-1" }, lifecycle_state: "active", sandbox_expires_at: FAR_FUTURE, snapshot_url: null, diff --git a/lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts b/lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts index b52d12a78..6b4f18966 100644 --- a/lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts +++ b/lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect } from "vitest"; import { hasRuntimeSandboxState } from "@/lib/sandbox/hasRuntimeSandboxState"; -const EXPIRES_AT = 4_102_444_800_000; - describe("hasRuntimeSandboxState", () => { it("returns false for null", () => { expect(hasRuntimeSandboxState(null)).toBe(false); @@ -22,23 +20,11 @@ describe("hasRuntimeSandboxState", () => { expect(hasRuntimeSandboxState({ type: "vercel" })).toBe(false); }); - it("returns false when sandboxName is set but expiresAt is absent (expired/cleared state)", () => { - expect(hasRuntimeSandboxState({ type: "vercel", sandboxName: "session-x" })).toBe(false); + it("returns true when sandboxName is set", () => { + expect(hasRuntimeSandboxState({ type: "vercel", sandboxName: "session-x" })).toBe(true); }); it("returns false when sandboxName is the empty string", () => { - expect(hasRuntimeSandboxState({ type: "vercel", sandboxName: "", expiresAt: EXPIRES_AT })).toBe( - false, - ); - }); - - it("returns true when sandboxName and expiresAt are both present", () => { - expect( - hasRuntimeSandboxState({ type: "vercel", sandboxName: "session-x", expiresAt: EXPIRES_AT }), - ).toBe(true); - }); - - it("returns false when expiresAt is present but sandboxName is absent", () => { - expect(hasRuntimeSandboxState({ type: "vercel", expiresAt: EXPIRES_AT })).toBe(false); + expect(hasRuntimeSandboxState({ type: "vercel", sandboxName: "" })).toBe(false); }); }); diff --git a/lib/sandbox/__tests__/isSandboxActive.test.ts b/lib/sandbox/__tests__/isSandboxActive.test.ts index d71e719c5..c8f08a1f3 100644 --- a/lib/sandbox/__tests__/isSandboxActive.test.ts +++ b/lib/sandbox/__tests__/isSandboxActive.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from "vitest"; import { isSandboxActive } from "@/lib/sandbox/isSandboxActive"; -import { runtimeSandboxState } from "@/lib/sandbox/__tests__/fixtures/runtimeSandboxState"; const FAR_FUTURE = "2099-01-01T00:00:00.000Z"; const FAR_PAST = "2000-01-01T00:00:00.000Z"; @@ -23,7 +22,7 @@ describe("isSandboxActive", () => { expect( isSandboxActive({ ...baseRow, - sandbox_state: runtimeSandboxState("session-x"), + sandbox_state: { type: "vercel", sandboxName: "session-x" }, sandbox_expires_at: FAR_FUTURE, } as any), ).toBe(true); @@ -33,17 +32,17 @@ describe("isSandboxActive", () => { expect( isSandboxActive({ ...baseRow, - sandbox_state: runtimeSandboxState("session-x"), + sandbox_state: { type: "vercel", sandboxName: "session-x" }, sandbox_expires_at: FAR_PAST, } as any), ).toBe(false); }); - it("returns true when runtime state is live but sandbox_expires_at column is null", () => { + it("returns true when sandboxName is set but expiry is null (no expiry to compare against)", () => { expect( isSandboxActive({ ...baseRow, - sandbox_state: runtimeSandboxState("session-x"), + sandbox_state: { type: "vercel", sandboxName: "session-x" }, sandbox_expires_at: null, } as any), ).toBe(true); diff --git a/lib/sandbox/dirnameSandboxPath.ts b/lib/sandbox/dirnameSandboxPath.ts deleted file mode 100644 index 8d6438ec4..000000000 --- a/lib/sandbox/dirnameSandboxPath.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as path from "path"; -import { isPosixSandboxPath } from "@/lib/sandbox/isPosixSandboxPath"; -import { toPosixSegment } from "@/lib/sandbox/toPosixSegment"; - -export function dirnameSandboxPath(filePath: string, workingDirectory: string): string { - if (isPosixSandboxPath(workingDirectory) || isPosixSandboxPath(filePath)) { - return path.posix.dirname(toPosixSegment(filePath)); - } - return path.dirname(filePath); -} diff --git a/lib/sandbox/hasRuntimeSandboxState.ts b/lib/sandbox/hasRuntimeSandboxState.ts index 2546e51d4..5dc7e4171 100644 --- a/lib/sandbox/hasRuntimeSandboxState.ts +++ b/lib/sandbox/hasRuntimeSandboxState.ts @@ -1,27 +1,24 @@ -import { getResumableSandboxName } from "@/lib/sandbox/getResumableSandboxName"; -import { getStateExpiresAt } from "@/lib/sandbox/getStateExpiresAt"; - /** - * Returns true when `sandbox_state` carries live runtime metadata — - * i.e. a sandbox has been provisioned, is not yet expired, and has a - * resumable name. This mirrors open-agents semantics exactly: + * Returns true when `sandbox_state` carries actual runtime metadata + * (i.e. a sandbox has been provisioned and bound to the session) rather + * than the type-only stub written at session creation. * - * expiresAt defined → sandbox was started (not a type-only stub or expired entry) - * resumable name → sandbox can be operated on + * `POST /api/sessions` (api PR #515) inserts `sandbox_state` as + * `{ type: "vercel" }` — a type discriminator with no runtime data. + * Callers must NOT treat this stub as evidence of a live sandbox; doing + * so causes `GET /api/sandbox/status` to report `"active"` immediately + * after session creation, which defeats the chat loading-state UX. * - * `POST /api/sessions` inserts `{ type: "vercel" }` (no expiresAt), so - * the creation stub correctly returns false. - * - * Expired state (sandboxName present but expiresAt absent/stripped) also - * returns false, preventing the 409 unarchive guard from firing on inert - * rows and stopping `stopSandboxOnArchive` from attempting to stop an - * already-gone sandbox. + * Runtime presence is currently keyed off a non-empty `sandboxName` — + * `POST /api/sandbox` writes this via `getSessionSandboxName(sessionId)` + * and the abstraction's `connectSandbox(...).getState()` preserves it. * * @param state - The persisted `sandbox_state` JSON column value. - * @returns true when the state describes a live, operable sandbox. + * @returns true when the state has real runtime metadata; false for + * null/undefined, scalars, the empty type stub, or empty sandboxName. */ export function hasRuntimeSandboxState(state: unknown): boolean { if (!state || typeof state !== "object") return false; - if (getStateExpiresAt(state) === undefined) return false; - return getResumableSandboxName(state) !== null; + const candidate = state as { sandboxName?: unknown }; + return typeof candidate.sandboxName === "string" && candidate.sandboxName.length > 0; } diff --git a/lib/sandbox/isPathWithinSandboxDirectory.ts b/lib/sandbox/isPathWithinSandboxDirectory.ts deleted file mode 100644 index c15f3ecad..000000000 --- a/lib/sandbox/isPathWithinSandboxDirectory.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as path from "path"; -import { isPosixSandboxPath } from "@/lib/sandbox/isPosixSandboxPath"; -import { toPosixSegment } from "@/lib/sandbox/toPosixSegment"; - -export function isPathWithinSandboxDirectory(filePath: string, directory: string): boolean { - if (isPosixSandboxPath(directory)) { - const resolvedPath = path.posix.normalize(toPosixSegment(filePath)); - const resolvedDir = path.posix.normalize(directory); - // Avoid the "//child" false-negative when resolvedDir is the root "/". - const dirPrefix = resolvedDir.endsWith("/") ? resolvedDir : `${resolvedDir}/`; - return resolvedPath === resolvedDir || resolvedPath.startsWith(dirPrefix); - } - - // Windows paths are case-insensitive; lowercase both before comparing. - const resolvedPath = path.resolve(filePath).toLowerCase(); - const resolvedDir = path.resolve(directory).toLowerCase(); - return resolvedPath.startsWith(resolvedDir + path.sep) || resolvedPath === resolvedDir; -} diff --git a/lib/sandbox/isPosixSandboxPath.ts b/lib/sandbox/isPosixSandboxPath.ts deleted file mode 100644 index a03ffed47..000000000 --- a/lib/sandbox/isPosixSandboxPath.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Remote sandboxes use POSIX paths even when the API process runs on Windows. */ -export function isPosixSandboxPath(directory: string): boolean { - return directory.startsWith("/"); -} diff --git a/lib/sandbox/joinSandboxPath.ts b/lib/sandbox/joinSandboxPath.ts deleted file mode 100644 index 8eb8e6d14..000000000 --- a/lib/sandbox/joinSandboxPath.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as path from "path"; -import { isPosixSandboxPath } from "@/lib/sandbox/isPosixSandboxPath"; -import { toPosixSegment } from "@/lib/sandbox/toPosixSegment"; - -/** Join path segments, preserving POSIX semantics for sandbox paths. */ -export function joinSandboxPath(...segments: string[]): string { - if (segments.some(isPosixSandboxPath)) { - return path.posix.join(...segments.map(toPosixSegment)); - } - return path.join(...segments); -} diff --git a/lib/sandbox/relativeSandboxPath.ts b/lib/sandbox/relativeSandboxPath.ts deleted file mode 100644 index da032d095..000000000 --- a/lib/sandbox/relativeSandboxPath.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as path from "path"; -import { isPosixSandboxPath } from "@/lib/sandbox/isPosixSandboxPath"; -import { toPosixSegment } from "@/lib/sandbox/toPosixSegment"; - -export function relativeSandboxPath(from: string, to: string): string { - if (isPosixSandboxPath(from)) { - return path.posix.relative(from, toPosixSegment(to)); - } - return path.relative(from, to); -} diff --git a/lib/sandbox/resolveSandboxPath.ts b/lib/sandbox/resolveSandboxPath.ts deleted file mode 100644 index 6906d096b..000000000 --- a/lib/sandbox/resolveSandboxPath.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as path from "path"; -import { isPosixSandboxPath } from "@/lib/sandbox/isPosixSandboxPath"; -import { toPosixSegment } from "@/lib/sandbox/toPosixSegment"; - -/** - * Resolve a workspace-relative or absolute path inside a sandbox working directory. - */ -export function resolveSandboxPath(workingDirectory: string, filePath: string): string { - if (isPosixSandboxPath(workingDirectory)) { - const normalized = toPosixSegment(filePath); - if (normalized.startsWith("/")) { - return path.posix.normalize(normalized); - } - return path.posix.resolve(workingDirectory, normalized); - } - - return path.isAbsolute(filePath) - ? path.resolve(filePath) - : path.resolve(workingDirectory, filePath); -} diff --git a/lib/sandbox/toPosixSegment.ts b/lib/sandbox/toPosixSegment.ts deleted file mode 100644 index 004bc624d..000000000 --- a/lib/sandbox/toPosixSegment.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Normalize native path separators to POSIX forward slashes. */ -export function toPosixSegment(segment: string): string { - return segment.replace(/\\/g, "/"); -} diff --git a/lib/skills/discoverSkills.ts b/lib/skills/discoverSkills.ts index 62489a0d7..9ae0ced67 100644 --- a/lib/skills/discoverSkills.ts +++ b/lib/skills/discoverSkills.ts @@ -1,6 +1,5 @@ -import path from "path"; +import * as path from "path"; import type { Sandbox } from "@/lib/sandbox/interface"; -import { joinSandboxPath } from "@/lib/sandbox/joinSandboxPath"; import { findSkillFile } from "@/lib/skills/findSkillFile"; import { parseSkillFrontmatter } from "@/lib/skills/parseSkillFrontmatter"; import { frontmatterToOptions, type SkillMetadata } from "@/lib/skills/skillTypes"; @@ -50,7 +49,7 @@ export async function discoverSkills( for (const entry of entries) { if (!entry.isDirectory()) continue; - const skillDir = joinSandboxPath(dir, entry.name); + const skillDir = path.join(dir, entry.name); const skillFile = await findSkillFile(sandbox, skillDir); if (!skillFile) continue; @@ -80,7 +79,7 @@ export async function discoverSkills( name: frontmatter.name, description: frontmatter.description, path: skillDir, - filename: path.basename(skillFile.replace(/\\/g, "/")), + filename: path.basename(skillFile), options: frontmatterToOptions(frontmatter), }); } diff --git a/lib/skills/findSkillFile.ts b/lib/skills/findSkillFile.ts index 09c45a58e..a81b9e415 100644 --- a/lib/skills/findSkillFile.ts +++ b/lib/skills/findSkillFile.ts @@ -1,5 +1,5 @@ +import * as path from "path"; import type { Sandbox } from "@/lib/sandbox/interface"; -import { joinSandboxPath } from "@/lib/sandbox/joinSandboxPath"; /** * Locate the SKILL.md file inside a candidate skill directory. Prefers @@ -15,8 +15,8 @@ import { joinSandboxPath } from "@/lib/sandbox/joinSandboxPath"; * @param skillDir - Absolute path to the candidate skill directory. */ export async function findSkillFile(sandbox: Sandbox, skillDir: string): Promise { - const uppercase = joinSandboxPath(skillDir, "SKILL.md"); - const lowercase = joinSandboxPath(skillDir, "skill.md"); + const uppercase = path.join(skillDir, "SKILL.md"); + const lowercase = path.join(skillDir, "skill.md"); try { await sandbox.access(uppercase); From 40e7a2eb26c0e1bae0620a94f3c331bb53291fe4 Mon Sep 17 00:00:00 2001 From: ahmednahima0-beep Date: Thu, 28 May 2026 01:51:13 +0700 Subject: [PATCH 17/21] refactor(sessions): extract isSandboxPausing into lib/sandbox OCP fix: move isSandboxPausing predicate out of patchSessionByIdHandler into its own lib so the handler only calls lib functions, not inline logic. --- lib/sandbox/isSandboxPausing.ts | 21 +++++++++++++++++++++ lib/sessions/patchSessionByIdHandler.ts | 9 ++------- 2 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 lib/sandbox/isSandboxPausing.ts diff --git a/lib/sandbox/isSandboxPausing.ts b/lib/sandbox/isSandboxPausing.ts new file mode 100644 index 000000000..46eb195ff --- /dev/null +++ b/lib/sandbox/isSandboxPausing.ts @@ -0,0 +1,21 @@ +import { hasRuntimeSandboxState } from "@/lib/sandbox/hasRuntimeSandboxState"; + +/** + * Returns true when a sandbox is actively being paused — i.e. it has + * live runtime state but has not yet reached a terminal lifecycle state + * (`hibernated` or `archived`). + * + * Used by `PATCH /api/sessions/{sessionId}` to guard unarchive requests: + * if the sandbox is still pausing the request returns 409 so the caller + * can retry once the sandbox has settled. + */ +export function isSandboxPausing(row: { + sandbox_state: unknown; + lifecycle_state: string | null; +}): boolean { + return ( + hasRuntimeSandboxState(row.sandbox_state) && + row.lifecycle_state !== "hibernated" && + row.lifecycle_state !== "archived" + ); +} diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts index 4049be424..f43c28031 100644 --- a/lib/sessions/patchSessionByIdHandler.ts +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -1,7 +1,7 @@ import { after } from "next/server"; import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { hasRuntimeSandboxState } from "@/lib/sandbox/hasRuntimeSandboxState"; +import { isSandboxPausing } from "@/lib/sandbox/isSandboxPausing"; import { validatePatchSessionBody } from "@/lib/sessions/validatePatchSessionBody"; import { stopSandboxOnArchive } from "@/lib/sessions/stopSandboxOnArchive"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; @@ -62,12 +62,7 @@ export async function patchSessionByIdHandler( const shouldArchive = body.status === "archived" && row.status !== "archived"; const shouldUnarchive = body.status === "running" && row.status === "archived"; - const isSandboxPausing = - hasRuntimeSandboxState(row.sandbox_state) && - row.lifecycle_state !== "hibernated" && - row.lifecycle_state !== "archived"; - - if (shouldUnarchive && !row.snapshot_url && isSandboxPausing) { + if (shouldUnarchive && !row.snapshot_url && isSandboxPausing(row)) { return NextResponse.json( { status: "error", error: "Sandbox is still being paused, try again in a few seconds." }, { status: 409, headers: getCorsHeaders() }, From 1b021b00d4f20c56c68e588e4545e0916e25b392 Mon Sep 17 00:00:00 2001 From: ahmednahima0-beep Date: Thu, 28 May 2026 02:00:27 +0700 Subject: [PATCH 18/21] fix(sessions): preserve snapshot as fallback until stop succeeds; clear stale lifecycle_error on archive - Move snapshot_url/snapshot_created_at null-out from synchronous PATCH update into stopSandboxOnArchive success branch, matching open-agents behavior. Snapshot now remains as a fallback if stop() fails. - Add lifecycle_error: null to synchronous archive update so stale errors from a prior failed run are cleared on re-archive (open-agents parity). --- app/api/sessions/[sessionId]/__tests__/route.test.ts | 3 +-- lib/sessions/patchSessionByIdHandler.ts | 3 +-- lib/sessions/stopSandboxOnArchive.ts | 2 ++ 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts index 45625d091..17afe983e 100644 --- a/app/api/sessions/[sessionId]/__tests__/route.test.ts +++ b/app/api/sessions/[sessionId]/__tests__/route.test.ts @@ -362,9 +362,8 @@ describe("PATCH /api/sessions/[sessionId]", () => { title: "Renamed session", status: "archived", lifecycle_state: "archived", + lifecycle_error: null, lifecycle_run_id: null, - snapshot_url: null, - snapshot_created_at: null, sandbox_expires_at: null, hibernate_after: null, }); diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts index f43c28031..1a62e9493 100644 --- a/lib/sessions/patchSessionByIdHandler.ts +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -76,9 +76,8 @@ export async function patchSessionByIdHandler( ...(body.linesRemoved !== undefined && { lines_removed: body.linesRemoved }), ...(shouldArchive && { lifecycle_state: "archived", + lifecycle_error: null, lifecycle_run_id: null, - snapshot_url: null, - snapshot_created_at: null, sandbox_expires_at: null, hibernate_after: null, }), diff --git a/lib/sessions/stopSandboxOnArchive.ts b/lib/sessions/stopSandboxOnArchive.ts index 874ba8bac..6559aa892 100644 --- a/lib/sessions/stopSandboxOnArchive.ts +++ b/lib/sessions/stopSandboxOnArchive.ts @@ -58,6 +58,8 @@ export async function stopSandboxOnArchive(session: Tables<"sessions">): Promise } await updateSession(session.id, { + snapshot_url: null, + snapshot_created_at: null, sandbox_state: clearSandboxState(session.sandbox_state) as unknown as Json, }); } catch (error) { From 83af7dde3c40468ecaf2d2b55463dcf0f6d34780 Mon Sep 17 00:00:00 2001 From: ahmednahima0-beep Date: Thu, 28 May 2026 02:10:43 +0700 Subject: [PATCH 19/21] refactor(sessions): extract isUnarchiveConflict predicate into lib OCP: move the 409-guard predicate out of patchSessionByIdHandler and into lib/sessions/isUnarchiveConflict.ts. Handler now calls isUnarchiveConflict(row). --- lib/sessions/isUnarchiveConflict.ts | 16 ++++++++++++++++ lib/sessions/patchSessionByIdHandler.ts | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 lib/sessions/isUnarchiveConflict.ts diff --git a/lib/sessions/isUnarchiveConflict.ts b/lib/sessions/isUnarchiveConflict.ts new file mode 100644 index 000000000..78a8b4513 --- /dev/null +++ b/lib/sessions/isUnarchiveConflict.ts @@ -0,0 +1,16 @@ +import { isSandboxPausing } from "@/lib/sandbox/isSandboxPausing"; + +/** + * Returns true when an unarchive request should be rejected with 409: + * the sandbox has no snapshot to restore from and is still actively pausing. + * + * Without a snapshot, the sandbox cannot be unarchived until the pause + * completes; callers should retry after the sandbox settles. + */ +export function isUnarchiveConflict(row: { + sandbox_state: unknown; + lifecycle_state: string | null; + snapshot_url: string | null; +}): boolean { + return !row.snapshot_url && isSandboxPausing(row); +} diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts index 1a62e9493..ca550e05e 100644 --- a/lib/sessions/patchSessionByIdHandler.ts +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -1,7 +1,7 @@ import { after } from "next/server"; import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { isSandboxPausing } from "@/lib/sandbox/isSandboxPausing"; +import { isUnarchiveConflict } from "@/lib/sessions/isUnarchiveConflict"; import { validatePatchSessionBody } from "@/lib/sessions/validatePatchSessionBody"; import { stopSandboxOnArchive } from "@/lib/sessions/stopSandboxOnArchive"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; @@ -62,7 +62,7 @@ export async function patchSessionByIdHandler( const shouldArchive = body.status === "archived" && row.status !== "archived"; const shouldUnarchive = body.status === "running" && row.status === "archived"; - if (shouldUnarchive && !row.snapshot_url && isSandboxPausing(row)) { + if (shouldUnarchive && isUnarchiveConflict(row)) { return NextResponse.json( { status: "error", error: "Sandbox is still being paused, try again in a few seconds." }, { status: 409, headers: getCorsHeaders() }, From ecf407a4591ce993d5aef904f50dffae7629bfd1 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 28 May 2026 02:15:16 +0700 Subject: [PATCH 20/21] refactor(sessions): simplify lifecycle state updates in patchSessionByIdHandler Replaced inline lifecycle state updates with constants ARCHIVE_LIFECYCLE_PATCH and UNARCHIVE_LIFECYCLE_PATCH for better readability and maintainability. This change streamlines the handling of session lifecycle states during patch operations. --- lib/sessions/lifecycleStatePatches.ts | 21 +++++++++++++++++++++ lib/sessions/patchSessionByIdHandler.ts | 11 +++-------- 2 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 lib/sessions/lifecycleStatePatches.ts diff --git a/lib/sessions/lifecycleStatePatches.ts b/lib/sessions/lifecycleStatePatches.ts new file mode 100644 index 000000000..9388e849b --- /dev/null +++ b/lib/sessions/lifecycleStatePatches.ts @@ -0,0 +1,21 @@ +/** + * Lifecycle fields applied synchronously when a session is archived via + * `PATCH /api/sessions/{sessionId}`. Sandbox stop and snapshot clearing + * run afterward via `stopSandboxOnArchive`. + */ +export const ARCHIVE_LIFECYCLE_PATCH = { + lifecycle_state: "archived", + lifecycle_error: null, + lifecycle_run_id: null, + sandbox_expires_at: null, + hibernate_after: null, +} as const; + +/** + * Lifecycle fields applied synchronously when a session is unarchived via + * `PATCH /api/sessions/{sessionId}`. + */ +export const UNARCHIVE_LIFECYCLE_PATCH = { + lifecycle_state: null, + lifecycle_error: null, +} as const; diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts index ca550e05e..8c201b854 100644 --- a/lib/sessions/patchSessionByIdHandler.ts +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -2,6 +2,7 @@ import { after } from "next/server"; import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { isUnarchiveConflict } from "@/lib/sessions/isUnarchiveConflict"; +import { ARCHIVE_LIFECYCLE_PATCH, UNARCHIVE_LIFECYCLE_PATCH } from "@/lib/sessions/lifecycleStatePatches"; import { validatePatchSessionBody } from "@/lib/sessions/validatePatchSessionBody"; import { stopSandboxOnArchive } from "@/lib/sessions/stopSandboxOnArchive"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; @@ -74,14 +75,8 @@ export async function patchSessionByIdHandler( ...(body.status !== undefined && { status: body.status }), ...(body.linesAdded !== undefined && { lines_added: body.linesAdded }), ...(body.linesRemoved !== undefined && { lines_removed: body.linesRemoved }), - ...(shouldArchive && { - lifecycle_state: "archived", - lifecycle_error: null, - lifecycle_run_id: null, - sandbox_expires_at: null, - hibernate_after: null, - }), - ...(shouldUnarchive && { lifecycle_state: null, lifecycle_error: null }), + ...(shouldArchive && ARCHIVE_LIFECYCLE_PATCH), + ...(shouldUnarchive && UNARCHIVE_LIFECYCLE_PATCH), }; if (Object.keys(updates).length === 0) { From d6a924e093feaeb172726835cb2f592fb4c6d720 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 28 May 2026 02:18:31 +0700 Subject: [PATCH 21/21] refactor(sessions): format imports for lifecycle state patches in patchSessionByIdHandler Updated the import statements for ARCHIVE_LIFECYCLE_PATCH and UNARCHIVE_LIFECYCLE_PATCH to improve readability and maintain consistency in the code structure. This change enhances the clarity of the lifecycle state management within the session patching process. --- lib/sessions/patchSessionByIdHandler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts index 8c201b854..16010c908 100644 --- a/lib/sessions/patchSessionByIdHandler.ts +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -2,7 +2,10 @@ import { after } from "next/server"; import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { isUnarchiveConflict } from "@/lib/sessions/isUnarchiveConflict"; -import { ARCHIVE_LIFECYCLE_PATCH, UNARCHIVE_LIFECYCLE_PATCH } from "@/lib/sessions/lifecycleStatePatches"; +import { + ARCHIVE_LIFECYCLE_PATCH, + UNARCHIVE_LIFECYCLE_PATCH, +} from "@/lib/sessions/lifecycleStatePatches"; import { validatePatchSessionBody } from "@/lib/sessions/validatePatchSessionBody"; import { stopSandboxOnArchive } from "@/lib/sessions/stopSandboxOnArchive"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions";