diff --git a/src/client/sandbox.ts b/src/client/sandbox.ts index be076fe..f1cbfd3 100644 --- a/src/client/sandbox.ts +++ b/src/client/sandbox.ts @@ -230,4 +230,25 @@ export class Sandbox implements SandboxData { websocketUrl(path = "/", port?: number): string { return this.client.sandboxes.websocketUrl(this.id, path, port); } + + /** + * Fetches the resolved home directory for the sandbox user. + * + * @param options Optional request settings. + * @returns The resolved sandbox user home directory. + */ + async getUserHomeDir(options?: { timeout?: number }): Promise { + return this.client.sandboxes.getUserHomeDir(this.id, options); + } + + /** + * Fetches the configured working directory for the sandbox. + * + * @param options Optional request settings. + * @returns The configured sandbox workdir. + */ + async getWorkdir(options?: { timeout?: number }): Promise { + return this.client.sandboxes.getWorkdir(this.id, options); + } + } diff --git a/src/index.ts b/src/index.ts index 0e1a358..6387681 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,11 @@ export { export { Leap0Client, Sandbox } from "@/client/index.js"; export { CodeLanguage, + codeLanguageSchema, + listSandboxesParamsSchema, + listSandboxesResponseSchema, + listSnapshotsParamsSchema, + listSnapshotsResponseSchema, NetworkPolicyMode, RegistryCredentialType, SandboxState, @@ -44,7 +49,11 @@ export type { CodeExecutionResult, CreatePtySessionParams, CreateSandboxParams, + ListSandboxesParams, + ListSandboxesResponse, CreateSnapshotParams, + ListSnapshotsParams, + ListSnapshotsResponse, CreateTemplateParams, DesktopDisplayInfo, DesktopHealth, @@ -73,6 +82,7 @@ export type { ProcessResult, PtySession, ResumeSnapshotParams, + SandboxListItem, SearchMatch, SshAccess, SshValidation, diff --git a/src/models/index.ts b/src/models/index.ts index 61e805e..b3cb138 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -2,12 +2,31 @@ export type { RequestOptions } from "@/models/shared.js"; export type { SandboxRef, SnapshotRef, TemplateRef } from "@/models/refs.js"; -export { createSandboxParamsSchema, NetworkPolicyMode, SandboxState } from "@/models/sandbox.js"; -export type { CreateSandboxParams, NetworkPolicy, SandboxData } from "@/models/sandbox.js"; +export { + createSandboxParamsSchema, + listSandboxesParamsSchema, + listSandboxesResponseSchema, + NetworkPolicyMode, + SandboxState, +} from "@/models/sandbox.js"; +export type { + CreateSandboxParams, + ListSandboxesParams, + ListSandboxesResponse, + NetworkPolicy, + SandboxData, + SandboxListItem, +} from "@/models/sandbox.js"; -export { createSnapshotParamsSchema } from "@/models/snapshot.js"; +export { + createSnapshotParamsSchema, + listSnapshotsParamsSchema, + listSnapshotsResponseSchema, +} from "@/models/snapshot.js"; export type { CreateSnapshotParams, + ListSnapshotsParams, + ListSnapshotsResponse, ResumeSnapshotParams, SnapshotData, } from "@/models/snapshot.js"; @@ -48,7 +67,11 @@ export type { LspJsonRpcError, LspJsonRpcResponse, LspResponse } from "@/models/ export type { SshAccess, SshValidation } from "@/models/ssh.js"; -export { CodeLanguage, codeLanguageSchema, StreamEventType } from "@/models/code-interpreter.js"; +export { + CodeLanguage, + codeLanguageSchema, + StreamEventType, +} from "@/models/code-interpreter.js"; export type { CodeContext, CodeExecutionError, diff --git a/src/models/sandbox.ts b/src/models/sandbox.ts index 5f0c973..53134d4 100644 --- a/src/models/sandbox.ts +++ b/src/models/sandbox.ts @@ -135,6 +135,51 @@ export const sandboxDataSchema = z /** Sandbox resource returned by the control plane API. */ export type SandboxData = z.infer; +export const sandboxListItemSchema = z + .object({ + id: z.string(), + templateId: z.string(), + podId: z.string(), + state: sandboxStateSchema, + launchTime: z.string().optional(), + stateChangeTime: z.string().optional(), + timeoutAt: z.number().optional(), + createdAt: z.string(), + }) + .catchall(z.unknown()); +/** Sandbox summary returned by the list sandboxes API. */ +export type SandboxListItem = z.infer; + +export const listSandboxesResponseSchema = z + .object({ + items: z.array(sandboxListItemSchema), + totalItems: z.number().int().nonnegative(), + }) + .catchall(z.unknown()); +/** Paginated sandbox list response. */ +export type ListSandboxesResponse = z.infer; + +export const listSandboxesParamsSchema = z + .object({ + state: z + .enum([ + SandboxState.STARTING, + SandboxState.SNAPSHOTTING, + SandboxState.RUNNING, + SandboxState.PAUSED, + SandboxState.UNPAUSING, + SandboxState.DELETING, + ]) + .optional(), + sort: z.enum(["created_at", "state"]).optional(), + orderBy: z.enum(["asc", "desc"]).optional(), + page: z.number().int().min(1).optional(), + pageSize: z.number().int().min(1).max(100).optional(), + }) + .passthrough(); +/** Parameters accepted when listing sandboxes. */ +export type ListSandboxesParams = z.infer; + export const createSandboxParamsSchema = z.object({ templateName: z.string().optional(), vcpu: z.number().int().positive().optional(), diff --git a/src/models/snapshot.ts b/src/models/snapshot.ts index 9a56c4c..6de6de3 100644 --- a/src/models/snapshot.ts +++ b/src/models/snapshot.ts @@ -25,6 +25,25 @@ export const createSnapshotParamsSchema = z.object({ /** Parameters accepted when creating or naming a snapshot. */ export type CreateSnapshotParams = z.infer; +export const listSnapshotsParamsSchema = z.object({ + query: z.string().max(64).optional(), + sort: z.enum(["created_at", "template_id"]).optional(), + orderBy: z.enum(["asc", "desc"]).optional(), + page: z.number().int().min(1).optional(), + pageSize: z.number().int().min(1).max(100).optional(), +}); +/** Parameters accepted when listing snapshots. */ +export type ListSnapshotsParams = z.infer; + +export const listSnapshotsResponseSchema = z + .object({ + items: z.array(snapshotDataSchema), + totalItems: z.number().int().nonnegative(), + }) + .catchall(z.unknown()); +/** Paginated snapshot list response. */ +export type ListSnapshotsResponse = z.infer; + export const resumeSnapshotParamsSchema = z.object({ snapshotName: snapshotNameSchema, autoPause: z.boolean().optional(), diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 29e6ed6..2437e3f 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -3,11 +3,15 @@ import { Leap0Error } from "@/core/errors.js"; import { normalize } from "@/core/normalize.js"; import { createSandboxRuntimeParamsSchema, + listSandboxesParamsSchema, + listSandboxesResponseSchema, sandboxDataSchema, toNetworkPolicyWire, } from "@/models/sandbox.js"; import type { CreateSandboxParams, + ListSandboxesParams, + ListSandboxesResponse, RequestOptions, SandboxData, SandboxRef, @@ -118,6 +122,38 @@ export class SandboxesClient { }); } + /** + * Lists sandboxes for the authenticated organization. + * + * @param params Optional filter, sort, and pagination parameters. + * @param options Optional request settings such as timeout and headers. + * @returns Paginated sandbox summaries. + */ + async list( + params: ListSandboxesParams = {}, + options: RequestOptions = {}, + ): Promise { + return withErrorPrefix("Failed to list sandboxes: ", async () => { + const parsed = listSandboxesParamsSchema.parse(params); + const data = await this.transport.requestJson( + "/v1/sandboxes", + { method: "GET" }, + { + ...options, + query: { + ...options.query, + state: parsed.state, + sort: parsed.sort, + "order-by": parsed.orderBy, + page: parsed.page, + "page-size": parsed.pageSize, + }, + }, + ); + return normalize(listSandboxesResponseSchema, data); + }); + } + /** * Pauses a running sandbox. * @@ -166,6 +202,57 @@ export class SandboxesClient { ); } + /** + * Fetches the resolved home directory for the sandbox user. + * + * @param sandbox Sandbox ID or sandbox-like object. + * @param options Optional request settings such as timeout and query params. + * @returns The resolved sandbox user home directory. + */ + async getUserHomeDir(sandbox: SandboxRef, options: RequestOptions = {}): Promise { + return withErrorPrefix("Failed to get sandbox user home directory: ", async () => { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/system/user-home-dir`, + { method: "GET" }, + options, + ); + if ( + typeof data !== "object" || + data === null || + typeof (data as { user_home_dir?: unknown }).user_home_dir !== "string" + ) { + throw new Leap0Error("Sandbox user home directory response missing user_home_dir"); + } + return (data as { user_home_dir: string }).user_home_dir; + }); + } + + /** + * Fetches the configured working directory for the sandbox. + * + * @param sandbox Sandbox ID or sandbox-like object. + * @param options Optional request settings such as timeout and query params. + * @returns The configured sandbox workdir. + */ + async getWorkdir(sandbox: SandboxRef, options: RequestOptions = {}): Promise { + return withErrorPrefix("Failed to get sandbox workdir: ", async () => { + const data = await this.transport.requestJson( + `/v1/sandbox/${sandboxIdOf(sandbox)}/system/workdir`, + { method: "GET" }, + options, + ); + if ( + typeof data !== "object" || + data === null || + typeof (data as { workdir?: unknown }).workdir !== "string" + ) { + throw new Leap0Error("Sandbox workdir response missing workdir"); + } + return (data as { workdir: string }).workdir; + }); + } + + /** * Builds the public invoke URL for a sandbox. * diff --git a/src/services/snapshots.ts b/src/services/snapshots.ts index 3d229be..a6f9a15 100644 --- a/src/services/snapshots.ts +++ b/src/services/snapshots.ts @@ -1,5 +1,7 @@ import type { CreateSnapshotParams, + ListSnapshotsParams, + ListSnapshotsResponse, RequestOptions, ResumeSnapshotParams, SandboxData, @@ -10,6 +12,8 @@ import type { import { normalize } from "@/core/normalize.js"; import { createSnapshotParamsSchema, + listSnapshotsParamsSchema, + listSnapshotsResponseSchema, resumeSnapshotParamsSchema, snapshotDataSchema, } from "@/models/snapshot.js"; @@ -33,6 +37,38 @@ export class SnapshotsClient { return (this.sandboxFactory ? this.sandboxFactory(data) : data) as T; } + /** + * Lists snapshots for the authenticated organization. + * + * @param params Optional filter, sort, and pagination parameters. + * @param options Optional request settings such as timeout and headers. + * @returns Paginated snapshot summaries. + */ + async list( + params: ListSnapshotsParams = {}, + options: RequestOptions = {}, + ): Promise { + return withErrorPrefix("Failed to list snapshots: ", async () => { + const parsed = listSnapshotsParamsSchema.parse(params); + const data = await this.transport.requestJson( + "/v1/snapshots", + { method: "GET" }, + { + ...options, + query: { + ...options.query, + query: parsed.query, + sort: parsed.sort, + "order-by": parsed.orderBy, + page: parsed.page, + "page-size": parsed.pageSize, + }, + }, + ); + return normalize(listSnapshotsResponseSchema, data); + }); + } + /** * Creates a snapshot from a running sandbox. * diff --git a/tests/client/client-sandbox.test.ts b/tests/client/client-sandbox.test.ts index 73a9d05..d995fb2 100644 --- a/tests/client/client-sandbox.test.ts +++ b/tests/client/client-sandbox.test.ts @@ -86,6 +86,8 @@ test("Sandbox binds service methods to itself", async () => { createdAt: "2026-01-01T00:00:00Z", }), delete: async () => undefined, + getUserHomeDir: async (id: string) => `home:${id}`, + getWorkdir: async (id: string) => `workdir:${id}`, invokeUrl: (id: string, path: string, port?: number) => `invoke:${id}:${path}:${port ?? ""}`, websocketUrl: (id: string, path: string, port?: number) => `ws:${id}:${path}:${port ?? ""}`, }, @@ -120,6 +122,8 @@ test("Sandbox binds service methods to itself", async () => { await sandbox.pause(); assert.equal(sandbox.state, "paused"); assert.equal(sandbox.invokeUrl("/healthz", 3000), "invoke:sb-1:/healthz:3000"); + assert.equal(await sandbox.getUserHomeDir(), "home:sb-1"); + assert.equal(await sandbox.getWorkdir(), "workdir:sb-1"); }); test("Sandbox refresh rejects invalid sandbox states", async () => { @@ -178,6 +182,8 @@ test("client and sandbox helpers stay strongly typed", () => { expectTypeOf().parameters.toEqualTypeOf< [accessId: string, password: string, options?: RequestOptions] >(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf | undefined>(); diff --git a/tests/services/sandboxes-client.test.ts b/tests/services/sandboxes-client.test.ts index ea16e61..f4a90d4 100644 --- a/tests/services/sandboxes-client.test.ts +++ b/tests/services/sandboxes-client.test.ts @@ -82,6 +82,85 @@ test("sandboxes get pause and delete target sandbox ids", async () => { assert.equal(calls[2]?.path, "/v1/sandbox/sb-3/"); }); +test("sandboxes runtime info targets system endpoints", async () => { + const { transport, calls } = createRecordedTransport({ + requestJson: (path: string, init: RequestInit, options: unknown) => { + calls.push({ path, init, options: options as never }); + if (path.endsWith("/user-home-dir")) { + return Promise.resolve({ user_home_dir: "/home/steven" }); + } + return Promise.resolve({ workdir: "/home/steve/agent" }); + }, + }); + const client = new SandboxesClient(transport as never); + + assert.equal(await client.getUserHomeDir("sb-1"), "/home/steven"); + assert.equal(await client.getWorkdir("sb-1"), "/home/steve/agent"); + assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/system/user-home-dir"); + assert.equal(calls[1]?.path, "/v1/sandbox/sb-1/system/workdir"); +}); + +test("sandboxes runtime info rejects non-object responses", async () => { + const { transport } = createRecordedTransport({ + requestJson: (path: string) => { + if (path.endsWith("/user-home-dir")) { + return Promise.resolve(null); + } + return Promise.resolve("/tmp"); + }, + }); + const client = new SandboxesClient(transport as never); + + await assert.rejects(() => client.getUserHomeDir("sb-1"), /missing user_home_dir/); + await assert.rejects(() => client.getWorkdir("sb-1"), /missing workdir/); +}); + + +test("sandboxes list sends query params and normalizes response", async () => { + const { transport, calls } = createRecordedTransport({ + requestJson: (path: string, init: RequestInit, options: unknown) => { + calls.push({ path, init, options: options as never }); + return Promise.resolve({ + items: [ + { + id: "sb-1", + template_id: "tpl-1", + pod_id: "pod-1", + state: "running", + launch_time: "2026-01-01T00:00:05Z", + state_change_time: "2026-01-01T00:00:10Z", + timeout_at: 1735689900, + created_at: "2026-01-01T00:00:00Z", + }, + ], + total_items: 1, + }); + }, + }); + const client = new SandboxesClient(transport as never); + + const result = await client.list({ + state: "running", + sort: "state", + orderBy: "asc", + page: 2, + pageSize: 10, + }); + + assert.equal(calls[0]?.path, "/v1/sandboxes"); + assert.deepEqual(calls[0]?.options.query, { + state: "running", + sort: "state", + "order-by": "asc", + page: 2, + "page-size": 10, + }); + assert.equal(result.totalItems, 1); + assert.equal(result.items[0]?.templateId, "tpl-1"); + assert.equal(result.items[0]?.podId, "pod-1"); + assert.equal(result.items[0]?.launchTime, "2026-01-01T00:00:05Z"); +}); + test("sandboxes build invoke and websocket urls", () => { const { client } = makeClient(); assert.equal(client.invokeUrl("sb-1", "healthz"), "https://sb-1.sandbox.example.com/healthz"); diff --git a/tests/services/snapshots.test.ts b/tests/services/snapshots.test.ts index ae3a09a..8dfa5a0 100644 --- a/tests/services/snapshots.test.ts +++ b/tests/services/snapshots.test.ts @@ -65,3 +65,45 @@ test("snapshots client validates snapshot names before transport", async () => { await assert.rejects(() => client.resume({ snapshotName: "snap-a", timeoutMin: 999 })); assert.equal(calls.length, 0); }); + +test("snapshots client lists snapshots with query params", async () => { + const { transport, calls } = createRecordedTransport({ + requestJson: async (path: string, init: RequestInit, options: never) => { + calls.push({ path, init, options }); + return { + items: [ + { + id: "snap-1", + name: "snap-a", + template_id: "tpl-1", + vcpu: 2, + memory_mib: 1024, + disk_mib: 4096, + created_at: "2026-01-01T00:00:00Z", + }, + ], + total_items: 1, + }; + }, + }); + const client = new SnapshotsClient(transport as never); + + const result = await client.list({ + query: "snap", + sort: "template_id", + orderBy: "asc", + page: 2, + pageSize: 5, + }); + + assert.equal(calls[0]?.path, "/v1/snapshots"); + assert.deepEqual(calls[0]?.options.query, { + query: "snap", + sort: "template_id", + "order-by": "asc", + page: 2, + "page-size": 5, + }); + assert.equal(result.totalItems, 1); + assert.equal(result.items[0]?.name, "snap-a"); +});