@vellumai/cli 0.11.2 → 0.11.3-staging.2
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/knip.json +1 -0
- package/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
- package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
- package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
- package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
- package/node_modules/@vellumai/local-mode/package.json +1 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
- package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
- package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
- package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
- package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
- package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
- package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
- package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
- package/package.json +3 -1
- package/src/__tests__/client-token.test.ts +26 -1
- package/src/__tests__/guardian-token.test.ts +4 -0
- package/src/__tests__/nginx-ingress-command.test.ts +176 -16
- package/src/__tests__/nginx-ingress.test.ts +1821 -101
- package/src/__tests__/pair.test.ts +104 -2
- package/src/__tests__/sleep.test.ts +7 -3
- package/src/__tests__/tailscale-tunnel.test.ts +0 -1
- package/src/__tests__/tunnel.test.ts +1099 -55
- package/src/__tests__/wake.test.ts +166 -99
- package/src/commands/client.ts +182 -27
- package/src/commands/connect/import.ts +35 -156
- package/src/commands/nginx-ingress.ts +87 -110
- package/src/commands/pair.ts +84 -21
- package/src/commands/tunnel.ts +196 -85
- package/src/commands/upgrade.ts +3 -2
- package/src/commands/wake.ts +5 -163
- package/src/index.ts +1 -1
- package/src/lib/__tests__/local-ces.test.ts +10 -1
- package/src/lib/cloudflare-tunnel.ts +3 -16
- package/src/lib/environments/__tests__/paths.test.ts +20 -1
- package/src/lib/environments/paths.ts +29 -41
- package/src/lib/environments/resolve.ts +7 -5
- package/src/lib/guardian-token.ts +22 -62
- package/src/lib/ingress-config.ts +25 -0
- package/src/lib/local.ts +77 -20
- package/src/lib/nginx-ingress.ts +523 -76
- package/src/lib/ngrok.ts +154 -61
- package/src/lib/orphan-detection.test.ts +167 -0
- package/src/lib/orphan-detection.ts +40 -0
- package/src/lib/pair.test.ts +87 -0
- package/src/lib/pair.ts +55 -0
- package/src/lib/platform-client.ts +8 -5
- package/src/lib/tailscale-tunnel.ts +7 -18
- package/src/lib/tunnel-edge.ts +114 -0
- package/src/lib/xdg-log.ts +2 -2
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* hatch/retire/wake lifecycle ops) over a loopback HTTP boundary. Consumed by the
|
|
5
5
|
* CLI `client` server and the web app's dev-server middleware so the local
|
|
6
6
|
* endpoint behaviour is defined exactly once instead of one host reaching into
|
|
7
|
-
* another's source tree.
|
|
7
|
+
* another's source tree. `@vellumai/environments` is its only workspace
|
|
8
|
+
* dependency.
|
|
8
9
|
*/
|
|
9
10
|
export {
|
|
10
11
|
stripSensitiveFields,
|
|
11
12
|
isLoopbackAddr,
|
|
12
13
|
headerHostIsLoopback,
|
|
13
14
|
originIsAllowed,
|
|
15
|
+
hasSameOriginCredentialProof,
|
|
14
16
|
resolveDevCliInvocation,
|
|
15
17
|
} from "./util";
|
|
16
18
|
export type { CliInvocation } from "./util";
|
|
@@ -18,19 +20,28 @@ export {
|
|
|
18
20
|
resolveLocalConfigFromEnv,
|
|
19
21
|
resolveLockfilePaths,
|
|
20
22
|
resolveConfigDir,
|
|
23
|
+
resolveConfigDirPaths,
|
|
24
|
+
resolveRuntimeDir,
|
|
25
|
+
resolveLogDir,
|
|
26
|
+
resolveAssistantsDir,
|
|
27
|
+
resolveInstanceDir,
|
|
21
28
|
guardianTokenPath,
|
|
22
29
|
} from "./config";
|
|
23
30
|
export type { LocalEndpointConfig } from "./config";
|
|
31
|
+
export type { LocalPathOptions } from "./paths";
|
|
24
32
|
export {
|
|
25
33
|
defaultEnvironmentFilePath,
|
|
34
|
+
defaultEnvironmentFilePaths,
|
|
26
35
|
readDefaultEnvironment,
|
|
27
36
|
resolveEnvironmentName,
|
|
28
37
|
} from "./environment";
|
|
29
38
|
export {
|
|
30
39
|
getLockfileData,
|
|
31
40
|
upsertLockfileAssistant,
|
|
41
|
+
upsertRendererLockfileAssistant,
|
|
32
42
|
replacePlatformAssistants,
|
|
33
43
|
isActiveAssistant,
|
|
44
|
+
isPairedLockfileEntry,
|
|
34
45
|
} from "./lockfile";
|
|
35
46
|
export type { LockfileResult, WriteResult } from "./lockfile";
|
|
36
47
|
export { parseLockfile } from "./lockfile-contract";
|
|
@@ -44,6 +55,8 @@ export { runHatch } from "./hatch";
|
|
|
44
55
|
export type { HatchResult } from "./hatch";
|
|
45
56
|
export { runRetire } from "./retire";
|
|
46
57
|
export type { RetireOptions, RetireResult } from "./retire";
|
|
58
|
+
export { unpairAssistant } from "./unpair";
|
|
59
|
+
export { decodePairBundle, pairAssistant, connectImport } from "./pair";
|
|
47
60
|
export { runSleep } from "./sleep";
|
|
48
61
|
export type { SleepResult } from "./sleep";
|
|
49
62
|
export { runWake } from "./wake";
|
|
@@ -55,15 +68,34 @@ export type {
|
|
|
55
68
|
LocalAssistantRuntimeState,
|
|
56
69
|
LocalAssistantStatusResult,
|
|
57
70
|
} from "./status";
|
|
58
|
-
export { getGuardianAccessToken } from "./guardian-token";
|
|
59
|
-
export type { TokenResult } from "./guardian-token";
|
|
60
71
|
export {
|
|
72
|
+
getGuardianAccessToken,
|
|
73
|
+
getPairedGuardianAccessToken,
|
|
74
|
+
isConfidentialRefreshUrl,
|
|
75
|
+
PAIRED_GUARDIAN_TOKEN_HOST_ONLY_ERROR,
|
|
76
|
+
PAIRED_GUARDIAN_TARGET_MISMATCH_ERROR,
|
|
77
|
+
saveGuardianToken,
|
|
78
|
+
} from "./guardian-token";
|
|
79
|
+
export type {
|
|
80
|
+
TokenResult,
|
|
81
|
+
GuardianTokenData,
|
|
82
|
+
GuardianTokenOptions,
|
|
83
|
+
} from "./guardian-token";
|
|
84
|
+
export {
|
|
85
|
+
authorizePairedForwardHeaders,
|
|
61
86
|
parseGatewayUrl,
|
|
62
87
|
readAllowedGatewayPorts,
|
|
63
88
|
resolveGatewayProxyTarget,
|
|
89
|
+
parsePairedGatewayUrl,
|
|
90
|
+
pairedGatewayTargetsFromLockfile,
|
|
91
|
+
readPairedGatewayTargets,
|
|
92
|
+
resolvePairedGatewayProxyTarget,
|
|
93
|
+
sanitizePairedForwardHeaders,
|
|
64
94
|
} from "./gateway-proxy";
|
|
65
95
|
export type {
|
|
66
96
|
GatewayTarget,
|
|
67
97
|
GatewayParseResult,
|
|
68
98
|
GatewayProxyDecision,
|
|
99
|
+
PairedForwardAuthorizationResult,
|
|
100
|
+
PairedGuardianTokenProvider,
|
|
69
101
|
} from "./gateway-proxy";
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
KNOWN_CLOUDS,
|
|
5
|
+
pairedHostLabel,
|
|
5
6
|
parseLockfile,
|
|
6
7
|
resolveCloud,
|
|
7
8
|
SENSITIVE_KEYS,
|
|
@@ -338,6 +339,23 @@ describe("resolveCloud", () => {
|
|
|
338
339
|
});
|
|
339
340
|
});
|
|
340
341
|
|
|
342
|
+
describe("pairedHostLabel", () => {
|
|
343
|
+
test("names the remote host from a parseable runtimeUrl", () => {
|
|
344
|
+
expect(pairedHostLabel("https://tunnel.example.com")).toBe(
|
|
345
|
+
"Paired · tunnel.example.com",
|
|
346
|
+
);
|
|
347
|
+
expect(pairedHostLabel("https://remote-host.example:8443")).toBe(
|
|
348
|
+
"Paired · remote-host.example",
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("falls back to plain Paired for a missing or unparseable runtimeUrl", () => {
|
|
353
|
+
expect(pairedHostLabel(undefined)).toBe("Paired");
|
|
354
|
+
expect(pairedHostLabel("")).toBe("Paired");
|
|
355
|
+
expect(pairedHostLabel("not a url")).toBe("Paired");
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
341
359
|
describe("taxonomy", () => {
|
|
342
360
|
test("KNOWN_CLOUDS covers the documented topologies", () => {
|
|
343
361
|
expect(KNOWN_CLOUDS).toEqual([
|
|
@@ -76,6 +76,78 @@ export function resolveCloud(raw: {
|
|
|
76
76
|
return "local";
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Whether an entry's `runtimeUrl` is one a serving host can forward to: an
|
|
81
|
+
* absolute http(s) URL. Shared by the renderer's paired-gateway URL derivation
|
|
82
|
+
* and the host-side paired allowlist reader so the two can't drift.
|
|
83
|
+
*/
|
|
84
|
+
export function isUsableRuntimeUrl(url: unknown): boolean {
|
|
85
|
+
if (typeof url !== "string" || url === "") {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const parsed = new URL(url);
|
|
90
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* User-facing hosting label for a paired entry: "Paired · <hostname>" when
|
|
98
|
+
* `runtimeUrl` names the remote host, plain "Paired" otherwise. Shared by the
|
|
99
|
+
* macOS tray switcher and the web chooser so their renderings cannot drift.
|
|
100
|
+
*/
|
|
101
|
+
export function pairedHostLabel(runtimeUrl: string | undefined): string {
|
|
102
|
+
if (runtimeUrl) {
|
|
103
|
+
try {
|
|
104
|
+
return `Paired · ${new URL(runtimeUrl).hostname}`;
|
|
105
|
+
} catch {
|
|
106
|
+
// Unparseable runtimeUrl: plain "Paired".
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return "Paired";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Clouds whose gateway listens on a loopback port of this machine: the
|
|
114
|
+
* on-machine daemon ("local") and local Docker instances ("docker").
|
|
115
|
+
*/
|
|
116
|
+
export const LOOPBACK_GATEWAY_CLOUDS: readonly Cloud[] = ["local", "docker"];
|
|
117
|
+
|
|
118
|
+
export function isLoopbackGatewayCloud(cloud: Cloud): boolean {
|
|
119
|
+
return LOOPBACK_GATEWAY_CLOUDS.includes(cloud);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The loopback gateway port recorded for an assistant entry:
|
|
124
|
+
* `resources.gatewayPort` when present, else the port of a loopback
|
|
125
|
+
* `runtimeUrl` (how docker hatch records its published gateway). A
|
|
126
|
+
* non-loopback `runtimeUrl` yields nothing.
|
|
127
|
+
*/
|
|
128
|
+
export function getLoopbackGatewayPort(entry: {
|
|
129
|
+
resources?: { gatewayPort?: number };
|
|
130
|
+
runtimeUrl?: string;
|
|
131
|
+
}): number | undefined {
|
|
132
|
+
const recorded = entry.resources?.gatewayPort;
|
|
133
|
+
if (recorded != null) {
|
|
134
|
+
return recorded;
|
|
135
|
+
}
|
|
136
|
+
if (!entry.runtimeUrl) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const url = new URL(entry.runtimeUrl);
|
|
141
|
+
if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
const port = Number(url.port);
|
|
145
|
+
return Number.isInteger(port) && port > 0 ? port : undefined;
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
79
151
|
/**
|
|
80
152
|
* Per-instance resources for a local assistant: the renderer-facing subset of
|
|
81
153
|
* ports, the instance directory, and the local runtime install metadata.
|
|
@@ -5,8 +5,10 @@ import path from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
getLockfileData,
|
|
8
|
+
isPairedLockfileEntry,
|
|
8
9
|
replacePlatformAssistants,
|
|
9
10
|
upsertLockfileAssistant,
|
|
11
|
+
upsertRendererLockfileAssistant,
|
|
10
12
|
} from "./lockfile";
|
|
11
13
|
|
|
12
14
|
let dir: string;
|
|
@@ -112,6 +114,32 @@ describe("getLockfileData", () => {
|
|
|
112
114
|
});
|
|
113
115
|
});
|
|
114
116
|
|
|
117
|
+
// Hosts pass {paired} to getGuardianAccessToken so expired pairings get
|
|
118
|
+
// re-pair guidance instead of hatch/wake; this pins the classification.
|
|
119
|
+
describe("isPairedLockfileEntry", () => {
|
|
120
|
+
test("classifies paired cloud entries and host pairing markers as paired", () => {
|
|
121
|
+
writeOnDisk({
|
|
122
|
+
assistants: [
|
|
123
|
+
{ assistantId: "paired-1", cloud: "paired" },
|
|
124
|
+
{ assistantId: "paired-marker", cloud: "local", paired: true },
|
|
125
|
+
{ assistantId: "local-1", cloud: "local" },
|
|
126
|
+
],
|
|
127
|
+
activeAssistant: "local-1",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(isPairedLockfileEntry([lockfilePath], "paired-1")).toBe(true);
|
|
131
|
+
expect(isPairedLockfileEntry([lockfilePath], "paired-marker")).toBe(true);
|
|
132
|
+
expect(isPairedLockfileEntry([lockfilePath], "local-1")).toBe(false);
|
|
133
|
+
expect(isPairedLockfileEntry([lockfilePath], "missing")).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("an absent lockfile never classifies as paired", () => {
|
|
137
|
+
expect(
|
|
138
|
+
isPairedLockfileEntry([path.join(dir, "absent.json")], "paired-1"),
|
|
139
|
+
).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
115
143
|
describe("upsertLockfileAssistant", () => {
|
|
116
144
|
test("rejects an assistant with no id", () => {
|
|
117
145
|
const result = upsertLockfileAssistant(
|
|
@@ -223,7 +251,109 @@ describe("upsertLockfileAssistant", () => {
|
|
|
223
251
|
});
|
|
224
252
|
});
|
|
225
253
|
|
|
254
|
+
describe("upsertRendererLockfileAssistant", () => {
|
|
255
|
+
const paired = {
|
|
256
|
+
assistantId: "paired-1",
|
|
257
|
+
cloud: "paired",
|
|
258
|
+
paired: true,
|
|
259
|
+
runtimeUrl: "https://gateway.example.com",
|
|
260
|
+
name: "Paired assistant",
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
test("rejects creating or reclassifying an entry as paired", () => {
|
|
264
|
+
expect(
|
|
265
|
+
upsertRendererLockfileAssistant([lockfilePath], paired, "paired-1"),
|
|
266
|
+
).toMatchObject({ ok: false, status: 403 });
|
|
267
|
+
|
|
268
|
+
writeOnDisk({
|
|
269
|
+
assistants: [{ assistantId: "local-1", cloud: "local" }],
|
|
270
|
+
activeAssistant: "local-1",
|
|
271
|
+
});
|
|
272
|
+
expect(
|
|
273
|
+
upsertRendererLockfileAssistant(
|
|
274
|
+
[lockfilePath],
|
|
275
|
+
{
|
|
276
|
+
assistantId: "local-1",
|
|
277
|
+
cloud: "paired",
|
|
278
|
+
runtimeUrl: "https://gateway.example.com",
|
|
279
|
+
},
|
|
280
|
+
undefined,
|
|
281
|
+
),
|
|
282
|
+
).toMatchObject({ ok: false, status: 403 });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("rejects retargeting or reclassifying an existing paired entry", () => {
|
|
286
|
+
writeOnDisk({ assistants: [paired], activeAssistant: "paired-1" });
|
|
287
|
+
|
|
288
|
+
expect(
|
|
289
|
+
upsertRendererLockfileAssistant(
|
|
290
|
+
[lockfilePath],
|
|
291
|
+
{ assistantId: "paired-1", runtimeUrl: "https://attacker.example.com" },
|
|
292
|
+
undefined,
|
|
293
|
+
),
|
|
294
|
+
).toMatchObject({ ok: false, status: 403 });
|
|
295
|
+
expect(
|
|
296
|
+
upsertRendererLockfileAssistant(
|
|
297
|
+
[lockfilePath],
|
|
298
|
+
{ assistantId: "paired-1", cloud: "local" },
|
|
299
|
+
undefined,
|
|
300
|
+
),
|
|
301
|
+
).toMatchObject({ ok: false, status: 403 });
|
|
302
|
+
expect(readOnDisk()).toEqual({
|
|
303
|
+
assistants: [paired],
|
|
304
|
+
activeAssistant: "paired-1",
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("allows non-security updates and activation of an existing pairing", () => {
|
|
309
|
+
writeOnDisk({ assistants: [paired], activeAssistant: null });
|
|
310
|
+
|
|
311
|
+
const result = upsertRendererLockfileAssistant(
|
|
312
|
+
[lockfilePath],
|
|
313
|
+
{
|
|
314
|
+
assistantId: "paired-1",
|
|
315
|
+
cloud: "paired",
|
|
316
|
+
runtimeUrl: "https://gateway.example.com",
|
|
317
|
+
name: "Renamed pairing",
|
|
318
|
+
},
|
|
319
|
+
"paired-1",
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(result.ok).toBe(true);
|
|
323
|
+
expect(readOnDisk()).toEqual({
|
|
324
|
+
assistants: [{ ...paired, name: "Renamed pairing" }],
|
|
325
|
+
activeAssistant: "paired-1",
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
226
330
|
describe("replacePlatformAssistants", () => {
|
|
331
|
+
test("rejects a renderer sync that tries to create a paired entry", () => {
|
|
332
|
+
writeOnDisk({
|
|
333
|
+
activeAssistant: "asst_local",
|
|
334
|
+
assistants: [
|
|
335
|
+
{ assistantId: "asst_local", cloud: "local", runtimeUrl: "http://l" },
|
|
336
|
+
],
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(
|
|
340
|
+
replacePlatformAssistants([lockfilePath], [
|
|
341
|
+
{
|
|
342
|
+
assistantId: "asst_local",
|
|
343
|
+
cloud: "paired",
|
|
344
|
+
paired: true,
|
|
345
|
+
runtimeUrl: "https://attacker.example.com",
|
|
346
|
+
},
|
|
347
|
+
]),
|
|
348
|
+
).toMatchObject({ ok: false, status: 403 });
|
|
349
|
+
expect(readOnDisk()).toEqual({
|
|
350
|
+
activeAssistant: "asst_local",
|
|
351
|
+
assistants: [
|
|
352
|
+
{ assistantId: "asst_local", cloud: "local", runtimeUrl: "http://l" },
|
|
353
|
+
],
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
227
357
|
test("replaces platform assistants while keeping local ones and unknown fields", () => {
|
|
228
358
|
writeOnDisk({
|
|
229
359
|
schemaVersion: 99,
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
parseLockfile,
|
|
6
|
+
resolveCloud,
|
|
7
|
+
type Lockfile,
|
|
8
|
+
} from "./lockfile-contract";
|
|
5
9
|
import { stripSensitiveFields } from "./util";
|
|
6
10
|
|
|
7
11
|
export type LockfileResult =
|
|
@@ -35,10 +39,77 @@ export function getLockfileData(lockfilePaths: string[]): LockfileResult {
|
|
|
35
39
|
return { ok: true, data: parseLockfile(data) };
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Whether the lockfile records this assistant as a paired entry. Paired
|
|
44
|
+
* entries have no local daemon, so guardian-token failure guidance must say
|
|
45
|
+
* re-pair rather than hatch/wake.
|
|
46
|
+
*/
|
|
47
|
+
export function isPairedLockfileEntry(
|
|
48
|
+
lockfilePaths: string[],
|
|
49
|
+
assistantId: string,
|
|
50
|
+
): boolean {
|
|
51
|
+
const lockfile = readRawLockfile(lockfilePaths);
|
|
52
|
+
const assistants = Array.isArray(lockfile.assistants)
|
|
53
|
+
? (lockfile.assistants as Array<Record<string, unknown>>)
|
|
54
|
+
: [];
|
|
55
|
+
return assistants.some(
|
|
56
|
+
(assistant) =>
|
|
57
|
+
assistant?.assistantId === assistantId &&
|
|
58
|
+
(resolveCloud(assistant) === "paired" || assistant.paired === true),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
export type WriteResult =
|
|
39
63
|
| { ok: true; lockfile: Lockfile }
|
|
40
64
|
| { ok: false; status: number; error: string };
|
|
41
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Read the first parseable lockfile as raw JSON (unknown fields intact) for a
|
|
68
|
+
* read-modify-write cycle; an unreadable file yields an empty lockfile.
|
|
69
|
+
*/
|
|
70
|
+
export function readRawLockfile(
|
|
71
|
+
lockfilePaths: string[],
|
|
72
|
+
): Record<string, unknown> {
|
|
73
|
+
for (const candidate of lockfilePaths) {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(fs.readFileSync(candidate, "utf-8")) as Record<
|
|
76
|
+
string,
|
|
77
|
+
unknown
|
|
78
|
+
>;
|
|
79
|
+
} catch {
|
|
80
|
+
// continue
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { assistants: [], activeAssistant: null };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Atomically persist a raw lockfile (write-to-temp + rename) and return the
|
|
88
|
+
* validated, sensitive-field-stripped view of what was written.
|
|
89
|
+
*/
|
|
90
|
+
export function writeRawLockfile(
|
|
91
|
+
lockfilePaths: string[],
|
|
92
|
+
lockfile: Record<string, unknown>,
|
|
93
|
+
): WriteResult {
|
|
94
|
+
const writePath = lockfilePaths[0]!;
|
|
95
|
+
try {
|
|
96
|
+
const dir = path.dirname(writePath);
|
|
97
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
98
|
+
const tmp = `${writePath}.tmp.${process.pid}`;
|
|
99
|
+
fs.writeFileSync(tmp, JSON.stringify(lockfile, null, 2));
|
|
100
|
+
fs.renameSync(tmp, writePath);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return { ok: false, status: 500, error: `Failed to write lockfile: ${err}` };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const stripped = JSON.parse(JSON.stringify(lockfile)) as Record<
|
|
106
|
+
string,
|
|
107
|
+
unknown
|
|
108
|
+
>;
|
|
109
|
+
stripSensitiveFields(stripped);
|
|
110
|
+
return { ok: true, lockfile: parseLockfile(stripped) };
|
|
111
|
+
}
|
|
112
|
+
|
|
42
113
|
export function upsertLockfileAssistant(
|
|
43
114
|
lockfilePaths: string[],
|
|
44
115
|
assistant: Record<string, unknown>,
|
|
@@ -48,16 +119,7 @@ export function upsertLockfileAssistant(
|
|
|
48
119
|
return { ok: false, status: 400, error: "Missing assistant.assistantId" };
|
|
49
120
|
}
|
|
50
121
|
|
|
51
|
-
|
|
52
|
-
for (const candidate of lockfilePaths) {
|
|
53
|
-
try {
|
|
54
|
-
lockfile = JSON.parse(fs.readFileSync(candidate, "utf-8")) as Record<string, unknown>;
|
|
55
|
-
break;
|
|
56
|
-
} catch {
|
|
57
|
-
// continue
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
122
|
+
const lockfile = readRawLockfile(lockfilePaths);
|
|
61
123
|
const assistants = Array.isArray(lockfile.assistants) ? lockfile.assistants : [];
|
|
62
124
|
const existingIdx = assistants.findIndex(
|
|
63
125
|
(a: Record<string, unknown>) => a?.assistantId === assistant.assistantId,
|
|
@@ -72,20 +134,53 @@ export function upsertLockfileAssistant(
|
|
|
72
134
|
lockfile.activeAssistant = activeAssistant;
|
|
73
135
|
}
|
|
74
136
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
137
|
+
return writeRawLockfile(lockfilePaths, lockfile);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const PAIRED_LOCKFILE_WRITE_ERROR =
|
|
141
|
+
"Paired assistant identity can only be changed through the connect flow";
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Apply a renderer-originated lockfile upsert without allowing it to create,
|
|
145
|
+
* retarget, or reclassify a paired assistant. Existing paired entries may
|
|
146
|
+
* still update non-security fields and become the active assistant.
|
|
147
|
+
*/
|
|
148
|
+
export function upsertRendererLockfileAssistant(
|
|
149
|
+
lockfilePaths: string[],
|
|
150
|
+
assistant: Record<string, unknown>,
|
|
151
|
+
activeAssistant: string | undefined,
|
|
152
|
+
): WriteResult {
|
|
153
|
+
if (!assistant || typeof assistant.assistantId !== "string") {
|
|
154
|
+
return { ok: false, status: 400, error: "Missing assistant.assistantId" };
|
|
84
155
|
}
|
|
85
156
|
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
157
|
+
const lockfile = readRawLockfile(lockfilePaths);
|
|
158
|
+
const assistants = Array.isArray(lockfile.assistants)
|
|
159
|
+
? (lockfile.assistants as Array<Record<string, unknown>>)
|
|
160
|
+
: [];
|
|
161
|
+
const existing = assistants.find(
|
|
162
|
+
(entry) => entry?.assistantId === assistant.assistantId,
|
|
163
|
+
);
|
|
164
|
+
const merged = { ...existing, ...assistant };
|
|
165
|
+
const existingIsPaired =
|
|
166
|
+
existing != null &&
|
|
167
|
+
(resolveCloud(existing) === "paired" || existing.paired === true);
|
|
168
|
+
const mergedIsPaired =
|
|
169
|
+
resolveCloud(merged) === "paired" || merged.paired === true;
|
|
170
|
+
|
|
171
|
+
if (!existingIsPaired && mergedIsPaired) {
|
|
172
|
+
return { ok: false, status: 403, error: PAIRED_LOCKFILE_WRITE_ERROR };
|
|
173
|
+
}
|
|
174
|
+
if (
|
|
175
|
+
existingIsPaired &&
|
|
176
|
+
(resolveCloud(merged) !== "paired" ||
|
|
177
|
+
merged.runtimeUrl !== existing.runtimeUrl ||
|
|
178
|
+
merged.paired !== existing.paired)
|
|
179
|
+
) {
|
|
180
|
+
return { ok: false, status: 403, error: PAIRED_LOCKFILE_WRITE_ERROR };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return upsertLockfileAssistant(lockfilePaths, assistant, activeAssistant);
|
|
89
184
|
}
|
|
90
185
|
|
|
91
186
|
export function isActiveAssistant(
|
|
@@ -115,16 +210,23 @@ export function replacePlatformAssistants(
|
|
|
115
210
|
platformAssistants: Array<Record<string, unknown>>,
|
|
116
211
|
organizationId?: string,
|
|
117
212
|
): WriteResult {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
213
|
+
if (
|
|
214
|
+
platformAssistants.some(
|
|
215
|
+
(assistant) =>
|
|
216
|
+
typeof assistant?.assistantId !== "string" ||
|
|
217
|
+
assistant.assistantId === "" ||
|
|
218
|
+
resolveCloud(assistant) !== "vellum" ||
|
|
219
|
+
assistant.paired === true,
|
|
220
|
+
)
|
|
221
|
+
) {
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
status: 403,
|
|
225
|
+
error: "Platform sync only accepts platform assistants",
|
|
226
|
+
};
|
|
126
227
|
}
|
|
127
228
|
|
|
229
|
+
const lockfile = readRawLockfile(lockfilePaths);
|
|
128
230
|
const existing = Array.isArray(lockfile.assistants) ? lockfile.assistants : [];
|
|
129
231
|
const syncedIds = new Set(platformAssistants.map((a) => a.assistantId));
|
|
130
232
|
// Org-scoped sync preserves other orgs' platform entries; no org full-replaces.
|
|
@@ -143,18 +245,5 @@ export function replacePlatformAssistants(
|
|
|
143
245
|
if (!stillExists) lockfile.activeAssistant = null;
|
|
144
246
|
}
|
|
145
247
|
|
|
146
|
-
|
|
147
|
-
try {
|
|
148
|
-
const dir = path.dirname(writePath);
|
|
149
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
150
|
-
const tmp = `${writePath}.tmp.${process.pid}`;
|
|
151
|
-
fs.writeFileSync(tmp, JSON.stringify(lockfile, null, 2));
|
|
152
|
-
fs.renameSync(tmp, writePath);
|
|
153
|
-
} catch (err) {
|
|
154
|
-
return { ok: false, status: 500, error: `Failed to write lockfile: ${err}` };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const stripped = JSON.parse(JSON.stringify(lockfile)) as Record<string, unknown>;
|
|
158
|
-
stripSensitiveFields(stripped);
|
|
159
|
-
return { ok: true, lockfile: parseLockfile(stripped) };
|
|
248
|
+
return writeRawLockfile(lockfilePaths, lockfile);
|
|
160
249
|
}
|