Pair a Computer with OpenAgents from the CLI

5e947783da63 · Devin AI · · parent 56eaa4243210

Pair a Computer with OpenAgents from the CLI

Add browser-approved Computer pairing with endpoint-scoped machine credentials,
server polling, exactly-once claims, and explicit failure outcomes. Keep the
machine token in the OS credential store and the poll secret in memory, and
make status distinguish local pending state from an active remote pairing.

Closes #16.

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

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/README.md
  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/computer-client.ts
  • modified packages/openagents-cli/src/credential-store.ts
  • modified packages/openagents-cli/src/device-authorization-store.ts
  • modified packages/openagents-cli/src/errors.ts
  • modified packages/openagents-cli/src/index.ts
  • modified packages/openagents-cli/src/main.ts
  • modified packages/openagents-cli/src/runtime.ts
  • added packages/openagents-cli/test/computer-client.test.ts
  • added packages/openagents-cli/test/computer-pairing.test.ts
  • modified packages/openagents-cli/test/computer.test.ts
  • modified packages/openagents-cli/test/credential-store.test.ts
  • modified pnpm-workspace.yaml

Diff

14 files changed, +1112 -33

packages/openagents-cli/README.md modified +21

@@ -119,6 +119,27 @@ Run `openagents auth status` to inspect the selected endpoint and credential

119 119
source. Run `openagents auth logout` to remove the stored credential for that
120 120
exact API origin.
121 121
122
## Pair a Computer
123
124
Pair a local Computer with the selected OpenAgents API:
125
126
```sh
127
openagents computer pair
128
```
129
130
The command prints a browser approval URL and a short code, then waits for the
131
owner to approve the pairing. The machine token is stored in the operating
132
system credential store under the selected endpoint. It is not written to the
133
configuration file, output, or local Computer journal. The poll secret stays
134
in memory for the exchange.
135
136
Use `openagents computer status` to inspect local policy and pairing state. When
137
a Computer credential exists, status verifies it against the server and reports
138
when the machine is no longer active. It preserves the local credential and
139
directs you to run `openagents computer logout` for cleanup. A temporary
140
network failure is reported separately from revocation. Production and staging
141
use separate credential entries.
142
122 143
## Call any endpoint
123 144
124 145
`openagents api` sends an authenticated request to any OpenAgents API route and
packages/openagents-cli/src/cli.ts modified +233 -9

@@ -15,18 +15,32 @@ import {

15 15
} from "./api-passthrough.js";
16 16
import { ApiTransport } from "./api-transport.js";
17 17
import { BrowserLauncher } from "./browser-launcher.js";
18
<<<<<<< HEAD
18 19
import { runCoderPlain } from "./coder-plain.js";
19 20
import { CoderSession, DummyReplySource } from "./coder-session.js";
20 21
import { runCoderUi } from "./coder-ui.js";
21 22
import { OxAlphaReplySource } from "./coder-ox.js";
22 23
import { describeWorkspace } from "./coder-workspace.js";
23
import { ComputerConfiguration, type ComputerConfigurationValues } from "./computer-config.js";
24
import { ComputerClient } from "./computer-client.js";
25
import {
26
  ComputerConfiguration,
27
  type ComputerConfigurationValues,
28
  writeComputerConfiguration,
29
} from "./computer-config.js";
24 30
import { ComputerJournal, journalMaxBytes, journalReadTailBytes } from "./computer-journal.js";
25 31
import { ComputerProbe } from "./computer-probe.js";
26
import { formatAllowlist, resolveRoots } from "./computer-policy.js";
27
import { ApiError, InputError } from "./errors.js";
32
import { formatAllowlist, resolveRoots, type Tier } from "./computer-policy.js";
33
import {
34
  ApiError,
35
  ComputerAlreadyPaired,
36
  ComputerPairingInProgress,
37
  InputError,
38
} from "./errors.js";
28 39
import { CredentialStore } from "./credential-store.js";
29
import { PendingDeviceAuthorizationStore } from "./device-authorization-store.js";
40
import {
41
  PendingDeviceAuthorizationStore,
42
  type PendingDeviceAuthorization,
43
} from "./device-authorization-store.js";
30 44
import { DeviceClient } from "./device-client.js";
31 45
import { type EndpointOverrides, Profile } from "./endpoint.js";
32 46
import { ForumClient } from "./forum-client.js";

@@ -69,6 +83,10 @@ const computerRootFlag = Flag.string("root").pipe(

69 83
  Flag.atLeast(0),
70 84
  Flag.withDescription("Inspect a declared directory; repeatable. Empty means no roots."),
71 85
);
86
const computerTierFlag = Flag.choice("tier", ["probe", "curated", "shell"] as const).pipe(
87
  Flag.optional,
88
  Flag.withDescription("Set the local execution ceiling for this Computer"),
89
);
72 90
const computerJournalLimitFlag = Flag.integer("limit").pipe(
73 91
  Flag.withDefault(20),
74 92
  Flag.withDescription("Maximum number of local journal entries to show"),

@@ -157,11 +175,30 @@ const computerStatusCommand = Command.make("status", {}, () =>

157 175
  Effect.gen(function* () {
158 176
    const flags = yield* rootCommand;
159 177
    const config = yield* ComputerConfiguration;
178
    const endpoint = yield* resolveApiEndpoint(endpointOverrides(flags));
179
    const credentials = yield* CredentialStore;
180
    const pendingStore = yield* PendingDeviceAuthorizationStore;
181
    const stored = yield* credentials.get(endpoint.origin, "computer");
182
    const pending = yield* pendingStore.get(endpoint.origin);
183
    const computerPending =
184
      Option.isSome(pending) && pending.value.kind === "computer" ? pending.value : undefined;
185
    const remoteStatus = Option.isSome(stored)
186
      ? yield* (yield* ComputerClient).status(endpoint.origin, stored.value)
187
      : Option.none();
188
    const paired = Option.isSome(remoteStatus);
189
    const state = paired
190
      ? "paired"
191
      : Option.isSome(stored)
192
        ? "unpaired"
193
        : computerPending === undefined
194
          ? "local"
195
          : "pairing_pending";
160 196
    const output = yield* Output;
161 197
    const value = {
162 198
      schema: "openagents.computer_status.v1",
163
      state: "local",
164
      paired: false,
199
      state,
200
      paired,
201
      endpoint: endpoint.origin,
165 202
      tier: config.tier,
166 203
      roots: config.roots,
167 204
      machine: {

@@ -176,13 +213,28 @@ const computerStatusCommand = Command.make("status", {}, () =>

176 213
      journal_retention_bytes: journalMaxBytes,
177 214
      journal_read_tail_bytes: journalReadTailBytes,
178 215
      network: false,
216
      remote_state: paired ? "active" : "unpaired",
217
      ...(Option.isSome(remoteStatus)
218
        ? { machine_id: remoteStatus.value.machine_id }
219
        : computerPending?.machine_id === undefined
220
          ? {}
221
          : { machine_id: computerPending.machine_id }),
179 222
    };
180 223
    yield* output.write(
181 224
      {
182 225
        value,
183 226
        human: [
184
          "Computer state: local",
185
          "Pairing: not required for local inspection",
227
          `Computer state: ${state}`,
228
          `Pairing: ${
229
            paired
230
              ? "paired"
231
              : Option.isSome(stored)
232
                ? "no longer active; run computer logout"
233
                : computerPending === undefined
234
                  ? "not configured"
235
                  : "in progress"
236
          }`,
237
          `Endpoint: ${endpoint.origin}`,
186 238
          `Tier: ${config.tier}`,
187 239
          `Roots: ${config.roots.join(", ") || "(none declared)"}`,
188 240
          `Configuration: ${config.paths.config}`,

@@ -190,6 +242,9 @@ const computerStatusCommand = Command.make("status", {}, () =>

190 242
          `Journal retention: last ${journalMaxBytes} bytes; reads inspect the last ${journalReadTailBytes} bytes`,
191 243
          "The machine, not the server, decides what runs here.",
192 244
          "Path rules follow this host's POSIX or Windows semantics.",
245
          ...(Option.isSome(stored) && !paired
246
            ? ["The server no longer accepts this machine token; run computer logout."]
247
            : []),
193 248
        ],
194 249
      },
195 250
      outputMode(flags.json),

@@ -197,10 +252,166 @@ const computerStatusCommand = Command.make("status", {}, () =>

197 252
  }),
198 253
).pipe(
199 254
  Command.withDescription(
200
    "Show local Computer state and file locations without contacting OpenAgents or printing secrets.",
255
    "Show local Computer state, pairing state, and file locations without printing secrets.",
256
  ),
257
);
258
259
const computerPairCommand = Command.make(
260
  "pair",
261
  { tier: computerTierFlag, root: computerRootFlag },
262
  ({ root, tier }) =>
263
    Effect.gen(function* () {
264
      const flags = yield* rootCommand;
265
      const endpoint = yield* resolveApiEndpoint(endpointOverrides(flags));
266
      const config = yield* ComputerConfiguration;
267
      const credentials = yield* CredentialStore;
268
      const pendingStore = yield* PendingDeviceAuthorizationStore;
269
      const output = yield* Output;
270
      const stored = yield* credentials.get(endpoint.origin, "computer");
271
      if (Option.isSome(stored)) {
272
        return yield* new ComputerAlreadyPaired({
273
          message: `This Computer is already paired with ${endpoint.origin}; run computer logout before pairing again.`,
274
        });
275
      }
276
277
      const pending = yield* pendingStore.get(endpoint.origin);
278
      if (Option.isSome(pending)) {
279
        const pendingValue = pending.value;
280
        if (pendingValue.kind !== "computer") {
281
          return yield* new ComputerPairingInProgress({
282
            message: `An OpenAgents authorization is already pending for ${endpoint.origin}; complete it before pairing this Computer.`,
283
          });
284
        }
285
        const now = yield* Clock.currentTimeMillis;
286
        if (pendingValue.expires_at_ms > now) {
287
          return yield* new ComputerPairingInProgress({
288
            message: `A Computer pairing is already pending for ${endpoint.origin}; finish it before starting another.`,
289
          });
290
        }
291
        yield* pendingStore.remove(endpoint.origin);
292
      }
293
294
      const selectedTier: Tier = Option.getOrElse(tier, () => config.tier);
295
      const roots = root.length === 0 ? config.roots : resolveRoots(root);
296
      yield* writeComputerConfiguration(
297
        { tier: selectedTier, roots, preApproved: config.preApproved },
298
        config.paths,
299
      );
300
      const started = yield* (yield* ComputerClient).start(endpoint.origin, {
301
        name: hostname(),
302
        tier: selectedTier,
303
        platform: `${process.platform}-${process.arch}`,
304
        agentVersion: VERSION,
305
        roots,
306
      });
307
      const expiresAtMs = Date.parse(started.expires_at);
308
      if (!Number.isFinite(expiresAtMs)) {
309
        return yield* new InputError({
310
          message: "The Computer pairing response did not contain a valid expiry.",
311
        });
312
      }
313
      const terminal = yield* TerminalSession;
314
      if (terminal.interactive && !flags.json) {
315
        const browser = yield* BrowserLauncher;
316
        if (!(yield* browser.open(started.verify_url))) {
317
          yield* Console.error("The browser did not open. Open the approval URL above.");
318
        }
319
      }
320
      const pendingAuthorization: PendingDeviceAuthorization = {
321
        origin: endpoint.origin,
322
        device_code: started.pairing_id,
323
        user_code: started.code,
324
        verification_uri: started.verify_url,
325
        verification_uri_complete: started.verify_url,
326
        expires_at_ms: expiresAtMs,
327
        interval: started.interval_seconds,
328
        kind: "computer",
329
        state: "pending",
330
      };
331
      yield* pendingStore.set(pendingAuthorization);
332
      yield* output.write(
333
        {
334
          value: {
335
            endpoint: endpoint.origin,
336
            pairing_pending: true,
337
            verification_url: started.verify_url,
338
            code: started.code,
339
            expires_at: started.expires_at,
340
            interval_seconds: started.interval_seconds,
341
            tier: selectedTier,
342
            roots,
343
          },
344
          human: [
345
            `Approve this Computer at ${started.verify_url}`,
346
            `Pairing code: ${started.code}`,
347
            "Waiting for approval...",
348
          ],
349
        },
350
        outputMode(flags.json),
351
      );
352
353
      const claim = yield* (yield* ComputerClient).wait(endpoint.origin, started);
354
      yield* credentials.set(endpoint.origin, Redacted.make(claim.token), "computer");
355
      yield* pendingStore.set({
356
        ...pendingAuthorization,
357
        state: "paired",
358
        machine_id: claim.machine_id,
359
      });
360
      yield* output.write(
361
        {
362
          value: {
363
            endpoint: endpoint.origin,
364
            paired: true,
365
            machine_id: claim.machine_id,
366
            name: claim.name,
367
            token_source: "computer_credential_store",
368
          },
369
          human: [
370
            `Computer paired with ${endpoint.origin}.`,
371
            "The machine token is in the OS credential store.",
372
          ],
373
        },
374
        outputMode(flags.json),
375
      );
376
    }),
377
).pipe(
378
  Command.withDescription(
379
    "Pair this Computer through browser approval and store its machine token",
201 380
  ),
202 381
);
203 382
383
const computerLogoutCommand = Command.make("logout", {}, () =>
384
  Effect.gen(function* () {
385
    const flags = yield* rootCommand;
386
    const endpoint = yield* resolveApiEndpoint(endpointOverrides(flags));
387
    const credentials = yield* CredentialStore;
388
    const pendingStore = yield* PendingDeviceAuthorizationStore;
389
    const output = yield* Output;
390
    const stored = yield* credentials.get(endpoint.origin, "computer");
391
    const pending = yield* pendingStore.get(endpoint.origin);
392
    if (Option.isSome(stored)) {
393
      yield* credentials.remove(endpoint.origin, "computer");
394
    }
395
    if (Option.isSome(pending) && pending.value.kind === "computer") {
396
      yield* pendingStore.remove(endpoint.origin);
397
    }
398
    yield* output.write(
399
      {
400
        value: {
401
          endpoint: endpoint.origin,
402
          removed: Option.isSome(stored),
403
          remote_state: "unverified",
404
        },
405
        human: [
406
          `Removed the local Computer pairing for ${endpoint.origin}.`,
407
          "No local machine token remains. Remote pairing state is not queried.",
408
        ],
409
      },
410
      outputMode(flags.json),
411
    );
412
  }),
413
).pipe(Command.withDescription("Remove this Computer's local machine token and pairing state"));
414
204 415
const computerJournalCommand = Command.make(
205 416
  "journal",
206 417
  { limit: computerJournalLimitFlag },

@@ -244,6 +455,8 @@ const computerCommand = Command.make("computer").pipe(

244 455
    computerProbeCommand,
245 456
    computerPolicyCommand,
246 457
    computerStatusCommand,
458
    computerPairCommand,
459
    computerLogoutCommand,
247 460
    computerJournalCommand,
248 461
  ]),
249 462
);

@@ -434,6 +647,12 @@ const authLoginCommand = Command.make(

434 647
      }
435 648
436 649
      const pendingStore = yield* PendingDeviceAuthorizationStore;
650
      const existingPending = yield* pendingStore.get(endpoint.origin);
651
      if (!resume && Option.isSome(existingPending) && existingPending.value.kind === "computer") {
652
        return yield* new InputError({
653
          message: `A Computer pairing is already pending for ${endpoint.origin}; complete it before starting API authorization.`,
654
        });
655
      }
437 656
      if (!resume) {
438 657
        const devices = yield* DeviceClient;
439 658
        const terminal = yield* TerminalSession;

@@ -509,6 +728,11 @@ const authLoginCommand = Command.make(

509 728
          message: `No pending authorization exists for ${endpoint.origin}. Run openagents auth login first.`,
510 729
        });
511 730
      }
731
      if (pending.value.kind === "computer") {
732
        return yield* new InputError({
733
          message: `The pending authorization for ${endpoint.origin} is a Computer pairing; use openagents computer status.`,
734
        });
735
      }
512 736
      const now = yield* Clock.currentTimeMillis;
513 737
      const expiresIn = Math.ceil((pending.value.expires_at_ms - now) / 1_000);
514 738
      if (expiresIn <= 0) {
packages/openagents-cli/src/computer-client.ts added +280

@@ -0,0 +1,280 @@

1
import { Clock, Duration, Effect, Layer, Option, Redacted, Schedule, Schema } from "effect";
2
import * as Context from "effect/Context";
3
4
import { ApiTransport } from "./api-transport.js";
5
import {
6
  ApiError,
7
  ComputerDisabled,
8
  ComputerPairingExpired,
9
  ComputerPairingNetworkFailure,
10
  ComputerPairingRefused,
11
  ComputerStatusNetworkFailure,
12
  ContractError,
13
  NetworkRefused,
14
  TransportError,
15
  type CliError,
16
} from "./errors.js";
17
18
export const ComputerPairingStart = Schema.Struct({
19
  pairing_id: Schema.String,
20
  code: Schema.String,
21
  poll_secret: Schema.String,
22
  verify_url: Schema.String,
23
  expires_at: Schema.String,
24
  interval_seconds: Schema.Number,
25
});
26
export interface ComputerPairingStart extends Schema.Schema.Type<typeof ComputerPairingStart> {}
27
28
export const ComputerPairingClaim = Schema.Struct({
29
  status: Schema.Literal("approved"),
30
  machine_id: Schema.String,
31
  name: Schema.String,
32
  token: Schema.String,
33
});
34
export interface ComputerPairingClaim extends Schema.Schema.Type<typeof ComputerPairingClaim> {}
35
36
export const ComputerStatus = Schema.Struct({
37
  machine_id: Schema.String,
38
  name: Schema.String,
39
  status: Schema.Literal("active"),
40
  token_expires_at: Schema.String,
41
});
42
export interface ComputerStatus extends Schema.Schema.Type<typeof ComputerStatus> {}
43
44
export interface ComputerPairingRequest {
45
  readonly name: string;
46
  readonly tier: "probe" | "curated" | "shell";
47
  readonly platform: string;
48
  readonly agentVersion: string;
49
  readonly roots: ReadonlyArray<string>;
50
}
51
52
export interface ComputerClientInterface {
53
  readonly start: (
54
    origin: string,
55
    request: ComputerPairingRequest,
56
  ) => Effect.Effect<ComputerPairingStart, CliError>;
57
  readonly wait: (
58
    origin: string,
59
    pairing: ComputerPairingStart,
60
  ) => Effect.Effect<ComputerPairingClaim, CliError>;
61
  readonly status: (
62
    origin: string,
63
    token: Redacted.Redacted<string>,
64
  ) => Effect.Effect<Option.Option<ComputerStatus>, CliError>;
65
}
66
67
export class ComputerClient extends Context.Service<ComputerClient, ComputerClientInterface>()(
68
  "@openagentsinc/cli/ComputerClient",
69
) {}
70
71
const ErrorBody = Schema.Struct({
72
  error: Schema.optionalKey(Schema.String),
73
});
74
75
const responseCode = (body: unknown): string | undefined => {
76
  const decoded = Schema.decodeUnknownOption(ErrorBody)(body);
77
  return Option.isSome(decoded) ? decoded.value.error : undefined;
78
};
79
80
class PairingPending extends Schema.TaggedErrorClass<PairingPending>()(
81
  "OpenAgentsCli.Internal.PairingPending",
82
  {},
83
) {}
84
85
export const computerClientLayer = Layer.effect(
86
  ComputerClient,
87
  Effect.gen(function* () {
88
    const transport = yield* ApiTransport;
89
90
    const networkFailure = (origin: string) =>
91
      new ComputerPairingNetworkFailure({
92
        message: `The Computer pairing request could not reach ${origin}.`,
93
      });
94
    const statusNetworkFailure = (origin: string) =>
95
      new ComputerStatusNetworkFailure({
96
        message: `The Computer status request could not reach ${origin}.`,
97
      });
98
99
    const request = <A>(
100
      effect: Effect.Effect<A, NetworkRefused | TransportError>,
101
      origin: string,
102
    ): Effect.Effect<A, ComputerPairingNetworkFailure> =>
103
      effect.pipe(Effect.mapError(() => networkFailure(origin)));
104
105
    const start = Effect.fn("ComputerClient.start")(function* (
106
      origin: string,
107
      pairing: ComputerPairingRequest,
108
    ) {
109
      const response = yield* request(
110
        transport.request({
111
          origin,
112
          method: "POST",
113
          path: "/controller/pairings",
114
          body: {
115
            name: pairing.name,
116
            tier: pairing.tier,
117
            platform: pairing.platform,
118
            agent_version: pairing.agentVersion,
119
            roots: pairing.roots,
120
          },
121
        }),
122
        origin,
123
      );
124
      if (
125
        response.status === 404 &&
126
        responseCode(response.body) === "computer_controller_disabled"
127
      ) {
128
        return yield* new ComputerDisabled({
129
          message: "The OpenAgents Computer surface is not enabled on this server.",
130
        });
131
      }
132
      if (response.status === 422) {
133
        return yield* new ComputerPairingRefused({
134
          message: "The OpenAgents server refused this Computer pairing.",
135
        });
136
      }
137
      if (response.status !== 200 && response.status !== 201) {
138
        return yield* new ApiError({
139
          operation: "register computer pairing",
140
          status: response.status,
141
          message: "OpenAgents could not register this Computer pairing.",
142
          ...(response.requestId === undefined ? {} : { requestId: response.requestId }),
143
        });
144
      }
145
      return yield* Schema.decodeUnknownEffect(ComputerPairingStart)(response.body).pipe(
146
        Effect.mapError(
147
          (cause) =>
148
            new ContractError({
149
              operation: "register computer pairing",
150
              message: "The Computer pairing response did not match the API contract.",
151
              cause,
152
            }),
153
        ),
154
      );
155
    });
156
157
    const poll = Effect.fn("ComputerClient.poll")(function* (
158
      origin: string,
159
      pairing: ComputerPairingStart,
160
    ) {
161
      const response = yield* request(
162
        transport.request({
163
          origin,
164
          method: "GET",
165
          path: `/controller/pairings/${encodeURIComponent(pairing.pairing_id)}`,
166
          headers: { "x-pairing-secret": pairing.poll_secret },
167
        }),
168
        origin,
169
      );
170
      if (response.status === 410) {
171
        return yield* new ComputerPairingExpired({
172
          message: "The Computer pairing expired before the owner approved it.",
173
        });
174
      }
175
      if (response.status === 404 || response.status === 401 || response.status === 403) {
176
        return yield* new ComputerPairingRefused({
177
          message: "The Computer pairing was refused or is no longer available.",
178
        });
179
      }
180
      if (response.status !== 200) {
181
        return yield* new ApiError({
182
          operation: "poll computer pairing",
183
          status: response.status,
184
          message: "OpenAgents could not poll this Computer pairing.",
185
          ...(response.requestId === undefined ? {} : { requestId: response.requestId }),
186
        });
187
      }
188
      if (
189
        typeof response.body === "object" &&
190
        response.body !== null &&
191
        "status" in response.body &&
192
        response.body.status === "pending"
193
      ) {
194
        return yield* new PairingPending();
195
      }
196
      return yield* Schema.decodeUnknownEffect(ComputerPairingClaim)(response.body).pipe(
197
        Effect.mapError(
198
          (cause) =>
199
            new ContractError({
200
              operation: "claim computer pairing",
201
              message: "The Computer claim response did not match the API contract.",
202
              cause,
203
            }),
204
        ),
205
      );
206
    });
207
208
    const wait = Effect.fn("ComputerClient.wait")(function* (
209
      origin: string,
210
      pairing: ComputerPairingStart,
211
    ) {
212
      const expiresAtMs = Date.parse(pairing.expires_at);
213
      if (!Number.isFinite(expiresAtMs)) {
214
        return yield* new ContractError({
215
          operation: "poll computer pairing",
216
          message: "The Computer pairing expiry did not match the API contract.",
217
          cause: new Error("invalid expires_at"),
218
        });
219
      }
220
      const now = yield* Clock.currentTimeMillis;
221
      const timeoutMs = Math.max(0, expiresAtMs - now);
222
      const result = yield* poll(origin, pairing).pipe(
223
        Effect.retry({
224
          schedule: Schedule.spaced(Duration.seconds(Math.max(1, pairing.interval_seconds))),
225
          while: (failure) => failure instanceof PairingPending,
226
        }),
227
        Effect.timeoutOption(Duration.millis(timeoutMs)),
228
        Effect.catchTag("OpenAgentsCli.Internal.PairingPending", () =>
229
          Effect.succeed(Option.none()),
230
        ),
231
      );
232
      if (Option.isNone(result)) {
233
        return yield* new ComputerPairingExpired({
234
          message: "The Computer pairing expired before the owner approved it.",
235
        });
236
      }
237
      return result.value;
238
    });
239
240
    const status = Effect.fn("ComputerClient.status")(function* (
241
      origin: string,
242
      token: Redacted.Redacted<string>,
243
    ) {
244
      const response = yield* transport
245
        .request({
246
          origin,
247
          method: "GET",
248
          path: "/controller/status",
249
          token,
250
        })
251
        .pipe(Effect.mapError(() => statusNetworkFailure(origin)));
252
      if (response.status === 401) {
253
        return Option.none();
254
      }
255
      if (response.status !== 200) {
256
        const code = responseCode(response.body);
257
        return yield* new ApiError({
258
          operation: "read computer status",
259
          status: response.status,
260
          message: "OpenAgents could not read this Computer status.",
261
          ...(code === undefined ? {} : { code }),
262
          ...(response.requestId === undefined ? {} : { requestId: response.requestId }),
263
        });
264
      }
265
      return yield* Schema.decodeUnknownEffect(ComputerStatus)(response.body).pipe(
266
        Effect.mapError(
267
          (cause) =>
268
            new ContractError({
269
              operation: "read computer status",
270
              message: "The Computer status response did not match the API contract.",
271
              cause,
272
            }),
273
        ),
274
        Effect.map(Option.some),
275
      );
276
    });
277
278
    return ComputerClient.of({ start, wait, status });
279
  }),
280
);
packages/openagents-cli/src/credential-store.ts modified +63 -20

@@ -7,16 +7,22 @@ import { dirname } from "node:path";

7 7
import { CredentialPersistenceUnavailable, CredentialStoreError } from "./errors.js";
8 8
9 9
export type CredentialStoreFailure = CredentialPersistenceUnavailable | CredentialStoreError;
10
export type CredentialKind = "api" | "computer";
10 11
11 12
export interface CredentialStoreInterface {
12 13
  readonly get: (
13 14
    origin: string,
15
    kind?: CredentialKind,
14 16
  ) => Effect.Effect<Option.Option<Redacted.Redacted<string>>, CredentialStoreFailure>;
15 17
  readonly set: (
16 18
    origin: string,
17 19
    token: Redacted.Redacted<string>,
20
    kind?: CredentialKind,
21
  ) => Effect.Effect<void, CredentialStoreFailure>;
22
  readonly remove: (
23
    origin: string,
24
    kind?: CredentialKind,
18 25
  ) => Effect.Effect<void, CredentialStoreFailure>;
19
  readonly remove: (origin: string) => Effect.Effect<void, CredentialStoreFailure>;
20 26
}
21 27
22 28
export class CredentialStore extends Context.Service<CredentialStore, CredentialStoreInterface>()(

@@ -43,7 +49,8 @@ export const credentialStoreUnavailableLayer = Layer.succeed(

43 49
  }),
44 50
);
45 51
46
const keychainService = "openagents-cli";
52
const keychainService = (kind: CredentialKind): string =>
53
  kind === "computer" ? "openagents-cli-computer" : "openagents-cli";
47 54
const encoder = new TextEncoder();
48 55
49 56
interface ProcessResult {

@@ -62,24 +69,34 @@ export const credentialCommandFor = (

62 69
  operation: "get" | "set" | "remove",
63 70
  origin: string,
64 71
  token?: string,
72
  kind: CredentialKind = "api",
65 73
): CredentialCommand | undefined => {
66 74
  if (platform === "darwin") {
67 75
    if (operation === "get") {
68 76
      return {
69 77
        command: "security",
70
        args: ["find-generic-password", "-a", origin, "-s", keychainService, "-w"],
78
        args: ["find-generic-password", "-a", origin, "-s", keychainService(kind), "-w"],
71 79
      };
72 80
    }
73 81
    if (operation === "set" && token !== undefined) {
74 82
      return {
75 83
        command: "security",
76
        args: ["add-generic-password", "-U", "-a", origin, "-s", keychainService, "-w", token],
84
        args: [
85
          "add-generic-password",
86
          "-U",
87
          "-a",
88
          origin,
89
          "-s",
90
          keychainService(kind),
91
          "-w",
92
          token,
93
        ],
77 94
      };
78 95
    }
79 96
    if (operation === "remove") {
80 97
      return {
81 98
        command: "security",
82
        args: ["delete-generic-password", "-a", origin, "-s", keychainService],
99
        args: ["delete-generic-password", "-a", origin, "-s", keychainService(kind)],
83 100
      };
84 101
    }
85 102
    return undefined;

@@ -89,20 +106,27 @@ export const credentialCommandFor = (

89 106
    if (operation === "get") {
90 107
      return {
91 108
        command: "secret-tool",
92
        args: ["lookup", "service", keychainService, "origin", origin],
109
        args: ["lookup", "service", keychainService(kind), "origin", origin],
93 110
      };
94 111
    }
95 112
    if (operation === "set" && token !== undefined) {
96 113
      return {
97 114
        command: "secret-tool",
98
        args: ["store", "--label=OpenAgents CLI", "service", keychainService, "origin", origin],
115
        args: [
116
          "store",
117
          "--label=OpenAgents CLI",
118
          "service",
119
          keychainService(kind),
120
          "origin",
121
          origin,
122
        ],
99 123
        input: token,
100 124
      };
101 125
    }
102 126
    if (operation === "remove") {
103 127
      return {
104 128
        command: "secret-tool",
105
        args: ["clear", "service", keychainService, "origin", origin],
129
        args: ["clear", "service", keychainService(kind), "origin", origin],
106 130
      };
107 131
    }
108 132
  }

@@ -140,15 +164,20 @@ export const credentialStoreOsLayer = Layer.effect(

140 164
141 165
    const unsupported = () => Effect.fail(unavailable("Persistent authentication"));
142 166
143
    const get = Effect.fn("CredentialStore.OS.get")(function* (origin: string) {
144
      const command = credentialCommandFor(process.platform, "get", origin);
167
    const get = Effect.fn("CredentialStore.OS.get")(function* (
168
      origin: string,
169
      kind: CredentialKind = "api",
170
    ) {
171
      const command = credentialCommandFor(process.platform, "get", origin, undefined, kind);
145 172
      if (command === undefined) return yield* unsupported();
146 173
      const result = yield* run(command.command, command.args, command.input).pipe(
147 174
        Effect.mapError((cause) => storeError("read the OS credential store", cause)),
148 175
      );
149 176
      if (result.exitCode !== 0) return Option.none();
150 177
      const token = result.stdout.trim();
151
      if (!token.startsWith("oa_pat_") || token.length >= 160) {
178
      const validPrefix =
179
        kind === "computer" ? token.startsWith("smct_") : token.startsWith("oa_pat_");
180
      if (!validPrefix || token.length >= 160) {
152 181
        return yield* storeError("read the OS credential store", new Error("invalid token record"));
153 182
      }
154 183
      return Option.some(Redacted.make(token));

@@ -157,9 +186,10 @@ export const credentialStoreOsLayer = Layer.effect(

157 186
    const set = Effect.fn("CredentialStore.OS.set")(function* (
158 187
      origin: string,
159 188
      token: Redacted.Redacted<string>,
189
      kind: CredentialKind = "api",
160 190
    ) {
161 191
      const tokenValue = Redacted.value(token);
162
      const command = credentialCommandFor(process.platform, "set", origin, tokenValue);
192
      const command = credentialCommandFor(process.platform, "set", origin, tokenValue, kind);
163 193
      if (command === undefined) return yield* unsupported();
164 194
      const result = yield* run(command.command, command.args, command.input).pipe(
165 195
        Effect.mapError(() =>

@@ -175,7 +205,7 @@ export const credentialStoreOsLayer = Layer.effect(

175 205
          new Error(`credential command exited ${result.exitCode}`),
176 206
        );
177 207
      }
178
      const stored = yield* get(origin);
208
      const stored = yield* get(origin, kind);
179 209
      if (Option.isNone(stored) || Redacted.value(stored.value) !== tokenValue) {
180 210
        return yield* storeError(
181 211
          "verify the OS credential store",

@@ -184,8 +214,11 @@ export const credentialStoreOsLayer = Layer.effect(

184 214
      }
185 215
    });
186 216
187
    const remove = Effect.fn("CredentialStore.OS.remove")(function* (origin: string) {
188
      const command = credentialCommandFor(process.platform, "remove", origin);
217
    const remove = Effect.fn("CredentialStore.OS.remove")(function* (
218
      origin: string,
219
      kind: CredentialKind = "api",
220
    ) {
221
      const command = credentialCommandFor(process.platform, "remove", origin, undefined, kind);
189 222
      if (command === undefined) return yield* unsupported();
190 223
      yield* run(command.command, command.args, command.input).pipe(
191 224
        Effect.mapError((cause) => storeError("remove the OS credential", cause)),

@@ -204,6 +237,9 @@ type CredentialFile = typeof CredentialFile.Type;

204 237
205 238
const emptyCredentialFile = (): CredentialFile => ({ version: 1, tokens: {} });
206 239
240
const credentialKey = (origin: string, kind: CredentialKind): string =>
241
  kind === "computer" ? `computer:${origin}` : origin;
242
207 243
const hasErrorCode = (value: unknown): value is { readonly code: string } =>
208 244
  typeof value === "object" && value !== null && "code" in value && typeof value.code === "string";
209 245

@@ -251,27 +287,34 @@ export const credentialStoreTestFileLayer = (path: string) =>

251 287
      });
252 288
    });
253 289
254
    const get = Effect.fn("CredentialStore.TestFile.get")(function* (origin: string) {
290
    const get = Effect.fn("CredentialStore.TestFile.get")(function* (
291
      origin: string,
292
      kind: CredentialKind = "api",
293
    ) {
255 294
      const file = yield* load();
256
      const token = file.tokens[origin];
295
      const token = file.tokens[credentialKey(origin, kind)];
257 296
      return token === undefined ? Option.none() : Option.some(Redacted.make(token));
258 297
    });
259 298
260 299
    const set = Effect.fn("CredentialStore.TestFile.set")(function* (
261 300
      origin: string,
262 301
      token: Redacted.Redacted<string>,
302
      kind: CredentialKind = "api",
263 303
    ) {
264 304
      const file = yield* load();
265 305
      yield* save({
266 306
        ...file,
267
        tokens: { ...file.tokens, [origin]: Redacted.value(token) },
307
        tokens: { ...file.tokens, [credentialKey(origin, kind)]: Redacted.value(token) },
268 308
      });
269 309
    });
270 310
271
    const remove = Effect.fn("CredentialStore.TestFile.remove")(function* (origin: string) {
311
    const remove = Effect.fn("CredentialStore.TestFile.remove")(function* (
312
      origin: string,
313
      kind: CredentialKind = "api",
314
    ) {
272 315
      const file = yield* load();
273 316
      const tokens = { ...file.tokens };
274
      delete tokens[origin];
317
      delete tokens[credentialKey(origin, kind)];
275 318
      if (Object.keys(tokens).length === 0) {
276 319
        yield* Effect.tryPromise({
277 320
          try: () => rm(path, { force: true }),
packages/openagents-cli/src/device-authorization-store.ts modified +3

@@ -15,6 +15,9 @@ export const PendingDeviceAuthorization = Schema.Struct({

15 15
  verification_uri_complete: Schema.String,
16 16
  expires_at_ms: Schema.Number,
17 17
  interval: Schema.Number,
18
  kind: Schema.optionalKey(Schema.Literals(["device", "computer"])),
19
  state: Schema.optionalKey(Schema.Literals(["pending", "paired"])),
20
  machine_id: Schema.optionalKey(Schema.String),
18 21
});
19 22
export interface PendingDeviceAuthorization extends Schema.Schema.Type<
20 23
  typeof PendingDeviceAuthorization
packages/openagents-cli/src/errors.ts modified +56 -1

@@ -121,6 +121,41 @@ export class OutputError extends Schema.TaggedErrorClass<OutputError>()(

121 121
  },
122 122
) {}
123 123
124
export class ComputerAlreadyPaired extends Schema.TaggedErrorClass<ComputerAlreadyPaired>()(
125
  "OpenAgentsCli.ComputerAlreadyPaired",
126
  { message: Schema.String },
127
) {}
128
129
export class ComputerPairingInProgress extends Schema.TaggedErrorClass<ComputerPairingInProgress>()(
130
  "OpenAgentsCli.ComputerPairingInProgress",
131
  { message: Schema.String },
132
) {}
133
134
export class ComputerDisabled extends Schema.TaggedErrorClass<ComputerDisabled>()(
135
  "OpenAgentsCli.ComputerDisabled",
136
  { message: Schema.String },
137
) {}
138
139
export class ComputerPairingExpired extends Schema.TaggedErrorClass<ComputerPairingExpired>()(
140
  "OpenAgentsCli.ComputerPairingExpired",
141
  { message: Schema.String },
142
) {}
143
144
export class ComputerPairingRefused extends Schema.TaggedErrorClass<ComputerPairingRefused>()(
145
  "OpenAgentsCli.ComputerPairingRefused",
146
  { message: Schema.String },
147
) {}
148
149
export class ComputerPairingNetworkFailure extends Schema.TaggedErrorClass<ComputerPairingNetworkFailure>()(
150
  "OpenAgentsCli.ComputerPairingNetworkFailure",
151
  { message: Schema.String },
152
) {}
153
154
export class ComputerStatusNetworkFailure extends Schema.TaggedErrorClass<ComputerStatusNetworkFailure>()(
155
  "OpenAgentsCli.ComputerStatusNetworkFailure",
156
  { message: Schema.String },
157
) {}
158
124 159
export type CliError =
125 160
  | InputError
126 161
  | ConfigurationError

@@ -136,13 +171,33 @@ export type CliError =

136 171
  | ProvisioningFailed
137 172
  | ProvisioningWaitTimeout
138 173
  | GitExecutionError
139
  | OutputError;
174
  | OutputError
175
  | ComputerAlreadyPaired
176
  | ComputerPairingInProgress
177
  | ComputerDisabled
178
  | ComputerPairingExpired
179
  | ComputerPairingRefused
180
  | ComputerPairingNetworkFailure
181
  | ComputerStatusNetworkFailure;
140 182
141 183
export const exitCodeFor = (error: CliError): number => {
142 184
  switch (error._tag) {
143 185
    case "OpenAgentsCli.InputError":
144 186
    case "OpenAgentsCli.ConfigurationError":
145 187
      return 2;
188
    case "OpenAgentsCli.ComputerAlreadyPaired":
189
    case "OpenAgentsCli.ComputerPairingInProgress":
190
      return 5;
191
    case "OpenAgentsCli.ComputerDisabled":
192
      return 8;
193
    case "OpenAgentsCli.ComputerPairingExpired":
194
      return 9;
195
    case "OpenAgentsCli.ComputerPairingRefused":
196
      return 10;
197
    case "OpenAgentsCli.ComputerPairingNetworkFailure":
198
      return 11;
199
    case "OpenAgentsCli.ComputerStatusNetworkFailure":
200
      return 12;
146 201
    case "OpenAgentsCli.AuthenticationRequired":
147 202
    case "OpenAgentsCli.CredentialPersistenceUnavailable":
148 203
    case "OpenAgentsCli.CredentialStoreError":
packages/openagents-cli/src/index.ts modified +1

@@ -3,6 +3,7 @@ export * from "./api-passthrough.js";

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

@@ -24,6 +24,13 @@ const cliErrorTags = new Set([

24 24
  "OpenAgentsCli.ProvisioningWaitTimeout",
25 25
  "OpenAgentsCli.GitExecutionError",
26 26
  "OpenAgentsCli.OutputError",
27
  "OpenAgentsCli.ComputerAlreadyPaired",
28
  "OpenAgentsCli.ComputerPairingInProgress",
29
  "OpenAgentsCli.ComputerDisabled",
30
  "OpenAgentsCli.ComputerPairingExpired",
31
  "OpenAgentsCli.ComputerPairingRefused",
32
  "OpenAgentsCli.ComputerPairingNetworkFailure",
33
  "OpenAgentsCli.ComputerStatusNetworkFailure",
27 34
]);
28 35
29 36
const isCliError = (value: unknown): value is CliError =>
packages/openagents-cli/src/runtime.ts modified +3

@@ -5,6 +5,7 @@ import { Layer } from "effect";

5 5
import { apiTransportNodeLayer, networkPolicyLiveLayer } from "./api-transport.js";
6 6
import { browserLauncherLayer } from "./browser-launcher.js";
7 7
import { computerConfigurationLayer } from "./computer-config.js";
8
import { computerClientLayer } from "./computer-client.js";
8 9
import { computerJournalLayer } from "./computer-journal.js";
9 10
import { computerProbeLayer } from "./computer-probe.js";
10 11
import { credentialStoreOsLayer } from "./credential-store.js";

@@ -27,6 +28,7 @@ const transportLayer = apiTransportNodeLayer.pipe(

27 28
const repositoryLayer = repositoryClientLayer.pipe(Layer.provide(transportLayer));
28 29
const forumLayer = forumClientLayer.pipe(Layer.provide(transportLayer));
29 30
const deviceLayer = deviceClientLayer.pipe(Layer.provide(transportLayer));
31
const computerClient = computerClientLayer.pipe(Layer.provide(transportLayer));
30 32
const credentialsLayer = credentialStoreOsLayer.pipe(Layer.provide(NodeServices.layer));
31 33
const pendingAuthorizationLayer = pendingDeviceAuthorizationStoreLayer.pipe(
32 34
  Layer.provide(environmentLayer),

@@ -57,6 +59,7 @@ export const runtimeLayer = Layer.mergeAll(

57 59
  repositoryLayer,
58 60
  forumLayer,
59 61
  deviceLayer,
62
  computerClient,
60 63
  browserLayer,
61 64
  computerConfiguration,
62 65
  computerJournal,
packages/openagents-cli/test/computer-client.test.ts added +229

@@ -0,0 +1,229 @@

1
import { Effect, Fiber, Layer, Option, Redacted, Ref } from "effect";
2
import { TestClock } from "effect/testing";
3
import { describe, expect, it } from "vitest";
4
5
import { apiTransportTestLayer, type ApiResponse } from "../src/api-transport.js";
6
import { ComputerClient, computerClientLayer } from "../src/computer-client.js";
7
import { NetworkRefused } from "../src/errors.js";
8
9
const pairing = {
10
  pairing_id: "pairing-id",
11
  code: "ABCD-EFGH",
12
  poll_secret: "poll-secret",
13
  verify_url: "https://openagents.com/computers",
14
  expires_at: "2099-01-01T00:00:00.000Z",
15
  interval_seconds: 3,
16
};
17
18
describe("Computer pairing client", () => {
19
  it("registers with the local policy and claims only after approval", async () => {
20
    const requests: Array<{ path: string; body?: unknown; headers?: unknown }> = [];
21
    const polls = await Effect.runPromise(Ref.make(0));
22
    const transport = apiTransportTestLayer((input) =>
23
      Effect.gen(function* () {
24
        requests.push({ path: input.path, body: input.body, headers: input.headers });
25
        if (input.method === "POST") return { status: 200, body: pairing } satisfies ApiResponse;
26
        const poll = yield* Ref.getAndUpdate(polls, (count) => count + 1);
27
        return poll === 0
28
          ? ({ status: 200, body: { status: "pending" } } satisfies ApiResponse)
29
          : ({
30
              status: 200,
31
              body: {
32
                status: "approved",
33
                machine_id: "machine-id",
34
                name: "devin-box",
35
                token: "smct_secret",
36
              },
37
            } satisfies ApiResponse);
38
      }),
39
    );
40
    const layer = computerClientLayer.pipe(Layer.provide(transport));
41
42
    const claim = await Effect.runPromise(
43
      Effect.gen(function* () {
44
        const fiber = yield* Effect.gen(function* () {
45
          const client = yield* ComputerClient;
46
          const started = yield* client.start("https://openagents.com", {
47
            name: "devin-box",
48
            tier: "probe",
49
            platform: "linux-x64",
50
            agentVersion: "0.1.7",
51
            roots: ["/workspace/project"],
52
          });
53
          return yield* client.wait("https://openagents.com", started);
54
        }).pipe(Effect.provide(layer), Effect.forkChild);
55
        yield* TestClock.adjust("3 seconds");
56
        return yield* Fiber.join(fiber);
57
      }).pipe(Effect.provide(TestClock.layer())),
58
    );
59
60
    expect(claim.machine_id).toBe("machine-id");
61
    expect(requests).toEqual([
62
      {
63
        path: "/controller/pairings",
64
        body: {
65
          name: "devin-box",
66
          tier: "probe",
67
          platform: "linux-x64",
68
          agent_version: "0.1.7",
69
          roots: ["/workspace/project"],
70
        },
71
        headers: undefined,
72
      },
73
      {
74
        path: "/controller/pairings/pairing-id",
75
        body: undefined,
76
        headers: { "x-pairing-secret": "poll-secret" },
77
      },
78
      {
79
        path: "/controller/pairings/pairing-id",
80
        body: undefined,
81
        headers: { "x-pairing-secret": "poll-secret" },
82
      },
83
    ]);
84
  });
85
86
  it("distinguishes disabled, expired, refused, and network failures", async () => {
87
    const disabled = computerClientLayer.pipe(
88
      Layer.provide(
89
        apiTransportTestLayer(() =>
90
          Effect.succeed({ status: 404, body: { error: "computer_controller_disabled" } }),
91
        ),
92
      ),
93
    );
94
    const disabledExit = await Effect.runPromiseExit(
95
      Effect.gen(function* () {
96
        yield* (yield* ComputerClient).start("https://openagents.com", {
97
          name: "box",
98
          tier: "probe",
99
          platform: "linux-x64",
100
          agentVersion: "0.1.7",
101
          roots: [],
102
        });
103
      }).pipe(Effect.provide(disabled)),
104
    );
105
    expect(String(disabledExit)).toContain("ComputerDisabled");
106
107
    const expired = computerClientLayer.pipe(
108
      Layer.provide(
109
        apiTransportTestLayer(() => Effect.succeed({ status: 410, body: { status: "expired" } })),
110
      ),
111
    );
112
    const expiredExit = await Effect.runPromiseExit(
113
      Effect.gen(function* () {
114
        yield* (yield* ComputerClient).wait("https://openagents.com", pairing);
115
      }).pipe(Effect.provide(expired)),
116
    );
117
    expect(String(expiredExit)).toContain("ComputerPairingExpired");
118
119
    const refused = computerClientLayer.pipe(
120
      Layer.provide(apiTransportTestLayer(() => Effect.succeed({ status: 404, body: {} }))),
121
    );
122
    const refusedExit = await Effect.runPromiseExit(
123
      Effect.gen(function* () {
124
        yield* (yield* ComputerClient).wait("https://openagents.com", pairing);
125
      }).pipe(Effect.provide(refused)),
126
    );
127
    expect(String(refusedExit)).toContain("ComputerPairingRefused");
128
129
    const network = computerClientLayer.pipe(
130
      Layer.provide(
131
        apiTransportTestLayer(() =>
132
          Effect.fail(new NetworkRefused({ origin: "https://openagents.com", message: "offline" })),
133
        ),
134
      ),
135
    );
136
    const networkExit = await Effect.runPromiseExit(
137
      Effect.gen(function* () {
138
        yield* (yield* ComputerClient).start("https://openagents.com", {
139
          name: "box",
140
          tier: "probe",
141
          platform: "linux-x64",
142
          agentVersion: "0.1.7",
143
          roots: [],
144
        });
145
      }).pipe(Effect.provide(network)),
146
    );
147
    expect(String(networkExit)).toContain("ComputerPairingNetworkFailure");
148
  });
149
150
  it("reads the caller's remote status without exposing its token", async () => {
151
    const requests: Array<{ path: string; token?: string }> = [];
152
    const layer = computerClientLayer.pipe(
153
      Layer.provide(
154
        apiTransportTestLayer((input) => {
155
          requests.push(
156
            input.token === undefined
157
              ? { path: input.path }
158
              : { path: input.path, token: Redacted.value(input.token) },
159
          );
160
          return Effect.succeed({
161
            status: 200,
162
            body: {
163
              machine_id: "machine-id",
164
              name: "devin-box",
165
              status: "active",
166
              token_expires_at: "2099-01-01T00:00:00.000Z",
167
            },
168
          });
169
        }),
170
      ),
171
    );
172
173
    const result = await Effect.runPromise(
174
      Effect.gen(function* () {
175
        return yield* (yield* ComputerClient).status(
176
          "https://openagents.com",
177
          Redacted.make("smct_secret"),
178
        );
179
      }).pipe(Effect.provide(layer)),
180
    );
181
182
    expect(Option.isSome(result)).toBe(true);
183
    if (Option.isSome(result)) {
184
      expect(result.value).toEqual({
185
        machine_id: "machine-id",
186
        name: "devin-box",
187
        status: "active",
188
        token_expires_at: "2099-01-01T00:00:00.000Z",
189
      });
190
    }
191
    expect(requests).toEqual([{ path: "/controller/status", token: "smct_secret" }]);
192
  });
193
194
  it("treats remote revocation as unpaired and network failure separately", async () => {
195
    const revoked = computerClientLayer.pipe(
196
      Layer.provide(
197
        apiTransportTestLayer(() =>
198
          Effect.succeed({ status: 401, body: { error: "machine_revoked" } }),
199
        ),
200
      ),
201
    );
202
    const revokedResult = await Effect.runPromise(
203
      Effect.gen(function* () {
204
        return yield* (yield* ComputerClient).status(
205
          "https://openagents.com",
206
          Redacted.make("smct_secret"),
207
        );
208
      }).pipe(Effect.provide(revoked)),
209
    );
210
    expect(revokedResult._tag).toBe("None");
211
212
    const network = computerClientLayer.pipe(
213
      Layer.provide(
214
        apiTransportTestLayer(() =>
215
          Effect.fail(new NetworkRefused({ origin: "https://openagents.com", message: "offline" })),
216
        ),
217
      ),
218
    );
219
    const networkExit = await Effect.runPromiseExit(
220
      Effect.gen(function* () {
221
        yield* (yield* ComputerClient).status(
222
          "https://openagents.com",
223
          Redacted.make("smct_secret"),
224
        );
225
      }).pipe(Effect.provide(network)),
226
    );
227
    expect(String(networkExit)).toContain("ComputerStatusNetworkFailure");
228
  });
229
});
packages/openagents-cli/test/computer-pairing.test.ts added +178

@@ -0,0 +1,178 @@

1
import * as NodeServices from "@effect/platform-node/NodeServices";
2
import { Effect, Layer, Option, Redacted } from "effect";
3
import { mkdtemp, readFile, rm } from "node:fs/promises";
4
import { join } from "node:path";
5
import { tmpdir } from "node:os";
6
import { describe, expect, it } from "vitest";
7
8
import { runCliWith } from "../src/cli.js";
9
import { ComputerClient, type ComputerClientInterface } from "../src/computer-client.js";
10
import { ComputerConfiguration } from "../src/computer-config.js";
11
import { computerPaths } from "../src/computer-config.js";
12
import { credentialStoreTestFileLayer, CredentialStore } from "../src/credential-store.js";
13
import { environmentLayerFromValues } from "../src/environment.js";
14
import { outputTestLayer, type OutputDocument } from "../src/output.js";
15
import { pendingDeviceAuthorizationStoreLayer } from "../src/device-authorization-store.js";
16
import { persistedConfigurationTestLayer } from "../src/persisted-configuration.js";
17
import { terminalSessionTestLayer } from "../src/terminal-session.js";
18
19
const computerClientTestLayer = (client: ComputerClientInterface): Layer.Layer<ComputerClient> =>
20
  Layer.succeed(ComputerClient, ComputerClient.of(client));
21
22
describe("Computer pairing commands", () => {
23
  it("stores only the machine credential and removes it on logout", async () => {
24
    const directory = await mkdtemp(join(tmpdir(), "openagents-cli-pairing-"));
25
    try {
26
      const credentialPath = join(directory, "credentials.json");
27
      const configPath = join(directory, "config.json");
28
      const outputs: Array<OutputDocument> = [];
29
      const client = computerClientTestLayer({
30
        start: () =>
31
          Effect.succeed({
32
            pairing_id: "pairing-id",
33
            code: "ABCD-EFGH",
34
            poll_secret: "poll-secret",
35
            verify_url: "https://openagents.com/computers",
36
            expires_at: "2099-01-01T00:00:00.000Z",
37
            interval_seconds: 3,
38
          }),
39
        wait: () =>
40
          Effect.succeed({
41
            status: "approved" as const,
42
            machine_id: "machine-id",
43
            name: "devin-box",
44
            token: "smct_machine-secret",
45
          }),
46
        status: () => Effect.succeed(Option.none()),
47
      });
48
      const layer = Layer.mergeAll(
49
        NodeServices.layer,
50
        environmentLayerFromValues({ configPath }),
51
        persistedConfigurationTestLayer({}),
52
        terminalSessionTestLayer(false),
53
        credentialStoreTestFileLayer(credentialPath),
54
        pendingDeviceAuthorizationStoreLayer.pipe(
55
          Layer.provide(environmentLayerFromValues({ configPath })),
56
        ),
57
        client,
58
        outputTestLayer((document) =>
59
          Effect.sync(() => {
60
            outputs.push(document);
61
          }),
62
        ),
63
        Layer.succeed(
64
          ComputerConfiguration,
65
          ComputerConfiguration.of({
66
            tier: "probe",
67
            roots: ["/workspace/project"],
68
            preApproved: [],
69
            paths: computerPaths(configPath),
70
          }),
71
        ),
72
      );
73
74
      await Effect.runPromise(
75
        runCliWith(["--profile", "local", "computer", "pair"]).pipe(Effect.provide(layer)),
76
      );
77
      const config = await readFile(join(directory, "computer.json"), "utf8");
78
      const pending = await readFile(join(directory, "device-authorizations.json"), "utf8");
79
      expect(config).not.toContain("smct_machine-secret");
80
      expect(config).not.toContain("poll-secret");
81
      expect(pending).not.toContain("smct_machine-secret");
82
      expect(pending).not.toContain("poll-secret");
83
      expect(outputs.flatMap((output) => JSON.stringify(output.value))).not.toContain(
84
        "smct_machine-secret",
85
      );
86
      expect(outputs.flatMap((output) => JSON.stringify(output.value))).not.toContain(
87
        "poll-secret",
88
      );
89
90
      const stored = await Effect.runPromise(
91
        Effect.gen(function* () {
92
          const credentials = yield* CredentialStore;
93
          return yield* credentials.get("http://localhost:4000", "computer");
94
        }).pipe(Effect.provide(layer)),
95
      );
96
      expect(Option.map(stored, Redacted.value)).toEqual(Option.some("smct_machine-secret"));
97
98
      await Effect.runPromise(
99
        runCliWith(["--profile", "local", "computer", "logout"]).pipe(Effect.provide(layer)),
100
      );
101
      const removed = await Effect.runPromise(
102
        Effect.gen(function* () {
103
          const credentials = yield* CredentialStore;
104
          return yield* credentials.get("http://localhost:4000", "computer");
105
        }).pipe(Effect.provide(layer)),
106
      );
107
      expect(Option.isNone(removed)).toBe(true);
108
    } finally {
109
      await rm(directory, { recursive: true, force: true });
110
    }
111
  });
112
113
  it("reports remote revocation without deleting the stored credential", async () => {
114
    const directory = await mkdtemp(join(tmpdir(), "openagents-cli-status-"));
115
    try {
116
      const credentialPath = join(directory, "credentials.json");
117
      const configPath = join(directory, "config.json");
118
      const outputs: Array<OutputDocument> = [];
119
      const layer = Layer.mergeAll(
120
        NodeServices.layer,
121
        environmentLayerFromValues({ configPath }),
122
        persistedConfigurationTestLayer({}),
123
        credentialStoreTestFileLayer(credentialPath),
124
        pendingDeviceAuthorizationStoreLayer.pipe(
125
          Layer.provide(environmentLayerFromValues({ configPath })),
126
        ),
127
        computerClientTestLayer({
128
          start: () => Effect.die("unused"),
129
          wait: () => Effect.die("unused"),
130
          status: () => Effect.succeed(Option.none()),
131
        }),
132
        outputTestLayer((document) =>
133
          Effect.sync(() => {
134
            outputs.push(document);
135
          }),
136
        ),
137
        Layer.succeed(
138
          ComputerConfiguration,
139
          ComputerConfiguration.of({
140
            tier: "probe",
141
            roots: [],
142
            preApproved: [],
143
            paths: computerPaths(configPath),
144
          }),
145
        ),
146
      );
147
148
      await Effect.runPromise(
149
        Effect.gen(function* () {
150
          const credentials = yield* CredentialStore;
151
          yield* credentials.set(
152
            "http://localhost:4000",
153
            Redacted.make("smct_revoked"),
154
            "computer",
155
          );
156
          yield* runCliWith(["--profile", "local", "--json", "computer", "status"]);
157
        }).pipe(Effect.provide(layer)),
158
      );
159
160
      expect(outputs.at(-1)?.value).toMatchObject({
161
        state: "unpaired",
162
        paired: false,
163
        remote_state: "unpaired",
164
      });
165
      expect(outputs.at(-1)?.human.join("\n")).toContain("run computer logout");
166
167
      const stored = await Effect.runPromise(
168
        Effect.gen(function* () {
169
          const credentials = yield* CredentialStore;
170
          return yield* credentials.get("http://localhost:4000", "computer");
171
        }).pipe(Effect.provide(layer)),
172
      );
173
      expect(Option.map(stored, Redacted.value)).toEqual(Option.some("smct_revoked"));
174
    } finally {
175
      await rm(directory, { recursive: true, force: true });
176
    }
177
  });
178
});
packages/openagents-cli/test/computer.test.ts modified +9

@@ -34,6 +34,9 @@ import {

34 34
  toolchainCatalog,
35 35
} from "../src/computer-probe.js";
36 36
import { environmentLayerFromValues } from "../src/environment.js";
37
import { credentialStoreTestFileLayer } from "../src/credential-store.js";
38
import { pendingDeviceAuthorizationStoreTestLayer } from "../src/device-authorization-store.js";
39
import { persistedConfigurationTestLayer } from "../src/persisted-configuration.js";
37 40
import { outputTestLayer, type OutputDocument, type OutputMode } from "../src/output.js";
38 41
39 42
const computerConfigurationTestLayer = (

@@ -297,10 +300,15 @@ describe("Computer CLI output", () => {

297 300
298 301
  it("prints stable JSON policy and status without auth or network", async () => {
299 302
    const documents: Array<{ readonly document: OutputDocument; readonly mode: OutputMode }> = [];
303
    const credentialPath = join("/tmp", "openagents-cli-status-credentials.json");
300 304
    const layer = Layer.mergeAll(
301 305
      computerConfigurationTestLayer({ roots: [] }),
302 306
      output(documents),
303 307
      NodeServices.layer,
308
      environmentLayerFromValues({}),
309
      persistedConfigurationTestLayer({}),
310
      credentialStoreTestFileLayer(credentialPath),
311
      pendingDeviceAuthorizationStoreTestLayer(),
304 312
    );
305 313
    await Effect.runPromise(
306 314
      runCliWith(["--json", "computer", "policy"]).pipe(Effect.provide(layer)),

@@ -324,6 +332,7 @@ describe("Computer CLI output", () => {

324 332
    });
325 333
    expect(JSON.stringify(documents)).not.toContain("oa_pat_");
326 334
    expect(JSON.stringify(documents)).not.toContain("oa_machine_");
335
    await rm(credentialPath, { force: true });
327 336
  });
328 337
329 338
  it("prints stable JSON probe output without auth or network", async () => {
packages/openagents-cli/test/credential-store.test.ts modified +25

@@ -61,6 +61,31 @@ describe("credential store", () => {

61 61
    expect(Option.isNone(result.staging)).toBe(true);
62 62
  });
63 63
64
  it("keeps Computer credentials separate from API credentials and endpoints", async () => {
65
    const directory = await mkdtemp(join(tmpdir(), "openagents-cli-credentials-kinds-"));
66
    temporaryDirectories.push(directory);
67
    const layer = credentialStoreTestFileLayer(join(directory, "tokens.json"));
68
    const result = await Effect.runPromise(
69
      Effect.gen(function* () {
70
        const store = yield* CredentialStore;
71
        yield* store.set("https://openagents.com", Redacted.make("oa_pat_api"), "api");
72
        yield* store.set("https://openagents.com", Redacted.make("smct_prod"), "computer");
73
        yield* store.set("https://staging.openagents.com", Redacted.make("smct_stage"), "computer");
74
        return {
75
          api: yield* store.get("https://openagents.com", "api"),
76
          production: yield* store.get("https://openagents.com", "computer"),
77
          staging: yield* store.get("https://staging.openagents.com", "computer"),
78
          local: yield* store.get("http://localhost:4000", "computer"),
79
        };
80
      }).pipe(Effect.provide(layer)),
81
    );
82
83
    expect(Option.map(result.api, Redacted.value)).toEqual(Option.some("oa_pat_api"));
84
    expect(Option.map(result.production, Redacted.value)).toEqual(Option.some("smct_prod"));
85
    expect(Option.map(result.staging, Redacted.value)).toEqual(Option.some("smct_stage"));
86
    expect(Option.isNone(result.local)).toBe(true);
87
  });
88
64 89
  it("refuses production persistence when no OS adapter is admitted", async () => {
65 90
    const exit = await Effect.runPromiseExit(
66 91
      Effect.gen(function* () {
pnpm-workspace.yaml modified +4 -3

@@ -57,6 +57,7 @@ allowBuilds:

57 57
  nostr-effect@https://github.com/OpenAgentsInc/nostr-effect/archive/2bb57870eeeb214ed80ca8a275292f5e4dd89863.tar.gz: false
58 58
  nostr-effect@https://codeload.github.com/OpenAgentsInc/nostr-effect/tar.gz/77073343c68f159f3dea80ddbe9e9896b1f052f2: false
59 59
  nostr-effect@https://github.com/OpenAgentsInc/nostr-effect/archive/77073343c68f159f3dea80ddbe9e9896b1f052f2.tar.gz: false
60
  '@livekit/local-inference': set this to true or false
60 61
61 62
overrides:
62 63
  "@effect/platform-browser": 4.0.0-beta.94

@@ -80,9 +81,9 @@ peerDependencyRules:

80 81
    - vite
81 82
82 83
supportedArchitectures:
83
  cpu: [current, x64]
84
  libc: [current, glibc]
85
  os: [current, linux]
84
  cpu: [ current, x64 ]
85
  libc: [ current, glibc ]
86
  os: [ current, linux ]
86 87
87 88
minimumReleaseAgeExclude:
88 89
  - blume@1.0.4

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