@staix/agent-mem 0.20.3 → 0.22.0
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/.claude-plugin/plugin.json +1 -1
- package/README.md +30 -3
- package/package.json +2 -2
- package/src/cli.ts +36 -3
- package/src/commands-table.ts +3 -0
- package/src/hook-spool.ts +45 -3
- package/src/index.ts +31 -2
- package/src/memory/bridge.ts +66 -18
- package/src/memory/candidates.ts +71 -9
- package/src/memory/commands.ts +3 -2
- package/src/memory/generation-index.ts +95 -18
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/generation.ts +33 -15
- package/src/memory/platform.ts +38 -3
- package/src/memory/policy.ts +28 -15
- package/src/memory/prune-publisher-lineage.ts +50 -12
- package/src/memory/public-prune-activation.ts +4 -4
- package/src/memory/public-prune-advance.ts +11 -5
- package/src/memory/public-prune-inputs.ts +17 -16
- package/src/memory/shard-sync-applications.ts +153 -0
- package/src/memory/shard-sync-format.ts +15 -0
- package/src/memory/shard-sync-graph.ts +6 -4
- package/src/memory/shard-sync-history.ts +238 -0
- package/src/memory/shard-sync.ts +66 -151
- package/src/memory/shard.ts +32 -6
- package/src/memory/store.ts +14 -1
- package/src/memory/sync-binding.ts +63 -0
- package/src/memory/sync-cadence.ts +2 -1
- package/src/memory/sync-placement-abandon.ts +142 -0
- package/src/memory/sync-placement-activation-locks.ts +28 -0
- package/src/memory/sync-placement-activation-path.ts +2 -0
- package/src/memory/sync-placement-activation-proof.ts +68 -0
- package/src/memory/sync-placement-activation-storage.ts +167 -0
- package/src/memory/sync-placement-activation.ts +185 -0
- package/src/memory/sync-placement-active.ts +75 -0
- package/src/memory/sync-placement-cache.ts +32 -0
- package/src/memory/sync-placement-capture.ts +373 -0
- package/src/memory/sync-placement-dependencies.ts +255 -0
- package/src/memory/sync-placement-effective-writers.ts +84 -0
- package/src/memory/sync-placement-format.ts +182 -0
- package/src/memory/sync-placement-git.ts +95 -0
- package/src/memory/sync-placement-guard.ts +104 -0
- package/src/memory/sync-placement-inspection-evidence.ts +257 -0
- package/src/memory/sync-placement-inspection-pending.ts +66 -0
- package/src/memory/sync-placement-inspection.ts +240 -0
- package/src/memory/sync-placement-journal-format.ts +114 -0
- package/src/memory/sync-placement-journal-storage.ts +557 -0
- package/src/memory/sync-placement-journal.ts +160 -0
- package/src/memory/sync-placement-materialize.ts +149 -0
- package/src/memory/sync-placement-plan.ts +171 -0
- package/src/memory/sync-placement-prerequisites.ts +50 -0
- package/src/memory/sync-placement-projection.ts +79 -0
- package/src/memory/sync-placement-publication-plan.ts +189 -0
- package/src/memory/sync-placement-publication-storage.ts +87 -0
- package/src/memory/sync-placement-transport.ts +500 -0
- package/src/memory/sync-placement-writer-history.ts +278 -0
- package/src/memory/sync-placement-writer-renewal.ts +179 -0
- package/src/memory/sync-placement-writer-revision-format.ts +122 -0
- package/src/memory/sync-placement-writer-roots.ts +167 -0
- package/src/memory/sync-placement-writers.ts +135 -0
- package/src/memory/sync-policy-inspection.ts +126 -0
- package/src/memory/sync-screen.ts +128 -33
- package/src/memory/sync.ts +19 -64
- package/src/setup.ts +101 -14
- package/src/sync-placement-writer-context.ts +136 -0
- package/src/ui/inbox-cache.ts +219 -0
- package/src/ui/inbox-worker-version.ts +14 -0
- package/src/ui/inbox-worker.ts +32 -0
- package/src/ui/inbox.ts +281 -0
- package/src/ui/panel.js +7 -3
- package/src/ui/plugin.ts +26 -53
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { closeSync, constants, fstatSync, openSync,
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { types } from "node:util";
|
|
2
4
|
import { join } from "node:path";
|
|
5
|
+
import { OP_CONTRACTS } from "./ops-contract.ts";
|
|
6
|
+
import { SUGGESTED } from "./store.ts";
|
|
7
|
+
import { checkedEntry } from "./bridge.ts";
|
|
3
8
|
import { loadAmemConfig } from "./config.ts";
|
|
4
|
-
import { createStandalonePolicy } from "./policy.ts";
|
|
9
|
+
import { createStandalonePolicy, type StandalonePolicyDecline } from "./policy.ts";
|
|
5
10
|
import type { MemoryStore } from "./store.ts";
|
|
6
11
|
|
|
7
12
|
/**
|
|
@@ -19,7 +24,8 @@ import type { MemoryStore } from "./store.ts";
|
|
|
19
24
|
* ponytail: a person clears a declined entry by removing the offending pattern or editing the policy; sync does not skip a row, and `amem forget` is an
|
|
20
25
|
* append-only op that leaves the original row in the log. Upgrade: a per-row placement that keeps a declined note local without refusing the sync.
|
|
21
26
|
*/
|
|
22
|
-
const
|
|
27
|
+
export const SYNC_SCREEN_MAX_BYTES = 8 * 1024 * 1024, SYNC_SCREEN_MAX_ROWS = 20_000;
|
|
28
|
+
const MAX_BYTES = SYNC_SCREEN_MAX_BYTES, MAX_ROWS = SYNC_SCREEN_MAX_ROWS;
|
|
23
29
|
|
|
24
30
|
export type SyncScreen = { ok: true } | { ok: false; reason: string };
|
|
25
31
|
|
|
@@ -27,19 +33,55 @@ export type SyncScreen = { ok: true } | { ok: false; reason: string };
|
|
|
27
33
|
* What a row says: everything except the fields the library itself generates (ids, hashes, times, the op and enum names, machine and actor ids). Left IN on purpose
|
|
28
34
|
* are `project`, `peer`, `task`, `about`, `title`, `text`, `milestones` and any field this file has never heard of: an unknown text field must be screened, never skipped.
|
|
29
35
|
*/
|
|
30
|
-
const GENERATED = new Set(["id", "schema", "op", "at", "machine", "actor", "
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
const GENERATED = new Set(["id", "schema", "op", "at", "machine", "actor", "promotedFrom", "supersedes", "expires", "tier", "scope", "kind", "source"]);
|
|
37
|
+
/** Only a known operation's validated id/enum/time operand is generated metadata.
|
|
38
|
+
* A task key, an unknown operand field or an unsupported operand shape remains content. */
|
|
39
|
+
function generatedOperand(operation: unknown, key: string, value: unknown): boolean {
|
|
40
|
+
if (key !== "target" && key !== "by" || typeof operation !== "string" || !Object.hasOwn(OP_CONTRACTS, operation)) return false;
|
|
41
|
+
const field = OP_CONTRACTS[operation]?.payloadSchema?.fields[key];
|
|
42
|
+
if (typeof value !== "string" || !field) return false;
|
|
43
|
+
if (field.format === "id") return /^[a-f0-9]{32}$/.test(value);
|
|
44
|
+
if (field.format === "utc") return Number.isFinite(Date.parse(value));
|
|
45
|
+
if (field.values) return field.values.includes(value);
|
|
46
|
+
// Suggestion operands are a closed relation enum plus an immutable entry id, not task text.
|
|
47
|
+
if (operation === "suggest" && key === "by") {
|
|
48
|
+
const match = /^([^:]+):[a-f0-9]{32}$/.exec(value);
|
|
49
|
+
return match !== null && (SUGGESTED as readonly string[]).includes(match[1]!);
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function syncOutboundContent(row: unknown): Record<string, unknown> {
|
|
55
|
+
if (!row || typeof row !== "object" || Array.isArray(row) || types.isProxy(row) || Object.getOwnPropertySymbols(row).length) throw new Error("outbound row unavailable");
|
|
56
|
+
const out: Record<string, unknown> = {}, descriptors = Object.getOwnPropertyDescriptors(row);
|
|
57
|
+
const operation = descriptors.op?.value;
|
|
58
|
+
for (const [key, descriptor] of Object.entries(descriptors)) {
|
|
59
|
+
if (GENERATED.has(key) || generatedOperand(operation, key, descriptor.value)) continue;
|
|
60
|
+
if (!("value" in descriptor) || !descriptor.enumerable) throw new Error("outbound row unavailable");
|
|
61
|
+
Object.defineProperty(out, key, { value: descriptor.value, enumerable: true, writable: true, configurable: true });
|
|
62
|
+
}
|
|
34
63
|
return out;
|
|
35
64
|
}
|
|
36
65
|
|
|
37
66
|
/** A row passes when the machine's configured policy and the caller's extra screen (if any) both accept its text. */
|
|
38
67
|
export type RowScreen = (row: Record<string, unknown>) => boolean;
|
|
39
|
-
export
|
|
68
|
+
export type SyncPolicyCategory = StandalonePolicyDecline | "host";
|
|
69
|
+
export type OutboundVerdict = { ok: true } | { ok: false; category: SyncPolicyCategory };
|
|
70
|
+
/** One evaluator for actual sync and public inspection, with the same projection and AND composition. */
|
|
71
|
+
export function outboundPolicyEvaluator(home: string, extra?: RowScreen): (row: Record<string, unknown>) => OutboundVerdict {
|
|
40
72
|
const { config } = loadAmemConfig(home);
|
|
41
73
|
const policy = createStandalonePolicy({ denyPaths: config.denyPaths, piiPatterns: config.piiPatterns });
|
|
42
|
-
return row => {
|
|
74
|
+
return row => {
|
|
75
|
+
const text = syncOutboundContent(row), own = policy.inspectStandalone(text);
|
|
76
|
+
if (!own.ok) return own;
|
|
77
|
+
const accepted = extra === undefined ? true : extra(text);
|
|
78
|
+
if (typeof accepted !== "boolean") throw new Error("outbound policy unavailable");
|
|
79
|
+
return accepted ? { ok: true } : { ok: false, category: "host" };
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function outboundScreen(home: string, extra?: RowScreen): RowScreen {
|
|
83
|
+
const evaluate = outboundPolicyEvaluator(home, extra);
|
|
84
|
+
return row => evaluate(row).ok;
|
|
43
85
|
}
|
|
44
86
|
const refusal = (declined: string[]): SyncScreen => declined.length ? { ok: false, reason: `the configured privacy policy declines ${declined.length} long-term entr${declined.length === 1 ? "y" : "ies"} (${declined.slice(0, 8).join(", ")}${declined.length > 8 ? ", ..." : ""}); fix the entry or the policy, then sync again` } : { ok: true };
|
|
45
87
|
const idOf = (row: Record<string, unknown>) => typeof row.id === "string" ? row.id.slice(0, 8) : "(no id)";
|
|
@@ -48,39 +90,92 @@ const idOf = (row: Record<string, unknown>) => typeof row.id === "string" ? row.
|
|
|
48
90
|
export function screenOutboundRows(store: MemoryStore, pass: RowScreen): SyncScreen {
|
|
49
91
|
const declined: string[] = [];
|
|
50
92
|
let rows = 0;
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
93
|
+
// Actual sync retains its existing canonical refresh/recovery snapshot. Public inspection uses the read-only
|
|
94
|
+
// source visitor instead, so it never performs this derived-state mutation or adopts a torn prune recovery tail.
|
|
95
|
+
try {
|
|
96
|
+
const snapshot = store.prunePort().snapshot();
|
|
97
|
+
for (const source of snapshot.rows) {
|
|
98
|
+
if (source.placement !== "synced" || !(source.own || source.legacy)) continue;
|
|
99
|
+
if (++rows > MAX_ROWS) return { ok: false, reason: `more than ${MAX_ROWS} long-term rows, so they were not screened` };
|
|
100
|
+
if (!pass(source.row as unknown as Record<string, unknown>)) declined.push(idOf(source.row as unknown as Record<string, unknown>));
|
|
101
|
+
}
|
|
102
|
+
} catch { return { ok: false, reason: "this store's long-term rows cannot be read, so they were not screened; inspect the local inputs and reopen the store before retrying" }; }
|
|
59
103
|
return refusal(declined);
|
|
60
104
|
}
|
|
61
105
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
106
|
+
export class SyncScreenInputError extends Error {
|
|
107
|
+
constructor(readonly code: "input-unavailable" | "input-bound" | "input-changed", readonly bound?: "bytes" | "rows") { super(code); }
|
|
108
|
+
}
|
|
109
|
+
const sameStat = (a: BigIntStats, b: BigIntStats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
|
|
110
|
+
|
|
111
|
+
/** One bounded physical segment. The returned check pins it across the other opened targets and host callbacks too. */
|
|
112
|
+
export function visitOwnSegment(dir: string, machineId: string, visit: (row: Record<string, unknown>) => void): () => void {
|
|
113
|
+
const parent = join(dir, "log"), parentStat = lstatSync(parent, { bigint: true, throwIfNoEntry: false });
|
|
114
|
+
if (parentStat && (!parentStat.isDirectory() || parentStat.isSymbolicLink())) throw new SyncScreenInputError("input-unavailable");
|
|
115
|
+
const checkParent = () => {
|
|
116
|
+
const now = lstatSync(parent, { bigint: true, throwIfNoEntry: false });
|
|
117
|
+
if (parentStat ? !now || !now.isDirectory() || now.isSymbolicLink() || now.dev !== parentStat.dev || now.ino !== parentStat.ino : now !== undefined) throw new SyncScreenInputError("input-changed");
|
|
118
|
+
};
|
|
119
|
+
const file = join(parent, `${machineId}.jsonl`);
|
|
65
120
|
let fd: number;
|
|
66
121
|
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
67
122
|
catch (error) {
|
|
68
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return
|
|
69
|
-
|
|
123
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return () => {
|
|
124
|
+
checkParent();
|
|
125
|
+
if (lstatSync(file, { throwIfNoEntry: false })) throw new SyncScreenInputError("input-changed");
|
|
126
|
+
};
|
|
127
|
+
throw new SyncScreenInputError("input-unavailable");
|
|
70
128
|
}
|
|
71
129
|
try {
|
|
72
|
-
const stat = fstatSync(fd);
|
|
73
|
-
if (!stat.isFile() || stat.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
130
|
+
const stat = fstatSync(fd, { bigint: true });
|
|
131
|
+
if (!stat.isFile() || stat.nlink !== 1n) throw new SyncScreenInputError("input-unavailable");
|
|
132
|
+
if (stat.size > BigInt(MAX_BYTES)) throw new SyncScreenInputError("input-bound", "bytes");
|
|
133
|
+
const check = () => {
|
|
134
|
+
checkParent();
|
|
135
|
+
const named = lstatSync(file, { bigint: true });
|
|
136
|
+
if (!named.isFile() || named.isSymbolicLink() || !sameStat(stat, named)) throw new SyncScreenInputError("input-changed");
|
|
137
|
+
};
|
|
138
|
+
check();
|
|
139
|
+
// No whole-file string/split array: the stat bound precedes allocation; the row bound precedes parsing each row.
|
|
140
|
+
const bytes = Buffer.alloc(Number(stat.size));
|
|
141
|
+
let at = 0;
|
|
142
|
+
while (at < bytes.length) { const n = readSync(fd, bytes, at, bytes.length - at, at); if (!n) throw new SyncScreenInputError("input-changed"); at += n; }
|
|
143
|
+
if (bytes.length && bytes.at(-1) !== 10) throw new SyncScreenInputError("input-unavailable");
|
|
144
|
+
let start = 0, rows = 0;
|
|
145
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
146
|
+
for (let end = 0; end < bytes.length; end++) {
|
|
147
|
+
if (bytes[end] !== 10) continue;
|
|
148
|
+
if (start === end) { start = end + 1; continue; }
|
|
149
|
+
const line = decoder.decode(bytes.subarray(start, end)); start = end + 1;
|
|
77
150
|
if (!line.trim()) continue;
|
|
78
|
-
if (++rows > MAX_ROWS)
|
|
79
|
-
let row:
|
|
80
|
-
try { row = JSON.parse(line)
|
|
81
|
-
|
|
82
|
-
if (!pass(row)) declined.push(idOf(row));
|
|
151
|
+
if (++rows > MAX_ROWS) throw new SyncScreenInputError("input-bound", "rows");
|
|
152
|
+
let row: unknown;
|
|
153
|
+
try { row = checkedEntry(JSON.parse(line)); } catch { throw new SyncScreenInputError("input-unavailable"); }
|
|
154
|
+
visit(row as Record<string, unknown>);
|
|
83
155
|
}
|
|
84
|
-
|
|
156
|
+
const expected = createHash("sha256").update(bytes).digest("hex"), digest = createHash("sha256"), buffer = Buffer.alloc(65_536);
|
|
157
|
+
at = 0;
|
|
158
|
+
while (at < bytes.length) { const n = readSync(fd, buffer, 0, Math.min(buffer.length, bytes.length - at), at); if (!n) throw new SyncScreenInputError("input-changed"); digest.update(buffer.subarray(0, n)); at += n; }
|
|
159
|
+
if (expected !== digest.digest("hex") || !sameStat(stat, fstatSync(fd, { bigint: true }))) throw new SyncScreenInputError("input-changed");
|
|
160
|
+
check(); return check;
|
|
85
161
|
} finally { closeSync(fd); }
|
|
86
162
|
}
|
|
163
|
+
|
|
164
|
+
/** Screen one machine's own long-term segment under `dir`, using the inspector's same bounded reader. */
|
|
165
|
+
export function screenOwnSegment(dir: string, machineId: string, pass: RowScreen): SyncScreen {
|
|
166
|
+
const declined: string[] = [];
|
|
167
|
+
try { visitOwnSegment(dir, machineId, row => { if (!pass(row)) declined.push(idOf(row)); })(); }
|
|
168
|
+
catch (error) {
|
|
169
|
+
let code: unknown, bound: unknown;
|
|
170
|
+
if (error && typeof error === "object" && !types.isProxy(error)) {
|
|
171
|
+
code = Object.getOwnPropertyDescriptor(error, "code")?.value;
|
|
172
|
+
bound = Object.getOwnPropertyDescriptor(error, "bound")?.value;
|
|
173
|
+
}
|
|
174
|
+
const inspect = "; inspect the local input and reopen the store before retrying";
|
|
175
|
+
if (code === "input-bound" && bound === "rows") return { ok: false, reason: `more than ${MAX_ROWS} long-term rows, so they were not screened${inspect}` };
|
|
176
|
+
if (code === "input-bound" && bound === "bytes") return { ok: false, reason: `this machine's long-term segment exceeds ${MAX_BYTES} bytes, so it was not screened${inspect}` };
|
|
177
|
+
if (code === "input-changed") return { ok: false, reason: "this machine's long-term segment changed while being screened, so it was not screened; wait for writers to finish and retry" };
|
|
178
|
+
return { ok: false, reason: "this machine's long-term segment is not readable as complete valid rows, so it was not screened" + inspect };
|
|
179
|
+
}
|
|
180
|
+
return refusal(declined);
|
|
181
|
+
}
|
package/src/memory/sync.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import type { PlacementCapturePort } from "./sync-placement-capture.ts";
|
|
2
|
+
import { placementRuntimeRefusal } from "./sync-placement-guard.ts";
|
|
1
3
|
import { assertFormatFloor } from "./formats.ts";
|
|
2
|
-
import { closeSync, constants, existsSync,
|
|
3
|
-
import {
|
|
4
|
-
import { dirname, join, relative, sep } from "node:path";
|
|
4
|
+
import { closeSync, constants, existsSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
5
6
|
import { rebindPublicPruneProfile } from "./generation.ts";
|
|
6
7
|
import { memoryGitArgv, memoryGitEnv, processLiveness, processSignature } from "./platform.ts";
|
|
7
8
|
import { acquireMutation } from "./mutation.ts";
|
|
8
9
|
import type { MemoryStore } from "./store.ts";
|
|
9
10
|
import { syncGenerationMemory } from "./generation-sync.ts";
|
|
10
11
|
import { outboundScreen, screenOutboundRows, screenOwnSegment, type RowScreen } from "./sync-screen.ts";
|
|
12
|
+
import { shardSyncBinding } from "./sync-binding.ts";
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* #258 phase 2: hub memory between a person's machines through a git remote they configure. The memory directory is a
|
|
@@ -16,6 +18,8 @@ import { outboundScreen, screenOutboundRows, screenOwnSegment, type RowScreen }
|
|
|
16
18
|
*/
|
|
17
19
|
export type Visibility = "public" | "private" | "unknown";
|
|
18
20
|
export interface SyncResult { ok: boolean; pushed: boolean; entries: number; warning?: string; error?: string;
|
|
21
|
+
placementPublication?: { attemptHash?: string; outcome: "committed" | "unknown" | "not-needed"; withheld: number; exported: number;
|
|
22
|
+
lastPolicyHash?: string; lastPolicyCheckUnixMs?: number; policyCheckSource?: "live" | "durable-intent"; policyCeiling: "last-positive-check-before-in-flight-push"; recovered?: boolean };
|
|
19
23
|
pruneApplication?: {transport:"durable"|"unconfirmed";proofComplete:number;projectionActive:number|null;nativeApplied:number|null;held:number;phase:"preflight"|"prepared"|"active"|"complete"};
|
|
20
24
|
}
|
|
21
25
|
|
|
@@ -214,6 +218,8 @@ function lock(dir: string): (() => void) | undefined {
|
|
|
214
218
|
}
|
|
215
219
|
|
|
216
220
|
export interface SyncOptions {
|
|
221
|
+
/** Fresh compatibility facts for an already activated v3 profile; never activates a legacy profile or bypasses policy. */
|
|
222
|
+
placementAttestation?: PlacementCapturePort["attest"];
|
|
217
223
|
/** Set (or replace) the remote first: a person's choice. */
|
|
218
224
|
remote?: string;
|
|
219
225
|
visibility?: (url: string) => Promise<Visibility>;
|
|
@@ -225,67 +231,13 @@ export interface SyncOptions {
|
|
|
225
231
|
signal?: AbortSignal;
|
|
226
232
|
}
|
|
227
233
|
|
|
228
|
-
|
|
229
|
-
export function shardSyncBinding(store: MemoryStore): string { return captureShardSyncBinding(store, true); }
|
|
230
|
-
/** Selection bytes may change only after this sync commits; source/policy identity remains pinned across that commit. */
|
|
231
|
-
export function shardSyncSourceBinding(store: MemoryStore): string { return captureShardSyncBinding(store, false); }
|
|
232
|
-
function captureShardSyncBinding(store: MemoryStore, selection: boolean): string {
|
|
233
|
-
const unavailable = (): never => { throw new Error("sharded sync binding unavailable"); };
|
|
234
|
-
if (!store.profile.sharded) return unavailable();
|
|
235
|
-
const uid = process.getuid?.(); if (uid === undefined) return unavailable();
|
|
236
|
-
const same = (a: BigIntStats, b: BigIntStats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
|
|
237
|
-
const owned = (stat: BigIntStats, directory: boolean, privateMode = true) => {
|
|
238
|
-
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1n)
|
|
239
|
-
|| stat.uid !== BigInt(uid) || privateMode && (stat.mode & 0o077n) !== 0n) unavailable();
|
|
240
|
-
};
|
|
241
|
-
const home = store.profile.home, digest = createHash("sha256"), directories = new Map<string, BigIntStats>(), checks: (() => void)[] = [];
|
|
242
|
-
const emit = (value: unknown) => { digest.update(JSON.stringify(value) + "\n"); };
|
|
243
|
-
const directory = (dir: string): void => {
|
|
244
|
-
if (directories.has(dir)) return;
|
|
245
|
-
const rel = relative(home, dir); if (rel === ".." || rel.startsWith(".." + sep)) unavailable();
|
|
246
|
-
if (dir !== home) directory(dirname(dir));
|
|
247
|
-
const before = lstatSync(dir, { bigint: true }); owned(before, true); directories.set(dir, before);
|
|
248
|
-
emit(["directory", dir, String(before.dev), String(before.ino)]);
|
|
249
|
-
checks.push(() => { const now = lstatSync(dir, { bigint: true }); owned(now, true); if (!same(before, now)) unavailable(); });
|
|
250
|
-
};
|
|
251
|
-
// Policy config keeps its established file modes inside this already-private owned home. Its complete bytes/inode still bind the epoch.
|
|
252
|
-
const source = (file: string, privateMode = true): void => {
|
|
253
|
-
directory(dirname(file));
|
|
254
|
-
let before: BigIntStats;
|
|
255
|
-
try { before = lstatSync(file, { bigint: true }); }
|
|
256
|
-
catch (error) {
|
|
257
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
258
|
-
emit(["source", file, "absent"]);
|
|
259
|
-
checks.push(() => { if (lstatSync(file, { bigint: true, throwIfNoEntry: false }) !== undefined) unavailable(); }); return;
|
|
260
|
-
}
|
|
261
|
-
owned(before, false, privateMode);
|
|
262
|
-
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
263
|
-
try {
|
|
264
|
-
const opened = fstatSync(fd, { bigint: true }); owned(opened, false, privateMode); if (!same(before, opened)) unavailable();
|
|
265
|
-
const content = createHash("sha256"), buffer = Buffer.allocUnsafe(65_536); let bytes = 0n;
|
|
266
|
-
for (;;) { const n = readSync(fd, buffer, 0, buffer.length, null); if (!n) break; content.update(buffer.subarray(0, n)); bytes += BigInt(n); }
|
|
267
|
-
const after = fstatSync(fd, { bigint: true }); owned(after, false, privateMode);
|
|
268
|
-
if (bytes !== before.size || !same(before, after) || !same(before, lstatSync(file, { bigint: true }))) unavailable();
|
|
269
|
-
emit(["source", file, "present", String(before.dev), String(before.ino), String(before.size), String(before.mtimeNs), String(before.ctimeNs), content.digest("hex")]);
|
|
270
|
-
} finally { closeSync(fd); }
|
|
271
|
-
checks.push(() => { const now = lstatSync(file, { bigint: true }); owned(now, false, privateMode); if (!same(before, now)) unavailable(); });
|
|
272
|
-
};
|
|
273
|
-
try {
|
|
274
|
-
directory(home);
|
|
275
|
-
for (const name of [...(selection ? ["profile.json", "shards.json"] : []), "aliases.jsonl", "amem.config.json", "machine.json"]) source(join(home, name), name !== "amem.config.json");
|
|
276
|
-
const targets = [...store.openedSyncTargets()].sort((a, b) => a.dir.localeCompare(b.dir));
|
|
277
|
-
for (const target of targets) {
|
|
278
|
-
directory(target.dir);
|
|
279
|
-
emit(["target", target.dir, target.profile.generation ?? null, target.machine.id, target.profile.shardUser ?? false, target.profile.shardKey ?? null]);
|
|
280
|
-
for (const name of ["project.json", "generation.json", "generation-seal.json", "machine.json"]) source(join(target.dir, name));
|
|
281
|
-
}
|
|
282
|
-
for (const check of checks) check();
|
|
283
|
-
return digest.digest("hex");
|
|
284
|
-
} catch { return unavailable(); }
|
|
285
|
-
}
|
|
234
|
+
export { shardSyncBinding, shardSyncSourceBinding } from "./sync-binding.ts";
|
|
286
235
|
|
|
287
236
|
export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Promise<SyncResult> {
|
|
288
237
|
assertFormatFloor(store.profile.home, "sync");
|
|
238
|
+
const placementRefusal = placementRuntimeRefusal(store.profile.home, store.profile);
|
|
239
|
+
if (placementRefusal) return { ok: false, pushed: false, entries: 0, error: placementRefusal };
|
|
240
|
+
if (store.profile.publicationFormat === "agent-mem.sync-placement/v1") return (await import("./sync-placement-transport.ts")).syncPlacementMemory(store, opts);
|
|
289
241
|
if (store.profile.sharded) {
|
|
290
242
|
try {
|
|
291
243
|
// Refresh only through the existing proved monotone profile rebind before pinning this attempt.
|
|
@@ -297,20 +249,23 @@ export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Pr
|
|
|
297
249
|
assertBinding();
|
|
298
250
|
const screen = outboundScreen(store.profile.home, opts.screen);
|
|
299
251
|
const pass: RowScreen = row => { assertBinding(); const accepted = screen(row); assertBinding(); if (typeof accepted !== "boolean") throw new Error("invalid screen result"); return accepted; };
|
|
252
|
+
const errors: string[] = [];
|
|
300
253
|
for (const target of store.openedSyncTargets()) {
|
|
301
254
|
const screened = screenOutboundRows(target, pass); assertBinding();
|
|
302
|
-
if (!screened.ok)
|
|
255
|
+
if (!screened.ok) errors.push(screened.reason);
|
|
303
256
|
}
|
|
257
|
+
if (errors.length) return { ok: false, pushed: false, entries: 0, error: errors.join("; ") };
|
|
304
258
|
const transport = await import("./shard-sync.ts"); assertBinding();
|
|
305
259
|
return transport.syncShardedMemory(store, opts);
|
|
306
260
|
} catch { return { ok: false, pushed: false, entries: 0, error: "sharded sync binding or policy unavailable or changed" }; }
|
|
307
261
|
}
|
|
308
262
|
// F4: before anything is staged or pushed, this machine's own long-term notes pass the person's configured policy (every profile: single log, generation, shards).
|
|
309
|
-
const pass = outboundScreen(store.profile.home, opts.screen);
|
|
263
|
+
const pass = outboundScreen(store.profile.home, opts.screen), errors: string[] = [];
|
|
310
264
|
for (const target of store.openedSyncTargets()) {
|
|
311
265
|
const screened = target.profile.kind === "generation" ? screenOutboundRows(target, pass) : screenOwnSegment(target.dir, target.machine.id, pass);
|
|
312
|
-
if (!screened.ok)
|
|
266
|
+
if (!screened.ok) errors.push(screened.reason);
|
|
313
267
|
}
|
|
268
|
+
if (errors.length) return { ok: false, pushed: false, entries: 0, error: errors.join("; ") };
|
|
314
269
|
if (store.profile.kind === "generation") return syncGenerationMemory(store, opts);
|
|
315
270
|
const dir = store.dir;
|
|
316
271
|
const fail = (error: string): SyncResult => ({ ok: false, pushed: false, entries: 0, error });
|
package/src/setup.ts
CHANGED
|
@@ -10,11 +10,11 @@ import { JCODE_CAPTURE_EVENTS, putJcodeHooks } from "./setup-jcode.ts";
|
|
|
10
10
|
const jcodeHookCommand = (bun: string, entry: string) => `${quote(bun)} --smol ${quote(entry)} hook jcode`;
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
12
|
import { createHash, randomUUID } from "node:crypto";
|
|
13
|
-
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, opendirSync, readFileSync, readSync, renameSync, rmdirSync, rmSync, writeFileSync, type BigIntStats } from "node:fs";
|
|
14
14
|
import { homedir, tmpdir } from "node:os";
|
|
15
15
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { isDeepStrictEqual } from "node:util";
|
|
17
|
+
import { isDeepStrictEqual, types } from "node:util";
|
|
18
18
|
import { HOOK_EVENTS } from "./hook-contract.ts";
|
|
19
19
|
import { memoryHome } from "./memory/home.ts";
|
|
20
20
|
import { label, PLAIN, singleLine, type Style } from "./memory/style.ts";
|
|
@@ -209,20 +209,16 @@ function assertOpencodeLayout(c: ReturnType<typeof context>, mode: SetupPlan["mo
|
|
|
209
209
|
}
|
|
210
210
|
function intent(c: ReturnType<typeof context>, target: SetupTarget): string { return digest([1, target, c.paths[target], c.bun, c.entry, ...(["codex", "kimi", "qwen"].includes(target) ? ["capture-json-v1"] : []), ...(target === "jcode" ? ["jcode-hooks-v1"] : []), ...(["claude", "codex", "kimi", "pi", "qwen", "jcode"].includes(target) ? ["root-hooks-v2"] : []), ...(target === "agy" ? [c.workspace!.root] : [])]); }
|
|
211
211
|
function journal(c: ReturnType<typeof context>, target: SetupTarget): string { return join(c.state, target === "agy" ? `agy-${digest(c.workspace!.root)}.json` : `${target}.json`); }
|
|
212
|
-
function
|
|
213
|
-
|
|
214
|
-
const value = record(JSON.parse(Buffer.from(raw.bytes, "base64").toString("utf8")));
|
|
212
|
+
function decodeReceipt(value: Record<string, unknown>, target: SetupTarget, count: number, strict = false): Receipt {
|
|
213
|
+
if (strict && Object.keys(value).some(key => !["version", "target", "intent", "phase", "changes", "createdDirectory"].includes(key))) throw Error("setup receipt invalid");
|
|
215
214
|
if (value.version !== 1 && value.version !== 2) throw Error("setup receipt version is unsupported; run setup or undo with the release that wrote it");
|
|
216
|
-
if (value.target !== target || typeof value.intent !== "string" || !/^[a-f0-9]{64}$/.test(value.intent) ||
|
|
215
|
+
if (value.target !== target || typeof value.intent !== "string" || !/^[a-f0-9]{64}$/.test(value.intent) || strict && value.intent.length !== 64 || !["prepared", "applied"].includes(String(value.phase)) || !Array.isArray(value.changes) || value.changes.length !== count && !(target === "jcode" && value.changes.length === 1)) throw Error("setup receipt does not match this installation");
|
|
217
216
|
for (let i = 0; i < value.changes.length; i++) {
|
|
218
|
-
const change = record(value.changes[i]);
|
|
219
|
-
if (target
|
|
220
|
-
&& resolveSetupPaths({ ...c.env, CLAUDE_CONFIG_DIR: dirname(change.path) }, c.home).redirects.some(row => row.from === change.path && row.to === currentPath))
|
|
221
|
-
throw Error("setup receipt was written from an Orca Claude profile; rerun setup from that Orca profile");
|
|
222
|
-
if (change.path !== currentPath && !legacyRedirect || change.key !== `${target}:${i}`) throw Error("setup receipt target mismatch");
|
|
217
|
+
const change = record(value.changes[i]);
|
|
218
|
+
if (change.key !== `${target}:${i}` || strict && (Object.keys(change).some(key => !["key", "path", "before", "after", "start"].includes(key)) || typeof change.path !== "string" || !isAbsolute(change.path) || change.path.length > 4096 || change.path.includes("\0"))) throw Error("setup receipt target mismatch");
|
|
223
219
|
for (const key of ["before", "after", ...(change.start !== undefined ? ["start"] : [])]) {
|
|
224
220
|
const v = record(change[key]);
|
|
225
|
-
if (!Number.isInteger(v.mode) || Number(v.mode) < 0 || Number(v.mode) > 0o777 || v.bytes !== null && (typeof v.bytes !== "string" || v.bytes.length > 2 * MAX || Buffer.from(v.bytes, "base64").toString("base64") !== v.bytes)) throw Error("setup receipt invalid");
|
|
221
|
+
if (strict && Object.keys(v).sort().join() !== "bytes,mode" || !Number.isInteger(v.mode) || Number(v.mode) < 0 || Number(v.mode) > 0o777 || v.bytes !== null && (typeof v.bytes !== "string" || v.bytes.length > 2 * MAX || strict && (v.bytes.length > Math.ceil(MAX / 3) * 4 || v.bytes.length / 4 * 3 - (v.bytes.endsWith("==") ? 2 : v.bytes.endsWith("=") ? 1 : 0) > MAX) || Buffer.from(v.bytes, "base64").toString("base64") !== v.bytes)) throw Error("setup receipt invalid");
|
|
226
222
|
}
|
|
227
223
|
}
|
|
228
224
|
if (value.createdDirectory !== undefined) {
|
|
@@ -231,6 +227,19 @@ function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRunt
|
|
|
231
227
|
}
|
|
232
228
|
return value as unknown as Receipt;
|
|
233
229
|
}
|
|
230
|
+
function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRuntime = false): Receipt | undefined {
|
|
231
|
+
const raw = read(journal(c, target), JOURNAL_MAX); if (raw.bytes === null) return;
|
|
232
|
+
const value = decodeReceipt(record(JSON.parse(Buffer.from(raw.bytes, "base64").toString("utf8"))), target, c.paths[target].length);
|
|
233
|
+
if (requireRuntime && value.intent !== intent(c, target)) throw Error("setup receipt does not match this installation");
|
|
234
|
+
for (let i = 0; i < value.changes.length; i++) {
|
|
235
|
+
const change = value.changes[i]!, currentPath = c.paths[target][i], legacyRedirect = c.redirects.some(row => row.target === target && row.from === change.path && row.to === currentPath);
|
|
236
|
+
if (target === "claude" && i === 0 && typeof change.path === "string" && change.path !== currentPath && !legacyRedirect
|
|
237
|
+
&& resolveSetupPaths({ ...c.env, CLAUDE_CONFIG_DIR: dirname(change.path) }, c.home).redirects.some(row => row.from === change.path && row.to === currentPath))
|
|
238
|
+
throw Error("setup receipt was written from an Orca Claude profile; rerun setup from that Orca profile");
|
|
239
|
+
if (change.path !== currentPath && !legacyRedirect) throw Error("setup receipt target mismatch");
|
|
240
|
+
}
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
234
243
|
function plannedHooks(c: ReturnType<typeof context>, target: SetupTarget): PlannedHook[] {
|
|
235
244
|
if (target === "jcode") return JCODE_CAPTURE_EVENTS.map(event => ({ event, command: jcodeHookCommand(c.bun, c.entry), timeout: 1 }));
|
|
236
245
|
if (target === "opencode" || target === "gemini" || target === "agy") return [];
|
|
@@ -407,9 +416,14 @@ function previousRuntimes(target: SetupTarget, images: readonly Image[], bun: st
|
|
|
407
416
|
return [...found.values()];
|
|
408
417
|
}
|
|
409
418
|
/** The runtime of a generated `<bun> --smol <entry> mcp` server record, if the record is exactly that shape. */
|
|
410
|
-
function launcher(value: unknown): { bun: string; entry: string } | undefined {
|
|
419
|
+
function launcher(value: unknown, target?: SetupTarget): { bun: string; entry: string } | undefined {
|
|
411
420
|
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
412
|
-
const server = value as Record<string, unknown
|
|
421
|
+
const server = value as Record<string, unknown>;
|
|
422
|
+
if (target === "opencode") {
|
|
423
|
+
if (server.type !== "local" || !Array.isArray(server.command) || server.command.length !== 4) return;
|
|
424
|
+
return launcher({ command: server.command[0], args: server.command.slice(1) });
|
|
425
|
+
}
|
|
426
|
+
const args = server.args;
|
|
413
427
|
return typeof server.command === "string" && isAbsolute(server.command) && Array.isArray(args) && args.length === 3
|
|
414
428
|
&& args[0] === "--smol" && typeof args[1] === "string" && isAbsolute(args[1]) && args[2] === "mcp" ? { bun: server.command, entry: args[1] } : undefined;
|
|
415
429
|
}
|
|
@@ -720,3 +734,76 @@ export async function runSetup(target: string, mode: SetupPlan["mode"], options:
|
|
|
720
734
|
const result = mode === "apply" ? applySetup(plan, options) : undoSetup(plan, options);
|
|
721
735
|
return options.json ? JSON.stringify(result, null, 2) : setupResultText(result, s);
|
|
722
736
|
}
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
/** Fixed safe category only; receipt text, native paths and external error messages are never diagnostics. */
|
|
740
|
+
export class SetupWriterRootsError extends Error {
|
|
741
|
+
readonly code = "setup_writer_roots_unavailable";
|
|
742
|
+
constructor(readonly category: "unsafe" | "changed" | "bound" | "unsupported" | "pending" | "unavailable") {
|
|
743
|
+
super(`Setup writer inventory ${category}; have the person inspect setup receipts and rerun setup before activation.`);
|
|
744
|
+
this.name = "SetupWriterRootsError";
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/** Current managed MCP AFTER images only. Native configuration paths and historical BEFORE/START images are never opened. */
|
|
748
|
+
export function readSetupWriterRoots(home: string): string[] {
|
|
749
|
+
function fail(category: SetupWriterRootsError["category"]): never { throw new SetupWriterRootsError(category); }
|
|
750
|
+
try {
|
|
751
|
+
const owned = (stat: BigIntStats) => { if (stat.isSymbolicLink() || stat.uid !== BigInt(process.getuid?.() ?? -1) || (stat.mode & 0o077n) !== 0n) fail("unsafe"); };
|
|
752
|
+
const stamp = (stat: BigIntStats) => JSON.stringify([stat.dev, stat.ino, stat.uid, stat.mode, stat.nlink, stat.size, stat.mtimeNs, stat.ctimeNs].map(String));
|
|
753
|
+
const base = lstatSync(home, { bigint: true, throwIfNoEntry: false }); if (!base) return []; owned(base); if (!base.isDirectory()) fail("unsafe");
|
|
754
|
+
const directory = join(home, "setup"), initial = lstatSync(directory, { bigint: true, throwIfNoEntry: false });
|
|
755
|
+
if (!initial) return [];
|
|
756
|
+
owned(initial); if (!initial.isDirectory()) fail("unsafe");
|
|
757
|
+
// ponytail:256 setup namespace entries and doctor's existing32MiB aggregate receipt-byte ceiling.
|
|
758
|
+
// A reviewed metadata-only setup inventory is required before increasing either ceiling.
|
|
759
|
+
const files: { name: string; stat: BigIntStats }[] = [], namespace: string[] = [], iterator = opendirSync(directory);
|
|
760
|
+
let entries = 0, totalBytes = 0;
|
|
761
|
+
try { for (;;) {
|
|
762
|
+
const entry = iterator.readSync(); if (!entry) break;
|
|
763
|
+
namespace.push(entry.name);
|
|
764
|
+
if (++entries > 256) fail("bound"); if (entry.name === "setup.lock") fail("pending");
|
|
765
|
+
if (!entry.name.endsWith(".json")) continue; // Retained .json.stale-* and unregistered temps are historical, never current authority.
|
|
766
|
+
const stat = lstatSync(join(directory, entry.name), { bigint: true }); owned(stat); if (!stat.isFile() || stat.nlink !== 1n) fail("unsafe");
|
|
767
|
+
if (stat.size > BigInt(JOURNAL_MAX) || totalBytes + Number(stat.size) > 32 * 1024 * 1024) fail("bound");
|
|
768
|
+
totalBytes += Number(stat.size); files.push({ name: entry.name, stat });
|
|
769
|
+
} } finally { iterator.closeSync(); }
|
|
770
|
+
const roots = new Set<string>(), launchers: { entry: string; root: string }[] = [];
|
|
771
|
+
for (const file of files.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
772
|
+
const target = (file.name.startsWith("agy-") && /^agy-[a-f0-9]{64}\.json$/.test(file.name) ? "agy" : file.name.slice(0, -5)) as SetupTarget;
|
|
773
|
+
if (!SETUP_TARGETS.includes(target) || target === "agy" && !/^agy-[a-f0-9]{64}\.json$/.test(file.name)) fail("unsupported");
|
|
774
|
+
const path = join(directory, file.name), fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
775
|
+
let text: string;
|
|
776
|
+
try {
|
|
777
|
+
const opened = fstatSync(fd, { bigint: true }); owned(opened); if (!opened.isFile() || opened.nlink !== 1n || stamp(opened) !== stamp(file.stat)) fail("changed");
|
|
778
|
+
const data = Buffer.alloc(Number(opened.size) + 1); let count = 0;
|
|
779
|
+
while (count < data.length) { const n = readSync(fd, data, count, data.length - count, count); if (!n) break; count += n; }
|
|
780
|
+
if (count !== Number(opened.size) || stamp(fstatSync(fd, { bigint: true })) !== stamp(opened) || stamp(lstatSync(path, { bigint: true })) !== stamp(opened)) fail("changed");
|
|
781
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(data.subarray(0, count));
|
|
782
|
+
} finally { closeSync(fd); }
|
|
783
|
+
const counts: Record<SetupTarget, number> = { claude: 2, codex: 2, kimi: 2, pi: 2, qwen: 1, jcode: 2, opencode: 1, gemini: 1, agy: 1 };
|
|
784
|
+
let value: Receipt;
|
|
785
|
+
try { value = decodeReceipt(record(JSON.parse(text)), target, counts[target], true); } catch { return fail("unsupported"); }
|
|
786
|
+
if (value.phase !== "applied") fail("pending");
|
|
787
|
+
const runtime = launcher(mcpRecord(target, value.changes[0]!.after), target);
|
|
788
|
+
if (!runtime || runtime.bun.includes("\0") || runtime.entry.length > 4096 || runtime.entry.includes("\0") || !["/bin/amem.js", "/src/cli.ts"].some(suffix => runtime.entry.endsWith(suffix))
|
|
789
|
+
|| target === "opencode" && [runtime.bun, runtime.entry].some(value => /\{(?:env|file):/.test(value))) fail("unsupported");
|
|
790
|
+
const root = realPath(dirname(dirname(runtime.entry))); if (!lstatSync(root).isDirectory()) fail("unavailable");
|
|
791
|
+
roots.add(root); launchers.push({ entry: runtime.entry, root });
|
|
792
|
+
}
|
|
793
|
+
const finalHome = lstatSync(home, { bigint: true }); owned(finalHome);
|
|
794
|
+
if (!finalHome.isDirectory() || stamp(lstatSync(directory, { bigint: true })) !== stamp(initial) || finalHome.dev !== base.dev || finalHome.ino !== base.ino) fail("changed");
|
|
795
|
+
const finalNames: string[] = [], finalDirectory = opendirSync(directory);
|
|
796
|
+
try { for (;;) { const entry = finalDirectory.readSync(); if (!entry) break; if (finalNames.length >= namespace.length) fail("changed"); finalNames.push(entry.name); } }
|
|
797
|
+
finally { finalDirectory.closeSync(); }
|
|
798
|
+
if (JSON.stringify(finalNames.sort()) !== JSON.stringify(namespace.sort())) fail("changed");
|
|
799
|
+
for (const file of files) if (stamp(lstatSync(join(directory, file.name), { bigint: true })) !== stamp(file.stat)) fail("changed");
|
|
800
|
+
for (const runtime of launchers) if (realPath(dirname(dirname(runtime.entry))) !== runtime.root) fail("changed");
|
|
801
|
+
return [...roots].sort();
|
|
802
|
+
} catch (error) {
|
|
803
|
+
if (error && typeof error === "object" && !types.isProxy(error) && error instanceof SetupWriterRootsError) {
|
|
804
|
+
const category = Object.getOwnPropertyDescriptor(error, "category");
|
|
805
|
+
if (category && "value" in category && ["unsafe", "changed", "bound", "unsupported", "pending", "unavailable"].includes(category.value)) throw new SetupWriterRootsError(category.value);
|
|
806
|
+
}
|
|
807
|
+
throw new SetupWriterRootsError("unavailable");
|
|
808
|
+
}
|
|
809
|
+
}
|