Skills › Compound Engineering › ce-work › cross-model-work.sh
cross-model-work.sh
1400 lines · skills/ce-work/scripts/cross-model-work.sh at 3449230
1#!/usr/bin/env bash
2# Run one pre-sanctioned, write-capable implementation route in a controller-
3# supplied detached workspace. The adapter never creates worktrees, changes
4# recipients, integrates output, or retries through another route.
5#
6# Every route reaches its agent through acpx (the Agent Client Protocol
7# client), which prints the JSON-RPC stream; one parser reads every route.
8#
9# Usage:
10# cross-model-work.sh <authorization-json> <workspace> <unit-packet> <expected-packet-sha256> <result-dir>
11#
12# Routes: codex | claude | grok-cli | cursor | composer | grok-cursor | opencode | antigravity
13# Output: <result-dir>/implementation-result.json and redacted adapter.log
14# Exit: 0 host-resolvable terminal result, 1 failed/schema-invalid, 2 unavailable
15#
16# Introspection (no model call):
17# cross-model-work.sh --emit-adapter <route>
18# cross-model-work.sh --check-model antigravity <model>
19# opens an Antigravity session for <model> without sending a prompt; exit 0
20# when the account offers it, exit 2 with the reason on stderr otherwise
22set -uo pipefail
23umask 077
25# Prefer the runner-exported interpreter (sys.executable via CE_PEER_PYTHON),
26# else probe execution — Windows Store's python3 stub satisfies `command -v`
27# then exits nonzero (see resolve-python convention / #1247).
28PY="${CE_PEER_PYTHON:-}"
29if [ -z "$PY" ]; then
30 PY="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c '' >/dev/null 2>&1 && { echo "$c"; break; }; done)"
31fi
32[ -n "$PY" ] || { echo "no working Python 3 interpreter on PATH" >&2; exit 1; }
34# Cursor's ACP server offers one preset per model and rejects effort variants,
35# so grok-cursor runs at high/fast and composer at its fast tier.
36M_GROK_CURSOR="grok-4.7[context=256k,reasoning_effort=high,fast=true]"
37M_COMPOSER="composer-2.5[fast=true]"
38M_ANTIGRAVITY="gemini-4-argon"
40log() { printf '[cross-model-work] %s\n' "$*" >&2; }
42route_target() {
43 case "$1" in
44 codex|claude|cursor|composer|antigravity) printf '%s' "$1" ;;
45 grok-cli|grok-cursor) printf 'grok' ;;
46 opencode) printf 'opencode' ;;
47 *) return 1 ;;
48 esac
49}
51route_harness() {
52 case "$1" in
53 codex) printf 'codex' ;;
54 claude) printf 'claude' ;;
55 grok-cli) printf 'grok' ;;
56 cursor|composer|grok-cursor) printf 'cursor-agent' ;;
57 opencode) printf 'opencode' ;;
58 antigravity) printf 'antigravity' ;;
59 *) return 1 ;;
60 esac
61}
63route_model() {
64 local route="$1" target override="${CE_WORK_MODEL_OVERRIDE:-}"
65 if [ -n "${MODEL_REQUESTED:-}" ]; then
66 printf '%s' "$MODEL_REQUESTED"
67 return
68 fi
69 target="$(route_target "$route")" || return 1
70 if [ -n "$override" ] && [ "${CE_WORK_MODEL_OVERRIDE_TARGET:-}" = "$target" ]; then
71 printf '%s' "$override"
72 return
73 fi
74 case "$route" in
75 codex|claude|grok-cli|cursor) printf 'auto' ;;
76 grok-cursor) printf '%s' "$M_GROK_CURSOR" ;;
77 composer) printf '%s' "$M_COMPOSER" ;;
78 opencode) printf 'auto' ;;
79 antigravity) printf '%s' "$M_ANTIGRAVITY" ;;
80 esac
81}
83route_available() {
84 case "$1" in
85 codex) command -v codex >/dev/null 2>&1 ;;
86 claude) command -v claude >/dev/null 2>&1 ;;
87 grok-cli) command -v grok >/dev/null 2>&1 ;;
88 cursor|composer|grok-cursor) command -v cursor-agent >/dev/null 2>&1 ;;
89 opencode) command -v opencode >/dev/null 2>&1 ;;
90 antigravity) command -v agy_acp_server.par >/dev/null 2>&1 ;;
91 *) return 1 ;;
92 esac
93}
95# --- acpx transport (keep byte-identical across migrated peer workers) -----
96# Every route reaches its agent through acpx, which speaks the Agent Client
97# Protocol and prints the JSON-RPC stream, so one parser covers every CLI.
98# The calling worker supplies route_available, route_model, and log.
99ACPX_VERSION="0.21.0"
100# 0.18.0 fixes approve-reads allowing ambiguous destructive permission requests.
101ACPX_MIN_VERSION="0.18.0"
102ACPX_UNAVAILABLE="" # pre-egress reason; ACPX_SCOPE says whether every route shares it
103ACPX_SCOPE=""
104ACPX_NPM_REFRESH=false
106acpx_package_spec() {
107 printf 'acpx@>=%s <=%s' "$ACPX_MIN_VERSION" "$ACPX_VERSION"
108}
110acpx_agent() { # <route> -> acpx built-in agent name
111 case "$1" in
112 codex) printf 'codex' ;;
113 claude) printf 'claude' ;;
114 grok-cli) printf 'grok-build' ;;
115 grok-cursor|cursor|composer) printf 'cursor' ;;
116 opencode) printf 'opencode' ;;
117 antigravity) printf 'antigravity' ;;
118 *) return 1 ;;
119 esac
120}
122# acpx launches Google's Antigravity ACP runtime by name. It is a separate
123# download from the agy CLI and needs its localharness_external helper from the
124# same release, beside it or named by ANTIGRAVITY_HARNESS_PATH.
125acpx_antigravity_helper() {
126 local runtime
127 if [ -n "${ANTIGRAVITY_HARNESS_PATH:-}" ]; then printf '%s' "$ANTIGRAVITY_HARNESS_PATH"; return 0; fi
128 runtime="$(command -v agy_acp_server.par 2>/dev/null)" || return 1
129 printf '%s/localharness_external' "$(dirname "$runtime")"
130}
132# The runtime reads skills and settings from GEMINI_HOME, so every run gets a
133# private one, deleted with the caller's scratch. A file-backed login (no macOS
134# keychain) is read only from there, so it is copied in; refreshes are not
135# written back, so concurrent peers cannot clobber the user's token.
136acpx_antigravity_home() { # <private-dir> -> GEMINI_HOME path
137 local home="$1/gemini-home"
138 local token="${GEMINI_HOME:-${HOME:-}/.gemini}/antigravity-acp/acp_token.json"
139 (umask 077; mkdir -p "$home/antigravity-acp") || return 1
140 printf '{"auth":{"type":"oauth-personal"}}\n' > "$home/antigravity-acp/settings.json" || return 1
141 if [ -f "$token" ]; then
142 (umask 077; cp "$token" "$home/antigravity-acp/acp_token.json") || return 1
143 fi
144 printf '%s' "$home"
145}
147acpx_unavailable() { # <shared|route> <reason>
148 ACPX_SCOPE="$1"; ACPX_UNAVAILABLE="$2"
149 return 1
150}
152# acpx lets ~/.acpx/config.json and <cwd>/.acpxrc.json replace a built-in
153# agent's launch command; a route that would run something else is skipped.
154# opencode launches the installed CLI by path, which no config entry overrides.
155acpx_config_guard() { # <route> <cwd>
156 local agent file
157 [ "$1" = opencode ] && return 0
158 agent="$(acpx_agent "$1")" || return 1
159 for file in "${HOME:-}/.acpx/config.json" "$2/.acpxrc.json"; do
160 [ -e "$file" ] || continue
161 if ! jq -e --arg a "$agent" '(.agents // {}) | keys | map(ascii_downcase) | index($a) == null' "$file" >/dev/null 2>&1; then
162 acpx_unavailable route "$file overrides or cannot be checked for the '$agent' agent launch"
163 return 1
164 fi
165 done
166}
168acpx_preflight() { # <route> <cwd>; nothing is sent to a provider here
169 local v major minor
170 ACPX_UNAVAILABLE=""; ACPX_SCOPE=""
171 command -v node >/dev/null 2>&1 || { acpx_unavailable shared "node not found; acpx needs Node 22.13 or newer"; return 1; }
172 v="$(node -p 'process.versions.node' 2>/dev/null)"
173 major="${v%%.*}"; minor="${v#*.}"; minor="${minor%%.*}"
174 case "$major$minor" in ''|*[!0-9]*) acpx_unavailable shared "cannot read the Node version; acpx needs Node 22.13 or newer"; return 1 ;; esac
175 if [ "$major" -lt 22 ] || { [ "$major" -eq 22 ] && [ "$minor" -lt 13 ]; }; then
176 acpx_unavailable shared "Node $v is too old; acpx needs Node 22.13 or newer"
177 return 1
178 fi
179 command -v npx >/dev/null 2>&1 || { acpx_unavailable shared "npx not found; acpx runs through npx"; return 1; }
180 if [ "$1" = antigravity ]; then
181 case "$(uname -s 2>/dev/null)" in
182 MINGW*|MSYS*|CYGWIN*) acpx_unavailable route "the Antigravity route is not yet supported on native Windows"; return 1 ;;
183 esac
184 route_available "$1" || { acpx_unavailable route "Antigravity's ACP runtime (agy_acp_server.par) is not on PATH; it is a separate download from the agy CLI (run ce-setup for the link)"; return 1; }
185 [ -x "$(acpx_antigravity_helper)" ] || { acpx_unavailable route "Antigravity's localharness_external helper is not beside agy_acp_server.par or at ANTIGRAVITY_HARNESS_PATH; install both from the same release (run ce-setup for the link)"; return 1; }
186 fi
187 route_available "$1" || { acpx_unavailable route "the agent CLI for route '$1' is not installed"; return 1; }
188 if [ "$1" = opencode ]; then
189 case "$(uname -s 2>/dev/null)" in
190 MINGW*|MSYS*|CYGWIN*) acpx_unavailable route "opencode launches through acpx's raw --agent command, which acpx rejects on native Windows"; return 1 ;;
191 esac
192 fi
193 acpx_config_guard "$1" "$2"
194}
196# Claude's ACP adapter always loads project settings, so the launch goes
197# through a wrapper that adds --safe-mode.
198acpx_claude_wrapper() { # <private-dir> -> wrapper path
199 local real wrapper="$1/claude-safe-mode"
200 real="$(command -v claude)" || return 1
201 printf '#!/bin/sh\nexec %s --safe-mode "$@"\n' "$(printf '%q' "$real")" > "$wrapper" && chmod 700 "$wrapper" || return 1
202 printf '%s' "$wrapper"
203}
205acpx_npm_failure_reason() {
206 if grep -qE 'No matching version found for .* with a date before ' "$1" 2>/dev/null; then
207 printf '%s' "npm could not resolve $(acpx_package_spec) or a dependency before its date cutoff; check min-release-age/before policy or wait for the cooldown"
208 else
209 grep -m1 '^npm error' "$1" 2>/dev/null
210 fi
211}
213# ETARGET is ambiguous: refresh metadata once while preserving npm's date policy.
214# npm ignores prefer_online while prefer_offline is set, so refresh disables it.
215acpx_npm_resolution_retryable() { # <stderr-log> <stream-log>
216 [ "$ACPX_NPM_REFRESH" = true ] && return 1
217 grep -qE 'ETARGET|notarget|No matching version found' "$1" "$2" 2>/dev/null
218}
220# The codex and claude adapters are pointed at the installed CLIs so the pin
221# bounds the transport version, not which models the agent can serve.
222acpx_base_argv() { # <route> <cwd> <mcp-config> <timeout-secs> <claude-wrapper> <gemini-home>
223 printf '%s\0' env
224 # An ambient Google API key or cloud project would bill it instead of the
225 # Antigravity subscription. env takes -u only before its first assignment.
226 case "$1" in
227 antigravity) printf '%s\0' -u GEMINI_API_KEY -u GOOGLE_API_KEY -u GOOGLE_APPLICATION_CREDENTIALS \
228 -u GOOGLE_CLOUD_PROJECT -u GOOGLE_CLOUD_LOCATION -u GOOGLE_GENAI_USE_VERTEXAI -u AGY_LLM_GATEWAY_URL ;;
229 esac
230 if [ "$ACPX_NPM_REFRESH" = true ]; then
231 printf '%s\0' npm_config_prefer_offline=false npm_config_fetch_retries=0 npm_config_prefer_online=true
232 else
233 printf '%s\0' npm_config_prefer_offline=true npm_config_fetch_retries=0
234 fi
235 case "$1" in
236 # codex-acp ignores app-server CLI flags.
237 codex) printf '%s\0' "CODEX_PATH=$(command -v codex)" 'CODEX_CONFIG={"features":{"plugins":false}}' ;;
238 claude) printf '%s\0' "CLAUDE_CODE_EXECUTABLE=$5" ;;
239 antigravity) printf '%s\0' "GEMINI_HOME=$6" "ANTIGRAVITY_HARNESS_PATH=$(acpx_antigravity_helper)" ;;
240 esac
241 # Explicit --package avoids npx reusing a global binary that violates npm age policy.
242 # npx also exports npm_config_package, which breaks the nested npx acpx uses
243 # to launch the codex adapter when npm is not beside node (exit 127).
244 printf '%s\0' npx -y --package "$(acpx_package_spec)" -- env -u npm_config_package acpx --cwd "$2" --format json --mcp-config "$3" --timeout "$4"
245}
247acpx_agent_argv() { # <route>
248 # The preflight refuses opencode when it is not installed; the bare name keeps
249 # --emit-adapter output readable on a machine without it.
250 if [ "$1" = opencode ]; then printf '%s\0' --agent "$(command -v opencode || printf opencode) acp" exec
251 else printf '%s\0' "$(acpx_agent "$1")" exec; fi
252}
254# The run's outcome is the result of its own session/prompt request, matched by
255# id because --model adds a session/set_model request before the prompt.
256acpx_outcome() { # <stream-log> -> end_turn | <other stopReason> | error | incomplete | not-sent
257 local id outcome
258 id="$(jq -R 'fromjson? | select(.method == "session/prompt") | .id' "$1" 2>/dev/null | tail -1)"
259 [ -n "$id" ] || { printf 'not-sent'; return 0; }
260 outcome="$(jq -rR --argjson id "$id" 'fromjson? | select(.method == null and .id == $id) | if .error then "error" else (.result.stopReason // "error") end' "$1" 2>/dev/null | tail -1)"
261 printf '%s' "${outcome:-incomplete}"
262}
264# Antigravity holds session/new open while it waits for a browser sign-in it
265# will never get, so a run without a usable login would otherwise sit out the
266# whole idle cap. Nothing has been sent while the session is pending.
267ACPX_SESSION_START_SECS="${CROSS_MODEL_SESSION_START_SECS:-60}"
268acpx_session_state() { # <stream-log> -> starting | pending | open
269 local id
270 jq -eR 'fromjson? | select(.result.protocolVersion? != null)' "$1" >/dev/null 2>&1 || { printf 'starting'; return 0; }
271 id="$(jq -R 'fromjson? | select(.method == "session/new") | .id' "$1" 2>/dev/null | tail -1)"
272 if [ -n "$id" ] && jq -eR --argjson id "$id" 'fromjson? | select(.method == null and .id == $id)' "$1" >/dev/null 2>&1; then
273 printf 'open'
274 else
275 printf 'pending'
276 fi
277}
279acpx_text() { # <stream-log> <outfile>: the agent's reply text
280 jq -jR 'fromjson? | select(.method == "session/update" and .params.update.sessionUpdate == "agent_message_chunk") | .params.update.content.text // empty' "$1" > "$2" 2>/dev/null
281}
283# Model ids the adapter reports in the prompt result's _meta. acpx documents
284# _meta as adapter-defined, so these are the adapter's assertion, not proof.
285acpx_served_models() { # <stream-log> -> "<model> <tokens>" lines
286 jq -rR 'fromjson? | select(.result.stopReason?) | .result._meta // {} |
287 ((.quota.model_usage // [])[] | "\(.model) \(.token_count.totalTokens // 0)"),
288 (.modelId // empty | "\(.) 0")' "$1" 2>/dev/null
289}
291# Bounded failure evidence from the stream. The stream echoes the outbound
292# prompt, so only error messages and the stop reason are read from it.
293acpx_failure_evidence() { # <stream-log>
294 local evidence
295 evidence="$(jq -rR 'fromjson? | (.error.message? // empty), (.result.stopReason? // empty | select(. != "end_turn") | "stopReason=" + .)' "$1" 2>/dev/null | tr '\n' ' ')"
296 evidence="${evidence% }"
297 [ "${#evidence}" -gt 300 ] && evidence="${evidence:0:147} ... ${evidence: -147}"
298 printf '%s' "$evidence"
299}
301# codex-acp streams a provider rejection (such as a model the account may not
302# use) as reply text and still ends the turn with end_turn, so only the reply
303# carries it. Bounded inside jq so a long reply never enters a bash string.
304acpx_reply_evidence() { # <stream-log>
305 jq -nrR '[inputs | fromjson? | select(.method == "session/update" and .params.update.sessionUpdate == "agent_message_chunk") | .params.update.content.text // empty]
306 | join("") | gsub("\\s+"; " ") | ltrimstr(" ") | rtrimstr(" ")
307 | if length > 300 then .[0:147] + " ... " + .[-147:] else . end' "$1" 2>/dev/null
308}
310acpx_advertised_models() { # <stream-log>
311 jq -nrR '[inputs | fromjson? | .result.models.availableModels[]?.modelId | sub("\\[.*$"; "")]
312 | reduce .[] as $m ([]; if any(.[]; . == $m) then . else . + [$m] end)
313 | join(", ") | if length > 300 then .[0:296] + " ..." else . end' "$1" 2>/dev/null
314}
316# Exit 5 is acpx's permission refusal. Paired with stopReason cancelled, the
317# refusal ended the turn. Exit 5 with end_turn means the turn finished. A
318# cancelled turn with any other exit is an interrupt.
319acpx_permission_cancel_reason() { # <stream-log> <exit-code>
320 [ "$(acpx_outcome "$1")" = cancelled ] && [ "$2" = 5 ] || return 0
321 printf '%s' "permission request cancelled the turn"
322}
324acpx_node_startup_line() { # <stderr-log>
325 local line=""
326 line="$(grep -m1 -E '^Error ' "$1" 2>/dev/null)"
327 [ -n "$line" ] || line="$(grep -m1 -E '^node:' "$1" 2>/dev/null)"
328 printf '%s' "$line"
329}
330# --- end acpx transport -----------------------------------------------------
332# Cursor routes take Cursor's ACP model ids, which may carry a bracketed preset
333# (grok-4.7[context=256k,reasoning_effort=high,fast=true]); the family rule
334# applies to the id before the bracket.
335CURSOR_MODEL_RE='^([A-Za-z0-9][A-Za-z0-9._:/-]*)(\[[A-Za-z0-9._=,:-]*\])?$'
337validate_model_override() {
338 local route="$1" override="${CE_WORK_MODEL_OVERRIDE:-}" override_target="${CE_WORK_MODEL_OVERRIDE_TARGET:-}" target base base_lower
339 [ -n "$override" ] || { [ -z "$override_target" ]; return; }
340 case "$override_target" in
341 codex|claude|grok|cursor|composer|opencode|antigravity) ;;
342 *) return 1 ;;
343 esac
344 target="$(route_target "$route")" || return 1
345 [ "$override_target" = "$target" ] || return 0
346 base="$override"
347 case "$route" in
348 cursor|composer|grok-cursor)
349 [[ "$override" =~ $CURSOR_MODEL_RE ]] || return 1
350 base="${BASH_REMATCH[1]}"
351 ;;
352 esac
353 if [ "$route" = cursor ]; then
354 base_lower="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')"
355 case "$base_lower" in composer|composer-*|grok|grok-*|cursor-grok-*) return 1 ;; esac
356 return 0
357 fi
358 case "$route:$base" in
359 codex:gpt-*|codex:o[0-9]*|claude:fable|claude:opus|claude:sonnet|claude:haiku|claude:claude-*|grok-cli:grok-*|grok-cursor:grok-*|grok-cursor:cursor-grok-*|composer:composer-*|opencode:*/*) ;;
360 antigravity:gemini-*) [[ "$base" =~ ^gemini-[A-Za-z0-9._-]+$ ]] || return 1 ;;
361 *) return 1 ;;
362 esac
363}
365# Accept an effort only where the route's ACP adapter exposes an effort option
366# and lists the value (claude effort, codex and grok reasoning_effort, checked
367# 2026-10-06 through acpx 0.19.4; antigravity thinking_level, checked
368# 2026-10-14; opencode 2.x effort, checked 2026-10-23). Codex and OpenCode
369# levels vary per model, and OpenCode 1.x has no effort option, so a listed
370# level can still be refused before the prompt is sent. Cursor fixes effort in
371# the model preset, so any effort there makes the route unavailable instead of
372# being dropped.
373validate_effort_override() {
374 local route="$1" effort="${EFFORT_REQUESTED:-}"
375 [ -n "$effort" ] || return 0
376 case "$route:$effort" in
377 claude:low|claude:medium|claude:high|claude:xhigh|claude:max) ;;
378 codex:low|codex:medium|codex:high|codex:xhigh|codex:max|codex:ultra) ;;
379 grok-cli:low|grok-cli:medium|grok-cli:high|grok-cli:xhigh) ;;
380 antigravity:low|antigravity:medium|antigravity:high) ;;
381 opencode:none|opencode:low|opencode:medium|opencode:high|opencode:xhigh|opencode:max) ;;
382 *) return 1 ;;
383 esac
384}
386# The worker runs in the prepared workspace with every permission request
387# approved. Each adapter runs in its write-capable mode (codex agent, claude
388# default, cursor agent, opencode build, antigravity auto_edit, since its
389# default mode waits on approvals that never come); none of them confines
390# writes to the workspace, which references/cross-model-execution.md records
391# per route. Codex, Claude, and Antigravity run at high effort and native Grok
392# at xhigh unless the authorization requests another level.
393adapter_argv() {
394 local route="$1" model
395 acpx_agent "$route" >/dev/null || return 1
396 model="$(route_model "$route")"
397 acpx_base_argv "$route" "$WORKSPACE" "$MCP_CONFIG" "$ACPX_TIMEOUT" "$CLAUDE_WRAPPER" "$GEMINI_HOME_DIR"
398 printf '%s\0' --approve-all
399 [ "$model" = auto ] || printf '%s\0' --model "$model"
400 acpx_agent_argv "$route"
401 case "$route" in
402 codex) printf '%s\0' --config-option mode=agent --config-option "reasoning_effort=${EFFORT_REQUESTED:-high}" ;;
403 # Claude otherwise starts in the user's default permission mode.
404 claude) printf '%s\0' --config-option mode=default --config-option "effort=${EFFORT_REQUESTED:-high}" ;;
405 grok-cli) printf '%s\0' --config-option "reasoning_effort=${EFFORT_REQUESTED:-xhigh}" ;;
406 cursor|composer|grok-cursor) printf '%s\0' --config-option mode=agent ;;
407 opencode) printf '%s\0' --config-option mode=build
408 [ -z "${EFFORT_REQUESTED:-}" ] || printf '%s\0' --config-option "effort=$EFFORT_REQUESTED" ;;
409 antigravity) printf '%s\0' --config-option mode=auto_edit --config-option "thinking_level=${EFFORT_REQUESTED:-high}" ;;
410 esac
411 printf '%s\0' --file "$PROMPT_FILE"
412}
414ACPX_TIMEOUT="${CE_PEER_HARD_SECS:-7200}"
415case "$ACPX_TIMEOUT" in ''|0|*[!0-9]*) ACPX_TIMEOUT=7200 ;; esac
417if [ "${1:-}" = "--emit-adapter" ]; then
418 WORKSPACE="<workspace>"
419 PROMPT_FILE="<prompt-file>"
420 MCP_CONFIG="<mcp-config>"
421 CLAUDE_WRAPPER="<claude-safe-mode-wrapper>"
422 GEMINI_HOME_DIR="<gemini-home>"
423 ROUTE="${2:-}"
424 EFFORT_REQUESTED="${CROSS_MODEL_EFFORT_OVERRIDE:-}"
425 validate_model_override "$ROUTE" || {
426 printf "model override '%s' not compatible with route '%s'\n" "${CE_WORK_MODEL_OVERRIDE:-}" "$ROUTE" >&2
427 exit 2
428 }
429 validate_effort_override "$ROUTE" || {
430 printf "effort override '%s' not compatible with route '%s'\n" "$EFFORT_REQUESTED" "$ROUTE" >&2
431 exit 2
432 }
433 adapter_argv "$ROUTE" >/dev/null 2>&1 || { printf "unknown route '%s'\n" "$ROUTE" >&2; exit 2; }
434 adapter_argv "$ROUTE" | tr '\0' ' '
435 printf '\n'
436 exit 0
437fi
439SIGN_IN_STALL_REASON="transport unavailable (pre-egress, route): Antigravity is waiting for a Google sign-in; the agy CLI's sign-in does not carry over, so sign the ACP runtime in once: put {\"auth\":{\"type\":\"oauth-personal\"}} in ${GEMINI_HOME:-~/.gemini}/antigravity-acp/settings.json, run npx -y --package '$(acpx_package_spec)' -- acpx antigravity exec ok in a terminal and finish the browser sign-in, then retry"
441# --check-model antigravity <model>: acpx checks --model against the models the
442# session advertises before any prompt, so opening a session answers whether the
443# account offers the model. acpx records the session under $HOME/.acpx; the
444# check deletes that record, and the directories when it created them. A scratch
445# HOME is not an option: the runtime then never opens the session.
446if [ "${1:-}" = "--check-model" ]; then
447 ROUTE="${2:-}"
448 CE_WORK_MODEL_OVERRIDE="${3:-}"
449 CE_WORK_MODEL_OVERRIDE_TARGET=antigravity
450 [ "$ROUTE" = antigravity ] || { printf "no model check for route '%s'\n" "$ROUTE" >&2; exit 2; }
451 validate_model_override antigravity && [ -n "$CE_WORK_MODEL_OVERRIDE" ] || {
452 printf "model '%s' not compatible with route 'antigravity'\n" "$CE_WORK_MODEL_OVERRIDE" >&2
453 exit 2
454 }
455 command -v jq >/dev/null 2>&1 || { echo "transport unavailable (pre-egress, shared): jq not found; the ACP stream is read with jq" >&2; exit 2; }
456 CHECK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ce-work-model-check-XXXXXX")" || exit 2
457 CHECK_PID=""
458 ACPX_STATE="${HOME:-}/.acpx"
459 ACPX_STATE_EXISTED=false; [ -d "$ACPX_STATE" ] && ACPX_STATE_EXISTED=true
460 # Every exit path stops the detached acpx group (TERM, then KILL) and removes
461 # what the check created.
462 check_cleanup() {
463 local i
464 # Probe the whole group, not the leader: acpx can exit and leave the
465 # runtime running in its group.
466 if [ -n "$CHECK_PID" ] && kill -0 -- -"$CHECK_PID" 2>/dev/null; then
467 kill -TERM -- -"$CHECK_PID" 2>/dev/null
468 for i in 1 2 3 4 5; do kill -0 -- -"$CHECK_PID" 2>/dev/null || break; sleep 1; done
469 kill -KILL -- -"$CHECK_PID" 2>/dev/null || true
470 fi
471 rm -rf "$CHECK_DIR"
472 [ "$ACPX_STATE_EXISTED" = true ] || { rmdir "$ACPX_STATE/sessions" "$ACPX_STATE" 2>/dev/null || true; }
473 }
474 trap check_cleanup EXIT
475 trap 'exit 130' INT TERM HUP QUIT
476 mkdir "$CHECK_DIR/cwd" || exit 2
477 acpx_preflight antigravity "$CHECK_DIR/cwd" || {
478 printf 'transport unavailable (pre-egress, %s): %s\n' "$ACPX_SCOPE" "$ACPX_UNAVAILABLE" >&2
479 exit 2
480 }
481 GEMINI_HOME_DIR="$(acpx_antigravity_home "$CHECK_DIR")" || { echo "cannot prepare a private Antigravity home" >&2; exit 2; }
482 printf '{"mcpServers":[]}\n' > "$CHECK_DIR/mcp.json"
483 open_check_session() {
484 ARGS=()
485 while IFS= read -r -d '' token; do ARGS+=("$token"); done < <(
486 acpx_base_argv antigravity "$CHECK_DIR/cwd" "$CHECK_DIR/mcp.json" "$ACPX_TIMEOUT" "" "$GEMINI_HOME_DIR")
487 ARGS+=(--model "$CE_WORK_MODEL_OVERRIDE" antigravity sessions new --name "ce-work-model-check-$$")
488 set -m
489 (cd "$CHECK_DIR" && exec "${ARGS[@]}" < /dev/null > "$CHECK_DIR/stdout" 2> "$CHECK_DIR/stderr") &
490 CHECK_PID=$!
491 set +m
492 CHECK_DEADLINE=$(( $(date +%s) + ACPX_SESSION_START_SECS ))
493 while kill -0 "$CHECK_PID" 2>/dev/null && [ "$(date +%s)" -lt "$CHECK_DEADLINE" ]; do sleep 1; done
494 if kill -0 "$CHECK_PID" 2>/dev/null; then
495 echo "$SIGN_IN_STALL_REASON" >&2
496 exit 2
497 fi
498 wait "$CHECK_PID"
499 CHECK_EXIT=$?
500 }
501 open_check_session
502 if [ "$CHECK_EXIT" -ne 0 ] && acpx_npm_resolution_retryable "$CHECK_DIR/stderr" "$CHECK_DIR/stdout"; then
503 log "npm could not resolve a package; retrying the model check once with fresh metadata (npm policy unchanged)"
504 ACPX_NPM_REFRESH=true
505 open_check_session
506 fi
507 RECORD_ID="$(jq -r 'select(.action? == "session_ensured") | .acpxRecordId // empty' "$CHECK_DIR/stdout" 2>/dev/null | tail -1)"
508 case "$RECORD_ID" in ''|*[!A-Za-z0-9-]*) ;; *) rm -f "$ACPX_STATE/sessions/$RECORD_ID.json" ;; esac
509 [ "$CHECK_EXIT" -eq 0 ] && exit 0
510 REASON="$(acpx_npm_failure_reason "$CHECK_DIR/stderr")"
511 if [ -n "$REASON" ]; then
512 printf 'transport unavailable (pre-egress, shared): %s\n' "${REASON:0:200}" >&2
513 else
514 REASON="$(acpx_npm_failure_reason "$CHECK_DIR/stdout")"
515 [ -n "$REASON" ] || REASON="$(acpx_failure_evidence "$CHECK_DIR/stdout")"
516 [ -n "$REASON" ] || REASON="$(grep -m1 -e 'did not advertise' -e 'Error' "$CHECK_DIR/stderr" 2>/dev/null)"
517 printf 'transport unavailable (pre-egress, route): %s\n' "${REASON:-acpx exited $CHECK_EXIT while opening a session}" >&2
518 fi
519 exit 2
520fi
522AUTHORIZATION="${1:-}"
523WORKSPACE="${2:-}"
524PACKET="${3:-}"
525EXPECTED_PACKET_DIGEST="${4:-}"
526RESULT_DIR="${5:-}"
527[[ "$EXPECTED_PACKET_DIGEST" =~ ^[0-9a-f]{64}$ ]] || { log "expected packet digest must be lowercase SHA-256"; exit 2; }
528[ -n "$AUTHORIZATION" ] || { log "controller authorization JSON path is required"; exit 2; }
529[ -d "$WORKSPACE" ] || { log "workspace '$WORKSPACE' is not a directory"; exit 2; }
530[ -f "$PACKET" ] && [ ! -L "$PACKET" ] || { log "unit packet '$PACKET' is not a regular non-link file"; exit 2; }
531[ -d "$RESULT_DIR" ] && [ ! -L "$RESULT_DIR" ] || { log "result dir '$RESULT_DIR' is not a directory"; exit 2; }
533DISPATCH_AUTHORIZATION="$AUTHORIZATION"
534DISPATCH_WORKSPACE="$WORKSPACE"
535DISPATCH_PACKET="$PACKET"
536DISPATCH_RESULT_DIR="$RESULT_DIR"
538MAX_PACKET_BYTES="${CE_WORK_MAX_PACKET_BYTES:-200000}"
539case "$MAX_PACKET_BYTES" in ''|*[!0-9]*) MAX_PACKET_BYTES=200000 ;; esac
541SKILL_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || exit 2
542PERSONA="$SKILL_ROOT/references/agents/implementation-worker.md"
543SCHEMA="$SKILL_ROOT/references/implementation-result-schema.json"
544[ -f "$PERSONA" ] && [ -f "$SCHEMA" ] || { log "worker persona or result schema missing"; exit 2; }
546SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/ce-work-adapter-XXXXXX")" || exit 2
547chmod 700 "$SCRATCH"
548PROMPT_FILE="$SCRATCH/prompt.md"
549# acpx prints the ACP stream on stdout; npm and adapter diagnostics go to stderr.
550RAW_STDOUT="$SCRATCH/stdout.log"
551RAW_STDERR="$SCRATCH/stderr.log"
552STREAM="$SCRATCH/stream.redacted"
553REPLY_TEXT="$SCRATCH/reply.txt"
554SERVED_MODELS="$SCRATCH/served-models"
555MCP_CONFIG="$SCRATCH/mcp.json"
556CLAUDE_WRAPPER=""
557GEMINI_HOME_DIR=""
558SESSION_STALL_MARKER="$SCRATCH/session-stalled"
559RAW_LIMIT_MARKER="$SCRATCH/raw-output-limit"
560PACKET_SNAPSHOT="$SCRATCH/unit-packet"
561AUTH_VALUES="$SCRATCH/authorization-values"
562RESULT_FILE="$RESULT_DIR/implementation-result.json"
563LOG_FILE="$RESULT_DIR/adapter.log"
564LOG_RETAINED=0
565trap 'rm -rf "$SCRATCH"' EXIT
567# The controller's create-exclusive authorization artifact is the production
568# dispatch capability. Read it once through a no-follow descriptor, validate
569# its exact route/model/packet contract, and derive every dispatch identity
570# field from those bytes before constructing a prompt or invoking a model CLI.
571"$PY" - "$AUTHORIZATION" "$EXPECTED_PACKET_DIGEST" "$AUTH_VALUES" <<'PY'
572import json, os, re, stat, sys
574source, expected_packet_digest, output = sys.argv[1:]
575required = {
576 "schema_version", "run_id", "unit_id", "attempt_id", "route", "target", "harness",
577 "intermediaries", "model_requested", "restriction_posture",
578 "restrictions", "activity_posture", "packet_digest",
579}
580contracts = {
581 "codex": ("codex", "codex", [], "cooperative"),
582 "claude": ("claude", "claude", [], "cooperative"),
583 "grok-cli": ("grok", "grok", [], "cooperative"),
584 "cursor": ("cursor", "cursor-agent", [], "cooperative"),
585 "composer": ("composer", "cursor-agent", ["cursor"], "cooperative"),
586 "grok-cursor": ("grok", "cursor-agent", ["cursor"], "cooperative"),
587 "opencode": ("opencode", "opencode", [], "cooperative"),
588 "antigravity": ("antigravity", "antigravity", [], "cooperative"),
589}
591def fail(message):
592 raise ValueError(message)
594def model_allowed(route, model):
595 if not isinstance(model, str) or not model or "\n" in model or "\r" in model:
596 return False
597 if route in ("cursor", "composer", "grok-cursor"):
598 match = re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9._:/-]*)(?:\[[A-Za-z0-9._=,:-]*\])?", model)
599 if not match:
600 return False
601 base = match.group(1)
602 if route == "composer":
603 return bool(re.fullmatch(r"composer-[A-Za-z0-9._-]+", base))
604 if route == "grok-cursor":
605 return bool(re.fullmatch(r"(?:cursor-)?grok-[A-Za-z0-9._-]+", base))
606 lowered = base.lower()
607 return not (lowered in {"composer", "grok"} or lowered.startswith(("composer-", "grok-", "cursor-grok-")))
608 if route == "codex":
609 return model == "auto" or bool(re.fullmatch(r"(?:gpt-[A-Za-z0-9._-]+|o[0-9][A-Za-z0-9._-]*)", model))
610 if route == "claude":
611 return model in {"auto", "fable", "opus", "sonnet", "haiku"} or bool(re.fullmatch(r"claude-[A-Za-z0-9._-]+", model))
612 if route == "grok-cli":
613 return model == "auto" or bool(re.fullmatch(r"grok-[A-Za-z0-9._-]+", model))
614 if route == "opencode":
615 return model == "auto" or bool(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9._-]+", model))
616 if route == "antigravity":
617 return bool(re.fullmatch(r"gemini-[A-Za-z0-9._-]+", model))
618 return False
620try:
621 flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
622 fd = os.open(os.path.abspath(source), flags)
623 try:
624 before = os.fstat(fd)
625 if not stat.S_ISREG(before.st_mode):
626 fail("authorization is not a regular file")
627 geteuid = getattr(os, "geteuid", None)
628 if geteuid is not None and before.st_uid != geteuid():
629 fail("authorization is not owned by the current user")
630 if stat.S_IMODE(before.st_mode) != 0o600:
631 fail("authorization mode is not 0600")
632 if before.st_size > 64 * 1024:
633 fail("authorization exceeds 65536 bytes")
634 chunks, total = [], 0
635 while True:
636 part = os.read(fd, min(65536, 65537 - total))
637 if not part:
638 break
639 chunks.append(part)
640 total += len(part)
641 if total > 65536:
642 fail("authorization grew past 65536 bytes")
643 after = os.fstat(fd)
644 if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (
645 after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns
646 ):
647 fail("authorization changed while being read")
648 finally:
649 os.close(fd)
650 try:
651 value = json.loads(b"".join(chunks))
652 except (ValueError, UnicodeDecodeError) as exc:
653 fail(f"authorization is malformed JSON: {exc}")
654 if not isinstance(value, dict) or set(value) - {"effort_requested"} != required:
655 fail("authorization keys do not match the exact controller schema")
656 if type(value["schema_version"]) is not int or value["schema_version"] != 1:
657 fail("authorization schema_version must be 1")
658 for key in ("run_id", "unit_id", "attempt_id"):
659 if not isinstance(value[key], str) or not re.fullmatch(r"[A-Za-z0-9._-]{1,128}", value[key]) or not value[key].strip("."):
660 fail(f"authorization {key} is unsafe")
661 route = value["route"]
662 if route not in contracts:
663 fail("authorization route is unsupported")
664 target, harness, intermediaries, posture = contracts[route]
665 if (value["target"], value["harness"], value["intermediaries"], value["restriction_posture"]) != (target, harness, intermediaries, posture):
666 fail("authorization route identity or restriction posture is inconsistent")
667 if value["activity_posture"] not in {"incremental", "hard-only"}:
668 fail("authorization activity_posture is invalid")
669 restrictions = value["restrictions"]
670 if not isinstance(restrictions, list) or not all(isinstance(item, str) for item in restrictions):
671 fail("authorization restrictions must be a string list")
672 if not model_allowed(route, value["model_requested"]):
673 fail("authorization model is incompatible with the fixed route")
674 effort = value.get("effort_requested", "")
675 if "effort_requested" in value and (not isinstance(effort, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,31}", effort)):
676 fail("authorization effort_requested is not a short plain token")
677 packet_digest = value["packet_digest"]
678 if not isinstance(packet_digest, str) or not re.fullmatch(r"[0-9a-f]{64}", packet_digest):
679 fail("authorization packet_digest is not lowercase SHA-256")
680 if packet_digest != expected_packet_digest:
681 fail("authorization packet digest does not match dispatch")
682 authorization_digest = __import__("hashlib").sha256(b"".join(chunks)).hexdigest()
683 fields = (
684 authorization_digest, value["run_id"], value["unit_id"], value["attempt_id"],
685 route, target, harness, value["model_requested"], value["activity_posture"], posture, effort,
686 )
687 out = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
688 try:
689 os.write(out, b"\0".join(item.encode() for item in fields) + b"\0")
690 finally:
691 os.close(out)
692except (OSError, ValueError) as exc:
693 print(f"controller authorization rejected: {exc}", file=sys.stderr)
694 raise SystemExit(2)
695PY
696AUTH_EXIT=$?
697[ "$AUTH_EXIT" -eq 0 ] || { log "controller authorization rejected"; exit 2; }
699AUTH_FIELDS=()
700while IFS= read -r -d '' field; do AUTH_FIELDS+=("$field"); done < "$AUTH_VALUES"
701[ "${#AUTH_FIELDS[@]}" -eq 11 ] || { log "controller authorization projection is incomplete"; exit 2; }
702OBSERVED_AUTH_DIGEST="${AUTH_FIELDS[0]}"
703RUN_ID="${AUTH_FIELDS[1]}"
704UNIT_ID="${AUTH_FIELDS[2]}"
705ATTEMPT_ID="${AUTH_FIELDS[3]}"
706ROUTE="${AUTH_FIELDS[4]}"
707AUTH_TARGET="${AUTH_FIELDS[5]}"
708AUTH_HARNESS="${AUTH_FIELDS[6]}"
709MODEL_REQUESTED="${AUTH_FIELDS[7]}"
710ACTIVITY_POSTURE="${AUTH_FIELDS[8]}"
711RESTRICTION_POSTURE="${AUTH_FIELDS[9]}"
712EFFORT_REQUESTED="${AUTH_FIELDS[10]}"
713RUNNER_JOB_ID="${CE_PEER_JOB_ID:-}"
714[[ "$RUNNER_JOB_ID" =~ ^[A-Za-z0-9._-]{1,128}$ && "$RUNNER_JOB_ID" =~ [A-Za-z0-9_-] ]] || {
715 log "runner job identity is missing or unsafe"
716 exit 2
717}
719# A valid JSON file is not itself dispatch authority. Prove the exact no-follow
720# snapshot and every raw controller-returned path back to the controller before
721# prompt construction. Only its AUTHORIZED status permits external egress.
722CONTROLLER="$SKILL_ROOT/scripts/unit-workspace.py"
723AUTH_RESPONSE="$("$PY" "$CONTROLLER" authorize-dispatch \
724 --authorization "$DISPATCH_AUTHORIZATION" \
725 --authorization-digest "$OBSERVED_AUTH_DIGEST" \
726 --workspace "$DISPATCH_WORKSPACE" \
727 --packet "$DISPATCH_PACKET" \
728 --packet-digest "$EXPECTED_PACKET_DIGEST" \
729 --result-dir "$DISPATCH_RESULT_DIR" \
730 --run-id "$RUN_ID" --unit-id "$UNIT_ID" --attempt-id "$ATTEMPT_ID" --job-id "$RUNNER_JOB_ID" 2>&1)"
731CONTROLLER_EXIT=$?
732AUTH_STATUS="${AUTH_RESPONSE%%$'\n'*}"
733if [ "$CONTROLLER_EXIT" -ne 0 ] || [ "$AUTH_STATUS" != "AUTHORIZED" ]; then
734 [ -n "$AUTH_RESPONSE" ] && printf '%s\n' "$AUTH_RESPONSE" >&2
735 log "controller dispatch authorization failed"
736 exit 2
737fi
739# Canonicalize operational paths only after the handshake. The controller
740# compares the raw paths it returned, including platform compatibility symlinks.
741WORKSPACE="$(cd "$WORKSPACE" && pwd -P)" || exit 2
742PACKET="$(cd "$(dirname "$PACKET")" && pwd -P)/$(basename "$PACKET")" || exit 2
743RESULT_DIR="$(cd "$RESULT_DIR" && pwd -P)" || exit 2
744case "$RESULT_DIR/" in "$WORKSPACE/"*) log "result dir must be outside the worker workspace"; exit 2 ;; esac
745case "$PACKET" in "$WORKSPACE"/*) log "unit packet must be outside the worker workspace"; exit 2 ;; esac
746git -C "$WORKSPACE" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { log "workspace is not a Git worktree"; exit 2; }
747chmod 700 "$RESULT_DIR" 2>/dev/null || { log "result dir could not be made private"; exit 2; }
748RESULT_DIR_IDENTITY="$("$PY" - "$RESULT_DIR" <<'PY'
749import os, stat, sys
751path = sys.argv[1]
752flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
753fd = os.open(path, flags)
754try:
755 info = os.fstat(fd)
756 if not stat.S_ISDIR(info.st_mode):
757 raise OSError("result dir is not a directory")
758 print(f"{info.st_dev}:{info.st_ino}")
759finally:
760 os.close(fd)
761PY
762)" || { log "result dir identity could not be captured"; exit 2; }
764write_adapter_log() {
765 "$PY" -c '
766import os, stat, sys
768path, expected = sys.argv[1:]
769dir_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
770try:
771 dir_fd = os.open(path, dir_flags)
772 try:
773 directory = os.fstat(dir_fd)
774 if not stat.S_ISDIR(directory.st_mode):
775 raise OSError("result dir is not a directory")
776 if f"{directory.st_dev}:{directory.st_ino}" != expected:
777 raise OSError("result dir identity changed during route")
778 file_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
779 log_fd = os.open("adapter.log", file_flags, 0o600, dir_fd=dir_fd)
780 try:
781 target = os.fstat(log_fd)
782 if not stat.S_ISREG(target.st_mode):
783 raise OSError("adapter log is not a regular file")
784 os.fchmod(log_fd, 0o600)
785 while True:
786 chunk = sys.stdin.buffer.read(65536)
787 if not chunk:
788 break
789 view = memoryview(chunk)
790 while view:
791 view = view[os.write(log_fd, view):]
792 finally:
793 os.close(log_fd)
794 finally:
795 os.close(dir_fd)
796except OSError as error:
797 print(f"adapter log retention refused: {error}", file=sys.stderr)
798 raise SystemExit(2)
799' "$RESULT_DIR" "$RESULT_DIR_IDENTITY"
800}
802write_result_receipt() {
803 "$PY" -c '
804import os, secrets, stat, sys
806path, expected = sys.argv[1:]
807dir_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
808dir_fd = None
809receipt_fd = None
810tmp_name = None
811try:
812 data = sys.stdin.buffer.read()
813 dir_fd = os.open(path, dir_flags)
814 directory = os.fstat(dir_fd)
815 if not stat.S_ISDIR(directory.st_mode):
816 raise OSError("result dir is not a directory")
817 if f"{directory.st_dev}:{directory.st_ino}" != expected:
818 raise OSError("result dir identity changed during route")
819 file_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
820 for _ in range(128):
821 candidate = f".result-{os.getpid()}-{secrets.token_hex(8)}"
822 try:
823 receipt_fd = os.open(candidate, file_flags, 0o600, dir_fd=dir_fd)
824 tmp_name = candidate
825 break
826 except FileExistsError:
827 continue
828 if receipt_fd is None:
829 raise OSError("could not reserve a result receipt temporary file")
830 target = os.fstat(receipt_fd)
831 if not stat.S_ISREG(target.st_mode):
832 raise OSError("result receipt temporary file is not regular")
833 os.fchmod(receipt_fd, 0o600)
834 view = memoryview(data)
835 while view:
836 view = view[os.write(receipt_fd, view):]
837 os.close(receipt_fd)
838 receipt_fd = None
839 os.replace(
840 tmp_name,
841 "implementation-result.json",
842 src_dir_fd=dir_fd,
843 dst_dir_fd=dir_fd,
844 )
845 tmp_name = None
846except OSError as error:
847 print(f"result receipt publication refused: {error}", file=sys.stderr)
848 raise SystemExit(2)
849finally:
850 if receipt_fd is not None:
851 os.close(receipt_fd)
852 if tmp_name is not None and dir_fd is not None:
853 try:
854 os.unlink(tmp_name, dir_fd=dir_fd)
855 except OSError:
856 pass
857 if dir_fd is not None:
858 os.close(dir_fd)
859' "$RESULT_DIR" "$RESULT_DIR_IDENTITY"
860}
862# Read the packet once through a no-follow descriptor, hash those exact bytes,
863# and build the prompt from the private snapshot. The controller-provided
864# digest is therefore bound to the content that actually crosses the route.
865OBSERVED_PACKET_DIGEST="$("$PY" - "$PACKET" "$PACKET_SNAPSHOT" "$MAX_PACKET_BYTES" <<'PY'
866import hashlib, os, stat, sys
868source, snapshot, raw_cap = sys.argv[1:]
869cap = int(raw_cap)
870flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
871fd = os.open(source, flags)
872try:
873 info = os.fstat(fd)
874 if not stat.S_ISREG(info.st_mode):
875 raise OSError("unit packet is not a regular file")
876 chunks, total = [], 0
877 while True:
878 chunk = os.read(fd, min(65536, cap + 1 - total))
879 if not chunk:
880 break
881 chunks.append(chunk)
882 total += len(chunk)
883 if total > cap:
884 raise OSError(f"unit packet exceeds {cap} bytes")
885finally:
886 os.close(fd)
887data = b"".join(chunks)
888out = os.open(snapshot, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
889try:
890 view = memoryview(data)
891 while view:
892 written = os.write(out, view)
893 view = view[written:]
894finally:
895 os.close(out)
896print(hashlib.sha256(data).hexdigest())
897PY
898)" || { log "unit packet could not be snapshotted safely"; exit 2; }
899[ "$OBSERVED_PACKET_DIGEST" = "$EXPECTED_PACKET_DIGEST" ] || {
900 log "unit packet digest mismatch (expected $EXPECTED_PACKET_DIGEST, observed $OBSERVED_PACKET_DIGEST)"
901 exit 2
902}
904redact_stream() {
905 CE_WORK_REDACT_FILE="${CE_WORK_REDACT_FILE:-}" "$PY" -c '
906import json, os, sys
908# The ACP stream is JSON, so a value is also matched in its escaped forms.
909def forms(value):
910 yield value
911 try:
912 text = value.decode("utf-8")
913 except UnicodeDecodeError:
914 return
915 yield json.dumps(text, ensure_ascii=False)[1:-1].encode("utf-8")
916 yield json.dumps(text)[1:-1].encode("ascii")
918p = os.environ.get("CE_WORK_REDACT_FILE", "")
919if p:
920 try:
921 values = sorted(
922 {form for v in open(p, "rb").read().splitlines() if v for form in forms(v)},
923 key=lambda value: (-len(value), value),
924 )
925 except OSError:
926 values = []
927else:
928 values = []
930def emit(data):
931 while data:
932 written = os.write(sys.stdout.fileno(), data)
933 data = data[written:]
935pending = b""
936max_value_bytes = max((len(value) for value in values), default=1)
937try:
938 if not values:
939 while True:
940 chunk = os.read(sys.stdin.fileno(), 65536)
941 if not chunk:
942 break
943 emit(chunk)
944 sys.exit(0)
946 while True:
947 chunk = os.read(sys.stdin.fileno(), 65536)
948 if not chunk:
949 break
950 pending += chunk
951 offset = 0
952 output = bytearray()
953 while len(pending) - offset >= max_value_bytes:
954 match = next((value for value in values if pending.startswith(value, offset)), None)
955 if match is not None:
956 output.extend(b"[REDACTED]")
957 offset += len(match)
958 else:
959 output.append(pending[offset])
960 offset += 1
961 emit(bytes(output))
962 pending = pending[offset:]
963 offset = 0
964 output = bytearray()
965 while offset < len(pending):
966 match = next((value for value in values if pending.startswith(value, offset)), None)
967 if match is not None:
968 output.extend(b"[REDACTED]")
969 offset += len(match)
970 else:
971 output.append(pending[offset])
972 offset += 1
973 emit(bytes(output))
974except BrokenPipeError:
975 os._exit(0)
976'
977}
979cap_stream() {
980 "$PY" -c '
981import os, sys
983remaining = int(sys.argv[1])
984while True:
985 chunk = os.read(sys.stdin.fileno(), 65536)
986 if not chunk:
987 break
988 if remaining:
989 retained = chunk[:remaining]
990 while retained:
991 written = os.write(sys.stdout.fileno(), retained)
992 retained = retained[written:]
993 remaining -= min(len(chunk), remaining)
994' "$MAX_RAW_BYTES"
995}
997{
998 cat "$PERSONA"
999 if [ "$ROUTE" = codex ]; then
1000 printf '\n\nSocket binds, OS permission checks, peer credentials, and similar capability probes are host-owned. Preserve the host command and observed result; do not treat a sandbox EPERM as proof the host lacks the capability.\n'
1001 fi
1002 printf '\n\nThe required final-result JSON schema is:\n\n'
1003 cat "$SCHEMA"
1004 printf '\n\n--- BOUNDED IMPLEMENTATION UNIT PACKET ---\n\n'
1005 redact_stream < "$PACKET_SNAPSHOT"
1006} > "$PROMPT_FILE"
1007chmod 600 "$PROMPT_FILE"
1009TARGET="$AUTH_TARGET"
1010HARNESS="$AUTH_HARNESS"
1012publish_unavailable() {
1013 local reason terminal_status="${2:-unavailable}" actual_route="${3:-}"
1014 # Failure evidence can quote the stream, so it is redacted like the log.
1015 reason="$(printf '%s' "$1" | redact_stream)"
1016 if [ "$LOG_RETAINED" -ne 1 ]; then
1017 printf '%s\n' "$reason" | write_adapter_log || {
1018 log "result dir or adapter log identity changed during route"
1019 exit 2
1020 }
1021 LOG_RETAINED=1
1022 fi
1023 "$PY" - "$ROUTE" "$TARGET" "$HARNESS" "$MODEL_REQUESTED" "$EXPECTED_PACKET_DIGEST" "$LOG_FILE" "$reason" "$ACTIVITY_POSTURE" "$RESTRICTION_POSTURE" "$terminal_status" "$actual_route" "$EFFORT_REQUESTED" <<'PY' | write_result_receipt
1024import json, sys
1025route, target, harness, requested, packet_digest, log, reason, activity, restriction, terminal_status, actual_route, effort = sys.argv[1:]
1026value = {
1027 "schema_version": 1, "terminal_status": terminal_status,
1028 "summary": "External route failed after launch" if terminal_status == "failed" else "External route unavailable",
1029 "changed_files": [], "evidence": [], "scope_expansion": None,
1030 "requested_route": route, "actual_route": actual_route or None, "target": target, "harness": harness,
1031 "intermediaries": ["cursor"] if route in ("composer", "grok-cursor") else [],
1032 "model_requested": requested, "model_actual": "unverified", "model_receipt_status": "unverified",
1033 "effort_requested": effort or None,
1034 "packet_digest": packet_digest,
1035 "activity_posture": activity, "restriction_posture": restriction,
1036 "failure_reason": reason, "raw_log": log,
1037}
1038json.dump(value, sys.stdout, indent=2)
1039sys.stdout.write("\n")
1040PY
1041}
1043if [ "${CE_WORK_REQUIRE_ENFORCED_CONFINEMENT:-}" = "1" ] && [ "$RESTRICTION_POSTURE" != adapter-enforced ]; then
1044 publish_unavailable "route offers cooperative workspace restriction, not required enforceable confinement" || exit 2
1045 exit 2
1046fi
1048validate_effort_override "$ROUTE" || {
1049 publish_unavailable "effort override '$EFFORT_REQUESTED' not compatible with route '$ROUTE'" || exit 2
1050 exit 2
1051}
1053# The Codex desktop app (Codex.app, or ChatGPT.app since the July 2026 merger)
1054# ships `codex` at Contents/Resources without linking it onto PATH (#1272).
1055# Append, never prepend, so a PATH-installed CLI stays authoritative.
1056# CROSS_MODEL_CODEX_APP_DIRS (colon-separated) overrides the probed dirs.
1057if ! command -v codex >/dev/null 2>&1; then
1058 OLDIFS="$IFS"; IFS=':'
1059 for d in ${CROSS_MODEL_CODEX_APP_DIRS-"${HOME:-}/Applications/ChatGPT.app/Contents/Resources:/Applications/ChatGPT.app/Contents/Resources:${HOME:-}/Applications/Codex.app/Contents/Resources:/Applications/Codex.app/Contents/Resources"}; do
1060 if [ -n "$d" ] && [ -x "$d/codex" ]; then PATH="${PATH:+$PATH:}$d"; export PATH; break; fi
1061 done
1062 IFS="$OLDIFS"
1063fi
1065# Nothing is sent to a provider before this point.
1066if ! command -v jq >/dev/null 2>&1; then
1067 publish_unavailable "transport unavailable (pre-egress, shared): jq not found; the ACP stream is read with jq" || exit 2
1068 exit 2
1069fi
1070acpx_preflight "$ROUTE" "$WORKSPACE" || {
1071 publish_unavailable "transport unavailable (pre-egress, $ACPX_SCOPE): $ACPX_UNAVAILABLE" || exit 2
1072 exit 2
1073}
1074if [ "$ROUTE" = claude ]; then
1075 CLAUDE_WRAPPER="$(acpx_claude_wrapper "$SCRATCH")" || {
1076 publish_unavailable "transport unavailable (pre-egress, route): cannot prepare the Claude --safe-mode launcher" || exit 2
1077 exit 2
1078 }
1079fi
1080if [ "$ROUTE" = antigravity ]; then
1081 GEMINI_HOME_DIR="$(acpx_antigravity_home "$SCRATCH")" || {
1082 publish_unavailable "transport unavailable (pre-egress, route): cannot prepare a private Antigravity home" || exit 2
1083 exit 2
1084 }
1085fi
1086printf '{"mcpServers":[]}\n' > "$MCP_CONFIG"
1088ARGS=()
1089while IFS= read -r -d '' token; do ARGS+=("$token"); done < <(adapter_argv "$ROUTE")
1091MIN_ENV=(env -i "PATH=$PATH" "PYTHONDONTWRITEBYTECODE=1")
1092[ -n "${HOME:-}" ] && MIN_ENV+=("HOME=$HOME")
1093[ -n "${USER:-}" ] && MIN_ENV+=("USER=$USER")
1094[ -n "${TMPDIR:-}" ] && MIN_ENV+=("TMPDIR=$TMPDIR")
1095[ -n "${LANG:-}" ] && MIN_ENV+=("LANG=$LANG")
1096[ -n "${LC_ALL:-}" ] && MIN_ENV+=("LC_ALL=$LC_ALL")
1097[ -n "${XDG_CONFIG_HOME:-}" ] && MIN_ENV+=("XDG_CONFIG_HOME=$XDG_CONFIG_HOME")
1098# npm reads its config file for authentication.
1099for name in npm_config_cache NPM_CONFIG_CACHE npm_config_registry NPM_CONFIG_REGISTRY npm_config_userconfig NPM_CONFIG_USERCONFIG \
1100 npm_config_min_release_age NPM_CONFIG_MIN_RELEASE_AGE npm_config_before NPM_CONFIG_BEFORE \
1101 npm_config_min_release_age_exclude NPM_CONFIG_MIN_RELEASE_AGE_EXCLUDE; do
1102 [ -n "${!name:-}" ] && MIN_ENV+=("$name=${!name}")
1103done
1104# Preserve route-specific config-directory pointers so existing CLI-native login
1105# remains reachable. Credential-bearing API-key variables are intentionally not
1106# forwarded; the worker gets paths to the CLI's own auth store, not secrets.
1107case "$ROUTE" in
1108 codex) [ -n "${CODEX_HOME:-}" ] && MIN_ENV+=("CODEX_HOME=$CODEX_HOME") ;;
1109 claude) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && MIN_ENV+=("CLAUDE_CONFIG_DIR=$CLAUDE_CONFIG_DIR") ;;
1110 grok-cli) [ -n "${GROK_CONFIG_HOME:-}" ] && MIN_ENV+=("GROK_CONFIG_HOME=$GROK_CONFIG_HOME") ;;
1111 opencode)
1112 [ -n "${OPENCODE_CONFIG_DIR:-}" ] && MIN_ENV+=("OPENCODE_CONFIG_DIR=$OPENCODE_CONFIG_DIR")
1113 [ -n "${OPENCODE_CONFIG:-}" ] && MIN_ENV+=("OPENCODE_CONFIG=$OPENCODE_CONFIG")
1114 ;;
1115 cursor|composer|grok-cursor)
1116 [ -n "${CURSOR_CONFIG_DIR:-}" ] && MIN_ENV+=("CURSOR_CONFIG_DIR=$CURSOR_CONFIG_DIR")
1117 ;;
1118esac
1120ACTIVITY_POLL_SECS="${CE_WORK_ACTIVITY_POLL_SECS:-15}"
1121case "$ACTIVITY_POLL_SECS" in ''|*[!0-9]*) ACTIVITY_POLL_SECS=15 ;; esac
1122[ "$ACTIVITY_POLL_SECS" -lt 1 ] && ACTIVITY_POLL_SECS=1
1123MAX_RAW_BYTES="${CE_WORK_MAX_RAW_BYTES:-10485760}"
1124case "$MAX_RAW_BYTES" in ''|*[!0-9]*) MAX_RAW_BYTES=10485760 ;; esac
1125[ "$MAX_RAW_BYTES" -lt 1 ] && MAX_RAW_BYTES=10485760
1127raw_byte_count() {
1128 local total=0 bytes file
1129 for file in "$RAW_STDOUT" "$RAW_STDERR"; do
1130 [ -f "$file" ] || continue
1131 bytes="$(wc -c < "$file" | tr -d '[:space:]')"
1132 case "$bytes" in ''|*[!0-9]*) bytes=0 ;; esac
1133 total=$((total + bytes))
1134 done
1135 printf '%s' "$total"
1136}
1138ACTIVE_ROUTE_PID=""
1139ACTIVITY_PID=""
1140# The route runs in its own process group (set -m below). npm launches acpx through
1141# `sh -c`, and a shell that does not exec its command (Ubuntu's dash) would stop a
1142# leader-only TERM short of acpx and the agent, so signal the whole group.
1143stop_route() {
1144 kill -TERM -- -"$1" 2>/dev/null || kill -TERM "$1" 2>/dev/null || true
1145}
1146terminate_route() {
1147 [ -n "$ACTIVITY_PID" ] && kill "$ACTIVITY_PID" 2>/dev/null || true
1148 [ -n "$ACTIVE_ROUTE_PID" ] && stop_route "$ACTIVE_ROUTE_PID"
1149 [ -n "$ACTIVE_ROUTE_PID" ] && wait "$ACTIVE_ROUTE_PID" 2>/dev/null || true
1150 rm -rf "$SCRATCH"
1151 exit 143
1152}
1153trap 'terminate_route' TERM INT
1155set +e
1156# npx resolves packages from its working directory's node_modules and .npmrc first;
1157# acpx gets the agent's cwd from --cwd, so start it from private scratch.
1158launch_route() {
1159 set -m
1160 (cd "$SCRATCH" && exec "${MIN_ENV[@]}" "${ARGS[@]}" < /dev/null > "$RAW_STDOUT" 2> "$RAW_STDERR") &
1161 ACTIVE_ROUTE_PID=$!
1162 set +m
1163 (
1164 # A foreground sleep would outlive this subshell's TERM and hold the script's
1165 # output open, so callers (bun 1.4 spawnSync) would wait out the poll interval.
1166 # Kill every background job, so a TERM between `sleep &` and `$!` still reaps it.
1167 trap 'kill $(jobs -p) 2>/dev/null; exit 0' TERM
1168 previous=0
1169 session=open
1170 [ "$ROUTE" = antigravity ] && session=starting
1171 started="$(date +%s)"
1172 while kill -0 "$ACTIVE_ROUTE_PID" 2>/dev/null; do
1173 current="$(raw_byte_count)"
1174 if [ "$current" -gt "$MAX_RAW_BYTES" ]; then
1175 : > "$RAW_LIMIT_MARKER"
1176 log "activity route=$ROUTE raw-output-limit bytes=$current cap=$MAX_RAW_BYTES"
1177 stop_route "$ACTIVE_ROUTE_PID"
1178 break
1179 fi
1180 if [ "$session" != open ] && [ $(( $(date +%s) - started )) -ge "$ACPX_SESSION_START_SECS" ]; then
1181 session="$(acpx_session_state "$RAW_STDOUT")"
1182 if [ "$session" = pending ]; then
1183 : > "$SESSION_STALL_MARKER"
1184 log "Antigravity opened no session within ${ACPX_SESSION_START_SECS}s; stopping the route"
1185 stop_route "$ACTIVE_ROUTE_PID"
1186 break
1187 fi
1188 fi
1189 if [ "$current" != "$previous" ]; then
1190 log "activity route=$ROUTE output-updated"
1191 previous="$current"
1192 fi
1193 sleep "$ACTIVITY_POLL_SECS" &
1194 wait $!
1195 done
1196 ) &
1197 ACTIVITY_PID=$!
1198 wait "$ACTIVE_ROUTE_PID"
1199 ROUTE_EXIT=$?
1200 kill "$ACTIVITY_PID" 2>/dev/null || true
1201 wait "$ACTIVITY_PID" 2>/dev/null || true
1202 ACTIVE_ROUTE_PID=""
1203 ACTIVITY_PID=""
1204}
1205LAUNCH_STARTED="$(date +%s)"
1206launch_route
1207if acpx_npm_resolution_retryable "$RAW_STDERR" "$RAW_STDOUT" && [ "$(acpx_outcome "$RAW_STDOUT")" = not-sent ]; then
1208 REMAINING=$(( ACPX_TIMEOUT - ( $(date +%s) - LAUNCH_STARTED ) ))
1209 if [ "$REMAINING" -gt 0 ]; then
1210 log "npm could not resolve a package; retrying the same route once with fresh metadata in the ${REMAINING}s left (nothing was sent; npm policy unchanged)"
1211 ACPX_NPM_REFRESH=true
1212 ACPX_TIMEOUT="$REMAINING"
1213 ARGS=()
1214 while IFS= read -r -d '' token; do ARGS+=("$token"); done < <(adapter_argv "$ROUTE")
1215 launch_route
1216 fi
1217fi
1218RAW_BYTES="$(raw_byte_count)"
1219[ "$RAW_BYTES" -gt "$MAX_RAW_BYTES" ] && : > "$RAW_LIMIT_MARKER"
1220# acpx echoes the outbound prompt and the contents of files the agent reads, so
1221# everything retained or published comes from the redacted copy. The protocol is
1222# parsed from the private raw stream: a redaction value can collide with ACP text.
1223redact_stream < "$RAW_STDOUT" > "$STREAM"
1224{
1225 cat "$STREAM"
1226 redact_stream < "$RAW_STDERR"
1227} | cap_stream | write_adapter_log || {
1228 log "result dir or adapter log identity changed during route"
1229 exit 2
1230}
1231LOG_RETAINED=1
1233if [ -f "$RAW_LIMIT_MARKER" ]; then
1234 publish_unavailable "fixed route raw output exceeded ${MAX_RAW_BYTES} bytes" || exit 2
1235 exit 1
1236fi
1238# The run's outcome is its own prompt's result, not acpx's exit code.
1239OUTCOME="$(acpx_outcome "$RAW_STDOUT")"
1240case "$OUTCOME" in
1241 end_turn) ;;
1242 not-sent)
1243 # Nothing reached the provider: npm could not fetch acpx (every route fails
1244 # alike), or the adapter refused before the prompt (this route only).
1245 REASON="$(acpx_npm_failure_reason "$RAW_STDERR")"
1246 if [ -n "$REASON" ]; then
1247 REASON="transport unavailable (pre-egress, shared): ${REASON:0:200}"
1248 elif [ -f "$SESSION_STALL_MARKER" ]; then
1249 REASON="$SIGN_IN_STALL_REASON"
1250 else
1251 node_line="$(acpx_node_startup_line "$RAW_STDERR")"
1252 if [ -n "$node_line" ]; then
1253 REASON="transport unavailable (pre-egress, route): ${node_line:0:200}"
1254 else
1255 REASON="$(acpx_npm_failure_reason "$RAW_STDOUT")"
1256 [ -n "$REASON" ] || REASON="$(acpx_failure_evidence "$STREAM")"
1257 REASON="transport unavailable (pre-egress, route): ${REASON:-acpx exited $ROUTE_EXIT before sending the prompt}"
1258 fi
1259 fi
1260 publish_unavailable "$REASON" || exit 2
1261 exit 2
1262 ;;
1263 *)
1264 REASON="$(acpx_permission_cancel_reason "$RAW_STDOUT" "$ROUTE_EXIT")"
1265 [ -n "$REASON" ] || REASON="$(acpx_failure_evidence "$STREAM")"
1266 publish_unavailable "fixed route ended with $OUTCOME (acpx exit $ROUTE_EXIT)${REASON:+: $REASON}" failed "$ROUTE" || exit 2
1267 exit 1
1268 ;;
1269esac
1271# The reply and served models are redacted with the rest of the receipt below.
1272acpx_text "$RAW_STDOUT" "$REPLY_TEXT"
1273acpx_served_models "$RAW_STDOUT" > "$SERVED_MODELS"
1274set +e
1275CE_WORK_REDACT_FILE="${CE_WORK_REDACT_FILE:-}" "$PY" - \
1276 "$REPLY_TEXT" "$SERVED_MODELS" "$ROUTE" "$TARGET" "$HARNESS" \
1277 "$MODEL_REQUESTED" "$EXPECTED_PACKET_DIGEST" "$LOG_FILE" "$ACTIVITY_POSTURE" "$RESTRICTION_POSTURE" "$EFFORT_REQUESTED" \
1278 "$(acpx_reply_evidence "$RAW_STDOUT")" "$(acpx_advertised_models "$RAW_STDOUT")" <<'PY' | write_result_receipt
1279import json, os, re, sys
1280reply, served_models, route, target, harness, requested, packet_digest, log, activity, restriction, effort, reply_evidence, advertised = sys.argv[1:]
1282def redactions():
1283 p=os.environ.get("CE_WORK_REDACT_FILE", "")
1284 if not p: return []
1285 try: return sorted(set(v for v in open(p, encoding="utf-8").read().splitlines() if v), key=lambda value: (-len(value), value))
1286 except OSError: return []
1288redaction_values=redactions()
1290def redact(value):
1291 if isinstance(value,str):
1292 for secret in redaction_values: value=value.replace(secret, "[REDACTED]")
1293 return value
1294 if isinstance(value,list): return [redact(child) for child in value]
1295 if isinstance(value,dict): return {key:redact(child) for key,child in value.items()}
1296 return value
1298def parse_text(text):
1299 found=[]
1300 decoder=json.JSONDecoder()
1301 def inspect(value):
1302 if isinstance(value,dict):
1303 if all(k in value for k in ("terminal_status","summary","changed_files","evidence","scope_expansion")):
1304 found.append(value)
1305 for child in value.values(): inspect(child)
1306 elif isinstance(value,list):
1307 for child in value: inspect(child)
1308 elif isinstance(value,str):
1309 inner=re.sub(r"^```(?:json)?\s*|\s*```$", "", value.strip(), flags=re.S)
1310 for i,ch in enumerate(inner):
1311 if ch not in "[{": continue
1312 try:
1313 child,_=decoder.raw_decode(inner,i); inspect(child)
1314 except Exception: pass
1315 inspect(text)
1316 for line in text.splitlines():
1317 try: inspect(json.loads(line))
1318 except Exception: pass
1319 return found[-1] if found else None
1321def normalize_served_model(value):
1322 # Strip terminal control sequences before validating the receipt token;
1323 # never publish a partially sanitized or otherwise unsafe identity.
1324 text=str(value)
1325 text=re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", text)
1326 text=re.sub(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)", "", text)
1327 text="".join(ch for ch in text if ord(ch) >= 0x20 and ord(ch) != 0x7f).strip()
1328 return text if 0 < len(text) <= 128 else "unverified"
1330try: text=open(reply, encoding="utf-8", errors="replace").read()
1331except OSError: text=""
1332worker=parse_text(text)
1333valid=isinstance(worker,dict)
1334worker_fields=("terminal_status", "summary", "changed_files", "evidence", "scope_expansion")
1335if valid:
1336 valid=(set(worker) == set(worker_fields)
1337 and worker.get("terminal_status") in ("completed","blocked","scope_expansion")
1338 and isinstance(worker.get("summary"),str) and bool(worker["summary"])
1339 and isinstance(worker.get("changed_files"),list) and all(isinstance(x,str) and x for x in worker["changed_files"])
1340 and isinstance(worker.get("evidence"),list) and all(isinstance(x,str) and x for x in worker["evidence"])
1341 and ((worker["terminal_status"]=="scope_expansion" and isinstance(worker.get("scope_expansion"),dict))
1342 or (worker["terminal_status"]!="scope_expansion" and worker.get("scope_expansion") is None)))
1344# The adapter's _meta report is its own assertion of the served model, so a
1345# match is recorded as "asserted", never "verified". Requested and served ids
1346# are compared on the model id before any Cursor preset bracket; an alias
1347# names its Claude family. An auxiliary model (Claude's Haiku) may also be
1348# reported, so a served id in the requested family wins, else the heaviest.
1349served=[]
1350try:
1351 for line in open(served_models, encoding="utf-8", errors="replace").read().splitlines():
1352 model, _, tokens = line.rpartition(" ")
1353 model=normalize_served_model(model)
1354 if model != "unverified":
1355 served.append((model, int(tokens) if tokens.isdigit() else 0))
1356except OSError:
1357 pass
1358model_base=lambda value: value.split("[",1)[0].lower()
1359family=model_base(requested)
1360if family in ("fable","opus","sonnet","haiku"): family="claude-"+family
1361in_family=[m for m,_ in served if model_base(m)==family or model_base(m).startswith(family+"-")]
1362if not served: actual, receipt = "unverified", "unverified"
1363elif requested == "auto": actual, receipt = max(served, key=lambda item: item[1])[0], "asserted"
1364elif in_family: actual, receipt = in_family[0], "asserted"
1365else: actual, receipt = max(served, key=lambda item: item[1])[0], "mismatch"
1367intermediaries=["cursor"] if route in ("composer","grok-cursor") else []
1368base_receipt={
1369 "schema_version":1,
1370 "requested_route":route, "actual_route":route, "target":target, "harness":harness,
1371 "intermediaries":intermediaries, "model_requested":requested, "model_actual":actual,
1372 "model_receipt_status":receipt, "effort_requested":effort or None, "activity_posture":activity,
1373 "packet_digest":packet_digest,
1374 "restriction_posture":restriction,
1375 "failure_reason":None, "raw_log":log,
1376}
1377if valid:
1378 base_receipt.update({key:worker[key] for key in worker_fields})
1379else:
1380 base_receipt.update({"terminal_status":"failed", "summary":"Adapter terminal output failed result schema",
1381 "changed_files":[], "evidence":[], "scope_expansion":None,
1382 "failure_reason":"; ".join(["terminal output failed implementation result schema"]
1383 + ([f"reply: {reply_evidence}"] if reply_evidence else [])
1384 + ([f"advertised models: {advertised}"] if advertised else []))})
1385base_receipt=redact(base_receipt)
1386json.dump(base_receipt,sys.stdout,indent=2)
1387sys.stdout.write("\n")
1388sys.exit(0 if valid else 4)
1389PY
1390NORMALIZE_STATUSES=("${PIPESTATUS[@]}")
1391NORMALIZE_EXIT="${NORMALIZE_STATUSES[0]}"
1392PUBLISH_EXIT="${NORMALIZE_STATUSES[1]}"
1393if [ "$PUBLISH_EXIT" -ne 0 ]; then exit 2; fi
1394if [ "$NORMALIZE_EXIT" -ne 0 ]; then exit 1; fi
1396TERMINAL_STATUS="$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["terminal_status"])' "$RESULT_FILE")"
1397case "$TERMINAL_STATUS" in
1398 completed|blocked|scope_expansion) exit 0 ;;
1399 *) exit 1 ;;
1400esac