chati-dev 4.5.27 → 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 +137 -20
  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
@@ -7,8 +7,9 @@
7
7
  */
8
8
 
9
9
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
10
- import { join } from 'path';
11
- import { resolveFrameworkDir } from '../utils/framework-dir.js';
10
+ import { dirname } from 'path';
11
+ import * as yaml from 'js-yaml';
12
+ import { agentHandoffPath, findAgentHandoffPath } from '../utils/handoff-paths.js';
12
13
 
13
14
  /**
14
15
  * Build a handoff document from task execution results.
@@ -60,8 +61,8 @@ export function formatHandoff(handoff) {
60
61
  `from_task: ${handoff.from.task_id}`,
61
62
  `from_phase: ${handoff.from.phase}`,
62
63
  `to: ${handoff.to || 'orchestrator'}`,
63
- `timestamp: ${handoff.timestamp}`,
64
- `status: ${handoff.status}`,
64
+ ...(handoff.timestamp ? [`timestamp: ${handoff.timestamp}`] : []),
65
+ ...(handoff.status_declared === false ? [] : [`status: ${handoff.status}`]),
65
66
  `score: ${handoff.score ?? 'N/A'}`,
66
67
  '---',
67
68
  '',
@@ -112,6 +113,10 @@ export function formatHandoff(handoff) {
112
113
  lines.push('');
113
114
  }
114
115
 
116
+ if (handoff.deep_context) {
117
+ lines.push('## Deep Context', handoff.deep_context, '');
118
+ }
119
+
115
120
  return lines.join('\n');
116
121
  }
117
122
 
@@ -124,13 +129,12 @@ export function formatHandoff(handoff) {
124
129
  */
125
130
  export function saveHandoff(projectDir, handoff) {
126
131
  try {
127
- const handoffsDir = join(projectDir, resolveFrameworkDir(projectDir), 'artifacts', 'handoffs');
132
+ const filePath = agentHandoffPath(projectDir, handoff.from.agent);
133
+ const handoffsDir = dirname(filePath);
128
134
  if (!existsSync(handoffsDir)) {
129
135
  mkdirSync(handoffsDir, { recursive: true });
130
136
  }
131
137
 
132
- const filename = `${handoff.from.agent}-handoff.md`;
133
- const filePath = join(handoffsDir, filename);
134
138
  const content = formatHandoff(handoff);
135
139
 
136
140
  writeFileSync(filePath, content, 'utf-8');
@@ -148,15 +152,14 @@ export function saveHandoff(projectDir, handoff) {
148
152
  * @returns {{ loaded: boolean, handoff: object|null, error: string|null }}
149
153
  */
150
154
  export function loadHandoff(projectDir, agentName) {
151
- const filePath = join(projectDir, resolveFrameworkDir(projectDir), 'artifacts', 'handoffs', `${agentName}-handoff.md`);
152
-
153
- if (!existsSync(filePath)) {
154
- return { loaded: false, handoff: null, error: `No handoff found from agent '${agentName}'.` };
155
- }
156
-
157
155
  try {
156
+ const filePath = findAgentHandoffPath(projectDir, agentName);
157
+ if (!filePath) {
158
+ return { loaded: false, handoff: null, error: `No handoff found from agent '${agentName}'.` };
159
+ }
158
160
  const raw = readFileSync(filePath, 'utf-8');
159
161
  const parsed = parseHandoffContent(raw);
162
+ if (!raw.trim() || !parsed.summary) throw new Error('Handoff summary is missing');
160
163
  return { loaded: true, handoff: parsed, error: null };
161
164
  } catch (err) {
162
165
  return { loaded: false, handoff: null, error: `Failed to parse handoff: ${err.message}` };
@@ -170,22 +173,17 @@ export function loadHandoff(projectDir, agentName) {
170
173
  * @returns {object} Parsed handoff data
171
174
  */
172
175
  export function parseHandoffContent(content) {
176
+ content = content.replace(/\r\n/g, '\n');
173
177
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
174
178
 
175
- const meta = {};
179
+ let meta = {};
176
180
  let body = content;
177
181
 
178
182
  if (frontmatterMatch) {
179
- const yamlPart = frontmatterMatch[1];
183
+ meta = yaml.load(frontmatterMatch[1], { schema: yaml.JSON_SCHEMA }) ?? {};
184
+ if (typeof meta !== 'object' || Array.isArray(meta)) throw new Error('Handoff metadata must be a mapping');
180
185
  body = frontmatterMatch[2];
181
-
182
- for (const line of yamlPart.split('\n')) {
183
- const match = line.match(/^(\w[\w_-]*):\s*(.+)$/);
184
- if (match) {
185
- meta[match[1]] = match[2].trim();
186
- }
187
- }
188
- }
186
+ } else if (content.startsWith('---\n')) throw new Error('Handoff frontmatter is not closed');
189
187
 
190
188
  // Extract sections from body
191
189
  const sections = {};
@@ -193,9 +191,13 @@ export function parseHandoffContent(content) {
193
191
  const sectionLines = {};
194
192
 
195
193
  for (const line of body.split('\n')) {
196
- const headerMatch = line.match(/^## (.+)/);
194
+ const headerMatch = line.match(/^## (.+)/) || line.match(/^### (Layer [12].+)/i);
197
195
  if (headerMatch) {
198
- currentSection = headerMatch[1].toLowerCase().replace(/\s+/g, '_');
196
+ const title = headerMatch[1].trim();
197
+ currentSection = /^layer\s+1\s*(?::\s*(?:executive\s+)?summary|\(summary\))$/i.test(title)
198
+ ? 'summary' : /^layer\s+2\s*(?::\s*deep context|\(deep context\))$/i.test(title)
199
+ ? 'deep_context' : title.toLowerCase().replace(/\s+/g, '_');
200
+ if (Object.hasOwn(sectionLines, currentSection)) throw new Error('Conflicting handoff sections');
199
201
  sectionLines[currentSection] = [];
200
202
  } else if (currentSection) {
201
203
  sectionLines[currentSection].push(line);
@@ -210,17 +212,39 @@ export function parseHandoffContent(content) {
210
212
  .map(l => l.replace(/^- (\[.\] )?/, '').trim());
211
213
  }
212
214
 
215
+ // Presence, not truthiness, controls aliases. Invalid or conflicting fields
216
+ // cannot be hidden by another spelling or a stale legacy handoff.
217
+ const field = (names, valid, fallback = null, extra = []) => {
218
+ const values = [...names.filter(name => Object.hasOwn(meta, name)).map(name => meta[name]), ...extra];
219
+ if (values.some(value => !valid(value))) throw new Error(`Invalid handoff ${names[0]}`);
220
+ if (values.some(value => JSON.stringify(value) !== JSON.stringify(values[0]))) throw new Error(`Conflicting handoff ${names[0]}`);
221
+ return values.length ? values[0] : fallback;
222
+ };
223
+ const text = value => typeof value === 'string' && value.trim().length > 0;
224
+ const list = value => Array.isArray(value) && value.every(text);
225
+ const listField = (name, alias) => field(alias ? [name, alias] : [name], list, [],
226
+ Object.hasOwn(sections, name) ? [sections[name]] : []);
227
+ const rawScore = field(['score', 'quality_score'], value => value === 'N/A'
228
+ || (typeof value === 'number' && Number.isFinite(value))
229
+ || (typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value)));
230
+
213
231
  return {
214
- from_agent: meta.from_agent || null,
215
- from_task: meta.from_task || null,
216
- to: meta.to || null,
217
- timestamp: meta.timestamp || null,
218
- status: meta.status || 'unknown',
219
- score: meta.score !== 'N/A' ? parseInt(meta.score, 10) || null : null,
232
+ from_agent: field(['from_agent', 'agent'], text),
233
+ from_task: field(['from_task'], text),
234
+ from_phase: field(['from_phase'], text),
235
+ to: field(['to', 'next_agent'], text),
236
+ timestamp: field(['timestamp'], text),
237
+ status: field(['status'], text, 'unknown'),
238
+ // Preserve the legacy status default without confusing an omitted optional
239
+ // declaration with an explicit non-complete status. Derive from actual
240
+ // metadata presence, never from a caller-supplied status_declared field.
241
+ status_declared: Object.hasOwn(meta, 'status'),
242
+ score: rawScore === null || rawScore === 'N/A' ? null : Number(rawScore),
220
243
  summary: (sectionLines.summary || []).join(' ').trim(),
221
- outputs: sections.outputs || [],
244
+ deep_context: (sectionLines.deep_context || []).join('\n').trim(),
245
+ outputs: listField('outputs', 'artifacts'),
222
246
  decisions: sections.decisions || [],
223
- blockers: sections.blockers || [],
247
+ blockers: listField('blockers'),
224
248
  criteria_met: sections.criteria_met || [],
225
249
  criteria_unmet: sections.criteria_unmet || [],
226
250
  };
@@ -21,6 +21,9 @@ export function buildCommand(config, provider) {
21
21
  const readOnly = Array.isArray(config.writeScope) && config.writeScope.length === 0;
22
22
  const args = provider.baseArgs.filter((argument) => !(readOnly && argument === '--dangerously-skip-permissions'));
23
23
  if (readOnly) args.push('--permission-mode', 'plan', '--no-session-persistence');
24
+ if (config.councilToolIsolation === true) {
25
+ args.push('--restricted', '--strict-mcp-config', '--disable-slash-commands', '--tools', '', '--permission-prompts', 'none');
26
+ }
24
27
  let effectiveModel = null;
25
28
 
26
29
  if (config.model) {
@@ -16,6 +16,19 @@ export function buildCommand(config, provider) {
16
16
  const args = [...provider.baseArgs];
17
17
  const readOnly = Array.isArray(config.writeScope) && config.writeScope.length === 0;
18
18
  if (readOnly) args.push('-s', 'read-only', '--ephemeral');
19
+ else if (Array.isArray(config.writeScope) && config.writeScope.length > 0) {
20
+ // `codex exec` otherwise defaults to read-only, including agents that must
21
+ // write their reports/handoffs. This inner mode does not expand the outer
22
+ // OS-enforced write scopes and does not change approval or rule settings.
23
+ args.push('-s', 'workspace-write');
24
+ }
25
+ if (config.councilToolIsolation === true) {
26
+ // Keep the approval policy through exec's supported config override, not
27
+ // the global flag rejected after exec by the pinned CLI. The authority still
28
+ // removes command binaries and hides the source project. Council material
29
+ // is an immutable artifact bundle, deliberately not a Git checkout.
30
+ args.push('--ignore-user-config', '--ignore-rules', '-c', 'approval_policy="on-request"', '--skip-git-repo-check');
31
+ }
19
32
  let effectiveModel = null;
20
33
 
21
34
  if (config.model) {
@@ -9,6 +9,7 @@ export function buildCommand(config, provider) {
9
9
  const args = [...provider.baseArgs];
10
10
  const readOnly = Array.isArray(config.writeScope) && config.writeScope.length === 0;
11
11
  if (readOnly) args.push('--permission-mode', 'plan', '--no-subagents', '--disable-web-search');
12
+ if (config.councilToolIsolation === true) args.push('--tools', '', '--output-format', 'json');
12
13
  let effectiveModel = null;
13
14
  if (config.model) {
14
15
  effectiveModel = provider.modelMap[config.model] || config.model;
@@ -0,0 +1,35 @@
1
+ // The reserved claim owns the total execution budget, including preparation,
2
+ // retry backoff, process cleanup and result persistence. A timeout is only one
3
+ // attempt's budget and never extends that claim.
4
+ const CLEANUP_RESERVE_MS = 60_000;
5
+ export const AGENT_RETRY_DELAY_MS = 500;
6
+
7
+ export function getAgentTimeout(agent) {
8
+ return agent === 'qa-planning' ? 1_200_000 : 600_000;
9
+ }
10
+
11
+ export function parseAgentTimeout(value, agent) {
12
+ const timeout = value === undefined ? getAgentTimeout(agent) : Number(value);
13
+ if ((value !== undefined && !/^\d+$/.test(String(value)))
14
+ || !Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2_147_483_647) {
15
+ throw Object.assign(new Error('Agent timeout must be a positive integer in milliseconds within the timer range.'), { code: 'INVALID_AGENT_TIMEOUT' });
16
+ }
17
+ return timeout;
18
+ }
19
+
20
+ export function createAgentAttemptBudget(timeout, expiresAt, { now = Date.now } = {}) {
21
+ const deadline = expiresAt === null ? Infinity : Date.parse(expiresAt);
22
+ if (Number.isNaN(deadline)) {
23
+ throw Object.assign(new Error('Execution claim has no valid expiration.'), { code: 'AGENT_EXECUTION_CLAIM_INVALID' });
24
+ }
25
+ function canStart(delayMs = 0) {
26
+ return deadline - now() >= timeout + CLEANUP_RESERVE_MS + delayMs;
27
+ }
28
+ function assertCanStart() {
29
+ if (!canStart()) {
30
+ throw Object.assign(new Error('The remaining execution claim lease cannot cover a full attempt and cleanup. No new provider process was launched.'),
31
+ { code: 'AGENT_EXECUTION_BUDGET_EXHAUSTED' });
32
+ }
33
+ }
34
+ return Object.freeze({ canStart, assertCanStart });
35
+ }
@@ -0,0 +1,174 @@
1
+ // Trusted transport, not an evidence issuer. Its caller supplies the private
2
+ // native owner's proof function. It never accepts proof from HTTP/app output.
3
+ import { createServer, request } from 'node:http';
4
+ import { connect } from 'node:net';
5
+
6
+ function error(code) { return Object.assign(new Error(code), { code }); }
7
+ const HOP_HEADERS = new Set(['connection', 'proxy-connection', 'keep-alive', 'transfer-encoding',
8
+ 'te', 'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate']);
9
+ function headersFor(headers) {
10
+ const omitted = new Set([...HOP_HEADERS, ...String(headers.connection || '').toLowerCase().split(',').map(value => value.trim())]);
11
+ return Object.fromEntries(Object.entries(headers).filter(([name]) => !omitted.has(name.toLowerCase())));
12
+ }
13
+
14
+ /** One retained TCP connection per upstream request/upgrade, proved before a
15
+ * single application byte is sent or forwarded. No upstream pool or reconnect.
16
+ * HTTP and WS use exactly the same proof path; CONNECT/other origins are denied.
17
+ * This alone does not enforce the browser/application OS network boundaries.
18
+ */
19
+ export async function createVerifiedBrowserProxy({ hostname, applicationPort, timeoutMs, verifyPeer }) {
20
+ if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname || '')
21
+ || !Number.isInteger(applicationPort) || applicationPort < 1024 || applicationPort > 65535
22
+ || !Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 1800000 || typeof verifyPeer !== 'function') {
23
+ throw error('BROWSER_PROXY_CONFIGURATION_INVALID');
24
+ }
25
+ const host = `${hostname}:${applicationPort}`;
26
+ const sockets = new Set();
27
+ const facts = { connections_proved: 0, owner_rejections: 0, policy_violations: 0, timed_out: false, closed: false };
28
+ let closePromise;
29
+ const server = createServer({ maxHeaderSize: 16384, requestTimeout: timeoutMs, headersTimeout: Math.min(timeoutMs, 5000) });
30
+ server.maxConnections = 64;
31
+ function own(socket) {
32
+ sockets.add(socket);
33
+ socket.on('error', () => {});
34
+ socket.once('close', () => sockets.delete(socket));
35
+ return socket;
36
+ }
37
+ server.on('connection', socket => own(socket));
38
+ function rejectPolicy(target) {
39
+ facts.policy_violations++;
40
+ target.destroy();
41
+ }
42
+ function targetUrl(req, upgrade) {
43
+ try {
44
+ const url = new URL(req.url);
45
+ if (url.hostname !== hostname || url.port !== String(applicationPort) || url.username || url.password
46
+ || url.hash || req.headers.host !== host || !(upgrade ? ['http:', 'ws:'] : ['http:']).includes(url.protocol)) return null;
47
+ return url;
48
+ } catch { return null; }
49
+ }
50
+ function openVerified() {
51
+ return new Promise((resolve, reject) => {
52
+ if (facts.closed) { reject(error('BROWSER_PROXY_CLOSED')); return; }
53
+ const socket = own(connect({ host: '127.0.0.1', port: applicationPort, family: 4 }));
54
+ socket.pause();
55
+ let settled = false; let retryTimer; let proofStarted = false;
56
+ const deadline = setTimeout(() => {
57
+ if (proofStarted) facts.owner_rejections++;
58
+ finish(error('BROWSER_ENDPOINT_PROOF_TIMEOUT'));
59
+ }, Math.min(timeoutMs, 2000));
60
+ function finish(failure) {
61
+ if (settled) return;
62
+ settled = true; clearTimeout(deadline); clearTimeout(retryTimer);
63
+ socket.off('error', onError); socket.off('close', onClose);
64
+ if (failure) { socket.destroy(); reject(failure); } else resolve(socket);
65
+ }
66
+ function onError(failure) { finish(failure); }
67
+ function onClose() { finish(error('BROWSER_ENDPOINT_CLOSED')); }
68
+ socket.once('error', onError); socket.once('close', onClose);
69
+ socket.once('connect', () => {
70
+ // These are read from this retained kernel-backed Socket, not a request.
71
+ const tuple = Object.freeze({ local_address: socket.localAddress, local_port: socket.localPort,
72
+ remote_address: socket.remoteAddress, remote_port: socket.remotePort });
73
+ async function prove() {
74
+ if (settled) return;
75
+ proofStarted = true;
76
+ try {
77
+ const proved = await verifyPeer(tuple);
78
+ if (settled) return;
79
+ if (facts.closed || socket.destroyed || !socket.readable || !socket.writable
80
+ || socket.localAddress !== tuple.local_address || socket.localPort !== tuple.local_port
81
+ || socket.remoteAddress !== tuple.remote_address || socket.remotePort !== tuple.remote_port) {
82
+ finish(error('BROWSER_ENDPOINT_CLOSED')); return;
83
+ }
84
+ // TCP connect can precede the application's accept(). Query this
85
+ // same retained connection again, within the original deadline.
86
+ // Only an explicit negative verdict is retryable, never a channel
87
+ // failure; the private proof channel supplies a fresh query ID.
88
+ if (proved === false) { retryTimer = setTimeout(() => { void prove(); }, 25); return; }
89
+ if (proved !== true) { facts.owner_rejections++; finish(error('BROWSER_ENDPOINT_OWNER_UNPROVEN')); return; }
90
+ facts.connections_proved++;
91
+ finish();
92
+ } catch (failure) {
93
+ if (!settled) facts.owner_rejections++;
94
+ finish(failure);
95
+ }
96
+ }
97
+ void prove();
98
+ });
99
+ });
100
+ }
101
+ async function forward(req, response, clientHead) {
102
+ const upgrade = clientHead !== undefined;
103
+ const client = upgrade ? response : req.socket;
104
+ const url = targetUrl(req, upgrade);
105
+ if (!url || req.method === 'CONNECT' || (upgrade && (req.method !== 'GET' || req.headers.upgrade?.toLowerCase() !== 'websocket'))) {
106
+ rejectPolicy(client); return;
107
+ }
108
+ req.pause();
109
+ let socket; let upstream;
110
+ try {
111
+ socket = await openVerified();
112
+ if (facts.closed || client.destroyed || req.aborted) throw error('BROWSER_PROXY_CLOSED');
113
+ const headers = { ...headersFor(req.headers), host };
114
+ if (upgrade) { headers.connection = 'Upgrade'; headers.upgrade = 'websocket'; }
115
+ else headers.connection = 'close';
116
+ // createConnection disables Node's agent. Never use agent:false here:
117
+ // that creates a new agent which could open an unproved second socket.
118
+ upstream = request({ hostname: '127.0.0.1', port: applicationPort, method: req.method,
119
+ path: `${url.pathname}${url.search}`, headers, createConnection: () => socket });
120
+ upstream.once('socket', assigned => {
121
+ if (assigned !== socket) { assigned.destroy(); upstream.destroy(); return; }
122
+ assigned.resume(); // Parser is attached now; pre-proof data stayed paused.
123
+ });
124
+ upstream.on('error', () => { socket.destroy(); response.destroy(); });
125
+ client.once('close', () => { upstream.destroy(); socket.destroy(); });
126
+ upstream.on('response', reply => {
127
+ if (upgrade) { reply.destroy(); socket.destroy(); client.destroy(); return; }
128
+ response.writeHead(reply.statusCode, headersFor(reply.headers));
129
+ reply.on('error', () => response.destroy());
130
+ response.once('close', () => { reply.destroy(); socket.destroy(); });
131
+ reply.pipe(response);
132
+ });
133
+ upstream.on('upgrade', (reply, upgradedSocket, head) => {
134
+ if (!upgrade || upgradedSocket !== socket || reply.statusCode !== 101
135
+ || reply.headers.upgrade?.toLowerCase() !== 'websocket') {
136
+ upgradedSocket.destroy(); response.destroy(); return;
137
+ }
138
+ const headers = { ...headersFor(reply.headers), connection: 'Upgrade', upgrade: 'websocket' };
139
+ client.write(`HTTP/1.1 101 Switching Protocols\r\n${Object.entries(headers).flatMap(([key, value]) =>
140
+ (Array.isArray(value) ? value : [value]).map(item => `${key}: ${item}\r\n`)).join('')}\r\n`);
141
+ if (head.length) client.write(head);
142
+ if (clientHead.length) socket.write(clientHead);
143
+ socket.once('close', () => client.destroy());
144
+ client.pipe(socket); socket.pipe(client);
145
+ });
146
+ if (upgrade) upstream.end(); else req.pipe(upstream);
147
+ } catch {
148
+ socket?.destroy(); upstream?.destroy();
149
+ if (upgrade) client.destroy(); else { response.writeHead(502, { connection: 'close' }); response.end(); }
150
+ }
151
+ }
152
+ server.on('request', (req, res) => { void forward(req, res); });
153
+ server.on('upgrade', (req, socket, head) => { void forward(req, socket, head); });
154
+ server.on('connect', (_req, socket) => rejectPolicy(socket));
155
+ server.on('clientError', (_error, socket) => rejectPolicy(socket));
156
+ async function close() {
157
+ if (closePromise) return closePromise;
158
+ facts.closed = true; clearTimeout(timer);
159
+ const stopped = [...sockets].map(socket => new Promise(resolve => { socket.once('close', resolve); socket.destroy(); }));
160
+ stopped.push(new Promise(resolve => { server.close(() => resolve()); }));
161
+ closePromise = Promise.all(stopped).then(() => {});
162
+ return closePromise;
163
+ }
164
+ await new Promise((resolve, reject) => {
165
+ server.once('error', reject);
166
+ server.listen({ host: '127.0.0.1', port: 0, exclusive: true }, () => { server.off('error', reject); resolve(); });
167
+ });
168
+ server.on('error', () => { void close(); });
169
+ const port = server.address().port;
170
+ const timer = setTimeout(() => { facts.timed_out = true; void close(); }, timeoutMs);
171
+ return Object.freeze({ serverUrl: `http://127.0.0.1:${port}`, port, close,
172
+ facts: () => Object.freeze({ ...facts, active_connections: sockets.size }),
173
+ });
174
+ }
@@ -0,0 +1,94 @@
1
+ // Shared trusted Playwright scenario. OS containment, application lifetime and
2
+ // process evidence belong to the caller, never to page content or this driver.
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+
5
+ export async function observeBrowserScenario({ chromium, config, launchOptions, isApplicationAlive }) {
6
+ const origin = `http://${config.hostname}:${config.application.port}`;
7
+ const observed = { passed: false, error_code: null, steps: [], artifacts: [],
8
+ policy_violations: 0, page_errors: 0, console_errors: 0, browser_version: null };
9
+ let browser; let stage = 'BROWSER_LAUNCH_FAILED';
10
+ function rejected(code) { throw Object.assign(new Error(code), { observationCode: code }); }
11
+ function allowedUrl(value, websocket = false) {
12
+ try {
13
+ const url = new URL(value);
14
+ return url.hostname === config.hostname && url.port === String(config.application.port)
15
+ && url.protocol === (websocket ? 'ws:' : 'http:') && !url.username && !url.password;
16
+ } catch { return false; }
17
+ }
18
+ try {
19
+ browser = await chromium.launch({ ...launchOptions, headless: true, chromiumSandbox: true });
20
+ observed.browser_version = browser.version();
21
+ const context = await browser.newContext({ serviceWorkers: 'block', acceptDownloads: false,
22
+ viewport: { width: 1280, height: 720 } });
23
+ context.setDefaultTimeout(Math.min(5000, config.timeout_ms));
24
+ await context.route('**/*', route => {
25
+ if (!allowedUrl(route.request().url())) { observed.policy_violations++; return route.abort('blockedbyclient'); }
26
+ return route.continue();
27
+ });
28
+ await context.routeWebSocket('**/*', socket => {
29
+ if (!allowedUrl(socket.url(), true)) { observed.policy_violations++; socket.close(); return; }
30
+ socket.connectToServer();
31
+ });
32
+ context.on('page', page => {
33
+ page.on('pageerror', () => { observed.page_errors++; });
34
+ page.on('console', message => { if (message.type() === 'error') observed.console_errors++; });
35
+ page.on('download', () => { observed.policy_violations++; });
36
+ page.on('framenavigated', frame => {
37
+ if (frame.url() !== 'about:blank' && !allowedUrl(frame.url())) observed.policy_violations++;
38
+ });
39
+ });
40
+ const page = await context.newPage();
41
+ stage = 'BROWSER_STEP_FAILED';
42
+ for (const [index, step] of config.steps.entries()) {
43
+ if (!isApplicationAlive()) rejected('BROWSER_APPLICATION_EXITED');
44
+ const record = { index, action: step.action, passed: false }; observed.steps.push(record);
45
+ if (step.action === 'navigate') {
46
+ const response = await page.goto(`${origin}${step.path}`, { waitUntil: 'load' });
47
+ if (!response || !response.ok()) rejected('BROWSER_NAVIGATION_FAILED');
48
+ } else {
49
+ const locator = page.locator(step.selector);
50
+ if (step.action === 'click') await locator.click();
51
+ else if (step.action === 'fill') await locator.fill(step.text);
52
+ else if (step.action === 'assert_visible') await locator.waitFor({ state: 'visible' });
53
+ else if (step.action === 'assert_text') {
54
+ await locator.waitFor({ state: 'attached' });
55
+ const assertionDeadline = performance.now() + Math.min(5000, config.timeout_ms);
56
+ while (await locator.textContent() !== step.text) {
57
+ if (performance.now() >= assertionDeadline) rejected('BROWSER_ASSERTION_FAILED');
58
+ await delay(25);
59
+ }
60
+ }
61
+ }
62
+ if (observed.policy_violations) rejected('BROWSER_DOMAIN_POLICY_VIOLATION');
63
+ record.passed = true;
64
+ }
65
+ stage = 'BROWSER_ARTIFACT_FAILED';
66
+ const protocol = await context.newCDPSession(page);
67
+ const snapshot = await protocol.send('DOMSnapshot.captureSnapshot', { computedStyles: [] });
68
+ const safeTags = new Set('HTML HEAD BODY TITLE META LINK STYLE SCRIPT DIV SPAN P H1 H2 H3 H4 H5 H6 A BUTTON INPUT FORM LABEL SELECT OPTION TEXTAREA UL OL LI TABLE THEAD TBODY TR TH TD MAIN NAV HEADER FOOTER SECTION ARTICLE ASIDE IMG SVG PATH CANVAS VIDEO AUDIO IFRAME BR HR STRONG EM B I PRE CODE'.split(' '));
69
+ const nodes = [];
70
+ for (const [documentIndex, document] of snapshot.documents.entries()) {
71
+ for (let index = 0; index < document.nodes.nodeType.length; index++) {
72
+ if (nodes.length >= 5000) rejected('BROWSER_DOM_SIZE_LIMIT');
73
+ const type = document.nodes.nodeType[index];
74
+ const name = snapshot.strings[document.nodes.nodeName[index]];
75
+ nodes.push({ document: documentIndex, index, parent: document.nodes.parentIndex[index],
76
+ type, tag: safeTags.has(name) ? name : type === 3 ? '#text' : type === 9 ? '#document' : 'OTHER',
77
+ ...(type === 3 ? { text: '[REDACTED]' } : {}) });
78
+ }
79
+ }
80
+ // Persist structure only, never raw DOM, attributes, values, bodies or logs.
81
+ observed.artifacts.push({ kind: 'dom', redaction_status: 'redacted',
82
+ content: { schema_version: 1, projection: 'dom-structure-all-values-redacted', nodes } });
83
+ if (observed.policy_violations) rejected('BROWSER_DOMAIN_POLICY_VIOLATION');
84
+ if (observed.page_errors || observed.console_errors) rejected('BROWSER_PAGE_ERRORS');
85
+ if (!isApplicationAlive()) rejected('BROWSER_APPLICATION_EXITED');
86
+ observed.passed = true;
87
+ } catch (error) { observed.error_code = error.observationCode || stage; }
88
+ finally {
89
+ if (browser) {
90
+ try { await browser.close(); } catch { observed.passed = false; observed.error_code = 'BROWSER_CLOSE_FAILED'; }
91
+ }
92
+ }
93
+ return observed;
94
+ }
@@ -0,0 +1,73 @@
1
+ /* global configuration */
2
+ // Executed from trusted installed source inside the outer network/PID sandbox.
3
+ // Application code gets a second mount/PID namespace, without observer tools,
4
+ // browser profiles, parent process descriptors, keys or operator files.
5
+ import { spawn } from 'node:child_process';
6
+ import { existsSync } from 'node:fs';
7
+ import { get } from 'node:http';
8
+ import { setTimeout as delay } from 'node:timers/promises';
9
+ import { chromium } from '/run/chati-observer-tools/playwright-core/index.mjs';
10
+ import { observeBrowserScenario } from '/run/chati-observer-tools/observer-driver.mjs';
11
+
12
+ const config = configuration;
13
+ const observed = { passed: false, error_code: null, steps: [], artifacts: [],
14
+ policy_violations: 0, page_errors: 0, console_errors: 0, browser_version: null, application_stopped: false };
15
+ let application; let applicationClosed; let applicationExited = false;
16
+ let stage = 'BROWSER_APPLICATION_START_FAILED';
17
+ function rejected(code) { throw Object.assign(new Error(code), { observationCode: code }); }
18
+ function ready() {
19
+ return new Promise((resolve) => {
20
+ const request = get({ hostname: '127.0.0.1', port: config.application.port, path: config.ready_path,
21
+ headers: { Host: `${config.hostname}:${config.application.port}` }, timeout: 250 }, (response) => {
22
+ const ok = response.statusCode >= 200 && response.statusCode < 400;
23
+ response.destroy(); resolve(ok);
24
+ });
25
+ request.on('timeout', () => request.destroy()); request.on('error', () => resolve(false));
26
+ });
27
+ }
28
+ try {
29
+ application = spawn('/usr/bin/bwrap', [
30
+ '--die-with-parent', '--unshare-pid', '--unshare-ipc', '--unshare-uts',
31
+ // Network remains shared with the *isolated* observer, never the host.
32
+ ...['/usr', '/bin', '/sbin', '/lib', '/lib64'].filter((path) => existsSync(path)).flatMap((path) => ['--ro-bind', path, path]),
33
+ '--dir', '/etc', ...(existsSync('/etc/ld.so.cache') ? ['--ro-bind', '/etc/ld.so.cache', '/etc/ld.so.cache'] : []),
34
+ '--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', '--tmpfs', '/run',
35
+ '--bind', config.working_dir, config.working_dir,
36
+ '--ro-bind', '/run/chati-observer-tools/application', '/run/chati-application',
37
+ '--chdir', config.working_dir, '--', '/run/chati-application', ...config.application.args,
38
+ ], { detached: true, env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', TZ: 'UTC',
39
+ TMPDIR: '/tmp', PORT: String(config.application.port), HOST: '127.0.0.1' }, stdio: ['pipe', 'pipe', 'pipe'] });
40
+ let appBytes = 0;
41
+ for (const stream of ['stdout', 'stderr']) application[stream].on('data', (bytes) => {
42
+ // Never mix builder-controlled stdout with the observation protocol.
43
+ appBytes += bytes.length;
44
+ if (appBytes > 2 * 1024 * 1024) {
45
+ try { process.kill(-application.pid, 'SIGKILL'); } catch { /* group already exited */ }
46
+ }
47
+ });
48
+ applicationClosed = new Promise((resolve) => {
49
+ application.once('error', () => { applicationExited = true; resolve(); });
50
+ application.once('close', () => { applicationExited = true; resolve(); });
51
+ });
52
+ application.stdin.on('error', () => {});
53
+ application.stdin.end(config.application.stdin);
54
+ const deadline = performance.now() + Math.min(30000, config.timeout_ms);
55
+ while (!await ready()) {
56
+ if (applicationExited) rejected('BROWSER_APPLICATION_EXITED');
57
+ if (performance.now() >= deadline) rejected('BROWSER_APPLICATION_NOT_READY');
58
+ await delay(25);
59
+ }
60
+ stage = 'BROWSER_LAUNCH_FAILED';
61
+ Object.assign(observed, await observeBrowserScenario({ chromium, config, isApplicationAlive: () => !applicationExited,
62
+ launchOptions: { executablePath: `/run/chati-observer-tools/chromium/${config.browser_file}`,
63
+ args: [`--host-resolver-rules=MAP ${config.hostname} 127.0.0.1, MAP * ~NOTFOUND`, '--no-proxy-server'] } }));
64
+ } catch (error) {
65
+ observed.error_code = error.observationCode || stage;
66
+ } finally {
67
+ if (application && !applicationExited) {
68
+ try { process.kill(-application.pid, 'SIGKILL'); } catch { /* process exited during cleanup */ }
69
+ }
70
+ if (applicationClosed) await applicationClosed;
71
+ observed.application_stopped = applicationExited;
72
+ }
73
+ process.stdout.write(JSON.stringify(observed));