diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63b3af1..edac645 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,18 @@ jobs: - name: Build Runtime working-directory: .ci-runtime run: go build -trimpath -ldflags "-X github.com/Judgment-Pack/judgment-pack-runtime/internal/result.CLIVersion=0.33.0-dev.09206e7" -o "$RUNNER_TEMP/jpack" ./cmd/jpack + # Test-only old companion: its current-store refusal is part of the + # explicit manual-store preparation contract, not a release dependency. + - name: Check out ownership-era Runner + uses: actions/checkout@v4 + with: + repository: Judgment-Pack/judgment-pack-runner + ref: 1cc6b29885b344716a970cd21fefeaca0a4edaeb + path: .ci-runner-old + persist-credentials: false + - name: Build ownership-era Runner + working-directory: .ci-runner-old + run: go build -buildvcs=false -trimpath -o "$RUNNER_TEMP/jpack-runner-old" ./cmd/jpack-runner # A Runtime that signs and does not know the expected key, as every # release from v0.26.0 to v0.29.x: Runner must name the key a replaced # seed signed under (TestAnEarlierRuntimeSigningWithAReplacedSeedIsNamed, @@ -92,12 +104,58 @@ jobs: go build -trimpath -o "$RUNNER_TEMP/gateway" . cd ../adapters go build -trimpath -o "$RUNNER_TEMP/adapter-mcp" ./cmd/adapter-mcp + - name: Manual admission helper and real Runtime gate tests cannot skip + run: | + out="$(JPACK_TEST_BIN="$RUNNER_TEMP/jpack" \ + JPACK_OLD_RUNNER_TEST_BIN="$RUNNER_TEMP/jpack-runner-old" \ + go test -count=1 -v \ + -run '^(TestManualGovernedRealRuntimeGate|TestManualGovernedActualRuntimeHasNoAuthorityFD|TestManualStorePrepareActualChildRetainsOriginalCustody|TestManualStorePreparationDurableAndNoDowngrade|TestManualStorePreparationSubstitutedStateRefuses|TestManualStoreSQLiteTargetUsesHeldDirectory|TestManualStoreLibraryNeverChangesCWD|TestManualInheritedAuthoritySocketClosesBeforeRuntimeExec|TestManualAuthorityDescriptorObserverDetectsSocket|TestManualStorePreparationPendingAndUnsupported|TestManualExternalDemandCannotBeOmitted|TestManualQueuedRunListsKeepPrivatePreimages)$' \ + ./internal/runner)" || { printf '%s\n' "$out"; exit 1; } + printf '%s\n' "$out" + for name in \ + TestManualGovernedRealRuntimeGate \ + TestManualGovernedRealRuntimeGate/success \ + TestManualGovernedRealRuntimeGate/blocked \ + TestManualGovernedRealRuntimeGate/lost-reply \ + TestManualGovernedRealRuntimeGate/changed-facts \ + TestManualGovernedRealRuntimeGate/changed-config \ + TestManualGovernedRealRuntimeGate/changed-lock \ + TestManualGovernedRealRuntimeGate/changed-signing \ + TestManualGovernedRealRuntimeGate/changed-epoch \ + TestManualGovernedRealRuntimeGate/changed-demand \ + TestManualGovernedRealRuntimeGate/journal-failure \ + TestManualGovernedRealRuntimeGate/start-journal-failure \ + TestManualGovernedRealRuntimeGate/recovered-reserve \ + TestManualStorePrepareActualChildRetainsOriginalCustody \ + TestManualStorePrepareActualChildRetainsOriginalCustody/committed \ + TestManualStorePrepareActualChildRetainsOriginalCustody/refused \ + TestManualStorePrepareActualChildRetainsOriginalCustody/lose-reply \ + TestManualStorePreparationDurableAndNoDowngrade \ + TestManualStorePreparationSubstitutedStateRefuses \ + TestManualStorePreparationSubstitutedStateRefuses/before-adapter \ + TestManualStorePreparationSubstitutedStateRefuses/after-adapter \ + TestManualStoreSQLiteTargetUsesHeldDirectory \ + TestManualStoreLibraryNeverChangesCWD \ + TestManualInheritedAuthoritySocketClosesBeforeRuntimeExec \ + TestManualAuthorityDescriptorObserverDetectsSocket \ + TestManualStorePreparationPendingAndUnsupported \ + TestManualExternalDemandCannotBeOmitted \ + TestManualQueuedRunListsKeepPrivatePreimages; do + printf '%s\n' "$out" | grep -q -- "^[[:space:]]*--- PASS: $name " || { echo "$name did not run and pass"; exit 1; } + done + # This observer identifies the actual Runtime through Linux /proc. + # Every other named helper/gate test is mandatory on both hosts. + if [ "$RUNNER_OS" = Linux ]; then + name=TestManualGovernedActualRuntimeHasNoAuthorityFD + printf '%s\n' "$out" | grep -q -- "^[[:space:]]*--- PASS: $name " || { echo "$name did not run and pass"; exit 1; } + fi - name: Unit, conformance and cross-process recovery tests run: | JPACK_TEST_BIN="$RUNNER_TEMP/jpack" \ JPACK_EARLIER_TEST_BIN="$RUNNER_TEMP/jpack-earlier" \ JPACK_GATEWAY_TEST_BIN="$RUNNER_TEMP/gateway" \ JPACK_MCP_ADAPTER_TEST_BIN="$RUNNER_TEMP/adapter-mcp" \ + JPACK_OLD_RUNNER_TEST_BIN="$RUNNER_TEMP/jpack-runner-old" \ go test -race ./... # A skipped test passes `go test`; these must run (#35, #54, #55). - name: Chain, signature, binding and ownership tests run under the pinned Runtimes diff --git a/cmd/jpack-runner/capabilities.go b/cmd/jpack-runner/capabilities.go index 9955a0d..e664a5a 100644 --- a/cmd/jpack-runner/capabilities.go +++ b/cmd/jpack-runner/capabilities.go @@ -6,12 +6,15 @@ import ( "fmt" "io" "strings" + + "github.com/Judgment-Pack/judgment-pack-runner/internal/runner" ) const ( jobsCapability = "jobs/1" requiredBootCapability = "required-boot-capabilities/1" executorOwnershipCapability = "executor-ownership/1" + manualAdmissionCapability = "policy-manual-pack-run/1" ) // This is a private, closed versioned contract; see docs/design/runner-capabilities.md. @@ -20,6 +23,9 @@ func printCapabilities(args []string, stdout io.Writer) error { if len(args) != 2 || args[0] != "--format" || args[1] != "json" { return fmt.Errorf("usage: jpack-runner capabilities --format json") } + if runner.ManualAdmissionAvailable() { + return printManualCapabilities(stdout) + } return json.NewEncoder(stdout).Encode(struct { Version int `json:"version"` Capabilities []string `json:"capabilities"` @@ -27,6 +33,15 @@ func printCapabilities(args []string, stdout io.Writer) error { }{2, []string{jobsCapability, requiredBootCapability, executorOwnershipCapability}, false}) } +func printManualCapabilities(stdout io.Writer) error { + return json.NewEncoder(stdout).Encode(struct { + Version int `json:"version"` + Capabilities []string `json:"capabilities"` + GovernanceAdmission bool `json:"governanceAdmission"` + GovernanceProfiles []string `json:"governanceProfiles"` + }{3, []string{jobsCapability, requiredBootCapability, executorOwnershipCapability, manualAdmissionCapability}, true, []string{runner.ManualAdmissionProfile}}) +} + // checkBootCapabilities runs before runner.Open: even a rejected requirement // must not create state, pin a Runtime, recover a queue, or start workers. func checkBootCapabilities(line []byte) error { @@ -47,6 +62,9 @@ func checkBootCapabilities(line []byte) error { return invalidBoot } for name := range boot { + if name != "executorAdmission" && strings.EqualFold(name, "executorAdmission") { + return fmt.Errorf("invalid manual admission binding") + } // encoding/json accepts case-insensitive struct field names. Do not let // a misspelled requirement disappear or replace a stronger requirement. if name != "executorOwnership" && strings.EqualFold(name, "executorOwnership") { @@ -58,6 +76,9 @@ func checkBootCapabilities(line []byte) error { } raw, required := boot["requiredCapabilities"] if !required { + if _, present := boot["executorAdmission"]; present { + return fmt.Errorf("manual admission requires its boot capability") + } if _, present := boot["executorOwnership"]; present { return fmt.Errorf("executor ownership requires its boot capability") } @@ -82,7 +103,7 @@ func checkBootCapabilities(line []byte) error { return invalidRequirements } seen[capability] = true - if capability != jobsCapability && capability != requiredBootCapability && capability != executorOwnershipCapability { + if capability != jobsCapability && capability != requiredBootCapability && capability != executorOwnershipCapability && capability != manualAdmissionCapability { return fmt.Errorf("unsupported required boot capability") } } @@ -90,6 +111,10 @@ func checkBootCapabilities(line []byte) error { if owned != seen[executorOwnershipCapability] { return fmt.Errorf("executor ownership and its boot capability must agree") } + _, admission := boot["executorAdmission"] + if admission != seen[manualAdmissionCapability] || admission && !owned { + return fmt.Errorf("manual admission and its ownership/capability must agree") + } return nil } diff --git a/cmd/jpack-runner/capabilities_test.go b/cmd/jpack-runner/capabilities_test.go index cddef67..5ba71f4 100644 --- a/cmd/jpack-runner/capabilities_test.go +++ b/cmd/jpack-runner/capabilities_test.go @@ -9,6 +9,8 @@ import ( "reflect" "strings" "testing" + + "github.com/Judgment-Pack/judgment-pack-runner/internal/runner" ) type unreadableBoot struct{} @@ -25,6 +27,11 @@ func TestCapabilitiesCommandDoesNotReadBootOrInitializeRunner(t *testing.T) { var stdout, stderr bytes.Buffer status := runnerCommand([]string{"capabilities", "--format", "json"}, unreadableBoot{}, &stdout, &stderr) want := "{\"version\":2,\"capabilities\":[\"jobs/1\",\"required-boot-capabilities/1\",\"executor-ownership/1\"],\"governanceAdmission\":false}\n" + members := 3 + if runner.ManualAdmissionAvailable() { + want = "{\"version\":3,\"capabilities\":[\"jobs/1\",\"required-boot-capabilities/1\",\"executor-ownership/1\",\"policy-manual-pack-run/1\"],\"governanceAdmission\":true,\"governanceProfiles\":[\"local-admin-manual-pack/1\"]}\n" + members = 4 + } if status != 0 || stdout.String() != want || stderr.Len() != 0 || stdout.Len() > 512 { t.Fatalf("status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String()) } @@ -33,7 +40,7 @@ func TestCapabilitiesCommandDoesNotReadBootOrInitializeRunner(t *testing.T) { } decoder := json.NewDecoder(&stdout) var report map[string]json.RawMessage - if err := decoder.Decode(&report); err != nil || len(report) != 3 { + if err := decoder.Decode(&report); err != nil || len(report) != members { t.Fatalf("not one closed capability object: %v %v", report, err) } if err := decoder.Decode(&report); err != io.EOF { diff --git a/cmd/jpack-runner/disposition_binding_test.go b/cmd/jpack-runner/disposition_binding_test.go index 3529a94..7c17c9b 100644 --- a/cmd/jpack-runner/disposition_binding_test.go +++ b/cmd/jpack-runner/disposition_binding_test.go @@ -146,15 +146,25 @@ func TestVerificationCoverageInHelpAndGuide(t *testing.T) { } } for name, check := range map[string]struct { - typeOf reflect.Type - want []string + typeOf reflect.Type + want []string + private []string }{ - "verification bundle": {reflect.TypeOf(runner.VerificationBundle{}), []string{"version", "releaseDigest", "release", "run", "inputs", "chain"}}, - "run": {reflect.TypeOf(runner.Run{}), []string{"trigger", "schemaVersion", "id", "jobId", "releaseId", "revision", "state", "input", "createdAt", "startedAt", "finishedAt", "requestedBy", "attempt", "result", "audit", "auditBytes", "auditSignatures", "problem"}}, + "verification bundle": {reflect.TypeOf(runner.VerificationBundle{}), []string{"version", "releaseDigest", "release", "run", "inputs", "chain"}, nil}, + "run": {reflect.TypeOf(runner.Run{}), []string{"trigger", "schemaVersion", "id", "jobId", "releaseId", "revision", "state", "input", "createdAt", "startedAt", "finishedAt", "requestedBy", "attempt", "result", "audit", "auditBytes", "auditSignatures", "problem"}, []string{"manual"}}, } { var got []string + var private []string for i := 0; i < check.typeOf.NumField(); i++ { - tag := strings.Split(check.typeOf.Field(i).Tag.Get("json"), ",")[0] + field := check.typeOf.Field(i) + tag := strings.Split(field.Tag.Get("json"), ",")[0] + if !field.IsExported() { + if tag != "-" { + t.Fatalf("%s private field %s lacks explicit JSON exclusion", name, field.Name) + } + private = append(private, field.Name) + continue + } if tag != "-" { got = append(got, tag) } @@ -162,5 +172,8 @@ func TestVerificationCoverageInHelpAndGuide(t *testing.T) { if !reflect.DeepEqual(got, check.want) { t.Fatalf("%s export members changed: got %v; update verification coverage and this list together", name, got) } + if !reflect.DeepEqual(private, check.private) { + t.Fatalf("%s private storage members changed: %v", name, private) + } } } diff --git a/cmd/jpack-runner/main.go b/cmd/jpack-runner/main.go index 39d592f..7a152e7 100644 --- a/cmd/jpack-runner/main.go +++ b/cmd/jpack-runner/main.go @@ -4,6 +4,7 @@ package main import ( "bufio" + "bytes" "context" "encoding/json" "errors" @@ -38,6 +39,12 @@ func runnerCommand(args []string, stdin io.Reader, stdout, stderr io.Writer) int } else { _, err = fmt.Fprintln(stdout, "jpack-runner", buildinfo.Version()) } + case args[0] == "prepare-manual-store": + if len(args) != 1 { + err = fmt.Errorf("prepare-manual-store takes one trusted JSON request on stdin") + } else { + return prepareManualStore(stdin, stdout) + } case args[0] == "relocate-workspace": if len(args) == 2 && args[1] == "--capabilities" { _, err = fmt.Fprintln(stdout, `{"version":1}`) @@ -62,6 +69,19 @@ func runnerCommand(args []string, stdin io.Reader, stdout, stderr io.Writer) int return 0 } +func prepareManualStore(stdin io.Reader, stdout io.Writer) int { + raw, err := io.ReadAll(io.LimitReader(stdin, 16385)) + result := runner.ManualStorePrepareResult{Version: 1, Status: "refused", Code: "invalid-request"} + if err == nil && len(raw) > 1 && len(raw) <= 16384 && raw[len(raw)-1] == '\n' && bytes.Count(raw, []byte{'\n'}) == 1 { + runner.RunManualStorePreparationProcess(raw[:len(raw)-1], stdout) + return 1 // the process-only adapter always exits + } + if json.NewEncoder(stdout).Encode(result) != nil || result.Status != "ok" { + return 1 + } + return 0 +} + // bootConfig reads the boot line: the service's configuration and the // bearer. func bootConfig(line []byte) (runner.Config, string, error) { @@ -89,7 +109,16 @@ func bootConfig(line []byte) (runner.Config, string, error) { if err != nil { return runner.Config{}, "", err } - return runner.Config{ExecutorOwnership: ownership, Dir: boot.Dir, Runtime: boot.Runtime, Workspace: boot.Workspace, Owner: boot.Owner, InputProfiles: boot.InputProfiles, InputRoot: boot.InputRoot, CloudConnections: boot.CloudConnections, GatewayConnections: boot.GatewayConnections, RequireTestedReleases: boot.RequireTested, SigningKey: boot.SigningKey, SigningPublicKey: boot.SigningPublicKey}, boot.Token, nil + var envelope map[string]json.RawMessage + _ = json.Unmarshal(line, &envelope) + var admission *runner.ManualAdmissionBinding + if raw, present := envelope["executorAdmission"]; present { + admission, err = runner.ParseManualAdmissionBinding(raw, ownership, boot.Workspace) + if err != nil { + return runner.Config{}, "", fmt.Errorf("invalid manual admission binding") + } + } + return runner.Config{ExecutorAdmission: admission, ExecutorOwnership: ownership, Dir: boot.Dir, Runtime: boot.Runtime, Workspace: boot.Workspace, Owner: boot.Owner, InputProfiles: boot.InputProfiles, InputRoot: boot.InputRoot, CloudConnections: boot.CloudConnections, GatewayConnections: boot.GatewayConnections, RequireTestedReleases: boot.RequireTested, SigningKey: boot.SigningKey, SigningPublicKey: boot.SigningPublicKey}, boot.Token, nil } func serve(stdin io.Reader, stdout, stderr io.Writer) error { diff --git a/cmd/jpack-runner/manual_admission_test.go b/cmd/jpack-runner/manual_admission_test.go new file mode 100644 index 0000000..c511e96 --- /dev/null +++ b/cmd/jpack-runner/manual_admission_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Judgment-Pack/judgment-pack-runner/internal/runner" +) + +func manualBootFixture(t *testing.T, state string) map[string]any { + t.Helper() + var wire struct { + Synthetic struct { + Boot map[string]any `json:"boot"` + } `json:"synthetic"` + } + raw, err := os.ReadFile("../../docs/design/manual-admission-wire.json") + if err != nil || json.Unmarshal(raw, &wire) != nil { + t.Fatal("wire fixture", err) + } + b := wire.Synthetic.Boot + return map[string]any{"dir": state, "runtime": "/missing-runtime", "workspace": b["runnerWorkspace"], "owner": "owner", "token": strings.Repeat("s", 32), "executorOwnership": map[string]any{"version": 1, "root": "/never-opened", "machineWorkspace": b["machineWorkspace"], "runnerWorkspace": b["runnerWorkspace"], "rootDevice": "1", "rootInode": "1", "fileDevice": "1", "fileInode": "1"}, "requiredCapabilities": map[string]any{"version": 1, "capabilities": []string{"jobs/1", "required-boot-capabilities/1", "executor-ownership/1", "policy-manual-pack-run/1"}}, "executorAdmission": b} +} +func TestManualAdmissionBootSnapshotRefusesUnsafeOwnership(t *testing.T) { + state := filepath.Join(t.TempDir(), "absent") + boot := manualBootFixture(t, state) + line, _ := json.Marshal(boot) + cfg, _, err := bootConfig(line) + if err != nil || cfg.ExecutorAdmission == nil { + t.Fatal("strict snapshot", err) + } + if cfg.ExecutorAdmission.FD != 3 || cfg.ExecutorAdmission.Profile != "local-admin-manual-pack/1" { + t.Fatal("binding changed") + } + boot["executorAdmission"].(map[string]any)["challenge"] = "mutated" + if cfg.ExecutorAdmission.Challenge == "mutated" { + t.Fatal("mutable binding") + } + var stdout, stderr bytes.Buffer + if status := runnerCommand(nil, bytes.NewReader(append(line, '\n')), &stdout, &stderr); status == 0 || stdout.Len() != 0 || stderr.Len() == 0 { + t.Fatalf("unsafe ownership escaped: %d %q %q", status, stdout.String(), stderr.String()) + } + if runner.ManualAdmissionAvailable() && strings.Contains(stderr.String(), "not activated") { + t.Fatal("enabled candidate retained inactive hold") + } + if _, err := os.Lstat(state); !os.IsNotExist(err) { + t.Fatal("unsafe ownership touched state", err) + } +} +func TestManualAdmissionBootClosedShapes(t *testing.T) { + edits := map[string]func(map[string]any){ + "missing requirement": func(b map[string]any) { delete(b, "requiredCapabilities") }, + "missing binding": func(b map[string]any) { delete(b, "executorAdmission") }, + "missing ownership": func(b map[string]any) { delete(b, "executorOwnership") }, + "unknown member": func(b map[string]any) { b["executorAdmission"].(map[string]any)["url"] = "http://never" }, + "case aliased member": func(b map[string]any) { + a := b["executorAdmission"].(map[string]any) + a["Challenge"] = a["challenge"] + delete(a, "challenge") + }, + "case aliased binding": func(b map[string]any) { + b["ExecutorAdmission"] = b["executorAdmission"] + delete(b, "executorAdmission") + }, + "null": func(b map[string]any) { b["executorAdmission"] = nil }, + "null fd": func(b map[string]any) { b["executorAdmission"].(map[string]any)["fd"] = nil }, + "wrong fd": func(b map[string]any) { b["executorAdmission"].(map[string]any)["fd"] = 4 }, + "string fd": func(b map[string]any) { b["executorAdmission"].(map[string]any)["fd"] = "3" }, + "unsupported profile": func(b map[string]any) { b["executorAdmission"].(map[string]any)["profile"] = "other/1" }, + "runner mismatch": func(b map[string]any) { b["executorAdmission"].(map[string]any)["runnerWorkspace"] = "other" }, + "machine mismatch": func(b map[string]any) { + b["executorAdmission"].(map[string]any)["machineWorkspace"] = strings.Repeat("e", 64) + }, + "nonhex executor": func(b map[string]any) { b["executorAdmission"].(map[string]any)["executorId"] = "executor_browser" }, + } + for name, edit := range edits { + t.Run(name, func(t *testing.T) { + state := filepath.Join(t.TempDir(), "absent") + boot := manualBootFixture(t, state) + edit(boot) + line, _ := json.Marshal(boot) + if _, _, err := bootConfig(line); err == nil { + t.Fatal("malformed binding passed parser") + } + var stdout, stderr bytes.Buffer + if runnerCommand(nil, bytes.NewReader(append(line, '\n')), &stdout, &stderr) == 0 || stdout.Len() != 0 { + t.Fatal("malformed binding started") + } + if _, err := os.Stat(state); !os.IsNotExist(err) { + t.Fatal("state created", err) + } + }) + } + state := filepath.Join(t.TempDir(), "absent") + raw, _ := json.Marshal(manualBootFixture(t, state)) + duplicate := bytes.Replace(raw, []byte(`"fd":3`), []byte(`"fd":3,"f\u0064":3`), 1) + if _, _, err := bootConfig(duplicate); err == nil { + t.Fatal("escaped duplicate accepted") + } + fractional := bytes.Replace(raw, []byte(`"fd":3`), []byte(`"fd":3.0`), 1) + if _, _, err := bootConfig(fractional); err == nil { + t.Fatal("fractional fd accepted") + } +} diff --git a/cmd/jpack-runner/manual_store_prepare_test.go b/cmd/jpack-runner/manual_store_prepare_test.go new file mode 100644 index 0000000..3a9d211 --- /dev/null +++ b/cmd/jpack-runner/manual_store_prepare_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "strings" + "testing" +) + +func TestManualStorePrepareCLIRefusesMalformedFrames(t *testing.T) { + for _, raw := range []string{"", "{}", "{}\n{}\n", "{\n}\n", "{}\n ", strings.Repeat("x", 16384) + "\n", "{\"version\":1,\"Version\":1}\n"} { + var out, diagnostic bytes.Buffer + cmd := exec.Command(os.Args[0], "-test.run=^TestManualStorePrepareCommandHelper$") + cmd.Env = append(os.Environ(), "JPACK_MANUAL_CLI_PREPARE_CHILD=1") + cmd.Stdin, cmd.Stdout, cmd.Stderr = strings.NewReader(raw), &out, &diagnostic + if err := cmd.Run(); err == nil { + t.Fatal("malformed helper frame accepted") + } + var refusal map[string]any + if json.Unmarshal(out.Bytes(), &refusal) != nil || len(refusal) != 3 || refusal["status"] != "refused" || refusal["code"] != "invalid-request" { + t.Fatalf("closed refusal %s", out.Bytes()) + } + } +} + +func TestManualAdmissionClosedV3Codec(t *testing.T) { + var out bytes.Buffer + if err := printManualCapabilities(&out); err != nil { + t.Fatal(err) + } + want := `{"version":3,"capabilities":["jobs/1","required-boot-capabilities/1","executor-ownership/1","policy-manual-pack-run/1"],"governanceAdmission":true,"governanceProfiles":["local-admin-manual-pack/1"]}` + "\n" + if out.String() != want { + t.Fatalf("closed v3 %s", out.String()) + } +} + +// The real helper entry terminates its dedicated process, including refusals. +func TestManualStorePrepareCommandHelper(t *testing.T) { + if os.Getenv("JPACK_MANUAL_CLI_PREPARE_CHILD") == "1" { + os.Exit(runnerCommand([]string{"prepare-manual-store"}, os.Stdin, os.Stdout, os.Stderr)) + } +} diff --git a/docs/design/manual-admission-gate.md b/docs/design/manual-admission-gate.md new file mode 100644 index 0000000..6de4149 --- /dev/null +++ b/docs/design/manual-admission-gate.md @@ -0,0 +1,81 @@ +# Private manual admission gate + +This is an experimental host contract, not adopted Policy Spec conformance or +portable authority. The sole profile is `local-admin-manual-pack/1`. Runner's +coordinator has enabled the reviewed uniform candidate for isolated +Desk/Runner/Runtime validation. Supported hosts report closed v3 with +governanceAdmission true scoped solely to this profile. Explicit trusted +enrollment, matching demands and fresh authority admission remain required; +the capability response grants no permission by itself. There is no environment +switch or alternate test capability protocol. Release/live activation remains +outside this candidate validation. + +Desk retains execution demand independently of portable Runner data. Its fixed +`executors/policy-execution-v1/` leaf is a sibling of the +workspace's pinned ownership directory. Runner validates the bounded private +directory and closed demand, including when admission configuration is omitted. +An empty/malformed directory holds; valid other-workspace demand preserves the +current ownership-only workspace. Current demand requires matching boot identity. + +`prepare-manual-store` is a nonexecuting offline helper with closed stdin/stdout +and an intentional duplicate of Desk's continuously held external lease at fd3. +It never starts Open, Runtime, providers, recovery or a worker. See the adjacent +[wire fixture](manual-admission-store-prepare-wire.json) and +[shape](manual-admission-store-prepare-shape.md). It refuses all triggers, old +queued/running work and pending acquisition before publishing Runner schema4. +Legacy workspaces retain schema3. Its durable marker precedes SQLite publication; +recovered or partial preparation cannot automatically advance Desk enable. + +Helper lock/marker/sync use the held state root. The dedicated helper process +retains that directory as cwd until exit and selects a private, nondefault clone +of SQLite's native VFS. Only `xFullPathname` changes: it accepts and preserves the +exact relative main filename `runs.sqlite`. Native open/delete/access, locking, +sync, journal and WAL/SHM callbacks retain relative paths under that cwd. Temp +storage stays in memory; unrelated database filenames refuse. The normal default +VFS is unchanged. Open and library callers never change cwd; the library adapter +uses a held descriptor path on Linux and refuses hosts without that path. + +Configured placement and database inode are rechecked around connection opening +and durable boundaries. Subprocess regressions replace the named state after +adapter setup, observe actual journal/WAL/SHM in the retained original, and inspect +both stores after preparation refuses. These checks do not promise atomicity +against a hostile same-OS-user actor replacing database inodes between checks or +rearranging the entire trusted plane. Detected replacement holds; no normal start +repairs a marker/schema mismatch. Desk accepts only an original fresh committed +helper response, never recovered observation. + +Governed startup validates the immutable boot and store demands, acquires actual +external ownership, owns/closes the inherited authority descriptor, fixes actual +signing, then activates the authenticated challenge before Runner recovery or +workers. No stopped configured service falls back to unowned execution. Ordinary +enabled Open refuses old schemas rather than upgrading restored queues. + +The owner-only execution-binding export describes exact canonical Release bytes. +Desk's anchored authority owns registration, current eligibility, trusted intent, +logical-operation replay barriers and attempts. A private HTTP selector alone +grants nothing. Runner retains exact original request/facts/evidence bytes and +the selected resource/registration. Reserve binds those bytes and the existing +execution-payload descriptor; SQLite retains its exact attempt before commitStart. + +Before commitStart, Runner verifies actual Pack/config/lock/facts/evidence, +Runtime, signing, current job/release tuple and external custody. Only the real +channel's original successful fresh reply can supply its sealed session/epoch +proof. Successful local start journaling precedes minting one in-memory launch. +The common operational starter consumes it, repeats actual-material/current-tuple +verification and delegates to the ownership-aware lease starter. Fabricated or +copied receipt DTOs, recovered history/reserve, lost replies, journal failures, +stale sessions/epochs, changed files and post-Close calls cannot launch or replay. + +The current common starter explicitly allows only fixed utility command shapes +for validation, lock, saved checks, version/audit verification and rehearsal; +utilities retain ownership and do not become operational permits. Operational +evaluate, including alternate/private workflow callers, requires the original +one-use launch. Enabled workspaces globally refuse triggers, acquisitions, +SourceInput/Preparation/mapping, profile installation and upgrades before activity. +There is no mixed legacy-resource escape within an enabled workspace. + +Fresh committed logical start is irrevocable: later withdrawal may precede its +physical spawn. Withdrawal before the anchored commitment blocks. This is not +an atomic ledger/OS transaction; failure or uncertainty after commitment remains +history without automatic replay. Exact ordering and host lifecycle behavior +require the combined real Desk/Runner/Runtime fixture before usable activation. diff --git a/docs/design/manual-admission-phase-a.md b/docs/design/manual-admission-phase-a.md new file mode 100644 index 0000000..da791e9 --- /dev/null +++ b/docs/design/manual-admission-phase-a.md @@ -0,0 +1,65 @@ +# Private manual admission: historical Phase A seams + +Historical snapshot, 2026-10-19. The statements below describe the inactive +Phase A implementation. The completed candidate is described in +[manual admission gate](manual-admission-gate.md) and +[current capabilities](runner-capabilities.md); the Phase A hold below is no +longer the current supported-host capability/Open behavior. + +This is private host machinery, with no adopted portable Policy conformance claim. +Production activation remains disabled. The capability probe is still the exact +closed v2 response with `governanceAdmission:false`; `Open` refuses every supplied +`executorAdmission` binding before state creation, ownership acquisition, recovery +or workers. Validating a staged binding is not capability support or permission. +No registration mutation route or usable execution-enrollment command is added. +Unconfigured Jobs retain their behavior. + +The agreed [wire schemas and synthetic vectors](manual-admission-wire.json) and +[shape rules](manual-admission-wire-shape.md) bind a dedicated inherited Unix stream +socket at fd3. The inactive client snapshots trusted binding fields, requires one +fresh activation first, serializes exact sequence strings, enforces 64KiB frames +and five-second exchanges, and permanently closes on cancellation or protocol +fault. It never retries a mutation. Full request digests and result semantics are +checked; a recovered reservation cannot progress to commitStart. History carries +its original decision digest and grants nothing. Domain refusals are bounded codes +and do not imply that no durable mutation happened. + +The host must create both raw socketpair ends atomically close-on-exec or retain syscall.ForkLock.RLock across creation and setting both flags and pass +only the designated child end. Runner marks the explicitly inherited fd, duplicates +it through `net.FileConn`, and closes that original. A process fixture verifies +that an actual worker inherits the ownership file and no authority socket. This +fixture proves descriptor plumbing; Phase C still requires real Desk authority +and real Runtime admission/order/recovery fixtures. + +`GET /v1/jobs/{job}/execution-binding` is protected by the existing owner bearer +and origin guards. Its closed v1 export contains both the exact canonical typed +Release preimage (base64) and its digest, plus workspace/job/revision/release tuple. +A single SQLite snapshot reads the selected job/release and all triggers. Any +trigger, mapping, dialect, source/preparation or mismatched retained Pack/Runtime +material refuses. Exporting material creates no registration or operation. + +Private manual ingress captures only facts and optional evidence from the original +bounded request body. `X-Jpack-Manual-Intent` is a host-prepared selector, never +proof of authority; an unconfigured Runner rejects it. No caller/operation identity +is accepted in the body. Dormant exact-byte storage retains request/facts/evidence +as base64, with original Idempotency-Key and evidence presence. Restore verifies +those bindings before restoring the exact bytes used by the payload codec and +Runtime files. This metadata is excluded from public Run/verification exports; +legacy serialized runs keep their existing encoding. A restored manual entry +cannot dispatch through an unconfigured Service. + +Private one-use launch types accept only a matching fresh start-committed receipt, +require successful local persistence, and consume before starting the exact +prepared executable/arguments/environment through the existing executor lease. +They remain unwired. Phase C must first verify all actual input/config/lock files +against the frozen payload and use this object at the common operational starter. +The current defensive manual mode blocks direct operational evaluation, private +workflow acquisition/evaluation, automatic submit and upgrades. + +Remaining integration: explicit anchored execution enrollment and older-binary +schema refusal; Desk local-admin intent preparation; actual host socket lifecycle; +post-ownership epoch activation before workers; authoritative registration lookup; +durable one-attempt preparation and start journal; complete automatic-trigger and +provider-route refusal; the non-bypassable operational starter; real ordering and +restore/recovery fixtures. No existing field, successful export or retained receipt +can substitute for those steps. Keep the current capability false until they pass. diff --git a/docs/design/manual-admission-store-prepare-shape.md b/docs/design/manual-admission-store-prepare-shape.md new file mode 100644 index 0000000..62338d4 --- /dev/null +++ b/docs/design/manual-admission-store-prepare-shape.md @@ -0,0 +1,15 @@ +# Private nonexecuting manual-store preparation v1 + +Frozen jointly with Desk/coordinator for implementation. `jpack-runner prepare-manual-store` accepts no arguments, exactly one bounded UTF-8 JSON+LF request on stdin (≤16KiB including LF), then EOF. Closed schemas and synthetic vectors: `/tmp/jps-policy-manual-admission-store-prepare-wire.json`. Exact casing, no duplicates/unknown/null/type coercion; canonical uint64 decimal strings must not overflow. Paths are existing clean absolute private real directories with disjoint state/external custody. No secret boot, authority socket, listener, provider, Runtime, recovery or worker. + +Request: version1, stable random bare64hex prepareId, dir, Runner workspace, existing owner, leaseFd3, existing executorOwnership v1, executionDemand v1. Demand is exactly version1, installationId, machineWorkspace, runnerWorkspace, executorId, profile local-admin-manual-pack/1. Repeated identities must agree with current state, retained external custody and Desk pending intent. Device/inode IDs remain strings. + +Desk holds machine-use and the original external EX lease continuously across helper creation, processing and exit, passing an inherited duplicate at fd3. Runner marks fd3 CLOEXEC, verifies pinned private root/state/file identity and the shared held flock, and takes its instance lock. It never explicitly unlocks the inherited open description. No release/reacquire window or general new authority transport. + +Before any local marker/schema write, validate external demand at `filepath.Join(filepath.Dir(ownership.Root), "policy-execution-v1", machineWorkspace)`: a sibling of per-workspace executor directories. Leaf has no extension. The fixed directory is private, real, nonempty and bounded; its regular single-link private files are closed exact demands named by machineWorkspace. Runner validates all entries and its current demand. Current demand without matching admission boot refuses before recovery/Runtime/providers. Valid demands for other workspaces preserve ownership-only behavior for an unenabled current workspace; missing current demand with admission boot refuses. Desk independently verifies full bidirectional registry/demand closure. + +Only explicit initial preparation of untouched schema3 may publish schema4. Reject all triggers (even paused), queued/running runs, pending acquisition/occurrences and malformed/unrecognized stores before completing migration. A private immutable marker, its exact request digest and execution demand must be durable with parent syncing BEFORE publishing schema4+journal demand in one SQLite FULL transaction. Ordinary Open never migrates enabled restored stores. schema4 without matching boot/demand refuses. + +Exit0/one closed success line: `{version:1,status:"ok",prepareId,requestDigest,delivery:"committed"|"recovered",storeSchema:"4"}`. requestDigest is sha256: of exact request JSON UTF-8 bytes excluding only its terminating LF. Only fresh committed permits Desk's next activation stage. Exact already-schema4 plus matching marker may report recovered observation, which NEVER advances Desk activation. Prior marker+schema3, partial/mismatched state or replay cannot repair/migrate automatically. + +Nonzero/one closed refusal line: `{version:1,status:"refused",code}`. Finite codes: invalid-request, unsafe-custody, busy, unsupported-store, pending-transition, unsupported-profile, storage-failed. A refusal or lost/truncated reply can follow partial durable work; it does not prove absence or authorize automatic retry. Explicit reviewed reconciliation remains separate. Legacy workspace/store behavior is unchanged outside these demands. A manually stripped complete trusted plane plus old binary remains outside the guarantee. diff --git a/docs/design/manual-admission-store-prepare-wire.json b/docs/design/manual-admission-store-prepare-wire.json new file mode 100644 index 0000000..adf7832 --- /dev/null +++ b/docs/design/manual-admission-store-prepare-wire.json @@ -0,0 +1,302 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Private manual-store preparation helper v1", + "command": [ + "jpack-runner", + "prepare-manual-store" + ], + "maxFrameBytes": 16384, + "$defs": { + "ownership": { + "type": "object", + "properties": { + "version": { + "const": 1 + }, + "root": { + "type": "string", + "minLength": 2, + "maxLength": 4096 + }, + "machineWorkspace": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "runnerWorkspace": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[!-~]+$" + }, + "rootDevice": { + "type": "string", + "pattern": "^(0|[1-9][0-9]{0,19})$" + }, + "rootInode": { + "type": "string", + "pattern": "^[1-9][0-9]{0,19}$" + }, + "fileDevice": { + "type": "string", + "pattern": "^(0|[1-9][0-9]{0,19})$" + }, + "fileInode": { + "type": "string", + "pattern": "^[1-9][0-9]{0,19}$" + } + }, + "required": [ + "version", + "root", + "machineWorkspace", + "runnerWorkspace", + "rootDevice", + "rootInode", + "fileDevice", + "fileInode" + ], + "additionalProperties": false + }, + "demand": { + "type": "object", + "properties": { + "version": { + "const": 1 + }, + "installationId": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "machineWorkspace": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "runnerWorkspace": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[!-~]+$" + }, + "executorId": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "profile": { + "const": "local-admin-manual-pack/1" + } + }, + "required": [ + "version", + "installationId", + "machineWorkspace", + "runnerWorkspace", + "executorId", + "profile" + ], + "additionalProperties": false + }, + "request": { + "type": "object", + "properties": { + "version": { + "const": 1 + }, + "prepareId": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "dir": { + "type": "string", + "minLength": 2, + "maxLength": 4096 + }, + "workspace": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[!-~]+$" + }, + "owner": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "leaseFd": { + "const": 3 + }, + "executorOwnership": { + "$ref": "#/$defs/ownership" + }, + "executionDemand": { + "$ref": "#/$defs/demand" + } + }, + "required": [ + "version", + "prepareId", + "dir", + "workspace", + "owner", + "leaseFd", + "executorOwnership", + "executionDemand" + ], + "additionalProperties": false + }, + "ok": { + "type": "object", + "properties": { + "version": { + "const": 1 + }, + "status": { + "const": "ok" + }, + "prepareId": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "requestDigest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "delivery": { + "enum": [ + "committed", + "recovered" + ] + }, + "storeSchema": { + "const": "4" + } + }, + "required": [ + "version", + "status", + "prepareId", + "requestDigest", + "delivery", + "storeSchema" + ], + "additionalProperties": false + }, + "refused": { + "type": "object", + "properties": { + "version": { + "const": 1 + }, + "status": { + "const": "refused" + }, + "code": { + "enum": [ + "invalid-request", + "unsafe-custody", + "busy", + "unsupported-store", + "pending-transition", + "unsupported-profile", + "storage-failed" + ] + } + }, + "required": [ + "version", + "status", + "code" + ], + "additionalProperties": false + } + }, + "vectors": { + "request": { + "version": 1, + "prepareId": "1111111111111111111111111111111111111111111111111111111111111111", + "dir": "/tmp/jps-synthetic-runner", + "workspace": "runner-synthetic", + "owner": "local-admin", + "leaseFd": 3, + "executorOwnership": { + "version": 1, + "root": "/tmp/jps-synthetic-machine/executors/2222222222222222222222222222222222222222222222222222222222222222", + "machineWorkspace": "2222222222222222222222222222222222222222222222222222222222222222", + "runnerWorkspace": "runner-synthetic", + "rootDevice": "2049", + "rootInode": "9007199254740993", + "fileDevice": "2049", + "fileInode": "9007199254740994" + }, + "executionDemand": { + "version": 1, + "installationId": "3333333333333333333333333333333333333333333333333333333333333333", + "machineWorkspace": "2222222222222222222222222222222222222222222222222222222222222222", + "runnerWorkspace": "runner-synthetic", + "executorId": "4444444444444444444444444444444444444444444444444444444444444444", + "profile": "local-admin-manual-pack/1" + } + }, + "requestLine": "{\"version\":1,\"prepareId\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"dir\":\"/tmp/jps-synthetic-runner\",\"workspace\":\"runner-synthetic\",\"owner\":\"local-admin\",\"leaseFd\":3,\"executorOwnership\":{\"version\":1,\"root\":\"/tmp/jps-synthetic-machine/executors/2222222222222222222222222222222222222222222222222222222222222222\",\"machineWorkspace\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"runnerWorkspace\":\"runner-synthetic\",\"rootDevice\":\"2049\",\"rootInode\":\"9007199254740993\",\"fileDevice\":\"2049\",\"fileInode\":\"9007199254740994\"},\"executionDemand\":{\"version\":1,\"installationId\":\"3333333333333333333333333333333333333333333333333333333333333333\",\"machineWorkspace\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"runnerWorkspace\":\"runner-synthetic\",\"executorId\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"profile\":\"local-admin-manual-pack/1\"}}\n", + "requestDigest": "sha256:34a658531e86680c890bcfbb939e7a77a37309090ca8d55e29e476b43a69479a", + "committed": { + "version": 1, + "status": "ok", + "prepareId": "1111111111111111111111111111111111111111111111111111111111111111", + "requestDigest": "sha256:34a658531e86680c890bcfbb939e7a77a37309090ca8d55e29e476b43a69479a", + "delivery": "committed", + "storeSchema": "4" + }, + "recovered": { + "version": 1, + "status": "ok", + "prepareId": "1111111111111111111111111111111111111111111111111111111111111111", + "requestDigest": "sha256:34a658531e86680c890bcfbb939e7a77a37309090ca8d55e29e476b43a69479a", + "delivery": "recovered", + "storeSchema": "4" + }, + "refused": { + "version": 1, + "status": "refused", + "code": "pending-transition" + } + }, + "semanticInvalid": [ + { + "name": "uint64-overflow", + "path": "executorOwnership.rootInode", + "value": "18446744073709551616" + }, + { + "name": "workspace-mismatch", + "path": "executionDemand.runnerWorkspace", + "value": "other" + }, + { + "name": "wrong-fd", + "path": "leaseFd", + "value": 4 + }, + { + "name": "wrong-profile", + "path": "executionDemand.profile", + "value": "legacy" + }, + { + "name": "noncanonical-identity", + "path": "executorOwnership.fileDevice", + "value": "01" + } + ], + "rules": [ + "uint64 values must not overflow despite the bounded schema regex", + "all repeated workspace identities must agree", + "absolute clean real private paths; ownership root and state may not overlap", + "fixed inherited fd3 shares Desk continuously held exclusive flock; never LOCK_UN", + "only exact untouched schema3 with no old marker or unsupported pending work may become schema4", + "private marker and parent fsync precede schema4 publication", + "exact existing schema4 plus matching marker may report recovered observation; cannot advance host activation", + "schema3 with prior marker, partial or mismatched state refuses without repair", + "requestDigest hashes exact request JSON UTF8 bytes excluding the sole terminating LF", + "closed duplicate/case/null/unknown/type rules and EOF after the one line apply" + ] +} diff --git a/docs/design/manual-admission-wire-shape.md b/docs/design/manual-admission-wire-shape.md new file mode 100644 index 0000000..67c18a1 --- /dev/null +++ b/docs/design/manual-admission-wire-shape.md @@ -0,0 +1,21 @@ +# Private manual admission wire draft 1 + +Exact schemas and synthetic (non-executable) vectors are in `jps-policy-manual-admission-wire.json`. `$defs` are closed: exact case-sensitive members, no duplicates, unknown fields, nulls or coercion. All counters are canonical positive uint64 decimal strings. Version is integer literal1. NDJSON frames are valid UTF-8, ≤65,536 bytes including LF, one request in flight, 5seconds/exchange, no retries; cancellation/deadline/EOF/protocol fault permanently closes channel. Reply method/sequence must exactly echo outstanding request; extra/preplayed replies refuse. + +`executorAdmission` boot exactly: `{version,fd:3,installationId,machineWorkspace,runnerWorkspace,executorId,challenge,profile:"local-admin-manual-pack/1"}`. Installation/machine/executor/challenge are bare64 lowercase hex; Runner workspace is bounded visibleASCII and exactly matches both boot.workspace and ownership.runnerWorkspace. The host creates BOTH raw socketpair ends atomically close-on-exec (or holds syscall.ForkLock.RLock across creation and setting both flags), passes only the designated child socket via ExtraFiles, and closes originals; otherwise an unrelated original fd can bypass the negotiated slot. Slot3 is the sole authority socket descriptor; Runner marks it close-on-exec, duplicates through net.FileConn, and closes the supplied original before any Runtime invocation. Runtime receives ONLY separately appended ownership descriptor. Require matching executor-ownership/1 and policy-manual-pack-run/1. Historical Phase A refused this profile and reported closed v2/false; the completed supported-host candidate reports closed v3 with the sole governance profile local-admin-manual-pack/1. + +Request `{version:1,sequence,method,body}`. Reply `{version:1,sequence,method,status:"ok"|"refused",body}`. A refusal body is only `{code}` from finite fixture enum; it conveys no retry permission or nonmutation guarantee. Method bodies/results follow fixture exactly: + +- `activate`: challenge+actual signing mode/public key → delivery committed/recovered, durable epoch, requestDigest. Only original committed activation proceeds. Host retained context supplies workspace/executor identity. +- `inspect`: exact resource tuple → observed registration revision/digest, current eligible/reason/time. Unknown resource refuses. Observation never permits launch. +- `reserve`: intentId+rawRequestDigest+resource+registration+full descriptor payload → committed/recovered operationId/attemptId/epoch/requestDigest. Failed eligibility can durably seal the one-attempt barrier before refusal. One attempt per original intent. +- `commitStart`: complete reserve body+operationId+attemptId+epoch → committed/recovered decision(start-committed/blocked), identities/requestDigest/reason/time/checked authorityRevision. Blocked is durable; only fresh committed+start-committed can later mint a one-use launch object after local persistence. +- `history`: intentId → observed intent/operation/attempt/epoch/state/requestDigest/reason/time. This diagnoses a lost reserve even before the caller knows operationId. Never grants a launch. + +Resource tuple: `{jobId,jobRevision,releaseId,releaseProfile:"judgment-pack-runner/canonical/1",releaseDigest}`. Registration `{revision,digest}`. Payload `{profile:"judgment-pack-runner/execution-payload/1",digest,bytes}`; bytes is canonical base64 of exact canonical descriptor≤4KiB, verified against digest/profile and complete trusted intent/registration/signing bindings. RequestDigest hashes canonical `{method,body}`, excluding sequence/version; safe typed string bodies share Runner canonical/1 and Desk safe canonical encoding. authorityRevision is the bare hash of the exact checked precommit Policy ledger (avoids receipt/hash recursion). All other digest fields are sha256:-prefixed. + +Owner HTTP export `GET /v1/jobs/{job}/execution-binding`: `{version:1,workspace,jobId,jobRevision,releaseId,releaseProfile,releaseDigest,releaseBytes}`. Exact existing canonical typed Release preimage is base64≤8MiB; profile restrictions and absence of every trigger enforced. New endpoint grants no authority. + +`rawRequestDigest` in reserve and commitStart is SHA256 of the exact original manual HTTP body. It is compared against the host-prepared intent, independently of facts/evidence descriptor digests, and is excluded from the stable logical-operation key. Even an outer-whitespace change with the same original Idempotency-Key conflicts. + +Private host ingress header `X-Jpack-Manual-Intent` is stripped/rejected at browser proxy; it is forwarded only by Desk after anchored local-admin intent preparation. Runner preserves exact original body bytes and facts/evidence preimages; no body caller/operation fields grant authority. Historical Phase A types/transports were inactive; the completed candidate requires the full registration, reserve and one-use operational gate. diff --git a/docs/design/manual-admission-wire.json b/docs/design/manual-admission-wire.json new file mode 100644 index 0000000..228ad46 --- /dev/null +++ b/docs/design/manual-admission-wire.json @@ -0,0 +1,1444 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Private local-admin-manual-pack/1 authority wire, draft1", + "protocol": { + "version": 1, + "bootMember": "executorAdmission", + "descriptorSlot": 3, + "frameBytes": 65536, + "frameIncludesNewline": true, + "timeoutSeconds": 5, + "oneInFlight": true, + "requestDigest": "sha256 of judgment-pack-runner/canonical/1({method,body}); no version/sequence", + "authorityRevision": "bare SHA256 of exact checked precommit Policy ledger; no self-reference", + "manualIntentHeader": "X-Jpack-Manual-Intent" + }, + "$defs": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,128}$" + }, + "hex": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "positive": { + "type": "string", + "pattern": "^[1-9][0-9]{0,19}$", + "description": "Canonical uint64 in 1..18446744073709551615; no wrap." + }, + "time": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" + }, + "workspace": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[!-~]+$" + }, + "signing": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "mode" + ], + "properties": { + "mode": { + "const": "off" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "mode", + "publicKey" + ], + "properties": { + "mode": { + "const": "ed25519" + }, + "publicKey": { + "$ref": "#/$defs/hex" + } + } + } + ] + }, + "resource": { + "type": "object", + "additionalProperties": false, + "required": [ + "jobId", + "jobRevision", + "releaseId", + "releaseProfile", + "releaseDigest" + ], + "properties": { + "jobId": { + "$ref": "#/$defs/id" + }, + "jobRevision": { + "$ref": "#/$defs/positive" + }, + "releaseId": { + "$ref": "#/$defs/id" + }, + "releaseProfile": { + "const": "judgment-pack-runner/canonical/1" + }, + "releaseDigest": { + "$ref": "#/$defs/digest" + } + } + }, + "registration": { + "type": "object", + "additionalProperties": false, + "required": [ + "revision", + "digest" + ], + "properties": { + "revision": { + "$ref": "#/$defs/positive" + }, + "digest": { + "$ref": "#/$defs/digest" + } + } + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "profile", + "digest", + "bytes" + ], + "properties": { + "profile": { + "const": "judgment-pack-runner/execution-payload/1" + }, + "digest": { + "$ref": "#/$defs/digest" + }, + "bytes": { + "type": "string", + "contentEncoding": "base64", + "minLength": 4, + "maxLength": 5464 + } + } + }, + "delivery": { + "enum": [ + "committed", + "recovered" + ] + }, + "reason": { + "enum": [ + "eligible", + "registered", + "reserved", + "start-committed", + "resource-unregistered", + "resource-changed", + "unsupported-profile", + "intent-unavailable", + "binding-conflict", + "operation-held", + "withdrawn", + "not-effective", + "expired", + "superseded", + "adoption-changes-requested", + "dependency-unavailable", + "authority-unavailable", + "clock-unavailable", + "capacity" + ] + }, + "refusal": { + "type": "object", + "additionalProperties": false, + "required": [ + "code" + ], + "properties": { + "code": { + "enum": [ + "authority-unavailable", + "invalid-request", + "unauthorized", + "stale-epoch", + "resource-unregistered", + "resource-changed", + "unsupported-profile", + "intent-unavailable", + "binding-conflict", + "operation-held", + "eligibility-blocked", + "capacity", + "clock-unavailable", + "history-unavailable" + ] + } + } + }, + "boot": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "fd", + "installationId", + "machineWorkspace", + "runnerWorkspace", + "executorId", + "challenge", + "profile" + ], + "properties": { + "version": { + "const": 1 + }, + "fd": { + "const": 3 + }, + "installationId": { + "$ref": "#/$defs/hex" + }, + "machineWorkspace": { + "$ref": "#/$defs/hex" + }, + "runnerWorkspace": { + "$ref": "#/$defs/workspace" + }, + "executorId": { + "$ref": "#/$defs/hex" + }, + "challenge": { + "$ref": "#/$defs/hex" + }, + "profile": { + "const": "local-admin-manual-pack/1" + } + } + }, + "executionBinding": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "workspace", + "jobId", + "jobRevision", + "releaseId", + "releaseProfile", + "releaseDigest", + "releaseBytes" + ], + "properties": { + "version": { + "const": 1 + }, + "workspace": { + "$ref": "#/$defs/workspace" + }, + "jobId": { + "$ref": "#/$defs/id" + }, + "jobRevision": { + "$ref": "#/$defs/positive" + }, + "releaseId": { + "$ref": "#/$defs/id" + }, + "releaseProfile": { + "const": "judgment-pack-runner/canonical/1" + }, + "releaseDigest": { + "$ref": "#/$defs/digest" + }, + "releaseBytes": { + "type": "string", + "contentEncoding": "base64", + "minLength": 4, + "maxLength": 11184812 + } + } + }, + "activateRequestBody": { + "type": "object", + "additionalProperties": false, + "required": [ + "challenge", + "signing" + ], + "properties": { + "challenge": { + "$ref": "#/$defs/hex" + }, + "signing": { + "$ref": "#/$defs/signing" + } + } + }, + "inspectRequestBody": { + "type": "object", + "additionalProperties": false, + "required": [ + "resource" + ], + "properties": { + "resource": { + "$ref": "#/$defs/resource" + } + } + }, + "reserveRequestBody": { + "type": "object", + "additionalProperties": false, + "required": [ + "intentId", + "rawRequestDigest", + "resource", + "registration", + "payload" + ], + "properties": { + "intentId": { + "$ref": "#/$defs/id" + }, + "resource": { + "$ref": "#/$defs/resource" + }, + "registration": { + "$ref": "#/$defs/registration" + }, + "payload": { + "$ref": "#/$defs/payload" + }, + "rawRequestDigest": { + "$ref": "#/$defs/digest" + } + } + }, + "commitStartRequestBody": { + "type": "object", + "additionalProperties": false, + "required": [ + "intentId", + "rawRequestDigest", + "resource", + "registration", + "payload", + "operationId", + "attemptId", + "epoch" + ], + "properties": { + "intentId": { + "$ref": "#/$defs/id" + }, + "resource": { + "$ref": "#/$defs/resource" + }, + "registration": { + "$ref": "#/$defs/registration" + }, + "payload": { + "$ref": "#/$defs/payload" + }, + "operationId": { + "$ref": "#/$defs/id" + }, + "attemptId": { + "$ref": "#/$defs/id" + }, + "epoch": { + "$ref": "#/$defs/positive" + }, + "rawRequestDigest": { + "$ref": "#/$defs/digest" + } + } + }, + "historyRequestBody": { + "type": "object", + "additionalProperties": false, + "required": [ + "intentId" + ], + "properties": { + "intentId": { + "$ref": "#/$defs/id" + } + } + }, + "activateResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "delivery", + "epoch", + "requestDigest" + ], + "properties": { + "delivery": { + "$ref": "#/$defs/delivery" + }, + "epoch": { + "$ref": "#/$defs/positive" + }, + "requestDigest": { + "$ref": "#/$defs/digest" + } + } + }, + "inspectResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "delivery", + "registration", + "eligible", + "reason", + "at" + ], + "properties": { + "delivery": { + "const": "observed" + }, + "registration": { + "$ref": "#/$defs/registration" + }, + "eligible": { + "type": "boolean" + }, + "reason": { + "$ref": "#/$defs/reason" + }, + "at": { + "$ref": "#/$defs/time" + } + } + }, + "reserveResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "delivery", + "operationId", + "attemptId", + "epoch", + "requestDigest" + ], + "properties": { + "delivery": { + "$ref": "#/$defs/delivery" + }, + "operationId": { + "$ref": "#/$defs/id" + }, + "attemptId": { + "$ref": "#/$defs/id" + }, + "epoch": { + "$ref": "#/$defs/positive" + }, + "requestDigest": { + "$ref": "#/$defs/digest" + } + } + }, + "commitStartResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "delivery", + "operationId", + "attemptId", + "epoch", + "requestDigest", + "decision", + "reason", + "at", + "authorityRevision" + ], + "properties": { + "delivery": { + "$ref": "#/$defs/delivery" + }, + "operationId": { + "$ref": "#/$defs/id" + }, + "attemptId": { + "$ref": "#/$defs/id" + }, + "epoch": { + "$ref": "#/$defs/positive" + }, + "requestDigest": { + "$ref": "#/$defs/digest" + }, + "decision": { + "enum": [ + "start-committed", + "blocked" + ] + }, + "reason": { + "$ref": "#/$defs/reason" + }, + "at": { + "$ref": "#/$defs/time" + }, + "authorityRevision": { + "$ref": "#/$defs/hex" + } + } + }, + "historyResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "delivery", + "intentId", + "operationId", + "attemptId", + "epoch", + "state", + "requestDigest", + "reason", + "at" + ], + "properties": { + "delivery": { + "const": "observed" + }, + "intentId": { + "$ref": "#/$defs/id" + }, + "operationId": { + "$ref": "#/$defs/id" + }, + "attemptId": { + "$ref": "#/$defs/id" + }, + "epoch": { + "$ref": "#/$defs/positive" + }, + "state": { + "enum": [ + "reserved", + "start-committed", + "blocked" + ] + }, + "requestDigest": { + "$ref": "#/$defs/digest" + }, + "reason": { + "$ref": "#/$defs/reason" + }, + "at": { + "$ref": "#/$defs/time" + } + } + }, + "request": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "sequence", + "method", + "body" + ], + "properties": { + "version": { + "const": 1 + }, + "sequence": { + "$ref": "#/$defs/positive" + }, + "method": { + "const": "activate" + }, + "body": { + "$ref": "#/$defs/activateRequestBody" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "sequence", + "method", + "body" + ], + "properties": { + "version": { + "const": 1 + }, + "sequence": { + "$ref": "#/$defs/positive" + }, + "method": { + "const": "inspect" + }, + "body": { + "$ref": "#/$defs/inspectRequestBody" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "sequence", + "method", + "body" + ], + "properties": { + "version": { + "const": 1 + }, + "sequence": { + "$ref": "#/$defs/positive" + }, + "method": { + "const": "reserve" + }, + "body": { + "$ref": "#/$defs/reserveRequestBody" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "sequence", + "method", + "body" + ], + "properties": { + "version": { + "const": 1 + }, + "sequence": { + "$ref": "#/$defs/positive" + }, + "method": { + "const": "commitStart" + }, + "body": { + "$ref": "#/$defs/commitStartRequestBody" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "sequence", + "method", + "body" + ], + "properties": { + "version": { + "const": 1 + }, + "sequence": { + "$ref": "#/$defs/positive" + }, + "method": { + "const": "history" + }, + "body": { + "$ref": "#/$defs/historyRequestBody" + } + } + } + ] + }, + "reply": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "sequence", + "method", + "status", + "body" + ], + "properties": { + "version": { + "const": 1 + }, + "sequence": { + "$ref": "#/$defs/positive" + }, + "method": { + "const": "activate" + }, + "status": { + "const": "ok" + }, + "body": { + "$ref": "#/$defs/activateResult" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "sequence", + "method", + "status", + "body" + ], + "properties": { + "version": { + "const": 1 + }, + "sequence": { + "$ref": "#/$defs/positive" + }, + "method": { + "const": "activate" + }, + "status": { + "const": "refused" + }, + "body": { + "$ref": "#/$defs/refusal" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "sequence", + "method", + "status", + "body" + ], + "properties": { + "version": { + "const": 1 + }, + "sequence": { + "$ref": "#/$defs/positive" + }, + "method": { + "const": "inspect" + }, + "status": { + "const": "ok" + }, + "body": { + "$ref": "#/$defs/inspectResult" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "sequence", + "method", + "status", + "body" + ], + "properties": { + "version": { + "const": 1 + }, + "sequence": { + "$ref": "#/$defs/positive" + }, + "method": { + "const": "inspect" + }, + "status": { + "const": "refused" + }, + "body": { + "$ref": "#/$defs/refusal" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "sequence", + "method", + "status", + "body" + ], + "properties": { + "version": { + "const": 1 + }, + "sequence": { + "$ref": "#/$defs/positive" + }, + "method": { + "const": "reserve" + }, + "status": { + "const": "ok" + }, + "body": { + "$ref": "#/$defs/reserveResult" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "sequence", + "method", + "status", + "body" + ], + "properties": { + "version": { + "const": 1 + }, + "sequence": { + "$ref": "#/$defs/positive" + }, + "method": { + "const": "reserve" + }, + "status": { + "const": "refused" + }, + "body": { + "$ref": "#/$defs/refusal" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "sequence", + "method", + "status", + "body" + ], + "properties": { + "version": { + "const": 1 + }, + "sequence": { + "$ref": "#/$defs/positive" + }, + "method": { + "const": "commitStart" + }, + "status": { + "const": "ok" + }, + "body": { + "$ref": "#/$defs/commitStartResult" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "sequence", + "method", + "status", + "body" + ], + "properties": { + "version": { + "const": 1 + }, + "sequence": { + "$ref": "#/$defs/positive" + }, + "method": { + "const": "commitStart" + }, + "status": { + "const": "refused" + }, + "body": { + "$ref": "#/$defs/refusal" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "sequence", + "method", + "status", + "body" + ], + "properties": { + "version": { + "const": 1 + }, + "sequence": { + "$ref": "#/$defs/positive" + }, + "method": { + "const": "history" + }, + "status": { + "const": "ok" + }, + "body": { + "$ref": "#/$defs/historyResult" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "sequence", + "method", + "status", + "body" + ], + "properties": { + "version": { + "const": 1 + }, + "sequence": { + "$ref": "#/$defs/positive" + }, + "method": { + "const": "history" + }, + "status": { + "const": "refused" + }, + "body": { + "$ref": "#/$defs/refusal" + } + } + } + ] + } + }, + "requestSchema": { + "$ref": "#/$defs/request" + }, + "replySchema": { + "$ref": "#/$defs/reply" + }, + "bootSchema": { + "$ref": "#/$defs/boot" + }, + "executionBindingSchema": { + "$ref": "#/$defs/executionBinding" + }, + "synthetic": { + "notExecutable": true, + "boot": { + "version": 1, + "fd": 3, + "installationId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "machineWorkspace": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "runnerWorkspace": "runner-distinct", + "executorId": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "challenge": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "profile": "local-admin-manual-pack/1" + }, + "factsBytes": "eyAibnVtYmVyIjo5MDA3MTk5MjU0NzQwOTkzLjAwLCAiZmxhZyI6ZmFsc2UsICJ6ZXJvIjowLCAibnVsbCI6bnVsbCB9", + "descriptorBytes": "eyJldmlkZW5jZSI6eyJwcmVzZW5jZSI6Im9taXR0ZWQifSwiZmFjdHMiOnsiYnl0ZXMiOjY5LCJkaWdlc3QiOiJzaGEyNTY6OTQzMjA2YjhhODczMjA2MmIzNzU3NmQ3YTc4MjJhNzQzOWQ1ODdjOTgyNDQ0YWZmZWJjZDg5ZjJlZjU0MDIwMSIsImVuY29kaW5nIjoiZXhhY3QtYnl0ZXMifSwicGFja0RpZ2VzdCI6InNoYTI1NjozMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzIiwicHJvZmlsZSI6Imp1ZGdtZW50LXBhY2stcnVubmVyL2V4ZWN1dGlvbi1wYXlsb2FkLzEiLCJyZWxlYXNlRGlnZXN0Ijoic2hhMjU2OjExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEiLCJydW50aW1lRGlnZXN0Ijoic2hhMjU2OjQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQiLCJzaWduaW5nIjp7Im1vZGUiOiJvZmYifX0=", + "manualRequestBytes": "eyAiZmFjdHMiOiB7ICJudW1iZXIiOjkwMDcxOTkyNTQ3NDA5OTMuMDAsICJmbGFnIjpmYWxzZSwgInplcm8iOjAsICJudWxsIjpudWxsIH0gfQo=", + "rawRequestDigest": "sha256:4a12ee4e59976cfc19723ec266e6ca5c3d0d53b87162c8d907afa97d17fe2081" + }, + "vectors": [ + { + "name": "activate", + "request": { + "version": 1, + "sequence": "1", + "method": "activate", + "body": { + "challenge": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "signing": { + "mode": "off" + } + } + }, + "requestDigest": "sha256:00610258f465bf41e58d9578e1af7516e94263d53d2d42ce11f03bdeb105f037", + "reply": { + "version": 1, + "sequence": "1", + "method": "activate", + "status": "ok", + "body": { + "delivery": "committed", + "epoch": "1", + "requestDigest": "sha256:00610258f465bf41e58d9578e1af7516e94263d53d2d42ce11f03bdeb105f037" + } + }, + "requestLine": "{\"body\":{\"challenge\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"signing\":{\"mode\":\"off\"}},\"method\":\"activate\",\"sequence\":\"1\",\"version\":1}\n", + "replyLine": "{\"body\":{\"delivery\":\"committed\",\"epoch\":\"1\",\"requestDigest\":\"sha256:00610258f465bf41e58d9578e1af7516e94263d53d2d42ce11f03bdeb105f037\"},\"method\":\"activate\",\"sequence\":\"1\",\"status\":\"ok\",\"version\":1}\n" + }, + { + "name": "inspect", + "request": { + "version": 1, + "sequence": "2", + "method": "inspect", + "body": { + "resource": { + "jobId": "job_synthetic", + "jobRevision": "1", + "releaseId": "release_synthetic", + "releaseProfile": "judgment-pack-runner/canonical/1", + "releaseDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + } + }, + "requestDigest": "sha256:82c165ecf790f60f3d92fed62e4838150c5ab0442ed9f1f486649d55e2f180a2", + "reply": { + "version": 1, + "sequence": "2", + "method": "inspect", + "status": "ok", + "body": { + "delivery": "observed", + "registration": { + "revision": "1", + "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222" + }, + "eligible": true, + "reason": "eligible", + "at": "2026-10-19T12:00:00Z" + } + }, + "requestLine": "{\"body\":{\"resource\":{\"jobId\":\"job_synthetic\",\"jobRevision\":\"1\",\"releaseDigest\":\"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\"releaseId\":\"release_synthetic\",\"releaseProfile\":\"judgment-pack-runner/canonical/1\"}},\"method\":\"inspect\",\"sequence\":\"2\",\"version\":1}\n", + "replyLine": "{\"body\":{\"at\":\"2026-10-19T12:00:00Z\",\"delivery\":\"observed\",\"eligible\":true,\"reason\":\"eligible\",\"registration\":{\"digest\":\"sha256:2222222222222222222222222222222222222222222222222222222222222222\",\"revision\":\"1\"}},\"method\":\"inspect\",\"sequence\":\"2\",\"status\":\"ok\",\"version\":1}\n" + }, + { + "name": "reserve", + "request": { + "version": 1, + "sequence": "3", + "method": "reserve", + "body": { + "intentId": "intent_synthetic", + "resource": { + "jobId": "job_synthetic", + "jobRevision": "1", + "releaseId": "release_synthetic", + "releaseProfile": "judgment-pack-runner/canonical/1", + "releaseDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + }, + "registration": { + "revision": "1", + "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222" + }, + "payload": { + "profile": "judgment-pack-runner/execution-payload/1", + "digest": "sha256:06c95db2fd80f303f8f8d9ee9130ae4d67e4dea29b243c2257a6437a1016d5c5", + "bytes": "eyJldmlkZW5jZSI6eyJwcmVzZW5jZSI6Im9taXR0ZWQifSwiZmFjdHMiOnsiYnl0ZXMiOjY5LCJkaWdlc3QiOiJzaGEyNTY6OTQzMjA2YjhhODczMjA2MmIzNzU3NmQ3YTc4MjJhNzQzOWQ1ODdjOTgyNDQ0YWZmZWJjZDg5ZjJlZjU0MDIwMSIsImVuY29kaW5nIjoiZXhhY3QtYnl0ZXMifSwicGFja0RpZ2VzdCI6InNoYTI1NjozMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzIiwicHJvZmlsZSI6Imp1ZGdtZW50LXBhY2stcnVubmVyL2V4ZWN1dGlvbi1wYXlsb2FkLzEiLCJyZWxlYXNlRGlnZXN0Ijoic2hhMjU2OjExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEiLCJydW50aW1lRGlnZXN0Ijoic2hhMjU2OjQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQiLCJzaWduaW5nIjp7Im1vZGUiOiJvZmYifX0=" + }, + "rawRequestDigest": "sha256:4a12ee4e59976cfc19723ec266e6ca5c3d0d53b87162c8d907afa97d17fe2081" + } + }, + "requestDigest": "sha256:70b54c56531b357f05da93bd6f531f6c55200d33180f4e4a0c363ff1be684681", + "reply": { + "version": 1, + "sequence": "3", + "method": "reserve", + "status": "ok", + "body": { + "delivery": "committed", + "operationId": "operation_synthetic", + "attemptId": "attempt_synthetic", + "epoch": "1", + "requestDigest": "sha256:70b54c56531b357f05da93bd6f531f6c55200d33180f4e4a0c363ff1be684681" + } + }, + "requestLine": "{\"body\":{\"intentId\":\"intent_synthetic\",\"payload\":{\"bytes\":\"eyJldmlkZW5jZSI6eyJwcmVzZW5jZSI6Im9taXR0ZWQifSwiZmFjdHMiOnsiYnl0ZXMiOjY5LCJkaWdlc3QiOiJzaGEyNTY6OTQzMjA2YjhhODczMjA2MmIzNzU3NmQ3YTc4MjJhNzQzOWQ1ODdjOTgyNDQ0YWZmZWJjZDg5ZjJlZjU0MDIwMSIsImVuY29kaW5nIjoiZXhhY3QtYnl0ZXMifSwicGFja0RpZ2VzdCI6InNoYTI1NjozMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzIiwicHJvZmlsZSI6Imp1ZGdtZW50LXBhY2stcnVubmVyL2V4ZWN1dGlvbi1wYXlsb2FkLzEiLCJyZWxlYXNlRGlnZXN0Ijoic2hhMjU2OjExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEiLCJydW50aW1lRGlnZXN0Ijoic2hhMjU2OjQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQiLCJzaWduaW5nIjp7Im1vZGUiOiJvZmYifX0=\",\"digest\":\"sha256:06c95db2fd80f303f8f8d9ee9130ae4d67e4dea29b243c2257a6437a1016d5c5\",\"profile\":\"judgment-pack-runner/execution-payload/1\"},\"rawRequestDigest\":\"sha256:4a12ee4e59976cfc19723ec266e6ca5c3d0d53b87162c8d907afa97d17fe2081\",\"registration\":{\"digest\":\"sha256:2222222222222222222222222222222222222222222222222222222222222222\",\"revision\":\"1\"},\"resource\":{\"jobId\":\"job_synthetic\",\"jobRevision\":\"1\",\"releaseDigest\":\"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\"releaseId\":\"release_synthetic\",\"releaseProfile\":\"judgment-pack-runner/canonical/1\"}},\"method\":\"reserve\",\"sequence\":\"3\",\"version\":1}\n", + "replyLine": "{\"body\":{\"attemptId\":\"attempt_synthetic\",\"delivery\":\"committed\",\"epoch\":\"1\",\"operationId\":\"operation_synthetic\",\"requestDigest\":\"sha256:70b54c56531b357f05da93bd6f531f6c55200d33180f4e4a0c363ff1be684681\"},\"method\":\"reserve\",\"sequence\":\"3\",\"status\":\"ok\",\"version\":1}\n" + }, + { + "name": "commitStart", + "request": { + "version": 1, + "sequence": "4", + "method": "commitStart", + "body": { + "intentId": "intent_synthetic", + "resource": { + "jobId": "job_synthetic", + "jobRevision": "1", + "releaseId": "release_synthetic", + "releaseProfile": "judgment-pack-runner/canonical/1", + "releaseDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + }, + "registration": { + "revision": "1", + "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222" + }, + "payload": { + "profile": "judgment-pack-runner/execution-payload/1", + "digest": "sha256:06c95db2fd80f303f8f8d9ee9130ae4d67e4dea29b243c2257a6437a1016d5c5", + "bytes": "eyJldmlkZW5jZSI6eyJwcmVzZW5jZSI6Im9taXR0ZWQifSwiZmFjdHMiOnsiYnl0ZXMiOjY5LCJkaWdlc3QiOiJzaGEyNTY6OTQzMjA2YjhhODczMjA2MmIzNzU3NmQ3YTc4MjJhNzQzOWQ1ODdjOTgyNDQ0YWZmZWJjZDg5ZjJlZjU0MDIwMSIsImVuY29kaW5nIjoiZXhhY3QtYnl0ZXMifSwicGFja0RpZ2VzdCI6InNoYTI1NjozMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzIiwicHJvZmlsZSI6Imp1ZGdtZW50LXBhY2stcnVubmVyL2V4ZWN1dGlvbi1wYXlsb2FkLzEiLCJyZWxlYXNlRGlnZXN0Ijoic2hhMjU2OjExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEiLCJydW50aW1lRGlnZXN0Ijoic2hhMjU2OjQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQiLCJzaWduaW5nIjp7Im1vZGUiOiJvZmYifX0=" + }, + "operationId": "operation_synthetic", + "attemptId": "attempt_synthetic", + "epoch": "1", + "rawRequestDigest": "sha256:4a12ee4e59976cfc19723ec266e6ca5c3d0d53b87162c8d907afa97d17fe2081" + } + }, + "requestDigest": "sha256:f05453a4a165166ef82ce9c23c61e9986da25a6170c86ad849b83cef929f1035", + "reply": { + "version": 1, + "sequence": "4", + "method": "commitStart", + "status": "ok", + "body": { + "delivery": "committed", + "operationId": "operation_synthetic", + "attemptId": "attempt_synthetic", + "epoch": "1", + "requestDigest": "sha256:f05453a4a165166ef82ce9c23c61e9986da25a6170c86ad849b83cef929f1035", + "decision": "start-committed", + "reason": "start-committed", + "at": "2026-10-19T12:00:00Z", + "authorityRevision": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + }, + "requestLine": "{\"body\":{\"attemptId\":\"attempt_synthetic\",\"epoch\":\"1\",\"intentId\":\"intent_synthetic\",\"operationId\":\"operation_synthetic\",\"payload\":{\"bytes\":\"eyJldmlkZW5jZSI6eyJwcmVzZW5jZSI6Im9taXR0ZWQifSwiZmFjdHMiOnsiYnl0ZXMiOjY5LCJkaWdlc3QiOiJzaGEyNTY6OTQzMjA2YjhhODczMjA2MmIzNzU3NmQ3YTc4MjJhNzQzOWQ1ODdjOTgyNDQ0YWZmZWJjZDg5ZjJlZjU0MDIwMSIsImVuY29kaW5nIjoiZXhhY3QtYnl0ZXMifSwicGFja0RpZ2VzdCI6InNoYTI1NjozMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzIiwicHJvZmlsZSI6Imp1ZGdtZW50LXBhY2stcnVubmVyL2V4ZWN1dGlvbi1wYXlsb2FkLzEiLCJyZWxlYXNlRGlnZXN0Ijoic2hhMjU2OjExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEiLCJydW50aW1lRGlnZXN0Ijoic2hhMjU2OjQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQiLCJzaWduaW5nIjp7Im1vZGUiOiJvZmYifX0=\",\"digest\":\"sha256:06c95db2fd80f303f8f8d9ee9130ae4d67e4dea29b243c2257a6437a1016d5c5\",\"profile\":\"judgment-pack-runner/execution-payload/1\"},\"rawRequestDigest\":\"sha256:4a12ee4e59976cfc19723ec266e6ca5c3d0d53b87162c8d907afa97d17fe2081\",\"registration\":{\"digest\":\"sha256:2222222222222222222222222222222222222222222222222222222222222222\",\"revision\":\"1\"},\"resource\":{\"jobId\":\"job_synthetic\",\"jobRevision\":\"1\",\"releaseDigest\":\"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\"releaseId\":\"release_synthetic\",\"releaseProfile\":\"judgment-pack-runner/canonical/1\"}},\"method\":\"commitStart\",\"sequence\":\"4\",\"version\":1}\n", + "replyLine": "{\"body\":{\"at\":\"2026-10-19T12:00:00Z\",\"attemptId\":\"attempt_synthetic\",\"authorityRevision\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"decision\":\"start-committed\",\"delivery\":\"committed\",\"epoch\":\"1\",\"operationId\":\"operation_synthetic\",\"reason\":\"start-committed\",\"requestDigest\":\"sha256:f05453a4a165166ef82ce9c23c61e9986da25a6170c86ad849b83cef929f1035\"},\"method\":\"commitStart\",\"sequence\":\"4\",\"status\":\"ok\",\"version\":1}\n" + }, + { + "name": "history", + "request": { + "version": 1, + "sequence": "5", + "method": "history", + "body": { + "intentId": "intent_synthetic" + } + }, + "requestDigest": "sha256:c03ae3795c263314bb3e599e70bcf00b307e02aa835c9e47509ce9a40f54f2b6", + "reply": { + "version": 1, + "sequence": "5", + "method": "history", + "status": "ok", + "body": { + "delivery": "observed", + "intentId": "intent_synthetic", + "operationId": "operation_synthetic", + "attemptId": "attempt_synthetic", + "epoch": "1", + "requestDigest": "sha256:f05453a4a165166ef82ce9c23c61e9986da25a6170c86ad849b83cef929f1035", + "state": "start-committed", + "reason": "start-committed", + "at": "2026-10-19T12:00:00Z" + } + }, + "requestLine": "{\"body\":{\"intentId\":\"intent_synthetic\"},\"method\":\"history\",\"sequence\":\"5\",\"version\":1}\n", + "replyLine": "{\"body\":{\"at\":\"2026-10-19T12:00:00Z\",\"attemptId\":\"attempt_synthetic\",\"delivery\":\"observed\",\"epoch\":\"1\",\"intentId\":\"intent_synthetic\",\"operationId\":\"operation_synthetic\",\"reason\":\"start-committed\",\"requestDigest\":\"sha256:f05453a4a165166ef82ce9c23c61e9986da25a6170c86ad849b83cef929f1035\",\"state\":\"start-committed\"},\"method\":\"history\",\"sequence\":\"5\",\"status\":\"ok\",\"version\":1}\n" + }, + { + "name": "commitStart-recovered", + "reply": { + "version": 1, + "sequence": "4", + "method": "commitStart", + "status": "ok", + "body": { + "delivery": "recovered", + "operationId": "operation_synthetic", + "attemptId": "attempt_synthetic", + "epoch": "1", + "requestDigest": "sha256:f05453a4a165166ef82ce9c23c61e9986da25a6170c86ad849b83cef929f1035", + "decision": "start-committed", + "reason": "start-committed", + "at": "2026-10-19T12:00:00Z", + "authorityRevision": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + } + }, + { + "name": "commitStart-blocked", + "reply": { + "version": 1, + "sequence": "4", + "method": "commitStart", + "status": "ok", + "body": { + "delivery": "committed", + "operationId": "operation_synthetic", + "attemptId": "attempt_synthetic", + "epoch": "1", + "requestDigest": "sha256:f05453a4a165166ef82ce9c23c61e9986da25a6170c86ad849b83cef929f1035", + "decision": "blocked", + "reason": "withdrawn", + "at": "2026-10-19T12:00:00Z", + "authorityRevision": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + } + }, + { + "name": "reserve-refused", + "reply": { + "version": 1, + "sequence": "3", + "method": "reserve", + "status": "refused", + "body": { + "code": "eligibility-blocked" + } + } + } + ], + "invalidVectors": [ + { + "name": "duplicate-sequence", + "line": "{\"version\":1,\"sequence\":\"1\",\"sequence\":\"2\",\"method\":\"history\",\"body\":{\"intentId\":\"intent_synthetic\"}}\n" + }, + { + "name": "case-alias", + "line": "{\"version\":1,\"Sequence\":\"1\",\"method\":\"history\",\"body\":{\"intentId\":\"intent_synthetic\"}}\n" + }, + { + "name": "null-body", + "line": "{\"version\":1,\"sequence\":\"1\",\"method\":\"history\",\"body\":null}\n" + }, + { + "name": "number-sequence", + "line": "{\"version\":1,\"sequence\":1,\"method\":\"history\",\"body\":{\"intentId\":\"intent_synthetic\"}}\n" + }, + { + "name": "leading-zero-sequence", + "line": "{\"version\":1,\"sequence\":\"01\",\"method\":\"history\",\"body\":{\"intentId\":\"intent_synthetic\"}}\n" + }, + { + "name": "unknown-member", + "line": "{\"version\":1,\"sequence\":\"1\",\"method\":\"history\",\"body\":{\"intentId\":\"intent_synthetic\",\"allow\":true}}\n" + }, + { + "name": "unknown-method", + "line": "{\"version\":1,\"sequence\":\"1\",\"method\":\"allow\",\"body\":{}}\n" + }, + { + "name": "unmatched-sequence", + "reply": { + "version": 1, + "sequence": "6", + "method": "commitStart", + "status": "ok", + "body": { + "delivery": "committed", + "operationId": "operation_synthetic", + "attemptId": "attempt_synthetic", + "epoch": "1", + "requestDigest": "sha256:f05453a4a165166ef82ce9c23c61e9986da25a6170c86ad849b83cef929f1035", + "decision": "start-committed", + "reason": "start-committed", + "at": "2026-10-19T12:00:00Z", + "authorityRevision": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + }, + "sentSequence": "4" + }, + { + "name": "bad-result-decision", + "reply": { + "version": 1, + "sequence": "4", + "method": "commitStart", + "status": "ok", + "body": { + "delivery": "committed", + "operationId": "operation_synthetic", + "attemptId": "attempt_synthetic", + "epoch": "1", + "requestDigest": "sha256:f05453a4a165166ef82ce9c23c61e9986da25a6170c86ad849b83cef929f1035", + "decision": "allow", + "reason": "start-committed", + "at": "2026-10-19T12:00:00Z", + "authorityRevision": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + } + }, + { + "name": "eligible-reason-contradiction", + "reply": { + "version": 1, + "sequence": "2", + "method": "inspect", + "status": "ok", + "body": { + "delivery": "observed", + "registration": { + "revision": "1", + "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222" + }, + "eligible": false, + "reason": "eligible", + "at": "2026-10-19T12:00:00Z" + } + }, + "semantic": true + }, + { + "name": "start-decision-reason-contradiction", + "reply": { + "version": 1, + "sequence": "4", + "method": "commitStart", + "status": "ok", + "body": { + "delivery": "committed", + "operationId": "operation_synthetic", + "attemptId": "attempt_synthetic", + "epoch": "1", + "requestDigest": "sha256:f05453a4a165166ef82ce9c23c61e9986da25a6170c86ad849b83cef929f1035", + "decision": "start-committed", + "reason": "withdrawn", + "at": "2026-10-19T12:00:00Z", + "authorityRevision": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + }, + "semantic": true + }, + { + "name": "uint64-sequence-overflow", + "line": "{\"version\":1,\"sequence\":\"18446744073709551616\",\"method\":\"history\",\"body\":{\"intentId\":\"intent_synthetic\"}}\n" + }, + { + "name": "noncanonical-base64", + "line": "{\"body\":{\"intentId\":\"intent_synthetic\",\"payload\":{\"bytes\":\"eyJldmlkZW5jZSI6eyJwcmVzZW5jZSI6Im9taXR0ZWQifSwiZmFjdHMiOnsiYnl0ZXMiOjY5LCJkaWdlc3QiOiJzaGEyNTY6OTQzMjA2YjhhODczMjA2MmIzNzU3NmQ3YTc4MjJhNzQzOWQ1ODdjOTgyNDQ0YWZmZWJjZDg5ZjJlZjU0MDIwMSIsImVuY29kaW5nIjoiZXhhY3QtYnl0ZXMifSwicGFja0RpZ2VzdCI6InNoYTI1NjozMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzIiwicHJvZmlsZSI6Imp1ZGdtZW50LXBhY2stcnVubmVyL2V4ZWN1dGlvbi1wYXlsb2FkLzEiLCJyZWxlYXNlRGlnZXN0Ijoic2hhMjU2OjExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEiLCJydW50aW1lRGlnZXN0Ijoic2hhMjU2OjQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQiLCJzaWduaW5nIjp7Im1vZGUiOiJvZmYifX0\",\"digest\":\"sha256:06c95db2fd80f303f8f8d9ee9130ae4d67e4dea29b243c2257a6437a1016d5c5\",\"profile\":\"judgment-pack-runner/execution-payload/1\"},\"rawRequestDigest\":\"sha256:4a12ee4e59976cfc19723ec266e6ca5c3d0d53b87162c8d907afa97d17fe2081\",\"registration\":{\"digest\":\"sha256:2222222222222222222222222222222222222222222222222222222222222222\",\"revision\":\"1\"},\"resource\":{\"jobId\":\"job_synthetic\",\"jobRevision\":\"1\",\"releaseDigest\":\"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\"releaseId\":\"release_synthetic\",\"releaseProfile\":\"judgment-pack-runner/canonical/1\"}},\"method\":\"reserve\",\"sequence\":\"3\",\"version\":1}\n" + }, + { + "name": "inspect-before-activation", + "request": { + "version": 1, + "sequence": "2", + "method": "inspect", + "body": { + "resource": { + "jobId": "job_synthetic", + "jobRevision": "1", + "releaseId": "release_synthetic", + "releaseProfile": "judgment-pack-runner/canonical/1", + "releaseDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + } + }, + "channelPhase": "inactive", + "semantic": true + }, + { + "name": "activation-replayed", + "request": { + "version": 1, + "sequence": "1", + "method": "activate", + "body": { + "challenge": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "signing": { + "mode": "off" + } + } + }, + "channelPhase": "active", + "semantic": true + }, + { + "name": "activation-at-sequence-two", + "request": { + "version": 1, + "sequence": "2", + "method": "activate", + "body": { + "challenge": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "signing": { + "mode": "off" + } + } + }, + "channelPhase": "inactive", + "semantic": true + }, + { + "name": "mismatched-original-body-digest", + "request": { + "version": 1, + "sequence": "3", + "method": "reserve", + "body": { + "intentId": "intent_synthetic", + "resource": { + "jobId": "job_synthetic", + "jobRevision": "1", + "releaseId": "release_synthetic", + "releaseProfile": "judgment-pack-runner/canonical/1", + "releaseDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + }, + "registration": { + "revision": "1", + "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222" + }, + "payload": { + "profile": "judgment-pack-runner/execution-payload/1", + "digest": "sha256:06c95db2fd80f303f8f8d9ee9130ae4d67e4dea29b243c2257a6437a1016d5c5", + "bytes": "eyJldmlkZW5jZSI6eyJwcmVzZW5jZSI6Im9taXR0ZWQifSwiZmFjdHMiOnsiYnl0ZXMiOjY5LCJkaWdlc3QiOiJzaGEyNTY6OTQzMjA2YjhhODczMjA2MmIzNzU3NmQ3YTc4MjJhNzQzOWQ1ODdjOTgyNDQ0YWZmZWJjZDg5ZjJlZjU0MDIwMSIsImVuY29kaW5nIjoiZXhhY3QtYnl0ZXMifSwicGFja0RpZ2VzdCI6InNoYTI1NjozMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzIiwicHJvZmlsZSI6Imp1ZGdtZW50LXBhY2stcnVubmVyL2V4ZWN1dGlvbi1wYXlsb2FkLzEiLCJyZWxlYXNlRGlnZXN0Ijoic2hhMjU2OjExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEiLCJydW50aW1lRGlnZXN0Ijoic2hhMjU2OjQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQiLCJzaWduaW5nIjp7Im1vZGUiOiJvZmYifX0=" + }, + "rawRequestDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "semantic": true, + "expectedRefusal": "binding-conflict" + } + ] +} diff --git a/docs/design/runner-capabilities.md b/docs/design/runner-capabilities.md index 65a8879..283bc68 100644 --- a/docs/design/runner-capabilities.md +++ b/docs/design/runner-capabilities.md @@ -1,12 +1,12 @@ # Runner pre-start capabilities -Status: private experimental host/companion contract, version 2, 2026-10-18. -This implements the capability-negotiation foundation of Desk's Policy execution -admission design. It does not implement governed activation, resource registration, -per-attempt admission, withdrawal enforcement or Runtime dispatch authorization. -Explicit trusted-host enrollment can enable external executor ownership. Execution -payloads remain an inactive foundation; ownership grants no governance permission. -All jobs remain ungoverned. +Status: private experimental host/companion contract, version 3, 2026-10-19. +The reviewed candidate supports optional `local-admin-manual-pack/1` host admission +on supported platforms, with explicit retained enrollment, exact registration and +per-attempt authority commitment at the actual operational starter. This is not +adopted portable Policy conformance. Ownership itself grants no permission. +Unconfigured legacy workspaces retain their behavior. See +[manual admission gate](manual-admission-gate.md) for scope and recovery limits. ## Probe before boot @@ -20,26 +20,33 @@ It exits successfully after writing this single UTF-8 JSON object and a newline to stdout, with no stderr output: ```json -{"version":2,"capabilities":["jobs/1","required-boot-capabilities/1","executor-ownership/1"],"governanceAdmission":false} +{"version":3,"capabilities":["jobs/1","required-boot-capabilities/1","executor-ownership/1","policy-manual-pack-run/1"],"governanceAdmission":true,"governanceProfiles":["local-admin-manual-pack/1"]} ``` -Version 2 is a closed shape: exactly these three case-sensitive members, integer -`version: 2`, this ordered array of supported capability strings, and boolean -`governanceAdmission: false`. No duplicate members are permitted. There are no +Version 3 is a closed shape: exactly these four case-sensitive members, integer +`version: 3`, this ordered capability array, `governanceAdmission: true`, and the +single named governance profile. No duplicate members are permitted. There are no installation paths, identifiers, configuration values, credentials or secrets in the response. The response is less than 512 bytes. A shape or semantic change requires an explicitly reviewed contract version; consumers must not interpret -unknown versions as version 2. The earlier version 1 response was also closed; -adding executor ownership deliberately changes the response version. +unknown versions as a known shape. Earlier version 2 remains the closed +three-member ownership-only response below; hosts without supported admission +retain it. Earlier version 1 was also closed. Adding admission deliberately +changes the response version rather than widening version 2. + +```json +{"version":2,"capabilities":["jobs/1","required-boot-capabilities/1","executor-ownership/1"],"governanceAdmission":false} +``` `jobs/1` names the existing companion protocol. `required-boot-capabilities/1` means Runner strictly checks the optional boot requirement below before opening its service. `executor-ownership/1` means a supplied trusted ownership binding is required before the service opens and is inherited by every Service Runtime -invocation ([binding and lifetime](executor-ownership.md)). Governance admission is explicitly unsupported: a payload descriptor, -lease helper, version string or successful probe does not establish permission to -execute. No governed execution capability is advertised. Requiring an unknown -capability, including `governance-admission/1`, refuses startup. +invocation ([binding and lifetime](executor-ownership.md)). +`policy-manual-pack-run/1` names only the closed manual profile: a payload +descriptor, lease helper, version string or successful probe does not establish +permission to execute. Requiring an unknown capability, including the distinct +unsupported `governance-admission/1`, refuses startup. The command does not read stdin or installation/project configuration, inspect a Runtime or workspace, open a store, bind a listener, start workers or make provider @@ -132,6 +139,23 @@ Ownership binding and requirement syntax are checked before `Open`; `Open` then acquires and verifies the existing external lease before any portable-directory creation, store access, Runtime pinning, recovery or worker/provider startup. +## Manual admission and requirement agreement + +The closed `executorAdmission` binding in the +[manual wire contract](manual-admission-wire-shape.md) requires +`policy-manual-pack-run/1` if and only if the binding is supplied, and also +requires matching executor ownership. Installation, both workspace identities, +executor, challenge, profile and inherited Unix stream descriptor are trusted +host custody, never browser or restored-store assertions. All fixed external, +prepared schema4 and boot demands must agree. Unsupported platforms refuse. + +After actual external ownership and fixed operational signing, Runner activates +the dedicated channel before recovery/workers. Every operational start requires +current reserve/start commitment and the original session's locally journaled +one-use launch. Enabled workspaces reject automatic triggers, acquisitions, +workflow operations and upgrades; there is no mixed legacy-resource escape. +Legacy workspaces do not acquire governance from a probe response alone. + ## Verification Targeted command tests use stdin that panics on any read and missing configuration diff --git a/go.mod b/go.mod index 08a0875..17da82f 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( golang.org/x/oauth2 v0.36.0 + modernc.org/libc v1.75.7 modernc.org/sqlite v1.59.0 ) @@ -15,7 +16,6 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/sys v0.47.0 // indirect - modernc.org/libc v1.75.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect ) diff --git a/internal/runner/automatic_inputs.go b/internal/runner/automatic_inputs.go index 1a82dea..b48b71e 100644 --- a/internal/runner/automatic_inputs.go +++ b/internal/runner/automatic_inputs.go @@ -16,6 +16,9 @@ func inputPath(p string) bool { return p != "" && fs.ValidPath(p) && p != "." && !strings.Contains(p, "\\") && strings.HasSuffix(strings.ToLower(p), ".json") } func (s *Service) readAutomaticFile(name string) ([]byte, error) { + if s.cfg.ExecutorAdmission != nil { + return nil, manualUnsupported() + } if s.cfg.InputRoot == "" || !inputPath(name) { return nil, bad("invalid_input_path", "Choose a JSON file inside this job's project.") } diff --git a/internal/runner/background.go b/internal/runner/background.go index 076138e..b269e13 100644 --- a/internal/runner/background.go +++ b/internal/runner/background.go @@ -104,6 +104,9 @@ func (s *Service) validateGatewayMapping(r Release) error { var automaticHTTP = &http.Client{Timeout: 60 * time.Second, Transport: &http.Transport{Proxy: nil}, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("Gateway redirect refused") }} func (s *Service) acquireAutomatic(parent context.Context, input Input) (Input, error) { + if s.cfg.ExecutorAdmission != nil { + return Input{}, manualUnsupported() + } if input.Source == nil { return input, bad("automatic_mapping_required", "Source inputs are required.") } @@ -142,6 +145,9 @@ func (s *Service) acquireAutomatic(parent context.Context, input Input) (Input, // acquirePlannedOperation performs one fresh call. Its caller checkpoints the // returned response before another request, and never retries an uncertain call. func (s *Service) acquirePlannedOperation(parent context.Context, next PlannedSource) (SourceValue, error) { + if s.cfg.ExecutorAdmission != nil { + return SourceValue{}, manualUnsupported() + } if next.Kind != "operation" { return SourceValue{}, bad("automatic_source_unavailable", "A fresh operation source is required.") } @@ -200,6 +206,9 @@ func automaticCall(ctx context.Context, target string, body []byte, limit int64) return raw, nil } func (s *Service) startBackground(ctx context.Context) { + if s.cfg.ExecutorAdmission != nil { + return + } for worker := 0; worker < 4; worker++ { s.background.Add(1) go func() { @@ -228,6 +237,9 @@ func (s *Service) startBackground(ctx context.Context) { } } func (s *Service) prepareLegacyOccurrence(ctx context.Context, occurrenceID string) error { + if s.cfg.ExecutorAdmission != nil { + return manualUnsupported() + } s.automationMu.Lock() if s.unhealthy.Load() { s.automationMu.Unlock() diff --git a/internal/runner/cloud.go b/internal/runner/cloud.go index 7fe3683..95a34c0 100644 --- a/internal/runner/cloud.go +++ b/internal/runner/cloud.go @@ -55,6 +55,9 @@ func (s *Service) cloudProblem(id string, e error) { } } func (s *Service) cloudWorker(ctx context.Context, c CloudConnection) { + if s.cfg.ExecutorAdmission != nil { + return + } var transport cloudTransport for ctx.Err() == nil { var e error diff --git a/internal/runner/executor_ownership.go b/internal/runner/executor_ownership.go index eeaffb6..32aecee 100644 --- a/internal/runner/executor_ownership.go +++ b/internal/runner/executor_ownership.go @@ -4,6 +4,7 @@ import ( "errors" "os/exec" "path/filepath" + "slices" "strings" ) @@ -34,6 +35,63 @@ func (o ExecutorOwnership) identity() ExecutorLeaseIdentity { // configured lease pointer is immutable, including after Close: a stopped or // invalid lease refuses launch rather than falling back to an unowned command. func (s *Service) startRuntime(cmd *exec.Cmd) error { + return s.startRuntimeAs(cmd, manualUtilityInvocation, nil) +} + +type manualInvocationKind uint8 + +const ( + manualUtilityInvocation manualInvocationKind = iota + manualOperationalInvocation +) + +func manualUtilityCommand(cmd *exec.Cmd) bool { + if cmd == nil || len(cmd.Args) < 2 || !slices.Equal(cmd.Env, []string{"LANG=C", "LC_ALL=C"}) || cmd.Stdin != nil || len(cmd.ExtraFiles) != 0 { + return false + } + args := cmd.Args[1:] + if slices.Equal(args, []string{"version", "--format", "json"}) { + return true + } + if len(args) < 2 { + return false + } + switch args[0] + " " + args[1] { + case "spec validate": + return slices.Equal(args, []string{"spec", "validate", "pack.json", "--format", "json"}) + case "packs lock": + return slices.Equal(args, []string{"packs", "lock", "--config", "jpack.json", "--format", "json"}) + case "packs test": + return slices.Equal(args, []string{"packs", "test", "--id", "target", "--config", "jpack.json", "--format", "json"}) + case "packs list": + return slices.Equal(args, []string{"packs", "list", "--config", "jpack.json", "--format", "json"}) + case "audit verify": + return len(args) == 6 && args[2] == "--trail" && args[3] != "" && args[4] == "--format" && args[5] == "json" + case "experimental evaluate": + base := []string{"experimental", "evaluate", "--pack-id", "target", "--config", "jpack.json", "--facts", "facts.json", "--format", "json"} + if slices.Equal(args, append(base, "--rehearsal")) { + return true + } + return slices.Equal(args, append(base, "--evidence", "evidence.json", "--rehearsal")) + } + return false +} + +// All Service paths arrive here. Enabled operational invocation requires the +// original session's verified, durable, one-use launch; alternate evaluate and +// private workflow paths cannot inherit a utility classification accidentally. +func (s *Service) startRuntimeAs(cmd *exec.Cmd, kind manualInvocationKind, launch *manualLaunch) error { + if s.cfg.ExecutorAdmission != nil { + if kind == manualOperationalInvocation { + if launch == nil || launch.client != s.authority || launch.material == nil { + return errManualProtocol + } + return launch.start(cmd, s.executor) + } + if kind != manualUtilityInvocation || launch != nil || !manualUtilityCommand(cmd) { + return errManualProtocol + } + } if s.cfg.ExecutorOwnership != nil || s.executor != nil { _, err := s.executor.Start(cmd) return err diff --git a/internal/runner/http.go b/internal/runner/http.go index 5482f3c..05485cf 100644 --- a/internal/runner/http.go +++ b/internal/runner/http.go @@ -91,6 +91,7 @@ func releaseNamed(raw []byte) string { } func (s *Service) Handler(token string) http.Handler { mux := http.NewServeMux() + mux.HandleFunc("GET /v1/jobs/{job}/execution-binding", s.manualExecutionBindingHandler) s.triggerRoutes(mux) mux.HandleFunc("POST /v1/input-profiles", s.registerInputProfileHandler) mux.HandleFunc("GET /v1/background-connections", func(w http.ResponseWriter, r *http.Request) { @@ -269,13 +270,13 @@ func (s *Service) Handler(token string) http.Handler { failure(w, err) return } - var input Input - if err := decodeInput(w, r, &input); err != nil { + input, manual, err := s.manualOwnerInput(w, r, key) + if err != nil { s.refusedRun(r.PathValue("job"), err) failure(w, err) return } - run, replayed, err := s.submit(r.PathValue("job"), key, input) + run, replayed, err := s.submitInternalIntent(r.PathValue("job"), key, input, nil, manual) if err != nil { s.refusedRun(r.PathValue("job"), err) failure(w, err) diff --git a/internal/runner/job_revisions.go b/internal/runner/job_revisions.go index 33e0fa6..91eb21c 100644 --- a/internal/runner/job_revisions.go +++ b/internal/runner/job_revisions.go @@ -53,6 +53,9 @@ func recordInitialJob(tx *sql.Tx, j Job) error { } func (s *Service) upgradeJob(ctx context.Context, jobID string, req UpgradeJobRequest) (Job, error) { + if s.cfg.ExecutorAdmission != nil { + return Job{}, manualUnsupported() + } if !req.Reviewed || req.BaseRevision < 1 || req.BaseRevision == math.MaxInt || req.ReleaseID == "" || req.BaseReleaseID == "" { return Job{}, bad("review_required", "Review the checked release and the job revision being replaced.") } diff --git a/internal/runner/list.go b/internal/runner/list.go index cbc1d77..7e7121c 100644 --- a/internal/runner/list.go +++ b/internal/runner/list.go @@ -12,6 +12,35 @@ type recordFilter struct { Review bool } +// A list is a stable public projection, independent of Run's durable encoding. +// Do not embed Run/storedRun or copy arbitrary stored members: private fields +// such as exact manual admission preimages must never become list metadata. +type runListSummary struct { + Trigger *TriggerOrigin `json:"trigger,omitempty"` + SchemaVersion *string `json:"schemaVersion,omitempty"` + ID *string `json:"id,omitempty"` + JobID *string `json:"jobId,omitempty"` + ReleaseID *string `json:"releaseId,omitempty"` + Revision *int `json:"revision,omitempty"` + State *string `json:"state,omitempty"` + CreatedAt *string `json:"createdAt,omitempty"` + StartedAt *string `json:"startedAt,omitempty"` + FinishedAt *string `json:"finishedAt,omitempty"` + RequestedBy *string `json:"requestedBy,omitempty"` + Attempt *int `json:"attempt,omitempty"` + Problem *string `json:"problem,omitempty"` + InterruptedAt *string `json:"interruptedAt,omitempty"` + Reason *string `json:"reason,omitempty"` + Signing *RunSigning `json:"signing,omitempty"` + Binding *RunBinding `json:"binding,omitempty"` + Result *runListResult `json:"result,omitempty"` + JobName string `json:"jobName"` +} +type runListResult struct { + Disposition json.RawMessage `json:"disposition"` + HandoffTarget json.RawMessage `json:"handoffTarget"` +} + func validRunState(state string) bool { switch state { case "queued", "running", "completed", "failed", "interrupted": @@ -71,23 +100,18 @@ func (s *Service) filteredRecords(kind, jobID string, after int64, filter record break } var summary map[string]json.RawMessage + if kind == "runs" { + var public runListSummary + if err = json.Unmarshal(raw, &public); err != nil { + return nil, 0, err + } + public.JobName = label + raw = encode(public) + } if err = json.Unmarshal(raw, &summary); err != nil { return nil, 0, err } - if kind == "runs" { - delete(summary, "input") - delete(summary, "audit") - delete(summary, "auditBytes") - delete(summary, "auditSignatures") - summary["jobName"] = encode(label) - if raw := summary["result"]; len(raw) > 0 { - var result map[string]json.RawMessage - if err = json.Unmarshal(raw, &result); err != nil { - return nil, 0, err - } - summary["result"] = encode(map[string]json.RawMessage{"disposition": result["disposition"], "handoffTarget": result["handoffTarget"]}) - } - } else { + if kind != "runs" { summary["packTitle"] = encode(label) summary["packVersion"] = encode(version) } diff --git a/internal/runner/list_test.go b/internal/runner/list_test.go index 2ce5855..814166d 100644 --- a/internal/runner/list_test.go +++ b/internal/runner/list_test.go @@ -1,13 +1,90 @@ package runner import ( + "bytes" "database/sql" "encoding/json" "fmt" + "net/http" + "net/http/httptest" "strings" "testing" ) +func TestManualQueuedRunListsKeepPrivatePreimages(t *testing.T) { + s := manualServiceFixture(t) + job := manualSavedJob(t, s) + manualGateAuthority(t, s, nil) + const token = "owner-list-secret" + const key = "original-private-list-key" + const intentID = "intent_private_list" + const facts = `{ "number":9007199254740993.00, "private":"fact-secret" }` + const evidence = `{ "private-proof":"present" }` + raw := `{ "facts": ` + facts + `, "evidence": ` + evidence + ` }` + request := httptest.NewRequest(http.MethodPost, "/v1/jobs/"+job.ID+"/runs", strings.NewReader(raw)) + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("Idempotency-Key", key) + request.Header.Set(ManualIntentHeader, intentID) + accepted := httptest.NewRecorder() + s.Handler(token).ServeHTTP(accepted, request) + if accepted.Code != http.StatusAccepted { + t.Fatalf("real manual queue: %d %s", accepted.Code, accepted.Body.String()) + } + var run Run + if err := json.Unmarshal(accepted.Body.Bytes(), &run); err != nil { + t.Fatal(err) + } + var before string + if err := s.db.QueryRow("SELECT record FROM runs WHERE id=?", run.ID).Scan(&before); err != nil { + t.Fatal(err) + } + if !strings.Contains(before, `"manualAdmission"`) { + t.Fatal("fixture did not retain private admission metadata") + } + for _, path := range []string{"/v1/runs", "/v1/jobs/" + job.ID + "/runs"} { + t.Run(path, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, path, nil) + request.Header.Set("Authorization", "Bearer "+token) + response := httptest.NewRecorder() + s.Handler(token).ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("list: %d %s", response.Code, response.Body.String()) + } + var page struct { + Items []map[string]json.RawMessage `json:"items"` + Next int64 `json:"next"` + } + if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil { + t.Fatal(err) + } + if len(page.Items) != 1 || page.Next != 0 { + t.Fatal("manual queue list page", response.Body.String()) + } + expected := map[string]any{"schemaVersion": run.SchemaVersion, "id": run.ID, "jobId": job.ID, "releaseId": job.ReleaseID, "revision": job.Revision, "state": "queued", "createdAt": run.CreatedAt, "requestedBy": s.cfg.Owner, "attempt": 0, "jobName": job.Name} + item := page.Items[0] + if len(item) != len(expected) { + t.Fatalf("unexpected public summary fields: %s", encode(item)) + } + for name, want := range expected { + if !bytes.Equal(item[name], encode(want)) { + t.Fatalf("public summary %s: %s != %s", name, item[name], encode(want)) + } + } + }) + } + var after string + if err := s.db.QueryRow("SELECT record FROM runs WHERE id=?", run.ID).Scan(&after); err != nil { + t.Fatal(err) + } + if after != before { + t.Fatal("listing changed private durable record") + } + held, err := s.run(run.ID) + if err != nil || held.manual == nil || held.manual.request != raw || held.manual.intentID != intentID || held.manual.key != key || held.manual.resource == nil || held.manual.registration == nil || !bytes.Equal(held.Input.Facts, []byte(facts)) || !bytes.Equal(held.Input.Evidence, []byte(evidence)) { + t.Fatal("listing lost exact private durable preimages", err) + } +} + func TestFilteredListsAcrossPages(t *testing.T) { db, err := sql.Open("sqlite", ":memory:") if err != nil { diff --git a/internal/runner/manual_admission_activation.go b/internal/runner/manual_admission_activation.go new file mode 100644 index 0000000..5acce14 --- /dev/null +++ b/internal/runner/manual_admission_activation.go @@ -0,0 +1,66 @@ +package runner + +import "context" + +// The coordinator flips this one source constant only after both real gates +// are source-ready/reviewed, for the uniform final-candidate integration run. +const manualAdmissionReady = true + +func ManualAdmissionAvailable() bool { + return manualAdmissionReady && manualAdmissionPlatformSupported() +} + +func (s *Service) validateManualStore() error { + var schema, raw string + if s.db.QueryRow("SELECT value FROM metadata WHERE key='schema'").Scan(&schema) != nil || schema != manualStoreSchema || s.cfg.ExecutorAdmission == nil { + return errManualProtocol + } + if s.db.QueryRow("SELECT value FROM metadata WHERE key='manualExecutionDemand'").Scan(&raw) != nil { + return errManualProtocol + } + var d ManualExecutionDemand + if manualDecode([]byte(raw), &d) != nil || d != s.cfg.ExecutorAdmission.demand() { + return errManualProtocol + } + marker, err := readManualStateFile(s.executor, manualPrepareMarker, 16384) + var held manualPrepareRecord + if err != nil || manualDecode(marker, &held) != nil || held.Version != 1 || !manualHex(held.PrepareID) || !validDigest(held.RequestDigest) || held.Demand != d { + return errManualProtocol + } + for _, query := range []string{"SELECT count(*) FROM triggers", "SELECT count(*) FROM occurrences WHERE state IN ('accepted','preparing','waiting','needs-attention')"} { + var count int + if s.db.QueryRow(query).Scan(&count) != nil || count != 0 { + return manualUnsupported() + } + } + rows, err := s.db.Query("SELECT record FROM runs WHERE state='queued'") + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var raw string + if rows.Scan(&raw) != nil { + return errManualProtocol + } + r, e := decodeRun([]byte(raw)) + if e != nil || r.manual == nil || r.manual.resource == nil || r.manual.registration == nil || r.Trigger != nil || r.Input.Source != nil || r.Input.Preparation != nil { + return errManualProtocol + } + } + return rows.Err() +} +func (s *Service) activateManualAuthority() error { + if s.cfg.ExecutorAdmission == nil { + return nil + } + if len(s.cfg.CloudConnections) != 0 || len(s.cfg.GatewayConnections) != 0 || len(s.cfg.InputProfiles) != 0 || len(s.storedProfiles) != 0 { + return manualUnsupported() + } + signing := ManualSigning{Mode: "off"} + if s.expectedKey != nil { + signing = ManualSigning{Mode: "ed25519", PublicKey: hexPublic(s.expectedKey)} + } + _, err := s.authority.Activate(context.Background(), ManualActivateRequest{Challenge: s.cfg.ExecutorAdmission.Challenge, Signing: signing}) + return err +} diff --git a/internal/runner/manual_admission_client.go b/internal/runner/manual_admission_client.go new file mode 100644 index 0000000..b50c359 --- /dev/null +++ b/internal/runner/manual_admission_client.go @@ -0,0 +1,242 @@ +package runner + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "net" + "strconv" + "sync" + "sync/atomic" + "time" +) + +type manualAuthorityClient struct { + binding ManualAdmissionBinding + conn net.Conn + reader *bufio.Reader + mu sync.Mutex + closed atomic.Bool + sequence uint64 + epoch string + signing ManualSigning + fresh *ManualStartRequest + commitProof *manualFreshCommit +} + +// NewManualAuthority owns conn and snapshots binding. It is an inactive adapter; +// Open does not connect or activate it until the complete gate is integrated. +func NewManualAuthority(conn net.Conn, binding ManualAdmissionBinding) (ManualAuthority, error) { + if conn == nil || binding.Version != 1 || binding.FD != 3 || binding.Profile != ManualAdmissionProfile || !manualHex(binding.InstallationID) || !manualHex(binding.MachineWorkspace) || !manualWorkspace(binding.RunnerWorkspace) || !manualHex(binding.ExecutorID) || !manualHex(binding.Challenge) { + if conn != nil { + conn.Close() + } + return nil, errManualProtocol + } + return &manualAuthorityClient{binding: binding, conn: conn, reader: bufio.NewReaderSize(conn, manualFrameLimit)}, nil +} +func (c *manualAuthorityClient) Close() error { + if c.closed.CompareAndSwap(false, true) { + return c.conn.Close() + } + return nil +} +func (c *manualAuthorityClient) fault() error { c.Close(); return errManualProtocol } + +type manualRequestEnvelope struct { + Version int `json:"version"` + Sequence string `json:"sequence"` + Method string `json:"method"` + Body json.RawMessage `json:"body"` +} +type manualReplyEnvelope struct { + Version int `json:"version"` + Sequence string `json:"sequence"` + Method string `json:"method"` + Status string `json:"status"` + Body json.RawMessage `json:"body"` +} + +func (c *manualAuthorityClient) originalCommit(result ManualStartResult) *manualFreshCommit { + c.mu.Lock() + defer c.mu.Unlock() + if c.commitProof == nil || c.commitProof.receipt != result { + return nil + } + return c.commitProof +} + +// exchange holds one request through validation of the exact reply. A partial +// write or received refusal is never retried. Any uncertain framing/cancellation +// permanently closes the descriptor, including cancellation while waiting. +func (c *manualAuthorityClient) exchange(ctx context.Context, method string, body any, result any, validate func(string) bool) error { + if ctx == nil { + return c.fault() + } + stop := context.AfterFunc(ctx, func() { c.Close() }) + defer stop() + c.mu.Lock() + defer c.mu.Unlock() + if c.closed.Load() || ctx.Err() != nil || c.sequence == ^uint64(0) { + return c.fault() + } + if method == "activate" { + if c.epoch != "" || c.sequence != 0 { + return c.fault() + } + } else if c.epoch == "" { + return c.fault() + } + if method == "commitStart" { + request, ok := body.(ManualStartRequest) + if !ok || c.fresh == nil || request.Epoch != c.epoch || !bytes.Equal(encode(*c.fresh), encode(request)) { + return c.fault() + } + c.fresh = nil // consume before any bytes leave; every uncertain exchange holds + } + if method == "reserve" && c.fresh != nil { + return c.fault() + } + if method == "reserve" { + request := body.(ManualReserveRequest) + var descriptor executionPayloadDescriptor + if json.Unmarshal(request.Payload.Bytes, &descriptor) != nil || ManualSigning(descriptor.Signing) != c.signing { + return c.fault() + } + } + raw, err := json.Marshal(body) + if err != nil || manualDecode(raw, bodyClone(body)) != nil { + return c.fault() + } + requestDigest, err := manualRequestDigest(method, body) + if err != nil { + return c.fault() + } + c.sequence++ + sequence := strconv.FormatUint(c.sequence, 10) + frame, err := json.Marshal(manualRequestEnvelope{1, sequence, method, raw}) + if err != nil || len(frame)+1 > manualFrameLimit { + return c.fault() + } + frame = append(frame, '\n') + until := time.Now().Add(5 * time.Second) + if deadline, ok := ctx.Deadline(); ok && deadline.Before(until) { + until = deadline + } + if c.conn.SetDeadline(until) != nil { + return c.fault() + } + for len(frame) > 0 { + n, e := c.conn.Write(frame) + if e != nil || n <= 0 { + return c.fault() + } + frame = frame[n:] + } + line, err := c.reader.ReadSlice('\n') + if err != nil || len(line) > manualFrameLimit || c.reader.Buffered() != 0 { + return c.fault() + } + var reply manualReplyEnvelope + if manualDecode(line, &reply) != nil || reply.Version != 1 || reply.Sequence != sequence || reply.Method != method { + return c.fault() + } + if reply.Status == "refused" { + var refusal ManualAuthorityRefusal + if manualDecode(reply.Body, &refusal) != nil || !manualRefusal(refusal.Code) { + return c.fault() + } + if ctx.Err() != nil { + return c.fault() + } + return &refusal + } + if reply.Status != "ok" || manualDecode(reply.Body, result) != nil || !validate(requestDigest) || ctx.Err() != nil || c.closed.Load() { + return c.fault() + } + if method == "commitStart" { + r := body.(ManualStartRequest) + out := *result.(*ManualStartResult) + if out.Delivery == "committed" && out.Decision == "start-committed" { + c.commitProof = &manualFreshCommit{client: c, request: r, receipt: out} + } + } + return nil +} +func bodyClone(body any) any { + switch body.(type) { + case ManualActivateRequest: + return new(ManualActivateRequest) + case ManualInspectRequest: + return new(ManualInspectRequest) + case ManualReserveRequest: + return new(ManualReserveRequest) + case ManualStartRequest: + return new(ManualStartRequest) + case ManualHistoryRequest: + return new(ManualHistoryRequest) + } + return new(struct{}) +} +func (c *manualAuthorityClient) Activate(ctx context.Context, r ManualActivateRequest) (out ManualActivateResult, err error) { + if r.Challenge != c.binding.Challenge || !manualSigning(r.Signing) { + return out, c.fault() + } + err = c.exchange(ctx, "activate", r, &out, func(d string) bool { + if out.Delivery != "committed" || !manualPositive(out.Epoch) || out.RequestDigest != d { + return false + } + c.epoch = out.Epoch + c.signing = r.Signing + return true + }) + return +} +func (c *manualAuthorityClient) Inspect(ctx context.Context, r ManualInspectRequest) (out ManualInspectResult, err error) { + if !manualResource(r.Resource) { + return out, c.fault() + } + err = c.exchange(ctx, "inspect", r, &out, func(string) bool { + return out.Delivery == "observed" && manualRegistration(out.Registration) && manualAt(out.At) && (out.Eligible && out.Reason == "eligible" || !out.Eligible && manualBlocked(out.Reason)) + }) + return +} +func (c *manualAuthorityClient) Reserve(ctx context.Context, r ManualReserveRequest) (out ManualReserveResult, err error) { + r.Payload.Bytes = bytes.Clone(r.Payload.Bytes) + if !manualReserve(r) { + return out, c.fault() + } + err = c.exchange(ctx, "reserve", r, &out, func(d string) bool { + if !manualDelivery(out.Delivery) || !manualID(out.OperationID) || !manualID(out.AttemptID) || out.Epoch != c.epoch || out.RequestDigest != d || c.fresh != nil { + return false + } + if out.Delivery == "committed" { + c.fresh = &ManualStartRequest{IntentID: r.IntentID, RawRequestDigest: r.RawRequestDigest, Resource: r.Resource, Registration: r.Registration, Payload: r.Payload, OperationID: out.OperationID, AttemptID: out.AttemptID, Epoch: out.Epoch} + } + return true + }) + return +} +func (c *manualAuthorityClient) CommitStart(ctx context.Context, r ManualStartRequest) (out ManualStartResult, err error) { + r.Payload.Bytes = bytes.Clone(r.Payload.Bytes) + if !manualReserve(ManualReserveRequest{IntentID: r.IntentID, RawRequestDigest: r.RawRequestDigest, Resource: r.Resource, Registration: r.Registration, Payload: r.Payload}) || !manualID(r.OperationID) || !manualID(r.AttemptID) || !manualPositive(r.Epoch) { + return out, c.fault() + } + // Reserve freshness and consumption are checked within the same channel lock + // as the exchange. A recovered reservation cannot continue to commitStart. + err = c.exchange(ctx, "commitStart", r, &out, func(d string) bool { + valid := manualDelivery(out.Delivery) && out.OperationID == r.OperationID && out.AttemptID == r.AttemptID && out.Epoch == r.Epoch && out.RequestDigest == d && manualAt(out.At) && manualHex(out.AuthorityRevision) && (out.Decision == "start-committed" && out.Reason == "start-committed" || out.Decision == "blocked" && manualBlocked(out.Reason)) + return valid + }) + return +} +func (c *manualAuthorityClient) History(ctx context.Context, r ManualHistoryRequest) (out ManualHistoryResult, err error) { + if !manualID(r.IntentID) { + return out, c.fault() + } + err = c.exchange(ctx, "history", r, &out, func(string) bool { + return out.Delivery == "observed" && out.IntentID == r.IntentID && manualID(out.OperationID) && manualID(out.AttemptID) && manualPositive(out.Epoch) && validDigest(out.RequestDigest) && manualAt(out.At) && (out.State == "reserved" && out.Reason == "reserved" || out.State == "start-committed" && out.Reason == "start-committed" || out.State == "blocked" && manualBlocked(out.Reason)) + }) + return +} diff --git a/internal/runner/manual_admission_client_test.go b/internal/runner/manual_admission_client_test.go new file mode 100644 index 0000000..1f1ada2 --- /dev/null +++ b/internal/runner/manual_admission_client_test.go @@ -0,0 +1,420 @@ +package runner + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "os" + "strconv" + "sync" + "testing" + "time" +) + +type manualWireVector struct { + Name string `json:"name"` + Request json.RawMessage `json:"request"` + Reply json.RawMessage `json:"reply"` + RequestDigest string `json:"requestDigest"` + RequestLine string `json:"requestLine"` + ReplyLine string `json:"replyLine"` +} +type manualWireFixture struct { + Synthetic struct { + Boot ManualAdmissionBinding `json:"boot"` + } `json:"synthetic"` + Vectors []manualWireVector `json:"vectors"` + Invalid []struct { + Name string `json:"name"` + Line string `json:"line"` + } `json:"invalidVectors"` +} + +func manualFixture(t *testing.T) manualWireFixture { + t.Helper() + raw, err := os.ReadFile("../../docs/design/manual-admission-wire.json") + if err != nil { + t.Fatal(err) + } + var f manualWireFixture + if json.Unmarshal(raw, &f) != nil { + t.Fatal("fixture") + } + return f +} +func manualVectorBody[T any](t *testing.T, raw []byte) T { + t.Helper() + var envelope manualRequestEnvelope + if manualDecode(raw, &envelope) != nil { + t.Fatal("fixture envelope") + } + var out T + if manualDecode(envelope.Body, &out) != nil { + t.Fatal("fixture body") + } + return out +} +func manualScenario(t *testing.T, vectors []manualWireVector, mutate func(int, []byte) []byte) (ManualAuthority, <-chan error) { + t.Helper() + a, b := net.Pipe() + client, err := NewManualAuthority(a, manualFixture(t).Synthetic.Boot) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { client.Close(); b.Close() }) + done := make(chan error, 1) + go func() { + defer b.Close() + reader := bufio.NewReaderSize(b, manualFrameLimit) + for i, v := range vectors { + line, err := reader.ReadBytes('\n') + if err != nil { + done <- err + return + } + actual, e := canonical(line) + expected, _ := canonical(v.Request) + if e != nil || !bytes.Equal(actual, expected) { + done <- fmt.Errorf("request %d differs: %s", i, line) + return + } + reply, _ := canonical(v.Reply) + if mutate != nil { + reply = mutate(i, reply) + } + if _, err = b.Write(append(reply, '\n')); err != nil { + done <- err + return + } + } + done <- nil + }() + return client, done +} +func TestManualAdmissionWireVectors(t *testing.T) { + f := manualFixture(t) + for _, v := range f.Vectors { + if len(v.Request) == 0 { + continue + } + var envelope manualRequestEnvelope + if manualDecode(v.Request, &envelope) != nil { + t.Fatal(v.Name) + } + var body any + switch envelope.Method { + case "activate": + body = manualVectorBody[ManualActivateRequest](t, v.Request) + case "inspect": + body = manualVectorBody[ManualInspectRequest](t, v.Request) + case "reserve": + body = manualVectorBody[ManualReserveRequest](t, v.Request) + case "commitStart": + body = manualVectorBody[ManualStartRequest](t, v.Request) + case "history": + body = manualVectorBody[ManualHistoryRequest](t, v.Request) + } + got, err := manualRequestDigest(envelope.Method, body) + if err != nil || got != v.RequestDigest { + t.Fatalf("%s digest %s != %s", v.Name, got, v.RequestDigest) + } + } + for _, v := range f.Invalid { + if v.Line == "" { + continue + } + valid := validManualRequestVector([]byte(v.Line)) + var err error + if !valid { + err = errManualProtocol + } + if err == nil { + t.Fatalf("invalid request accepted: %s", v.Name) + } + } +} +func TestManualAdmissionClientExactSequenceAndBindings(t *testing.T) { + f := manualFixture(t) + client, done := manualScenario(t, f.Vectors[:5], nil) + if _, err := client.Activate(context.Background(), manualVectorBody[ManualActivateRequest](t, f.Vectors[0].Request)); err != nil { + t.Fatal(err) + } + if _, err := client.Inspect(context.Background(), manualVectorBody[ManualInspectRequest](t, f.Vectors[1].Request)); err != nil { + t.Fatal(err) + } + if _, err := client.Reserve(context.Background(), manualVectorBody[ManualReserveRequest](t, f.Vectors[2].Request)); err != nil { + t.Fatal(err) + } + if _, err := client.CommitStart(context.Background(), manualVectorBody[ManualStartRequest](t, f.Vectors[3].Request)); err != nil { + t.Fatal(err) + } + history, err := client.History(context.Background(), manualVectorBody[ManualHistoryRequest](t, f.Vectors[4].Request)) + if err != nil || history.Delivery != "observed" || history.RequestDigest == f.Vectors[4].RequestDigest { + t.Fatal("history became permission", err) + } + if err := <-done; err != nil { + t.Fatal(err) + } +} +func TestManualAdmissionClientPoisonsBadReplies(t *testing.T) { + f := manualFixture(t) + cases := map[string]func([]byte) []byte{ + "oversized frame": func(b []byte) []byte { return bytes.Repeat([]byte("x"), manualFrameLimit+1) }, + "incomplete JSON": func(b []byte) []byte { return b[:len(b)-1] }, + "duplicate": func(b []byte) []byte { + return bytes.Replace(b, []byte(`"version":1`), []byte(`"version":1,"version":1`), 1) + }, + "case alias": func(b []byte) []byte { return bytes.Replace(b, []byte(`"sequence"`), []byte(`"Sequence"`), 1) }, + "sequence replay": func(b []byte) []byte { return bytes.Replace(b, []byte(`"sequence":"1"`), []byte(`"sequence":"2"`), 1) }, + "unknown": func(b []byte) []byte { return append([]byte(`{"extra":true,`), b[1:]...) }, + "null": func(b []byte) []byte { + var v map[string]json.RawMessage + _ = json.Unmarshal(b, &v) + v["body"] = json.RawMessage(`null`) + return encode(v) + }, + "epoch overflow": func(b []byte) []byte { + return bytes.Replace(b, []byte(`"epoch":"1"`), []byte(`"epoch":"18446744073709551616"`), 1) + }, + "request digest": func(b []byte) []byte { + return bytes.Replace(b, []byte(f.Vectors[0].RequestDigest), []byte("sha256:"+string(bytes.Repeat([]byte("0"), 64))), 1) + }, + "activation recovered": func(b []byte) []byte { return bytes.Replace(b, []byte(`"committed"`), []byte(`"recovered"`), 1) }, + "trailing frame": func(b []byte) []byte { return append(append(b, '\n'), b...) }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + client, done := manualScenario(t, f.Vectors[:1], func(_ int, b []byte) []byte { return mutate(b) }) + if _, err := client.Activate(context.Background(), manualVectorBody[ManualActivateRequest](t, f.Vectors[0].Request)); err == nil { + t.Fatal("bad reply accepted") + } + if _, err := client.Activate(context.Background(), manualVectorBody[ManualActivateRequest](t, f.Vectors[0].Request)); err == nil { + t.Fatal("poisoned channel reused") + } + <-done + }) + } +} +func TestManualAdmissionClientSemanticRefusals(t *testing.T) { + f := manualFixture(t) + t.Run("eligible reason contradiction", func(t *testing.T) { + client, done := manualScenario(t, f.Vectors[:2], func(i int, b []byte) []byte { + if i == 1 { + return bytes.Replace(b, []byte(`"eligible":true`), []byte(`"eligible":false`), 1) + } + return b + }) + _, err := client.Activate(context.Background(), manualVectorBody[ManualActivateRequest](t, f.Vectors[0].Request)) + if err != nil { + t.Fatal(err) + } + if _, err = client.Inspect(context.Background(), manualVectorBody[ManualInspectRequest](t, f.Vectors[1].Request)); err == nil { + t.Fatal("contradiction") + } + <-done + }) + for _, mode := range []string{"recovered reserve", "changed commit binding", "changed original body digest", "start reason contradiction"} { + t.Run(mode, func(t *testing.T) { + n := 4 + if mode != "start reason contradiction" { + n = 3 + } + client, done := manualScenario(t, f.Vectors[:n], func(i int, b []byte) []byte { + if i == 2 && mode == "recovered reserve" { + return bytes.Replace(b, []byte(`"committed"`), []byte(`"recovered"`), 1) + } + if i == 3 { + return bytes.Replace(b, []byte(`"reason":"start-committed"`), []byte(`"reason":"withdrawn"`), 1) + } + return b + }) + _, err := client.Activate(context.Background(), manualVectorBody[ManualActivateRequest](t, f.Vectors[0].Request)) + if err != nil { + t.Fatal(err) + } + _, err = client.Inspect(context.Background(), manualVectorBody[ManualInspectRequest](t, f.Vectors[1].Request)) + if err != nil { + t.Fatal(err) + } + _, err = client.Reserve(context.Background(), manualVectorBody[ManualReserveRequest](t, f.Vectors[2].Request)) + if err != nil { + t.Fatal(err) + } + request := manualVectorBody[ManualStartRequest](t, f.Vectors[3].Request) + if mode == "changed commit binding" { + request.IntentID = "different" + } + if mode == "changed original body digest" { + request.RawRequestDigest = "sha256:" + string(bytes.Repeat([]byte("0"), 64)) + } + if _, err = client.CommitStart(context.Background(), request); err == nil { + t.Fatal("unsafe continuation") + } + <-done + }) + } +} +func TestManualAdmissionClientCancellationAndPhaseHold(t *testing.T) { + f := manualFixture(t) + for _, phase := range []string{"timeout", "already cancelled", "inspect before activate"} { + t.Run(phase, func(t *testing.T) { + a, b := net.Pipe() + defer b.Close() + client, err := NewManualAuthority(a, f.Synthetic.Boot) + if err != nil { + t.Fatal(err) + } + defer client.Close() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + if phase == "already cancelled" { + cancel() + } + go func() { ioReader := bufio.NewReader(b); _, _ = ioReader.ReadBytes('\n') }() + if phase == "inspect before activate" { + _, err = client.Inspect(ctx, manualVectorBody[ManualInspectRequest](t, f.Vectors[1].Request)) + } else { + _, err = client.Activate(ctx, manualVectorBody[ManualActivateRequest](t, f.Vectors[0].Request)) + } + if err == nil { + t.Fatal("unsafe phase continued") + } + if _, err = client.Activate(context.Background(), manualVectorBody[ManualActivateRequest](t, f.Vectors[0].Request)); err == nil { + t.Fatal("channel revived") + } + }) + } +} +func TestManualAdmissionClientSerializesConcurrentRequests(t *testing.T) { + f := manualFixture(t) + a, b := net.Pipe() + defer b.Close() + client, err := NewManualAuthority(a, f.Synthetic.Boot) + if err != nil { + t.Fatal(err) + } + defer client.Close() + done := make(chan error, 1) + go func() { + reader := bufio.NewReader(b) + for i := 1; i <= 17; i++ { + line, e := reader.ReadBytes('\n') + if e != nil { + done <- e + return + } + var r manualRequestEnvelope + if manualDecode(line, &r) != nil || r.Sequence != strconv.Itoa(i) { + done <- fmt.Errorf("sequence %d", i) + return + } + var result any + if i == 1 { + var request ManualActivateRequest + _ = json.Unmarshal(r.Body, &request) + d, _ := manualRequestDigest(r.Method, request) + result = ManualActivateResult{"committed", "1", d} + } else { + result = ManualInspectResult{"observed", ManualRegistration{"1", "sha256:" + string(bytes.Repeat([]byte("2"), 64))}, true, "eligible", "2026-10-19T12:00:00Z"} + } + reply := encode(manualReplyEnvelope{1, r.Sequence, r.Method, "ok", encode(result)}) + if _, e = b.Write(append(reply, '\n')); e != nil { + done <- e + return + } + } + done <- nil + }() + if _, err = client.Activate(context.Background(), manualVectorBody[ManualActivateRequest](t, f.Vectors[0].Request)); err != nil { + t.Fatal(err) + } + var group sync.WaitGroup + errs := make(chan error, 16) + for i := 0; i < 16; i++ { + group.Add(1) + go func() { + defer group.Done() + _, e := client.Inspect(context.Background(), manualVectorBody[ManualInspectRequest](t, f.Vectors[1].Request)) + errs <- e + }() + } + group.Wait() + close(errs) + for e := range errs { + if e != nil { + t.Fatal(e) + } + } + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func validManualRequestVector(raw []byte) bool { + var r manualRequestEnvelope + if manualDecode(raw, &r) != nil || r.Version != 1 || !manualPositive(r.Sequence) { + return false + } + switch r.Method { + case "activate": + var body ManualActivateRequest + return manualDecode(r.Body, &body) == nil && manualHex(body.Challenge) && manualSigning(body.Signing) + case "inspect": + var body ManualInspectRequest + return manualDecode(r.Body, &body) == nil && manualResource(body.Resource) + case "reserve": + var body ManualReserveRequest + return manualDecode(r.Body, &body) == nil && manualReserve(body) + case "commitStart": + var body ManualStartRequest + return manualDecode(r.Body, &body) == nil && manualReserve(ManualReserveRequest{IntentID: body.IntentID, RawRequestDigest: body.RawRequestDigest, Resource: body.Resource, Registration: body.Registration, Payload: body.Payload}) && manualID(body.OperationID) && manualID(body.AttemptID) && manualPositive(body.Epoch) + case "history": + var body ManualHistoryRequest + return manualDecode(r.Body, &body) == nil && manualID(body.IntentID) + } + return false +} + +func TestManualAdmissionActivationRefusalCannotRetry(t *testing.T) { + f := manualFixture(t) + a, b := net.Pipe() + client, err := NewManualAuthority(a, f.Synthetic.Boot) + if err != nil { + t.Fatal(err) + } + defer client.Close() + defer b.Close() + done := make(chan error, 1) + go func() { + reader := bufio.NewReader(b) + _, err := reader.ReadBytes('\n') + if err != nil { + done <- err + return + } + _, err = b.Write(append(encode(manualReplyEnvelope{1, "1", "activate", "refused", encode(ManualAuthorityRefusal{"authority-unavailable"})}), '\n')) + if err != nil { + done <- err + return + } + line, err := reader.ReadBytes('\n') + if len(line) != 0 || err == nil { + done <- fmt.Errorf("activation retried: %s", line) + return + } + done <- nil + }() + request := manualVectorBody[ManualActivateRequest](t, f.Vectors[0].Request) + if _, err = client.Activate(context.Background(), request); err == nil { + t.Fatal("refusal accepted") + } + if _, err = client.Activate(context.Background(), request); err == nil { + t.Fatal("activation retried") + } + if err = <-done; err != nil { + t.Fatal(err) + } +} diff --git a/internal/runner/manual_admission_demand.go b/internal/runner/manual_admission_demand.go new file mode 100644 index 0000000..ddb25b1 --- /dev/null +++ b/internal/runner/manual_admission_demand.go @@ -0,0 +1,58 @@ +package runner + +import "path/filepath" + +// ManualExecutionDemand is independently retained host custody, not permission. +type ManualExecutionDemand struct { + Version int `json:"version"` + InstallationID string `json:"installationId"` + MachineWorkspace string `json:"machineWorkspace"` + RunnerWorkspace string `json:"runnerWorkspace"` + ExecutorID string `json:"executorId"` + Profile string `json:"profile"` +} + +func (d ManualExecutionDemand) valid() bool { + return d.Version == 1 && manualHex(d.InstallationID) && manualHex(d.MachineWorkspace) && manualWorkspace(d.RunnerWorkspace) && manualHex(d.ExecutorID) && d.Profile == ManualAdmissionProfile +} +func (b ManualAdmissionBinding) demand() ManualExecutionDemand { + return ManualExecutionDemand{1, b.InstallationID, b.MachineWorkspace, b.RunnerWorkspace, b.ExecutorID, b.Profile} +} +func manualDemandDirectory(o ExecutorOwnership) string { + return filepath.Join(filepath.Dir(o.Root), "policy-execution-v1") +} + +func checkManualDemand(o *ExecutorOwnership, binding *ManualAdmissionBinding) error { + if o == nil { + if binding != nil { + return errManualProtocol + } + return nil + } + demand, err := readManualDemand(*o) + if err != nil { + return err + } + if demand == nil { + if binding != nil { + return errManualProtocol + } + return nil + } + if binding == nil || *demand != binding.demand() { + return errManualProtocol + } + return nil +} + +func verifyManualLease(lease *ExecutorLease) error { + if lease == nil { + return errManualProtocol + } + lease.mu.Lock() + defer lease.mu.Unlock() + if lease.file == nil { + return errManualProtocol + } + return verifyExecutorLease(lease) +} diff --git a/internal/runner/manual_admission_demand_other.go b/internal/runner/manual_admission_demand_other.go new file mode 100644 index 0000000..4b7dd8c --- /dev/null +++ b/internal/runner/manual_admission_demand_other.go @@ -0,0 +1,30 @@ +//go:build !linux && !darwin && !freebsd + +package runner + +import ( + "io" + "os" +) + +func readManualDemand(ExecutorOwnership) (*ManualExecutionDemand, error) { + return nil, errManualProtocol +} +func adoptManualPreparationLease(_ ExecutorOwnership, _ string, file *os.File) (*ExecutorLease, error) { + if file != nil { + file.Close() + } + return nil, errManualProtocol +} +func manualPreparationInstanceLock(*ExecutorLease) (*os.File, error) { return nil, errManualProtocol } +func readManualStateFile(*ExecutorLease, string, int64) ([]byte, error) { + return nil, errManualProtocol +} +func writeManualPrepareMarker(*ExecutorLease, []byte) error { return errManualProtocol } +func verifyManualDatabasePath(*ExecutorLease, os.FileInfo) error { return errManualProtocol } +func manualDatabaseTarget(*ExecutorLease) (string, *os.File, error) { + return "", nil, errManualProtocol +} +func manualProcessDatabaseTarget(*ExecutorLease) (string, io.Closer, error) { + return "", nil, errManualProtocol +} diff --git a/internal/runner/manual_admission_demand_unix.go b/internal/runner/manual_admission_demand_unix.go new file mode 100644 index 0000000..1a9ec62 --- /dev/null +++ b/internal/runner/manual_admission_demand_unix.go @@ -0,0 +1,200 @@ +//go:build linux || darwin || freebsd + +package runner + +import ( + "errors" + "io" + "os" + "path/filepath" + "runtime" + "strconv" + "syscall" +) + +func readManualDemand(o ExecutorOwnership) (*ManualExecutionDemand, error) { + path := manualDemandDirectory(o) + if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { + return nil, nil + } + root, _, err := openExecutorDirectory(path) + if err != nil { + return nil, errManualProtocol + } + defer root.Close() + dir, err := root.Open(".") + if err != nil { + return nil, errManualProtocol + } + defer dir.Close() + entries, err := dir.ReadDir(257) + if err != nil && !errors.Is(err, io.EOF) || len(entries) == 0 || len(entries) > 256 { + return nil, errManualProtocol + } + var current *ManualExecutionDemand + for _, entry := range entries { + if !manualHex(entry.Name()) { + return nil, errManualProtocol + } + looked, err := root.Lstat(entry.Name()) + if err != nil || !executorPrivate(looked, false) { + return nil, errManualProtocol + } + f, err := root.OpenFile(entry.Name(), os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return nil, errManualProtocol + } + opened, statErr := f.Stat() + raw, readErr := io.ReadAll(io.LimitReader(f, 4097)) + closeErr := f.Close() + if statErr != nil || readErr != nil || closeErr != nil || len(raw) > 4096 || !executorPrivate(opened, false) || !os.SameFile(looked, opened) { + return nil, errManualProtocol + } + var d ManualExecutionDemand + if manualDecode(raw, &d) != nil || !d.valid() || d.MachineWorkspace != entry.Name() { + return nil, errManualProtocol + } + if d.MachineWorkspace == o.MachineWorkspace { + if d.RunnerWorkspace != o.RunnerWorkspace { + return nil, errManualProtocol + } + copy := d + current = © + } + } + return current, nil +} + +// adoptManualPreparationLease takes an intentional inherited duplicate. The +// trusted parent retains its original continuously. A fresh open must contend, +// while relocking the inherited open description must succeed. Neither check +// identifies a parent PID or substitutes for that trusted custody contract. +func adoptManualPreparationLease(o ExecutorOwnership, statePath string, file *os.File) (*ExecutorLease, error) { + if file == nil { + return nil, errManualProtocol + } + syscall.CloseOnExec(int(file.Fd())) + root, rootChain, err := openExecutorDirectory(o.Root) + if err != nil { + file.Close() + return nil, err + } + state, stateChain, err := openExecutorDirectory(statePath) + if err != nil { + file.Close() + root.Close() + return nil, err + } + l := &ExecutorLease{rootPath: o.Root, statePath: statePath, identity: o.identity(), root: root, state: state, file: file} + fail := func() (*ExecutorLease, error) { l.Close(); return nil, errManualProtocol } + if executorDirectoriesOverlap(rootChain, stateChain) || !executorObjectIdentity(rootChain[len(rootChain)-1], o.RootDevice, o.RootInode) || verifyExecutorLease(l) != nil { + return fail() + } + probe, err := openExecutorLeaseFile(root, o.identity()) + if err != nil { + return fail() + } + contended := syscall.Flock(int(probe.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + probe.Close() // never explicitly unlock a shared open description + if !errors.Is(contended, syscall.EWOULDBLOCK) && !errors.Is(contended, syscall.EAGAIN) { + return fail() + } + if syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil { + return fail() + } + return l, nil +} + +func manualPreparationInstanceLock(lease *ExecutorLease) (*os.File, error) { + f, err := lease.state.OpenFile("runner.lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0600) + if err != nil { + return nil, err + } + info, err := f.Stat() + if err != nil || !executorPrivate(info, false) || syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil { + f.Close() + return nil, errManualProtocol + } + return f, nil +} +func readManualStateFile(lease *ExecutorLease, name string, limit int64) ([]byte, error) { + f, err := lease.state.OpenFile(name, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return nil, err + } + defer f.Close() + info, err := f.Stat() + if err != nil || !executorPrivate(info, false) { + return nil, errManualProtocol + } + raw, err := io.ReadAll(io.LimitReader(f, limit+1)) + if err != nil || int64(len(raw)) > limit { + return nil, errManualProtocol + } + return raw, nil +} +func writeManualPrepareMarker(lease *ExecutorLease, raw []byte) error { + f, err := lease.state.OpenFile(manualPrepareMarker, os.O_CREATE|os.O_EXCL|os.O_WRONLY|syscall.O_NOFOLLOW, 0600) + if err != nil { + return err + } + _, err = f.Write(raw) + if err == nil { + err = f.Sync() + } + closeErr := f.Close() + if err == nil { + err = closeErr + } + if err != nil { + return err + } + dir, err := lease.state.Open(".") + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} +func verifyManualDatabasePath(lease *ExecutorLease, expected os.FileInfo) error { + if verifyManualLease(lease) != nil { + return errManualProtocol + } + held, err := lease.state.OpenFile("runs.sqlite", os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return err + } + defer held.Close() + info, err := held.Stat() + if err != nil || !executorPrivate(info, false) || expected != nil && !os.SameFile(info, expected) { + return errManualProtocol + } + current, err := os.Lstat(filepath.Join(lease.statePath, "runs.sqlite")) + if err != nil || !executorPrivate(current, false) || !os.SameFile(info, current) { + return errManualProtocol + } + return nil +} + +// SQLite needs a filename for its DB/journal/WAL family. Retain an actual +// directory fd and use its OS descriptor path, rather than reusing statePath. +// Refuse hosts without a working descriptor-relative directory path. +func manualDatabaseTarget(lease *ExecutorLease) (string, *os.File, error) { + dir, err := lease.state.Open(".") + if err != nil { + return "", nil, err + } + if runtime.GOOS != "linux" { + dir.Close() + return "", nil, errManualProtocol + } + base := "/proc/self/fd" + path := filepath.Join(base, strconv.FormatUint(uint64(dir.Fd()), 10), "runs.sqlite") + target, err := os.Stat(path) + held, heldErr := lease.state.Stat("runs.sqlite") + if err != nil || heldErr != nil || !executorPrivate(target, false) || !os.SameFile(target, held) { + dir.Close() + return "", nil, errManualProtocol + } + return path, dir, nil +} diff --git a/internal/runner/manual_admission_gate_test.go b/internal/runner/manual_admission_gate_test.go new file mode 100644 index 0000000..3e22cb8 --- /dev/null +++ b/internal/runner/manual_admission_gate_test.go @@ -0,0 +1,181 @@ +//go:build linux || darwin || freebsd + +package runner + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestManualProfileRefusesUnsupportedBeforeActivity(t *testing.T) { + s := manualServiceFixture(t) + b := manualFixture(t).Synthetic.Boot + s.cfg.ExecutorAdmission = &b + checks := map[string]func() error{ + "trigger-create": func() error { _, e := s.configureTrigger("missing", "", 0, TriggerConfig{}); return e }, + "trigger-enable": func() error { _, _, e := s.setTriggerState("missing", 1, false, true); return e }, + "trigger-key": func() error { _, _, e := s.rotateTriggerKey("missing", 1); return e }, + "event": func() error { _, _, e := s.event("missing", "secret", EventDelivery{}); return e }, + "record": func() error { _, _, e := s.deliverRecord("missing", "release", EventDelivery{}); return e }, + "schedule": func() error { return s.scheduleTick(Trigger{}, "", time.Now()) }, + "file": func() error { return s.fileTick(Trigger{}, "", time.Now()) }, + "dispatch-occurrence": func() error { return s.dispatchOccurrence(Occurrence{}, time.Now()) }, + "automatic": func() error { _, e := s.acquireAutomatic(context.Background(), Input{}); return e }, + "planned": func() error { _, e := s.acquirePlannedOperation(context.Background(), PlannedSource{}); return e }, + "prepare": func() error { return s.prepareOccurrence(context.Background()) }, + "prepare-legacy": func() error { return s.prepareLegacyOccurrence(context.Background(), "missing") }, + "input-plan": func() error { _, e := s.nextInput(Input{}, time.Now()); return e }, + "read-source": func() error { _, e := s.readAutomaticFile("/must-not-open"); return e }, + "profile": func() error { _, e := s.registerInputProfile(InputProfile{}); return e }, + "upgrade": func() error { _, e := s.upgradeJob(context.Background(), "missing", UpgradeJobRequest{}); return e }, + } + for name, check := range checks { + t.Run(name, func(t *testing.T) { + if err := check(); err == nil { + t.Fatal("unsupported profile path proceeded") + } + }) + } + if _, err := os.Stat(filepath.Join(s.cfg.Dir, "workflow-attempts")); !os.IsNotExist(err) { + t.Fatal("unsupported path created workflow state") + } +} + +func TestManualGovernedRealRuntimeGate(t *testing.T) { + for _, scenario := range []string{"success", "blocked", "lost-reply", "changed-facts", "changed-config", "changed-lock", "changed-signing", "changed-epoch", "changed-demand", "journal-failure", "start-journal-failure", "recovered-reserve"} { + t.Run(scenario, func(t *testing.T) { + cfg := testConfig(t) + cfg.disableAutomation = true + cfg.disableDispatcher = true + if scenario == "changed-signing" { + cfg.SigningKey = signingKey(t) + } + s, err := Open(cfg) + executorMust(t, err) + defer s.Close() + release := testRelease(t, s) + job, err := s.createJob("governed", release.ID) + executorMust(t, err) + f := executorFixture(t) + old := f.leaf() + f.identity.Workspace = strings.Repeat("c", 64) + executorMust(t, os.Rename(old, f.leaf())) + f.state = cfg.Dir + lease, err := f.acquire() + executorMust(t, err) + s.executor = lease + o := ExecutorOwnership{Root: f.root, MachineWorkspace: f.identity.Workspace, RunnerWorkspace: cfg.Workspace, RootDevice: f.identity.RootDevice, RootInode: f.identity.RootInode, FileDevice: f.identity.FileDevice, FileInode: f.identity.FileInode} + s.cfg.ExecutorOwnership = &o + _, err = s.db.Exec(manualJournalSchema) + executorMust(t, err) + var runID string + commits := 0 + manualGateAuthority(t, s, func(method string, body json.RawMessage) (string, any, bool) { + if method == "reserve" { + if scenario == "journal-failure" { + _, e := s.db.Exec("DROP TABLE manual_attempts") + if e != nil { + return "", nil, true + } + } + if scenario == "recovered-reserve" { + d, _ := manualRequestDigest(method, body) + return "ok", ManualReserveResult{Delivery: "recovered", OperationID: "operation_test", AttemptID: "attempt_test", Epoch: "1", RequestDigest: d}, false + } + } + if method == "commitStart" { + commits++ + if scenario == "start-journal-failure" { + if _, e := s.db.Exec("DROP TABLE manual_attempts"); e != nil { + return "", nil, true + } + } + if scenario == "lost-reply" { + return "", nil, true + } + if scenario == "changed-facts" { + if os.WriteFile(filepath.Join(cfg.Dir, "attempts", runID, "facts.json"), []byte(`{}`), 0600) != nil { + return "", nil, true + } + } + if scenario == "changed-config" || scenario == "changed-lock" { + name := "jpack.json" + if scenario == "changed-lock" { + name = "jpack.lock.json" + } + if os.WriteFile(filepath.Join(cfg.Dir, "attempts", runID, name), []byte("null"), 0600) != nil { + return "", nil, true + } + } + if scenario == "changed-signing" { + if os.WriteFile(cfg.SigningKey, []byte(strings.Repeat("0", 64)+"\n"), 0600) != nil { + return "", nil, true + } + } + if scenario == "changed-demand" { + if os.Remove(filepath.Join(manualDemandDirectory(o), o.MachineWorkspace)) != nil { + return "", nil, true + } + } + if scenario == "changed-epoch" { + d, _ := manualRequestDigest(method, body) + return "ok", ManualStartResult{Delivery: "committed", OperationID: "operation_test", AttemptID: "attempt_test", Epoch: "2", RequestDigest: d, Decision: "start-committed", Reason: "start-committed", At: "2026-10-19T12:00:00Z", AuthorityRevision: strings.Repeat("b", 64)}, false + } + if scenario == "blocked" { + d, _ := manualRequestDigest(method, body) + return "ok", ManualStartResult{Delivery: "committed", OperationID: "operation_test", AttemptID: "attempt_test", Epoch: "1", RequestDigest: d, Decision: "blocked", Reason: "withdrawn", At: "2026-10-19T12:00:00Z", AuthorityRevision: strings.Repeat("b", 64)}, false + } + } + return "", nil, false + }) + executorMust(t, os.Mkdir(manualDemandDirectory(o), 0700)) + executorMust(t, os.WriteFile(filepath.Join(manualDemandDirectory(o), o.MachineWorkspace), encode(s.cfg.ExecutorAdmission.demand()), 0600)) + facts := `{ ` + string(sample().Facts[1:len(sample().Facts)-1]) + `, "n":9007199254740993.00 }` + raw := []byte(`{"facts":` + facts + `,"evidence":` + string(sample().Evidence) + `}`) + intent, err := captureManualIntent("intent_gate", "original-key", raw) + executorMust(t, err) + run, replay, err := s.submitInternalIntent(job.ID, "original-key", intent.input(), nil, intent) + executorMust(t, err) + if replay { + t.Fatal("unexpected replay") + } + runID = run.ID + worked, err := s.dispatchNext(context.Background()) + executorMust(t, err) + if !worked { + t.Fatal("queued governed run not handled") + } + finished, err := s.run(run.ID) + executorMust(t, err) + if scenario == "success" { + if finished.State != "completed" || len(finished.Audit) == 0 { + t.Fatalf("actual Runtime failed: %s %s", finished.State, finished.Problem) + } + actual, e := os.ReadFile(filepath.Join(cfg.Dir, "attempts", run.ID, "facts.json")) + executorMust(t, e) + if string(actual) != facts { + t.Fatal("actual Runtime facts preimage changed") + } + } else { + if finished.State != "failed" || len(finished.Audit) != 0 { + t.Fatalf("uncertain/blocked launch: %s %s", finished.State, finished.Problem) + } + if _, e := os.Stat(filepath.Join(cfg.Dir, "attempts", run.ID, "audit", "evaluations.jsonl")); !os.IsNotExist(e) { + t.Fatalf("operational trail after refusal: %v", e) + } + } + if (scenario == "recovered-reserve" || scenario == "journal-failure") && commits != 0 { + t.Fatal("uncertain reserve/local journal continued to commit") + } + if err := s.startRuntimeAs(exec.Command(s.runtime, "experimental", "evaluate"), manualUtilityInvocation, nil); err == nil { + t.Fatal("alternate operational utility path started") + } + }) + } +} diff --git a/internal/runner/manual_admission_intent.go b/internal/runner/manual_admission_intent.go new file mode 100644 index 0000000..a62741c --- /dev/null +++ b/internal/runner/manual_admission_intent.go @@ -0,0 +1,164 @@ +package runner + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "reflect" + "strings" + "unicode/utf8" +) + +// The private header is only an intent selector. Its syntax or presence is not +// authentication, registration or permission; later reserve/start must match +// Desk's anchored trusted intent against these exact bytes. +type manualIntentInput struct { + intentID, key, request, facts, evidence string + supplied bool + resource *ManualResource + registration *ManualRegistration +} +type manualIntentStored struct { + Version int `json:"version"` + IntentID string `json:"intentId"` + IdempotencyKey string `json:"idempotencyKey"` + RequestDigest string `json:"requestDigest"` + Request []byte `json:"requestBytes"` + Facts []byte `json:"factsBytes"` + EvidencePresence string `json:"evidencePresence"` + Evidence []byte `json:"evidenceBytes,omitempty"` + Resource *ManualResource `json:"resource,omitempty"` + Registration *ManualRegistration `json:"registration,omitempty"` +} + +func captureManualIntent(id, key string, raw []byte) (*manualIntentInput, error) { + if !manualID(id) || len(key) < 1 || len(key) > 128 || strings.IndexFunc(key, func(c rune) bool { return c < 33 || c > 126 }) >= 0 || len(raw) > MaxBody || !utf8.Valid(raw) { + return nil, errManualProtocol + } + if _, err := readInputJSONDepth(raw, 64); err != nil { + return nil, errManualProtocol + } + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil || fields == nil { + return nil, errManualProtocol + } + facts, ok := fields["facts"] + if !ok { + return nil, errManualProtocol + } + evidence, supplied := fields["evidence"] + expectedFields := 1 + if supplied { + expectedFields++ + } + if len(fields) != expectedFields { + return nil, manualUnsupported() + } + input := Input{Facts: bytes.Clone(facts), Evidence: bytes.Clone(evidence)} + if input.validate() != nil { + return nil, errManualProtocol + } + return &manualIntentInput{intentID: id, key: key, request: string(raw), facts: string(facts), evidence: string(evidence), supplied: supplied}, nil +} +func (m *manualIntentInput) input() Input { + return Input{Facts: []byte(m.facts), Evidence: []byte(m.evidence)} +} +func (m *manualIntentInput) stored() *manualIntentStored { + p := "omitted" + if m.supplied { + p = "supplied" + } + return &manualIntentStored{Version: 1, IntentID: m.intentID, IdempotencyKey: m.key, RequestDigest: digest([]byte(m.request)), Request: []byte(m.request), Facts: []byte(m.facts), EvidencePresence: p, Evidence: []byte(m.evidence), Resource: m.resource, Registration: m.registration} +} +func (s manualIntentStored) restore(held Input) (*manualIntentInput, error) { + if s.Version != 1 || s.RequestDigest != digest(s.Request) || held.Source != nil || held.Preparation != nil { + return nil, errManualProtocol + } + restored, err := captureManualIntent(s.IntentID, s.IdempotencyKey, s.Request) + if err != nil { + return nil, err + } + if !bytes.Equal(s.Facts, []byte(restored.facts)) || !bytes.Equal(s.Evidence, []byte(restored.evidence)) || s.EvidencePresence != restored.stored().EvidencePresence || !sameJSON(held.Facts, s.Facts) || (len(held.Evidence) != 0) != (s.EvidencePresence == "supplied") || s.EvidencePresence == "supplied" && !sameJSON(held.Evidence, s.Evidence) { + return nil, errManualProtocol + } + if (s.Resource == nil) != (s.Registration == nil) || s.Resource != nil && (!manualResource(*s.Resource) || !manualRegistration(*s.Registration)) { + return nil, errManualProtocol + } + if s.Resource != nil { + resource, registration := *s.Resource, *s.Registration + restored.resource = &resource + restored.registration = ®istration + } + return restored, nil +} + +var reflectManualStored = reflect.TypeOf(manualIntentStored{}) + +func manualStoredDecode(raw []byte, dst *manualIntentStored) error { + if len(raw) > 4*MaxBody+4096 || !utf8.Valid(raw) { + return errManualProtocol + } + if _, err := readInputJSONDepth(raw, 64); err != nil { + return errManualProtocol + } + if manualShape(raw, reflectManualStored) != nil || json.Unmarshal(raw, dst) != nil { + return errManualProtocol + } + return nil +} +func (s *Service) manualOwnerInput(w http.ResponseWriter, r *http.Request, key string) (Input, *manualIntentInput, error) { + values := r.Header.Values(ManualIntentHeader) + if s.cfg.ExecutorAdmission == nil { + if len(values) != 0 { + return Input{}, nil, &apiError{400, "unexpected_manual_intent", "This Runner has no activated manual admission profile."} + } + var input Input + err := decodeInput(w, r, &input) + return input, nil, err + } + keys := r.Header.Values("Idempotency-Key") + if len(keys) != 1 || keys[0] != key { + return Input{}, nil, &apiError{400, "idempotency_key_required", "Supply exactly one original Idempotency-Key."} + } + if len(values) != 1 { + return Input{}, nil, &apiError{400, "manual_intent_required", "Supply the original host-prepared manual intent selector."} + } + raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, MaxBody)) + if err != nil { + return Input{}, nil, errManualProtocol + } + intent, err := captureManualIntent(values[0], key, raw) + if err != nil { + return Input{}, nil, err + } + return intent.input(), intent, nil +} + +// reservation binds the original host-selector ingress to the frozen descriptor +// without inventing caller identity. Desk still validates the actual intent and +// all current registration/epoch/operation state on the dedicated channel. +func (m *manualIntentInput) reservation(resource ManualResource, registration ManualRegistration, payload ManualPayload) (ManualReserveRequest, error) { + if m == nil { + return ManualReserveRequest{}, errManualProtocol + } + request := ManualReserveRequest{IntentID: m.intentID, RawRequestDigest: digest([]byte(m.request)), Resource: resource, Registration: registration, Payload: payload} + request.Payload.Bytes = bytes.Clone(payload.Bytes) + if !manualReserve(request) { + return ManualReserveRequest{}, errManualProtocol + } + var descriptor executionPayloadDescriptor + _ = json.Unmarshal(payload.Bytes, &descriptor) + if descriptor.Facts.Digest != digest([]byte(m.facts)) || descriptor.Facts.Bytes != len(m.facts) { + return ManualReserveRequest{}, errManualProtocol + } + if m.supplied { + e := descriptor.Evidence.Value + if descriptor.Evidence.Presence != "supplied" || e == nil || e.Digest != digest([]byte(m.evidence)) || e.Bytes != len(m.evidence) { + return ManualReserveRequest{}, errManualProtocol + } + } else if descriptor.Evidence.Presence != "omitted" || descriptor.Evidence.Value != nil { + return ManualReserveRequest{}, errManualProtocol + } + return request, nil +} diff --git a/internal/runner/manual_admission_journal.go b/internal/runner/manual_admission_journal.go new file mode 100644 index 0000000..d75bc50 --- /dev/null +++ b/internal/runner/manual_admission_journal.go @@ -0,0 +1,124 @@ +package runner + +import ( + "context" + "os/exec" + "path/filepath" + "strconv" +) + +type manualAttemptJournal struct { + Version int `json:"version"` + State string `json:"state"` + Request ManualStartRequest `json:"request"` + Receipt *ManualStartResult `json:"receipt,omitempty"` +} + +func (s *Service) inspectManualSubmission(jobID string, m *manualIntentInput) (*manualIntentInput, error) { + if s.authority == nil || m == nil { + return nil, errManualProtocol + } + binding, err := s.manualExecutionBinding(jobID) + if err != nil { + return nil, err + } + resource := manualResourceOf(binding) + observation, err := s.authority.Inspect(context.Background(), ManualInspectRequest{Resource: resource}) + if err != nil { + return nil, err + } + // inspect does not authorize dispatch. Reserve checks the trusted intent and + // current eligibility independently, even after a positive observation. + if !observation.Eligible { + return nil, manualUnsupported() + } + copy := *m + copy.resource = &resource + registration := observation.Registration + copy.registration = ®istration + return ©, nil +} +func (s *Service) retainManualAttempt(run Run, q ManualStartRequest) error { + if run.manual == nil || run.manual.resource == nil || run.manual.registration == nil || q.Resource != *run.manual.resource || q.Registration != *run.manual.registration { + return errManualProtocol + } + record := manualAttemptJournal{Version: 1, State: "reserved", Request: q} + _, err := s.db.Exec("INSERT INTO manual_attempts(run_id,intent_id,record) VALUES(?,?,?)", run.ID, q.IntentID, string(encode(record))) + return err +} +func (s *Service) retainManualStart(runID string, q ManualStartRequest, receipt ManualStartResult) error { + before := manualAttemptJournal{Version: 1, State: "reserved", Request: q} + after := manualAttemptJournal{Version: 1, State: receipt.Decision, Request: q, Receipt: &receipt} + result, err := s.db.Exec("UPDATE manual_attempts SET record=? WHERE run_id=? AND record=?", string(encode(after)), runID, string(encode(before))) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil || n != 1 { + return errManualProtocol + } + return nil +} +func (s *Service) governedEvaluation(ctx context.Context, run Run, r Release) (result, trail []byte, signatures []byte, binding *RunBinding, err error) { + if s.authority == nil || s.cfg.ExecutorAdmission == nil || run.manual == nil || run.manual.resource == nil || run.manual.registration == nil || !manualRelease(r) || run.Trigger != nil { + return nil, nil, nil, nil, errManualProtocol + } + resource := *run.manual.resource + if resource.JobID != run.JobID || resource.ReleaseID != run.ReleaseID || resource.JobRevision != decimalRevision(run.Revision) { + return nil, nil, nil, nil, errManualProtocol + } + // Existing local attempt history is a replay barrier, never permission. + var held int + if e := s.db.QueryRow("SELECT count(*) FROM manual_attempts WHERE run_id=? OR intent_id=?", run.ID, run.manual.intentID).Scan(&held); e != nil || held != 0 { + return nil, nil, nil, nil, errManualProtocol + } + signing := signingInputs{} + if s.expectedKey != nil { + signing = signingInputs{s.cfg.SigningKey, hexPublic(s.expectedKey)} + } + payload, e := freezeExecutionPayload(r, run.manual.input(), signing) + if e != nil { + return nil, nil, nil, nil, e + } + reserve, e := run.manual.reservation(resource, *run.manual.registration, ManualPayload{Profile: executionPayloadProfile, Digest: payload.Digest(), Bytes: payload.Bytes()}) + if e != nil { + return nil, nil, nil, nil, e + } + reserved, e := s.authority.Reserve(ctx, reserve) + if e != nil || reserved.Delivery != "committed" { + return nil, nil, nil, nil, errManualProtocol + } + q := ManualStartRequest{IntentID: reserve.IntentID, RawRequestDigest: reserve.RawRequestDigest, Resource: reserve.Resource, Registration: reserve.Registration, Payload: reserve.Payload, OperationID: reserved.OperationID, AttemptID: reserved.AttemptID, Epoch: reserved.Epoch} + if e = s.retainManualAttempt(run, q); e != nil { + return nil, nil, nil, nil, e + } + bin := filepath.Join(s.cfg.Dir, "runtimes", r.RuntimeDigest[len("sha256:"):]) + invoker := func(ctx context.Context, bin, dir, label string, env []string, args ...string) ([]byte, error) { + return invokeStarting(ctx, bin, dir, label, env, func(cmd *exec.Cmd) error { + prepared, e := s.prepareGovernedLaunch(q, cmd, r, run.manual.input()) + if e != nil { + return e + } + receipt, e := s.authority.CommitStart(ctx, q) + if e != nil { + return e + } + if receipt.Decision != "start-committed" || receipt.Delivery != "committed" { + s.retainManualStart(run.ID, q, receipt) + return errManualProtocol + } + launch, e := prepared.retainFreshCommit(s.authority.originalCommit(receipt), func(r ManualStartResult) error { return s.retainManualStart(run.ID, q, r) }) + if e != nil { + return e + } + return s.startRuntimeAs(cmd, manualOperationalInvocation, launch) + }, args...) + } + return evaluateWithInvoker(ctx, bin, r, run.manual.input(), filepath.Join(s.cfg.Dir, "attempts", run.ID), false, signing, invoker) +} + +func decimalRevision(revision int) string { return strconv.Itoa(revision) } + +func manualResourceOf(binding ManualExecutionBinding) ManualResource { + return ManualResource{JobID: binding.JobID, JobRevision: binding.JobRevision, ReleaseID: binding.ReleaseID, ReleaseProfile: binding.ReleaseProfile, ReleaseDigest: binding.ReleaseDigest} +} diff --git a/internal/runner/manual_admission_launch.go b/internal/runner/manual_admission_launch.go new file mode 100644 index 0000000..5da1d79 --- /dev/null +++ b/internal/runner/manual_admission_launch.go @@ -0,0 +1,128 @@ +package runner + +import ( + "bytes" + "encoding/json" + "os/exec" + "sync" +) + +// These private types are dormant. Phase C must construct preparation only +// after verifying all actual Runtime files against the frozen payload, then +// wire this starter into the common invocation boundary. No current call can +// enable the profile or obtain a permit by presenting a receipt over HTTP. +type manualPreparedLaunch struct { + mu sync.Mutex + used bool + request ManualStartRequest + invocation string + runtimeDigest string + lease *ExecutorLease + client *manualAuthorityClient + material *manualLaunchMaterial +} +type manualLaunch struct { + mu sync.Mutex + consumed bool + invocation, runtimeDigest string + lease *ExecutorLease + client *manualAuthorityClient + material *manualLaunchMaterial +} + +// Only the original channel client stores this pointer after checking a fresh +// reply. A receipt DTO, persisted journal or recovered observation is not it. +type manualFreshCommit struct { + client *manualAuthorityClient + request ManualStartRequest + receipt ManualStartResult +} + +func prepareManualLaunch(request ManualStartRequest, cmd *exec.Cmd, lease *ExecutorLease) (*manualPreparedLaunch, error) { + request.Payload.Bytes = bytes.Clone(request.Payload.Bytes) + if lease == nil || cmd == nil || cmd.Process != nil || !manualReserve(ManualReserveRequest{IntentID: request.IntentID, RawRequestDigest: request.RawRequestDigest, Resource: request.Resource, Registration: request.Registration, Payload: request.Payload}) || !manualID(request.OperationID) || !manualID(request.AttemptID) || !manualPositive(request.Epoch) { + return nil, errManualProtocol + } + var descriptor executionPayloadDescriptor + if json.Unmarshal(request.Payload.Bytes, &descriptor) != nil { + return nil, errManualProtocol + } + invocation, err := manualInvocation(cmd, descriptor.RuntimeDigest) + if err != nil { + return nil, err + } + return &manualPreparedLaunch{request: request, invocation: invocation, runtimeDigest: descriptor.RuntimeDigest, lease: lease}, nil +} +func (p *manualPreparedLaunch) retainFreshCommit(proof *manualFreshCommit, persist func(ManualStartResult) error) (*manualLaunch, error) { + if p == nil { + return nil, errManualProtocol + } + p.mu.Lock() + defer p.mu.Unlock() + if p.used { + return nil, errManualProtocol + } + p.used = true + if proof == nil || proof.client == nil || p.client != proof.client { + return nil, errManualProtocol + } + c := proof.client + c.mu.Lock() + owned := c.commitProof == proof && c.epoch == p.request.Epoch && bytes.Equal(encode(proof.request), encode(p.request)) + if c.commitProof == proof { + c.commitProof = nil + } + c.mu.Unlock() + if !owned { + return nil, errManualProtocol + } + receipt := proof.receipt + expected, err := manualRequestDigest("commitStart", p.request) + if err != nil || receipt.Delivery != "committed" || receipt.Decision != "start-committed" || receipt.Reason != "start-committed" || receipt.OperationID != p.request.OperationID || receipt.AttemptID != p.request.AttemptID || receipt.Epoch != p.request.Epoch || receipt.RequestDigest != expected || !manualAt(receipt.At) || !manualHex(receipt.AuthorityRevision) || persist == nil { + return nil, errManualProtocol + } + if err = persist(receipt); err != nil { + return nil, err + } + return &manualLaunch{invocation: p.invocation, runtimeDigest: p.runtimeDigest, lease: p.lease, client: p.client, material: p.material}, nil +} +func (l *manualLaunch) start(cmd *exec.Cmd, lease *ExecutorLease) error { + if l == nil { + return errManualProtocol + } + l.mu.Lock() + defer l.mu.Unlock() + if l.consumed { + return errManualProtocol + } + l.consumed = true + if lease == nil || lease != l.lease { + return errManualProtocol + } + if l.material != nil && l.material.verify(cmd) != nil { + return errManualProtocol + } + invocation, err := manualInvocation(cmd, l.runtimeDigest) + if err != nil || invocation != l.invocation { + return errManualProtocol + } + _, err = lease.Start(cmd) + return err +} +func manualInvocation(cmd *exec.Cmd, runtimeDigest string) (string, error) { + if cmd == nil || cmd.Process != nil || len(cmd.ExtraFiles) != 0 { + return "", errManualProtocol + } + image, err := readLimit(cmd.Path, 128<<20) + if err != nil || digest(image) != runtimeDigest { + return "", errManualProtocol + } + raw, err := canonical(encode(struct { + Path, Dir string + Args, Env []string + }{cmd.Path, cmd.Dir, cmd.Args, cmd.Env})) + if err != nil { + return "", err + } + return string(raw), nil +} diff --git a/internal/runner/manual_admission_launch_test.go b/internal/runner/manual_admission_launch_test.go new file mode 100644 index 0000000..c82b4aa --- /dev/null +++ b/internal/runner/manual_admission_launch_test.go @@ -0,0 +1,112 @@ +//go:build linux || darwin || freebsd + +package runner + +import ( + "context" + "encoding/json" + "errors" + "os/exec" + "testing" +) + +func TestManualPreparedLaunchFreshDurableAndSingleUse(t *testing.T) { + fixture := manualFixture(t) + s := manualServiceFixture(t) + owned := executorFixture(t) + lease, err := owned.acquire() + executorMust(t, err) + defer lease.Close() + request := manualVectorBody[ManualStartRequest](t, fixture.Vectors[3].Request) + var descriptor executionPayloadDescriptor + executorMust(t, json.Unmarshal(request.Payload.Bytes, &descriptor)) + descriptor.RuntimeDigest = s.runtimeDigest + request.Payload.Bytes, _ = canonical(encode(descriptor)) + request.Payload.Digest = digest(request.Payload.Bytes) + command := func() *exec.Cmd { + cmd := exec.CommandContext(context.Background(), s.runtime, "-test.run=^NoSuchTest$") + cmd.Env = []string{"LANG=C"} + cmd.Dir = s.cfg.Dir + return cmd + } + receipt := func() ManualStartResult { + d, _ := manualRequestDigest("commitStart", request) + return ManualStartResult{"committed", request.OperationID, request.AttemptID, request.Epoch, d, "start-committed", "start-committed", "2026-10-19T12:00:00Z", fixture.Synthetic.Boot.InstallationID} + } + proofFor := func(p *manualPreparedLaunch, r ManualStartResult) *manualFreshCommit { + c := &manualAuthorityClient{epoch: request.Epoch} + p.client = c + proof := &manualFreshCommit{client: c, request: request, receipt: r} + c.commitProof = proof + return proof + } + for _, mode := range []string{"copied-proof", "other-session", "stale-epoch"} { + t.Run(mode, func(t *testing.T) { + prepared, err := prepareManualLaunch(request, command(), lease) + executorMust(t, err) + proof := proofFor(prepared, receipt()) + switch mode { + case "copied-proof": + copy := *proof + proof = © + case "other-session": + prepared.client = &manualAuthorityClient{epoch: request.Epoch} + case "stale-epoch": + proof.client.epoch = "999" + } + if _, err := prepared.retainFreshCommit(proof, func(ManualStartResult) error { t.Fatal("invalid proof reached journal"); return nil }); err == nil { + t.Fatal("nonoriginal session proof minted") + } + }) + } + t.Run("persistence failure", func(t *testing.T) { + prepared, err := prepareManualLaunch(request, command(), lease) + executorMust(t, err) + if launch, err := prepared.retainFreshCommit(proofFor(prepared, receipt()), func(ManualStartResult) error { return errors.New("disk uncertainty") }); err == nil || launch != nil { + t.Fatal("failed persistence minted launch") + } + if _, err = prepared.retainFreshCommit(proofFor(prepared, receipt()), func(ManualStartResult) error { return nil }); err == nil { + t.Fatal("preparation reused after uncertainty") + } + }) + t.Run("recovered receipt", func(t *testing.T) { + prepared, err := prepareManualLaunch(request, command(), lease) + executorMust(t, err) + r := receipt() + r.Delivery = "recovered" + if _, err = prepared.retainFreshCommit(proofFor(prepared, r), func(ManualStartResult) error { t.Fatal("recovered persistence"); return nil }); err == nil { + t.Fatal("recovered receipt minted") + } + }) + t.Run("changed invocation consumes", func(t *testing.T) { + prepared, err := prepareManualLaunch(request, command(), lease) + executorMust(t, err) + launch, err := prepared.retainFreshCommit(proofFor(prepared, receipt()), func(ManualStartResult) error { return nil }) + executorMust(t, err) + changed := command() + changed.Args = append(changed.Args, "-test.v") + if err = launch.start(changed, lease); err == nil { + changed.Wait() + t.Fatal("changed invocation launched") + } + if err = launch.start(command(), lease); err == nil { + t.Fatal("consumed launch reused") + } + }) + t.Run("one actual start", func(t *testing.T) { + cmd := command() + prepared, err := prepareManualLaunch(request, cmd, lease) + executorMust(t, err) + persisted := false + launch, err := prepared.retainFreshCommit(proofFor(prepared, receipt()), func(ManualStartResult) error { persisted = true; return nil }) + executorMust(t, err) + if !persisted { + t.Fatal("not durable") + } + executorMust(t, launch.start(cmd, lease)) + executorMust(t, cmd.Wait()) + if err = launch.start(command(), lease); err == nil { + t.Fatal("duplicate process started") + } + }) +} diff --git a/internal/runner/manual_admission_material.go b/internal/runner/manual_admission_material.go new file mode 100644 index 0000000..b92391a --- /dev/null +++ b/internal/runner/manual_admission_material.go @@ -0,0 +1,87 @@ +package runner + +import ( + "bytes" + "encoding/hex" + "os" + "os/exec" + "path/filepath" +) + +func hexPublic(raw []byte) string { return hex.EncodeToString(raw) } + +// All retained preimages are immutable strings. The common starter repeats this +// verification after the authority commitment; any change consumes/refuses. +type manualLaunchMaterial struct { + files map[string]string + evidence bool + config Config + publicKey string + resource func() error +} + +func (s *Service) prepareGovernedLaunch(q ManualStartRequest, cmd *exec.Cmd, r Release, in Input) (*manualPreparedLaunch, error) { + if s.authority == nil || s.cfg.ExecutorAdmission == nil || !manualRelease(r) || in.Source != nil || in.Preparation != nil { + return nil, errManualProtocol + } + if verifyManualLease(s.executor) != nil { + return nil, errManualProtocol + } + signing := signingInputs{} + if s.expectedKey != nil { + signing = signingInputs{s.cfg.SigningKey, hexPublic(s.expectedKey)} + } + payload, err := freezeExecutionPayload(r, in, signing) + if err != nil || q.Payload.Digest != payload.Digest() || !bytes.Equal(q.Payload.Bytes, payload.Bytes()) { + return nil, errManualProtocol + } + m := &manualLaunchMaterial{files: map[string]string{"pack.json": r.Pack, "jpack.json": r.Config, "jpack.lock.json": r.Lock, "facts.json": string(in.Facts)}, evidence: len(in.Evidence) > 0, config: s.cfg, publicKey: signing.publicKey} + m.resource = func() error { + binding, e := s.manualExecutionBinding(q.Resource.JobID) + if e != nil || manualResourceOf(binding) != q.Resource { + return errManualProtocol + } + return nil + } + if m.evidence { + m.files["evidence.json"] = string(in.Evidence) + } + if m.verify(cmd) != nil { + return nil, errManualProtocol + } + p, err := prepareManualLaunch(q, cmd, s.executor) + if err != nil { + return nil, err + } + p.client = s.authority + p.material = m + return p, nil +} + +func (m *manualLaunchMaterial) verify(cmd *exec.Cmd) error { + if m == nil || cmd == nil || !filepath.IsAbs(cmd.Dir) { + return errManualProtocol + } + if checkManualDemand(m.config.ExecutorOwnership, m.config.ExecutorAdmission) != nil { + return errManualProtocol + } + if m.resource == nil || m.resource() != nil { + return errManualProtocol + } + for name, want := range m.files { + got, err := readLimit(filepath.Join(cmd.Dir, name), MaxBody) + if err != nil || string(got) != want { + return errManualProtocol + } + } + if !m.evidence { + if _, err := os.Lstat(filepath.Join(cmd.Dir, "evidence.json")); !os.IsNotExist(err) { + return errManualProtocol + } + } + key, err := expectedSigningKey(m.config) + if err != nil || hexPublic(key) != m.publicKey { + return errManualProtocol + } + return nil +} diff --git a/internal/runner/manual_admission_other.go b/internal/runner/manual_admission_other.go new file mode 100644 index 0000000..344bedb --- /dev/null +++ b/internal/runner/manual_admission_other.go @@ -0,0 +1,9 @@ +//go:build !linux && !darwin && !freebsd + +package runner + +func manualAdmissionPlatformSupported() bool { return false } + +func OpenInheritedManualAuthority(ManualAdmissionBinding) (ManualAuthority, error) { + return nil, errManualProtocol +} diff --git a/internal/runner/manual_admission_phase_a_test.go b/internal/runner/manual_admission_phase_a_test.go new file mode 100644 index 0000000..a5db84a --- /dev/null +++ b/internal/runner/manual_admission_phase_a_test.go @@ -0,0 +1,260 @@ +package runner + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func manualServiceFixture(t *testing.T) *Service { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err = os.Chmod(dir, 0700); err != nil { + t.Fatal(err) + } + s, err := Open(Config{Dir: dir, Runtime: os.Args[0], Workspace: "manual-test", Owner: "local-owner", disableAutomation: true, disableDispatcher: true}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(s.Close) + return s +} +func manualSavedJob(t *testing.T, s *Service) Job { + t.Helper() + pack, err := os.ReadFile("testdata/triage.pack.json") + if err != nil { + t.Fatal(err) + } + var doc struct{ ID, Version, Title string } + _ = json.Unmarshal(pack, &doc) + release := Release{SchemaVersion: "1", ID: "release_manual", Pack: string(pack), PackDigest: digest(pack), PackID: doc.ID, PackVersion: doc.Version, Title: doc.Title, RuntimeDigest: s.runtimeDigest, CreatedAt: "2026-10-19T12:00:00Z", Config: `{"configVersion":"3","packs":{"target":{"path":"pack.json"}}}`, Lock: `{}`, Validation: json.RawMessage(`{}`), Sample: sample(), Preview: json.RawMessage(`{}`), Tests: "not-run"} + if err = s.saveRelease(release); err != nil { + t.Fatal(err) + } + job, err := s.createJob("Manual binding", release.ID) + if err != nil { + t.Fatal(err) + } + return job +} +func TestManualExecutionBindingOwnerExactExport(t *testing.T) { + s := manualServiceFixture(t) + job := manualSavedJob(t, s) + request := httptest.NewRequest(http.MethodGet, "/v1/jobs/"+job.ID+"/execution-binding", nil) + denied := httptest.NewRecorder() + s.Handler("owner-secret").ServeHTTP(denied, request) + if denied.Code != 401 { + t.Fatalf("unauthorized %d", denied.Code) + } + request.Header.Set("Authorization", "Bearer owner-secret") + response := httptest.NewRecorder() + s.Handler("owner-secret").ServeHTTP(response, request) + if response.Code != 200 { + t.Fatalf("binding %d %s", response.Code, response.Body.String()) + } + var binding ManualExecutionBinding + if json.Unmarshal(response.Body.Bytes(), &binding) != nil { + t.Fatal("binding JSON") + } + release, err := s.release(job.ReleaseID) + if err != nil { + t.Fatal(err) + } + expected, err := executionPayloadCanonical(release, maxExecutionRelease) + if err != nil { + t.Fatal(err) + } + if binding.Version != 1 || binding.Workspace != s.cfg.Workspace || binding.JobID != job.ID || binding.JobRevision != "1" || binding.ReleaseProfile != runnerCanonicalProfile || !bytes.Equal(binding.ReleaseBytes, expected) || binding.ReleaseDigest != digest(expected) { + t.Fatal("canonical binding changed") + } + binding.ReleaseBytes[0] = 'x' + again, err := s.manualExecutionBinding(job.ID) + if err != nil || !bytes.Equal(again.ReleaseBytes, expected) { + t.Fatal("export mutability", err) + } + if _, err = s.db.Exec("INSERT INTO triggers(id,job_id,record)VALUES(?,?,?)", "trg_manual", job.ID, `{}`); err != nil { + t.Fatal(err) + } + if _, err = s.manualExecutionBinding(job.ID); err == nil { + t.Fatal("paused/unsupported trigger exported") + } +} +func TestManualIntentDurableExactPreimages(t *testing.T) { + for _, facts := range []string{`false`, `0`, `null`, `{ "n":9007199254740993.00, "other":1e+2,"flag":false,"null":null,"unicode":"\u003c" }`} { + t.Run(facts, func(t *testing.T) { + raw := []byte("{\n \"facts\" : " + facts + "\n}") + intent, err := captureManualIntent("intent_test", "original-key", raw) + if err != nil { + t.Fatal(err) + } + raw[0] = 'x' + run := Run{SchemaVersion: "1", ID: "run_manual", Input: intent.input(), manual: intent} + encoded := runText(run) + restored, err := decodeRun([]byte(encoded)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(restored.Input.Facts, []byte(facts)) || restored.manual.key != "original-key" || restored.manual.intentID != "intent_test" || restored.manual.supplied || !bytes.Equal(restored.manual.stored().Request, []byte(intent.request)) { + t.Fatal("exact preimages changed") + } + if bytes.Contains(encode(served(restored)), []byte("manualAdmission")) || bytes.Contains(encode(verificationExport(Release{}, restored, 2)), []byte("requestBytes")) { + t.Fatal("private ingress metadata leaked into public export") + } + var wire map[string]json.RawMessage + _ = json.Unmarshal([]byte(encoded), &wire) + var stored manualIntentStored + _ = json.Unmarshal(wire["manualAdmission"], &stored) + stored.Facts = []byte(`true`) + wire["manualAdmission"] = encode(stored) + if _, err := decodeRun(encode(wire)); err == nil { + t.Fatal("changed preimage accepted") + } + }) + } + raw := []byte(`{ "facts": { "n": 1.00 }, "evidence": {"x":"unknown"} }`) + intent, err := captureManualIntent("intent_test", "original-key", raw) + if err != nil { + t.Fatal(err) + } + restored, err := decodeRun([]byte(runText(Run{Input: intent.input(), manual: intent}))) + if err != nil || !bytes.Equal(restored.Input.Evidence, []byte(`{"x":"unknown"}`)) || !restored.manual.supplied { + t.Fatal("evidence presence/preimage", err) + } + for _, raw := range []string{`{"facts":{},"source":null}`, `{"facts":{},"preparation":{}}`, `{"Facts":{}}`, `{"facts":{},"facts":{}}`, `{"facts":{},"evidence":null}`, `{"facts":{},"caller":"admin"}`} { + if _, err := captureManualIntent("intent_test", "key", []byte(raw)); err == nil { + t.Fatalf("unsupported input accepted %s", raw) + } + } +} +func TestManualAdmissionHeaderSelectorAndDormantPaths(t *testing.T) { + s := manualServiceFixture(t) + request := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"facts":{}}`)) + request.Header.Set(ManualIntentHeader, "intent_forged") + if _, _, err := s.manualOwnerInput(httptest.NewRecorder(), request, "key"); err == nil { + t.Fatal("unconfigured private header accepted") + } + binding := manualFixture(t).Synthetic.Boot + s.cfg.ExecutorAdmission = &binding + request = httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{ "facts": { "n":9007199254740993.00 } }`)) + request.Header.Set(ManualIntentHeader, "intent_host_selector") + request.Header.Set("Idempotency-Key", "original-key") + input, intent, err := s.manualOwnerInput(httptest.NewRecorder(), request, "original-key") + if err != nil || intent == nil || !bytes.Equal(input.Facts, []byte(`{ "n":9007199254740993.00 }`)) { + t.Fatal("ingress lost original bytes", err) + } + if _, _, err = s.submit("missing-job", "key", Input{Facts: json.RawMessage(`{}`)}); err == nil { + t.Fatal("old queue ingress bypass") + } + if _, err = s.workflowDriver(nil).Acquire(context.Background(), workflowAcquisition{}); err == nil { + t.Fatal("workflow acquired") + } + if _, err = s.workflowDriver(nil).Evaluate(context.Background(), Release{}, Input{}, "should-not-create"); err == nil { + t.Fatal("workflow evaluated") + } + if _, err = s.upgradeJob(context.Background(), "missing-job", UpgradeJobRequest{}); err == nil { + t.Fatal("upgrade bypass") + } + if _, _, _, _, err = s.evaluate(context.Background(), Release{}, Input{}, "should-not-create", false); err == nil { + t.Fatal("alternate operational evaluator bypass") + } +} +func TestManualAdmissionOpenRefusesUnsafeOwnershipBeforeSideEffects(t *testing.T) { + f := manualFixture(t) + o := &ExecutorOwnership{Root: "/never-opened", MachineWorkspace: f.Synthetic.Boot.MachineWorkspace, RunnerWorkspace: f.Synthetic.Boot.RunnerWorkspace, RootInode: 1, FileInode: 1} + state := filepath.Join(t.TempDir(), "not-created") + cfg := Config{Dir: state, Runtime: "/missing-runtime", Workspace: f.Synthetic.Boot.RunnerWorkspace, Owner: "owner", ExecutorOwnership: o, ExecutorAdmission: &f.Synthetic.Boot} + if _, err := Open(cfg); err == nil || ManualAdmissionAvailable() && err == errManualInactive || !ManualAdmissionAvailable() && err != errManualInactive { + t.Fatalf("unsafe ownership or unsupported profile accepted: %v", err) + } + if _, err := os.Stat(state); !os.IsNotExist(err) { + t.Fatal("unsafe ownership touched state", err) + } +} + +func TestManualIntentReservationBindsOriginalBodyAndPayload(t *testing.T) { + f := manualFixture(t) + request := manualVectorBody[ManualReserveRequest](t, f.Vectors[2].Request) + rawFile, err := os.ReadFile("../../docs/design/manual-admission-wire.json") + if err != nil { + t.Fatal(err) + } + var rawFixture struct { + Synthetic struct { + Request []byte `json:"manualRequestBytes"` + } `json:"synthetic"` + } + if json.Unmarshal(rawFile, &rawFixture) != nil { + t.Fatal("fixture") + } + intent, err := captureManualIntent(request.IntentID, "original-key", rawFixture.Synthetic.Request) + if err != nil { + t.Fatal(err) + } + actual, err := intent.reservation(request.Resource, request.Registration, request.Payload) + if err != nil || !bytes.Equal(encode(actual), encode(request)) { + t.Fatal("trusted body binding", err) + } + changed, err := captureManualIntent(request.IntentID, "original-key", append([]byte(" "), rawFixture.Synthetic.Request...)) + if err != nil { + t.Fatal(err) + } + other, err := changed.reservation(request.Resource, request.Registration, request.Payload) + if err != nil || other.RawRequestDigest == request.RawRequestDigest { + t.Fatal("outer body bytes did not bind", err) + } + intent.facts = "{}" + if _, err = intent.reservation(request.Resource, request.Registration, request.Payload); err == nil { + t.Fatal("changed facts bound to original payload") + } +} + +func TestManualIntentSQLiteRestoreAndNoDowngrade(t *testing.T) { + service := manualServiceFixture(t) + job := manualSavedJob(t, service) + binding := manualFixture(t).Synthetic.Boot + service.cfg.ExecutorAdmission = &binding + manualGateAuthority(t, service, nil) + raw := []byte(`{ "facts": { "number":9007199254740993.00,"flag":false }, "evidence": { "x":"unknown" } }`) + intent, err := captureManualIntent("intent_sqlite", "original-key", raw) + if err != nil { + t.Fatal(err) + } + accepted, replayed, err := service.submitInternalIntent(job.ID, "original-key", intent.input(), nil, intent) + if err != nil || replayed { + t.Fatal("queue", err) + } + stored, err := service.run(accepted.ID) + if err != nil || !bytes.Equal(stored.Input.Facts, []byte(intent.facts)) || !bytes.Equal(stored.Input.Evidence, []byte(intent.evidence)) { + t.Fatal("SQLite changed preimages", err) + } + changed, err := captureManualIntent("intent_sqlite", "original-key", append([]byte(" "), raw...)) + if err != nil { + t.Fatal(err) + } + if _, _, err = service.submitInternalIntent(job.ID, "original-key", changed.input(), nil, changed); err == nil { + t.Fatal("same original key accepted changed outer body") + } + cfg := service.cfg + cfg.ExecutorAdmission = nil + service.Close() + restored, err := Open(cfg) + if err != nil { + t.Fatal(err) + } + defer restored.Close() + held, err := restored.run(accepted.ID) + if err != nil || !bytes.Equal(held.Input.Facts, []byte(intent.facts)) || !bytes.Equal(held.Input.Evidence, []byte(intent.evidence)) || held.manual == nil || held.manual.request != intent.request { + t.Fatal("restore changed exact ingress", err) + } + if worked, err := restored.dispatchNext(context.Background()); err == nil || worked { + t.Fatal("restored manual entry escaped through unconfigured dispatch") + } +} diff --git a/internal/runner/manual_admission_runtime_linux_test.go b/internal/runner/manual_admission_runtime_linux_test.go new file mode 100644 index 0000000..be3e636 --- /dev/null +++ b/internal/runner/manual_admission_runtime_linux_test.go @@ -0,0 +1,131 @@ +//go:build linux + +package runner + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func TestManualGovernedActualRuntimeHasNoAuthorityFD(t *testing.T) { + cfg := testConfig(t) + cfg.disableAutomation = true + cfg.disableDispatcher = true + s, err := Open(cfg) + executorMust(t, err) + defer s.Close() + r := testRelease(t, s) + job, err := s.createJob("fd-real", r.ID) + executorMust(t, err) + f := executorFixture(t) + old := f.leaf() + f.identity.Workspace = strings.Repeat("d", 64) + executorMust(t, os.Rename(old, f.leaf())) + f.state = cfg.Dir + s.executor, err = f.acquire() + executorMust(t, err) + o := ExecutorOwnership{f.root, f.identity.Workspace, cfg.Workspace, f.identity.RootDevice, f.identity.RootInode, f.identity.FileDevice, f.identity.FileInode} + s.cfg.ExecutorOwnership = &o + _, err = s.db.Exec(manualJournalSchema) + executorMust(t, err) + syscall.ForkLock.RLock() + pair, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err == nil { + syscall.CloseOnExec(pair[0]) + syscall.CloseOnExec(pair[1]) + } + syscall.ForkLock.RUnlock() + executorMust(t, err) + left, right := os.NewFile(uintptr(pair[0]), "runner-authority"), os.NewFile(uintptr(pair[1]), "host-authority") + leftInfo, err := left.Stat() + executorMust(t, err) + rightInfo, err := right.Stat() + executorMust(t, err) + a, err := net.FileConn(left) + executorMust(t, err) + b, err := net.FileConn(right) + executorMust(t, err) + left.Close() + right.Close() + var trail *os.File + var runID string + ready := make(chan error, 1) + manualGateAuthorityConnections(t, s, a, b, func(method string, _ json.RawMessage) (string, any, bool) { + if method == "commitStart" { + path := filepath.Join(cfg.Dir, "attempts", runID, "audit") + e := os.Mkdir(path, 0700) + if e == nil { + trail, e = os.OpenFile(filepath.Join(path, "evaluations.jsonl"), os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600) + } + if e == nil { + e = syscall.Flock(int(trail.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + } + ready <- e + if e != nil { + return "", nil, true + } + } + return "", nil, false + }) + executorMust(t, os.Mkdir(manualDemandDirectory(o), 0700)) + executorMust(t, os.WriteFile(filepath.Join(manualDemandDirectory(o), o.MachineWorkspace), encode(s.cfg.ExecutorAdmission.demand()), 0600)) + raw := []byte(`{"facts":` + string(sample().Facts) + `,"evidence":` + string(sample().Evidence) + `}`) + intent, err := captureManualIntent("intent_fd_real", "fd-key", raw) + executorMust(t, err) + run, _, err := s.submitInternalIntent(job.ID, "fd-key", intent.input(), nil, intent) + executorMust(t, err) + runID = run.ID + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { _, e := s.dispatchNext(ctx); done <- e }() + select { + case e := <-ready: + executorMust(t, e) + case <-time.After(5 * time.Second): + t.Fatal("commit boundary not reached") + } + defer func() { + trail.Close() + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("actual Runtime did not reap") + } + }() + image, err := os.Stat(s.runtime) + executorMust(t, err) + audit, err := trail.Stat() + executorMust(t, err) + pid := waitOwnedRuntimeAudit(t, os.Getpid(), image, audit, f.leaf()) + fds, err := os.ReadDir(fmt.Sprintf("/proc/%d/fd", pid)) + executorMust(t, err) + for _, fd := range fds { + info, e := os.Stat(fmt.Sprintf("/proc/%d/fd/%s", pid, fd.Name())) + if e == nil && (os.SameFile(info, leftInfo) || os.SameFile(info, rightInfo)) { + t.Fatal("actual pinned Runtime inherited authority endpoint", fd.Name()) + } + } + executorMust(t, trail.Close()) + select { + case e := <-done: + executorMust(t, e) + done <- nil + case <-time.After(5 * time.Second): + t.Fatal("actual Runtime failed after audit release") + } + finished, err := s.run(runID) + executorMust(t, err) + if finished.State != "completed" { + t.Fatal(finished.State, finished.Problem) + } +} diff --git a/internal/runner/manual_admission_test_authority_test.go b/internal/runner/manual_admission_test_authority_test.go new file mode 100644 index 0000000..c7aacaf --- /dev/null +++ b/internal/runner/manual_admission_test_authority_test.go @@ -0,0 +1,85 @@ +package runner + +import ( + "bufio" + "context" + "encoding/json" + "net" + "strings" + "testing" +) + +// This is a transport peer for Runner gate tests, not Desk Policy authority. +// Combined tests use the real anchored Desk core separately. +func manualGateAuthority(t *testing.T, s *Service, change func(string, json.RawMessage) (string, any, bool)) { + t.Helper() + a, b := net.Pipe() + manualGateAuthorityConnections(t, s, a, b, change) +} + +func manualGateAuthorityConnections(t *testing.T, s *Service, a, b net.Conn, change func(string, json.RawMessage) (string, any, bool)) { + t.Helper() + binding := manualFixture(t).Synthetic.Boot + binding.RunnerWorkspace = s.cfg.Workspace + if s.cfg.ExecutorOwnership != nil { + binding.MachineWorkspace = s.cfg.ExecutorOwnership.MachineWorkspace + } + authority, err := NewManualAuthority(a, binding) + if err != nil { + t.Fatal(err) + } + s.authority = authority.(*manualAuthorityClient) + s.cfg.ExecutorAdmission = &binding + t.Cleanup(func() { a.Close(); b.Close() }) + go func() { + defer b.Close() + reader := bufio.NewReader(b) + for { + line, err := reader.ReadBytes('\n') + if err != nil { + return + } + var frame manualRequestEnvelope + if manualDecode(line, &frame) != nil { + return + } + d, err := manualRequestDigest(frame.Method, frame.Body) + if err != nil { + return + } + var body any + switch frame.Method { + case "activate": + body = ManualActivateResult{Delivery: "committed", Epoch: "1", RequestDigest: d} + case "inspect": + body = ManualInspectResult{Delivery: "observed", Registration: ManualRegistration{Revision: "1", Digest: "sha256:" + strings.Repeat("a", 64)}, Eligible: true, Reason: "eligible", At: "2026-10-19T12:00:00Z"} + case "reserve": + body = ManualReserveResult{Delivery: "committed", OperationID: "operation_test", AttemptID: "attempt_test", Epoch: "1", RequestDigest: d} + case "commitStart": + body = ManualStartResult{Delivery: "committed", OperationID: "operation_test", AttemptID: "attempt_test", Epoch: "1", RequestDigest: d, Decision: "start-committed", Reason: "start-committed", At: "2026-10-19T12:00:00Z", AuthorityRevision: strings.Repeat("b", 64)} + default: + return + } + status := "ok" + if change != nil { + if next, replacement, drop := change(frame.Method, frame.Body); drop { + return + } else if replacement != nil { + status = next + body = replacement + } + } + if _, err = b.Write(append(encode(manualReplyEnvelope{1, frame.Sequence, frame.Method, status, encode(body)}), '\n')); err != nil { + return + } + } + }() + signing := ManualSigning{Mode: "off"} + if s.expectedKey != nil { + signing = ManualSigning{Mode: "ed25519", PublicKey: hexPublic(s.expectedKey)} + } + _, err = s.authority.Activate(context.Background(), ManualActivateRequest{Challenge: binding.Challenge, Signing: signing}) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/runner/manual_admission_unix.go b/internal/runner/manual_admission_unix.go new file mode 100644 index 0000000..1e8eaf2 --- /dev/null +++ b/internal/runner/manual_admission_unix.go @@ -0,0 +1,42 @@ +//go:build linux || darwin || freebsd + +package runner + +import ( + "net" + "os" + "syscall" +) + +func manualAdmissionPlatformSupported() bool { return true } + +// OpenInheritedManualAuthority takes only the explicitly negotiated Unix stream +// socket. net.FileConn duplicates it with close-on-exec; the inherited original +// is marked first, then closed on every path. No descriptor reaches Runtime. +func OpenInheritedManualAuthority(binding ManualAdmissionBinding) (ManualAuthority, error) { + if binding.FD != 3 { + return nil, errManualProtocol + } + file := os.NewFile(uintptr(binding.FD), "manual-authority") + if file == nil { + return nil, errManualProtocol + } + defer file.Close() + syscall.CloseOnExec(binding.FD) + address, err := syscall.Getsockname(binding.FD) + if err != nil { + return nil, errManualProtocol + } + if _, ok := address.(*syscall.SockaddrUnix); !ok { + return nil, errManualProtocol + } + kind, err := syscall.GetsockoptInt(binding.FD, syscall.SOL_SOCKET, syscall.SO_TYPE) + if err != nil || kind != syscall.SOCK_STREAM { + return nil, errManualProtocol + } + conn, err := net.FileConn(file) + if err != nil { + return nil, errManualProtocol + } + return NewManualAuthority(conn, binding) +} diff --git a/internal/runner/manual_admission_unix_test.go b/internal/runner/manual_admission_unix_test.go new file mode 100644 index 0000000..7063d01 --- /dev/null +++ b/internal/runner/manual_admission_unix_test.go @@ -0,0 +1,219 @@ +//go:build linux || darwin || freebsd + +package runner + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "runtime" + "strconv" + "syscall" + "testing" + "time" +) + +func TestManualInheritedAuthorityDescriptorHelper(t *testing.T) { + role := os.Getenv("JPACK_MANUAL_FD_TEST") + if role == "" { + return + } + if role == "runtime" { + leaseFD := uintptr(3) + if value := os.Getenv("JPACK_EXPECTED_LEASE_FD"); value != "" { + parsed, err := strconv.ParseUint(value, 10, 32) + executorMust(t, err) + leaseFD = uintptr(parsed) + } + info, err := os.NewFile(leaseFD, "inherited-lease").Stat() + if err != nil || !info.Mode().IsRegular() { + t.Fatal("Runtime did not inherit ownership file", err) + } + expected, err := os.Stat(os.Getenv("JPACK_EXPECTED_OWNERSHIP")) + if err != nil || !os.SameFile(info, expected) { + t.Fatal("worker inherited a different ownership file", err) + } + var limit syscall.Rlimit + executorMust(t, syscall.Getrlimit(syscall.RLIMIT_NOFILE, &limit)) + // This fresh worker has only explicitly inherited descriptors. Probe the + // complete finite OS fd range directly; /dev/fd directory enumeration is + // not portable on Darwin. Refuse an unbounded fixture rather than sample. + if limit.Cur == 0 || limit.Cur > 1<<20 { + t.Fatal("unsupported descriptor-probe bound", limit.Cur) + } + for fd := 3; uint64(fd) < limit.Cur; fd++ { + // Authority endpoints are AF_UNIX/SOCK_STREAM. SO_TYPE identifies + // non-stream native sockets without requiring their protocol to + // implement getsockname (Apple getsockname(2): EOPNOTSUPP). + kind, err := syscall.GetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_TYPE) + if err == syscall.EBADF || err == syscall.ENOTSOCK { + continue + } + if err != nil { + t.Fatalf("descriptor type probe failed for %d: %v", fd, err) + } + if kind != syscall.SOCK_STREAM { + continue + } + address, err := syscall.Getsockname(fd) + // Go's BSD anyToSockaddr returns EAFNOSUPPORT for families it + // cannot decode; AF_UNIX is explicitly supported. Darwin also + // documents protocol-level EOPNOTSUPP; XNU's Unix uipc_sockaddr + // implements this operation (uipc_usrreq.c), returning 0/EINVAL. + if err == syscall.EAFNOSUPPORT || runtime.GOOS == "darwin" && err == syscall.EOPNOTSUPP { + continue + } + if err != nil && err != syscall.EBADF && err != syscall.ENOTSOCK { + t.Fatalf("stream descriptor name probe failed for %d: %v", fd, err) + } + if _, ok := address.(*syscall.SockaddrUnix); err == nil && ok { + t.Fatalf("Runtime inherited authority socket %d", fd) + } + } + return + } + var fixture struct { + Binding ManualAdmissionBinding + Root, State string + Identity ExecutorLeaseIdentity + } + if json.Unmarshal([]byte(os.Getenv("JPACK_MANUAL_FD_FIXTURE")), &fixture) != nil { + t.Fatal("fixture") + } + authority, err := OpenInheritedManualAuthority(fixture.Binding) + executorMust(t, err) + defer authority.Close() + // The supplied original fd3 is closed; Runtime will later reuse that child + // slot solely for the independently opened ownership lease. + if _, err = syscall.Getsockname(3); err != syscall.EBADF { + t.Fatal("inherited original remained open", err) + } + lease, err := AcquireExecutorLease(fixture.Root, fixture.State, fixture.Identity.Workspace, fixture.Identity) + executorMust(t, err) + defer lease.Close() + _, err = authority.Activate(context.Background(), ManualActivateRequest{fixture.Binding.Challenge, ManualSigning{Mode: "off"}}) + executorMust(t, err) + cmd := exec.Command(os.Args[0], "-test.run=^TestManualInheritedAuthorityDescriptorHelper$") + cmd.Env = []string{"JPACK_MANUAL_FD_TEST=runtime", "JPACK_EXPECTED_OWNERSHIP=" + fixture.Root + "/" + fixture.Identity.Workspace + ".lease"} + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + _, err = lease.Start(cmd) + executorMust(t, err) + if err = cmd.Wait(); err != nil { + t.Fatal("actual worker descriptor check", err, output.String()) + } +} +func TestManualInheritedAuthoritySocketClosesBeforeRuntimeExec(t *testing.T) { + f := manualFixture(t) + lease := executorFixture(t) + oldLeaf := lease.leaf() + lease.identity.Workspace = f.Synthetic.Boot.MachineWorkspace + executorMust(t, os.Rename(oldLeaf, lease.leaf())) + // Go fork uses ForkLock's writer lock; exclude every concurrent fork until + // both raw sockets have CLOEXEC. Only ExtraFiles may deliberately inherit. + syscall.ForkLock.RLock() + sockets, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err == nil { + syscall.CloseOnExec(sockets[0]) + syscall.CloseOnExec(sockets[1]) + } + syscall.ForkLock.RUnlock() + executorMust(t, err) + parent := os.NewFile(uintptr(sockets[0]), "authority-parent") + child := os.NewFile(uintptr(sockets[1]), "authority-child") + defer parent.Close() + defer child.Close() + conn, err := net.FileConn(parent) + executorMust(t, err) + defer conn.Close() + fixture := struct { + Binding ManualAdmissionBinding + Root, State string + Identity ExecutorLeaseIdentity + }{f.Synthetic.Boot, lease.root, lease.state, lease.identity} + raw, _ := json.Marshal(fixture) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestManualInheritedAuthorityDescriptorHelper$") + cmd.ExtraFiles = []*os.File{child} + cmd.Env = append(os.Environ(), "JPACK_MANUAL_FD_TEST=owner", "JPACK_MANUAL_FD_FIXTURE="+string(raw)) + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + executorMust(t, cmd.Start()) + child.Close() + _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) + line, err := bufio.NewReader(conn).ReadBytes('\n') + if err != nil { + cmd.Process.Kill() + cmd.Wait() + t.Fatal(err, output.String()) + } + var request manualRequestEnvelope + executorMust(t, manualDecode(line, &request)) + if request.Method != "activate" || request.Sequence != strconv.Itoa(1) { + cmd.Process.Kill() + cmd.Wait() + t.Fatal("wrong activation") + } + var body ManualActivateRequest + executorMust(t, manualDecode(request.Body, &body)) + requestDigest, _ := manualRequestDigest("activate", body) + reply := manualReplyEnvelope{1, "1", "activate", "ok", encode(ManualActivateResult{"committed", "1", requestDigest})} + _, err = fmt.Fprintln(conn, string(encode(reply))) + executorMust(t, err) + if err = cmd.Wait(); err != nil { + t.Fatal("descriptor fixture", err, output.String()) + } +} + +// Prove the finite native descriptor observer distinguishes a deliberately +// leaked authority socket from the positive lease-only worker above. +func TestManualAuthorityDescriptorObserverDetectsSocket(t *testing.T) { + for _, kind := range []int{syscall.SOCK_STREAM, syscall.SOCK_DGRAM} { + name := "stream-leak" + if kind == syscall.SOCK_DGRAM { + name = "unrelated-datagram" + } + t.Run(name, func(t *testing.T) { + fixture := executorFixture(t) + lease, err := fixture.acquire() + executorMust(t, err) + defer lease.Close() + syscall.ForkLock.RLock() + sockets, err := syscall.Socketpair(syscall.AF_UNIX, kind, 0) + if err == nil { + syscall.CloseOnExec(sockets[0]) + syscall.CloseOnExec(sockets[1]) + } + syscall.ForkLock.RUnlock() + executorMust(t, err) + inherited := os.NewFile(uintptr(sockets[0]), "deliberately-inherited-socket") + defer inherited.Close() + defer syscall.Close(sockets[1]) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestManualInheritedAuthorityDescriptorHelper$") + cmd.Env = append(os.Environ(), "JPACK_MANUAL_FD_TEST=runtime", "JPACK_EXPECTED_LEASE_FD=4", "JPACK_EXPECTED_OWNERSHIP="+fixture.leaf()) + cmd.ExtraFiles = []*os.File{inherited} + var output bytes.Buffer + cmd.Stdout, cmd.Stderr = &output, &output + _, err = lease.Start(cmd) + executorMust(t, err) + err = cmd.Wait() + if kind == syscall.SOCK_STREAM { + if err == nil || !bytes.Contains(output.Bytes(), []byte("Runtime inherited authority socket 3")) { + t.Fatalf("observer missed deliberately inherited authority socket: %v %s", err, output.String()) + } + } else if err != nil { + t.Fatalf("observer confused datagram with authority stream: %v %s", err, output.String()) + } + }) + } +} diff --git a/internal/runner/manual_admission_wire.go b/internal/runner/manual_admission_wire.go new file mode 100644 index 0000000..13967b6 --- /dev/null +++ b/internal/runner/manual_admission_wire.go @@ -0,0 +1,347 @@ +package runner + +// Private host-only protocol. These types confer no authority by themselves. +// Phase A leaves activation and operational dispatch disabled in Open. + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "reflect" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +const ManualAdmissionProfile = "local-admin-manual-pack/1" +const ManualIntentHeader = "X-Jpack-Manual-Intent" +const manualFrameLimit = 64 << 10 + +var errManualProtocol = errors.New("manual admission channel is unavailable or invalid") +var errManualInactive = errors.New("manual admission profile is not activated in this Runner") + +type ManualAdmissionBinding struct { + Version int `json:"version"` + FD int `json:"fd"` + InstallationID string `json:"installationId"` + MachineWorkspace string `json:"machineWorkspace"` + RunnerWorkspace string `json:"runnerWorkspace"` + ExecutorID string `json:"executorId"` + Challenge string `json:"challenge"` + Profile string `json:"profile"` +} + +func (b ManualAdmissionBinding) Validate(o *ExecutorOwnership, workspace string) error { + if b.Version != 1 || b.FD != 3 || !manualHex(b.InstallationID) || !manualHex(b.MachineWorkspace) || !manualWorkspace(b.RunnerWorkspace) || !manualHex(b.ExecutorID) || !manualHex(b.Challenge) || b.Profile != ManualAdmissionProfile || o == nil || b.MachineWorkspace != o.MachineWorkspace || b.RunnerWorkspace != o.RunnerWorkspace || b.RunnerWorkspace != workspace || o.Validate(workspace) != nil { + return errManualProtocol + } + return nil +} + +// ParseManualAdmissionBinding performs closed, case-sensitive parsing only. It +// never opens the descriptor, activates an epoch or grants execution authority. +func ParseManualAdmissionBinding(raw []byte, o *ExecutorOwnership, workspace string) (*ManualAdmissionBinding, error) { + var b ManualAdmissionBinding + if manualDecode(raw, &b) != nil || b.Validate(o, workspace) != nil { + return nil, errManualProtocol + } + return &b, nil +} + +type ManualSigning struct { + Mode string `json:"mode"` + PublicKey string `json:"publicKey,omitempty"` +} +type ManualResource struct { + JobID string `json:"jobId"` + JobRevision string `json:"jobRevision"` + ReleaseID string `json:"releaseId"` + ReleaseProfile string `json:"releaseProfile"` + ReleaseDigest string `json:"releaseDigest"` +} +type ManualRegistration struct { + Revision string `json:"revision"` + Digest string `json:"digest"` +} +type ManualPayload struct { + Profile string `json:"profile"` + Digest string `json:"digest"` + Bytes []byte `json:"bytes"` +} +type ManualActivateRequest struct { + Challenge string `json:"challenge"` + Signing ManualSigning `json:"signing"` +} +type ManualActivateResult struct { + Delivery string `json:"delivery"` + Epoch string `json:"epoch"` + RequestDigest string `json:"requestDigest"` +} +type ManualInspectRequest struct { + Resource ManualResource `json:"resource"` +} +type ManualInspectResult struct { + Delivery string `json:"delivery"` + Registration ManualRegistration `json:"registration"` + Eligible bool `json:"eligible"` + Reason string `json:"reason"` + At string `json:"at"` +} +type ManualReserveRequest struct { + RawRequestDigest string `json:"rawRequestDigest"` + IntentID string `json:"intentId"` + Resource ManualResource `json:"resource"` + Registration ManualRegistration `json:"registration"` + Payload ManualPayload `json:"payload"` +} +type ManualReserveResult struct { + Delivery string `json:"delivery"` + OperationID string `json:"operationId"` + AttemptID string `json:"attemptId"` + Epoch string `json:"epoch"` + RequestDigest string `json:"requestDigest"` +} +type ManualStartRequest struct { + RawRequestDigest string `json:"rawRequestDigest"` + IntentID string `json:"intentId"` + Resource ManualResource `json:"resource"` + Registration ManualRegistration `json:"registration"` + Payload ManualPayload `json:"payload"` + OperationID string `json:"operationId"` + AttemptID string `json:"attemptId"` + Epoch string `json:"epoch"` +} +type ManualStartResult struct { + Delivery string `json:"delivery"` + OperationID string `json:"operationId"` + AttemptID string `json:"attemptId"` + Epoch string `json:"epoch"` + RequestDigest string `json:"requestDigest"` + Decision string `json:"decision"` + Reason string `json:"reason"` + At string `json:"at"` + AuthorityRevision string `json:"authorityRevision"` +} +type ManualHistoryRequest struct { + IntentID string `json:"intentId"` +} +type ManualHistoryResult struct { + Delivery string `json:"delivery"` + IntentID string `json:"intentId"` + OperationID string `json:"operationId"` + AttemptID string `json:"attemptId"` + Epoch string `json:"epoch"` + State string `json:"state"` + RequestDigest string `json:"requestDigest"` + Reason string `json:"reason"` + At string `json:"at"` +} + +type ManualAuthority interface { + Activate(context.Context, ManualActivateRequest) (ManualActivateResult, error) + Inspect(context.Context, ManualInspectRequest) (ManualInspectResult, error) + Reserve(context.Context, ManualReserveRequest) (ManualReserveResult, error) + CommitStart(context.Context, ManualStartRequest) (ManualStartResult, error) + History(context.Context, ManualHistoryRequest) (ManualHistoryResult, error) + Close() error +} +type ManualAuthorityRefusal struct { + Code string `json:"code"` +} + +func (e *ManualAuthorityRefusal) Error() string { return "manual admission refused: " + e.Code } + +func manualHex(s string) bool { + if len(s) != 64 { + return false + } + for _, c := range []byte(s) { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + return false + } + } + return true +} +func manualID(s string) bool { return executorWorkspaceID(s) } +func manualWorkspace(s string) bool { + if len(s) < 1 || len(s) > 256 { + return false + } + for _, c := range []byte(s) { + if c < 33 || c > 126 { + return false + } + } + return true +} +func manualPositive(s string) bool { + n, e := strconv.ParseUint(s, 10, 64) + return e == nil && n > 0 && strconv.FormatUint(n, 10) == s +} +func manualAt(s string) bool { + t, e := time.Parse("2006-01-02T15:04:05Z", s) + return e == nil && t.Format("2006-01-02T15:04:05Z") == s +} +func manualSigning(s ManualSigning) bool { + if s.Mode == "off" { + return s.PublicKey == "" + } + if s.Mode != "ed25519" || !manualHex(s.PublicKey) { + return false + } + _, err := ParsePublicKey(s.PublicKey) + return err == nil +} +func manualResource(r ManualResource) bool { + return manualID(r.JobID) && manualPositive(r.JobRevision) && manualID(r.ReleaseID) && r.ReleaseProfile == runnerCanonicalProfile && validDigest(r.ReleaseDigest) +} +func manualRegistration(r ManualRegistration) bool { + return manualPositive(r.Revision) && validDigest(r.Digest) +} +func manualDelivery(s string) bool { return s == "committed" || s == "recovered" } +func manualBlocked(s string) bool { + switch s { + case "resource-unregistered", "resource-changed", "unsupported-profile", "intent-unavailable", "binding-conflict", "operation-held", "withdrawn", "not-effective", "expired", "superseded", "adoption-changes-requested", "dependency-unavailable", "authority-unavailable", "clock-unavailable", "capacity": + return true + } + return false +} +func manualRefusal(s string) bool { + switch s { + case "authority-unavailable", "invalid-request", "unauthorized", "stale-epoch", "resource-unregistered", "resource-changed", "unsupported-profile", "intent-unavailable", "binding-conflict", "operation-held", "eligibility-blocked", "capacity", "clock-unavailable", "history-unavailable": + return true + } + return false +} +func manualPayload(p ManualPayload) bool { + if p.Profile != executionPayloadProfile || !validDigest(p.Digest) || len(p.Bytes) == 0 || len(p.Bytes) > maxExecutionDescriptor || digest(p.Bytes) != p.Digest { + return false + } + var d executionPayloadDescriptor + if manualDecode(p.Bytes, &d) != nil || d.Profile != executionPayloadProfile || !validDigest(d.ReleaseDigest) || !validDigest(d.PackDigest) || !validDigest(d.RuntimeDigest) || d.Preparation != nil || d.Source != nil || d.Facts.Encoding != executionExactBytes || !validDigest(d.Facts.Digest) || d.Facts.Bytes < 1 || d.Facts.Bytes > MaxBody { + return false + } + if d.Evidence.Presence == "omitted" { + if d.Evidence.Value != nil { + return false + } + } else if d.Evidence.Presence == "supplied" { + v := d.Evidence.Value + if v == nil || v.Encoding != executionExactBytes || !validDigest(v.Digest) || v.Bytes < 1 || v.Bytes > MaxBody { + return false + } + } else { + return false + } + if !manualSigning(ManualSigning(d.Signing)) { + return false + } + raw, e := canonical(p.Bytes) + return e == nil && bytes.Equal(raw, p.Bytes) +} +func manualReserve(r ManualReserveRequest) bool { + if !validDigest(r.RawRequestDigest) || !manualID(r.IntentID) || !manualResource(r.Resource) || !manualRegistration(r.Registration) || !manualPayload(r.Payload) { + return false + } + var d executionPayloadDescriptor + _ = json.Unmarshal(r.Payload.Bytes, &d) + return d.ReleaseDigest == r.Resource.ReleaseDigest +} +func manualRequestDigest(method string, body any) (string, error) { + raw, e := json.Marshal(struct { + Method string `json:"method"` + Body any `json:"body"` + }{method, body}) + if e != nil { + return "", e + } + raw, e = canonical(raw) + if e != nil { + return "", e + } + return digest(raw), nil +} + +// manualDecode adds exact field spelling, required-member/null checks and +// canonical base64 to the existing duplicate-safe number-preserving decoder. +func manualDecode(raw []byte, dst any) error { + if !utf8.Valid(raw) || len(raw) == 0 || len(raw) > manualFrameLimit { + return errManualProtocol + } + if _, err := readInputJSONDepth(raw, 64); err != nil { + return errManualProtocol + } + if err := manualShape(raw, reflect.TypeOf(dst).Elem()); err != nil { + return err + } + if json.Unmarshal(raw, dst) != nil { + return errManualProtocol + } + return nil +} + +var manualRawType = reflect.TypeOf(json.RawMessage{}) + +func manualShape(raw []byte, t reflect.Type) error { + raw = bytes.TrimSpace(raw) + if bytes.Equal(raw, []byte("null")) || len(raw) == 0 { + return errManualProtocol + } + if t == manualRawType { + return nil + } + if t.Kind() == reflect.Pointer { + return manualShape(raw, t.Elem()) + } + if t.Kind() == reflect.Struct { + var m map[string]json.RawMessage + if json.Unmarshal(raw, &m) != nil || m == nil { + return errManualProtocol + } + if t == reflect.TypeOf(ManualSigning{}) || t == reflect.TypeOf(executionPayloadSigning{}) { + var mode string + _ = json.Unmarshal(m["mode"], &mode) + if mode == "off" && len(m) != 1 || mode == "ed25519" && len(m) != 2 { + return errManualProtocol + } + } + fields := map[string]reflect.StructField{} + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + tag := strings.Split(f.Tag.Get("json"), ",") + if tag[0] == "-" || tag[0] == "" { + continue + } + fields[tag[0]] = f + if _, ok := m[tag[0]]; !ok && !strings.Contains(f.Tag.Get("json"), ",omitempty") { + return errManualProtocol + } + } + for name, value := range m { + f, ok := fields[name] + if !ok || manualShape(value, f.Type) != nil { + return errManualProtocol + } + } + return nil + } + if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 { + var s string + if json.Unmarshal(raw, &s) != nil { + return errManualProtocol + } + decoded, e := base64.StdEncoding.Strict().DecodeString(s) + if e != nil || base64.StdEncoding.EncodeToString(decoded) != s { + return errManualProtocol + } + return nil + } + v := reflect.New(t).Interface() + if json.Unmarshal(raw, v) != nil { + return errManualProtocol + } + return nil +} diff --git a/internal/runner/manual_execution_binding.go b/internal/runner/manual_execution_binding.go new file mode 100644 index 0000000..5f1cf60 --- /dev/null +++ b/internal/runner/manual_execution_binding.go @@ -0,0 +1,87 @@ +package runner + +import ( + "encoding/json" + "net/http" + "path/filepath" + "strconv" + "strings" +) + +// ManualExecutionBinding is an immutable description exported under the normal +// owner API. Base64 release bytes are the existing Runner canonical preimage; +// callers cannot supply a different encoding/profile or infer registration. +type ManualExecutionBinding struct { + Version int `json:"version"` + Workspace string `json:"workspace"` + JobID string `json:"jobId"` + JobRevision string `json:"jobRevision"` + ReleaseID string `json:"releaseId"` + ReleaseProfile string `json:"releaseProfile"` + ReleaseDigest string `json:"releaseDigest"` + ReleaseBytes []byte `json:"releaseBytes"` +} + +func (s *Service) manualExecutionBinding(jobID string) (ManualExecutionBinding, error) { + var out ManualExecutionBinding + tx, err := s.db.Begin() + if err != nil { + return out, err + } + defer tx.Rollback() + var raw []byte + if err = tx.QueryRow("SELECT record FROM jobs WHERE id=?", jobID).Scan(&raw); err != nil { + return out, err + } + var job Job + if json.Unmarshal(raw, &job) != nil || job.ID != jobID || job.Workspace != s.cfg.Workspace || job.Revision < 1 { + return out, errManualProtocol + } + if err = tx.QueryRow("SELECT record FROM releases WHERE id=?", job.ReleaseID).Scan(&raw); err != nil { + return out, err + } + var release Release + if json.Unmarshal(raw, &release) != nil || release.ID != job.ReleaseID || !manualRelease(release) { + return out, manualUnsupported() + } + var triggers int + if err = tx.QueryRow("SELECT count(*) FROM triggers WHERE job_id=?", jobID).Scan(&triggers); err != nil { + return out, err + } + if triggers != 0 || job.InitialTriggerID != "" || job.InitialTriggerDigest != "" { + return out, manualUnsupported() + } + preimage, err := executionPayloadCanonical(release, maxExecutionRelease) + if err != nil { + return out, err + } + // Export only existing verified bytes; never copy, fix or repin a Runtime. + image, err := readLimit(filepath.Join(s.cfg.Dir, "runtimes", strings.TrimPrefix(release.RuntimeDigest, "sha256:")), 128<<20) + if err != nil || digest(image) != release.RuntimeDigest { + return out, errManualProtocol + } + out = ManualExecutionBinding{1, s.cfg.Workspace, job.ID, strconv.Itoa(job.Revision), release.ID, runnerCanonicalProfile, digest(preimage), preimage} + return out, tx.Commit() +} +func manualRelease(r Release) bool { + var pack struct { + ID string `json:"id"` + Version string `json:"version"` + SpecVersion string `json:"specVersion"` + } + if json.Unmarshal([]byte(r.Pack), &pack) != nil || pack.ID != r.PackID || pack.Version != r.PackVersion || pack.SpecVersion != "0.2.0-draft" { + return false + } + return r.SchemaVersion == "1" && r.Dialect == nil && len(r.InputProfiles) == 0 && len(r.MappingWarnings) == 0 && r.InputMapping == nil && r.Sample.Source == nil && r.Sample.Preparation == nil && validDigest(r.PackDigest) && digest([]byte(r.Pack)) == r.PackDigest && validDigest(r.RuntimeDigest) && releaseReady(r) +} +func manualUnsupported() error { + return &apiError{409, "manual_profile_unsupported", "This profile requires an exact manual single-Pack job without triggers, mappings or workflow input."} +} +func (s *Service) manualExecutionBindingHandler(w http.ResponseWriter, r *http.Request) { + binding, err := s.manualExecutionBinding(r.PathValue("job")) + if err != nil { + failure(w, err) + return + } + write(w, 200, binding) +} diff --git a/internal/runner/manual_store_prepare.go b/internal/runner/manual_store_prepare.go new file mode 100644 index 0000000..36984d7 --- /dev/null +++ b/internal/runner/manual_store_prepare.go @@ -0,0 +1,242 @@ +package runner + +import ( + "database/sql" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strconv" +) + +const manualStoreSchema = "4" +const manualPrepareMarker = "manual-execution-prepare.json" + +type ManualOwnershipWire struct { + Version int `json:"version"` + Root string `json:"root"` + MachineWorkspace string `json:"machineWorkspace"` + RunnerWorkspace string `json:"runnerWorkspace"` + RootDevice string `json:"rootDevice"` + RootInode string `json:"rootInode"` + FileDevice string `json:"fileDevice"` + FileInode string `json:"fileInode"` +} + +func (w ManualOwnershipWire) ownership() (ExecutorOwnership, error) { + o := ExecutorOwnership{Root: w.Root, MachineWorkspace: w.MachineWorkspace, RunnerWorkspace: w.RunnerWorkspace} + if w.Version != 1 || !manualHex(w.MachineWorkspace) { + return o, errManualProtocol + } + for _, field := range []struct { + value string + dst *uint64 + }{{w.RootDevice, &o.RootDevice}, {w.RootInode, &o.RootInode}, {w.FileDevice, &o.FileDevice}, {w.FileInode, &o.FileInode}} { + n, err := strconv.ParseUint(field.value, 10, 64) + if err != nil || strconv.FormatUint(n, 10) != field.value { + return o, errManualProtocol + } + *field.dst = n + } + return o, o.Validate(w.RunnerWorkspace) +} + +type ManualStorePrepareRequest struct { + Version int `json:"version"` + PrepareID string `json:"prepareId"` + Dir string `json:"dir"` + Workspace string `json:"workspace"` + Owner string `json:"owner"` + LeaseFD int `json:"leaseFd"` + Ownership ManualOwnershipWire `json:"executorOwnership"` + Demand ManualExecutionDemand `json:"executionDemand"` +} +type ManualStorePrepareResult struct { + Version int `json:"version"` + Status string `json:"status"` + PrepareID string `json:"prepareId,omitempty"` + RequestDigest string `json:"requestDigest,omitempty"` + Delivery string `json:"delivery,omitempty"` + StoreSchema string `json:"storeSchema,omitempty"` + Code string `json:"code,omitempty"` +} +type manualPrepareRecord struct { + Version int `json:"version"` + PrepareID string `json:"prepareId"` + RequestDigest string `json:"requestDigest"` + Demand ManualExecutionDemand `json:"executionDemand"` +} + +const manualJournalSchema = `CREATE TABLE manual_attempts(run_id TEXT PRIMARY KEY,intent_id TEXT NOT NULL UNIQUE,record TEXT NOT NULL);` + +func prepareRefusal(code string) ManualStorePrepareResult { + return ManualStorePrepareResult{Version: 1, Status: "refused", Code: code} +} + +// PrepareManualStore is a nonexecuting library adapter. raw excludes LF. +// It never changes cwd and refuses hosts without a held-descriptor SQLite path. +// It takes ownership of fd3 only after the complete trusted request is valid. +func PrepareManualStore(raw []byte) ManualStorePrepareResult { + return prepareManualStore(raw, nil) +} + +// RunManualStorePreparationProcess is the dedicated nonexecuting CLI entry. +// It ALWAYS exits the process after its single reply. Only this entry may use +// a retained cwd/private SQLite VFS; it never returns to a service or library. +func RunManualStorePreparationProcess(raw []byte, output io.Writer) { + result := prepareManualStoreUsing(raw, nil, manualProcessDatabaseTarget) + status := 0 + if json.NewEncoder(output).Encode(result) != nil || result.Status != "ok" { + status = 1 + } + os.Exit(status) +} + +type manualStoreDatabaseTarget func(*ExecutorLease) (string, io.Closer, error) + +func manualLibraryDatabaseTarget(lease *ExecutorLease) (string, io.Closer, error) { + name, dir, err := manualDatabaseTarget(lease) + return name, dir, err +} +func prepareManualStore(raw []byte, inherited *os.File) ManualStorePrepareResult { + return prepareManualStoreUsing(raw, inherited, manualLibraryDatabaseTarget) +} +func prepareManualStoreUsing(raw []byte, inherited *os.File, target manualStoreDatabaseTarget) ManualStorePrepareResult { + var q ManualStorePrepareRequest + if len(raw) == 0 || len(raw)+1 > 16384 || manualDecode(raw, &q) != nil || q.Version != 1 || !manualHex(q.PrepareID) || !manualWorkspace(q.Workspace) || len(q.Owner) == 0 || len(q.Owner) > 256 || q.LeaseFD != 3 || !q.Demand.valid() || q.Demand.RunnerWorkspace != q.Workspace || q.Ownership.RunnerWorkspace != q.Workspace || q.Ownership.MachineWorkspace != q.Demand.MachineWorkspace || !filepath.IsAbs(q.Dir) || filepath.Clean(q.Dir) != q.Dir { + return prepareRefusal("invalid-request") + } + o, err := q.Ownership.ownership() + if err != nil { + return prepareRefusal("invalid-request") + } + if inherited == nil { + inherited = os.NewFile(3, "manual-store-lease") + } + lease, err := adoptManualPreparationLease(o, q.Dir, inherited) + if err != nil { + return prepareRefusal("unsafe-custody") + } + defer lease.Close() + return prepareManualStoreHeldUsing(q, raw, lease, target) +} + +func prepareManualStoreHeld(q ManualStorePrepareRequest, raw []byte, lease *ExecutorLease) ManualStorePrepareResult { + return prepareManualStoreHeldUsing(q, raw, lease, manualLibraryDatabaseTarget) +} +func prepareManualStoreHeldUsing(q ManualStorePrepareRequest, raw []byte, lease *ExecutorLease, target manualStoreDatabaseTarget) ManualStorePrepareResult { + o, err := q.Ownership.ownership() + if err != nil || verifyManualLease(lease) != nil { + return prepareRefusal("unsafe-custody") + } + demand, err := readManualDemand(o) + if err != nil || demand == nil || *demand != q.Demand { + return prepareRefusal("unsafe-custody") + } + lock, err := manualPreparationInstanceLock(lease) + if err != nil { + return prepareRefusal("busy") + } + defer lock.Close() + heldDatabase, err := lease.state.Open("runs.sqlite") + if err != nil { + return prepareRefusal("unsupported-store") + } + defer heldDatabase.Close() + databaseIdentity, err := heldDatabase.Stat() + if err != nil || verifyManualDatabasePath(lease, databaseIdentity) != nil { + return prepareRefusal("unsupported-store") + } + database, targetDirectory, err := target(lease) + if err != nil { + return prepareRefusal("unsafe-custody") + } + defer targetDirectory.Close() + db, err := sql.Open("sqlite", database) + if err != nil { + return prepareRefusal("storage-failed") + } + defer db.Close() + db.SetMaxOpenConns(1) + if _, err = db.Exec("PRAGMA temp_store=MEMORY; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000;"); err != nil { + return prepareRefusal("storage-failed") + } + tx, err := db.Begin() + if err != nil { + return prepareRefusal("storage-failed") + } + defer tx.Rollback() + // SQLite uses either a retained descriptor path or the isolated process's + // retained cwd and private relative VFS. Also + // compare the configured placement and DB inode around connection opening + // and before every durable boundary. This is not adversarial same-OS-user + // atomicity; detected replacement refuses and cannot finalize Desk enable. + if verifyManualDatabasePath(lease, databaseIdentity) != nil { + return prepareRefusal("unsafe-custody") + } + for key, want := range map[string]string{"workspace": q.Workspace, "owner": q.Owner} { + var got string + if tx.QueryRow("SELECT value FROM metadata WHERE key=?", key).Scan(&got) != nil || got != want { + return prepareRefusal("unsupported-store") + } + } + var schema string + if tx.QueryRow("SELECT value FROM metadata WHERE key='schema'").Scan(&schema) != nil { + return prepareRefusal("unsupported-store") + } + record := manualPrepareRecord{1, q.PrepareID, digest(raw), q.Demand} + old, markerErr := readManualStateFile(lease, manualPrepareMarker, 16384) + if markerErr == nil { + var held manualPrepareRecord + if manualDecode(old, &held) != nil || held != record || schema != manualStoreSchema { + return prepareRefusal("pending-transition") + } + var retained string + if tx.QueryRow("SELECT value FROM metadata WHERE key='manualExecutionDemand'").Scan(&retained) != nil || retained != string(encode(q.Demand)) { + return prepareRefusal("pending-transition") + } + var attempts int + if tx.QueryRow("SELECT count(*) FROM manual_attempts").Scan(&attempts) != nil || verifyManualDatabasePath(lease, databaseIdentity) != nil { + return prepareRefusal("pending-transition") + } + return ManualStorePrepareResult{Version: 1, Status: "ok", PrepareID: q.PrepareID, RequestDigest: record.RequestDigest, Delivery: "recovered", StoreSchema: manualStoreSchema} + } + if !errors.Is(markerErr, os.ErrNotExist) { + return prepareRefusal("pending-transition") + } + if schema != storeSchema { + return prepareRefusal("unsupported-store") + } + for _, query := range []string{"SELECT count(*) FROM triggers", "SELECT count(*) FROM runs WHERE state IN ('queued','running')", "SELECT count(*) FROM occurrences WHERE state IN ('accepted','preparing','waiting','needs-attention')"} { + var count int + if tx.QueryRow(query).Scan(&count) != nil { + return prepareRefusal("unsupported-store") + } + if count != 0 { + return prepareRefusal("unsupported-profile") + } + } + if verifyManualDatabasePath(lease, databaseIdentity) != nil { + return prepareRefusal("unsafe-custody") + } + if writeManualPrepareMarker(lease, encode(record)) != nil { + return prepareRefusal("storage-failed") + } + if _, err = tx.Exec(manualJournalSchema); err != nil { + return prepareRefusal("storage-failed") + } + if _, err = tx.Exec("INSERT INTO metadata VALUES ('manualExecutionDemand',?)", string(encode(q.Demand))); err != nil { + return prepareRefusal("storage-failed") + } + if _, err = tx.Exec("UPDATE metadata SET value=? WHERE key='schema'", manualStoreSchema); err != nil { + return prepareRefusal("storage-failed") + } + if verifyManualDatabasePath(lease, databaseIdentity) != nil { + return prepareRefusal("unsafe-custody") + } + if err = tx.Commit(); err != nil { + return prepareRefusal("storage-failed") + } + return ManualStorePrepareResult{Version: 1, Status: "ok", PrepareID: q.PrepareID, RequestDigest: record.RequestDigest, Delivery: "committed", StoreSchema: manualStoreSchema} +} diff --git a/internal/runner/manual_store_prepare_process_test.go b/internal/runner/manual_store_prepare_process_test.go new file mode 100644 index 0000000..e531309 --- /dev/null +++ b/internal/runner/manual_store_prepare_process_test.go @@ -0,0 +1,309 @@ +//go:build linux || darwin || freebsd + +package runner + +import ( + "bufio" + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +type manualLostPrepareReply struct{} + +func (manualLostPrepareReply) Write([]byte) (int, error) { + fmt.Println("prepared") + <-time.After(10 * time.Second) + return 0, io.ErrClosedPipe +} + +func manualPrepareInChild(t *testing.T, raw []byte, file *os.File) ManualStorePrepareResult { + t.Helper() + return manualPrepareInChildMode(t, raw, file, "committed") +} +func manualPrepareInChildMode(t *testing.T, raw []byte, file *os.File, mode string) ManualStorePrepareResult { + t.Helper() + defer file.Close() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestManualStorePrepareSubprocessHelper$") + cmd.Env = append(os.Environ(), "JPACK_MANUAL_PREPARE_CHILD="+mode) + cmd.ExtraFiles = []*os.File{file} + cmd.Stdin = bytes.NewReader(append(bytes.Clone(raw), '\n')) + var output, diagnostic bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &diagnostic + err := cmd.Run() + var result ManualStorePrepareResult + if json.Unmarshal(output.Bytes(), &result) != nil || result.Status != "ok" && result.Status != "refused" || (err == nil) != (result.Status == "ok") { + t.Fatalf("actual preparation child: %v %s %s", err, output.String(), diagnostic.String()) + } + return result +} + +func TestManualStorePrepareSubprocessHelper(t *testing.T) { + mode := os.Getenv("JPACK_MANUAL_PREPARE_CHILD") + if mode == "" { + return + } + raw, err := io.ReadAll(io.LimitReader(os.Stdin, 16385)) + if err != nil || len(raw) < 2 || raw[len(raw)-1] != '\n' { + os.Exit(2) + } + raw = raw[:len(raw)-1] + if mode == "target-probe" { + if err := manualProcessTargetProbe(raw); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) + } + if mode == "substituted-prepare" { + var request ManualStorePrepareRequest + if manualDecode(raw, &request) != nil { + os.Exit(2) + } + result := prepareManualStoreUsing(raw, nil, func(lease *ExecutorLease) (string, io.Closer, error) { + target, closer, err := manualProcessDatabaseTarget(lease) + if err != nil { + return "", nil, err + } + if err = manualSubstituteState(request.Dir); err != nil { + closer.Close() + return "", nil, err + } + return target, closer, nil + }) + json.NewEncoder(os.Stdout).Encode(result) + if result.Status != "ok" { + os.Exit(1) + } + os.Exit(0) + } + if mode == "lose-reply" { + RunManualStorePreparationProcess(raw, manualLostPrepareReply{}) + } + RunManualStorePreparationProcess(raw, os.Stdout) +} + +func TestManualStorePrepareActualChildRetainsOriginalCustody(t *testing.T) { + for _, mode := range []string{"committed", "refused", "lose-reply"} { + t.Run(mode, func(t *testing.T) { + q, lease, cfg := manualPreparationFixture(t) + if mode == "refused" { + db, e := sqlOpenManualTest(cfg.Dir) + executorMust(t, e) + _, e = db.Exec(`INSERT INTO runs(id,job_id,caller,idem,request_digest,state,record) VALUES('old','job','caller','key','digest','queued','{}')`) + executorMust(t, e) + db.Close() + } + o := *cfg.ExecutorOwnership + // Independent fresh opens attempt acquisition throughout the actual helper + // child's lifetime. This catches LOCK_UN on the shared description; the + // existing lease death tests cover final-descriptor process lifetimes. + stop, done, first := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var acquired atomic.Bool + go func() { + defer close(done) + once := true + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + contender, e := AcquireExecutorLease(o.Root, cfg.Dir, o.MachineWorkspace, o.identity()) + if e == nil { + acquired.Store(true) + contender.Close() + } + if once { + close(first) + once = false + } + select { + case <-stop: + return + case <-ticker.C: + } + } + }() + <-first + defer func() { + close(stop) + <-done + if acquired.Load() { + t.Error("helper transiently unlocked the continuously held original lease") + } + }() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestManualStorePrepareSubprocessHelper$") + cmd.Env = append(os.Environ(), "JPACK_MANUAL_PREPARE_CHILD="+mode) + cmd.Stdin = bytes.NewReader(append(encode(q), '\n')) + stdout, e := cmd.StdoutPipe() + executorMust(t, e) + var diagnostic bytes.Buffer + cmd.Stderr = &diagnostic + _, e = lease.Start(cmd) + executorMust(t, e) + waited := false + defer func() { + if !waited { + cmd.Process.Kill() + cmd.Wait() + } + }() + line := make(chan []byte, 1) + go func() { raw, _ := bufio.NewReader(stdout).ReadBytes('\n'); line <- raw }() + var response []byte + select { + case response = <-line: + case <-time.After(5 * time.Second): + t.Fatal("bounded helper child synchronization failed") + } + if mode == "lose-reply" { + if string(response) != "prepared\n" { + t.Fatalf("helper did not prepare: %s %s", response, diagnostic.String()) + } + cmd.Process.Kill() + cmd.Wait() + waited = true + if out := manualPrepareInChild(t, encode(q), manualLeaseDuplicate(t, lease)); out.Status != "ok" || out.Delivery != "recovered" { + t.Fatalf("lost helper reply was not observation only: %+v", out) + } + } else { + e = cmd.Wait() + waited = true + var result ManualStorePrepareResult + if json.Unmarshal(response, &result) != nil { + t.Fatalf("helper response %s %s", response, diagnostic.String()) + } + if mode == "committed" && (e != nil || result.Delivery != "committed") || mode == "refused" && (e == nil || result.Status != "refused") { + t.Fatalf("child result %+v %v %s", result, e, diagnostic.String()) + } + } + if contender, e := AcquireExecutorLease(o.Root, cfg.Dir, o.MachineWorkspace, o.identity()); e == nil { + contender.Close() + t.Fatal("helper ended parent custody") + } + }) + } +} + +// Runs only in the explicit target-probe child, then that child exits. Exercise +// SQLite after adapter setup and named-directory substitution, including actual +// rollback journal, WAL/SHM and rejected non-family ATTACH names. +func manualProcessTargetProbe(raw []byte) error { + var q ManualStorePrepareRequest + if err := manualDecode(raw, &q); err != nil { + return err + } + o, err := q.Ownership.ownership() + if err != nil { + return err + } + lease, err := adoptManualPreparationLease(o, q.Dir, os.NewFile(3, "probe-lease")) + if err != nil { + return err + } + defer lease.Close() + target, anchor, err := manualProcessDatabaseTarget(lease) + if err != nil { + return err + } + defer anchor.Close() + if err = manualSubstituteState(q.Dir); err != nil { + return err + } + db, err := sql.Open("sqlite", target) + if err != nil { + return err + } + defer db.Close() + db.SetMaxOpenConns(1) + if _, err = db.Exec("PRAGMA temp_store=MEMORY; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL;"); err != nil { + return err + } + tx, err := db.Begin() + if err != nil { + return err + } + if _, err = tx.Exec("INSERT INTO metadata VALUES ('anchor-probe-journal','original')"); err != nil { + tx.Rollback() + return err + } + if err = manualProbeSidecar(lease, q.Dir, "runs.sqlite-journal"); err != nil { + tx.Rollback() + return err + } + if err = tx.Commit(); err != nil { + return err + } + if _, err = db.Exec("PRAGMA journal_mode=WAL;"); err != nil { + return err + } + tx, err = db.Begin() + if err != nil { + return err + } + if _, err = tx.Exec("INSERT INTO metadata VALUES ('anchor-probe-wal','original')"); err != nil { + tx.Rollback() + return err + } + for _, name := range []string{"runs.sqlite-wal", "runs.sqlite-shm"} { + if err = manualProbeSidecar(lease, q.Dir, name); err != nil { + tx.Rollback() + return err + } + } + if err = tx.Commit(); err != nil { + return err + } + for _, name := range []string{"other.sqlite", filepath.Join(filepath.Dir(q.Dir), "escaped.sqlite")} { + if _, e := db.Exec("ATTACH DATABASE ? AS unexpected", name); e == nil { + return fmt.Errorf("private VFS accepted unrelated filename %s", name) + } + if _, e := os.Stat(name); !os.IsNotExist(e) { + return fmt.Errorf("unrelated database created: %s %v", name, e) + } + } + var sequence int + var schema, file string + if err = db.QueryRow("PRAGMA database_list").Scan(&sequence, &schema, &file); err != nil || file != "runs.sqlite" { + return fmt.Errorf("SQLite recanonicalized retained relative file: %q %v", file, err) + } + fmt.Println(`{"retainedMain":true,"journal":true,"wal":true,"shm":true,"unrelatedAttachRefused":true}`) + return nil +} +func manualProbeSidecar(lease *ExecutorLease, namedState, name string) error { + held, err := lease.state.Stat(name) + if err != nil || !held.Mode().IsRegular() { + return fmt.Errorf("missing retained sidecar %s: %v", name, err) + } + current, err := os.Stat(name) + if err != nil || !os.SameFile(held, current) { + return fmt.Errorf("sidecar escaped retained cwd: %s", name) + } + if _, err = os.Stat(filepath.Join(namedState, name)); !os.IsNotExist(err) { + return fmt.Errorf("sidecar reached substituted copy: %s %v", name, err) + } + return nil +} + +func manualSubstituteState(state string) error { + parked := state + "-parked" + if err := os.Rename(state, parked); err != nil { + return err + } + if err := os.Mkdir(state, 0700); err != nil { + return err + } + return os.CopyFS(state, os.DirFS(parked)) +} diff --git a/internal/runner/manual_store_prepare_test.go b/internal/runner/manual_store_prepare_test.go new file mode 100644 index 0000000..3c3f3a4 --- /dev/null +++ b/internal/runner/manual_store_prepare_test.go @@ -0,0 +1,264 @@ +//go:build linux || darwin || freebsd + +package runner + +import ( + "bytes" + "context" + "database/sql" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +func sqlOpenManualTest(dir string) (*sql.DB, error) { + return sql.Open("sqlite", filepath.Join(dir, "runs.sqlite")) +} + +func manualPreparationFixture(t *testing.T) (ManualStorePrepareRequest, *ExecutorLease, Config) { + t.Helper() + f := executorFixture(t) + old := f.leaf() + f.identity.Workspace = strings.Repeat("a", 64) + executorMust(t, os.Rename(old, f.leaf())) + cfg := Config{Dir: f.state, Workspace: "runner-test", Owner: "local-admin", Runtime: os.Args[0], disableDispatcher: true, disableAutomation: true} + s, err := Open(cfg) + executorMust(t, err) + s.Close() + o := ExecutorOwnership{f.root, f.identity.Workspace, cfg.Workspace, f.identity.RootDevice, f.identity.RootInode, f.identity.FileDevice, f.identity.FileInode} + d := ManualExecutionDemand{1, strings.Repeat("b", 64), f.identity.Workspace, cfg.Workspace, strings.Repeat("c", 64), ManualAdmissionProfile} + executorMust(t, os.Mkdir(manualDemandDirectory(o), 0700)) + executorMust(t, os.WriteFile(filepath.Join(manualDemandDirectory(o), d.MachineWorkspace), encode(d), 0600)) + u := func(v uint64) string { return strconv.FormatUint(v, 10) } + q := ManualStorePrepareRequest{1, strings.Repeat("d", 64), cfg.Dir, cfg.Workspace, cfg.Owner, 3, ManualOwnershipWire{1, o.Root, o.MachineWorkspace, o.RunnerWorkspace, u(o.RootDevice), u(o.RootInode), u(o.FileDevice), u(o.FileInode)}, d} + lease, err := f.acquire() + executorMust(t, err) + t.Cleanup(func() { lease.Close() }) + cfg.ExecutorOwnership = &o + return q, lease, cfg +} +func manualLeaseDuplicate(t *testing.T, l *ExecutorLease) *os.File { + t.Helper() + fd, err := syscall.Dup(int(l.file.Fd())) + executorMust(t, err) + syscall.CloseOnExec(fd) + return os.NewFile(uintptr(fd), "prepare-lease") +} +func TestManualStorePreparationDurableAndNoDowngrade(t *testing.T) { + q, lease, cfg := manualPreparationFixture(t) + raw := encode(q) + out := manualPrepareInChild(t, raw, manualLeaseDuplicate(t, lease)) + if out.Status != "ok" || out.Delivery != "committed" || out.RequestDigest != digest(raw) { + t.Fatalf("prepare %+v", out) + } + if contender, err := AcquireExecutorLease(cfg.ExecutorOwnership.Root, cfg.Dir, q.Demand.MachineWorkspace, cfg.ExecutorOwnership.identity()); err == nil { + contender.Close() + t.Fatal("helper released original ownership") + } + again := manualPrepareInChild(t, raw, manualLeaseDuplicate(t, lease)) + if again.Status != "ok" || again.Delivery != "recovered" { + t.Fatalf("exact recovered observation %+v", again) + } + lease.Close() + if s, err := Open(cfg); err == nil { + s.Close() + t.Fatal("omitted admission ignored external demand") + } + legacy := cfg + legacy.ExecutorOwnership = nil + if s, err := Open(legacy); err == nil { + s.Close() + t.Fatal("schema4 downgraded without trusted boot") + } + old := os.Getenv("JPACK_OLD_RUNNER_TEST_BIN") + if old != "" { + image, err := os.ReadFile(old) + executorMust(t, err) + t.Logf("actual old Runner schema4 refusal: %s %s", old, digest(image)) + boot := map[string]any{"dir": cfg.Dir, "workspace": cfg.Workspace, "owner": cfg.Owner, "runtime": cfg.Runtime, "token": strings.Repeat("x", 32)} + cmd := exec.Command(old) + cmd.Stdin = bytes.NewReader(append(encode(boot), '\n')) + output, err := cmd.CombinedOutput() + if err == nil || !bytes.Contains(output, []byte("another owner, workspace or schema")) { + t.Fatalf("old Runner did not refuse schema4: %v %s", err, output) + } + } +} + +func TestManualStorePreparationSubstitutedStateRefuses(t *testing.T) { + for _, phase := range []string{"before-adapter", "after-adapter"} { + t.Run(phase, func(t *testing.T) { + q, parent, _ := manualPreparationFixture(t) + parked := q.Dir + "-parked" + defer func() { os.RemoveAll(q.Dir); os.Rename(parked, q.Dir) }() + var out ManualStorePrepareResult + if phase == "before-adapter" { + o, err := q.Ownership.ownership() + executorMust(t, err) + adopted, err := adoptManualPreparationLease(o, q.Dir, manualLeaseDuplicate(t, parent)) + executorMust(t, err) + defer adopted.Close() + executorMust(t, manualSubstituteState(q.Dir)) + out = prepareManualStoreHeld(q, encode(q), adopted) + } else { + out = manualPrepareInChildMode(t, encode(q), manualLeaseDuplicate(t, parent), "substituted-prepare") + } + if out.Status != "refused" || out.Code != "unsafe-custody" { + t.Fatalf("substituted state %+v", out) + } + for _, state := range []string{parked, q.Dir} { + if _, err := os.Stat(filepath.Join(state, manualPrepareMarker)); !os.IsNotExist(err) { + t.Fatal("replaced state published marker", state, err) + } + db, err := sqlOpenManualTest(state) + executorMust(t, err) + var schema string + executorMust(t, db.QueryRow("SELECT value FROM metadata WHERE key='schema'").Scan(&schema)) + var demand, tables int + executorMust(t, db.QueryRow("SELECT count(*) FROM metadata WHERE key='manualExecutionDemand'").Scan(&demand)) + executorMust(t, db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='manual_attempts'").Scan(&tables)) + db.Close() + if schema != storeSchema || demand != 0 || tables != 0 { + t.Fatalf("substitution published usable state in %s: schema=%s demand=%d journal=%d", state, schema, demand, tables) + } + } + }) + } +} + +func TestManualStoreSQLiteTargetUsesHeldDirectory(t *testing.T) { + q, parent, _ := manualPreparationFixture(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestManualStorePrepareSubprocessHelper$") + cmd.Env = append(os.Environ(), "JPACK_MANUAL_PREPARE_CHILD=target-probe") + cmd.Stdin = bytes.NewReader(append(encode(q), '\n')) + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + _, err := parent.Start(cmd) + executorMust(t, err) + if err = cmd.Wait(); err != nil { + t.Fatalf("process retained target: %v %s", err, output.String()) + } + if !bytes.Contains(output.Bytes(), []byte(`"shm":true`)) { + t.Fatal("actual native sidecar probe missing", output.String()) + } + t.Logf("actual native relative VFS: %s", output.String()) + parked := q.Dir + "-parked" + defer func() { os.RemoveAll(q.Dir); os.Rename(parked, q.Dir) }() + for _, state := range []string{parked, q.Dir} { + db, e := sqlOpenManualTest(state) + executorMust(t, e) + var n int + executorMust(t, db.QueryRow("SELECT count(*) FROM metadata WHERE key IN ('anchor-probe-journal','anchor-probe-wal')").Scan(&n)) + db.Close() + if state == parked && n != 2 || state == q.Dir && n != 0 { + t.Fatal("SQLite descriptor target redirected", state, n) + } + } +} +func TestManualStorePreparationPendingAndUnsupported(t *testing.T) { + for _, kind := range []string{"queued", "paused-trigger", "marker", "separate-description", "unheld-description", "uint64-overflow"} { + t.Run(kind, func(t *testing.T) { + q, lease, cfg := manualPreparationFixture(t) + var file *os.File + switch kind { + case "queued", "paused-trigger": + db, err := sqlOpenManualTest(cfg.Dir) + executorMust(t, err) + if kind == "queued" { + _, err = db.Exec(`INSERT INTO runs(id,job_id,caller,idem,request_digest,state,record) VALUES('old','job','caller','key','digest','queued','{}')`) + } else { + _, err = db.Exec(`INSERT INTO triggers(id,job_id,record) VALUES('trigger','job','{"paused":true}')`) + } + executorMust(t, err) + db.Close() + case "marker": + executorMust(t, os.WriteFile(filepath.Join(cfg.Dir, manualPrepareMarker), []byte(`{}`), 0600)) + case "uint64-overflow": + q.Ownership.RootInode = "18446744073709551616" + case "separate-description", "unheld-description": + if kind == "unheld-description" { + lease.Close() + } + var err error + file, err = os.OpenFile(filepath.Join(q.Ownership.Root, q.Demand.MachineWorkspace+".lease"), os.O_RDWR, 0) + executorMust(t, err) + } + if file == nil { + file = manualLeaseDuplicate(t, lease) + } + defer file.Close() + out := manualPrepareInChild(t, encode(q), file) + if out.Status != "refused" { + t.Fatalf("unsupported transition %+v", out) + } + db, err := sqlOpenManualTest(cfg.Dir) + executorMust(t, err) + defer db.Close() + var schema string + executorMust(t, db.QueryRow("SELECT value FROM metadata WHERE key='schema'").Scan(&schema)) + if schema != storeSchema { + t.Fatal("refusal published schema4") + } + }) + } +} + +func TestManualStoreLibraryNeverChangesCWD(t *testing.T) { + q, lease, _ := manualPreparationFixture(t) + before, err := os.Stat(".") + executorMust(t, err) + out := prepareManualStore(encode(q), manualLeaseDuplicate(t, lease)) + if runtime.GOOS == "linux" { + if out.Status != "ok" { + t.Fatal(out) + } + } else if out.Status != "refused" || out.Code != "unsafe-custody" { + t.Fatal("library must refuse unsupported anchored path", out) + } + after, err := os.Stat(".") + executorMust(t, err) + if !os.SameFile(before, after) { + t.Fatal("library changed caller cwd") + } +} + +func TestManualExternalDemandCannotBeOmitted(t *testing.T) { + q, lease, cfg := manualPreparationFixture(t) + lease.Close() + if _, err := Open(cfg); err != errManualProtocol { + t.Fatalf("missing admission must refuse before runtime/recovery: %v", err) + } + o := *cfg.ExecutorOwnership + current := filepath.Join(manualDemandDirectory(o), q.Demand.MachineWorkspace) + executorMust(t, os.Remove(current)) + if checkManualDemand(&o, nil) == nil { + t.Fatal("empty fixed directory accepted") + } + other := q.Demand + other.MachineWorkspace = strings.Repeat("e", 64) + executorMust(t, os.WriteFile(filepath.Join(manualDemandDirectory(o), other.MachineWorkspace), encode(other), 0600)) + if err := checkManualDemand(&o, nil); err != nil { + t.Fatal("other workspace changed legacy current workspace", err) + } + b := ManualAdmissionBinding{Version: 1, FD: 3, InstallationID: q.Demand.InstallationID, MachineWorkspace: q.Demand.MachineWorkspace, RunnerWorkspace: q.Demand.RunnerWorkspace, ExecutorID: q.Demand.ExecutorID, Challenge: strings.Repeat("f", 64), Profile: ManualAdmissionProfile} + if checkManualDemand(&o, &b) == nil { + t.Fatal("enabled current workspace without demand accepted") + } + executorMust(t, os.WriteFile(current, encode(q.Demand), 0600)) + if err := checkManualDemand(&o, &b); err != nil { + t.Fatal(err) + } + executorMust(t, os.Chmod(current, 0644)) + if checkManualDemand(&o, &b) == nil { + t.Fatal("public demand accepted") + } +} diff --git a/internal/runner/manual_store_process_unix.go b/internal/runner/manual_store_process_unix.go new file mode 100644 index 0000000..959cf5b --- /dev/null +++ b/internal/runner/manual_store_process_unix.go @@ -0,0 +1,109 @@ +//go:build linux || darwin || freebsd + +package runner + +import ( + "io" + "os" + "syscall" + "unsafe" + + "modernc.org/libc" + sqlite3 "modernc.org/sqlite/lib" +) + +// This adapter is reachable only from the process-terminating helper entry and +// explicit helper subprocess tests. Ordinary Open/PrepareManualStore never call +// it. cwd stays anchored to the retained directory until this helper exits. +// A private nondefault VFS preserves one exact relative main filename; its +// native callbacks derive DB/journal/WAL/SHM paths relative to that fixed cwd. +func manualProcessDatabaseTarget(lease *ExecutorLease) (string, io.Closer, error) { + if verifyManualLease(lease) != nil { + return "", nil, errManualProtocol + } + dir, err := lease.state.Open(".") + if err != nil { + return "", nil, err + } + if err = syscall.Fchdir(int(dir.Fd())); err != nil { + dir.Close() + return "", nil, err + } + held, err := dir.Stat() + current, e := os.Stat(".") + if err != nil || e != nil || !os.SameFile(held, current) { + dir.Close() + return "", nil, errManualProtocol + } + anchor, err := newManualProcessVFS(dir) + if err != nil { + dir.Close() + return "", nil, err + } + return "file:runs.sqlite?vfs=" + anchor.name, anchor, nil +} + +type manualProcessVFS struct { + dir *os.File + tls *libc.TLS + name string + cname, vfs uintptr +} + +func newManualProcessVFS(dir *os.File) (*manualProcessVFS, error) { + a := &manualProcessVFS{dir: dir, tls: libc.NewTLS(), name: id("manual-root-")} + base := sqlite3.Xsqlite3_vfs_find(a.tls, 0) + if base == 0 { + a.tls.Close() + return nil, errManualProtocol + } + var err error + a.cname, err = libc.CString(a.name) + if err != nil { + a.tls.Close() + return nil, err + } + a.vfs = libc.Xmalloc(a.tls, libc.Tsize_t(unsafe.Sizeof(sqlite3.Tsqlite3_vfs{}))) + if a.vfs == 0 { + libc.Xfree(a.tls, a.cname) + a.tls.Close() + return nil, errManualProtocol + } + // These live ABI addresses come from SQLite/libc, not caller Go data. + // Use libc's native-memory ABI and compiler-derived field offsets. + libc.Xmemcpy(a.tls, a.vfs, base, libc.Tsize_t(unsafe.Sizeof(sqlite3.Tsqlite3_vfs{}))) + libc.AssignPtrUintptr(a.vfs+unsafe.Offsetof(sqlite3.Tsqlite3_vfs{}.FpNext), 0) + libc.AssignPtrUintptr(a.vfs+unsafe.Offsetof(sqlite3.Tsqlite3_vfs{}.FzName), a.cname) + // SQLite's documented cFuncPointer pattern (sqlite.go): a nonclosure + // function declaration has a stable function value; unsafe Rule #1 applies. + fullPathname := *(*uintptr)(unsafe.Pointer(&struct { + f func(*libc.TLS, uintptr, uintptr, int32, uintptr) int32 + }{manualProcessFullPathname})) + libc.AssignPtrUintptr(a.vfs+unsafe.Offsetof(sqlite3.Tsqlite3_vfs{}.FxFullPathname), fullPathname) + // Keep the actual native default intact. Only this DSN selects the clone. + if sqlite3.Xsqlite3_vfs_register(a.tls, a.vfs, 0) != sqlite3.SQLITE_OK { + libc.Xfree(a.tls, a.vfs) + libc.Xfree(a.tls, a.cname) + a.tls.Close() + return nil, errManualProtocol + } + return a, nil +} +func (a *manualProcessVFS) Close() error { + // The caller defers this before opening SQLite, so DB/sidecars close first. + sqlite3.Xsqlite3_vfs_unregister(a.tls, a.vfs) + libc.Xfree(a.tls, a.vfs) + libc.Xfree(a.tls, a.cname) + a.tls.Close() + return a.dir.Close() +} +func manualProcessFullPathname(tls *libc.TLS, _ uintptr, path uintptr, nOut int32, out uintptr) int32 { + const name = "runs.sqlite" + if path == 0 || out == 0 || nOut < int32(len(name)+1) || libc.GoString(path) != name { + return sqlite3.SQLITE_CANTOPEN + } + // Validation established the exact NUL-terminated native input extent; + // copy it within the native ABI without converting either address to Go. + libc.Xmemcpy(tls, out, path, libc.Tsize_t(len(name)+1)) + return sqlite3.SQLITE_OK +} diff --git a/internal/runner/mapping_v2.go b/internal/runner/mapping_v2.go index d79ff3c..05d6e32 100644 --- a/internal/runner/mapping_v2.go +++ b/internal/runner/mapping_v2.go @@ -12,6 +12,9 @@ import ( ) func (s *Service) normalizeInput(i Input) (Input, error) { + if s.cfg.ExecutorAdmission != nil && (i.Source != nil || i.Preparation != nil) { + return Input{}, manualUnsupported() + } if e := s.refuseSetAside(i); e != nil { return i, e } diff --git a/internal/runner/model.go b/internal/runner/model.go index bb61e99..75e9fa0 100644 --- a/internal/runner/model.go +++ b/internal/runner/model.go @@ -63,21 +63,22 @@ type Job struct { Owner string `json:"owner"` } type Run struct { - Trigger *TriggerOrigin `json:"trigger,omitempty"` - SchemaVersion string `json:"schemaVersion"` - ID string `json:"id"` - JobID string `json:"jobId"` - ReleaseID string `json:"releaseId"` - Revision int `json:"revision"` - State string `json:"state"` - Input Input `json:"input"` - CreatedAt string `json:"createdAt"` - StartedAt string `json:"startedAt,omitempty"` - FinishedAt string `json:"finishedAt,omitempty"` - RequestedBy string `json:"requestedBy"` - Attempt int `json:"attempt"` - Result json.RawMessage `json:"result,omitempty"` - Audit json.RawMessage `json:"audit,omitempty"` + manual *manualIntentInput `json:"-"` // private exact-byte storage; never exported as authority + Trigger *TriggerOrigin `json:"trigger,omitempty"` + SchemaVersion string `json:"schemaVersion"` + ID string `json:"id"` + JobID string `json:"jobId"` + ReleaseID string `json:"releaseId"` + Revision int `json:"revision"` + State string `json:"state"` + Input Input `json:"input"` + CreatedAt string `json:"createdAt"` + StartedAt string `json:"startedAt,omitempty"` + FinishedAt string `json:"finishedAt,omitempty"` + RequestedBy string `json:"requestedBy"` + Attempt int `json:"attempt"` + Result json.RawMessage `json:"result,omitempty"` + Audit json.RawMessage `json:"audit,omitempty"` // AuditBytes is the audit record exactly as the Runtime wrote it, without // the newline that ends its line: what a digest of the record is taken over. // Audit is the same record parsed, which Runner encodes again, with other @@ -145,10 +146,33 @@ type storedRun struct { func served(r Run) storedRun { return storedRun{Run: r, InterruptedAt: r.InterruptedAt, Reason: r.Reason, Signing: r.Signing, Binding: r.Binding} } -func runText(r Run) string { return string(encode(served(r))) } +func runText(r Run) string { + if r.manual == nil { + return string(encode(served(r))) + } + return string(encode(struct { + storedRun + Manual *manualIntentStored `json:"manualAdmission"` + }{served(r), r.manual.stored()})) +} func decodeRun(b []byte) (Run, error) { - var held storedRun - e := json.Unmarshal(b, &held) + var wire struct { + storedRun + Manual json.RawMessage `json:"manualAdmission"` + } + e := json.Unmarshal(b, &wire) + held := wire.storedRun + if e == nil && wire.Manual != nil { + var stored manualIntentStored + if manualStoredDecode(wire.Manual, &stored) != nil { + return Run{}, errManualProtocol + } + held.Run.manual, e = stored.restore(held.Run.Input) + if e != nil { + return Run{}, e + } + held.Run.Input = held.Run.manual.input() + } held.Run.InterruptedAt, held.Run.Reason, held.Run.Signing, held.Run.Binding = held.InterruptedAt, held.Reason, held.Signing, held.Binding return held.Run, e } diff --git a/internal/runner/preparation.go b/internal/runner/preparation.go index cbe5df2..1e81ba4 100644 --- a/internal/runner/preparation.go +++ b/internal/runner/preparation.go @@ -84,6 +84,9 @@ func (s *Service) durableMapping(r Release) bool { return operations > 0 } func (s *Service) prepareOccurrence(ctx context.Context) error { + if s.cfg.ExecutorAdmission != nil { + return manualUnsupported() + } s.automationMu.Lock() if s.unhealthy.Load() { s.automationMu.Unlock() diff --git a/internal/runner/profile_registry.go b/internal/runner/profile_registry.go index b534158..33e627d 100644 --- a/internal/runner/profile_registry.go +++ b/internal/runner/profile_registry.go @@ -182,6 +182,9 @@ func profileRefusal(whose string, profiles []InputProfile, err error) error { } func (s *Service) registerInputProfile(p InputProfile) (bool, error) { + if s.cfg.ExecutorAdmission != nil { + return false, manualUnsupported() + } if err := validateProfiles([]InputProfile{p}); err != nil { return false, bad("invalid_input_profile", "Supply one valid installation input profile.") } diff --git a/internal/runner/profile_set_aside.go b/internal/runner/profile_set_aside.go index 3a90556..f61c916 100644 --- a/internal/runner/profile_set_aside.go +++ b/internal/runner/profile_set_aside.go @@ -94,6 +94,9 @@ func (s *Service) refuseSetAsideMapping(m *InputMapping) error { // nextInput is nextInput under the installation's profiles, refusing first an // input that names a set-aside profile. func (s *Service) nextInput(i Input, at time.Time) (InputPlan, error) { + if s.cfg.ExecutorAdmission != nil { + return InputPlan{}, manualUnsupported() + } if e := s.refuseSetAside(i); e != nil { return InputPlan{}, e } diff --git a/internal/runner/runtime.go b/internal/runner/runtime.go index 208dbed..11bb956 100644 --- a/internal/runner/runtime.go +++ b/internal/runner/runtime.go @@ -277,6 +277,9 @@ func (s *Service) preview(ctx context.Context, req PreviewRequest) (Release, err return r, s.saveRelease(r) } func (s *Service) evaluate(ctx context.Context, r Release, input Input, dir string, rehearsal bool) (json.RawMessage, json.RawMessage, []byte, *RunBinding, error) { + if !rehearsal && s.cfg.ExecutorAdmission != nil { + return nil, nil, nil, nil, errManualInactive + } if input.Source != nil && input.Source.Mapping.dialect() { if input.Preparation == nil || !mappingMatchesRelease(r, input) { return nil, nil, nil, nil, errors.New("missing or mismatched verified preparation") diff --git a/internal/runner/store.go b/internal/runner/store.go index 3ba4bfb..a8e93a3 100644 --- a/internal/runner/store.go +++ b/internal/runner/store.go @@ -1,6 +1,7 @@ package runner import ( + "bytes" "context" "crypto/ed25519" "database/sql" @@ -16,6 +17,8 @@ import ( ) type Config struct { + // ExecutorAdmission is snapshotted strictly but remains inactive in Phase A. + ExecutorAdmission *ManualAdmissionBinding // ExecutorOwnership opts this workspace into trusted external ownership. // Open snapshots it before taking the lease; nil preserves legacy behavior. ExecutorOwnership *ExecutorOwnership @@ -65,6 +68,9 @@ type Service struct { // executor is fixed at Open and remains non-nil after Close. executor *ExecutorLease + // authority is the immutable original inherited channel/session. It never + // becomes a nil fallback after Close or transport failure. + authority *manualAuthorityClient // expectedKey is the public key of the signing key as Runner read it at // start, fixed for as long as it runs: the key each run's record must be // signed under. Nil without a signing key. @@ -86,6 +92,18 @@ type Service struct { } func Open(cfg Config) (_ *Service, err error) { + if cfg.ExecutorAdmission != nil { + binding := *cfg.ExecutorAdmission + cfg.ExecutorAdmission = &binding + if binding.Validate(cfg.ExecutorOwnership, cfg.Workspace) != nil { + return nil, errManualProtocol + } + // Do not expose an enabled profile before registration and the actual + // operational starter have been integrated and proved together. + if !ManualAdmissionAvailable() { + return nil, errManualInactive + } + } // Snapshot the binding before any side effects. Never derive pins from state. var executor *ExecutorLease if cfg.ExecutorOwnership != nil { @@ -104,6 +122,24 @@ func Open(cfg Config) (_ *Service, err error) { } }() } + // Sticky external demand remains binding when admission was omitted from a + // later boot. It is never inferred from a portable store or repaired here. + if err = checkManualDemand(cfg.ExecutorOwnership, cfg.ExecutorAdmission); err != nil { + return nil, err + } + var authority *manualAuthorityClient + if cfg.ExecutorAdmission != nil { + channel, e := OpenInheritedManualAuthority(*cfg.ExecutorAdmission) + if e != nil { + return nil, e + } + authority = channel.(*manualAuthorityClient) + defer func() { + if err != nil { + authority.Close() + } + }() + } if err = validateProfiles(cfg.InputProfiles); err != nil { return nil, profileRefusal("configured", cfg.InputProfiles, err) @@ -124,6 +160,16 @@ func Open(cfg Config) (_ *Service, err error) { if cfg.Workspace == "" || cfg.Owner == "" || !filepath.IsAbs(cfg.Dir) || !filepath.IsAbs(cfg.Runtime) { return nil, errors.New("absolute paths and workspace owner required") } + if cfg.ExecutorAdmission == nil { + if _, e := os.Lstat(filepath.Join(cfg.Dir, manualPrepareMarker)); !os.IsNotExist(e) { + return nil, errManualProtocol + } + } + if cfg.ExecutorAdmission != nil { + if privateFile(filepath.Join(cfg.Dir, "runs.sqlite")) != nil { + return nil, errManualProtocol + } + } if err = privateDir(cfg.Dir); err != nil { return nil, err } @@ -131,7 +177,7 @@ func Open(cfg Config) (_ *Service, err error) { if err != nil { return nil, err } - s := &Service{cloudProblems: map[string]string{}, automationDone: make(chan struct{}), cfg: cfg, executor: executor, expectedKey: expectedKey, wake: make(chan struct{}, 1), done: make(chan struct{}), previewGate: make(chan struct{}, 1)} + s := &Service{cloudProblems: map[string]string{}, automationDone: make(chan struct{}), cfg: cfg, executor: executor, authority: authority, expectedKey: expectedKey, wake: make(chan struct{}, 1), done: make(chan struct{}), previewGate: make(chan struct{}, 1)} defer func() { if err != nil { if s.db != nil { @@ -172,6 +218,11 @@ func Open(cfg Config) (_ *Service, err error) { return nil, err } s.db.SetMaxOpenConns(1) + if cfg.ExecutorAdmission != nil { + if err = s.validateManualStore(); err != nil { + return nil, err + } + } _, err = s.db.Exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS briefs (subject TEXT PRIMARY KEY, record TEXT NOT NULL); @@ -203,8 +254,10 @@ func Open(cfg Config) (_ *Service, err error) { // A store an earlier Runner kept, at schema "1", or a new one, begins its // journal here and is raised to schema "2", which every earlier Runner // refuses (docs/design/activity-journal.md, section 4). - if err = s.migrateStore(); err != nil { - return nil, err + if cfg.ExecutorAdmission == nil { + if err = s.migrateStore(); err != nil { + return nil, err + } } if err = s.loadRegisteredProfiles(); err != nil { return nil, err @@ -227,6 +280,9 @@ func Open(cfg Config) (_ *Service, err error) { if err != nil { return nil, err } + if err = s.activateManualAuthority(); err != nil { + return nil, err + } // Holding the process lock proves the old dispatcher is gone. Never replay an // invocation that may have appended an operational audit before crashing. if err = s.restart(); err != nil { @@ -239,7 +295,7 @@ func Open(cfg Config) (_ *Service, err error) { } else { go s.worker(ctx) } - if cfg.disableAutomation { + if cfg.disableAutomation || cfg.ExecutorAdmission != nil { close(s.automationDone) } else { go s.automation(ctx) @@ -250,6 +306,9 @@ func Open(cfg Config) (_ *Service, err error) { func (s *Service) Close() { s.closeOnce.Do(func() { s.cancel() + if s.authority != nil { + s.authority.Close() + } <-s.done <-s.automationDone s.background.Wait() @@ -295,12 +354,18 @@ func (s *Service) createJob(name, releaseID string) (Job, error) { return s.createJobConfigured(name, releaseID, nil) } func (s *Service) createJobConfigured(name, releaseID string, c *TriggerConfig) (Job, error) { + if s.cfg.ExecutorAdmission != nil && c != nil { + return Job{}, manualUnsupported() + } s.automationMu.Lock() defer s.automationMu.Unlock() r, err := s.release(releaseID) if err != nil { return Job{}, err } + if s.cfg.ExecutorAdmission != nil && !manualRelease(r) { + return Job{}, manualUnsupported() + } if !releaseReady(r) { return Job{}, &apiError{409, "release_not_ready", "Saved tests failed or could not complete. Correct the pack or cases and check a new release before creating a job."} } @@ -392,6 +457,21 @@ func (s *Service) submit(jobID, key string, input Input) (Run, bool, error) { return s.submitInternal(jobID, key, input, nil) } func (s *Service) submitInternal(jobID, key string, input Input, origin *TriggerOrigin) (Run, bool, error) { + return s.submitInternalIntent(jobID, key, input, origin, nil) +} +func (s *Service) submitInternalIntent(jobID, key string, input Input, origin *TriggerOrigin, manual *manualIntentInput) (Run, bool, error) { + if s.cfg.ExecutorAdmission != nil { + if origin != nil || input.Source != nil || input.Preparation != nil || manual == nil || manual.key != key || !bytes.Equal(input.Facts, []byte(manual.facts)) || !bytes.Equal(input.Evidence, []byte(manual.evidence)) { + return Run{}, false, manualUnsupported() + } + var err error + manual, err = s.inspectManualSubmission(jobID, manual) + if err != nil { + return Run{}, false, err + } + } else if manual != nil { + return Run{}, false, errManualInactive + } caller := s.cfg.Owner if origin != nil { caller = "trigger:" + origin.TriggerID @@ -461,6 +541,9 @@ func (s *Service) submitInternal(jobID, key string, input Input, origin *Trigger return Run{}, false, err } hash := digest(b) + if manual != nil { + hash = digest([]byte(manual.request)) + } if v2 { hash = requestHash } @@ -497,6 +580,12 @@ func (s *Service) submitInternal(jobID, key string, input Input, origin *Trigger if err = json.Unmarshal(releaseBytes, &release); err != nil { return Run{}, false, err } + if manual != nil { + preimage, e := executionPayloadCanonical(release, maxExecutionRelease) + if e != nil || manual.resource == nil || manual.registration == nil || manual.resource.JobID != j.ID || manual.resource.JobRevision != decimalRevision(j.Revision) || manual.resource.ReleaseID != j.ReleaseID || manual.resource.ReleaseDigest != digest(preimage) || !manualRelease(release) { + return Run{}, false, errManualProtocol + } + } if !mappingMatchesRelease(release, input) { return Run{}, false, bad("mapping_mismatch", "Use this job’s reviewed input mapping. Create a new job to change its input source or mapping.") } @@ -507,7 +596,7 @@ func (s *Service) submitInternal(jobID, key string, input Input, origin *Trigger if count >= queueLimit { return Run{}, false, &apiError{429, "queue_full", "The local queue is full. Try again after pending runs finish."} } - r := Run{Trigger: origin, SchemaVersion: "1", ID: id("run_"), JobID: j.ID, ReleaseID: j.ReleaseID, Revision: j.Revision, State: "queued", Input: input, CreatedAt: now(), RequestedBy: caller} + r := Run{manual: manual, Trigger: origin, SchemaVersion: "1", ID: id("run_"), JobID: j.ID, ReleaseID: j.ReleaseID, Revision: j.Revision, State: "queued", Input: input, CreatedAt: now(), RequestedBy: caller} _, err = tx.Exec("INSERT INTO runs(id,job_id,caller,idem,request_digest,state,record) VALUES (?,?,?,?,?,?,?)", r.ID, j.ID, caller, key, hash, r.State, runText(r)) if err != nil { return Run{}, false, err @@ -572,6 +661,9 @@ func (s *Service) dispatchNext(ctx context.Context) (bool, error) { if err != nil { return false, err } + if r.manual != nil && s.cfg.ExecutorAdmission == nil || r.manual == nil && s.cfg.ExecutorAdmission != nil { + return false, errManualInactive + } if r.Trigger != nil && r.Trigger.ExpiresAt != "" { deadline, e := time.Parse(time.RFC3339Nano, r.Trigger.ExpiresAt) if e != nil || !time.Now().Before(deadline) { @@ -611,7 +703,11 @@ func (s *Service) dispatchNext(ctx context.Context) (bool, error) { if err == nil { var trail, signatures []byte var binding *RunBinding - r.Result, trail, signatures, binding, err = s.evaluate(ctx, release, r.Input, filepath.Join(s.cfg.Dir, "attempts", r.ID), false) + if s.cfg.ExecutorAdmission != nil { + r.Result, trail, signatures, binding, err = s.governedEvaluation(ctx, r, release) + } else { + r.Result, trail, signatures, binding, err = s.evaluate(ctx, release, r.Input, filepath.Join(s.cfg.Dir, "attempts", r.ID), false) + } // A sidecar that could not be read, with a signing key, fails the // record's signing and not the run (evaluateWith). unread := errors.Is(err, errSidecarUnread) diff --git a/internal/runner/triggers_dispatch.go b/internal/runner/triggers_dispatch.go index 18076ad..dd55f84 100644 --- a/internal/runner/triggers_dispatch.go +++ b/internal/runner/triggers_dispatch.go @@ -91,6 +91,9 @@ func (s *Service) automationTick(at time.Time) error { return nil } func (s *Service) scheduleTick(t Trigger, hash string, at time.Time) error { + if s.cfg.ExecutorAdmission != nil { + return manualUnsupported() + } if t.NextAt == "" { return nil } @@ -162,6 +165,9 @@ func (s *Service) scheduleTick(t Trigger, hash string, at time.Time) error { return tx.Commit() } func (s *Service) fileTick(t Trigger, hash string, at time.Time) error { + if s.cfg.ExecutorAdmission != nil { + return manualUnsupported() + } raw, e := s.readAutomaticFile(t.Config.WatchPath) if e != nil { if t.Problem != e.Error() { @@ -237,6 +243,9 @@ func (s *Service) fileTick(t Trigger, hash string, at time.Time) error { return tx.Commit() } func (s *Service) dispatchOccurrence(o Occurrence, at time.Time) error { + if s.cfg.ExecutorAdmission != nil { + return manualUnsupported() + } // Reconcile admission first: a crash after run creation must not become a // second evaluation or an expiry, even if source receipts have since aged. var runID string diff --git a/internal/runner/triggers_store.go b/internal/runner/triggers_store.go index 8e543bd..dafa53f 100644 --- a/internal/runner/triggers_store.go +++ b/internal/runner/triggers_store.go @@ -62,6 +62,9 @@ func (s *Service) writeTrigger(t Trigger, hash string, history bool) error { return tx.Commit() } func (s *Service) configureTrigger(jobID, triggerID string, revision int, c TriggerConfig) (Trigger, error) { + if s.cfg.ExecutorAdmission != nil { + return Trigger{}, manualUnsupported() + } s.automationMu.Lock() defer s.automationMu.Unlock() job, e := s.job(jobID) @@ -142,6 +145,9 @@ func (s *Service) configureTrigger(jobID, triggerID string, revision int, c Trig return t, tx.Commit() } func (s *Service) setTriggerState(triggerID string, revision int, paused, reviewed bool) (Trigger, string, error) { + if s.cfg.ExecutorAdmission != nil { + return Trigger{}, "", manualUnsupported() + } s.automationMu.Lock() defer s.automationMu.Unlock() t, hash, e := s.trigger(triggerID) @@ -254,6 +260,9 @@ func (s *Service) writeTriggerChange(t Trigger, hash string, c change) error { return tx.Commit() } func (s *Service) rotateTriggerKey(triggerID string, revision int) (Trigger, string, error) { + if s.cfg.ExecutorAdmission != nil { + return Trigger{}, "", manualUnsupported() + } s.automationMu.Lock() defer s.automationMu.Unlock() t, _, e := s.trigger(triggerID) @@ -285,6 +294,9 @@ func (s *Service) rotateTriggerKey(triggerID string, revision int) (Trigger, str return t, secret, e } func (s *Service) event(triggerID, token string, event EventDelivery) (Occurrence, bool, error) { + if s.cfg.ExecutorAdmission != nil { + return Occurrence{}, false, manualUnsupported() + } s.automationMu.Lock() defer s.automationMu.Unlock() t, hash, e := s.trigger(triggerID) @@ -312,6 +324,9 @@ func (s *Service) admitEvent(t Trigger, hash string, event EventDelivery) (Occur // persists the complete envelope before delivery; old timestamps are allowed, but // source freshness and queue expiry still apply at admission/execution. func (s *Service) deliverRecord(triggerID, releaseID string, event EventDelivery) (Occurrence, bool, error) { + if s.cfg.ExecutorAdmission != nil { + return Occurrence{}, false, manualUnsupported() + } s.automationMu.Lock() defer s.automationMu.Unlock() t, _, e := s.trigger(triggerID) @@ -325,6 +340,9 @@ func (s *Service) deliverRecord(triggerID, releaseID string, event EventDelivery } func (s *Service) admitBoundEvent(t Trigger, hash string, event EventDelivery, expectedRelease string) (Occurrence, bool, error) { + if s.cfg.ExecutorAdmission != nil { + return Occurrence{}, false, manualUnsupported() + } if t.Config.Kind != "event" { return Occurrence{}, false, bad("not_event_trigger", "This trigger does not accept event delivery.") } diff --git a/internal/runner/workflow_host.go b/internal/runner/workflow_host.go index 082b3e0..7c075b0 100644 --- a/internal/runner/workflow_host.go +++ b/internal/runner/workflow_host.go @@ -14,12 +14,18 @@ import ( func (s *Service) workflowDriver(store *workflowStore) workflowDriver { return workflowDriver{Store: store, Profiles: s.inputProfiles(), Acquire: func(ctx context.Context, request workflowAcquisition) (SourceValue, error) { + if s.cfg.ExecutorAdmission != nil { + return SourceValue{}, manualUnsupported() + } if request.Next.Kind != "operation" { return SourceValue{}, errWorkflowAcquisitionUnavailable } return s.acquirePlannedOperation(ctx, request.Next) }, Evaluate: func(ctx context.Context, release Release, input Input, key string) (workflowEvaluation, error) { + if s.cfg.ExecutorAdmission != nil { + return workflowEvaluation{}, manualUnsupported() + } root := filepath.Join(s.cfg.Dir, "workflow-attempts") if err := os.MkdirAll(root, 0700); err != nil { return workflowEvaluation{}, err diff --git a/openapi.json b/openapi.json index 70c09df..ef50571 100644 --- a/openapi.json +++ b/openapi.json @@ -1848,6 +1848,44 @@ } } } + }, + "/jobs/{job}/execution-binding": { + "get": { + "operationId": "getJobExecutionBinding", + "description": "Owner-only exact manual single-Pack execution-binding export. Refuses any trigger, mapping, dialect, source/preparation or unavailable immutable release material. Admission remains inactive.", + "parameters": [ + { + "name": "job", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Exact immutable execution binding", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualExecutionBinding" + } + } + } + }, + "default": { + "description": "Binding unavailable or profile unsupported", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } } }, "components": { @@ -5595,6 +5633,51 @@ "reason" ], "additionalProperties": false + }, + "ManualExecutionBinding": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "workspace", + "jobId", + "jobRevision", + "releaseId", + "releaseProfile", + "releaseDigest", + "releaseBytes" + ], + "properties": { + "version": { + "const": 1 + }, + "workspace": { + "type": "string" + }, + "jobId": { + "type": "string" + }, + "jobRevision": { + "type": "string", + "pattern": "^[1-9][0-9]{0,19}$" + }, + "releaseId": { + "type": "string" + }, + "releaseProfile": { + "const": "judgment-pack-runner/canonical/1" + }, + "releaseDigest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "releaseBytes": { + "type": "string", + "contentEncoding": "base64", + "maxLength": 11184812 + } + }, + "description": "Private experimental exact canonical typed Release preimage. Read-only binding material grants no registration or execution permission." } } }