@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
package/src/custodian.ts CHANGED
@@ -21,14 +21,17 @@
21
21
  // so the two pod shapes cannot drift
22
22
  //
23
23
  // The Custodian is a separate container from the Harness, and that is the point: the Agent
24
- // executes code in the Harness container and nothing here (ADR-0005). What it holds — the
25
- // Sandbox token and every held secret its pod claims — is mounted into this container alone, and
26
- // `shareProcessNamespace` stays off, so the Agent cannot read its env, its files or its `/proc`.
24
+ // executes code in the Harness container and nothing here (ADR-0005). What it holds — the pod's
25
+ // control and fetch tokens and every held secret its pod claims — is mounted into this container
26
+ // alone, and `shareProcessNamespace` stays off, so the Agent cannot read its env, its files or its
27
+ // `/proc`. The Harness container gets the Stand-ins, every one but the public fetch Stand-in
28
+ // secret, as files under `/run/jr2/` that no child the Harness confines can read (ADR-0076).
27
29
 
28
30
  import { readFileSync } from "node:fs";
29
31
  import type { HarnessEnvVar } from "./config.ts";
30
32
  import type { ResourceRequirements } from "./size.ts";
31
33
  import {
34
+ CONTROL_TOKEN_NAME,
32
35
  CREDENTIAL_HEADERS,
33
36
  SANDBOX_TOKEN_NAME,
34
37
  standIn,
@@ -37,10 +40,12 @@ import {
37
40
  } from "./held-secrets.ts";
38
41
  import {
39
42
  CA_CONFIGMAP,
40
- CUSTODIAN_CONTROL_PORT,
43
+ CUSTODIAN_CONTROL_SOCKET,
44
+ CUSTODIAN_FETCH_PORT,
41
45
  CUSTODIAN_HEALTH_PORT,
42
46
  CUSTODIAN_PORT,
43
47
  CUSTODIAN_PROGRAM_KEY,
48
+ CUSTODIAN_SOCKET_DIR,
44
49
  HELD_CONFIGMAP,
45
50
  HELD_TLS_SECRET,
46
51
  heldSecretsSecret,
@@ -96,8 +101,60 @@ export const CUSTODIAN_ARGS: readonly string[] = [
96
101
  "warning",
97
102
  ];
98
103
 
99
- /** The address the Harness reaches the Custodian's control listener at: its Menu and the ask. */
100
- export const CUSTODIAN_URL = `http://127.0.0.1:${CUSTODIAN_CONTROL_PORT}`;
104
+ /** The address of the Custodian's fetch listener: the ask alone (ADR-0053), which
105
+ * `jr2-upload-pack` makes from the domain. The Harness container gets it as `JR2_CUSTODIAN_URL`,
106
+ * the one `JR2_*` name that a confined process keeps (ADR-0076). The agent routes are not here. */
107
+ export const CUSTODIAN_URL = `http://127.0.0.1:${CUSTODIAN_FETCH_PORT}`;
108
+
109
+ /** The Harness env that names the Custodian's control socket ({@link CUSTODIAN_CONTROL_SOCKET}):
110
+ * where the Harness sends each control request — the surface read, a pick, a delivery's poll and a
111
+ * notice's acknowledgment (ADR-0076). A path, never a value; the domain's env does not keep it. */
112
+ export const CUSTODIAN_SOCKET_ENV = "JR2_CUSTODIAN_SOCKET";
113
+ /** The volume of the control socket's folder ({@link CUSTODIAN_SOCKET_DIR}): an emptyDir in the
114
+ * Harness container and the Custodian container, and in no other container of the pod. */
115
+ export const CUSTODIAN_SOCKET_VOLUME = "custodian-socket";
116
+
117
+ /** Where the Harness container holds its Stand-ins (ADR-0059, ADR-0076): a read-only mount that no
118
+ * child the Harness confines can read — the confiner denies all of `/run/jr2`. Never env. */
119
+ export const HARNESS_CREDENTIALS = "/run/jr2";
120
+ /** The secret control Stand-in, as the Harness reads it once at start (C1). */
121
+ export const CONTROL_STANDIN_FILE = `${HARNESS_CREDENTIALS}/control/standin`;
122
+ /** The directory of the held secrets' Stand-ins, one file per name: `<dir>/<NAME>`. */
123
+ export const STANDINS_DIR = `${HARNESS_CREDENTIALS}/standins`;
124
+ /** The Harness env that names the control Stand-in's file; its default is {@link CONTROL_STANDIN_FILE}. */
125
+ export const CONTROL_STANDIN_FILE_ENV = "JR2_CONTROL_STANDIN_FILE";
126
+ /** The Harness env that names the held secrets the `workspace()` grants a confined child (its
127
+ * `secrets` list, ADR-0076), comma-separated: names, never a value. Absent → none is granted. */
128
+ export const GRANTED_SECRETS_ENV = "JR2_GRANTED_SECRETS";
129
+
130
+ /**
131
+ * The keys of a pod's credentials Secret (a Sandbox's `<sandbox>-token`): the fetch token, the
132
+ * control token, the secret control Stand-in, and one secret Stand-in per held secret
133
+ * ({@link heldStandInKey}). The pod's maker mints them all. The tokens are projected into the
134
+ * Custodian alone; each Stand-in into the Custodian, for its swap, and the Harness container.
135
+ */
136
+ export const CREDENTIAL_KEYS = {
137
+ fetchToken: "token",
138
+ controlToken: "control-token",
139
+ controlStandIn: "control-standin",
140
+ } as const;
141
+
142
+ /** The key of one held secret's secret Stand-in in its pod's credentials Secret. */
143
+ export function heldStandInKey(name: string): string {
144
+ return `standin-${name}`;
145
+ }
146
+
147
+ /** The file of a Stand-in among the Custodian's values: beside its value, `<NAME>.standin`. A held
148
+ * name is an env var's, so it holds no `.` and never meets another value's file. */
149
+ export function standInValueFile(name: string): string {
150
+ return `${name}.standin`;
151
+ }
152
+
153
+ /** The path prefixes each control credential may ride, after the control listener's rewrite
154
+ * (ADR-0076, C3): the control token on the agent routes alone, the fetch token on the fetch route
155
+ * alone. The program's path guard refuses either one elsewhere, before any swap. */
156
+ export const CONTROL_PATHS = "/agents/";
157
+ export const FETCH_PATHS = "/sandboxes/";
101
158
 
102
159
  /** Where the Orchestrator's Service is, from inside a Harness pod. */
103
160
  export type OrchestratorAddress = { host: string; port: number };
@@ -409,9 +466,14 @@ function isIpLiteral(host: string): boolean {
409
466
  * The Custodian's static bootstrap (ADR-0059): Envoy's whole configuration, rendered once per
410
467
  * converge by `jr2 up`, identical for every Harness pod of the Instance.
411
468
  *
412
- * control 127.0.0.1:8081 the Menu, a pick's answer and the ask, plain HTTP, to the Orchestrator's Service —
413
- * four routes and nothing else, each swapping the Sandbox token's
414
- * Stand-in for the token (ADR-0013, ADR-0053)
469
+ * control /run/jr2/custodian/control.sock
470
+ * the four agent routes — the Menu, a pick, a delivery's poll and a
471
+ * notice's acknowledgment — plain HTTP, to the Orchestrator's Service.
472
+ * They swap the control Stand-in for the control token. A Unix socket
473
+ * in a folder that no confined process can write, so no process in
474
+ * the domain can take this listener's place (ADR-0013, ADR-0076)
475
+ * fetch 127.0.0.1:8081 the ask alone, plain HTTP, to the same Service: it swaps the public
476
+ * fetch Stand-in for the fetch token (ADR-0053, ADR-0076)
415
477
  * health 0.0.0.0:15021 GET /healthz, for the kubelet, which cannot reach loopback
416
478
  * egress 127.0.0.1:15001 CONNECT only — present when a held secret exists; a bound target
417
479
  * goes to its intercept listener, every other one is tunneled
@@ -428,55 +490,76 @@ export function custodianBootstrap(manifest: HeldManifest, orchestrator: Orchest
428
490
  metadata: meta({ role: "control", route: name }),
429
491
  });
430
492
 
493
+ // One listener toward the Orchestrator, with its routes and the deny route after them: a route
494
+ // of the other listener gets the deny route's 404 here.
495
+ const toOrchestrator = (name: string, address: Json, routes: Json[]): Json => ({
496
+ name,
497
+ address,
498
+ filter_chains: [
499
+ {
500
+ filters: [
501
+ hcm(name, {
502
+ target: orchestratorTarget,
503
+ idleSeconds: 3600,
504
+ lua: true,
505
+ local: ORCHESTRATOR_UNREACHABLE,
506
+ routes: {
507
+ virtual_hosts: [
508
+ {
509
+ name: "orchestrator",
510
+ domains: ["*"],
511
+ routes: [
512
+ ...routes,
513
+ {
514
+ name: "deny",
515
+ match: { prefix: "/" },
516
+ direct_response: { status: 404 },
517
+ metadata: meta({ role: "deny" }),
518
+ },
519
+ ],
520
+ },
521
+ ],
522
+ },
523
+ }),
524
+ ],
525
+ },
526
+ ],
527
+ });
528
+
431
529
  const listeners: Json[] = [
432
- {
433
- name: "control",
434
- address: { socket_address: { address: "127.0.0.1", port_value: CUSTODIAN_CONTROL_PORT } },
435
- filter_chains: [
436
- {
437
- filters: [
438
- hcm("control", {
439
- target: orchestratorTarget,
440
- idleSeconds: 3600,
441
- lua: true,
442
- local: ORCHESTRATOR_UNREACHABLE,
443
- routes: {
444
- virtual_hosts: [
445
- {
446
- name: "orchestrator",
447
- domains: ["*"],
448
- routes: [
449
- // A read retries any reset. A pick or an ask retries only a reset before
450
- // it was sent: one the Orchestrator may have read is not sent twice.
451
- route("surface", "GET", "/agents/[^/]+/surface", {
452
- retry_policy: { retry_on: "reset,connect-failure", num_retries: 2 },
453
- }),
454
- route("events", "POST", "/agents/[^/]+/events", {
455
- retry_policy: { retry_on: "reset-before-request,connect-failure", num_retries: 2 },
456
- }),
457
- // An outstanding pick's answer, long-polled (ADR-0024): a read, so any reset
458
- // is retried — an Orchestrator restart mid-poll costs a retry, not the pick.
459
- route("deliveries", "GET", "/agents/[^/]+/deliveries/[^/]+", {
460
- retry_policy: { retry_on: "reset,connect-failure", num_retries: 2 },
461
- }),
462
- route("fetch", "POST", "/fetch", {
463
- retry_policy: { retry_on: "reset-before-request,connect-failure", num_retries: 2 },
464
- }),
465
- {
466
- name: "deny",
467
- match: { prefix: "/" },
468
- direct_response: { status: 404 },
469
- metadata: meta({ role: "deny" }),
470
- },
471
- ],
472
- },
473
- ],
474
- },
475
- }),
476
- ],
477
- },
478
- ],
479
- },
530
+ // The agent routes, on the control socket (ADR-0076). A Unix socket, not a loopback port: a
531
+ // confined process can bind a free loopback port while the Custodian restarts, and it then gets
532
+ // the control Stand-in on the Harness's next request. It cannot make a socket under `/run/jr2`.
533
+ // Envoy removes a socket that a stopped Envoy left, before it binds. Mode 0666, because the
534
+ // Harness runs as a uid that is not Envoy's: the folder, not the mode, keeps the domain out.
535
+ toOrchestrator("control", { pipe: { path: CUSTODIAN_CONTROL_SOCKET, mode: 0o666 } }, [
536
+ // A read retries any reset. A pick retries only a reset before it was sent: one the
537
+ // Orchestrator may have read is not sent twice.
538
+ route("surface", "GET", "/agents/[^/]+/surface", {
539
+ retry_policy: { retry_on: "reset,connect-failure", num_retries: 2 },
540
+ }),
541
+ route("events", "POST", "/agents/[^/]+/events", {
542
+ retry_policy: { retry_on: "reset-before-request,connect-failure", num_retries: 2 },
543
+ }),
544
+ // An outstanding pick's answer, long-polled (ADR-0024): a read, so any reset is retried — an
545
+ // Orchestrator restart mid-poll costs a retry, not the pick.
546
+ route("deliveries", "GET", "/agents/[^/]+/deliveries/[^/]+", {
547
+ retry_policy: { retry_on: "reset,connect-failure", num_retries: 2 },
548
+ }),
549
+ // The acknowledgment of the notices a step boundary appended (ADR-0062, ADR-0076): marking
550
+ // them heard twice changes nothing, so any reset is retried.
551
+ route("notices", "POST", "/agents/[^/]+/notices", {
552
+ retry_policy: { retry_on: "reset,connect-failure", num_retries: 2 },
553
+ }),
554
+ ]),
555
+ // The ask (ADR-0053), on loopback: `jr2-upload-pack` runs in the domain, and the fetch Stand-in
556
+ // is public, so a process that takes this port gets nothing secret. Like a pick, it retries only
557
+ // a reset before it was sent.
558
+ toOrchestrator("fetch", { socket_address: { address: "127.0.0.1", port_value: CUSTODIAN_FETCH_PORT } }, [
559
+ route("fetch", "POST", "/fetch", {
560
+ retry_policy: { retry_on: "reset-before-request,connect-failure", num_retries: 2 },
561
+ }),
562
+ ]),
480
563
  {
481
564
  name: "health",
482
565
  address: { socket_address: { address: "0.0.0.0", port_value: CUSTODIAN_HEALTH_PORT } },
@@ -792,24 +875,40 @@ function luaList(values: readonly string[]): string {
792
875
  /**
793
876
  * The script the Custodian runs: a generated `HELD` table, then `dofile` of the fixed program
794
877
  * (`CUSTODIAN_PROGRAM`, mounted beside it from `jr2-held`). The table names every secret this pod
795
- * holds — the Sandbox token first, then the manifest's — with the headers and paths each may ride
796
- * and the targets each is bound to. It never holds a value: the program reads those from the
797
- * mounted files at start.
878
+ * holds — the control credentials first, then the manifest's — with the headers and paths each may
879
+ * ride and the targets each is bound to. It never holds a value, and never a secret Stand-in: the
880
+ * program reads both from the mounted files at start (ADR-0059).
881
+ *
882
+ * The control credentials (ADR-0076, C3): the control token, path-restricted to the agent routes,
883
+ * whose secret Stand-in the program reads from its values; and, on a pod that serves a Sandbox
884
+ * (`fetch`), the fetch token, path-restricted to the fetch route, whose Stand-in is the one public
885
+ * one and so rides the table. The Instance Harness and a check pod serve no fetch, and hold the
886
+ * control token alone.
798
887
  */
799
- export function custodianScript(manifest: HeldManifest): string {
888
+ export function custodianScript(manifest: HeldManifest, opts: { fetch: boolean }): string {
889
+ const control = [
890
+ `{ name = ${lua(CONTROL_TOKEN_NAME)}, headers = { "authorization" }, paths = ${luaList([CONTROL_PATHS])} }`,
891
+ ...(opts.fetch
892
+ ? [
893
+ `{ name = ${lua(SANDBOX_TOKEN_NAME)}, headers = { "authorization" }, standIn = ${lua(standIn(SANDBOX_TOKEN_NAME))}, ` +
894
+ `paths = ${luaList([FETCH_PATHS])} }`,
895
+ ]
896
+ : []),
897
+ ];
800
898
  const lines = [
801
899
  "-- Generated by `jr2 up` from its pod's held manifest (ADR-0059). Names, headers, paths, targets; never a value.",
802
900
  `HELD = { secrets = {}, targets = {}, hosts = ${targetsOf(manifest).length} }`,
803
901
  `HELD.credential = ${luaList(CREDENTIAL_HEADERS)}`,
804
- `HELD.secrets[1] = { name = ${lua(SANDBOX_TOKEN_NAME)}, headers = { "authorization" } }`,
805
- "HELD.control = { HELD.secrets[1] }",
902
+ ...control.map((entry, i) => `HELD.secrets[${i + 1}] = ${entry}`),
903
+ `HELD.control = { ${control.map((_, i) => `HELD.secrets[${i + 1}]`).join(", ")} }`,
806
904
  ];
905
+ const offset = control.length + 1;
807
906
  manifest.secrets.forEach((s, i) => {
808
907
  const paths = s.paths ? `, paths = ${luaList(s.paths)}` : "";
809
- lines.push(`HELD.secrets[${i + 2}] = { name = ${lua(s.name)}, headers = ${luaList(s.headers)}${paths} }`);
908
+ lines.push(`HELD.secrets[${i + offset}] = { name = ${lua(s.name)}, headers = ${luaList(s.headers)}${paths} }`);
810
909
  });
811
910
  for (const t of targetsOf(manifest)) {
812
- const refs = t.secrets.map((s) => `HELD.secrets[${manifest.secrets.indexOf(s) + 2}]`);
911
+ const refs = t.secrets.map((s) => `HELD.secrets[${manifest.secrets.indexOf(s) + offset}]`);
813
912
  lines.push(`HELD.targets[${lua(t.key)}] = { ${refs.join(", ")} }`);
814
913
  }
815
914
  // `dofile`, not a copy: Envoy's Lua opens the standard libraries (the program already reads its
@@ -830,12 +929,26 @@ export type CustodianCompositionOptions = {
830
929
  * makes the pod: the Orchestrator at provision (`sandboxCustodianConfigMap`, owned by the CR),
831
930
  * `jr2 up` for the Instance Harness (`INSTANCE_CUSTODIAN_CONFIGMAP`). */
832
931
  config: string;
833
- /** Where the Sandbox token lives: a Sandbox's `<name>-token` Secret, or the Instance Harness's key
834
- * of `jr2-instance`. Mounted into the Custodian alone (ADR-0013). */
835
- token: { secret: string; key: string };
836
- /** This pod's Sandbox — the name the ask is addressed by (ADR-0053). Absent on the Instance
837
- * Harness, which mounts no Repo and answers an ask by saying so. */
932
+ /**
933
+ * The pod's credentials Secret (ADR-0076): its control token, its secret control Stand-in, its
934
+ * fetch token (a Sandbox alone) and one secret Stand-in per held secret, under
935
+ * {@link CREDENTIAL_KEYS} and {@link heldStandInKey} — a key may be renamed here. The pod's maker
936
+ * mints them: the Orchestrator a Sandbox's (`<sandbox>-token`), `jr2 up` the Instance Harness's
937
+ * and a check pod's. The tokens are mounted into the Custodian alone (ADR-0013); each Stand-in
938
+ * into the Custodian and the Harness container, as files.
939
+ */
940
+ credentials: {
941
+ secret: string;
942
+ keys?: Partial<Record<keyof typeof CREDENTIAL_KEYS, string>>;
943
+ };
944
+ /** This pod's Sandbox — the name the ask is addressed by (ADR-0053), and the reason the pod holds
945
+ * a fetch token. Absent on the Instance Harness and a check pod, which mount no Repo and answer an
946
+ * ask by saying so. */
838
947
  sandbox?: string;
948
+ /** The held secrets the `workspace()` grants its confined children (its `secrets` list, ADR-0076):
949
+ * the Harness puts their Stand-ins, and only theirs, in a child's env. Names the manifest does not
950
+ * hold are left out. Absent → none. */
951
+ granted?: readonly string[];
839
952
  /** The names of the Instance objects that this Custodian reads in place of the defaults. The
840
953
  * defaults are the Instance's own: `pod`'s literal values (`heldSecretsSecret(pod)`),
841
954
  * `jr2-held-tls`, `jr2-ca`, and the program in `jr2-held`.
@@ -850,15 +963,24 @@ export type CustodianCompositionOptions = {
850
963
  };
851
964
 
852
965
  type Container = Record<string, unknown>;
966
+
967
+ /** The Harness container's credentials volume: its Stand-ins, mounted at {@link HARNESS_CREDENTIALS}. */
968
+ export const HARNESS_CREDENTIALS_VOLUME = "harness-credentials";
853
969
  type Volume = Record<string, unknown>;
854
970
  type VolumeMount = { name: string; mountPath: string; readOnly?: boolean };
855
971
 
856
972
  /** What one Harness pod gains from the Custodian. */
857
973
  export type CustodianComposition = {
858
- /** The Harness container's env — in `env`, after `harness.env`, before the wire's gate (§5.1). In
859
- * Kubernetes `env` wins over `envFrom`, so a Stand-in overrides a same-named key of any `envFrom`
860
- * Secret, even one that changes after the converge. */
974
+ /** The Harness container's env — in `env`, after `harness.env`, before the wire's gate (§5.1):
975
+ * the Custodian's fetch address and control socket, the control Stand-in's file, the granted
976
+ * names, the proxy and the
977
+ * trust. Never a Stand-in: those are files ({@link HARNESS_CREDENTIALS}), so no `envFrom` key is
978
+ * hidden by one, and `jr2 up` refuses a held name that is a key of an `envFrom` Secret (R14). */
861
979
  harnessEnv: HarnessEnvVar[];
980
+ /** The Harness container's mounts: the trust bundles, its Stand-ins at {@link HARNESS_CREDENTIALS},
981
+ * and the control socket's folder below it. Each is read-only. A placement mounts the
982
+ * conversations' volume at `/run/jr2/conversations`, below the Stand-ins too: the kubelet makes
983
+ * the mount point of a volume that is below a projected volume (ADR-0076). */
862
984
  harnessMounts: VolumeMount[];
863
985
  custodianContainer: Container;
864
986
  volumes: Volume[];
@@ -892,15 +1014,22 @@ export function custodianComposition(manifest: HeldManifest, opts: CustodianComp
892
1014
  program = HELD_CONFIGMAP,
893
1015
  } = opts.reads ?? {};
894
1016
 
1017
+ const keys = { ...CREDENTIAL_KEYS, ...opts.credentials.keys };
1018
+ const fetch = opts.sandbox !== undefined;
1019
+ const heldNames = new Set(manifest.secrets.map((s) => s.name));
1020
+ const granted = (opts.granted ?? []).filter((n) => heldNames.has(n));
1021
+
1022
+ // No Stand-in rides env (ADR-0059, ADR-0076): each is a file under `/run/jr2/`, below.
895
1023
  const harnessEnv: HarnessEnvVar[] = [
896
1024
  { name: "JR2_CUSTODIAN_URL", value: CUSTODIAN_URL },
897
- { name: SANDBOX_TOKEN_NAME, value: standIn(SANDBOX_TOKEN_NAME) },
1025
+ { name: CUSTODIAN_SOCKET_ENV, value: CUSTODIAN_CONTROL_SOCKET },
1026
+ { name: CONTROL_STANDIN_FILE_ENV, value: CONTROL_STANDIN_FILE },
1027
+ ...(granted.length ? [{ name: GRANTED_SECRETS_ENV, value: granted.join(",") }] : []),
898
1028
  ];
899
1029
  if (held) {
900
1030
  const proxy = `http://127.0.0.1:${CUSTODIAN_PORT}`;
901
1031
  const noProxy = "localhost,127.0.0.1,::1";
902
1032
  harnessEnv.push(
903
- ...manifest.secrets.map((s) => ({ name: s.name, value: standIn(s.name) })),
904
1033
  { name: "HTTPS_PROXY", value: proxy },
905
1034
  { name: "https_proxy", value: proxy },
906
1035
  { name: "NO_PROXY", value: noProxy },
@@ -923,7 +1052,20 @@ export function custodianComposition(manifest: HeldManifest, opts: CustodianComp
923
1052
  // Group-readable, for the pod's fsGroup: the Custodian runs as Envoy's own uid.
924
1053
  defaultMode: 0o440,
925
1054
  sources: [
926
- { secret: { name: opts.token.secret, items: [{ key: opts.token.key, path: SANDBOX_TOKEN_NAME }] } },
1055
+ // The control credentials (ADR-0076): the control token beside its secret Stand-in, and
1056
+ // on a Sandbox the fetch token, whose Stand-in is public and rides the script. Then each
1057
+ // held secret's secret Stand-in, beside the value the Custodian swaps it for.
1058
+ {
1059
+ secret: {
1060
+ name: opts.credentials.secret,
1061
+ items: [
1062
+ { key: keys.controlToken, path: CONTROL_TOKEN_NAME },
1063
+ { key: keys.controlStandIn, path: standInValueFile(CONTROL_TOKEN_NAME) },
1064
+ ...(fetch ? [{ key: keys.fetchToken, path: SANDBOX_TOKEN_NAME }] : []),
1065
+ ...manifest.secrets.map((s) => ({ key: heldStandInKey(s.name), path: standInValueFile(s.name) })),
1066
+ ],
1067
+ },
1068
+ },
927
1069
  ...(literal.length ? [{ secret: { name: literals, items: literal.map((n) => ({ key: n, path: n })) } }] : []),
928
1070
  ...manifest.secrets.flatMap((s) =>
929
1071
  s.source.kind === "secret"
@@ -962,6 +1104,31 @@ export function custodianComposition(manifest: HeldManifest, opts: CustodianComp
962
1104
  ]
963
1105
  : []),
964
1106
  ...(trust ? [{ name: "ca", configMap: { name: trustMap } }] : []),
1107
+ // The control socket's folder (ADR-0076): the Custodian makes the socket, and the Harness
1108
+ // connects to it. It goes with the pod, and it outlives a restart of either container, so the
1109
+ // socket's path never changes.
1110
+ { name: CUSTODIAN_SOCKET_VOLUME, emptyDir: {} },
1111
+ // The Harness container's Stand-ins (ADR-0076): the secret control Stand-in and each held
1112
+ // secret's, as files under `/run/jr2/`, which the confiner denies to every child the Harness
1113
+ // starts. Group-readable for the pod's fsGroup — the Harness runs as no fixed uid, and the
1114
+ // kubelet writes the files as root — so 0440 is the least mode the Harness can read.
1115
+ {
1116
+ name: HARNESS_CREDENTIALS_VOLUME,
1117
+ projected: {
1118
+ defaultMode: 0o440,
1119
+ sources: [
1120
+ {
1121
+ secret: {
1122
+ name: opts.credentials.secret,
1123
+ items: [
1124
+ { key: keys.controlStandIn, path: "control/standin" },
1125
+ ...manifest.secrets.map((s) => ({ key: heldStandInKey(s.name), path: `standins/${s.name}` })),
1126
+ ],
1127
+ },
1128
+ },
1129
+ ],
1130
+ },
1131
+ },
965
1132
  ];
966
1133
 
967
1134
  const custodianContainer: Container = {
@@ -974,6 +1141,8 @@ export function custodianComposition(manifest: HeldManifest, opts: CustodianComp
974
1141
  volumeMounts: [
975
1142
  { name: "custodian-values", mountPath: CUSTODIAN_VALUES, readOnly: true },
976
1143
  { name: "custodian-config", mountPath: CUSTODIAN_CONFIG, readOnly: true },
1144
+ // The one folder that the Custodian writes: where it makes its control socket.
1145
+ { name: CUSTODIAN_SOCKET_VOLUME, mountPath: CUSTODIAN_SOCKET_DIR },
977
1146
  ...(held
978
1147
  ? [
979
1148
  { name: "custodian-tls", mountPath: CUSTODIAN_TLS, readOnly: true },
@@ -996,7 +1165,12 @@ export function custodianComposition(manifest: HeldManifest, opts: CustodianComp
996
1165
 
997
1166
  return {
998
1167
  harnessEnv,
999
- harnessMounts: trust ? [{ name: "ca", mountPath: CA_MOUNT, readOnly: true }] : [],
1168
+ harnessMounts: [
1169
+ ...(trust ? [{ name: "ca", mountPath: CA_MOUNT, readOnly: true }] : []),
1170
+ { name: HARNESS_CREDENTIALS_VOLUME, mountPath: HARNESS_CREDENTIALS, readOnly: true },
1171
+ // Read-only: the Harness connects to the socket, and nothing in its container writes here.
1172
+ { name: CUSTODIAN_SOCKET_VOLUME, mountPath: CUSTODIAN_SOCKET_DIR, readOnly: true },
1173
+ ],
1000
1174
  custodianContainer,
1001
1175
  volumes,
1002
1176
  };
package/src/customize.ts CHANGED
@@ -45,6 +45,8 @@
45
45
  // its slot MAP Open (`repos: open`, ADR-0051) declared exactly this: every slot is the composer's
46
46
  // to name, so the map they write is the declaration, not an addition to one.
47
47
 
48
+ import type { CallbackActorLogic } from "xstate";
49
+ import type { AgentRunReceiveEvent } from "./actor.ts";
48
50
  import { StateMachine, type AnyStateMachine, type InputFrom, type ProvidedActor, type UnknownActorLogic } from "xstate";
49
51
  import type { AgentLogic } from "./actor.ts";
50
52
  import { isAgent, type AgentDefinition } from "./agent.ts";
@@ -128,8 +130,10 @@ type LogicAt<M extends AnyStateMachine, K extends string> = Extract<SlotsOf<M>,
128
130
  *
129
131
  * @internal
130
132
  */
131
- type AgentSlots<M extends AnyStateMachine> = Extract<SlotsOf<M>, { logic: AgentLogic }>["src"];
132
-
133
+ type AgentSlots<M extends AnyStateMachine> = Extract<
134
+ SlotsOf<M>,
135
+ { logic: CallbackActorLogic<AgentRunReceiveEvent, any> }
136
+ >["src"];
133
137
  /** The Effect slots with an Open part — told by the phantom `OpenEffect` their constructor's
134
138
  * overload states for an `open` url or argv, or a `secret(open)` (open-effect.ts).
135
139
  *
@@ -311,7 +315,11 @@ export type Customize<M extends AnyStateMachine> = {
311
315
  * the Machine does not declare does not compile. Some Machines, such as `task`, leave their
312
316
  * whole slot map Open. Then you name every slot, and at least one. The keys are your names for
313
317
  * the Repos, and the Machine says what their order means. */
314
- repos?: [RepoSlotsOf<M>] extends [never] ? never : { [K in RepoSlotsOf<M>]?: RepoSlot<InputFrom<WorkspaceOf<M>>> };
318
+ // A Workspace with only optional fields can start with no input, but the wrapper gives a mapper `{}`,
319
+ // so its `input` is never `undefined`.
320
+ repos?: [RepoSlotsOf<M>] extends [never]
321
+ ? never
322
+ : { [K in RepoSlotsOf<M>]?: RepoSlot<Exclude<InputFrom<WorkspaceOf<M>>, undefined>> };
315
323
  };
316
324
 
317
325
  /** The same shape with the types erased — what the implementation walks. */
@@ -27,6 +27,11 @@
27
27
  // Agent reads the Effect's output, not the answer. Everywhere else a settled child with no
28
28
  // `onDone` is Rest, and the one-line form is correct — silent on purpose.
29
29
  //
30
+ // - AN onDone WITH NO LAST ARM (ADR-0075, `refuseUnroutedOutcome`). Each Effect outcome is open,
31
+ // so an `onDone` that an output can miss holds the run in the state and shows no fault. A type
32
+ // cannot see the missing arm, so the walk refuses it. No `onDone` at all is Rest, as above: the
33
+ // author routes nothing, so no outcome can miss a route.
34
+ //
30
35
  // The probe reads, it never runs work: a mapper is called with a Proxy event whose reads are
31
36
  // recorded, and a context and self that survive any read. A guard and an assign are pure by
32
37
  // contract, and only they are probed; any other action may do work, and is never run.
@@ -34,12 +39,43 @@
34
39
  import type { AnyStateMachine, UnknownActorLogic } from "xstate";
35
40
  import type { EventDef } from "@jr2/agent-protocol";
36
41
  import { isAgent } from "./agent.ts";
37
- import { inputKeysOf, isEffect } from "./effect.ts";
42
+ import { inputKeysOf, inputShapeOf, isEffect } from "./effect.ts";
38
43
  import { mayTake, partiesAt } from "./party.ts";
39
44
  import { isAnswer, isPickMapper } from "./pick.ts";
40
45
 
41
46
  type Node = AnyStateMachine["root"];
42
47
 
48
+ /**
49
+ * Refuse a Machine with a state that invokes an Effect when the last arm of that `onDone` has a
50
+ * guard (ADR-0075). The slots are the Machine's own `actors`. Thrown at build by
51
+ * `jr2Setup.createMachine`, so also at a Chart's door, and at load by `partsOf`, so a Machine built
52
+ * with xstate's own `createMachine` is refused where `jr2 check`, `jr2 up` and the Orchestrator read
53
+ * it. Throws one line that names each state and the arm to write.
54
+ */
55
+ export function refuseUnroutedOutcome(machine: AnyStateMachine): void {
56
+ const actors = (machine.implementations.actors ?? {}) as Record<string, unknown>;
57
+ const unrouted: string[] = [];
58
+ const visit = (node: Node): void => {
59
+ for (const inv of node.invoke) {
60
+ if (typeof inv.src !== "string" || !isEffect(actors[inv.src])) continue;
61
+ const arms = node.transitions.get(`xstate.done.actor.${inv.id}`);
62
+ if (arms !== undefined && (arms.length === 0 || arms.at(-1)!.guard !== undefined)) {
63
+ unrouted.push(`"${node.id}" (Effect "${inv.src}")`);
64
+ }
65
+ }
66
+ for (const child of Object.values(node.states)) visit(child);
67
+ };
68
+ visit(machine.root);
69
+ if (!unrouted.length) return;
70
+ const many = unrouted.length > 1;
71
+ throw new Error(
72
+ `machine "${machine.id}": ${many ? "states" : "state"} ${unrouted.join(", ")} ${many ? "have" : "has"} an ` +
73
+ `onDone with no unguarded last arm — an Effect's outcomes are open, and an output that no arm matches holds ` +
74
+ `the run in that state with no fault: add a last arm with no guard, as onDone: [..., { target: "<the state ` +
75
+ `that handles the rest>" }] (ADR-0075)`,
76
+ );
77
+ }
78
+
43
79
  /** What the walk found: refusals, and warnings. */
44
80
  export type EffectFindings = { errors: string[]; warnings: string[] };
45
81
 
@@ -47,6 +83,7 @@ export function effectChecks(
47
83
  machine: AnyStateMachine,
48
84
  actors: Record<string, UnknownActorLogic>,
49
85
  defs: Map<string, EventDef>,
86
+ chart = false,
50
87
  ): EffectFindings {
51
88
  const errors: string[] = [];
52
89
  const warnings: string[] = [];
@@ -220,6 +257,15 @@ export function effectChecks(
220
257
  };
221
258
  });
222
259
  const effects = invokes.filter((inv) => inv.effect !== undefined);
260
+ // xstate's `input` is optional for an actor: a kit Effect that takes a key refuses an invoke
261
+ // with no input when `jr2Setup` or `customize()` builds the Machine.
262
+ for (const inv of effects) {
263
+ const shape = inputShapeOf(inv.effect!);
264
+ const needs = [...(shape?.required ?? []), ...(shape?.oneOf ?? [])];
265
+ if (inv.mapper === undefined && needs.length > 0) {
266
+ errors.push(`state "${n.id}": Effect "${inv.slot}" has no \`input\`, and it takes ${needs.join(", ")}`);
267
+ }
268
+ }
223
269
  if (!effects.length) continue;
224
270
  const at = `state "${n.id}" (Effect${effects.length > 1 ? "s" : ""} ${effects.map((e) => `"${e.slot}"`).join(", ")})`;
225
271
  for (const [event, { pick, ...reads }] of entering.get(n) ?? []) {
@@ -276,7 +322,8 @@ export function effectChecks(
276
322
  });
277
323
  } catch (err) {
278
324
  probed = false;
279
- warnings.push(`${own}: could not probe the input mapper (${messageOf(err)}); its params are unchecked`);
325
+ if (!chart)
326
+ warnings.push(`${own}: could not probe the input mapper (${messageOf(err)}); its params are unchecked`);
280
327
  }
281
328
  }
282
329
  }
@@ -289,7 +336,7 @@ export function effectChecks(
289
336
  const picked = effects.filter((e) => isPickMapper(e.mapper));
290
337
  if (probed && lost.length && probe.unrun.length) {
291
338
  // Any other action may do work, so the probe never runs one; it may read the param.
292
- warnings.push(unrunWarning(where, lost, probe.unrun));
339
+ if (!chart) warnings.push(unrunWarning(where, lost, probe.unrun));
293
340
  } else if (probed && lost.length && picked.length) {
294
341
  const inputs =
295
342
  picked.length === 1