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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ agent config list # list saved agent configs
agent config get <config-id> # 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
Expand Down
2 changes: 2 additions & 0 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -19,6 +20,7 @@ registerLogin(program)
registerMe(program)
registerRun(program)
registerConfig(program)
registerSandbox(program)
registerTemplate(program)
registerUsage(program)
registerPing(program)
Expand Down
160 changes: 160 additions & 0 deletions src/commands/sandbox.ts
Original file line number Diff line number Diff line change
@@ -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 <path>', '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 <name>')
.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 <path>')
}
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)]),
)
}
34 changes: 34 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ import type {
CliAuthStart,
CreateAgentConfigRequest,
CreatedAgentConfig,
GetSandboxVariablesResponse,
ListAgentConfigsResponse,
ListAgentRunsQuery,
ListAgentRunsResponse,
ListAgentTemplatesResponse,
SandboxVariableInput,
SandboxVariableSummary,
SavedAgentConfig,
StartAgentRunRequest,
UsageDashboard,
Expand Down Expand Up @@ -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<SandboxVariableSummary[]> {
const res = await this.request<GetSandboxVariablesResponse>(
'GET',
'/v1/sandboxes/variables',
)
return res.variables
}

async putSandboxVariables(
variables: SandboxVariableInput[],
): Promise<SandboxVariableSummary[]> {
const res = await this.request<GetSandboxVariablesResponse>(
'PUT',
'/v1/sandboxes/variables',
{ variables },
)
return res.variables
}

async deleteSandboxVariable(name: string): Promise<SandboxVariableSummary[]> {
const res = await this.request<GetSandboxVariablesResponse>(
'DELETE',
`/v1/sandboxes/variables/${encodeURIComponent(name)}`,
)
return res.variables
}

// ---------------------------- agent templates ---------------------------

async listAgentTemplates(): Promise<AgentTemplate[]> {
Expand Down
24 changes: 24 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
46 changes: 46 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
Loading
Loading