flowviant 0.125.20 → 0.125.22
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/README.md +11 -1
- package/bin/cli.mjs +13 -0
- package/bin/lib/agentCards.mjs +24 -1
- package/bin/lib/agentPlan.mjs +3 -0
- package/bin/lib/agentQueueContext.mjs +20 -1
- package/bin/lib/artifactContracts.mjs +132 -2
- package/bin/lib/artifactIteration.mjs +2 -1
- package/bin/lib/artifactReporter.mjs +2 -2
- package/bin/lib/artifacts.mjs +8 -1
- package/bin/lib/claudeStream.mjs +7 -0
- package/bin/lib/codexFencedMount.mjs +2 -2
- package/bin/lib/codexSubagents.mjs +6 -2
- package/bin/lib/knowledgeFetch.mjs +8 -3
- package/bin/lib/knowledgeFiles.mjs +4 -4
- package/bin/lib/orchestrator.mjs +40 -14
- package/bin/lib/orchestratorHooks.mjs +15 -2
- package/bin/lib/orchestratorNative.mjs +30 -9
- package/bin/lib/pinnedCapture.mjs +19 -8
- package/bin/lib/postureTools.mjs +2 -2
- package/bin/lib/prompts.mjs +25 -4
- package/bin/lib/publicReferenceBrowser.mjs +180 -0
- package/bin/lib/publicReferenceNetwork.mjs +131 -0
- package/bin/lib/publicReferenceProtocol.mjs +55 -0
- package/bin/lib/publicReferenceRuntime.mjs +65 -0
- package/bin/lib/publicReferenceTool.mjs +89 -0
- package/bin/lib/referenceCaptureProtocol.mjs +12 -0
- package/bin/lib/referenceCaptures.mjs +140 -0
- package/bin/lib/runTurn.mjs +8 -0
- package/bin/lib/runtimeCodex.mjs +1 -1
- package/bin/lib/runtimeEvents.mjs +24 -2
- package/bin/lib/turnGaps.mjs +47 -0
- package/bin/lib/uiReview.mjs +305 -0
- package/bin/lib/uiReviewBrowser.mjs +212 -0
- package/bin/lib/uiReviewContract.mjs +51 -0
- package/bin/lib/uiReviewTurn.mjs +15 -0
- package/bin/lib/websiteAccess.mjs +68 -0
- package/bin/lib/websiteTrace.mjs +36 -0
- package/bin/lib/work.mjs +1 -0
- package/bin/lib/workAgentPrecheck.mjs +4 -1
- package/bin/lib/workAgentTools.mjs +3 -2
- package/bin/lib/workAgentTurnExecution.mjs +49 -5
- package/bin/lib/workAgentTurnOutcome.mjs +13 -3
- package/bin/lib/workAgentTurns.mjs +2 -0
- package/bin/lib/workToolLog.mjs +24 -3
- package/package.json +1 -1
package/bin/lib/orchestrator.mjs
CHANGED
|
@@ -58,7 +58,9 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
58
58
|
// wakes never evicts a note input() accepted.
|
|
59
59
|
const inbox = new Map(), wakes = new Map();
|
|
60
60
|
const pending = new Map(), steps = new Map();
|
|
61
|
-
|
|
61
|
+
// Whether this run admitted a sealed (Codex V2) spawn or follow-up, whose
|
|
62
|
+
// child reads its contract at SubagentStart or its first tool instead.
|
|
63
|
+
let closed = false, polling = false, rootSession = null, offerAt = 0, sealedRun = false;
|
|
62
64
|
const identity = readOnlyRun ? { ...readOnlyRun, instance: DAEMON_INSTANCE } : { scope: 'agent', turnId, instance: DAEMON_INSTANCE };
|
|
63
65
|
const ended = () => closed || signal?.aborted;
|
|
64
66
|
const resolve = id => aliases.get(id) ?? id;
|
|
@@ -123,6 +125,19 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
123
125
|
return held.find(a => a.brief === brief) ?? held.find(a => title && a.title === title) ?? held.find(a => kind && a.kind === kind)
|
|
124
126
|
?? held[0] ?? open.find(a => a.brief === brief) ?? open.find(a => title && a.title === title);
|
|
125
127
|
};
|
|
128
|
+
// A child's contract, naming its record by its id (the words ride its
|
|
129
|
+
// prompt) or, with no id, a sealed Codex V2 child's record by its task
|
|
130
|
+
// name: the plain `Task name: /root/<name>` header Codex puts on its task
|
|
131
|
+
// message, which resolves through the spawn's task_name alias (`named`).
|
|
132
|
+
// The start hook cannot say which spawn a child came from; its name can.
|
|
133
|
+
const childContract = (id = null) => {
|
|
134
|
+
const ref = id ?? 'your task name: the "Task name:" line of your task message, such as /root/backend',
|
|
135
|
+
arg = id ? `agentId=${id}` : 'agentId set to that task name';
|
|
136
|
+
return (readOnlyRun
|
|
137
|
+
? `\n\nRead-only investigation agent record id: ${ref}. FIRST call card_orchestrator.register_agent with ${arg} before work (tool discovery is allowed). Share this checkout and its repository/knowledge read boundary. Write no file, edit nothing, commit nothing, start no work and call no board or staging tool. Return findings, source paths and uncertainties in your final message to the coordinator. Ask questions only through card_orchestrator.agent_question with ${arg}, and read its answer before proceeding. ${readOnlyRun.scope === 'capture' ? 'Call no flowviant tool, including streaming. Only the coordinator dedupes and authors staged outcomes and asks the person through the existing single-question flowviant-ask fence.' : 'Only the coordinator reconciles the complete workload and returns the proposal.'}`
|
|
138
|
+
: `\n\nCard orchestrator agent record id: ${ref}. FIRST call card_orchestrator.register_agent with ${arg} before doing work (tool discovery is allowed). Share this worktree and preserve others’ edits. Ask questions through card_orchestrator.agent_question with ${arg}; read its answer before proceeding. Only your orchestrator escalates person-only decisions. When you are done, end with your work or findings as your final message: that is how they reach your orchestrator, which owns the card’s final commit and JSON. agent_question is only for a question you cannot settle yourself, never for handing back results.`)
|
|
139
|
+
+ (!readOnlyRun ? assignmentContext() : '') + (access === 'read' && !readOnlyRun ? READ_ONLY_CHILD : '');
|
|
140
|
+
};
|
|
126
141
|
// Admitted: the offer is spent, and notes that paused this child are being
|
|
127
142
|
// delivered, so its checkpoint lets it work until their route lands.
|
|
128
143
|
const start = (a, now) => {
|
|
@@ -146,7 +161,8 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
146
161
|
if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
|
|
147
162
|
if (calls.size > 128) calls.delete(calls.keys().next().value);
|
|
148
163
|
if (!records.has(id) && records.size >= SUBAGENT_CAP) return { deny: `This run already has ${SUBAGENT_CAP} agent records. Continue the work yourself.` };
|
|
149
|
-
|
|
164
|
+
// A sealed message's words are unknown: no brief, never its ciphertext.
|
|
165
|
+
const a = emit({ id, ...(spawn.sealed ? {} : { brief }), title: spawn.title ?? '', kind: spawn.kind ?? '', status: 'waiting', now: WAIT });
|
|
150
166
|
if (!slot(id)) { a.denied = resumed ? 'resume' : 'spawn'; return { deny: `${WAIT}. Agent id: ${id}. Keep working yourself; you will be told when this spawn can be retried. Do not wait for a child that has not started.` }; }
|
|
151
167
|
// Claude's SendMessage addresses a child by its Agent `name`, Codex V2 by its task name.
|
|
152
168
|
for (const name of spawn.names) if (filled(name) && name.length <= 160) aliases.set(name, id);
|
|
@@ -155,11 +171,9 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
155
171
|
// stream, and the parent is asked to say why in its own update.
|
|
156
172
|
const choice = childChoice(event, spawn, card);
|
|
157
173
|
if (choice) trace.prose('note', `Runs on ${choice}.`, id);
|
|
158
|
-
|
|
159
|
-
? `\n\nRead-only investigation agent record id: ${id}. FIRST call card_orchestrator.register_agent with agentId=${id} before work (tool discovery is allowed). Share this checkout and its repository/knowledge read boundary. Write no file, edit nothing, commit nothing, start no work and call no board or staging tool. Return findings, source paths and uncertainties in your final message to the coordinator. Ask questions only through card_orchestrator.agent_question with agentId=${id}, and read its answer before proceeding. ${readOnlyRun.scope === 'capture' ? 'Call no flowviant tool, including streaming. Only the coordinator dedupes and authors staged outcomes and asks the person through the existing single-question flowviant-ask fence.' : 'Only the coordinator reconciles the complete workload and returns the proposal.'}`
|
|
160
|
-
: `\n\nCard orchestrator agent record id: ${id}. FIRST call card_orchestrator.register_agent with agentId=${id} before doing work (tool discovery is allowed). Share this worktree and preserve others’ edits. Ask questions through card_orchestrator.agent_question with agentId=${id}; read its answer before proceeding. Only your orchestrator escalates person-only decisions. When you are done, end with your work or findings as your final message: that is how they reach your orchestrator, which owns the card’s final commit and JSON. agent_question is only for a question you cannot settle yourself, never for handing back results.`;
|
|
174
|
+
if (spawn.sealed) sealedRun = true;
|
|
161
175
|
return { context: `Agent record id: ${id}. Use it with card_orchestrator for routing and questions.${spawn.relocated ? ' Its isolation and cwd were removed: children share this card’s worktree on this machine.' : ''}${choice ? ` This child runs on ${choice}: name that choice and why in your next update.` : ''}`,
|
|
162
|
-
updatedInput: withChildContract(event, spawn,
|
|
176
|
+
updatedInput: withChildContract(event, spawn, spawn.sealed ? '' : childContract(id), chosen.brain) };
|
|
163
177
|
}
|
|
164
178
|
// Native readings never revive a settled record or start a waiting spawn
|
|
165
179
|
// or follow-up (Codex records a refused call's start anyway); only an
|
|
@@ -205,10 +219,13 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
205
219
|
steps.delete(native);
|
|
206
220
|
if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
|
|
207
221
|
};
|
|
208
|
-
// Codex V2 names a child `backend` or `/root/backend` interchangeably
|
|
222
|
+
// Codex V2 names a child `backend` or `/root/backend` interchangeably, and
|
|
223
|
+
// a sealed child registers by the canonical path its task message wears.
|
|
209
224
|
const named = native => {
|
|
225
|
+
if (typeof native !== 'string') return resolve(native);
|
|
210
226
|
if (aliases.has(native) || records.has(native)) return resolve(native);
|
|
211
|
-
const
|
|
227
|
+
const near = k => k.endsWith(`/${native}`) || native.endsWith(`/${k}`);
|
|
228
|
+
const hits = new Set([...[...aliases].filter(([k]) => near(k)).map(([, v]) => v), ...[...records.keys()].filter(near)]);
|
|
212
229
|
return hits.size === 1 ? [...hits][0] : native;
|
|
213
230
|
};
|
|
214
231
|
function postSpawn(event) {
|
|
@@ -219,7 +236,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
219
236
|
if (failed) { release(id); emit({ id, status: 'stopped' }); }
|
|
220
237
|
}
|
|
221
238
|
function preResume(event) {
|
|
222
|
-
const input = event.tool_input ?? {}, { claude, native } = followupTarget(event);
|
|
239
|
+
const input = event.tool_input ?? {}, { claude, native, sealed } = followupTarget(event);
|
|
223
240
|
if (native === null) return claude ? null : { deny: 'Name the native agent to resume.' };
|
|
224
241
|
const id = named(native);
|
|
225
242
|
if (!records.has(id)) {
|
|
@@ -227,7 +244,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
227
244
|
// SendMessage also reaches "main" and teammates: only a known child is a follow-up.
|
|
228
245
|
if (claude) return null;
|
|
229
246
|
if (records.size >= SUBAGENT_CAP) return { deny: 'Continue the work in this run’s orchestrator.' };
|
|
230
|
-
emit({ id, kind: '', title: native, brief: typeof input.message === 'string' ? input.message : '', status: 'waiting', now: WAIT, resumable: true });
|
|
247
|
+
emit({ id, kind: '', title: native, ...(sealed ? {} : { brief: typeof input.message === 'string' ? input.message : '' }), status: 'waiting', now: WAIT, resumable: true });
|
|
231
248
|
}
|
|
232
249
|
if (spawnable(records.get(id))) return { deny: `Agent ${id} has not started. Retry its native spawn instead.` };
|
|
233
250
|
// A refused follow-up retried on its offered slot reaches a finished child.
|
|
@@ -236,6 +253,9 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
236
253
|
start(records.get(id), 'Continuing with the orchestrator’s note');
|
|
237
254
|
records.get(id).queued = running;
|
|
238
255
|
calls.set(event.tool_use_id, id);
|
|
256
|
+
// A sealed follow-up rides as written; its child registers by task name
|
|
257
|
+
// if this run has not bound it yet (the refusal at its first tool says how).
|
|
258
|
+
if (sealed) { sealedRun = true; return {}; }
|
|
239
259
|
return typeof input.message === 'string' ? { updatedInput: { ...input,
|
|
240
260
|
message: input.message + `\n\nCurrent card orchestrator agent record id: ${id}. Before continuing work, call card_orchestrator.register_agent with this id. Return questions to the orchestrator.` } } : {};
|
|
241
261
|
}
|
|
@@ -259,6 +279,10 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
259
279
|
return { deny: 'Your orchestrator has a note for this checkpoint. Stop editing and wait for its follow-up; keep your work so far.' };
|
|
260
280
|
}
|
|
261
281
|
function hook(ev) {
|
|
282
|
+
// A sealed child's contract, as it starts (measured on codex-cli 0.163
|
|
283
|
+
// and 0.164: this additionalContext reached the child, and the event
|
|
284
|
+
// names the child's agent_id but not the spawn that started it).
|
|
285
|
+
if (ev.hook_event_name === 'SubagentStart') return sealedRun && childOf(ev) ? { context: childContract().trimStart() } : {};
|
|
262
286
|
// A child's next tool round carries a follow-up queued to it; after its
|
|
263
287
|
// stop, its own tool use is the CLI running it again with that follow-up.
|
|
264
288
|
const kid = records.get(resolve(childOf(ev)));
|
|
@@ -275,12 +299,14 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
275
299
|
if (String(ev.tool_name).includes(ORCHESTRATOR_SERVER) && String(ev.tool_name).endsWith('register_agent')) {
|
|
276
300
|
// Binding the root would pause the orchestrator at its children's checkpoints.
|
|
277
301
|
if (!child) return { deny: 'Only a native child registers an agent record. As the orchestrator, use the record id with describe_agent, route_message or answer_agent.' };
|
|
278
|
-
const id =
|
|
279
|
-
if (!records.has(id)) return { deny: 'Use the agent record id in your prompt.' };
|
|
302
|
+
const id = named(ev.tool_input?.agentId);
|
|
303
|
+
if (!records.has(id)) return { deny: sealedRun ? `That names no agent in this run.${childContract()}` : 'Use the agent record id in your prompt.' };
|
|
280
304
|
bind(child, id);
|
|
281
305
|
}
|
|
306
|
+
// A sealed child that missed (or never got) its start's words reads
|
|
307
|
+
// them here, whole: an earlier run's follow-up, or a CLI without the hook.
|
|
282
308
|
if (child && !records.has(resolve(child)) && !isDiscoveryTool(ev.tool_name))
|
|
283
|
-
return { deny: 'Before working, call card_orchestrator.register_agent with the agent record id in your prompt. Tool discovery is allowed.' };
|
|
309
|
+
return { deny: sealedRun ? `Register with your orchestrator before working.${childContract()}` : 'Before working, call card_orchestrator.register_agent with the agent record id in your prompt. Tool discovery is allowed.' };
|
|
284
310
|
if ((child && access === 'read' || readOnlyRun) && isChildWriteTool(ev.tool_name)) return { deny: readOnlyRun ? 'This attempt only reads: no file writes.' : READ_ONLY_REFUSAL };
|
|
285
311
|
if (rootTools && captureToolName(ev.tool_name)) {
|
|
286
312
|
if (child || !rootSession || ev.session_id !== rootSession) return { deny: 'Only the top-level capture coordinator may call capture tools. Return findings to it.' };
|
|
@@ -314,7 +340,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
314
340
|
return held.length ? { ...result, context: [result.context, ...held].filter(Boolean).join('\n\n') } : result;
|
|
315
341
|
}
|
|
316
342
|
if (action === 'route_message') return route(args);
|
|
317
|
-
const id =
|
|
343
|
+
const id = named(args.agentId);
|
|
318
344
|
// A person’s answer starts a new CLI turn. The parent carries the earlier
|
|
319
345
|
// question and native identity in its own conversation; relay that known
|
|
320
346
|
// context instead of inventing a new child or requiring another question.
|
|
@@ -24,9 +24,22 @@ export function orchestratorHooks() {
|
|
|
24
24
|
return { PreToolUse: [{ matcher: '.*', hooks }], PostToolUse: [{ matcher: spawns, hooks }], PostToolUseFailure: [{ matcher: spawns, hooks }], SubagentStop: [{ hooks }] };
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* CODEX'S SubagentStart HANDS A SEALED CHILD ITS CONTRACT (2026-10-24). A V2
|
|
29
|
+
* spawn's message is ciphertext the gate must not edit (orchestratorNative.mjs
|
|
30
|
+
* `isSealedMessage`), so the words reach the child as this hook's
|
|
31
|
+
* additionalContext. Measured on real codex-cli 0.163.0 and 0.164.0 with this
|
|
32
|
+
* exact `matcher=".*"` row: the hook ran once per child and the child obeyed
|
|
33
|
+
* its context. Both CLIs start normally with the key (and with an unknown
|
|
34
|
+
* event's, offline). An older CLI is unmeasured, so it goes without; its
|
|
35
|
+
* children meet the same words when the gate refuses their first tool.
|
|
36
|
+
*/
|
|
37
|
+
export const CODEX_SUBAGENT_START_MIN = '0.163.0';
|
|
38
|
+
export function codexOrchestratorArgs(codexVersion) {
|
|
28
39
|
// A Codex `-c` value is TOML, quoted by the one quoter (codexToml.mjs).
|
|
29
40
|
const row = `{matcher=".*",hooks=[{type="command",command=${toml(command())},timeout=${ORCHESTRATOR_HOOK_TIMEOUT_S}}]}`;
|
|
41
|
+
const start = versionAtLeast(String(codexVersion ?? '').replace(/^codex-cli\s+/, ''), CODEX_SUBAGENT_START_MIN);
|
|
30
42
|
return ['--dangerously-bypass-hook-trust', '-c', 'features.hooks=true',
|
|
31
|
-
'-c', `hooks.PreToolUse=[${row}]`, '-c', `hooks.PostToolUse=[${row}]`, '-c', `hooks.SubagentStop=[${row}]
|
|
43
|
+
'-c', `hooks.PreToolUse=[${row}]`, '-c', `hooks.PostToolUse=[${row}]`, '-c', `hooks.SubagentStop=[${row}]`,
|
|
44
|
+
...(start ? ['-c', `hooks.SubagentStart=[${row}]`] : [])];
|
|
32
45
|
}
|
|
@@ -12,8 +12,27 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { isBackgroundSpawnTool, isClaudeFollowupTool, isSpawnTool } from './nativeAgentTools.mjs';
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* A CODEX V2 MESSAGE IS CIPHERTEXT (2026-10-24). Since codex-cli 0.138
|
|
17
|
+
* (openai/codex PR #26210) the model writes spawn_agent's and followup_task's
|
|
18
|
+
* `message` encrypted, and the child's first request hands it back to be
|
|
19
|
+
* decrypted. The gate appended its contract to it, so every V2 child of the
|
|
20
|
+
* card runs from 10-19 to 10-24 (133 of them) died on that request with
|
|
21
|
+
* `invalid_encrypted_content` before its first tool. Measured on real 0.164:
|
|
22
|
+
* no hook and an identity rewrite both ran the child; one appended line failed
|
|
23
|
+
* it. Sealed, a message is relayed untouched and never read as a brief.
|
|
24
|
+
* V2 is the `collaboration` namespace (all 209 real messages, every one a
|
|
25
|
+
* `gAAAAA…` token); the token's shape is checked too, so a sealed message under
|
|
26
|
+
* another spelling is never edited. Codex V1 and Claude write plain text.
|
|
27
|
+
*/
|
|
28
|
+
const SEALED_TOKEN = /^gAAAAA[A-Za-z0-9_-]{40,}={0,2}$/;
|
|
29
|
+
export const isSealedMessage = (toolName, message) => typeof message === 'string' &&
|
|
30
|
+
!isBackgroundSpawnTool(toolName) && !isClaudeFollowupTool(toolName) &&
|
|
31
|
+
(/^collaboration[./]?(?:spawn_agent|followup_task)$/.test(String(toolName ?? '')) || SEALED_TOKEN.test(message));
|
|
32
|
+
|
|
15
33
|
/** A spawn's prompt and the fields a waiting record is matched and named by,
|
|
16
|
-
* or null when the gate cannot hold it (no call id or no text prompt).
|
|
34
|
+
* or null when the gate cannot hold it (no call id or no text prompt). A
|
|
35
|
+
* sealed message (`isSealedMessage`) gives no brief: its words are unknown. */
|
|
17
36
|
export function readSpawn(event) {
|
|
18
37
|
// A child stays in the card's worktree on this machine, inside the pool.
|
|
19
38
|
const { isolation, cwd, ...input } = event.tool_input ?? {}, callId = event.tool_use_id;
|
|
@@ -21,18 +40,20 @@ export function readSpawn(event) {
|
|
|
21
40
|
const items = Array.isArray(input.items) ? input.items : null;
|
|
22
41
|
const key = ['prompt', 'message'].find(k => typeof input[k] === 'string') ?? (items ? 'items' : null);
|
|
23
42
|
if (!callId || !key) return null;
|
|
24
|
-
const
|
|
25
|
-
|
|
43
|
+
const sealed = key === 'message' && isSealedMessage(event.tool_name, input.message);
|
|
44
|
+
const brief = sealed ? null : key === 'items' ? items.filter(i => i?.type === 'text' && typeof i.text === 'string').map(i => i.text).join('\n') : input[key];
|
|
45
|
+
return { callId, input, items, key, brief, sealed, relocated: isolation !== undefined || cwd !== undefined,
|
|
26
46
|
resume: typeof input.resume === 'string' ? input.resume : null,
|
|
27
47
|
title: input.description ?? input.task_name, kind: input.subagent_type ?? input.agent_type,
|
|
28
48
|
names: [input.name, input.task_name] };
|
|
29
49
|
}
|
|
30
50
|
|
|
31
51
|
/** The spawn's input as the gate rewrites it: the contract appended to its
|
|
32
|
-
* prompt
|
|
33
|
-
*
|
|
52
|
+
* prompt (never to a sealed message, whose child is handed it at its start),
|
|
53
|
+
* a Claude child sent to the background, and the child's model and effort
|
|
54
|
+
* (`brain`, from `childBrain`) where the spawn named none. */
|
|
34
55
|
export const withChildContract = (event, spawn, contract, brain = {}) => ({ ...spawn.input, ...brain, ...(isBackgroundSpawnTool(event.tool_name) ? { run_in_background: true } : {}),
|
|
35
|
-
...(spawn.key === 'items' ? { items: [...spawn.items, { type: 'text', text: contract.trimStart() }] } : { [spawn.key]: spawn.input[spawn.key] + contract }) });
|
|
56
|
+
...(spawn.sealed ? {} : spawn.key === 'items' ? { items: [...spawn.items, { type: 'text', text: contract.trimStart() }] } : { [spawn.key]: spawn.input[spawn.key] + contract }) });
|
|
36
57
|
|
|
37
58
|
/** Both CLIs' effort ladder, lowest first (Codex stops at xhigh). */
|
|
38
59
|
export const EFFORT_LADDER = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
@@ -178,11 +199,11 @@ export function spawnReceipt(event) {
|
|
|
178
199
|
return { ids, failed: Boolean(output?.is_error || output?.isError || event.error) };
|
|
179
200
|
}
|
|
180
201
|
|
|
181
|
-
/** Which child a follow-up addresses (null when it names none in bounds),
|
|
182
|
-
* whether it is Claude's SendMessage. */
|
|
202
|
+
/** Which child a follow-up addresses (null when it names none in bounds),
|
|
203
|
+
* whether it is Claude's SendMessage, and whether its message is sealed. */
|
|
183
204
|
export function followupTarget(event) {
|
|
184
205
|
const input = event.tool_input ?? {}, claude = isClaudeFollowupTool(event.tool_name);
|
|
185
206
|
// Measured Claude 2.1.302 PreToolUse SendMessage input: {to, message, recipient, content, …}.
|
|
186
207
|
const native = claude ? input.to ?? input.recipient ?? input.target ?? input.id : input.target ?? input.id;
|
|
187
|
-
return { claude, native: typeof native === 'string' && native.length <= 160 ? native : null };
|
|
208
|
+
return { claude, native: typeof native === 'string' && native.length <= 160 ? native : null, sealed: isSealedMessage(event.tool_name, input.message) };
|
|
188
209
|
}
|
|
@@ -59,17 +59,28 @@ const ATTACH_WAIT_MS = 2_000;
|
|
|
59
59
|
* protocol's error; `on(fn)` receives every event; `close(reason)` rejects
|
|
60
60
|
* whatever is still waiting (the browser is gone).
|
|
61
61
|
*/
|
|
62
|
-
export function createCdpClient(writable, readable) {
|
|
62
|
+
export function createCdpClient(writable, readable, { maxMessageBytes = Infinity } = {}) {
|
|
63
63
|
let nextId = 0;
|
|
64
64
|
let closed = null;
|
|
65
65
|
const waiting = new Map();
|
|
66
66
|
const listeners = [];
|
|
67
67
|
let chunks = [];
|
|
68
|
+
let chunkBytes = 0;
|
|
69
|
+
const close = reason => {
|
|
70
|
+
if (closed) return;
|
|
71
|
+
closed = reason;
|
|
72
|
+
chunks = []; chunkBytes = 0;
|
|
73
|
+
for (const w of waiting.values()) w.reject(new Error(reason));
|
|
74
|
+
waiting.clear();
|
|
75
|
+
};
|
|
68
76
|
readable.on('data', (d) => {
|
|
77
|
+
if (closed) return;
|
|
69
78
|
let start = 0;
|
|
70
79
|
for (let i = 0; i < d.length; i++) {
|
|
71
80
|
if (d[i] !== 0) continue;
|
|
72
81
|
chunks.push(d.subarray(start, i));
|
|
82
|
+
chunkBytes += i - start;
|
|
83
|
+
if (chunkBytes > maxMessageBytes) { close('DevTools message exceeded the reference bound.'); return; }
|
|
73
84
|
start = i + 1;
|
|
74
85
|
let msg;
|
|
75
86
|
try {
|
|
@@ -78,6 +89,7 @@ export function createCdpClient(writable, readable) {
|
|
|
78
89
|
msg = null;
|
|
79
90
|
}
|
|
80
91
|
chunks = [];
|
|
92
|
+
chunkBytes = 0;
|
|
81
93
|
if (!msg) continue;
|
|
82
94
|
if (msg.id != null && waiting.has(msg.id)) {
|
|
83
95
|
const w = waiting.get(msg.id);
|
|
@@ -88,7 +100,11 @@ export function createCdpClient(writable, readable) {
|
|
|
88
100
|
for (const fn of listeners) fn(msg);
|
|
89
101
|
}
|
|
90
102
|
}
|
|
91
|
-
if (start < d.length)
|
|
103
|
+
if (start < d.length) {
|
|
104
|
+
chunkBytes += d.length - start;
|
|
105
|
+
if (chunkBytes > maxMessageBytes) { close('DevTools message exceeded the reference bound.'); return; }
|
|
106
|
+
chunks.push(d.subarray(start));
|
|
107
|
+
}
|
|
92
108
|
});
|
|
93
109
|
return {
|
|
94
110
|
send(method, params = {}, sessionId) {
|
|
@@ -108,12 +124,7 @@ export function createCdpClient(writable, readable) {
|
|
|
108
124
|
on(fn) {
|
|
109
125
|
listeners.push(fn);
|
|
110
126
|
},
|
|
111
|
-
close
|
|
112
|
-
if (closed) return;
|
|
113
|
-
closed = reason;
|
|
114
|
-
for (const w of waiting.values()) w.reject(new Error(reason));
|
|
115
|
-
waiting.clear();
|
|
116
|
-
},
|
|
127
|
+
close,
|
|
117
128
|
};
|
|
118
129
|
}
|
|
119
130
|
|
package/bin/lib/postureTools.mjs
CHANGED
|
@@ -15,10 +15,10 @@ const POSTURES = {
|
|
|
15
15
|
research: {
|
|
16
16
|
claude: {
|
|
17
17
|
has: 'the repository’s files (never .env files), the project’s knowledge library, web search and web fetch, and writing under .flowviant/artifacts/ and the granted scratch directory',
|
|
18
|
-
lacks: 'no measured native image-generation tool, no delegation, guarded read commands only (no helper scripts or
|
|
18
|
+
lacks: 'no measured native image-generation tool, no delegation, guarded read commands only (no helper scripts or direct browser launches), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
|
|
19
19
|
// An orchestrated research card (2026-10-19): its children read under
|
|
20
20
|
// CARD ORCHESTRATOR, so the note does not withhold delegation.
|
|
21
|
-
orchestratedLacks: 'no measured native image-generation tool, guarded read commands only (no helper scripts or
|
|
21
|
+
orchestratedLacks: 'no measured native image-generation tool, guarded read commands only (no helper scripts or direct browser launches), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
|
|
22
22
|
},
|
|
23
23
|
codex: {
|
|
24
24
|
has: 'a sandboxed shell over an isolated copy of the repository (no .env, credential or git files, and no network from the shell), web search, and writing under .flowviant/artifacts/ and the granted scratch directory',
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { AGENT_PREREQUISITE_CONTRACT } from './agentPrerequisiteContract.mjs';
|
|
|
24
24
|
import { agentTaskKindOf } from './agentTaskKinds.mjs';
|
|
25
25
|
// A card's type (0.106.0) likewise: the table holds the words, this file prints them.
|
|
26
26
|
import { AGENT_TASK_TYPES, agentTaskTypeFor, agentTaskTypeOf } from './agentTaskTypes.mjs';
|
|
27
|
-
import { ARTIFACT_CONTRACT_TAIL, HELP_THEM_DECIDE, LOOK_HEADLESS, SYSTEM_AGENT_DECK, SYSTEM_AGENT_IMAGE, SYSTEM_AGENT_MODEL } from './artifactContracts.mjs';
|
|
27
|
+
import { ARTIFACT_CONTRACT_TAIL, HELP_THEM_DECIDE, LOOK_HEADLESS, SYSTEM_AGENT_DECK, SYSTEM_AGENT_IMAGE, SYSTEM_AGENT_MODEL, WHOLE_REQUEST } from './artifactContracts.mjs';
|
|
28
28
|
import { isLibraryReference } from './knowledgeLibrary.mjs';
|
|
29
29
|
import { readCardThread } from './cardThread.mjs';
|
|
30
30
|
import { AGENT_FILE_LINE_MAX, AGENT_FILES_PER_MESSAGE_MAX, CARD_FILES_PER_TURN_MAX } from './agentFiles.mjs';
|
|
@@ -581,9 +581,19 @@ with the matching name your CLI exposes.
|
|
|
581
581
|
ask: each brief names the original ask, the part this card delivers and
|
|
582
582
|
what its sibling cards own, and carries the context its agent needs — the
|
|
583
583
|
design link or kept reference, the expected look and behaviour of its
|
|
584
|
-
area, the files you found.
|
|
585
|
-
|
|
586
|
-
|
|
584
|
+
area, the files you found. Copy the person's links, design references and
|
|
585
|
+
file lists into every brief they shape, word for word. If you could not
|
|
586
|
+
open or read a reference, say so in each brief and in \`assumed\`, and cut
|
|
587
|
+
by what the person described and the current product shows; never invent
|
|
588
|
+
what it contains. AN UNREAD REFERENCE NEVER NARROWS SCOPE: write "UNREAD
|
|
589
|
+
AT PLANNING: <the link or name>. The agent working this card opens it
|
|
590
|
+
itself (a Claude Code agent reads a claude.ai/design link with its
|
|
591
|
+
DesignSync tool), builds this card's share of all of it, and names any part
|
|
592
|
+
no card owns." Never write that the agent should use only what you could
|
|
593
|
+
read as its scope, and never state that the reference is unavailable to the
|
|
594
|
+
agent: your read access is not its. When the person names a design or
|
|
595
|
+
mockup without a link, look for its link in the repository's docs, decision
|
|
596
|
+
records and the Library, and carry what you found. For example,
|
|
587
597
|
"remake the settings page from this design", where the design shows a new
|
|
588
598
|
sidebar, a redesigned profile form, a billing tab and inline save
|
|
589
599
|
confirmations, is four cards, each carrying the design link and the
|
|
@@ -1268,6 +1278,8 @@ implementation and ask for the person's decision. Unknown generator-source
|
|
|
1268
1278
|
identity is not a claim that an embedded GLB image is the original PNG. Opening
|
|
1269
1279
|
a result handoff never authorizes an automatic asset commit or merge.
|
|
1270
1280
|
|
|
1281
|
+
${WHOLE_REQUEST}
|
|
1282
|
+
|
|
1271
1283
|
${HELP_THEM_DECIDE}`;
|
|
1272
1284
|
|
|
1273
1285
|
/**
|
|
@@ -1814,6 +1826,15 @@ HOW TO READ IT:
|
|
|
1814
1826
|
asked for that the diff does not show, changes that reach further than the
|
|
1815
1827
|
card did. Not formatting, not naming preferences, not the rewrite you would
|
|
1816
1828
|
have preferred.
|
|
1829
|
+
6. THE WHOLE REQUEST, AND WHAT THE AGENT SAYS IS MISSING. A card may carry
|
|
1830
|
+
THE PERSON'S OWN WORDS (the chat it was planned in) and the agent's
|
|
1831
|
+
declared gaps. Judge the diff against the words as well as the criteria:
|
|
1832
|
+
a screen, state, rule or linked design file the person asked for that the
|
|
1833
|
+
diff does not show and the gaps do not list is a concern, named in the
|
|
1834
|
+
card's note. A declared gap is the agent being honest, not a concern by
|
|
1835
|
+
itself; put the serious ones in "overall" so the reviewer meets them first.
|
|
1836
|
+
A linked design you cannot open is named in the note as unchecked, never
|
|
1837
|
+
judged from its name.
|
|
1817
1838
|
|
|
1818
1839
|
END YOUR TURN WITH ONE JSON OBJECT AND NOTHING AFTER IT, in a \`\`\`json fence:
|
|
1819
1840
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/** Chromium has an empty private root and no network. Public response bytes
|
|
2
|
+
* enter only through the bounded DNS-pinned broker, over a private CDP pipe.
|
|
3
|
+
* Unsupported namespace or Chrome sandbox launch never retries unsandboxed. */
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { existsSync, openSync, readSync, closeSync, realpathSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { resolveBrowser, SANDBOX_FAILURE_RE } from './shot.mjs';
|
|
9
|
+
import { createCdpClient } from './pinnedCapture.mjs';
|
|
10
|
+
import { killGroup } from './processGroup.mjs';
|
|
11
|
+
import { createReferenceNetwork, publicReferenceUrl } from './publicReferenceNetwork.mjs';
|
|
12
|
+
import { REFERENCE_BOUNDS as B } from './publicReferenceProtocol.mjs';
|
|
13
|
+
|
|
14
|
+
export function referenceBrowserLaunch(browser = resolveBrowser()) {
|
|
15
|
+
if (process.platform !== 'linux') return { unavailable: 'unsupported_isolation' };
|
|
16
|
+
if (process.getuid?.() === 0) return { unavailable: 'root_browser_unsupported' };
|
|
17
|
+
if (!browser || browser.viaWindows) return { unavailable: 'no_native_browser' };
|
|
18
|
+
const bwrap = ['/usr/bin/bwrap', '/bin/bwrap'].find(existsSync);
|
|
19
|
+
if (!bwrap) return { unavailable: 'no_namespace_sandbox' };
|
|
20
|
+
let binary;
|
|
21
|
+
try {
|
|
22
|
+
const real = realpathSync(browser.bin);
|
|
23
|
+
binary = [real, join(dirname(real), 'chrome'), join(dirname(real), 'chromium')].find(file => {
|
|
24
|
+
let fd;
|
|
25
|
+
try { fd = openSync(file, 'r'); const magic = Buffer.alloc(4); readSync(fd, magic, 0, 4, 0); return magic.equals(Buffer.from([127, 69, 76, 70])); }
|
|
26
|
+
catch { return false; } finally { if (fd !== undefined) closeSync(fd); }
|
|
27
|
+
});
|
|
28
|
+
} catch { /* An unsupported wrapper cannot widen the filesystem mounts. */ }
|
|
29
|
+
if (!binary) return { unavailable: 'unsupported_browser_layout' };
|
|
30
|
+
const root = dirname(binary);
|
|
31
|
+
// A broad system-bin bind would expose shell/network helpers. Only browser
|
|
32
|
+
// distribution files, runtime libraries and fonts are in the private root.
|
|
33
|
+
if (!['/opt/google/chrome', '/opt/google/chrome-beta', '/opt/google/chrome-unstable',
|
|
34
|
+
'/usr/lib/chromium', '/usr/lib/chromium-browser', '/opt/microsoft/msedge', '/opt/brave.com/brave'].includes(root))
|
|
35
|
+
return { unavailable: 'unsupported_browser_layout' };
|
|
36
|
+
const args = ['--unshare-all', '--die-with-parent', '--new-session', '--cap-drop', 'ALL',
|
|
37
|
+
'--proc', '/proc', '--dev', '/dev', '--size', String(B.scratchBytes), '--tmpfs', '/tmp',
|
|
38
|
+
'--size', String(B.profileBytes), '--tmpfs', '/profile', '--size', '1048576', '--tmpfs', '/home', '--size', '1048576', '--tmpfs', '/etc',
|
|
39
|
+
'--ro-bind', root, '/browser'];
|
|
40
|
+
for (const dir of ['/usr/lib', '/lib', '/lib64', '/usr/share/fonts', '/usr/share/fontconfig', '/etc/fonts'])
|
|
41
|
+
if (existsSync(dir)) args.push('--ro-bind', dir, dir);
|
|
42
|
+
args.push('--clearenv', '--setenv', 'HOME', '/profile', '--setenv', 'TMPDIR', '/tmp', '--setenv', 'LANG', 'C.UTF-8',
|
|
43
|
+
'--', `/browser/${binary.slice(root.length + 1)}`,
|
|
44
|
+
'--headless=new', '--remote-debugging-pipe', '--user-data-dir=/profile', '--no-first-run', '--no-default-browser-check',
|
|
45
|
+
'--disable-gpu', '--disable-dev-shm-usage', '--disable-extensions', '--disable-background-networking',
|
|
46
|
+
'--disable-component-update', '--disable-sync', '--disable-breakpad', '--disable-crash-reporter',
|
|
47
|
+
'--disable-domain-reliability', '--disable-default-apps', '--no-pings', '--no-proxy-server',
|
|
48
|
+
'--disable-quic', '--disable-features=MediaRouter,OptimizationHints,AutofillServerCommunication',
|
|
49
|
+
'--renderer-process-limit=2', '--js-flags=--max-old-space-size=128', '--force-color-profile=srgb',
|
|
50
|
+
`--window-size=${B.width},${B.height}`, 'about:blank');
|
|
51
|
+
return { command: bwrap, args, env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function capturePublicReference({ url, signal, attemptId = randomUUID() }, {
|
|
55
|
+
launch = referenceBrowserLaunch, spawnBrowser = spawn, networkFactory = createReferenceNetwork,
|
|
56
|
+
} = {}) {
|
|
57
|
+
const attemptedAt = new Date().toISOString();
|
|
58
|
+
const capture = { version: 1, attemptId, requestedUrl: url, attemptedAt, capturedAt: null, viewport: { width: B.width, height: B.height },
|
|
59
|
+
observation: { access: 'unknown', observedAt: attemptedAt }, contentAssessment: 'unverified' };
|
|
60
|
+
try { publicReferenceUrl(url); } catch { return { ...capture, reason: 'blocked_destination', observation: { ...capture.observation, access: 'permission_denied', permissionSource: 'flowviant' } }; }
|
|
61
|
+
if (signal?.aborted) return { ...capture, reason: 'aborted' };
|
|
62
|
+
const spec = launch();
|
|
63
|
+
if (spec.unavailable) return { ...capture, reason: spec.unavailable, observation: { ...capture.observation, access: 'unavailable' } };
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const network = networkFactory({ signal: controller.signal });
|
|
66
|
+
let child, cdp, timer, abort, interrupt, started = false, stderr = '';
|
|
67
|
+
let documents = 0, requests = 0, blocked = 0, mainFrame, target, session, loaded = false, pending = 0, networkFailure;
|
|
68
|
+
const responses = new Map();
|
|
69
|
+
const finish = (access, reason) => ({ ...capture, blockedRequests: blocked, ...(reason ? { reason } : {}),
|
|
70
|
+
observation: { ...capture.observation, observedAt: new Date().toISOString(), access,
|
|
71
|
+
...(['blocked_destination', 'blocked_method'].includes(reason) ? { access: 'permission_denied', permissionSource: 'flowviant' } : {}) } });
|
|
72
|
+
const interrupted = new Promise(resolve => {
|
|
73
|
+
interrupt = (access, reason) => { controller.abort(); if (child) killGroup(child); resolve(finish(access, reason)); };
|
|
74
|
+
abort = () => interrupt('unknown', 'aborted');
|
|
75
|
+
if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true });
|
|
76
|
+
timer = setTimeout(() => interrupt(!started ? 'unavailable' : loaded ? 'partial' : 'failed', 'timeout'), B.timeoutMs);
|
|
77
|
+
});
|
|
78
|
+
const run = async () => {
|
|
79
|
+
child = spawnBrowser(spec.command, spec.args, { env: spec.env, detached: true, stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] });
|
|
80
|
+
child.stderr.on('data', data => { stderr = (stderr + data.toString()).slice(-4000); });
|
|
81
|
+
cdp = createCdpClient(child.stdio[3], child.stdio[4], { maxMessageBytes: 8 * 1024 * 1024 });
|
|
82
|
+
for (const fd of [3, 4]) child.stdio[fd].on('error', () => {});
|
|
83
|
+
child.once('error', () => cdp.close('Browser unavailable.'));
|
|
84
|
+
child.once('exit', () => cdp.close('Browser exited.'));
|
|
85
|
+
await cdp.send('Browser.getVersion');
|
|
86
|
+
started = true;
|
|
87
|
+
await cdp.send('Browser.setDownloadBehavior', { behavior: 'deny' });
|
|
88
|
+
// Only our page runs. Popups, out-of-process frames and workers are closed
|
|
89
|
+
// while paused; in-process requests still pass the broker. A bypass has
|
|
90
|
+
// no network route, no user home and no host credential files to reach.
|
|
91
|
+
cdp.on(message => {
|
|
92
|
+
const p = message.params ?? {};
|
|
93
|
+
if (message.method === 'Target.attachedToTarget' && p.targetInfo?.targetId !== target) {
|
|
94
|
+
if (p.waitingForDebugger && typeof p.targetInfo?.targetId === 'string') { blocked++; cdp.send('Target.closeTarget', { targetId: p.targetInfo.targetId }).catch(() => {}); }
|
|
95
|
+
}
|
|
96
|
+
if (message.sessionId !== session) return;
|
|
97
|
+
if (message.method === 'Page.lifecycleEvent' && p.frameId === mainFrame && ['DOMContentLoaded', 'load'].includes(p.name)) loaded = true;
|
|
98
|
+
if (message.method !== 'Fetch.requestPaused') return;
|
|
99
|
+
pending++;
|
|
100
|
+
void (async () => {
|
|
101
|
+
try {
|
|
102
|
+
if (controller.signal.aborted) throw new Error('aborted');
|
|
103
|
+
if (++requests > B.requests) { interrupt(loaded ? 'partial' : 'failed', 'request_limit'); return; }
|
|
104
|
+
if (p.resourceType === 'Document' && ++documents > B.documents) { interrupt(loaded ? 'partial' : 'failed', 'navigation_limit'); return; }
|
|
105
|
+
const response = await network.request(p.request.url, p.request.method);
|
|
106
|
+
if (p.resourceType === 'Document' && p.frameId === mainFrame) responses.set(publicReferenceUrl(p.request.url).href, { status: response.status,
|
|
107
|
+
challenge: response.headers.some(h => h.name.toLowerCase() === 'cf-mitigated' && h.value.toLowerCase() === 'challenge') });
|
|
108
|
+
await cdp.send('Fetch.fulfillRequest', { requestId: p.requestId, responseCode: response.status, responseHeaders: response.headers, body: response.body.toString('base64') }, session);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.reason === 'byte_limit' || error.reason === 'request_limit') { interrupt(loaded ? 'partial' : 'failed', error.reason); return; }
|
|
111
|
+
if (p.resourceType === 'Document' && p.frameId === mainFrame) networkFailure = error.reason ?? 'navigation_failed';
|
|
112
|
+
blocked++;
|
|
113
|
+
await cdp.send('Fetch.failRequest', { requestId: p.requestId, errorReason: 'BlockedByClient' }, session).catch(() => {});
|
|
114
|
+
} finally { pending--; }
|
|
115
|
+
})();
|
|
116
|
+
});
|
|
117
|
+
const context = await cdp.send('Target.createBrowserContext', { disposeOnDetach: true });
|
|
118
|
+
const created = await cdp.send('Target.createTarget', { url: 'about:blank', browserContextId: context.browserContextId });
|
|
119
|
+
target = created.targetId;
|
|
120
|
+
session = (await cdp.send('Target.attachToTarget', { targetId: target, flatten: true })).sessionId;
|
|
121
|
+
await cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }, session);
|
|
122
|
+
await cdp.send('Page.enable', {}, session);
|
|
123
|
+
await cdp.send('Page.setLifecycleEventsEnabled', { enabled: true }, session);
|
|
124
|
+
await cdp.send('Network.enable', {}, session);
|
|
125
|
+
await cdp.send('Network.setBypassServiceWorker', { bypass: true }, session);
|
|
126
|
+
await cdp.send('Network.setCacheDisabled', { cacheDisabled: true }, session);
|
|
127
|
+
await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] }, session);
|
|
128
|
+
await cdp.send('Emulation.setDeviceMetricsOverride', { width: B.width, height: B.height, deviceScaleFactor: 1, mobile: false }, session);
|
|
129
|
+
mainFrame = (await cdp.send('Page.getFrameTree', {}, session)).frameTree.frame.id;
|
|
130
|
+
await cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
|
|
131
|
+
const nav = await cdp.send('Page.navigate', { url }, session);
|
|
132
|
+
if (nav.errorText) return finish('failed', networkFailure ?? 'navigation_failed');
|
|
133
|
+
const until = Date.now() + 12_000;
|
|
134
|
+
while ((!loaded || pending) && Date.now() < until && !controller.signal.aborted) await new Promise(resolve => setTimeout(resolve, 50));
|
|
135
|
+
if (controller.signal.aborted) return finish('unknown', 'aborted');
|
|
136
|
+
const frame = (await cdp.send('Page.getFrameTree', {}, session)).frameTree.frame;
|
|
137
|
+
let finalUrl;
|
|
138
|
+
try { publicReferenceUrl(frame.url); finalUrl = frame.url; } catch { return finish('failed', 'unverified_document'); }
|
|
139
|
+
const response = responses.get(publicReferenceUrl(finalUrl).href);
|
|
140
|
+
if (!response || frame.unreachableUrl || !loaded) return finish('failed', networkFailure ?? 'unverified_document');
|
|
141
|
+
capture.finalUrl = finalUrl;
|
|
142
|
+
capture.observation.finalUrl = finalUrl;
|
|
143
|
+
capture.observation.httpStatus = response.status;
|
|
144
|
+
// Content remains untrusted source data. Restrict what evaluation returns;
|
|
145
|
+
// there is no tool for arbitrary JS, clicks, forms or document mutation.
|
|
146
|
+
const content = await cdp.send('Runtime.evaluate', { returnByValue: true, timeout: 2000, expression: `JSON.stringify({text:(document.body?.innerText||'').slice(0,${B.textChars}),links:Array.from(document.querySelectorAll('a[href]')).slice(0,${B.links}).map(a=>({url:a.href.slice(0,4096),text:(a.innerText||'').slice(0,160)}))})` }, session);
|
|
147
|
+
const parsed = JSON.parse(content.result?.value ?? '{}');
|
|
148
|
+
capture.text = typeof parsed.text === 'string' ? parsed.text.slice(0, B.textChars) : '';
|
|
149
|
+
capture.capturedAt = new Date().toISOString();
|
|
150
|
+
capture.links = Array.isArray(parsed.links) ? parsed.links.slice(0, B.links).flatMap(link => {
|
|
151
|
+
try { publicReferenceUrl(link.url); return typeof link.text === 'string' ? [{ url: link.url, text: link.text.slice(0, 160) }] : []; } catch { return []; }
|
|
152
|
+
}) : [];
|
|
153
|
+
const shot = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }, session);
|
|
154
|
+
const png = Buffer.from(shot.data ?? '', 'base64');
|
|
155
|
+
if (!png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return finish('partial', 'screenshot_unavailable');
|
|
156
|
+
if (png.length > B.screenshotBytes) return finish('partial', 'screenshot_limit');
|
|
157
|
+
capture.screenshot = { mimeType: 'image/png', data: png.toString('base64') };
|
|
158
|
+
capture.capturedAt = new Date().toISOString();
|
|
159
|
+
const after = (await cdp.send('Page.getFrameTree', {}, session)).frameTree.frame;
|
|
160
|
+
if (after.loaderId !== frame.loaderId || after.url !== frame.url || pending) {
|
|
161
|
+
delete capture.screenshot; delete capture.text; delete capture.links; capture.capturedAt = null;
|
|
162
|
+
return finish('partial', 'document_changed');
|
|
163
|
+
}
|
|
164
|
+
return finish(response.challenge ? 'challenge' : response.status >= 400 ? 'failed' : blocked ? 'partial' : 'loaded');
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
return await Promise.race([run().catch(() => finish(started ? 'failed' : 'unavailable', SANDBOX_FAILURE_RE.test(stderr) ? 'browser_sandbox_unavailable' : started ? 'browser_failed' : 'isolation_launch_failed')), interrupted]);
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timer); signal?.removeEventListener('abort', abort);
|
|
170
|
+
controller.abort(); network.close(); cdp?.close('Reference capture ended.');
|
|
171
|
+
if (child) {
|
|
172
|
+
const exited = new Promise(resolve => { if (child.exitCode !== null || child.signalCode !== null) resolve(); else { child.once('exit', resolve); child.once('error', resolve); } });
|
|
173
|
+
killGroup(child);
|
|
174
|
+
let reapTimer;
|
|
175
|
+
await Promise.race([exited, new Promise(resolve => { reapTimer = setTimeout(resolve, 2000); })]);
|
|
176
|
+
clearTimeout(reapTimer);
|
|
177
|
+
for (const stream of child.stdio ?? []) stream?.destroy?.();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|