mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3aee8c2df | ||
|
|
6639847425 | ||
|
|
e1c769c018 | ||
|
|
c081e0350d | ||
|
|
e2de6df2e2 | ||
|
|
c906c2f4a4 | ||
|
|
fffd76e8fb | ||
|
|
d2e2154a8a | ||
|
|
cb9654a65c | ||
|
|
3dc17b7f30 | ||
|
|
76a9781ba5 | ||
|
|
a272a22d7e | ||
|
|
26f227bed9 | ||
|
|
aa3ffaeb6c | ||
|
|
513f2a9ea2 | ||
|
|
8cd989529e | ||
|
|
04c3e003f8 | ||
|
|
e68553f759 | ||
|
|
dc9799f6fc | ||
|
|
43a2a7969c | ||
|
|
90d978ab68 | ||
|
|
a048094da4 | ||
|
|
315803688d | ||
|
|
bf4dc22c2f | ||
|
|
5c9f767d51 | ||
|
|
84304b5532 | ||
|
|
28507224dc | ||
|
|
db4fd33406 | ||
|
|
e3c853df58 | ||
|
|
ea7a741851 | ||
|
|
c67b7158b4 | ||
|
|
91dc927335 | ||
|
|
49f9cec6be | ||
|
|
e07da55f81 | ||
|
|
27d6a71856 | ||
|
|
bbf8cce3f7 | ||
|
|
6016ed7059 | ||
|
|
8e858f0e35 | ||
|
|
fbc83613c0 | ||
|
|
c356394bfa | ||
|
|
e6085c1e98 | ||
|
|
1bf5312297 | ||
|
|
7fa78244fd |
@@ -1,5 +1,65 @@
|
||||
# Changelog
|
||||
|
||||
## 0.9.2 - 2026-09-24
|
||||
|
||||
### Added
|
||||
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins ([#5206](https://github.com/getpaseo/paseo/pull/5206))
|
||||
|
||||
### Improved
|
||||
|
||||
- Reduced background Git polling for repositories the watcher cannot observe ([#5170](https://github.com/getpaseo/paseo/pull/5170))
|
||||
- Reduced Add Project directory search time on large home directories ([#5190](https://github.com/getpaseo/paseo/pull/5190))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens ([c3e1e08](https://github.com/getpaseo/paseo/commit/c3e1e084a068e5895710c43b034ced8ebef256e8) by [@Marcus172](https://github.com/Marcus172))
|
||||
- Fixed daemon memory growing after client connections close ([9978988](https://github.com/getpaseo/paseo/commit/9978988e35409a018a52d0d7646f18b51e562346))
|
||||
- Fixed a project folder ending in a space crashing the app on load ([#5205](https://github.com/getpaseo/paseo/pull/5205) by [@L4XB](https://github.com/L4XB))
|
||||
- Fixed workspace labels missing when an agent screen opens before the sidebar ([#5079](https://github.com/getpaseo/paseo/pull/5079) by [@morven-ai](https://github.com/morven-ai))
|
||||
- Fixed workspaces disappearing when their disk or network share is unavailable ([#5227](https://github.com/getpaseo/paseo/pull/5227))
|
||||
- Fixed a failed session import making an archived worktree impossible to restore ([#5238](https://github.com/getpaseo/paseo/pull/5238))
|
||||
- Fixed archived agent logs failing after Paseo removes their worktree ([#5229](https://github.com/getpaseo/paseo/pull/5229))
|
||||
- Fixed a newly created branch pushing to the default branch through an inherited upstream ([#5249](https://github.com/getpaseo/paseo/pull/5249))
|
||||
- Fixed fork checkout pull requests disappearing from the workspace after refresh ([#5221](https://github.com/getpaseo/paseo/pull/5221))
|
||||
- Fixed agent-created local workspaces accepting a missing path or file ([#5322](https://github.com/getpaseo/paseo/pull/5322))
|
||||
- Fixed chat uploads replacing valid characters in original file names with underscores ([#5317](https://github.com/getpaseo/paseo/pull/5317))
|
||||
- Fixed multi-select questions dropping checked options or a typed Other answer ([#5320](https://github.com/getpaseo/paseo/pull/5320) by [@ThePharmer](https://github.com/ThePharmer))
|
||||
- Fixed Android Back leaving the screen while a bottom sheet is open ([#5245](https://github.com/getpaseo/paseo/pull/5245))
|
||||
- Fixed voice mode playing the thinking tone between spoken reply segments ([#5281](https://github.com/getpaseo/paseo/pull/5281))
|
||||
- Fixed a Claude slash command sent with an attachment reaching Claude as plain text ([#5240](https://github.com/getpaseo/paseo/pull/5240) by [@joecorkerton](https://github.com/joecorkerton))
|
||||
- Fixed Claude rewind after a turn that received no response ([#5285](https://github.com/getpaseo/paseo/pull/5285))
|
||||
- Fixed Claude rewind after a turn containing subagent messages ([#5289](https://github.com/getpaseo/paseo/pull/5289))
|
||||
- Fixed repeated agent history after refresh when a durable timeline store is configured ([#5286](https://github.com/getpaseo/paseo/pull/5286))
|
||||
- Fixed an OMP custom message ending a turn before the provider finished ([#3258](https://github.com/getpaseo/paseo/pull/3258))
|
||||
- Fixed a stopped OMP turn appearing as a failed turn ([#5243](https://github.com/getpaseo/paseo/pull/5243))
|
||||
- Fixed Stop refusing to settle a Pi or OMP agent whose runtime has exited ([#5235](https://github.com/getpaseo/paseo/pull/5235))
|
||||
- Fixed OpenCode agents ignoring their configured permission rules ([#5296](https://github.com/getpaseo/paseo/pull/5296) by [@gurvancampion](https://github.com/gurvancampion))
|
||||
- Fixed Codex Default and Read-only modes sending approval requests to Auto-review ([#5239](https://github.com/getpaseo/paseo/pull/5239) by [@HMWCS](https://github.com/HMWCS))
|
||||
- Fixed Cursor agent creation after switching from a Fast model to one without Fast ([#5274](https://github.com/getpaseo/paseo/pull/5274) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Fixed the Fast control missing for GPT-6 Sol and GPT-6 Luna in Codex ([#5273](https://github.com/getpaseo/paseo/pull/5273) by [@basilk15](https://github.com/basilk15), [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed Claude's configured Fable model missing from the model picker ([#5326](https://github.com/getpaseo/paseo/pull/5326) by [@noahg9](https://github.com/noahg9))
|
||||
- Fixed ACP terminals started by the daemon using the wrong agent identity ([#5248](https://github.com/getpaseo/paseo/pull/5248))
|
||||
- Fixed an agent's completed plugin session appearing failed after a daemon restart ([#5253](https://github.com/getpaseo/paseo/pull/5253))
|
||||
- Fixed plugin reload crashing its subprocess while a provider session closes ([#5231](https://github.com/getpaseo/paseo/pull/5231))
|
||||
- Fixed a failed plugin provider request crashing the daemon ([#5298](https://github.com/getpaseo/paseo/pull/5298))
|
||||
- Fixed Hub executions being unable to title workspaces they create ([#5302](https://github.com/getpaseo/paseo/pull/5302))
|
||||
- Fixed an invalid schedule file preventing the daemon from starting ([#5301](https://github.com/getpaseo/paseo/pull/5301))
|
||||
- Fixed an empty `paseo.pid` preventing the daemon from starting ([#5306](https://github.com/getpaseo/paseo/pull/5306))
|
||||
- Fixed a recycled daemon PID preventing startup after a reboot ([#5277](https://github.com/getpaseo/paseo/pull/5277))
|
||||
- Fixed daemon startup failing on `config.json` saved with a UTF-8 byte order mark ([#5315](https://github.com/getpaseo/paseo/pull/5315))
|
||||
- Fixed background daemon startup errors missing from the reported log file ([#5332](https://github.com/getpaseo/paseo/pull/5332))
|
||||
- Fixed CLI errors for invalid `config.json` omitting the file and failing field ([#5337](https://github.com/getpaseo/paseo/pull/5337))
|
||||
- Fixed the CLI suggesting daemon startup after a password rejection ([#5310](https://github.com/getpaseo/paseo/pull/5310))
|
||||
- Fixed Open in editor missing for a password-protected desktop daemon ([#5335](https://github.com/getpaseo/paseo/pull/5335))
|
||||
- Fixed `paseo permit ls --json` shortening request IDs needed by `permit allow` and `permit deny` ([#5305](https://github.com/getpaseo/paseo/pull/5305))
|
||||
- Fixed a replica cache read spinning when its store keeps rejecting writes ([#5290](https://github.com/getpaseo/paseo/pull/5290))
|
||||
- Fixed the MiniMax card showing a raw error for an inactive token subscription ([#5258](https://github.com/getpaseo/paseo/pull/5258))
|
||||
- Fixed modified Backspace being rejected as a custom shortcut ([#5224](https://github.com/getpaseo/paseo/pull/5224))
|
||||
- Fixed multi-step shortcuts failing when the app updates between steps ([#5255](https://github.com/getpaseo/paseo/pull/5255) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed multi-step shortcuts failing when the second step holds a modifier ([#5272](https://github.com/getpaseo/paseo/pull/5272))
|
||||
- Fixed rebound pane-focus shortcuts failing while typing ([#5287](https://github.com/getpaseo/paseo/pull/5287))
|
||||
|
||||
## 0.9.1 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
+6
-2
@@ -42,11 +42,15 @@ The daemon requires a valid cryptographic handshake before processing any comman
|
||||
|
||||
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
||||
|
||||
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
|
||||
|
||||
## Local daemon trust boundary
|
||||
|
||||
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||
By default, the daemon binds to `127.0.0.1`. With no password configured, anything that can reach the daemon socket can control the daemon. Loopback is reachable by other users on the machine and by some forwarding tools.
|
||||
|
||||
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
|
||||
The daemon supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). WebSocket clients send the password in `hello`; the daemon sends no session data before admission. Direct connections still accept bearer headers and WebSocket bearer subprotocols for older clients. HTTP stays bearer-header based. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt; `/api/files/download` and `/mcp/agents` use their own capability tokens.
|
||||
|
||||
The daemon writes a new `$PASEO_HOME/local-credential` on every run with mode `0600` and removes it on shutdown. The CLI and desktop main process read it only for the daemon whose PID lock `listen` matches their connection target. A same-user process can read this credential, so the password protects against network clients and other OS users, not processes running as the daemon user. Protect `$PASEO_HOME` accordingly. Relay traffic remains end-to-end encrypted independently of password admission.
|
||||
|
||||
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
||||
|
||||
|
||||
@@ -714,7 +714,7 @@ Each entry in the `models` array:
|
||||
|
||||
### Claude settings.json model discovery
|
||||
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_FABLE_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ $PASEO_HOME/
|
||||
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
|
||||
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
|
||||
├── paseo.pid # Daemon PID lock file
|
||||
├── local-credential # Per-run local client credential (mode 0600)
|
||||
├── daemon.log # Default log file (path configurable)
|
||||
├── agents/
|
||||
│ └── {sanitized-cwd}/
|
||||
@@ -557,6 +558,7 @@ These small files are not validated as full Zod schemas but are persisted under
|
||||
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
|
||||
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
|
||||
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
|
||||
| `local-credential` | 32 random bytes encoded as base64url text | Rotated before each listen and deleted on shutdown; mode `0600`. |
|
||||
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
|
||||
|
||||
---
|
||||
|
||||
@@ -10,6 +10,8 @@ Two contracts follow from it.
|
||||
|
||||
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
|
||||
|
||||
`hello` carries an optional credential. The daemon accepts a client protocol version from its minimum through newer client versions, selects the lower of client and daemon maximum, and reports it as optional `server_info.protocolVersion`. A `hello.rejected` frame precedes an auth close only when the client sent `hello.auth` or advertised `hello_rejection`; older clients receive the existing WebSocket close code and reason. This avoids sending them a new top-level message their validator does not recognize.
|
||||
|
||||
- New fields are `.optional()` with a sensible default.
|
||||
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
|
||||
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
|
||||
|
||||
+9
-3
@@ -69,7 +69,7 @@ model; it does not override the model list returned by a resolver.
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
@@ -79,6 +79,8 @@ A provider that can register runtime tools directly should set `supportsNativePa
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
Pi extension adapters live under `packages/server/src/server/agent/providers/pi/extensions/<extension>/`. Each adapter turns Pi RPC facts into Paseo tool, timeline, subagent, or question mappings through the [extension contract](../packages/server/src/server/agent/providers/pi/extensions/contract.ts). To add one, create its directory, add one entry to `extensions/registry.ts`, and test it with fixtures captured from the real extension in Pi that record package, version, source commit, Pi version, and capture date. `agent.ts`, `history-mapper.ts`, and `tool-call-mapper.ts` never name an extension.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
|
||||
|
||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
||||
@@ -95,9 +97,13 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets.
|
||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
|
||||
sha256-EU28KEISx1lV1wKSiB3npPER1g+hTMD64JuDWxDjHPE=
|
||||
|
||||
Generated
+266
-51
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -42,6 +42,13 @@
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@0no-co/graphql.web": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
|
||||
@@ -8271,6 +8278,84 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
@@ -8644,6 +8729,48 @@
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/client": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.10.tgz",
|
||||
"integrity": "sha512-Pz4FD6jVtfZYX97AghVTM+cedi3HfI+U6gg/we5Luf08L85TewKsSXR4jHOZTCvggl0vACTquz1/hOjsJWE0Sg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "2.0.10",
|
||||
"@opencode/schema": "2.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"effect": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/protocol": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.10.tgz",
|
||||
"integrity": "sha512-cHr0aSK39Q9c3JwPDvG45FFlE6rU/ntTFDSaqm4LRl74tki88G4WpzKk6Q56G06K6hKIONrezcPdXtDKtnZzyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "2.0.10",
|
||||
"effect": "4.0.0-rc.112"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/schema": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.10.tgz",
|
||||
"integrity": "sha512-RpRhRZFH1YFnM+OcrxOZc2ObIW3RQ63z/LkcHrZKa47kWF0OiaZVkbAOITeAoAoofcnS3O5ekZBvgT/7kBwRUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"effect": "4.0.0-rc.112"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
@@ -14263,13 +14390,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abab": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
|
||||
@@ -18622,6 +18742,16 @@
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-rc.112",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz",
|
||||
"integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-check": "^4.9.0",
|
||||
"msgpackr": "^2.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||
@@ -21866,6 +21996,28 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz",
|
||||
"integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
||||
@@ -28141,6 +28293,37 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz",
|
||||
"integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||
@@ -28466,6 +28649,21 @@
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
@@ -30141,6 +30339,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
|
||||
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
@@ -33127,23 +33341,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-length": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz",
|
||||
@@ -33318,6 +33515,23 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stringify-entities": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||
@@ -36196,7 +36410,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37262,12 +37476,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/server": "0.9.1",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/server": "0.9.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37514,10 +37728,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37528,7 +37742,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37772,7 +37986,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38668,7 +38882,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38697,14 +38911,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38714,8 +38928,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38737,7 +38951,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38764,7 +38978,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38982,19 +39196,20 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/highlight": "0.9.1",
|
||||
"@getpaseo/plugin": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/highlight": "0.9.2",
|
||||
"@getpaseo/plugin": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@opencode/client": "2.0.10",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.20.0",
|
||||
@@ -39895,7 +40110,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -6,13 +6,16 @@ import {
|
||||
expectAgentProfilesEmptyPrompt,
|
||||
expectProfileEditTooltip,
|
||||
expectComposerMode,
|
||||
expectCreateProfileFromModelRow,
|
||||
expectComposerModel,
|
||||
expectModelRowProfileActionBesideRow,
|
||||
expectModelRowSelected,
|
||||
expectProfileEditIsPencilOnly,
|
||||
expectProfileVisibleForProvider,
|
||||
openModelPicker,
|
||||
openAgentProfilesFromEmptyPrompt,
|
||||
seedAgentProfiles,
|
||||
selectModelRow,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
@@ -115,4 +118,34 @@ test.describe("Agent profiles in the model picker", () => {
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a model row and its create-profile action are separate buttons", async ({ page }) => {
|
||||
const seed = await seedAgentProfiles([]);
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-profiles-row-action-",
|
||||
title: "Agent profiles row action",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
|
||||
try {
|
||||
const oneMinute = {
|
||||
provider: "mock",
|
||||
modelId: "one-minute-stream",
|
||||
modelLabel: "One minute stream",
|
||||
};
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
await expectModelRowProfileActionBesideRow(page, oneMinute);
|
||||
await expectCreateProfileFromModelRow(page, oneMinute);
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await openModelPicker(page);
|
||||
await selectModelRow(page, oneMinute);
|
||||
await expectComposerModel(page, oneMinute.modelLabel);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
@@ -25,3 +28,39 @@ test("settings restart waits for a replacement worker while retaining the superv
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(process.platform === "win32", "The slow provider stub is a POSIX script");
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID(), {
|
||||
paseoHome: await createHomeWithSlowProvider(1_800),
|
||||
});
|
||||
try {
|
||||
const previousWorker = await readWorkerPid(daemon);
|
||||
await openDaemonOverview(page, daemon);
|
||||
await restartDaemonInSettings(page);
|
||||
await expectDaemonRestartComplete(page);
|
||||
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function createHomeWithSlowProvider(versionDelayMs: number): Promise<string> {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-slow-provider-"));
|
||||
const provider = path.join(paseoHome, "slow-provider");
|
||||
await writeFile(
|
||||
provider,
|
||||
`#!${process.execPath}\nsetTimeout(() => console.log("provider 1.0.0"), ${versionDelayMs});\n`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
agents: { providers: { claude: { command: { mode: "replace", argv: [provider] } } } },
|
||||
})}\n`,
|
||||
);
|
||||
return paseoHome;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
@@ -120,3 +121,75 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const MANY_THEMES_PLUGIN_ID = "plugin-theme-many-e2e";
|
||||
const MANY_THEMES_COUNT = 40;
|
||||
const LAST_THEME_NAME = `Pack theme ${MANY_THEMES_COUNT}`;
|
||||
|
||||
const MANY_THEMES_SOURCE = `export default function contribute(plugin) {
|
||||
for (let index = 1; index <= ${MANY_THEMES_COUNT}; index += 1) {
|
||||
plugin.addTheme({
|
||||
id: "pack-" + index,
|
||||
name: "Pack theme " + index,
|
||||
appearance: "dark",
|
||||
colors: {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
raised: "#313244",
|
||||
control: "#45475a",
|
||||
border: "#45475a",
|
||||
accent: "#cba6f7",
|
||||
mutedForeground: "#a6adc8",
|
||||
ring: "#6c7086",
|
||||
},
|
||||
});
|
||||
}
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
async function scrollThemeMenuToLastTheme(page: Page) {
|
||||
const firstTheme = page.getByText("Pack theme 1", { exact: true });
|
||||
await expect(firstTheme).toBeVisible({ timeout: 30_000 });
|
||||
await firstTheme.hover();
|
||||
await page.mouse.wheel(0, 4000);
|
||||
await expect(page.getByText(LAST_THEME_NAME, { exact: true })).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
test("scrolls to the last theme when a plugin contributes more themes than fit", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: MANY_THEMES_PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.ts"), MANY_THEMES_SOURCE);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
await page.getByLabel("Theme: System", { exact: true }).click();
|
||||
await scrollThemeMenuToLastTheme(page);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
|
||||
await page.getByText(LAST_THEME_NAME, { exact: true }).click();
|
||||
await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { TEST_HOST_LABEL } from "../support/helpers/daemon-registry";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
@@ -18,6 +19,12 @@ import {
|
||||
expectRetiredSidebarSectionsAbsent,
|
||||
expectHostPageVisible,
|
||||
seedSavedSettingsHosts,
|
||||
selectSettingsHost,
|
||||
expectHostRejectedWithReAddGuidance,
|
||||
expectNoHostPasswordControls,
|
||||
removeHostFromHostPage,
|
||||
addDirectHostFromSettings,
|
||||
expectHostOnlineWithoutError,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
@@ -89,6 +96,47 @@ test.describe("Settings host page", () => {
|
||||
await expect(updateButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("a host rejected for its password reconnects after being removed and added again with it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const password = "e2e host password";
|
||||
const daemon = await startIsolatedHostDaemon("srv_e2e_password_host", {
|
||||
environment: { ...process.env, PASEO_PASSWORD: password },
|
||||
});
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: TEST_HOST_LABEL,
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: daemon.serverId,
|
||||
label: "Password host",
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
},
|
||||
]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, daemon.serverId);
|
||||
await openHostSection(page, daemon.serverId, "host");
|
||||
|
||||
await test.step("the host page shows the reason and the re-add guidance, with no password UI", async () => {
|
||||
await expectHostRejectedWithReAddGuidance(page, "Password required");
|
||||
await expectNoHostPasswordControls(page);
|
||||
});
|
||||
|
||||
await test.step("removing the host and adding it again with the password brings it online", async () => {
|
||||
await removeHostFromHostPage(page, daemon.serverId);
|
||||
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: daemon.port, password });
|
||||
await openHostSection(page, daemon.serverId, "host");
|
||||
await expectHostOnlineWithoutError(page);
|
||||
});
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { escapeRegex } from "../support/helpers/regex";
|
||||
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
@@ -108,7 +110,207 @@ async function withPaseoOwnedWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
async function readCachedRowIds(
|
||||
page: Page,
|
||||
rowKind: "workspace" | "checkpoint",
|
||||
): Promise<string[]> {
|
||||
return page.evaluate(
|
||||
async ({ serverId, kind }) => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("paseo-replica-row-store");
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
try {
|
||||
return await new Promise<string[]>((resolve, reject) => {
|
||||
const transaction = database.transaction("rows", "readonly");
|
||||
const request = transaction.objectStore("rows").getAll();
|
||||
request.onsuccess = () =>
|
||||
resolve(
|
||||
request.result
|
||||
.filter(
|
||||
(row: { serverId: string; kind: string }) =>
|
||||
row.serverId === serverId && row.kind === kind,
|
||||
)
|
||||
.map((row: { id: string }) => row.id)
|
||||
.sort(),
|
||||
);
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
},
|
||||
{ serverId: getServerId(), kind: rowKind },
|
||||
);
|
||||
}
|
||||
|
||||
async function simulateDamagedLegacyDirectoryCache(
|
||||
page: Page,
|
||||
workspaceIdToRemove: string,
|
||||
): Promise<void> {
|
||||
// Stop the app before altering storage so no pending app write can repair the fixture.
|
||||
const fixtureUrl = new URL("/__directory_cache_fixture", page.url()).href;
|
||||
await page.route(fixtureUrl, (route) =>
|
||||
route.fulfill({ contentType: "text/html", body: "<html><body>Cache fixture</body></html>" }),
|
||||
);
|
||||
await page.goto(fixtureUrl);
|
||||
await page.evaluate(
|
||||
async ({ serverId, missingWorkspaceId }) => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("paseo-replica-row-store");
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = database.transaction("rows", "readwrite");
|
||||
const store = transaction.objectStore("rows");
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => {
|
||||
const rows = request.result as Array<{
|
||||
serverId: string;
|
||||
kind: string;
|
||||
id: string;
|
||||
payload: string;
|
||||
}>;
|
||||
const checkpoint = rows.find(
|
||||
(row) => row.serverId === serverId && row.kind === "checkpoint",
|
||||
);
|
||||
if (!checkpoint) {
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
const stored = JSON.parse(checkpoint.payload);
|
||||
// Accept either writer so this fixture also reproduces on the unfixed client.
|
||||
const cursors = stored.version === 1 ? stored.cursors : stored;
|
||||
if (!cursors.workspaces?.generation) {
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
store.put({ ...checkpoint, payload: JSON.stringify(cursors) });
|
||||
for (const row of rows) {
|
||||
if (
|
||||
row.serverId === serverId &&
|
||||
row.kind === "workspace" &&
|
||||
row.id === missingWorkspaceId
|
||||
)
|
||||
store.delete([serverId, row.kind, row.id]);
|
||||
}
|
||||
};
|
||||
transaction.addEventListener("complete", () => resolve());
|
||||
transaction.addEventListener("abort", () =>
|
||||
reject(transaction.error ?? new Error("Missing current directory checkpoint")),
|
||||
);
|
||||
transaction.addEventListener("error", () => reject(transaction.error));
|
||||
});
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
},
|
||||
{ serverId: getServerId(), missingWorkspaceId: workspaceIdToRemove },
|
||||
);
|
||||
expect(await readCachedRowIds(page, "workspace")).not.toContain(workspaceIdToRemove);
|
||||
}
|
||||
|
||||
async function holdHostResponsesUntilCacheIsVisible(page: Page): Promise<() => void> {
|
||||
let release!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.routeWebSocket(new RegExp(`:${getE2EDaemonPort()}(?:/|$)`), (socket) => {
|
||||
const server = socket.connectToServer();
|
||||
server.onMessage(async (message) => {
|
||||
await ready;
|
||||
socket.send(message);
|
||||
});
|
||||
});
|
||||
return release;
|
||||
}
|
||||
|
||||
async function createPinnedSiblingWorkspace(
|
||||
project: Awaited<ReturnType<typeof seedWorkspace>>,
|
||||
): Promise<string> {
|
||||
const sibling = await project.client.createWorkspace({
|
||||
source: { kind: "directory", path: project.repoPath, projectId: project.projectId },
|
||||
title: "Pinned sibling",
|
||||
});
|
||||
if (!sibling.workspace) throw new Error(sibling.error ?? "Sibling workspace was not created");
|
||||
await project.client.setWorkspacePinned(sibling.workspace.id, true);
|
||||
return sibling.workspace.id;
|
||||
}
|
||||
|
||||
async function expectSidebarWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await waitForSidebarWorkspace(page, workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await expectSidebarWorkspaces(page, workspaceIds);
|
||||
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
|
||||
}
|
||||
|
||||
async function reopenDamagedCacheBeforeHostResponds(
|
||||
page: Page,
|
||||
missingWorkspaceId: string,
|
||||
): Promise<() => void> {
|
||||
await simulateDamagedLegacyDirectoryCache(page, missingWorkspaceId);
|
||||
const releaseHost = await holdHostResponsesUntilCacheIsVisible(page);
|
||||
await gotoAppShell(page);
|
||||
return releaseHost;
|
||||
}
|
||||
|
||||
async function expectDamagedCacheHydrated(
|
||||
page: Page,
|
||||
retainedWorkspaceId: string,
|
||||
missingWorkspaceId: string,
|
||||
): Promise<void> {
|
||||
await waitForSidebarWorkspace(page, retainedWorkspaceId);
|
||||
await expect(page.getByTestId(getWorkspaceRowTestId(missingWorkspaceId))).toHaveCount(0);
|
||||
await expect.poll(() => readCachedRowIds(page, "checkpoint")).toEqual([]);
|
||||
}
|
||||
|
||||
async function expectRecoveredWorkspacesAfterReload(
|
||||
page: Page,
|
||||
workspaceIds: string[],
|
||||
testInfo: TestInfo,
|
||||
): Promise<void> {
|
||||
await expectSidebarWorkspaces(page, workspaceIds);
|
||||
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
|
||||
await page.reload();
|
||||
await expectSidebarWorkspaces(page, workspaceIds);
|
||||
await page.screenshot({ path: testInfo.outputPath("recovered-workspaces.png") });
|
||||
await testInfo.attach("recovered-workspaces", {
|
||||
path: testInfo.outputPath("recovered-workspaces.png"),
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Sidebar workspace list", () => {
|
||||
test("recovers missing workspaces from a damaged legacy cache after reload", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "directory-cache-recovery-" });
|
||||
try {
|
||||
const siblingId = await createPinnedSiblingWorkspace(project);
|
||||
const ids = [project.workspaceId, siblingId].sort();
|
||||
await test.step("cache both workspaces", () => cacheWorkspaces(page, ids));
|
||||
|
||||
const releaseHost = await test.step("reopen damaged cache before host responds", () =>
|
||||
reopenDamagedCacheBeforeHostResponds(page, project.workspaceId));
|
||||
await test.step("keep cached workspace and discard stale checkpoint", () =>
|
||||
expectDamagedCacheHydrated(page, siblingId, project.workspaceId));
|
||||
|
||||
releaseHost();
|
||||
await test.step("recover missing workspace and retain both after reload", () =>
|
||||
expectRecoveredWorkspacesAfterReload(page, ids, testInfo));
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-remote-",
|
||||
|
||||
@@ -482,6 +482,50 @@ export async function expectModelRowSelected(
|
||||
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
function modelRow(page: Page, input: { provider: string; modelId: string }): Locator {
|
||||
return pickerViewport(page).getByTestId(`model-row-${input.provider}-${input.modelId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A model row's profile action is its own button. On web, a button inside the
|
||||
* row's button is invalid HTML that React reports on every render.
|
||||
*/
|
||||
export async function expectModelRowProfileActionBesideRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string },
|
||||
): Promise<void> {
|
||||
const row = modelRow(page, input);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(pickerViewport(page).locator("button button")).toHaveCount(0);
|
||||
await expect(row.getByRole("button")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** The hover-revealed plus on an unprofiled model row opens a seeded profile form. */
|
||||
export async function expectCreateProfileFromModelRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string; modelLabel: string },
|
||||
): Promise<void> {
|
||||
await modelRow(page, input).hover();
|
||||
await pickerViewport(page)
|
||||
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
|
||||
.click();
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
|
||||
input.modelLabel,
|
||||
);
|
||||
await expect(modal.getByRole("button", { name: `Model (${input.modelLabel})` })).toBeVisible();
|
||||
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(modal).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function selectModelRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string },
|
||||
): Promise<void> {
|
||||
await modelRow(page, input).click();
|
||||
}
|
||||
|
||||
// ─── Cross-provider model search ───────────────────────────────────────────
|
||||
|
||||
export async function searchAllModels(page: Page, query: string): Promise<void> {
|
||||
|
||||
@@ -91,12 +91,15 @@ export async function selectHostConnectionType(
|
||||
|
||||
export async function addDirectHostFromSettings(
|
||||
page: Page,
|
||||
input: { host: string; port: number },
|
||||
input: { host: string; port: number; password?: string },
|
||||
): Promise<void> {
|
||||
await openAddHostFlow(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
await page.getByTestId("direct-host-input").fill(input.host);
|
||||
await page.getByTestId("direct-port-input").fill(String(input.port));
|
||||
if (input.password) {
|
||||
await page.getByTestId("direct-password-input").fill(input.password);
|
||||
}
|
||||
await page.getByTestId("direct-host-submit").click();
|
||||
await expect(page.getByTestId("add-host-modal")).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
@@ -418,3 +421,31 @@ export async function expectLocalHostEntryFirst(page: Page, _serverId: string):
|
||||
await expect(picker).toBeVisible();
|
||||
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostRejectedWithReAddGuidance(
|
||||
page: Page,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
const error = page.getByTestId("host-connection-error");
|
||||
await expect(error).toContainText(reason);
|
||||
await expect(error).toContainText(
|
||||
"Remove this host and add it again with the password this daemon asks for.",
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectNoHostPasswordControls(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Password", { exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('input[type="password"]')).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /password/i })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function removeHostFromHostPage(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId("host-page-remove-host-button").click();
|
||||
await page.getByTestId("remove-host-confirm").click();
|
||||
await expect(page).not.toHaveURL(new RegExp(`/settings/hosts/${serverId}`));
|
||||
}
|
||||
|
||||
export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
|
||||
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"APP_VARIANT": "production"
|
||||
},
|
||||
"android": {
|
||||
"resourceClass": "large"
|
||||
"resourceClass": "large",
|
||||
"gradleCommand": ":app:bundleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -105,7 +105,6 @@ import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
useHostRegistryLoaded,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
useHostRuntimeIsConnected,
|
||||
useHosts,
|
||||
@@ -666,13 +665,11 @@ function MobileGestureWrapper({
|
||||
}
|
||||
|
||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||
|
||||
return (
|
||||
<AppearanceProvider>
|
||||
<VoiceProvider>
|
||||
<DesktopWindowControlsSync />
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<OfferLinkListener />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
{children}
|
||||
@@ -699,45 +696,36 @@ function DesktopWindowControlsSync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function OfferLinkListener({
|
||||
upsertDaemonFromOfferUrl,
|
||||
}: {
|
||||
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<unknown>;
|
||||
}) {
|
||||
function OfferLinkListener() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const handleUrl = (url: string | null) => {
|
||||
const handleUrl = async (url: string | null) => {
|
||||
if (!url) return;
|
||||
if (!url.includes("#offer=")) return;
|
||||
void upsertDaemonFromOfferUrl(url)
|
||||
.then((profile) => {
|
||||
if (cancelled) return;
|
||||
const serverId = (profile as { serverId?: unknown } | null)?.serverId;
|
||||
if (typeof serverId !== "string" || !serverId) return;
|
||||
router.replace(buildOpenProjectRoute());
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
console.warn("[Linking] Failed to import pairing offer", error);
|
||||
});
|
||||
if (!url.includes("#offer=") && !url.includes("#connect=") && !url.startsWith("relay://"))
|
||||
return;
|
||||
try {
|
||||
const result = await getHostRuntimeStore().importConnectionLink(url, "openProject");
|
||||
if (!cancelled && result.status === "connected") router.replace(buildOpenProjectRoute());
|
||||
} catch (error) {
|
||||
console.warn("[OfferLinkListener] Pairing link failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
void Linking.getInitialURL()
|
||||
.then(handleUrl)
|
||||
.then((url) => handleUrl(url))
|
||||
.catch(() => undefined);
|
||||
|
||||
const subscription = Linking.addEventListener("url", (event) => {
|
||||
handleUrl(event.url);
|
||||
void handleUrl(event.url);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
subscription.remove();
|
||||
};
|
||||
}, [router, upsertDaemonFromOfferUrl]);
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
||||
import { useHostMutations } from "@/runtime/host-runtime";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -114,7 +112,8 @@ function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
|
||||
const raw = typeof result.data === "string" ? result.data.trim() : "";
|
||||
if (!raw) return null;
|
||||
|
||||
if (raw.includes("#offer=")) return raw;
|
||||
if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://"))
|
||||
return raw;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -128,11 +127,11 @@ export default function PairScanScreen() {
|
||||
source?: string;
|
||||
}>();
|
||||
const source = typeof params.source === "string" ? params.source : "settings";
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isPairing, setIsPairing] = useState(false);
|
||||
const lastScannedRef = useRef<string | null>(null);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const [passwordOfferUrl, setPasswordOfferUrl] = useState<string | null>(null);
|
||||
|
||||
const navigateToPairedHost = useCallback(
|
||||
(serverId: string) => {
|
||||
@@ -160,48 +159,34 @@ export default function PairScanScreen() {
|
||||
}, [permission, requestPermission]);
|
||||
|
||||
const handleScan = useCallback(
|
||||
async (result: BarcodeScanningResult) => {
|
||||
(result: BarcodeScanningResult) => {
|
||||
if (isPairing) return;
|
||||
const offerUrl = extractOfferUrlFromScan(result);
|
||||
if (!offerUrl) return;
|
||||
|
||||
if (lastScannedRef.current === offerUrl) return;
|
||||
lastScannedRef.current = offerUrl;
|
||||
|
||||
try {
|
||||
setIsPairing(true);
|
||||
const idx = offerUrl.indexOf("#offer=");
|
||||
const encoded = offerUrl.slice(idx + "#offer=".length).trim();
|
||||
const offerPayload = decodeOfferFragmentPayload(encoded);
|
||||
const offer = ConnectionOfferSchema.parse(offerPayload);
|
||||
|
||||
const { client, hostname } = await connectToDaemon(
|
||||
{
|
||||
id: "probe",
|
||||
type: "relay",
|
||||
relayEndpoint: normalizeHostPort(offer.relay.endpoint),
|
||||
useTls: offer.relay.useTls,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
},
|
||||
{ serverId: offer.serverId },
|
||||
);
|
||||
await client.close().catch(() => undefined);
|
||||
|
||||
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
|
||||
|
||||
navigateToPairedHost(profile.serverId);
|
||||
} catch (error) {
|
||||
lastScannedRef.current = null;
|
||||
const message = error instanceof Error ? error.message : t("pairing.scan.unableToPair");
|
||||
Alert.alert(t("pairing.scan.errorTitle"), message);
|
||||
} finally {
|
||||
setIsPairing(false);
|
||||
}
|
||||
const store = getHostRuntimeStore();
|
||||
if (passwordOfferUrl) return;
|
||||
setIsPairing(true);
|
||||
setScanError(null);
|
||||
void store
|
||||
.importConnectionLink(offerUrl, source === "onboarding" ? "hostRoot" : "hostSettings")
|
||||
.then((outcome) => {
|
||||
if (outcome.status === "connected") navigateToPairedHost(outcome.serverId);
|
||||
else setPasswordOfferUrl(offerUrl);
|
||||
return outcome;
|
||||
})
|
||||
.catch((error) => setScanError(error instanceof Error ? error.message : String(error)))
|
||||
.finally(() => setIsPairing(false));
|
||||
},
|
||||
[isPairing, navigateToPairedHost, t, upsertDaemonFromOfferUrl],
|
||||
[isPairing, navigateToPairedHost, passwordOfferUrl, source],
|
||||
);
|
||||
|
||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||
const closePasswordModal = useCallback(() => setPasswordOfferUrl(null), []);
|
||||
const savePasswordPairing = useCallback(
|
||||
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
|
||||
[navigateToPairedHost],
|
||||
);
|
||||
const handleRequestPermission = useCallback(() => {
|
||||
void requestPermission();
|
||||
}, [requestPermission]);
|
||||
@@ -263,10 +248,18 @@ export default function PairScanScreen() {
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
|
||||
{scanError ? <Text style={helperTextStyle}>{scanError}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<PairLinkModal
|
||||
visible={passwordOfferUrl !== null}
|
||||
initialUrl={passwordOfferUrl ?? undefined}
|
||||
initialPasswordRequired
|
||||
onClose={closePasswordModal}
|
||||
onSaved={savePasswordPairing}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogB
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Alert
|
||||
size="md"
|
||||
variant="error"
|
||||
title={t("changelog.error.title")}
|
||||
description={t("changelog.error.description")}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useReducer, useState } from "react";
|
||||
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";
|
||||
@@ -11,9 +11,13 @@ import {
|
||||
serializeConnectionUri,
|
||||
serializeConnectionUriForStorage,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
import {
|
||||
DaemonConnectionTestError,
|
||||
getConnectionAuthFailureReason,
|
||||
} from "@/utils/test-daemon-connection";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
|
||||
@@ -250,8 +254,8 @@ function buildConnectionFailureCopy(input: {
|
||||
const rawLower = raw?.toLowerCase() ?? "";
|
||||
let detail: string | null = null;
|
||||
|
||||
if (raw === "Incorrect password" || raw === "Password required") {
|
||||
detail = raw;
|
||||
if (getConnectionAuthFailureReason(error)) {
|
||||
detail = error instanceof Error ? error.message : raw;
|
||||
} else if (rawLower.includes("timed out")) {
|
||||
detail = labels.timedOut;
|
||||
} else if (
|
||||
@@ -293,7 +297,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { probeAndUpsertDirectConnection } = useHostMutations();
|
||||
const { probeAndUpsertDirectConnection, probeAndUpsertConnectionFromOfferUrl } =
|
||||
useHostMutations();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -306,6 +311,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||
const [advancedUri, setAdvancedUri] = useState("");
|
||||
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
const advancedTarget = useRef(new PairingTargetTracker("", true));
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
setHost("");
|
||||
@@ -315,6 +321,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setIsPasswordVisible(false);
|
||||
setIsAdvancedOpen(false);
|
||||
setAdvancedUri("");
|
||||
advancedTarget.current = new PairingTargetTracker("", true);
|
||||
bumpInputResetKey();
|
||||
}, []);
|
||||
|
||||
@@ -365,9 +372,45 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
(onCancel ?? onClose)();
|
||||
}, [isSaving, clearInput, onCancel, onClose]);
|
||||
|
||||
const handleSaveRelay = useCallback(
|
||||
async (relayUri: string) => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
|
||||
relayUri,
|
||||
password || undefined,
|
||||
);
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : directConnectionLabels.invalidConnection,
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
daemons,
|
||||
directConnectionLabels.invalidConnection,
|
||||
handleClose,
|
||||
onSaved,
|
||||
password,
|
||||
probeAndUpsertConnectionFromOfferUrl,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return;
|
||||
|
||||
const relayUri = isAdvancedOpen ? advancedUri.trim() : "";
|
||||
if (relayUri.startsWith("relay://") || relayUri.includes("#connect=")) {
|
||||
await handleSaveRelay(relayUri);
|
||||
return;
|
||||
}
|
||||
|
||||
let connection: PreparedDirectConnection;
|
||||
try {
|
||||
connection = prepareDirectConnection(
|
||||
@@ -422,10 +465,12 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
advancedUri,
|
||||
daemons,
|
||||
directConnectionLabels,
|
||||
handleClose,
|
||||
host,
|
||||
isAdvancedOpen,
|
||||
isMobile,
|
||||
isSaving,
|
||||
onSaved,
|
||||
@@ -433,6 +478,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
port,
|
||||
probeAndUpsertDirectConnection,
|
||||
t,
|
||||
handleSaveRelay,
|
||||
useTls,
|
||||
]);
|
||||
|
||||
@@ -444,6 +490,15 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
void handleSave();
|
||||
}, [handleSave]);
|
||||
|
||||
const handleChangeAdvancedUri = useCallback((next: string) => {
|
||||
if (advancedTarget.current.changeUrl(next)) {
|
||||
setPassword("");
|
||||
bumpInputResetKey();
|
||||
setErrorMessage("");
|
||||
}
|
||||
setAdvancedUri(next);
|
||||
}, []);
|
||||
|
||||
const handleToggleUseTls = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
setUseTls((current) => !current);
|
||||
@@ -455,6 +510,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
|
||||
const handleToggleAdvanced = useCallback(() => {
|
||||
if (!isAdvancedOpen) {
|
||||
advancedTarget.current = new PairingTargetTracker("", true);
|
||||
try {
|
||||
setAdvancedUri(
|
||||
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
|
||||
@@ -612,7 +668,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
initialValue={advancedUri}
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
onChangeText={setAdvancedUri}
|
||||
onChangeText={handleChangeAdvancedUri}
|
||||
placeholder="tcp://localhost:6767?ssl=true"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
|
||||
@@ -625,13 +625,6 @@ function ModelRowProfileAction({
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const visible = hovered || isNative || isCompact;
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
onPress();
|
||||
},
|
||||
[onPress],
|
||||
);
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered: buttonHovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.rowIconButton,
|
||||
@@ -645,7 +638,7 @@ function ModelRowProfileAction({
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={pressableStyle}
|
||||
pointerEvents={visible ? "auto" : "none"}
|
||||
@@ -778,7 +771,6 @@ function ModelRow({
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.browserRow,
|
||||
styles.browserModelRow,
|
||||
Boolean(hovered) && styles.browserRowHovered,
|
||||
pressed && styles.browserRowPressed,
|
||||
],
|
||||
@@ -787,7 +779,7 @@ function ModelRow({
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.modelRowHoverBoundary}
|
||||
style={[styles.modelRowHoverBoundary, styles.browserModelRow]}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
@@ -815,10 +807,17 @@ function ModelRow({
|
||||
<ThemedCheck size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||
) : null}
|
||||
</View>
|
||||
{profileAction}
|
||||
{profileAction ? <View style={styles.rowIconButton} /> : null}
|
||||
</View>
|
||||
</View>
|
||||
</ModelBrowserPressable>
|
||||
{/* The row renders a <button> on web, so its profile action sits beside it,
|
||||
over the slot reserved above, rather than inside it. */}
|
||||
{profileAction ? (
|
||||
<View style={styles.modelRowProfileActionSlot} pointerEvents="box-none">
|
||||
{profileAction}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1590,6 +1589,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
modelRowHoverBoundary: {
|
||||
position: "relative",
|
||||
},
|
||||
modelRowProfileActionSlot: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: isWeb ? theme.spacing[3] : theme.spacing[6],
|
||||
justifyContent: "center",
|
||||
},
|
||||
browserModelRow: isWeb ? {} : { marginBottom: theme.spacing[1] },
|
||||
browserRowHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
|
||||
describe("pairing target password", () => {
|
||||
it("clears the password and hides its input when switching hosts", () => {
|
||||
const first = "relay://relay.example:443/srv_a?key=AAAA&ssl=true";
|
||||
const second = "relay://relay.example:443/srv_b?key=BBBB&ssl=true";
|
||||
const target = new PairingTargetTracker(first);
|
||||
expect(target.changeUrl("relay://relay.example:443/")).toBe(false);
|
||||
expect(target.changeUrl(second)).toBe(true);
|
||||
expect(target.changeUrl(second)).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the direct form credential when its advanced URI changes to a relay target", () => {
|
||||
const target = new PairingTargetTracker("", true);
|
||||
expect(target.changeUrl("relay://relay.example:443/srv_new?key=BBBB&ssl=true")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the password when a relay URI receives harmless whitespace", () => {
|
||||
const uri = "relay://relay.example:443/srv_a?key=AAAA&ssl=true";
|
||||
const target = new PairingTargetTracker(uri);
|
||||
expect(target.changeUrl(`${uri} `)).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes a different host in an offer link after an incomplete edit", () => {
|
||||
const offer = Buffer.from(
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
serverId: "srv_b",
|
||||
daemonPublicKeyB64: "BBBB",
|
||||
relay: { endpoint: "relay.example:443" },
|
||||
}),
|
||||
).toString("base64url");
|
||||
const target = new PairingTargetTracker("relay://relay.example:443/srv_a?key=AAAA");
|
||||
expect(target.changeUrl("https://app.paseo.sh/#offer=")).toBe(false);
|
||||
expect(target.changeUrl(`https://app.paseo.sh/#offer=${offer}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
||||
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
|
||||
|
||||
function pairingTarget(url: string): string | null {
|
||||
try {
|
||||
const trimmed = url.trim();
|
||||
const offer =
|
||||
trimmed.startsWith("relay://") || trimmed.includes("#connect=")
|
||||
? parseRelayConnectionUri(trimmed).offer
|
||||
: parseConnectionOfferFromUrl(trimmed);
|
||||
return offer ? `${offer.serverId}:${offer.daemonPublicKeyB64}` : null;
|
||||
} catch {
|
||||
// Keep the last valid target while the user edits an incomplete URI.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tracks the last valid host so harmless edits never clear its password. */
|
||||
export class PairingTargetTracker {
|
||||
private currentTarget: string | null;
|
||||
|
||||
constructor(initialUrl = "", startsFromDirectForm = false) {
|
||||
this.currentTarget = pairingTarget(initialUrl) ?? (startsFromDirectForm ? "direct-form" : null);
|
||||
}
|
||||
|
||||
changeUrl(nextUrl: string): boolean {
|
||||
const nextTarget = pairingTarget(nextUrl);
|
||||
if (!nextTarget) return false;
|
||||
const changed = this.currentTarget !== null && this.currentTarget !== nextTarget;
|
||||
this.currentTarget = nextTarget;
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
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";
|
||||
@@ -6,15 +6,27 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Link } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
||||
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
|
||||
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
|
||||
function parsedHostLabel(input: string): string {
|
||||
try {
|
||||
if (input.startsWith("relay://") || input.includes("#connect=")) {
|
||||
return parseRelayConnectionUri(input).offer.serverId;
|
||||
}
|
||||
return parseConnectionOfferFromUrl(input)?.serverId ?? "host";
|
||||
} catch {
|
||||
return "host";
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
helper: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -50,6 +62,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
export interface PairLinkModalProps {
|
||||
visible: boolean;
|
||||
initialUrl?: string;
|
||||
initialPasswordRequired?: boolean;
|
||||
onClose: () => void;
|
||||
onCancel?: () => void;
|
||||
onSaved?: (result: {
|
||||
@@ -60,21 +74,57 @@ export interface PairLinkModalProps {
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
|
||||
export function PairLinkModal({
|
||||
visible,
|
||||
initialUrl,
|
||||
initialPasswordRequired = false,
|
||||
onClose,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: PairLinkModalProps) {
|
||||
return (
|
||||
<PairLinkModalContent
|
||||
key={`${visible}:${initialUrl ?? ""}:${initialPasswordRequired}`}
|
||||
visible={visible}
|
||||
initialUrl={initialUrl}
|
||||
initialPasswordRequired={initialPasswordRequired}
|
||||
onClose={onClose}
|
||||
onCancel={onCancel}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PairLinkModalContent({
|
||||
visible,
|
||||
initialUrl,
|
||||
initialPasswordRequired = false,
|
||||
onClose,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: PairLinkModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||
const { probeAndUpsertConnectionFromOfferUrl } = useHostMutations();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const offerUrlRef = useRef("");
|
||||
const offerUrlRef = useRef(initialUrl ?? "");
|
||||
const targetTracker = useRef(new PairingTargetTracker(initialUrl));
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [needsPassword, setNeedsPassword] = useState(initialPasswordRequired);
|
||||
const [passwordResetKey, resetPasswordInput] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
offerUrlRef.current = "";
|
||||
targetTracker.current = new PairingTargetTracker();
|
||||
inputRef.current?.replaceText("");
|
||||
setPassword("");
|
||||
setNeedsPassword(false);
|
||||
resetPasswordInput();
|
||||
}, []);
|
||||
|
||||
const pairIcon = useMemo(
|
||||
@@ -96,74 +146,63 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
(onCancel ?? onClose)();
|
||||
}, [isSaving, clearInput, onCancel, onClose]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return;
|
||||
const raw = offerUrlRef.current.trim();
|
||||
if (!raw) {
|
||||
setErrorMessage(t("pairing.link.errors.required"));
|
||||
return;
|
||||
}
|
||||
if (!raw.includes("#offer=")) {
|
||||
setErrorMessage(t("pairing.link.errors.missingOffer"));
|
||||
return;
|
||||
}
|
||||
const handleSave = useCallback(
|
||||
async (input?: string) => {
|
||||
if (isSaving) return;
|
||||
const raw = (input ?? offerUrlRef.current).trim();
|
||||
if (!raw) {
|
||||
setErrorMessage(t("pairing.link.errors.required"));
|
||||
return;
|
||||
}
|
||||
if (!raw.includes("#offer=") && !raw.startsWith("relay://") && !raw.includes("#connect=")) {
|
||||
setErrorMessage(t("pairing.link.errors.missingOffer"));
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedOffer = (() => {
|
||||
try {
|
||||
const idx = raw.indexOf("#offer=");
|
||||
const encoded = raw.slice(idx + "#offer=".length).trim();
|
||||
if (!encoded) {
|
||||
throw new Error(t("pairing.link.errors.emptyOffer"));
|
||||
}
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
return ConnectionOfferSchema.parse(payload);
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
|
||||
raw,
|
||||
password || undefined,
|
||||
);
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t("pairing.link.errors.invalid");
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
|
||||
setErrorMessage(message);
|
||||
if (getConnectionAuthFailureReason(error)) {
|
||||
setNeedsPassword(true);
|
||||
return;
|
||||
}
|
||||
if (!isMobile) {
|
||||
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
})();
|
||||
|
||||
if (!parsedOffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
|
||||
const { client, hostname } = await connectToDaemon(
|
||||
{
|
||||
id: "probe",
|
||||
type: "relay",
|
||||
relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint),
|
||||
useTls: parsedOffer.relay.useTls,
|
||||
daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64,
|
||||
},
|
||||
{ serverId: parsedOffer.serverId },
|
||||
);
|
||||
await client.close().catch(() => undefined);
|
||||
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
||||
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined);
|
||||
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
|
||||
setErrorMessage(message);
|
||||
if (!isMobile) {
|
||||
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [daemons, handleClose, isMobile, isSaving, onSaved, t, upsertDaemonFromOfferUrl]);
|
||||
},
|
||||
[
|
||||
daemons,
|
||||
handleClose,
|
||||
isMobile,
|
||||
isSaving,
|
||||
onSaved,
|
||||
password,
|
||||
t,
|
||||
probeAndUpsertConnectionFromOfferUrl,
|
||||
],
|
||||
);
|
||||
|
||||
const handleChangeOfferUrl = useCallback((next: string) => {
|
||||
if (targetTracker.current.changeUrl(next)) {
|
||||
setPassword("");
|
||||
setNeedsPassword(false);
|
||||
resetPasswordInput();
|
||||
setErrorMessage("");
|
||||
}
|
||||
offerUrlRef.current = next;
|
||||
}, []);
|
||||
|
||||
@@ -186,6 +225,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
<Text style={styles.label}>{t("pairing.link.label")}</Text>
|
||||
<AdaptiveTextInput
|
||||
ref={inputRef}
|
||||
initialValue={initialUrl}
|
||||
testID="pair-link-input"
|
||||
nativeID="pair-link-input"
|
||||
accessibilityLabel={t("pairing.link.label")}
|
||||
@@ -201,6 +241,22 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
|
||||
{needsPassword ? (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>
|
||||
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
|
||||
</Text>
|
||||
<AdaptiveTextInput
|
||||
testID="pair-link-password-input"
|
||||
resetKey={`pair-link-password-${passwordResetKey}`}
|
||||
accessibilityLabel={t("pairing.hostPassword.label")}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
style={styles.input}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
style={FLEX_ONE_STYLE}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
buildQuestionFormResponse,
|
||||
parseQuestionFormQuestions,
|
||||
questionShowsTextInput,
|
||||
resolveDismissLabel,
|
||||
@@ -54,6 +55,54 @@ describe("question form card core", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps checked options and appends the other answer for multi-select", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Which fruits do you like?",
|
||||
header: "Fruits",
|
||||
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
|
||||
multiSelect: true,
|
||||
allowOther: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!questions) throw new Error("questions did not parse");
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, { 0: " durian " })).toEqual({
|
||||
Fruits: "Apple, Cherry, durian",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, {})).toEqual({
|
||||
Fruits: "Apple, Cherry",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set() }, { 0: "durian" })).toEqual({
|
||||
Fruits: "durian",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, {}, { 0: "durian" })).toEqual({ Fruits: "durian" });
|
||||
});
|
||||
|
||||
test("replaces the selected option with the other answer for single-select", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider?",
|
||||
header: "Provider",
|
||||
options: [{ label: "Claude Code" }, { label: "Codex" }],
|
||||
multiSelect: false,
|
||||
allowOther: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!questions) throw new Error("questions did not parse");
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, { 0: "OpenCode" })).toEqual({
|
||||
Provider: "OpenCode",
|
||||
});
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, {})).toEqual({
|
||||
Provider: "Codex",
|
||||
});
|
||||
});
|
||||
|
||||
test("shows text input for explicit other questions", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
@@ -99,4 +148,44 @@ describe("question form card core", () => {
|
||||
Response: "custom",
|
||||
});
|
||||
});
|
||||
test("submits each question's answer as structured labels so commas stay unambiguous", () => {
|
||||
const input = {
|
||||
questions: [
|
||||
{
|
||||
question: "Which colors?",
|
||||
header: "Colors",
|
||||
options: [{ label: "Red, bright" }, { label: "Blue" }],
|
||||
multiSelect: true,
|
||||
allowOther: true,
|
||||
},
|
||||
{
|
||||
question: "Which city?",
|
||||
header: "City",
|
||||
options: [{ label: "Paris, France" }, { label: "Rome" }],
|
||||
allowOther: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const questions = parseQuestionFormQuestions(input);
|
||||
if (!questions) throw new Error("questions did not parse");
|
||||
|
||||
expect(
|
||||
buildQuestionFormResponse(
|
||||
input,
|
||||
questions,
|
||||
{ 0: new Set([0, 1]), 1: new Set([1]) },
|
||||
{ 0: " Green, dark ", 1: "Lyon, France" },
|
||||
),
|
||||
).toEqual({
|
||||
behavior: "allow",
|
||||
questionAnswers: [
|
||||
{ selected: ["Red, bright", "Blue"], text: "Green, dark" },
|
||||
{ selected: [], text: "Lyon, France" },
|
||||
],
|
||||
updatedInput: {
|
||||
...input,
|
||||
answers: { Colors: "Red, bright, Blue, Green, dark", City: "Lyon, France" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AgentPermissionResponse, AgentQuestionAnswer } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
@@ -101,33 +103,60 @@ export function areQuestionsAnswered(
|
||||
);
|
||||
}
|
||||
|
||||
export function buildQuestionFormResponse(
|
||||
input: Record<string, unknown> | undefined,
|
||||
questions: QuestionFormQuestion[],
|
||||
selections: QuestionSelections,
|
||||
otherTexts: QuestionOtherTexts,
|
||||
): AgentPermissionResponse {
|
||||
return {
|
||||
behavior: "allow",
|
||||
questionAnswers: buildQuestionAnswers(questions, selections, otherTexts),
|
||||
updatedInput: {
|
||||
...input,
|
||||
// COMPAT(questionAnswers): added in v0.9.3, remove after 2027-03-26 once daemon floor >= v0.9.3.
|
||||
// Older daemons read only these text answers; newer daemons derive them from questionAnswers.
|
||||
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildQuestionAnswers(
|
||||
questions: QuestionFormQuestion[],
|
||||
selections: QuestionSelections,
|
||||
otherTexts: QuestionOtherTexts,
|
||||
): AgentQuestionAnswer[] {
|
||||
return questions.map((q, i) => {
|
||||
const selected = selections[i];
|
||||
const otherText = otherTexts[i]?.trim();
|
||||
const labels = selected ? Array.from(selected).map((idx) => q.options[idx].label) : [];
|
||||
|
||||
if (questionShowsTextInput(q)) {
|
||||
if (otherText && otherText.length > 0) {
|
||||
// Multi-select keeps the checked options alongside the custom answer, the way
|
||||
// Claude Code's own AskUserQuestion UI does. Single-select replaces the option.
|
||||
return { selected: q.multiSelect ? labels : [], text: otherText };
|
||||
}
|
||||
if (q.allowEmpty && q.options.length === 0) {
|
||||
return { selected: [], text: "" };
|
||||
}
|
||||
}
|
||||
return { selected: labels };
|
||||
});
|
||||
}
|
||||
|
||||
export function buildQuestionFormAnswers(
|
||||
questions: QuestionFormQuestion[],
|
||||
selections: QuestionSelections,
|
||||
otherTexts: QuestionOtherTexts,
|
||||
): Record<string, string> {
|
||||
const answers: Record<string, string> = {};
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const selected = selections[i];
|
||||
const otherText = otherTexts[i]?.trim();
|
||||
|
||||
if (questionShowsTextInput(q)) {
|
||||
if (otherText && otherText.length > 0) {
|
||||
answers[q.header] = otherText;
|
||||
continue;
|
||||
}
|
||||
if (q.allowEmpty && q.options.length === 0) {
|
||||
answers[q.header] = "";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (selected && selected.size > 0) {
|
||||
const labels = Array.from(selected).map((idx) => q.options[idx].label);
|
||||
answers[q.header] = labels.join(", ");
|
||||
}
|
||||
}
|
||||
buildQuestionAnswers(questions, selections, otherTexts).forEach((answer, i) => {
|
||||
if (answer.selected.length === 0 && answer.text === undefined) return;
|
||||
answers[questions[i].header] = [...answer.selected, ...(answer.text ? [answer.text] : [])].join(
|
||||
", ",
|
||||
);
|
||||
});
|
||||
return answers;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { within } from "@testing-library/dom";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import { QuestionFormCard } from "./question-form-card";
|
||||
|
||||
// Load translations so controls expose their real accessible names.
|
||||
void testI18n;
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
/**
|
||||
* A real browser with the real web `EditingTextInput`, because the bug under test lives in the
|
||||
* gap between that input and React state: the input owns its text and never replays state, so a
|
||||
* card that drops the Other text from state alone keeps showing it while submit ignores it.
|
||||
*/
|
||||
|
||||
interface Mounted {
|
||||
root: Root;
|
||||
container: HTMLDivElement;
|
||||
}
|
||||
|
||||
const mounted: Mounted[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const entry of mounted.splice(0)) {
|
||||
act(() => entry.root.unmount());
|
||||
entry.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
function buildPermission(question: Record<string, unknown>): PendingPermission {
|
||||
return {
|
||||
key: "perm-1",
|
||||
agentId: "agent-1",
|
||||
request: {
|
||||
id: "perm-1",
|
||||
provider: "claude",
|
||||
name: "AskUserQuestion",
|
||||
kind: "question",
|
||||
input: { questions: [question] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mountCard(question: Record<string, unknown>) {
|
||||
const onRespond = vi.fn<(response: AgentPermissionResponse) => void>();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() =>
|
||||
root.render(
|
||||
<QuestionFormCard
|
||||
permission={buildPermission(question)}
|
||||
onRespond={onRespond}
|
||||
isResponding={false}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
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 type = (text: string) => {
|
||||
const input = otherInput();
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
if (!valueSetter) throw new Error("HTML input value setter is unavailable");
|
||||
act(() => {
|
||||
valueSetter.call(input, text);
|
||||
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: text }));
|
||||
});
|
||||
};
|
||||
const submit = () => act(() => view.getByRole("button", { name: "Submit" }).click());
|
||||
const submittedAnswers = (): Record<string, string> => {
|
||||
const response = onRespond.mock.calls[0]?.[0];
|
||||
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 };
|
||||
}
|
||||
|
||||
const multiSelectQuestion = {
|
||||
question: "Which fruits do you like?",
|
||||
header: "Fruits",
|
||||
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
|
||||
multiSelect: true,
|
||||
allowOther: true,
|
||||
};
|
||||
|
||||
const singleSelectQuestion = {
|
||||
question: "Which provider?",
|
||||
header: "Provider",
|
||||
options: [{ label: "Claude Code" }, { label: "Codex" }],
|
||||
multiSelect: false,
|
||||
allowOther: true,
|
||||
};
|
||||
|
||||
describe("QuestionFormCard other answers", () => {
|
||||
it("keeps checked options when the other answer is typed afterwards (multi-select)", () => {
|
||||
const card = mountCard(multiSelectQuestion);
|
||||
|
||||
card.check("Apple");
|
||||
card.check("Cherry");
|
||||
card.type("durian");
|
||||
card.submit();
|
||||
|
||||
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Cherry, durian" });
|
||||
});
|
||||
|
||||
it("keeps the typed other answer when options are checked afterwards (multi-select)", () => {
|
||||
const card = mountCard(multiSelectQuestion);
|
||||
|
||||
card.type("durian");
|
||||
card.check("Apple");
|
||||
card.check("Banana");
|
||||
|
||||
expect(card.otherInput().value).toBe("durian");
|
||||
card.submit();
|
||||
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Banana, durian" });
|
||||
});
|
||||
|
||||
it("replaces the selected option with the typed other answer (single-select)", () => {
|
||||
const card = mountCard(singleSelectQuestion);
|
||||
|
||||
card.check("Codex");
|
||||
card.type("OpenCode");
|
||||
card.submit();
|
||||
|
||||
expect(card.submittedAnswers()).toEqual({ Provider: "OpenCode" });
|
||||
});
|
||||
|
||||
it("clears the typed other answer on screen when an option is picked afterwards (single-select)", () => {
|
||||
const card = mountCard(singleSelectQuestion);
|
||||
|
||||
card.type("OpenCode");
|
||||
card.check("Codex");
|
||||
|
||||
expect(card.otherInput().value).toBe("");
|
||||
card.submit();
|
||||
expect(card.submittedAnswers()).toEqual({ Provider: "Codex" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useRef, type RefObject } from "react";
|
||||
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -9,9 +9,10 @@ import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
buildQuestionFormResponse,
|
||||
isQuestionAnswered,
|
||||
parseQuestionFormQuestions,
|
||||
questionShowsTextInput,
|
||||
@@ -258,6 +259,7 @@ function QuestionNav({
|
||||
|
||||
interface QuestionOtherInputProps {
|
||||
qIndex: number;
|
||||
inputRef: RefObject<EditingTextInputHandle | null>;
|
||||
accessibilityLabel: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
@@ -268,6 +270,7 @@ interface QuestionOtherInputProps {
|
||||
|
||||
function QuestionOtherInput({
|
||||
qIndex,
|
||||
inputRef,
|
||||
accessibilityLabel,
|
||||
value,
|
||||
placeholder,
|
||||
@@ -303,6 +306,7 @@ function QuestionOtherInput({
|
||||
);
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={otherInputStyle}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
@@ -328,6 +332,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
|
||||
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
||||
const otherInputRef = useRef<EditingTextInputHandle | null>(null);
|
||||
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
|
||||
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
|
||||
|
||||
@@ -349,29 +354,40 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
}
|
||||
|
||||
setSelections((prev) => ({ ...prev, [qIndex]: next }));
|
||||
setOtherTexts((prev) => {
|
||||
if (!prev[qIndex]) return prev;
|
||||
const nextTexts = { ...prev };
|
||||
delete nextTexts[qIndex];
|
||||
return nextTexts;
|
||||
});
|
||||
|
||||
// Single-select: an option and a custom answer replace each other, as in Claude Code.
|
||||
// Multi-select keeps both. The editing surface owns its text and never replays state
|
||||
// (docs/forms.md), so clearing state alone would leave stale text on screen that
|
||||
// submit ignores; clear the surface explicitly.
|
||||
if (!multiSelect && otherTexts[qIndex]) {
|
||||
setOtherTexts((prev) => {
|
||||
const nextTexts = { ...prev };
|
||||
delete nextTexts[qIndex];
|
||||
return nextTexts;
|
||||
});
|
||||
otherInputRef.current?.replaceText("");
|
||||
}
|
||||
|
||||
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
|
||||
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
|
||||
}
|
||||
},
|
||||
[activeQuestionIndex, questions, selections],
|
||||
[activeQuestionIndex, otherTexts, questions, selections],
|
||||
);
|
||||
|
||||
const setOtherText = useCallback((qIndex: number, text: string) => {
|
||||
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||
if (text.length > 0) {
|
||||
setSelections((prev) => {
|
||||
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
|
||||
return { ...prev, [qIndex]: new Set<number>() };
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const setOtherText = useCallback(
|
||||
(qIndex: number, text: string) => {
|
||||
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||
const multiSelect = questions?.[qIndex]?.multiSelect ?? false;
|
||||
if (!multiSelect && text.length > 0) {
|
||||
setSelections((prev) => {
|
||||
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
|
||||
return { ...prev, [qIndex]: new Set<number>() };
|
||||
});
|
||||
}
|
||||
},
|
||||
[questions],
|
||||
);
|
||||
|
||||
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
|
||||
const resolvedActiveQuestionIndex = questions
|
||||
@@ -386,13 +402,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!questions || !allAnswered || isResponding) return;
|
||||
setRespondingAction("submit");
|
||||
onRespond({
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
...permission.request.input,
|
||||
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
|
||||
},
|
||||
});
|
||||
onRespond(
|
||||
buildQuestionFormResponse(permission.request.input, questions, selections, otherTexts),
|
||||
);
|
||||
}, [
|
||||
questions,
|
||||
allAnswered,
|
||||
@@ -407,13 +419,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
if (!questions) return;
|
||||
setRespondingAction("dismiss");
|
||||
if (shouldSubmitEmptyOnDismiss(questions)) {
|
||||
onRespond({
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
...permission.request.input,
|
||||
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
|
||||
},
|
||||
});
|
||||
onRespond(
|
||||
buildQuestionFormResponse(permission.request.input, questions, selections, otherTexts),
|
||||
);
|
||||
return;
|
||||
}
|
||||
onRespond({
|
||||
@@ -554,6 +562,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
{showTextInput ? (
|
||||
<QuestionOtherInput
|
||||
qIndex={resolvedActiveQuestionIndex}
|
||||
inputRef={otherInputRef}
|
||||
accessibilityLabel={activeQuestion.question}
|
||||
value={otherText}
|
||||
placeholder={getQuestionInputPlaceholder({
|
||||
|
||||
@@ -2,14 +2,21 @@ import { AlertTriangle, CheckCircle2, Info, XCircle, type LucideIcon } from "luc
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
type ButtonControlSize,
|
||||
buttonIconSize,
|
||||
createControlGeometry,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { hexColorWithAlpha } from "@/utils/color";
|
||||
|
||||
export type AlertVariant = "default" | "info" | "success" | "warning" | "error";
|
||||
export type AlertSize = ButtonControlSize;
|
||||
|
||||
export interface AlertProps {
|
||||
title?: string;
|
||||
description?: ReactNode;
|
||||
variant?: AlertVariant;
|
||||
icon?: ReactNode;
|
||||
size?: AlertSize;
|
||||
children?: ReactNode;
|
||||
testID?: string;
|
||||
}
|
||||
@@ -25,50 +32,93 @@ export function Alert({
|
||||
title,
|
||||
description,
|
||||
variant = "default",
|
||||
icon,
|
||||
size = "md",
|
||||
children,
|
||||
testID,
|
||||
}: AlertProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const accentColor = resolveAccentColor(variant, theme);
|
||||
const borderColor = variant === "success" ? theme.colors.border : accentColor;
|
||||
const borderColor =
|
||||
variant === "success" || !accentColor
|
||||
? theme.colors.border
|
||||
: hexColorWithAlpha(accentColor, 0.5);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, borderColor ? { borderColor } : null],
|
||||
[borderColor],
|
||||
);
|
||||
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, accentColor ? { color: accentColor } : null],
|
||||
[accentColor],
|
||||
);
|
||||
const sized = resolveSizeStyles(size);
|
||||
const containerStyle = [styles.container, sized.container, { borderColor }];
|
||||
const titleStyle = [styles.title, sized.text, accentColor ? { color: accentColor } : null];
|
||||
const iconSize = buttonIconSize[size];
|
||||
|
||||
const resolvedIcon = useMemo(() => {
|
||||
if (icon !== undefined) return icon;
|
||||
if (variant === "default") return null;
|
||||
const Icon = VARIANT_ICON[variant];
|
||||
return <Icon size={theme.iconSize.sm} color={accentColor ?? theme.colors.foreground} />;
|
||||
}, [icon, variant, theme, accentColor]);
|
||||
return <Icon size={iconSize} color={accentColor ?? theme.colors.foreground} />;
|
||||
}, [variant, theme, accentColor, iconSize]);
|
||||
|
||||
const hasDescription = description != null && description !== "";
|
||||
let descriptionContent: ReactNode = null;
|
||||
if (typeof description === "string" && description !== "") {
|
||||
descriptionContent = <Text style={[styles.description, sized.text]}>{description}</Text>;
|
||||
} else if (description != null && description !== "") {
|
||||
descriptionContent = <View style={styles.descriptionSlot}>{description}</View>;
|
||||
}
|
||||
const leadContent = title ? <Text style={titleStyle}>{title}</Text> : descriptionContent;
|
||||
const belowLead = title ? descriptionContent : null;
|
||||
const hasBody = belowLead !== null || Boolean(children);
|
||||
|
||||
return (
|
||||
<View style={containerStyle} testID={testID} accessibilityRole="alert">
|
||||
{resolvedIcon ? <View style={styles.iconSlot}>{resolvedIcon}</View> : null}
|
||||
<View style={styles.body}>
|
||||
{title ? <Text style={titleStyle}>{title}</Text> : null}
|
||||
{hasDescription && typeof description === "string" ? (
|
||||
<Text style={styles.description}>{description}</Text>
|
||||
<View style={[styles.lead, sized.lead]}>
|
||||
{resolvedIcon ? (
|
||||
<View style={[styles.iconSlot, sized.iconSlot]}>{resolvedIcon}</View>
|
||||
) : null}
|
||||
{hasDescription && typeof description !== "string" ? (
|
||||
<View style={styles.descriptionSlot}>{description}</View>
|
||||
) : null}
|
||||
{children ? <View style={styles.actions}>{children}</View> : null}
|
||||
{leadContent ? <View style={styles.leadText}>{leadContent}</View> : null}
|
||||
</View>
|
||||
{hasBody ? (
|
||||
<View style={resolvedIcon ? sized.indent : null}>
|
||||
{belowLead}
|
||||
{children ? <View style={styles.actions}>{children}</View> : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSizeStyles(size: AlertSize) {
|
||||
if (size === "xs") {
|
||||
return {
|
||||
container: styles.containerXs,
|
||||
text: styles.textXs,
|
||||
lead: styles.leadXs,
|
||||
iconSlot: styles.iconSlotXs,
|
||||
indent: styles.indentXs,
|
||||
};
|
||||
}
|
||||
if (size === "sm") {
|
||||
return {
|
||||
container: styles.containerSm,
|
||||
text: styles.textSm,
|
||||
lead: styles.leadSm,
|
||||
iconSlot: styles.iconSlotSm,
|
||||
indent: styles.indentSm,
|
||||
};
|
||||
}
|
||||
if (size === "lg") {
|
||||
return {
|
||||
container: styles.containerLg,
|
||||
text: styles.textLg,
|
||||
lead: styles.leadLg,
|
||||
iconSlot: styles.iconSlotLg,
|
||||
indent: styles.indentLg,
|
||||
};
|
||||
}
|
||||
return {
|
||||
container: styles.containerMd,
|
||||
text: styles.textMd,
|
||||
lead: styles.leadMd,
|
||||
iconSlot: styles.iconSlotMd,
|
||||
indent: styles.indentMd,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAccentColor(
|
||||
variant: AlertVariant,
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
@@ -80,43 +130,63 @@ function resolveAccentColor(
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[3],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: "transparent",
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
iconSlot: {
|
||||
paddingTop: 2,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
description: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
descriptionSlot: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
const styles = StyleSheet.create((theme) => {
|
||||
const { alert } = createControlGeometry(theme);
|
||||
|
||||
return {
|
||||
container: {
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
containerXs: alert.xs.container,
|
||||
containerSm: alert.sm.container,
|
||||
containerMd: alert.md.container,
|
||||
containerLg: alert.lg.container,
|
||||
lead: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
iconSlot: {
|
||||
alignItems: "center",
|
||||
},
|
||||
leadText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
leadXs: alert.xs.lead,
|
||||
leadSm: alert.sm.lead,
|
||||
leadMd: alert.md.lead,
|
||||
leadLg: alert.lg.lead,
|
||||
iconSlotXs: alert.xs.iconSlot,
|
||||
iconSlotSm: alert.sm.iconSlot,
|
||||
iconSlotMd: alert.md.iconSlot,
|
||||
iconSlotLg: alert.lg.iconSlot,
|
||||
indentXs: alert.xs.indent,
|
||||
indentSm: alert.sm.indent,
|
||||
indentMd: alert.md.indent,
|
||||
indentLg: alert.lg.indent,
|
||||
textXs: alert.xs.text,
|
||||
textSm: alert.sm.text,
|
||||
textMd: alert.md.text,
|
||||
textLg: alert.lg.text,
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
description: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
descriptionSlot: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buttonControlHeight,
|
||||
buttonIconSize,
|
||||
createControlGeometry,
|
||||
getControlInteractionPhase,
|
||||
} from "@/components/ui/control-geometry";
|
||||
@@ -11,6 +12,7 @@ const theme = {
|
||||
md: 6,
|
||||
lg: 8,
|
||||
xl: 12,
|
||||
"2xl": 16,
|
||||
full: 9999,
|
||||
},
|
||||
borderWidth: {
|
||||
@@ -30,10 +32,13 @@ const theme = {
|
||||
},
|
||||
spacing: {
|
||||
0: 0,
|
||||
0.5: 2,
|
||||
1: 4,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
6: 24,
|
||||
8: 32,
|
||||
},
|
||||
} as unknown as Theme;
|
||||
|
||||
@@ -157,4 +162,41 @@ describe("control geometry", () => {
|
||||
geometry.buttonMd.paddingHorizontal,
|
||||
);
|
||||
});
|
||||
|
||||
it("gives alert title and description one font size and a tight gap per size", () => {
|
||||
const { alert } = createControlGeometry(theme);
|
||||
|
||||
expect(alert.xs.text).toEqual({ fontSize: 12 });
|
||||
expect(alert.sm.text).toEqual({ fontSize: 14 });
|
||||
expect(alert.md.text).toEqual(alert.sm.text);
|
||||
expect(alert.lg.text).toEqual(alert.sm.text);
|
||||
for (const size of ["xs", "sm", "md", "lg"] as const) {
|
||||
expect(alert[size].container.gap).toBeLessThanOrEqual(4);
|
||||
}
|
||||
});
|
||||
|
||||
it("starts alert text below the lead row at the lead text's left edge", () => {
|
||||
const { alert } = createControlGeometry(theme);
|
||||
|
||||
for (const size of ["xs", "sm", "md", "lg"] as const) {
|
||||
expect(alert[size].iconSlot.width).toBe(buttonIconSize[size]);
|
||||
expect(alert[size].indent.marginLeft).toBe(alert[size].iconSlot.width + alert[size].lead.gap);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps sm alerts as roomy and round as the original alert, and scales the rest around it", () => {
|
||||
const { alert } = createControlGeometry(theme);
|
||||
|
||||
expect(alert.sm.container).toMatchObject({
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 16,
|
||||
});
|
||||
expect(alert.xs.container.paddingVertical).toBeLessThan(alert.sm.container.paddingVertical);
|
||||
expect(alert.md.container.paddingVertical).toBeGreaterThan(alert.sm.container.paddingVertical);
|
||||
expect(alert.lg.container.paddingVertical).toBeGreaterThan(alert.md.container.paddingVertical);
|
||||
for (const size of ["xs", "sm", "md", "lg"] as const) {
|
||||
expect(alert[size].container.borderRadius).toBeGreaterThanOrEqual(12);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,77 @@ function fieldVerticalPadding(
|
||||
return (controlHeight - lineHeight - borderWidth * 2) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alerts share the button's size names, text size, and icon size (`buttonIconSize`).
|
||||
* The icon and the first text sit in one centered lead row; everything below is indented
|
||||
* by the icon slot plus its gap so it starts at the lead text's left edge. Title and
|
||||
* description use one font size; only the container gap separates them.
|
||||
*/
|
||||
function createAlertGeometry(theme: Theme) {
|
||||
function alertSize(input: {
|
||||
size: ButtonControlSize;
|
||||
fontSize: number;
|
||||
paddingVertical: number;
|
||||
paddingHorizontal: number;
|
||||
borderRadius: number;
|
||||
iconGap: number;
|
||||
textGap: number;
|
||||
}) {
|
||||
const iconSize = buttonIconSize[input.size];
|
||||
return {
|
||||
container: {
|
||||
paddingVertical: input.paddingVertical,
|
||||
paddingHorizontal: input.paddingHorizontal,
|
||||
borderRadius: input.borderRadius,
|
||||
gap: input.textGap,
|
||||
},
|
||||
lead: { gap: input.iconGap },
|
||||
iconSlot: { width: iconSize },
|
||||
indent: { marginLeft: iconSize + input.iconGap },
|
||||
text: { fontSize: input.fontSize },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
xs: alertSize({
|
||||
size: "xs",
|
||||
fontSize: theme.fontSize.sm,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
iconGap: theme.spacing[2],
|
||||
textGap: 0,
|
||||
}),
|
||||
sm: alertSize({
|
||||
size: "sm",
|
||||
fontSize: theme.fontSize.base,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
iconGap: theme.spacing[3],
|
||||
textGap: theme.spacing[0.5],
|
||||
}),
|
||||
md: alertSize({
|
||||
size: "md",
|
||||
fontSize: theme.fontSize.base,
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
iconGap: theme.spacing[3],
|
||||
textGap: theme.spacing[0.5],
|
||||
}),
|
||||
lg: alertSize({
|
||||
size: "lg",
|
||||
fontSize: theme.fontSize.base,
|
||||
paddingVertical: theme.spacing[6],
|
||||
paddingHorizontal: theme.spacing[8],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
iconGap: theme.spacing[4],
|
||||
textGap: theme.spacing[1],
|
||||
}),
|
||||
} satisfies Record<ButtonControlSize, unknown>;
|
||||
}
|
||||
|
||||
export function getControlInteractionPhase(
|
||||
state: ControlInteractionState,
|
||||
): ControlInteractionPhase {
|
||||
@@ -147,6 +218,7 @@ export function createControlGeometry(theme: Theme) {
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: fieldTextMdLineHeight,
|
||||
};
|
||||
const alert = createAlertGeometry(theme);
|
||||
const switchControl = {
|
||||
minHeight: CONTROL_HEIGHTS.compact,
|
||||
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
|
||||
@@ -179,6 +251,7 @@ export function createControlGeometry(theme: Theme) {
|
||||
buttonTextXs: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
alert,
|
||||
formTextInputSm: {
|
||||
...fieldControlSm,
|
||||
...fieldTextSm,
|
||||
|
||||
@@ -131,6 +131,7 @@ export function DesktopNotificationsSection() {
|
||||
</View>
|
||||
{testNotificationState.status === "success" ? (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="success"
|
||||
title={t("settings.notifications.sentTitle")}
|
||||
description={t("settings.notifications.sentDescription")}
|
||||
@@ -139,6 +140,7 @@ export function DesktopNotificationsSection() {
|
||||
) : null}
|
||||
{testNotificationState.status === "error" ? (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={t("settings.notifications.sendFailedTitle")}
|
||||
description={testNotificationState.message}
|
||||
|
||||
@@ -169,7 +169,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Alert variant="error" description={message}>
|
||||
<Alert size="sm" variant="error" description={message}>
|
||||
<Button variant="outline" size="sm" leftIcon={RotateCw} onPress={onRetry}>
|
||||
{t("pairing.device.retry")}
|
||||
</Button>
|
||||
@@ -197,9 +197,11 @@ function RelayConsent(props: PairDeviceBodyProps) {
|
||||
accessibilityLabel={t("pairing.device.relayDocsAccessibility")}
|
||||
/>
|
||||
</View>
|
||||
{props.enableError ? <Alert variant="error" description={props.enableError.message} /> : null}
|
||||
{props.enableError ? (
|
||||
<Alert size="sm" variant="error" description={props.enableError.message} />
|
||||
) : null}
|
||||
{!props.canConfigureRelay ? (
|
||||
<Alert variant="warning" description={t("pairing.device.updateRequired")} />
|
||||
<Alert size="sm" variant="warning" description={t("pairing.device.updateRequired")} />
|
||||
) : null}
|
||||
<View style={styles.actions}>
|
||||
<Button variant="secondary" style={FLEX_ONE_STYLE} onPress={props.onClose}>
|
||||
@@ -262,7 +264,7 @@ function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
|
||||
{props.copied ? t("pairing.device.copied") : t("pairing.device.copy")}
|
||||
</Button>
|
||||
</View>
|
||||
<Alert variant="warning" description={t("pairing.device.securityWarning")} />
|
||||
<Alert size="sm" variant="warning" description={t("pairing.device.securityWarning")} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { HostConnection } from "@/types/host-connection";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
|
||||
function connectionListen(connection: HostConnection): string | null {
|
||||
switch (connection.type) {
|
||||
case "directTcp":
|
||||
return connection.endpoint;
|
||||
case "directSocket":
|
||||
return `unix://${connection.path}`;
|
||||
case "directPipe":
|
||||
return `pipe://${connection.path}`;
|
||||
case "remoteSsh":
|
||||
case "relay":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop main checks that this target matches the live desktop-managed daemon.
|
||||
// A plain browser has no bridge and falls back to its saved host password.
|
||||
export async function readDesktopManagedLocalCredential(
|
||||
connection: HostConnection,
|
||||
invoke: (listen: string) => Promise<unknown> = async (listen) =>
|
||||
getDesktopHost()?.invoke?.("desktop_local_credential", { listen }),
|
||||
): Promise<string | undefined> {
|
||||
const listen = connectionListen(connection);
|
||||
if (!listen) return undefined;
|
||||
const credential = await invoke(listen);
|
||||
return typeof credential === "string" ? credential : undefined;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { defaultHostAppearance } from "@/hosts/appearance";
|
||||
function makeHost(): HostProfile {
|
||||
return {
|
||||
serverId: "srv-secret",
|
||||
password: "tcp-password",
|
||||
label: "Secret host",
|
||||
appearance: defaultHostAppearance(),
|
||||
lifecycle: {},
|
||||
@@ -23,7 +24,6 @@ function makeHost(): HostProfile {
|
||||
type: "directTcp",
|
||||
endpoint: "secret.example.test:6767",
|
||||
useTls: true,
|
||||
password: "tcp-password",
|
||||
},
|
||||
{
|
||||
id: "relay:relay.secret.test:443",
|
||||
|
||||
@@ -125,11 +125,11 @@ export function redactAppDiagnosticReport(report: string, hosts: HostProfile[]):
|
||||
function collectSensitiveHostValues(hosts: HostProfile[]): string[] {
|
||||
const values = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
if (host.password) values.add(host.password);
|
||||
for (const connection of host.connections) {
|
||||
values.add(connection.id);
|
||||
if (connection.type === "directTcp") {
|
||||
values.add(connection.endpoint);
|
||||
if (connection.password) values.add(connection.password);
|
||||
} else if (connection.type === "relay") {
|
||||
values.add(connection.relayEndpoint);
|
||||
values.add(connection.daemonPublicKeyB64);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
resolveWorkingDiffComparison,
|
||||
selectWorkingDiffComparison,
|
||||
} from "@/git/working-diff-comparison";
|
||||
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
|
||||
import {
|
||||
applyCheckoutStatusUpdateFromEvent,
|
||||
ensureCheckoutStatus,
|
||||
@@ -197,6 +198,51 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("drops the checkout's cached draft slash commands when its branch changes", () => {
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(
|
||||
checkoutStatusQueryKey(serverId, cwd),
|
||||
checkoutStatus({ currentBranch: "chore/build-paseo" }),
|
||||
);
|
||||
const thisCheckout = draftAgentCommandsQueryKey({
|
||||
serverId,
|
||||
draftConfig: { provider: "claude", cwd, model: "haiku" },
|
||||
});
|
||||
const otherCheckout = draftAgentCommandsQueryKey({
|
||||
serverId,
|
||||
draftConfig: { provider: "claude", cwd: "/repo2", model: "haiku" },
|
||||
});
|
||||
queryClient.setQueryData(thisCheckout, [{ name: "build-paseo" }]);
|
||||
queryClient.setQueryData(otherCheckout, [{ name: "build-paseo" }]);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus({ currentBranch: "main" })),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData(thisCheckout)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(otherCheckout)).toEqual([{ name: "build-paseo" }]);
|
||||
});
|
||||
|
||||
it("keeps the checkout's draft slash commands when a push leaves its branch unchanged", () => {
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), checkoutStatus());
|
||||
const thisCheckout = draftAgentCommandsQueryKey({
|
||||
serverId,
|
||||
draftConfig: { provider: "claude", cwd, model: "haiku" },
|
||||
});
|
||||
queryClient.setQueryData(thisCheckout, [{ name: "build-paseo" }]);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData(thisCheckout)).toEqual([{ name: "build-paseo" }]);
|
||||
});
|
||||
|
||||
it("writes the PR status cache when prStatus is present, and skips it otherwise", () => {
|
||||
const queryClient = createQueryClient();
|
||||
const pushedPr = prStatus({ requestId: "pr-1" });
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
invalidatePrPaneTimelineForCheckout,
|
||||
} from "@/git/query-keys";
|
||||
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query";
|
||||
import { expireWorkingDiffComparisons } from "@/git/working-diff-comparison";
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
@@ -67,7 +68,13 @@ export function applyCheckoutStatusUpdateFromEvent({
|
||||
? normalizeCheckoutPrStatusPayload(payload.prStatus)
|
||||
: undefined;
|
||||
const cachePayload = prStatus ? { ...payload, prStatus } : payload;
|
||||
const previousStatus = queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(serverId, payload.cwd),
|
||||
);
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload);
|
||||
if (previousStatus?.currentBranch !== payload.currentBranch) {
|
||||
void resetDraftAgentCommandsForCheckout(queryClient, { serverId, cwd: payload.cwd });
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
|
||||
});
|
||||
|
||||
@@ -38,9 +38,10 @@ describe("agent command query keys", () => {
|
||||
"agentCommands",
|
||||
"server-1",
|
||||
"draft",
|
||||
"codex",
|
||||
"cwd",
|
||||
"/repo",
|
||||
"provider",
|
||||
"codex",
|
||||
"mode",
|
||||
null,
|
||||
"model",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
@@ -24,17 +25,24 @@ export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId:
|
||||
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
|
||||
}
|
||||
|
||||
function draftAgentCommandsCheckoutScope(input: { serverId: string; cwd: string }) {
|
||||
return [
|
||||
...agentCommandsQueryRoot(input.serverId),
|
||||
"draft",
|
||||
"cwd",
|
||||
normalizeAgentCommandsCwd(input.cwd),
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function draftAgentCommandsQueryKey(input: {
|
||||
serverId: string;
|
||||
draftConfig: AgentCommandsDraftConfig;
|
||||
}) {
|
||||
const { draftConfig } = input;
|
||||
return [
|
||||
...agentCommandsQueryRoot(input.serverId),
|
||||
"draft",
|
||||
...draftAgentCommandsCheckoutScope({ serverId: input.serverId, cwd: draftConfig.cwd }),
|
||||
"provider",
|
||||
draftConfig.provider,
|
||||
"cwd",
|
||||
normalizeAgentCommandsCwd(draftConfig.cwd),
|
||||
"mode",
|
||||
draftConfig.modeId ?? null,
|
||||
"model",
|
||||
@@ -46,6 +54,16 @@ export function draftAgentCommandsQueryKey(input: {
|
||||
] as const;
|
||||
}
|
||||
|
||||
// Draft commands include project skills read from the checkout. When the checkout moves to
|
||||
// another branch the cached list describes files that are gone, so it is dropped rather than
|
||||
// shown while a refetch runs.
|
||||
export function resetDraftAgentCommandsForCheckout(
|
||||
queryClient: QueryClient,
|
||||
input: { serverId: string; cwd: string },
|
||||
): Promise<void> {
|
||||
return queryClient.resetQueries({ queryKey: draftAgentCommandsCheckoutScope(input) });
|
||||
}
|
||||
|
||||
export function agentCommandsQueryKey(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
|
||||
@@ -1613,6 +1613,10 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "كلمة المرور لـ {{host}}",
|
||||
label: "كلمة مرور المضيف",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "إضافة اتصال",
|
||||
direct: {
|
||||
@@ -2294,6 +2298,9 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance: "أزل هذا المضيف ثم أضفه مرة أخرى بكلمة المرور التي يطلبها هذا الخادم.",
|
||||
},
|
||||
appearance: {
|
||||
title: "المظهر",
|
||||
name: {
|
||||
|
||||
@@ -1638,6 +1638,10 @@ export const en = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "Password for {{host}}",
|
||||
label: "Host password",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Add connection",
|
||||
direct: {
|
||||
@@ -2413,6 +2417,9 @@ export const en = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance: "Remove this host and add it again with the password this daemon asks for.",
|
||||
},
|
||||
appearance: {
|
||||
title: "Appearance",
|
||||
name: {
|
||||
|
||||
@@ -1659,6 +1659,10 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "Contraseña de {{host}}",
|
||||
label: "Contraseña del host",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Agregar conexión",
|
||||
direct: {
|
||||
@@ -2348,6 +2352,9 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance: "Elimina este host y vuelve a añadirlo con la contraseña que pide este daemon.",
|
||||
},
|
||||
appearance: {
|
||||
title: "Apariencia",
|
||||
name: {
|
||||
|
||||
@@ -1663,6 +1663,10 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "Mot de passe pour {{host}}",
|
||||
label: "Mot de passe de l’hôte",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Ajouter une connexion",
|
||||
direct: {
|
||||
@@ -2352,6 +2356,10 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance:
|
||||
"Supprimez cet hôte puis ajoutez-le à nouveau avec le mot de passe demandé par ce daemon.",
|
||||
},
|
||||
appearance: {
|
||||
title: "Apparence",
|
||||
name: {
|
||||
|
||||
@@ -1630,6 +1630,10 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "{{host}} のパスワード",
|
||||
label: "ホストのパスワード",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "接続を追加",
|
||||
direct: {
|
||||
@@ -2315,6 +2319,10 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance:
|
||||
"このホストを削除し、このデーモンが求めるパスワードを入力して追加し直してください。",
|
||||
},
|
||||
appearance: {
|
||||
title: "外観",
|
||||
name: {
|
||||
|
||||
@@ -1623,6 +1623,10 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "{{host}}의 비밀번호",
|
||||
label: "호스트 비밀번호",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "연결 추가",
|
||||
direct: {
|
||||
@@ -2304,6 +2308,9 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance: "이 호스트를 제거한 뒤 이 데몬이 요구하는 비밀번호로 다시 추가하세요.",
|
||||
},
|
||||
appearance: {
|
||||
title: "모양",
|
||||
name: {
|
||||
|
||||
@@ -1644,6 +1644,10 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "Senha de {{host}}",
|
||||
label: "Senha do host",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Adicionar conexão",
|
||||
direct: {
|
||||
@@ -2330,6 +2334,9 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance: "Remova este host e adicione-o novamente com a senha que este daemon pede.",
|
||||
},
|
||||
appearance: {
|
||||
title: "Aparência",
|
||||
name: {
|
||||
|
||||
@@ -1642,6 +1642,10 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "Пароль для {{host}}",
|
||||
label: "Пароль хоста",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Добавить подключение",
|
||||
direct: {
|
||||
@@ -2334,6 +2338,10 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance:
|
||||
"Удалите этот хост и добавьте его снова с паролем, который запрашивает этот демон.",
|
||||
},
|
||||
appearance: {
|
||||
title: "Оформление",
|
||||
name: {
|
||||
|
||||
@@ -1596,6 +1596,10 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
hostPassword: {
|
||||
title: "{{host}} 的密码",
|
||||
label: "主机密码",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "添加连接",
|
||||
direct: {
|
||||
@@ -2268,6 +2272,9 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
host: {
|
||||
password: {
|
||||
guidance: "移除此主机,然后使用此守护进程要求的密码重新添加。",
|
||||
},
|
||||
appearance: {
|
||||
title: "外观",
|
||||
name: {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import type { DaemonStartResult, StartDaemonIfEnabledInput } from "@/runtime/daemon-start-service";
|
||||
|
||||
describe("startHostRuntimeBootstrap", () => {
|
||||
it("boots the host registry and starts the managed-daemon decision as one operation", async () => {
|
||||
it("boots the host registry and starts the managed-daemon decision independently", async () => {
|
||||
const events: string[] = [];
|
||||
const shouldStartDaemon = async () => true;
|
||||
const store = {
|
||||
@@ -40,9 +40,10 @@ describe("startHostRuntimeBootstrap", () => {
|
||||
|
||||
expect(events).toEqual(["boot", "daemon-start-decision"]);
|
||||
expect(await receivedDecisions[0]).toBe(true);
|
||||
expect(events).toEqual(["boot", "daemon-start-decision"]);
|
||||
});
|
||||
|
||||
it("waits for the host registry to load before evaluating managed-daemon startup", async () => {
|
||||
it("loads the registry before evaluating managed-daemon startup", async () => {
|
||||
const events: string[] = [];
|
||||
let resolveBoot!: () => void;
|
||||
const booted = new Promise<void>((resolve) => {
|
||||
|
||||
@@ -145,6 +145,7 @@ function BlockedSetupNotice({
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="warning"
|
||||
title={t("workspace.setup.blocked.title")}
|
||||
description={t("workspace.setup.blocked.description", {
|
||||
@@ -158,7 +159,7 @@ function BlockedSetupNotice({
|
||||
</Button>
|
||||
) : null}
|
||||
</Alert>
|
||||
{runError ? <Alert variant="error" description={runError} /> : null}
|
||||
{runError ? <Alert size="sm" variant="error" description={runError} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,12 @@ function ProviderUsageBody({
|
||||
|
||||
if (view.kind === "error") {
|
||||
return (
|
||||
<Alert variant="error" title={providerUsageCopy.errorTitle} description={view.message}>
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={providerUsageCopy.errorTitle}
|
||||
description={view.message}
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={onRefresh}>
|
||||
{providerUsageCopy.retry}
|
||||
</Button>
|
||||
|
||||
@@ -325,6 +325,15 @@ describe("upsertDesktopDaemonConnection", () => {
|
||||
expect(fake.upserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps an already registered host without waiting for a listen address", async () => {
|
||||
const fake = createFakeStore([makeRelayOnlyHost("srv_desktop")]);
|
||||
|
||||
const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ listen: null }));
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fake.upserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a missing listen address without upserting", async () => {
|
||||
const fake = createFakeStore();
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ export async function upsertDesktopDaemonConnection(
|
||||
if (store.getHosts().some((host) => host.serverId === serverId)) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const listenAddress = daemon.listen?.trim() ?? "";
|
||||
if (!listenAddress) {
|
||||
return { ok: false, error: "Desktop daemon did not return a listen address." };
|
||||
|
||||
@@ -44,6 +44,7 @@ class FakeDirectoryClient {
|
||||
projectResult: ProjectListResult | null = null;
|
||||
private pendingAgentFetch: Promise<AgentFetchResult> | null = null;
|
||||
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
|
||||
private pendingProjectFetch: Promise<ProjectListResult> | null = null;
|
||||
private readonly handlers = new Map<
|
||||
SessionOutboundMessage["type"],
|
||||
Set<(message: SessionOutboundMessage) => void>
|
||||
@@ -82,6 +83,14 @@ class FakeDirectoryClient {
|
||||
return complete;
|
||||
}
|
||||
|
||||
holdProjectFetch(): (result: ProjectListResult) => void {
|
||||
let complete!: (result: ProjectListResult) => void;
|
||||
this.pendingProjectFetch = new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
});
|
||||
return complete;
|
||||
}
|
||||
|
||||
async fetchAgents(options?: unknown): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
|
||||
this.fetchAgentsCalls += 1;
|
||||
this.lastAgentOptions = options;
|
||||
@@ -116,6 +125,11 @@ class FakeDirectoryClient {
|
||||
async listProjects(options?: unknown): Promise<ProjectListResult> {
|
||||
this.listProjectsCalls += 1;
|
||||
this.lastProjectOptions = options;
|
||||
if (this.pendingProjectFetch) {
|
||||
const pending = this.pendingProjectFetch;
|
||||
this.pendingProjectFetch = null;
|
||||
return pending;
|
||||
}
|
||||
if (this.projectResult) return this.projectResult;
|
||||
return {
|
||||
requestId: "projects",
|
||||
@@ -227,6 +241,25 @@ function createAgent(serverId: string, id: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspaceEntry(id: string): WorkspaceFetchResult["entries"][number] {
|
||||
return {
|
||||
id,
|
||||
projectId: "snapshot-project",
|
||||
projectDisplayName: "Snapshot project",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: `/repo/${id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: id,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId);
|
||||
serverIds.clear();
|
||||
@@ -1115,6 +1148,41 @@ describe("DirectorySync session readiness", () => {
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("keeps the accepted baseline when an old connection finishes its workspace request", async () => {
|
||||
const serverId = "superseded-workspace-baseline";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true, directorySync: true },
|
||||
});
|
||||
directory.acceptWorkspaces([normalizeWorkspaceDescriptor(createWorkspaceEntry("retained"))]);
|
||||
const release = client.holdWorkspaceFetch();
|
||||
const refresh = directory.refreshWorkspaces();
|
||||
const rejected = expect(refresh).rejects.toBeInstanceOf(DirectoryRefreshSupersededError);
|
||||
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
|
||||
directory.connectionChanged({
|
||||
client: client as unknown as DaemonClient,
|
||||
status: "online",
|
||||
source: { clientGeneration: 1, connectionEpoch: 2 },
|
||||
});
|
||||
release({
|
||||
requestId: "old-empty-snapshot",
|
||||
entries: [],
|
||||
emptyProjects: [],
|
||||
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
||||
sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] },
|
||||
});
|
||||
await rejected;
|
||||
expect([...useSessionStore.getState().sessions[serverId].workspaces.keys()]).toEqual([
|
||||
"retained",
|
||||
]);
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("rejects a session wait on disconnect so the reconnect can refresh", async () => {
|
||||
const serverId = "session-wait-reconnect";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
@@ -1182,9 +1250,19 @@ describe("DirectorySync session readiness", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
client.emit({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: { ...createWorkspaceEntry("updated-workspace"), name: "Live name" },
|
||||
},
|
||||
});
|
||||
completeFetch({
|
||||
requestId: "workspaces",
|
||||
entries: [],
|
||||
entries: [
|
||||
createWorkspaceEntry("removed-workspace"),
|
||||
createWorkspaceEntry("updated-workspace"),
|
||||
],
|
||||
emptyProjects: [
|
||||
{
|
||||
projectId: "snapshot-project",
|
||||
@@ -1207,6 +1285,11 @@ describe("DirectorySync session readiness", () => {
|
||||
expect(projects?.get("workspace-project")).toMatchObject({
|
||||
projectDisplayName: "Project from workspace update",
|
||||
});
|
||||
expect(
|
||||
[...useSessionStore.getState().sessions[serverId].workspaces.values()].map(
|
||||
({ id, name }) => ({ id, name }),
|
||||
),
|
||||
).toEqual([{ id: "updated-workspace", name: "Live name" }]);
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
@@ -1278,6 +1361,8 @@ function createSqliteCache() {
|
||||
return {
|
||||
cache,
|
||||
database,
|
||||
storage,
|
||||
reopen: () => new ReplicaCache(storage, { clearLegacyCache: async () => {} }),
|
||||
holdRead: (operation: () => Promise<void>) => {
|
||||
beforeRead = operation;
|
||||
},
|
||||
@@ -1473,3 +1558,198 @@ describe("DirectorySync workspace labels", () => {
|
||||
directory.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["before", "during", "before-metadata", "damaged"] as const)(
|
||||
"retains unchanged workspaces across route/full demand and restart (cache: %s)",
|
||||
async (timing) => {
|
||||
const cacheFirst = timing === "before";
|
||||
const damaged = timing === "damaged";
|
||||
const serverId = `cache-route-overlap-${timing}`;
|
||||
serverIds.add(serverId);
|
||||
const { cache, database, reopen, storage } = createSqliteCache();
|
||||
cache.setHosts([serverId]);
|
||||
const projectEntry: ProjectListResult["projects"][number] = {
|
||||
projectId: "P",
|
||||
projectDisplayName: "Project P",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
};
|
||||
const project = normalizeProjectDescriptor(projectEntry);
|
||||
const workspaceEntries: WorkspaceFetchResult["entries"] = ["A", "B"].map((id) => ({
|
||||
id,
|
||||
projectId: "P",
|
||||
projectDisplayName: "Project P",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: `/repo/${id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: id,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
}));
|
||||
const workspaces = workspaceEntries.map(normalizeWorkspaceDescriptor);
|
||||
const cursor = { generation: "g", afterSeq: 7 };
|
||||
cache.replaceDirectoryBaseline(serverId, {
|
||||
agents: new Map(),
|
||||
projects: new Map([[project.projectId, project]]),
|
||||
workspaces: new Map(
|
||||
(damaged ? [] : workspaces).map((workspace) => [workspace.id, workspace]),
|
||||
),
|
||||
checkpoint: { projects: cursor, workspaces: cursor, agents: cursor },
|
||||
});
|
||||
await cache.flush();
|
||||
if (damaged) {
|
||||
await storage.apply({
|
||||
deletes: [],
|
||||
upserts: [
|
||||
{
|
||||
serverId,
|
||||
kind: "checkpoint",
|
||||
id: "singleton",
|
||||
payload: JSON.stringify({ projects: cursor, workspaces: cursor, agents: cursor }),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// The host keeps A/B unchanged. A current cursor legitimately receives no rows.
|
||||
class UnchangedDirectoryClient extends FakeDirectoryClient {
|
||||
readonly workspaceSyncRequests: unknown[] = [];
|
||||
override async fetchWorkspaces(
|
||||
options?: Parameters<DaemonClient["fetchWorkspaces"]>[0],
|
||||
): Promise<WorkspaceFetchResult> {
|
||||
this.fetchWorkspacesCalls += 1;
|
||||
this.lastWorkspaceOptions = options;
|
||||
this.workspaceSyncRequests.push(options?.sync);
|
||||
const changes = options?.sync?.generation === "g" && options.sync.afterSeq === 7;
|
||||
return {
|
||||
requestId: "workspaces",
|
||||
entries: changes ? [] : [workspaceEntries[options?.page?.cursor ? 1 : 0]],
|
||||
emptyProjects: [],
|
||||
pageInfo: {
|
||||
hasMore: !changes && !options?.page?.cursor,
|
||||
nextCursor: !changes && !options?.page?.cursor ? "page-2" : null,
|
||||
prevCursor: null,
|
||||
},
|
||||
sync: {
|
||||
generation: "g",
|
||||
headSeq: 7,
|
||||
mode: changes ? "changes" : "snapshot",
|
||||
removals: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const client = new UnchangedDirectoryClient();
|
||||
const releaseAgents = client.holdAgentFetch();
|
||||
const releaseProjects = client.holdProjectFetch();
|
||||
const projectResult: ProjectListResult = {
|
||||
requestId: "projects",
|
||||
projects: [projectEntry],
|
||||
sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] },
|
||||
};
|
||||
client.projectResult = projectResult;
|
||||
const callbacks = {
|
||||
onAgentStoppedRunning: () => {},
|
||||
markAgentLoading: () => {},
|
||||
markAgentReady: () => {},
|
||||
markAgentError: () => {},
|
||||
};
|
||||
const directory = new DirectorySync(serverId, callbacks, cache);
|
||||
const publishServerInfo = () => {
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true, directorySync: true, projectList: true },
|
||||
});
|
||||
};
|
||||
const initializeSession = (ready = true) => {
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
if (ready) publishServerInfo();
|
||||
};
|
||||
const connection = {
|
||||
client: client as unknown as DaemonClient,
|
||||
status: "online" as const,
|
||||
source: { clientGeneration: 1, connectionEpoch: 1 },
|
||||
};
|
||||
initializeSession(timing !== "before-metadata");
|
||||
directory.connectionChanged(connection);
|
||||
const visibleIds = () =>
|
||||
[...useSessionStore.getState().sessions[serverId].workspaces.keys()].sort();
|
||||
let restarted: DirectorySync | undefined;
|
||||
const restartedCache = reopen();
|
||||
try {
|
||||
if (cacheFirst) await directory.restoreCachedDirectory();
|
||||
directory.setAgentRouteDemand(["agent"]);
|
||||
if (timing === "before-metadata") {
|
||||
await directory.restoreCachedDirectory();
|
||||
publishServerInfo();
|
||||
}
|
||||
await expect.poll(() => client.listProjectsCalls).toBe(1);
|
||||
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
|
||||
directory.setDemand({}, true);
|
||||
await directory.restoreCachedDirectory();
|
||||
expect(visibleIds()).toEqual(damaged ? [] : ["A", "B"]);
|
||||
|
||||
releaseProjects(projectResult);
|
||||
releaseAgents({
|
||||
requestId: "agents",
|
||||
entries: [],
|
||||
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
||||
sync: {
|
||||
generation: "g",
|
||||
headSeq: 7,
|
||||
mode: cacheFirst ? "changes" : "snapshot",
|
||||
removals: [],
|
||||
},
|
||||
});
|
||||
await directory.refreshDemand();
|
||||
expect(client.lastProjectOptions).toEqual({ sync: cacheFirst ? cursor : {} });
|
||||
expect(client.workspaceSyncRequests).toEqual(cacheFirst ? [cursor] : [{}, {}]);
|
||||
expect.soft(visibleIds(), "after overlapping refresh").toEqual(["A", "B"]);
|
||||
await cache.flush();
|
||||
expect([...(await cache.readDirectory(serverId)).workspaces.keys()].sort()).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
]);
|
||||
|
||||
await directory.refreshAll();
|
||||
await cache.flush();
|
||||
directory.dispose();
|
||||
useSessionStore.getState().clearSession(serverId);
|
||||
restartedCache.setHosts([serverId]);
|
||||
const persisted = await restartedCache.readDirectory(serverId);
|
||||
expect(persisted.checkpoint?.workspaces).toEqual(cursor);
|
||||
expect
|
||||
.soft([...persisted.workspaces.keys()].sort(), "persisted baseline")
|
||||
.toEqual(["A", "B"]);
|
||||
initializeSession();
|
||||
restarted = new DirectorySync(serverId, callbacks, restartedCache);
|
||||
restarted.connectionChanged(connection);
|
||||
await restarted.refreshAll();
|
||||
expect.soft(visibleIds(), "after restart and refresh").toEqual(["A", "B"]);
|
||||
const beforeUpdate = visibleIds();
|
||||
client.emit({
|
||||
type: "workspace_update",
|
||||
payload: { kind: "upsert", workspace: workspaceEntries[0], generation: "g", seq: 8 },
|
||||
});
|
||||
expect(visibleIds()).toEqual([...new Set([...beforeUpdate, "A"])].sort());
|
||||
client.emit({
|
||||
type: "workspace_update",
|
||||
payload: { kind: "upsert", workspace: workspaceEntries[1], generation: "g", seq: 9 },
|
||||
});
|
||||
expect(visibleIds()).toEqual(["A", "B"]);
|
||||
} finally {
|
||||
directory.dispose();
|
||||
restarted?.dispose();
|
||||
await cache.flush();
|
||||
await restartedCache.flush();
|
||||
database.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -61,6 +61,10 @@ interface AgentSnapshot {
|
||||
syncRemovals: Array<{ id: string; seq: number }>;
|
||||
}
|
||||
|
||||
interface WorkspaceRefreshSnapshot extends WorkspaceDirectorySnapshot {
|
||||
readonly requestCursors: Readonly<Pick<DirectoryCheckpoint, "projects" | "workspaces">>;
|
||||
}
|
||||
|
||||
interface AgentPageInfo {
|
||||
hasMore?: boolean;
|
||||
hasMoreAfter?: boolean;
|
||||
@@ -133,7 +137,7 @@ export class DirectorySync {
|
||||
AgentDirectoryDelta
|
||||
>();
|
||||
private readonly workspaceTransactions = new DirectoryTransactionOwner<
|
||||
WorkspaceDirectorySnapshot,
|
||||
WorkspaceRefreshSnapshot,
|
||||
WorkspaceDirectoryDelta
|
||||
>();
|
||||
private readonly agents: AgentDirectoryReplica;
|
||||
@@ -478,14 +482,20 @@ export class DirectorySync {
|
||||
const onlineConnection = this.getOnlineConnection();
|
||||
if (!onlineConnection) return;
|
||||
const { client, source } = onlineConnection;
|
||||
const transaction = this.workspaceTransactions.begin(source, () => ({
|
||||
workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces),
|
||||
projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects),
|
||||
syncCursors: {},
|
||||
syncModes: {},
|
||||
touchedWorkspaceIds: new Set(),
|
||||
touchedProjectIds: new Set(),
|
||||
}));
|
||||
const transaction = this.workspaceTransactions.begin(source, () => {
|
||||
const baseline = this.workspaces.snapshot();
|
||||
return {
|
||||
workspaces: new Map(baseline.workspaces),
|
||||
projects: new Map(baseline.projects),
|
||||
// Cache hydration can advance the live replica while these requests await responses.
|
||||
// Every page must use the cursors belonging to the maps captured here.
|
||||
requestCursors: { ...this.cursors },
|
||||
syncCursors: {},
|
||||
syncModes: {},
|
||||
touchedWorkspaceIds: new Set(),
|
||||
touchedProjectIds: new Set(),
|
||||
};
|
||||
});
|
||||
try {
|
||||
await this.waitForSessionMetadata(client, source);
|
||||
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
|
||||
@@ -517,7 +527,7 @@ export class DirectorySync {
|
||||
private async fetchWorkspaceSnapshot(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
initialSubscribe: boolean,
|
||||
supportsDirectorySync: boolean,
|
||||
): Promise<void> {
|
||||
@@ -527,7 +537,9 @@ export class DirectorySync {
|
||||
const query: Parameters<DaemonClient["observeWorkspaces"]>[0] = {
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
|
||||
...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}),
|
||||
...(supportsDirectorySync
|
||||
? { sync: transaction.snapshot.requestCursors.workspaces ?? {} }
|
||||
: {}),
|
||||
};
|
||||
let payload: FetchWorkspacesPayload;
|
||||
if (subscribe) {
|
||||
@@ -667,7 +679,7 @@ export class DirectorySync {
|
||||
private assertWorkspaceTransactionCurrent(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
): void {
|
||||
if (!this.workspaceTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) {
|
||||
throw new DirectoryRefreshSupersededError("workspace fetch no longer current");
|
||||
@@ -724,11 +736,13 @@ export class DirectorySync {
|
||||
private async fetchProjectSnapshot(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
supportsDirectorySync: boolean,
|
||||
): Promise<void> {
|
||||
const payload = await client.listProjects(
|
||||
supportsDirectorySync ? { sync: this.readCursors().projects ?? {} } : undefined,
|
||||
supportsDirectorySync
|
||||
? { sync: transaction.snapshot.requestCursors.projects ?? {} }
|
||||
: undefined,
|
||||
);
|
||||
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
||||
if (payload.sync?.mode !== "changes") transaction.snapshot.projects.clear();
|
||||
@@ -753,7 +767,7 @@ export class DirectorySync {
|
||||
private completeWorkspaceRefresh(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
): void {
|
||||
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
|
||||
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AppStateStatus } from "react-native";
|
||||
import { bindHostRuntimeAppState } from "@/navigation/host-runtime-bootstrap";
|
||||
import {
|
||||
bindHostRuntimeAppState,
|
||||
startHostRuntimeBootstrap,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import type {
|
||||
DaemonClient,
|
||||
ConnectionState,
|
||||
FetchAgentsEntry,
|
||||
FetchAgentsOptions,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
@@ -26,11 +30,31 @@ import {
|
||||
import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
|
||||
|
||||
import { subscriptionFixture } from "./subscription-fixture";
|
||||
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
|
||||
|
||||
it("requests the managed connection credential through desktop main without a web hint", async () => {
|
||||
const requests: string[] = [];
|
||||
const connection: HostConnection = {
|
||||
id: "direct:localhost:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "localhost:6767",
|
||||
};
|
||||
let token = "local-token";
|
||||
const invoke = async (listen: string) => {
|
||||
requests.push(listen);
|
||||
return token;
|
||||
};
|
||||
expect(await readDesktopManagedLocalCredential(connection, invoke)).toBe("local-token");
|
||||
token = "rotated-token";
|
||||
expect(await readDesktopManagedLocalCredential(connection, invoke)).toBe("rotated-token");
|
||||
expect(requests).toEqual(["localhost:6767", "localhost:6767"]);
|
||||
});
|
||||
|
||||
class FakeDaemonClient {
|
||||
private state: ConnectionState = { status: "idle" };
|
||||
private listeners = new Set<(status: ConnectionState) => void>();
|
||||
private error: string | null = null;
|
||||
authFailureReason: "password_required" | "incorrect_password" | null = null;
|
||||
private heartbeatRttMs: number | null = null;
|
||||
private latencyMeasurementFailure: Error | null = null;
|
||||
private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = [];
|
||||
@@ -381,6 +405,7 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
|
||||
|
||||
return {
|
||||
serverId: input?.serverId ?? "srv_test",
|
||||
...(input?.password ? { password: input.password } : {}),
|
||||
label: input?.label ?? "test host",
|
||||
appearance: input?.appearance ?? defaultHostAppearance(),
|
||||
lifecycle: input?.lifecycle ?? {},
|
||||
@@ -559,6 +584,21 @@ function onceHostListMatches(store: HostRuntimeStore, predicate: () => boolean):
|
||||
});
|
||||
}
|
||||
|
||||
function onceHostSnapshotMatches(
|
||||
store: HostRuntimeStore,
|
||||
serverId: string,
|
||||
predicate: (snapshot: ReturnType<HostRuntimeStore["getSnapshot"]>) => boolean,
|
||||
): Promise<void> {
|
||||
if (predicate(store.getSnapshot(serverId))) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const unsubscribe = store.subscribe(serverId, () => {
|
||||
if (!predicate(store.getSnapshot(serverId))) return;
|
||||
unsubscribe();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class BrowserClientLifecycle {
|
||||
public active: Array<{ serverId: string; connectionId: string }> = [];
|
||||
|
||||
@@ -3350,7 +3390,7 @@ describe("HostRuntimeStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("upsertDirectConnection stores SSL and password settings", async () => {
|
||||
it("upsertDirectConnection stores SSL on the connection and password on the host", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
@@ -3378,9 +3418,9 @@ describe("HostRuntimeStore", () => {
|
||||
type: "directTcp",
|
||||
endpoint: "example.paseo.test:7443",
|
||||
useTls: true,
|
||||
password: "shared-secret",
|
||||
},
|
||||
]);
|
||||
expect(host?.password).toBe("shared-secret");
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
@@ -3557,6 +3597,33 @@ describe("HostRuntimeStore", () => {
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("probes a pairing link immediately and saves only after admission", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => {
|
||||
if (host.password !== "correct-password")
|
||||
throw new DaemonAuthenticationError("password_required");
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: "paired host",
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_pairing",
|
||||
},
|
||||
});
|
||||
const offerUrl = encodeOfferUrl(makeOffer());
|
||||
await expect(store.probeAndUpsertConnectionFromOfferUrl(offerUrl)).rejects.toThrow(
|
||||
"Password required",
|
||||
);
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
const result = await store.probeAndUpsertConnectionFromOfferUrl(offerUrl, "correct-password");
|
||||
expect(result.serverId).toBe("srv_offer");
|
||||
expect(store.getHosts()[0]?.password).toBe("correct-password");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("preserves the existing host label when re-pairing an existing relay host", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
@@ -3625,6 +3692,322 @@ describe("readInitialDaemonConnectionHint", () => {
|
||||
});
|
||||
|
||||
describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
||||
it("starts saved remote-host probes while desktop daemon startup is still pending", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_remote_saved",
|
||||
connections: [
|
||||
{ id: "direct:remote.example:6799", type: "directTcp", endpoint: "remote.example:6799" },
|
||||
],
|
||||
});
|
||||
const storage = createMemoryHostRuntimeStorage();
|
||||
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
|
||||
let remoteProbeStarted = false;
|
||||
let finishDesktopStart: (() => void) | undefined;
|
||||
const desktopStartPending = new Promise<void>((resolve) => {
|
||||
finishDesktopStart = resolve;
|
||||
});
|
||||
const store = new HostRuntimeStore({
|
||||
storage,
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async () => {
|
||||
remoteProbeStarted = true;
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label,
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_remote_saved",
|
||||
},
|
||||
});
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
shouldStartDaemon: true,
|
||||
daemonStartService: {
|
||||
startIfEnabled: async () => {
|
||||
await desktopStartPending;
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
await vi.waitFor(() => expect(remoteProbeStarted).toBe(true), { timeout: 400 });
|
||||
expect(store.isHostRegistryLoaded()).toBe(true);
|
||||
} finally {
|
||||
finishDesktopStart?.();
|
||||
await store.boot();
|
||||
store.syncHosts([]);
|
||||
}
|
||||
});
|
||||
it("resolves a local credential for a reopened saved desktop host connection", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_desktop_reopened",
|
||||
connections: [{ id: "direct:localhost:6799", type: "directTcp", endpoint: "localhost:6799" }],
|
||||
});
|
||||
const storage = createMemoryHostRuntimeStorage();
|
||||
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
|
||||
const credentialRequests: string[] = [];
|
||||
const store = new HostRuntimeStore({
|
||||
storage,
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host: probeHost, connection }) => {
|
||||
expect(probeHost.password).toBeUndefined();
|
||||
const credential = await readDesktopManagedLocalCredential(connection, async (listen) => {
|
||||
credentialRequests.push(listen);
|
||||
return listen === "localhost:6799" ? "local-token" : null;
|
||||
});
|
||||
expect(credential).toBe("local-token");
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label,
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_desktop_reopened",
|
||||
},
|
||||
});
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
shouldStartDaemon: true,
|
||||
daemonStartService: {
|
||||
startIfEnabled: async () => ({ ok: true }),
|
||||
},
|
||||
});
|
||||
await waitForHostOnline(store, host.serverId);
|
||||
expect(credentialRequests).toContain("localhost:6799");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("requires a new password and stops reconnecting after a previously connected client is rejected", async () => {
|
||||
const client = makeConnectedProbeClient(5);
|
||||
const host = makeHost({
|
||||
serverId: "srv_changed_password",
|
||||
password: "old-password",
|
||||
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
|
||||
});
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => client as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: client as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label,
|
||||
}),
|
||||
getClientId: async () => "cid_changed_password",
|
||||
},
|
||||
});
|
||||
store.syncHosts([host], {
|
||||
initialConnectionByServerId: new Map([
|
||||
[
|
||||
host.serverId,
|
||||
{
|
||||
connectionId: host.connections[0]!.id,
|
||||
existingClient: client as unknown as DaemonClient,
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
await waitForHostOnline(store, host.serverId);
|
||||
client.authFailureReason = "incorrect_password";
|
||||
client.setConnectionState({ status: "disconnected", reason: "Incorrect password" });
|
||||
await onceHostSnapshotMatches(
|
||||
store,
|
||||
host.serverId,
|
||||
(snapshot) => snapshot?.authFailureReason === "incorrect_password",
|
||||
);
|
||||
expect(store.getSnapshot(host.serverId)?.lastError).toBe("Incorrect password");
|
||||
expect(client.reconnectEnabledChanges.at(-1)).toBe(false);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("tries another connection after the active connection rejects its password", async () => {
|
||||
const relayClient = makeConnectedProbeClient(5);
|
||||
const directClient = makeConnectedProbeClient(5);
|
||||
const host = makeHost({
|
||||
serverId: "srv_rejected_relay_with_local_direct",
|
||||
password: "stale-password",
|
||||
preferredConnectionId: "relay:remote:443",
|
||||
connections: [
|
||||
{
|
||||
id: "relay:remote:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "remote:443",
|
||||
daemonPublicKeyB64: "pk_test_offer",
|
||||
},
|
||||
{ id: "direct:localhost:6799", type: "directTcp", endpoint: "localhost:6799" },
|
||||
],
|
||||
});
|
||||
const probeAttempts: string[] = [];
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ connection }) => {
|
||||
probeAttempts.push(connection.id);
|
||||
if (connection.type === "relay")
|
||||
throw new DaemonAuthenticationError("incorrect_password");
|
||||
return {
|
||||
client: directClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label,
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_rejected_relay_with_local_direct",
|
||||
},
|
||||
});
|
||||
store.syncHosts([host], {
|
||||
initialConnectionByServerId: new Map([
|
||||
[
|
||||
host.serverId,
|
||||
{
|
||||
connectionId: "relay:remote:443",
|
||||
existingClient: relayClient as unknown as DaemonClient,
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
await waitForHostOnline(store, host.serverId);
|
||||
relayClient.authFailureReason = "incorrect_password";
|
||||
relayClient.setConnectionState({ status: "disconnected", reason: "Incorrect password" });
|
||||
await onceHostSnapshotMatches(
|
||||
store,
|
||||
host.serverId,
|
||||
(snapshot) => snapshot?.authFailureReason === "incorrect_password",
|
||||
);
|
||||
await store.runProbeCycleNow(host.serverId);
|
||||
await onceHostSnapshotMatches(
|
||||
store,
|
||||
host.serverId,
|
||||
(snapshot) =>
|
||||
snapshot?.connectionStatus === "online" &&
|
||||
snapshot.activeConnectionId === "direct:localhost:6799",
|
||||
);
|
||||
expect(probeAttempts).toContain("direct:localhost:6799");
|
||||
expect(probeAttempts).not.toContain("relay:remote:443");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("keeps five rejected hosts red with independent password errors", async () => {
|
||||
let probeAttempts = 0;
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async () => {
|
||||
probeAttempts += 1;
|
||||
throw new DaemonAuthenticationError("password_required");
|
||||
},
|
||||
getClientId: async () => "cid_five_rejected_hosts",
|
||||
},
|
||||
});
|
||||
for (let index = 1; index <= 5; index += 1) {
|
||||
const serverId = `srv_rejected_${index}`;
|
||||
await store.upsertDirectConnection({ serverId, endpoint: `host-${index}:6767` });
|
||||
await onceHostSnapshotMatches(
|
||||
store,
|
||||
serverId,
|
||||
(snapshot) => snapshot?.authFailureReason === "password_required",
|
||||
);
|
||||
}
|
||||
for (let index = 1; index <= 5; index += 1) {
|
||||
const snapshot = store.getSnapshot(`srv_rejected_${index}`);
|
||||
expect(snapshot?.connectionStatus).toBe("error");
|
||||
expect(snapshot?.lastError).toBe("Password required");
|
||||
}
|
||||
const attemptsBeforeRetry = probeAttempts;
|
||||
await store.runProbeCycleNow("srv_rejected_1");
|
||||
expect(probeAttempts).toBe(attemptsBeforeRetry);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("imports a pairing link after a password retry in the add flow", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => {
|
||||
if (host.password !== "correct-password")
|
||||
throw new DaemonAuthenticationError("password_required");
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: "paired",
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_link_import",
|
||||
},
|
||||
});
|
||||
const link = "relay://relay.example:443/srv_pair?key=AAAA&ssl=true";
|
||||
await expect(store.importConnectionLink(link, "hostRoot")).resolves.toEqual({
|
||||
status: "password_required",
|
||||
});
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
await store.probeAndUpsertConnectionFromOfferUrl(link, "correct-password");
|
||||
expect(store.getHosts()[0]).toMatchObject({
|
||||
serverId: "srv_pair",
|
||||
password: "correct-password",
|
||||
});
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("imports a deep link without waiting for admission, then leaves a rejected saved host red", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async () => {
|
||||
throw new DaemonAuthenticationError("password_required");
|
||||
},
|
||||
getClientId: async () => "cid_deep_link",
|
||||
},
|
||||
});
|
||||
const link = "relay://relay.example:443/srv_deep?key=AAAA&ssl=true";
|
||||
await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({
|
||||
status: "connected",
|
||||
serverId: "srv_deep",
|
||||
});
|
||||
expect(store.getHosts()).toHaveLength(1);
|
||||
await onceHostSnapshotMatches(
|
||||
store,
|
||||
"srv_deep",
|
||||
(snapshot) => snapshot?.authFailureReason === "password_required",
|
||||
);
|
||||
expect(store.getSnapshot("srv_deep")?.connectionStatus).toBe("error");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("saves and reconnects a rejected saved host after changing its password", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => {
|
||||
if (host.password !== "correct-password")
|
||||
throw new DaemonAuthenticationError("password_required");
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label,
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_test_password_setting",
|
||||
},
|
||||
});
|
||||
await store.upsertDirectConnection({ serverId: "srv_test", endpoint: "lan:6767" });
|
||||
await onceHostSnapshotMatches(
|
||||
store,
|
||||
"srv_test",
|
||||
(snapshot) => snapshot?.authFailureReason === "password_required",
|
||||
);
|
||||
expect(store.getSnapshot("srv_test")?.connectionStatus).toBe("error");
|
||||
await store.setHostPassword("srv_test", "correct-password");
|
||||
await waitForHostOnline(store, "srv_test");
|
||||
expect(store.getHosts()[0]?.password).toBe("correct-password");
|
||||
expect(store.getSnapshot("srv_test")?.authFailureReason).toBeNull();
|
||||
await store.setHostPassword("srv_test", "");
|
||||
expect(store.getHosts()[0]?.password).toBeUndefined();
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("attempts the explicit initial connection hint before default localhost bootstrap", async () => {
|
||||
const seenProbes: { endpoint: string; useTls?: boolean }[] = [];
|
||||
const store = new HostRuntimeStore({
|
||||
|
||||
@@ -3,6 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import equal from "fast-deep-equal/es6";
|
||||
import {
|
||||
DaemonClient,
|
||||
DaemonAuthenticationError,
|
||||
type DaemonClientConfig,
|
||||
type ConnectionState,
|
||||
type FetchAgentsOptions,
|
||||
@@ -23,13 +24,14 @@ import {
|
||||
buildRelayWebSocketUrl,
|
||||
decodeOfferFragmentPayload,
|
||||
normalizeHostPort,
|
||||
parseRelayConnectionUri,
|
||||
shouldUseTlsForDefaultHostedRelay,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { connectToDaemon, getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
import { z } from "zod";
|
||||
import { readValidatedJson, readValidatedString } from "@/storage/validated-storage";
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
createDesktopDaemonTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
import {
|
||||
@@ -82,6 +85,7 @@ import { revokePushNotifications } from "@/push-notifications";
|
||||
import { createAppWebSocketFactory } from "./websocket-factory";
|
||||
|
||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||
export type PairingNavigationTarget = "openProject" | "hostRoot" | "hostSettings";
|
||||
export type HostRegistryStatus = "loading" | "ready";
|
||||
|
||||
export type ActiveConnection =
|
||||
@@ -106,6 +110,7 @@ export interface HostRuntimeSnapshot {
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
client: DaemonClient | null;
|
||||
lastError: string | null;
|
||||
authFailureReason?: "password_required" | "incorrect_password" | null;
|
||||
lastOnlineAt: string | null;
|
||||
agentDirectoryStatus: HostRuntimeAgentDirectoryStatus;
|
||||
agentDirectoryError: string | null;
|
||||
@@ -517,6 +522,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
capabilities: appCapabilities,
|
||||
trace: nativePerformanceTrace,
|
||||
providerSnapshots: "wire",
|
||||
...(host.password ? { password: host.password } : {}),
|
||||
localCredential: () => readDesktopManagedLocalCredential(connection),
|
||||
} satisfies Omit<DaemonClientConfig, "url">;
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return new DaemonClient({
|
||||
@@ -550,7 +557,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint, {
|
||||
useTls: connection.useTls ?? false,
|
||||
}),
|
||||
...(connection.password ? { password: connection.password } : {}),
|
||||
});
|
||||
}
|
||||
return new DaemonClient({
|
||||
@@ -570,6 +576,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
connectToDaemon: ({ host, connection, timeoutMs }) =>
|
||||
connectToDaemon(connection, {
|
||||
...(host.serverId ? { serverId: host.serverId } : {}),
|
||||
...(host.password ? { password: host.password } : {}),
|
||||
localCredential: () => readDesktopManagedLocalCredential(connection),
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
capabilities: appCapabilities,
|
||||
trace: nativePerformanceTrace,
|
||||
@@ -617,6 +625,7 @@ export class HostRuntimeController {
|
||||
private switchRequestVersion = 0;
|
||||
private probeRequestVersion = 0;
|
||||
private probeCycleInFlight: Promise<void> | null = null;
|
||||
private readonly authRejectedConnectionIds = new Set<string>();
|
||||
|
||||
constructor(input: {
|
||||
host: HostProfile;
|
||||
@@ -705,6 +714,12 @@ export class HostRuntimeController {
|
||||
}
|
||||
|
||||
async updateHost(host: HostProfile): Promise<void> {
|
||||
const passwordChanged = this.host.password !== host.password;
|
||||
if (passwordChanged) {
|
||||
this.probeRequestVersion += 1;
|
||||
this.authRejectedConnectionIds.clear();
|
||||
this.updateSnapshot({ authFailureReason: null });
|
||||
}
|
||||
const activeConnectionId = this.snapshot.activeConnectionId;
|
||||
const previousActiveConnection = findConnectionById(this.host, activeConnectionId);
|
||||
this.host = host;
|
||||
@@ -714,11 +729,13 @@ export class HostRuntimeController {
|
||||
activeConnectionId &&
|
||||
previousActiveConnection &&
|
||||
nextActiveConnection &&
|
||||
!equal(previousActiveConnection, nextActiveConnection)
|
||||
(!equal(previousActiveConnection, nextActiveConnection) || passwordChanged)
|
||||
) {
|
||||
this.connectionLastProbedAt.delete(activeConnectionId);
|
||||
await this.switchToConnection({ connectionId: activeConnectionId });
|
||||
}
|
||||
if (passwordChanged) this.connectionLastProbedAt.clear();
|
||||
if (passwordChanged) await this.probeCycleInFlight;
|
||||
await this.runProbeCycleNow();
|
||||
}
|
||||
|
||||
@@ -808,6 +825,7 @@ export class HostRuntimeController {
|
||||
const hasActiveOnlineConnection = isOnline && activeConnectionId !== null;
|
||||
|
||||
const connectionsToProbe = this.host.connections.filter((connection) => {
|
||||
if (this.authRejectedConnectionIds.has(connection.id)) return false;
|
||||
const lastProbed = this.connectionLastProbedAt.get(connection.id);
|
||||
if (lastProbed == null) {
|
||||
return true;
|
||||
@@ -843,6 +861,7 @@ export class HostRuntimeController {
|
||||
this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) });
|
||||
|
||||
let remaining = connectionsToProbe.length;
|
||||
let probeAuthFailure: "password_required" | "incorrect_password" | null = null;
|
||||
let activationLock: Promise<void> | null = null;
|
||||
|
||||
const publishProbeState = (): void => {
|
||||
@@ -893,11 +912,18 @@ export class HostRuntimeController {
|
||||
connectionId: nextConnectionId,
|
||||
expectedProbeVersion: requestVersion,
|
||||
});
|
||||
} else if (probeAuthFailure) {
|
||||
const message = new DaemonAuthenticationError(probeAuthFailure).message;
|
||||
this.applyConnectionEvent({ type: "connect_failed", message });
|
||||
this.updateSnapshot({
|
||||
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
|
||||
authFailureReason: probeAuthFailure,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeProbe?.status === "unavailable") {
|
||||
if (this.isConnectionUnavailable(currentActiveConnectionId, activeProbe)) {
|
||||
const nextConnectionId = selectBestConnection({
|
||||
candidates: buildConnectionCandidates(this.host),
|
||||
probeByConnectionId,
|
||||
@@ -1030,8 +1056,13 @@ export class HostRuntimeController {
|
||||
latencyMs: rttMs,
|
||||
});
|
||||
publishProbeState();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (this.isCurrentProbeRequest(requestVersion)) {
|
||||
const authFailure = getConnectionAuthFailureReason(error);
|
||||
if (authFailure) {
|
||||
this.authRejectedConnectionIds.add(connection.id);
|
||||
probeAuthFailure = authFailure;
|
||||
}
|
||||
probeByConnectionId.set(connection.id, {
|
||||
status: "unavailable",
|
||||
latencyMs: null,
|
||||
@@ -1049,6 +1080,13 @@ export class HostRuntimeController {
|
||||
});
|
||||
}
|
||||
|
||||
private isConnectionUnavailable(
|
||||
connectionId: string,
|
||||
probe: ConnectionProbeState | null | undefined,
|
||||
): boolean {
|
||||
return probe?.status === "unavailable" || this.authRejectedConnectionIds.has(connectionId);
|
||||
}
|
||||
|
||||
private updateSnapshot(patch: HostRuntimeSnapshotPatch): void {
|
||||
const preservedPatch: HostRuntimeSnapshotPatch = { ...patch };
|
||||
let hasChanged = this.host.serverId !== this.snapshot.serverId;
|
||||
@@ -1257,6 +1295,8 @@ export class HostRuntimeController {
|
||||
|
||||
const failConnection = async (error: unknown) => {
|
||||
if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return;
|
||||
const authFailure = getConnectionAuthFailureReason(error);
|
||||
if (authFailure) this.authRejectedConnectionIds.add(connection.id);
|
||||
this.unsubscribeClientStatus?.();
|
||||
this.unsubscribeClientStatus = null;
|
||||
this.unsubscribeClientHandlers?.();
|
||||
@@ -1266,12 +1306,29 @@ export class HostRuntimeController {
|
||||
this.updateSnapshot({
|
||||
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
|
||||
client: null,
|
||||
authFailureReason: authFailure,
|
||||
});
|
||||
if (authFailure) {
|
||||
const probes = new Map(this.snapshot.probeByConnectionId);
|
||||
probes.set(connection.id, { status: "unavailable", latencyMs: null });
|
||||
this.updateSnapshot({ probeByConnectionId: probes });
|
||||
for (const candidate of this.host.connections) {
|
||||
if (!this.authRejectedConnectionIds.has(candidate.id)) {
|
||||
this.connectionLastProbedAt.delete(candidate.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.close();
|
||||
} catch {
|
||||
/* Preserve the compatibility/connection error. */
|
||||
}
|
||||
if (
|
||||
authFailure &&
|
||||
this.host.connections.some((candidate) => !this.authRejectedConnectionIds.has(candidate.id))
|
||||
) {
|
||||
void this.runProbeCycleNow();
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (!existingClient) await client.connect();
|
||||
@@ -1280,11 +1337,16 @@ export class HostRuntimeController {
|
||||
this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null;
|
||||
this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => {
|
||||
if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return;
|
||||
if (client.authFailureReason) {
|
||||
void failConnection(new DaemonAuthenticationError(client.authFailureReason));
|
||||
return;
|
||||
}
|
||||
this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError });
|
||||
this.updateSnapshot({
|
||||
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
|
||||
...this.buildAgentDirectoryStatusPatch(),
|
||||
client,
|
||||
authFailureReason: null,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1345,6 +1407,19 @@ function readConfiguredLocalDaemonOverride(): string | null {
|
||||
return value && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function parseOfferConnectionUrl(input: string): { offer: ConnectionOffer; password?: string } {
|
||||
if (input.trim().startsWith("relay://") || input.includes("#connect=")) {
|
||||
return parseRelayConnectionUri(input);
|
||||
}
|
||||
const marker = "#offer=";
|
||||
const idx = input.indexOf(marker);
|
||||
if (idx === -1) throw new Error("Missing #offer= fragment");
|
||||
const encoded = input.slice(idx + marker.length).trim();
|
||||
if (!encoded) throw new Error("Offer payload is empty");
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
return { offer: ConnectionOfferSchema.parse(payload) };
|
||||
}
|
||||
|
||||
export function hasConfiguredLocalDaemonOverride(): boolean {
|
||||
return readConfiguredLocalDaemonOverride() !== null;
|
||||
}
|
||||
@@ -1495,6 +1570,14 @@ export class HostRuntimeStore {
|
||||
normalizedProfiles.length = 0;
|
||||
break;
|
||||
}
|
||||
// COMPAT(connectionPassword): added in v0.9.1, remove after 2027-03-24 with stored-password migration.
|
||||
if (
|
||||
entry.connections.some(
|
||||
(connection) => connection.type === "directTcp" && connection.password,
|
||||
)
|
||||
) {
|
||||
shouldPersistHosts = true;
|
||||
}
|
||||
normalizedProfiles.push(profile);
|
||||
}
|
||||
profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId));
|
||||
@@ -1717,12 +1800,12 @@ export class HostRuntimeStore {
|
||||
return this.upsertHostConnection({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
password,
|
||||
connection: {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "directTcp",
|
||||
endpoint,
|
||||
useTls: input.useTls ?? false,
|
||||
...(password ? { password } : {}),
|
||||
},
|
||||
existingClient: input.existingClient,
|
||||
});
|
||||
@@ -1730,6 +1813,7 @@ export class HostRuntimeStore {
|
||||
|
||||
async probeAndUpsertConnection(input: {
|
||||
connection: HostConnection;
|
||||
password?: string;
|
||||
label?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
@@ -1738,6 +1822,7 @@ export class HostRuntimeStore {
|
||||
}
|
||||
const probeHost: HostProfile = {
|
||||
serverId: "",
|
||||
...(input.password ? { password: input.password } : {}),
|
||||
label: input.label ?? input.connection.id,
|
||||
appearance: defaultHostAppearance(),
|
||||
lifecycle: {},
|
||||
@@ -1754,6 +1839,7 @@ export class HostRuntimeStore {
|
||||
const profile = await this.upsertHostConnection({
|
||||
serverId,
|
||||
label: input.label ?? hostname ?? undefined,
|
||||
password: input.password,
|
||||
connection: input.connection,
|
||||
existingClient: client,
|
||||
});
|
||||
@@ -1770,12 +1856,12 @@ export class HostRuntimeStore {
|
||||
const password = input.password?.trim();
|
||||
return this.probeAndUpsertConnection({
|
||||
label: input.label,
|
||||
password,
|
||||
connection: {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "directTcp",
|
||||
endpoint,
|
||||
useTls: input.useTls ?? false,
|
||||
...(password ? { password } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1798,6 +1884,7 @@ export class HostRuntimeStore {
|
||||
useTls?: boolean;
|
||||
daemonPublicKeyB64: string;
|
||||
label?: string;
|
||||
password?: string;
|
||||
}): Promise<HostProfile> {
|
||||
const relayEndpoint = normalizeHostPort(input.relayEndpoint);
|
||||
const useTls = input.useTls ?? false;
|
||||
@@ -1806,7 +1893,7 @@ export class HostRuntimeStore {
|
||||
throw new Error("daemonPublicKeyB64 is required");
|
||||
}
|
||||
const explicitUseTls = input.useTls !== undefined;
|
||||
return this.upsertHostConnection({
|
||||
const profile = await this.upsertHostConnection({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
connection: {
|
||||
@@ -1817,9 +1904,18 @@ export class HostRuntimeStore {
|
||||
daemonPublicKeyB64,
|
||||
},
|
||||
});
|
||||
if (input.password) {
|
||||
await this.setHostPassword(profile.serverId, input.password);
|
||||
return this.hosts.find((host) => host.serverId === profile.serverId) ?? profile;
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise<HostProfile> {
|
||||
async upsertConnectionFromOffer(
|
||||
offer: ConnectionOffer,
|
||||
label?: string,
|
||||
password?: string,
|
||||
): Promise<HostProfile> {
|
||||
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
|
||||
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
|
||||
return this.upsertRelayConnection({
|
||||
@@ -1828,25 +1924,67 @@ export class HostRuntimeStore {
|
||||
useTls,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
label,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
async upsertConnectionFromOfferUrl(
|
||||
offerUrlOrFragment: string,
|
||||
label?: string,
|
||||
password?: string,
|
||||
): Promise<HostProfile> {
|
||||
const marker = "#offer=";
|
||||
const idx = offerUrlOrFragment.indexOf(marker);
|
||||
if (idx === -1) {
|
||||
throw new Error("Missing #offer= fragment");
|
||||
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
|
||||
return this.upsertConnectionFromOffer(parsed.offer, label, password ?? parsed.password);
|
||||
}
|
||||
|
||||
async probeAndUpsertConnectionFromOfferUrl(
|
||||
offerUrlOrFragment: string,
|
||||
password?: string,
|
||||
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
|
||||
const offer = parsed.offer;
|
||||
const credential = password ?? parsed.password;
|
||||
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
|
||||
const relayEndpoint = normalizeHostPort(offer.relay.endpoint);
|
||||
const connection: HostConnection = {
|
||||
id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
|
||||
type: "relay",
|
||||
relayEndpoint,
|
||||
useTls,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
};
|
||||
const probeHost: HostProfile = {
|
||||
serverId: offer.serverId,
|
||||
...(credential ? { password: credential } : {}),
|
||||
label: offer.serverId,
|
||||
appearance: defaultHostAppearance(),
|
||||
lifecycle: {},
|
||||
connections: [connection],
|
||||
preferredConnectionId: connection.id,
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
};
|
||||
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
|
||||
await client.close().catch(() => undefined);
|
||||
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, credential);
|
||||
return { profile, serverId: offer.serverId, hostname };
|
||||
}
|
||||
|
||||
async importConnectionLink(
|
||||
url: string,
|
||||
target: PairingNavigationTarget,
|
||||
): Promise<{ status: "connected"; serverId: string } | { status: "password_required" }> {
|
||||
if (target === "openProject") {
|
||||
const profile = await this.upsertConnectionFromOfferUrl(url);
|
||||
return { status: "connected", serverId: profile.serverId };
|
||||
}
|
||||
const encoded = offerUrlOrFragment.slice(idx + marker.length).trim();
|
||||
if (!encoded) {
|
||||
throw new Error("Offer payload is empty");
|
||||
try {
|
||||
const { serverId } = await this.probeAndUpsertConnectionFromOfferUrl(url);
|
||||
return { status: "connected", serverId };
|
||||
} catch (error) {
|
||||
if (!getConnectionAuthFailureReason(error)) throw error;
|
||||
return { status: "password_required" };
|
||||
}
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
const offer = ConnectionOfferSchema.parse(payload);
|
||||
return this.upsertConnectionFromOffer(offer, label);
|
||||
}
|
||||
|
||||
async upsertConnectionFromListen(input: {
|
||||
@@ -1886,6 +2024,13 @@ export class HostRuntimeStore {
|
||||
await this.updateHost(serverId, (host) => ({ ...host, label }));
|
||||
}
|
||||
|
||||
async setHostPassword(serverId: string, password: string): Promise<void> {
|
||||
await this.updateHost(serverId, (host) => {
|
||||
const { password: _previous, ...rest } = host;
|
||||
return password ? { ...rest, password } : rest;
|
||||
});
|
||||
}
|
||||
|
||||
async setHostColor(serverId: string, color: HostColor): Promise<void> {
|
||||
await this.updateHostAppearance(serverId, (host) => ({
|
||||
...host,
|
||||
@@ -1963,6 +2108,7 @@ export class HostRuntimeStore {
|
||||
private async upsertHostConnection(input: {
|
||||
serverId: string;
|
||||
label?: string;
|
||||
password?: string;
|
||||
connection: HostConnection;
|
||||
existingClient?: DaemonClient;
|
||||
}): Promise<HostProfile> {
|
||||
@@ -1972,6 +2118,7 @@ export class HostRuntimeStore {
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
connection: input.connection,
|
||||
password: input.password,
|
||||
now,
|
||||
});
|
||||
this.setHostsAndSync(next, {
|
||||
@@ -2605,12 +2752,18 @@ export interface HostMutations {
|
||||
useTls?: boolean;
|
||||
daemonPublicKeyB64: string;
|
||||
label?: string;
|
||||
password?: string;
|
||||
}) => Promise<HostProfile>;
|
||||
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>;
|
||||
upsertConnectionFromOfferUrl: (
|
||||
offerUrlOrFragment: string,
|
||||
label?: string,
|
||||
password?: string,
|
||||
) => Promise<HostProfile>;
|
||||
probeAndUpsertConnectionFromOfferUrl: (
|
||||
offerUrlOrFragment: string,
|
||||
password?: string,
|
||||
) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
|
||||
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
|
||||
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
|
||||
@@ -2627,7 +2780,10 @@ export function useHostMutations(): HostMutations {
|
||||
probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input),
|
||||
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
|
||||
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
|
||||
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
|
||||
upsertConnectionFromOfferUrl: (url, label, password) =>
|
||||
store.upsertConnectionFromOfferUrl(url, label, password),
|
||||
probeAndUpsertConnectionFromOfferUrl: (url, password) =>
|
||||
store.probeAndUpsertConnectionFromOfferUrl(url, password),
|
||||
renameHost: (serverId, label) => store.renameHost(serverId, label),
|
||||
setHostColor: (serverId, color) => store.setHostColor(serverId, color),
|
||||
setHostBadgeDisplay: (serverId, badgeDisplay) =>
|
||||
|
||||
@@ -256,6 +256,41 @@ function deleteDirectory(cache: ReplicaCache, serverId: string): void {
|
||||
}
|
||||
|
||||
describe("ReplicaCache", () => {
|
||||
it("rejects pre-baseline-fix checkpoints without discarding cached rows or timelines", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const baseline = directory();
|
||||
writer.replaceDirectoryBaseline(SERVER_ID, baseline);
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", timeline());
|
||||
await writer.flush();
|
||||
await storage.apply({
|
||||
deletes: [],
|
||||
upserts: [
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
kind: "checkpoint",
|
||||
id: "singleton",
|
||||
payload: JSON.stringify(baseline.checkpoint),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const reader = createCache(storage);
|
||||
const restored = await reader.readDirectory(SERVER_ID);
|
||||
expect(restored.checkpoint).toBeUndefined();
|
||||
expect([...restored.workspaces.keys()]).toEqual([...baseline.workspaces.keys()]);
|
||||
expect([...restored.projects.keys()]).toEqual([...baseline.projects.keys()]);
|
||||
expect([...restored.agents.keys()]).toEqual([...baseline.agents.keys()]);
|
||||
expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline());
|
||||
expect(await storage.read(SERVER_ID, ["checkpoint"])).toEqual([]);
|
||||
|
||||
reader.replaceDirectoryBaseline(SERVER_ID, baseline);
|
||||
await reader.flush();
|
||||
expect((await createCache(storage).readDirectory(SERVER_ID)).checkpoint).toEqual(
|
||||
baseline.checkpoint,
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing until an owner explicitly commits data", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
|
||||
@@ -378,6 +378,21 @@ const DirectoryCheckpointSchema = z.strictObject({
|
||||
agents: DirectoryCursorSchema.optional(),
|
||||
});
|
||||
|
||||
// Old checkpoints could describe a baseline that an overlapping refresh had discarded.
|
||||
// Revalidate directory contents once without evicting cached rows or timelines.
|
||||
const StoredDirectoryCheckpointSchema = z.strictObject({
|
||||
version: z.literal(1),
|
||||
cursors: DirectoryCheckpointSchema,
|
||||
});
|
||||
|
||||
function serializeDirectoryCheckpoint(cursors: DirectoryCheckpoint) {
|
||||
return { version: 1 as const, cursors };
|
||||
}
|
||||
|
||||
function deserializeDirectoryCheckpoint(payload: string): DirectoryCheckpoint {
|
||||
return parseStoredPayload(StoredDirectoryCheckpointSchema, payload).cursors;
|
||||
}
|
||||
|
||||
function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null {
|
||||
if (!stored) {
|
||||
return null;
|
||||
@@ -849,7 +864,7 @@ function applyDirectoryRow(
|
||||
if (row.id !== REPLICA_SINGLETON_ROW_ID) {
|
||||
throw new Error("Replica checkpoint row id mismatch");
|
||||
}
|
||||
result.checkpoint = parseStoredPayload(DirectoryCheckpointSchema, row.payload);
|
||||
result.checkpoint = deserializeDirectoryCheckpoint(row.payload);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
@@ -1015,7 +1030,7 @@ export class ReplicaCache {
|
||||
let checkpoint: DirectoryCheckpoint | undefined;
|
||||
if (checkpointRow) {
|
||||
try {
|
||||
checkpoint = parseStoredPayload(DirectoryCheckpointSchema, checkpointRow.payload);
|
||||
checkpoint = deserializeDirectoryCheckpoint(checkpointRow.payload);
|
||||
checkpoint = { ...checkpoint };
|
||||
delete checkpoint[invalidEntity];
|
||||
} catch {
|
||||
@@ -1031,7 +1046,7 @@ export class ReplicaCache {
|
||||
serverId: row.serverId,
|
||||
kind: "checkpoint",
|
||||
id: REPLICA_SINGLETON_ROW_ID,
|
||||
payload: JSON.stringify(checkpoint),
|
||||
payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -1057,7 +1072,7 @@ export class ReplicaCache {
|
||||
serverId,
|
||||
kind: "checkpoint",
|
||||
id: REPLICA_SINGLETON_ROW_ID,
|
||||
payload: JSON.stringify(checkpoint),
|
||||
payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -1323,7 +1338,7 @@ export class ReplicaCache {
|
||||
if (!value) return null;
|
||||
break;
|
||||
case "checkpoint":
|
||||
value = upsert.value;
|
||||
value = serializeDirectoryCheckpoint(upsert.value);
|
||||
break;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -419,7 +419,7 @@ function ReadFailureCallout({ kind, error, onReload }: ReadFailureCalloutProps)
|
||||
});
|
||||
return (
|
||||
<View style={styles.errorBlock}>
|
||||
<Alert testID={testID} variant="error" title={title} description={description}>
|
||||
<Alert size="md" testID={testID} variant="error" title={title} description={description}>
|
||||
<Button testID={`${testID}-action-0`} onPress={onReload} variant="outline" size="sm">
|
||||
{t("settings.project.actions.reload")}
|
||||
</Button>
|
||||
@@ -702,6 +702,7 @@ function ProjectConfigForm({
|
||||
>
|
||||
{hasUncommittedWorktreeSetupChanges ? (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="warning"
|
||||
title={t("settings.project.worktree.uncommittedTitle")}
|
||||
description={t("settings.project.worktree.uncommittedDescription")}
|
||||
@@ -776,6 +777,7 @@ function ProjectConfigForm({
|
||||
{isStale ? (
|
||||
<View style={styles.calloutWrap}>
|
||||
<Alert
|
||||
size="sm"
|
||||
testID="stale-callout"
|
||||
variant="error"
|
||||
title={t("settings.project.writeFailures.staleTitle")}
|
||||
@@ -796,6 +798,7 @@ function ProjectConfigForm({
|
||||
{isWriteFailed ? (
|
||||
<View style={styles.calloutWrap}>
|
||||
<Alert
|
||||
size="sm"
|
||||
testID="write-failed-callout"
|
||||
variant="error"
|
||||
title={t("settings.project.writeFailures.failedTitle")}
|
||||
|
||||
@@ -195,7 +195,7 @@ function ThemeRow({
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200} scrollable>
|
||||
{THEME_OPTIONS.map((option, index) => {
|
||||
const previousOption = THEME_OPTIONS[index - 1];
|
||||
return (
|
||||
|
||||
@@ -226,12 +226,23 @@ function HostStatusBadges({ serverId }: { serverId: string }) {
|
||||
}
|
||||
|
||||
function HostConnectionError({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||
const lastError = snapshot?.lastError ?? null;
|
||||
const connectionError =
|
||||
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
||||
if (!connectionError) return null;
|
||||
return <Text style={styles.errorText}>{connectionError}</Text>;
|
||||
return (
|
||||
<View style={styles.connectionError}>
|
||||
<InlineAlert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={connectionError}
|
||||
description={snapshot?.authFailureReason ? t("settings.host.password.guidance") : undefined}
|
||||
testID="host-connection-error"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostConnectionsPage({ serverId }: { serverId: string }) {
|
||||
@@ -371,6 +382,7 @@ export function HostSettingsPage({
|
||||
</View>
|
||||
|
||||
<HostStatusBadges serverId={serverId} />
|
||||
<HostConnectionError serverId={serverId} />
|
||||
|
||||
<HostAppearanceSection host={host} />
|
||||
|
||||
@@ -622,7 +634,7 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
{
|
||||
restartServer: (reason) => daemonClient.restartServer(reason),
|
||||
getStatus: async () => ({
|
||||
...(await daemonClient.getDaemonStatus({ timeout: 1500 })),
|
||||
...(await daemonClient.getDaemonStatus()),
|
||||
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
|
||||
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
|
||||
}),
|
||||
@@ -766,7 +778,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
void updateDaemonFromSettings(host.serverId, {
|
||||
updateDaemon: () => daemonClient.updateDaemon(requestId),
|
||||
getStatus: async () => ({
|
||||
...(await daemonClient.getDaemonStatus({ timeout: 1500 })),
|
||||
...(await daemonClient.getDaemonStatus()),
|
||||
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
|
||||
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
|
||||
}),
|
||||
@@ -835,6 +847,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
</View>
|
||||
{updateState.status === "complete" ? (
|
||||
<InlineAlert
|
||||
size="sm"
|
||||
variant="success"
|
||||
title={t("desktop.daemon.lifecycle.workerUpdated", {
|
||||
version: updateState.workerVersion,
|
||||
@@ -845,6 +858,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
{updateState.status === "failed" ? (
|
||||
<View style={styles.updateFailure}>
|
||||
<InlineAlert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={updateState.title}
|
||||
description={updateState.message}
|
||||
@@ -1742,10 +1756,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginBottom: theme.spacing[2],
|
||||
connectionError: {
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
connectionLatency: {
|
||||
fontSize: theme.fontSize.base,
|
||||
|
||||
@@ -230,6 +230,7 @@ function PluginLogsSheet({
|
||||
if (logs.isError) {
|
||||
content = (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={t("settings.plugins.logs.errorTitle")}
|
||||
description={errorMessage(logs.error)}
|
||||
@@ -412,6 +413,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
if (pageState === "offline") {
|
||||
return (
|
||||
<Alert
|
||||
size="md"
|
||||
variant="warning"
|
||||
title={t("settings.plugins.states.offlineTitle")}
|
||||
description={t("settings.plugins.states.offlineDescription")}
|
||||
@@ -419,13 +421,14 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
);
|
||||
}
|
||||
if (pageState === "unsupported") {
|
||||
return <Alert variant="warning" title={t("settings.plugins.states.updateTitle")} />;
|
||||
return <Alert size="md" variant="warning" title={t("settings.plugins.states.updateTitle")} />;
|
||||
}
|
||||
|
||||
let catalogContent = <Alert title={t("settings.plugins.states.loading")} />;
|
||||
let catalogContent = <Alert size="sm" title={t("settings.plugins.states.loading")} />;
|
||||
if (pageState === "error") {
|
||||
catalogContent = (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={t("settings.plugins.states.errorTitle")}
|
||||
description={errorMessage(plugins.error)}
|
||||
@@ -469,6 +472,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
<View>
|
||||
<SettingsSection title={t("settings.plugins.title")}>
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="warning"
|
||||
title={t("settings.plugins.trustedTitle")}
|
||||
description={t("settings.plugins.trustedDescription")}
|
||||
@@ -508,10 +512,15 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<Alert variant="warning" title={t("settings.plugins.states.sourceUpdateTitle")} />
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="warning"
|
||||
title={t("settings.plugins.states.sourceUpdateTitle")}
|
||||
/>
|
||||
)}
|
||||
{feedback ? (
|
||||
<Alert
|
||||
size="sm"
|
||||
variant={feedback.kind}
|
||||
title={feedback.message}
|
||||
testID="plugin-management-feedback"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { page } from "@vitest/browser/context";
|
||||
import { page, userEvent } from "@vitest/browser/context";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
||||
@@ -40,6 +40,7 @@ interface MountedTerminal {
|
||||
sizes: TerminalSize[];
|
||||
terminalKeys: TerminalKeyRecord[];
|
||||
inputModeChanges: TerminalInputModeState[];
|
||||
openedUrls: string[];
|
||||
}
|
||||
|
||||
const mountedTerminals: MountedTerminal[] = [];
|
||||
@@ -95,6 +96,7 @@ function createTerminalHost(input: {
|
||||
const inputs: string[] = [];
|
||||
const terminalKeys: TerminalKeyRecord[] = [];
|
||||
const inputModeChanges: TerminalInputModeState[] = [];
|
||||
const openedUrls: string[] = [];
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
@@ -110,6 +112,9 @@ function createTerminalHost(input: {
|
||||
onInputModeChange: (state) => {
|
||||
inputModeChanges.push(state);
|
||||
},
|
||||
onOpenExternalUrl: (url) => {
|
||||
openedUrls.push(url);
|
||||
},
|
||||
},
|
||||
});
|
||||
runtime.mount({
|
||||
@@ -124,7 +129,16 @@ function createTerminalHost(input: {
|
||||
},
|
||||
});
|
||||
|
||||
const mounted = { host, root, runtime, inputs, sizes, terminalKeys, inputModeChanges };
|
||||
const mounted = {
|
||||
host,
|
||||
root,
|
||||
runtime,
|
||||
inputs,
|
||||
sizes,
|
||||
terminalKeys,
|
||||
inputModeChanges,
|
||||
openedUrls,
|
||||
};
|
||||
mountedTerminals.push(mounted);
|
||||
return mounted;
|
||||
}
|
||||
@@ -188,7 +202,43 @@ function dispatchTerminalKey(input: {
|
||||
);
|
||||
}
|
||||
|
||||
/** An OSC 8 hyperlink, the escape sequence CLIs such as gh and ls --hyperlink print. */
|
||||
function hyperlink(input: { url: string; text: string }): string {
|
||||
return `\x1b]8;;${input.url}\x1b\\${input.text}\x1b]8;;\x1b\\`;
|
||||
}
|
||||
|
||||
function writeLines(mounted: MountedTerminal, lines: string[]): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
mounted.runtime.write({
|
||||
data: terminalOutput(lines.map((line) => `${line}\r\n`).join("")),
|
||||
onCommitted: resolve,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function clickTerminalLink(input: {
|
||||
host: HTMLElement;
|
||||
row: number;
|
||||
col: number;
|
||||
}): Promise<void> {
|
||||
const terminal = getBrowserTerminal();
|
||||
const screen = input.host.querySelector<HTMLElement>(".xterm-screen");
|
||||
if (!screen) {
|
||||
throw new Error("Expected xterm screen to be mounted");
|
||||
}
|
||||
const position = {
|
||||
x: (input.col + 0.5) * (screen.clientWidth / terminal.cols),
|
||||
y: (input.row + 0.5) * (screen.clientHeight / terminal.rows),
|
||||
};
|
||||
await userEvent.hover(screen, { position });
|
||||
await waitFor({
|
||||
predicate: () => input.host.querySelector(".xterm-cursor-pointer") !== null,
|
||||
});
|
||||
await userEvent.click(screen, { position });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const mounted of mountedTerminals.splice(0)) {
|
||||
mounted.runtime.unmount();
|
||||
mounted.root.remove();
|
||||
@@ -526,4 +576,23 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
|
||||
await writeLines(mounted, [
|
||||
hyperlink({ url: "https://example.com/osc8", text: "example" }),
|
||||
"https://example.com/plain",
|
||||
]);
|
||||
|
||||
await clickTerminalLink({ host: mounted.host, row: 1, col: 4 });
|
||||
await clickTerminalLink({ host: mounted.host, row: 0, col: 2 });
|
||||
|
||||
expect(mounted.openedUrls).toEqual(["https://example.com/plain", "https://example.com/osc8"]);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -434,6 +434,10 @@ export class TerminalEmulatorRuntime {
|
||||
this.inputModeTracker.reset();
|
||||
this.emitInputModeChange();
|
||||
|
||||
const openExternalLink = (event: MouseEvent, uri: string) => {
|
||||
event.preventDefault();
|
||||
void this.callbacks.onOpenExternalUrl?.(uri);
|
||||
};
|
||||
const terminal = new Terminal({
|
||||
allowProposedApi: true,
|
||||
convertEol: false,
|
||||
@@ -441,6 +445,8 @@ export class TerminalEmulatorRuntime {
|
||||
cursorStyle: "bar",
|
||||
fontFamily: resolveTerminalFontFamily(input.fontFamily),
|
||||
fontSize: resolveTerminalFontSize(input.fontSize),
|
||||
// OSC 8 hyperlinks; without a handler xterm prompts and calls window.open().
|
||||
linkHandler: { activate: openExternalLink },
|
||||
lineHeight: 1.0,
|
||||
macOptionIsMeta: true,
|
||||
minimumContrastRatio: 1,
|
||||
@@ -457,12 +463,7 @@ export class TerminalEmulatorRuntime {
|
||||
let imageAddon: ImageAddon | null = null;
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(unicode11Addon);
|
||||
terminal.loadAddon(
|
||||
new WebLinksAddon((event, uri) => {
|
||||
event.preventDefault();
|
||||
void this.callbacks.onOpenExternalUrl?.(uri);
|
||||
}),
|
||||
);
|
||||
terminal.loadAddon(new WebLinksAddon(openExternalLink));
|
||||
const localFileLinkProvider = terminal.registerLinkProvider(
|
||||
createTerminalLocalFileLinkProvider(terminal, {
|
||||
resolveLink: async (source) => {
|
||||
|
||||
@@ -76,6 +76,15 @@ describe("normalizeStoredHostProfile", () => {
|
||||
expect(profile?.connections[0]).not.toHaveProperty("password");
|
||||
});
|
||||
|
||||
it("moves a stored direct TCP password to the host profile", () => {
|
||||
const profile = normalizeStoredHostProfile({
|
||||
serverId: "srv_legacy",
|
||||
connections: [{ type: "directTcp", endpoint: "localhost:6767", password: "old-secret" }],
|
||||
});
|
||||
expect(profile?.password).toBe("old-secret");
|
||||
expect(profile?.connections[0]).not.toHaveProperty("password");
|
||||
});
|
||||
|
||||
it("preserves legacy relay ids when TLS is absent", () => {
|
||||
const profile = normalizeStoredHostProfile({
|
||||
serverId: "srv_relay",
|
||||
@@ -228,26 +237,28 @@ describe("upsertHostConnectionInProfiles", () => {
|
||||
type: "directTcp",
|
||||
endpoint: "example.test:6767",
|
||||
useTls: false,
|
||||
password: "old-secret",
|
||||
};
|
||||
const existing: HostProfile = {
|
||||
...makeHost("srv_known"),
|
||||
password: "old-secret",
|
||||
connections: [existingConnection],
|
||||
preferredConnectionId: existingConnection.id,
|
||||
};
|
||||
const replacement: HostConnection = {
|
||||
...existingConnection,
|
||||
useTls: true,
|
||||
password: "new-secret",
|
||||
};
|
||||
|
||||
const [profile] = upsertHostConnectionInProfiles({
|
||||
profiles: [existing],
|
||||
serverId: "srv_known",
|
||||
connection: replacement,
|
||||
password: "new-secret",
|
||||
});
|
||||
|
||||
expect(profile.connections).toEqual([replacement]);
|
||||
expect("password" in profile.connections[0]!).toBe(false);
|
||||
expect(profile.password).toBe("new-secret");
|
||||
expect(profile.preferredConnectionId).toBe(replacement.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "@getpaseo/protocol/daemon-endpoints";
|
||||
import {
|
||||
DirectTcpHostConnectionSchema,
|
||||
type DirectTcpHostConnection,
|
||||
type DirectTcpHostConnection as WireDirectTcpHostConnection,
|
||||
} from "@getpaseo/protocol/host-connection-schema";
|
||||
import {
|
||||
DEFAULT_SSH_DAEMON_PORT,
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
} from "@/hosts/appearance";
|
||||
import { z } from "zod";
|
||||
|
||||
export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection };
|
||||
export { DirectTcpHostConnectionSchema };
|
||||
export type DirectTcpHostConnection = Omit<WireDirectTcpHostConnection, "password">;
|
||||
|
||||
export interface DirectSocketHostConnection {
|
||||
id: string;
|
||||
@@ -59,6 +60,7 @@ export type HostLifecycle = Record<string, never>;
|
||||
|
||||
export interface HostProfile {
|
||||
serverId: string;
|
||||
password?: string;
|
||||
label: string;
|
||||
appearance: HostAppearance;
|
||||
lifecycle: HostLifecycle;
|
||||
@@ -125,11 +127,7 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
|
||||
}
|
||||
|
||||
if (left.type === "directTcp" && right.type === "directTcp") {
|
||||
return (
|
||||
left.endpoint === right.endpoint &&
|
||||
(left.useTls ?? false) === (right.useTls ?? false) &&
|
||||
left.password === right.password
|
||||
);
|
||||
return left.endpoint === right.endpoint && (left.useTls ?? false) === (right.useTls ?? false);
|
||||
}
|
||||
if (left.type === "directSocket" && right.type === "directSocket") {
|
||||
return left.path === right.path;
|
||||
@@ -186,11 +184,40 @@ function upsertHostConnectionById(
|
||||
return next;
|
||||
}
|
||||
|
||||
function hasProfileChanged(input: {
|
||||
matchingCount: number;
|
||||
previous: HostProfile;
|
||||
serverId: string;
|
||||
password?: string;
|
||||
createdAt: string;
|
||||
label: string;
|
||||
preferredConnectionId: string;
|
||||
lifecycle: HostLifecycle;
|
||||
connections: HostConnection[];
|
||||
}): boolean {
|
||||
const { previous, connections } = input;
|
||||
return (
|
||||
input.matchingCount > 1 ||
|
||||
previous.serverId !== input.serverId ||
|
||||
(input.password !== undefined && input.password !== previous.password) ||
|
||||
input.createdAt !== previous.createdAt ||
|
||||
input.label !== previous.label ||
|
||||
input.preferredConnectionId !== previous.preferredConnectionId ||
|
||||
!hostLifecycleEquals(previous.lifecycle, input.lifecycle) ||
|
||||
connections.length !== previous.connections.length ||
|
||||
connections.some((candidate, index) => {
|
||||
const old = previous.connections[index];
|
||||
return !old || !hostConnectionEquals(candidate, old);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertHostConnectionInProfiles(input: {
|
||||
profiles: HostProfile[];
|
||||
serverId: string;
|
||||
label?: string;
|
||||
connection: HostConnection;
|
||||
password?: string;
|
||||
now?: string;
|
||||
}): HostProfile[] {
|
||||
const serverId = input.serverId.trim();
|
||||
@@ -199,13 +226,17 @@ export function upsertHostConnectionInProfiles(input: {
|
||||
}
|
||||
|
||||
const now = input.now ?? new Date().toISOString();
|
||||
const password = input.password;
|
||||
const normalizedConnection = input.connection;
|
||||
const labelTrimmed = input.label?.trim() ?? "";
|
||||
const derivedLabel = labelTrimmed || serverId;
|
||||
const existing = input.profiles;
|
||||
const matchingIndexes = existing.reduce<number[]>((matches, daemon, index) => {
|
||||
if (
|
||||
daemon.serverId === serverId ||
|
||||
daemon.connections.some((connection) => hostConnectionEquals(connection, input.connection))
|
||||
daemon.connections.some((existingConnection) =>
|
||||
hostConnectionEquals(existingConnection, normalizedConnection),
|
||||
)
|
||||
) {
|
||||
matches.push(index);
|
||||
}
|
||||
@@ -215,11 +246,12 @@ export function upsertHostConnectionInProfiles(input: {
|
||||
if (matchingIndexes.length === 0) {
|
||||
const profile: HostProfile = {
|
||||
serverId,
|
||||
...(password ? { password } : {}),
|
||||
label: derivedLabel,
|
||||
appearance: defaultHostAppearance(),
|
||||
lifecycle: defaultLifecycle(),
|
||||
connections: [input.connection],
|
||||
preferredConnectionId: input.connection.id,
|
||||
connections: [normalizedConnection],
|
||||
preferredConnectionId: normalizedConnection.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -230,7 +262,7 @@ export function upsertHostConnectionInProfiles(input: {
|
||||
const prev = matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0];
|
||||
const nextConnections = upsertHostConnectionById(
|
||||
matchedProfiles.flatMap((daemon) => daemon.connections),
|
||||
input.connection,
|
||||
normalizedConnection,
|
||||
);
|
||||
const nextLifecycle = prev.lifecycle;
|
||||
const nextLabel = prev.label === prev.serverId ? derivedLabel : prev.label;
|
||||
@@ -238,23 +270,22 @@ export function upsertHostConnectionInProfiles(input: {
|
||||
prev.preferredConnectionId &&
|
||||
nextConnections.some((connection) => connection.id === prev.preferredConnectionId)
|
||||
? prev.preferredConnectionId
|
||||
: input.connection.id;
|
||||
: normalizedConnection.id;
|
||||
const nextCreatedAt = matchedProfiles.reduce(
|
||||
(earliest, daemon) => (daemon.createdAt < earliest ? daemon.createdAt : earliest),
|
||||
prev.createdAt,
|
||||
);
|
||||
const changed =
|
||||
matchingIndexes.length > 1 ||
|
||||
prev.serverId !== serverId ||
|
||||
nextCreatedAt !== prev.createdAt ||
|
||||
nextLabel !== prev.label ||
|
||||
nextPreferredConnectionId !== prev.preferredConnectionId ||
|
||||
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
|
||||
nextConnections.length !== prev.connections.length ||
|
||||
nextConnections.some((connection, index) => {
|
||||
const previousConnection = prev.connections[index];
|
||||
return !previousConnection || !hostConnectionEquals(connection, previousConnection);
|
||||
});
|
||||
const changed = hasProfileChanged({
|
||||
matchingCount: matchingIndexes.length,
|
||||
previous: prev,
|
||||
serverId,
|
||||
password,
|
||||
createdAt: nextCreatedAt,
|
||||
label: nextLabel,
|
||||
preferredConnectionId: nextPreferredConnectionId,
|
||||
lifecycle: nextLifecycle,
|
||||
connections: nextConnections,
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return existing;
|
||||
@@ -262,6 +293,7 @@ export function upsertHostConnectionInProfiles(input: {
|
||||
|
||||
const nextProfile: HostProfile = {
|
||||
...prev,
|
||||
...(password ? { password } : {}),
|
||||
serverId,
|
||||
label: nextLabel,
|
||||
lifecycle: nextLifecycle,
|
||||
@@ -386,6 +418,7 @@ const StoredHostConnectionSchema = z.discriminatedUnion("type", [
|
||||
]);
|
||||
const StoredHostProfileSchema = z.strictObject({
|
||||
serverId: z.string().trim().min(1),
|
||||
password: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
appearance: HostAppearanceSchema.optional(),
|
||||
lifecycle: z.strictObject({}).optional(),
|
||||
@@ -401,13 +434,13 @@ function normalizeStoredConnection(connection: StoredHostConnection): HostConnec
|
||||
if (connection.type === "directTcp") {
|
||||
try {
|
||||
const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(connection.endpoint));
|
||||
return DirectTcpHostConnectionSchema.parse({
|
||||
const parsed = DirectTcpHostConnectionSchema.parse({
|
||||
id: `direct:${endpoint}`,
|
||||
type: "directTcp",
|
||||
endpoint,
|
||||
useTls: connection.useTls,
|
||||
...(connection.password !== undefined ? { password: connection.password } : {}),
|
||||
});
|
||||
return { id: parsed.id, type: parsed.type, endpoint: parsed.endpoint, useTls: parsed.useTls };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -459,6 +492,12 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
|
||||
}
|
||||
const record = result.data;
|
||||
const serverId = record.serverId;
|
||||
// COMPAT(connectionPassword): added in v0.9.1, remove after 2027-03-24 once stored direct passwords have migrated.
|
||||
const legacyPassword = record.connections.find(
|
||||
(connection) => connection.type === "directTcp" && connection.password,
|
||||
);
|
||||
const password =
|
||||
record.password ?? (legacyPassword?.type === "directTcp" ? legacyPassword.password : undefined);
|
||||
|
||||
const connections = record.connections
|
||||
.map((connection) => normalizeStoredConnection(connection))
|
||||
@@ -478,6 +517,7 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
|
||||
|
||||
return {
|
||||
serverId,
|
||||
...(password ? { password } : {}),
|
||||
label,
|
||||
appearance: record.appearance ?? defaultHostAppearance(),
|
||||
lifecycle: defaultLifecycle(),
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
extractHostPortFromWebSocketUrl,
|
||||
normalizeHostPort,
|
||||
parseConnectionUri,
|
||||
parseRelayConnectionUri,
|
||||
serializeRelayConnectionUri,
|
||||
parseHostPort,
|
||||
serializeConnectionUri,
|
||||
serializeConnectionUriForStorage,
|
||||
@@ -22,6 +24,8 @@ export {
|
||||
extractHostPortFromWebSocketUrl,
|
||||
normalizeHostPort,
|
||||
parseConnectionUri,
|
||||
parseRelayConnectionUri,
|
||||
serializeRelayConnectionUri,
|
||||
parseHostPort,
|
||||
serializeConnectionUri,
|
||||
serializeConnectionUriForStorage,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
|
||||
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
|
||||
|
||||
class FakeDaemonClient implements DaemonProbeClient {
|
||||
@@ -169,16 +170,15 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes direct TCP connection passwords into the client config", async () => {
|
||||
it("passes the host password into the client config", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
},
|
||||
undefined,
|
||||
{ password: "shared-secret" },
|
||||
probe.deps,
|
||||
);
|
||||
await result.client.close();
|
||||
@@ -242,7 +242,7 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
it("surfaces auth rejection as an incorrect password", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
probe.failNextConnection(
|
||||
new Error("Transport closed (code 4001)"),
|
||||
new DaemonAuthenticationError("incorrect_password"),
|
||||
"Transport closed (code 4001)",
|
||||
);
|
||||
|
||||
@@ -252,13 +252,13 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "wrong-secret",
|
||||
},
|
||||
undefined,
|
||||
{ password: "wrong-secret" },
|
||||
probe.deps,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: "Incorrect password",
|
||||
authFailureReason: "incorrect_password",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,9 +272,8 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
},
|
||||
undefined,
|
||||
{ password: "shared-secret" },
|
||||
probe.deps,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
DaemonAuthenticationError,
|
||||
DaemonClient,
|
||||
getDaemonAuthFailureReason,
|
||||
type DaemonAuthFailureReason,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { HostConnection } from "@/types/host-connection";
|
||||
import { getOrCreateClientId } from "./client-id";
|
||||
@@ -16,6 +21,7 @@ import type { DesktopDaemonTransportTarget } from "@/desktop/daemon/desktop-daem
|
||||
|
||||
export interface DaemonProbeClient {
|
||||
readonly lastError: string | null;
|
||||
readonly authFailureReason?: DaemonAuthFailureReason | null;
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null;
|
||||
@@ -84,41 +90,53 @@ function pickBestReason(reason: string | null, lastError: string | null): string
|
||||
return "Unable to connect";
|
||||
}
|
||||
|
||||
function isIncorrectPasswordFailure(input: {
|
||||
config: DaemonClientConfig;
|
||||
reason: string | null;
|
||||
lastError: string | null;
|
||||
}): boolean {
|
||||
if (!input.config.password) {
|
||||
return false;
|
||||
}
|
||||
const details = [input.reason, input.lastError].filter(Boolean).join("\n").toLowerCase();
|
||||
return (
|
||||
details.includes("401") ||
|
||||
details.includes("4001") ||
|
||||
details.includes("unauthorized") ||
|
||||
details.includes("code 1006")
|
||||
);
|
||||
}
|
||||
|
||||
export class DaemonConnectionTestError extends Error {
|
||||
reason: string | null;
|
||||
lastError: string | null;
|
||||
authFailureReason: DaemonAuthFailureReason | null;
|
||||
|
||||
constructor(message: string, details: { reason: string | null; lastError: string | null }) {
|
||||
constructor(
|
||||
message: string,
|
||||
details: {
|
||||
reason: string | null;
|
||||
lastError: string | null;
|
||||
authFailureReason?: DaemonAuthFailureReason | null;
|
||||
},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DaemonConnectionTestError";
|
||||
this.reason = details.reason;
|
||||
this.lastError = details.lastError;
|
||||
this.authFailureReason = details.authFailureReason ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConnectionAuthFailureReason(error: unknown): DaemonAuthFailureReason | null {
|
||||
if (error instanceof DaemonConnectionTestError) return error.authFailureReason;
|
||||
return getDaemonAuthFailureReason(error);
|
||||
}
|
||||
|
||||
function resolveConnectionCredentials(
|
||||
connection: HostConnection,
|
||||
options:
|
||||
| { password?: string; localCredential?: DaemonClientConfig["localCredential"] }
|
||||
| undefined,
|
||||
): Pick<DaemonClientConfig, "password" | "localCredential"> {
|
||||
const password = options?.password;
|
||||
return {
|
||||
...(password ? { password } : {}),
|
||||
...(options?.localCredential ? { localCredential: options.localCredential } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildClientConfig(
|
||||
connection: HostConnection,
|
||||
serverId?: string,
|
||||
options?: {
|
||||
capabilities?: DaemonClientConfig["capabilities"];
|
||||
trace?: DaemonClientConfig["trace"];
|
||||
password?: string;
|
||||
localCredential?: DaemonClientConfig["localCredential"];
|
||||
},
|
||||
deps: Pick<
|
||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||
@@ -136,6 +154,7 @@ export async function buildClientConfig(
|
||||
appVersion: deps.resolveAppVersion() ?? undefined,
|
||||
suppressSendErrors: true,
|
||||
reconnect: { enabled: false },
|
||||
...resolveConnectionCredentials(connection, options),
|
||||
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
|
||||
...(options?.trace ? { trace: options.trace } : {}),
|
||||
...((connection.type === "directSocket" || connection.type === "directPipe") &&
|
||||
@@ -167,7 +186,6 @@ export async function buildClientConfig(
|
||||
return {
|
||||
...base,
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }),
|
||||
...(connection.password ? { password: connection.password } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -245,11 +263,13 @@ export function connectAndProbe(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
const lastError = normalizeNonEmptyString(client.lastError);
|
||||
const message = isIncorrectPasswordFailure({ config, reason, lastError })
|
||||
? "Incorrect password"
|
||||
const authFailureReason =
|
||||
getDaemonAuthFailureReason(error) ?? client.authFailureReason ?? null;
|
||||
const message = authFailureReason
|
||||
? new DaemonAuthenticationError(authFailureReason).message
|
||||
: pickBestReason(reason, lastError);
|
||||
void client.close().catch(() => undefined);
|
||||
reject(new DaemonConnectionTestError(message, { reason, lastError }));
|
||||
reject(new DaemonConnectionTestError(message, { reason, lastError, authFailureReason }));
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -258,6 +278,8 @@ export function connectAndProbe(
|
||||
interface ProbeOptions {
|
||||
serverId?: string;
|
||||
timeoutMs?: number;
|
||||
password?: string;
|
||||
localCredential?: DaemonClientConfig["localCredential"];
|
||||
capabilities?: DaemonClientConfig["capabilities"];
|
||||
trace?: DaemonClientConfig["trace"];
|
||||
}
|
||||
|
||||
@@ -108,6 +108,17 @@ describe("formatMessageTimestamp", () => {
|
||||
expect(formatted).toMatch(/10:12 PM|22:12/);
|
||||
});
|
||||
|
||||
it("shows the full date for last week's same weekday, even under seven days ago", () => {
|
||||
// 2026-09-18 is a Friday. The reply is 6 days 23h45m old and lands on today's weekday,
|
||||
// so a weekday label would read as today.
|
||||
const now = new Date(2026, 8, 25, 11, 47);
|
||||
const date = new Date(2026, 8, 18, 12, 2);
|
||||
const formatted = formatMessageTimestamp(date, now);
|
||||
expect(formatted).toMatch(/Sep|September/);
|
||||
expect(formatted).toMatch(/18/);
|
||||
expect(formatted).not.toMatch(/Friday/);
|
||||
});
|
||||
|
||||
it("includes full date for older timestamps", () => {
|
||||
const now = new Date(2026, 4, 14, 17, 30);
|
||||
const date = new Date(2026, 3, 1, 9, 5);
|
||||
|
||||
@@ -91,12 +91,14 @@ export function formatCompactTimeAgo(date: Date, now: Date = new Date()): string
|
||||
return describeCompactTimeAgo(date, now).label;
|
||||
}
|
||||
|
||||
function isSameLocalDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
/**
|
||||
* How many local midnights lie between two instants: 0 for the same day, 1 for yesterday.
|
||||
* Counted on the calendar rather than in elapsed time, so six days and 23 hours ago on
|
||||
* today's weekday is 7, and rounded so a DST day of 23 or 25 hours still counts as one.
|
||||
*/
|
||||
function localCalendarDaysBetween(earlier: Date, later: Date): number {
|
||||
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
return Math.round((startOfDay(later).getTime() - startOfDay(earlier).getTime()) / DAY_MS);
|
||||
}
|
||||
|
||||
// Cached Intl formatter. Explicitly carrying `hourCycle` from the resolved
|
||||
@@ -120,19 +122,18 @@ function getTimeFormatter(): Intl.DateTimeFormat {
|
||||
/**
|
||||
* Format a chat-message timestamp for hover-revealed UI.
|
||||
* - Same day: "10:11 PM" or "22:11" depending on user preference
|
||||
* - Within ~6 days: "Wednesday 10:11 PM"
|
||||
* - Older: "14 May 2026, 10:11 PM"
|
||||
* - The previous 6 calendar days: "Wednesday 10:11 PM"
|
||||
* - Older, including today's weekday last week: "14 May 2026, 10:11 PM"
|
||||
*/
|
||||
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
|
||||
const time = getTimeFormatter().format(date);
|
||||
const daysAgo = localCalendarDaysBetween(date, now);
|
||||
|
||||
if (isSameLocalDay(date, now)) {
|
||||
if (daysAgo === 0) {
|
||||
return time;
|
||||
}
|
||||
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffDays >= 0 && diffDays < 7) {
|
||||
if (daysAgo > 0 && daysAgo < 7) {
|
||||
const weekday = date.toLocaleDateString(undefined, { weekday: "long" });
|
||||
return `${weekday} ${time}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/server": "0.9.1",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/server": "0.9.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
resolveExistingRunWorkspace,
|
||||
resolveRunCallerAgentId,
|
||||
@@ -8,13 +9,51 @@ import {
|
||||
|
||||
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
||||
|
||||
// Answers fetchAgent the way a daemon does: an unknown id is an error.
|
||||
function daemonWithAgents(...agentIds: string[]) {
|
||||
return {
|
||||
async fetchAgent({ agentId }: { agentId: string }) {
|
||||
if (!agentIds.includes(agentId)) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
return { agent: { id: agentId } };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("managed agent caller context", () => {
|
||||
it("propagates a trimmed PASEO_AGENT_ID", () => {
|
||||
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent");
|
||||
it("uses a trimmed PASEO_AGENT_ID when the target daemon runs that agent", async () => {
|
||||
await expect(
|
||||
resolveRunCallerAgentId(daemonWithAgents("parent-agent"), {
|
||||
PASEO_AGENT_ID: " parent-agent ",
|
||||
}),
|
||||
).resolves.toBe("parent-agent");
|
||||
});
|
||||
|
||||
it("omits blank caller ids", () => {
|
||||
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " " })).toBeUndefined();
|
||||
it("runs without a caller when PASEO_AGENT_ID belongs to another daemon", async () => {
|
||||
await expect(
|
||||
resolveRunCallerAgentId(daemonWithAgents("other-agent"), {
|
||||
PASEO_AGENT_ID: "parent-agent",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails instead of dropping the caller when the lookup loses its connection", async () => {
|
||||
const disconnectedDaemon = {
|
||||
async fetchAgent(): Promise<never> {
|
||||
throw new DaemonConnectionError("Connection lost before message could be sent");
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }),
|
||||
).rejects.toBeInstanceOf(DaemonConnectionError);
|
||||
});
|
||||
|
||||
it("omits blank caller ids", async () => {
|
||||
await expect(
|
||||
resolveRunCallerAgentId(daemonWithAgents(), { PASEO_AGENT_ID: " " }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
StructuredAgentResponseError,
|
||||
} from "@getpaseo/server/agent-response";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
@@ -526,7 +527,7 @@ export async function resolveExistingRunWorkspace(
|
||||
|
||||
// Workspace policy for `paseo run`. Precedence:
|
||||
// 1. --workspace <id> -> run in that existing workspace
|
||||
// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace
|
||||
// 2. caller agent -> daemon resolves the caller's workspace
|
||||
// 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals
|
||||
// 4. --new-workspace <kind> -> mint a new workspace explicitly
|
||||
// 5. bare run -> mint a new local-backed workspace for cwd
|
||||
@@ -534,6 +535,7 @@ async function resolveRunWorkspace(
|
||||
client: ConnectedDaemonClient,
|
||||
options: AgentRunOptions,
|
||||
cwd: string,
|
||||
callerAgentId: string | undefined,
|
||||
): Promise<RunWorkspace> {
|
||||
const newWorkspace = resolveNewWorkspaceKind(options);
|
||||
const explicit = newWorkspace ? undefined : options.workspace?.trim();
|
||||
@@ -542,7 +544,7 @@ async function resolveRunWorkspace(
|
||||
return resolveExistingRunWorkspace(client, explicit);
|
||||
}
|
||||
|
||||
if (!newWorkspace && resolveRunCallerAgentId()) {
|
||||
if (!newWorkspace && callerAgentId) {
|
||||
return { cwd };
|
||||
}
|
||||
|
||||
@@ -609,9 +611,9 @@ export async function runRunCommand(
|
||||
const env = parseRunEnv(options.env);
|
||||
const requestEnv = Object.keys(env).length > 0 ? env : undefined;
|
||||
|
||||
const workspace = await resolveRunWorkspace(client, options, cwd);
|
||||
const callerAgentId = await resolveRunCallerAgentId(client);
|
||||
const workspace = await resolveRunWorkspace(client, options, cwd, callerAgentId);
|
||||
const workspaceId = workspace.id;
|
||||
const callerAgentId = resolveRunCallerAgentId();
|
||||
const runCwd = workspace.cwd;
|
||||
|
||||
if (outputSchema) {
|
||||
@@ -743,8 +745,28 @@ export async function runRunCommand(
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRunCallerAgentId(
|
||||
export interface RunCallerLookupClient {
|
||||
fetchAgent(options: { agentId: string }): Promise<{ agent: { id: string } } | null>;
|
||||
}
|
||||
|
||||
// PASEO_AGENT_ID names an agent on the daemon that launched this shell. A run
|
||||
// sent to another daemon (--host or --home) has no caller there, so it runs as
|
||||
// a top-level agent instead of failing on an unknown caller.
|
||||
export async function resolveRunCallerAgentId(
|
||||
client: RunCallerLookupClient,
|
||||
env: { PASEO_AGENT_ID?: string } = process.env,
|
||||
): string | undefined {
|
||||
return env.PASEO_AGENT_ID?.trim() || undefined;
|
||||
): Promise<string | undefined> {
|
||||
const agentId = env.PASEO_AGENT_ID?.trim();
|
||||
if (!agentId) {
|
||||
return undefined;
|
||||
}
|
||||
const caller = await client.fetchAgent({ agentId }).catch((error: unknown) => {
|
||||
// A daemon without this agent answers with an error. A lost or timed-out
|
||||
// connection is not an answer, so it must not drop the caller.
|
||||
if (error instanceof DaemonConnectionError) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return caller?.agent.id === agentId ? agentId : undefined;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { hashDaemonPassword } from "@getpaseo/server/auth";
|
||||
import { startDaemonInstance, readDaemonInstance } from "@getpaseo/server/daemon-control";
|
||||
import { expect, test } from "vitest";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
@@ -135,8 +136,8 @@ test("managed two-home restart retains its supervisor and never routes ordinary
|
||||
const beforeA = await f.liveStatus(a);
|
||||
const beforeB = await f.liveStatus(b, poisoned);
|
||||
if (process.platform !== "win32") {
|
||||
for (const home of [a, b, path.join(f.root, ".paseo")])
|
||||
expect((await stat(home)).mode & 0o777).toBe(0o700);
|
||||
for (const home of [a, b]) expect((await stat(home)).mode & 0o777).toBe(0o700);
|
||||
expect(existsSync(path.join(f.root, ".paseo"))).toBe(false);
|
||||
}
|
||||
|
||||
const repoB = path.join(f.root, "project-b");
|
||||
@@ -196,6 +197,26 @@ test("removed flags and ambiguous targets fail before side effects; observation
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("local status uses the credential and reports the server id of a password-protected daemon", async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
try {
|
||||
await f.configure(home, `127.0.0.1:${await port()}`);
|
||||
const configPath = path.join(home, "config.json");
|
||||
const config = JSON.parse(await readFile(configPath, "utf8"));
|
||||
config.daemon.auth = { password: await hashDaemonPassword("secret") };
|
||||
await writeFile(configPath, JSON.stringify(config));
|
||||
await f.ok(["start", "--home", home, "--timeout", "30"]);
|
||||
const authenticated = await f.liveStatus(home, { PASEO_PASSWORD: "secret" });
|
||||
expect(await f.ok(["daemon", "status", "--home", home])).toMatchObject({
|
||||
connectedDaemon: "reachable",
|
||||
serverId: authenticated.serverId,
|
||||
});
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("an occupied initial or replacement address fails without false readiness or killing its owner", async () => {
|
||||
const f = await fixture();
|
||||
const [a, b] = f.homes as [string, string];
|
||||
@@ -591,6 +612,25 @@ test("malformed configuration cannot turn a readiness timeout into launch cancel
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("a background start that fails before the supervisor logs names the cause in a log that exists", async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
const configPath = path.join(home, "config.json");
|
||||
try {
|
||||
await mkdir(home, { recursive: true });
|
||||
await writeFile(configPath, '{"version":1,');
|
||||
const failed = await f.run(["daemon", "start", "--home", home, "--timeout", "30"]);
|
||||
expect(failed.code).toBe(1);
|
||||
expect(failed.stderr).toContain(`Logs: ${path.join(home, "daemon.log")}`);
|
||||
expect(failed.stderr).toContain(`Invalid JSON in ${configPath}`);
|
||||
expect(await readFile(path.join(home, "daemon.log"), "utf8")).toContain(
|
||||
`Invalid JSON in ${configPath}`,
|
||||
);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("onboarding preserves an existing MCP choice unless explicitly supplied", async () => {
|
||||
const f = await fixture();
|
||||
const home = f.homes[0]!;
|
||||
|
||||
@@ -12,6 +12,8 @@ import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { DaemonTarget } from "../../utils/daemon-target.js";
|
||||
import { addJsonAndDaemonHostOptions, withGlobalOptions } from "../../utils/command-options.js";
|
||||
import { formatPairingInstructions } from "../../output/pairing.js";
|
||||
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
||||
import { serializeRelayConnectionUri } from "@getpaseo/protocol/daemon-endpoints";
|
||||
|
||||
interface PairOptions {
|
||||
daemonTarget: DaemonTarget;
|
||||
@@ -195,10 +197,13 @@ function outputPairingResult(
|
||||
return;
|
||||
}
|
||||
|
||||
const offer = parseConnectionOfferFromUrl(pairing.url);
|
||||
const connectionUri = offer ? serializeRelayConnectionUri({ offer }) : null;
|
||||
|
||||
if (options.json) {
|
||||
output.writeStdout(
|
||||
`${JSON.stringify(
|
||||
{ relayEnabled: pairing.relayEnabled, url: pairing.url, qr: pairing.qr },
|
||||
{ relayEnabled: pairing.relayEnabled, url: pairing.url, qr: pairing.qr, connectionUri },
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
@@ -210,6 +215,7 @@ function outputPairingResult(
|
||||
formatPairingInstructions({
|
||||
url: pairing.url,
|
||||
qr: pairing.qr,
|
||||
connectionUri,
|
||||
columns: output.columns,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -57,6 +57,17 @@ function createCommandError(code: string, message: string, details?: string): Co
|
||||
return { code, message, ...(details ? { details } : {}) };
|
||||
}
|
||||
|
||||
function terminalPasswordPrompt(): PromptPassword {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw createCommandError(
|
||||
"PASSWORD_TTY_REQUIRED",
|
||||
"paseo daemon set-password needs a terminal to read the password",
|
||||
"Run it in an interactive terminal, or set PASEO_PASSWORD in the daemon's environment instead.",
|
||||
);
|
||||
}
|
||||
return (message) => passwordPrompt({ message });
|
||||
}
|
||||
|
||||
async function promptForPassword(promptPassword: PromptPassword): Promise<string> {
|
||||
const first = await promptPassword("New daemon password");
|
||||
if (isCancel(first)) {
|
||||
@@ -112,7 +123,7 @@ export async function runSetPasswordCommand(
|
||||
const promptPassword =
|
||||
typeof options.promptPassword === "function"
|
||||
? (options.promptPassword as PromptPassword)
|
||||
: (message: string) => passwordPrompt({ message });
|
||||
: terminalPasswordPrompt();
|
||||
const newPassword = await promptForPassword(promptPassword);
|
||||
const result = await setDaemonPasswordInConfig(newPassword, {
|
||||
home: options.daemonTarget.kind === "instance" ? options.daemonTarget.home : undefined,
|
||||
|
||||
@@ -124,6 +124,7 @@ function localStatus(home: string, instance: Awaited<ReturnType<typeof readDaemo
|
||||
pid: instance?.pid ?? null,
|
||||
startedAt: instance?.startedAt ?? null,
|
||||
listen: instance?.listen ?? null,
|
||||
serverId: instance?.serverId ?? undefined,
|
||||
hostname: instance?.hostname ?? null,
|
||||
configuredListen: config.listen,
|
||||
localDaemon,
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface PermissionResponseItem {
|
||||
export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
|
||||
idField: "requestId",
|
||||
columns: [
|
||||
{ header: "REQUEST ID", field: "requestId", width: 12 },
|
||||
{ header: "REQUEST ID", field: (item) => item.requestId.slice(0, 8), width: 12 },
|
||||
{ header: "AGENT", field: "agentShortId", width: 10 },
|
||||
{ header: "TOOL", field: "name", width: 20 },
|
||||
{
|
||||
@@ -32,6 +32,21 @@ export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Transform a permission the agent answered to a response item */
|
||||
export function toPermissionResponseItem(
|
||||
agentId: string,
|
||||
permission: AgentPermissionRequest,
|
||||
result: "allowed" | "denied",
|
||||
): PermissionResponseItem {
|
||||
return {
|
||||
requestId: permission.id,
|
||||
agentId,
|
||||
agentShortId: agentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
export type PermitAllowResult = ListResult<PermissionResponseItem>;
|
||||
|
||||
export interface PermitAllowOptions extends CommandOptions {
|
||||
@@ -118,13 +133,7 @@ export async function runAllowCommand(
|
||||
behavior: "allow",
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
});
|
||||
return {
|
||||
requestId: permission.id.slice(0, 8),
|
||||
agentId: resolvedAgentId,
|
||||
agentShortId: resolvedAgentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result: "allowed",
|
||||
};
|
||||
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { Command } from "commander";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, CommandError } from "../../output/index.js";
|
||||
import { permitResponseSchema, type PermissionResponseItem } from "./allow.js";
|
||||
import {
|
||||
permitResponseSchema,
|
||||
toPermissionResponseItem,
|
||||
type PermissionResponseItem,
|
||||
} from "./allow.js";
|
||||
|
||||
export type PermitDenyResult = ListResult<PermissionResponseItem>;
|
||||
|
||||
@@ -83,13 +87,7 @@ export async function runDenyCommand(
|
||||
...(options.message ? { message: options.message } : {}),
|
||||
...(options.interrupt ? { interrupt: true } : {}),
|
||||
});
|
||||
return {
|
||||
requestId: permission.id.slice(0, 8),
|
||||
agentId: resolvedAgentId,
|
||||
agentShortId: resolvedAgentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result: "denied",
|
||||
};
|
||||
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export const permitLsSchema: OutputSchema<PermissionListItem> = {
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: "AGENT", field: "agentShortId", width: 12 },
|
||||
{ header: "REQ_ID", field: "id", width: 12 },
|
||||
{ header: "REQ_ID", field: (item) => item.id.slice(0, 8), width: 12 },
|
||||
{ header: "TOOL", field: "name", width: 20 },
|
||||
{ header: "DESCRIPTION", field: "description", width: 50 },
|
||||
],
|
||||
@@ -30,7 +30,7 @@ function toListItem(
|
||||
permission: AgentPermissionRequest,
|
||||
): PermissionListItem {
|
||||
return {
|
||||
id: permission.id.slice(0, 8),
|
||||
id: permission.id,
|
||||
agentId: agent.id,
|
||||
agentShortId: agent.id.slice(0, 7),
|
||||
name: permission.name,
|
||||
@@ -40,6 +40,22 @@ function toListItem(
|
||||
|
||||
export type PermitLsResult = ListResult<PermissionListItem>;
|
||||
|
||||
/** Collect the pending permissions of every agent */
|
||||
export function listPendingPermissions(agents: AgentSnapshotPayload[]): PermitLsResult {
|
||||
const items: PermissionListItem[] = [];
|
||||
for (const agent of agents) {
|
||||
for (const permission of agent.pendingPermissions ?? []) {
|
||||
items.push(toListItem(agent, permission));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "list",
|
||||
data: items,
|
||||
schema: permitLsSchema,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PermitLsOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
@@ -55,21 +71,7 @@ export async function runLsCommand(
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
await client.close();
|
||||
|
||||
// Collect all pending permissions from all agents
|
||||
const items: PermissionListItem[] = [];
|
||||
for (const agent of agents) {
|
||||
if (agent.pendingPermissions && agent.pendingPermissions.length > 0) {
|
||||
for (const permission of agent.pendingPermissions) {
|
||||
items.push(toListItem(agent, permission));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "list",
|
||||
data: items,
|
||||
schema: permitLsSchema,
|
||||
};
|
||||
return listPendingPermissions(agents);
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render } from "../../output/index.js";
|
||||
import { permitResponseSchema, toPermissionResponseItem } from "./allow.js";
|
||||
import { listPendingPermissions } from "./ls.js";
|
||||
|
||||
const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f";
|
||||
|
||||
function permissionRequest(id: string): AgentPermissionRequest {
|
||||
return { id, provider: "claude", name: "Write", kind: "tool" };
|
||||
}
|
||||
|
||||
const writeRequest = permissionRequest("permission-cc93510e-a45d-4c4e-a0bf-fc2821f82783");
|
||||
const editRequest = permissionRequest("permission-654ff862-b312-4f6e-8aac-427421ff65eb");
|
||||
|
||||
const agent = {
|
||||
id: agentId,
|
||||
pendingPermissions: [writeRequest, editRequest],
|
||||
} as AgentSnapshotPayload;
|
||||
|
||||
describe("permit ls output", () => {
|
||||
it("emits full request ids that permit allow accepts", () => {
|
||||
const result = listPendingPermissions([agent]);
|
||||
|
||||
const listed = JSON.parse(render(result, { format: "json" })) as Array<{ id: string }>;
|
||||
expect(listed.map((item) => item.id)).toEqual([writeRequest.id, editRequest.id]);
|
||||
expect(render(result, { quiet: true }).split("\n")).toEqual([writeRequest.id, editRequest.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permit allow and deny output", () => {
|
||||
it("emits the full id of the request that was answered", () => {
|
||||
const result = {
|
||||
type: "list" as const,
|
||||
data: [toPermissionResponseItem(agentId, writeRequest, "allowed")],
|
||||
schema: permitResponseSchema,
|
||||
};
|
||||
|
||||
const answered = JSON.parse(render(result, { format: "json" })) as Array<{
|
||||
requestId: string;
|
||||
}>;
|
||||
expect(answered.map((item) => item.requestId)).toEqual([writeRequest.id]);
|
||||
});
|
||||
});
|
||||
@@ -30,4 +30,17 @@ describe("formatPairingInstructions", () => {
|
||||
expect(output).toContain("terminal width could not be detected");
|
||||
expect(output.split("\n")).toContain(URL);
|
||||
});
|
||||
|
||||
it("redacts a password in a printed connection URI", () => {
|
||||
const output = formatPairingInstructions({
|
||||
qr: null,
|
||||
url: URL,
|
||||
connectionUri: "relay://relay.paseo.sh:443/srv_test?key=public&password=private-value",
|
||||
});
|
||||
|
||||
expect(output).toContain(
|
||||
"relay://relay.paseo.sh:443/srv_test?key=public&password=%5Bredacted%5D",
|
||||
);
|
||||
expect(output).not.toContain("private-value");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
|
||||
interface PairingInstructions {
|
||||
url: string;
|
||||
qr: string | null;
|
||||
connectionUri?: string | null;
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
@@ -32,6 +33,23 @@ function formatQr(qr: string | null, columns: number | undefined): string {
|
||||
return qr;
|
||||
}
|
||||
|
||||
export function formatPairingInstructions({ url, qr, columns }: PairingInstructions): string {
|
||||
return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n\nTreat this pairing link like a password. Anyone with it can access this daemon.\n`;
|
||||
function redactConnectionUri(uri: string): string {
|
||||
try {
|
||||
const parsed = new URL(uri);
|
||||
if (parsed.searchParams.has("password")) {
|
||||
parsed.searchParams.set("password", "[redacted]");
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return "Invalid connection URI";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPairingInstructions({
|
||||
url,
|
||||
qr,
|
||||
columns,
|
||||
connectionUri,
|
||||
}: PairingInstructions): string {
|
||||
return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}${connectionUri ? `\n\nConnection URI:\n${redactConnectionUri(connectionUri)}` : ""}\n\nTreat this pairing link like a password. Anyone with it can access this daemon.\n`;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
waitForDaemonReady,
|
||||
resolvePaseoHome,
|
||||
type DaemonInstance,
|
||||
readLocalCredentialForTarget,
|
||||
} from "@getpaseo/server/daemon-control";
|
||||
import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js";
|
||||
export type { DaemonTarget } from "./daemon-target.js";
|
||||
@@ -28,6 +29,12 @@ export interface ConnectOptions {
|
||||
timeout?: number;
|
||||
instance?: DaemonInstance;
|
||||
}
|
||||
export function resolveClientPaseoHome(
|
||||
target: DaemonTarget,
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): string {
|
||||
return target.kind === "instance" ? target.home : resolvePaseoHome(env);
|
||||
}
|
||||
const DEFAULT_TIMEOUT = 15000;
|
||||
type TransportTarget =
|
||||
| { type: "tcp"; url: string }
|
||||
@@ -52,13 +59,20 @@ export function buildDaemonConnectionCommandError(options: ConnectOptions & { er
|
||||
return {
|
||||
code,
|
||||
message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
|
||||
details:
|
||||
options.target.kind === "instance"
|
||||
? `Start with: paseo daemon start --home ${JSON.stringify(options.target.home)}`
|
||||
: "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.",
|
||||
details: describeConnectionRemedy(code, options.target),
|
||||
};
|
||||
}
|
||||
|
||||
function describeConnectionRemedy(code: string, target: DaemonTarget): string {
|
||||
if (code === "AUTH_REQUIRED")
|
||||
return "The daemon requires a password. Set PASEO_PASSWORD and retry.";
|
||||
if (code === "AUTH_FAILED")
|
||||
return "The daemon rejected the password. Check PASEO_PASSWORD and retry.";
|
||||
if (target.kind === "instance")
|
||||
return `Start with: paseo daemon start --home ${JSON.stringify(target.home)}`;
|
||||
return "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.";
|
||||
}
|
||||
|
||||
export function normalizeDaemonHost(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
@@ -165,6 +179,16 @@ export function resolveDaemonPassword(host: string): string | undefined {
|
||||
return fromEnv && fromEnv.length > 0 ? fromEnv : undefined;
|
||||
}
|
||||
|
||||
export function resolveDaemonCredential(
|
||||
host: string,
|
||||
home: string,
|
||||
): { kind: "password"; password: string } | { kind: "localCredential"; token: string } | null {
|
||||
const password = resolveDaemonPassword(host);
|
||||
if (password) return { kind: "password", password };
|
||||
const token = readLocalCredentialForTarget(home, host);
|
||||
return token ? { kind: "localCredential", token } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a WebSocket factory that works in Node.js
|
||||
*/
|
||||
@@ -186,7 +210,8 @@ function createNodeWebSocketFactory() {
|
||||
*/
|
||||
async function tryConnectHost(
|
||||
host: string,
|
||||
password: string | undefined,
|
||||
credential: ReturnType<typeof resolveDaemonCredential>,
|
||||
home: string,
|
||||
clientId: string,
|
||||
timeout: number,
|
||||
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
|
||||
@@ -197,7 +222,10 @@ async function tryConnectHost(
|
||||
clientId,
|
||||
clientType: "cli",
|
||||
appVersion: resolveCliVersion(),
|
||||
password,
|
||||
...(credential?.kind === "password" ? { password: credential.password } : {}),
|
||||
...(credential?.kind === "localCredential"
|
||||
? { localCredential: () => readLocalCredentialForTarget(home, host) ?? undefined }
|
||||
: {}),
|
||||
connectTimeoutMs: timeout,
|
||||
webSocketFactory: (
|
||||
url: string,
|
||||
@@ -280,7 +308,8 @@ async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonCli
|
||||
instance: options.instance,
|
||||
})
|
||||
).listen;
|
||||
const clientId = await getOrCreateCliClientId(resolvePaseoHome({}));
|
||||
const home = resolveClientPaseoHome(options.target);
|
||||
const clientId = await getOrCreateCliClientId(home);
|
||||
const nodeWebSocketFactory = createNodeWebSocketFactory();
|
||||
|
||||
if (explicitHost?.trim().startsWith("ssh://")) {
|
||||
@@ -289,7 +318,8 @@ async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonCli
|
||||
const password = resolveDaemonPassword(explicitHost);
|
||||
const result = await tryConnectHost(
|
||||
tunnel.endpoint,
|
||||
password,
|
||||
password ? { kind: "password", password } : null,
|
||||
home,
|
||||
clientId,
|
||||
Math.max(1, deadline - Date.now()),
|
||||
nodeWebSocketFactory,
|
||||
@@ -323,7 +353,8 @@ async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonCli
|
||||
|
||||
const result = await tryConnectHost(
|
||||
explicitHost,
|
||||
resolveDaemonPassword(explicitHost),
|
||||
resolveDaemonCredential(explicitHost, home),
|
||||
home,
|
||||
clientId,
|
||||
Math.max(1, deadline - Date.now()),
|
||||
nodeWebSocketFactory,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { selectDaemonTarget, describeDaemonTarget } from "./daemon-target.js";
|
||||
import { resolveDaemonCredential, resolveClientPaseoHome } from "./client.js";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
test("explicit selectors win over both environment selectors", () => {
|
||||
const env = { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" };
|
||||
@@ -12,6 +16,15 @@ test("explicit selectors win over both environment selectors", () => {
|
||||
expect(() => selectDaemonTarget({ home: "/tmp/b", host: "chosen:23456" }, {})).toThrow();
|
||||
});
|
||||
|
||||
test("endpoint connections resolve local credentials from PASEO_HOME", () => {
|
||||
expect(
|
||||
resolveClientPaseoHome(
|
||||
{ kind: "endpoint", host: "localhost:6767" },
|
||||
{ PASEO_HOME: "/tmp/custom-home" },
|
||||
),
|
||||
).toBe("/tmp/custom-home");
|
||||
});
|
||||
|
||||
test("local operations ignore routing environment but reject an explicit endpoint", () => {
|
||||
expect(
|
||||
selectDaemonTarget({}, { PASEO_HOME: "/tmp/b", PASEO_HOST: "unused:12345" }, true),
|
||||
@@ -30,3 +43,27 @@ test("endpoint descriptions redact pairing material and credentials", () => {
|
||||
describeDaemonTarget({ kind: "endpoint", host: "https://app.paseo.sh/#offer=private" }),
|
||||
).not.toContain("private");
|
||||
});
|
||||
|
||||
test("CLI selects an explicit password before a matching local credential and never sends the latter remotely", async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), "paseo-cli-credential-"));
|
||||
const previousPassword = process.env.PASEO_PASSWORD;
|
||||
delete process.env.PASEO_PASSWORD;
|
||||
try {
|
||||
const token = "a".repeat(43);
|
||||
await writeFile(join(home, "paseo.pid"), JSON.stringify({ listen: "127.0.0.1:6767" }));
|
||||
await writeFile(join(home, "local-credential"), token);
|
||||
expect(resolveDaemonCredential("tcp://localhost:6767", home)).toEqual({
|
||||
kind: "localCredential",
|
||||
token,
|
||||
});
|
||||
expect(resolveDaemonCredential("tcp://remote.example:6767", home)).toBeNull();
|
||||
expect(resolveDaemonCredential("tcp://localhost:6767?password=explicit", home)).toEqual({
|
||||
kind: "password",
|
||||
password: "explicit",
|
||||
});
|
||||
} finally {
|
||||
if (previousPassword === undefined) delete process.env.PASEO_PASSWORD;
|
||||
else process.env.PASEO_PASSWORD = previousPassword;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Command } from "commander";
|
||||
import { isBearerTokenValid } from "@getpaseo/server/auth";
|
||||
import { isBearerTokenValidAsync } from "@getpaseo/server/auth";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
import {
|
||||
runSetPasswordCommand,
|
||||
setDaemonPasswordInConfig,
|
||||
@@ -60,7 +61,10 @@ try {
|
||||
assert.notStrictEqual(config.daemon.auth.password, "shared-secret");
|
||||
assert.match(config.daemon.auth.password, /^\$2[aby]\$12\$/);
|
||||
assert.strictEqual(
|
||||
isBearerTokenValid({ password: config.daemon.auth.password, token: "shared-secret" }),
|
||||
await isBearerTokenValidAsync({
|
||||
password: config.daemon.auth.password,
|
||||
token: "shared-secret",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
console.log("✓ set-password writes bcrypt hash without clobbering config\n");
|
||||
@@ -80,7 +84,7 @@ try {
|
||||
|
||||
assert.strictEqual(result.data.action, "password_set");
|
||||
assert.strictEqual(
|
||||
isBearerTokenValid({ password: config.daemon.auth.password, token: "new-secret" }),
|
||||
await isBearerTokenValidAsync({ password: config.daemon.auth.password, token: "new-secret" }),
|
||||
true,
|
||||
);
|
||||
console.log("✓ command accepts matching confirmation\n");
|
||||
@@ -105,6 +109,27 @@ try {
|
||||
);
|
||||
console.log("✓ command refuses password mismatch\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 4: piped stdin fails with a message instead of exiting silently");
|
||||
const pipedHome = join(root, "piped");
|
||||
await mkdir(pipedHome, { recursive: true });
|
||||
const run = runLocalPaseo(["daemon", "set-password", "--home", pipedHome, "--json"]);
|
||||
run.stdin.end("x\nx\n");
|
||||
const result = await run;
|
||||
|
||||
assert.strictEqual(result.exitCode, 1, result.stderr);
|
||||
const error = JSON.parse(result.stderr).error as {
|
||||
code: string;
|
||||
message: string;
|
||||
details: string;
|
||||
};
|
||||
assert.strictEqual(error.code, "PASSWORD_TTY_REQUIRED");
|
||||
assert.match(error.message, /needs a terminal/);
|
||||
assert.match(error.details, /PASEO_PASSWORD/);
|
||||
await assert.rejects(readFile(join(pipedHome, "config.json"), "utf-8"));
|
||||
console.log("✓ piped stdin reports that a terminal is required\n");
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const daemon = await startTestDaemon({
|
||||
|
||||
try {
|
||||
{
|
||||
console.log("Test 1: status reports password requirement without marking daemon unreachable");
|
||||
console.log("Test 1: local status uses the daemon's local credential");
|
||||
const result = await runLocalPaseo(["daemon", "status", "--json"], {
|
||||
PASEO_HOME: daemon.paseoHome,
|
||||
PASEO_HOST: "",
|
||||
@@ -26,12 +26,11 @@ try {
|
||||
const status = JSON.parse(result.stdout);
|
||||
|
||||
assert.strictEqual(status.localDaemon, "running");
|
||||
assert.strictEqual(status.connectedDaemon, "auth_required");
|
||||
assert.strictEqual(status.connectedDaemon, "reachable");
|
||||
assert(!("runningAgents" in status), "status should not fetch agent counts");
|
||||
assert(!("idleAgents" in status), "status should not fetch agent counts");
|
||||
assert.match(status.note, /Password required/i);
|
||||
assert.doesNotMatch(status.note, /not reachable/i);
|
||||
console.log("✓ missing password reports auth_required\n");
|
||||
assert.doesNotMatch(status.note ?? "", /Password required|not reachable/i);
|
||||
console.log("✓ missing password uses local credential\n");
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
import { startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Daemon Auth Command Errors ===\n");
|
||||
|
||||
const daemon = await startTestDaemon({
|
||||
env: { PASEO_PASSWORD: "shared-secret" },
|
||||
});
|
||||
|
||||
async function lsError(password: string, remoteClient = false) {
|
||||
const result = await runLocalPaseo(["ls", "--json"], {
|
||||
PASEO_HOME: remoteClient ? "" : daemon.paseoHome,
|
||||
PASEO_HOST: remoteClient ? `127.0.0.1:${daemon.port}` : "",
|
||||
PASEO_PASSWORD: password,
|
||||
});
|
||||
assert.notStrictEqual(result.exitCode, 0, "ls should fail without a valid password");
|
||||
return JSON.parse(result.stderr).error as {
|
||||
code: string;
|
||||
message: string;
|
||||
details: string;
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
{
|
||||
console.log("Test 1: a client outside the daemon home needs PASEO_PASSWORD");
|
||||
const error = await lsError("", true);
|
||||
assert.strictEqual(error.code, "AUTH_REQUIRED");
|
||||
assert.match(error.message, /Password required/);
|
||||
assert.match(error.details, /PASEO_PASSWORD/);
|
||||
assert.doesNotMatch(error.details, /daemon start/);
|
||||
console.log("✓ client without the local credential is asked for PASEO_PASSWORD\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: wrong password asks for PASEO_PASSWORD, not a daemon start");
|
||||
const error = await lsError("wrong-secret");
|
||||
assert.strictEqual(error.code, "AUTH_FAILED");
|
||||
assert.match(error.message, /Incorrect password/);
|
||||
assert.match(error.details, /PASEO_PASSWORD/);
|
||||
assert.doesNotMatch(error.details, /daemon start/);
|
||||
console.log("✓ wrong password points at PASEO_PASSWORD\n");
|
||||
}
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
|
||||
console.log("=== Daemon Auth Command Errors Tests Passed ===");
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -125,6 +125,7 @@ export class ConnectionSubscriptions extends OwnedSubscriptions {
|
||||
// Protocol support belongs to the installed client. Only browser hosting needs
|
||||
// a resource supplied by the caller. Keep this exhaustive as the protocol evolves.
|
||||
export const DEFAULT_CLIENT_CAPABILITIES = {
|
||||
[CLIENT_CAPS.helloRejection]: true,
|
||||
[CLIENT_CAPS.ownedSubscriptions]: true,
|
||||
[CLIENT_CAPS.allProviders]: true,
|
||||
[CLIENT_CAPS.selectiveAgentTimeline]: true,
|
||||
|
||||
@@ -20,12 +20,19 @@ export function createRelayE2eeTransportFactory(args: {
|
||||
daemonPublicKeyB64: string;
|
||||
logger: TransportLogger;
|
||||
}): DaemonTransportFactory {
|
||||
return ({ url, headers }) => {
|
||||
const base = args.baseFactory({ url, headers });
|
||||
return ({ url }) => {
|
||||
const base = args.baseFactory({ url });
|
||||
return createEncryptedTransport(base, args.daemonPublicKeyB64, args.logger);
|
||||
};
|
||||
}
|
||||
|
||||
export function createRelayTransportFactory(
|
||||
baseFactory: DaemonTransportFactory,
|
||||
): DaemonTransportFactory {
|
||||
// The relay upgrade precedes E2EE. Only the encrypted hello may carry daemon credentials.
|
||||
return ({ url }) => baseFactory({ url });
|
||||
}
|
||||
|
||||
export function createEncryptedTransport(
|
||||
base: DaemonTransport,
|
||||
daemonPublicKeyB64: string,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user