@litfamily/litopencode 1.0.1 → 1.0.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/CHANGELOG.md +7 -0
- package/README-Ko-KR.md +8 -7
- package/README.md +8 -7
- package/dist/activation-prompt-utils.d.ts +1 -1
- package/dist/activation-routing.d.ts +1 -1
- package/dist/activation-routing.js +1 -8
- package/dist/activation-workflow-prompts.d.ts +0 -1
- package/dist/activation-workflow-prompts.js +0 -8
- package/dist/activation.d.ts +1 -1
- package/dist/activation.js +1 -1
- package/dist/cli/args.d.ts +1 -1
- package/dist/cli/args.js +1 -31
- package/dist/cli/loop.js +0 -19
- package/dist/cli/managed-skill-assets.d.ts +1 -1
- package/dist/cli/managed-skill-assets.js +0 -2
- package/dist/cli/types.d.ts +0 -4
- package/dist/cli.js +0 -14
- package/dist/commands.d.ts +1 -10
- package/dist/commands.js +1 -11
- package/dist/features.d.ts +1 -22
- package/dist/features.js +0 -43
- package/dist/hooks.js +0 -13
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -5
- package/dist/skills.d.ts +1 -8
- package/dist/skills.js +0 -13
- package/docs/assets/cover-motion.webp +0 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/privacy.md +2 -2
- package/docs/reference.md +5 -48
- package/package.json +2 -1
- package/skills/frontend-ui-ux/references/complete-contract.md +1 -1
- package/skills/lit-plan/SKILL.md +1 -1
- package/skills/managed-skill-manifest.json +2 -9
- package/skills/visual-qa/references/complete-contract.md +1 -1
- package/tools/check-payload-substance.mjs +74 -27
- package/tools/payload-substance-parity.json +10 -25
- package/tools/version-manifests.json +2 -2
- package/dist/cli/skill-loop.d.ts +0 -3
- package/dist/cli/skill-loop.js +0 -649
- package/dist/skill-loop/apply.d.ts +0 -19
- package/dist/skill-loop/apply.js +0 -483
- package/dist/skill-loop/config.d.ts +0 -50
- package/dist/skill-loop/config.js +0 -215
- package/dist/skill-loop/curator.d.ts +0 -18
- package/dist/skill-loop/curator.js +0 -232
- package/dist/skill-loop/ledger.d.ts +0 -39
- package/dist/skill-loop/ledger.js +0 -344
- package/dist/skill-loop/proposals.d.ts +0 -48
- package/dist/skill-loop/proposals.js +0 -290
- package/dist/skill-loop/storage.d.ts +0 -72
- package/dist/skill-loop/storage.js +0 -817
- package/dist/skill-loop/time.d.ts +0 -2
- package/dist/skill-loop/time.js +0 -23
- package/dist/skill-loop/transaction.d.ts +0 -62
- package/dist/skill-loop/transaction.js +0 -836
- package/dist/skill-loop/usage.d.ts +0 -31
- package/dist/skill-loop/usage.js +0 -146
- package/dist/skill-observer.d.ts +0 -22
- package/dist/skill-observer.js +0 -1154
- package/skills/skill-observer/SKILL.md +0 -148
- package/skills/skill-observer/references/review-contract.md +0 -95
|
@@ -1,817 +0,0 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import fs from "node:fs/promises";
|
|
4
|
-
import { constants as fsConstants } from "node:fs";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import os from "node:os";
|
|
7
|
-
import { sameStableDirectoryIdentity, sameStableStatIdentity, stableStatIdentity } from "../stable-identity.js";
|
|
8
|
-
const noFollowRead = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0);
|
|
9
|
-
const lockRetryMs = 10;
|
|
10
|
-
const lockTimeoutMs = 5_000;
|
|
11
|
-
const ownerlessLockGraceMs = 250;
|
|
12
|
-
const capabilityBrand = Symbol("skill-loop-lock-capability");
|
|
13
|
-
const capabilities = new WeakMap();
|
|
14
|
-
function errorCode(error) {
|
|
15
|
-
return error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined;
|
|
16
|
-
}
|
|
17
|
-
function unsafe() {
|
|
18
|
-
throw new Error("SKILL_LOOP_PATH_UNSAFE");
|
|
19
|
-
}
|
|
20
|
-
function isContained(base, target) {
|
|
21
|
-
const relative = path.relative(path.resolve(base), path.resolve(target));
|
|
22
|
-
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
23
|
-
}
|
|
24
|
-
async function lstat(file) {
|
|
25
|
-
try {
|
|
26
|
-
return await fs.lstat(file);
|
|
27
|
-
}
|
|
28
|
-
catch (error) {
|
|
29
|
-
if (errorCode(error) === "ENOENT")
|
|
30
|
-
return undefined;
|
|
31
|
-
throw error;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
function assertDirectory(stat) {
|
|
35
|
-
if (stat === undefined || stat.isSymbolicLink() || !stat.isDirectory())
|
|
36
|
-
unsafe();
|
|
37
|
-
}
|
|
38
|
-
function assertRegular(stat) {
|
|
39
|
-
if (stat === undefined || stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1)
|
|
40
|
-
unsafe();
|
|
41
|
-
}
|
|
42
|
-
async function assertNoUserControlledSymlinkAncestor(target) {
|
|
43
|
-
const resolved = path.resolve(target);
|
|
44
|
-
const parsed = path.parse(resolved);
|
|
45
|
-
let current = parsed.root;
|
|
46
|
-
for (const component of resolved.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
|
|
47
|
-
const candidate = path.join(current, component);
|
|
48
|
-
const observed = await lstat(candidate);
|
|
49
|
-
if (observed === undefined)
|
|
50
|
-
break;
|
|
51
|
-
if (observed.isSymbolicLink()) {
|
|
52
|
-
const parent = await fs.lstat(current);
|
|
53
|
-
const trustedSystemLink = observed.uid === 0 && parent.uid === 0 && (parent.mode & 0o022) === 0;
|
|
54
|
-
if (!trustedSystemLink)
|
|
55
|
-
unsafe();
|
|
56
|
-
}
|
|
57
|
-
current = candidate;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
async function createDirectoryWithoutUserSymlinks(target) {
|
|
61
|
-
const resolved = path.resolve(target);
|
|
62
|
-
const parsed = path.parse(resolved);
|
|
63
|
-
let current = parsed.root;
|
|
64
|
-
for (const component of resolved.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
|
|
65
|
-
const candidate = path.join(current, component);
|
|
66
|
-
const observed = await lstat(candidate);
|
|
67
|
-
if (observed === undefined) {
|
|
68
|
-
try {
|
|
69
|
-
await fs.mkdir(candidate, { mode: 0o700 });
|
|
70
|
-
}
|
|
71
|
-
catch (error) {
|
|
72
|
-
if (errorCode(error) !== "EEXIST")
|
|
73
|
-
throw error;
|
|
74
|
-
}
|
|
75
|
-
assertDirectory(await lstat(candidate));
|
|
76
|
-
}
|
|
77
|
-
else if (observed.isSymbolicLink()) {
|
|
78
|
-
const parent = await fs.lstat(current);
|
|
79
|
-
if (observed.uid !== 0 || parent.uid !== 0 || (parent.mode & 0o022) !== 0)
|
|
80
|
-
unsafe();
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
assertDirectory(observed);
|
|
84
|
-
}
|
|
85
|
-
current = candidate;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
export async function pinPhysicalDirectory(directory, create = false) {
|
|
89
|
-
const lexical = path.resolve(directory);
|
|
90
|
-
await assertNoUserControlledSymlinkAncestor(lexical);
|
|
91
|
-
if (create)
|
|
92
|
-
await createDirectoryWithoutUserSymlinks(lexical);
|
|
93
|
-
const physical = await fs.realpath(lexical).catch((error) => {
|
|
94
|
-
if (errorCode(error) === "ENOENT")
|
|
95
|
-
unsafe();
|
|
96
|
-
throw error;
|
|
97
|
-
});
|
|
98
|
-
const observed = await fs.lstat(physical);
|
|
99
|
-
assertDirectory(observed);
|
|
100
|
-
const identity = stableStatIdentity(observed);
|
|
101
|
-
return Object.freeze({ lexical, physical, ...identity, dev: Number(identity.dev), ino: Number(identity.ino) });
|
|
102
|
-
}
|
|
103
|
-
export async function pinPhysicalDirectoryIfPresent(directory) {
|
|
104
|
-
const lexical = path.resolve(directory);
|
|
105
|
-
await assertNoUserControlledSymlinkAncestor(lexical);
|
|
106
|
-
if (await lstat(lexical) === undefined)
|
|
107
|
-
return undefined;
|
|
108
|
-
return pinPhysicalDirectory(lexical);
|
|
109
|
-
}
|
|
110
|
-
export async function canonicalPhysicalPath(target) {
|
|
111
|
-
const lexical = path.resolve(target);
|
|
112
|
-
await assertNoUserControlledSymlinkAncestor(lexical);
|
|
113
|
-
let existing = lexical;
|
|
114
|
-
const suffix = [];
|
|
115
|
-
while (await lstat(existing) === undefined) {
|
|
116
|
-
const parent = path.dirname(existing);
|
|
117
|
-
if (parent === existing)
|
|
118
|
-
unsafe();
|
|
119
|
-
suffix.unshift(path.basename(existing));
|
|
120
|
-
existing = parent;
|
|
121
|
-
}
|
|
122
|
-
const pin = await pinPhysicalDirectory(existing);
|
|
123
|
-
return path.join(pin.physical, ...suffix);
|
|
124
|
-
}
|
|
125
|
-
export async function assertPhysicalDirectoryPin(pin) {
|
|
126
|
-
await assertNoUserControlledSymlinkAncestor(pin.lexical);
|
|
127
|
-
const physical = await fs.realpath(pin.lexical).catch(() => unsafe());
|
|
128
|
-
const observed = await fs.lstat(pin.physical).catch(() => unsafe());
|
|
129
|
-
assertDirectory(observed);
|
|
130
|
-
if (physical !== pin.physical || !sameStableDirectoryIdentity(stableStatIdentity(observed), pin))
|
|
131
|
-
throw new Error("SKILL_LOOP_CONFLICT");
|
|
132
|
-
}
|
|
133
|
-
export async function pinPhysicalSkillLoopRoots(projectRoot, configRoot) {
|
|
134
|
-
const project = await pinPhysicalDirectory(projectRoot);
|
|
135
|
-
const config = await pinPhysicalDirectory(configRoot, true);
|
|
136
|
-
const skills = await pinPhysicalDirectory(path.join(config.physical, "skills"), true);
|
|
137
|
-
await assertPhysicalDirectoryPin(project);
|
|
138
|
-
await assertPhysicalDirectoryPin(config);
|
|
139
|
-
await assertPhysicalDirectoryPin(skills);
|
|
140
|
-
return Object.freeze({ project, config, skills });
|
|
141
|
-
}
|
|
142
|
-
async function runtimeLocation(projectRoot, target) {
|
|
143
|
-
const lexicalProject = path.resolve(projectRoot);
|
|
144
|
-
const lexicalRuntime = path.join(lexicalProject, ".litopencode");
|
|
145
|
-
const lexicalTarget = path.resolve(target);
|
|
146
|
-
const project = await pinPhysicalDirectory(lexicalProject);
|
|
147
|
-
const physicalRuntime = path.join(project.physical, ".litopencode");
|
|
148
|
-
if (!isContained(lexicalRuntime, lexicalTarget) && !isContained(physicalRuntime, lexicalTarget))
|
|
149
|
-
unsafe();
|
|
150
|
-
const relative = isContained(lexicalRuntime, lexicalTarget)
|
|
151
|
-
? path.relative(lexicalProject, lexicalTarget)
|
|
152
|
-
: path.relative(project.physical, lexicalTarget);
|
|
153
|
-
return { project, runtime: path.join(project.physical, ".litopencode"), target: path.join(project.physical, relative) };
|
|
154
|
-
}
|
|
155
|
-
export async function ensureSafeRuntimeDirectory(projectRoot, directory) {
|
|
156
|
-
const location = await runtimeLocation(projectRoot, directory);
|
|
157
|
-
let current = location.project.physical;
|
|
158
|
-
for (const component of path.relative(location.project.physical, location.target).split(path.sep).filter(Boolean)) {
|
|
159
|
-
current = path.join(current, component);
|
|
160
|
-
const observed = await lstat(current);
|
|
161
|
-
if (observed !== undefined) {
|
|
162
|
-
assertDirectory(observed);
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
try {
|
|
166
|
-
await fs.mkdir(current, { mode: 0o700 });
|
|
167
|
-
}
|
|
168
|
-
catch (error) {
|
|
169
|
-
if (errorCode(error) !== "EEXIST")
|
|
170
|
-
throw error;
|
|
171
|
-
}
|
|
172
|
-
assertDirectory(await lstat(current));
|
|
173
|
-
}
|
|
174
|
-
await assertPhysicalDirectoryPin(location.project);
|
|
175
|
-
}
|
|
176
|
-
async function safeRuntimeDirectoryExists(projectRoot, directory) {
|
|
177
|
-
const location = await runtimeLocation(projectRoot, directory);
|
|
178
|
-
let current = location.project.physical;
|
|
179
|
-
for (const component of path.relative(location.project.physical, location.target).split(path.sep).filter(Boolean)) {
|
|
180
|
-
current = path.join(current, component);
|
|
181
|
-
const observed = await lstat(current);
|
|
182
|
-
if (observed === undefined)
|
|
183
|
-
return { exists: false, target: location.target };
|
|
184
|
-
assertDirectory(observed);
|
|
185
|
-
}
|
|
186
|
-
await assertPhysicalDirectoryPin(location.project);
|
|
187
|
-
return { exists: true, target: location.target };
|
|
188
|
-
}
|
|
189
|
-
export async function assertSafeRuntimeFileForWrite(projectRoot, file) {
|
|
190
|
-
const location = await runtimeLocation(projectRoot, file);
|
|
191
|
-
await ensureSafeRuntimeDirectory(projectRoot, path.dirname(file));
|
|
192
|
-
const observed = await lstat(location.target);
|
|
193
|
-
if (observed !== undefined)
|
|
194
|
-
assertRegular(observed);
|
|
195
|
-
await assertPhysicalDirectoryPin(location.project);
|
|
196
|
-
}
|
|
197
|
-
export async function readSafeRuntimeFile(projectRoot, file, options = {}) {
|
|
198
|
-
const directory = await safeRuntimeDirectoryExists(projectRoot, path.dirname(file));
|
|
199
|
-
if (!directory.exists) {
|
|
200
|
-
if (options.allowMissing !== false)
|
|
201
|
-
return undefined;
|
|
202
|
-
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
203
|
-
}
|
|
204
|
-
const target = path.join(directory.target, path.basename(file));
|
|
205
|
-
const observed = await lstat(target);
|
|
206
|
-
if (observed === undefined) {
|
|
207
|
-
if (options.allowMissing !== false)
|
|
208
|
-
return undefined;
|
|
209
|
-
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
210
|
-
}
|
|
211
|
-
assertRegular(observed);
|
|
212
|
-
if (options.maxBytes !== undefined && observed.size > options.maxBytes)
|
|
213
|
-
throw new Error("SKILL_LOOP_FILE_TOO_LARGE");
|
|
214
|
-
let handle;
|
|
215
|
-
try {
|
|
216
|
-
handle = await fs.open(target, noFollowRead);
|
|
217
|
-
}
|
|
218
|
-
catch (error) {
|
|
219
|
-
if (errorCode(error) === "ELOOP")
|
|
220
|
-
unsafe();
|
|
221
|
-
throw error;
|
|
222
|
-
}
|
|
223
|
-
try {
|
|
224
|
-
const descriptor = await handle.stat();
|
|
225
|
-
if (!descriptor.isFile() || descriptor.nlink !== 1 || !sameStableStatIdentity(stableStatIdentity(descriptor), stableStatIdentity(observed)))
|
|
226
|
-
unsafe();
|
|
227
|
-
const bytes = await handle.readFile();
|
|
228
|
-
if (options.maxBytes !== undefined && bytes.byteLength > options.maxBytes)
|
|
229
|
-
throw new Error("SKILL_LOOP_FILE_TOO_LARGE");
|
|
230
|
-
const final = await lstat(target);
|
|
231
|
-
if (final === undefined || final.isSymbolicLink() || !final.isFile() || final.nlink !== 1 ||
|
|
232
|
-
!sameStableStatIdentity(stableStatIdentity(final), stableStatIdentity(descriptor)))
|
|
233
|
-
unsafe();
|
|
234
|
-
return bytes;
|
|
235
|
-
}
|
|
236
|
-
finally {
|
|
237
|
-
await handle.close();
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
export async function writeSafeRuntimeFile(projectRoot, file, bytes) {
|
|
241
|
-
const location = await runtimeLocation(projectRoot, file);
|
|
242
|
-
const target = location.target;
|
|
243
|
-
await ensureSafeRuntimeDirectory(projectRoot, path.dirname(file));
|
|
244
|
-
const parentBefore = await fs.lstat(path.dirname(target));
|
|
245
|
-
assertDirectory(parentBefore);
|
|
246
|
-
const before = await lstat(target);
|
|
247
|
-
if (before !== undefined)
|
|
248
|
-
assertRegular(before);
|
|
249
|
-
const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${randomUUID()}.tmp`);
|
|
250
|
-
let handle;
|
|
251
|
-
try {
|
|
252
|
-
handle = await fs.open(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0), 0o600);
|
|
253
|
-
await handle.writeFile(bytes);
|
|
254
|
-
await handle.sync();
|
|
255
|
-
await handle.chmod(0o600);
|
|
256
|
-
await handle.close();
|
|
257
|
-
handle = undefined;
|
|
258
|
-
await assertPhysicalDirectoryPin(location.project);
|
|
259
|
-
const parentAfter = await fs.lstat(path.dirname(target));
|
|
260
|
-
if (!sameStableDirectoryIdentity(stableStatIdentity(parentAfter), stableStatIdentity(parentBefore)))
|
|
261
|
-
throw new Error("SKILL_LOOP_CONFLICT");
|
|
262
|
-
const current = await lstat(target);
|
|
263
|
-
if ((before === undefined) !== (current === undefined) ||
|
|
264
|
-
(before !== undefined && current !== undefined && !sameStableStatIdentity(stableStatIdentity(before), stableStatIdentity(current)))) {
|
|
265
|
-
throw new Error("SKILL_LOOP_CONFLICT");
|
|
266
|
-
}
|
|
267
|
-
if (current !== undefined)
|
|
268
|
-
assertRegular(current);
|
|
269
|
-
await fs.rename(temporary, target);
|
|
270
|
-
await syncDirectory(path.dirname(target));
|
|
271
|
-
}
|
|
272
|
-
catch (error) {
|
|
273
|
-
if (handle !== undefined)
|
|
274
|
-
await handle.close().catch(() => undefined);
|
|
275
|
-
await fs.rm(temporary, { force: true }).catch(() => undefined);
|
|
276
|
-
throw error;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
export async function removeSafeRuntimeFile(projectRoot, file) {
|
|
280
|
-
const directory = await safeRuntimeDirectoryExists(projectRoot, path.dirname(file));
|
|
281
|
-
if (!directory.exists)
|
|
282
|
-
return;
|
|
283
|
-
const target = path.join(directory.target, path.basename(file));
|
|
284
|
-
const observed = await lstat(target);
|
|
285
|
-
if (observed === undefined)
|
|
286
|
-
return;
|
|
287
|
-
assertRegular(observed);
|
|
288
|
-
await fs.unlink(target);
|
|
289
|
-
await syncDirectory(path.dirname(target));
|
|
290
|
-
}
|
|
291
|
-
export async function syncDirectory(directory) {
|
|
292
|
-
const stat = await fs.lstat(directory);
|
|
293
|
-
assertDirectory(stat);
|
|
294
|
-
const handle = await fs.open(directory, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
295
|
-
try {
|
|
296
|
-
await handle.sync();
|
|
297
|
-
}
|
|
298
|
-
finally {
|
|
299
|
-
await handle.close();
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
const anchoredSkillMutationWorker = String.raw `
|
|
303
|
-
const fs = require("node:fs");
|
|
304
|
-
const path = require("node:path");
|
|
305
|
-
const crypto = require("node:crypto");
|
|
306
|
-
function fail() { process.exit(70); }
|
|
307
|
-
function relativeName(value) {
|
|
308
|
-
if (typeof value !== "string" || value === "" || path.isAbsolute(value) || path.basename(value) !== value || value === "." || value === "..") fail();
|
|
309
|
-
return value;
|
|
310
|
-
}
|
|
311
|
-
function safeTreeEntry(file) {
|
|
312
|
-
const stat = fs.lstatSync(file);
|
|
313
|
-
if (stat.isSymbolicLink()) fail();
|
|
314
|
-
return stat;
|
|
315
|
-
}
|
|
316
|
-
function sameIdentity(left, right) {
|
|
317
|
-
return Number(left.dev) === Number(right.dev) && Number(left.ino) === Number(right.ino) &&
|
|
318
|
-
Number(left.ctimeMs) === Number(right.ctimeMs) && Number(left.birthtimeMs) === Number(right.birthtimeMs);
|
|
319
|
-
}
|
|
320
|
-
function sameDirectoryIdentity(left, right) {
|
|
321
|
-
return Number(left.dev) === Number(right.dev) && Number(left.ino) === Number(right.ino) &&
|
|
322
|
-
Number(left.birthtimeMs) === Number(right.birthtimeMs);
|
|
323
|
-
}
|
|
324
|
-
function identityOf(stat) {
|
|
325
|
-
return { dev: Number(stat.dev), ino: Number(stat.ino), ctimeMs: Number(stat.ctimeMs), birthtimeMs: Number(stat.birthtimeMs) };
|
|
326
|
-
}
|
|
327
|
-
function expectedFiles(request) {
|
|
328
|
-
if (!Array.isArray(request.files) || request.files.length > 4096) fail();
|
|
329
|
-
const result = new Map();
|
|
330
|
-
for (const item of request.files) {
|
|
331
|
-
if (!item || typeof item.file !== "string" || path.isAbsolute(item.file) || item.file.split(/[\\/]/u).some((part) => !part || part === "." || part === "..") ||
|
|
332
|
-
typeof item.digest !== "string" || !/^[a-f0-9]{64}$/u.test(item.digest) || !Number.isInteger(item.size) || item.size < 0 || result.has(item.file)) fail();
|
|
333
|
-
result.set(item.file, item);
|
|
334
|
-
}
|
|
335
|
-
return result;
|
|
336
|
-
}
|
|
337
|
-
function copyTree(source, destination, budget, expected, prefix = "") {
|
|
338
|
-
const stat = safeTreeEntry(source);
|
|
339
|
-
if (!stat.isDirectory()) fail();
|
|
340
|
-
fs.mkdirSync(destination, { mode: 0o700 });
|
|
341
|
-
const entries = fs.readdirSync(source, { withFileTypes: true });
|
|
342
|
-
budget.entries += entries.length;
|
|
343
|
-
if (budget.entries > 4096) fail();
|
|
344
|
-
for (const entry of entries) {
|
|
345
|
-
const from = path.join(source, entry.name);
|
|
346
|
-
const to = path.join(destination, entry.name);
|
|
347
|
-
const item = safeTreeEntry(from);
|
|
348
|
-
if (item.isDirectory()) {
|
|
349
|
-
copyTree(from, to, budget, expected, prefix === "" ? entry.name : path.posix.join(prefix, entry.name));
|
|
350
|
-
continue;
|
|
351
|
-
}
|
|
352
|
-
if (!item.isFile() || item.nlink !== 1 || item.size > 8 * 1024 * 1024) fail();
|
|
353
|
-
budget.bytes += item.size;
|
|
354
|
-
if (budget.bytes > 32 * 1024 * 1024) fail();
|
|
355
|
-
const input = fs.openSync(from, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
356
|
-
const output = fs.openSync(to, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
|
|
357
|
-
try {
|
|
358
|
-
const opened = fs.fstatSync(input);
|
|
359
|
-
if (!opened.isFile() || opened.nlink !== 1 || !sameIdentity(opened, item) || opened.size !== item.size) fail();
|
|
360
|
-
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
361
|
-
let offset = 0;
|
|
362
|
-
const hash = crypto.createHash("sha256");
|
|
363
|
-
while (offset < item.size) {
|
|
364
|
-
const read = fs.readSync(input, buffer, 0, Math.min(buffer.length, item.size - offset), offset);
|
|
365
|
-
if (read <= 0) fail();
|
|
366
|
-
hash.update(buffer.subarray(0, read));
|
|
367
|
-
let written = 0;
|
|
368
|
-
while (written < read) written += fs.writeSync(output, buffer, written, read - written);
|
|
369
|
-
offset += read;
|
|
370
|
-
}
|
|
371
|
-
const fileName = prefix === "" ? entry.name : path.posix.join(prefix, entry.name);
|
|
372
|
-
const expectedFile = expected.get(fileName);
|
|
373
|
-
if (!expectedFile || expectedFile.size !== item.size || expectedFile.digest !== hash.digest("hex")) fail();
|
|
374
|
-
budget.seen.add(fileName);
|
|
375
|
-
const final = safeTreeEntry(from);
|
|
376
|
-
if (!sameIdentity(final, opened) || final.size !== opened.size) fail();
|
|
377
|
-
fs.fsyncSync(output);
|
|
378
|
-
} finally {
|
|
379
|
-
fs.closeSync(input);
|
|
380
|
-
fs.closeSync(output);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
const descriptor = fs.openSync(destination, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
384
|
-
try { fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); }
|
|
385
|
-
}
|
|
386
|
-
function verifyTree(root, expected, prefix = "", seen = new Set()) {
|
|
387
|
-
const rootStat = safeTreeEntry(root);
|
|
388
|
-
if (!rootStat.isDirectory()) fail();
|
|
389
|
-
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
390
|
-
for (const entry of entries) {
|
|
391
|
-
const target = path.join(root, entry.name);
|
|
392
|
-
const relative = prefix === "" ? entry.name : path.posix.join(prefix, entry.name);
|
|
393
|
-
const stat = safeTreeEntry(target);
|
|
394
|
-
if (stat.isDirectory()) { verifyTree(target, expected, relative, seen); continue; }
|
|
395
|
-
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 8 * 1024 * 1024) fail();
|
|
396
|
-
const descriptor = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
397
|
-
try {
|
|
398
|
-
const opened = fs.fstatSync(descriptor);
|
|
399
|
-
if (!sameIdentity(opened, stat) || opened.size !== stat.size) fail();
|
|
400
|
-
const bytes = fs.readFileSync(descriptor);
|
|
401
|
-
const final = safeTreeEntry(target);
|
|
402
|
-
const expectedFile = expected.get(relative);
|
|
403
|
-
if (!expectedFile || expectedFile.size !== bytes.length || expectedFile.digest !== crypto.createHash("sha256").update(bytes).digest("hex") ||
|
|
404
|
-
!sameIdentity(final, opened) || final.size !== opened.size) fail();
|
|
405
|
-
seen.add(relative);
|
|
406
|
-
} finally { fs.closeSync(descriptor); }
|
|
407
|
-
}
|
|
408
|
-
if (prefix === "" && seen.size !== expected.size) fail();
|
|
409
|
-
}
|
|
410
|
-
try {
|
|
411
|
-
const request = JSON.parse(process.argv[1]);
|
|
412
|
-
const root = fs.statSync(".");
|
|
413
|
-
if (!sameDirectoryIdentity(root, request)) fail();
|
|
414
|
-
let response = "ok";
|
|
415
|
-
if (request.action === "rename") {
|
|
416
|
-
const source = relativeName(request.source);
|
|
417
|
-
const destination = relativeName(request.destination);
|
|
418
|
-
safeTreeEntry(source);
|
|
419
|
-
try { fs.lstatSync(destination); fail(); } catch (error) { if (!error || error.code !== "ENOENT") fail(); }
|
|
420
|
-
fs.renameSync(source, destination);
|
|
421
|
-
} else if (request.action === "remove") {
|
|
422
|
-
const target = relativeName(request.target);
|
|
423
|
-
try {
|
|
424
|
-
const stat = safeTreeEntry(target);
|
|
425
|
-
if (!stat.isDirectory()) fail();
|
|
426
|
-
fs.rmSync(target, { recursive: true, force: false });
|
|
427
|
-
} catch (error) {
|
|
428
|
-
if (!error || error.code !== "ENOENT") fail();
|
|
429
|
-
}
|
|
430
|
-
} else if (request.action === "copy") {
|
|
431
|
-
const destination = relativeName(request.destination);
|
|
432
|
-
if (typeof request.source !== "string" || !path.isAbsolute(request.source)) fail();
|
|
433
|
-
try { fs.lstatSync(destination); fail(); } catch (error) { if (!error || error.code !== "ENOENT") fail(); }
|
|
434
|
-
const expected = expectedFiles(request);
|
|
435
|
-
const budget = { entries: 0, bytes: 0, seen: new Set() };
|
|
436
|
-
copyTree(request.source, destination, budget, expected);
|
|
437
|
-
if (budget.seen.size !== expected.size) fail();
|
|
438
|
-
} else if (request.action === "verify") {
|
|
439
|
-
const target = relativeName(request.target);
|
|
440
|
-
verifyTree(target, expectedFiles(request));
|
|
441
|
-
} else if (request.action === "absent") {
|
|
442
|
-
const target = relativeName(request.target);
|
|
443
|
-
try { fs.lstatSync(target); fail(); } catch (error) { if (!error || error.code !== "ENOENT") fail(); }
|
|
444
|
-
} else if (request.action === "read-file") {
|
|
445
|
-
const target = relativeName(request.target);
|
|
446
|
-
let observed;
|
|
447
|
-
try { observed = safeTreeEntry(target); } catch (error) {
|
|
448
|
-
if (error && error.code === "ENOENT") response = JSON.stringify({ missing: true });
|
|
449
|
-
else fail();
|
|
450
|
-
}
|
|
451
|
-
if (observed) {
|
|
452
|
-
if (!observed.isFile() || observed.nlink !== 1 || observed.size > request.maxBytes || (observed.mode & 0o077) !== 0) fail();
|
|
453
|
-
const descriptor = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
454
|
-
try {
|
|
455
|
-
const current = fs.fstatSync(descriptor);
|
|
456
|
-
if (!sameIdentity(current, observed) || current.size !== observed.size) fail();
|
|
457
|
-
const bytes = fs.readFileSync(descriptor);
|
|
458
|
-
if (bytes.length > request.maxBytes) fail();
|
|
459
|
-
const final = safeTreeEntry(target);
|
|
460
|
-
if (!sameIdentity(final, current) || final.size !== current.size) fail();
|
|
461
|
-
response = JSON.stringify({ missing: false, ...identityOf(current), bytes: bytes.toString("base64") });
|
|
462
|
-
} finally { fs.closeSync(descriptor); }
|
|
463
|
-
}
|
|
464
|
-
} else if (request.action === "write-file") {
|
|
465
|
-
const target = relativeName(request.target);
|
|
466
|
-
const temporary = relativeName(request.temporary);
|
|
467
|
-
const bytes = Buffer.from(request.bytes, "base64");
|
|
468
|
-
if (bytes.length > 4096) fail();
|
|
469
|
-
let before;
|
|
470
|
-
try { before = safeTreeEntry(target); } catch (error) { if (!error || error.code !== "ENOENT") fail(); }
|
|
471
|
-
if (before && (!before.isFile() || before.nlink !== 1 || (before.mode & 0o077) !== 0)) fail();
|
|
472
|
-
if ((request.expected === null) !== (before === undefined) ||
|
|
473
|
-
(before && !sameIdentity(before, request.expected))) fail();
|
|
474
|
-
const descriptor = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
|
|
475
|
-
try { fs.writeFileSync(descriptor, bytes); fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); }
|
|
476
|
-
let current;
|
|
477
|
-
try { current = safeTreeEntry(target); } catch (error) { if (!error || error.code !== "ENOENT") fail(); }
|
|
478
|
-
if ((request.expected === null) !== (current === undefined) ||
|
|
479
|
-
(current && !sameIdentity(current, request.expected))) fail();
|
|
480
|
-
fs.renameSync(temporary, target);
|
|
481
|
-
} else fail();
|
|
482
|
-
const descriptor = fs.openSync(".", fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
483
|
-
try { fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); }
|
|
484
|
-
process.stdout.write(response);
|
|
485
|
-
} catch { fail(); }
|
|
486
|
-
`;
|
|
487
|
-
async function runAnchoredSkillMutation(capability, request) {
|
|
488
|
-
const scope = capabilities.get(capability);
|
|
489
|
-
if (scope?.rootPins === undefined)
|
|
490
|
-
throw new Error("SKILL_LOOP_LOCK_CAPABILITY_INVALID");
|
|
491
|
-
await assertSkillLoopRootPins(capability);
|
|
492
|
-
const result = spawnSync(process.execPath, ["-e", anchoredSkillMutationWorker, JSON.stringify({
|
|
493
|
-
...request,
|
|
494
|
-
dev: scope.rootPins.skills.dev,
|
|
495
|
-
ino: scope.rootPins.skills.ino,
|
|
496
|
-
ctimeMs: scope.rootPins.skills.ctimeMs,
|
|
497
|
-
birthtimeMs: scope.rootPins.skills.birthtimeMs
|
|
498
|
-
})], {
|
|
499
|
-
cwd: scope.rootPins.skills.physical,
|
|
500
|
-
env: { PATH: "", NO_COLOR: "1" },
|
|
501
|
-
encoding: "utf8",
|
|
502
|
-
timeout: 30_000,
|
|
503
|
-
maxBuffer: 16 * 1024
|
|
504
|
-
});
|
|
505
|
-
if (result.status !== 0)
|
|
506
|
-
throw new Error("SKILL_LOOP_CONFLICT");
|
|
507
|
-
return result.stdout;
|
|
508
|
-
}
|
|
509
|
-
export async function anchoredRenameInSkillsRoot(capability, source, destination) {
|
|
510
|
-
const response = await runAnchoredSkillMutation(capability, { action: "rename", source, destination });
|
|
511
|
-
if (response !== "ok")
|
|
512
|
-
throw new Error("SKILL_LOOP_CONFLICT");
|
|
513
|
-
}
|
|
514
|
-
export async function anchoredRemoveFromSkillsRoot(capability, target) {
|
|
515
|
-
const response = await runAnchoredSkillMutation(capability, { action: "remove", target });
|
|
516
|
-
if (response !== "ok")
|
|
517
|
-
throw new Error("SKILL_LOOP_CONFLICT");
|
|
518
|
-
}
|
|
519
|
-
export async function anchoredCopyIntoSkillsRoot(capability, source, destination, files) {
|
|
520
|
-
const response = await runAnchoredSkillMutation(capability, {
|
|
521
|
-
action: "copy",
|
|
522
|
-
source: path.resolve(source),
|
|
523
|
-
destination,
|
|
524
|
-
files: files.map((item) => ({ file: item.file, digest: item.blob.digest, size: item.blob.size }))
|
|
525
|
-
});
|
|
526
|
-
if (response !== "ok")
|
|
527
|
-
throw new Error("SKILL_LOOP_CONFLICT");
|
|
528
|
-
}
|
|
529
|
-
export async function anchoredVerifySkillTree(capability, target, files) {
|
|
530
|
-
const response = await runAnchoredSkillMutation(capability, {
|
|
531
|
-
action: "verify",
|
|
532
|
-
target,
|
|
533
|
-
files: files.map((item) => ({ file: item.file, digest: item.blob.digest, size: item.blob.size }))
|
|
534
|
-
});
|
|
535
|
-
if (response !== "ok")
|
|
536
|
-
throw new Error("SKILL_LOOP_CONFLICT");
|
|
537
|
-
}
|
|
538
|
-
export async function anchoredAssertSkillPathAbsent(capability, target) {
|
|
539
|
-
const response = await runAnchoredSkillMutation(capability, { action: "absent", target });
|
|
540
|
-
if (response !== "ok")
|
|
541
|
-
throw new Error("SKILL_LOOP_CONFLICT");
|
|
542
|
-
}
|
|
543
|
-
export async function anchoredReadFileFromSkillsRoot(capability, target, maxBytes) {
|
|
544
|
-
const response = await runAnchoredSkillMutation(capability, { action: "read-file", target, maxBytes });
|
|
545
|
-
let parsed;
|
|
546
|
-
try {
|
|
547
|
-
parsed = JSON.parse(response);
|
|
548
|
-
}
|
|
549
|
-
catch {
|
|
550
|
-
throw new Error("SKILL_LOOP_CONFLICT");
|
|
551
|
-
}
|
|
552
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
553
|
-
throw new Error("SKILL_LOOP_CONFLICT");
|
|
554
|
-
const record = parsed;
|
|
555
|
-
if (record.missing === true && Object.keys(record).length === 1)
|
|
556
|
-
return undefined;
|
|
557
|
-
if (record.missing !== false || typeof record.bytes !== "string" || !Number.isInteger(record.dev) || !Number.isInteger(record.ino) ||
|
|
558
|
-
typeof record.ctimeMs !== "number" || typeof record.birthtimeMs !== "number") {
|
|
559
|
-
throw new Error("SKILL_LOOP_CONFLICT");
|
|
560
|
-
}
|
|
561
|
-
return {
|
|
562
|
-
bytes: Buffer.from(record.bytes, "base64"),
|
|
563
|
-
dev: Number(record.dev),
|
|
564
|
-
ino: Number(record.ino),
|
|
565
|
-
ctimeMs: Number(record.ctimeMs),
|
|
566
|
-
birthtimeMs: Number(record.birthtimeMs)
|
|
567
|
-
};
|
|
568
|
-
}
|
|
569
|
-
export async function anchoredWriteFileToSkillsRoot(capability, target, bytes, expected) {
|
|
570
|
-
const response = await runAnchoredSkillMutation(capability, {
|
|
571
|
-
action: "write-file",
|
|
572
|
-
target,
|
|
573
|
-
temporary: `.${target}.${randomUUID()}.tmp`,
|
|
574
|
-
bytes: Buffer.from(bytes).toString("base64"),
|
|
575
|
-
expected
|
|
576
|
-
});
|
|
577
|
-
if (response !== "ok")
|
|
578
|
-
throw new Error("SKILL_LOOP_CONFLICT");
|
|
579
|
-
}
|
|
580
|
-
function delay(milliseconds) {
|
|
581
|
-
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
582
|
-
}
|
|
583
|
-
export function processBirthIdentityForPlatform(pid, platform, probe = (command, args) => spawnSync(command, args, {
|
|
584
|
-
encoding: "utf8",
|
|
585
|
-
env: { PATH: platform === "win32" ? "C:\\Windows\\System32\\WindowsPowerShell\\v1.0;C:\\Windows\\System32" : "/usr/bin:/bin", LANG: "C" },
|
|
586
|
-
timeout: 1_000,
|
|
587
|
-
maxBuffer: 1024
|
|
588
|
-
})) {
|
|
589
|
-
if (platform === "win32")
|
|
590
|
-
return undefined;
|
|
591
|
-
const result = probe("ps", ["-o", "lstart=", "-p", String(pid)]);
|
|
592
|
-
const value = result.status === 0 ? result.stdout.trim() : "";
|
|
593
|
-
return value.length > 0 && value.length <= 128 ? value : undefined;
|
|
594
|
-
}
|
|
595
|
-
export function assertSkillLoopGlobalBoundarySupported(platform = process.platform) {
|
|
596
|
-
if (platform === "win32")
|
|
597
|
-
throw new Error("WINDOWS_SKILL_LOOP_UNSUPPORTED");
|
|
598
|
-
}
|
|
599
|
-
function processIdentity(pid) {
|
|
600
|
-
return processBirthIdentityForPlatform(pid, process.platform);
|
|
601
|
-
}
|
|
602
|
-
function processIsSame(pid, identity) {
|
|
603
|
-
try {
|
|
604
|
-
process.kill(pid, 0);
|
|
605
|
-
}
|
|
606
|
-
catch (error) {
|
|
607
|
-
if (errorCode(error) === "ESRCH")
|
|
608
|
-
return false;
|
|
609
|
-
return true;
|
|
610
|
-
}
|
|
611
|
-
if (identity === null)
|
|
612
|
-
return true;
|
|
613
|
-
const current = processIdentity(pid);
|
|
614
|
-
return current === undefined || current === identity;
|
|
615
|
-
}
|
|
616
|
-
async function readLockOwner(ownerFile) {
|
|
617
|
-
const stat = await lstat(ownerFile);
|
|
618
|
-
assertRegular(stat);
|
|
619
|
-
if (Number(stat.size) > 1024 || (Number(stat.mode) & 0o077) !== 0)
|
|
620
|
-
unsafe();
|
|
621
|
-
const handle = await fs.open(ownerFile, noFollowRead);
|
|
622
|
-
try {
|
|
623
|
-
const descriptor = await handle.stat();
|
|
624
|
-
if (!descriptor.isFile() || descriptor.nlink !== 1 ||
|
|
625
|
-
!sameStableStatIdentity(stableStatIdentity(descriptor), stableStatIdentity(stat)))
|
|
626
|
-
unsafe();
|
|
627
|
-
const parsed = JSON.parse((await handle.readFile()).toString("utf8"));
|
|
628
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) ||
|
|
629
|
-
Object.keys(parsed).some((key) => key !== "pid" && key !== "token" && key !== "processIdentity") ||
|
|
630
|
-
!Number.isInteger(parsed.pid) || Number(parsed.pid) < 1 ||
|
|
631
|
-
typeof parsed.token !== "string" || !/^[a-f0-9-]{36}$/u.test(parsed.token) ||
|
|
632
|
-
(parsed.processIdentity !== null &&
|
|
633
|
-
(typeof parsed.processIdentity !== "string" || parsed.processIdentity.length < 1)))
|
|
634
|
-
unsafe();
|
|
635
|
-
return parsed;
|
|
636
|
-
}
|
|
637
|
-
finally {
|
|
638
|
-
await handle.close();
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
async function retireDeadLock(lock) {
|
|
642
|
-
const lockStat = await lstat(lock);
|
|
643
|
-
if (lockStat === undefined)
|
|
644
|
-
return true;
|
|
645
|
-
assertDirectory(lockStat);
|
|
646
|
-
const ownerFile = path.join(lock, "owner.json");
|
|
647
|
-
const ownerStat = await lstat(ownerFile);
|
|
648
|
-
if (ownerStat === undefined) {
|
|
649
|
-
if (Date.now() - Number(lockStat.mtimeMs) < ownerlessLockGraceMs)
|
|
650
|
-
return false;
|
|
651
|
-
const entries = await fs.readdir(lock, { withFileTypes: true });
|
|
652
|
-
if (entries.length > 1 || entries.some((entry) => !entry.isFile() || !/^\.owner\.[a-f0-9-]{36}\.tmp$/u.test(entry.name)))
|
|
653
|
-
return false;
|
|
654
|
-
if (entries.length === 1) {
|
|
655
|
-
const temporary = path.join(lock, entries[0].name);
|
|
656
|
-
const before = await lstat(temporary);
|
|
657
|
-
assertRegular(before);
|
|
658
|
-
const currentLock = await lstat(lock);
|
|
659
|
-
const current = await lstat(temporary);
|
|
660
|
-
if (currentLock === undefined || current === undefined ||
|
|
661
|
-
!sameStableDirectoryIdentity(stableStatIdentity(currentLock), stableStatIdentity(lockStat)) ||
|
|
662
|
-
!sameStableStatIdentity(stableStatIdentity(current), stableStatIdentity(before)))
|
|
663
|
-
throw new Error("SKILL_LOOP_CONFLICT");
|
|
664
|
-
await fs.unlink(temporary);
|
|
665
|
-
}
|
|
666
|
-
try {
|
|
667
|
-
await fs.rmdir(lock);
|
|
668
|
-
await syncDirectory(path.dirname(lock));
|
|
669
|
-
return true;
|
|
670
|
-
}
|
|
671
|
-
catch (error) {
|
|
672
|
-
if (errorCode(error) === "ENOENT")
|
|
673
|
-
return true;
|
|
674
|
-
if (errorCode(error) === "ENOTEMPTY")
|
|
675
|
-
return false;
|
|
676
|
-
throw error;
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
const owner = await readLockOwner(ownerFile);
|
|
680
|
-
if (processIsSame(owner.pid, owner.processIdentity))
|
|
681
|
-
return false;
|
|
682
|
-
const currentLock = await lstat(lock);
|
|
683
|
-
const currentOwner = await lstat(ownerFile);
|
|
684
|
-
if (currentLock === undefined || currentOwner === undefined ||
|
|
685
|
-
!sameStableDirectoryIdentity(stableStatIdentity(currentLock), stableStatIdentity(lockStat)) ||
|
|
686
|
-
!sameStableStatIdentity(stableStatIdentity(currentOwner), stableStatIdentity(ownerStat)))
|
|
687
|
-
throw new Error("SKILL_LOOP_CONFLICT");
|
|
688
|
-
await fs.unlink(ownerFile);
|
|
689
|
-
await fs.rmdir(lock);
|
|
690
|
-
await syncDirectory(path.dirname(lock));
|
|
691
|
-
return true;
|
|
692
|
-
}
|
|
693
|
-
async function withOwnerTokenLock(parent, name, run) {
|
|
694
|
-
const lock = path.join(parent, name);
|
|
695
|
-
const token = randomUUID();
|
|
696
|
-
const identity = processIdentity(process.pid) ?? null;
|
|
697
|
-
const deadline = Date.now() + lockTimeoutMs;
|
|
698
|
-
while (true) {
|
|
699
|
-
try {
|
|
700
|
-
await fs.mkdir(lock, { mode: 0o700 });
|
|
701
|
-
break;
|
|
702
|
-
}
|
|
703
|
-
catch (error) {
|
|
704
|
-
if (errorCode(error) !== "EEXIST")
|
|
705
|
-
throw error;
|
|
706
|
-
assertDirectory(await lstat(lock));
|
|
707
|
-
if (await retireDeadLock(lock))
|
|
708
|
-
continue;
|
|
709
|
-
if (Date.now() >= deadline)
|
|
710
|
-
throw new Error("SKILL_LOOP_BUSY");
|
|
711
|
-
await delay(lockRetryMs);
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
const ownerFile = path.join(lock, "owner.json");
|
|
715
|
-
const temporaryOwnerFile = path.join(lock, `.owner.${token}.tmp`);
|
|
716
|
-
try {
|
|
717
|
-
const owner = await fs.open(temporaryOwnerFile, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0), 0o600);
|
|
718
|
-
try {
|
|
719
|
-
await owner.writeFile(`${JSON.stringify({ pid: process.pid, token, processIdentity: identity })}\n`);
|
|
720
|
-
await owner.sync();
|
|
721
|
-
}
|
|
722
|
-
finally {
|
|
723
|
-
await owner.close();
|
|
724
|
-
}
|
|
725
|
-
await fs.rename(temporaryOwnerFile, ownerFile);
|
|
726
|
-
await syncDirectory(lock);
|
|
727
|
-
await syncDirectory(parent);
|
|
728
|
-
}
|
|
729
|
-
catch (error) {
|
|
730
|
-
await fs.unlink(temporaryOwnerFile).catch(() => undefined);
|
|
731
|
-
await fs.unlink(ownerFile).catch(() => undefined);
|
|
732
|
-
await fs.rmdir(lock).catch(() => undefined);
|
|
733
|
-
throw error;
|
|
734
|
-
}
|
|
735
|
-
try {
|
|
736
|
-
return await run(token);
|
|
737
|
-
}
|
|
738
|
-
finally {
|
|
739
|
-
assertDirectory(await lstat(lock));
|
|
740
|
-
const owner = await readLockOwner(ownerFile);
|
|
741
|
-
if (owner.token !== token || owner.pid !== process.pid || owner.processIdentity !== identity)
|
|
742
|
-
unsafe();
|
|
743
|
-
await fs.unlink(ownerFile);
|
|
744
|
-
await syncDirectory(lock);
|
|
745
|
-
await fs.rmdir(lock);
|
|
746
|
-
await syncDirectory(parent);
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
function createCapability(scope) {
|
|
750
|
-
const capability = Object.freeze({ [capabilityBrand]: true });
|
|
751
|
-
capabilities.set(capability, scope);
|
|
752
|
-
return capability;
|
|
753
|
-
}
|
|
754
|
-
export function assertSkillLoopLockCapability(capability, projectRoot, skillsRoot) {
|
|
755
|
-
if (typeof capability !== "object" || capability === null)
|
|
756
|
-
throw new Error("SKILL_LOOP_LOCK_CAPABILITY_INVALID");
|
|
757
|
-
const scope = capabilities.get(capability);
|
|
758
|
-
if (scope === undefined || !scope.projectRoots.includes(path.resolve(projectRoot)) ||
|
|
759
|
-
(skillsRoot !== undefined && scope.skillsRoot !== path.resolve(skillsRoot))) {
|
|
760
|
-
throw new Error("SKILL_LOOP_LOCK_CAPABILITY_INVALID");
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
export async function assertSkillLoopRootPins(capability) {
|
|
764
|
-
const scope = capabilities.get(capability);
|
|
765
|
-
if (scope?.rootPins === undefined)
|
|
766
|
-
throw new Error("SKILL_LOOP_LOCK_CAPABILITY_INVALID");
|
|
767
|
-
await assertPhysicalDirectoryPin(scope.rootPins.project);
|
|
768
|
-
await assertPhysicalDirectoryPin(scope.rootPins.config);
|
|
769
|
-
await assertPhysicalDirectoryPin(scope.rootPins.skills);
|
|
770
|
-
}
|
|
771
|
-
export async function withSkillLoopMutationLock(projectRoot, run) {
|
|
772
|
-
const project = await pinPhysicalDirectory(projectRoot);
|
|
773
|
-
const runtime = path.join(project.physical, ".litopencode");
|
|
774
|
-
await ensureSafeRuntimeDirectory(project.physical, runtime);
|
|
775
|
-
return withOwnerTokenLock(runtime, "skill-loop-mutation.lock", async () => {
|
|
776
|
-
const capability = createCapability({ projectRoots: [...new Set([project.lexical, project.physical])] });
|
|
777
|
-
try {
|
|
778
|
-
return await run(capability);
|
|
779
|
-
}
|
|
780
|
-
finally {
|
|
781
|
-
capabilities.delete(capability);
|
|
782
|
-
}
|
|
783
|
-
});
|
|
784
|
-
}
|
|
785
|
-
export async function withSkillRootsMutationLock(projectRoot, configRoot, run) {
|
|
786
|
-
assertSkillLoopGlobalBoundarySupported();
|
|
787
|
-
const roots = await pinPhysicalSkillLoopRoots(projectRoot, configRoot);
|
|
788
|
-
const uid = process.getuid?.();
|
|
789
|
-
if (uid === undefined)
|
|
790
|
-
throw new Error("SKILL_LOOP_LOCK_IDENTITY_UNAVAILABLE");
|
|
791
|
-
const lockParent = path.join(await fs.realpath(os.tmpdir()), `.litopencode-skill-root-locks-${uid}`);
|
|
792
|
-
await createDirectoryWithoutUserSymlinks(lockParent);
|
|
793
|
-
const lockParentStat = await fs.lstat(lockParent);
|
|
794
|
-
if (!lockParentStat.isDirectory() || lockParentStat.isSymbolicLink() || lockParentStat.uid !== uid || (lockParentStat.mode & 0o077) !== 0)
|
|
795
|
-
unsafe();
|
|
796
|
-
const lockKey = createHash("sha256")
|
|
797
|
-
.update(`${roots.skills.physical}\0${roots.skills.dev}:${roots.skills.ino}:${roots.skills.birthtimeMs}`, "utf8")
|
|
798
|
-
.digest("hex");
|
|
799
|
-
return withOwnerTokenLock(lockParent, `${lockKey}.lock`, async () => {
|
|
800
|
-
await assertPhysicalDirectoryPin(roots.project);
|
|
801
|
-
await assertPhysicalDirectoryPin(roots.config);
|
|
802
|
-
await assertPhysicalDirectoryPin(roots.skills);
|
|
803
|
-
const capability = createCapability({
|
|
804
|
-
projectRoots: [...new Set([roots.project.lexical, roots.project.physical])],
|
|
805
|
-
skillsRoot: roots.skills.physical,
|
|
806
|
-
rootPins: roots
|
|
807
|
-
});
|
|
808
|
-
try {
|
|
809
|
-
const result = await run(capability, roots);
|
|
810
|
-
await assertSkillLoopRootPins(capability);
|
|
811
|
-
return result;
|
|
812
|
-
}
|
|
813
|
-
finally {
|
|
814
|
-
capabilities.delete(capability);
|
|
815
|
-
}
|
|
816
|
-
});
|
|
817
|
-
}
|