chati-dev 4.5.28 → 4.5.29

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 (172) hide show
  1. package/bin/chati.js +151 -123
  2. package/framework/agents/build/dev.md +4 -4
  3. package/framework/agents/deploy/devops.md +3 -3
  4. package/framework/agents/discover/brief.md +18 -6
  5. package/framework/agents/discover/brownfield-wu.md +3 -3
  6. package/framework/agents/discover/greenfield-wu.md +4 -4
  7. package/framework/agents/plan/architect.md +2 -2
  8. package/framework/agents/plan/detail.md +4 -4
  9. package/framework/agents/plan/phases.md +3 -3
  10. package/framework/agents/plan/tasks.md +47 -19
  11. package/framework/agents/plan/ux-brand-architect.md +1 -1
  12. package/framework/agents/plan/ux.md +26 -2
  13. package/framework/agents/quality/qa-implementation.md +8 -8
  14. package/framework/agents/quality/qa-planning.md +58 -4
  15. package/framework/agents/quality/qa-visual.md +4 -4
  16. package/framework/config.yaml +2 -2
  17. package/framework/constitution.md +12 -1
  18. package/framework/context/governance.md +2 -1
  19. package/framework/context/root.md +3 -1
  20. package/framework/domains/constitution.yaml +1 -1
  21. package/framework/domains/global.yaml +1 -1
  22. package/framework/domains/workflows/standard-flow.yaml +7 -8
  23. package/framework/hooks/advance-trigger.js +3 -2
  24. package/framework/hooks/brief-validator.js +1 -1
  25. package/framework/hooks/post-dev.js +1 -1
  26. package/framework/hooks/team-quality-gate.js +9 -2
  27. package/framework/i18n/en.yaml +1 -0
  28. package/framework/i18n/es.yaml +1 -0
  29. package/framework/i18n/fr.yaml +1 -0
  30. package/framework/i18n/pt.yaml +1 -0
  31. package/framework/manifest.json +83 -83
  32. package/framework/manifest.sig +1 -1
  33. package/framework/orchestrator/chati-router.js +14 -6
  34. package/framework/orchestrator/chati.md +61 -23
  35. package/framework/rules/human-writing-style.md +7 -0
  36. package/framework/schemas/session.schema.json +25 -2
  37. package/framework/schemas/task.schema.json +26 -0
  38. package/framework/tasks/orchestrator-handoff.md +10 -10
  39. package/framework/tasks/orchestrator-resume.md +3 -3
  40. package/framework/workflows/brownfield-fullstack.yaml +6 -3
  41. package/framework/workflows/brownfield-service.yaml +5 -3
  42. package/framework/workflows/brownfield-ui.yaml +5 -3
  43. package/framework/workflows/greenfield-fullstack.yaml +6 -3
  44. package/framework/workflows/standard-flow.yaml +8 -3
  45. package/node_modules/@chati/core/native/browser-proof-wire.h +29 -0
  46. package/node_modules/@chati/core/native/darwin-browser-listener.c +36 -0
  47. package/node_modules/@chati/core/native/darwin-browser-listener.h +32 -0
  48. package/node_modules/@chati/core/native/darwin-browser-proof.h +86 -0
  49. package/node_modules/@chati/core/native/darwin-code-identity.h +35 -0
  50. package/node_modules/@chati/core/native/darwin-launch-messages.h +37 -0
  51. package/node_modules/@chati/core/native/darwin-launcher.c +312 -0
  52. package/node_modules/@chati/core/native/descriptor-fs.c +407 -0
  53. package/node_modules/@chati/core/native/execution-admission.h +59 -0
  54. package/node_modules/@chati/core/native/execution-gate.c +41 -0
  55. package/node_modules/@chati/core/native/linux-process.c +41 -0
  56. package/node_modules/@chati/core/native/prebuilds/darwin-arm64/darwin-launcher +0 -0
  57. package/node_modules/@chati/core/native/prebuilds/darwin-arm64/descriptor-fs.node +0 -0
  58. package/node_modules/@chati/core/native/prebuilds/darwin-arm64/execution-gate +0 -0
  59. package/node_modules/@chati/core/native/prebuilds/linux-x64/descriptor-fs.node +0 -0
  60. package/node_modules/@chati/core/native/prebuilds/linux-x64/execution-gate +0 -0
  61. package/node_modules/@chati/core/native/process-info-pipe.c +91 -0
  62. package/node_modules/@chati/core/package.json +2 -1
  63. package/node_modules/@chati/core/scripts/build-native.js +36 -0
  64. package/node_modules/@chati/core/src/darwin-browser-proof.js +87 -0
  65. package/node_modules/@chati/core/src/darwin-host-boot.js +58 -0
  66. package/node_modules/@chati/core/src/darwin-process.js +79 -0
  67. package/node_modules/@chati/core/src/darwin-supervisor-channels.js +82 -0
  68. package/node_modules/@chati/core/src/darwin-terminal-receipt.js +111 -0
  69. package/node_modules/@chati/core/src/descriptor-fs.js +222 -0
  70. package/node_modules/@chati/core/src/execution-gate.js +8 -0
  71. package/node_modules/@chati/core/src/host-boot-marker.js +45 -0
  72. package/node_modules/@chati/core/src/index.js +18 -0
  73. package/node_modules/@chati/core/src/linux-host-boot.js +218 -0
  74. package/node_modules/@chati/core/src/linux-process.js +133 -0
  75. package/node_modules/@chati/planning/src/browser-verification-plan.js +71 -0
  76. package/node_modules/@chati/planning/src/index.js +33 -0
  77. package/node_modules/@chati/planning/src/planning-material.js +64 -0
  78. package/node_modules/@chati/planning/src/verification-plan.js +57 -0
  79. package/node_modules/@chati/rail/package.json +1 -0
  80. package/node_modules/@chati/rail/src/execution-restoration.js +92 -0
  81. package/node_modules/@chati/rail/src/index.js +812 -167
  82. package/node_modules/@chati/rail/src/provider-processing.js +118 -0
  83. package/node_modules/@chati/rail/src/terminal-containment.js +23 -0
  84. package/node_modules/@chati/release-lane/src/index.js +40 -43
  85. package/node_modules/@chati/review-council/src/index.js +223 -0
  86. package/node_modules/@chati/tracking-clickup/src/index.js +67 -58
  87. package/package-artifact-manifest.json +1 -1
  88. package/package-artifact-manifest.sig +1 -1
  89. package/package.json +3 -2
  90. package/scripts/verify-real-harness-e2e.js +20 -8
  91. package/src/installer/core.js +147 -107
  92. package/src/installer/package-artifact.js +7 -0
  93. package/src/installer/preflight.js +13 -0
  94. package/src/installer/provider-overlay.js +1 -2
  95. package/src/installer/templates.js +58 -0
  96. package/src/installer-v2/catalog-client.js +31 -1
  97. package/src/installer-v2/clickup-connection-storage.js +92 -0
  98. package/src/installer-v2/clickup-mcp-client.js +155 -0
  99. package/src/installer-v2/clickup-oauth-loopback.js +96 -0
  100. package/src/installer-v2/clickup-oauth.js +233 -0
  101. package/src/installer-v2/index.js +141 -15
  102. package/src/installer-v2/installation-authority.js +98 -4
  103. package/src/installer-v2/provider-executable.js +117 -12
  104. package/src/installer-v2/wizard-installation.js +11 -0
  105. package/src/orchestrator/agent-selector.js +39 -1
  106. package/src/orchestrator/browser-artifact-store.js +131 -0
  107. package/src/orchestrator/cli.js +762 -205
  108. package/src/orchestrator/clickup-mcp-observer.js +226 -0
  109. package/src/orchestrator/clickup-runtime.js +45 -24
  110. package/src/orchestrator/council-gate-runtime.js +903 -0
  111. package/src/orchestrator/evidence-authority-enrollment.js +75 -0
  112. package/src/orchestrator/evidence-authority-request.js +46 -0
  113. package/src/orchestrator/handoff-engine.js +4 -11
  114. package/src/orchestrator/pipeline-manager.js +42 -4
  115. package/src/orchestrator/planning-contract-revision.js +711 -0
  116. package/src/orchestrator/planning-reconciliation.js +302 -0
  117. package/src/orchestrator/planning-runtime.js +476 -29
  118. package/src/orchestrator/rail-adjudication-evidence.js +15 -24
  119. package/src/orchestrator/rail-human-effort-authority.js +46 -0
  120. package/src/orchestrator/rail-human-effort.js +149 -0
  121. package/src/orchestrator/rail-runtime.js +291 -47
  122. package/src/orchestrator/rail-verification.js +278 -0
  123. package/src/orchestrator/review-runtime.js +37 -2
  124. package/src/orchestrator/runtime-installation-v2.js +143 -24
  125. package/src/orchestrator/session-manager.js +1457 -29
  126. package/src/tasks/handoff.js +57 -33
  127. package/src/terminal/adapters/claude-adapter.js +3 -0
  128. package/src/terminal/adapters/codex-adapter.js +13 -0
  129. package/src/terminal/adapters/grok-adapter.js +1 -0
  130. package/src/terminal/agent-attempt-budget.js +35 -0
  131. package/src/terminal/browser-endpoint-proxy.js +174 -0
  132. package/src/terminal/browser-observer-driver.js +94 -0
  133. package/src/terminal/browser-observer-supervisor.js +73 -0
  134. package/src/terminal/browser-observer.js +95 -0
  135. package/src/terminal/browser-process-facts.js +75 -0
  136. package/src/terminal/browser-runtime.js +131 -0
  137. package/src/terminal/darwin-authority-policy.js +213 -0
  138. package/src/terminal/darwin-browser-application.js +116 -0
  139. package/src/terminal/darwin-browser-observer.js +57 -0
  140. package/src/terminal/darwin-execution.js +185 -0
  141. package/src/terminal/executable-snapshot.js +202 -0
  142. package/src/terminal/handoff-parser.js +48 -2
  143. package/src/terminal/isolation.js +14 -13
  144. package/src/terminal/prompt-builder.js +176 -15
  145. package/src/terminal/provider-preflight.js +47 -27
  146. package/src/terminal/rail-execution-worktree.js +22 -6
  147. package/src/terminal/rail-process-observer.js +10 -0
  148. package/src/terminal/rail-prompts.js +21 -3
  149. package/src/terminal/rail-readonly-workspace.js +54 -5
  150. package/src/terminal/rail-worktree-checkpoint.js +315 -0
  151. package/src/terminal/rail-worktree-restoration.js +485 -0
  152. package/src/terminal/run-agent.js +164 -42
  153. package/src/terminal/run-council-gate.js +338 -0
  154. package/src/terminal/run-planning-reconciliation.js +49 -0
  155. package/src/terminal/run-rail-adjudication.js +29 -7
  156. package/src/terminal/run-rail-recovery.js +97 -0
  157. package/src/terminal/run-rail-review.js +36 -15
  158. package/src/terminal/run-rail-rework.js +26 -15
  159. package/src/terminal/run-rail-task.js +22 -9
  160. package/src/terminal/runtime-native-helper.js +85 -0
  161. package/src/terminal/spawner.js +899 -142
  162. package/src/terminal/verification-process-facts.js +49 -0
  163. package/src/upgrade/entrypoints.js +68 -0
  164. package/src/upgrade/generation.js +194 -0
  165. package/src/upgrade/maintenance.js +79 -0
  166. package/src/upgrade/paths.js +81 -0
  167. package/src/upgrade/upgrade.js +267 -0
  168. package/src/utils/execution-role.js +15 -0
  169. package/src/utils/handoff-paths.js +59 -0
  170. package/src/wizard/i18n.js +1 -0
  171. package/src/wizard/index.js +17 -5
  172. package/src/wizard/questions.js +24 -12
@@ -0,0 +1,116 @@
1
+ /** Trusted application/proxy lifetime, not an evidence issuer. Application
2
+ * stdout is never an ownership, readiness or completion message. */
3
+ import { spawn } from 'node:child_process';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { realpathSync } from 'node:fs';
6
+ import { isAbsolute } from 'node:path';
7
+ import { sha256 } from '@chati/core';
8
+ import { validateBrowserObservationPlan } from '@chati/planning';
9
+ import { prepareDarwinBrowserApplicationLaunch, assertDarwinSealedLaunch, disposeDarwinSealedLaunch } from './darwin-execution.js';
10
+ import { createVerifiedBrowserProxy } from './browser-endpoint-proxy.js';
11
+ import { createDarwinVerificationBinding } from './verification-process-facts.js';
12
+
13
+ function error(code) { return Object.assign(new Error(code), { code }); }
14
+
15
+ export async function startDarwinBrowserApplication(input) {
16
+ if (!input || Object.keys(input).sort().join(',') !== 'observerDirectory,plan,workingDir'
17
+ || typeof input.workingDir !== 'string' || !isAbsolute(input.workingDir)
18
+ || typeof input.observerDirectory !== 'string' || !isAbsolute(input.observerDirectory)) throw error('BROWSER_APPLICATION_CONFIGURATION_INVALID');
19
+ const plan = structuredClone(validateBrowserObservationPlan(input.plan));
20
+ if (plan.application.listener_transport !== 'darwin_prebound_tcp_v1') throw error('BROWSER_APPLICATION_LISTENER_TRANSPORT_REQUIRED');
21
+ const workingDir = realpathSync(input.workingDir);
22
+ const context = Object.freeze({ authority_kind: 'verification_check', invocation_id: randomUUID(),
23
+ launch_nonce: randomUUID(), executable_binding_digest: sha256(plan.application.executable_binding) });
24
+ let launch; let proxy; let child; let ready; let timer; let escalation; let drainTimer;
25
+ let setupError; let spawnError; let inputError = null; let outputBytes = 0;
26
+ let timedOut = false; let outputLimitExceeded = false; let requestedStop = false; let finalizing = false;
27
+ let resolveFinished;
28
+ const finished = new Promise(resolve => { resolveFinished = resolve; });
29
+ function terminate(signal = 'SIGTERM') {
30
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
31
+ // Signal only our unreaped helper. Its native owner/reaper handles the
32
+ // admitted application, not a fresh caller-side PID/group lookup.
33
+ try { child.kill(signal); } catch (failure) { spawnError ??= failure; }
34
+ if (!escalation) escalation = setTimeout(() => {
35
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
36
+ }, 2000);
37
+ }
38
+ async function finalize(exitCode, signal, failure) {
39
+ if (finalizing) return;
40
+ finalizing = true;
41
+ clearTimeout(timer); clearTimeout(escalation); clearTimeout(drainTimer);
42
+ failure ??= setupError ?? spawnError;
43
+ let completion; let binding;
44
+ try {
45
+ await proxy?.close();
46
+ if (!failure) {
47
+ completion = launch.supervisorChannels.readCompletion(exitCode);
48
+ assertDarwinSealedLaunch(launch);
49
+ binding = createDarwinVerificationBinding({ context, helperSha256: launch.helperSha256,
50
+ policyDigest: launch.policyDigest, ready, completion, workingDir,
51
+ executableBinding: plan.application.executable_binding, args: plan.application.args, stdin: plan.application.stdin });
52
+ }
53
+ } catch (caught) { failure ??= caught; }
54
+ try { disposeDarwinSealedLaunch(launch); } catch (caught) { failure ??= caught; }
55
+ // EOF callbacks on different descriptors have no ordering guarantee.
56
+ // Report closure after our own explicit revocation, not before cleanup.
57
+ const proof = launch.browserProofChannels.summary();
58
+ resolveFinished(Object.freeze({ containment_kind: 'darwin_scoped_process_group_v1',
59
+ process_tree_stopped: false, process_group_stopped: Boolean(completion), detached_descendants: 'unknown',
60
+ exit_code: exitCode, signal, timed_out: timedOut, output_limit_exceeded: outputLimitExceeded,
61
+ input_error: inputError, discarded_output_bytes: outputBytes, requested_stop: requestedStop,
62
+ error_code: failure ? failure.code || 'BROWSER_APPLICATION_COMPLETION_INVALID' : null,
63
+ listener_transport: plan.application.listener_transport,
64
+ executable_binding: Object.freeze(plan.application.executable_binding),
65
+ stdin_sha256: createHash('sha256').update(plan.application.stdin).digest('hex'),
66
+ containment_digest: sha256({ role: 'browser-application', helper_sha256: launch.helperSha256,
67
+ policy_digest: launch.policyDigest, native_nonce: context.launch_nonce }),
68
+ darwin_verification_binding: binding ?? null, endpoint_proof: proof, proxy: proxy.facts() }));
69
+ }
70
+ try {
71
+ launch = prepareDarwinBrowserApplicationLaunch({ binding: plan.application.executable_binding,
72
+ projectDir: workingDir, args: plan.application.args, authorityContext: context,
73
+ applicationPort: plan.application.port, listenerTransport: plan.application.listener_transport, observerDirectory: input.observerDirectory });
74
+ proxy = await createVerifiedBrowserProxy({ hostname: plan.hostname, applicationPort: plan.application.port,
75
+ timeoutMs: plan.timeout_ms, verifyPeer: tuple => launch.browserProofChannels.verifyPeer(tuple) });
76
+ assertDarwinSealedLaunch(launch);
77
+ child = spawn(launch.command, launch.args, { cwd: '/', detached: true,
78
+ env: { PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', TZ: 'UTC',
79
+ HOME: launch.temporaryDirectory, TMPDIR: launch.temporaryDirectory,
80
+ PORT: String(plan.application.port), HOST: '127.0.0.1', CHATI_APPLICATION_LISTENER_FD: '11' },
81
+ stdio: ['pipe', 'pipe', 'pipe', 'ignore', launch.projectDescriptor,
82
+ ...launch.supervisorChannels.inheritedDescriptors, ...launch.browserProofChannels.inheritedDescriptors, launch.browserListenerDescriptor] });
83
+ for (const stream of ['stdout', 'stderr']) child[stream].on('data', bytes => {
84
+ outputBytes += bytes.length;
85
+ if (outputBytes > 2 * 1024 * 1024 && !outputLimitExceeded) { outputLimitExceeded = true; terminate(); }
86
+ });
87
+ child.once('error', failure => { spawnError = failure; });
88
+ child.stdin.on('error', failure => { inputError = failure.code || failure.name; });
89
+ child.once('exit', (code, signal) => {
90
+ clearTimeout(timer); clearTimeout(escalation);
91
+ drainTimer = setTimeout(() => {
92
+ child.stdout.destroy(); child.stderr.destroy(); child.stdin.destroy();
93
+ void finalize(code, signal, error('BROWSER_APPLICATION_OUTPUT_NOT_CLOSED'));
94
+ }, 2000);
95
+ });
96
+ child.once('close', (code, signal) => { void finalize(code, signal); });
97
+ timer = setTimeout(() => { timedOut = true; terminate(); }, plan.timeout_ms);
98
+ launch.browserProofChannels.afterSpawn();
99
+ ready = launch.supervisorChannels.readReady(child.pid, launch.verifiedCdhash);
100
+ assertDarwinSealedLaunch(launch);
101
+ launch.supervisorChannels.authorize();
102
+ child.stdin.end(plan.application.stdin);
103
+ return Object.freeze({ proxyUrl: proxy.serverUrl, proxyPort: proxy.port, finished,
104
+ // Caller closes the browser first, then the proxy/application, and consumes
105
+ // private completion. No recursion through app-mutated TMP or subject.
106
+ async stop() { requestedStop = true; terminate(); await proxy.close(); return finished; } });
107
+ } catch (failure) {
108
+ setupError = failure;
109
+ if (child) { terminate('SIGKILL'); await finished; }
110
+ else {
111
+ await proxy?.close();
112
+ if (launch) disposeDarwinSealedLaunch(launch);
113
+ }
114
+ throw failure;
115
+ }
116
+ }
@@ -0,0 +1,57 @@
1
+ /** Host-owned composition. Application and observer have separate sandboxes;
2
+ * only this host process owns the endpoint proof and proxy. */
3
+ import { mkdtempSync, realpathSync, rmdirSync } from 'node:fs';
4
+ import { request } from 'node:http';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { setTimeout as delay } from 'node:timers/promises';
8
+ import { startDarwinBrowserApplication } from './darwin-browser-application.js';
9
+ import { runIsolatedDarwinBrowserObserver } from './spawner.js';
10
+ import { readDarwinBrowserRendezvousNamespace } from './browser-runtime.js';
11
+ import { darwinBrowserObserverInvocation } from './browser-process-facts.js';
12
+
13
+ function fail(code) { throw Object.assign(new Error(code), { code }); }
14
+ function ready(handle, plan) {
15
+ return new Promise(resolve => {
16
+ const probe = request({ hostname: '127.0.0.1', port: handle.proxyPort, method: 'GET',
17
+ path: `http://${plan.hostname}:${plan.application.port}${plan.ready_path}`,
18
+ headers: { host: `${plan.hostname}:${plan.application.port}` }, timeout: 2200 }, response => {
19
+ const ok = response.statusCode >= 200 && response.statusCode < 400;
20
+ response.destroy(); resolve(ok);
21
+ });
22
+ probe.on('timeout', () => probe.destroy()); probe.on('error', () => resolve(false)); probe.end();
23
+ });
24
+ }
25
+
26
+ export async function runDarwinBrowserObservation({ workingDir, runtimeDirectory, browserFile, nodeBinding, plan }) {
27
+ const observerDir = mkdtempSync(join(realpathSync(tmpdir()), 'chati-browser-observer-subject-'));
28
+ const applicationLifetime = new AbortController();
29
+ let app; let appFacts; let facts; let invocation; let baseBundleId; let failure;
30
+ try {
31
+ baseBundleId = readDarwinBrowserRendezvousNamespace(join(runtimeDirectory, 'chromium', browserFile));
32
+ app = await startDarwinBrowserApplication({ workingDir, observerDirectory: runtimeDirectory, plan });
33
+ void app.finished.then(() => applicationLifetime.abort());
34
+ const deadline = performance.now() + Math.min(30000, plan.timeout_ms);
35
+ while (!await ready(app, plan)) {
36
+ if (applicationLifetime.signal.aborted) fail('BROWSER_APPLICATION_EXITED');
37
+ if (performance.now() >= deadline) fail('BROWSER_APPLICATION_NOT_READY');
38
+ await delay(25);
39
+ }
40
+ invocation = darwinBrowserObserverInvocation({ workingDir: observerDir, runtimeDirectory, browserFile,
41
+ proxyPort: app.proxyPort, nodeBinding, plan });
42
+ facts = await runIsolatedDarwinBrowserObserver({ ...invocation, observerDirectory: runtimeDirectory,
43
+ applicationProject: workingDir, proxyPort: app.proxyPort, baseBundleId, applicationSignal: applicationLifetime.signal });
44
+ if (applicationLifetime.signal.aborted) fail('BROWSER_APPLICATION_EXITED');
45
+ } catch (error) { failure = error; }
46
+ finally {
47
+ try { if (app) appFacts = await app.stop(); } catch (error) { failure ??= error; }
48
+ // Unknown detached descendants do not justify traversing a mutable tree.
49
+ try { rmdirSync(observerDir); } catch (error) { if (error.code !== 'ENOTEMPTY') failure ??= error; }
50
+ }
51
+ if (failure) throw failure;
52
+ // base_bundle_id names Chromium's BaseBundleID rendezvous namespace. A flat
53
+ // headless shell does not acquire fictitious application bundle metadata.
54
+ return { facts, invocation, binding: { schema_version: 1, runtime_directory: runtimeDirectory,
55
+ observer_working_dir: observerDir, browser_file: browserFile, proxy_port: app.proxyPort,
56
+ base_bundle_id: baseBundleId, application: appFacts } };
57
+ }
@@ -0,0 +1,185 @@
1
+ /** Sealed Darwin launch preparation. Receipt authority remains in the spawner. */
2
+ import { spawnSync } from 'node:child_process';
3
+ import { closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, realpathSync, rmdirSync } from 'node:fs';
4
+ import { homedir, tmpdir } from 'node:os';
5
+ import { dirname, isAbsolute, join } from 'node:path';
6
+ import { createDarwinBrowserListenerSync, inspectDarwinBrowserListenerSync, createDarwinBrowserProofChannels, createDarwinSupervisorChannels, sha256 } from '@chati/core';
7
+ import { installationAuthorityStateDirectory } from '../installer-v2/installation-authority.js';
8
+ import { assertExecutableSnapshot, disposeExecutableSnapshot, prepareExecutableSnapshot, prepareRuntimeNativeHelperSnapshot } from './executable-snapshot.js';
9
+ import { darwinAuthorityProfile, darwinBrowserApplicationProfile, darwinBrowserObserverProfile, darwinVerificationProfile } from './darwin-authority-policy.js';
10
+
11
+ const RECORDS = new WeakMap();
12
+ function fail(message) { throw Object.assign(new Error(message), { code: 'DARWIN_SEALED_EXECUTION_UNAVAILABLE' }); }
13
+ function quote(path) {
14
+ if (typeof path !== 'string' || !isAbsolute(path)
15
+ || Array.from(path).some((character) => character.codePointAt(0) < 32 || character.codePointAt(0) === 127)) {
16
+ fail('sandbox paths must be absolute and contain no control characters');
17
+ }
18
+ return JSON.stringify(path);
19
+ }
20
+ export function darwinSealedPlanningProfile({ snapshotDirectory, authorityDirectory, promptDirectory, launcherPath, launcherDirectory }) {
21
+ // This preserves the existing non-authority Planning execution contract.
22
+ // The later authority-bearing backend requires its own scoped policy/receipt.
23
+ const protectedPaths = [snapshotDirectory, authorityDirectory, promptDirectory, launcherPath, launcherDirectory].filter(Boolean);
24
+ const ancestors = new Set();
25
+ for (const path of protectedPaths) {
26
+ quote(path);
27
+ for (let parent = dirname(path); ; parent = dirname(parent)) {
28
+ ancestors.add(parent);
29
+ if (parent === dirname(parent)) break;
30
+ }
31
+ }
32
+ return `(version 1) (allow default)\n(deny file-write* (subpath ${quote(snapshotDirectory)}))\n(deny file-read* file-write* (subpath ${quote(authorityDirectory)}))\n`
33
+ + (promptDirectory ? `(deny file-write* (subpath ${quote(promptDirectory)}))\n` : '')
34
+ + (launcherPath ? `(deny file-write* (literal ${quote(launcherPath)}))\n` : '')
35
+ + (launcherDirectory ? `(deny file-write* (subpath ${quote(launcherDirectory)}))\n` : '')
36
+ // A subpath rule alone does not stop renaming a writable ancestor and
37
+ // accessing the same image through its new, unprotected pathname.
38
+ + [...ancestors].map((path) => `(deny file-write* (literal ${quote(path)}))\n`).join('');
39
+ }
40
+ function projectIdentity(stat) { return { dev: stat.dev, ino: stat.ino, uid: stat.uid, mode: stat.mode }; }
41
+ function matches(left, right) { return Object.keys(left).every((key) => left[key] === right[key]); }
42
+
43
+ export function prepareDarwinSealedLaunch(options) { return prepareDarwinLaunch(options, false); }
44
+ export function prepareDarwinAuthorityLaunch(options) { return prepareDarwinLaunch(options, true); }
45
+ export function prepareDarwinBrowserApplicationLaunch({ applicationPort, listenerTransport, observerDirectory, ...options }) {
46
+ if (listenerTransport !== 'darwin_prebound_tcp_v1') fail('Darwin browser application requires an explicit prebound TCP plan');
47
+ return prepareDarwinLaunch({ ...options, verificationIsolation: true, hasPrompt: false }, true,
48
+ { applicationPort, observerDirectory });
49
+ }
50
+ export function prepareDarwinBrowserObserverLaunch({ proxyPort, observerDirectory, applicationProject, baseBundleId, ...options }) {
51
+ return prepareDarwinLaunch({ ...options, verificationIsolation: true, hasPrompt: false }, true, null,
52
+ { proxyPort, observerDirectory, applicationProject, baseBundleId });
53
+ }
54
+
55
+ function disposeTemporary(state) {
56
+ if (!state) return;
57
+ try {
58
+ if (!matches(state.identity, projectIdentity(fstatSync(state.descriptor)))
59
+ || !matches(state.identity, projectIdentity(lstatSync(state.path)))) fail('private runtime directory changed before cleanup');
60
+ // A detached descendant may outlive the observed group. Never recursively
61
+ // traverse its mutable temporary tree or turn group-empty into quiescence.
62
+ try { rmdirSync(state.path); } catch (error) { if (error.code !== 'ENOTEMPTY') throw error; }
63
+ } finally { closeSync(state.descriptor); }
64
+ }
65
+
66
+ function prepareDarwinLaunch({ binding, harnessId, projectDir, args, hasPrompt = false, promptDirectory, temporaryRoot,
67
+ authorityContext, stateDirectory, writeScopes, maskedReadPaths = [], councilToolIsolation = false, verificationIsolation = false,
68
+ subjectReadOnly = false }, authority, browserApplication = null, browserObserver = null) {
69
+ if (process.platform !== 'darwin') fail('Darwin sealed launch requires an actual macOS host');
70
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string' || arg.includes('\0'))) fail('provider arguments must be an array of strings');
71
+ const project = realpathSync(projectDir);
72
+ let snapshot;
73
+ let descriptor;
74
+ let helperSnapshot;
75
+ let channels;
76
+ let browserProof;
77
+ let browserListener;
78
+ let temporary;
79
+ try {
80
+ helperSnapshot = prepareRuntimeNativeHelperSnapshot('darwin-launcher', { projectDir: project, temporaryRoot });
81
+ const launcher = helperSnapshot.executablePath;
82
+ snapshot = prepareExecutableSnapshot(binding, { harnessId, projectDir: project, temporaryRoot, requireNativeImage: verificationIsolation });
83
+ descriptor = openSync(project, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
84
+ const identity = projectIdentity(fstatSync(descriptor));
85
+ if (!matches(identity, projectIdentity(lstatSync(project)))) fail('project changed before descriptor binding');
86
+ assertExecutableSnapshot(helperSnapshot);
87
+ const inspected = spawnSync(launcher, ['inspect', snapshot.executablePath], {
88
+ encoding: 'utf8', timeout: 10000, maxBuffer: 65536, cwd: '/',
89
+ env: { PATH: '/usr/bin:/bin' }, stdio: ['ignore', 'pipe', 'pipe'],
90
+ });
91
+ if (inspected.status !== 0 || inspected.error || inspected.signal) {
92
+ fail(`the staged image has no usable native code identity: ${inspected.stderr?.trim() || inspected.error?.code || inspected.status}`);
93
+ }
94
+ let information;
95
+ try { information = JSON.parse(inspected.stdout); } catch { fail('Darwin image inspection returned invalid output'); }
96
+ if (!information || typeof information !== 'object' || Array.isArray(information)
97
+ || Object.keys(information).sort().join(',') !== 'cdhash,schema_version'
98
+ || information.schema_version !== 1 || !/^[a-f0-9]{40}$/.test(information.cdhash)) fail('Darwin image inspection returned invalid identity');
99
+ let profile;
100
+ if (authority) {
101
+ channels = createDarwinSupervisorChannels(authorityContext);
102
+ if (browserApplication) {
103
+ browserProof = createDarwinBrowserProofChannels(channels.nonce);
104
+ const fd = createDarwinBrowserListenerSync(browserApplication.applicationPort);
105
+ browserListener = { fd, port: browserApplication.applicationPort };
106
+ browserListener.generation = inspectDarwinBrowserListenerSync(fd, browserListener.port);
107
+ }
108
+ const path = mkdtempSync(join(realpathSync(temporaryRoot ?? tmpdir()), 'chati-runtime-'));
109
+ const temporaryDescriptor = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
110
+ temporary = { path, descriptor: temporaryDescriptor, identity: projectIdentity(fstatSync(temporaryDescriptor)) };
111
+ const profileInputs = { project,
112
+ temporaryDirectory: path, snapshotDirectory: snapshot.directory, launcherDirectory: helperSnapshot.directory,
113
+ launcherPath: launcher, executablePath: snapshot.executablePath,
114
+ supportingExecutablePaths: snapshot.supportingExecutables.map(entry => entry.executable_path),
115
+ authorityDirectory: installationAuthorityStateDirectory(), homeDirectory: realpathSync(homedir()),
116
+ promptDirectory: promptDirectory ? realpathSync(promptDirectory) : undefined,
117
+ writeScopes, maskedReadPaths, councilToolIsolation };
118
+ profile = browserApplication ? darwinBrowserApplicationProfile({ ...profileInputs,
119
+ applicationPort: browserApplication.applicationPort, observerDirectory: realpathSync(browserApplication.observerDirectory) })
120
+ : browserObserver ? darwinBrowserObserverProfile({ ...profileInputs, ...browserObserver,
121
+ observerDirectory: realpathSync(browserObserver.observerDirectory), applicationProject: realpathSync(browserObserver.applicationProject) })
122
+ : verificationIsolation ? darwinVerificationProfile({ ...profileInputs, subjectReadOnly })
123
+ : darwinAuthorityProfile({ ...profileInputs, stateDirectory: realpathSync(stateDirectory) });
124
+ } else {
125
+ profile = darwinSealedPlanningProfile({ snapshotDirectory: snapshot.directory,
126
+ authorityDirectory: installationAuthorityStateDirectory(), promptDirectory, launcherPath: launcher,
127
+ launcherDirectory: helperSnapshot.directory });
128
+ }
129
+ const record = Object.freeze({
130
+ command: '/usr/bin/sandbox-exec',
131
+ args: Object.freeze(['-p', profile, launcher, browserApplication ? 'launch-browser-prebound-admitted' : authority ? 'launch-admitted' : 'launch', information.cdhash, snapshot.executablePath,
132
+ hasPrompt ? '1' : '0', ...(channels ? [channels.nonce] : []), ...(browserListener ? [String(browserListener.port)] : []), '--', ...args]),
133
+ projectDescriptor: descriptor,
134
+ executableSnapshot: snapshot,
135
+ ...(authority ? { supervisorChannels: channels, verifiedCdhash: information.cdhash,
136
+ helperSha256: helperSnapshot.executableSha256, policyDigest: sha256(profile), temporaryDirectory: temporary.path } : {}),
137
+ ...(browserProof ? { browserProofChannels: browserProof, browserListenerDescriptor: browserListener.fd } : {}),
138
+ kind: authority ? 'darwin_scoped_process_group_v1' : 'darwin_sealed_planning_v1',
139
+ });
140
+ RECORDS.set(record, { descriptor, snapshot, project, identity, helperSnapshot, channels, browserProof, browserListener, temporary });
141
+ assertDarwinSealedLaunch(record);
142
+ return record;
143
+ } catch (error) {
144
+ if (browserListener) { try { closeSync(browserListener.fd); } catch (cleanupError) { error.listenerCleanupError = cleanupError; } }
145
+ if (browserProof) { try { browserProof.dispose(); } catch (cleanupError) { error.browserProofCleanupError = cleanupError; } }
146
+ if (channels) { try { channels.dispose(); } catch (cleanupError) { error.channelCleanupError = cleanupError; } }
147
+ if (temporary) { try { disposeTemporary(temporary); } catch (cleanupError) { error.temporaryCleanupError = cleanupError; } }
148
+ if (descriptor !== undefined) closeSync(descriptor);
149
+ if (snapshot) { try { disposeExecutableSnapshot(snapshot); } catch (cleanupError) { error.snapshotCleanupError = cleanupError; } }
150
+ if (helperSnapshot) { try { disposeExecutableSnapshot(helperSnapshot); } catch (cleanupError) { error.helperCleanupError = cleanupError; } }
151
+ throw error;
152
+ }
153
+ }
154
+
155
+ export function assertDarwinSealedLaunch(record) {
156
+ const state = RECORDS.get(record);
157
+ if (!state) fail('Darwin launch is unknown or disposed');
158
+ assertExecutableSnapshot(state.snapshot);
159
+ assertExecutableSnapshot(state.helperSnapshot);
160
+ if (state.browserListener && inspectDarwinBrowserListenerSync(state.browserListener.fd, state.browserListener.port) !== state.browserListener.generation) fail('prebound browser listener changed before Darwin launch');
161
+ if (!matches(state.identity, projectIdentity(fstatSync(state.descriptor)))
162
+ || !matches(state.identity, projectIdentity(lstatSync(state.project)))) fail('project directory changed before Darwin launch');
163
+ if (state.temporary && (!matches(state.temporary.identity, projectIdentity(fstatSync(state.temporary.descriptor)))
164
+ || !matches(state.temporary.identity, projectIdentity(lstatSync(state.temporary.path))))) fail('private runtime directory changed before Darwin launch');
165
+ return true;
166
+ }
167
+
168
+ export function disposeDarwinSealedLaunch(record) {
169
+ const state = RECORDS.get(record);
170
+ if (!state) return false;
171
+ RECORDS.delete(record);
172
+ closeSync(state.descriptor);
173
+ try {
174
+ if (state.browserListener) closeSync(state.browserListener.fd);
175
+ } finally {
176
+ try {
177
+ try { try { state.browserProof?.dispose(); } finally { state.channels?.dispose(); } }
178
+ finally { disposeTemporary(state.temporary); }
179
+ } finally {
180
+ try { disposeExecutableSnapshot(state.snapshot); }
181
+ finally { disposeExecutableSnapshot(state.helperSnapshot); }
182
+ }
183
+ }
184
+ return true;
185
+ }
@@ -0,0 +1,202 @@
1
+ /** Private executable copies, hashed while reading the sealed source descriptor. */
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import {
4
+ closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, openSync,
5
+ readSync, realpathSync, rmdirSync, writeSync,
6
+ } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { basename, isAbsolute, join, relative, sep } from 'node:path';
9
+ import { directoryPathSync, lstatAtSync, mkdirAtSync, openAtSync, readDirectorySync, unlinkAtSync } from '@chati/core';
10
+ import {
11
+ digestProviderExecutable, openPinnedProviderExecutable, openPinnedProviderSupportingExecutables,
12
+ } from '../installer-v2/provider-executable.js';
13
+ import { openAttestedRuntimeNativeHelper } from './runtime-native-helper.js';
14
+
15
+ const RECORDS = new WeakMap();
16
+ const HOOKS = Symbol.for('chati.terminal.security-test-hooks');
17
+ const DIRECTORY_FLAGS = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
18
+ function failure(message) {
19
+ throw Object.assign(new Error(message), { code: 'PROVIDER_EXECUTABLE_SNAPSHOT_INVALID' });
20
+ }
21
+ function hook(name, value) { globalThis[HOOKS]?.[name]?.(value); }
22
+ function inside(root, target) {
23
+ const part = relative(root, target);
24
+ return part === '' || (part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part));
25
+ }
26
+ function sameInode(left, right) { return left.dev === right.dev && left.ino === right.ino; }
27
+ function sourceUnchanged(before, after) {
28
+ return ['dev', 'ino', 'mode', 'uid', 'gid', 'nlink', 'size', 'mtimeMs', 'ctimeMs']
29
+ .every((field) => before[field] === after[field]);
30
+ }
31
+
32
+ function copyPinnedImage(state, source) {
33
+ const name = basename(source.binding.executable_path);
34
+ if (state.entries.some((entry) => entry.name === name)) failure('snapshot executable basenames collide');
35
+ const before = fstatSync(source.descriptor);
36
+ const writer = openAtSync(state.directoryDescriptor, name,
37
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NONBLOCK, 0o600);
38
+ const entry = { name, identity: fstatSync(writer), descriptor: undefined, digest: source.binding.executable_sha256 };
39
+ state.entries.push(entry);
40
+ try {
41
+ const hash = createHash('sha256');
42
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
43
+ let position = 0;
44
+ while (position < before.size) {
45
+ const bytes = readSync(source.descriptor, buffer, 0, Math.min(buffer.length, before.size - position), position);
46
+ if (bytes === 0) failure('sealed executable changed during snapshot copy');
47
+ hash.update(buffer.subarray(0, bytes));
48
+ let written = 0;
49
+ while (written < bytes) {
50
+ const count = writeSync(writer, buffer, written, bytes - written, position + written);
51
+ if (count === 0) failure('snapshot write made no progress');
52
+ written += count;
53
+ }
54
+ position += bytes;
55
+ hook('afterExecutableSnapshotChunk', { sourceDescriptor: source.descriptor, position });
56
+ }
57
+ if (!sourceUnchanged(before, fstatSync(source.descriptor)) || hash.digest('hex') !== entry.digest) {
58
+ failure('snapshot bytes differ from the sealed installation binding');
59
+ }
60
+ fchmodSync(writer, 0o500);
61
+ fsyncSync(writer);
62
+ const completed = fstatSync(writer);
63
+ if (!sameInode(completed, entry.identity) || completed.nlink !== 1 || completed.size !== before.size) {
64
+ failure('snapshot image changed while being prepared');
65
+ }
66
+ entry.descriptor = openAtSync(state.directoryDescriptor, name, constants.O_RDONLY | constants.O_NONBLOCK);
67
+ entry.snapshot = digestProviderExecutable(entry.descriptor).snapshot;
68
+ if (!sameInode(entry.snapshot, completed) || entry.snapshot.nlink !== 1
69
+ || (entry.snapshot.mode & 0o777) !== 0o500) failure('snapshot image could not be pinned read-only');
70
+ } finally { closeSync(writer); }
71
+ }
72
+
73
+ function assertDirectory(state) {
74
+ const attached = lstatAtSync(state.parentDescriptor, state.name);
75
+ const pinned = fstatSync(state.directoryDescriptor);
76
+ if (!attached.isDirectory() || !sameInode(attached, state.identity)
77
+ || !sameInode(pinned, state.identity) || (pinned.mode & 0o777) !== 0o700
78
+ || pinned.uid !== process.geteuid() || directoryPathSync(state.directoryDescriptor) !== state.path) {
79
+ failure('private executable snapshot directory changed');
80
+ }
81
+ }
82
+
83
+ function cleanup(state) {
84
+ // Never recursive: unknown/replaced entries are retained, not deleted.
85
+ try {
86
+ assertDirectory(state);
87
+ for (const entry of state.entries) {
88
+ const current = lstatAtSync(state.directoryDescriptor, entry.name);
89
+ if (!current.isFile() || !sameInode(current, entry.identity) || current.nlink !== 1) {
90
+ failure('snapshot cleanup refused an unowned or multiply linked entry');
91
+ }
92
+ }
93
+ if (readDirectorySync(state.directoryDescriptor).some((name) => !state.entries.some((entry) => entry.name === name))) {
94
+ failure('snapshot cleanup refused unknown directory contents');
95
+ }
96
+ for (const entry of state.entries) unlinkAtSync(state.directoryDescriptor, entry.name);
97
+ fsyncSync(state.directoryDescriptor);
98
+ // rmdir can only remove an empty directory. No recursive path traversal.
99
+ assertDirectory(state);
100
+ rmdirSync(state.path);
101
+ fsyncSync(state.parentDescriptor);
102
+ } finally {
103
+ for (const entry of state.entries) if (entry.descriptor !== undefined) closeSync(entry.descriptor);
104
+ if (state.directoryDescriptor !== undefined) closeSync(state.directoryDescriptor);
105
+ if (state.parentDescriptor !== undefined) closeSync(state.parentDescriptor);
106
+ }
107
+ return true;
108
+ }
109
+
110
+ function preparePinnedSnapshot(sources, { projectDir, temporaryRoot = tmpdir() }) {
111
+ const project = realpathSync(projectDir);
112
+ const parent = realpathSync(temporaryRoot);
113
+ if (inside(project, parent)) failure('private executable snapshot must be outside the project');
114
+ let state;
115
+ let parentDescriptor;
116
+ try {
117
+ const parentStat = lstatSync(parent);
118
+ parentDescriptor = openSync(parent, DIRECTORY_FLAGS);
119
+ const pinnedParent = fstatSync(parentDescriptor);
120
+ if (!parentStat.isDirectory() || !sameInode(parentStat, pinnedParent)
121
+ || ((pinnedParent.mode & 0o022) !== 0 && (pinnedParent.mode & 0o1000) === 0)) {
122
+ failure('temporary snapshot parent is unsafe');
123
+ }
124
+ const name = `chati-executable-${randomUUID()}`;
125
+ hook('beforeExecutableSnapshotDirectoryCreate', { parent });
126
+ mkdirAtSync(parentDescriptor, name, { mode: 0o700 });
127
+ const directoryDescriptor = openAtSync(parentDescriptor, name, DIRECTORY_FLAGS);
128
+ state = { parentDescriptor, directoryDescriptor, name, entries: [],
129
+ path: directoryPathSync(directoryDescriptor), identity: fstatSync(directoryDescriptor) };
130
+ parentDescriptor = undefined;
131
+ assertDirectory(state);
132
+ if (inside(project, state.path)) failure('snapshot directory overlaps the project');
133
+ for (const source of sources) copyPinnedImage(state, source);
134
+ fsyncSync(directoryDescriptor);
135
+ fsyncSync(state.parentDescriptor);
136
+ const record = Object.freeze({
137
+ directory: state.path, directoryDescriptor,
138
+ executablePath: join(state.path, state.entries[0].name),
139
+ executableSha256: state.entries[0].digest,
140
+ supportingExecutables: Object.freeze(state.entries.slice(1).map((entry) => Object.freeze({
141
+ executable_path: join(state.path, entry.name), executable_sha256: entry.digest,
142
+ }))),
143
+ });
144
+ RECORDS.set(record, state);
145
+ assertExecutableSnapshot(record);
146
+ return record;
147
+ } catch (error) {
148
+ if (state) {
149
+ try { cleanup(state); } catch (cleanupError) { error.snapshotCleanupError = cleanupError; }
150
+ }
151
+ if (parentDescriptor !== undefined) closeSync(parentDescriptor);
152
+ throw error;
153
+ }
154
+ }
155
+
156
+ export function prepareExecutableSnapshot(binding, { harnessId, projectDir, temporaryRoot, requireNativeImage = false } = {}) {
157
+ const project = realpathSync(projectDir);
158
+ const sources = [];
159
+ try {
160
+ sources.push(openPinnedProviderExecutable(binding, { harnessId, projectDir: project, requireNativeImage }));
161
+ sources.push(...openPinnedProviderSupportingExecutables(binding, { harnessId, projectDir: project }));
162
+ for (const source of sources) {
163
+ if (realpathSync(source.binding.executable_path) !== source.binding.executable_path
164
+ || inside(project, source.binding.executable_path)) failure('snapshot source must be physically outside the project');
165
+ }
166
+ hook('afterExecutableSnapshotSourcesOpen', { sources });
167
+ return preparePinnedSnapshot(sources, { projectDir: project, temporaryRoot });
168
+ } finally { for (const source of sources) closeSync(source.descriptor); }
169
+ }
170
+
171
+ /** The only installed-project source accepted here is a publisher-attested
172
+ * runtime helper. The provider entrypoint above retains its outside-project
173
+ * restriction. Both paths copy/hash exactly the already opened source FD. */
174
+ export function prepareRuntimeNativeHelperSnapshot(name, { projectDir, temporaryRoot } = {}) {
175
+ const source = openAttestedRuntimeNativeHelper(name);
176
+ try {
177
+ hook('afterRuntimeHelperSnapshotSourceOpen', { source });
178
+ return preparePinnedSnapshot([source], { projectDir, temporaryRoot });
179
+ } finally { closeSync(source.descriptor); }
180
+ }
181
+
182
+ export function assertExecutableSnapshot(record) {
183
+ const state = RECORDS.get(record);
184
+ if (!state) failure('snapshot is unknown or already disposed');
185
+ assertDirectory(state);
186
+ for (const entry of state.entries) {
187
+ const attached = lstatAtSync(state.directoryDescriptor, entry.name);
188
+ if (!sameInode(attached, entry.identity) || !attached.isFile() || attached.nlink !== 1) {
189
+ failure('snapshot executable entry changed');
190
+ }
191
+ const current = digestProviderExecutable(entry.descriptor, entry.snapshot);
192
+ if (current.sha256 !== entry.digest) failure('snapshot executable digest changed');
193
+ }
194
+ return true;
195
+ }
196
+
197
+ export function disposeExecutableSnapshot(record) {
198
+ const state = RECORDS.get(record);
199
+ if (!state) return false;
200
+ RECORDS.delete(record);
201
+ return cleanup(state);
202
+ }
@@ -25,6 +25,31 @@ const LEGACY_STATUS_ALIASES = {
25
25
  BLOCKED: 'needs_input',
26
26
  };
27
27
 
28
+ /** Execution completion and the quality verdict are independent facts. */
29
+ export function isPlanningQaOutcomeValid(outcome, threshold) {
30
+ return Boolean(outcome && Number.isFinite(outcome.score) && outcome.score >= 0 && outcome.score <= 100
31
+ && ['approved', 'needs_correction'].includes(outcome.gate_result)
32
+ && Number.isSafeInteger(outcome.blocking_count) && outcome.blocking_count >= 0
33
+ && (outcome.gate_result !== 'approved' || (outcome.score >= threshold && outcome.blocking_count === 0))
34
+ && (outcome.gate_result !== 'needs_correction' || outcome.score < threshold || outcome.blocking_count > 0));
35
+ }
36
+
37
+ export function normalizePlanningQaOutcome(handoff, threshold) {
38
+ const outcome = {
39
+ score: handoff?.score,
40
+ gate_result: handoff?.decisions?.gate_result,
41
+ blocking_count: Array.isArray(handoff?.blockers) ? handoff.blockers.length : null,
42
+ };
43
+ // Old agents sometimes emitted score100 for task completion and score0 in
44
+ // decisions. Never silently choose between competing quality scores.
45
+ const alternateScore = handoff?.decisions?.planning_score;
46
+ if (handoff?.status !== 'complete' || !isPlanningQaOutcomeValid(outcome, threshold)
47
+ || (alternateScore !== undefined && Number(alternateScore) !== outcome.score)) {
48
+ return { ok: false, code: 'QA_RESULT_INVALID', error: 'QA must return its planning quality score, an explicit approved/needs_correction verdict, and consistent blockers. Execution completion is not approval.' };
49
+ }
50
+ return { ok: true, outcome };
51
+ }
52
+
28
53
  /**
29
54
  * Parse the <chati-handoff> block from agent stdout.
30
55
  *
@@ -33,7 +58,7 @@ const LEGACY_STATUS_ALIASES = {
33
58
  * @param {string} output - Full stdout from the agent process
34
59
  * @returns {{ found: boolean, handoff: object|null, rawOutput: string, valid: boolean, warnings: string[] }}
35
60
  */
36
- export function parseAgentOutput(output) {
61
+ export function parseAgentOutput(output, { strict = false } = {}) {
37
62
  if (!output || typeof output !== 'string') {
38
63
  return { found: false, handoff: null, rawOutput: '', valid: false, warnings: ['No output provided'] };
39
64
  }
@@ -44,7 +69,28 @@ export function parseAgentOutput(output) {
44
69
  }
45
70
 
46
71
  const content = match[1].trim();
47
- const handoff = parseHandoffFields(content);
72
+ let handoff;
73
+ if (strict) {
74
+ try {
75
+ if ([...output.matchAll(/<chati-handoff>([\s\S]*?)<\/chati-handoff>/g)].length !== 1) {
76
+ throw new Error('Exactly one terminal handoff is required.');
77
+ }
78
+ // Decisions that can approve a plan must come from unambiguous source
79
+ // bytes. The permissive legacy parser truncates numeric strings and
80
+ // overwrites duplicate fields, so it is not a fallback on this path.
81
+ const document = yaml.load(content, { schema: yaml.JSON_SCHEMA });
82
+ if (!document || typeof document !== 'object' || Array.isArray(document)) throw new Error('Handoff must be a YAML mapping.');
83
+ handoff = { ...parseHandoffFields(''), ...document };
84
+ if ((handoff.score !== null || handoff.status === 'complete')
85
+ && (!Number.isFinite(handoff.score) || handoff.score < 0 || handoff.score > 100)) {
86
+ throw new Error('Handoff score must be a finite number from 0 to 100.');
87
+ }
88
+ } catch (error) {
89
+ return { found: true, handoff: null, rawOutput: output, valid: false, warnings: [`Invalid strict handoff: ${error.message}`] };
90
+ }
91
+ } else {
92
+ handoff = parseHandoffFields(content);
93
+ }
48
94
  handoff.status = LEGACY_STATUS_ALIASES[handoff.status] || handoff.status;
49
95
 
50
96
  // Validate the parsed handoff