From 2ae989f82af9096d56e22b5b89aef86cef2a71f6 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Tue, 28 Jul 2026 23:16:57 +0000 Subject: [PATCH] Sync Pitot from Intelligence Flow @ 4cd27ab7a10e --- README.md | 86 ++++++++--- UPSTREAM.json | 27 ++-- cmd/pitot/kimi_control_test.go | 42 ++++-- cmd/pitot/kimi_smoke_test.go | 4 +- cmd/pitot/main.go | 22 ++- cmd/pitot/main_test.go | 104 +++++++++++++- cmd/pitot/workbench.go | 148 ++++++++++++++----- cmd/pitot/workbench_build_test.go | 23 ++- cmd/pitot/workbench_contract_test.go | 57 ++++---- cmd/pitot/workbench_dev_test.go | 24 ++-- cmd/pitot/workbench_test.go | 123 +++++++++++----- config/config.go | 204 +++++++++++++++++++++++++-- config/merge_test.go | 191 +++++++++++++++++++++++++ runtime/runtime.go | 8 +- 14 files changed, 882 insertions(+), 181 deletions(-) create mode 100644 config/merge_test.go diff --git a/README.md b/README.md index 3c615eb..b1b2f81 100644 --- a/README.md +++ b/README.md @@ -201,21 +201,21 @@ pitot doctor ## Quickstart -**1. Scaffold a Controller.** `pitot init` writes a runnable project — source, a -package manifest, and `.pitot.yaml` — and never overwrites existing files unless -you pass `--force`. Pick a starting template with `--template`: +**1. Scaffold a Controller.** `pitot init` writes a runnable project — source +and a package manifest — and registers it as one tenant fragment under +`.pitot/conf.d/`. It never overwrites existing files unless you pass `--force`. +Pick a starting template with `--template`: ```bash -pitot init --template shell-policy --language go --dir ./kimi-policy +pitot init --template shell-policy --language go --dir kimi-policy ``` ``` -Initialized go controller (shell-policy) in ./kimi-policy -Files written: .pitot.yaml, go.mod, main.go +Initialized go controller (shell-policy) in kimi-policy +Files written: .pitot/conf.d/kimi-policy.yaml, kimi-policy/go.mod, kimi-policy/main.go Next: - 1. cd ./kimi-policy - 2. Configure a supported host hook (see: pitot doctor --host HOST). - 3. Run: pitot dev --host HOST -- AGENT [ARGS...] + 1. Configure a supported host hook (see: pitot doctor --host HOST). + 2. Run: pitot dev --host HOST -- AGENT [ARGS...] example: pitot dev --host kimi -- kimi -p "" ``` @@ -226,10 +226,11 @@ detects the language from the current directory or prompts you to choose. The four first-class languages (`python`, `typescript`, `go`, `rust`) each generate a complete project that builds after installing dependencies. -**2. Run your agent behind it.** `pitot dev` starts the runtime and the -Controllers declared in `.pitot.yaml`, waits until the runtime is ready, then -launches the agent you name after `--` with `PITOT_RUNTIME` set so its host hook -finds the runtime. It prints each decision as the agent makes it: +**2. Run your agent behind it.** `pitot dev` discovers every fragment under +`.pitot/conf.d/`, starts the runtime and the declared Controllers, waits until +the runtime is ready, then launches the agent you name after `--` with +`PITOT_RUNTIME` set so its host hook finds the runtime. It prints each decision +as the agent makes it: ```bash pitot dev --host kimi -- kimi -p "Run: PITOT_DENY_ME=1 echo nope" @@ -250,9 +251,9 @@ already be wired to `pitot hook HOST` (see **Connect your agent** and temporary path and is removed on exit, so concurrent `pitot dev` sessions never collide. -**3. Swap the agent.** The same project — the same Controller and `.pitot.yaml` — -works with any other supported host whose hook is wired. Change only `--host` and -the agent command: +**3. Swap the agent.** The same project — the same Controller and the same +fragment — works with any other supported host whose hook is wired. Change only +`--host` and the agent command: ```bash pitot dev --host cursor -- cursor-agent -p "Run: PITOT_DENY_ME=1 echo nope" @@ -260,6 +261,51 @@ pitot dev --host cursor -- cursor-agent -p "Run: PITOT_DENY_ME=1 echo nope" The boundary is language- and agent-neutral: one Controller, every agent. +## Multiple tools, one Pitot + +Configuration is tenant-partitioned: every tool or person that registers +processes with Pitot owns exactly one fragment in `.pitot/conf.d/`, and no +tenant ever edits another tenant's file. The effective configuration is the +deterministic merge of all fragments in filename order: + +``` +.pitot/ + conf.d/ + boatstack.yaml # a tool's controller, written by its installer + interlock.yaml # another tool's controller, different request kind + my-policy.yaml # your own, scaffolded by `pitot init` +``` + +Each fragment is a complete, strictly parsed mini-config declaring +`controllers:` and/or `consumers:`. Merge rules: + +- **Consumers always compose.** Any number of tenants can observe + `action.requested` events. +- **A request kind has one owner.** Two fragments claiming the same kind (for + example `shell`) fail discovery with an error naming both files — a loud, + attributable conflict instead of two tools silently fighting over one + blocking hook. Controller and consumer ids must also be unique across + fragments. +- **`requires_protocol: "1"`** optionally pins the protocol version a fragment + was written against; a fragment this binary cannot honor fails discovery. +- **`dir:`** sets a process's working directory (relative to the repository + root), so each tenant's command stays project-relative: + +```yaml +controllers: + shell: + id: local-shell-policy + command: ["go", "run", "main.go"] + dir: "kimi-policy" + deadline_ms: 2000 + on_timeout: deny + on_unavailable: deny +``` + +Installing a second Pitot-based tool is therefore additive by construction: it +drops its own fragment next to yours, `pitot run`/`pitot dev` merge them, and +uninstalling it is deleting its fragment. + ## Advanced: manual runtime `pitot dev` is the recommended path. If you need to manage the runtime yourself @@ -269,9 +315,13 @@ descriptor: ```bash export PITOT_RUNTIME="${XDG_RUNTIME_DIR:-$TMPDIR}/pitot/project.json" -pitot run --config .pitot.yaml --runtime "$PITOT_RUNTIME" +pitot run --runtime "$PITOT_RUNTIME" ``` +With no `--config`, `pitot run` discovers and merges the repository's +`.pitot/conf.d/` fragments. Pass `--config PATH` to override discovery with one +explicit file (useful for tests and ad-hoc runtimes). + Start coding-agent CLIs from the same environment. Their `pitot hook HOST` commands discover the authenticated runtime through `PITOT_RUNTIME`. Without that variable or `--runtime PATH`, hooks remain observation-only for backwards @@ -282,7 +332,7 @@ On Windows, set the descriptor in the launching PowerShell session: ```powershell $env:PITOT_RUNTIME = Join-Path $env:LOCALAPPDATA "Pitot\project.json" -pitot run --config .pitot.yaml --runtime $env:PITOT_RUNTIME +pitot run --runtime $env:PITOT_RUNTIME ``` ## Supported hosts diff --git a/UPSTREAM.json b/UPSTREAM.json index f615c06..25764ca 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "files": { "CONTRIBUTING.md": "23728d8a132d62b8adfb2e5c3eb9d9bfcf8a4d04543765b1e22ad8d55424af8f", - "README.md": "bd662302b629066b630dbb4c274174a4df61e98ab699417b481d7a48590de40b", + "README.md": "e4a7587107f7db1eb75859f62f6abfb780c99e2b210c808dda3aaa25612eb172", "adapter-verification.json": "f8ad4e206571650f698826a8b66d8c00822be425e8d2de8ae98d98239e575eb4", "adapters/adapters.go": "1b46ba131fa3b2c93eed23526330275a3506451ba4bbd4f497e5378dfab2b6a8", "assets/pitot-boundary.png": "8a0ddb7d81831d94e14813f50ea4ca8670d77417f339ed2f91f0c653bf52f41d", @@ -16,17 +16,18 @@ "cmd/generate-schema/main.go": "6e9d0030290d99e36967433f96e38385a122974f899ad9421aac1ef7e50d8fcb", "cmd/pitot/doctor_host.go": "7ecade40618bfb3510ae8e55fa802361371b4f7fbafedcd61233d19ef46cb219", "cmd/pitot/doctor_host_test.go": "4e6e327f6cf27cf94a0a608e10eb6790d6c11fcd53e6dfd7370007190749952f", - "cmd/pitot/kimi_control_test.go": "b3803a9bbdecf5f7e9a3dca90e317bdb94b4ba9bfa21d369d5a152d6742eac63", - "cmd/pitot/kimi_smoke_test.go": "6c2b92a8d3257955617d1387bc3f788846e0be091742c074a762f2b5cd04fbdf", - "cmd/pitot/main.go": "27d00919d7cc687e2b58c930024aba2ae7009a768af0abd9e73abfe123f1c8c3", - "cmd/pitot/main_test.go": "544997295e0c4b75ef8f3d698b3de0883153f671b6b8f62057cc6e3452d6dc93", - "cmd/pitot/workbench.go": "70497ca0fd5579d8c1df5350e037cb46096038449293814f995b8c209a99b215", - "cmd/pitot/workbench_build_test.go": "8e9ae497a03c9f1ca0a1fc9ebf821df3a1869993cbb2cd568da3996d437551ec", - "cmd/pitot/workbench_contract_test.go": "f88ba5a34d16d1a18fd2a4fed54c7b4cbb62fb3ffbd0eb6091e2143ba89234c5", - "cmd/pitot/workbench_dev_test.go": "9693e84f24facd7d97cefcc92d95c0e6950d9422a7c23f8b07487bbd35df2eac", - "cmd/pitot/workbench_test.go": "457caa11cd4b73c1fb4e0dad806b3050b196ddac690a8125f1615a4c695cc073", - "config/config.go": "e6666567d0c0cca41de69361e8f1243adda1ec0a54a9300b39a84d2290bff319", + "cmd/pitot/kimi_control_test.go": "27b38867d4799636a664e3b1726ef55568f5dfd6a9be11f0ce5eca9931d759a4", + "cmd/pitot/kimi_smoke_test.go": "01cbf18312902cac42ec1f2547d35362c2bc920938acea508ba7c7f0638a9473", + "cmd/pitot/main.go": "9baae94a571c20b7c2d4e11985026ebaa8fe256372ce70c3198a73cc82d8390b", + "cmd/pitot/main_test.go": "b381fe30dafe3299c82fe23e1899b64f79c4a2b27059b78823179625e3b6679e", + "cmd/pitot/workbench.go": "976c81951c565da1a897637566f662c493b2fe6d279debe852dc783b6fac1db4", + "cmd/pitot/workbench_build_test.go": "8d5c5c35e8cbd59e21cfdd7e206d6b5b769892ab27a99fe5d87b13b1a31d0714", + "cmd/pitot/workbench_contract_test.go": "5e465f3d3f8b93ffaacfb4738279d369b13514bf7613b8611952f3cd26896586", + "cmd/pitot/workbench_dev_test.go": "abffe81e1a25f086d7f3c2f1c32986ee93618bbfc87220bdfda840ec6c6466bf", + "cmd/pitot/workbench_test.go": "3c561498dfee4aacf6935fe4b0bfe4449c3961c169c04f1f13e81a38b22d0914", + "config/config.go": "fa734117191ab941cfa92db82a9a121368fd604d7e556649c1b14b924b20b7f7", "config/config_test.go": "87d3e5ddc4a3b43c736070de671d03e03ffe29cdd759771526ad27fd9bc0034c", + "config/merge_test.go": "595d2c96ac879cca7c57b77b99504f34629b8aa55b38d515f35ad76c006ef532", "conformance/conformance.go": "43b692114f45c8b52958e34b35aee1cee339d8321c90f92ab4f5b963e79935bb", "conformance/conformance_test.go": "83ab0bcc15371265a954d177e4e97d81ad3ea734bbf736a29a54628ef64b52cd", "conformance/fixtures/negative.jsonl": "503ea76988df595d96ebf695f991b8ea6c892be4a578522dff4ddb0d39b647e4", @@ -59,7 +60,7 @@ "runtime/descriptor_windows.go": "2d9ffefe3af0154fa8042de6b67460d4e86dd3f4cdd9e986f180f7d0c535c9a5", "runtime/request.go": "198c44fd6c547022a15b6d0d48e4d0130fa8afb687994365115576e4d874550d", "runtime/request_test.go": "86d8a2feb4ec72e8ed675b9567da2d1f5d628950eeec907c10b9cc1675aa1904", - "runtime/runtime.go": "e0624a16ac9f79e8080246042912c73b1588f1b9aeb4f9f95b3711c7acef7e81", + "runtime/runtime.go": "b90072bf119c9121e3d185fa27e8ac372ec9dcb33c9f38fea8050c314b9dd5e4", "runtime/runtime_test.go": "afd78d122af20148bf30d0db873ff002544189df0dfec0f6167b8cf5cd0d42b1", "runtime/transport.go": "83e2218fb28474e875dafa6943bc5b665acef0565aaf5955fa88b1b4fd21614e", "runtime/transport_test.go": "9b69f590f1e258470adea249b3ac6d4a00f1001f10bb08dfa7b56c6e2d6709ae", @@ -120,7 +121,7 @@ "generator": "operatorstack/pitot:project", "schema_version": 1, "source": { - "commit": "44cdd76953435a74daac53faa5917bbb19deb014", + "commit": "4cd27ab7a10e85184157f71249708254fbf7c208", "path": "labs/15-pitot", "repository": "operatorstack/intelligence-flow" } diff --git a/cmd/pitot/kimi_control_test.go b/cmd/pitot/kimi_control_test.go index 1c05f34..93832e3 100644 --- a/cmd/pitot/kimi_control_test.go +++ b/cmd/pitot/kimi_control_test.go @@ -22,27 +22,45 @@ import ( func buildGeneratedShellPolicy(t *testing.T) (binPath, configWithBinary string) { t.Helper() - proj := filepath.Join(t.TempDir(), "shell-policy-proj") - var out, errb bytes.Buffer - if err := runInit([]string{"--language", "go", "--template", "shell-policy", "--dir", proj}, strings.NewReader(""), &out, &errb); err != nil { - t.Fatalf("init shell-policy: %v\n%s", err, errb.String()) + // Resolve the in-tree module root (cmd/pitot -> module root) before leaving + // the package directory, so the generated project can resolve the SDK with + // a filesystem replace. + moduleRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) } - // The generated config must register the controller under the shell kind. - cfg, err := os.ReadFile(filepath.Join(proj, ".pitot.yaml")) + // Scaffold inside a scratch repository root so the tenant fragment lands in + // a temporary .pitot/conf.d, then restore the caller's working directory. + root := t.TempDir() + previous, err := os.Getwd() if err != nil { t.Fatal(err) } - if !strings.Contains(string(cfg), "shell:") || !strings.Contains(string(cfg), "local-shell-policy") { - t.Fatalf("generated config does not register the shell-policy controller under shell:\n%s", cfg) + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(previous); err != nil { + t.Fatal(err) + } + }() + + var out, errb bytes.Buffer + if err := runInit([]string{"--language", "go", "--template", "shell-policy", "--dir", "shell-policy-proj"}, strings.NewReader(""), &out, &errb); err != nil { + t.Fatalf("init shell-policy: %v\n%s", err, errb.String()) } + proj := filepath.Join(root, "shell-policy-proj") - // Resolve the in-tree module root (cmd/pitot -> module root) and add a - // filesystem replace so the generated project resolves the SDK locally. - moduleRoot, err := filepath.Abs(filepath.Join("..", "..")) + // The generated fragment must register the controller under the shell kind + // with the tenant-scoped id (the fragment name). + cfg, err := os.ReadFile(filepath.Join(root, ".pitot", "conf.d", "shell-policy-proj.yaml")) if err != nil { t.Fatal(err) } + if !strings.Contains(string(cfg), "shell:") || !strings.Contains(string(cfg), "shell-policy-proj") { + t.Fatalf("generated fragment does not register the shell-policy controller under shell:\n%s", cfg) + } gomod := filepath.Join(proj, "go.mod") existing, err := os.ReadFile(gomod) if err != nil { @@ -69,7 +87,7 @@ func buildGeneratedShellPolicy(t *testing.T) (binPath, configWithBinary string) // per-spawn `go run` compile and any working-directory coupling. configWithBinary = fmt.Sprintf(`controllers: shell: - id: local-shell-policy + id: shell-policy-proj command: [%q] deadline_ms: 2000 on_timeout: deny diff --git a/cmd/pitot/kimi_smoke_test.go b/cmd/pitot/kimi_smoke_test.go index dac66db..281e823 100644 --- a/cmd/pitot/kimi_smoke_test.go +++ b/cmd/pitot/kimi_smoke_test.go @@ -78,9 +78,7 @@ func TestKimiSmokeRealCLI(t *testing.T) { _, configBody := buildGeneratedShellPolicy(t) proj := t.TempDir() - if err := os.WriteFile(filepath.Join(proj, ".pitot.yaml"), []byte(configBody), 0o600); err != nil { - t.Fatal(err) - } + writeFragment(t, proj, "shell-policy", configBody) // A private Kimi home whose config.toml wires the PreToolUse hook to pitot. kimiHome := t.TempDir() diff --git a/cmd/pitot/main.go b/cmd/pitot/main.go index ad6ceb5..c57b3e9 100644 --- a/cmd/pitot/main.go +++ b/cmd/pitot/main.go @@ -254,16 +254,22 @@ func runRuntime(ctx context.Context, args []string, stdout, stderr io.Writer) er return fmt.Errorf("pitot: unexpected argument %q", args[i]) } } - if configPath == "" { - return errors.New("pitot: run requires --config PATH") - } if runtimePath == "" { runtimePath = os.Getenv("PITOT_RUNTIME") } if runtimePath == "" { return errors.New("pitot: run requires --runtime PATH or PITOT_RUNTIME") } - loaded, err := config.Load(configPath) + var loaded config.Loaded + var err error + if configPath == "" { + loaded, err = config.Discover(".") + if errors.Is(err, config.ErrNoConfig) { + return errors.New("pitot: no config fragments under .pitot/conf.d (run 'pitot init' to register a controller or consumer, or pass --config PATH)") + } + } else { + loaded, err = config.Load(configPath) + } if err != nil { return err } @@ -300,12 +306,16 @@ func usage() string { return `pitot — the open sensor and control transport for coding-agent tooling usage: - pitot init [--language python|typescript|go|rust] [--role consumer|controller] [--template shell-policy|release-approval|blank-controller|blank-consumer] [--dir PATH] [--force] + pitot init [--language python|typescript|go|rust] [--role consumer|controller] [--template shell-policy|release-approval|blank-controller|blank-consumer] [--dir PATH] [--fragment NAME] [--force] pitot dev --host HOST -- AGENT [ARGS...] pitot doctor [--host HOST] - pitot run --config PATH --runtime PATH + pitot run [--config PATH] --runtime PATH pitot hook HOST [--runtime PATH] pitot request KIND [--data JSON] --runtime PATH + +configuration is tenant-partitioned: each tool or user registers its processes +in its own fragment under .pitot/conf.d/; the runtime merges every fragment and +rejects collisions. --config PATH overrides discovery with one explicit file. ` } diff --git a/cmd/pitot/main_test.go b/cmd/pitot/main_test.go index 939e995..ea7e99c 100644 --- a/cmd/pitot/main_test.go +++ b/cmd/pitot/main_test.go @@ -45,10 +45,18 @@ func TestDoctorReportsBoundary(t *testing.T) { } } -func TestRunRequiresConfig(t *testing.T) { +func TestRunRequiresConfigOrFragments(t *testing.T) { + t.Setenv("PITOT_RUNTIME", "") var stdout, stderr bytes.Buffer if err := run([]string{"run"}, &stdout, &stderr); err == nil { - t.Fatal("expected run without --config to fail") + t.Fatal("expected run without a runtime path to fail") + } + // With a runtime path but neither --config nor fragments, run must point + // the user at .pitot/conf.d. + t.Chdir(t.TempDir()) + err := run([]string{"run", "--runtime", "runtime.json"}, &stdout, &stderr) + if err == nil || !strings.Contains(err.Error(), ".pitot/conf.d") { + t.Fatalf("expected no-fragments guidance, got %v", err) } } @@ -128,6 +136,98 @@ controllers: } } +// TestRunDiscoversFragmentsAndHonorsDir proves the tenant model end to end: +// `pitot run` with no --config merges the .pitot/conf.d fragments in the +// working directory, a controller from one tenant resolves explicit requests, +// and a consumer declared with dir: runs in that working directory — its +// relative receipt path lands inside the tenant's own directory. +func TestRunDiscoversFragmentsAndHonorsDir(t *testing.T) { + t.Setenv("PITOT_RUNTIME", "") + helper := buildTestRole(t) // build before chdir: it compiles from the package dir + runtimePath := filepath.Join(t.TempDir(), "runtime.json") + t.Chdir(t.TempDir()) + + controllerFragment := fmt.Sprintf(`controllers: + release.approval: + id: release-policy + command: [%q, "--role", "controller", "--id", "release-policy", "--nonce", "cli"] + deadline_ms: 2000 + on_timeout: deny + on_unavailable: deny +`, helper) + consumerFragment := fmt.Sprintf(`consumers: + - id: audit + command: [%q, "--role", "consumer", "--receipt", "receipt.jsonl"] + dir: "tenant-b" + events: ["action.requested"] + projection: {content: omit} +`, helper) + confDir := filepath.Join(".pitot", "conf.d") + if err := os.MkdirAll(confDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(confDir, "a.yaml"), []byte(controllerFragment), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(confDir, "b.yaml"), []byte(consumerFragment), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll("tenant-b", 0o755); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var runtimeOut bytes.Buffer + var runtimeErr lockedBuffer + done := make(chan error, 1) + go func() { + done <- runWithIO(ctx, []string{"run", "--runtime", runtimePath}, strings.NewReader(""), &runtimeOut, &runtimeErr) + }() + for i := 0; i < 250; i++ { + if _, err := os.Stat(runtimePath); err == nil { + break + } + time.Sleep(20 * time.Millisecond) + } + if _, err := os.Stat(runtimePath); err != nil { + t.Fatalf("runtime did not become ready: %v\n%s", err, runtimeErr.String()) + } + + // The controller tenant answers explicit requests through the merged config. + var requestOut bytes.Buffer + if err := runWithIO(context.Background(), []string{"request", "release.approval", "--data", `{"phase":"ship"}`, "--runtime", runtimePath}, strings.NewReader(""), &requestOut, &bytes.Buffer{}); err != nil { + t.Fatalf("request through merged config: %v\n%s", err, requestOut.String()) + } + if !strings.Contains(requestOut.String(), `"outcome":"allow"`) { + t.Fatalf("request outcome: %s", requestOut.String()) + } + + // A hook observation fans to the consumer tenant, whose relative receipt + // path must resolve inside its declared dir. + payload := `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status"}}` + var hookOut, hookErr bytes.Buffer + if err := runWithIO(context.Background(), []string{"hook", "claude", "--runtime", runtimePath}, strings.NewReader(payload), &hookOut, &hookErr); err != nil { + t.Fatalf("hook err=%v stderr=%s", err, hookErr.String()) + } + receipt := filepath.Join("tenant-b", "receipt.jsonl") + deadline := time.Now().Add(5 * time.Second) + for { + if data, err := os.ReadFile(receipt); err == nil && strings.Contains(string(data), `"type":"action.requested"`) { + break + } + if time.Now().After(deadline) { + t.Fatalf("consumer receipt never appeared in tenant dir %s\n%s", receipt, runtimeErr.String()) + } + time.Sleep(20 * time.Millisecond) + } + + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } +} + func TestUnknownCommandFails(t *testing.T) { var stdout, stderr bytes.Buffer if err := run([]string{"fly"}, &stdout, &stderr); err == nil { diff --git a/cmd/pitot/workbench.go b/cmd/pitot/workbench.go index ce40994..2800a45 100644 --- a/cmd/pitot/workbench.go +++ b/cmd/pitot/workbench.go @@ -35,15 +35,19 @@ var supportedRoles = []string{"consumer", "controller"} // kind; the others target the test.approval kind or the consumer role. var supportedTemplates = []string{"shell-policy", "release-approval", "blank-controller", "blank-consumer"} -// runInit scaffolds a complete, runnable Pitot project. It validates its inputs, -// detects or interactively selects the language and role, refuses to overwrite -// existing files unless --force is set, and writes a package manifest alongside -// the source so the generated project builds and runs without further setup. +// runInit scaffolds a complete, runnable Pitot project and registers it as one +// tenant fragment under .pitot/conf.d/. It validates its inputs, detects or +// interactively selects the language and role, refuses to overwrite existing +// files unless --force is set, and writes a package manifest alongside the +// source so the generated project builds and runs without further setup. +// Because every registration is its own fragment, running init in a repository +// that already hosts other Pitot tenants is additive by construction. func runInit(args []string, stdin io.Reader, stdout, stderr io.Writer) error { lang := "" role := "" template := "" dir := "pitot-project" + fragment := "" force := false for i := 0; i < len(args); i++ { @@ -72,6 +76,12 @@ func runInit(args []string, stdin io.Reader, stdout, stderr io.Writer) error { } dir = args[i+1] i++ + case "--fragment": + if i+1 >= len(args) { + return errors.New("pitot init: --fragment requires a name") + } + fragment = args[i+1] + i++ case "--force": force = true default: @@ -79,6 +89,13 @@ func runInit(args []string, stdin io.Reader, stdout, stderr io.Writer) error { } } + if fragment == "" { + fragment = filepath.Base(filepath.Clean(dir)) + } + if !validFragmentName(fragment) { + return fmt.Errorf("pitot init: invalid fragment name %q (want lowercase letters, digits, '.', '_', '-')", fragment) + } + interactive := isInteractive(stdin) reader := bufio.NewReader(stdin) @@ -135,17 +152,36 @@ func runInit(args []string, stdin io.Reader, stdout, stderr io.Writer) error { if err != nil { return err } + // Tenant-scoped identity: the fragment name doubles as the controller or + // consumer id, so two scaffolded tenants never collide on the fixed + // template ids. The generated source carries the same identity. + for name, content := range files { + files[name] = strings.ReplaceAll(content, defaultRoleID(template), fragment) + } + fragmentPath := filepath.Join(config.FragmentDir, fragment+".yaml") + fragmentBody := pitotConfig(lang, template, dir, fragment) - // Non-destructive: refuse to clobber existing files unless --force. + // Pre-flight the tenancy contract before writing anything: the new + // registration must merge cleanly with every existing tenant, so a + // collision surfaces at registration time, not at the next `pitot run`. + if err := config.Preflight(".", fragment+".yaml", []byte(fragmentBody)); err != nil { + return fmt.Errorf("pitot init: %s does not merge with the existing tenants: %w", filepath.ToSlash(fragmentPath), err) + } + + // Non-destructive: refuse to clobber existing files unless --force. The + // fragment path is checked too — an existing fragment belongs to a tenant. if !force { var conflicts []string for name := range files { if _, statErr := os.Stat(filepath.Join(dir, name)); statErr == nil { - conflicts = append(conflicts, name) + conflicts = append(conflicts, filepath.ToSlash(filepath.Join(dir, name))) } } + if _, statErr := os.Stat(fragmentPath); statErr == nil { + conflicts = append(conflicts, filepath.ToSlash(fragmentPath)) + } if len(conflicts) > 0 { - return fmt.Errorf("pitot init: refusing to overwrite existing files in %s: %s (use --force)", dir, strings.Join(sorted(conflicts), ", ")) + return fmt.Errorf("pitot init: refusing to overwrite existing files: %s (use --force)", strings.Join(sorted(conflicts), ", ")) } } @@ -163,19 +199,47 @@ func runInit(args []string, stdin io.Reader, stdout, stderr io.Writer) error { return fmt.Errorf("pitot init: write %s: %w", name, err) } } + if err := os.MkdirAll(filepath.Dir(fragmentPath), 0o755); err != nil { + return fmt.Errorf("pitot init: create %s: %w", filepath.Dir(fragmentPath), err) + } + if err := os.WriteFile(fragmentPath, []byte(fragmentBody), 0o644); err != nil { + return fmt.Errorf("pitot init: write %s: %w", fragmentPath, err) + } + written := []string{filepath.ToSlash(fragmentPath)} + for _, name := range keys(files) { + written = append(written, filepath.ToSlash(filepath.Join(dir, name))) + } fmt.Fprintf(stdout, "Initialized %s %s (%s) in %s\n", lang, role, template, dir) - fmt.Fprintf(stdout, "Files written: %s\n", strings.Join(sorted(keys(files)), ", ")) - // The runtime launches the generated program from .pitot.yaml; the command - // after `--` is the coding agent Pitot supervises, never the Controller. + fmt.Fprintf(stdout, "Files written: %s\n", strings.Join(sorted(written), ", ")) + // The runtime launches the generated program from its conf.d fragment; the + // command after `--` is the coding agent Pitot supervises, never the + // Controller. Everything runs from the repository root. fmt.Fprintln(stdout, "Next:") - fmt.Fprintf(stdout, " 1. cd %s\n", dir) - fmt.Fprintln(stdout, " 2. Configure a supported host hook (see: pitot doctor --host HOST).") - fmt.Fprintln(stdout, " 3. Run: pitot dev --host HOST -- AGENT [ARGS...]") + fmt.Fprintln(stdout, " 1. Configure a supported host hook (see: pitot doctor --host HOST).") + fmt.Fprintln(stdout, " 2. Run: pitot dev --host HOST -- AGENT [ARGS...]") fmt.Fprintln(stdout, " example: pitot dev --host kimi -- kimi -p \"\"") return nil } +// validFragmentName keeps tenant fragment filenames portable and predictable: +// a lowercase alphanumeric first character, then lowercase letters, digits, +// dots, underscores, and dashes. +func validFragmentName(name string) bool { + if name == "" { + return false + } + for i, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + case i > 0 && (r == '.' || r == '_' || r == '-'): + default: + return false + } + } + return true +} + // resolveTemplate reconciles the explicit --template value with the resolved // role. An empty template defaults from the role (controller -> blank-controller, // consumer -> blank-consumer); an explicit template must not contradict an @@ -204,7 +268,7 @@ func templateRole(template string) string { return "controller" } -// controllerKind returns the .pitot.yaml request kind a controller template +// controllerKind returns the config request kind a controller template // registers under. Only shell-policy governs the shell action kind that host // adapters (Kimi, Claude, Codex, ...) normalize Bash/PreToolUse events into. func controllerKind(template string) string { @@ -214,17 +278,23 @@ func controllerKind(template string) string { return "test.approval" } -// controllerID returns the controller id embedded in the generated config and -// source for a template. -func controllerID(template string) string { - if template == "shell-policy" { +// defaultRoleID returns the placeholder identity embedded in a template's +// source; runInit replaces it with the tenant's fragment name so every +// scaffolded tenant carries a unique id. +func defaultRoleID(template string) string { + switch { + case template == "shell-policy": return "local-shell-policy" + case templateRole(template) == "consumer": + return "local-consumer" + default: + return "local-controller" } - return "local-controller" } -// projectFiles returns the complete file set for a language/template: source, -// package manifest(s), and the .pitot.yaml runtime configuration. +// projectFiles returns the complete project file set for a language/template: +// source and package manifest(s). The runtime registration is written +// separately as a tenant fragment under .pitot/conf.d/. func projectFiles(lang, template string) (map[string]string, error) { files := map[string]string{} @@ -251,7 +321,6 @@ func projectFiles(lang, template string) (map[string]string, error) { return nil, fmt.Errorf("pitot init: unsupported language %q", lang) } - files[".pitot.yaml"] = pitotConfig(lang, template) return files, nil } @@ -301,32 +370,36 @@ func sourceTemplate(lang, template string) (string, error) { } } -// pitotConfig renders the .pitot.yaml wiring the generated template to its run -// command. Consumers subscribe to events; controllers register for a request -// kind — shell-policy under "shell" (the kind host adapters normalize Bash -// events into), every other controller under "test.approval". -func pitotConfig(lang, template string) string { +// pitotConfig renders the tenant fragment wiring the generated template to its +// run command under the tenant's own id. Consumers subscribe to events; +// controllers register for a request kind — shell-policy under "shell" (the +// kind host adapters normalize Bash events into), every other controller under +// "test.approval". The dir field keeps the run command project-relative while +// the runtime starts at the repository root. +func pitotConfig(lang, template, dir, id string) string { cmdList := runCommandList(lang) if templateRole(template) == "consumer" { - return `consumers: - - id: local-consumer - command: ` + cmdList + ` + return fmt.Sprintf(`consumers: + - id: %s + command: %s + dir: %q events: ["action.requested"] projection: content: full -` +`, id, cmdList, dir) } return fmt.Sprintf(`controllers: %s: id: %s command: %s + dir: %q deadline_ms: 2000 on_timeout: deny on_unavailable: deny -`, controllerKind(template), controllerID(template), cmdList) +`, controllerKind(template), id, cmdList, dir) } -// runCommandList is the JSON array form embedded in .pitot.yaml. +// runCommandList is the JSON array form embedded in the tenant fragment. func runCommandList(lang string) string { switch lang { case "python": @@ -722,12 +795,11 @@ func runDev(ctx context.Context, args []string, stdout, stderr io.Writer) error programArgs = fields[1:] } - configPath := ".pitot.yaml" - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return errors.New("pitot dev: .pitot.yaml not found in current directory. Run 'pitot init' first") - } - loaded, err := config.Load(configPath) + loaded, err := config.Discover(".") if err != nil { + if errors.Is(err, config.ErrNoConfig) { + return errors.New("pitot dev: no config fragments under .pitot/conf.d. Run 'pitot init' first") + } return fmt.Errorf("pitot dev: load config: %w", err) } diff --git a/cmd/pitot/workbench_build_test.go b/cmd/pitot/workbench_build_test.go index f974a2f..e2ec800 100644 --- a/cmd/pitot/workbench_build_test.go +++ b/cmd/pitot/workbench_build_test.go @@ -20,16 +20,29 @@ func labRoot(t *testing.T) string { return p } -// initInto scaffolds a shell-policy project for lang into a fresh dir and returns -// the directory. It fails the test on any init error. +// initInto scaffolds a shell-policy project for lang inside a scratch +// repository root (so the tenant fragment lands in a temp .pitot/conf.d) and +// returns the absolute project directory. It fails the test on any init error. func initInto(t *testing.T, lang string) string { t.Helper() - dir := filepath.Join(t.TempDir(), "proj") + root := t.TempDir() + previous, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(previous); err != nil { + t.Fatal(err) + } + }() var out, errb bytes.Buffer - if err := runInit([]string{"--language", lang, "--template", "shell-policy", "--dir", dir}, strings.NewReader(""), &out, &errb); err != nil { + if err := runInit([]string{"--language", lang, "--template", "shell-policy", "--dir", "proj"}, strings.NewReader(""), &out, &errb); err != nil { t.Fatalf("init %s: %v\n%s", lang, err, errb.String()) } - return dir + return filepath.Join(root, "proj") } // TestBuildGeneratedGoShellPolicy makes the Go build a first-class Step-5 diff --git a/cmd/pitot/workbench_contract_test.go b/cmd/pitot/workbench_contract_test.go index 7b40018..5e4fd92 100644 --- a/cmd/pitot/workbench_contract_test.go +++ b/cmd/pitot/workbench_contract_test.go @@ -24,44 +24,47 @@ var shellPolicyExpect = map[string]struct { } // TestInitShellPolicyContract asserts the shell-policy scaffold is coherent per -// language: expected files exist, the config parses and registers the controller -// under the shell kind, and the source references the SDK controller API plus -// the deny canary. +// language: expected files exist, the tenant fragment discovers and registers +// the controller under the shell kind with the project working directory, and +// the source references the SDK controller API plus the deny canary. func TestInitShellPolicyContract(t *testing.T) { for lang, wantFiles := range initExpectations { lang, wantFiles := lang, wantFiles t.Run(lang, func(t *testing.T) { - dir := filepath.Join(t.TempDir(), "proj") + t.Chdir(t.TempDir()) var stdout, stderr bytes.Buffer - if err := runInit([]string{"--language", lang, "--template", "shell-policy", "--dir", dir}, strings.NewReader(""), &stdout, &stderr); err != nil { + if err := runInit([]string{"--language", lang, "--template", "shell-policy", "--dir", "proj"}, strings.NewReader(""), &stdout, &stderr); err != nil { t.Fatalf("init shell-policy %s: %v", lang, err) } for _, name := range wantFiles { - if _, statErr := readIf(dir, name); statErr != nil { + if _, statErr := readIf("proj", name); statErr != nil { t.Errorf("%s: expected generated file %q: %v", lang, name, statErr) } } - // The generated config must parse and register the controller for shell. - loaded, err := config.Load(filepath.Join(dir, ".pitot.yaml")) + // The generated fragment must discover and register the controller for shell. + loaded, err := config.Discover(".") if err != nil { - t.Fatalf("%s: generated .pitot.yaml did not parse: %v", lang, err) + t.Fatalf("%s: generated fragment did not discover: %v", lang, err) } ctrl, ok := loaded.Config.Controllers["shell"] if !ok { t.Fatalf("%s: controller not registered under shell kind: %+v", lang, loaded.Config.Controllers) } - if ctrl.ID != "local-shell-policy" { - t.Errorf("%s: controller id = %q, want local-shell-policy", lang, ctrl.ID) + if ctrl.ID != "proj" { + t.Errorf("%s: controller id = %q, want the tenant-scoped id proj", lang, ctrl.ID) } if len(ctrl.Command) == 0 { t.Errorf("%s: controller command is empty", lang) } + if ctrl.Dir != "proj" { + t.Errorf("%s: controller dir = %q, want proj", lang, ctrl.Dir) + } // The source must use the SDK controller API and the deny canary. want := shellPolicyExpect[lang] - src, err := readIf(dir, want.sourceFile) + src, err := readIf("proj", want.sourceFile) if err != nil { t.Fatalf("%s: read source: %v", lang, err) } @@ -71,8 +74,8 @@ func TestInitShellPolicyContract(t *testing.T) { if !strings.Contains(src, "PITOT_DENY_ME") { t.Errorf("%s: source missing PITOT_DENY_ME canary:\n%s", lang, src) } - if !strings.Contains(src, "local-shell-policy") { - t.Errorf("%s: source missing local-shell-policy id:\n%s", lang, src) + if !strings.Contains(src, `"proj"`) { + t.Errorf("%s: source missing the tenant-scoped id proj:\n%s", lang, src) } }) } @@ -82,9 +85,9 @@ func TestInitShellPolicyContract(t *testing.T) { // hint must point users at `pitot dev --host HOST -- AGENT`, never at `--exec` // with the Controller command. func TestInitNextStepLaunchesAgentNotController(t *testing.T) { - dir := filepath.Join(t.TempDir(), "proj") + t.Chdir(t.TempDir()) var stdout, stderr bytes.Buffer - if err := runInit([]string{"--language", "go", "--template", "shell-policy", "--dir", dir}, strings.NewReader(""), &stdout, &stderr); err != nil { + if err := runInit([]string{"--language", "go", "--template", "shell-policy", "--dir", "proj"}, strings.NewReader(""), &stdout, &stderr); err != nil { t.Fatalf("init: %v", err) } out := stdout.String() @@ -105,14 +108,14 @@ func TestInitBlankControllerUsesApprovalKind(t *testing.T) { for _, template := range []string{"blank-controller", "release-approval"} { template := template t.Run(template, func(t *testing.T) { - dir := filepath.Join(t.TempDir(), "proj") + t.Chdir(t.TempDir()) var stdout, stderr bytes.Buffer - if err := runInit([]string{"--language", "go", "--template", template, "--dir", dir}, strings.NewReader(""), &stdout, &stderr); err != nil { + if err := runInit([]string{"--language", "go", "--template", template, "--dir", "proj"}, strings.NewReader(""), &stdout, &stderr); err != nil { t.Fatalf("init %s: %v", template, err) } - loaded, err := config.Load(filepath.Join(dir, ".pitot.yaml")) + loaded, err := config.Discover(".") if err != nil { - t.Fatalf("%s: config did not parse: %v", template, err) + t.Fatalf("%s: fragment did not discover: %v", template, err) } if _, ok := loaded.Config.Controllers["test.approval"]; !ok { t.Errorf("%s: expected test.approval controller, got %+v", template, loaded.Config.Controllers) @@ -127,28 +130,28 @@ func TestInitBlankControllerUsesApprovalKind(t *testing.T) { // TestInitTemplateRoleConsistency verifies template/role validation. func TestInitTemplateRoleConsistency(t *testing.T) { t.Run("mismatch rejected", func(t *testing.T) { - dir := filepath.Join(t.TempDir(), "proj") + t.Chdir(t.TempDir()) var stdout, stderr bytes.Buffer - err := runInit([]string{"--language", "go", "--role", "consumer", "--template", "shell-policy", "--dir", dir}, strings.NewReader(""), &stdout, &stderr) + err := runInit([]string{"--language", "go", "--role", "consumer", "--template", "shell-policy", "--dir", "proj"}, strings.NewReader(""), &stdout, &stderr) if err == nil || !strings.Contains(err.Error(), "implies role") { t.Fatalf("expected role/template mismatch error, got %v", err) } }) t.Run("unsupported template rejected", func(t *testing.T) { - dir := filepath.Join(t.TempDir(), "proj") + t.Chdir(t.TempDir()) var stdout, stderr bytes.Buffer - err := runInit([]string{"--language", "go", "--template", "nonesuch", "--dir", dir}, strings.NewReader(""), &stdout, &stderr) + err := runInit([]string{"--language", "go", "--template", "nonesuch", "--dir", "proj"}, strings.NewReader(""), &stdout, &stderr) if err == nil || !strings.Contains(err.Error(), "unsupported template") { t.Fatalf("expected unsupported template error, got %v", err) } }) t.Run("blank-consumer infers consumer role", func(t *testing.T) { - dir := filepath.Join(t.TempDir(), "proj") + t.Chdir(t.TempDir()) var stdout, stderr bytes.Buffer - if err := runInit([]string{"--language", "go", "--template", "blank-consumer", "--dir", dir}, strings.NewReader(""), &stdout, &stderr); err != nil { + if err := runInit([]string{"--language", "go", "--template", "blank-consumer", "--dir", "proj"}, strings.NewReader(""), &stdout, &stderr); err != nil { t.Fatalf("init blank-consumer: %v", err) } - loaded, err := config.Load(filepath.Join(dir, ".pitot.yaml")) + loaded, err := config.Discover(".") if err != nil { t.Fatal(err) } diff --git a/cmd/pitot/workbench_dev_test.go b/cmd/pitot/workbench_dev_test.go index c3ff5a1..ad5ca2f 100644 --- a/cmd/pitot/workbench_dev_test.go +++ b/cmd/pitot/workbench_dev_test.go @@ -50,6 +50,18 @@ func buildPitotBinary(t *testing.T) string { return bin } +// writeFragment registers a tenant config fragment under root/.pitot/conf.d. +func writeFragment(t *testing.T, root, name, body string) { + t.Helper() + dir := filepath.Join(root, ".pitot", "conf.d") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name+".yaml"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + // devAgentScript writes a POSIX agent stand-in that records the runtime it was // handed and its own argv, then drives one allow and one deny decision through // the running runtime via `pitot hook kimi`. It stands in for a real coding @@ -89,9 +101,7 @@ func TestDevRunsAgentBehindShellController(t *testing.T) { _, configBody := buildGeneratedShellPolicy(t) proj := t.TempDir() - if err := os.WriteFile(filepath.Join(proj, ".pitot.yaml"), []byte(configBody), 0o600); err != nil { - t.Fatal(err) - } + writeFragment(t, proj, "shell-policy", configBody) seenDir := t.TempDir() agent := devAgentScript(t, seenDir) @@ -156,9 +166,7 @@ func TestDevRuntimePathsAreUniquePerRun(t *testing.T) { _, configBody := buildGeneratedShellPolicy(t) proj := t.TempDir() - if err := os.WriteFile(filepath.Join(proj, ".pitot.yaml"), []byte(configBody), 0o600); err != nil { - t.Fatal(err) - } + writeFragment(t, proj, "shell-policy", configBody) t.Setenv("PITOT_BIN", pitotBin) t.Setenv("PITOT_RUNTIME", "") t.Chdir(proj) @@ -189,9 +197,7 @@ func TestDevExecSplitsWhereArgvDoesNot(t *testing.T) { _, configBody := buildGeneratedShellPolicy(t) proj := t.TempDir() - if err := os.WriteFile(filepath.Join(proj, ".pitot.yaml"), []byte(configBody), 0o600); err != nil { - t.Fatal(err) - } + writeFragment(t, proj, "shell-policy", configBody) t.Setenv("PITOT_BIN", pitotBin) t.Setenv("PITOT_RUNTIME", "") t.Chdir(proj) diff --git a/cmd/pitot/workbench_test.go b/cmd/pitot/workbench_test.go index 6d00a90..5f9c622 100644 --- a/cmd/pitot/workbench_test.go +++ b/cmd/pitot/workbench_test.go @@ -9,55 +9,59 @@ import ( "testing" ) -// initExpectations maps each language to the files a fresh controller project -// must contain to be runnable without further setup. +// initExpectations maps each language to the project files a fresh controller +// scaffold must contain to be runnable without further setup. The runtime +// registration lands separately in the tenant fragment. var initExpectations = map[string][]string{ - "python": {"main.py", "requirements.txt", "pyproject.toml", ".pitot.yaml"}, - "typescript": {"main.ts", "package.json", "tsconfig.json", ".pitot.yaml"}, - "go": {"main.go", "go.mod", ".pitot.yaml"}, - "rust": {"main.rs", "Cargo.toml", ".pitot.yaml"}, + "python": {"main.py", "requirements.txt", "pyproject.toml"}, + "typescript": {"main.ts", "package.json", "tsconfig.json"}, + "go": {"main.go", "go.mod"}, + "rust": {"main.rs", "Cargo.toml"}, } func TestInitGeneratesRunnableProjectPerLanguage(t *testing.T) { for lang, wantFiles := range initExpectations { lang, wantFiles := lang, wantFiles t.Run(lang, func(t *testing.T) { - dir := filepath.Join(t.TempDir(), "proj") + t.Chdir(t.TempDir()) var stdout, stderr bytes.Buffer - err := runInit([]string{"--language", lang, "--role", "controller", "--dir", dir}, strings.NewReader(""), &stdout, &stderr) + err := runInit([]string{"--language", lang, "--role", "controller", "--dir", "proj"}, strings.NewReader(""), &stdout, &stderr) if err != nil { t.Fatalf("init %s: %v", lang, err) } for _, name := range wantFiles { - if _, statErr := os.Stat(filepath.Join(dir, name)); statErr != nil { + if _, statErr := os.Stat(filepath.Join("proj", name)); statErr != nil { t.Errorf("%s: expected generated file %q: %v", lang, name, statErr) } } - cfg, readErr := os.ReadFile(filepath.Join(dir, ".pitot.yaml")) + cfg, readErr := os.ReadFile(filepath.Join(".pitot", "conf.d", "proj.yaml")) if readErr != nil { - t.Fatalf("read .pitot.yaml: %v", readErr) + t.Fatalf("read tenant fragment: %v", readErr) } if !strings.Contains(string(cfg), "controllers:") { - t.Errorf("%s: controller config missing controllers block:\n%s", lang, cfg) + t.Errorf("%s: controller fragment missing controllers block:\n%s", lang, cfg) + } + if !strings.Contains(string(cfg), `dir: "proj"`) { + t.Errorf("%s: fragment must pin the project working directory:\n%s", lang, cfg) } }) } } func TestInitConsumerRoleWritesConsumerConfig(t *testing.T) { - dir := filepath.Join(t.TempDir(), "proj") + t.Chdir(t.TempDir()) var stdout, stderr bytes.Buffer - if err := runInit([]string{"--language", "go", "--role", "consumer", "--dir", dir}, strings.NewReader(""), &stdout, &stderr); err != nil { + if err := runInit([]string{"--language", "go", "--role", "consumer", "--dir", "proj"}, strings.NewReader(""), &stdout, &stderr); err != nil { t.Fatalf("init: %v", err) } - cfg, err := os.ReadFile(filepath.Join(dir, ".pitot.yaml")) + cfg, err := os.ReadFile(filepath.Join(".pitot", "conf.d", "proj.yaml")) if err != nil { t.Fatal(err) } if !strings.Contains(string(cfg), "consumers:") { - t.Errorf("consumer config missing consumers block:\n%s", cfg) + t.Errorf("consumer fragment missing consumers block:\n%s", cfg) } - src, err := os.ReadFile(filepath.Join(dir, "main.go")) + src, err := os.ReadFile(filepath.Join("proj", "main.go")) if err != nil { t.Fatal(err) } @@ -66,10 +70,49 @@ func TestInitConsumerRoleWritesConsumerConfig(t *testing.T) { } } +// Two tenants initialized into the same repository must never collide: each +// gets its own project directory and its own fragment, and the merged config +// discovers both. +func TestInitIsAdditiveAcrossTenants(t *testing.T) { + t.Chdir(t.TempDir()) + var stdout, stderr bytes.Buffer + if err := runInit([]string{"--language", "go", "--role", "controller", "--dir", "tool-a"}, strings.NewReader(""), &stdout, &stderr); err != nil { + t.Fatalf("first tenant: %v", err) + } + if err := runInit([]string{"--language", "go", "--role", "consumer", "--dir", "tool-b"}, strings.NewReader(""), &stdout, &stderr); err != nil { + t.Fatalf("second tenant: %v", err) + } + for _, fragment := range []string{"tool-a.yaml", "tool-b.yaml"} { + if _, err := os.Stat(filepath.Join(".pitot", "conf.d", fragment)); err != nil { + t.Errorf("expected fragment %s: %v", fragment, err) + } + } +} + +// A second tenant claiming an already-owned request kind must fail at +// registration time — before any file is written — naming both fragments. +func TestInitPreflightsKindCollision(t *testing.T) { + t.Chdir(t.TempDir()) + var stdout, stderr bytes.Buffer + if err := runInit([]string{"--language", "go", "--template", "shell-policy", "--dir", "tool-a"}, strings.NewReader(""), &stdout, &stderr); err != nil { + t.Fatalf("first tenant: %v", err) + } + err := runInit([]string{"--language", "go", "--template", "shell-policy", "--dir", "tool-b"}, strings.NewReader(""), &stdout, &stderr) + if err == nil || !strings.Contains(err.Error(), `request kind "shell" is claimed by both .pitot/conf.d/tool-a.yaml and .pitot/conf.d/tool-b.yaml`) { + t.Fatalf("expected kind collision naming both fragments, got %v", err) + } + if _, statErr := os.Stat("tool-b"); !os.IsNotExist(statErr) { + t.Errorf("refused registration must not scaffold the project, stat err=%v", statErr) + } + if _, statErr := os.Stat(filepath.Join(".pitot", "conf.d", "tool-b.yaml")); !os.IsNotExist(statErr) { + t.Errorf("refused registration must not write the fragment, stat err=%v", statErr) + } +} + func TestInitRejectsInvalidRole(t *testing.T) { - dir := filepath.Join(t.TempDir(), "proj") + t.Chdir(t.TempDir()) var stdout, stderr bytes.Buffer - err := runInit([]string{"--language", "python", "--role", "admin", "--dir", dir}, strings.NewReader(""), &stdout, &stderr) + err := runInit([]string{"--language", "python", "--role", "admin", "--dir", "proj"}, strings.NewReader(""), &stdout, &stderr) if err == nil { t.Fatal("expected invalid role to be rejected") } @@ -79,55 +122,71 @@ func TestInitRejectsInvalidRole(t *testing.T) { } func TestInitRejectsInvalidLanguage(t *testing.T) { - dir := filepath.Join(t.TempDir(), "proj") + t.Chdir(t.TempDir()) var stdout, stderr bytes.Buffer - err := runInit([]string{"--language", "cobol", "--dir", dir}, strings.NewReader(""), &stdout, &stderr) + err := runInit([]string{"--language", "cobol", "--dir", "proj"}, strings.NewReader(""), &stdout, &stderr) if err == nil || !strings.Contains(err.Error(), "unsupported language") { t.Fatalf("expected unsupported language error, got %v", err) } } +func TestInitRejectsInvalidFragmentName(t *testing.T) { + t.Chdir(t.TempDir()) + var stdout, stderr bytes.Buffer + err := runInit([]string{"--language", "go", "--role", "controller", "--dir", "proj", "--fragment", "Bad Name"}, strings.NewReader(""), &stdout, &stderr) + if err == nil || !strings.Contains(err.Error(), "invalid fragment name") { + t.Fatalf("expected fragment name rejection, got %v", err) + } +} + func TestInitIsNonDestructive(t *testing.T) { - dir := filepath.Join(t.TempDir(), "proj") + t.Chdir(t.TempDir()) var out, errb bytes.Buffer - if err := runInit([]string{"--language", "go", "--role", "controller", "--dir", dir}, strings.NewReader(""), &out, &errb); err != nil { + if err := runInit([]string{"--language", "go", "--role", "controller", "--dir", "proj"}, strings.NewReader(""), &out, &errb); err != nil { t.Fatalf("first init: %v", err) } - // Second init without --force must refuse. - err := runInit([]string{"--language", "go", "--role", "controller", "--dir", dir}, strings.NewReader(""), &out, &errb) + // Second init without --force must refuse — the existing fragment belongs + // to a tenant. + err := runInit([]string{"--language", "go", "--role", "controller", "--dir", "proj"}, strings.NewReader(""), &out, &errb) if err == nil || !strings.Contains(err.Error(), "refusing to overwrite") { t.Fatalf("expected non-destructive refusal, got %v", err) } + if !strings.Contains(err.Error(), ".pitot/conf.d/proj.yaml") { + t.Fatalf("refusal must name the contested fragment, got %v", err) + } // With --force it must succeed. - if err := runInit([]string{"--language", "go", "--role", "controller", "--dir", dir, "--force"}, strings.NewReader(""), &out, &errb); err != nil { + if err := runInit([]string{"--language", "go", "--role", "controller", "--dir", "proj", "--force"}, strings.NewReader(""), &out, &errb); err != nil { t.Fatalf("forced init: %v", err) } } func TestInitRequiresLanguageWhenNonInteractiveAndUndetectable(t *testing.T) { - dir := filepath.Join(t.TempDir(), "empty") + t.Chdir(t.TempDir()) var stdout, stderr bytes.Buffer - err := runInit([]string{"--dir", dir}, strings.NewReader(""), &stdout, &stderr) + err := runInit([]string{"--dir", "empty"}, strings.NewReader(""), &stdout, &stderr) if err == nil || !strings.Contains(err.Error(), "--language is required") { t.Fatalf("expected language-required error, got %v", err) } } func TestInitDetectsLanguageFromExistingManifest(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "Cargo.toml"), []byte("[package]\nname=\"x\"\n"), 0o644); err != nil { + t.Chdir(t.TempDir()) + if err := os.MkdirAll("proj", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("proj", "Cargo.toml"), []byte("[package]\nname=\"x\"\n"), 0o644); err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer // Non-interactive with no --language: detection must select rust. --force // because Cargo.toml already exists. - if err := runInit([]string{"--dir", dir, "--role", "controller", "--force"}, strings.NewReader(""), &stdout, &stderr); err != nil { + if err := runInit([]string{"--dir", "proj", "--role", "controller", "--force"}, strings.NewReader(""), &stdout, &stderr); err != nil { t.Fatalf("init with detection: %v", err) } if !strings.Contains(stdout.String(), "Detected rust") { t.Errorf("expected rust detection, got:\n%s", stdout.String()) } - if _, err := os.Stat(filepath.Join(dir, "main.rs")); err != nil { + if _, err := os.Stat(filepath.Join("proj", "main.rs")); err != nil { t.Errorf("expected rust source generated: %v", err) } } diff --git a/config/config.go b/config/config.go index be2e280..811a7a5 100644 --- a/config/config.go +++ b/config/config.go @@ -1,4 +1,8 @@ -// Package config defines and validates Pitot's repository-owned runtime configuration. +// Package config defines and validates Pitot's repository-owned runtime +// configuration. Configuration is tenant-partitioned: each tool or user +// registers its processes in its own fragment under .pitot/conf.d/, and the +// effective config is the deterministic merge of every fragment. No tenant +// ever edits another tenant's registration. package config import ( @@ -9,6 +13,7 @@ import ( "fmt" "io" "os" + "path/filepath" "sort" "github.com/operatorstack/pitot/projection" @@ -16,16 +21,29 @@ import ( "go.yaml.in/yaml/v4" ) -// Config is the complete v1 process-delivery boundary. +// FragmentDir is the repository-relative directory holding one config +// fragment per tenant. +const FragmentDir = ".pitot/conf.d" + +// ErrNoConfig reports that discovery found no config fragments. +var ErrNoConfig = errors.New("pitot: no config fragments found under " + FragmentDir) + +// Config is one tenant's complete process registration — and, once merged, +// the complete v1 process-delivery boundary. type Config struct { - Consumers []ConsumerConfig `yaml:"consumers,omitempty"` - Controllers map[string]ControllerConfig `yaml:"controllers,omitempty"` + // RequiresProtocol optionally pins the Pitot protocol version a fragment + // was written against. A fragment demanding a protocol this binary does + // not speak fails discovery instead of misbehaving at runtime. + RequiresProtocol string `yaml:"requires_protocol,omitempty"` + Consumers []ConsumerConfig `yaml:"consumers,omitempty"` + Controllers map[string]ControllerConfig `yaml:"controllers,omitempty"` } // ConsumerConfig declares a passive JSON-Lines event sink. type ConsumerConfig struct { ID string `yaml:"id"` Command []string `yaml:"command"` + Dir string `yaml:"dir,omitempty"` Events []string `yaml:"events"` Projection ProjectionConfig `yaml:"projection"` } @@ -39,6 +57,7 @@ type ProjectionConfig struct { type ControllerConfig struct { ID string `yaml:"id"` Command []string `yaml:"command"` + Dir string `yaml:"dir,omitempty"` DeadlineMS int `yaml:"deadline_ms"` OnTimeout string `yaml:"on_timeout"` OnUnavailable string `yaml:"on_unavailable"` @@ -50,30 +69,168 @@ type Loaded struct { SHA256 string } -// Load reads exactly one strict YAML document and validates its complete process surface. +// Load reads exactly one strict YAML document from an explicit path and +// validates its complete process surface. This is the single-file override +// path (`pitot run --config PATH`); repository configs are discovered from +// fragments via Discover. func Load(path string) (Loaded, error) { raw, err := os.ReadFile(path) if err != nil { return Loaded{}, fmt.Errorf("pitot: read config %q: %w", path, err) } + cfg, err := decodeStrict(raw) + if err != nil { + return Loaded{}, fmt.Errorf("pitot: config %q: %w", path, err) + } + if err := cfg.Validate(); err != nil { + return Loaded{}, err + } + digest := sha256.Sum256(raw) + return Loaded{Config: cfg, SHA256: hex.EncodeToString(digest[:])}, nil +} + +// source is one fragment's filename and raw bytes. +type source struct { + name string + raw []byte +} + +// Discover assembles the effective config from every fragment under +// root/.pitot/conf.d, in lexicographic filename order. Each fragment is a +// strict, complete mini-config owned by one tenant. Collisions across +// fragments — a request kind, controller id, or consumer id claimed twice — +// are hard errors naming both source files. The digest is deterministic over +// the sorted fragment set. +func Discover(root string) (Loaded, error) { + sources, err := readSources(root) + if err != nil { + return Loaded{}, err + } + if len(sources) == 0 { + return Loaded{}, ErrNoConfig + } + return mergeSources(sources) +} + +// Preflight reports whether registering a new fragment (name + raw bytes) +// would merge cleanly with the fragments already present under root. A +// same-named existing fragment is treated as replaced by the candidate. +func Preflight(root, name string, raw []byte) error { + sources, err := readSources(root) + if err != nil { + return err + } + kept := sources[:0] + for _, existing := range sources { + if existing.name != name { + kept = append(kept, existing) + } + } + _, err = mergeSources(append(kept, source{name: name, raw: raw})) + return err +} + +// readSources collects the fragment files under root/.pitot/conf.d in sorted +// filename order. A missing directory is an empty, not an error. +func readSources(root string) ([]source, error) { + dir := filepath.Join(root, ".pitot", "conf.d") + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("pitot: read config directory %q: %w", dir, err) + } + var names []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + switch filepath.Ext(entry.Name()) { + case ".yaml", ".yml": + names = append(names, entry.Name()) + } + } + sort.Strings(names) + sources := make([]source, 0, len(names)) + for _, name := range names { + raw, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + return nil, fmt.Errorf("pitot: read fragment %q: %w", filepath.ToSlash(filepath.Join(FragmentDir, name)), err) + } + sources = append(sources, source{name: name, raw: raw}) + } + return sources, nil +} + +// mergeSources unions fragments into the effective config, enforcing the +// tenancy contract: strict per-fragment decode, one owner per request kind, +// globally unique controller and consumer ids, and a digest that is a pure +// function of the sorted fragment set. +func mergeSources(sources []source) (Loaded, error) { + sort.Slice(sources, func(i, j int) bool { return sources[i].name < sources[j].name }) + merged := Config{Controllers: map[string]ControllerConfig{}} + kindSource := map[string]string{} + controllerIDSource := map[string]string{} + consumerIDSource := map[string]string{} + var manifest bytes.Buffer + for _, item := range sources { + rel := filepath.ToSlash(filepath.Join(FragmentDir, item.name)) + fragment, err := decodeStrict(item.raw) + if err != nil { + return Loaded{}, fmt.Errorf("pitot: fragment %q: %w", rel, err) + } + if len(fragment.Consumers) == 0 && len(fragment.Controllers) == 0 { + return Loaded{}, fmt.Errorf("pitot: fragment %q declares no consumers or controllers", rel) + } + if fragment.RequiresProtocol != "" && fragment.RequiresProtocol != schema.Version { + return Loaded{}, fmt.Errorf("pitot: fragment %q requires protocol %q but this pitot speaks protocol %q", rel, fragment.RequiresProtocol, schema.Version) + } + for _, consumer := range fragment.Consumers { + if other, exists := consumerIDSource[consumer.ID]; exists { + return Loaded{}, fmt.Errorf("pitot: consumer id %q is declared by both %s and %s", consumer.ID, other, rel) + } + consumerIDSource[consumer.ID] = rel + merged.Consumers = append(merged.Consumers, consumer) + } + for _, kind := range sortedControllerKinds(fragment.Controllers) { + controller := fragment.Controllers[kind] + if other, exists := kindSource[kind]; exists { + return Loaded{}, fmt.Errorf("pitot: request kind %q is claimed by both %s and %s", kind, other, rel) + } + kindSource[kind] = rel + if other, exists := controllerIDSource[controller.ID]; exists { + return Loaded{}, fmt.Errorf("pitot: controller id %q is declared by both %s and %s", controller.ID, other, rel) + } + controllerIDSource[controller.ID] = rel + merged.Controllers[kind] = controller + } + digest := sha256.Sum256(item.raw) + fmt.Fprintf(&manifest, "%s %s\n", item.name, hex.EncodeToString(digest[:])) + } + if err := merged.Validate(); err != nil { + return Loaded{}, err + } + digest := sha256.Sum256(manifest.Bytes()) + return Loaded{Config: merged, SHA256: hex.EncodeToString(digest[:])}, nil +} + +// decodeStrict decodes exactly one YAML document with unknown fields rejected. +func decodeStrict(raw []byte) (Config, error) { dec := yaml.NewDecoder(bytes.NewReader(raw)) dec.KnownFields(true) var cfg Config if err := dec.Decode(&cfg); err != nil { - return Loaded{}, fmt.Errorf("pitot: decode config %q: %w", path, err) + return Config{}, fmt.Errorf("decode: %w", err) } var trailing any if err := dec.Decode(&trailing); !errors.Is(err, io.EOF) { if err == nil { - return Loaded{}, fmt.Errorf("pitot: config %q must contain exactly one YAML document", path) + return Config{}, errors.New("must contain exactly one YAML document") } - return Loaded{}, fmt.Errorf("pitot: decode trailing config %q: %w", path, err) + return Config{}, fmt.Errorf("decode trailing document: %w", err) } - if err := cfg.Validate(); err != nil { - return Loaded{}, err - } - digest := sha256.Sum256(raw) - return Loaded{Config: cfg, SHA256: hex.EncodeToString(digest[:])}, nil + return cfg, nil } // Validate enforces role separation and deterministic registration. @@ -81,6 +238,9 @@ func (c Config) Validate() error { if len(c.Consumers) == 0 && len(c.Controllers) == 0 { return errors.New("pitot: config requires at least one consumer or controller") } + if c.RequiresProtocol != "" && c.RequiresProtocol != schema.Version { + return fmt.Errorf("pitot: config requires protocol %q but this pitot speaks protocol %q", c.RequiresProtocol, schema.Version) + } consumerIDs := map[string]struct{}{} for i, consumer := range c.Consumers { if consumer.ID == "" { @@ -93,6 +253,9 @@ func (c Config) Validate() error { if err := validateCommand("consumer "+consumer.ID, consumer.Command); err != nil { return err } + if err := validateDir("consumer "+consumer.ID, consumer.Dir); err != nil { + return err + } if len(consumer.Events) == 0 { return fmt.Errorf("pitot: consumer %q requires at least one event", consumer.ID) } @@ -126,6 +289,9 @@ func (c Config) Validate() error { if err := validateCommand("controller "+controller.ID, controller.Command); err != nil { return err } + if err := validateDir("controller "+controller.ID, controller.Dir); err != nil { + return err + } if controller.DeadlineMS <= 0 { return fmt.Errorf("pitot: controller %q requires a positive deadline_ms", controller.ID) } @@ -151,6 +317,18 @@ func validateCommand(role string, command []string) error { return nil } +// validateDir keeps process working directories repository-relative so a +// committed fragment behaves identically on every checkout. +func validateDir(role, dir string) error { + if dir == "" { + return nil + } + if filepath.IsAbs(dir) { + return fmt.Errorf("pitot: %s dir %q must be relative to the repository root", role, dir) + } + return nil +} + func validOutcome(value string) bool { return value == schema.OutcomeAllow || value == schema.OutcomeDeny } diff --git a/config/merge_test.go b/config/merge_test.go new file mode 100644 index 0000000..5ccab06 --- /dev/null +++ b/config/merge_test.go @@ -0,0 +1,191 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeTenant registers a fragment under root/.pitot/conf.d. +func writeTenant(t *testing.T, root, name, raw string) { + t.Helper() + dir := filepath.Join(root, ".pitot", "conf.d") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(raw), 0o600); err != nil { + t.Fatal(err) + } +} + +const tenantController = `controllers: + shell: + id: shell-policy + command: ["policy"] + dir: tool-a + deadline_ms: 1000 + on_timeout: deny + on_unavailable: deny +` + +const tenantExplicit = `requires_protocol: "1" +controllers: + interlock.effect: + id: interlock + command: ["interlock-controller"] + deadline_ms: 1000 + on_timeout: deny + on_unavailable: deny +` + +const tenantConsumer = `consumers: + - id: audit + command: ["audit-log"] + events: ["action.requested"] + projection: {content: sha256} +` + +func TestDiscoverMergesTenantFragments(t *testing.T) { + root := t.TempDir() + writeTenant(t, root, "boatstack.yaml", tenantController) + writeTenant(t, root, "interlock.yaml", tenantExplicit) + writeTenant(t, root, "meter.yml", tenantConsumer) + + loaded, err := Discover(root) + if err != nil { + t.Fatal(err) + } + if len(loaded.Config.Controllers) != 2 { + t.Fatalf("merged controllers = %+v, want shell + interlock.effect", loaded.Config.Controllers) + } + if loaded.Config.Controllers["shell"].Dir != "tool-a" { + t.Errorf("shell controller dir = %q, want tool-a", loaded.Config.Controllers["shell"].Dir) + } + if len(loaded.Config.Consumers) != 1 || loaded.Config.Consumers[0].ID != "audit" { + t.Errorf("merged consumers = %+v, want the audit consumer", loaded.Config.Consumers) + } + if len(loaded.SHA256) != 64 { + t.Errorf("merged digest %q is not 64 hex chars", loaded.SHA256) + } +} + +// The merged digest must be a pure function of the sorted fragment set: stable +// across repeated discovery, changed by any content change. +func TestDiscoverDigestIsDeterministic(t *testing.T) { + root := t.TempDir() + writeTenant(t, root, "a.yaml", tenantController) + writeTenant(t, root, "b.yaml", tenantConsumer) + + first, err := Discover(root) + if err != nil { + t.Fatal(err) + } + second, err := Discover(root) + if err != nil { + t.Fatal(err) + } + if first.SHA256 != second.SHA256 { + t.Fatalf("digest not stable: %s vs %s", first.SHA256, second.SHA256) + } + writeTenant(t, root, "b.yaml", strings.Replace(tenantConsumer, "audit", "audit2", 1)) + third, err := Discover(root) + if err != nil { + t.Fatal(err) + } + if third.SHA256 == first.SHA256 { + t.Fatal("digest did not change with fragment content") + } +} + +func TestDiscoverNoFragments(t *testing.T) { + if _, err := Discover(t.TempDir()); !errors.Is(err, ErrNoConfig) { + t.Fatalf("empty root: want ErrNoConfig, got %v", err) + } + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".pitot", "conf.d"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := Discover(root); !errors.Is(err, ErrNoConfig) { + t.Fatalf("empty conf.d: want ErrNoConfig, got %v", err) + } +} + +// Collisions across tenants are hard errors that name both source files, so +// the loser knows exactly whose registration it ran into. +func TestDiscoverCollisionsNameBothFragments(t *testing.T) { + cases := []struct { + name string + second string + wantErr string + }{ + { + name: "request kind claimed twice", + second: strings.Replace(tenantController, "shell-policy", "other-policy", 1), + wantErr: `request kind "shell" is claimed by both .pitot/conf.d/a.yaml and .pitot/conf.d/z.yaml`, + }, + { + name: "controller id declared twice", + second: strings.Replace(tenantController, "shell:", "other.kind:", 1), + wantErr: `controller id "shell-policy" is declared by both .pitot/conf.d/a.yaml and .pitot/conf.d/z.yaml`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + writeTenant(t, root, "a.yaml", tenantController) + writeTenant(t, root, "z.yaml", tc.second) + _, err := Discover(root) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("want error containing %q, got %v", tc.wantErr, err) + } + }) + } + + t.Run("consumer id declared twice", func(t *testing.T) { + root := t.TempDir() + writeTenant(t, root, "a.yaml", tenantConsumer) + writeTenant(t, root, "z.yaml", tenantConsumer) + _, err := Discover(root) + want := `consumer id "audit" is declared by both .pitot/conf.d/a.yaml and .pitot/conf.d/z.yaml` + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("want error containing %q, got %v", want, err) + } + }) +} + +func TestDiscoverRejectsBrokenFragments(t *testing.T) { + cases := []struct { + name string + raw string + wantErr string + }{ + {"empty fragment", "consumers: []\n", "declares no consumers or controllers"}, + {"unknown field", "controllers: {}\nowner: me\n", "field owner not found"}, + {"protocol mismatch", strings.Replace(tenantExplicit, `"1"`, `"9"`, 1), `requires protocol "9" but this pitot speaks protocol "1"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + writeTenant(t, root, "bad.yaml", tc.raw) + _, err := Discover(root) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("want error containing %q, got %v", tc.wantErr, err) + } + if err != nil && !strings.Contains(err.Error(), "bad.yaml") && tc.name != "protocol mismatch" { + t.Fatalf("error must name the offending fragment, got %v", err) + } + }) + } +} + +func TestValidateRejectsAbsoluteDir(t *testing.T) { + root := t.TempDir() + abs := root // any absolute path + writeTenant(t, root, "a.yaml", strings.Replace(tenantController, "dir: tool-a", "dir: "+abs, 1)) + _, err := Discover(root) + if err == nil || !strings.Contains(err.Error(), "must be relative") { + t.Fatalf("want relative-dir rejection, got %v", err) + } +} diff --git a/runtime/runtime.go b/runtime/runtime.go index 97aacc2..a466dc7 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -85,7 +85,7 @@ func Start(parent context.Context, cfg config.Config, stderr io.Writer) (*Manage cancel() return nil, err } - worker := newControllerWorker(ctx, router, registration, declared.Command, stderr) + worker := newControllerWorker(ctx, router, registration, declared, stderr) manager.controllers[kind] = worker if worker.startErr != nil { fmt.Fprintf(stderr, "pitot: controller %q unavailable: %v\n", declared.ID, worker.startErr) @@ -173,7 +173,7 @@ type controllerWorker struct { closeOnce sync.Once } -func newControllerWorker(ctx context.Context, router *bridge.Router, registration bridge.Registration, command []string, stderr io.Writer) *controllerWorker { +func newControllerWorker(ctx context.Context, router *bridge.Router, registration bridge.Registration, declared config.ControllerConfig, stderr io.Writer) *controllerWorker { worker := &controllerWorker{ ctx: ctx, router: router, @@ -182,7 +182,8 @@ func newControllerWorker(ctx context.Context, router *bridge.Router, registratio done: make(chan error, 1), resolved: map[string]struct{}{}, } - cmd := exec.CommandContext(ctx, command[0], command[1:]...) + cmd := exec.CommandContext(ctx, declared.Command[0], declared.Command[1:]...) + cmd.Dir = declared.Dir cmd.Stderr = stderr stdout, err := cmd.StdoutPipe() if err != nil { @@ -324,6 +325,7 @@ func newConsumerWorker(ctx context.Context, declared config.ConsumerConfig, stde worker.events[event] = struct{}{} } cmd := exec.CommandContext(ctx, declared.Command[0], declared.Command[1:]...) + cmd.Dir = declared.Dir cmd.Stdout = stderr cmd.Stderr = stderr stdin, err := cmd.StdinPipe()