diff --git a/cmd/publish-server/templates/case.html b/cmd/publish-server/templates/case.html index 8ea5b09..2cd5432 100644 --- a/cmd/publish-server/templates/case.html +++ b/cmd/publish-server/templates/case.html @@ -16,15 +16,20 @@

{{.C.Submission.Listing.DisplayName}} {{.C.Submission.Ver ID{{.C.Submission.ID}} Version{{.C.Submission.Version}} Description{{.C.Submission.Description}} + {{if .C.Submission.Backend.IsCLI}} + Backendcli: {{join .C.Submission.Backend.Command}} + {{if .C.Submission.Backend.EnvPassthrough}}Env passthrough{{join .C.Submission.Backend.EnvPassthrough}}{{end}} + {{else}} Backend{{.C.Submission.Backend.BaseURL}} {{range .C.Submission.Backend.Headers}}Header{{.Name}}: {{.Value}}{{end}} + {{end}}

Methods

- + {{range .C.Submission.Methods}} - + {{end}}
MethodLatencyPathDescription
MethodLatencyRouteDescription
{{.Name}}{{.Latency}}{{.HTTP.Verb}} {{.HTTP.Path}}{{.Description}}{{if $.C.Submission.Backend.IsCLI}}{{if .CLI.Passthrough}}passthrough {args[]}{{else}}{{join .CLI.Args}}{{if .CLI.ParamsAsFlags}} +flags{{end}}{{end}}{{else}}{{.HTTP.Verb}} {{.HTTP.Path}}{{end}}{{.Description}}
diff --git a/docs/CLI-ADAPTER.md b/docs/CLI-ADAPTER.md index 95aad1c..fee63bc 100644 --- a/docs/CLI-ADAPTER.md +++ b/docs/CLI-ADAPTER.md @@ -1,14 +1,57 @@ # CLI adapter archetype — design & the `proc.exec` capability -`pilot-app` already **generates** a working CLI-backed adapter (`backend.type: -cli`): `internal/backend/exec.go` runs a subprocess per method, substitutes -`${field}` placeholders from the IPC payload, and returns stdout as the JSON -reply. The generated Go compiles and serves IPC exactly like the HTTP archetype. +`pilot-app` **generates** a working CLI-backed adapter (`backend.type: cli`): +`internal/backend/exec.go` runs a subprocess per method and returns its output +as the JSON reply. The generated Go compiles and serves IPC exactly like the +HTTP archetype (a `go build ./...` regression test pins this). What it can't do **yet** is install through the catalogue with permission to exec, because the platform has no capability for "run a local process." This doc specifies the one platform change that unblocks it. +## Invocation model — enumerated vs passthrough + +A method maps to a subprocess one of two ways: + +- **Enumerated** (`cli: {args: [...]}`): a baked argv with `${field}` + placeholders filled from the payload, optionally `params_as_flags: true` to + append `--key value` for each payload field (sorted, deterministic). A missing + `${field}` is a hard error, not a silently-empty arg. Use this to expose a + curated, named surface (`weather.current`, `weather.forecast`). + +- **Passthrough** (`cli: {passthrough: true}`): the caller supplies a verbatim + `args` array, so **every** subcommand of the fronted CLI is reachable without + enumerating it: + + ``` + pilotctl appstore call io.pilot.toolx toolx.exec '{"args":["status","--short"]}' + ``` + + is exactly `toolx status --short`. This is the "translate all CLI commands" + shape: one method fronts the whole tool. Both shapes accept an optional + `stdin` string piped to the child. + +Because argv is exec'd directly (no shell), payload values can never be +re-parsed as shell metacharacters. Passthrough is strictly more powerful than an +enumerated surface — the caller chooses the subcommand and flags — so reserve it +for trusted callers and keep CLI apps `guarded` (below). + +## Hardening built into the runner + +The generated `exec.go` is defensive by default: + +- **Scrubbed environment** — the child inherits only a minimal baseline (`PATH`, + `HOME`, locale, `TMPDIR`) plus vars the spec opts in via + `backend.env_passthrough: [TOKEN, ...]`. The adapter's own environment (app + identity, broker secrets) never leaks to the fronted CLI. +- **Bounded output** — stdout/stderr are capped (4 MiB) so a runaway child can't + OOM the adapter; truncation is flagged in the reply. +- **Structured failures** — a non-zero exit is returned as + `{"stdout","stderr","exit","truncated"}` rather than an opaque error, so the + caller sees everything the CLI produced. Only spawn failures (binary missing) + and timeouts surface as IPC errors; the per-method `timeout`/`duration` bounds + the run and the child is killed on cancel. + ## Why HTTP works today and CLI doesn't An app's grants are the *only* thing the daemon authorizes (`pkg/manifest`). diff --git a/go.mod b/go.mod index db45888..0ca64f9 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/pilot-protocol/app-template go 1.25.0 require ( - github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260609061942-8852c785a264 + github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260622180016-07b4170265dc github.com/sendgrid/sendgrid-go v3.16.1+incompatible gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.52.0 diff --git a/go.sum b/go.sum index c44046c..4ecf949 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260609061942-8852c785a264 h1:NL9rFdakbVQ0V7xfJbCk8RJZSaQ1AmvdhAJwFIouMsk= -github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260609061942-8852c785a264/go.mod h1:zoCxHYoNdj0V44OkG3Yzcye0jnwZDVUcJgAvR5Z1kwc= +github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260622180016-07b4170265dc h1:Ze7h3rEPMhFaAyjNH9riySBs8HEeeoB3wODwtoLQ4Eo= +github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260622180016-07b4170265dc/go.mod h1:leZPtX43gE2JB7xeljexXri81g6qhdZfYExLtzI+bhg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= diff --git a/internal/publish/cli_e2e_test.go b/internal/publish/cli_e2e_test.go new file mode 100644 index 0000000..68b30ef --- /dev/null +++ b/internal/publish/cli_e2e_test.go @@ -0,0 +1,160 @@ +package publish + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "io" + "strings" + "testing" +) + +// sampleCLISubmission is a CLI-backed submission with both method shapes: +// an enumerated subcommand (status) and a passthrough that fronts the whole +// tool (exec) — the "translate all CLI commands" surface. +func sampleCLISubmission() Submission { + return Submission{ + ID: "io.pilot.gh", Version: "0.1.0", Description: "Fronts the gh CLI over the app store.", Email: "dev@acme.example", + Backend: SubBackend{Type: "cli", Command: []string{"gh"}, EnvPassthrough: []string{"GH_TOKEN"}}, + Methods: []SubMethod{ + {Name: "gh.status", Description: "Show gh auth status.", Latency: "fast", + CLI: SubCLIRoute{Args: []string{"auth", "status"}}}, + {Name: "gh.exec", Description: "Run any gh subcommand.", Latency: "med", + Params: []SubParam{{Name: "args", Type: "array", Description: "verbatim argv forwarded to gh"}}, + CLI: SubCLIRoute{Passthrough: true}}, + }, + Listing: SubListing{DisplayName: "GitHub CLI", License: "MIT", Categories: []string{"dev"}}, + Vendor: SubVendor{Name: "Acme", AgentUsage: "agents drive gh", Capabilities: "github"}, + } +} + +// fileFromTarball pulls one path out of a gzipped bundle tarball. +func fileFromTarball(t *testing.T, tarball []byte, name string) []byte { + t.Helper() + gz, err := gzip.NewReader(bytes.NewReader(tarball)) + if err != nil { + t.Fatal(err) + } + tr := tar.NewReader(gz) + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + if h.Name == name { + b, _ := io.ReadAll(tr) + return b + } + } + t.Fatalf("%s not found in tarball", name) + return nil +} + +// TestCLISubmissionValidates pins the submission-level CLI rules: a well-formed +// cli submission passes, and the common misconfigurations are rejected with a +// friendly message (fast, no build). +func TestCLISubmissionValidates(t *testing.T) { + t.Parallel() + if errs := sampleCLISubmission().Validate(); len(errs) != 0 { + t.Fatalf("a well-formed cli submission must validate, got: %v", errs) + } + + noCmd := sampleCLISubmission() + noCmd.Backend.Command = nil + if !hasSubErr(noCmd.Validate(), "CLI backend requires a command") { + t.Errorf("missing command should be rejected, got: %v", noCmd.Validate()) + } + + badPass := sampleCLISubmission() + badPass.Methods[1].CLI.Args = []string{"oops"} // passthrough + args is contradictory + if !hasSubErr(badPass.Validate(), "passthrough takes argv") { + t.Errorf("passthrough+args should be rejected, got: %v", badPass.Validate()) + } + + emptyRoute := sampleCLISubmission() + emptyRoute.Methods[0].CLI = SubCLIRoute{} // no args, no flags, no passthrough + if !hasSubErr(emptyRoute.Validate(), "needs args, params_as_flags, or passthrough") { + t.Errorf("empty cli route should be rejected, got: %v", emptyRoute.Validate()) + } +} + +// TestCLIHelpPreviewShowsPassthroughArgs verifies the live preview renders the +// correct pilotctl line for a passthrough method: an {"args":[...]} payload, not +// a params skeleton. +func TestCLIHelpPreviewShowsPassthroughArgs(t *testing.T) { + t.Parallel() + _, cmds := sampleCLISubmission().HelpPreview() + joined := strings.Join(cmds, "\n") + if !strings.Contains(joined, `call io.pilot.gh gh.exec '{"args":[`) { + t.Errorf("passthrough method should preview an args[] payload; got:\n%s", joined) + } + if !strings.Contains(joined, "call io.pilot.gh gh.status") { + t.Errorf("enumerated method missing from preview:\n%s", joined) + } +} + +// TestCLISubmissionBuildsAndVerifies is the publish-path e2e: a CLI submission +// builds through the real pipeline (scaffold → cross-compile → sign → catalogue +// self-verify) for every platform. Because BuildBundle self-verifies through the +// exact catalogue gate, a successful build PROVES the proc.exec manifest passes +// validation — the gate that rejected cli apps before this capability landed. +// It then asserts the shipped manifest declares proc.exec (scoped to the command) +// and is guarded. +func TestCLISubmissionBuildsAndVerifies(t *testing.T) { + if testing.Short() { + t.Skip("cross-compiles the cli adapter for all platforms; skipped under -short") + } + priv, err := LoadOrCreateKey(t.TempDir() + "/k.key") + if err != nil { + t.Fatal(err) + } + sub := sampleCLISubmission() + if errs := sub.Validate(); len(errs) != 0 { + t.Fatalf("submission invalid: %v", errs) + } + + b, err := BuildBundle(sub.ToConfig(), priv) + if err != nil { + t.Fatalf("BuildBundle (implies catalogue self-verify) failed for a proc.exec app: %v", err) + } + if len(b.Platforms) != len(DefaultPlatforms) { + t.Fatalf("want %d platforms, got %d", len(DefaultPlatforms), len(b.Platforms)) + } + + // The shipped manifest must carry the hardened proc.exec grant + guarded. + mfRaw := fileFromTarball(t, b.Primary().Tarball, "./manifest.json") + var mf struct { + Protection string `json:"protection"` + Grants []struct { + Cap, Target string + } `json:"grants"` + } + if err := json.Unmarshal(mfRaw, &mf); err != nil { + t.Fatalf("parse shipped manifest: %v", err) + } + if mf.Protection != "guarded" { + t.Errorf("cli app must ship protection=guarded, got %q", mf.Protection) + } + var procExec string + for _, g := range mf.Grants { + if g.Cap == "proc.exec" { + procExec = g.Target + } + } + if procExec != "gh" { + t.Errorf("manifest must declare proc.exec scoped to the command (target=gh), got %q", procExec) + } +} + +func hasSubErr(errs []string, substr string) bool { + for _, e := range errs { + if strings.Contains(e, substr) { + return true + } + } + return false +} diff --git a/internal/publish/submission.go b/internal/publish/submission.go index ce33c07..871d4ec 100644 --- a/internal/publish/submission.go +++ b/internal/publish/submission.go @@ -28,9 +28,14 @@ type Submission struct { Vendor SubVendor `json:"vendor"` } -// SubBackend is the HTTP API the adapter forwards to. (Native/CLI binary -// delivery is captured in Listing.RequiresBinary for now — see NATIVE-APPS.md.) +// SubBackend selects and configures the data plane the adapter forwards to: +// either an HTTP API (Type "http", the default) or a local CLI (Type "cli"). type SubBackend struct { + // Type is "http" (default) or "cli". Empty means http for back-compat with + // older form payloads that predate the selector. + Type string `json:"type"` + + // --- http fields --- BaseURL string `json:"base_url"` Headers []SubHeader `json:"headers"` // auth/extra headers; values may use ${TOKEN} // Auth selects how the adapter authenticates to the backend: @@ -41,10 +46,22 @@ type SubBackend struct { // Quota is the per-caller call cap the broker enforces for a managed app // (0 = unlimited). Set at publish time so the rate limit ships with the app. Quota int `json:"quota"` + + // --- cli fields --- + // Command is the base argv the adapter execs (e.g. ["gh"] or ["python","-m","tool"]). + Command []string `json:"command"` + // EnvPassthrough names host env vars the fronted CLI may see, on top of a + // minimal baseline (PATH/HOME/locale/TMPDIR). The child never inherits the + // adapter's full environment. + EnvPassthrough []string `json:"env_passthrough"` } -// Managed reports whether this submission uses Pilot's managed master key. -func (b SubBackend) Managed() bool { return b.Auth == "managed" } +// IsCLI reports whether this submission fronts a local CLI rather than an HTTP API. +func (b SubBackend) IsCLI() bool { return b.Type == "cli" } + +// Managed reports whether this submission uses Pilot's managed master key. Only +// meaningful for http backends (a cli app holds no key). +func (b SubBackend) Managed() bool { return !b.IsCLI() && b.Auth == "managed" } // SubHeader is one request header. Value may contain ${TOKEN} placeholders that // the operator supplies at install (env or $APP/secrets.json) — never baked in. @@ -54,13 +71,15 @@ type SubHeader struct { } // SubMethod is one IPC method the agent can call, mapped to a backend route. +// Exactly one of HTTP / CLI is meaningful, selected by the backend type. type SubMethod struct { - Name string `json:"name"` // ., e.g. weather.current - Description string `json:"description"` // full description, shown in help - Latency string `json:"latency"` // fast | med | slow (REQUIRED) - Timeout string `json:"timeout"` // optional Go duration (e.g. "280s") overriding the latency-class default - HTTP SubRoute `json:"http"` - Params []SubParam `json:"params"` + Name string `json:"name"` // ., e.g. weather.current + Description string `json:"description"` // full description, shown in help + Latency string `json:"latency"` // fast | med | slow (REQUIRED) + Timeout string `json:"timeout"` // optional Go duration (e.g. "280s") overriding the latency-class default + HTTP SubRoute `json:"http"` // http backend route + CLI SubCLIRoute `json:"cli"` // cli backend route + Params []SubParam `json:"params"` } // SubRoute is the backend HTTP mapping for a method. @@ -69,6 +88,17 @@ type SubRoute struct { Path string `json:"path"` // e.g. /current } +// SubCLIRoute is the backend CLI mapping for a method. Enumerated methods bake +// Args (with ${field} placeholders from the payload) and optionally append each +// payload field as --key value (ParamsAsFlags). Passthrough instead forwards a +// verbatim "args" array, so every subcommand of the fronted CLI is reachable — +// the "translate all CLI commands" shape. +type SubCLIRoute struct { + Args []string `json:"args"` + ParamsAsFlags bool `json:"params_as_flags"` + Passthrough bool `json:"passthrough"` +} + // SubParam is one structured input parameter (vs the old free-text field). type SubParam struct { Name string `json:"name"` @@ -138,7 +168,11 @@ func (s Submission) Validate() []string { if !reEmail.MatchString(strings.TrimSpace(s.Email)) { e = append(e, "A valid email is required") } - if !reURL.MatchString(strings.TrimSpace(s.Backend.BaseURL)) { + if s.Backend.IsCLI() { + if len(s.Backend.Command) == 0 || strings.TrimSpace(s.Backend.Command[0]) == "" { + e = append(e, `CLI backend requires a command (the base argv, e.g. ["gh"])`) + } + } else if !reURL.MatchString(strings.TrimSpace(s.Backend.BaseURL)) { e = append(e, "Backend base URL must be an absolute http(s) URL") } if len(s.Methods) == 0 { @@ -165,7 +199,16 @@ func (s Submission) Validate() []string { if strings.TrimSpace(m.Description) == "" { e = append(e, fmt.Sprintf("Method %q: description is required", n)) } - if m.HTTP.Path == "" || !strings.HasPrefix(m.HTTP.Path, "/") { + if s.Backend.IsCLI() { + switch { + case m.CLI.Passthrough: + if len(m.CLI.Args) > 0 || m.CLI.ParamsAsFlags { + e = append(e, fmt.Sprintf("Method %q: passthrough takes argv from the call — remove args/params_as_flags", n)) + } + case len(m.CLI.Args) == 0 && !m.CLI.ParamsAsFlags: + e = append(e, fmt.Sprintf("Method %q: CLI method needs args, params_as_flags, or passthrough", n)) + } + } else if m.HTTP.Path == "" || !strings.HasPrefix(m.HTTP.Path, "/") { e = append(e, fmt.Sprintf("Method %q: path must start with /", n)) } } @@ -176,11 +219,15 @@ func (s Submission) Validate() []string { // the generator needs). Review-only fields (vendor free-text, agent-usage, // capabilities, binary URL) are intentionally not part of it. func (s Submission) ToConfig() *scaffold.Config { + backend := scaffold.Backend{Type: "http", BaseURL: s.Backend.BaseURL, Auth: s.Backend.Auth} + if s.Backend.IsCLI() { + backend = scaffold.Backend{Type: "cli", Command: s.Backend.Command, EnvPassthrough: s.Backend.EnvPassthrough} + } cfg := &scaffold.Config{ ID: s.ID, AppVersion: s.Version, Description: s.Description, - Backend: scaffold.Backend{Type: "http", BaseURL: s.Backend.BaseURL, Auth: s.Backend.Auth}, + Backend: backend, Listing: scaffold.Listing{ DisplayName: s.Listing.DisplayName, Tagline: s.Listing.Tagline, @@ -192,9 +239,9 @@ func (s Submission) ToConfig() *scaffold.Config { Vendor: scaffold.Vendor{Name: s.Vendor.Name, URL: s.Vendor.URL, Contact: s.Vendor.Contact}, }, } - // Managed apps are keyless: the partner auth header is the broker's job, not - // the adapter's, so it is never baked into the generated bundle. - if !s.Backend.Managed() { + // HTTP byo apps carry auth headers; managed apps are keyless (the broker + // holds the key) and cli apps have no HTTP headers at all. + if !s.Backend.IsCLI() && !s.Backend.Managed() { headers := map[string]string{} for _, h := range s.Backend.Headers { if strings.TrimSpace(h.Name) != "" { @@ -220,14 +267,23 @@ func (s Submission) ToConfig() *scaffold.Config { } params[p.Name] = desc } - cfg.Methods = append(cfg.Methods, scaffold.Method{ + method := scaffold.Method{ Name: m.Name, Summary: m.Description, // help "summary" carries the description Duration: m.Latency, Timeout: m.Timeout, // explicit per-method timeout (overrides the latency-class default) - HTTP: &scaffold.HTTPRoute{Verb: orDefault(m.HTTP.Verb, "GET"), Path: m.HTTP.Path}, Params: params, - }) + } + if s.Backend.IsCLI() { + method.CLI = &scaffold.CLIRoute{ + Args: m.CLI.Args, + ParamsAsFlags: m.CLI.ParamsAsFlags, + Passthrough: m.CLI.Passthrough, + } + } else { + method.HTTP = &scaffold.HTTPRoute{Verb: orDefault(m.HTTP.Verb, "GET"), Path: m.HTTP.Path} + } + cfg.Methods = append(cfg.Methods, method) } cfg.Resolve() return cfg @@ -243,31 +299,39 @@ func (s Submission) HelpPreview() (HelpDoc, []string) { ns := s.Namespace() doc := HelpDoc{App: s.ID, Description: s.Description, DurationClasses: LatencyRef} var cmds []string - add := func(name, desc, lat string, params []SubParam) { + add := func(m SubMethod) { pm := map[string]string{} - for _, p := range params { + for _, p := range m.Params { if p.Name != "" { pm[p.Name] = p.Type } } - doc.Methods = append(doc.Methods, HelpMethod{Method: name, Summary: desc, Duration: lat, Params: pm}) - // pilotctl call line with a JSON skeleton of the params. - var kv []string - ks := make([]string, 0, len(pm)) - for k := range pm { - ks = append(ks, k) - } - sort.Strings(ks) - for _, k := range ks { - kv = append(kv, fmt.Sprintf("%q:%q", k, "<"+pm[k]+">")) + doc.Methods = append(doc.Methods, HelpMethod{Method: m.Name, Summary: m.Description, Duration: orDefault(m.Latency, "fast"), Params: pm}) + // pilotctl call line. A cli passthrough method takes a verbatim argv + // array, so its payload is {"args":[...]}; everything else shows a JSON + // skeleton of the named params. + var payload string + if s.Backend.IsCLI() && m.CLI.Passthrough { + payload = `{"args":["",""]}` + } else { + var kv []string + ks := make([]string, 0, len(pm)) + for k := range pm { + ks = append(ks, k) + } + sort.Strings(ks) + for _, k := range ks { + kv = append(kv, fmt.Sprintf("%q:%q", k, "<"+pm[k]+">")) + } + payload = "{" + strings.Join(kv, ",") + "}" } - cmds = append(cmds, fmt.Sprintf("pilotctl appstore call %s %s '{%s}'", s.ID, name, strings.Join(kv, ","))) + cmds = append(cmds, fmt.Sprintf("pilotctl appstore call %s %s '%s'", s.ID, m.Name, payload)) } for _, m := range s.Methods { if strings.TrimSpace(m.Name) == "" { continue } - add(m.Name, m.Description, orDefault(m.Latency, "fast"), m.Params) + add(m) } // The always-present discovery method. doc.Methods = append(doc.Methods, HelpMethod{Method: ns + ".help", Summary: "Discovery: every method with params, latency, and description.", Duration: "fast"}) diff --git a/internal/scaffold/cli_runtime_e2e_test.go b/internal/scaffold/cli_runtime_e2e_test.go new file mode 100644 index 0000000..3229106 --- /dev/null +++ b/internal/scaffold/cli_runtime_e2e_test.go @@ -0,0 +1,145 @@ +//go:build !windows + +package scaffold + +import ( + "encoding/json" + "net" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/pilot-protocol/app-store/pkg/ipc" +) + +// TestCLIAdapterRuntimeE2E is the end-to-end proof of "translate all CLI +// commands": it scaffolds a cli adapter fronting a real local script, builds it, +// runs it as the pilot daemon would (--socket/--manifest), and drives both +// method shapes over the actual app-store IPC protocol. An enumerated method maps +// to a fixed subcommand; a passthrough method forwards a verbatim argv array — +// i.e. `pilotctl appstore call run {"args":[...]}` becomes ` ...`. +func TestCLIAdapterRuntimeE2E(t *testing.T) { + if testing.Short() { + t.Skip("builds and runs a real adapter binary; skipped under -short") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go toolchain not available") + } + + root := t.TempDir() + + // A real local CLI: JSON for `status`, arg-reflection for `echo`, stderr + + // non-zero exit for `fail`. + tool := filepath.Join(root, "faketool") + script := "#!/usr/bin/env bash\n" + + "case \"$1\" in\n" + + " status) echo '{\"ok\":true,\"state\":\"green\"}';;\n" + + " echo) shift; echo \"got: $*\";;\n" + + " fail) echo boom >&2; exit 7;;\n" + + " *) echo \"unknown: $1\" >&2; exit 2;;\n" + + "esac\n" + if err := os.WriteFile(tool, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + spec := ` +id: io.pilot.faketool +app_version: 0.1.0 +description: "Fronts faketool." +namespace: faketool +backend: + type: cli + command: ["` + tool + `"] +methods: + - name: faketool.status + summary: "Status." + cli: {args: ["status"]} + - name: faketool.run + summary: "Passthrough." + cli: {passthrough: true} +` + cfg := parseSpec(t, spec) + proj := filepath.Join(root, "proj") + if _, err := Generate(cfg, proj); err != nil { + t.Fatalf("generate: %v", err) + } + // Seed go.sum from the parent module so the build is hermetic/offline. + if sum, err := os.ReadFile(filepath.Join("..", "..", "go.sum")); err == nil { + _ = os.WriteFile(filepath.Join(proj, "go.sum"), sum, 0o644) + } + + bin := filepath.Join(root, "adapter") + build := exec.Command("go", "build", "-o", bin, "./cmd/"+cfg.BinaryName) + build.Dir = proj + build.Env = append(os.Environ(), "GOFLAGS=-mod=mod") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build adapter: %v\n%s", err, out) + } + + sock := filepath.Join(root, "app.sock") + adapter := exec.Command(bin, "--socket", sock, "--manifest", filepath.Join(proj, "manifest.json")) + adapter.Stderr = os.Stderr + if err := adapter.Start(); err != nil { + t.Fatalf("start adapter: %v", err) + } + defer func() { _ = adapter.Process.Kill(); _, _ = adapter.Process.Wait() }() + + // Wait for the socket to appear. + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(sock); err == nil { + break + } + time.Sleep(20 * time.Millisecond) + } + + call := func(method, args string) json.RawMessage { + t.Helper() + conn, err := net.DialTimeout("unix", sock, 3*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + var out json.RawMessage + if err := ipc.Call(conn, method, json.RawMessage(args), &out); err != nil { + t.Fatalf("call %s: %v", method, err) + } + return out + } + + // Enumerated method → fixed subcommand, JSON passed through verbatim. + if got := string(call("faketool.status", "{}")); got != `{"ok":true,"state":"green"}` { + t.Errorf("status: got %s", got) + } + + // Passthrough → arbitrary subcommand+args become the CLI argv. + var echo struct { + Stdout string `json:"stdout"` + Exit int `json:"exit"` + } + if err := json.Unmarshal(call("faketool.run", `{"args":["echo","hello","world"]}`), &echo); err != nil { + t.Fatalf("run echo: %v", err) + } + if echo.Stdout != "got: hello world" || echo.Exit != 0 { + t.Errorf("passthrough echo: got %+v", echo) + } + + // Passthrough non-zero exit is surfaced structurally (not an IPC error). + var fail struct { + Stderr string `json:"stderr"` + Exit int `json:"exit"` + } + if err := json.Unmarshal(call("faketool.run", `{"args":["fail"]}`), &fail); err != nil { + t.Fatalf("run fail: %v", err) + } + if fail.Exit != 7 || fail.Stderr != "boom" { + t.Errorf("passthrough fail: got %+v", fail) + } + + // Discovery method works locally with no backend call. + if got := string(call("faketool.help", "{}")); !json.Valid([]byte(got)) || got == "" { + t.Errorf("help: invalid: %s", got) + } +} diff --git a/internal/scaffold/compile_test.go b/internal/scaffold/compile_test.go new file mode 100644 index 0000000..199a071 --- /dev/null +++ b/internal/scaffold/compile_test.go @@ -0,0 +1,106 @@ +package scaffold + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// cliPassthroughSpec exercises both cli method shapes (enumerated + passthrough) +// and env_passthrough, so the compile test covers every generated cli code path. +const cliPassthroughSpec = ` +id: io.pilot.toolx +app_version: 0.2.0 +description: "Wraps the toolx CLI." +backend: + type: cli + command: ["toolx"] + env_passthrough: [TOOLX_TOKEN] +methods: + - name: toolx.run + summary: "Run a named job." + duration: med + cli: {args: ["run", "--name", "${name}"]} + - name: toolx.exec + summary: "Passthrough: any toolx subcommand." + duration: med + cli: {passthrough: true} +` + +// TestGeneratedCLIProjectCompiles is the load-bearing guard the parse-only +// TestGenerateProducesValidGo cannot provide: it actually type-checks the +// generated cli project with `go build ./...`. An unused variable (e.g. the +// http-only cfg leaking into the cli main) parses fine but fails to compile — +// exactly the regression this catches. go.sum entries are keyed by +// module@version, so reusing the parent module's checksums keeps the build +// hermetic and offline. +func TestGeneratedCLIProjectCompiles(t *testing.T) { + if testing.Short() { + t.Skip("skipping compile test in -short mode") + } + goBin, err := exec.LookPath("go") + if err != nil { + t.Skip("go toolchain not available") + } + + cfg := parseSpec(t, cliPassthroughSpec) + dir := t.TempDir() + if _, err := Generate(cfg, dir); err != nil { + t.Fatalf("generate: %v", err) + } + if sum, err := os.ReadFile(filepath.Join("..", "..", "go.sum")); err == nil { + if err := os.WriteFile(filepath.Join(dir, "go.sum"), sum, 0o644); err != nil { + t.Fatalf("seed go.sum: %v", err) + } + } + + cmd := exec.Command(goBin, "build", "./...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("generated cli project failed to compile: %v\n%s", err, out) + } +} + +// TestCLIRouteValidation pins the cli route rules: passthrough is mutually +// exclusive with baked args/flags, and an empty route is rejected. +func TestCLIRouteValidation(t *testing.T) { + cases := []struct { + name string + route string + wantError bool + }{ + {"baked args", `cli: {args: ["run"]}`, false}, + {"passthrough only", `cli: {passthrough: true}`, false}, + {"params as flags", `cli: {params_as_flags: true}`, false}, + {"passthrough with args", `cli: {passthrough: true, args: ["run"]}`, true}, + {"empty route", `cli: {}`, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + spec := ` +id: io.pilot.toolx +app_version: 0.1.0 +description: "x" +backend: {type: cli, command: ["toolx"]} +methods: + - name: toolx.m + summary: "m" + ` + tc.route + ` +` + cfg, err := Parse([]byte(spec)) + if err != nil { + t.Fatalf("parse: %v", err) + } + cfg.Resolve() + errs := cfg.Validate() + if tc.wantError && len(errs) == 0 { + t.Errorf("expected a validation error, got none") + } + if !tc.wantError && len(errs) != 0 { + t.Errorf("expected no validation error, got %v", errs) + } + }) + } +} diff --git a/internal/scaffold/config.go b/internal/scaffold/config.go index 234a432..d55a783 100644 --- a/internal/scaffold/config.go +++ b/internal/scaffold/config.go @@ -22,7 +22,7 @@ import ( // defaultAppStoreModule pins the published app-store module the generated // adapter imports for pkg/ipc. Overridable per-spec via app_store_module. // Matches the pin the reference app (cosift-app) ships today. -const defaultAppStoreModule = "github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260609061942-8852c785a264" +const defaultAppStoreModule = "github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260622180016-07b4170265dc" // Config is the full pilot.app.yaml spec. Only id, app_version, description, // backend, and methods are required from the author; everything else is @@ -92,6 +92,12 @@ type Backend struct { Command []string `yaml:"command"` // cli: base argv (method args appended) EnvPrefix string `yaml:"env_prefix"` // override env var prefix; default = NAMESPACE upper + // EnvPassthrough (cli) names host environment variables the fronted CLI is + // allowed to see, on top of a minimal baseline (PATH, HOME, locale, TMPDIR). + // The child never inherits the adapter's full environment. e.g. + // env_passthrough: [GITHUB_TOKEN, AWS_PROFILE] + EnvPassthrough []string `yaml:"env_passthrough"` + // Headers are sent on every backend request (http). Values may contain // ${TOKEN} placeholders resolved at runtime from the app's environment or // from $APP/secrets.json — so an API key is supplied by the operator at @@ -214,12 +220,14 @@ type HTTPRoute struct { Path string `yaml:"path"` // e.g. /current } -// CLIRoute maps a method to a local subprocess invocation (planned archetype). -// Args may reference payload fields as {{.field}}; ParamsAsFlags appends each -// payload key as --key value. +// CLIRoute maps a method to a local subprocess invocation. Args may reference +// payload fields as ${field}; ParamsAsFlags appends each payload key as +// --key value. Passthrough instead forwards a verbatim "args" array from the +// call payload — every CLI subcommand is reachable without enumerating it. type CLIRoute struct { Args []string `yaml:"args"` ParamsAsFlags bool `yaml:"params_as_flags"` + Passthrough bool `yaml:"passthrough"` } // Grants tunes the manifest's declared capabilities. The standard set @@ -411,6 +419,15 @@ func (c *Config) Validate() []error { case "cli": if m.CLI == nil { errs = append(errs, fmt.Errorf("methods[%d] (%s): cli backend requires a cli: route", i, m.Name)) + } else if m.CLI.Passthrough { + if len(m.CLI.Args) > 0 { + errs = append(errs, fmt.Errorf("methods[%d] (%s): cli.passthrough takes argv from the call payload — remove cli.args", i, m.Name)) + } + if m.CLI.ParamsAsFlags { + errs = append(errs, fmt.Errorf("methods[%d] (%s): cli.params_as_flags has no effect with passthrough", i, m.Name)) + } + } else if len(m.CLI.Args) == 0 && !m.CLI.ParamsAsFlags { + errs = append(errs, fmt.Errorf("methods[%d] (%s): cli route needs args, params_as_flags, or passthrough", i, m.Name)) } } } diff --git a/internal/scaffold/templates/client_cli.go.tmpl b/internal/scaffold/templates/client_cli.go.tmpl index 5800a08..49eca28 100644 --- a/internal/scaffold/templates/client_cli.go.tmpl +++ b/internal/scaffold/templates/client_cli.go.tmpl @@ -1,80 +1,219 @@ // Package backend runs the local CLI that {{.ID}} fronts. The adapter forwards -// each IPC method to one subprocess invocation and returns its stdout as the +// each IPC method to one subprocess invocation and returns its output as the // IPC reply. GENERATED by pilot-app; edit pilot.app.yaml and re-generate. // +// Two method shapes are supported: +// - enumerated: baked argv with ${field} placeholders filled from the payload, +// optionally appending --key value flags for each payload field. +// - passthrough: the caller supplies a verbatim "args" array — every CLI +// subcommand becomes `pilotctl appstore call {{.ID}} {"args":[...]}`. +// +// Safety properties (see docs/CLI-ADAPTER.md): +// - No shell: argv is exec'd directly, so values can never be re-parsed as +// shell metacharacters. ${field} substitution inserts each value as ONE argv +// element. +// - Scrubbed environment: the child inherits only a minimal allowlist plus any +// vars the spec opts in via backend.env_passthrough — never the adapter's +// full environment (which may hold the app identity or broker secrets). +// - Bounded output: stdout/stderr are capped so a runaway child cannot OOM the +// adapter; truncation is flagged in the reply. +// - A non-zero exit is a normal result returned structurally ({stdout, stderr, +// exit}); only spawn/timeout failures surface as IPC errors. +// // TODO(native-apps): COMING SOON. The real model delivers the binary via the // app store (manifest `assets`: per-OS/arch url+sha256, staged at $APP/), // and this runner execs that staged path — not an assumed-installed command. -// See docs/NATIVE-APPS.md. -// -// NOTE: the command must already be installed on the operator's host — the -// publisher uploads no binary. Exec is not yet a brokered capability, so the -// manifest declares only fs/audit grants for now; `proc.exec` is the planned -// explicit declaration. See docs/CLI-ADAPTER.md. +// See docs/NATIVE-APPS.md. The command must currently already be installed on the +// operator's host. Exec is a declared `proc.exec` capability (user-consented at +// install); it is not yet brokered per-call. See docs/CLI-ADAPTER.md. package backend import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "os" "os/exec" + "regexp" + "sort" "strings" + "time" ) -// Runner invokes a fixed base command, appending per-method args. +// maxOutputBytes caps captured stdout/stderr so a runaway child cannot exhaust +// the adapter's memory. Output past the cap is dropped and flagged truncated. +const maxOutputBytes = 4 << 20 // 4 MiB + +// killGrace is how long the child has to exit after its context is cancelled +// before Wait abandons it; pairs with the SIGKILL exec sends on cancel. +const killGrace = 5 * time.Second + +// baseEnvKeys is the minimal environment every fronted CLI may see. Anything an +// app actually needs (API tokens, config dirs) is opted in via env_passthrough. +var baseEnvKeys = []string{"PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR"} + +var placeholderRE = regexp.MustCompile(`\$\{([^}]+)\}`) + +// Runner invokes a fixed base command, appending per-call args. type Runner struct { - base []string // e.g. ["weathercli"] or ["python", "-m", "tool"] + base []string // e.g. ["weathercli"] or ["python", "-m", "tool"] + envKeys []string // extra host env vars forwarded to the child (allowlist) } // Spec is one method's invocation shape, baked in at generation time. type Spec struct { Args []string // may contain ${field} placeholders, substituted from params - ParamsAsFlags bool // if true, append --key value for each payload field + ParamsAsFlags bool // if true, append --key value for each payload field (sorted) + Passthrough bool // if true, ignore Args; argv comes from the call's "args" array } -// NewRunner returns a Runner over the given base argv. -func NewRunner(base []string) *Runner { return &Runner{base: base} } +// Call carries one invocation's dynamic inputs, decoded from the IPC payload. +type Call struct { + Params map[string]string // flat string params (enumerated methods) + Args []string // verbatim argv (passthrough methods) + Stdin string // optional stdin piped to the child +} -// Run executes the command for one call and returns stdout. If stdout is valid -// JSON it is passed through verbatim; otherwise it is wrapped as -// {"stdout": "...", "exit": 0} so the IPC reply is always a JSON object. -func (r *Runner) Run(ctx context.Context, spec Spec, params map[string]string) (json.RawMessage, error) { +// NewRunner returns a Runner over the given base argv. envKeys names host +// environment variables that may be forwarded to the child, on top of the +// minimal baseline. +func NewRunner(base []string, envKeys ...string) *Runner { + return &Runner{base: base, envKeys: envKeys} +} + +// Run executes the command for one call. On clean exit with pure-JSON stdout the +// JSON is passed through verbatim; otherwise the reply is wrapped as +// {"stdout","stderr","exit","truncated"} so the IPC reply is always a JSON +// object and the caller can see exactly what happened. +func (r *Runner) Run(ctx context.Context, spec Spec, call Call) (json.RawMessage, error) { if len(r.base) == 0 { return nil, fmt.Errorf("backend: no base command configured") } - args := make([]string, 0, len(r.base)+len(spec.Args)+2*len(params)) - args = append(args, r.base[1:]...) - for _, a := range spec.Args { - args = append(args, substitute(a, params)) - } - if spec.ParamsAsFlags { - for k, v := range params { - args = append(args, "--"+k, v) + + args := append([]string(nil), r.base[1:]...) + if spec.Passthrough { + args = append(args, call.Args...) + } else { + for _, a := range spec.Args { + sub, err := substitute(a, call.Params) + if err != nil { + return nil, err + } + args = append(args, sub) + } + if spec.ParamsAsFlags { + for _, k := range sortedKeys(call.Params) { + args = append(args, "--"+k, call.Params[k]) + } } } cmd := exec.CommandContext(ctx, r.base[0], args...) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := cmd.Run() - if err != nil { - return nil, fmt.Errorf("backend: %s: %w: %s", r.base[0], err, strings.TrimSpace(stderr.String())) + cmd.Env = r.childEnv() + cmd.WaitDelay = killGrace + if call.Stdin != "" { + cmd.Stdin = strings.NewReader(call.Stdin) + } + stdout := &capBuffer{limit: maxOutputBytes} + stderr := &capBuffer{limit: maxOutputBytes} + cmd.Stdout = stdout + cmd.Stderr = stderr + + runErr := cmd.Run() + + exitCode := 0 + if runErr != nil { + var ee *exec.ExitError + if errors.As(runErr, &ee) { + exitCode = ee.ExitCode() // ran, exited non-zero — a normal CLI result + } else { + // Failed to start (binary missing) or killed (timeout/cancel): the + // command never produced a meaningful result. Surface as an error. + return nil, fmt.Errorf("backend: %s: %w: %s", r.base[0], runErr, strings.TrimSpace(stderr.String())) + } } out := bytes.TrimSpace(stdout.Bytes()) - if json.Valid(out) && len(out) > 0 { + if exitCode == 0 && len(out) > 0 && json.Valid(out) && !stdout.truncated { return json.RawMessage(out), nil } - wrapped, _ := json.Marshal(map[string]any{"stdout": string(out), "exit": 0}) + wrapped, _ := json.Marshal(map[string]any{ + "stdout": string(out), + "stderr": strings.TrimSpace(stderr.String()), + "exit": exitCode, + "truncated": stdout.truncated || stderr.truncated, + }) return wrapped, nil } -// substitute replaces ${field} tokens in s with params[field] (empty if absent). -func substitute(s string, params map[string]string) string { - for k, v := range params { - s = strings.ReplaceAll(s, "${"+k+"}", v) +// childEnv builds the scrubbed environment for the child: the minimal baseline +// plus any explicitly opted-in keys that are actually set in the host env. +func (r *Runner) childEnv() []string { + var env []string + for _, k := range baseEnvKeys { + if v, ok := os.LookupEnv(k); ok { + env = append(env, k+"="+v) + } + } + for _, k := range r.envKeys { + if v, ok := os.LookupEnv(k); ok { + env = append(env, k+"="+v) + } } - return s + return env } + +// substitute replaces every ${field} token in s with params[field]. An +// unresolved token is an error rather than a silently-empty arg, so a missing +// required param fails loudly instead of mis-invoking the CLI. +func substitute(s string, params map[string]string) (string, error) { + var missing []string + out := placeholderRE.ReplaceAllStringFunc(s, func(tok string) string { + name := tok[2 : len(tok)-1] + if v, ok := params[name]; ok { + return v + } + missing = append(missing, name) + return "" + }) + if len(missing) > 0 { + return "", fmt.Errorf("backend: missing required param(s): %s", strings.Join(missing, ", ")) + } + return out, nil +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// capBuffer accumulates up to limit bytes, then silently drops the rest while +// reporting full Write lengths so the child is never blocked on a full pipe. +type capBuffer struct { + buf bytes.Buffer + limit int + truncated bool +} + +func (c *capBuffer) Write(p []byte) (int, error) { + if room := c.limit - c.buf.Len(); room > 0 { + if len(p) > room { + c.buf.Write(p[:room]) + c.truncated = true + } else { + c.buf.Write(p) + } + } else if len(p) > 0 { + c.truncated = true + } + return len(p), nil +} + +func (c *capBuffer) Bytes() []byte { return c.buf.Bytes() } +func (c *capBuffer) String() string { return c.buf.String() } diff --git a/internal/scaffold/templates/example.pilot.app.yaml b/internal/scaffold/templates/example.pilot.app.yaml index 207f8ce..51d1f17 100644 --- a/internal/scaffold/templates/example.pilot.app.yaml +++ b/internal/scaffold/templates/example.pilot.app.yaml @@ -88,13 +88,23 @@ listing: # date: "2026-06-16" # notes: ["Initial release"] -# --- a cli backend instead (planned archetype; see docs/CLI-ADAPTER.md) --- +# --- a cli backend instead (COMING SOON; see docs/CLI-ADAPTER.md) --- # backend: # type: cli -# command: ["weathercli"] # base argv; method args appended +# command: ["weathercli"] # base argv; method args appended +# env_passthrough: [WEATHER_TOKEN] # host env vars the CLI may see (else scrubbed) # methods: +# # enumerated: a curated, named subcommand with ${field} from the payload # - name: weather.current # summary: "Current conditions." # duration: fast # cli: -# args: ["current", "--lat", "${lat}", "--lon", "${lon}"] # ${field} from payload +# args: ["current", "--lat", "${lat}", "--lon", "${lon}"] +# # passthrough: front the WHOLE CLI — every subcommand reachable via one method +# # pilotctl appstore call io.pilot.weather weather.exec '{"args":["forecast","--days","5"]}' +# - name: weather.exec +# summary: "Run any weathercli subcommand. Payload {\"args\":[...]}." +# duration: med +# params: {args: "verbatim argv forwarded to weathercli"} +# cli: +# passthrough: true diff --git a/internal/scaffold/templates/main.go.tmpl b/internal/scaffold/templates/main.go.tmpl index ac27958..2ac0834 100644 --- a/internal/scaffold/templates/main.go.tmpl +++ b/internal/scaffold/templates/main.go.tmpl @@ -57,8 +57,8 @@ func main() { log.Fatal("{{.BinaryName}}: --socket is required (the pilot daemon supplies it)") } - cfg := resolveConfig(*manifestPath) {{- if eq .Backend.Type "http"}} + cfg := resolveConfig(*manifestPath) {{- if .Managed}} signer, err := backend.NewSigner(*identity) if err != nil { @@ -70,7 +70,7 @@ func main() { log.Fatalf("{{.BinaryName}}: backend config: %v", err) } {{- else}} - runner := backend.NewRunner({{printf "%#v" .Backend.Command}}) + runner := backend.NewRunner({{printf "%#v" .Backend.Command}}{{range .Backend.EnvPassthrough}}, {{printf "%q" .}}{{end}}) {{- end}} d := ipc.NewDispatcher() @@ -157,23 +157,56 @@ func forwardPost(c *backend.Client, path string, timeout time.Duration) ipc.Hand {{- else}} func registerHandlers(d *ipc.Dispatcher, r *backend.Runner, version string) { {{- range .Methods}} - d.Register("{{.Name}}", forwardExec(r, backend.Spec{Args: {{printf "%#v" .CLI.Args}}, ParamsAsFlags: {{.CLI.ParamsAsFlags}}}, dur("{{.TimeoutFor}}"))) // {{.Duration}} + d.Register("{{.Name}}", forwardExec(r, backend.Spec{ {{if .CLI.Passthrough}}Passthrough: true{{else}}Args: {{printf "%#v" .CLI.Args}}, ParamsAsFlags: {{.CLI.ParamsAsFlags}}{{end}} }, dur("{{.TimeoutFor}}"))) // {{.Duration}} {{- end}} d.Register("{{.Namespace}}.help", helpHandler(version)) } -// forwardExec runs the backend CLI for one method and returns its stdout as the -// IPC reply. ${field} placeholders in Args are substituted from the payload. +// forwardExec runs the backend CLI for one method and returns its output as the +// IPC reply. Enumerated methods fill ${field} placeholders from the payload; +// passthrough methods take a verbatim "args" array. Both accept an optional +// "stdin" string piped to the child. func forwardExec(r *backend.Runner, spec backend.Spec, timeout time.Duration) ipc.Handler { return func(ctx context.Context, req *ipc.Envelope) (json.RawMessage, error) { - params, err := stringParams(req.Payload) + call, err := decodeCall(spec, req.Payload) if err != nil { return nil, err } ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - return r.Run(ctx, spec, params) + return r.Run(ctx, spec, call) + } +} + +// decodeCall extracts one invocation's dynamic inputs from the IPC payload. A +// passthrough method reads {"args":[...], "stdin"?:"..."} — the args array is +// the verbatim argv appended to the base command. An enumerated method reads a +// flat object of string params; the reserved key "stdin" is piped to the child. +func decodeCall(spec backend.Spec, raw json.RawMessage) (backend.Call, error) { + var call backend.Call + if spec.Passthrough { + var p struct { + Args []string `json:"args"` + Stdin string `json:"stdin"` + } + if len(raw) > 0 { + if err := json.Unmarshal(raw, &p); err != nil { + return call, fmt.Errorf(`passthrough payload must be {"args":[...],"stdin"?:"..."}: %w`, err) + } + } + call.Args, call.Stdin = p.Args, p.Stdin + return call, nil + } + params, err := stringParams(raw) + if err != nil { + return call, err + } + if s, ok := params["stdin"]; ok { + call.Stdin = s + delete(params, "stdin") } + call.Params = params + return call, nil } {{- end}} diff --git a/internal/scaffold/templates/manifest.json.tmpl b/internal/scaffold/templates/manifest.json.tmpl index 876894e..860a14c 100644 --- a/internal/scaffold/templates/manifest.json.tmpl +++ b/internal/scaffold/templates/manifest.json.tmpl @@ -27,6 +27,9 @@ {{- if eq .Backend.Type "http"}} {"cap": "net.dial", "target": "{{.AdapterBackendHost}}", "if": {"kind": "rate", "params": {"per": "min", "limit": {{.Grants.RatePerMin}}}}}, +{{- end}} +{{- if eq .Backend.Type "cli"}} + {"cap": "proc.exec", "target": "{{index .Backend.Command 0}}"}, {{- end}} {"cap": "audit.log", "target": "*"} {{- if .Backend.X402}}, @@ -36,7 +39,7 @@ {"cap": "{{.Cap}}", "target": "{{.Target}}"} {{- end}} ], - "protection": "{{if .Backend.X402}}guarded{{else}}shareable{{end}}", + "protection": "{{if or .Backend.X402 (eq .Backend.Type "cli")}}guarded{{else}}shareable{{end}}", "store": { "publisher": "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "signature": "sig:placeholder-store-sig-replaced-at-publish-time"