Skip to content

fix(app): validate persisted client state - #3289

Merged
boudra merged 5 commits into
mainfrom
diagnose-windows-desktop-release
Aug 13, 2026
Merged

boudra merged 5 commits into
mainfrom
diagnose-windows-desktop-release

Conversation

@boudra

@boudra boudra commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Linked issue

Discord crash report

Type of change

  • Bug fix
  • New feature
  • Enhancement
  • Refactor
  • Docs

Reasoning

A replica cached an older todo item without the newly required activity field. A handwritten type predicate asserted it was a current StreamItem, so the cached object reached rendering and crashed before the daemon could replace it. AsyncStorage is an untrusted data boundary like daemon traffic and must not manufacture application types through assertions.

Goals

  • Validate every AsyncStorage-backed value with an explicit strict Zod schema.
  • Delete malformed, incompatible, or unknown persisted values instead of partially hydrating them.
  • Replace the replica cache object graph with a versioned storage DTO and bump the cache version.
  • Keep opaque tool-call payloads out of the replica cache.
  • Remove storage-boundary type assertions.

Non-goals

  • No cache-clearing or recovery UI.
  • No protocol changes.
  • No attempt to retain invalid fragments from an otherwise invalid stored value.

QA

  • Targeted storage/cache/store suite: 16 files, 208 tests passed.
  • Host runtime: 67 tests passed.
  • Final cache boundary suite: 5 files, 104 tests passed.
  • Agent-profile migration and client-id tests passed.
  • Pre-commit hook passed format, lint, and typecheck for every workspace.
  • npm run format:check passed.
  • npm run lint -- packages/app/src passed with 0 warnings/errors.
  • Windows desktop was not manually exercised; this changes platform-independent persistence decoding and has no UI changes.

Checklist

  • One focused change
  • npm run typecheck passes
  • npm run lint passes
  • npm run format passes
  • QA evidence
  • Tests added or updated where it made sense

@greptile-apps

greptile-apps Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR validates AsyncStorage-backed application state with strict Zod schemas and removes incompatible persisted values before hydration.

  • Adds reusable validated JSON, string, and Zustand persistence adapters.
  • Restores pre-migration draft validation and migration, including preservation of draft text while dropping obsolete review attachments.
  • Introduces a versioned replica-cache DTO that excludes opaque tool-call payloads.
  • Updates persisted-state schemas, migrations, fixtures, and targeted tests across app stores.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/app/src/storage/validated-storage.ts Adds reusable validated string and JSON reads that remove malformed persisted values without exposing them to callers.
packages/app/src/storage/validated-persist-storage.ts Adds strict validation for Zustand persistence envelopes on both reads and writes.
packages/app/src/stores/draft-store/migration.ts Defines supported pre-migration draft shapes and safely removes obsolete review attachments while retaining draft content.
packages/app/src/stores/draft-store/index.ts Restores Zustand draft migration behind the appropriate pre-migration validation schema.
packages/app/src/runtime/replica-cache/index.ts Replaces cached runtime objects with a validated versioned storage DTO and excludes opaque tool-call payloads.
packages/app/src/runtime/host-runtime.ts Updates host persistence and client configuration to consume validated state without the removed single-use helper.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[AsyncStorage value] --> B[Parse persisted envelope]
  B --> C{Strict schema valid?}
  C -- No --> D[Remove incompatible key]
  C -- Yes --> E{Migration required?}
  E -- Yes --> F[Migrate supported legacy DTO]
  E -- No --> G[Hydrate validated state]
  F --> G
Loading

Reviews (6): Last reviewed commit: "fix(app): preserve drafts with legacy re..." | Re-trigger Greptile

boudra added 2 commits August 13, 2026 00:50
Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate.
@boudra
boudra force-pushed the diagnose-windows-desktop-release branch from 8829fbd to 7bdcfb4 Compare August 12, 2026 22:51
boudra added 2 commits August 13, 2026 01:27
Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider.
Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields.
Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.
@boudra
boudra merged commit 3631235 into main Aug 13, 2026
41 of 42 checks passed
@boudra
boudra deleted the diagnose-windows-desktop-release branch August 13, 2026 10:02
desmond-rai added a commit to desmond-rai/paseo that referenced this pull request Aug 15, 2026
* feat(app): render mermaid diagrams in agent chat with pan/zoom (getpaseo#2306)

* feat(app): render mermaid diagrams in agent chat with pan/zoom

Fenced mermaid blocks in assistant messages now render as diagrams
instead of code blocks.

Web: mermaid renders inline via dynamic import (code-split, loads on
first diagram). Drag to pan, ctrl/cmd+wheel or trackpad pinch to zoom
about the cursor, double-click to reset; plain wheel still scrolls the
chat. Rendered SVGs are cached across virtualization remounts.

iOS/Android: diagrams render in a WebView running a self-contained
generated bundle (build:mermaid-webview, mirrors the native terminal's
xterm pattern — no CDN, mermaid ships in the app). The inline preview
is touch-shielded and height-driven by a bridge message; tapping opens
a fullscreen viewer where the platform WebView owns pinch-zoom.

Streaming source that doesn't parse yet falls back to the highlighted
code block and keeps the last good render; renders are serialized
through one queue with stale-task skipping so token-by-token updates
cost at most one in-flight layout.

Security: mermaid can fetch external resources during layout (image
shapes, CSS url()) before output sanitization runs — see
mermaid-js/mermaid#7645 — so resource-bearing source is rejected
up front (containsUnsafeMermaidSource) on every platform, htmlLabels
stay off, securityLevel is strict, and the mermaid secure list is
extended so directives can't override app theming or themeCSS.

The lockfile diff includes npm 11.17 normalization churn beyond the
mermaid closure; a no-op npm install produces the same rewrite.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(app): serialize native mermaid renders and pin viewer to last good source

Serialize render messages inside the mermaid webview entry so a newer
streamed update can't re-initialize global mermaid state (e.g. flip the
theme) while a prior layout is still running; superseded queued renders
are skipped. Track the last successfully rendered source on the RN side
and open the fullscreen viewer with that, so tapping mid-stream while
the latest chunk is still invalid can't show a blank viewer.

Addresses PR review feedback.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(app): pair native mermaid renders with their source via request id

A "rendered" bridge event was credited to the newest sent source, but an
older render can complete while a newer (possibly invalid) chunk's send
is in flight, mis-crediting the older render's success to the newer
source. Render messages now carry a request id that the entry echoes on
rendered/renderError; the RN side resolves the source by id, so the
fullscreen viewer always opens the exact source that produced the
visible render.

Addresses PR review feedback.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(app): harden mermaid guard and native preview per adversarial review

- Guard bypass (blocker): mermaid accepts quoted and unicode-escaped
  object keys, so quoted "img": evaded the resource denylist while
  still parsing into a real img property. The guard now decodes u/x
  escapes and strips quoting characters before matching, and tests
  both raw and normalized source.
- structuredClone polyfill banner in the webview bundle: the app's iOS
  floor is 15.1 but structuredClone exists only from 15.4; esbuild
  targets don't polyfill runtime APIs.
- Preview height sizes the inner content view so fence padding/borders
  no longer clip the reported SVG height, and measurement happens at
  the final content width (measuring overlay keeps the fence insets).
- The invisible measuring overlay is hit-test transparent, so an
  invalid diagram's fallback code block keeps its copy button and
  gestures.
- renderError now prunes all superseded source-map entries, matching
  the rendered path, so a long broken stream can't accumulate sources.
- The entry's render chain can no longer be poisoned: the whole task
  body is inside try/catch and the chain appends a trailing catch.
- "Mermaid diagram" accessibility label is translated in all locales
  (message.mermaidDiagram) on web and native.
- esbuild pinned in packages/app devDependencies so the generated
  webview artifact doesn't depend on hoisting layout; </script is
  escaped in the generated JS.

Addresses adversarial review round 2.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(app): mermaid source toggle and visible zoom controls

Web: a hover control cluster on the diagram (zoom in/out anchored to
the viewport center, reset view, view source) mirroring the code
block's hover-copy idiom; always visible on compact form factors where
hover doesn't exist. View source swaps to the highlighted code block
(with its own copy button) and a view-diagram button swaps back.

Native: the fullscreen viewer gains a source toggle beside the close
button, swapping the webview for the highlighted source in a
scrollable view.

All control labels are translated in every locale under
message.mermaid.*; the flat mermaidDiagram key from the previous
commit is folded into the same group.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(app): align mermaid control icons with the app's icon vocabulary

Network is the settings Connections icon — reusing it for "view
diagram" gave one glyph two meanings; use Workflow instead. RotateCw is
the app's established reset/reload glyph (RotateCcw had no precedent).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(app): use Scan for mermaid reset-view control

RotateCw is the app's reload glyph (19 uses) — a reset-zoom button
wearing it invites a reload misread. Scan (corner-bracket fit-to-frame)
is unused and carries the intended fit/reset semantics.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(app): remove raw NUL byte from mermaid cache key separator

A literal 0x00 in the template string made git classify the file as
binary, rendering the PR diff for it unreviewable. Use the escaped
sequence instead — identical runtime key, plain-text source.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(app): align mermaid source-view toggle with the copy button

The source view wrapped the code block while the fence margins stayed
inside it, so the floating view-diagram toggle hung in the margin
whitespace above the visible box — misaligned with the code block's
copy button and outside its hover region. Hoist the margins onto the
wrapper (both buttons now share the box edge) and render the toggle in
the copy button's plain style so the pair reads as one control row.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(app): close mermaid review findings

* chore(app): finish Mermaid PR rebase cleanup

* refactor(app): isolate diagram rendering and stabilize streaming

* fix(app): keep Mermaid previews stable while streaming

* fix(nix): update npm dependency hash

* fix(app): preserve streamed rows through completion

---------

Co-authored-by: Damon Meledones <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Mohamed Boudra <[email protected]>

* Make large diffs open quickly and scroll as one surface (getpaseo#3212)

* perf(app): virtualize large diff rendering

Render bounded diff row groups through one vertical list and one horizontal surface. Bound retained list ranges when expansion grows the data set so large diffs paint promptly without sacrificing scrolling behavior.

* refactor(app): normalize working tree diff options

* fix(app): keep wide diff text reachable

* Let users choose the metadata generation model (getpaseo#3215)

Expose Automatic and Manual metadata model selection in Host Settings, preserve existing configured fallbacks, and keep structured generation resilient through discovered candidates. Includes Playwright coverage, translations, public documentation, and settings-row alignment polish.

* fix(app): keep theme selection consistent (getpaseo#3214)

Theme registration, the appearance picker, persistence, and shortcut cycling drifted as new choices were added. Make one ordered catalog own those paths so Auto and Pure Black participate consistently, while lifting the Pure Black selected-workspace surface above its sidebar.

* Show the source branch on development builds (getpaseo#3216)

Development web and Electron builds now show their source branch in the expanded sidebar, including a branch icon and safe truncation. Dev launchers inject the label at build time; production builds remain unchanged.

* docs: fix typos across contributing, product, and skill docs (getpaseo#3153)

Fix spelling and grammar errors found in a full sweep of the
repo's English markdown files.

- CONTRIBUTING.md: "contributing to to it", "accpeted", "wil"
- .github/PULL_REQUEST_TEMPLATE.md: "value provider to" instead
  of "value provided to"
- docs/product.md: "vendor-lock in" instead of "vendor lock-in"
- packages/expo-two-way-audio/CONTRIBUTING.md: "prescreptive"
- packages/expo-two-way-audio/README.md: "can bee added"
- skills/paseo-committee/SKILL.md: "considered and reject"

Co-authored-by: Claude Sonnet 5 <[email protected]>

* feat(app): switch sidebar Group by from the command center (getpaseo#3063)

Switching the sidebar between Project and Status grouping takes three
clicks today: open the display preferences menu, open Grouping, pick the
mode. It is the only sidebar preference with no fast path.

Typing "group" in the command center now offers a single entry that
switches to the mode you are not in, so it reads "Group by status" while
grouped by project and the reverse once you are in status mode. With
two modes a "cycle grouping" label would not say where you land, and an
entry that reads the same in both states looks like it does nothing.

The entry is query-only, so the default Cmd-K list is unchanged. It does
not clear the focus restore element, because it navigates nowhere and
focus has to return to whatever you were typing in.

The contribution is built by a pure function in root-contributions.ts so
its branches can be unit tested, mirroring workspace-contributions.ts.
The registration subscribes to groupMode with a narrow selector to avoid
re-registering every root action when host filters are reconciled.

Co-authored-by: Claude Opus 5 <[email protected]>

* Add reusable agent profiles (getpaseo#3208)

* Search models across all providers from the picker root

* Store agent profiles in daemon config and apply agent config in one RPC

An agent profile is a named bundle of provider, model, mode, thinking
option, feature values and notes, stored host-wide next to terminal
profiles. It carries no system prompt: AgentSessionConfig.systemPrompt is
creation-only, so a profile holding one would apply when starting an agent
and silently do nothing when applied to a running one.

Applying a profile through the four single-field RPCs can fail halfway and
leave an agent matching no profile, so agent.config.apply takes the whole
bundle. Model runs first because a thinking option only means anything
against the model that offers it.

Old daemons parse their persisted config strictly and would drop an
unknown agentProfiles key, making a save appear to succeed and vanish, so
both capabilities are advertised on server_info for the client to gate on.

list_profiles exposes the profiles to orchestrating agents, which read the
notes to pick one and copy its values into create_agent.

* Add the agent profiles settings section

Profiles live behind one module boundary so removing the feature is a
directory plus one mount, which is what favourites never was. The picker
and composer will ask for rows and an apply function rather than learning
what a profile contains.

The edit form follows the form-model pattern instead of the terminal
profile modal's per-field state, because provider selection cascades into
which models, modes, thinking levels and features exist below it.

The section is gated on server_info.features.agentProfiles: an older daemon
drops the key on write, and a save that appears to work and then vanishes
is worse than an unavailable state.

* Document agent profiles and name the four senses of profile

The glossary reserved Profile for HostProfile, terminal profiles owned the
user-facing word, and custom-providers.md used it for provider aliases.
Agent profile is now the term, with all four cross-referenced.

data-model.md never documented terminalProfiles either, so both profile
lists are backfilled together along with the whole-list patch semantics and
why absent and empty differ.

* Pin agent profiles to the model picker and delete favourites

Applying a profile writes its values into the agent controls and is then
forgotten, so rows carry no checkmark and no selected state: after applying,
nothing is selected. Chevron still means navigate deeper, so the absence of
one is what marks a row as an action.

A running agent is its provider's process and agent.config.apply carries no
provider, so the live picker only offers profiles matching the agent's
provider rather than showing rows that cannot do what they say. The draft
path has no such limit and switches provider freely.

Draft feature values go through preferences rather than the feature
callback alone, because the draft feature hook clears local values whenever
the provider changes and a provider-switching profile would lose them.

Favourites are deleted rather than migrated: they were device-local
AsyncStorage keyed by provider and model, while profiles are host-wide
daemon config. ProviderSelectionModelRow.favoriteKey stays as row identity
and moves to its real owner; renaming it is its own commit.

* Cover agent profiles and cross-provider search with E2E specs

Model rows expressed selection only as a checkmark glyph, which assistive
tech cannot see and a spec cannot assert. Rows that draw the indicator now
carry aria-selected; profile rows deliberately carry none, because a
profile is an action rather than a selection. That is what makes "nothing
is marked selected after applying a profile" a real assertion instead of a
tautology, with the materialized model one level down as the control.

The search fixture extends claude rather than mock: provider-config.ts
only allows the six shipped providers as extends targets, so a derived
provider cannot inherit the dev-only mock provider.

* State what applying a config bundle does on partial failure

applyBundle is not a transaction and the earlier commit message implied it
was. A step that rejects aborts the rest and the steps that already applied
stay applied, which a profile omitting modelId can reach: provider setters
validate against the agent's live model, so mode can land while a feature
that model does not support is rejected.

Nothing is hidden by that. Every setter emits agent state before returning,
so what applied is streamed to the client next to the rejected response.
Rolling back would re-invoke the setters that just failed, usually against
a session that is gone, and pre-flight validation would restate each
provider's model-dependent checks where they would drift from the setters
that own them.

What sending the bundle in one request removes is the multi-round-trip
failure class: interruption between hops, and interleaving with another
client. The test already pinned this behaviour; its name now says so.

* feat(agent-profiles): refine profile discovery and appearance

Make profiles discoverable from empty and provider-scoped model pickers, keep global search stable and virtualized, and move profile settings under Agents. Replace emoji identity with a shared Lucide icon and host-color picker, while requiring concrete provider selections so profiles materialize predictably.

* fix(agent-profiles): defer profile swatch styles

Resolving Unistyles proxies at module load can freeze the temporary startup color scheme before the persisted theme is restored. Keep only style-key names at module scope and resolve the selected swatch during render.

* Always create a fresh worktree (getpaseo#3224)

* fix(server): always create requested worktrees

A slug is a preferred name, not workspace identity. Let the worktree creator suffix occupied paths and branches so duplicate and detached-slug requests still create fresh worktrees.

* fix(server): suffix occupied checkout branches

Fresh creation also applies when checkout targets are already in use. Create a suffixed branch from the requested branch instead of rejecting the request.

* test(server): align occupied branch coverage

* test(server): remove stale worktree reuse expectations

* Show live task progress while agents work (getpaseo#3227)

* feat(agents): surface live task progress

Keep current task state above the composer and turn task snapshots into semantic timeline activity. Normalize provider task protocols while preserving genuine plan flows.

* fix(agents): admit tasks through stream sequencing

* test(agents): expect normalized task status

* feat: Implement workspace rename cli (getpaseo#3209)

* feat(cli): add workspace rename command

`paseo workspace rename <workspace-id> <title>` sets a workspace's
user-visible title through the existing workspace.title.set RPC, and
`--reset` clears the override so the name reverts to the branch or
directory. The command reads the descriptor back so the output shows
what a reset reverted to.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(cli): harden workspace rename against read-back and arg failures

Review findings on the rename command:

- A failed workspace list sweep after a successful title change reported
  the whole command as failed. The read-back is display-only, so it now
  runs outside the mutation's error path and degrades to a null name.
- Only --reset needs the descriptor read back; a set title is already the
  resolved display name, so the common path no longer pages the list.
- Dropped the `"code" in error` rethrow. Nothing in that block throws a
  CommandError, but DaemonRpcError always carries a `code` property even
  when it is undefined, so the guard let those through to render as
  UNKNOWN_ERROR with a stack instead of WORKSPACE_RENAME_FAILED.
- Commander 12 allows excess positionals, so an unquoted multi-word title
  renamed to its first word and dropped the rest. The command now rejects
  excess arguments.

Co-Authored-By: Claude Opus 5 <[email protected]>

* refactor(cli): report the rename RPC response instead of reading back

Reading the descriptor back to show what --reset reverted to was the only
reason rename needed a paged workspace sweep, and that sweep dragged in
everything around it: a failure path that could report an applied rename
as failed, a nullable name, and a bespoke warning sink to tell the two
null cases apart.

Sibling commands don't do this. `workspace archive`, `agent mode`, and
`schedule update` all report what the RPC returned and stop; the one
command that reads back is `agent update`, which has the same "applied
but reported as failed" bug. So rename now mirrors `workspace archive`.

`--reset` reports `title: null` rather than naming what it reverted to;
`paseo workspace ls` shows the resolved name. This also reverts the
`collectWorkspaces` extraction, whose only second caller was rename.

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>

* fix: terminalize Codex compaction on turn end (getpaseo#3211)

* fix: terminalize Codex compaction on turn end

* fix: ignore stale Codex compaction completions

---------

Co-authored-by: Mohamed Boudra <[email protected]>

* Notify callers when watched children close and cap responses (getpaseo#3192)

Wake caller agents when watched children close and cap embedded child responses at 4,000 characters.

Co-authored-by: Hanjun(Tony) <[email protected]>
Co-authored-by: Thong Van <[email protected]>

* fix(omp): negotiate RPC protocol v2 so large model catalogs don't overflow (getpaseo#3184)

* fix(omp): negotiate RPC protocol v2 so large responses don't overflow

OMP caps protocol-v1 single-line RPC frames at 1 MiB. get_available_models
returns the full effective model catalog (hundreds of models), which exceeds
that cap, so OMP replies with an overflow error ("RPC response exceeded the
transport limit") and the OMP provider fails to start sessions.

Negotiate RPC protocol v2 after OMP's startup `ready` frame when it is
supported: v2 lifts the single-frame ceiling to 64 MiB by chunking oversized
frames. Add rpc_chunk reassembly to the shared JSONL transport so chunked
responses are reconstructed before routing, and gate the negotiation on the
ready frame so it is in place before the first request.

Protocol v2 is negotiated only when the peer advertises support in its ready
frame; peers that stay on v1 (e.g. Pi's --mode rpc) are unaffected.

* fix(omp): make protocol startup atomic

---------

Co-authored-by: pi3123 <[email protected]>
Co-authored-by: Mohamed Boudra <[email protected]>

* Remove unused session message processing (getpaseo#3231)

* perf(app): remove unused session message state

The legacy message accumulator had no readers but copied its full array for each activity and updated Zustand for every assistant chunk. Keep only the live voice-abort notification and voice transcription behavior.

* test(app): use live session action in task subscription test

* Reduce storage writes during agent streaming (getpaseo#3232)

* perf(app): skip transient replica cache writes

The replica cache subscribed to the entire session store, so live stream-head updates forced repeated JSON serialization and browser storage writes even though the head is not persisted. Select the cache's persisted inputs once and schedule writes only when that projection changes.

* test(app): move perf cleanup into fixture

Keep the replica-cache performance scenario as a direct setup, stream, measure, and assert flow while the spec-local Playwright fixture owns deterministic mock-agent cleanup.

* Use saved Project Settings for new worktree setup (getpaseo#3233)

* fix(worktrees): honor saved setup configuration

Project Settings writes paseo.json in the source checkout, but existing committed copies in new worktrees previously won silently. Copy the saved project config before lifecycle setup so the command shown to users is the command that runs.

* fix(worktrees): replace config symlinks safely

A selected ref can place a symlink at paseo.json, so copying over that path may overwrite a file outside the worktree. Materialize the saved source config with atomic rename semantics to replace the path entry without following it.

* fix(server): keep daemon workers alive across host sleep (getpaseo#3235)

The worker watchdog treated wall-clock time spent in host sleep as evidence that the worker was unresponsive. Count consecutive observed heartbeat misses instead so sleep does not destroy live terminal sessions while genuinely stalled workers still restart after the same active observation window.

* fix(app): bound Android provider snapshot storage (getpaseo#3234)

Android AsyncStorage's 6 MiB SQLite ceiling was exhausted by per-workspace provider snapshots with no eviction. Raise the native cap to 10 MiB and bound snapshots to 4 MiB with indexed eviction, clearing legacy unindexed data without scanning payloads on every save.

* Improve Markdown file preview readability (getpaseo#3240)

* feat(app): improve markdown file previews

Keep rendered documents on the chat reading rail, surface YAML front matter as readable metadata, and make shared Markdown links retain the chat accent and hover treatment.

* fix(app): preserve unsafe frontmatter source

* test(app): cover markdown link behavior

* fix(app): avoid unsupported Android array sorting (getpaseo#3241)

Hermes in the production Android app does not provide Array.prototype.toSorted. Multi-host project selection and provider cache eviction could therefore crash when they reached deterministic sorting paths.

* Help agents diagnose Paseo provider problems (getpaseo#3243)

* feat(skills): add Paseo help and provider diagnostics

Give support agents a topology-aware troubleshooting entry point backed by the deployed documentation index, and expose the daemon provider diagnostic through the CLI so agents can inspect the affected host directly. Agent-launching skills now use configured profiles instead of the removed orchestration preferences.

* test(cli): keep provider diagnostics on real boundaries

Remove the internal client mock and preserve its normalization and host-targeting assertions through the existing isolated-daemon coverage.

* Preserve daemon sessions when workers stall (getpaseo#3263)

Stop treating heartbeat silence as proof that a live daemon worker should be destroyed. Crash recovery, explicit lifecycle requests, and supervisor-loss cleanup remain unchanged.

* Document per-execution Hub worktree branch names (getpaseo#3267)

* docs(hub): document per-execution worktree branch names

Hub now materializes `${{ paseo.execution.id }}` inside a daemon
environment's `worktree.newBranch` before it persists or dispatches the
launch, so a reusable branch-off environment can get a stable branch per
execution instead of every trigger contending for one literal name.

The reference states the constraint precisely because it is the part the
YAML cannot show: `newBranch` accepts that one execution-scoped path and
nothing else, and any other expression fails bundle activation with the
authored file and field in the error. Literal branch names stay valid.

* docs(hub): reshape the per-execution worktree branch docs

Split the two pages by the job each reader is doing. The daemon page is a
task guide, so it now carries the worktree YAML and only the two facts
that reader needs to choose a branch name, then links out. The reference
owns the exhaustive contract at the `newBranch` field: a table contrasting
a literal with the template, then the accepted expression scope and what
activation rejects.

Dropped the render-before-dispatch ordering. Readers cannot act on where
Hub materializes the value; the reachable behavior is that a retried or
recovered execution keeps its branch, which is what the table says.

* docs(hub): make the per-execution worktree branch the only guidance

A branch-off Hub environment that aims every execution at one branch name
is not a configuration worth teaching, so the docs no longer present it as
an alternative. Both pages now show `newBranch: trigger-${{ paseo.execution.id }}`
and say what it does, with no literal-branch example and no comparison.

The reference keeps the field's type as a bare syntax fact, along with the
accepted expression, what activation rejects, and the authored field the
failure names.

* fix(usage): read Cursor token from modern state.vscdb key via node:sqlite (getpaseo#2704)

* fix(usage): read Cursor token from modern state.vscdb key via node:sqlite

Settings → Usage showed Cursor as unavailable for a normal Cursor desktop
login. The quota fetcher read the access token from the legacy `cursorAuthStatus`
key, but current Cursor builds store a plain JWT under `cursorAuth/accessToken`
with no such legacy row, so the lookup returned null.

Read the token with built-in node:sqlite instead of shelling out to a `sqlite3`
CLI (absent by default on Windows) — a second, independent cause of the same
"token not found → unavailable" failure. Prefer the modern key, fall back to the
legacy `cursorAuthStatus` blob for older builds, and log read failures at debug
so an unavailable card stays diagnosable.

Fixes getpaseo#2586

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* test(usage): cover Cursor sqlite text and blob auth

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Mohamed Boudra <[email protected]>

* fix(desktop): shut down cleanly with workspace terminals (getpaseo#3268)

Workspace terminal shutdown only signaled an npm wrapper, leaving Electron alive with broken output pipes. Make the signal-handling runner the terminal-owned process and route external Electron termination through the existing quit lifecycle.

* fix: stop completed Codex subagents appearing active (getpaseo#3188)

* Revert "Make large diffs open quickly and scroll as one surface (getpaseo#3212)"

This reverts commit b599d38.

* fix(app): align track header hover states

The subagent track used a custom hover path that left its label and chevron muted. Reuse the ghost button interaction so track headers promote their content consistently.

* fix(app): restore file tree layout

Restore the established tree rails while retaining file actions. Keep Escape local to generic editable fields so cancelling a file draft cannot interrupt an agent.

* fix(app): align agent profile controls

Keep trailing edit controls from changing section heading rhythm, and center each profile's icon with its own text block so one-line and noted rows share the same visual alignment.

* Restore model-default variant selection (getpaseo#3281)

* fix(providers): restore implicit model variant selection

Provider catalogs list only explicit overrides, so treating the first override as the default made base model behavior unreachable. Represent the base choice in the catalog and translate it back to an omitted variant at the provider boundary.

* test(providers): exercise variant catalog boundary

The regression concerns the model catalog callers receive, so verify normalization through fetchCatalog instead of coupling the test to its internal builder.

* docs(changelog): add 0.4.0-beta.1

* chore(release): cut 0.4.0-beta.1

* fix: update lockfile signatures and Nix hash [skip ci]

* Preserve opened subagents when parents are archived (getpaseo#3279)

* fix(subagents): preserve opened children on parent archive

Opening a managed child represents user ownership only while that client's tab remains open. Track that intent per client, detach surviving children during parent archive, and keep single and bulk tab-close behavior consistent.

* fix(subagents): mark generic tab openings

Centralize managed child tab ownership in agent navigation so list, command-center, deep-link, notification, and subagent-track openings all mark ownership before creating the tab. Use stable localized errors for lifecycle failures.

* fix(subagents): classify cold tab openings

Fetch uncached agents before deciding whether tab ownership must be recorded, so notification, desktop, and cold-route openings cannot bypass parent archive protection.

* fix(app): translate navigation fallback errors

* fix(navigation): allow offline workspace intents

* refactor(app): derive subagent ownership from open tabs

* fix(server): serialize subagent lifecycle ownership

* fix(app): invalidate incompatible replica cache

Todo timeline entries gained required activity metadata in beta.1 while the persisted cache format remained unchanged. Reject version 3 snapshots so upgrades hydrate authoritative state instead of rendering legacy rows.

* docs(changelog): add 0.4.0-beta.2

* chore(release): cut 0.4.0-beta.2

* fix: update lockfile signatures and Nix hash [skip ci]

* test(app): remove unintended replica cache case

* Keep new worktrees clean when setup changes are uncommitted (getpaseo#3311)

* fix(worktrees): preserve selected base config

New worktrees were overwriting the selected ref's paseo.json with source-checkout bytes, which could create an accidental revert in an otherwise fresh workspace. Keep committed base config authoritative and warn when locally saved setup changes still need to be committed.

* fix(worktrees): seed config exclusively

* fix(projects): refresh config status on focus

* fix(app): validate persisted client state (getpaseo#3289)

* fix(app): validate persisted client state

Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate.

* fix(app): preserve validated legacy state

* fix(app): preserve validated preferences

Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider.

* fix(app): migrate legacy agent preferences

Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields.

* fix(app): preserve drafts with legacy reviews

Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.

* Let Claude apply its native MCP timeouts (getpaseo#3315)

* fix(claude): preserve native MCP timeouts

Paseo forced Claude MCP startup and tool waits to ten minutes, turning unavailable servers into long silent first turns. Let Claude apply its own defaults while preserving explicit user environment overrides.

* test(claude): isolate MCP timeout regression

Exercise timeout precedence through the existing provider runtime environment input without mutating process-wide test state.

* test(workspace-git): await refresh boundaries

Synchronize fetch observation tests with initial snapshot and fetch completion before advancing fake timers.

* Disconnect daemons locally when Hub is unreachable (getpaseo#3321)

* fix(hub): make daemon disconnect unilateral

Local relationship removal must not depend on reaching the Hub. Fence execution authority, make one bounded courtesy revocation attempt, then clear local state so status and reconnect eligibility agree immediately.

* fix(hub): preserve disconnect intent across crashes

Persist the terminal disconnect marker only while the bounded Hub notification is in flight. Startup removes that marker, so a crash cannot restore the old relationship or create a revocation retry loop.

* Make provider catalog refresh deadlines configurable (getpaseo#3322)

* fix(providers): make catalog refresh deadlines configurable

Provider startup can legitimately exceed a fixed short timeout when catalog discovery has substantial initialization work. Give each provider refresh one configurable deadline covering availability and the complete catalog probe, and cancel named child operations before allowing retries so timed-out work cannot accumulate in the background.

* fix(opencode): abort catalog server acquisition

A provider refresh could expire while the helper server was still starting because acquisition only tracked the activity without observing its abort signal. Reject acquisition before transferring a server reference, while leaving the manager-owned startup reusable by the next caller.

* test(file-observer): allow native burst recovery

* Stop idle workspace Git refreshes from starving daemon requests (getpaseo#3323)

* fix(server): trust healthy workspace git watchers

Quiet observed workspaces were periodically scheduling full Git refreshes, saturating the daemon-wide process pool as workspace count grew. Keep only cheap observation re-ensure work, refresh ignore paths from watcher events, and detect silent metadata watchers with a one-shot canary before accepting the subscription.

* fix(server): refresh watcher ignores from git metadata

Repository exclude and Git configuration changes also affect the standard ignore set. Route those metadata events back to linked working-tree observations so their excluded directory lists stay current without periodic Git polling.

* test(server): drive ignore updates through watcher events

The integration contract now exercises the production event-driven ignore refresh instead of waiting for the removed periodic audit.

* fix(server): normalize git metadata paths on windows

File observation returns platform-native relative separators. Normalize those paths before matching Git metadata names so repository exclusion changes refresh working-tree ignores on Windows too.

* fix(server): trail concurrent ignore refreshes

Remember ignore-source events that arrive during an in-flight Git query and recompute once more before considering the working-tree observer current.

* Add guided Hub setup (getpaseo#3318)

* feat(cli): add Hub init wizard

Reuse the existing login, daemon enrollment, project listing, and deploy paths so onboarding stays compatible with current Hub servers. Generated triggers require an explicit sender allowlist and existing bundles are never modified.

* fix(cli): stop Hub init until daemon is ready

A durable Hub relationship can be reconnecting without being ready to execute workflows. Keep enrollment skipped, but stop the wizard until daemon status reports connected.

* fix(cli): wait for Hub daemon readiness

Both fresh enrollment and reconnect paths can expose a durable relationship before the execution socket is connected. Poll the existing status RPC with a bounded wait before resolving projects or deploying.

* fix(cli): make hub init detect resolvable resources

* Keep agent profile settings valid across providers (getpaseo#3331)

* fix(app): keep profile modes scoped to providers

Applying a profile through sequential field setters allowed a provider change
to race mode persistence, poisoning modeless provider preferences with another
provider's mode. Apply profiles as one form transition and heal invalid
provider-scoped mode preferences when profiles are used.

* test(app): cover profile features across providers

* feat(app): unify task list status styling

Use one task-row presentation across message and composer lists so running, pending, and completed states keep the same hierarchy and status language.

* fix(providers): allow two minutes for catalog refreshes

Provider catalog discovery can exceed one minute during cold startup. Increase the default deadline while preserving explicit config and environment overrides.

* fix(server): prefer portable project icons

Project icon discovery could select an ICO that native clients cannot decode even when a renderable SVG or PNG was available. Keep ICO as the final fallback and recognize common sized icon names.

* fix(app): support copying Hermes resume commands (getpaseo#3300)

* docs(release): require PR context for changelogs

* fix(app): open sole provider model picker directly

Running chats expose one provider, so an intermediate provider screen and back action are redundant. Restore the direct model-list entry behavior while preserving the multi-provider picker.

* docs(release): make changelog wording factual

* docs(changelog): prepare 0.4.0

* chore(release): cut 0.4.0

* fix: update lockfile signatures and Nix hash [skip ci]

* Stabilize live relay status coverage (getpaseo#3337)

* test(cli): stabilize live relay status coverage

The regression fixture deleted the supervisor's active PID lock to model a reachable daemon without local process state. Under CI load, worker readiness could race that deletion and make the supervisor tear down the daemon while status and pairing were probing it.\n\nLaunch the real worker directly so the no-PID state exists naturally, then wait on the CLI-visible live relay state before asserting behavior.

* test(cli): wait for complete relay probe state

Status readiness alone does not guarantee the next pairing RPC or the foreign-home identity probe will finish inside the CLI's per-call timeout under CI contention. Retry each exact observable outcome within the fixture deadline and resolve the real worker entry through the server package.

* test(cli): name retry mechanics once

Keep the relay scenarios expressed as CLI probes and exact outcomes while one file-local helper owns the shared deadline, worker-liveness check, and retry cadence.

* Restore host directories instantly before reconnect (getpaseo#3259)

* feat(sync): restore host directories before reconnect

Persist complete project, workspace, and active-agent replicas so every registered host can paint immediately, including offline hosts. Reconcile those replicas through monotonic per-entity cursors on the existing directory RPCs without retaining an event journal.

* fix(sync): preserve active turn identity during catch-up

Own Agent-to-wire projection beside the existing snapshot normalizer so replica persistence does not reach through directory-sync internals. This also keeps identified active turns intact when unchanged agents are folded into an incremental directory response.

* test(e2e): inspect the real IndexedDB replica

Keep cache setup, reload, and hydration coverage on the production browser storage backend after the replica moved off localStorage. Measure writes at the actual IndexedDB put boundary as well.

* fix warm reconnect cache paths

* feat(sync): persist project icons and deepen directory sync

* fix(sync): preserve project update order

* fix(cache): preserve validated replica persistence

* fix(sync): retain cached agents during catch-up

* feat(app): make pinned workspaces sortable (getpaseo#3341)

Reuse the existing workspace drag interaction for pinned rows and persist their local order across grouping modes. Keep pressed and dragged row scrims aligned with the active backdrop, and preserve memoized row rendering on web.

* fix(desktop): keep element selectors available on loaded pages (getpaseo#3187)

* fix(desktop): keep element selectors available on loaded pages

Annotate element and Screenshot element returned early when a cached load signal said the guest was not ready, even if the current document was complete and executable. Let executeJavaScript own the readiness boundary and keep selector cleanup available in the same stale-signal state.

Extend the real Electron browser regression to reproduce the complete-document/stale-signal mismatch through both toolbar actions.

* test(desktop): await browser selector injection

Poll the guest selector state through the browser bridge instead of assuming injection completes within 250 ms. This keeps the Electron regression deterministic on slower CI hosts.

* Harden browser selector session lifecycle

* Keep selector E2E fixtures independent

* fix(desktop): harden selector and annotation lifecycle

* fix(desktop): keep selector PR focused

* refactor(desktop): own element selector lifecycle

---------

Co-authored-by: Daniel G. Kang <[email protected]>
Co-authored-by: Mohamed Boudra <[email protected]>

* fix(app): make composer input IME-safe and paste-aware (getpaseo#3343)

* fix(app): preserve native composer input semantics

Native text was round-tripped through application state on every keystroke, which could overwrite an input method's in-flight edits. Keep native text ownership inside the input and reserve application-driven replacement for explicit draft mutations.\n\nRoute native clipboard images through the existing persisted attachment pipeline so keyboard and system paste actions behave like other image sources.

* fix(nix): refresh dependency hash

The native composer dependency changes the fixed npm dependency closure used by desktop builds.

* fix(app): keep composer locked across image pastes

Count concurrent native image persistence operations so submit remains disabled until every pasted image is attached.

* fix(app): replace composer text through the DOM on web

Web TextInput refs are DOM elements, so application-driven draft replacement must update their value and selection without native setNativeProps.

* fix(app): preserve web IME composition

Carry the Chinese IME fix from getpaseo#2990 into the web composer input boundary.

Co-authored-by: Jimmy Lee <[email protected]>

---------

Co-authored-by: Jimmy Lee <[email protected]>

* Resume cached timelines without replaying their tail (getpaseo#3329)

* perf(timeline): resume cached history from its exact range

Warm launches were repainting cached history and then downloading the same bounded tail to rediscover that nothing changed. Persist synchronization authority only when the complete current canonical window can be encoded losslessly; otherwise retain the existing display-only fallback.

* test(timeline): hide replica setup behind E2E helper

* fix(timeline): bound persisted resume overflow

* test(timeline): cover workspace eviction catch-up order

* test(timeline): read persisted range from IndexedDB

* Add managed local plugin lifecycle (getpaseo#3222)

* feat(plugins): add local plugin surfaces and RPCs

Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches.

* feat(plugins): add Linear attachment source

* fix(nix): refresh npm dependency hash

* test(server): exclude internal sessions from Hub counts

* test(server): distinguish Hub execution sessions

* feat(plugins): make local plugins explicitly opt-in

Keep plugin loading dormant until configuration enables it, and collect unsupported examples under one directory.

* refactor(plugins): establish host boundaries

* fix(nix): refresh plugin dependency hash

* fix(plugins): complete packaging and startup rollback

* feat(plugins): add managed local lifecycle

Make trusted local plugins recoverable and authorable through daemon-owned lifecycle operations, host settings, and a capability-gated CLI. Reloads fully stop the previous plugin before starting new code, while generated projects provide strict TSX checking without changing runtime dependency ownership.

* fix(nix): refresh plugin dependency hash

* Archive finished subagents across the whole track (getpaseo#3368)

* feat(app): archive finished subagents together

Make the track action clean up finished managed and provider-owned children as one operation while preserving running work. Managed archives remain sequential and provider-owned dismissal stays client-local.

* test(app): clarify subagent archive assertions

* Reload daemon configuration without restarting (getpaseo#3365)

* feat(daemon): reload configuration without restarting

Keep config validation, reloadability classification, override handling, and atomic runtime application inside the daemon so local and remote clients share one authoritative workflow.

* fix(daemon): preserve reload resource ownership

Keep every materialized provider client under daemon-lifetime ownership and classify speech overrides from resolved providers. Restore the complete WebSocket config-store test contract exposed by CI.

* fix(acp): isolate Hermes state per Paseo agent

---------

Co-authored-by: Damon <[email protected]>
Co-authored-by: Damon Meledones <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Mohamed Boudra <[email protected]>
Co-authored-by: Christoph Leiter <[email protected]>
Co-authored-by: Martin Hanzík <[email protected]>
Co-authored-by: Edi Hasaj <[email protected]>
Co-authored-by: Hanjun(Tony) <[email protected]>
Co-authored-by: Thong Van <[email protected]>
Co-authored-by: pi3123 <[email protected]>
Co-authored-by: pi3123 <[email protected]>
Co-authored-by: QuteSaltyFish <[email protected]>
Co-authored-by: James Strain <[email protected]>
Co-authored-by: paseo-ai[bot] <266920839+paseo-ai[bot]@users.noreply.github.com>
Co-authored-by: Daniel G. Kang <[email protected]>
Co-authored-by: Daniel G. Kang <[email protected]>
Co-authored-by: Jimmy Lee <[email protected]>
mogul added a commit to GSA-TTS/agentic-coding-patterns that referenced this pull request Aug 17, 2026
…nect loop

The web UI could sit on a spinner with a repeating ws://…/ws 101 loop even
after "Empty Cache and Hard Reload". Two distinct causes, neither covered by
the existing "No hosts" section:

- A newer client (e.g. desktop 0.4.0) attached to the pinned 0.3.1 daemon:
  the hello handshake completes but the post-hello liveness ping/pong contract
  differs, so the client disposes the socket on its ~30s heartbeat and reconnects
  forever, never subscribing a session.
- An incompatible persisted client cache in Local Storage/IndexedDB on 0.3.1.
  "Empty Cache and Hard Reload" clears only the HTTP cache, not Local
  Storage/IndexedDB, so it does not fix it; "Clear site data" does. Fixed
  upstream in 0.4.0 (getpaseo/paseo#3289, "Fixed crash when persisted cache was
  incompatible").

Changes:
- Bump PASEO_CLI_VERSION pin 0.3.1 -> 0.4.0 in spec.yaml and the in-script
  fallback in paseo-start.sh (kept in sync per the file header).
- TROUBLESHOOTING.md: note Hard Reload is insufficient and point to Clear site
  data; add a new section for the "handshake succeeds but socket torn down every
  ~30s" version-skew / incompatible-cache case, with an appVersion-vs-daemon
  diagnostic.

Verified: spec.yaml parses (PyYAML), paseo-start.sh passes `sh -n`. The offline
validate-kits.py gate needs jsonschema, unavailable in this sandbox (BLOCKED).
mogul added a commit to GSA-TTS/agentic-coding-patterns that referenced this pull request Aug 17, 2026
* feat(kits): add paseo self-hosted web UI acq mixin kit

Add an opt-in, backend-neutral hybrid/v1 acq mixin that self-hosts the
Paseo daemon + bundled browser web UI (https://github.com/getpaseo/paseo)
inside a sandbox on one port (6767) and publishes it to the host.

- Startup script installs @getpaseo/cli on first boot and supervises the
  daemon (paseo daemon start --foreground --web-ui) with a respawn loop.
- Agent-generic `opencode` wrapper: passthrough with args; no-arg path pins
  Paseo's global worktrees.root to <primary-project>/.paseo-worktrees and
  bounces the daemon (Paseo reads worktrees.root only at boot; no HUP), then
  holds PID 1 on `acq run` so the sandbox/UI stay alive. No shared TUI
  session (Paseo launches its own agent CLIs) — reusable with other agents.
- Neutral publishedPorts + background flag; no backend shortcuts/extras.
- Idempotent worktrees.root JSON merge helper; verify script (offline gate +
  live RUN_ACQ end-to-end); README, TROUBLESHOOTING, and 5 ADRs.
- Registry entry in kits.yaml and a row in acq-kits/README.md.

Live-verified end-to-end via acq (all checks pass).

Co-authored-by: OpenCode Agent <[email protected]>

* refactor(kits): split paseo entrypoint into thin wrapper + generic shim

Factor the paseo kit's monolithic ~/.local/bin/opencode into a thin,
agent-named wrapper over a new agent-neutral shim (paseo-agent-shim), so
the kit's Paseo/PID-1/worktree logic is reusable by any agent:

- files/home/paseo-agent-shim: all agent-neutral kit work (pin worktrees.root
  to <cwd>/.paseo-worktrees, bounce daemon on change, print connect info,
  PID-1 discriminator + hold).
- files/home/.local/bin/opencode: now ~25 lines — passthrough to the real
  opencode with args (still required for interactive use AND Paseo's own
  opencode provider), else `exec sh $SHIM`. Execs (not calls) the shim so it
  inherits PID 1 on the acq run path, preserving the hold.
- spec.yaml drops the shim (0755) and updates the DESIGN notes.
- verify step 8 asserts the wrapper execs the shim and that the shim (not the
  wrapper) carries the $$-eq-1 check + hold_pid1.

Also correct the generic-wrapper ADR: passthrough is NOT droppable while
shadowing an agent binary (needed for Paseo's provider); the primary-workspace
signal is the entrypoint cwd (portable across backends), not the msb-only
/var/lib/acq/workspace marker. Docs, TROUBLESHOOTING, and kits.yaml updated.

Behavior-equivalent to the prior monolith; static checks + local exec/PID
harness pass. Adding another agent = drop another thin wrapper reusing the shim.

Co-authored-by: OpenCode Agent <[email protected]>

* fix(kits): steer paseo UI to ssh-L publish path (msb chunked-asset truncation)

The Paseo web UI loaded a black page on the msb backend: the small
length-delimited assets (index.html, manifest.json, css) succeeded, but the
large (~15 MB) chunked JS bundle failed with ERR_INCOMPLETE_CHUNKED_ENCODING
and follow-on connections were refused.

Root cause is upstream, not in this kit: msb's create-time published-port relay
(crates/network/lib/publisher.rs) never propagates the guest server's TCP
half-close, so a chunked response (no Content-Length) truncates while
length-delimited ones survive. The equivalent CLOSE_WAIT half-close handling
already exists on the egress relay (conn.rs, via microsandbox#1181/#1255) but was
never mirrored into the ingress/published-port relay. Discussed upstream on
microsandbox#1330.

Interim fix in this kit until #1330 lands:
- README, spec.yaml, TROUBLESHOOTING, and the agent shim now steer users to reach
  the UI via `acq ports <sandbox> --publish 6767:6767` (an OpenSSH `ssh -L`
  forward that terminates inside the guest and bypasses the publisher relay),
  instead of the auto-published ephemeral port.
- Add docs/decisions/large-assets-need-ssh-forward.md capturing the diagnosis,
  the conn.rs vs publisher.rs asymmetry, and the trigger to relax this once
  #1330 is fixed.
- scripts/verify: new step 5b fetches the large hashed JS bundle END TO END over
  the published HOST port (requesting browser-style compression so the daemon
  serves the failing chunked variant) and compares the decoded size to the
  in-guest baseline — so this black-page regression is caught rather than passing
  on the small files alone.

Verification (offline): validate-kits.py OK (6/6 kits); bash -n on verify and
the shim; sh -n on the POSIX shim; offline gate of scripts/verify passes 5/5.
Live host-side verify (RUN_ACQ=1) requires an acq/msb host and is not run here.

Co-authored-by: OpenCode Agent <[email protected]>

* docs(kits): link paseo ssh-L ADR to tracking issue #318

Cross-reference #318 from the decision record so
the deferred "relax once msb #1330 lands" work is discoverable from the ADR.

Co-authored-by: OpenCode Agent <[email protected]>

* test(kits): paseo verify exercises both host paths for the large bundle

Extend scripts/verify step 5b to fetch the large hashed JS bundle end-to-end
over BOTH host paths in one run, documenting the contrast:

- Path (A): the auto-published ephemeral port (msb create-time publisher relay).
  Informational — expected to truncate today (microsandbox#1330). If it instead
  delivers the full bundle, verify emits a loud WARN that #1330 may be fixed and
  the ssh -L workaround can be relaxed (points at #318).
- Path (B): `acq ports <sandbox> --publish <hp>:6767` (ssh -L, terminates inside
  the guest). This is the hard PASS/FAIL gate: it publishes a fixed host port,
  waits for /api/health, then asserts the full decoded bundle arrives.

Shared helpers fetch_host_bundle() (rc + code + decoded size, browser-style
compression so the daemon serves the chunked variant) and bundle_ok() (matches
the in-guest baseline, or clearly-big when the baseline is unknown). Path (B)
requires the acq driver; SKIPs cleanly under RUN_SBX.

Verification (offline): validate-kits.py OK (6/6); bash -n on verify; unit-tested
the bundle_ok/parse logic (baseline-match, no-baseline-big, short→fail,
curl-rc-fail→fail); offline gate still 5/5.

Co-authored-by: OpenCode Agent <[email protected]>

* fix(kits): correct paseo verify integrity guard; drop unfounded relay-bug fix

Host-side testing disproved the earlier "msb create-time relay truncates the
UI's large chunked bundle" theory that PR #312 briefly acted on. Fetching the
bundle raw (no client-side decode), 5x over each path, transfers are complete
and repeatable everywhere — in-guest, the auto-published port, and ssh -L
(identity 15,779,639 / gzip 3,428,240 / br 2,506,699 bytes, all rc=0). The
ERR_INCOMPLETE_CHUNKED_ENCODING / rc=61 I had seen was a CLIENT artifact: a
libz-only curl asked for `Accept-Encoding: br,zstd` --compressed and failed to
DECODE the precompressed body (CURLE_BAD_CONTENT_ENCODING), independent of the
network. The upstream comment on microsandbox#1330 has been retracted.

This reverts the unfounded parts and fixes the test that produced the false
signal:

- Back out the "steer to acq ports --publish / ssh -L to dodge a relay bug"
  framing from README.md, spec.yaml, TROUBLESHOOTING.md, and the agent shim
  (restored to their pre-#312-fix wording); remove
  docs/decisions/large-assets-need-ssh-forward.md (wrong premise).
- scripts/verify step 5b: fetch the large bundle with `Accept-Encoding: identity`
  and WITHOUT `--compressed`, comparing byte count to the in-guest identity
  baseline. This removes the client codec-support variable entirely, so a decode
  failure can never masquerade as truncation; only a genuine short read / reset
  fails the guard. (This is the §8.3 lesson: live validation caught a defect the
  harness itself introduced.)
- Add scripts/paseo-probe3: a read-only host diagnostic for the STILL-OPEN,
  browser-only black page (WebSocket upgrade over the host port, the injected
  same-origin daemon connection hint, service worker / manifest, Host/Origin
  acceptance, HTTP/2 vs 1.1) — curl shows assets arrive intact, so the cause is
  on a browser-only path, not asset delivery.

The real black-page cause is not yet identified and is NOT shown to be an msb
bug. Tracking follows in the issue update.

Verification (offline): validate-kits.py OK (6/6); bash -n on verify + probe3;
offline gate 5/5. Net diff vs the pre-fix baseline touches only scripts/.

Co-authored-by: OpenCode Agent <[email protected]>

* fix(kits): gate paseo UI readiness on the full bundle (immutable-cache boot race)

Root cause of the persistent black page is now confirmed, and it is NOT an msb
bug: it is a first-load race that poisons the browser's HTTP cache. The web UI's
hashed assets are served `Cache-Control: immutable`; if a browser loads the page
during the boot window (after /api/health answers but before the ~15 MB hashed
bundle is fully serveable), Chrome caches a partial/broken br response and then
fails to decode it (ERR_CONTENT_DECODING_FAILED) on every later reload until the
cache is cleared. Over the host port the bytes are byte-identical to the on-disk
artifact and Brotli-decode correctly, and a disable-cache hard reload / Incognito
renders the app — proving stale client state, not transport or artifact.

Fix (kit-side; timing + guidance, no change to Paseo's caching):
- paseo-agent-shim: add ui_ready() — beyond /api/health, fetch the hashed entry
  bundle with `Accept-Encoding: br` (no decode) and require the received byte
  count to equal the daemon's on-disk .br artifact size (the bundle is chunked,
  so there's no Content-Length to compare). The shim now waits on ui_ready()
  before printing "READY — safe to open," and otherwise prints a WAIT notice plus
  the one-time remedy (disable-cache hard reload / Incognito).
- scripts/verify step 5b: add a browser-accurate check that fetches the bundle
  with `Accept-Encoding: br` and Brotli-decodes it via node zlib
  (brotliDecompressSync), asserting the decoded length equals the identity
  baseline. Models the exact ERR_CONTENT_DECODING_FAILED mode; independent of the
  host curl's codec support. (Keeps the identity end-to-end integrity check too.)
- TROUBLESHOOTING: add "Black page that persists on reload" with cause, the
  one-time remedy, and a scriptable bundle-readiness gate.
- Add docs/decisions/ui-readiness-gate-immutable-cache.md.
- paseo-probe3: bound the WebSocket handshake probe (--max-time; treat 101 as
  success) so the diagnostic can't hang, and add a python end-to-end WS handshake.

Verification (offline): ui_ready() logic exercised against a live local daemon
(on-disk .br 2,506,699 == br wire bytes -> ready); node brotliDecompressSync
decodes the served bundle to 15,779,639 and a truncated .br yields
"unexpected end of file" (negative test); sh -n/bash -n clean; validate-kits.py
6/6; offline gate 5/5. Live RUN_ACQ=1 host run still recommended.

Co-authored-by: OpenCode Agent <[email protected]>

* fix(kits): disable paseo cloud relay by default (egress-blocked, unneeded)

The daemon defaults its cloud relay ON and dials wss://relay.paseo.sh on a
~30s retry loop. That host is not on this kit's egress allow-list (only
registry.npmjs.org is), so the sandbox proxy resets the TLS handshake mid-
flight and the daemon log fills with `relay_error` / `relay_control_disconnected`
(ECONNRESET) forever. The relay is unnecessary here: the host reaches the web UI
directly over the loopback-published port, not through Paseo's cloud relay.

Set PASEO_RELAY_ENABLED=false in the kit's guest env. It is read at daemon
config load (server/config.js resolveRelayConfig) and takes precedence over
persisted config, so it holds across daemon restarts/self-updates without
touching config.json — off by default for every new sandbox.

Note this does NOT affect web UI reachability: the daemon still binds
0.0.0.0:6767 and /api/health returns 200 throughout. A host-side "connection
refused" to the UI is a port-mapping issue, not the relay.

Add a TROUBLESHOOTING.md section documenting the relay symptom, cause, the
default fix, and a live workaround for pre-existing sandboxes.

Verified live: after disabling the relay and bouncing the daemon, relay_error
lines stop and http://127.0.0.1:6767/api/health stays 200. spec.yaml parses
(YAML OK) with the new quoted-string env value.

Co-authored-by: OpenCode Agent <[email protected]>

* docs(kits): steer paseo UI to localhost, document "no hosts" reconnect loop

With the cloud relay disabled (this kit's default), the daemon self-advertises
as a local "host" over the same origin: server/web-ui.js injectConnectionHint
injects window.__PASEO_INITIAL_DAEMON_CONNECTION__ into index.html, with its
`listen` value echoed verbatim from the request Host header. The web UI keys its
built-in local daemon on `localhost:<port>` (and normalizes 127.0.0.1/::1/0.0.0.0
to localhost). So opening the UI via http://127.0.0.1:<port> yields a hint of
`127.0.0.1:<port>` that does not match the client's `localhost:<port>` local-host
key, and the client falls into a clean client-side reconnect loop: repeated
ws://…/ws upgrades (101), each closed by the browser with code 1000
"Client closed" on growing backoff, while the UI shows "no hosts". The daemon is
healthy throughout (status running, /api/health 200); this is browser-side state,
not a daemon fault.

- README.md quick start + "Reaching it from the host": open http://localhost:<port>
  (not 127.0.0.1), with a short why-note.
- spec.yaml agentContext: same localhost guidance.
- TROUBLESHOOTING.md: new "No hosts configured / ws reconnect loop" section —
  explains the same-origin self-advertise mechanism, that the host registry is
  browser-side (not the daemon), and the fixes in order: open via localhost;
  clear @paseo:daemon-registry / @paseo:replica-cache in Local Storage (or use
  Incognito); verify the daemon injects the hint. Notes the daemon.get_status
  ws_slow_request entries are a one-time boot artifact, not the loop cause.

Verified live: curl with `Host: localhost:6767` vs `Host: 127.0.0.1:6767` returns
matching hint `listen` values (localhost:6767 / 127.0.0.1:6767 respectively),
confirming the Host-derived mismatch. spec.yaml parses (YAML OK).

Co-authored-by: OpenCode Agent <[email protected]>

* fix(kits): bump paseo pin to 0.4.0; document incompatible-cache reconnect loop

The web UI could sit on a spinner with a repeating ws://…/ws 101 loop even
after "Empty Cache and Hard Reload". Two distinct causes, neither covered by
the existing "No hosts" section:

- A newer client (e.g. desktop 0.4.0) attached to the pinned 0.3.1 daemon:
  the hello handshake completes but the post-hello liveness ping/pong contract
  differs, so the client disposes the socket on its ~30s heartbeat and reconnects
  forever, never subscribing a session.
- An incompatible persisted client cache in Local Storage/IndexedDB on 0.3.1.
  "Empty Cache and Hard Reload" clears only the HTTP cache, not Local
  Storage/IndexedDB, so it does not fix it; "Clear site data" does. Fixed
  upstream in 0.4.0 (getpaseo/paseo#3289, "Fixed crash when persisted cache was
  incompatible").

Changes:
- Bump PASEO_CLI_VERSION pin 0.3.1 -> 0.4.0 in spec.yaml and the in-script
  fallback in paseo-start.sh (kept in sync per the file header).
- TROUBLESHOOTING.md: note Hard Reload is insufficient and point to Clear site
  data; add a new section for the "handshake succeeds but socket torn down every
  ~30s" version-skew / incompatible-cache case, with an appVersion-vs-daemon
  diagnostic.

Verified: spec.yaml parses (PyYAML), paseo-start.sh passes `sh -n`. The offline
validate-kits.py gate needs jsonschema, unavailable in this sandbox (BLOCKED).

* fix(kits): harden paseo kit per review — loopback bind + --ignore-scripts

Address the non-blocking hardening from @wz-gsa's review of #312:

- In-guest bind to 127.0.0.1:6767 (was 0.0.0.0:6767). The daemon is
  single-consumer; acq's port-publish reaches it over the guest loopback
  interface, so a loopback bind stays reachable over the published host
  port while removing exposure on the guest's external interface
  (defense in depth). Reconciled across spec.yaml, paseo-start.sh,
  paseo-agent-shim, README, TROUBLESHOOTING, and the supervisor ADR.
- npm install runs with npm_config_ignore_scripts=true, blocking
  preinstall/install/postinstall exec vectors. @getpaseo/* are plain JS
  and sherpa-onnx resolves via platform prebuilts, so nothing needs a
  lifecycle script; the top-level `paseo` bin link is still created.
- README: note that enabling the kit shadows `opencode` in-sandbox for
  all invocations (args pass through; only no-arg is redirected).

scripts/verify gains two live assertions so a single RUN_ACQ=1 host run
confirms both changes: step 6b reads the daemon LISTEN address and asserts
loopback (plus host-port reachability), step 6c asserts the CLI is usable
despite --ignore-scripts.

Follow-up tracked in #332 (safely preinstall speech-recognition assets)
so re-enabling voice does not regress the --ignore-scripts hardening.

Co-authored-by: OpenCode Agent <[email protected]>

---------

Co-authored-by: OpenCode Agent <[email protected]>
finelly977 pushed a commit to finelly977/paseo that referenced this pull request Aug 19, 2026
* fix(app): validate persisted client state

Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate.

* fix(app): preserve validated legacy state

* fix(app): preserve validated preferences

Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider.

* fix(app): migrate legacy agent preferences

Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields.

* fix(app): preserve drafts with legacy reviews

Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.
@boudra boudra mentioned this pull request Aug 24, 2026
7 of 11 tasks
mogul added a commit to GSA-TTS/agentic-coding-patterns that referenced this pull request Oct 9, 2026
#453)

## Summary

Adds a read-only identity gate to the `paseo` acq-kit that catches a
sandbox whose **host-published port reaches a different Paseo daemon
than `acq exec` reaches** — a state in which every signal the kit
previously checked passes. Documents the failure mode, records the
rationale in an ADR, and **corrects standing guidance that is actively
wrong** for this setup.

Relates to #452.

## Context

Both daemons were genuinely healthy — they were simply **different
daemons**:

| Signal | Reading while broken |
|---|---|
| `acq ports <sandbox>` | reported the create-time mapping (it echoes
declared config, not live state — GSA-TTS/agentic-coding-quickstart#520)
|
| host `curl /api/health` | `{"status":"ok"}` |
| in-guest `curl /api/health` | `{"status":"ok"}` |
| UI bundle | byte-complete, satisfying the existing readiness gate |
| `~/.paseo/server-id` | **divergent — the only discriminating signal**
|

Under acq/msb this surfaces as a provider **401
`{"detail":"Authentication failed"}`** from browser agent sessions:
injected secrets are placeholders that the sandbox's TLS-interception
layer substitutes on the wire, so a request reaching a daemon *outside*
that path carries the unsubstituted placeholder. Note `Authentication
failed` ≠ `Not authenticated` — a credential *was* sent and rejected.

That is what made this expensive to diagnose. The failure presents as a
**credential problem** while the fault is **routing**, two layers down.
The key demonstrably worked from inside the sandbox (`curl`, `opencode
run`, `paseo run` all succeeded), and the natural next step — restarting
— changed nothing, because the stale component was on the **host**.

**Root cause is upstream, not ours.** A host `msb` forwarder outlived
the sandbox it was created for: it survived `acq stop` **and** `acq rm`,
kept the create-time `-p` bind, and came from a **different `msb`
installation** than the one `acq` drives. Being foreign to the active
build, it was invisible to the lifecycle commands; only `kill <pid>`
released the port. The kit can detect and document this; it cannot fix
it.

## Plan

Four changes, all confined to `integrations/isolation/acq-kits/paseo/`:

1. **NEW `scripts/paseo-verify-identity`** — read-only gate, exits
non-zero when the host-published port and `acq exec` disagree on
`~/.paseo/server-id`. Seven sections: environment, listener provenance
(age + build compare), loopback-resolution pairing, guest identity, host
health with a restart grace, WebSocket server-ids, `peer=external` vs
`peer=loopback` provenance, verdict + remediation.
2. **NEW `docs/decisions/identity-gate-stale-host-forwarder.md`** — the
durable rationale.
3. **MODIFIED `TROUBLESHOOTING.md`** — new section "The published port
reaches a DIFFERENT daemon than `acq exec` (stale host forwarder)"; a
restart-grace note on the existing `rc=52` section; **and a correction**
(below).
4. **MODIFIED `README.md`** — validating section, layout, design-records
pointer.

### Design constraints, each learned from an observed failure

- **Compare the ids to each other, never to a remembered value** — the
id changes on VM recreate, so any hardcoded expectation rots
immediately. Also fails when the three host probes disagree *among
themselves* (multiple backends behind one port).
- **Resolve the host port from `acq ports`; never assume host == guest**
— the post-hoc publish path maps a different host port, and a probe
hardcoded to `6767` would silently test the wrong endpoint.
- **Strip CR once, at the guest boundary** — `acq exec` returns CRLF; an
unstripped `\r` produced a **false MISMATCH printing two identical
ids**.
- **Allow a health grace period** — for a few seconds after restart the
daemon accepts TCP but answers nothing (`rc=52`), matching
`scripts/verify`'s existing `POST_INSTALL_GRACE` convention.
- **Warn only when `::1` resolves *first*** against an IPv4-only
listener; mere presence of `::1` is harmless and keying on presence
fires on healthy systems.
- **Read full argv (`ps -ww`)** — the installation-version segment is
exactly what truncation drops.
- **Treat `PPID 1` as normal**, not as a leak signal — launchd
legitimately adopts these forwarders. Age and build are the
discriminating signals.

### Corrected wrong guidance

The "No hosts configured" section said *"Open the UI via `localhost`,
not `127.0.0.1`."* That predates the IPv4-only observation: when the
resolver returns `::1` **first**, a `localhost` URL connects where
nothing is listening and **hangs** instead of failing fast — converting
a working setup into a failure that mimics the bug above. It is now
conditional on checking the listener's address family, with the commands
to check it.

## Verification

### Environment

```text
python  3.14.4  (jsonschema 4.26.0 | pyyaml 6.0.3 — installed in an isolated venv)
node    v22.22.1
base    5d1a877 (main)
```

### Repo gates

```text
=== make validate-kits (--strict) ===
  OK  agentic-coding-playbook / git-ssh-sign / openchamber / paseo
  OK  pi-coding-agent / prime-agent / usai-provider / zscaler-ca-certificate
All 8 acq-kits valid.                                              exit=0

=== make validate ===
✓ All validators passed                                            exit=0

=== make generate-check ===
✓ INDEX.yaml is up to date
✓ CATALOG.md is up to date                                         exit=0

=== scripts/scan_unsafe_shell.py .  (repo-wide; covers the new bash script) ===
unsafe-shell scan: 0 error(s), 0 warning(s)                         exit=0

=== bash -n integrations/.../paseo/scripts/paseo-verify-identity ===
syntax OK                                                          exit=0

=== paseo kit node --test ===
# tests 8   # pass 8   # fail 0

=== make test-cases ===
Suites: 12   Tests: 73   Passed: 73   Failed: 0

=== pytest scripts/tests ===
401 passed in 12.00s

=== pip-audit --skip-editable ===
No known vulnerabilities found

=== markdownlint-cli2 (kit markdown) ===
Linting: 70 files   Summary: 0 issues in 0 files

=== pre-commit run --files <the 4 changed files> ===
trim trailing whitespace ......... Passed
fix end of files ................. Passed
check for added large files ...... Passed
check for merge conflicts ........ Passed
Detect hardcoded secrets ......... Passed
markdownlint-cli2 ................ Passed
Validate pattern frontmatter ..... Passed
Validate acq-kits (hybrid/v1) .... Passed              exit=0
```

### Live sandbox run (not mocked)

Run against a real running sandbox (guest daemon `0.9.1`), through the
script's exact code paths:

```text
guest (via the script's one-exec KEY=VALUE probe, CRLF-stripped):
  SERVER_ID=srv_5NMCI_uKzkPs   DAEMON_PID=697   VM_UPTIME=3345
  BIND=0100007F:9161 00000000:1A6F 0100007F:9431
  HEALTH={"status":"ok",…}      EXTERNAL=125   LOOPBACK=35

host WebSocket probe (the browser's path), all three address forms:
  localhost:    srv_5NMCI_uKzkPs
  127.0.0.1:    srv_5NMCI_uKzkPs
  ipv4-mapped:  srv_5NMCI_uKzkPs

VERDICT = pass (host port and guest agree on srv_5NMCI_uKzkPs); PROBE_TIMEOUTS=0
host health: 127.0.0.1 / localhost / [::ffff:127.0.0.1]  → all {"status":"ok"}
guest bind : 00000000:1A6F → 0.0.0.0:6767 (correct wildcard branch, not loopback-only)
external>0 : 125 → the "host traffic reached this daemon" branch fires correctly
```

Precondition/exit-code paths exercised directly: no argument → `usage`,
`exit 2`; `acq` absent → `FATAL: acq not on PATH`, `exit 2`.

### Table-driven predicate checks (all pass)

| Predicate | Cases |
|---|---|
| `etime_to_secs` | `05`→5, `01:40`→100, `16:40`→1000, `1:00:00`→3600,
`7-00:00:00`→604800, whitespace-padded→123 |
| Orphan age | observed-healthy 1001s vs 1000s → PASS; real orphan 7d vs
1d → FAIL; slack boundary 1120 → PASS, 1121 → FAIL |
| Loopback ordering | `127.0.0.1` first + IPv4 → silent; `::1` first +
IPv4 → warns; `::1` + IPv6 → silent |
| Build compare | the two real observed paths → warns; same prefix →
PASS; non-Cellar → PASS (no false warning); one-sided non-Cellar → PASS
|
| Verdict matrix | agree → pass; mismatch → fail; two distinct host ids
→ fail; no host id → fail |
| CRLF boundary | stripped: id `EQUAL`, arithmetic `1120`; **unstripped:
id `UNEQUAL`, arithmetic error** — reproducing the original defect and
confirming the fix |
| `mktemp` | explicit `XXXXXX` template → OK; bare `-t` → `too few X's`
(the macOS defect, also reproducible here) |
| Embedded Node probe | `node --check` clean; verified end-to-end
against a synthetic `/ws` server returning a known `srv_` id, then
against the real daemon |

### Defects this verification found and fixed

Three CRLF defects were exposed by the **live** run of the precursor (a
mocked pass would have shown none of them), plus one macOS-only `mktemp`
defect, plus one dead helper removed during this review:

1. False `MISMATCH` printing two **identical** ids — trailing CR.
2. `syntax error: invalid arithmetic operator (error token is "\r")` on
the age compare — same cause.
3. False "zero `peer=external`" directly below a printed count of `110`
— same cause.
4. `mktemp -t` without an explicit `XXXXXX` template fails on macOS.
5. An unused `in_guest()` helper (every guest read goes through the
single batched `acq exec`) — removed rather than left as a second,
CR-unsafe path for a future edit to pick up.

**End-to-end confirmation of the underlying fix** (separate from the
gate): after killing the stale forwarder, `"peer":"external"` client
counts went **0 → 110** and a live browser agent round-trip succeeded.

### Pre-existing issue encountered (not caused by this PR)

`make validate-kits` fails with `FAIL: - __pycache__: missing spec.yaml`
whenever a `__pycache__/` exists under `acq-kits/` — which any Python
import of `validate-kits.py` creates, including `make test`/`pytest`.
Already tracked as #383. Confirmed
pre-existing by reproducing it in a **clean worktree at `main`
(5d1a877)** with no changes applied: `validate-kits` `rc=0` → run the
pytest that imports the validator → `validate-kits` `rc=1` with the
identical error. `__pycache__/` is gitignored, so CI's fresh checkout is
unaffected; all runs reported above were made with it removed. **Not
addressed here** — out of scope for this PR, and already filed.

## Rollback

Self-contained and fully reversible; no runtime or spec surface is
touched.

```bash
git revert <merge-commit>
```

Or piecemeal: delete `scripts/paseo-verify-identity` and
`docs/decisions/identity-gate-stale-host-forwarder.md`, and revert
`TROUBLESHOOTING.md` + `README.md`. Nothing else references them —
`spec.yaml` is unchanged, so no sandbox behavior changes either way, and
the new script is **never invoked automatically** (no CI wiring, no
startup hook). Reverting restores the previous `localhost`-first
guidance, which is wrong in the `::1`-first case; that is the only
functional loss.

## Security Impact

**Net-positive; no new attack surface.**

- **No change to authn/authz, data handling, or the sandbox boundary.**
`spec.yaml` is untouched: no new capability, port, mount, or environment
variable. The kit's runtime behavior is byte-identical.
- **The new script is strictly read-only.** It performs no mutation, no
`kill`, no restart, no file writes outside one `mktemp` temp file
removed on `trap … EXIT`. Remediation is **printed for a human to run**,
deliberately not automated: killing a process by PID is unsafe to do
unattended, and the output says to investigate the parent before `kill
-9` when `PPID != 1`.
- **No secret exposure.** It never reads, prints, or transmits provider
credentials. It reads only `~/.paseo/server-id`, `paseo.pid`,
`/proc/uptime`, `/proc/net/tcp`, and `grep -c` counts from `daemon.log`.
The injected `USAI_API_KEY` placeholder is never touched — deliberately,
since printing it would be treated as secret exfiltration.
- **Network scope is loopback-only.** All probes target `127.0.0.1` /
`localhost` / `::ffff:127.0.0.1` on the host-published port plus
in-guest loopback. No outbound network access, no remote code fetched or
executed.
- **Injection-safe guest invocation.** The remote script is
single-quoted (no host-side interpolation) and the guest port is passed
as a positional `$1`. The host port is extracted with a
`127\.0\.0\.1:[0-9]+` regex, not interpolated from free text. Repo-wide
`scan_unsafe_shell.py`: 0 errors, 0 warnings. `gitleaks`: clean.
- **Reduces risk it does not create.** It detects a silent misroute in
which browser traffic bypasses the sandbox's TLS-intercept path — which,
besides breaking auth, means those requests are not traversing the
intended egress control point at all. The corrected `localhost` guidance
also removes advice that could push an operator toward a hanging,
harder-to-diagnose state.

---

## Eligibility & Accountability

- [x] I am a **federal employee or contractor**.
- [x] **I understand this change and can explain it in my own words** —
I am the author of record and accountable for it.
- [x] By opening this PR, I **attest I have the right to release this
work** under the repository's CC0-1.0 dedication.

## AI Assistance Disclosure

- [x] AI tools were used in this contribution: **OpenCode** — live
host-side investigation of the failure, drafting the gate script, the
TROUBLESHOOTING section, and the ADR.
- [x] I verified all AI-generated content: no fabricated APIs,
dependencies, citations, or placeholder content. Every claim in the
ADR's Verification section corresponds to a command actually run and its
real output; no dependencies were added.
- [x] No inherited/reused third-party material.

## Type of Change

- [x] New tool/editor integration (`integrations/`)
- [x] Documentation update
- [x] Bug fix — corrects `localhost`-first guidance that is wrong when
the resolver returns `::1` first

## Testing

- [x] Ran `make validate` locally (all checks pass) — full transcript
above
- [x] Ran `make test` / `make test-cases` / `make validate-kits` / `make
generate-check` / kit `node --test`
- [x] Tested with a real scenario — live sandbox run, plus the original
live failure this was built from
- [x] Examples are concrete and realistic (all command output is real,
not illustrative)
- [x] Commit message follows Conventional Commits

## Safety Checklist

- [x] No secrets, API keys, tokens, or passwords included
- [x] No PII
- [x] No CUI
- [x] No internal URLs, hostnames, or system paths — verified: no
operator username, host path, PID, or real `srv_` id from the incident
appears in any committed file
- [x] No customer data or proprietary information
- [x] N/A — this is an acq-kit script/docs change, not a pattern with an
output contract

## Quality Checklist

- [x] Follows the kit's existing conventions (one decision per ADR file;
`scripts/` layout; `POST_INSTALL_GRACE`-style grace windows)
- [x] Plain language
- [x] Examples included
- [x] Prerequisites stated (`acq`, `curl`, `node` — each with an
explicit `FATAL` precondition and `exit 2`)
- [x] N/A — not a pattern with frontmatter

## Related Issues

Relates to #452
Relates to GSA-TTS/agentic-coding-quickstart#520 (`acq ports` reports
declared config as live state — why every restart appeared to succeed)
Encountered but **not** addressed: #383
(`__pycache__` breaks local `make validate-kits`)

## Additional Context

Four **upstream** reports are drafted but deliberately **not yet
filed**, pending a duplicate search (the kit already cites
`getpaseo/paseo#3289` and three `microsandbox` issues, so duplicate risk
is real):

1. `msb` forwarder processes survive `msb stop`/`msb rm` and keep the
create-time `-p` bind — the primary defect.
2. Create-time published ports bind IPv4-only, while clients may resolve
`localhost` to `::1` first.
3. Paseo's `normalizeLoopbackToLocalhost` plus a `localhost` default
bootstrap can defeat the daemon's own injected `127.0.0.1` connection
hint.
4. Paseo does not reconcile or warn when a stored host profile's live
`serverId` differs from the expected one.

These will be framed as a **forwarder-lifecycle** defect, not as "auth
fails" — the 401 is a downstream symptom specific to this TLS-intercept
setup, and leading with it would send maintainers down the wrong path.

## Reviewer Notes

**Please do not merge on my approval — this needs a code-owner review.**
Highest-value review targets:

- **Is the corrected `localhost` guidance right for *your* host?** Run
`node -e
'require("dns").lookup("localhost",{all:true,verbatim:true},(e,a)=>console.log(a))'`
and compare with the `TYPE` column of `lsof -nP -iTCP:<host-port>
-sTCP:LISTEN`. This repo's prior advice was the opposite.
- **The `kill` remediation is printed, never executed.** Confirm that is
the right boundary, and that the `PPID != 1` caution is sufficient.
- **CRLF handling.** One `strip_cr` at the guest boundary. Worth
confirming no later-added guest read bypasses it (the now-removed
`in_guest()` helper was exactly that hazard).
- **`peer=external` is a WARN, not a FAIL** — it cannot distinguish "UI
not opened yet" from "traffic landing elsewhere". Confirm that severity
choice.
- The gate reads `~/.paseo/daemon.log` (the daemon's own log) rather
than the supervisor-captured `~/.local/state/paseo/paseo-daemon.log` the
rest of the doc tails. Same stream; the former does not depend on this
kit's redirect.

---------

Co-authored-by: OpenCode Agent <[email protected]>
Co-authored-by: OpenCode Agent <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant