@mycadet/gateway 0.12.10 → 0.13.0
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 +10 -10
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- 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 +10 -10
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/__tests__/actor-token-revocation.test.ts +41 -34
- package/src/__tests__/contact-store-mirror-op-missing.test.ts +33 -70
- package/src/__tests__/contacts-mirror-op-reporter.test.ts +33 -89
- package/src/__tests__/credential-requests.test.ts +4 -4
- package/src/__tests__/devices-mine.test.ts +187 -0
- package/src/__tests__/edge-auth.test.ts +1 -20
- package/src/__tests__/edge-guardian-auth.test.ts +1 -17
- package/src/__tests__/feature-flag-resolver.test.ts +2 -30
- package/src/__tests__/feature-flag-watcher-callback.test.ts +0 -22
- package/src/__tests__/feature-flags-route.test.ts +2 -191
- package/src/__tests__/guardian-bootstrap-lookups.test.ts +22 -29
- package/src/__tests__/guardian-init-lockfile.test.ts +2 -2
- package/src/__tests__/guardian-integrity-reporter.test.ts +51 -82
- package/src/__tests__/guardian-integrity.test.ts +16 -20
- package/src/__tests__/handle-inbound-trust-verdict.test.ts +1 -14
- package/src/__tests__/ipc-feature-flag-routes.test.ts +0 -56
- package/src/__tests__/lum-2783-recovery-e2e.test.ts +0 -12
- package/src/__tests__/post-assistant-ready-deferred.test.ts +0 -18
- package/src/__tests__/refresh-device-binding.test.ts +0 -12
- package/src/__tests__/remote-web-pairing-token.test.ts +5 -15
- package/src/__tests__/runtime-client.test.ts +7 -7
- package/src/__tests__/speech-relay-websocket.test.ts +4 -4
- package/src/__tests__/trust-verdict-resolver-guardian-integrity.test.ts +30 -36
- package/src/__tests__/twilio-webhooks.test.ts +2 -2
- package/src/auth/actor-token-revocation.ts +34 -9
- package/src/auth/guardian-bootstrap.ts +1 -1
- package/src/cli/__tests__/pairing.test.ts +95 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/pairing.ts +81 -6
- package/src/contacts-mirror-op-reporter.ts +19 -36
- package/src/db/connection.ts +0 -10
- package/src/feature-flag-resolver.ts +2 -7
- package/src/feature-flag-watcher.ts +1 -14
- package/src/guardian-integrity-reporter.ts +19 -47
- package/src/http/middleware/auth.ts +33 -27
- package/src/http/routes/auth-token.ts +1 -1
- package/src/http/routes/devices-mine.ts +103 -0
- package/src/http/routes/feature-flags.ts +1 -8
- package/src/http/routes/guardian-pin.ts +2 -11
- package/src/http/routes/ipc-runtime-proxy.ts +1 -1
- package/src/http/routes/live-voice-websocket.ts +1 -1
- package/src/http/routes/runtime-audio-stream.ts +1 -1
- package/src/http/routes/runtime-proxy.ts +2 -2
- package/src/http/routes/speech-relay-websocket.ts +2 -2
- package/src/http/routes/twilio-voice-webhook.test.ts +13 -13
- package/src/index.ts +11 -198
- package/src/ipc/feature-flag-handlers.ts +2 -9
- package/src/remote-web/pairing-challenge-store.ts +15 -0
- package/src/risk/command-registry/commands/assistant.ts +0 -24
- package/src/risk/command-registry.test.ts +2 -4
- package/src/schema.ts +44 -372
- package/src/__tests__/auth-fallback-count-tracker.test.ts +0 -92
- package/src/__tests__/auth-fallback-reporter.test.ts +0 -101
- package/src/__tests__/backup-worker-cloud-mode.test.ts +0 -44
- package/src/__tests__/backup-worker-export-timeout.test.ts +0 -164
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -216
- package/src/__tests__/remote-feature-flag-sync.test.ts +0 -961
- package/src/auth-fallback-count-tracker.ts +0 -113
- package/src/auth-fallback-reporter.ts +0 -127
- package/src/backup/backup-key.ts +0 -138
- package/src/backup/backup-routes.ts +0 -161
- package/src/backup/backup-worker.ts +0 -425
- package/src/backup/list-snapshots.ts +0 -97
- package/src/backup/local-writer.ts +0 -87
- package/src/backup/offsite-writer.ts +0 -182
- package/src/backup/paths.ts +0 -113
- package/src/backup/stream-crypt.ts +0 -258
- package/src/device-id.ts +0 -18
- package/src/feature-flag-remote-store.ts +0 -166
- package/src/http/routes/migration-proxy.ts +0 -636
- package/src/internal-telemetry-client.ts +0 -39
- package/src/ipc/debug-export-handlers.test.ts +0 -102
- package/src/ipc/debug-export-handlers.ts +0 -81
- package/src/remote-feature-flag-sync.ts +0 -440
- package/src/watchdog-reporter.ts +0 -110
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { and, eq, max } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
import { actorTokenRecordHash } from "../../auth/actor-token-revocation.js";
|
|
4
|
+
import { getGatewayDb } from "../../db/connection.js";
|
|
5
|
+
import { actorTokenRecords } from "../../db/schema.js";
|
|
6
|
+
import { errorResponse } from "../loopback-guard.js";
|
|
7
|
+
import {
|
|
8
|
+
admittedActorTokenRecord,
|
|
9
|
+
isCloudAuthBypassActive,
|
|
10
|
+
} from "../middleware/auth.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `GET /v1/devices/mine` — the caller's own paired-device row.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately browser-reachable (no loopback guard, no origin rejection):
|
|
16
|
+
* the bearer IS the credential, and the route answers only for the token
|
|
17
|
+
* presenting it. This is how the paired web UI surfaces "this device" so
|
|
18
|
+
* its row can be matched against the host-side `cadet devices` list — the
|
|
19
|
+
* gateway stores nothing but the hashed device id, so the hash is the only
|
|
20
|
+
* matchable identity either side can name. The projection mirrors
|
|
21
|
+
* `/v1/devices` exactly (including the cross-status `max(lastUsedAt)`, since
|
|
22
|
+
* a rotation leaves the newest activity stamp on the revoked row), so a
|
|
23
|
+
* `devices/mine` response matches the same device's row in the list output
|
|
24
|
+
* field for field.
|
|
25
|
+
*/
|
|
26
|
+
export async function handleGetMyDevice(req: Request): Promise<Response> {
|
|
27
|
+
if (req.method !== "GET") {
|
|
28
|
+
return new Response("method not allowed", {
|
|
29
|
+
status: 405,
|
|
30
|
+
headers: { Allow: "GET" },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Cloud-hosted assistants have no paired-device rows to name — the route
|
|
35
|
+
// only answers for self-hosted gateways.
|
|
36
|
+
if (isCloudAuthBypassActive()) {
|
|
37
|
+
return errorResponse(
|
|
38
|
+
"NOT_AVAILABLE_ON_CLOUD",
|
|
39
|
+
"this assistant is cloud-hosted — it has no paired-device rows",
|
|
40
|
+
404,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const authorization = req.headers.get("authorization") ?? "";
|
|
45
|
+
const bearer = authorization.startsWith("Bearer ")
|
|
46
|
+
? authorization.slice("Bearer ".length).trim()
|
|
47
|
+
: "";
|
|
48
|
+
|
|
49
|
+
const db = getGatewayDb();
|
|
50
|
+
// Edge auth already loaded this row by the bearer's hash while admitting
|
|
51
|
+
// the token (admitActorToken) — reuse it when present. The fallback query
|
|
52
|
+
// covers paths where admission produced no row (fail-open on a DB error,
|
|
53
|
+
// or the handler driven directly in tests).
|
|
54
|
+
const row =
|
|
55
|
+
admittedActorTokenRecord(req) ??
|
|
56
|
+
db
|
|
57
|
+
.select({
|
|
58
|
+
guardianPrincipalId: actorTokenRecords.guardianPrincipalId,
|
|
59
|
+
hashedDeviceId: actorTokenRecords.hashedDeviceId,
|
|
60
|
+
platform: actorTokenRecords.platform,
|
|
61
|
+
pairingUserAgent: actorTokenRecords.pairingUserAgent,
|
|
62
|
+
clientReportedName: actorTokenRecords.clientReportedName,
|
|
63
|
+
issuedAt: actorTokenRecords.issuedAt,
|
|
64
|
+
expiresAt: actorTokenRecords.expiresAt,
|
|
65
|
+
})
|
|
66
|
+
.from(actorTokenRecords)
|
|
67
|
+
.where(eq(actorTokenRecords.tokenHash, actorTokenRecordHash(bearer)))
|
|
68
|
+
.get();
|
|
69
|
+
|
|
70
|
+
if (!row) {
|
|
71
|
+
return errorResponse(
|
|
72
|
+
"DEVICE_NOT_FOUND",
|
|
73
|
+
"no paired device row for this token",
|
|
74
|
+
404,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const lastUsed = db
|
|
79
|
+
.select({ lastUsedAt: max(actorTokenRecords.lastUsedAt) })
|
|
80
|
+
.from(actorTokenRecords)
|
|
81
|
+
.where(
|
|
82
|
+
and(
|
|
83
|
+
eq(actorTokenRecords.hashedDeviceId, row.hashedDeviceId),
|
|
84
|
+
eq(actorTokenRecords.guardianPrincipalId, row.guardianPrincipalId),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
.get();
|
|
88
|
+
|
|
89
|
+
return Response.json(
|
|
90
|
+
{
|
|
91
|
+
device: {
|
|
92
|
+
hashedDeviceId: row.hashedDeviceId,
|
|
93
|
+
platform: row.platform,
|
|
94
|
+
pairingUserAgent: row.pairingUserAgent ?? null,
|
|
95
|
+
clientReportedName: row.clientReportedName ?? null,
|
|
96
|
+
issuedAt: row.issuedAt,
|
|
97
|
+
expiresAt: row.expiresAt ?? null,
|
|
98
|
+
lastUsedAt: lastUsed?.lastUsedAt ?? null,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{ headers: { "Cache-Control": "no-store" } },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
isFlagDeclared,
|
|
6
6
|
} from "../../feature-flag-defaults.js";
|
|
7
7
|
import { readEnvFeatureFlagOverrides } from "../../feature-flag-env-overrides.js";
|
|
8
|
-
import { readRemoteFeatureFlags } from "../../feature-flag-remote-store.js";
|
|
9
8
|
import {
|
|
10
9
|
readPersistedFeatureFlags,
|
|
11
10
|
writeFeatureFlag,
|
|
@@ -81,19 +80,13 @@ export function createFeatureFlagsGetHandler() {
|
|
|
81
80
|
try {
|
|
82
81
|
const defaults = loadFeatureFlagDefaults();
|
|
83
82
|
const persisted = readPersistedFeatureFlags();
|
|
84
|
-
const remote = readRemoteFeatureFlags();
|
|
85
83
|
const envOverrides = readEnvFeatureFlagOverrides();
|
|
86
84
|
|
|
87
85
|
const entries: FeatureFlagEntry[] = [];
|
|
88
86
|
for (const [key, def] of Object.entries(defaults)) {
|
|
89
87
|
const persistedValue = persisted[key];
|
|
90
|
-
const remoteValue = remote[key];
|
|
91
88
|
const base =
|
|
92
|
-
persistedValue !== undefined
|
|
93
|
-
? persistedValue
|
|
94
|
-
: remoteValue !== undefined
|
|
95
|
-
? remoteValue
|
|
96
|
-
: def.defaultEnabled;
|
|
89
|
+
persistedValue !== undefined ? persistedValue : def.defaultEnabled;
|
|
97
90
|
const envValue = envOverrides[key];
|
|
98
91
|
entries.push({
|
|
99
92
|
key,
|
|
@@ -38,6 +38,7 @@ import type { Logger } from "pino";
|
|
|
38
38
|
import { authorizeRuntimeAudioStream } from "./runtime-audio-stream.js";
|
|
39
39
|
import { findCadetGuardian } from "../../auth/guardian-bootstrap.js";
|
|
40
40
|
import type { GatewayConfig } from "../../config.js";
|
|
41
|
+
import { getIsCloud } from "../../cloud-url.js";
|
|
41
42
|
import { requestHasTunnelBridgeAuth } from "../../tunnel/bridge-auth.js";
|
|
42
43
|
import { readStoredCloudUserId } from "../../cloud-user-id.js";
|
|
43
44
|
import { isWebsocketUpgradeRequest } from "../utils.js";
|
|
@@ -46,16 +47,6 @@ const TUNNEL_USER_ID_HEADER = "x-tunnel-user-id";
|
|
|
46
47
|
const TUNNEL_ORG_ID_HEADER = "x-tunnel-org-id";
|
|
47
48
|
const TUNNEL_ACTOR_HEADER = "x-tunnel-actor";
|
|
48
49
|
|
|
49
|
-
/**
|
|
50
|
-
* True when the gateway runs in managed/cloud mode (vembda + tunnel ingress).
|
|
51
|
-
* Mirrors the `IS_CLOUD` check used by the gateway's HTTP edge auth
|
|
52
|
-
* (`src/http/middleware/auth.ts`) and feature-flag resolver.
|
|
53
|
-
*/
|
|
54
|
-
export function isPlatformManaged(): boolean {
|
|
55
|
-
const v = process.env.IS_CLOUD?.trim().toLowerCase();
|
|
56
|
-
return v === "1" || v === "true";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
50
|
/**
|
|
60
51
|
* True when a tunnel-attested caller can reach this gateway at all: a cloud
|
|
61
52
|
* pod, or any gateway started with a tunnel (`TUNNEL_BASE_URL`), which is
|
|
@@ -68,7 +59,7 @@ export function isPlatformManaged(): boolean {
|
|
|
68
59
|
export function acceptsTunnelAttestation(
|
|
69
60
|
config: Pick<GatewayConfig, "tunnelBaseUrl">,
|
|
70
61
|
): boolean {
|
|
71
|
-
return
|
|
62
|
+
return getIsCloud() || config.tunnelBaseUrl !== undefined;
|
|
72
63
|
}
|
|
73
64
|
|
|
74
65
|
/** Tunnel-attested managed caller context, extracted from injected headers. */
|
|
@@ -95,7 +95,7 @@ export async function tryIpcProxy(
|
|
|
95
95
|
);
|
|
96
96
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
97
97
|
}
|
|
98
|
-
if (!admitActorToken(edgeJwt, result.claims)) {
|
|
98
|
+
if (!admitActorToken(edgeJwt, result.claims).admitted) {
|
|
99
99
|
log.warn(
|
|
100
100
|
{ method: req.method, path: new URL(req.url).pathname },
|
|
101
101
|
"IPC proxy auth rejected: actor token revoked",
|
|
@@ -168,7 +168,7 @@ async function checkLiveVoiceAuth(
|
|
|
168
168
|
return { response: new Response("Unauthorized", { status: 401 }) };
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
if (!admitActorToken(rawToken, result.claims)) {
|
|
171
|
+
if (!admitActorToken(rawToken, result.claims).admitted) {
|
|
172
172
|
log.warn("Live voice WS: rejected, actor token revoked");
|
|
173
173
|
return { response: new Response("Unauthorized", { status: 401 }) };
|
|
174
174
|
}
|
|
@@ -122,7 +122,7 @@ export function authorizeRuntimeAudioStream(
|
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
if (!admitActorToken(rawToken, result.claims)) {
|
|
125
|
+
if (!admitActorToken(rawToken, result.claims).admitted) {
|
|
126
126
|
log.warn("audio stream WS: rejected, actor token revoked");
|
|
127
127
|
return {
|
|
128
128
|
ok: false,
|
|
@@ -70,7 +70,7 @@ function presentedProxyGrant(authHeader: string | null): TokenClaims | null {
|
|
|
70
70
|
if (!result.ok || result.claims.scope_profile !== "oauth_proxy_v1") {
|
|
71
71
|
return null;
|
|
72
72
|
}
|
|
73
|
-
if (!admitActorToken(token, result.claims)) {
|
|
73
|
+
if (!admitActorToken(token, result.claims).admitted) {
|
|
74
74
|
return null;
|
|
75
75
|
}
|
|
76
76
|
return result.claims;
|
|
@@ -149,7 +149,7 @@ export function createRuntimeProxyHandler(config: GatewayConfig) {
|
|
|
149
149
|
);
|
|
150
150
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
151
151
|
}
|
|
152
|
-
if (!admitActorToken(edgeJwt, result.claims)) {
|
|
152
|
+
if (!admitActorToken(edgeJwt, result.claims).admitted) {
|
|
153
153
|
log.warn(
|
|
154
154
|
{ method: req.method, path: url.pathname },
|
|
155
155
|
"Runtime proxy auth rejected: actor token revoked",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import type { OutgoingHttpHeaders } from "node:http";
|
|
24
24
|
|
|
25
|
-
import {
|
|
25
|
+
import { tunnelHostForCloudHost } from "@mycadet/service-contracts/ingress";
|
|
26
26
|
|
|
27
27
|
import { resolveScopeProfile } from "../../auth/scopes.js";
|
|
28
28
|
import { validateEdgeToken } from "../../auth/token-exchange.js";
|
|
@@ -163,7 +163,7 @@ function jsonError(status: number, code: string, detail: string): Response {
|
|
|
163
163
|
* shared with the web live-voice client; this wrapper adds the scheme.
|
|
164
164
|
*/
|
|
165
165
|
export function tunnelOriginForPlatformHost(host: string): string | null {
|
|
166
|
-
const tunnelHost =
|
|
166
|
+
const tunnelHost = tunnelHostForCloudHost(host);
|
|
167
167
|
return tunnelHost ? `https://${tunnelHost}` : null;
|
|
168
168
|
}
|
|
169
169
|
|
|
@@ -394,7 +394,7 @@ describe("resolvePublicBaseWssUrl", () => {
|
|
|
394
394
|
test("uses tunnelBaseUrl + cloudAssistantId when both are present", () => {
|
|
395
395
|
const config = {
|
|
396
396
|
...baseConfig,
|
|
397
|
-
tunnelBaseUrl: "https://tunnel
|
|
397
|
+
tunnelBaseUrl: "https://dev-tunnel.mycadet.ai",
|
|
398
398
|
};
|
|
399
399
|
const result = resolvePublicBaseWssUrl(
|
|
400
400
|
config,
|
|
@@ -402,7 +402,7 @@ describe("resolvePublicBaseWssUrl", () => {
|
|
|
402
402
|
"abc12345-0000-0000-0000-000000000000",
|
|
403
403
|
);
|
|
404
404
|
expect(result).toBe(
|
|
405
|
-
"wss://tunnel
|
|
405
|
+
"wss://dev-tunnel.mycadet.ai/abc12345-0000-0000-0000-000000000000",
|
|
406
406
|
);
|
|
407
407
|
});
|
|
408
408
|
|
|
@@ -430,24 +430,24 @@ describe("resolvePublicBaseWssUrl", () => {
|
|
|
430
430
|
test("falls back to configFile publicBaseUrl when cloudAssistantId is missing", () => {
|
|
431
431
|
const config = {
|
|
432
432
|
...baseConfig,
|
|
433
|
-
tunnelBaseUrl: "https://tunnel
|
|
433
|
+
tunnelBaseUrl: "https://dev-tunnel.mycadet.ai",
|
|
434
434
|
};
|
|
435
435
|
const mockConfigFile = {
|
|
436
436
|
getString: (section: string, key: string) =>
|
|
437
437
|
section === "ingress" && key === "publicBaseUrl"
|
|
438
|
-
? "https://tunnel
|
|
438
|
+
? "https://dev-tunnel.mycadet.ai/abc12345-0000-0000-0000-000000000000"
|
|
439
439
|
: undefined,
|
|
440
440
|
} as Parameters<typeof resolvePublicBaseWssUrl>[1];
|
|
441
441
|
const result = resolvePublicBaseWssUrl(config, mockConfigFile, undefined);
|
|
442
442
|
expect(result).toBe(
|
|
443
|
-
"wss://tunnel
|
|
443
|
+
"wss://dev-tunnel.mycadet.ai/abc12345-0000-0000-0000-000000000000",
|
|
444
444
|
);
|
|
445
445
|
});
|
|
446
446
|
|
|
447
447
|
test("uses assistant ID from validated platform callback URL with Tunnel", () => {
|
|
448
448
|
const config = {
|
|
449
449
|
...baseConfig,
|
|
450
|
-
tunnelBaseUrl: "https://tunnel
|
|
450
|
+
tunnelBaseUrl: "https://staging-tunnel.mycadet.ai",
|
|
451
451
|
};
|
|
452
452
|
const result = resolvePublicBaseWssUrl(
|
|
453
453
|
config,
|
|
@@ -456,14 +456,14 @@ describe("resolvePublicBaseWssUrl", () => {
|
|
|
456
456
|
"https://staging-cloud.mycadet.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e/webhooks/twilio/voice/?callSessionId=37b47ade-2eaf-469a-bede-6f2454875e6e",
|
|
457
457
|
);
|
|
458
458
|
expect(result).toBe(
|
|
459
|
-
"wss://tunnel
|
|
459
|
+
"wss://staging-tunnel.mycadet.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
|
|
460
460
|
);
|
|
461
461
|
});
|
|
462
462
|
|
|
463
463
|
test("prefers validated platform callback URL over stale configFile publicBaseUrl", () => {
|
|
464
464
|
const config = {
|
|
465
465
|
...baseConfig,
|
|
466
|
-
tunnelBaseUrl: "https://tunnel
|
|
466
|
+
tunnelBaseUrl: "https://staging-tunnel.mycadet.ai",
|
|
467
467
|
};
|
|
468
468
|
const mockConfigFile = {
|
|
469
469
|
getString: (section: string, key: string) =>
|
|
@@ -478,14 +478,14 @@ describe("resolvePublicBaseWssUrl", () => {
|
|
|
478
478
|
"https://staging-cloud.mycadet.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e/webhooks/twilio/voice?callSessionId=sess-1",
|
|
479
479
|
);
|
|
480
480
|
expect(result).toBe(
|
|
481
|
-
"wss://tunnel
|
|
481
|
+
"wss://staging-tunnel.mycadet.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
|
|
482
482
|
);
|
|
483
483
|
});
|
|
484
484
|
|
|
485
485
|
test("does not use a cloud callback URL as the websocket base", () => {
|
|
486
486
|
const config = {
|
|
487
487
|
...baseConfig,
|
|
488
|
-
tunnelBaseUrl: "https://tunnel
|
|
488
|
+
tunnelBaseUrl: "https://staging-tunnel.mycadet.ai",
|
|
489
489
|
};
|
|
490
490
|
const mockConfigFile = {
|
|
491
491
|
getString: (section: string, key: string) =>
|
|
@@ -495,14 +495,14 @@ describe("resolvePublicBaseWssUrl", () => {
|
|
|
495
495
|
} as Parameters<typeof resolvePublicBaseWssUrl>[1];
|
|
496
496
|
const result = resolvePublicBaseWssUrl(config, mockConfigFile, undefined);
|
|
497
497
|
expect(result).toBe(
|
|
498
|
-
"wss://tunnel
|
|
498
|
+
"wss://staging-tunnel.mycadet.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
|
|
499
499
|
);
|
|
500
500
|
});
|
|
501
501
|
|
|
502
502
|
test("strips trailing slash from tunnelBaseUrl before joining assistant ID", () => {
|
|
503
503
|
const config = {
|
|
504
504
|
...baseConfig,
|
|
505
|
-
tunnelBaseUrl: "https://tunnel
|
|
505
|
+
tunnelBaseUrl: "https://dev-tunnel.mycadet.ai/",
|
|
506
506
|
};
|
|
507
507
|
const result = resolvePublicBaseWssUrl(
|
|
508
508
|
config,
|
|
@@ -510,7 +510,7 @@ describe("resolvePublicBaseWssUrl", () => {
|
|
|
510
510
|
"abc12345-0000-0000-0000-000000000000",
|
|
511
511
|
);
|
|
512
512
|
expect(result).toBe(
|
|
513
|
-
"wss://tunnel
|
|
513
|
+
"wss://dev-tunnel.mycadet.ai/abc12345-0000-0000-0000-000000000000",
|
|
514
514
|
);
|
|
515
515
|
});
|
|
516
516
|
});
|
package/src/index.ts
CHANGED
|
@@ -17,12 +17,10 @@ import {
|
|
|
17
17
|
} from "./auth/token-service.js";
|
|
18
18
|
import { validateEdgeToken, mintServiceToken } from "./auth/token-exchange.js";
|
|
19
19
|
import { findGuardianForChannelActor } from "./auth/guardian-bootstrap.js";
|
|
20
|
-
import { AuthFallbackReporter } from "./auth-fallback-reporter.js";
|
|
21
|
-
import { loopbackFallbackCountTracker } from "./http/middleware/auth.js";
|
|
22
20
|
import { ConfigFileCache } from "./config-file-cache.js";
|
|
23
21
|
import { ConfigFileWatcher } from "./config-file-watcher.js";
|
|
24
22
|
import { FeatureFlagWatcher } from "./feature-flag-watcher.js";
|
|
25
|
-
|
|
23
|
+
|
|
26
24
|
import { loadConfig } from "./config.js";
|
|
27
25
|
import { CredentialCache } from "./credential-cache.js";
|
|
28
26
|
import { credentialKey } from "./credential-key.js";
|
|
@@ -104,6 +102,7 @@ import {
|
|
|
104
102
|
handleListDevices,
|
|
105
103
|
handleRevokeDevice,
|
|
106
104
|
} from "./http/routes/devices.js";
|
|
105
|
+
import { handleGetMyDevice } from "./http/routes/devices-mine.js";
|
|
107
106
|
import { handleSaveIngressDomain } from "./http/routes/ingress-domains.js";
|
|
108
107
|
import { handlePair } from "./http/routes/pair.js";
|
|
109
108
|
import { handleCredentialEntryPage } from "./http/routes/credential-entry-page.js";
|
|
@@ -129,20 +128,7 @@ import { createPsHandler } from "./http/routes/ps.js";
|
|
|
129
128
|
import { createTunnelStatusHandler } from "./http/routes/tunnel-status.js";
|
|
130
129
|
import { createRuntimeHealthProxyHandler } from "./http/routes/runtime-health-proxy.js";
|
|
131
130
|
import { createUpgradeBroadcastProxyHandler } from "./http/routes/upgrade-broadcast-proxy.js";
|
|
132
|
-
import {
|
|
133
|
-
createMigrationExportProxyHandler,
|
|
134
|
-
createMigrationExportToGcsProxyHandler,
|
|
135
|
-
createMigrationImportFromGcsProxyHandler,
|
|
136
|
-
createMigrationImportProxyHandler,
|
|
137
|
-
createMigrationImportStatusProxyHandler,
|
|
138
|
-
createMigrationJobStatusProxyHandler,
|
|
139
|
-
} from "./http/routes/migration-proxy.js";
|
|
140
131
|
import { createMigrationRollbackProxyHandler } from "./http/routes/migration-rollback-proxy.js";
|
|
141
|
-
import {
|
|
142
|
-
createListBackupsHandler,
|
|
143
|
-
createBackupSnapshotHandler,
|
|
144
|
-
} from "./backup/backup-routes.js";
|
|
145
|
-
import { startBackupWorker } from "./backup/backup-worker.js";
|
|
146
132
|
import { createWorkspaceCommitProxyHandler } from "./http/routes/workspace-commit-proxy.js";
|
|
147
133
|
import { createDesktopSetupProxyHandler } from "./http/routes/desktop-setup-proxy.js";
|
|
148
134
|
import { createBrainGraphProxyHandler } from "./http/routes/brain-graph-proxy.js";
|
|
@@ -256,7 +242,6 @@ import { admissionPolicyRoutes } from "./ipc/admission-policy-handlers.js";
|
|
|
256
242
|
import { channelPermissionRoutes } from "./ipc/channel-permission-handlers.js";
|
|
257
243
|
import { trustVerdictRoutes } from "./ipc/trust-verdict-handlers.js";
|
|
258
244
|
import { guardianDeliveryRoutes } from "./ipc/guardian-delivery-handlers.js";
|
|
259
|
-
import { createDebugExportRoutes } from "./ipc/debug-export-handlers.js";
|
|
260
245
|
import { createLogTailRoutes } from "./ipc/log-tail-handlers.js";
|
|
261
246
|
import { createChannelSocketHealthRoutes } from "./ipc/channel-socket-health-handlers.js";
|
|
262
247
|
import { createCredentialRequestIpcRoutes } from "./ipc/credential-request-handlers.js";
|
|
@@ -675,15 +660,6 @@ async function main() {
|
|
|
675
660
|
const tunnelStatusHandler = createTunnelStatusHandler(tunnelClient);
|
|
676
661
|
const runtimeHealthProxy = createRuntimeHealthProxyHandler(config);
|
|
677
662
|
const upgradeBroadcastProxy = createUpgradeBroadcastProxyHandler(config);
|
|
678
|
-
const migrationExportProxy = createMigrationExportProxyHandler(config);
|
|
679
|
-
const migrationImportProxy = createMigrationImportProxyHandler(config);
|
|
680
|
-
const migrationImportStatusProxy =
|
|
681
|
-
createMigrationImportStatusProxyHandler(config);
|
|
682
|
-
const migrationExportToGcsProxy =
|
|
683
|
-
createMigrationExportToGcsProxyHandler(config);
|
|
684
|
-
const migrationImportFromGcsProxy =
|
|
685
|
-
createMigrationImportFromGcsProxyHandler(config);
|
|
686
|
-
const migrationJobStatusProxy = createMigrationJobStatusProxyHandler(config);
|
|
687
663
|
const migrationRollbackProxy = createMigrationRollbackProxyHandler(config);
|
|
688
664
|
const workspaceCommitProxy = createWorkspaceCommitProxyHandler(config);
|
|
689
665
|
const desktopSetupProxy = createDesktopSetupProxyHandler(config);
|
|
@@ -743,12 +719,6 @@ async function main() {
|
|
|
743
719
|
|
|
744
720
|
const audioProxy = createAudioProxyHandler(config);
|
|
745
721
|
|
|
746
|
-
const backupDeps = {
|
|
747
|
-
assistantRuntimeBaseUrl: config.assistantRuntimeBaseUrl,
|
|
748
|
-
};
|
|
749
|
-
const handleListBackups = createListBackupsHandler(backupDeps);
|
|
750
|
-
const handleCreateBackup = createBackupSnapshotHandler(backupDeps);
|
|
751
|
-
|
|
752
722
|
const handleRuntimeProxy = createRuntimeProxyHandler(config);
|
|
753
723
|
|
|
754
724
|
// Helper to reject when an integration isn't configured
|
|
@@ -1100,6 +1070,14 @@ async function main() {
|
|
|
1100
1070
|
handleRevokeDevice(req, getClientIp()),
|
|
1101
1071
|
},
|
|
1102
1072
|
|
|
1073
|
+
// ── The caller's own paired-device row (bearer-authed, browser-legit) ──
|
|
1074
|
+
{
|
|
1075
|
+
path: "/v1/devices/mine",
|
|
1076
|
+
method: "GET",
|
|
1077
|
+
auth: "edge",
|
|
1078
|
+
handler: (req) => handleGetMyDevice(req),
|
|
1079
|
+
},
|
|
1080
|
+
|
|
1103
1081
|
// ── Channel verification sessions ──
|
|
1104
1082
|
{
|
|
1105
1083
|
// Bootstrap endpoint — may be replaced with an SSH-based exchange in the
|
|
@@ -1324,73 +1302,6 @@ async function main() {
|
|
|
1324
1302
|
handler: (req) => upgradeBroadcastProxy(req),
|
|
1325
1303
|
},
|
|
1326
1304
|
|
|
1327
|
-
// ── Migration export/import ──
|
|
1328
|
-
{
|
|
1329
|
-
path: "/v1/migrations/export",
|
|
1330
|
-
method: "POST",
|
|
1331
|
-
auth: "edge-scoped",
|
|
1332
|
-
scope: "settings.write",
|
|
1333
|
-
handler: (req) => migrationExportProxy(req),
|
|
1334
|
-
},
|
|
1335
|
-
{
|
|
1336
|
-
path: "/v1/migrations/import",
|
|
1337
|
-
method: "POST",
|
|
1338
|
-
auth: "edge-scoped",
|
|
1339
|
-
scope: "settings.write",
|
|
1340
|
-
handler: (req) => migrationImportProxy(req),
|
|
1341
|
-
},
|
|
1342
|
-
{
|
|
1343
|
-
// Async-job status endpoint for URL-based imports. The gateway keeps
|
|
1344
|
-
// an in-memory job map keyed by the jobId it handed back in the
|
|
1345
|
-
// 202 response; this lets callers poll for progress without holding
|
|
1346
|
-
// an HTTP connection open for the full import duration.
|
|
1347
|
-
//
|
|
1348
|
-
// Trailing slash is optional to preserve compatibility with existing
|
|
1349
|
-
// pollers. PlatformMigrationClient.pollImportStatus (macOS) and
|
|
1350
|
-
// cli/src/lib/cloud-client.ts both hit `.../status/` against the
|
|
1351
|
-
// platform API today; other callers may follow the bare-path
|
|
1352
|
-
// convention (`.../status`). Regex routes in the gateway router are
|
|
1353
|
-
// NOT trailing-slash-normalized, so the optionality is encoded here.
|
|
1354
|
-
path: /^\/v1\/migrations\/import\/([^/]+)\/status\/?$/,
|
|
1355
|
-
method: "GET",
|
|
1356
|
-
auth: "edge-scoped",
|
|
1357
|
-
// Read-only polling endpoint — read scope, not write. Matches the
|
|
1358
|
-
// convention used for other GET endpoints in this router (OAuth
|
|
1359
|
-
// providers GET, OAuth apps GET) so a token
|
|
1360
|
-
// profile with `settings.read` only (e.g. the `ui_page_v1`
|
|
1361
|
-
// profile) can still poll import progress.
|
|
1362
|
-
scope: "settings.read",
|
|
1363
|
-
handler: (req, params) =>
|
|
1364
|
-
migrationImportStatusProxy(req, params[0] ?? ""),
|
|
1365
|
-
},
|
|
1366
|
-
|
|
1367
|
-
// ── Teleport-GCS migration (unified daemon-async flow) ──
|
|
1368
|
-
// Registered as explicit routes (not via the runtime-proxy catch-all)
|
|
1369
|
-
// for dedicated auth and timeout handling. The daemon returns 202
|
|
1370
|
-
// { job_id } on POST and cheap JSON on GET, so the gateway just
|
|
1371
|
-
// transparently forwards without wrapping.
|
|
1372
|
-
{
|
|
1373
|
-
path: "/v1/migrations/export-to-gcs",
|
|
1374
|
-
method: "POST",
|
|
1375
|
-
auth: "edge-scoped",
|
|
1376
|
-
scope: "settings.write",
|
|
1377
|
-
handler: (req) => migrationExportToGcsProxy(req),
|
|
1378
|
-
},
|
|
1379
|
-
{
|
|
1380
|
-
path: "/v1/migrations/import-from-gcs",
|
|
1381
|
-
method: "POST",
|
|
1382
|
-
auth: "edge-scoped",
|
|
1383
|
-
scope: "settings.write",
|
|
1384
|
-
handler: (req) => migrationImportFromGcsProxy(req),
|
|
1385
|
-
},
|
|
1386
|
-
{
|
|
1387
|
-
path: /^\/v1\/migrations\/jobs\/([^/]+)\/?$/,
|
|
1388
|
-
method: "GET",
|
|
1389
|
-
auth: "edge-scoped",
|
|
1390
|
-
scope: "settings.read",
|
|
1391
|
-
handler: (req, params) => migrationJobStatusProxy(req, params[0] ?? ""),
|
|
1392
|
-
},
|
|
1393
|
-
|
|
1394
1305
|
// ── Workspace commit ──
|
|
1395
1306
|
{
|
|
1396
1307
|
path: "/v1/admin/workspace-commit",
|
|
@@ -1409,45 +1320,6 @@ async function main() {
|
|
|
1409
1320
|
handler: (req) => migrationRollbackProxy(req),
|
|
1410
1321
|
},
|
|
1411
1322
|
|
|
1412
|
-
// ── Backups ──
|
|
1413
|
-
{
|
|
1414
|
-
path: "/v1/backups",
|
|
1415
|
-
method: "GET",
|
|
1416
|
-
auth: "edge-scoped",
|
|
1417
|
-
scope: "settings.read",
|
|
1418
|
-
handler: (req) => handleListBackups(req),
|
|
1419
|
-
},
|
|
1420
|
-
{
|
|
1421
|
-
path: "/v1/backups/create",
|
|
1422
|
-
method: "POST",
|
|
1423
|
-
auth: "edge-scoped",
|
|
1424
|
-
scope: "settings.write",
|
|
1425
|
-
handler: (req) => handleCreateBackup(req),
|
|
1426
|
-
},
|
|
1427
|
-
|
|
1428
|
-
// ── Backups — assistant-scoped variants ──
|
|
1429
|
-
// Mirror the flat /v1/backups routes for clients that use the daemon
|
|
1430
|
-
// SDK's assistant-scoped URLs (/v1/assistants/<id>/backups/...).
|
|
1431
|
-
// Without these, the request falls through to the runtime-proxy
|
|
1432
|
-
// catch-all and the daemon rejects create ("moved to gateway").
|
|
1433
|
-
// Backups are gateway-global, so the assistant id is matched and
|
|
1434
|
-
// discarded. Same precedent as the trust-rules assistant-scoped
|
|
1435
|
-
// variants below.
|
|
1436
|
-
{
|
|
1437
|
-
path: /^\/v1\/assistants\/[^/]+\/backups\/?$/,
|
|
1438
|
-
method: "GET",
|
|
1439
|
-
auth: "edge-scoped",
|
|
1440
|
-
scope: "settings.read",
|
|
1441
|
-
handler: (req) => handleListBackups(req),
|
|
1442
|
-
},
|
|
1443
|
-
{
|
|
1444
|
-
path: /^\/v1\/assistants\/[^/]+\/backups\/create\/?$/,
|
|
1445
|
-
method: "POST",
|
|
1446
|
-
auth: "edge-scoped",
|
|
1447
|
-
scope: "settings.write",
|
|
1448
|
-
handler: (req) => handleCreateBackup(req),
|
|
1449
|
-
},
|
|
1450
|
-
|
|
1451
1323
|
// ── Platform push / Live Activity tokens ──
|
|
1452
1324
|
// Django-owned registration. Remote-gateway clients hit these
|
|
1453
1325
|
// same-origin; without a dedicated route they fall through to the
|
|
@@ -2022,7 +1894,7 @@ async function main() {
|
|
|
2022
1894
|
port: config.port,
|
|
2023
1895
|
idleTimeout: 0,
|
|
2024
1896
|
// Match the daemon's 512 MB limit (assistant/src/runtime/http-server.ts)
|
|
2025
|
-
//
|
|
1897
|
+
// to accommodate large app package uploads.
|
|
2026
1898
|
maxRequestBodySize: 512 * 1024 * 1024,
|
|
2027
1899
|
websocket: {
|
|
2028
1900
|
open(ws) {
|
|
@@ -2851,14 +2723,6 @@ async function main() {
|
|
|
2851
2723
|
log.info("Discord Gateway client started");
|
|
2852
2724
|
}
|
|
2853
2725
|
|
|
2854
|
-
// Lazily bound below, once `remoteFeatureFlagSync` is constructed, so the
|
|
2855
|
-
// credential-change callback can trigger an immediate per-assistant flag
|
|
2856
|
-
// re-sync. On a warm-pool claim the `cadet` credentials change to the
|
|
2857
|
-
// newly-assigned identity; without forcing a re-sync, flag values would not
|
|
2858
|
-
// refresh until the next scheduled poll (up to 5 min), so the onboarding
|
|
2859
|
-
// first message evaluates flags against stale values (see JARVIS-1018).
|
|
2860
|
-
let remoteFeatureFlagSyncRef: RemoteFeatureFlagSync | null = null;
|
|
2861
|
-
|
|
2862
2726
|
/**
|
|
2863
2727
|
* Fingerprint of the Telegram bot token the dedup cache's watermark belongs
|
|
2864
2728
|
* to. Null while no token is stored, which is itself a bot change: the next
|
|
@@ -2981,21 +2845,6 @@ async function main() {
|
|
|
2981
2845
|
);
|
|
2982
2846
|
});
|
|
2983
2847
|
}
|
|
2984
|
-
|
|
2985
|
-
// Force an immediate per-assistant feature-flag re-sync. A `cadet`
|
|
2986
|
-
// credential change means a warm-pool claim / key rotation / late
|
|
2987
|
-
// provisioning — the assistant identity the cloud evaluates flags
|
|
2988
|
-
// for just changed. Without this, flags stay at their previous (often
|
|
2989
|
-
// registry-default) values until the next ~5-min poll, which the
|
|
2990
|
-
// onboarding auto-greet beats, so first-message flags read stale
|
|
2991
|
-
// (JARVIS-1018). The ref is null only during the initial credential
|
|
2992
|
-
// poll at startup, which `start()` already covers.
|
|
2993
|
-
remoteFeatureFlagSyncRef?.syncNow().catch((err) => {
|
|
2994
|
-
log.error(
|
|
2995
|
-
{ err },
|
|
2996
|
-
"Failed to sync remote feature flags after cadet credential change",
|
|
2997
|
-
);
|
|
2998
|
-
});
|
|
2999
2848
|
}
|
|
3000
2849
|
});
|
|
3001
2850
|
|
|
@@ -3102,7 +2951,6 @@ async function main() {
|
|
|
3102
2951
|
...guardianDeliveryRoutes,
|
|
3103
2952
|
...riskClassificationRoutes,
|
|
3104
2953
|
...createLogTailRoutes(config),
|
|
3105
|
-
...createDebugExportRoutes(config),
|
|
3106
2954
|
...createChannelSocketHealthRoutes({
|
|
3107
2955
|
slack: () => slackSocketClient,
|
|
3108
2956
|
discord: () => discordGatewayClient,
|
|
@@ -3120,11 +2968,6 @@ async function main() {
|
|
|
3120
2968
|
|
|
3121
2969
|
void refreshRouteSchema();
|
|
3122
2970
|
|
|
3123
|
-
// ── Backup worker ──
|
|
3124
|
-
const backupWorkerHandle = startBackupWorker({
|
|
3125
|
-
assistantRuntimeBaseUrl: config.assistantRuntimeBaseUrl,
|
|
3126
|
-
});
|
|
3127
|
-
|
|
3128
2971
|
let tunnelWebhooksEnabled = isFeatureFlagEnabled("tunnel-webhooks");
|
|
3129
2972
|
emitFlagChanged = () => {
|
|
3130
2973
|
ipcServer.emit("feature_flags_changed");
|
|
@@ -3153,26 +2996,6 @@ async function main() {
|
|
|
3153
2996
|
});
|
|
3154
2997
|
featureFlagWatcher.start();
|
|
3155
2998
|
|
|
3156
|
-
const remoteFeatureFlagSync = new RemoteFeatureFlagSync({
|
|
3157
|
-
credentials: credentialCache,
|
|
3158
|
-
onChanged: emitFlagChanged,
|
|
3159
|
-
});
|
|
3160
|
-
// Bind the ref so the credential-change callback above can force an
|
|
3161
|
-
// immediate re-sync when the assistant's `cadet` identity changes
|
|
3162
|
-
// (warm-pool claim / key rotation) instead of waiting for the next poll.
|
|
3163
|
-
remoteFeatureFlagSyncRef = remoteFeatureFlagSync;
|
|
3164
|
-
// Intentionally fire-and-forget: remote flag fetch is best-effort;
|
|
3165
|
-
// the gateway continues with registry defaults if it fails.
|
|
3166
|
-
void remoteFeatureFlagSync.start();
|
|
3167
|
-
|
|
3168
|
-
// Periodically ship legacy-loopback auth-fallback counts to the daemon
|
|
3169
|
-
// telemetry route. Best-effort background work; never blocks auth.
|
|
3170
|
-
const authFallbackReporter = new AuthFallbackReporter({
|
|
3171
|
-
tracker: loopbackFallbackCountTracker,
|
|
3172
|
-
baseUrl: config.assistantRuntimeBaseUrl,
|
|
3173
|
-
});
|
|
3174
|
-
authFallbackReporter.start();
|
|
3175
|
-
|
|
3176
2999
|
// ── Sleep/wake detection ──
|
|
3177
3000
|
// Detect system sleep/wake transitions and force-reconnect channels
|
|
3178
3001
|
// that may have stale connections after the OS suspended the process.
|
|
@@ -3186,12 +3009,6 @@ async function main() {
|
|
|
3186
3009
|
configFileCache.invalidate();
|
|
3187
3010
|
credentialCache.invalidate();
|
|
3188
3011
|
|
|
3189
|
-
// Immediately refresh remote feature flags so the gateway doesn't run
|
|
3190
|
-
// with stale values until the next scheduled poll (up to 5 min away).
|
|
3191
|
-
remoteFeatureFlagSync.syncNow().catch((err) => {
|
|
3192
|
-
log.error({ err }, "Failed to sync remote feature flags after wake");
|
|
3193
|
-
});
|
|
3194
|
-
|
|
3195
3012
|
// Re-register Telegram webhook with current ingress URL
|
|
3196
3013
|
if (telegramReady) {
|
|
3197
3014
|
reconcileTelegramWebhook(telegramCaches).catch((err) => {
|
|
@@ -3206,13 +3023,9 @@ async function main() {
|
|
|
3206
3023
|
draining = true;
|
|
3207
3024
|
const shutdownTasks: Promise<void>[] = [];
|
|
3208
3025
|
sleepWakeDetector.stop();
|
|
3209
|
-
backupWorkerHandle.stop();
|
|
3210
3026
|
credentialWatcher.stop();
|
|
3211
3027
|
configFileWatcher.stop();
|
|
3212
3028
|
featureFlagWatcher.stop();
|
|
3213
|
-
remoteFeatureFlagSync.stop();
|
|
3214
|
-
// Stop the timer and flush any buffered auth-fallback counts before exit.
|
|
3215
|
-
shutdownTasks.push(authFallbackReporter.stop());
|
|
3216
3029
|
const tunnelStop = tunnelClient?.stop();
|
|
3217
3030
|
if (tunnelStop) shutdownTasks.push(tunnelStop);
|
|
3218
3031
|
ipcServer.stop();
|