diff --git a/lib/sandbox/__tests__/createSandboxHandler.test.ts b/lib/sandbox/__tests__/createSandboxHandler.test.ts index b79c2b98..d257f9b3 100644 --- a/lib/sandbox/__tests__/createSandboxHandler.test.ts +++ b/lib/sandbox/__tests__/createSandboxHandler.test.ts @@ -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": "*" }), @@ -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"; @@ -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" }, diff --git a/lib/sandbox/__tests__/installSessionGlobalSkills.test.ts b/lib/sandbox/__tests__/installSessionGlobalSkills.test.ts new file mode 100644 index 00000000..33cdf62d --- /dev/null +++ b/lib/sandbox/__tests__/installSessionGlobalSkills.test.ts @@ -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]); + }); +}); diff --git a/lib/sandbox/__tests__/resolveSandboxHomeDirectory.test.ts b/lib/sandbox/__tests__/resolveSandboxHomeDirectory.test.ts new file mode 100644 index 00000000..39ce3f40 --- /dev/null +++ b/lib/sandbox/__tests__/resolveSandboxHomeDirectory.test.ts @@ -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"); + }); +}); diff --git a/lib/sandbox/__tests__/shellEscape.test.ts b/lib/sandbox/__tests__/shellEscape.test.ts new file mode 100644 index 00000000..1f1feab1 --- /dev/null +++ b/lib/sandbox/__tests__/shellEscape.test.ts @@ -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'"); + }); +}); diff --git a/lib/sandbox/createSandboxHandler.ts b/lib/sandbox/createSandboxHandler.ts index eb3f9be2..00891e4c 100644 --- a/lib/sandbox/createSandboxHandler.ts +++ b/lib/sandbox/createSandboxHandler.ts @@ -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]; @@ -35,26 +36,24 @@ export async function createSandboxHandler(request: NextRequest): Promise | 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; @@ -85,14 +84,14 @@ export async function createSandboxHandler(request: NextRequest): Promise; + sandbox: Sandbox; +}): Promise { + 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, + }); +} diff --git a/lib/sandbox/resolveSandboxHomeDirectory.ts b/lib/sandbox/resolveSandboxHomeDirectory.ts new file mode 100644 index 00000000..ee3e2301 --- /dev/null +++ b/lib/sandbox/resolveSandboxHomeDirectory.ts @@ -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 { + 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; +} diff --git a/lib/sandbox/shellEscape.ts b/lib/sandbox/shellEscape.ts new file mode 100644 index 00000000..03f69120 --- /dev/null +++ b/lib/sandbox/shellEscape.ts @@ -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, "'\\''")}'`; +} diff --git a/lib/skills/__tests__/defaultGlobalSkillRefs.test.ts b/lib/skills/__tests__/defaultGlobalSkillRefs.test.ts new file mode 100644 index 00000000..52815c16 --- /dev/null +++ b/lib/skills/__tests__/defaultGlobalSkillRefs.test.ts @@ -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); + }); +}); diff --git a/lib/skills/__tests__/globalSkillRef.test.ts b/lib/skills/__tests__/globalSkillRef.test.ts new file mode 100644 index 00000000..af0ec63b --- /dev/null +++ b/lib/skills/__tests__/globalSkillRef.test.ts @@ -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); + }); +}); diff --git a/lib/skills/__tests__/installGlobalSkills.test.ts b/lib/skills/__tests__/installGlobalSkills.test.ts new file mode 100644 index 00000000..a532d279 --- /dev/null +++ b/lib/skills/__tests__/installGlobalSkills.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { installGlobalSkills } from "@/lib/skills/installGlobalSkills"; +import { resolveSandboxHomeDirectory } from "@/lib/sandbox/resolveSandboxHomeDirectory"; + +vi.mock("@/lib/sandbox/resolveSandboxHomeDirectory", () => ({ + resolveSandboxHomeDirectory: vi.fn(async () => "/home/agent"), +})); + +const exec = vi.fn(); +const sandbox = { + workingDirectory: "/workspace", + exec, +} as never; + +const REF_API = { source: "recoupable/skills", skillName: "recoup-api" }; +const REF_WORKSPACE = { source: "recoupable/skills", skillName: "artist-workspace" }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(resolveSandboxHomeDirectory).mockResolvedValue("/home/agent"); + exec.mockResolvedValue({ + success: true, + exitCode: 0, + stdout: "", + stderr: "", + truncated: false, + }); +}); + +describe("installGlobalSkills", () => { + it("returns immediately when given an empty list", async () => { + await installGlobalSkills({ sandbox, globalSkillRefs: [] }); + expect(exec).not.toHaveBeenCalled(); + }); + + it("runs `npx skills add` once per ref, with the resolved HOME", async () => { + await installGlobalSkills({ sandbox, globalSkillRefs: [REF_API, REF_WORKSPACE] }); + + expect(exec).toHaveBeenCalledTimes(2); + expect(exec.mock.calls[0][0]).toContain("HOME='/home/agent'"); + expect(exec.mock.calls[0][0]).toContain( + "npx skills add 'recoupable/skills' --skill 'recoup-api'", + ); + expect(exec.mock.calls[1][0]).toContain("--skill 'artist-workspace'"); + }); + + it("dedupes duplicate refs via the schema before installing", async () => { + await installGlobalSkills({ + sandbox, + globalSkillRefs: [REF_API, REF_API], + }); + expect(exec).toHaveBeenCalledTimes(1); + }); + + it("throws when any install command fails", async () => { + exec.mockResolvedValueOnce({ + success: false, + exitCode: 1, + stdout: "", + stderr: "package not found", + truncated: false, + }); + + await expect(installGlobalSkills({ sandbox, globalSkillRefs: [REF_API] })).rejects.toThrow( + /package not found/, + ); + }); +}); diff --git a/lib/skills/defaultGlobalSkillRefs.ts b/lib/skills/defaultGlobalSkillRefs.ts new file mode 100644 index 00000000..1d2984b9 --- /dev/null +++ b/lib/skills/defaultGlobalSkillRefs.ts @@ -0,0 +1,12 @@ +import type { GlobalSkillRef } from "@/lib/skills/globalSkillRef"; + +/** + * Skills installed into every session sandbox at create time, regardless + * of user preferences. Platform-level defaults that the agent should + * always be able to load on demand — they are descriptor-only at install + * time, so adding entries here doesn't bloat the system prompt. + */ +export const DEFAULT_GLOBAL_SKILL_REFS: readonly GlobalSkillRef[] = [ + { source: "recoupable/skills", skillName: "recoup-api" }, + { source: "recoupable/skills", skillName: "artist-workspace" }, +]; diff --git a/lib/skills/globalSkillRef.ts b/lib/skills/globalSkillRef.ts new file mode 100644 index 00000000..98d5be44 --- /dev/null +++ b/lib/skills/globalSkillRef.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +const GLOBAL_SKILL_SOURCE_PATTERN = /^[^\s/]+\/[^\s/]+$/; +const GLOBAL_SKILL_NAME_PATTERN = /^\S+$/; + +export const globalSkillRefSchema = z.object({ + source: z + .string() + .trim() + .min(1, "Source is required") + .regex(GLOBAL_SKILL_SOURCE_PATTERN, "Source must be in owner/repo format"), + skillName: z + .string() + .trim() + .min(1, "Skill name is required") + .regex(GLOBAL_SKILL_NAME_PATTERN, "Skill name cannot contain spaces"), +}); + +export type GlobalSkillRef = z.infer; + +function getGlobalSkillRefKey(ref: GlobalSkillRef): string { + return `${ref.source.toLowerCase()}::${ref.skillName.toLowerCase()}`; +} + +export const globalSkillRefsSchema = z.array(globalSkillRefSchema).transform(refs => { + const seen = new Set(); + const out: GlobalSkillRef[] = []; + for (const ref of refs) { + const key = getGlobalSkillRefKey(ref); + if (seen.has(key)) continue; + seen.add(key); + out.push(ref); + } + return out; +}); + +/** + * Best-effort parse of an arbitrary `global_skill_refs` JSON value into + * a deduped array of valid refs. Invalid input returns `[]` instead of + * throwing — caller code can safely combine the result with the + * platform defaults. + * + * @param value - The raw JSON value (typically `row.global_skill_refs`). + * @returns Validated and deduped refs, or `[]` on any parse failure. + */ +export function normalizeGlobalSkillRefs(value: unknown): GlobalSkillRef[] { + const parsed = globalSkillRefsSchema.safeParse(value); + return parsed.success ? parsed.data : []; +} diff --git a/lib/skills/installGlobalSkills.ts b/lib/skills/installGlobalSkills.ts new file mode 100644 index 00000000..4b0e825a --- /dev/null +++ b/lib/skills/installGlobalSkills.ts @@ -0,0 +1,40 @@ +import { resolveSandboxHomeDirectory } from "@/lib/sandbox/resolveSandboxHomeDirectory"; +import { shellEscape } from "@/lib/sandbox/shellEscape"; +import { globalSkillRefsSchema, type GlobalSkillRef } from "@/lib/skills/globalSkillRef"; +import type { Sandbox } from "@/lib/sandbox/interface"; + +const GLOBAL_SKILLS_INSTALL_TIMEOUT_MS = 120_000; + +/** + * Installs the supplied skill refs into the sandbox by running + * `npx skills add ...` for each one. Refs are validated and deduped + * via `globalSkillRefsSchema` before any command runs. Throws on the + * first failure — caller is expected to handle the error + * (typically best-effort: log and continue). + * + * @param params.sandbox - The connected sandbox handle. + * @param params.globalSkillRefs - Refs to install (defaults + user prefs). + */ +export async function installGlobalSkills(params: { + sandbox: Sandbox; + globalSkillRefs: GlobalSkillRef[]; +}): Promise { + const refs = globalSkillRefsSchema.parse(params.globalSkillRefs); + if (refs.length === 0) return; + + const homeDirectory = await resolveSandboxHomeDirectory(params.sandbox); + + for (const ref of refs) { + const result = await params.sandbox.exec( + `HOME=${shellEscape(homeDirectory)} npx skills add ${shellEscape(ref.source)} --skill ${shellEscape(ref.skillName)} --agent amp -g -y --copy`, + params.sandbox.workingDirectory, + GLOBAL_SKILLS_INSTALL_TIMEOUT_MS, + ); + + if (!result.success) { + throw new Error( + `Failed to install global skill ${ref.skillName} from ${ref.source}: ${result.stderr || result.stdout || "unknown error"}`, + ); + } + } +}