Compare commits

..
Author SHA1 Message Date
Mohamed Boudra bbb2b40855 chore(release): cut 0.7.1 2026-09-01 20:24:44 +02:00
Mohamed Boudra c062278595 docs: prepare 0.7.1 changelog 2026-09-01 20:21:34 +02:00
Mohamed Boudra b9cdfb6528 fix(nix): refresh npm dependency hash 2026-09-01 20:21:34 +02:00
Mohamed Boudra 1361da5ef6 fix(claude): group Fable models in catalog 2026-09-01 20:13:58 +02:00
Mohamed Boudra 3377ffed29 feat(claude): add Fable 5.1 model
Expose the new 1M-context model through the curated Claude catalog and normalize its dated and context-suffixed runtime IDs back to one canonical entry.
2026-09-01 20:12:36 +02:00
Mohamed Boudra c56638ea8c chore(release): cut 0.7.0 2026-08-31 19:25:32 +02:00
Mohamed Boudra 185ab3f300 docs: prepare v0.7.0 changelog 2026-08-31 19:20:58 +02:00
Matt Cowger 1694828275 fix(opencode): show child session prompts (#4055)
Show OpenCode child-session user prompts even when the text part has no completion timestamp.
2026-08-31 18:35:28 +02:00
74a377ff6a fix(server): keep daemon alive when JSONL RPC stdin hits EPIPE (#4048)
* fix(server): keep daemon alive when JSONL RPC stdin hits EPIPE

A dead Claude/OMP child currently turns stdin.write() EPIPE into
uncaughtException and kills the daemon. Catch the write, listen for
stdin errors, and close that transport instead.

Closes #4047

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(server): reproduce JSONL RPC stdin EPIPE

* test(server): gate native EPIPE fixture on POSIX

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-31 15:35:34 +02:00
Mohamed Boudra 63923fdd07 Keep Android timeline still when tapping text (#4090)
* fix(android): keep timeline still when tapping text

Selectable Android text takes touch focus. React Native then scrolls focused children using untransformed coordinates, which moves inverted timelines even though the user did not scroll.\n\nSkip that auto-scroll only for touch-focused, selectable, non-editable text in inverted vertical scroll views, preserving normal focus navigation and editable inputs.

* test(e2e): isolate PR pane fixtures from bots
2026-08-31 12:10:40 +02:00
Mohamed Boudra 11e76f6655 fix(server): resume agents cleanly after failed turns
Track each foreground run start as pending, started, or failed so a previous turn error cannot reject a newly accepted prompt. Includes a regression for resuming after a failed turn.
2026-08-31 12:10:34 +02:00
Mohamed Boudra 4a3e6dea63 Increase mobile content text to 16px (#4111)
Keep mobile interface chrome at 15px while defaulting readable content to 16px. Migrate existing mobile 15px content preferences once while preserving later explicit choices.
2026-08-31 11:58:42 +02:00
paseo-ai[bot] f35d860725 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-31 08:17:46 +00:00
Mohamed Boudra 95f4eb7577 chore(release): cut 0.7.0-beta.3 2026-08-31 09:52:59 +02:00
Mohamed Boudra fcd81f591e chore(release): carry forward 0.7.0-beta.2 version 2026-08-31 09:46:56 +02:00
Mohamed Boudra b0fe4efc8c docs: add v0.7.0-beta.3 changelog 2026-08-31 09:45:26 +02:00
Mohamed Boudra f2f93087ed docs(website): refresh product comparisons
Reframe the pages around verified product differences while emphasizing local ownership, privacy, extensibility, and deployment flexibility.
2026-08-30 23:48:54 +02:00
Mohamed Boudra 73a5e6c63e feat(website): expand homepage product story 2026-08-30 23:48:54 +02:00
Mohamed Boudra fa53ba8f93 feat(website): refine responsive product mockups
Keep desktop and mobile scenes visually consistent while improving phone depth, diff realism, alignment, and interaction polish.
2026-08-30 23:48:54 +02:00
Mohamed Boudra 375e9b3e53 feat(website): rebuild homepage product mockups
Replace static captures with live, responsive scenes that demonstrate building, reviewing, shipping, and extending work across desktop and mobile.
2026-08-30 23:48:54 +02:00
Mohamed Boudra adf80a9fe0 Keep Hub daemon access explicit (#4088)
* feat(hub): make daemon authority explicit

Connecting a daemon establishes presence without granting execution. Hub authority now uses semantic session permissions, with workflow execution granted explicitly and inspectable through the normal session protocol.

Attention frames remain part of the existing agent and terminal subscriptions, keeping session output solicited.

* test(server): match sentence-case tool labels
2026-08-30 22:46:24 +02:00
133 changed files with 6085 additions and 2717 deletions
+41 -1
View File
@@ -1,30 +1,70 @@
# Changelog
## 0.7.0-beta.1 - 2026-08-27
## 0.7.1 - 2026-09-01
### Added
- Added Claude Fable 5.1 with a 1M context window ([#4179](https://github.com/getpaseo/paseo/pull/4179))
## 0.7.0 - 2026-08-31
### Changed
- Changed the project license to Apache-2.0 ([#3944](https://github.com/getpaseo/paseo/pull/3944))
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view ([#3896](https://github.com/getpaseo/paseo/pull/3896))
- Changed the default mobile content text size from 15px to 16px ([#4111](https://github.com/getpaseo/paseo/pull/4111))
### Added
- Added plugin installation and updates directly from Git repositories ([#3920](https://github.com/getpaseo/paseo/pull/3920))
- Added plugin-defined timeline transformations and rendering ([#3940](https://github.com/getpaseo/paseo/pull/3940))
- Added SSH connections to existing remote daemons from Desktop and CLI ([#3989](https://github.com/getpaseo/paseo/pull/3989) by [@reidlevesque](https://github.com/reidlevesque))
- Added workspace management and layout actions to the Command Center ([#3013](https://github.com/getpaseo/paseo/pull/3013) by [@cleiter](https://github.com/cleiter))
- Added contextual composer-pill contributions for plugins ([#3956](https://github.com/getpaseo/paseo/pull/3956))
- Added host UI primitives for client plugins ([#3967](https://github.com/getpaseo/paseo/pull/3967))
- Added readable prompts, details, and results for Paseo tool calls ([#4066](https://github.com/getpaseo/paseo/pull/4066))
- Added zoom and pan to workspace images and assistant timeline previews ([#4032](https://github.com/getpaseo/paseo/pull/4032), [#4049](https://github.com/getpaseo/paseo/pull/4049), [#4064](https://github.com/getpaseo/paseo/pull/4064))
- Added opening child folders directly in the preferred desktop editor ([#3615](https://github.com/getpaseo/paseo/pull/3615) by [@caikovsky](https://github.com/caikovsky))
- Added host navigation from plugin surfaces to agents and workspaces ([#3901](https://github.com/getpaseo/paseo/pull/3901) by [@omercnet](https://github.com/omercnet))
- Added host-rendered icons to plugin client surfaces ([#3903](https://github.com/getpaseo/paseo/pull/3903) by [@omercnet](https://github.com/omercnet))
- Added semantic surface, border, success, and warning colors to plugin themes ([#3898](https://github.com/getpaseo/paseo/pull/3898) by [@omercnet](https://github.com/omercnet))
- Added registered project listing to the public plugin SDK ([#3899](https://github.com/getpaseo/paseo/pull/3899) by [@omercnet](https://github.com/omercnet))
- Added PR and MR number search to the Command Center ([#3008](https://github.com/getpaseo/paseo/pull/3008) by [@cleiter](https://github.com/cleiter))
- Added session commands to agent SDK handles ([#3719](https://github.com/getpaseo/paseo/pull/3719) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
- Added F-Droid release metadata and stable changelog generation ([#2501](https://github.com/getpaseo/paseo/pull/2501) by [@antonok-edm](https://github.com/antonok-edm))
- Added Astro syntax highlighting to files, the editor, and diffs ([#3997](https://github.com/getpaseo/paseo/pull/3997))
### Improved
- Streamed assistant text at a steady visual rate instead of in arrival lumps ([#3612](https://github.com/getpaseo/paseo/pull/3612) by [@Tommypop2](https://github.com/Tommypop2))
- Painted cached conversations before unrelated cache restoration and network loading ([#3907](https://github.com/getpaseo/paseo/pull/3907))
- Reused one OpenCode helper across ordinary agent sessions instead of spawning one per agent ([#4009](https://github.com/getpaseo/paseo/pull/4009))
- Batched GitHub pull request polling within the reserved API budget ([#3825](https://github.com/getpaseo/paseo/pull/3825) by [@dezchai](https://github.com/dezchai))
- Added manual, action-required, and warning states to GitLab and Gitea checks ([#2337](https://github.com/getpaseo/paseo/pull/2337) by [@nllptrx](https://github.com/nllptrx))
- Kept cold-start pull request status checks on the shared GitHub batch path ([#4025](https://github.com/getpaseo/paseo/pull/4025))
### Fixed
- Fixed the daemon crashing when a Claude or OMP JSONL process closes during a write ([#4048](https://github.com/getpaseo/paseo/pull/4048))
- Fixed Android first-message submissions crashing when the keyboard restarted during composer teardown ([#4044](https://github.com/getpaseo/paseo/pull/4044))
- Fixed long mobile drafts hiding composer controls behind the keyboard ([#4051](https://github.com/getpaseo/paseo/pull/4051))
- Fixed Android timelines shifting when tapping selectable text ([#4090](https://github.com/getpaseo/paseo/pull/4090))
- Fixed web and Electron composers losing focus after submission ([#4067](https://github.com/getpaseo/paseo/pull/4067))
- Fixed Android dictation leaving Bluetooth audio in call-quality routing after capture stopped ([#4069](https://github.com/getpaseo/paseo/pull/4069))
- Fixed dictation retries timing out after an abandoned partial transcript ([#4065](https://github.com/getpaseo/paseo/pull/4065))
- Fixed immediate dictation submissions omitting the newest speech segment ([#3968](https://github.com/getpaseo/paseo/pull/3968))
- Fixed the model selector crashing in iPad desktop layouts ([#3992](https://github.com/getpaseo/paseo/pull/3992) by [@yzim](https://github.com/yzim))
- Fixed OpenCode child-session prompts missing from provider subagent timelines ([#4055](https://github.com/getpaseo/paseo/pull/4055) by [@mcowger](https://github.com/mcowger))
- Fixed archived agents disappearing when opened from History ([#4033](https://github.com/getpaseo/paseo/pull/4033))
- Fixed restored workspace tabs entering reconciliation loops ([#3987](https://github.com/getpaseo/paseo/pull/3987))
- Fixed archived workspaces returning from durable cache ([#3975](https://github.com/getpaseo/paseo/pull/3975))
- Fixed Grok unified-billing accounts showing zero credits instead of weekly usage ([#4029](https://github.com/getpaseo/paseo/pull/4029) by [@Lite-G](https://github.com/Lite-G), [@claude](https://github.com/claude))
- Fixed daemon reconnects stalling while Git processes were queued ([#3945](https://github.com/getpaseo/paseo/pull/3945))
- Fixed slow Pi and OMP startup requests timing out after 30 seconds ([#4008](https://github.com/getpaseo/paseo/pull/4008))
- Fixed commits and squash merges bypassing configured Git signing ([#3976](https://github.com/getpaseo/paseo/pull/3976))
- Fixed Android system navigation covering the Commits controls ([#4005](https://github.com/getpaseo/paseo/pull/4005))
- Fixed new agent work appearing as reopened tasks ([#4068](https://github.com/getpaseo/paseo/pull/4068))
- Fixed Markdown literal characters being replaced by typographic symbols ([#3253](https://github.com/getpaseo/paseo/pull/3253) by [@cleiter](https://github.com/cleiter))
- Fixed rewinding long conversations replaying the complete rebuilt timeline ([#3642](https://github.com/getpaseo/paseo/pull/3642))
- Fixed rewound prompts remaining blank in the mounted iOS composer ([#3946](https://github.com/getpaseo/paseo/pull/3946))
- Fixed native plugin setup and async client callbacks failing on iOS ([#3942](https://github.com/getpaseo/paseo/pull/3942))
-1
View File
@@ -48,7 +48,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/source-editor.md](docs/source-editor.md) | Shared CodeMirror runtime, native bridge boundary, line-ending preservation, and embedded bundle workflow |
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
-1
View File
@@ -109,7 +109,6 @@ Cross-platform React Native app that connects to one or more daemons.
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
- `SessionContext` wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
- Source editing uses one CodeMirror runtime on web and in an offline native WebView. Its ownership and bridge constraints are documented in [docs/source-editor.md](source-editor.md).
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
- Voice features: dictation (STT) and voice agent (realtime)
+1 -1
View File
@@ -30,7 +30,7 @@ Before adding a new component, read `components/ui/`. The primitive usually exis
Hierarchy is conveyed through weight and color, not size. Most interface text is `fontSize.base`; compact metadata and hints use `fontSize.sm`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 15px on every platform. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 16px on native and 15px on web and desktop. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
Weight has three tiers, applied by role:
+10 -6
View File
@@ -17,17 +17,21 @@ The daemon persists a relationship ID and private connection credential before e
relationship is independent of its current transport, so a future transport can replace the direct
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
Normal authenticated daemon sessions may run the `hub.management.daemon.connect`,
`hub.management.daemon.get_status`, and `hub.management.daemon.disconnect` RPCs. Hub connections
receive only `hub.execution.*` authority, so execution credentials cannot manage the relationship.
Normal authenticated daemon sessions may manage the daemon's Hub relationship and permissions.
Hub connections have no daemon permissions by default. Connecting gives Hub machine identity and
presence but no execution authority. The `hub.execute` permission lets workflows triggered from
GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents. Grant it
during interactive login or later with `paseo hub permissions grant hub.execute`. Relationships
created before this split migrate their legacy execution scope to `hub.execute`. Hub sessions cannot
manage their own relationship or permissions.
## Session grants and execution ownership
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub
connection receives `hub.execute`. The current enrollment and persistence format still carries
`hub.execution.*`; one compatibility boundary translates it before the session is created. A denied
request returns the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
connection receives its persisted permissions. Legacy relationship files carrying
`hub.execution.*` migrate once to `hub.execute` at the persistence boundary. A denied request returns
the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
+1 -1
View File
@@ -56,4 +56,4 @@ The Hub authenticates as a service principal. Its locally selected grants decide
Hub user and role identifiers remain opaque external subjects. The Hub may create and revoke linked daemon principals when granted `access.manage`; the daemon does not interpret accounts, organizations, or roles.
The current Hub enrollment protocol still exchanges `hub.execution.*`. The daemon translates that legacy value to `hub.execute` once at the connection boundary. Remove the translation when Hub enrollment exchanges semantic permissions.
Hub enrollment and permission updates exchange these semantic permissions directly. Legacy persisted Hub relationships that contain `hub.execution.*` migrate once to `hub.execute` when the daemon loads them; new relationships never persist or emit transport scopes as authority.
-35
View File
@@ -1,35 +0,0 @@
# Source editor
The source editor has one CodeMirror runtime and two hosts:
- Web mounts the runtime directly into the page.
- iOS and Android mount the same runtime from a generated, offline HTML document in a WebView.
Keep editor mechanics in `packages/app/src/source-editor/`. File loading, conflict handling, and
persistence belong to the file pane's `FileEditorModel`. The source editor receives a document and
emits edits; it does not know about workspaces or daemon RPCs.
## Native bridge
The native bridge sends compact change ranges in normalized LF coordinates. The host-side document
mirror validates and applies them, then restores the file's original line separator before calling
the file model. Full document replacements cross the bridge only for mount and external changes.
Every message carries an editor key. Ignore messages from an old key after remounting or switching
files. Keep the bridge protocol private to this module; React components outside it use
`SourceEditorProps`.
Do not expose raw CodeMirror extensions as the cross-platform API. Add named capabilities to the
source editor contract, then implement them once in the shared runtime or explicitly in each host.
## Embedded bundle
After changing the CodeMirror runtime, bridge protocol, or WebView entry point, run:
```bash
npm --prefix packages/app run build:source-editor-webview
```
The generated `webview-html.ts` is committed so native builds do not need a runtime bundler or
network access. `build:source-editor-webview:check` fails when the committed artifact is stale, and
EAS post-install rebuilds all embedded WebViews.
@@ -0,0 +1,11 @@
Changed
- Changed the project license to Apache-2.0
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
- Changed the default mobile content text size from 15px to 16px
Added
- Added plugin installation and updates directly from Git repositories
- Added plugin-defined timeline transformations and rendering
- Added SSH connections to existing remote daemons from Desktop and CLI
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Changed
- Changed the project license to Apache-2.0
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
- Changed the default mobile content text size from 15px to 16px
Added
- Added plugin installation and updates directly from Git repositories
- Added plugin-defined timeline transformations and rendering
- Added SSH connections to existing remote daemons from Desktop and CLI
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Changed
- Changed the project license to Apache-2.0
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
- Changed the default mobile content text size from 15px to 16px
Added
- Added plugin installation and updates directly from Git repositories
- Added plugin-defined timeline transformations and rendering
- Added SSH connections to existing remote daemons from Desktop and CLI
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,11 @@
Changed
- Changed the project license to Apache-2.0
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
- Changed the default mobile content text size from 15px to 16px
Added
- Added plugin installation and updates directly from Git repositories
- Added plugin-defined timeline transformations and rendering
- Added SSH connections to existing remote daemons from Desktop and CLI
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Added
- Added Claude Fable 5.1 with a 1M context window
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Added
- Added Claude Fable 5.1 with a 1M context window
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Added
- Added Claude Fable 5.1 with a 1M context window
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Added
- Added Claude Fable 5.1 with a 1M context window
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-zn0UJm7RPR5mlCvKTddcq0bIugrp6VcRBPdwJX8kJIg=
sha256-HFYBrCP62r6ZofazBIGSOnMhk8IIEmv+TFJMpqTIJQ8=
+27 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36298,7 +36298,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37363,12 +37363,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/server": "0.7.0-beta.1",
"@getpaseo/client": "0.7.1",
"@getpaseo/protocol": "0.7.1",
"@getpaseo/server": "0.7.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37615,10 +37615,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"dependencies": {
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.1",
"@getpaseo/relay": "0.7.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37629,7 +37629,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37872,7 +37872,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38768,7 +38768,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38797,13 +38797,13 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/client": "0.7.1",
"@getpaseo/protocol": "0.7.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38812,15 +38812,15 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/client": "0.7.1",
"@getpaseo/protocol": "0.7.1",
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38833,7 +38833,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39051,17 +39051,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/highlight": "0.7.0-beta.1",
"@getpaseo/plugin": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"@getpaseo/client": "0.7.1",
"@getpaseo/highlight": "0.7.1",
"@getpaseo/plugin": "0.7.1",
"@getpaseo/protocol": "0.7.1",
"@getpaseo/relay": "0.7.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39985,7 +39985,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+10 -15
View File
@@ -3,8 +3,8 @@ import {
openPrPane,
expectPrPaneTitle,
expectPrPaneState,
expectPrPaneCheckSummary,
expectPrPaneActivityCount,
expectPrPaneChecks,
expectPrPaneActivity,
} from "../support/helpers/pr-pane";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
@@ -50,7 +50,6 @@ test.describe("PR pane", () => {
],
},
{ title: "PR with reviews", state: "open", commentCount: 3 },
{ title: "PR with no checks", state: "open" },
],
});
@@ -102,25 +101,21 @@ test.describe("PR pane", () => {
await expectPrPaneTitle(page, "Work in progress");
});
test("renders check pills with correct success/failure/pending counts", async ({ page }) => {
test("renders seeded checks in their success, failure, and pending groups", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
await openPrPane(page);
await expectPrPaneCheckSummary(page, { success: 2, failure: 1, pending: 1 });
await expectPrPaneChecks(page, {
success: ["build-1", "build-2"],
failure: ["deploy"],
pending: ["security"],
});
});
test("renders activity rows with correct count", async ({ page }) => {
test("renders every seeded activity comment", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
await openPrPane(page);
await expectPrPaneActivityCount(page, 3);
});
test("renders gracefully with zero checks", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
await openPrPane(page);
await expectPrPaneCheckSummary(page, { success: 0, failure: 0, pending: 0 });
await expectPrPaneTitle(page, "PR with no checks");
await expectPrPaneActivity(page, ["Test comment 1", "Test comment 2", "Test comment 3"]);
});
});
@@ -43,7 +43,6 @@ const cases: ProviderSubagentCase[] = [
sentinel: "OPENCODE_CHILD_SENTINEL",
expectedName: "Verify OpenCode descriptor",
expectedSubtitle: /explore · gpt-5\.4(?: · [^\n·]+)? · \d+(?:\.\d+)?k? tokens/i,
expectsUserMessage: false,
providerConfig: { model: "openai/gpt-5.4" },
prompt:
'Use the task tool exactly once with description "Verify OpenCode descriptor" and the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.',
+24 -17
View File
@@ -1,5 +1,4 @@
import { expect, type Page } from "@playwright/test";
import type { CheckPresentationCounts } from "@/git/check-presentation";
import { getStateLabel } from "@/git/pull-request-panel/data";
import { openPullRequestPanel } from "./workspace-tabs";
@@ -20,23 +19,31 @@ export async function expectPrPaneState(
});
}
async function assertCheckPill(page: Page, testId: string, count: number): Promise<void> {
const locator = page.getByTestId(testId);
await expect(locator).toHaveCount(count > 0 ? 1 : 0, { timeout: 15_000 });
if (count > 0) {
await expect(locator).toContainText(String(count));
export async function expectPrPaneChecks(
page: Page,
checks: { success: string[]; failure: string[]; pending: string[] },
): Promise<void> {
const section = page.getByTestId("pr-pane-checks");
const groups = [
{ status: "failure", label: /failing checks?$/, names: checks.failure },
{ status: "pending", label: /in progress checks?$/, names: checks.pending },
{ status: "success", label: /successful checks?$/, names: checks.success },
];
for (const group of groups) {
const groupSection = section.getByTestId(`pr-pane-check-group-${group.status}`);
await expect(groupSection.getByRole("button", { name: group.label })).toBeVisible({
timeout: 15_000,
});
for (const name of group.names) {
await expect(groupSection.getByText(name, { exact: true })).toHaveCount(1);
}
}
}
export async function expectPrPaneCheckSummary(
page: Page,
counts: Pick<CheckPresentationCounts, "success" | "failure" | "pending">,
): Promise<void> {
await assertCheckPill(page, "pr-pane-check-success", counts.success);
await assertCheckPill(page, "pr-pane-check-failure", counts.failure);
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
}
export async function expectPrPaneActivityCount(page: Page, count: number): Promise<void> {
await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 });
export async function expectPrPaneActivity(page: Page, bodies: string[]): Promise<void> {
const activity = page.getByTestId("pr-pane-activity-row");
for (const body of bodies) {
await expect(activity.getByText(body, { exact: true })).toBeVisible({ timeout: 15_000 });
}
}
+2 -5
View File
@@ -1,13 +1,13 @@
{
"name": "@getpaseo/app",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"private": true,
"main": "index.ts",
"scripts": {
"start": "npm run start:expo",
"start:expo": "cross-env APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js",
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:embedded-webviews",
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
"android": "npm run android:development",
"android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
@@ -35,10 +35,7 @@
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
"build:embedded-webviews": "npm run build:terminal-webview && npm run build:source-editor-webview",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
"build:source-editor-webview": "node ./scripts/build-source-editor-webview-html.mjs",
"build:source-editor-webview:check": "node ./scripts/build-source-editor-webview-html.mjs --check",
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
},
"dependencies": {
@@ -1,95 +0,0 @@
import esbuild from "esbuild";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const appRoot = path.resolve(scriptDirectory, "..");
const repoRoot = path.resolve(appRoot, "../..");
const entry = path.join(appRoot, "src/source-editor/codemirror/webview-entry.ts");
const output = path.join(appRoot, "src/source-editor/codemirror/webview-html.ts");
const checkOnly = process.argv.includes("--check");
async function resolveTsPath(basePath) {
const candidates = [
basePath,
`${basePath}.ts`,
`${basePath}.tsx`,
`${basePath}.js`,
`${basePath}.jsx`,
path.join(basePath, "index.ts"),
path.join(basePath, "index.tsx"),
];
for (const candidate of candidates) {
try {
if ((await fs.stat(candidate)).isFile()) return candidate;
} catch {
// Try the next TypeScript resolution candidate.
}
}
return basePath;
}
const aliasPlugin = {
name: "paseo-alias",
setup(build) {
build.onResolve({ filter: /^@\// }, async (args) => ({
path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
}));
},
};
const result = await esbuild.build({
entryPoints: [entry],
bundle: true,
write: false,
format: "iife",
platform: "browser",
target: ["ios15", "chrome100"],
plugins: [aliasPlugin],
logLevel: "info",
});
const javascript = result.outputFiles[0]?.text;
if (!javascript) throw new Error("source editor WebView bundle produced no JavaScript");
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<style>
html, body, #source-editor-root, #source-editor-host {
width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden;
}
html, body { overscroll-behavior: none; }
#source-editor-root, #source-editor-host { display: flex; min-width: 0; min-height: 0; }
#source-editor-host { flex: 1; }
* { -webkit-tap-highlight-color: transparent; }
</style>
</head>
<body>
<script>${javascript}</script>
</body>
</html>`;
const contents = `// Generated by packages/app/scripts/build-source-editor-webview-html.mjs.
// Do not edit by hand.
export const sourceEditorWebViewHtml =
${JSON.stringify(html)};
`;
if (checkOnly) {
const existing = await fs.readFile(output, "utf8").catch(() => null);
if (existing !== contents) {
throw new Error(
`${path.relative(repoRoot, output)} is stale. Run npm --prefix packages/app run build:source-editor-webview.`,
);
}
console.log(`${path.relative(repoRoot, output)} is current (${html.length} bytes)`);
} else {
await fs.writeFile(output, contents);
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
}
@@ -13,10 +13,22 @@ import {
keymap,
lineNumbers,
} from "@codemirror/view";
import { createCodeMirrorHighlightStyle } from "@getpaseo/highlight";
import type { SourceEditorTheme } from "../contract";
import { createCodeMirrorHighlightStyle, type HighlightStyle } from "@getpaseo/highlight";
export function sourceEditorBaseExtensions(onSave: () => void) {
export interface EditorVisualTheme {
colorScheme: "light" | "dark";
background: string;
foreground: string;
cursor: string;
foregroundMuted: string;
border: string;
selection: string;
monoFont: string;
codeFontSize: number;
syntax: Record<HighlightStyle, string>;
}
export function editorBaseExtensions(onSave: () => void) {
return [
lineNumbers(),
history(),
@@ -35,7 +47,7 @@ export function sourceEditorBaseExtensions(onSave: () => void) {
];
}
export function sourceEditorTheme(theme: SourceEditorTheme) {
export function editorTheme(theme: EditorVisualTheme) {
return [
EditorView.theme(
{
@@ -50,13 +62,8 @@ export function sourceEditorTheme(theme: SourceEditorTheme) {
overflow: "auto",
fontFamily: theme.monoFont,
lineHeight: "1.45",
WebkitOverflowScrolling: "touch",
},
".cm-content": {
caretColor: theme.foreground,
padding: "16px 0",
minHeight: "100%",
},
".cm-content": { caretColor: theme.foreground, padding: "16px 0" },
".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor },
".cm-gutters": {
backgroundColor: theme.background,
@@ -68,7 +75,9 @@ export function sourceEditorTheme(theme: SourceEditorTheme) {
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
backgroundColor: theme.selection,
},
".cm-selectionBackground, ::selection": { backgroundColor: theme.selection },
".cm-selectionBackground, ::selection": {
backgroundColor: theme.selection,
},
"&.cm-focused": { outline: "none" },
},
{ dark: theme.colorScheme === "dark" },
+22 -35
View File
@@ -1,49 +1,36 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { SourceEditor } from "@/source-editor/source-editor";
import type { SourceEditorTheme } from "@/source-editor/contract";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { HighlightStyle } from "@getpaseo/highlight";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FileEditorModel } from "./model";
export function FileEditorView(props: {
export function FileEditorView(_props: {
model: FileEditorModel;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
vimEnabled: boolean;
theme: SourceEditorTheme;
theme: {
background: string;
foreground: string;
foregroundMuted: string;
border: string;
selection: string;
monoFont: string;
codeFontSize: number;
syntax: Record<HighlightStyle, string>;
};
onCursorChange(position: { line: number; column: number }): void;
onVimModeChange(mode: string | null): void;
}) {
const snapshot = useSyncExternalStore(
props.model.subscribe,
props.model.getSnapshot,
props.model.getSnapshot,
);
const selectionTarget = useMemo(
() =>
props.location.lineStart
? {
lineStart: props.location.lineStart,
lineEnd: props.location.lineEnd,
revision: props.navigationRevision,
}
: undefined,
[props.location.lineEnd, props.location.lineStart, props.navigationRevision],
);
const handleChange = useCallback((document: string) => props.model.edit(document), [props.model]);
const handleSave = useCallback(() => void props.model.save(), [props.model]);
return (
<SourceEditor
document={snapshot.content}
filename={props.filename}
selectionTarget={selectionTarget}
vimEnabled={props.vimEnabled}
theme={props.theme}
onChange={handleChange}
onSave={handleSave}
onCursorChange={props.onCursorChange}
onVimModeChange={props.onVimModeChange}
/>
<View style={styles.container}>
<Text style={styles.text}>Source editing is available on web and desktop.</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: { flex: 1, alignItems: "center", justifyContent: "center" },
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base },
}));
@@ -0,0 +1,155 @@
import { useEffect, useRef, useSyncExternalStore } from "react";
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { getCM, vim } from "@replit/codemirror-vim";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FileEditorModel } from "./model";
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
interface FileEditorViewProps {
model: FileEditorModel;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
vimEnabled: boolean;
theme: EditorVisualTheme;
onCursorChange(position: { line: number; column: number }): void;
onVimModeChange(mode: string | null): void;
}
const languageCompartment = new Compartment();
const wrappingCompartment = new Compartment();
const themeCompartment = new Compartment();
const vimCompartment = new Compartment();
function wrappingForFile(filename: string) {
return isRenderedMarkdownFile(filename) ? EditorView.lineWrapping : [];
}
export function FileEditorView({
model,
filename,
location,
navigationRevision,
vimEnabled,
theme,
onCursorChange,
onVimModeChange,
}: FileEditorViewProps) {
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const initial = useRef({ filename, model, theme, vimEnabled, content: snapshot.content });
const onCursorChangeRef = useRef(onCursorChange);
onCursorChangeRef.current = onCursorChange;
useEffect(() => {
if (!hostRef.current) return;
const values = initial.current;
const view = new EditorView({
parent: hostRef.current,
state: EditorState.create({
doc: values.content,
extensions: [
vimCompartment.of(values.vimEnabled ? vim() : []),
...editorBaseExtensions(() => void values.model.save()),
languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []),
wrappingCompartment.of(wrappingForFile(values.filename)),
themeCompartment.of(editorTheme(values.theme)),
EditorView.updateListener.of((update) => {
if (
update.docChanged &&
!update.transactions.some((tr) => tr.annotation(remoteUpdate))
) {
const { lineSeparator } = values.model.getSnapshot();
values.model.edit(update.state.doc.sliceString(0, undefined, lineSeparator));
}
if (update.selectionSet || update.docChanged) {
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
onCursorChangeRef.current({ line: line.number, column: head - line.from + 1 });
}
}),
],
}),
});
viewRef.current = view;
onCursorChangeRef.current({ line: 1, column: 1 });
return () => {
view.destroy();
viewRef.current = null;
};
}, []);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const document = view.state.toText(snapshot.content);
if (view.state.doc.eq(document)) return;
const head = Math.min(view.state.selection.main.head, document.length);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: document },
selection: { anchor: head },
annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)],
});
}, [snapshot.content]);
useEffect(() => {
const view = viewRef.current;
if (!view || !location.lineStart) return;
const lineStart = Math.min(location.lineStart, view.state.doc.lines);
const lineEnd = Math.min(location.lineEnd ?? lineStart, view.state.doc.lines);
const from = view.state.doc.line(lineStart).from;
const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to;
view.dispatch({
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
effects: EditorView.scrollIntoView(from, { y: "center" }),
});
}, [location.lineEnd, location.lineStart, navigationRevision]);
useEffect(() => {
viewRef.current?.dispatch({
effects: [
languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []),
wrappingCompartment.reconfigure(wrappingForFile(filename)),
],
});
}, [filename]);
useEffect(() => {
viewRef.current?.dispatch({ effects: themeCompartment.reconfigure(editorTheme(theme)) });
}, [theme]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({ effects: vimCompartment.reconfigure(vimEnabled ? vim() : []) });
if (!vimEnabled) {
onVimModeChange(null);
return;
}
const cm = getCM(view);
if (!cm) return;
function handleModeChange(event: { mode?: string }) {
onVimModeChange((event.mode ?? "normal").toUpperCase());
}
cm.on("vim-mode-change", handleModeChange);
onVimModeChange("NORMAL");
return () => cm.off("vim-mode-change", handleModeChange);
}, [onVimModeChange, vimEnabled]);
return (
<div
ref={hostRef}
data-pmono=""
data-testid="file-source-editor"
aria-label={`Source editor for ${filename}`}
style={HOST_STYLE}
/>
);
}
const remoteUpdate = Annotation.define<boolean>();
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
+5 -1
View File
@@ -22,6 +22,7 @@ import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
import { isWeb } from "@/constants/platform";
import { useAppSettings } from "@/hooks/use-settings";
import { useLiveFile } from "./live-file/hook";
import { useFilePreview } from "./preview-lifecycle/hook";
@@ -328,7 +329,10 @@ function isEditableTextFile(input: {
supportsEditing: boolean;
}): boolean {
return Boolean(
input.supportsEditing && input.preview?.kind === "text" && input.preview.size <= 1024 * 1024,
isWeb &&
input.supportsEditing &&
input.preview?.kind === "text" &&
input.preview.size <= 1024 * 1024,
);
}
@@ -2,9 +2,9 @@ import { useEffect, useMemo, useRef } from "react";
import { FlatList, Text, View, type ListRenderItem } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import type { SourceEditorTheme } from "@/source-editor/contract";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { selectSourcePresentation } from "./presentation";
interface FileSourceViewProps {
@@ -13,7 +13,7 @@ interface FileSourceViewProps {
location: WorkspaceFileLocation;
navigationRevision: number;
size: number;
theme: SourceEditorTheme;
theme: EditorVisualTheme;
tooLargeMessage: string;
}
@@ -2,9 +2,9 @@ import { useEffect, useRef } from "react";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import type { SourceEditorTheme } from "@/source-editor/contract";
import { sourceEditorTheme } from "@/source-editor/codemirror/configuration";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { editorTheme } from "../editor/extensions.web";
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
interface FileSourceViewProps {
@@ -13,7 +13,7 @@ interface FileSourceViewProps {
location: WorkspaceFileLocation;
navigationRevision: number;
size: number;
theme: SourceEditorTheme;
theme: EditorVisualTheme;
tooLargeMessage: string;
}
@@ -76,7 +76,7 @@ function ReadonlyCodeMirror({
languageCompartment.of(
languageFor({ filename: values.filename, presentation: values.presentation }),
),
themeCompartment.of(sourceEditorTheme(values.theme)),
themeCompartment.of(editorTheme(values.theme)),
],
}),
});
@@ -97,7 +97,7 @@ function ReadonlyCodeMirror({
viewRef.current?.dispatch({
effects: [
languageCompartment.reconfigure(languageFor({ filename, presentation })),
themeCompartment.reconfigure(sourceEditorTheme(theme)),
themeCompartment.reconfigure(editorTheme(theme)),
],
});
}, [filename, presentation, theme]);
@@ -148,7 +148,7 @@ function CheckGroup({
}) {
const handlePress = useCallback(() => onToggle(group.status), [group.status, onToggle]);
return (
<View>
<View testID={`pr-pane-check-group-${group.status}`}>
<Pressable onPress={handlePress} style={styles.groupHeader} accessibilityRole="button">
<Text style={styles.groupLabel}>{group.label}</Text>
{collapsed ? (
@@ -20,6 +20,11 @@ function storedSendBehavior(storage: Storage): SendBehavior | undefined {
return raw === undefined ? undefined : JSON.parse(raw).sendBehavior;
}
function storedContentFontSize(storage: Storage): number | undefined {
const raw = storage.entries.get(APP_SETTINGS_KEY);
return raw === undefined ? undefined : JSON.parse(raw).contentFontSize;
}
/** An in-memory storage whose write to `failingKey` always throws, as a full disk would. */
function createFailingWriteStorage(failingKey: string): Storage {
const storage = createInMemoryKeyValueStorage();
@@ -81,6 +86,47 @@ describe("migrateAppSettings", () => {
expect(appliedIds(storage)).toEqual(["some-later-migration", "steer-default"]);
});
it("migrates every mobile 15px content preference to 16px", async () => {
const storage = createInMemoryKeyValueStorage();
const settings = { ...settingsWith("steer"), contentFontSize: 15 };
const result = await migrateAppSettings(settings, storage, undefined, { native: true });
expect(result.contentFontSize).toBe(16);
expect(storedContentFontSize(storage)).toBe(16);
expect(appliedIds(storage)).toEqual(["steer-default", "mobile-content-16"]);
});
it("leaves a 15px web content preference unchanged", async () => {
const storage = createInMemoryKeyValueStorage();
const settings = { ...settingsWith("steer"), contentFontSize: 15 };
const result = await migrateAppSettings(settings, storage, undefined, { native: false });
expect(result.contentFontSize).toBe(15);
expect(storedContentFontSize(storage)).toBeUndefined();
expect(appliedIds(storage)).toEqual(["steer-default"]);
});
it("lets a mobile user choose 15px after the default migration ran", async () => {
const storage = createInMemoryKeyValueStorage();
await migrateAppSettings(
{ ...settingsWith("steer"), contentFontSize: 15 },
storage,
undefined,
{ native: true },
);
const result = await migrateAppSettings(
{ ...settingsWith("steer"), contentFontSize: 15 },
storage,
undefined,
{ native: true },
);
expect(result.contentFontSize).toBe(15);
});
it("stays unmarked when the settings write fails, so a later launch retries", async () => {
const storage = createFailingWriteStorage(APP_SETTINGS_KEY);
@@ -13,6 +13,9 @@ const AppliedMigrationsSchema = z.strictObject({ applied: z.array(z.string()) })
*/
const STEER_DEFAULT_MIGRATION = "steer-default";
/** Existing mobile installs materialized the old 15px content default in storage. */
const MOBILE_CONTENT_16_MIGRATION = "mobile-content-16";
/**
* Brings stored settings up to date, returning what the caller should use. Owns both writes so
* the marker can only ever be written after the settings it describes: a failed marker write
@@ -23,6 +26,7 @@ export async function migrateAppSettings(
settings: AppSettings,
storage: KeyValueStorage,
stored?: PersistedAppSettings,
options: { native?: boolean } = {},
): Promise<AppSettings> {
const migrationMarker = await readValidatedJson(
storage,
@@ -30,12 +34,26 @@ export async function migrateAppSettings(
AppliedMigrationsSchema,
);
const applied = new Set(migrationMarker?.applied ?? []);
if (applied.has(STEER_DEFAULT_MIGRATION)) {
let addedMigration = false;
let migrated = settings;
if (!applied.has(STEER_DEFAULT_MIGRATION)) {
migrated =
migrated.sendBehavior === "interrupt" ? { ...migrated, sendBehavior: "steer" } : migrated;
applied.add(STEER_DEFAULT_MIGRATION);
addedMigration = true;
}
if (options.native && !applied.has(MOBILE_CONTENT_16_MIGRATION)) {
migrated = migrated.contentFontSize === 15 ? { ...migrated, contentFontSize: 16 } : migrated;
applied.add(MOBILE_CONTENT_16_MIGRATION);
addedMigration = true;
}
if (!addedMigration) {
return settings;
}
const migrated: AppSettings =
settings.sendBehavior === "interrupt" ? { ...settings, sendBehavior: "steer" } : settings;
if (migrated !== settings) {
const storedSidebarRowItems = stored?.sidebarRowItems ?? {};
await storage.setItem(
@@ -48,7 +66,6 @@ export async function migrateAppSettings(
);
}
applied.add(STEER_DEFAULT_MIGRATION);
await storage.setItem(SETTINGS_MIGRATIONS_KEY, JSON.stringify({ applied: [...applied] }));
return migrated;
}
@@ -659,8 +659,8 @@ describe("appearance settings", () => {
expect(defaultUiBaseFontSize(false)).toBe(14);
});
it("uses a 15px content default on mobile and web", () => {
expect(defaultContentFontSize(true)).toBe(15);
it("uses a 16px content default on mobile and a 15px default on web", () => {
expect(defaultContentFontSize(true)).toBe(16);
expect(defaultContentFontSize(false)).toBe(15);
expect(DEFAULT_CONTENT_FONT_SIZE).toBe(defaultContentFontSize(false));
});
@@ -52,7 +52,7 @@ export const DEFAULT_UI_BASE_FONT_SIZE = defaultUiBaseFontSize(isNative);
export const MIN_UI_BASE_FONT_SIZE = 10;
export const MAX_UI_BASE_FONT_SIZE = 21;
export function defaultContentFontSize(native: boolean): number {
return native ? 15 : FONT_SIZE.content;
return native ? 16 : FONT_SIZE.content;
}
export const DEFAULT_CONTENT_FONT_SIZE = defaultContentFontSize(isNative);
@@ -75,7 +75,7 @@ export interface AppSettings {
uiFontFamily: string; // "" = platform default UI stack
monoFontFamily: string; // "" = platform default mono stack
uiBaseFontSize: number; // clamped px, platform default 14 or 15
contentFontSize: number; // clamped px, default 15
contentFontSize: number; // clamped px, platform default 15 or 16
codeFontSize: number; // clamped px, default 12
syntaxTheme: SyntaxThemeId; // default "one"
workspaceTitleSource: WorkspaceTitleSource;
@@ -343,7 +343,7 @@ export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<Ap
await writeAppSettings(deps.storage, read.stored, read.settings);
}
const { needsWrite: _needsWrite, ...stored } = read.stored;
return await migrateAppSettings(read.settings, deps.storage, stored);
return await migrateAppSettings(read.settings, deps.storage, stored, { native: isNative });
} catch (error) {
console.error("[AppSettings] Failed to load settings:", error);
throw error;
@@ -1,29 +0,0 @@
import { describe, expect, test } from "vitest";
import { parseSourceEditorBridgeMessage } from "./bridge-protocol";
describe("parseSourceEditorBridgeMessage", () => {
test("accepts compact document changes", () => {
expect(
parseSourceEditorBridgeMessage(
JSON.stringify({
type: "change",
editorKey: "editor-1",
changes: [{ from: 2, to: 4, insert: "replacement" }],
}),
),
).toEqual({
type: "change",
editorKey: "editor-1",
changes: [{ from: 2, to: 4, insert: "replacement" }],
});
});
test("rejects malformed bridge input", () => {
expect(parseSourceEditorBridgeMessage("not json")).toBeNull();
expect(
parseSourceEditorBridgeMessage(
JSON.stringify({ type: "change", editorKey: "editor-1", changes: [{ from: "2" }] }),
),
).toBeNull();
});
});
@@ -1,162 +0,0 @@
import type {
SourceChange,
SourceEditorConfiguration,
SourceEditorPosition,
SourceEditorSelectionTarget,
} from "../contract";
export type SourceEditorHostMessage =
| {
type: "mount";
editorKey: string;
document: string;
configuration: SourceEditorConfiguration;
}
| { type: "replaceDocument"; editorKey: string; document: string }
| { type: "configure"; editorKey: string; configuration: SourceEditorConfiguration }
| ({ type: "reveal"; editorKey: string } & SourceEditorSelectionTarget)
| { type: "destroy"; editorKey: string };
export type SourceEditorBridgeMessage =
| { type: "bridgeReady" }
| { type: "ready"; editorKey: string }
| { type: "change"; editorKey: string; changes: readonly SourceChange[] }
| ({ type: "cursor"; editorKey: string } & SourceEditorPosition)
| { type: "save"; editorKey: string }
| { type: "vimMode"; editorKey: string; mode: string | null };
export function parseSourceEditorBridgeMessage(value: string): SourceEditorBridgeMessage | null {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return null;
}
if (!isRecord(parsed) || typeof parsed.type !== "string") return null;
if (parsed.type === "bridgeReady") return { type: "bridgeReady" };
if (typeof parsed.editorKey !== "string") return null;
switch (parsed.type) {
case "ready":
case "save":
return { type: parsed.type, editorKey: parsed.editorKey };
case "vimMode":
return parsed.mode === null || typeof parsed.mode === "string"
? { type: "vimMode", editorKey: parsed.editorKey, mode: parsed.mode }
: null;
case "cursor":
return isPositiveInteger(parsed.line) && isPositiveInteger(parsed.column)
? {
type: "cursor",
editorKey: parsed.editorKey,
line: parsed.line,
column: parsed.column,
}
: null;
case "change": {
const changes = parseChanges(parsed.changes);
return changes ? { type: "change", editorKey: parsed.editorKey, changes } : null;
}
default:
return null;
}
}
export function parseSourceEditorHostMessage(value: string): SourceEditorHostMessage | null {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return null;
}
if (
!isRecord(parsed) ||
typeof parsed.type !== "string" ||
typeof parsed.editorKey !== "string"
) {
return null;
}
switch (parsed.type) {
case "mount":
return typeof parsed.document === "string" && isConfiguration(parsed.configuration)
? {
type: "mount",
editorKey: parsed.editorKey,
document: parsed.document,
configuration: parsed.configuration,
}
: null;
case "replaceDocument":
return typeof parsed.document === "string"
? { type: "replaceDocument", editorKey: parsed.editorKey, document: parsed.document }
: null;
case "configure":
return isConfiguration(parsed.configuration)
? { type: "configure", editorKey: parsed.editorKey, configuration: parsed.configuration }
: null;
case "reveal":
return isPositiveInteger(parsed.lineStart) &&
(parsed.lineEnd === undefined || isPositiveInteger(parsed.lineEnd)) &&
Number.isInteger(parsed.revision)
? {
type: "reveal",
editorKey: parsed.editorKey,
lineStart: parsed.lineStart,
lineEnd: parsed.lineEnd,
revision: parsed.revision as number,
}
: null;
case "destroy":
return { type: "destroy", editorKey: parsed.editorKey };
default:
return null;
}
}
function parseChanges(value: unknown): SourceChange[] | null {
if (!Array.isArray(value)) return null;
const changes: SourceChange[] = [];
for (const change of value) {
if (
!isRecord(change) ||
!Number.isInteger(change.from) ||
!Number.isInteger(change.to) ||
typeof change.insert !== "string"
) {
return null;
}
changes.push({ from: change.from as number, to: change.to as number, insert: change.insert });
}
return changes;
}
function isConfiguration(value: unknown): value is SourceEditorConfiguration {
if (
!isRecord(value) ||
typeof value.filename !== "string" ||
typeof value.vimEnabled !== "boolean"
) {
return false;
}
const theme = value.theme;
return (
isRecord(theme) &&
(theme.colorScheme === "light" || theme.colorScheme === "dark") &&
typeof theme.background === "string" &&
typeof theme.foreground === "string" &&
typeof theme.cursor === "string" &&
typeof theme.foregroundMuted === "string" &&
typeof theme.border === "string" &&
typeof theme.selection === "string" &&
typeof theme.monoFont === "string" &&
typeof theme.codeFontSize === "number" &&
isRecord(theme.syntax)
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isPositiveInteger(value: unknown): value is number {
return Number.isInteger(value) && (value as number) > 0;
}
@@ -1,152 +0,0 @@
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
import { EditorView, type ViewUpdate } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { getCM, vim } from "@replit/codemirror-vim";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import type {
SourceChange,
SourceEditorConfiguration,
SourceEditorPosition,
SourceEditorSelectionTarget,
} from "../contract";
import { sourceEditorBaseExtensions, sourceEditorTheme } from "./configuration";
interface CodeMirrorRuntimeCallbacks {
onChange(changes: readonly SourceChange[]): void;
onSave(): void;
onCursorChange(position: SourceEditorPosition): void;
onVimModeChange(mode: string | null): void;
}
const remoteUpdate = Annotation.define<boolean>();
export class CodeMirrorRuntime {
private readonly languageCompartment = new Compartment();
private readonly wrappingCompartment = new Compartment();
private readonly themeCompartment = new Compartment();
private readonly vimCompartment = new Compartment();
private view: EditorView | null = null;
private callbacks: CodeMirrorRuntimeCallbacks | null = null;
private vimModeListener: ((event: { mode?: string }) => void) | null = null;
mount(input: {
host: HTMLElement;
document: string;
configuration: SourceEditorConfiguration;
callbacks: CodeMirrorRuntimeCallbacks;
}): void {
this.destroy();
this.callbacks = input.callbacks;
this.view = new EditorView({
parent: input.host,
state: EditorState.create({
doc: input.document,
extensions: [
this.vimCompartment.of(input.configuration.vimEnabled ? vim() : []),
...sourceEditorBaseExtensions(() => this.callbacks?.onSave()),
this.languageCompartment.of(languageForFile(input.configuration.filename)),
this.wrappingCompartment.of(wrappingForFile(input.configuration.filename)),
this.themeCompartment.of(sourceEditorTheme(input.configuration.theme)),
EditorView.updateListener.of((update) => this.receiveUpdate(update)),
],
}),
});
this.configureVim(input.configuration.vimEnabled);
this.reportCursor();
}
replaceDocument(document: string): void {
const view = this.view;
if (!view || view.state.doc.toString() === document) return;
const head = Math.min(view.state.selection.main.head, document.length);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: document },
selection: { anchor: head },
annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)],
});
}
configure(configuration: SourceEditorConfiguration): void {
const view = this.view;
if (!view) return;
this.removeVimModeListener();
view.dispatch({
effects: [
this.languageCompartment.reconfigure(languageForFile(configuration.filename)),
this.wrappingCompartment.reconfigure(wrappingForFile(configuration.filename)),
this.themeCompartment.reconfigure(sourceEditorTheme(configuration.theme)),
this.vimCompartment.reconfigure(configuration.vimEnabled ? vim() : []),
],
});
this.configureVim(configuration.vimEnabled);
}
reveal(target: SourceEditorSelectionTarget): void {
const view = this.view;
if (!view) return;
const lineStart = Math.min(target.lineStart, view.state.doc.lines);
const lineEnd = Math.min(target.lineEnd ?? lineStart, view.state.doc.lines);
const from = view.state.doc.line(lineStart).from;
const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to;
view.dispatch({
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
effects: EditorView.scrollIntoView(from, { y: "center" }),
});
}
destroy(): void {
this.removeVimModeListener();
this.view?.destroy();
this.view = null;
this.callbacks = null;
}
private receiveUpdate(update: ViewUpdate): void {
if (
update.docChanged &&
!update.transactions.some((transaction) => transaction.annotation(remoteUpdate))
) {
const changes: SourceChange[] = [];
update.changes.iterChanges((from, to, _fromNew, _toNew, inserted) => {
changes.push({ from, to, insert: inserted.toString() });
});
this.callbacks?.onChange(changes);
}
if (update.selectionSet || update.docChanged) this.reportCursor();
}
private reportCursor(): void {
const view = this.view;
if (!view) return;
const head = view.state.selection.main.head;
const line = view.state.doc.lineAt(head);
this.callbacks?.onCursorChange({ line: line.number, column: head - line.from + 1 });
}
private configureVim(enabled: boolean): void {
if (!enabled || !this.view) {
this.callbacks?.onVimModeChange(null);
return;
}
const cm = getCM(this.view);
if (!cm) return;
this.vimModeListener = (event) =>
this.callbacks?.onVimModeChange((event.mode ?? "normal").toUpperCase());
cm.on("vim-mode-change", this.vimModeListener);
this.callbacks?.onVimModeChange("NORMAL");
}
private removeVimModeListener(): void {
if (!this.view || !this.vimModeListener) return;
getCM(this.view)?.off("vim-mode-change", this.vimModeListener);
this.vimModeListener = null;
}
}
function languageForFile(filename: string) {
return getLanguageForFile(filename)?.extension ?? [];
}
function wrappingForFile(filename: string) {
return isRenderedMarkdownFile(filename) ? EditorView.lineWrapping : [];
}
@@ -1,83 +0,0 @@
import { CodeMirrorRuntime } from "./runtime";
import {
parseSourceEditorHostMessage,
type SourceEditorBridgeMessage,
type SourceEditorHostMessage,
} from "./bridge-protocol";
declare global {
interface Window {
ReactNativeWebView?: { postMessage?: (data: string) => void };
__PASEO_SOURCE_EDITOR_RECEIVE__?: (message: string) => void;
}
}
function send(message: SourceEditorBridgeMessage): void {
window.ReactNativeWebView?.postMessage?.(JSON.stringify(message));
}
class SourceEditorWebViewBridge {
private runtime: CodeMirrorRuntime | null = null;
private editorKey: string | null = null;
receive = (rawMessage: string): void => {
const message = parseSourceEditorHostMessage(rawMessage);
if (!message) return;
if (message.type === "mount") {
this.mount(message);
return;
}
if (message.editorKey !== this.editorKey) return;
switch (message.type) {
case "replaceDocument":
this.runtime?.replaceDocument(message.document);
break;
case "configure":
this.runtime?.configure(message.configuration);
break;
case "reveal":
this.runtime?.reveal(message);
break;
case "destroy":
this.destroy();
break;
}
};
private mount(message: Extract<SourceEditorHostMessage, { type: "mount" }>): void {
this.destroy();
this.editorKey = message.editorKey;
const runtime = new CodeMirrorRuntime();
this.runtime = runtime;
runtime.mount({
host,
document: message.document,
configuration: message.configuration,
callbacks: {
onChange: (changes) => send({ type: "change", editorKey: message.editorKey, changes }),
onSave: () => send({ type: "save", editorKey: message.editorKey }),
onCursorChange: (position) =>
send({ type: "cursor", editorKey: message.editorKey, ...position }),
onVimModeChange: (mode) => send({ type: "vimMode", editorKey: message.editorKey, mode }),
},
});
send({ type: "ready", editorKey: message.editorKey });
}
private destroy(): void {
this.runtime?.destroy();
this.runtime = null;
this.editorKey = null;
}
}
const root = document.createElement("div");
root.id = "source-editor-root";
const host = document.createElement("div");
host.id = "source-editor-host";
root.appendChild(host);
document.body.appendChild(root);
const bridge = new SourceEditorWebViewBridge();
window.__PASEO_SOURCE_EDITOR_RECEIVE__ = bridge.receive;
send({ type: "bridgeReady" });
File diff suppressed because one or more lines are too long
@@ -1,46 +0,0 @@
import type { HighlightStyle } from "@getpaseo/highlight";
export interface SourceChange {
from: number;
to: number;
insert: string;
}
export interface SourceEditorPosition {
line: number;
column: number;
}
export interface SourceEditorSelectionTarget {
lineStart: number;
lineEnd?: number;
revision: number;
}
export interface SourceEditorTheme {
colorScheme: "light" | "dark";
background: string;
foreground: string;
cursor: string;
foregroundMuted: string;
border: string;
selection: string;
monoFont: string;
codeFontSize: number;
syntax: Record<HighlightStyle, string>;
}
export interface SourceEditorConfiguration {
filename: string;
vimEnabled: boolean;
theme: SourceEditorTheme;
}
export interface SourceEditorProps extends SourceEditorConfiguration {
document: string;
selectionTarget?: SourceEditorSelectionTarget;
onChange(document: string): void;
onSave(): void;
onCursorChange(position: SourceEditorPosition): void;
onVimModeChange(mode: string | null): void;
}
@@ -1,59 +0,0 @@
import { describe, expect, test } from "vitest";
import { SourceEditorController } from "./controller";
function makeController() {
const events: unknown[] = [];
const controller = new SourceEditorController({
editorKey: "current",
document: "one\r\ntwo",
callbacks: {
onChange: (document) => events.push(["change", document]),
onSave: () => events.push(["save"]),
onCursorChange: (position) => events.push(["cursor", position]),
onVimModeChange: (mode) => events.push(["vim", mode]),
},
});
return { controller, events };
}
describe("SourceEditorController", () => {
test("turns compact runtime changes into a document callback", () => {
const { controller, events } = makeController();
expect(
controller.receive({
type: "change",
editorKey: "current",
changes: [{ from: 4, to: 7, insert: "second" }],
}),
).toBe(true);
expect(events).toEqual([["change", "one\r\nsecond"]]);
});
test("rejects messages from a stale editor generation", () => {
const { controller, events } = makeController();
expect(controller.receive({ type: "save", editorKey: "stale" })).toBe(false);
expect(events).toEqual([]);
});
test("rejects invalid changes at the document boundary", () => {
const { controller, events } = makeController();
expect(
controller.receive({
type: "change",
editorKey: "current",
changes: [{ from: 0, to: 99, insert: "bad" }],
}),
).toBe(false);
expect(events).toEqual([]);
});
test("returns a normalized replacement only when the controlled document changed", () => {
const { controller } = makeController();
expect(controller.replaceDocument("one\r\ntwo")).toBeNull();
expect(controller.replaceDocument("replacement\r\ndocument")).toBe("replacement\ndocument");
});
});
@@ -1,61 +0,0 @@
import type { SourceEditorBridgeMessage } from "./codemirror/bridge-protocol";
import type { SourceEditorPosition } from "./contract";
import { SourceDocumentMirror } from "./document-mirror";
interface SourceEditorControllerCallbacks {
onChange(document: string): void;
onSave(): void;
onCursorChange(position: SourceEditorPosition): void;
onVimModeChange(mode: string | null): void;
}
export class SourceEditorController {
private readonly editorKey: string;
private readonly mirror: SourceDocumentMirror;
private readonly callbacks: SourceEditorControllerCallbacks;
constructor(input: {
editorKey: string;
document: string;
callbacks: SourceEditorControllerCallbacks;
}) {
this.editorKey = input.editorKey;
this.mirror = new SourceDocumentMirror(input.document);
this.callbacks = input.callbacks;
}
getRuntimeDocument(): string {
return this.mirror.getRuntimeDocument();
}
replaceDocument(document: string): string | null {
return this.mirror.replace(document) ? this.mirror.getRuntimeDocument() : null;
}
receive(message: SourceEditorBridgeMessage): boolean {
if (message.type === "bridgeReady" || message.editorKey !== this.editorKey) return false;
switch (message.type) {
case "ready":
return true;
case "change": {
let document: string;
try {
document = this.mirror.apply(message.changes);
} catch {
return false;
}
this.callbacks.onChange(document);
return true;
}
case "cursor":
this.callbacks.onCursorChange({ line: message.line, column: message.column });
return true;
case "save":
this.callbacks.onSave();
return true;
case "vimMode":
this.callbacks.onVimModeChange(message.mode);
return true;
}
}
}
@@ -1,38 +0,0 @@
import { describe, expect, test } from "vitest";
import { SourceDocumentMirror } from "./document-mirror";
describe("SourceDocumentMirror", () => {
test("applies CodeMirror changes without replacing unchanged text", () => {
const mirror = new SourceDocumentMirror("one two three");
const document = mirror.apply([
{ from: 0, to: 3, insert: "ONE" },
{ from: 8, to: 13, insert: "THREE" },
]);
expect(document).toBe("ONE two THREE");
});
test("preserves the file line separator while runtime offsets stay normalized", () => {
const mirror = new SourceDocumentMirror("one\r\ntwo\r\n");
expect(mirror.getRuntimeDocument()).toBe("one\ntwo\n");
expect(mirror.apply([{ from: 4, to: 7, insert: "second\nline" }])).toBe(
"one\r\nsecond\r\nline\r\n",
);
});
test("rejects overlapping or out-of-range changes", () => {
const mirror = new SourceDocumentMirror("one two");
expect(() =>
mirror.apply([
{ from: 0, to: 4, insert: "" },
{ from: 3, to: 7, insert: "" },
]),
).toThrow("Invalid source editor change range");
expect(() => mirror.apply([{ from: 0, to: 8, insert: "" }])).toThrow(
"Invalid source editor change range",
);
});
});
@@ -1,66 +0,0 @@
import type { SourceChange } from "./contract";
type LineSeparator = "\n" | "\r\n" | "\r";
export class SourceDocumentMirror {
private text: string;
private lineSeparator: LineSeparator;
constructor(document: string) {
this.lineSeparator = detectLineSeparator(document);
this.text = normalizeLineSeparators(document);
}
getRuntimeDocument(): string {
return this.text;
}
getDocument(): string {
return serializeDocument(this.text, this.lineSeparator);
}
replace(document: string): boolean {
if (document === this.getDocument()) return false;
this.lineSeparator = detectLineSeparator(document);
this.text = normalizeLineSeparators(document);
return true;
}
apply(changes: readonly SourceChange[]): string {
validateChanges(changes, this.text.length);
for (let index = changes.length - 1; index >= 0; index -= 1) {
const change = changes[index];
this.text = `${this.text.slice(0, change.from)}${normalizeLineSeparators(change.insert)}${this.text.slice(change.to)}`;
}
return this.getDocument();
}
}
function validateChanges(changes: readonly SourceChange[], documentLength: number): void {
let previousEnd = 0;
for (const change of changes) {
if (
!Number.isInteger(change.from) ||
!Number.isInteger(change.to) ||
change.from < previousEnd ||
change.from > change.to ||
change.to > documentLength
) {
throw new Error("Invalid source editor change range");
}
previousEnd = change.to;
}
}
function normalizeLineSeparators(document: string): string {
return document.replace(/\r\n?|\n/g, "\n");
}
function serializeDocument(document: string, lineSeparator: LineSeparator): string {
return lineSeparator === "\n" ? document : document.replace(/\n/g, lineSeparator);
}
function detectLineSeparator(document: string): LineSeparator {
const match = /\r\n|\r|\n/.exec(document);
return (match?.[0] as LineSeparator | undefined) ?? "\n";
}
@@ -1,152 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { WebViewMessageEvent } from "react-native-webview";
import { WebView } from "react-native-webview";
import { StyleSheet } from "react-native-unistyles";
import { SourceEditorController } from "./controller";
import type { SourceEditorConfiguration, SourceEditorProps } from "./contract";
import {
parseSourceEditorBridgeMessage,
type SourceEditorHostMessage,
} from "./codemirror/bridge-protocol";
import { sourceEditorWebViewHtml } from "./codemirror/webview-html";
const BASE_URL = "about:blank";
const ORIGIN_WHITELIST = ["*"];
let nextEditorKey = 1;
export function SourceEditor(props: SourceEditorProps) {
const webViewRef = useRef<WebView>(null);
const bridgeReadyRef = useRef(false);
const propsRef = useRef(props);
propsRef.current = props;
const [editorKey] = useState(() => `native-source-editor-${nextEditorKey++}`);
const controllerRef = useRef<SourceEditorController | null>(null);
if (!controllerRef.current) {
controllerRef.current = new SourceEditorController({
editorKey,
document: props.document,
callbacks: {
onChange: (document) => propsRef.current.onChange(document),
onSave: () => propsRef.current.onSave(),
onCursorChange: (position) => propsRef.current.onCursorChange(position),
onVimModeChange: (mode) => propsRef.current.onVimModeChange(mode),
},
});
}
const controller = controllerRef.current;
const source = useMemo(() => ({ html: sourceEditorWebViewHtml, baseUrl: BASE_URL }), []);
const configuration = useMemo<SourceEditorConfiguration>(
() => ({ filename: props.filename, vimEnabled: props.vimEnabled, theme: props.theme }),
[props.filename, props.theme, props.vimEnabled],
);
const send = useCallback((message: SourceEditorHostMessage) => {
if (!bridgeReadyRef.current) return;
const serialized = JSON.stringify(JSON.stringify(message));
webViewRef.current?.injectJavaScript(
`window.__PASEO_SOURCE_EDITOR_RECEIVE__?.(${serialized}); true;`,
);
}, []);
const mount = useCallback(() => {
send({
type: "mount",
editorKey,
document: controller.getRuntimeDocument(),
configuration: configurationFromProps(propsRef.current),
});
}, [controller, editorKey, send]);
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
const message = parseSourceEditorBridgeMessage(event.nativeEvent.data);
if (!message) return;
if (message.type === "bridgeReady") {
bridgeReadyRef.current = true;
mount();
const selectionTarget = propsRef.current.selectionTarget;
if (selectionTarget) send({ type: "reveal", editorKey, ...selectionTarget });
return;
}
controller.receive(message);
},
[controller, editorKey, mount, send],
);
useEffect(() => {
const replacement = controller.replaceDocument(props.document);
if (replacement !== null) {
send({ type: "replaceDocument", editorKey, document: replacement });
}
}, [controller, editorKey, props.document, send]);
useEffect(() => {
send({
type: "configure",
editorKey,
configuration,
});
}, [configuration, editorKey, send]);
useEffect(() => {
if (props.selectionTarget) {
send({ type: "reveal", editorKey, ...props.selectionTarget });
}
}, [editorKey, props.selectionTarget, send]);
useEffect(
() => () => {
send({ type: "destroy", editorKey });
bridgeReadyRef.current = false;
},
[editorKey, send],
);
const allowOnlyInitialDocument = useCallback(({ url }: { url: string }) => {
return url === BASE_URL;
}, []);
const reloadAfterProcessFailure = useCallback(() => {
bridgeReadyRef.current = false;
webViewRef.current?.reload();
}, []);
const handleLoadStart = useCallback(() => {
bridgeReadyRef.current = false;
}, []);
const handleRenderProcessGone = useCallback(() => {
reloadAfterProcessFailure();
return true;
}, [reloadAfterProcessFailure]);
return (
<WebView
ref={webViewRef}
testID="file-source-editor"
style={[styles.webview, { backgroundColor: props.theme.background }]}
source={source}
originWhitelist={ORIGIN_WHITELIST}
onShouldStartLoadWithRequest={allowOnlyInitialDocument}
onMessage={handleMessage}
onLoadStart={handleLoadStart}
onContentProcessDidTerminate={reloadAfterProcessFailure}
onRenderProcessGone={handleRenderProcessGone}
setSupportMultipleWindows={false}
javaScriptCanOpenWindowsAutomatically={false}
domStorageEnabled={false}
thirdPartyCookiesEnabled={false}
cacheEnabled={false}
incognito
scrollEnabled={false}
overScrollMode="never"
keyboardDisplayRequiresUserAction={false}
/>
);
}
function configurationFromProps(props: SourceEditorProps): SourceEditorConfiguration {
return { filename: props.filename, vimEnabled: props.vimEnabled, theme: props.theme };
}
const styles = StyleSheet.create(() => ({
webview: { flex: 1 },
}));
@@ -1,85 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { SourceEditorController } from "./controller";
import type { SourceEditorConfiguration, SourceEditorProps } from "./contract";
import { CodeMirrorRuntime } from "./codemirror/runtime";
let nextEditorKey = 1;
export function SourceEditor(props: SourceEditorProps) {
const hostRef = useRef<HTMLDivElement>(null);
const runtimeRef = useRef<CodeMirrorRuntime | null>(null);
const propsRef = useRef(props);
propsRef.current = props;
const initial = useRef(props);
const [editorKey] = useState(() => `web-source-editor-${nextEditorKey++}`);
const controllerRef = useRef<SourceEditorController | null>(null);
if (!controllerRef.current) {
controllerRef.current = new SourceEditorController({
editorKey,
document: initial.current.document,
callbacks: {
onChange: (document) => propsRef.current.onChange(document),
onSave: () => propsRef.current.onSave(),
onCursorChange: (position) => propsRef.current.onCursorChange(position),
onVimModeChange: (mode) => propsRef.current.onVimModeChange(mode),
},
});
}
const controller = controllerRef.current;
const configuration = useMemo<SourceEditorConfiguration>(
() => ({ filename: props.filename, vimEnabled: props.vimEnabled, theme: props.theme }),
[props.filename, props.theme, props.vimEnabled],
);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const runtime = new CodeMirrorRuntime();
runtimeRef.current = runtime;
runtime.mount({
host,
document: controller.getRuntimeDocument(),
configuration: configurationFromProps(initial.current),
callbacks: {
onChange: (changes) => controller.receive({ type: "change", editorKey, changes }),
onSave: () => controller.receive({ type: "save", editorKey }),
onCursorChange: (position) =>
controller.receive({ type: "cursor", editorKey, ...position }),
onVimModeChange: (mode) => controller.receive({ type: "vimMode", editorKey, mode }),
},
});
return () => {
runtime.destroy();
runtimeRef.current = null;
};
}, [controller, editorKey]);
useEffect(() => {
const replacement = controller.replaceDocument(props.document);
if (replacement !== null) runtimeRef.current?.replaceDocument(replacement);
}, [controller, props.document]);
useEffect(() => {
runtimeRef.current?.configure(configuration);
}, [configuration]);
useEffect(() => {
if (props.selectionTarget) runtimeRef.current?.reveal(props.selectionTarget);
}, [props.selectionTarget]);
return (
<div
ref={hostRef}
data-pmono=""
data-testid="file-source-editor"
aria-label={`Source editor for ${props.filename}`}
style={HOST_STYLE}
/>
);
}
function configurationFromProps(props: SourceEditorProps): SourceEditorConfiguration {
return { filename: props.filename, vimEnabled: props.vimEnabled, theme: props.theme };
}
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/server": "0.7.0-beta.1",
"@getpaseo/client": "0.7.1",
"@getpaseo/protocol": "0.7.1",
"@getpaseo/server": "0.7.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+106 -8
View File
@@ -9,6 +9,7 @@ import { runHubLogin } from "./login.js";
import { runHubLogout } from "./logout.js";
import { runHubProjects } from "./projects.js";
import { createHubCommand } from "./index.js";
import { runHubPermissionChange, runHubPermissionsList } from "./permissions.js";
import type { HubReporter } from "./reporter.js";
const quietReporter: HubReporter = { progress() {} };
@@ -25,6 +26,7 @@ describe("Hub commands", () => {
"connect",
"status",
"disconnect",
"permissions",
"projects",
"export",
"deploy",
@@ -174,12 +176,87 @@ describe("Hub commands", () => {
assert.deepEqual(observed, [{ origin: "https://hub.test", credential: "stored-human-secret" }]);
assert.deepEqual(daemon.connections, [
{ origin: "https://hub.test", token: "one-time-enrollment-token-with-enough-length" },
{
origin: "https://hub.test",
token: "one-time-enrollment-token-with-enough-length",
permissions: [],
},
]);
assert.equal(daemon.connections[0]?.token.includes("stored-human-secret"), false);
assert.deepEqual(progress, ["Connecting this daemon to https://hub.test"]);
});
it("grants workflow execution only when explicitly requested", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "secret" });
const daemon = new FakeDaemon("https://hub.test");
await runHubConnect(
"https://hub.test",
{ permissions: ["hub.execute"] },
{
env: {},
credentials,
hub: { issueEnrollmentToken: async () => "one-time-token" },
daemon: new FakeDaemonConnection(daemon),
reporter: quietReporter,
},
);
assert.deepEqual(daemon.connections[0]?.permissions, ["hub.execute"]);
});
it("revokes a relationship when an older daemon widens connected-only access", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "secret" });
const daemon = new FakeDaemon("https://hub.test");
daemon.returnedPermissions = ["hub.execute"];
await assert.rejects(
runHubConnect(
"https://hub.test",
{},
{
env: {},
credentials,
hub: { issueEnrollmentToken: async () => "one-time-token" },
daemon: new FakeDaemonConnection(daemon),
reporter: quietReporter,
},
),
/did not honor the requested Hub access/u,
);
assert.equal(daemon.disconnects, 1);
});
it("lists, grants, and revokes semantic Hub permissions without a wizard", async () => {
const daemon = new FakeDaemon("https://hub.test");
daemon.returnedPermissions = [];
const connection = new FakeDaemonConnection(daemon);
const progress: string[] = [];
const dependencies = {
daemon: connection,
reporter: { progress: (message: string) => progress.push(message) },
};
const empty = await runHubPermissionsList({}, dependencies);
assert.deepEqual(empty.data, []);
await runHubPermissionChange("grant", "hub.execute", {}, dependencies);
const granted = await runHubPermissionsList({}, dependencies);
assert.deepEqual(granted.data, [
{ permission: "hub.execute", description: "Run agents for Hub automations" },
]);
await runHubPermissionChange("revoke", "hub.execute", {}, dependencies);
assert.deepEqual((await runHubPermissionsList({}, dependencies)).data, []);
assert.deepEqual(progress, [
"Granted hub.execute to https://hub.test",
"Revoked hub.execute from https://hub.test",
]);
});
it("projects reports its normalized destination before listing", async () => {
const credentials = new MemoryCredentials();
const events: string[] = [];
@@ -523,22 +600,39 @@ class FakeDaemonConnection implements HubDaemonConnection {
}
class FakeDaemon implements HubDaemonClient {
readonly connections: Array<{ origin: string; token: string }> = [];
readonly connections: Array<{
origin: string;
token: string;
permissions: readonly string[];
}> = [];
disconnects = 0;
readonly disconnectForces: boolean[] = [];
disconnectError: Error | null = null;
disconnectWarning: string | null = null;
beforeDisconnect: (() => void) | null = null;
returnedPermissions: readonly string[] | null = null;
constructor(private readonly origin: string) {}
async connectHub(origin: string, token: string) {
this.connections.push({ origin, token });
return { status: hubStatus("connected", origin) };
async connectHub(origin: string, token: string, permissions: readonly string[] = []) {
this.connections.push({ origin, token, permissions });
return {
status: hubStatus("connected", origin, this.returnedPermissions ?? permissions),
};
}
async updateHubPermissions(input: { grant?: readonly string[]; revoke?: readonly string[] }) {
const current = new Set(this.returnedPermissions ?? []);
for (const permission of input.revoke ?? []) current.delete(permission);
for (const permission of input.grant ?? []) current.add(permission);
this.returnedPermissions = [...current];
return { status: hubStatus("connected", this.origin, this.returnedPermissions) };
}
async getHubStatus() {
return { status: hubStatus("connected", this.origin) };
return {
status: hubStatus("connected", this.origin, this.returnedPermissions ?? ["hub.execute"]),
};
}
async getProvidersSnapshot() {
@@ -559,12 +653,16 @@ class FakeDaemon implements HubDaemonClient {
async close() {}
}
function hubStatus(state: string, origin: string | null): HubStatus {
function hubStatus(
state: string,
origin: string | null,
permissions: readonly string[] = ["hub.execute"],
): HubStatus {
return {
state,
daemonId: state === "connected" ? "daemon-1" : null,
hubOrigin: origin,
scopes: state === "connected" ? ["hub.execution.*"] : [],
permissions: state === "connected" ? [...permissions] : [],
connectedAt: null,
lastError: null,
};
+19 -2
View File
@@ -14,6 +14,8 @@ interface HubConnectOptions {
apiKey?: string;
host?: string;
json?: boolean;
permission?: readonly string[];
permissions?: readonly string[];
}
interface HubConnectDependencies {
@@ -38,8 +40,18 @@ export async function runHubConnect(
reportHubProgress(dependencies.reporter, options, `Connecting this daemon to ${origin}`);
const credential = resolveHubCredential({ ...resolution, origin });
const token = await dependencies.hub.issueEnrollmentToken(origin, credential);
const permissions = options.permissions ?? options.permission ?? [];
return withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
const response = await daemon.connectHub(origin, token);
const response = await daemon.connectHub(origin, token, permissions);
if (
response.status.hubOrigin !== null &&
!samePermissions(response.status.permissions, permissions)
) {
await daemon.disconnectHub(false).catch(() => undefined);
throw new Error(
"The daemon did not honor the requested Hub access. Update Paseo before connecting it.",
);
}
return hubStatusResult(response.status);
});
}
@@ -51,7 +63,8 @@ export function addHubConnectCommand(parent: Command, dependencies: HubConnectDe
.command("connect")
.description("Enroll this daemon with a Paseo Hub")
.argument("[origin]", "Paseo Hub origin")
.option("--api-key <secret>", "Organization API key"),
.option("--api-key <secret>", "Organization API key")
.option("--permission <permission...>", "Grant daemon permission during connection"),
),
).action(
withOutput(async (...args) => {
@@ -61,3 +74,7 @@ export function addHubConnectCommand(parent: Command, dependencies: HubConnectDe
}),
);
}
function samePermissions(actual: readonly string[], expected: readonly string[]): boolean {
return actual.length === expected.length && expected.every((scope) => actual.includes(scope));
}
+10 -2
View File
@@ -5,7 +5,7 @@ export interface HubStatus {
state: string;
daemonId: string | null;
hubOrigin: string | null;
scopes: string[];
permissions: string[];
connectedAt: string | null;
lastError: string | null;
}
@@ -15,7 +15,15 @@ export interface HubProvidersSnapshotOptions {
}
export interface HubDaemonClient {
connectHub(url: string, token: string): Promise<{ status: HubStatus }>;
connectHub(
url: string,
token: string,
permissions?: readonly string[],
): Promise<{ status: HubStatus }>;
updateHubPermissions(input: {
grant?: readonly string[];
revoke?: readonly string[];
}): Promise<{ status: HubStatus }>;
getHubStatus(): Promise<{ status: HubStatus }>;
disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>;
getProvidersSnapshot(
+5
View File
@@ -20,6 +20,7 @@ import { processHubReporter, type HubReporter } from "./reporter.js";
import { hubStatusResult } from "./status-output.js";
import { addHubResolutionHelp } from "./help.js";
import { addHubInitCommand, continueHubGuidedSetup } from "./init.js";
import { addHubPermissionsCommand } from "./permissions.js";
interface HubCommandEnvironment {
env: Readonly<Record<string, string | undefined>>;
@@ -80,6 +81,10 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {})
daemon: environment.daemon,
reporter: environment.reporter,
});
addHubPermissionsCommand(hub, {
daemon: environment.daemon,
reporter: environment.reporter,
});
addHubProjectsCommand(hub, {
env: environment.env,
credentials: environment.credentials,
@@ -25,7 +25,7 @@ describe("Hub guided setup continuation", () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
const daemon = new SetupDaemon();
const prompts = new PromptAnswers([true], [], []);
const prompts = new PromptAnswers([true, false], [], []);
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls);
@@ -42,9 +42,13 @@ describe("Hub guided setup continuation", () => {
},
);
assert.deepEqual(prompts.confirmations, ["Connect this daemon to this Hub?"]);
assert.deepEqual(prompts.confirmations, [
"Connect this daemon to Paseo Hub?\n\nConnecting lets Hub identify this daemon and show whether it is online.\nIt does not allow Hub to create workspaces or run agents.",
"Allow Hub automations to run agents on this daemon?\n\nThis lets workflows triggered from GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents here.\n\nAgents can access files and run commands allowed by their workspace runtime.",
]);
assert.deepEqual(prompts.selections, []);
assert.deepEqual(prompts.messages, [
"Daemon connected with no permissions.\n\nEnable Hub automations later:\n paseo hub permissions grant hub.execute",
"Configure triggers in Hub: https://hub.test/triggers\nOr scaffold triggers as code: paseo hub init",
]);
assert.deepEqual(calls, [{ operation: "token", origin: "https://hub.test" }]);
@@ -70,6 +74,26 @@ describe("Hub guided setup continuation", () => {
]);
});
it("does not reopen onboarding or widen permissions for an existing connection", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "secret" });
const daemon = new SetupDaemon();
await daemon.connectHub("https://hub.test", "existing-token", []);
const prompts = new PromptAnswers([], [], []);
await continueHubGuidedSetup(
"https://hub.test",
setupEnvironment(cwd, credentials, daemon, prompts, []),
);
assert.deepEqual(prompts.confirmations, []);
assert.deepEqual(prompts.messages, [
"This daemon is already connected to https://hub.test. Permissions: None.",
"Configure triggers in Hub: https://hub.test/triggers\nOr scaffold triggers as code: paseo hub init",
]);
});
it("does not require a Hub app connection during login", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
@@ -333,6 +357,7 @@ class SetupDaemon implements HubDaemonClient {
connections = 0;
readonly snapshotCwds: Array<string | undefined> = [];
private origin: string | null = null;
private currentPermissions: string[] = [];
constructor(
readonly providerEntries: readonly ProviderSnapshotEntry[] = [
@@ -355,14 +380,26 @@ class SetupDaemon implements HubDaemonClient {
private readonly providerEntrySequence?: readonly (readonly ProviderSnapshotEntry[])[],
) {}
async connectHub(origin: string) {
async connectHub(origin: string, _token: string, permissions: readonly string[] = []) {
this.connections += 1;
this.origin = origin;
return { status: status(origin) };
this.currentPermissions = [...permissions];
return { status: status(origin, permissions) };
}
async updateHubPermissions(input: { grant?: readonly string[]; revoke?: readonly string[] }) {
const permissions = new Set(this.origin === null ? [] : this.currentPermissions);
for (const permission of input.revoke ?? []) permissions.delete(permission);
for (const permission of input.grant ?? []) permissions.add(permission);
this.currentPermissions = [...permissions];
return { status: status(this.origin ?? "https://hub.test", this.currentPermissions) };
}
async getHubStatus() {
return { status: this.origin === null ? disconnectedStatus() : status(this.origin) };
return {
status:
this.origin === null ? disconnectedStatus() : status(this.origin, this.currentPermissions),
};
}
async getProvidersSnapshot(options?: { cwd?: string }) {
@@ -381,12 +418,12 @@ class SetupDaemon implements HubDaemonClient {
async close() {}
}
function status(origin: string): HubStatus {
function status(origin: string, permissions: readonly string[] = []): HubStatus {
return {
state: "connected",
daemonId: "daemon-1",
hubOrigin: origin,
scopes: [],
permissions: [...permissions],
connectedAt: null,
lastError: null,
};
@@ -397,7 +434,7 @@ function disconnectedStatus(): HubStatus {
state: "not_connected",
daemonId: null,
hubOrigin: null,
scopes: [],
permissions: [],
connectedAt: null,
lastError: null,
};
@@ -68,7 +68,7 @@ describe("Hub init planning", () => {
state: "connected",
daemonId: "daemon-1",
hubOrigin: "https://hub.test",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
connectedAt: null,
lastError: null,
};
+52 -5
View File
@@ -191,8 +191,43 @@ export async function continueHubGuidedSetup(
origin: string,
environment: HubGuidedSetupEnvironment,
): Promise<void> {
if (await requiredConfirm(environment, "Connect this daemon to this Hub?", true)) {
await ensureDaemonConnection(origin, environment, true);
const currentStatus = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
daemon.getHubStatus().then((response) => response.status),
);
const current = resolveHubInitConnection(currentStatus, origin);
if (current.kind === "connected") {
reportMessage(
environment,
`This daemon is already connected to ${origin}. Permissions: ${
currentStatus.permissions.join(", ") || "None"
}.`,
);
} else if (current.kind === "pending") {
await waitForDaemonReady(origin, environment.daemon);
} else if (current.kind === "conflict") {
reportMessage(
environment,
`This daemon is connected to ${current.origin}. Disconnect it before connecting to ${origin}.`,
);
} else if (
await requiredConfirm(
environment,
"Connect this daemon to Paseo Hub?\n\nConnecting lets Hub identify this daemon and show whether it is online.\nIt does not allow Hub to create workspaces or run agents.",
true,
)
) {
const grantExecution = await requiredConfirm(
environment,
"Allow Hub automations to run agents on this daemon?\n\nThis lets workflows triggered from GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents here.\n\nAgents can access files and run commands allowed by their workspace runtime.",
false,
);
await ensureDaemonConnection(origin, environment, true, grantExecution ? ["hub.execute"] : []);
if (!grantExecution) {
reportMessage(
environment,
"Daemon connected with no permissions.\n\nEnable Hub automations later:\n paseo hub permissions grant hub.execute",
);
}
} else {
reportMessage(
environment,
@@ -252,12 +287,19 @@ async function ensureDaemonConnection(
origin: string,
environment: HubGuidedSetupEnvironment,
confirmed = false,
permissions: readonly string[] = ["hub.execute"],
): Promise<string> {
const status = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
daemon.getHubStatus().then((response) => response.status),
);
const connection = resolveHubInitConnection(status, origin);
if (connection.kind === "connected") {
if (permissions.includes("hub.execute") && !status.permissions.includes("hub.execute")) {
throw new HubCommandError(
"HUB_DAEMON_EXECUTION_NOT_ALLOWED",
"This daemon is connected to Hub but cannot run Hub automations. Run `paseo hub permissions grant hub.execute`, then run Hub init again.",
);
}
return connection.daemonId;
}
if (connection.kind === "pending") {
@@ -271,20 +313,25 @@ async function ensureDaemonConnection(
}
if (
!confirmed &&
!(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true))
!(await requiredConfirm(
environment,
`Connect this daemon to ${origin} and allow Hub workflows to create and control workspaces and agents?`,
true,
))
) {
throw new HubInitCancelledError("A connected daemon is required to create the bundle.");
}
return connectDaemon(origin, environment);
return connectDaemon(origin, environment, permissions);
}
async function connectDaemon(
origin: string,
environment: HubGuidedSetupEnvironment,
permissions: readonly string[] = ["hub.execute"],
): Promise<string> {
await runHubConnect(
origin,
{},
{ permissions },
{
env: environment.env,
credentials: environment.credentials,
@@ -0,0 +1,112 @@
import type { Command } from "commander";
import { withOutput, type ListResult, type OutputSchema } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import type { HubDaemonConnection, HubStatus } from "./daemon-client.js";
import { withHubDaemon } from "./daemon-client.js";
import { reportHubProgress, type HubReporter } from "./reporter.js";
import { hubStatusResult } from "./status-output.js";
interface HubPermissionsOptions {
host?: string;
json?: boolean;
}
interface HubPermissionsDependencies {
daemon: HubDaemonConnection;
reporter: HubReporter;
}
interface PermissionRow {
permission: string;
description: string;
}
const schema: OutputSchema<PermissionRow> = {
idField: "permission",
columns: [
{ header: "PERMISSION", field: "permission" },
{ header: "DESCRIPTION", field: "description" },
],
};
export function runHubPermissionsList(
options: HubPermissionsOptions,
dependencies: HubPermissionsDependencies,
): Promise<ListResult<PermissionRow>> {
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
const status = (await client.getHubStatus()).status;
requireConnectedHub(status);
return {
type: "list",
data: status.permissions.map((permission) => ({
permission,
description: describePermission(permission),
})),
schema,
};
});
}
export function runHubPermissionChange(
operation: "grant" | "revoke",
permission: string,
options: HubPermissionsOptions,
dependencies: HubPermissionsDependencies,
) {
return withHubDaemon(dependencies.daemon, options.host, async (client) => {
const current = (await client.getHubStatus()).status;
requireConnectedHub(current);
const response = await client.updateHubPermissions(
operation === "grant" ? { grant: [permission] } : { revoke: [permission] },
);
reportHubProgress(
dependencies.reporter,
options,
`${operation === "grant" ? "Granted" : "Revoked"} ${permission} ${
operation === "grant" ? "to" : "from"
} ${response.status.hubOrigin}`,
);
return hubStatusResult(response.status);
});
}
export function addHubPermissionsCommand(
parent: Command,
dependencies: HubPermissionsDependencies,
): void {
const permissions = parent
.command("permissions")
.description("Manage this Hub's daemon permissions");
addJsonAndDaemonHostOptions(permissions.command("list")).action(
withOutput(async (...args) => {
const options = args.at(-2) as HubPermissionsOptions;
return runHubPermissionsList(options, dependencies);
}),
);
for (const operation of ["grant", "revoke"] as const) {
addJsonAndDaemonHostOptions(
permissions.command(operation).argument("<permission>", "Daemon permission"),
).action(
withOutput(async (...args) => {
const permission = args[0] as string;
const options = args.at(-2) as HubPermissionsOptions;
return runHubPermissionChange(operation, permission, options, dependencies);
}),
);
}
}
function requireConnectedHub(status: HubStatus): void {
if (
status.hubOrigin === null ||
(status.state !== "connected" && status.state !== "reconnecting")
) {
throw new Error("This daemon is not connected to a Hub");
}
}
function describePermission(permission: string): string {
return permission === "hub.execute" ? "Run agents for Hub automations" : permission;
}
@@ -5,7 +5,7 @@ export interface HubRow {
state: string;
daemonId: string | null;
hub: string | null;
scopes: string;
permissions: string;
connectedAt: string | null;
error: string | null;
warning?: string;
@@ -17,7 +17,7 @@ const schema: OutputSchema<HubRow> = {
{ header: "STATE", field: "state" },
{ header: "HUB", field: "hub" },
{ header: "DAEMON", field: "daemonId" },
{ header: "SCOPES", field: "scopes" },
{ header: "PERMISSIONS", field: "permissions" },
{ header: "CONNECTED", field: "connectedAt" },
{ header: "ERROR", field: "error" },
{ header: "WARNING", field: "warning" },
@@ -36,7 +36,7 @@ export function hubStatusResult(
state: status.state,
daemonId: status.daemonId,
hub: reportedHubOrigin,
scopes: status.scopes.join(", "),
permissions: status.permissions.join(", "),
connectedAt: status.connectedAt,
error: status.lastError,
warning,
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.1",
"@getpaseo/relay": "0.7.1",
"zod": "^4.4.3"
},
"devDependencies": {
+24 -3
View File
@@ -309,7 +309,7 @@ export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteRe
export interface DaemonClientConfig {
url: string;
clientId: string;
clientType?: "mobile" | "browser" | "cli" | "mcp";
clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub";
appVersion?: string;
runtimeGeneration?: number | null;
password?: string;
@@ -4680,15 +4680,36 @@ export class DaemonClient {
});
}
async connectHub(hubUrl: string, token: string, requestId?: string) {
async connectHub(
hubUrl: string,
token: string,
permissions: readonly string[] = [],
requestId?: string,
) {
this.requireHubRelationshipSupport();
return this.sendCorrelatedSessionRequest({
requestId,
message: { type: "hub.management.daemon.connect.request", hubUrl, token },
message: { type: "hub.management.daemon.connect.request", hubUrl, token, permissions },
responseType: "hub.management.daemon.connect.response",
});
}
async updateHubPermissions(
input: { grant?: readonly string[]; revoke?: readonly string[] },
requestId?: string,
) {
this.requireHubRelationshipSupport();
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "hub.management.daemon.permissions.update.request",
grant: input.grant ?? [],
revoke: input.revoke ?? [],
},
responseType: "hub.management.daemon.permissions.update.response",
});
}
async getHubStatus(requestId?: string) {
this.requireHubRelationshipSupport();
return this.sendCorrelatedSessionRequest({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -44,8 +44,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/client": "0.7.1",
"@getpaseo/protocol": "0.7.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -54,8 +54,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/client": "0.7.1",
"@getpaseo/protocol": "0.7.1",
"react": "19.1.0",
"zod": "^4.4.3"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+24 -3
View File
@@ -335,9 +335,16 @@ describe("Hub session protocol", () => {
requestId: "r1",
hubUrl: "https://hub.example",
token: "token",
permissions: [],
},
{ type: "hub.management.daemon.get_status.request", requestId: "r2" },
{ type: "hub.management.daemon.disconnect.request", requestId: "r3", force: true },
{
type: "hub.management.daemon.permissions.update.request",
requestId: "r4",
grant: ["hub.execute"],
revoke: [],
},
])("accepts trusted management request $type", (message) => {
expect(SessionInboundMessageSchema.parse(message)).toEqual(message);
});
@@ -351,7 +358,7 @@ describe("Hub session protocol", () => {
state: "connected",
daemonId: "daemon-1",
hubOrigin: "https://hub.example",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
connectedAt: "2026-07-13T00:00:00.000Z",
lastError: null,
},
@@ -365,7 +372,7 @@ describe("Hub session protocol", () => {
state: "not_connected",
daemonId: null,
hubOrigin: null,
scopes: [],
permissions: [],
connectedAt: null,
lastError: null,
},
@@ -379,13 +386,27 @@ describe("Hub session protocol", () => {
state: "disconnecting",
daemonId: "daemon-1",
hubOrigin: "https://hub.example",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
connectedAt: null,
lastError: "offline",
},
warning: "pending",
},
},
{
type: "hub.management.daemon.permissions.update.response",
payload: {
requestId: "r4",
status: {
state: "connected",
daemonId: "daemon-1",
hubOrigin: "https://hub.example",
permissions: ["hub.execute"],
connectedAt: "2026-07-13T00:00:00.000Z",
lastError: null,
},
},
},
])("accepts trusted management response $type", (message) => {
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
});
+31 -2
View File
@@ -93,6 +93,20 @@ export {
// Mutable daemon config schemas (shared between server store and client)
// ---------------------------------------------------------------------------
export const DAEMON_PERMISSIONS = [
"daemon.read",
"daemon.manage",
"tunnel.manage",
"access.manage",
"workspace.read",
"workspace.write",
"workspace.manage",
"automation.manage",
"hub.execute",
] as const;
export const DaemonPermissionSchema = z.enum(DAEMON_PERMISSIONS);
export type DaemonPermission = z.infer<typeof DaemonPermissionSchema>;
const MutableDaemonProviderModelSchema = z
.object({
id: z.string().min(1),
@@ -1393,6 +1407,7 @@ export const HubManagementDaemonConnectRequestSchema = z.object({
requestId: z.string(),
hubUrl: z.string(),
token: z.string(),
permissions: z.array(DaemonPermissionSchema).default([]),
});
export const HubManagementDaemonGetStatusRequestSchema = z.object({
type: z.literal("hub.management.daemon.get_status.request"),
@@ -1403,6 +1418,12 @@ export const HubManagementDaemonDisconnectRequestSchema = z.object({
requestId: z.string(),
force: z.boolean().optional(),
});
export const HubManagementDaemonPermissionsUpdateRequestSchema = z.object({
type: z.literal("hub.management.daemon.permissions.update.request"),
requestId: z.string(),
grant: z.array(DaemonPermissionSchema).default([]),
revoke: z.array(DaemonPermissionSchema).default([]),
});
export const DiagnosticsRequestSchema = z.object({
type: z.literal("diagnostics.request"),
@@ -3011,6 +3032,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
HubManagementDaemonConnectRequestSchema,
HubManagementDaemonGetStatusRequestSchema,
HubManagementDaemonDisconnectRequestSchema,
HubManagementDaemonPermissionsUpdateRequestSchema,
DiagnosticsRequestSchema,
PluginCatalogGetRequestSchema,
PluginListRequestSchema,
@@ -3325,6 +3347,8 @@ export const ServerInfoStatusPayloadSchema = z
serverId: z.string().trim().min(1),
hostname: ServerInfoHostnameSchema.optional(),
version: ServerInfoVersionSchema.optional(),
// COMPAT(sessionPermissions): optional while clients support older daemons.
permissions: z.array(DaemonPermissionSchema).optional(),
// COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16.
desktopManaged: z.boolean().optional(),
capabilities: ServerCapabilitiesFromUnknownSchema.optional(),
@@ -4599,7 +4623,7 @@ export const HubRelationshipStatusSchema = z.object({
]),
daemonId: z.string().nullable(),
hubOrigin: z.string().nullable(),
scopes: z.array(z.string()),
permissions: z.array(DaemonPermissionSchema),
connectedAt: z.string().nullable(),
lastError: z.string().nullable(),
});
@@ -4619,6 +4643,10 @@ export const HubManagementDaemonDisconnectResponseSchema = z.object({
warning: z.string().optional(),
}),
});
export const HubManagementDaemonPermissionsUpdateResponseSchema = z.object({
type: z.literal("hub.management.daemon.permissions.update.response"),
payload: z.object({ requestId: z.string(), status: HubRelationshipStatusSchema }),
});
export const DaemonGetPairingOfferResponseSchema = z.object({
type: z.literal("daemon.get_pairing_offer.response"),
@@ -6362,6 +6390,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
HubManagementDaemonConnectResponseSchema,
HubManagementDaemonGetStatusResponseSchema,
HubManagementDaemonDisconnectResponseSchema,
HubManagementDaemonPermissionsUpdateResponseSchema,
DiagnosticsResponseSchema,
GetDaemonConfigResponseMessageSchema,
SetDaemonConfigResponseMessageSchema,
@@ -6936,7 +6965,7 @@ export const WSPongMessageSchema = z.object({
export const WSHelloMessageSchema = z.object({
type: z.literal("hello"),
clientId: z.string().min(1),
clientType: z.enum(["mobile", "browser", "cli", "mcp"]),
clientType: z.enum(["mobile", "browser", "cli", "mcp", "hub"]),
protocolVersion: z.number().int(),
appVersion: z.string().optional(),
capabilities: z
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -72,11 +72,11 @@
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/highlight": "0.7.0-beta.1",
"@getpaseo/plugin": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"@getpaseo/client": "0.7.1",
"@getpaseo/highlight": "0.7.1",
"@getpaseo/plugin": "0.7.1",
"@getpaseo/protocol": "0.7.1",
"@getpaseo/relay": "0.7.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -129,9 +129,9 @@ second line'`,
const result = curateAgentActivity(timeline);
expect(result).toContain("[Exec Command]");
expect(result).toContain("[Read File]");
expect(result).toContain("[Web Search]");
expect(result).toContain("[Exec command]");
expect(result).toContain("[Read file]");
expect(result).toContain("[Web search]");
expect(result).not.toContain("npm run lint");
expect(result).not.toContain("src/index.ts");
expect(result).not.toContain("zod union");
@@ -5543,6 +5543,88 @@ test("waitForAgentRunStart resolves while a foreground run is still only pending
expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle");
});
test("waitForAgentRunStart ignores a prior turn error while the next run starts", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-error-resume-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const retryStartEntered = deferred<void>();
const releaseRetryStart = deferred<void>();
class ErrorThenResumeSession extends TestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
const turnId = `turn-${++this.turnIdCounter}`;
if (this.turnIdCounter === 1) {
void (async () => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.pushEvent({
type: "turn_failed",
provider: this.provider,
turnId,
error: "model at capacity",
});
})();
return { turnId };
}
retryStartEntered.resolve();
await releaseRetryStart.promise;
void (async () => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
})();
return { turnId };
}
}
class ErrorThenResumeClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new ErrorThenResumeSession(config);
}
}
const manager = new AgentManager({
clients: { codex: new ErrorThenResumeClient() },
registry: storage,
logger,
});
let agentId: string | null = null;
try {
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = snapshot.id;
await manager.runAgent(agentId, "fail").catch(() => undefined);
expect(manager.getAgent(agentId)?.lifecycle).toBe("error");
const dispatch = await startAgentRun(manager, agentId, "resume", logger);
expect(dispatch.disposition).toBe("turn_started");
const wait = manager.waitForAgentRunStart(agentId);
let earlyResult: "pending" | "resolved" | "rejected" = "pending";
void wait.then(
() => {
earlyResult = "resolved";
return earlyResult;
},
() => {
earlyResult = "rejected";
return earlyResult;
},
);
await retryStartEntered.promise;
await Promise.resolve();
expect(earlyResult).toBe("pending");
releaseRetryStart.resolve();
await expect(wait).resolves.toBeUndefined();
await manager.waitForAgentEvent(agentId);
} finally {
releaseRetryStart.resolve();
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("replaceAgentRun does not emit idle or resolve waiters between interrupted and replacement runs", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-replace-run-"));
const storagePath = join(workdir, "agents");
@@ -2165,6 +2165,7 @@ export class AgentManager {
}
agent.pendingReplacement = false;
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
pendingRun.start = { status: "failed", error: errorMsg };
await this.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
@@ -2232,8 +2233,7 @@ export class AgentManager {
agent.pendingReplacement = false;
}
const turnStartedAt = new Date();
pendingRun.started = true;
pendingRun.turnId = turnId;
pendingRun.start = { status: "started", turnId };
agent.activeForegroundTurnId = turnId;
this.openActiveTurn(agent, turnId, turnStartedAt);
agent.lifecycle = "running";
@@ -2575,7 +2575,10 @@ export class AgentManager {
}
const pendingRun = this.runs.getPendingRun(agentId);
if ((snapshot.lifecycle === "running" || pendingRun?.started) && !snapshot.pendingReplacement) {
if (
(snapshot.lifecycle === "running" || pendingRun?.start.status === "started") &&
!snapshot.pendingReplacement
) {
return;
}
@@ -2640,14 +2643,19 @@ export class AgentManager {
const currentPendingRun = this.runs.getPendingRun(agentId);
if (
(current.lifecycle === "running" || currentPendingRun?.started) &&
(current.lifecycle === "running" || currentPendingRun?.start.status === "started") &&
!current.pendingReplacement
) {
finishOk();
return true;
}
if (current.lifecycle === "error" && !currentPendingRun?.started) {
if (currentPendingRun?.start.status === "failed") {
finishErr(new Error(currentPendingRun.start.error));
return true;
}
if (current.lifecycle === "error" && !currentPendingRun) {
finishErr(new Error(current.lastError ?? `Agent ${agentId} failed to start`));
return true;
}
@@ -2734,16 +2742,17 @@ export class AgentManager {
return { status: settlement === "completed" ? "settled" : "refused" };
}
if (settlement === "timed_out" && run.turnId) {
const runTurnId = this.runs.getTurnId(agentId);
if (settlement === "timed_out" && runTurnId) {
this.logger.warn(
{ agentId, turnId: run.turnId, kind: run.kind },
{ agentId, turnId: runTurnId, kind: run.kind },
"cancelAgentRun: acknowledged turn still active after timeout, force-canceling",
);
await this.dispatchSessionEvent(agent, {
type: "turn_canceled",
provider: agent.provider,
reason: "interrupted",
turnId: run.turnId,
turnId: runTurnId,
});
await run.settledPromise;
} else if (settlement === "timed_out" && run.kind === "foreground") {
@@ -3023,7 +3032,7 @@ export class AgentManager {
let hasStarted =
isAgentBusy(initialStatus) ||
Boolean(snapshot.activeForegroundTurnId) ||
Boolean(pendingForegroundRun?.started);
pendingForegroundRun?.start.status === "started";
let terminalStatusOverride: AgentLifecycleStatus | null = null;
let finished = false;
@@ -3429,7 +3438,7 @@ export class AgentManager {
return;
}
const pendingRun = this.runs.getPendingRun(agentId);
if (pendingRun && !pendingRun.started) {
if (pendingRun?.start.status === "pending") {
pendingRun.stagedEvents.push(event);
return;
}
@@ -14,8 +14,10 @@ export interface PendingForegroundRun {
token: string;
kind: "foreground";
stagedEvents: AgentStreamEvent[];
turnId: string | null;
started: boolean;
start:
| { status: "pending" }
| { status: "started"; turnId: string }
| { status: "failed"; error: string };
settled: boolean;
settledPromise: Promise<void>;
resolveSettled: () => void;
@@ -64,6 +66,13 @@ export class AgentRunState {
return this.runs.has(agentId);
}
getTurnId(agentId: string): string | null {
const run = this.runs.get(agentId);
if (!run) return null;
if (run.kind === "autonomous") return run.turnId;
return run.start.status === "started" ? run.start.turnId : null;
}
trackAutonomousRun(agentId: string, turnId: string | null): TrackedAgentRun {
const current = this.runs.get(agentId);
if (current) {
@@ -85,7 +94,10 @@ export class AgentRunState {
if (!run) {
return;
}
if (run.kind === "foreground" && (run.turnId === null || run.turnId !== turnId)) {
if (
run.kind === "foreground" &&
(run.start.status !== "started" || run.start.turnId !== turnId)
) {
return;
}
if (
@@ -268,8 +280,7 @@ function createPendingForegroundRun(): PendingForegroundRun {
return {
...createTrackedRunState(),
kind: "foreground",
turnId: null,
started: false,
start: { status: "pending" },
stagedEvents: [],
};
}
@@ -425,6 +425,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
expect(models.map((m) => m.id)).toEqual([
"claude-opus-5",
"claude-fable-5-1",
"claude-fable-5",
"claude-fable-5[1m]",
"claude-opus-4-8[1m]",
@@ -495,6 +496,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
};
expect(getThinkingIds("claude-opus-5")).toContain("ultracode");
expect(getThinkingIds("claude-fable-5-1")).toContain("ultracode");
expect(getThinkingIds("claude-fable-5")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
@@ -45,12 +45,19 @@ export const CLAUDE_MODEL_MANIFEST = [
supportsThinkingDisabled: true,
supportsFastMode: true,
},
{
id: "claude-fable-5-1",
label: "Fable 5.1",
description: "Fable 5.1 · Most powerful model",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
},
{
id: "claude-fable-5",
// COMPAT(claudeFable5OneMillionId): added in v0.3.0, remove after 2027-02-06 once pre-v0.3.0 app preferences are outside support.
aliases: ["claude-fable-5[1m]"],
label: "Fable 5",
description: "Fable 5 · Most powerful model",
description: "Fable 5 · Previous release",
minimumClaudeCodeVersion: "2.1.169",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
@@ -326,7 +333,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
}
const runtimeMatch = trimmed.match(
/^(?:claude[-_ ])?(opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
/^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
);
if (!runtimeMatch) {
return null;
@@ -371,7 +378,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
}
const runtimeMatch = trimmed.match(
/claude[-_ ](opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
);
if (!runtimeMatch) {
return null;
@@ -417,6 +424,14 @@ function normalizeMajorMinorClaudeModelId(
): string | null {
const family = familyValue.toLowerCase();
const suffix = hasOneMillionContext ? "[1m]" : "";
const candidate = `claude-${family}-${major}-${minor}${suffix}`;
return isClaudeManifestModelId(candidate) ? candidate : null;
const candidates = [
`claude-${family}-${major}-${minor}${suffix}`,
`claude-${family}-${major}-${minor}`,
];
for (const candidate of candidates) {
if (isClaudeManifestModelId(candidate)) {
return candidate;
}
}
return null;
}
@@ -51,6 +51,7 @@ describe("getClaudeModels", () => {
const models = getClaudeModels();
expect(models.map((m) => m.id)).toEqual([
"claude-opus-5",
"claude-fable-5-1",
"claude-fable-5",
"claude-fable-5[1m]",
"claude-opus-4-8[1m]",
@@ -82,6 +83,7 @@ describe("getClaudeModels", () => {
expect(contextWindows).toEqual(
new Map([
["claude-opus-5", 1_000_000],
["claude-fable-5-1", 1_000_000],
["claude-fable-5", 1_000_000],
["claude-fable-5[1m]", 1_000_000],
["claude-opus-4-8[1m]", 1_000_000],
@@ -160,6 +162,9 @@ describe("getClaudeModels", () => {
expect(models.get("claude-fable-5")?.thinkingOptions?.map((option) => option.id)).not.toContain(
CLAUDE_DISABLED_THINKING_OPTION_ID,
);
expect(
models.get("claude-fable-5-1")?.thinkingOptions?.map((option) => option.id),
).not.toContain(CLAUDE_DISABLED_THINKING_OPTION_ID);
expect(models.get("claude-haiku-4-5")?.thinkingOptions).toBeUndefined();
});
@@ -170,6 +175,7 @@ describe("getClaudeModels", () => {
["claude-sonnet-5[1m]", true, "high"],
["claude-sonnet-5-20260101", true, "high"],
["claude-fable-5", false, "high"],
["claude-fable-5-1", false, "high"],
["claude-haiku-4-5", false, undefined],
["openrouter/anthropic/claude-opus-4-8", false, undefined],
[null, false, undefined],
@@ -458,7 +464,7 @@ describe("Claude Opus 5 catalog", () => {
describe("Claude Fable 5 catalog", () => {
it("offers one selectable Fable 5 entry and a compatibility entry for old apps", () => {
const fable5Models = getClaudeModels()
.filter((model) => model.id.startsWith("claude-fable-5"))
.filter((model) => model.id === "claude-fable-5" || model.id === "claude-fable-5[1m]")
.map(({ id, aliases, isSelectable, label, contextWindowMaxTokens }) => ({
id,
aliases,
@@ -491,6 +497,24 @@ describe("Claude Fable 5 catalog", () => {
});
});
describe("Claude Fable 5.1 catalog", () => {
it("offers one Fable 5.1 entry with a 1M context window", () => {
const fable51Models = getClaudeModels()
.filter((model) => model.id.startsWith("claude-fable-5-1"))
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
expect(fable51Models).toEqual([
{ id: "claude-fable-5-1", label: "Fable 5.1", contextWindowMaxTokens: 1_000_000 },
]);
});
it("resolves suffixed and dated Fable 5.1 IDs to the catalog entry", () => {
expect(findClaudeModel("claude-fable-5-1[1m]")?.id).toBe("claude-fable-5-1");
expect(findClaudeModel("claude-fable-5-1-20260901")?.id).toBe("claude-fable-5-1");
expect(findClaudeModel("claude-fable-5-1-20260901[1m]")?.id).toBe("claude-fable-5-1");
});
});
describe("claudeManifestModelSupportsFastMode", () => {
it("keeps fast mode strict to first-party manifest model IDs", () => {
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
@@ -502,5 +526,6 @@ describe("claudeManifestModelSupportsFastMode", () => {
expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true);
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);
expect(claudeManifestModelSupportsFastMode("claude-fable-5-1")).toBe(false);
});
});
@@ -54,6 +54,14 @@ readline.createInterface({ input: process.stdin, crlfDelay: Infinity }).on("line
});
`;
const CLOSED_STDIN_CHILD_SOURCE = String.raw`
const fs = require("node:fs");
fs.closeSync(0);
process.stdout.write(JSON.stringify({ type: "stdin_closed" }) + "\n");
setInterval(() => {}, 1_000);
`;
interface InMemoryChildProcess extends ChildProcessWithoutNullStreams {
stdin: PassThrough;
stdout: PassThrough;
@@ -63,6 +71,7 @@ interface InMemoryChildProcess extends ChildProcessWithoutNullStreams {
interface StartProcessOptions {
child?: ChildProcessWithoutNullStreams;
defaultRequestTimeoutMs?: number;
source?: string;
}
function createInMemoryChildProcess(): InMemoryChildProcess {
@@ -85,7 +94,7 @@ function startProcess(options: StartProcessOptions = {}): JsonlRpcProcess {
return new JsonlRpcProcess({
launch: {
command: process.execPath,
args: ["-e", CHILD_SOURCE, "--", "resolved-arg"],
args: ["-e", options.source ?? CHILD_SOURCE, "--", "resolved-arg"],
cwd: process.cwd(),
env: { JSONL_RPC_TEST_VALUE: "resolved-env" },
},
@@ -222,6 +231,70 @@ describe("JsonlRpcProcess", () => {
await rejection;
});
test("closes the transport when a direct send synchronously throws EPIPE", async () => {
const child = createInMemoryChildProcess();
const transport = startProcess({ child });
child.stdin.write = () => {
throw Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
};
expect(() => transport.send({ type: "notice" })).not.toThrow();
await expect(transport.request({ type: "echo", value: "after" })).rejects.toThrow(
"JSONL RPC process is closed",
);
});
// Closing fd 0 does not sever the inherited pipe on Windows, so this fixture cannot produce EPIPE.
test.skipIf(process.platform === "win32")(
"keeps the parent alive when a real child closes its stdin pipe",
async () => {
const transport = startProcess({ source: CLOSED_STDIN_CHILD_SOURCE });
const stdinClosed = new Promise<void>((resolve) => {
const unsubscribe = transport.onMessage((message) => {
if (message.type !== "stdin_closed") return;
unsubscribe();
resolve();
});
});
await stdinClosed;
await expect(transport.request({ type: "echo", value: "after" })).rejects.toThrow(
/EPIPE|stdin is not writable/,
);
await expect(transport.request({ type: "echo", value: "later" })).rejects.toThrow(
"JSONL RPC process is closed",
);
},
);
test("stdin error events close the transport instead of becoming uncaught exceptions", async () => {
const child = createInMemoryChildProcess();
const transport = startProcess({ child });
const request = transport.request({ type: "hang" });
const err = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
child.stdin.emit("error", err);
await expect(request).rejects.toThrow("write EPIPE");
await expect(transport.request({ type: "echo", value: "after" })).rejects.toThrow(
"JSONL RPC process is closed",
);
});
test("a non-writable stdin closes the transport instead of hanging the request", async () => {
const child = createInMemoryChildProcess();
const transport = startProcess({ child });
child.stdin.end();
await expect(transport.request({ type: "hang" })).rejects.toThrow(
"JSONL RPC stdin is not writable",
);
await expect(transport.request({ type: "echo", value: "after" })).rejects.toThrow(
"JSONL RPC process is closed",
);
});
test("reassembles protocol v2 chunked responses into the logical frame", async () => {
const child = createInMemoryChildProcess();
const transport = startProcess({ child });
@@ -104,6 +104,9 @@ export class JsonlRpcProcess {
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
}
});
this.child.stdin.on("error", (error) => {
this.handleStdinError(error);
});
this.child.on("error", (error) => {
this.failAll(error instanceof Error ? error : new Error(String(error)));
});
@@ -173,10 +176,18 @@ export class JsonlRpcProcess {
}
send(message: Record<string, unknown>): void {
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
if (this.disposed) {
return;
}
this.child.stdin.write(`${JSON.stringify(message)}\n`);
if (this.child.stdin.destroyed || !this.child.stdin.writable) {
this.handleStdinError(new Error(`${this.diagnosticName} stdin is not writable`));
return;
}
try {
this.child.stdin.write(`${JSON.stringify(message)}\n`);
} catch (error) {
this.handleStdinError(error);
}
}
async close(error = new Error(`${this.diagnosticName} process is closed`)): Promise<void> {
@@ -243,6 +254,15 @@ export class JsonlRpcProcess {
pending.resolve(response.data);
}
private handleStdinError(error: unknown): void {
if (this.disposed) {
return;
}
const err = error instanceof Error ? error : new Error(String(error));
this.options.logger.warn({ err }, `${this.diagnosticName} stdin write failed`);
void this.close(err);
}
private failAll(error: Error): void {
if (this.disposed) {
return;
@@ -6361,7 +6361,7 @@ describe("OpenCode provider subagent contract", () => {
messageID: "msg_child_prompt",
type: "text",
text: "Inspect the auth flow.",
time: { start: 1, end: 2 },
time: { start: 1 },
},
},
});
@@ -2811,7 +2811,7 @@ function appendOpenCodeTextPart(
events: AgentStreamEvent[],
): void {
if (messageRole === "user") {
if (!part.time?.end || !part.text || state.emittedUserMessageIds?.has(part.messageID)) {
if (!part.text || state.emittedUserMessageIds?.has(part.messageID)) {
return;
}
state.emittedUserMessageIds?.add(part.messageID);
@@ -10,6 +10,7 @@ import {
OWNER_PERMISSIONS,
SessionAuthorization,
permissionsForLegacyHubScopes,
parseDaemonPermissions,
} from "./index.js";
function inboundOperationTypes(): SessionInboundMessage["type"][] {
@@ -71,4 +72,9 @@ describe("SessionAuthorization", () => {
),
).toBe(true);
});
test("permission parsing validates against the shared registry and removes duplicates", () => {
expect(parseDaemonPermissions(["hub.execute", "hub.execute"])).toEqual(["hub.execute"]);
expect(() => parseDaemonPermissions(["hub.execution.*"])).toThrow("Invalid daemon permission");
});
});
@@ -1,22 +1,23 @@
import type { SessionInboundMessage, SessionOutboundMessage } from "../messages.js";
import { DAEMON_PERMISSIONS, type DaemonPermission } from "@getpaseo/protocol/messages";
import {
requiredPermissionForInbound,
requiredPermissionForOutbound,
} from "./operation-permissions.js";
export const DAEMON_PERMISSIONS = [
"daemon.read",
"daemon.manage",
"tunnel.manage",
"access.manage",
"workspace.read",
"workspace.write",
"workspace.manage",
"automation.manage",
"hub.execute",
] as const;
export { DAEMON_PERMISSIONS, type DaemonPermission };
export type DaemonPermission = (typeof DAEMON_PERMISSIONS)[number];
const daemonPermissionSet: ReadonlySet<string> = new Set(DAEMON_PERMISSIONS);
export function isDaemonPermission(value: string): value is DaemonPermission {
return daemonPermissionSet.has(value);
}
export function parseDaemonPermissions(values: readonly string[]): DaemonPermission[] {
const permissions = [...new Set(values)];
if (!permissions.every(isDaemonPermission)) throw new Error("Invalid daemon permission");
return permissions;
}
export const OWNER_PERMISSIONS: readonly DaemonPermission[] = DAEMON_PERMISSIONS;
@@ -39,6 +40,14 @@ export class SessionAuthorization {
this.permissions = new Set(permissions);
}
listPermissions(): DaemonPermission[] {
return [...this.permissions];
}
allowsPermission(permission: DaemonPermission): boolean {
return this.permissions.has(permission);
}
private allows(permission: DaemonPermission | null): boolean {
return permission === null || this.permissions.has(permission);
}
@@ -96,6 +96,7 @@ const INBOUND_PERMISSION = {
"hub.management.daemon.connect.request": "tunnel.manage",
"hub.management.daemon.disconnect.request": "tunnel.manage",
"hub.management.daemon.get_status.request": "tunnel.manage",
"hub.management.daemon.permissions.update.request": "access.manage",
import_agent_request: "workspace.write",
kill_terminal_request: "workspace.write",
list_available_editors_request: "workspace.read",
@@ -305,6 +306,7 @@ const OUTBOUND_PERMISSION = {
"hub.management.daemon.connect.response": "tunnel.manage",
"hub.management.daemon.disconnect.response": "tunnel.manage",
"hub.management.daemon.get_status.response": "tunnel.manage",
"hub.management.daemon.permissions.update.response": "access.manage",
kill_terminal_response: "workspace.write",
list_available_editors_response: "workspace.read",
list_available_providers_response: "daemon.read",
@@ -536,10 +536,13 @@ describe("paseo daemon bootstrap", () => {
await enrollmentReleased;
return {
daemonId: input.daemonId,
scopes: input.scopes,
permissions: input.permissions,
webSocketUrl: "wss://hub.test/daemon",
};
},
async updatePermissions(input) {
return { permissions: input.permissions };
},
async revoke(_input: HubRevocation): Promise<void> {},
openSocket(_input: HubSocketCredentials, _events: HubSocketEvents): HubSocketConnection {
return { close: () => undefined };
+27 -7
View File
@@ -671,7 +671,7 @@ export async function createPaseoDaemon(
serviceProxy,
onChange: createScriptStatusEmitter({
sessions: () =>
wsServer?.listTrustedSessions().map((session) => ({
wsServer?.listSessions().map((session) => ({
emit: (message) => session.emitServerMessage(message),
})) ?? [],
serviceProxy,
@@ -951,7 +951,7 @@ export async function createPaseoDaemon(
onWorkspaceArchived: teardownArchivedWorkspaceRuntime,
onWorkspacesChanged: async (workspaceIds) => {
await fanOutReconciledWorkspaceUpdates({
sessions: wsServer?.listTrustedSessions() ?? [],
sessions: wsServer?.listSessions() ?? [],
workspaceIds,
logger,
});
@@ -1015,20 +1015,20 @@ export async function createPaseoDaemon(
};
const markWorkspaceArchivingExternal = (workspaceIds: Iterable<string>, archivingAt: string) => {
const workspaceIdList = Array.from(workspaceIds);
for (const session of wsServer?.listTrustedSessions() ?? []) {
for (const session of wsServer?.listSessions() ?? []) {
session.markWorkspaceArchivingForExternalMutation(workspaceIdList, archivingAt);
}
};
const clearWorkspaceArchivingExternal = (workspaceIds: Iterable<string>) => {
const workspaceIdList = Array.from(workspaceIds);
for (const session of wsServer?.listTrustedSessions() ?? []) {
for (const session of wsServer?.listSessions() ?? []) {
session.clearWorkspaceArchivingForExternalMutation(workspaceIdList);
}
};
const emitWorkspaceUpdatesExternal = async (workspaceIds: Iterable<string>) => {
const workspaceIdList = Array.from(workspaceIds);
await Promise.all(
(wsServer?.listTrustedSessions() ?? []).map((session) =>
(wsServer?.listSessions() ?? []).map((session) =>
session.emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIdList),
),
);
@@ -1108,7 +1108,7 @@ export async function createPaseoDaemon(
warmWorkspaceGitData: async (workspace) => {
await Promise.all(
wsServer
?.listTrustedSessions()
?.listSessions()
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
);
},
@@ -1206,7 +1206,27 @@ export async function createPaseoDaemon(
createDaemonId: dependencies.createHubDaemonId,
attachSocket: async (socket, options) => {
if (!wsServer) throw new Error("WebSocket server is not running");
await wsServer.attachHubSocket(socket, options);
await wsServer.attachExternalSocket(
socket,
{ transport: "hub", hubDaemonId: options.daemonId },
{
principalId: options.principalId,
permissions: options.permissions,
hubExecutionAgents: options.agents,
},
options.sessionProtocol === "legacy"
? {
type: "hello",
clientId: `hub:${options.daemonId}`,
clientType: "hub",
protocolVersion: 1,
}
: undefined,
);
},
updateAttachedPermissions: (principalId, permissions) => {
if (!wsServer) throw new Error("WebSocket server is not running");
wsServer.updatePrincipalPermissions(principalId, permissions);
},
createExecutionAgents: (daemonId) =>
new DaemonExecutions({
@@ -35,7 +35,7 @@ test("Hub retries one durable daemon execution across concurrency and reconstruc
expect(reconstructed.durableAgentCount).toBe(1);
});
test("Hub denies trusted steering and browser dispatch", async () => {
test("Hub session applies its scoped authorization to ordinary protocol messages", async () => {
const hub = await launchRelationship();
const localAgentId = await hub.createUnrelatedLocalAgent();
@@ -55,22 +55,34 @@ test("Hub denies trusted steering and browser dispatch", async () => {
code: "access_denied",
});
expect(hub.observedAgentIds()).not.toContain(localAgentId);
expect(hub.observedTrustedLifecycleMessages()).toEqual([]);
expect(hub.observedTrustedLifecycleMessages()).toEqual(["server_info"]);
expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]);
});
test("Hub sockets reject trusted hello and capabilities", async () => {
test("Hub completes the standard hello before rejecting a second hello", async () => {
const hub = await launchRelationship();
expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]);
expect(hub.probeTrustedHello()).toBe(4002);
});
test("Hub sockets reject trusted binary frames", async () => {
const hub = await launchRelationship();
test("legacy Hub wire behavior still enters the common Session bootstrap", async () => {
const launched = await HubRelationshipHarness.start();
await launched.beginConnect().result;
launched.connectLatestLegacySocket();
relationship = launched;
expect(hub.probeBinaryFrame()).toBe(4002);
expect(launched.observedTrustedLifecycleMessages()).toEqual(["server_info"]);
expect(launched.serverInfoPermissions()).toEqual([["hub.execute"]]);
});
test("Hub does not receive trusted broadcasts", async () => {
test("Hub binary frames enter the standard active-session path", async () => {
const hub = await launchRelationship();
expect(hub.probeBinaryFrame()).toBeNull();
});
test("Hub receives standard server info but not broadcasts outside its scope", async () => {
const hub = await launchRelationship();
const trustedBroadcasts = await hub.trustedBroadcastCount();
@@ -78,10 +90,10 @@ test("Hub does not receive trusted broadcasts", async () => {
expect(trustedBroadcasts).toBe(0);
expect(trustedStatus).toMatchObject({ pid: process.pid, relay: { enabled: false } });
expect(hub.observedTrustedLifecycleMessages()).toEqual([]);
expect(hub.observedTrustedLifecycleMessages()).toEqual(["server_info"]);
});
test("Hub reconnects without retaining trusted session state", async () => {
test("Hub reconnects through the standard resumable session bootstrap", async () => {
const hub = await launchRelationship();
const created = await hub.createOwnedConcurrently();
@@ -91,7 +103,8 @@ test("Hub reconnects without retaining trusted session state", async () => {
executionId: "execution-1",
agentId: created.first.agentId,
});
expect(hub.observedTrustedLifecycleMessages()).toEqual([]);
expect(hub.observedTrustedLifecycleMessages()).toEqual(["server_info", "server_info"]);
expect(hub.serverInfoPermissions()).toEqual([["hub.execute"], ["hub.execute"]]);
});
test("Hub interrupts an owned running execution idempotently", async () => {
@@ -98,27 +98,68 @@ describe("Hub relationship", () => {
});
});
test("Hub enrollment cannot widen the daemon's locally granted scopes", async () => {
test("new relationships can connect without granting Hub execution", async () => {
relationship = await HubRelationshipHarness.start();
relationship.returnEnrollmentScopes(["hub.execution.*", "*"]);
relationship.returnEnrollmentPermissions([]);
await relationship.beginConnect().result;
await relationship.beginConnect("registered-token", "https://hub.example", false).result;
relationship.connectLatestSocket();
const responses = relationship.sendHubRequestOnLatest({
type: "daemon.get_status.request",
requestId: "scope-escalation",
type: "hub.execution.agent.create.request",
requestId: "registered-execution",
executionId: "execution-1",
provider: "codex",
cwd: "/workspace",
prompt: "Do not run",
});
expect(relationship.relationshipFile()?.relationship.scopes).toEqual(["hub.execution.*"]);
expect(relationship.relationshipFile()?.relationship.permissions).toEqual([]);
expect(responses).toContainEqual({
type: "rpc_error",
payload: {
requestId: "scope-escalation",
requestType: "daemon.get_status.request",
error: "Session is not authorized for daemon.get_status.request",
requestId: "registered-execution",
requestType: "hub.execution.agent.create.request",
error: "Session is not authorized for hub.execution.agent.create.request",
code: "access_denied",
},
});
await relationship.grantPermission("hub.execute");
expect(relationship.relationshipFile()?.relationship.permissions).toEqual(["hub.execute"]);
relationship.beginOwnedCreate("granted-execution", "execution-after-grant");
await expect(relationship.ownedCreateResult("granted-execution")).resolves.toMatchObject({
payload: { success: true, executionId: "execution-after-grant" },
});
await relationship.revokePermission("hub.execute");
expect(relationship.relationshipFile()?.relationship.permissions).toEqual([]);
const revokedResponses = relationship.sendHubRequestOnLatest({
type: "hub.execution.agent.create.request",
requestId: "revoked-execution",
executionId: "execution-after-revoke",
provider: "codex",
cwd: "/workspace",
prompt: "Do not run",
});
expect(revokedResponses).toContainEqual({
type: "rpc_error",
payload: {
requestId: "revoked-execution",
requestType: "hub.execution.agent.create.request",
error: "Session is not authorized for hub.execution.agent.create.request",
code: "access_denied",
},
});
});
test("Hub enrollment cannot widen the daemon's locally granted permissions", async () => {
relationship = await HubRelationshipHarness.start();
relationship.returnEnrollmentPermissions(["hub.execute", "daemon.manage"]);
await relationship.beginConnect().result;
expect(relationship.relationshipFile()?.relationship.permissions).toEqual(["hub.execute"]);
expect((await relationship.status()).state).toBe("reconnecting");
expect(relationship.socketAttempts()).toBe(0);
});
test("a lost enrollment response reuses the exact ceremony", async () => {
@@ -232,6 +273,33 @@ describe("Hub relationship", () => {
},
);
test("legacy execution scope migrates once to the semantic permission", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.corruptRelationshipFile(
JSON.stringify({
version: 1,
state: "active",
relationship: {
daemonId: "daemon-legacy",
idempotencyKey: "ceremony-legacy",
hubOrigin: "https://hub.test",
createdAt: "2026-07-13T00:00:00.000Z",
scopes: ["hub.execution.*"],
},
credential: { secret: "credential" },
transport: { kind: "direct_websocket", webSocketUrl: "wss://hub.test/daemon" },
}),
);
await relationship.startStoppedDaemon();
expect(relationship.relationshipFile()).toMatchObject({
version: 2,
relationship: { permissions: ["hub.execute"] },
});
expect(await relationship.status()).toMatchObject({ permissions: "hub.execute" });
});
test.each([
["a non-HTTP scheme", "ftp://hub.test"],
["embedded credentials", "https://user:password@hub.test"],
@@ -264,12 +332,12 @@ describe("Hub relationship", () => {
expect(relationship.enrollmentAttempts()).toEqual([]);
});
test("a persisted Hub relationship cannot widen its local execution scope", async () => {
test("a persisted Hub relationship cannot widen its local permissions", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
const persisted = relationship.relationshipFile();
expect(persisted).not.toBeNull();
persisted!.relationship.scopes = ["*"];
persisted!.relationship.permissions = ["*"];
await relationship.corruptRelationshipFile(JSON.stringify(persisted));
const socketAttemptsBeforeRestart = relationship.socketAttempts();
@@ -744,14 +812,14 @@ describe("Hub relationship", () => {
state: "revoked",
daemonId: enrollment.daemonId,
hub: "https://hub.test",
scopes: "hub.execution.*",
permissions: "hub.execute",
error: reason,
});
expect(persisted?.state).toBe("revoked");
expect(persisted?.relationship).toMatchObject({
daemonId: enrollment.daemonId,
hubOrigin: "https://hub.test",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
});
expect(persisted?.reason).toBe(reason);
expect(persisted).not.toHaveProperty("credential");
@@ -5,6 +5,12 @@ import type pino from "pino";
import { z } from "zod";
import { ensurePrivateFile, writePrivateFileAtomicSync } from "../private-files.js";
import type { WebSocketLike } from "../websocket-server.js";
import {
isDaemonPermission,
parseDaemonPermissions,
permissionsForLegacyHubScopes,
type DaemonPermission,
} from "../authorization/index.js";
import type { HubExecutionAgents } from "./daemon-executions.js";
import type {
HubRelationshipRemote,
@@ -15,8 +21,6 @@ import { HubEnrollmentRejectedError } from "./relationship-remote.js";
import { BoundedExponentialHubRetryPolicy } from "./relationship-retry.js";
const FILE_NAME = "hub-relationship.json";
const HUB_EXECUTION_SCOPE = "hub.execution.*";
const SCOPES = [HUB_EXECUTION_SCOPE] as const;
const HubOriginSchema = z
.string()
.url()
@@ -31,12 +35,22 @@ const HubOriginSchema = z
}
});
const DaemonPermissionsSchema = z
.array(z.string())
.superRefine((permissions, context) => {
for (const permission of permissions) {
if (!isDaemonPermission(permission)) {
context.addIssue({ code: "custom", message: `Invalid daemon permission: ${permission}` });
}
}
})
.transform(parseDaemonPermissions);
const RelationshipSchema = z.object({
daemonId: z.string().min(1),
idempotencyKey: z.string().min(1),
hubOrigin: HubOriginSchema,
createdAt: z.string(),
scopes: z.tuple([z.literal(HUB_EXECUTION_SCOPE)]),
permissions: DaemonPermissionsSchema,
});
const SanitizedRelationshipSchema = RelationshipSchema.omit({ idempotencyKey: true });
const CredentialSchema = z.object({ secret: z.string().min(1) });
@@ -49,7 +63,7 @@ const TransportSchema = z.object({
.refine((value) => new URL(value).hash === ""),
});
const PendingSchema = z.object({
version: z.literal(1),
version: z.literal(2),
state: z.literal("pending"),
relationship: RelationshipSchema,
credential: CredentialSchema,
@@ -57,27 +71,27 @@ const PendingSchema = z.object({
identity: z.object({ serverId: z.string().min(1), daemonPublicKey: z.string().min(1) }),
});
const ActiveSchema = z.object({
version: z.literal(1),
version: z.literal(2),
state: z.literal("active"),
relationship: RelationshipSchema,
credential: CredentialSchema,
transport: TransportSchema,
});
const DisconnectingSchema = z.object({
version: z.literal(1),
version: z.literal(2),
state: z.literal("disconnecting"),
relationship: RelationshipSchema,
credential: CredentialSchema,
transport: TransportSchema.optional(),
});
const RevokedSchema = z.object({
version: z.literal(1),
version: z.literal(2),
state: z.literal("revoked"),
relationship: SanitizedRelationshipSchema,
transport: TransportSchema.optional(),
reason: z.string().optional(),
});
const RecordSchema = z
const CurrentRecordSchema = z
.discriminatedUnion("state", [PendingSchema, ActiveSchema, DisconnectingSchema, RevokedSchema])
.superRefine((record, context) => {
if (!("transport" in record) || !record.transport) return;
@@ -91,6 +105,7 @@ const RecordSchema = z
message: "Hub WebSocket URL must match the Hub origin",
});
});
const RecordSchema = z.preprocess(migrateLegacyRecord, CurrentRecordSchema);
type PendingRecord = z.infer<typeof PendingSchema>;
type ActiveRecord = z.infer<typeof ActiveSchema>;
type DisconnectingRecord = z.infer<typeof DisconnectingSchema>;
@@ -109,13 +124,21 @@ export interface HubRelationshipStatus {
state: HubConnectionState;
daemonId: string | null;
hubOrigin: string | null;
scopes: string[];
permissions: DaemonPermission[];
connectedAt: string | null;
lastError: string | null;
}
export interface HubRelationshipManagement {
connect(input: { hubUrl: string; token: string }): Promise<HubRelationshipStatus>;
connect(input: {
hubUrl: string;
token: string;
permissions: readonly string[];
}): Promise<HubRelationshipStatus>;
updatePermissions(input: {
grant: readonly string[];
revoke: readonly string[];
}): Promise<HubRelationshipStatus>;
status(): HubRelationshipStatus;
disconnect(input: {
force: boolean;
@@ -147,8 +170,18 @@ export interface HubRelationshipControllerOptions {
createDaemonId?: () => string;
attachSocket: (
socket: WebSocketLike,
options: { daemonId: string; scopes: readonly string[]; agents: HubExecutionAgents },
options: {
daemonId: string;
principalId: string;
permissions: readonly DaemonPermission[];
agents: HubExecutionAgents;
sessionProtocol: "legacy" | "session-v1";
},
) => Promise<void>;
updateAttachedPermissions: (
principalId: string,
permissions: readonly DaemonPermission[],
) => void;
createExecutionAgents: (daemonId: string) => HubExecutionAgents;
}
@@ -241,13 +274,18 @@ export class HubRelationshipController implements HubRelationshipManagement {
state: this.state,
daemonId: this.record?.relationship.daemonId ?? null,
hubOrigin: this.record?.relationship.hubOrigin ?? null,
scopes: this.record?.relationship.scopes.slice() ?? [],
permissions: this.record?.relationship.permissions.slice() ?? [],
connectedAt: this.connectedAt,
lastError: this.lastError,
};
}
async connect(input: { hubUrl: string; token: string }): Promise<HubRelationshipStatus> {
async connect(input: {
hubUrl: string;
token: string;
permissions: readonly string[];
}): Promise<HubRelationshipStatus> {
const permissions = parseDaemonPermissions(input.permissions);
if (this.record?.state === "pending") {
if (normalizeHubUrl(input.hubUrl) !== this.record.relationship.hubOrigin) {
throw new Error("A pending Hub enrollment already exists for a different Hub");
@@ -264,14 +302,14 @@ export class HubRelationshipController implements HubRelationshipManagement {
throw new Error("This daemon already has a Hub relationship");
}
const pending: PendingRecord = {
version: 1,
version: 2,
state: "pending",
relationship: {
daemonId: this.options.createDaemonId?.() ?? randomUUID(),
idempotencyKey: randomUUID(),
hubOrigin: normalizeHubUrl(input.hubUrl),
createdAt: this.clock.now().toISOString(),
scopes: [...SCOPES],
permissions,
},
credential: { secret: randomBytes(32).toString("base64url") },
enrollment: { token: input.token },
@@ -285,6 +323,37 @@ export class HubRelationshipController implements HubRelationshipManagement {
return this.status();
}
async updatePermissions(input: {
grant: readonly string[];
revoke: readonly string[];
}): Promise<HubRelationshipStatus> {
if (!this.record || this.record.state !== "active") {
throw new Error("This daemon is not connected to a Hub");
}
const grant = parseDaemonPermissions(input.grant);
const revoke = parseDaemonPermissions(input.revoke);
const permissions = parseDaemonPermissions([
...this.record.relationship.permissions.filter((permission) => !revoke.includes(permission)),
...grant,
]);
const result = await this.options.remote.updatePermissions({
daemonId: this.record.relationship.daemonId,
hubOrigin: this.record.relationship.hubOrigin,
credential: this.record.credential.secret,
permissions,
});
if (!samePermissions(result.permissions, permissions)) {
throw new Error("Hub permission response did not match the local grant");
}
this.record = {
...this.record,
relationship: { ...this.record.relationship, permissions },
};
this.persist(this.record);
this.options.updateAttachedPermissions(hubPrincipalId(this.record), permissions);
return this.status();
}
async disconnect(input: {
force: boolean;
}): Promise<{ status: HubRelationshipStatus; warning?: string }> {
@@ -299,7 +368,7 @@ export class HubRelationshipController implements HubRelationshipManagement {
return { status: this.status() };
}
const disconnecting: DisconnectingRecord = {
version: 1,
version: 2,
state: "disconnecting",
relationship: this.record.relationship,
credential: this.record.credential,
@@ -345,7 +414,7 @@ export class HubRelationshipController implements HubRelationshipManagement {
serverId: pending.identity.serverId,
daemonPublicKey: pending.identity.daemonPublicKey,
credentialVerifier: verifier,
scopes: pending.relationship.scopes,
permissions: pending.relationship.permissions,
});
const settled = request.then(
() => undefined,
@@ -357,12 +426,12 @@ export class HubRelationshipController implements HubRelationshipManagement {
if (enrollmentGeneration !== this.enrollmentGeneration) return;
if (
enrollment.daemonId !== pending.relationship.daemonId ||
!enrollment.scopes.includes(HUB_EXECUTION_SCOPE)
!samePermissions(enrollment.permissions, pending.relationship.permissions)
) {
throw new Error("Hub enrollment response did not match the pending relationship");
}
const active: ActiveRecord = {
version: 1,
version: 2,
state: "active",
relationship: pending.relationship,
credential: pending.credential,
@@ -390,7 +459,8 @@ export class HubRelationshipController implements HubRelationshipManagement {
const generation = ++this.generation;
this.state = reconnecting ? "reconnecting" : "connecting";
const events: HubSocketEvents = {
connected: (socket) => this.socketConnected(generation, record, socket),
connected: (socket, sessionProtocol) =>
this.socketConnected(generation, record, socket, sessionProtocol),
rejected: (statusCode) => this.socketRejected(generation, statusCode),
closed: (code) => this.socketClosed(generation, record, code),
failed: (error) => this.socketFailed(generation, record, error),
@@ -405,7 +475,12 @@ export class HubRelationshipController implements HubRelationshipManagement {
);
}
private socketConnected(generation: number, record: ActiveRecord, socket: WebSocketLike): void {
private socketConnected(
generation: number,
record: ActiveRecord,
socket: WebSocketLike,
sessionProtocol: "legacy" | "session-v1",
): void {
if (generation !== this.generation) {
socket.close();
return;
@@ -416,8 +491,10 @@ export class HubRelationshipController implements HubRelationshipManagement {
this.lastError = null;
void this.options.attachSocket(socket, {
daemonId: record.relationship.daemonId,
scopes: record.relationship.scopes,
principalId: hubPrincipalId(record),
permissions: record.relationship.permissions,
agents: this.executionAgentsFor(record.relationship.daemonId),
sessionProtocol,
});
}
@@ -487,13 +564,13 @@ export class HubRelationshipController implements HubRelationshipManagement {
this.cancelLifecycle();
if (!this.record) return;
const revoked: RevokedRecord = {
version: 1,
version: 2,
state: "revoked",
relationship: {
daemonId: this.record.relationship.daemonId,
hubOrigin: this.record.relationship.hubOrigin,
createdAt: this.record.relationship.createdAt,
scopes: this.record.relationship.scopes,
permissions: this.record.relationship.permissions,
},
transport: "transport" in this.record ? this.record.transport : undefined,
reason,
@@ -536,7 +613,9 @@ export class HubRelationshipController implements HubRelationshipManagement {
if (!existsSync(this.filePath)) return null;
let record: HubRelationshipRecord;
try {
record = RecordSchema.parse(JSON.parse(readFileSync(this.filePath, "utf8")));
const raw = JSON.parse(readFileSync(this.filePath, "utf8"));
record = RecordSchema.parse(raw);
if (isLegacyRecord(raw)) this.persist(record);
} catch (error) {
const quarantinePath = path.join(
path.dirname(this.filePath),
@@ -554,3 +633,38 @@ export class HubRelationshipController implements HubRelationshipManagement {
return record;
}
}
function hubPrincipalId(record: ActiveRecord): string {
const credentialFingerprint = createHash("sha256")
.update(record.credential.secret)
.digest("base64url");
return `hub:${record.relationship.daemonId}:${credentialFingerprint}`;
}
function samePermissions(actual: readonly string[], expected: readonly string[]): boolean {
return actual.length === expected.length && expected.every((scope) => actual.includes(scope));
}
function isLegacyRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && Reflect.get(value, "version") === 1;
}
function migrateLegacyRecord(value: unknown): unknown {
if (!isLegacyRecord(value)) return value;
const relationship = Reflect.get(value, "relationship");
if (typeof relationship !== "object" || relationship === null) return value;
const scopes = Reflect.get(relationship, "scopes");
if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string")) return value;
const { scopes: _legacyScopes, ...relationshipWithoutScopes } = relationship as Record<
string,
unknown
>;
return {
...(value as Record<string, unknown>),
version: 2,
relationship: {
...relationshipWithoutScopes,
permissions: permissionsForLegacyHubScopes(scopes),
},
};
}
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, expect, test } from "vitest";
import { WebSocket } from "ws";
import { WebSocket, WebSocketServer } from "ws";
import type { HubExecutionAgents } from "./daemon-executions.js";
import {
HubRelationshipController,
@@ -51,6 +51,87 @@ test("transient revocation failures remain retryable", async () => {
).rejects.toThrow("Hub revocation failed (503)");
});
test("permission updates use the daemon credential and preserve semantic permissions", async () => {
let observed: { method?: string; authorization?: string; body?: unknown } = {};
const server = createServer(async (request, response) => {
let body = "";
for await (const chunk of request) body += chunk;
observed = {
method: request.method,
authorization: request.headers.authorization,
body: JSON.parse(body),
};
response
.writeHead(200, { "content-type": "application/json" })
.end(JSON.stringify({ permissions: ["hub.execute"] }));
});
openServers.push(server);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address() as AddressInfo;
const hubOrigin = `http://127.0.0.1:${address.port}`;
const result = await new DirectHubRelationshipRemote().updatePermissions({
daemonId: "daemon-1",
hubOrigin,
credential: "daemon-secret",
permissions: ["hub.execute"],
});
expect(result).toEqual({ permissions: ["hub.execute"] });
expect(observed).toEqual({
method: "PATCH",
authorization: "Bearer daemon-secret",
body: { permissions: ["hub.execute"] },
});
});
test.each([
{ acknowledge: false, expected: "legacy" as const },
{ acknowledge: true, expected: "session-v1" as const },
])(
"negotiates $expected Hub session startup during WebSocket upgrade",
async ({ acknowledge, expected }) => {
const server = createServer();
const webSockets = new WebSocketServer({ noServer: true });
if (acknowledge) {
webSockets.on("headers", (headers) => headers.push("x-paseo-session-protocol: 1"));
}
let offeredProtocol: string | string[] | undefined;
server.on("upgrade", (request, socket, head) => {
offeredProtocol = request.headers["x-paseo-session-protocol"];
webSockets.handleUpgrade(request, socket, head, () => undefined);
});
openServers.push(server);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address() as AddressInfo;
const negotiated = deferred<"legacy" | "session-v1">();
const socket = new DirectHubRelationshipRemote().openSocket(
{
daemonId: "daemon-1",
credential: "credential",
webSocketUrl: `ws://127.0.0.1:${address.port}/daemon`,
},
{
connected: (_connected, protocol) => negotiated.resolve(protocol),
rejected: () => undefined,
closed: () => undefined,
failed: () => undefined,
},
);
await expect(negotiated.promise).resolves.toBe(expected);
expect(offeredProtocol).toBe("1");
socket.close();
webSockets.close();
},
);
test.each([408, 429])("transient enrollment status %s remains retryable", async (status) => {
const hubOrigin = await startHubReturning(status);
const remote = new DirectHubRelationshipRemote();
@@ -65,7 +146,7 @@ test.each([408, 429])("transient enrollment status %s remains retryable", async
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
})
.catch((caught: unknown) => caught);
@@ -91,7 +172,7 @@ test("enrollment rejects a transport URL that cannot open a WebSocket", async ()
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
}),
).rejects.toThrow("Hub WebSocket URL must use ws or wss");
expect(enrolledHostname).toBe("test-daemon.local");
@@ -111,7 +192,7 @@ test("enrollment rejects a WebSocket URL with a fragment", async () => {
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
}),
).rejects.toThrow("Hub WebSocket URL cannot include a fragment");
});
@@ -130,7 +211,7 @@ test("enrollment rejects a WebSocket outside the enrolled Hub authority", async
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
}),
).rejects.toThrow("Hub WebSocket URL must match the Hub origin");
});
@@ -150,7 +231,7 @@ test.each(["enrollment", "revocation"])("%s HTTP calls are bounded", async (oper
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
})
: remote.revoke({
daemonId: "daemon-1",
@@ -311,7 +392,7 @@ async function startEnrollmentHub(
response.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
daemonId: enrollment.daemonId,
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
webSocketUrl,
}),
);
@@ -402,7 +483,7 @@ class UpgradeRejectingHub {
response.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
daemonId: enrollment.daemonId,
scopes: ["hub.execution.*"],
permissions: ["hub.execute"],
webSocketUrl: this.webSocketUrl,
}),
);
@@ -544,9 +625,14 @@ async function connectController(
clock,
retryPolicy: clock,
attachSocket: async () => undefined,
updateAttachedPermissions: () => undefined,
createExecutionAgents: () => unusedExecutionAgents,
});
await controller.connect({ hubUrl: hub.origin, token: "enrollment-token" });
await controller.connect({
hubUrl: hub.origin,
token: "enrollment-token",
permissions: ["hub.execute"],
});
return controller;
}
@@ -11,15 +11,22 @@ export interface HubEnrollment {
serverId: string;
daemonPublicKey: string;
credentialVerifier: string;
scopes: string[];
permissions: string[];
}
export interface HubEnrollmentResult {
daemonId: string;
scopes: string[];
permissions: string[];
webSocketUrl: string;
}
export interface HubPermissionUpdate {
daemonId: string;
hubOrigin: string;
credential: string;
permissions: string[];
}
export interface HubRevocation {
daemonId: string;
hubOrigin: string;
@@ -33,7 +40,7 @@ export interface HubSocketCredentials {
}
export interface HubSocketEvents {
connected(socket: WebSocketLike): void;
connected(socket: WebSocketLike, sessionProtocol: "legacy" | "session-v1"): void;
rejected(statusCode: 401 | 403): void;
closed(code: number): void;
failed(error: Error): void;
@@ -45,6 +52,7 @@ export interface HubSocketConnection {
export interface HubRelationshipRemote {
enroll(input: HubEnrollment): Promise<HubEnrollmentResult>;
updatePermissions(input: HubPermissionUpdate): Promise<{ permissions: string[] }>;
revoke(input: HubRevocation): Promise<void>;
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection;
}
@@ -58,7 +66,7 @@ export class HubEnrollmentRejectedError extends Error {
const EnrollmentResultSchema = z.object({
daemonId: z.string(),
scopes: z.array(z.string()),
permissions: z.array(z.string()),
webSocketUrl: z
.string()
.url()
@@ -101,7 +109,7 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
serverId: input.serverId,
daemonPublicKey: input.daemonPublicKey,
credentialVerifier: input.credentialVerifier,
scopes: input.scopes,
permissions: input.permissions,
}),
signal,
});
@@ -117,6 +125,25 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
});
}
async updatePermissions(input: HubPermissionUpdate): Promise<{ permissions: string[] }> {
return this.withRequestTimeout(async (signal) => {
const response = await fetch(
`${input.hubOrigin}/api/daemons/${encodeURIComponent(input.daemonId)}`,
{
method: "PATCH",
headers: {
"content-type": "application/json",
authorization: `Bearer ${input.credential}`,
},
body: JSON.stringify({ permissions: input.permissions }),
signal,
},
);
if (!response.ok) throw new Error(`Hub permission update failed (${response.status})`);
return z.object({ permissions: z.array(z.string()) }).parse(await response.json());
});
}
async revoke(input: HubRevocation): Promise<void> {
await this.withRequestTimeout(async (signal) => {
const response = await fetch(
@@ -134,16 +161,23 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
}
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection {
let sessionProtocol: "legacy" | "session-v1" = "legacy";
const socket = new WebSocket(input.webSocketUrl, {
handshakeTimeout: this.requestTimeoutMs,
headers: {
authorization: `Bearer ${input.credential}`,
"x-paseo-daemon-id": input.daemonId,
"x-paseo-session-protocol": "1",
},
});
let settled = false;
socket.once("upgrade", (response) => {
if (response.headers["x-paseo-session-protocol"] === "1") {
sessionProtocol = "session-v1";
}
});
socket.once("open", () => {
if (!settled) events.connected(socket as WebSocketLike);
if (!settled) events.connected(socket as WebSocketLike, sessionProtocol);
});
socket.once("unexpected-response", (_request, response) => {
if (settled) {
@@ -85,7 +85,7 @@ interface PersistedRelationship {
daemonId: string;
idempotencyKey?: string;
hubOrigin: string;
scopes: string[];
permissions: string[];
};
credential?: { secret: string };
enrollment?: { token: string };
@@ -296,7 +296,7 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
private enrollmentObserved = deferred<void>();
private socketObserved = deferred<void>();
private enrollmentRejection: 401 | 403 | null = null;
private enrollmentScopes = ["hub.execution.*"];
private enrollmentPermissions: string[] | null = null;
private revokeFailures = 0;
private revocationGate: Deferred<void> | null = null;
private readonly relationships = new Set<string>();
@@ -313,13 +313,13 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
this.enrollmentRejection = statusCode;
}
returnEnrollmentScopes(scopes: string[]): void {
this.enrollmentScopes = scopes.slice();
returnEnrollmentPermissions(permissions: string[]): void {
this.enrollmentPermissions = permissions.slice();
}
async enroll(input: HubEnrollment): Promise<HubEnrollmentResult> {
this.enrollmentSnapshots.push(this.captureRelationship());
this.enrollments.push({ ...input, scopes: input.scopes.slice() });
this.enrollments.push({ ...input, permissions: input.permissions.slice() });
this.relationships.add(input.idempotencyKey);
this.enrollmentObserved.resolve();
if (this.enrollmentRejection) {
@@ -387,6 +387,15 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
}
}
async updatePermissions(input: {
daemonId: string;
hubOrigin: string;
credential: string;
permissions: string[];
}): Promise<{ permissions: string[] }> {
return { permissions: input.permissions.slice() };
}
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection {
this.socketSnapshots.push(this.captureRelationship());
const attempt = { input: { ...input }, events, socket: new MemoryHubSocket() };
@@ -410,7 +419,7 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
private enrollmentResult(input: HubEnrollment): HubEnrollmentResult {
return {
daemonId: input.daemonId,
scopes: this.enrollmentScopes.slice(),
permissions: this.enrollmentPermissions?.slice() ?? input.permissions.slice(),
webSocketUrl: SOCKET_URL,
};
}
@@ -505,9 +514,16 @@ export class HubRelationshipHarness {
this.remote.holdEnrollment();
}
beginConnect(token = "ceremony-token", hubUrl = HUB_ORIGIN): CliProcess {
beginConnect(token = "ceremony-token", hubUrl = HUB_ORIGIN, execute = true): CliProcess {
return {
result: this.runCli(["hub", "connect", hubUrl, "--api-key", `hub-contract-api-key:${token}`]),
result: this.runCli([
"hub",
"connect",
hubUrl,
"--api-key",
`hub-contract-api-key:${token}`,
...(execute ? ["--permission", "hub.execute"] : []),
]),
};
}
@@ -519,6 +535,14 @@ export class HubRelationshipHarness {
return this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]);
}
async grantPermission(permission: string): Promise<Record<string, unknown>> {
return this.runCli(["hub", "permissions", "grant", permission]);
}
async revokePermission(permission: string): Promise<Record<string, unknown>> {
return this.runCli(["hub", "permissions", "revoke", permission]);
}
beginDisconnect(force = false): CliProcess {
return { result: this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]) };
}
@@ -590,6 +614,7 @@ export class HubRelationshipHarness {
requestId: "browser-hub-connect",
hubUrl: HUB_ORIGIN,
token: "browser-token",
permissions: [],
},
}),
);
@@ -606,8 +631,8 @@ export class HubRelationshipHarness {
this.remote.completeEnrollment();
}
returnEnrollmentScopes(scopes: string[]): void {
this.remote.returnEnrollmentScopes(scopes);
returnEnrollmentPermissions(permissions: string[]): void {
this.remote.returnEnrollmentPermissions(permissions);
}
loseEnrollmentResponse(): void {
@@ -652,7 +677,18 @@ export class HubRelationshipHarness {
connectLatestSocket(): void {
const socket = this.latestSocket();
socket.events.connected(socket.socket);
socket.events.connected(socket.socket, "session-v1");
socket.socket.receiveEnvelope({
type: "hello",
clientId: `hub:${socket.input.daemonId}`,
clientType: "hub",
protocolVersion: 1,
});
}
connectLatestLegacySocket(): void {
const socket = this.latestSocket();
socket.events.connected(socket.socket, "legacy");
}
rejectLatestSocket(statusCode: 401 | 403): void {
@@ -676,7 +712,13 @@ export class HubRelationshipHarness {
connectSocket(index: number): void {
const socket = this.remote.sockets[index];
if (!socket) throw new Error(`Socket ${index} does not exist`);
socket.events.connected(socket.socket);
socket.events.connected(socket.socket, "session-v1");
socket.socket.receiveEnvelope({
type: "hello",
clientId: `hub:${socket.input.daemonId}`,
clientType: "hub",
protocolVersion: 1,
});
}
closeSocket(index: number, code: number): void {
@@ -1143,6 +1185,16 @@ export class HubRelationshipHarness {
);
}
serverInfoPermissions(): string[][] {
return this.remote.sockets.flatMap(({ socket }) =>
socket.sent.flatMap((message) =>
message.type === "status" && message.payload.status === "server_info"
? [message.payload.permissions ?? []]
: [],
),
);
}
probeTrustedHello(): number | null {
const socket = this.latestSocket().socket;
socket.receiveEnvelope({
@@ -1234,7 +1286,10 @@ export class HubRelationshipHarness {
}
enrollmentAttempts(): HubEnrollment[] {
return this.remote.enrollments.map((input) => ({ ...input, scopes: input.scopes.slice() }));
return this.remote.enrollments.map((input) => ({
...input,
permissions: input.permissions.slice(),
}));
}
enrollmentInvocation(index = 0): RelationshipInvocationSnapshot {
+31
View File
@@ -1911,6 +1911,33 @@ export class Session {
this.authorization.replacePermissions(permissions);
}
public getPermissions(): DaemonPermission[] {
return this.authorization.listPermissions();
}
public allowsInbound(message: SessionInboundMessage): boolean {
return this.authorization.allowsInbound(message);
}
public allowsPermission(permission: DaemonPermission): boolean {
return this.authorization.allowsPermission(permission);
}
public subscribesToAgent(agent: ManagedAgent): Promise<boolean> {
return this.agentUpdates.includesLiveAgent(agent);
}
public subscribesToTerminalDirectory(input: {
cwd: string;
workspaceId?: string;
}): Promise<boolean> {
return this.terminalController.hasDirectorySubscription(input);
}
public publish(message: SessionOutboundMessage): void {
this.emit(message);
}
private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise<void> {
const promise =
this.dispatchVoiceAndControlMessage(msg) ??
@@ -2341,6 +2368,7 @@ export class Session {
case "hub.management.daemon.connect.request":
case "hub.management.daemon.get_status.request":
case "hub.management.daemon.disconnect.request":
case "hub.management.daemon.permissions.update.request":
return this.daemonSession.handleHubRelationshipRequest(msg);
case "diagnostics.request":
return this.daemonSession.handleDiagnosticsRequest(msg);
@@ -2635,6 +2663,9 @@ export class Session {
}
public async handleBinaryFrame(binaryFrame: BinaryFrame): Promise<void> {
if (!this.authorization.allowsPermission("workspace.write")) {
return;
}
if (binaryFrame.kind === "file_transfer") {
await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame);
return;
@@ -617,6 +617,21 @@ describe("bootstrap buffering", () => {
});
describe("subscription lifecycle", () => {
test("attention eligibility follows the active directory subscription filter", async () => {
const h = buildHarness();
h.register(makeAgentPayload({ id: "matching", workspaceId: "ws-1", labels: { team: "a" } }));
h.register(makeAgentPayload({ id: "excluded", workspaceId: "ws-2", labels: { team: "b" } }));
expect(await h.service.includesLiveAgent(h.managed("matching"))).toBe(false);
h.service.beginSubscription({ subscriptionId: "sub", filter: { labels: { team: "a" } } });
expect(await h.service.includesLiveAgent(h.managed("matching"))).toBe(true);
expect(await h.service.includesLiveAgent(h.managed("excluded"))).toBe(false);
h.service.clearSubscription("sub");
expect(await h.service.includesLiveAgent(h.managed("matching"))).toBe(false);
});
test("flushBootstrapped is a no-op for a stale subscription id", async () => {
const h = buildHarness();
h.service.beginSubscription({ subscriptionId: "sub", filter: {} });
@@ -49,6 +49,7 @@ export interface AgentUpdatesService {
): void;
clearSubscription(subscriptionId: string): void;
hasSubscription(): boolean;
includesLiveAgent(agent: ManagedAgent): Promise<boolean>;
forwardLiveAgent(agent: ManagedAgent): Promise<void>;
emitStoredRecord(record: StoredAgentRecord): Promise<AgentSnapshotPayload>;
removeAgent(agentId: string): Promise<void>;
@@ -243,6 +244,28 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
return subscription !== null;
}
async function includesLiveAgent(agent: ManagedAgent): Promise<boolean> {
const activeSubscription = subscription;
if (!activeSubscription) return false;
const payload = await deps.enrichAgentPayload(toAgentPayload(agent));
if (subscription !== activeSubscription || !deps.isProviderVisibleToClient(payload.provider)) {
return false;
}
const project = payload.workspaceId
? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId)
: null;
return (
subscription === activeSubscription &&
project !== null &&
matchesAgentUpdatesFilter({
agent: payload,
project,
filter: activeSubscription.filter,
})
);
}
async function emitStoredRecord(record: StoredAgentRecord): Promise<AgentSnapshotPayload> {
const payload = deps.buildStoredAgentPayload(record);
const sub = subscription;
@@ -391,6 +414,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
flushBootstrapped,
clearSubscription,
hasSubscription,
includesLiveAgent,
forwardLiveAgent,
emitStoredRecord,
removeAgent,
@@ -110,7 +110,8 @@ export class DaemonSession {
type:
| "hub.management.daemon.connect.request"
| "hub.management.daemon.get_status.request"
| "hub.management.daemon.disconnect.request";
| "hub.management.daemon.disconnect.request"
| "hub.management.daemon.permissions.update.request";
}
>,
): Promise<void> {
@@ -120,6 +121,7 @@ export class DaemonSession {
const status = await this.hubRelationships.connect({
hubUrl: msg.hubUrl,
token: msg.token,
permissions: msg.permissions,
});
this.host.emit({
type: "hub.management.daemon.connect.response",
@@ -127,6 +129,17 @@ export class DaemonSession {
});
return;
}
if (msg.type === "hub.management.daemon.permissions.update.request") {
const status = await this.hubRelationships.updatePermissions({
grant: msg.grant,
revoke: msg.revoke,
});
this.host.emit({
type: "hub.management.daemon.permissions.update.response",
payload: { requestId: msg.requestId, status },
});
return;
}
if (msg.type === "hub.management.daemon.disconnect.request") {
const result = await this.hubRelationships.disconnect({ force: msg.force ?? false });
this.host.emit({
@@ -169,11 +169,13 @@ function createSessionWithActivity(
appVisible: boolean;
appVisibilityChangedAt?: Date;
} | null,
subscribed = true,
) {
return {
getClientActivity: vi.fn(() => activity),
supports: () => false,
supportsForSource: () => false,
subscribesToAgent: vi.fn(async () => subscribed),
};
}
@@ -186,11 +188,12 @@ function connectClient(
appVisible: boolean;
appVisibilityChangedAt?: Date;
} | null,
options: { subscribed?: boolean } = {},
) {
const ws = createOpenSocket();
asInternals<WebSocketServerInternals>(server).sessions.set(ws, {
kind: "trusted",
session: createSessionWithActivity(activity),
session: createSessionWithActivity(activity, options.subscribed ?? true),
clientId: "client-test",
appVersion: null,
connectionLogger: createLogger(),
@@ -216,6 +219,30 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
vi.clearAllMocks();
});
it("does not emit attention or include presence without an agent-directory subscription", async () => {
const { server, pushNotifications } = createServer();
const now = new Date();
const unsubscribed = connectClient(
server,
{
deviceType: "web",
appVisible: true,
focusedAgentId: "agent-1",
lastActivityAt: now,
},
{ subscribed: false },
);
await asInternals<WebSocketServerInternals>(server).broadcastAgentAttention({
agentId: "agent-1",
provider: "claude",
reason: "finished",
});
expect(unsubscribed.send).not.toHaveBeenCalled();
expect(pushNotifications.sent).toHaveLength(1);
});
it("uses assistant preview text for push notifications with markdown removed", async () => {
const getLastAssistantMessage = vi.fn(
async () => "**Done**. Updated `README.md` and [link](https://example.com).",
@@ -56,6 +56,13 @@ const sessionMock = vi.hoisted(() => {
clearAgentTimelineSubscription = vi.fn();
getClientActivity = vi.fn(() => null);
getSessionId = vi.fn(() => "mock-session-id");
getPermissions = vi.fn(() => this.args.permissions as string[]);
allowsInbound = vi.fn(() => true);
allowsPermission = vi.fn(() => true);
publish = vi.fn((message: unknown) => {
const onMessage = this.args.onMessage as ((message: unknown) => void) | undefined;
onMessage?.(message);
});
resetPeakInflight = vi.fn(() => {});
getRuntimeMetrics = vi.fn(() => ({
checkoutDiffTargetCount: 0,
@@ -96,7 +103,7 @@ vi.mock("./push/index.js", () => ({
import { z } from "zod";
import { VoiceAssistantWebSocketServer } from "./websocket-server";
import { parseServerInfoStatusPayload } from "./messages.js";
import { DAEMON_PERMISSIONS, parseServerInfoStatusPayload } from "./messages.js";
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
interface WebSocketServerInternals {
@@ -676,6 +683,28 @@ describe("relay external socket reconnect behavior", () => {
await server.close();
});
test("isolates resumable sessions by principal while sharing hello bootstrap", async () => {
const server = createServer();
const clientId = "shared-client-id";
const ownerSocket = new MockSocket();
const hubSocket = new MockSocket();
const ownerInfo = await attachRelayAndHello({ server, socket: ownerSocket, clientId });
await server.attachExternalSocket(
hubSocket,
{ transport: "hub", hubDaemonId: "daemon-1" },
{ principalId: "hub:daemon-1", permissions: ["hub.execute"] },
);
hubSocket.emit("message", JSON.stringify(createHelloMessage(clientId)));
const hubEnvelope = parseSentEnvelope(hubSocket.sent[0]);
const hubInfo = parseServerInfoStatusPayload(hubEnvelope.message?.payload);
expect(sessionMock.instances).toHaveLength(2);
expect(ownerInfo.permissions).toEqual(DAEMON_PERMISSIONS);
expect(hubInfo?.permissions).toEqual(["hub.execute"]);
await server.close();
});
test("rejects session messages before hello", async () => {
const server = createServer();
const socket = new MockSocket();
@@ -982,6 +1011,7 @@ describe("relay external socket reconnect behavior", () => {
expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true);
expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true);
expect(serverInfo.features?.agentTurnIdentity).toBeUndefined();
expect(serverInfo.permissions).toEqual(DAEMON_PERMISSIONS);
await server.close();
});
@@ -192,12 +192,13 @@ function createOpenSocket() {
};
}
function connectClient(server: VoiceAssistantWebSocketServer) {
function connectClient(server: VoiceAssistantWebSocketServer, subscribed = true) {
const ws = createOpenSocket();
asInternals<{ sessions: Map<unknown, unknown> }>(server).sessions.set(ws, {
kind: "trusted",
session: {
getClientActivity: vi.fn(() => null),
subscribesToTerminalDirectory: vi.fn(async () => subscribed),
},
clientId: "client-test",
appVersion: null,
@@ -274,6 +275,25 @@ describe("VoiceAssistantWebSocketServer terminal attention notifications", () =>
vi.clearAllMocks();
});
it("does not emit attention without a matching terminal-directory subscription", async () => {
const { manager, emit } = createTerminalManager();
const { server, pushNotifications } = createServer(manager);
const ws = connectClient(server, false);
emit(
transition({
previousState: "working",
previousChangedAt: 1000,
state: "idle",
changedAt: 11001,
}),
);
await flushAsync();
expectNoTerminalAttentionMessage(ws);
expect(pushNotifications.sent).toHaveLength(1);
});
it("broadcasts terminal_attention_required after working -> idle", async () => {
const { manager, emit } = createTerminalManager();
const { server } = createServer(manager);
+132 -157
View File
@@ -98,11 +98,7 @@ import {
import type { BrowserToolsBroker } from "./browser-tools/broker.js";
import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js";
import { DirectorySyncService } from "./directory-sync/index.js";
import {
OWNER_PERMISSIONS,
permissionsForLegacyHubScopes,
type DaemonPermission,
} from "./authorization/index.js";
import { OWNER_PERMISSIONS, type DaemonPermission } from "./authorization/index.js";
import type { WorkspaceLabelService } from "./workspace-labels/index.js";
import {
APPLICATION_SOCKET_LEASE_CHECK_INTERVAL_MS,
@@ -117,20 +113,28 @@ import {
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
export interface ExternalSocketMetadata {
transport: "relay";
transport: "relay" | "hub";
externalSessionKey?: string;
relayConnectionId?: string;
hubDaemonId?: string;
}
export interface SessionAdmission {
principalId: string;
permissions: readonly DaemonPermission[];
hubExecutionAgents?: HubExecutionAgents;
}
interface PendingConnection {
connectionLogger: pino.Logger;
helloTimeout: ReturnType<typeof setTimeout> | null;
identity: WebSocketConnectionIdentity;
admission: SessionAdmission;
}
interface WebSocketConnectionIdentity {
connectionId: string;
transport: "direct" | "relay";
transport: "direct" | "relay" | "hub";
peer: "loopback" | "local_ipc" | "external";
browserOrigin: boolean;
host?: string;
@@ -138,6 +142,7 @@ interface WebSocketConnectionIdentity {
userAgent?: string;
remoteAddress?: string;
relayConnectionId?: string;
hubDaemonId?: string;
clientId?: string;
sessionId?: string;
appVersion?: string;
@@ -433,9 +438,10 @@ export interface WebSocketLike {
once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void;
}
interface TrustedSessionConnectionBase {
kind: "trusted";
interface SessionConnectionBase {
session: Session;
principalId: string;
sessionKey: string;
clientId: string;
appVersion: string | null;
clientCapabilities: Record<string, unknown> | null;
@@ -443,37 +449,17 @@ interface TrustedSessionConnectionBase {
sockets: Set<WebSocketLike>;
}
interface ReconnectableSessionConnection extends TrustedSessionConnectionBase {
interface ReconnectableSessionConnection extends SessionConnectionBase {
lifecycle: "reconnectable";
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
}
interface PluginSessionConnection extends TrustedSessionConnectionBase {
interface PluginSessionConnection extends SessionConnectionBase {
lifecycle: "ephemeral-plugin";
pluginId: string;
}
type TrustedSessionConnection = ReconnectableSessionConnection | PluginSessionConnection;
interface HubConnection {
kind: "hub";
session: Session;
daemonId: string;
connectionLogger: pino.Logger;
socket: WebSocketLike;
}
type SessionConnection = TrustedSessionConnection | HubConnection;
type TrustedLifecycleKey =
| "clientId"
| "appVersion"
| "clientCapabilities"
| "sockets"
| "externalDisconnectCleanupTimeout";
type HubLifecycleOverlap = Extract<keyof HubConnection, TrustedLifecycleKey>;
const HUB_HAS_NO_TRUSTED_LIFECYCLE_STATE: HubLifecycleOverlap extends never ? true : never = true;
void HUB_HAS_NO_TRUSTED_LIFECYCLE_STATE;
type SessionConnection = ReconnectableSessionConnection | PluginSessionConnection;
interface BrowserToolsRegistration {
capabilitySignature: string;
@@ -511,6 +497,10 @@ const WS_CLOSE_INCOMPATIBLE_PROTOCOL = 4003;
const WS_CLOSE_SERVER_SHUTDOWN = 1001;
const WS_PROTOCOL_VERSION = 1;
const WS_RUNTIME_METRICS_FLUSH_MS = 30_000;
const OWNER_SESSION_ADMISSION: SessionAdmission = {
principalId: "owner",
permissions: OWNER_PERMISSIONS,
};
export class MissingDaemonVersionError extends Error {
constructor() {
@@ -929,26 +919,27 @@ export class VoiceAssistantWebSocketServer {
}
public broadcast(message: WSOutboundMessage): void {
const trustedSockets = [...this.sessions]
.filter(([, connection]) => connection.kind === "trusted")
.map(([ws]) => ws);
this.sendMessageToSockets(trustedSockets, message);
if (message.type === "session") {
for (const connection of new Set(this.sessions.values())) {
connection.session.publish(message.message);
}
return;
}
this.sendMessageToSockets(this.sessions.keys(), message);
}
public listTrustedSessions(): Session[] {
public listSessions(): Session[] {
return Array.from(
new Set(
[...this.sessions.values(), ...this.externalSessionsByKey.values()]
.filter(
(connection): connection is TrustedSessionConnection => connection.kind === "trusted",
)
.map((connection) => connection.session),
[...this.sessions.values(), ...this.externalSessionsByKey.values()].map(
(connection) => connection.session,
),
),
);
}
public publishProjectUpdate(update: ProjectUpdate): void {
for (const session of this.listTrustedSessions()) {
for (const session of this.listSessions()) {
void session
.emitProjectUpdate(update)
.catch((error) => this.logger.warn({ err: error }, "Failed to publish project update"));
@@ -971,11 +962,13 @@ export class VoiceAssistantWebSocketServer {
public async attachExternalSocket(
ws: WebSocketLike,
metadata?: ExternalSocketMetadata,
admission: SessionAdmission = OWNER_SESSION_ADMISSION,
initialHello?: WSHelloMessage,
): Promise<void> {
if (metadata?.transport === "relay") {
this.incrementRuntimeCounter("relayExternalSocketAttached");
}
await this.attachSocket(ws, undefined, metadata);
await this.attachSocket(ws, undefined, metadata, false, admission, initialHello);
}
public async attachPluginSocket(
@@ -1001,42 +994,21 @@ export class VoiceAssistantWebSocketServer {
return { closed };
}
public async attachHubSocket(
ws: WebSocketLike,
options: {
daemonId: string;
scopes: readonly string[];
agents: HubExecutionAgents;
},
): Promise<void> {
if (this.connectionLifecycle !== "accepting") {
ws.close(WS_CLOSE_SERVER_SHUTDOWN, "Server shutting down");
return;
public updatePrincipalPermissions(
principalId: string,
permissions: readonly DaemonPermission[],
): void {
for (const pending of this.pendingConnections.values()) {
if (pending.admission.principalId === principalId) {
pending.admission = { ...pending.admission, permissions };
}
}
for (const connection of new Set(this.externalSessionsByKey.values())) {
if (connection.principalId === principalId) {
connection.session.setPermissions(permissions);
this.syncBrowserToolsClientRegistration(connection);
}
}
const connectionLogger = this.logger.child({
connectionKind: "hub",
daemonId: options.daemonId,
});
const session = this.createSocketSession({
clientId: `hub:${options.daemonId}`,
appVersion: null,
clientCapabilities: null,
permissions: permissionsForLegacyHubScopes(options.scopes),
connectionLogger,
onMessage: (message) => this.sendToClient(ws, wrapSessionMessage(message)),
hubExecutionAgents: options.agents,
});
const connection: HubConnection = {
kind: "hub",
session,
daemonId: options.daemonId,
connectionLogger,
socket: ws,
};
this.sessions.set(ws, connection);
this.bindSocketHandlers(ws);
connectionLogger.info("Hub session attached");
}
public prepareForShutdown(): void {
@@ -1085,18 +1057,13 @@ export class VoiceAssistantWebSocketServer {
const cleanupPromises: Promise<void>[] = [];
for (const connection of uniqueConnections) {
if (
connection.kind === "trusted" &&
connection.lifecycle === "reconnectable" &&
connection.externalDisconnectCleanupTimeout
) {
if (connection.lifecycle === "reconnectable" && connection.externalDisconnectCleanupTimeout) {
clearTimeout(connection.externalDisconnectCleanupTimeout);
connection.externalDisconnectCleanupTimeout = null;
}
cleanupPromises.push(Promise.resolve(connection.session.cleanup()));
const sockets = connection.kind === "trusted" ? connection.sockets : [connection.socket];
for (const ws of sockets) {
for (const ws of connection.sockets) {
cleanupPromises.push(
new Promise<void>((resolve) => {
// WebSocket.CLOSED = 3
@@ -1256,14 +1223,10 @@ export class VoiceAssistantWebSocketServer {
}
private sendToConnection(connection: SessionConnection, message: WSOutboundMessage): void {
const sockets = connection.kind === "trusted" ? connection.sockets : [connection.socket];
this.sendMessageToSockets(sockets, message);
this.sendMessageToSockets(connection.sockets, message);
}
private sendBinaryToConnection(connection: SessionConnection, frame: Uint8Array): void {
if (connection.kind !== "trusted") {
return;
}
for (const ws of connection.sockets) {
this.sendBinaryToClient(ws, frame);
}
@@ -1274,6 +1237,8 @@ export class VoiceAssistantWebSocketServer {
request?: unknown,
metadata?: ExternalSocketMetadata,
allowDuringStartup = false,
admission: SessionAdmission = OWNER_SESSION_ADMISSION,
initialHello?: WSHelloMessage,
): Promise<void> {
if (
this.connectionLifecycle === "stopping" ||
@@ -1296,6 +1261,7 @@ export class VoiceAssistantWebSocketServer {
connectionLogger,
helloTimeout: null,
identity,
admission,
};
const timeout = setTimeout(() => {
if (this.pendingConnections.get(ws) !== pending) {
@@ -1327,6 +1293,9 @@ export class VoiceAssistantWebSocketServer {
},
"Client connected; awaiting hello",
);
if (initialHello) {
this.handleHello({ ws, message: initialHello, pending });
}
}
private createSessionConnection(params: {
@@ -1336,15 +1305,17 @@ export class VoiceAssistantWebSocketServer {
clientCapabilities: Record<string, unknown> | null;
connectionLogger: pino.Logger;
lifecycle: { kind: "reconnectable" } | { kind: "ephemeral-plugin"; pluginId: string };
}): TrustedSessionConnection {
const { ws, clientId, appVersion, clientCapabilities, connectionLogger, lifecycle } = params;
let connection: TrustedSessionConnection | null = null;
admission: SessionAdmission;
}): SessionConnection {
const { ws, clientId, appVersion, clientCapabilities, connectionLogger, lifecycle, admission } =
params;
let connection: SessionConnection | null = null;
const session = this.createSocketSession({
clientId,
appVersion,
clientCapabilities,
permissions: OWNER_PERMISSIONS,
permissions: admission.permissions,
connectionLogger,
onMessage: (msg) => {
if (!connection) {
@@ -1389,12 +1360,14 @@ export class VoiceAssistantWebSocketServer {
onLifecycleIntent: (intent) => {
this.onLifecycleIntent?.(intent);
},
hubExecutionAgents: admission.hubExecutionAgents,
hubRelationships: this.hubRelationships ?? undefined,
});
const base: TrustedSessionConnectionBase = {
kind: "trusted",
const base: SessionConnectionBase = {
session,
principalId: admission.principalId,
sessionKey: sessionConnectionKey(admission.principalId, clientId),
clientId,
appVersion,
clientCapabilities,
@@ -1424,7 +1397,7 @@ export class VoiceAssistantWebSocketServer {
logger: options.connectionLogger.child({ module: "session" }),
onWorkspaceRecovered: async (workspace) => {
await Promise.all(
this.listTrustedSessions().map((activeSession) =>
this.listSessions().map((activeSession) =>
activeSession.refreshRecoveredWorkspaceForExternalMutation(workspace),
),
);
@@ -1564,7 +1537,8 @@ export class VoiceAssistantWebSocketServer {
if (message.appVersion) {
pending.identity.appVersion = message.appVersion;
}
const existing = pluginId ? undefined : this.externalSessionsByKey.get(clientId);
const sessionKey = sessionConnectionKey(pending.admission.principalId, clientId);
const existing = pluginId ? undefined : this.externalSessionsByKey.get(sessionKey);
if (existing) {
this.resumeSession({ ws, message, pending, existing });
return;
@@ -1579,14 +1553,15 @@ export class VoiceAssistantWebSocketServer {
clientCapabilities: message.capabilities ?? null,
connectionLogger,
lifecycle: pluginId ? { kind: "ephemeral-plugin", pluginId } : { kind: "reconnectable" },
admission: pending.admission,
});
this.sessions.set(ws, connection);
if (connection.lifecycle === "reconnectable") {
this.externalSessionsByKey.set(clientId, connection);
this.externalSessionsByKey.set(sessionKey, connection);
}
pending.identity.sessionId = connection.session.getSessionId();
this.syncBrowserToolsClientRegistration(connection);
this.sendToClient(ws, this.createServerInfoMessage());
this.sendToClient(ws, this.createServerInfoMessage(connection.session));
connection.connectionLogger.info(
{
...toConnectionLogFields(pending.identity),
@@ -1630,7 +1605,7 @@ export class VoiceAssistantWebSocketServer {
this.sessions.set(ws, existing);
pending.identity.sessionId = existing.session.getSessionId();
this.syncBrowserToolsClientRegistration(existing);
this.sendToClient(ws, this.createServerInfoMessage());
this.sendToClient(ws, this.createServerInfoMessage(existing.session));
pending.connectionLogger.info(
{
...toConnectionLogFields(pending.identity),
@@ -1641,12 +1616,13 @@ export class VoiceAssistantWebSocketServer {
);
}
private buildServerInfoStatusPayload(): ServerInfoStatusPayload {
private buildServerInfoStatusPayload(session: Session): ServerInfoStatusPayload {
return {
status: "server_info",
serverId: this.serverId,
hostname: getHostname(),
version: this.daemonVersion,
permissions: session.getPermissions(),
// COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16.
desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true,
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
@@ -1785,12 +1761,12 @@ export class VoiceAssistantWebSocketServer {
};
}
private createServerInfoMessage(): WSOutboundMessage {
private createServerInfoMessage(session: Session): WSOutboundMessage {
return {
type: "session",
message: {
type: "status",
payload: this.buildServerInfoStatusPayload(),
payload: this.buildServerInfoStatusPayload(session),
},
};
}
@@ -1806,7 +1782,9 @@ export class VoiceAssistantWebSocketServer {
}
private broadcastCapabilitiesUpdate(): void {
this.broadcast(this.createServerInfoMessage());
for (const connection of new Set(this.sessions.values())) {
this.sendToConnection(connection, this.createServerInfoMessage(connection.session));
}
}
private broadcastDaemonConfigChanged(config: MutableDaemonConfig): void {
@@ -1892,22 +1870,12 @@ export class VoiceAssistantWebSocketServer {
}
this.sessions.delete(ws);
if (connection.kind === "hub") {
this.socketIdentities.delete(ws);
connection.connectionLogger.info(
{ code: details.code, reason: stringifyCloseReason(details.reason) },
"Hub session disconnected",
);
connection.session.cleanup();
this.finishPluginSocketCleanup(ws);
return;
}
connection.sockets.delete(ws);
connection.session.clearAgentTimelineSubscription(ws);
this.socketIdentities.delete(ws);
if (connection.sockets.size === 0) {
this.unregisterBrowserToolsClient(connection.clientId);
this.unregisterBrowserToolsClient(connection);
if (connection.lifecycle === "ephemeral-plugin") {
this.pluginSocketIds.delete(ws);
await this.cleanupConnection(connection, "Plugin session disconnected");
@@ -1965,7 +1933,7 @@ export class VoiceAssistantWebSocketServer {
}
private async cleanupConnection(
connection: TrustedSessionConnection,
connection: SessionConnection,
logMessage: string,
): Promise<void> {
this.incrementRuntimeCounter("sessionCleanup");
@@ -1980,12 +1948,12 @@ export class VoiceAssistantWebSocketServer {
}
connection.sockets.clear();
if (connection.lifecycle === "reconnectable") {
const existing = this.externalSessionsByKey.get(connection.clientId);
const existing = this.externalSessionsByKey.get(connection.sessionKey);
if (existing === connection) {
this.externalSessionsByKey.delete(connection.clientId);
this.externalSessionsByKey.delete(connection.sessionKey);
}
}
this.unregisterBrowserToolsClient(connection.clientId);
this.unregisterBrowserToolsClient(connection);
connection.connectionLogger.trace(
{ clientId: connection.clientId, totalSessions: this.sessions.size },
@@ -1994,45 +1962,51 @@ export class VoiceAssistantWebSocketServer {
await connection.session.cleanup();
}
private syncBrowserToolsClientRegistration(connection: TrustedSessionConnection): void {
private syncBrowserToolsClientRegistration(connection: SessionConnection): void {
if (!this.browserToolsBroker) {
return;
}
const registrationKey = connection.sessionKey;
if (!connection.session.allowsPermission("workspace.write")) {
this.unregisterBrowserToolsClient(registrationKey);
return;
}
const browserHostCapability = getBrowserHostCapability(connection.clientCapabilities);
if (!browserHostCapability) {
this.unregisterBrowserToolsClient(connection.clientId);
this.unregisterBrowserToolsClient(registrationKey);
return;
}
const capabilitySignature = JSON.stringify(browserHostCapability);
const existing = this.browserToolsRegistrations.get(connection.clientId);
const existing = this.browserToolsRegistrations.get(registrationKey);
if (existing?.capabilitySignature === capabilitySignature) {
return;
}
if (existing) {
this.browserToolsRegistrations.delete(connection.clientId);
this.browserToolsRegistrations.delete(registrationKey);
existing.unregister();
}
const unregister = this.browserToolsBroker.registerClient({
id: connection.clientId,
id: connection.principalId === "owner" ? connection.clientId : registrationKey,
hostKind: browserHostCapability.hostKind,
supportedCommands: browserHostCapability.supportedCommands,
sendBrowserAutomationRequest: (request) => {
this.sendToConnection(connection, wrapSessionMessage(request));
},
});
this.browserToolsRegistrations.set(connection.clientId, {
this.browserToolsRegistrations.set(registrationKey, {
capabilitySignature,
unregister,
});
}
private unregisterBrowserToolsClient(clientId: string): void {
const registration = this.browserToolsRegistrations.get(clientId);
private unregisterBrowserToolsClient(connection: SessionConnection | string): void {
const registrationKey = typeof connection === "string" ? connection : connection.sessionKey;
const registration = this.browserToolsRegistrations.get(registrationKey);
if (!registration) {
return;
}
this.browserToolsRegistrations.delete(clientId);
this.browserToolsRegistrations.delete(registrationKey);
registration.unregister();
}
@@ -2070,7 +2044,7 @@ export class VoiceAssistantWebSocketServer {
log.warn(
{
clientId: activeConnection?.kind === "trusted" ? activeConnection.clientId : undefined,
clientId: activeConnection?.clientId,
requestId: requestInfo?.requestId,
requestType: requestInfo?.requestType,
error: parsedMessage.error.message,
@@ -2135,11 +2109,6 @@ export class VoiceAssistantWebSocketServer {
}
return true;
}
if (activeConnection.kind === "hub") {
log.warn("Rejected binary frame on Hub session");
ws.close(WS_CLOSE_INVALID_HELLO, "Binary frames are not supported on Hub sessions");
return true;
}
void Promise.resolve(activeConnection.session.handleBinaryFrame(decodedFrame)).catch(
(error: unknown) => {
this.handleRawMessageError({
@@ -2288,10 +2257,8 @@ export class VoiceAssistantWebSocketServer {
let connectionFields: Record<string, unknown>;
if (identity) {
connectionFields = toConnectionLogFields(identity);
} else if (activeConnection.kind === "trusted") {
connectionFields = { clientId: activeConnection.clientId };
} else {
connectionFields = { daemonId: activeConnection.daemonId };
connectionFields = { clientId: activeConnection.clientId };
}
activeConnection.connectionLogger.warn(
{
@@ -2301,10 +2268,11 @@ export class VoiceAssistantWebSocketServer {
"ws_control_rpc_received",
);
}
if (
activeConnection.kind === "trusted" &&
message.message.type === "browser.automation.execute.response"
) {
if (message.message.type === "browser.automation.execute.response") {
if (!activeConnection.session.allowsInbound(message.message)) {
await activeConnection.session.handleMessage(message.message, ws);
return;
}
this.browserToolsBroker?.receiveResponse(message.message as BrowserAutomationExecuteResponse);
return;
}
@@ -2314,7 +2282,7 @@ export class VoiceAssistantWebSocketServer {
const durationMs = performance.now() - startMs;
this.recordRequestLatency(message.message.type, durationMs);
if (durationMs >= SLOW_REQUEST_THRESHOLD_MS && activeConnection.kind === "trusted") {
if (durationMs >= SLOW_REQUEST_THRESHOLD_MS) {
activeConnection.connectionLogger.warn(
{
requestType: message.message.type,
@@ -2426,9 +2394,7 @@ export class VoiceAssistantWebSocketServer {
}
private collectSessionRuntimeMetrics(): WebSocketRuntimeMetrics {
const uniqueConnections = new Set<TrustedSessionConnection>(
this.externalSessionsByKey.values(),
);
const uniqueConnections = new Set<SessionConnection>(this.externalSessionsByKey.values());
let terminalDirectorySubscriptionCount = 0;
let terminalSubscriptionCount = 0;
let workspaceGitWatchedDirectoryCount = 0;
@@ -2537,15 +2503,17 @@ export class VoiceAssistantWebSocketServer {
provider: AgentProvider;
reason: "finished" | "error" | "permission";
}): Promise<void> {
const agent = this.agentManager.getAgent(params.agentId);
if (!agent?.workspaceId) {
return;
}
const clientEntries: Array<{
ws: WebSocketLike;
state: ClientPresenceState;
}> = [];
for (const [ws, connection] of this.sessions) {
if (connection.kind !== "trusted") {
continue;
}
if (!(await connection.session.subscribesToAgent(agent))) continue;
clientEntries.push({
ws,
state: this.getClientActivityState(connection.session),
@@ -2554,10 +2522,6 @@ export class VoiceAssistantWebSocketServer {
const allStates = clientEntries.map((e) => e.state);
const nowMs = Date.now();
const agent = this.agentManager.getAgent(params.agentId);
if (!agent?.workspaceId) {
return;
}
const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId);
const notification = buildAgentAttentionNotificationPayload({
reason: params.reason,
@@ -2632,7 +2596,12 @@ export class VoiceAssistantWebSocketServer {
}> = [];
for (const [ws, connection] of this.sessions) {
if (connection.kind !== "trusted") {
if (
!(await connection.session.subscribesToTerminalDirectory({
cwd: params.cwd,
...(params.workspaceId ? { workspaceId: params.workspaceId } : {}),
}))
) {
continue;
}
clientEntries.push({
@@ -2708,7 +2677,7 @@ function createWebSocketConnectionIdentity(
): WebSocketConnectionIdentity {
return {
connectionId: `conn_${randomUUID().replaceAll("-", "")}`,
transport: metadata?.transport === "relay" ? "relay" : "direct",
transport: metadata?.transport ?? "direct",
peer: resolveConnectionPeer(requestMetadata, metadata),
browserOrigin: requestMetadata.origin !== undefined,
...(requestMetadata.host ? { host: requestMetadata.host } : {}),
@@ -2716,9 +2685,14 @@ function createWebSocketConnectionIdentity(
...(requestMetadata.userAgent ? { userAgent: requestMetadata.userAgent } : {}),
...(requestMetadata.remoteAddress ? { remoteAddress: requestMetadata.remoteAddress } : {}),
...(metadata?.relayConnectionId ? { relayConnectionId: metadata.relayConnectionId } : {}),
...(metadata?.hubDaemonId ? { hubDaemonId: metadata.hubDaemonId } : {}),
};
}
function sessionConnectionKey(principalId: string, clientId: string): string {
return JSON.stringify([principalId, clientId]);
}
function toConnectionLogFields(identity: WebSocketConnectionIdentity): Record<string, string> {
return {
connectionId: identity.connectionId,
@@ -2729,6 +2703,7 @@ function toConnectionLogFields(identity: WebSocketConnectionIdentity): Record<st
...(identity.userAgent ? { userAgent: identity.userAgent } : {}),
...(identity.remoteAddress ? { remoteAddress: identity.remoteAddress } : {}),
...(identity.relayConnectionId ? { relayConnectionId: identity.relayConnectionId } : {}),
...(identity.hubDaemonId ? { hubDaemonId: identity.hubDaemonId } : {}),
...(identity.clientId ? { clientId: identity.clientId } : {}),
...(identity.sessionId ? { sessionId: identity.sessionId } : {}),
...(identity.appVersion ? { appVersion: identity.appVersion } : {}),
@@ -2739,7 +2714,7 @@ function resolveConnectionPeer(
requestMetadata: SocketRequestMetadata,
metadata: ExternalSocketMetadata | undefined,
): WebSocketConnectionIdentity["peer"] {
if (metadata?.transport === "relay") return "external";
if (metadata !== undefined) return "external";
if (!requestMetadata.remoteAddress) return "local_ipc";
return isLoopbackAddress(requestMetadata.remoteAddress) ? "loopback" : "external";
}
@@ -617,11 +617,23 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => {
controller.dispatch({ type: "subscribe_terminals_request", cwd, workspaceId: "ws-a" });
controller.dispatch({ type: "subscribe_terminals_request", cwd, workspaceId: "ws-b" });
await expect(controller.hasDirectorySubscription({ cwd, workspaceId: "ws-a" })).resolves.toBe(
true,
);
await expect(controller.hasDirectorySubscription({ cwd, workspaceId: "ws-b" })).resolves.toBe(
true,
);
await flushMicrotasks();
outboundMessages.length = 0;
// Tearing down workspace B must not drop workspace A's live subscription.
controller.dispatch({ type: "unsubscribe_terminals_request", cwd, workspaceId: "ws-b" });
await expect(controller.hasDirectorySubscription({ cwd, workspaceId: "ws-a" })).resolves.toBe(
true,
);
await expect(controller.hasDirectorySubscription({ cwd, workspaceId: "ws-b" })).resolves.toBe(
false,
);
changedListener?.({ cwd, terminals: [{ id: "a", name: "A", cwd, workspaceId: "ws-a" }] });
await flushMicrotasks();
@@ -178,6 +178,19 @@ export class TerminalSessionController {
};
}
async hasDirectorySubscription(input: { cwd: string; workspaceId?: string }): Promise<boolean> {
const workspaceRoots = await this.listTerminalWorkspaceRoots();
return Array.from(this.subscribedDirectories.values()).some((subscription) => {
if (
subscription.workspaceId !== undefined &&
subscription.workspaceId !== input.workspaceId
) {
return false;
}
return this.terminalBelongsToRoot(subscription.cwd, input.cwd, workspaceRoots);
});
}
dispatch(msg: SessionInboundMessage): Promise<void> | undefined {
if (!isTerminalMessage(msg)) {
return undefined;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.7.0-beta.1",
"version": "0.7.1",
"private": true,
"type": "module",
"scripts": {

Some files were not shown because too many files have changed in this diff Show More