diff --git a/api/openapi.yaml b/api/openapi.yaml index 2f341ee..6501061 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2346,6 +2346,54 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/admin/users/{id}/admin: + put: + summary: Grant or revoke platform admin + operationId: setUserAdmin + tags: [admin] + description: | + Sets the platform admin flag on a user. Cannot remove the last admin. + Requires platform admin access (JWT + is_admin). + The target user's JWT is not re-issued — their frontend will reflect the + change on next login or team switch. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + example: "usr-a1b2c3d4" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [admin] + properties: + admin: + type: boolean + description: true to grant admin, false to revoke. + responses: + "204": + description: Admin status updated + "400": + $ref: "#/components/responses/BadRequest" + "403": + description: Caller is not a platform admin + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: User not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + components: securitySchemes: apiKeyAuth: @@ -2366,14 +2414,6 @@ components: name: X-Host-Token description: Host JWT returned from POST /v1/hosts/register or POST /v1/hosts/auth/refresh. Valid for 7 days. - responses: - BadRequest: - description: Invalid request - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - schemas: SignupRequest: type: object diff --git a/src/capsule.ts b/src/capsule.ts index dce0485..69a3154 100644 --- a/src/capsule.ts +++ b/src/capsule.ts @@ -8,6 +8,7 @@ import { CommandManager } from "./commands.js"; import type { ClientConfig } from "./config.js"; import { TimeoutError, WrennError } from "./exceptions.js"; import { FileManager } from "./files.js"; +import { Git } from "./git/index.js"; import { PtyManager } from "./pty.js"; export type CapsuleInfo = OperationJsonResponse<"getCapsule", 200>; @@ -93,6 +94,7 @@ export class Capsule { readonly client: WrennClient; readonly commands: CommandManager; readonly files: FileManager; + readonly git: Git; readonly pty: PtyManager; /** @@ -106,6 +108,7 @@ export class Capsule { this.client = new WrennClient(opts); this.commands = new CommandManager(id, this.client); this.files = new FileManager(id, this.client); + this.git = new Git(this); this.pty = new PtyManager(id, this.client); } diff --git a/src/code-interpreter/index.ts b/src/code-interpreter/index.ts new file mode 100644 index 0000000..73fb260 --- /dev/null +++ b/src/code-interpreter/index.ts @@ -0,0 +1,58 @@ +import { Capsule, type CapsuleCreateOptions } from "../capsule.js"; +import type { ClientConfig } from "../config.js"; + +export interface ExecCellOptions { + /** Command timeout in seconds. Defaults to 30. */ + timeoutSec?: number; +} + +export interface ExecCellResult { + /** Captured standard output from Python. */ + stdout: string; + /** Captured standard error from Python. */ + stderr: string; + /** Process exit code returned by Python. */ + exitCode: number | undefined; +} + +/** Executes notebook-style Python cells inside a capsule. */ +export class Notebook { + constructor(private readonly capsule: Capsule) {} + + /** Executes Python code using the capsule's Python 3 CLI. */ + async execCell( + code: string, + opts?: ExecCellOptions, + ): Promise { + const result = await this.capsule.commands.exec("python3", { + args: ["-c", code], + timeoutSec: opts?.timeoutSec ?? 30, + }); + return { + exitCode: result.exit_code, + stderr: result.stderr ?? "", + stdout: result.stdout ?? "", + }; + } +} + +/** Specialized capsule wrapper for Python/Jupyter code execution. */ +export class CodeInterpreter { + readonly notebook: Notebook; + + constructor(readonly capsule: Capsule) { + this.notebook = new Notebook(capsule); + } + + /** Creates a capsule using the Jupyter template by default. */ + static async create(opts?: CapsuleCreateOptions): Promise { + const template = opts?.template ?? "jupyter"; + const capsule = await Capsule.create(template, opts); + return new CodeInterpreter(capsule); + } + + /** Wraps an existing capsule ID without validating it remotely. */ + static connect(id: string, opts?: ClientConfig): CodeInterpreter { + return new CodeInterpreter(Capsule.connect(id, opts)); + } +} diff --git a/src/commands.ts b/src/commands.ts index ff467f2..f3e54f2 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -13,6 +13,7 @@ export type ProcessList = OperationJsonResponse<"listProcesses", 200>; export interface CommandOptions { args?: string[]; timeoutSec?: number; + cwd?: string; } export interface BackgroundCommandOptions extends CommandOptions { @@ -38,16 +39,16 @@ function commandBody( background: boolean, opts?: BackgroundCommandOptions, ): OperationJsonBody<"execCommand"> { - const body: OperationJsonBody<"execCommand"> = { - background, - cmd, - timeout_sec: opts?.timeoutSec ?? DEFAULT_COMMAND_TIMEOUT_SEC, - }; + const body: Partial> = { cmd }; + if (background) body.background = true; + if (background || opts?.timeoutSec !== undefined) { + body.timeout_sec = opts?.timeoutSec ?? DEFAULT_COMMAND_TIMEOUT_SEC; + } if (opts?.args) body.args = opts.args; if (opts?.tag) body.tag = opts.tag; if (opts?.envs) body.envs = opts.envs; if (opts?.cwd) body.cwd = opts.cwd; - return body; + return body as OperationJsonBody<"execCommand">; } function isTerminalEvent(event: CommandStreamEvent): boolean { diff --git a/src/git/index.ts b/src/git/index.ts new file mode 100644 index 0000000..f0ab8e2 --- /dev/null +++ b/src/git/index.ts @@ -0,0 +1,114 @@ +import type { Capsule } from "../capsule.js"; +import type { CommandOptions, CommandResult } from "../commands.js"; + +export interface GitOptions { + /** Working directory used for repository-scoped git commands. */ + cwd?: string; +} + +export interface GitCloneOptions { + /** Destination path for the cloned repository. */ + path?: string; + /** Branch to check out during clone. */ + branch?: string; +} + +export interface GitRemoteBranchOptions extends GitOptions { + /** Remote name. Defaults to git's configured default when omitted. */ + remote?: string; + /** Branch name. Defaults to git's configured default when omitted. */ + branch?: string; +} + +export interface GitLogOptions extends GitOptions { + /** Maximum number of commits to return. */ + maxCount?: number; +} + +export interface GitCheckoutOptions extends GitOptions { + /** Create the branch before checking it out. */ + create?: boolean; +} + +/** High-level Git helper that runs git CLI commands inside a capsule. */ +export class Git { + constructor( + private readonly capsule: Capsule, + private readonly opts: GitOptions = {}, + ) {} + + /** Clones a repository into the capsule. */ + clone(url: string, opts?: GitCloneOptions): Promise { + const args = ["clone"]; + if (opts?.branch) args.push("--branch", opts.branch); + args.push(url); + if (opts?.path) args.push(opts.path); + return this.run(args); + } + + /** Returns porcelain status for the current repository. */ + status(opts?: GitOptions): Promise { + return this.run(["status", "--short"], opts); + } + + /** Pulls changes from a remote branch. */ + pull(opts?: GitRemoteBranchOptions): Promise { + return this.runRemoteBranch("pull", opts); + } + + /** Pushes changes to a remote branch. */ + push(opts?: GitRemoteBranchOptions): Promise { + return this.runRemoteBranch("push", opts); + } + + /** Returns compact commit history. */ + log(opts?: GitLogOptions): Promise { + const args = ["log", "--oneline"]; + if (opts?.maxCount !== undefined) args.push("-n", String(opts.maxCount)); + return this.run(args, opts); + } + + /** Returns the current branch name. */ + branch(opts?: GitOptions): Promise { + return this.run(["branch", "--show-current"], opts); + } + + /** Checks out an existing branch or creates it when requested. */ + checkout(branch: string, opts?: GitCheckoutOptions): Promise { + const args = opts?.create + ? ["checkout", "-b", branch] + : ["checkout", branch]; + return this.run(args, opts); + } + + /** Stages one or more files. */ + add(files: string | string[], opts?: GitOptions): Promise { + const fileList = Array.isArray(files) ? files : [files]; + if (!fileList.length) { + return Promise.reject(new Error("At least one file is required")); + } + return this.run(["add", ...fileList], opts); + } + + /** Creates a commit. Git user identity must already be configured inside the capsule. */ + commit(message: string, opts?: GitOptions): Promise { + return this.run(["commit", "-m", message], opts); + } + + private run(args: string[], opts?: GitOptions): Promise { + const commandOpts: CommandOptions = { args }; + const cwd = opts?.cwd ?? this.opts.cwd; + if (cwd) commandOpts.cwd = cwd; + return this.capsule.commands.exec("git", commandOpts); + } + + private runRemoteBranch( + command: "pull" | "push", + opts?: GitRemoteBranchOptions, + ): Promise { + const args: string[] = [command]; + if (opts?.remote) args.push(opts.remote); + if (opts?.branch) args.push(opts.branch); + return this.run(args, opts); + } +} diff --git a/src/index.ts b/src/index.ts index 0395fcb..1794ca3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,11 @@ export { UsersResource, WrennClient, } from "./client.js"; +export type { + ExecCellOptions, + ExecCellResult, +} from "./code-interpreter/index.js"; +export { CodeInterpreter, Notebook } from "./code-interpreter/index.js"; export type { BackgroundCommandOptions, BackgroundProcess, @@ -70,5 +75,13 @@ export type { MakeDirectoryResult, } from "./files.js"; export { FileManager } from "./files.js"; +export type { + GitCheckoutOptions, + GitCloneOptions, + GitLogOptions, + GitOptions, + GitRemoteBranchOptions, +} from "./git/index.js"; +export { Git } from "./git/index.js"; export type { PtyEvent, PtyStartOptions } from "./pty.js"; export { PtyManager, PtySession } from "./pty.js"; diff --git a/tests/code-interpreter.test.ts b/tests/code-interpreter.test.ts new file mode 100644 index 0000000..2ae0a47 --- /dev/null +++ b/tests/code-interpreter.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; + +import { Capsule } from "../src/capsule.js"; +import { CodeInterpreter } from "../src/code-interpreter/index.js"; + +describe("CodeInterpreter", () => { + it("creates a capsule with the jupyter template by default", async () => { + const create = vi.spyOn(Capsule, "create").mockResolvedValue( + new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }), + ); + + const interpreter = await CodeInterpreter.create({ + baseUrl: "https://api.example.com", + }); + + expect(interpreter.capsule.id).toBe("cap_1"); + expect(create).toHaveBeenCalledWith("jupyter", { + baseUrl: "https://api.example.com", + }); + create.mockRestore(); + }); + + it("connects to an existing capsule", () => { + const interpreter = CodeInterpreter.connect("cap_1", { + baseUrl: "https://api.example.com", + }); + + expect(interpreter.capsule.id).toBe("cap_1"); + }); + + it("executes notebook cells through python3", async () => { + const capsule = new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }); + const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({ + exit_code: 0, + stderr: "", + stdout: "42\n", + }); + const interpreter = new CodeInterpreter(capsule); + + await expect( + interpreter.notebook.execCell("print(6 * 7)"), + ).resolves.toEqual({ + exitCode: 0, + stderr: "", + stdout: "42\n", + }); + expect(exec).toHaveBeenCalledWith("python3", { + args: ["-c", "print(6 * 7)"], + timeoutSec: 30, + }); + }); +}); diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 8f8efe4..628b4b0 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -21,7 +21,6 @@ describe("CommandManager", () => { expect(exec).toHaveBeenCalledWith("cap_1", { args: ["--version"], - background: false, cmd: "node", timeout_sec: 5, }); diff --git a/tests/git.test.ts b/tests/git.test.ts new file mode 100644 index 0000000..239969c --- /dev/null +++ b/tests/git.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; + +import { Capsule } from "../src/capsule.js"; +import { Git } from "../src/git/index.js"; + +describe("Git", () => { + it("runs clone with optional path and branch", async () => { + const capsule = new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }); + const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({ + exit_code: 0, + stdout: "", + }); + + await expect( + capsule.git.clone("https://example.com/repo.git", { + branch: "main", + path: "/work/repo", + }), + ).resolves.toMatchObject({ exit_code: 0 }); + + expect(exec).toHaveBeenCalledWith("git", { + args: [ + "clone", + "--branch", + "main", + "https://example.com/repo.git", + "/work/repo", + ], + }); + }); + + it("runs repository commands in cwd", async () => { + const capsule = new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }); + const git = new Git(capsule, { cwd: "/work/repo" }); + const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({ + exit_code: 0, + stdout: "main\n", + }); + + await git.status(); + await git.pull({ branch: "main", remote: "origin" }); + await git.push({ branch: "main", remote: "origin" }); + await git.log({ maxCount: 3 }); + await git.branch(); + await git.checkout("feature", { create: true }); + await git.add(["src/a.ts", "src/b.ts"]); + await git.commit("test commit"); + + expect(exec.mock.calls).toEqual([ + ["git", { args: ["status", "--short"], cwd: "/work/repo" }], + ["git", { args: ["pull", "origin", "main"], cwd: "/work/repo" }], + ["git", { args: ["push", "origin", "main"], cwd: "/work/repo" }], + ["git", { args: ["log", "--oneline", "-n", "3"], cwd: "/work/repo" }], + ["git", { args: ["branch", "--show-current"], cwd: "/work/repo" }], + ["git", { args: ["checkout", "-b", "feature"], cwd: "/work/repo" }], + ["git", { args: ["add", "src/a.ts", "src/b.ts"], cwd: "/work/repo" }], + ["git", { args: ["commit", "-m", "test commit"], cwd: "/work/repo" }], + ]); + }); + + it("rejects empty git add file lists", async () => { + const capsule = new Capsule("cap_1", { + baseUrl: "https://api.example.com", + }); + + await expect(capsule.git.add([])).rejects.toThrow( + "At least one file is required", + ); + }); +}); diff --git a/tests/integration/higher-level-abstractions.integration.test.ts b/tests/integration/higher-level-abstractions.integration.test.ts new file mode 100644 index 0000000..cd4ed9d --- /dev/null +++ b/tests/integration/higher-level-abstractions.integration.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vitest"; + +import { Capsule, type CapsuleCreateOptions } from "../../src/capsule.js"; +import { CodeInterpreter } from "../../src/code-interpreter/index.js"; +import { DEFAULT_BASE_URL } from "../../src/config.js"; +import { Git } from "../../src/git/index.js"; + +const baseUrl = process.env.WRENN_BASE_URL ?? DEFAULT_BASE_URL; +const apiKey = process.env.WRENN_API_KEY; +const template = process.env.WRENN_TEST_TEMPLATE ?? "minimal"; +const codeTemplate = process.env.WRENN_TEST_CODE_TEMPLATE ?? "jupyter"; +const codeCapsuleId = process.env.WRENN_TEST_CODE_CAPSULE_ID; +const waitTimeoutMs = Number(process.env.WRENN_TEST_WAIT_TIMEOUT_MS ?? 120_000); +const testTimeoutMs = waitTimeoutMs + 60_000; +const describeWithApiKey = apiKey ? describe : describe.skip; + +const clientOpts = { apiKey, baseUrl } satisfies CapsuleCreateOptions; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRetryableServerError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "statusCode" in error && + typeof error.statusCode === "number" && + error.statusCode >= 500 + ); +} + +async function expectCommandOk( + promise: Promise<{ exit_code?: number; stderr?: string }>, +): Promise { + const result = await promise; + expect(result.exit_code, result.stderr).toBe(0); +} + +async function withLiveCapsule( + fn: (capsule: Capsule) => Promise, +): Promise { + let capsule: Capsule | undefined; + try { + capsule = await Capsule.create(template, { + ...clientOpts, + timeout_sec: 120, + }); + await capsule.waitForReady({ + intervalMs: 2_000, + timeoutMs: waitTimeoutMs, + }); + await fn(capsule); + } finally { + if (capsule) { + await capsule.destroy().catch(() => undefined); + } + } +} + +async function createCodeInterpreterWithRetry(): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + return await CodeInterpreter.create({ + ...clientOpts, + template: codeTemplate, + timeout_sec: 120, + }); + } catch (error) { + lastError = error; + if (!isRetryableServerError(error) || attempt === 3) break; + await delay(2_000); + } + } + throw lastError; +} + +async function getCodeInterpreter(): Promise<{ + interpreter: CodeInterpreter; + shouldDestroy: boolean; +}> { + if (!codeCapsuleId) { + return { + interpreter: await createCodeInterpreterWithRetry(), + shouldDestroy: true, + }; + } + + const interpreter = CodeInterpreter.connect(codeCapsuleId, clientOpts); + const info = await interpreter.capsule.getInfo(); + if (info.status === "paused") { + await interpreter.capsule.resume({ + intervalMs: 2_000, + timeoutMs: waitTimeoutMs, + wait: true, + }); + } else { + await interpreter.capsule.waitForReady({ + intervalMs: 2_000, + timeoutMs: waitTimeoutMs, + }); + } + + return { interpreter, shouldDestroy: false }; +} + +describeWithApiKey("Higher-level abstractions live integration", () => { + it( + "runs git workflows inside a live capsule", + async () => { + await withLiveCapsule(async (capsule) => { + const root = `/tmp/wrenn-git-${Date.now()}`; + const origin = `${root}/origin.git`; + const work = `${root}/work`; + + await expectCommandOk( + capsule.commands.exec("mkdir", { args: ["-p", root] }), + ); + await expectCommandOk( + capsule.commands.exec("git", { args: ["init", "--bare", origin] }), + ); + + await expectCommandOk(capsule.git.clone(origin, { path: work })); + const git = new Git(capsule, { cwd: work }); + const pwd = await capsule.commands.exec("pwd", { cwd: work }); + expect(pwd.stdout?.trim(), pwd.stderr).toBe(work); + + await expectCommandOk( + capsule.commands.exec("git", { + args: ["config", "user.email", "integration@example.com"], + cwd: work, + }), + ); + await expectCommandOk( + capsule.commands.exec("git", { + args: ["config", "user.name", "Integration Test"], + cwd: work, + }), + ); + + await expectCommandOk( + capsule.commands.exec("sh", { + args: ["-lc", "printf 'phase5-git\\n' > README.md"], + cwd: work, + }), + ); + const dirty = await git.status(); + expect(dirty.exit_code, dirty.stderr).toBe(0); + expect(dirty.stdout, dirty.stderr).toContain("README.md"); + + await expectCommandOk(git.add("README.md")); + const commit = await git.commit("initial commit"); + expect(commit.exit_code, commit.stderr).toBe(0); + + const log = await git.log({ maxCount: 1 }); + expect(log.exit_code, log.stderr).toBe(0); + expect(log.stdout, log.stderr).toContain("initial commit"); + + await expectCommandOk(git.branch()); + await expectCommandOk(git.checkout("feature", { create: true })); + const branch = await git.branch(); + expect(branch.exit_code, branch.stderr).toBe(0); + expect(branch.stdout?.trim(), branch.stderr).toBe("feature"); + + await expectCommandOk( + git.push({ branch: "feature", remote: "origin" }), + ); + await expectCommandOk( + git.pull({ branch: "feature", remote: "origin" }), + ); + }); + }, + testTimeoutMs, + ); + + it( + "executes a code interpreter cell in a live capsule", + async () => { + let interpreter: CodeInterpreter | undefined; + let shouldDestroy = false; + try { + const codeInterpreter = await getCodeInterpreter(); + interpreter = codeInterpreter.interpreter; + shouldDestroy = codeInterpreter.shouldDestroy; + + const result = await interpreter.notebook.execCell("print(6 * 7)", { + timeoutSec: 15, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe("42"); + expect(result.stderr).toBe(""); + } finally { + if (interpreter && shouldDestroy) { + await interpreter.capsule.destroy().catch(() => undefined); + } + } + }, + testTimeoutMs, + ); +});