# feat\(server\): work a linked environment starts stays within its link · PR \#16695 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/16695)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

State: open

Source repository: pingdotgg/t3code

Source branch: t3code/peer/link-origin

Target branch: t3code/peer/forwarding

Head commit: e7be3191310fe8c8ee8493338a2e98665a999d4d

[Discussion and reviews](https://git.cafe/pingdotgg/t3code/pull/16695)

[Commits](https://git.cafe/pingdotgg/t3code/pull/16695/commits?format=markdown)

[Files changed](https://git.cafe/pingdotgg/t3code/pull/16695/diffs?format=markdown)

## Description

---

Part of cross-environment orchestration. #16653 caps the modes of work that a linked environment starts here. Within those modes, though, the link could still steer the user's own threads or change this environment's projects and settings. This fences work that a link starts to that link, following the `peerOrigin` idea from #15966.

**A full-access link is not fenced.** An agent a link starts runs as this environment's user, so at full access the fence only ever stopped T3's own tools, never what the agent could do with a shell. It also kept a host from driving machines it fully trusts: answering a question or steering a thread the user started there, updating a project, or getting a working worktree, since the setup script was skipped. The stamp now records the access the link was paired with (`linkOrigin.access`), and only a link below full access is fenced. Full-access work still carries the stamp, so clients still show "From <machine>". A stamp from before access was recorded stays fenced.

**How a session is known to be a link**
- #16655's linking side now registers its OAuth client with the `t3code-peer-link` software id.
- The receiving side carries that marker through the signed client id and the authorization code into the session's signed claims (`plk`), then into the MCP caller (`client.linked`).
- An ordinary outside agent, such as Claude Code, is not a link and is not fenced. Claiming the marker only ever adds limits.

**Stamping**
- `OrchestrationV2AppThread` and the thread shell gain an optional, immutable `linkOrigin: {sessionId, label, access}`.
- **What carries it:**
  - Threads a link session launches (`t3_thread_launch`, via `startsThreads` handing the handler `linkOrigin`).
  - Subagents and forks, which already spread their source thread.
  - `create_threads`, which copies its parent's stamp.
- **What can't set it:** a client's own `thread.create` over the WebSocket has `linkOrigin` stripped in `withCreationProvenance`.

**Rules for a link below full access** (`mcp/linkOrigin.ts`, applied in the shared declarations in `McpToolAccess`, not tool by tool)
- **`writesThreads`:** a linked caller may change only threads with the same `linkOrigin` session. That rules out the user's own threads and another link's.
- **`writesEnvironment`:** changing projects and settings is refused for a linked caller. Adding one (`t3_project_create`, `t3_project_clone`) is allowed, though below full access it still needs a full-access caller.
- **`writes`:** refused for linked callers, except attachment uploads and discards, which their own sends need.
- **`startsThreads`:** each tool now has to say whether linked work is stamped or refused. `t3_thread_launch` is stamped. `schedule_task` is refused, because a scheduled run starts later with nothing to carry the link.
- Reads are not fenced.
- **Setup scripts:** `ProjectSetupScriptRunner.runForThread` skips threads with a fenced stamp, so the launch, worktree-handoff and PR-checkout paths all obey it. The worktree handoff reports it as `skipped`. Opting in at pairing is left for later; the default is off.
- **Docs:** `docs/internals/remote.md` gets the trust model, and says this is a routing rule, not OS isolation.

**Verification**
- `mcp/linkOrigin.test.ts` runs a real orchestrator (replay harness, in-memory SQLite) behind the production orchestrator, thread and project toolkits. Two tests:
  - **Stamping.** A launch by a linked session is stamped with that session. The real subagent builder (`makeSubagentChildThread`) and the real fork planner (`ThreadForkService.plan`) carry the stamp to child and fork. An ordinary outside agent's launch is not stamped.
  - **Fencing**, through an `auto` link. The link can rename its own thread. It is refused (`capability_denied`) on the user's own thread, and another link is refused on it; the user's thread keeps its title. An ordinary outside agent can still rename the user's thread. `t3_project_update` and `delete_scheduled_task` are refused for the link. `t3_thread_read` on the user's thread still works.
- `mcp/linkOrigin.test.ts`, a third test: a full-access link's launch is stamped with its access, and the link may rename the user's own thread, update a project and add one.
- `mcp/McpToolAccess.test.ts`: `writes` and `writesWithCapability` refuse a linked client below full access and run for a full-access one.
- `project/ProjectSetupScriptRunner.test.ts`: the real runner skips a thread stamped by an `auto` link and opens no terminal, and runs the script for one stamped by a full-access link.
- `orchestration-v2/ThreadManagementService.test.ts`: a client `thread.create` carrying a forged `linkOrigin` loses it.
- `peer/PeerForwarding.test.ts` (#16684): B's fixture thread is now one the laptop's real link session started, so forwarded sends still land. That's the fence passing end to end over HTTP.
- Mutation-checked: each of these fails its test when removed:
  - the decider's stamp;
  - the launch handler's stamp;
  - the same-link check;
  - comparing by session rather than "any stamp";
  - the environment-write fence;
  - the scheduled-task fence;
  - the setup-script skip;
  - subagents keeping the stamp;
  - the client strip.
- Also ran all of `mcp`, `peer`, `auth`, `project` and `cli`, plus `ThreadManagementService`, `ThreadLaunchService`, `ThreadForkService`, `ProjectionStore` and `GitManager`: 74 files, 924 tests, all passing. Server, contracts, client-runtime and web typecheck clean.

**Review fixes** (bots plus two adversarial reviews)
- **No third environment below full access.** Work a fenced link started here can't use this environment's own links: `PeerForwarding` refuses before anything leaves, since the next environment would see this one's session, not the link the work came from. Tested for read, send and launch; no bearer reaches the box.
- **Own id is still here.** A linked caller naming this environment's own id is fenced like any local target (layer 4's fix, tested here).
- **Only fenced peers.** A new `linkFence` capability; linking requires the peer to advertise it, so a peer running only the earlier layers is refused.
- **Setup scripts fail closed.** If the thread's origin can't be read, nothing runs.

Opus 5.5 via Claude Code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/pingdotgg/t3code/pull/16695" target="_blank"><picture><source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-devin-review-dark.svg?v=4"><img src="https://static.devin.ai/assets/gh-devin-review-light.svg?v=4" alt="Devin Review"></picture></a>
<!-- devin-review-badge-end -->

