@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.
- package/agentic/code/addons/composition-engine/lib/runtime.mjs +1401 -0
- package/agentic/code/addons/composition-engine/lib/validator.mjs +513 -0
- package/agentic/code/addons/composition-engine/schemas/flow-graph.schema.json +372 -0
- package/agentic/code/providers/model-capabilities.v1.json +74 -50
- package/bin/aiwg.mjs +3 -1
- package/dist/src/artifacts/browser-export.js +1 -0
- package/dist/src/artifacts/citation-parser.js +8 -1
- package/dist/src/artifacts/cli.js +72 -9
- package/dist/src/artifacts/fortemi-core-query-adapter.js +2 -0
- package/dist/src/artifacts/fortemi-core-sync.js +26 -8
- package/dist/src/artifacts/graph-query.js +18 -8
- package/dist/src/artifacts/index-adapters.js +339 -0
- package/dist/src/artifacts/index-builder.js +146 -16
- package/dist/src/artifacts/index-files.js +15 -4
- package/dist/src/artifacts/index-preparation.js +91 -26
- package/dist/src/artifacts/index-reader.js +44 -0
- package/dist/src/artifacts/index-refresh.js +130 -56
- package/dist/src/artifacts/index-status.js +20 -5
- package/dist/src/artifacts/index-worker.mjs +9 -7
- package/dist/src/artifacts/memory-maintenance.js +503 -0
- package/dist/src/artifacts/prebuilt-relations.js +3 -1
- package/dist/src/artifacts/query-engine.js +263 -108
- package/dist/src/artifacts/relations-builder.js +2 -2
- package/dist/src/artifacts/source-graph.js +2 -2
- package/dist/src/artifacts/types.js +9 -1
- package/dist/src/cli/handlers/decision.js +19 -1
- package/dist/src/cli/handlers/guided-setup.js +470 -0
- package/dist/src/cli/handlers/help.js +2 -2
- package/dist/src/cli/handlers/housekeeper.js +1110 -0
- package/dist/src/cli/handlers/index.js +4 -2
- package/dist/src/cli/handlers/init.js +41 -6
- package/dist/src/cli/handlers/local-executor.js +5 -3
- package/dist/src/cli/handlers/mc.js +509 -53
- package/dist/src/cli/handlers/models.js +32 -2
- package/dist/src/cli/handlers/ralph-launcher.js +76 -4
- package/dist/src/cli/handlers/rules.js +1 -1
- package/dist/src/cli/handlers/script-runner.js +73 -2
- package/dist/src/cli/handlers/serve.js +5 -3
- package/dist/src/cli/handlers/sessions.js +40 -6
- package/dist/src/cli/handlers/setup.js +428 -1
- package/dist/src/cli/handlers/skill-lint.js +1 -1
- package/dist/src/cli/handlers/subcommands.js +118 -8
- package/dist/src/cli/handlers/use.js +279 -93
- package/dist/src/cli/handlers/workspace.js +3 -1
- package/dist/src/cli/prompt-utils.js +8 -0
- package/dist/src/cli/router.js +16 -1
- package/dist/src/config/aiwg-config.js +42 -0
- package/dist/src/config/cli.js +7 -3
- package/dist/src/config/decision-config.js +14 -1
- package/dist/src/config/native-sandbox-warnings.js +25 -0
- package/dist/src/contributors/validation.js +6 -5
- package/dist/src/decision/admission.js +7 -3
- package/dist/src/decision/benchmark.js +469 -0
- package/dist/src/decision/ledger.js +22 -3
- package/dist/src/decision/pricing.js +147 -0
- package/dist/src/decision/qualification/gate-evidence.js +18 -15
- package/dist/src/decision/qualification/load-staged.js +539 -0
- package/dist/src/decision/qualification/load.js +109 -33
- package/dist/src/decision/qualification/runner.js +5 -2
- package/dist/src/decision/review/service.js +6 -4
- package/dist/src/decision/review/validate.js +23 -2
- package/dist/src/decision/savings.js +8 -3
- package/dist/src/discovery/index.js +492 -0
- package/dist/src/discovery/interview.js +233 -0
- package/dist/src/discovery/plan-apply.js +571 -0
- package/dist/src/discovery/plan-store.js +171 -0
- package/dist/src/discovery/plan-types.js +2 -0
- package/dist/src/discovery/plan-validation.js +139 -0
- package/dist/src/discovery/types.js +2 -0
- package/dist/src/extensions/commands/definitions.js +49 -9
- package/dist/src/extensions/manifest.js +71 -44
- package/dist/src/extensions/project-local-discovery.js +1 -1
- package/dist/src/extensions/project-quickref.js +18 -17
- package/dist/src/extensions/validation.js +32 -29
- package/dist/src/housekeeper/daemon-config.js +148 -0
- package/dist/src/housekeeper/ipc.js +84 -0
- package/dist/src/housekeeper/lifecycle.js +148 -0
- package/dist/src/housekeeper/platform.js +35 -0
- package/dist/src/housekeeper/status.js +70 -0
- package/dist/src/housekeeper/units.js +114 -0
- package/dist/src/jobs/flow.js +22 -21
- package/dist/src/lint/rule-activation.js +20 -5
- package/dist/src/lint/runner.js +1 -1
- package/dist/src/mcp/cli.mjs +5 -6
- package/dist/src/mcp/config-file.mjs +6 -2
- package/dist/src/mcp/config-layers.mjs +23 -7
- package/dist/src/mcp/profiles.js +18 -31
- package/dist/src/mcp/profiles.mjs +18 -32
- package/dist/src/mcp/registry.js +7 -27
- package/dist/src/mcp/registry.mjs +7 -28
- package/dist/src/mcp/server.mjs +34 -1
- package/dist/src/mcp/tools/agentic-sandbox.mjs +8 -7
- package/dist/src/mcp/tools/command-run.mjs +3 -2
- package/dist/src/mcp/tools/decision.mjs +49 -48
- package/dist/src/mcp/tools/discovery.mjs +46 -21
- package/dist/src/mcp/tools/orchestration.mjs +22 -22
- package/dist/src/mcp/tools/subsystems.mjs +9 -8
- package/dist/src/mcp/tools/worker-invoke.mjs +17 -4
- package/dist/src/mcp/tools/writer-profiles.mjs +1 -1
- package/dist/src/memory/audit.js +719 -0
- package/dist/src/memory/canonical-context.js +392 -13
- package/dist/src/memory/canonical-maintenance.js +95 -0
- package/dist/src/memory/cli-result.js +22 -0
- package/dist/src/memory/cli.js +176 -0
- package/dist/src/memory/context-pack.js +57 -6
- package/dist/src/memory/cost.js +512 -0
- package/dist/src/memory/firewall-classification.d.mts +7 -0
- package/dist/src/memory/firewall-classification.mjs +32 -0
- package/dist/src/memory/index.js +8 -0
- package/dist/src/memory/line-maintenance.js +68 -0
- package/dist/src/memory/maintenance-cli.js +286 -0
- package/dist/src/memory/maintenance-contract.js +136 -0
- package/dist/src/memory/maintenance-index.js +18 -0
- package/dist/src/memory/maintenance-owner.js +2 -0
- package/dist/src/memory/maintenance-record.js +138 -0
- package/dist/src/memory/maintenance.js +254 -0
- package/dist/src/memory/provider-memory-adapter.js +154 -0
- package/dist/src/memory/recommend-cli.js +83 -0
- package/dist/src/memory/recommend.js +269 -0
- package/dist/src/memory/scheduled.js +81 -0
- package/dist/src/memory/taxonomy.js +44 -0
- package/dist/src/mission-protocol/codecs.js +19 -9
- package/dist/src/models/config-loader.js +11 -1
- package/dist/src/models/model-capabilities.v1.json +74 -50
- package/dist/src/models/provider-policy.js +125 -62
- package/dist/src/models/resolver.js +45 -8
- package/dist/src/models/worker-installation.d.mts +7 -0
- package/dist/src/models/worker-installation.mjs +169 -37
- package/dist/src/models/wrapper-route.js +1 -1
- package/dist/src/output-modes/registry.js +8 -7
- package/dist/src/plugin/metadata-validator.js +1 -1
- package/dist/src/providers/deploy-version.d.mts +1 -0
- package/dist/src/providers/deploy-version.mjs +17 -0
- package/dist/src/providers/provider-definitions.js +19 -16
- package/dist/src/providers/skill-ownership.d.mts +11 -2
- package/dist/src/providers/skill-ownership.mjs +35 -17
- package/dist/src/rules/activation.d.mts +11 -2
- package/dist/src/rules/activation.mjs +82 -6
- package/dist/src/rules/inventory.js +1 -1
- package/dist/src/rules/rule-frontmatter.schema.json +2 -0
- package/dist/src/schema/zod-validation.d.mts +6 -0
- package/dist/src/schema/zod-validation.mjs +50 -0
- package/dist/src/serve/executor-registry.js +297 -20
- package/dist/src/serve/executor-token.d.mts +1 -0
- package/dist/src/serve/executor-token.mjs +8 -0
- package/dist/src/serve/shared-host-scheduler.js +1102 -31
- package/dist/src/serve/worker-invocation.js +114 -15
- package/dist/src/sessions/adapters/claude.js +19 -18
- package/dist/src/sessions/adapters/codex.js +33 -19
- package/dist/src/sessions/adapters/copilot.js +14 -13
- package/dist/src/sessions/adapters/cursor.js +19 -18
- package/dist/src/sessions/adapters/deepseek-harness.js +9 -8
- package/dist/src/sessions/adapters/factory.js +11 -10
- package/dist/src/sessions/adapters/generic.js +32 -23
- package/dist/src/sessions/adapters/grokbot.js +35 -35
- package/dist/src/sessions/adapters/hermes.js +20 -19
- package/dist/src/sessions/adapters/muse.js +50 -49
- package/dist/src/sessions/adapters/omp.js +5 -4
- package/dist/src/sessions/adapters/openclaw.js +30 -29
- package/dist/src/sessions/adapters/opencode.js +26 -26
- package/dist/src/sessions/adapters/openhuman.js +24 -23
- package/dist/src/sessions/adapters/pi.js +5 -4
- package/dist/src/sessions/adapters/windsurf.js +8 -8
- package/dist/src/sessions/analytics.js +10 -9
- package/dist/src/sessions/batch-contracts.js +1 -1
- package/dist/src/sessions/batch-import.js +202 -2
- package/dist/src/sessions/candidates.js +7 -6
- package/dist/src/sessions/contracts.js +73 -54
- package/dist/src/sessions/coverage-preflight.js +26 -1
- package/dist/src/sessions/output-registration.js +11 -10
- package/dist/src/sessions/policy.js +18 -7
- package/dist/src/sessions/refresh.js +2 -3
- package/dist/src/sessions/repository.js +4 -2
- package/dist/src/sessions/workspace-discovery.js +92 -26
- package/dist/src/smiths/context-pipeline/aiwg-md.js +18 -1
- package/dist/src/smiths/context-pipeline/claude-hook.js +42 -6
- package/dist/src/smiths/context-pipeline/parallelism-section.js +72 -5
- package/dist/src/smiths/context-pipeline/workspace-context.js +106 -10
- package/dist/src/update/checker.d.mts +4 -0
- package/dist/src/update/checker.mjs +14 -0
- package/dist/src/writing/exemplar-selection.js +6 -6
- package/dist/src/writing/voice-evaluation.js +24 -23
- package/dist/src/writing/writer-migration.js +14 -13
- package/dist/src/writing/writer-profile.js +19 -18
- package/dist/src/writing/writing-brief.js +18 -17
- package/dist/src/writing/writing-receipt.js +47 -46
- package/package.json +5 -2
- package/schemas/decision/DecisionReview.schema.json +78 -1
- package/schemas/mission-v1.schema.json +3 -0
- package/tools/agents/deploy-agents.mjs +67 -39
- package/tools/agents/providers/antigravity.mjs +6 -1
- package/tools/agents/providers/base.mjs +19 -13
- package/tools/agents/providers/claude.mjs +1 -1
- package/tools/agents/providers/codex.mjs +50 -56
- package/tools/agents/providers/copilot.mjs +8 -2
- package/tools/agents/providers/cursor.mjs +1 -1
- package/tools/agents/providers/deepseek-harness.mjs +1 -1
- package/tools/agents/providers/factory.mjs +7 -1
- package/tools/agents/providers/grok-build.mjs +18 -2
- package/tools/agents/providers/grokbot.mjs +1 -1
- package/tools/agents/providers/hermes.mjs +7 -13
- package/tools/agents/providers/muse-hooks.mjs +21 -14
- package/tools/agents/providers/muse.mjs +7 -2
- package/tools/agents/providers/omp.mjs +28 -4
- package/tools/agents/providers/opencode.mjs +14 -5
- package/tools/agents/providers/openhuman.mjs +1 -1
- package/tools/agents/providers/pi.mjs +1 -1
- package/tools/agents/providers/provider-prepare-coordinator.mjs +29 -9
- package/tools/agents/providers/rule-delivery.mjs +263 -71
- package/tools/agents/providers/windsurf.mjs +7 -0
- package/tools/cli/workspace-status.mjs +5 -0
- package/tools/commands/deploy-prompts-codex.mjs +11 -8
- package/tools/ralph-external/lib/codex-adapter.mjs +6 -2
- package/tools/ralph-external/lib/muse-adapter.mjs +12 -2
- package/tools/ralph-external/lib/worker-health.mjs +1 -1
- package/tools/ralph-external/lib/worker-isolation.mjs +65 -9
- package/tools/ralph-external/process-monitor.mjs +7 -4
- package/tools/ralph-external/session-launcher.mjs +280 -32
- package/tools/skills/deploy-skills-codex.mjs +11 -7
- 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(
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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));
|
|
@@ -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
|
-
|
|
160
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
216
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|