@jr2/orchestrator 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/docs/agents-block.md +2 -2
  2. package/docs/concepts/agents-and-turns.md +56 -20
  3. package/docs/concepts/architecture.md +30 -17
  4. package/docs/concepts/charts.md +6 -3
  5. package/docs/concepts/converge.md +3 -0
  6. package/docs/concepts/effects.md +86 -31
  7. package/docs/concepts/machines-and-workflows.md +9 -0
  8. package/docs/concepts/pools-and-sources.md +6 -6
  9. package/docs/concepts/secrets-and-trust.md +126 -42
  10. package/docs/concepts/the-menu.md +3 -0
  11. package/docs/concepts/workspaces.md +129 -51
  12. package/docs/get-started/an-agent-in-a-sandbox.md +21 -19
  13. package/docs/get-started/have-your-assistant-write-a-machine.md +111 -40
  14. package/docs/get-started/install.md +4 -0
  15. package/docs/guides/add-tools-to-the-sandbox.md +17 -4
  16. package/docs/guides/bind-open-parts.md +5 -4
  17. package/docs/guides/cut-a-worktree.md +560 -0
  18. package/docs/guides/fix-a-failed-converge.md +1 -1
  19. package/docs/guides/let-an-agent-write-a-chart.md +5 -1
  20. package/docs/guides/look-inside-a-sandbox.md +18 -15
  21. package/docs/guides/move-from-kind-to-a-shared-cluster.md +16 -0
  22. package/docs/guides/push-and-sign-a-branch.md +67 -21
  23. package/docs/guides/run-a-command-or-call-a-url.md +84 -41
  24. package/docs/guides/run-one-worker-per-item.md +19 -18
  25. package/docs/guides/set-an-agents-access-and-dials.md +67 -22
  26. package/docs/guides/size-a-workspace.md +19 -6
  27. package/docs/guides/use-a-hosted-provider.md +6 -0
  28. package/docs/guides/watch-a-run.md +47 -24
  29. package/docs/guides/write-a-workflow.md +1 -1
  30. package/docs/llms.txt +33 -12
  31. package/docs/reference/authoring-surface/agent.md +34 -11
  32. package/docs/reference/authoring-surface/answer.md +19 -3
  33. package/docs/reference/authoring-surface/arg.md +27 -5
  34. package/docs/reference/authoring-surface/branchname.md +15 -0
  35. package/docs/reference/authoring-surface/command.md +36 -21
  36. package/docs/reference/authoring-surface/fail.md +17 -4
  37. package/docs/reference/authoring-surface/file.md +36 -16
  38. package/docs/reference/authoring-surface/http.md +108 -8
  39. package/docs/reference/authoring-surface/isready.md +14 -0
  40. package/docs/reference/authoring-surface/jr2setup.md +1 -1
  41. package/docs/reference/authoring-surface/open.md +36 -5
  42. package/docs/reference/authoring-surface/paramsof.md +22 -4
  43. package/docs/reference/authoring-surface/pool.md +4 -4
  44. package/docs/reference/authoring-surface/push.md +30 -12
  45. package/docs/reference/authoring-surface/readyof.md +21 -0
  46. package/docs/reference/authoring-surface/secret.md +25 -4
  47. package/docs/reference/authoring-surface/source.md +3 -3
  48. package/docs/reference/authoring-surface/type-agentdefinition.md +2 -2
  49. package/docs/reference/authoring-surface/type-agentslots.md +4 -1
  50. package/docs/reference/authoring-surface/type-anyframepath.md +14 -0
  51. package/docs/reference/authoring-surface/type-anyworktreepath.md +15 -0
  52. package/docs/reference/authoring-surface/type-bindactors.md +14 -0
  53. package/docs/reference/authoring-surface/type-binding.md +5 -6
  54. package/docs/reference/authoring-surface/type-commandinput.md +4 -3
  55. package/docs/reference/authoring-surface/type-commandoptions.md +0 -22
  56. package/docs/reference/authoring-surface/type-commandoutput.md +22 -4
  57. package/docs/reference/authoring-surface/type-customize.md +4 -2
  58. package/docs/reference/authoring-surface/type-dispatchoutput.md +8 -1
  59. package/docs/reference/authoring-surface/type-doorlessinput.md +19 -0
  60. package/docs/reference/authoring-surface/type-filebody.md +4 -8
  61. package/docs/reference/authoring-surface/type-framepath.md +14 -0
  62. package/docs/reference/authoring-surface/type-hostinjectedinput.md +2 -4
  63. package/docs/reference/authoring-surface/type-httpdefinition.md +6 -3
  64. package/docs/reference/authoring-surface/type-httpfileinput.md +20 -0
  65. package/docs/reference/authoring-surface/type-httpoutput.md +7 -1
  66. package/docs/reference/authoring-surface/type-lateroutcome.md +21 -0
  67. package/docs/reference/authoring-surface/type-namestart.md +77 -0
  68. package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +1 -6
  69. package/docs/reference/authoring-surface/type-notworktreekeys.md +12 -0
  70. package/docs/reference/authoring-surface/type-opencommandinput.md +7 -2
  71. package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +1 -6
  72. package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +5 -10
  73. package/docs/reference/authoring-surface/type-poolnextinput.md +22 -0
  74. package/docs/reference/authoring-surface/type-poolspec.md +2 -2
  75. package/docs/reference/authoring-surface/type-pushinput.md +22 -3
  76. package/docs/reference/authoring-surface/type-pushoutput.md +32 -7
  77. package/docs/reference/authoring-surface/type-reposlot.md +5 -5
  78. package/docs/reference/authoring-surface/type-sandboxoptions.md +11 -4
  79. package/docs/reference/authoring-surface/type-slothandles.md +5 -13
  80. package/docs/reference/authoring-surface/type-sourcespec.md +10 -10
  81. package/docs/reference/authoring-surface/type-workspaced.md +29 -8
  82. package/docs/reference/authoring-surface/type-workspacehandles.md +5 -6
  83. package/docs/reference/authoring-surface/type-workspaceoptions.md +1 -13
  84. package/docs/reference/authoring-surface/type-worktreefound.md +16 -0
  85. package/docs/reference/authoring-surface/type-worktreeinput.md +56 -0
  86. package/docs/reference/authoring-surface/type-worktreekey.md +14 -0
  87. package/docs/reference/authoring-surface/type-worktreeoptions.md +29 -0
  88. package/docs/reference/authoring-surface/type-worktreeoutput.md +45 -0
  89. package/docs/reference/authoring-surface/type-worktreepath.md +16 -0
  90. package/docs/reference/authoring-surface/type-worktreeready.md +37 -0
  91. package/docs/reference/authoring-surface/type-worktreerefused.md +21 -0
  92. package/docs/reference/authoring-surface/workspace.md +65 -37
  93. package/docs/reference/authoring-surface/worktree.md +71 -0
  94. package/docs/reference/authoring-surface.md +30 -10
  95. package/docs/reference/cli.md +1 -1
  96. package/docs/reference/config.md +13 -4
  97. package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +10 -9
  98. package/docs/reference/decisions/0002-duplex-streaming-actor.md +17 -10
  99. package/docs/reference/decisions/0004-worktree-provisioning-model.md +27 -21
  100. package/docs/reference/decisions/0005-sandbox-pod-composition.md +29 -19
  101. package/docs/reference/decisions/0006-agent-control-surface.md +6 -3
  102. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +30 -30
  103. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +35 -17
  104. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +8 -2
  105. package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +9 -7
  106. package/docs/reference/decisions/0017-pool-over-a-source-port.md +6 -4
  107. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +21 -9
  108. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +2 -1
  109. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +8 -9
  110. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +62 -65
  111. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +9 -1
  112. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +13 -12
  113. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +4 -1
  114. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +18 -19
  115. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +8 -8
  116. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +4 -4
  117. package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +57 -46
  118. package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +5 -0
  119. package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +20 -8
  120. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +24 -24
  121. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +5 -3
  122. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +110 -67
  123. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +101 -74
  124. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +5 -6
  125. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +2 -0
  126. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +181 -153
  127. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +74 -63
  128. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +31 -20
  129. package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +1 -1
  130. package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +295 -0
  131. package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +320 -0
  132. package/docs/reference/decisions/0077-jr2-requires-landlock-on-every-sandbox-node.md +79 -0
  133. package/docs/reference/decisions.md +3 -0
  134. package/docs/reference/glossary.md +51 -12
  135. package/docs/reference/http-api.md +45 -7
  136. package/docs/reference/machines.md +0 -1
  137. package/docs/why.md +5 -4
  138. package/package.json +3 -3
  139. package/src/actor.ts +161 -35
  140. package/src/agent.ts +6 -6
  141. package/src/ambient.ts +3 -5
  142. package/src/chart.ts +186 -19
  143. package/src/charts.ts +9 -2
  144. package/src/config.ts +16 -6
  145. package/src/custodian.lua +17 -5
  146. package/src/custodian.ts +251 -77
  147. package/src/customize.ts +11 -3
  148. package/src/effect-checks.ts +50 -3
  149. package/src/effect.ts +343 -127
  150. package/src/effector-wire.ts +6 -3
  151. package/src/fail.ts +17 -4
  152. package/src/harness-client.ts +74 -31
  153. package/src/harness-env.ts +33 -0
  154. package/src/held-secrets.ts +125 -16
  155. package/src/holds.ts +3 -0
  156. package/src/http-effect.ts +88 -32
  157. package/src/http.ts +56 -17
  158. package/src/index.ts +15 -1
  159. package/src/internal.ts +1 -0
  160. package/src/kube-client.ts +6 -3
  161. package/src/machine-doc.ts +3 -4
  162. package/src/model-keys.ts +24 -0
  163. package/src/names.ts +19 -6
  164. package/src/notices.ts +443 -54
  165. package/src/open-body.ts +85 -19
  166. package/src/open.ts +36 -5
  167. package/src/parts.ts +40 -28
  168. package/src/party.ts +33 -0
  169. package/src/pick.ts +41 -7
  170. package/src/pool.ts +44 -23
  171. package/src/registration.ts +53 -11
  172. package/src/repo-identity.ts +7 -3
  173. package/src/repos.ts +20 -2
  174. package/src/run-host.ts +106 -20
  175. package/src/sandbox-effect.ts +217 -66
  176. package/src/sandbox-kube.ts +63 -16
  177. package/src/secret-placement.ts +9 -2
  178. package/src/setup.ts +86 -6
  179. package/src/tokens.ts +107 -32
  180. package/src/vocabulary.ts +23 -4
  181. package/src/wire.ts +10 -10
  182. package/src/workspace.ts +264 -253
  183. package/src/worktree.ts +249 -0
  184. package/docs/reference/authoring-surface/type-noslotspec.md +0 -26
  185. package/docs/reference/authoring-surface/type-workspacespec.md +0 -37
@@ -0,0 +1,295 @@
1
+ # A Machine cuts a Worktree, and the Workspace only clones
2
+
3
+ `workspace()` made one branch Worktree for each Repo Slot from `spec.branch`, and one detached Worktree from
4
+ `spec.reviewSha`. So `spec` collected kinds of Worktree, a slot that is only read got a Worktree that no Agent used, and
5
+ a Machine could not run two coders in one clone, retry a review in a clean folder, or name a branch after its plan. Now
6
+ the Workspace only clones. A Worktree is a folder that a Machine names and makes with a kit Effect, `worktree()`.
7
+
8
+ The layout and the branch order follow the conventions that [gwtmux](https://github.com/snapwich/gwtmux) defined: jr2
9
+ does not call gwtmux and does not depend on it. Three designs were built on real files, attacked, and judged: a design
10
+ with the path as the reference (this one), a design with the branch as the reference, and a design with no step that
11
+ makes a Worktree. A prototype of the chosen design passed 62 of 62 `@kind` scenarios in real pods.
12
+
13
+ ## Decision
14
+
15
+ ### The Workspace
16
+
17
+ - **The Workspace clones.** `spec`, `workspace.branch` and `workspace.review` go. `workGroup` is an option of
18
+ `workspace()`: `workspace(body, { workGroup: 2000 })`.
19
+ - **A handle is the slot's folder, and not a Worktree.** `context.workspace.repos.app` is `/work/app`.
20
+ - **`default/` stays on the Binding's base.** A branch is attached, and a tag or a commit is detached. With no base, it
21
+ is the Repo's default branch. A slot that is only read needs nothing more: an Agent reads `/work/<slot>/default`. No
22
+ work happens in `default/`, and a cut of the branch that it holds is `refused`, and the reason names
23
+ `/work/<slot>/default`.
24
+ - **The author chooses how many Agents work in a Workspace.** The rule "one Agent at a time" goes. Agents in one
25
+ Worktree share git as people in one checkout do. Each coder that needs its own branch gets its own Worktree of the one
26
+ clone.
27
+ - **`/work/.jr2/` is reserved for jr2.** A Repo Slot name cannot start with `.`. An Agent can write under `/work`, so
28
+ nothing there is a record that jr2 trusts.
29
+
30
+ ### `worktree()`
31
+
32
+ ```ts
33
+ worktree(); // a local branch, then origin (tracked), then new
34
+ worktree({ expect: "new" }); // refuse a branch that exists
35
+ worktree({ expect: "existing" }); // refuse a branch that does not exist, and make nothing
36
+
37
+ { path: "/work/app/fix", branch: "docs/123", from?: "v3" } // a branch Worktree
38
+ { path: "/work/app/review", detach: sha } // forced and cleaned on each cut
39
+
40
+ { outcome: "ready", slot, path, branch: string | null, head, found, stale? }
41
+ { outcome: "refused", reason } // nothing changed
42
+ { outcome: "unknown", reason }
43
+ ```
44
+
45
+ - **The path is the reference, and the Machine knows it before the cut.** It is exactly `/work/<slot>/<name>`, with one
46
+ segment that is not `default` and starts with neither `.` nor `-`. So a door word or a planner's word does not become
47
+ a folder tree, and a `run` branch and a `run/a` branch never need nested folders.
48
+ - **`branch` is required unless `detach` is given.** The branch is read from the cut's record, never from the folder or
49
+ from HEAD. A Machine can use one word for both. The rule can relax after v1, and the reverse is a break.
50
+ - **A cut resolves a branch in one order, after a fetch:** a local branch, then `origin/<branch>` with tracking, then a
51
+ new branch from `from` or the Binding's base with no upstream. `found` says which: `local`, `remote` or `none`. A path
52
+ that a cut already made needs no fetch, and `found` is `held`. A detached cut says `commit`.
53
+ - **A failed fetch degrades.** `stale` carries ADR-0053's warning, and the cut uses what the clone holds. A name that
54
+ the clone does not hold is `refused`, because the remote cannot confirm that it is new.
55
+ - **`expect` is static**, so a Chart cannot change it. The string can grow. A boolean cannot, and its absent value and
56
+ its `false` would mean different things.
57
+ - **A cut runs on the `command` machinery**: a kit program in the Harness container, no new wire kind, marked
58
+ idempotent. Each thing that an Agent's word can cause settles as `refused`, and never as `onError` (ADR-0067). A cut
59
+ writes a record before it changes git, so `refused` changes nothing and the next cut in the clone repairs a killed
60
+ cut. It never removes a folder that its record does not name. A re-cut that disagrees with the first cut is `refused`.
61
+ Cuts of one clone are serialized by a lock.
62
+ - **A detached cut is forced from its own record** and cleaned with `-ffd`. Ignored files stay, because a reviewer runs
63
+ the tests (ADR-0028).
64
+ - **A cut needs a Repo Slot.** `jr2 up` and the invoke refuse `worktree()` under a Workspace with no Repo Slot, as they
65
+ refuse a `push` (ADR-0051).
66
+ - **v1 has no kit removal.** The docs give a `command` that runs `git worktree remove`.
67
+
68
+ ### What names a Worktree
69
+
70
+ ```ts
71
+ coder: { prompt, cwd: "/work/app/fix" } // required under Repo Slots
72
+ publish: { worktree: "/work/app/fix", sha } // push
73
+ test: { worktree: "/work/app/fix" } // command
74
+ send: { name: "r.md", worktree: "/work/app/fix" } // http with a file body
75
+ ```
76
+
77
+ - **`cwd` and `worktree` are required under Repo Slots.** A Workspace that only clones has no Worktree for a default to
78
+ name, and `/work` is the wrong default: an Agent's search hits `default/`, and a relative write lands outside each
79
+ Worktree. That is the incident in ADR-0057. A Turn that wants `/work` states it. A Workspace with no Repo Slot, and an
80
+ Agent with `workspace: "none"`, need no `cwd`.
81
+ - **`push` has no `to` and no `branch` in its input.** It keeps its static `slot`. The Orchestrator finds the cut's
82
+ settled row in the run's ledger, by the path and this Sandbox, and tells the Effector which branch to publish. An
83
+ Agent can change all of git in the pod, and it cannot change that row. With a read of HEAD, `git switch main` would
84
+ publish `main`. With an input key, a Chart could aim at any remote branch. A push with no row, a row of another slot,
85
+ or a detached Worktree is `rejected` at stage start. The ledger key is (slot, branch). The local name equals the
86
+ remote name, so an Agent, a Gate and a person in the pod all see the real branch.
87
+ - **A `command` that names a secret and an `http` file body need the same row.** A path alone does not name the target
88
+ of a credential.
89
+ - **A bad Worktree does not add an outcome.** A `command` settles `exited` 127 with the reason in its tail, and an
90
+ `http` file body settles `refused`.
91
+ - **A compile error catches a wrong slot.** `Workspaced` types a handle as the literal `/work/<slot>`, and `jr2Setup`
92
+ maps the body's slots to each Worktree input. The compile check is a help and the guarantee is at run time. It sees
93
+ the slot and the first character of the name, under `jr2Setup`, on a literal path. It does not see a path held as
94
+ `string`, a plain `setup()`, a `repos: open` Machine, or a body that is generic in its slots. A helper must return
95
+ `as const`.
96
+
97
+ ### A Chart
98
+
99
+ - A cut lent to a Chart must state `expect`, and a Chart reads it with the kit guard `jr2.ready`.
100
+ - The door refuses `expect: "existing"` beside a lent force push, because together they let a Chart force-push any
101
+ existing branch that `default/` does not hold.
102
+ - The door refuses a `cwd` that is a slot's folder.
103
+ - A Chart's payload has no `pattern`, so a Chart cannot state the branch rule, and the cut refuses a bad name. A
104
+ hand-written Machine's pick param uses the kit schema `branchName`, so a bad name is refused at the Menu. The cut and
105
+ the push share that rule. Names with `@`, `#` or `+` are refused, as the Effector refuses them.
106
+
107
+ ### Output and the walk
108
+
109
+ - **Every Effect outcome is open.** `worktree()`, `push`, `command` and `http` each hold a stand-in member in their
110
+ union, so that no author can write an exhaustive check. A later outcome is then no break.
111
+ - **The load-time walk refuses an Effect's `onDone` with no unguarded last arm.** A type cannot see a missing arm, and
112
+ with no match a run stays in that state and shows no fault.
113
+
114
+ ### Fetches in one clone
115
+
116
+ All Worktrees of a clone share `refs/remotes/origin/*`. A `git fetch` updates a ref only if it still holds the value
117
+ that the fetch read, so two fetches that overlap on a moved remote can fail with
118
+ `cannot lock ref … is at <sha> but expected <sha>`. The failure does no harm: the ref is already newer, and a second
119
+ fetch works. But it costs a Turn, so `jr2-upload-pack` takes a per-clone lock and keeps it until the fetch ends. It
120
+ takes the lock after it asks the node cache, so a dead remote does not make coders wait in line. The program execs
121
+ `git upload-pack`. That process can end before the fetch writes its refs: that happens when a pack moves. In a shared
122
+ clone, git often keeps the connection open to the end. The lock must last to the end of the fetch in both cases, so a
123
+ small child process that watches the fetch holds it, and ends when the fetch ends. The child watches the `git fetch`
124
+ process and not its parent, because `git remote-ext` ends with the connection.
125
+
126
+ A cut keeps its own lock, and fetches under it. A fetch waits only for a fetch: a cut can wait for the fetch lock, and
127
+ nothing that holds the fetch lock waits for a cut, so the two cannot deadlock. A fetch waits at most 30 s in line, then
128
+ runs with no lock, because the lock only helps and must not fail a fetch.
129
+
130
+ | Measured | 4 coders | 8 coders |
131
+ | ---------------------------------------------------------------------- | --------------------- | -------- |
132
+ | Real pod, `ext::` path, 90 s, moving remote, no lock | 29% of fetches failed | 39% |
133
+ | Local, no lock | 8% | 11% |
134
+ | Local, `core.filesRefLockTimeout=5000` | 8% | 11% |
135
+ | Local, lock for the connection only | 3% | 4% |
136
+ | Local, lock held to the end of the fetch | 0% | 0% |
137
+ | Real pod, same load, lock held to the end of the fetch: failed fetches | 0 of 349 | 0 of 725 |
138
+ | Real pod, same load, with the lock: fetch p95 | 1066 ms | 1078 ms |
139
+ | Real pod, same load, with the lock: cut p95 | 4289 ms | 8256 ms |
140
+
141
+ Git's lock timeout does not help, because no lock is busy: the compare fails. If the wait under the lock is too long in
142
+ a pod, jr2 drops this lock and adds a `@model` claim that an Agent runs the fetch again.
143
+
144
+ ### Smaller rules
145
+
146
+ - **A Pool's `next` reads `{ active, settled, input }`.** `input` is the Pool's input and `settled` holds the ids of the
147
+ finished and failed items, so a Source can fan out over a plan with no state of its own.
148
+ - **A wrapper's door is parsed under a parent too**, so a parent that passes less than a `workspace()` or a `pool()`
149
+ requires fails (ADR-0033).
150
+ - **A Sandbox delete waits for its pod**, because a retry can take the name of a pod that is still going away
151
+ (ADR-0021).
152
+ - **An Agent's commits on a side branch are not published**, and `found` says `held`. A warning field can follow after
153
+ v1.
154
+ - **`jr2 status <run>` shows each path and branch.** `jr2 ls` shows only Sandboxes, because it reads cluster labels.
155
+
156
+ ## Example
157
+
158
+ A coder, a detached reviewer, and a signed push of the commit that the reviewer read. The reviewer has its own folder,
159
+ which each round forces to the commit and cleans.
160
+
161
+ ```ts
162
+ type BodyInput = Workspaced<{ prompt: string; branch: string }, "app">;
163
+ const coderTree = (c: BodyInput) => `${c.workspace.repos.app}/work` as const;
164
+ const reviewTree = (c: BodyInput) => `${c.workspace.repos.app}/review` as const;
165
+
166
+ const body = jr2Setup({
167
+ types: {} as { input: BodyInput; context: BodyInput & { reviewed?: string } },
168
+ actors: {
169
+ coder: agent({ model, instructions: "You fix the bug. You commit the fix." }),
170
+ reviewer: agent({ model, workspace: "read", instructions: "You review one commit. You change nothing." }),
171
+ cut: worktree(),
172
+ publish: push({ sign: true, pin: true }),
173
+ },
174
+ }).createMachine({
175
+ initial: "cutting",
176
+ states: {
177
+ cutting: {
178
+ invoke: {
179
+ src: "cut",
180
+ input: ({ context }) => ({ path: coderTree(context), branch: context.branch }),
181
+ onDone: [{ guard: ({ event }) => isReady(event.output), target: "coding" }, { target: "notCut" }],
182
+ },
183
+ },
184
+ coding: {
185
+ invoke: {
186
+ src: "coder",
187
+ input: ({ context }) => ({ prompt: context.prompt, cwd: coderTree(context), continue: true }),
188
+ },
189
+ on: { finished: "reading" },
190
+ },
191
+ // A cut is idempotent, so it also reads a Worktree. `head` is the tip of the branch, whatever HEAD the coder left.
192
+ reading: {
193
+ invoke: {
194
+ src: "cut",
195
+ input: ({ context }) => ({ path: coderTree(context), branch: context.branch }),
196
+ onDone: [
197
+ {
198
+ guard: ({ event }) => isReady(event.output),
199
+ target: "detaching",
200
+ actions: assign({ reviewed: ({ event }) => readyOf(event.output).head }),
201
+ },
202
+ { target: "notCut" },
203
+ ],
204
+ },
205
+ },
206
+ detaching: {
207
+ invoke: {
208
+ src: "cut",
209
+ input: ({ context }) => ({ path: reviewTree(context), detach: context.reviewed! }),
210
+ onDone: [{ guard: ({ event }) => isReady(event.output), target: "reviewing" }, { target: "notCut" }],
211
+ },
212
+ },
213
+ reviewing: {
214
+ invoke: {
215
+ src: "reviewer",
216
+ input: ({ context }) => ({ prompt: `Review ${context.reviewed}.`, cwd: reviewTree(context) }),
217
+ },
218
+ on: { pass: "publishing", revise: "coding" },
219
+ },
220
+ publishing: {
221
+ invoke: {
222
+ src: "publish",
223
+ input: ({ context }) => ({ worktree: coderTree(context), sha: context.reviewed! }),
224
+ onDone: [{ guard: ({ event }) => event.output.outcome === "pushed", target: "done" }, { target: "coding" }],
225
+ },
226
+ },
227
+ done: { type: "final" },
228
+ notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
229
+ },
230
+ });
231
+ ```
232
+
233
+ Concurrent coders in one clone. A Pool in the body runs one worker for each task, and each worker cuts its own Worktree.
234
+ The body cuts its merge target first, so a name conflict is refused before a coder works. Then it merges each branch, in
235
+ one clone with no fetch, and pushes once.
236
+
237
+ ```ts
238
+ const taskTree = (c: WorkerInput) => `${c.workspace.repos.app}/task-${c.id}` as const;
239
+ const mergeTree = (c: BodyInput) => `${c.workspace.repos.app}/merge` as const;
240
+
241
+ // In the worker:
242
+ cutting: { invoke: { src: "cut", input: ({ context }) => ({ path: taskTree(context), branch: `task/${context.id}` }) } },
243
+ coding: { invoke: { src: "coder", input: ({ context }) => ({ prompt: context.title, cwd: taskTree(context) }) } },
244
+
245
+ // In the body:
246
+ cutting: { invoke: { src: "cut", input: ({ context }) => ({ path: mergeTree(context), branch: context.branch }) } },
247
+ working: { invoke: { src: "coders", input: ({ context }) => ({ tasks: context.tasks, workspace: context.workspace }) } },
248
+ merging: {
249
+ invoke: {
250
+ src: "merge", // command(["git", "merge", "--no-edit", arg("branch")])
251
+ input: ({ context }) => ({ branch: `task/${context.left[0]!}`, worktree: mergeTree(context) }),
252
+ },
253
+ },
254
+ publishing: { invoke: { src: "publish", input: ({ context }) => ({ worktree: mergeTree(context) }) } },
255
+ ```
256
+
257
+ ## Considered options
258
+
259
+ - **The branch as the reference: `worktree({ slot, branch })`, with the path as output.** Rejected. The path is known
260
+ only after the cut, so each later state reads it from context, and a Chart needs an output read. `/` in a branch must
261
+ map to a folder. S1 cost 29 more lines against 8 for this design, and its attack found three fatal faults.
262
+ - **No step: a Worktree named as data, made on first use.** Rejected. A Turn has no route for a refused cut, and that is
263
+ the fault of its central idea. A planner's typo becomes a Turn that cannot start. It saves lines in the hard
264
+ scenarios, and it saves nothing in the common one.
265
+ - **The name is the local branch.** Rejected. A door word then reaches a path, a slash needs a mapping, and `run` beside
266
+ `run/a` collides.
267
+ - **`to` on `push`.** Rejected for v1: no scenario needs it, and it can be added with no break. A name chosen after the
268
+ work needs a cut first.
269
+ - **`push` reads HEAD.** Rejected. An Agent can move HEAD.
270
+ - **`default/` always on the Repo's default branch.** Rejected. An Agent would read the wrong version of a pinned
271
+ handbook with no sign, and a read-only slot is a reason for this design.
272
+ - **`cwd` defaults to `/work`.** Rejected, as ADR-0057 rejected it.
273
+ - **A bad path goes to `onError`.** Rejected by ADR-0067: an Agent's param can cause it.
274
+
275
+ ## Consequences
276
+
277
+ - After v1, these are additive: a removal Effect, a sparse checkout, a new Worktree source, `to`, a relaxed `branch` or
278
+ `cwd`, a later outcome, and durable output under `/work/.jr2/` or on its own mount.
279
+ - Park and restore can only restore all of `/work`. A restore from the remote alone cannot work: settled cuts do not run
280
+ again, and unpushed work is gone. v1 does not promise that form.
281
+ - A packaged Machine with two slots still needs a static `slot` on its `push`.
282
+ - A kill of the cut's node process alone can leave `git worktree add` running after the cut's lock frees, because git
283
+ does not inherit the lock.
284
+ - The fetch lock's holder ends as an orphan, so PID 1 must reap it. A User Container whose PID 1 does not reap keeps one
285
+ more zombie for each fetch there: 50 fetches left 50, beside the 52 that git leaves by itself with no jr2 program.
286
+ - A kill in the short `update-ref` step leaves a stale ref lock, and no cut removes it. So that name stays `refused`.
287
+ - Several Agents now share one Sandbox (concurrent coders, or a reviewer beside a coder), and so do the workers of a
288
+ Pool in one clone. They share the one Harness and `/work`. Only the Harness process holds the control credential, and
289
+ each process it starts in `/work` runs confined, so no Agent's code can act as another Agent
290
+ ([ADR-0076](0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md)).
291
+ - This change alters the Frame, the Chart briefing and the door's refusal text. The full `@model` run read 18 of 19
292
+ scenarios. `chart-refusal/off` read 6 of 10, below its floor of 7, and each miss failed at the Menu's validation,
293
+ before the door, on the JSON-string Chart that ADR-0070 records. Run alone, two runs on each side at one time, `main`
294
+ read 8 and 8 of 10, and this change read 9 and 7 of 10. So this change does not lower the cell. The floor stays, and a
295
+ full run can miss it.
@@ -0,0 +1,320 @@
1
+ # An Agent's control credential lives only where no Workspace code runs
2
+
3
+ [ADR-0013](0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md) scoped an Agent's control
4
+ credential to its Sandbox. That stops a coder in one Sandbox from delivering a pick to a reviewer in another Sandbox.
5
+ [ADR-0075](0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md) made several Agents in one Sandbox normal:
6
+ concurrent coders in one clone, or a coder beside an independent reviewer. In one Sandbox the Sandbox scope is not
7
+ sufficient. Every Agent's `bash` is a child of the one Harness process, at the one uid, and it inherits the env of that
8
+ process. So a coder's `bash`, driven by a prompt injection, can read the Stand-in of the control credential, send
9
+ `POST /agents/<reviewer-iid>/events` through the Custodian, and approve its own review. The reviewer did nothing.
10
+
11
+ The attack does not need the coder's own `bash`. The Agents of one Workspace share a writable `/work`. Code that one
12
+ Agent writes there — a test file, a git hook, `core.fsmonitor`, a smudge filter — runs when another process runs on
13
+ `/work`: the reviewer's `bash`, its `node --test` or `git diff`, or a `command` Effect. That code then has whatever
14
+ credential its process can read. So the question is not which container holds the credential. It is which processes run
15
+ code from `/work`.
16
+
17
+ ## Decision
18
+
19
+ - **The boundary.** A credential that lets a request act for an Agent lives only where no Workspace code runs.
20
+ **Workspace code** is each process whose program or input comes from `/work`: the Working tools, tests, builds, git
21
+ (its hooks, its filters, fsmonitor, textconv and each repository-local config) and `command` Effects. The boundary
22
+ needs no separate container for each Agent.
23
+ - **One Harness container for each Sandbox.** It serves every workspace-ful Agent below the `workspace()`
24
+ ([ADR-0005](0005-sandbox-pod-composition.md)). A Menu-only Agent has no Sandbox, and it runs on the Instance Harness,
25
+ as before ([ADR-0031](0031-menu-only-agents-run-on-the-instance-harness.md)).
26
+ - **The Harness process is trusted, and every program that a model drives runs confined.** The Harness process runs
27
+ jr2's code only. It runs the engine loop for each conversation, and it holds the control credential. Each other
28
+ process in the Harness container runs in one Landlock domain, which the Harness is not in:
29
+ - each `bash` call, and so each test, build, git hook and planted program that it runs;
30
+ - each process of a search tool (`rg`, `grep`);
31
+ - the I/O of the file tools (read, write, edit);
32
+ - jr2's own git for the attach, the cut and the sync
33
+ ([ADR-0067](0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md),
34
+ [ADR-0075](0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md)), because a smudge filter or a textconv
35
+ in the shared `.git/config` runs in git's process;
36
+ - each `command` Effect that is placed in the Harness, which is each `command` that names no secret.
37
+
38
+ So the trust boundary is inside the Harness container: the Harness process on one side, the domain on the other.
39
+ [ADR-0077](0077-jr2-requires-landlock-on-every-sandbox-node.md) makes Landlock a requirement of jr2, so no path runs a
40
+ program unconfined.
41
+
42
+ - **One spawner makes the domain.** At its start, the Harness starts the **spawner**, `effector spawner` on the runtime
43
+ volume ([ADR-0068](0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md)), confined once. The spawner
44
+ starts each process that the Harness asks for, and it does each file tool's I/O and each read, list and remove of a
45
+ path that the Harness needs in the Agent's files (the sync's git folders and its bundle). It sends back the output,
46
+ the exit status, the file's bytes and the names in a folder. A process inherits its parent's domain, so every program
47
+ of every Agent of the Sandbox is in the one domain. They see, signal and trace each other, as one user's processes do
48
+ on a machine: a later `bash` call can `kill` or `lsof` a server that an earlier call started. The tools that the model
49
+ calls do not change: `bash` takes a command, and `read`, `write` and `edit` take a path. If the spawner stops, the
50
+ Harness starts it again, with the same rules. The new spawner makes a new domain, a sibling of the old one, and the
51
+ processes that the old spawner started run on in the old domain. A process of the new domain cannot trace one of the
52
+ old domain, or read its `/proc/<pid>/environ` or its open files. Below ABI 6 it can still signal it, so a later `bash`
53
+ call can end a server from before the restart; at ABI 6 or later it cannot. The confiner never follows a symbolic link
54
+ to find what a rule allows: a link gets no rule (what it names gets its own rule where it is allowed, and the kernel
55
+ checks the path that a link resolves to), and the confiner refuses to start when a rule's path has a link on it. A
56
+ rule on a link would allow what the link names at each restart, and a confined process can change that.
57
+ - **The domain can do what the container's uid can do, except reach jr2's secrets.** The rules give every right on every
58
+ path, with one exception: `/run/jr2`. There the domain can list the names in each folder, and do nothing else.
59
+ Landlock only narrows the Unix permissions, so a confined program can do everything that the uid can do without
60
+ Landlock, except:
61
+ - reach the contents of `/run/jr2`: read, write, truncate, make, remove, move or link a file or a folder there.
62
+ `/run/jr2` holds the control Stand-in, each held secret's Stand-in, the folder of the Custodian's control socket,
63
+ and the Harness's conversations. Every path that jr2 holds for the Harness alone is under `/run/jr2`. Landlock
64
+ allows a path only by its rules, so the confiner allows every sibling of `/run/jr2` and of each folder above it, one
65
+ level at a time. It also gives the root the right to list names on its whole tree, so `ls /`, `ls /run` and `find /`
66
+ work as they do outside jr2. That right lists the names under `/run/jr2` too: a held secret's name, a conversation's
67
+ file name. These names are not secrets.
68
+ - trace the Harness process, or read its `/proc/<pid>/environ` or `/proc/<pid>/mem`. A confined process cannot ptrace
69
+ a process outside its domain.
70
+ - at Landlock ABI 6 or later, connect to an abstract Unix socket that a process outside the domain made. The
71
+ containers of the pod share one network namespace, so this keeps the domain off the abstract sockets of the Harness
72
+ and of the Custodian.
73
+
74
+ Landlock does not limit a connect to a named Unix socket. So a confined program can connect to the Custodian's control
75
+ socket. There the Custodian is the guard: it refuses a request that does not carry the secret control Stand-in, and
76
+ the fetch Stand-in, which every confined program holds, is valid only on the fetch route. Each listener answers 404 to
77
+ the other's routes, so the fetch Stand-in gets nothing on the control socket.
78
+
79
+ The kernel, not the rules, keeps what other pods share. The Repo caches under `/repos`, the runtime under `/opt/jr2`
80
+ and the CA bundles are mounted read-only, and the pod mounts no ServiceAccount token. The domain does not limit the
81
+ network. A request reaches a held credential only through the Custodian
82
+ ([ADR-0059](0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md)).
83
+
84
+ - **No Harness pod reaches a cloud metadata address.** Where the metadata service of a node answers pods, it gives the
85
+ node's cloud credential, which no `workspace()` grants. The Custodian's dial guard (ADR-0059) applies only to a
86
+ request that goes through the Custodian, and a program of the Agent can connect directly. So `jr2 up` converges an
87
+ egress NetworkPolicy for each Harness pod: `jr2-sandbox-egress` selects each Sandbox pod,
88
+ `jr2-instance-harness-egress` selects the Instance Harness, and each check pod of the provider preflight gets one of
89
+ its own, before the pod. Each policy allows all egress except `169.254.169.254/32`, `fd00:ec2::254/128`,
90
+ `169.254.170.2/32`, `169.254.170.23/32` and `100.100.100.200/32`: the metadata service of AWS, GCP and Azure, the
91
+ credential agents of ECS and EKS Pod Identity, and the metadata service of Alibaba Cloud.
92
+ - NetworkPolicy has no deny rule. So the rule allows `0.0.0.0/0` and `::/0`, each with its addresses as `except`, and
93
+ it also allows every pod of the cluster (`namespaceSelector: {}`).
94
+ - The Instance Harness and the check pod run no Workspace code. They get the policy because the check pod stands for
95
+ every Harness pod ([ADR-0019](0019-one-converging-command-against-the-current-context.md)): a model that only the
96
+ node's credential reaches must fail the check, not a Turn.
97
+ - The block has two limits. The list is not complete for every cloud provider, and only a CNI that enforces
98
+ NetworkPolicy applies it. So the shared-cluster guide tells the admin to check the metadata address of their
99
+ provider, and to give pods no node credential: Workload Identity, or IMDSv2 with a hop limit of 1.
100
+
101
+ - **The env of the domain is the container's own env, with the granted Stand-ins.** A program in the domain gets the env
102
+ that the container started with: the image's `ENV`, `harness.env` and `envFrom`, the proxy and CA variables. jr2's own
103
+ `JR2_*` names are removed, except `JR2_CUSTODIAN_URL`. It also gets `HOME`, `TMPDIR`, and the Stand-ins of the secrets
104
+ that the `workspace()` grants (its `secrets` list), each under its own name. It never gets what the Harness process
105
+ added to its own env after the start, and so it never gets the control Stand-in, or the Stand-in of a model key that
106
+ the `secrets` list does not name. A toolchain and a bring-your-own image work as they work outside jr2.
107
+ - **The Harness process runs with jr2's env alone.** node, glibc and OpenSSL read names from the env that load code or
108
+ configuration from a path: `NODE_OPTIONS=--require /app/x.js`, `LD_PRELOAD`, `OPENSSL_CONF`. The image's `ENV` can
109
+ name a file that the domain writes. So the Harness starts as `effector nocore --harness-env -- node …`. The effector
110
+ and tini are static binaries, so no `LD_*` name changes them. Before it starts node, the effector:
111
+ - gives node only the names that jr2 composed for the container: each name of its `env`, which the Orchestrator lists
112
+ in `JR2_HARNESS_ENV_NAMES`, and each key of its `envFrom`, to which the Orchestrator gives the prefix
113
+ `JR2_ENVFROM_`. The image's `ENV` can also hold a name with that prefix, and node then gets it. The marks keep out a
114
+ name that an image sets by accident, not one that its author sets to attack: the image's author already controls the
115
+ Harness process through the image's glibc
116
+ ([ADR-0037](0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md)).
117
+ - stops, and starts nothing, on an `envFrom` key that starts with `JR2_`. jr2's own names, such as `JR2_MENU_ONLY`,
118
+ which turns the domain off, come from the container's `env` alone, so a shared Secret cannot set one. `jr2 up`
119
+ refuses a `JR2_` name in `harness.env`, and a `JR2_` key of a Secret or a ConfigMap that `harness.envFrom` loads.
120
+ - removes from them each name that loads code or configuration from a path, or writes to one: every `LD_*` and
121
+ `MALLOC_*` name, `NODE_OPTIONS`, `NODE_PATH`, `NODE_COMPILE_CACHE`, `OPENSSL_CONF`, `GCONV_PATH` and the others of
122
+ its list, and a `TZ` that names a file.
123
+ - gives node `HOME=/run/jr2/home`, whatever the start env holds. node reads `HOME` once, at its start, into the
124
+ folders where a bare `require()` looks last: `$HOME/.node_modules` and `$HOME/.node_libraries`. A provider module
125
+ that pi-ai loads on its first Turn can miss in `node_modules` and fall through to them. So a Harness process with a
126
+ `HOME` that the domain writes runs a module that the domain put there.
127
+ - puts the container's whole start env, each `envFrom` key under its own name, in `JR2_START_ENV`, as base64. The
128
+ domain's env is made from it, so the image's `ENV` and the container's `HOME` reach each program of the domain. No
129
+ program reads that name. A `JR2_START_ENV` that the start env already holds is dropped. Without
130
+ `JR2_HARNESS_ENV_NAMES` the effector starts nothing.
131
+
132
+ At its start, before it loads any other module, the Harness refuses to run when a folder where node looks for a module
133
+ is outside `/opt/jr2` and `/run/jr2`, and it checks that its `HOME` is `/run/jr2/home`. A change of `HOME` after node
134
+ started is too late, so the Harness never makes one.
135
+
136
+ A name of jr2's env that names a credential file which the Harness process reads (`AWS_CONFIG_FILE`,
137
+ `GOOGLE_APPLICATION_CREDENTIALS`, and the others) is the admin's choice. `jr2 up` refuses such a path in a folder that
138
+ the domain writes in every pod (`/work`, `/tmp`, `/dev/shm`, `/home/jr2`), and warns on any other path: whether the
139
+ Sandbox's uid can write a folder of the image is the image's, and jr2 cannot read it.
140
+
141
+ - **The file tools reach what `bash` reaches.** Their I/O runs in the spawner, so the kernel applies the domain's rules
142
+ to them. A path under `/run/jr2`, or a symbolic link to one, fails with the error that an unreadable file gives. `~`
143
+ in a path is the domain's `HOME`, as it is for `bash`.
144
+ - **The Harness process touches no path that the domain can write.** Each path in the Agent's files that the Harness
145
+ reads, lists or removes goes through the spawner. The one exception is the file that holds the full output of a `bash`
146
+ call. The Harness makes it in the domain's `TMPDIR`, with a name that must be new and must not be a link, and it
147
+ writes through that open file alone. If the file did not land in `TMPDIR`, the Harness removes it and refuses. The
148
+ Harness process has its own `HOME`, `/run/jr2/home`, from its start (above), which does not exist and which no process
149
+ can make, because code in the Harness process reads files under `HOME`: node looks for modules in `~/.node_modules`,
150
+ the AWS SDK reads `~/.aws/config`, which can name a `credential_process` to run, and Google's library reads
151
+ `~/.config/gcloud`. The domain keeps the Workspace's `HOME`.
152
+ - **One secret control Stand-in for each Sandbox.** Only the Harness process holds it, as a file under `/run/jr2`. It is
153
+ in no env of the domain. The Custodian puts the Sandbox's control token in its place on the control routes (ADR-0059),
154
+ and the real token never leaves the Custodian.
155
+ - **The control routes are a Unix socket under `/run/jr2`.** The Harness sends each control request to the Custodian's
156
+ control socket, not to a loopback port. A confined program cannot make a socket under `/run/jr2`, so it cannot take
157
+ the control listener's place while the Custodian restarts, and it cannot receive the control Stand-in. It can connect
158
+ to the socket, and the Custodian refuses what it sends (above). The fetch route stays on loopback, because its
159
+ Stand-in is public.
160
+ - **The Harness states the slot.** On each control request, the Harness states the Agent that the conversation belongs
161
+ to: its slot. It takes the slot from its own record of the conversation, never from the URL. The slot is the Agent's
162
+ full identity below the run: the actor path of its Machine and its agent name, which is a continued conversation's
163
+ Instance ID without the run id ([ADR-0057](0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md)). So two
164
+ Agents with one key in two child Machines are two slots. The Orchestrator checks that the stated slot is the slot of
165
+ the target registration, on the surface read, the pick, the delivery poll and the acknowledgment of a notice
166
+ ([ADR-0062](0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md)). This check finds a routing fault. It is not
167
+ the security boundary: the boundary is that only the Harness holds the credential.
168
+ - **Two tokens, each checked on its own routes.** This is defence in depth:
169
+ - The **control token** is what the Custodian puts in place of the secret control Stand-in. It is valid only on the
170
+ agent routes (`/agents/<iid>/surface`, `/agents/<iid>/events`, `/agents/<iid>/deliveries/<id>`, and the route on
171
+ which the Harness acknowledges a notice), and it carries the stated slot.
172
+ - The **fetch token** is what the Custodian puts in place of the public Sandbox Stand-in that `jr2-upload-pack` holds
173
+ as a constant. It is valid only on `POST /sandboxes/<sandbox>/fetch`
174
+ ([ADR-0053](0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md)). The Custodian
175
+ accepts the public Stand-in on the fetch route only.
176
+ - The Orchestrator accepts each token only on its own routes. A Gate accepts neither, and no other principal of an
177
+ Agent: an Agent never reaches a Gate (ADR-0013).
178
+ - **An engine runs its loop in the Harness process.** pi does: its loop is in the Harness process, and only its tools
179
+ run in the domain. Each engine adapter states where its loop runs, and `engine.ts` refuses an adapter whose engine
180
+ runs in the domain. Every process of the one domain can see, trace and signal every other one, so another Agent's code
181
+ could write such an engine's stdin, read its environ and forge its Menu calls.
182
+ - **An engine with its own process, such as Claude Code, needs a domain for each Agent first.** The root domain then
183
+ holds only the spawner, which no process can trace or read (`PR_SET_DUMPABLE 0`). For each conversation, the spawner
184
+ starts a sub-spawner one Landlock layer deeper, with a socket pair as its stdio and as the subreaper
185
+ (`PR_SET_CHILD_SUBREAPER`) of what it starts. Every process of that Agent comes from its sub-spawner: the engine, its
186
+ `bash` and its git. Landlock denies a process of one nested domain the `/proc/<pid>/fd` and the environ of a process
187
+ in a sibling domain, so an Agent's code cannot drive another Agent's engine. They still share `/work`. Such an engine
188
+ also needs these, as mechanisms:
189
+ - **Authority stays in the Harness.** The engine holds no token in its env, its argv or a file. The Harness knows the
190
+ engine by the socket that it gave it. The Harness accepts a Menu call only when it matches a tool call of a model
191
+ answer that the Harness relayed, and only the first pick of that answer
192
+ ([ADR-0024](0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md)). The Turn's safety hooks become decisions
193
+ of the relay and of the Menu server.
194
+ - **The model endpoint is not a proxy.** It sets the path, the model and the Dials, and refuses every other API of the
195
+ provider (files, batches, fine-tuning). It counts the usage into the Settlement, and it refuses a request outside a
196
+ Turn. The context of each request comes from jr2's record: the engine's own session file is a cache that jr2 does
197
+ not trust. It is never on the Harness's wire port.
198
+ - **Spawns go through the spawner.** An adapter starts its engine through the spawner, with the domain's env, never
199
+ with the Harness process's env.
200
+ - **No such engine on the Instance Harness.** The Instance Harness has no domain, and its control Stand-in is valid
201
+ for each Menu-only Agent of the Instance. A Menu-only Agent's engine runs its loop in the Harness process
202
+ ([ADR-0031](0031-menu-only-agents-run-on-the-instance-harness.md)).
203
+ - **Chart Agents and Pool workers need no provisioning.** The Agents that a Chart makes
204
+ ([ADR-0070](0070-a-machine-lends-its-parts-to-a-chart.md)) and each Turn of a Pool worker run in the one Harness, and
205
+ the Harness states their slot. The provision knows nothing of the Agents of the Sandbox.
206
+
207
+ ## Considered options
208
+
209
+ - **One Harness container for each agent slot, each with its own secret Stand-in.** Rejected. Kubernetes isolates one
210
+ container from another, but all the slots share the writable `/work`. Code that one Agent plants there runs in a
211
+ sibling's container when the sibling's `bash`, git or tests run, or when a `command` Effect runs there, and that code
212
+ reads the credential of the container that runs it. So container isolation between Agents gives nothing against an
213
+ attack that goes through `/work`. It also costs a container, a port, a Secret and a share of the Size for each slot,
214
+ and it has no container for an Agent that a Chart makes at run time.
215
+ - **A trusted control container and a tools container with no credential.** Rejected. It gives the same protection as
216
+ confinement: no Workspace code runs where the credential is. But it adds a container, it sends each tool call across a
217
+ container edge, and it changes [ADR-0027](0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md) (the
218
+ Harness is one server). jr2 already confines the code that an Agent wrote in the Effector (ADR-0068). One mechanism
219
+ serves one problem.
220
+ - **A second uid inside the Harness container**, so that `bash` runs as a uid that cannot read the credential. Rejected.
221
+ Under `runAsNonRoot`, `allowPrivilegeEscalation: false` and `capabilities: drop ALL`, no process can `setuid` without
222
+ `CAP_SETUID`, and under `no_new_privs` that likely also needs `allowPrivilegeEscalation: true`. It adds privilege to
223
+ the container that runs code a model controls.
224
+ - **A user namespace**, so that the Harness maps a second uid with no host privilege. Rejected: the default seccomp
225
+ profile (`RuntimeDefault`) blocks `unshare(CLONE_NEWUSER)` from a process with no privilege. It needs a custom seccomp
226
+ profile and a kernel that permits it, so it is not portable to a self-hosted cluster.
227
+ - **No right to list `/` and the folders above `/run/jr2`.** Rejected. `ls /`, `ls /run`, `find /` and a glob such as
228
+ `/*` then fail, which stops normal work. Landlock can only add rights, and a right on a folder holds on its whole
229
+ tree, so a right to list `/` also lists `/run/jr2`. The names there are not secrets.
230
+ - **The Harness process inherits the image's env.** Rejected: the image's `ENV` can hold
231
+ `NODE_OPTIONS=--require /app/x.js`, and `/app` can be a folder that the Sandbox's uid owns. Workspace code writes
232
+ `/app/x.js`, ends the Harness (below ABI 6 a signal can), and the restarted Harness runs that file with the control
233
+ Stand-in in its memory. A deny-list alone on the image's env is not sufficient: the next name that loads code is not
234
+ on it.
235
+ - **The Harness process keeps the domain's `HOME`, or takes its own after node starts.** Rejected: the domain writes
236
+ that folder, and code in the Harness process reads files there. A planted `~/.aws/config` with a `credential_process`
237
+ would run a program outside the domain. node reads `HOME` once, at its start: a module planted in
238
+ `$HOME/.node_modules` or `$HOME/.node_libraries` would run in the Harness process, as one named by `NODE_OPTIONS` or
239
+ `NODE_PATH` would. So the Harness gets its `HOME` when it is started.
240
+ - **Rules that allow only `/work`, `HOME`, `TMPDIR` and the system paths.** Rejected. It stops what an Agent does on a
241
+ machine: a write to a folder of the image that the uid owns (`/app`, a virtualenv, `/usr/local`), a read of `/var` or
242
+ `/srv`, a write to `/proc/self/oom_score_adj`. A bring-your-own image breaks in ways that its author cannot see. The
243
+ secrets are under one path, so a rule that denies that path is sufficient.
244
+ - **A new domain for each process that the Harness starts.** Rejected. Each `bash` call is then its own domain, so a
245
+ later call cannot signal, trace or `lsof` the server that an earlier call started. That is normal behaviour for an
246
+ Agent.
247
+ - **One spawner for each conversation, today.** Rejected while every engine runs its loop in the Harness process. Then
248
+ an Agent's processes hold nothing that another Agent's code can use to act as that Agent: the credential and the loop
249
+ are in the Harness, and each process gets the one env of the Sandbox. Through `/work`, one Agent can only try to
250
+ persuade another Agent's model. The separation costs a process and a layer for each conversation. It is the path for
251
+ an engine with its own process (above): there, a sibling in the one domain forges the engine's Menu calls at once.
252
+ - **The file tools do their I/O in the Harness, with a check of the path.** Rejected. It puts the rule in two places,
253
+ the kernel and jr2's code, and the check in code must handle each symbolic link and each race.
254
+ - **The control routes on a loopback port.** Rejected. A confined program can bind `127.0.0.1:<port>` while the
255
+ Custodian restarts, and it then receives the secret control Stand-in on the Harness's next request.
256
+ - **The Custodian authenticates the caller.** Rejected alone: every process reaches the Custodian over the one pod
257
+ loopback. The Custodian can tell the Harness from a child only by a secret that the child cannot read, which is this
258
+ decision.
259
+ - **Keep the Sandbox-scoped credential, and call the Agents of one Workspace mutually trusting.** Rejected: a coder
260
+ beside an independent reviewer in one Workspace is a motivating case.
261
+ - **Refuse all of `169.254.0.0/16` at the egress.** Rejected: NodeLocal DNSCache answers on `169.254.20.10`, so the pods
262
+ of a cluster that runs it lose DNS.
263
+ - **An egress policy with `ipBlock` peers only.** Rejected: a CNI that applies an `ipBlock` to the address of a pod
264
+ (Cilium) then cuts DNS, the Orchestrator and each Service. So the rule also allows every pod of the cluster.
265
+ - **The egress policy on the Sandbox pods only.** Rejected: the check pod of the provider preflight stands for every
266
+ Harness pod. Without the policy there, a model that only the node's credential reaches passes the check and fails in a
267
+ Turn.
268
+
269
+ ## Consequences
270
+
271
+ - **No Workspace code can act as an Agent.** Planted code finds no control credential: not in its env, not in a file
272
+ that it can read, not through the Harness's `/proc`. It cannot deliver a pick for any Agent, its own included. Only
273
+ the Harness process delivers a pick, for the conversation whose model called the pick.
274
+ - **What remains.**
275
+ - An Agent can still try to persuade another Agent through what that Agent reads: a file, a commit message, the output
276
+ of a test. That is an attack on content, not a forged pick. The other Agent's model chooses each pick, and the
277
+ Orchestrator judges it against that Turn's Allowed picks
278
+ ([ADR-0029](0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md)).
279
+ - A confined child can spend only a held secret that its `workspace()` grants. Every Stand-in but the fetch Stand-in
280
+ is secret, and the child's env holds only the granted ones (ADR-0059). So code in `/work` reaches a model key only
281
+ when the author grants that key.
282
+ - Below Landlock ABI 6, a confined process can still send a signal to the Harness process, which has the same uid. So
283
+ the Harness starts with no core file (`effector nocore`, a core limit of zero), and with Node's `--disable-sigusr1`.
284
+ Without them, a dumping signal writes the Harness's memory, with its Stand-ins, to a file in its working folder, and
285
+ SIGUSR1 opens Node's inspector on the pod loopback, which runs the child's code in the Harness. What a signal can
286
+ still do is stop or end the Harness. That is a denial of service in the Agent's own Sandbox, not an impersonation.
287
+ The Harness names its process `jr2-harness`, so an Agent's `pkill node` does not end it by accident. ABI 6 scopes
288
+ signals and abstract Unix sockets to the domain, and the confiner uses both scopes when the kernel offers them
289
+ (ADR-0077): the Harness is then outside each signal from the domain.
290
+ - The domain can connect to the Custodian's control socket. The Custodian refuses each request that does not carry the
291
+ control Stand-in, which no process of the domain holds.
292
+ - The domain can see that a path under `/run/jr2` exists, by its name or by `stat`, and its size. It cannot read what
293
+ is in it.
294
+ - The node's cloud credential is closed only at the metadata addresses that jr2 knows, and only on a CNI that enforces
295
+ NetworkPolicy (the decision above).
296
+ - **The pod is ADR-0005's pod**: one Harness, a Custodian, and an optional User Container and Sandbox Effector. The
297
+ count of Agents does not change it, and the Size splits as before
298
+ ([ADR-0060](0060-a-workspace-states-its-size-and-jr2-reserves-all-of-it.md)).
299
+ - **The Instance Harness runs no Workspace code.** A Menu-only Agent has no Working tools (ADR-0031), so the credential
300
+ of the Instance Harness is already where no Workspace code runs.
301
+ - **Tests.** The `@kind` tier proves the boundary from the model's own `bash`: it finds no Stand-in of the pod in its
302
+ env, in a file or through the Harness's `/proc`; a test that the coder plants and the reviewer's own `node --test`
303
+ runs cannot deliver a pick as the reviewer, and the run does not settle; a granted held secret works, and no model key
304
+ is in the env; a second Agent runs git on its Worktree, and the step checks the output. It also proves that the domain
305
+ does not stop normal work: a server that one `bash` call starts is found and ended by a later call; a write to a
306
+ folder of the image that the uid owns, outside `/work`, succeeds; a move of a file into another folder succeeds;
307
+ `ls /` and `ls /run` succeed; and `read` reads the full output of a `bash` call. It proves the metadata block: kind
308
+ has no metadata service, so the step gives `169.254.169.254` to the node, and a pod that the policy does not select
309
+ gets the node's refusal there. The Agent's `bash` does not, and it still connects to the model's host and to the
310
+ Orchestrator's Service. A unit test pins the policy that `jr2 up` composes. The unit and conformance tests refuse the
311
+ control token on the fetch route, the fetch token on an agent route, a stated slot that is not the registration's, and
312
+ both tokens at a Gate, and they check the slot on the delivery poll. A test reads the Harness container's mounts from
313
+ the pod spec. It fails on a mount that is not read-only, not under `/run/jr2`, and not the Workspace's own (`/work`,
314
+ `HOME`), so a new mount is classified before it ships. Unit tests start node through `effector nocore --harness-env`
315
+ with a `HOME` that holds a planted `.node_modules`, and check that node looks for no module there; they also retarget
316
+ a link onto `/run/jr2`, restart the spawner, and check that the new domain reads nothing there. The `@kind` tier reads
317
+ the Harness process's environ from outside the pod and checks its `HOME`.
318
+ - **The `@model` tier runs after the build.** The change alters what the Working tools can do and where a notice is
319
+ delivered ([ADR-0062](0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md)), so it is a `@model`-tier change
320
+ ([ADR-0066](0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md)).