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.
- package/bin/chati.js +151 -123
- package/framework/agents/build/dev.md +4 -4
- package/framework/agents/deploy/devops.md +3 -3
- package/framework/agents/discover/brief.md +18 -6
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +4 -4
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +3 -3
- package/framework/agents/plan/tasks.md +47 -19
- package/framework/agents/plan/ux-brand-architect.md +1 -1
- package/framework/agents/plan/ux.md +26 -2
- package/framework/agents/quality/qa-implementation.md +8 -8
- package/framework/agents/quality/qa-planning.md +58 -4
- package/framework/agents/quality/qa-visual.md +4 -4
- package/framework/config.yaml +2 -2
- package/framework/constitution.md +12 -1
- package/framework/context/governance.md +2 -1
- package/framework/context/root.md +3 -1
- package/framework/domains/constitution.yaml +1 -1
- package/framework/domains/global.yaml +1 -1
- package/framework/domains/workflows/standard-flow.yaml +7 -8
- package/framework/hooks/advance-trigger.js +3 -2
- package/framework/hooks/brief-validator.js +1 -1
- package/framework/hooks/post-dev.js +1 -1
- package/framework/hooks/team-quality-gate.js +9 -2
- package/framework/i18n/en.yaml +1 -0
- package/framework/i18n/es.yaml +1 -0
- package/framework/i18n/fr.yaml +1 -0
- package/framework/i18n/pt.yaml +1 -0
- package/framework/manifest.json +83 -83
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +14 -6
- package/framework/orchestrator/chati.md +61 -23
- package/framework/rules/human-writing-style.md +7 -0
- package/framework/schemas/session.schema.json +25 -2
- package/framework/schemas/task.schema.json +26 -0
- package/framework/tasks/orchestrator-handoff.md +10 -10
- package/framework/tasks/orchestrator-resume.md +3 -3
- package/framework/workflows/brownfield-fullstack.yaml +6 -3
- package/framework/workflows/brownfield-service.yaml +5 -3
- package/framework/workflows/brownfield-ui.yaml +5 -3
- package/framework/workflows/greenfield-fullstack.yaml +6 -3
- package/framework/workflows/standard-flow.yaml +8 -3
- package/node_modules/@chati/core/native/browser-proof-wire.h +29 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.c +36 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.h +32 -0
- package/node_modules/@chati/core/native/darwin-browser-proof.h +86 -0
- package/node_modules/@chati/core/native/darwin-code-identity.h +35 -0
- package/node_modules/@chati/core/native/darwin-launch-messages.h +37 -0
- package/node_modules/@chati/core/native/darwin-launcher.c +312 -0
- package/node_modules/@chati/core/native/descriptor-fs.c +407 -0
- package/node_modules/@chati/core/native/execution-admission.h +59 -0
- package/node_modules/@chati/core/native/execution-gate.c +41 -0
- package/node_modules/@chati/core/native/linux-process.c +41 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/darwin-launcher +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/execution-gate +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/execution-gate +0 -0
- package/node_modules/@chati/core/native/process-info-pipe.c +91 -0
- package/node_modules/@chati/core/package.json +2 -1
- package/node_modules/@chati/core/scripts/build-native.js +36 -0
- package/node_modules/@chati/core/src/darwin-browser-proof.js +87 -0
- package/node_modules/@chati/core/src/darwin-host-boot.js +58 -0
- package/node_modules/@chati/core/src/darwin-process.js +79 -0
- package/node_modules/@chati/core/src/darwin-supervisor-channels.js +82 -0
- package/node_modules/@chati/core/src/darwin-terminal-receipt.js +111 -0
- package/node_modules/@chati/core/src/descriptor-fs.js +222 -0
- package/node_modules/@chati/core/src/execution-gate.js +8 -0
- package/node_modules/@chati/core/src/host-boot-marker.js +45 -0
- package/node_modules/@chati/core/src/index.js +18 -0
- package/node_modules/@chati/core/src/linux-host-boot.js +218 -0
- package/node_modules/@chati/core/src/linux-process.js +133 -0
- package/node_modules/@chati/planning/src/browser-verification-plan.js +71 -0
- package/node_modules/@chati/planning/src/index.js +33 -0
- package/node_modules/@chati/planning/src/planning-material.js +64 -0
- package/node_modules/@chati/planning/src/verification-plan.js +57 -0
- package/node_modules/@chati/rail/package.json +1 -0
- package/node_modules/@chati/rail/src/execution-restoration.js +92 -0
- package/node_modules/@chati/rail/src/index.js +812 -167
- package/node_modules/@chati/rail/src/provider-processing.js +118 -0
- package/node_modules/@chati/rail/src/terminal-containment.js +23 -0
- package/node_modules/@chati/release-lane/src/index.js +40 -43
- package/node_modules/@chati/review-council/src/index.js +223 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +67 -58
- package/package-artifact-manifest.json +1 -1
- package/package-artifact-manifest.sig +1 -1
- package/package.json +3 -2
- package/scripts/verify-real-harness-e2e.js +20 -8
- package/src/installer/core.js +147 -107
- package/src/installer/package-artifact.js +7 -0
- package/src/installer/preflight.js +13 -0
- package/src/installer/provider-overlay.js +1 -2
- package/src/installer/templates.js +58 -0
- package/src/installer-v2/catalog-client.js +31 -1
- package/src/installer-v2/clickup-connection-storage.js +92 -0
- package/src/installer-v2/clickup-mcp-client.js +155 -0
- package/src/installer-v2/clickup-oauth-loopback.js +96 -0
- package/src/installer-v2/clickup-oauth.js +233 -0
- package/src/installer-v2/index.js +141 -15
- package/src/installer-v2/installation-authority.js +98 -4
- package/src/installer-v2/provider-executable.js +137 -20
- package/src/installer-v2/wizard-installation.js +11 -0
- package/src/orchestrator/agent-selector.js +39 -1
- package/src/orchestrator/browser-artifact-store.js +131 -0
- package/src/orchestrator/cli.js +762 -205
- package/src/orchestrator/clickup-mcp-observer.js +226 -0
- package/src/orchestrator/clickup-runtime.js +45 -24
- package/src/orchestrator/council-gate-runtime.js +903 -0
- package/src/orchestrator/evidence-authority-enrollment.js +75 -0
- package/src/orchestrator/evidence-authority-request.js +46 -0
- package/src/orchestrator/handoff-engine.js +4 -11
- package/src/orchestrator/pipeline-manager.js +42 -4
- package/src/orchestrator/planning-contract-revision.js +711 -0
- package/src/orchestrator/planning-reconciliation.js +302 -0
- package/src/orchestrator/planning-runtime.js +476 -29
- package/src/orchestrator/rail-adjudication-evidence.js +15 -24
- package/src/orchestrator/rail-human-effort-authority.js +46 -0
- package/src/orchestrator/rail-human-effort.js +149 -0
- package/src/orchestrator/rail-runtime.js +291 -47
- package/src/orchestrator/rail-verification.js +278 -0
- package/src/orchestrator/review-runtime.js +37 -2
- package/src/orchestrator/runtime-installation-v2.js +143 -24
- package/src/orchestrator/session-manager.js +1457 -29
- package/src/tasks/handoff.js +57 -33
- package/src/terminal/adapters/claude-adapter.js +3 -0
- package/src/terminal/adapters/codex-adapter.js +13 -0
- package/src/terminal/adapters/grok-adapter.js +1 -0
- package/src/terminal/agent-attempt-budget.js +35 -0
- package/src/terminal/browser-endpoint-proxy.js +174 -0
- package/src/terminal/browser-observer-driver.js +94 -0
- package/src/terminal/browser-observer-supervisor.js +73 -0
- package/src/terminal/browser-observer.js +95 -0
- package/src/terminal/browser-process-facts.js +75 -0
- package/src/terminal/browser-runtime.js +131 -0
- package/src/terminal/darwin-authority-policy.js +213 -0
- package/src/terminal/darwin-browser-application.js +116 -0
- package/src/terminal/darwin-browser-observer.js +57 -0
- package/src/terminal/darwin-execution.js +185 -0
- package/src/terminal/executable-snapshot.js +202 -0
- package/src/terminal/handoff-parser.js +48 -2
- package/src/terminal/isolation.js +14 -13
- package/src/terminal/prompt-builder.js +176 -15
- package/src/terminal/provider-preflight.js +47 -27
- package/src/terminal/rail-execution-worktree.js +22 -6
- package/src/terminal/rail-process-observer.js +10 -0
- package/src/terminal/rail-prompts.js +21 -3
- package/src/terminal/rail-readonly-workspace.js +54 -5
- package/src/terminal/rail-worktree-checkpoint.js +315 -0
- package/src/terminal/rail-worktree-restoration.js +485 -0
- package/src/terminal/run-agent.js +164 -42
- package/src/terminal/run-council-gate.js +338 -0
- package/src/terminal/run-planning-reconciliation.js +49 -0
- package/src/terminal/run-rail-adjudication.js +29 -7
- package/src/terminal/run-rail-recovery.js +97 -0
- package/src/terminal/run-rail-review.js +36 -15
- package/src/terminal/run-rail-rework.js +26 -15
- package/src/terminal/run-rail-task.js +22 -9
- package/src/terminal/runtime-native-helper.js +85 -0
- package/src/terminal/spawner.js +899 -142
- package/src/terminal/verification-process-facts.js +49 -0
- package/src/upgrade/entrypoints.js +68 -0
- package/src/upgrade/generation.js +194 -0
- package/src/upgrade/maintenance.js +79 -0
- package/src/upgrade/paths.js +81 -0
- package/src/upgrade/upgrade.js +267 -0
- package/src/utils/execution-role.js +15 -0
- package/src/utils/handoff-paths.js +59 -0
- package/src/wizard/i18n.js +1 -0
- package/src/wizard/index.js +17 -5
- package/src/wizard/questions.js +24 -12
package/src/tasks/handoff.js
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
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
|
|
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
|
-
|
|
179
|
+
let meta = {};
|
|
176
180
|
let body = content;
|
|
177
181
|
|
|
178
182
|
if (frontmatterMatch) {
|
|
179
|
-
|
|
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
|
-
|
|
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:
|
|
215
|
-
from_task:
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
244
|
+
deep_context: (sectionLines.deep_context || []).join('\n').trim(),
|
|
245
|
+
outputs: listField('outputs', 'artifacts'),
|
|
222
246
|
decisions: sections.decisions || [],
|
|
223
|
-
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));
|