@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.
- package/README.md +104 -44
- package/docs/reuse-and-distribution.md +3 -2
- package/docs/service-management.md +62 -3
- package/package.json +10 -7
- package/src/agent-panel-cadence.ts +651 -0
- package/src/agent-panel-model.ts +521 -0
- package/src/agent-panel.ts +474 -0
- package/src/index.ts +12 -4
- package/src/install-config.mjs +25 -0
- package/src/native-client.ts +142 -3
- package/src/operator-cli.mjs +52 -16
- package/src/service/coordination.ts +35 -16
- package/src/service/private-files.ts +141 -28
- package/src/service-cli.mjs +793 -149
- package/src/service-host.mjs +847 -319
- package/src/task-shell.ts +27 -14
- package/src/task-store.ts +104 -25
- package/src/worker-tools.mjs +42 -17
- package/web/dist/assets/cadence.css +1 -1
- package/web/dist/assets/cadence.js +7 -7
- package/web/dist/manifest.json +6 -6
package/src/native-client.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/** Native attachment only. No SDK/auth resolution, worker or storage mutation. */
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
185
|
+
globalThis.fetch(url, Object.assign({}, init, { dispatcher })),
|
|
47
186
|
});
|
|
48
187
|
const client = new ServiceClient(this.transport);
|
|
49
188
|
this.client = client;
|
package/src/operator-cli.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
268
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
302
|
-
this.
|
|
303
|
-
|
|
304
|
-
const
|
|
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(
|
|
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(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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(
|
|
79
|
-
|
|
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
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|