@aiwg/cli 2026.10.4 → 2026.10.6

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 (220) hide show
  1. package/agentic/code/addons/composition-engine/lib/runtime.mjs +1401 -0
  2. package/agentic/code/addons/composition-engine/lib/validator.mjs +513 -0
  3. package/agentic/code/addons/composition-engine/schemas/flow-graph.schema.json +372 -0
  4. package/agentic/code/providers/model-capabilities.v1.json +74 -50
  5. package/bin/aiwg.mjs +3 -1
  6. package/dist/src/artifacts/browser-export.js +1 -0
  7. package/dist/src/artifacts/citation-parser.js +8 -1
  8. package/dist/src/artifacts/cli.js +72 -9
  9. package/dist/src/artifacts/fortemi-core-query-adapter.js +2 -0
  10. package/dist/src/artifacts/fortemi-core-sync.js +26 -8
  11. package/dist/src/artifacts/graph-query.js +18 -8
  12. package/dist/src/artifacts/index-adapters.js +339 -0
  13. package/dist/src/artifacts/index-builder.js +146 -16
  14. package/dist/src/artifacts/index-files.js +15 -4
  15. package/dist/src/artifacts/index-preparation.js +91 -26
  16. package/dist/src/artifacts/index-reader.js +44 -0
  17. package/dist/src/artifacts/index-refresh.js +130 -56
  18. package/dist/src/artifacts/index-status.js +20 -5
  19. package/dist/src/artifacts/index-worker.mjs +9 -7
  20. package/dist/src/artifacts/memory-maintenance.js +503 -0
  21. package/dist/src/artifacts/prebuilt-relations.js +3 -1
  22. package/dist/src/artifacts/query-engine.js +263 -108
  23. package/dist/src/artifacts/relations-builder.js +2 -2
  24. package/dist/src/artifacts/source-graph.js +2 -2
  25. package/dist/src/artifacts/types.js +9 -1
  26. package/dist/src/cli/handlers/decision.js +19 -1
  27. package/dist/src/cli/handlers/guided-setup.js +470 -0
  28. package/dist/src/cli/handlers/help.js +2 -2
  29. package/dist/src/cli/handlers/housekeeper.js +1110 -0
  30. package/dist/src/cli/handlers/index.js +4 -2
  31. package/dist/src/cli/handlers/init.js +41 -6
  32. package/dist/src/cli/handlers/local-executor.js +5 -3
  33. package/dist/src/cli/handlers/mc.js +509 -53
  34. package/dist/src/cli/handlers/models.js +32 -2
  35. package/dist/src/cli/handlers/ralph-launcher.js +76 -4
  36. package/dist/src/cli/handlers/rules.js +1 -1
  37. package/dist/src/cli/handlers/script-runner.js +73 -2
  38. package/dist/src/cli/handlers/serve.js +5 -3
  39. package/dist/src/cli/handlers/sessions.js +40 -6
  40. package/dist/src/cli/handlers/setup.js +428 -1
  41. package/dist/src/cli/handlers/skill-lint.js +1 -1
  42. package/dist/src/cli/handlers/subcommands.js +118 -8
  43. package/dist/src/cli/handlers/use.js +279 -93
  44. package/dist/src/cli/handlers/workspace.js +3 -1
  45. package/dist/src/cli/prompt-utils.js +8 -0
  46. package/dist/src/cli/router.js +16 -1
  47. package/dist/src/config/aiwg-config.js +42 -0
  48. package/dist/src/config/cli.js +7 -3
  49. package/dist/src/config/decision-config.js +14 -1
  50. package/dist/src/config/native-sandbox-warnings.js +25 -0
  51. package/dist/src/contributors/validation.js +6 -5
  52. package/dist/src/decision/admission.js +7 -3
  53. package/dist/src/decision/benchmark.js +469 -0
  54. package/dist/src/decision/ledger.js +22 -3
  55. package/dist/src/decision/pricing.js +147 -0
  56. package/dist/src/decision/qualification/gate-evidence.js +18 -15
  57. package/dist/src/decision/qualification/load-staged.js +539 -0
  58. package/dist/src/decision/qualification/load.js +109 -33
  59. package/dist/src/decision/qualification/runner.js +5 -2
  60. package/dist/src/decision/review/service.js +6 -4
  61. package/dist/src/decision/review/validate.js +23 -2
  62. package/dist/src/decision/savings.js +8 -3
  63. package/dist/src/discovery/index.js +492 -0
  64. package/dist/src/discovery/interview.js +233 -0
  65. package/dist/src/discovery/plan-apply.js +571 -0
  66. package/dist/src/discovery/plan-store.js +171 -0
  67. package/dist/src/discovery/plan-types.js +2 -0
  68. package/dist/src/discovery/plan-validation.js +139 -0
  69. package/dist/src/discovery/types.js +2 -0
  70. package/dist/src/extensions/commands/definitions.js +49 -9
  71. package/dist/src/extensions/manifest.js +71 -44
  72. package/dist/src/extensions/project-local-discovery.js +1 -1
  73. package/dist/src/extensions/project-quickref.js +18 -17
  74. package/dist/src/extensions/validation.js +32 -29
  75. package/dist/src/housekeeper/daemon-config.js +148 -0
  76. package/dist/src/housekeeper/ipc.js +84 -0
  77. package/dist/src/housekeeper/lifecycle.js +148 -0
  78. package/dist/src/housekeeper/platform.js +35 -0
  79. package/dist/src/housekeeper/status.js +70 -0
  80. package/dist/src/housekeeper/units.js +114 -0
  81. package/dist/src/jobs/flow.js +22 -21
  82. package/dist/src/lint/rule-activation.js +20 -5
  83. package/dist/src/lint/runner.js +1 -1
  84. package/dist/src/mcp/cli.mjs +5 -6
  85. package/dist/src/mcp/config-file.mjs +6 -2
  86. package/dist/src/mcp/config-layers.mjs +23 -7
  87. package/dist/src/mcp/profiles.js +18 -31
  88. package/dist/src/mcp/profiles.mjs +18 -32
  89. package/dist/src/mcp/registry.js +7 -27
  90. package/dist/src/mcp/registry.mjs +7 -28
  91. package/dist/src/mcp/server.mjs +34 -1
  92. package/dist/src/mcp/tools/agentic-sandbox.mjs +8 -7
  93. package/dist/src/mcp/tools/command-run.mjs +3 -2
  94. package/dist/src/mcp/tools/decision.mjs +49 -48
  95. package/dist/src/mcp/tools/discovery.mjs +46 -21
  96. package/dist/src/mcp/tools/orchestration.mjs +22 -22
  97. package/dist/src/mcp/tools/subsystems.mjs +9 -8
  98. package/dist/src/mcp/tools/worker-invoke.mjs +17 -4
  99. package/dist/src/mcp/tools/writer-profiles.mjs +1 -1
  100. package/dist/src/memory/audit.js +719 -0
  101. package/dist/src/memory/canonical-context.js +392 -13
  102. package/dist/src/memory/canonical-maintenance.js +95 -0
  103. package/dist/src/memory/cli-result.js +22 -0
  104. package/dist/src/memory/cli.js +176 -0
  105. package/dist/src/memory/context-pack.js +57 -6
  106. package/dist/src/memory/cost.js +512 -0
  107. package/dist/src/memory/firewall-classification.d.mts +7 -0
  108. package/dist/src/memory/firewall-classification.mjs +32 -0
  109. package/dist/src/memory/index.js +8 -0
  110. package/dist/src/memory/line-maintenance.js +68 -0
  111. package/dist/src/memory/maintenance-cli.js +286 -0
  112. package/dist/src/memory/maintenance-contract.js +136 -0
  113. package/dist/src/memory/maintenance-index.js +18 -0
  114. package/dist/src/memory/maintenance-owner.js +2 -0
  115. package/dist/src/memory/maintenance-record.js +138 -0
  116. package/dist/src/memory/maintenance.js +254 -0
  117. package/dist/src/memory/provider-memory-adapter.js +154 -0
  118. package/dist/src/memory/recommend-cli.js +83 -0
  119. package/dist/src/memory/recommend.js +269 -0
  120. package/dist/src/memory/scheduled.js +81 -0
  121. package/dist/src/memory/taxonomy.js +44 -0
  122. package/dist/src/mission-protocol/codecs.js +19 -9
  123. package/dist/src/models/config-loader.js +11 -1
  124. package/dist/src/models/model-capabilities.v1.json +74 -50
  125. package/dist/src/models/provider-policy.js +125 -62
  126. package/dist/src/models/resolver.js +45 -8
  127. package/dist/src/models/worker-installation.d.mts +7 -0
  128. package/dist/src/models/worker-installation.mjs +169 -37
  129. package/dist/src/models/wrapper-route.js +1 -1
  130. package/dist/src/output-modes/registry.js +8 -7
  131. package/dist/src/plugin/metadata-validator.js +1 -1
  132. package/dist/src/providers/deploy-version.d.mts +1 -0
  133. package/dist/src/providers/deploy-version.mjs +17 -0
  134. package/dist/src/providers/provider-definitions.js +19 -16
  135. package/dist/src/providers/skill-ownership.d.mts +11 -2
  136. package/dist/src/providers/skill-ownership.mjs +35 -17
  137. package/dist/src/rules/activation.d.mts +11 -2
  138. package/dist/src/rules/activation.mjs +82 -6
  139. package/dist/src/rules/inventory.js +1 -1
  140. package/dist/src/rules/rule-frontmatter.schema.json +2 -0
  141. package/dist/src/schema/zod-validation.d.mts +6 -0
  142. package/dist/src/schema/zod-validation.mjs +50 -0
  143. package/dist/src/serve/executor-registry.js +297 -20
  144. package/dist/src/serve/executor-token.d.mts +1 -0
  145. package/dist/src/serve/executor-token.mjs +8 -0
  146. package/dist/src/serve/shared-host-scheduler.js +1102 -31
  147. package/dist/src/serve/worker-invocation.js +114 -15
  148. package/dist/src/sessions/adapters/claude.js +19 -18
  149. package/dist/src/sessions/adapters/codex.js +33 -19
  150. package/dist/src/sessions/adapters/copilot.js +14 -13
  151. package/dist/src/sessions/adapters/cursor.js +19 -18
  152. package/dist/src/sessions/adapters/deepseek-harness.js +9 -8
  153. package/dist/src/sessions/adapters/factory.js +11 -10
  154. package/dist/src/sessions/adapters/generic.js +32 -23
  155. package/dist/src/sessions/adapters/grokbot.js +35 -35
  156. package/dist/src/sessions/adapters/hermes.js +20 -19
  157. package/dist/src/sessions/adapters/muse.js +50 -49
  158. package/dist/src/sessions/adapters/omp.js +5 -4
  159. package/dist/src/sessions/adapters/openclaw.js +30 -29
  160. package/dist/src/sessions/adapters/opencode.js +26 -26
  161. package/dist/src/sessions/adapters/openhuman.js +24 -23
  162. package/dist/src/sessions/adapters/pi.js +5 -4
  163. package/dist/src/sessions/adapters/windsurf.js +8 -8
  164. package/dist/src/sessions/analytics.js +10 -9
  165. package/dist/src/sessions/batch-contracts.js +1 -1
  166. package/dist/src/sessions/batch-import.js +202 -2
  167. package/dist/src/sessions/candidates.js +7 -6
  168. package/dist/src/sessions/contracts.js +73 -54
  169. package/dist/src/sessions/coverage-preflight.js +26 -1
  170. package/dist/src/sessions/output-registration.js +11 -10
  171. package/dist/src/sessions/policy.js +18 -7
  172. package/dist/src/sessions/refresh.js +2 -3
  173. package/dist/src/sessions/repository.js +4 -2
  174. package/dist/src/sessions/workspace-discovery.js +92 -26
  175. package/dist/src/smiths/context-pipeline/aiwg-md.js +18 -1
  176. package/dist/src/smiths/context-pipeline/claude-hook.js +42 -6
  177. package/dist/src/smiths/context-pipeline/parallelism-section.js +72 -5
  178. package/dist/src/smiths/context-pipeline/workspace-context.js +106 -10
  179. package/dist/src/update/checker.d.mts +4 -0
  180. package/dist/src/update/checker.mjs +14 -0
  181. package/dist/src/writing/exemplar-selection.js +6 -6
  182. package/dist/src/writing/voice-evaluation.js +24 -23
  183. package/dist/src/writing/writer-migration.js +14 -13
  184. package/dist/src/writing/writer-profile.js +19 -18
  185. package/dist/src/writing/writing-brief.js +18 -17
  186. package/dist/src/writing/writing-receipt.js +47 -46
  187. package/package.json +5 -2
  188. package/schemas/decision/DecisionReview.schema.json +78 -1
  189. package/schemas/mission-v1.schema.json +3 -0
  190. package/tools/agents/deploy-agents.mjs +67 -39
  191. package/tools/agents/providers/antigravity.mjs +6 -1
  192. package/tools/agents/providers/base.mjs +19 -13
  193. package/tools/agents/providers/claude.mjs +1 -1
  194. package/tools/agents/providers/codex.mjs +50 -56
  195. package/tools/agents/providers/copilot.mjs +8 -2
  196. package/tools/agents/providers/cursor.mjs +1 -1
  197. package/tools/agents/providers/deepseek-harness.mjs +1 -1
  198. package/tools/agents/providers/factory.mjs +7 -1
  199. package/tools/agents/providers/grok-build.mjs +18 -2
  200. package/tools/agents/providers/grokbot.mjs +1 -1
  201. package/tools/agents/providers/hermes.mjs +7 -13
  202. package/tools/agents/providers/muse-hooks.mjs +21 -14
  203. package/tools/agents/providers/muse.mjs +7 -2
  204. package/tools/agents/providers/omp.mjs +28 -4
  205. package/tools/agents/providers/opencode.mjs +14 -5
  206. package/tools/agents/providers/openhuman.mjs +1 -1
  207. package/tools/agents/providers/pi.mjs +1 -1
  208. package/tools/agents/providers/provider-prepare-coordinator.mjs +29 -9
  209. package/tools/agents/providers/rule-delivery.mjs +263 -71
  210. package/tools/agents/providers/windsurf.mjs +7 -0
  211. package/tools/cli/workspace-status.mjs +5 -0
  212. package/tools/commands/deploy-prompts-codex.mjs +11 -8
  213. package/tools/ralph-external/lib/codex-adapter.mjs +6 -2
  214. package/tools/ralph-external/lib/muse-adapter.mjs +12 -2
  215. package/tools/ralph-external/lib/worker-health.mjs +1 -1
  216. package/tools/ralph-external/lib/worker-isolation.mjs +65 -9
  217. package/tools/ralph-external/process-monitor.mjs +7 -4
  218. package/tools/ralph-external/session-launcher.mjs +280 -32
  219. package/tools/skills/deploy-skills-codex.mjs +11 -7
  220. package/vscode-extension/schemas/aiwg.config.v1.json +11 -1
@@ -1,9 +1,21 @@
1
1
  /** Shared source frontmatter contract for lint, deployment, and show/MCP (#2938). */
2
- import { readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
+ import path from 'node:path';
3
4
  import Ajv from 'ajv';
4
5
  import { parse } from 'yaml';
5
6
 
6
- export const RULE_ACTIVATIONS = Object.freeze(['kernel', 'summary', 'bound', 'paths', 'on-demand']);
7
+ export const RULE_ACTIVATIONS = Object.freeze(['core', 'kernel', 'summary', 'bound', 'paths', 'on-demand']);
8
+
9
+ // Core rules (#3450): compact normative bodies delivered to every harness's
10
+ // always-loaded surface regardless of deploy mode. A body is `<rule>.core.md`
11
+ // beside the full rule; the full rule is reachable only by plain path or
12
+ // `aiwg show rule <name>`, never an @ import.
13
+ export const CORE_BODY_MAX_BYTES = 1024;
14
+ export const CORE_TOTAL_MAX_BYTES = 6144;
15
+ export const CORE_RULES_START = '<!-- AIWG:core-rules:start -->';
16
+ export const CORE_RULES_END = '<!-- AIWG:core-rules:end -->';
17
+ const AT_IMPORT = /(?:^|[\s(`"'])@[\w$.\/-]*[\/.][\w$.\/-]*/m;
18
+
7
19
  export const RULE_BINDING_TYPES = Object.freeze({ skills: 'skill', agents: 'agent', flows: 'flow' });
8
20
  export const ruleFrontmatterSchema = JSON.parse(readFileSync(new URL('./rule-frontmatter.schema.json', import.meta.url), 'utf8'));
9
21
  const ajv = new Ajv({ strict: true, allErrors: true });
@@ -11,9 +23,9 @@ const validate = ajv.compile(ruleFrontmatterSchema);
11
23
  // Legacy paths/applies_to/scope never become activation inputs during migration.
12
24
  const validateLegacy = ajv.compile({ type: 'object', properties: ruleFrontmatterSchema.properties });
13
25
 
14
- export function inferRuleActivation(enforcement) {
15
- return typeof enforcement === 'string' && ['medium', 'low'].includes(enforcement.trim().toLowerCase())
16
- ? 'on-demand' : 'kernel';
26
+ export function inferRuleActivation() {
27
+ // Missing metadata never grants an always-on policy surface.
28
+ return 'on-demand';
17
29
  }
18
30
 
19
31
  /** Validate a parsed YAML mapping. Invalid explicit activation never falls back. */
@@ -31,7 +43,7 @@ export function validateRuleFrontmatter(data) {
31
43
  });
32
44
  }
33
45
  if (!tagged) diagnostics.push({ severity: 'warn', code: 'missing-activation',
34
- message: `Missing activation; inferred ${inferRuleActivation(data.enforcement)} delivery from legacy enforcement (migration fallback).` });
46
+ message: `Missing activation; inferred ${inferRuleActivation(data.enforcement)} delivery (migration fallback); add explicit activation frontmatter.` });
35
47
  if (Object.hasOwn(data, 'enforcedBy')) diagnostics.push({ severity: 'info', code: 'reserved-enforcedBy',
36
48
  message: 'enforcedBy is opaque reserved metadata (#2839); it has no effect.' });
37
49
  const valid = !diagnostics.some(item => item.severity === 'error');
@@ -60,3 +72,67 @@ export function parseRuleFrontmatter(content) {
60
72
  return { valid: false, diagnostics: [{ severity: 'error', code: 'frontmatter', message: `Invalid rule YAML: ${error.message}` }], data: {}, body: content };
61
73
  }
62
74
  }
75
+
76
+ export function coreBodyPath(ruleFile) {
77
+ return ruleFile.replace(/\.md$/, '.core.md');
78
+ }
79
+
80
+ /** Validate one core body's text. Returns error strings (empty when valid). */
81
+ export function validateCoreBody(text, label = 'core body') {
82
+ const errors = [];
83
+ const bytes = Buffer.byteLength(text, 'utf8');
84
+ if (!text.trim()) errors.push(`${label}: core body is empty`);
85
+ if (bytes > CORE_BODY_MAX_BYTES) errors.push(`${label}: core body is ${bytes} bytes; the per-body cap is ${CORE_BODY_MAX_BYTES} bytes`);
86
+ if (/^---\r?\n/.test(text)) errors.push(`${label}: core body must not carry frontmatter`);
87
+ if (AT_IMPORT.test(text)) errors.push(`${label}: core body must not contain an @ file reference/import; use a plain path or \`aiwg show rule <name>\``);
88
+ return errors;
89
+ }
90
+
91
+ /** Validate the whole core set against the reserved budget. */
92
+ export function validateCoreSet(bodies) {
93
+ const total = bodies.reduce((sum, item) => sum + Buffer.byteLength(item.text, 'utf8'), 0);
94
+ return total > CORE_TOTAL_MAX_BYTES
95
+ ? [`Core rules total ${total} bytes; the reserved core budget is ${CORE_TOTAL_MAX_BYTES} bytes (${bodies.map(item => `${item.name}: ${Buffer.byteLength(item.text, 'utf8')}`).join(', ')})`]
96
+ : [];
97
+ }
98
+
99
+ /** Read and validate the core body for a rule file; throws with a clear diagnostic. */
100
+ export function readCoreBody(ruleFile) {
101
+ const file = coreBodyPath(ruleFile);
102
+ if (!existsSync(file)) throw new Error(`${ruleFile}: activation core requires a compact body file ${path.basename(file)}`);
103
+ const text = readFileSync(file, 'utf8').trim();
104
+ const errors = validateCoreBody(text, file);
105
+ if (errors.length) throw new Error(errors.join('; '));
106
+ return text;
107
+ }
108
+
109
+ /**
110
+ * Measure the core slice already deployed in a project, for `aiwg doctor`.
111
+ * Native providers write core-<name>.md(c) files; the others carry a marked
112
+ * section in their startup context file.
113
+ */
114
+ export function measureDeployedCoreRules(target, provider, rulesDir) {
115
+ const bodies = [];
116
+ if (['claude', 'cursor'].includes(provider)) {
117
+ const dir = rulesDir || path.join(target, `.${provider}`, 'rules');
118
+ if (existsSync(dir)) for (const name of readdirSync(dir).filter(n => /^core-.+\.(md|mdc)$/.test(n))) {
119
+ // Count the authored body only, not the managed marker or MDC frontmatter.
120
+ const text = readFileSync(path.join(dir, name), 'utf8')
121
+ .replace(/^<!-- aiwg:managed[^\n]*-->\n/, '').replace(/^---\n[\s\S]*?\n---\n/, '').trim();
122
+ bodies.push({ name, bytes: Buffer.byteLength(text, 'utf8') });
123
+ }
124
+ } else {
125
+ for (const name of ['AIWG.md', 'AGENTS.md', 'WARP.md']) {
126
+ const file = path.join(target, name);
127
+ if (!existsSync(file)) continue;
128
+ const content = readFileSync(file, 'utf8');
129
+ const start = content.indexOf(CORE_RULES_START);
130
+ const end = content.indexOf(CORE_RULES_END);
131
+ if (start >= 0 && end > start) {
132
+ bodies.push({ name, bytes: Buffer.byteLength(content.slice(start + CORE_RULES_START.length, end).trim(), 'utf8') });
133
+ break;
134
+ }
135
+ }
136
+ }
137
+ return { bodies, bytes: bodies.reduce((sum, item) => sum + item.bytes, 0), limit: CORE_TOTAL_MAX_BYTES };
138
+ }
@@ -118,7 +118,7 @@ export async function ruleInventory(cwd, root, options) {
118
118
  canonical_activation: canonical, effective_activation: installed ? effective : null,
119
119
  enforcement, pin_reasons: pin?.matches ?? [],
120
120
  bindings: relation ? relation[1].targets : null, binding_evidence: relation ? 'relationship-index' : 'unknown', bound_to: parsed.rule.bound_to, paths: parsed.rule.paths ?? [],
121
- fetch_command: stableId ? `aiwg show rule ${stableId}` : null,
121
+ fetch_command: stableId ? `aiwg show rule ${stableId} --body` : null,
122
122
  configured_providers: configuredProviders, delivery_evidence: configuredProviders.length ? 'capability deployment recorded; per-rule delivery unverified' : 'none', delivery: 'unknown', session_loaded: 'unknown',
123
123
  ...(options.bodies ? { content } : {}) }];
124
124
  }).sort((a, b) => a.capability.localeCompare(b.capability) || a.source.localeCompare(b.source));
@@ -12,6 +12,7 @@
12
12
  "activation": {
13
13
  "type": "string",
14
14
  "enum": [
15
+ "core",
15
16
  "kernel",
16
17
  "summary",
17
18
  "bound",
@@ -95,6 +96,7 @@
95
96
  "properties": {
96
97
  "activation": {
97
98
  "enum": [
99
+ "core",
98
100
  "kernel",
99
101
  "on-demand"
100
102
  ]
@@ -0,0 +1,6 @@
1
+ import type { ZodNumber, ZodString, ZodType } from 'zod';
2
+
3
+ export function integer(): ZodNumber;
4
+ export function dateTime(options?: { offset?: boolean; error?: string }): ZodString;
5
+ export function url(): ZodString;
6
+ export function number(): ZodType<number>;
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod';
2
+
3
+ /** Preserve integer validation without narrowing existing inputs to safe integers. */
4
+ export function integer() {
5
+ return z.number().check(context => {
6
+ if (!Number.isInteger(context.value)) {
7
+ context.issues.push({ code: 'invalid_type', expected: 'int', input: context.value, continue: true });
8
+ }
9
+ }).meta({ type: 'integer' });
10
+ }
11
+
12
+ // Calendar validation includes leap years. Existing timestamps also permit minute
13
+ // precision and numeric offsets without a colon.
14
+ const calendar = String.raw`((\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\d|3[01])|(0[469]|11)-(0[1-9]|[12]\d|30)|(02)-(0[1-9]|1\d|2[0-8])))`;
15
+ const time = String.raw`([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?`;
16
+
17
+ /**
18
+ * Keep the timestamp grammar accepted by existing persisted AIWG documents.
19
+ * @param {{ offset?: boolean, error?: string }} [options]
20
+ */
21
+ export function dateTime({ offset = false, error } = {}) {
22
+ const zone = offset ? String.raw`(Z|[+-]\d{2}:?\d{2})` : 'Z';
23
+ const pattern = new RegExp(`^${calendar}T${time}${zone}$`);
24
+ return z.string().check(context => {
25
+ if (!pattern.test(context.value)) {
26
+ context.issues.push({ code: 'invalid_format', format: 'datetime', input: context.value, continue: true,
27
+ ...(error === undefined ? {} : { message: error }) });
28
+ }
29
+ }).meta({ format: 'date-time' });
30
+ }
31
+
32
+ /** Validate URLs without rewriting their stored spelling or whitespace. */
33
+ export function url() {
34
+ return z.string().check(context => {
35
+ try {
36
+ new URL(context.value);
37
+ } catch {
38
+ context.issues.push({ code: 'invalid_format', format: 'url', input: context.value, continue: true });
39
+ }
40
+ }).meta({ format: 'uri' });
41
+ }
42
+
43
+ /** Preserve numeric documents that may carry infinite measurements. */
44
+ export function number() {
45
+ return z.unknown().check(context => {
46
+ if (typeof context.value !== 'number' || Number.isNaN(context.value)) {
47
+ context.issues.push({ code: 'invalid_type', expected: 'number', input: context.value, continue: false });
48
+ }
49
+ }).meta({ type: 'number' });
50
+ }
@@ -15,13 +15,15 @@
15
15
  * @see docs/contracts/executor.v1.md — authoritative prose spec
16
16
  */
17
17
  import { randomBytes } from 'crypto';
18
+ import { executorTokensEqual } from './executor-token.mjs';
18
19
  import { EventEmitter } from 'events';
19
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'fs';
20
+ import { constants, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, lstatSync, realpathSync, openSync, closeSync, fstatSync, fsyncSync } from 'fs';
20
21
  import { homedir } from 'os';
21
- import { dirname, isAbsolute, join, resolve as resolvePath } from 'path';
22
+ import { dirname, isAbsolute, join, relative, resolve as resolvePath } from 'path';
22
23
  import { createRequire } from 'module';
23
24
  import { fileURLToPath } from 'url';
24
25
  import { projectAiwgPath } from '../config/project-artifacts.js';
26
+ import { FileAdmissionStore, hostProjectAdmissionPolicy } from './shared-host-scheduler.js';
25
27
  // ============================================================
26
28
  // Ajv bootstrap (transitive dep — zero new top-level deps)
27
29
  // ============================================================
@@ -152,26 +154,72 @@ export function resolveExecutorIdentityStorePath(projectRootOverride) {
152
154
  catch { /* fall through */ }
153
155
  return DEFAULT_EXECUTOR_IDENTITY_STORE_PATH;
154
156
  }
155
- function loadExecutorIdentityStore() {
156
- const path = resolveExecutorIdentityStorePath();
157
+ function loadExecutorIdentityStore(path = resolveExecutorIdentityStorePath(), strict = false) {
157
158
  try {
158
159
  if (existsSync(path)) {
159
- const data = JSON.parse(readFileSync(path, 'utf-8'));
160
- return new Map(data.map((r) => [r.executorId, r]));
160
+ let contents;
161
+ if (strict) {
162
+ const file = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
163
+ try {
164
+ const stat = fstatSync(file);
165
+ if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.() || stat.size > 1024 * 1024)
166
+ throw new Error('host registration must be a private bounded regular file');
167
+ contents = readFileSync(file, 'utf-8');
168
+ }
169
+ finally {
170
+ closeSync(file);
171
+ }
172
+ }
173
+ else
174
+ contents = readFileSync(path, 'utf-8');
175
+ const data = JSON.parse(contents);
176
+ if (strict && (!Array.isArray(data) || data.some(record => !record || typeof record.executorId !== 'string' || !record.executorId)
177
+ || new Set(data.map(record => record.executorId)).size !== data.length))
178
+ throw new Error('invalid trusted executor identity store');
179
+ return new Map(data.map((r) => {
180
+ if (strict)
181
+ return [r.executorId, r];
182
+ const { hostAdmission: _hostAdmission, ...identity } = r;
183
+ return [r.executorId, identity];
184
+ }));
161
185
  }
162
186
  }
163
- catch { /* ignore */ }
187
+ catch (error) {
188
+ if (strict)
189
+ throw error; /* legacy best effort */
190
+ }
164
191
  return new Map();
165
192
  }
166
- function saveExecutorIdentityStore(store) {
167
- const path = resolveExecutorIdentityStorePath();
193
+ function saveExecutorIdentityStore(store, path = resolveExecutorIdentityStorePath(), strict = false) {
168
194
  try {
169
195
  const dir = dirname(path);
170
- mkdirSync(dir, { recursive: true });
196
+ mkdirSync(dir, { recursive: true, ...(strict ? { mode: 0o700 } : {}) });
197
+ if (strict)
198
+ assertHostRegistrationPath(path, []);
171
199
  const tmp = `${path}.tmp.${process.pid}`;
172
- writeFileSync(tmp, JSON.stringify([...store.values()], null, 2), 'utf-8');
200
+ if (strict) {
201
+ const file = openSync(tmp, 'wx', 0o600);
202
+ try {
203
+ writeFileSync(file, JSON.stringify([...store.values()], null, 2));
204
+ fsyncSync(file);
205
+ }
206
+ finally {
207
+ closeSync(file);
208
+ }
209
+ }
210
+ else
211
+ writeFileSync(tmp, JSON.stringify([...store.values()], null, 2), { encoding: 'utf-8', mode: 0o600, flag: 'wx' });
173
212
  try {
174
213
  renameSync(tmp, path);
214
+ if (strict) {
215
+ const directory = openSync(dir, 'r');
216
+ try {
217
+ fsyncSync(directory);
218
+ }
219
+ finally {
220
+ closeSync(directory);
221
+ }
222
+ }
175
223
  }
176
224
  catch (err) {
177
225
  try {
@@ -181,7 +229,30 @@ function saveExecutorIdentityStore(store) {
181
229
  throw err;
182
230
  }
183
231
  }
184
- catch { /* non-fatal — best effort */ }
232
+ catch (error) {
233
+ if (strict)
234
+ throw error; /* legacy best effort */
235
+ }
236
+ }
237
+ /** Host registration has a fixed user authority location, independent of project
238
+ * storage redirects. A trusted embedding may provide an explicit location. */
239
+ function assertHostRegistrationPath(path, workerWritableRoots) {
240
+ const parent = dirname(path);
241
+ if (!isAbsolute(path) || realpathSync(parent) !== resolvePath(parent))
242
+ throw new Error('host registration requires a canonical absolute path');
243
+ const directory = lstatSync(parent);
244
+ if ((directory.mode & 0o077) !== 0 || directory.uid !== process.getuid?.())
245
+ throw new Error('host registration directory must be private');
246
+ if (existsSync(path)) {
247
+ const stat = lstatSync(path);
248
+ if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.() || stat.size > 1024 * 1024)
249
+ throw new Error('host registration must be a private bounded regular file');
250
+ }
251
+ for (const root of workerWritableRoots) {
252
+ const within = relative(realpathSync(root), path);
253
+ if (!within || (!within.startsWith('../') && within !== '..' && !isAbsolute(within)))
254
+ throw new Error('worker writable mount includes the host registration');
255
+ }
185
256
  }
186
257
  // ============================================================
187
258
  // Max recent events kept per mission
@@ -201,21 +272,169 @@ const MAX_MISSION_EVENTS = 50;
201
272
  * 'mission:state_change' — { missionId, executorId, state, prevState }
202
273
  */
203
274
  export class ExecutorRegistry extends EventEmitter {
275
+ hostIdentityStorePath;
204
276
  executors = new Map();
277
+ admissionAuthorities = new Map();
205
278
  missions = new Map();
206
279
  /** Persistent token store — executorId → identity record */
207
280
  identityStore;
208
- constructor() {
281
+ constructor(hostIdentityStorePath = DEFAULT_EXECUTOR_IDENTITY_STORE_PATH) {
209
282
  super();
283
+ this.hostIdentityStorePath = hostIdentityStorePath;
210
284
  this.identityStore = loadExecutorIdentityStore();
211
285
  }
286
+ /** Trusted service bootstrap only. This is deliberately absent from the public
287
+ * executor registration payload: an executor cannot grant itself host authority.
288
+ * The owner supplies the protected existing AdmissionStore, never a project path. */
289
+ installHostAdmissionAuthority(executorId, version, store, workerWritableRoots) {
290
+ const executor = this.executors.get(executorId);
291
+ const binding = store.read().hostBinding;
292
+ if (!executor || executor.version !== version || !executor.capabilities.includes('x-admission-policy-revision:1') || !binding) {
293
+ throw new Error('qualified executor and revision-aware host admission binding are required');
294
+ }
295
+ if (store instanceof FileAdmissionStore)
296
+ store.protectHostAuthority(workerWritableRoots);
297
+ else if (!Array.isArray(workerWritableRoots))
298
+ throw new Error('worker writable roots are required');
299
+ const previous = this.admissionAuthorities.get(binding.authorityRef);
300
+ if (previous && (previous.executorId !== executorId || previous.store !== store || previous.version !== version))
301
+ throw new Error('host admission authority is already installed');
302
+ if (store instanceof FileAdmissionStore) {
303
+ mkdirSync(dirname(this.hostIdentityStorePath), { recursive: true, mode: 0o700 });
304
+ assertHostRegistrationPath(this.hostIdentityStorePath, workerWritableRoots);
305
+ const identities = loadExecutorIdentityStore(this.hostIdentityStorePath, true);
306
+ const identity = identities.get(executorId) ?? { executorId, lastSeenAt: new Date().toISOString() };
307
+ identities.set(executorId, { ...identity, token: executor.token, hostAdmission: { authorityRef: binding.authorityRef,
308
+ path: store.hostAuthorityPath(), version, workerWritableRoots: [...workerWritableRoots], revisionSupport: 1 } });
309
+ saveExecutorIdentityStore(identities, this.hostIdentityStorePath, true);
310
+ }
311
+ if (store.read().hostRegistrationRevoked)
312
+ store.transact(snapshot => { snapshot.hostRegistrationRevoked = false; });
313
+ this.admissionAuthorities.set(binding.authorityRef, { executorId, version, store });
314
+ }
315
+ resolveHostAdmissionAuthority(authorityRef, projectPolicy) {
316
+ if (!this.admissionAuthorities.has(authorityRef) && existsSync(this.hostIdentityStorePath)) {
317
+ assertHostRegistrationPath(this.hostIdentityStorePath, []);
318
+ const identities = loadExecutorIdentityStore(this.hostIdentityStorePath, true);
319
+ for (const identity of identities.values()) {
320
+ const installed = identity.hostAdmission;
321
+ if (installed?.authorityRef !== authorityRef)
322
+ continue;
323
+ if (installed.revisionSupport !== 1 || !Array.isArray(installed.workerWritableRoots))
324
+ throw new Error('host registration lacks policy revision support');
325
+ assertHostRegistrationPath(this.hostIdentityStorePath, installed.workerWritableRoots);
326
+ const store = new FileAdmissionStore(installed.path);
327
+ store.protectHostAuthority(installed.workerWritableRoots);
328
+ if (store.read().hostBinding?.authorityRef !== authorityRef)
329
+ throw new Error('host registration authority reference mismatch');
330
+ this.admissionAuthorities.set(authorityRef, { executorId: identity.executorId, version: installed.version, store, restored: true });
331
+ }
332
+ }
333
+ const installed = this.admissionAuthorities.get(authorityRef);
334
+ if (installed?.store instanceof FileAdmissionStore) {
335
+ assertHostRegistrationPath(this.hostIdentityStorePath, []);
336
+ const current = loadExecutorIdentityStore(this.hostIdentityStorePath, true).get(installed.executorId)?.hostAdmission;
337
+ if (!current || current.authorityRef !== authorityRef || current.version !== installed.version
338
+ || current.path !== installed.store.hostAuthorityPath())
339
+ throw new Error('trusted host executor registration has been revoked or changed');
340
+ }
341
+ const executor = installed && this.executors.get(installed.executorId);
342
+ if (!installed || (!executor && !installed.restored) || (executor && (executor.version !== installed.version || !executor.capabilities.includes('x-admission-policy-revision:1')))) {
343
+ throw new Error('missing current revision-aware trusted host executor binding');
344
+ }
345
+ const snapshot = installed.store.read();
346
+ if (snapshot.hostRegistrationRevoked)
347
+ throw new Error('trusted host executor registration is revoked');
348
+ const approved = snapshot.hostBinding?.projects[projectPolicy.managed.projectId]?.policy;
349
+ // Compare complete approved input, including defaults, rather than trusting a
350
+ // caller's activation digest to cover its effective limits.
351
+ const normalize = (value) => JSON.stringify(value, function (_key, node) {
352
+ return node && typeof node === 'object' && !Array.isArray(node)
353
+ ? Object.fromEntries(Object.keys(node).sort().filter(key => node[key] !== undefined).map(key => [key, node[key]])) : node;
354
+ });
355
+ if (snapshot.hostBinding?.authorityRef !== authorityRef || normalize(approved) !== normalize(projectPolicy))
356
+ throw new Error('project policy is not approved by the host authority');
357
+ return { store: installed.store, policy: hostProjectAdmissionPolicy(snapshot, projectPolicy.managed.projectId) };
358
+ }
359
+ revokeHostAdmissionAuthority(executorId) {
360
+ const installed = [...this.admissionAuthorities.values()].filter(entry => entry.executorId === executorId);
361
+ const view = this.hostView();
362
+ if (!view.trusted && view.untrustedHostBound.has(executorId))
363
+ throw new Error('host registration store is not protected');
364
+ const stored = view.identities.get(executorId)?.hostAdmission;
365
+ if (stored && !installed.length) {
366
+ const store = new FileAdmissionStore(stored.path);
367
+ store.protectHostAuthority(stored.workerWritableRoots);
368
+ installed.push({ executorId, version: stored.version, store });
369
+ }
370
+ if (!installed.length)
371
+ return;
372
+ // Fence first. A failed registration write can leave a discoverable binding,
373
+ // but cannot make an old scheduler's policy current again.
374
+ for (const entry of installed)
375
+ entry.store.transact(snapshot => {
376
+ if (snapshot.hostBinding) {
377
+ snapshot.hostBinding.revision++;
378
+ snapshot.hostRegistrationRevoked = true;
379
+ }
380
+ });
381
+ if (installed.some(entry => entry.store instanceof FileAdmissionStore)) {
382
+ assertHostRegistrationPath(this.hostIdentityStorePath, []);
383
+ const identities = loadExecutorIdentityStore(this.hostIdentityStorePath, true);
384
+ const identity = identities.get(executorId);
385
+ if (identity) {
386
+ delete identity.hostAdmission;
387
+ saveExecutorIdentityStore(identities, this.hostIdentityStorePath, true);
388
+ }
389
+ }
390
+ for (const [ref, entry] of this.admissionAuthorities)
391
+ if (entry.executorId === executorId)
392
+ this.admissionAuthorities.delete(ref);
393
+ }
394
+ loadHostIdentities() {
395
+ if (!existsSync(this.hostIdentityStorePath))
396
+ return new Map();
397
+ assertHostRegistrationPath(this.hostIdentityStorePath, []);
398
+ return loadExecutorIdentityStore(this.hostIdentityStorePath, true);
399
+ }
400
+ /**
401
+ * Host view for ordinary registry operations. When the shared identity file is
402
+ * not a protected host store (e.g. a 0755 ~/.config/aiwg holding only ordinary
403
+ * executors), ordinary IDs keep working; IDs that claim host admission in it
404
+ * are reported as untrusted so callers fail closed for them only. Untrusted
405
+ * content is never used for tokens, paths or metadata.
406
+ */
407
+ hostView() {
408
+ try {
409
+ return { trusted: true, identities: this.loadHostIdentities(), untrustedHostBound: new Set() };
410
+ }
411
+ catch {
412
+ const untrustedHostBound = new Set();
413
+ try {
414
+ const data = JSON.parse(readFileSync(this.hostIdentityStorePath, 'utf-8'));
415
+ if (Array.isArray(data))
416
+ for (const record of data) {
417
+ if (record && typeof record.executorId === 'string' && record.hostAdmission)
418
+ untrustedHostBound.add(record.executorId);
419
+ }
420
+ }
421
+ catch {
422
+ untrustedHostBound.add('*');
423
+ }
424
+ return { trusted: false, identities: new Map(), untrustedHostBound };
425
+ }
426
+ }
427
+ isUntrustedHostBound(view, executorId) {
428
+ return view.untrustedHostBound.has(executorId) || view.untrustedHostBound.has('*');
429
+ }
212
430
  // ---- Registration ----
213
431
  /**
214
432
  * Register an executor. Validates payload against `register_payload` schema.
215
- * Returns 400-level error string on invalid payload.
216
- * On re-register with same executor_id: reclaims prior token (per ADR §9).
433
+ * Returns a 400 error on invalid payload or 401 for unauthorized re-registration.
434
+ * Every existing identity requires its current bearer token before any update.
435
+ * Authenticated re-registration preserves the prior token (per ADR §9; #3439).
217
436
  */
218
- register(req) {
437
+ register(req, bearerToken = '') {
219
438
  // Validate against JSON Schema
220
439
  const { valid, errors } = validateRegisterPayload(req);
221
440
  if (!valid) {
@@ -226,7 +445,25 @@ export class ExecutorRegistry extends EventEmitter {
226
445
  // Check for existing token in identity store (re-register reclaims it)
227
446
  const existing = this.executors.get(executorId);
228
447
  const storedIdentity = this.identityStore.get(executorId);
229
- const token = existing?.token ?? storedIdentity?.token ?? issueToken();
448
+ const view = this.hostView();
449
+ const hostRecord = view.identities.get(executorId);
450
+ const hostAdmission = hostRecord?.hostAdmission;
451
+ const installedAuthority = [...this.admissionAuthorities.values()].find(binding => binding.executorId === executorId);
452
+ if (!view.trusted && (installedAuthority || this.isUntrustedHostBound(view, executorId)))
453
+ return { error: 'Unauthorized', status: 401 };
454
+ // Host-bound IDs authenticate only against the live executor or the protected
455
+ // host record, never the environment/project-selectable identity store.
456
+ const priorToken = (hostAdmission || installedAuthority)
457
+ ? existing?.token ?? hostRecord?.token
458
+ : existing?.token ?? storedIdentity?.token;
459
+ if (((hostAdmission || installedAuthority) || priorToken !== undefined) && !executorTokensEqual(priorToken, bearerToken)) {
460
+ return { error: 'Unauthorized', status: 401 };
461
+ }
462
+ const token = priorToken ?? issueToken();
463
+ if ((hostAdmission && (hostAdmission.version !== req.version || !req.capabilities.includes('x-admission-policy-revision:1')))
464
+ || (installedAuthority && (installedAuthority.version !== req.version || !req.capabilities.includes('x-admission-policy-revision:1')))) {
465
+ this.revokeHostAdmissionAuthority(executorId);
466
+ }
230
467
  // Update or create in-memory registration
231
468
  if (existing) {
232
469
  // Upsert — preserve token and registeredAt
@@ -261,7 +498,34 @@ export class ExecutorRegistry extends EventEmitter {
261
498
  token,
262
499
  lastSeenAt: now,
263
500
  });
264
- saveExecutorIdentityStore(this.identityStore);
501
+ const identityPath = resolveExecutorIdentityStorePath();
502
+ if (identityPath === this.hostIdentityStorePath) {
503
+ mkdirSync(dirname(this.hostIdentityStorePath), { recursive: true, mode: 0o700 });
504
+ const persisted = this.hostView();
505
+ if (persisted.trusted) {
506
+ const identities = persisted.identities;
507
+ for (const [id, identity] of this.identityStore)
508
+ identities.set(id, { ...identities.get(id), ...identity });
509
+ try {
510
+ saveExecutorIdentityStore(identities, this.hostIdentityStorePath, true);
511
+ }
512
+ catch (error) {
513
+ // A pre-existing non-private directory may still hold ordinary identities;
514
+ // only a store that carries host admission must be protected.
515
+ if ([...identities.values()].some(record => record.hostAdmission))
516
+ throw error;
517
+ saveExecutorIdentityStore(this.identityStore, identityPath);
518
+ }
519
+ }
520
+ else if (!persisted.untrustedHostBound.size) {
521
+ // No host admission lives here: keep the legacy ordinary identity store.
522
+ saveExecutorIdentityStore(this.identityStore, identityPath);
523
+ }
524
+ else
525
+ throw new Error('host registration store is not protected');
526
+ }
527
+ else
528
+ saveExecutorIdentityStore(this.identityStore, identityPath);
265
529
  // Emit event (for dashboard SSE, etc.)
266
530
  this.emit('executor:registered', { executorId, name: req.name });
267
531
  return {
@@ -278,6 +542,7 @@ export class ExecutorRegistry extends EventEmitter {
278
542
  const executor = this.executors.get(executorId);
279
543
  if (!executor)
280
544
  return false;
545
+ this.revokeHostAdmissionAuthority(executorId);
281
546
  // Close WS if open
282
547
  if (executor.wsConn && executor.wsConn.readyState <= 1) {
283
548
  try {
@@ -295,10 +560,21 @@ export class ExecutorRegistry extends EventEmitter {
295
560
  authenticate(executorId, token) {
296
561
  const executor = this.executors.get(executorId);
297
562
  if (executor)
298
- return executor.token === token;
563
+ return executorTokensEqual(executor.token, token);
564
+ // A host-bound ID authenticates only against the protected host record,
565
+ // never the environment/project-selectable identity store (#2923 review).
566
+ const view = this.hostView();
567
+ if (!view.trusted && this.isUntrustedHostBound(view, executorId))
568
+ return false; // unverifiable host-bound id fails closed
569
+ if (!view.trusted && [...this.admissionAuthorities.values()].some(entry => entry.executorId === executorId))
570
+ return false;
571
+ const hostRecord = view.identities.get(executorId);
572
+ if (hostRecord?.hostAdmission || [...this.admissionAuthorities.values()].some(entry => entry.executorId === executorId)) {
573
+ return executorTokensEqual(hostRecord?.token, token);
574
+ }
299
575
  // Fall back to identity store for executors that registered in a prior server run
300
576
  const stored = this.identityStore.get(executorId);
301
- return stored !== undefined && stored.token === token;
577
+ return executorTokensEqual(stored?.token, token);
302
578
  }
303
579
  /**
304
580
  * Mark the WS event stream as connected or disconnected.
@@ -695,6 +971,7 @@ export class ExecutorRegistry extends EventEmitter {
695
971
  catch { /* ignore */ }
696
972
  }
697
973
  }
974
+ this.admissionAuthorities.clear();
698
975
  this.executors.clear();
699
976
  this.missions.clear();
700
977
  this.removeAllListeners();
@@ -0,0 +1 @@
1
+ export function executorTokensEqual(expected: string | undefined | null, presented: string | undefined | null): boolean;
@@ -0,0 +1,8 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+
3
+ /** Compare bearer tokens using fixed-size digests, including unequal-length input. */
4
+ export function executorTokensEqual(expected, presented) {
5
+ if (typeof expected !== 'string' || !expected || typeof presented !== 'string' || !presented) return false;
6
+ const digest = value => createHash('sha256').update(value, 'utf8').digest();
7
+ return timingSafeEqual(digest(expected), digest(presented));
8
+ }