Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 8fe83e24ac test(pi): separate stopped and failed history replay cases 2026-11-02 08:27:30 +00:00
paseo-bot[bot]andRyan Winograd 5038999e81 fix(pi): preserve history cleanup across current resume behavior
Keep the original session identity and missing-folder cleanup when releasing
history-only runtimes. Use an existing directory in the regression so the
newer resume fallback does not try to copy a nonexistent session fixture.
Cover interrupted and failed replay, interactive residency, and automatic
cleanup of a missing-folder history copy.

Co-authored-by: Ryan Winograd <ryan@thewinograds.com>
2026-11-02 08:03:40 +00:00
Ryan Winograd beda7180c0 fix(pi): close history-only runtimes after replay
Archived timeline reads resume a Pi RPC process to reconstruct the saved
conversation. The history-only session remained open after replay, so
browsing or synchronizing archived agents left one idle Pi process per
agent.

Mark history-purpose resumes for closure and close the runtime in
streamHistory's finally path. Interactive sessions remain resident, and
idempotent close keeps later manager cleanup safe.
2026-11-02 08:03:40 +00:00
paseo-bot[bot]andhadan8977 3e3a707f5a Keep moved file paths and diffs intact in commit history (#8509)
* fix(server): keep commit file paths when a file moves up a directory

* test(git): open moved commit files through their listed path

Verify commit history and diff lookup agree for files moved up a directory, with and without content edits.

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>

* refactor(git): clarify compact rename handling and test cases

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>

---------

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 07:58:56 +00:00
Mohamed Boudra d4ac64f335 Let plugins stream live events and binary data to the app (#8527)
* feat(plugins): stream events and bytes between plugins and the app

Plugins can declare a subscription with defineSubscription, handle it with
server.handle(contract, (input, { push, signal })), and listen from a surface
with useSubscription or client.subscribe. A top-level Uint8Array in an event,
an RPC output, or an RPC input crosses the wire as a binary frame (opcodes
0x20-0x2f) without JSON or base64; a nested one fails validation on the sender.

Each subscriber runs its own handler, owned by the session that subscribed,
so release, disconnect, plugin stop and reload need no plugin code. The
runtime buffers pushes until the subscribe response has gone out, so a client
never sees an event for a subscription it does not know yet.

Gated on server_info.features.pluginSubscriptions; binary outputs reach only
clients that advertise plugin_binary_payloads.

* fix(plugins): close subscription gaps found in review

- Losing daemon.manage now releases the session's plugin subscriptions;
  binary events bypass the outbound permission check, so streams would
  otherwise outlive the revocation.
- A subscribe that times out sends unsubscribe, since the plugin may
  still start the handler after the daemon gave up.
- The byte limit leaves room for the largest frame header and routing,
  and lives in assertPluginWireValue so events, outputs and inputs share it.
- A release right after the host accepted keeps `ready` resolved, so a
  handler that returns at once reports closed rather than an error.
- Session trace logging tolerates loggers without isLevelEnabled.

* fix(plugins): withhold binary plugin results after plugin access is revoked

A binary RPC output or event skipped the outbound permission check its
JSON form passes, so an RPC in flight when daemon.manage was revoked still
delivered its bytes. Each frame now checks the permission of the JSON
message it stands in for.

* test(plugins): cover subscriptions in a real browser instead of JSDOM

The app subscription tests mounted hooks in JSDOM, which docs/testing.md
rules out. The adapter and contract behavior is now a plain unit test,
and a Playwright spec drives the diff-watch and media-stream examples:
JSON events following file changes, streamed bytes, and an upload.

* test(plugins): add the plain subscription unit test and the browser spec

* fix(plugins): apply subscription schema transforms once

Both ends parsed each subscription value, and the sender sent its parse
output, so the receiver parsed a schema's output as fresh input. An event
schema like z.string().transform((s) => s.length) closed the stream as
invalid. The sender now validates and sends the value as given; the
receiver's parse produces what the handler or callback sees.

* fix(plugins): parse a subscription's input once in the plugin process

server.handle wrapped subscription handlers in a second input parse, so a
transforming input schema rejected its own parsed output before the
handler ran.
2026-11-02 14:10:15 +07:00
Mohamed Boudra 70cb34927a fix(app): keep question choices open until explicit navigation (#8525)
Allow description clicks to select options while users copy text. Removing automatic advance keeps that interaction reversible; links remain independent and submission stays explicit.
2026-11-02 14:08:07 +07:00
paseo-bot[bot] b280ca5355 fix(relay): keep local E2E tests independent of production upstream (#8514)
Wrangler local mode retains deployment vars, so relay readiness probes were forwarded to an unavailable upstream instead of the local Durable Object. Clear that binding only in the E2E launcher.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 13:59:19 +07:00
da8d58bfee fix(plugins): preserve agent message provenance in timeline transforms (#8492)
Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 05:04:23 +00:00
Mohamed Boudra 7c2812dde6 fix(app): add hosts without leaving the current screen (#8512)
* fix(app): add hosts without leaving the current screen

A retained Settings route replayed its addHost query after appearance remounts. Own the connection flow outside screen lifetimes and let each consumer choose its completion navigation.

* fix(app): release the project dialog before adding a host

* fix(app): size the QR scanner viewport on wide layouts

* test(app): align creation and desktop setup with in-place host flow
2026-11-02 11:07:48 +07:00
Mohamed Boudra b474beedce Homepage shows an agent building a plugin live (#8515)
* feat(website): show agents building plugins on the homepage

The plugins section now leads with the workflow instead of the API list:
you ask an agent for a panel and it appears, without a restart. It plays
as a timeline (prompt, tool calls, result) and then slides in the panel
the agent built, followed by the 400+ community plugins and their
categories. It moves above the automation section.

Mock tool calls on the homepage now match the app's collapsed timeline
rows: same labels and icons, muted text in the UI font, and Paseo MCP
tools named by action ("Create agent"). The hero pills are now nouns.

* fix(website): show the finished plugin demo when motion is reduced

The demo staged its items and slid the panel across its width even for visitors who ask for reduced motion. It now starts in its finished state for them, matching the hero mockup.
2026-11-02 10:50:12 +07:00
e2f0779379 Render Markdown tables written directly below prose (#8485)
* fix(app): render tables immediately after paragraphs

Insert a paragraph break before GFM tables adjacent to prose so MD4C
recognizes them, preserving quote/list containers and line endings.

* fix(app): preserve table headers during source adaptation

Replace the preceding line ending when inserting a table separator.
This keeps the separator before the header's source range, so entity
and local image edits cannot consume the paragraph break.

---------

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: thsrite <thsrite@gmail.com>
Co-authored-by: Woosuk Nam <me@suk.kr>
2026-11-02 03:30:52 +00:00
paseo-bot[bot] a166a3317a test(terminal): check original processes after protocol cleanup (#8502)
Startup launches another worker after closing obsolete protocols, so a reused numeric PID does not prove the original process survived. Check the recorded birth identities and require cleanup to be confirmed.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 03:01:36 +00:00
150 changed files with 4670 additions and 2137 deletions
-8
View File
@@ -406,10 +406,7 @@
{
"files": [
"packages/app/src/app/_layout.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/agent-status-dot.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
@@ -428,7 +425,6 @@
"packages/app/src/components/hosts/host-picker.tsx",
"packages/app/src/components/icons/paseo-logo.tsx",
"packages/app/src/components/left-sidebar.tsx",
"packages/app/src/components/pair-link-modal.tsx",
"packages/app/src/components/plan-card.tsx",
"packages/app/src/components/provider-diagnostic-sheet.tsx",
"packages/app/src/components/question-form-card.tsx",
@@ -543,14 +539,10 @@
"packages/app/src/agent-skills/selection-sheet.tsx",
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
"packages/app/src/agent-stream/view.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/changelog/internal/changelog-sheet.tsx",
"packages/app/src/command-center/command-center.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/add-project-flow.tsx",
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/app-diagnostic-sheet.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
+4 -4
View File
@@ -35,7 +35,7 @@ The authored interface ramp uses a 14px base. New native installs default to 15p
Weight has three tiers, applied by role:
- **Screen titles** — the title in app chrome — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx:19-23`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
@@ -102,7 +102,7 @@ Five primitives. The pick is determined by option count, the need to search, and
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
@@ -114,7 +114,7 @@ Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value
## 7. Density and rhythm
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`).
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
@@ -278,7 +278,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
+6
View File
@@ -58,6 +58,12 @@ restore the same workspace and could repeat the crash.
## App-Wide Route Hops
Keep Add host in `hosts/add-host`, opened through `useAddHost()`. It is an in-place
flow; callers choose what happens after success. Settings opts into the new host’s
Overview. Do not send callers to Settings with an `addHost` query parameter: a
retained route replays that request when appearance changes remount its content.
The flow provider and sheet host stay outside appearance keys so open drafts survive.
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
branch on its current route.
+24 -6
View File
@@ -357,9 +357,27 @@ keys are stored under a `param.` prefix; plugins never see it and may use any ke
`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
params.
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
Paseo gives each plugin installation its own query client.
RPC and subscription contracts validate inputs, outputs, and events in both the app and plugin
subprocess. `useRpc` returns a typed async function. Use the host-provided `@tanstack/react-query`
for request state and caching; Paseo gives each plugin installation its own query client.
A subscription is a session-owned observation (`plugin-rpc` delivery family): the session that
subscribed owns its lifetime, so release, disconnect, plugin stop, and reload need no plugin code.
Each subscriber gets its own handler call in the subprocess; there is no fan-out or replay. The
runtime buffers pushes until the session has sent `plugin.rpc.subscribe.response`, so a client
never sees an event for an unknown subscription. The app installation's lifetime aborts every
subscription its surfaces left open, and a reload replaces the installation, which resubscribes
mounted hooks.
The byte rule (a top-level `Uint8Array` is binary, a nested one is a validation error) is checked
once per hop by `assertPluginWireValue`. Bytes ride plugin binary frames, opcodes `0x20`-`0x2f`
(`packages/protocol/src/binary-frames/plugin.ts`); nothing on the path copies or re-encodes them,
and `plugin-subscriptions.perf.e2e.test.ts` locks that. Keep it that way: never stringify a plugin
payload for a log, and the client's bootstrap buffer holds bytes only until the first observer
attaches. Binary outputs reach only clients that send `pluginBinaryPayloads`. A binary input
frame (`0x22`) becomes a synthetic `plugin.rpc.invoke.request` and runs through the normal message
path, so it is authorized like any other invoke. The author contract, including the native upload
limit, is in [stream events from the daemon](../public-docs/plugins/reference.md#subscriptions).
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
@@ -374,10 +392,10 @@ changes both `usePaseo()` and `useRpc()` to that host's installation. An offline
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
Host handles and host-targeted clients are owned by `packages/app/src/plugins/hosts`. Its `projectHost` is the secret boundary; host target input uses the shared `parseHostTarget` grammar. Per-installation
Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation
bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not
resolve ownership through a mutable current-plugin global. Keep observation ownership in this module
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#hosts).
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
@@ -396,7 +414,7 @@ in-memory bundles usable, and a later catalog retries persistence.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#hosts).
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
Workspace panels, Command Center items, and client slash commands are client contributions. The
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
@@ -5,6 +5,7 @@ import path from "node:path";
import { test, expect } from "../support/fixtures";
import {
addProjectFlow,
chooseAddHostFromProjectFlow,
addProjectFlowBack,
addProjectFlowHost,
addProjectFlowInput,
@@ -19,6 +20,7 @@ import {
} from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
disconnectAllHostsAndReload,
addConnectedHostAndReload,
addConnectedHostsAndReload,
addOfflineHostAndReload,
@@ -69,6 +71,19 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
test.describe("Add Project command-center flow", () => {
test.describe.configure({ timeout: 180_000 });
test("Add host replaces the project dialog when no hosts are connected", async ({ page }) => {
await gotoAppShell(page);
await disconnectAllHostsAndReload(page);
await openAddProjectHostSelection(page);
const originalUrl = page.url();
await chooseAddHostFromProjectFlow(page);
await expect(addProjectFlow(page)).toBeHidden();
await expect(
page.getByRole("button", { name: "Direct connection", exact: true }),
).toBeVisible();
await expect(page).toHaveURL(originalUrl);
});
test("method selection shows the daemon's available project sources without search", async ({
page,
}) => {
@@ -294,10 +294,17 @@ test("creates, renames, copies, and deletes entries through the file explorer",
await entry("renamed.txt").click({ button: "right" });
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
const deleteLabelColor = await deleteAction
.getByText("Delete", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
await expect
.poll(async () => {
const labelColor = await deleteAction
.getByText("Delete", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
const iconColor = await deleteAction
.locator("svg")
.evaluate((element) => getComputedStyle(element).stroke);
return labelColor !== "" && iconColor === labelColor;
})
.toBe(true);
const cancelledConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
@@ -122,10 +122,14 @@ test.describe("New workspace entry points", () => {
});
const hostTrigger = page.getByTestId("host-picker-trigger");
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
const originalUrl = page.url();
await hostTrigger.click();
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
await page.getByTestId("new-workspace-host-picker-add-host").click();
await expect(page).toHaveURL(/\/settings\/general\?addHost=/);
await expect(
page.getByRole("button", { name: "Direct connection", exact: true }),
).toBeVisible();
await expect(page).toHaveURL(originalUrl);
} finally {
await seeded.cleanup();
}
@@ -0,0 +1,79 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { copyPluginExample } from "../support/helpers/plugin-fixture";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
async function runCommand(page: Page, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
/** Installs a plugin example, opens a seeded workspace, and runs `exercise` against them. */
async function withExample(
page: Page,
example: { name: string; id: string },
exercise: (workspace: SeededWorkspace) => Promise<void>,
): Promise<void> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const copy = await copyPluginExample(example.name);
const workspace = await seedWorkspace({ repoPrefix: `${example.name}-` });
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(copy.directory);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
await waitForWorkspaceInSidebar(page, target);
await switchWorkspaceViaSidebar({ page, ...target });
await exercise(workspace);
} finally {
try {
await client.removePlugin(example.id);
} finally {
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await workspace.cleanup();
await client.close();
await copy.cleanup();
}
}
}
test.describe("plugin subscriptions", () => {
test.describe.configure({ timeout: 180_000 });
test("a panel follows events the daemon pushes as files change", async ({ page }) => {
await withExample(page, { name: "diff-watch", id: "diff-watch" }, async (workspace) => {
await runCommand(page, "Open live changes");
await expect(page.getByText("No changes", { exact: true })).toBeVisible();
await writeFile(path.join(workspace.repoPath, "pushed.txt"), "from the test\n");
await expect(page.getByText("1 file changed", { exact: true })).toBeVisible();
await expect(page.getByText("pushed.txt", { exact: true })).toBeVisible();
});
});
test("a panel plays streamed bytes and sends bytes back", async ({ page }) => {
await withExample(page, { name: "media-stream", id: "media-stream" }, async () => {
await runCommand(page, "Open media stream");
await expect(page.getByText("live", { exact: true })).toBeVisible();
await expect(page.getByText(/^[1-9]\d* video frames/)).toBeVisible();
await page.getByRole("button", { name: "Send this frame back", exact: true }).click();
await expect(
page.getByText(/^The daemon received [1-9]\d* bytes, sha256 [0-9a-f]{12}$/),
).toBeVisible();
});
});
});
@@ -108,10 +108,7 @@ test.describe("Question option text", () => {
}
});
test("selecting description text never answers, and the label still does", async ({
page,
context,
}) => {
test("selecting description text keeps the question open", async ({ page, context }) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
@@ -120,16 +117,13 @@ test.describe("Question option text", () => {
await dragAcross(page, description);
await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION);
await expectChecked(done, false);
await description.getByText("Logs are at", { exact: false }).dblclick({
position: { x: 4, y: 4 },
});
await expect.poll(() => selectedText(page)).toBe("Logs");
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
await expectChecked(done, false);
await done.getByText("Done", { exact: true }).click();
await expectChecked(done, true);
await continueToNextQuestion(page);
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
await openQuestion(page, { index: 1, total: 2 });
await expectChecked(done, true);
@@ -138,11 +132,17 @@ test.describe("Question option text", () => {
}
});
test("double-clicking a single-select label answers once", async ({ page, context }) => {
test("double-clicking a single-select label selects once without advancing", async ({
page,
context,
}) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick();
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
await expectChecked(card.getByRole("radio", { name: "Done" }), true);
await continueToNextQuestion(page);
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false);
@@ -154,14 +154,12 @@ test.describe("Question option text", () => {
}
});
test("multi-select description links and selection leave the option unchanged", async ({
page,
context,
}) => {
test("multi-select descriptions support links and text selection", async ({ page, context }) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click();
await continueToNextQuestion(page);
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
const lint = card.getByRole("checkbox", { name: "Lint" });
@@ -177,18 +175,16 @@ test.describe("Question option text", () => {
await dragAcross(page, description);
await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION);
await expectChecked(lint, false);
await description.getByText("Rules:", { exact: false }).dblclick({
position: { x: 4, y: 4 },
});
await expect.poll(() => selectedText(page)).toBe("Rules");
await expectChecked(lint, false);
await expectChecked(lint, true);
await typecheck.getByText("Typecheck", { exact: true }).dblclick();
await expectChecked(typecheck, true);
await lint.getByText("Lint", { exact: true }).click();
await expectChecked(lint, true);
await expectChecked(lint, false);
} finally {
await session.cleanup();
}
@@ -268,6 +264,7 @@ test.describe("Question prompt pagination", () => {
await expectQuestionHidden(page, SUCCESS_QUESTION);
await chooseQuestionOption(page, "App");
await continueToNextQuestion(page);
await expectCurrentQuestion(page, {
index: 2,
total: TOTAL_QUESTIONS,
@@ -288,6 +285,7 @@ test.describe("Question prompt pagination", () => {
preview: 'flags.enabled("new-surface")',
});
await chooseQuestionOption(page, "Behind feature flag");
await continueToNextQuestion(page);
await expectCurrentQuestion(page, {
index: 3,
total: TOTAL_QUESTIONS,
@@ -466,7 +464,9 @@ retryTest(
retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card");
await chooseQuestionOption(page, "App");
await continueToNextQuestion(page);
await chooseQuestionOption(page, "Behind feature flag");
await continueToNextQuestion(page);
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Preserve my answers after a failed submission.",
@@ -520,7 +520,9 @@ retryTest(
const card = page.getByTestId("question-form-card");
questionClient.releaseFirstResponse();
await chooseQuestionOption(page, "App");
await continueToNextQuestion(page);
await chooseQuestionOption(page, "Behind feature flag");
await continueToNextQuestion(page);
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Start with the internal team.",
@@ -1,3 +1,8 @@
import {
openGlobalNewWorkspaceComposer,
fillNewWorkspaceDraft,
expectNewWorkspaceDraft,
} from "../support/helpers/new-workspace";
import { readFile, mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -273,3 +278,71 @@ test("restart recovery host controls persist the opt-out and custom prompt", asy
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
await editRestartRecoveryPrompt(page, "Resume working.");
});
test("sidebar Add host preserves its caller and stays closed after theme changes", async ({
page,
}) => {
await gotoAppShell(page);
await expect(page).toHaveURL(/\/open-project$/);
const originalUrl = page.url();
await openSidebarAddHost(page);
await expect(page).toHaveURL(originalUrl);
await closeAddHostAndChangeTheme(page);
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
await expect(page).toHaveURL(originalUrl);
});
async function openSidebarAddHost(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Hosts", exact: true }).click();
await page.getByRole("button", { name: "Add host", exact: true }).click();
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeVisible();
}
async function closeAddHostAndChangeTheme(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Close", exact: true }).click();
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
await page.keyboard.press("Control+Alt+t");
}
test("sidebar Add host completes without navigating", async ({ page }) => {
await gotoAppShell(page);
await expect(page).toHaveURL(/\/open-project$/);
const originalUrl = page.url();
await openSidebarAddHost(page);
await connectToTestHost(page);
await expect(page).toHaveURL(originalUrl);
await expect(page.getByRole("button", { name: "Hosts", exact: true })).toBeVisible();
});
test("Settings Add host opens the saved host Overview", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: Number(getE2EDaemonPort()) });
await expectSettingsHeader(page, "Overview");
await expect(page).toHaveURL(new RegExp(`/settings/hosts/${getServerId()}/host$`));
});
test("New workspace Add host preserves the prompt after connecting", async ({ page }) => {
await gotoAppShell(page);
await openGlobalNewWorkspaceComposer(page);
await fillNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
const originalUrl = page.url();
await openNewWorkspaceAddHost(page);
await expect(page).toHaveURL(originalUrl);
await connectToTestHost(page);
await expect(page).toHaveURL(originalUrl);
await expectNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
});
async function openNewWorkspaceAddHost(page: import("@playwright/test").Page) {
await page.getByTestId("host-picker-trigger").click();
await page.getByRole("button", { name: "Add host", exact: true }).click();
}
async function connectToTestHost(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Direct connection", exact: true }).click();
await page.getByRole("textbox", { name: "Host", exact: true }).fill("127.0.0.1");
await page.getByRole("textbox", { name: "Port", exact: true }).fill(String(getE2EDaemonPort()));
await page.getByRole("button", { name: "Connect", exact: true }).click();
await expect(page.getByTestId("add-host-modal")).toBeHidden({ timeout: 30_000 });
}
@@ -113,3 +113,7 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
})
.toBe(true);
}
export async function chooseAddHostFromProjectFlow(page: Page): Promise<void> {
await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click();
}
+22
View File
@@ -273,3 +273,25 @@ export async function reloadPreservingHostRegistry(page: Page): Promise<void> {
);
await page.reload();
}
export async function disconnectAllHostsAndReload(page: Page): Promise<void> {
await page.evaluate(
({ registryKey, nonceKey, disableSeedKey }) => {
const nonce = localStorage.getItem(nonceKey);
if (!nonce) throw new Error("Expected the e2e seed nonce");
const hosts = JSON.parse(localStorage.getItem(registryKey) ?? "[]");
for (const host of hosts) {
host.connections = [{ id: "offline", type: "directTcp", endpoint: "127.0.0.1:59999" }];
host.preferredConnectionId = "offline";
}
localStorage.setItem(registryKey, JSON.stringify(hosts));
localStorage.setItem(disableSeedKey, nonce);
},
{
registryKey: REGISTRY_KEY,
nonceKey: SEED_NONCE_KEY,
disableSeedKey: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
);
await page.reload();
}
@@ -69,6 +69,9 @@ function installProbe(
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
};
}
+9 -6
View File
@@ -1,3 +1,4 @@
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
@@ -465,6 +466,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const surface = (
<View style={layoutStyles.surfaceFill}>
{workspaceChrome}
<AddHostFlowHost />
<AppearanceStyleBoundary>
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
<WindowChromeRegion corners="top-left">
@@ -805,7 +807,6 @@ function RootStack() {
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
<Stack.Screen name="schedules" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
@@ -890,11 +891,13 @@ function RootAppTree() {
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
<AddHostProvider>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</AddHostProvider>
</LucideProvider>
</View>
</GestureHandlerRootView>
+2 -3
View File
@@ -35,10 +35,9 @@ function SettingsDaemonRedirect() {
}
export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
const params = useLocalSearchParams<{ section?: string }>();
const rawSection = typeof params.section === "string" ? params.section : "";
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
@@ -50,5 +49,5 @@ export default function SettingsSectionRoute() {
);
}
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
return <SettingsScreen view={view} />;
}
@@ -1,3 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -92,7 +93,7 @@ import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store";
import type { Theme } from "@/styles/theme";
import { shortenPath } from "@/utils/shorten-path";
import { buildNewWorkspaceRoute, buildSettingsAddHostRoute } from "@/utils/host-routes";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
interface AddProjectFlowProps {
request: AddProjectFlowRequest;
@@ -325,6 +326,7 @@ function setPageStatus(
// The product flow is intentionally one cohesive page-stack state machine.
// eslint-disable-next-line complexity
export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const addHost = useAddHost();
const hosts = useHosts();
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
@@ -616,9 +618,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
icon: Plus,
testID: "add-project-flow-add-host",
select: () => {
// The native project Modal must release its window before the root sheet opens.
onClose();
usePanelStore.getState().showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
addHost();
},
});
}
@@ -717,6 +719,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
directoryPaths,
githubQuery.data,
host,
addHost,
onClose,
openAddedProject,
page,
@@ -1,42 +0,0 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it } from "vitest";
import { HostPickerOption } from "./host-picker";
const actions: string[] = [];
function select() {
actions.push("select");
}
function openSettings() {
actions.push("settings");
}
it("keeps host selection and settings as separate accessible buttons", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
actions.length = 0;
act(() =>
root.render(
<HostPickerOption
serverId="host-a"
label="Host A"
showActiveConnection={false}
active={false}
onPress={select}
onOpenHostSettings={openSettings}
/>,
),
);
const host = container.querySelector('button[aria-label="Host A"]');
const settings = container.querySelector('button[aria-label="Open Host A settings"]');
expect(host).not.toBeNull();
expect(settings).not.toBeNull();
expect(host?.contains(settings!)).toBe(false);
act(() => (settings as HTMLElement).click());
expect(actions).toEqual(["settings"]);
act(() => (host as HTMLElement).click());
expect(actions).toEqual(["settings", "select"]);
act(() => root.unmount());
container.remove();
});
@@ -8,7 +8,6 @@ import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/comb
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useHostRuntimeSnapshot, type ActiveConnection } from "@/runtime/host-runtime";
import { orderHostsLocalFirst } from "@/types/host-connection";
import { isWeb } from "@/constants/platform";
import {
ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID,
@@ -90,33 +89,35 @@ export function HostPickerOption({
);
const trailingSlot = useMemo(() => {
if (!onOpenHostSettings) return undefined;
return <View style={styles.settingsSlot} />;
}, [onOpenHostSettings]);
return (
<Pressable
onPress={handleSettingsPress}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Open ${label} settings`}
>
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
);
}, [
handleSettingsPress,
label,
onOpenHostSettings,
theme.colors.foregroundMuted,
theme.iconSize.sm,
]);
return (
<View style={styles.optionContainer}>
<ComboboxItem
label={label}
description={connectionLabel}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
onPress={onPress}
testID={testID}
/>
{onOpenHostSettings ? (
<Pressable
style={styles.settingsAction}
onPress={handleSettingsPress}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Open ${label} settings`}
>
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
</View>
<ComboboxItem
label={label}
description={connectionLabel}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
onPress={onPress}
testID={testID}
/>
);
}
@@ -318,15 +319,6 @@ export function HostPicker({
}
const styles = StyleSheet.create((theme) => ({
optionContainer: { position: "relative" },
settingsSlot: { width: theme.iconSize.sm, height: theme.iconSize.sm },
settingsAction: {
position: "absolute",
right: theme.spacing[3] + (isWeb ? 0 : theme.spacing[1]),
top: 0,
bottom: 0,
justifyContent: "center",
},
statusDotSlot: {
width: theme.iconSize.sm,
height: theme.iconSize.sm,
+4 -9
View File
@@ -1,3 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import { router } from "expo-router";
import {
CircleGauge,
@@ -62,7 +63,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
@@ -177,14 +178,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
router.push(buildSettingsRoute());
}, []);
const handleAddHostMobile = useCallback(() => {
showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
}, [showMobileAgent]);
const handleAddHostDesktop = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const handleAddHostMobile = useAddHost();
const handleAddHostDesktop = handleAddHostMobile;
const handleOpenHostSettingsMobile = useCallback(
(serverId: string) => {
@@ -130,6 +130,186 @@ it("preserves empty table cells while escaping spoilers in cell text", () => {
);
});
it("renders a table directly after a paragraph through the library parser", async () => {
const source = "**Notes**\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
const separated = "**Notes**\n\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
const flags = { hardSoftBreaks: true, latexMath: false };
const expected = await parseMarkdown(separated, flags);
expect(expected.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
expect(await parseMarkdown(prepareMarkdownSource(source).render(), flags)).toEqual(expected);
});
it.each([
[
"a quote",
"> Notes\n> | Item |\n> | --- |\n> | A |",
"> Notes\n>\n> | Item |\n> | --- |\n> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item",
"- Notes\n | Item |\n | --- |\n | A |\n- Next",
"- Notes\n\n | Item |\n | --- |\n | A |\n- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a quoted list item",
"> - Notes\n> | Item |\n> | --- |\n> | A |",
"> - Notes\n>\n> | Item |\n> | --- |\n> | A |",
["Document", ["Blockquote", ["UnorderedList", ["ListItem", "Paragraph", "Table"]]]],
],
[
"a quote inside a list item",
"- > Notes\n > | Item |\n > | --- |\n > | A |",
"- > Notes\n >\n > | Item |\n > | --- |\n > | A |",
["Document", ["UnorderedList", ["ListItem", ["Blockquote", "Paragraph", "Table"]]]],
],
[
"aligned columns without outer pipes",
"Notes\nItem | Detail\n:--- | ---:\nA | one",
"Notes\n\nItem | Detail\n:--- | ---:\nA | one",
["Document", "Paragraph", "Table"],
],
[
"an entity at the start of a header without outer pipes",
"Notes\nAT&amp;T | Detail\n--- | ---\nA | one",
"Notes\n\nAT\\&T | Detail\n--- | ---\nA | one",
["Document", "Paragraph", "Table"],
],
[
"a local image at the start of a header without outer pipes",
"Notes\n![Local](assets/a.png) | Detail\n--- | ---\nA | one",
"Notes\n\nLocal | Detail\n--- | ---\nA | one",
["Document", "Paragraph", "Table"],
],
[
"CRLF lines",
"Notes\r\n| Item |\r\n| --- |\r\n| A |",
"Notes\r\n\r\n| Item |\r\n| --- |\r\n| A |",
["Document", "Paragraph", "Table"],
],
[
"CR lines",
"Notes\r| Item |\r| --- |\r| A |",
"Notes\r\r| Item |\r| --- |\r| A |",
["Document", "Paragraph", "Table"],
],
[
"a quote with CRLF lines",
"> Notes\r\n> | Item |\r\n> | --- |\r\n> | A |",
"> Notes\r\n>\r\n> | Item |\r\n> | --- |\r\n> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item with CRLF lines",
"- Notes\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
"- Notes\r\n\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a quote with CR lines",
"> Notes\r> | Item |\r> | --- |\r> | A |",
"> Notes\r>\r> | Item |\r> | --- |\r> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item with CR lines",
"- Notes\r | Item |\r | --- |\r | A |\r- Next",
"- Notes\r\r | Item |\r | --- |\r | A |\r- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a non-interrupting ordered marker with tildes",
"Intro\n2. ~~~\n Notes\n | A |\n | --- |\n | x |",
"Intro\n2. ~~~\n Notes\n\n | A |\n | --- |\n | x |",
["Document", "Paragraph", "Table"],
],
[
"a non-interrupting ordered marker with backticks",
"Notes\n2. ```\n | Item |\n | --- |\n | A |",
"Notes\n2. ```\n\n | Item |\n | --- |\n | A |",
["Document", "Paragraph", "Table"],
],
])("preserves tables after paragraphs in %s", async (_name, source, separated, expected) => {
const rendered = prepareMarkdownSource(source).render();
expect(rendered).toEqual(separated);
const flags = { hardSoftBreaks: true, latexMath: false };
function blocks(node: ASTNode): unknown {
if (node.type === "Paragraph" || node.type === "Table") return node.type;
return [node.type, ...(node.children ?? []).map(blocks)];
}
expect(blocks(await parseMarkdown(rendered, flags))).toEqual(expected);
});
it.each([
[
"a literal backslash",
"Notes\\\n| Item |\n| --- |\n| A |",
"Notes\\\n\n| Item |\n| --- |\n| A |",
"Notes\\",
],
[
"an entity and literal backslash with LF lines",
"AT&amp;T\\\n| Item |\n| --- |\n| A |",
"AT\\&T\\\n\n| Item |\n| --- |\n| A |",
"AT&T\\",
],
[
"an entity and literal backslash with CRLF lines",
"AT&amp;T\\\r\n| Item |\r\n| --- |\r\n| A |",
"AT\\&T\\\r\n\r\n| Item |\r\n| --- |\r\n| A |",
"AT&T\\",
],
[
"an entity and literal backslash with CR lines",
"AT&amp;T\\\r| Item |\r| --- |\r| A |",
"AT\\&T\\\r\r| Item |\r| --- |\r| A |",
"AT&T\\",
],
])(
"preserves paragraph text before an interrupting table in %s",
async (_name, source, separated, text) => {
const rendered = prepareMarkdownSource(source).render();
expect(rendered).toEqual(separated);
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
expect(tree.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
expect(tree.children?.[0]?.children?.map((node) => node.content).join("")).toEqual(text);
},
);
it.each([
"```yaml\n---\nkey: value\n---\n```\n",
"~~~md\n| Item |\n| --- |\n| A |\n~~~\n",
"````md\n```\n| Item |\n| --- |\n```\n`````\n",
"> ```md\n> | Item |\n> | --- |\n> ```\n",
"> ```md\n> Code\n",
"- ```md\n Code\n",
" ```\n\n",
"```not`a-fence\n",
])("renders a table after a fence without changing its code: %s", async (prefix) => {
const table = "| Item |\n| --- |\n| A |";
const rendered = prepareMarkdownSource(`${prefix}Notes\n${table}`).render();
expect(rendered).toEqual(`${prefix}Notes\n\n${table}`);
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
expect(tree.children?.slice(-2).map((node) => node.type)).toEqual(["Paragraph", "Table"]);
});
it.each([
"| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
"Notes\n\n| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
"Notes\n\n| Item |\n| --- |\n| A |",
"| Item |\n| --- |\n| A |",
"```md\nNotes\n| Item |\n| --- |\n| A |\n```",
"```yaml\n---\nkey: value\n---\n```",
"```yaml\n---\nkey: value",
"~~~md\n| Item |\n| --- |\n| A |\n~~~",
"````md\n```\n| Item |\n| --- |\n```\n````",
" Notes\n | Item |\n | --- |\n | A |",
"Notes\n| Item | Detail |\n| --- |\n| A |",
])("leaves separated tables and table-like text unchanged: %s", (source) => {
expect(prepareMarkdownSource(source).render()).toEqual(source);
});
it.each([
"src/components/message.tsx",
"C:\\repo\\file.ts",
+24 -5
View File
@@ -138,6 +138,10 @@ export function prepareMarkdownSource(
!source.includes("![") &&
!source.includes("||") &&
!source.includes("&") &&
// Match content, header, and delimiter lines; quote-only lines count as blank.
!/^[\t >]*[^\t >\r\n][^\r\n]*(?:\r\n|\r|\n)[^\r\n]+(?:\r\n|\r|\n)(?=[\t >:|-]*\|)[\t >|]*:?-[-\t :|]*$/m.test(
source,
) &&
!(workspaceRoot && source.includes("]("))
) {
return { images, ...metadata, render: () => source };
@@ -212,11 +216,25 @@ export function prepareMarkdownSource(
if (lineEnd < end) split(lineEnd);
}
}
function visit(node: Nodes) {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) {
function visit(node: Nodes, previous?: Nodes) {
const position = node.position;
if (position?.start.offset !== undefined && position.end.offset !== undefined) {
const start = position.start.offset;
const end = position.end.offset;
if (node.type === "paragraph") splitStandaloneImages(node, start, end);
if (
node.type === "table" &&
previous?.type === "paragraph" &&
previous.position?.end.line === position.start.line - 1
) {
// MD4C only recognizes a table header on the first line of a paragraph.
const lineStart =
Math.max(source.lastIndexOf("\n", start - 1), source.lastIndexOf("\r", start - 1)) + 1;
const newline = source.slice(lineStart - 2, lineStart).replace(/[^\r\n]/g, "");
const blank = source.slice(lineStart, start).replace(/[^>]/g, " ").trimEnd();
const boundary = lineStart - newline.length;
edits.push({ start: boundary, end: lineStart, text: `${newline}${blank}${newline}` });
}
if (node.type === "link") {
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
if (title !== null)
@@ -244,7 +262,8 @@ export function prepareMarkdownSource(
if (text !== source.slice(start, end)) edits.push({ start, end, text });
}
}
if ("children" in node) node.children.forEach(visit);
if ("children" in node)
node.children.forEach((child, index) => visit(child, node.children[index - 1]));
}
visit(getTree());
return {
@@ -39,7 +39,10 @@ afterEach(() => {
}
});
function buildPermission(question: Record<string, unknown>): PendingPermission {
function buildPermission(
question: Record<string, unknown>,
following: Record<string, unknown>[] = [],
): PendingPermission {
return {
key: "perm-1",
agentId: "agent-1",
@@ -48,12 +51,12 @@ function buildPermission(question: Record<string, unknown>): PendingPermission {
provider: "claude",
name: "AskUserQuestion",
kind: "question",
input: { questions: [question] },
input: { questions: [question, ...following] },
},
};
}
function mountCard(question: Record<string, unknown>) {
function mountCard(question: Record<string, unknown>, following: Record<string, unknown>[] = []) {
const onRespond = vi.fn<(response: AgentPermissionResponse) => void>();
const container = document.createElement("div");
document.body.appendChild(container);
@@ -61,7 +64,7 @@ function mountCard(question: Record<string, unknown>) {
act(() =>
root.render(
<QuestionFormCard
permission={buildPermission(question)}
permission={buildPermission(question, following)}
onRespond={onRespond}
isResponding={false}
responseError={null}
@@ -71,10 +74,14 @@ function mountCard(question: Record<string, unknown>) {
mounted.push({ root, container });
const view = within(container);
const optionRole = question.multiSelect ? "checkbox" : "radio";
const otherInput = () =>
view.getByRole<HTMLInputElement>("textbox", { name: String(question.question) });
const check = (label: string) => act(() => view.getByRole(optionRole, { name: label }).click());
const check = (label: string) =>
act(() =>
(
view.queryByRole("radio", { name: label }) ?? view.getByRole("checkbox", { name: label })
).click(),
);
const type = (text: string) => {
const input = otherInput();
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
@@ -90,7 +97,7 @@ function mountCard(question: Record<string, unknown>) {
if (!response || response.behavior !== "allow") throw new Error("card did not submit");
return (response.updatedInput as { answers: Record<string, string> }).answers;
};
return { check, type, otherInput, submit, submittedAnswers };
return { check, type, otherInput, submit, submittedAnswers, view, onRespond };
}
const multiSelectQuestion = {
@@ -154,3 +161,31 @@ describe("QuestionFormCard other answers", () => {
expect(card.submittedAnswers()).toEqual({ Provider: "Codex" });
});
});
describe("QuestionFormCard explicit navigation", () => {
it("keeps a picked option visible until Next and submits only on Submit", () => {
const card = mountCard(singleSelectQuestion, [multiSelectQuestion]);
card.check("Codex");
expect(card.view.getByRole("radio", { name: "Codex" }).getAttribute("aria-checked")).toBe(
"true",
);
expect(card.onRespond).not.toHaveBeenCalled();
act(() => card.view.getByRole("button", { name: "Next" }).click());
card.check("Apple");
expect(card.onRespond).not.toHaveBeenCalled();
card.submit();
expect(card.submittedAnswers()).toEqual({ Provider: "Codex", Fruits: "Apple" });
});
it("selects an option when its description is clicked without submitting", () => {
const card = mountCard({
...singleSelectQuestion,
options: [{ label: "Codex", description: "Use this provider" }],
});
act(() => card.view.getByText("Use this provider").click());
expect(card.view.getByRole("radio", { name: "Codex" }).getAttribute("aria-checked")).toBe(
"true",
);
expect(card.onRespond).not.toHaveBeenCalled();
});
});
@@ -43,20 +43,16 @@ const IS_WEB = isWeb;
// drag to the preview's nested scroll, so lines past a cap would be unreachable.
const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined;
// Web clicks that must not answer: a link in the label, anything in the description, and the
// second click of a double-click, which would otherwise land on the next question's options.
function isNonAnsweringClick(event: GestureResponderEvent, description: View | null): boolean {
// Links are independent actions. Ignore repeat clicks so double-click text selection
// toggles an option only once.
function isNonAnsweringClick(event: GestureResponderEvent): boolean {
if (!IS_WEB) return false;
const click = event.nativeEvent as unknown as MouseEvent;
if (click.detail > 1) return true;
const target = click.target as Element | null;
const descriptionElement = description as unknown as Element | null;
return Boolean(target?.closest("a") || descriptionElement?.contains(target));
return Boolean(target?.closest("a"));
}
// The description is selectable text, so a touch on it belongs to the text, not the option.
const claimResponder = () => true;
function getQuestionInputPlaceholder({
question,
answerPlaceholder,
@@ -92,10 +88,9 @@ function QuestionOptionRow({
}: QuestionOptionRowProps) {
const { theme } = useUnistyles();
const descriptionRef = useRef<View>(null);
const handlePress = useCallback(
(event: GestureResponderEvent) => {
if (isNonAnsweringClick(event, descriptionRef.current)) return;
if (isNonAnsweringClick(event)) return;
onToggle(qIndex, optIndex, multiSelect);
},
[onToggle, qIndex, optIndex, multiSelect],
@@ -152,11 +147,7 @@ function QuestionOptionRow({
<View style={styles.optionTextBlock}>
<MarkdownRenderer text={option.label} compact muted={!isSelected} selectable={false} />
{option.description ? (
<View
ref={descriptionRef}
testID="question-form-option-description"
onStartShouldSetResponder={claimResponder}
>
<View testID="question-form-option-description">
<MarkdownRenderer text={option.description} compact muted />
</View>
) : null}
@@ -413,12 +404,8 @@ export function QuestionFormCard({
});
otherInputRef.current?.replaceText("");
}
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
}
},
[activeQuestionIndex, otherTexts, questions, selections],
[otherTexts, selections],
);
const setOtherText = useCallback(
+7 -48
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useAddHost } from "@/hosts/add-host";
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router";
@@ -12,11 +13,7 @@ import {
Terminal,
} from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { AddHostModal } from "./add-host-modal";
import { AddRemoteSshHostModal } from "./add-remote-ssh-host-modal";
import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button";
import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -162,20 +159,13 @@ function useAnyHostOnline(serverIds: string[]): string | null {
);
}
export interface WelcomeScreenProps {
onHostAdded?: (profile: HostProfile) => void;
}
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
export function WelcomeScreen() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
const [isDirectOpen, setIsDirectOpen] = useState(false);
const [isRemoteSshOpen, setIsRemoteSshOpen] = useState(false);
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
@@ -196,23 +186,10 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
router.push("/settings");
}, [router]);
const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []);
const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []);
const handleOpenRemoteSsh = useCallback(() => setIsRemoteSshOpen(true), []);
const handleCloseRemoteSsh = useCallback(() => setIsRemoteSshOpen(false), []);
const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []);
const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []);
const handleScanQr = useCallback(() => {
router.push("/pair-scan?source=onboarding");
}, [router]);
const handleHostSaved = useCallback(
({ profile }: { profile: HostProfile; serverId: string }) => {
onHostAdded?.(profile);
finishOnboarding();
},
[onHostAdded, finishOnboarding],
);
const handleOpenDirect = useAddHost({ method: "direct", onAdded: finishOnboarding });
const handleOpenRemoteSsh = useAddHost({ method: "ssh", onAdded: finishOnboarding });
const handleOpenPasteLink = useAddHost({ method: "link", onAdded: finishOnboarding });
const handleScanQr = useAddHost({ method: "scan", onAdded: finishOnboarding });
const actions: WelcomeAction[] =
isWeb || isFdroidBuild
@@ -316,24 +293,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
</Button>
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>
<AddHostModal
visible={isDirectOpen}
onClose={handleCloseDirect}
onSaved={handleHostSaved}
/>
<AddRemoteSshHostModal
visible={isRemoteSshOpen}
onClose={handleCloseRemoteSsh}
onSaved={handleHostSaved}
/>
<PairLinkModal
visible={isPasteLinkOpen}
onClose={handleClosePasteLink}
onSaved={handleHostSaved}
/>
</ScrollView>
</View>
);
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { executeDaemonManagementToggle } from "./daemon-management-toggle";
import { resolveDesktopDaemonServerId, type DesktopDaemonStatus } from "./desktop-daemon";
import type { DesktopDaemonStatus } from "./desktop-daemon";
const runningManagedStatus: DesktopDaemonStatus = {
serverId: "desktop",
@@ -54,29 +54,6 @@ function makeDeps(overrides?: {
};
}
it("retains the desktop identity when the daemon is stopped or status fails", async () => {
expect(
await resolveDesktopDaemonServerId({
getStatus: async () => ({ ...stoppedStatus, serverId: "" }),
getSavedId: async () => "desktop",
}),
).toBe("desktop");
expect(
await resolveDesktopDaemonServerId({
getStatus: async () => {
throw new Error("offline");
},
getSavedId: async () => "desktop",
}),
).toBe("desktop");
expect(
await resolveDesktopDaemonServerId({
getStatus: async () => runningManagedStatus,
getSavedId: async () => "old",
}),
).toBe("desktop");
});
describe("executeDaemonManagementToggle", () => {
describe("enable path (currentlyManaging: false)", () => {
it("persists the new setting then starts the daemon", async () => {
@@ -186,28 +186,6 @@ export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand("desktop_daemon_status"));
}
/** Resolve the identity even when the daemon has stopped. */
export async function resolveDesktopDaemonServerId(
deps: {
getStatus(): Promise<DesktopDaemonStatus>;
getSavedId(): Promise<string | null>;
} = {
getStatus: getDesktopDaemonStatus,
getSavedId: () => invokeDesktopCommand<string | null>("desktop_daemon_identity"),
},
): Promise<string | null> {
try {
const id = (await deps.getStatus()).serverId.trim();
if (id) return id;
} catch {
/* The persisted identity remains available if the status request fails. */
}
const id = await deps.getSavedId();
if (id === null) return null;
if (typeof id !== "string") throw new Error("Invalid desktop daemon identity");
return id.trim() || null;
}
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon"));
}
+89
View File
@@ -0,0 +1,89 @@
import { createContext, useCallback, useContext, useState, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useStore } from "zustand";
import { AddHostMethodModal } from "./internal/add-host-method-modal";
import { AddHostModal } from "./internal/add-host-modal";
import { AddRemoteSshHostModal } from "./internal/add-remote-ssh-host-modal";
import { PairLinkModal } from "./internal/pair-link-modal";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { PairScanContent } from "./internal/scan";
import { createAddHostFlow, type AddHostOptions, type AddHostSession } from "./internal/model";
const Context = createContext<ReturnType<typeof createAddHostFlow> | null>(null);
/** Owns the flow outside screen and appearance lifetimes. Callers own success navigation. */
export function AddHostProvider({ children }: { children: ReactNode }) {
const [flow] = useState(createAddHostFlow);
return <Context.Provider value={flow}>{children}</Context.Provider>;
}
export function AddHostFlowHost() {
const flow = useContext(Context);
if (!flow) throw new Error("AddHostFlowHost requires AddHostProvider");
const session = useStore(flow.store, (state) => state.session);
return session ? <AddHostSessionView key={session.id} session={session} /> : null;
}
export function useAddHost({ method, onAdded }: AddHostOptions = {}) {
const flow = useContext(Context);
if (!flow) throw new Error("useAddHost requires AddHostProvider");
return useCallback(() => flow.open({ method, onAdded }), [flow, method, onAdded]);
}
function AddHostSessionView({ session }: { session: AddHostSession }) {
const { t } = useTranslation();
const method = useStore(session.step, (state) => state.method);
const back = useCallback(() => session.choose(null), [session]);
const direct = useCallback(() => session.choose("direct"), [session]);
const ssh = useCallback(() => session.choose("ssh"), [session]);
const link = useCallback(() => session.choose("link"), [session]);
const scan = useCallback(() => session.choose("scan"), [session]);
const scanned = useCallback((serverId: string) => session.complete({ serverId }), [session]);
const header = useMemo(
() => ({ title: t("pairing.scan.title"), back: { onPress: back } }),
[t, back],
);
return (
<>
<AddHostMethodModal
visible={method === null}
onClose={session.close}
onDirectConnection={direct}
onRemoteSsh={ssh}
onPasteLink={link}
onScanQr={scan}
/>
<AddHostModal
visible={method === "direct"}
onClose={session.close}
onCancel={back}
onSaved={session.complete}
/>
<AddRemoteSshHostModal
visible={method === "ssh"}
onClose={session.close}
onCancel={back}
onSaved={session.complete}
/>
<PairLinkModal
visible={method === "link"}
onClose={session.close}
onCancel={back}
onSaved={session.complete}
/>
{method === "scan" ? (
<AdaptiveModalSheet
header={header}
visible
onClose={session.close}
scrollable={false}
snapPoints={SCAN_SNAP_POINTS}
desktopHeight="85%"
>
<PairScanContent onConnected={scanned} onClose={back} />
</AdaptiveModalSheet>
) : null}
</>
);
}
const SCAN_SNAP_POINTS = ["90%"];
@@ -1,9 +1,10 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { isFdroidBuild } from "@/constants/build-profile";
import { isNative } from "@/constants/platform";
import { isElectronRuntime } from "@/desktop/host";
@@ -84,7 +85,7 @@ export function AddHostMethodModal({
onClose={onClose}
testID="add-host-method-modal"
>
<Pressable
<PressHighlight
style={styles.option}
onPress={handleDirect}
accessibilityRole="button"
@@ -98,10 +99,10 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.direct.description")}
</Text>
</View>
</Pressable>
</PressHighlight>
{isElectronRuntime() ? (
<Pressable
<PressHighlight
style={styles.option}
onPress={handleRemoteSsh}
accessibilityRole="button"
@@ -115,11 +116,11 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.remoteSsh.description")}
</Text>
</View>
</Pressable>
</PressHighlight>
) : null}
{isNative && !isFdroidBuild ? (
<Pressable
<PressHighlight
style={styles.option}
onPress={handleScan}
accessibilityRole="button"
@@ -132,10 +133,10 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.scanQr.description")}
</Text>
</View>
</Pressable>
</PressHighlight>
) : null}
<Pressable
<PressHighlight
style={styles.option}
onPress={handlePaste}
accessibilityRole="button"
@@ -149,7 +150,7 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.pasteLink.description")}
</Text>
</View>
</Pressable>
</PressHighlight>
</AdaptiveModalSheet>
);
}
@@ -1,17 +1,22 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Alert, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { buildConnectionFailureCopy } from "@/components/connection-failure-copy";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "./pair-link-credentials";
import { buildDirectConnectionUri } from "./direct-connection-host";
import { PairingTargetTracker } from "@/components/pair-link-credentials";
import { buildDirectConnectionUri } from "@/components/direct-connection-host";
const FLEX_ONE_STYLE = { flex: 1 } as const;
@@ -188,7 +193,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
}
function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations();
@@ -207,10 +211,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const advancedTarget = useRef(new PairingTargetTracker("", true));
const connectIcon = useMemo(
() => <Link2 size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const connectIcon = useMemo(() => <ThemedLink2 size={16} />, []);
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
const checkboxStyle = useMemo(
@@ -415,8 +416,8 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsAdvancedOpen(false);
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
const AdvancedIcon = isAdvancedOpen ? ThemedChevronDown : ThemedChevronRight;
const PasswordIcon = isPasswordVisible ? ThemedEyeOff : ThemedEye;
return (
<AdaptiveModalSheet
@@ -430,7 +431,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
<View style={styles.portRow}>
<View style={hostFieldStyle}>
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
<AdaptiveTextInput
<ThemedAdaptiveTextInput
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel={t("pairing.direct.fields.host")}
@@ -438,7 +439,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-host-${inputResetKey}`}
onChangeText={setHost}
placeholder="localhost"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
@@ -449,7 +449,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</View>
<View style={portFieldStyle}>
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
<AdaptiveTextInput
<ThemedAdaptiveTextInput
testID="direct-port-input"
nativeID="direct-port-input"
accessibilityLabel={t("pairing.direct.fields.port")}
@@ -457,7 +457,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-port-${inputResetKey}`}
onChangeText={setPort}
placeholder="6767"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
@@ -469,7 +468,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</View>
</View>
<Pressable
<PressHighlight
style={styles.checkboxRow}
onPress={handleToggleUseTls}
disabled={isSaving}
@@ -482,17 +481,17 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
<View style={checkboxStyle}>
{useTls ? (
<View testID="direct-ssl-toggle-checked">
<Check size={14} color={theme.colors.accentForeground} />
<ThemedCheck size={14} />
</View>
) : null}
</View>
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
</Pressable>
</PressHighlight>
<View style={styles.field}>
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
<View style={styles.passwordRow}>
<AdaptiveTextInput
<ThemedAdaptiveTextInput
testID="direct-password-input"
nativeID="direct-password-input"
accessibilityLabel={t("pairing.direct.fields.password")}
@@ -500,7 +499,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-password-${inputResetKey}`}
onChangeText={setPassword}
placeholder={t("pairing.direct.fields.optional")}
placeholderTextColor={theme.colors.foregroundMuted}
style={passwordInputStyle}
autoCapitalize="none"
autoCorrect={false}
@@ -509,7 +507,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
<Pressable
<PressHighlight
style={styles.iconButton}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
@@ -521,13 +519,13 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
}
testID="direct-password-visibility-toggle"
>
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
</Pressable>
<PasswordIcon size={18} />
</PressHighlight>
</View>
</View>
<View style={styles.field}>
<Pressable
<PressHighlight
style={styles.advancedToggle}
onPress={handleToggleAdvanced}
disabled={isSaving}
@@ -537,11 +535,11 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
}
testID="direct-host-advanced-toggle"
>
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
<AdvancedIcon size={16} />
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
</Pressable>
</PressHighlight>
{isAdvancedOpen ? (
<AdaptiveTextInput
<ThemedAdaptiveTextInput
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
@@ -549,7 +547,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-host-uri-${inputResetKey}`}
onChangeText={handleChangeAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
@@ -585,3 +582,23 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</AdaptiveModalSheet>
);
}
const ThemedLink2 = withUnistyles(Link2, (theme) => ({ color: theme.colors.accentForeground }));
const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.accentForeground }));
const ThemedChevronDown = withUnistyles(ChevronDown, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
@@ -1,7 +1,7 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Pressable,
Text,
View,
type PressableStateCallbackType,
@@ -23,7 +23,7 @@ import {
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { useIsCompactFormFactor } from "@/constants/layout";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ThemedTerminal = withUnistyles(Terminal);
@@ -174,11 +174,11 @@ export function AddRemoteSshHostModal({
}
clear();
onClose();
onSaved?.({
...result,
isNewHost: !hosts.some((profile) => profile.serverId === result.serverId),
});
onClose();
}, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]);
const handleTargetChange = useCallback((value: string) => {
targetRef.current = value;
@@ -260,7 +260,7 @@ export function AddRemoteSshHostModal({
onSubmitEditing={handleSubmit}
/>
</View>
<Pressable
<PressHighlight
style={iconButtonStyle}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
@@ -273,7 +273,7 @@ export function AddRemoteSshHostModal({
testID="remote-ssh-password-visibility-toggle"
>
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
</Pressable>
</PressHighlight>
</View>
</Field>
<View style={styles.actions}>
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { createAddHostFlow } from "./model";
describe("add host flow", () => {
it("closes before notifying the consumer and ignores duplicate completion", () => {
const flow = createAddHostFlow();
const added: string[] = [];
flow.open({
onAdded: (serverId) => {
expect(flow.store.getState().session).toBeNull();
added.push(serverId);
},
});
const session = flow.store.getState().session!;
session.choose("direct");
session.complete({ serverId: "new-host" });
session.complete({ serverId: "new-host" });
expect(added).toEqual(["new-host"]);
});
it("cancels without a success action and accepts a fresh request", () => {
const flow = createAddHostFlow();
const added: string[] = [];
flow.open({ onAdded: (id) => added.push(id) });
const cancelled = flow.store.getState().session!;
cancelled.choose("scan");
cancelled.close();
expect(flow.store.getState().session).toBeNull();
flow.open({ method: "link" });
const current = flow.store.getState().session!;
cancelled.complete({ serverId: "late-result" });
cancelled.close();
expect(flow.store.getState().session).toBe(current);
expect(current.step.getState()).toEqual({ method: "link" });
expect(added).toEqual([]);
current.complete({ serverId: "new-host" });
expect(flow.store.getState().session).toBeNull();
});
it("returns to method selection within the same request", () => {
const flow = createAddHostFlow();
flow.open();
const session = flow.store.getState().session!;
session.choose("ssh");
session.choose(null);
expect(flow.store.getState().session).toBe(session);
expect(session.step.getState()).toEqual({ method: null });
});
});
@@ -0,0 +1,47 @@
import { createStore, type StoreApi } from "zustand/vanilla";
export type AddHostMethod = "direct" | "ssh" | "link" | "scan";
export interface AddHostOptions {
method?: AddHostMethod;
onAdded?: (serverId: string) => void;
}
export interface AddHostSession {
id: number;
step: StoreApi<{ method: AddHostMethod | null }>;
choose(method: AddHostMethod | null): void;
close(): void;
complete(result: { serverId: string }): void;
}
export function createAddHostFlow() {
const store = createStore<{ session: AddHostSession | null }>(() => ({ session: null }));
let nextId = 0;
function createSession(options: AddHostOptions) {
const step = createStore<{ method: AddHostMethod | null }>(() => ({
method: options.method ?? null,
}));
const session = {
id: ++nextId,
step,
choose(method: AddHostMethod | null) {
step.setState({ method });
},
close() {
if (store.getState().session === session) store.setState({ session: null });
},
complete({ serverId }: { serverId: string }) {
if (store.getState().session !== session) return;
session.close();
options.onAdded?.(serverId);
},
};
return session;
}
return {
store,
open(options: AddHostOptions = {}) {
store.setState({ session: createSession(options) });
},
};
}
@@ -1,16 +1,20 @@
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
import { PairingTargetTracker } from "./pair-link-credentials";
import { PairingTargetTracker } from "@/components/pair-link-credentials";
import { Button } from "@/components/ui/button";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
@@ -100,7 +104,6 @@ function PairLinkModalContent({
onCancel,
onSaved,
}: PairLinkModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { beginLinkPairing } = useHostMutations();
@@ -126,10 +129,7 @@ function PairLinkModalContent({
resetPasswordInput();
}, []);
const pairIcon = useMemo(
() => <Link size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const pairIcon = useMemo(() => <ThemedLink size={16} />, []);
const handleClose = useCallback(() => {
if (isSaving) return;
@@ -212,7 +212,7 @@ function PairLinkModalContent({
<View style={styles.field}>
<Text style={styles.label}>{t("pairing.link.label")}</Text>
<AdaptiveTextInput
<ThemedAdaptiveTextInput
ref={inputRef}
initialValue={initialUrl}
testID="pair-link-input"
@@ -220,7 +220,6 @@ function PairLinkModalContent({
accessibilityLabel={t("pairing.link.label")}
onChangeText={handleChangeOfferUrl}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoFocus
autoCapitalize="none"
@@ -235,7 +234,7 @@ function PairLinkModalContent({
<Text style={styles.label}>
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
</Text>
<AdaptiveTextInput
<ThemedAdaptiveTextInput
testID="pair-link-password-input"
resetKey={`pair-link-password-${passwordResetKey}`}
accessibilityLabel={t("pairing.hostPassword.label")}
@@ -274,3 +273,9 @@ function PairLinkModalContent({
</AdaptiveModalSheet>
);
}
const ThemedLink = withUnistyles(Link, (theme) => ({ color: theme.colors.accentForeground }));
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
@@ -1,17 +1,14 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { openPairScan } from "@/hosts/pair-scan-model";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { openPairScan } from "./pair-scan-model";
import { isWeb } from "@/constants/platform";
import { BackHeader } from "@/components/headers/back-header";
import { PairLinkModal } from "@/components/pair-link-modal";
import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button";
const styles = StyleSheet.create((theme) => ({
@@ -113,50 +110,26 @@ const styles = StyleSheet.create((theme) => ({
},
}));
export default function PairScanScreen() {
const { theme } = useUnistyles();
export function PairScanContent({
onConnected,
onClose,
}: {
onConnected: (serverId: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const params = useLocalSearchParams<{
source?: string;
}>();
const source = typeof params.source === "string" ? params.source : "settings";
const [permission, requestPermission] = useCameraPermissions();
const navigateToPairedHost = useCallback(
(serverId: string) => {
if (source === "onboarding") {
router.replace(buildHostRootRoute(serverId));
return;
}
router.replace(buildSettingsHostRoute(serverId));
},
[router, source],
);
const [scan] = useState(() =>
openPairScan({
importConnectionLink: (link) =>
getHostRuntimeStore().importConnectionLink(
link,
source === "onboarding" ? "hostRoot" : "hostSettings",
),
onConnected: navigateToPairedHost,
importConnectionLink: (link) => getHostRuntimeStore().importConnectionLink(link, "hostRoot"),
onConnected,
}),
);
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
useEffect(() => () => scan.close(), [scan]);
const closeToSource = useCallback(() => {
try {
router.back();
} catch {
router.replace("/" as Href);
}
}, [router]);
useEffect(() => {
if (isWeb) return;
if (permission && permission.granted) return;
@@ -169,35 +142,29 @@ export default function PairScanScreen() {
[scan],
);
const handleRouterBack = useCallback(() => router.back(), [router]);
const savePasswordPairing = useCallback(
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
[navigateToPairedHost],
({ serverId }: { serverId: string }) => onConnected(serverId),
[onConnected],
);
const handleRequestPermission = useCallback(() => {
void requestPermission();
}, [requestPermission]);
const bodyStyle = useMemo(
() => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }],
[insets.bottom, theme.spacing],
);
const helperTextStyle = useMemo(
() => [styles.helperText, { color: theme.colors.foreground }],
[theme.colors.foreground],
() => [styles.body, { paddingBottom: insets.bottom + 24 }],
[insets.bottom],
);
if (isWeb) {
return (
<View style={styles.container}>
<BackHeader title={t("pairing.scan.title")} onBack={handleRouterBack} />
<View style={bodyStyle}>
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
<Pressable style={styles.permissionButton} onPress={closeToSource}>
<Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
</Pressable>
<Button style={styles.permissionButton} onPress={onClose}>
<Text style={styles.permissionButtonText}>{t("common.actions.back")}</Text>
</Button>
</View>
</View>
</View>
@@ -208,16 +175,14 @@ export default function PairScanScreen() {
return (
<View style={styles.container}>
<BackHeader title={t("pairing.scan.title")} onBack={closeToSource} />
<View style={bodyStyle}>
{!granted ? (
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</Text>
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
<Button style={styles.permissionButton} onPress={handleRequestPermission}>
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
</Pressable>
</Button>
</View>
) : (
<View style={styles.cameraWrap}>
@@ -235,10 +200,10 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBR]} />
</View>
{scanState.status === "pairing" ? (
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
<Text style={styles.helperText}>{t("pairing.scan.pairing")}</Text>
) : null}
{scanState.status === "stopped" && scanState.error ? (
<Text style={helperTextStyle}>{scanState.error}</Text>
<Text style={styles.helperText}>{scanState.error}</Text>
) : null}
{scanState.status === "stopped" ? (
<Button
+4 -11
View File
@@ -1,12 +1,11 @@
import { useAddHost } from "@/hosts/add-host";
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { router } from "expo-router";
import { Server } from "lucide-react-native";
import { create } from "zustand";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -20,7 +19,6 @@ import {
} from "@/lib/overlay-root";
import { useHosts } from "@/runtime/host-runtime";
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
type HostFilter = (host: HostProfile) => boolean;
type HostChoiceHandler = (serverId: string) => void | Promise<void>;
@@ -65,6 +63,7 @@ function matchesHostQuery(host: HostProfile, query: string): boolean {
}
export function useHostChooser() {
const addHost = useAddHost();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const open = useHostChooserStore((state) => state.open);
@@ -76,13 +75,7 @@ export function useHostChooser() {
);
if (availableHosts.length === 0) {
(
input.onNoHosts ??
(() => {
usePanelStore.getState().showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
})
)();
(input.onNoHosts ?? addHost)();
return false;
}
@@ -98,7 +91,7 @@ export function useHostChooser() {
});
return true;
},
[hosts, localServerId, open],
[hosts, localServerId, open, addHost],
);
}
@@ -17,6 +17,9 @@ function installation(): InstalledPlugin {
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
@@ -132,19 +132,12 @@ function harness() {
return {
hosts: createPluginHosts(
{
addHost: async () => ({ status: "cancelled" }),
getHosts: () => [],
getSnapshot: () => null,
subscribeAll: () => () => {},
subscribeHostList: () => () => {},
renameHost: async () => {},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async () => {},
removeConnection: async () => {},
},
installation.lifetime.signal,
{ openSettings() {}, isManagedLocalHost: async () => false },
),
paseo: installation.paseo,
i18n: installation.i18n,
@@ -160,6 +153,9 @@ function harness() {
openPanel() {},
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
};
},
});
+4 -12
View File
@@ -1,11 +1,8 @@
import { createPlayAudio, type AudioEngine } from "@/audio";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import {
resolveDesktopDaemonServerId,
shouldUseDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { createPluginHosts } from "./hosts";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
import {
createPluginAgentActionContext,
createPluginCapabilities,
@@ -30,14 +27,7 @@ export function createPluginClientRuntime(
...capabilities,
i18n: installation.i18n,
playAudio: createPlayAudio(audio, installation.lifetime.signal),
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal, {
openSettings(serverId) {
createPluginNavigation({ serverId, workspaceId: null }).openHostSettings(serverId);
},
async isManagedLocalHost(serverId) {
return shouldUseDesktopDaemon() && (await resolveDesktopDaemonServerId()) === serverId;
},
}),
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
addComposerPill(contribution) {
return pluginButtonStore.addComposerPill(installation, contribution);
},
@@ -47,6 +37,8 @@ export function createPluginClientRuntime(
openPanel(panelId, options) {
openClientPanel({ installation, state, panelId, options });
},
subscribe: (contract, input, onEvent, options) =>
subscribePluginRpc(contract, installation.subscribe, input, onEvent, options),
};
}
@@ -71,6 +71,9 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
clientType: "cli",
}),
),
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
invoke: async (method: string, input: unknown) => {
expect(method).toBe("review.inspect");
return { value: inspect.input.parse(input).value + 1 };
+2 -15
View File
@@ -819,14 +819,6 @@ it("binds imported getters to each originating installation across delayed callb
hosts: {
getSnapshot: () => [],
subscribe: () => () => {},
observeHosts: () => {
calls.push(`${installation}/observe`);
return () => {};
},
addHost: async (input) => {
calls.push(`${installation}/add:${input.connection}`);
return { status: "cancelled" };
},
getPaseoClient(serverId) {
calls.push(`${installation}/${serverId}`);
return runtime.paseo;
@@ -834,12 +826,11 @@ it("binds imported getters to each originating installation across delayed callb
},
});
const source = bundle(`
const { getPaseoClient, observeHosts, addHost } = require("@getpaseo/plugin/client");
const { getPaseoClient } = require("@getpaseo/plugin/client");
getPaseoClient("entry-host");
observeHosts(() => {});
plugin.addCommandCenterItem({
id: "read", title: "Read", icon: "Server", context: "global",
onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); await addHost({ connection: "example:6767" }); },
onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); },
});
`);
const first = runPluginClientBundle("same-id", source, hostRuntime("first"));
@@ -849,13 +840,9 @@ it("binds imported getters to each originating installation across delayed callb
await second.commandCenterItems[0].onSelect({} as never);
expect(calls).toEqual([
"first/entry-host",
"first/observe",
"second/entry-host",
"second/observe",
"first/target-host",
"first/add:example:6767",
"second/target-host",
"second/add:example:6767",
]);
await first.cleanup();
await second.cleanup();
+1 -4
View File
@@ -111,6 +111,7 @@ export type PluginClientRuntime = Pick<
| "openScreen"
| "openSurface"
| "openPanel"
| "subscribe"
| "addComposerPill"
| "addHeaderButton"
> & { hosts: ReturnType<typeof createPluginHosts> };
@@ -520,10 +521,6 @@ export function runPluginClientBundle(
useSettings,
openExternalUrl,
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
addHost: (input: Parameters<typeof runtime.hosts.addHost>[0]) =>
runtime.hosts.addHost(input),
observeHosts: (listener: Parameters<typeof runtime.hosts.observeHosts>[0]) =>
runtime.hosts.observeHosts(listener),
useHosts: () =>
React.useSyncExternalStore(
runtime.hosts.subscribe,
+7 -227
View File
@@ -1,66 +1,17 @@
import { describe, expect, it } from "vitest";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createPluginHosts, type PluginHostsSource } from "./index";
import { resolveDesktopDaemonServerId } from "@/desktop/daemon/desktop-daemon";
function registry() {
const hosts = [
{
serverId: "a",
label: "Alpha",
password: "secret",
appearance: { color: "none" as const, badgeDisplay: null },
connections: [
{ id: "one", type: "directTcp" as const, endpoint: "localhost:6767", useTls: false },
{ id: "two", type: "directSocket" as const, path: "/tmp/test" },
],
lifecycle: {},
preferredConnectionId: null,
createdAt: "today",
updatedAt: "today",
},
{
serverId: "b",
label: "Beta",
password: "secret",
appearance: { color: "none" as const, badgeDisplay: null },
connections: [],
lifecycle: {},
preferredConnectionId: null,
createdAt: "today",
updatedAt: "today",
},
{ serverId: "a", label: "Alpha", password: "secret" },
{ serverId: "b", label: "Beta", password: "secret" },
];
const snapshots = new Map<
string,
{ connectionStatus: "online" | "offline" | "connecting"; client: DaemonClient | null }
>();
const snapshots = new Map<string, NonNullable<ReturnType<PluginHostsSource["getSnapshot"]>>>();
const listeners = new Set<() => void>();
const source: PluginHostsSource = {
addHost: async () => ({ status: "cancelled" }),
getHosts: () => hosts,
getSnapshot: (id) => {
const value = snapshots.get(id);
return value
? { ...value, activeConnectionId: null, lastError: null, lastOnlineAt: null }
: null;
},
renameHost: async (id, label) => {
hosts.find((candidate) => candidate.serverId === id)!.label = label;
},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async (id) => {
const index = hosts.findIndex((host) => host.serverId === id);
if (index >= 0) hosts.splice(index, 1);
for (const l of listeners) l();
},
removeConnection: async (id, connectionId) => {
const host = hosts.find((candidate) => candidate.serverId === id)!;
host.connections = host.connections.filter((c) => c.id !== connectionId);
if (!host.connections.length) await source.removeHost(id);
else for (const l of listeners) l();
},
getSnapshot: (id) => snapshots.get(id) ?? null,
subscribeAll(listener) {
listeners.add(listener);
return () => {
@@ -75,10 +26,7 @@ function registry() {
},
};
const lifetime = new AbortController();
const runtime = createPluginHosts(source, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const runtime = createPluginHosts(source, lifetime.signal);
return {
hosts,
snapshots,
@@ -100,7 +48,7 @@ function connection(id: string) {
describe("plugin host access", () => {
it("publishes credential-free configured hosts and only changes snapshots when summaries change", () => {
const h = registry();
expect(h.runtime.getSnapshot()).toMatchObject([
expect(h.runtime.getSnapshot()).toEqual([
{ serverId: "a", label: "Alpha", status: "offline" },
{ serverId: "b", label: "Beta", status: "offline" },
]);
@@ -120,171 +68,6 @@ describe("plugin host access", () => {
expect(h.listeners.size).toBe(0);
});
it("keeps handles stable, protects credentials, and stops observations on unload", async () => {
const h = registry();
const seen: unknown[] = [];
const stop = h.runtime.observeHosts((hosts) => seen.push(hosts));
const first = h.runtime.getSnapshot()[0];
function walk(value: unknown): void {
if (typeof value === "string") expect(value).not.toContain("secret");
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
expect(key.toLowerCase()).not.toBe("password");
walk(child);
}
}
walk(h.runtime.getSnapshot());
h.hosts[0].label = "New name";
h.publish();
expect(h.runtime.getSnapshot()[0]).toBe(first);
expect(first.label).toBe("New name");
expect(seen).toHaveLength(2);
h.lifetime.abort();
h.publish();
expect(seen).toHaveLength(2);
await expect(first.remove()).rejects.toThrow("Plugin has stopped");
stop();
});
it("redacts saved passwords from mutation errors", async () => {
const h = registry();
h.source.renameHost = async () => {
throw new Error("Failed for secret");
};
await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow("secret");
h.lifetime.abort();
});
it("isolates a throwing observer from other observers and registry mutations", async () => {
const h = registry();
let calls = 0;
h.runtime.observeHosts(() => {
if (calls++ > 0) throw new Error("plugin listener failed");
});
let delivered = 0;
h.runtime.observeHosts(() => {
delivered++;
});
await expect(h.runtime.getSnapshot()[0].removeConnection("one")).resolves.toBeUndefined();
expect(delivered).toBe(2);
expect(h.runtime.getSnapshot()[0].connections).toHaveLength(1);
h.lifetime.abort();
});
it("refuses to remove the last connection of the desktop daemon", async () => {
const h = registry();
const lifetime = new AbortController();
const runtime = createPluginHosts(h.source, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async (id) => id === "a",
});
const host = runtime.getSnapshot()[0];
await host.removeConnection("one");
await expect(host.removeConnection("two")).rejects.toThrow("desktop daemon");
expect(host.connections).toHaveLength(1);
lifetime.abort();
h.lifetime.abort();
});
it("returns the projected handle after adding a host", async () => {
const h = registry();
h.source.addHost = async () => ({ status: "saved", serverId: "a" });
const result = await h.runtime.addHost({ connection: "tcp://example:6767?password=secret" });
expect(result.status).toBe("saved");
if (result.status !== "saved") throw new Error("expected saved host");
expect(result.host).toBe(h.runtime.getSnapshot()[0]);
expect(JSON.stringify(result)).not.toContain("secret");
h.lifetime.abort();
await expect(h.runtime.addHost({ connection: "example:6767" })).rejects.toThrow(
"Plugin has stopped",
);
});
it("passes cancellation and authentication results without exposing credentials", async () => {
const h = registry();
for (const result of [
{ status: "cancelled" as const },
{ status: "password_required" as const, reason: "incorrect_password" as const },
]) {
h.source.addHost = async () => result;
const returned = await h.runtime.addHost({
connection: "tcp://example:6767?password=secret",
});
expect(returned).toEqual(result);
for (const [key, value] of Object.entries(returned)) {
expect(key).not.toBe("password");
expect(value).not.toBe("secret");
}
}
h.lifetime.abort();
});
it("redacts encoded saved passwords in mutation errors", async () => {
const h = registry();
h.hosts[0].password = "two words";
h.source.renameHost = async () => {
throw new Error("failed for two%20words");
};
await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow(
"two%20words",
);
h.lifetime.abort();
});
it("never exposes supplied passwords in add errors", async () => {
const h = registry();
h.source.addHost = async () => {
throw new Error("secret was rejected");
};
await expect(
h.runtime.addHost({ connection: "tcp://example:6767?password=secret" }),
).rejects.not.toThrow("secret");
h.lifetime.abort();
});
it("removes a connection and then the last host connection", async () => {
const h = registry();
const host = h.runtime.getSnapshot()[0];
await host.removeConnection("one");
expect(host.connections.map((item) => item.id)).toEqual(["two"]);
await host.removeConnection("two");
expect(h.runtime.getSnapshot().map((entry) => entry.serverId)).toEqual(["b"]);
h.lifetime.abort();
});
it("rejects removal of the managed desktop daemon", async () => {
const h = registry();
const lifetime = new AbortController();
const runtime = createPluginHosts(h.source, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async (id) => id === "a",
});
await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon");
expect(runtime.getSnapshot()).toHaveLength(2);
lifetime.abort();
h.lifetime.abort();
});
it("protects the desktop host after its daemon has stopped", async () => {
const h = registry();
const lifetime = new AbortController();
const runtime = createPluginHosts(h.source, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async (id) =>
id ===
(await resolveDesktopDaemonServerId({
getStatus: async () => {
throw new Error("daemon stopped");
},
getSavedId: async () => "a",
})),
});
await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon");
expect(runtime.getSnapshot()).toHaveLength(2);
lifetime.abort();
h.lifetime.abort();
});
it("rejects unknown and disconnected targets without falling through to another host", () => {
const h = registry();
h.snapshots.set("a", { connectionStatus: "online", client: connection("a") });
@@ -299,10 +82,7 @@ describe("plugin host access", () => {
h.snapshots.set("b", { connectionStatus: "online", client });
h.publish();
const otherLifetime = new AbortController();
const other = createPluginHosts(h.source, otherLifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const other = createPluginHosts(h.source, otherLifetime.signal);
const api = h.runtime.getPaseoClient("b");
expect(api).toBe(h.runtime.getPaseoClient("b"));
expect(api).not.toBe(other.getPaseoClient("b"));
+17 -319
View File
@@ -1,288 +1,31 @@
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type {
PluginHost,
PluginHostConnection,
PluginHostPatch,
PluginHostAddInput,
PluginHostAddResult,
} from "@getpaseo/plugin/client";
import type { HostProfile } from "@/types/host-connection";
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
import {
parseHostPort,
serializeConnectionUri,
serializeRelayConnectionUri,
shouldUseTlsForDefaultHostedRelay,
} from "@getpaseo/protocol/daemon-endpoints";
import { parseHostTarget, serializeSocketHostTarget } from "@getpaseo/protocol/host-target";
import {
DEFAULT_SSH_DAEMON_PORT,
serializeSshTransportUri,
} from "@getpaseo/protocol/ssh-transport";
import type { PluginHostSummary } from "@getpaseo/plugin/client";
type HostSnapshot = Pick<
HostRuntimeSnapshot,
| "connectionStatus"
| "client"
| "activeConnectionId"
| "lastError"
| "authFailureReason"
| "lastOnlineAt"
>;
export interface PluginHostsSource {
addHost(
input: PluginHostAddInput,
signal?: AbortSignal,
): Promise<
| { status: "saved"; serverId: string }
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
| { status: "cancelled" }
>;
getHosts(): readonly HostProfile[];
getSnapshot(serverId: string): HostSnapshot | null;
getHosts(): readonly { serverId: string; label: string }[];
getSnapshot(serverId: string): {
connectionStatus: PluginHostSummary["status"];
client: DaemonClient | null;
} | null;
subscribeAll(listener: () => void): () => void;
subscribeHostList(listener: () => void): () => void;
renameHost(serverId: string, label: string): Promise<void>;
setHostColor(serverId: string, color: PluginHost["appearance"]["color"]): Promise<void>;
setHostBadgeDisplay(
serverId: string,
badge: PluginHost["appearance"]["badgeDisplay"],
): Promise<void>;
removeHost(serverId: string): Promise<void>;
removeConnection(serverId: string, connectionId: string): Promise<void>;
}
function redactSecret(message: string, secret: string | undefined): string {
if (!secret) return message;
for (const value of [
secret,
encodeURIComponent(secret),
new URLSearchParams({ password: secret }).toString().slice(9),
]) {
message = message.split(value).join("[redacted]");
}
return message;
}
type ProjectedHost = Omit<PluginHost, "update" | "remove" | "removeConnection" | "openSettings">;
function projectHost(profile: HostProfile, snapshot: HostSnapshot | null): ProjectedHost {
const connections: PluginHostConnection[] = profile.connections.map((connection) => {
switch (connection.type) {
case "directTcp":
return {
id: connection.id,
type: connection.type,
endpoint: connection.endpoint,
useTls: connection.useTls ?? false,
uri: serializeConnectionUri({
...parseHostPort(connection.endpoint),
useTls: connection.useTls ?? false,
}),
};
case "directSocket":
return {
id: connection.id,
type: connection.type,
path: connection.path,
uri: serializeSocketHostTarget("unix", connection.path),
};
case "directPipe":
return {
id: connection.id,
type: connection.type,
path: connection.path,
uri: serializeSocketHostTarget("pipe", connection.path),
};
case "remoteSsh":
return {
id: connection.id,
type: connection.type,
host: connection.host,
...(connection.sshPort === undefined ? {} : { sshPort: connection.sshPort }),
...(connection.daemonPort === undefined ? {} : { daemonPort: connection.daemonPort }),
uri: serializeSshTransportUri({
host: connection.host,
sshPort: connection.sshPort,
daemonPort: connection.daemonPort ?? DEFAULT_SSH_DAEMON_PORT,
}),
};
case "relay": {
const useTls =
connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint);
return {
id: connection.id,
type: connection.type,
relayEndpoint: connection.relayEndpoint,
useTls,
daemonPublicKeyB64: connection.daemonPublicKeyB64,
...(connection.minimumSecurityMode
? { minimumSecurityMode: connection.minimumSecurityMode }
: {}),
uri: serializeRelayConnectionUri({
offer: {
v: 2,
serverId: profile.serverId,
daemonPublicKeyB64: connection.daemonPublicKeyB64,
relay: { endpoint: connection.relayEndpoint, useTls },
...(connection.minimumSecurityMode
? { minimumSecurityMode: connection.minimumSecurityMode }
: {}),
},
}),
};
}
case "routed":
return {
id: connection.id,
type: connection.type,
originServerId: connection.originServerId,
uri: null,
};
}
});
const error = snapshot?.lastError ?? null;
return {
serverId: profile.serverId,
label: profile.label,
status: snapshot?.connectionStatus ?? "offline",
appearance: { color: profile.appearance.color, badgeDisplay: profile.appearance.badgeDisplay },
connections,
activeConnectionId: snapshot?.activeConnectionId ?? null,
error: error ? redactSecret(error, profile.password) : null,
authFailureReason: snapshot?.authFailureReason ?? null,
lastOnlineAt: snapshot?.lastOnlineAt ?? null,
createdAt: profile.createdAt,
updatedAt: profile.updatedAt,
};
}
/** Each evaluated installation owns its borrowed APIs and registry subscriptions. */
export function createPluginHosts(
source: PluginHostsSource,
signal: AbortSignal,
options: {
openSettings(serverId: string): void;
isManagedLocalHost(serverId: string): Promise<boolean>;
},
) {
export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal) {
const clients = new Map<
string,
{ client: DaemonClient; api: PaseoApi; lifetime: AbortController }
>();
const listeners = new Set<() => void>();
let snapshot: readonly PluginHost[] = [];
let serialized = "";
const entries = new Map<string, { data: ProjectedHost; handle: PluginHost }>();
function assertHost(id: string) {
if (signal.aborted) throw new Error("Plugin has stopped");
if (!source.getHosts().some((host) => host.serverId === id))
throw new Error(`Unknown Paseo host: ${id}`);
}
async function perform(id: string, action: () => Promise<void>): Promise<void> {
const secret = source.getHosts().find((host) => host.serverId === id)?.password;
try {
await action();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// A cause would expose the original error, including any password.
// oxlint-disable-next-line preserve-caught-error
throw new Error(redactSecret(message, secret));
}
}
function readHosts(): { hosts: PluginHost[]; key: string } {
const projected = source
.getHosts()
.map((profile) => projectHost(profile, source.getSnapshot(profile.serverId)));
const ids = new Set(projected.map((host) => host.serverId));
for (const id of entries.keys()) if (!ids.has(id)) entries.delete(id);
const hosts = projected.map((data) => {
let entry = entries.get(data.serverId);
if (!entry) {
const id = data.serverId;
const read = () => entry!.data;
const handle: PluginHost = {
get serverId() {
return read().serverId;
},
get label() {
return read().label;
},
get status() {
return read().status;
},
get appearance() {
return read().appearance;
},
get connections() {
return read().connections;
},
get activeConnectionId() {
return read().activeConnectionId;
},
get error() {
return read().error;
},
get authFailureReason() {
return read().authFailureReason;
},
get lastOnlineAt() {
return read().lastOnlineAt;
},
get createdAt() {
return read().createdAt;
},
get updatedAt() {
return read().updatedAt;
},
async update(patch: PluginHostPatch) {
await perform(id, async () => {
assertHost(id);
if (patch.label !== undefined) await source.renameHost(id, patch.label);
if (patch.appearance?.color !== undefined) {
assertHost(id);
await source.setHostColor(id, patch.appearance.color);
}
if (patch.appearance?.badgeDisplay !== undefined) {
assertHost(id);
await source.setHostBadgeDisplay(id, patch.appearance.badgeDisplay);
}
});
},
async remove() {
await perform(id, async () => {
assertHost(id);
if (await options.isManagedLocalHost(id))
throw new Error("Cannot remove the desktop daemon host");
assertHost(id);
await source.removeHost(id);
});
},
async removeConnection(connectionId) {
await perform(id, async () => {
assertHost(id);
const isManaged = await options.isManagedLocalHost(id);
assertHost(id);
const host = source.getHosts().find((candidate) => candidate.serverId === id)!;
if (
isManaged &&
host.connections.length === 1 &&
host.connections[0].id === connectionId
) {
throw new Error("Cannot remove the desktop daemon host");
}
await source.removeConnection(id, connectionId);
});
},
openSettings() {
assertHost(id);
options.openSettings(id);
},
};
entry = { data, handle };
entries.set(id, entry);
} else entry.data = data;
return entry.handle;
});
return { hosts, key: JSON.stringify(projected) };
let snapshot: readonly PluginHostSummary[] = [];
function readHosts(): readonly PluginHostSummary[] {
return source.getHosts().map(({ serverId, label }) => ({
serverId,
label,
status: source.getSnapshot(serverId)?.connectionStatus ?? "offline",
}));
}
function resolve(serverId: string): DaemonClient {
if (signal.aborted) throw new Error("Plugin has stopped");
@@ -306,16 +49,11 @@ export function createPluginHosts(
}
}
const next = readHosts();
if (serialized === next.key) return;
serialized = next.key;
snapshot = next.hosts;
if (JSON.stringify(snapshot) === JSON.stringify(next)) return;
snapshot = next;
for (const listener of listeners) listener();
}
{
const initial = readHosts();
snapshot = initial.hosts;
serialized = initial.key;
}
snapshot = readHosts();
const unsubscribeRuntime = source.subscribeAll(refresh);
const unsubscribeHosts = source.subscribeHostList(refresh);
function stop() {
@@ -329,46 +67,6 @@ export function createPluginHosts(
else signal.addEventListener("abort", stop, { once: true });
return {
getSnapshot: () => snapshot,
async addHost(input: PluginHostAddInput): Promise<PluginHostAddResult> {
if (signal.aborted) throw new Error("Plugin has stopped");
let result: Awaited<ReturnType<PluginHostsSource["addHost"]>>;
try {
result = await source.addHost(input, signal);
} catch (error) {
let secret: string | undefined;
try {
secret = parseHostTarget(input.connection).password;
} catch {
/* Invalid input has no parsed password. */
}
const message = redactSecret(
error instanceof Error ? error.message : String(error),
secret,
);
// The original error can contain the supplied credential.
// oxlint-disable-next-line preserve-caught-error
throw new Error(message);
}
if (result.status !== "saved") return result;
const host = snapshot.find((item) => item.serverId === result.serverId);
if (!host) throw new Error(`Unknown Paseo host: ${result.serverId}`);
return { status: "saved", host };
},
observeHosts(listener: (hosts: readonly PluginHost[]) => void) {
if (signal.aborted) throw new Error("Plugin has stopped");
const notify = () => {
try {
listener(snapshot);
} catch (error) {
console.error("[Plugins] Host observer failed", error);
}
};
listeners.add(notify);
notify();
return () => {
listeners.delete(notify);
};
},
subscribe(listener: () => void) {
if (signal.aborted) return () => {};
listeners.add(listener);
@@ -29,6 +29,9 @@ function installation(invoked: string[]): InstalledPlugin {
invoked.push(method);
return { state: "green" };
},
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
queryClient: new QueryClient(),
cleanup: () => undefined,
settingsScreens: [],
@@ -18,7 +18,7 @@ export function PluginInstallationProvider({
return (
<QueryClientProvider client={plugin.queryClient}>
<PaseoApiProvider paseo={plugin.paseo}>
<PluginRpcProvider invoke={plugin.invoke}>
<PluginRpcProvider invoke={plugin.invoke} subscribe={plugin.subscribe}>
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
</PluginRpcProvider>
</PaseoApiProvider>
+1 -5
View File
@@ -1,4 +1,3 @@
import { buildSettingsHostRoute } from "@/utils/host-routes";
import { buildPluginSettingsRoute } from "./settings/routes";
import { router } from "expo-router";
import type { PluginPanelLocation } from "@getpaseo/plugin/client";
@@ -10,7 +9,7 @@ import type { PluginNavigation } from "./actions";
export function createPluginNavigation(input: {
serverId: string;
workspaceId: string | null;
}): PluginNavigation & { openHostSettings(serverId: string): void } {
}): PluginNavigation {
const { serverId, workspaceId } = input;
function placement(location: PluginPanelLocation) {
if (location !== "explorer") return undefined;
@@ -21,9 +20,6 @@ export function createPluginNavigation(input: {
return { mode: "pane" as const, paneId };
}
return {
openHostSettings(hostId) {
router.push(buildSettingsHostRoute(hostId));
},
openSettings(pluginId, screenId) {
router.push(buildPluginSettingsRoute(serverId, pluginId, screenId));
},
@@ -43,19 +43,12 @@ function registry(
return {
hosts: createPluginHosts(
{
addHost: async () => ({ status: "cancelled" }),
getHosts: () => [],
getSnapshot: () => null,
subscribeAll: () => () => {},
subscribeHostList: () => () => {},
renameHost: async () => {},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async () => {},
removeConnection: async () => {},
},
installation.lifetime.signal,
{ openSettings() {}, isManagedLocalHost: async () => false },
),
paseo: installation.paseo,
i18n: installation.i18n,
@@ -69,6 +62,9 @@ function registry(
openPanel: () => {},
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
};
},
});
@@ -18,19 +18,12 @@ function registry(version: string) {
return {
hosts: createPluginHosts(
{
addHost: async () => ({ status: "cancelled" }),
getHosts: () => [],
getSnapshot: () => null,
subscribeAll: () => () => {},
subscribeHostList: () => () => {},
renameHost: async () => {},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async () => {},
removeConnection: async () => {},
},
installation.lifetime.signal,
{ openSettings() {}, isManagedLocalHost: async () => false },
),
paseo: installation.paseo,
i18n: installation.i18n,
@@ -46,6 +39,9 @@ function registry(version: string) {
openPanel() {},
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
};
},
});
+8 -1
View File
@@ -8,6 +8,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
import { resolveAppVersion } from "@/utils/app-version";
import { createPluginClientRuntime } from "./client-runtime";
import { createPluginSubscribe } from "./subscriptions";
import { runPluginClientBundle } from "./evaluate";
import type { InstalledPlugin } from "./types";
@@ -89,12 +90,18 @@ export class PluginRegistry {
return [existing];
}
const client = options.client;
const lifetime = new AbortController();
installation = {
lifetime: new AbortController(),
lifetime,
locale: this.dependencies.localeSource.getLocale(),
i18n: new PluginTranslationRuntime(this.dependencies.localeSource),
paseo: createPaseoApi(client),
invoke: (method, input) => client.invokePluginRpc(entry.id, method, input),
subscribe: createPluginSubscribe({
client,
pluginId: entry.id,
lifetime: lifetime.signal,
}),
id: entry.id,
serverId,
clientBundle: entry.clientBundle,
@@ -26,6 +26,9 @@ function installed(
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
settingsScreens: [],
surfaces: [{ id: "surface", title: "Surface", Component: () => null }],
sidebarItems: {
@@ -0,0 +1,189 @@
import { defineSubscription } from "@getpaseo/plugin";
import type { PluginSubscriptionClosed } from "@getpaseo/plugin/client";
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { SubscriptionObserver } from "@getpaseo/client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { expect, it } from "vitest";
import { z } from "zod";
import { createPluginSubscribe } from "./subscriptions";
const changes = defineSubscription({
name: "diff.watch",
input: z.object({ ref: z.string() }),
event: z.union([z.instanceof(Uint8Array), z.object({ path: z.string() })]),
});
type Observed = Parameters<DaemonClient["observePluginRpc"]>;
/** One host-side subscription as the daemon client exposes it, driven by the test. */
class HostSubscription {
readonly released: Promise<void>;
isReleased = false;
private observer: SubscriptionObserver<never> | null = null;
private accept!: () => void;
private refuse!: (error: Error) => void;
private markReleased!: () => void;
readonly ready = new Promise<never>((resolve, reject) => {
this.accept = () => resolve({ requestId: "r", subscriptionId: "s" } as never);
this.refuse = reject;
});
constructor(
readonly method: string,
readonly input: unknown,
readonly signal: AbortSignal | undefined,
) {
void this.ready.catch(() => undefined);
this.released = new Promise((resolve) => {
this.markReleased = resolve;
});
signal?.addEventListener("abort", () => void this.release(), { once: true });
}
handle() {
return {
subscriptionId: "s",
ready: this.ready,
subscribe: (observer: SubscriptionObserver<never>) => {
this.observer = observer;
return () => undefined;
},
release: () => this.release(),
};
}
acknowledge() {
this.accept();
}
refuseWith(error: Error) {
this.refuse(error);
this.observer?.error?.(error);
}
event(event: unknown) {
this.observer?.update({
type: "plugin.rpc.event",
payload: { subscriptionId: "s", event },
} as SessionOutboundMessage);
}
bytes(payload: Uint8Array) {
this.observer?.binary?.(payload);
}
closed(error?: string) {
this.observer?.update({
type: "plugin.rpc.closed",
payload: { subscriptionId: "s", ...(error ? { error } : {}) },
} as SessionOutboundMessage);
}
async release() {
this.isReleased = true;
this.markReleased();
}
}
/** An installation's subscribe on a host the test drives, and what one subscriber observed. */
function installation() {
const subscriptions: HostSubscription[] = [];
const client = {
observePluginRpc: (...[pluginId, method, input, options]: Observed) => {
expect(pluginId).toBe("diffs");
const subscription = new HostSubscription(method, input, options?.signal);
subscriptions.push(subscription);
return subscription.handle() as unknown as ReturnType<DaemonClient["observePluginRpc"]>;
},
};
const lifetime = new AbortController();
const subscribe = createPluginSubscribe({ client, pluginId: "diffs", lifetime: lifetime.signal });
function open(ref: string, signal?: AbortSignal) {
const received: string[] = [];
const closed: PluginSubscriptionClosed[] = [];
const handle = subscribePluginRpc(
changes,
subscribe,
{ ref },
(event) =>
received.push(event instanceof Uint8Array ? `bytes:${[...event]}` : `path:${event.path}`),
{ signal, onClosed: (result) => closed.push(result) },
);
return { handle, received, closed, host: subscriptions[subscriptions.length - 1]! };
}
return { open, lifetime };
}
it("delivers JSON and byte events in order and releases the host subscription", async () => {
const { open } = installation();
const { handle, received, closed, host } = open("main");
expect(host.method).toBe("diff.watch");
expect(host.input).toEqual({ ref: "main" });
host.acknowledge();
await handle.ready;
host.event({ path: "a.ts" });
host.bytes(new Uint8Array([1, 2]));
expect(received).toEqual(["path:a.ts", "bytes:1,2"]);
handle();
await host.released;
expect(closed).toEqual([]);
});
it("reports a refusal, a failure after going live, and a clean close", async () => {
const { open } = installation();
const refused = open("a");
refused.host.refuseWith(new Error("no such ref"));
await expect(refused.handle.ready).rejects.toThrow("no such ref");
expect(refused.closed).toEqual([{ error: new Error("no such ref") }]);
const failed = open("b");
failed.host.acknowledge();
await failed.handle.ready;
failed.host.closed("Plugin stopped: diffs");
expect(failed.closed).toEqual([{ error: new Error("Plugin stopped: diffs") }]);
await failed.host.released;
// A handler that returns at once closes in the same tick the host accepts it.
const finished = open("c");
finished.host.acknowledge();
finished.host.closed();
await expect(finished.handle.ready).resolves.toBeUndefined();
expect(finished.closed).toEqual([{}]);
});
it("closes a subscription whose event fails the contract and drops what follows", async () => {
const { open } = installation();
const { handle, received, closed, host } = open("a");
host.acknowledge();
await handle.ready;
host.event({ path: 3 });
host.event({ path: "late.ts" });
expect(received).toEqual([]);
expect(closed).toEqual([
{ error: new Error("Plugin subscription diff.watch sent an invalid event") },
]);
await host.released;
});
it("releases on its signal, on its remover, and when the installation stops", async () => {
const { open, lifetime } = installation();
const controller = new AbortController();
const bySignal = open("a", controller.signal);
bySignal.host.acknowledge();
await bySignal.handle.ready;
controller.abort();
await bySignal.host.released;
const byRemover = open("b");
byRemover.handle();
await byRemover.host.released;
const byLifetime = open("c");
byLifetime.host.event({ path: "a.ts" });
expect(byLifetime.received).toEqual(["path:a.ts"]);
lifetime.abort();
await byLifetime.host.released;
});
+52
View File
@@ -0,0 +1,52 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginSubscribe } from "@getpaseo/plugin/client/host";
/**
* Plugin subscriptions on the installation's client. The client re-subscribes after a reconnect;
* the installation's lifetime releases whatever its surfaces left open.
*/
export function createPluginSubscribe(input: {
client: Pick<DaemonClient, "observePluginRpc">;
pluginId: string;
lifetime: AbortSignal;
}): PluginSubscribe {
const { client, pluginId, lifetime } = input;
return (method, subscriptionInput, observer) => {
const controller = new AbortController();
const abort = () => controller.abort();
lifetime.addEventListener("abort", abort, { once: true });
controller.signal.addEventListener(
"abort",
() => lifetime.removeEventListener("abort", abort),
{ once: true },
);
if (lifetime.aborted) controller.abort();
const subscription = client.observePluginRpc(pluginId, method, subscriptionInput, {
signal: controller.signal,
});
subscription.subscribe({
snapshot: () => undefined,
update: (message) => {
if (message.type === "plugin.rpc.event") observer.event(message.payload.event);
else if (message.type === "plugin.rpc.closed") {
controller.abort();
const error = message.payload.error;
observer.closed(error === undefined ? undefined : new Error(error));
}
},
binary: (payload) => observer.event(payload),
error: (error) => {
controller.abort();
observer.closed(error instanceof Error ? error : new Error(String(error)));
},
});
const release = () => {
controller.abort();
return subscription.release();
};
return Object.assign(() => void release().catch(() => undefined), {
ready: subscription.ready.then(() => undefined),
release,
});
};
}
@@ -26,6 +26,9 @@ function installation(
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => undefined,
settingsScreens: [],
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })),
+3
View File
@@ -56,6 +56,9 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
settingsScreens: [],
surfaces: [],
sidebarItems: { header: [], footer: [] },
@@ -18,6 +18,9 @@ function plugin(input: {
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => {},
settingsScreens: [],
surfaces: [],
@@ -14,6 +14,45 @@ function thought(text: string, status: "loading" | "ready" = "loading"): StreamI
}
describe("plugin timeline projection", () => {
it.each(["message", "finished", "errored", "permission-required", "closed"] as const)(
"preserves immutable agent-message provenance for %s cards",
(event) => {
const agentMessage = {
event,
sender: { id: "remote::reviewer", title: "QA question-timing #7014/#7000/#6809" },
text: "The original message, including **Markdown**.",
};
const source: StreamItem = {
kind: "tool_call",
id: "message-1",
timestamp: new Date("2026-01-01T00:00:00.000Z"),
payload: {
source: "agent",
data: {
provider: "codex",
callId: "delivery-1",
name: "agent_message",
status: "completed",
error: null,
detail: { type: "plain_text", text: agentMessage.text, icon: "bot" },
agentMessage,
},
},
};
const inspect: TimelineItemTransform = ({ item }) => {
expect(item.type).toBe("tool_call");
if (item.type !== "tool_call") throw new Error("Expected a tool call");
expect(item.agentMessage).toEqual(agentMessage);
expect(item.agentMessage).not.toBe(agentMessage);
expect(Object.isFrozen(item.agentMessage)).toBe(true);
expect(Object.isFrozen(item.agentMessage?.sender)).toBe(true);
return undefined;
};
expect(projectPluginTimelineItems([source], inspect)).toEqual([source]);
expect(Object.isFrozen(agentMessage.sender)).toBe(false);
},
);
it("does not expose mutable tool detail from stream state", () => {
const detail = { type: "read" as const, filePath: "/repo/original.ts" };
const source: StreamItem = {
@@ -22,13 +22,14 @@ function cloneAndFreeze<T>(value: T): T {
}
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
const { callId, name, status, error, detail, metadata } = data;
const { callId, name, status, error, detail, metadata, agentMessage } = data;
const base = {
type: "tool_call" as const,
callId,
name,
detail,
...(metadata ? { metadata } : {}),
...(agentMessage ? { agentMessage } : {}),
};
switch (status) {
case "running":
+2
View File
@@ -71,6 +71,8 @@ export interface InstalledPlugin extends EvaluatedPlugin {
paseo: PaseoApi;
/** Calls one of the plugin's server RPC methods on its host. */
invoke(method: string, input: unknown): Promise<unknown>;
/** Subscribes to one of the plugin's server subscriptions; released with the lifetime. */
subscribe: import("@getpaseo/plugin/client/host").PluginSubscribe;
serverId: string;
requirements?: PluginRequirements;
clientBundle: string;
@@ -15,6 +15,9 @@ function installed(): InstalledPlugin {
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => {},
settingsScreens: [],
surfaces: [],
+10 -24
View File
@@ -55,38 +55,24 @@ export class HostConfirmations {
}
/** Resolves true without asking when the link matches a saved host exactly. */
confirmLink(
offer: ConnectionOffer,
savedHosts: HostProfile[],
signal?: AbortSignal,
): Promise<boolean> {
confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise<boolean> {
const connection = relayConnectionFromOffer(offer);
const savedHost = savedHosts.find((host) => host.serverId === offer.serverId);
if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true);
if (signal?.aborted) return Promise.resolve(false);
return this.ask(
{
id: this.nextRequestId++,
serverId: offer.serverId,
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
relayEndpoint: connection.relayEndpoint,
kind: savedHost ? "changedConnection" : "newHost",
},
signal,
);
return this.ask({
id: this.nextRequestId++,
serverId: offer.serverId,
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
relayEndpoint: connection.relayEndpoint,
kind: savedHost ? "changedConnection" : "newHost",
});
}
private ask(request: HostConfirmationRequest, signal?: AbortSignal): Promise<boolean> {
private ask(request: HostConfirmationRequest): Promise<boolean> {
// One question at a time: a newer one cancels the unanswered one.
this.pending?.resolve(false);
return new Promise<boolean>((resolve) => {
const cancel = () => this.answer(request.id, false);
const finish = (approved: boolean) => {
signal?.removeEventListener("abort", cancel);
resolve(approved);
};
this.pending = { request, resolve: finish };
signal?.addEventListener("abort", cancel, { once: true });
this.pending = { request, resolve };
this.emit();
});
}
+1 -294
View File
@@ -16,7 +16,7 @@ import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { defaultHostAppearance, selectHostBadges } from "@/hosts/appearance";
import { defaultHostAppearance } from "@/hosts/appearance";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
@@ -33,7 +33,6 @@ import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
import { subscriptionFixture } from "./subscription-fixture";
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
import type { HostConfirmationRequest } from "./host-confirmation";
import { createPluginHosts } from "@/plugins/hosts";
it("requests the managed connection credential through desktop main without a web hint", async () => {
const requests: string[] = [];
@@ -4779,295 +4778,3 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
expect(store.getHosts()).toHaveLength(0);
});
});
describe("adding hosts from plugin connection targets", () => {
it("does not save an unauthenticated target and retries with the password", async () => {
const store = createPairingStore({ password: "secret", serverIdForHost: () => "srv_direct" });
const requests = answerHostConfirmations(store, true);
expect(await store.addHost({ connection: "example.com:6767" })).toEqual({
status: "password_required",
reason: "password_required",
});
expect(store.getHosts()).toHaveLength(0);
expect(
await store.addHost({ connection: "tcp://example.com:6767?password=secret", label: "Build" }),
).toEqual({ status: "saved", serverId: "srv_direct" });
expect(store.getHosts()[0]).toMatchObject({
label: "Build",
password: "secret",
connections: [{ type: "directTcp", endpoint: "example.com:6767" }],
});
expect(requests).toHaveLength(0);
await waitForHostOnline(store, "srv_direct");
store.syncHosts([]);
});
it("persists the supplied password and reconnects after the app restarts", async () => {
const storage = createMemoryHostRuntimeStorage({ "@paseo:e2e": "1" });
const first = createPairingStore({
storage,
password: "secret",
serverIdForHost: () => "srv_direct",
});
const lifetime = new AbortController();
const plugin = createPluginHosts(first, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const result = await plugin.addHost({ connection: "tcp://example.com:6767?password=secret" });
expect(result.status).toBe("saved");
expect(JSON.stringify(result)).not.toContain("secret");
const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]");
expect(persisted[0].password).toBe("secret");
lifetime.abort();
first.syncHosts([]);
const attempts: string[] = [];
const restarted = createPairingStore({
storage,
password: "secret",
connectAttempts: attempts,
serverIdForHost: () => "srv_direct",
});
await restarted.boot();
await waitForHostOnline(restarted, "srv_direct");
expect(attempts).toContain("direct:example.com:6767");
expect(restarted.getHosts()[0].password).toBe("secret");
const restartedLifetime = new AbortController();
const restartedPlugin = createPluginHosts(restarted, restartedLifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
expect(JSON.stringify(restartedPlugin.getSnapshot())).not.toContain("secret");
restartedLifetime.abort();
restarted.syncHosts([]);
});
it("asks before saving a relay target and respects rejection", async () => {
const uri = "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true";
const rejected = createPairingStore();
const requests = answerHostConfirmations(rejected, false);
expect(await rejected.addHost({ connection: uri })).toEqual({ status: "cancelled" });
expect(requests).toHaveLength(1);
expect(rejected.getHosts()).toHaveLength(0);
const accepted = createPairingStore();
const acceptedRequests = answerHostConfirmations(accepted, true);
expect(await accepted.addHost({ connection: uri, label: "Relay" })).toEqual({
status: "saved",
serverId: "srv_offer",
});
expect(acceptedRequests).toHaveLength(1);
expect(accepted.getHosts()[0]).toMatchObject({
label: "Relay",
connections: [{ type: "relay" }],
});
accepted.syncHosts([]);
});
it.each([
["ssh://deploy@build:2222?daemonPort=7777", "remoteSsh"],
["unix:///tmp/paseo.sock", "directSocket"],
["pipe://mypipe", "directPipe"],
["wss://example.com/ws", "directTcp"],
])("probes and saves %s", async (connection, type) => {
const store = createPairingStore({ serverIdForHost: () => "srv_target" });
expect(await store.addHost({ connection })).toEqual({
status: "saved",
serverId: "srv_target",
});
expect(store.getHosts()[0].connections[0].type).toBe(type);
store.syncHosts([]);
});
it("shares host changes between plugin handles, host settings, and sidebar badges", async () => {
const store = createPairingStore({
serverIdForHost: (host) => {
const connection = host.connections[0];
return connection?.type === "directTcp" ? connection.endpoint : "";
},
});
await store.addHost({ connection: "first:6767", label: "First" });
await store.addHost({ connection: "second:6767", label: "Second" });
await waitForHostOnline(store, "first:6767");
await waitForHostOnline(store, "second:6767");
const lifetime = new AbortController();
const plugin = createPluginHosts(store, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
expect(
plugin.getSnapshot().map((host) => [host.label, host.status, host.connections[0].uri]),
).toEqual([
["First", "online", "tcp://first:6767"],
["Second", "online", "tcp://second:6767"],
]);
await plugin.getSnapshot()[0].update({ label: "Build", appearance: { color: "sky" } });
expect(store.getHosts()[0]).toMatchObject({ label: "Build", appearance: { color: "sky" } });
expect(
selectHostBadges({ hosts: store.getHosts(), localServerId: null, enabled: true }).get(
"first:6767",
),
).toMatchObject({ label: "Build", color: "sky" });
lifetime.abort();
store.syncHosts([]);
});
it.each([
"tcp://example.com:6767?ssl=true",
"ssh://deploy@build:2222?daemonPort=7777",
"unix:///tmp/paseo.sock",
"relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true",
])("recreates the projected connection on another device: %s", async (connection) => {
const first = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" });
answerHostConfirmations(first, true);
const result = await first.addHost({ connection });
if (result.status !== "saved") throw new Error("expected saved host");
const lifetime = new AbortController();
const plugin = createPluginHosts(first, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const uri = plugin.getSnapshot().find((host) => host.serverId === result.serverId)
?.connections[0].uri;
if (!uri) throw new Error("expected connection URI");
const second = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" });
answerHostConfirmations(second, true);
expect(await second.addHost({ connection: uri })).toEqual({
status: "saved",
serverId: result.serverId,
});
expect(second.getHosts()[0].connections).toEqual(first.getHosts()[0].connections);
lifetime.abort();
first.syncHosts([]);
second.syncHosts([]);
});
it("does not save an in-flight addition after its plugin stops", async () => {
const started = createDeferred<void>();
const probe = createDeferred<{
client: DaemonClient;
serverId: string;
hostname: string | null;
}>();
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async () => {
started.resolve(undefined);
return probe.promise;
},
getClientId: async () => "test",
readInitialConnectionHint: () => null,
},
});
const lifetime = new AbortController();
const plugin = createPluginHosts(store, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const adding = plugin.addHost({ connection: "example.com:6767" });
await started.promise;
lifetime.abort();
probe.resolve({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: "srv",
hostname: null,
});
await expect(adding).rejects.toThrow("Plugin has stopped");
expect(store.getHosts()).toHaveLength(0);
store.syncHosts([]);
});
it("reports a stopped plugin when an aborted probe rejects authentication", async () => {
const started = createDeferred<void>();
const probe = createDeferred<{
client: DaemonClient;
serverId: string;
hostname: string | null;
}>();
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async () => {
started.resolve(undefined);
return probe.promise;
},
getClientId: async () => "test",
readInitialConnectionHint: () => null,
},
});
const lifetime = new AbortController();
const plugin = createPluginHosts(store, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const adding = plugin.addHost({ connection: "example.com:6767" });
await started.promise;
lifetime.abort();
probe.reject(new DaemonAuthenticationError("password_required"));
await expect(adding).rejects.toThrow("Plugin has stopped");
expect(store.getHosts()).toHaveLength(0);
store.syncHosts([]);
});
it("dismisses an unanswered confirmation when the plugin stops", async () => {
const store = createPairingStore();
const lifetime = new AbortController();
const plugin = createPluginHosts(store, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const pending = createDeferred<void>();
const unsubscribe = store.subscribeHostConfirmation(() => {
if (store.getPendingHostConfirmation()) pending.resolve(undefined);
});
const adding = plugin.addHost({
connection: "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true",
});
await pending.promise;
lifetime.abort();
await expect(adding).rejects.toThrow("Plugin has stopped");
expect(store.getPendingHostConfirmation()).toBeNull();
expect(store.getHosts()).toHaveLength(0);
unsubscribe();
store.syncHosts([]);
});
it("returns incorrect_password without saving and redacts failed probe errors", async () => {
let failure: "auth" | "network" = "auth";
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async () => {
if (failure === "auth") throw new DaemonAuthenticationError("incorrect_password");
throw new Error("connection failed for secret");
},
getClientId: async () => "test",
readInitialConnectionHint: () => null,
},
});
expect(await store.addHost({ connection: "tcp://example.com:6767?password=secret" })).toEqual({
status: "password_required",
reason: "incorrect_password",
});
expect(store.getHosts()).toHaveLength(0);
failure = "network";
await expect(
store.addHost({ connection: "tcp://example.com:6767?password=secret" }),
).rejects.not.toThrow("secret");
expect(store.getHosts()).toHaveLength(0);
store.syncHosts([]);
});
it("rejects websocket paths and query options the app cannot represent", async () => {
const store = createPairingStore();
await expect(store.addHost({ connection: "wss://example.com/custom" })).rejects.toThrow(
"unsupported",
);
await expect(
store.addHost({ connection: "wss://example.com/ws?token=secret" }),
).rejects.toThrow("unsupported");
expect(store.getHosts()).toHaveLength(0);
store.syncHosts([]);
});
});
+5 -123
View File
@@ -3,7 +3,6 @@ import { watchdog } from "@/watchdog";
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
import type { OwnedSubscription } from "@getpaseo/client";
import type { HostDirectory } from "@getpaseo/protocol/hosts";
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
import { useSyncExternalStore, useMemo } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import equal from "fast-deep-equal/es6";
@@ -1613,48 +1612,6 @@ interface AgentDirectoryRefreshInput {
page?: FetchAgentsOptions["page"];
}
function directHostTarget(target: HostTarget, input: string): HostConnection {
let connection: HostConnection;
if (target.kind === "ssh") {
connection = createRemoteSshHostConnection(target.ssh);
} else {
if (/^wss?:\/\//.test(input.trim())) {
const url = new URL(target.url);
if (url.pathname !== "/ws" || url.search)
throw new Error("WebSocket path or query is unsupported by the app");
}
switch (target.connection.type) {
case "directTcp": {
const endpoint = normalizeHostPort(target.connection.endpoint);
connection = {
id: `direct:${endpoint}`,
type: "directTcp",
endpoint,
useTls: target.connection.useTls,
};
break;
}
case "directSocket":
connection = {
id: `socket:${target.connection.path}`,
type: "directSocket",
path: target.connection.path,
};
break;
case "directPipe":
connection = {
id: `pipe:${target.connection.path}`,
type: "directPipe",
path: target.connection.path,
};
break;
case "relay":
throw new Error("Unexpected relay target");
}
}
return connection;
}
export class HostRuntimeStore {
private controllers = new Map<string, HostRuntimeController>();
private hostObservations = new Map<
@@ -2007,66 +1964,6 @@ export class HostRuntimeStore {
);
}
/** Add a saved host using the shared connection grammar. */
async addHost(
input: {
connection: string;
label?: string;
},
signal?: AbortSignal,
): Promise<
| { status: "saved"; serverId: string }
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
| { status: "cancelled" }
> {
const target = parseHostTarget(input.connection);
await this.loadRegistry();
try {
if (signal?.aborted) throw new Error("Plugin has stopped");
if (target.kind === "socket" && target.connection.type === "relay") {
const approved = await this.confirmLink(target.connection.offer, signal);
if (signal?.aborted) throw new Error("Plugin has stopped");
if (!approved) return { status: "cancelled" };
const result = await this.probeAndUpsertOffer(
target.connection.offer,
target.password,
input.label,
signal,
);
return { status: "saved", serverId: result.serverId };
}
const connection = directHostTarget(target, input.connection);
const result = await this.probeAndUpsertConnection({
connection,
password: target.password,
label: input.label,
signal,
});
return { status: "saved", serverId: result.serverId };
} catch (error) {
if (signal?.aborted) {
// The failed probe can include a credential; do not attach it as a cause.
// oxlint-disable-next-line preserve-caught-error
throw new Error("Plugin has stopped");
}
const reason = getConnectionAuthFailureReason(error);
if (reason) return { status: "password_required", reason };
let message = error instanceof Error ? error.message : String(error);
if (target.password) {
for (const value of [
target.password,
encodeURIComponent(target.password),
new URLSearchParams({ password: target.password }).toString().slice(9),
]) {
if (value) message = message.split(value).join("[redacted]");
}
}
// The original error can contain a credential; do not expose it as a cause.
// oxlint-disable-next-line preserve-caught-error
throw new Error(message);
}
}
async upsertDirectConnection(input: {
serverId: string;
endpoint: string;
@@ -2096,7 +1993,6 @@ export class HostRuntimeStore {
password?: string;
label?: string;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
if (input.connection.type === "relay") {
throw new Error("Cannot probe a relay connection without a server id.");
@@ -2117,10 +2013,6 @@ export class HostRuntimeStore {
connection: input.connection,
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
});
if (input.signal?.aborted) {
await client.close().catch(() => undefined);
throw new Error("Plugin has stopped");
}
const profile = await this.upsertHostConnection({
serverId,
label: input.label ?? hostname ?? undefined,
@@ -2170,9 +2062,9 @@ export class HostRuntimeStore {
// A pairing link (a URL, a QR code, or a pasted link) asks the user to
// confirm when it adds a new host or changes a saved host's key, relay, or
// TLS setting. The answer needs the saved hosts, so it waits for them to load.
private async confirmLink(offer: ConnectionOffer, signal?: AbortSignal): Promise<boolean> {
private async confirmLink(offer: ConnectionOffer): Promise<boolean> {
await this.loadRegistry();
return this.hostConfirmations.confirmLink(offer, this.hosts, signal);
return this.hostConfirmations.confirmLink(offer, this.hosts);
}
getPendingHostConfirmation(): HostConfirmationRequest | null {
@@ -2252,8 +2144,6 @@ export class HostRuntimeStore {
private async probeAndUpsertOffer(
offer: ConnectionOffer,
password: string | undefined,
label?: string,
signal?: AbortSignal,
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
const connection = relayConnectionFromOffer(offer);
const probeHost: HostProfile = {
@@ -2269,12 +2159,7 @@ export class HostRuntimeStore {
};
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
await client.close().catch(() => undefined);
if (signal?.aborted) throw new Error("Plugin has stopped");
const profile = await this.upsertConnectionFromOffer(
offer,
label ?? hostname ?? undefined,
password,
);
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, password);
return { profile, serverId: offer.serverId, hostname };
}
@@ -2336,10 +2221,7 @@ export class HostRuntimeStore {
}));
}
async setHostBadgeDisplay(
serverId: string,
badgeDisplay: HostBadgeDisplay | null,
): Promise<void> {
async setHostBadgeDisplay(serverId: string, badgeDisplay: HostBadgeDisplay): Promise<void> {
await this.updateHostAppearance(serverId, (host) => ({
...host,
appearance: { ...host.appearance, badgeDisplay },
@@ -3152,7 +3034,7 @@ export interface HostMutations {
beginLinkPairing: () => LinkPairing;
renameHost: (serverId: string, label: string) => Promise<void>;
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay | null) => Promise<void>;
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
removeHost: (serverId: string) => Promise<void>;
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
}
@@ -1,3 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import type {
CreateAgentRequestOptions,
CreateWorkspaceRequestOptions,
@@ -8,7 +9,6 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { router } from "expo-router";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { createNameId } from "mnemonic-id";
@@ -62,7 +62,6 @@ import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { toErrorMessage } from "@/utils/error-messages";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
import {
getHostProjectId,
getWorktreeSupportForHostProject,
@@ -1166,9 +1165,7 @@ export function NewWorkspaceScreen({
openAddProjectPicker(selectedServerId);
}, [openAddProjectPicker, selectedServerId]);
const handleAddHost = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const handleAddHost = useAddHost();
const openPicker = useCallback(() => {
setPickerOpen(true);
+8 -97
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAddHost } from "@/hosts/add-host";
import { useCallback, useMemo, useRef, useState } from "react";
import type { ComponentType, ReactNode } from "react";
import {
Alert,
@@ -74,10 +75,6 @@ import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window
import { confirmDialog } from "@/utils/confirm-dialog";
import { BackHeader } from "@/components/headers/back-header";
import { PageLayout } from "@/components/page-layout";
import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal";
import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { EditorSection } from "@/screens/settings/editor-section";
import { FilesSection } from "@/screens/settings/files-section";
@@ -1089,21 +1086,15 @@ function SettingsSidebar({
export interface SettingsScreenProps {
view: SettingsView;
openAddHostIntent?: string | null;
}
export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) {
export default function SettingsScreen({ view }: SettingsScreenProps) {
const router = useRouter();
const { t } = useTranslation();
const voiceAudioEngine = useVoiceAudioEngineOptional();
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isRemoteSshVisible, setIsRemoteSshVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
const lastOpenedAddHostIntentRef = useRef<string | null>(null);
const isDesktopApp = isElectronRuntime();
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
@@ -1164,50 +1155,10 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
}
}, [isPlaybackTestRunning, t, voiceAudioEngine]);
const closeAddConnectionFlow = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(false);
setIsRemoteSshVisible(false);
setIsPasteLinkVisible(false);
}, []);
const goBackToAddConnectionMethods = useCallback(() => {
setIsDirectHostVisible(false);
setIsRemoteSshVisible(false);
setIsPasteLinkVisible(false);
setIsAddHostMethodVisible(true);
}, []);
const handleAddHost = useCallback(() => {
setIsAddHostMethodVisible(true);
}, []);
useEffect(() => {
if (!openAddHostIntent || lastOpenedAddHostIntentRef.current === openAddHostIntent) {
return;
}
lastOpenedAddHostIntentRef.current = openAddHostIntent;
handleAddHost();
}, [handleAddHost, openAddHostIntent]);
const handleSelectDirectConnection = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(true);
}, []);
const handleSelectRemoteSsh = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsRemoteSshVisible(true);
}, []);
const handleSelectPasteLink = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsPasteLinkVisible(true);
}, []);
const handleHostAdded = useCallback(
({ serverId }: { serverId: string }) => {
const target = buildSettingsHostSectionRoute(serverId, "connections");
(serverId: string) => {
selectSettingsHost(serverId);
const target = buildSettingsHostSectionRoute(serverId, "host");
if (isCompactLayout) {
router.push(target);
} else {
@@ -1217,6 +1168,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
[isCompactLayout, router],
);
const handleAddHost = useAddHost({ onAdded: handleHostAdded });
const handleSelectSection = useCallback(
(section: SettingsSectionSlug) => {
const target = buildSettingsSectionRoute(section);
@@ -1272,14 +1225,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
[activeHostServerId, handleAddHost, isCompactLayout, router],
);
const handleScanQr = useCallback(() => {
closeAddConnectionFlow();
router.push({
pathname: "/pair-scan",
params: { source: "settings" },
});
}, [closeAddConnectionFlow, router]);
const handleHostRemoved = useCallback(() => {
const fallback = buildSettingsSectionRoute("general");
if (isCompactLayout) {
@@ -1388,37 +1333,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
);
}
const addHostModals = (
<>
<AddHostMethodModal
visible={isAddHostMethodVisible}
onClose={closeAddConnectionFlow}
onDirectConnection={handleSelectDirectConnection}
onRemoteSsh={handleSelectRemoteSsh}
onPasteLink={handleSelectPasteLink}
onScanQr={handleScanQr}
/>
<AddHostModal
visible={isDirectHostVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
<AddRemoteSshHostModal
visible={isRemoteSshVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
<PairLinkModal
visible={isPasteLinkVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
</>
);
// Mobile root: full-screen sidebar-as-list.
if (isCompactLayout && view.kind === "root") {
return (
@@ -1436,7 +1350,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
layout="mobile"
/>
</ScrollView>
{addHostModals}
</View>
);
}
@@ -1447,7 +1360,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
<PageLayout title={detailTitle} onBack={handleBackFromDetail}>
{content}
</PageLayout>
{addHostModals}
</View>
);
}
@@ -1482,7 +1394,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
</View>
</WindowChromeRegion>
</View>
{addHostModals}
</View>
);
}
@@ -8,7 +8,6 @@ import {
buildOpenProjectRoute,
resolveKnownHostRoute,
buildSessionsRoute,
buildSettingsAddHostRoute,
buildProjectSettingsRoute,
buildProjectsSettingsRoute,
decodeFilePathFromPathSegment,
@@ -185,14 +184,6 @@ describe("workspace route parsing", () => {
});
describe("projects settings routes", () => {
it("buildSettingsAddHostRoute opens settings with the add-host flag", () => {
expect(buildSettingsAddHostRoute()).toBe("/settings/general?addHost=1");
});
it("buildSettingsAddHostRoute accepts a repeatable intent id", () => {
expect(buildSettingsAddHostRoute("retry 1")).toBe("/settings/general?addHost=retry%201");
});
it("buildProjectsSettingsRoute scopes the list to a host", () => {
expect(buildProjectsSettingsRoute("host a")).toBe("/settings/hosts/host%20a/projects");
});
-4
View File
@@ -553,10 +553,6 @@ export function buildSettingsSectionRoute(section: SettingsSectionSlug) {
return `/settings/${section}` as const;
}
export function buildSettingsAddHostRoute(intentId: string | number = "1") {
return `/settings/general?addHost=${encodeURIComponent(String(intentId))}` as const;
}
export function buildSettingsHostRoute(serverId: string) {
const normalized = trimNonEmpty(serverId);
if (!normalized) {
+1
View File
@@ -144,6 +144,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = {
[CLIENT_CAPS.pluginTimelineItems]: true,
[CLIENT_CAPS.workspaceSetupBlocked]: true,
[CLIENT_CAPS.workspaceSetupObservation]: true,
[CLIENT_CAPS.pluginBinaryPayloads]: true,
[CLIENT_CAPS.explicitEventSubscriptions]: true,
} satisfies Record<Exclude<ClientCapability, typeof CLIENT_CAPS.browserHost>, true>;
@@ -83,6 +83,26 @@ test("cancellation before the ID waits for bootstrap and releases exactly that I
expect(c.released).toEqual(["cancelled"]);
});
test("a release right after the host accepts still reports the subscription as accepted", async () => {
const c = connection();
const handle = c.observe();
c.requests[0]!("closing");
const released = handle.release();
await expect(handle.ready).resolves.toMatchObject({ subscriptionId: "closing" });
await released;
expect(c.released).toEqual(["closing"]);
});
test("a release before the host accepts rejects ready", async () => {
const c = connection();
const handle = c.observe();
const released = handle.release();
await expect(handle.ready).rejects.toThrow("Subscription released");
c.requests[0]!("late");
await released;
expect(c.released).toEqual(["late"]);
});
test("surviving handles use new remote IDs after reconnect and released handles stay gone", async () => {
const c = connection();
const a = c.observe();
+32 -14
View File
@@ -5,6 +5,8 @@ export type SubscriptionSnapshot<T> = T & { subscriptionId: string };
export interface SubscriptionObserver<T> {
snapshot(snapshot: T): void;
update(message: SessionOutboundMessage): void;
/** A binary frame the host addressed to this subscription; the payload is a view. */
binary?(payload: Uint8Array): void;
error?(error: unknown): void;
}
@@ -20,9 +22,12 @@ interface Registration {
start(): void;
disconnected(): void;
receive(message: SessionOutboundMessage): void;
receiveBinary(payload: Uint8Array): void;
release(): Promise<void>;
}
type Update = { message: SessionOutboundMessage } | { binary: Uint8Array };
export interface OwnedSubscriptionsHost {
release(subscriptionId: string): Promise<void>;
failed(error: unknown): void;
@@ -50,7 +55,7 @@ export class OwnedSubscriptions {
let teardown: Promise<void> | null = null;
let disposed = false;
let overflow = false;
const buffered: SessionOutboundMessage[] = [];
const buffered: Update[] = [];
const observers = new Set<SubscriptionObserver<SubscriptionSnapshot<T>>>();
let resolve!: (snapshot: SubscriptionSnapshot<T>) => void;
let reject!: (error: unknown) => void;
@@ -60,12 +65,10 @@ export class OwnedSubscriptions {
});
void ready.catch(() => {});
const deliver = (
observer: SubscriptionObserver<SubscriptionSnapshot<T>>,
message?: SessionOutboundMessage,
) => {
const deliver = (observer: SubscriptionObserver<SubscriptionSnapshot<T>>, update?: Update) => {
try {
if (message) observer.update(message);
if (update && "binary" in update) observer.binary?.(update.binary);
else if (update) observer.update(update.message);
else if (snapshot) observer.snapshot(snapshot);
} catch (error) {
this.host.failed(error);
@@ -78,6 +81,13 @@ export class OwnedSubscriptions {
this.routes.delete(released);
await this.host.release(released);
};
const buffer = (update: Update) => {
if (buffered.length < MAX_BOOTSTRAP_UPDATES && !overflow) buffered.push(update);
else {
buffered.length = 0;
overflow = true;
}
};
const registration: Registration = {
start: () => {
if (disposed || pending || id || !this.connected) return;
@@ -128,12 +138,14 @@ export class OwnedSubscriptions {
},
receive: (message) => {
if (disposed || ("payload" in message && Object.is(message.payload, snapshot))) return;
if (buffered.length < MAX_BOOTSTRAP_UPDATES && !overflow) buffered.push(message);
else {
buffered.length = 0;
overflow = true;
}
for (const observer of observers) deliver(observer, message);
buffer({ message });
for (const observer of observers) deliver(observer, { message });
},
receiveBinary: (payload) => {
if (disposed) return;
// Bytes wait only for the first observer; retaining them after would hold every frame.
if (observers.size === 0) buffer({ binary: payload });
for (const observer of observers) deliver(observer, { binary: payload });
},
release: () => {
if (teardown) return teardown;
@@ -142,7 +154,9 @@ export class OwnedSubscriptions {
observers.clear();
buffered.length = 0;
if (id) this.routes.delete(id);
reject(new Error("Subscription released"));
// A host that already accepted keeps `ready` resolved, even if released in the same tick.
if (snapshot) resolve(snapshot);
else reject(new Error("Subscription released"));
teardown = (async () => {
await pending;
await releaseId();
@@ -174,7 +188,7 @@ export class OwnedSubscriptions {
.catch(this.host.failed);
} else {
deliver(observer);
for (const message of buffered) deliver(observer, message);
for (const update of buffered) deliver(observer, update);
}
return () => {
observers.delete(observer);
@@ -205,6 +219,10 @@ export class OwnedSubscriptions {
this.routes.get(payload.subscriptionId)?.receive(message);
}
receiveBinary(subscriptionId: string, payload: Uint8Array): void {
this.routes.get(subscriptionId)?.receiveBinary(payload);
}
restore(): void {
this.connected = true;
for (const registration of this.registrations) registration.start();
@@ -1106,6 +1106,7 @@ test("advertises client capabilities in hello", async () => {
terminal_chunked_replay: true,
timeline_notifications: true,
plugin_timeline_items: true,
plugin_binary_payloads: true,
workspace_setup_blocked: true,
workspace_setup_observation: true,
hello_rejection: true,
+79 -9
View File
@@ -164,6 +164,11 @@ import {
FileTransferOpcode,
TerminalStreamOpcode,
type FileTransferFrame,
assertPluginWireValue,
decodePluginFrame,
encodePluginInputFrame,
isPluginFrameOpcode,
PluginFrameOpcode,
} from "@getpaseo/protocol/binary-frames/index";
import {
createRelayE2eeTransportFactory,
@@ -2260,6 +2265,8 @@ export class DaemonClient {
timeout?: number;
select: (msg: SessionOutboundMessage) => T | null;
options?: { skipQueue?: boolean };
/** Sends the request in another form, such as a binary frame; defaults to `message`. */
transmit?: () => Promise<void>;
}): Promise<T> {
const wire = this.owned.prepareRequest(params.message);
const timeout = params.timeout ?? DEFAULT_SESSION_RPC_TIMEOUT_MS;
@@ -2290,7 +2297,9 @@ export class DaemonClient {
// The response deadline still applies while the transport waits for drain.
void promise.catch(() => undefined);
try {
const sent = this.sendSessionMessageOrThrow(wire.message);
const sent = params.transmit
? params.transmit()
: this.sendSessionMessageOrThrow(wire.message);
const result = await Promise.race([promise, sent.then(() => promise)]);
if (result.kind === "error") throw result.error;
return result.value;
@@ -2313,12 +2322,14 @@ export class DaemonClient {
responseType: TResponseType;
options?: { skipQueue?: boolean };
selectPayload?: (payload: CorrelatedResponsePayload<TResponseType>) => TResult | null;
transmit?: () => Promise<void>;
}): Promise<TResult> {
return this.sendRequest({
requestId: params.requestId,
message: params.message,
timeout: params.timeout,
options: params.options,
...(params.transmit ? { transmit: params.transmit } : {}),
select: (msg) => {
const correlated = msg as CorrelatedResponseMessage;
if (correlated.type !== params.responseType) {
@@ -2650,6 +2661,7 @@ export class DaemonClient {
resetSource = false;
if (query.type === "workspace.setup.subscribe.request")
this.requireWorkspaceSetupObservation();
if (query.type === "plugin.rpc.subscribe.request") this.requirePluginSubscriptions();
const requestId = this.createRequestId(options?.requestId);
const request = SessionInboundMessageSchema.parse({ ...query, requestId });
try {
@@ -6197,22 +6209,59 @@ export class DaemonClient {
return payload.plugin;
}
/**
* A `Uint8Array` input or output crosses the wire as a binary frame. Any other value is JSON and
* must not contain a `Uint8Array`.
*/
async invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown> {
assertPluginWireValue(input, "input");
const requestId = this.createRequestId();
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "plugin.rpc.invoke.request",
if (!(input instanceof Uint8Array)) {
const payload = await this.sendCorrelatedSessionRequest({
requestId,
pluginId,
method,
input,
},
message: {
type: "plugin.rpc.invoke.request",
requestId,
pluginId,
method,
input,
},
responseType: "plugin.rpc.invoke.response",
});
return payload.output;
}
this.requirePluginSubscriptions();
const frame = encodePluginInputFrame({ requestId, pluginId, method, payload: input });
const payload = await this.sendCorrelatedRequest({
requestId,
message: { type: "plugin.rpc.invoke.request", requestId, pluginId, method, input: null },
responseType: "plugin.rpc.invoke.response",
options: { skipQueue: true },
transmit: () => this.sendBinaryFrame(frame),
});
return payload.output;
}
/** One subscription to a plugin subscription handler; events arrive as updates and binary. */
observePluginRpc(
pluginId: string,
method: string,
input: unknown,
options?: { signal?: AbortSignal },
): OwnedSubscription<CorrelatedResponsePayload<"plugin.rpc.subscribe.response">> {
assertPluginWireValue(input, "input");
return this.observe(
"plugin.rpc.subscribe.response",
{ type: "plugin.rpc.subscribe.request", pluginId, method, input },
options,
);
}
private requirePluginSubscriptions(): void {
if (this.lastServerInfoMessage?.features?.pluginSubscriptions !== true)
throw new Error("Update the host to use plugin subscriptions and binary plugin RPC");
}
async respondToPermissionAndWait(
agentId: string,
requestId: string,
@@ -6975,6 +7024,23 @@ export class DaemonClient {
}
}
private handlePluginFrame(rawBytes: Uint8Array): void {
const frame = decodePluginFrame(rawBytes);
this.consecutiveLivenessFailures = 0;
this.runtimeMetrics?.recordBinaryFrame("plugin", rawBytes.byteLength, 0);
if (frame?.opcode === PluginFrameOpcode.Event) {
this.owned.receiveBinary(frame.subscriptionId, frame.payload);
return;
}
if (frame?.opcode === PluginFrameOpcode.Output) {
// The binary form of plugin.rpc.invoke.response.
this.resolveWaiters({
type: "plugin.rpc.invoke.response",
payload: { requestId: frame.requestId, output: frame.payload },
});
}
}
private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean {
const hostFrame = decodeHostRouteFrame(rawBytes);
if (rawBytes[0] === HOST_ROUTE_OPCODE && !hostFrame) {
@@ -6986,6 +7052,10 @@ export class DaemonClient {
for (const handler of this.hostFrameListeners) handler(hostFrame);
return true;
}
if (isPluginFrameOpcode(rawBytes[0])) {
this.handlePluginFrame(rawBytes);
return true;
}
const fileFrame = decodeFileTransferFrame(rawBytes);
if (fileFrame) {
this.traceInstant("paseo.ws.message.inbound", {
+16
View File
@@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { chromium } from "playwright";
import { expect } from "@playwright/test";
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
@@ -198,6 +199,20 @@ async function waitForDesktopStatus(page) {
);
}
async function settleDesktopStartup(page) {
await expect(
page.getByRole("button", { name: "Desktop browser workspace 1", exact: true }),
).toBeVisible({ timeout: timeoutMs });
// The dev launcher supplies its package directory as an open-project request.
// Before the local host connects, that request opens the connection prompt.
// Finish startup before exercising the unrelated Settings/browser workflows.
const connectionPrompt = page.getByTestId("add-host-method-modal");
if (await connectionPrompt.isVisible()) {
await connectionPrompt.getByRole("button", { name: "Close", exact: true }).click();
await expect(connectionPrompt).toBeHidden();
}
}
async function startTargetPage() {
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
@@ -1208,6 +1223,7 @@ async function main() {
const page = await waitForAppPage(browser, expoPort);
await hideDeveloperToasts(page);
const status = await waitForDesktopStatus(page);
await settleDesktopStartup(page);
const checkPluginLinks = () =>
runPluginLinksRegression({
@@ -152,12 +152,6 @@ describe("daemon-manager commands", () => {
expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled();
});
it("reads the desktop daemon identity after it has stopped", async () => {
mkdirSync(mocks.paseoHome);
writeFileSync(path.join(mocks.paseoHome, "server-id"), "srv_existing\n");
expect(await createDaemonCommandHandlers().desktop_daemon_identity()).toBe("srv_existing");
});
it("reports an errored daemon when the local daemon state cannot be read", async () => {
mkdirSync(mocks.paseoHome);
writeFileSync(path.join(mocks.paseoHome, "paseo.pid"), "garbage");
@@ -476,14 +476,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
runningUnderARM64Translation: isRunningUnderARM64Translation(),
}),
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
desktop_daemon_identity: () => {
try {
return readFileSync(path.join(getPaseoHome(), "server-id"), "utf8").trim() || null;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
},
desktop_local_credential: async (args) => {
const instance = await readDaemonInstance(getPaseoHome());
if (!instance?.desktopManaged || typeof args?.listen !== "string") return null;
+65 -12
View File
@@ -92,18 +92,6 @@ function typeErrors(code: string): string[] {
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
}
describe("plugin SDK import boundaries", () => {
it("exposes host handles and object-shaped additions from the client entry", () => {
expect(
typeErrors(`
import { addHost, observeHosts, useHosts, type PluginHost } from "@getpaseo/plugin/client";
const hosts: readonly PluginHost[] = useHosts();
const stop = observeHosts((value) => { void value[0]?.openSettings(); });
void addHost({ connection: "localhost:6767", label: "Local" });
void hosts; stop();
`),
).toEqual([]);
});
it("publishes process capabilities without their Windows implementation helpers", () => {
expect(Object.keys(server).sort()).toEqual([
"defineAgentTool",
@@ -335,3 +323,68 @@ describe("agent session reload SDK boundary", () => {
).toEqual([]);
});
});
describe("plugin subscription SDK boundary", () => {
it("types contracts, overloaded handlers, the client hook, and the client entry handle", () => {
expect(
typeErrors(`
import { defineRpc, defineSubscription, type SubscriptionEvent, type SubscriptionInput } from "@getpaseo/plugin";
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { useRpc, useSubscription } from "@getpaseo/plugin/client";
import type { PluginServerContext, PluginSubscriptionContext } from "@getpaseo/plugin/server";
import { z } from "zod";
const diffWatch = defineSubscription({
name: "diff.watch",
input: z.object({ a: z.string(), b: z.string() }),
event: z.object({ path: z.string(), status: z.enum(["added", "modified", "deleted"]) }),
});
const frames = defineSubscription({
name: "screen.capture",
input: z.object({ display: z.number() }),
event: z.union([z.instanceof(Uint8Array), z.object({ type: z.literal("resized"), width: z.number() })]),
});
const thumbnail = defineRpc({ name: "thumbnail.render", input: z.object({ path: z.string() }), output: z.instanceof(Uint8Array) });
const input: SubscriptionInput<typeof diffWatch> = { a: "main", b: "HEAD" };
const event: SubscriptionEvent<typeof diffWatch> = { path: "a.ts", status: "added" };
function server(server: PluginServerContext) {
server.handle(diffWatch, ({ a, b }, { push, signal, paseo }: PluginSubscriptionContext<SubscriptionEvent<typeof diffWatch>>) => {
const pushed: Promise<void> = push({ path: a + b, status: "modified" });
// @ts-expect-error events follow the contract
void push({ path: 1, status: "modified" });
signal.addEventListener("abort", () => undefined, { once: true });
});
server.handle(frames, async (_input, { push }) => {
await push(new Uint8Array(4));
await push({ type: "resized", width: 2 });
});
server.handle(thumbnail, ({ path }) => new Uint8Array(path.length));
// @ts-expect-error subscription handlers receive push, not an output
server.handle(diffWatch, () => ({ path: "a", status: "added" as const }) as unknown as number);
}
function Panel() {
const state = useSubscription(diffWatch, { a: "main", b: "HEAD" }, (change) => {
const status: "added" | "modified" | "deleted" = change.status;
});
if (state.status === "error") state.error.message;
// @ts-expect-error the hook has no ready state, only live
if (state.status === "ready") return null;
useSubscription(frames, { display: 1 }, (event) => {
if (event instanceof Uint8Array) event.byteLength;
else event.width;
});
const render = useRpc(thumbnail);
void render({ path: "x" }).then((png: Uint8Array) => png.byteLength);
return null;
}
function client(client: PluginClientContext) {
const controller = new AbortController();
const remover = client.subscribe(diffWatch, { a: "main", b: "HEAD" }, (change) => change.path, { signal: controller.signal });
const ready: Promise<void> = remover.ready;
const released: Promise<void> = remover.release();
remover();
return () => {};
}
`),
).toEqual([]);
});
});
+18 -2
View File
@@ -4,7 +4,13 @@ import type { PaseoApi } from "@getpaseo/client";
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginFile } from "./file.js";
import type { PluginRpcContract } from "../rpc.js";
import type {
PluginRpcContract,
PluginSubscribeOptions,
PluginSubscriptionContract,
PluginSubscriptionHandle,
PluginWireInput,
} from "../rpc.js";
import type {
PluginButtonRegistration,
PluginHeaderButtonContribution,
@@ -203,6 +209,16 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
contribution: PluginTimelineRendererContribution<Schema>,
): PluginCleanup;
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
/**
* Subscribes to a plugin subscription on this client's host. Paseo re-subscribes after a
* reconnect and releases what is left when the plugin stops.
*/
subscribe<InputSchema extends ZodType, EventSchema extends ZodType>(
contract: PluginSubscriptionContract<InputSchema, EventSchema>,
input: PluginWireInput<ZodInput<InputSchema>>,
onEvent: (event: ZodOutput<EventSchema>) => void,
options?: PluginSubscribeOptions,
): PluginSubscriptionHandle;
}
export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
@@ -295,7 +311,7 @@ export interface PluginCommandCapabilities {
paseo: PaseoApi;
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
input: ZodInput<InputSchema>,
input: PluginWireInput<ZodInput<InputSchema>>,
): Promise<ZodOutput<OutputSchema>>;
openScreen(input: PluginOpenScreenInput): void;
/** @deprecated Use `openScreen`. */
+4 -2
View File
@@ -8,7 +8,7 @@ export {
import type { PluginAttachmentSourceContribution } from "../contracts.js";
import { PluginRpcProvider } from "./rpc-context.js";
import { PaseoApiProvider } from "./paseo-context.js";
import { callPluginRpc } from "../rpc.js";
import { callPluginRpc, subscribePluginRpc, type PluginSubscriptionObserver } from "../rpc.js";
export async function searchPluginAttachments(
source: PluginAttachmentSourceContribution<PluginText>,
@@ -19,7 +19,9 @@ export async function searchPluginAttachments(
return PluginAttachmentSearchPayloadSchema.parseAsync(output);
}
export { callPluginRpc, PaseoApiProvider, PluginRpcProvider };
export { callPluginRpc, subscribePluginRpc, PaseoApiProvider, PluginRpcProvider };
export type { PluginSubscriptionObserver };
export type { PluginSubscribe } from "./rpc-context.js";
export {
PluginTranslationRuntime,
-98
View File
@@ -1,98 +0,0 @@
import type { PluginHostSummary } from "./index.js";
export type PluginHostStatus = PluginHostSummary["status"];
export type PluginHostColor =
| "none"
| "violet"
| "sky"
| "emerald"
| "orange"
| "pink"
| "indigo"
| "teal"
| "red"
| "amber"
| "blue";
export type PluginHostBadgeDisplay = "name" | "icon" | "hidden";
export interface PluginHostAppearance {
readonly color: PluginHostColor;
/** Null uses the automatic local or remote default. */
readonly badgeDisplay: PluginHostBadgeDisplay | null;
}
/** Saved connection settings without credentials. Routed connections have no URI. */
export type PluginHostConnection =
| {
readonly id: string;
readonly type: "directTcp";
readonly uri: string;
readonly endpoint: string;
readonly useTls: boolean;
}
| {
readonly id: string;
readonly type: "directSocket";
readonly uri: string;
readonly path: string;
}
| {
readonly id: string;
readonly type: "directPipe";
readonly uri: string;
readonly path: string;
}
| {
readonly id: string;
readonly type: "remoteSsh";
readonly uri: string;
readonly host: string;
readonly sshPort?: number;
readonly daemonPort?: number;
}
| {
readonly id: string;
readonly type: "relay";
readonly uri: string;
readonly relayEndpoint: string;
readonly useTls: boolean;
readonly daemonPublicKeyB64: string;
readonly minimumSecurityMode?: "protected";
}
| {
readonly id: string;
readonly type: "routed";
readonly uri: null;
readonly originServerId: string;
};
export interface PluginHostPatch {
label?: string;
appearance?: Partial<PluginHostAppearance>;
}
export interface PluginHost extends PluginHostSummary {
readonly appearance: PluginHostAppearance;
readonly connections: readonly PluginHostConnection[];
readonly activeConnectionId: string | null;
readonly error: string | null;
readonly authFailureReason: "password_required" | "incorrect_password" | null;
readonly lastOnlineAt: string | null;
readonly createdAt: string;
readonly updatedAt: string;
update(patch: PluginHostPatch): Promise<void>;
/** Removes the host; throws for the desktop daemon host. */
remove(): Promise<void>;
/** Removing the last connection removes the host. */
removeConnection(connectionId: string): Promise<void>;
openSettings(): void;
}
export interface PluginHostAddInput {
/** Shared host connection grammar; may contain a password for the probe. */
connection: string;
label?: string;
}
export type PluginHostAddResult =
| { status: "saved"; host: PluginHost }
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
| { status: "cancelled" };
export declare function useHosts(): readonly PluginHost[];
export declare function observeHosts(listener: (hosts: readonly PluginHost[]) => void): () => void;
export declare function addHost(input: PluginHostAddInput): Promise<PluginHostAddResult>;
+7 -15
View File
@@ -71,7 +71,12 @@ export {
} from "./file.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useRpc } from "./rpc-context.js";
export { useRpc, useSubscription, type PluginSubscriptionState } from "./rpc-context.js";
export type {
PluginSubscribeOptions,
PluginSubscriptionClosed,
PluginSubscriptionHandle,
} from "../rpc.js";
import type { SettingsDefinition } from "../settings.js";
import type { SettingsState } from "./contracts.js";
import type { ZodType } from "zod";
@@ -86,20 +91,7 @@ export interface PluginHostSummary {
readonly status: "idle" | "connecting" | "online" | "offline" | "error";
}
/** Live configured hosts. Supplied by the app's client bundle loader. */
export declare const useHosts: typeof import("./hosts.js").useHosts;
export declare const observeHosts: typeof import("./hosts.js").observeHosts;
export declare const addHost: typeof import("./hosts.js").addHost;
export type {
PluginHost,
PluginHostStatus,
PluginHostColor,
PluginHostBadgeDisplay,
PluginHostAppearance,
PluginHostConnection,
PluginHostPatch,
PluginHostAddInput,
PluginHostAddResult,
} from "./hosts.js";
export declare function useHosts(): readonly PluginHostSummary[];
/** Borrow an online host's API under this installation's lifetime. */
export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi;
/** Open an absolute HTTP(S) URL using the client platform’s external opener. */
+108 -5
View File
@@ -1,9 +1,34 @@
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import { callPluginRpc, type PluginRpcContract } from "../rpc.js";
import {
callPluginRpc,
subscribePluginRpc,
type PluginRpcContract,
type PluginSubscriptionContract,
type PluginSubscriptionHandle,
type PluginSubscriptionObserver,
type PluginWireInput,
} from "../rpc.js";
import { shallowEqual } from "./shallow.js";
export type PluginSubscribe = (
method: string,
input: unknown,
observer: PluginSubscriptionObserver,
) => PluginSubscriptionHandle;
export interface PluginRpcContextValue {
invoke(method: string, input: unknown): Promise<unknown>;
subscribe: PluginSubscribe;
}
const PluginRpcContext = createContext<PluginRpcContextValue | null>(null);
@@ -15,22 +40,100 @@ export function usePluginRpcContextValue(): PluginRpcContextValue | null {
export function PluginRpcProvider({
children,
invoke,
subscribe,
}: {
children: ReactNode;
invoke(method: string, input: unknown): Promise<unknown>;
subscribe: PluginSubscribe;
}) {
const value = useMemo(() => ({ invoke }), [invoke]);
const value = useMemo(() => ({ invoke, subscribe }), [invoke, subscribe]);
return <PluginRpcContext.Provider value={value}>{children}</PluginRpcContext.Provider>;
}
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>> {
): (input: PluginWireInput<ZodInput<InputSchema>>) => Promise<ZodOutput<OutputSchema>> {
const context = usePluginRpcContextValue();
if (!context) throw new Error("useRpc must run inside a contributed plugin surface");
return useCallback(
(input: ZodInput<InputSchema>) => callPluginRpc(contract, context.invoke, input),
(input: PluginWireInput<ZodInput<InputSchema>>) =>
callPluginRpc(contract, context.invoke, input),
[context, contract],
);
}
export type PluginSubscriptionState =
| { status: "connecting" }
| { status: "live" }
/** The plugin's handler finished. The hook subscribes again when `input` changes. */
| { status: "closed" }
| { status: "error"; error: Error };
/**
* Subscribes while the component is mounted. Re-subscribes when `input` changes by shallow
* comparison; `onEvent` always runs the latest callback.
*/
export function useSubscription<InputSchema extends ZodType, EventSchema extends ZodType>(
contract: PluginSubscriptionContract<InputSchema, EventSchema>,
input: PluginWireInput<ZodInput<InputSchema>>,
onEvent: (event: ZodOutput<EventSchema>) => void,
): PluginSubscriptionState {
const context = usePluginRpcContextValue();
if (!context) throw new Error("useSubscription must run inside a contributed plugin surface");
const subscribe = context.subscribe;
const onEventRef = useRef(onEvent);
onEventRef.current = onEvent;
const stableInput = useShallowStable(input);
const [state, setState] = useState<PluginSubscriptionState>({ status: "connecting" });
useEffect(() => {
let active = true;
setState({ status: "connecting" });
let handle: PluginSubscriptionHandle;
try {
handle = subscribePluginRpc(
contract,
subscribe,
stableInput,
(event) => onEventRef.current(event),
{
onClosed: ({ error }) => {
if (active) setState(error ? { status: "error", error } : CLOSED);
},
},
);
} catch (error) {
setState({ status: "error", error: toError(error) });
return;
}
handle.ready.then(
() => {
if (active) setState((current) => (current.status === "connecting" ? LIVE : current));
return undefined;
},
(error: unknown) => {
if (active) setState({ status: "error", error: toError(error) });
},
);
return () => {
active = false;
handle();
};
}, [contract, subscribe, stableInput]);
return state;
}
const LIVE: PluginSubscriptionState = { status: "live" };
const CLOSED: PluginSubscriptionState = { status: "closed" };
function useShallowStable<Value>(value: Value): Value {
const ref = useRef(value);
if (!shallowEqual(ref.current, value)) ref.current = value;
return ref.current;
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
@@ -26,7 +26,7 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
);
return (
<PaseoApiProvider paseo={paseo}>
<PluginRpcProvider invoke={rpc.invoke}>
<PluginRpcProvider invoke={rpc.invoke} subscribe={rpc.subscribe}>
<PluginTranslationProvider runtime={translations}>{content}</PluginTranslationProvider>
</PluginRpcProvider>
</PaseoApiProvider>
+10 -1
View File
@@ -19,7 +19,16 @@ export {
type PluginAttachmentItem,
type PluginAttachmentSearchPayload,
} from "./attachments.js";
export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js";
export {
defineRpc,
defineSubscription,
type PluginRpcContract,
type PluginSubscriptionContract,
type RpcInput,
type RpcOutput,
type SubscriptionEvent,
type SubscriptionInput,
} from "./rpc.js";
export type { PluginText, PluginMessage } from "./text.js";
export {
+97 -1
View File
@@ -9,6 +9,12 @@ export interface PluginRpcContract<
output: OutputSchema;
}
/**
* `z.instanceof(Uint8Array)` infers `Uint8Array<ArrayBuffer>`; values crossing the wire accept any
* `Uint8Array`, including a `Buffer` or a view of a shared buffer.
*/
export type PluginWireInput<Value> = Value extends Uint8Array ? Uint8Array : Value;
export type RpcInput<Contract extends PluginRpcContract> = ZodOutput<Contract["input"]>;
export type RpcOutput<Contract extends PluginRpcContract> = ZodInput<Contract["output"]>;
@@ -31,9 +37,99 @@ export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodT
export async function callPluginRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
invoke: (method: string, input: unknown) => Promise<unknown>,
input: ZodInput<InputSchema>,
input: PluginWireInput<ZodInput<InputSchema>>,
): Promise<ZodOutput<OutputSchema>> {
const parsedInput = await contract.input.parseAsync(input);
const output = await invoke(contract.name, parsedInput);
return contract.output.parseAsync(output);
}
export interface PluginSubscriptionContract<
InputSchema extends ZodType = ZodType,
EventSchema extends ZodType = ZodType,
> {
name: string;
input: InputSchema;
event: EventSchema;
}
export type SubscriptionInput<Contract extends PluginSubscriptionContract> = ZodOutput<
Contract["input"]
>;
export type SubscriptionEvent<Contract extends PluginSubscriptionContract> = ZodOutput<
Contract["event"]
>;
interface SubscriptionDefinition<InputSchema extends ZodType, EventSchema extends ZodType> {
name: string;
input: InputSchema;
event: EventSchema;
}
export function defineSubscription<InputSchema extends ZodType, EventSchema extends ZodType>(
definition: SubscriptionDefinition<InputSchema, EventSchema>,
): PluginSubscriptionContract<InputSchema, EventSchema> {
const name = definition.name.trim();
if (!RPC_NAME.test(name)) throw new Error(`Invalid plugin subscription: ${definition.name}`);
return { ...definition, name };
}
/** One subscription from a client surface. Calling it or `release()` ends it. */
export type PluginSubscriptionHandle = (() => void) & {
/** Resolves once the plugin acknowledged the subscription; rejects if it refused. */
readonly ready: Promise<void>;
release(): Promise<void>;
};
export interface PluginSubscriptionObserver {
event(event: unknown): void;
closed(error?: Error): void;
}
export interface PluginSubscriptionClosed {
/** Set when the plugin's handler failed, the plugin stopped, or an event failed validation. */
error?: Error;
}
export interface PluginSubscribeOptions {
/** Aborting releases the subscription. */
signal?: AbortSignal;
/** Runs once when the daemon closes the subscription. Not called after a release. */
onClosed?: (closed: PluginSubscriptionClosed) => void;
}
export function subscribePluginRpc<InputSchema extends ZodType, EventSchema extends ZodType>(
contract: PluginSubscriptionContract<InputSchema, EventSchema>,
subscribe: (
method: string,
input: unknown,
observer: PluginSubscriptionObserver,
) => PluginSubscriptionHandle,
input: PluginWireInput<ZodInput<InputSchema>>,
onEvent: (event: ZodOutput<EventSchema>) => void,
options: PluginSubscribeOptions = {},
): PluginSubscriptionHandle {
// The plugin parses the input into the schema's output, so it receives the input as given.
contract.input.parse(input);
let closed = false;
function close(error?: Error) {
if (closed) return;
closed = true;
options.onClosed?.(error ? { error } : {});
}
const handle = subscribe(contract.name, input, {
event(event) {
if (closed) return;
const parsed = contract.event.safeParse(event);
if (parsed.success) return onEvent(parsed.data);
handle();
close(new Error(`Plugin subscription ${contract.name} sent an invalid event`));
},
closed: close,
});
if (options.signal) {
if (options.signal.aborted) handle();
else options.signal.addEventListener("abort", () => handle(), { once: true });
}
return handle;
}
+26 -2
View File
@@ -1,6 +1,6 @@
import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginRpcContract } from "../rpc.js";
import type { PluginRpcContract, PluginSubscriptionContract, PluginWireInput } from "../rpc.js";
import type { PluginCleanup } from "../contracts.js";
import type { ProviderRegistration } from "./provider.js";
import type { UsageSourceRegistration } from "./usage.js";
@@ -10,6 +10,18 @@ export interface PluginHandlerContext {
paseo: PaseoApi;
}
/** What a subscription handler receives beside its input. */
export interface PluginSubscriptionContext<Event = unknown> extends PluginHandlerContext {
/**
* Sends one event to this subscriber. A `Uint8Array` event travels as a binary frame; any other
* event is JSON and must not contain a `Uint8Array`. Resolves once the event left the plugin
* process. Events pushed after `signal` aborts are dropped.
*/
push(event: Event): Promise<void>;
/** Aborts when the subscriber releases, disconnects, or the plugin stops. */
signal: AbortSignal;
}
export type PluginSettingsState<Schema extends ZodType> =
| {
status: "ready";
@@ -31,12 +43,24 @@ export interface PluginServerContext extends PluginLifecycleRegistration {
registerSettings<Schema extends ZodType>(
definition: import("../settings.js").SettingsDefinition<Schema>,
): PluginSettings<Schema>;
/**
* Serves a subscription. The handler starts the work and returns; the subscription stays open
* until `signal` aborts. A handler that throws refuses the subscription. When it returns a
* promise, settling the promise closes the subscription, with the rejection as its error.
*/
handle<InputSchema extends ZodType, EventSchema extends ZodType>(
contract: PluginSubscriptionContract<InputSchema, EventSchema>,
handler: (
input: ZodOutput<InputSchema>,
context: PluginSubscriptionContext<PluginWireInput<ZodInput<EventSchema>>>,
) => void | Promise<void>,
): void;
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
handler: (
input: ZodOutput<InputSchema>,
context: PluginHandlerContext,
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
) => PluginWireInput<ZodInput<OutputSchema>> | Promise<PluginWireInput<ZodInput<OutputSchema>>>,
): void;
registerProvider(provider: ProviderRegistration): void;
registerUsageSource(source: UsageSourceRegistration): void;
+1
View File
@@ -11,6 +11,7 @@ export type {
PluginServerContribution,
PluginSettings,
PluginSettingsState,
PluginSubscriptionContext,
} from "./contracts.js";
export type {
PluginHooks,
+9 -1
View File
@@ -4,6 +4,7 @@ import {
FileTransferOpcode,
type FileTransferFrame,
} from "./file-transfer.js";
import { decodePluginFrame, PluginFrameOpcode, type PluginFrame } from "./plugin.js";
import {
decodeTerminalStreamFrame,
TerminalStreamOpcode,
@@ -13,7 +14,8 @@ import {
export type BinaryFrame =
| { kind: "host_route"; frame: HostRouteFrame }
| { kind: "terminal"; frame: TerminalStreamFrame }
| { kind: "file_transfer"; frame: FileTransferFrame };
| { kind: "file_transfer"; frame: FileTransferFrame }
| { kind: "plugin"; frame: PluginFrame };
export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
switch (bytes[0]) {
@@ -38,6 +40,12 @@ export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
const frame = decodeFileTransferFrame(bytes);
return frame ? { kind: "file_transfer", frame } : null;
}
case PluginFrameOpcode.Event:
case PluginFrameOpcode.Output:
case PluginFrameOpcode.Input: {
const frame = decodePluginFrame(bytes);
return frame ? { kind: "plugin", frame } : null;
}
default:
return null;
}
@@ -2,3 +2,4 @@ export * from "./demux.js";
export * from "./file-transfer.js";
export * from "./terminal.js";
export * from "./host-route.js";
export * from "./plugin.js";
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import {
assertPluginWireValue,
decodeBinaryFrame,
decodePluginFrame,
encodePluginEventFrame,
encodePluginInputFrame,
encodePluginOutputFrame,
FileTransferOpcode,
MAX_PLUGIN_PAYLOAD_BYTES,
MAX_ROUTED_PAYLOAD_BYTES,
PluginFrameOpcode,
TerminalStreamOpcode,
} from "./index.js";
describe("plugin binary frames", () => {
it("uses the reserved plugin opcode range", () => {
for (const opcode of Object.values(PluginFrameOpcode)) {
expect(opcode).toBeGreaterThanOrEqual(0x20);
expect(opcode).toBeLessThanOrEqual(0x2f);
expect(Object.values(TerminalStreamOpcode)).not.toContain(opcode);
expect(Object.values(FileTransferOpcode)).not.toContain(opcode);
}
});
it("frames a subscription event as opcode, id length, id, and the untouched payload", () => {
const payload = new Uint8Array([9, 8, 7]);
const frame = encodePluginEventFrame("sub-1", payload);
expect([...frame]).toEqual([0x20, 5, ...new TextEncoder().encode("sub-1"), 9, 8, 7]);
expect(decodeBinaryFrame(frame)).toEqual({
kind: "plugin",
frame: { opcode: PluginFrameOpcode.Event, subscriptionId: "sub-1", payload },
});
});
it("decodes payloads as views into the received buffer", () => {
const frame = encodePluginOutputFrame("req-1", new Uint8Array([1, 2, 3, 4]));
const decoded = decodePluginFrame(frame);
if (decoded?.opcode !== PluginFrameOpcode.Output) throw new Error("Expected an output frame");
expect(decoded.requestId).toBe("req-1");
expect(decoded.payload.buffer).toBe(frame.buffer);
expect([...decoded.payload]).toEqual([1, 2, 3, 4]);
});
it("frames an RPC input with the plugin and method in its header", () => {
const frame = encodePluginInputFrame({
requestId: "req-2",
pluginId: "uploads",
method: "upload.store",
payload: new Uint8Array([5, 6]),
});
expect(decodePluginFrame(frame)).toEqual({
opcode: PluginFrameOpcode.Input,
requestId: "req-2",
pluginId: "uploads",
method: "upload.store",
payload: new Uint8Array([5, 6]),
});
});
it("rejects truncated frames", () => {
expect(decodePluginFrame(new Uint8Array([0x20]))).toBeNull();
expect(decodePluginFrame(new Uint8Array([0x20, 9, 1]))).toBeNull();
expect(decodePluginFrame(new Uint8Array([0x22, 1, 65, 0, 40]))).toBeNull();
});
});
describe("the plugin byte rule", () => {
it("accepts JSON values and a top-level Uint8Array", () => {
expect(() => assertPluginWireValue({ a: [1, "b", null] }, "event")).not.toThrow();
expect(() => assertPluginWireValue(new Uint8Array(4), "event")).not.toThrow();
});
it("rejects bytes that would not fit in one frame with the largest header", () => {
const bytes = new Uint8Array(MAX_PLUGIN_PAYLOAD_BYTES + 1);
expect(() => assertPluginWireValue(bytes.subarray(1), "event")).not.toThrow();
expect(() => assertPluginWireValue(bytes, "event")).toThrow(
`event is ${MAX_PLUGIN_PAYLOAD_BYTES + 1} bytes; one value carries at most ${MAX_PLUGIN_PAYLOAD_BYTES} bytes`,
);
const frame = encodePluginInputFrame({
requestId: "r".repeat(255),
pluginId: "p",
method: "m".repeat(0xffff - 30),
payload: bytes.subarray(1),
});
expect(frame.byteLength).toBeLessThanOrEqual(MAX_ROUTED_PAYLOAD_BYTES);
});
it("rejects bytes nested in a value and names the rule and the path", () => {
expect(() => assertPluginWireValue({ frames: [{ data: new Uint8Array(1) }] }, "event")).toThrow(
"event.frames[0].data is a Uint8Array. Bytes cross the wire only as the whole value: send the Uint8Array itself, or encode it into the value.",
);
});
});
@@ -0,0 +1,152 @@
import { MAX_ROUTED_PAYLOAD_BYTES } from "./host-route.js";
/**
* Plugin values that are a `Uint8Array` travel as binary frames. Opcodes 0x20-0x2f are
* reserved for plugin traffic. Every frame is `[opcode][id length u8][id]...` and the payload
* is the rest of the frame, decoded as a view so no hop copies it.
*/
export const PluginFrameOpcode = {
/** `[0x20][id length][subscriptionId][payload]`: a subscription event. */
Event: 0x20,
/** `[0x21][id length][requestId][payload]`: an RPC output; no JSON response follows. */
Output: 0x21,
/** `[0x22][id length][requestId][header length u16][header JSON][payload]`: an RPC input. */
Input: 0x22,
} as const;
export type PluginFrameOpcode = (typeof PluginFrameOpcode)[keyof typeof PluginFrameOpcode];
/** The largest frame header: an input's opcode, ID and header JSON, each at its maximum length. */
const MAX_PLUGIN_FRAME_HEADER_BYTES = 2 + 0xff + 2 + 0xffff;
/** The largest `Uint8Array` a plugin value carries, so its frame fits in one routed message. */
export const MAX_PLUGIN_PAYLOAD_BYTES = MAX_ROUTED_PAYLOAD_BYTES - MAX_PLUGIN_FRAME_HEADER_BYTES;
export type PluginFrame =
| { opcode: typeof PluginFrameOpcode.Event; subscriptionId: string; payload: Uint8Array }
| { opcode: typeof PluginFrameOpcode.Output; requestId: string; payload: Uint8Array }
| {
opcode: typeof PluginFrameOpcode.Input;
requestId: string;
pluginId: string;
method: string;
payload: Uint8Array;
};
export function isPluginFrameOpcode(value: number | undefined): value is PluginFrameOpcode {
return (
value === PluginFrameOpcode.Event ||
value === PluginFrameOpcode.Output ||
value === PluginFrameOpcode.Input
);
}
export function encodePluginEventFrame(subscriptionId: string, payload: Uint8Array): Uint8Array {
return encodeFrame(PluginFrameOpcode.Event, subscriptionId, new Uint8Array(0), payload);
}
export function encodePluginOutputFrame(requestId: string, payload: Uint8Array): Uint8Array {
return encodeFrame(PluginFrameOpcode.Output, requestId, new Uint8Array(0), payload);
}
export function encodePluginInputFrame(input: {
requestId: string;
pluginId: string;
method: string;
payload: Uint8Array;
}): Uint8Array {
const header = new TextEncoder().encode(
JSON.stringify({ pluginId: input.pluginId, method: input.method }),
);
if (header.byteLength > 0xffff) throw new RangeError("Plugin input header is too long");
const prefix = new Uint8Array(2 + header.byteLength);
new DataView(prefix.buffer).setUint16(0, header.byteLength);
prefix.set(header, 2);
return encodeFrame(PluginFrameOpcode.Input, input.requestId, prefix, input.payload);
}
export function decodePluginFrame(bytes: Uint8Array): PluginFrame | null {
const opcode = bytes[0];
if (bytes.byteLength < 2 || !isPluginFrameOpcode(opcode)) return null;
const idLength = bytes[1];
if (idLength === 0 || idLength > bytes.byteLength - 2) return null;
const id = new TextDecoder().decode(bytes.subarray(2, 2 + idLength));
const body = bytes.subarray(2 + idLength);
if (opcode === PluginFrameOpcode.Event) return { opcode, subscriptionId: id, payload: body };
if (opcode === PluginFrameOpcode.Output) return { opcode, requestId: id, payload: body };
if (body.byteLength < 2) return null;
const headerLength = new DataView(body.buffer, body.byteOffset, body.byteLength).getUint16(0);
if (headerLength > body.byteLength - 2) return null;
const header = decodeInputHeader(body.subarray(2, 2 + headerLength));
if (!header) return null;
return { opcode, requestId: id, ...header, payload: body.subarray(2 + headerLength) };
}
function encodeFrame(
opcode: PluginFrameOpcode,
id: string,
prefix: Uint8Array,
payload: Uint8Array,
): Uint8Array {
const idBytes = new TextEncoder().encode(id);
if (idBytes.byteLength === 0) throw new RangeError("Plugin frame ID is required");
if (idBytes.byteLength > 0xff) throw new RangeError("Plugin frame ID is too long");
const headerLength = 2 + idBytes.byteLength + prefix.byteLength;
const frame = new Uint8Array(headerLength + payload.byteLength);
frame[0] = opcode;
frame[1] = idBytes.byteLength;
frame.set(idBytes, 2);
frame.set(prefix, 2 + idBytes.byteLength);
frame.set(payload, headerLength);
return frame;
}
function decodeInputHeader(bytes: Uint8Array): { pluginId: string; method: string } | null {
try {
const header: unknown = JSON.parse(new TextDecoder().decode(bytes));
if (typeof header !== "object" || header === null) return null;
const pluginId = Reflect.get(header, "pluginId");
const method = Reflect.get(header, "method");
if (typeof pluginId !== "string" || typeof method !== "string" || !pluginId || !method)
return null;
return { pluginId, method };
} catch {
return null;
}
}
/**
* The byte rule: a plugin value is either a `Uint8Array`, sent as a binary frame, or a JSON value
* with no `Uint8Array` inside it. Checks object structure only; never reads the bytes.
*/
export function assertPluginWireValue(value: unknown, label: string): void {
if (value instanceof Uint8Array) {
if (value.byteLength > MAX_PLUGIN_PAYLOAD_BYTES)
throw new RangeError(
`${label} is ${value.byteLength} bytes; one value carries at most ${MAX_PLUGIN_PAYLOAD_BYTES} bytes`,
);
return;
}
const nested = findNestedBytes(value, label);
if (nested)
throw new TypeError(
`${nested} is a Uint8Array. Bytes cross the wire only as the whole value: send the Uint8Array itself, or encode it into the value.`,
);
}
function findNestedBytes(value: unknown, path: string): string | null {
if (value instanceof Uint8Array) return path;
if (typeof value !== "object" || value === null) return null;
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) {
const found = findNestedBytes(value[index], `${path}[${index}]`);
if (found) return found;
}
return null;
}
for (const [key, entry] of Object.entries(value)) {
const found = findNestedBytes(entry, `${path}.${key}`);
if (found) return found;
}
return null;
}
@@ -46,6 +46,9 @@ export const CLIENT_CAPS = {
// COMPAT(pluginTimelineItems): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0.
pluginTimelineItems: "plugin_timeline_items",
workspaceSetupObservation: "workspace_setup_observation",
// COMPAT(pluginBinaryPayloads): added in v0.15.0, remove after 2027-05-02 once client floor >= v0.15.0.
// The daemon sends plugin RPC outputs as binary opcode 0x21 only to clients that decode it.
pluginBinaryPayloads: "plugin_binary_payloads",
// COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0.
workspaceSetupBlocked: "workspace_setup_blocked",
browserHost: "browser_host",
-50
View File
@@ -1,55 +1,5 @@
import { expect, test } from "vitest";
import { parseHostTarget } from "./host-target.js";
import { serializeSshTransportUri } from "./ssh-transport.js";
import { serializeSocketHostTarget } from "./host-target.js";
import {
parseConnectionUri,
serializeConnectionUri,
serializeRelayConnectionUri,
} from "./daemon-endpoints.js";
test("SSH and local socket targets round trip through their serializers", () => {
const ssh = parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777");
if (ssh.kind !== "ssh") throw new Error("expected SSH");
expect(parseHostTarget(serializeSshTransportUri(ssh.ssh))).toEqual(ssh);
for (const [type, path] of [
["unix", "/tmp/paseo.sock"],
["pipe", "\\\\.\\pipe\\paseo"],
] as const) {
const target = parseHostTarget(`${type}://${path}`);
expect(parseHostTarget(serializeSocketHostTarget(type, path))).toEqual(target);
}
});
test("TCP and relay targets round trip through credential-free serializers", () => {
const tcp = parseConnectionUri("tcp://example.com:6767?ssl=true&password=secret");
const tcpUri = serializeConnectionUri(tcp);
expect(tcpUri).not.toContain("secret");
expect(parseHostTarget(tcpUri)).toMatchObject({
connection: { type: "directTcp", endpoint: "example.com:6767", useTls: true },
});
const relay = parseHostTarget(
"relay://relay.example:443/server?key=public&ssl=true&password=secret",
);
if (relay.kind !== "socket" || relay.connection.type !== "relay")
throw new Error("expected relay");
const relayUri = serializeRelayConnectionUri({ offer: relay.connection.offer });
expect(relayUri).not.toContain("secret");
expect(parseHostTarget(relayUri)).toMatchObject({ connection: relay.connection });
});
test.each([
["example.com:6767", { type: "directTcp", endpoint: "example.com:6767", useTls: false }],
[
"tcp://example.com:6767?ssl=true",
{ type: "directTcp", endpoint: "example.com:6767", useTls: true },
],
["wss://example.com/ws", { type: "directTcp", endpoint: "example.com:443", useTls: true }],
["unix:///tmp/paseo.sock", { type: "directSocket", path: "/tmp/paseo.sock" }],
["pipe://mypipe", { type: "directPipe", path: "mypipe" }],
])("%s exposes its structured connection", (input, connection) => {
expect(parseHostTarget(input)).toMatchObject({ kind: "socket", connection });
});
test.each([
["127.0.0.1:6767", "tcp://127.0.0.1:6767"],
+1 -26
View File
@@ -14,23 +14,12 @@ export type HostTarget =
| {
kind: "socket";
url: string;
connection:
| { type: "directTcp"; endpoint: string; useTls: boolean }
| { type: "directSocket"; path: string }
| { type: "directPipe"; path: string }
| { type: "relay"; offer: ConnectionOffer };
socketPath?: string;
password?: string;
offer?: ConnectionOffer;
description: string;
};
/** Serialize a local socket target in the shared host grammar. */
export function serializeSocketHostTarget(type: "unix" | "pipe", path: string): string {
if (!path || path === "~") throw new Error("Invalid socket path");
return `${type}://${path}`;
}
/** The shared saved-host and CLI grammar. Errors never echo credential-bearing input. */
export function parseHostTarget(input: string): HostTarget {
try {
@@ -68,9 +57,6 @@ function parse(input: string): HostTarget {
const pipe = input.startsWith("pipe://") || input.startsWith("\\\\.\\pipe\\");
return {
kind: "socket",
connection: pipe
? { type: "directPipe", path: socketPath }
: { type: "directSocket", path: socketPath },
socketPath,
url: pipe ? "ws://localhost/ws" : `ws+unix://${socketPath}:/ws`,
description: `${pipe ? "pipe" : "unix"}://${socketPath}`,
@@ -81,7 +67,6 @@ function parse(input: string): HostTarget {
const endpoint = validatedEndpoint(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input);
return {
kind: "socket",
connection: { type: "directTcp", endpoint, useTls: false },
url: buildDaemonWebSocketUrl(endpoint, { useTls: false }),
description: `tcp://${endpoint}`,
};
@@ -90,7 +75,6 @@ function relayTarget(offer: ConnectionOffer, password?: string): HostTarget {
const endpoint = validatedEndpoint(offer.relay.endpoint);
return {
kind: "socket",
connection: { type: "relay", offer },
url: buildRelayWebSocketUrl({
endpoint,
serverId: offer.serverId,
@@ -113,7 +97,6 @@ function tcpTarget(input: string): HostTarget {
);
return {
kind: "socket",
connection: { type: "directTcp", endpoint, useTls: parts.useTls },
url: buildDaemonWebSocketUrl(endpoint, { useTls: parts.useTls }),
password: parts.password,
description: `tcp://${endpoint}`,
@@ -126,15 +109,7 @@ function websocketTarget(input: string): HostTarget {
// Unknown query parameters may themselves contain credentials; discovery shows only the origin.
if (url.pathname === "/") url.pathname = "/ws";
url.searchParams.delete("password");
const useTls = url.protocol === "wss:";
const endpoint = `${url.hostname}:${url.port || (useTls ? "443" : "80")}`;
return {
kind: "socket",
connection: { type: "directTcp", endpoint, useTls },
url: url.toString(),
password,
description: url.origin,
};
return { kind: "socket", url: url.toString(), password, description: url.origin };
}
function sshTarget(input: string): HostTarget {
@@ -372,3 +372,43 @@ it("npm metadata leaves legacy plugin rows parseable without extending their clo
payload: { requestId: "npm", plugins: [plugin] },
});
});
describe("plugin subscription protocol", () => {
it("parses a subscribe request and its response", () => {
const request = {
type: "plugin.rpc.subscribe.request",
requestId: "r1",
pluginId: "diff-watch",
method: "diff.watch",
input: { a: "main", b: "HEAD" },
};
expect(SessionInboundMessageSchema.parse(request)).toEqual(request);
const response = {
type: "plugin.rpc.subscribe.response",
payload: { requestId: "r1", subscriptionId: "s1" },
};
expect(SessionOutboundMessageSchema.parse(response)).toEqual(response);
});
it("parses JSON events and closes with and without an error", () => {
for (const message of [
{ type: "plugin.rpc.event", payload: { subscriptionId: "s1", event: { path: "a.ts" } } },
{ type: "plugin.rpc.closed", payload: { subscriptionId: "s1" } },
{ type: "plugin.rpc.closed", payload: { subscriptionId: "s1", error: "boom" } },
]) {
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
}
});
it("advertises the feature as an optional flag", () => {
const status = {
type: "status",
payload: {
status: "server_info",
serverId: "srv",
features: { pluginSubscriptions: true },
},
};
expect(StatusMessageSchema.parse(status)).toMatchObject(status);
});
});
+30
View File
@@ -1606,6 +1606,14 @@ export const PluginRpcInvokeRequestSchema = z.object({
input: z.unknown(),
});
export const PluginRpcSubscribeRequestSchema = z.object({
type: z.literal("plugin.rpc.subscribe.request"),
requestId: z.string(),
pluginId: PluginIdSchema,
method: z.string().min(1),
input: z.unknown(),
});
export const AgentTimelineAppendRequestSchema = z.object({
type: z.literal("agent.timeline.append.request"),
requestId: z.string(),
@@ -3339,6 +3347,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
PluginDisableRequestSchema,
PluginRemoveRequestSchema,
PluginRpcInvokeRequestSchema,
PluginRpcSubscribeRequestSchema,
AgentTimelineAppendRequestSchema,
AgentSkillsGetStatusRequestSchema,
AgentSkillsReconcileRequestSchema,
@@ -3739,6 +3748,8 @@ export const ServerInfoStatusPayloadSchema = z
pluginThemes: z.boolean().optional(),
pluginSettings: z.boolean().optional(),
pluginTimelineItems: z.boolean().optional(),
// COMPAT(pluginSubscriptions): added in v0.15.0, remove gate after 2027-05-02.
pluginSubscriptions: z.boolean().optional(),
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
skillManagement: z.boolean().optional(),
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26.
@@ -6962,6 +6973,22 @@ export const PluginRpcInvokeResponseSchema = z.object({
}),
});
export const PluginRpcSubscribeResponseSchema = z.object({
type: z.literal("plugin.rpc.subscribe.response"),
payload: z.object({ requestId: z.string(), subscriptionId: z.string() }),
});
/** A JSON subscription event. A `Uint8Array` event rides as binary opcode 0x20 instead. */
export const PluginRpcEventSchema = z.object({
type: z.literal("plugin.rpc.event"),
payload: z.object({ subscriptionId: z.string(), event: z.unknown() }),
});
export const PluginRpcClosedSchema = z.object({
type: z.literal("plugin.rpc.closed"),
payload: z.object({ subscriptionId: z.string(), error: z.string().optional() }),
});
export const AgentTimelineAppendResponseSchema = z.object({
type: z.literal("agent.timeline.append.response"),
payload: z.object({
@@ -7035,6 +7062,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
PluginDisableResponseSchema,
PluginRemoveResponseSchema,
PluginRpcInvokeResponseSchema,
PluginRpcSubscribeResponseSchema,
PluginRpcEventSchema,
PluginRpcClosedSchema,
AgentTimelineAppendResponseSchema,
AgentSkillsGetStatusResponseSchema,
AgentSkillsReconcileResponseSchema,
-13
View File
@@ -60,19 +60,6 @@ export function parseSshTransportUri(value: string): SshTransportTarget {
};
}
/** Serialize an SSH target without daemon credentials. */
export function serializeSshTransportUri(target: SshTransportTarget): string {
const host = validateSshHost(target.host);
const separator = host.lastIndexOf("@");
const username = separator < 0 ? "" : `${encodeURIComponent(host.slice(0, separator))}@`;
const hostname = host.slice(separator + 1);
const authority = hostname.includes(":") ? `[${hostname}]` : hostname;
const port = target.sshPort === undefined ? "" : `:${validatePort(target.sshPort, "SSH port")}`;
const daemonPort = validatePort(target.daemonPort, "Daemon port");
const query = daemonPort === DEFAULT_SSH_DAEMON_PORT ? "" : `?daemonPort=${daemonPort}`;
return `ssh://${username}${authority}${port}${query}`;
}
export function buildSshTunnelArgs(target: SshTransportTarget): string[] {
const host = validateSshHost(target.host);
const daemonPort = validatePort(target.daemonPort, "Daemon port");
+3
View File
@@ -56,6 +56,9 @@ function spawnRelayDevServer(port: number): ChildProcess {
wranglerCliPath,
"dev",
"--local",
// Local execution still inherits production vars; exercise the local Durable Object.
"--var",
"PASEO_RELAY_UPSTREAM:",
"--ip",
"127.0.0.1",
"--port",
@@ -1600,6 +1600,111 @@ describe("PiRpcAgentSession", () => {
]);
});
test("closes a history-only Pi runtime after replaying its timeline", async () => {
const pi = new FakePi();
pi.queueSessionSetup((runtimeSession) => {
runtimeSession.messages = [
{ role: "user", content: "Old question" },
{ role: "assistant", content: [{ type: "text", text: "Old answer" }] },
];
});
const client: import("../../agent-sdk-types.js").AgentClient = createClient(pi);
const session = await client.resumeSession(
{
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session",
metadata: { cwd: tmpdir() },
},
{},
undefined,
{ purpose: "history" },
);
onTestFinished(() => session.close());
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) history.push(event);
expect(history).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "timeline",
provider: "pi",
item: expect.objectContaining({ type: "assistant_message", text: "Old answer" }),
}),
]),
);
expect(pi.latestSession().closeCalls).toBe(1);
await session.close();
expect(pi.latestSession().closeCalls).toBe(1);
});
test("closes a history-only Pi runtime when replay is stopped", async () => {
const pi = new FakePi();
pi.queueSessionSetup((runtimeSession) => {
runtimeSession.messages = [
{ role: "user", content: "Old question" },
{ role: "assistant", content: [{ type: "text", text: "Old answer" }] },
];
});
const session = await createClient(pi).resumeSession(
{
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session",
metadata: { cwd: tmpdir() },
},
{},
undefined,
{ purpose: "history" },
);
onTestFinished(() => session.close());
const history = session.streamHistory();
expect((await history.next()).done).toBe(false);
await history.return();
expect(pi.latestSession().closeCalls).toBe(1);
});
test("closes a history-only Pi runtime when replay fails", async () => {
const pi = new FakePi();
pi.queueSessionSetup((runtimeSession) => {
runtimeSession.getMessagesError = new Error("History unavailable");
});
const session = await createClient(pi).resumeSession(
{
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session",
metadata: { cwd: tmpdir() },
},
{},
undefined,
{ purpose: "history" },
);
onTestFinished(() => session.close());
const history = session.streamHistory();
await expect(history.next()).rejects.toThrow("History unavailable");
expect(pi.latestSession().closeCalls).toBe(1);
});
test("keeps an interactive Pi runtime open after replaying history", async () => {
const pi = new FakePi();
const session = await createClient(pi).resumeSession({
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session",
metadata: { cwd: tmpdir() },
});
onTestFinished(() => session.close());
for await (const _event of session.streamHistory()) {
// Hydrate the session before its next interactive prompt.
}
expect(pi.latestSession().closeCalls).toBe(0);
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ provider: "pi" });
});
test("resumes a session whose model was removed on the model Pi falls back to", async () => {
const pi = new FakePi();
pi.removeModel("9router/deepseek-v4-flash");
@@ -3740,14 +3845,17 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => {
},
);
test("a history resume of an agent whose folder is gone removes its session copy on close and leaves the original unchanged", async () => {
test("a history resume of an agent whose folder is gone removes its session copy after replay and leaves the original unchanged", async () => {
const archived = writeArchivedSession(createDeletedFolder());
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
const sessionCopy = pi.recordedLaunches[0]!.session!;
expect(existsSync(sessionCopy)).toBe(true);
await session.close();
for await (const _event of session.streamHistory()) {
// Completion releases both the runtime and its temporary session copy.
}
expect(pi.latestSession().closeCalls).toBe(1);
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
@@ -253,6 +253,7 @@ interface PiRpcAgentSessionOptions {
currentModeId?: string | null;
persistedSessionFile?: string;
cleanup?: () => void;
closeAfterHistory?: boolean;
extensionTimeoutMs?: number;
logger: Logger;
usagePollScheduler?: PiUsagePollScheduler;
@@ -1255,6 +1256,7 @@ export class PiRpcAgentSession implements AgentSession {
this.currentModeId = options.currentModeId ?? null;
this.persistedSessionFile = options.persistedSessionFile;
this.cleanup = options.cleanup;
this.closeAfterHistory = options.closeAfterHistory ?? false;
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
this.logger = options.logger;
this.extensionHost = createPiExtensionHost(this.logger);
@@ -1286,6 +1288,7 @@ export class PiRpcAgentSession implements AgentSession {
private readonly config: AgentSessionConfig;
private readonly persistedSessionFile?: string;
private readonly cleanup?: () => void;
private readonly closeAfterHistory: boolean;
private readonly extensionTimeoutMs: number;
get id(): string | null {
@@ -1454,16 +1457,20 @@ export class PiRpcAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
await this.requestEntryCapture("history");
yield* streamPiHistory(
this.provider,
await this.runtimeSession.getMessages(),
this.contextUserEntries,
{},
// At most eight 2 MiB child files per replay; later cards retain their summaries.
createPiExtensionHost(this.logger, undefined, 16 * 1024 * 1024),
this.closeController.signal,
);
try {
await this.requestEntryCapture("history");
yield* streamPiHistory(
this.provider,
await this.runtimeSession.getMessages(),
this.contextUserEntries,
{},
// At most eight 2 MiB child files per replay; later cards retain their summaries.
createPiExtensionHost(this.logger, undefined, 16 * 1024 * 1024),
this.closeController.signal,
);
} finally {
if (this.closeAfterHistory) await this.close();
}
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
@@ -2738,6 +2745,7 @@ export class PiRpcAgentClient implements AgentClient {
capabilities: capabilitiesForSession(mcp !== null),
persistedSessionFile: sessionCopy ? sessionFile : undefined,
cleanup: cleanupTempFiles,
closeAfterHistory: options?.purpose === "history",
extensionTimeoutMs: providerOptions.extensionTimeoutMs,
logger: this.logger,
usagePollScheduler: this.usagePollScheduler,

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