@vellumai/cli 0.11.3 → 0.11.4-dev.202608190019.b94dbf2

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 (41) hide show
  1. package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +33 -0
  2. package/node_modules/@vellumai/local-mode/src/index.ts +3 -0
  3. package/node_modules/@vellumai/local-mode/src/lockfile-lock.test.ts +165 -0
  4. package/node_modules/@vellumai/local-mode/src/lockfile-lock.ts +156 -0
  5. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +249 -8
  6. package/node_modules/@vellumai/local-mode/src/lockfile.ts +186 -68
  7. package/node_modules/@vellumai/local-mode/src/unpair.ts +18 -0
  8. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  9. package/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  10. package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  11. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  12. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  13. package/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  14. package/package.json +1 -1
  15. package/src/__tests__/assistant-config.test.ts +35 -0
  16. package/src/__tests__/nginx-ingress-command.test.ts +4 -23
  17. package/src/__tests__/nginx-ingress.test.ts +59 -215
  18. package/src/__tests__/pair.test.ts +11 -197
  19. package/src/__tests__/retire-archive.test.ts +13 -1
  20. package/src/__tests__/retire-local.test.ts +58 -4
  21. package/src/__tests__/tunnel.test.ts +0 -28
  22. package/src/__tests__/wake.test.ts +91 -69
  23. package/src/__tests__/windows-lifecycle.test.ts +157 -0
  24. package/src/commands/client.ts +9 -31
  25. package/src/commands/nginx-ingress.ts +0 -15
  26. package/src/commands/pair.ts +0 -39
  27. package/src/commands/wake.ts +39 -12
  28. package/src/lib/__tests__/web-dist.test.ts +86 -0
  29. package/src/lib/assistant-config.ts +64 -27
  30. package/src/lib/local.ts +60 -20
  31. package/src/lib/nginx-ingress.ts +35 -108
  32. package/src/lib/orphan-detection.test.ts +3 -0
  33. package/src/lib/orphan-detection.ts +33 -11
  34. package/src/lib/pgrep.ts +20 -2
  35. package/src/lib/process.ts +191 -18
  36. package/src/lib/retire-archive.ts +38 -8
  37. package/src/lib/retire-local.ts +75 -9
  38. package/src/lib/tunnel-edge.ts +14 -22
  39. package/src/lib/web-dist.ts +48 -0
  40. package/src/lib/feature-flags.test.ts +0 -157
  41. package/src/lib/feature-flags.ts +0 -38
@@ -229,4 +229,37 @@ describe("unpairAssistant", () => {
229
229
  }
230
230
  expect(result.status).toBe(400);
231
231
  });
232
+
233
+ test("refuses with 423 under a held write lock, leaving entry and token alone", () => {
234
+ writeLockfile({
235
+ assistants: [{ assistantId: "paired-1", cloud: "paired" }],
236
+ activeAssistant: "paired-1",
237
+ });
238
+ const tokenPath = seedGuardianToken("paired-1");
239
+ fs.mkdirSync(`${lockfilePath}.lock`);
240
+ try {
241
+ const result = unpairAssistant([lockfilePath], configDir, "paired-1");
242
+
243
+ expect(result).toMatchObject({ ok: false, status: 423 });
244
+ expect(fs.existsSync(tokenPath)).toBe(true);
245
+ expect(readLockfileFromDisk().assistants).toEqual([
246
+ { assistantId: "paired-1", cloud: "paired" },
247
+ ]);
248
+ } finally {
249
+ fs.rmdirSync(`${lockfilePath}.lock`);
250
+ }
251
+ });
252
+
253
+ test("a successful unpair leaves no lock dir behind", () => {
254
+ writeLockfile({
255
+ assistants: [{ assistantId: "paired-1", cloud: "paired" }],
256
+ activeAssistant: "paired-1",
257
+ });
258
+ seedGuardianToken("paired-1");
259
+
260
+ const result = unpairAssistant([lockfilePath], configDir, "paired-1");
261
+
262
+ expect(result.ok).toBe(true);
263
+ expect(fs.existsSync(`${lockfilePath}.lock`)).toBe(false);
264
+ });
232
265
  });
@@ -37,6 +37,7 @@ export {
37
37
  } from "./environment";
38
38
  export {
39
39
  getLockfileData,
40
+ renameLockfileAssistantIfPresent,
40
41
  upsertLockfileAssistant,
41
42
  upsertRendererLockfileAssistant,
42
43
  replacePlatformAssistants,
@@ -44,6 +45,8 @@ export {
44
45
  isPairedLockfileEntry,
45
46
  } from "./lockfile";
46
47
  export type { LockfileResult, WriteResult } from "./lockfile";
48
+ export { withLockfileLock } from "./lockfile-lock";
49
+ export type { LockfileLockResult } from "./lockfile-lock";
47
50
  export { parseLockfile } from "./lockfile-contract";
48
51
  export type {
49
52
  Lockfile,
@@ -0,0 +1,165 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ import { withLockfileLock } from "./lockfile-lock";
8
+
9
+ let dir: string;
10
+ let lockfilePath: string;
11
+ let lockDir: string;
12
+
13
+ beforeEach(() => {
14
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "lockfile-lock-test-"));
15
+ lockfilePath = path.join(dir, "lockfile.json");
16
+ lockDir = `${lockfilePath}.lock`;
17
+ });
18
+
19
+ afterEach(() => {
20
+ fs.rmSync(dir, { recursive: true, force: true });
21
+ });
22
+
23
+ describe("withLockfileLock", () => {
24
+ test("runs fn, propagates its result, and removes the lock dir", () => {
25
+ const result = withLockfileLock([lockfilePath], () => {
26
+ expect(fs.existsSync(lockDir)).toBe(true);
27
+ return 42;
28
+ });
29
+ expect(result).toEqual({ ok: true, value: 42 });
30
+ expect(fs.existsSync(lockDir)).toBe(false);
31
+ });
32
+
33
+ test("keys the lock to the write path, ignoring fallback paths", () => {
34
+ const fallback = path.join(dir, "legacy.json");
35
+ withLockfileLock([lockfilePath, fallback], () => {
36
+ expect(fs.existsSync(lockDir)).toBe(true);
37
+ expect(fs.existsSync(`${fallback}.lock`)).toBe(false);
38
+ });
39
+ });
40
+
41
+ test("releases the lock when fn throws, rethrowing", () => {
42
+ expect(() =>
43
+ withLockfileLock([lockfilePath], () => {
44
+ throw new Error("boom");
45
+ }),
46
+ ).toThrow("boom");
47
+ expect(fs.existsSync(lockDir)).toBe(false);
48
+ });
49
+
50
+ test("is reentrant within the process", () => {
51
+ const result = withLockfileLock([lockfilePath], () => {
52
+ const inner = withLockfileLock([lockfilePath], () => "inner");
53
+ expect(inner).toEqual({ ok: true, value: "inner" });
54
+ // Inner exit must not release the outer hold.
55
+ expect(fs.existsSync(lockDir)).toBe(true);
56
+ return "outer";
57
+ });
58
+ expect(result).toEqual({ ok: true, value: "outer" });
59
+ expect(fs.existsSync(lockDir)).toBe(false);
60
+ });
61
+
62
+ test("fails cleanly within budget when another holder is fresh", () => {
63
+ fs.mkdirSync(lockDir);
64
+ let ran = false;
65
+ const start = Date.now();
66
+ const result = withLockfileLock([lockfilePath], () => {
67
+ ran = true;
68
+ });
69
+ expect(Date.now() - start).toBeLessThan(2_000);
70
+ expect(ran).toBe(false);
71
+ expect(result.ok).toBe(false);
72
+ if (!result.ok) {
73
+ expect(result.error).toContain("Timed out acquiring lockfile lock");
74
+ }
75
+ // The other holder's lock is untouched.
76
+ expect(fs.existsSync(lockDir)).toBe(true);
77
+ });
78
+
79
+ test("breaks a stale lock left by a crashed holder", () => {
80
+ fs.mkdirSync(lockDir);
81
+ const past = new Date(Date.now() - 60_000);
82
+ fs.utimesSync(lockDir, past, past);
83
+
84
+ const result = withLockfileLock([lockfilePath], () => "ran");
85
+ expect(result).toEqual({ ok: true, value: "ran" });
86
+ expect(fs.existsSync(lockDir)).toBe(false);
87
+ });
88
+
89
+ test("breaks a stale lock whose recorded owner process is dead", () => {
90
+ // A synchronously-spawned child has exited by the time spawnSync returns.
91
+ const deadPid = spawnSync(process.execPath, ["--version"]).pid;
92
+ fs.mkdirSync(lockDir);
93
+ fs.writeFileSync(path.join(lockDir, "owner"), String(deadPid));
94
+ const past = new Date(Date.now() - 60_000);
95
+ fs.utimesSync(lockDir, past, past);
96
+
97
+ const result = withLockfileLock([lockfilePath], () => "ran");
98
+ expect(result).toEqual({ ok: true, value: "ran" });
99
+ expect(fs.existsSync(lockDir)).toBe(false);
100
+ });
101
+
102
+ test("never steals a stale-aged lock whose owner is still alive", () => {
103
+ fs.mkdirSync(lockDir);
104
+ fs.writeFileSync(path.join(lockDir, "owner"), String(process.pid));
105
+ const past = new Date(Date.now() - 60_000);
106
+ fs.utimesSync(lockDir, past, past);
107
+
108
+ let ran = false;
109
+ const result = withLockfileLock([lockfilePath], () => {
110
+ ran = true;
111
+ });
112
+ expect(ran).toBe(false);
113
+ expect(result.ok).toBe(false);
114
+ expect(fs.existsSync(lockDir)).toBe(true);
115
+ expect(fs.readFileSync(path.join(lockDir, "owner"), "utf-8")).toBe(
116
+ String(process.pid),
117
+ );
118
+ });
119
+
120
+ test("breaks a hard-stale lock even when the recorded owner pid is alive", () => {
121
+ // Models a crashed holder whose pid was recycled to a live process: past
122
+ // the hard ceiling the pid check no longer keeps the lock alive.
123
+ fs.mkdirSync(lockDir);
124
+ fs.writeFileSync(path.join(lockDir, "owner"), String(process.pid));
125
+ const past = new Date(Date.now() - 11 * 60_000);
126
+ fs.utimesSync(lockDir, past, past);
127
+
128
+ const result = withLockfileLock([lockfilePath], () => "ran");
129
+ expect(result).toEqual({ ok: true, value: "ran" });
130
+ expect(fs.existsSync(lockDir)).toBe(false);
131
+ });
132
+
133
+ test("records this process as the owner while the lock is held", () => {
134
+ withLockfileLock([lockfilePath], () => {
135
+ expect(fs.readFileSync(path.join(lockDir, "owner"), "utf-8")).toBe(
136
+ String(process.pid),
137
+ );
138
+ });
139
+ });
140
+
141
+ test("release leaves a lock re-acquired by another owner untouched", () => {
142
+ const result = withLockfileLock([lockfilePath], () => {
143
+ // Simulate a break-and-reacquire while suspended: another process now
144
+ // records itself as the owner of the lock path.
145
+ fs.writeFileSync(path.join(lockDir, "owner"), String(process.pid + 1));
146
+ return "ran";
147
+ });
148
+ expect(result).toEqual({ ok: true, value: "ran" });
149
+ expect(fs.existsSync(lockDir)).toBe(true);
150
+ expect(fs.readFileSync(path.join(lockDir, "owner"), "utf-8")).toBe(
151
+ String(process.pid + 1),
152
+ );
153
+ });
154
+
155
+ test("creates a missing parent directory and acquires", () => {
156
+ const nested = path.join(dir, "a", "b", "lockfile.json");
157
+ const result = withLockfileLock([nested], () => "ran");
158
+ expect(result).toEqual({ ok: true, value: "ran" });
159
+ expect(fs.existsSync(`${nested}.lock`)).toBe(false);
160
+ });
161
+
162
+ test("refuses an empty path list", () => {
163
+ expect(withLockfileLock([], () => "never").ok).toBe(false);
164
+ });
165
+ });
@@ -0,0 +1,156 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export type LockfileLockResult<T> =
5
+ { ok: true; value: T } | { ok: false; error: string };
6
+
7
+ /** Minimum age before a lock is even considered for breaking; the owner pid
8
+ * check is what decides, so a live-but-slow holder is never stolen from. */
9
+ const STALE_LOCK_MS = 5_000;
10
+ /** A pid is not stable identity: a crashed holder's pid can be recycled to an
11
+ * unrelated long-lived process, which would wedge writers forever. Real holds
12
+ * last microseconds, so past this ceiling the pid check is ignored. */
13
+ const HARD_STALE_LOCK_MS = 10 * 60_000;
14
+ const ACQUIRE_ATTEMPTS = 10;
15
+ const RETRY_DELAY_MS = 25;
16
+
17
+ /** Locks held by this process, so composed writers reenter instead of deadlocking. */
18
+ const heldLocks = new Set<string>();
19
+
20
+ /** Disambiguates concurrent break attempts within one process. */
21
+ let breakCounter = 0;
22
+
23
+ /** Node-compatible synchronous sleep (no Bun.sleepSync in Electron/Vite hosts). */
24
+ function sleepSync(ms: number): void {
25
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
26
+ }
27
+
28
+ function ownerFilePath(lockDir: string): string {
29
+ return path.join(lockDir, "owner");
30
+ }
31
+
32
+ function readOwnerPid(lockDir: string): number | null {
33
+ try {
34
+ const pid = Number(fs.readFileSync(ownerFilePath(lockDir), "utf-8"));
35
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function isProcessAlive(pid: number): boolean {
42
+ try {
43
+ process.kill(pid, 0);
44
+ return true;
45
+ } catch (err) {
46
+ // EPERM means the pid exists but belongs to another user.
47
+ return (err as NodeJS.ErrnoException).code !== "ESRCH";
48
+ }
49
+ }
50
+
51
+ function tryBreakStaleLock(lockDir: string): void {
52
+ try {
53
+ const age = Date.now() - fs.statSync(lockDir).mtimeMs;
54
+ if (age <= STALE_LOCK_MS) return;
55
+ if (age <= HARD_STALE_LOCK_MS) {
56
+ const ownerPid = readOwnerPid(lockDir);
57
+ if (ownerPid !== null && isProcessAlive(ownerPid)) return;
58
+ }
59
+ // Rename before removing so exactly one contender wins the break.
60
+ const tombstone = `${lockDir}.stale-${process.pid}-${breakCounter++}`;
61
+ fs.renameSync(lockDir, tombstone);
62
+ fs.rmSync(tombstone, { recursive: true, force: true });
63
+ } catch {
64
+ // Holder released or another contender broke it; next mkdir attempt decides.
65
+ }
66
+ }
67
+
68
+ /** Remove the lock only if this process still owns it: a lock broken as stale
69
+ * and re-acquired while `fn` ran records the replacement holder's pid. */
70
+ function releaseLock(lockDir: string): void {
71
+ try {
72
+ if (readOwnerPid(lockDir) !== process.pid) return;
73
+ fs.rmSync(lockDir, { recursive: true, force: true });
74
+ } catch {
75
+ // Best effort; a leftover dir is broken once its owner process dies.
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Run `fn` under a cross-process advisory lock keyed to the lockfile write
81
+ * path (`lockfilePaths[0]`). The lock is a `${writePath}.lock` directory:
82
+ * mkdir has O_EXCL semantics on every platform, so exactly one process wins.
83
+ * The winner records its pid in an `owner` file; a contender breaks the lock
84
+ * only when the recorded owner process is gone (or never recorded ownership),
85
+ * except past a hard age ceiling that bounds recovery from pid reuse.
86
+ * Acquisition is bounded (~250ms worst case) and failure
87
+ * is returned structurally rather than thrown, so never-throw seams stay
88
+ * never-throw; `fn`'s own exceptions propagate, with the lock released in
89
+ * `finally`.
90
+ */
91
+ export function withLockfileLock<T>(
92
+ lockfilePaths: string[],
93
+ fn: () => T,
94
+ ): LockfileLockResult<T> {
95
+ const writePath = lockfilePaths[0];
96
+ if (!writePath) {
97
+ return { ok: false, error: "No lockfile path to lock" };
98
+ }
99
+ const lockDir = `${writePath}.lock`;
100
+
101
+ if (heldLocks.has(lockDir)) {
102
+ return { ok: true, value: fn() };
103
+ }
104
+
105
+ let acquired = false;
106
+ for (let attempt = 0; attempt < ACQUIRE_ATTEMPTS; attempt++) {
107
+ if (attempt > 0) sleepSync(RETRY_DELAY_MS);
108
+ try {
109
+ fs.mkdirSync(lockDir);
110
+ } catch (err) {
111
+ const code = (err as NodeJS.ErrnoException).code;
112
+ if (code === "EEXIST") {
113
+ tryBreakStaleLock(lockDir);
114
+ continue;
115
+ }
116
+ if (code === "ENOENT") {
117
+ try {
118
+ fs.mkdirSync(path.dirname(lockDir), { recursive: true });
119
+ } catch {
120
+ // Surfaced by the next mkdir attempt.
121
+ }
122
+ continue;
123
+ }
124
+ return { ok: false, error: `Failed to acquire lockfile lock: ${err}` };
125
+ }
126
+ try {
127
+ fs.writeFileSync(ownerFilePath(lockDir), String(process.pid));
128
+ } catch (err) {
129
+ try {
130
+ fs.rmSync(lockDir, { recursive: true, force: true });
131
+ } catch {
132
+ // An ownerless dir is broken as stale once it ages out.
133
+ }
134
+ return {
135
+ ok: false,
136
+ error: `Failed to record lockfile lock ownership: ${err}`,
137
+ };
138
+ }
139
+ acquired = true;
140
+ break;
141
+ }
142
+ if (!acquired) {
143
+ return {
144
+ ok: false,
145
+ error: `Timed out acquiring lockfile lock at ${lockDir}`,
146
+ };
147
+ }
148
+
149
+ heldLocks.add(lockDir);
150
+ try {
151
+ return { ok: true, value: fn() };
152
+ } finally {
153
+ heldLocks.delete(lockDir);
154
+ releaseLock(lockDir);
155
+ }
156
+ }
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import {
7
7
  getLockfileData,
8
8
  isPairedLockfileEntry,
9
+ renameLockfileAssistantIfPresent,
9
10
  replacePlatformAssistants,
10
11
  upsertLockfileAssistant,
11
12
  upsertRendererLockfileAssistant,
@@ -224,6 +225,33 @@ describe("upsertLockfileAssistant", () => {
224
225
  });
225
226
  });
226
227
 
228
+ test("a name-only payload preserves resources and on-disk secrets byte-for-byte", () => {
229
+ const entry = {
230
+ assistantId: "asst_1",
231
+ cloud: "local",
232
+ runtimeUrl: "http://a",
233
+ name: "Old Name",
234
+ resources: { gatewayPort: 7830, daemonPort: 7831, dataDir: "/tmp/x" },
235
+ signingKey: "sk-on-disk-secret",
236
+ bearerToken: "bt-on-disk-secret",
237
+ guardianBootstrapSecret: "gb-on-disk-secret",
238
+ };
239
+ writeOnDisk({ activeAssistant: "asst_1", assistants: [entry] });
240
+
241
+ const result = upsertLockfileAssistant(
242
+ [lockfilePath],
243
+ { assistantId: "asst_1", name: "Renamed" },
244
+ undefined,
245
+ );
246
+
247
+ expect(result.ok).toBe(true);
248
+ const assistants = readOnDisk().assistants as Array<
249
+ Record<string, unknown>
250
+ >;
251
+ expect(assistants).toHaveLength(1);
252
+ expect(assistants[0]).toEqual({ ...entry, name: "Renamed" });
253
+ });
254
+
227
255
  test("preserves activeAssistant when no active id is provided", () => {
228
256
  writeOnDisk({
229
257
  activeAssistant: "asst_active",
@@ -251,6 +279,216 @@ describe("upsertLockfileAssistant", () => {
251
279
  });
252
280
  });
253
281
 
282
+ describe("renameLockfileAssistantIfPresent", () => {
283
+ const entry = {
284
+ assistantId: "asst_1",
285
+ cloud: "local",
286
+ runtimeUrl: "http://a",
287
+ name: "Old Name",
288
+ resources: { gatewayPort: 7830, daemonPort: 7831, dataDir: "/tmp/x" },
289
+ signingKey: "sk-on-disk-secret",
290
+ bearerToken: "bt-on-disk-secret",
291
+ };
292
+
293
+ test("renames the entry preserving resources, secrets, and activeAssistant", () => {
294
+ writeOnDisk({
295
+ activeAssistant: "asst_other",
296
+ assistants: [entry, { assistantId: "asst_other", cloud: "local" }],
297
+ });
298
+
299
+ const result = renameLockfileAssistantIfPresent(
300
+ [lockfilePath],
301
+ "asst_1",
302
+ "Renamed",
303
+ );
304
+
305
+ expect(result.ok).toBe(true);
306
+ const onDisk = readOnDisk();
307
+ expect(onDisk.activeAssistant).toBe("asst_other");
308
+ const assistants = onDisk.assistants as Array<Record<string, unknown>>;
309
+ expect(assistants).toEqual([
310
+ { ...entry, name: "Renamed" },
311
+ { assistantId: "asst_other", cloud: "local" },
312
+ ]);
313
+ });
314
+
315
+ test("refuses a missing entry without writing the file", () => {
316
+ const result = renameLockfileAssistantIfPresent(
317
+ [lockfilePath],
318
+ "asst_gone",
319
+ "Renamed",
320
+ );
321
+
322
+ expect(result).toEqual({
323
+ ok: false,
324
+ status: 404,
325
+ error: "No lockfile entry for this assistant",
326
+ });
327
+ expect(fs.existsSync(lockfilePath)).toBe(false);
328
+ });
329
+
330
+ test("refuses when the id names another process's retired entry", () => {
331
+ writeOnDisk({ activeAssistant: null, assistants: [entry] });
332
+ const before = fs.readFileSync(lockfilePath, "utf-8");
333
+
334
+ const result = renameLockfileAssistantIfPresent(
335
+ [lockfilePath],
336
+ "asst_retired",
337
+ "Renamed",
338
+ );
339
+
340
+ expect(result.ok).toBe(false);
341
+ if (!result.ok) {
342
+ expect(result.status).toBe(404);
343
+ }
344
+ expect(fs.readFileSync(lockfilePath, "utf-8")).toBe(before);
345
+ });
346
+
347
+ test("refuses a corrupt on-disk file without clobbering it", () => {
348
+ fs.writeFileSync(lockfilePath, "{ not json");
349
+
350
+ const result = renameLockfileAssistantIfPresent(
351
+ [lockfilePath],
352
+ "asst_1",
353
+ "Renamed",
354
+ );
355
+
356
+ expect(result.ok).toBe(false);
357
+ if (!result.ok) {
358
+ expect(result.status).toBe(409);
359
+ }
360
+ expect(fs.readFileSync(lockfilePath, "utf-8")).toBe("{ not json");
361
+ });
362
+
363
+ test("refuses non-object JSON on disk without clobbering it", () => {
364
+ for (const raw of ["null", "[]", '"text"', "7"]) {
365
+ fs.writeFileSync(lockfilePath, raw);
366
+
367
+ const result = renameLockfileAssistantIfPresent(
368
+ [lockfilePath],
369
+ "asst_1",
370
+ "Renamed",
371
+ );
372
+
373
+ expect(result.ok).toBe(false);
374
+ if (!result.ok) {
375
+ expect(result.status).toBe(409);
376
+ }
377
+ expect(fs.readFileSync(lockfilePath, "utf-8")).toBe(raw);
378
+ }
379
+ });
380
+
381
+ test("an already-equal name succeeds without rewriting the file", () => {
382
+ // Compact formatting: any rewrite would re-indent and change the bytes.
383
+ fs.writeFileSync(
384
+ lockfilePath,
385
+ JSON.stringify({ activeAssistant: null, assistants: [entry] }),
386
+ );
387
+ const before = fs.readFileSync(lockfilePath, "utf-8");
388
+
389
+ const result = renameLockfileAssistantIfPresent(
390
+ [lockfilePath],
391
+ "asst_1",
392
+ "Old Name",
393
+ );
394
+
395
+ expect(result.ok).toBe(true);
396
+ if (result.ok) {
397
+ expect(result.lockfile.assistants[0]?.name).toBe("Old Name");
398
+ expect(result.lockfile.assistants[0]).not.toHaveProperty("signingKey");
399
+ }
400
+ expect(fs.readFileSync(lockfilePath, "utf-8")).toBe(before);
401
+ });
402
+
403
+ test("rejects a missing id or name without touching disk", () => {
404
+ writeOnDisk({ activeAssistant: null, assistants: [entry] });
405
+ const before = fs.readFileSync(lockfilePath, "utf-8");
406
+
407
+ expect(renameLockfileAssistantIfPresent([lockfilePath], "", "N").ok).toBe(
408
+ false,
409
+ );
410
+ expect(
411
+ renameLockfileAssistantIfPresent([lockfilePath], "asst_1", "").ok,
412
+ ).toBe(false);
413
+ expect(fs.readFileSync(lockfilePath, "utf-8")).toBe(before);
414
+ });
415
+ });
416
+
417
+ describe("lockfile writers under lock contention", () => {
418
+ const entry = {
419
+ assistantId: "asst_1",
420
+ cloud: "local",
421
+ runtimeUrl: "http://a",
422
+ name: "Old Name",
423
+ };
424
+
425
+ function holdLock(): string {
426
+ const lockDir = `${lockfilePath}.lock`;
427
+ fs.mkdirSync(lockDir);
428
+ return lockDir;
429
+ }
430
+
431
+ test("rename refuses with 423 and leaves the file untouched", () => {
432
+ writeOnDisk({ activeAssistant: null, assistants: [entry] });
433
+ const before = fs.readFileSync(lockfilePath, "utf-8");
434
+ holdLock();
435
+
436
+ const result = renameLockfileAssistantIfPresent(
437
+ [lockfilePath],
438
+ "asst_1",
439
+ "Renamed",
440
+ );
441
+
442
+ expect(result.ok).toBe(false);
443
+ if (!result.ok) {
444
+ expect(result.status).toBe(423);
445
+ }
446
+ expect(fs.readFileSync(lockfilePath, "utf-8")).toBe(before);
447
+ });
448
+
449
+ test("upsert refuses with 423 and leaves the file untouched", () => {
450
+ writeOnDisk({ activeAssistant: null, assistants: [entry] });
451
+ const before = fs.readFileSync(lockfilePath, "utf-8");
452
+ holdLock();
453
+
454
+ const result = upsertLockfileAssistant(
455
+ [lockfilePath],
456
+ { assistantId: "asst_2", cloud: "local" },
457
+ undefined,
458
+ );
459
+
460
+ expect(result).toMatchObject({ ok: false, status: 423 });
461
+ expect(fs.readFileSync(lockfilePath, "utf-8")).toBe(before);
462
+ });
463
+
464
+ test("platform replace refuses with 423 and leaves the file untouched", () => {
465
+ writeOnDisk({ activeAssistant: null, assistants: [entry] });
466
+ const before = fs.readFileSync(lockfilePath, "utf-8");
467
+ holdLock();
468
+
469
+ const result = replacePlatformAssistants(
470
+ [lockfilePath],
471
+ [{ assistantId: "asst_p", cloud: "vellum", runtimeUrl: "http://p" }],
472
+ );
473
+
474
+ expect(result).toMatchObject({ ok: false, status: 423 });
475
+ expect(fs.readFileSync(lockfilePath, "utf-8")).toBe(before);
476
+ });
477
+
478
+ test("a successful rename leaves no lock dir behind", () => {
479
+ writeOnDisk({ activeAssistant: null, assistants: [entry] });
480
+
481
+ const result = renameLockfileAssistantIfPresent(
482
+ [lockfilePath],
483
+ "asst_1",
484
+ "Renamed",
485
+ );
486
+
487
+ expect(result.ok).toBe(true);
488
+ expect(fs.existsSync(`${lockfilePath}.lock`)).toBe(false);
489
+ });
490
+ });
491
+
254
492
  describe("upsertRendererLockfileAssistant", () => {
255
493
  const paired = {
256
494
  assistantId: "paired-1",
@@ -337,14 +575,17 @@ describe("replacePlatformAssistants", () => {
337
575
  });
338
576
 
339
577
  expect(
340
- replacePlatformAssistants([lockfilePath], [
341
- {
342
- assistantId: "asst_local",
343
- cloud: "paired",
344
- paired: true,
345
- runtimeUrl: "https://attacker.example.com",
346
- },
347
- ]),
578
+ replacePlatformAssistants(
579
+ [lockfilePath],
580
+ [
581
+ {
582
+ assistantId: "asst_local",
583
+ cloud: "paired",
584
+ paired: true,
585
+ runtimeUrl: "https://attacker.example.com",
586
+ },
587
+ ],
588
+ ),
348
589
  ).toMatchObject({ ok: false, status: 403 });
349
590
  expect(readOnDisk()).toEqual({
350
591
  activeAssistant: "asst_local",