Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions lib/sandbox/__tests__/createSandboxHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { validateCreateSandboxBody } from "@/lib/sandbox/validateCreateSandboxBo
import { selectSessions } from "@/lib/supabase/sessions/selectSessions";
import { connectSandbox } from "@/lib/sandbox/factory";
import { updateSession } from "@/lib/supabase/sessions/updateSession";
import { installSessionGlobalSkills } from "@/lib/sandbox/installSessionGlobalSkills";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
Expand All @@ -25,6 +26,9 @@ vi.mock("@/lib/supabase/sessions/updateSession", () => ({
vi.mock("@/lib/github/getServiceGithubToken", () => ({
getServiceGithubToken: vi.fn(() => "ghs_test_token"),
}));
vi.mock("@/lib/sandbox/installSessionGlobalSkills", () => ({
installSessionGlobalSkills: vi.fn(async () => undefined),
}));

const ACCOUNT_ID = "acc-1";

Expand Down Expand Up @@ -147,6 +151,33 @@ describe("createSandboxHandler", () => {
expect(arg.options?.githubToken).toBe("ghs_test_token");
});

it("installs global skills into the freshly-provisioned sandbox", async () => {
await createSandboxHandler(makeReq());

expect(installSessionGlobalSkills).toHaveBeenCalledOnce();
const call = vi.mocked(installSessionGlobalSkills).mock.calls[0][0];
expect(call.sessionRow.id).toBe("sess-1");
});

it("returns 200 even when skill installation throws (best-effort)", async () => {
vi.mocked(installSessionGlobalSkills).mockRejectedValueOnce(new Error("npx skills add failed"));

const res = await createSandboxHandler(makeReq());

expect(res.status).toBe(200);
});

it("does not attempt skill installation when no sessionId is provided", async () => {
vi.mocked(validateCreateSandboxBody).mockResolvedValueOnce({
body: { repoUrl: "https://github.com/o/r" },
auth: { accountId: ACCOUNT_ID, orgId: null, authToken: "k" },
});

await createSandboxHandler(makeReq());

expect(installSessionGlobalSkills).not.toHaveBeenCalled();
});

it("skips the session-row write when no sessionId is provided", async () => {
vi.mocked(validateCreateSandboxBody).mockResolvedValueOnce({
body: { repoUrl: "https://github.com/o/r" },
Expand Down
63 changes: 63 additions & 0 deletions lib/sandbox/__tests__/installSessionGlobalSkills.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { installSessionGlobalSkills } from "@/lib/sandbox/installSessionGlobalSkills";
import { installGlobalSkills } from "@/lib/skills/installGlobalSkills";
import { DEFAULT_GLOBAL_SKILL_REFS } from "@/lib/skills/defaultGlobalSkillRefs";
import type { Tables } from "@/types/database.types";

vi.mock("@/lib/skills/installGlobalSkills", () => ({
installGlobalSkills: vi.fn(async () => undefined),
}));

const sandbox = { workingDirectory: "/workspace", exec: vi.fn() } as never;

// Tests only read `id` and `global_skill_refs`; everything else gets cast
// past TS so the fixture stays small.
const baseRow = {
id: "sess-1",
global_skill_refs: null as unknown,
} as unknown as Tables<"sessions">;

beforeEach(() => {
vi.clearAllMocks();
});

describe("installSessionGlobalSkills", () => {
it("installs only the platform defaults when the row has no user refs", async () => {
await installSessionGlobalSkills({ sessionRow: baseRow, sandbox });

expect(installGlobalSkills).toHaveBeenCalledOnce();
const { globalSkillRefs } = vi.mocked(installGlobalSkills).mock.calls[0][0];
expect(globalSkillRefs).toEqual([...DEFAULT_GLOBAL_SKILL_REFS]);
});

it("merges the platform defaults with normalized user refs", async () => {
const row = {
...baseRow,
global_skill_refs: [{ source: "user/repo", skillName: "custom-skill" }],
} as never;

await installSessionGlobalSkills({ sessionRow: row, sandbox });

const { globalSkillRefs } = vi.mocked(installGlobalSkills).mock.calls[0][0];
expect(globalSkillRefs).toEqual([
...DEFAULT_GLOBAL_SKILL_REFS,
{ source: "user/repo", skillName: "custom-skill" },
]);
});

// Behavior matches open-agents: if any user ref fails validation, ALL user
// refs are dropped (the schema rejects the whole array), and we install only
// the platform defaults. Strict on purpose — partial-acceptance would let a
// typo in one ref silently swallow others.
it("drops every user ref when any fail validation", async () => {
const row = {
...baseRow,
global_skill_refs: [{ bad: "shape" }, { source: "good/repo", skillName: "ok" }],
} as never;

await installSessionGlobalSkills({ sessionRow: row, sandbox });

const { globalSkillRefs } = vi.mocked(installGlobalSkills).mock.calls[0][0];
expect(globalSkillRefs).toEqual([...DEFAULT_GLOBAL_SKILL_REFS]);
});
});
29 changes: 29 additions & 0 deletions lib/sandbox/__tests__/resolveSandboxHomeDirectory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect, vi } from "vitest";
import { resolveSandboxHomeDirectory } from "@/lib/sandbox/resolveSandboxHomeDirectory";

function fakeSandbox(execImpl: () => Promise<{ success: boolean; stdout: string }>) {
return {
workingDirectory: "/workspace",
exec: vi.fn(async () => {
const result = await execImpl();
return { ...result, exitCode: result.success ? 0 : 1, stderr: "", truncated: false };
}),
};
}

describe("resolveSandboxHomeDirectory", () => {
it("returns the trimmed $HOME when probe succeeds", async () => {
const sandbox = fakeSandbox(async () => ({ success: true, stdout: "/home/agent\n" }));
expect(await resolveSandboxHomeDirectory(sandbox as never)).toBe("/home/agent");
});

it("falls back to /root when the probe fails", async () => {
const sandbox = fakeSandbox(async () => ({ success: false, stdout: "" }));
expect(await resolveSandboxHomeDirectory(sandbox as never)).toBe("/root");
});

it("falls back to /root when the probe returns an empty string", async () => {
const sandbox = fakeSandbox(async () => ({ success: true, stdout: " " }));
expect(await resolveSandboxHomeDirectory(sandbox as never)).toBe("/root");
});
});
21 changes: 21 additions & 0 deletions lib/sandbox/__tests__/shellEscape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, it, expect } from "vitest";
import { shellEscape } from "@/lib/sandbox/shellEscape";

describe("shellEscape", () => {
it("wraps a plain string in single quotes", () => {
expect(shellEscape("hello")).toBe("'hello'");
});

it("escapes embedded single quotes by closing/escaping/reopening", () => {
expect(shellEscape("it's")).toBe("'it'\\''s'");
});

it("returns '' for an empty string", () => {
expect(shellEscape("")).toBe("''");
});

it("preserves spaces and other shell metacharacters by quoting them", () => {
expect(shellEscape("rm -rf /")).toBe("'rm -rf /'");
expect(shellEscape("$HOME && echo")).toBe("'$HOME && echo'");
});
});
35 changes: 25 additions & 10 deletions lib/sandbox/createSandboxHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { validateCreateSandboxBody } from "@/lib/sandbox/validateCreateSandboxBo
import { selectSessions } from "@/lib/supabase/sessions/selectSessions";
import { connectSandbox } from "@/lib/sandbox/factory";
import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName";
import { installSessionGlobalSkills } from "@/lib/sandbox/installSessionGlobalSkills";
import { updateSession } from "@/lib/supabase/sessions/updateSession";
import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken";
import type { Json } from "@/types/database.types";
import type { Json, Tables } from "@/types/database.types";

const DEFAULT_TIMEOUT_MS = ms("30m");
const DEFAULT_PORTS = [3000];
Expand Down Expand Up @@ -35,26 +36,24 @@ export async function createSandboxHandler(request: NextRequest): Promise<NextRe

const sessionId = body.sessionId;

let currentLifecycleVersion = 0;
let sessionRow: Tables<"sessions"> | null = null;
if (sessionId) {
const rows = await selectSessions({ id: sessionId });
const row = rows[0];
sessionRow = rows[0] ?? null;

if (!row) {
if (!sessionRow) {
return NextResponse.json(
{ status: "error", error: "Session not found" },
{ status: 404, headers: getCorsHeaders() },
);
}

if (row.account_id !== auth.accountId) {
if (sessionRow.account_id !== auth.accountId) {
return NextResponse.json(
{ status: "error", error: "Forbidden" },
{ status: 403, headers: getCorsHeaders() },
);
}

currentLifecycleVersion = row.lifecycle_version;
}

const sandboxName = sessionId ? getSessionSandboxName(sessionId) : undefined;
Expand Down Expand Up @@ -85,21 +84,37 @@ export async function createSandboxHandler(request: NextRequest): Promise<NextRe
);
}

if (sessionId && sandbox.getState) {
if (sessionRow && sandbox.getState) {
const nextState = sandbox.getState() as Json;
const expiresAt =
typeof sandbox.expiresAt === "number" ? new Date(sandbox.expiresAt).toISOString() : null;
await updateSession(sessionId, {
await updateSession(sessionRow.id, {
sandbox_state: nextState,
lifecycle_state: "active",
lifecycle_version: currentLifecycleVersion + 1,
lifecycle_version: sessionRow.lifecycle_version + 1,
sandbox_expires_at: expiresAt,
last_activity_at: new Date().toISOString(),
snapshot_url: null,
snapshot_created_at: null,
});
}

// Best-effort skill installation — a failure here does not fail the
// sandbox creation request. The agent will start without skills loaded
// (or with whatever subset successfully installed before the throw),
// which the user can recover from with a follow-up request once the
// underlying issue is fixed.
if (sessionRow) {
try {
await installSessionGlobalSkills({ sessionRow, sandbox });
} catch (error) {
console.error(
`[createSandboxHandler] installSessionGlobalSkills failed for session ${sessionRow.id}:`,
error,
);
}
}

return NextResponse.json(
{
createdAt: Date.now(),
Expand Down
31 changes: 31 additions & 0 deletions lib/sandbox/installSessionGlobalSkills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { DEFAULT_GLOBAL_SKILL_REFS } from "@/lib/skills/defaultGlobalSkillRefs";
import { normalizeGlobalSkillRefs } from "@/lib/skills/globalSkillRef";
import { installGlobalSkills } from "@/lib/skills/installGlobalSkills";
import type { Sandbox } from "@/lib/sandbox/interface";
import type { Tables } from "@/types/database.types";

/**
* Installs the union of platform default skills and the session's
* configured user skills into the sandbox. Platform defaults are
* placed first so they win on dedup if a user happens to list the
* same skill in their preferences. No-op when the combined list is
* empty (currently impossible since defaults are non-empty, but
* future-proofed against changing that).
*
* @param params.sessionRow - The `sessions` row whose `global_skill_refs` define user-level skills.
* @param params.sandbox - The connected sandbox handle to install into.
*/
export async function installSessionGlobalSkills(params: {
sessionRow: Tables<"sessions">;
sandbox: Sandbox;
}): Promise<void> {
const userRefs = normalizeGlobalSkillRefs(params.sessionRow.global_skill_refs);
const refs = [...DEFAULT_GLOBAL_SKILL_REFS, ...userRefs];

if (refs.length === 0) return;

await installGlobalSkills({
sandbox: params.sandbox,
globalSkillRefs: refs,
});
}
22 changes: 22 additions & 0 deletions lib/sandbox/resolveSandboxHomeDirectory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Sandbox } from "@/lib/sandbox/interface";

const DEFAULT_HOME_DIRECTORY = "/root";
const HOME_RESOLUTION_TIMEOUT_MS = 5_000;

/**
* Probes the sandbox's `$HOME` directory by running `printf %s "$HOME"`.
* Falls back to `/root` (the convention for the open-agents base
* snapshot) when the probe fails or returns an empty value.
*
* @param sandbox - The connected sandbox handle.
* @returns The trimmed `$HOME` path, or `/root` as a fallback.
*/
export async function resolveSandboxHomeDirectory(sandbox: Sandbox): Promise<string> {
const result = await sandbox.exec(
'printf %s "$HOME"',
sandbox.workingDirectory,
HOME_RESOLUTION_TIMEOUT_MS,
);
const homeDirectory = result.success ? result.stdout.trim() : "";
return homeDirectory || DEFAULT_HOME_DIRECTORY;
}
13 changes: 13 additions & 0 deletions lib/sandbox/shellEscape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Wraps a string for safe inclusion as a single argument in a shell
* command. Uses POSIX single-quote escaping: every embedded apostrophe
* is closed, escaped, then re-opened (`'` → `'\''`). Spaces, env-var
* expansions, redirections, and other shell metacharacters are
* preserved verbatim within the quotes.
*
* @param value - The raw string to escape.
* @returns A shell-safe quoted version of `value`.
*/
export function shellEscape(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`;
}
14 changes: 14 additions & 0 deletions lib/skills/__tests__/defaultGlobalSkillRefs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, it, expect } from "vitest";
import { DEFAULT_GLOBAL_SKILL_REFS } from "@/lib/skills/defaultGlobalSkillRefs";

describe("DEFAULT_GLOBAL_SKILL_REFS", () => {
it("ships recoup-api and artist-workspace as platform defaults", () => {
const sourceNames = DEFAULT_GLOBAL_SKILL_REFS.map(r => `${r.source}::${r.skillName}`);
expect(sourceNames).toContain("recoupable/skills::recoup-api");
expect(sourceNames).toContain("recoupable/skills::artist-workspace");
});

it("only references the recoupable/skills source", () => {
expect(DEFAULT_GLOBAL_SKILL_REFS.every(r => r.source === "recoupable/skills")).toBe(true);
});
});
58 changes: 58 additions & 0 deletions lib/skills/__tests__/globalSkillRef.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import {
globalSkillRefSchema,
globalSkillRefsSchema,
normalizeGlobalSkillRefs,
} from "@/lib/skills/globalSkillRef";

describe("globalSkillRefSchema", () => {
it("accepts a valid owner/repo + skillName ref", () => {
const result = globalSkillRefSchema.safeParse({
source: "recoupable/skills",
skillName: "recoup-api",
});
expect(result.success).toBe(true);
});

it("rejects sources that aren't owner/repo format", () => {
expect(globalSkillRefSchema.safeParse({ source: "recoupable", skillName: "x" }).success).toBe(
false,
);
expect(globalSkillRefSchema.safeParse({ source: "a/b/c", skillName: "x" }).success).toBe(false);
});

it("rejects skill names with whitespace", () => {
expect(globalSkillRefSchema.safeParse({ source: "a/b", skillName: "two words" }).success).toBe(
false,
);
});
});

describe("globalSkillRefsSchema dedup transform", () => {
it("removes duplicates by case-insensitive (source, skillName) key", () => {
const result = globalSkillRefsSchema.parse([
{ source: "recoupable/skills", skillName: "recoup-api" },
{ source: "Recoupable/Skills", skillName: "RECOUP-API" },
{ source: "recoupable/skills", skillName: "artist-workspace" },
]);
expect(result).toHaveLength(2);
expect(result[0].skillName).toBe("recoup-api");
expect(result[1].skillName).toBe("artist-workspace");
});
});

describe("normalizeGlobalSkillRefs", () => {
it("returns [] for invalid input instead of throwing", () => {
expect(normalizeGlobalSkillRefs(null)).toEqual([]);
expect(normalizeGlobalSkillRefs("not an array")).toEqual([]);
expect(normalizeGlobalSkillRefs([{ bad: "shape" }])).toEqual([]);
});

it("returns valid + deduped refs on the happy path", () => {
const refs = normalizeGlobalSkillRefs([
{ source: "a/b", skillName: "x" },
{ source: "a/b", skillName: "x" },
]);
expect(refs).toHaveLength(1);
});
});
Loading
Loading