@jr2/orchestrator 0.4.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.
- package/README.md +20 -2
- package/console/components/app.ts +2 -0
- package/console/components/fleet.ts +14 -1
- package/console/components/machine-pane.ts +41 -2
- package/console/main.ts +13 -1
- package/console/store.ts +102 -6
- package/console/style.css +21 -0
- package/docs/agents-block.md +5 -0
- package/docs/concepts/agents-and-turns.md +478 -0
- package/docs/concepts/architecture.md +243 -0
- package/docs/concepts/charts.md +146 -0
- package/docs/concepts/composition.md +357 -0
- package/docs/concepts/converge.md +257 -0
- package/docs/concepts/durable-runs.md +189 -0
- package/docs/concepts/effects.md +658 -0
- package/docs/concepts/gates.md +208 -0
- package/docs/concepts/machines-and-workflows.md +298 -0
- package/docs/concepts/pools-and-sources.md +230 -0
- package/docs/concepts/secrets-and-trust.md +306 -0
- package/docs/concepts/the-menu.md +381 -0
- package/docs/concepts/workspaces.md +756 -0
- package/docs/get-started/a-model-decides.md +563 -0
- package/docs/get-started/an-agent-in-a-sandbox.md +383 -0
- package/docs/get-started/connect-a-model.md +248 -0
- package/docs/get-started/create-an-instance.md +194 -0
- package/docs/get-started/have-your-assistant-write-a-machine.md +993 -0
- package/docs/get-started/install.md +130 -0
- package/docs/guides/add-tools-to-the-sandbox.md +308 -0
- package/docs/guides/answer-a-gate.md +304 -0
- package/docs/guides/bind-open-parts.md +224 -0
- package/docs/guides/cancel-a-run.md +146 -0
- package/docs/guides/clean-up.md +206 -0
- package/docs/guides/cut-a-worktree.md +560 -0
- package/docs/guides/fix-a-failed-converge.md +361 -0
- package/docs/guides/give-the-cluster-git-credentials.md +222 -0
- package/docs/guides/iterate-on-a-machine-on-kind.md +190 -0
- package/docs/guides/let-an-agent-write-a-chart.md +230 -0
- package/docs/guides/look-inside-a-sandbox.md +177 -0
- package/docs/guides/mirror-the-kit-images.md +176 -0
- package/docs/guides/move-from-kind-to-a-shared-cluster.md +318 -0
- package/docs/guides/open-the-console.md +91 -0
- package/docs/guides/publish-a-machine-as-an-npm-package.md +350 -0
- package/docs/guides/push-and-sign-a-branch.md +532 -0
- package/docs/guides/run-a-chart-by-name.md +270 -0
- package/docs/guides/run-a-command-or-call-a-url.md +587 -0
- package/docs/guides/run-one-worker-per-item.md +322 -0
- package/docs/guides/set-an-agents-access-and-dials.md +420 -0
- package/docs/guides/size-a-workspace.md +217 -0
- package/docs/guides/start-a-run-from-a-run.md +237 -0
- package/docs/guides/start-a-run.md +207 -0
- package/docs/guides/upgrade-the-kit-version.md +240 -0
- package/docs/guides/use-a-hosted-provider.md +214 -0
- package/docs/guides/use-a-local-model.md +313 -0
- package/docs/guides/watch-a-run.md +349 -0
- package/docs/guides/write-a-workflow.md +318 -0
- package/docs/index.md +44 -0
- package/docs/llms.txt +237 -0
- package/docs/reference/authoring-surface/agent.md +151 -0
- package/docs/reference/authoring-surface/answer.md +156 -0
- package/docs/reference/authoring-surface/arg.md +82 -0
- package/docs/reference/authoring-surface/basic.md +62 -0
- package/docs/reference/authoring-surface/bearer.md +53 -0
- package/docs/reference/authoring-surface/branchname.md +15 -0
- package/docs/reference/authoring-surface/chartschema.md +23 -0
- package/docs/reference/authoring-surface/chartschemafor.md +48 -0
- package/docs/reference/authoring-surface/command.md +206 -0
- package/docs/reference/authoring-surface/customize.md +64 -0
- package/docs/reference/authoring-surface/defineconfig.md +41 -0
- package/docs/reference/authoring-surface/defineevent.md +114 -0
- package/docs/reference/authoring-surface/dispatch.md +126 -0
- package/docs/reference/authoring-surface/fail.md +96 -0
- package/docs/reference/authoring-surface/file.md +142 -0
- package/docs/reference/authoring-surface/frompick.md +277 -0
- package/docs/reference/authoring-surface/http.md +259 -0
- package/docs/reference/authoring-surface/isfailure.md +58 -0
- package/docs/reference/authoring-surface/isready.md +14 -0
- package/docs/reference/authoring-surface/jr2setup.md +237 -0
- package/docs/reference/authoring-surface/open.md +105 -0
- package/docs/reference/authoring-surface/openbody.md +86 -0
- package/docs/reference/authoring-surface/paramsof.md +143 -0
- package/docs/reference/authoring-surface/pool.md +186 -0
- package/docs/reference/authoring-surface/push.md +130 -0
- package/docs/reference/authoring-surface/readyof.md +21 -0
- package/docs/reference/authoring-surface/secret.md +139 -0
- package/docs/reference/authoring-surface/sentby.md +83 -0
- package/docs/reference/authoring-surface/source.md +51 -0
- package/docs/reference/authoring-surface/text.md +62 -0
- package/docs/reference/authoring-surface/type-agentdeclaration.md +27 -0
- package/docs/reference/authoring-surface/type-agentdefinition.md +80 -0
- package/docs/reference/authoring-surface/type-agentlogic.md +15 -0
- package/docs/reference/authoring-surface/type-agentslots.md +15 -0
- package/docs/reference/authoring-surface/type-answeraction.md +12 -0
- package/docs/reference/authoring-surface/type-anyframepath.md +14 -0
- package/docs/reference/authoring-surface/type-anyworktreepath.md +15 -0
- package/docs/reference/authoring-surface/type-arg.md +19 -0
- package/docs/reference/authoring-surface/type-argnames.md +12 -0
- package/docs/reference/authoring-surface/type-bindactors.md +14 -0
- package/docs/reference/authoring-surface/type-binding.md +61 -0
- package/docs/reference/authoring-surface/type-bindingof.md +12 -0
- package/docs/reference/authoring-surface/type-bodyacceptsdoor.md +15 -0
- package/docs/reference/authoring-surface/type-bodyacceptsslots.md +17 -0
- package/docs/reference/authoring-surface/type-ceilings.md +41 -0
- package/docs/reference/authoring-surface/type-chainlink.md +19 -0
- package/docs/reference/authoring-surface/type-chartaction.md +46 -0
- package/docs/reference/authoring-surface/type-chartagent.md +28 -0
- package/docs/reference/authoring-surface/type-chartevent.md +20 -0
- package/docs/reference/authoring-surface/type-chartguard.md +20 -0
- package/docs/reference/authoring-surface/type-chartinvoke.md +28 -0
- package/docs/reference/authoring-surface/type-chartjson.md +14 -0
- package/docs/reference/authoring-surface/type-chartstate.md +46 -0
- package/docs/reference/authoring-surface/type-charttext.md +40 -0
- package/docs/reference/authoring-surface/type-charttransition.md +22 -0
- package/docs/reference/authoring-surface/type-charttransitions.md +14 -0
- package/docs/reference/authoring-surface/type-chartvalue.md +15 -0
- package/docs/reference/authoring-surface/type-childat.md +12 -0
- package/docs/reference/authoring-surface/type-childslots.md +12 -0
- package/docs/reference/authoring-surface/type-commandenv.md +15 -0
- package/docs/reference/authoring-surface/type-commandinput.md +20 -0
- package/docs/reference/authoring-surface/type-commandoptions.md +56 -0
- package/docs/reference/authoring-surface/type-commandoutput.md +122 -0
- package/docs/reference/authoring-surface/type-customize.md +111 -0
- package/docs/reference/authoring-surface/type-dispatchdefinition.md +39 -0
- package/docs/reference/authoring-surface/type-dispatchinput.md +14 -0
- package/docs/reference/authoring-surface/type-dispatchopenparts.md +14 -0
- package/docs/reference/authoring-surface/type-dispatchoutput.md +72 -0
- package/docs/reference/authoring-surface/type-doorlessinput.md +19 -0
- package/docs/reference/authoring-surface/type-duration.md +15 -0
- package/docs/reference/authoring-surface/type-effectbinding.md +93 -0
- package/docs/reference/authoring-surface/type-effectlogic.md +12 -0
- package/docs/reference/authoring-surface/type-envopenparts.md +14 -0
- package/docs/reference/authoring-surface/type-eventaudience.md +27 -0
- package/docs/reference/authoring-surface/type-eventdef.md +45 -0
- package/docs/reference/authoring-surface/type-eventfrom.md +16 -0
- package/docs/reference/authoring-surface/type-eventsemantics.md +26 -0
- package/docs/reference/authoring-surface/type-execoutcome.md +34 -0
- package/docs/reference/authoring-surface/type-failslot.md +14 -0
- package/docs/reference/authoring-surface/type-failure.md +19 -0
- package/docs/reference/authoring-surface/type-failureinput.md +20 -0
- package/docs/reference/authoring-surface/type-filebody.md +37 -0
- package/docs/reference/authoring-surface/type-framepath.md +14 -0
- package/docs/reference/authoring-surface/type-frominput.md +14 -0
- package/docs/reference/authoring-surface/type-hostinjectedinput.md +56 -0
- package/docs/reference/authoring-surface/type-httpdefinition.md +104 -0
- package/docs/reference/authoring-surface/type-httpfileinput.md +20 -0
- package/docs/reference/authoring-surface/type-httpinput.md +12 -0
- package/docs/reference/authoring-surface/type-httpmethod.md +14 -0
- package/docs/reference/authoring-surface/type-httpopenparts.md +12 -0
- package/docs/reference/authoring-surface/type-httpoutput.md +95 -0
- package/docs/reference/authoring-surface/type-httprequestparts.md +33 -0
- package/docs/reference/authoring-surface/type-jr2config.md +126 -0
- package/docs/reference/authoring-surface/type-lateroutcome.md +21 -0
- package/docs/reference/authoring-surface/type-lends.md +38 -0
- package/docs/reference/authoring-surface/type-logicat.md +12 -0
- package/docs/reference/authoring-surface/type-markedif.md +12 -0
- package/docs/reference/authoring-surface/type-mergedactors.md +12 -0
- package/docs/reference/authoring-surface/type-namedsecret.md +25 -0
- package/docs/reference/authoring-surface/type-namestart.md +77 -0
- package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +27 -0
- package/docs/reference/authoring-surface/type-notworktreekeys.md +12 -0
- package/docs/reference/authoring-surface/type-openbodydeclaration.md +40 -0
- package/docs/reference/authoring-surface/type-openbodylogic.md +14 -0
- package/docs/reference/authoring-surface/type-openbodyoutput.md +42 -0
- package/docs/reference/authoring-surface/type-openbodyslot.md +18 -0
- package/docs/reference/authoring-surface/type-opencommandinput.md +22 -0
- package/docs/reference/authoring-surface/type-openeffect.md +12 -0
- package/docs/reference/authoring-surface/type-openeffectslots.md +12 -0
- package/docs/reference/authoring-surface/type-openof.md +12 -0
- package/docs/reference/authoring-surface/type-opensecret.md +23 -0
- package/docs/reference/authoring-surface/type-opensecretmark.md +12 -0
- package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +26 -0
- package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +27 -0
- package/docs/reference/authoring-surface/type-poolmachine.md +28 -0
- package/docs/reference/authoring-surface/type-poolnextinput.md +22 -0
- package/docs/reference/authoring-surface/type-poolspec.md +60 -0
- package/docs/reference/authoring-surface/type-pushinput.md +34 -0
- package/docs/reference/authoring-surface/type-pushoptions.md +79 -0
- package/docs/reference/authoring-surface/type-pushoutput.md +149 -0
- package/docs/reference/authoring-surface/type-pushwireoutput.md +12 -0
- package/docs/reference/authoring-surface/type-quantity.md +14 -0
- package/docs/reference/authoring-surface/type-reached.md +12 -0
- package/docs/reference/authoring-surface/type-reposlot.md +70 -0
- package/docs/reference/authoring-surface/type-reposlotsof.md +16 -0
- package/docs/reference/authoring-surface/type-sandboxoptions.md +84 -0
- package/docs/reference/authoring-surface/type-secret.md +25 -0
- package/docs/reference/authoring-surface/type-sender.md +19 -0
- package/docs/reference/authoring-surface/type-sentbyguards.md +12 -0
- package/docs/reference/authoring-surface/type-size.md +85 -0
- package/docs/reference/authoring-surface/type-slothandles.md +24 -0
- package/docs/reference/authoring-surface/type-sourcenextresult.md +17 -0
- package/docs/reference/authoring-surface/type-sourcespec.md +44 -0
- package/docs/reference/authoring-surface/type-syncstate.md +12 -0
- package/docs/reference/authoring-surface/type-text.md +19 -0
- package/docs/reference/authoring-surface/type-textpart.md +24 -0
- package/docs/reference/authoring-surface/type-thinkinglevel.md +39 -0
- package/docs/reference/authoring-surface/type-toparameterizedobject.md +14 -0
- package/docs/reference/authoring-surface/type-toprovidedactor.md +14 -0
- package/docs/reference/authoring-surface/type-unknown.md +21 -0
- package/docs/reference/authoring-surface/type-usercontainer.md +77 -0
- package/docs/reference/authoring-surface/type-withoutemptyindex.md +20 -0
- package/docs/reference/authoring-surface/type-workflowevent.md +46 -0
- package/docs/reference/authoring-surface/type-workspaceaccess.md +16 -0
- package/docs/reference/authoring-surface/type-workspaced.md +115 -0
- package/docs/reference/authoring-surface/type-workspacehandles.md +22 -0
- package/docs/reference/authoring-surface/type-workspacemachine.md +29 -0
- package/docs/reference/authoring-surface/type-workspaceof.md +13 -0
- package/docs/reference/authoring-surface/type-workspaceoptions.md +27 -0
- package/docs/reference/authoring-surface/type-worktreefound.md +16 -0
- package/docs/reference/authoring-surface/type-worktreeinput.md +56 -0
- package/docs/reference/authoring-surface/type-worktreekey.md +14 -0
- package/docs/reference/authoring-surface/type-worktreeoptions.md +29 -0
- package/docs/reference/authoring-surface/type-worktreeoutput.md +45 -0
- package/docs/reference/authoring-surface/type-worktreepath.md +16 -0
- package/docs/reference/authoring-surface/type-worktreeready.md +37 -0
- package/docs/reference/authoring-surface/type-worktreerefused.md +21 -0
- package/docs/reference/authoring-surface/workspace.md +287 -0
- package/docs/reference/authoring-surface/worktree.md +71 -0
- package/docs/reference/authoring-surface.md +217 -0
- package/docs/reference/cli.md +433 -0
- package/docs/reference/config.md +765 -0
- package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +114 -0
- package/docs/reference/decisions/0002-duplex-streaming-actor.md +117 -0
- package/docs/reference/decisions/0004-worktree-provisioning-model.md +130 -0
- package/docs/reference/decisions/0005-sandbox-pod-composition.md +240 -0
- package/docs/reference/decisions/0006-agent-control-surface.md +50 -0
- package/docs/reference/decisions/0007-durable-machine-state.md +109 -0
- package/docs/reference/decisions/0008-orchestrator-as-deployed-app.md +60 -0
- package/docs/reference/decisions/0009-cli-and-instance-interface.md +275 -0
- package/docs/reference/decisions/0010-bdd-acceptance-tests.md +160 -0
- package/docs/reference/decisions/0011-workflow-defined-events.md +141 -0
- package/docs/reference/decisions/0012-workspace-wrapper-machine.md +103 -0
- package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +175 -0
- package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +95 -0
- package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +271 -0
- package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +126 -0
- package/docs/reference/decisions/0017-pool-over-a-source-port.md +71 -0
- package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +111 -0
- package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +287 -0
- package/docs/reference/decisions/0020-private-ca-trust-is-a-named-concept.md +60 -0
- package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +101 -0
- package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +103 -0
- package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +175 -0
- package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +436 -0
- package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +65 -0
- package/docs/reference/decisions/0026-a-turn-that-is-over-is-never-prompted.md +62 -0
- package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +245 -0
- package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +90 -0
- package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +154 -0
- package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +100 -0
- package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +193 -0
- package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +61 -0
- package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +126 -0
- package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +57 -0
- package/docs/reference/decisions/0035-a-runaway-turn-is-ended-rerolled-once-then-a-fault.md +92 -0
- package/docs/reference/decisions/0036-a-turn-compacts-its-own-context-mid-flight.md +138 -0
- package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +196 -0
- package/docs/reference/decisions/0038-jr2-up-builds-every-image-it-deploys.md +170 -0
- package/docs/reference/decisions/0039-image-garbage-collects-by-reachability.md +115 -0
- package/docs/reference/decisions/0041-a-build-the-host-already-holds-is-not-spent-again.md +64 -0
- package/docs/reference/decisions/0042-ready-is-not-routable-an-admission-retries-its-connection.md +142 -0
- package/docs/reference/decisions/0043-the-kit-is-tested-as-installed-a-local-registry-stands-in-for-npm.md +170 -0
- package/docs/reference/decisions/0044-kit-images-live-at-a-canonical-home-a-self-host-mirrors-it.md +85 -0
- package/docs/reference/decisions/0045-the-platform-joins-the-image-address-and-the-cluster-chooses-it.md +82 -0
- package/docs/reference/decisions/0046-a-converge-failure-carries-its-diagnosis.md +34 -0
- package/docs/reference/decisions/0047-the-git-ssh-key-source-is-the-users-choice.md +47 -0
- package/docs/reference/decisions/0048-the-orchestrator-boots-without-its-repos.md +36 -0
- package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +126 -0
- package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +73 -0
- package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +238 -0
- package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +86 -0
- package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +123 -0
- package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +126 -0
- package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +107 -0
- package/docs/reference/decisions/0056-a-global-jr2-hands-off-to-the-instances-own-and-an-instance-has-one-kit-version.md +62 -0
- package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +152 -0
- package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +108 -0
- package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +427 -0
- package/docs/reference/decisions/0060-a-workspace-states-its-size-and-jr2-reserves-all-of-it.md +111 -0
- package/docs/reference/decisions/0061-a-memory-kill-ends-the-agents-processes-not-the-harness.md +83 -0
- package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +239 -0
- package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +82 -0
- package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +79 -0
- package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +65 -0
- package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +552 -0
- package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +458 -0
- package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +475 -0
- package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +180 -0
- package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +334 -0
- package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +183 -0
- package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +144 -0
- package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +122 -0
- package/docs/reference/decisions/0074-an-unrouted-mechanism-event-is-refused-at-load-and-faults-the-run.md +76 -0
- package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +295 -0
- package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +320 -0
- package/docs/reference/decisions/0077-jr2-requires-landlock-on-every-sandbox-node.md +79 -0
- package/docs/reference/decisions.md +86 -0
- package/docs/reference/glossary.md +687 -0
- package/docs/reference/http-api.md +1386 -0
- package/docs/reference/machines.md +189 -0
- package/docs/reference/mechanism-events.md +72 -0
- package/docs/why.md +207 -0
- package/package.json +5 -3
- package/src/actor.ts +694 -106
- package/src/agent.ts +114 -38
- package/src/ambient.ts +3 -4
- package/src/assembly-checks.ts +189 -7
- package/src/cause.ts +234 -0
- package/src/chart.ts +345 -46
- package/src/charts.ts +43 -8
- package/src/config.ts +440 -178
- package/src/custodian.lua +17 -5
- package/src/custodian.ts +325 -99
- package/src/customize.ts +199 -49
- package/src/dispatch-effect.ts +945 -0
- package/src/door-compat.ts +217 -0
- package/src/durability.ts +11 -4
- package/src/effect-checks.ts +50 -3
- package/src/effect.ts +641 -128
- package/src/effector-wire.ts +165 -13
- package/src/fail.ts +222 -0
- package/src/harness-client.ts +587 -50
- package/src/harness-env.ts +33 -0
- package/src/held-secrets.ts +125 -16
- package/src/holds.ts +3 -0
- package/src/http-effect.ts +556 -58
- package/src/http.ts +480 -30
- package/src/index.ts +74 -82
- package/src/instance.ts +68 -8
- package/src/internal.ts +85 -0
- package/src/kube-client.ts +6 -3
- package/src/machine-doc.ts +24 -7
- package/src/mechanism-routes.ts +168 -0
- package/src/model-keys.generated.ts +53 -0
- package/src/model-keys.ts +42 -58
- package/src/names.ts +19 -6
- package/src/notices.ts +483 -41
- package/src/open-body.ts +242 -40
- package/src/open-effect.ts +81 -8
- package/src/open.ts +90 -6
- package/src/parts.ts +213 -30
- package/src/party.ts +101 -2
- package/src/pick.ts +497 -11
- package/src/pool.ts +282 -50
- package/src/registration.ts +133 -12
- package/src/repo-identity.ts +7 -3
- package/src/repos.ts +22 -3
- package/src/run-host.ts +665 -45
- package/src/run-output.ts +38 -0
- package/src/sandbox-effect.ts +457 -71
- package/src/sandbox-kube.ts +66 -17
- package/src/secret-placement.ts +59 -17
- package/src/server.ts +3 -0
- package/src/setup.ts +325 -62
- package/src/size.ts +121 -9
- package/src/snapshot-store.ts +54 -10
- package/src/stub-harness.ts +44 -16
- package/src/timers.ts +14 -1
- package/src/tokens.ts +107 -32
- package/src/vocabulary.ts +88 -14
- package/src/wire.ts +100 -26
- package/src/workspace.ts +771 -238
- package/src/worktree.ts +249 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Sandboxes are provisioned by a Kubernetes operator via a generic CRD
|
|
2
|
+
|
|
3
|
+
A `Sandbox` custom resource describes infrastructure only — a primary container, a generic list of additional sidecar
|
|
4
|
+
container specs, volumes, resources, secrets, idle timeout — and a custom operator reconciles it into one Pod — created
|
|
5
|
+
once, never recreated: a pod that is gone or terminal makes the Sandbox `Lost` (ADR-0021) — plus a Service, reporting a
|
|
6
|
+
`status.endpoint` the Orchestrator uses to reach the Harness. The CRD names the Repos a Sandbox needs by identity —
|
|
7
|
+
enough for the operator to place the pod and mount each node cache read-only (ADR-0051) — and knows nothing about
|
|
8
|
+
clones, worktrees, or Agents; those are layered on by the Orchestrator after the Sandbox reaches `Ready`. The operator
|
|
9
|
+
also publishes what the Orchestrator needs to know about the pod onto the Sandbox's status — `podUID`, a scheduling
|
|
10
|
+
condition with the scheduler's message, the Harness container's restarts and last terminated reason, and the same for
|
|
11
|
+
the Sandbox Effector's container — because the Orchestrator watches Sandboxes and never reads a Pod
|
|
12
|
+
([ADR-0063](0063-the-orchestrator-watches-the-cluster.md)).
|
|
13
|
+
|
|
14
|
+
We chose the operator over the Orchestrator calling the Kubernetes API directly because the custom resource _is_ the
|
|
15
|
+
durable desired state: Sandboxes survive an Orchestrator restart, and garbage collection / readiness / retries live in
|
|
16
|
+
one reconciler instead of being threaded through the Machine. We kept the CRD generic (rather than coding/worktree-aware
|
|
17
|
+
with an init-container clone) so the operator stays workflow-agnostic — matching jr2's thesis that the framework is a
|
|
18
|
+
grab-bag of composable pieces, with git/worktree being just one piece a coding Machine bolts on. The operator is
|
|
19
|
+
git-aware to exactly the extent of the `Repo` CRD and the cache agent that keeps a bare checkout per node (ADR-0051),
|
|
20
|
+
and of `gitCredentials`, the push Secrets a Sandbox names, which it projects into the Sandbox Effector (the `sidecars`
|
|
21
|
+
entry named `effector`) alone (ADR-0051, ADR-0068): a fetch is infrastructure, shared by every Sandbox on the node; a
|
|
22
|
+
worktree is a Machine's. The cost is an extra runtime step (worktree setup as its own state that can fail independently)
|
|
23
|
+
and an operator to build and maintain.
|
|
24
|
+
|
|
25
|
+
## The Harness is the primary container; sidecars are opaque fragments
|
|
26
|
+
|
|
27
|
+
The CR's `spec.image` is the **Harness** — jr2's own server hosting the instance's Agents (ADR-0005/0018/0027).
|
|
28
|
+
Everything else in the pod rides `spec.sidecars`: plain Kubernetes `Container` fragments (image, ports, env,
|
|
29
|
+
volumeMounts) the operator schedules **without understanding them** — exactly as a Deployment's pod template carries
|
|
30
|
+
arbitrary containers without the controller knowing their roles. That is how the Custodian (ADR-0013, ADR-0059) and the
|
|
31
|
+
User Container (ADR-0005) land in the pod with zero operator awareness: `kubectlSandbox` compiles them into the sidecar
|
|
32
|
+
list when it builds the CR. Containers cannot be added to a live pod (native containers, that is), so everything a
|
|
33
|
+
Sandbox will run must be in the spec at spin-up.
|
|
34
|
+
|
|
35
|
+
One Harness **image** serves many **Agents** — the Harness resolves model, instructions, and its Menu at runtime without
|
|
36
|
+
a rebuild, reading the definition each admission carries (ADR-0018, ADR-0049) and reading the Menu through the Custodian
|
|
37
|
+
per turn (ADR-0013). So the Orchestrator injects a fixed Harness image plus **config** (env + the prepared clones),
|
|
38
|
+
never a per-Agent image build. The HTTP prompt body itself carries only `{message, images}`, so anything persona-shaping
|
|
39
|
+
is set at provision time, not per request.
|
|
40
|
+
|
|
41
|
+
## Idle GC: a renewed lease, not a TTL
|
|
42
|
+
|
|
43
|
+
The operator reaps **abandoned** Sandboxes — ones whose Orchestrator is gone — as the backstop behind the Orchestrator's
|
|
44
|
+
own teardown (ADR-0012). "Abandoned" is defined by a lease: each live workspace renews a keepalive annotation
|
|
45
|
+
(`jr2.dev/keepalive: <timestamp>`) on its CR, and the operator deletes a Sandbox only once `spec.idleTimeout` (default
|
|
46
|
+
`30m`) has elapsed since **max(creation, last keepalive)**. A run parked on a Gate for hours keeps its Sandbox — its
|
|
47
|
+
lease is still renewing — while a `kill -9`'d Orchestrator's Sandboxes reap one idle-timeout later. An Orchestrator that
|
|
48
|
+
restarts within the timeout re-attaches and resumes renewing; one that stays down longer finds the CR gone and delivers
|
|
49
|
+
`workspace.lost` to the restored body. Creation counts as the initial lease, so a CR whose run faults before it ever
|
|
50
|
+
renews is still reaped on schedule. The lease is renewed from the CR's write, so a Sandbox that waits for a node or for
|
|
51
|
+
quota is never taken for abandoned (ADR-0064).
|
|
52
|
+
|
|
53
|
+
There are no ownerReferences in this scheme — nothing in the cluster represents a run, so liveness has to be asserted,
|
|
54
|
+
not referenced. The lease only asserts; the Orchestrator learns from its watch of the Sandboxes (ADR-0021, ADR-0063).
|
|
55
|
+
|
|
56
|
+
## The operator scales with Sandboxes, not with the cluster
|
|
57
|
+
|
|
58
|
+
The operator is deployed once per cluster (ADR-0008), so its cost must follow what jr2 runs, not what the cluster runs.
|
|
59
|
+
The scaling review (R7, 2026-09-30) found it fine at 40 Sandboxes and predicted the breaks at hundreds, or on a busy
|
|
60
|
+
cluster; these are the rules.
|
|
61
|
+
|
|
62
|
+
- **The operator caches only what it labels.** controller-runtime keeps an in-memory copy of every object a controller
|
|
63
|
+
watches; the Pod and Service informers take the selector `app.kubernetes.io/managed-by=jr2-operator`, which every
|
|
64
|
+
object the operator creates carries, so memory and watch traffic are O(Sandboxes) whatever cluster the operator lands
|
|
65
|
+
in. Sandboxes and Repos are jr2's own kinds and are cached whole. The one read that must not trust the cache — the
|
|
66
|
+
confirmation before a pod is declared lost (ADR-0021) — goes past it through the API reader.
|
|
67
|
+
- **Reconciles run in parallel, at fixed numbers.** controller-runtime never reconciles one Sandbox twice at once and a
|
|
68
|
+
reconcile holds no shared state, so the Sandbox controller runs 16 workers, the Repo controller 4, and the client's
|
|
69
|
+
rate limit is 100 QPS with a burst of 200, so a burst of CRs is placed in a fraction of a second instead of one API
|
|
70
|
+
round trip at a time. Kit values, not config: nobody has a reading to tune them by, and the API server's own priority
|
|
71
|
+
and fairness still governs.
|
|
72
|
+
- **A Repo event wakes only the Sandboxes it changes.** A Sandbox reads only its own node's entry of each Repo it names
|
|
73
|
+
(ADR-0051, ADR-0053), so the Repo watch diffs `status.nodes` and enqueues the Sandboxes naming the Repo whose
|
|
74
|
+
`status.node` changed; a spec change wakes them all. Without this a cache agent's per-fetch status write woke every
|
|
75
|
+
Sandbox naming the Repo on every node, S×N per interval and S² on a burst of asks.
|
|
76
|
+
- **The token Secret is owned from birth.** The Orchestrator writes the CR first, then the Secret with the CR as its
|
|
77
|
+
owner, so no path — an ownerRef patch that fails, an Orchestrator that dies between the two writes — leaves a token
|
|
78
|
+
behind on the cluster. The pod is created the moment the CR is seen and may reach its mount before the Secret lands;
|
|
79
|
+
kubelet retries the mount, and the Secret arrives one API round trip after the CR, well before the scheduler and
|
|
80
|
+
kubelet get there.
|
|
81
|
+
|
|
82
|
+
Not taken (R15): a parked Workspace releasing its pod. A run parked on a Gate for hours holds a pod for hours; that is
|
|
83
|
+
the problem Agent Substrate exists for, and upstream Kubernetes has no production checkpoint/restore to build it on. The
|
|
84
|
+
one constraint kept: the Sandbox CRD stays close to `agent-sandbox`'s shape, so a backend swap stays possible.
|
|
85
|
+
|
|
86
|
+
Not taken (R14): a headless Service. A ClusterIP Service programs kube-proxy rules on every node per Sandbox; a headless
|
|
87
|
+
one keeps the name and the endpoint and costs no node anything. Deferred until a cluster with many nodes measures it.
|
|
88
|
+
|
|
89
|
+
## Known limitations
|
|
90
|
+
|
|
91
|
+
These are accepted gaps in the current operator, recorded so they aren't silently forgotten. Each is a deliberate
|
|
92
|
+
deferral.
|
|
93
|
+
|
|
94
|
+
- **Egress is open.** The pod is hardened at the host boundary — `automountServiceAccountToken: false`, pod/container
|
|
95
|
+
`securityContext` (`runAsNonRoot`, `allowPrivilegeEscalation: false`, drop `ALL`, seccomp `RuntimeDefault`) — and its
|
|
96
|
+
_ingress_ is the Orchestrator's alone: `jr2 up` converges a NetworkPolicy that admits no other pod to a Sandbox
|
|
97
|
+
([ADR-0058](0058-a-harness-answers-the-orchestrator-alone.md)), which kind's `kindnet` enforces. _Egress_ is open
|
|
98
|
+
except for the cloud metadata addresses, which no Harness pod reaches
|
|
99
|
+
([ADR-0076](0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md)): an untrusted Agent can
|
|
100
|
+
still reach the cluster's API server IP (it holds no credential there) and the internet. A default-deny egress policy
|
|
101
|
+
with an allowlist is the next layer, and ADR-0058 records it open. The ADR-0013 boundary does not depend on either:
|
|
102
|
+
only the token bounds what a pod may _do_; NetworkPolicy bounds where it may _talk_.
|
|
103
|
+
- **Sidecars are plain containers, not native sidecars.** They are scheduled as ordinary `containers`, not Kubernetes
|
|
104
|
+
≥1.29 native sidecars (`initContainers` with `restartPolicy: Always`). There is no start-ordering or
|
|
105
|
+
termination-ordering guarantee between the Harness and its sidecars — the Harness may begin serving before the
|
|
106
|
+
Custodian is up, or outlive it during shutdown. Start-up is covered without ordering: the Custodian's readiness probe
|
|
107
|
+
holds the pod's Ready, which holds the Sandbox's, so no Turn is admitted before it serves (ADR-0059). Revisit if
|
|
108
|
+
ordering becomes load-bearing.
|
|
109
|
+
- **`reconcileStatus` writes status only when it changed.** Every status write is a watch event at the Orchestrator
|
|
110
|
+
(ADR-0063), so an unconditional `Status().Update` per reconcile would wake it for nothing.
|
|
111
|
+
- **Consumers must gate on `phase: Ready`, not endpoint presence.** `status.endpoint` is populated as soon as the
|
|
112
|
+
Service exists, before the Harness is reachable; only `phase: Ready` (backed by a readiness probe) means "serving".
|
|
113
|
+
The `Terminating` phase is best-effort — there is no finalizer, so a fast delete may GC the Pod/Service before the
|
|
114
|
+
phase is observed.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# The agent actor is a duplex channel; its control plane is Menu tool calls
|
|
2
|
+
|
|
3
|
+
The `agentRun` actor drives an Agent run as a long-lived duplex channel, not a request/response call: it admits the run
|
|
4
|
+
with `POST /agents/:name/:id`, persists the durable handle host-side, receives the Agent's decisions as events, and can
|
|
5
|
+
feed later turns down to the same conversation. We rejected the simpler `fromPromise`-over-synchronous-`POST` model
|
|
6
|
+
because a synchronous call returns exactly once, at the end — it cannot express human-in-the-loop (mid-run approval,
|
|
7
|
+
steering), and a held hour-long cross-pod connection is lost on any blip or Orchestrator restart.
|
|
8
|
+
|
|
9
|
+
## Control plane: Menu tool calls, not stream interpretation
|
|
10
|
+
|
|
11
|
+
The Agent emits the domain events the Machine cares about by **calling tools** — its Menu, derived from the invoking
|
|
12
|
+
state's transitions (ADR-0015), read by the Harness through the pod's Custodian and presented to the model as tools
|
|
13
|
+
(ADR-0013). Events are Agent-authored and semantic rather than inferred from a generic event stream. This was proven
|
|
14
|
+
end-to-end against a real Harness + vLLM (PoC #5): the Agent called its tool, the Machine transitioned, and a gated
|
|
15
|
+
action observably occurred only post-approval.
|
|
16
|
+
|
|
17
|
+
The split-channel model:
|
|
18
|
+
|
|
19
|
+
| Direction | Kind | Mechanism |
|
|
20
|
+
| --------- | -------------------------------------------------------------- | --------------------------------------------- |
|
|
21
|
+
| up | control / decision (the workflow's events, ADR-0011) | Menu tool call → Custodian → delivery |
|
|
22
|
+
| up | progress / telemetry (tokens, attempts, "still working") | Harness stream (lossy, observation-only) |
|
|
23
|
+
| down | the next turn's prompt (feedback, steering at a boundary) | a new submission on the same iid |
|
|
24
|
+
| down | the Turn part, restated (the Allowed picks changed) | the Harness's read at each step boundary |
|
|
25
|
+
| down | a jr2 notice (a memory kill, ADR-0062), typed, to its audience | the same step-boundary read, per conversation |
|
|
26
|
+
| down | interrupts (deliberate terminal cancel) | `abort` on the wire |
|
|
27
|
+
|
|
28
|
+
Tool calls are Agent-initiated, so the Machine speaks inside a Turn only as an answer. The one exception is the
|
|
29
|
+
**restated Turn part**: when something other than the Agent's own pick changes its Allowed picks — a Gate's answer, an
|
|
30
|
+
outside event — the Harness learns it by reading the surface at each step boundary and appends the new list
|
|
31
|
+
([ADR-0029](0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md)). It is a pull, not a push:
|
|
32
|
+
the Harness asks at a step boundary, and nothing reaches it that it did not ask for. What is restated is fixed text plus
|
|
33
|
+
Menu names. jr2's own **typed notices** ride that same step-boundary read: the read of an open Turn returns its
|
|
34
|
+
conversation's pending notices, the Harness appends them, and its acknowledgment of the append delivers them. So a
|
|
35
|
+
running Agent hears a fact the mechanism knows — a memory kill above all — without an interrupt. A notice is typed data,
|
|
36
|
+
and the Harness words it ([ADR-0062](0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md)). The residual case is
|
|
37
|
+
**free-text human steering** — arbitrary text from a human Caller to an Agent that has not asked. jr2 reserves it for v1
|
|
38
|
+
on this same channel, authenticated as a Caller ([ADR-0011](0011-workflow-defined-events.md)) rather than by a
|
|
39
|
+
Sandbox-scoped credential; its sender and authorization are designed with the Caller. It is one behavior, not a
|
|
40
|
+
dependency of the whole control plane.
|
|
41
|
+
|
|
42
|
+
## Durability: the admission is the handle
|
|
43
|
+
|
|
44
|
+
`send` returns an **admission** — `{ streamUrl, offset, submissionId }` — and that is the durable handle: the host
|
|
45
|
+
persists an `iid → admission` ledger beside the run snapshot in the same save (ADR-0016), never in Machine context.
|
|
46
|
+
`wait(admission)` reconnects from the offset, which is how a restarted Orchestrator re-attaches to an in-flight turn
|
|
47
|
+
instead of restarting it (proven by dropping the actor mid-run and re-attaching; the run completed).
|
|
48
|
+
|
|
49
|
+
**Stopping the actor is a local abandon, deliberately.** The wire's `abort` (`POST /agents/:name/:id/abort`) is a
|
|
50
|
+
first-class terminal outcome, but actor STOP does not call it: a host shutdown stops every actor, and those runs must
|
|
51
|
+
stay alive server-side for restore to re-attach. Remote abort is reserved for a deliberate terminal act, not a stop side
|
|
52
|
+
effect.
|
|
53
|
+
|
|
54
|
+
## A held tool result is a poll, never a held socket
|
|
55
|
+
|
|
56
|
+
A pick that moves the workflow to work inside the Agent's state is answered when the workflow is back at Rest there
|
|
57
|
+
([ADR-0024](0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md)) — seconds or hours later. One request cannot
|
|
58
|
+
carry that wait: the Harness's HTTP client (Node's undici) cuts a request at 300 s with no response headers, and at 300
|
|
59
|
+
s between body chunks, and its error then told the Agent "the pick never reached the Orchestrator — try it again" for a
|
|
60
|
+
pick that WAS delivered (measured: cut at 302 s, both timers, and through the real turn loop at 301 s). An Orchestrator
|
|
61
|
+
restart mid-request is a reset after the request was sent, which the Custodian rightly does not retry and which read as
|
|
62
|
+
"the Custodian could not reach it". A model told either goes round again.
|
|
63
|
+
|
|
64
|
+
So the answer is **poll-with-progress**. The pick's `POST` answers at once: `outstanding`, with a delivery id. Inside
|
|
65
|
+
the one tool call, the Harness long-polls `GET /agents/:iid/deliveries/:id` (ADR-0013), about 25 s per request — the
|
|
66
|
+
same register as the Harness's own stream long-poll — until the answer comes. Every timer on the path sees short
|
|
67
|
+
requests; a GET is safe to repeat, so a reset, a 503 from a stopping Orchestrator and a park cost a retry, never the
|
|
68
|
+
pick; and an Orchestrator restart is a longer wait in the same Turn, because the hold is saved with the snapshot and the
|
|
69
|
+
restored host answers the same poll. There is no default time bound on the wait: pi awaits a tool's `execute` with no
|
|
70
|
+
timer of its own, and the Runaway guard counts calls, not time
|
|
71
|
+
([ADR-0035](0035-a-runaway-turn-is-ended-rerolled-once-then-a-fault.md)).
|
|
72
|
+
|
|
73
|
+
The same rule holds for the `POST` itself. The Harness says "never reached" only when it knows that the Orchestrator did
|
|
74
|
+
not take the pick. A pick that leaves the Agent's state ends the Turn. The Orchestrator's abort of that Turn can land
|
|
75
|
+
before the pick's receipt does, and the abort then cuts a request that the Orchestrator already took. Real models showed
|
|
76
|
+
this on 2026-10-22 (Haiku 5.5 and Qwen, the `@model` claim `noticed`). The run moved on the pick, and the model read
|
|
77
|
+
"the pick never reached the Orchestrator (This operation was aborted) — try it again". In its next Turn, the model said
|
|
78
|
+
that the call failed.
|
|
79
|
+
|
|
80
|
+
So a pick's `POST` has three cases when it gets no receipt. They give five texts: one for the first case, and two for
|
|
81
|
+
each of the others. The Harness returns the second and the third case as the call's answer, not as an error, because an
|
|
82
|
+
error reads as a failed pick and invites a second one:
|
|
83
|
+
|
|
84
|
+
- **The request never left the Harness.** Then the call fails, and the error is "the pick never reached the Orchestrator
|
|
85
|
+
(<detail>) — try it again". Two kinds of failure prove this. The first kind is a transport failure in which no
|
|
86
|
+
connection was made, so no byte of the request was written. These are a refused connection (`ECONNREFUSED`), a failed
|
|
87
|
+
name lookup (`ENOTFOUND`, `EAI_AGAIN`), undici's connect timeout (`UND_ERR_CONNECT_TIMEOUT`), and an unreachable
|
|
88
|
+
network or host (`ENETUNREACH`, `EHOSTUNREACH`) from the `connect` call. The second kind is the Custodian's 502 with
|
|
89
|
+
`x-jr2-custodian: unreachable`, when all of its Envoy response flags say that it did not forward the request: `UF`,
|
|
90
|
+
`UH`, `DF` or `URX` ([ADR-0059](0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md)). `URX`, the retry
|
|
91
|
+
limit, is in this set for one reason only. The `events` route retries only on `reset-before-request,connect-failure`,
|
|
92
|
+
so its last attempt failed before the request was sent. A test of `custodian.ts` holds that route to those two
|
|
93
|
+
conditions. The Custodian's text for these is "the Custodian could not reach it (<detail>)", in the place of
|
|
94
|
+
`<detail>`. After an abort of the call, no failure proves this, because the call was the Turn's and the Turn is over.
|
|
95
|
+
- **The Harness does not know whether the Orchestrator took the pick.** Every other failure can come after the request
|
|
96
|
+
left: a reset, a socket that the other side closed, a headers timeout, an abort of the call, or a Custodian 502 with
|
|
97
|
+
another flag. These flags are an upstream connection that closed (`UC`), a stream reset (`UMSDR`), an upstream
|
|
98
|
+
protocol error (`UPE`), a remote or a local reset (`UR`, `LR`), and a downstream protocol error (`DPE`). A `DPE` means
|
|
99
|
+
that the Custodian did not forward a whole request, but a body framing error can come after the headers went upstream.
|
|
100
|
+
The Custodian says `UPE`, `UR` and `LR` as its 502 too, so that Envoy's own 503 for them does not read as a stopping
|
|
101
|
+
Orchestrator's 503. After an abort of the call, the answer is "It is not known whether this pick reached the workflow:
|
|
102
|
+
your turn ended while the call to `<pick>` waited for the workflow's answer (<detail>). A pick that the workflow takes
|
|
103
|
+
can end the turn in this way. Do not call `<pick>` again before you read the next message: it tells you where the
|
|
104
|
+
workflow is now." Otherwise the Turn goes on, and the answer is "It is not known whether this pick reached the
|
|
105
|
+
workflow: the call to `<pick>` failed before the workflow answered (<detail>). A second call to `<pick>` can run the
|
|
106
|
+
pick twice." It gives no order to call again, because a pick with no target runs again when it is sent again. And it
|
|
107
|
+
does not say how the model can find out: a guard back, a self-transition and an unchanged list of Allowed picks all
|
|
108
|
+
look the same as a pick that did not arrive.
|
|
109
|
+
- **The Orchestrator took the pick, and its answer was not read.** This is an outstanding pick whose poll an abort of
|
|
110
|
+
the call cuts, or a 2xx whose body did not arrive. A 2xx is the Orchestrator's own word that it took the pick. The
|
|
111
|
+
answer is "This pick reached the workflow, and your turn ended before you read its answer. Do not call `<pick>` again
|
|
112
|
+
before you read the next message: it tells you where the workflow is now." When the call's own abort was not the
|
|
113
|
+
cause, "your turn ended before you read its answer" is "its answer did not reach you (<detail>)".
|
|
114
|
+
|
|
115
|
+
Each other answer of the Orchestrator keeps its own text, because it is a receipt or a refusal and not a lost request: a
|
|
116
|
+
503 from a stopping Orchestrator (the pick was not taken — try it again, ADR-0042), a 409 while a pick is outstanding
|
|
117
|
+
(ADR-0024), and a 404 when the Turn is over (ADR-0026).
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# A Sandbox's workspace is a private local clone of a read-only cache, not a shared worktree
|
|
2
|
+
|
|
3
|
+
Each Sandbox gets its **own** `.git` by running a local `git clone --shared` from a canonical bare checkout that is
|
|
4
|
+
mounted **read-only**, rather than a `git worktree add` against a single shared `.git`. The clone borrows objects from
|
|
5
|
+
the checkout (via alternates) for fast, disk-cheap setup but owns its refs, index, HEAD, config, and — critically — its
|
|
6
|
+
**lock files**, so a Sandbox contends only with itself. The checkout it borrows from is a **node-local cache** the
|
|
7
|
+
operator keeps (ADR-0051), one per repository per node, so a Sandbox can be scheduled on any node.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
jr gave every feature a `git worktree`, but all worktrees resolved through **one** `default/.git`. Git serializes access
|
|
12
|
+
to that shared state with lock files, so concurrent commit/fetch/rebase/ref updates across worktrees blocked each other
|
|
13
|
+
_through the filesystem_. That contention is the bottleneck the whole per-pod isolation design exists to kill — so the
|
|
14
|
+
worktree model had to change, not just move into pods. A private `.git` removes the shared lock surface **by
|
|
15
|
+
construction**; that is the load-bearing claim, and it does not depend on a measurement. A PoC corroborated it directly
|
|
16
|
+
(same concurrent churn: lock-retries present with a shared `.git`, zero with private clones) and confirmed setup stays
|
|
17
|
+
worktree-cheap (`--shared` `.git` ~7M vs a worktree's ~7M, vs a full copy ~33M).
|
|
18
|
+
|
|
19
|
+
The isolation is the point, not the speed: the read-only source plus a private pod-local clone is what confines an
|
|
20
|
+
Agent's blast radius to its own pod (it physically cannot write — or `gc` — the canonical source or any sibling's work),
|
|
21
|
+
which is the Sandbox thesis (GLOSSARY.md) applied to git.
|
|
22
|
+
|
|
23
|
+
We keep jr's worktree _ergonomics_ — fast spin-up, a working dir that looks like a totally normal checkout, one
|
|
24
|
+
canonical checkout everything branches from — because borrowing objects sets up about as cheaply as a worktree while
|
|
25
|
+
giving an independent `.git`.
|
|
26
|
+
|
|
27
|
+
## The `--shared` invariant, and how it is enforced
|
|
28
|
+
|
|
29
|
+
A `--shared` clone reads the cache's object store via `objects/info/alternates`; if an object it borrows is ever deleted
|
|
30
|
+
from the cache, the clone corrupts (a PoC reproduced this by `gc`-ing a writable checkout under a live clone). So the
|
|
31
|
+
one invariant: **objects in a cache are never deleted while clones borrow them.** Deletion only happens via
|
|
32
|
+
`git gc`/`prune` — including the `gc --auto` that porcelain commands (`fetch` among them) run past a loose-object
|
|
33
|
+
threshold, which prunes unreachable objects (e.g. commits a force-push orphaned that a long-lived clone still borrows).
|
|
34
|
+
Enforcement, by side:
|
|
35
|
+
|
|
36
|
+
- **Sandboxes cannot violate it**: the cache is mounted read-only, and gc is a write. Structural.
|
|
37
|
+
- **The cache agent is the one writer per node, and it is config-pinned**: it sets `gc.auto=0`, `gc.pruneExpire=never`,
|
|
38
|
+
and `maintenance.auto=false` on every checkout it clones **or adopts**, before any fetch — so neither its own fetches
|
|
39
|
+
nor a human running git inside the checkout can trigger object deletion. Only a deliberate `git gc --prune=now`
|
|
40
|
+
defeats it, which is `rm -rf` territory.
|
|
41
|
+
- **Maintenance that deletes no object is allowed, and it is the agent's** (scaling review R13, 2026-09-30). A cache
|
|
42
|
+
that is only ever fetched accumulates a pack per fetch until every object lookup in every borrowing clone opens
|
|
43
|
+
thousands of packs. So the agent consolidates on its interval, once loose objects or packs pass a count:
|
|
44
|
+
`git repack -a -d -k` (one pack holding EVERY object, unreachable ones included — `-k` is the invariant, spelled as a
|
|
45
|
+
flag: a force-pushed branch's commits stay for the clone that borrows them), `git commit-graph write` and
|
|
46
|
+
`git pack-refs --all`. Git is built for readers under a repack — an open pack stays readable after its unlink, and a
|
|
47
|
+
missed object re-scans the pack list — so a borrowing clone never sees an absent object. The rule for anyone who edits
|
|
48
|
+
that command: no `--prune`, no `gc`, and `gc.pruneExpire=never` stays.
|
|
49
|
+
- **Eviction is deletion of the whole checkout**, never of objects inside one, and only once no pod on the node mounts
|
|
50
|
+
it (ADR-0051). A live clone never loses what it borrows.
|
|
51
|
+
|
|
52
|
+
The fallback where borrowing can't be made safe (a source jr2 doesn't control) is a **full copy** — fully independent,
|
|
53
|
+
gc-proof, at full disk and the slowest setup. An immutable generation-swap scheme (refresh = a new snapshot, old
|
|
54
|
+
generations released as their Sandboxes drain) was considered again as OCI image volumes in ADR-0051 and rejected there:
|
|
55
|
+
it buys nothing gc pinning does not, and a repack costs a full re-upload.
|
|
56
|
+
|
|
57
|
+
## Refresh is fetch-in-place; the `Repo` resource is the catalog
|
|
58
|
+
|
|
59
|
+
The catalog is the set of `Repo` custom resources in the Instance's namespace (ADR-0051): the Orchestrator creates one
|
|
60
|
+
per repository its Machines bind, at boot from the walk and at first attach for a per-run url, and the operator
|
|
61
|
+
reconciles each onto the nodes that need it. A cache is cloned when missing and fetched when present — on the resource's
|
|
62
|
+
interval, on demand before every attach, so a Workspace starts from the remote's now, and on demand for every fetch run
|
|
63
|
+
inside a pod — the worktrees' `origin` asks the cache, and the cache asks the remote (ADR-0053). `fetch` only **adds**
|
|
64
|
+
objects — safe under the invariant by nature. A repository that fails to sync degrades that repository, never the
|
|
65
|
+
Instance: a cold clone that fails fails the one provision that needed it, pointedly; a fetch that fails on a warm cache
|
|
66
|
+
lets the attach proceed on what the cache holds, announced as stale
|
|
67
|
+
([ADR-0048](0048-the-orchestrator-boots-without-its-repos.md)). The cache agent runs in-cluster, so every repository
|
|
68
|
+
must be **fetchable from the cluster** (an HTTPS token or deploy-key Secret for private ones — ADR-0019, ADR-0047); a
|
|
69
|
+
working copy that exists only on someone's host is not a valid source. Local-path urls are cloned like any other url,
|
|
70
|
+
never used in place. A checkout found in a node's cache directory with no `Repo` resource is adopted and pinned but not
|
|
71
|
+
refreshed, and is evicted like any other once nothing mounts it.
|
|
72
|
+
|
|
73
|
+
A repository is identified by its url — host plus path, with scheme, user, a trailing `/` and `.git` dropped — so every
|
|
74
|
+
spelling a Machine writes for one repository lands on ONE cache per node, and the expensive clone happens once per
|
|
75
|
+
repository per node. The directory name is derived from that identity, printed beside the url by `jr2 status`, and never
|
|
76
|
+
chosen by a human; there is no name to derive twice or to orphan.
|
|
77
|
+
|
|
78
|
+
## Storage shape: a cache per node
|
|
79
|
+
|
|
80
|
+
The contract every Sandbox needs is narrow — _a read-only directory per repository it attaches, on the node it runs on_
|
|
81
|
+
— and the writable side is always pod-local (`emptyDir`), which scales with agent count by construction. The backing is
|
|
82
|
+
a **hostPath directory per node** that the Instance's cache agent (a DaemonSet) populates and the operator mounts
|
|
83
|
+
read-only into Sandboxes, with a soft affinity toward nodes already holding the repositories a Sandbox names. A node
|
|
84
|
+
that lacks one clones it on first need — the image-pull model, and the reason the cluster's node count is not a limit on
|
|
85
|
+
Sandbox placement. A single-node cluster (kind) is the one-node case of the same model; there is no separate volume path
|
|
86
|
+
to keep.
|
|
87
|
+
|
|
88
|
+
Read-only-ness is load-bearing twice over: nobody can write the shared thing (no write contention), and nobody can `gc`
|
|
89
|
+
it (the structural half of the invariant). Shell-in (`kubectl exec`) needs none of this — the cache exists purely for
|
|
90
|
+
fast setup.
|
|
91
|
+
|
|
92
|
+
## Where setup runs
|
|
93
|
+
|
|
94
|
+
The clone is a post-`Ready` step owned by the Orchestrator: the Sandbox CRD names repositories by identity so the
|
|
95
|
+
operator can place and mount them, but it knows nothing about clones or worktrees (ADR-0001, ADR-0051). `workspace()`'s
|
|
96
|
+
attach is one idempotent in-pod script the Harness runs on `POST /attach` (ADR-0012,
|
|
97
|
+
[ADR-0063](0063-the-orchestrator-watches-the-cluster.md)), once `Ready` says every cache is present and fetched. The
|
|
98
|
+
attach only clones. A Machine cuts each Worktree with the kit Effect `worktree()`
|
|
99
|
+
([ADR-0075](0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md)).
|
|
100
|
+
|
|
101
|
+
## In-Sandbox layout: worktrees off the per-Sandbox clone (gwtmux convention)
|
|
102
|
+
|
|
103
|
+
"Not a shared worktree" above means _not a worktree off a fleet-shared `.git`_. **Inside** a Sandbox, worktrees are the
|
|
104
|
+
intended ergonomic — taken off the Sandbox's **own** clone. The per-Sandbox clone _is_ the `default/`, and the Worktrees
|
|
105
|
+
are its siblings, matching the layout [gwtmux](https://github.com/snapwich/gwtmux) already expects, under the slot the
|
|
106
|
+
Machine gave the repository (ADR-0051):
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
/work/<slot>/default/ the per-Sandbox `git clone --shared` (holds the pod-local .git; stays on the Binding's base)
|
|
110
|
+
/work/<slot>/<name>/ `git worktree add` siblings: folders a Machine cuts, each with one branch or one commit
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Because the layout is identical to the one used outside the cluster, existing worktree tooling works **unchanged** when
|
|
114
|
+
you exec into a Sandbox. Object resolution falls through: a Worktree shares the per-Sandbox `default/.git`, whose
|
|
115
|
+
alternates point at the read-only cache — existing objects are read from the cache, new commits land in the pod-local
|
|
116
|
+
`.git`. `default/` stays on the Binding's base: a branch is attached, a tag or a commit is detached, and with no base it
|
|
117
|
+
is the Repo's default branch, as gwtmux's `default/` is outside the cluster. No work happens in it: work happens in the
|
|
118
|
+
Worktrees, and `default/` is the reference tree that a human, a tool or an Agent that only reads finds where it expects
|
|
119
|
+
one. Not `--no-checkout`: an empty working tree reads as every tracked file deleted to `git status`, which misleads an
|
|
120
|
+
Agent that looks. A Worktree's folder cannot be named `default`, and a cut of the branch that `default/` holds is
|
|
121
|
+
`refused`, with a reason that names `/work/<slot>/default`. `origin`'s push url is the binding's own spelling, so a
|
|
122
|
+
Machine that bound over ssh pushes over ssh even when the cache was cloned over https.
|
|
123
|
+
|
|
124
|
+
This does **not** reintroduce jr's contention: the shared `.git` here is _per-Sandbox_, so what contends stays in one
|
|
125
|
+
pod and never crosses the fleet. Inside the pod it is the author's choice. A Machine that runs concurrent Agents in
|
|
126
|
+
Worktrees of one clone, or an Agent that spawns parallel git-touching subagents, takes that contention, and a Machine
|
|
127
|
+
that does not want it uses separate Sandboxes. Two things keep one clone safe to share. The attach sets `gc.auto=0` and
|
|
128
|
+
`maintenance.auto=false` in the clone, because a `gc` beside a cut or a commit can prune what another Worktree needs.
|
|
129
|
+
And all Worktrees of a clone share `refs/remotes/origin/*`, so `jr2-upload-pack` holds a per-clone lock for each fetch
|
|
130
|
+
(ADR-0053, ADR-0075).
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# The Sandbox pod's containers: Harness, Custodian, an optional User Container, and an optional Sandbox Effector
|
|
2
|
+
|
|
3
|
+
A Sandbox pod composes up to four containers around one shared worktree volume (`/work`):
|
|
4
|
+
|
|
5
|
+
- **Harness container** — the CR's primary container: the Sandbox Image (user-built or user-brought,
|
|
6
|
+
[ADR-0037](0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md)) with jr2's runtime mounted at
|
|
7
|
+
`/opt/jr2` and the command overridden to start the Harness. The Agent's toolchain lives here because the Working tools
|
|
8
|
+
(read/write/edit/bash) execute here — the tools the Agent can reach are this image's. It is also where a human
|
|
9
|
+
`kubectl exec`s to inspect a run with the agent's exact tools, worktrees, and filesystem. One Harness container serves
|
|
10
|
+
every workspace-ful Agent of the Sandbox. Each process that the Harness starts in `/work` — a Working tool's shell, a
|
|
11
|
+
test, git, a `command` Effect — runs as a confined child of the Harness process, so no code from `/work` reaches the
|
|
12
|
+
control credential that the Harness process holds
|
|
13
|
+
([ADR-0076](0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md)).
|
|
14
|
+
- **Custodian container** — jr2-composed sidecar, upstream Envoy configured at provision by the Orchestrator
|
|
15
|
+
([ADR-0013](0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md),
|
|
16
|
+
[ADR-0059](0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md)): the pod's credential holder. It holds
|
|
17
|
+
the Sandbox token and the held secrets its pod names — model API keys above all — as files, and carries the Harness's
|
|
18
|
+
traffic out: its Menu and the ask to the Orchestrator, and its outbound HTTPS. It exists as a separate container
|
|
19
|
+
precisely _because_ the Working tools give the Agent code execution in the Harness container — the credentials live
|
|
20
|
+
where the Agent cannot read them, and the Harness container holds only their Stand-ins, which are secret: only the
|
|
21
|
+
Harness process holds them, as files, and a process it confines gets only those that its `workspace()` grants
|
|
22
|
+
(ADR-0059, ADR-0076).
|
|
23
|
+
- **User Container** — opt-in third seat, composed only when the `workspace()` names its image (a `file:` docker context
|
|
24
|
+
the Machine ships or a registry ref, the same resolution as the Sandbox Image, no default —
|
|
25
|
+
[ADR-0049](0049-a-machine-carries-its-parts-and-composes-by-invoke.md)). The authoring surface is that string —
|
|
26
|
+
`user?: string` among `workspace()`'s options, beside `image` — or the object it widens to,
|
|
27
|
+
`{ image, resources?: { limits: { memory?, cpu? } } }`, whose only other key is the seat's split of the Workspace's
|
|
28
|
+
Size ([ADR-0060](0060-a-workspace-states-its-size-and-jr2-reserves-all-of-it.md)): a budget is the pod's business, not
|
|
29
|
+
the process's, so it puts nothing in the container. Env and ports are not forwarded, because every key jr2 forwarded
|
|
30
|
+
into the process would be a crack in "jr2 puts nothing in it"; a routable port (the follow-up below) is the known
|
|
31
|
+
candidate for the next key. In the pod it is the container named `user` (`kubectl exec -c user`). It runs its **own
|
|
32
|
+
entrypoint, untouched**: jr2 injects nothing into its process — no env, no command, no probe — and overrides nothing;
|
|
33
|
+
it mounts only what the checkouts need to be checkouts (below). The zero-contract seat, which is exactly why it
|
|
34
|
+
exists. Two jobs no other seat can do:
|
|
35
|
+
- **Unattended services.** A container's one command belongs to the Harness in the primary seat (ADR-0037), so an
|
|
36
|
+
image's own services — an sshd for managed access, an IDE server, a metrics agent — need a container whose command
|
|
37
|
+
jr2 deliberately does not own. A system built _on_ jr2 that hands people access to their Sandboxes automates through
|
|
38
|
+
this seat.
|
|
39
|
+
- **Credential visibility.** A session's secrets — a forwarded ssh agent socket above all — live in this container's
|
|
40
|
+
private mount namespace, where the Agent executes nothing. The human sshs in with agent forwarding, works, and the
|
|
41
|
+
socket is gone on disconnect; at no point does it share a filesystem with code the Agent runs.
|
|
42
|
+
- **Sandbox Effector** — a jr2-composed container, added at provision only when an Effect below the `workspace()` is
|
|
43
|
+
placed there — a `push`, an `http` whose body is a Worktree file, or a credentialed `command`
|
|
44
|
+
([ADR-0067](0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md),
|
|
45
|
+
[ADR-0068](0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md)), which `jr2 up` reads off the Machines
|
|
46
|
+
and declares in the pod's manifest beside its Held one. In the pod it is the container named `effector`. Its image is
|
|
47
|
+
the Sandbox Image unless the `workspace()` names `effector: { image }` — a `file:` context or a registry ref, resolved
|
|
48
|
+
like the User Container's — for the tools and the credentials kept out of the Agent's image. Its command is jr2's
|
|
49
|
+
binary on the runtime volume (`/opt/jr2/bin/effector`); it runs as its image's own `USER` (ADR-0037's 1000 fallback
|
|
50
|
+
included), hardened like the Harness. The Agent executes nothing in it: it holds the secrets its Effects name and the
|
|
51
|
+
credentials its pushes spend, which the Agent never reads. With the default image it runs as the Agent's uid, and that
|
|
52
|
+
is the rule, not a gap: the containers share no mount namespace and no `/proc`, so the barrier is the container and
|
|
53
|
+
the read-only mount, not the uid. Inside it, a credentialed command's child — the author's argv, often running the
|
|
54
|
+
Agent's code — shares the Effector's uid, so the Effector confines it with Landlock to the image's system paths, its
|
|
55
|
+
own scratch (its `TMPDIR` included; never `/tmp`) and the secret files it names, and ends every process it left before
|
|
56
|
+
its Effect settles (ADR-0068). No Custodian, no `harness.env`, and no env beyond where its mounts are and its pod's
|
|
57
|
+
name, address and node.
|
|
58
|
+
|
|
59
|
+
The Harness and User containers mount `/work` read-write, **`/repos` read-only** — the node's Repo cache, one bare
|
|
60
|
+
checkout per Repo the Sandbox names (ADR-0004, ADR-0051) — **and `/opt/jr2` read-only**, the runtime volume — so human
|
|
61
|
+
and agent see identical files. Neither `/repos` nor `/opt/jr2` is a second exception; each is half of the first. The
|
|
62
|
+
worktrees are `--shared` clones whose alternates resolve objects from `/repos/<key>`, so a seat holding `/work` alone
|
|
63
|
+
holds checkouts whose every borrowed object is missing — git in the User Container dies on "unable to normalize
|
|
64
|
+
alternate object path". And their `origin` fetches through a program on `/opt/jr2` (ADR-0053), so a seat without that
|
|
65
|
+
volume holds checkouts whose `git fetch` dies. The Custodian mounts none of the three — the pod's credential holder has
|
|
66
|
+
no business in the working tree.
|
|
67
|
+
|
|
68
|
+
What each container mounts of the credentials, stated once:
|
|
69
|
+
|
|
70
|
+
| Container | Mounts |
|
|
71
|
+
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
72
|
+
| `harness` | `jr2-ca` at `/etc/jr2/ca` (the trust bundles); the Stand-ins — the control one and each held secret's — as files under `/run/jr2/` that only the Harness process reads and no process it confines can (ADR-0059, ADR-0076); no Stand-in is in its env. None of the Custodian's volumes. |
|
|
73
|
+
| `custodian` | its values (the Sandbox's control token and fetch token, its pod's `jr2-held-secrets-<pod>`, each user `secretKeyRef` its pod names) at `/etc/jr2/custodian/values`; its leaves (`jr2-held-tls`); its pod's config (its own `<sandbox>-custodian`, and `program.lua` from `jr2-held`); `upstream.crt`. |
|
|
74
|
+
| `user` | the checkouts' three volumes, and nothing else. |
|
|
75
|
+
| `effector` | `/work`, `/repos/<key>` and `/opt/jr2` read-only; at `/run/jr2/secrets` (0400) the secrets its Effects name — its pod's `jr2-effector-<pod>` literals, each user `secretKeyRef` by reference — and at `@git/<name>/` each `git.credentials` entry's push Secret the Sandbox CR names (`gitCredentials`), which the operator projects from that entry's Secret by reference into this container alone (`@` is no character of a secret's name, so no secret's file takes that directory); at `/run/jr2/effector` its key (its own `<sandbox>-effector`) and `effector.json`; an emptyDir at `/run/jr2/scratch` as `$HOME`; at `/etc/jr2/effector-ca`, read-only, the instance's caBundle ConfigMap when the instance ships one (ADR-0020). |
|
|
76
|
+
|
|
77
|
+
One git wall stays the image's own: git's dubious-ownership guard fires in the User Container whenever its uid differs
|
|
78
|
+
from the Harness's (the attach created the trees), and `safe.directory` is honored only from system/global config —
|
|
79
|
+
files jr2 does not own in this seat. Env-form config (`GIT_CONFIG_*` on the sidecar) was considered and rejected: it
|
|
80
|
+
would crack "jr2 puts nothing in it", and it does not even cover the seat's flagship access path — ssh login sessions
|
|
81
|
+
scrub container env — so the image would still owe a line for sshd while carrying jr2's env for exec sessions, two
|
|
82
|
+
mechanisms where one honest one serves. A User Container image whose sessions run git carries its own
|
|
83
|
+
`git config --global --add safe.directory '*'` (or ships it in `/etc/gitconfig`).
|
|
84
|
+
|
|
85
|
+
The worktrees' remotes encode which hop each seat can make: `git fetch origin` asks the node cache, and the cache asks
|
|
86
|
+
the remote with the cluster's credential (ADR-0053 — the hop the pod can make, from any seat, with no credential of its
|
|
87
|
+
own), while origin's **push url** is the real remote in the Binding's own spelling — the attach sets it from the slot
|
|
88
|
+
the Machine bound (ADR-0051), so a Machine that bound over ssh pushes over ssh even when the cache was cloned over
|
|
89
|
+
https, and nothing else plumbs it. A push from the Harness or the User Container therefore succeeds exactly when a
|
|
90
|
+
caller supplies the credential — a human's forwarded agent in the User Container — and never for the Agent, because no
|
|
91
|
+
container it executes in holds one (the credential-visibility story above, unchanged). An UNATTENDED publish is the
|
|
92
|
+
Machine's `push()` Effect (ADR-0067): it runs in the Sandbox Effector, which holds the push credential of the
|
|
93
|
+
`git.credentials` entry the Repo Slot's url falls under — the provision names that entry's Secret on the Sandbox CR and
|
|
94
|
+
the operator projects it (ADR-0051) — and pushes to that url, never to origin's push url from the Agent's config. A
|
|
95
|
+
`git.credentials` entry's credentials never enter the Orchestrator: `jr2 up` writes the push credential — a
|
|
96
|
+
`push: { token }` or `push: { sshKey }`, or the clone credential where the entry has no `push` field — into that entry's
|
|
97
|
+
own push Secret (`jr2-git-push-<hash of match>`), which only the Effector is given, and the clone token into the clone
|
|
98
|
+
Secret `jr2-git-<hash of match>`, which only the cache agent reads (ADR-0051, ADR-0068). The Orchestrator names both and
|
|
99
|
+
holds neither. Opening a pull request is an `http` Effect the author writes; jr2 has no forge client.
|
|
100
|
+
|
|
101
|
+
## Sharing `/work` across uids
|
|
102
|
+
|
|
103
|
+
jr2 sets `runAsUser` nowhere — each image's own `USER` decides its seat's uid — so the two writing seats may disagree,
|
|
104
|
+
and plain POSIX modes would then make the other seat's files read-only. jr2 closes that with two mechanisms it happens
|
|
105
|
+
to own, unconditionally (both are inert when the uids already match):
|
|
106
|
+
|
|
107
|
+
- **The pod carries `fsGroup` — the work group, default `2000`.** The default is convention; the one override is an
|
|
108
|
+
option of `workspace()` (`workspace(body, { workGroup: 2000 })`, beside `image` and `user` — pod composition is the
|
|
109
|
+
wrapper's business, ADR-0037), for the image whose session users already hold a gid of their own. Never a config key.
|
|
110
|
+
Any value is safe for the jr2 seats: Kubernetes grants the fsGroup as a supplemental group to every container process,
|
|
111
|
+
so the Harness writes `/work` whatever the number, and no image's `/etc/group` needs to know it. fsGroup puts a setgid
|
|
112
|
+
group on `/work`'s root, and setgid propagates down, so everything either seat creates is group-_owned_ by the work
|
|
113
|
+
group.
|
|
114
|
+
- **The attach stamps a default ACL on every repo root it creates** — `u::rwx,g::rwx,o::r-x`, set by `work-acl`, a
|
|
115
|
+
static helper on the runtime volume (part of ADR-0037's published `/opt/jr2` surface, static for the same reason `rg`
|
|
116
|
+
is: it executes on a libc jr2 does not control). A default ACL is inherited by everything created beneath it, and
|
|
117
|
+
POSIX ignores the process umask where one is present — so every file either seat writes under a repo tree lands
|
|
118
|
+
group-writable (664/775), with **zero lines in any image**: no umask in a login shell, no jr2 knowledge in a brought
|
|
119
|
+
User Container. Ownership without writability was the trap — fsGroup alone leaves the other seat group-_read_. On a
|
|
120
|
+
filesystem without POSIX ACL support the helper warns and changes nothing, and sharing degrades to the umask fallback
|
|
121
|
+
below.
|
|
122
|
+
|
|
123
|
+
Group _membership_ is the one thing an image can still owe. `kubectl exec` sessions and a non-root sshd's logins hold
|
|
124
|
+
the work group automatically — both inherit the container's supplemental groups, where Kubernetes put the fsGroup. A
|
|
125
|
+
**root** sshd is the exception: the logins it setuids rebuild their groups from `/etc/group`, so that image puts its
|
|
126
|
+
session user in the work group itself (`groupadd -g 2000 work && usermod -aG work <user>`) — or the `workspace()` points
|
|
127
|
+
`workGroup` at a gid its users already hold.
|
|
128
|
+
|
|
129
|
+
**The promise is scoped to repo trees.** An ad-hoc path at `/work`'s top level — either seat's `mkdir /work/scratch` —
|
|
130
|
+
carries no default ACL, so files there fall back to the writer's umask. The Harness runs at `umask 002` (itself and
|
|
131
|
+
every tool child) and the attach script opens with the same line, so jr2's own writes are group-writable even there;
|
|
132
|
+
what a User Container session writes outside a repo tree is its own business. A Workspace with no Repo Slot (ADR-0051)
|
|
133
|
+
has no repo tree: its steps share `/work` itself, every Agent and `command` of it at the one uid in the one Harness
|
|
134
|
+
container, so they need no ACL; a User Container's writes there have this paragraph's limit, until a user of that seat
|
|
135
|
+
needs more.
|
|
136
|
+
|
|
137
|
+
## Constraints on the seat
|
|
138
|
+
|
|
139
|
+
The seat is zero-_contract_, not zero-_physics_. What an image brought here must live with:
|
|
140
|
+
|
|
141
|
+
- **The entrypoint must block.** The pod's restart policy restarts an exited container, so a `CMD` that finishes
|
|
142
|
+
crash-loops. Run a long-lived process — an sshd, an IDE server, `sleep infinity` at minimum.
|
|
143
|
+
- **The ports of jr2's seats are taken** — one network namespace: `:8080` (the Harness), the Custodian's
|
|
144
|
+
`127.0.0.1:8081` (the fetch), `127.0.0.1:15001` (egress) and `:15021` (health), and `:8082` (the Sandbox Effector, in
|
|
145
|
+
a pod that has one). Everything else is the image's. A process that binds `:8082` while the Sandbox Effector is down
|
|
146
|
+
gets signed requests it can neither forge nor answer (ADR-0068): the Effect fails closed.
|
|
147
|
+
- **Root is allowed.** The hardened security context (non-root, all capabilities dropped, no escalation) applies to the
|
|
148
|
+
seats jr2 owns — Harness, Custodian and Sandbox Effector, per container — and deliberately not here: the seat's
|
|
149
|
+
identity is "what jr2 does not own", and hardening it is an opinion. A root sshd that binds `:22` and setuids sessions
|
|
150
|
+
down to its login user — the standard shape for managed access — runs unmodified; a platform that wants this seat
|
|
151
|
+
hardened hardens its own image or its namespace's Pod Security profile. The credential-visibility boundary never
|
|
152
|
+
depended on this seat being unprivileged, only on the Agent executing nothing in it.
|
|
153
|
+
|
|
154
|
+
## Why separate containers, not one image
|
|
155
|
+
|
|
156
|
+
- **Image ownership** — the Sandbox Image is the user's (ADR-0037), the Custodian is a pinned upstream image, and the
|
|
157
|
+
User Container is whatever its owner wants, contract-free because jr2 puts nothing in it. None bloats or constrains
|
|
158
|
+
the others.
|
|
159
|
+
- **Independent resource limits** — the Workspace's Size is one ceiling for the pod, and a Machine may split it so the
|
|
160
|
+
User Container gets a limit of its own (ADR-0060); then a runaway build there can't starve the agent (and vice-versa).
|
|
161
|
+
Without a split the User Container shares the pod budget.
|
|
162
|
+
- **Lifecycle independence** — a Harness crash restarts only the Harness; an interactive session in the User Container
|
|
163
|
+
survives, and vice-versa.
|
|
164
|
+
|
|
165
|
+
## What the split is — and is not — a security boundary
|
|
166
|
+
|
|
167
|
+
The **pod** is the isolation unit (one per Workspace, the k8s north-star): its containers share a kernel, network
|
|
168
|
+
namespace, node, and the worktree volume, and are one trust domain in every isolation sense. The one property a
|
|
169
|
+
container edge does provide is **credential visibility** — the asymmetry ADR-0013 builds on: the Agent executes code in
|
|
170
|
+
the Harness container and none in the others, so a secret that exists only in another container's mount namespace is out
|
|
171
|
+
of its reach. That is why the Custodian holds the Orchestrator credential and the model keys, and it is what the User
|
|
172
|
+
Container offers a human session. Inside the Harness container, a second edge holds the one credential the Harness keeps
|
|
173
|
+
itself, the control Stand-in: the Harness process is jr2's code, and each process it starts in `/work` is confined from
|
|
174
|
+
that Stand-in by Landlock (ADR-0076, [ADR-0077](0077-jr2-requires-landlock-on-every-sandbox-node.md)). The edges, stated
|
|
175
|
+
plainly:
|
|
176
|
+
|
|
177
|
+
- It holds only for what stays in the private filesystem or session. Anything on `/work` or listening on `localhost` is
|
|
178
|
+
shared with the Agent — the pod has one network namespace. The Custodian's listeners are on `localhost` too; what they
|
|
179
|
+
hand out is a key's USE toward its bound host, never the key (ADR-0059).
|
|
180
|
+
- A live forwarded agent socket is usable by anything that executes as that session's user in that namespace — so a
|
|
181
|
+
session holding one should not blindly execute what it finds on `/work` (the worktree is agent-authored; sourcing its
|
|
182
|
+
`direnv`, running its scripts, or trusting its `justfile` volunteers the boundary away).
|
|
183
|
+
- **It is not port isolation.** The User Container's sshd and the Agent's dev server contend for the same ports.
|
|
184
|
+
- **It is not a seat for parallel authorship.** Two writers on one checkout collide as files, whatever the container
|
|
185
|
+
layout; parallel work belongs on a second checkout — fetch the branch out
|
|
186
|
+
(`git fetch "ext::kubectl exec -i <pod> -c harness -- git upload-pack /work/<slot>/default"` needs no credential in
|
|
187
|
+
the pod at all) and push from where the keys already live.
|
|
188
|
+
|
|
189
|
+
## Considered options
|
|
190
|
+
|
|
191
|
+
- **No third seat; start services by hand** (`kubectl exec -c harness -- sshd`). Works for a person, rejected as the
|
|
192
|
+
mechanism: a service a managed system relies on cannot depend on a human's exec, and it plants the session's
|
|
193
|
+
credentials in the Agent's own mount namespace.
|
|
194
|
+
- **Ephemeral containers as the attach path.** Right for one-off, creds-free debugging (`kubectl debug --target=harness`
|
|
195
|
+
with a plain image), rejected as the standing mechanism: they cannot run from pod birth, accumulate per attach, and
|
|
196
|
+
are spent on exit.
|
|
197
|
+
- **A truly minimal Harness** (agent loop only, tools executed in a sibling container) — would need a cross-container
|
|
198
|
+
execution transport jr2 does not have. Not blocking: the agent toolchain lives in the Sandbox Image; revisit only if
|
|
199
|
+
that ever becomes a real cost (ADR-0037 records the same rejection from the image side).
|
|
200
|
+
- **umask discipline as the sharing mechanism** (fsGroup + `umask 002` in every writer). Rejected as what the promise
|
|
201
|
+
rests on: the Harness's half is one startup line jr2 controls, but the User Container's half is a line every image
|
|
202
|
+
must remember (`[ -d /work ] && umask 002` in a login shell) — a gotcha, and a silent one: forgetting it costs nothing
|
|
203
|
+
until the Agent cannot edit a human's file, and the failure reads as the Agent's. It also cuts the other way — a
|
|
204
|
+
session's deliberate `umask 077` under a repo tree would break the sharing the pod exists for; under a default ACL,
|
|
205
|
+
`/work`'s repo trees are definitionally shared. The umask survives as defence in depth, not contract.
|
|
206
|
+
- **A root init container stamping the ACL on `/work`'s own root** — complete coverage (no repo-tree scoping), owner
|
|
207
|
+
rights with every capability dropped, kit code only, the standard volume-permissions idiom. Rejected: it puts a
|
|
208
|
+
`runAsNonRoot: false` container in _every_ pod, so no jr2 pod could satisfy the Pod Security "restricted" profile —
|
|
209
|
+
today that is forfeited only by a User Container that chooses root, and that choice belongs to the user, not to the
|
|
210
|
+
composition. The attach already owns the only place repo roots are born, so the narrower stamp costs one script line.
|
|
211
|
+
- **Unifying uids across seats** (document "make both images run the same `USER`"). Rejected: it makes the uid a
|
|
212
|
+
cross-image contract — every pairing of Sandbox Image and User Container must agree forever, which is exactly the
|
|
213
|
+
coupling the zero-contract seat exists to remove.
|
|
214
|
+
|
|
215
|
+
## Consequences
|
|
216
|
+
|
|
217
|
+
- The generic `Sandbox` CRD composes all of this as plain container specs
|
|
218
|
+
([ADR-0001](0001-operator-owned-generic-sandbox.md) holds — the operator stays agent-agnostic); the Orchestrator's
|
|
219
|
+
`kubectlSandbox` supplies the images. The pod runs two containers, plus `user` when the `workspace()` names one and
|
|
220
|
+
`effector` when an Effect is placed there.
|
|
221
|
+
- Every container but the Sandbox Effector carries a limit from the Workspace's Size (ADR-0060): the Custodian a fixed
|
|
222
|
+
kit slice, the User Container its split if stated, the Harness the rest. The Sandbox Effector carries no limit of its
|
|
223
|
+
own: like a User Container with no split, it shares the pod's Size ceiling — the whole Size, as pod-level resources —
|
|
224
|
+
with the containers beside it, and what share of the Size it gets is not decided. The Harness container alone mounts a
|
|
225
|
+
memory-backed `/dev/shm` (`sizeLimit` 25% of its share); the User Container keeps the pod's default.
|
|
226
|
+
- jr2 does not gate readiness on the User Container and never restarts the pod for it; it lives and dies by the pod's
|
|
227
|
+
own policy.
|
|
228
|
+
- **Operator**: `runAsNonRoot` moves from the pod level to the jr2-owned containers — the Harness, the Custodian and the
|
|
229
|
+
Sandbox Effector (the Custodian states its own context: non-root, read-only root filesystem, no capabilities); the
|
|
230
|
+
hardened-by-default rule for sidecar specs exempts the `user` container; the pod gains `fsGroup`
|
|
231
|
+
(`spec.workGroup ?? 2000`). **Orchestrator**: the attach script stamps the default ACL on each repo root _before_ the
|
|
232
|
+
clone that fills it — inheritance happens at creation, never retroactively. **Harness image**: vendors the static
|
|
233
|
+
`work-acl` into `/opt/jr2/bin` (ADR-0037's surface). **Harness**: sets `umask 002` at startup. `shareProcessNamespace`
|
|
234
|
+
stays off — ever — because the credential-visibility boundary depends on it.
|
|
235
|
+
- The Harness container's `/work` writes being group-writable widens nothing: every process that could abuse group
|
|
236
|
+
access already runs in the same trust domain, and the work group exists only inside this pod.
|
|
237
|
+
- **Routable access is a follow-up, deliberately untaken**: the operator's Service serves the Harness at `:8080`, and a
|
|
238
|
+
managed system wanting cluster-routed ssh to the User Container needs a declared port there. `kubectl port-forward`
|
|
239
|
+
reaches any listener in the pod without one, which covers the human case day one.
|
|
240
|
+
- **GLOSSARY.md**: **User Container** is a glossary term.
|