@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
@@ -14,6 +14,7 @@
14
14
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
15
15
  import { lookup } from "node:dns/promises";
16
16
  import { z } from "zod";
17
+ import type { LaterOutcome } from "./effect.ts";
17
18
 
18
19
  // --- The request (POST /effects/<id>) ------------------------------------------------------------
19
20
  //
@@ -104,10 +105,10 @@ export const PairShape = z.strictObject({
104
105
  });
105
106
  export type Pair = z.infer<typeof PairShape>;
106
107
 
107
- /** A `push` start: the Workspace's branch to the Repo Slot's Binding, with the push credential of
108
+ /** A `push` start: a cut Worktree's branch to the Repo Slot's Binding, with the push credential of
108
109
  * the `git.credentials` entry the url falls under — the Effector's config holds the entries, and
109
110
  * matches the url as the fence does (`credential` names one by its `match` instead). `pin` is the
110
- * commit; absent, the Effector reads the Agent's branch tip and its answer carries it (`pinned`).
111
+ * commit; absent, the Effector reads the branch's tip and its answer carries it (`pinned`).
111
112
  */
112
113
  export const PushStartShape = z.strictObject({
113
114
  kind: z.literal("push"),
@@ -259,6 +260,8 @@ export const HttpOutputShape = z.union([
259
260
  * - `unknown`: the call can have gone out, but no answer came, or jr2 cannot tell after a restart.
260
261
  * `reason` tells why. jr2 does not send it again unless it is marked `idempotent`.
261
262
  *
263
+ * The union is open (`LaterOutcome`): give the rest a last arm with no guard.
264
+ *
262
265
  * @example
263
266
  * ```ts
264
267
  * import { fail, http, jr2Setup, type HttpOutput } from "@jr2/orchestrator";
@@ -291,7 +294,7 @@ export const HttpOutputShape = z.union([
291
294
  * @see docs/guides/run-a-command-or-call-a-url.md
292
295
  * @see docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md
293
296
  */
294
- export type HttpOutput = z.infer<typeof HttpOutputShape>;
297
+ export type HttpOutput = z.infer<typeof HttpOutputShape> | LaterOutcome;
295
298
 
296
299
  /**
297
300
  * A `push` Effect's output (ADR-0067). `pushed`: the published commit, its source (the Agent's
package/src/fail.ts CHANGED
@@ -87,24 +87,37 @@ export type FailSlot = PromiseActorLogic<never, FailureInput> & { readonly [FAIL
87
87
  * @example
88
88
  * ```ts
89
89
  * import { z } from "zod";
90
- * import { command, fail, jr2Setup, workspace, type Workspaced } from "@jr2/orchestrator";
90
+ * import { command, fail, isReady, jr2Setup, workspace, worktree, type Workspaced } from "@jr2/orchestrator";
91
91
  *
92
92
  * const runInput = z.object({ branch: z.string() });
93
+ * type BodyInput = Workspaced<z.infer<typeof runInput>, "app">;
94
+ *
95
+ * const testTree = (context: BodyInput) => `${context.workspace.repos.app}/test` as const;
93
96
  *
94
97
  * const body = jr2Setup({
95
- * types: {} as { input: Workspaced<z.infer<typeof runInput>, "app"> },
98
+ * types: {} as { input: BodyInput; context: BodyInput },
96
99
  * actors: {
100
+ * cut: worktree(),
97
101
  * tests: command(["npm", "test"], { timeout: "15m" }),
98
102
  * fail: fail(),
99
103
  * },
100
104
  * }).createMachine({
101
105
  * id: "check",
106
+ * context: ({ input }) => input,
102
107
  * on: { "workspace.lost": ".lost" },
103
- * initial: "testing",
108
+ * initial: "cutting",
104
109
  * states: {
110
+ * cutting: {
111
+ * invoke: {
112
+ * src: "cut",
113
+ * input: ({ context }) => ({ path: testTree(context), branch: context.branch }),
114
+ * onDone: [{ guard: ({ event }) => isReady(event.output), target: "testing" }, { target: "notCut" }],
115
+ * },
116
+ * },
105
117
  * testing: {
106
118
  * invoke: {
107
119
  * src: "tests",
120
+ * input: ({ context }) => ({ worktree: testTree(context) }),
108
121
  * onDone: [
109
122
  * {
110
123
  * guard: ({ event }) => event.output.outcome === "exited" && event.output.code === 0,
@@ -117,6 +130,7 @@ export type FailSlot = PromiseActorLogic<never, FailureInput> & { readonly [FAIL
117
130
  * // Not a final state: the run ends `failed`, and the Workspace deletes the Sandbox.
118
131
  * failing: { invoke: { src: "fail", input: { reason: "npm test did not pass" } } },
119
132
  * passed: { type: "final" },
133
+ * notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
120
134
  * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
121
135
  * },
122
136
  * });
@@ -124,7 +138,6 @@ export type FailSlot = PromiseActorLogic<never, FailureInput> & { readonly [FAIL
124
138
  * export const machine = workspace(body, {
125
139
  * input: runInput,
126
140
  * repos: { app: "https://github.com/you/app.git" },
127
- * spec: ({ input }) => ({ branch: input.branch }),
128
141
  * });
129
142
  * ```
130
143
  *
@@ -107,6 +107,9 @@ export type HarnessClient = {
107
107
  * `/work` — the stub-Harness run with no Workspace. */
108
108
  cwd?: string;
109
109
  definition: AgentDefinition;
110
+ /** The Agent's slot (ADR-0076), which the Harness states on each control request for this
111
+ * conversation. Omitted only by a caller that drives no Machine (a test of the wire). */
112
+ slot?: string;
110
113
  model?: string;
111
114
  thinkingLevel?: ThinkingLevel;
112
115
  /** The pending notices in this admission's scope (ADR-0062). Omitted when there are none. */
@@ -382,6 +385,8 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
382
385
  // `machine-doc.ts` and the surface read it — and the Harness neither validates nor
383
386
  // uses it, so it has no business on the Turn's wire.
384
387
  definition: admittedDefinition(sendOptions.definition),
388
+ // Who the conversation is (ADR-0076): the Harness states it on each control request.
389
+ ...(sendOptions.slot === undefined ? {} : { slot: sendOptions.slot }),
385
390
  // Omitted when unset, so an admission with no dials is byte-identical to before.
386
391
  ...(sendOptions.model ? { model: sendOptions.model } : {}),
387
392
  ...(sendOptions.thinkingLevel ? { thinkingLevel: sendOptions.thinkingLevel } : {}),
@@ -629,6 +634,7 @@ export function harnessAgentRunPort(client: HarnessClient): AgentRunPort {
629
634
  // to the Harness's refusal if it is not a path.
630
635
  ...(input.cwd === undefined ? {} : { cwd: input.cwd }),
631
636
  definition: opts.definition,
637
+ slot: opts.slot,
632
638
  model: input.model,
633
639
  thinkingLevel: input.thinkingLevel,
634
640
  ...(opts.notices?.length ? { notices: opts.notices } : {}),
@@ -751,8 +757,9 @@ export function createLiveSetPush(options: {
751
757
  * `invoke: { src: "coder", input: { prompt } }`. The key is the Agent's name.
752
758
  *
753
759
  * The invoke's `input` is the Turn: `prompt` (required), `cwd`, `model`, `thinkingLevel` and
754
- * `continue`. `cwd` is the absolute directory where the Working tools work. Absent, it is the
755
- * Worktree of the Workspace's one Repo Slot. `continue: true` continues the Agent's conversation
760
+ * `continue`. `cwd` is the absolute directory where the Working tools work. Under Repo Slots it
761
+ * is required: a Worktree that a cut made, `<slot>/default` to read, or `/work`. An Agent with
762
+ * `workspace: "none"`, and a Workspace with no Repo Slot, need no `cwd`. `continue: true` continues the Agent's conversation
756
763
  * in this Machine. Absent, each Turn starts a new conversation. `continue` is the literal `true`,
757
764
  * not a `boolean`. A Turn can join only a conversation that an earlier Turn started with
758
765
  * `continue: true`. Thus in a retry loop, put `continue: true` on each Turn, also on the first, so
@@ -778,10 +785,9 @@ export function createLiveSetPush(options: {
778
785
  * - a key in `input` that a Turn does not have: ``agent "<slot>": the Turn's input carries
779
786
  * `<key>`, which a Turn has no use for — its input is its Frame (`prompt`, `cwd`), its Dials
780
787
  * (`model`, `thinkingLevel`), and `continue` (ADR-0057)``
781
- * - no `cwd` under a Workspace with more than one Repo Slot: ``turn <id>: agent "<slot>" works
782
- * under a Workspace carrying more than one Repo Slot (<slots>) and its Turn framed no `cwd` —
783
- * say which Worktree it works in (`cwd: context.workspace.repos.<slot>`, one of those), because
784
- * the kit gives no slot a meaning (ADR-0051) and a Turn's Frame is where it works (ADR-0057)``
788
+ * - no `cwd` under Repo Slots: ``turn <id>: agent "<slot>" works under a Workspace with Repo Slots
789
+ * (<slots>) and its Turn framed no `cwd`: say where it works, a Worktree, or `cwd: "/work"` above
790
+ * them (ADR-0057)``
785
791
  * - a `cwd` that is not an absolute path: ``agent "<slot>": the Turn's Frame carries a `cwd` that
786
792
  * is not an absolute path — the Frame says WHERE this Turn works (ADR-0057), and a Turn rooted
787
793
  * anywhere else would work in the wrong directory silently``. The Harness makes this refusal
@@ -801,7 +807,16 @@ export function createLiveSetPush(options: {
801
807
  * @example
802
808
  * ```ts
803
809
  * import { z } from "zod";
804
- * import { agent, defineEvent, fail, jr2Setup, workspace, type Workspaced } from "@jr2/orchestrator";
810
+ * import {
811
+ * agent,
812
+ * defineEvent,
813
+ * fail,
814
+ * isReady,
815
+ * jr2Setup,
816
+ * workspace,
817
+ * worktree,
818
+ * type Workspaced,
819
+ * } from "@jr2/orchestrator";
805
820
  *
806
821
  * const finish = defineEvent({
807
822
  * name: "finish",
@@ -811,6 +826,8 @@ export function createLiveSetPush(options: {
811
826
  * const runInput = z.object({ branch: z.string() });
812
827
  * type BodyInput = Workspaced<z.infer<typeof runInput>, "app">;
813
828
  *
829
+ * const fixTree = (context: BodyInput) => `${context.workspace.repos.app}/fix` as const;
830
+ *
814
831
  * const body = jr2Setup({
815
832
  * types: {} as { input: BodyInput; context: BodyInput },
816
833
  * events: [finish],
@@ -819,26 +836,35 @@ export function createLiveSetPush(options: {
819
836
  * model: "anthropic/claude-haiku-5-5",
820
837
  * instructions: "You fix failing tests. You change no test.",
821
838
  * }),
839
+ * cut: worktree(),
822
840
  * fail: fail(),
823
841
  * },
824
842
  * }).createMachine({
825
843
  * id: "fix",
826
844
  * context: ({ input }) => input,
827
845
  * on: { "workspace.lost": ".lost", "agent.fault": ".failed" },
828
- * initial: "coding",
846
+ * initial: "cutting",
829
847
  * states: {
848
+ * cutting: {
849
+ * invoke: {
850
+ * src: "cut",
851
+ * input: ({ context }) => ({ path: fixTree(context), branch: context.branch }),
852
+ * onDone: [{ guard: ({ event }) => isReady(event.output), target: "coding" }, { target: "notCut" }],
853
+ * },
854
+ * },
830
855
  * coding: {
831
856
  * invoke: {
832
857
  * src: "coder",
833
858
  * input: ({ context }) => ({
834
859
  * prompt: "Make `node --test` pass, then commit.",
835
- * cwd: context.workspace.repos.app,
860
+ * cwd: fixTree(context),
836
861
  * thinkingLevel: "high",
837
862
  * }),
838
863
  * },
839
864
  * on: { finish: "done" },
840
865
  * },
841
866
  * done: { type: "final" },
867
+ * notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
842
868
  * failed: { invoke: { src: "fail", input: { reason: "the Agent's Turn could not go on" } } },
843
869
  * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
844
870
  * },
@@ -847,7 +873,6 @@ export function createLiveSetPush(options: {
847
873
  * export const machine = workspace(body, {
848
874
  * input: runInput,
849
875
  * repos: { app: "https://github.com/you/app.git" },
850
- * spec: ({ input }) => ({ branch: input.branch }),
851
876
  * });
852
877
  * ```
853
878
  *
@@ -856,6 +881,9 @@ export function createLiveSetPush(options: {
856
881
  * @see docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md
857
882
  * @see docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md
858
883
  */
884
+ export function agent<const D extends AgentDeclaration>(
885
+ declaration: D,
886
+ ): AgentLogic & (D extends { workspace: "none" } ? { readonly definition: { readonly workspace: "none" } } : unknown);
859
887
  export function agent(declaration: AgentDeclaration): AgentLogic {
860
888
  // THE authoring surface for an Agent (ADR-0049): one definition in, one actor slot out. The
861
889
  // slot key is the Agent's name (the Harness route, the minted iid, the markers), so a name the
@@ -956,8 +984,8 @@ export function createExecPort(options: {
956
984
  *
957
985
  * Where it runs:
958
986
  * - With no Effect secret in `env`, in the Harness container of the enclosing Workspace's Sandbox.
959
- * It runs in the Worktree of a Repo Slot, or in `/work` in a Workspace with no Repo Slot. The
960
- * Agent's Working tools also run in this container.
987
+ * It runs in the Worktree that `worktree` of the invoke's input names, or in `/work` in a
988
+ * Workspace with no Repo Slot. The Agent's Working tools also run in this container.
961
989
  * - With an Effect secret in `env`, in the Sandbox Effector, against a commit of the branch. This
962
990
  * is the branch tip when it starts, or with `pin: true` the commit that the invoke's `input.sha`
963
991
  * names. The output then says `dirty: true` when the Worktree held edits that the command did
@@ -994,11 +1022,9 @@ export function createExecPort(options: {
994
1022
  * And when it starts, with no Effect secret in `env`:
995
1023
  * - outside a `workspace()`: `command "<slot>" runs in the Harness container of a Workspace, and
996
1024
  * its state is not inside a workspace() — invoke it from a Workspace's body (ADR-0067)`
997
- * - no `slot` under a Workspace with more than one Repo Slot: `command "<slot>" runs under a
998
- * Workspace carrying more than one Repo Slot (<slots>) and names none — say which Worktree it
999
- * runs in: command(argv, { slot: "<slot>" }) (ADR-0051)`
1000
- * - a `slot` that the Workspace does not have: `command "<slot>" names Repo Slot "<name>", which
1001
- * its Workspace does not carry (<slots>)`
1025
+ * - no `worktree` under Repo Slots: `` command "<slot>" runs under a Workspace with Repo Slots
1026
+ * (<slots>) and its input names no `worktree`: say which, a Worktree that a cut gave or
1027
+ * `<slot>/default` ``
1002
1028
  * - an `arg()` with no string in `input`: `command "<slot>": argv slot <<name>> has no string
1003
1029
  * value in its input (got <type>) — map it in the invoke's input`. The angle brackets around the
1004
1030
  * arg's name are in the text: `argv slot <file>`. `<type>` is `nothing` when `input` has no such
@@ -1007,15 +1033,13 @@ export function createExecPort(options: {
1007
1033
  * And when it starts, with an Effect secret in `env`:
1008
1034
  * - outside a `workspace()`: `command "<slot>" runs in the Sandbox Effector of a Workspace, and its
1009
1035
  * state is not inside a workspace() — invoke it from a Workspace's body (ADR-0067)`
1010
- * - no `slot` under a Workspace with more than one Repo Slot: `command "<slot>" runs under a
1011
- * Workspace carrying more than one Repo Slot (<slots>) — say which: { slot: "<slot>" }
1012
- * (ADR-0051)`
1013
1036
  * - a Workspace with no Repo Slot: `command "<slot>" runs under a Workspace with no Repo Slot: a
1014
1037
  * command with an Effect secret runs against one commit of a Repo Slot's branch, and there is
1015
1038
  * none — declare a Repo Slot, or name no secret in its env, and it runs in /work beside the Agent
1016
1039
  * (ADR-0051)`. `jr2 up` refuses it first, with the same reason
1017
- * - a `slot` that the Workspace does not have: `command "<slot>" names Repo Slot "<name>", which
1018
- * its Workspace does not carry (<slots>)`
1040
+ * - a `worktree` that no cut made in this Sandbox: the output is `exited` 127, with `no cut made
1041
+ * <path> in this Sandbox: cut it with worktree()` in its tail. A detached Worktree gets the same
1042
+ * form
1019
1043
  * - `pin: true` and an `input.sha` that is not a full commit id: `command "<slot>" (pin): its
1020
1044
  * input's sha is not a full commit id`
1021
1045
  * - an `arg()` with no string in `input`: the same text as above.
@@ -1023,8 +1047,7 @@ export function createExecPort(options: {
1023
1047
  * @param argv - The program and its arguments: strings and `arg("name")` entries. Or `open`, in a
1024
1048
  * Machine that you publish as a package, for the Instance that registers it to bind with
1025
1049
  * `customize()`.
1026
- * @param options - Optional. `slot`: the Repo Slot whose Worktree the command runs in; needed only
1027
- * where the Workspace has more than one. `timeout`: `"90s"`, `"10m"` or `"1h"`, default
1050
+ * @param options - Optional. `timeout`: `"90s"`, `"10m"` or `"1h"`, default
1028
1051
  * `"10m"`; when it expires, the output is `timeout`. `tail`: how many characters of the end of
1029
1052
  * the output stream to keep, default 4096. `env`: environment variables. Each value is
1030
1053
  * `secret("name")` for the value of an Effect secret, or `secret("name").path` for the path of a
@@ -1035,26 +1058,46 @@ export function createExecPort(options: {
1035
1058
  * @example
1036
1059
  * ```ts
1037
1060
  * import { z } from "zod";
1038
- * import { arg, command, fail, jr2Setup, workspace, type Workspaced } from "@jr2/orchestrator";
1061
+ * import {
1062
+ * arg,
1063
+ * command,
1064
+ * fail,
1065
+ * isReady,
1066
+ * jr2Setup,
1067
+ * workspace,
1068
+ * worktree,
1069
+ * type Workspaced,
1070
+ * } from "@jr2/orchestrator";
1039
1071
  *
1040
1072
  * const runInput = z.object({ branch: z.string() });
1041
1073
  * type BodyInput = Workspaced<z.infer<typeof runInput>, "app">;
1042
1074
  *
1075
+ * const testTree = (context: BodyInput) => `${context.workspace.repos.app}/test` as const;
1076
+ *
1043
1077
  * const body = jr2Setup({
1044
- * types: {} as { input: BodyInput },
1078
+ * types: {} as { input: BodyInput; context: BodyInput },
1045
1079
  * actors: {
1080
+ * cut: worktree(),
1046
1081
  * tests: command(["node", "--test", arg("file")], { timeout: "5m" }),
1047
1082
  * fail: fail(),
1048
1083
  * },
1049
1084
  * }).createMachine({
1050
1085
  * id: "check",
1086
+ * context: ({ input }) => input,
1051
1087
  * on: { "workspace.lost": ".lost" },
1052
- * initial: "testing",
1088
+ * initial: "cutting",
1053
1089
  * states: {
1090
+ * cutting: {
1091
+ * invoke: {
1092
+ * src: "cut",
1093
+ * input: ({ context }) => ({ path: testTree(context), branch: context.branch }),
1094
+ * onDone: [{ guard: ({ event }) => isReady(event.output), target: "testing" }, { target: "notCut" }],
1095
+ * },
1096
+ * },
1054
1097
  * testing: {
1055
1098
  * invoke: {
1056
1099
  * src: "tests",
1057
- * input: { file: "test/greet.test.js" },
1100
+ * input: ({ context }) => ({ file: "test/greet.test.js", worktree: testTree(context) }),
1058
1101
  * onDone: [
1059
1102
  * {
1060
1103
  * guard: ({ event }) => event.output.outcome === "exited" && event.output.code === 0,
@@ -1067,6 +1110,7 @@ export function createExecPort(options: {
1067
1110
  * passed: { type: "final" },
1068
1111
  * // Not final states: each one fails the run, and the run ends `failed`.
1069
1112
  * failed: { invoke: { src: "fail", input: { reason: "the test did not pass" } } },
1113
+ * notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
1070
1114
  * lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
1071
1115
  * },
1072
1116
  * });
@@ -1074,7 +1118,6 @@ export function createExecPort(options: {
1074
1118
  * export const machine = workspace(body, {
1075
1119
  * input: runInput,
1076
1120
  * repos: { app: "https://github.com/you/app.git" },
1077
- * spec: ({ input }) => ({ branch: input.branch }),
1078
1121
  * });
1079
1122
  * ```
1080
1123
  *
@@ -1086,7 +1129,7 @@ export function createExecPort(options: {
1086
1129
  export function command<const A extends readonly (string | Arg)[], const Opt extends CommandOptions = CommandOptions>(
1087
1130
  argv: A,
1088
1131
  options?: Opt,
1089
- ): EffectLogic<CommandInput<A, Opt["pin"]>, CommandOutput> & OpenOf<EnvOpenParts<Opt>>;
1132
+ ): EffectLogic<CommandInput<A, Opt["pin"], Opt>, CommandOutput> & OpenOf<EnvOpenParts<Opt>>;
1090
1133
  /**
1091
1134
  * Make a `command` Effect whose argv is Open: `command(open, options)`, in a Machine that you
1092
1135
  * publish as a package. The Instance that registers the Machine binds the argv one time:
@@ -1103,7 +1146,7 @@ export function command<const A extends readonly (string | Arg)[], const Opt ext
1103
1146
  export function command<const Opt extends CommandOptions = CommandOptions>(
1104
1147
  argv: typeof open,
1105
1148
  options?: Opt,
1106
- ): EffectLogic<OpenCommandInput<Opt["pin"]>, CommandOutput> & OpenEffect<"argv" | EnvOpenParts<Opt>>;
1149
+ ): EffectLogic<OpenCommandInput<Opt["pin"], Opt>, CommandOutput> & OpenEffect<"argv" | EnvOpenParts<Opt>>;
1107
1150
  export function command(
1108
1151
  argv: readonly (string | Arg)[] | typeof open,
1109
1152
  options: CommandOptions = {},
@@ -0,0 +1,33 @@
1
+ // What a Harness container's env tells its start (ADR-0076): which names jr2 composed. The Harness
2
+ // starts as `effector nocore --harness-env -- node …`, and that gives the Harness PROCESS only the
3
+ // names jr2 composed, less each name that loads code from a path (`NODE_OPTIONS`, `LD_*`,
4
+ // `OPENSSL_CONF` …), because the image's `ENV` can name a file that Workspace code writes. The
5
+ // domain, where the Agent's programs run, still gets the container's whole start env.
6
+ //
7
+ // Two marks make the names jr2 composed visible inside the container:
8
+ // - `JR2_HARNESS_ENV_NAMES`: the names of the container's `env`, comma-separated.
9
+ // - The prefix `JR2_ENVFROM_` before each `envFrom` key (with a source's own prefix after it):
10
+ // its keys are not known here, a user's Secret or ConfigMap among them. The start puts each key
11
+ // back under its own name, below a name of `env`, as Kubernetes orders them.
12
+
13
+ /** The env name that lists the names of the Harness container's `env`. */
14
+ export const HARNESS_ENV_NAMES_ENV = "JR2_HARNESS_ENV_NAMES";
15
+
16
+ /** The prefix before each `envFrom` key of a Harness container. */
17
+ export const HARNESS_ENVFROM_PREFIX = "JR2_ENVFROM_";
18
+
19
+ /** The Harness container's `env` with `JR2_HARNESS_ENV_NAMES` last, and its `envFrom` with the
20
+ * prefix `JR2_ENVFROM_`. Both Harness placements use it: a Sandbox's and the Instance Harness. */
21
+ export function markHarnessEnv<E extends { name: string }, F extends { prefix?: string }>(
22
+ env: readonly E[],
23
+ envFrom: readonly F[],
24
+ ): { env: (E | { name: string; value: string })[]; envFrom: F[] } {
25
+ const names = [...new Set(env.map((v) => v.name))].filter((n) => n !== HARNESS_ENV_NAMES_ENV);
26
+ return {
27
+ env: [
28
+ ...env.filter((v) => v.name !== HARNESS_ENV_NAMES_ENV),
29
+ { name: HARNESS_ENV_NAMES_ENV, value: names.join(",") },
30
+ ],
31
+ envFrom: envFrom.map((f) => ({ ...f, prefix: `${HARNESS_ENVFROM_PREFIX}${f.prefix ?? ""}` })),
32
+ };
33
+ }
@@ -9,7 +9,8 @@
9
9
  //
10
10
  // This module is the pure half, shared by every seat that needs it:
11
11
  //
12
- // standIn(name) the value the Harness sees — deterministic and public
12
+ // heldStandIn(...) a held secret's Stand-in, minted per pod by the pod's maker — secret
13
+ // standIn(name) the fetch Stand-in's form — deterministic and public, for that one alone
13
14
  // heldSecretsOf(...) `jr2 up`'s normalization of the claimed entries: every refusal (R1–R13),
14
15
  // every warning, and the engine-neutral bindings each pod's manifest
15
16
  // (`held-<pod>.json`) takes its own from
@@ -23,23 +24,46 @@ import { createHash } from "node:crypto";
23
24
  import { readFile } from "node:fs/promises";
24
25
  import { isIP } from "node:net";
25
26
  import type { JR2Config, HarnessEnvVar } from "./config.ts";
26
- import { MODEL_KEYS, MODEL_KEY_PROVIDERS, SIGNING_CREDENTIALS } from "./model-keys.ts";
27
- import { HELD_CA_SECRET, HELD_SECRETS_SECRET, HELD_TLS_SECRET, INSTANCE_EFFECTOR, INSTANCE_SECRET } from "./names.ts";
27
+ import {
28
+ CREDENTIAL_FILES,
29
+ DOMAIN_WRITABLE_ROOTS,
30
+ MODEL_KEYS,
31
+ MODEL_KEY_PROVIDERS,
32
+ SIGNING_CREDENTIALS,
33
+ } from "./model-keys.ts";
34
+ import {
35
+ HELD_CA_SECRET,
36
+ HELD_SECRETS_SECRET,
37
+ HELD_TLS_SECRET,
38
+ INSTANCE_EFFECTOR,
39
+ INSTANCE_HARNESS_SERVICE,
40
+ INSTANCE_SECRET,
41
+ } from "./names.ts";
28
42
  import type { PlacementRoute } from "./parts.ts";
43
+ import { sign } from "./tokens.ts";
29
44
 
30
45
  /** The kit's Secrets no Harness container may read (R13): the Instance token and signing key, each
31
46
  * pod's literal held values, the leaves, the CA, and every Effector's (ADR-0068) — the Instance
32
47
  * Effector's Effect secrets and key, each Workspace's Sandbox Effector Secret (`jr2-effector-<pod>`),
33
48
  * each Sandbox's Effector key (`<sandbox>-effector`), and each `git.credentials` entry's clone and
34
- * push Secrets (`jr2-git-<hash>`, `jr2-git-push-<hash>`, ADR-0051). */
35
- const KIT_SECRETS: readonly string[] = [INSTANCE_SECRET, HELD_TLS_SECRET, HELD_CA_SECRET, INSTANCE_EFFECTOR];
49
+ * push Secrets (`jr2-git-<hash>`, `jr2-git-push-<hash>`, ADR-0051) — and each pod's own credentials
50
+ * (ADR-0076): the Instance Harness's (`jr2-instance-harness-token`) and each Sandbox's
51
+ * (`<sandbox>-token`), which hold the real control token beside the Stand-ins. */
52
+ const KIT_SECRETS: readonly string[] = [
53
+ INSTANCE_SECRET,
54
+ HELD_TLS_SECRET,
55
+ HELD_CA_SECRET,
56
+ INSTANCE_EFFECTOR,
57
+ `${INSTANCE_HARNESS_SERVICE}-token`,
58
+ ];
36
59
  const KIT_SECRET_PREFIXES: readonly string[] = [`${HELD_SECRETS_SECRET}-`, "jr2-effector-", "jr2-git-"];
37
60
  const isKitSecret = (name: string): boolean =>
38
61
  KIT_SECRETS.includes(name) ||
39
62
  name === HELD_SECRETS_SECRET ||
40
63
  KIT_SECRET_PREFIXES.some((p) => name.startsWith(p)) ||
41
- // A Sandbox's own Effector key: every Sandbox's name starts `ws-` (`workspaceName`).
42
- (name.startsWith("ws-") && name.endsWith("-effector"));
64
+ // A Sandbox's own Effector key and its credentials: every Sandbox's name starts `ws-`
65
+ // (`workspaceName`).
66
+ (name.startsWith("ws-") && (name.endsWith("-effector") || name.endsWith("-token")));
43
67
 
44
68
  /**
45
69
  * The pod key of one Workspace (ADR-0059): its registered workflow and the placement route to its
@@ -70,18 +94,36 @@ export function providerClaims(config: JR2Config, models: readonly string[]): st
70
94
  }
71
95
 
72
96
  /**
73
- * The Stand-in for one held secret: what its env var holds in the Harness container.
74
- *
75
- * Deterministic and public — it is not a secret, the Agent reads it from its env, and it means
76
- * something only to the Custodian and only toward a bound host. Unique per name, so one request can
77
- * carry two secrets' Stand-ins and the Custodian can tell them apart. ASCII letters, digits, `-`
78
- * and `_` only, so it is a valid header and env value; and it never contains `sk-ant-oat`, which pi
79
- * reads as an Anthropic OAuth token and answers with different auth headers.
97
+ * The PUBLIC Stand-in form, `jr2-held-<NAME>`. Since ADR-0076 one Stand-in alone takes it: the fetch
98
+ * Stand-in, `standIn("JR2_SANDBOX_TOKEN")`, which `jr2-upload-pack` holds as a constant and the
99
+ * Custodian swaps on the fetch route alone. Every other Stand-in is secret ({@link heldStandIn}).
80
100
  */
81
101
  export function standIn(name: string): string {
82
102
  return `jr2-held-${name}`;
83
103
  }
84
104
 
105
+ /** What a Stand-in must never contain (ADR-0059): pi reads a key with it as an Anthropic OAuth
106
+ * token, and answers with different auth headers. */
107
+ const OAUTH_MARK = "sk-ant-oat";
108
+
109
+ /**
110
+ * The SECRET Stand-in of one held secret in one pod (ADR-0059, ADR-0076): what the Harness process
111
+ * holds for it, as the file `/run/jr2/standins/<NAME>`, and what the Custodian of that pod swaps for
112
+ * the value. `jr2-held-<NAME>.<hmac>` — the readable prefix keeps a log scrub's pattern, and the
113
+ * HMAC under the signing key makes it unguessable, so Workspace code can spend only the secrets its
114
+ * `workspace()` grants. `pod` is the Sandbox's name, or `instance` for the Instance Harness. The
115
+ * pod's maker mints it: the Orchestrator for a Sandbox, `jr2 up` for the Instance Harness and a
116
+ * check pod. ASCII letters, digits, `-`, `_` and `.` only, so it is a valid header and env value,
117
+ * and it never contains `sk-ant-oat`: a signature that would is signed again under a counter.
118
+ */
119
+ export function heldStandIn(key: Buffer, pod: string, name: string): string {
120
+ for (let n = 0; ; n++) {
121
+ const domain = `held-standin:${pod}/${name}${n === 0 ? "" : `#${n}`}`;
122
+ const value = `${standIn(name)}.${sign(key, domain)}`;
123
+ if (!value.includes(OAUTH_MARK)) return value;
124
+ }
125
+ }
126
+
85
127
  /** The request headers a Stand-in is swapped in when a held secret names none — the four places a
86
128
  * model API carries its key: `Authorization: Bearer` (OpenAI-compatible, `ANTHROPIC_AUTH_TOKEN`),
87
129
  * `x-api-key` (Anthropic), `x-goog-api-key` (Gemini), `api-key` (Azure). */
@@ -99,10 +141,15 @@ export const CREDENTIAL_HEADERS: readonly string[] = [...DEFAULT_CREDENTIAL_HEAD
99
141
  /** The env var the custom provider's key rides as (ADR-0018) — held since ADR-0059, and the kit's. */
100
142
  export const PROVIDER_KEY_NAME = "JR2_PROVIDER_API_KEY";
101
143
 
102
- /** The Sandbox token's env var (ADR-0013) — a held secret of the kit's own, bound to the
103
- * Orchestrator (ADR-0059). The Harness container holds its Stand-in; the Custodian holds the token. */
144
+ /** The fetch token's held name (ADR-0013, ADR-0076) — a held secret of the kit's own, bound to the
145
+ * Orchestrator's fetch route (ADR-0059). Its Stand-in is the public `jr2-held-JR2_SANDBOX_TOKEN`,
146
+ * which `jr2-upload-pack` holds; the Custodian holds the token. */
104
147
  export const SANDBOX_TOKEN_NAME = "JR2_SANDBOX_TOKEN";
105
148
 
149
+ /** The control token's held name (ADR-0076) — the kit's own, bound to the agent routes. Its Stand-in
150
+ * is secret: the Harness process alone holds it, as `/run/jr2/control/standin`. */
151
+ export const CONTROL_TOKEN_NAME = "JR2_CONTROL_TOKEN";
152
+
106
153
  /** The env names the mechanism owns on a Harness container (ADR-0059): the proxy, the trust
107
154
  * bundles, and every `JR2_` name. A held secret may not choose them, and while a held secret exists,
108
155
  * `harness.env` may not either — the Custodian cannot chain to a second proxy yet (R11). */
@@ -233,6 +280,35 @@ export function heldSecretsOf(
233
280
  catalogHosts.set(id, target.port === 443 ? target.host : `${target.host}:${target.port}`);
234
281
  }
235
282
 
283
+ // jr2's own names (ADR-0076): the Harness's start takes a `JR2_` name from jr2's composition alone,
284
+ // and refuses to start on an envFrom key that starts with `JR2_` (`effector nocore --harness-env`).
285
+ // A user's `JR2_MENU_ONLY` or `JR2_START_ENV` would turn the domain off or replace its env.
286
+ for (const [i, v] of env.entries()) {
287
+ if (v.name.startsWith("JR2_")) {
288
+ throw new Error(
289
+ `harness.env[${i}] sets ${v.name}: JR2_ names are jr2's own, and the Harness takes them from jr2 alone ` +
290
+ "(ADR-0076). Name the env var your program reads.",
291
+ );
292
+ }
293
+ }
294
+ for (const [i, ref] of (harness.envFrom ?? []).entries()) {
295
+ const lookup = ref.secretRef
296
+ ? `secret/${ref.secretRef.name}`
297
+ : ref.configMapRef
298
+ ? `configmap/${ref.configMapRef.name}`
299
+ : "";
300
+ const own = (lookups.envFromKeys?.get(lookup) ?? [])
301
+ .map((key) => (ref.prefix ?? "") + key)
302
+ .find((key) => key.startsWith("JR2_"));
303
+ if (own !== undefined) {
304
+ const where = ref.secretRef ? `Secret "${ref.secretRef.name}"` : `ConfigMap "${ref.configMapRef?.name}"`;
305
+ throw new Error(
306
+ `harness.envFrom[${i}] loads ${where}, which carries ${own}: JR2_ names are jr2's own, and the Harness ` +
307
+ "does not start with one from envFrom (ADR-0076). Drop the key, or give the source a prefix.",
308
+ );
309
+ }
310
+ }
311
+
236
312
  // R1: a known model key the Agent could read.
237
313
  for (const [i, v] of env.entries()) {
238
314
  if (!(v.name in MODEL_KEYS)) continue;
@@ -268,6 +344,39 @@ export function heldSecretsOf(
268
344
  }
269
345
  }
270
346
 
347
+ // A credential file that the Harness process reads (ADR-0076): never one that the domain writes.
348
+ for (const [i, v] of env.entries()) {
349
+ if (!CREDENTIAL_FILES.includes(v.name)) continue;
350
+ const path = v.value;
351
+ if (path !== undefined) {
352
+ const writable = DOMAIN_WRITABLE_ROOTS.find((root) => path === root || path.startsWith(`${root}/`));
353
+ if (!path.startsWith("/") || writable !== undefined || path.split("/").includes("..")) {
354
+ throw new Error(
355
+ `harness.env[${i}] sets ${v.name} to "${path}", ${writable !== undefined ? `which is in ${writable}, a folder the Agent's code writes` : "which is not an absolute path"}: the Harness process reads that file, and code that writes it acts outside the Agent's confinement (ADR-0076). Point it at a file of the image that the Sandbox's user cannot write.`,
356
+ );
357
+ }
358
+ }
359
+ warnings.push(
360
+ `harness.env sets ${v.name}: the Harness process reads ${path !== undefined ? `"${path}"` : "the file it names"}, ` +
361
+ "so the Sandbox's user must not be able to write it — a folder of the image that the user owns is one the " +
362
+ "Agent's code writes (ADR-0076)",
363
+ );
364
+ }
365
+ for (const [i, ref] of (harness.envFrom ?? []).entries()) {
366
+ const lookup = ref.secretRef
367
+ ? `secret/${ref.secretRef.name}`
368
+ : ref.configMapRef
369
+ ? `configmap/${ref.configMapRef.name}`
370
+ : "";
371
+ for (const key of (lookups.envFromKeys?.get(lookup) ?? []).map((k) => (ref.prefix ?? "") + k)) {
372
+ if (!CREDENTIAL_FILES.includes(key)) continue;
373
+ warnings.push(
374
+ `harness.envFrom[${i}] sets ${key}: the Harness process reads the file it names, so the Sandbox's user must ` +
375
+ "not be able to write it — a folder of the image that the user owns is one the Agent's code writes (ADR-0076)",
376
+ );
377
+ }
378
+ }
379
+
271
380
  type Entry = {
272
381
  at: string;
273
382
  name: string;
package/src/holds.ts CHANGED
@@ -38,6 +38,9 @@ export type Hold = {
38
38
  /** The Sandbox whose token may read the answer (ADR-0013) — the registration's, kept because the
39
39
  * answer outlives a registration that a route out of the Agent's state ends. */
40
40
  sandbox?: string;
41
+ /** The slot whose stated control requests may read the answer (ADR-0076) — the registration's,
42
+ * kept with `sandbox` for the same reason. */
43
+ slot?: string;
41
44
  /** The invoke id of the Agent's state when the pick was made: a re-registration of the same
42
45
  * address by another state is another Turn. */
43
46
  invokeId?: string;