Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions cmd/publish-server/templates/case.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,20 @@ <h1>{{.C.Submission.Listing.DisplayName}} <span class="soft">{{.C.Submission.Ver
<tr><th>ID</th><td class="mono">{{.C.Submission.ID}}</td></tr>
<tr><th>Version</th><td class="mono">{{.C.Submission.Version}}</td></tr>
<tr><th>Description</th><td>{{.C.Submission.Description}}</td></tr>
{{if .C.Submission.Backend.IsCLI}}
<tr><th>Backend</th><td class="mono">cli: {{join .C.Submission.Backend.Command}}</td></tr>
{{if .C.Submission.Backend.EnvPassthrough}}<tr><th>Env passthrough</th><td class="mono">{{join .C.Submission.Backend.EnvPassthrough}}</td></tr>{{end}}
{{else}}
<tr><th>Backend</th><td class="mono">{{.C.Submission.Backend.BaseURL}}</td></tr>
{{range .C.Submission.Backend.Headers}}<tr><th>Header</th><td class="mono">{{.Name}}: {{.Value}}</td></tr>{{end}}
{{end}}
</table></div>

<div class="card review"><h2>Methods</h2><table class="methods">
<tr><th>Method</th><th>Latency</th><th>Path</th><th>Description</th></tr>
<tr><th>Method</th><th>Latency</th><th>Route</th><th>Description</th></tr>
{{range .C.Submission.Methods}}<tr>
<td class="mono">{{.Name}}</td><td>{{.Latency}}</td>
<td class="mono">{{.HTTP.Verb}} {{.HTTP.Path}}</td><td>{{.Description}}</td>
<td class="mono">{{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}}</td><td>{{.Description}}</td>
</tr>{{end}}
</table></div>

Expand Down
51 changes: 47 additions & 4 deletions docs/CLI-ADAPTER.md
Original file line number Diff line number Diff line change
@@ -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`).
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
160 changes: 160 additions & 0 deletions internal/publish/cli_e2e_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading