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