@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.
Files changed (97) hide show
  1. package/knip.json +1 -0
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  4. package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  6. package/node_modules/@vellumai/environments/src/index.ts +17 -6
  7. package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  8. package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  9. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
  11. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
  12. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
  13. package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
  14. package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
  15. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
  16. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
  17. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  18. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
  19. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  20. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  21. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  22. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
  23. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  24. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  25. package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
  26. package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
  27. package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
  28. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
  29. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
  30. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
  31. package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
  32. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
  33. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
  34. package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
  35. package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
  36. package/node_modules/@vellumai/local-mode/package.json +1 -0
  37. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
  38. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
  39. package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
  40. package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
  41. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
  42. package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
  43. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
  44. package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
  45. package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
  46. package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
  47. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
  48. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
  49. package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
  50. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
  51. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
  52. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
  53. package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
  54. package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
  55. package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
  56. package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
  57. package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
  58. package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
  59. package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
  60. package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
  61. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
  62. package/package.json +3 -1
  63. package/src/__tests__/client-token.test.ts +26 -1
  64. package/src/__tests__/guardian-token.test.ts +4 -0
  65. package/src/__tests__/nginx-ingress-command.test.ts +176 -16
  66. package/src/__tests__/nginx-ingress.test.ts +1821 -101
  67. package/src/__tests__/pair.test.ts +104 -2
  68. package/src/__tests__/sleep.test.ts +7 -3
  69. package/src/__tests__/tailscale-tunnel.test.ts +0 -1
  70. package/src/__tests__/tunnel.test.ts +1099 -55
  71. package/src/__tests__/wake.test.ts +166 -99
  72. package/src/commands/client.ts +182 -27
  73. package/src/commands/connect/import.ts +35 -156
  74. package/src/commands/nginx-ingress.ts +87 -110
  75. package/src/commands/pair.ts +84 -21
  76. package/src/commands/tunnel.ts +196 -85
  77. package/src/commands/upgrade.ts +3 -2
  78. package/src/commands/wake.ts +5 -163
  79. package/src/index.ts +1 -1
  80. package/src/lib/__tests__/local-ces.test.ts +10 -1
  81. package/src/lib/cloudflare-tunnel.ts +3 -16
  82. package/src/lib/environments/__tests__/paths.test.ts +20 -1
  83. package/src/lib/environments/paths.ts +29 -41
  84. package/src/lib/environments/resolve.ts +7 -5
  85. package/src/lib/guardian-token.ts +22 -62
  86. package/src/lib/ingress-config.ts +25 -0
  87. package/src/lib/local.ts +77 -20
  88. package/src/lib/nginx-ingress.ts +523 -76
  89. package/src/lib/ngrok.ts +154 -61
  90. package/src/lib/orphan-detection.test.ts +167 -0
  91. package/src/lib/orphan-detection.ts +40 -0
  92. package/src/lib/pair.test.ts +87 -0
  93. package/src/lib/pair.ts +55 -0
  94. package/src/lib/platform-client.ts +8 -5
  95. package/src/lib/tailscale-tunnel.ts +7 -18
  96. package/src/lib/tunnel-edge.ts +114 -0
  97. 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. Depends only on `@vellumai/environments`.
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 { parseLockfile, type Lockfile } from "./lockfile-contract";
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
- let lockfile: Record<string, unknown> = { assistants: [], activeAssistant: null };
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
- const writePath = lockfilePaths[0]!;
76
- try {
77
- const dir = path.dirname(writePath);
78
- fs.mkdirSync(dir, { recursive: true });
79
- const tmp = `${writePath}.tmp.${process.pid}`;
80
- fs.writeFileSync(tmp, JSON.stringify(lockfile, null, 2));
81
- fs.renameSync(tmp, writePath);
82
- } catch (err) {
83
- return { ok: false, status: 500, error: `Failed to write lockfile: ${err}` };
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 stripped = JSON.parse(JSON.stringify(lockfile)) as Record<string, unknown>;
87
- stripSensitiveFields(stripped);
88
- return { ok: true, lockfile: parseLockfile(stripped) };
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
- let lockfile: Record<string, unknown> = { assistants: [], activeAssistant: null };
119
- for (const candidate of lockfilePaths) {
120
- try {
121
- lockfile = JSON.parse(fs.readFileSync(candidate, "utf-8")) as Record<string, unknown>;
122
- break;
123
- } catch {
124
- // continue
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
- const writePath = lockfilePaths[0]!;
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
  }