@abelxiaoxing/cadence 2.0.1 → 2.0.3

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.
@@ -1,6 +1,10 @@
1
1
  /** Native attachment only. No SDK/auth resolution, worker or storage mutation. */
2
2
 
3
- import { Agent } from "undici";
3
+ import { existsSync, realpathSync } from "node:fs";
4
+ import { homedir, userInfo } from "node:os";
5
+ import { isAbsolute, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { Agent, Dispatcher1Wrapper } from "undici";
4
8
  import { nativeBuild } from "./native-build.ts";
5
9
  import { ServiceCoordination } from "./service/coordination.ts";
6
10
  import { ServiceClient } from "./service-client.ts";
@@ -10,10 +14,138 @@ import {
10
14
  type SessionBootstrap,
11
15
  } from "./service-contract.ts";
12
16
  import { HttpServiceTransport } from "./service-http-client.ts";
17
+ import { effectiveWorkspace } from "./task-store.ts";
13
18
 
14
19
  export const MANAGEMENT_GUIDANCE =
15
20
  "Cadence owner absent, unavailable or incompatible. Explicitly run cadence-service status --workspace <path>, cadence-service diagnose --workspace <path>, or cadence-service start --workspace <path>. Stop older Cadence sessions first. For retained fences use cadence-service recover inspect; resolve requires a local terminal and separate process, credential and storage acknowledgments. Never treat unavailable/quarantined as stopped. No service was started and no embedded executor is available.";
16
21
 
22
+ const UNIT = "cadence.service";
23
+ /** Exists only when systemd booted the machine (sd_booted); without it there
24
+ * is no user manager, and `autostart install` reports unavailable. */
25
+ const SYSTEMD_BOOTED = "/run/systemd/system";
26
+ const xdg = (value: string | undefined, fallback: string) =>
27
+ value && isAbsolute(value) ? value : fallback;
28
+ const cliPath = () =>
29
+ JSON.stringify(fileURLToPath(new URL("./service-cli.mjs", import.meta.url)));
30
+ /** CADENCE_LINGER=0 keeps the explicit command login-only, too. */
31
+ const installCommand = (env: NodeJS.ProcessEnv) =>
32
+ `node ${cliPath()} autostart install${env.CADENCE_LINGER === "0" ? "" : " --linger"}`;
33
+
34
+ /** Linux: npm may skip or hide the Pi install hook that registers the user
35
+ * unit, so name the explicit command when it is absent. Reads two paths only. */
36
+ export function autostartHint(): string {
37
+ if (process.platform !== "linux" || !existsSync(SYSTEMD_BOOTED)) return "";
38
+ const data = xdg(
39
+ process.env.XDG_DATA_HOME,
40
+ join(homedir(), ".local", "share"),
41
+ );
42
+ if (existsSync(join(data, "systemd", "user", UNIT))) return "";
43
+ return `\nTo run the shared service and WebUI (default http://127.0.0.1:17423) ${process.env.CADENCE_LINGER === "0" ? "at login" : "at boot"}: ${installCommand(process.env)}`;
44
+ }
45
+
46
+ export interface AutostartObservation {
47
+ platform: NodeJS.Platform;
48
+ env: NodeJS.ProcessEnv;
49
+ home: string;
50
+ /** Package root; only Pi's managed user copy registers itself. */
51
+ root: string;
52
+ /** logind keeps one flag file per lingering user name. */
53
+ lingerFile: string;
54
+ /** Exists only on a systemd-booted machine. */
55
+ systemd: string;
56
+ }
57
+ function hostObservation(): AutostartObservation {
58
+ let user = "";
59
+ try {
60
+ user = userInfo().username;
61
+ } catch {
62
+ // No passwd entry: linger is unobservable, so it reads as off.
63
+ }
64
+ return {
65
+ platform: process.platform,
66
+ env: process.env,
67
+ home: homedir(),
68
+ root: fileURLToPath(new URL("..", import.meta.url)),
69
+ lingerFile: user ? join("/var/lib/systemd/linger", user) : "",
70
+ systemd: SYSTEMD_BOOTED,
71
+ };
72
+ }
73
+ /** Same selection as Pi's getAgentDir: PI_CODING_AGENT_DIR or ~/.pi/agent. */
74
+ function piUserCopy(o: AutostartObservation): boolean {
75
+ const selected = o.env.PI_CODING_AGENT_DIR;
76
+ const agent = !selected
77
+ ? join(o.home, ".pi", "agent")
78
+ : resolve(
79
+ selected === "~"
80
+ ? o.home
81
+ : selected.startsWith("~/")
82
+ ? join(o.home, selected.slice(2))
83
+ : selected,
84
+ );
85
+ try {
86
+ const managed = join(
87
+ agent,
88
+ "npm",
89
+ "node_modules",
90
+ "@abelxiaoxing",
91
+ "cadence",
92
+ );
93
+ return realpathSync(managed) === realpathSync(o.root);
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+ /** Startup-card line while boot start of the Linux user unit needs a human
99
+ * step. Path observations only: nothing is created, spawned or started. A
100
+ * machine without systemd, a disabled or (also runtime-)masked unit and
101
+ * CADENCE_AUTOSTART=0 are quiet; CADENCE_LINGER=0 never suggests linger. */
102
+ export function autostartNotice(
103
+ o: AutostartObservation = hostObservation(),
104
+ ): string | undefined {
105
+ if (
106
+ o.platform !== "linux" ||
107
+ o.env.CADENCE_AUTOSTART === "0" ||
108
+ !existsSync(o.systemd)
109
+ )
110
+ return;
111
+ const data = xdg(o.env.XDG_DATA_HOME, join(o.home, ".local", "share"));
112
+ const config = xdg(o.env.XDG_CONFIG_HOME, join(o.home, ".config"));
113
+ const boot = o.env.CADENCE_LINGER !== "0";
114
+ if (!existsSync(join(data, "systemd", "user", UNIT)))
115
+ return piUserCopy(o)
116
+ ? `Cadence服务:未注册${boot ? "开机" : ""}自启(npm 可能跳过或隐藏了安装脚本)。注册并${boot ? "开机" : "登录后"}启动:${installCommand(o.env)}`
117
+ : undefined;
118
+ const user = join(config, "systemd", "user");
119
+ const runtime = o.env.XDG_RUNTIME_DIR;
120
+ if (
121
+ existsSync(join(user, UNIT)) ||
122
+ (runtime &&
123
+ isAbsolute(runtime) &&
124
+ existsSync(join(runtime, "systemd", "user", UNIT))) ||
125
+ !existsSync(join(user, "default.target.wants", UNIT)) ||
126
+ o.env.CADENCE_LINGER === "0" ||
127
+ existsSync(o.lingerFile)
128
+ )
129
+ return;
130
+ return "Cadence服务:linger 未开启,仅在登录后启动、注销时停止。开机启动:loginctl enable-linger";
131
+ }
132
+
133
+ /** Pi exits right after its session_shutdown handlers settle. An unsent
134
+ * detach leaves the attachment holding one of the owner's viewer slots until
135
+ * its idle expiry, so a departing session waits briefly for the detach; a
136
+ * slow or absent owner never holds Pi's quit longer than `ms`. */
137
+ export async function departure(detaching: Promise<void>, ms = 1500) {
138
+ let timer: ReturnType<typeof setTimeout> | undefined;
139
+ await Promise.race([
140
+ detaching.catch(() => {}),
141
+ new Promise<void>((resolve) => {
142
+ timer = setTimeout(resolve, ms);
143
+ timer.unref?.();
144
+ }),
145
+ ]);
146
+ clearTimeout(timer);
147
+ }
148
+
17
149
  export class NativeConnection {
18
150
  private transport?: HttpServiceTransport;
19
151
  private network?: Agent;
@@ -28,10 +160,17 @@ export class NativeConnection {
28
160
  // Failed detach keeps its exact transport/slot. Explicit re-entry may retry
29
161
  // detach, but never forget it and allocate a replacement on uncertainty.
30
162
  await this.detach();
31
- const access = new ServiceCoordination(cwd).localAccess();
163
+ // Pi started in the home directory attaches to the ~/cadence landing owner,
164
+ // the same one the management client starts there. Observation only.
165
+ const access = new ServiceCoordination(
166
+ effectiveWorkspace(cwd).workspace,
167
+ ).localAccess();
32
168
  this.epoch = access.epoch;
33
169
  const network = new Agent();
34
170
  this.network = network;
171
+ // Node 22/24 built-in fetch bundles undici 6/7, whose legacy handlers an
172
+ // undici 8 Agent rejects. The public wrapper adapts them on every line.
173
+ const dispatcher = new Dispatcher1Wrapper(network);
35
174
  this.transport = new HttpServiceTransport({
36
175
  origin: access.endpoint,
37
176
  localCapability: access.capability,
@@ -43,7 +182,7 @@ export class NativeConnection {
43
182
  // process routes provider auth through a proxy. Public per-request Node
44
183
  // dispatcher only: never replace/restore the parent's global dispatcher.
45
184
  fetch: (url, init) =>
46
- globalThis.fetch(url, Object.assign({}, init, { dispatcher: network })),
185
+ globalThis.fetch(url, Object.assign({}, init, { dispatcher })),
47
186
  });
48
187
  const client = new ServiceClient(this.transport);
49
188
  this.client = client;
@@ -136,11 +136,8 @@ function safeDirectory(path, create = false, inspectExisting) {
136
136
  at = join(at, part);
137
137
  let existing;
138
138
  try {
139
- const st = lstatSync(at);
140
- if (!st.isDirectory())
141
- throw new Error("Unsafe directory");
142
139
  const checked = lstatSync(at);
143
- if (checked.isSymbolicLink() || inspectExisting && !checked.isDirectory())
140
+ if (!checked.isDirectory() || checked.isSymbolicLink())
144
141
  throw new Error("Unsafe directory");
145
142
  existing = checked;
146
143
  } catch (error) {
@@ -154,7 +151,8 @@ function safeDirectory(path, create = false, inspectExisting) {
154
151
  if (!object(mkdirError) || mkdirError.code !== "EEXIST")
155
152
  throw mkdirError;
156
153
  }
157
- if (!lstatSync(at).isDirectory() || lstatSync(at).isSymbolicLink())
154
+ const created = lstatSync(at);
155
+ if (!created.isDirectory() || created.isSymbolicLink())
158
156
  throw new Error("Unsafe directory");
159
157
  }
160
158
  if (existing)
@@ -162,13 +160,29 @@ function safeDirectory(path, create = false, inspectExisting) {
162
160
  }
163
161
  return true;
164
162
  }
165
- function readBounded(path, max) {
163
+ var transientWindows = new Set(["EPERM", "EACCES", "EBUSY"]);
164
+ function retryWindowsFileOperation(operation, recheck) {
165
+ if (process.platform !== "win32")
166
+ return operation();
167
+ const deadline = performance.now() + 2000;
168
+ for (let delay = 2;; delay = Math.min(delay * 2, 50)) {
169
+ try {
170
+ return operation();
171
+ } catch (error) {
172
+ if (!object(error) || !transientWindows.has(String(error.code)) || performance.now() >= deadline)
173
+ throw error;
174
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
175
+ recheck?.();
176
+ }
177
+ }
178
+ }
179
+ function readBounded(path, max, packaged = false) {
166
180
  if (!safeDirectory(dirname(path)))
167
181
  return;
168
182
  let fd;
169
183
  try {
170
184
  const st = lstatSync(path);
171
- if (!st.isFile() || st.isSymbolicLink() || st.nlink !== 1 || st.size > max)
185
+ if (!st.isFile() || st.isSymbolicLink() || !packaged && st.nlink !== 1 || st.size > max)
172
186
  throw new Error("Unsafe or oversized file");
173
187
  fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
174
188
  } catch (error) {
@@ -178,7 +192,7 @@ function readBounded(path, max) {
178
192
  }
179
193
  try {
180
194
  const st = fstatSync(fd);
181
- if (!st.isFile() || st.nlink !== 1 || st.size > max)
195
+ if (!st.isFile() || !packaged && st.nlink !== 1 || st.size > max)
182
196
  throw new Error("Unsafe or oversized file");
183
197
  const buffer = Buffer.alloc(max + 1);
184
198
  let size = 0;
@@ -256,6 +270,23 @@ function canonicalAmbientRoot(path) {
256
270
  return join(canonicalAmbientRoot(parent), full.slice(parent.length));
257
271
  }
258
272
  }
273
+ function privateRootFor(canonicalRoot, stateHome) {
274
+ return join(canonicalAmbientRoot(stateHome), "cadence-assistant-v1", createHash("sha256").update(canonicalRoot).digest("hex"));
275
+ }
276
+ function contains(root, path) {
277
+ const within = relative(root, resolve(path));
278
+ return !within || !within.startsWith(`..${sep}`) && within !== ".." && !isAbsolute(within);
279
+ }
280
+ function stateHomeInside(root, stateHome = defaultStateHome()) {
281
+ const canonical = realpathSync(root);
282
+ return contains(canonical, privateRootFor(canonical, stateHome));
283
+ }
284
+ var LANDING_WORKSPACE = "cadence";
285
+ function effectiveWorkspace(requested, stateHome, home = homedir()) {
286
+ if (!stateHomeInside(resolveWorkspace(requested).root, stateHome))
287
+ return { workspace: requested, redirected: false };
288
+ return { workspace: join(home, LANDING_WORKSPACE), redirected: true };
289
+ }
259
290
 
260
291
  class TaskStore {
261
292
  root;
@@ -264,9 +295,8 @@ class TaskStore {
264
295
  constructor(root, stateHome = defaultStateHome()) {
265
296
  this.root = realpathSync(root);
266
297
  this.tasks = join(this.root, ".cadence", "assistant", "tasks");
267
- this.privateRoot = join(canonicalAmbientRoot(stateHome), "cadence-assistant-v1", createHash("sha256").update(this.root).digest("hex"));
268
- const within = relative(this.root, resolve(this.privateRoot));
269
- if (!within || !within.startsWith(`..${sep}`) && within !== ".." && !isAbsolute(within))
298
+ this.privateRoot = privateRootFor(this.root, stateHome);
299
+ if (contains(this.root, this.privateRoot))
270
300
  throw new Error("Private state home must be outside the working copy");
271
301
  }
272
302
  get lockPath() {
@@ -318,7 +348,7 @@ class TaskStore {
318
348
  };
319
349
  let fd;
320
350
  try {
321
- fd = openSync(this.lockPath, "wx", 384);
351
+ fd = retryWindowsFileOperation(() => openSync(this.lockPath, "wx", 384));
322
352
  } catch {
323
353
  throw new Error("Checkout busy or unresolved lock; inspect and explicitly recover only a dead owner");
324
354
  }
@@ -338,7 +368,7 @@ class TaskStore {
338
368
  if (lock.pid !== process.pid || lock.host !== hostname())
339
369
  throw new Error("Only the owning process may release a lock");
340
370
  this.assertOwner(lock);
341
- unlinkSync(this.lockPath);
371
+ retryWindowsFileOperation(() => unlinkSync(this.lockPath), () => this.assertOwner(lock));
342
372
  }
343
373
  recover(expected, acknowledgeEscapedProcesses) {
344
374
  if (!acknowledgeEscapedProcesses || expected.host !== hostname())
@@ -418,7 +448,12 @@ class TaskStore {
418
448
  const latest = readBounded(join(dir, "task.json"), MAX_RECORD_BYTES);
419
449
  if ((latest ? contentIdentity(latest) : undefined) !== expectedIdentity)
420
450
  throw new Error("Task content changed; replacement preserved without overwrite");
421
- renameSync(temporary, join(dir, "task.json"));
451
+ retryWindowsFileOperation(() => renameSync(temporary, join(dir, "task.json")), () => {
452
+ this.assertOwner(lock);
453
+ const now = readBounded(join(dir, "task.json"), MAX_RECORD_BYTES);
454
+ if ((now ? contentIdentity(now) : undefined) !== expectedIdentity)
455
+ throw new Error("Task content changed; replacement preserved without overwrite");
456
+ });
422
457
  return next;
423
458
  } finally {
424
459
  if (!held)
@@ -459,8 +494,9 @@ class TaskStore {
459
494
  var TASK_CONFIG_SCHEMA = "cadence-assistant-checks/1";
460
495
 
461
496
  // src/operator-tools.ts
497
+ var observed = (cwd) => resolveWorkspace(effectiveWorkspace(cwd).workspace);
462
498
  function inspectConsumer(cwd) {
463
- const workspace = resolveWorkspace(cwd);
499
+ const workspace = observed(cwd);
464
500
  const store = new TaskStore(workspace.root);
465
501
  const tasks = store.list();
466
502
  const checks = store.checks();
@@ -481,7 +517,7 @@ function inspectConsumer(cwd) {
481
517
  };
482
518
  }
483
519
  function inspectRuns(cwd) {
484
- const workspace = resolveWorkspace(cwd);
520
+ const workspace = observed(cwd);
485
521
  const store = new TaskStore(workspace.root);
486
522
  return {
487
523
  ok: true,
@@ -19,6 +19,7 @@ import {
19
19
  text,
20
20
  } from "./contracts/core.ts";
21
21
  import {
22
+ DirectoryWalks,
22
23
  encoded,
23
24
  type FileSnapshot,
24
25
  hash,
@@ -282,8 +283,8 @@ export class ServiceCoordination {
282
283
  },
283
284
  };
284
285
  }
285
- assertNamespace(): void {
286
- if (!this.files.directory(this.directory)) refused();
286
+ assertNamespace(walks?: DirectoryWalks): void {
287
+ if (!this.files.directory(this.directory, false, walks)) refused();
287
288
  names(this.directory, [
288
289
  "format.json",
289
290
  "config.json",
@@ -295,13 +296,18 @@ export class ServiceCoordination {
295
296
  "host-config.json",
296
297
  "history-v2",
297
298
  ]);
298
- this.files.directory(join(this.directory, "history-v2"));
299
+ this.files.directory(join(this.directory, "history-v2"), false, walks);
299
300
  }
300
301
  private selected(): Selected | undefined {
301
- if (!this.files.directory(this.directory)) return undefined;
302
- this.assertNamespace();
303
- const manifest = this.files.read(this.path("format.json"));
304
- const config = this.files.read(this.path("config.json"));
302
+ const walks = new DirectoryWalks();
303
+ if (!this.files.directory(this.directory, false, walks)) return undefined;
304
+ this.assertNamespace(walks);
305
+ const manifest = this.files.read(
306
+ this.path("format.json"),
307
+ undefined,
308
+ walks,
309
+ );
310
+ const config = this.files.read(this.path("config.json"), undefined, walks);
305
311
  if (!manifest || !config) refused();
306
312
  const f = closed(json(manifest), [
307
313
  "schema",
@@ -314,12 +320,24 @@ export class ServiceCoordination {
314
320
  literal(f.stateHome, this.stateHome);
315
321
  literal(f.configIdentity, config.identity);
316
322
  const c = decodeOwnerConfig(json(config));
317
- const hostConfig = this.files.read(this.path("host-config.json"));
323
+ const hostConfig = this.files.read(
324
+ this.path("host-config.json"),
325
+ undefined,
326
+ walks,
327
+ );
318
328
  if (hostConfig && hostConfig.identity !== c.policyIdentity) refused();
319
- const registration = this.files.read(this.path("registration.json"));
320
- const owner = this.files.read(this.path("owner.lock"));
321
- const capability = this.files.read(this.path("local-capability.json"));
322
- const checkout = this.files.read(this.store.lockPath, 8192);
329
+ const registration = this.files.read(
330
+ this.path("registration.json"),
331
+ undefined,
332
+ walks,
333
+ );
334
+ const owner = this.files.read(this.path("owner.lock"), undefined, walks);
335
+ const capability = this.files.read(
336
+ this.path("local-capability.json"),
337
+ undefined,
338
+ walks,
339
+ );
340
+ const checkout = this.files.read(this.store.lockPath, 8192, walks);
323
341
  const value = registration
324
342
  ? decodeRegistration(json(registration))
325
343
  : undefined;
@@ -808,8 +826,9 @@ export class OwnerLease {
808
826
  this.selected.value.host !== hostname()
809
827
  )
810
828
  throw new ContractError("conflict");
811
- const c = this.coordination;
812
- c.assertNamespace();
829
+ const c = this.coordination,
830
+ walks = new DirectoryWalks();
831
+ c.assertNamespace(walks);
813
832
  for (const [name, snapshot] of [
814
833
  ["config.json", this.selected.config],
815
834
  ["host-config.json", this.selected.hostConfig],
@@ -818,8 +837,8 @@ export class OwnerLease {
818
837
  ["owner.lock", this.selected.owner],
819
838
  ["local-capability.json", this.selected.capability],
820
839
  ] as const)
821
- c.files.assert(c.path(name), snapshot);
822
- c.files.assert(c.store.lockPath, this.selected.checkout, 8192);
840
+ c.files.assert(c.path(name), snapshot, undefined, walks);
841
+ c.files.assert(c.store.lockPath, this.selected.checkout, 8192, walks);
823
842
  c.store.assertOwner(this.checkout);
824
843
  }
825
844
  phase(phase: OwnerPhase): void {
@@ -5,14 +5,16 @@ import {
5
5
  fstatSync,
6
6
  fsyncSync,
7
7
  lstatSync,
8
+ mkdirSync,
8
9
  openSync,
10
+ readSync,
9
11
  renameSync,
10
12
  type Stats,
11
13
  unlinkSync,
12
14
  writeFileSync,
13
15
  } from "node:fs";
14
16
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
15
- import { readBounded, safeDirectory } from "../task-store.ts";
17
+ import { retryWindowsFileOperation, safeDirectory } from "../task-store.ts";
16
18
  import { ContractError, parseWireJson } from "./contracts/core.ts";
17
19
 
18
20
  export const METADATA_BYTES = 16 * 1024;
@@ -24,10 +26,14 @@ export function refused(): never {
24
26
  export function hash(bytes: string | Buffer): string {
25
27
  return createHash("sha256").update(bytes).digest("hex");
26
28
  }
27
- function privateMode(st: Stats): void {
29
+ /** Owner-only mode for state Cadence creates. External sources it only reads
30
+ * (Pi's agent directory and files) keep whatever mode the user's umask gave
31
+ * them, such as 0755/0775 from Pi itself; ownership is still required. */
32
+ function privateMode(st: Stats, ownerOnly = true): void {
28
33
  if (
29
34
  process.platform !== "win32" &&
30
- ((st.mode & 0o077) !== 0 || (process.getuid && st.uid !== process.getuid()))
35
+ ((ownerOnly && (st.mode & 0o077) !== 0) ||
36
+ (process.getuid && st.uid !== process.getuid()))
31
37
  )
32
38
  refused();
33
39
  }
@@ -46,37 +52,132 @@ function same(a: Stats, b: Stats): boolean {
46
52
  function missing(error: unknown): boolean {
47
53
  return error instanceof Error && "code" in error && error.code === "ENOENT";
48
54
  }
55
+ // Advances once per job, at its first microtask checkpoint. Nothing queued
56
+ // retains a scope, so a dropped read budget is collected as usual.
57
+ let job = 0,
58
+ advancing = false;
59
+ function currentJob(): number {
60
+ if (!advancing) {
61
+ advancing = true;
62
+ queueMicrotask(() => {
63
+ job++;
64
+ advancing = false;
65
+ });
66
+ }
67
+ return job;
68
+ }
69
+ /** Directories that ONE synchronous operation already walked. Reuse re-observes
70
+ * the directory itself, no-follow, with the walked dev/ino and private mode: an
71
+ * ancestor substituted for other content resolves elsewhere and walks again. It
72
+ * goes stale at the next microtask checkpoint, so an await or a later call
73
+ * always walks; an ancestor's mode change inside one operation is seen by the
74
+ * next. Absence, refusal and any changed identity fall back to the full walk.
75
+ */
76
+ export class DirectoryWalks {
77
+ private readonly job = currentJob();
78
+ private readonly walked = new Map<string, { dev: number; ino: number }>();
79
+ private open(): boolean {
80
+ if (this.job === job) return true;
81
+ this.walked.clear();
82
+ return false;
83
+ }
84
+ remember(path: string, st: Stats): void {
85
+ if (this.open()) this.walked.set(path, { dev: st.dev, ino: st.ino });
86
+ }
87
+ reuse(path: string, ownerOnly = true): boolean {
88
+ const walked = this.open() ? this.walked.get(path) : undefined;
89
+ if (!walked) return false;
90
+ let st: Stats;
91
+ try {
92
+ st = lstatSync(path);
93
+ } catch (error) {
94
+ if (!missing(error)) throw error;
95
+ this.walked.delete(path);
96
+ return false;
97
+ }
98
+ if (
99
+ !st.isDirectory() ||
100
+ st.isSymbolicLink() ||
101
+ st.dev !== walked.dev ||
102
+ st.ino !== walked.ino
103
+ ) {
104
+ this.walked.delete(path);
105
+ return false;
106
+ }
107
+ privateMode(st, ownerOnly);
108
+ return true;
109
+ }
110
+ }
49
111
  /** Strengthens the existing helpers only inside the owned private namespace.
50
112
  * Ambient ancestors retain TaskStore's canonicalization policy. No directory CAS
51
113
  * or hostile-same-user guarantee; failed temporaries are deliberately preserved.
52
114
  */
53
115
  export class PrivateFiles {
54
- constructor(readonly namespace: string) {}
55
- directory(path: string, create = false): boolean {
116
+ /** `ownerOnly: false` for an external namespace such as Pi's agent directory. */
117
+ constructor(
118
+ readonly namespace: string,
119
+ readonly ownerOnly = true,
120
+ ) {}
121
+ directory(path: string, create = false, walks?: DirectoryWalks): boolean {
56
122
  const tail = relative(this.namespace, path);
57
123
  if (isAbsolute(tail) || tail === ".." || tail.startsWith(`..${sep}`))
58
124
  refused();
59
125
  if (!create) {
126
+ const full = resolve(path);
127
+ if (walks?.reuse(full, this.ownerOnly)) return true;
60
128
  const namespace = resolve(this.namespace);
61
- let inside = false;
62
- return safeDirectory(path, false, (at, st) => {
129
+ let inside = false,
130
+ last: Stats | undefined;
131
+ const found = safeDirectory(path, false, (at, st) => {
63
132
  // Exact segment boundary, not a textual prefix. One synchronous walk
64
133
  // checks every ambient ancestor and each existing private ancestor,
65
- // including an unsafe parent before a missing child. Nothing is cached.
134
+ // including an unsafe parent before a missing child. Only the caller's
135
+ // DirectoryWalks keeps the result, for that same synchronous operation.
66
136
  if (at === namespace) inside = true;
67
- if (inside) privateMode(st);
137
+ if (inside) privateMode(st, this.ownerOnly);
138
+ last = st;
68
139
  });
140
+ if (found && last) walks?.remember(full, last);
141
+ return found;
69
142
  }
143
+ // Walk the namespace and its ambient ancestors once, then check each
144
+ // private segment exactly once, creating a missing one privately.
145
+ if (!safeDirectory(this.namespace, true)) return false;
146
+ privateMode(lstatSync(this.namespace), this.ownerOnly);
70
147
  let at = this.namespace;
71
- for (const part of ["", ...tail.split(sep).filter(Boolean)]) {
72
- if (part) at = join(at, part);
73
- if (!safeDirectory(at, create)) return false;
74
- privateMode(lstatSync(at));
148
+ for (const part of tail.split(sep).filter(Boolean)) {
149
+ at = join(at, part);
150
+ let st: Stats;
151
+ try {
152
+ st = lstatSync(at);
153
+ } catch (error) {
154
+ if (!missing(error)) throw error;
155
+ try {
156
+ mkdirSync(at, { mode: 0o700 });
157
+ } catch (mkdirError) {
158
+ if (
159
+ !(
160
+ mkdirError instanceof Error &&
161
+ "code" in mkdirError &&
162
+ mkdirError.code === "EEXIST"
163
+ )
164
+ )
165
+ throw mkdirError;
166
+ }
167
+ st = lstatSync(at);
168
+ }
169
+ if (!st.isDirectory() || st.isSymbolicLink())
170
+ throw new Error("Unsafe directory");
171
+ privateMode(st, this.ownerOnly);
75
172
  }
76
173
  return true;
77
174
  }
78
- read(path: string, maximum = METADATA_BYTES): FileSnapshot | undefined {
79
- if (!this.directory(dirname(path))) return undefined;
175
+ read(
176
+ path: string,
177
+ maximum = METADATA_BYTES,
178
+ walks?: DirectoryWalks,
179
+ ): FileSnapshot | undefined {
180
+ if (!this.directory(dirname(path), false, walks)) return undefined;
80
181
  let before: Stats;
81
182
  try {
82
183
  before = lstatSync(path);
@@ -86,20 +187,25 @@ export class PrivateFiles {
86
187
  }
87
188
  if (!before.isFile() || before.nlink !== 1 || before.size > maximum)
88
189
  refused();
89
- privateMode(before);
190
+ privateMode(before, this.ownerOnly);
90
191
  const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
91
192
  try {
92
193
  const opened = fstatSync(fd);
93
194
  if (!same(before, opened)) refused();
94
- // Reuse the bounded no-follow reader; surround its read with descriptor and
95
- // selected-path identity checks rather than weakening legacy behavior.
96
- // The caller's independent maximum was already checked above. Bound the
97
- // inner allocation to this exact selected size (+1 growth sentinel); keep
98
- // its full ancestor/open checks and our final descriptor/path checks.
99
- const bytes = readBounded(path, opened.size);
195
+ // Read this already opened, no-follow, identity-checked descriptor: its
196
+ // parents were walked just above, so a second walk and open add nothing.
197
+ // Bound the read to the selected size (+1 growth sentinel) and keep the
198
+ // final descriptor/path identity checks.
199
+ const buffer = Buffer.alloc(opened.size + 1);
200
+ let size = 0;
201
+ while (size < buffer.length) {
202
+ const n = readSync(fd, buffer, size, buffer.length - size, null);
203
+ if (!n) break;
204
+ size += n;
205
+ }
206
+ const bytes = buffer.subarray(0, size);
100
207
  if (
101
- !bytes ||
102
- bytes.length !== opened.size ||
208
+ size !== opened.size ||
103
209
  !same(opened, fstatSync(fd)) ||
104
210
  !same(opened, lstatSync(path))
105
211
  )
@@ -113,8 +219,9 @@ export class PrivateFiles {
113
219
  path: string,
114
220
  expected: FileSnapshot | undefined,
115
221
  maximum = METADATA_BYTES,
222
+ walks?: DirectoryWalks,
116
223
  ): void {
117
- const current = this.read(path, maximum);
224
+ const current = this.read(path, maximum, walks);
118
225
  if (
119
226
  current?.identity !== expected?.identity ||
120
227
  current?.stamp !== expected?.stamp
@@ -124,7 +231,7 @@ export class PrivateFiles {
124
231
  create(path: string, bytes: string, maximum = METADATA_BYTES): FileSnapshot {
125
232
  if (Buffer.byteLength(bytes) > maximum) throw new ContractError("oversize");
126
233
  this.directory(dirname(path), true);
127
- const fd = openSync(path, "wx", 0o600);
234
+ const fd = retryWindowsFileOperation(() => openSync(path, "wx", 0o600));
128
235
  try {
129
236
  writeFileSync(fd, bytes);
130
237
  fsyncSync(fd);
@@ -146,7 +253,10 @@ export class PrivateFiles {
146
253
  const temporary = join(dirname(path), `pending-${randomUUID()}.json`);
147
254
  this.create(temporary, bytes, maximum);
148
255
  this.assert(path, expected, maximum);
149
- renameSync(temporary, path);
256
+ retryWindowsFileOperation(
257
+ () => renameSync(temporary, path),
258
+ () => this.assert(path, expected, maximum),
259
+ );
150
260
  this.syncDirectory(dirname(path));
151
261
  const result = this.read(path, maximum);
152
262
  if (!result || result.identity !== hash(bytes)) refused();
@@ -154,7 +264,10 @@ export class PrivateFiles {
154
264
  }
155
265
  remove(path: string, expected: FileSnapshot, maximum = METADATA_BYTES): void {
156
266
  this.assert(path, expected, maximum);
157
- unlinkSync(path);
267
+ retryWindowsFileOperation(
268
+ () => unlinkSync(path),
269
+ () => this.assert(path, expected, maximum),
270
+ );
158
271
  this.syncDirectory(dirname(path));
159
272
  }
160
273
  private syncDirectory(path: string): void {