machine-bridge-mcp 1.1.5 → 1.2.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/CHANGELOG.md +21 -0
- package/CODE_OF_CONDUCT.md +24 -0
- package/CONTRIBUTING.md +3 -1
- package/GOVERNANCE.md +50 -0
- package/README.md +26 -0
- package/SUPPORT.md +31 -0
- package/browser-extension/browser-operations.js +10 -4
- package/browser-extension/devtools-input.js +2 -2
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +1 -1
- package/docs/ARCHITECTURE.md +14 -17
- package/docs/AUDIT.md +28 -0
- package/docs/ENGINEERING.md +8 -2
- package/docs/PROJECT_STANDARDS.md +4 -2
- package/docs/TESTING.md +9 -3
- package/docs/UPGRADING.md +32 -0
- package/package.json +12 -4
- package/scripts/coverage-check.mjs +27 -10
- package/src/local/account-access.mjs +7 -5
- package/src/local/agent-context.mjs +7 -148
- package/src/local/agent-contract.mjs +206 -0
- package/src/local/browser-bridge.mjs +30 -281
- package/src/local/browser-extension-protocol.mjs +19 -4
- package/src/local/browser-operation-service.mjs +325 -0
- package/src/local/call-registry.mjs +44 -1
- package/src/local/capability-ranking.mjs +13 -0
- package/src/local/cli-local-admin.mjs +74 -38
- package/src/local/cli-options.mjs +18 -18
- package/src/local/cli-policy.mjs +1 -1
- package/src/local/cli-service.mjs +132 -0
- package/src/local/cli.mjs +27 -109
- package/src/local/daemon-process.mjs +1 -1
- package/src/local/full-access-test.mjs +1 -1
- package/src/local/job-runner.mjs +9 -3
- package/src/local/managed-job-plan.mjs +3 -3
- package/src/local/monotonic-deadline.mjs +7 -0
- package/src/local/numbers.mjs +8 -0
- package/src/local/policy.mjs +97 -16
- package/src/local/project-metadata.mjs +7 -1
- package/src/local/project-package.mjs +1 -1
- package/src/local/records.mjs +6 -0
- package/src/local/resource-operations.mjs +1 -1
- package/src/local/runtime-capabilities.mjs +66 -0
- package/src/local/runtime-diagnostics.mjs +91 -0
- package/src/local/runtime-reporting.mjs +113 -0
- package/src/local/runtime.mjs +54 -191
- package/src/local/service-convergence.mjs +1 -1
- package/src/local/service.mjs +1 -1
- package/src/local/state.mjs +1 -1
- package/src/local/windows-service.mjs +1 -1
- package/src/local/worker-deployment.mjs +15 -10
- package/src/local/worker-health.mjs +8 -4
- package/src/worker/access.ts +8 -7
- package/src/worker/account-admin.ts +2 -2
- package/src/worker/authority.ts +3 -3
- package/src/worker/http.ts +2 -2
- package/src/worker/index.ts +29 -337
- package/src/worker/oauth-controller.ts +343 -0
- package/src/worker/oauth-state.ts +1 -1
- package/src/worker/oauth-tokens.ts +2 -2
- package/src/worker/tool-catalog.ts +1 -1
- package/tsconfig.json +2 -1
- package/tsconfig.local.json +27 -0
|
@@ -9,16 +9,24 @@ const coverageDir = mkdtempSync(resolve(tmpdir(), "machine-bridge-coverage-"));
|
|
|
9
9
|
const tests = [
|
|
10
10
|
"tests/policy-test.mjs",
|
|
11
11
|
"tests/runtime-infrastructure-test.mjs",
|
|
12
|
+
"tests/runtime-boundaries-test.mjs",
|
|
12
13
|
"tests/worker-runtime-infrastructure-test.mjs",
|
|
14
|
+
"tests/worker-oauth-controller-test.mjs",
|
|
13
15
|
"tests/logging-structure-test.mjs",
|
|
14
16
|
"tests/runtime-handler-matrix-test.mjs",
|
|
15
17
|
"tests/cli-entrypoint-test.mjs",
|
|
18
|
+
"tests/cli-service-test.mjs",
|
|
16
19
|
"tests/local-self-test.mjs",
|
|
20
|
+
"tests/runtime-self-test.mjs",
|
|
17
21
|
"tests/numbers-test.mjs",
|
|
18
22
|
"tests/records-test.mjs",
|
|
19
23
|
"tests/project-metadata-test.mjs",
|
|
20
24
|
"tests/state-inventory-test.mjs",
|
|
21
25
|
"tests/worker-deployment-test.mjs",
|
|
26
|
+
"tests/agent-context-test.mjs",
|
|
27
|
+
"tests/capability-ranking-test.mjs",
|
|
28
|
+
"tests/browser-bridge-test.mjs",
|
|
29
|
+
"tests/monotonic-deadline-test.mjs",
|
|
22
30
|
];
|
|
23
31
|
|
|
24
32
|
try {
|
|
@@ -38,27 +46,36 @@ try {
|
|
|
38
46
|
|
|
39
47
|
const coverage = collectCoverage(coverageDir);
|
|
40
48
|
const thresholds = {
|
|
41
|
-
"src/local/policy.mjs": [
|
|
49
|
+
"src/local/policy.mjs": [90, 65],
|
|
42
50
|
"src/local/errors.mjs": [70, 50],
|
|
43
|
-
"src/local/call-registry.mjs": [
|
|
44
|
-
"src/local/tool-executor.mjs": [90,
|
|
45
|
-
"src/local/observability.mjs": [
|
|
51
|
+
"src/local/call-registry.mjs": [85, 55],
|
|
52
|
+
"src/local/tool-executor.mjs": [90, 40],
|
|
53
|
+
"src/local/observability.mjs": [95, 40],
|
|
46
54
|
"src/local/process-tracker.mjs": [65, 35],
|
|
47
55
|
"src/local/log.mjs": [60, 40],
|
|
48
|
-
"src/local/runtime.mjs": [
|
|
49
|
-
"src/local/cli.mjs": [
|
|
56
|
+
"src/local/runtime.mjs": [75, 55],
|
|
57
|
+
"src/local/cli.mjs": [48, 21.9],
|
|
58
|
+
"src/local/cli-service.mjs": [90, 65],
|
|
50
59
|
"src/local/cli-options.mjs": [65, 35],
|
|
51
60
|
"src/local/cli-policy.mjs": [70, 35],
|
|
52
61
|
"src/local/numbers.mjs": [100, 100],
|
|
53
62
|
"src/local/records.mjs": [100, 100],
|
|
54
|
-
"src/local/project-metadata.mjs": [
|
|
63
|
+
"src/local/project-metadata.mjs": [95, 55],
|
|
55
64
|
"src/local/state-inventory.mjs": [85, 55],
|
|
56
65
|
"src/local/network-proxy.mjs": [90, 65],
|
|
57
66
|
"src/local/worker-health.mjs": [85, 60],
|
|
58
67
|
"src/local/worker-deployment.mjs": [80, 55],
|
|
59
|
-
"src/
|
|
60
|
-
"src/
|
|
61
|
-
"src/
|
|
68
|
+
"src/local/agent-contract.mjs": [95, 40],
|
|
69
|
+
"src/local/capability-ranking.mjs": [95, 70],
|
|
70
|
+
"src/local/browser-extension-protocol.mjs": [95, 35],
|
|
71
|
+
"src/local/browser-operation-service.mjs": [80, 50],
|
|
72
|
+
"src/local/runtime-reporting.mjs": [95, 75],
|
|
73
|
+
"src/local/runtime-diagnostics.mjs": [75, 65],
|
|
74
|
+
"src/local/runtime-capabilities.mjs": [75, 45],
|
|
75
|
+
"src/local/monotonic-deadline.mjs": [100, 100],
|
|
76
|
+
"src/worker/pending-calls.ts": [90, 35],
|
|
77
|
+
"src/worker/policy.ts": [100, 25],
|
|
78
|
+
"src/worker/errors.ts": [100, 40],
|
|
62
79
|
};
|
|
63
80
|
const failures = [];
|
|
64
81
|
for (const [file, [minimumFunctions, minimumBlocks]] of Object.entries(thresholds)) {
|
|
@@ -3,20 +3,22 @@ import { BridgeError } from "./errors.mjs";
|
|
|
3
3
|
import { policyProfile, toolNamesForPolicy, assertToolAllowed } from "./policy.mjs";
|
|
4
4
|
|
|
5
5
|
export const ACCOUNT_ACCESS_REVISION = Number(accessContract.revision);
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
)
|
|
6
|
+
const ACCOUNT_ROLE_ENTRIES = Object.entries(accessContract.roles)
|
|
7
|
+
.map(([name, value]) => [name, Object.freeze({ ...value })]);
|
|
8
|
+
const ACCOUNT_ROLE_BY_NAME = new Map(ACCOUNT_ROLE_ENTRIES);
|
|
9
|
+
export const ACCOUNT_ROLES = Object.freeze(Object.fromEntries(ACCOUNT_ROLE_ENTRIES));
|
|
10
|
+
export const DEFAULT_ACCOUNT_ROLE = String(accessContract.defaultRole);
|
|
9
11
|
export const OWNER_ACCOUNT_ROLE = String(accessContract.ownerRole);
|
|
10
12
|
|
|
11
13
|
export function normalizeAccountRole(value) {
|
|
12
14
|
const role = String(value || "").trim().toLowerCase();
|
|
13
|
-
if (!
|
|
15
|
+
if (!ACCOUNT_ROLE_BY_NAME.has(role)) throw new BridgeError("invalid_request", `unknown account role: ${role}`);
|
|
14
16
|
return role;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export function accountRolePolicy(role) {
|
|
18
20
|
const normalized = normalizeAccountRole(role);
|
|
19
|
-
return policyProfile(
|
|
21
|
+
return policyProfile(ACCOUNT_ROLE_BY_NAME.get(normalized).profile, "explicit");
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export function accountRoleToolNames(role) {
|
|
@@ -2,11 +2,15 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { commandMatchText, recommendTools, relevanceScore } from "./capability-ranking.mjs";
|
|
3
3
|
import { constants as fsConstants } from "node:fs";
|
|
4
4
|
import { lstat, open, opendir, realpath, stat } from "node:fs/promises";
|
|
5
|
-
import { dirname,
|
|
5
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { createBuiltinInstruction, discoverAutomaticProjectInstruction } from "./default-instructions.mjs";
|
|
7
7
|
import { automaticPackageCommands, readProjectPackageMetadata } from "./project-package.mjs";
|
|
8
8
|
import { clampInteger } from "./numbers.mjs";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
MAX_COMMAND_ARGUMENT_BYTES, MAX_COMMANDS, MAX_SKILL_ROOTS,
|
|
11
|
+
assertAllowedPath, assertContainedPath, isContainedPath, normalizeAgentConfig, requiredString,
|
|
12
|
+
resolveConfiguredPath, resolveInstructionPath, validateStringArray,
|
|
13
|
+
} from "./agent-contract.mjs";
|
|
10
14
|
|
|
11
15
|
const CONFIG_RELATIVE_PATH = join(".machine-bridge", "agent.json");
|
|
12
16
|
const GLOBAL_CONFIG_RELATIVE_PATH = join(".config", "machine-bridge-mcp", "agent.json");
|
|
@@ -15,20 +19,12 @@ const DEFAULT_INSTRUCTION_MAX_BYTES = 32 * 1024;
|
|
|
15
19
|
const MAX_CONTEXT_SKILL_SUMMARY_CHARS = 8_000;
|
|
16
20
|
const MAX_CONFIG_BYTES = 256 * 1024;
|
|
17
21
|
const MAX_INSTRUCTION_FILE_BYTES = 512 * 1024;
|
|
18
|
-
const MAX_TOTAL_INSTRUCTION_BYTES = 2 * 1024 * 1024;
|
|
19
22
|
const MAX_INSTRUCTION_FILES = 64;
|
|
20
23
|
const MAX_SKILL_ENTRY_BYTES = 512 * 1024;
|
|
21
|
-
const MAX_SKILL_ROOTS = 32;
|
|
22
24
|
const MAX_SKILL_RESULTS = 500;
|
|
23
25
|
const MAX_SKILL_SCAN_ENTRIES = 20_000;
|
|
24
26
|
const MAX_SKILL_SCAN_DEPTH = 8;
|
|
25
27
|
const MAX_SKILL_FILES = 500;
|
|
26
|
-
const MAX_COMMANDS = 128;
|
|
27
|
-
const MAX_COMMAND_ARGV = 128;
|
|
28
|
-
const MAX_COMMAND_ARGUMENT_BYTES = 256 * 1024;
|
|
29
|
-
const COMMAND_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
30
|
-
const CONFIG_KEYS = new Set(["version", "builtin_instructions", "automatic_project_context", "model_instructions_file", "instruction_files", "instruction_max_bytes", "skill_roots", "commands"]);
|
|
31
|
-
const COMMAND_KEYS = new Set(["description", "argv", "cwd", "timeout_seconds", "allow_extra_args"]);
|
|
32
28
|
|
|
33
29
|
export class AgentContextManager {
|
|
34
30
|
constructor({ workspace, policy, displayPath, resolveExistingPath, throwIfCancelled = () => {}, home = process.env.HOME || process.env.USERPROFILE || "", codexHome = process.env.CODEX_HOME || "" }) {
|
|
@@ -84,7 +80,6 @@ export class AgentContextManager {
|
|
|
84
80
|
return result;
|
|
85
81
|
}
|
|
86
82
|
|
|
87
|
-
|
|
88
83
|
async sessionBootstrap(args = {}, context = {}) {
|
|
89
84
|
const state = await this.discoverState(args.path || ".", context);
|
|
90
85
|
const fingerprint = capabilityFingerprint(state, []);
|
|
@@ -522,109 +517,7 @@ async function readOptionalConfig(configPath, allowedRoot, rejectPathAliases) {
|
|
|
522
517
|
} catch {
|
|
523
518
|
throw new Error(`agent config is not valid JSON: ${configPath}`);
|
|
524
519
|
}
|
|
525
|
-
return
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
function normalizeConfig(value, configPath) {
|
|
529
|
-
if (!isPlainRecord(value)) throw new Error(`agent config must be a JSON object: ${configPath}`);
|
|
530
|
-
for (const key of Object.keys(value)) if (!CONFIG_KEYS.has(key)) throw new Error(`unknown agent config field '${key}': ${configPath}`);
|
|
531
|
-
if (value.version !== 1) throw new Error(`agent config version must be 1: ${configPath}`);
|
|
532
|
-
const result = {
|
|
533
|
-
builtinInstructions: null,
|
|
534
|
-
automaticProjectContext: null,
|
|
535
|
-
modelInstructionsFile: null,
|
|
536
|
-
instructionFiles: null,
|
|
537
|
-
instructionMaxBytes: null,
|
|
538
|
-
skillRoots: null,
|
|
539
|
-
commands: new Map(),
|
|
540
|
-
};
|
|
541
|
-
if (value.builtin_instructions !== undefined) {
|
|
542
|
-
if (typeof value.builtin_instructions !== "boolean") throw new Error(`builtin_instructions must be boolean: ${configPath}`);
|
|
543
|
-
result.builtinInstructions = value.builtin_instructions;
|
|
544
|
-
}
|
|
545
|
-
if (value.automatic_project_context !== undefined) {
|
|
546
|
-
if (typeof value.automatic_project_context !== "boolean") throw new Error(`automatic_project_context must be boolean: ${configPath}`);
|
|
547
|
-
result.automaticProjectContext = value.automatic_project_context;
|
|
548
|
-
}
|
|
549
|
-
if (value.model_instructions_file !== undefined) {
|
|
550
|
-
result.modelInstructionsFile = requiredString(value.model_instructions_file, "model_instructions_file");
|
|
551
|
-
}
|
|
552
|
-
if (value.instruction_files !== undefined) {
|
|
553
|
-
result.instructionFiles = validateInstructionFiles(value.instruction_files, configPath);
|
|
554
|
-
}
|
|
555
|
-
if (value.instruction_max_bytes !== undefined) {
|
|
556
|
-
if (!Number.isInteger(value.instruction_max_bytes) || value.instruction_max_bytes < 1024 || value.instruction_max_bytes > MAX_TOTAL_INSTRUCTION_BYTES) {
|
|
557
|
-
throw new Error(`instruction_max_bytes must be an integer from 1024 to ${MAX_TOTAL_INSTRUCTION_BYTES}: ${configPath}`);
|
|
558
|
-
}
|
|
559
|
-
result.instructionMaxBytes = value.instruction_max_bytes;
|
|
560
|
-
}
|
|
561
|
-
if (value.skill_roots !== undefined) {
|
|
562
|
-
result.skillRoots = validateStringArray(value.skill_roots, "skill_roots", MAX_SKILL_ROOTS, MAX_COMMAND_ARGUMENT_BYTES);
|
|
563
|
-
}
|
|
564
|
-
if (value.commands !== undefined) {
|
|
565
|
-
if (!isPlainRecord(value.commands)) throw new Error(`agent config commands must be an object: ${configPath}`);
|
|
566
|
-
for (const [name, definition] of Object.entries(value.commands)) {
|
|
567
|
-
if (!COMMAND_NAME_PATTERN.test(name)) throw new Error(`invalid registered command name '${name}': ${configPath}`);
|
|
568
|
-
if (definition === null) {
|
|
569
|
-
result.commands.set(name, null);
|
|
570
|
-
continue;
|
|
571
|
-
}
|
|
572
|
-
result.commands.set(name, normalizeCommand(definition, name, configPath));
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
return result;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
function normalizeCommand(value, name, configPath) {
|
|
579
|
-
if (!isPlainRecord(value)) throw new Error(`registered command '${name}' must be an object: ${configPath}`);
|
|
580
|
-
for (const key of Object.keys(value)) if (!COMMAND_KEYS.has(key)) throw new Error(`unknown field '${key}' for registered command '${name}': ${configPath}`);
|
|
581
|
-
const argv = validateStringArray(value.argv, `commands.${name}.argv`, MAX_COMMAND_ARGV, MAX_COMMAND_ARGUMENT_BYTES);
|
|
582
|
-
if (!argv.length) throw new Error(`registered command '${name}' requires a non-empty argv: ${configPath}`);
|
|
583
|
-
const description = typeof value.description === "string" ? value.description.trim() : "";
|
|
584
|
-
if (!description || description.length > 1000) throw new Error(`registered command '${name}' requires a description of at most 1000 characters: ${configPath}`);
|
|
585
|
-
const cwd = value.cwd === undefined ? "." : requiredString(value.cwd, `commands.${name}.cwd`);
|
|
586
|
-
const timeoutSeconds = clampInteger(value.timeout_seconds, 120, 1, 600);
|
|
587
|
-
if (value.timeout_seconds !== undefined && timeoutSeconds !== value.timeout_seconds) {
|
|
588
|
-
throw new Error(`registered command '${name}' timeout_seconds must be an integer from 1 to 600: ${configPath}`);
|
|
589
|
-
}
|
|
590
|
-
if (value.allow_extra_args !== undefined && typeof value.allow_extra_args !== "boolean") {
|
|
591
|
-
throw new Error(`registered command '${name}' allow_extra_args must be boolean: ${configPath}`);
|
|
592
|
-
}
|
|
593
|
-
return {
|
|
594
|
-
description,
|
|
595
|
-
argv,
|
|
596
|
-
cwd,
|
|
597
|
-
timeoutSeconds,
|
|
598
|
-
allowExtraArgs: value.allow_extra_args === true,
|
|
599
|
-
};
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
function validateInstructionFiles(value, configPath) {
|
|
603
|
-
const files = validateStringArray(value, "instruction_files", 32, 64 * 1024);
|
|
604
|
-
if (!files.length) throw new Error(`instruction_files must not be empty: ${configPath}`);
|
|
605
|
-
for (const name of files) resolveInstructionPath("/", name);
|
|
606
|
-
return files;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
function resolveInstructionPath(directory, configuredName) {
|
|
610
|
-
const raw = requiredString(configuredName, "instruction file name");
|
|
611
|
-
if (raw.includes("\0") || isAbsolute(raw)) throw new Error(`instruction file path must be relative: ${raw}`);
|
|
612
|
-
const candidate = resolve(directory, raw);
|
|
613
|
-
assertContainedPath(resolve(directory), candidate, "instruction file path");
|
|
614
|
-
return candidate;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
function resolveConfiguredPath(configuredPath, baseDir, home, workspace, unrestricted) {
|
|
618
|
-
const raw = requiredString(configuredPath, "configured path");
|
|
619
|
-
if (raw.includes("\0")) throw new Error("configured path contains a NUL byte");
|
|
620
|
-
let expanded = raw;
|
|
621
|
-
if (raw === "~" || raw.startsWith(`~${sep}`) || raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
622
|
-
if (!home) throw new Error("HOME or USERPROFILE is required to expand '~'");
|
|
623
|
-
expanded = raw === "~" ? home : join(home, raw.slice(2));
|
|
624
|
-
}
|
|
625
|
-
const candidate = isAbsolute(expanded) ? resolve(expanded) : resolve(baseDir, expanded);
|
|
626
|
-
assertAllowedPath(candidate, workspace, unrestricted, "configured path");
|
|
627
|
-
return candidate;
|
|
520
|
+
return normalizeAgentConfig(parsed, configPath);
|
|
628
521
|
}
|
|
629
522
|
|
|
630
523
|
async function findSkillEntrypoint(directory) {
|
|
@@ -680,7 +573,6 @@ export function parseSkillMetadata(content) {
|
|
|
680
573
|
return metadata;
|
|
681
574
|
}
|
|
682
575
|
|
|
683
|
-
|
|
684
576
|
async function listSkillFiles(root, maxFiles, context, throwIfCancelled) {
|
|
685
577
|
const files = [];
|
|
686
578
|
const stack = [{ directory: root, depth: 0 }];
|
|
@@ -853,42 +745,10 @@ async function readRegularUtf8(filePath, maxBytes, label) {
|
|
|
853
745
|
}
|
|
854
746
|
}
|
|
855
747
|
|
|
856
|
-
function validateStringArray(value, label, maxItems, maxBytes) {
|
|
857
|
-
if (!Array.isArray(value)) throw new Error(`${label} must be an array of strings`);
|
|
858
|
-
if (value.length > maxItems) throw new Error(`${label} contains more than ${maxItems} items`);
|
|
859
|
-
let bytes = 0;
|
|
860
|
-
return value.map((item, index) => {
|
|
861
|
-
if (typeof item !== "string" || !item.length || item.includes("\0")) throw new Error(`${label}[${index}] must be a non-empty string without NUL bytes`);
|
|
862
|
-
bytes += Buffer.byteLength(item);
|
|
863
|
-
if (bytes > maxBytes) throw new Error(`${label} exceeds maximum encoded size (${maxBytes} bytes)`);
|
|
864
|
-
return item;
|
|
865
|
-
});
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
function assertAllowedPath(candidate, workspace, unrestricted, label) {
|
|
869
|
-
if (!unrestricted) assertContainedPath(workspace, candidate, label);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
function assertContainedPath(root, target, label) {
|
|
873
|
-
if (isContainedPath(root, target)) return;
|
|
874
|
-
throw new Error(`${label} is outside the configured workspace`);
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
function isContainedPath(root, target) {
|
|
878
|
-
const rel = relative(resolve(root), resolve(target));
|
|
879
|
-
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
|
|
883
748
|
function boundedMessage(error) {
|
|
884
749
|
return String(error?.message || error || "invalid local skill").replace(/[\r\n]+/g, " ").slice(0, 1000);
|
|
885
750
|
}
|
|
886
751
|
|
|
887
|
-
function requiredString(value, label) {
|
|
888
|
-
if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required`);
|
|
889
|
-
return value.trim();
|
|
890
|
-
}
|
|
891
|
-
|
|
892
752
|
function unquoteScalar(value) {
|
|
893
753
|
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
894
754
|
return value.slice(1, -1);
|
|
@@ -896,7 +756,6 @@ function unquoteScalar(value) {
|
|
|
896
756
|
return value;
|
|
897
757
|
}
|
|
898
758
|
|
|
899
|
-
|
|
900
759
|
function sha256(value) {
|
|
901
760
|
return createHash("sha256").update(String(value)).digest("hex");
|
|
902
761
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { clampInteger } from "./numbers.mjs";
|
|
5
|
+
import { isPlainRecord } from "./records.mjs";
|
|
6
|
+
|
|
7
|
+
export const MAX_TOTAL_INSTRUCTION_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
export const MAX_SKILL_ROOTS = 32;
|
|
9
|
+
export const MAX_COMMANDS = 128;
|
|
10
|
+
export const MAX_COMMAND_ARGV = 128;
|
|
11
|
+
export const MAX_COMMAND_ARGUMENT_BYTES = 256 * 1024;
|
|
12
|
+
|
|
13
|
+
const COMMAND_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
14
|
+
const CONFIG_KEYS = new Set([
|
|
15
|
+
"version", "builtin_instructions", "automatic_project_context", "model_instructions_file",
|
|
16
|
+
"instruction_files", "instruction_max_bytes", "skill_roots", "commands",
|
|
17
|
+
]);
|
|
18
|
+
const COMMAND_KEYS = new Set(["description", "argv", "cwd", "timeout_seconds", "allow_extra_args"]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {{
|
|
22
|
+
* description: string,
|
|
23
|
+
* argv: string[],
|
|
24
|
+
* cwd: string,
|
|
25
|
+
* timeoutSeconds: number,
|
|
26
|
+
* allowExtraArgs: boolean,
|
|
27
|
+
* }} NormalizedCommand
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {{
|
|
32
|
+
* builtinInstructions: boolean | null,
|
|
33
|
+
* automaticProjectContext: boolean | null,
|
|
34
|
+
* modelInstructionsFile: string | null,
|
|
35
|
+
* instructionFiles: string[] | null,
|
|
36
|
+
* instructionMaxBytes: number | null,
|
|
37
|
+
* skillRoots: string[] | null,
|
|
38
|
+
* commands: Map<string, NormalizedCommand | null>,
|
|
39
|
+
* }} NormalizedAgentConfig
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {unknown} value
|
|
44
|
+
* @param {string} configPath
|
|
45
|
+
* @returns {NormalizedAgentConfig}
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeAgentConfig(value, configPath) {
|
|
48
|
+
if (!isPlainRecord(value)) throw new Error(`agent config must be a JSON object: ${configPath}`);
|
|
49
|
+
for (const key of Object.keys(value)) {
|
|
50
|
+
if (!CONFIG_KEYS.has(key)) throw new Error(`unknown agent config field '${key}': ${configPath}`);
|
|
51
|
+
}
|
|
52
|
+
if (value.version !== 1) throw new Error(`agent config version must be 1: ${configPath}`);
|
|
53
|
+
/** @type {NormalizedAgentConfig} */
|
|
54
|
+
const result = {
|
|
55
|
+
builtinInstructions: null,
|
|
56
|
+
automaticProjectContext: null,
|
|
57
|
+
modelInstructionsFile: null,
|
|
58
|
+
instructionFiles: null,
|
|
59
|
+
instructionMaxBytes: null,
|
|
60
|
+
skillRoots: null,
|
|
61
|
+
commands: new Map(),
|
|
62
|
+
};
|
|
63
|
+
if (value.builtin_instructions !== undefined) {
|
|
64
|
+
if (typeof value.builtin_instructions !== "boolean") throw new Error(`builtin_instructions must be boolean: ${configPath}`);
|
|
65
|
+
result.builtinInstructions = value.builtin_instructions;
|
|
66
|
+
}
|
|
67
|
+
if (value.automatic_project_context !== undefined) {
|
|
68
|
+
if (typeof value.automatic_project_context !== "boolean") throw new Error(`automatic_project_context must be boolean: ${configPath}`);
|
|
69
|
+
result.automaticProjectContext = value.automatic_project_context;
|
|
70
|
+
}
|
|
71
|
+
if (value.model_instructions_file !== undefined) {
|
|
72
|
+
result.modelInstructionsFile = requiredString(value.model_instructions_file, "model_instructions_file");
|
|
73
|
+
}
|
|
74
|
+
if (value.instruction_files !== undefined) {
|
|
75
|
+
result.instructionFiles = validateInstructionFiles(value.instruction_files, configPath);
|
|
76
|
+
}
|
|
77
|
+
if (value.instruction_max_bytes !== undefined) {
|
|
78
|
+
const maximum = value.instruction_max_bytes;
|
|
79
|
+
if (typeof maximum !== "number" || !Number.isInteger(maximum)
|
|
80
|
+
|| maximum < 1024 || maximum > MAX_TOTAL_INSTRUCTION_BYTES) {
|
|
81
|
+
throw new Error(`instruction_max_bytes must be an integer from 1024 to ${MAX_TOTAL_INSTRUCTION_BYTES}: ${configPath}`);
|
|
82
|
+
}
|
|
83
|
+
result.instructionMaxBytes = maximum;
|
|
84
|
+
}
|
|
85
|
+
if (value.skill_roots !== undefined) {
|
|
86
|
+
result.skillRoots = validateStringArray(value.skill_roots, "skill_roots", MAX_SKILL_ROOTS, MAX_COMMAND_ARGUMENT_BYTES);
|
|
87
|
+
}
|
|
88
|
+
if (value.commands !== undefined) {
|
|
89
|
+
if (!isPlainRecord(value.commands)) throw new Error(`agent config commands must be an object: ${configPath}`);
|
|
90
|
+
for (const [name, definition] of Object.entries(value.commands)) {
|
|
91
|
+
if (!COMMAND_NAME_PATTERN.test(name)) throw new Error(`invalid registered command name '${name}': ${configPath}`);
|
|
92
|
+
result.commands.set(name, definition === null ? null : normalizeCommand(definition, name, configPath));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {string} directory @param {unknown} configuredName */
|
|
99
|
+
export function resolveInstructionPath(directory, configuredName) {
|
|
100
|
+
const raw = requiredString(configuredName, "instruction file name");
|
|
101
|
+
if (raw.includes("\0") || isAbsolute(raw)) throw new Error(`instruction file path must be relative: ${raw}`);
|
|
102
|
+
const candidate = resolve(directory, raw);
|
|
103
|
+
assertContainedPath(resolve(directory), candidate, "instruction file path");
|
|
104
|
+
return candidate;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {unknown} configuredPath
|
|
109
|
+
* @param {string} baseDir
|
|
110
|
+
* @param {string} home
|
|
111
|
+
* @param {string} workspace
|
|
112
|
+
* @param {boolean} unrestricted
|
|
113
|
+
*/
|
|
114
|
+
export function resolveConfiguredPath(configuredPath, baseDir, home, workspace, unrestricted) {
|
|
115
|
+
const raw = requiredString(configuredPath, "configured path");
|
|
116
|
+
if (raw.includes("\0")) throw new Error("configured path contains a NUL byte");
|
|
117
|
+
let expanded = raw;
|
|
118
|
+
if (raw === "~" || raw.startsWith(`~${sep}`) || raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
119
|
+
if (!home) throw new Error("HOME or USERPROFILE is required to expand '~'");
|
|
120
|
+
expanded = raw === "~" ? home : join(home, raw.slice(2));
|
|
121
|
+
}
|
|
122
|
+
const candidate = isAbsolute(expanded) ? resolve(expanded) : resolve(baseDir, expanded);
|
|
123
|
+
assertAllowedPath(candidate, workspace, unrestricted, "configured path");
|
|
124
|
+
return candidate;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {unknown} value
|
|
129
|
+
* @param {string} label
|
|
130
|
+
* @param {number} maxItems
|
|
131
|
+
* @param {number} maxBytes
|
|
132
|
+
* @returns {string[]}
|
|
133
|
+
*/
|
|
134
|
+
export function validateStringArray(value, label, maxItems, maxBytes) {
|
|
135
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array of strings`);
|
|
136
|
+
if (value.length > maxItems) throw new Error(`${label} contains more than ${maxItems} items`);
|
|
137
|
+
let bytes = 0;
|
|
138
|
+
return value.map((item, index) => {
|
|
139
|
+
if (typeof item !== "string" || !item.length || item.includes("\0")) {
|
|
140
|
+
throw new Error(`${label}[${index}] must be a non-empty string without NUL bytes`);
|
|
141
|
+
}
|
|
142
|
+
bytes += Buffer.byteLength(item);
|
|
143
|
+
if (bytes > maxBytes) throw new Error(`${label} exceeds maximum encoded size (${maxBytes} bytes)`);
|
|
144
|
+
return item;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @param {string} candidate @param {string} workspace @param {boolean} unrestricted @param {string} label */
|
|
149
|
+
export function assertAllowedPath(candidate, workspace, unrestricted, label) {
|
|
150
|
+
if (!unrestricted) assertContainedPath(workspace, candidate, label);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @param {string} root @param {string} target @param {string} label */
|
|
154
|
+
export function assertContainedPath(root, target, label) {
|
|
155
|
+
if (isContainedPath(root, target)) return;
|
|
156
|
+
throw new Error(`${label} is outside the configured workspace`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @param {string} root @param {string} target */
|
|
160
|
+
export function isContainedPath(root, target) {
|
|
161
|
+
const rel = relative(resolve(root), resolve(target));
|
|
162
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** @param {unknown} value @param {string} label */
|
|
166
|
+
export function requiredString(value, label) {
|
|
167
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required`);
|
|
168
|
+
return value.trim();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** @param {unknown} value @param {string} name @param {string} configPath @returns {NormalizedCommand} */
|
|
172
|
+
function normalizeCommand(value, name, configPath) {
|
|
173
|
+
if (!isPlainRecord(value)) throw new Error(`registered command '${name}' must be an object: ${configPath}`);
|
|
174
|
+
for (const key of Object.keys(value)) {
|
|
175
|
+
if (!COMMAND_KEYS.has(key)) throw new Error(`unknown field '${key}' for registered command '${name}': ${configPath}`);
|
|
176
|
+
}
|
|
177
|
+
const argv = validateStringArray(value.argv, `commands.${name}.argv`, MAX_COMMAND_ARGV, MAX_COMMAND_ARGUMENT_BYTES);
|
|
178
|
+
if (!argv.length) throw new Error(`registered command '${name}' requires a non-empty argv: ${configPath}`);
|
|
179
|
+
const description = typeof value.description === "string" ? value.description.trim() : "";
|
|
180
|
+
if (!description || description.length > 1000) {
|
|
181
|
+
throw new Error(`registered command '${name}' requires a description of at most 1000 characters: ${configPath}`);
|
|
182
|
+
}
|
|
183
|
+
const cwd = value.cwd === undefined ? "." : requiredString(value.cwd, `commands.${name}.cwd`);
|
|
184
|
+
const timeoutSeconds = clampInteger(value.timeout_seconds, 120, 1, 600);
|
|
185
|
+
if (value.timeout_seconds !== undefined && timeoutSeconds !== value.timeout_seconds) {
|
|
186
|
+
throw new Error(`registered command '${name}' timeout_seconds must be an integer from 1 to 600: ${configPath}`);
|
|
187
|
+
}
|
|
188
|
+
if (value.allow_extra_args !== undefined && typeof value.allow_extra_args !== "boolean") {
|
|
189
|
+
throw new Error(`registered command '${name}' allow_extra_args must be boolean: ${configPath}`);
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
description,
|
|
193
|
+
argv,
|
|
194
|
+
cwd,
|
|
195
|
+
timeoutSeconds,
|
|
196
|
+
allowExtraArgs: value.allow_extra_args === true,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** @param {unknown} value @param {string} configPath */
|
|
201
|
+
function validateInstructionFiles(value, configPath) {
|
|
202
|
+
const files = validateStringArray(value, "instruction_files", 32, 64 * 1024);
|
|
203
|
+
if (!files.length) throw new Error(`instruction_files must not be empty: ${configPath}`);
|
|
204
|
+
for (const name of files) resolveInstructionPath("/", name);
|
|
205
|
+
return files;
|
|
206
|
+
}
|