Skip to content

fix: guarantee the picked working directory reaches every shell/coder call - #388

Merged
ytallo merged 3 commits into
mainfrom
fix/workspace-scope-guarantee
Jul 3, 2026
Merged

fix: guarantee the picked working directory reaches every shell/coder call#388
ytallo merged 3 commits into
mainfrom
fix/workspace-scope-guarantee

Conversation

@ytallo

@ytallo ytallo commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

When a working directory is picked in the chat console, it must be reachable from every shell::*/coder::* call the session makes. The mechanism for that guarantee already exists — the harness stamps the turn's working_dir onto each scoped call as base_dir, and the shell worker adopts an operator-picked base_dir as an effective jail root per call — but three invocation paths skipped the stamp, silently breaking the guarantee:

  • Approval-release path (deferred.rs execute): a hook-held call, once approved, was invoked with the transcript-original arguments and no stamp — the approved shell/coder call ran outside the session scope, and a model-supplied base_dir recovered from the transcript survived un-stripped (a scope-widening hole).
  • Sub-agents (subagent.rs): child turns were seeded with metadata: None, so the picked directory was unreachable from every child (and grandchild) turn. Children now inherit exactly the parent's working_dir — never its per-turn tracing metadata.
  • harness::function::trigger (function_trigger.rs): the direct-invoke entry point never stamped, with the same un-scoped/escape consequences. With no turn record, a caller-supplied base_dir on a scoped call is now stripped.

Two related hardenings ride along:

  • pre_trigger hooks now receive the stamped arguments, so an approval gate reviews the base_dir the call will actually run under (previously it saw the call without its scope, or with a bogus model-supplied one). The stamp is re-applied after the hook chain, so a hook rewrite can never widen or drop the scope.
  • The console picker's browsed "use this folder" selection now round-trips through shell::workspace::validate like pasted/remembered picks — every selection path is validated against the live worker and stores the worker-echoed canonical path.

TurnOptions::working_dir() is the new shared accessor; the turn loop, deferred release, function::trigger, and the system-prompt working-dir aid all read the same source.

Known gaps left out (deliberate contracts, follow-up decisions)

  • A send merged into a running turn keeps the old working_dir until the next turn (turn options are frozen by design).
  • Sandbox-targeted exec/fs drops base_dir by design (it is a host path).
  • coder::info has no base_dir field and describes the unscoped jail to a scoped model.

Test plan

  • cargo test in harness/ — 119 passed, including new unit tests for TurnOptions::working_dir() and sub-agent working_dir inheritance (only-inherit-the-scope, no-parent, unscoped-parent cases)
  • cargo clippy --all-targets and cargo fmt --check clean
  • console/web: tsc -b --noEmit, vitest run (803 tests, 52 files), biome check on the changed file — all clean
  • Adversarial review of each change (turn-loop ordering parity, hook-envelope consumers, grandchild inheritance, picker error surfacing and root-path edge cases) found no regressions
  • Live smoke on the running stack: pick a directory outside the static jail, trigger an approval-gated shell::exec and a sub-agent spawn, confirm both operate in the picked directory

Summary by CodeRabbit

  • New Features

    • Child sessions now inherit the active working folder more consistently.
    • Folder selection in the chat directory picker now validates consistently across browse, paste, and remembered paths, and stores the canonical path from validation.
  • Bug Fixes

    • Improved consistency for tool and command actions so they reliably remain scoped to the active workspace, including during function calls and hook processing.
    • Prevented folder selection from being confirmed while validation is still running.

@vercel

vercel Bot commented Jul 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workers Ready Ready Preview, Comment Jul 2, 2026 11:01am
workers-tech-spec Ready Ready Preview, Comment Jul 2, 2026 11:01am

Request Review

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

skill-check — worker

0 verified, 30 skipped (no docs/).

Layer Result
structure
vale
ai
render

Four for four. Nicely done.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ytallo, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 37 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a3b2556c-deb7-4475-9ea9-4f87099fb368

📥 Commits

Reviewing files that changed from the base of the PR and between 16cb669 and 38c0cc1.

📒 Files selected for processing (2)
  • harness/src/subagent.rs
  • harness/src/types/turn.rs
📝 Walkthrough

Walkthrough

Adds a working_dir() accessor on TurnOptions, reapplies workspace scoping across harness invocation paths, propagates child turn workspace metadata, and routes DirectoryPicker browse selection through validation.

Changes

Harness workspace scope stamping

Layer / File(s) Summary
TurnOptions.working_dir() accessor
harness/src/types/turn.rs
Adds public working_dir() method reading metadata["working_dir"] as a string, with unit tests for present, absent, and non-string cases.
Turn loop argument staging
harness/src/turn_loop.rs
Stages tool-call arguments via workspace_inject::inject before pre-trigger hooks and re-applies it after hook rewrites; with_working_dir_aid now uses the new accessor.
Function trigger scope stamping
harness/src/functions/function_trigger.rs
Injects workspace scope into arguments before running pre-trigger hooks and re-injects it afterward.
Deferred resolve execute-path stamping
harness/src/deferred.rs
Re-applies workspace_inject::inject to recovered transcript arguments in the execute branch of resolve.
Subagent working_dir inheritance
harness/src/subagent.rs
Adds inherit_workspace so child turn metadata only inherits the parent's working_dir, with tests for inherited and absent cases.

Estimated code review effort: 3 (Moderate) | ~30 minutes

DirectoryPicker unified validation

Layer / File(s) Summary
Browse path validation and docs
console/web/src/components/chat/DirectoryPicker.tsx
Browse view's “use this folder” button now calls validateAndSelect(path) and disables while validating; comments describe one validation path for all directory selections.

Estimated code review effort: 1 (Trivial) | ~5 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TurnLoop
  participant WorkspaceInject
  participant PreTriggerHooks
  participant TargetCall
  TurnLoop->>WorkspaceInject: inject(function_id, call.arguments, working_dir())
  WorkspaceInject-->>TurnLoop: staged_args
  TurnLoop->>PreTriggerHooks: run_pre_trigger(staged_args)
  PreTriggerHooks-->>TurnLoop: eff_args
  TurnLoop->>WorkspaceInject: inject(eff_args, working_dir())
  WorkspaceInject-->>TurnLoop: scoped_args
  TurnLoop->>TargetCall: invoke(scoped_args)
Loading

Possibly related PRs

  • iii-hq/workers#327: Both PRs wire working_dir/base_dir through the harness turn loop and console directory selection flow.
  • iii-hq/workers#333: Both PRs modify the same invocation-argument preparation points in deferred.rs, function_trigger.rs, and turn_loop.rs.
  • iii-hq/workers#376: Both PRs modify DirectoryPicker's validation flow alongside the harness workspace injection stack.

Suggested reviewers: andersonleal, sergiofilhowz

Poem

A hop through code where paths align,
Each folder stamped with scope so fine,
Parent turns pass down their den,
Working dirs inherited again,
Browse and paste, now one true way —
This rabbit thumps approval today! 🐇📁

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: ensuring the picked working directory is propagated to shell/coder calls.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/workspace-scope-guarantee

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (1)
harness/src/types/turn.rs (1)

88-101: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider centralizing the stamp call to reduce duplication and future drift.

The working_dir() accessor is solid. Every call site in this cohort (turn_loop.rs ×2, function_trigger.rs ×2, deferred.rs ×1) repeats the same crate::workspace_inject::inject(&function_id, args, record.options.working_dir()) pattern. A thin wrapper here (e.g. TurnOptions::stamp_workspace(&self, function_id, arguments) -> Value) would remove that duplication and reduce the chance of call sites drifting apart (see the cross-file comment on function_trigger.rs documenting one such drift in behavior description).

♻️ Suggested helper
 impl TurnOptions {
     pub fn working_dir(&self) -> Option<&str> {
         self.metadata
             .as_ref()
             .and_then(|m| m.get("working_dir"))
             .and_then(Value::as_str)
     }
+
+    /// Stamp this turn's workspace scope onto a scoped `shell::*` / `coder::*`
+    /// call's arguments. Thin wrapper over `workspace_inject::inject` so every
+    /// call site derives the same `working_dir` the same way.
+    pub fn stamp_workspace(&self, function_id: &str, arguments: Value) -> Value {
+        crate::workspace_inject::inject(function_id, arguments, self.working_dir())
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@harness/src/types/turn.rs` around lines 88 - 101, Centralize the repeated
workspace-stamping logic around TurnOptions::working_dir() by adding a thin
helper on TurnOptions, such as a stamp_workspace method that wraps
workspace_inject::inject for a function_id and arguments. Then replace the
duplicated crate::workspace_inject::inject(&function_id, args,
record.options.working_dir()) calls in turn_loop.rs, function_trigger.rs, and
deferred.rs with the new helper so the behavior stays consistent across all
scoped shell/coder paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@harness/src/types/turn.rs`:
- Around line 88-101: Centralize the repeated workspace-stamping logic around
TurnOptions::working_dir() by adding a thin helper on TurnOptions, such as a
stamp_workspace method that wraps workspace_inject::inject for a function_id and
arguments. Then replace the duplicated
crate::workspace_inject::inject(&function_id, args,
record.options.working_dir()) calls in turn_loop.rs, function_trigger.rs, and
deferred.rs with the new helper so the behavior stays consistent across all
scoped shell/coder paths.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 54568fd1-4cdf-4804-87f4-d771f24f4cc9

📥 Commits

Reviewing files that changed from the base of the PR and between 868999c and f68690b.

📒 Files selected for processing (6)
  • console/web/src/components/chat/DirectoryPicker.tsx
  • harness/src/deferred.rs
  • harness/src/functions/function_trigger.rs
  • harness/src/subagent.rs
  • harness/src/turn_loop.rs
  • harness/src/types/turn.rs

ytallo added 2 commits July 2, 2026 07:37
…n path

The console-picked working directory is guaranteed reachable through the
per-call base_dir stamp, but three invocation paths skipped it:

- the deferred approval-release path invoked the recovered transcript
  arguments un-stamped, so an approved shell/coder call lost the session
  scope and a model-supplied base_dir survived un-stripped
- sub-agent turns were seeded with no metadata, so children could never
  reach the parent session's directory
- harness::function::trigger invoked its target without the stamp

pre_trigger hooks now receive the stamped arguments (an approver reviews
the base_dir the call actually runs under), with an idempotent re-stamp
after the chain so a hook rewrite can never widen the scope.
TurnOptions::working_dir() is the shared accessor for all stamp sites;
children inherit only the parent's working_dir, never its per-turn
tracing metadata.
… worker

Browsed "use this folder" selections bypassed shell::workspace::validate.
Every selection path — pasted, remembered, or browsed — now round-trips
through validate, so a stale listing can't select a vanished directory
and the worker-echoed canonical path is what gets stored.
Extract the working_dir metadata key into a single WORKING_DIR_KEY
constant (mirroring workspace_inject::BASE_DIR_FIELD) used by both
TurnOptions::working_dir (read) and subagent::inherit_workspace (write),
so a rename cannot silently desync the two and drop child scope. Also
complete the working_dir() doc enumeration to include function::trigger,
the third stamping path.
@ytallo
ytallo merged commit 298df7b into main Jul 3, 2026
39 of 40 checks passed
andersonleal added a commit that referenced this pull request Jul 3, 2026
Upstream #388 added a test TurnRecord literal that predates this branch's
display_parent_session_id / spawned_by_subscription_id / reactive_depth
fields; the rebase merged clean but test compilation broke.
andersonleal added a commit that referenced this pull request Jul 3, 2026
Upstream #388 added a test TurnRecord literal that predates this branch's
display_parent_session_id / spawned_by_subscription_id / reactive_depth
fields; the rebase merged clean but test compilation broke.
andersonleal added a commit that referenced this pull request Jul 3, 2026
…-in, wire hardening, and spawn console view (#401)

* feat(harness): reactive trigger bridge (harness::react) with join fan-in and lifecycle hardening

Ports the engine's trigger/notify primitives into a harness-native reactive
sub-agent bridge and hardens the full registration/fire/teardown lifecycle
against gaps found in live testing.

- harness::react: sub-agent spec fires on engine triggers (turn events,
  state, cron, stream); join fan-in with an expect array, fire-once
  accumulator, and rearm for standing watchers.
- Interceptor pass-through (subscribe.rs): agent-issued
  engine::register_trigger calls get owner + subscription id stamped
  server-side into the react metadata, closing several trust gaps in the
  raw registration path.
- Idempotent registration (dedup by canonical request key) and a durable
  owner sweep on session::deleted, replacing two pipelines that could
  double-register the same reaction.
- Startup reconcile: GC react bindings whose owner session is gone and
  notify bindings unknown to the local registry; never GC on doubt.
- Loop breakers: self-edge drop, reactive-depth cap, per-subscription
  fire-rate limit.
- Join results deliver into the registering (owner) session by default
  instead of a detached, unread child session; parent nesting falls back
  from the event's session through the owner stamp to resolve_root.
- Registration advisories for turn-event filters naming a nonexistent
  session, and for a join key wired to the same event source as a
  sibling key.
- Policy aid: narrowed sub-agents are told their allowed/denied function
  surface directly in the system prompt instead of discovering it via a
  denied functions::list call.
- Heal dangling function_calls left by interrupted/compacted turns
  before the next generate step.
- Docs: tech spec, skill, and all prompt variants updated for the
  react/join doctrine.

* feat(console): dedicated chat view for harness::spawn

Replace the raw-JSON fallback card with an instrument-panel view:
policy chips (model/mode/turns/thinking/output/allow/deny), the task
rendered as markdown, and the child's result as markdown, highlighted
JSON, or the direct-call child ids. Guard errors and failed children
route through the existing SandboxErrorView; the approval gate gets a
policy-first preview. Session ids link to the child conversation via
the sidebar's select when the console knows the session.

Includes Zod parsers for the spawn wire schema (excerpt-tolerant),
fixtures for all six card states, a gated-spawn playground scenario,
and parser tests locking envelope unwrapping and error-before-success
dispatch.

* fix(harness): seed react-bridge TurnRecord fields in subagent test

Upstream #388 added a test TurnRecord literal that predates this branch's
display_parent_session_id / spawned_by_subscription_id / reactive_depth
fields; the rebase merged clean but test compilation broke.

* feat(harness): prompt doctrine — name every spawned child session

Every harness::spawn must pass session_id: a short readable job slug plus
a few random characters (fetch-headlines-b4k9), replacing the opaque
engine-minted UUIDs in the console tree. Never the parent session id as a
prefix; the random suffix carries the run-uniqueness guarantee instead
(a reused id silently resumes the old session). Scoped to direct spawn
calls only — in a react trigger's metadata a fixed session_id funnels
every firing into one session and re-aims join delivery. Fan-in doctrine
updated to the same naming across all five prompt variants.

* fix(providers): keep displaced tool results adjacent to their call

A notification or steering user entry injected while a call window is open
(a parked harness::spawn holds one open for minutes) lands between
function_call and function_result in the durable transcript. Every wire
mapper only repaired MISSING results (orphan placeholder) — a DISPLACED
result survived to the wire as assistant(tool_use) / user(text) /
user(tool_result), which Anthropic 400s ('tool_use ids were found without
tool_result blocks immediately after') and OpenAI/xAI/Responses reject as
a user row between tool_calls and its tool rows. The durable transcript
replays the shape on every retry, permanently wedging the turn.

Fix: shared llm_router::types::messages::reorder_displaced_results runs
first in all four providers' to_wire_messages — each FunctionResult moves
directly after the assistant that emitted its call, order preserved,
orphan results untouched. Wedged sessions self-heal: the transcript
itself was never illegal, only the wire projection.

Repro test written first and failed with the exact live shape; regression
tests in all four providers plus unit tests on the shared helper.

* fix(harness): rotate mid-generation user arrivals past the interrupted reply

A user entry appended while a step is generating (or assembling — the
compaction/hook window) lands before that step's assistant entry in the
durable log. The steering check then re-generates, but the assembled
context ENDS with a call-less assistant message — a prefill request newer
Anthropic models reject ('This model does not support assistant message
prefill. The conversation must end with a user message.'), wedging the
turn on every retry. Older models silently accepted prefill, hiding this
path.

Fix: rotate_mid_generation_users presents arrivals after the previous
step's watermark AFTER the reply they interrupted — semantically exact,
the model answered without seeing them. Two invariants hardened by
adversarial review:
- the new watermark is assigned only after router.chat returns; the
  pre-generate put_turn persists the OLD one, so a redelivered step keeps
  its rotation window instead of re-issuing the rejected shape forever
- rotation runs on the FINAL assembled values, never on the candidate:
  compaction persists tail_start_entry_id as a log-order cursor indexed
  from the candidate, and rotating first would silently drop the rotated
  message from every future window

Also: has_user_after_watermark now loads include_custom=true, matching
the list the watermark comes from (a watermark landing on a custom entry
silently disabled the steering check).

* style: cargo fmt (harness, provider-openai, provider-xai)

* fix(harness): Display for DispatchError + fmt (rebase fallout)

Main's fs-scope refactor (#397) introduced the bare DispatchError struct;
the react-bridge reconcile pass logs it with %e, which needs Display —
an error type should carry one anyway.

* docs(harness): revert harness.md spec changes

Restore tech-specs/2026-06-agentic/harness.md to main's version — the
react-bridge spec additions come out of this PR.

* fix(harness): address CodeRabbit review on #401

- react: include the join (id, key) in the fallback fire-gate hash —
  state-based join predecessors share the whole downstream spec except
  their key, so a wide join shared one 10-fires/min budget and tripped
  the breaker spuriously
- react: retry the join accumulator delete (3 attempts) — a failed
  delete left fire=1 behind, permanently wedging a rearmed join's
  fire-once guard; persistent failure on a rearmed join now logs at
  error level with the recovery path
- spawn: strip spawned_by_subscription_id / reactive_depth on the
  model-reachable dispatch path — react-internal bookkeeping a model
  could spoof to defeat the self-edge breaker and depth cap
- skills: align SKILL.md fan-in naming with the prompt doctrine (slug +
  random suffix, never the originating session id as a prefix)
- tests: multi-owner displaced-result reorder case; round-trip the
  react-bridge TurnRecord fields with real values
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.

1 participant