@bybrawe/opencode-loop 0.6.6 → 0.6.8

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.
@@ -0,0 +1,172 @@
1
+ import assert from "node:assert/strict"
2
+ import { spawn, spawnSync } from "node:child_process"
3
+ import { randomUUID } from "node:crypto"
4
+ import { mkdtemp, mkdir, readFile, readdir, rm, writeFile, link, stat } from "node:fs/promises"
5
+ import os from "node:os"
6
+ import path from "node:path"
7
+ import { fileURLToPath } from "node:url"
8
+ import { readState, writeState, removeState, stateDir } from "../src/source/core/state.js"
9
+
10
+ const filename = fileURLToPath(import.meta.url)
11
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
12
+ async function exists(file) { try { await stat(file); return true } catch (error) { if (error?.code === "ENOENT") return false; throw error } }
13
+ async function until(fn, duration = 15_000) {
14
+ const started = Date.now()
15
+ while (Date.now() - started < duration) {
16
+ if (await fn()) return
17
+ await pause(20)
18
+ }
19
+ throw new Error("state worker sync timeout")
20
+ }
21
+
22
+ if (process.argv[2] === "--worker") {
23
+ const [, , , root, sessionID, marker, gate] = process.argv
24
+ const snapshot = await readState(root, sessionID)
25
+ await writeFile(`${gate}.${marker}.ready`, "ready")
26
+ await until(() => exists(gate))
27
+ if (marker === "delete") await removeState(root, sessionID)
28
+ else {
29
+ snapshot.jobs[0][marker] = Number(marker.slice(1))
30
+ await writeState(root, sessionID, snapshot)
31
+ }
32
+ } else {
33
+ const root = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-process-lock-"))
34
+ const sessionID = "multi-process"
35
+ const lockFile = path.join(stateDir(root), `${sessionID}.json.lock`)
36
+ try {
37
+ for (let round = 0; round < 6; round++) {
38
+ const initial = await readState(root, sessionID)
39
+ initial.jobs = [{ id: "shared", round }]
40
+ await writeState(root, sessionID, initial)
41
+ const gate = path.join(root, `round-${round}.go`)
42
+ const workers = []
43
+ for (let n = 0; n < 4; n++) {
44
+ const marker = `f${n}`
45
+ const child = spawn(process.execPath, [filename, "--worker", root, sessionID, marker, gate], { stdio: ["ignore", "ignore", "pipe"] })
46
+ let stderr = ""
47
+ child.stderr.on("data", (part) => { stderr += part.toString() })
48
+ const closed = new Promise((resolve, reject) => {
49
+ child.once("error", reject)
50
+ child.once("close", (code) => code === 0 ? resolve() : reject(new Error(`worker ${marker} exited ${code}: ${stderr}`)))
51
+ })
52
+ workers.push({ marker, closed })
53
+ }
54
+ await until(async () => (await Promise.all(workers.map((w) => exists(`${gate}.${w.marker}.ready`)))).every(Boolean))
55
+ await writeFile(gate, "go")
56
+ await Promise.all(workers.map((w) => w.closed))
57
+ const saved = (await readState(root, sessionID)).jobs[0]
58
+ assert.equal(saved.round, round)
59
+ for (let n = 0; n < 4; n++) assert.equal(saved[`f${n}`], n, "each independent writer must survive")
60
+ assert.equal(await exists(lockFile), false, "all writers must release the lock")
61
+ }
62
+
63
+ // A stale writer must not resurrect a job removed by another process.
64
+ // Race both operation orders: the lock serializes remove and write, while
65
+ // baseline-aware merge must preserve the deletion if write runs second.
66
+ for (let round = 0; round < 8; round++) {
67
+ const seed = await readState(root, sessionID)
68
+ seed.jobs = [{ id: "shared", round: `delete-${round}` }]
69
+ await writeState(root, sessionID, seed)
70
+ const gate = path.join(root, `delete-${round}.go`)
71
+ const workers = []
72
+ for (const marker of ["delete", "f0"]) {
73
+ const child = spawn(process.execPath, [filename, "--worker", root, sessionID, marker, gate], {
74
+ stdio: ["ignore", "ignore", "pipe"],
75
+ })
76
+ let stderr = ""
77
+ child.stderr.on("data", (part) => { stderr += part.toString() })
78
+ workers.push({
79
+ marker,
80
+ closed: new Promise((resolve, reject) => {
81
+ child.once("error", reject)
82
+ child.once("close", (code) => code === 0 ? resolve() : reject(new Error(`worker ${marker} exited ${code}: ${stderr}`)))
83
+ }),
84
+ })
85
+ }
86
+ await until(async () => (await Promise.all(workers.map((w) =>
87
+ exists(`${gate}.${w.marker}.ready`)))).every(Boolean))
88
+ await writeFile(gate, "go")
89
+ await Promise.all(workers.map((w) => w.closed))
90
+ assert.deepEqual((await readState(root, sessionID)).jobs, [],
91
+ "remove-vs-stale-writer must not resurrect deleted work")
92
+ assert.equal(await exists(lockFile), false, "both processes must release lock after deletion race")
93
+ }
94
+ const fresh = await readState(root, sessionID)
95
+ fresh.jobs = [{ id: "shared", round: "reseeded" }]
96
+ await writeState(root, sessionID, fresh)
97
+
98
+ // A malformed lock is never stolen or silently removed.
99
+ const corrupt = await readState(root, sessionID)
100
+ await writeFile(lockFile, "corrupt owner")
101
+ corrupt.jobs[0].newField = "must-not-commit"
102
+ await assert.rejects(writeState(root, sessionID, corrupt), /state lock invalid_owner/)
103
+ assert.equal((await readState(root, sessionID)).jobs[0].newField, undefined)
104
+ await rm(lockFile)
105
+
106
+ // A dead owner is reclaimed with an election lock, never a blind unlink.
107
+ const dead = spawnSync(process.execPath, ["-e", "process.exit(0)"], { encoding: "utf8" })
108
+ assert.equal(dead.status, 0)
109
+ assert.ok(dead.pid > 0)
110
+ const token = randomUUID()
111
+ const candidate = path.join(stateDir(root), `.loop-lock-${dead.pid}-${token}.json`)
112
+ const owner = { schemaVersion: 1, pid: dead.pid, token, acquiredAt: Date.now(), candidateName: path.basename(candidate) }
113
+ await writeFile(candidate, JSON.stringify(owner))
114
+ await link(candidate, lockFile)
115
+ // Simulate a crash while an earlier process was cleaning the dead owner.
116
+ // A stray, dead `.lock.cleanup` must not permanently block recovery.
117
+ const cleanupToken = randomUUID()
118
+ const cleanupCandidate = path.join(stateDir(root), `.loop-lock-${dead.pid}-${cleanupToken}.json`)
119
+ const cleanupOwner = {
120
+ schemaVersion: 1, pid: dead.pid, token: cleanupToken,
121
+ acquiredAt: Date.now(), candidateName: path.basename(cleanupCandidate),
122
+ }
123
+ await writeFile(cleanupCandidate, JSON.stringify(cleanupOwner))
124
+ await link(cleanupCandidate, `${lockFile}.cleanup`)
125
+ const revived = await readState(root, sessionID)
126
+ revived.jobs[0].recovered = true
127
+ await writeState(root, sessionID, revived)
128
+ assert.equal((await readState(root, sessionID)).jobs[0].recovered, true)
129
+ assert.equal(await exists(lockFile), false)
130
+ assert.equal(await exists(candidate), false)
131
+ assert.equal(await exists(cleanupCandidate), false, "orphaned election candidate must be cleaned")
132
+ assert.equal(await exists(`${lockFile}.cleanup`), false, "orphaned cleanup lock must be removed")
133
+
134
+ // A malformed election must fail closed and not delete another owner's files.
135
+ const badToken = randomUUID()
136
+ const badCandidate = path.join(stateDir(root), `.loop-lock-${dead.pid}-${badToken}.json`)
137
+ const badOwner = {
138
+ schemaVersion: 1, pid: dead.pid, token: badToken,
139
+ acquiredAt: Date.now(), candidateName: path.basename(badCandidate),
140
+ }
141
+ await writeFile(badCandidate, JSON.stringify(badOwner))
142
+ await link(badCandidate, lockFile)
143
+ await writeFile(`${lockFile}.cleanup`, "not valid owner metadata")
144
+ const blocked = await readState(root, sessionID)
145
+ blocked.jobs[0].mustNotWrite = true
146
+ await assert.rejects(writeState(root, sessionID, blocked), /state lock invalid_owner/)
147
+ assert.equal((await readState(root, sessionID)).jobs[0].mustNotWrite, undefined)
148
+ assert.equal(await exists(lockFile), true, "invalid election must not erase the dead canonical owner")
149
+ await rm(lockFile)
150
+ await rm(`${lockFile}.cleanup`)
151
+ await rm(badCandidate)
152
+
153
+ // A path supplied by another tool cannot symlink Loop state outside the project.
154
+ const other = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-escape-"))
155
+ const unsafe = path.join(root, "unsafe")
156
+ await mkdir(unsafe)
157
+ try {
158
+ const linkTarget = path.join(unsafe, ".opencode")
159
+ try {
160
+ const { symlink } = await import("node:fs/promises")
161
+ await symlink(other, linkTarget, process.platform === "win32" ? "junction" : "dir")
162
+ await assert.rejects(readState(unsafe, sessionID), /state lock unsafe_path/)
163
+ await assert.rejects(writeState(unsafe, sessionID, { version: 4, jobs: [] }), /state lock unsafe_path/)
164
+ } catch (error) {
165
+ if (process.platform !== "win32" || !["EPERM", "EACCES"].includes(error?.code)) throw error
166
+ }
167
+ } finally { await rm(other, { recursive: true, force: true }) }
168
+ console.log("Loop multi-process write/delete races, stale-owner recovery, and unsafe-path regressions passed")
169
+ } finally {
170
+ await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
171
+ }
172
+ }
package/src/index.js CHANGED
@@ -381,6 +381,7 @@ import { spawn } from "child_process";
381
381
 
382
382
  // src/source/core/state.js
383
383
  import { promises as fs } from "fs";
384
+ import { randomUUID as stateLockRandomUUID } from "crypto";
384
385
  import os from "os";
385
386
  import path from "path";
386
387
  var STATE_DIR = ".opencode/opencode-loop";
@@ -403,6 +404,226 @@ async function pathExists(filePath) {
403
404
  return false;
404
405
  }
405
406
  }
407
+ var STATE_PROCESS_LOCK_TIMEOUT_MS = 5000;
408
+ var STATE_PROCESS_LOCK_POLL_MS = 20;
409
+ function stateLockError(kind, detail) {
410
+ const error = new Error(`OpenCode Loop state lock ${kind}: ${detail}`);
411
+ error.code = "OPENCODE_LOOP_STATE_LOCK";
412
+ return error;
413
+ }
414
+ async function assertStatePathSafe(directory, target) {
415
+ const root = path.resolve(directory);
416
+ const relative = path.relative(root, path.resolve(target));
417
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
418
+ throw stateLockError("unsafe_path", `path escapes project: ${target}`);
419
+ }
420
+ let current = root;
421
+ for (const part of relative.split(path.sep).filter(Boolean)) {
422
+ current = path.join(current, part);
423
+ let info;
424
+ try {
425
+ info = await fs.lstat(current);
426
+ } catch (error) {
427
+ if (error?.code === "ENOENT")
428
+ return;
429
+ throw error;
430
+ }
431
+ if (info.isSymbolicLink())
432
+ throw stateLockError("unsafe_path", `symbolic link or junction: ${current}`);
433
+ }
434
+ }
435
+ function stateProcessLockOwner() {
436
+ const token = stateLockRandomUUID();
437
+ return {
438
+ schemaVersion: 1,
439
+ pid: process.pid,
440
+ token,
441
+ acquiredAt: Date.now(),
442
+ candidateName: `.loop-lock-${process.pid}-${token}.json`
443
+ };
444
+ }
445
+ function stateProcessLockOwnerValid(owner) {
446
+ return owner?.schemaVersion === 1 && Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.token === "string" && /^[0-9a-f-]{36}$/i.test(owner.token) && Number.isFinite(owner.acquiredAt) && owner.acquiredAt > 0 && owner.candidateName === `.loop-lock-${owner.pid}-${owner.token}.json` && path.basename(owner.candidateName) === owner.candidateName;
447
+ }
448
+ async function readStateProcessOwner(file) {
449
+ let raw;
450
+ for (let attempt = 0;; attempt++) {
451
+ try {
452
+ raw = await fs.readFile(file, "utf8");
453
+ break;
454
+ } catch (error) {
455
+ if (error?.code === "ENOENT")
456
+ return null;
457
+ if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
458
+ throw error;
459
+ await delay(10 * (attempt + 1));
460
+ }
461
+ }
462
+ if (raw.length === 0) {
463
+ for (let attempt = 0;attempt < 8 && raw.length === 0; attempt++) {
464
+ await delay(10 * (attempt + 1));
465
+ try {
466
+ raw = await fs.readFile(file, "utf8");
467
+ } catch (error) {
468
+ if (error?.code === "ENOENT")
469
+ return null;
470
+ throw error;
471
+ }
472
+ }
473
+ }
474
+ let owner;
475
+ try {
476
+ owner = JSON.parse(raw);
477
+ } catch {
478
+ throw stateLockError("invalid_owner", `corrupt lock metadata at ${file}`);
479
+ }
480
+ if (!stateProcessLockOwnerValid(owner))
481
+ throw stateLockError("invalid_owner", `invalid lock metadata at ${file}`);
482
+ return owner;
483
+ }
484
+ function stateProcessAlive(pid) {
485
+ if (pid === process.pid)
486
+ return true;
487
+ try {
488
+ process.kill(pid, 0);
489
+ return true;
490
+ } catch (error) {
491
+ return error?.code !== "ESRCH";
492
+ }
493
+ }
494
+ async function removeStateProcessFile(file) {
495
+ for (let attempt = 0;; attempt++) {
496
+ try {
497
+ await fs.rm(file, { force: true });
498
+ return;
499
+ } catch (error) {
500
+ if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
501
+ throw error;
502
+ await delay(10 * (attempt + 1));
503
+ }
504
+ }
505
+ }
506
+ async function claimStateProcessLock(candidate, canonical) {
507
+ for (let attempt = 0;; attempt++) {
508
+ try {
509
+ await fs.link(candidate, canonical);
510
+ return true;
511
+ } catch (error) {
512
+ if (error?.code === "EEXIST")
513
+ return false;
514
+ if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
515
+ const contents = await fs.readFile(candidate);
516
+ let handle;
517
+ try {
518
+ handle = await fs.open(canonical, "wx", 384);
519
+ } catch (failure) {
520
+ if (failure?.code === "EEXIST")
521
+ return false;
522
+ throw failure;
523
+ }
524
+ try {
525
+ await handle.writeFile(contents);
526
+ } catch (failure) {
527
+ await handle.close().catch(() => {});
528
+ await removeStateProcessFile(canonical).catch(() => {});
529
+ throw failure;
530
+ }
531
+ await handle.close();
532
+ return true;
533
+ }
534
+ if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
535
+ throw error;
536
+ await delay(10 * (attempt + 1));
537
+ }
538
+ }
539
+ }
540
+ async function createStateLockCandidate(directory, owner) {
541
+ const candidate = path.join(stateDir(directory), owner.candidateName);
542
+ await assertStatePathSafe(directory, candidate);
543
+ await fs.writeFile(candidate, `${JSON.stringify(owner)}
544
+ `, { flag: "wx", mode: 384 });
545
+ return candidate;
546
+ }
547
+ async function recoverDeadStateProcessLock(directory, canonical, stale, depth = 0) {
548
+ const cleanupFile = `${canonical}.cleanup`;
549
+ const cleanupOwner = stateProcessLockOwner();
550
+ const cleanupCandidate = await createStateLockCandidate(directory, cleanupOwner);
551
+ let elected = false;
552
+ try {
553
+ await assertStatePathSafe(directory, cleanupFile);
554
+ elected = await claimStateProcessLock(cleanupCandidate, cleanupFile);
555
+ if (!elected) {
556
+ const existing = await readStateProcessOwner(cleanupFile);
557
+ if (existing && !stateProcessAlive(existing.pid)) {
558
+ if (depth >= 6)
559
+ throw stateLockError("recovery_depth", `too many orphaned cleanup elections for ${canonical}`);
560
+ await recoverDeadStateProcessLock(directory, cleanupFile, existing, depth + 1);
561
+ }
562
+ return false;
563
+ }
564
+ const current = await readStateProcessOwner(canonical);
565
+ if (!current || current.token !== stale.token || current.pid !== stale.pid || stateProcessAlive(current.pid))
566
+ return false;
567
+ await assertStatePathSafe(directory, canonical);
568
+ await removeStateProcessFile(canonical);
569
+ const staleCandidate = path.join(stateDir(directory), current.candidateName);
570
+ await assertStatePathSafe(directory, staleCandidate);
571
+ await removeStateProcessFile(staleCandidate).catch(() => {});
572
+ return true;
573
+ } finally {
574
+ if (elected) {
575
+ const current = await readStateProcessOwner(cleanupFile).catch(() => null);
576
+ if (current?.token === cleanupOwner.token)
577
+ await removeStateProcessFile(cleanupFile);
578
+ }
579
+ await removeStateProcessFile(cleanupCandidate).catch(() => {});
580
+ }
581
+ }
582
+ async function acquireStateProcessLock(directory, sessionID) {
583
+ const targetDirectory = stateDir(directory);
584
+ await assertStatePathSafe(directory, targetDirectory);
585
+ await ensureDir(targetDirectory);
586
+ await assertStatePathSafe(directory, targetDirectory);
587
+ const target = statePath(directory, sessionID);
588
+ const canonical = `${target}.lock`;
589
+ await assertStatePathSafe(directory, target);
590
+ await assertStatePathSafe(directory, canonical);
591
+ const owner = stateProcessLockOwner();
592
+ const candidate = await createStateLockCandidate(directory, owner);
593
+ const started = Date.now();
594
+ let acquired = false;
595
+ try {
596
+ while (true) {
597
+ await assertStatePathSafe(directory, canonical);
598
+ if (await claimStateProcessLock(candidate, canonical)) {
599
+ acquired = true;
600
+ break;
601
+ }
602
+ const current = await readStateProcessOwner(canonical);
603
+ if (current && !stateProcessAlive(current.pid))
604
+ await recoverDeadStateProcessLock(directory, canonical, current);
605
+ if (Date.now() - started >= STATE_PROCESS_LOCK_TIMEOUT_MS) {
606
+ throw stateLockError("timeout", `session ${sessionID} remained locked for ${STATE_PROCESS_LOCK_TIMEOUT_MS}ms`);
607
+ }
608
+ await delay(STATE_PROCESS_LOCK_POLL_MS);
609
+ }
610
+ return async () => {
611
+ const current = await readStateProcessOwner(canonical);
612
+ if (current?.token !== owner.token || current.pid !== owner.pid) {
613
+ throw stateLockError("lost", `session ${sessionID} lock ownership changed`);
614
+ }
615
+ await assertStatePathSafe(directory, canonical);
616
+ await removeStateProcessFile(canonical);
617
+ await removeStateProcessFile(candidate);
618
+ };
619
+ } catch (error) {
620
+ if (acquired && (await readStateProcessOwner(canonical).catch(() => null))?.token === owner.token) {
621
+ await removeStateProcessFile(canonical).catch(() => {});
622
+ }
623
+ await removeStateProcessFile(candidate).catch(() => {});
624
+ throw error;
625
+ }
626
+ }
406
627
  function stateLockKey(directory, sessionID) {
407
628
  return `${path.resolve(directory)}:${safeID(sessionID)}`;
408
629
  }
@@ -416,21 +637,32 @@ async function withStateWriteLock(directory, sessionID, fn) {
416
637
  const next = previous.catch(() => {}).then(() => current);
417
638
  stateWriteLocks.set(key, next);
418
639
  await previous.catch(() => {});
640
+ let unlock;
419
641
  try {
642
+ unlock = await acquireStateProcessLock(directory, sessionID);
420
643
  return await fn();
421
644
  } finally {
422
- release();
423
- if (stateWriteLocks.get(key) === next)
424
- stateWriteLocks.delete(key);
645
+ try {
646
+ if (unlock)
647
+ await unlock();
648
+ } finally {
649
+ release();
650
+ if (stateWriteLocks.get(key) === next)
651
+ stateWriteLocks.delete(key);
652
+ }
425
653
  }
426
654
  }
427
655
  async function readStateFile(directory, sessionID) {
428
656
  const target = statePath(directory, sessionID);
657
+ await assertStatePathSafe(directory, target);
429
658
  const attempts = 5;
430
659
  for (let attempt = 0;attempt < attempts; attempt++) {
431
660
  try {
432
661
  const parsed = JSON.parse(await fs.readFile(target, "utf8"));
433
- return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] };
662
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.jobs)) {
663
+ throw new SyntaxError("invalid Loop state schema: jobs must be an array");
664
+ }
665
+ return { version: 4, jobs: parsed.jobs };
434
666
  } catch (error) {
435
667
  if (error?.code === "ENOENT")
436
668
  return { version: 4, jobs: [] };
@@ -444,7 +676,7 @@ async function readStateFile(directory, sessionID) {
444
676
  await ensureDir(stateDir(directory));
445
677
  await fs.copyFile(target, `${target}.corrupt-${Date.now()}`);
446
678
  } catch {}
447
- return { version: 4, jobs: [] };
679
+ throw new Error(`OpenCode Loop state at ${target} could not be read; refusing to replace persisted jobs.`);
448
680
  }
449
681
  async function readState(directory, sessionID) {
450
682
  const state = await readStateFile(directory, sessionID);
@@ -574,25 +806,31 @@ function mergeStateJobs(baseJobs, intendedJobs, currentJobs) {
574
806
  }
575
807
  async function writeState(directory, sessionID, state) {
576
808
  await withStateWriteLock(directory, sessionID, async () => {
577
- await ensureDir(stateDir(directory));
578
809
  const target = statePath(directory, sessionID);
810
+ await assertStatePathSafe(directory, target);
811
+ const current = await readStateFile(directory, sessionID);
579
812
  const baseline = state?.[STATE_BASELINE];
580
813
  let jobs = structuredClone(state.jobs || []);
581
814
  if (Array.isArray(baseline)) {
582
- const current = await readStateFile(directory, sessionID);
583
815
  jobs = mergeStateJobs(baseline, jobs, current.jobs || []);
584
- state.jobs = structuredClone(jobs);
585
- state[STATE_BASELINE] = structuredClone(jobs);
586
816
  }
587
817
  const payload = JSON.stringify({ version: 4, jobs }, null, 2);
588
818
  await writeFileAtomically(target, payload);
819
+ state.jobs = structuredClone(jobs);
820
+ if (Array.isArray(baseline))
821
+ state[STATE_BASELINE] = structuredClone(jobs);
589
822
  });
590
823
  }
591
824
  async function removeState(directory, sessionID) {
592
825
  await withStateWriteLock(directory, sessionID, async () => {
826
+ const target = statePath(directory, sessionID);
827
+ await assertStatePathSafe(directory, target);
593
828
  try {
594
- await fs.unlink(statePath(directory, sessionID));
595
- } catch {}
829
+ await fs.unlink(target);
830
+ } catch (error) {
831
+ if (error?.code !== "ENOENT")
832
+ throw error;
833
+ }
596
834
  });
597
835
  }
598
836