@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.
Files changed (35) hide show
  1. package/dist/scripts/init-runtime.js +4 -13
  2. package/dist/scripts/runtime-setup-directories.d.ts +2 -0
  3. package/dist/scripts/runtime-setup-directories.js +38 -0
  4. package/dist/src/shared/directory-authority/bootstrap.d.ts +1 -0
  5. package/dist/src/shared/directory-authority/bootstrap.js +118 -0
  6. package/dist/src/shared/directory-authority/command.d.ts +8 -0
  7. package/dist/src/shared/directory-authority/command.js +27 -0
  8. package/dist/src/shared/directory-authority/errors.d.ts +5 -0
  9. package/dist/src/shared/directory-authority/errors.js +10 -0
  10. package/dist/src/shared/directory-authority/index.d.ts +3 -0
  11. package/dist/src/shared/directory-authority/index.js +3 -0
  12. package/dist/src/shared/directory-authority/protocol.d.ts +11 -0
  13. package/dist/src/shared/directory-authority/protocol.js +62 -0
  14. package/dist/src/shared/directory-authority/task.d.ts +7 -0
  15. package/dist/src/shared/directory-authority/task.js +97 -0
  16. package/docs/plugins.md +15 -9
  17. package/package.json +1 -1
  18. package/plugins/exec/plugin.json +3 -3
  19. package/plugins/exec/skills/exec_skill/SKILL.md +1 -1
  20. package/plugins/exec/source/process-manager.ts +1 -20
  21. package/plugins/exec/source/shell-platform.ts +29 -0
  22. package/plugins/exec/src/index.cjs +25 -18
  23. package/plugins/filesystem/source/atomic-write.ts +9 -0
  24. package/plugins/filesystem/source/bounded-io.ts +5 -99
  25. package/plugins/filesystem/source/directory-authority-write.ts +64 -0
  26. package/plugins/filesystem/source/directory-sample-task.ts +23 -0
  27. package/plugins/filesystem/source/directory-sample.ts +132 -0
  28. package/plugins/filesystem/source/directory-write-task.ts +160 -0
  29. package/plugins/filesystem/source/mutation-parent.ts +11 -0
  30. package/plugins/filesystem/src/index.cjs +693 -118
  31. package/plugins/local-search/source/index.ts +2 -0
  32. package/plugins/local-search/source/paths.ts +30 -7
  33. package/plugins/local-search/source/ripgrep-process.ts +69 -0
  34. package/plugins/local-search/source/ripgrep.ts +19 -23
  35. package/plugins/local-search/src/index.cjs +295 -32
@@ -1,7 +1,8 @@
1
1
  import { constants } from "node:fs";
2
2
  import { access, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
3
- import { dirname, join, resolve } from "node:path";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
+ import { initializeConfiguredRuntimeDirectories } from "./runtime-setup-directories.js";
5
6
  import { buildModelConfig, buildProviderConfig, DEFAULT_ROOT_RESPONSE_METHODOLOGY_FILES, isRecord, parseBaseUrl, parseProvider, readJsonObject, resolveRuntimePackageRoot, writeJsonObject, } from "./runtime-setup-files.js";
6
7
  import { getInitNextSteps, getInitUsage, } from "./runtime-setup-presentation.js";
7
8
  const ENV_FILE = ".env";
@@ -13,16 +14,6 @@ const CONFIG_EXAMPLE_FILE = join("examples", "runtime.config.example.json");
13
14
  const REQUEST_RUNNER_CONFIG_EXAMPLE_FILE = join("examples", "request-runner.config.example.json");
14
15
  const MODEL_CONFIG_EXAMPLE_FILE = join("examples", "models", "default.config.json");
15
16
  const REQUIRED_ENV_LINE = "LLM_RUNTIME_CONFIG_FILE=local/runtime.config.json";
16
- const RUNTIME_DIRS = [
17
- join(".runtime", "compiled"),
18
- join(".runtime", "shared", "logs"),
19
- join(".runtime", "prod"),
20
- join(".runtime", "prod", "sandbox"),
21
- join(".runtime", "prod", "sessions"),
22
- join(".runtime", "dev"),
23
- join(".runtime", "dev", "sandbox"),
24
- join(".runtime", "dev", "sessions"),
25
- ];
26
17
  function requireValue(argv, index, flag) {
27
18
  const value = argv[index + 1]?.trim();
28
19
  if (!value || value.startsWith("--")) {
@@ -238,7 +229,7 @@ export async function runInitRuntime(argv = process.argv.slice(2), runOptions =
238
229
  if (modelConfigStatus !== "kept") {
239
230
  await configureModelProfile(modelConfigPath, options.provider, options.model);
240
231
  }
241
- await Promise.all(RUNTIME_DIRS.map((runtimeDir) => mkdir(join(options.rootDir, runtimeDir), { recursive: true })));
232
+ const runtimeDirectories = await initializeConfiguredRuntimeDirectories(options.rootDir, configPath);
242
233
  console.log([
243
234
  "abot initialized",
244
235
  `root: ${options.rootDir}`,
@@ -250,7 +241,7 @@ export async function runInitRuntime(argv = process.argv.slice(2), runOptions =
250
241
  `${LOCAL_REQUEST_RUNNER_CONFIG_FILE}: ${requestRunnerConfigStatus}`,
251
242
  `${LOCAL_MODEL_CONFIG_FILE}: ${modelConfigStatus}`,
252
243
  ...methodologyStatuses.map(({ relativePath, status }) => `${relativePath}: ${status}`),
253
- "created runtime directories: .runtime/compiled, .runtime/shared/logs, .runtime/prod, .runtime/dev",
244
+ `initialized runtime directories: ${runtimeDirectories.map((directory) => relative(options.rootDir, directory) || ".").join(", ")}`,
254
245
  "",
255
246
  ...getInitNextSteps(commandMode),
256
247
  ].join("\n"));
@@ -0,0 +1,2 @@
1
+ /** Initialize the preserved configuration's output roots without moving existing state. */
2
+ export declare function initializeConfiguredRuntimeDirectories(rootDir: string, configPath: string): Promise<string[]>;
@@ -0,0 +1,38 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { loadRuntimeConfig } from "../src/runtime/config.js";
4
+ import { RUNTIME_LOGS_DIR_NAME } from "../src/runtime/config/layout.js";
5
+ import { isRecord, readJsonObject, } from "./runtime-setup-files.js";
6
+ function configuredEnvironmentIds(config) {
7
+ if (!isRecord(config.environment))
8
+ return [undefined];
9
+ if (!isRecord(config.environment.profiles))
10
+ return [undefined];
11
+ const profileIds = Object.keys(config.environment.profiles);
12
+ const hasConfiguredEnvironments = profileIds.length > 0;
13
+ return hasConfiguredEnvironments ? profileIds : [undefined];
14
+ }
15
+ function runtimeOwnedDirectories(paths) {
16
+ return [
17
+ paths.runtimeDir,
18
+ paths.agentWorkDir,
19
+ paths.sessionsDir,
20
+ paths.attachmentsDir,
21
+ paths.sharedDir,
22
+ paths.compiledDir,
23
+ dirname(paths.traceFile),
24
+ join(paths.sharedDir, RUNTIME_LOGS_DIR_NAME),
25
+ ];
26
+ }
27
+ /** Initialize the preserved configuration's output roots without moving existing state. */
28
+ export async function initializeConfiguredRuntimeDirectories(rootDir, configPath) {
29
+ const config = await readJsonObject(configPath);
30
+ // Resolve every environment before creating any directories. This preserves the
31
+ // canonical guard against silently reassigning existing shared runtime state.
32
+ const configurations = configuredEnvironmentIds(config).map((profileId) => loadRuntimeConfig({ rootDir, configPath, profileId }));
33
+ const directories = [
34
+ ...new Set(configurations.flatMap(({ paths }) => runtimeOwnedDirectories(paths))),
35
+ ];
36
+ await Promise.all(directories.map((directory) => mkdir(directory, { recursive: true })));
37
+ return directories;
38
+ }
@@ -0,0 +1 @@
1
+ export declare function directoryAuthorityScript(mode: "task" | "command", messageLimit: number, task?: (input: never) => unknown): string;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * This function is serialized into a fresh Node process. It must remain
3
+ * closure-free and use only node: built-ins; the starting cwd is untrusted
4
+ * until its identity has been compared with the inherited directory handle.
5
+ */
6
+ function directoryAuthorityBootstrap(mode, messageLimit, task) {
7
+ const fs = require("node:fs");
8
+ function fail(code, message) {
9
+ throw Object.assign(new Error(message), { code });
10
+ }
11
+ function verifyDirectoryAuthority() {
12
+ const expected = fs.fstatSync(3, { bigint: true });
13
+ const actual = fs.statSync(".", { bigint: true });
14
+ const matchesHeldDirectory = expected.isDirectory() &&
15
+ actual.isDirectory() &&
16
+ expected.dev === actual.dev &&
17
+ expected.ino === actual.ino;
18
+ if (matchesHeldDirectory)
19
+ return;
20
+ fail("directory_authority_changed", "The directory changed before the operation could establish its authority.");
21
+ }
22
+ function readRequest() {
23
+ const fd = mode === "task" ? 0 : 4;
24
+ const chunk = Buffer.alloc(64 * 1024);
25
+ const chunks = [];
26
+ let total = 0;
27
+ for (;;) {
28
+ const length = fs.readSync(fd, chunk, 0, chunk.length, null);
29
+ if (length === 0)
30
+ break;
31
+ total += length;
32
+ if (total > messageLimit) {
33
+ fail("directory_authority_request_too_large", "The directory operation input exceeds the bridge byte limit.");
34
+ }
35
+ chunks.push(Buffer.from(chunk.subarray(0, length)));
36
+ }
37
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
38
+ }
39
+ function reportFailure(error) {
40
+ const detail = error;
41
+ const code = typeof detail?.code === "string"
42
+ ? detail.code.slice(0, 256)
43
+ : "directory_authority_failed";
44
+ const message = typeof detail?.message === "string"
45
+ ? detail.message.slice(0, 4096)
46
+ : "The directory operation failed.";
47
+ let encoded;
48
+ try {
49
+ encoded = JSON.stringify({
50
+ ok: false,
51
+ error: { code, message, data: detail?.data },
52
+ });
53
+ if (Buffer.byteLength(encoded) > messageLimit)
54
+ throw new Error();
55
+ }
56
+ catch {
57
+ encoded = JSON.stringify({ ok: false, error: { code, message } });
58
+ }
59
+ writeResponse(mode === "task" ? 1 : 2, encoded);
60
+ process.exitCode = mode === "task" ? 0 : 125;
61
+ }
62
+ function writeResponse(fd, encoded) {
63
+ const bytes = Buffer.from(encoded);
64
+ let offset = 0;
65
+ while (offset < bytes.length) {
66
+ offset += fs.writeSync(fd, bytes, offset, bytes.length - offset);
67
+ }
68
+ }
69
+ async function runTask(input) {
70
+ if (!task)
71
+ fail("directory_authority_failed", "The directory task is missing.");
72
+ const result = await task(input);
73
+ const encoded = JSON.stringify({ ok: true, result });
74
+ if (Buffer.byteLength(encoded) > messageLimit) {
75
+ fail("directory_authority_result_too_large", "The directory operation result exceeds the bridge byte limit.");
76
+ }
77
+ writeResponse(1, encoded);
78
+ }
79
+ function runCommand(input) {
80
+ const { spawn } = require("node:child_process");
81
+ const command = input;
82
+ // Omitting cwd inherits the kernel-pinned authority. Resolving cwd() back
83
+ // into a pathname here would reopen the race this bridge prevents.
84
+ const child = spawn(command.command, command.args, {
85
+ stdio: ["ignore", "inherit", "inherit"],
86
+ env: process.env,
87
+ });
88
+ child.once("error", reportFailure);
89
+ child.once("exit", (code, signal) => {
90
+ if (signal) {
91
+ process.kill(process.pid, signal);
92
+ return;
93
+ }
94
+ if (typeof code === "number")
95
+ process.exitCode = code;
96
+ });
97
+ }
98
+ try {
99
+ verifyDirectoryAuthority();
100
+ const input = readRequest();
101
+ if (mode === "command") {
102
+ runCommand(input);
103
+ return;
104
+ }
105
+ void runTask(input).catch(reportFailure);
106
+ }
107
+ catch (error) {
108
+ reportFailure(error);
109
+ }
110
+ }
111
+ export function directoryAuthorityScript(mode, messageLimit, task) {
112
+ const taskSource = task ? `(${task.toString()})` : "undefined";
113
+ // tsx preserves nested function names with esbuild's __name annotation.
114
+ // Include that annotation's definition so the same trusted task works in
115
+ // development and in the production bundle without rewriting its source.
116
+ const preserveFunctionName = "const __name=(target,value)=>Object.defineProperty(target,'name',{value,configurable:true});";
117
+ return `${preserveFunctionName}(${directoryAuthorityBootstrap.toString()})(${JSON.stringify(mode)},${messageLimit},${taskSource});`;
118
+ }
@@ -0,0 +1,8 @@
1
+ import { type ChildProcessByStdio } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ import { type DirectoryAuthorityLocation } from "./protocol.js";
4
+ export declare function spawnDirectoryAuthorityCommand(input: DirectoryAuthorityLocation & Readonly<{
5
+ command: string;
6
+ args: readonly string[];
7
+ env?: NodeJS.ProcessEnv;
8
+ }>): ChildProcessByStdio<null, Readable, Readable>;
@@ -0,0 +1,27 @@
1
+ import { spawn } from "node:child_process";
2
+ import { directoryAuthorityScript } from "./bootstrap.js";
3
+ import { assertDirectoryAuthorityLocation, authorityEnvironment, AUTHORITY_MESSAGE_LIMIT, encodeAuthorityRequest, } from "./protocol.js";
4
+ export function spawnDirectoryAuthorityCommand(input) {
5
+ assertDirectoryAuthorityLocation(input);
6
+ const request = encodeAuthorityRequest({
7
+ command: input.command,
8
+ args: input.args,
9
+ });
10
+ const child = spawn(process.execPath, [
11
+ "--input-type=commonjs",
12
+ "-e",
13
+ directoryAuthorityScript("command", AUTHORITY_MESSAGE_LIMIT),
14
+ ], {
15
+ cwd: input.directoryPath,
16
+ env: authorityEnvironment(input.env),
17
+ detached: true,
18
+ windowsHide: true,
19
+ stdio: ["ignore", "pipe", "pipe", input.directoryFd, "pipe"],
20
+ });
21
+ const requestPipe = child.stdio[4];
22
+ // An early authority failure may close the request pipe before the parent
23
+ // finishes writing. The process result remains the authoritative error.
24
+ requestPipe?.on("error", () => undefined);
25
+ requestPipe?.end(request);
26
+ return child;
27
+ }
@@ -0,0 +1,5 @@
1
+ export declare class DirectoryAuthorityError extends Error {
2
+ readonly code: string;
3
+ readonly data?: Readonly<Record<string, unknown>> | undefined;
4
+ constructor(code: string, message: string, data?: Readonly<Record<string, unknown>> | undefined);
5
+ }
@@ -0,0 +1,10 @@
1
+ export class DirectoryAuthorityError extends Error {
2
+ code;
3
+ data;
4
+ constructor(code, message, data) {
5
+ super(message);
6
+ this.code = code;
7
+ this.data = data;
8
+ this.name = "DirectoryAuthorityError";
9
+ }
10
+ }
@@ -0,0 +1,3 @@
1
+ export { spawnDirectoryAuthorityCommand } from "./command.js";
2
+ export { DirectoryAuthorityError } from "./errors.js";
3
+ export { runDirectoryAuthorityTask } from "./task.js";
@@ -0,0 +1,3 @@
1
+ export { spawnDirectoryAuthorityCommand } from "./command.js";
2
+ export { DirectoryAuthorityError } from "./errors.js";
3
+ export { runDirectoryAuthorityTask } from "./task.js";
@@ -0,0 +1,11 @@
1
+ export declare const AUTHORITY_MESSAGE_LIMIT: number;
2
+ export declare const AUTHORITY_STDERR_LIMIT: number;
3
+ export declare const AUTHORITY_TASK_TIMEOUT_MS = 30000;
4
+ export type DirectoryAuthorityLocation = Readonly<{
5
+ directoryPath: string;
6
+ directoryFd: number;
7
+ }>;
8
+ export declare function assertDirectoryAuthorityLocation(location: DirectoryAuthorityLocation): void;
9
+ export declare function authorityEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
10
+ export declare function encodeAuthorityRequest(input: unknown): string;
11
+ export declare function decodeAuthorityResult<Result>(output: string): Result;
@@ -0,0 +1,62 @@
1
+ import { isAbsolute } from "node:path";
2
+ import { DirectoryAuthorityError } from "./errors.js";
3
+ export const AUTHORITY_MESSAGE_LIMIT = 8 * 1024 * 1024;
4
+ export const AUTHORITY_STDERR_LIMIT = 16 * 1024;
5
+ export const AUTHORITY_TASK_TIMEOUT_MS = 30_000;
6
+ export function assertDirectoryAuthorityLocation(location) {
7
+ if (!isAbsolute(location.directoryPath)) {
8
+ throw new DirectoryAuthorityError("directory_authority_invalid_path", "The directory authority requires an absolute starting path.");
9
+ }
10
+ const hasOpenDescriptorNumber = Number.isInteger(location.directoryFd) && location.directoryFd >= 0;
11
+ if (hasOpenDescriptorNumber)
12
+ return;
13
+ throw new DirectoryAuthorityError("directory_authority_invalid_fd", "The directory authority requires an open directory descriptor.");
14
+ }
15
+ export function authorityEnvironment(environment = process.env) {
16
+ const sanitized = { ...environment };
17
+ delete sanitized.NODE_OPTIONS;
18
+ delete sanitized.NODE_PATH;
19
+ return sanitized;
20
+ }
21
+ export function encodeAuthorityRequest(input) {
22
+ let encoded;
23
+ try {
24
+ encoded = JSON.stringify(input);
25
+ }
26
+ catch {
27
+ throw new DirectoryAuthorityError("directory_authority_invalid_request", "The directory operation input must be JSON serializable.");
28
+ }
29
+ if (encoded === undefined) {
30
+ throw new DirectoryAuthorityError("directory_authority_invalid_request", "The directory operation input must be JSON serializable.");
31
+ }
32
+ if (Buffer.byteLength(encoded) <= AUTHORITY_MESSAGE_LIMIT)
33
+ return encoded;
34
+ throw new DirectoryAuthorityError("directory_authority_request_too_large", "The directory operation input exceeds the bridge byte limit.");
35
+ }
36
+ function isAuthorityMessageObject(value) {
37
+ return typeof value === "object" && value !== null && !Array.isArray(value);
38
+ }
39
+ export function decodeAuthorityResult(output) {
40
+ let message;
41
+ try {
42
+ message = JSON.parse(output);
43
+ }
44
+ catch {
45
+ throw new DirectoryAuthorityError("directory_authority_protocol_error", "The directory operation returned an invalid response.");
46
+ }
47
+ if (!isAuthorityMessageObject(message)) {
48
+ throw new DirectoryAuthorityError("directory_authority_protocol_error", "The directory operation returned an invalid response.");
49
+ }
50
+ if (message.ok === true)
51
+ return message.result;
52
+ const hasAuthorityError = message.ok === false && isAuthorityMessageObject(message.error);
53
+ if (!hasAuthorityError) {
54
+ throw new DirectoryAuthorityError("directory_authority_protocol_error", "The directory operation returned an invalid response.");
55
+ }
56
+ const { code, message: explanation, data, } = message.error;
57
+ const hasErrorDescription = typeof code === "string" && typeof explanation === "string";
58
+ if (!hasErrorDescription) {
59
+ throw new DirectoryAuthorityError("directory_authority_protocol_error", "The directory operation returned an invalid error response.");
60
+ }
61
+ throw new DirectoryAuthorityError(code, explanation, isAuthorityMessageObject(data) ? data : undefined);
62
+ }
@@ -0,0 +1,7 @@
1
+ import { type DirectoryAuthorityLocation } from "./protocol.js";
2
+ export declare function runDirectoryAuthorityTask<Input, Result>(input: DirectoryAuthorityLocation & Readonly<{
3
+ input: Input;
4
+ task: (input: Input) => Result | Promise<Result>;
5
+ timeoutMs?: number;
6
+ signal?: AbortSignal;
7
+ }>): Promise<Result>;
@@ -0,0 +1,97 @@
1
+ import { spawn } from "node:child_process";
2
+ import { directoryAuthorityScript } from "./bootstrap.js";
3
+ import { DirectoryAuthorityError } from "./errors.js";
4
+ import { assertDirectoryAuthorityLocation, authorityEnvironment, AUTHORITY_MESSAGE_LIMIT, AUTHORITY_STDERR_LIMIT, AUTHORITY_TASK_TIMEOUT_MS, decodeAuthorityResult, encodeAuthorityRequest, } from "./protocol.js";
5
+ export async function runDirectoryAuthorityTask(input) {
6
+ assertDirectoryAuthorityLocation(input);
7
+ const request = encodeAuthorityRequest(input.input);
8
+ const timeoutMs = input.timeoutMs ?? AUTHORITY_TASK_TIMEOUT_MS;
9
+ const hasPositiveDeadline = Number.isFinite(timeoutMs) && timeoutMs > 0;
10
+ if (!hasPositiveDeadline) {
11
+ throw new DirectoryAuthorityError("directory_authority_invalid_timeout", "The directory operation deadline must be a positive finite duration.");
12
+ }
13
+ if (input.signal?.aborted) {
14
+ throw new DirectoryAuthorityError("directory_authority_aborted", "The directory operation was cancelled before it started.");
15
+ }
16
+ const child = spawn(process.execPath, [
17
+ "--input-type=commonjs",
18
+ "-e",
19
+ directoryAuthorityScript("task", AUTHORITY_MESSAGE_LIMIT, input.task),
20
+ ], {
21
+ cwd: input.directoryPath,
22
+ env: authorityEnvironment(),
23
+ detached: true,
24
+ windowsHide: true,
25
+ stdio: ["pipe", "pipe", "pipe", input.directoryFd],
26
+ });
27
+ return new Promise((resolve, reject) => {
28
+ const chunks = [];
29
+ const stderrChunks = [];
30
+ let bytes = 0;
31
+ let stderrBytes = 0;
32
+ let failure;
33
+ const stop = (error) => {
34
+ if (failure)
35
+ return;
36
+ failure = error;
37
+ if (typeof child.pid === "number") {
38
+ try {
39
+ process.kill(-child.pid, "SIGKILL");
40
+ return;
41
+ }
42
+ catch {
43
+ // A process which has already exited may no longer own its group.
44
+ }
45
+ }
46
+ child.kill("SIGKILL");
47
+ };
48
+ const onAbort = () => stop(new DirectoryAuthorityError("directory_authority_aborted", "The directory operation was cancelled."));
49
+ const timer = setTimeout(() => stop(new DirectoryAuthorityError("directory_authority_timeout", "The directory operation exceeded its deadline.")), timeoutMs);
50
+ timer.unref();
51
+ input.signal?.addEventListener("abort", onAbort, { once: true });
52
+ if (input.signal?.aborted)
53
+ onAbort();
54
+ child.stdout.on("data", (chunk) => {
55
+ bytes += chunk.length;
56
+ if (bytes > AUTHORITY_MESSAGE_LIMIT) {
57
+ stop(new DirectoryAuthorityError("directory_authority_result_too_large", "The directory operation result exceeds the bridge byte limit."));
58
+ return;
59
+ }
60
+ chunks.push(chunk);
61
+ });
62
+ child.stderr.on("data", (chunk) => {
63
+ const remaining = Math.max(AUTHORITY_STDERR_LIMIT - stderrBytes, 0);
64
+ if (remaining === 0)
65
+ return;
66
+ stderrChunks.push(chunk.subarray(0, remaining));
67
+ stderrBytes += Math.min(chunk.length, remaining);
68
+ });
69
+ child.stdin.on("error", () => undefined);
70
+ child.once("error", (error) => {
71
+ failure ??= new DirectoryAuthorityError("directory_authority_unavailable", `The directory operation could not start: ${error.message}`);
72
+ });
73
+ child.once("close", (code, signal) => {
74
+ clearTimeout(timer);
75
+ input.signal?.removeEventListener("abort", onAbort);
76
+ if (failure) {
77
+ reject(failure);
78
+ return;
79
+ }
80
+ if (code !== 0) {
81
+ reject(new DirectoryAuthorityError("directory_authority_failed", "The directory operation process did not complete successfully.", {
82
+ exitCode: code,
83
+ signal,
84
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
85
+ }));
86
+ return;
87
+ }
88
+ try {
89
+ resolve(decodeAuthorityResult(Buffer.concat(chunks).toString("utf8")));
90
+ }
91
+ catch (error) {
92
+ reject(error);
93
+ }
94
+ });
95
+ child.stdin.end(request);
96
+ });
97
+ }
package/docs/plugins.md CHANGED
@@ -300,13 +300,19 @@ Node filesystem APIs do not provide inode compare-and-swap, so a completely
300
300
  independent writer that ignores this coordination can still race in the final
301
301
  existing-file commit window. Consumers that require stronger multi-process
302
302
  coordination must place the work root behind a versioned or transactional store.
303
- Mutation path traversal requires Linux procfs and no-follow file-descriptor
304
- support. The plugin fails closed when either safety primitive is unavailable.
305
-
306
- The bundled `local-search` plugin also requires Linux procfs and no-follow
307
- file-descriptor support. It holds the selected file or directory through a
308
- stable descriptor for the complete ripgrep invocation and fails closed when
309
- that authority cannot be established.
303
+ Mutation path traversal requires no-follow directory handles. On Linux the
304
+ plugin anchors operations through procfs. On macOS an isolated Node child
305
+ inherits the held directory handle, verifies its current directory against
306
+ that handle, and performs basename-relative operations from the pinned current
307
+ directory. Each descendant transition is verified before mutation. The parent
308
+ Runtime never changes its current directory, and unavailable authority fails
309
+ closed. Directory inspection uses the same platform-specific authority.
310
+
311
+ The bundled `local-search` plugin holds the selected file or directory through
312
+ a stable descriptor for the complete ripgrep invocation. Linux directory
313
+ searches use procfs; macOS searches inherit the verified current directory of
314
+ an isolated Node child. File content searches use the held input descriptor on
315
+ both platforms. A search fails closed when its authority cannot be established.
310
316
 
311
317
  All output and structured data must be bounded at the producer. The shared SDK
312
318
  reserves wrapper headroom by rejecting plugin results above 128 KiB before they
@@ -316,8 +322,8 @@ and rendered text. A truncated success reports machine-readable truncation
316
322
  metadata; an operation that cannot produce a truthful bounded result fails
317
323
  explicitly.
318
324
 
319
- The bundled `exec` plugin is intentionally a Linux-only integration with
320
- non-interactive `/bin/bash`. Its configured working directory is constrained to
325
+ The bundled `exec` plugin supports Linux and macOS with non-interactive
326
+ `/bin/bash`. Commands must use utilities available on the host. Its configured working directory is constrained to
321
327
  agent-work or workspace roots, but the shell runs with the Runtime process
322
328
  permissions and is not an operating-system sandbox.
323
329
 
package/package.json CHANGED
@@ -174,5 +174,5 @@
174
174
  },
175
175
  "type": "module",
176
176
  "types": "./dist/src/runtime/index.d.ts",
177
- "version": "1.3.0"
177
+ "version": "1.3.1"
178
178
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "exec",
4
4
  "version": "1.0.0",
5
- "description": "Execute, observe, and cancel bounded non-interactive Linux /bin/bash processes.",
5
+ "description": "Execute, observe, and cancel bounded non-interactive /bin/bash processes on Linux and macOS.",
6
6
  "extensions": {
7
7
  "ai.abot.runtime": {
8
8
  "version": 1,
@@ -19,7 +19,7 @@
19
19
  "capabilities": {
20
20
  "exec": {
21
21
  "catalogGroups": ["read", "write", "exec"],
22
- "description": "Execute one bounded non-interactive Linux /bin/bash command. The explicit cwd must resolve inside agent work or the configured workspace; `.` means the agent-work root and `workspace/...` selects the workspace. The shell itself runs with the runtime process permissions and is not an operating-system sandbox. A command still running after the foreground window yields a process ID and cursor that exec_wait can resume without restarting it. Prefer dedicated inspection and file-mutation capabilities when they can express the task.",
22
+ "description": "Execute one bounded non-interactive /bin/bash command on Linux or macOS. The explicit cwd must resolve inside agent work or the configured workspace; `.` means the agent-work root and `workspace/...` selects the workspace. The shell itself runs with the runtime process permissions and is not an operating-system sandbox. A command still running after the foreground window yields a process ID and cursor that exec_wait can resume without restarting it. Prefer dedicated inspection and file-mutation capabilities when they can express the task.",
23
23
  "routingCapability": "filesystem_inspection",
24
24
  "developmentRoles": ["inspect", "establish", "verify", "auxiliary"],
25
25
  "eventPresentation": {
@@ -31,7 +31,7 @@
31
31
  "skills": ["exec_skill"],
32
32
  "operations": {
33
33
  "execute_command": {
34
- "summary": "Execute one bounded non-interactive Linux /bin/bash command in an explicit existing agent-work or workspace directory. The cwd boundary is not an OS sandbox. Full-access availability does not authorize servers, GUI automation, installs, service changes, background work, or other sensitive effects.",
34
+ "summary": "Execute one bounded non-interactive /bin/bash command on Linux or macOS in an explicit existing agent-work or workspace directory. The cwd boundary is not an OS sandbox. Full-access availability does not authorize servers, GUI automation, installs, service changes, background work, or other sensitive effects.",
35
35
  "input": {
36
36
  "type": "object",
37
37
  "additionalProperties": false,
@@ -1,7 +1,7 @@
1
1
  # Exec Skill
2
2
 
3
3
  - Use `exec` for narrow shell-native discovery or verification when a dedicated tool is insufficient.
4
- - `exec` v1 is available only on Linux with executable `/bin/bash`; it does not select a Windows shell or another fallback shell.
4
+ - `exec` is available on Linux and macOS with executable `/bin/bash`; it does not select a Windows shell or another fallback shell. Commands must use the utilities and options available on the host.
5
5
  - Resolve `.` and ordinary relative `cwd` values from the configured agent work directory. They are never rewritten to the active Worker working directory. Use `workspace/...` only when the configured workspace is explicitly intended.
6
6
  - Always provide an explicit existing `cwd`. For an existing project, use its project path. To create a new project, use an existing parent `cwd` (normally `.`) and create the project path in the command.
7
7
  - The cwd is restricted to configured roots, but the shell runs with the runtime process permissions and is not an operating-system sandbox.
@@ -1,6 +1,4 @@
1
1
  import { spawn, type ChildProcessByStdio } from "node:child_process";
2
- import { constants } from "node:fs";
3
- import { access } from "node:fs/promises";
4
2
  import { randomUUID } from "node:crypto";
5
3
  import type { Readable } from "node:stream";
6
4
  import { StringDecoder } from "node:string_decoder";
@@ -8,13 +6,13 @@ import { StringDecoder } from "node:string_decoder";
8
6
  import { sanitizeJsonText } from "../../../src/plugin-sdk/index.js";
9
7
 
10
8
  import { ExecPluginError } from "./errors.js";
9
+ import { assertSupportedShell, EXEC_SHELL } from "./shell-platform.js";
11
10
  import type {
12
11
  ExecProcessSnapshot,
13
12
  ExecStreamSnapshot,
14
13
  ExecTerminationReason,
15
14
  } from "./types.js";
16
15
 
17
- const EXEC_SHELL = "/bin/bash";
18
16
  const COMPLETED_PROCESS_RETENTION_MS = 5 * 60_000;
19
17
  const MAX_ACTIVE_PROCESSES_PER_SCOPE = 4;
20
18
 
@@ -115,23 +113,6 @@ export type ExecProcessManager = Readonly<{
115
113
  release(processId: string, scope: string): Promise<void>;
116
114
  }>;
117
115
 
118
- async function assertSupportedShell(): Promise<void> {
119
- if (process.platform !== "linux") {
120
- throw new ExecPluginError(
121
- "exec_platform_unsupported",
122
- "The exec plugin v1 requires Linux and /bin/bash.",
123
- );
124
- }
125
- try {
126
- await access(EXEC_SHELL, constants.X_OK);
127
- } catch {
128
- throw new ExecPluginError(
129
- "exec_shell_unavailable",
130
- "The exec plugin cannot start because executable /bin/bash is unavailable.",
131
- );
132
- }
133
- }
134
-
135
116
  export function createExecProcessManager(): ExecProcessManager {
136
117
  const processes = new Map<string, ManagedExecProcess>();
137
118
 
@@ -0,0 +1,29 @@
1
+ import { constants } from "node:fs";
2
+ import { access } from "node:fs/promises";
3
+
4
+ import { ExecPluginError } from "./errors.js";
5
+
6
+ export const EXEC_SHELL = "/bin/bash";
7
+
8
+ function isSupportedExecPlatform(platform: NodeJS.Platform): boolean {
9
+ return platform === "linux" || platform === "darwin";
10
+ }
11
+
12
+ export async function assertSupportedShell(
13
+ platform: NodeJS.Platform = process.platform,
14
+ ): Promise<void> {
15
+ if (!isSupportedExecPlatform(platform)) {
16
+ throw new ExecPluginError(
17
+ "exec_platform_unsupported",
18
+ "The exec plugin requires Linux or macOS and executable /bin/bash.",
19
+ );
20
+ }
21
+ try {
22
+ await access(EXEC_SHELL, constants.X_OK);
23
+ } catch {
24
+ throw new ExecPluginError(
25
+ "exec_shell_unavailable",
26
+ "The exec plugin cannot start because executable /bin/bash is unavailable.",
27
+ );
28
+ }
29
+ }