@mycadet/gateway 0.12.9 → 0.12.11

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 (33) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  2. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  3. package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  5. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  8. package/package.json +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__/guardian-bootstrap-lookups.test.ts +4 -4
  12. package/src/__tests__/guardian-init-lockfile.test.ts +2 -2
  13. package/src/__tests__/remote-web-pairing-challenge.test.ts +37 -0
  14. package/src/__tests__/remote-web-pairing-token.test.ts +1 -1
  15. package/src/auth/guardian-bootstrap.ts +1 -1
  16. package/src/cli/__tests__/pairing.test.ts +131 -0
  17. package/src/cli/index.ts +12 -1
  18. package/src/cli/pairing.ts +71 -0
  19. package/src/credential-reader.ts +1 -1
  20. package/src/http/routes/guardian-pin.ts +2 -1
  21. package/src/http/routes/ingress-domains.test.ts +5 -2
  22. package/src/http/routes/ingress-domains.ts +2 -8
  23. package/src/http/routes/live-voice-websocket.ts +2 -1
  24. package/src/http/routes/plugin-webhook-websocket.test.ts +33 -40
  25. package/src/http/routes/plugin-webhook-websocket.ts +2 -1
  26. package/src/http/routes/remote-web-pairing-challenge.ts +8 -1
  27. package/src/http/routes/runtime-audio-stream.ts +2 -1
  28. package/src/http/routes/speech-relay-websocket.ts +4 -3
  29. package/src/http/utils.ts +3 -0
  30. package/src/index.ts +4 -3
  31. package/src/remote-web/pairing-challenge-rate-limit-store.ts +94 -0
  32. package/src/remote-web/pairing-challenge-store.ts +130 -2
  33. package/src/remote-web/pairing-mint.ts +94 -0
@@ -10,7 +10,7 @@ import {
10
10
  trimmedNonEmptyString,
11
11
  TUNNEL_PROVIDERS,
12
12
  type TunnelRecord,
13
- tunnelHostForPlatformHost,
13
+ tunnelHostForCloudHost,
14
14
  } from "../ingress.js";
15
15
  import {
16
16
  buildTwilioMediaStreamUrl,
@@ -19,27 +19,27 @@ import {
19
19
  resolveTwilioPublicBaseUrl,
20
20
  } from "../twilio-ingress.js";
21
21
 
22
- describe("tunnelHostForPlatformHost", () => {
22
+ describe("tunnelHostForCloudHost", () => {
23
23
  test("maps the prod platform host to the prod tunnel", () => {
24
- expect(tunnelHostForPlatformHost("cloud.mycadet.ai")).toBe(
24
+ expect(tunnelHostForCloudHost("cloud.mycadet.ai")).toBe(
25
25
  "tunnel.mycadet.ai",
26
26
  );
27
27
  });
28
28
 
29
29
  test("maps env-prefixed platform hosts to their env tunnel", () => {
30
- expect(tunnelHostForPlatformHost("staging-cloud.mycadet.ai")).toBe(
30
+ expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
31
31
  "tunnel-staging.mycadet.ai",
32
32
  );
33
- expect(tunnelHostForPlatformHost("dev-cloud.mycadet.ai")).toBe(
33
+ expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
34
34
  "tunnel-dev.mycadet.ai",
35
35
  );
36
36
  });
37
37
 
38
38
  test("returns null for hosts outside the deployment convention", () => {
39
- expect(tunnelHostForPlatformHost("localhost")).toBeNull();
40
- expect(tunnelHostForPlatformHost("platform.example.com")).toBeNull();
39
+ expect(tunnelHostForCloudHost("localhost")).toBeNull();
40
+ expect(tunnelHostForCloudHost("platform.example.com")).toBeNull();
41
41
  expect(
42
- tunnelHostForPlatformHost("staging-cloud.mycadet.ai.evil.com"),
42
+ tunnelHostForCloudHost("staging-cloud.mycadet.ai.evil.com"),
43
43
  ).toBeNull();
44
44
  });
45
45
  });
@@ -6,7 +6,7 @@
6
6
  * gateway speech relay and the web live-voice client so the two ends of the
7
7
  * cloud-speech transport can't drift.
8
8
  */
9
- export function tunnelHostForPlatformHost(host: string): string | null {
9
+ export function tunnelHostForCloudHost(host: string): string | null {
10
10
  if (host === "cloud.mycadet.ai") {
11
11
  return "tunnel.mycadet.ai";
12
12
  }
@@ -143,6 +143,19 @@ export interface CesHttpCredentialClient {
143
143
  // Factory
144
144
  // ---------------------------------------------------------------------------
145
145
 
146
+ function listErrorContext(
147
+ response: Response,
148
+ path: string,
149
+ ): Record<string, unknown> {
150
+ const requestId = response.headers.get("x-ces-error-id");
151
+ return {
152
+ status: response.status,
153
+ method: "GET",
154
+ path,
155
+ ...(requestId && /^[a-f0-9-]{36}$/i.test(requestId) ? { requestId } : {}),
156
+ };
157
+ }
158
+
146
159
  export function createCesHttpCredentialClient(
147
160
  config: CesHttpCredentialConfig,
148
161
  logger: CesHttpLogger,
@@ -298,7 +311,7 @@ export function createCesHttpCredentialClient(
298
311
  if (!res) return { accounts: [], unreachable: true };
299
312
  if (!res.ok) {
300
313
  logger.warn(
301
- { status: res.status },
314
+ listErrorContext(res, "/v1/credentials"),
302
315
  "CES credential list returned non-OK status",
303
316
  );
304
317
  return { accounts: [], unreachable: true };
@@ -399,6 +412,10 @@ export function createCesHttpCredentialClient(
399
412
  return { records: [], unreachable: true };
400
413
  }
401
414
  if (!res.ok) {
415
+ logger.warn(
416
+ listErrorContext(res, "/v1/metadata"),
417
+ "CES metadata list returned non-OK status",
418
+ );
402
419
  return { records: [], unreachable: true };
403
420
  }
404
421
  const data = (await res.json()) as {
@@ -10,7 +10,7 @@ import {
10
10
  trimmedNonEmptyString,
11
11
  TUNNEL_PROVIDERS,
12
12
  type TunnelRecord,
13
- tunnelHostForPlatformHost,
13
+ tunnelHostForCloudHost,
14
14
  } from "../ingress.js";
15
15
  import {
16
16
  buildTwilioMediaStreamUrl,
@@ -19,27 +19,27 @@ import {
19
19
  resolveTwilioPublicBaseUrl,
20
20
  } from "../twilio-ingress.js";
21
21
 
22
- describe("tunnelHostForPlatformHost", () => {
22
+ describe("tunnelHostForCloudHost", () => {
23
23
  test("maps the prod platform host to the prod tunnel", () => {
24
- expect(tunnelHostForPlatformHost("cloud.mycadet.ai")).toBe(
24
+ expect(tunnelHostForCloudHost("cloud.mycadet.ai")).toBe(
25
25
  "tunnel.mycadet.ai",
26
26
  );
27
27
  });
28
28
 
29
29
  test("maps env-prefixed platform hosts to their env tunnel", () => {
30
- expect(tunnelHostForPlatformHost("staging-cloud.mycadet.ai")).toBe(
30
+ expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
31
31
  "tunnel-staging.mycadet.ai",
32
32
  );
33
- expect(tunnelHostForPlatformHost("dev-cloud.mycadet.ai")).toBe(
33
+ expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
34
34
  "tunnel-dev.mycadet.ai",
35
35
  );
36
36
  });
37
37
 
38
38
  test("returns null for hosts outside the deployment convention", () => {
39
- expect(tunnelHostForPlatformHost("localhost")).toBeNull();
40
- expect(tunnelHostForPlatformHost("platform.example.com")).toBeNull();
39
+ expect(tunnelHostForCloudHost("localhost")).toBeNull();
40
+ expect(tunnelHostForCloudHost("platform.example.com")).toBeNull();
41
41
  expect(
42
- tunnelHostForPlatformHost("staging-cloud.mycadet.ai.evil.com"),
42
+ tunnelHostForCloudHost("staging-cloud.mycadet.ai.evil.com"),
43
43
  ).toBeNull();
44
44
  });
45
45
  });
@@ -6,7 +6,7 @@
6
6
  * gateway speech relay and the web live-voice client so the two ends of the
7
7
  * cloud-speech transport can't drift.
8
8
  */
9
- export function tunnelHostForPlatformHost(host: string): string | null {
9
+ export function tunnelHostForCloudHost(host: string): string | null {
10
10
  if (host === "cloud.mycadet.ai") {
11
11
  return "tunnel.mycadet.ai";
12
12
  }
@@ -10,7 +10,7 @@ import {
10
10
  trimmedNonEmptyString,
11
11
  TUNNEL_PROVIDERS,
12
12
  type TunnelRecord,
13
- tunnelHostForPlatformHost,
13
+ tunnelHostForCloudHost,
14
14
  } from "../ingress.js";
15
15
  import {
16
16
  buildTwilioMediaStreamUrl,
@@ -19,27 +19,27 @@ import {
19
19
  resolveTwilioPublicBaseUrl,
20
20
  } from "../twilio-ingress.js";
21
21
 
22
- describe("tunnelHostForPlatformHost", () => {
22
+ describe("tunnelHostForCloudHost", () => {
23
23
  test("maps the prod platform host to the prod tunnel", () => {
24
- expect(tunnelHostForPlatformHost("cloud.mycadet.ai")).toBe(
24
+ expect(tunnelHostForCloudHost("cloud.mycadet.ai")).toBe(
25
25
  "tunnel.mycadet.ai",
26
26
  );
27
27
  });
28
28
 
29
29
  test("maps env-prefixed platform hosts to their env tunnel", () => {
30
- expect(tunnelHostForPlatformHost("staging-cloud.mycadet.ai")).toBe(
30
+ expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
31
31
  "tunnel-staging.mycadet.ai",
32
32
  );
33
- expect(tunnelHostForPlatformHost("dev-cloud.mycadet.ai")).toBe(
33
+ expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
34
34
  "tunnel-dev.mycadet.ai",
35
35
  );
36
36
  });
37
37
 
38
38
  test("returns null for hosts outside the deployment convention", () => {
39
- expect(tunnelHostForPlatformHost("localhost")).toBeNull();
40
- expect(tunnelHostForPlatformHost("platform.example.com")).toBeNull();
39
+ expect(tunnelHostForCloudHost("localhost")).toBeNull();
40
+ expect(tunnelHostForCloudHost("platform.example.com")).toBeNull();
41
41
  expect(
42
- tunnelHostForPlatformHost("staging-cloud.mycadet.ai.evil.com"),
42
+ tunnelHostForCloudHost("staging-cloud.mycadet.ai.evil.com"),
43
43
  ).toBeNull();
44
44
  });
45
45
  });
@@ -6,7 +6,7 @@
6
6
  * gateway speech relay and the web live-voice client so the two ends of the
7
7
  * cloud-speech transport can't drift.
8
8
  */
9
- export function tunnelHostForPlatformHost(host: string): string | null {
9
+ export function tunnelHostForCloudHost(host: string): string | null {
10
10
  if (host === "cloud.mycadet.ai") {
11
11
  return "tunnel.mycadet.ai";
12
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mycadet/gateway",
3
- "version": "0.12.9",
3
+ "version": "0.12.11",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 {
@@ -337,7 +337,7 @@ describe("resolve-or-mint (resolveOrCreateCadetGuardian)", () => {
337
337
  assistantBackend = makeAssistantBackend();
338
338
 
339
339
  const principalId = await ensureCadetGuardianBinding();
340
- expect(principalId).toMatch(/^cadet-principal-/);
340
+ expect(principalId).toMatch(/^principal-/);
341
341
 
342
342
  // Gateway now has the row; the assistant DB must not be read again.
343
343
  assistantBackend = throwingBackend;
@@ -350,7 +350,7 @@ describe("resolve-or-mint (resolveOrCreateCadetGuardian)", () => {
350
350
 
351
351
  const principalId = await ensureCadetGuardianBinding();
352
352
 
353
- expect(principalId).toMatch(/^cadet-principal-/);
353
+ expect(principalId).toMatch(/^principal-/);
354
354
  const gwRows = gatewayCadetGuardians();
355
355
  expect(gwRows).toHaveLength(1);
356
356
  expect(gwRows[0]?.principalId).toBe(principalId);
@@ -383,7 +383,7 @@ describe("resolve-or-mint (resolveOrCreateCadetGuardian)", () => {
383
383
  deviceId: "device-1",
384
384
  });
385
385
 
386
- expect(result.guardianPrincipalId).toMatch(/^cadet-principal-/);
386
+ expect(result.guardianPrincipalId).toMatch(/^principal-/);
387
387
  expect(result.isNew).toBe(true);
388
388
  expect(result.mintedOverPriorEvidence).toBe(false);
389
389
  });
@@ -606,7 +606,7 @@ describe("resolve-or-mint (resolveOrCreateCadetGuardian)", () => {
606
606
  deviceId: "device-1",
607
607
  });
608
608
 
609
- expect(result.guardianPrincipalId).toMatch(/^cadet-principal-/);
609
+ expect(result.guardianPrincipalId).toMatch(/^principal-/);
610
610
  expect(result.isNew).toBe(true);
611
611
  expect(result.mintedOverPriorEvidence).toBe(true);
612
612
  expect(gatewayCadetGuardians()).toHaveLength(1);
@@ -271,7 +271,7 @@ describe("guardian/init bootstrap secret", () => {
271
271
 
272
272
  expect(res.status).toBe(200);
273
273
  const body = await res.json();
274
- expect(body.guardianPrincipalId).toMatch(/^cadet-principal-/);
274
+ expect(body.guardianPrincipalId).toMatch(/^principal-/);
275
275
  expect(body.accessToken).toBeTruthy();
276
276
  expect(body.refreshToken).toBeTruthy();
277
277
  expect(body.isNew).toBe(true);
@@ -391,7 +391,7 @@ describe("guardian/init one-time-use lockfile", () => {
391
391
 
392
392
  expect(res.status).toBe(200);
393
393
  const body = await res.json();
394
- expect(body.guardianPrincipalId).toMatch(/^cadet-principal-/);
394
+ expect(body.guardianPrincipalId).toMatch(/^principal-/);
395
395
  expect(body.accessToken).toBeTruthy();
396
396
  expect(body.refreshToken).toBeTruthy();
397
397
  expect(lockFileExists()).toBe(true);
@@ -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
+ });
@@ -622,7 +622,7 @@ describe("remote web pairing token exchange", () => {
622
622
  .where(eq(contacts.role, "guardian"))
623
623
  .all();
624
624
  expect(gwGuardian).toHaveLength(1);
625
- expect(gwGuardian[0].principalId).toMatch(/^cadet-principal-/);
625
+ expect(gwGuardian[0].principalId).toMatch(/^principal-/);
626
626
  });
627
627
 
628
628
  test("gateway binding write failure fails the mint and leaves the code retryable", async () => {
@@ -1220,7 +1220,7 @@ async function resolveOrCreateCadetGuardian(options: {
1220
1220
 
1221
1221
  // No gateway guardian — mint a fresh principal.
1222
1222
  const displayName = await fetchPlatformOwnerDisplayName();
1223
- const guardianPrincipalId = `cadet-principal-${uuid()}`;
1223
+ const guardianPrincipalId = `principal-${uuid()}`;
1224
1224
  await createGuardianBinding({
1225
1225
  channel: "cadet",
1226
1226
  externalUserId: guardianPrincipalId,
@@ -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
+ });
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 the contact ACL with 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
+ }
@@ -46,7 +46,7 @@ type StoreFile = StoreFileV1 | StoreFileV2;
46
46
  const STORE_KEY_FILENAME = "store.key";
47
47
 
48
48
  function getPlatformName(): string {
49
- // Must match assistant/src/util/platform.ts#getPlatformName exactly.
49
+ // Must match assistant/src/util/os.ts#getPlatformName exactly.
50
50
  // Using user-friendly labels like "macOS" here changes PBKDF2 entropy and
51
51
  // makes gateway unable to decrypt credentials written by the daemon.
52
52
  return process.platform;
@@ -40,6 +40,7 @@ import { findCadetGuardian } from "../../auth/guardian-bootstrap.js";
40
40
  import type { GatewayConfig } from "../../config.js";
41
41
  import { requestHasTunnelBridgeAuth } from "../../tunnel/bridge-auth.js";
42
42
  import { readStoredCloudUserId } from "../../cloud-user-id.js";
43
+ import { isWebsocketUpgradeRequest } from "../utils.js";
43
44
 
44
45
  const TUNNEL_USER_ID_HEADER = "x-tunnel-user-id";
45
46
  const TUNNEL_ORG_ID_HEADER = "x-tunnel-org-id";
@@ -175,7 +176,7 @@ export async function authorizeGuardianStream(
175
176
  // Checked here as well as in the shared gate, because the cloud path
176
177
  // below skips that gate entirely: without this, a cloud caller sending a
177
178
  // plain request would fall through to `server.upgrade` and get a 500.
178
- if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
179
+ if (!isWebsocketUpgradeRequest(req)) {
179
180
  return new Response("Upgrade Required", { status: 426 });
180
181
  }
181
182
 
@@ -97,14 +97,17 @@ describe("POST /v1/ingress/domains", () => {
97
97
  expect(afterSecond).toBe(afterFirst);
98
98
  });
99
99
 
100
- test("rejects non-loopback callers", async () => {
100
+ test("does not gate on the peer IP — auth lives in the edge-guardian strategy", async () => {
101
+ // The docker hatch's gateway sees the host CLI as a non-loopback peer
102
+ // (the docker bridge IP); peer-IP gating here 403'd valid
103
+ // guardian-token requests, so it moved to the auth strategy.
101
104
  const res = await handleSaveIngressDomain(
102
105
  request({ publicBaseUrl: "https://foo.ngrok.app" }),
103
106
  "10.0.0.1",
104
107
  new ConfigFileCache(),
105
108
  );
106
109
 
107
- expect(res.status).toBe(403);
110
+ expect(res.status).toBe(200);
108
111
  });
109
112
 
110
113
  test("rejects a body without a usable public URL", async () => {