@abot-ai/runtime 1.3.0 → 1.3.1
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/dist/scripts/init-runtime.js +4 -13
- package/dist/scripts/runtime-setup-directories.d.ts +2 -0
- package/dist/scripts/runtime-setup-directories.js +38 -0
- package/dist/src/shared/directory-authority/bootstrap.d.ts +1 -0
- package/dist/src/shared/directory-authority/bootstrap.js +118 -0
- package/dist/src/shared/directory-authority/command.d.ts +8 -0
- package/dist/src/shared/directory-authority/command.js +27 -0
- package/dist/src/shared/directory-authority/errors.d.ts +5 -0
- package/dist/src/shared/directory-authority/errors.js +10 -0
- package/dist/src/shared/directory-authority/index.d.ts +3 -0
- package/dist/src/shared/directory-authority/index.js +3 -0
- package/dist/src/shared/directory-authority/protocol.d.ts +11 -0
- package/dist/src/shared/directory-authority/protocol.js +62 -0
- package/dist/src/shared/directory-authority/task.d.ts +7 -0
- package/dist/src/shared/directory-authority/task.js +97 -0
- package/docs/plugins.md +15 -9
- package/package.json +1 -1
- package/plugins/exec/plugin.json +3 -3
- package/plugins/exec/skills/exec_skill/SKILL.md +1 -1
- package/plugins/exec/source/process-manager.ts +1 -20
- package/plugins/exec/source/shell-platform.ts +29 -0
- package/plugins/exec/src/index.cjs +25 -18
- package/plugins/filesystem/source/atomic-write.ts +9 -0
- package/plugins/filesystem/source/bounded-io.ts +5 -99
- package/plugins/filesystem/source/directory-authority-write.ts +64 -0
- package/plugins/filesystem/source/directory-sample-task.ts +23 -0
- package/plugins/filesystem/source/directory-sample.ts +132 -0
- package/plugins/filesystem/source/directory-write-task.ts +160 -0
- package/plugins/filesystem/source/mutation-parent.ts +11 -0
- package/plugins/filesystem/src/index.cjs +693 -118
- package/plugins/local-search/source/index.ts +2 -0
- package/plugins/local-search/source/paths.ts +30 -7
- package/plugins/local-search/source/ripgrep-process.ts +69 -0
- package/plugins/local-search/source/ripgrep.ts +19 -23
- package/plugins/local-search/src/index.cjs +295 -32
|
@@ -558,11 +558,34 @@ function resolveExecScopedPath(pluginContext, executionContext, rawPath, cwd) {
|
|
|
558
558
|
|
|
559
559
|
// plugins/exec/source/process-manager.ts
|
|
560
560
|
var import_node_child_process = require("node:child_process");
|
|
561
|
-
var import_node_fs2 = require("node:fs");
|
|
562
|
-
var import_promises3 = require("node:fs/promises");
|
|
563
561
|
var import_node_crypto2 = require("node:crypto");
|
|
564
562
|
var import_node_string_decoder = require("node:string_decoder");
|
|
563
|
+
|
|
564
|
+
// plugins/exec/source/shell-platform.ts
|
|
565
|
+
var import_node_fs2 = require("node:fs");
|
|
566
|
+
var import_promises3 = require("node:fs/promises");
|
|
565
567
|
var EXEC_SHELL = "/bin/bash";
|
|
568
|
+
function isSupportedExecPlatform(platform) {
|
|
569
|
+
return platform === "linux" || platform === "darwin";
|
|
570
|
+
}
|
|
571
|
+
async function assertSupportedShell(platform = process.platform) {
|
|
572
|
+
if (!isSupportedExecPlatform(platform)) {
|
|
573
|
+
throw new ExecPluginError(
|
|
574
|
+
"exec_platform_unsupported",
|
|
575
|
+
"The exec plugin requires Linux or macOS and executable /bin/bash."
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
await (0, import_promises3.access)(EXEC_SHELL, import_node_fs2.constants.X_OK);
|
|
580
|
+
} catch {
|
|
581
|
+
throw new ExecPluginError(
|
|
582
|
+
"exec_shell_unavailable",
|
|
583
|
+
"The exec plugin cannot start because executable /bin/bash is unavailable."
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// plugins/exec/source/process-manager.ts
|
|
566
589
|
var COMPLETED_PROCESS_RETENTION_MS = 5 * 6e4;
|
|
567
590
|
var MAX_ACTIVE_PROCESSES_PER_SCOPE = 4;
|
|
568
591
|
var CappedTextBuffer = class {
|
|
@@ -602,22 +625,6 @@ var CappedTextBuffer = class {
|
|
|
602
625
|
});
|
|
603
626
|
}
|
|
604
627
|
};
|
|
605
|
-
async function assertSupportedShell() {
|
|
606
|
-
if (process.platform !== "linux") {
|
|
607
|
-
throw new ExecPluginError(
|
|
608
|
-
"exec_platform_unsupported",
|
|
609
|
-
"The exec plugin v1 requires Linux and /bin/bash."
|
|
610
|
-
);
|
|
611
|
-
}
|
|
612
|
-
try {
|
|
613
|
-
await (0, import_promises3.access)(EXEC_SHELL, import_node_fs2.constants.X_OK);
|
|
614
|
-
} catch {
|
|
615
|
-
throw new ExecPluginError(
|
|
616
|
-
"exec_shell_unavailable",
|
|
617
|
-
"The exec plugin cannot start because executable /bin/bash is unavailable."
|
|
618
|
-
);
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
628
|
function createExecProcessManager() {
|
|
622
629
|
const processes = /* @__PURE__ */ new Map();
|
|
623
630
|
const unknownProcess = () => new ExecPluginError(
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "./bounded-io.js";
|
|
12
12
|
import { fail, isNodeErrorCode, rethrowFilesystemError } from "./errors.js";
|
|
13
13
|
import { openMutationParent } from "./mutation-parent.js";
|
|
14
|
+
import { writeWithDirectoryAuthority } from "./directory-authority-write.js";
|
|
14
15
|
|
|
15
16
|
export async function atomicWriteText(
|
|
16
17
|
input: Readonly<{
|
|
@@ -20,6 +21,10 @@ export async function atomicWriteText(
|
|
|
20
21
|
}>,
|
|
21
22
|
): Promise<void> {
|
|
22
23
|
assertMutationContentSize(input.content);
|
|
24
|
+
if (requiresIsolatedDirectoryAuthority()) {
|
|
25
|
+
await writeWithDirectoryAuthority(input);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
23
28
|
const parent = await openMutationParent(input.target);
|
|
24
29
|
const targetPath = `${parent.procPath}/${parent.targetName}`;
|
|
25
30
|
const anchoredTarget = Object.freeze({
|
|
@@ -113,3 +118,7 @@ async function syncDirectoryBestEffort(
|
|
|
113
118
|
// The prepared file itself is always synced before the atomic install.
|
|
114
119
|
}
|
|
115
120
|
}
|
|
121
|
+
|
|
122
|
+
function requiresIsolatedDirectoryAuthority(): boolean {
|
|
123
|
+
return process.platform === "darwin";
|
|
124
|
+
}
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { constants, type BigIntStats } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
opendir,
|
|
5
|
-
open,
|
|
6
|
-
realpath,
|
|
7
|
-
stat,
|
|
8
|
-
type FileHandle,
|
|
9
|
-
} from "node:fs/promises";
|
|
3
|
+
import { open, realpath, stat, type FileHandle } from "node:fs/promises";
|
|
10
4
|
import { relative, sep } from "node:path";
|
|
11
5
|
import { StringDecoder } from "node:string_decoder";
|
|
12
6
|
import { TextDecoder } from "node:util";
|
|
@@ -19,7 +13,10 @@ export const MAX_MUTATION_BYTES = 1_048_576;
|
|
|
19
13
|
export const READ_HEAD_BYTES = 24_576;
|
|
20
14
|
export const READ_TAIL_BYTES = 12_288;
|
|
21
15
|
export const DEV_SCAN_BYTES = 1_048_576;
|
|
22
|
-
export
|
|
16
|
+
export {
|
|
17
|
+
readDirectorySample,
|
|
18
|
+
DIRECTORY_ENTRY_LIMIT,
|
|
19
|
+
} from "./directory-sample.js";
|
|
23
20
|
|
|
24
21
|
export type BoundedText = Readonly<{
|
|
25
22
|
text: string;
|
|
@@ -350,97 +347,6 @@ export function assertMutationContentSize(content: string): number {
|
|
|
350
347
|
return byteCount;
|
|
351
348
|
}
|
|
352
349
|
|
|
353
|
-
export async function readDirectorySample(
|
|
354
|
-
target: ResolvedRuntimeToolPath,
|
|
355
|
-
): Promise<Readonly<{ entries: readonly string[]; truncated: boolean }>> {
|
|
356
|
-
const entries: string[] = [];
|
|
357
|
-
let handle: FileHandle | undefined;
|
|
358
|
-
try {
|
|
359
|
-
if (
|
|
360
|
-
typeof constants.O_DIRECTORY !== "number" ||
|
|
361
|
-
typeof constants.O_NOFOLLOW !== "number"
|
|
362
|
-
) {
|
|
363
|
-
fail(
|
|
364
|
-
"filesystem_safe_io_unsupported",
|
|
365
|
-
"This platform does not provide the no-follow directory operation required for a safe read.",
|
|
366
|
-
);
|
|
367
|
-
}
|
|
368
|
-
handle = await open(
|
|
369
|
-
target.absolutePath,
|
|
370
|
-
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
|
371
|
-
);
|
|
372
|
-
const before = await handle.stat({ bigint: true });
|
|
373
|
-
if (!before.isDirectory()) {
|
|
374
|
-
fail("not_a_directory", `Expected a directory: ${target.logicalPath}`);
|
|
375
|
-
}
|
|
376
|
-
const [canonicalRoot, canonicalTarget] = await Promise.all([
|
|
377
|
-
realpath(target.rootPath),
|
|
378
|
-
realpath(target.absolutePath),
|
|
379
|
-
]);
|
|
380
|
-
const rootRelative = relative(canonicalRoot, canonicalTarget);
|
|
381
|
-
if (
|
|
382
|
-
rootRelative === ".." ||
|
|
383
|
-
rootRelative.startsWith(`..${sep}`) ||
|
|
384
|
-
rootRelative.startsWith(sep)
|
|
385
|
-
) {
|
|
386
|
-
fail(
|
|
387
|
-
"filesystem_path_changed",
|
|
388
|
-
`Directory changed outside its configured root: ${target.logicalPath}`,
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
|
-
const pathIdentity = await stat(canonicalTarget, { bigint: true });
|
|
392
|
-
if (
|
|
393
|
-
!pathIdentity.isDirectory() ||
|
|
394
|
-
pathIdentity.dev !== before.dev ||
|
|
395
|
-
pathIdentity.ino !== before.ino
|
|
396
|
-
) {
|
|
397
|
-
fail(
|
|
398
|
-
"filesystem_path_changed",
|
|
399
|
-
`Directory changed while it was being opened: ${target.logicalPath}`,
|
|
400
|
-
);
|
|
401
|
-
}
|
|
402
|
-
const directory = await opendir(`/proc/self/fd/${handle.fd}`);
|
|
403
|
-
for await (const entry of directory) {
|
|
404
|
-
if (entries.length >= DIRECTORY_ENTRY_LIMIT) {
|
|
405
|
-
await assertDirectoryStable(handle, before, target.logicalPath);
|
|
406
|
-
entries.sort((left, right) => left.localeCompare(right));
|
|
407
|
-
return Object.freeze({
|
|
408
|
-
entries: Object.freeze(entries),
|
|
409
|
-
truncated: true,
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
entries.push(`${entry.name}${entry.isDirectory() ? "/" : ""}`);
|
|
413
|
-
}
|
|
414
|
-
await assertDirectoryStable(handle, before, target.logicalPath);
|
|
415
|
-
entries.sort((left, right) => left.localeCompare(right));
|
|
416
|
-
return Object.freeze({ entries: Object.freeze(entries), truncated: false });
|
|
417
|
-
} catch (error: unknown) {
|
|
418
|
-
return rethrowFilesystemError(error, "inspect", target.logicalPath);
|
|
419
|
-
} finally {
|
|
420
|
-
await handle?.close().catch(() => undefined);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
async function assertDirectoryStable(
|
|
425
|
-
handle: FileHandle,
|
|
426
|
-
before: BigIntStats,
|
|
427
|
-
logicalPath: string,
|
|
428
|
-
): Promise<void> {
|
|
429
|
-
const after = await handle.stat({ bigint: true });
|
|
430
|
-
if (
|
|
431
|
-
!after.isDirectory() ||
|
|
432
|
-
after.dev !== before.dev ||
|
|
433
|
-
after.ino !== before.ino ||
|
|
434
|
-
after.mtimeNs !== before.mtimeNs ||
|
|
435
|
-
after.ctimeNs !== before.ctimeNs
|
|
436
|
-
) {
|
|
437
|
-
fail(
|
|
438
|
-
"filesystem_read_changed",
|
|
439
|
-
`Directory changed while it was being read: ${logicalPath}`,
|
|
440
|
-
);
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
350
|
function rejectBinary(buffer: Buffer, logicalPath: string): void {
|
|
445
351
|
if (buffer.includes(0)) {
|
|
446
352
|
fail(
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ResolvedRuntimeToolPath } from "../../../src/plugin-sdk/index.js";
|
|
2
|
+
import {
|
|
3
|
+
DirectoryAuthorityError,
|
|
4
|
+
runDirectoryAuthorityTask,
|
|
5
|
+
} from "../../../src/shared/directory-authority/index.js";
|
|
6
|
+
import type { MutationTargetVersion } from "./bounded-io.js";
|
|
7
|
+
import { fail, rethrowFilesystemError } from "./errors.js";
|
|
8
|
+
import { openMutationRoot } from "./mutation-parent.js";
|
|
9
|
+
import {
|
|
10
|
+
commitDirectoryWrite,
|
|
11
|
+
type DirectoryWriteInput,
|
|
12
|
+
} from "./directory-write-task.js";
|
|
13
|
+
|
|
14
|
+
export async function writeWithDirectoryAuthority(
|
|
15
|
+
input: Readonly<{
|
|
16
|
+
target: ResolvedRuntimeToolPath;
|
|
17
|
+
content: string;
|
|
18
|
+
expectedVersion: MutationTargetVersion;
|
|
19
|
+
}>,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const root = await openMutationRoot(input.target);
|
|
22
|
+
const expected = input.expectedVersion;
|
|
23
|
+
const expectedVersion: DirectoryWriteInput["expectedVersion"] =
|
|
24
|
+
expected.kind === "absent"
|
|
25
|
+
? { kind: "absent" }
|
|
26
|
+
: {
|
|
27
|
+
...expected,
|
|
28
|
+
device: String(expected.device),
|
|
29
|
+
inode: String(expected.inode),
|
|
30
|
+
modifiedNs: String(expected.modifiedNs),
|
|
31
|
+
changedNs: String(expected.changedNs),
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
await runDirectoryAuthorityTask({
|
|
35
|
+
directoryPath: input.target.rootPath,
|
|
36
|
+
directoryFd: root.fd,
|
|
37
|
+
input: {
|
|
38
|
+
relativePath: input.target.relativePath,
|
|
39
|
+
content: input.content,
|
|
40
|
+
expectedVersion,
|
|
41
|
+
},
|
|
42
|
+
task: commitDirectoryWrite,
|
|
43
|
+
});
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error instanceof DirectoryAuthorityError) {
|
|
46
|
+
if (error.code.startsWith("filesystem_")) {
|
|
47
|
+
fail(
|
|
48
|
+
error.code,
|
|
49
|
+
`Safe write failed: ${input.target.logicalPath}`,
|
|
50
|
+
error.data,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (error.code.startsWith("directory_authority_")) {
|
|
54
|
+
fail(
|
|
55
|
+
"filesystem_path_changed",
|
|
56
|
+
`Directory authority was lost: ${input.target.logicalPath}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
rethrowFilesystemError(error, "write", input.target.logicalPath);
|
|
61
|
+
} finally {
|
|
62
|
+
await root.close().catch(() => undefined);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Self-contained trusted task, run with an already verified directory cwd. */
|
|
2
|
+
export async function sampleDirectoryTask(
|
|
3
|
+
input: Readonly<{ maxEntries: number }>,
|
|
4
|
+
): Promise<
|
|
5
|
+
Readonly<{
|
|
6
|
+
entries: readonly string[];
|
|
7
|
+
truncated: boolean;
|
|
8
|
+
}>
|
|
9
|
+
> {
|
|
10
|
+
const { opendir } =
|
|
11
|
+
require("node:fs/promises") as typeof import("node:fs/promises");
|
|
12
|
+
const entries: string[] = [];
|
|
13
|
+
const directory = await opendir(".");
|
|
14
|
+
for await (const entry of directory) {
|
|
15
|
+
if (entries.length >= input.maxEntries) {
|
|
16
|
+
entries.sort((left, right) => left.localeCompare(right));
|
|
17
|
+
return { entries, truncated: true };
|
|
18
|
+
}
|
|
19
|
+
entries.push(`${entry.name}${entry.isDirectory() ? "/" : ""}`);
|
|
20
|
+
}
|
|
21
|
+
entries.sort((left, right) => left.localeCompare(right));
|
|
22
|
+
return { entries, truncated: false };
|
|
23
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { constants, type BigIntStats } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
open,
|
|
4
|
+
opendir,
|
|
5
|
+
realpath,
|
|
6
|
+
stat,
|
|
7
|
+
type FileHandle,
|
|
8
|
+
} from "node:fs/promises";
|
|
9
|
+
import { relative, sep } from "node:path";
|
|
10
|
+
import type { ResolvedRuntimeToolPath } from "../../../src/plugin-sdk/index.js";
|
|
11
|
+
import {
|
|
12
|
+
DirectoryAuthorityError,
|
|
13
|
+
runDirectoryAuthorityTask,
|
|
14
|
+
} from "../../../src/shared/directory-authority/index.js";
|
|
15
|
+
import { fail, rethrowFilesystemError } from "./errors.js";
|
|
16
|
+
import { sampleDirectoryTask } from "./directory-sample-task.js";
|
|
17
|
+
|
|
18
|
+
export const DIRECTORY_ENTRY_LIMIT = 160;
|
|
19
|
+
|
|
20
|
+
export async function readDirectorySample(
|
|
21
|
+
target: ResolvedRuntimeToolPath,
|
|
22
|
+
): Promise<Readonly<{ entries: readonly string[]; truncated: boolean }>> {
|
|
23
|
+
const entries: string[] = [];
|
|
24
|
+
let handle: FileHandle | undefined;
|
|
25
|
+
try {
|
|
26
|
+
if (
|
|
27
|
+
typeof constants.O_DIRECTORY !== "number" ||
|
|
28
|
+
typeof constants.O_NOFOLLOW !== "number"
|
|
29
|
+
) {
|
|
30
|
+
fail(
|
|
31
|
+
"filesystem_safe_io_unsupported",
|
|
32
|
+
"This platform does not provide the no-follow directory operation required for a safe read.",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
handle = await open(
|
|
36
|
+
target.absolutePath,
|
|
37
|
+
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
|
38
|
+
);
|
|
39
|
+
const before = await handle.stat({ bigint: true });
|
|
40
|
+
if (!before.isDirectory()) {
|
|
41
|
+
fail("not_a_directory", `Expected a directory: ${target.logicalPath}`);
|
|
42
|
+
}
|
|
43
|
+
const [canonicalRoot, canonicalTarget] = await Promise.all([
|
|
44
|
+
realpath(target.rootPath),
|
|
45
|
+
realpath(target.absolutePath),
|
|
46
|
+
]);
|
|
47
|
+
const rootRelative = relative(canonicalRoot, canonicalTarget);
|
|
48
|
+
if (
|
|
49
|
+
rootRelative === ".." ||
|
|
50
|
+
rootRelative.startsWith(`..${sep}`) ||
|
|
51
|
+
rootRelative.startsWith(sep)
|
|
52
|
+
) {
|
|
53
|
+
fail(
|
|
54
|
+
"filesystem_path_changed",
|
|
55
|
+
`Directory changed outside its configured root: ${target.logicalPath}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const pathIdentity = await stat(canonicalTarget, { bigint: true });
|
|
59
|
+
if (
|
|
60
|
+
!pathIdentity.isDirectory() ||
|
|
61
|
+
pathIdentity.dev !== before.dev ||
|
|
62
|
+
pathIdentity.ino !== before.ino
|
|
63
|
+
) {
|
|
64
|
+
fail(
|
|
65
|
+
"filesystem_path_changed",
|
|
66
|
+
`Directory changed while it was being opened: ${target.logicalPath}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (requiresIsolatedDirectorySample()) {
|
|
70
|
+
const sample = await runDirectoryAuthorityTask({
|
|
71
|
+
directoryPath: target.absolutePath,
|
|
72
|
+
directoryFd: handle.fd,
|
|
73
|
+
input: { maxEntries: DIRECTORY_ENTRY_LIMIT },
|
|
74
|
+
task: sampleDirectoryTask,
|
|
75
|
+
});
|
|
76
|
+
await assertDirectoryStable(handle, before, target.logicalPath);
|
|
77
|
+
return sample;
|
|
78
|
+
}
|
|
79
|
+
const directory = await opendir(`/proc/self/fd/${handle.fd}`);
|
|
80
|
+
for await (const entry of directory) {
|
|
81
|
+
if (entries.length >= DIRECTORY_ENTRY_LIMIT) {
|
|
82
|
+
await assertDirectoryStable(handle, before, target.logicalPath);
|
|
83
|
+
entries.sort((left, right) => left.localeCompare(right));
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
entries: Object.freeze(entries),
|
|
86
|
+
truncated: true,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
entries.push(`${entry.name}${entry.isDirectory() ? "/" : ""}`);
|
|
90
|
+
}
|
|
91
|
+
await assertDirectoryStable(handle, before, target.logicalPath);
|
|
92
|
+
entries.sort((left, right) => left.localeCompare(right));
|
|
93
|
+
return Object.freeze({ entries: Object.freeze(entries), truncated: false });
|
|
94
|
+
} catch (error: unknown) {
|
|
95
|
+
if (
|
|
96
|
+
error instanceof DirectoryAuthorityError &&
|
|
97
|
+
error.code.startsWith("directory_authority_")
|
|
98
|
+
) {
|
|
99
|
+
fail(
|
|
100
|
+
"filesystem_path_changed",
|
|
101
|
+
`Directory authority was lost: ${target.logicalPath}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return rethrowFilesystemError(error, "inspect", target.logicalPath);
|
|
105
|
+
} finally {
|
|
106
|
+
await handle?.close().catch(() => undefined);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function assertDirectoryStable(
|
|
111
|
+
handle: FileHandle,
|
|
112
|
+
before: BigIntStats,
|
|
113
|
+
logicalPath: string,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const after = await handle.stat({ bigint: true });
|
|
116
|
+
if (
|
|
117
|
+
!after.isDirectory() ||
|
|
118
|
+
after.dev !== before.dev ||
|
|
119
|
+
after.ino !== before.ino ||
|
|
120
|
+
after.mtimeNs !== before.mtimeNs ||
|
|
121
|
+
after.ctimeNs !== before.ctimeNs
|
|
122
|
+
) {
|
|
123
|
+
fail(
|
|
124
|
+
"filesystem_read_changed",
|
|
125
|
+
`Directory changed while it was being read: ${logicalPath}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function requiresIsolatedDirectorySample(): boolean {
|
|
131
|
+
return process.platform === "darwin";
|
|
132
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
export type DirectoryWriteInput = Readonly<{
|
|
2
|
+
relativePath: string;
|
|
3
|
+
content: string;
|
|
4
|
+
expectedVersion:
|
|
5
|
+
| Readonly<{ kind: "absent" }>
|
|
6
|
+
| Readonly<{
|
|
7
|
+
kind: "file";
|
|
8
|
+
device: string;
|
|
9
|
+
inode: string;
|
|
10
|
+
size: number;
|
|
11
|
+
mode: number;
|
|
12
|
+
modifiedNs: string;
|
|
13
|
+
changedNs: string;
|
|
14
|
+
digest: string;
|
|
15
|
+
}>;
|
|
16
|
+
}>;
|
|
17
|
+
|
|
18
|
+
/** Self-contained trusted task: serialized into an isolated Node process. */
|
|
19
|
+
export async function commitDirectoryWrite(
|
|
20
|
+
input: DirectoryWriteInput,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const fs = require("node:fs/promises") as typeof import("node:fs/promises");
|
|
23
|
+
const { constants } = require("node:fs") as typeof import("node:fs");
|
|
24
|
+
const { createHash, randomUUID } =
|
|
25
|
+
require("node:crypto") as typeof import("node:crypto");
|
|
26
|
+
const expected = input.expectedVersion;
|
|
27
|
+
|
|
28
|
+
function failWrite(code: string): never {
|
|
29
|
+
throw Object.assign(new Error(code), { code });
|
|
30
|
+
}
|
|
31
|
+
function hasNodeCode(error: unknown, code: string): boolean {
|
|
32
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
33
|
+
}
|
|
34
|
+
function isSingleComponent(value: string): boolean {
|
|
35
|
+
if (!value || value === "." || value === "..") return false;
|
|
36
|
+
if (value.includes("/") || value.includes("\\")) return false;
|
|
37
|
+
return !value.includes("\0");
|
|
38
|
+
}
|
|
39
|
+
const segments = input.relativePath.split("/");
|
|
40
|
+
if (!segments.every(isSingleComponent)) failWrite("filesystem_path_changed");
|
|
41
|
+
const targetName = segments.pop()!;
|
|
42
|
+
if (Buffer.byteLength(input.content, "utf8") > 1_048_576) {
|
|
43
|
+
failWrite("filesystem_file_too_large");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function enterChildDirectory(name: string): Promise<void> {
|
|
47
|
+
let handle: Awaited<ReturnType<typeof fs.open>>;
|
|
48
|
+
const flags =
|
|
49
|
+
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
|
|
50
|
+
try {
|
|
51
|
+
handle = await fs.open(name, flags);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (!hasNodeCode(error, "ENOENT")) throw error;
|
|
54
|
+
try {
|
|
55
|
+
await fs.mkdir(name);
|
|
56
|
+
} catch (creationError) {
|
|
57
|
+
if (!hasNodeCode(creationError, "EEXIST")) throw creationError;
|
|
58
|
+
}
|
|
59
|
+
handle = await fs.open(name, flags);
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const held = await handle.stat({ bigint: true });
|
|
63
|
+
if (!held.isDirectory()) failWrite("filesystem_path_changed");
|
|
64
|
+
process.chdir(name);
|
|
65
|
+
const entered = await fs.stat(".", { bigint: true });
|
|
66
|
+
if (held.dev !== entered.dev || held.ino !== entered.ino) {
|
|
67
|
+
failWrite("filesystem_path_changed");
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
await handle.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const segment of segments) await enterChildDirectory(segment);
|
|
74
|
+
|
|
75
|
+
function hasExpectedMetadata(info: import("node:fs").BigIntStats): boolean {
|
|
76
|
+
if (expected.kind !== "file" || !info.isFile()) return false;
|
|
77
|
+
if (String(info.dev) !== expected.device) return false;
|
|
78
|
+
if (String(info.ino) !== expected.inode) return false;
|
|
79
|
+
if (Number(info.size) !== expected.size) return false;
|
|
80
|
+
if ((Number(info.mode) & 0o7777) !== expected.mode) return false;
|
|
81
|
+
if (String(info.mtimeNs) !== expected.modifiedNs) return false;
|
|
82
|
+
return String(info.ctimeNs) === expected.changedNs;
|
|
83
|
+
}
|
|
84
|
+
async function assertExpectedTarget(): Promise<void> {
|
|
85
|
+
let target: Awaited<ReturnType<typeof fs.open>>;
|
|
86
|
+
try {
|
|
87
|
+
target = await fs.open(
|
|
88
|
+
targetName,
|
|
89
|
+
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
90
|
+
);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (hasNodeCode(error, "ENOENT") && expected.kind === "absent") return;
|
|
93
|
+
failWrite("filesystem_target_changed");
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const before = await target.stat({ bigint: true });
|
|
97
|
+
if (!hasExpectedMetadata(before)) failWrite("filesystem_target_changed");
|
|
98
|
+
if (expected.kind !== "file") failWrite("filesystem_target_changed");
|
|
99
|
+
const bytes = Buffer.alloc(expected.size);
|
|
100
|
+
let offset = 0;
|
|
101
|
+
while (offset < bytes.length) {
|
|
102
|
+
const { bytesRead } = await target.read(
|
|
103
|
+
bytes,
|
|
104
|
+
offset,
|
|
105
|
+
bytes.length - offset,
|
|
106
|
+
offset,
|
|
107
|
+
);
|
|
108
|
+
if (bytesRead <= 0) failWrite("filesystem_target_changed");
|
|
109
|
+
offset += bytesRead;
|
|
110
|
+
}
|
|
111
|
+
const after = await target.stat({ bigint: true });
|
|
112
|
+
if (!hasExpectedMetadata(after)) failWrite("filesystem_target_changed");
|
|
113
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
114
|
+
if (digest !== expected.digest) failWrite("filesystem_target_changed");
|
|
115
|
+
} finally {
|
|
116
|
+
await target.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const parent = await fs.open(
|
|
121
|
+
".",
|
|
122
|
+
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
|
123
|
+
);
|
|
124
|
+
const temporaryName = `.abot-${targetName}-${randomUUID()}.tmp`;
|
|
125
|
+
let temporaryCreated = false;
|
|
126
|
+
try {
|
|
127
|
+
const temporary = await fs.open(temporaryName, "wx");
|
|
128
|
+
temporaryCreated = true;
|
|
129
|
+
try {
|
|
130
|
+
await temporary.writeFile(input.content, "utf8");
|
|
131
|
+
if (expected.kind === "file") await temporary.chmod(expected.mode);
|
|
132
|
+
await temporary.sync();
|
|
133
|
+
} finally {
|
|
134
|
+
await temporary.close();
|
|
135
|
+
}
|
|
136
|
+
await assertExpectedTarget();
|
|
137
|
+
if (expected.kind === "file") {
|
|
138
|
+
await fs.rename(temporaryName, targetName);
|
|
139
|
+
temporaryCreated = false;
|
|
140
|
+
} else {
|
|
141
|
+
try {
|
|
142
|
+
await fs.link(temporaryName, targetName);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (hasNodeCode(error, "EEXIST"))
|
|
145
|
+
failWrite("filesystem_target_changed");
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
const removed = await fs
|
|
149
|
+
.rm(temporaryName, { force: true })
|
|
150
|
+
.then(() => true)
|
|
151
|
+
.catch(() => false);
|
|
152
|
+
temporaryCreated = !removed;
|
|
153
|
+
}
|
|
154
|
+
await parent.sync().catch(() => undefined);
|
|
155
|
+
} finally {
|
|
156
|
+
if (temporaryCreated)
|
|
157
|
+
await fs.rm(temporaryName, { force: true }).catch(() => undefined);
|
|
158
|
+
await parent.close().catch(() => undefined);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -66,6 +66,7 @@ async function openDirectory(
|
|
|
66
66
|
`Directory changed while it was being opened: ${logicalPath}`,
|
|
67
67
|
);
|
|
68
68
|
}
|
|
69
|
+
if (usesIsolatedDirectoryAuthority()) return handle;
|
|
69
70
|
const procPath = `/proc/self/fd/${handle.fd}`;
|
|
70
71
|
const procIdentity = await stat(procPath, { bigint: true });
|
|
71
72
|
if (!procIdentity.isDirectory() || !sameIdentity(opened, procIdentity)) {
|
|
@@ -162,3 +163,13 @@ export async function openMutationParent(
|
|
|
162
163
|
throw error;
|
|
163
164
|
}
|
|
164
165
|
}
|
|
166
|
+
|
|
167
|
+
function usesIsolatedDirectoryAuthority(): boolean {
|
|
168
|
+
return process.platform === "darwin";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function openMutationRoot(
|
|
172
|
+
target: ResolvedRuntimeToolPath,
|
|
173
|
+
): Promise<FileHandle> {
|
|
174
|
+
return openDirectory(target.rootPath, target.rootPath, target.logicalPath);
|
|
175
|
+
}
|