Add openagents box CLI commands for cloud computer sandbox management

6f575f466a7b · AtlantisPleb · · parent 97f6a88c216d

Add openagents box CLI commands for cloud computer sandbox management

Implements box list, create, view, exec, stop, run, runs, and fanout
commands backed by BoxClient for conversation-owned Cloud Computer
sandboxes.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • added packages/openagents-cli/src/box-client.ts
  • added packages/openagents-cli/src/box-command.ts
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/runtime.ts
  • added packages/openagents-cli/test/box-command.test.ts

Diff

7 files changed, +1220 -3

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2497,
7
    "filesScanned": 2498,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:aa65b0babe6932defea3bc307894b66a1655cc50e6f8c1c281d7c60e48d62913",
4
  "sourceDigest": "sha256:16994e22e7a28dfde0b696b8d6acf958710b2b4e9d0ff7da0baf17653d12f3e5",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (85 tracked test files)"
1879
          "ref": "packages/openagents-cli (86 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/box-client.ts added +539

@@ -0,0 +1,539 @@

1
/**
2
 * The Box client for conversation-owned Cloud Computer sandbox VMs.
3
 *
4
 * Interacts with OpenAgents backend at `/api/v1/conversations/:id/boxes`
5
 * with support for listing, creating, inspecting, commanding, stopping,
6
 * running durable jobs, and fanout planning.
7
 */
8
9
import { Effect, Layer } from "effect";
10
import * as Context from "effect/Context";
11
12
import { ApiTransport } from "./api-transport.js";
13
import { API_VERSION_PATH } from "./constants.js";
14
import { ApiError, type CliError } from "./errors.js";
15
import type { AuthenticatedApi } from "./repository-client.js";
16
import { asRecord, asRows, asText, makeTrackerRequest } from "./tracker-request.js";
17
18
export interface BoxRecord {
19
  readonly box_id: string;
20
  readonly label?: string | null;
21
  readonly state: string;
22
  readonly setup_status: string;
23
  readonly created_at: string;
24
  readonly updated_at?: string | null;
25
  readonly stopped_at?: string | null;
26
}
27
28
export interface BoxCommandResult {
29
  readonly box_id: string;
30
  readonly exit_code: number;
31
  readonly stdout: string;
32
  readonly stderr: string;
33
  readonly timed_out: boolean;
34
  readonly stdout_truncated: boolean;
35
  readonly stderr_truncated: boolean;
36
}
37
38
export interface BoxRunRecord {
39
  readonly id: string;
40
  readonly box_id: string;
41
  readonly command: string;
42
  readonly state: string;
43
  readonly exit_status?: number | null;
44
  readonly timed_out?: boolean | null;
45
  readonly output_offset?: number | null;
46
  readonly output_base_offset?: number | null;
47
  readonly failure_reason?: string | null;
48
  readonly admitted_at?: string | null;
49
  readonly dispatched_at?: string | null;
50
  readonly started_at?: string | null;
51
  readonly finished_at?: string | null;
52
  readonly deadline_at?: string | null;
53
  readonly cancellation_requested_at?: string | null;
54
  readonly cancellation_effective_at?: string | null;
55
}
56
57
export interface BoxFanoutItem {
58
  readonly position: number;
59
  readonly label: string;
60
  readonly state: string;
61
  readonly box_id?: string | null;
62
  readonly queue_reason?: string | null;
63
  readonly estimated_burn_rate_microusd?: number | null;
64
  readonly admitted_at?: string | null;
65
}
66
67
export interface BoxFanoutPlan {
68
  readonly id: string;
69
  readonly requested_count: number;
70
  readonly admitted: ReadonlyArray<BoxFanoutItem>;
71
  readonly queued: ReadonlyArray<BoxFanoutItem>;
72
  readonly effective_limits?: Record<string, unknown>;
73
  readonly budgeted: boolean;
74
  readonly created_at?: string | null;
75
  readonly updated_at?: string | null;
76
}
77
78
export interface BoxListInput extends AuthenticatedApi {
79
  readonly conversationId?: string;
80
}
81
82
export interface BoxCreateInput extends AuthenticatedApi {
83
  readonly conversationId?: string;
84
  readonly label?: string;
85
}
86
87
export interface BoxViewInput extends AuthenticatedApi {
88
  readonly conversationId?: string;
89
  readonly boxId: string;
90
}
91
92
export interface BoxCommandInput extends AuthenticatedApi {
93
  readonly conversationId?: string;
94
  readonly boxId: string;
95
  readonly command: string;
96
  readonly timeoutSeconds?: number;
97
}
98
99
export interface BoxStopInput extends AuthenticatedApi {
100
  readonly conversationId?: string;
101
  readonly boxId: string;
102
}
103
104
export interface BoxRunCreateInput extends AuthenticatedApi {
105
  readonly conversationId?: string;
106
  readonly boxId: string;
107
  readonly command: string;
108
  readonly idempotencyKey?: string;
109
}
110
111
export interface BoxRunListInput extends AuthenticatedApi {
112
  readonly conversationId?: string;
113
  readonly boxId: string;
114
}
115
116
export interface BoxRunViewInput extends AuthenticatedApi {
117
  readonly conversationId?: string;
118
  readonly boxId: string;
119
  readonly runId: string;
120
}
121
122
export interface BoxRunOutputInput extends AuthenticatedApi {
123
  readonly conversationId?: string;
124
  readonly boxId: string;
125
  readonly runId: string;
126
  readonly offset?: number;
127
}
128
129
export interface BoxRunCancelInput extends AuthenticatedApi {
130
  readonly conversationId?: string;
131
  readonly boxId: string;
132
  readonly runId: string;
133
}
134
135
export interface BoxFanoutInput extends AuthenticatedApi {
136
  readonly conversationId?: string;
137
  readonly count: number;
138
  readonly labels?: ReadonlyArray<string>;
139
  readonly budgeted?: boolean;
140
}
141
142
export interface BoxFanoutViewInput extends AuthenticatedApi {
143
  readonly conversationId?: string;
144
  readonly requestId: string;
145
}
146
147
export interface BoxClientInterface {
148
  readonly resolveConversationId: (input: AuthenticatedApi) => Effect.Effect<string, CliError>;
149
  readonly list: (input: BoxListInput) => Effect.Effect<ReadonlyArray<BoxRecord>, CliError>;
150
  readonly create: (input: BoxCreateInput) => Effect.Effect<BoxRecord, CliError>;
151
  readonly view: (input: BoxViewInput) => Effect.Effect<BoxRecord, CliError>;
152
  readonly exec: (input: BoxCommandInput) => Effect.Effect<BoxCommandResult, CliError>;
153
  readonly stop: (input: BoxStopInput) => Effect.Effect<BoxRecord, CliError>;
154
  readonly startRun: (input: BoxRunCreateInput) => Effect.Effect<BoxRunRecord, CliError>;
155
  readonly listRuns: (input: BoxRunListInput) => Effect.Effect<ReadonlyArray<BoxRunRecord>, CliError>;
156
  readonly viewRun: (input: BoxRunViewInput) => Effect.Effect<BoxRunRecord, CliError>;
157
  readonly runOutput: (input: BoxRunOutputInput) => Effect.Effect<{ run_id: string; output: string }, CliError>;
158
  readonly cancelRun: (input: BoxRunCancelInput) => Effect.Effect<BoxRunRecord, CliError>;
159
  readonly fanout: (input: BoxFanoutInput) => Effect.Effect<BoxFanoutPlan, CliError>;
160
  readonly viewFanout: (input: BoxFanoutViewInput) => Effect.Effect<BoxFanoutPlan, CliError>;
161
}
162
163
export class BoxClient extends Context.Service<BoxClient, BoxClientInterface>()(
164
  "@openagentsinc/cli/BoxClient",
165
) {}
166
167
export const boxClientLayer = Layer.effect(
168
  BoxClient,
169
  Effect.gen(function* () {
170
    const transport = yield* ApiTransport;
171
    const request = makeTrackerRequest(transport);
172
173
    const resolveConversationId = Effect.fn("BoxClient.resolveConversationId")(function* (
174
      input: AuthenticatedApi,
175
    ) {
176
      const response = yield* transport.request({
177
        origin: input.origin,
178
        method: "GET",
179
        path: `${API_VERSION_PATH}/user`,
180
        token: input.token,
181
      });
182
      if (response.status === 200) {
183
        const body = asRecord(response.body);
184
        const convId = asText(body["conversation_id"]) ?? asText(asRecord(body["user"])["conversation_id"]);
185
        if (convId !== undefined) return convId;
186
      }
187
      return yield* new ApiError({
188
        operation: "resolve user conversation",
189
        status: response.status,
190
        message: "Could not find an active conversation for this account.",
191
      });
192
    });
193
194
    const getConvId = (input: AuthenticatedApi & { readonly conversationId?: string }) =>
195
      input.conversationId !== undefined
196
        ? Effect.succeed(input.conversationId)
197
        : resolveConversationId(input);
198
199
    const list = Effect.fn("BoxClient.list")(function* (input: BoxListInput) {
200
      const convId = yield* getConvId(input);
201
      const body = yield* request("list conversation boxes", {
202
        origin: input.origin,
203
        token: input.token,
204
        method: "GET",
205
        path: `${API_VERSION_PATH}/conversations/${encodeURIComponent(convId)}/boxes`,
206
        acceptedStatuses: [200],
207
      });
208
      const rows = asRows(body, "boxes");
209
      return rows.map((row) => ({
210
        box_id: asText(row["box_id"]) ?? "",
211
        label: asText(row["label"]) ?? null,
212
        state: asText(row["state"]) ?? "unknown",
213
        setup_status: asText(row["setup_status"]) ?? "unknown",
214
        created_at: asText(row["created_at"]) ?? "",
215
        updated_at: asText(row["updated_at"]) ?? null,
216
        stopped_at: asText(row["stopped_at"]) ?? null,
217
      }));
218
    });
219
220
    const create = Effect.fn("BoxClient.create")(function* (input: BoxCreateInput) {
221
      const convId = yield* getConvId(input);
222
      const body = yield* request("create box", {
223
        origin: input.origin,
224
        token: input.token,
225
        method: "POST",
226
        path: `${API_VERSION_PATH}/conversations/${encodeURIComponent(convId)}/boxes`,
227
        body: input.label !== undefined ? { label: input.label } : {},
228
        acceptedStatuses: [201],
229
      });
230
      const box = asRecord(asRecord(body)["box"]);
231
      return {
232
        box_id: asText(box["box_id"]) ?? "",
233
        label: asText(box["label"]) ?? null,
234
        state: asText(box["state"]) ?? "unknown",
235
        setup_status: asText(box["setup_status"]) ?? "unknown",
236
        created_at: asText(box["created_at"]) ?? "",
237
        updated_at: asText(box["updated_at"]) ?? null,
238
        stopped_at: asText(box["stopped_at"]) ?? null,
239
      };
240
    });
241
242
    const view = Effect.fn("BoxClient.view")(function* (input: BoxViewInput) {
243
      const convId = yield* getConvId(input);
244
      const body = yield* request("view box", {
245
        origin: input.origin,
246
        token: input.token,
247
        method: "GET",
248
        path: `${API_VERSION_PATH}/conversations/${encodeURIComponent(convId)}/boxes/${encodeURIComponent(input.boxId)}`,
249
        acceptedStatuses: [200],
250
      });
251
      const box = asRecord(asRecord(body)["box"]);
252
      return {
253
        box_id: asText(box["box_id"]) ?? "",
254
        label: asText(box["label"]) ?? null,
255
        state: asText(box["state"]) ?? "unknown",
256
        setup_status: asText(box["setup_status"]) ?? "unknown",
257
        created_at: asText(box["created_at"]) ?? "",
258
        updated_at: asText(box["updated_at"]) ?? null,
259
        stopped_at: asText(box["stopped_at"]) ?? null,
260
      };
261
    });
262
263
    const exec = Effect.fn("BoxClient.exec")(function* (input: BoxCommandInput) {
264
      const convId = yield* getConvId(input);
265
      const body = yield* request("run box command", {
266
        origin: input.origin,
267
        token: input.token,
268
        method: "POST",
269
        path: `${API_VERSION_PATH}/conversations/${encodeURIComponent(convId)}/boxes/${encodeURIComponent(input.boxId)}/commands`,
270
        body: {
271
          command: input.command,
272
          ...(input.timeoutSeconds !== undefined ? { timeout_seconds: input.timeoutSeconds } : {}),
273
        },
274
        acceptedStatuses: [200],
275
      });
276
      const res = asRecord(asRecord(body)["result"]);
277
      return {
278
        box_id: asText(res["box_id"]) ?? input.boxId,
279
        exit_code: typeof res["exit_code"] === "number" ? res["exit_code"] : -1,
280
        stdout: asText(res["stdout"]) ?? "",
281
        stderr: asText(res["stderr"]) ?? "",
282
        timed_out: res["timed_out"] === true,
283
        stdout_truncated: res["stdout_truncated"] === true,
284
        stderr_truncated: res["stderr_truncated"] === true,
285
      };
286
    });
287
288
    const stop = Effect.fn("BoxClient.stop")(function* (input: BoxStopInput) {
289
      const convId = yield* getConvId(input);
290
      const body = yield* request("stop box", {
291
        origin: input.origin,
292
        token: input.token,
293
        method: "POST",
294
        path: `${API_VERSION_PATH}/conversations/${encodeURIComponent(convId)}/boxes/${encodeURIComponent(input.boxId)}/stop`,
295
        acceptedStatuses: [200],
296
      });
297
      const box = asRecord(asRecord(body)["box"]);
298
      return {
299
        box_id: asText(box["box_id"]) ?? "",
300
        label: asText(box["label"]) ?? null,
301
        state: asText(box["state"]) ?? "unknown",
302
        setup_status: asText(box["setup_status"]) ?? "unknown",
303
        created_at: asText(box["created_at"]) ?? "",
304
        updated_at: asText(box["updated_at"]) ?? null,
305
        stopped_at: asText(box["stopped_at"]) ?? null,
306
      };
307
    });
308
309
    const startRun = Effect.fn("BoxClient.startRun")(function* (input: BoxRunCreateInput) {
310
      const convId = yield* getConvId(input);
311
      const body = yield* request("start box run", {
312
        origin: input.origin,
313
        token: input.token,
314
        method: "POST",
315
        path: `${API_VERSION_PATH}/conversations/${encodeURIComponent(convId)}/boxes/${encodeURIComponent(input.boxId)}/runs`,
316
        body: {
317
          command: input.command,
318
          idempotency_key: input.idempotencyKey ?? crypto.randomUUID(),
319
        },
320
        acceptedStatuses: [200, 202],
321
      });
322
      const run = asRecord(asRecord(body)["run"]);
323
      return {
324
        id: asText(run["id"]) ?? "",
325
        box_id: asText(run["box_id"]) ?? input.boxId,
326
        command: asText(run["command"]) ?? input.command,
327
        state: asText(run["state"]) ?? "unknown",
328
        exit_status: typeof run["exit_status"] === "number" ? run["exit_status"] : null,
329
        timed_out: typeof run["timed_out"] === "boolean" ? run["timed_out"] : null,
330
        output_offset: typeof run["output_offset"] === "number" ? run["output_offset"] : null,
331
        output_base_offset: typeof run["output_base_offset"] === "number" ? run["output_base_offset"] : null,
332
        failure_reason: asText(run["failure_reason"]) ?? null,
333
        admitted_at: asText(run["admitted_at"]) ?? null,
334
        dispatched_at: asText(run["dispatched_at"]) ?? null,
335
        started_at: asText(run["started_at"]) ?? null,
336
        finished_at: asText(run["finished_at"]) ?? null,
337
        deadline_at: asText(run["deadline_at"]) ?? null,
338
        cancellation_requested_at: asText(run["cancellation_requested_at"]) ?? null,
339
        cancellation_effective_at: asText(run["cancellation_effective_at"]) ?? null,
340
      };
341
    });
342
343
    const listRuns = Effect.fn("BoxClient.listRuns")(function* (input: BoxRunListInput) {
344
      const convId = yield* getConvId(input);
345
      const body = yield* request("list box runs", {
346
        origin: input.origin,
347
        token: input.token,
348
        method: "GET",
349
        path: `${API_VERSION_PATH}/conversations/${encodeURIComponent(convId)}/boxes/${encodeURIComponent(input.boxId)}/runs`,
350
        acceptedStatuses: [200],
351
      });
352
      const rows = asRows(body, "runs");
353
      return rows.map((run) => ({
354
        id: asText(run["id"]) ?? "",
355
        box_id: asText(run["box_id"]) ?? input.boxId,
356
        command: asText(run["command"]) ?? "",
357
        state: asText(run["state"]) ?? "unknown",
358
        exit_status: typeof run["exit_status"] === "number" ? run["exit_status"] : null,
359
        timed_out: typeof run["timed_out"] === "boolean" ? run["timed_out"] : null,
360
        output_offset: typeof run["output_offset"] === "number" ? run["output_offset"] : null,
361
        output_base_offset: typeof run["output_base_offset"] === "number" ? run["output_base_offset"] : null,
362
        failure_reason: asText(run["failure_reason"]) ?? null,
363
        admitted_at: asText(run["admitted_at"]) ?? null,
364
        dispatched_at: asText(run["dispatched_at"]) ?? null,
365
        started_at: asText(run["started_at"]) ?? null,
366
        finished_at: asText(run["finished_at"]) ?? null,
367
        deadline_at: asText(run["deadline_at"]) ?? null,
368
        cancellation_requested_at: asText(run["cancellation_requested_at"]) ?? null,
369
        cancellation_effective_at: asText(run["cancellation_effective_at"]) ?? null,
370
      }));
371
    });
372
373
    const viewRun = Effect.fn("BoxClient.viewRun")(function* (input: BoxRunViewInput) {
374
      const convId = yield* getConvId(input);
375
      const body = yield* request("view box run", {
376
        origin: input.origin,
377
        token: input.token,
378
        method: "GET",
379
        path: `${API_VERSION_PATH}/conversations/${encodeURIComponent(convId)}/boxes/${encodeURIComponent(input.boxId)}/runs/${encodeURIComponent(input.runId)}`,
380
        acceptedStatuses: [200],
381
      });
382
      const run = asRecord(asRecord(body)["run"]);
383
      return {
384
        id: asText(run["id"]) ?? input.runId,
385
        box_id: asText(run["box_id"]) ?? input.boxId,
386
        command: asText(run["command"]) ?? "",
387
        state: asText(run["state"]) ?? "unknown",
388
        exit_status: typeof run["exit_status"] === "number" ? run["exit_status"] : null,
389
        timed_out: typeof run["timed_out"] === "boolean" ? run["timed_out"] : null,
390
        output_offset: typeof run["output_offset"] === "number" ? run["output_offset"] : null,
391
        output_base_offset: typeof run["output_base_offset"] === "number" ? run["output_base_offset"] : null,
392
        failure_reason: asText(run["failure_reason"]) ?? null,
393
        admitted_at: asText(run["admitted_at"]) ?? null,
394
        dispatched_at: asText(run["dispatched_at"]) ?? null,
395
        started_at: asText(run["started_at"]) ?? null,
396
        finished_at: asText(run["finished_at"]) ?? null,
397
        deadline_at: asText(run["deadline_at"]) ?? null,
398
        cancellation_requested_at: asText(run["cancellation_requested_at"]) ?? null,
399
        cancellation_effective_at: asText(run["cancellation_effective_at"]) ?? null,
400
      };
401
    });
402
403
    const runOutput = Effect.fn("BoxClient.runOutput")(function* (input: BoxRunOutputInput) {
404
      const convId = yield* getConvId(input);
405
      const query = input.offset !== undefined ? `?offset=${String(input.offset)}` : "";
406
      const body = yield* request("get box run output", {
407
        origin: input.origin,
408
        token: input.token,
409
        method: "GET",
410
        path: `${API_VERSION_PATH}/conversations/${encodeURIComponent(convId)}/boxes/${encodeURIComponent(input.boxId)}/runs/${encodeURIComponent(input.runId)}/output${query}`,
411
        acceptedStatuses: [200],
412
      });
413
      const res = asRecord(body);
414
      return {
415
        run_id: asText(res["run_id"]) ?? input.runId,
416
        output: asText(res["output"]) ?? "",
417
      };
418
    });
419
420
    const cancelRun = Effect.fn("BoxClient.cancelRun")(function* (input: BoxRunCancelInput) {
421
      const convId = yield* getConvId(input);
422
      const body = yield* request("cancel box run", {
423
        origin: input.origin,
424
        token: input.token,
425
        method: "POST",
426
        path: `${API_VERSION_PATH}/conversations/${encodeURIComponent(convId)}/boxes/${encodeURIComponent(input.boxId)}/runs/${encodeURIComponent(input.runId)}/cancel`,
427
        acceptedStatuses: [200, 202],
428
      });
429
      const run = asRecord(asRecord(body)["run"]);
430
      return {
431
        id: asText(run["id"]) ?? input.runId,
432
        box_id: asText(run["box_id"]) ?? input.boxId,
433
        command: asText(run["command"]) ?? "",
434
        state: asText(run["state"]) ?? "unknown",
435
        exit_status: typeof run["exit_status"] === "number" ? run["exit_status"] : null,
436
        timed_out: typeof run["timed_out"] === "boolean" ? run["timed_out"] : null,
437
        output_offset: typeof run["output_offset"] === "number" ? run["output_offset"] : null,
438
        output_base_offset: typeof run["output_base_offset"] === "number" ? run["output_base_offset"] : null,
439
        failure_reason: asText(run["failure_reason"]) ?? null,
440
        admitted_at: asText(run["admitted_at"]) ?? null,
441
        dispatched_at: asText(run["dispatched_at"]) ?? null,
442
        started_at: asText(run["started_at"]) ?? null,
443
        finished_at: asText(run["finished_at"]) ?? null,
444
        deadline_at: asText(run["deadline_at"]) ?? null,
445
        cancellation_requested_at: asText(run["cancellation_requested_at"]) ?? null,
446
        cancellation_effective_at: asText(run["cancellation_effective_at"]) ?? null,
447
      };
448
    });
449
450
    const fanout = Effect.fn("BoxClient.fanout")(function* (input: BoxFanoutInput) {
451
      const convId = yield* getConvId(input);
452
      const body = yield* request("request box fanout", {
453
        origin: input.origin,
454
        token: input.token,
455
        method: "POST",
456
        path: `${API_VERSION_PATH}/conversations/${encodeURIComponent(convId)}/boxes/fanout`,
457
        body: {
458
          count: input.count,
459
          ...(input.labels !== undefined ? { labels: input.labels } : {}),
460
          ...(input.budgeted !== undefined ? { budgeted: input.budgeted } : {}),
461
        },
462
        acceptedStatuses: [200, 202],
463
      });
464
      const plan = asRecord(asRecord(body)["plan"]);
465
      const admittedRows = asRows(plan, "admitted");
466
      const queuedRows = asRows(plan, "queued");
467
      const parseItem = (item: Record<string, unknown>): BoxFanoutItem => ({
468
        position: typeof item["position"] === "number" ? item["position"] : 0,
469
        label: asText(item["label"]) ?? "",
470
        state: asText(item["state"]) ?? "unknown",
471
        box_id: asText(item["box_id"]) ?? null,
472
        queue_reason: asText(item["queue_reason"]) ?? null,
473
        estimated_burn_rate_microusd: typeof item["estimated_burn_rate_microusd"] === "number" ? item["estimated_burn_rate_microusd"] : null,
474
        admitted_at: asText(item["admitted_at"]) ?? null,
475
      });
476
477
      return {
478
        id: asText(plan["id"]) ?? "",
479
        requested_count: typeof plan["requested_count"] === "number" ? plan["requested_count"] : input.count,
480
        admitted: admittedRows.map(parseItem),
481
        queued: queuedRows.map(parseItem),
482
        effective_limits: asRecord(plan["effective_limits"]),
483
        budgeted: plan["budgeted"] === true,
484
        created_at: asText(plan["created_at"]) ?? null,
485
        updated_at: asText(plan["updated_at"]) ?? null,
486
      };
487
    });
488
489
    const viewFanout = Effect.fn("BoxClient.viewFanout")(function* (input: BoxFanoutViewInput) {
490
      const convId = yield* getConvId(input);
491
      const body = yield* request("view box fanout", {
492
        origin: input.origin,
493
        token: input.token,
494
        method: "GET",
495
        path: `${API_VERSION_PATH}/conversations/${encodeURIComponent(convId)}/boxes/fanout/${encodeURIComponent(input.requestId)}`,
496
        acceptedStatuses: [200],
497
      });
498
      const plan = asRecord(asRecord(body)["plan"]);
499
      const admittedRows = asRows(plan, "admitted");
500
      const queuedRows = asRows(plan, "queued");
501
      const parseItem = (item: Record<string, unknown>): BoxFanoutItem => ({
502
        position: typeof item["position"] === "number" ? item["position"] : 0,
503
        label: asText(item["label"]) ?? "",
504
        state: asText(item["state"]) ?? "unknown",
505
        box_id: asText(item["box_id"]) ?? null,
506
        queue_reason: asText(item["queue_reason"]) ?? null,
507
        estimated_burn_rate_microusd: typeof item["estimated_burn_rate_microusd"] === "number" ? item["estimated_burn_rate_microusd"] : null,
508
        admitted_at: asText(item["admitted_at"]) ?? null,
509
      });
510
511
      return {
512
        id: asText(plan["id"]) ?? input.requestId,
513
        requested_count: typeof plan["requested_count"] === "number" ? plan["requested_count"] : 0,
514
        admitted: admittedRows.map(parseItem),
515
        queued: queuedRows.map(parseItem),
516
        effective_limits: asRecord(plan["effective_limits"]),
517
        budgeted: plan["budgeted"] === true,
518
        created_at: asText(plan["created_at"]) ?? null,
519
        updated_at: asText(plan["updated_at"]) ?? null,
520
      };
521
    });
522
523
    return BoxClient.of({
524
      resolveConversationId,
525
      list,
526
      create,
527
      view,
528
      exec,
529
      stop,
530
      startRun,
531
      listRuns,
532
      viewRun,
533
      runOutput,
534
      cancelRun,
535
      fanout,
536
      viewFanout,
537
    });
538
  }),
539
);
packages/openagents-cli/src/box-command.ts added +482

@@ -0,0 +1,482 @@

1
/**
2
 * CLI command definitions for `openagents box`.
3
 */
4
5
import { Effect, Option } from "effect";
6
import { Argument, Command, Flag } from "effect/unstable/cli";
7
8
import { BoxClient, type BoxRecord, type BoxRunRecord, type BoxFanoutPlan } from "./box-client.js";
9
import { type EndpointOverrides, type Profile } from "./endpoint.js";
10
import { InputError } from "./errors.js";
11
import { Output, type OutputMode } from "./output.js";
12
import { resolveApiSession } from "./session.js";
13
14
interface SharedFlags {
15
  readonly profile: Option.Option<Profile>;
16
  readonly apiUrl: Option.Option<string>;
17
  readonly json: boolean;
18
  readonly noColor: boolean;
19
}
20
21
const endpointOverrides = (flags: {
22
  readonly profile: Option.Option<Profile>;
23
  readonly apiUrl: Option.Option<string>;
24
}): EndpointOverrides => ({ profile: flags.profile, apiUrl: flags.apiUrl });
25
26
const outputMode = (json: boolean): OutputMode => (json ? "json" : "human");
27
28
const conversationIdFlag = Flag.string("conversation").pipe(
29
  Flag.optional,
30
  Flag.withDescription("Conversation ID override"),
31
);
32
33
const boxIdArgument = Argument.string("box_id").pipe(
34
  Argument.withDescription("Box VM ID (e.g. bx_8bhkse3n)"),
35
);
36
37
const runIdArgument = Argument.string("run_id").pipe(
38
  Argument.withDescription("Box Run ID"),
39
);
40
41
const labelFlag = Flag.string("label").pipe(
42
  Flag.optional,
43
  Flag.withDescription("Optional label for the box"),
44
);
45
46
const timeoutFlag = Flag.integer("timeout").pipe(
47
  Flag.optional,
48
  Flag.withDescription("Timeout in seconds for command execution"),
49
);
50
51
const countFlag = Flag.integer("count").pipe(
52
  Flag.withDescription("Number of boxes to request"),
53
);
54
55
const budgetedFlag = Flag.boolean("budgeted").pipe(
56
  Flag.withDescription("Allow scaling up to budgeted limit (e.g. 10-15)"),
57
);
58
59
const labelsFlag = Flag.string("labels").pipe(
60
  Flag.optional,
61
  Flag.withDescription("Comma-separated list of labels for fanout boxes"),
62
);
63
64
const offsetFlag = Flag.integer("offset").pipe(
65
  Flag.optional,
66
  Flag.withDescription("Byte offset to start reading output from"),
67
);
68
69
const boxListHuman = (boxes: ReadonlyArray<BoxRecord>): ReadonlyArray<string> => {
70
  if (boxes.length === 0) return ["No boxes provisioned for this conversation."];
71
  const lines: string[] = ["BOX ID        STATE       SETUP     LABEL        CREATED"];
72
  for (const b of boxes) {
73
    const id = b.box_id.padEnd(13, " ");
74
    const state = b.state.padEnd(11, " ");
75
    const setup = b.setup_status.padEnd(9, " ");
76
    const label = (b.label ?? "-").padEnd(12, " ");
77
    const created = b.created_at;
78
    lines.push(`${id} ${state} ${setup} ${label} ${created}`);
79
  }
80
  return lines;
81
};
82
83
const boxViewHuman = (b: BoxRecord): ReadonlyArray<string> => [
84
  `Box ID:       ${b.box_id}`,
85
  `State:        ${b.state}`,
86
  `Setup Status: ${b.setup_status}`,
87
  `Label:        ${b.label ?? "-"}`,
88
  `Created:      ${b.created_at}`,
89
  ...(b.stopped_at ? [`Stopped:      ${b.stopped_at}`] : []),
90
];
91
92
const boxRunListHuman = (runs: ReadonlyArray<BoxRunRecord>): ReadonlyArray<string> => {
93
  if (runs.length === 0) return ["No runs recorded for this box."];
94
  const lines: string[] = ["RUN ID                               STATE      EXIT  COMMAND"];
95
  for (const r of runs) {
96
    const id = r.id.padEnd(36, " ");
97
    const state = r.state.padEnd(10, " ");
98
    const exit = (r.exit_status !== null && r.exit_status !== undefined ? String(r.exit_status) : "-").padEnd(5, " ");
99
    const cmd = r.command.length > 40 ? r.command.slice(0, 37) + "..." : r.command;
100
    lines.push(`${id} ${state} ${exit} ${cmd}`);
101
  }
102
  return lines;
103
};
104
105
const boxRunViewHuman = (r: BoxRunRecord): ReadonlyArray<string> => [
106
  `Run ID:       ${r.id}`,
107
  `Box ID:       ${r.box_id}`,
108
  `State:        ${r.state}`,
109
  `Command:      ${r.command}`,
110
  `Exit Status:  ${r.exit_status !== null && r.exit_status !== undefined ? String(r.exit_status) : "-"}`,
111
  `Timed Out:    ${r.timed_out ? "yes" : "no"}`,
112
  ...(r.failure_reason ? [`Failure:      ${r.failure_reason}`] : []),
113
  `Admitted:     ${r.admitted_at ?? "-"}`,
114
  `Dispatched:   ${r.dispatched_at ?? "-"}`,
115
  `Started:      ${r.started_at ?? "-"}`,
116
  `Finished:     ${r.finished_at ?? "-"}`,
117
];
118
119
const boxFanoutHuman = (plan: BoxFanoutPlan): ReadonlyArray<string> => [
120
  `Fanout Plan:  ${plan.id}`,
121
  `Requested:    ${String(plan.requested_count)} boxes (Budgeted: ${plan.budgeted ? "yes" : "no"})`,
122
  `Admitted:     ${String(plan.admitted.length)}`,
123
  ...plan.admitted.map((item) => `  [#${String(item.position)}] ${item.label} -> ${item.box_id ?? "allocating"} (${item.state})`),
124
  `Queued:       ${String(plan.queued.length)}`,
125
  ...plan.queued.map((item) => `  [#${String(item.position)}] ${item.label} (Reason: ${item.queue_reason ?? "waiting for capacity"})`),
126
];
127
128
export const makeBoxCommand = <R>(root: Effect.Effect<SharedFlags, never, R>) => {
129
  const boxListCommand = Command.make(
130
    "list",
131
    { conversation: conversationIdFlag },
132
    ({ conversation }) =>
133
      Effect.gen(function* () {
134
        const flags = yield* root;
135
        const session = yield* resolveApiSession(endpointOverrides(flags));
136
        const client = yield* BoxClient;
137
        const output = yield* Output;
138
        const boxes = yield* client.list({
139
          origin: session.endpoint.origin,
140
          token: session.token,
141
          ...(Option.isNone(conversation) ? {} : { conversationId: conversation.value }),
142
        });
143
        yield* output.write(
144
          {
145
            value: { boxes },
146
            human: boxListHuman(boxes),
147
          },
148
          outputMode(flags.json),
149
        );
150
      }),
151
  ).pipe(Command.withDescription("List active and recent Box VMs in a conversation"));
152
153
  const boxCreateCommand = Command.make(
154
    "create",
155
    { conversation: conversationIdFlag, label: labelFlag },
156
    ({ conversation, label }) =>
157
      Effect.gen(function* () {
158
        const flags = yield* root;
159
        const session = yield* resolveApiSession(endpointOverrides(flags));
160
        const client = yield* BoxClient;
161
        const output = yield* Output;
162
        const box = yield* client.create({
163
          origin: session.endpoint.origin,
164
          token: session.token,
165
          ...(Option.isNone(conversation) ? {} : { conversationId: conversation.value }),
166
          ...(Option.isNone(label) ? {} : { label: label.value }),
167
        });
168
        yield* output.write(
169
          {
170
            value: { box },
171
            human: [
172
              `Provisioned Box ${box.box_id} (state: ${box.state}, setup: ${box.setup_status}).`,
173
              ...(box.label ? [`Label: ${box.label}`] : []),
174
            ],
175
          },
176
          outputMode(flags.json),
177
        );
178
      }),
179
  ).pipe(Command.withDescription("Provision a new Box VM"));
180
181
  const boxViewCommand = Command.make(
182
    "view",
183
    { boxId: boxIdArgument, conversation: conversationIdFlag },
184
    ({ boxId, conversation }) =>
185
      Effect.gen(function* () {
186
        const flags = yield* root;
187
        const session = yield* resolveApiSession(endpointOverrides(flags));
188
        const client = yield* BoxClient;
189
        const output = yield* Output;
190
        const box = yield* client.view({
191
          origin: session.endpoint.origin,
192
          token: session.token,
193
          boxId,
194
          ...(Option.isNone(conversation) ? {} : { conversationId: conversation.value }),
195
        });
196
        yield* output.write(
197
          {
198
            value: { box },
199
            human: boxViewHuman(box),
200
          },
201
          outputMode(flags.json),
202
        );
203
      }),
204
  ).pipe(Command.withDescription("Inspect a Box VM's status and lifecycle"));
205
206
  const boxExecCommand = Command.make(
207
    "exec",
208
    {
209
      boxId: boxIdArgument,
210
      conversation: conversationIdFlag,
211
      timeout: timeoutFlag,
212
      command: Argument.string("command").pipe(
213
        Argument.withDescription("Command to execute"),
214
        Argument.variadic({ min: 1 }),
215
      ),
216
    },
217
    ({ boxId, command, conversation, timeout }) =>
218
      Effect.gen(function* () {
219
        const flags = yield* root;
220
        const session = yield* resolveApiSession(endpointOverrides(flags));
221
        const client = yield* BoxClient;
222
        const output = yield* Output;
223
        const cmdStr = command.join(" ");
224
        const result = yield* client.exec({
225
          origin: session.endpoint.origin,
226
          token: session.token,
227
          boxId,
228
          command: cmdStr,
229
          ...(Option.isNone(conversation) ? {} : { conversationId: conversation.value }),
230
          ...(Option.isNone(timeout) ? {} : { timeoutSeconds: timeout.value }),
231
        });
232
        yield* output.write(
233
          {
234
            value: { result },
235
            human: [
236
              ...(result.stdout ? [result.stdout.trimEnd()] : []),
237
              ...(result.stderr ? [`[STDERR] ${result.stderr.trimEnd()}`] : []),
238
              ...(result.timed_out ? ["[TIMED OUT]"] : []),
239
            ],
240
          },
241
          outputMode(flags.json),
242
        );
243
        if (result.exit_code !== 0) {
244
          process.exitCode = result.exit_code;
245
        }
246
      }),
247
  ).pipe(Command.withDescription("Execute a command synchronously on a Box VM"));
248
249
  const boxStopCommand = Command.make(
250
    "stop",
251
    { boxId: boxIdArgument, conversation: conversationIdFlag },
252
    ({ boxId, conversation }) =>
253
      Effect.gen(function* () {
254
        const flags = yield* root;
255
        const session = yield* resolveApiSession(endpointOverrides(flags));
256
        const client = yield* BoxClient;
257
        const output = yield* Output;
258
        const box = yield* client.stop({
259
          origin: session.endpoint.origin,
260
          token: session.token,
261
          boxId,
262
          ...(Option.isNone(conversation) ? {} : { conversationId: conversation.value }),
263
        });
264
        yield* output.write(
265
          {
266
            value: { box },
267
            human: [`Stopped Box ${box.box_id} (state: ${box.state}). Slot released.`],
268
          },
269
          outputMode(flags.json),
270
        );
271
      }),
272
  ).pipe(Command.withDescription("Stop and snapshot a Box VM to release capacity"));
273
274
  const boxRunCommand = Command.make(
275
    "run",
276
    {
277
      boxId: boxIdArgument,
278
      conversation: conversationIdFlag,
279
      command: Argument.string("command").pipe(
280
        Argument.withDescription("Command to execute as a background run"),
281
        Argument.variadic({ min: 1 }),
282
      ),
283
    },
284
    ({ boxId, command, conversation }) =>
285
      Effect.gen(function* () {
286
        const flags = yield* root;
287
        const session = yield* resolveApiSession(endpointOverrides(flags));
288
        const client = yield* BoxClient;
289
        const output = yield* Output;
290
        const cmdStr = command.join(" ");
291
        const run = yield* client.startRun({
292
          origin: session.endpoint.origin,
293
          token: session.token,
294
          boxId,
295
          command: cmdStr,
296
          ...(Option.isNone(conversation) ? {} : { conversationId: conversation.value }),
297
        });
298
        yield* output.write(
299
          {
300
            value: { run },
301
            human: [
302
              `Started background run ${run.id} on Box ${run.box_id}.`,
303
              `State: ${run.state}`,
304
              `Inspect with: openagents box runs view ${run.box_id} ${run.id}`,
305
            ],
306
          },
307
          outputMode(flags.json),
308
        );
309
      }),
310
  ).pipe(Command.withDescription("Start a durable background command run on a Box VM"));
311
312
  const boxRunsListCommand = Command.make(
313
    "list",
314
    { boxId: boxIdArgument, conversation: conversationIdFlag },
315
    ({ boxId, conversation }) =>
316
      Effect.gen(function* () {
317
        const flags = yield* root;
318
        const session = yield* resolveApiSession(endpointOverrides(flags));
319
        const client = yield* BoxClient;
320
        const output = yield* Output;
321
        const runs = yield* client.listRuns({
322
          origin: session.endpoint.origin,
323
          token: session.token,
324
          boxId,
325
          ...(Option.isNone(conversation) ? {} : { conversationId: conversation.value }),
326
        });
327
        yield* output.write(
328
          {
329
            value: { runs },
330
            human: boxRunListHuman(runs),
331
          },
332
          outputMode(flags.json),
333
        );
334
      }),
335
  ).pipe(Command.withDescription("List durable runs on a Box VM"));
336
337
  const boxRunsViewCommand = Command.make(
338
    "view",
339
    { boxId: boxIdArgument, runId: runIdArgument, conversation: conversationIdFlag },
340
    ({ boxId, conversation, runId }) =>
341
      Effect.gen(function* () {
342
        const flags = yield* root;
343
        const session = yield* resolveApiSession(endpointOverrides(flags));
344
        const client = yield* BoxClient;
345
        const output = yield* Output;
346
        const run = yield* client.viewRun({
347
          origin: session.endpoint.origin,
348
          token: session.token,
349
          boxId,
350
          runId,
351
          ...(Option.isNone(conversation) ? {} : { conversationId: conversation.value }),
352
        });
353
        yield* output.write(
354
          {
355
            value: { run },
356
            human: boxRunViewHuman(run),
357
          },
358
          outputMode(flags.json),
359
        );
360
      }),
361
  ).pipe(Command.withDescription("View details of a Box run"));
362
363
  const boxRunsOutputCommand = Command.make(
364
    "output",
365
    {
366
      boxId: boxIdArgument,
367
      runId: runIdArgument,
368
      conversation: conversationIdFlag,
369
      offset: offsetFlag,
370
    },
371
    ({ boxId, conversation, offset, runId }) =>
372
      Effect.gen(function* () {
373
        const flags = yield* root;
374
        const session = yield* resolveApiSession(endpointOverrides(flags));
375
        const client = yield* BoxClient;
376
        const output = yield* Output;
377
        const result = yield* client.runOutput({
378
          origin: session.endpoint.origin,
379
          token: session.token,
380
          boxId,
381
          runId,
382
          ...(Option.isNone(conversation) ? {} : { conversationId: conversation.value }),
383
          ...(Option.isNone(offset) ? {} : { offset: offset.value }),
384
        });
385
        yield* output.write(
386
          {
387
            value: result,
388
            human: [result.output],
389
          },
390
          outputMode(flags.json),
391
        );
392
      }),
393
  ).pipe(Command.withDescription("Read bounded output stream from a Box run"));
394
395
  const boxRunsCancelCommand = Command.make(
396
    "cancel",
397
    { boxId: boxIdArgument, runId: runIdArgument, conversation: conversationIdFlag },
398
    ({ boxId, conversation, runId }) =>
399
      Effect.gen(function* () {
400
        const flags = yield* root;
401
        const session = yield* resolveApiSession(endpointOverrides(flags));
402
        const client = yield* BoxClient;
403
        const output = yield* Output;
404
        const run = yield* client.cancelRun({
405
          origin: session.endpoint.origin,
406
          token: session.token,
407
          boxId,
408
          runId,
409
          ...(Option.isNone(conversation) ? {} : { conversationId: conversation.value }),
410
        });
411
        yield* output.write(
412
          {
413
            value: { run },
414
            human: [`Requested cancellation for run ${run.id} (state: ${run.state}).`],
415
          },
416
          outputMode(flags.json),
417
        );
418
      }),
419
  ).pipe(Command.withDescription("Cancel an active Box run"));
420
421
  const boxRunsGroupCommand = Command.make("runs").pipe(
422
    Command.withDescription("Manage durable runs on Box VMs"),
423
    Command.withSubcommands([
424
      boxRunsListCommand,
425
      boxRunsViewCommand,
426
      boxRunsOutputCommand,
427
      boxRunsCancelCommand,
428
    ]),
429
  );
430
431
  const boxFanoutCommand = Command.make(
432
    "fanout",
433
    {
434
      count: countFlag,
435
      labels: labelsFlag,
436
      budgeted: budgetedFlag,
437
      conversation: conversationIdFlag,
438
    },
439
    ({ budgeted, count, conversation, labels }) =>
440
      Effect.gen(function* () {
441
        if (count < 1) {
442
          return yield* new InputError({ message: "--count must be at least 1." });
443
        }
444
        const flags = yield* root;
445
        const session = yield* resolveApiSession(endpointOverrides(flags));
446
        const client = yield* BoxClient;
447
        const output = yield* Output;
448
        const parsedLabels = Option.isSome(labels)
449
          ? labels.value.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
450
          : undefined;
451
        const plan = yield* client.fanout({
452
          origin: session.endpoint.origin,
453
          token: session.token,
454
          count,
455
          ...(parsedLabels ? { labels: parsedLabels } : {}),
456
          budgeted,
457
          ...(Option.isNone(conversation) ? {} : { conversationId: conversation.value }),
458
        });
459
        yield* output.write(
460
          {
461
            value: { plan },
462
            human: boxFanoutHuman(plan),
463
          },
464
          outputMode(flags.json),
465
        );
466
      }),
467
  ).pipe(Command.withDescription("Request multi-box fanout admission plan"));
468
469
  return Command.make("box").pipe(
470
    Command.withDescription("Manage conversation-owned Box cloud computer sandboxes"),
471
    Command.withSubcommands([
472
      boxListCommand,
473
      boxCreateCommand,
474
      boxViewCommand,
475
      boxExecCommand,
476
      boxStopCommand,
477
      boxRunCommand,
478
      boxRunsGroupCommand,
479
      boxFanoutCommand,
480
    ]),
481
  );
482
};
packages/openagents-cli/src/cli.ts modified +4

@@ -117,6 +117,7 @@ import { ResponsesReplySource } from "./coder-responses.js";

117 117
import { TIER_MODELS, tierForModel, tierUnavailable, type CoderTierId } from "./coder-tiers.js";
118 118
import { ZenReplySource, zenCredential } from "./coder-zen.js";
119 119
import { describeWorkspace } from "./coder-workspace.js";
120
import { makeBoxCommand } from "./box-command.js";
120 121
import { ComputerClient } from "./computer-client.js";
121 122
import { ComputerUp } from "./computer-up.js";
122 123
import {

@@ -4207,6 +4208,8 @@ const traceCommand = makeTraceCommand(rootCommand);

4207 4208
4208 4209
const providerCommand = makeProviderCommand(rootCommand);
4209 4210
4211
const boxCommand = makeBoxCommand(rootCommand);
4212
4210 4213
// The deploy command group: named operator deployment commands over the
4211 4214
// operator-only fleet promotion API from OpenAgentsInc/openagents.com#57.
4212 4215
// It consumes only that API — never `/admin/forge`, SSH, or an internal RPC —

@@ -4586,6 +4589,7 @@ export const openagentsCommand = rootCommand.pipe(

4586 4589
  Command.withSubcommands([
4587 4590
    apiCommand,
4588 4591
    authCommand,
4592
    boxCommand,
4589 4593
    coderCommand,
4590 4594
    delegateCommand,
4591 4595
    computerCommand,
packages/openagents-cli/src/runtime.ts modified +3

@@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";

3 3
import { Layer } from "effect";
4 4
5 5
import { apiTransportNodeLayer, networkPolicyLiveLayer } from "./api-transport.js";
6
import { boxClientLayer } from "./box-client.js";
6 7
import { browserLauncherLayer } from "./browser-launcher.js";
7 8
import { computerConfigurationLayer } from "./computer-config.js";
8 9
import { computerClientLayer } from "./computer-client.js";

@@ -37,6 +38,7 @@ const fleetLayer = fleetClientLayer.pipe(Layer.provide(transportLayer));

37 38
const deviceLayer = deviceClientLayer.pipe(Layer.provide(transportLayer));
38 39
const issueLayer = issueClientLayer.pipe(Layer.provide(transportLayer));
39 40
const projectLayer = projectClientLayer.pipe(Layer.provide(transportLayer));
41
const boxClient = boxClientLayer.pipe(Layer.provide(transportLayer));
40 42
const computerClient = computerClientLayer.pipe(Layer.provide(transportLayer));
41 43
const credentialsLayer = credentialStoreOsLayer.pipe(Layer.provide(NodeServices.layer));
42 44
const pendingAuthorizationLayer = pendingDeviceAuthorizationStoreLayer.pipe(

@@ -85,6 +87,7 @@ export const runtimeLayer = Layer.mergeAll(

85 87
  issueLayer,
86 88
  projectLayer,
87 89
  deviceLayer,
90
  boxClient,
88 91
  computerClient,
89 92
  browserLayer,
90 93
  computerConfiguration,
packages/openagents-cli/test/box-command.test.ts added +189

@@ -0,0 +1,189 @@

1
import * as NodeServices from "@effect/platform-node/NodeServices";
2
import { Effect, Layer } from "effect";
3
import { describe, expect, it } from "vitest";
4
5
import { apiTransportTestLayer, type ApiRequest, type ApiResponse } from "../src/api-transport.js";
6
import { boxClientLayer } from "../src/box-client.js";
7
import { runCliWith } from "../src/cli.js";
8
import { credentialStoreUnavailableLayer } from "../src/credential-store.js";
9
import { environmentLayerFromValues } from "../src/environment.js";
10
import { gitRunnerTestLayer } from "../src/git-runner.js";
11
import { outputTestLayer, type OutputDocument, type OutputMode } from "../src/output.js";
12
import { persistedConfigurationTestLayer } from "../src/persisted-configuration.js";
13
import { requestBodyInputTestLayer } from "../src/request-body-input.js";
14
import { secretInputTestLayer } from "../src/secret-input.js";
15
import { terminalSessionTestLayer } from "../src/terminal-session.js";
16
17
interface Written {
18
  readonly document: OutputDocument;
19
  readonly mode: OutputMode;
20
}
21
22
const harness = (
23
  handler: (input: ApiRequest) => Effect.Effect<ApiResponse, never>,
24
  standardInput: Readonly<Record<string, string>> = {},
25
) => {
26
  const written: Array<Written> = [];
27
  const transport = apiTransportTestLayer(handler);
28
  const layer = Layer.mergeAll(
29
    NodeServices.layer,
30
    environmentLayerFromValues({ token: "test-token" }),
31
    persistedConfigurationTestLayer({}),
32
    terminalSessionTestLayer(false),
33
    credentialStoreUnavailableLayer,
34
    gitRunnerTestLayer(() => Effect.void),
35
    secretInputTestLayer("stdin-token"),
36
    requestBodyInputTestLayer(standardInput),
37
    boxClientLayer.pipe(Layer.provide(transport)),
38
    outputTestLayer((document, mode) =>
39
      Effect.sync(() => {
40
        written.push({ document, mode });
41
      }),
42
    ),
43
  );
44
  return { written, layer };
45
};
46
47
describe("openagents box CLI commands", () => {
48
  it("lists boxes for the conversation", async () => {
49
    const { layer, written } = harness((req) => {
50
      if (req.path === "/api/v1/user") {
51
        return Effect.succeed({
52
          status: 200,
53
          body: { conversation_id: "conv-123" },
54
        });
55
      }
56
      if (req.path === "/api/v1/conversations/conv-123/boxes") {
57
        return Effect.succeed({
58
          status: 200,
59
          body: {
60
            boxes: [
61
              {
62
                box_id: "bx_test123",
63
                label: "worker-1",
64
                state: "ready",
65
                setup_status: "done",
66
                created_at: "2026-08-25T12:00:00Z",
67
                stopped_at: null,
68
              },
69
            ],
70
          },
71
        });
72
      }
73
      return Effect.succeed({ status: 404, body: {} });
74
    });
75
76
    await Effect.runPromise(
77
      runCliWith(["box", "list"]).pipe(Effect.provide(layer)),
78
    );
79
80
    expect(written.length).toBe(1);
81
    const human = written[0]!.document.human;
82
    expect(human.some((line) => line.includes("bx_test123"))).toBe(true);
83
    expect(human.some((line) => line.includes("worker-1"))).toBe(true);
84
  });
85
86
  it("provisions a new box", async () => {
87
    const { layer, written } = harness((req) => {
88
      if (req.path === "/api/v1/user") {
89
        return Effect.succeed({
90
          status: 200,
91
          body: { conversation_id: "conv-123" },
92
        });
93
      }
94
      if (req.path === "/api/v1/conversations/conv-123/boxes" && req.method === "POST") {
95
        return Effect.succeed({
96
          status: 201,
97
          body: {
98
            box: {
99
              box_id: "bx_created99",
100
              label: "new-box",
101
              state: "ready",
102
              setup_status: "done",
103
              created_at: "2026-08-25T12:05:00Z",
104
            },
105
          },
106
        });
107
      }
108
      return Effect.succeed({ status: 404, body: {} });
109
    });
110
111
    await Effect.runPromise(
112
      runCliWith(["box", "create", "--label", "new-box"]).pipe(Effect.provide(layer)),
113
    );
114
115
    expect(written.length).toBe(1);
116
    const human = written[0]!.document.human;
117
    expect(human.some((line) => line.includes("bx_created99"))).toBe(true);
118
  });
119
120
  it("executes a command on a box", async () => {
121
    const { layer, written } = harness((req) => {
122
      if (req.path === "/api/v1/user") {
123
        return Effect.succeed({
124
          status: 200,
125
          body: { conversation_id: "conv-123" },
126
        });
127
      }
128
      if (req.path === "/api/v1/conversations/conv-123/boxes/bx_test123/commands") {
129
        return Effect.succeed({
130
          status: 200,
131
          body: {
132
            result: {
133
              box_id: "bx_test123",
134
              exit_code: 0,
135
              stdout: "hello box\n",
136
              stderr: "",
137
              timed_out: false,
138
              stdout_truncated: false,
139
              stderr_truncated: false,
140
            },
141
          },
142
        });
143
      }
144
      return Effect.succeed({ status: 404, body: {} });
145
    });
146
147
    await Effect.runPromise(
148
      runCliWith(["box", "exec", "bx_test123", "echo", "hello box"]).pipe(Effect.provide(layer)),
149
    );
150
151
    expect(written.length).toBe(1);
152
    const human = written[0]!.document.human;
153
    expect(human.some((line) => line.includes("hello box"))).toBe(true);
154
  });
155
156
  it("stops a box", async () => {
157
    const { layer, written } = harness((req) => {
158
      if (req.path === "/api/v1/user") {
159
        return Effect.succeed({
160
          status: 200,
161
          body: { conversation_id: "conv-123" },
162
        });
163
      }
164
      if (req.path === "/api/v1/conversations/conv-123/boxes/bx_test123/stop") {
165
        return Effect.succeed({
166
          status: 200,
167
          body: {
168
            box: {
169
              box_id: "bx_test123",
170
              state: "archiving",
171
              setup_status: "done",
172
              created_at: "2026-08-25T12:00:00Z",
173
              stopped_at: "2026-08-25T12:10:00Z",
174
            },
175
          },
176
        });
177
      }
178
      return Effect.succeed({ status: 404, body: {} });
179
    });
180
181
    await Effect.runPromise(
182
      runCliWith(["box", "stop", "bx_test123"]).pipe(Effect.provide(layer)),
183
    );
184
185
    expect(written.length).toBe(1);
186
    const human = written[0]!.document.human;
187
    expect(human.some((line) => line.includes("Stopped Box bx_test123"))).toBe(true);
188
  });
189
});

This page updates live while a promote is in flight · changelog