diff --git a/README.md b/README.md index eac9afa..b75206e 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ agent config list # list saved agent configs agent config get # show one config as YAML (-o json for JSON) agent config init [path] # scaffold a starter config (default: agents/my_agent.yaml) +agent sandbox variable list # list sandbox env variable names (values are write-only) +agent sandbox variable set A=1 B=2 # create/update variables (or --from-file .env/.json) +agent sandbox variable rm K # delete a variable + agent budget # current budget summary agent usage # usage dashboard for the period agent ping # check authenticated /v1 connectivity diff --git a/src/cli.tsx b/src/cli.tsx index 27c66ed..59f4729 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -3,6 +3,7 @@ import { registerLogin } from './commands/login' import { registerMe } from './commands/me' import { registerRun } from './commands/run' import { registerConfig } from './commands/config' +import { registerSandbox } from './commands/sandbox' import { registerTemplate } from './commands/template' import { registerUsage } from './commands/usage' import { registerPing } from './commands/ping' @@ -19,6 +20,7 @@ registerLogin(program) registerMe(program) registerRun(program) registerConfig(program) +registerSandbox(program) registerTemplate(program) registerUsage(program) registerPing(program) diff --git a/src/commands/sandbox.ts b/src/commands/sandbox.ts new file mode 100644 index 0000000..61921ad --- /dev/null +++ b/src/commands/sandbox.ts @@ -0,0 +1,160 @@ +import { type Command } from 'commander' +import { readFileSync } from 'node:fs' +import { ApiClient } from '../lib/api' +import { formatTs, printJson, printTable, runAction } from '../lib/output' +import type { SandboxVariableInput, SandboxVariableSummary } from '../lib/types' + +export function registerSandbox(program: Command): void { + const sandbox = program.command('sandbox').description('Manage sandbox resources') + + const variable = sandbox + .command('variable') + .alias('var') + .description('Manage sandbox environment variables (values are write-only)') + + variable + .command('list') + .alias('ls') + .description('List sandbox environment variables (GET /v1/sandboxes/variables)') + .option('--json', 'output raw JSON') + .action(async (opts: { json?: boolean }) => { + await runAction(async () => { + const variables = await new ApiClient().listSandboxVariables() + printVariables(variables, opts.json) + }) + }) + + variable + .command('set [assignments...]') + .description('Create or update variables, e.g. `set A=1 B=2` (PUT /v1/sandboxes/variables)') + .option('-f, --from-file ', 'load variables from a .env or .json file') + .option('--json', 'output raw JSON') + .action(async (assignments: string[], opts: { fromFile?: string; json?: boolean }) => { + await runAction(async () => { + const inputs = collectInputs(assignments, opts.fromFile) + const variables = await new ApiClient().putSandboxVariables(inputs) + if (!opts.json) { + const names = inputs.map((v) => v.name).join(', ') + console.log(`✓ stored ${inputs.length} variable(s) (values hidden): ${names}`) + } + printVariables(variables, opts.json) + }) + }) + + variable + .command('rm ') + .alias('delete') + .description('Delete a variable (DELETE /v1/sandboxes/variables/{name})') + .option('--json', 'output raw JSON') + .action(async (name: string, opts: { json?: boolean }) => { + await runAction(async () => { + const variables = await new ApiClient().deleteSandboxVariable(name) + if (!opts.json) console.log(`✓ deleted ${name}`) + printVariables(variables, opts.json) + }) + }) +} + +// Build the upsert batch from inline `KEY=VALUE` arguments and/or a file. File +// pairs come first so an inline arg with the same name overrides the file (the +// server upserts in order, last write wins). Name validation is left to the +// server (one source of truth for the rules); we only parse and require that +// the caller gave us something to send. +export function collectInputs( + assignments: string[], + fromFile: string | undefined, +): SandboxVariableInput[] { + const inputs: SandboxVariableInput[] = [] + if (fromFile) { + const fileInputs = readVarsFromFile(fromFile) + if (fileInputs.length === 0) throw new Error(`no variables found in ${fromFile}`) + inputs.push(...fileInputs) + } + for (const raw of assignments) { + const parsed = parseAssignment(raw) + if (parsed === null) { + throw new Error(`invalid assignment '${raw}' (expected KEY=VALUE)`) + } + inputs.push(parsed) + } + if (inputs.length === 0) { + throw new Error('provide KEY=VALUE pairs, or --from-file ') + } + return inputs +} + +// Read a variables file, picking the format by extension: `.json` is a flat +// object of name → value, anything else is a .env-style KEY=VALUE file. +function readVarsFromFile(path: string): SandboxVariableInput[] { + const contents = readFileSync(path, 'utf8') + return path.toLowerCase().endsWith('.json') + ? parseJsonVars(contents) + : parseEnvFile(contents) +} + +// Parse a flat JSON object of name → value. Values must be strings (sandbox +// variable values are strings); a nested object, array, or non-string value is +// rejected rather than silently coerced. +export function parseJsonVars(contents: string): SandboxVariableInput[] { + let parsed: unknown + try { + parsed = JSON.parse(contents) + } catch (err) { + throw new Error(`invalid JSON: ${(err as Error).message}`) + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('expected a JSON object of variable name to value') + } + const inputs: SandboxVariableInput[] = [] + for (const [name, value] of Object.entries(parsed)) { + if (typeof value !== 'string') { + throw new Error(`value for '${name}' must be a string`) + } + inputs.push({ name, value }) + } + return inputs +} + +// Parse one `KEY=VALUE` assignment. Strips an optional leading `export `, splits +// on the first `=` (so values may contain `=`), and removes matching surrounding +// quotes. Returns null when there is no `=`. +export function parseAssignment(raw: string): SandboxVariableInput | null { + const line = raw.trim() + const withoutExport = line.startsWith('export ') ? line.slice(7).trim() : line + const eq = withoutExport.indexOf('=') + if (eq === -1) return null + const name = withoutExport.slice(0, eq).trim() + let value = withoutExport.slice(eq + 1).trim() + if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0]) { + value = value.slice(1, -1) + } + return { name, value } +} + +// Parse a .env-style file: one `KEY=VALUE` per line, with blank lines and `#` +// comments skipped. Each non-comment line is parsed by parseAssignment. +export function parseEnvFile(contents: string): SandboxVariableInput[] { + const inputs: SandboxVariableInput[] = [] + for (const raw of contents.split('\n')) { + const line = raw.trim() + if (line === '' || line.startsWith('#')) continue + const parsed = parseAssignment(line) + if (parsed !== null) inputs.push(parsed) + } + return inputs +} + +function printVariables(variables: SandboxVariableSummary[], json?: boolean): void { + if (json) { + printJson(variables) + return + } + if (variables.length === 0) { + console.log('No sandbox variables.') + return + } + printTable( + ['NAME', 'CREATED', 'UPDATED'], + variables.map((v) => [v.name, formatTs(v.created_at), formatTs(v.updated_at)]), + ) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 06805ed..4b527b1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,10 +7,13 @@ import type { CliAuthStart, CreateAgentConfigRequest, CreatedAgentConfig, + GetSandboxVariablesResponse, ListAgentConfigsResponse, ListAgentRunsQuery, ListAgentRunsResponse, ListAgentTemplatesResponse, + SandboxVariableInput, + SandboxVariableSummary, SavedAgentConfig, StartAgentRunRequest, UsageDashboard, @@ -124,6 +127,37 @@ export class ApiClient { return this.request('GET', `/v1/agents/configs/${encodeURIComponent(configId)}`) } + // -------------------------- sandbox variables --------------------------- + // All three return the full current list (the backend echoes it after every + // mutation), so callers can render the resulting state. + + async listSandboxVariables(): Promise { + const res = await this.request( + 'GET', + '/v1/sandboxes/variables', + ) + return res.variables + } + + async putSandboxVariables( + variables: SandboxVariableInput[], + ): Promise { + const res = await this.request( + 'PUT', + '/v1/sandboxes/variables', + { variables }, + ) + return res.variables + } + + async deleteSandboxVariable(name: string): Promise { + const res = await this.request( + 'DELETE', + `/v1/sandboxes/variables/${encodeURIComponent(name)}`, + ) + return res.variables + } + // ---------------------------- agent templates --------------------------- async listAgentTemplates(): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index 9bcfcde..fd2a58d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -178,6 +178,30 @@ export interface ListAgentRunsQuery { limit?: number } +// -------------------------- sandbox variables --------------------------- +// Customer-scoped environment variables injected into a sandbox when an agent +// config names them. Values are write-only: the API accepts them but never +// returns them, so the summary carries only the name and timestamps. + +export interface SandboxVariableSummary { + name: string + created_at: string + updated_at: string +} + +export interface GetSandboxVariablesResponse { + variables: SandboxVariableSummary[] +} + +export interface SandboxVariableInput { + name: string + value: string +} + +export interface PutSandboxVariablesRequest { + variables: SandboxVariableInput[] +} + // ------------------------------ cli auth -------------------------------- export interface CliAuthStart { diff --git a/test/api.test.ts b/test/api.test.ts index b5bd09a..a148e75 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -93,6 +93,52 @@ describe('ApiClient.request', () => { }) }) +describe('ApiClient sandbox variables', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('lists variables and unwraps the response envelope', async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ variables: [{ name: 'A', created_at: '', updated_at: '' }] }), { + status: 200, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const out = await new ApiClient('http://api.test', 't').listSandboxVariables() + expect(out).toEqual([{ name: 'A', created_at: '', updated_at: '' }]) + expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/v1/sandboxes/variables') + expect((fetchMock.mock.calls[0][1] as RequestInit).method).toBe('GET') + }) + + it('PUTs the variables batch and returns the echoed list', async () => { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify({ variables: [] }), { status: 200 }), + ) + vi.stubGlobal('fetch', fetchMock) + + await new ApiClient('http://api.test', 't').putSandboxVariables([{ name: 'TOKEN', value: 'x' }]) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('http://api.test/v1/sandboxes/variables') + expect((init as RequestInit).method).toBe('PUT') + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + variables: [{ name: 'TOKEN', value: 'x' }], + }) + }) + + it('URL-encodes the name on delete', async () => { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify({ variables: [] }), { status: 200 }), + ) + vi.stubGlobal('fetch', fetchMock) + + await new ApiClient('http://api.test', 't').deleteSandboxVariable('MY/VAR') + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('http://api.test/v1/sandboxes/variables/MY%2FVAR') + expect((init as RequestInit).method).toBe('DELETE') + }) +}) + describe('agent templates', () => { afterEach(() => vi.unstubAllGlobals()) diff --git a/test/sandbox.test.ts b/test/sandbox.test.ts new file mode 100644 index 0000000..265852a --- /dev/null +++ b/test/sandbox.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import { + collectInputs, + parseAssignment, + parseEnvFile, + parseJsonVars, +} from '../src/commands/sandbox' + +describe('parseJsonVars', () => { + it('parses a flat object of name → value', () => { + expect(parseJsonVars('{"A":"1","B":"two"}')).toEqual([ + { name: 'A', value: '1' }, + { name: 'B', value: 'two' }, + ]) + }) + + it('accepts an empty object', () => { + expect(parseJsonVars('{}')).toEqual([]) + }) + + it('throws on invalid JSON', () => { + expect(() => parseJsonVars('{not json}')).toThrow(/invalid JSON/) + }) + + it('rejects a top-level array', () => { + expect(() => parseJsonVars('["A","B"]')).toThrow(/object of variable name/) + }) + + it('rejects non-string values', () => { + expect(() => parseJsonVars('{"A":1}')).toThrow(/value for 'A' must be a string/) + expect(() => parseJsonVars('{"A":{"nested":"x"}}')).toThrow(/value for 'A' must be a string/) + }) +}) + +describe('parseAssignment', () => { + it('parses KEY=VALUE', () => { + expect(parseAssignment('A=1')).toEqual({ name: 'A', value: '1' }) + }) + + it('splits on the first = so values may contain =', () => { + expect(parseAssignment('URL=https://x.test/?a=b')).toEqual({ + name: 'URL', + value: 'https://x.test/?a=b', + }) + }) + + it('strips a leading `export ` and surrounding quotes', () => { + expect(parseAssignment('export TOKEN="abc"')).toEqual({ name: 'TOKEN', value: 'abc' }) + }) + + it('returns null when there is no =', () => { + expect(parseAssignment('NOEQUALS')).toBeNull() + }) +}) + +describe('collectInputs', () => { + it('collects multiple inline assignments', () => { + expect(collectInputs(['A=1', 'B=2'], undefined)).toEqual([ + { name: 'A', value: '1' }, + { name: 'B', value: '2' }, + ]) + }) + + it('throws on an inline arg with no =', () => { + expect(() => collectInputs(['A=1', 'BAD'], undefined)).toThrow(/invalid assignment 'BAD'/) + }) + + it('throws when given nothing', () => { + expect(() => collectInputs([], undefined)).toThrow(/provide KEY=VALUE/) + }) +}) + +describe('parseEnvFile', () => { + it('parses KEY=VALUE pairs', () => { + expect(parseEnvFile('A=1\nB=two')).toEqual([ + { name: 'A', value: '1' }, + { name: 'B', value: 'two' }, + ]) + }) + + it('skips blank lines and # comments', () => { + expect(parseEnvFile('\n# a comment\nA=1\n\n')).toEqual([{ name: 'A', value: '1' }]) + }) + + it('strips a leading `export `', () => { + expect(parseEnvFile('export TOKEN=abc')).toEqual([{ name: 'TOKEN', value: 'abc' }]) + }) + + it('splits on the first = so values may contain =', () => { + expect(parseEnvFile('URL=https://x.test/?a=b')).toEqual([ + { name: 'URL', value: 'https://x.test/?a=b' }, + ]) + }) + + it('strips matching surrounding quotes', () => { + expect(parseEnvFile(`A="quoted"\nB='single'\nC="mismatch'`)).toEqual([ + { name: 'A', value: 'quoted' }, + { name: 'B', value: 'single' }, + { name: 'C', value: `"mismatch'` }, + ]) + }) + + it('ignores lines with no =', () => { + expect(parseEnvFile('NOEQUALS\nA=1')).toEqual([{ name: 'A', value: '1' }]) + }) +})