Add self-contained ACP delegation

863053a3c475 · Devin AI · · parent 45b666198cd8

Add self-contained ACP delegation

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>

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 packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/computer-agents.ts
  • modified packages/openagents-cli/src/computer-channel.ts
  • modified packages/openagents-cli/src/computer-config.ts
  • modified packages/openagents-cli/src/computer-executor.ts
  • modified packages/openagents-cli/src/computer-policy.ts
  • modified packages/openagents-cli/src/computer-probe.ts
  • modified packages/openagents-cli/src/computer-up.ts
  • modified packages/openagents-cli/src/runtime.ts
  • added packages/openagents-cli/test/computer-agents.test.ts
  • modified packages/openagents-cli/test/computer.test.ts

Diff

11 files changed, +1639 -6

packages/openagents-cli/src/cli.ts modified +1

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

1 1
import { Clock, Console, Effect, Option, Redacted } from "effect";
2 2
import { Argument, Command, Flag } from "effect/unstable/cli";
3 3
import { hostname } from "node:os";
4
import { VERSION } from "./version.js";
4 5
5 6
import { apiErrorDetails, type Repository } from "./api-contract.js";
6 7
import {
packages/openagents-cli/src/computer-agents.ts added +693

@@ -0,0 +1,693 @@

1
import { Effect, Layer, Schema } from "effect";
2
import * as Context from "effect/Context";
3
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
4
import { resolve } from "node:path";
5
import { StringDecoder } from "node:string_decoder";
6
7
import type { AgentConfigEntry } from "./computer-config.js";
8
import { defaultCuratedExecute, type Tier, withinRoot } from "./computer-policy.js";
9
import { VERSION } from "./version.js";
10
11
export type AgentCatalogSource = "local" | "configured" | "remote";
12
13
export interface AgentCatalogEntry {
14
  readonly id: string;
15
  readonly argv: ReadonlyArray<string>;
16
  readonly source: AgentCatalogSource;
17
  readonly version: string;
18
  readonly env: ReadonlyArray<string>;
19
}
20
21
export interface AcpAgentInventoryEntry {
22
  readonly id: string;
23
  readonly source: AgentCatalogSource;
24
  readonly version: string;
25
}
26
27
export type AgentRemoteResolver = (requestedId: string) => AgentCatalogEntry | undefined;
28
29
export const buildAgentCatalog = (
30
  config: ComputerConfigurationValuesLike,
31
  discovered: ReadonlyArray<{
32
    readonly name: string;
33
    readonly present: boolean;
34
    readonly version: string;
35
  }>,
36
): ReadonlyArray<AgentCatalogEntry> => {
37
  const local: Array<AgentCatalogEntry> = discovered
38
    .filter((tool) => tool.present && tool.name === "opencode")
39
    .map((tool) => ({
40
      id: "opencode",
41
      argv: ["opencode", "acp"],
42
      source: "local" as const,
43
      version: tool.version,
44
      env: [],
45
    }));
46
  const configured: Array<AgentCatalogEntry> = (config.agents ?? []).map((entry) => ({
47
    id: entry.id,
48
    argv: entry.argv,
49
    source: "configured" as const,
50
    version: "",
51
    env: entry.env,
52
  }));
53
  const byId = new Map(local.map((entry) => [entry.id, entry]));
54
  for (const entry of configured) byId.set(entry.id, entry);
55
  // Remote resolution is deliberately not performed here. The flag is retained
56
  // in the catalog input so a future resolver can remain explicitly opt-in.
57
  void config.registryAgents;
58
  return [...byId.values()].slice(0, 64);
59
};
60
61
interface ComputerConfigurationValuesLike {
62
  readonly agents?: ReadonlyArray<AgentConfigEntry>;
63
  readonly registryAgents?: boolean;
64
}
65
66
export const resolveAgent = (
67
  catalog: ReadonlyArray<AgentCatalogEntry>,
68
  requestedId: string,
69
  options: Readonly<{
70
    readonly registryAgents?: boolean;
71
    readonly resolveRemote?: AgentRemoteResolver;
72
  }> = {},
73
):
74
  | { readonly _tag: "resolved"; readonly entry: AgentCatalogEntry }
75
  | {
76
      readonly _tag: "unavailable";
77
      readonly requestedId: string;
78
      readonly availableIds: ReadonlyArray<string>;
79
    } => {
80
  const entry =
81
    catalog.find((candidate) => candidate.id === requestedId) ??
82
    (options.registryAgents === true ? options.resolveRemote?.(requestedId) : undefined);
83
  return entry === undefined
84
    ? { _tag: "unavailable", requestedId, availableIds: catalog.map((candidate) => candidate.id) }
85
    : { _tag: "resolved", entry };
86
};
87
88
export interface PermissionQuery {
89
  readonly kind: string;
90
  readonly title: string;
91
  readonly rawInput: unknown;
92
}
93
94
export const permissionAllowed = (
95
  tier: Tier,
96
  query: PermissionQuery,
97
  roots: ReadonlyArray<string>,
98
  curatedExecute: ReadonlyArray<string> = defaultCuratedExecute,
99
  cwd = process.cwd(),
100
): boolean => {
101
  const input = record(query.rawInput);
102
  const material = [
103
    query.title,
104
    ...Object.values(input).filter((value): value is string => typeof value === "string"),
105
  ];
106
  if (
107
    material.some(
108
      (value) =>
109
        /(?:^|\s)(?:sudo|doas|su|chmod|chown|dd|shutdown|reboot|ssh-add|ssh-keygen|gpg|crontab|systemctl|launchctl|nc|telnet)(?:\s|$)/u.test(
110
          value,
111
        ) ||
112
        /(?:\.ssh|\.aws|\.gnupg|\.kube|\.netrc|\.npmrc|\.pypirc|\.git-credentials|id_rsa|id_ed25519|\.env|credentials\.json|Keychains)/iu.test(
113
          value,
114
        ),
115
    )
116
  )
117
    return false;
118
  if (tier === "probe") return false;
119
  if (tier === "shell") return true;
120
  if (["read", "search", "fetch", "think"].includes(query.kind)) return true;
121
  if (query.kind === "edit" || query.kind === "write") {
122
    const candidate = firstString(input, ["path", "file_path", "filePath", "file"]);
123
    return (
124
      candidate !== undefined && roots.some((root) => withinRoot(resolve(cwd, candidate), root))
125
    );
126
  }
127
  if (query.kind === "execute") {
128
    const command = firstString(input, ["command"]) ?? query.title;
129
    const segments = command.split(/&&|\|\||;|\||\n/u);
130
    return (
131
      command !== "" &&
132
      segments.every((segment) => {
133
        const first = segment.trim().split(/\s+/u)[0] ?? "";
134
        return first === "cd" || curatedExecute.includes(first);
135
      })
136
    );
137
  }
138
  return false;
139
};
140
141
const record = (value: unknown): Record<string, unknown> => (isRecord(value) ? { ...value } : {});
142
143
const isRecord = (value: unknown): value is Record<string, unknown> =>
144
  typeof value === "object" && value !== null && !Array.isArray(value);
145
146
const firstString = (
147
  value: Record<string, unknown>,
148
  keys: ReadonlyArray<string>,
149
): string | undefined => {
150
  for (const key of keys) {
151
    const candidate = value[key];
152
    if (typeof candidate === "string" && candidate !== "") return candidate;
153
  }
154
  return undefined;
155
};
156
157
export interface AgentProcess {
158
  readonly request: (method: string, params: unknown) => Promise<unknown>;
159
  readonly notify: (method: string, params: unknown) => void;
160
  readonly onNotification: (method: string, handler: (params: unknown) => void) => () => void;
161
  readonly onRequest: (method: string, handler: AgentReverseHandler) => () => void;
162
  readonly terminate: (sessionId?: string) => Promise<void>;
163
}
164
165
type AgentReverseHandler = (
166
  params: unknown,
167
  context: Readonly<{
168
    method: string;
169
    requestId: string | number | null;
170
    signal: AbortSignal;
171
    generation: number;
172
    bindSession?(sessionId: string): boolean;
173
  }>,
174
) => unknown | Promise<unknown>;
175
176
export interface AgentProcessFactory {
177
  readonly start: (
178
    entry: AgentCatalogEntry,
179
    cwd: string,
180
    env: Readonly<Record<string, string>>,
181
  ) => Effect.Effect<AgentProcess, AgentProcessError>;
182
}
183
184
export class AgentProcessError extends Schema.TaggedErrorClass<AgentProcessError>()(
185
  "OpenAgentsCli.AgentProcessError",
186
  { message: Schema.String },
187
) {}
188
189
export class ComputerAgentProcess extends Context.Service<
190
  ComputerAgentProcess,
191
  AgentProcessFactory
192
>()("@openagentsinc/cli/ComputerAgentProcess") {}
193
194
const scrub = (value: string): string =>
195
  value
196
    .replaceAll(
197
      /(?:oa_(?:pat|agent|assignment)_[A-Za-z0-9._-]+|smct_[A-Za-z0-9._-]+)/gu,
198
      "[REDACTED]",
199
    )
200
    .replaceAll(
201
      /(?:api[-_]?key|token|secret|password|authorization)\s*[=:]\s*\S+/giu,
202
      "[REDACTED]",
203
    );
204
205
export interface AgentDelegationRequest {
206
  readonly entry: AgentCatalogEntry;
207
  readonly prompt: string;
208
  readonly cwd: string;
209
  readonly resumeSessionId?: string;
210
  readonly tier: Tier;
211
  readonly roots: ReadonlyArray<string>;
212
  readonly curatedExecute: ReadonlyArray<string>;
213
  readonly env: Readonly<Record<string, string>>;
214
  readonly timeoutMs: number;
215
  readonly maximumOutputBytes: number;
216
  readonly onChunk: (text: string) => void;
217
  readonly onSession: (sessionId: string) => void;
218
  readonly onPermission: (allowed: boolean, detail: string) => void;
219
}
220
221
export interface AgentDelegationOutcome {
222
  readonly status: "completed" | "failed" | "cancelled" | "timeout" | "truncated";
223
  readonly sessionId: string;
224
  readonly output: string;
225
  readonly truncated: boolean;
226
  readonly detail: string;
227
  readonly durationMs: number;
228
}
229
230
export interface AgentDelegationJob {
231
  readonly done: Promise<AgentDelegationOutcome>;
232
  readonly cancel: () => void;
233
}
234
235
interface ResolverSlot<T> {
236
  resolve: (value: T) => void;
237
}
238
239
const captureResolver =
240
  <T>(slot: ResolverSlot<T>) =>
241
  (resolver: (value: T) => void): void => {
242
    slot.resolve = resolver;
243
  };
244
245
const deferred = <T>(): {
246
  readonly promise: Promise<T>;
247
  readonly resolve: (value: T) => void;
248
} => {
249
  const slot: ResolverSlot<T> = { resolve: () => undefined };
250
  const promise = new Promise<T>(captureResolver(slot));
251
  return { promise, resolve: slot.resolve };
252
};
253
254
export const startAgentDelegation = (
255
  factory: AgentProcessFactory,
256
  request: AgentDelegationRequest,
257
): AgentDelegationJob => {
258
  const startedAt = Date.now();
259
  let process: AgentProcess | undefined;
260
  let sessionId = request.resumeSessionId ?? "";
261
  let settled = false;
262
  let cancelled = false;
263
  let timedOut = false;
264
  let bytes = 0;
265
  let output = "";
266
  let truncated = false;
267
  const completion = deferred<AgentDelegationOutcome>();
268
  const done = completion.promise;
269
  const settle = (status: AgentDelegationOutcome["status"], detail: string): void => {
270
    if (settled) return;
271
    settled = true;
272
    completion.resolve({
273
      status,
274
      sessionId,
275
      output,
276
      truncated,
277
      detail,
278
      durationMs: Date.now() - startedAt,
279
    });
280
    void process?.terminate(sessionId);
281
  };
282
  const emit = (value: string): void => {
283
    const safe = scrub(value);
284
    const remaining = request.maximumOutputBytes - bytes;
285
    if (remaining <= 0) {
286
      truncated = true;
287
      return;
288
    }
289
    const bounded = safe.slice(0, remaining);
290
    bytes += bounded.length;
291
    output += bounded;
292
    if (bounded.length < safe.length) truncated = true;
293
    request.onChunk(bounded);
294
  };
295
  const run = async (): Promise<void> => {
296
    process = await Effect.runPromise(factory.start(request.entry, request.cwd, request.env));
297
    process.onNotification("session/update", (params) => {
298
      const text = extractText(params);
299
      if (text !== "") emit(text);
300
    });
301
    process.onRequest("session/request_permission", async (params: unknown) => {
302
      const value = record(params);
303
      const toolCall = record(value.toolCall);
304
      const kind = typeof toolCall.kind === "string" ? toolCall.kind : "other";
305
      const title = typeof toolCall.title === "string" ? toolCall.title : "";
306
      const allowed = permissionAllowed(
307
        request.tier,
308
        { kind, title, rawInput: toolCall.rawInput },
309
        request.roots,
310
        request.curatedExecute,
311
        request.cwd,
312
      );
313
      const options = Array.isArray(value.options) ? value.options : [];
314
      const option = options.find((candidate) => {
315
        const item = record(candidate);
316
        const optionKind = item.kind;
317
        return optionKind === (allowed ? "allow_once" : "reject_once");
318
      });
319
      const detail = `${kind}${title === "" ? "" : `: ${scrub(title)}`}`;
320
      request.onPermission(allowed, detail);
321
      return option === undefined
322
        ? { outcome: { outcome: "cancelled" } }
323
        : { outcome: { outcome: "selected", optionId: String(record(option).optionId ?? "") } };
324
    });
325
    const initialized = record(
326
      await process.request("initialize", {
327
        protocolVersion: 1,
328
        clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
329
        clientInfo: { name: "openagents-cli", title: "OpenAgents CLI", version: VERSION },
330
      }),
331
    );
332
    if (initialized.protocolVersion !== undefined && initialized.protocolVersion !== 1) {
333
      settle(
334
        "failed",
335
        `the agent negotiated unsupported ACP protocol version ${String(initialized.protocolVersion)}`,
336
      );
337
      return;
338
    }
339
    const capabilities = record(initialized.agentCapabilities);
340
    if (request.resumeSessionId !== undefined) {
341
      if (capabilities.loadSession !== true) {
342
        settle("failed", "the agent cannot reattach the requested ACP session");
343
        return;
344
      }
345
      await process.request("session/load", {
346
        sessionId: request.resumeSessionId,
347
        cwd: request.cwd,
348
        mcpServers: [],
349
      });
350
    } else {
351
      const created = record(
352
        await process.request("session/new", { cwd: request.cwd, mcpServers: [] }),
353
      );
354
      if (typeof created.sessionId !== "string" || created.sessionId === "") {
355
        settle("failed", "the agent did not return an ACP session id");
356
        return;
357
      }
358
      sessionId = created.sessionId;
359
      request.onSession(sessionId);
360
    }
361
    const result = record(
362
      await process.request("session/prompt", {
363
        sessionId,
364
        prompt: [{ type: "text", text: request.prompt.slice(0, 32_768) }],
365
      }),
366
    );
367
    const stopReason = result.stopReason;
368
    settle(
369
      stopReason === "cancelled"
370
        ? timedOut
371
          ? "timeout"
372
          : "cancelled"
373
        : stopReason === "refusal"
374
          ? "failed"
375
          : truncated
376
            ? "truncated"
377
            : "completed",
378
      "",
379
    );
380
  };
381
  void run().catch((cause: unknown) =>
382
    settle(
383
      cancelled ? "cancelled" : timedOut ? "timeout" : "failed",
384
      scrub(cause instanceof Error ? cause.message : String(cause)),
385
    ),
386
  );
387
  const timer = setTimeout(() => {
388
    timedOut = true;
389
    process?.notify("session/cancel", { sessionId });
390
    setTimeout(() => settle("timeout", "delegation timed out"), 1_000).unref();
391
  }, request.timeoutMs);
392
  timer.unref();
393
  done.finally(() => clearTimeout(timer));
394
  return {
395
    done,
396
    cancel: () => {
397
      cancelled = true;
398
      process?.notify("session/cancel", { sessionId });
399
      setTimeout(() => settle("cancelled", "cancelled by request"), 1_000).unref();
400
    },
401
  };
402
};
403
404
const extractText = (value: unknown): string => {
405
  const texts: string[] = [];
406
  const visit = (current: unknown): void => {
407
    if (typeof current === "string") return;
408
    if (Array.isArray(current)) {
409
      for (const item of current) visit(item);
410
      return;
411
    }
412
    if (typeof current !== "object" || current === null) return;
413
    const object = current as Record<string, unknown>;
414
    if (typeof object.text === "string") texts.push(object.text);
415
    for (const [key, item] of Object.entries(object)) if (key !== "text") visit(item);
416
  };
417
  visit(value);
418
  return texts.join("");
419
};
420
421
const maximumAgentLineBytes = 1_048_576;
422
const maximumAgentBufferedBytes = 2_097_152;
423
const agentRequestTimeoutMs = 60_000;
424
const agentShutdownGraceMs = 1_000;
425
426
type AgentRpcId = string | number | null;
427
428
interface PendingAgentRequest {
429
  readonly resolve: (value: unknown) => void;
430
  readonly reject: (error: Error) => void;
431
  readonly timer: ReturnType<typeof setTimeout>;
432
}
433
434
const agentRpcIdKey = (id: AgentRpcId): string => `${typeof id}:${String(id)}`;
435
436
const isAgentRpcId = (value: unknown): value is AgentRpcId =>
437
  value === null ||
438
  typeof value === "string" ||
439
  (typeof value === "number" && Number.isSafeInteger(value));
440
441
const isAgentRecord = (value: unknown): value is Record<string, unknown> =>
442
  typeof value === "object" && value !== null && !Array.isArray(value);
443
444
const writeAgentMessage = (
445
  child: ChildProcessWithoutNullStreams,
446
  message: Record<string, unknown>,
447
): boolean => {
448
  if (child.stdin.destroyed || child.stdin.writableEnded) return false;
449
  const line = `${JSON.stringify(message)}\n`;
450
  if (Buffer.byteLength(line, "utf8") > maximumAgentLineBytes) return false;
451
  try {
452
    child.stdin.write(line);
453
    return true;
454
  } catch {
455
    return false;
456
  }
457
};
458
459
const killAgentProcess = (child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void => {
460
  if (child.pid === undefined) return;
461
  try {
462
    if (process.platform === "win32") child.kill(signal);
463
    else process.kill(-child.pid, signal);
464
  } catch {
465
    try {
466
      child.kill(signal);
467
    } catch {
468
      // The process may have exited between the two kill attempts.
469
    }
470
  }
471
};
472
473
const startLocalAgentProcess = async (
474
  entry: AgentCatalogEntry,
475
  cwd: string,
476
  env: Readonly<Record<string, string>>,
477
): Promise<AgentProcess> => {
478
  const executable = entry.argv[0] ?? "";
479
  const child = spawn(executable, entry.argv.slice(1), {
480
    cwd,
481
    env: { ...env },
482
    shell: false,
483
    detached: process.platform !== "win32",
484
    stdio: ["pipe", "pipe", "pipe"],
485
  });
486
  const pending = new Map<string, PendingAgentRequest>();
487
  const notificationHandlers = new Map<string, Set<(params: unknown) => void>>();
488
  const requestHandlers = new Map<string, AgentReverseHandler>();
489
  const decoder = new StringDecoder("utf8");
490
  let lineBuffer = "";
491
  let disposed = false;
492
  let nextRequestId = 0;
493
  let closeResolve: (() => void) | undefined;
494
  const closed = new Promise<void>((resolveClosed) => {
495
    closeResolve = resolveClosed;
496
  });
497
498
  const rejectPending = (message: string): void => {
499
    for (const request of pending.values()) {
500
      clearTimeout(request.timer);
501
      request.reject(new Error(message));
502
    }
503
    pending.clear();
504
  };
505
506
  const dispose = (message: string): void => {
507
    if (disposed) return;
508
    disposed = true;
509
    rejectPending(message);
510
    closeResolve?.();
511
  };
512
513
  const sendResponse = (id: AgentRpcId, result: unknown): void => {
514
    writeAgentMessage(child, { jsonrpc: "2.0", id, result });
515
  };
516
517
  const sendError = (id: AgentRpcId, code: number, message: string): void => {
518
    writeAgentMessage(child, {
519
      jsonrpc: "2.0",
520
      id,
521
      error: { code, message },
522
    });
523
  };
524
525
  const handleReverseRequest = (message: Record<string, unknown>): void => {
526
    const method = typeof message.method === "string" ? message.method : "";
527
    const id = isAgentRpcId(message.id) ? message.id : null;
528
    const handler = requestHandlers.get(method);
529
    if (handler === undefined) {
530
      sendError(id, -32601, "method not found");
531
      return;
532
    }
533
    const controller = new AbortController();
534
    Promise.resolve(
535
      handler(message.params, {
536
        method,
537
        requestId: id,
538
        signal: controller.signal,
539
        generation: 1,
540
        bindSession: () => true,
541
      }),
542
    )
543
      .then((result) => sendResponse(id, result))
544
      .catch(() => sendError(id, -32000, "request refused"));
545
  };
546
547
  const handleMessage = (message: unknown): void => {
548
    if (!isAgentRecord(message)) {
549
      dispose("ACP process returned an invalid message");
550
      killAgentProcess(child, "SIGTERM");
551
      return;
552
    }
553
    if (typeof message.method === "string") {
554
      if (Object.hasOwn(message, "id")) handleReverseRequest(message);
555
      else {
556
        for (const handler of notificationHandlers.get(message.method) ?? []) {
557
          handler(message.params);
558
        }
559
      }
560
      return;
561
    }
562
    if (!Object.hasOwn(message, "id") || !isAgentRpcId(message.id)) {
563
      dispose("ACP process returned an invalid response");
564
      killAgentProcess(child, "SIGTERM");
565
      return;
566
    }
567
    const request = pending.get(agentRpcIdKey(message.id));
568
    if (request === undefined) return;
569
    pending.delete(agentRpcIdKey(message.id));
570
    clearTimeout(request.timer);
571
    if (isAgentRecord(message.error)) request.reject(new Error("ACP request failed"));
572
    else request.resolve(message.result);
573
  };
574
575
  const handleStdout = (chunk: Buffer | string): void => {
576
    if (disposed) return;
577
    lineBuffer += decoder.write(chunk);
578
    if (Buffer.byteLength(lineBuffer, "utf8") > maximumAgentBufferedBytes) {
579
      dispose("ACP process output exceeded the buffer limit");
580
      killAgentProcess(child, "SIGTERM");
581
      return;
582
    }
583
    let newline = lineBuffer.indexOf("\n");
584
    while (newline >= 0) {
585
      const line = lineBuffer.slice(0, newline).replace(/\r$/u, "");
586
      lineBuffer = lineBuffer.slice(newline + 1);
587
      if (Buffer.byteLength(line, "utf8") > maximumAgentLineBytes) {
588
        dispose("ACP process line exceeded the size limit");
589
        killAgentProcess(child, "SIGTERM");
590
        return;
591
      }
592
      try {
593
        handleMessage(JSON.parse(line));
594
      } catch {
595
        dispose("ACP process returned malformed JSON");
596
        killAgentProcess(child, "SIGTERM");
597
        return;
598
      }
599
      newline = lineBuffer.indexOf("\n");
600
    }
601
  };
602
603
  child.stdout.on("data", handleStdout);
604
  child.stderr.on("data", () => undefined);
605
  child.on("error", () => dispose("ACP process failed"));
606
  child.on("close", () => {
607
    dispose("ACP process exited");
608
    closeResolve?.();
609
  });
610
611
  await new Promise<void>((resolveStarted, rejectStarted) => {
612
    const onSpawn = () => {
613
      child.off("error", onError);
614
      resolveStarted();
615
    };
616
    const onError = (cause: Error) => {
617
      child.off("spawn", onSpawn);
618
      rejectStarted(cause);
619
    };
620
    child.once("spawn", onSpawn);
621
    child.once("error", onError);
622
  });
623
624
  const request = (method: string, params: unknown): Promise<unknown> => {
625
    if (disposed) return Promise.reject(new Error("ACP process is not running"));
626
    const id = ++nextRequestId;
627
    const key = agentRpcIdKey(id);
628
    return new Promise((resolveRequest, rejectRequest) => {
629
      const timer = setTimeout(() => {
630
        pending.delete(key);
631
        rejectRequest(new Error("ACP request timed out"));
632
      }, agentRequestTimeoutMs);
633
      timer.unref();
634
      pending.set(key, { resolve: resolveRequest, reject: rejectRequest, timer });
635
      if (!writeAgentMessage(child, { jsonrpc: "2.0", id, method, params })) {
636
        clearTimeout(timer);
637
        pending.delete(key);
638
        rejectRequest(new Error("ACP process is not writable"));
639
      }
640
    });
641
  };
642
643
  const notify = (method: string, params: unknown): void => {
644
    writeAgentMessage(child, { jsonrpc: "2.0", method, params });
645
  };
646
647
  const terminate = async (): Promise<void> => {
648
    if (disposed) return;
649
    disposed = true;
650
    rejectPending("ACP process terminated");
651
    child.stdin.end();
652
    await Promise.race([
653
      closed,
654
      new Promise<void>((resolveTimeout) => {
655
        const timer = setTimeout(resolveTimeout, agentShutdownGraceMs);
656
        timer.unref();
657
      }),
658
    ]);
659
    if (!child.killed) killAgentProcess(child, "SIGTERM");
660
  };
661
662
  return {
663
    request,
664
    notify,
665
    onNotification: (method, handler) => {
666
      const handlers = notificationHandlers.get(method) ?? new Set();
667
      handlers.add(handler);
668
      notificationHandlers.set(method, handlers);
669
      return () => {
670
        handlers.delete(handler);
671
        if (handlers.size === 0) notificationHandlers.delete(method);
672
      };
673
    },
674
    onRequest: (method, handler) => {
675
      requestHandlers.set(method, handler);
676
      return () => {
677
        if (requestHandlers.get(method) === handler) requestHandlers.delete(method);
678
      };
679
    },
680
    terminate,
681
  };
682
};
683
684
export const computerAgentProcessNodeLayer = Layer.succeed(ComputerAgentProcess, {
685
  start: (entry, cwd, env) =>
686
    Effect.tryPromise({
687
      try: () => startLocalAgentProcess(entry, cwd, env),
688
      catch: (cause) =>
689
        new AgentProcessError({
690
          message: cause instanceof Error ? cause.message : String(cause),
691
        }),
692
    }),
693
});
packages/openagents-cli/src/computer-channel.ts modified +20

@@ -36,6 +36,10 @@ export interface ComputerResponder {

36 36
  readonly refused: (reason: string, detail: string) => void;
37 37
}
38 38
39
export interface ComputerAgentResponder extends ComputerResponder {
40
  readonly session: (sessionId: string) => void;
41
}
42
39 43
export interface ComputerChannelHandlers {
40 44
  readonly onProbe: (requestId: string) => Promise<unknown>;
41 45
  readonly onRun: (

@@ -43,6 +47,11 @@ export interface ComputerChannelHandlers {

43 47
    payload: Record<string, unknown>,
44 48
    responder: ComputerResponder,
45 49
  ) => void;
50
  readonly onAgent?: (
51
    requestId: string,
52
    payload: Record<string, unknown>,
53
    responder: ComputerAgentResponder,
54
  ) => void;
46 55
  readonly onCancel: (requestId: string) => void;
47 56
  readonly onJoined: () => void;
48 57
  readonly onEvent: (event: string) => void;

@@ -132,6 +141,10 @@ const serveConnection = (

132 141
      exit: (payload) => push("exit", { request_id: requestId, ...payload }),
133 142
      refused: (reason, detail) => push("refused", { request_id: requestId, reason, detail }),
134 143
    });
144
    const agentResponder = (requestId: string): ComputerAgentResponder => ({
145
      ...responder(requestId),
146
      session: (sessionId) => push("session", { request_id: requestId, session_id: sessionId }),
147
    });
135 148
136 149
    socket.on("open", () => {
137 150
      socket.send(JSON.stringify([joinRef, joinRef, topic, "phx_join", {}]));

@@ -195,6 +208,13 @@ const serveConnection = (

195 208
      } else if (event === "run") {
196 209
        handlers.onEvent(`run:${requestId.slice(0, 8)}`);
197 210
        handlers.onRun(requestId, payload, responder(requestId));
211
      } else if (event === "agent") {
212
        handlers.onEvent(`agent:${requestId.slice(0, 8)}`);
213
        if (handlers.onAgent === undefined) {
214
          agentResponder(requestId).refused("unsupported", "ACP delegation is unavailable");
215
        } else {
216
          handlers.onAgent(requestId, payload, agentResponder(requestId));
217
        }
198 218
      } else if (event === "cancel") {
199 219
        handlers.onEvent(`cancel:${requestId.slice(0, 8)}`);
200 220
        handlers.onCancel(requestId);
packages/openagents-cli/src/computer-config.ts modified +50 -1

@@ -6,7 +6,7 @@ import { homedir } from "node:os";

6 6
7 7
import { ConfigurationError } from "./errors.js";
8 8
import { EnvironmentConfiguration } from "./environment.js";
9
import { resolveRoots, type PolicyConfig } from "./computer-policy.js";
9
import { defaultCuratedExecute, resolveRoots, type PolicyConfig } from "./computer-policy.js";
10 10
11 11
export interface ComputerPaths {
12 12
  readonly config: string;

@@ -17,6 +17,12 @@ export interface ComputerConfigurationValues extends PolicyConfig {

17 17
  readonly paths: ComputerPaths;
18 18
}
19 19
20
export interface AgentConfigEntry {
21
  readonly id: string;
22
  readonly argv: ReadonlyArray<string>;
23
  readonly env: ReadonlyArray<string>;
24
}
25
20 26
export class ComputerConfiguration extends Context.Service<
21 27
  ComputerConfiguration,
22 28
  ComputerConfigurationValues

@@ -26,6 +32,17 @@ const StoredConfiguration = Schema.Struct({

26 32
  tier: Schema.optionalKey(Schema.Literals(["probe", "curated", "shell"])),
27 33
  roots: Schema.optionalKey(Schema.Array(Schema.String)),
28 34
  pre_approved: Schema.optionalKey(Schema.Array(Schema.String)),
35
  agents: Schema.optionalKey(
36
    Schema.Record(
37
      Schema.String,
38
      Schema.Struct({
39
        argv: Schema.Array(Schema.String),
40
        env: Schema.optionalKey(Schema.Array(Schema.String)),
41
      }),
42
    ),
43
  ),
44
  registry_agents: Schema.optionalKey(Schema.Boolean),
45
  curated_execute: Schema.optionalKey(Schema.Array(Schema.String)),
29 46
});
30 47
31 48
const defaultConfig = (paths: ComputerPaths): ComputerConfigurationValues => ({

@@ -33,6 +50,9 @@ const defaultConfig = (paths: ComputerPaths): ComputerConfigurationValues => ({

33 50
  roots: [],
34 51
  preApproved: [],
35 52
  paths,
53
  agents: [],
54
  registryAgents: false,
55
  curatedExecute: [...defaultCuratedExecute],
36 56
});
37 57
38 58
const errorCode = (cause: unknown): string | undefined =>

@@ -91,6 +111,24 @@ const readConfiguration = (

91 111
      roots: resolveRoots(decoded.roots ?? []),
92 112
      preApproved: [...new Set(decoded.pre_approved ?? [])].slice(0, 64),
93 113
      paths,
114
      agents: Object.entries(decoded.agents ?? {}).flatMap(([id, entry]) => {
115
        if (!/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(id) || entry.argv.length === 0) return [];
116
        return [
117
          {
118
            id,
119
            argv: entry.argv.slice(0, 16).map((value) => value.slice(0, 256)),
120
            env: [
121
              ...new Set(
122
                (entry.env ?? []).filter((value) => /^[A-Z_][A-Z0-9_]{0,63}$/u.test(value)),
123
              ),
124
            ].slice(0, 32),
125
          },
126
        ];
127
      }),
128
      registryAgents: decoded.registry_agents ?? false,
129
      curatedExecute: [...new Set(decoded.curated_execute ?? defaultCuratedExecute)]
130
        .filter((value) => value.length > 0)
131
        .slice(0, 64),
94 132
    };
95 133
  });
96 134

@@ -119,6 +157,17 @@ export const writeComputerConfiguration = (

119 157
            tier: config.tier,
120 158
            roots: resolveRoots(config.roots),
121 159
            pre_approved: config.preApproved,
160
            agents: Object.fromEntries(
161
              (config.agents ?? []).map((entry) => [
162
                entry.id,
163
                {
164
                  argv: entry.argv,
165
                  env: entry.env,
166
                },
167
              ]),
168
            ),
169
            registry_agents: config.registryAgents ?? false,
170
            curated_execute: config.curatedExecute ?? defaultCuratedExecute,
122 171
          },
123 172
          null,
124 173
          2,
packages/openagents-cli/src/computer-executor.ts modified +2 -2

@@ -25,7 +25,7 @@ export const computerExecutionDefaults: ComputerExecutionLimits = {

25 25
26 26
const environmentNames = ["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "SHELL", "USER", "TERM"];
27 27
28
const scrubEnvironment = (source: NodeJS.ProcessEnv): NodeJS.ProcessEnv =>
28
export const scrubbedEnvironment = (source: NodeJS.ProcessEnv): Record<string, string> =>
29 29
  Object.fromEntries(
30 30
    environmentNames.flatMap((name) => {
31 31
      const value = source[name];

@@ -99,7 +99,7 @@ export const executeComputerCommand = (

99 99
    child = spawn(command, args, {
100 100
      cwd,
101 101
      detached: process.platform !== "win32",
102
      env: scrubEnvironment(process.env),
102
      env: scrubbedEnvironment(process.env),
103 103
      shell: false,
104 104
      stdio: ["ignore", "pipe", "pipe"],
105 105
    });
packages/openagents-cli/src/computer-policy.ts modified +32

@@ -16,8 +16,40 @@ export interface PolicyConfig {

16 16
  readonly tier: Tier;
17 17
  readonly roots: ReadonlyArray<string>;
18 18
  readonly preApproved: ReadonlyArray<string>;
19
  readonly agents?: ReadonlyArray<{
20
    readonly id: string;
21
    readonly argv: ReadonlyArray<string>;
22
    readonly env: ReadonlyArray<string>;
23
  }>;
24
  readonly registryAgents?: boolean;
25
  readonly curatedExecute?: ReadonlyArray<string>;
19 26
}
20 27
28
export const defaultCuratedExecute: ReadonlyArray<string> = [
29
  "git",
30
  "gh",
31
  "ls",
32
  "cat",
33
  "head",
34
  "tail",
35
  "wc",
36
  "pwd",
37
  "which",
38
  "find",
39
  "rg",
40
  "grep",
41
  "sed",
42
  "node",
43
  "npm",
44
  "npx",
45
  "pnpm",
46
  "python3",
47
  "cargo",
48
  "go",
49
  "make",
50
  "mix",
51
];
52
21 53
export interface CommandRequest {
22 54
  readonly argv: ReadonlyArray<string>;
23 55
  readonly cwd: string;
packages/openagents-cli/src/computer-probe.ts modified +8

@@ -6,6 +6,7 @@ import { existsSync, statSync } from "node:fs";

6 6
import { join } from "node:path";
7 7
8 8
import { ComputerConfiguration } from "./computer-config.js";
9
import { buildAgentCatalog, type AcpAgentInventoryEntry } from "./computer-agents.js";
9 10
10 11
export interface ToolReport {
11 12
  readonly name: string;

@@ -38,6 +39,7 @@ export interface ProbeReport {

38 39
  readonly toolchains: ReadonlyArray<ToolReport>;
39 40
  readonly roots: ReadonlyArray<string>;
40 41
  readonly worktrees: ReadonlyArray<WorktreeReport>;
42
  readonly acp_agents?: ReadonlyArray<AcpAgentInventoryEntry>;
41 43
}
42 44
43 45
interface Probed {

@@ -157,6 +159,11 @@ export const computerProbeLayer = Layer.effect(

157 159
        });
158 160
      const codingAgents = yield* Effect.forEach(codingAgentCatalog, probeOne, { concurrency: 4 });
159 161
      const toolchains = yield* Effect.forEach(toolchainCatalog, probeOne, { concurrency: 4 });
162
      const acpAgents = buildAgentCatalog(config, codingAgents).map((entry) => ({
163
        id: entry.id,
164
        source: entry.source,
165
        version: entry.version,
166
      }));
160 167
      return {
161 168
        schema: "openagents.computer_probe.v1" as const,
162 169
        host: hostReport(),

@@ -164,6 +171,7 @@ export const computerProbeLayer = Layer.effect(

164 171
        toolchains,
165 172
        roots: resolvedRoots,
166 173
        worktrees: resolvedRoots.map(worktreeReport),
174
        acp_agents: acpAgents,
167 175
      };
168 176
    });
169 177
    return ComputerProbe.of({ probe });
packages/openagents-cli/src/computer-up.ts modified +215 -3

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

1 1
import { Effect, Layer, Option } from "effect";
2 2
import * as Context from "effect/Context";
3 3
4
import { ComputerChannel, type ComputerChannelHandlers } from "./computer-channel.js";
4
import {
5
  ComputerChannel,
6
  type ComputerAgentResponder,
7
  type ComputerChannelHandlers,
8
} from "./computer-channel.js";
5 9
import { ComputerClient } from "./computer-client.js";
6 10
import { ComputerConfiguration } from "./computer-config.js";
7 11
import { ComputerJournal, type JournalInterface } from "./computer-journal.js";

@@ -15,6 +19,17 @@ import { ComputerProbe } from "./computer-probe.js";

15 19
import { decide, tierAllows, type CommandRequest, type Tier } from "./computer-policy.js";
16 20
import { CredentialStore } from "./credential-store.js";
17 21
import { InputError, type CliError } from "./errors.js";
22
import {
23
  buildAgentCatalog,
24
  ComputerAgentProcess,
25
  resolveAgent,
26
  startAgentDelegation,
27
  type AgentDelegationJob,
28
  type AgentDelegationOutcome,
29
} from "./computer-agents.js";
30
import { scrubbedEnvironment } from "./computer-executor.js";
31
import { withinRoot } from "./computer-policy.js";
32
import { resolve } from "node:path";
18 33
19 34
export interface ComputerUpInterface {
20 35
  readonly serve: (origin: string, agentVersion: string) => Effect.Effect<string, CliError>;

@@ -29,6 +44,7 @@ const maximumArgvLength = 64;

29 44
const maximumArgumentLength = 1_024;
30 45
const maximumTimeoutMillis = computerExecutionDefaults.timeoutMillis;
31 46
const maximumOutputBytes = computerExecutionDefaults.maximumOutputBytes;
47
const maximumPromptLength = 32_768;
32 48
33 49
const requestFields = (payload: Record<string, unknown>): CommandRequest | undefined => {
34 50
  const argv = payload.argv;

@@ -87,6 +103,7 @@ export const computerUpLayer = Layer.effect(

87 103
    const credentials = yield* CredentialStore;
88 104
    const journalService = yield* ComputerJournal;
89 105
    const probe = yield* ComputerProbe;
106
    const agentProcess = yield* Effect.serviceOption(ComputerAgentProcess);
90 107
    const probeContext = yield* Effect.context<ComputerProbe>();
91 108
92 109
    const serve = Effect.fn("ComputerUp.serve")(function* (origin: string, agentVersion: string) {

@@ -104,6 +121,15 @@ export const computerUpLayer = Layer.effect(

104 121
      }
105 122
      const initialProbe = yield* probe.probe(config.roots);
106 123
      const executions = new Map<string, RunningComputerExecution>();
124
      const agentJobs = new Map<
125
        string,
126
        {
127
          readonly job: AgentDelegationJob;
128
          readonly request: { readonly argv: ReadonlyArray<string>; readonly cwd: string };
129
          readonly setResponder: (responder: ComputerAgentResponder) => void;
130
          sessionId: string;
131
        }
132
      >();
107 133
      let active = 0;
108 134
      const append = (
109 135
        requestId: string,

@@ -234,10 +260,196 @@ export const computerUpLayer = Layer.effect(

234 260
              responder.exit({ status: "failed", exit_code: null, truncated: false });
235 261
            });
236 262
        },
263
        onAgent: (requestId, payload, responder) => {
264
          const agentId = typeof payload.agent_id === "string" ? payload.agent_id : "";
265
          const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
266
          const cwd = typeof payload.cwd === "string" ? resolve(payload.cwd) : "";
267
          const request = { argv: ["<agent>", agentId.slice(0, 64)], cwd };
268
          append(requestId, request, "received", "pending", "ACP delegation received");
269
          if (
270
            Object.hasOwn(payload, "assignment_credential") ||
271
            Object.hasOwn(payload, "forge_credentials")
272
          ) {
273
            append(
274
              requestId,
275
              request,
276
              "credentials_delivered",
277
              "not_used",
278
              "scoped forge credentials delivered; not used by this ACP delegation",
279
            );
280
          }
281
          if (Option.isNone(agentProcess)) {
282
            append(requestId, request, "unsupported", "refused", "ACP delegation is unavailable");
283
            responder.refused("unsupported", "ACP delegation is unavailable");
284
            return;
285
          }
286
          if (
287
            agentId === "" ||
288
            prompt === "" ||
289
            prompt.length > maximumPromptLength ||
290
            cwd === ""
291
          ) {
292
            append(
293
              requestId,
294
              request,
295
              "refused",
296
              "refused",
297
              "agent, prompt, and cwd are required and bounded",
298
            );
299
            responder.refused("invalid_request", "agent, prompt, and cwd are required and bounded");
300
            return;
301
          }
302
          if (!config.roots.some((root) => withinRoot(cwd, root))) {
303
            append(
304
              requestId,
305
              request,
306
              "root_not_declared",
307
              "refused",
308
              "the working directory is outside every declared root",
309
            );
310
            responder.refused(
311
              "root_not_declared",
312
              "the working directory is outside every declared root",
313
            );
314
            return;
315
          }
316
          const catalog = buildAgentCatalog(
317
            { agents: config.agents ?? [], registryAgents: config.registryAgents ?? false },
318
            initialProbe.codingAgents,
319
          );
320
          const resolution = resolveAgent(catalog, agentId);
321
          if (resolution._tag === "unavailable") {
322
            const detail = `agent ${resolution.requestedId} is unavailable; available agents: ${resolution.availableIds.join(", ") || "(none)"}`;
323
            append(requestId, request, "agent_unavailable", "refused", detail);
324
            responder.refused("agent_unavailable", detail);
325
            return;
326
          }
327
          const resumeSessionId =
328
            typeof payload.resume_session_id === "string"
329
              ? payload.resume_session_id.slice(0, 128)
330
              : undefined;
331
          if (resumeSessionId !== undefined) {
332
            const existing = [...agentJobs.values()].find(
333
              (value) => value.sessionId === resumeSessionId,
334
            );
335
            if (existing !== undefined) {
336
              existing.setResponder(responder);
337
              agentJobs.set(requestId, existing);
338
              append(
339
                requestId,
340
                request,
341
                "reattached",
342
                "running",
343
                "reattached to the existing ACP session",
344
              );
345
              return;
346
            }
347
            append(
348
              requestId,
349
              request,
350
              "session_not_found",
351
              "refused",
352
              "the requested ACP session is no longer live",
353
            );
354
            responder.refused("session_not_found", "the requested ACP session is no longer live");
355
            return;
356
          }
357
          if (active >= maximumConcurrency) {
358
            append(
359
              requestId,
360
              request,
361
              "allowed",
362
              "refused",
363
              "local delegation concurrency limit reached",
364
            );
365
            responder.refused("busy", "the local delegation limit is reached");
366
            return;
367
          }
368
          active += 1;
369
          let currentResponder = responder;
370
          let sessionId = "";
371
          const entry = resolution.entry;
372
          const environment = scrubbedEnvironment(process.env);
373
          for (const name of entry.env) {
374
            const value = process.env[name];
375
            if (value !== undefined) environment[name] = value;
376
          }
377
          const job = startAgentDelegation(agentProcess.value, {
378
            entry,
379
            prompt,
380
            cwd,
381
            tier: config.tier,
382
            roots: config.roots,
383
            curatedExecute: config.curatedExecute ?? [],
384
            env: environment,
385
            timeoutMs: numberField(
386
              payload,
387
              ["timeout_ms", "timeout"],
388
              maximumTimeoutMillis,
389
              maximumTimeoutMillis,
390
            ),
391
            maximumOutputBytes: numberField(
392
              payload,
393
              ["maximum_output_bytes", "max_output_bytes"],
394
              maximumOutputBytes,
395
              maximumOutputBytes,
396
            ),
397
            onChunk: (text) => currentResponder.chunk(text),
398
            onSession: (value) => {
399
              sessionId = value;
400
              currentResponder.session(value);
401
            },
402
            onPermission: (allowed, detail) => {
403
              append(
404
                requestId,
405
                request,
406
                allowed ? "permission_granted" : "permission_refused",
407
                "running",
408
                detail,
409
              );
410
            },
411
          });
412
          const state = {
413
            job,
414
            request,
415
            setResponder: (value: ComputerAgentResponder) => {
416
              currentResponder = value;
417
              if (sessionId !== "") value.session(sessionId);
418
            },
419
            get sessionId() {
420
              return sessionId;
421
            },
422
          };
423
          agentJobs.set(requestId, state);
424
          append(requestId, request, "allowed", "running", `agent=${entry.id}`);
425
          void job.done.then((outcome: AgentDelegationOutcome) => {
426
            active -= 1;
427
            for (const [key, value] of agentJobs) {
428
              if (value === state) agentJobs.delete(key);
429
            }
430
            const terminal = outcome.status;
431
            append(
432
              requestId,
433
              request,
434
              "allowed",
435
              terminal,
436
              outcome.detail || (outcome.truncated ? "output truncated" : ""),
437
            );
438
            currentResponder.exit({
439
              status: terminal,
440
              session_id: outcome.sessionId,
441
              truncated: outcome.truncated,
442
              duration_ms: outcome.durationMs,
443
            });
444
          });
445
        },
237 446
        onCancel: (requestId) => {
238 447
          const execution = executions.get(requestId);
239
          if (execution === undefined) return;
240
          execution.cancel();
448
          if (execution !== undefined) {
449
            execution.cancel();
450
          } else {
451
            agentJobs.get(requestId)?.job.cancel();
452
          }
241 453
          const request = { argv: ["<cancel>"], cwd: "" };
242 454
          append(
243 455
            requestId,
packages/openagents-cli/src/runtime.ts modified +2

@@ -10,6 +10,7 @@ import { computerChannelNodeLayer, computerSocketNodeLayer } from "./computer-ch

10 10
import { computerJournalLayer } from "./computer-journal.js";
11 11
import { computerProbeLayer } from "./computer-probe.js";
12 12
import { computerUpLayer } from "./computer-up.js";
13
import { computerAgentProcessNodeLayer } from "./computer-agents.js";
13 14
import { credentialStoreOsLayer } from "./credential-store.js";
14 15
import { pendingDeviceAuthorizationStoreLayer } from "./device-authorization-store.js";
15 16
import { deviceClientLayer } from "./device-client.js";

@@ -51,6 +52,7 @@ const computerUp = computerUpLayer.pipe(

51 52
  Layer.provide(
52 53
    Layer.mergeAll(
53 54
      computerChannel,
55
      computerAgentProcessNodeLayer,
54 56
      computerClient,
55 57
      computerConfiguration,
56 58
      computerJournal,
packages/openagents-cli/test/computer-agents.test.ts added +410

@@ -0,0 +1,410 @@

1
import { Effect } from "effect";
2
import { describe, expect, it, vi } from "vitest";
3
4
import {
5
  buildAgentCatalog,
6
  ComputerAgentProcess,
7
  computerAgentProcessNodeLayer,
8
  permissionAllowed,
9
  resolveAgent,
10
  startAgentDelegation,
11
  type AgentCatalogEntry,
12
  type AgentProcess,
13
} from "../src/computer-agents.js";
14
15
const localOpenCode = (present = true) => [{ name: "opencode", present, version: "1.2.3" }];
16
17
describe("ACP agent catalog and client", () => {
18
  it("speaks bounded JSON-RPC over a direct argv stdio process", async () => {
19
    const script = `
20
      const readline = require("node:readline");
21
      let promptId;
22
      readline.createInterface({ input: process.stdin }).on("line", (line) => {
23
        const request = JSON.parse(line);
24
        if (request.method === "initialize") {
25
          process.stdout.write(JSON.stringify({
26
            jsonrpc: "2.0",
27
            method: "session/update",
28
            params: { update: "ready" }
29
          }) + "\\n");
30
          process.stdout.write(JSON.stringify({
31
            jsonrpc: "2.0",
32
            id: request.id,
33
            result: { agentCapabilities: {} }
34
          }) + "\\n");
35
        } else if (request.method === "session/prompt") {
36
          promptId = request.id;
37
          process.stdout.write(JSON.stringify({
38
            jsonrpc: "2.0",
39
            id: "permission-1",
40
            method: "session/request_permission",
41
            params: { toolCall: { kind: "read", title: "read", rawInput: {} } }
42
          }) + "\\n");
43
        } else if (request.id === "permission-1") {
44
          process.stdout.write(JSON.stringify({
45
            jsonrpc: "2.0",
46
            id: promptId,
47
            result: { stopReason: "end_turn" }
48
          }) + "\\n");
49
        }
50
      });
51
    `;
52
    const factory = await Effect.runPromise(
53
      ComputerAgentProcess.pipe(Effect.provide(computerAgentProcessNodeLayer)),
54
    );
55
    const agent = await Effect.runPromise(
56
      factory.start(
57
        {
58
          id: "stub",
59
          argv: [process.execPath, "-e", script],
60
          source: "configured",
61
          version: "",
62
          env: [],
63
        },
64
        process.cwd(),
65
        { PATH: process.env.PATH ?? "" },
66
      ),
67
    );
68
    const updates: unknown[] = [];
69
    agent.onNotification("session/update", (params) => updates.push(params));
70
    agent.onRequest("session/request_permission", () => ({ outcome: "selected" }));
71
    await expect(agent.request("initialize", {})).resolves.toEqual({
72
      agentCapabilities: {},
73
    });
74
    await expect(agent.request("session/prompt", {})).resolves.toEqual({
75
      stopReason: "end_turn",
76
    });
77
    expect(updates).toEqual([{ update: "ready" }]);
78
    await agent.terminate();
79
  });
80
81
  it("advertises discovered OpenCode and lets owner configuration override it", () => {
82
    const catalog = buildAgentCatalog(
83
      {
84
        agents: [{ id: "opencode", argv: ["custom-opencode", "--acp"], env: ["XAI_API_KEY"] }],
85
        registryAgents: false,
86
      },
87
      localOpenCode(),
88
    );
89
    expect(catalog).toEqual([
90
      {
91
        id: "opencode",
92
        argv: ["custom-opencode", "--acp"],
93
        source: "configured",
94
        version: "",
95
        env: ["XAI_API_KEY"],
96
      },
97
    ]);
98
    expect(resolveAgent(catalog, "missing")).toEqual({
99
      _tag: "unavailable",
100
      requestedId: "missing",
101
      availableIds: ["opencode"],
102
    });
103
  });
104
105
  it("does not resolve a missing local agent remotely", () => {
106
    const catalog = buildAgentCatalog({ agents: [], registryAgents: true }, localOpenCode(false));
107
    expect(catalog).toEqual([]);
108
    expect(resolveAgent(catalog, "opencode")._tag).toBe("unavailable");
109
    expect(
110
      resolveAgent(catalog, "remote", {
111
        registryAgents: false,
112
        resolveRemote: () => ({
113
          id: "remote",
114
          argv: ["remote-agent"],
115
          source: "remote",
116
          version: "",
117
          env: [],
118
        }),
119
      })._tag,
120
    ).toBe("unavailable");
121
    expect(
122
      resolveAgent(catalog, "remote", {
123
        registryAgents: true,
124
        resolveRemote: (id) => ({
125
          id,
126
          argv: ["remote-agent"],
127
          source: "remote",
128
          version: "",
129
          env: [],
130
        }),
131
      }),
132
    ).toMatchObject({ _tag: "resolved", entry: { source: "remote" } });
133
  });
134
135
  it("maps each local permission tier without selecting a blanket bypass", () => {
136
    const roots = ["/workspace/project"];
137
    expect(permissionAllowed("probe", { kind: "read", title: "", rawInput: {} }, roots)).toBe(
138
      false,
139
    );
140
    expect(permissionAllowed("curated", { kind: "read", title: "", rawInput: {} }, roots)).toBe(
141
      true,
142
    );
143
    expect(
144
      permissionAllowed(
145
        "curated",
146
        {
147
          kind: "edit",
148
          title: "",
149
          rawInput: { path: "/workspace/project/a.ts" },
150
        },
151
        roots,
152
      ),
153
    ).toBe(true);
154
    expect(
155
      permissionAllowed(
156
        "curated",
157
        {
158
          kind: "edit",
159
          title: "",
160
          rawInput: { path: "/tmp/a.ts" },
161
        },
162
        roots,
163
      ),
164
    ).toBe(false);
165
    expect(
166
      permissionAllowed(
167
        "curated",
168
        {
169
          kind: "edit",
170
          title: "",
171
          rawInput: { path: "/workspace/project/a.ts" },
172
        },
173
        [],
174
      ),
175
    ).toBe(false);
176
    expect(
177
      permissionAllowed(
178
        "curated",
179
        {
180
          kind: "execute",
181
          title: "",
182
          rawInput: { command: "git status" },
183
        },
184
        roots,
185
      ),
186
    ).toBe(true);
187
    expect(
188
      permissionAllowed(
189
        "curated",
190
        {
191
          kind: "execute",
192
          title: "",
193
          rawInput: { command: "curl https://example.com" },
194
        },
195
        roots,
196
      ),
197
    ).toBe(false);
198
    expect(permissionAllowed("shell", { kind: "execute", title: "", rawInput: {} }, roots)).toBe(
199
      true,
200
    );
201
    expect(
202
      permissionAllowed(
203
        "shell",
204
        {
205
          kind: "execute",
206
          title: "sudo rm -rf /",
207
          rawInput: {},
208
        },
209
        roots,
210
      ),
211
    ).toBe(false);
212
  });
213
214
  it("initializes, reports the session before prompting, streams bounded output, and ends once", async () => {
215
    const calls: string[] = [];
216
    let promptText = "";
217
    let permissionHandler: ((params: unknown) => Promise<unknown>) | undefined;
218
    let resolvePrompt: ((value: unknown) => void) | undefined;
219
    const process: AgentProcess = {
220
      request: async (method, params) => {
221
        calls.push(method);
222
        if (method === "initialize") return { agentCapabilities: {} };
223
        if (method === "session/new") return { sessionId: "session-1" };
224
        if (method === "session/prompt") {
225
          const value = params as { readonly prompt?: ReadonlyArray<{ readonly text?: string }> };
226
          promptText = value.prompt?.[0]?.text ?? "";
227
          return await new Promise((resolve) => {
228
            resolvePrompt = resolve;
229
          });
230
        }
231
        return {};
232
      },
233
      notify: () => undefined,
234
      onNotification: (_method, handler) => {
235
        handler({ update: { content: [{ type: "text", text: "hello" }] } });
236
        return () => undefined;
237
      },
238
      onRequest: (_method, handler) => {
239
        permissionHandler = async (params) =>
240
          handler(params, {
241
            method: "session/request_permission",
242
            requestId: "permission-1",
243
            signal: new AbortController().signal,
244
            generation: 1,
245
          });
246
        return () => undefined;
247
      },
248
      terminate: async () => undefined,
249
    };
250
    const entry: AgentCatalogEntry = {
251
      id: "opencode",
252
      argv: ["opencode", "acp"],
253
      source: "local",
254
      version: "1.2.3",
255
      env: [],
256
    };
257
    const chunks: string[] = [];
258
    const sessions: string[] = [];
259
    const permissions: string[] = [];
260
    const job = startAgentDelegation(
261
      {
262
        start: () => Effect.succeed(process),
263
      },
264
      {
265
        entry,
266
        prompt: "x".repeat(40_000),
267
        cwd: "/workspace/project",
268
        tier: "probe",
269
        roots: ["/workspace/project"],
270
        curatedExecute: [],
271
        env: { PATH: "/bin" },
272
        timeoutMs: 1_000,
273
        maximumOutputBytes: 3,
274
        onChunk: (chunk) => chunks.push(chunk),
275
        onSession: (sessionId) => sessions.push(sessionId),
276
        onPermission: (allowed, detail) => permissions.push(`${allowed}:${detail}`),
277
      },
278
    );
279
    await Promise.resolve();
280
    await Promise.resolve();
281
    await Promise.resolve();
282
    await Promise.resolve();
283
    await Promise.resolve();
284
    expect(permissionHandler).toBeDefined();
285
    expect(
286
      await permissionHandler?.({
287
        toolCall: { kind: "read", title: "read", rawInput: {} },
288
        options: [
289
          { kind: "allow_once", optionId: "allow" },
290
          { kind: "reject_once", optionId: "reject" },
291
        ],
292
      }),
293
    ).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
294
    resolvePrompt?.({ stopReason: "end_turn" });
295
    const outcome = await job.done;
296
    expect(calls).toEqual(["initialize", "session/new", "session/prompt"]);
297
    expect(sessions).toEqual(["session-1"]);
298
    expect(chunks).toEqual(["hel"]);
299
    expect(outcome.status).toBe("truncated");
300
    expect(outcome.output).toBe("hel");
301
    expect(promptText).toHaveLength(32_768);
302
    expect(permissions).toEqual(["false:read: read"]);
303
  });
304
305
  it("loads a resumable session and bounds an impossible reattachment", async () => {
306
    const methods: string[] = [];
307
    const process: AgentProcess = {
308
      request: async (method) => {
309
        methods.push(method);
310
        if (method === "initialize") return { agentCapabilities: { loadSession: true } };
311
        if (method === "session/prompt") return { stopReason: "end_turn" };
312
        return {};
313
      },
314
      notify: () => undefined,
315
      onNotification: () => () => undefined,
316
      onRequest: () => () => undefined,
317
      terminate: async () => undefined,
318
    };
319
    const outcome = await startAgentDelegation(
320
      { start: () => Effect.succeed(process) },
321
      {
322
        entry: { id: "opencode", argv: ["opencode", "acp"], source: "local", version: "", env: [] },
323
        prompt: "continue",
324
        cwd: "/workspace/project",
325
        resumeSessionId: "existing",
326
        tier: "curated",
327
        roots: ["/workspace/project"],
328
        curatedExecute: [],
329
        env: {},
330
        timeoutMs: 1_000,
331
        maximumOutputBytes: 100,
332
        onChunk: () => undefined,
333
        onSession: () => undefined,
334
        onPermission: () => undefined,
335
      },
336
    ).done;
337
    expect(methods).toEqual(["initialize", "session/load", "session/prompt"]);
338
    expect(outcome.sessionId).toBe("existing");
339
340
    const failed = await startAgentDelegation(
341
      {
342
        start: () =>
343
          Effect.succeed({
344
            ...process,
345
            request: async (method: string) =>
346
              method === "initialize" ? { agentCapabilities: {} } : {},
347
          }),
348
      },
349
      {
350
        entry: { id: "opencode", argv: ["opencode", "acp"], source: "local", version: "", env: [] },
351
        prompt: "continue",
352
        cwd: "/workspace/project",
353
        resumeSessionId: "lost",
354
        tier: "curated",
355
        roots: ["/workspace/project"],
356
        curatedExecute: [],
357
        env: {},
358
        timeoutMs: 1_000,
359
        maximumOutputBytes: 100,
360
        onChunk: () => undefined,
361
        onSession: () => undefined,
362
        onPermission: () => undefined,
363
      },
364
    ).done;
365
    expect(failed.status).toBe("failed");
366
    expect(failed.detail).toContain("reattach");
367
  });
368
369
  it("cancels the ACP session and reports a cancelled terminal result", async () => {
370
    vi.useFakeTimers();
371
    const notifications: string[] = [];
372
    const process: AgentProcess = {
373
      request: async (method) => {
374
        if (method === "initialize") return { agentCapabilities: {} };
375
        if (method === "session/new") return { sessionId: "session-cancel" };
376
        return await new Promise(() => undefined);
377
      },
378
      notify: (method) => notifications.push(method),
379
      onNotification: () => () => undefined,
380
      onRequest: () => () => undefined,
381
      terminate: async () => undefined,
382
    };
383
    const job = startAgentDelegation(
384
      { start: () => Effect.succeed(process) },
385
      {
386
        entry: { id: "opencode", argv: ["opencode", "acp"], source: "local", version: "", env: [] },
387
        prompt: "cancel",
388
        cwd: "/workspace/project",
389
        tier: "curated",
390
        roots: ["/workspace/project"],
391
        curatedExecute: [],
392
        env: {},
393
        timeoutMs: 10_000,
394
        maximumOutputBytes: 100,
395
        onChunk: () => undefined,
396
        onSession: () => undefined,
397
        onPermission: () => undefined,
398
      },
399
    );
400
    await vi.advanceTimersByTimeAsync(0);
401
    job.cancel();
402
    await vi.advanceTimersByTimeAsync(1_000);
403
    await expect(job.done).resolves.toMatchObject({
404
      status: "cancelled",
405
      sessionId: "session-cancel",
406
    });
407
    expect(notifications).toContain("session/cancel");
408
    vi.useRealTimers();
409
  });
410
});
packages/openagents-cli/test/computer.test.ts modified +206

@@ -41,6 +41,7 @@ import {

41 41
  toolchainCatalog,
42 42
} from "../src/computer-probe.js";
43 43
import { executeComputerCommand } from "../src/computer-executor.js";
44
import { ComputerAgentProcess, type AgentProcess } from "../src/computer-agents.js";
44 45
import { ComputerClient, type ComputerStatus } from "../src/computer-client.js";
45 46
import { ComputerUp, computerUpLayer } from "../src/computer-up.js";
46 47
import { environmentLayerFromValues } from "../src/environment.js";

@@ -58,6 +59,9 @@ const computerConfigurationTestLayer = (

58 59
      tier: values.tier ?? "probe",
59 60
      roots: resolveRoots(values.roots ?? []),
60 61
      preApproved: values.preApproved ?? [],
62
      agents: values.agents ?? [],
63
      registryAgents: values.registryAgents ?? false,
64
      curatedExecute: values.curatedExecute ?? [],
61 65
      paths: values.paths ?? computerPaths(),
62 66
    }),
63 67
  );

@@ -264,6 +268,7 @@ describe("local Computer probe", () => {

264 268
    expect(report.roots).toEqual([]);
265 269
    expect(report.host.platform).toBe(process.platform);
266 270
    expect(report.codingAgents.every((entry) => typeof entry.present === "boolean")).toBe(true);
271
    expect(report.acp_agents).toBeDefined();
267 272
    expect(report.toolchains.every((entry) => typeof entry.version === "string")).toBe(true);
268 273
    expect(report.worktrees).toEqual([]);
269 274
  });

@@ -433,6 +438,70 @@ describe("Computer channel", () => {

433 438
    await expect(channelRun).resolves.toBe("phx_close");
434 439
  });
435 440
441
  it("accepts agent frames and sends the ACP session before terminal output", async () => {
442
    const socket = new StubSocket();
443
    let agentResponder:
444
      | {
445
          readonly session: (sessionId: string) => void;
446
          readonly chunk: (text: string) => void;
447
          readonly exit: (payload: Record<string, unknown>) => void;
448
          readonly refused: (reason: string, detail: string) => void;
449
        }
450
      | undefined;
451
    const run = Effect.runPromise(
452
      Effect.gen(function* () {
453
        const channel = yield* ComputerChannel;
454
        return yield* channel.serve(options, {
455
          onProbe: async () => ({}),
456
          onRun: () => undefined,
457
          onAgent: (_requestId, _payload, responder) => {
458
            agentResponder = responder;
459
          },
460
          onCancel: () => undefined,
461
          onJoined: () => undefined,
462
          onEvent: () => undefined,
463
          onClosed: () => undefined,
464
        });
465
      }).pipe(Effect.provide(computerChannelTestLayer({ connect: () => socket }))),
466
    );
467
    await Promise.resolve();
468
    socket.open();
469
    socket.message(["1", "1", "computer:machine-1", "phx_reply", { status: "ok", response: {} }]);
470
    socket.message([
471
      "1",
472
      null,
473
      "computer:machine-1",
474
      "agent",
475
      { request_id: "agent-1", agent_id: "opencode", prompt: "work", cwd: "/workspace" },
476
    ]);
477
    agentResponder?.session("acp-session-1");
478
    agentResponder?.chunk("done");
479
    agentResponder?.exit({ status: "completed" });
480
    expect(sentFrames(socket)).toContainEqual([
481
      "1",
482
      "3",
483
      "computer:machine-1",
484
      "session",
485
      { request_id: "agent-1", session_id: "acp-session-1" },
486
    ]);
487
    expect(sentFrames(socket)).toContainEqual([
488
      "1",
489
      "4",
490
      "computer:machine-1",
491
      "chunk",
492
      { request_id: "agent-1", text: "done" },
493
    ]);
494
    expect(sentFrames(socket)).toContainEqual([
495
      "1",
496
      "5",
497
      "computer:machine-1",
498
      "exit",
499
      { request_id: "agent-1", status: "completed" },
500
    ]);
501
    socket.message(["1", null, "computer:machine-1", "phx_close", {}]);
502
    await expect(run).resolves.toBe("phx_close");
503
  });
504
436 505
  it("retries machine_reconnecting and stops on authorization refusals", async () => {
437 506
    vi.useFakeTimers();
438 507
    const reconnectingSockets: StubSocket[] = [];

@@ -571,6 +640,143 @@ describe("Computer channel", () => {

571 640
    expect(sentFrames(socket)).toContainEqual([null, "2", "phoenix", "heartbeat", {}]);
572 641
    await expect(result).resolves.toBe("heartbeat_timeout");
573 642
  });
643
644
  it("serves an accepted agent request through the local ACP process boundary", async () => {
645
    let handlers: ComputerChannelHandlers | undefined;
646
    const terminal: Array<Record<string, unknown>> = [];
647
    let startedEnvironment: Readonly<Record<string, string>> = {};
648
    const machineStatus: ComputerStatus = {
649
      machine_id: "machine-1",
650
      name: "test-computer",
651
      status: "active",
652
      token_expires_at: "2099-01-01T00:00:00.000Z",
653
    };
654
    const process: AgentProcess = {
655
      request: async (method) => {
656
        if (method === "initialize") return { agentCapabilities: {} };
657
        if (method === "session/new") return { sessionId: "session-up" };
658
        if (method === "session/prompt") return { stopReason: "end_turn" };
659
        return {};
660
      },
661
      notify: () => undefined,
662
      onNotification: () => () => undefined,
663
      onRequest: () => () => undefined,
664
      terminate: async () => undefined,
665
    };
666
    const channel = Layer.succeed(
667
      ComputerChannel,
668
      ComputerChannel.of({
669
        serve: (_options, value) => {
670
          handlers = value;
671
          value.onJoined();
672
          return Effect.succeed("phx_close");
673
        },
674
      }),
675
    );
676
    const processLayer = Layer.succeed(ComputerAgentProcess, {
677
      start: (_entry, _cwd, env) => {
678
        startedEnvironment = env;
679
        return Effect.succeed(process);
680
      },
681
    });
682
    const probe = Layer.succeed(
683
      ComputerProbe,
684
      ComputerProbe.of({
685
        probe: () =>
686
          Effect.succeed({
687
            schema: "openagents.computer_probe.v1" as const,
688
            host: {
689
              platform: "linux",
690
              release: "test",
691
              architecture: "x64",
692
              hostname: "test",
693
              shell: "",
694
              cpuCount: 1,
695
              totalMemoryBytes: 1,
696
              uptimeSeconds: 1,
697
            },
698
            codingAgents: [
699
              { name: "opencode", present: true, path: "/usr/bin/opencode", version: "1.2.3" },
700
            ],
701
            toolchains: [],
702
            roots: ["/workspace"],
703
            worktrees: [],
704
          }),
705
      }),
706
    );
707
    const layer = computerUpLayer.pipe(
708
      Layer.provide(
709
        Layer.mergeAll(
710
          channel,
711
          processLayer,
712
          Layer.succeed(
713
            ComputerClient,
714
            ComputerClient.of({
715
              start: () => Effect.die("unused"),
716
              wait: () => Effect.die("unused"),
717
              status: () => Effect.succeed(Option.some(machineStatus)),
718
            }),
719
          ),
720
          Layer.succeed(
721
            CredentialStore,
722
            CredentialStore.of({
723
              get: () => Effect.succeed(Option.some(Redacted.make("smct_test-secret"))),
724
              set: () => Effect.void,
725
              remove: () => Effect.void,
726
            }),
727
          ),
728
          Layer.succeed(
729
            ComputerJournal,
730
            ComputerJournal.of({
731
              append: (entry) => Effect.sync(() => terminal.push(entry)),
732
              read: () => Effect.succeed([]),
733
            }),
734
          ),
735
          probe,
736
          computerConfigurationTestLayer({ tier: "curated", roots: ["/workspace"] }),
737
        ),
738
      ),
739
    );
740
    await Effect.runPromise(
741
      Effect.gen(function* () {
742
        const up = yield* ComputerUp;
743
        return yield* up.serve("https://openagents.example", "0.2.1");
744
      }).pipe(Effect.provide(layer)),
745
    );
746
    const responderOutput: Array<Record<string, unknown>> = [];
747
    handlers?.onAgent?.(
748
      "agent-1",
749
      {
750
        request_id: "agent-1",
751
        agent_id: "opencode",
752
        prompt: "delegate",
753
        cwd: "/workspace",
754
        assignment_credential: "forge-secret",
755
        env: { FORGE_TOKEN: "forge-secret" },
756
      },
757
      {
758
        session: () => undefined,
759
        chunk: () => undefined,
760
        exit: (value) => responderOutput.push(value),
761
        refused: (reason, detail) => responderOutput.push({ reason, detail }),
762
      },
763
    );
764
    for (let attempt = 0; attempt < 50 && responderOutput.length === 0; attempt += 1) {
765
      await new Promise((resolve) => setTimeout(resolve, 10));
766
    }
767
    expect(responderOutput).toHaveLength(1);
768
    expect(responderOutput[0]).toMatchObject({ status: "completed", session_id: "session-up" });
769
    expect(startedEnvironment).not.toHaveProperty("FORGE_TOKEN");
770
    expect(JSON.stringify(terminal)).not.toContain("smct_test-secret");
771
    expect(JSON.stringify(terminal)).not.toContain("forge-secret");
772
    expect(terminal).toContainEqual(
773
      expect.objectContaining({
774
        decision: "credentials_delivered",
775
        outcome: "not_used",
776
        detail: "scoped forge credentials delivered; not used by this ACP delegation",
777
      }),
778
    );
779
  });
574 780
});
575 781
576 782
describe("Computer up service", () => {

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