Skip to content

fix(run_task): read an agent's own project config, and name a missing working dir - #555

Merged
atlas-from-plumb merged 9 commits into
mainfrom
atlas/fix-522-agent-task-config
Oct 1, 2026
Merged

atlas-from-plumb merged 9 commits into
mainfrom
atlas/fix-522-agent-task-config

Conversation

@atlas-from-plumb

@atlas-from-plumb atlas-from-plumb commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #522

Defect

On a shared plumb serve connection, an agent that pins its own shard to project B (session_start, agent scope) while the connection stays on project A:

  • run_task and mutation_test resolved A's [tasks.<lang>] block (commands, working_dir, env) and ran it against B's root. A's working_dir = "plumb" sent the agent's build into <B>/plumb, which does not exist.
  • run_command resolved A's [[command]] allow-list and A's exec trust, so A's trusted scripts ran in B, and B's own entries were invisible.
  • topology_affected built test targets relative to A's working_dir, and session_start described A's task surface.

The failure was then reported as fork/exec /opt/homebrew/bin/golangci-lint: no such file or directory. Go reports a missing cmd.Dir as a missing binary, so the user goes looking for a PATH problem when the binary is fine.

I reproduced both halves live while working on this PR. My agent was pinned to this worktree and the connection to the ops checkout. run_task build failed with fork/exec /opt/homebrew/bin/go: no such file or directory, and mutation_test refused to start because it had run go build in <worktree>/plumb.

Cause

PLAN-440 item 4 moved the resolvers' working-directory root to the calling agent (workspaceFor(ctx)). The blocks the root is combined with still came from s.view(), which applyProjectConfig fills for the connection's root only. Task trust was already checked against the agent's root, because taskProvenance and IsTrustedForTasks take ws. Command trust was not: v.execTrusted is the connection's.

Fix

  • projectViewFor(ctx) (new, internal/cli/conn_project_view.go). When the calling agent's effective root differs from the connection's, it loads that root's project config once. It returns the view with [tasks.<lang>], [[command]], [command_policy], the exec trust and command provenance from that load, plus the shard's language. A call on the connection's own root gets s.view() unchanged, so single-agent connections, unattributed calls and agents on the connection's project take the same path as before. The commands and the trust that gates them still come from one load, which keeps the "authorise the content you run" property conn_commands.go relies on. An unreadable config falls back to the global config, as applyProjectConfig does, and never to the connection's project.
    • Why per call and not cached on the shard: these calls are rare next to the commands they start, a per-call load cannot go stale, and it needs none of the connection's watch and reload machinery.
  • The task resolver, the command resolver (including require_sandbox), testScope and taskState all read it. testScope and taskState now take ctx, so TopologyAffected.WithTestScope and SessionStart.WithTasks take func(context.Context) ….
  • A missing working directory is named. RunArgv and RunTaskArgv stat the directory before exec. If it is missing or not a directory they return a *WorkingDirError (working directory <dir> does not exist). TaskCommand.WorkingDirSource records the setting the directory came from, for example [tasks.go] working_dir = "plumb" in <config path>, naming the project or global config. run_task and mutation_test append it as (from …). run_command gets the directory-naming error. The plumb build|test|… CLI always runs from the root, so it cannot reach this case.

Behaviour change to note

An agent in a git worktree now gets that worktree's project config under that worktree's plumb trust, the same rule a connection-level pin into the worktree already follows. Project-supplied task or command overrides therefore need plumb trust in the worktree. plumb's own GOTMPDIR entry is exempt, so run_task works in plumb worktrees with no grant.

Not changed

The [git] policy stays per connection. The git tool takes an explicit repo that can already point outside the pinned project under the connection's policy, so tying the policy to the agent's pin is a separate design question. It would also switch off allow_push for agents in untrusted worktrees. That question should be its own issue.

Verification

  • Regression tests first, red on the pre-fix code, reproducing the issue:
    • internal/cli/conn_project_view_test.go: an agent in B resolves B's (default) build at B, not go build -v in <B>/sub. B's untrusted [tasks] and [[command]] are refused on B's trust, and B's trusted ones run. A's [[command]] does not resolve in B. testScope and taskState describe B. WorkingDirSource names B's config.
    • internal/tools/cmdexec_workdir_test.go: the RunArgv, RunTaskArgv, not-a-directory, run_task and mutation_test paths. The red output before the fix was the exact symptom, fork/exec /bin/echo: no such file or directory.
  • Positive controls: an unattributed call and an agent pinned to the connection's own project still resolve A's command, A's working_dir, A's [[command]] and A's test scope. An existing or empty working directory still runs.
  • TestRunCommandResolvesTheCallingAgentsRoot was updated. Its worktree now carries (and trusts) its own copy of the project config, as a real checkout does.
  • Mutation testing: I ran 17 mutants that each revert part of the fix; all 17 were KILLED. They covered: projectViewFor returning the connection view; each resolver and accessor reading s.view(); dropping each override of tasks, commands, exec trust and command provenance; a missing or always-global WorkingDirSource; dropping the pre-exec directory check and the not-a-directory branch; and dropping the (from …) suffix in run_task and in mutation_test. The first run left the provenance mutant SURVIVED. TestRunCommand_AgentPinnedElsewhereRunsGlobalCommandsAsGlobal now pins it, and a re-run KILLED it. I mutated by hand from a committed tree, restored each file with git checkout, and confirmed a clean tree afterwards. plumb's own mutation_test could not be used: the live daemon predates this fix, and its compile gate ran in <worktree>/plumb, which is this bug.
  • CI-style runs with GOTMPDIR=$PWD/.testcache: go test ./... passed (55 packages). XDG_DATA_HOME was pointed at a temp directory: without that, TestWriteSessionCollabPolicy_SilentWithoutPeers and TestOnProtocolNegotiated block on the live daemon's session-directory flock, which is local contention and not caused by this change. -race on the affected tests in internal/tools and internal/cli passed. golangci-lint run ./... reported 0 issues. make check-size check-brief check-changelog passed.

Review follow-ups (folded in)

  • Malformed agent config. It falls back to the global config, never to the connection's view. TestProjectView_MalformedAgentConfigNeverBorrowsTheConnections pins this; before, a mutant that returned the connection view passed the whole suite.
  • Re-pin window. applyProjectConfig now records the root it loaded the config for, in a new sessionView.configRoot. projectViewFor's fast path checks that root, not acquiredRoot. A connection re-pin moves acquiredRoot before the new config is applied, so a call made in between could pair the new root with the old project's [[command]] list and trust. TestProjectView_RepinWindowDoesNotPairNewRootWithOldConfig sets up that state exactly, so the test is deterministic. A view that has never had a project config applied holds only the global config, so it is still used as is.
  • agent_config writes the calling agent's project. Only the connection's own project is re-applied into the connection's view, because any other root is read per call. Whether writes are enabled still comes from the connection's view: agent_config_writes is ClassForcedGlobal, so it resolves to the same value in every project. Tested in both directions.
  • Boundary. The assumption that project A's [workspace] extra_roots/read_roots widen an agent's boundary in B doesn't hold. Those fields and allow_dependency_reads are ClassForcedGlobal, so no project config ever sets them, trusted or not. The only per-project widening is the roots the user granted (WorkspaceRootsStore), and buildAgentPolicy already reads those for the agent's own root. TestAgentBoundary_GrantedRootsFollowTheAgentsRoot pins both directions: A's granted root and A's configured extra_roots are refused in B, and B's granted root is allowed.
  • CHANGELOG. It now says a project's own [[command]] entries and task overrides in a worktree need plumb trust there.
  • Mutation testing of the follow-ups: 8 of 8 mutants KILLED. They covered: the malformed-config fallback; the fast path keyed on acquiredRoot again; configRoot never recorded; agent_config writing the connection's workspace; agent_config re-applying any root, or never re-applying; the agent boundary built for the connection's root; and projectViewFor always returning the connection view. I re-ran them after merging main (fix(session_start): report which pin a re-pin moved (#517) #533, fix: shared-daemon friction — mutation_test slot holder, pre-commit lint, workspace_sessions timeout #557) and all were still KILLED.
  • Merged main twice with merge commits (fix(session_start): report which pin a re-pin moved (#517) #533, fix: shared-daemon friction — mutation_test slot holder, pre-commit lint, workspace_sessions timeout #557). The one conflict was the SessionStart struct: I kept main's RepinReport field and this branch's tasksFn that takes a ctx. fix(roots): no attach after close; coalesce roots/list_changed (#514) #534 is still open. After the final merge, go test ./... passed (55 packages), -race passed on the affected tests, and golangci-lint reported 0 issues.

🤖 Generated with Claude Code

atlas-from-plumb and others added 8 commits October 1, 2026 09:24
… working dir

On a shared plumb serve connection, an agent that pinned its own shard to
project B while the connection stayed on project A got its working
directory's root from its shard but everything else from the connection's
sessionView: run_task and mutation_test ran A's [tasks.<lang>] commands,
working_dir and env against B's root, and run_command ran A's [[command]]
allow-list under A's exec trust. A's working_dir = "plumb" sent the agent's
build into <B>/plumb, which does not exist.

projectViewFor resolves the project-scoped exec blocks ([tasks.<lang>],
[[command]], [command_policy] and the exec trust loaded with them) for the
calling agent's effective workspace, from one load, so the gate still
authorises exactly the content it runs. A call on the connection's own root
keeps the connection's view untouched, so a single-agent connection and an
agent on the connection's project behave as before. The task resolver, the
command resolver, topology_affected's test scope and session_start's task
section all read it; the latter two now take the call's ctx.

The exec path also checks the working directory before starting the child.
os/exec reports a missing cmd.Dir as the binary missing ("fork/exec
/opt/homebrew/bin/go: no such file or directory"), which sent the caller
hunting for a PATH problem. RunArgv and RunTaskArgv now refuse with a
WorkingDirError naming the directory, and run_task and mutation_test add the
working_dir setting and config file it came from.

Fixes #522.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: giant-bison
…t elsewhere

A mutation run found projectViewFor's command-provenance override
unpinned: dropping it left the connection project's "has [[command]]
entries" flag on the agent's view, which put the user's own global
command behind project B's trust. Every existing test still passed.
This test fails without the override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: giant-bison
These follow-ups come from the independent review of #555.

The fast path in projectViewFor used the connection's view whenever the
caller's root equalled acquiredRoot. A connection re-pin moves acquiredRoot
before applyProjectConfig swaps in the new root's config. A call made in
between therefore got the new root paired with the old project's
[[command]] list and exec trust. applyProjectConfig now records the root it
loaded (configRoot), and the fast path checks that instead. A view that has
never had a project config applied holds only the global config, so it is
still used as is.

agent_config wrote to s.workspace(), which is the connection's project. An
agent pinned to B that set tasks.go.lint changed A's config, and its own
run_task never saw the change. The write now goes to the calling agent's
workspace. Only the connection's own project is re-applied into the
connection's view; any other root is read per call. Whether writes are
enabled still comes from the connection's view, because
agent_config_writes is ClassForcedGlobal and resolves to the same value in
every project.

The new tests cover:
- an agent whose own config is malformed: before, a mutant that fell back to
  the connection's view passed the whole suite;
- the re-pin window, reproduced deterministically;
- agent_config writes in both directions;
- the agent's boundary.

The review assumed project [workspace] extra_roots widen an agent's
boundary. They cannot: those fields are ClassForcedGlobal, so no project
config ever sets them. buildAgentPolicy already keys the user-granted roots
store on the agent's own root. The boundary test pins both directions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: giant-bison
…ask-config

#533 changed session_start's struct (repin now returns a RepinReport) and
repinAgent's return values. Conflict resolved by keeping main's struct with
this branch's ctx-taking tasksFn, and updating the new #522 test helper to
repinAgent's three return values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: vivid-mink
…ask-config

Picks up #557. Merged cleanly; mutation_test's start-error path keeps the
working_dir origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: vivid-mink
golimpio
golimpio previously approved these changes Oct 1, 2026

@golimpio golimpio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two independent review rounds: round 1 found no blockers, and the fold-ins were checked with race and mutation controls, with no blockers. Follow-up for the pre-existing stale apply in agent_config to be filed separately.

Merging main (#534) into this branch took internal/cli/conn.go to 602
lines and check-size failed on both verify jobs. Condense the configRoot
field comment; the meaning is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: teal-dingo

@golimpio golimpio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-approving after a comment-only commit that brings conn.go back within the 600-line cap after the main merge.

@atlas-from-plumb
atlas-from-plumb merged commit 722210e into main Oct 1, 2026
9 checks passed
golimpio pushed a commit that referenced this pull request Oct 1, 2026
Brings in #555 (per-agent task config). No conflicts.

Plumb-Session: golden-moose
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.

run_task on an agent-level pin uses the connection's project config; a missing working dir is reported as a missing binary

2 participants