@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.
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/credential-reader.test.ts +1 -1
- package/src/__tests__/credential-watcher.test.ts +1 -1
- package/src/__tests__/guardian-bootstrap-lookups.test.ts +4 -4
- package/src/__tests__/guardian-init-lockfile.test.ts +2 -2
- package/src/__tests__/remote-web-pairing-challenge.test.ts +37 -0
- package/src/__tests__/remote-web-pairing-token.test.ts +1 -1
- package/src/auth/guardian-bootstrap.ts +1 -1
- package/src/cli/__tests__/pairing.test.ts +131 -0
- package/src/cli/index.ts +12 -1
- package/src/cli/pairing.ts +71 -0
- package/src/credential-reader.ts +1 -1
- package/src/http/routes/guardian-pin.ts +2 -1
- package/src/http/routes/ingress-domains.test.ts +5 -2
- package/src/http/routes/ingress-domains.ts +2 -8
- package/src/http/routes/live-voice-websocket.ts +2 -1
- package/src/http/routes/plugin-webhook-websocket.test.ts +33 -40
- package/src/http/routes/plugin-webhook-websocket.ts +2 -1
- package/src/http/routes/remote-web-pairing-challenge.ts +8 -1
- package/src/http/routes/runtime-audio-stream.ts +2 -1
- package/src/http/routes/speech-relay-websocket.ts +4 -3
- package/src/http/utils.ts +3 -0
- package/src/index.ts +4 -3
- package/src/remote-web/pairing-challenge-rate-limit-store.ts +94 -0
- package/src/remote-web/pairing-challenge-store.ts +130 -2
- 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
|
-
|
|
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("
|
|
22
|
+
describe("tunnelHostForCloudHost", () => {
|
|
23
23
|
test("maps the prod platform host to the prod tunnel", () => {
|
|
24
|
-
expect(
|
|
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(
|
|
30
|
+
expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
|
|
31
31
|
"tunnel-staging.mycadet.ai",
|
|
32
32
|
);
|
|
33
|
-
expect(
|
|
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(
|
|
40
|
-
expect(
|
|
39
|
+
expect(tunnelHostForCloudHost("localhost")).toBeNull();
|
|
40
|
+
expect(tunnelHostForCloudHost("platform.example.com")).toBeNull();
|
|
41
41
|
expect(
|
|
42
|
-
|
|
42
|
+
tunnelHostForCloudHost("staging-cloud.mycadet.ai.evil.com"),
|
|
43
43
|
).toBeNull();
|
|
44
44
|
});
|
|
45
45
|
});
|
package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
22
|
+
describe("tunnelHostForCloudHost", () => {
|
|
23
23
|
test("maps the prod platform host to the prod tunnel", () => {
|
|
24
|
-
expect(
|
|
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(
|
|
30
|
+
expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
|
|
31
31
|
"tunnel-staging.mycadet.ai",
|
|
32
32
|
);
|
|
33
|
-
expect(
|
|
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(
|
|
40
|
-
expect(
|
|
39
|
+
expect(tunnelHostForCloudHost("localhost")).toBeNull();
|
|
40
|
+
expect(tunnelHostForCloudHost("platform.example.com")).toBeNull();
|
|
41
41
|
expect(
|
|
42
|
-
|
|
42
|
+
tunnelHostForCloudHost("staging-cloud.mycadet.ai.evil.com"),
|
|
43
43
|
).toBeNull();
|
|
44
44
|
});
|
|
45
45
|
});
|
package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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("
|
|
22
|
+
describe("tunnelHostForCloudHost", () => {
|
|
23
23
|
test("maps the prod platform host to the prod tunnel", () => {
|
|
24
|
-
expect(
|
|
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(
|
|
30
|
+
expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
|
|
31
31
|
"tunnel-staging.mycadet.ai",
|
|
32
32
|
);
|
|
33
|
-
expect(
|
|
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(
|
|
40
|
-
expect(
|
|
39
|
+
expect(tunnelHostForCloudHost("localhost")).toBeNull();
|
|
40
|
+
expect(tunnelHostForCloudHost("platform.example.com")).toBeNull();
|
|
41
41
|
expect(
|
|
42
|
-
|
|
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
|
|
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
|
@@ -53,7 +53,7 @@ function getMachineEntropy(): string {
|
|
|
53
53
|
} catch {
|
|
54
54
|
parts.push("unknown-user");
|
|
55
55
|
}
|
|
56
|
-
// Must match assistant/src/util/
|
|
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/
|
|
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(/^
|
|
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(/^
|
|
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(/^
|
|
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(/^
|
|
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(/^
|
|
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(/^
|
|
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(/^
|
|
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 = `
|
|
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(
|
|
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
|
+
}
|
package/src/credential-reader.ts
CHANGED
|
@@ -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/
|
|
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
|
|
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("
|
|
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(
|
|
110
|
+
expect(res.status).toBe(200);
|
|
108
111
|
});
|
|
109
112
|
|
|
110
113
|
test("rejects a body without a usable public URL", async () => {
|