pi-session-mcp 0.8.0 → 0.8.2
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/.pi-session-mcp-cli/doctor.js +14 -7
- package/.pi-session-mcp-cli/override-lock.js +125 -0
- package/.pi-session-mcp-cli/setup-platform.js +195 -0
- package/.pi-session-mcp-cli/skill-customize.js +131 -46
- package/.pi-session-mcp-cli/skill-library.js +72 -4
- package/CHANGELOG.md +159 -0
- package/CONTRIBUTING.md +5 -3
- package/README.md +5 -3
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +14 -7
- package/dist/override-lock.d.ts +34 -0
- package/dist/override-lock.js +125 -0
- package/dist/pi-adapter.d.ts +6 -4
- package/dist/sdk-pi-adapter.js +28 -5
- package/dist/session-registry.d.ts +8 -6
- package/dist/session-registry.js +3 -2
- package/dist/setup-ownership.d.ts +4 -1
- package/dist/setup-ownership.js +6 -3
- package/dist/setup-skills.d.ts +46 -1
- package/dist/setup-skills.js +349 -78
- package/dist/setup.js +21 -4
- package/dist/skill-customize.d.ts +19 -1
- package/dist/skill-customize.js +131 -46
- package/dist/skill-library.d.ts +46 -1
- package/dist/skill-library.js +72 -4
- package/docs/acceptance.md +747 -26
- package/docs/adr/0007-profile-skill-selection.md +11 -0
- package/docs/architecture.md +1 -1
- package/docs/client-setup-design.md +157 -24
- package/docs/client-setup.md +38 -7
- package/docs/doctor-design.md +1 -1
- package/docs/installation.md +8 -2
- package/docs/pi-extensions-design.md +11 -0
- package/docs/releasing.md +25 -10
- package/docs/reviews/2026-10-13-post-0.8.1-audit.md +393 -0
- package/docs/roadmap.md +127 -0
- package/docs/skills.md +173 -16
- package/docs/sprints/2026-10-13-s1-correctness.md +355 -0
- package/docs/sprints/2026-10-13-s2-evidence.md +431 -0
- package/docs/sprints/2026-10-13-s3-live-usage.md +267 -0
- package/docs/sprints/2026-10-13-s4-decision-inputs.md +241 -0
- package/docs/threat-model.md +3 -3
- package/docs/tool-contracts.md +20 -2
- package/package.json +1 -1
- package/skills/pi-session-orchestration/SKILL.md +8 -4
|
@@ -8,7 +8,10 @@ export interface OwnershipRecord {
|
|
|
8
8
|
readonly transactionId: string;
|
|
9
9
|
/**
|
|
10
10
|
* Skill records only, while pending: the fingerprint of setup's own copy that the pending
|
|
11
|
-
* update replaces, so an interrupted update can still be completed or removed.
|
|
11
|
+
* update replaces, so an interrupted update can still be completed or removed. The
|
|
12
|
+
* transaction id also names the update's intermediate directories, so recovery needs no path.
|
|
13
|
+
* Skill fingerprints are `v2:` values; a bare 64-hex value is a record that 0.8.0 wrote and
|
|
14
|
+
* names a tree only once a trusted reference proves it.
|
|
12
15
|
*/
|
|
13
16
|
readonly replaces?: string;
|
|
14
17
|
}
|
package/dist/setup-ownership.js
CHANGED
|
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
import { acquireSetupGuardian } from "./setup-process.js";
|
|
6
6
|
import { assertSupportedFilesystem, currentSetupPlatform, openLockedDarwin } from "./setup-platform.js";
|
|
7
|
-
import { SKILL_NAME_PATTERN } from "./skill-library.js";
|
|
7
|
+
import { LEGACY_SKILL_TREE_FINGERPRINT_PATTERN, SKILL_NAME_PATTERN, SKILL_TREE_FINGERPRINT_PATTERN } from "./skill-library.js";
|
|
8
8
|
/** Safe default for one process; callers needing recovery inject a durable store. */
|
|
9
9
|
export class MemoryOwnershipStore {
|
|
10
10
|
entries = new Map();
|
|
@@ -231,10 +231,13 @@ export function isRecord(value) {
|
|
|
231
231
|
const skill = skillAlias !== null && SKILL_NAME_PATTERN.test(skillAlias[1]) &&
|
|
232
232
|
(client === "codex" ? scope === "user" && skillAlias[2] === undefined
|
|
233
233
|
: client === "claude-code" && ((scope === "user" && skillAlias[2] === undefined) || (scope === "project" && skillAlias[2] !== undefined)));
|
|
234
|
+
// A registration fingerprint is a snapshot hash; a skill fingerprint is a version 2 tree value or a 0.8.0 legacy one.
|
|
235
|
+
const validFingerprint = (value) => typeof value === "string" &&
|
|
236
|
+
(skill ? SKILL_TREE_FINGERPRINT_PATTERN.test(value) || LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value) : /^[a-f0-9]{64}$/.test(value));
|
|
234
237
|
// `replaces` belongs only to a pending skill record.
|
|
235
|
-
if ("replaces" in x && !(skill && x.phase === "pending" &&
|
|
238
|
+
if ("replaces" in x && !(skill && x.phase === "pending" && validFingerprint(x.replaces)))
|
|
236
239
|
return false;
|
|
237
|
-
return (registration || skill) &&
|
|
240
|
+
return (registration || skill) && validFingerprint(x.fingerprint) &&
|
|
238
241
|
(x.phase === "pending" || x.phase === "owned") && x.previous === "absent" &&
|
|
239
242
|
typeof x.transactionId === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(x.transactionId);
|
|
240
243
|
}
|
package/dist/setup-skills.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ReleasedSkillTree } from "./skill-library.js";
|
|
1
2
|
import type { OwnershipAccess } from "./setup-ownership.js";
|
|
2
3
|
import type { SetupFinding, SetupOperation, SetupTarget } from "./setup-result.js";
|
|
3
4
|
export interface SetupSkillInstaller {
|
|
@@ -15,12 +16,22 @@ export interface SetupSkillRoots {
|
|
|
15
16
|
readonly home: string;
|
|
16
17
|
readonly cwd: string;
|
|
17
18
|
}
|
|
19
|
+
/** The crash-relevant points of an update, in order. Only a test seam observes them. */
|
|
20
|
+
export type SkillCheckpoint = "staged" | "backup-renamed" | "installed" | "trash-renamed" | "trash-entry-removed" | "trash-deleted" | "before-ownership";
|
|
21
|
+
export interface SetupSkillDependencies {
|
|
22
|
+
/** Trusted released trees that prove 0.8.0 records; defaults to the shipped table. */
|
|
23
|
+
readonly released?: readonly ReleasedSkillTree[];
|
|
24
|
+
/** Test seam, called after each step in `SkillCheckpoint` completed; defaults to a no-op. */
|
|
25
|
+
readonly checkpoint?: (step: SkillCheckpoint) => void | Promise<void>;
|
|
26
|
+
}
|
|
18
27
|
/** Paths remain internal. Ownership identifies a client slot, never its location. */
|
|
19
28
|
export declare class FileSetupSkillInstaller implements SetupSkillInstaller {
|
|
20
29
|
private readonly roots;
|
|
21
|
-
|
|
30
|
+
private readonly dependencies;
|
|
31
|
+
constructor(roots: SetupSkillRoots, dependencies?: SetupSkillDependencies);
|
|
22
32
|
run(operation: SetupOperation, target: SetupTarget, ownership: OwnershipAccess): Promise<readonly SetupFinding[]>;
|
|
23
33
|
owns(target: SetupTarget, ownership: OwnershipAccess): Promise<boolean>;
|
|
34
|
+
private step;
|
|
24
35
|
/**
|
|
25
36
|
* The slot path and its ownership key. A project may be untrusted: only its root is
|
|
26
37
|
* canonicalized, and a link below it (for example `.claude` pointing elsewhere) makes the
|
|
@@ -32,6 +43,40 @@ export declare class FileSetupSkillInstaller implements SetupSkillInstaller {
|
|
|
32
43
|
*/
|
|
33
44
|
private location;
|
|
34
45
|
private library;
|
|
46
|
+
/**
|
|
47
|
+
* Whether the record names this installed tree: its version 2 value, or a 0.8.0 value that a
|
|
48
|
+
* trusted reference proves for exactly these bytes. A bare legacy value never proves identity.
|
|
49
|
+
*/
|
|
50
|
+
private ownsTree;
|
|
35
51
|
private skill;
|
|
52
|
+
/**
|
|
53
|
+
* Installs the new tree through the transaction's own intermediates. A failure leaves them for
|
|
54
|
+
* recovery; nothing here deletes by guess.
|
|
55
|
+
*/
|
|
36
56
|
private replace;
|
|
57
|
+
/**
|
|
58
|
+
* Deletes the backup only after it is verified to be the exact old copy: it is renamed to the
|
|
59
|
+
* transaction's trash name first, so the deletion never leaves an unverifiable partial tree
|
|
60
|
+
* under the backup name.
|
|
61
|
+
*/
|
|
62
|
+
private discardBackup;
|
|
63
|
+
/**
|
|
64
|
+
* Deletes the transaction's trash entry by entry, so a crash leaves only a smaller partial tree.
|
|
65
|
+
* Callers must have proven the trash to be the complete replaced copy; a later recovery retains
|
|
66
|
+
* a partial tree instead of deleting it.
|
|
67
|
+
*/
|
|
68
|
+
private discardTrash;
|
|
69
|
+
/**
|
|
70
|
+
* Settles an interrupted update of the pending record's transaction, inside the ownership
|
|
71
|
+
* transaction. Only directories with this transaction's exact names are examined, and only
|
|
72
|
+
* verified ones are deleted: the backup must equal the replaced copy, the staging directory
|
|
73
|
+
* the target or an interrupted write of it, the trash the complete replaced copy. Anything
|
|
74
|
+
* else stays and is reported, including a partial or changed trash.
|
|
75
|
+
* A proven trash is still deleted when a later intermediate (backup or staging) is retained.
|
|
76
|
+
*
|
|
77
|
+
* apply, slot absent: a complete staging directory is renamed into the slot (forward);
|
|
78
|
+
* otherwise a verified backup is moved back. remove and rollback do not restore, they delete
|
|
79
|
+
* what is verified and leave the slot to the caller.
|
|
80
|
+
*/
|
|
81
|
+
private recover;
|
|
37
82
|
}
|
package/dist/setup-skills.js
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
|
|
3
|
-
import { basename, dirname, join } from "node:path";
|
|
4
|
-
import { CONTROLLER_SKILLS, readSkillTree, resolveLibrarySkill, skillOverrideDirectory } from "./skill-library.js";
|
|
5
|
-
|
|
2
|
+
import { lstat, mkdir, open, readdir, readFile, realpath, rename, rm, rmdir } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, posix } from "node:path";
|
|
4
|
+
import { CONTROLLER_SKILLS, LEGACY_SKILL_TREE_FINGERPRINT_PATTERN, MAX_SKILL_TREE_BYTES, MAX_SKILL_TREE_FILES, provesLegacyFingerprint, RELEASED_SKILL_TREES, readSkillTree, resolveLibrarySkill, SKILL_TREE_FINGERPRINT_PATTERN, skillOverrideDirectory, } from "./skill-library.js";
|
|
5
|
+
const TRANSACTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
6
|
+
const MAX_TREE_DEPTH = 8;
|
|
7
|
+
const MAX_TREE_ENTRIES = 256;
|
|
8
|
+
/** Whether a version 2 fingerprint is setup's own copy: the owned one, or the one a pending update replaces. */
|
|
6
9
|
function ownsFingerprint(record, fingerprint) {
|
|
7
10
|
return fingerprint === record.fingerprint || (record.phase === "pending" && record.replaces === fingerprint);
|
|
8
11
|
}
|
|
12
|
+
const isLegacy = (value) => value !== undefined && LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value);
|
|
13
|
+
const hasLegacy = (record) => isLegacy(record.fingerprint) || isLegacy(record.replaces);
|
|
14
|
+
/**
|
|
15
|
+
* Every intermediate directory of an update is named after the pending record's transaction id
|
|
16
|
+
* in the slot's parent, so recovery derives them from persisted state and never searches.
|
|
17
|
+
*/
|
|
18
|
+
function intermediates(path, name, transactionId) {
|
|
19
|
+
if (!TRANSACTION_ID.test(transactionId))
|
|
20
|
+
throw new Error("skill_transaction_invalid");
|
|
21
|
+
const base = join(dirname(path), `.pi-session-mcp-${name}-${transactionId}`);
|
|
22
|
+
return { staging: `${base}.staging`, backup: `${base}.backup`, trash: `${base}.trash` };
|
|
23
|
+
}
|
|
9
24
|
/** Paths remain internal. Ownership identifies a client slot, never its location. */
|
|
10
25
|
export class FileSetupSkillInstaller {
|
|
11
26
|
roots;
|
|
12
|
-
|
|
27
|
+
dependencies;
|
|
28
|
+
constructor(roots, dependencies = {}) {
|
|
13
29
|
this.roots = roots;
|
|
30
|
+
this.dependencies = dependencies;
|
|
14
31
|
}
|
|
15
32
|
async run(operation, target, ownership) {
|
|
16
33
|
const findings = [];
|
|
@@ -29,6 +46,7 @@ export class FileSetupSkillInstaller {
|
|
|
29
46
|
return true;
|
|
30
47
|
return false;
|
|
31
48
|
}
|
|
49
|
+
async step(step) { await this.dependencies.checkpoint?.(step); }
|
|
32
50
|
/**
|
|
33
51
|
* The slot path and its ownership key. A project may be untrusted: only its root is
|
|
34
52
|
* canonicalized, and a link below it (for example `.claude` pointing elsewhere) makes the
|
|
@@ -50,6 +68,16 @@ export class FileSetupSkillInstaller {
|
|
|
50
68
|
library(name) {
|
|
51
69
|
return resolveLibrarySkill(name, { overrideDirectory: skillOverrideDirectory(this.roots.configPath), packageRoot: this.roots.packageRoot });
|
|
52
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Whether the record names this installed tree: its version 2 value, or a 0.8.0 value that a
|
|
73
|
+
* trusted reference proves for exactly these bytes. A bare legacy value never proves identity.
|
|
74
|
+
*/
|
|
75
|
+
ownsTree(name, record, slot, references) {
|
|
76
|
+
if (ownsFingerprint(record, slot.fingerprint))
|
|
77
|
+
return true;
|
|
78
|
+
const named = record.phase === "pending" ? [record.fingerprint, record.replaces] : [record.fingerprint];
|
|
79
|
+
return named.some((value) => isLegacy(value) && provesLegacyFingerprint(name, value, slot, references, this.dependencies.released ?? RELEASED_SKILL_TREES));
|
|
80
|
+
}
|
|
53
81
|
async skill(operation, target, name, ownership) {
|
|
54
82
|
const finding = (code, status) => ({ target: { ...target, alias: name }, code, status });
|
|
55
83
|
let location;
|
|
@@ -63,9 +91,10 @@ export class FileSetupSkillInstaller {
|
|
|
63
91
|
}
|
|
64
92
|
const { path, owned: ownedTarget } = location;
|
|
65
93
|
const library = this.library(name);
|
|
66
|
-
// Only a usable library skill can stand behind an accepted link; otherwise verify and apply would disagree.
|
|
67
|
-
const
|
|
68
|
-
const linkTarget =
|
|
94
|
+
// Only a usable library skill can stand behind an accepted link or a legacy proof; otherwise verify and apply would disagree.
|
|
95
|
+
const desired = library ? await readSkillTree(library.directory).catch(() => undefined) : undefined;
|
|
96
|
+
const linkTarget = desired ? await realpath(library.directory).catch(() => undefined) : undefined;
|
|
97
|
+
const references = desired ? [desired] : [];
|
|
69
98
|
if (operation === "remove" || operation === "rollback") {
|
|
70
99
|
try {
|
|
71
100
|
const owned = await ownership.get(ownedTarget);
|
|
@@ -76,6 +105,9 @@ export class FileSetupSkillInstaller {
|
|
|
76
105
|
if (other?.phase === "owned")
|
|
77
106
|
return finding("skill_kept_shared", "unchanged");
|
|
78
107
|
}
|
|
108
|
+
if (owned.phase === "pending" && SKILL_TREE_FINGERPRINT_PATTERN.test(owned.fingerprint)
|
|
109
|
+
&& await this.recover(operation, path, name, owned, desired, linkTarget, ownership) === "retained")
|
|
110
|
+
return finding("skill_recovery_retained", "divergent");
|
|
79
111
|
const current = await inspectSlot(path, linkTarget);
|
|
80
112
|
if (current.state === "absent") {
|
|
81
113
|
healthy(ownership);
|
|
@@ -90,8 +122,10 @@ export class FileSetupSkillInstaller {
|
|
|
90
122
|
healthy(ownership);
|
|
91
123
|
return finding("skill_not_owned", "unchanged");
|
|
92
124
|
}
|
|
93
|
-
if (current.state !== "tree"
|
|
125
|
+
if (current.state !== "tree")
|
|
94
126
|
return finding("skill_divergent", "divergent");
|
|
127
|
+
if (!this.ownsTree(name, owned, current, references))
|
|
128
|
+
return finding(hasLegacy(owned) ? "skill_legacy_unverified" : "skill_divergent", "divergent");
|
|
95
129
|
healthy(ownership);
|
|
96
130
|
if (!await sameSlot(path, current, linkTarget))
|
|
97
131
|
return finding("skill_divergent", "divergent");
|
|
@@ -114,29 +148,26 @@ export class FileSetupSkillInstaller {
|
|
|
114
148
|
return finding("skill_missing", "failed");
|
|
115
149
|
if (current.state === "linked")
|
|
116
150
|
return finding("skill_verified", "ok");
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
throw new Error("skill_source_invalid");
|
|
120
|
-
const desired = await readSkillTree(library.directory);
|
|
121
|
-
return current.state === "tree" && current.fingerprint === desired.fingerprint
|
|
122
|
-
? finding("skill_verified", "ok") : finding("skill_not_equivalent", "failed");
|
|
123
|
-
}
|
|
124
|
-
catch {
|
|
125
|
-
return finding("skill_not_equivalent", "failed");
|
|
126
|
-
}
|
|
151
|
+
return desired && current.state === "tree" && current.fingerprint === desired.fingerprint
|
|
152
|
+
? finding("skill_verified", "ok") : finding("skill_not_equivalent", "failed");
|
|
127
153
|
}
|
|
128
|
-
|
|
129
|
-
try {
|
|
130
|
-
if (!library)
|
|
131
|
-
throw new Error("skill_source_invalid");
|
|
132
|
-
desired = await readSkillTree(library.directory);
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
154
|
+
if (!desired)
|
|
135
155
|
return finding("skill_source_invalid", "failed");
|
|
136
|
-
}
|
|
137
156
|
try {
|
|
157
|
+
let owned = await ownership.get(ownedTarget);
|
|
158
|
+
let recovered = false;
|
|
159
|
+
if (operation === "apply" && owned?.phase === "pending" && SKILL_TREE_FINGERPRINT_PATTERN.test(owned.fingerprint)) {
|
|
160
|
+
const outcome = await this.recover("apply", path, name, owned, desired, linkTarget, ownership);
|
|
161
|
+
if (outcome === "retained")
|
|
162
|
+
return finding("skill_recovery_retained", "divergent");
|
|
163
|
+
recovered = outcome === "recovered";
|
|
164
|
+
}
|
|
165
|
+
// A dry-run never recovers; it announces the settlement apply would perform for a version 2 transaction, or its retention.
|
|
166
|
+
const recoveryPlanned = operation === "dry-run" && owned?.phase === "pending" && SKILL_TREE_FINGERPRINT_PATTERN.test(owned.fingerprint) && !hasLegacy(owned);
|
|
167
|
+
// The classification apply's recovery acts on, read-only: an intermediate apply would retain is not announced as planned.
|
|
168
|
+
if (recoveryPlanned && (await classifyIntermediates(path, name, owned, desired, linkTarget)).state === "retained")
|
|
169
|
+
return finding("skill_recovery_retained", "divergent");
|
|
138
170
|
const current = await inspectSlot(path, linkTarget);
|
|
139
|
-
const owned = await ownership.get(ownedTarget);
|
|
140
171
|
if (current.state === "linked") {
|
|
141
172
|
if (operation === "apply" && owned) {
|
|
142
173
|
healthy(ownership);
|
|
@@ -147,6 +178,17 @@ export class FileSetupSkillInstaller {
|
|
|
147
178
|
}
|
|
148
179
|
if (current.state === "divergent")
|
|
149
180
|
return finding("skill_divergent", "divergent");
|
|
181
|
+
if (owned && current.state === "tree" && hasLegacy(owned)) {
|
|
182
|
+
// A record from 0.8.0: it names the slot only if a trusted reference proves these exact bytes.
|
|
183
|
+
if (!this.ownsTree(name, owned, current, references))
|
|
184
|
+
return finding("skill_legacy_unverified", "divergent");
|
|
185
|
+
owned = { target: ownedTarget, fingerprint: current.fingerprint, previous: owned.previous, phase: "owned", transactionId: owned.transactionId };
|
|
186
|
+
if (operation === "apply") {
|
|
187
|
+
healthy(ownership);
|
|
188
|
+
await ownership.put(owned);
|
|
189
|
+
healthy(ownership);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
150
192
|
// A copy setup did not write, or its own copy modified since, is never replaced.
|
|
151
193
|
if (current.state === "tree" && (!owned || !ownsFingerprint(owned, current.fingerprint))) {
|
|
152
194
|
if (!owned && current.fingerprint === desired.fingerprint)
|
|
@@ -154,17 +196,26 @@ export class FileSetupSkillInstaller {
|
|
|
154
196
|
return finding("skill_divergent", "divergent");
|
|
155
197
|
}
|
|
156
198
|
if (current.state === "tree" && current.fingerprint === desired.fingerprint) {
|
|
199
|
+
if (recoveryPlanned)
|
|
200
|
+
return finding("skill_recovery_planned", "planned");
|
|
157
201
|
if (operation === "apply" && owned?.phase === "pending") {
|
|
158
202
|
healthy(ownership);
|
|
159
203
|
await ownership.put(ownedRecord(owned, desired.fingerprint));
|
|
160
204
|
healthy(ownership);
|
|
205
|
+
return finding("skill_recovered", "ok");
|
|
161
206
|
}
|
|
207
|
+
if (operation === "apply" && recovered)
|
|
208
|
+
return finding("skill_recovered", "ok");
|
|
162
209
|
return finding(operation === "apply" ? "skill_already_equivalent" : "skill_equivalent", "unchanged");
|
|
163
210
|
}
|
|
164
|
-
if (operation === "dry-run")
|
|
211
|
+
if (operation === "dry-run") {
|
|
212
|
+
if (recoveryPlanned)
|
|
213
|
+
return finding("skill_recovery_planned", "planned");
|
|
165
214
|
return current.state === "absent" ? finding("skill_absent", "absent") : finding("skill_update_planned", "planned");
|
|
166
|
-
|
|
167
|
-
//
|
|
215
|
+
}
|
|
216
|
+
// The pending record is persisted before any intermediate exists. It names the intermediates
|
|
217
|
+
// (transaction id), the target and the replaced copy, so an interrupted update can be
|
|
218
|
+
// recovered, finished or removed from persisted state alone.
|
|
168
219
|
const pending = {
|
|
169
220
|
target: ownedTarget, fingerprint: desired.fingerprint, previous: "absent", phase: "pending", transactionId: randomUUID(),
|
|
170
221
|
...(current.state === "tree" ? { replaces: current.fingerprint } : {}),
|
|
@@ -172,11 +223,12 @@ export class FileSetupSkillInstaller {
|
|
|
172
223
|
healthy(ownership);
|
|
173
224
|
await ownership.put(pending);
|
|
174
225
|
healthy(ownership);
|
|
175
|
-
await this.replace(path, name, desired, current, ownership);
|
|
226
|
+
await this.replace(path, name, desired, current, pending, ownership);
|
|
176
227
|
healthy(ownership);
|
|
177
228
|
const after = await inspectSlot(path);
|
|
178
229
|
if (after.state !== "tree" || after.fingerprint !== desired.fingerprint)
|
|
179
230
|
throw new Error("skill_post_install_mismatch");
|
|
231
|
+
await this.step("before-ownership");
|
|
180
232
|
healthy(ownership);
|
|
181
233
|
await ownership.put(ownedRecord(pending, desired.fingerprint));
|
|
182
234
|
healthy(ownership);
|
|
@@ -186,69 +238,185 @@ export class FileSetupSkillInstaller {
|
|
|
186
238
|
return finding(operation === "apply" ? "skill_install_failed" : "skill_source_invalid", "failed");
|
|
187
239
|
}
|
|
188
240
|
}
|
|
189
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Installs the new tree through the transaction's own intermediates. A failure leaves them for
|
|
243
|
+
* recovery; nothing here deletes by guess.
|
|
244
|
+
*/
|
|
245
|
+
async replace(path, name, desired, current, record, ownership) {
|
|
190
246
|
const parent = dirname(path);
|
|
247
|
+
const names = intermediates(path, name, record.transactionId);
|
|
191
248
|
healthy(ownership);
|
|
192
249
|
await ensureParents(parent, ownership);
|
|
193
250
|
healthy(ownership);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
try {
|
|
198
|
-
healthy(ownership);
|
|
199
|
-
await mkdir(temporary, { mode: 0o755 });
|
|
200
|
-
staged = true;
|
|
201
|
-
for (const file of desired.files) {
|
|
202
|
-
healthy(ownership);
|
|
203
|
-
const destination = join(temporary, ...file.path.split("/"));
|
|
204
|
-
await ensureParents(dirname(destination), ownership);
|
|
205
|
-
healthy(ownership);
|
|
206
|
-
const handle = await open(destination, "wx", 0o644);
|
|
207
|
-
try {
|
|
208
|
-
await handle.writeFile(file.bytes);
|
|
209
|
-
await handle.sync();
|
|
210
|
-
}
|
|
211
|
-
finally {
|
|
212
|
-
await handle.close();
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
await syncTreeDirectories(temporary, desired, ownership);
|
|
251
|
+
healthy(ownership);
|
|
252
|
+
await mkdir(names.staging, { mode: 0o755 });
|
|
253
|
+
for (const file of desired.files) {
|
|
216
254
|
healthy(ownership);
|
|
217
|
-
|
|
218
|
-
|
|
255
|
+
const destination = join(names.staging, ...file.path.split("/"));
|
|
256
|
+
await ensureParents(dirname(destination), ownership);
|
|
219
257
|
healthy(ownership);
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
await
|
|
224
|
-
healthy(ownership);
|
|
258
|
+
const handle = await open(destination, "wx", 0o644);
|
|
259
|
+
try {
|
|
260
|
+
await handle.writeFile(file.bytes);
|
|
261
|
+
await handle.sync();
|
|
225
262
|
}
|
|
226
|
-
|
|
227
|
-
|
|
263
|
+
finally {
|
|
264
|
+
await handle.close();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
await syncTreeDirectories(names.staging, desired, ownership);
|
|
268
|
+
await this.step("staged");
|
|
269
|
+
healthy(ownership);
|
|
270
|
+
if (!await sameSlot(path, current))
|
|
271
|
+
throw new Error("skill_slot_changed");
|
|
272
|
+
healthy(ownership);
|
|
273
|
+
if (current.state === "tree") {
|
|
274
|
+
// A rename onto an existing empty directory would replace it silently.
|
|
275
|
+
if (await present(names.backup))
|
|
276
|
+
throw new Error("skill_backup_exists");
|
|
277
|
+
await rename(path, names.backup);
|
|
228
278
|
healthy(ownership);
|
|
229
279
|
await syncDirectory(parent);
|
|
230
280
|
healthy(ownership);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
281
|
+
await this.step("backup-renamed");
|
|
282
|
+
}
|
|
283
|
+
await rename(names.staging, path);
|
|
284
|
+
healthy(ownership);
|
|
285
|
+
await syncDirectory(parent);
|
|
286
|
+
healthy(ownership);
|
|
287
|
+
await this.step("installed");
|
|
288
|
+
const after = await inspectSlot(path);
|
|
289
|
+
if (after.state !== "tree" || after.fingerprint !== desired.fingerprint)
|
|
290
|
+
throw new Error("skill_post_install_mismatch");
|
|
291
|
+
if (current.state === "tree")
|
|
292
|
+
await this.discardBackup(names, current, parent, ownership);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Deletes the backup only after it is verified to be the exact old copy: it is renamed to the
|
|
296
|
+
* transaction's trash name first, so the deletion never leaves an unverifiable partial tree
|
|
297
|
+
* under the backup name.
|
|
298
|
+
*/
|
|
299
|
+
async discardBackup(names, backup, parent, ownership) {
|
|
300
|
+
if (await present(names.trash))
|
|
301
|
+
throw new Error("skill_trash_exists");
|
|
302
|
+
if (!await sameSlot(names.backup, backup))
|
|
303
|
+
throw new Error("skill_backup_changed");
|
|
304
|
+
healthy(ownership);
|
|
305
|
+
await rename(names.backup, names.trash);
|
|
306
|
+
healthy(ownership);
|
|
307
|
+
await syncDirectory(parent);
|
|
308
|
+
healthy(ownership);
|
|
309
|
+
await this.step("trash-renamed");
|
|
310
|
+
await this.discardTrash(names, parent, ownership);
|
|
311
|
+
await this.step("trash-deleted");
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Deletes the transaction's trash entry by entry, so a crash leaves only a smaller partial tree.
|
|
315
|
+
* Callers must have proven the trash to be the complete replaced copy; a later recovery retains
|
|
316
|
+
* a partial tree instead of deleting it.
|
|
317
|
+
*/
|
|
318
|
+
async discardTrash(names, parent, ownership) {
|
|
319
|
+
await removeEntries(names.trash, parent, ownership, () => this.step("trash-entry-removed"));
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Settles an interrupted update of the pending record's transaction, inside the ownership
|
|
323
|
+
* transaction. Only directories with this transaction's exact names are examined, and only
|
|
324
|
+
* verified ones are deleted: the backup must equal the replaced copy, the staging directory
|
|
325
|
+
* the target or an interrupted write of it, the trash the complete replaced copy. Anything
|
|
326
|
+
* else stays and is reported, including a partial or changed trash.
|
|
327
|
+
* A proven trash is still deleted when a later intermediate (backup or staging) is retained.
|
|
328
|
+
*
|
|
329
|
+
* apply, slot absent: a complete staging directory is renamed into the slot (forward);
|
|
330
|
+
* otherwise a verified backup is moved back. remove and rollback do not restore, they delete
|
|
331
|
+
* what is verified and leave the slot to the caller.
|
|
332
|
+
*/
|
|
333
|
+
async recover(operation, path, name, record, library, linkTarget, ownership) {
|
|
334
|
+
const parent = dirname(path);
|
|
335
|
+
const names = intermediates(path, name, record.transactionId);
|
|
336
|
+
const target = record.fingerprint;
|
|
337
|
+
const previous = record.replaces !== undefined && SKILL_TREE_FINGERPRINT_PATTERN.test(record.replaces) ? record.replaces : undefined;
|
|
338
|
+
let found = await classifyIntermediates(path, name, record, library, linkTarget);
|
|
339
|
+
if (found.state === "untouched")
|
|
340
|
+
return "untouched";
|
|
341
|
+
let changed = false;
|
|
342
|
+
if (found.deleteTrash) {
|
|
343
|
+
await this.discardTrash(names, parent, ownership);
|
|
344
|
+
changed = true;
|
|
345
|
+
// Backup and staging are read again once the trash is gone; the slot stays the one first read.
|
|
346
|
+
found = await classifyIntermediates(path, name, record, library, linkTarget, found.slot);
|
|
347
|
+
}
|
|
348
|
+
if (found.state !== "settle")
|
|
349
|
+
return found.state;
|
|
350
|
+
const { slot } = found;
|
|
351
|
+
let { backup, staging } = found;
|
|
352
|
+
if (operation === "apply" && slot.state === "absent") {
|
|
353
|
+
if (staging === "complete" || backup.state === "tree") {
|
|
354
|
+
const source = staging === "complete" ? names.staging : names.backup;
|
|
355
|
+
const expected = staging === "complete" ? target : previous;
|
|
238
356
|
healthy(ownership);
|
|
239
|
-
await
|
|
357
|
+
if (!await sameSlot(path, slot))
|
|
358
|
+
throw new Error("skill_slot_changed");
|
|
359
|
+
healthy(ownership);
|
|
360
|
+
await rename(source, path);
|
|
240
361
|
healthy(ownership);
|
|
241
362
|
await syncDirectory(parent);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
finally {
|
|
245
|
-
// An unhealthy fence retains staged/backup content for conservative recovery.
|
|
246
|
-
if (staged) {
|
|
247
363
|
healthy(ownership);
|
|
248
|
-
|
|
364
|
+
const restored = await inspectSlot(path);
|
|
365
|
+
if (restored.state !== "tree" || restored.fingerprint !== expected)
|
|
366
|
+
throw new Error("skill_recovery_mismatch");
|
|
367
|
+
changed = true;
|
|
368
|
+
if (staging === "complete")
|
|
369
|
+
staging = "absent";
|
|
370
|
+
else
|
|
371
|
+
backup = { state: "absent" };
|
|
249
372
|
}
|
|
250
373
|
}
|
|
374
|
+
if (backup.state === "tree") {
|
|
375
|
+
await this.discardBackup(names, backup, parent, ownership);
|
|
376
|
+
changed = true;
|
|
377
|
+
}
|
|
378
|
+
if (staging !== "absent") {
|
|
379
|
+
healthy(ownership);
|
|
380
|
+
await removeEntries(names.staging, parent, ownership);
|
|
381
|
+
changed = true;
|
|
382
|
+
}
|
|
383
|
+
return changed ? "recovered" : "settled";
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Reads a pending version 2 transaction's slot, trash, backup and staging directory, by their
|
|
388
|
+
* exact names, and never writes. The slot is only inspected when `known` is not given. Shared by
|
|
389
|
+
* recovery and the dry-run, so both apply the same rules.
|
|
390
|
+
*
|
|
391
|
+
* Only an empty slot, a link or one of setup's two copies can have intermediates settled. A trash
|
|
392
|
+
* is deletable only while it is the complete replaced copy; a partial or changed trash, a backup
|
|
393
|
+
* that is not the replaced copy and a foreign staging directory are retained.
|
|
394
|
+
*/
|
|
395
|
+
async function classifyIntermediates(path, name, record, library, linkTarget, known) {
|
|
396
|
+
const names = intermediates(path, name, record.transactionId);
|
|
397
|
+
const target = record.fingerprint;
|
|
398
|
+
const previous = record.replaces !== undefined && SKILL_TREE_FINGERPRINT_PATTERN.test(record.replaces) ? record.replaces : undefined;
|
|
399
|
+
const slot = known ?? await inspectSlot(path, linkTarget);
|
|
400
|
+
if (slot.state === "divergent" || (slot.state === "tree" && slot.fingerprint !== target && slot.fingerprint !== previous))
|
|
401
|
+
return { state: "untouched" };
|
|
402
|
+
// A trash directory with the transaction's name holds the verified replaced copy, but only
|
|
403
|
+
// while it is complete: it is deleted only when it still equals the record's replaced
|
|
404
|
+
// fingerprint. A partial trash (an interrupted deletion) or any changed content cannot be
|
|
405
|
+
// proven to be setup's own, so it is retained and reported for the operator to settle.
|
|
406
|
+
let deleteTrash = false;
|
|
407
|
+
if (await present(names.trash)) {
|
|
408
|
+
const trash = await inspectSlot(names.trash);
|
|
409
|
+
if (trash.state !== "tree" || previous === undefined || trash.fingerprint !== previous)
|
|
410
|
+
return { state: "retained", slot, deleteTrash };
|
|
411
|
+
deleteTrash = true;
|
|
251
412
|
}
|
|
413
|
+
const backup = await inspectSlot(names.backup);
|
|
414
|
+
if (backup.state === "divergent" || (backup.state === "tree" && backup.fingerprint !== previous))
|
|
415
|
+
return { state: "retained", slot, deleteTrash };
|
|
416
|
+
const staging = await classifyStaging(names.staging, target, library);
|
|
417
|
+
if (staging === "foreign")
|
|
418
|
+
return { state: "retained", slot, deleteTrash };
|
|
419
|
+
return { state: "settle", slot, deleteTrash, backup, staging };
|
|
252
420
|
}
|
|
253
421
|
function healthy(ownership) { ownership.assertHealthy?.(); }
|
|
254
422
|
/** The owned form of a record, without the replaced fingerprint of a finished update. */
|
|
@@ -310,6 +478,109 @@ async function sameSlot(path, expected, linkTarget) {
|
|
|
310
478
|
return expected.state === "absent" ? actual.state === "absent" :
|
|
311
479
|
expected.state === "tree" && actual.state === "tree" && actual.fingerprint === expected.fingerprint && actual.dev === expected.dev && actual.ino === expected.ino;
|
|
312
480
|
}
|
|
481
|
+
async function present(path) {
|
|
482
|
+
try {
|
|
483
|
+
await lstat(path);
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
if (error.code === "ENOENT")
|
|
488
|
+
return false;
|
|
489
|
+
throw error;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* The regular files and directories (relative, `/` separated) below a directory, or undefined
|
|
494
|
+
* unless it is a real directory with only regular files and directories, no hidden entry, and
|
|
495
|
+
* within the tree limits. A link or special file is never followed or counted as plain.
|
|
496
|
+
*/
|
|
497
|
+
async function collectPlain(root) {
|
|
498
|
+
const files = [];
|
|
499
|
+
const directories = [];
|
|
500
|
+
let bytes = 0;
|
|
501
|
+
let entries = 0;
|
|
502
|
+
const visit = async (current, prefix, depth) => {
|
|
503
|
+
if (depth > MAX_TREE_DEPTH)
|
|
504
|
+
return false;
|
|
505
|
+
for (const entry of await readdir(current)) {
|
|
506
|
+
if (entry.startsWith(".") || ++entries > MAX_TREE_ENTRIES)
|
|
507
|
+
return false;
|
|
508
|
+
const path = join(current, entry);
|
|
509
|
+
const relative = prefix === "" ? entry : `${prefix}/${entry}`;
|
|
510
|
+
const info = await lstat(path);
|
|
511
|
+
if (info.isDirectory()) {
|
|
512
|
+
directories.push(relative);
|
|
513
|
+
if (!await visit(path, relative, depth + 1))
|
|
514
|
+
return false;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (!info.isFile())
|
|
518
|
+
return false;
|
|
519
|
+
bytes += info.size;
|
|
520
|
+
if (files.length + 1 > MAX_SKILL_TREE_FILES || bytes > MAX_SKILL_TREE_BYTES)
|
|
521
|
+
return false;
|
|
522
|
+
files.push(relative);
|
|
523
|
+
}
|
|
524
|
+
return true;
|
|
525
|
+
};
|
|
526
|
+
try {
|
|
527
|
+
const info = await lstat(root);
|
|
528
|
+
return info.isDirectory() && await visit(root, "", 0) ? { files, directories } : undefined;
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* How a staging directory of the pending record relates to its target: `complete` (its tree
|
|
536
|
+
* equals the target fingerprint), `partial` (an interrupted write: every entry is a regular file
|
|
537
|
+
* of the target tree whose content equals that file or a prefix of it, or a directory on the path
|
|
538
|
+
* of one, checked only against the current library tree when it is the record's target),
|
|
539
|
+
* `absent`, or `foreign`, which is never deleted.
|
|
540
|
+
*/
|
|
541
|
+
async function classifyStaging(staging, target, library) {
|
|
542
|
+
const staged = await inspectSlot(staging);
|
|
543
|
+
if (staged.state === "absent")
|
|
544
|
+
return "absent";
|
|
545
|
+
if (staged.state === "tree" && staged.fingerprint === target)
|
|
546
|
+
return "complete";
|
|
547
|
+
if (!library || library.fingerprint !== target)
|
|
548
|
+
return "foreign";
|
|
549
|
+
const plain = await collectPlain(staging);
|
|
550
|
+
if (!plain)
|
|
551
|
+
return "foreign";
|
|
552
|
+
const expected = new Map(library.files.map((file) => [file.path, file.bytes]));
|
|
553
|
+
const ancestors = new Set();
|
|
554
|
+
for (const file of library.files)
|
|
555
|
+
for (let cursor = posix.dirname(file.path); cursor !== "."; cursor = posix.dirname(cursor))
|
|
556
|
+
ancestors.add(cursor);
|
|
557
|
+
if (!plain.directories.every((directory) => ancestors.has(directory)))
|
|
558
|
+
return "foreign";
|
|
559
|
+
for (const file of plain.files) {
|
|
560
|
+
const bytes = expected.get(file);
|
|
561
|
+
if (!bytes)
|
|
562
|
+
return "foreign";
|
|
563
|
+
const content = await readFile(join(staging, ...file.split("/")));
|
|
564
|
+
if (content.length > bytes.length || !bytes.subarray(0, content.length).equals(content))
|
|
565
|
+
return "foreign";
|
|
566
|
+
}
|
|
567
|
+
return "partial";
|
|
568
|
+
}
|
|
569
|
+
/** Removes a verified directory entry by entry and then itself, with directory fsyncs, so a crash leaves a smaller partial tree. */
|
|
570
|
+
async function removeEntries(directory, parent, ownership, afterEntry) {
|
|
571
|
+
for (const entry of await readdir(directory)) {
|
|
572
|
+
healthy(ownership);
|
|
573
|
+
await rm(join(directory, entry), { recursive: true });
|
|
574
|
+
healthy(ownership);
|
|
575
|
+
await syncDirectory(directory);
|
|
576
|
+
await afterEntry?.();
|
|
577
|
+
}
|
|
578
|
+
healthy(ownership);
|
|
579
|
+
await rmdir(directory);
|
|
580
|
+
healthy(ownership);
|
|
581
|
+
await syncDirectory(parent);
|
|
582
|
+
healthy(ownership);
|
|
583
|
+
}
|
|
313
584
|
async function ensureParents(directory, ownership) {
|
|
314
585
|
if (dirname(directory) !== directory)
|
|
315
586
|
await ensureParents(dirname(directory), ownership);
|