Serve bounded Computer requests

77043c01bf59 · Devin AI · · parent a7adc69244d7

Serve bounded Computer requests

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 README.md
  • modified packages/openagents-cli/package.json
  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/computer-channel.ts
  • added packages/openagents-cli/src/computer-executor.ts
  • modified packages/openagents-cli/src/computer-policy.ts
  • added packages/openagents-cli/src/computer-up.ts
  • modified packages/openagents-cli/src/index.ts
  • modified packages/openagents-cli/src/runtime.ts
  • modified pnpm-lock.yaml

Diff

10 files changed, +676 -5

README.md modified +1 -2

@@ -130,8 +130,7 @@ probes and source-derived fixtures remain explicitly non-release evidence. A

130 130
checked opt-in production runner is no longer part of the support path. The
131 131
[human proof ledger](docs/qa/2026-07-16-acp10-release-proof/README.md) remains a
132 132
historical record, and no release-evidence path can promote either peer above
133
`experimental`. ACP peer support remains experimental because it has no
134
release-evidence path.
133
`experimental`.
135 134
Hermetic conformance checks cover the required scenario catalog. Other
136 135
platforms remain explicitly untested. Execution is tracked in
137 136
[#8887 — Full Agent Client Protocol integration for Grok and Cursor](https://github.com/OpenAgentsInc/openagents/issues/8887).
packages/openagents-cli/package.json modified +3 -1

@@ -54,10 +54,12 @@

54 54
  "dependencies": {
55 55
    "@effect/platform-node": "catalog:",
56 56
    "@effect/platform-node-shared": "4.0.0-beta.94",
57
    "effect": "catalog:"
57
    "effect": "catalog:",
58
    "ws": "8.21.1"
58 59
  },
59 60
  "devDependencies": {
60 61
    "@types/node": "catalog:",
62
    "@types/ws": "8.18.1",
61 63
    "typescript": "catalog:",
62 64
    "vite-plus": "0.2.4",
63 65
    "vitest": "^4.1.10"
packages/openagents-cli/src/cli.ts modified +23

@@ -21,6 +21,7 @@ import { runCoderUi } from "./coder-ui.js";

21 21
import { OxAlphaReplySource } from "./coder-ox.js";
22 22
import { describeWorkspace } from "./coder-workspace.js";
23 23
import { ComputerClient } from "./computer-client.js";
24
import { ComputerUp } from "./computer-up.js";
24 25
import {
25 26
  ComputerConfiguration,
26 27
  type ComputerConfigurationValues,

@@ -255,6 +256,27 @@ const computerStatusCommand = Command.make("status", {}, () =>

255 256
  ),
256 257
);
257 258
259
const computerUpCommand = Command.make("up", {}, () =>
260
  Effect.gen(function* () {
261
    const flags = yield* rootCommand;
262
    const endpoint = yield* resolveApiEndpoint(endpointOverrides(flags));
263
    const up = yield* ComputerUp;
264
    const reason = yield* up.serve(endpoint.origin);
265
    const output = yield* Output;
266
    yield* output.write(
267
      {
268
        value: { schema: "openagents.computer_connection.v1", state: "closed", reason },
269
        human: [`Computer connection ended: ${reason}`],
270
      },
271
      outputMode(flags.json),
272
    );
273
  }),
274
).pipe(
275
  Command.withDescription(
276
    "Serve bounded Computer requests over an outbound connection until the server disconnects.",
277
  ),
278
);
279
258 280
const computerPairCommand = Command.make(
259 281
  "pair",
260 282
  { tier: computerTierFlag, root: computerRootFlag },

@@ -454,6 +476,7 @@ const computerCommand = Command.make("computer").pipe(

454 476
    computerProbeCommand,
455 477
    computerPolicyCommand,
456 478
    computerStatusCommand,
479
    computerUpCommand,
457 480
    computerPairCommand,
458 481
    computerLogoutCommand,
459 482
    computerJournalCommand,
packages/openagents-cli/src/computer-channel.ts added +179

@@ -0,0 +1,179 @@

1
import WebSocket from "ws";
2
import { Effect, Layer, Redacted } from "effect";
3
import * as Context from "effect/Context";
4
5
type Frame = [string | null, string | null, string, string, unknown];
6
7
export interface ComputerChannelOptions {
8
  readonly origin: string;
9
  readonly token: Redacted.Redacted<string>;
10
  readonly machineId: string;
11
  readonly hello: unknown;
12
  readonly heartbeatMillis?: number;
13
}
14
15
export interface ComputerResponder {
16
  readonly chunk: (text: string) => void;
17
  readonly exit: (payload: Record<string, unknown>) => void;
18
  readonly refused: (reason: string, detail: string) => void;
19
}
20
21
export interface ComputerChannelHandlers {
22
  readonly onProbe: (requestId: string) => Promise<unknown>;
23
  readonly onRun: (
24
    requestId: string,
25
    payload: Record<string, unknown>,
26
    responder: ComputerResponder,
27
  ) => void;
28
  readonly onCancel: (requestId: string) => void;
29
  readonly onJoined: () => void;
30
  readonly onEvent: (event: string) => void;
31
  readonly onClosed: (reason: string) => void;
32
}
33
34
export interface ComputerChannelInterface {
35
  readonly serve: (
36
    options: ComputerChannelOptions,
37
    handlers: ComputerChannelHandlers,
38
  ) => Effect.Effect<string>;
39
}
40
41
export class ComputerChannel extends Context.Service<ComputerChannel, ComputerChannelInterface>()(
42
  "@openagentsinc/cli/ComputerChannel",
43
) {}
44
45
const frame = (value: unknown): value is Frame =>
46
  Array.isArray(value) &&
47
  value.length === 5 &&
48
  (value[0] === null || typeof value[0] === "string") &&
49
  (value[1] === null || typeof value[1] === "string") &&
50
  typeof value[2] === "string" &&
51
  typeof value[3] === "string";
52
53
const record = (value: unknown): Record<string, unknown> =>
54
  typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
55
56
const socketUrl = (origin: string, token: string): string =>
57
  `${origin.replace(/^http/u, "ws").replace(/\/$/u, "")}/controller/socket/websocket?vsn=2.0.0&token=${encodeURIComponent(token)}`;
58
59
const serveLive = (
60
  options: ComputerChannelOptions,
61
  handlers: ComputerChannelHandlers,
62
): Promise<string> =>
63
  new Promise((resolve) => {
64
    const topic = `computer:${options.machineId}`;
65
    const joinRef = "1";
66
    let heartbeatRef = "";
67
    let reference = 1;
68
    let heartbeat: NodeJS.Timeout | undefined;
69
    let heartbeatPending = false;
70
    let finished = false;
71
    const socket = new WebSocket(socketUrl(options.origin, Redacted.value(options.token)));
72
73
    const finish = (reason: string): void => {
74
      if (finished) return;
75
      finished = true;
76
      if (heartbeat !== undefined) clearInterval(heartbeat);
77
      handlers.onClosed(reason);
78
      if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
79
        socket.close();
80
      }
81
      resolve(reason);
82
    };
83
84
    const push = (event: string, payload: unknown, joined = true, forcedRef?: string): void => {
85
      if (socket.readyState !== WebSocket.OPEN) return;
86
      reference += 1;
87
      const outgoing: Frame = [
88
        joined ? joinRef : null,
89
        forcedRef ?? String(reference),
90
        joined ? topic : "phoenix",
91
        event,
92
        payload,
93
      ];
94
      socket.send(JSON.stringify(outgoing));
95
    };
96
97
    const responder = (requestId: string): ComputerResponder => ({
98
      chunk: (text) => push("chunk", { request_id: requestId, text }),
99
      exit: (payload) => push("exit", { request_id: requestId, ...payload }),
100
      refused: (reason, detail) => push("refused", { request_id: requestId, reason, detail }),
101
    });
102
103
    socket.on("open", () => {
104
      socket.send(JSON.stringify([joinRef, joinRef, topic, "phx_join", {}]));
105
      heartbeat = setInterval(() => {
106
        if (heartbeatPending) {
107
          finish("heartbeat_timeout");
108
          return;
109
        }
110
        if (socket.readyState !== WebSocket.OPEN) {
111
          finish("socket_not_open");
112
          return;
113
        }
114
        heartbeatPending = true;
115
        const ref = String(++reference);
116
        heartbeatRef = ref;
117
        push("heartbeat", {}, false, ref);
118
      }, options.heartbeatMillis ?? 30_000);
119
      heartbeat.unref();
120
    });
121
122
    socket.on("message", (data: WebSocket.RawData) => {
123
      let parsed: unknown;
124
      try {
125
        parsed = JSON.parse(data.toString());
126
      } catch {
127
        return;
128
      }
129
      if (!frame(parsed)) return;
130
      const [, responseRef, responseTopic, event, rawPayload] = parsed;
131
      if (responseTopic === "phoenix" && event === "phx_reply" && responseRef === heartbeatRef) {
132
        heartbeatPending = false;
133
        return;
134
      }
135
      if (responseTopic !== topic) return;
136
      if (event === "phx_reply") {
137
        const payload = record(rawPayload);
138
        if (responseRef !== joinRef) return;
139
        if (payload.status === "ok") {
140
          handlers.onJoined();
141
          push("hello", options.hello);
142
        } else {
143
          finish(`join_refused:${JSON.stringify(payload.response ?? {})}`);
144
        }
145
        return;
146
      }
147
      if (event === "phx_close" || event === "phx_error") {
148
        finish(event);
149
        return;
150
      }
151
      const payload = record(rawPayload);
152
      const requestId = payload.request_id;
153
      if (typeof requestId !== "string") return;
154
      if (event === "probe") {
155
        handlers.onEvent(`probe:${requestId.slice(0, 8)}`);
156
        handlers.onProbe(requestId).then(
157
          (probe) => push("probe_result", { request_id: requestId, probe }),
158
          () => push("probe_refused", { request_id: requestId }),
159
        );
160
      } else if (event === "run") {
161
        handlers.onEvent(`run:${requestId.slice(0, 8)}`);
162
        handlers.onRun(requestId, payload, responder(requestId));
163
      } else if (event === "cancel") {
164
        handlers.onEvent(`cancel:${requestId.slice(0, 8)}`);
165
        handlers.onCancel(requestId);
166
      }
167
    });
168
    socket.on("error", (cause: Error) => finish(`error:${cause.message}`));
169
    socket.on("close", () => finish("closed"));
170
  });
171
172
export const computerChannelNodeLayer = Layer.effect(
173
  ComputerChannel,
174
  Effect.succeed(
175
    ComputerChannel.of({
176
      serve: (options, handlers) => Effect.promise(() => serveLive(options, handlers)),
177
    }),
178
  ),
179
);
packages/openagents-cli/src/computer-executor.ts added +167

@@ -0,0 +1,167 @@

1
import { spawn, type ChildProcess } from "node:child_process";
2
3
export interface ComputerExecutionLimits {
4
  readonly timeoutMillis: number;
5
  readonly maximumOutputBytes: number;
6
}
7
8
export interface ComputerExecutionOutcome {
9
  readonly exitCode: number | null;
10
  readonly truncated: boolean;
11
  readonly timedOut: boolean;
12
  readonly cancelled: boolean;
13
  readonly durationMillis: number;
14
}
15
16
export interface RunningComputerExecution {
17
  readonly done: Promise<ComputerExecutionOutcome>;
18
  readonly cancel: () => void;
19
}
20
21
export const computerExecutionDefaults: ComputerExecutionLimits = {
22
  timeoutMillis: 30_000,
23
  maximumOutputBytes: 64 * 1024,
24
};
25
26
const environmentNames = ["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "SHELL", "USER", "TERM"];
27
28
const scrubEnvironment = (source: NodeJS.ProcessEnv): NodeJS.ProcessEnv =>
29
  Object.fromEntries(
30
    environmentNames.flatMap((name) => {
31
      const value = source[name];
32
      return value === undefined ? [] : [[name, value]];
33
    }),
34
  );
35
36
const scrubOutput = (text: string): string =>
37
  text
38
    .replaceAll(
39
      /(?:oa_(?:pat|agent|assignment)_[A-Za-z0-9._-]+|smct_[A-Za-z0-9._-]+)/gu,
40
      "[REDACTED]",
41
    )
42
    .replaceAll(/Bearer\s+\S+/giu, "Bearer [REDACTED]");
43
44
const terminateGroup = (child: ChildProcess): void => {
45
  if (child.pid === undefined) return;
46
  try {
47
    if (process.platform === "win32") {
48
      child.kill("SIGTERM");
49
    } else {
50
      process.kill(-child.pid, "SIGTERM");
51
    }
52
  } catch {
53
    child.kill("SIGTERM");
54
  }
55
  const escalation = setTimeout(() => {
56
    try {
57
      if (process.platform === "win32") child.kill("SIGKILL");
58
      else if (child.pid !== undefined) process.kill(-child.pid, "SIGKILL");
59
    } catch {
60
      child.kill("SIGKILL");
61
    }
62
  }, 2_000);
63
  escalation.unref();
64
};
65
66
export const executeComputerCommand = (
67
  argv: ReadonlyArray<string>,
68
  cwd: string,
69
  limits: ComputerExecutionLimits,
70
  onChunk: (text: string) => void,
71
): RunningComputerExecution => {
72
  const startedAt = Date.now();
73
  let settled = false;
74
  let cancelled = false;
75
  let timedOut = false;
76
  let bytes = 0;
77
  let truncated = false;
78
  let resolveDone: (outcome: ComputerExecutionOutcome) => void = () => undefined;
79
  const done = new Promise<ComputerExecutionOutcome>((resolve) => {
80
    resolveDone = resolve;
81
  });
82
  const [command, ...args] = argv;
83
  if (command === undefined) {
84
    queueMicrotask(() => {
85
      settled = true;
86
      resolveDone({
87
        exitCode: 127,
88
        truncated: false,
89
        timedOut: false,
90
        cancelled: false,
91
        durationMillis: Date.now() - startedAt,
92
      });
93
    });
94
    return { done, cancel: () => undefined };
95
  }
96
97
  let child: ChildProcess;
98
  try {
99
    child = spawn(command, args, {
100
      cwd,
101
      detached: process.platform !== "win32",
102
      env: scrubEnvironment(process.env),
103
      shell: false,
104
      stdio: ["ignore", "pipe", "pipe"],
105
    });
106
  } catch {
107
    queueMicrotask(() => {
108
      settled = true;
109
      resolveDone({
110
        exitCode: 127,
111
        truncated: false,
112
        timedOut: false,
113
        cancelled: false,
114
        durationMillis: Date.now() - startedAt,
115
      });
116
    });
117
    return { done, cancel: () => undefined };
118
  }
119
120
  const finish = (exitCode: number | null): void => {
121
    if (settled) return;
122
    settled = true;
123
    resolveDone({
124
      exitCode,
125
      truncated,
126
      timedOut,
127
      cancelled,
128
      durationMillis: Date.now() - startedAt,
129
    });
130
  };
131
  const emit = (chunk: Buffer): void => {
132
    const text = scrubOutput(chunk.toString("utf8"));
133
    const encoded = Buffer.from(text, "utf8");
134
    if (bytes >= limits.maximumOutputBytes) {
135
      truncated = true;
136
      return;
137
    }
138
    const remaining = limits.maximumOutputBytes - bytes;
139
    const bounded = encoded.subarray(0, remaining);
140
    bytes += bounded.byteLength;
141
    truncated ||= bounded.byteLength < encoded.byteLength;
142
    if (bounded.byteLength > 0) onChunk(bounded.toString("utf8"));
143
  };
144
  child.stdout?.on("data", emit);
145
  child.stderr?.on("data", emit);
146
  const timeout = setTimeout(() => {
147
    timedOut = true;
148
    terminateGroup(child);
149
  }, limits.timeoutMillis);
150
  timeout.unref();
151
  child.once("error", () => {
152
    clearTimeout(timeout);
153
    finish(127);
154
  });
155
  child.once("close", (code) => {
156
    clearTimeout(timeout);
157
    finish(code);
158
  });
159
  return {
160
    done,
161
    cancel: () => {
162
      if (settled) return;
163
      cancelled = true;
164
      terminateGroup(child);
165
    },
166
  };
167
};
packages/openagents-cli/src/computer-policy.ts modified +2 -1

@@ -30,7 +30,8 @@ export type RefusalReason =

30 30
  | "root_not_declared"
31 31
  | "denied_command"
32 32
  | "denied_argument"
33
  | "shell_metacharacter";
33
  | "shell_metacharacter"
34
  | "confirmation_required";
34 35
35 36
export type Decision =
36 37
  | { readonly _tag: "Allowed"; readonly needsConfirmation: boolean }
packages/openagents-cli/src/computer-up.ts added +279

@@ -0,0 +1,279 @@

1
import { Effect, Layer, Option } from "effect";
2
import * as Context from "effect/Context";
3
4
import { ComputerChannel, type ComputerChannelHandlers } from "./computer-channel.js";
5
import { ComputerClient } from "./computer-client.js";
6
import { ComputerConfiguration } from "./computer-config.js";
7
import { ComputerJournal, type JournalInterface } from "./computer-journal.js";
8
import {
9
  computerExecutionDefaults,
10
  executeComputerCommand,
11
  type ComputerExecutionLimits,
12
  type RunningComputerExecution,
13
} from "./computer-executor.js";
14
import { ComputerProbe } from "./computer-probe.js";
15
import { decide, tierAllows, type CommandRequest, type Tier } from "./computer-policy.js";
16
import { CredentialStore } from "./credential-store.js";
17
import { InputError, type CliError } from "./errors.js";
18
19
export interface ComputerUpInterface {
20
  readonly serve: (origin: string) => Effect.Effect<string, CliError>;
21
}
22
23
export class ComputerUp extends Context.Service<ComputerUp, ComputerUpInterface>()(
24
  "@openagentsinc/cli/ComputerUp",
25
) {}
26
27
const maximumConcurrency = 2;
28
const maximumArgvLength = 64;
29
const maximumArgumentLength = 1_024;
30
const maximumTimeoutMillis = computerExecutionDefaults.timeoutMillis;
31
const maximumOutputBytes = computerExecutionDefaults.maximumOutputBytes;
32
33
const requestFields = (payload: Record<string, unknown>): CommandRequest | undefined => {
34
  const argv = payload.argv;
35
  const cwd = payload.cwd;
36
  if (
37
    !Array.isArray(argv) ||
38
    argv.length === 0 ||
39
    argv.length > maximumArgvLength ||
40
    !argv.every((value) => typeof value === "string" && value.length <= maximumArgumentLength) ||
41
    typeof cwd !== "string" ||
42
    cwd.length > 4_096
43
  ) {
44
    return undefined;
45
  }
46
  return { argv, cwd };
47
};
48
49
const numberField = (
50
  payload: Record<string, unknown>,
51
  names: ReadonlyArray<string>,
52
  fallback: number,
53
  ceiling: number,
54
): number => {
55
  const requested = names.map((name) => payload[name]).find((value) => typeof value === "number");
56
  if (typeof requested !== "number" || !Number.isFinite(requested) || requested <= 0)
57
    return fallback;
58
  return Math.min(Math.floor(requested), ceiling);
59
};
60
61
const journal = (
62
  journalService: JournalInterface,
63
  requestId: string,
64
  request: CommandRequest,
65
  decision: string,
66
  outcome: string,
67
  detail: string,
68
): void => {
69
  Effect.runFork(
70
    journalService.append({
71
      requestId,
72
      argv: request.argv,
73
      cwd: request.cwd,
74
      decision,
75
      outcome,
76
      detail,
77
    }),
78
  );
79
};
80
81
export const computerUpLayer = Layer.effect(
82
  ComputerUp,
83
  Effect.gen(function* () {
84
    const channel = yield* ComputerChannel;
85
    const client = yield* ComputerClient;
86
    const config = yield* ComputerConfiguration;
87
    const credentials = yield* CredentialStore;
88
    const journalService = yield* ComputerJournal;
89
    const probe = yield* ComputerProbe;
90
    const probeContext = yield* Effect.context<ComputerProbe>();
91
92
    const serve = Effect.fn("ComputerUp.serve")(function* (origin: string) {
93
      const stored = yield* credentials.get(origin, "computer");
94
      if (Option.isNone(stored)) {
95
        return yield* new InputError({
96
          message: `This Computer is not paired with ${origin}; run computer pair first.`,
97
        });
98
      }
99
      const status = yield* client.status(origin, stored.value);
100
      if (Option.isNone(status)) {
101
        return yield* new InputError({
102
          message: `This Computer is no longer active on ${origin}; run computer logout.`,
103
        });
104
      }
105
      const initialProbe = yield* probe.probe(config.roots);
106
      const executions = new Map<string, RunningComputerExecution>();
107
      let active = 0;
108
      const append = (
109
        requestId: string,
110
        request: CommandRequest,
111
        decision: string,
112
        outcome: string,
113
        detail: string,
114
      ) => journal(journalService, requestId, request, decision, outcome, detail);
115
      const handlers: ComputerChannelHandlers = {
116
        onProbe: async (requestId) => {
117
          const request = { argv: ["<probe>"], cwd: config.roots[0] ?? "" };
118
          append(requestId, request, "received", "pending", "read-only probe requested");
119
          try {
120
            const report = await Effect.runPromiseWith(probeContext)(probe.probe(config.roots));
121
            append(requestId, request, "allowed", "completed", "probe completed");
122
            return report;
123
          } catch (cause) {
124
            append(requestId, request, "allowed", "refused", String(cause));
125
            throw cause;
126
          }
127
        },
128
        onRun: (requestId, payload, responder) => {
129
          const request = requestFields(payload);
130
          if (request === undefined) {
131
            const malformed = { argv: ["<invalid>"], cwd: "" };
132
            append(requestId, malformed, "refused", "refused", "invalid command request");
133
            responder.refused("invalid_request", "argv and cwd are required and must be bounded");
134
            return;
135
          }
136
          append(requestId, request, "received", "pending", "command request received");
137
          const requestedTier = payload.tier;
138
          if (
139
            (requestedTier === "probe" ||
140
              requestedTier === "curated" ||
141
              requestedTier === "shell") &&
142
            !tierAllows(config.tier, requestedTier as Tier)
143
          ) {
144
            append(
145
              requestId,
146
              request,
147
              "tier_insufficient",
148
              "refused",
149
              "the requested tier exceeds the local ceiling",
150
            );
151
            responder.refused("tier_insufficient", "the requested tier exceeds the local ceiling");
152
            return;
153
          }
154
          const decision = decide(request, config);
155
          if ("reason" in decision) {
156
            append(requestId, request, decision.reason, "refused", decision.detail);
157
            responder.refused(decision.reason, decision.detail);
158
            return;
159
          }
160
          if (decision.needsConfirmation) {
161
            append(
162
              requestId,
163
              request,
164
              "confirmation_required",
165
              "refused",
166
              "local confirmation is required",
167
            );
168
            responder.refused(
169
              "confirmation_required",
170
              "local confirmation is required for this command",
171
            );
172
            return;
173
          }
174
          if (active >= maximumConcurrency) {
175
            append(
176
              requestId,
177
              request,
178
              "allowed",
179
              "refused",
180
              "local execution concurrency limit reached",
181
            );
182
            responder.refused("busy", "the local execution limit is reached");
183
            return;
184
          }
185
          active += 1;
186
          const limits: ComputerExecutionLimits = {
187
            timeoutMillis: numberField(
188
              payload,
189
              ["timeout_ms", "timeout"],
190
              maximumTimeoutMillis,
191
              maximumTimeoutMillis,
192
            ),
193
            maximumOutputBytes: numberField(
194
              payload,
195
              ["maximum_output_bytes", "output_max_bytes", "max_output_bytes"],
196
              maximumOutputBytes,
197
              maximumOutputBytes,
198
            ),
199
          };
200
          append(requestId, request, "allowed", "running", `timeout=${limits.timeoutMillis}`);
201
          const execution = executeComputerCommand(
202
            request.argv,
203
            request.cwd,
204
            limits,
205
            responder.chunk,
206
          );
207
          executions.set(requestId, execution);
208
          void execution.done
209
            .then((outcome) => {
210
              active -= 1;
211
              executions.delete(requestId);
212
              const terminalStatus = outcome.cancelled
213
                ? "cancelled"
214
                : outcome.timedOut
215
                  ? "timeout"
216
                  : outcome.exitCode === 0
217
                    ? "completed"
218
                    : "failed";
219
              const detail = outcome.truncated ? "output truncated" : "";
220
              append(requestId, request, "allowed", terminalStatus, detail);
221
              responder.exit({
222
                status: terminalStatus,
223
                exit_code: outcome.exitCode,
224
                timed_out: outcome.timedOut,
225
                cancelled: outcome.cancelled,
226
                truncated: outcome.truncated,
227
                duration_ms: outcome.durationMillis,
228
              });
229
            })
230
            .catch(() => {
231
              active -= 1;
232
              executions.delete(requestId);
233
              append(requestId, request, "allowed", "failed", "execution failed");
234
              responder.exit({ status: "failed", exit_code: null, truncated: false });
235
            });
236
        },
237
        onCancel: (requestId) => {
238
          const execution = executions.get(requestId);
239
          if (execution === undefined) return;
240
          execution.cancel();
241
          const request = { argv: ["<cancel>"], cwd: "" };
242
          append(
243
            requestId,
244
            request,
245
            "allowed",
246
            "cancelling",
247
            "process group termination requested",
248
          );
249
        },
250
        onJoined: () => undefined,
251
        onEvent: (event) => {
252
          const request = { argv: ["<connection>"], cwd: "" };
253
          append("connection", request, "transport", "event", event);
254
        },
255
        onClosed: (reason) => {
256
          const request = { argv: ["<connection>"], cwd: "" };
257
          append("connection", request, "transport", "closed", reason);
258
          for (const execution of executions.values()) execution.cancel();
259
        },
260
      };
261
      return yield* channel.serve(
262
        {
263
          origin,
264
          token: stored.value,
265
          machineId: status.value.machine_id,
266
          hello: {
267
            agent_version: "openagents-cli",
268
            tier: config.tier,
269
            roots: config.roots,
270
            platform: `${process.platform}-${process.arch}`,
271
            probe: initialProbe,
272
          },
273
        },
274
        handlers,
275
      );
276
    });
277
    return ComputerUp.of({ serve });
278
  }),
279
);
packages/openagents-cli/src/index.ts modified +1 -1

@@ -2,8 +2,8 @@ export * from "./api-contract.js";

2 2
export * from "./api-passthrough.js";
3 3
export * from "./api-transport.js";
4 4
export * from "./cli.js";
5
export * from "./computer-config.js";
6 5
export * from "./computer-client.js";
6
export * from "./computer-config.js";
7 7
export * from "./computer-journal.js";
8 8
export * from "./computer-policy.js";
9 9
export * from "./computer-probe.js";
packages/openagents-cli/src/runtime.ts modified +15

@@ -6,8 +6,10 @@ import { apiTransportNodeLayer, networkPolicyLiveLayer } from "./api-transport.j

6 6
import { browserLauncherLayer } from "./browser-launcher.js";
7 7
import { computerConfigurationLayer } from "./computer-config.js";
8 8
import { computerClientLayer } from "./computer-client.js";
9
import { computerChannelNodeLayer } from "./computer-channel.js";
9 10
import { computerJournalLayer } from "./computer-journal.js";
10 11
import { computerProbeLayer } from "./computer-probe.js";
12
import { computerUpLayer } from "./computer-up.js";
11 13
import { credentialStoreOsLayer } from "./credential-store.js";
12 14
import { pendingDeviceAuthorizationStoreLayer } from "./device-authorization-store.js";
13 15
import { deviceClientLayer } from "./device-client.js";

@@ -40,6 +42,18 @@ const computerJournal = computerJournalLayer.pipe(Layer.provide(computerConfigur

40 42
const computerProbe = computerProbeLayer.pipe(
41 43
  Layer.provide(Layer.merge(computerConfiguration, NodeServices.layer)),
42 44
);
45
const computerUp = computerUpLayer.pipe(
46
  Layer.provide(
47
    Layer.mergeAll(
48
      computerChannelNodeLayer,
49
      computerClient,
50
      computerConfiguration,
51
      computerJournal,
52
      computerProbe,
53
      credentialsLayer,
54
    ),
55
  ),
56
);
43 57
44 58
const nodeDependentServices = Layer.mergeAll(
45 59
  outputLayer,

@@ -64,5 +78,6 @@ export const runtimeLayer = Layer.mergeAll(

64 78
  computerConfiguration,
65 79
  computerJournal,
66 80
  computerProbe,
81
  computerUp,
67 82
  nodeDependentServices,
68 83
);
pnpm-lock.yaml modified +6

@@ -1154,10 +1154,16 @@ importers:

1154 1154
      effect:
1155 1155
        specifier: 4.0.0-beta.94
1156 1156
        version: 4.0.0-beta.94
1157
      ws:
1158
        specifier: 8.21.1
1159
        version: 8.21.1
1157 1160
    devDependencies:
1158 1161
      '@types/node':
1159 1162
        specifier: 24.13.1
1160 1163
        version: 24.13.1
1164
      '@types/ws':
1165
        specifier: 8.18.1
1166
        version: 8.18.1
1161 1167
      typescript:
1162 1168
        specifier: 'catalog:'
1163 1169
        version: 6.0.3

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