Skip to content

feat: LibreChat v0.8.7 compatibility (skills, read-only inputs, upload/delete parity, programmatic tool calling) - #73

Closed
gafda wants to merge 8 commits into
aron-muon:mainfrom
nosportugal:feat-librechat-0.8.7-compat
Closed

gafda wants to merge 8 commits into
aron-muon:mainfrom
nosportugal:feat-librechat-0.8.7-compat

Conversation

@gafda

@gafda gafda commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Brings KubeCodeRun to full wire-contract compatibility with LibreChat v0.8.7
(@librechat/agents@^3.2.46). Closes the four skills/file gaps (G/A/C/B), adopts
the canonical inherited contract, and adds Programmatic Tool Calling
(/exec/programmatic). A fifth gap (D) is intentionally deferred — see Deferred.

Both auth paths are preserved unchanged: the model-facing execute_code /
programmatic tools keep using X-API-Key, and host-side file/skill operations
keep using Bearer JWT (EdDSA, iss=librechat, aud=codeapi).

What changed

Area Change Commit
Skills (G) Preserve safe nested file paths end-to-end instead of flattening to a basename c8fe1cd
Inputs (A) Persist & enforce read_only; echo passthroughs as inherited:true inside files[] (canonical) a7d91d7, 502abef
Uploads (C) Single /upload identity parity (kind/id/version/read_only) with /upload/batch 390360a
Deletes (B) DELETE /sessions/{session}/objects/{file} alias to avoid LibreChat's 404 fallback 3c92153
PTC POST /exec/programmatic — Programmatic Tool Calling via the stateless replay model d3409aa

Gap G — Preserve nested skill file paths

LibreChat skill bundles upload files whose multipart filename carries a relative
path (e.g. skillName/SKILL.md). Previously these were flattened to the
basename at multiple layers, breaking the skill directory structure, relative
references, and readSandboxFile('/mnt/data/skillName/SKILL.md'), and risking
collisions. This required a multi-layer fix:

  • Python /upload + /upload/batch: preserve safe relative paths (per-segment
    sanitize, mirroring LibreChat's isSafeCodeEnvFilepath).
  • Go runner: honor the out-of-band path field on upload (os.MkdirAll,
    reusing the existing validatePath traversal guard — Go's multipart parser
    strips directories per RFC 7578) and recurse on list.
  • Output filter: match generated artifacts by relative path.

Gap A — Enforce read_only, echo inherited (canonical contract)

read_only persists end-to-end (FileInfo.read_only,
store_uploaded_file(read_only=...)); the executor excludes read-only mounted
inputs from generated artifacts even when modified.

Canonical contract (revised for 0.8.7 final). Read-only inputs are echoed
inside files[] flagged inherited: true. @librechat/agents@3.2.46
(CodeSessionFileSummary) filters file.inherited !== true out of the model
summary, and the host (callbacks.js / tools.js) already skips
if (file.inherited) when creating user downloads and re-downloads. This
matches the SaaS code-interpreter reference. (The earlier rc1-era approach
used a separate inherited_files field; 502abef replaces it.)

Gap C — Single /upload identity parity

/upload now accepts kind/id/version/read_only form fields like
/upload/batch: read_only is persisted on the stored file; kind → session
metadata["kind"], id → metadata["resource_id"].

Gap B — DELETE /sessions/{session}/objects/{file} alias

LibreChat's deleteFile issues this path first and only falls back to
DELETE /files/{session}/{file} on failure. The alias delegates to the same
handler; kind/id/version query params are accepted for parity (not
enforced).

Programmatic Tool Calling — POST /exec/programmatic

@librechat/agents@3.2.46 ships ProgrammaticToolCalling, letting the model
write Python that calls LibreChat tools as async functions. KubeCodeRun
execution pods have no network egress (NetworkPolicy egress: []), so the
live-callback bridge is impossible here — this implements the stateless replay
model
from the reference code-interpreter service:

  1. Initial { code, tools, session_id?, files? } → the sandbox runs harnessed
    code (tool stubs + async wrap) with an injected _ptc_history.json.
  2. The first un-cached tool call is printed in an execution-scoped stdout
    sentinel
    and the process exits → API returns
    { status: "tool_call_required", tool_calls: [{id,name,input}], continuation_token }.
  3. LibreChat executes the tool and POSTs { continuation_token, tool_results };
    the result is merged into history and the code re-runs — until it completes
    ({ status: "completed", stdout, stderr, files }).

Security: forged/un-issued tool_result call_ids are dropped; unregistered
tool calls and empty pending blocks return status:"error"; user files named
_ptc_history.json (or traversal paths) are rejected. Bash PTC returns a
clear 400 for now (Python only).

Deferred — Gap D (deterministic sessionKey bucketing)

Intentionally not implemented. It is an optimization (cross-user skill/agent
bucket sharing + cache hits), not a compatibility requirement — no LibreChat
v0.8.7 flow breaks without it. It would also deliberately reverse KubeCodeRun's
per-user isolation invariant (test_upload_does_NOT_reuse_session_for_different_user)
for skill/agent kinds, adding cross-user exposure for little reward in the
effectively single-tenant prod deployment. If pursued later, implement behind a
default-off flag (e.g. CODEAPI_DETERMINISTIC_BUCKETS) with dedicated cross-user
isolation tests and outputs always keyed <ns>:user:<userId>.

gafda added 7 commits June 24, 2026 11:34
LibreChat skills upload files with relative paths like
"<skillName>/SKILL.md", but every layer flattened them to a basename,
breaking skill directory structure, relative references and causing
cross-skill basename collisions.

Fix flattening across all four layers:

- output.py: add `_is_safe_relative_path` + `sanitize_filepath`
  (per-segment sanitize, preserves subdirs; falls back to basename for
  unsafe/traversal/absolute/dot inputs). `sanitize_filename` (basename
  flatten) retained for output filenames.
- api/files.py: `/upload` and `/upload/batch` use `sanitize_filepath`.
- docker/runner/files.go: Go's multipart parser strips directories per
  RFC 7578, so HandleUpload now reads the relative path out-of-band from
  a `path` form field (paired by index), MkdirAll's the parent, and
  validates via validatePath (authoritative traversal guard; dotfile
  rejection preserved). HandleList rewritten with WalkDir (recursive,
  relative slash paths).
- manager.py / job_executor.py: send `data={"path": filename}` alongside
  the file part so the runner can reconstruct the nested path.
- runner.py: mounted-filename set includes full relative path + basename;
  generated-file filtering matches by relative-path-under-/mnt/data or
  basename.

Tests: nested round-trip, traversal/absolute/dot fallback, per-segment
sanitize, recursive list, and a LibreChat nested-path upload contract.

Note: the Go runner is baked into all 12 language images, so this fix
requires rebuilding every language image.
Skill/agent bundles are uploaded with read_only=true but the flag was a
placeholder: read-only inputs the sandbox touched surfaced as ghost
download artifacts, and there was no way to distinguish inherited inputs
from generated outputs.

- models/files.py: FileInfo carries read_only (default False).
- file.py / interfaces.py: store_uploaded_file persists read_only on file
  metadata; get_file_info reads it back (legacy files default False).
- api/files.py: batch /upload threads its read_only form field to storage.
- orchestrator.py: mounted file dicts carry read_only (explicit + auto-mount
  + cross-session consolidation); _build_response echoes read-only inputs in
  a new ExecResponse.inherited_files list, each FileRef marked inherited=true.
- runner.py: _filter_generated_files excludes read-only inputs from the
  generated files list even when modified (suppresses ghost downloads),
  via a new _get_readonly_filenames expansion.

inherited_files is deliberately separate from files[]: @librechat/agents
3.2.35 maps every files[] entry into a user download artifact and ignores
the inherited flag, so emitting inherited entries there would re-create the
ghosts. The separate field is forward-compatible for consumers that read it.

Tests: read-only suppression in the runner, inherited_files assembly in the
orchestrator, batch read_only persistence + default, updated consolidation
contract.
Thread kind/id/version/read_only through the single-file /upload handler
so it reaches identity parity with /upload/batch. read_only is persisted
on the stored file; kind and id land on the session metadata. Existing
direct-call unit tests updated to pass the new Form params explicitly,
matching the batch test convention.
LibreChat's deleteFile issues DELETE /sessions/{session}/objects/{file}
first and only falls back to DELETE /files/{session}/{file} on failure.
Serving the objects path directly (delegating to delete_file) avoids the
extra 404 round-trip. kind/id/version query params are accepted for
parity with buildCodeEnvDownloadQuery.
* Build the runner image synchronously before other language images.
* Prevents build failures due to missing runner image during subsequent builds.
* Adds error handling for missing Dockerfile and build failures.
…ract)

LibreChat 0.8.7 pins @librechat/agents ^3.2.46, whose CodeSessionFileSummary
filters files[] by `file.inherited !== true`, and the host
(callbacks.js / tools.js) already skips `if (file.inherited)` when creating
user downloads and re-downloads. The canonical wire contract is therefore to
echo read-only inputs INSIDE files[] flagged inherited:true — not in a
separate field.

Replace the separate ExecResponse.inherited_files field with inherited:true
entries appended to files[] (orchestrator._build_response). Matches the SaaS
code-interpreter reference and what final LibreChat + agents 3.2.46 consume.
The runner still excludes read-only inputs from the generated list, so each
read-only input appears exactly once (as an inherited entry).
… mode)

@librechat/agents 3.2.46 ships ProgrammaticToolCalling, which POSTs to
${baseURL}/exec/programmatic so the model can write Python that calls
LibreChat tools as async functions. KubeCodeRun execution pods have no
network egress (NetworkPolicy egress: []), so the live-callback bridge is
impossible here; this implements the stateless replay model used by the
reference code-interpreter service instead.

Each round the sandbox re-runs the user code with an injected history file
(_ptc_history.json: call_id -> prior result). A cached call returns
immediately; the first un-cached call is printed inside an execution-scoped
stdout sentinel and the process exits. The API relays that call to LibreChat
and continues via continuation_token + tool_results until the code completes.

- models/programmatic.py: request/response wire models (language/lang alias,
  tool_calls {id,name,input}, tool_results {call_id,result,is_error}).
- services/programmatic/: constants (sentinels, reserved-filename guard),
  harness (replay preamble + tool stubs + async wrap), sentinel parser,
  Redis-backed continuation state, and the replay orchestration service.
- api/programmatic.py: POST /exec/programmatic (auth + user_id like /exec).
- orchestrator: internal extra_files (inject the history file) and
  capture_state_override (PTC runs are stateless).

Bash PTC is rejected with a clear 400 for now (Python only). Forged/un-issued
tool_result call_ids are dropped; unregistered tool calls and empty pending
blocks return status=error to avoid tight retry loops.
@gafda
gafda requested a review from aron-muon as a code owner June 25, 2026 14:33
Copilot AI review requested due to automatic review settings June 25, 2026 14:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR updates KubeCodeRun to match LibreChat v0.8.7’s wire contract more closely, closing several compatibility gaps around skill file paths, read-only/inherited inputs, upload/delete parity, and adding Programmatic Tool Calling via a replay-loop endpoint.

Changes:

  • Add Programmatic Tool Calling (POST /exec/programmatic) with a Python replay harness, sentinel parsing, and Redis-backed continuation state.
  • Preserve safe nested upload paths end-to-end (Python upload sanitization, K8s file copy, Go runner upload + recursive listing) and adjust generated-file filtering to recognize nested mounted/read-only inputs.
  • Persist read_only on stored files and echo read-only mounted inputs back as files[] entries with inherited: true; add a LibreChat-compatible delete alias route.

Reviewed changes

Copilot reviewed 30 out of 30 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/unit/test_programmatic_service.py New unit tests covering PTC replay loop behavior, continuation, and validation cases.
tests/unit/test_programmatic_harness.py Tests for harness generation, sentinel markers, and reserved filename handling.
tests/unit/test_output_processor.py Adds coverage for nested-path-preserving sanitize_filepath.
tests/unit/test_orchestrator.py Verifies read-only mounted inputs are echoed as inherited file refs.
tests/unit/test_librechat_contract.py Contract tests for inherited file refs, upload identity/read_only threading, and PTC response shapes.
tests/unit/test_execution_runner.py Tests nested mounted filename matching and read-only exclusion from generated artifacts.
tests/unit/test_cross_session_files.py Updates mocks/calls for new upload fields and read_only in file info.
tests/unit/test_api_files.py Adds coverage for the delete alias route and updates upload calls for new fields.
src/services/programmatic/state.py Introduces Redis-backed continuation state model + merge logic for tool results.
src/services/programmatic/service.py Implements the replay-loop orchestration over the existing ExecutionOrchestrator.
src/services/programmatic/sentinel.py Adds stdout sentinel parsing to extract pending tool calls and clean user output.
src/services/programmatic/harness.py Generates the Python replay harness/preamble and async wrapping for user code.
src/services/programmatic/constants.py Defines PTC limits, sentinel building, reserved filename checks, and history constants.
src/services/programmatic/init.py Exposes PTC service/state types for import.
src/services/orchestrator.py Adds support for inline extra files + stateless override; threads read_only and emits inherited file refs.
src/services/kubernetes/manager.py Sends nested paths out-of-band (path field) when uploading files to runner.
src/services/kubernetes/job_executor.py Same out-of-band path field for job-mode runner uploads.
src/services/interfaces.py Extends file storage interface to persist read_only.
src/services/file.py Persists and reloads read_only from file metadata.
src/services/execution/runner.py Enhances generated-file detection/filtering for nested paths + read-only exclusion.
src/services/execution/output.py Adds sanitize_filepath to preserve safe nested upload paths segment-by-segment.
src/models/programmatic.py Adds request/response models for the /exec/programmatic endpoint.
src/models/files.py Adds read_only to FileInfo.
src/models/exec.py Adds inherited flag to FileRef for LibreChat contract behavior.
src/main.py Registers the new programmatic router.
src/api/programmatic.py Adds /exec/programmatic endpoint with auth/user-id resolution and error mapping.
src/api/files.py Adds upload identity/read_only fields, uses path-preserving sanitization, and adds delete alias route.
scripts/build-images.sh Ensures the runner image is built first since other images depend on it.
docker/runner/files.go Supports nested upload paths via path field and makes file listing recursive with relative paths.
docker/runner/files_test.go Adds coverage for nested uploads, traversal rejection, and recursive listing.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/services/programmatic/sentinel.py
Comment thread src/services/programmatic/service.py
… validation)

- sentinel: match the marker line exactly (tolerating only a trailing CR for
  CRLF-captured stdout) instead of strip(), so user output that merely contains
  the marker padded by whitespace can't be mistaken for a sentinel or shift the
  byte-accurate stdout reconstruction.
- service: validate tool names up front — reject empty/whitespace names and
  names with no identifier-safe characters (which would emit `async def ()` and
  surface as a confusing "completed" syntax error), and reject two distinct
  names that normalize to the same Python identifier. Returns a clear 400.
- harness: expose normalize_python_function_name (was private) for reuse.
@gafda

gafda commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

@aron-muon Any news here?

aron-muon added a commit that referenced this pull request Sep 16, 2026
* feat(files): preserve nested skill file paths end-to-end

LibreChat skills upload files with relative paths like
"<skillName>/SKILL.md", but every layer flattened them to a basename,
breaking skill directory structure, relative references and causing
cross-skill basename collisions.

Fix flattening across all four layers:

- output.py: add `_is_safe_relative_path` + `sanitize_filepath`
  (per-segment sanitize, preserves subdirs; falls back to basename for
  unsafe/traversal/absolute/dot inputs). `sanitize_filename` (basename
  flatten) retained for output filenames.
- api/files.py: `/upload` and `/upload/batch` use `sanitize_filepath`.
- docker/runner/files.go: Go's multipart parser strips directories per
  RFC 7578, so HandleUpload now reads the relative path out-of-band from
  a `path` form field (paired by index), MkdirAll's the parent, and
  validates via validatePath (authoritative traversal guard; dotfile
  rejection preserved). HandleList rewritten with WalkDir (recursive,
  relative slash paths).
- manager.py / job_executor.py: send `data={"path": filename}` alongside
  the file part so the runner can reconstruct the nested path.
- runner.py: mounted-filename set includes full relative path + basename;
  generated-file filtering matches by relative-path-under-/mnt/data or
  basename.

Tests: nested round-trip, traversal/absolute/dot fallback, per-segment
sanitize, recursive list, and a LibreChat nested-path upload contract.

Note: the Go runner is baked into all 12 language images, so this fix
requires rebuilding every language image.

* feat(files): enforce read_only inputs and echo inherited passthroughs

Skill/agent bundles are uploaded with read_only=true but the flag was a
placeholder: read-only inputs the sandbox touched surfaced as ghost
download artifacts, and there was no way to distinguish inherited inputs
from generated outputs.

- models/files.py: FileInfo carries read_only (default False).
- file.py / interfaces.py: store_uploaded_file persists read_only on file
  metadata; get_file_info reads it back (legacy files default False).
- api/files.py: batch /upload threads its read_only form field to storage.
- orchestrator.py: mounted file dicts carry read_only (explicit + auto-mount
  + cross-session consolidation); _build_response echoes read-only inputs in
  a new ExecResponse.inherited_files list, each FileRef marked inherited=true.
- runner.py: _filter_generated_files excludes read-only inputs from the
  generated files list even when modified (suppresses ghost downloads),
  via a new _get_readonly_filenames expansion.

inherited_files is deliberately separate from files[]: @librechat/agents
3.2.35 maps every files[] entry into a user download artifact and ignores
the inherited flag, so emitting inherited entries there would re-create the
ghosts. The separate field is forward-compatible for consumers that read it.

Tests: read-only suppression in the runner, inherited_files assembly in the
orchestrator, batch read_only persistence + default, updated consolidation
contract.

* feat(files): add identity form fields to single /upload

Thread kind/id/version/read_only through the single-file /upload handler
so it reaches identity parity with /upload/batch. read_only is persisted
on the stored file; kind and id land on the session metadata. Existing
direct-call unit tests updated to pass the new Form params explicitly,
matching the batch test convention.

* feat(files): add DELETE /sessions/{session}/objects/{file} alias

LibreChat's deleteFile issues DELETE /sessions/{session}/objects/{file}
first and only falls back to DELETE /files/{session}/{file} on failure.
Serving the objects path directly (delegating to delete_file) avoids the
extra 404 round-trip. kind/id/version query params are accepted for
parity with buildCodeEnvDownloadQuery.

* feat(build): ensure runner image builds first as dependency

* Build the runner image synchronously before other language images.
* Prevents build failures due to missing runner image during subsequent builds.
* Adds error handling for missing Dockerfile and build failures.

* refactor(files): echo inherited inputs inside files[] (canonical contract)

LibreChat 0.8.7 pins @librechat/agents ^3.2.46, whose CodeSessionFileSummary
filters files[] by `file.inherited !== true`, and the host
(callbacks.js / tools.js) already skips `if (file.inherited)` when creating
user downloads and re-downloads. The canonical wire contract is therefore to
echo read-only inputs INSIDE files[] flagged inherited:true — not in a
separate field.

Replace the separate ExecResponse.inherited_files field with inherited:true
entries appended to files[] (orchestrator._build_response). Matches the SaaS
code-interpreter reference and what final LibreChat + agents 3.2.46 consume.
The runner still excludes read-only inputs from the generated list, so each
read-only input appears exactly once (as an inherited entry).

* feat(exec): add /exec/programmatic (Programmatic Tool Calling, replay mode)

@librechat/agents 3.2.46 ships ProgrammaticToolCalling, which POSTs to
${baseURL}/exec/programmatic so the model can write Python that calls
LibreChat tools as async functions. KubeCodeRun execution pods have no
network egress (NetworkPolicy egress: []), so the live-callback bridge is
impossible here; this implements the stateless replay model used by the
reference code-interpreter service instead.

Each round the sandbox re-runs the user code with an injected history file
(_ptc_history.json: call_id -> prior result). A cached call returns
immediately; the first un-cached call is printed inside an execution-scoped
stdout sentinel and the process exits. The API relays that call to LibreChat
and continues via continuation_token + tool_results until the code completes.

- models/programmatic.py: request/response wire models (language/lang alias,
  tool_calls {id,name,input}, tool_results {call_id,result,is_error}).
- services/programmatic/: constants (sentinels, reserved-filename guard),
  harness (replay preamble + tool stubs + async wrap), sentinel parser,
  Redis-backed continuation state, and the replay orchestration service.
- api/programmatic.py: POST /exec/programmatic (auth + user_id like /exec).
- orchestrator: internal extra_files (inject the history file) and
  capture_state_override (PTC runs are stateless).

Bash PTC is rejected with a clear 400 for now (Python only). Forged/un-issued
tool_result call_ids are dropped; unregistered tool calls and empty pending
blocks return status=error to avoid tight retry loops.

* fix(programmatic): address PR review (exact sentinel match, tool-name validation)

- sentinel: match the marker line exactly (tolerating only a trailing CR for
  CRLF-captured stdout) instead of strip(), so user output that merely contains
  the marker padded by whitespace can't be mistaken for a sentinel or shift the
  byte-accurate stdout reconstruction.
- service: validate tool names up front — reject empty/whitespace names and
  names with no identifier-safe characters (which would emit `async def ()` and
  surface as a confusing "completed" syntax error), and reject two distinct
  names that normalize to the same Python identifier. Returns a clear 400.
- harness: expose normalize_python_function_name (was private) for reuse.

* fix(programmatic): make harness generation robust to hostile tool schemas and user code

Three harness-generation bugs, each reproduced before fixing:

- Parameter names from tool JSON schemas were interpolated raw into the
  stub signature and input dict: a name like "user-name" generated
  `async def search(user-name: str)` — a syntax error surfacing as a
  confusing 'completed' response with a traceback. Property names are now
  normalized with normalize_python_function_name (dispatch still sends the
  original name, JSON-escaped, as the input key), and requests whose
  parameter names have no identifier-safe characters or collide after
  normalization get a clear 400.
- Tool descriptions ending in '"' (or containing quote runs) terminated
  the generated docstring literal early. Every quote is now escaped, not
  just literal triple-quote sequences.
- wrap_user_code_in_async re-indented every user line by 4 spaces, which
  corrupts the *content* of multi-line string literals ('line1\nline2'
  became 'line1\n    line2'). User code is now embedded verbatim as a
  string literal and compiled at runtime with PyCF_ALLOW_TOP_LEVEL_AWAIT,
  preserving bytes exactly, keeping module-level scoping (global,
  top-level def/class) and traceback line numbers, while still supporting
  top-level await.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(programmatic): injected replay history must override same-named session files

_mount_files appended extra_files only when no mounted file shared the
name, so a session file named _ptc_history.json (uploadable via /upload —
the reserved-name check in ProgrammaticService only covers request.files)
silently shadowed the injected replay history. That hands replay control
to session content: forged tool results, or a stale history that re-emits
the same tool_call_required forever and burns the client's 20-round cap.

Extra files now replace same-named mounted entries. The history file is
runtime infrastructure; nothing a session contains may impersonate it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(programmatic): report truncated-sentinel runs as errors, not completed

The PTC sentinel is the last thing the harness prints, and pod stdout is
capped at 64KB by OutputProcessor.sanitize_output — so any run that
prints enough before its first tool call gets its sentinel cut off and
was previously misreported as status=completed with truncated stdout,
silently dropping the pending tool call.

When no sentinel is found and stdout carries the truncation notice, the
service now returns a structured status=error telling the model to print
less before calling tools, and drops the continuation state. The notice
string is promoted to OutputProcessor.TRUNCATION_NOTICE so the two sites
cannot drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(programmatic): harden the continuation contract against the real client behavior

Verified against @librechat/agents 3.2.46 source (ProgrammaticToolCalling
+ Bash variant) and LibreChat v0.8.7:

- Bind continuation tokens to the identity that started the execution.
  A token presented under a different user_id now gets the same
  'not found or expired' error as an unknown token, instead of resuming
  someone else's replay state under the shared API key.
- Mid-loop failures (unknown/expired token, replay-call cap) now return
  HTTP 200 with status='error'. The agents client's makeRequest throws on
  any non-2xx with an unstructured 'CodeAPI request failed' message and
  never reads the body's error field; only the 200 + status:'error' shape
  takes its structured path. Up-front request validation (bad tool names,
  missing code) keeps returning 400 - that path is developer-facing.
- Treat any body carrying continuation_token as a continuation. Requiring
  tool_results too meant a token-only body was misread as an initial
  request and rejected with a misleading 'Missing required field: code'.
- Raise the continuation-state sliding TTL from 600s to 3600s: the client
  caps the loop at 20 round trips but has NO timeout between rounds -
  each gap lasts as long as the host takes to run the requested tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(runner): reject hidden path segments and make multi-field uploads deterministic

Nested upload paths reopened a hole the old basename-only flow had
closed: only the final segment was checked for a leading dot, so
'.git/config' or 'skill/.ssh/authorized_keys' could materialize hidden
directories inside /mnt/data. Every segment of the relative path is now
checked.

Also iterate multipart field names in sorted order when pairing files
with the out-of-band 'path' values: Go map iteration is randomized, so a
request using more than one field name could nondeterministically
associate paths with the wrong files. The control plane sends one field
per request today, but correctness must not depend on that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: ruff format

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(programmatic): execute the generated harness end-to-end in a subprocess

Round 1 with empty history must emit the scoped sentinel for the first
tool call and preserve user prints; round 2 with the cached result must
run to completion; a cached is_error result must raise inside user code.
This runs the real generated Python (via PTC_HISTORY_PATH), so harness,
sentinel parser and replay semantics are proven together rather than
assumed from string assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Gil Assunção <gil.assuncao@parceiros.nos.pt>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 3.7.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants