@mycadet/gateway 0.12.8 → 0.12.9-dev.202610131452.9acab64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/package.json +1 -1
  2. package/node_modules/@mycadet/ces-client/package.json +1 -1
  3. package/node_modules/@mycadet/ces-client/src/__tests__/ces-client.test.ts +14 -14
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/package.json +1 -1
  5. package/node_modules/@mycadet/service-contracts/package.json +1 -1
  6. package/openapi.json +17 -17
  7. package/package.json +1 -1
  8. package/scripts/generate-openapi.ts +1 -1
  9. package/src/__tests__/credential-reader.test.ts +1 -1
  10. package/src/__tests__/credential-watcher.test.ts +1 -1
  11. package/src/__tests__/remote-web-pairing-challenge.test.ts +37 -0
  12. package/src/auth/guardian-bootstrap.ts +2 -3
  13. package/src/cli/__tests__/pairing.test.ts +131 -0
  14. package/src/cli/contacts.ts +3 -1
  15. package/src/cli/index.ts +12 -1
  16. package/src/cli/pairing.ts +71 -0
  17. package/src/cloud-url.ts +15 -4
  18. package/src/credential-reader.ts +1 -1
  19. package/src/http/routes/auto-approve-thresholds-routes.ts +1 -1
  20. package/src/http/routes/channel-admission-policy-routes.ts +2 -2
  21. package/src/http/routes/channel-ingress-routes.ts +2 -2
  22. package/src/http/routes/channel-permission-overrides-routes.ts +3 -3
  23. package/src/http/routes/channel-verification-session-proxy.ts +4 -18
  24. package/src/http/routes/contacts-control-plane-proxy.ts +1 -1
  25. package/src/http/routes/contacts-control-plane-routes.ts +5 -5
  26. package/src/http/routes/feature-flags.ts +1 -1
  27. package/src/http/routes/guardian-pin.ts +2 -1
  28. package/src/http/routes/ingress-domains.test.ts +5 -2
  29. package/src/http/routes/ingress-domains.ts +2 -8
  30. package/src/http/routes/live-voice-websocket.ts +2 -1
  31. package/src/http/routes/plugin-webhook-websocket.test.ts +33 -40
  32. package/src/http/routes/plugin-webhook-websocket.ts +2 -1
  33. package/src/http/routes/remote-web-pairing-challenge.ts +8 -1
  34. package/src/http/routes/runtime-audio-stream.ts +2 -1
  35. package/src/http/routes/speech-relay-websocket.ts +2 -1
  36. package/src/http/routes/trust-rules-routes.ts +2 -2
  37. package/src/http/utils.ts +3 -0
  38. package/src/index.ts +4 -3
  39. package/src/remote-feature-flag-sync.ts +1 -1
  40. package/src/remote-web/pairing-challenge-rate-limit-store.ts +94 -0
  41. package/src/remote-web/pairing-challenge-store.ts +130 -2
  42. package/src/remote-web/pairing-mint.ts +94 -0
  43. package/src/schema.ts +24 -24
  44. package/src/tunnel/client.ts +2 -3
@@ -40,6 +40,6 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/bun": "1.2.4",
43
- "typescript": "5.7.3"
43
+ "typescript": "5.9.3"
44
44
  }
45
45
  }
@@ -19,6 +19,6 @@
19
19
  },
20
20
  "devDependencies": {
21
21
  "@types/bun": "1.2.4",
22
- "typescript": "5.7.3"
22
+ "typescript": "5.9.3"
23
23
  }
24
24
  }
@@ -141,7 +141,7 @@ describe("CesHttpCredentialClient", () => {
141
141
  Promise.resolve(
142
142
  Response.json({ account: "test-acct", value: "secret-123" }),
143
143
  ),
144
- ) as typeof fetch;
144
+ ) as unknown as typeof fetch;
145
145
 
146
146
  const client = createCesHttpCredentialClient(
147
147
  createTestConfig(),
@@ -156,7 +156,7 @@ describe("CesHttpCredentialClient", () => {
156
156
  test("get returns not-found on 404", async () => {
157
157
  globalThis.fetch = mock(() =>
158
158
  Promise.resolve(new Response(null, { status: 404 })),
159
- ) as typeof fetch;
159
+ ) as unknown as typeof fetch;
160
160
 
161
161
  const client = createCesHttpCredentialClient(
162
162
  createTestConfig(),
@@ -171,7 +171,7 @@ describe("CesHttpCredentialClient", () => {
171
171
  test("get returns unreachable on 500", async () => {
172
172
  globalThis.fetch = mock(() =>
173
173
  Promise.resolve(new Response("internal error", { status: 500 })),
174
- ) as typeof fetch;
174
+ ) as unknown as typeof fetch;
175
175
 
176
176
  const client = createCesHttpCredentialClient(
177
177
  createTestConfig(),
@@ -186,7 +186,7 @@ describe("CesHttpCredentialClient", () => {
186
186
  test("get returns unreachable on network error", async () => {
187
187
  globalThis.fetch = mock(() =>
188
188
  Promise.reject(new Error("ECONNREFUSED")),
189
- ) as typeof fetch;
189
+ ) as unknown as typeof fetch;
190
190
 
191
191
  const client = createCesHttpCredentialClient(
192
192
  createTestConfig(),
@@ -201,7 +201,7 @@ describe("CesHttpCredentialClient", () => {
201
201
  test("delete returns deleted on 200", async () => {
202
202
  globalThis.fetch = mock(() =>
203
203
  Promise.resolve(Response.json({ ok: true, account: "acct" })),
204
- ) as typeof fetch;
204
+ ) as unknown as typeof fetch;
205
205
 
206
206
  const client = createCesHttpCredentialClient(
207
207
  createTestConfig(),
@@ -215,7 +215,7 @@ describe("CesHttpCredentialClient", () => {
215
215
  test("delete returns not-found on 404", async () => {
216
216
  globalThis.fetch = mock(() =>
217
217
  Promise.resolve(new Response(null, { status: 404 })),
218
- ) as typeof fetch;
218
+ ) as unknown as typeof fetch;
219
219
 
220
220
  const client = createCesHttpCredentialClient(
221
221
  createTestConfig(),
@@ -229,7 +229,7 @@ describe("CesHttpCredentialClient", () => {
229
229
  test("delete returns error on network failure", async () => {
230
230
  globalThis.fetch = mock(() =>
231
231
  Promise.reject(new Error("network down")),
232
- ) as typeof fetch;
232
+ ) as unknown as typeof fetch;
233
233
 
234
234
  const client = createCesHttpCredentialClient(
235
235
  createTestConfig(),
@@ -243,7 +243,7 @@ describe("CesHttpCredentialClient", () => {
243
243
  test("list returns accounts on 200", async () => {
244
244
  globalThis.fetch = mock(() =>
245
245
  Promise.resolve(Response.json({ accounts: ["a", "b", "c"] })),
246
- ) as typeof fetch;
246
+ ) as unknown as typeof fetch;
247
247
 
248
248
  const client = createCesHttpCredentialClient(
249
249
  createTestConfig(),
@@ -258,7 +258,7 @@ describe("CesHttpCredentialClient", () => {
258
258
  test("list returns unreachable on error", async () => {
259
259
  globalThis.fetch = mock(() =>
260
260
  Promise.reject(new Error("timeout")),
261
- ) as typeof fetch;
261
+ ) as unknown as typeof fetch;
262
262
 
263
263
  const client = createCesHttpCredentialClient(
264
264
  createTestConfig(),
@@ -273,7 +273,7 @@ describe("CesHttpCredentialClient", () => {
273
273
  test("bulkSet returns all-failed when request fails", async () => {
274
274
  globalThis.fetch = mock(() =>
275
275
  Promise.reject(new Error("network")),
276
- ) as typeof fetch;
276
+ ) as unknown as typeof fetch;
277
277
 
278
278
  const client = createCesHttpCredentialClient(
279
279
  createTestConfig(),
@@ -311,7 +311,7 @@ describe("fetchCesLogExport", () => {
311
311
  headers: { "Content-Type": "application/gzip" },
312
312
  }),
313
313
  ),
314
- ) as typeof fetch;
314
+ ) as unknown as typeof fetch;
315
315
 
316
316
  const result = await fetchCesLogExport({
317
317
  baseUrl: "http://ces-test:8090",
@@ -327,7 +327,7 @@ describe("fetchCesLogExport", () => {
327
327
  test("returns error on non-OK status", async () => {
328
328
  globalThis.fetch = mock(() =>
329
329
  Promise.resolve(new Response("forbidden", { status: 403 })),
330
- ) as typeof fetch;
330
+ ) as unknown as typeof fetch;
331
331
 
332
332
  const result = await fetchCesLogExport({
333
333
  baseUrl: "http://ces-test:8090",
@@ -343,7 +343,7 @@ describe("fetchCesLogExport", () => {
343
343
  test("returns error on network failure", async () => {
344
344
  globalThis.fetch = mock(() =>
345
345
  Promise.reject(new Error("connection refused")),
346
- ) as typeof fetch;
346
+ ) as unknown as typeof fetch;
347
347
 
348
348
  const result = await fetchCesLogExport({
349
349
  baseUrl: "http://ces-test:8090",
@@ -361,7 +361,7 @@ describe("fetchCesLogExport", () => {
361
361
  globalThis.fetch = mock((url: string | URL | Request) => {
362
362
  capturedUrl = typeof url === "string" ? url : url.toString();
363
363
  return Promise.resolve(new Response(new ArrayBuffer(0), { status: 200 }));
364
- }) as typeof fetch;
364
+ }) as unknown as typeof fetch;
365
365
 
366
366
  await fetchCesLogExport(
367
367
  { baseUrl: "http://ces-test:8090", serviceToken: "tok" },
@@ -40,6 +40,6 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/bun": "1.2.4",
43
- "typescript": "5.7.3"
43
+ "typescript": "5.9.3"
44
44
  }
45
45
  }
@@ -40,6 +40,6 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/bun": "1.2.4",
43
- "typescript": "5.7.3"
43
+ "typescript": "5.9.3"
44
44
  }
45
45
  }
package/openapi.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Cadet Gateway API",
5
- "version": "0.12.6",
5
+ "version": "0.12.8",
6
6
  "description": "Auto-generated OpenAPI specification for the Cadet Gateway HTTP endpoints."
7
7
  },
8
- "servers": [{ "url": "", "description": "Same-origin (gateway)" }],
8
+ "servers": [{ "url": "", "description": "Same-origin — the gateway" }],
9
9
  "paths": {
10
10
  "/v1/permissions/thresholds": {
11
11
  "get": {
@@ -105,7 +105,7 @@
105
105
  "operationId": "conversationThresholdGet",
106
106
  "summary": "Get a conversation's threshold override",
107
107
  "tags": ["permissions"],
108
- "description": "Returns { threshold: null } when no override exists. (Gateways predating that behavior returned 404 for the same condition; clients tolerate both during rollout.)",
108
+ "description": "Returns { threshold: null } when no override exists. Gateways predating that behavior returned 404 for the same condition; clients tolerate both during rollout.",
109
109
  "parameters": [
110
110
  {
111
111
  "name": "conversation_id",
@@ -209,7 +209,7 @@
209
209
  "operationId": "channelAdmissionPolicyList",
210
210
  "summary": "List per-channel admission floors",
211
211
  "tags": ["channel-admission-policy"],
212
- "description": "Returns one entry per enforced channel (exempt and hidden channels are omitted), seeded with defaults for channels without a stored row.",
212
+ "description": "Returns one entry per enforced channel, excluding exempt and hidden channels, seeded with defaults for channels without a stored row.",
213
213
  "responses": {
214
214
  "200": {
215
215
  "description": "Successful response",
@@ -272,7 +272,7 @@
272
272
  "in": "path",
273
273
  "required": true,
274
274
  "schema": { "type": "string" },
275
- "description": "The channel type (e.g. slack)"
275
+ "description": "The channel type, e.g. slack"
276
276
  }
277
277
  ],
278
278
  "requestBody": {
@@ -452,7 +452,7 @@
452
452
  "in": "path",
453
453
  "required": true,
454
454
  "schema": { "type": "string" },
455
- "description": "The declaring ingress source (today, a plugin name)"
455
+ "description": "The declaring ingress source, today a plugin name"
456
456
  }
457
457
  ],
458
458
  "requestBody": {
@@ -502,7 +502,7 @@
502
502
  "in": "path",
503
503
  "required": true,
504
504
  "schema": { "type": "string" },
505
- "description": "The declaring ingress source (today, a plugin name)"
505
+ "description": "The declaring ingress source, today a plugin name"
506
506
  }
507
507
  ],
508
508
  "responses": {
@@ -530,7 +530,7 @@
530
530
  "operationId": "channelPermissionOverridesList",
531
531
  "summary": "List channel-permission matrix cells",
532
532
  "tags": ["channel-permission-overrides"],
533
- "description": "Returns every persisted cell (cascade selector × contact-type → RiskThreshold). Unset cells fall through the cascade; the list contains only explicit overrides.",
533
+ "description": "Returns every persisted cell in the cascade selector × contact-type → RiskThreshold map. Unset cells fall through the cascade; the list contains only explicit overrides.",
534
534
  "responses": {
535
535
  "200": {
536
536
  "description": "Successful response",
@@ -854,7 +854,7 @@
854
854
  "operationId": "channelPermissionResolve",
855
855
  "summary": "Resolve the effective channel-permission threshold",
856
856
  "tags": ["channel-permission-overrides"],
857
- "description": "Read-only cascade resolution for one coordinate: walks channel → channel_type → adapter → workspace for the given selector keys and contact-type, returning the winning cell's threshold and scope, or null when no cell matches (the caller then falls through to the global thresholds). Same resolver the runtime evaluator uses over IPC.",
857
+ "description": "Read-only cascade resolution for one coordinate: walks channel → channel_type → adapter → workspace for the given selector keys and contact-type, returning the winning cell's threshold and scope, or null when no cell matches, in which case the caller falls through to the global thresholds. Same resolver the runtime evaluator uses over IPC.",
858
858
  "requestBody": {
859
859
  "required": true,
860
860
  "content": {
@@ -931,7 +931,7 @@
931
931
  "operationId": "channelPermissionOverrideDelete",
932
932
  "summary": "Delete a channel-permission matrix cell",
933
933
  "tags": ["channel-permission-overrides"],
934
- "description": "Removes one cell by its composite key (selector × contact-type), letting the next cascade tier up win. Returns whether a cell was removed.",
934
+ "description": "Removes one cell by its composite key, selector × contact-type, letting the next cascade tier up win. Returns whether a cell was removed.",
935
935
  "requestBody": {
936
936
  "required": true,
937
937
  "content": {
@@ -1027,7 +1027,7 @@
1027
1027
  "operationId": "contactsUpsert",
1028
1028
  "summary": "Create or update a contact",
1029
1029
  "tags": ["contacts"],
1030
- "description": "Gateway-native contact upsert (dual-writes the gateway ACL store and the assistant info mirror). Matches by id, then by any provided (type, address) channel, else creates.",
1030
+ "description": "Gateway-native contact upsert that dual-writes the gateway ACL store and the assistant info mirror. Matches by id, then by the provided type and address channel, else creates.",
1031
1031
  "requestBody": {
1032
1032
  "required": true,
1033
1033
  "content": {
@@ -1085,7 +1085,7 @@
1085
1085
  "address": { "type": "string" },
1086
1086
  "isPrimary": { "type": "boolean" },
1087
1087
  "externalChatId": {
1088
- "description": "Delivery chat id. Omit to preserve the stored value. A legacy explicit null is accepted for compatibility and treated as omitted; clearing is not supported (a stored null is indistinguishable from never-learned, so no consumer could tell a clear from a gap).",
1088
+ "description": "Delivery chat id. Omit to preserve the stored value. A legacy explicit null is accepted for compatibility and treated as omitted; clearing is not supported: a stored null is indistinguishable from never-learned, so no consumer could tell a clear from a gap.",
1089
1089
  "type": "string"
1090
1090
  },
1091
1091
  "status": { "type": "string" },
@@ -1141,7 +1141,7 @@
1141
1141
  },
1142
1142
  { "type": "null" }
1143
1143
  ],
1144
- "description": "Per-contact auto-approve ceiling. Null means unset (inherit cascade)."
1144
+ "description": "Per-contact auto-approve ceiling. Null means unset, inheriting the cascade."
1145
1145
  },
1146
1146
  "assistantMetadata": {
1147
1147
  "anyOf": [
@@ -1184,7 +1184,7 @@
1184
1184
  },
1185
1185
  "externalUserId": {
1186
1186
  "type": "string",
1187
- "description": "Compat alias for address (older macOS clients)"
1187
+ "description": "Compat alias for address, for older macOS clients"
1188
1188
  },
1189
1189
  "status": {
1190
1190
  "anyOf": [
@@ -1479,7 +1479,7 @@
1479
1479
  "operationId": "contactChannelVerify",
1480
1480
  "summary": "Manually verify a contact channel",
1481
1481
  "tags": ["contacts"],
1482
- "description": "Guardian-only manual attestation: marks the channel active/verified in the gateway store (source of truth) with a best-effort assistant mirror. Idempotent.",
1482
+ "description": "Guardian-only manual attestation: marks the channel active/verified in the gateway store, the source of truth, with a best-effort assistant mirror. Idempotent.",
1483
1483
  "parameters": [
1484
1484
  {
1485
1485
  "name": "channel_id",
@@ -1828,7 +1828,7 @@
1828
1828
  "in": "query",
1829
1829
  "required": false,
1830
1830
  "schema": { "type": "string" },
1831
- "description": "Filter by origin (default | user_defined)"
1831
+ "description": "Filter by origin: default or user_defined"
1832
1832
  },
1833
1833
  {
1834
1834
  "name": "tool",
@@ -1929,7 +1929,7 @@
1929
1929
  },
1930
1930
  "description": { "type": "string", "minLength": 1 },
1931
1931
  "scope": {
1932
- "description": "Compatibility field. Trust rules apply workspace-wide: the engine matches on (tool, pattern) only, so a narrower scope cannot be honored and any value other than \"everywhere\" is rejected rather than stored broader than the consent it records.",
1932
+ "description": "Compatibility field. Trust rules apply workspace-wide: the engine matches on tool and pattern only, so a narrower scope cannot be honored and any value other than \"everywhere\" is rejected rather than stored broader than the consent it records.",
1933
1933
  "type": "string",
1934
1934
  "const": "everywhere"
1935
1935
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mycadet/gateway",
3
- "version": "0.12.8",
3
+ "version": "0.12.9-dev.202610131452.9acab64",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -126,7 +126,7 @@ function buildSpec(version: string) {
126
126
  description:
127
127
  "Auto-generated OpenAPI specification for the Cadet Gateway HTTP endpoints.",
128
128
  },
129
- servers: [{ url: "", description: "Same-origin (gateway)" }],
129
+ servers: [{ url: "", description: "Same-origin — the gateway" }],
130
130
  paths,
131
131
  });
132
132
  }
@@ -53,7 +53,7 @@ function getMachineEntropy(): string {
53
53
  } catch {
54
54
  parts.push("unknown-user");
55
55
  }
56
- // Must match assistant/src/util/platform.ts#getPlatformName.
56
+ // Must match assistant/src/util/os.ts#getPlatformName.
57
57
  parts.push(process.platform);
58
58
  parts.push(process.arch);
59
59
  try {
@@ -56,7 +56,7 @@ function getMachineEntropy(): string {
56
56
  } catch {
57
57
  parts.push("unknown-user");
58
58
  }
59
- // Must mirror assistant/src/util/platform.ts#getPlatformName (raw platform).
59
+ // Must mirror assistant/src/util/os.ts#getPlatformName (raw platform).
60
60
  parts.push(process.platform);
61
61
  parts.push(process.arch);
62
62
  try {
@@ -1,3 +1,5 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { beforeEach, describe, expect, test } from "bun:test";
2
4
 
3
5
  const { handleCreateRemoteWebPairingChallenge } =
@@ -15,6 +17,7 @@ const {
15
17
  } = await import("../remote-web/pairing-challenge-store.js");
16
18
  const { resetRemoteWebPairingChallengeRateLimiterForTests } =
17
19
  await import("../remote-web/pairing-challenge-rate-limit-store.js");
20
+ const { testSecurityDir } = await import("./test-preload.js");
18
21
 
19
22
  import {
20
23
  LOOPBACK_IP,
@@ -501,3 +504,37 @@ describe("remote web pairing request list/approve/deny", () => {
501
504
  });
502
505
  });
503
506
  });
507
+
508
+ describe("file-backed persistence", () => {
509
+ test("persists challenges to the security dir for other processes", () => {
510
+ setRemoteWebPairingChallengeNowForTests(() => 1_000);
511
+ createRemoteWebPairingChallenge(PUBLIC_BASE_URL, TEST_REQUESTER);
512
+
513
+ const storePath = join(
514
+ testSecurityDir,
515
+ "remote-web-pairing-challenges.json",
516
+ );
517
+ expect(existsSync(storePath)).toBe(true);
518
+ const persisted: unknown[] = JSON.parse(readFileSync(storePath, "utf-8"));
519
+ expect(persisted).toHaveLength(1);
520
+
521
+ // The reset helper removes the file so other tests start empty.
522
+ resetRemoteWebPairingChallengesForTests();
523
+ expect(existsSync(storePath)).toBe(false);
524
+ });
525
+
526
+ test("starts empty and warns when the store file is corrupt", () => {
527
+ const storePath = join(
528
+ testSecurityDir,
529
+ "remote-web-pairing-challenges.json",
530
+ );
531
+ writeFileSync(storePath, "{not json", "utf-8");
532
+
533
+ const challenge = createRemoteWebPairingChallenge(
534
+ PUBLIC_BASE_URL,
535
+ TEST_REQUESTER,
536
+ );
537
+ const stored = getRemoteWebPairingChallengeForTests(challenge.userCode);
538
+ expect(stored).toBeDefined();
539
+ });
540
+ });
@@ -41,6 +41,7 @@ import {
41
41
  import { CURRENT_POLICY_EPOCH } from "./policy.js";
42
42
  import { mintToken } from "./token-service.js";
43
43
  import type { ScopeProfile } from "./types.js";
44
+ import { getIsCloud } from "../cloud-url.js";
44
45
 
45
46
  const log = getLogger("guardian-bootstrap");
46
47
 
@@ -1046,9 +1047,7 @@ async function fetchPlatformOwnerDisplayName(): Promise<string | null> {
1046
1047
  return null;
1047
1048
  }
1048
1049
 
1049
- const isCloud =
1050
- process.env.IS_CLOUD?.trim().toLowerCase() === "true" ||
1051
- process.env.IS_CLOUD?.trim() === "1";
1050
+ const isCloud = getIsCloud();
1052
1051
  if (!isCloud) return null;
1053
1052
 
1054
1053
  const assistantApiKey = await readCredential(
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Tests for the `gateway pairing mint <url>` CLI command. The mint executes
3
+ * the pairing logic DIRECTLY against the file-backed challenge store — no
4
+ * IPC (the assistant daemon shares the gateway's socket, so an IPC-exposed
5
+ * mint would let the assistant approve pairings for itself). These tests run
6
+ * the real command and pin both the printed challenge and its landing in
7
+ * the on-disk store the running gateway reads.
8
+ */
9
+
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
13
+ import { z } from "zod";
14
+
15
+ const { parsePairingArgs, runPairingCommand } = await import("../pairing.js");
16
+ const {
17
+ getRemoteWebPairingChallengeForTests,
18
+ resetRemoteWebPairingChallengesForTests,
19
+ } = await import("../../remote-web/pairing-challenge-store.js");
20
+ const { resetRemoteWebPairingChallengeRateLimiterForTests } =
21
+ await import("../../remote-web/pairing-challenge-rate-limit-store.js");
22
+ const { testSecurityDir } = await import("../../__tests__/test-preload.js");
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Test infrastructure
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const PrintedChallengeSchema = z.object({
29
+ deviceCode: z.string().min(1),
30
+ userCode: z.string().regex(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/),
31
+ verificationUri: z.string(),
32
+ expiresAt: z.string(),
33
+ expiresInSeconds: z.number(),
34
+ intervalSeconds: z.number(),
35
+ });
36
+
37
+ let logs: string[];
38
+ let errors: string[];
39
+ let logSpy: ReturnType<typeof spyOn>;
40
+ let errorSpy: ReturnType<typeof spyOn>;
41
+
42
+ beforeEach(() => {
43
+ resetRemoteWebPairingChallengesForTests();
44
+ resetRemoteWebPairingChallengeRateLimiterForTests();
45
+
46
+ logs = [];
47
+ errors = [];
48
+ logSpy = spyOn(console, "log").mockImplementation((...args: unknown[]) => {
49
+ logs.push(args.map(String).join(" "));
50
+ });
51
+ errorSpy = spyOn(console, "error").mockImplementation(
52
+ (...args: unknown[]) => {
53
+ errors.push(args.map(String).join(" "));
54
+ },
55
+ );
56
+ });
57
+
58
+ afterEach(() => {
59
+ logSpy.mockRestore();
60
+ errorSpy.mockRestore();
61
+ });
62
+
63
+ function lastLoggedChallenge() {
64
+ const line = logs[logs.length - 1];
65
+ expect(line).toBeTruthy();
66
+ return PrintedChallengeSchema.parse(JSON.parse(line!));
67
+ }
68
+
69
+ describe("gateway pairing mint", () => {
70
+ test("mints an approved challenge directly against the file-backed store", async () => {
71
+ const code = await runPairingCommand([
72
+ "mint",
73
+ "https://my-assistant.ts.net",
74
+ ]);
75
+
76
+ expect(code).toBe(0);
77
+ const challenge = lastLoggedChallenge();
78
+ expect(challenge.verificationUri).toBe(
79
+ "https://my-assistant.ts.net/assistant/pair",
80
+ );
81
+ expect(challenge.intervalSeconds).toBe(5);
82
+
83
+ // Approved on the spot: the CLI invocation IS the approval.
84
+ const stored = getRemoteWebPairingChallengeForTests(challenge.userCode);
85
+ expect(stored?.status).toBe("approved");
86
+ });
87
+
88
+ test("persists the challenge to the store the running gateway reads", async () => {
89
+ const code = await runPairingCommand([
90
+ "mint",
91
+ "https://my-assistant.ts.net",
92
+ ]);
93
+ expect(code).toBe(0);
94
+
95
+ const storePath = join(
96
+ testSecurityDir,
97
+ "remote-web-pairing-challenges.json",
98
+ );
99
+ expect(existsSync(storePath)).toBe(true);
100
+ const raw = readFileSync(storePath, "utf-8");
101
+ expect(raw).toContain('"status":"approved"');
102
+ expect(raw).toContain('"requesterIp":"cli"');
103
+ });
104
+
105
+ test("rejects an unparseable public base URL", async () => {
106
+ const code = await runPairingCommand(["mint", "not-a-url"]);
107
+
108
+ expect(code).toBe(1);
109
+ expect(errors.join("\n")).toContain("publicBaseUrl is required");
110
+ });
111
+
112
+ test("requires the public base URL argument", async () => {
113
+ const code = await runPairingCommand(["mint"]);
114
+
115
+ expect(code).toBe(1);
116
+ expect(errors.join("\n")).toContain("mint requires the public base URL");
117
+ });
118
+ });
119
+
120
+ describe("parsePairingArgs", () => {
121
+ test("splits the subcommand and positional", () => {
122
+ expect(parsePairingArgs(["mint", "https://x.example"])).toEqual({
123
+ subcommand: "mint",
124
+ positional: "https://x.example",
125
+ });
126
+ expect(parsePairingArgs([])).toEqual({
127
+ subcommand: null,
128
+ positional: null,
129
+ });
130
+ });
131
+ });
@@ -204,7 +204,9 @@ export async function executeContactsCommand(
204
204
  );
205
205
  }
206
206
  console.log("");
207
- console.log(`${contacts.length} contact(s)`);
207
+ console.log(
208
+ `${contacts.length} ${contacts.length === 1 ? "contact" : "contacts"}`,
209
+ );
208
210
  return 0;
209
211
  }
210
212
 
package/src/cli/index.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { runContactsCommand } from "./contacts.js";
11
+ import { runPairingCommand } from "./pairing.js";
11
12
 
12
13
  function printUsage(): void {
13
14
  console.log("Usage: gateway <command>");
@@ -15,7 +16,12 @@ function printUsage(): void {
15
16
  console.log("Operator commands for gateway-owned state.");
16
17
  console.log("");
17
18
  console.log("Commands:");
18
- console.log(" contacts Manage contact ACL (list, get, set-risk-threshold)");
19
+ console.log(
20
+ " contacts Manage the contact ACL with list, get, set-risk-threshold",
21
+ );
22
+ console.log(
23
+ " pairing Mint/approve a remote-web pairing challenge (mint <url>)",
24
+ );
19
25
  }
20
26
 
21
27
  const args = process.argv.slice(2);
@@ -31,6 +37,11 @@ if (command === "contacts") {
31
37
  process.exit(code);
32
38
  }
33
39
 
40
+ if (command === "pairing") {
41
+ const code = await runPairingCommand(args.slice(1));
42
+ process.exit(code);
43
+ }
44
+
34
45
  console.error(`Unknown command: ${command}`);
35
46
  printUsage();
36
47
  process.exit(1);
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `gateway pairing` — operator subcommands for the remote-web pairing flow.
3
+ *
4
+ * gateway pairing mint <url>
5
+ *
6
+ * Mints (and approves) a remote-web pairing challenge by executing the
7
+ * pairing mint logic DIRECTLY in this process — deliberately NOT over the
8
+ * gateway's IPC socket, which the assistant daemon shares (an IPC-exposed
9
+ * mint would let the assistant mint + approve pairings for itself). The
10
+ * challenge persists to the file-backed pairing store under
11
+ * GATEWAY_SECURITY_DIR, so the running gateway's verification and
12
+ * token-exchange routes see it. This is the surface `cadet pair` invokes
13
+ * (directly on the host, or via `docker exec` inside the gateway container):
14
+ * process execution is the local-presence proof, so no loopback-peer check
15
+ * is needed and the topology (native or containerized) stops mattering.
16
+ * Prints the challenge response as single-line JSON on stdout; the caller
17
+ * parses it and renders the pairing link/QR.
18
+ */
19
+
20
+ import { mintAndApproveRemoteWebPairingChallenge } from "../remote-web/pairing-mint.js";
21
+
22
+ function printUsage(): void {
23
+ console.log("Usage: gateway pairing mint <url>");
24
+ console.log("");
25
+ console.log(
26
+ "Mint and approve a remote-web pairing challenge on this machine and",
27
+ );
28
+ console.log("print the challenge as JSON.");
29
+ console.log("");
30
+ console.log("Arguments:");
31
+ console.log(" <url> Public base URL to advertise in the pairing link,");
32
+ console.log(" e.g. https://your-assistant.ts.net");
33
+ }
34
+
35
+ /** Parse `pairing` argv (after the command word) into a subcommand + arg. */
36
+ export function parsePairingArgs(args: string[]): {
37
+ subcommand: string | null;
38
+ positional: string | null;
39
+ } {
40
+ const [subcommand = null, positional = null] = args;
41
+ return { subcommand, positional };
42
+ }
43
+
44
+ export async function runPairingCommand(args: string[]): Promise<number> {
45
+ const { subcommand, positional } = parsePairingArgs(args);
46
+
47
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") {
48
+ printUsage();
49
+ return subcommand ? 0 : 1;
50
+ }
51
+ if (subcommand !== "mint") {
52
+ console.error(`Error: unknown pairing subcommand '${subcommand}'.`);
53
+ printUsage();
54
+ return 1;
55
+ }
56
+ if (!positional) {
57
+ console.error("Error: mint requires the public base URL to advertise.");
58
+ printUsage();
59
+ return 1;
60
+ }
61
+
62
+ try {
63
+ const challenge = mintAndApproveRemoteWebPairingChallenge(positional);
64
+ console.log(JSON.stringify(challenge));
65
+ return 0;
66
+ } catch (err) {
67
+ const message = err instanceof Error ? err.message : String(err);
68
+ console.error(`Error: pairing mint failed: ${message}`);
69
+ return 1;
70
+ }
71
+ }