Skip to content

refactor(harness-pi): drop the workspace mirror and make session start sandbox-free - #23103

Closed
gdaybrice wants to merge 12 commits into
vercel:mainfrom
gdaybrice:gdaybrice/pi-drop-workspace-mirror
Closed

gdaybrice wants to merge 12 commits into
vercel:mainfrom
gdaybrice:gdaybrice/pi-drop-workspace-mirror

Conversation

@gdaybrice

@gdaybrice gdaybrice commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Background

At session start the harness copied the sandbox workspace to the host and patched Node's fs module to serve that copy at the sandbox path. The copy existed only so Pi's filesystem discovery could find AGENTS.md, CLAUDE.md and project skills, at the cost of copying the workspace and a process-wide fs patch. Session start also resolved the sandbox HOME and every fileToolPathPolicy denied root, so even a turn that never touches a file made sandbox calls.

Summary

  • The mirror, the fs patch and the host probe are deleted. Native file tools resolve relative paths against the sandbox work directory directly, and Pi reads no project settings, context files or skills from the host.
  • A new resources.skills setting covers skills that already exist in the sandbox: they are listed to the model by name and description, must exist at filePath, and are read with the read tool. Skills passed to HarnessAgent keep working as before. There is no setting for context files; project context goes in instructions, which already reaches the system prompt.
  • HOME, the canonical denied roots, the path mapper and the remote ops are resolved the first time a native file tool runs or a turn has skills to write, and the resolution is retried on the next use if it fails. A turn without skills or native file tool calls makes no sandbox calls.
  • fileToolPathPolicy from feat(harness-pi): add configurable writable sandbox roots for Pi's native file tools #18632 keeps working without the mirror: readable, writable and denied roots are checked against sandbox paths only, denied roots first, and writable roots reach Pi through the same first-use resolution. The two feat(harness-pi): add configurable writable sandbox roots for Pi's native file tools #18632 tests that exercised host mirror paths are removed with the mirror.

Behavior changes for existing users

  • Pi no longer picks up AGENTS.md, CLAUDE.md or project skills from the sandbox workspace on its own. Put project context in instructions, and list sandbox skills through resources.skills.
  • Inline extensions no longer see a host copy of the workspace, including when the sandbox shares the host filesystem. They read project files through session.sandboxSession (feat(harness-pi): pass the harness session to extension factories #23100).
  • A denied root that cannot be resolved now fails the native file tool call that needs it instead of failing session start. Canonical denied roots are captured before the first native file tool runs rather than at session start.

End-to-End Verification

Run against the real Pi runtime through HarnessAgent, with a local just-bash sandbox whose calls were logged and Claude Haiku 4.5 over the AI Gateway, on this branch merged with current main.

  • A chat-only turn: Pi makes no sandbox call. The only calls at session start are HarnessAgent's own work directory mkdir and onSession, which feat(harness): defer sandbox setup to first use for harnesses without a bootstrap recipe #23101 defers.
  • A turn that runs bash: the command's output comes back (VERIFY-42).
  • With fileToolPathPolicy: { writableRoots: ['/tmp/pi-out'] }, the model writes /tmp/pi-out/hello.txt with the write tool and the file in the sandbox has the requested content.
  • resources.skills is covered by a test that runs real Pi against a scripted model; it was not run against a live model.

Checklist

  • All commits are signed (PRs with unsigned commits cannot be merged)
  • Tests have been added / updated (for bug fixes / features)
  • Documentation has been added / updated (for bug fixes / features)
  • A patch changeset for relevant packages has been added (for bug fixes / features - run pnpm changeset in the project root)
  • I have reviewed this pull request (self-review)

Related Issues

Split out of #22289.

An `extensionFactories` entry received only Pi's `ExtensionAPI`, so an
extension could not reach the sandbox the session runs against or the
instructions the caller set for the turn. A Task-style tool that runs a
child Pi session against the parent's sandbox with the parent's
instructions had no way to get either.

Each factory now gets a second argument, `PiHarnessExtensionSession`:
the restricted sandbox session (the same view the builtin tools use), the
session work directory, and an `instructions()` getter. Instructions
arrive per turn and the factory runs before the first turn, so a getter
over the session's current instructions is the only shape that stays
correct. A second argument keeps every existing `(pi) => void` factory
assignable; the session wraps each factory in a one-line adapter before
handing the list to Pi's resource loader.
`mcpServers` was served by pi-mcp-adapter, a third-party Pi extension
loaded through a dynamic import, with `mcpSettings` (vercel#22196) steering its
tool prefix and output guard. Pi 1.0 ships MCP as a built-in extension
with a `tool_search` tool for deferred exposure, so the adapter is a
second MCP client in the process with its own naming and no deferred
mode. This removes it and wires the native extension.

When servers are configured the session loads `createToolSearchExtension()`,
`createMcpExtension()` and one factory that registers every entry with
`pi.registerMcpServer`. The extension gets `loadConfig` returning no
servers and `logPath: devNull`, so no mcp.json is read and no mcp.log is
written under Pi's global agent directory; OAuth never runs for servers
with an `Authorization` header, so mcp-auth.json is never touched either.
`exposure` defaults to `direct` because Pi's own default, `codemode`,
needs an extension the harness does not load. `mcpServers` entries are
typed as Pi's `McpServerConfig` with that narrower exposure, exported as
`PiMcpServerConfig`, so adapter-only fields stop compiling instead of
being passed through. The `session_shutdown` emit on dispose stays: Pi's
`dispose()` does not emit it and the native extension closes its
connections only from that event.

Native names are always `mcp__<server>__<tool>` and results over 20 KB
are always truncated with the full text spilled to the host temp dir, so
`mcpSettings.toolPrefix` and `outputGuard` are deprecated; a value the
native extension cannot honor fails session start rather than silently
changing the model-facing tool names. The translator drops the adapter's
`mcp` proxy-tool case and keeps the `mcp__` prefix classification.
`@earendil-works/pi-ai` leaves the dependencies with the adapter; nothing
in the package imported it.

Tests: the mocked session tests assert the native wiring, and a new
pi-mcp.test.ts runs real Pi against a hand-rolled streamable-HTTP MCP
server and a scripted OpenAI-compatible model: direct tools are declared
on the first request, a deferred tool is loaded by `tool_search` and
streams under its real name, every MCP request carries the bearer header,
destroy sends DELETE for each MCP session, and the agent dir holds no MCP
files.
…workspace-mirror

The mirror removal takes away the host copy of the workspace that inline extensions could read. The extension session is the replacement, so this branch builds on it.
…ls from settings

Before, session start probed whether the sandbox workspace was visible on the host and, when it was not, copied the workspace into a host directory and patched Node's fs module so Pi's own resource discovery found AGENTS.md, CLAUDE.md and project skills at the sandbox path. Each turn re-synced the copy. The path mapper resolved relative tool paths through that host directory.

This deletes the mirror, the fs patch and the host probe. Pi now runs with noSkills and noContextFiles, its settings manager points at a project directory that never exists, and the new resources setting supplies sandbox skills (listed through skillsOverride, with each skill directory added to the readable roots so the read tool can open SKILL.md). Project context that AGENTS.md or CLAUDE.md carried goes in instructions; the setting has no context-file field because instructions already reaches the system prompt. The path mapper resolves relative paths against the sandbox work directory alone.

The mirror existed only to feed Pi's filesystem discovery, at the cost of copying the workspace and patching a Node built-in. Passing skills as data keeps Pi off the host filesystem and makes the session's prompt inputs explicit. Inline extensions that read the host copy must read through session.sandboxSession instead.
…tart makes no sandbox calls

Before this commit, createPiSession resolved the sandbox HOME and every fileToolPathPolicy denied root in the sandbox, then built the path mapper and remote ops, before it returned. Every turn also ran writeSkills, which reads the skills manifest from the sandbox even when the turn has no skills. So a session start and a chat-only turn each cost sandbox round trips, and an unresolvable denied root failed session start.

Now HOME, the canonical denied roots, the path mapper and the remote ops form one record that resolveSandboxPaths creates the first time something needs it: a native file tool call, a turn with skills to write, or the legacy session-file pull and persist. Concurrent callers share the in-flight resolution, and a failed resolution is dropped so the next use retries it. Native tools receive a getter for the remote ops instead of the ops themselves. A turn writes skills only when it has some or when an earlier turn left some to remove, so a chat-only turn makes no sandbox calls.

One lazily resolved record keeps the mapper and ops consistent with the HOME they were built from, and it leaves createPiSession with no sandbox calls before it returns. That is the property the lifecycle-data change in the next commit relies on. An unresolvable denied root now fails the file tool call that needs it, and the docs and the fileToolPathPolicy TSDoc say so.
@gdaybrice
gdaybrice force-pushed the gdaybrice/pi-drop-workspace-mirror branch from 3b5649e to 2e1b428 Compare October 12, 2026 10:40
Review found the provider docs page still described one-argument
extension factories, and the README example claimed to use all three
session fields while using two.

The provider page now lists `sandboxSession`, `sessionWorkDir` and
`instructions()` with an example, and both examples run a command in the
session's sandbox so each field appears. The example type-checks against
`createPi`.

`extensionSession` moves above the comment about MCP servers, which it
had been inserted beneath.
…Pi's stored sign-ins out

Review found four gaps between what the native MCP change promised and
what it enforced. Each has a test that fails on the previous commit.

A server without an `Authorization` header was sent the OAuth token of
whoever had signed in to it with the Pi CLI on the same machine, because
Pi's MCP extension defaults its credential store to `mcp-auth.json` in
the host agent directory. The extension now gets an in-memory store, so
the file is neither read nor written.

An entry Pi rejects no longer starts a session without that server. Pi
records an error thrown by an extension factory and carries on, so the
registration factory keeps the error and session start rethrows it after
the first resource load.

`exposure: 'codemode'`, its `codemode-deferred` alias and the same values
in `toolExposure` now fail session start. The type already excluded them,
but `mcpServers` is typed `Record<string, unknown>` at the public
boundary, and the tools were unreachable because the harness does not
load Pi's codemode extension.

An entry with `exposure: undefined` is served as `direct`. The spread
that applied the default let the explicit `undefined` win, and Pi then
fell back to codemode.

The provider docs page still described the adapter's `mcpSettings`; it
now describes native MCP. The README and changeset say that an HTTP
server needs credentials only when it requires authentication.
…workspace-mirror

# Conflicts:
#	packages/harness-pi/src/pi-session.ts
felixarntz added a commit that referenced this pull request Oct 14, 2026
…3100)

## Background

An `extensionFactories` entry received only Pi's `ExtensionAPI`, so an
extension could not reach the sandbox the session runs against or the
instructions the caller set for the turn. A Task-style tool that runs a
child Pi session against the parent's sandbox with the parent's
instructions had no way to get either. #23103 also builds on this: it
removes the host copy of the workspace, which leaves
`session.sandboxSession` as the only way an extension can read project
files.

## Summary

- Each factory now receives a second argument,
`PiHarnessExtensionSession`: the restricted `sandboxSession` (the same
view the builtin tools use), the `sessionWorkDir`, and an
`instructions()` getter.
- Instructions arrive per turn and the factory runs before the first
turn, so the getter reads the session's current instructions rather than
a value captured at creation.
- Existing `(pi) => void` factories stay assignable and keep working
unchanged.

## End-to-End Verification

Run against the real Pi runtime through `HarnessAgent`, with a local
just-bash sandbox and Claude Haiku 4.5 over the AI Gateway. The same
scenario was run on `main` as the control.

- Registered an extension factory that records its second argument and
ran a turn with instructions containing a token. On `main` the factory
receives nothing. On this branch it receives the session work directory,
a sandbox session, and `instructions()` returns the turn's instructions
including the token.

## Checklist

- [x] All commits are signed (PRs with unsigned commits cannot be
merged)
- [x] Tests have been added / updated (for bug fixes / features)
- [x] Documentation has been added / updated (for bug fixes / features)
- [x] A _patch_ changeset for relevant packages has been added (for bug
fixes / features - run `pnpm changeset` in the project root)
- [ ] I have reviewed this pull request (self-review)

## Related Issues

Split out of #22289.

---------

Co-authored-by: Felix Arntz <felix.arntz@vercel.com>
…orkspace-mirror

# Conflicts:
#	content/providers/02-ai-sdk-harnesses/03-pi.mdx
#	packages/harness-pi/README.md
#	packages/harness-pi/src/index.ts
#	packages/harness-pi/src/pi-harness.test-d.ts
#	packages/harness-pi/src/pi-harness.ts
#	packages/harness-pi/src/pi-mcp.test.ts
#	packages/harness-pi/src/pi-paths.test.ts
#	packages/harness-pi/src/pi-paths.ts
#	packages/harness-pi/src/pi-session.test.ts
#	packages/harness-pi/src/pi-session.ts
…f allowed roots

Writable and readable paths go through one check with the denied roots first, the read tool uses the session work directory directly, and the session tests drop the file-system imports the workspace mirror needed.
@vercel-security-reviewer

Copy link
Copy Markdown
Contributor

Security review details

@gdaybrice

Copy link
Copy Markdown
Contributor Author

Superseded by #23370, which combines the workspace-mirror removal and the Pi session in lifecycle data into one PR against main.

@gdaybrice gdaybrice closed this Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

harness/pi Issues related to @ai-sdk/harness-pi task-identify-harness-labels-done

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant