Prove refusal on uncertain operator cleanup
3 files changed,
+74
−12
implementation/operator-binding/proposal.json+28 −5internal/operator/command_linux.go+3 −0internal/operator/command_linux_test.go+43 −7
CI passed — run details
diff --git a/implementation/operator-binding/proposal.json b/implementation/operator-binding/proposal.json| index 656c7c9..7f4f1d1 100644 |
| --- a/implementation/operator-binding/proposal.json |
| +++ b/implementation/operator-binding/proposal.json |
| @@ -1,11 +1,29 @@ |
| { |
| "status": "Proposed private adapter component; no command or HTTP mounting", |
| - "parents": ["Home startup6289db0 with Fable approval", "Opened DB4770bc6", "Operator/provisioner898fe219"], |
| + "parents": [ |
| + "Home startup6289db0 with Fable approval", |
| + "Opened DB4770bc6", |
| + "Operator/provisioner898fe219" |
| + ], |
| "configuration": { |
| "source": "HOME_OPERATOR_COMMAND from the worker's immutable protected app environment; never argv or signed-request fields", |
| "wire": "UTF-8 JSON at most8192 bytes; duplicate, unknown and missing fields refuse", |
| - "fields": ["version=1", "account", "app", "host", "role", "origin", "installation", "registry", "device", "public_key", "key_id", "actions"], |
| - "validation": "Shared route validators, existing NewVerifier role/origin/identity/key/action checks, and exact derived key ID match. No default, trust-on-first-input or key rotation by request." |
| + "fields": [ |
| + "version=1", |
| + "account", |
| + "app", |
| + "host", |
| + "role", |
| + "origin", |
| + "installation", |
| + "registry", |
| + "device", |
| + "public_key", |
| + "key_id", |
| + "actions" |
| + ], |
| + "validation": "Shared route validators, existing NewVerifier role/origin/identity/key/action checks, and exact derived key ID match. No default, trust-on-first-input or key rotation by request.", |
| + "origin_and_host": "Transport host names the deployment endpoint; signed origin is the canonical Home identity origin and can differ during preview/custom-host routing. Both are independently pinned from protected configuration and matched against their corresponding transport or signed fields; neither is derived from the other." |
| }, |
| "ordering": [ |
| "Parse protected configuration and build the existing signature verifier before database access.", |
| @@ -16,6 +34,11 @@ |
| "Final cleanup checks pinned database identity, closes the database and clears retained private byte buffers; any uncertain cleanup/deadline yields unavailable, never a successful result." |
| ], |
| "replay": "Expired authentic requests remain eligible for the existing engine's exact committed-result recovery. The adapter does not grant freshness or execute a mutation. The engine separately enforces freshness before a new operation.", |
| - "tests": "Real inherited-descriptor subprocesses plus Linux inotify IN_OPEN prove wrong signer, signed scope, configuration and transport scope refuse before any database open. Actual pinned modernc connection accepts the valid path; wrong inode is opened only for identity refusal. Closed config tests cover every missing field, duplicates, unknown fields, trailing data and key mismatch.", |
| - "remaining": ["Fable approval of configuration alignment and component", "Final fixed command dispatch, role-specific key/sealer/owner resolver and schema checks", "Native worker authentication, owner approval, lease/fence/checkpoint and resume workflow", "Whole integration/canary validation before any live invocation"] |
| + "tests": "Real inherited-descriptor subprocesses plus Linux inotify IN_OPEN prove wrong signer, signed scope, configuration and transport scope refuse before any database open. Actual pinned modernc connection accepts the valid path; wrong inode is opened only for identity refusal. Closed config tests cover every missing field, duplicates, unknown fields, trailing data and key mismatch. Real subprocess cleanup refuses a swapped database and expired caller deadline, refuses a second close, and parent checks original rows unchanged. Descriptor tests pair controlled and default Go runtime settings. Oversized valid JSON is refused.", |
| + "remaining": [ |
| + "Fable approval of configuration alignment and component", |
| + "Final fixed command dispatch, role-specific key/sealer/owner resolver and schema checks", |
| + "Native worker authentication, owner approval, lease/fence/checkpoint and resume workflow", |
| + "Whole integration/canary validation before any live invocation" |
| + ] |
| } |
diff --git a/internal/operator/command_linux.go b/internal/operator/command_linux.go| index 558bdbb..a632c4e 100644 |
| --- a/internal/operator/command_linux.go |
| +++ b/internal/operator/command_linux.go |
| @@ -91,6 +91,9 @@ func openCommandInvocation(parent context.Context, database string) (*commandInv |
| db, err := newBoundDatabase(launch.DatabasePath, launch.DatabaseDevice, launch.DatabaseInode, deadline) |
| if err != nil { |
| cancel() |
| + if err == ErrUnavailable { |
| + return nil, ErrUnavailable |
| + } |
| return nil, ErrRefused |
| } |
| keep = true |
diff --git a/internal/operator/command_linux_test.go b/internal/operator/command_linux_test.go| index 0ec2aa0..f998ddf 100644 |
| --- a/internal/operator/command_linux_test.go |
| +++ b/internal/operator/command_linux_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "bytes" |
| "context" |
| "crypto/sha256" |
| + "database/sql" |
| "encoding/hex" |
| "encoding/json" |
| "errors" |
| @@ -49,7 +50,7 @@ func TestCommandConfigRequiresExactPins(t *testing.T) { |
| } |
| fields[key] = value |
| } |
| - for _, bad := range []string{"", string(raw) + string(raw), strings.Repeat("x", 8193), strings.Replace(string(raw), `"version":1`, `"version":1,"version":1`, 1), strings.Replace(string(raw), `"version":1`, `"version":1,"extra":"x"`, 1), strings.Replace(string(raw), v.kid, strings.Repeat("a", 64), 1)} { |
| + for _, bad := range []string{"", string(raw) + string(raw), strings.Repeat("x", 8193), string(raw) + strings.Repeat(" ", 8193-len(raw)), strings.Replace(string(raw), `"version":1`, `"version":1,"version":1`, 1), strings.Replace(string(raw), `"version":1`, `"version":1,"extra":"x"`, 1), strings.Replace(string(raw), v.kid, strings.Repeat("a", 64), 1)} { |
| if _, _, err := parseCommandConfig(bad); err == nil { |
| t.Fatal("bad configuration accepted") |
| } |
| @@ -69,7 +70,19 @@ func TestCommandInvocationChild(t *testing.T) { |
| if i.request.Authorization().OperationID != token(4) || i.launch.RequestID != token(5) || i.launch.AttemptID != token(6) || i.database.db.QueryRowContext(i.ctx, "SELECT count(*) FROM changes").Scan(&count) != nil || count != 0 { |
| os.Exit(3) |
| } |
| - if err := i.close(); err != nil { |
| + var wantClose error |
| + switch os.Getenv("HOME_COMMAND_FIXTURE") { |
| + case "swap-before-close": |
| + path := os.Getenv("HOME_COMMAND_DATABASE") |
| + if os.Rename(path, path+".saved") != nil || os.Rename(path+".other", path) != nil { |
| + os.Exit(7) |
| + } |
| + wantClose = ErrUnavailable |
| + case "deadline-before-close": |
| + <-ctx.Done() |
| + wantClose = ErrUnavailable |
| + } |
| + if err := i.close(); err != wantClose { |
| os.Exit(4) |
| } |
| if i.close() != ErrRefused { |
| @@ -88,7 +101,7 @@ func TestCommandInvocationBindsSignatureBeforeOpeningDatabase(t *testing.T) { |
| t.Fatal(err) |
| } |
| imageHash := sha256.Sum256(image) |
| - for _, mode := range []string{"valid", "expired-authentic", "signer", "origin", "registry", "role", "action", "config-key", "transport-account", "transport-app", "transport-host", "inode"} { |
| + for _, mode := range []string{"valid", "runtime-default", "swap-before-close", "deadline-before-close", "expired-authentic", "signer", "origin", "registry", "role", "action", "config-key", "transport-account", "transport-app", "transport-host", "inode"} { |
| t.Run(mode, func(t *testing.T) { |
| k, v, a := fixture(t) |
| cfg := commandConfigFixture(v) |
| @@ -97,10 +110,11 @@ func TestCommandInvocationBindsSignatureBeforeOpeningDatabase(t *testing.T) { |
| } |
| switch mode { |
| case "signer": |
| - k, err = family.Generate() |
| - if err != nil { |
| - t.Fatal(err) |
| + otherKey, generateErr := family.Generate() |
| + if generateErr != nil { |
| + t.Fatal(generateErr) |
| } |
| + k = otherKey |
| case "origin": |
| a.Origin = "https://other.example" |
| case "registry": |
| @@ -117,6 +131,9 @@ func TestCommandInvocationBindsSignatureBeforeOpeningDatabase(t *testing.T) { |
| dir := t.TempDir() |
| database := filepath.Join(dir, "home.db") |
| seedOpenDB(t, database) |
| + if mode == "swap-before-close" { |
| + seedOpenDB(t, database+".other") |
| + } |
| var st unix.Stat_t |
| if unix.Stat(database, &st) != nil { |
| t.Fatal("database stat") |
| @@ -175,6 +192,9 @@ func TestCommandInvocationBindsSignatureBeforeOpeningDatabase(t *testing.T) { |
| cmd := exec.CommandContext(ctx, path, "-test.run=^TestCommandInvocationChild$") |
| cmd.Dir = "/" |
| cmd.Env = []string{"HOME_COMMAND_FIXTURE=" + mode, "HOME_COMMAND_DATABASE=" + database, commandConfigEnvironment + "=" + string(config)} |
| + if mode != "runtime-default" { |
| + cmd.Env = append(cmd.Env, "GODEBUG=containermaxprocs=0,updatemaxprocs=0") |
| + } |
| cmd.Stdin = inR |
| cmd.ExtraFiles = []*os.File{contextR, pin} |
| var output bytes.Buffer |
| @@ -201,7 +221,7 @@ func TestCommandInvocationBindsSignatureBeforeOpeningDatabase(t *testing.T) { |
| <-writes |
| <-writes |
| var result struct{ Accepted bool } |
| - want := mode == "valid" || mode == "expired-authentic" |
| + want := mode == "valid" || mode == "runtime-default" || mode == "swap-before-close" || mode == "deadline-before-close" || mode == "expired-authentic" |
| if json.Unmarshal(output.Bytes(), &result) != nil || result.Accepted != want { |
| t.Fatal("wrong invocation result", output.String()) |
| } |
| @@ -211,6 +231,22 @@ func TestCommandInvocationBindsSignatureBeforeOpeningDatabase(t *testing.T) { |
| if err != nil && !errors.Is(err, unix.EAGAIN) { |
| t.Fatal(err) |
| } |
| + if mode == "swap-before-close" || mode == "deadline-before-close" { |
| + original := database |
| + if mode == "swap-before-close" { |
| + original += ".saved" |
| + } |
| + db, err := sql.Open("sqlite", "file:"+original+"?mode=ro") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var count int |
| + queryErr := db.QueryRow("SELECT count(*) FROM changes").Scan(&count) |
| + closeErr := db.Close() |
| + if queryErr != nil || closeErr != nil || count != 0 { |
| + t.Fatal("original database changed", queryErr, closeErr, count) |
| + } |
| + } |
| if opened != (want || mode == "inode") { |
| t.Fatal("database opened before signed scope validation", mode, opened) |
| } |