# PatchTrail Agent Bridge

Local Node/TypeScript bridge for PatchTrail-controlled agent runtime integration.

This package intentionally starts with hardened boundaries before launching OpenCode:

- TUF-style runtime metadata verification contracts.
- File-system OpenCode binary manager that reads signed metadata, resolves the platform target, verifies artifact SHA-256/size, checks executability, and fail-closes on mismatch.
- OpenCode process lifecycle manager that starts only a verified binary with `serve --hostname 127.0.0.1 --port ...`, authenticates password-protected `/global/health`, tracks process status, isolates runtime HOME/XDG paths per credential scope, and stops the process tree before credential changes.
- OpenCode HTTP client for authenticated health, session creation, `prompt_async`, permission replies, and SSE event parsing.
- PatchTrail-owned credential authority with OS keyring primary, encrypted local fallback when explicitly keyed, and production-blocked plaintext/in-memory dev modes.
- Runtime credential injection that starts OpenCode with a strict child-environment allowlist, strips ambient provider/cloud/secrets env from the parent process, and injects only PatchTrail-managed workspace credentials at verified OpenCode process start.
- Local SQLite state store for workspace records, run status, and cloud-ready outbox events.
- Permission scope/capability policy model including break-glass UX contracts.
- Loopback-only health/status HTTP surface.
- Audit-safe structured events.

The bridge does **not** execute OpenCode until the binary manager can prove a verified runtime artifact.

## Scripts

```bash
pnpm install
pnpm check
pnpm test
pnpm build
pnpm opencode:bootstrap
pnpm smoke:trust-boundary -- --json
pnpm smoke:authority
pnpm smoke:debug-reveal -- --json
pnpm smoke:opencode -- --json
pnpm smoke:permissions
pnpm smoke:timeline -- --json
pnpm local-ui:handoff -- --workspace-id <id> --user-session-id <id>
pnpm start
```

The `local-ui:handoff` command runs the compiled launcher; run `pnpm build` first in development.

Default server:

```txt
http://127.0.0.1:47839
```

Optional binary-manager environment for local spikes:

```bash
pnpm opencode:bootstrap -- --version 1.15.1
pnpm smoke:opencode

PATCHTRAIL_OPENCODE_METADATA_DIR=/path/to/tuf-metadata \
PATCHTRAIL_OPENCODE_ARTIFACT_ROOT=/path/to/artifacts \
PATCHTRAIL_OPENCODE_TRUSTED_ROOT_KEY_IDS=root-key-1 \
pnpm start
```

The metadata directory must contain `root.json`, `targets.json`, `snapshot.json`, and `timestamp.json`. Remote artifact download is intentionally not implemented yet; targets must provide a `bundledPath` under `PATCHTRAIL_OPENCODE_ARTIFACT_ROOT`. The dev bootstrap also writes `.runtime/opencode-dev/manifest.json`, which the smoke harness reads so it does not scrape shell output or require copy-pasted metadata env vars.

### Runtime lifecycle contract

This section is for bridge, UI, credential, permission, timeline, and redaction engineers who need to consume runtime lifecycle state without requesting raw local authority. After reading it, they should be able to decide whether OpenCode is trusted, running, blocked, or needs remediation using only safe bridge surfaces.

- **Verified-only start:** the bridge starts OpenCode only after signed metadata authorizes the selected platform target and the artifact passes SHA-256, size, trusted path, and executable checks. If metadata is missing, malformed, unknown, expired, revoked, mismatched, or the artifact is tampered/non-executable, runtime state is `blocked_unverified_binary` and `POST /runtime/start` fails closed with a sanitized lifecycle diagnostic. Remote binary download is outside the MVP; bootstrap, bundled, and dev artifacts still go through the same metadata/artifact/executable validation before launch.
- **Loopback-only process boundary:** the process manager enforces loopback host configuration before resolving a binary or spawning a child. Non-loopback configuration fails with `reasonCode: "non_loopback_host"` and `phase: "configuration"`; no executable authority is resolved and no process is started.
- **Safe global lifecycle surfaces:** `GET /runtime/status`, `POST /runtime/start`, and `POST /runtime/stop` return only `provider`, `state`, `activeSessions`, sanitized `binary` state/version/platform, optional sanitized `process` state/host/port/timestamps/version, and whitelisted diagnostic fields. `GET /health` exposes a smaller unauthenticated readiness view without process details or lifecycle diagnostics.
- **Safe workspace diagnostics:** `GET /workspaces/:workspaceId/runtime/status` adds `overallState`, sanitized runtime status, sanitized credential status, `credentialFreshness`, `restartRequired`, whitelisted `reasons`, whitelisted `recommendedActions`, and an optional sanitized `lastSmoke`. Credential versions/fingerprints are not exposed.
- **Reason-code semantics:** `reasonCode` is the machine-actionable field. `phase` is the coarse lifecycle stage (`configuration`, `metadata`, `target_resolution`, `artifact`, `filesystem`, `process`, `readiness`, `smoke`, or `unknown`). `reason`/`lastError` are safe human messages generated from the whitelist. Do not parse raw error text or branch on prose.
- **Values intentionally unavailable:** lifecycle responses do not expose executable paths, local metadata/artifact paths, raw artifact hashes, process IDs, provider credentials, bridge/local-UI capability tokens, command-capable authority, credential fingerprints, prompt text, raw OpenCode stdout, or raw OpenCode stderr.
- **Readiness failure class:** readiness problems are reported as `readiness_timeout` or `readiness_failed` with `phase: "readiness"`; raw child output and auth material stay unavailable.
- **Last smoke result:** workspace diagnostics may include a bounded `lastSmoke` with `status`, `ranAt`, `summary`, `allowExternal`, optional `durationMs`, version/platform labels, and a sanitized failure message. It is a diagnostic breadcrumb, not a log sink.

Closeout regression matrix from the repository root:

```bash
pnpm --dir agent-bridge test \
  && pnpm --dir agent-bridge opencode:bootstrap \
  && pnpm --dir agent-bridge smoke:trust-boundary -- --json \
  && pnpm --dir agent-bridge smoke:authority \
  && pnpm --dir agent-bridge smoke:permissions -- --json \
  && pnpm --dir agent-bridge smoke:timeline -- --json \
  && pnpm --dir agent-bridge smoke:debug-reveal -- --json \
  && pnpm --dir agent-bridge smoke:opencode -- --json
```

Run the full matrix for release verification. Individual smokes are still useful while debugging a single boundary, but the integrated trust-boundary smoke is the final M002 composition proof because it drives one real bridge against one isolated SQLite/app-data state space instead of wrapping the existing smoke commands.

The compiled OpenCode smoke starts the real bridge entrypoint, proves explicit lifecycle start/stop, performs Basic-auth readiness, verifies tampered-artifact and unknown-target rejection, scans lifecycle/status/smoke surfaces for forbidden authority leaks, and confirms the OpenCode health endpoint is unavailable after stop.

### Real OpenCode runtime smoke

The default smoke starts the real bridge entrypoint on an ephemeral loopback port, uses isolated temp app data, verifies the signed OpenCode dev artifact, stores a workspace OpenAI key through the bridge provider-auth API, creates a bridge-owned run/session against real OpenCode, checks sanitized runtime/status responses, revokes the key, and stops the runtime process tree. It also seeds deterministic permission activity and drives the real frontend through scoped local UI handoffs for the Settings permission timeline, run-detail permission activity, and `/app/audit` ledger proof. The Audit pass verifies static policy/proof/human-decision rows alongside bridge-backed sanitized permission evidence, rejects enabled mutation controls, checks that the handoff hash is cleared, and reports only forbidden-value labels if raw commands, provider ids, audit ids, bridge tokens, handoff fragments, session tokens, or seeded secrets leak. It does **not** send a prompt or call an external model provider by default.

```bash
pnpm opencode:bootstrap -- --version 1.15.1
pnpm smoke:opencode
```

Optional prompt smoke is intentionally explicit because it can call an external provider and incur cost. Provide the key only through the smoke env var; the harness stores it through the local bridge credential API, keeps temp state isolated, and fails if the key, bridge token, OpenCode server password, or prompt text appears in HTTP responses or bridge logs.

```bash
PATCHTRAIL_SMOKE_OPENAI_API_KEY=<key> \
  pnpm smoke:opencode -- --allow-external --prompt "Reply with pong."
```

Useful smoke flags:

- `--manifest <path>` — use a non-default dev artifact manifest.
- `--workspace-root <path>` — use a specific local workspace root for the run.
- `--timeout-ms <ms>` — adjust bridge readiness timeout.
- `--keep-temp` — keep isolated smoke temp files for local debugging.
- `--model-provider <id> --model <id>` — include an explicit model only with `--prompt`.

### Integrated bridge trust-boundary smoke

This section is for operators and future agents who need one safe artifact proving that the bridge trust boundaries still compose. After reading it, they should be able to run the smoke, paste the proof packet into a bug report, and choose the right recovery path without exposing local authority.

```bash
pnpm opencode:bootstrap -- --version 1.15.1
pnpm smoke:trust-boundary -- --json
```

What it proves in one compiled bridge process:

- Bridge authority and loopback-only health/authority surfaces.
- Verified OpenCode lifecycle start/readiness/stop against the bootstrapped manifest.
- Provider credential status/store/read routes through the in-memory smoke credential broker.
- Local UI handoff/session exchange with all required S07 scopes.
- Run creation through the bridge route, without sending an external model prompt by default.
- Permission request/reply/activity/decision/evaluate flow, including deny-only external mutation behavior.
- Runtime timeline initial, refresh, and restart reads from one local SQLite state space.
- Debug reveal local-only activation, restart persistence, revoke, and no outbox sync.
- Runtime diagnostics, credential diagnostics, proof output, route bodies, child output, and local SQLite marker scans for forbidden leaks.

Useful integrated smoke flags:

- `--manifest <path>` — use a non-default dev artifact manifest. The default is the ignored bootstrap manifest written by `opencode:bootstrap`.
- `--timeout-ms <ms>` — adjust compiled bridge readiness and route-flow timeout.
- `--keep-temp` — keep the isolated temp SQLite/app-data directory for local debugging; the path is intentionally not printed. Without this flag, temp state is removed when the smoke exits.
- `--json` — print the machine-readable proof packet.
- `--prompt <text> --allow-external` — opt into an external model call. This requires `PATCHTRAIL_SMOKE_OPENAI_API_KEY`; without both flags the integrated smoke does not call a provider model.
- `--model-provider <id> --model <id>` — include an explicit model only with `--prompt`.

The JSON proof packet is intentionally safe to paste into bug reports. It contains labels, scopes, finite state names, event counts, marker counts, booleans, and no-leak surface names under these top-level fields: `bridgeAuthority`, `runtimeLifecycle`, `keyBroker`, `localUi`, `runCreation`, `runtimeDiagnostics`, `permissionFlow`, `runtimeTimeline`, `debugReveal`, `outbox`, `diagnostics`, and `noLeak`. It must not contain bridge tokens, local UI tokens or handoff fragments, provider keys, local paths, raw prompts, raw commands, provider/runtime/session ids, matcher ids, audit ids, OpenCode stdout/stderr, or raw debug reveal content. Failure output uses the same sanitized diagnostic path; `--json` failures return only `{ "status": "failed", "error": "..." }` with forbidden values redacted.

Recovery paths:

| Failure label | What it usually means | Recovery |
| --- | --- | --- |
| Missing dev manifest | `opencode:bootstrap` has not written the local OpenCode artifact metadata, or `--manifest` points at a stale/missing file. | Run `pnpm opencode:bootstrap -- --version 1.15.1`, then rerun `pnpm smoke:trust-boundary -- --json`. If using `--manifest`, regenerate that manifest rather than editing smoke output. |
| Bridge startup timeout | The compiled bridge did not become ready on its ephemeral loopback port before `--timeout-ms`. | Rebuild with `pnpm build`, rerun with a larger `--timeout-ms`, and use `--keep-temp` only for local inspection. Do not add logging that prints tokens or temp paths. |
| OpenCode readiness auth failure | The verified OpenCode process started but `/global/health` did not accept the expected Basic auth password. | Check that the process manager still passes username `opencode` and the generated `OPENCODE_SERVER_PASSWORD`; then rerun the integrated smoke. Do not paste the password into diagnostics. |
| Provider credential route rejection | Credential status/store/read routes rejected the all-scope local UI session or returned an unexpected sanitized status. | Verify `provider-auth:read` and `provider-auth:write` are still minted for the local UI session and that credential mutation still uses the PatchTrail vault/broker boundary. |
| Local UI session failure | Handoff mint/exchange did not return the bound origin/workspace/scopes, or the one-time fragment leaked. | Rebuild, rerun with `--json`, and inspect only scope/route labels. Do not print handoff fragments, session tokens, or capability tokens. |
| Permission deny-only mismatch | The external-service mutation fixture was not denied or the enabled reply options changed. | Debug the permission policy/evaluator and route payload sanitizer. Keep external mutation approvals disabled until a separate break-glass path exists. |
| Timeline raw-field leak | A runtime route, proof packet, or bridge child stream exposed raw session/provider ids, commands, prompt text, local paths, or raw payloads. | Fix the runtime timeline projection/redaction boundary before changing UI parsers. The smoke should continue to fail closed until the leak label disappears. |
| Debug reveal no-sync failure | A `debug_reveal` marker reached outbox/cloud-sync state, or raw reveal state was exposed. | Inspect debug reveal marker persistence and outbox filtering; debug reveal remains local-only and marker-only. |
| Leak scanner failure | Any route body, SQLite marker projection, child output, or proof output contained a forbidden token/value/pattern. | Treat this as a boundary regression. Remove the leak at the source and rerun the integrated smoke; do not weaken the forbidden corpus or paste the leaked value into an issue. |

For permission and debug reveal policy semantics, see `docs/opencode-security-and-permissions.md`; the integrated smoke is the composition proof that those semantics still hold when authority, runtime, local UI, permissions, timeline, diagnostics, and debug reveal share one bridge state.

### Permission surface smoke fixture

The permission-only smoke starts the compiled bridge entrypoint on an ephemeral loopback port with isolated temp app data and SQLite state. It seeds safe permission activity, a revoked `allow_for_session` approval, and a pending external-service deny-only request, then verifies the surfaces through a scoped local UI session minted by the compiled handoff launcher. It does not require OpenCode bootstrap metadata, does not start OpenCode, does not call an external provider, and does not print durable bridge tokens.

```bash
pnpm smoke:permissions
```

Useful permission smoke flags:

- `--timeout-ms <ms>` — adjust bridge and launcher readiness timeout.
- `--keep-temp` — keep the isolated temp SQLite/app-data directory for local inspection after the bridge shuts down.
- `--json` — print a machine-readable non-secret summary.

Permission endpoint contract for local UI consumers:

- `GET /permissions/requests?workspaceId=...&status=pending&limit=...` requires `bridge:read` for a scoped local UI session and returns outer PatchTrail permission request ids, status, sanitized tool card fields, sanitized evaluation, selected option/reply state, bounded `lastError`, and timestamps. It intentionally omits raw workspace/run/runtime ids, provider ids, provider permission/request ids, source runtime event ids, audit ids, matcher keys, raw commands, and provider/model ids.
- `POST /permissions/requests/:id/reply` requires `permissions:reply`. Current browser/manual approval supports `approve_once`, `allow_for_session`, and `deny`; broad always/escape-hatch options and external-service mutation approvals stay disabled until a future explicit break-glass path exists.
- `POST /permissions/evaluate` requires `permissions:evaluate` and returns sanitized policy results. External-service mutation categories such as `git push`, PR/issue mutation, deploy, package publish, infrastructure apply, cloud relay remote-run, and shell/network commands with mutation indicators remain deny-only in this slice.
- `GET /workspaces/:workspaceId/permissions/activity?runId=...&limit=...` requires `bridge:read` and reads the PatchTrail-owned append-only permission activity ledger. Event kinds are `request_created`, `request_reply_failed`, `request_answered`, `session_decision_recorded`, and `session_decision_revoked`; each event keeps a durable activity id, kind, timestamp, status/option/reply fields where applicable, sanitized tool card fields, sanitized error/revoke reason, and deny-only diagnostics.
- `GET /workspaces/:workspaceId/permissions/decisions?status=revoked&limit=...` requires `bridge:read` and returns session `allow_for_session` decisions with outer decision ids, status, capabilities, sanitized tool fields, timestamps, and revoke metadata. Pattern/matcher internals and audit ids stay omitted.
- `POST /workspaces/:workspaceId/permissions/decisions/:decisionId/revoke` requires `permissions:manage`; it records `session_decision_revoked` in the append-only ledger and returns the same sanitized decision shape.

Retained ids are PatchTrail action ids needed for follow-up routes (`requests[].id`, `decisions[].id`, and `events[].id`). Raw provider/runtime identifiers and audit internals are not browser-facing contract fields; timeline and redaction consumers should key off the retained action ids plus event kind/timestamp/tool card labels.

The fixture verifies `GET /workspaces/:workspaceId/permissions/activity`, run-filtered activity, revoked approval history, pending deny-only diagnostics, scoped evaluation/reply/manage routes, unauthenticated rejection, and the local UI handoff/session exchange. It also seeds one permission request that first records `request_reply_failed` and later records `request_answered` with the same safe tool title/input preview, proving retry success does not erase append-only failure evidence. With `--json`, the summary is a proof packet: it includes the proved local UI route list, local UI scopes, deny-only request/options, retry-history event kinds, revoked decision id, unauthenticated rejection flag, run-filter exclusion flag, and no-leak surface labels without exposing provider/runtime/audit internals. Use this before browser/UI work when you need a deterministic backend permission surface without reconstructing ad hoc seed data.

### Runtime timeline smoke fixture

The timeline smoke starts the compiled bridge entrypoint on an ephemeral loopback port with isolated temp app data and a deterministic local SQLite fixture. It seeds a PatchTrail run plus hostile runtime event rows whose stored title, description, runtime session id, provider/request/permission/message/tool-call ids, raw command, matcher key, local path, prompt text, provider/model ids, raw payload, and fake secret must never leave the bridge. The smoke then mints scoped local UI sessions, reads `GET /runs/:runId/events` through `bridge:read`, repeats the read to simulate a browser refresh, restarts the bridge against the same SQLite file, reads again, verifies missing-scope and unauthenticated rejections, scans HTTP bodies plus bridge stdout/stderr for leaks, and prints a non-secret proof packet.

```bash
pnpm smoke:timeline -- --json
```

Useful timeline smoke flags:

- `--timeout-ms <ms>` — adjust compiled bridge readiness timeout.
- `--keep-temp` — keep the isolated temp SQLite/app-data directory; the path is intentionally not printed because local paths are part of the hostile no-leak set.
- `--json` — print the machine-readable proof packet with event counts, event ids/types, local UI scopes, persistence labels, rejection flags, unavailable raw fields, and no-leak surface labels.

Runtime timeline endpoint contract for local UI consumers:

- `GET /runs/:runId/events?limit=50&cursor=...` requires a scoped local UI session with `bridge:read` for the run's workspace, or the durable bridge token for launcher/internal use.
- The response shape is `{ runId, events, nextCursor }`. Each event is a PatchTrail-owned public projection with only `id`, `source`, `type`, `at`, `title`, `description`, `tone`, and `metadata`.
- Public event fields are finite and generated by the bridge: `source` is always `runtime`; `type` is one of `runtime_event`, `runtime_message`, `runtime_permission`, `runtime_session`, or `runtime_tool`; `metadata` only exposes finite `category`, optional `status`, and optional `risk`; titles/descriptions come from the PatchTrail contract, not raw OpenCode text.
- Raw runtime/provider fields are intentionally unavailable to browser/cloud consumers: runtime session ids, provider request/permission ids, message ids, tool-call ids, provider/model ids, raw commands, matcher keys, local filesystem paths, prompt text, raw payloads, and secrets. Consumers must key UI behavior off the retained public event id, type, timestamp, and finite metadata, not OpenCode internals.

Expected `smoke:timeline -- --json` proof labels include `persistenceLabels: ["initial-read", "refresh-read", "restart-read"]`, `localUiScopes: ["bridge:read"]`, `localUiAuthorityVerified: true`, `refreshReadVerified: true`, `restartPersistenceVerified: true`, `missingScopeRejected: true`, and `unauthenticatedRejected: true`. Failure signals are deliberately sanitized: bridge readiness timeout, malformed timeline shape, missing event after restart, missing `bridge:read`, or any forbidden sentinel in route bodies/logs/proof output fails the smoke without printing the sentinel value. Recovery is to rerun `pnpm --dir agent-bridge build`, then `pnpm --dir agent-bridge smoke:timeline -- --json`; if restart proof fails, inspect local SQLite migration/listing code before changing frontend parsers. S07 can compose this command as the backend proof that a refreshed or restarted bridge still serves the sanitized runtime timeline.

### Debug reveal guardrail smoke fixture

The debug reveal smoke starts the compiled bridge entrypoint on an ephemeral loopback port with isolated temp app data and a local SQLite database. It mints local UI sessions with `debug:reveal`, proves unauthenticated, durable bridge-token, wrong-scope, missing-confirmation, invalid-surface, duplicate-active, and unknown-revoke rejection paths, activates a break-glass window with hostile reason/surface sentinels, restarts the bridge against the same SQLite file, verifies the active marker survives restart, revokes the active session, checks that no `debug_reveal` marker was queued in `outbox_events`, and scans HTTP bodies, SQLite markers, stdout, stderr, and proof output with the public redaction guard.

```bash
pnpm smoke:debug-reveal -- --json
```

Useful debug reveal smoke flags:

- `--timeout-ms <ms>` — adjust compiled bridge readiness timeout.
- `--keep-temp` — keep the isolated temp SQLite/app-data directory; the path is intentionally not printed because local paths are part of the hostile no-leak set.
- `--json` — print the machine-readable proof packet with route labels, scopes, state labels, supported surface names, rejection flags, marker counts, outbox no-sync count, and no-leak surface labels.

Debug reveal endpoint contract for local UI consumers:

- `GET /workspaces/:workspaceId/debug-reveal/status` requires a scoped local UI session with `debug:reveal`. The durable bridge token is explicitly rejected for this surface so internal launch authority cannot be reused as browser break-glass authority.
- `POST /workspaces/:workspaceId/debug-reveal/sessions` requires `{ breakGlassConfirmed: true }`, clamps TTL to the supported window, accepts only finite debug surface labels (`runtime-diagnostics`, `provider-auth-status`, `permission-audit`, `run-timeline`), stores a redacted reason label when a reason is provided, and returns the same safe status contract.
- `DELETE /workspaces/:workspaceId/debug-reveal/sessions/:reveal` revokes only the currently active local reveal session and appends a sanitized SQLite marker.
- Status payloads contain only `state`, `active`, `safeguards.localOnly`, `safeguards.cloudSync`, `safeguards.rawStorage`, `audit.markerCount`, optional `audit.lastMarker` metadata, and optional session timing/surface/reason-label metadata. Raw runtime, provider, permission, path, prompt, token, handoff, stdout/stderr, or local filesystem values are intentionally unavailable.

Expected `smoke:debug-reveal -- --json` proof labels include `smokeName: "debug-reveal-guardrails"`, `scopes: ["debug:reveal"]`, `states: ["inactive", "active", "active-after-restart", "revoked"]`, `safeguards.cloudSync: "disabled"`, `safeguards.rawStorage: "disabled"`, `durability.sqliteAuditMarkers: "persisted-after-restart"`, `restartPersistenceVerified: true`, `revokeVerified: true`, and `outbox.debugRevealEvents: 0`. Failure signals are sanitized and label-only: bridge start failure, route status mismatch, missing confirmation validation, unsupported surface, restart persistence mismatch, revoke mismatch, any outbox sync attempt, or a leak in route bodies/logs/proof output fails without echoing the sentinel value. Recovery is to rerun `pnpm --dir agent-bridge build`, then `pnpm --dir agent-bridge smoke:debug-reveal -- --json`; if marker counts or states diverge, inspect `LocalSqliteStore.listDebugRevealEvents()` and `DebugRevealService.expireIfNeeded()` before changing frontend parsers. There is intentionally no raw reveal endpoint: S06 proves only local-only status/audit markers and scoped break-glass state, and S07 should compose this command as operational evidence rather than adding a browser/cloud path to raw runtime details.

Optional credential-store environment:

```bash
# default: auto = OS keyring primary, encrypted fallback only if key is configured
PATCHTRAIL_CREDENTIAL_STORE=auto

# 32-byte base64 or 64-char hex key; enables encrypted fallback file
PATCHTRAIL_CREDENTIAL_FALLBACK_KEY=<base64-32-byte-key>
PATCHTRAIL_CREDENTIAL_FALLBACK_FILE=/path/to/credentials.enc.json
```

Supported `PATCHTRAIL_CREDENTIAL_STORE` values:

- `auto`: OS keyring primary; encrypted fallback if `PATCHTRAIL_CREDENTIAL_FALLBACK_KEY` exists.
- `os`: OS keyring only.
- `encrypted`: encrypted local fallback only; requires `PATCHTRAIL_CREDENTIAL_FALLBACK_KEY`.
- `plaintext-dev`: plaintext file, hard-blocked in production.
- `in-memory`: non-persistent dev/test store, hard-blocked in production.

Credential state defaults to OS app data unless overridden:

```bash
PATCHTRAIL_LOCAL_DB_PATH=/path/to/patchtrail.sqlite
PATCHTRAIL_APP_DATA_DIR=/path/to/patchtrail-app-data
```

All non-health HTTP routes require the per-process bridge capability token. For local development this can be pinned with:

```bash
PATCHTRAIL_BRIDGE_CAPABILITY_TOKEN=dev-only-token
curl -H "x-patchtrail-bridge-token: dev-only-token" http://127.0.0.1:47839/state/status
```

If `PATCHTRAIL_BRIDGE_CAPABILITY_TOKEN` is omitted, the bridge generates an in-memory token at startup. The future `patchtrail` launcher/hosted-UI handoff should supply this token out-of-band; it must not be logged, synced, embedded in URLs, or exposed to browser JavaScript.

For same-machine hosted UI handoff, the trusted launcher can mint a short-lived one-time local UI capability with the durable bridge token, then the browser exchanges that one-time token for a scoped in-memory local UI session. The bridge stores only token hashes in memory; no local UI capability or session token is written to SQLite, outbox events, or audit metadata. The launcher delivers the one-time token through a URL fragment, never a query string.

Developer launcher smoke:

```bash
PATCHTRAIL_BRIDGE_CAPABILITY_TOKEN=dev-only-token \
  pnpm local-ui:handoff -- \
    --ui-url https://app.patchtrail.local/control \
    --workspace-id workspace-1 \
    --user-session-id dev-session \
    --scope runs:write \
    --scope permissions:reply \
    --print-url
```

The CLI deliberately rejects `--bridge-token`; use `PATCHTRAIL_BRIDGE_CAPABILITY_TOKEN` so the durable token does not enter shell history. Default output hides the fragment URL because it contains the one-time handoff token; `--print-url` is **DEBUG ONLY** for local debugging or tests and marks JSON output with `debugOnly: "handoffUrl"` when the fragment is included. The legacy `--open` path is rejected fail-closed because browser opener commands receive URLs through child process arguments; do not re-enable it unless the launcher uses a non-argv delivery mechanism for the fragment. Use `pnpm --silent local-ui:handoff ... --json` for machine-readable output because pnpm's normal lifecycle banner is not JSON.

Local UI session scopes are intentionally narrower than the durable bridge token:

- `bridge:read` — read bridge/runtime/state/timeline/permission-request surfaces for the bound workspace.
- `runs:write` — create runs and send prompts only for the bound workspace.
- `permissions:reply` — answer permission requests only for the bound workspace.
- `permissions:manage` — revoke scoped session permission decisions only for the bound workspace.
- `permissions:evaluate` — evaluate permission cards only for the bound workspace.
- `provider-auth:read` — read sanitized provider credential status only for the bound workspace.
- `provider-auth:write` — store or revoke provider credentials only for the bound workspace; values are written through the configured `SecureStore` and never echoed.
- `debug:reveal` — inspect, activate, and revoke local-only debug reveal break-glass state for the bound workspace; status exposes only sanitized marker/timing/surface labels and never raw runtime/provider details.

Runtime lifecycle control (`POST /runtime/start`, `POST /runtime/stop`) remains bridge-token-only in this slice. `POST /workspaces/:workspaceId/runs` starts OpenCode with the workspace OpenAI API key only when that key is present in the PatchTrail credential vault. OpenCode receives only allowlisted inherited process env plus bridge-owned runtime env; ambient parent-process provider/cloud/secrets env such as `OPENAI_API_KEY`, `GITHUB_TOKEN`, `DATABASE_URL`, and PatchTrail bridge/credential tokens are not inherited. Runtime `HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, and `XDG_CACHE_HOME` point to a PatchTrail-controlled directory derived from the credential scope, not the user's real home. Runtime diagnostics redact sensitive bridge-owned env values before status/error surfaces are updated. A running OpenCode process cannot silently switch between different workspace credential scopes or credential versions; provider key store/revoke shares a credential lifecycle lock with runtime credential acquisition, stops the active process tree before mutation, and returns `409 runtime_stop_failed` without changing stored credentials if the runtime cannot be stopped.

## Initial endpoints

- `GET /health`
- `GET /runtime/status`
- `POST /runtime/start`
- `POST /runtime/stop`
- `GET /state/status`
- `GET /security/contracts`
- `POST /local-ui/capabilities` — bridge-token-only launcher endpoint that mints a short-lived one-time local UI capability token for an exact origin/workspace/device/user-session binding.
- `POST /local-ui/session` — exchanges the one-time capability token for a scoped local UI session token; the one-time token is consumed and cannot be reused.
- `GET /local-ui/session` — validates the active local UI session token and returns its scoped session metadata.
- `GET /workspaces/:workspaceId/providers/openai/credentials/api-key` — returns sanitized OpenAI API key status for the bound workspace; never returns the secret.
- `PUT /workspaces/:workspaceId/providers/openai/credentials/api-key` — stores/replaces the workspace OpenAI API key through the local credential vault after stopping the active process tree; response contains masked status only, and failed runtime stop returns `409` without mutation.
- `DELETE /workspaces/:workspaceId/providers/openai/credentials/api-key` — revokes the workspace OpenAI API key after stopping the active process tree so the old env-bearing process cannot continue; failed runtime stop returns `409` without mutation.
- `POST /workspaces/:workspaceId/runs` — upserts the workspace, starts the verified OpenCode runtime if needed, creates an OpenCode session, persists a PatchTrail run, and appends cloud-ready outbox events.
- `POST /runs/:runId/prompt` — sends prompt text through the run's OpenCode session using `prompt_async`; outbox metadata records text length only, not prompt contents.
- `GET /runs/:runId/events?limit=50&cursor=...` — returns the bridge-owned normalized run timeline from local SQLite using the finite public event contract (`source`, `type`, `at`, `title`, `description`, `tone`, allowlisted `metadata`); it never proxies OpenCode `/event`, raw provider/runtime ids, commands, paths, prompt text, or raw payloads.
- `GET /permissions/requests?status=pending&runId=...&workspaceId=...` — lists sanitized PatchTrail permission requests created from OpenCode permission events; raw runtime/provider/audit ids, matcher keys, and commands are omitted.
- `POST /permissions/requests/:id/reply` — records the PatchTrail approval/denial and replies to OpenCode with `once` or `reject`; the bridge does not delegate broad `always` authority to OpenCode.
- `POST /permissions/evaluate` — evaluates a sanitized tool card against the PatchTrail permission policy; external-service mutation categories return deny-only manual options in this slice.
- `GET /workspaces/:workspaceId/permissions/activity?runId=...&limit=...` — returns append-only sanitized permission activity events (`request_created`, `request_reply_failed`, `request_answered`, `session_decision_recorded`, `session_decision_revoked`).
- `GET /workspaces/:workspaceId/permissions/decisions?status=revoked&limit=...` — lists sanitized scoped session approvals and revoke history.
- `POST /workspaces/:workspaceId/permissions/decisions/:decisionId/revoke` — revokes an active session approval and records append-only revoke activity.
- `GET /workspaces/:workspaceId/debug-reveal/status` — local UI `debug:reveal` only; returns sanitized local-only break-glass status, safeguards, marker counts, and optional session timing/surface labels.
- `POST /workspaces/:workspaceId/debug-reveal/sessions` — local UI `debug:reveal` only; requires explicit break-glass confirmation, stores only redacted reason/surface marker metadata, and rejects bridge-token or wrong-scope callers.
- `DELETE /workspaces/:workspaceId/debug-reveal/sessions/:reveal` — local UI `debug:reveal` only; revokes the active debug reveal marker without syncing raw reveal state to outbox.

All endpoints are local development surfaces. Production hosted UI command capability must use the local UI handoff/session flow with exact-origin CORS/PNA checks, short TTLs, and workspace-bound scopes.
