@tetsuo-ai/agenc 0.6.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/generated/agenc-runtime-manifest-v2.json +335 -0
- package/lib/activation-lock-identity.d.mts +3 -0
- package/lib/activation-lock-identity.mjs +151 -0
- package/lib/generated-wrapper.d.mts +23 -0
- package/lib/generated-wrapper.mjs +197 -0
- package/lib/runtime-archive.mjs +299 -0
- package/lib/runtime-compatibility.mjs +141 -0
- package/lib/runtime-manager.mjs +1190 -59
- package/lib/runtime-release-contract.d.mts +94 -0
- package/lib/runtime-release-contract.mjs +386 -0
- package/lib/sqlite-lock.d.mts +44 -0
- package/lib/sqlite-lock.mjs +1121 -0
- package/package.json +27 -7
- package/src/launcher.mjs +9 -2
- package/generated/.gitkeep +0 -0
- package/generated/agenc-runtime-manifest.json +0 -63
- package/scripts/build-runtime-tarball.mjs +0 -201
- package/scripts/gen-manifest.mjs +0 -91
|
@@ -0,0 +1,1121 @@
|
|
|
1
|
+
// Cross-process local filesystem locks backed by SQLite's OS locking layer.
|
|
2
|
+
//
|
|
3
|
+
// BEGIN IMMEDIATE owns the writer reservation for the caller's critical
|
|
4
|
+
// section. SQLite releases it on close or process death, including SIGKILL.
|
|
5
|
+
// A process-wide FIFO registry prevents duplicate module instances from
|
|
6
|
+
// blocking one another inside synchronous SQLite calls; cross-process busy
|
|
7
|
+
// contention is retried asynchronously against one monotonic deadline.
|
|
8
|
+
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import {
|
|
11
|
+
chmod,
|
|
12
|
+
lstat,
|
|
13
|
+
mkdir,
|
|
14
|
+
open,
|
|
15
|
+
readFile,
|
|
16
|
+
realpath,
|
|
17
|
+
} from "node:fs/promises";
|
|
18
|
+
import {
|
|
19
|
+
basename,
|
|
20
|
+
dirname,
|
|
21
|
+
join,
|
|
22
|
+
resolve,
|
|
23
|
+
sep,
|
|
24
|
+
win32,
|
|
25
|
+
} from "node:path";
|
|
26
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
27
|
+
|
|
28
|
+
const LOCK_APPLICATION_ID = 0x41474e43; // "AGNC"
|
|
29
|
+
const LOCK_FORMAT_VERSION = 1;
|
|
30
|
+
const SQLITE_BUSY = 5;
|
|
31
|
+
const REGISTRY_VERSION = 1;
|
|
32
|
+
const REGISTRY_SYMBOL = Symbol.for("@tetsuo-ai/agenc.sqlite-lock-registry");
|
|
33
|
+
const MAX_BUSY_RETRY_MS = 50;
|
|
34
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
35
|
+
const UNSUPPORTED_FILE_ID_64 = 0xffff_ffff_ffff_ffffn;
|
|
36
|
+
const WINDOWS_SYSTEM_ROOT = String.raw`\\?\GLOBALROOT\SystemRoot`;
|
|
37
|
+
const LOCAL_FILESYSTEM_TYPES = new Set([
|
|
38
|
+
"apfs", "bcachefs", "btrfs", "exfat", "ext2", "ext3", "ext4", "f2fs",
|
|
39
|
+
"hfs", "hfsplus", "jfs", "msdos", "nilfs2", "ntfs", "ntfs3", "overlay",
|
|
40
|
+
"ramfs", "reiserfs", "tmpfs", "ubifs", "ufs", "vfat", "xfs", "zfs",
|
|
41
|
+
]);
|
|
42
|
+
const DARWIN_ACL_READ_RIGHTS = new Set([
|
|
43
|
+
"read", "list", "search", "execute", "readattr", "readextattr", "readsecurity",
|
|
44
|
+
]);
|
|
45
|
+
const DARWIN_ACL_INHERITANCE_FLAGS = new Set([
|
|
46
|
+
"file_inherit", "directory_inherit", "limit_inherit", "only_inherit",
|
|
47
|
+
]);
|
|
48
|
+
const DARWIN_ACL_MUTATION_RIGHTS = new Set([
|
|
49
|
+
"write", "append", "add_file", "add_subdirectory", "delete", "delete_child",
|
|
50
|
+
"writeattr", "writeextattr", "writesecurity", "chown",
|
|
51
|
+
]);
|
|
52
|
+
const DARWIN_ACL_KNOWN_TOKENS = new Set([
|
|
53
|
+
...DARWIN_ACL_READ_RIGHTS,
|
|
54
|
+
...DARWIN_ACL_INHERITANCE_FLAGS,
|
|
55
|
+
...DARWIN_ACL_MUTATION_RIGHTS,
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const WINDOWS_SECURITY_SCRIPT = String.raw`
|
|
59
|
+
$ErrorActionPreference = 'Stop'
|
|
60
|
+
$entries = @(ConvertFrom-Json -InputObject $env:AGENC_LOCK_PATHS_JSON)
|
|
61
|
+
$currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
62
|
+
$trusted = @(
|
|
63
|
+
$currentSid,
|
|
64
|
+
'S-1-5-18',
|
|
65
|
+
'S-1-5-32-544',
|
|
66
|
+
'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
|
|
67
|
+
)
|
|
68
|
+
$leafMutationMask = [int64]852310
|
|
69
|
+
$ancestorMutationMask = [int64]852306
|
|
70
|
+
foreach ($entry in $entries) {
|
|
71
|
+
$requested = [string]$entry.path
|
|
72
|
+
$role = [string]$entry.role
|
|
73
|
+
if (@('leafDirectory', 'ancestorDirectory', 'file') -notcontains $role) {
|
|
74
|
+
throw "invalid protected-path role: $role"
|
|
75
|
+
}
|
|
76
|
+
$mutationMask = if ($role -eq 'ancestorDirectory') {
|
|
77
|
+
$ancestorMutationMask
|
|
78
|
+
} else {
|
|
79
|
+
$leafMutationMask
|
|
80
|
+
}
|
|
81
|
+
$full = [System.IO.Path]::GetFullPath([string]$requested)
|
|
82
|
+
if ($full.StartsWith('\\') -or $full.StartsWith('\\?\') -or $full.StartsWith('\\.\')) {
|
|
83
|
+
throw "network and device paths are unsupported: $full"
|
|
84
|
+
}
|
|
85
|
+
$item = Get-Item -LiteralPath $full -Force
|
|
86
|
+
if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
|
|
87
|
+
throw "reparse points are unsupported: $full"
|
|
88
|
+
}
|
|
89
|
+
$drive = [System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($full))
|
|
90
|
+
if (@(2, 3, 6) -notcontains [int]$drive.DriveType) {
|
|
91
|
+
throw "non-local drive is unsupported: $full"
|
|
92
|
+
}
|
|
93
|
+
if ($drive.DriveFormat -ne 'NTFS') {
|
|
94
|
+
throw "filesystem cannot enforce the required ACL contract: $full"
|
|
95
|
+
}
|
|
96
|
+
$acl = Get-Acl -LiteralPath $full
|
|
97
|
+
if (-not $acl.AreAccessRulesCanonical) {
|
|
98
|
+
throw "non-canonical ACL is unsupported: $full"
|
|
99
|
+
}
|
|
100
|
+
$bytes = [byte[]]::new($acl.BinaryLength)
|
|
101
|
+
$acl.GetSecurityDescriptorBinaryForm($bytes, 0)
|
|
102
|
+
$raw = [System.Security.AccessControl.RawSecurityDescriptor]::new($bytes, 0)
|
|
103
|
+
if ($null -eq $raw.DiscretionaryAcl) {
|
|
104
|
+
throw "null DACL is unsupported: $full"
|
|
105
|
+
}
|
|
106
|
+
$owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
|
107
|
+
if ($trusted -notcontains $owner) {
|
|
108
|
+
throw "untrusted owner SID on lock path: $full"
|
|
109
|
+
}
|
|
110
|
+
$rules = $acl.GetAccessRules(
|
|
111
|
+
$true,
|
|
112
|
+
$true,
|
|
113
|
+
[System.Security.Principal.SecurityIdentifier]
|
|
114
|
+
)
|
|
115
|
+
foreach ($rule in $rules) {
|
|
116
|
+
if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) {
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
$inheritOnly = ($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0
|
|
120
|
+
if ($inheritOnly) {
|
|
121
|
+
$childInheritance = [System.Security.AccessControl.InheritanceFlags]::ObjectInherit -bor [System.Security.AccessControl.InheritanceFlags]::ContainerInherit
|
|
122
|
+
$reachesNewChild = ($rule.InheritanceFlags -band $childInheritance) -ne 0
|
|
123
|
+
if ($role -ne 'leafDirectory' -or -not $reachesNewChild) {
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
$sid = $rule.IdentityReference.Value
|
|
128
|
+
if ($trusted -notcontains $sid -and (([int64]$rule.FileSystemRights -band $mutationMask) -ne 0)) {
|
|
129
|
+
throw "untrusted mutation ACE on lock path: $full"
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
[Console]::Out.Write('OK')
|
|
134
|
+
`;
|
|
135
|
+
const WINDOWS_SECURITY_SCRIPT_BASE64 = Buffer.from(
|
|
136
|
+
WINDOWS_SECURITY_SCRIPT,
|
|
137
|
+
"utf16le",
|
|
138
|
+
).toString("base64");
|
|
139
|
+
|
|
140
|
+
export class LocalSqliteLockTimeoutError extends Error {
|
|
141
|
+
constructor({ path, label, timeoutMs, cause }) {
|
|
142
|
+
super(
|
|
143
|
+
`agenc: ${label} timed out after ${timeoutMs}ms waiting for local process lock ${path}`,
|
|
144
|
+
cause === undefined ? undefined : { cause },
|
|
145
|
+
);
|
|
146
|
+
this.name = "LocalSqliteLockTimeoutError";
|
|
147
|
+
this.code = "AGENC_LOCK_TIMEOUT";
|
|
148
|
+
this.path = path;
|
|
149
|
+
this.label = label;
|
|
150
|
+
this.timeoutMs = timeoutMs;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function timeoutError(context, path, cause) {
|
|
155
|
+
return new LocalSqliteLockTimeoutError({
|
|
156
|
+
path,
|
|
157
|
+
label: context.label,
|
|
158
|
+
timeoutMs: context.timeoutMs,
|
|
159
|
+
cause,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function remainingMilliseconds(context) {
|
|
164
|
+
return Math.floor(context.deadline - performance.now());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function throwIfExpired(context, path, cause) {
|
|
168
|
+
if (remainingMilliseconds(context) <= 0) {
|
|
169
|
+
throw timeoutError(context, path, cause);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function processLockRegistry() {
|
|
174
|
+
const current = process[REGISTRY_SYMBOL];
|
|
175
|
+
if (current !== undefined) {
|
|
176
|
+
if (
|
|
177
|
+
current === null ||
|
|
178
|
+
typeof current !== "object" ||
|
|
179
|
+
current.version !== REGISTRY_VERSION ||
|
|
180
|
+
!(current.locks instanceof Map)
|
|
181
|
+
) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
"agenc: incompatible process-wide SQLite lock registry is already installed",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return current;
|
|
187
|
+
}
|
|
188
|
+
const created = { version: REGISTRY_VERSION, locks: new Map() };
|
|
189
|
+
Object.defineProperty(process, REGISTRY_SYMBOL, {
|
|
190
|
+
value: created,
|
|
191
|
+
configurable: false,
|
|
192
|
+
enumerable: false,
|
|
193
|
+
writable: false,
|
|
194
|
+
});
|
|
195
|
+
return created;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function acquireInProcessLock(prepared, context) {
|
|
199
|
+
const registry = processLockRegistry();
|
|
200
|
+
const key = prepared.identityKey;
|
|
201
|
+
let state = registry.locks.get(key);
|
|
202
|
+
if (state === undefined) {
|
|
203
|
+
state = { locked: false, waiters: [] };
|
|
204
|
+
registry.locks.set(key, state);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const makeRelease = () => {
|
|
208
|
+
let released = false;
|
|
209
|
+
return () => {
|
|
210
|
+
if (released) return;
|
|
211
|
+
released = true;
|
|
212
|
+
const next = state.waiters.shift();
|
|
213
|
+
if (next === undefined) {
|
|
214
|
+
state.locked = false;
|
|
215
|
+
registry.locks.delete(key);
|
|
216
|
+
} else {
|
|
217
|
+
clearTimeout(next.timer);
|
|
218
|
+
next.resolve(makeRelease());
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
if (!state.locked) {
|
|
224
|
+
throwIfExpired(context, prepared.path);
|
|
225
|
+
state.locked = true;
|
|
226
|
+
return Promise.resolve(makeRelease());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const remaining = remainingMilliseconds(context);
|
|
230
|
+
if (remaining <= 0) {
|
|
231
|
+
return Promise.reject(timeoutError(context, prepared.path));
|
|
232
|
+
}
|
|
233
|
+
return new Promise((resolveWait, rejectWait) => {
|
|
234
|
+
const waiter = {
|
|
235
|
+
resolve: resolveWait,
|
|
236
|
+
timer: undefined,
|
|
237
|
+
};
|
|
238
|
+
const armTimeout = () => {
|
|
239
|
+
const delayMs = remainingMilliseconds(context);
|
|
240
|
+
if (delayMs <= 0) {
|
|
241
|
+
const index = state.waiters.indexOf(waiter);
|
|
242
|
+
if (index !== -1) state.waiters.splice(index, 1);
|
|
243
|
+
rejectWait(timeoutError(context, prepared.path));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
waiter.timer = setTimeout(armTimeout, Math.min(delayMs, MAX_TIMER_DELAY_MS));
|
|
247
|
+
};
|
|
248
|
+
state.waiters.push(waiter);
|
|
249
|
+
armTimeout();
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function decodeMountPath(value) {
|
|
254
|
+
return value.replace(/\\([0-7]{3})/g, (_match, octal) =>
|
|
255
|
+
String.fromCharCode(Number.parseInt(octal, 8)));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function pathIsWithin(path, mountPoint) {
|
|
259
|
+
return path === mountPoint ||
|
|
260
|
+
path.startsWith(mountPoint === sep ? mountPoint : `${mountPoint}${sep}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function execFileUtf8(file, args, options, context, path) {
|
|
264
|
+
return new Promise((resolveRun, rejectRun) => {
|
|
265
|
+
let deadlineTimer;
|
|
266
|
+
let expired = false;
|
|
267
|
+
const child = execFile(
|
|
268
|
+
file,
|
|
269
|
+
args,
|
|
270
|
+
{ ...options, encoding: "utf8" },
|
|
271
|
+
(error, stdout, stderr) => {
|
|
272
|
+
if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
|
|
273
|
+
if (expired) {
|
|
274
|
+
rejectRun(timeoutError(context, path, error ?? undefined));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (error !== null) {
|
|
278
|
+
Object.assign(error, { stdout, stderr });
|
|
279
|
+
rejectRun(error);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
resolveRun({ stdout, stderr });
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
const armDeadline = () => {
|
|
286
|
+
const remaining = remainingMilliseconds(context);
|
|
287
|
+
if (remaining <= 0) {
|
|
288
|
+
expired = true;
|
|
289
|
+
child.kill();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
deadlineTimer = setTimeout(
|
|
293
|
+
armDeadline,
|
|
294
|
+
Math.min(remaining, MAX_TIMER_DELAY_MS),
|
|
295
|
+
);
|
|
296
|
+
};
|
|
297
|
+
armDeadline();
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function normalizeTimedCommandError(error, context, path) {
|
|
302
|
+
if (
|
|
303
|
+
remainingMilliseconds(context) <= 0 ||
|
|
304
|
+
error?.code === "ETIMEDOUT" ||
|
|
305
|
+
error?.killed === true
|
|
306
|
+
) {
|
|
307
|
+
return timeoutError(context, path, error);
|
|
308
|
+
}
|
|
309
|
+
return error;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function validateDarwinAclListing(stdout, path, role) {
|
|
313
|
+
if (stdout.includes("\r")) {
|
|
314
|
+
throw new Error(`agenc: Darwin ACL helper returned non-canonical output for ${path}`);
|
|
315
|
+
}
|
|
316
|
+
const lines = stdout.split("\n");
|
|
317
|
+
if (lines.at(-1) === "") lines.pop();
|
|
318
|
+
if (lines.length === 0 || lines[0].length === 0) {
|
|
319
|
+
throw new Error(`agenc: Darwin ACL helper returned no metadata for ${path}`);
|
|
320
|
+
}
|
|
321
|
+
let previousOrdinal = -1;
|
|
322
|
+
let sawLegacyOwner = false;
|
|
323
|
+
for (const line of lines.slice(1)) {
|
|
324
|
+
if (/^\s*owner:\s+\S.*$/u.test(line) && !sawLegacyOwner && previousOrdinal === -1) {
|
|
325
|
+
sawLegacyOwner = true;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const match = line.match(
|
|
329
|
+
/^\s*(\d+):\s+(.+?)\s+(?:(inherited)\s+)?(allow|deny)\s+([a-z_]+(?:,[a-z_]+)*)\s*$/u,
|
|
330
|
+
);
|
|
331
|
+
if (match === null) {
|
|
332
|
+
throw new Error(`agenc: Darwin ACL helper returned unrecognized output for ${path}`);
|
|
333
|
+
}
|
|
334
|
+
const ordinal = Number(match[1]);
|
|
335
|
+
if (!Number.isSafeInteger(ordinal) || ordinal <= previousOrdinal) {
|
|
336
|
+
throw new Error(`agenc: Darwin ACL helper returned invalid ACE ordering for ${path}`);
|
|
337
|
+
}
|
|
338
|
+
previousOrdinal = ordinal;
|
|
339
|
+
const association = match[4];
|
|
340
|
+
const tokens = match[5].split(",");
|
|
341
|
+
for (const token of tokens) {
|
|
342
|
+
if (!DARWIN_ACL_KNOWN_TOKENS.has(token)) {
|
|
343
|
+
throw new Error(`agenc: Darwin ACL helper returned unknown right ${token}: ${path}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (
|
|
347
|
+
association === "allow" &&
|
|
348
|
+
tokens.some((token) => DARWIN_ACL_MUTATION_RIGHTS.has(token))
|
|
349
|
+
) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`agenc: protected ${role} has a mutation-capable Darwin ACL: ${path}`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function assertDarwinPathSecurity(path, role, context) {
|
|
358
|
+
throwIfExpired(context, path);
|
|
359
|
+
let result;
|
|
360
|
+
try {
|
|
361
|
+
result = await execFileUtf8(
|
|
362
|
+
"/bin/ls",
|
|
363
|
+
["-ldeq", path],
|
|
364
|
+
{
|
|
365
|
+
env: { LC_ALL: "C" },
|
|
366
|
+
maxBuffer: 256 * 1024,
|
|
367
|
+
},
|
|
368
|
+
context,
|
|
369
|
+
path,
|
|
370
|
+
);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
throw normalizeTimedCommandError(error, context, path);
|
|
373
|
+
}
|
|
374
|
+
if (result.stderr !== "") {
|
|
375
|
+
throw new Error(`agenc: Darwin ACL helper returned unexpected diagnostics for ${path}`);
|
|
376
|
+
}
|
|
377
|
+
validateDarwinAclListing(result.stdout, path, role);
|
|
378
|
+
throwIfExpired(context, path);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function trustedWindowsPowerShellPath() {
|
|
382
|
+
// GLOBALROOT enters the true system object-manager namespace instead of a
|
|
383
|
+
// session-specific or environment-selected DOS path. Never resolve this
|
|
384
|
+
// executable through PATH, SystemRoot, WINDIR, or another caller-controlled
|
|
385
|
+
// value.
|
|
386
|
+
return {
|
|
387
|
+
systemRoot: WINDOWS_SYSTEM_ROOT,
|
|
388
|
+
workingDirectory: win32.join(WINDOWS_SYSTEM_ROOT, "System32"),
|
|
389
|
+
executable: win32.join(
|
|
390
|
+
WINDOWS_SYSTEM_ROOT,
|
|
391
|
+
"System32",
|
|
392
|
+
"WindowsPowerShell",
|
|
393
|
+
"v1.0",
|
|
394
|
+
"powershell.exe",
|
|
395
|
+
),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function windowsPowerShellEnvironment(paths) {
|
|
400
|
+
const { systemRoot, workingDirectory } = trustedWindowsPowerShellPath();
|
|
401
|
+
// libuv fills a fixed set of "required" Windows variables from the parent
|
|
402
|
+
// when they are absent. Define every one so poisoned caller state cannot be
|
|
403
|
+
// silently inherited into the validation helper.
|
|
404
|
+
return {
|
|
405
|
+
AGENC_LOCK_PATHS_JSON: JSON.stringify(paths),
|
|
406
|
+
APPDATA: "",
|
|
407
|
+
COMSPEC: "",
|
|
408
|
+
HOMEDRIVE: "",
|
|
409
|
+
HOMEPATH: "",
|
|
410
|
+
LOCALAPPDATA: "",
|
|
411
|
+
LOGONSERVER: "",
|
|
412
|
+
PATH: workingDirectory,
|
|
413
|
+
PATHEXT: ".EXE",
|
|
414
|
+
PSMODULEPATH: "",
|
|
415
|
+
SYSTEMDRIVE: "",
|
|
416
|
+
SYSTEMROOT: systemRoot,
|
|
417
|
+
TEMP: workingDirectory,
|
|
418
|
+
TMP: workingDirectory,
|
|
419
|
+
USERDOMAIN: "",
|
|
420
|
+
USERNAME: "",
|
|
421
|
+
USERPROFILE: workingDirectory,
|
|
422
|
+
WINDIR: systemRoot,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function assertWindowsPathSecurity(entries, context) {
|
|
427
|
+
const displayPath = entries.at(-1)?.path ?? "unknown";
|
|
428
|
+
throwIfExpired(context, displayPath);
|
|
429
|
+
const { workingDirectory, executable } = trustedWindowsPowerShellPath();
|
|
430
|
+
let result;
|
|
431
|
+
try {
|
|
432
|
+
result = await execFileUtf8(
|
|
433
|
+
executable,
|
|
434
|
+
[
|
|
435
|
+
"-NoLogo",
|
|
436
|
+
"-NoProfile",
|
|
437
|
+
"-NonInteractive",
|
|
438
|
+
"-EncodedCommand",
|
|
439
|
+
WINDOWS_SECURITY_SCRIPT_BASE64,
|
|
440
|
+
],
|
|
441
|
+
{
|
|
442
|
+
cwd: workingDirectory,
|
|
443
|
+
env: windowsPowerShellEnvironment(entries),
|
|
444
|
+
maxBuffer: 1024 * 1024,
|
|
445
|
+
windowsHide: true,
|
|
446
|
+
},
|
|
447
|
+
context,
|
|
448
|
+
displayPath,
|
|
449
|
+
);
|
|
450
|
+
} catch (error) {
|
|
451
|
+
throw normalizeTimedCommandError(error, context, displayPath);
|
|
452
|
+
}
|
|
453
|
+
if (result.stdout !== "OK") {
|
|
454
|
+
throw new Error(`agenc: Windows lock-path validation returned an invalid response for ${displayPath}`);
|
|
455
|
+
}
|
|
456
|
+
throwIfExpired(context, displayPath);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function assertLocalFilesystem(parent, context) {
|
|
460
|
+
throwIfExpired(context, parent);
|
|
461
|
+
let filesystemType;
|
|
462
|
+
if (process.platform === "linux") {
|
|
463
|
+
const mounts = await readFile("/proc/self/mountinfo", "utf8");
|
|
464
|
+
throwIfExpired(context, parent);
|
|
465
|
+
let longest = -1;
|
|
466
|
+
for (const line of mounts.split("\n")) {
|
|
467
|
+
const fields = line.split(" ");
|
|
468
|
+
const separatorIndex = fields.indexOf("-");
|
|
469
|
+
if (
|
|
470
|
+
separatorIndex < 6 ||
|
|
471
|
+
fields[4] === undefined ||
|
|
472
|
+
fields[separatorIndex + 1] === undefined
|
|
473
|
+
) continue;
|
|
474
|
+
const mountPoint = decodeMountPath(fields[4]);
|
|
475
|
+
if (pathIsWithin(parent, mountPoint) && mountPoint.length > longest) {
|
|
476
|
+
longest = mountPoint.length;
|
|
477
|
+
filesystemType = fields[separatorIndex + 1];
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
} else if (process.platform === "darwin") {
|
|
481
|
+
let stdout;
|
|
482
|
+
try {
|
|
483
|
+
({ stdout } = await execFileUtf8("/sbin/mount", [], {
|
|
484
|
+
env: { LC_ALL: "C" },
|
|
485
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
486
|
+
}, context, parent));
|
|
487
|
+
} catch (error) {
|
|
488
|
+
throw normalizeTimedCommandError(error, context, parent);
|
|
489
|
+
}
|
|
490
|
+
throwIfExpired(context, parent);
|
|
491
|
+
let longest = -1;
|
|
492
|
+
for (const line of stdout.split("\n")) {
|
|
493
|
+
const match = line.match(/ on (.+) \(([^,]+)/);
|
|
494
|
+
if (match === null) continue;
|
|
495
|
+
const mountPoint = decodeMountPath(match[1]);
|
|
496
|
+
if (pathIsWithin(parent, mountPoint) && mountPoint.length > longest) {
|
|
497
|
+
longest = mountPoint.length;
|
|
498
|
+
filesystemType = match[2];
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
} else if (process.platform === "win32") {
|
|
502
|
+
await assertWindowsPathSecurity([{ path: parent, role: "leafDirectory" }], context);
|
|
503
|
+
return;
|
|
504
|
+
} else {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`agenc: cannot establish lock filesystem locality on ${process.platform}`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
if (filesystemType === undefined || !LOCAL_FILESYSTEM_TYPES.has(filesystemType)) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
`agenc: non-local or unknown lock filesystem is unsupported (${filesystemType ?? "unknown"}): ${parent}`,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Establish that an existing directory is a local, privately mutable
|
|
518
|
+
* coordination boundary. Wrapper replacement uses a registry-hosted SQLite
|
|
519
|
+
* lock, so a shared or attacker-writable wrapper directory would otherwise
|
|
520
|
+
* permit cross-host races or path substitution outside that lock.
|
|
521
|
+
*/
|
|
522
|
+
export async function assertLocalPrivateDirectory(
|
|
523
|
+
requestedPath,
|
|
524
|
+
{
|
|
525
|
+
timeoutMs = 60_000,
|
|
526
|
+
label = "AgenC operation",
|
|
527
|
+
deadline: suppliedDeadline,
|
|
528
|
+
allowTrustedStickyLeaf = false,
|
|
529
|
+
} = {},
|
|
530
|
+
) {
|
|
531
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
532
|
+
throw new TypeError("lock timeoutMs must be a positive safe integer");
|
|
533
|
+
}
|
|
534
|
+
if (suppliedDeadline !== undefined && !Number.isFinite(suppliedDeadline)) {
|
|
535
|
+
throw new TypeError("lock deadline must be finite");
|
|
536
|
+
}
|
|
537
|
+
if (typeof allowTrustedStickyLeaf !== "boolean") {
|
|
538
|
+
throw new TypeError("allowTrustedStickyLeaf must be boolean");
|
|
539
|
+
}
|
|
540
|
+
const context = {
|
|
541
|
+
deadline: Math.min(
|
|
542
|
+
suppliedDeadline ?? Number.POSITIVE_INFINITY,
|
|
543
|
+
performance.now() + timeoutMs,
|
|
544
|
+
),
|
|
545
|
+
label,
|
|
546
|
+
timeoutMs,
|
|
547
|
+
};
|
|
548
|
+
const absolute = resolve(requestedPath);
|
|
549
|
+
throwIfExpired(context, absolute);
|
|
550
|
+
const canonical = await realpath(absolute);
|
|
551
|
+
const ancestors = [];
|
|
552
|
+
for (let current = canonical; ; current = dirname(current)) {
|
|
553
|
+
ancestors.push(current);
|
|
554
|
+
if (dirname(current) === current) break;
|
|
555
|
+
}
|
|
556
|
+
const currentUid = process.getuid?.();
|
|
557
|
+
const beforeIdentities = new Map();
|
|
558
|
+
for (let index = 0; index < ancestors.length; index += 1) {
|
|
559
|
+
const path = ancestors[index];
|
|
560
|
+
const stats = await lstat(path, { bigint: true });
|
|
561
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
562
|
+
throw new Error(`agenc: protected path ancestor is not a real directory: ${path}`);
|
|
563
|
+
}
|
|
564
|
+
if (process.platform !== "win32") {
|
|
565
|
+
const leaf = index === 0;
|
|
566
|
+
const trustedOwner = stats.uid === 0n ||
|
|
567
|
+
(currentUid !== undefined && stats.uid === BigInt(currentUid));
|
|
568
|
+
const stickyBoundary = (!leaf || allowTrustedStickyLeaf) &&
|
|
569
|
+
(stats.mode & 0o1000n) !== 0n && trustedOwner;
|
|
570
|
+
if (!trustedOwner || ((stats.mode & 0o022n) !== 0n && !stickyBoundary)) {
|
|
571
|
+
throw new Error(
|
|
572
|
+
`agenc: protected directory chain permits untrusted mutation: ${path}; ` +
|
|
573
|
+
"remove group/world write access before retrying",
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
if (
|
|
577
|
+
leaf && !stickyBoundary && currentUid !== undefined &&
|
|
578
|
+
stats.uid !== BigInt(currentUid)
|
|
579
|
+
) {
|
|
580
|
+
throw new Error(`agenc: protected directory is not owned by the current user: ${path}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
beforeIdentities.set(path, { dev: stats.dev, ino: stats.ino });
|
|
584
|
+
identityFromStats(stats, path);
|
|
585
|
+
}
|
|
586
|
+
if (process.platform === "win32") {
|
|
587
|
+
await assertWindowsPathSecurity(
|
|
588
|
+
ancestors.map((path, index) => ({
|
|
589
|
+
path,
|
|
590
|
+
role: index === 0 ? "leafDirectory" : "ancestorDirectory",
|
|
591
|
+
})),
|
|
592
|
+
context,
|
|
593
|
+
);
|
|
594
|
+
} else {
|
|
595
|
+
await assertLocalFilesystem(canonical, context);
|
|
596
|
+
if (process.platform === "darwin") {
|
|
597
|
+
for (let index = 0; index < ancestors.length; index += 1) {
|
|
598
|
+
await assertDarwinPathSecurity(
|
|
599
|
+
ancestors[index],
|
|
600
|
+
index === 0 ? "leaf directory" : "ancestor directory",
|
|
601
|
+
context,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
throwIfExpired(context, canonical);
|
|
607
|
+
for (const path of ancestors) {
|
|
608
|
+
const after = await lstat(path, { bigint: true });
|
|
609
|
+
const before = beforeIdentities.get(path);
|
|
610
|
+
if (
|
|
611
|
+
!after.isDirectory() || after.isSymbolicLink() || before === undefined ||
|
|
612
|
+
after.dev !== before.dev || after.ino !== before.ino
|
|
613
|
+
) {
|
|
614
|
+
throw new Error(`agenc: protected directory identity changed during validation: ${path}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return canonical;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function assertRegularSingleLink(stats, path) {
|
|
621
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
622
|
+
throw new Error(`agenc: lock database is not a regular file: ${path}`);
|
|
623
|
+
}
|
|
624
|
+
if (stats.nlink !== 1n) {
|
|
625
|
+
throw new Error(`agenc: lock database must not have hard-link aliases: ${path}`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function identityFromStats(stats, path) {
|
|
630
|
+
if (
|
|
631
|
+
stats.dev === 0n ||
|
|
632
|
+
stats.ino === 0n ||
|
|
633
|
+
stats.ino === -1n ||
|
|
634
|
+
BigInt.asUintN(64, stats.ino) === UNSUPPORTED_FILE_ID_64
|
|
635
|
+
) {
|
|
636
|
+
throw new Error(`agenc: lock database has no stable filesystem identity: ${path}`);
|
|
637
|
+
}
|
|
638
|
+
return `${stats.dev}:${stats.ino}`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function assertPosixOwnership(stats, path, kind) {
|
|
642
|
+
if (process.platform === "win32") return;
|
|
643
|
+
const currentUid = process.getuid?.();
|
|
644
|
+
if (currentUid !== undefined && stats.uid !== BigInt(currentUid)) {
|
|
645
|
+
throw new Error(`agenc: lock database ${kind} is not owned by the current user: ${path}`);
|
|
646
|
+
}
|
|
647
|
+
if ((stats.mode & 0o022n) !== 0n) {
|
|
648
|
+
throw new Error(`agenc: lock database ${kind} is group/world-writable: ${path}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Validate a regular file and its complete directory chain before a caller
|
|
654
|
+
* trusts its contents. This is intentionally non-mutating: unsafe ownership,
|
|
655
|
+
* mode bits, ACLs, aliases, or identity changes fail closed.
|
|
656
|
+
*/
|
|
657
|
+
export async function assertLocalPrivateFile(
|
|
658
|
+
requestedPath,
|
|
659
|
+
{
|
|
660
|
+
timeoutMs = 60_000,
|
|
661
|
+
label = "AgenC operation",
|
|
662
|
+
deadline: suppliedDeadline,
|
|
663
|
+
} = {},
|
|
664
|
+
) {
|
|
665
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
666
|
+
throw new TypeError("lock timeoutMs must be a positive safe integer");
|
|
667
|
+
}
|
|
668
|
+
if (suppliedDeadline !== undefined && !Number.isFinite(suppliedDeadline)) {
|
|
669
|
+
throw new TypeError("lock deadline must be finite");
|
|
670
|
+
}
|
|
671
|
+
const context = {
|
|
672
|
+
deadline: Math.min(
|
|
673
|
+
suppliedDeadline ?? Number.POSITIVE_INFINITY,
|
|
674
|
+
performance.now() + timeoutMs,
|
|
675
|
+
),
|
|
676
|
+
label,
|
|
677
|
+
timeoutMs,
|
|
678
|
+
};
|
|
679
|
+
const absolute = resolve(requestedPath);
|
|
680
|
+
const parent = dirname(absolute);
|
|
681
|
+
const canonicalParent = await assertLocalPrivateDirectory(parent, {
|
|
682
|
+
timeoutMs,
|
|
683
|
+
label,
|
|
684
|
+
deadline: context.deadline,
|
|
685
|
+
});
|
|
686
|
+
if (canonicalParent !== parent) {
|
|
687
|
+
throw new Error(`agenc: protected file parent must use its canonical path: ${parent}`);
|
|
688
|
+
}
|
|
689
|
+
throwIfExpired(context, absolute);
|
|
690
|
+
const before = await lstat(absolute, { bigint: true });
|
|
691
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n) {
|
|
692
|
+
throw new Error(`agenc: protected file must be a regular single-link file: ${absolute}`);
|
|
693
|
+
}
|
|
694
|
+
identityFromStats(before, absolute);
|
|
695
|
+
if (process.platform !== "win32") {
|
|
696
|
+
const currentUid = process.getuid?.();
|
|
697
|
+
if (currentUid !== undefined && before.uid !== BigInt(currentUid)) {
|
|
698
|
+
throw new Error(`agenc: protected file is not owned by the current user: ${absolute}`);
|
|
699
|
+
}
|
|
700
|
+
if ((before.mode & 0o022n) !== 0n) {
|
|
701
|
+
throw new Error(`agenc: protected file is group/world-writable: ${absolute}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const canonical = await realpath(absolute);
|
|
705
|
+
if (canonical !== absolute) {
|
|
706
|
+
throw new Error(`agenc: protected file must use its canonical path: ${absolute}`);
|
|
707
|
+
}
|
|
708
|
+
if (process.platform === "win32") {
|
|
709
|
+
await assertWindowsPathSecurity([{ path: canonical, role: "file" }], context);
|
|
710
|
+
} else if (process.platform === "darwin") {
|
|
711
|
+
await assertDarwinPathSecurity(canonical, "file", context);
|
|
712
|
+
}
|
|
713
|
+
throwIfExpired(context, canonical);
|
|
714
|
+
const after = await lstat(canonical, { bigint: true });
|
|
715
|
+
if (
|
|
716
|
+
!after.isFile() || after.isSymbolicLink() || after.nlink !== 1n ||
|
|
717
|
+
after.dev !== before.dev || after.ino !== before.ino
|
|
718
|
+
) {
|
|
719
|
+
throw new Error(`agenc: protected file identity changed during validation: ${canonical}`);
|
|
720
|
+
}
|
|
721
|
+
return canonical;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function prepareLockPath(requestedPath, context) {
|
|
725
|
+
const absolute = resolve(requestedPath);
|
|
726
|
+
throwIfExpired(context, absolute);
|
|
727
|
+
await mkdir(dirname(absolute), { recursive: true, mode: 0o700 });
|
|
728
|
+
throwIfExpired(context, absolute);
|
|
729
|
+
const parent = await realpath(dirname(absolute));
|
|
730
|
+
throwIfExpired(context, absolute);
|
|
731
|
+
const validatedParent = await assertLocalPrivateDirectory(parent, {
|
|
732
|
+
timeoutMs: context.timeoutMs,
|
|
733
|
+
label: context.label,
|
|
734
|
+
deadline: context.deadline,
|
|
735
|
+
});
|
|
736
|
+
if (validatedParent !== parent) {
|
|
737
|
+
throw new Error(`agenc: lock database parent must use its canonical path: ${parent}`);
|
|
738
|
+
}
|
|
739
|
+
const parentStats = await lstat(parent, { bigint: true });
|
|
740
|
+
if (!parentStats.isDirectory() || parentStats.isSymbolicLink()) {
|
|
741
|
+
throw new Error(`agenc: lock database parent is not a real directory: ${parent}`);
|
|
742
|
+
}
|
|
743
|
+
assertPosixOwnership(parentStats, parent, "parent");
|
|
744
|
+
|
|
745
|
+
const path = join(parent, basename(absolute));
|
|
746
|
+
throwIfExpired(context, path);
|
|
747
|
+
try {
|
|
748
|
+
const handle = await open(path, "wx", 0o600);
|
|
749
|
+
try {
|
|
750
|
+
await handle.close();
|
|
751
|
+
} catch (error) {
|
|
752
|
+
await handle.close().catch(() => {});
|
|
753
|
+
throw error;
|
|
754
|
+
}
|
|
755
|
+
} catch (error) {
|
|
756
|
+
if (error?.code !== "EEXIST") throw error;
|
|
757
|
+
}
|
|
758
|
+
throwIfExpired(context, path);
|
|
759
|
+
const pathStats = await lstat(path, { bigint: true });
|
|
760
|
+
assertRegularSingleLink(pathStats, path);
|
|
761
|
+
assertPosixOwnership(pathStats, path, "file");
|
|
762
|
+
const canonical = await realpath(path);
|
|
763
|
+
const stats = await lstat(canonical, { bigint: true });
|
|
764
|
+
assertRegularSingleLink(stats, canonical);
|
|
765
|
+
assertPosixOwnership(stats, canonical, "file");
|
|
766
|
+
if (process.platform !== "win32") {
|
|
767
|
+
await chmod(canonical, 0o600);
|
|
768
|
+
if (process.platform === "darwin") {
|
|
769
|
+
await assertDarwinPathSecurity(canonical, "lock database file", context);
|
|
770
|
+
}
|
|
771
|
+
} else {
|
|
772
|
+
await assertWindowsPathSecurity([
|
|
773
|
+
{ path: parent, role: "leafDirectory" },
|
|
774
|
+
{ path: canonical, role: "file" },
|
|
775
|
+
], context);
|
|
776
|
+
}
|
|
777
|
+
throwIfExpired(context, canonical);
|
|
778
|
+
const securedStats = await lstat(canonical, { bigint: true });
|
|
779
|
+
assertRegularSingleLink(securedStats, canonical);
|
|
780
|
+
assertPosixOwnership(securedStats, canonical, "file");
|
|
781
|
+
return {
|
|
782
|
+
path: canonical,
|
|
783
|
+
parent,
|
|
784
|
+
dev: securedStats.dev,
|
|
785
|
+
ino: securedStats.ino,
|
|
786
|
+
identityKey: identityFromStats(securedStats, canonical),
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function revalidatePreparedLock(prepared, context) {
|
|
791
|
+
throwIfExpired(context, prepared.path);
|
|
792
|
+
const parentStats = await lstat(prepared.parent, { bigint: true });
|
|
793
|
+
if (!parentStats.isDirectory() || parentStats.isSymbolicLink()) {
|
|
794
|
+
throw new Error(`agenc: lock database parent is no longer a real directory: ${prepared.parent}`);
|
|
795
|
+
}
|
|
796
|
+
assertPosixOwnership(parentStats, prepared.parent, "parent");
|
|
797
|
+
const stats = await lstat(prepared.path, { bigint: true });
|
|
798
|
+
assertRegularSingleLink(stats, prepared.path);
|
|
799
|
+
assertPosixOwnership(stats, prepared.path, "file");
|
|
800
|
+
if (stats.dev !== prepared.dev || stats.ino !== prepared.ino) {
|
|
801
|
+
throw new Error(`agenc: lock database identity changed during acquisition: ${prepared.path}`);
|
|
802
|
+
}
|
|
803
|
+
if (process.platform === "win32") {
|
|
804
|
+
await assertWindowsPathSecurity([
|
|
805
|
+
{ path: prepared.parent, role: "leafDirectory" },
|
|
806
|
+
{ path: prepared.path, role: "file" },
|
|
807
|
+
], context);
|
|
808
|
+
} else if (process.platform === "darwin") {
|
|
809
|
+
await assertDarwinPathSecurity(prepared.path, "lock database file", context);
|
|
810
|
+
}
|
|
811
|
+
throwIfExpired(context, prepared.path);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function pragmaValue(database, pragma) {
|
|
815
|
+
const row = database.prepare(`PRAGMA ${pragma}`).get();
|
|
816
|
+
return row === undefined ? undefined : Object.values(row)[0];
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function pragmaNumber(database, pragma) {
|
|
820
|
+
const value = pragmaValue(database, pragma);
|
|
821
|
+
return typeof value === "number" ? value : undefined;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function pragmaText(database, pragma) {
|
|
825
|
+
const value = pragmaValue(database, pragma);
|
|
826
|
+
return typeof value === "string" ? value.toLowerCase() : undefined;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
export function configureLocalSqliteLockConnection(database) {
|
|
830
|
+
database.exec("PRAGMA busy_timeout = 0");
|
|
831
|
+
database.exec("PRAGMA trusted_schema = OFF");
|
|
832
|
+
database.exec("PRAGMA synchronous = EXTRA");
|
|
833
|
+
database.enableDefensive(true);
|
|
834
|
+
database.enableLoadExtension(false);
|
|
835
|
+
if (
|
|
836
|
+
pragmaNumber(database, "busy_timeout") !== 0 ||
|
|
837
|
+
pragmaNumber(database, "trusted_schema") !== 0 ||
|
|
838
|
+
pragmaNumber(database, "synchronous") !== 3
|
|
839
|
+
) {
|
|
840
|
+
throw new Error("agenc: SQLite lock connection hardening did not take effect");
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function inspectLockDatabase(database, path) {
|
|
845
|
+
const applicationId = pragmaNumber(database, "application_id");
|
|
846
|
+
if (applicationId === 0) {
|
|
847
|
+
const row = database.prepare(
|
|
848
|
+
"SELECT count(*) AS count FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'",
|
|
849
|
+
).get();
|
|
850
|
+
if (row?.count !== 0) {
|
|
851
|
+
throw new Error(
|
|
852
|
+
`agenc: refusing to reuse an unrelated SQLite database as a lock: ${path}`,
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
return "empty";
|
|
856
|
+
}
|
|
857
|
+
if (applicationId !== LOCK_APPLICATION_ID) {
|
|
858
|
+
throw new Error(`agenc: lock database has an incompatible application id: ${path}`);
|
|
859
|
+
}
|
|
860
|
+
try {
|
|
861
|
+
const schema = database.prepare(
|
|
862
|
+
"SELECT type, sql FROM sqlite_schema WHERE name = 'agenc_local_process_lock'",
|
|
863
|
+
).get();
|
|
864
|
+
const objects = database.prepare(
|
|
865
|
+
"SELECT count(*) AS count FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'",
|
|
866
|
+
).get();
|
|
867
|
+
const rows = database.prepare(
|
|
868
|
+
"SELECT singleton, format_version FROM agenc_local_process_lock",
|
|
869
|
+
).all();
|
|
870
|
+
const normalizedSchema = typeof schema?.sql === "string"
|
|
871
|
+
? schema.sql.replace(/\s+/g, " ").trim()
|
|
872
|
+
: undefined;
|
|
873
|
+
if (
|
|
874
|
+
schema?.type !== "table" ||
|
|
875
|
+
normalizedSchema !==
|
|
876
|
+
"CREATE TABLE agenc_local_process_lock ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), format_version INTEGER NOT NULL ) STRICT" ||
|
|
877
|
+
objects?.count !== 1 ||
|
|
878
|
+
rows.length !== 1 ||
|
|
879
|
+
rows[0]?.singleton !== 1 ||
|
|
880
|
+
rows[0]?.format_version !== LOCK_FORMAT_VERSION
|
|
881
|
+
) {
|
|
882
|
+
throw new Error("invalid sentinel schema");
|
|
883
|
+
}
|
|
884
|
+
} catch (error) {
|
|
885
|
+
throw new Error(`agenc: lock database has an incompatible format: ${path}`, {
|
|
886
|
+
cause: error,
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
return "valid";
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function busyTransitionError(path, mode) {
|
|
893
|
+
return Object.assign(
|
|
894
|
+
new Error(`agenc: SQLite lock journal mode remained ${mode ?? "unknown"}: ${path}`),
|
|
895
|
+
{ errcode: SQLITE_BUSY },
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function beginAndValidateLock(database, path) {
|
|
900
|
+
for (let phase = 0; phase < 8; phase += 1) {
|
|
901
|
+
database.exec("BEGIN IMMEDIATE");
|
|
902
|
+
const state = inspectLockDatabase(database, path);
|
|
903
|
+
const journalMode = pragmaText(database, "journal_mode");
|
|
904
|
+
if (journalMode !== "delete") {
|
|
905
|
+
database.exec("ROLLBACK");
|
|
906
|
+
const selected = pragmaText(database, "journal_mode=DELETE");
|
|
907
|
+
if (selected !== "delete") throw busyTransitionError(path, selected);
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
if (state === "empty") {
|
|
911
|
+
database.exec(`
|
|
912
|
+
PRAGMA application_id = ${LOCK_APPLICATION_ID};
|
|
913
|
+
CREATE TABLE agenc_local_process_lock (
|
|
914
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
915
|
+
format_version INTEGER NOT NULL
|
|
916
|
+
) STRICT;
|
|
917
|
+
INSERT INTO agenc_local_process_lock (singleton, format_version)
|
|
918
|
+
VALUES (1, ${LOCK_FORMAT_VERSION});
|
|
919
|
+
COMMIT;
|
|
920
|
+
`);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
throw new Error(`agenc: lock database initialization did not converge: ${path}`);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function closeDatabase(database) {
|
|
929
|
+
if (!database?.isOpen) return;
|
|
930
|
+
const errors = [];
|
|
931
|
+
try {
|
|
932
|
+
if (database.isTransaction) database.exec("ROLLBACK");
|
|
933
|
+
} catch (error) {
|
|
934
|
+
errors.push(error);
|
|
935
|
+
}
|
|
936
|
+
try {
|
|
937
|
+
database.close();
|
|
938
|
+
} catch (error) {
|
|
939
|
+
errors.push(error);
|
|
940
|
+
}
|
|
941
|
+
if (errors.length === 1) throw errors[0];
|
|
942
|
+
if (errors.length > 1) {
|
|
943
|
+
throw new AggregateError(errors, "agenc: failed to close a local process lock database");
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
export function isSqliteBusyError(error) {
|
|
948
|
+
return typeof error?.errcode === "number" &&
|
|
949
|
+
(error.errcode & 0xff) === SQLITE_BUSY;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async function waitForBusyRetry(context, path, attempt, cause) {
|
|
953
|
+
const remaining = remainingMilliseconds(context);
|
|
954
|
+
if (remaining <= 0) throw timeoutError(context, path, cause);
|
|
955
|
+
const exponentialCap = Math.min(MAX_BUSY_RETRY_MS, 2 ** Math.min(attempt, 6));
|
|
956
|
+
const jitter = Math.max(1, Math.floor(Math.random() * (exponentialCap + 1)));
|
|
957
|
+
await delay(Math.min(remaining, jitter));
|
|
958
|
+
throwIfExpired(context, path, cause);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async function acquireSqliteDatabase(DatabaseSync, prepared, context) {
|
|
962
|
+
let attempt = 0;
|
|
963
|
+
let lastBusy;
|
|
964
|
+
while (true) {
|
|
965
|
+
throwIfExpired(context, prepared.path, lastBusy);
|
|
966
|
+
await revalidatePreparedLock(prepared, context);
|
|
967
|
+
let database;
|
|
968
|
+
try {
|
|
969
|
+
database = new DatabaseSync(prepared.path, {
|
|
970
|
+
allowExtension: false,
|
|
971
|
+
timeout: 0,
|
|
972
|
+
});
|
|
973
|
+
configureLocalSqliteLockConnection(database);
|
|
974
|
+
await revalidatePreparedLock(prepared, context);
|
|
975
|
+
beginAndValidateLock(database, prepared.path);
|
|
976
|
+
throwIfExpired(context, prepared.path, lastBusy);
|
|
977
|
+
return database;
|
|
978
|
+
} catch (error) {
|
|
979
|
+
const cleanupErrors = [];
|
|
980
|
+
if (database !== undefined) {
|
|
981
|
+
try {
|
|
982
|
+
closeDatabase(database);
|
|
983
|
+
} catch (cleanupError) {
|
|
984
|
+
cleanupErrors.push(cleanupError);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
if (cleanupErrors.length > 0) {
|
|
988
|
+
throw new AggregateError(
|
|
989
|
+
[error, ...cleanupErrors],
|
|
990
|
+
`agenc: lock attempt and cleanup both failed for ${prepared.path}`,
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
if (!isSqliteBusyError(error)) throw error;
|
|
994
|
+
lastBusy = error;
|
|
995
|
+
attempt += 1;
|
|
996
|
+
await waitForBusyRetry(context, prepared.path, attempt, lastBusy);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function releaseAcquired(acquired, label) {
|
|
1002
|
+
const errors = [];
|
|
1003
|
+
for (const item of acquired.toReversed()) {
|
|
1004
|
+
try {
|
|
1005
|
+
closeDatabase(item.database);
|
|
1006
|
+
} catch (error) {
|
|
1007
|
+
errors.push(error);
|
|
1008
|
+
}
|
|
1009
|
+
if ((!item.database || !item.database.isOpen) && !item.inProcessReleased) {
|
|
1010
|
+
try {
|
|
1011
|
+
item.releaseInProcess();
|
|
1012
|
+
item.inProcessReleased = true;
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
errors.push(error);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (errors.length === 1) throw errors[0];
|
|
1019
|
+
if (errors.length > 1) {
|
|
1020
|
+
throw new AggregateError(errors, `agenc: ${label} lock release failed`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
export async function acquireLocalSqliteLocks(
|
|
1025
|
+
requestedPaths,
|
|
1026
|
+
{
|
|
1027
|
+
timeoutMs = 60_000,
|
|
1028
|
+
label = "AgenC operation",
|
|
1029
|
+
deadline: suppliedDeadline,
|
|
1030
|
+
} = {},
|
|
1031
|
+
) {
|
|
1032
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
1033
|
+
throw new TypeError("lock timeoutMs must be a positive safe integer");
|
|
1034
|
+
}
|
|
1035
|
+
if (suppliedDeadline !== undefined && !Number.isFinite(suppliedDeadline)) {
|
|
1036
|
+
throw new TypeError("lock deadline must be finite");
|
|
1037
|
+
}
|
|
1038
|
+
if (!Array.isArray(requestedPaths)) {
|
|
1039
|
+
throw new TypeError("lock paths must be an array");
|
|
1040
|
+
}
|
|
1041
|
+
if (requestedPaths.length === 0) return () => {};
|
|
1042
|
+
|
|
1043
|
+
const startedAt = performance.now();
|
|
1044
|
+
const context = {
|
|
1045
|
+
deadline: Math.min(
|
|
1046
|
+
suppliedDeadline ?? Number.POSITIVE_INFINITY,
|
|
1047
|
+
startedAt + timeoutMs,
|
|
1048
|
+
),
|
|
1049
|
+
label,
|
|
1050
|
+
timeoutMs,
|
|
1051
|
+
};
|
|
1052
|
+
const firstDisplayPath = resolve(requestedPaths[0]);
|
|
1053
|
+
throwIfExpired(context, firstDisplayPath);
|
|
1054
|
+
|
|
1055
|
+
const preparedByIdentity = new Map();
|
|
1056
|
+
for (const requestedPath of requestedPaths) {
|
|
1057
|
+
throwIfExpired(context, resolve(requestedPath));
|
|
1058
|
+
const prepared = await prepareLockPath(requestedPath, context);
|
|
1059
|
+
preparedByIdentity.set(prepared.identityKey, prepared);
|
|
1060
|
+
}
|
|
1061
|
+
const preparedLocks = [...preparedByIdentity.values()].sort((left, right) =>
|
|
1062
|
+
left.identityKey < right.identityKey ? -1 : left.identityKey > right.identityKey ? 1 : 0);
|
|
1063
|
+
const pendingLocal = [];
|
|
1064
|
+
const acquired = [];
|
|
1065
|
+
let currentPath = preparedLocks[0]?.path ?? firstDisplayPath;
|
|
1066
|
+
try {
|
|
1067
|
+
for (const prepared of preparedLocks) {
|
|
1068
|
+
currentPath = prepared.path;
|
|
1069
|
+
const release = await acquireInProcessLock(prepared, context);
|
|
1070
|
+
pendingLocal.push({ prepared, release });
|
|
1071
|
+
}
|
|
1072
|
+
throwIfExpired(context, currentPath);
|
|
1073
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
1074
|
+
throwIfExpired(context, currentPath);
|
|
1075
|
+
for (const { prepared, release } of pendingLocal) {
|
|
1076
|
+
currentPath = prepared.path;
|
|
1077
|
+
const item = {
|
|
1078
|
+
database: undefined,
|
|
1079
|
+
releaseInProcess: release,
|
|
1080
|
+
inProcessReleased: false,
|
|
1081
|
+
};
|
|
1082
|
+
acquired.push(item);
|
|
1083
|
+
item.database = await acquireSqliteDatabase(DatabaseSync, prepared, context);
|
|
1084
|
+
}
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
const cleanupErrors = [];
|
|
1087
|
+
try {
|
|
1088
|
+
releaseAcquired(acquired, label);
|
|
1089
|
+
} catch (cleanupError) {
|
|
1090
|
+
cleanupErrors.push(cleanupError);
|
|
1091
|
+
}
|
|
1092
|
+
for (const { release } of pendingLocal.slice(acquired.length).toReversed()) {
|
|
1093
|
+
try {
|
|
1094
|
+
release();
|
|
1095
|
+
} catch (cleanupError) {
|
|
1096
|
+
cleanupErrors.push(cleanupError);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const formatted = isSqliteBusyError(error)
|
|
1100
|
+
? timeoutError(context, currentPath, error)
|
|
1101
|
+
: error;
|
|
1102
|
+
if (cleanupErrors.length > 0) {
|
|
1103
|
+
throw new AggregateError(
|
|
1104
|
+
[formatted, ...cleanupErrors],
|
|
1105
|
+
`agenc: ${label} lock acquisition and rollback both failed`,
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
throw formatted;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
let released = false;
|
|
1112
|
+
return () => {
|
|
1113
|
+
if (released) return;
|
|
1114
|
+
releaseAcquired(acquired, label);
|
|
1115
|
+
released = acquired.every((item) => item.inProcessReleased);
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
export async function acquireLocalSqliteLock(path, options) {
|
|
1120
|
+
return acquireLocalSqliteLocks([path], options);
|
|
1121
|
+
}
|