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.
Files changed (63) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/CODE_OF_CONDUCT.md +24 -0
  3. package/CONTRIBUTING.md +3 -1
  4. package/GOVERNANCE.md +50 -0
  5. package/README.md +26 -0
  6. package/SUPPORT.md +31 -0
  7. package/browser-extension/browser-operations.js +10 -4
  8. package/browser-extension/devtools-input.js +2 -2
  9. package/browser-extension/manifest.json +2 -2
  10. package/browser-extension/page-automation.js +1 -1
  11. package/docs/ARCHITECTURE.md +14 -17
  12. package/docs/AUDIT.md +28 -0
  13. package/docs/ENGINEERING.md +8 -2
  14. package/docs/PROJECT_STANDARDS.md +4 -2
  15. package/docs/TESTING.md +9 -3
  16. package/docs/UPGRADING.md +32 -0
  17. package/package.json +12 -4
  18. package/scripts/coverage-check.mjs +27 -10
  19. package/src/local/account-access.mjs +7 -5
  20. package/src/local/agent-context.mjs +7 -148
  21. package/src/local/agent-contract.mjs +206 -0
  22. package/src/local/browser-bridge.mjs +30 -281
  23. package/src/local/browser-extension-protocol.mjs +19 -4
  24. package/src/local/browser-operation-service.mjs +325 -0
  25. package/src/local/call-registry.mjs +44 -1
  26. package/src/local/capability-ranking.mjs +13 -0
  27. package/src/local/cli-local-admin.mjs +74 -38
  28. package/src/local/cli-options.mjs +18 -18
  29. package/src/local/cli-policy.mjs +1 -1
  30. package/src/local/cli-service.mjs +132 -0
  31. package/src/local/cli.mjs +27 -109
  32. package/src/local/daemon-process.mjs +1 -1
  33. package/src/local/full-access-test.mjs +1 -1
  34. package/src/local/job-runner.mjs +9 -3
  35. package/src/local/managed-job-plan.mjs +3 -3
  36. package/src/local/monotonic-deadline.mjs +7 -0
  37. package/src/local/numbers.mjs +8 -0
  38. package/src/local/policy.mjs +97 -16
  39. package/src/local/project-metadata.mjs +7 -1
  40. package/src/local/project-package.mjs +1 -1
  41. package/src/local/records.mjs +6 -0
  42. package/src/local/resource-operations.mjs +1 -1
  43. package/src/local/runtime-capabilities.mjs +66 -0
  44. package/src/local/runtime-diagnostics.mjs +91 -0
  45. package/src/local/runtime-reporting.mjs +113 -0
  46. package/src/local/runtime.mjs +54 -191
  47. package/src/local/service-convergence.mjs +1 -1
  48. package/src/local/service.mjs +1 -1
  49. package/src/local/state.mjs +1 -1
  50. package/src/local/windows-service.mjs +1 -1
  51. package/src/local/worker-deployment.mjs +15 -10
  52. package/src/local/worker-health.mjs +8 -4
  53. package/src/worker/access.ts +8 -7
  54. package/src/worker/account-admin.ts +2 -2
  55. package/src/worker/authority.ts +3 -3
  56. package/src/worker/http.ts +2 -2
  57. package/src/worker/index.ts +29 -337
  58. package/src/worker/oauth-controller.ts +343 -0
  59. package/src/worker/oauth-state.ts +1 -1
  60. package/src/worker/oauth-tokens.ts +2 -2
  61. package/src/worker/tool-catalog.ts +1 -1
  62. package/tsconfig.json +2 -1
  63. 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": [75, 45],
49
+ "src/local/policy.mjs": [90, 65],
42
50
  "src/local/errors.mjs": [70, 50],
43
- "src/local/call-registry.mjs": [80, 50],
44
- "src/local/tool-executor.mjs": [90, 25],
45
- "src/local/observability.mjs": [65, 35],
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": [60, 35],
49
- "src/local/cli.mjs": [45, 20],
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": [85, 55],
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/worker/pending-calls.ts": [85, null],
60
- "src/worker/policy.ts": [90, null],
61
- "src/worker/errors.ts": [100, null],
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
- export const ACCOUNT_ROLES = Object.freeze(Object.fromEntries(
7
- Object.entries(accessContract.roles).map(([name, value]) => [name, Object.freeze({ ...value })]),
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 (!ACCOUNT_ROLES[role]) throw new BridgeError("invalid_request", `unknown account role: ${role}`);
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(ACCOUNT_ROLES[normalized].profile, "explicit");
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, isAbsolute, join, relative, resolve, sep } from "node:path";
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 { isPlainRecord } from "./records.mjs";
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 normalizeConfig(parsed, configPath);
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
+ }