Skip to content

Add a parser and request builder for the sandbox step CLI - #279

Merged
jameswnl merged 6 commits into
lightspeed-core:mainfrom
jameswnl:exec-step-protocol
Nov 1, 2026
Merged

jameswnl merged 6 commits into
lightspeed-core:mainfrom
jameswnl:exec-step-protocol

Conversation

@jameswnl

@jameswnl jameswnl commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

First piece of running sandbox steps through OpenShell exec instead of an exposed HTTP server. This adds two modules that are not wired into anything yet:

  • spawner/exec_request.py builds the JSON request the sandbox step CLI (python -m lightspeed_agentic.step) reads on stdin.
  • spawner/step_protocol.py reads the CLI's stdout/stderr and maps its exit code and result to the runner's existing step outcomes.

The CLI contract is the sandbox's .ai/spec/what/step-cli.md. The CLI runs as the same user as the agent's tools, so its output is treated as untrusted.

Request builder

  • Fields match the CLI's StepRequest: query, context, systemPrompt, outputSchema, timeout_ms, stepId, runDir, traceparent. Unknown keys are rejected by the CLI, so allowedTools/deniedTools are never sent (a warning is logged if present) and tracestate is dropped.
  • stepId is derived from the step name: a slug plus a hash of workflow id, step name and attempt, always matching ^[a-z0-9-]{1,40}$.
  • Requests are capped at 1 MiB minus 64 KiB. check_exec_message_size(stdin, env, argv) checks the whole exec message against the gateway's 1 MiB limit, for the transport to call.
  • A step whose MCP config has plain headers values is refused, because exec mode has no safe way to pass them yet. The error names the server, never the values.

Outcome rules

  • A step is completed only from a valid result line on stdout.
  • Exit 0 without a valid result line is a protocol violation; result.json is not used as a fallback, because a process started by the agent can replace it.
  • Exit 4 or a lost stream: result.json is read only for a fixed-vocabulary diagnostic (status code, success flag, presence flags for a fixed list of fields, byte size). No text from the file is carried. The step fails, is not retried, and the sandbox is marked unsafe to reuse.
  • Exit 2, 1 and 3 fail without retry. A 502 result stays transient, as on the HTTP path; other errors (deadline, kill, unexpected exit) are not retried.
  • Reading is bounded: 256 KiB per event line, 4 MiB for the result line, 64 MiB of stdout, JSON nesting depth 64, a 64 KiB allowance for output before the first protocol line. The stderr tail is redacted before it is shortened.

Testing

  • Tests written first, including adversarial input (huge lines, NaN, extra keys, two result lines, partial lines, invalid UTF-8, forged result files, secrets crossing truncation boundaries).
  • Golden fixtures under tests/unit/spawner/fixtures/step_cli/ were captured from the real step CLI (sandbox commit bca5ad01) using its own fake providers, with requests built by this builder. generate.py and the README describe how; expected outcomes are written by hand from the spec.
  • Unit suite: 2547 passed, 5 skipped. black and ruff clean.

Not in this PR

The exec transport, the spawner changes and the runner wiring come in later PRs. Nothing here changes runtime behaviour.

🤖 Generated with Claude Code

jameswnl and others added 6 commits November 1, 2026 01:00
Pure, not-yet-wired modules for running sandbox steps over exec through
the step CLI (python3 -P -m lightspeed_agentic.step), per the sandbox's
step-cli.md contract:

- spawner/step_protocol.py: incremental stdout JSONL reader with strict,
  bounded validation of event and result records (no NaN/Infinity,
  duplicate or extra keys, deep nesting; line, total and transcript
  caps), pid from the exec wrapper's first stderr line, a redacted
  stderr tail, and mapping of exit codes 0/1/2/3/4/124/signals plus
  result.json to step outcomes.
- spawner/exec_request.py: builds the StepRequest stdin line with the
  same fields as /run, a derived stepId, a fresh runDir and traceparent.
  Omits allowedTools/deniedTools with a warning, rejects MCP servers
  with plain headers, and caps the request size.

Golden fixtures were captured from the real step CLI at sandbox
bca5ad0 using its fake providers; see fixtures/step_cli/README.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Address review of the step protocol modules:

- Exit 0 without a valid result line is now a protocol violation. Exit
  0 promises delivery on the CLI's private fd, and result.json can be
  replaced by a setsid'd agent descendant, so it is no longer a
  fallback there.
- Results read from result.json (exit 4, transport loss) always taint
  the lease. After transport loss a file success is never trusted: the
  step raises so run_with_retries reruns it in a fresh sandbox. An
  invalid file after exit 4 is a protocol violation.
- Exit 3 is a non-retryable failure that leaves the circuit breaker
  alone, since it is deterministic or agent-inducible.
- The MCP plain-header check rejects any non-empty headers dict,
  including an empty header name.
- The stderr tail is redacted on a wider window before truncation, so
  a secret cut at the window edge can't leak a fragment.
- Document the stderr pid as best effort, and add
  check_exec_message_size() so the exec caller can check stdin, env
  and argv against the gateway's 1 MiB message limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
completed now comes only from a valid stdout result line. After exit 4
or a lost stream, result.json is replaceable by a same-uid descendant,
so a valid file no longer decides the outcome. The step fails with
reason result_unverified: no retry, no breaker, and the lease is
tainted. The file's status and error summary go into a new, bounded
diagnostic field. That field is kept out of error, because
run_with_retries classifies error text and forged file text could
otherwise make the step look transient.

Align the docs with retry behaviour: error outcomes raise, but
is_transient_failure only matches the 502 text, so lost stream, exit
124, killed and unexpected exits fail without a rerun. Tests pin that
classification.

Also add notes for the exec caller (run the CLI with python3 -I, and
never reuse a tainted lease) and document the stderr redaction limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The diagnostic for an untrusted result.json held up to 200 chars of
its error or summary text, truncated before the caller could redact
it. A secret straddling the cut, or longer than the limit, left a
fragment that redaction could not catch.

The diagnostic now uses fixed wording and validated values only: the
status code, the success flag on 200, has_<key> flags for a hardcoded
key list, a count of other keys and the byte size. No string from the
file appears in it, not even key names, so it needs no redaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous commit's test rewrite also deleted
test_stdout_result_does_not_taint_lease and the seven cases of
test_exit4_invalid_file_is_violation. Restore them verbatim; the
current mapping still gives failed/protocol_violation with a tainted
lease for an invalid result.json after exit 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@beesarmy beesarmy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head e456d06 against base 37a0840. No blocking findings remain in scope.

Approval covers only the new, currently unused protocol/parser and request-builder modules and their tests/fixtures. Inspected strict bounded parsing, request serialization and size limits, plain MCP-header rejection, stdout-only completion, diagnostic-only file outcomes, metadata-only diagnostics, and preservation of lease-taint and retry decisions. These modules do not wire in or enable exec transport.

Read-only source/test review: local unit and fixture-generation results are implementer evidence, not independently rerun here. Confirmed exact-head build, lint, unit-tests and e2e checks succeeded. Integration did not run because the workflow path filter excludes this diff; that is not an integration pass.

No live gateway, sandbox image, end-to-end transport, finite-tool enforcement, secret-bearing MCP activation, or warm-lease safety is established by this approval. Wiring and activation require separate review and evidence.

@jameswnl
jameswnl merged commit 3a1605e into lightspeed-core:main Nov 1, 2026
5 checks passed
jameswnl added a commit to jameswnl/lightspeed-cloud-agents that referenced this pull request Nov 1, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants