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}}
+ | Backend | cli: {{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
- | Method | Latency | Path | Description |
+ | Method | Latency | Route | Description |
{{range .C.Submission.Methods}}
| {{.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}} |
{{end}}
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"