@kontourai/flow-agents 3.2.0 → 3.4.0
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/.github/workflows/add-to-project.yml +15 -0
- package/.github/workflows/ci.yml +173 -0
- package/CHANGELOG.md +54 -0
- package/CONTEXT.md +5 -1
- package/README.md +19 -8
- package/build/src/builder-flow-run-adapter.d.ts +80 -0
- package/build/src/builder-flow-run-adapter.js +241 -0
- package/build/src/builder-flow-runtime.d.ts +16 -0
- package/build/src/builder-flow-runtime.js +290 -0
- package/build/src/cli/assignment-provider.js +10 -1
- package/build/src/cli/builder-run.d.ts +1 -0
- package/build/src/cli/builder-run.js +27 -0
- package/build/src/cli/effective-backlog-settings.js +70 -2
- package/build/src/cli/init.d.ts +34 -0
- package/build/src/cli/init.js +341 -61
- package/build/src/cli/kit.js +55 -12
- package/build/src/cli/pull-work-provider.js +346 -5
- package/build/src/cli/skill-drift-check.d.ts +1 -0
- package/build/src/cli/skill-drift-check.js +165 -0
- package/build/src/cli/telemetry-doctor.d.ts +37 -0
- package/build/src/cli/telemetry-doctor.js +53 -6
- package/build/src/cli/validate-hook-influence.js +37 -7
- package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
- package/build/src/cli/workflow-sidecar.d.ts +310 -1
- package/build/src/cli/workflow-sidecar.js +1914 -126
- package/build/src/cli.js +5 -0
- package/build/src/flow-kit/validate.d.ts +54 -34
- package/build/src/flow-kit/validate.js +237 -26
- package/build/src/index.d.ts +2 -0
- package/build/src/index.js +1 -0
- package/build/src/lib/console-connect-options.d.ts +97 -0
- package/build/src/lib/console-connect-options.js +199 -0
- package/build/src/lib/console-telemetry-validate.d.ts +49 -0
- package/build/src/lib/console-telemetry-validate.js +91 -0
- package/build/src/lib/flow-resolver.d.ts +54 -1
- package/build/src/lib/flow-resolver.js +112 -5
- package/build/src/lib/fs.d.ts +17 -0
- package/build/src/lib/fs.js +172 -0
- package/build/src/lib/local-artifact-root.d.ts +44 -1
- package/build/src/lib/local-artifact-root.js +131 -3
- package/build/src/runtime-adapters.d.ts +39 -3
- package/build/src/runtime-adapters.js +77 -31
- package/build/src/tools/build-universal-bundles.js +40 -2
- package/build/src/tools/codex-agent-routing.d.ts +2 -0
- package/build/src/tools/codex-agent-routing.js +49 -0
- package/build/src/tools/generate-context-map.js +1 -0
- package/build/src/tools/validate-source-tree.js +30 -3
- package/context/contracts/artifact-contract.md +16 -2
- package/context/scripts/hooks/lib/kit-catalog.js +235 -0
- package/context/scripts/hooks/lib/runnable-command.js +177 -0
- package/context/scripts/hooks/stop-goal-fit.js +278 -48
- package/context/scripts/hooks/workflow-steering.js +194 -22
- package/context/scripts/package.json +3 -0
- package/context/scripts/telemetry/install-console-config.sh +25 -4
- package/context/scripts/telemetry/lib/config.sh +102 -12
- package/context/scripts/telemetry/lib/pricing.sh +50 -0
- package/context/scripts/telemetry/lib/session.sh +3 -0
- package/context/scripts/telemetry/lib/transport.sh +87 -0
- package/context/scripts/telemetry/lib/usage.sh +205 -4
- package/context/scripts/telemetry/telemetry.conf +6 -0
- package/context/scripts/telemetry/telemetry.sh +48 -0
- package/context/settings/workspace-backlog-provider-settings.example.json +48 -0
- package/docs/agent-usage-feedback-loop.md +35 -0
- package/docs/architecture-engine-and-kits.md +110 -0
- package/docs/context-map.md +2 -0
- package/docs/coordination-guide.md +370 -0
- package/docs/decisions/agent-coordination.md +26 -9
- package/docs/decisions/embeddable-engine.md +152 -0
- package/docs/decisions/index.md +5 -3
- package/docs/decisions/trust-ledger-retention.md +88 -0
- package/docs/decisions/trust-reconcile.md +42 -9
- package/docs/decisions/workflow-enforcement.md +31 -9
- package/docs/fixture-ownership.md +6 -2
- package/docs/implementing-trust-reconciliation.md +129 -0
- package/docs/index.md +23 -9
- package/docs/integrations/flow-agents-console.md +275 -0
- package/docs/integrations/index.md +4 -0
- package/docs/kit-authoring-guide.md +52 -21
- package/docs/spec/builder-flow-runtime.md +80 -0
- package/docs/spec/runtime-hook-surface.md +45 -1
- package/docs/specs/economics-record-contract.md +270 -0
- package/docs/specs/harness-capability-matrix.md +74 -0
- package/docs/specs/learning-review-proposals-contract.md +340 -0
- package/docs/specs/routing-efficiency-review.md +59 -0
- package/docs/verifiable-trust.md +74 -25
- package/docs/workflow-artifact-lifecycle.md +38 -1
- package/docs/workflow-usage-guide.md +10 -0
- package/evals/acceptance/prove-capture-teeth.sh +132 -0
- package/evals/ci/antigaming-suite.sh +2 -0
- package/evals/ci/run-baseline.sh +78 -4
- package/evals/fixtures/economics/acceptance.json +12 -0
- package/evals/fixtures/economics/agents/tool-worker-1/events.jsonl +2 -0
- package/evals/fixtures/economics/agents/tool-worker-2/events.jsonl +2 -0
- package/evals/fixtures/economics/agents/tool-worker-3/events.jsonl +2 -0
- package/evals/fixtures/economics/agents/tool-worker-4/events.jsonl +1 -0
- package/evals/fixtures/economics/agents/tool-worker-5/events.jsonl +2 -0
- package/evals/fixtures/economics/critique.json +22 -0
- package/evals/fixtures/economics/expected-record.json +71 -0
- package/evals/fixtures/economics/session-usage-event.json +1 -0
- package/evals/fixtures/economics/state.json +11 -0
- package/evals/fixtures/economics/transcript.jsonl +3 -0
- package/evals/fixtures/hook-influence/cases.json +7 -7
- package/evals/fixtures/learning-review-proposals/balanced/economics.jsonl +6 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/economics.jsonl +5 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-1/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-2/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-3/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-4/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-5/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/economics.jsonl +6 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.json +30 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.md +66 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/gate-review.inquiries.json +26 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/gate-review.inquiries.json +26 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/gate-review.inquiries.json +26 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/gate-review.inquiries.json +26 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-5/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-6/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/repeat-window/economics.jsonl +6 -0
- package/evals/fixtures/learning-review-proposals/under-threshold/economics.jsonl +3 -0
- package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
- package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
- package/evals/fixtures/telemetry/usage-transcript-sample.jsonl +4 -0
- package/evals/fixtures/trust-reconcile-exploits/mcp-degrade.json +42 -0
- package/evals/integration/test_builder_entry_enforcement.sh +241 -0
- package/evals/integration/test_builder_step_producers.sh +18 -10
- package/evals/integration/test_bundle_install.sh +172 -0
- package/evals/integration/test_checkpoint_signing.sh +10 -2
- package/evals/integration/test_ci_actor_identity.sh +221 -0
- package/evals/integration/test_console_tenant_isolation.sh +167 -0
- package/evals/integration/test_critique_supersession_roundtrip.sh +4 -1
- package/evals/integration/test_dual_emit_flow_step.sh +10 -4
- package/evals/integration/test_economics_record.sh +674 -0
- package/evals/integration/test_effective_backlog_settings.sh +1 -1
- package/evals/integration/test_evidence_capture_hook.sh +17 -2
- package/evals/integration/test_exemption_usage_review.sh +198 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_flow_kit_install_git.sh +83 -0
- package/evals/integration/test_flowdef_session_activation.sh +0 -1
- package/evals/integration/test_flowdef_session_history_preservation.sh +13 -3
- package/evals/integration/test_gate_lockdown.sh +7 -0
- package/evals/integration/test_gate_review_inquiry_records.sh +9 -1
- package/evals/integration/test_goal_fit_hook.sh +2031 -0
- package/evals/integration/test_hook_category_behaviors.sh +8 -1
- package/evals/integration/test_hook_influence_cases.sh +25 -1
- package/evals/integration/test_install_merge.sh +227 -2
- package/evals/integration/test_kit_conformance_levels.sh +6 -6
- package/evals/integration/test_learning_review_proposals.sh +329 -0
- package/evals/integration/test_liveness_conflict_injection.sh +26 -22
- package/evals/integration/test_liveness_console_relay.sh +166 -0
- package/evals/integration/test_liveness_heartbeat.sh +17 -17
- package/evals/integration/test_liveness_worktree_root.sh +575 -0
- package/evals/integration/test_phase_map_and_gate_claim.sh +6 -1
- package/evals/integration/test_publish_delivery.sh +389 -2
- package/evals/integration/test_pull_work_board.sh +200 -0
- package/evals/integration/test_pull_work_provider.sh +1 -1
- package/evals/integration/test_reconcile_preflight.sh +304 -0
- package/evals/integration/test_record_check.sh +378 -0
- package/evals/integration/test_routing_efficiency.sh +71 -0
- package/evals/integration/test_runtime_adapter_activation.sh +28 -0
- package/evals/integration/test_session_resume_roundtrip.sh +16 -19
- package/evals/integration/test_skill_drift_check.sh +870 -0
- package/evals/integration/test_takeover_protocol.sh +340 -0
- package/evals/integration/test_telemetry.sh +445 -0
- package/evals/integration/test_telemetry_doctor.sh +66 -0
- package/evals/integration/test_telemetry_usage_pipeline.sh +228 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +121 -13
- package/evals/integration/test_trust_reconcile_trailer_diagnostic.sh +247 -0
- package/evals/integration/test_usage_cost.sh +61 -0
- package/evals/integration/test_verify_hold.sh +910 -0
- package/evals/integration/test_veritas_governance_kit.sh +257 -0
- package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
- package/evals/integration/test_workflow_sidecar_writer.sh +1395 -0
- package/evals/integration/test_workflow_steering_hook.sh +157 -16
- package/evals/integration/test_workspace_settings.sh +176 -0
- package/evals/lib/env.sh +26 -0
- package/evals/lib/node.sh +8 -0
- package/evals/run.sh +37 -0
- package/evals/static/test_ci_integration_coverage.sh +115 -0
- package/evals/static/test_declared_scope_forms_documented.sh +114 -0
- package/evals/static/test_universal_bundles.sh +34 -0
- package/evals/static/test_validate_source_kit_asset_scope.sh +259 -0
- package/evals/static/test_workflow_skills.sh +1 -1
- package/kits/builder/flows/build.flow.json +9 -18
- package/kits/builder/flows/publish-learn.flow.json +5 -1
- package/kits/builder/kit.json +120 -0
- package/kits/builder/skills/continue-work/SKILL.md +2 -0
- package/kits/builder/skills/deliver/SKILL.md +115 -0
- package/kits/builder/skills/evidence-gate/SKILL.md +12 -0
- package/kits/builder/skills/execute-plan/SKILL.md +9 -0
- package/kits/builder/skills/learning-review/SKILL.md +51 -0
- package/kits/builder/skills/plan-work/SKILL.md +17 -20
- package/kits/builder/skills/pull-work/SKILL.md +33 -2
- package/kits/builder/skills/release-readiness/SKILL.md +12 -0
- package/kits/knowledge/kit.json +9 -0
- package/kits/veritas-governance/docs/README.md +113 -7
- package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
- package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
- package/kits/veritas-governance/fixtures/exemption-review/mixed-fresh-stale.DECLARED.json +14 -0
- package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
- package/kits/veritas-governance/kit.json +19 -0
- package/kits/veritas-governance/skills/exemption-usage-review/SKILL.md +128 -0
- package/kits/veritas-governance/skills/exemption-usage-review/review-exemptions.mjs +231 -0
- package/package.json +2 -2
- package/packaging/manifest.json +29 -0
- package/schemas/backlog-provider-settings.schema.json +13 -0
- package/schemas/workflow-state.schema.json +44 -0
- package/scripts/README.md +4 -0
- package/scripts/check-content-boundary.cjs +8 -1
- package/scripts/ci/trust-reconcile.js +214 -253
- package/scripts/hooks/codex-hook-adapter.js +77 -2
- package/scripts/hooks/evidence-capture.js +38 -5
- package/scripts/hooks/lib/actor-identity.js +82 -0
- package/scripts/hooks/lib/codex-exit-code.js +316 -0
- package/scripts/hooks/lib/kit-catalog.js +235 -0
- package/scripts/hooks/lib/liveness-write.js +28 -1
- package/scripts/hooks/lib/local-artifact-paths.js +97 -1
- package/scripts/hooks/lib/runnable-command.js +177 -0
- package/scripts/hooks/lib/skill-drift.js +350 -0
- package/scripts/hooks/stop-goal-fit.js +278 -48
- package/scripts/hooks/workflow-steering.js +194 -22
- package/scripts/install-codex-home.sh +97 -47
- package/scripts/install-merge.js +72 -14
- package/scripts/install-owned-files.js +178 -0
- package/scripts/lib/reconcile-shape.js +381 -0
- package/scripts/liveness/relay.sh +84 -0
- package/scripts/telemetry/economics-record.schema.json +145 -0
- package/scripts/telemetry/economics-record.sh +331 -0
- package/scripts/telemetry/install-console-config.sh +25 -4
- package/scripts/telemetry/learning-review-decide.sh +124 -0
- package/scripts/telemetry/learning-review-proposals.schema.json +161 -0
- package/scripts/telemetry/learning-review-proposals.sh +484 -0
- package/scripts/telemetry/lib/config.sh +102 -12
- package/scripts/telemetry/lib/pricing.sh +14 -6
- package/scripts/telemetry/lib/session.sh +3 -0
- package/scripts/telemetry/lib/transport.sh +133 -15
- package/scripts/telemetry/lib/usage.sh +121 -28
- package/scripts/telemetry/routing-efficiency.sh +0 -0
- package/scripts/telemetry/telemetry.conf +6 -0
- package/scripts/telemetry/telemetry.sh +48 -0
- package/src/builder-flow-run-adapter.ts +357 -0
- package/src/builder-flow-runtime.ts +348 -0
- package/src/cli/assignment-provider.ts +12 -1
- package/src/cli/builder-flow-run-adapter.test.mjs +495 -0
- package/src/cli/builder-flow-runtime.test.mjs +213 -0
- package/src/cli/builder-run.ts +28 -0
- package/src/cli/codex-agent-routing.test.mjs +44 -0
- package/src/cli/codex-exit-code.test.mjs +207 -0
- package/src/cli/console-connect-options.test.mjs +329 -0
- package/src/cli/console-telemetry-validate.test.mjs +157 -0
- package/src/cli/effective-backlog-settings.ts +68 -2
- package/src/cli/flow-resolver-composition.test.mjs +72 -0
- package/src/cli/init.test.mjs +161 -0
- package/src/cli/init.ts +407 -62
- package/src/cli/kit-metadata-security.test.mjs +443 -0
- package/src/cli/kit.ts +50 -12
- package/src/cli/pull-work-provider.ts +377 -3
- package/src/cli/sidecar-pure-helpers.test.mjs +64 -0
- package/src/cli/skill-drift-check.ts +196 -0
- package/src/cli/telemetry-doctor.test.mjs +53 -0
- package/src/cli/telemetry-doctor.ts +50 -7
- package/src/cli/validate-hook-influence.ts +37 -6
- package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
- package/src/cli/workflow-sidecar.ts +1980 -119
- package/src/cli.ts +5 -0
- package/src/flow-kit/validate.ts +277 -38
- package/src/index.ts +19 -0
- package/src/lib/console-connect-options.ts +261 -0
- package/src/lib/console-telemetry-validate.ts +88 -0
- package/src/lib/flow-resolver.ts +117 -4
- package/src/lib/fs.ts +160 -0
- package/src/lib/local-artifact-root.ts +129 -3
- package/src/runtime-adapters.ts +113 -33
- package/src/tools/build-universal-bundles.ts +36 -2
- package/src/tools/codex-agent-routing.ts +48 -0
- package/src/tools/generate-context-map.ts +1 -0
- package/src/tools/validate-source-tree.ts +29 -3
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
* Records to `.kontourai/flow-agents/<slug>/command-log.jsonl`, one JSON object per line:
|
|
13
13
|
* {
|
|
14
14
|
* "command": "<the command string the agent ran>",
|
|
15
|
-
* "observedResult": "pass" | "fail",
|
|
15
|
+
* "observedResult": "pass" | "fail" | "ambiguous", // deterministically inferred
|
|
16
|
+
* // ("ambiguous": a bare, non-self-asserting
|
|
17
|
+
* // grep/diff exited 1 — could mean zero
|
|
18
|
+
* // matches/no differences (PASS for an
|
|
19
|
+
* // absence check) or an unintended miss
|
|
20
|
+
* // (FAIL for a presence check); #362)
|
|
16
21
|
* "exitCode": <integer> | null, // null when only pass/fail is inferable
|
|
17
22
|
* "capturedAt": "<ISO-8601 timestamp>",
|
|
18
23
|
* "source": "postToolUse-capture",
|
|
@@ -38,7 +43,10 @@
|
|
|
38
43
|
* (per docs/spec/runtime-hook-surface.md §1, postToolUse). A clean integer exit
|
|
39
44
|
* code is host-dependent. We extract the real exit code where present; otherwise
|
|
40
45
|
* we derive `observedResult` from `error`/stderr-style failure indication and
|
|
41
|
-
* record `exitCode: null`.
|
|
46
|
+
* record `exitCode: null`. Absent positive success evidence (a clean integer
|
|
47
|
+
* exit code of 0; no host currently surfaces a positive success flag), the
|
|
48
|
+
* result is 'ambiguous', never 'pass'. We never record the model's words about
|
|
49
|
+
* the outcome.
|
|
42
50
|
*
|
|
43
51
|
* Non-blocking — always exits 0. Idempotent/append-only. Fail-open on any error:
|
|
44
52
|
* a capture failure must never block the agent or corrupt the log.
|
|
@@ -51,6 +59,7 @@ const path = require('path');
|
|
|
51
59
|
const { flowAgentsArtifactRootsForRead } = require('./lib/local-artifact-paths');
|
|
52
60
|
const { resolveActor } = require('./lib/actor-identity.js');
|
|
53
61
|
const { readCurrentPointer } = require('./lib/current-pointer.js');
|
|
62
|
+
const { isAmbiguousAbsenceCommand } = require('./lib/runnable-command.js');
|
|
54
63
|
const crypto = require('crypto');
|
|
55
64
|
|
|
56
65
|
const MAX_STDIN = 1024 * 1024;
|
|
@@ -248,15 +257,32 @@ function cleanExitCode(value) {
|
|
|
248
257
|
*
|
|
249
258
|
* Priority:
|
|
250
259
|
* 1. A clean integer exit code anywhere the host surfaces it → exitCode set;
|
|
251
|
-
* observedResult = pass iff exitCode === 0
|
|
260
|
+
* observedResult = pass iff exitCode === 0, EXCEPT (#362) a bare,
|
|
261
|
+
* non-self-asserting `grep`/`diff` invocation (per
|
|
262
|
+
* `isAmbiguousAbsenceCommand`, ./lib/runnable-command.js) that exits
|
|
263
|
+
* EXACTLY 1 → observedResult = 'ambiguous' instead of 'fail'. Exit 1 for
|
|
264
|
+
* such a command could mean zero matches/no differences (PASS for an
|
|
265
|
+
* absence check) or an unintended miss (FAIL for a presence check) —
|
|
266
|
+
* this is never coerced to 'pass' (that would trade a false-block for a
|
|
267
|
+
* false-pass) nor silently left as 'fail' (that is the exact false
|
|
268
|
+
* caught-completion #362 reports). Exit codes >= 2 for these two
|
|
269
|
+
* binaries are real tool errors and remain 'fail', unchanged.
|
|
252
270
|
* 2. Else, a non-empty `error` field or stderr-style failure indication →
|
|
253
271
|
* observedResult = fail, exitCode = null.
|
|
254
|
-
* 3. Else → observedResult =
|
|
272
|
+
* 3. Else → observedResult = ambiguous, exitCode = null. Absent positive
|
|
273
|
+
* success evidence (a clean integer exit code of 0; no host currently
|
|
274
|
+
* surfaces a positive success flag), the result is 'ambiguous', never
|
|
275
|
+
* 'pass'.
|
|
276
|
+
*
|
|
277
|
+
* `input.command` (the raw command string, already in scope at the run()
|
|
278
|
+
* call site) is required to evaluate the #362 carve-out; when absent, the
|
|
279
|
+
* carve-out simply never fires and behavior is byte-identical to before #362.
|
|
255
280
|
*/
|
|
256
281
|
function observeResult(input) {
|
|
257
282
|
const response = input.tool_response;
|
|
258
283
|
const output = input.tool_output;
|
|
259
284
|
const error = input.error;
|
|
285
|
+
const command = typeof input.command === 'string' ? input.command : '';
|
|
260
286
|
|
|
261
287
|
// Candidate locations for a host-provided exit code.
|
|
262
288
|
const candidates = [];
|
|
@@ -274,6 +300,9 @@ function observeResult(input) {
|
|
|
274
300
|
}
|
|
275
301
|
|
|
276
302
|
if (exitCode !== null) {
|
|
303
|
+
if (exitCode === 1 && command && isAmbiguousAbsenceCommand(command)) {
|
|
304
|
+
return { exitCode, observedResult: 'ambiguous' };
|
|
305
|
+
}
|
|
277
306
|
return { exitCode, observedResult: exitCode === 0 ? 'pass' : 'fail' };
|
|
278
307
|
}
|
|
279
308
|
|
|
@@ -281,7 +310,7 @@ function observeResult(input) {
|
|
|
281
310
|
if (isFailureIndicated(error, response, output)) {
|
|
282
311
|
return { exitCode: null, observedResult: 'fail' };
|
|
283
312
|
}
|
|
284
|
-
return { exitCode: null, observedResult: '
|
|
313
|
+
return { exitCode: null, observedResult: 'ambiguous' };
|
|
285
314
|
}
|
|
286
315
|
|
|
287
316
|
// True when the host surfaces a deterministic failure signal: a non-empty
|
|
@@ -326,6 +355,10 @@ function run(rawInput) {
|
|
|
326
355
|
exit_code: input.exit_code,
|
|
327
356
|
status: input.status,
|
|
328
357
|
code: input.code,
|
|
358
|
+
// #362: thread the raw command string through so observeResult can apply the
|
|
359
|
+
// bare-grep/diff-exit-1 ambiguous carve-out. `command` is already in scope here
|
|
360
|
+
// (extracted above from input.tool_input.command).
|
|
361
|
+
command,
|
|
329
362
|
});
|
|
330
363
|
|
|
331
364
|
const record = {
|
|
@@ -229,6 +229,75 @@ function sanitizeSegment(value) {
|
|
|
229
229
|
return cleaned || 'unknown';
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
/**
|
|
233
|
+
* #398: detect a CI-triggered session and derive a STABLE actor identity from the CI provider's
|
|
234
|
+
* published identifiers. The right granularity is the JOB/RUN (a CI job ≈ one agent session), NOT
|
|
235
|
+
* the runner machine (hostname can be shared/reused across jobs). Detection is order-independent
|
|
236
|
+
* (each provider gates on its own canonical marker env var). Returns `{ runtime, session_id }` on a
|
|
237
|
+
* recognized provider WITH a non-blank stable id, else `null` (caller falls through to
|
|
238
|
+
* process-ancestry, where #293's advisory verify-hold net still protects).
|
|
239
|
+
*
|
|
240
|
+
* Deliberately conservative: a generic/unrecognized `CI=true` returns null rather than fabricating
|
|
241
|
+
* a "stable" id from something that might shift between subprocesses — a wrong stable classification
|
|
242
|
+
* would let the #293 hard gate ENFORCE against a shifting identity, which is worse than advisory.
|
|
243
|
+
* All segments are sanitized by serializeActor at the call site (allowed charset, length-capped),
|
|
244
|
+
* so a hostile env var value cannot inject.
|
|
245
|
+
*
|
|
246
|
+
* ACCEPTED GRANULARITY GAP (matrix / parallelism): the id is job/run-granular. GitLab (`CI_JOB_ID`),
|
|
247
|
+
* Azure (`SYSTEM_JOBID`), and Buildkite (`BUILDKITE_JOB_ID`) expose a per-job-INSTANCE id that is
|
|
248
|
+
* already unique across matrix/parallel legs. CircleCI parallelism is disambiguated below via
|
|
249
|
+
* `CIRCLE_NODE_INDEX`. But GitHub Actions and Jenkins declarative-`matrix{}` cells share
|
|
250
|
+
* `GITHUB_JOB` / `BUILD_TAG` across all legs of one run+attempt — matrix values live only in the
|
|
251
|
+
* `${{ matrix }}` / axis context, not in any env var this can read — so two concurrent legs collapse
|
|
252
|
+
* to the SAME CI actor. This degrades SAFELY: worst case is idempotent self-recognition (one leg
|
|
253
|
+
* treats another's claim as its own); it never false-blocks and never injects. The coordination-
|
|
254
|
+
* relevant CI jobs (trust-reconcile / publish) run as single, non-matrix jobs today. Filed as a
|
|
255
|
+
* fast-follow to add a GitHub-matrix disambiguator if a coordination path ever runs under a matrix.
|
|
256
|
+
*
|
|
257
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
258
|
+
* @returns {{runtime: string, session_id: string} | null}
|
|
259
|
+
*/
|
|
260
|
+
function detectCiActor(env = process.env) {
|
|
261
|
+
env = env || {};
|
|
262
|
+
const s = (v) => String(v == null ? '' : v).trim();
|
|
263
|
+
const compose = (...parts) => parts.map(s).filter(Boolean).join('-');
|
|
264
|
+
|
|
265
|
+
// GitHub Actions — run id + attempt + job is unique and stable across the whole job.
|
|
266
|
+
if (s(env.GITHUB_ACTIONS) === 'true') {
|
|
267
|
+
const id = compose(env.GITHUB_RUN_ID, env.GITHUB_RUN_ATTEMPT, env.GITHUB_JOB);
|
|
268
|
+
return id ? { runtime: 'github-actions', session_id: id } : null;
|
|
269
|
+
}
|
|
270
|
+
// GitLab CI — CI_JOB_ID is stable per job.
|
|
271
|
+
if (s(env.GITLAB_CI) === 'true') {
|
|
272
|
+
const id = s(env.CI_JOB_ID);
|
|
273
|
+
return id ? { runtime: 'gitlab-ci', session_id: id } : null;
|
|
274
|
+
}
|
|
275
|
+
// CircleCI — workflow id + job name + node index (CIRCLE_NODE_INDEX disambiguates the containers
|
|
276
|
+
// of a `parallelism: N` job; "0" for a single-container job, so it is always present and stable).
|
|
277
|
+
if (s(env.CIRCLECI) === 'true') {
|
|
278
|
+
const id = compose(env.CIRCLE_WORKFLOW_ID, env.CIRCLE_JOB, env.CIRCLE_NODE_INDEX) || s(env.CIRCLE_BUILD_NUM);
|
|
279
|
+
return id ? { runtime: 'circleci', session_id: id } : null;
|
|
280
|
+
}
|
|
281
|
+
// Jenkins — BUILD_TAG is the stable per-build identifier; fall back to job + build id.
|
|
282
|
+
if (s(env.JENKINS_URL)) {
|
|
283
|
+
const id = s(env.BUILD_TAG) || compose(env.JOB_NAME, env.BUILD_ID);
|
|
284
|
+
return id ? { runtime: 'jenkins', session_id: id } : null;
|
|
285
|
+
}
|
|
286
|
+
// Azure Pipelines — build id + system job id (SYSTEM_JOBID is a per-job-instance GUID, unique
|
|
287
|
+
// across matrix/strategy legs).
|
|
288
|
+
if (s(env.TF_BUILD) === 'true') {
|
|
289
|
+
const id = compose(env.BUILD_BUILDID, env.SYSTEM_JOBID);
|
|
290
|
+
return id ? { runtime: 'azure-pipelines', session_id: id } : null;
|
|
291
|
+
}
|
|
292
|
+
// Buildkite — job id is a stable UUID per job.
|
|
293
|
+
if (s(env.BUILDKITE) === 'true') {
|
|
294
|
+
const id = s(env.BUILDKITE_JOB_ID);
|
|
295
|
+
return id ? { runtime: 'buildkite', session_id: id } : null;
|
|
296
|
+
}
|
|
297
|
+
// Generic/unrecognized CI: do NOT fabricate stability — fall through to process-ancestry.
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
232
301
|
/**
|
|
233
302
|
* Serialize a runtime-agnostic actor struct into a single string safe for
|
|
234
303
|
* the existing `${subjectId}::${actor}` grouping key: each field is passed
|
|
@@ -334,6 +403,18 @@ function resolveActor(env = process.env) {
|
|
|
334
403
|
return { actor, source: `runtime-session-id:${runtime}` };
|
|
335
404
|
}
|
|
336
405
|
|
|
406
|
+
// #398: CI-runtime tier — sits ABOVE process-ancestry (stable across every subprocess in a CI
|
|
407
|
+
// job) and BELOW an explicit override / native runtime session id (those are more specific and
|
|
408
|
+
// already returned above). A CI-triggered agent session otherwise falls to process-ancestry,
|
|
409
|
+
// whose seed differs across subprocesses within one job (a subject claimed in `ensure-session`
|
|
410
|
+
// isn't recognized as self at `publish`) — the exact instability #293 had to degrade the
|
|
411
|
+
// verify-hold gate to advisory for. A stable CI identity lets that gate ENFORCE instead.
|
|
412
|
+
const ci = detectCiActor(env);
|
|
413
|
+
if (ci && ci.session_id) {
|
|
414
|
+
const actor = serializeActor({ runtime: ci.runtime, session_id: ci.session_id, host: os.hostname() });
|
|
415
|
+
return { actor, source: `ci-runtime:${ci.runtime}` };
|
|
416
|
+
}
|
|
417
|
+
|
|
337
418
|
const seed = ancestorActorSeed();
|
|
338
419
|
if (seed) {
|
|
339
420
|
const actor = serializeActor({ runtime, session_id: `anc-${seed}`, host: os.hostname() });
|
|
@@ -360,6 +441,7 @@ function isUnresolvedActor(actor) {
|
|
|
360
441
|
module.exports = {
|
|
361
442
|
detectRuntime,
|
|
362
443
|
runtimeSessionId,
|
|
444
|
+
detectCiActor,
|
|
363
445
|
ancestorActorSeed,
|
|
364
446
|
sanitizeSegment,
|
|
365
447
|
serializeActor,
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* codex-exit-code.js — codex-only host-banner exit-code extraction (#470).
|
|
4
|
+
*
|
|
5
|
+
* The codex PostToolUse payload carries no structured exit code (no
|
|
6
|
+
* `tool_response.exitCode`/`exit_code`, no `error`, no `stderr`). The only
|
|
7
|
+
* deterministic host signal is a fixed-format banner the codex CLI itself
|
|
8
|
+
* writes into the session rollout's `function_call_output`:
|
|
9
|
+
*
|
|
10
|
+
* Process exited with code N
|
|
11
|
+
*
|
|
12
|
+
* This is HOST-authored prose (observed on codex-cli 0.142.5), not model
|
|
13
|
+
* narration — regex-matching exactly this banner is a deterministic host
|
|
14
|
+
* signal, distinct from scanning stdout/narration for "error"/"fail" words
|
|
15
|
+
* (which docs/spec/runtime-hook-surface.md §2.5 forbids and this module does
|
|
16
|
+
* not do).
|
|
17
|
+
*
|
|
18
|
+
* SECURITY (iteration 2 / #470 review). The banner lives in the HOST-authored
|
|
19
|
+
* PREAMBLE of a `function_call_output.output` string, BEFORE the model's own
|
|
20
|
+
* stdout, which is appended after a literal `Output:` delimiter:
|
|
21
|
+
*
|
|
22
|
+
* Process exited with code 1
|
|
23
|
+
* Original token count: 25
|
|
24
|
+
* Output:
|
|
25
|
+
* <arbitrary model-produced stdout, which may itself contain the string
|
|
26
|
+
* "Process exited with code 0" — this is NOT a host signal and must never
|
|
27
|
+
* be matched>
|
|
28
|
+
*
|
|
29
|
+
* Both exports are therefore PREAMBLE-ANCHORED: extraction only ever looks
|
|
30
|
+
* BEFORE the first `Output:` delimiter and takes the FIRST banner match there
|
|
31
|
+
* (the host writes it once). The post-delimiter model stdout is never
|
|
32
|
+
* scanned, at any size — this is what makes the extraction fail-safe against
|
|
33
|
+
* a command that deliberately prints a forged banner to stdout.
|
|
34
|
+
*
|
|
35
|
+
* Both exports are pure/fail-open: malformed input, a missing/unreadable file,
|
|
36
|
+
* an oversized/inaccessible rollout, or an ambiguous cross-call correlation
|
|
37
|
+
* all degrade to `null` ("no signal"), never a throw. Callers must treat
|
|
38
|
+
* `null` as "extraction unavailable", not as exit code 0.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const fs = require('fs');
|
|
42
|
+
const path = require('path');
|
|
43
|
+
const os = require('os');
|
|
44
|
+
|
|
45
|
+
// The two forms the `Output:` delimiter can take depending on how far the
|
|
46
|
+
// caller has decoded the surrounding JSON:
|
|
47
|
+
// - a JSON.parse'd `output` string carries the delimiter as a REAL newline
|
|
48
|
+
// pair: "...\nOutput:\n...".
|
|
49
|
+
// - a raw (still JSON-escaped) slice of the rollout file — used only by the
|
|
50
|
+
// head-only fallback below, when a line is too large to fully parse —
|
|
51
|
+
// carries it literally escaped: "...\\nOutput:\\n...".
|
|
52
|
+
// extractExitCodeFromBanner accepts either input flavor and anchors on
|
|
53
|
+
// whichever form is present (real callers only ever hand it one flavor at a
|
|
54
|
+
// time; this is not an ambiguity, just support for both call sites).
|
|
55
|
+
const ANCHOR_REAL = '\nOutput:\n';
|
|
56
|
+
const ANCHOR_ESCAPED = '\\nOutput:\\n';
|
|
57
|
+
|
|
58
|
+
const BANNER_RE = /Process exited with code (\d+)/;
|
|
59
|
+
|
|
60
|
+
function splitPreamble(text) {
|
|
61
|
+
const iReal = text.indexOf(ANCHOR_REAL);
|
|
62
|
+
const iEsc = text.indexOf(ANCHOR_ESCAPED);
|
|
63
|
+
let idx = -1;
|
|
64
|
+
if (iReal !== -1 && iEsc !== -1) idx = Math.min(iReal, iEsc);
|
|
65
|
+
else if (iReal !== -1) idx = iReal;
|
|
66
|
+
else if (iEsc !== -1) idx = iEsc;
|
|
67
|
+
// No anchor found at all: fall back to treating the whole text as the
|
|
68
|
+
// preamble (documented residual — the observed codex format always
|
|
69
|
+
// includes the `Output:` delimiter; see docs/spec + plan risk log).
|
|
70
|
+
return idx === -1 ? text : text.slice(0, idx);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* extractExitCodeFromBanner(text) → integer | null
|
|
75
|
+
*
|
|
76
|
+
* Pure regex extraction — no I/O, cannot throw on well-formed string input.
|
|
77
|
+
* PREAMBLE-ANCHORED (#470 iteration 2, CRITICAL finding #1): splits `text` on
|
|
78
|
+
* the FIRST `Output:` delimiter (real or JSON-escaped form) and matches the
|
|
79
|
+
* banner ONLY in the portion before it, returning the FIRST match. The
|
|
80
|
+
* model's stdout after the delimiter is never scanned, so a command that
|
|
81
|
+
* prints a forged `Process exited with code 0` to its own stdout cannot
|
|
82
|
+
* override the real host-reported code in the preamble.
|
|
83
|
+
*/
|
|
84
|
+
function extractExitCodeFromBanner(text) {
|
|
85
|
+
if (typeof text !== 'string' || !text) return null;
|
|
86
|
+
const preamble = splitPreamble(text);
|
|
87
|
+
const match = BANNER_RE.exec(preamble);
|
|
88
|
+
if (!match) return null;
|
|
89
|
+
const n = Number(match[1]);
|
|
90
|
+
return Number.isInteger(n) ? n : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeCommandText(value) {
|
|
94
|
+
if (typeof value === 'string') return value.trim() || null;
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
const joined = value.map(v => String(v)).join(' ').trim();
|
|
97
|
+
return joined || null;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Best-effort parse of a rollout `function_call`'s `arguments` field into a
|
|
103
|
+
// normalized command string, used only for the command cross-check
|
|
104
|
+
// correlation (Decision B #2). Any shape mismatch degrades to `null`
|
|
105
|
+
// (unresolvable pairing), never a throw.
|
|
106
|
+
function normalizeCallArguments(argumentsField) {
|
|
107
|
+
let parsed = argumentsField;
|
|
108
|
+
if (typeof argumentsField === 'string') {
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(argumentsField);
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
116
|
+
return normalizeCommandText(parsed.command);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const OUTPUT_FIELD_NEEDLE = '"output":"';
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* parseCandidateLine(line, maxLineHeadBytes) → candidate | null
|
|
123
|
+
*
|
|
124
|
+
* Interprets one JSONL rollout line as either a `function_call_output` or a
|
|
125
|
+
* `function_call` response_item, HEAD-ANCHORED (#470 iteration 2, MEDIUM
|
|
126
|
+
* finding #5): a line that fits within `maxLineHeadBytes` is fully
|
|
127
|
+
* `JSON.parse`d (cheap, and correct for the real-newline anchor form); a line
|
|
128
|
+
* that exceeds the head window is NEVER fully parsed (that would mean holding
|
|
129
|
+
* arbitrarily large flooded stdout in memory and risks the preamble/banner —
|
|
130
|
+
* which lives at the line HEAD — being reasoned about incorrectly). Instead
|
|
131
|
+
* only the first `maxLineHeadBytes` raw bytes are inspected: the banner and
|
|
132
|
+
* `Output:` delimiter live within the first few hundred bytes of the `output`
|
|
133
|
+
* field regardless of how much stdout follows, so the raw head slice is
|
|
134
|
+
* sufficient for `extractExitCodeFromBanner` (escaped-anchor form).
|
|
135
|
+
*/
|
|
136
|
+
function parseCandidateLine(line, maxLineHeadBytes) {
|
|
137
|
+
const byteLen = Buffer.byteLength(line, 'utf8');
|
|
138
|
+
if (byteLen <= maxLineHeadBytes) {
|
|
139
|
+
let entry;
|
|
140
|
+
try {
|
|
141
|
+
entry = JSON.parse(line);
|
|
142
|
+
} catch {
|
|
143
|
+
return null; // malformed/partial line — skip, keep scanning
|
|
144
|
+
}
|
|
145
|
+
const payload = entry && typeof entry === 'object' ? entry.payload : null;
|
|
146
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
147
|
+
const callId = typeof payload.call_id === 'string' ? payload.call_id : null;
|
|
148
|
+
if (payload.type === 'function_call_output' && typeof payload.output === 'string') {
|
|
149
|
+
return { type: 'function_call_output', callId, output: payload.output };
|
|
150
|
+
}
|
|
151
|
+
if (payload.type === 'function_call') {
|
|
152
|
+
return { type: 'function_call', callId, command: normalizeCallArguments(payload.arguments) };
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Line exceeds the head window: raw, head-only, best-effort extraction.
|
|
158
|
+
// `function_call` entries carry only a short command string in practice and
|
|
159
|
+
// are not expected to exceed the head window; only `function_call_output`
|
|
160
|
+
// (whose stdout can flood) is handled here.
|
|
161
|
+
const head = line.slice(0, maxLineHeadBytes);
|
|
162
|
+
if (head.indexOf('"type":"function_call_output"') === -1) return null;
|
|
163
|
+
const callIdMatch = /"call_id":"([^"]*)"/.exec(head);
|
|
164
|
+
const outputIdx = head.indexOf(OUTPUT_FIELD_NEEDLE);
|
|
165
|
+
if (outputIdx === -1) return null; // output field itself starts beyond the head window
|
|
166
|
+
const rawOutputHead = head.slice(outputIdx + OUTPUT_FIELD_NEEDLE.length);
|
|
167
|
+
return {
|
|
168
|
+
type: 'function_call_output',
|
|
169
|
+
callId: callIdMatch ? callIdMatch[1] : null,
|
|
170
|
+
output: rawOutputHead, // still JSON-escaped; extractExitCodeFromBanner handles both anchor forms
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Best-effort containment (#470 iteration 2, LOW finding #8): realpath the
|
|
175
|
+
// transcript path and require it to be a regular file; when the codex
|
|
176
|
+
// sessions root itself resolves on this host, additionally reject a realpath
|
|
177
|
+
// that escapes it (defends against a symlinked transcript_path pointing
|
|
178
|
+
// somewhere unexpected). Fail-open: any resolution error propagates to the
|
|
179
|
+
// caller's try/catch as "unusable" (null); a non-resolvable sessions root
|
|
180
|
+
// (e.g. custom CODEX_HOME, test fixtures) simply skips the containment check.
|
|
181
|
+
function resolveContainedRealPath(transcriptPath) {
|
|
182
|
+
const realPath = fs.realpathSync(transcriptPath);
|
|
183
|
+
const stat = fs.statSync(realPath);
|
|
184
|
+
if (!stat.isFile()) return null;
|
|
185
|
+
try {
|
|
186
|
+
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
187
|
+
const sessionsRoot = path.join(codexHome, 'sessions');
|
|
188
|
+
const realRoot = fs.realpathSync(sessionsRoot);
|
|
189
|
+
const rel = path.relative(realRoot, realPath);
|
|
190
|
+
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
|
|
191
|
+
} catch {
|
|
192
|
+
// Sessions root doesn't exist/resolve on this host — best-effort only.
|
|
193
|
+
}
|
|
194
|
+
return realPath;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* readExitCodeFromRollout(transcriptPath, options) → integer | null
|
|
199
|
+
*
|
|
200
|
+
* options:
|
|
201
|
+
* - maxScanBytes (default 1MB): bounded backward-scan window from EOF used
|
|
202
|
+
* to locate candidate `response_item` lines. Beyond this cap, an
|
|
203
|
+
* unresolvable line start (and thus that candidate) is dropped — never
|
|
204
|
+
* read further back, and never a crash.
|
|
205
|
+
* - maxLineHeadBytes (default 64KB): per-line head window; see
|
|
206
|
+
* `parseCandidateLine`. This is what makes extraction survive a flooded
|
|
207
|
+
* `output` field (#470 iteration 2, MEDIUM finding #5) — the preamble
|
|
208
|
+
* banner sits at the line HEAD, so bounding reads to the head can never
|
|
209
|
+
* have the banner pushed out of the window by TAIL stdout volume.
|
|
210
|
+
* - callId: correlate to a specific `function_call_output.call_id`.
|
|
211
|
+
* - command: the claimed command (e.g. `tool_input.command`), used only for
|
|
212
|
+
* the command cross-check correlation below.
|
|
213
|
+
*
|
|
214
|
+
* Correlation policy (Decision B, #470 iteration 2, HIGH finding #4), in
|
|
215
|
+
* priority order:
|
|
216
|
+
* 1. call_id match wins — authoritative.
|
|
217
|
+
* 2. Absent a call_id match: take the newest `function_call_output` in the
|
|
218
|
+
* scanned window, resolve its paired `function_call` (same call_id), and
|
|
219
|
+
* compare that call's `arguments` command (normalized) to `command`. A
|
|
220
|
+
* resolved MATCH uses the newest output; a resolved MISMATCH DECLINES
|
|
221
|
+
* (`null`) rather than knowingly attribute a different call's exit code.
|
|
222
|
+
* 3. If no pairing is resolvable at all (the common single-call case): fall
|
|
223
|
+
* back to the newest `function_call_output` banner.
|
|
224
|
+
*
|
|
225
|
+
* Any failure (missing/unreadable/non-regular file, containment violation,
|
|
226
|
+
* malformed JSON lines, no candidate found) yields `null` — never throws.
|
|
227
|
+
*/
|
|
228
|
+
function readExitCodeFromRollout(transcriptPath, options) {
|
|
229
|
+
const opts = options || {};
|
|
230
|
+
const maxScanBytes = Number.isInteger(opts.maxScanBytes) && opts.maxScanBytes > 0
|
|
231
|
+
? opts.maxScanBytes
|
|
232
|
+
: 1024 * 1024;
|
|
233
|
+
const maxLineHeadBytes = Number.isInteger(opts.maxLineHeadBytes) && opts.maxLineHeadBytes > 0
|
|
234
|
+
? opts.maxLineHeadBytes
|
|
235
|
+
: 64 * 1024;
|
|
236
|
+
const callId = typeof opts.callId === 'string' && opts.callId ? opts.callId : null;
|
|
237
|
+
const command = normalizeCommandText(opts.command);
|
|
238
|
+
|
|
239
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath) return null;
|
|
240
|
+
|
|
241
|
+
let text;
|
|
242
|
+
let truncated;
|
|
243
|
+
try {
|
|
244
|
+
const realPath = resolveContainedRealPath(transcriptPath);
|
|
245
|
+
if (realPath === null) return null; // containment violation
|
|
246
|
+
|
|
247
|
+
const stat = fs.statSync(realPath);
|
|
248
|
+
const fd = fs.openSync(realPath, 'r');
|
|
249
|
+
try {
|
|
250
|
+
const readLen = Math.min(maxScanBytes, stat.size);
|
|
251
|
+
const start = stat.size - readLen;
|
|
252
|
+
const buf = Buffer.alloc(readLen);
|
|
253
|
+
fs.readSync(fd, buf, 0, readLen, start);
|
|
254
|
+
text = buf.toString('utf8');
|
|
255
|
+
// A read starting after byte 0 means the file is larger than the scan
|
|
256
|
+
// window and the first captured line may be a truncated fragment of a
|
|
257
|
+
// longer line that started before `start` — only then is it dropped.
|
|
258
|
+
truncated = start > 0;
|
|
259
|
+
} finally {
|
|
260
|
+
fs.closeSync(fd);
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let lines;
|
|
267
|
+
try {
|
|
268
|
+
lines = text.split('\n');
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
if (truncated && lines.length > 1) lines.shift();
|
|
273
|
+
|
|
274
|
+
let matchedByCallId = null;
|
|
275
|
+
let newestOutputEntry = null;
|
|
276
|
+
let pairedCommand = null;
|
|
277
|
+
|
|
278
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
279
|
+
const line = lines[i].trim();
|
|
280
|
+
if (!line) continue;
|
|
281
|
+
const candidate = parseCandidateLine(line, maxLineHeadBytes);
|
|
282
|
+
if (!candidate) continue; // malformed/partial/unrecognized line — skip, keep scanning
|
|
283
|
+
|
|
284
|
+
if (candidate.type === 'function_call_output') {
|
|
285
|
+
if (newestOutputEntry === null) newestOutputEntry = candidate; // first seen scanning backward = newest
|
|
286
|
+
if (callId && candidate.callId === callId) {
|
|
287
|
+
matchedByCallId = candidate;
|
|
288
|
+
break; // call_id match is authoritative — stop scanning
|
|
289
|
+
}
|
|
290
|
+
} else if (candidate.type === 'function_call') {
|
|
291
|
+
if (newestOutputEntry && pairedCommand === null && candidate.callId === newestOutputEntry.callId) {
|
|
292
|
+
pairedCommand = candidate.command;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let chosenOutput = null;
|
|
298
|
+
if (matchedByCallId) {
|
|
299
|
+
chosenOutput = matchedByCallId.output;
|
|
300
|
+
} else if (newestOutputEntry) {
|
|
301
|
+
if (pairedCommand !== null && command) {
|
|
302
|
+
if (pairedCommand === command) {
|
|
303
|
+
chosenOutput = newestOutputEntry.output;
|
|
304
|
+
} else {
|
|
305
|
+
return null; // positive command mismatch — decline rather than mis-attribute
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
chosenOutput = newestOutputEntry.output; // single-call fallback — no pairing resolvable
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (chosenOutput === null) return null;
|
|
313
|
+
return extractExitCodeFromBanner(chosenOutput);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = { extractExitCodeFromBanner, readExitCodeFromRollout };
|