diff --git a/go/cmd/compass-app/bridge_service.go b/go/cmd/compass-app/bridge_service.go index 2d8c6630..9141f774 100644 --- a/go/cmd/compass-app/bridge_service.go +++ b/go/cmd/compass-app/bridge_service.go @@ -44,6 +44,14 @@ type bridgeService struct { pump *bridge.Pump events eventEmitter + // accountID is the caller account id resolved by the embedded launch + // pipeline via WhoAmI (DL-111), exposed to the JS/UI through the bound + // AccountID method so it can build the native ConnectionProvider. It is set + // once by the launch pipeline immediately after construction and before + // app.Run, and only read thereafter, so it needs no lock. Empty in client + // mode or when identity was not resolved. + accountID string + mu sync.Mutex inflight map[string]*inflightCall } @@ -66,6 +74,17 @@ func newBridgeService(pump *bridge.Pump, events eventEmitter) *bridgeService { } } +// AccountID is the bound IPC getter the webview calls to learn the caller +// account id the embedded launch resolved via WhoAmI (DL-111). The JS side +// (compass-ui zone) reads it over Wails IPC to build the native +// ConnectionProvider; the account id is server-derived, never client-supplied. +// It returns the empty string when no identity was resolved (client mode, or a +// shell started against a hand-run daemon), which the JS treats as "not yet +// identified". +func (s *bridgeService) AccountID(_ context.Context) string { + return s.accountID +} + // headerPair is one request/response header as the JS side models it: an ordered // {name, value} object (apps/ui/src/daemon-transport.ts). Order is preserved. type headerPair struct { diff --git a/go/cmd/compass-app/bridge_service_test.go b/go/cmd/compass-app/bridge_service_test.go index daf7c894..8d76b12a 100644 --- a/go/cmd/compass-app/bridge_service_test.go +++ b/go/cmd/compass-app/bridge_service_test.go @@ -525,3 +525,18 @@ func TestCompassRPCConcurrentDistinctIDs(t *testing.T) { assertNotInflight(t, svcB, idB) } + +// TestAccountIDBoundGetter: the bound AccountID method returns the account id +// the embedded launch set on the service (the value the JS/UI reads over IPC to +// build the native ConnectionProvider), and the empty string when none was +// resolved. This is the T4.1 hand-off surface for the caller identity. +func TestAccountIDBoundGetter(t *testing.T) { + svc, _ := newService("/unused.sock") + if got := svc.AccountID(context.Background()); got != "" { + t.Errorf("AccountID with no identity = %q, want empty", got) + } + svc.accountID = "acc-resolved" + if got := svc.AccountID(context.Background()); got != "acc-resolved" { + t.Errorf("AccountID = %q, want acc-resolved", got) + } +} diff --git a/go/cmd/compass-app/embedded.go b/go/cmd/compass-app/embedded.go new file mode 100644 index 00000000..d6fba18c --- /dev/null +++ b/go/cmd/compass-app/embedded.go @@ -0,0 +1,329 @@ +//go:build unix && gtk3 + +// The embedded-mode launch pipeline (SEA-1685 T4.1). Embedded mode wires the +// native shell end-to-end before the window opens: host preflight → spawn and +// supervise the private stack (via the compass-stack CLI) → learn the caller +// account id (WhoAmI, DL-111) → hand the resolved socket + account id to the +// bridge/UI. It is the composition root that supplies the real external effects +// (podman/postgres probes, the compass-stack exec, the h2c-UDS WhoAmI dial) +// behind small injected seams, so the orchestration is unit-testable without a +// real stack. +// +// This file supervises the stack through the compass-stack BINARY (frozen +// design §T4: "consumes T2's compass-stack CLI"), not by importing +// go/internal/stack: `compass-stack up` brings the stack to Ready and exits 0 +// while the children keep running (fire-and-return), so the pipeline runs it, +// waits for exit 0, and then dials the same socket it passed as --socket. +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "connectrpc.com/connect" + + compassv1 "github.com/sealedsecurity/compass/go/gen/compass/v1" + "github.com/sealedsecurity/compass/go/gen/compass/v1/compassv1connect" + "github.com/sealedsecurity/compass/go/internal/appconfig" + "github.com/sealedsecurity/compass/go/internal/preflight" +) + +// defaultAgentImage is the canonical agent image ref the embedded stack runs +// when no --image/$COMPASS_AGENT_IMAGE is supplied. The ref is locked +// (docs/designs/platform/compass-agent-image-publish.md §Ref); the native app +// does not bundle the image (DL-112) — compass-stack podman-pulls it from GHCR +// at first run. +const defaultAgentImage = "ghcr.io/sealedsecurity/compass-agent:latest" + +// errClientNotImplemented is the native-client (T5) mode's placeholder outcome. +// Client mode is a later slice; embedded mode is this one. It is a sentinel so +// the mode branch is assertable in tests without matching on a message string. +var errClientNotImplemented = errors.New("native-client mode is not yet implemented (T5)") + +// embeddedPipeline is the embedded-mode launch pipeline over its injected +// external effects. Each field is one genuine effect the real launch supplies +// (preflight run, the compass-stack up exec, the WhoAmI dial); a test supplies +// deterministic stubs, so the orchestration — order, short-circuit, and the +// argv it builds — is verified with no real podman/postgres/stack/exec. +type embeddedPipeline struct { + // preflight runs the host precondition checks and folds any failures into a + // single legible error (the real seam wraps preflight.Deps.Run(...).Err()). + preflight func(ctx context.Context) error + // stackUp runs `compass-stack up` with the given argv and waits for it to + // exit 0 (fire-and-return); a non-zero exit is returned as an error carrying + // the captured stderr. + stackUp func(ctx context.Context, args []string) error + // whoAmI dials the stack socket over h2c-UDS and returns the caller account + // id (WhoAmI, DL-111 — server-derived, never supplied). + whoAmI func(ctx context.Context, socket string) (string, error) +} + +// embeddedParams is the resolved input to one embedded launch: the single +// socket path the stack serves and the pipeline then dials, the stack argv +// inputs, and the DSN the preflight DB probe used (informational; the argv omits +// --database so compass-stack recomputes the identical default from --state-dir). +type embeddedParams struct { + // socket is resolveSocket()'s result — the SAME value passed to + // `--socket` and dialed for WhoAmI (and, upstream, the bridge pump). + socket string + // stateDir is the app state directory passed to `--state-dir`. + stateDir string + // image is the agent image ref passed to `--image`. + image string +} + +// launchByMode dispatches the resolved app mode: embedded mode runs the +// pipeline (returning the caller account id), client mode returns the T5 +// not-implemented sentinel WITHOUT touching any pipeline effect. Any other mode +// value is a programming error (appconfig only ever yields the two). +func launchByMode(ctx context.Context, mode appconfig.Mode, pipeline embeddedPipeline, params embeddedParams) (string, error) { + switch mode { + case appconfig.ModeEmbedded: + return pipeline.run(ctx, params) + case appconfig.ModeClient: + return "", errClientNotImplemented + default: + return "", fmt.Errorf("unknown app mode %v", mode) + } +} + +// run executes the embedded launch in order: preflight → stack up → WhoAmI. A +// preflight failure short-circuits (the stack is never spawned) and returns the +// aggregated legible error verbatim. On success it returns the resolved caller +// account id. +func (p embeddedPipeline) run(ctx context.Context, params embeddedParams) (string, error) { + if err := p.preflight(ctx); err != nil { + return "", err + } + + args := stackUpArgs(params) + if err := p.stackUp(ctx, args); err != nil { + return "", err + } + slog.Info("stack ready", "socket", params.socket) + + accountID, err := p.whoAmI(ctx, params.socket) + if err != nil { + return "", fmt.Errorf("resolving caller identity over %s: %w", params.socket, err) + } + slog.Info("caller identity resolved", "account", accountID) + return accountID, nil +} + +// stackUpArgs builds the `compass-stack up` argv from the resolved params. It is +// pure (no I/O, no exec) so the exact invocation is unit-testable without +// running anything — mirroring cmd/compass-stack's pure resolveConfig. --database +// is deliberately omitted: compass-stack computes the identical default DSN from +// --state-dir (cmd/compass-stack/main.go defaultDSN), so passing it would +// duplicate that logic. +func stackUpArgs(p embeddedParams) []string { + args := []string{ + "up", + "--state-dir", p.stateDir, + "--image", p.image, + "--socket", p.socket, + } + return args +} + +// runStackUp is the real stackUp seam: it execs the compass-stack binary at bin +// with the given argv and waits for it to exit 0 (up is fire-and-return, so +// Run returning nil means the stack reached Ready and its children keep +// running). A non-zero exit is surfaced with the captured stderr so the failure +// copy is legible. +func runStackUp(bin string) func(ctx context.Context, args []string) error { + return func(ctx context.Context, args []string) error { + //nolint:gosec // G204: bin is operator/PATH-resolved (resolveStackBin) and + // the argv is pipeline-assembled (stackUpArgs), not user input. + cmd := exec.CommandContext(ctx, bin, args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded || errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("compass-stack up exceeded the %s bring-up window "+ + "(a cold agent-image pull from GHCR can take longer on first run): %w", bringUpTimeout, err) + } + if msg := strings.TrimSpace(stderr.String()); msg != "" { + return fmt.Errorf("compass-stack up failed: %w: %s", err, msg) + } + return fmt.Errorf("compass-stack up failed: %w", err) + } + return nil + } +} + +// whoAmIOverUDS is the real whoAmI seam: it dials the stack socket over +// prior-knowledge cleartext HTTP/2 (the same door compass-server serves) and +// calls WhoAmI, returning the server-derived caller account id. The transport +// shape mirrors internal/stack/adapters/health.go (the established h2c-UDS +// connect dial). +func whoAmIOverUDS(ctx context.Context, socket string) (string, error) { + protocols := new(http.Protocols) + protocols.SetUnencryptedHTTP2(true) + transport := &http.Transport{ + Protocols: protocols, + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", socket) + }, + } + defer transport.CloseIdleConnections() + + client := compassv1connect.NewCompassServiceClient(&http.Client{Transport: transport}, "http://unix") + resp, err := client.WhoAmI(ctx, connect.NewRequest(&compassv1.WhoAmIRequest{})) + if err != nil { + return "", err + } + id := resp.Msg.GetAccountId() + if id == "" { + return "", errors.New("WhoAmI returned an empty account id") + } + return id, nil +} + +// resolveMode resolves the --mode/$COMPASS_APP_MODE override to feed +// appconfig.Load. An empty flag falls back to the env; both empty is "no +// override" (Load then uses app.toml, else embedded). +func resolveMode(flagValue string) string { + if flagValue != "" { + return flagValue + } + return os.Getenv("COMPASS_APP_MODE") +} + +// resolveStackBin picks the compass-stack binary to supervise the stack with: +// the --compass-stack flag, else $COMPASS_STACK_BIN, else compass-stack on +// $PATH, else a compass-stack sibling of the running executable (where a +// packaged build stages it, mirroring resolveAssetsDir's beside-the-executable +// pattern). A legible error names every place it looked when none resolves. +func resolveStackBin(flagValue string) (string, error) { + if flagValue != "" { + return flagValue, nil + } + if env := os.Getenv("COMPASS_STACK_BIN"); env != "" { + return env, nil + } + if p, err := exec.LookPath("compass-stack"); err == nil { + return p, nil + } + if exe, err := os.Executable(); err == nil { + sibling := filepath.Join(filepath.Dir(exe), "compass-stack") + if info, statErr := os.Stat(sibling); statErr == nil && !info.IsDir() { + return sibling, nil + } + } + return "", errors.New("compass-stack binary not found: pass --compass-stack, set $COMPASS_STACK_BIN, " + + "put compass-stack on $PATH, or stage it beside the compass-app executable") +} + +// resolveStateDir picks the app state directory: the --state-dir flag, else +// $COMPASS_STATE_DIR, else $XDG_STATE_HOME/compass, else $HOME/.compass. A +// relative XDG_STATE_HOME is treated as unset (matching resolveSocket's handling +// of a relative XDG_RUNTIME_DIR), so the fallback is deterministic. +func resolveStateDir(flagValue string) string { + if flagValue != "" { + return flagValue + } + if env := os.Getenv("COMPASS_STATE_DIR"); env != "" { + return env + } + if stateHome := os.Getenv("XDG_STATE_HOME"); filepath.IsAbs(stateHome) { + return filepath.Join(stateHome, "compass") + } + return filepath.Join(os.Getenv("HOME"), ".compass") +} + +// resolveImage picks the agent image ref: the --image flag, else +// $COMPASS_AGENT_IMAGE (the same env compass-runner honors), else the locked +// GHCR default. +func resolveImage(flagValue string) string { + if flagValue != "" { + return flagValue + } + if env := os.Getenv("COMPASS_AGENT_IMAGE"); env != "" { + return env + } + return defaultAgentImage +} + +// embeddedDatabaseDSN is a DELIBERATE second copy of cmd/compass-stack's +// defaultDSN (go/cmd/compass-stack/main.go defaultDSN — the source of truth): +// the keyword/value DSN for the private postgres reachable over a unix socket +// under the state dir. It is duplicated here because the app's preflight DB +// probe needs the DSN BEFORE compass-stack runs; the compass-stack up argv +// omits --database so the CLI recomputes this identical default from +// --state-dir. The two formulas must stay in lockstep +// (TestEmbeddedDatabaseDSNMatchesCompassStackDefault guards this side). +// Consolidating both into one importable helper is a tracked follow-up +// (SEA-1856). +func embeddedDatabaseDSN(stateDir string) string { + sockDir := filepath.Join(stateDir, "postgres", "sock") + return fmt.Sprintf("host=%s port=5432 dbname=compass sslmode=disable", sockDir) +} + +// realPreflight builds the preflight seam over the real host-probe adapters, +// classified at this T4 wiring boundary (see classifyPreflight). +func realPreflight(image, dsn string) func(ctx context.Context) error { + deps := preflight.Deps{ + GOOS: runtime.GOOS, + CurrentUID: os.Getuid(), + ExpectedAgentUID: preflight.DefaultAgentUID, + PodmanRootless: podmanRootless, + ImagePresent: imagePresent, + DBReachable: dbReachable, + } + params := preflight.Params{AgentImage: image, DatabaseDSN: dsn} + return func(ctx context.Context) error { + return classifyPreflight(deps.Run(ctx, params)) + } +} + +// classifyPreflight splits the preflight results by severity at the T4 wiring +// boundary and returns only the FATAL failures folded into one legible error +// (nil when none are fatal). +// +// The split is load-bearing for embedded mode's zero-config cold start. `up` +// (compass-stack) is what STARTS postgres (design.md:176) and PULLS the agent +// image (internal/stack/adapters/image.go EnsureImage), so on a fresh state dir +// the DB and image checks NECESSARILY fail before `up` has run — gating on them +// would make the app unable to cold-start, breaking the zero-config charter. And +// they need no post-up re-check: `up`-Ready is GetServerInfo answering, which +// requires migrations against a live postgres AND the runner booting on the +// pulled image (design.md:189-191), so reaching Ready transitively verifies +// both. +// +// - FATAL (host capabilities `up` cannot create): OS, UID, rootless podman. +// - ADVISORY (`up` ensures them, `up`-Ready verifies them): image, database — +// logged at Warn, never fatal. +// +// PARKED as an SEA-1685 Open Question: whether this severity split belongs in +// the preflight core's Err() rather than here at the boundary. Kept at the +// boundary for now so the core's Run/Err control flow (every failure surfaced) +// is unchanged and still serves the operator-facing "show every unmet +// precondition at once" use. +func classifyPreflight(results preflight.Results) error { + var fatal preflight.Results + for _, r := range results { + if r.OK { + continue + } + switch r.Name { + case preflight.CheckImage, preflight.CheckDatabase: + slog.Warn("preflight: precondition unmet; compass-stack up will ensure it", + "check", r.Name, "detail", r.Detail) + default: + fatal = append(fatal, r) + } + } + return fatal.Err() +} diff --git a/go/cmd/compass-app/embedded_test.go b/go/cmd/compass-app/embedded_test.go new file mode 100644 index 00000000..314b47dd --- /dev/null +++ b/go/cmd/compass-app/embedded_test.go @@ -0,0 +1,618 @@ +//go:build unix && gtk3 + +package main + +// T4.1 embedded launch-pipeline gate. The pipeline is exercised through its Go +// entrypoints with INJECTED effects — no real podman/postgres/compass-stack/exec +// — so mode-select, preflight short-circuit, the exact compass-stack argv, the +// WhoAmI hop, and the two error paths are all verified deterministically. The +// one seam wired to a real transport is whoAmIOverUDS, driven against a REAL +// in-process compass.v1 WhoAmI server over h2c on a Unix socket (mirroring the +// bridge-service gate's stubServer and internal/runner/e2e_transport_test.go's +// UDS/connect pattern), so the h2c-UDS dial is proven on the wire it ships on. + +import ( + "context" + "errors" + "net" + "net/http" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "connectrpc.com/connect" + + compassv1 "github.com/sealedsecurity/compass/go/gen/compass/v1" + "github.com/sealedsecurity/compass/go/gen/compass/v1/compassv1connect" + "github.com/sealedsecurity/compass/go/internal/appconfig" + "github.com/sealedsecurity/compass/go/internal/preflight" +) + +const embeddedTestTimeout = 5 * time.Second + +// baseParams is a representative resolved launch input the argv/dial assertions +// key off. The socket is a fixed path (the tests never dial it except in the +// WhoAmI-server case, which overrides it). +var baseParams = embeddedParams{ + socket: "/run/compass/server.sock", + stateDir: "/state/compass", + image: "ghcr.io/sealedsecurity/compass-agent:latest", +} + +// stubPipeline builds an embeddedPipeline whose three seams are deterministic +// stubs, recording what the orchestration invoked. Each seam defaults to a +// success no-op; a test overrides the ones it drives. +type recorder struct { + preflightCalled bool + stackUpCalled bool + stackUpArgs []string + whoAmICalled bool + whoAmISocket string +} + +func stubPipeline(rec *recorder, preflightErr, stackUpErr, whoAmIErr error, accountID string) embeddedPipeline { + return embeddedPipeline{ + preflight: func(_ context.Context) error { + rec.preflightCalled = true + return preflightErr + }, + stackUp: func(_ context.Context, args []string) error { + rec.stackUpCalled = true + rec.stackUpArgs = args + return stackUpErr + }, + whoAmI: func(_ context.Context, socket string) (string, error) { + rec.whoAmICalled = true + rec.whoAmISocket = socket + return accountID, whoAmIErr + }, + } +} + +// TestLaunchByModeClientNotImplemented: client mode returns the T5 sentinel and +// touches NO pipeline effect (no preflight, no stack-up, no WhoAmI) — the client +// path is not stubbed-in here, it is explicitly out of scope. Mutation that +// reddens it: routing client mode into run() would flip a recorder flag. +func TestLaunchByModeClientNotImplemented(t *testing.T) { + rec := &recorder{} + pipeline := stubPipeline(rec, nil, nil, nil, "acc-x") + + id, err := launchByMode(context.Background(), appconfig.ModeClient, pipeline, baseParams) + if !errors.Is(err, errClientNotImplemented) { + t.Fatalf("client mode err = %v, want errClientNotImplemented", err) + } + if id != "" { + t.Errorf("client mode account id = %q, want empty", id) + } + if rec.preflightCalled || rec.stackUpCalled || rec.whoAmICalled { + t.Errorf("client mode ran a pipeline effect: %+v", rec) + } +} + +// TestLaunchByModeEmbeddedHappyPath: embedded mode runs preflight → stack up → +// WhoAmI in order, passes the SAME socket to the dial that the argv carries, and +// returns the resolved account id. Asserting the argv (up, --socket, --state-dir, +// --image) is the stack-invocation contract; asserting whoAmISocket == socket is +// the single-socket invariant (the value passed to --socket IS the value dialed). +func TestLaunchByModeEmbeddedHappyPath(t *testing.T) { + rec := &recorder{} + pipeline := stubPipeline(rec, nil, nil, nil, "acc-42") + + id, err := launchByMode(context.Background(), appconfig.ModeEmbedded, pipeline, baseParams) + if err != nil { + t.Fatalf("embedded happy path err = %v, want nil", err) + } + if id != "acc-42" { + t.Errorf("account id = %q, want acc-42", id) + } + if !rec.preflightCalled || !rec.stackUpCalled || !rec.whoAmICalled { + t.Fatalf("not every stage ran: %+v", rec) + } + assertArg(t, rec.stackUpArgs, "up") + assertArgPair(t, rec.stackUpArgs, "--socket", baseParams.socket) + assertArgPair(t, rec.stackUpArgs, "--state-dir", baseParams.stateDir) + assertArgPair(t, rec.stackUpArgs, "--image", baseParams.image) + if rec.whoAmISocket != baseParams.socket { + t.Errorf("WhoAmI dialed %q, want the SAME socket passed to --socket %q", + rec.whoAmISocket, baseParams.socket) + } +} + +// TestLaunchByModePreflightShortCircuits: a preflight failure returns the +// aggregated legible error VERBATIM and never proceeds to stack-up or WhoAmI. +// Mutation that reddens it: running the checks after a failure, or reformatting +// Results.Err's copy. +func TestLaunchByModePreflightShortCircuits(t *testing.T) { + rec := &recorder{} + preflightErr := errors.New("embedded-mode preflight failed:\n - windows is not linux") + pipeline := stubPipeline(rec, preflightErr, nil, nil, "acc-x") + + id, err := launchByMode(context.Background(), appconfig.ModeEmbedded, pipeline, baseParams) + if !errors.Is(err, preflightErr) { + t.Fatalf("preflight-fail err = %v, want the preflight error verbatim", err) + } + if id != "" { + t.Errorf("account id = %q, want empty on preflight failure", id) + } + if !rec.preflightCalled { + t.Error("preflight did not run") + } + if rec.stackUpCalled || rec.whoAmICalled { + t.Errorf("pipeline proceeded past a failed preflight: %+v", rec) + } +} + +// TestLaunchByModeStackUpFails: a non-zero compass-stack up exit is surfaced and +// the pipeline stops before WhoAmI. The stackUp seam already folds stderr into +// its error (see TestRunStackUpNonZeroExitSurfacesStderr); here the contract is +// that run() propagates it and does not dial. +func TestLaunchByModeStackUpFails(t *testing.T) { + rec := &recorder{} + stackErr := errors.New("compass-stack up failed: exit status 1: postgres refused") + pipeline := stubPipeline(rec, nil, stackErr, nil, "acc-x") + + id, err := launchByMode(context.Background(), appconfig.ModeEmbedded, pipeline, baseParams) + if !errors.Is(err, stackErr) { + t.Fatalf("stack-up-fail err = %v, want the stack-up error", err) + } + if id != "" { + t.Errorf("account id = %q, want empty on stack-up failure", id) + } + if rec.whoAmICalled { + t.Error("pipeline dialed WhoAmI after a failed stack-up") + } +} + +// TestLaunchByModeWhoAmIFails: a WhoAmI error is surfaced (wrapped with the +// socket for context) and no account id is returned. Mutation that reddens it: +// swallowing the WhoAmI error and returning an empty id as success. +func TestLaunchByModeWhoAmIFails(t *testing.T) { + rec := &recorder{} + whoErr := errors.New("connect: connection refused") + pipeline := stubPipeline(rec, nil, nil, whoErr, "") + + id, err := launchByMode(context.Background(), appconfig.ModeEmbedded, pipeline, baseParams) + if !errors.Is(err, whoErr) { + t.Fatalf("whoami-fail err = %v, want the WhoAmI error wrapped", err) + } + if id != "" { + t.Errorf("account id = %q, want empty on WhoAmI failure", id) + } + if !strings.Contains(err.Error(), baseParams.socket) { + t.Errorf("WhoAmI error %q does not name the socket for context", err.Error()) + } +} + +// TestStackUpArgsOmitsDatabase: the pure argv builder omits --database +// (compass-stack recomputes the identical default from --state-dir, so the app +// carries no second DSN). +func TestStackUpArgsOmitsDatabase(t *testing.T) { + args := stackUpArgs(baseParams) + if slices.Contains(args, "--database") { + t.Errorf("argv carries --database, want it omitted so compass-stack defaults the DSN: %v", args) + } +} + +// stubWhoAmIServer implements just the WhoAmI RPC over the generated +// CompassService handler; every other method returns Unimplemented. accountID is +// what WhoAmI reports; whoErr, when non-nil, is returned instead. +type stubWhoAmIServer struct { + compassv1connect.UnimplementedCompassServiceHandler + accountID string + whoErr error +} + +func (s *stubWhoAmIServer) WhoAmI( + _ context.Context, _ *connect.Request[compassv1.WhoAmIRequest], +) (*connect.Response[compassv1.WhoAmIResponse], error) { + if s.whoErr != nil { + return nil, s.whoErr + } + return connect.NewResponse(&compassv1.WhoAmIResponse{AccountId: s.accountID}), nil +} + +// serveWhoAmI stands up a real h2c compass.v1 server on a UDS listener, torn +// down via t.Cleanup, and returns the socket path whoAmIOverUDS dials. +func serveWhoAmI(t *testing.T, srv *stubWhoAmIServer) string { + t.Helper() + socket := filepath.Join(t.TempDir(), "server.sock") + ln, err := net.Listen("unix", socket) + if err != nil { + t.Fatalf("listen unix: %v", err) + } + _, handler := compassv1connect.NewCompassServiceHandler(srv) + p := new(http.Protocols) + p.SetUnencryptedHTTP2(true) + httpSrv := &http.Server{Handler: handler, Protocols: p} + go func() { _ = httpSrv.Serve(ln) }() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), embeddedTestTimeout) + defer cancel() + _ = httpSrv.Shutdown(ctx) + }) + return socket +} + +// TestWhoAmIOverUDSReturnsAccountID: the real h2c-UDS WhoAmI dial returns the +// server-derived account id. This proves the transport shape (borrowed from +// health.go) actually speaks to a compass.v1 server over the socket. +func TestWhoAmIOverUDSReturnsAccountID(t *testing.T) { + socket := serveWhoAmI(t, &stubWhoAmIServer{accountID: "acc-served"}) + ctx, cancel := context.WithTimeout(context.Background(), embeddedTestTimeout) + defer cancel() + + id, err := whoAmIOverUDS(ctx, socket) + if err != nil { + t.Fatalf("whoAmIOverUDS err = %v, want nil", err) + } + if id != "acc-served" { + t.Errorf("account id = %q, want acc-served", id) + } +} + +// TestWhoAmIOverUDSSurfacesError: an RPC error from the server surfaces as a +// non-nil error and an empty id (the dial does not fabricate an identity). +func TestWhoAmIOverUDSSurfacesError(t *testing.T) { + srv := &stubWhoAmIServer{whoErr: connect.NewError(connect.CodeUnavailable, errors.New("starting"))} + socket := serveWhoAmI(t, srv) + ctx, cancel := context.WithTimeout(context.Background(), embeddedTestTimeout) + defer cancel() + + id, err := whoAmIOverUDS(ctx, socket) + if err == nil { + t.Fatal("whoAmIOverUDS err = nil, want the server's error surfaced") + } + if id != "" { + t.Errorf("account id = %q, want empty on a WhoAmI error", id) + } +} + +// TestRunStackUpNonZeroExitSurfacesStderr: the real stackUp seam surfaces a +// non-zero exit as an error carrying the child's stderr, so the failure copy is +// legible. Driven with /bin/sh printing to stderr and exiting 1 — no real +// compass-stack (the argv is not compass-stack's; only the exec+stderr contract +// is under test here). +func TestRunStackUpNonZeroExitSurfacesStderr(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), embeddedTestTimeout) + defer cancel() + + stackUp := runStackUp("/bin/sh") + err := stackUp(ctx, []string{"-c", "echo 'boom on stderr' >&2; exit 1"}) + if err == nil { + t.Fatal("stackUp err = nil, want a non-zero-exit error") + } + if !strings.Contains(err.Error(), "boom on stderr") { + t.Errorf("stackUp error %q does not carry the child stderr", err.Error()) + } +} + +// TestRunStackUpZeroExitSucceeds: a zero-exit child (fire-and-return) returns +// nil — the Ready postcondition compass-stack up encodes. +func TestRunStackUpZeroExitSucceeds(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), embeddedTestTimeout) + defer cancel() + + stackUp := runStackUp("/bin/sh") + if err := stackUp(ctx, []string{"-c", "exit 0"}); err != nil { + t.Fatalf("stackUp on a zero exit err = %v, want nil", err) + } +} + +// classifyDeps returns a preflight.Deps whose every injected effect passes (host +// GOOS linux, uid the runner's expected uid) — mirroring preflight_test.go's +// okDeps. Tests override individual fields to drive one failing check at a time +// through classifyPreflight. +func classifyDeps() preflight.Deps { + return preflight.Deps{ + GOOS: "linux", + CurrentUID: preflight.DefaultAgentUID, + ExpectedAgentUID: preflight.DefaultAgentUID, + PodmanRootless: func(context.Context) error { return nil }, + ImagePresent: func(context.Context, string) error { return nil }, + DBReachable: func(context.Context, string) error { return nil }, + } +} + +var classifyParams = preflight.Params{ + AgentImage: "ghcr.io/sealedsecurity/compass-agent:latest", + DatabaseDSN: "host=/state/compass/postgres/sock port=5432 dbname=compass sslmode=disable", +} + +// classify runs deps and folds through the boundary classifier, the exact path +// realPreflight uses. +func classify(t *testing.T, deps preflight.Deps) error { + t.Helper() + return classifyPreflight(deps.Run(context.Background(), classifyParams)) +} + +// TestClassifyPreflightAllPass: no failing check → nil. +func TestClassifyPreflightAllPass(t *testing.T) { + if err := classify(t, classifyDeps()); err != nil { + t.Fatalf("all-pass classify err = %v, want nil", err) + } +} + +// TestClassifyPreflightHostCapUnmetIsFatal: each host-capability check (OS, UID, +// podman) that `up` CANNOT create is fatal — classifyPreflight returns non-nil, +// naming the failed check's copy. +func TestClassifyPreflightHostCapUnmetIsFatal(t *testing.T) { + sentinel := errors.New("no rootless podman here") + cases := map[string]struct { + mutate func(*preflight.Deps) + want string + }{ + "os": { + mutate: func(d *preflight.Deps) { d.GOOS = "windows" }, + want: "windows", + }, + "uid": { + mutate: func(d *preflight.Deps) { d.CurrentUID = 501 }, + want: "501", + }, + "podman": { + mutate: func(d *preflight.Deps) { + d.PodmanRootless = func(context.Context) error { return sentinel } + }, + want: sentinel.Error(), + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + deps := classifyDeps() + tc.mutate(&deps) + err := classify(t, deps) + if err == nil { + t.Fatalf("%s unmet: classify err = nil, want a fatal error", name) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s fatal error %q does not carry %q", name, err.Error(), tc.want) + } + }) + } +} + +// TestClassifyPreflightImageOrDBUnmetIsAdvisory: the load-bearing cold-start +// case. When only image and/or database are unmet (every host capability OK), +// classifyPreflight returns NIL — `up` will start postgres and pull the image, +// and `up`-Ready verifies both. Gating on them here would make the app unable to +// cold-start on a fresh state dir. +func TestClassifyPreflightImageOrDBUnmetIsAdvisory(t *testing.T) { + imgErr := errors.New("agent image absent locally") + dbErr := errors.New("postgres not accepting yet") + cases := map[string]func(*preflight.Deps){ + "image only": func(d *preflight.Deps) { + d.ImagePresent = func(context.Context, string) error { return imgErr } + }, + "db only": func(d *preflight.Deps) { + d.DBReachable = func(context.Context, string) error { return dbErr } + }, + "image and db (cold fresh-state-dir launch)": func(d *preflight.Deps) { + d.ImagePresent = func(context.Context, string) error { return imgErr } + d.DBReachable = func(context.Context, string) error { return dbErr } + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + deps := classifyDeps() + mutate(&deps) + if err := classify(t, deps); err != nil { + t.Fatalf("%s: classify err = %v, want nil (advisory, not fatal)", name, err) + } + }) + } +} + +// TestClassifyPreflightHostCapFatalEvenWithAdvisoryUnmet: a fatal host-cap +// failure is still fatal when image/DB are ALSO unmet — the advisory checks +// never mask a genuine host-capability gap. +func TestClassifyPreflightHostCapFatalEvenWithAdvisoryUnmet(t *testing.T) { + deps := classifyDeps() + deps.PodmanRootless = func(context.Context) error { return errors.New("no podman") } + deps.ImagePresent = func(context.Context, string) error { return errors.New("no image") } + deps.DBReachable = func(context.Context, string) error { return errors.New("no db") } + err := classify(t, deps) + if err == nil { + t.Fatal("classify err = nil, want fatal (podman unmet) despite advisory image/db failures") + } + if !strings.Contains(err.Error(), "no podman") { + t.Errorf("fatal error %q does not carry the podman failure", err.Error()) + } + if strings.Contains(err.Error(), "no image") || strings.Contains(err.Error(), "no db") { + t.Errorf("fatal error %q leaked an advisory failure into the fatal fold", err.Error()) + } +} + +// TestEmbeddedDatabaseDSNMatchesCompassStackDefault: the app-side DSN formula +// must stay byte-identical to cmd/compass-stack's defaultDSN +// (go/cmd/compass-stack/main.go defaultDSN — the source of truth). compass-stack +// is package main and unimportable, so this asserts against the literal expected +// value; a human changing one formula must update both, and this reddens if the +// app-side formula drifts. +func TestEmbeddedDatabaseDSNMatchesCompassStackDefault(t *testing.T) { + const want = "host=/tmp/st/postgres/sock port=5432 dbname=compass sslmode=disable" + if got := embeddedDatabaseDSN("/tmp/st"); got != want { + t.Errorf("embeddedDatabaseDSN = %q, want %q (must stay in lockstep with cmd/compass-stack defaultDSN)", got, want) + } +} + +// TestRunStackUpDeadlineExceededNamesBringUpWindow: when the child fails because +// the context deadline was exceeded, the error names the bring-up window (the +// likely cause) rather than surfacing a bare deadline error. Driven with an +// already-past deadline against a real binary so the classification is +// deterministic (no wall-clock wait). +func TestRunStackUpDeadlineExceededNamesBringUpWindow(t *testing.T) { + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + + stackUp := runStackUp("/bin/sh") + err := stackUp(ctx, []string{"-c", "exit 0"}) + if err == nil { + t.Fatal("stackUp err = nil, want a deadline-exceeded error") + } + if !strings.Contains(err.Error(), "bring-up window") { + t.Errorf("stackUp error %q does not name the bring-up window", err.Error()) + } +} + +// TestWhoAmIOverUDSRejectsEmptyAccountID: a SUCCESSFUL WhoAmI that reports an +// empty account id is rejected (non-nil error, empty id) rather than resolving +// an empty identity downstream. +func TestWhoAmIOverUDSRejectsEmptyAccountID(t *testing.T) { + socket := serveWhoAmI(t, &stubWhoAmIServer{accountID: ""}) + ctx, cancel := context.WithTimeout(context.Background(), embeddedTestTimeout) + defer cancel() + + id, err := whoAmIOverUDS(ctx, socket) + if err == nil { + t.Fatal("whoAmIOverUDS err = nil, want an error on an empty account id") + } + if id != "" { + t.Errorf("account id = %q, want empty when WhoAmI returns an empty id", id) + } +} + +// TestResolveStackBin: flag wins, then $COMPASS_STACK_BIN, then a not-found error +// that names every place it looked. The executable-sibling branch is not covered +// because os.Executable can't be overridden without mocking; the other three +// legs are deterministic. +func TestResolveStackBin(t *testing.T) { + t.Run("flag wins", func(t *testing.T) { + t.Setenv("COMPASS_STACK_BIN", "/env/compass-stack") + got, err := resolveStackBin("/flag/compass-stack") + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if got != "/flag/compass-stack" { + t.Errorf("got %q, want the flag value", got) + } + }) + t.Run("env wins over PATH", func(t *testing.T) { + t.Setenv("COMPASS_STACK_BIN", "/env/compass-stack") + got, err := resolveStackBin("") + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if got != "/env/compass-stack" { + t.Errorf("got %q, want the env value", got) + } + }) + t.Run("not found names all four locations", func(t *testing.T) { + t.Setenv("COMPASS_STACK_BIN", "") + t.Setenv("PATH", "") + _, err := resolveStackBin("") + if err == nil { + t.Fatal("err = nil, want a not-found error") + } + for _, want := range []string{"--compass-stack", "$COMPASS_STACK_BIN", "$PATH", "beside"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("not-found error %q does not name %q", err.Error(), want) + } + } + }) +} + +// TestResolveStateDir: flag wins, then $COMPASS_STATE_DIR, then an ABSOLUTE +// $XDG_STATE_HOME/compass. A RELATIVE $XDG_STATE_HOME is treated as unset and +// falls through to $HOME/.compass — the load-bearing determinism guard. +func TestResolveStateDir(t *testing.T) { + t.Run("flag wins", func(t *testing.T) { + t.Setenv("COMPASS_STATE_DIR", "/env/state") + if got := resolveStateDir("/flag/state"); got != "/flag/state" { + t.Errorf("got %q, want the flag value", got) + } + }) + t.Run("env wins", func(t *testing.T) { + t.Setenv("COMPASS_STATE_DIR", "/env/state") + t.Setenv("XDG_STATE_HOME", "/xdg/state") + if got := resolveStateDir(""); got != "/env/state" { + t.Errorf("got %q, want the env value", got) + } + }) + t.Run("absolute XDG_STATE_HOME", func(t *testing.T) { + t.Setenv("COMPASS_STATE_DIR", "") + xdg := t.TempDir() + t.Setenv("XDG_STATE_HOME", xdg) + if got := resolveStateDir(""); got != filepath.Join(xdg, "compass") { + t.Errorf("got %q, want %q", got, filepath.Join(xdg, "compass")) + } + }) + t.Run("relative XDG_STATE_HOME falls through to HOME/.compass", func(t *testing.T) { + t.Setenv("COMPASS_STATE_DIR", "") + t.Setenv("XDG_STATE_HOME", "rel/state") + home := t.TempDir() + t.Setenv("HOME", home) + if got := resolveStateDir(""); got != filepath.Join(home, ".compass") { + t.Errorf("got %q, want %q (relative XDG_STATE_HOME must fall through)", got, filepath.Join(home, ".compass")) + } + }) +} + +// TestResolveImage: flag wins, then $COMPASS_AGENT_IMAGE, then the locked GHCR +// default. +func TestResolveImage(t *testing.T) { + t.Run("flag wins", func(t *testing.T) { + t.Setenv("COMPASS_AGENT_IMAGE", "env/image:tag") + if got := resolveImage("flag/image:tag"); got != "flag/image:tag" { + t.Errorf("got %q, want the flag value", got) + } + }) + t.Run("env wins", func(t *testing.T) { + t.Setenv("COMPASS_AGENT_IMAGE", "env/image:tag") + if got := resolveImage(""); got != "env/image:tag" { + t.Errorf("got %q, want the env value", got) + } + }) + t.Run("default", func(t *testing.T) { + t.Setenv("COMPASS_AGENT_IMAGE", "") + if got := resolveImage(""); got != defaultAgentImage { + t.Errorf("got %q, want defaultAgentImage %q", got, defaultAgentImage) + } + }) +} + +// TestResolveMode: flag wins, then $COMPASS_APP_MODE, then "" (no override). +func TestResolveMode(t *testing.T) { + t.Run("flag wins", func(t *testing.T) { + t.Setenv("COMPASS_APP_MODE", "client") + if got := resolveMode("embedded"); got != "embedded" { + t.Errorf("got %q, want the flag value", got) + } + }) + t.Run("env wins", func(t *testing.T) { + t.Setenv("COMPASS_APP_MODE", "client") + if got := resolveMode(""); got != "client" { + t.Errorf("got %q, want the env value", got) + } + }) + t.Run("both empty", func(t *testing.T) { + t.Setenv("COMPASS_APP_MODE", "") + if got := resolveMode(""); got != "" { + t.Errorf("got %q, want empty (no override)", got) + } + }) +} + +// assertArg fails unless want appears as a token in args. +func assertArg(t *testing.T, args []string, want string) { + t.Helper() + if !slices.Contains(args, want) { + t.Errorf("argv %v missing token %q", args, want) + } +} + +// assertArgPair fails unless flag is immediately followed by value in args. +func assertArgPair(t *testing.T, args []string, flag, value string) { + t.Helper() + for i, a := range args { + if a == flag { + if i+1 < len(args) && args[i+1] == value { + return + } + t.Errorf("argv %v: flag %q not followed by %q", args, flag, value) + return + } + } + t.Errorf("argv %v missing flag %q", args, flag) +} diff --git a/go/cmd/compass-app/main.go b/go/cmd/compass-app/main.go index 196b0b87..c085e238 100644 --- a/go/cmd/compass-app/main.go +++ b/go/cmd/compass-app/main.go @@ -20,11 +20,14 @@ package main import ( + "context" "flag" "log/slog" "os" "path/filepath" + "time" + "github.com/sealedsecurity/compass/go/internal/appconfig" "github.com/sealedsecurity/compass/go/internal/bridge" "github.com/wailsapp/wails/v3/pkg/application" ) @@ -36,21 +39,71 @@ func main() { } } +// bringUpTimeout bounds the whole embedded bring-up (preflight + compass-stack +// up + WhoAmI) as a backstop against a wedged launch. app.Run() itself is not +// context-bound. Per the T4.1 brief the bring-up window is ~60s; lifecycle +// polish (a longer window covering a cold agent-image pull) is T4.2. +const bringUpTimeout = 60 * time.Second + func run() error { socketFlag := flag.String("socket", "", "Unix socket the Compass daemon serves compass.v1 on. Defaults to "+ - "$COMPASS_SOCKET, then $XDG_RUNTIME_DIR/compass/server.sock. The "+ - "developer starts the daemon (compass-stack up); the shell only "+ - "dials it. Stack supervision is T4.") + "$COMPASS_SOCKET, then $XDG_RUNTIME_DIR/compass/server.sock. In "+ + "embedded mode the supervised stack serves it; in client mode it is "+ + "dialed as-is.") assetsFlag := flag.String("assets", "", "Directory of the prebuilt apps/ui dist to serve. Defaults to "+ "$COMPASS_ASSETS_DIR, then a 'dist' directory beside the executable.") + modeFlag := flag.String("mode", "", + "Operating mode override (embedded|client). Defaults to $COMPASS_APP_MODE, "+ + "then app.toml, then embedded.") + stackBinFlag := flag.String("compass-stack", "", + "Path to the compass-stack binary the embedded stack is supervised with. "+ + "Defaults to $COMPASS_STACK_BIN, then compass-stack on $PATH, then a "+ + "compass-stack sibling of this executable.") + stateDirFlag := flag.String("state-dir", "", + "App state directory for the embedded stack. Defaults to "+ + "$COMPASS_STATE_DIR, then $XDG_STATE_HOME/compass, then $HOME/.compass.") + imageFlag := flag.String("image", "", + "Agent container image ref for the embedded stack. Defaults to "+ + "$COMPASS_AGENT_IMAGE, then "+defaultAgentImage+".") flag.Parse() socket := resolveSocket(*socketFlag) assetsDir := resolveAssetsDir(*assetsFlag) + // run() is the process root (called directly by main), so the root context + // originates here; the bring-up window is derived from it, not re-rooted. + cfg, err := appconfig.Load(os.Getenv("XDG_CONFIG_HOME"), os.Getenv("HOME"), resolveMode(*modeFlag)) + if err != nil { + return err + } + + // The embedded launch pipeline (mode-select → preflight → stack up → WhoAmI) + // runs BEFORE the window opens, under a bounded bring-up context. The + // resolved account id is handed to the bridge service for the JS/UI. + stateDir := resolveStateDir(*stateDirFlag) + image := resolveImage(*imageFlag) + stackBin, err := resolveStackBin(*stackBinFlag) + if err != nil { + return err + } + pipeline := embeddedPipeline{ + preflight: realPreflight(image, embeddedDatabaseDSN(stateDir)), + stackUp: runStackUp(stackBin), + whoAmI: whoAmIOverUDS, + } + params := embeddedParams{socket: socket, stateDir: stateDir, image: image} + + bringUpCtx, cancel := context.WithTimeout(context.Background(), bringUpTimeout) + accountID, err := launchByMode(bringUpCtx, cfg.Mode, pipeline, params) + cancel() + if err != nil { + return err + } + svc := newBridgeService(bridge.NewPump(bridge.NewUnixTarget(socket)), nil) + svc.accountID = accountID app := application.New(application.Options{ Name: "compass-app", diff --git a/go/cmd/compass-app/preflight_adapters.go b/go/cmd/compass-app/preflight_adapters.go new file mode 100644 index 00000000..7f29a6d2 --- /dev/null +++ b/go/cmd/compass-app/preflight_adapters.go @@ -0,0 +1,78 @@ +//go:build unix && gtk3 + +// The real host-preflight adapters for embedded mode: each is one genuine +// external effect the preflight core (go/internal/preflight) is inverted over — +// a rootless-podman probe, an agent-image presence check, and a Postgres +// reachability probe. They are thin shells around os/exec and pgx, mirroring how +// go/internal/stack/adapters wires real effects behind the stack core seams; the +// pipeline's composition root (realPreflight in embedded.go) supplies them. +package main + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// dbProbeTimeout bounds the embedded database reachability probe. A cheap +// connect+ping should answer well within this; the short window keeps a +// still-starting or wedged postgres from stalling the launch. +const dbProbeTimeout = 2 * time.Second + +// podmanRootless probes that rootless podman is present and usable by running +// `podman info`. A nil error means it answered; a non-nil error wraps the +// captured stderr so the preflight failure copy names why podman is unusable. +func podmanRootless(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "podman", "info") + if out, err := cmd.CombinedOutput(); err != nil { + if msg := strings.TrimSpace(string(out)); msg != "" { + return fmt.Errorf("podman info: %w: %s", err, msg) + } + return fmt.Errorf("podman info: %w", err) + } + return nil +} + +// imagePresent probes that the agent image ref is present in the local store via +// `podman image exists ` (exit 0 = present, non-zero = absent). A +// non-nil error means the image is not available locally (the preflight core +// reports it is pulled from GHCR at first run); it wraps the captured stderr for +// context. +func imagePresent(ctx context.Context, image string) error { + //nolint:gosec // G204: image is an operator/env-resolved ref, argv is fixed. + cmd := exec.CommandContext(ctx, "podman", "image", "exists", image) + if out, err := cmd.CombinedOutput(); err != nil { + if msg := strings.TrimSpace(string(out)); msg != "" { + return fmt.Errorf("podman image exists %s: %w: %s", image, err, msg) + } + return fmt.Errorf("podman image exists %s: %w", image, err) + } + return nil +} + +// dbReachable probes that Postgres is accepting connections on dsn by opening a +// short-lived pgx connection and pinging it, then closing immediately — the +// lightest genuine reachability check, mirroring +// go/internal/stack/adapters/dbprobe.go. A ~2s timeout keeps the probe cheap; a +// connect or ping failure means not-yet-reachable. +func dbReachable(ctx context.Context, dsn string) error { + ctx, cancel := context.WithTimeout(ctx, dbProbeTimeout) + defer cancel() + + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + // The probe's verdict is the ping result; a Close error on an already-broken + // conn is not actionable here, so it is discarded on the deferred cleanup. + defer func() { _ = conn.Close(ctx) }() + + if err := conn.Ping(ctx); err != nil { + return fmt.Errorf("ping: %w", err) + } + return nil +} diff --git a/go/internal/preflight/preflight.go b/go/internal/preflight/preflight.go index 93fe9c89..fd80bb05 100644 --- a/go/internal/preflight/preflight.go +++ b/go/internal/preflight/preflight.go @@ -65,6 +65,19 @@ const ( checkDB = "database" ) +// Exported aliases of the check names, so callers can classify results by check +// (e.g. the T4 wiring boundary hard-gates host-capability checks and treats the +// image/DB checks as advisory). These are additive: the unexported names above +// stay the values written into Result.Name, and these consts alias them so a +// caller's classification cannot drift from the Run implementation. +const ( + CheckOS = checkOS + CheckUID = checkUID + CheckPodman = checkPodman + CheckImage = checkImage + CheckDatabase = checkDB +) + // Run executes every host precondition in order and returns one Result per // check. It does NOT short-circuit: an operator should see every failing // precondition at once, so all checks run even after an earlier failure. Call diff --git a/go/internal/preflight/uid.go b/go/internal/preflight/uid.go new file mode 100644 index 00000000..9c6f5d24 --- /dev/null +++ b/go/internal/preflight/uid.go @@ -0,0 +1,8 @@ +package preflight + +// DefaultAgentUID is the uid the embedded agent runs as inside its container, +// matching compass-runner's defaultAgentUID (cmd/compass-runner/main.go). It is +// duplicated here deliberately because that const lives in package main and is +// not importable; consolidating both onto one shared const is a tracked +// follow-up (SEA-1685 Open Question). +const DefaultAgentUID = 1000