@sabaiway/agent-workflow-kit 5.1.0 → 5.3.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/CHANGELOG.md +95 -0
- package/SKILL.md +13 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +14 -3
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +220 -30
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +264 -8
- package/bridges/antigravity-cli-bridge/bin/agy.sh +12 -2
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
- package/bridges/antigravity-cli-bridge/capability.json +19 -13
- package/bridges/antigravity-cli-bridge/references/driving-agy.md +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +17 -7
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +156 -36
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +228 -4
- package/bridges/codex-cli-bridge/bin/codex-review.sh +205 -34
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +276 -5
- package/bridges/codex-cli-bridge/capability.json +10 -7
- package/bridges/codex-cli-bridge/references/driving-codex.md +7 -5
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +26 -12
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/flow-writer.md +37 -0
- package/references/modes/gates.md +4 -4
- package/references/modes/procedures.md +4 -2
- package/references/modes/receipt-deadline.md +16 -0
- package/references/modes/review-state.md +1 -1
- package/references/modes/set-flow.md +22 -0
- package/references/scripts/archive-decisions.mjs +340 -15
- package/references/scripts/archive-decisions.test.mjs +522 -2
- package/tools/cheap-agents.mjs +8 -2
- package/tools/commands.mjs +24 -2
- package/tools/commit-guard.mjs +44 -9
- package/tools/core-evidence.mjs +25 -22
- package/tools/detect-backends.mjs +33 -11
- package/tools/dispatch-record.mjs +926 -0
- package/tools/doc-parity.mjs +21 -6
- package/tools/flow-check.mjs +842 -0
- package/tools/flow-record.mjs +795 -0
- package/tools/flow-store-read.mjs +114 -0
- package/tools/flow-store.mjs +1178 -0
- package/tools/flow-writer.mjs +1265 -0
- package/tools/fs-read-nofollow.mjs +128 -0
- package/tools/gates-declaration.mjs +184 -0
- package/tools/gates-init.mjs +59 -17
- package/tools/orchestration-config.mjs +87 -10
- package/tools/orchestration-write.mjs +3 -3
- package/tools/plan-files.mjs +35 -0
- package/tools/procedures.mjs +75 -11
- package/tools/receipt-deadline.mjs +242 -0
- package/tools/recipes.mjs +21 -0
- package/tools/repo-lex.mjs +22 -0
- package/tools/review-state.mjs +240 -80
- package/tools/run-gates.mjs +361 -139
- package/tools/set-flow.mjs +465 -0
- package/tools/velocity-profile.mjs +8 -2
|
@@ -86,6 +86,18 @@ Notes:
|
|
|
86
86
|
INFORMATIONAL only: it is never persisted in a receipt or session sidecar
|
|
87
87
|
quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated
|
|
88
88
|
posture; a prose re-type drifts
|
|
89
|
+
every-run nested-sandbox scan (DUAL policy, deliberately two different rules): the scan runs on
|
|
90
|
+
EVERY completed run, not only a failed one, because a run that SURVIVES the nested-sandbox failure
|
|
91
|
+
exits 0 with an ungrounded answer and nothing said so. Failed run (rc != 0): the existing loose
|
|
92
|
+
whole-trace combination rule prints the recovery hint. Successful run (rc == 0): a warning fires
|
|
93
|
+
ONLY on precise per-item evidence — both a sandbox-mechanism token AND a permission/read-only
|
|
94
|
+
failure token inside the aggregated_output of ONE command_execution item whose failure is PROVEN
|
|
95
|
+
(a nonzero exit_code, or the serialized status "failed"); a null exit_code is never failure by
|
|
96
|
+
itself, tokens split across two items never fire, and a successful command's output never fires.
|
|
97
|
+
The answer is printed FIRST on stdout, then the warning on stderr. HONEST RESIDUAL: the exit
|
|
98
|
+
status does NOT change on that lane (a distinct nonzero exit would give a heuristic scan DENY
|
|
99
|
+
polarity, refusing real work whenever the scan over-warns), so an orchestrator keying on exit
|
|
100
|
+
status alone can still bank an ungrounded answer — the stderr warning is the signal
|
|
89
101
|
|
|
90
102
|
Settings file (KEY=VALUE, parsed never sourced; env wins over file, file wins over built-in default):
|
|
91
103
|
${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf
|
|
@@ -210,8 +222,9 @@ aw_apply_settings
|
|
|
210
222
|
|
|
211
223
|
# --- Effective-timeout resolver (D5 banner honesty; AD-061) --------------------
|
|
212
224
|
# ONE rule, both bridges: the posture banner prints EXACTLY the duration handed to timeout(1) —
|
|
213
|
-
# an integer-seconds value rendered with the `s` suffix, a duration string verbatim
|
|
214
|
-
#
|
|
225
|
+
# an integer-seconds value rendered with the `s` suffix, a duration string verbatim; without a
|
|
226
|
+
# capping binary the EXEC wrappers print `timeout=uncapped` and run, while the REVIEW wrappers
|
|
227
|
+
# refuse pre-spend (fail-closed preflight) — never a fabricated number.
|
|
215
228
|
# The EFFECTIVE value (env included — closing the aw_settings_valid env bypass) is validated by
|
|
216
229
|
# the same per-key rule as the settings file, plus a 7-digit integer-part bound (overflow); an
|
|
217
230
|
# invalid value warns + falls back to the built-in default — a typo never silently masquerades
|
|
@@ -411,9 +424,9 @@ fi
|
|
|
411
424
|
|
|
412
425
|
# --- Resume detection (must be the FIRST argument) ---------------------------
|
|
413
426
|
# A dedicated entrypoint for iterating on a session without re-sending context.
|
|
414
|
-
# `codex exec resume` RESETS posture and rejects the -s/--add-dir/-C posture flags
|
|
415
|
-
#
|
|
416
|
-
#
|
|
427
|
+
# `codex exec resume` RESETS posture and rejects the -s/--add-dir/-C posture flags,
|
|
428
|
+
# so we restate the FULL policy via -c. It DOES accept -o/--json/--color, and it now
|
|
429
|
+
# carries the same capture posture as a fresh run (one evidence surface, both modes).
|
|
417
430
|
resume_mode=""
|
|
418
431
|
resume_id=""
|
|
419
432
|
case "${1:-}" in
|
|
@@ -607,8 +620,10 @@ chmod 755 "$shim_dir/git"
|
|
|
607
620
|
# --- Build the codex invocation + the prompt ---------------------------------
|
|
608
621
|
if [[ -n "$resume_mode" ]]; then
|
|
609
622
|
# Resume RESETS posture and rejects the -s/--add-dir/-C posture flags, so restate
|
|
610
|
-
# the entire policy via -c.
|
|
611
|
-
#
|
|
623
|
+
# the entire policy via -c. It DOES accept -o/--json/--color, and it now carries the
|
|
624
|
+
# SAME capture posture as a fresh run: the mode asymmetry was a convenience, and it
|
|
625
|
+
# left resume — the lane the survived-nested-sandbox incident fired on — with no
|
|
626
|
+
# evidence surface at all (its event stream went nowhere).
|
|
612
627
|
codex_cmd=(codex exec resume "$resume_id"
|
|
613
628
|
--ignore-user-config
|
|
614
629
|
-m "$CODEX_MODEL"
|
|
@@ -619,6 +634,9 @@ if [[ -n "$resume_mode" ]]; then
|
|
|
619
634
|
-c hide_agent_reasoning=true
|
|
620
635
|
-c model_reasoning_summary=none
|
|
621
636
|
"${tier_flags[@]+"${tier_flags[@]}"}"
|
|
637
|
+
--color never
|
|
638
|
+
-o "$out"
|
|
639
|
+
--json
|
|
622
640
|
-)
|
|
623
641
|
full_prompt="$RESUME_REMINDER"$'\n\n'"$task"
|
|
624
642
|
else
|
|
@@ -671,25 +689,136 @@ aw_session_label="fresh"
|
|
|
671
689
|
[[ -n "$resume_mode" ]] && aw_session_label="resume:$resume_id"
|
|
672
690
|
echo "exec posture: model=$CODEX_MODEL effort=$CODEX_EFFORT tier=${CODEX_SERVICE_TIER:-standard} sandbox=workspace-write session=$aw_session_label timeout=$aw_timeout_banner" >&2
|
|
673
691
|
|
|
674
|
-
#
|
|
675
|
-
#
|
|
676
|
-
#
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
692
|
+
# --- Nested-sandbox evidence scan: ONE entry point, TWO policies ---------------
|
|
693
|
+
# The class: codex ships its OWN OS sandbox (bwrap); run nested inside a harness sandbox the FS is
|
|
694
|
+
# read-only and codex's sandbox setup fails. The scan runs on EVERY completed run, not only a failed
|
|
695
|
+
# one — when the backend SURVIVES the failure (degrades to "I cannot check" and exits 0) a paid run
|
|
696
|
+
# is spent on an ungrounded answer, and nothing said so.
|
|
697
|
+
#
|
|
698
|
+
# The two arms have DIFFERENT rules, deliberately:
|
|
699
|
+
# FAILED run (rc != 0) — the loose whole-trace COMBINATION rule, unchanged: a sandbox MECHANISM
|
|
700
|
+
# token AND a permission/read-only FAILURE token anywhere in the trace. The run already failed and
|
|
701
|
+
# the operator is already reading the tail, so an extra hint costs nothing. grep is line-oriented,
|
|
702
|
+
# so a plain alternation stays within a line — hence two `-q` passes rather than one pattern.
|
|
703
|
+
# SUCCESSFUL run (rc == 0) — per-item evidence ONLY: both tokens inside the aggregated_output of
|
|
704
|
+
# ONE command_execution item whose FAILURE is proven. Here a false positive would libel a good
|
|
705
|
+
# answer, so nothing loose is allowed near it.
|
|
706
|
+
# The successful-run scan is LINE-ORIENTED and TOLERANT: after the unified 2>&1 the trace
|
|
707
|
+
# legitimately mixes plain log lines with JSONL, so every line is judged alone, a line that is not a
|
|
708
|
+
# well-formed command_execution item is simply not evidence (never a parse error, never a stop), and
|
|
709
|
+
# no line can mask a later one.
|
|
710
|
+
AW_NS_MECHANISM='bwrap|landlock|user namespace|pivot_root|unshare|seccomp'
|
|
711
|
+
AW_NS_FAILURE='read-only file system|erofs|operation not permitted|permission denied|eperm'
|
|
712
|
+
|
|
713
|
+
# BOTH token classes present in one piece of text. Fed by here-string rather than `printf | grep`:
|
|
714
|
+
# with `pipefail` on, a producer that takes EPIPE when `grep -q` exits early on a match would make
|
|
715
|
+
# the pipeline non-zero and silently DROP a real signature — an under-fire invisible by
|
|
716
|
+
# construction. Not reproduced on this host, folded as portability hardening: with no explicit
|
|
717
|
+
# pipeline there is no producer left to fail.
|
|
718
|
+
aw_ns_both_tokens() {
|
|
719
|
+
grep -qiE "$AW_NS_MECHANISM" <<<"$1" || return 1
|
|
720
|
+
grep -qiE "$AW_NS_FAILURE" <<<"$1" || return 1
|
|
721
|
+
return 0
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
# Valid JSON string CONTENT — no UNESCAPED quote. This is what proves a delimiter slice stayed
|
|
725
|
+
# inside ONE string instead of crossing an object boundary: without it, a decoy object carrying the
|
|
726
|
+
# anchor lets the walk leave its own string and land in another item's fields.
|
|
727
|
+
# The predicate is the parity rule — a quote is escaped iff an ODD number of backslashes precedes
|
|
728
|
+
# it — expressed as an ERE and evaluated by grep: `(^|[^\])` then an EVEN run `(\\)*` then the
|
|
729
|
+
# quote. It is measured, not assumed: the obvious bash spelling (delete the `\\` and `\"` pairs,
|
|
730
|
+
# then look for a survivor) is the SAME predicate but bash's `${var//…}` is quadratic, and it hung
|
|
731
|
+
# the wrapper outright on a 200KB aggregated_output — a real tool call's output reaches that size.
|
|
732
|
+
aw_ns_is_string_content() {
|
|
733
|
+
if grep -qE '(^|[^\\])(\\\\)*"' <<<"$1"; then return 1; fi
|
|
734
|
+
return 0
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
# One trace line → 0 only when THAT line is a command_execution item with a PROVEN failure whose
|
|
738
|
+
# aggregated_output carries both token classes. The wrapper stays dependency-free, so this is not a
|
|
739
|
+
# JSON parse — it is ONE anchored walk over the CLI's observed serialization in which every SKIPPED
|
|
740
|
+
# gap is PROVEN to be a single JSON string's content (aw_ns_is_string_content). Both halves are
|
|
741
|
+
# load-bearing: testing the fields as independent substrings lets a decoy object supply the anchor
|
|
742
|
+
# while the failure fields belong to another item, and skipping a gap without validating it lets the
|
|
743
|
+
# walk leave its own string and land in that other item anyway.
|
|
744
|
+
# The observed shape (codex-cli 0.147.0, live-probed):
|
|
745
|
+
# {"id":…,"type":"command_execution","command":"…","aggregated_output":"…","exit_code":2,"status":"failed"}
|
|
746
|
+
# Failure proofs: A = a nonzero exit_code, B = the status "failed" immediately after it. A null
|
|
747
|
+
# exit_code is never failure by itself (an in-flight item carries "exit_code":null,"status":"in_progress").
|
|
748
|
+
# Two stated consequences, both deliberate:
|
|
749
|
+
# - a future CLI that REORDERS these fields makes the scan stop firing rather than misfire.
|
|
750
|
+
# Under-firing is the right direction here: over-firing would libel a correct answer.
|
|
751
|
+
# - only the FIRST matching item on a line is judged; a second item's evidence is missed.
|
|
752
|
+
# A hand-crafted trace line is outside the threat model (the same boundary the wrapper declares for
|
|
753
|
+
# a hostile parent environment): the trace's only non-CLI content is plain stderr, which does not
|
|
754
|
+
# start with `{`, and a tool call's own output is JSON-escaped into a string and cannot inject
|
|
755
|
+
# structure. Anything not matching the walk is "not evidence" — never an error, never a stop.
|
|
756
|
+
# Every slice is taken by a SHORT-pattern `#*` cut plus length arithmetic. That is not a style
|
|
757
|
+
# choice: `${var%%<long>*}` and a prefix removal whose PATTERN is a huge variable are both
|
|
758
|
+
# quadratic in bash, and either one hangs the wrapper outright on a 200KB aggregated_output
|
|
759
|
+
# (measured, not assumed — a real tool call's output reaches that size).
|
|
760
|
+
aw_ns_item_evidence() {
|
|
761
|
+
local line="$1" d1='","aggregated_output":"' d2='","exit_code":' rest tail cmd agg code after
|
|
762
|
+
case "$line" in '{'*) ;; *) return 1 ;; esac
|
|
763
|
+
rest="${line#*'"type":"command_execution","command":"'}"
|
|
764
|
+
if [[ "$rest" == "$line" ]]; then return 1; fi
|
|
765
|
+
tail="${rest#*"$d1"}"
|
|
766
|
+
if [[ "$tail" == "$rest" ]]; then return 1; fi
|
|
767
|
+
cmd="${rest:0:$(( ${#rest} - ${#tail} - ${#d1} ))}"
|
|
768
|
+
aw_ns_is_string_content "$cmd" || return 1
|
|
769
|
+
after="${tail#*"$d2"}"
|
|
770
|
+
if [[ "$after" == "$tail" ]]; then return 1; fi
|
|
771
|
+
agg="${tail:0:$(( ${#tail} - ${#after} - ${#d2} ))}"
|
|
772
|
+
aw_ns_is_string_content "$agg" || return 1
|
|
773
|
+
code="${after%%,*}"
|
|
774
|
+
if [[ "$code" =~ ^-?[0-9]+$ && "$code" != "0" ]]; then
|
|
775
|
+
:
|
|
776
|
+
elif [[ "${after#"$code",}" == '"status":"failed"'* ]]; then
|
|
777
|
+
:
|
|
681
778
|
else
|
|
682
|
-
|
|
779
|
+
return 1
|
|
683
780
|
fi
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
781
|
+
aw_ns_both_tokens "$agg"
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
aw_scan_nested_sandbox() { # $1 = rc, $2 = trace path
|
|
785
|
+
[[ -r "$2" ]] || return 0
|
|
786
|
+
if [[ "$1" -ne 0 ]]; then
|
|
787
|
+
if grep -qiE "$AW_NS_MECHANISM" "$2" 2>/dev/null && grep -qiE "$AW_NS_FAILURE" "$2" 2>/dev/null; then
|
|
788
|
+
echo "hint: this looks like a NESTED-SANDBOX failure — codex-exec ships its own OS sandbox (bwrap)," >&2
|
|
789
|
+
echo " which cannot run nested inside a harness sandbox (the FS is read-only). Route codex-exec" >&2
|
|
790
|
+
echo " OUTSIDE the harness sandbox: add it to the harness sandbox excludedCommands, or dispatch" >&2
|
|
791
|
+
echo " this one run via a per-run consented bypass. Do NOT blanket-disable the sandbox." >&2
|
|
792
|
+
fi
|
|
793
|
+
return 0
|
|
690
794
|
fi
|
|
691
|
-
|
|
795
|
+
local line
|
|
796
|
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
797
|
+
if aw_ns_item_evidence "$line"; then
|
|
798
|
+
echo "warning: NESTED-SANDBOX — this run COMPLETED, but a tool call inside it FAILED with a sandbox-setup" >&2
|
|
799
|
+
echo " signature. codex-exec ships its own OS sandbox (bwrap), which cannot run nested inside a" >&2
|
|
800
|
+
echo " harness sandbox (the FS turns read-only), so the backend most likely could not read what" >&2
|
|
801
|
+
echo " it was asked to check: the answer above may be UNGROUNDED — treat it as such rather than" >&2
|
|
802
|
+
echo " banking it. Re-dispatch OUTSIDE the harness sandbox: add codex-exec to the harness" >&2
|
|
803
|
+
echo " sandbox excludedCommands, or use a per-run consented bypass. Do NOT blanket-disable the" >&2
|
|
804
|
+
echo " sandbox. The exit status stays 0 on purpose — this is a warning, never a gate." >&2
|
|
805
|
+
return 0
|
|
806
|
+
fi
|
|
807
|
+
done <"$2"
|
|
808
|
+
return 0
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
# ONE capture posture for BOTH modes: -o writes the final message to $out, the JSON
|
|
812
|
+
# event stream and every log line go to $trace (stderr merged in). The final always
|
|
813
|
+
# lands in $out and the diagnostics always in $trace, so the post-processing below —
|
|
814
|
+
# including the evidence scan — is genuinely shared instead of mode-dependent.
|
|
815
|
+
set +e
|
|
816
|
+
if [[ -n "$timeout_bin" ]]; then
|
|
817
|
+
printf '%s' "$full_prompt" | "${run_env[@]}" "$timeout_bin" --kill-after=15s "$CODEX_HARD_TIMEOUT" "${codex_cmd[@]}" >"$trace" 2>&1
|
|
818
|
+
else
|
|
819
|
+
printf '%s' "$full_prompt" | "${run_env[@]}" "${codex_cmd[@]}" >"$trace" 2>&1
|
|
692
820
|
fi
|
|
821
|
+
rc=$?
|
|
693
822
|
set -e
|
|
694
823
|
|
|
695
824
|
if [[ $rc -eq 124 || $rc -eq 137 ]]; then
|
|
@@ -700,20 +829,7 @@ fi
|
|
|
700
829
|
if [[ $rc -ne 0 ]]; then
|
|
701
830
|
echo "error: codex exec failed (exit $rc). Last lines of the run trace:" >&2
|
|
702
831
|
tail -n 40 "$trace" >&2
|
|
703
|
-
|
|
704
|
-
# sandbox the FS is read-only and codex's own sandbox setup fails. Fire the STATED recovery hint
|
|
705
|
-
# only on a COMBINATION — a sandbox MECHANISM token AND a permission/read-only FAILURE token in the
|
|
706
|
-
# trace — so a lone 'bwrap' banner, or a lone 'permission denied' from unrelated code, is NOT enough
|
|
707
|
-
# (never a preemptive blanket). grep is line-oriented, so a plain alternation stays within a line —
|
|
708
|
-
# the old `[^\n]*` between the two halves was wrong twice over (it excluded the letter 'n', and grep
|
|
709
|
-
# never spans lines anyway), so the split into two `-q` passes both fixes it and states the intent.
|
|
710
|
-
if grep -qiE 'bwrap|landlock|user namespace|pivot_root|unshare|seccomp' "$trace" 2>/dev/null \
|
|
711
|
-
&& grep -qiE 'read-only file system|erofs|operation not permitted|permission denied|eperm' "$trace" 2>/dev/null; then
|
|
712
|
-
echo "hint: this looks like a NESTED-SANDBOX failure — codex-exec ships its own OS sandbox (bwrap)," >&2
|
|
713
|
-
echo " which cannot run nested inside a harness sandbox (the FS is read-only). Route codex-exec" >&2
|
|
714
|
-
echo " OUTSIDE the harness sandbox: add it to the harness sandbox excludedCommands, or dispatch" >&2
|
|
715
|
-
echo " this one run via a per-run consented bypass. Do NOT blanket-disable the sandbox." >&2
|
|
716
|
-
fi
|
|
832
|
+
aw_scan_nested_sandbox "$rc" "$trace"
|
|
717
833
|
exit $rc
|
|
718
834
|
fi
|
|
719
835
|
|
|
@@ -738,3 +854,7 @@ else
|
|
|
738
854
|
echo "warning: codex produced no final-message file — printing the run-trace tail instead." >&2
|
|
739
855
|
tail -n 40 "$trace"
|
|
740
856
|
fi
|
|
857
|
+
|
|
858
|
+
# The answer is printed FIRST, then the evidence speaks: a run that COMPLETED can still have been
|
|
859
|
+
# ungrounded, and saying so after the answer keeps stdout byte-identical for every caller.
|
|
860
|
+
aw_scan_nested_sandbox "$rc" "$trace"
|
|
@@ -46,6 +46,13 @@ const FAKE_CODEX = [
|
|
|
46
46
|
' cat <<EOF',
|
|
47
47
|
'{"type":"turn.started"}',
|
|
48
48
|
'{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"FAKE_FINAL_MESSAGE"}}',
|
|
49
|
+
'EOF',
|
|
50
|
+
// The event seam: verbatim extra stream lines (JSONL items, plain noise, or both) between the
|
|
51
|
+
// opening events and turn.completed — a multi-line value emits multiple lines.
|
|
52
|
+
' if [[ -n "${CODEX_FAKE_EVENT:-}" ]]; then echo "$CODEX_FAKE_EVENT"; fi',
|
|
53
|
+
// A file-borne twin: a payload too large for the environment (E2BIG) still has to be emittable.
|
|
54
|
+
' if [[ -n "${CODEX_FAKE_EVENT_FILE:-}" ]]; then cat "$CODEX_FAKE_EVENT_FILE"; fi',
|
|
55
|
+
' cat <<EOF',
|
|
49
56
|
'{"type":"turn.completed","usage":{}}',
|
|
50
57
|
'EOF',
|
|
51
58
|
'else',
|
|
@@ -365,6 +372,203 @@ describe('codex-exec.sh — clean output + session capture (1.2)', () => {
|
|
|
365
372
|
assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'a permission failure from unrelated code is not nested-sandbox proof');
|
|
366
373
|
});
|
|
367
374
|
|
|
375
|
+
// ── the rc == 0 arm: the SURVIVED nested-sandbox failure ──
|
|
376
|
+
// The class the failed-run arm cannot see: the backend hits the nested sandbox, degrades to "I
|
|
377
|
+
// cannot check", and exits 0 — a paid run spent on an ungrounded answer with nothing saying so.
|
|
378
|
+
// Both serialized shapes below were observed on the INSTALLED codex-cli 0.147.0: a finished item
|
|
379
|
+
// carries {"exit_code":2,"status":"failed"}, an in-flight one {"exit_code":null,"status":"in_progress"}.
|
|
380
|
+
const cmdItem = ({ command = '/bin/bash -lc probe', output = '', exitCode = null, status = 'completed', id = 'item_1' }) =>
|
|
381
|
+
JSON.stringify({ type: 'item.completed', item: { id, type: 'command_execution', command, aggregated_output: output, exit_code: exitCode, status } });
|
|
382
|
+
const MECHANISM = 'bwrap: setting up sandbox';
|
|
383
|
+
const FAILURE = 'mkdir /newroot: Read-only file system';
|
|
384
|
+
const SIGNATURE = `${MECHANISM}: ${FAILURE}\n`;
|
|
385
|
+
|
|
386
|
+
it('an rc == 0 run whose trace carries a command_execution with a NONZERO exit_code and the signature warns loudly and still prints the answer', () => {
|
|
387
|
+
const sb = makeSandbox();
|
|
388
|
+
const r = run(sb, { env: { CODEX_FAKE_EVENT: cmdItem({ output: SIGNATURE, exitCode: 1, status: 'completed' }) } });
|
|
389
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
390
|
+
assert.equal(r.status, 0, 'the warning lane never changes the exit status');
|
|
391
|
+
assert.match(r.stdout, /FAKE_FINAL_MESSAGE/, 'the answer is printed FIRST, on stdout, unchanged');
|
|
392
|
+
assert.match(r.stderr, /NESTED-SANDBOX/, 'names the class');
|
|
393
|
+
assert.match(r.stderr, /UNGROUNDED/, 'names the consequence for the answer above');
|
|
394
|
+
assert.match(r.stderr, /excludedCommands|per-run consented bypass/, 'names the reroute');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('an rc == 0 run whose trace carries a command_execution with a null exit_code and an explicitly FAILED status warns', () => {
|
|
398
|
+
const sb = makeSandbox();
|
|
399
|
+
const r = run(sb, { env: { CODEX_FAKE_EVENT: cmdItem({ output: SIGNATURE, exitCode: null, status: 'failed' }) } });
|
|
400
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
401
|
+
assert.equal(r.status, 0);
|
|
402
|
+
assert.match(r.stderr, /NESTED-SANDBOX/, 'the serialized failed status is the second failure proof');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('plain non-JSON stderr lines before and after a matching failed command_execution do not suppress the warning', () => {
|
|
406
|
+
const sb = makeSandbox();
|
|
407
|
+
const r = run(sb, {
|
|
408
|
+
env: {
|
|
409
|
+
CODEX_FAKE_STDERR: 'ERROR codex_core::session: failed to load skill /x/SKILL.md: missing field description',
|
|
410
|
+
CODEX_FAKE_EVENT: `not json at all\n${cmdItem({ output: SIGNATURE, exitCode: 2, status: 'failed' })}\nstill not json`,
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
414
|
+
assert.equal(r.status, 0);
|
|
415
|
+
assert.match(r.stderr, /NESTED-SANDBOX/, 'the merged stream is judged line by line — noise is not evidence and never a stop');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('the resume lane warns on an rc == 0 nested-sandbox signature — the lane the incident fired on', () => {
|
|
419
|
+
const sb = makeSandbox();
|
|
420
|
+
const r = run(sb, {
|
|
421
|
+
args: ['--resume', 'sess-nested', '-'], input: 'continue',
|
|
422
|
+
env: { CODEX_FAKE_EVENT: cmdItem({ output: SIGNATURE, exitCode: 1, status: 'failed' }) },
|
|
423
|
+
});
|
|
424
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
425
|
+
assert.equal(r.status, 0);
|
|
426
|
+
assert.match(r.stdout, /FAKE_FINAL_MESSAGE/);
|
|
427
|
+
assert.match(r.stderr, /NESTED-SANDBOX/, 'the whole point of unifying the capture');
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('an rc == 0 run with a clean trace warns nothing', () => {
|
|
431
|
+
const sb = makeSandbox();
|
|
432
|
+
const r = run(sb);
|
|
433
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
434
|
+
assert.equal(r.status, 0, r.stderr);
|
|
435
|
+
assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'a clean run must stay silent');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('a lone mechanism token and a lone failure token each warn nothing on the rc == 0 lane', () => {
|
|
439
|
+
for (const output of [`${MECHANISM} version 0.11.0\n`, `curl: (7) ${FAILURE}\n`]) {
|
|
440
|
+
const sb = makeSandbox();
|
|
441
|
+
const r = run(sb, { env: { CODEX_FAKE_EVENT: cmdItem({ output, exitCode: 1, status: 'failed' }) } });
|
|
442
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
443
|
+
assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, `a lone token class is not proof: ${output}`);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('nested-sandbox text appearing ONLY inside an agent_message item never warns', () => {
|
|
448
|
+
const sb = makeSandbox();
|
|
449
|
+
const event = JSON.stringify({ type: 'item.completed', item: { id: 'item_9', type: 'agent_message', text: `I hit ${SIGNATURE}` } });
|
|
450
|
+
const r = run(sb, { env: { CODEX_FAKE_EVENT: event } });
|
|
451
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
452
|
+
assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'the model TALKING about a sandbox is not a failed tool call');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('a SUCCESSFUL command_execution whose output merely QUOTES both tokens never warns', () => {
|
|
456
|
+
const sb = makeSandbox();
|
|
457
|
+
// The concrete false positive: codex-exec.sh itself carries both token classes, so any
|
|
458
|
+
// successful grep over it would trip a loose whole-trace rule.
|
|
459
|
+
const r = run(sb, {
|
|
460
|
+
env: { CODEX_FAKE_EVENT: cmdItem({ command: '/bin/bash -lc grep -n bwrap codex-exec.sh', output: SIGNATURE, exitCode: 0, status: 'completed' }) },
|
|
461
|
+
});
|
|
462
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
463
|
+
assert.equal(r.status, 0, r.stderr);
|
|
464
|
+
assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'a command that SUCCEEDED proves nothing failed');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('a command_execution with a null exit_code and no proven failed status never warns', () => {
|
|
468
|
+
const sb = makeSandbox();
|
|
469
|
+
const r = run(sb, { env: { CODEX_FAKE_EVENT: cmdItem({ output: SIGNATURE, exitCode: null, status: 'in_progress' }) } });
|
|
470
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
471
|
+
assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'a null exit_code is never failure by itself');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('tokens split across two different items never warn', () => {
|
|
475
|
+
const sb = makeSandbox();
|
|
476
|
+
const split = [
|
|
477
|
+
cmdItem({ id: 'item_1', output: `${MECHANISM} version 0.11.0\n`, exitCode: 1, status: 'failed' }),
|
|
478
|
+
cmdItem({ id: 'item_2', output: `curl: (7) ${FAILURE}\n`, exitCode: 1, status: 'failed' }),
|
|
479
|
+
].join('\n');
|
|
480
|
+
const r = run(sb, { env: { CODEX_FAKE_EVENT: split } });
|
|
481
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
482
|
+
assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'the combination must sit in ONE item — two failures are not one nested sandbox');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// ── object membership, not substring co-occurrence ──
|
|
486
|
+
// Testing the four fields independently is not enough: position in the line is not membership in
|
|
487
|
+
// the item. The scan walks ONE contiguous chain of raw delimiters instead, and every gap in that
|
|
488
|
+
// chain is inside a JSON string, where a quote is escaped and cannot forge the next delimiter.
|
|
489
|
+
it('a decoy object carrying the type, with the failure fields on a DIFFERENT item, never warns', () => {
|
|
490
|
+
const sb = makeSandbox();
|
|
491
|
+
const decoy = '{"type":"item.completed","decoy":{"type":"command_execution"},"item":{"type":"agent_message","aggregated_output":"bwrap: operation not permitted","exit_code":0,"status":"failed"}}';
|
|
492
|
+
const r = run(sb, { env: { CODEX_FAKE_EVENT: decoy } });
|
|
493
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
494
|
+
assert.equal(r.status, 0, r.stderr);
|
|
495
|
+
assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'the type belongs to the decoy; the failure fields belong to an agent_message');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('a decoy carrying BOTH the type and a command, with the failure fields on a DIFFERENT item, never warns', () => {
|
|
499
|
+
const sb = makeSandbox();
|
|
500
|
+
// Anchoring on a longer literal is not enough: the skip between fields must itself be PROVEN to
|
|
501
|
+
// be one JSON string's content, or the walk leaves the decoy's command and lands in the
|
|
502
|
+
// agent_message's fields.
|
|
503
|
+
const decoy = '{"type":"item.completed","decoy":{"type":"command_execution","command":"x"},"item":{"type":"agent_message","aggregated_output":"bwrap: setting up sandbox: operation not permitted","exit_code":1,"status":"failed"}}';
|
|
504
|
+
const r = run(sb, { env: { CODEX_FAKE_EVENT: decoy } });
|
|
505
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
506
|
+
assert.equal(r.status, 0, r.stderr);
|
|
507
|
+
assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'an unvalidated gap lets the walk cross an object boundary');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('a genuinely failed item with a ~200KB aggregated_output still warns — and the scan does not hang', () => {
|
|
511
|
+
const sb = makeSandbox();
|
|
512
|
+
// Two edges at once: the signature sits FIRST, so any early-exit consumer must not lose it, and
|
|
513
|
+
// the field is far larger than a pipe buffer. It also pins the cost: the quadratic bash string
|
|
514
|
+
// spellings of this walk hang the wrapper outright at this size.
|
|
515
|
+
const big = `${SIGNATURE}${'x'.repeat(200000)}`;
|
|
516
|
+
// The payload rides a FILE: 200KB in the environment is E2BIG on a normal host.
|
|
517
|
+
const payload = join(sb.repo, 'big-event.jsonl');
|
|
518
|
+
writeFileSync(payload, `${cmdItem({ output: big, exitCode: 1, status: 'failed' })}\n`);
|
|
519
|
+
const r = run(sb, { env: { CODEX_FAKE_EVENT_FILE: payload } });
|
|
520
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
521
|
+
assert.equal(r.status, 0, r.stderr);
|
|
522
|
+
assert.match(r.stderr, /NESTED-SANDBOX/, 'a large output must not silently drop a real signature');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('a plain non-JSON log line carrying the same substrings never warns', () => {
|
|
526
|
+
const sb = makeSandbox();
|
|
527
|
+
const lookalike = `ERROR codex_core: replaying "type":"command_execution","command":"x","aggregated_output":"${SIGNATURE.trim()}","exit_code":1,"status":"failed"`;
|
|
528
|
+
const r = run(sb, { env: { CODEX_FAKE_EVENT: lookalike } });
|
|
529
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
530
|
+
assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'prose ABOUT an event is not an event — an event line starts with {');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('a FOREIGN "status":"failed" elsewhere on the line never proves a SUCCESSFUL item failed', () => {
|
|
534
|
+
const sb = makeSandbox();
|
|
535
|
+
const event = `${cmdItem({ output: SIGNATURE, exitCode: 0, status: 'completed' })}{"type":"turn.failed","status":"failed"}`;
|
|
536
|
+
const r = run(sb, { env: { CODEX_FAKE_EVENT: event } });
|
|
537
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
538
|
+
assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'the failed status must sit immediately after THIS item exit_code');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('an escaped delimiter inside aggregated_output never fools the slice', () => {
|
|
542
|
+
const sb = makeSandbox();
|
|
543
|
+
const r = run(sb, {
|
|
544
|
+
env: { CODEX_FAKE_EVENT: cmdItem({ output: `${SIGNATURE}","exit_code":1,"status":"failed"`, exitCode: 0, status: 'completed' }) },
|
|
545
|
+
});
|
|
546
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
547
|
+
assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'a quote inside a JSON string is escaped, so the raw delimiter cannot occur there');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('only the FIRST command_execution item of a line is judged — a second item on the same line is missed (a STATED false negative)', () => {
|
|
551
|
+
const sb = makeSandbox();
|
|
552
|
+
const glued = `${cmdItem({ id: 'item_1', output: 'all good\n', exitCode: 0, status: 'completed' })}${cmdItem({ id: 'item_2', output: SIGNATURE, exitCode: 1, status: 'failed' })}`;
|
|
553
|
+
const r = run(sb, { env: { CODEX_FAKE_EVENT: glued } });
|
|
554
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
555
|
+
assert.doesNotMatch(r.stderr, /NESTED-SANDBOX/, 'under-firing is the deliberate direction on a warning lane; this pins it so it cannot change silently');
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('a trace of plain non-JSON lines alone carrying both tokens never warns on the rc == 0 arm — while the FAILED arm warns on exactly those bytes', () => {
|
|
559
|
+
const bytes = `${MECHANISM}: ${FAILURE}`;
|
|
560
|
+
const clean = makeSandbox();
|
|
561
|
+
const ok = run(clean, { env: { CODEX_FAKE_STDERR: bytes } });
|
|
562
|
+
rmSync(clean.root, { recursive: true, force: true });
|
|
563
|
+
assert.equal(ok.status, 0, ok.stderr);
|
|
564
|
+
assert.doesNotMatch(ok.stderr, /NESTED-SANDBOX/, 'on a COMPLETED run only per-item evidence speaks');
|
|
565
|
+
const failed = makeSandbox();
|
|
566
|
+
const bad = run(failed, { env: { CODEX_FAKE_STDERR: bytes, CODEX_FAKE_EXIT: '1' } });
|
|
567
|
+
rmSync(failed.root, { recursive: true, force: true });
|
|
568
|
+
assert.equal(bad.status, 1);
|
|
569
|
+
assert.match(bad.stderr, /NESTED-SANDBOX/, 'the failed-run arm keeps its loose whole-trace rule — that is what makes the dual policy visible');
|
|
570
|
+
});
|
|
571
|
+
|
|
368
572
|
it('warns (never silently) when the session sidecar cannot be written', () => {
|
|
369
573
|
const sb = makeSandbox();
|
|
370
574
|
const blocker = join(sb.repo, 'blocker');
|
|
@@ -447,10 +651,30 @@ describe('codex-exec.sh — resume entrypoint restates every invariant (3.1)', (
|
|
|
447
651
|
assert.equal(r.status, 0, r.stderr);
|
|
448
652
|
assert.match(r.argv, /(^|\n)sess-xyz(\n|$)/, 'the session id is passed positionally');
|
|
449
653
|
for (const inv of RESUME_INVARIANTS) assert.match(r.argv, inv, `resume argv must include ${inv}`);
|
|
450
|
-
assert.
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
654
|
+
assert.match(r.stdout, /FAKE_FINAL_MESSAGE/, 'resume prints the final message');
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// The capture unification: resume used to be the odd mode out — no -o, no --json, its event
|
|
658
|
+
// stream nowhere — which is precisely why the lane the nested-sandbox incident fired on had no
|
|
659
|
+
// evidence surface. `codex exec resume` accepts both (live-probed, codex-cli 0.147.0).
|
|
660
|
+
it('resume composes the unified capture — the same -o/--json/--color posture as a fresh run', () => {
|
|
661
|
+
const sb = makeSandbox();
|
|
662
|
+
const r = run(sb, { args: ['--resume', 'sess-unified', '-'], input: 'continue please' });
|
|
663
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
664
|
+
assert.equal(r.status, 0, r.stderr);
|
|
665
|
+
assert.match(r.argv, /(^|\n)-o(\n|$)/, 'resume writes the final message through -o');
|
|
666
|
+
assert.match(r.argv, /(^|\n)--json(\n|$)/, 'resume streams the structured events');
|
|
667
|
+
assert.match(r.argv, /(^|\n)--color(\n|$)/, 'resume disables colour like a fresh run');
|
|
668
|
+
assert.match(r.stdout, /FAKE_FINAL_MESSAGE/, 'resume stdout is still the final message');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('resume falls back to the trace tail when the final-message file is missing', () => {
|
|
672
|
+
const sb = makeSandbox();
|
|
673
|
+
const r = run(sb, { args: ['--resume', 'sess-noout', '-'], input: 'go', env: { CODEX_FAKE_NO_OUT: '1' } });
|
|
674
|
+
rmSync(sb.root, { recursive: true, force: true });
|
|
675
|
+
assert.equal(r.status, 0, r.stderr);
|
|
676
|
+
assert.match(r.stderr, /no final-message file/, 'the fallback is loud, never silent');
|
|
677
|
+
assert.match(r.stdout, /turn\.completed/, 'the trace tail carries the event stream resume now captures');
|
|
454
678
|
});
|
|
455
679
|
|
|
456
680
|
it('--resume-last reads the session id from the sidecar', () => {
|