Merge branch 'managed-daemon-bundling'

This commit is contained in:
Mohamed Boudra
2026-03-08 20:48:39 +07:00
47 changed files with 6244 additions and 508 deletions
+197 -14
View File
@@ -3,12 +3,12 @@ name: Desktop Release
on:
push:
tags:
- 'v*'
- 'desktop-v*'
- "v*"
- "desktop-v*"
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to build (e.g. v0.1.0)'
description: "Existing tag to build (e.g. v0.1.0)"
required: true
type: string
@@ -16,14 +16,22 @@ concurrency:
group: desktop-release-${{ github.ref }}
cancel-in-progress: false
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
jobs:
publish-tauri:
publish-macos:
strategy:
matrix:
include:
- runner: macos-14
rust_target: aarch64-apple-darwin
- runner: macos-13
rust_target: x86_64-apple-darwin
permissions:
contents: write
packages: read
runs-on: macos-latest
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
@@ -34,15 +42,15 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
node-version: "22"
cache: "npm"
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
targets: ${{ matrix.rust_target }}
- name: Install JS dependencies
run: npm ci
@@ -91,7 +99,7 @@ jobs:
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
NODE
- name: Build and publish Tauri release
- name: Build and publish macOS Tauri release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -110,4 +118,179 @@ jobs:
releaseBody: See the assets to download and install this version.
releaseDraft: false
prerelease: false
args: --target universal-apple-darwin
args: --target ${{ matrix.rust_target }}
publish-linux:
permissions:
contents: write
packages: read
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Install Linux packaging dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build web app for Tauri
run: npm run build:web --workspace=@getpaseo/app
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const rawTag = process.env.RELEASE_TAG;
if (!rawTag) throw new Error('RELEASE_TAG env var is missing');
const version = rawTag.replace(/^desktop-/, '').replace(/^v/, '');
console.log(`Using desktop version ${version} from tag ${rawTag}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) {
throw new Error(`Failed to find version field in ${tauriConfPath}`);
}
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false;
let updated = false;
const nextLines = cargoLines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
NODE
- name: Build and publish Linux Tauri release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}
releaseName: Paseo ${{ env.RELEASE_TAG }}
releaseBody: See the assets to download and install this version.
releaseDraft: false
prerelease: false
args: --bundles appimage
publish-windows:
permissions:
contents: write
packages: read
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build web app for Tauri
run: npm run build:web --workspace=@getpaseo/app
- name: Set desktop version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const rawTag = process.env.RELEASE_TAG;
if (!rawTag) throw new Error('RELEASE_TAG env var is missing');
const version = rawTag.replace(/^desktop-/, '').replace(/^v/, '');
console.log(`Using desktop version ${version} from tag ${rawTag}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) {
throw new Error(`Failed to find version field in ${tauriConfPath}`);
}
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false;
let updated = false;
const nextLines = cargoLines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
NODE
- name: Build and publish Windows Tauri release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}
releaseName: Paseo ${{ env.RELEASE_TAG }}
releaseBody: See the assets to download and install this version.
releaseDraft: false
prerelease: false
args: --bundles nsis,msi
+2
View File
@@ -76,3 +76,5 @@ packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
/artifacts
packages/desktop/.cache/
packages/desktop/src-tauri/resources/managed-runtime/
+81
View File
@@ -0,0 +1,81 @@
# Runtime Simplification Plan
## Goal
Remove the managed runtime install/copy layer. The desktop app must run the bundled runtime in place from the application resources/install directory on macOS, Windows, and Linux.
This is a simplification task. If a change preserves the old install-manager shape under a new name, it fails the goal.
## Hard Requirements
1. The bundled runtime is read-only.
The desktop app must not copy the bundled runtime to app data, temp, cache, or any other writable directory.
2. The app must execute Node/CLI/server entrypoints directly from the bundled runtime root.
On macOS this means inside `Paseo.app/Contents/Resources/...`.
On Windows and Linux this means inside the installed app resources directory.
3. There is exactly one runtime per installed app.
Remove versioned installed runtime directories like `runtime/<runtime-id>` from the runtime execution path.
4. The CLI shim may point into the installed app bundle/directory.
Do not keep an extra installed runtime tree just to preserve a stable shim target across updates.
5. All mutable state remains outside the bundled runtime.
Logs, sockets/pipes, PID files, daemon state, `PASEO_HOME`, and any other writable files must continue to live in managed home / app data locations.
6. Runtime discovery must stay cross-platform.
The implementation must resolve the bundled runtime/resources path on macOS, Windows, and Linux using the app install/resources directory, not hardcoded `.app` assumptions.
7. Remove dead machinery, do not leave adapters behind.
If install/copy/versioned-runtime code becomes unused, delete it instead of keeping fallback paths "just in case".
## Non-Goals
1. Do not change how the runtime is built into the desktop app bundle in this task.
This task is about runtime execution and path management, not bundling format.
2. Do not reintroduce a second runtime location for migration compatibility.
It is acceptable if older installed clients do not migrate cleanly.
3. Do not add feature flags, env-guarded fallback paths, or compatibility shims unless absolutely required by a real platform constraint proven in code.
## Concrete Implementation Direction
1. Treat `bundled_runtime_root(app)` plus `current-runtime.json` as the runtime source of truth.
2. Replace `paths.runtime_root` usage for runtime execution with the bundled runtime root selected by `current-runtime.json`.
3. Delete `install_runtime_if_needed(...)` and related copy/install staging logic if nothing else still needs it.
4. Rework CLI shim generation so the inner launcher points at the bundled runtime's Node + CLI entrypoint directly, while still keeping mutable state (`PASEO_HOME`) outside the runtime.
5. Simplify `ManagedPaths` and related structs if `runtime_root` and `stable_runtime_root` no longer need separate installed-runtime semantics.
6. Keep diagnostics/status reporting accurate.
If the app reports bundled vs installed runtime roots today, update that output to reflect the new single-runtime model.
## Acceptance Criteria
1. No code path copies the bundled runtime tree into app data before launching the daemon or CLI.
2. No code path depends on a versioned installed runtime directory for execution.
3. The CLI shim and daemon launch path both resolve to bundled runtime executables/resources.
4. Typecheck passes.
5. Relevant desktop/runtime tests pass, updated to reflect the new direct-from-bundle model.
6. The resulting implementation is materially simpler:
fewer runtime path concepts, fewer staging/install branches, fewer indirections.
## Review Bar
The implementation should be rejected if:
- it still copies the runtime anywhere before execution
- it keeps runtime version directories in the execution path
- it preserves the stable installed runtime launcher concept without a hard platform reason
- it adds migration complexity for old installs
- it introduces new fallback branches instead of removing obsolete ones
@@ -65,7 +65,7 @@ export function AddHostMethodModal({
<Link2 size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Direct connection</Text>
<Text style={styles.optionSubtext}>Local network or Tailscale (unencrypted).</Text>
<Text style={styles.optionSubtext}>Local network or VPN.</Text>
</View>
</Pressable>
@@ -74,7 +74,7 @@ export function AddHostMethodModal({
<QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Scan QR code</Text>
<Text style={styles.optionSubtext}>Relay pairing (E2EE).</Text>
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
</View>
</Pressable>
) : null}
@@ -83,7 +83,7 @@ export function AddHostMethodModal({
<ClipboardPaste size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Paste pairing link</Text>
<Text style={styles.optionSubtext}>Relay pairing (E2EE).</Text>
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
</View>
</Pressable>
</AdaptiveModalSheet>
@@ -103,13 +103,13 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title:
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = "Connection was refused. Is the daemon running on that host and port?";
detail = "Connection refused. Is the server running at this address?";
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = "Host not found. Check the hostname and try again.";
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = "Host is unreachable. Check your network and firewall.";
} else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) {
detail = "TLS/certificate error. This app expects a daemon reachable over the local network or via relay.";
detail = "TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
} else if (raw) {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
} else {
@@ -179,7 +179,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
setIsSaving(true);
setErrorMessage("");
const { serverId, hostname } = await probeConnection({ id: "probe", type: "direct", endpoint });
const { serverId, hostname } = await probeConnection({
id: "probe",
type: "directTcp",
endpoint,
});
if (targetServerId && serverId !== targetServerId) {
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
setErrorMessage(message);
@@ -217,7 +221,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
return (
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
<Text style={styles.helper}>Connect to a daemon by entering host:port.</Text>
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
<View style={styles.field}>
<Text style={styles.label}>Host</Text>
@@ -225,7 +229,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
ref={hostInputRef}
value={endpointRaw}
onChangeText={setEndpointRaw}
placeholder="host:6767"
placeholder="hostname:port"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
@@ -149,7 +149,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
return (
<AdaptiveModalSheet title="Paste pairing link" visible={visible} onClose={handleClose} testID="pair-link-modal">
<Text style={styles.helper}>Paste the daemon’s pairing link.</Text>
<Text style={styles.helper}>Paste the pairing link from your server.</Text>
<View style={styles.field}>
<Text style={styles.label}>Pairing link</Text>
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
hostHasDirectEndpoint,
registryHasDirectEndpoint,
reconcileDesktopStartupRegistry,
resolveManagedDesktopStartupStatus,
type HostProfile,
} from './daemon-registry-context'
@@ -10,6 +12,12 @@ function makeHost(input: Partial<HostProfile> & Pick<HostProfile, 'serverId'>):
return {
serverId: input.serverId,
label: input.label ?? input.serverId,
lifecycle: input.lifecycle ?? {
managed: false,
managedRuntimeId: null,
managedRuntimeVersion: null,
associatedServerId: null,
},
connections: input.connections ?? [],
preferredConnectionId: input.preferredConnectionId ?? null,
createdAt: input.createdAt ?? now,
@@ -21,7 +29,7 @@ describe('hostHasDirectEndpoint', () => {
it('returns true when host has matching direct endpoint', () => {
const host = makeHost({
serverId: 'srv_local',
connections: [{ id: 'direct:localhost:6767', type: 'direct', endpoint: 'localhost:6767' }],
connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }],
preferredConnectionId: 'direct:localhost:6767',
})
@@ -51,12 +59,12 @@ describe('registryHasDirectEndpoint', () => {
const hosts: HostProfile[] = [
makeHost({
serverId: 'srv_one',
connections: [{ id: 'direct:127.0.0.1:7777', type: 'direct', endpoint: '127.0.0.1:7777' }],
connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }],
preferredConnectionId: 'direct:127.0.0.1:7777',
}),
makeHost({
serverId: 'srv_two',
connections: [{ id: 'direct:localhost:6767', type: 'direct', endpoint: 'localhost:6767' }],
connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }],
preferredConnectionId: 'direct:localhost:6767',
}),
]
@@ -68,7 +76,7 @@ describe('registryHasDirectEndpoint', () => {
const hosts: HostProfile[] = [
makeHost({
serverId: 'srv_one',
connections: [{ id: 'direct:127.0.0.1:7777', type: 'direct', endpoint: '127.0.0.1:7777' }],
connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }],
preferredConnectionId: 'direct:127.0.0.1:7777',
}),
]
@@ -76,3 +84,240 @@ describe('registryHasDirectEndpoint', () => {
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false)
})
})
describe('reconcileDesktopStartupRegistry', () => {
it('seeds managed and localhost connections as normal host entries', () => {
const now = '2026-03-08T00:00:00.000Z'
const result = reconcileDesktopStartupRegistry({
existing: [],
managed: {
serverId: 'srv_managed',
hostname: 'managed-host',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_managed',
},
localhost: {
serverId: 'srv_localhost',
hostname: 'local-dev',
endpoint: 'localhost:6767',
},
now,
})
expect(result).toEqual([
makeHost({
serverId: 'srv_managed',
label: 'managed-host',
lifecycle: {
managed: true,
managedRuntimeId: 'runtime_1',
managedRuntimeVersion: '1.2.3',
associatedServerId: 'srv_managed',
},
connections: [
{
id: 'socket:/Users/test/.paseo-test/paseo.sock',
type: 'directSocket',
path: '/Users/test/.paseo-test/paseo.sock',
},
],
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
createdAt: now,
updatedAt: now,
}),
makeHost({
serverId: 'srv_localhost',
label: 'local-dev',
connections: [
{
id: 'direct:localhost:6767',
type: 'directTcp',
endpoint: 'localhost:6767',
},
],
preferredConnectionId: 'direct:localhost:6767',
createdAt: now,
updatedAt: now,
}),
])
})
it('keeps managed and localhost connections together when they resolve to the same server', () => {
const now = '2026-03-08T00:00:00.000Z'
const result = reconcileDesktopStartupRegistry({
existing: [],
managed: {
serverId: 'srv_shared',
hostname: 'devbox',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_shared',
},
localhost: {
serverId: 'srv_shared',
hostname: 'devbox',
endpoint: 'localhost:6767',
},
now,
})
expect(result).toEqual([
makeHost({
serverId: 'srv_shared',
label: 'devbox',
lifecycle: {
managed: true,
managedRuntimeId: 'runtime_1',
managedRuntimeVersion: '1.2.3',
associatedServerId: 'srv_shared',
},
connections: [
{
id: 'socket:/Users/test/.paseo-test/paseo.sock',
type: 'directSocket',
path: '/Users/test/.paseo-test/paseo.sock',
},
{
id: 'direct:localhost:6767',
type: 'directTcp',
endpoint: 'localhost:6767',
},
],
preferredConnectionId: 'socket:/Users/test/.paseo-test/paseo.sock',
createdAt: now,
updatedAt: now,
}),
])
})
it('is idempotent for repeated desktop startup reconciliation', () => {
const now = '2026-03-08T00:00:00.000Z'
const first = reconcileDesktopStartupRegistry({
existing: [],
managed: {
serverId: 'srv_shared',
hostname: 'devbox',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_shared',
},
localhost: {
serverId: 'srv_shared',
hostname: 'devbox',
endpoint: 'localhost:6767',
},
now,
})
const second = reconcileDesktopStartupRegistry({
existing: first,
managed: {
serverId: 'srv_shared',
hostname: 'devbox',
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
transportType: 'socket',
transportPath: '/Users/test/.paseo-test/paseo.sock',
associatedServerId: 'srv_shared',
},
localhost: {
serverId: 'srv_shared',
hostname: 'devbox',
endpoint: 'localhost:6767',
},
now: '2026-03-09T00:00:00.000Z',
})
expect(second).toEqual(first)
})
})
describe('resolveManagedDesktopStartupStatus', () => {
it('starts the managed daemon when management is enabled', async () => {
const managedStatus = {
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
runtimeRoot: '/runtime',
managedHome: '/home',
transportType: 'socket',
transportPath: '/tmp/paseo.sock',
daemonPid: 123,
daemonRunning: true,
daemonStatus: 'running',
logPath: '/tmp/daemon.log',
serverId: 'srv_managed',
hostname: 'managed-host',
relayEnabled: true,
tcpEnabled: false,
tcpListen: null,
cliShimPath: null,
}
let startCalls = 0
let statusCalls = 0
const result = await resolveManagedDesktopStartupStatus({
settings: { manageBuiltInDaemon: true },
startManagedDaemonFn: async () => {
startCalls += 1
return managedStatus
},
getManagedDaemonStatusFn: async () => {
statusCalls += 1
return managedStatus
},
})
expect(result).toEqual(managedStatus)
expect(startCalls).toBe(1)
expect(statusCalls).toBe(0)
})
it('only reads managed daemon status when management is paused', async () => {
const managedStatus = {
runtimeId: 'runtime_1',
runtimeVersion: '1.2.3',
runtimeRoot: '/runtime',
managedHome: '/home',
transportType: 'socket',
transportPath: '/tmp/paseo.sock',
daemonPid: null,
daemonRunning: false,
daemonStatus: 'stopped',
logPath: '/tmp/daemon.log',
serverId: null,
hostname: null,
relayEnabled: true,
tcpEnabled: false,
tcpListen: null,
cliShimPath: null,
}
let startCalls = 0
let statusCalls = 0
const result = await resolveManagedDesktopStartupStatus({
settings: { manageBuiltInDaemon: false },
startManagedDaemonFn: async () => {
startCalls += 1
return managedStatus
},
getManagedDaemonStatusFn: async () => {
statusCalls += 1
return managedStatus
},
})
expect(result).toEqual(managedStatus)
expect(startCalls).toBe(0)
expect(statusCalls).toBe(1)
})
})
@@ -4,33 +4,67 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
import { probeConnection } from '@/utils/test-daemon-connection'
import { useAppSettings, type AppSettings } from '@/hooks/use-settings'
import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer'
import {
getManagedDaemonStatus,
type ManagedDaemonStatus,
shouldUseManagedDesktopDaemon,
startManagedDaemon,
} from '@/desktop/managed-runtime/managed-runtime'
const REGISTRY_STORAGE_KEY = '@paseo:daemon-registry'
const DAEMON_REGISTRY_QUERY_KEY = ['daemon-registry']
const DEFAULT_LOCALHOST_ENDPOINT = 'localhost:6767'
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1'
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_TIMEOUT_MS = 6000
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_RETRY_MS = 2000
const DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_DEADLINE_MS = 120000
const E2E_STORAGE_KEY = '@paseo:e2e'
export type DirectHostConnection = {
export type DirectTcpHostConnection = {
id: string
type: 'direct'
endpoint: string // host:port
type: 'directTcp'
endpoint: string
}
export type DirectSocketHostConnection = {
id: string
type: 'directSocket'
path: string
}
export type DirectPipeHostConnection = {
id: string
type: 'directPipe'
path: string
}
export type RelayHostConnection = {
id: string
type: 'relay'
relayEndpoint: string // host:port
relayEndpoint: string
daemonPublicKeyB64: string
}
export type HostConnection = DirectHostConnection | RelayHostConnection
export type HostConnection =
| DirectTcpHostConnection
| DirectSocketHostConnection
| DirectPipeHostConnection
| RelayHostConnection
export type HostLifecycle = {
managed: boolean
managedRuntimeId: string | null
managedRuntimeVersion: string | null
associatedServerId: string | null
}
export type HostProfile = {
serverId: string
label: string
lifecycle: HostLifecycle
connections: HostConnection[]
preferredConnectionId: string | null
createdAt: string
@@ -39,6 +73,29 @@ export type HostProfile = {
export type UpdateHostInput = Partial<Omit<HostProfile, 'serverId' | 'createdAt'>>
export type ManagedHostReconciliationInput = {
serverId: string
hostname?: string | null
runtimeId: string
runtimeVersion: string
transportType: string
transportPath: string
associatedServerId?: string | null
}
export type LocalhostHostReconciliationInput = {
serverId: string
hostname: string | null
endpoint: string
}
export type DesktopStartupReconciliationInput = {
existing: HostProfile[]
managed: ManagedHostReconciliationInput | null
localhost: LocalhostHostReconciliationInput | null
now?: string
}
interface DaemonRegistryContextValue {
daemons: HostProfile[]
isLoading: boolean
@@ -63,6 +120,20 @@ interface DaemonRegistryContextValue {
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null)
function defaultLifecycle(): HostLifecycle {
return {
managed: false,
managedRuntimeId: null,
managedRuntimeVersion: null,
associatedServerId: null,
}
}
function normalizeHostLabel(value: string | null | undefined, serverId: string): string {
const trimmed = value?.trim() ?? ''
return trimmed.length > 0 ? trimmed : serverId
}
function normalizeEndpointOrNull(endpoint: string): string | null {
try {
return normalizeHostPort(endpoint)
@@ -71,8 +142,401 @@ function normalizeEndpointOrNull(endpoint: string): string | null {
}
}
function isDefaultLocalhostConnection(connection: HostConnection): boolean {
return connection.type === 'direct' && connection.endpoint === DEFAULT_LOCALHOST_ENDPOINT
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
function normalizeManagedTransportConnection(input: {
transportType: string
transportPath: string
}): HostConnection | null {
const transportPath = input.transportPath.trim()
if (!transportPath) {
return null
}
if (input.transportType === 'tcp') {
try {
const endpoint = normalizeHostPort(transportPath)
return {
id: `direct:${endpoint}`,
type: 'directTcp',
endpoint,
}
} catch {
return null
}
}
if (input.transportType === 'pipe') {
return {
id: `pipe:${transportPath}`,
type: 'directPipe',
path: transportPath,
}
}
if (input.transportType === 'socket') {
return {
id: `socket:${transportPath}`,
type: 'directSocket',
path: transportPath,
}
}
return null
}
function normalizeStoredConnection(connection: unknown): HostConnection | null {
if (!connection || typeof connection !== 'object') {
return null
}
const record = connection as Record<string, unknown>
const type = typeof record.type === 'string' ? record.type : null
if (type === 'directTcp') {
try {
const endpoint = normalizeHostPort(String(record.endpoint ?? ''))
return { id: `direct:${endpoint}`, type: 'directTcp', endpoint }
} catch {
return null
}
}
if (type === 'directSocket') {
const path = String(record.path ?? '').trim()
return path ? { id: `socket:${path}`, type: 'directSocket', path } : null
}
if (type === 'directPipe') {
const path = String(record.path ?? '').trim()
return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null
}
if (type === 'relay') {
try {
const relayEndpoint = normalizeHostPort(String(record.relayEndpoint ?? ''))
const daemonPublicKeyB64 = String(record.daemonPublicKeyB64 ?? '').trim()
if (!daemonPublicKeyB64) return null
return {
id: `relay:${relayEndpoint}`,
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
}
} catch {
return null
}
}
return null
}
function normalizeStoredLifecycle(lifecycle: unknown): HostLifecycle {
const record =
lifecycle && typeof lifecycle === 'object' ? (lifecycle as Record<string, unknown>) : null
return {
managed: record?.managed === true,
managedRuntimeId:
typeof record?.managedRuntimeId === 'string' ? record.managedRuntimeId : null,
managedRuntimeVersion:
typeof record?.managedRuntimeVersion === 'string' ? record.managedRuntimeVersion : null,
associatedServerId:
typeof record?.associatedServerId === 'string' ? record.associatedServerId : null,
}
}
function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
if (!entry || typeof entry !== 'object') {
return null
}
const record = entry as Record<string, unknown>
const serverId = typeof record.serverId === 'string' ? record.serverId.trim() : ''
if (!serverId) {
return null
}
const rawConnections = Array.isArray(record.connections) ? record.connections : []
const connections = rawConnections
.map((connection) => normalizeStoredConnection(connection))
.filter((connection): connection is HostConnection => connection !== null)
if (connections.length === 0) {
return null
}
const lifecycle = normalizeStoredLifecycle(record.lifecycle)
const now = new Date().toISOString()
const label = normalizeHostLabel(
typeof record.label === 'string' ? record.label : null,
serverId
)
const preferredConnectionId =
typeof record.preferredConnectionId === 'string' &&
connections.some((connection) => connection.id === record.preferredConnectionId)
? record.preferredConnectionId
: connections[0]?.id ?? null
return {
serverId,
label,
lifecycle,
connections,
preferredConnectionId,
createdAt: typeof record.createdAt === 'string' ? record.createdAt : now,
updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : now,
}
}
function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean {
if (left.type !== right.type || left.id !== right.id) {
return false
}
if (left.type === 'directTcp' && right.type === 'directTcp') {
return left.endpoint === right.endpoint
}
if (left.type === 'directSocket' && right.type === 'directSocket') {
return left.path === right.path
}
if (left.type === 'directPipe' && right.type === 'directPipe') {
return left.path === right.path
}
if (left.type === 'relay' && right.type === 'relay') {
return (
left.relayEndpoint === right.relayEndpoint &&
left.daemonPublicKeyB64 === right.daemonPublicKeyB64
)
}
return false
}
function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean {
return (
left.managed === right.managed &&
left.managedRuntimeId === right.managedRuntimeId &&
left.managedRuntimeVersion === right.managedRuntimeVersion &&
left.associatedServerId === right.associatedServerId
)
}
function upsertHostConnectionInProfiles(input: {
profiles: HostProfile[]
serverId: string
label?: string
lifecycle?: Partial<HostLifecycle>
connection: HostConnection
now?: string
}): HostProfile[] {
const serverId = input.serverId.trim()
if (!serverId) {
throw new Error('serverId is required')
}
const now = input.now ?? new Date().toISOString()
const labelTrimmed = input.label?.trim() ?? ''
const derivedLabel = labelTrimmed || serverId
const existing = input.profiles
const idx = existing.findIndex((daemon) => daemon.serverId === serverId)
if (idx === -1) {
const profile: HostProfile = {
serverId,
label: derivedLabel,
lifecycle: {
...defaultLifecycle(),
...(input.lifecycle ?? {}),
},
connections: [input.connection],
preferredConnectionId: input.connection.id,
createdAt: now,
updatedAt: now,
}
return [...existing, profile]
}
const prev = existing[idx]!
const connectionIdx = prev.connections.findIndex((connection) => connection.id === input.connection.id)
const hadConnection = connectionIdx !== -1
const connectionChanged =
connectionIdx === -1
? true
: !hostConnectionEquals(prev.connections[connectionIdx]!, input.connection)
const nextConnections =
connectionIdx === -1
? [...prev.connections, input.connection]
: connectionChanged
? prev.connections.map((connection, index) =>
index === connectionIdx ? input.connection : connection
)
: prev.connections
const nextLifecycle = {
...prev.lifecycle,
...(input.lifecycle ?? {}),
}
const nextLabel = labelTrimmed ? labelTrimmed : prev.label
const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id
const changed =
nextLabel !== prev.label ||
nextPreferredConnectionId !== prev.preferredConnectionId ||
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
!hadConnection ||
connectionChanged
if (!changed) {
return existing
}
const nextProfile: HostProfile = {
...prev,
label: nextLabel,
lifecycle: nextLifecycle,
connections: nextConnections,
preferredConnectionId: nextPreferredConnectionId,
updatedAt: now,
}
const next = [...existing]
next[idx] = nextProfile
return next
}
function reconcileManagedHostInProfiles(input: {
profiles: HostProfile[]
managed: ManagedHostReconciliationInput
now?: string
}): HostProfile[] {
const connection = normalizeManagedTransportConnection(input.managed)
if (!connection) {
throw new Error(`Unsupported managed daemon transport: ${input.managed.transportType}`)
}
const nextBase = input.profiles.filter((daemon) => {
return !daemon.lifecycle.managed || daemon.serverId === input.managed.serverId
})
const profiles = nextBase.length === input.profiles.length ? input.profiles : nextBase
return upsertHostConnectionInProfiles({
profiles,
serverId: input.managed.serverId,
label: input.managed.hostname ?? undefined,
lifecycle: {
managed: true,
managedRuntimeId: input.managed.runtimeId,
managedRuntimeVersion: input.managed.runtimeVersion,
associatedServerId:
input.managed.associatedServerId?.trim() || input.managed.serverId,
},
connection,
now: input.now,
})
}
export function reconcileDesktopStartupRegistry(
input: DesktopStartupReconciliationInput
): HostProfile[] {
let next = input.existing
if (input.managed) {
next = reconcileManagedHostInProfiles({
profiles: next,
managed: input.managed,
now: input.now,
})
}
if (input.localhost) {
next = upsertHostConnectionInProfiles({
profiles: next,
serverId: input.localhost.serverId,
label: input.localhost.hostname ?? undefined,
connection: {
id: `direct:${input.localhost.endpoint}`,
type: 'directTcp',
endpoint: input.localhost.endpoint,
},
now: input.now,
})
}
return next
}
export async function resolveManagedDesktopStartupStatus(input: {
settings: Pick<AppSettings, 'manageBuiltInDaemon'>
startManagedDaemonFn?: () => Promise<ManagedDaemonStatus>
getManagedDaemonStatusFn?: () => Promise<ManagedDaemonStatus>
}): Promise<ManagedDaemonStatus> {
if (input.settings.manageBuiltInDaemon) {
return await (input.startManagedDaemonFn ?? startManagedDaemon)()
}
return await (input.getManagedDaemonStatusFn ?? getManagedDaemonStatus)()
}
async function probeManagedStartupTarget(input: {
managedDaemon: ManagedDaemonStatus
cancelled?: () => boolean
}): Promise<ManagedHostReconciliationInput | null> {
const connection = normalizeManagedTransportConnection({
transportType: input.managedDaemon.transportType,
transportPath: input.managedDaemon.transportPath,
})
if (!connection) {
return null
}
let serverId = input.managedDaemon.serverId
let hostname = input.managedDaemon.hostname
if (!serverId) {
const probed = await probeConnection(connection, {
timeoutMs: DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_TIMEOUT_MS,
})
if (input.cancelled?.()) {
throw new Error('Managed daemon bootstrap cancelled')
}
serverId = probed.serverId
hostname = hostname ?? probed.hostname
}
return {
serverId,
hostname,
runtimeId: input.managedDaemon.runtimeId,
runtimeVersion: input.managedDaemon.runtimeVersion,
transportType: input.managedDaemon.transportType,
transportPath: input.managedDaemon.transportPath,
associatedServerId: input.managedDaemon.serverId,
}
}
async function probeManagedConnectionUntilReady(
input: {
managedDaemon: ManagedDaemonStatus
cancelled?: () => boolean
}
): Promise<ManagedHostReconciliationInput | null> {
const startedAt = Date.now()
let lastError: unknown = null
while (Date.now() - startedAt < DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_DEADLINE_MS) {
if (input.cancelled?.()) {
throw new Error('Managed daemon bootstrap cancelled')
}
try {
return await probeManagedStartupTarget(input)
} catch (error) {
lastError = error
if (input.cancelled?.()) {
throw error
}
await sleep(DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_RETRY_MS)
}
}
throw lastError ?? new Error('Managed daemon bootstrap timed out')
}
export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean {
@@ -81,7 +545,7 @@ export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): bool
return false
}
return host.connections.some(
(connection) => connection.type === 'direct' && connection.endpoint === normalized
(connection) => connection.type === 'directTcp' && connection.endpoint === normalized
)
}
@@ -99,7 +563,9 @@ export function useDaemonRegistry(): DaemonRegistryContextValue {
export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient()
const desktopStartupReconciledRef = useRef(false)
const localhostBootstrapAttemptedRef = useRef(false)
const { settings, isLoading: settingsLoading } = useAppSettings()
const {
data: daemons = [],
isPending,
@@ -123,10 +589,6 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons
}, [queryClient, daemons])
const markDefaultLocalhostBootstrapHandled = useCallback(async () => {
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
}, [])
const updateHost = useCallback(
async (serverId: string, updates: UpdateHostInput) => {
const next = readDaemons().map((daemon) =>
@@ -146,23 +608,15 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const removeHost = useCallback(
async (serverId: string) => {
const existing = readDaemons()
const removedHost = existing.find((daemon) => daemon.serverId === serverId) ?? null
const remaining = existing.filter((daemon) => daemon.serverId !== serverId)
await persist(remaining)
if (removedHost && hostHasDirectEndpoint(removedHost, DEFAULT_LOCALHOST_ENDPOINT)) {
await markDefaultLocalhostBootstrapHandled()
}
},
[markDefaultLocalhostBootstrapHandled, persist, readDaemons]
[persist, readDaemons]
)
const removeConnection = useCallback(
async (serverId: string, connectionId: string) => {
const existing = readDaemons()
const removedConnection =
existing
.find((daemon) => daemon.serverId === serverId)
?.connections.find((connection) => connection.id === connectionId) ?? null
const now = new Date().toISOString()
const next = existing
.map((daemon) => {
@@ -184,64 +638,28 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
})
.filter((entry): entry is HostProfile => entry !== null)
await persist(next)
if (removedConnection && isDefaultLocalhostConnection(removedConnection)) {
await markDefaultLocalhostBootstrapHandled()
}
},
[markDefaultLocalhostBootstrapHandled, persist, readDaemons]
[persist, readDaemons]
)
const upsertHostConnection = useCallback(
async (
input: {
serverId: string
label?: string
} & ({ connection: DirectHostConnection } | { connection: RelayHostConnection })
) => {
const existing = readDaemons()
async (input: {
serverId: string
label?: string
lifecycle?: Partial<HostLifecycle>
connection: HostConnection
}) => {
const now = new Date().toISOString()
const serverId = input.serverId.trim()
if (!serverId) {
throw new Error('serverId is required')
}
const labelTrimmed = input.label?.trim() ?? ''
const derivedLabel = labelTrimmed || serverId
const idx = existing.findIndex((d) => d.serverId === serverId)
if (idx === -1) {
const profile: HostProfile = {
serverId,
label: derivedLabel,
connections: [input.connection],
preferredConnectionId: input.connection.id,
createdAt: now,
updatedAt: now,
}
const next = [...existing, profile]
await persist(next)
return profile
}
const prev = existing[idx]!
const connectionIdx = prev.connections.findIndex((c) => c.id === input.connection.id)
const nextConnections =
connectionIdx === -1
? [...prev.connections, input.connection]
: prev.connections.map((c, i) => (i === connectionIdx ? input.connection : c))
const nextProfile: HostProfile = {
...prev,
label: labelTrimmed ? labelTrimmed : prev.label,
connections: nextConnections,
preferredConnectionId: prev.preferredConnectionId ?? input.connection.id,
updatedAt: now,
}
const next = [...existing]
next[idx] = nextProfile
const next = upsertHostConnectionInProfiles({
profiles: readDaemons(),
serverId: input.serverId,
label: input.label,
lifecycle: input.lifecycle,
connection: input.connection,
now,
})
await persist(next)
return nextProfile
return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile
},
[persist, readDaemons]
)
@@ -249,15 +667,14 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const upsertDirectConnection = useCallback(
async (input: { serverId: string; endpoint: string; label?: string }) => {
const endpoint = normalizeHostPort(input.endpoint)
const connection: DirectHostConnection = {
id: `direct:${endpoint}`,
type: 'direct',
endpoint,
}
return upsertHostConnection({
serverId: input.serverId,
label: input.label,
connection,
connection: {
id: `direct:${endpoint}`,
type: 'directTcp',
endpoint,
},
})
},
[upsertHostConnection]
@@ -265,24 +682,121 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (isPending) return
if (settingsLoading) return
if (!shouldUseManagedDesktopDaemon()) return
if (desktopStartupReconciledRef.current) return
desktopStartupReconciledRef.current = true
let cancelled = false
const reconcileDesktopStartup = async () => {
try {
const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY)
if (cancelled || isE2E) {
return
}
let managed: ManagedHostReconciliationInput | null = null
try {
const managedDaemon = await resolveManagedDesktopStartupStatus({
settings,
})
if (managedDaemon.daemonRunning) {
managed = await probeManagedConnectionUntilReady({
managedDaemon,
cancelled: () => cancelled,
})
}
} catch (managedBootstrapError) {
if (!cancelled) {
console.warn(
'[DaemonRegistry] Failed to reconcile managed daemon transport',
managedBootstrapError
)
}
}
let localhost: LocalhostHostReconciliationInput | null = null
try {
const { serverId, hostname } = await probeConnection(
{
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
type: 'directTcp',
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
},
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
)
if (!cancelled) {
localhost = {
serverId,
hostname,
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
}
}
} catch {
// Best-effort reconciliation only; keep startup resilient if localhost isn't reachable.
}
if (cancelled) {
return
}
const existing = readDaemons()
const next = reconcileDesktopStartupRegistry({
existing,
managed,
localhost,
})
if (next !== existing) {
await persist(next)
}
} catch (reconciliationError) {
if (cancelled) return
console.warn(
'[DaemonRegistry] Failed to reconcile desktop startup host connections',
reconciliationError
)
}
}
void reconcileDesktopStartup()
return () => {
cancelled = true
}
}, [
isPending,
persist,
readDaemons,
settings.manageBuiltInDaemon,
settingsLoading,
])
useEffect(() => {
if (isPending) return
if (shouldUseManagedDesktopDaemon()) return
if (localhostBootstrapAttemptedRef.current) return
localhostBootstrapAttemptedRef.current = true
let cancelled = false
const bootstrapDefaultLocalhost = async () => {
const bootstrapLocalhost = async () => {
try {
const [isE2E, alreadyHandled] = await Promise.all([
AsyncStorage.getItem(E2E_STORAGE_KEY),
AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY),
])
if (cancelled || isE2E || alreadyHandled) {
const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY)
if (cancelled || isE2E) {
return
}
const alreadyHandled = await AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY)
if (cancelled || alreadyHandled) {
return
}
const existing = readDaemons()
if (registryHasDirectEndpoint(existing, DEFAULT_LOCALHOST_ENDPOINT)) {
await markDefaultLocalhostBootstrapHandled()
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
return
}
@@ -290,7 +804,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const { serverId, hostname } = await probeConnection(
{
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
type: 'direct',
type: 'directTcp',
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
},
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
@@ -302,25 +816,26 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
label: hostname ?? undefined,
})
await markDefaultLocalhostBootstrapHandled()
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
} catch {
// Best-effort bootstrap only; keep startup resilient if localhost isn't reachable.
}
} catch (bootstrapError) {
if (cancelled) return
console.warn(
'[DaemonRegistry] Failed to bootstrap default localhost connection',
bootstrapError
)
console.warn('[DaemonRegistry] Failed to bootstrap host connections', bootstrapError)
}
}
void bootstrapDefaultLocalhost()
void bootstrapLocalhost()
return () => {
cancelled = true
}
}, [isPending, markDefaultLocalhostBootstrapHandled, readDaemons, upsertDirectConnection])
}, [
isPending,
readDaemons,
upsertDirectConnection,
])
const upsertRelayConnection = useCallback(
async (input: {
@@ -334,16 +849,15 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
if (!daemonPublicKeyB64) {
throw new Error('daemonPublicKeyB64 is required')
}
const connection: RelayHostConnection = {
id: `relay:${relayEndpoint}`,
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
}
return upsertHostConnection({
serverId: input.serverId,
label: input.label,
connection,
connection: {
id: `relay:${relayEndpoint}`,
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
},
})
},
[upsertHostConnection]
@@ -394,108 +908,21 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
return <DaemonRegistryContext.Provider value={value}>{children}</DaemonRegistryContext.Provider>
}
type LegacyHostProfileV1 = {
id: string
label: string
endpoints?: unknown
daemonPublicKeyB64?: unknown
relay?: unknown
createdAt: string
updatedAt: string
}
function isHostProfileV2(value: unknown): value is HostProfile {
if (!value || typeof value !== 'object') return false
const obj = value as Record<string, unknown>
return (
typeof obj.serverId === 'string' &&
typeof obj.label === 'string' &&
Array.isArray(obj.connections) &&
typeof obj.createdAt === 'string' &&
typeof obj.updatedAt === 'string'
)
}
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
try {
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored) as unknown
if (Array.isArray(parsed)) {
const v2 = parsed.filter((entry) => isHostProfileV2(entry)) as HostProfile[]
if (v2.length === parsed.length) {
return v2
}
// Hard migration from the previous in-repo schema (v1 HostProfile with `id/endpoints/relay`).
const migrated: HostProfile[] = parsed
.map((entry): HostProfile | null => {
if (!entry || typeof entry !== 'object') return null
const obj = entry as LegacyHostProfileV1
if (typeof obj.id !== 'string' || typeof obj.label !== 'string') return null
// Only keep stable daemon ids; discard transient entries to avoid confusing host selection.
if (!obj.id.startsWith('srv_')) return null
const now = new Date().toISOString()
const createdAt = typeof obj.createdAt === 'string' ? obj.createdAt : now
const updatedAt = typeof obj.updatedAt === 'string' ? obj.updatedAt : now
const connections: HostConnection[] = []
if (Array.isArray(obj.endpoints)) {
for (const endpointRaw of obj.endpoints) {
try {
const endpoint = normalizeHostPort(String(endpointRaw))
connections.push({ id: `direct:${endpoint}`, type: 'direct', endpoint })
} catch {
// ignore invalid endpoint
}
}
}
const relayEndpointRaw =
obj.relay && typeof (obj.relay as any)?.endpoint === 'string'
? String((obj.relay as any).endpoint)
: null
const daemonPublicKeyB64 =
typeof obj.daemonPublicKeyB64 === 'string' ? obj.daemonPublicKeyB64.trim() : ''
if (relayEndpointRaw && daemonPublicKeyB64) {
try {
const relayEndpoint = normalizeHostPort(relayEndpointRaw)
connections.push({
id: `relay:${relayEndpoint}`,
type: 'relay',
relayEndpoint,
daemonPublicKeyB64,
})
} catch {
// ignore invalid relay endpoint
}
}
if (connections.length === 0) return null
const preferredConnectionId: string | null = connections[0]?.id ?? null
return {
serverId: obj.id,
label: obj.label,
connections,
preferredConnectionId,
createdAt,
updatedAt,
}
})
.filter((entry): entry is HostProfile => entry !== null)
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated))
return migrated
}
if (!stored) {
return []
}
return []
const parsed = JSON.parse(stored) as unknown
if (!Array.isArray(parsed)) {
return []
}
return parsed
.map((entry) => normalizeStoredHostProfile(entry))
.filter((entry): entry is HostProfile => entry !== null)
} catch (error) {
console.error('[DaemonRegistry] Failed to load daemon registry', error)
throw error
@@ -1,64 +1,116 @@
import { useCallback, useState } from "react";
import { Alert, Text, View } from "react-native";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Image, Pressable, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles";
import { ArrowUpRight } from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { useAppSettings } from "@/hooks/use-settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { openExternalUrl } from "@/utils/open-external-url";
import {
buildDaemonUpdateDiagnostics,
formatVersionWithPrefix,
getLocalDaemonVersion,
isVersionMismatch,
runLocalDaemonUpdate,
shouldShowDesktopUpdateSection,
} from "@/desktop/updates/desktop-updates";
import {
getManagedDaemonLogs,
getManagedDaemonPairing,
getManagedDaemonStatus,
installManagedCliShim,
restartManagedDaemon,
shouldUseManagedDesktopDaemon,
startManagedDaemon,
stopManagedDaemon,
uninstallManagedCliShim,
type ManagedDaemonLogs,
type ManagedPairingOffer,
type ManagedDaemonStatus,
type CliManualInstructions,
} from "@/desktop/managed-runtime/managed-runtime";
export interface LocalDaemonSectionProps {
appVersion: string | null;
}
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
const showSection = shouldShowDesktopUpdateSection();
const [localDaemonVersion, setLocalDaemonVersion] = useState<string | null>(null);
const [localDaemonVersionError, setLocalDaemonVersionError] = useState<string | null>(null);
const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false);
const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState<string | null>(null);
const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>(
const showSection = shouldUseManagedDesktopDaemon();
const { settings, updateSettings } = useAppSettings();
const [managedStatus, setManagedStatus] = useState<ManagedDaemonStatus | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const [isInstallingCli, setIsInstallingCli] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
const [managedLogs, setManagedLogs] = useState<ManagedDaemonLogs | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
const [isCliInstallModalOpen, setIsCliInstallModalOpen] = useState(false);
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
const [pairingOffer, setPairingOffer] = useState<ManagedPairingOffer | null>(null);
const [cliInstallInstructions, setCliInstallInstructions] = useState<CliManualInstructions | null>(
null
);
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
const loadManagedStatus = useCallback(() => {
if (!showSection) {
return Promise.resolve();
}
return Promise.all([getManagedDaemonStatus(), getManagedDaemonLogs()])
.then(([status, logs]) => {
setManagedStatus(status);
setManagedLogs(logs);
setStatusError(null);
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setStatusError(message);
});
}, [showSection]);
useFocusEffect(
useCallback(() => {
if (!showSection) {
return undefined;
}
void getLocalDaemonVersion().then((result) => {
setLocalDaemonVersion(result.version);
setLocalDaemonVersionError(result.error);
});
void loadManagedStatus();
return undefined;
}, [showSection])
}, [loadManagedStatus, showSection])
);
const localDaemonVersionText = formatVersionWithPrefix(localDaemonVersion);
const daemonVersionMismatch = isVersionMismatch(appVersion, localDaemonVersion);
const daemonVersionHint = localDaemonVersionError ?? "Daemon installed on this computer.";
const localDaemonVersionText = formatVersionWithPrefix(managedStatus?.runtimeVersion ?? null);
const daemonVersionMismatch = isVersionMismatch(appVersion, managedStatus?.runtimeVersion ?? null);
const daemonStatusStateText =
statusError ??
(managedStatus?.daemonRunning
? managedStatus?.daemonStatus ?? "running"
: "not running");
const daemonStatusDetailText = `PID ${managedStatus?.daemonPid ? managedStatus.daemonPid : "—"}`;
const isDaemonManagementPaused = !settings.manageBuiltInDaemon;
const daemonActionLabel = managedStatus?.daemonRunning ? "Restart daemon" : "Start daemon";
const daemonActionMessage = managedStatus?.daemonRunning
? "Restarts the built-in daemon."
: isDaemonManagementPaused
? "Starts the built-in daemon manually. Paseo will not auto-start it while paused."
: "Starts the built-in daemon.";
const handleUpdateLocalDaemon = useCallback(() => {
if (!showSection) {
return;
}
if (isUpdatingLocalDaemon) {
if (isRestartingDaemon) {
return;
}
void confirmDialog({
title: "Update local daemon",
message:
"This updates the Paseo daemon on this computer. A restart is required afterwards.",
confirmLabel: "Update daemon",
title: daemonActionLabel,
message: managedStatus?.daemonRunning
? "This will restart the built-in daemon. The app will reconnect automatically."
: "This will start the built-in daemon.",
confirmLabel: daemonActionLabel,
cancelLabel: "Cancel",
})
.then((confirmed) => {
@@ -66,71 +118,213 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
return;
}
setIsUpdatingLocalDaemon(true);
setLocalDaemonUpdateMessage(null);
setLocalDaemonUpdateDiagnostics(null);
setIsRestartingDaemon(true);
setStatusMessage(null);
void runLocalDaemonUpdate()
.then((result) => {
const diagnostics = buildDaemonUpdateDiagnostics(result);
if (result.exitCode !== 0) {
setLocalDaemonUpdateMessage(
`Local daemon update failed (exit code ${result.exitCode}). Copy diagnostics below to troubleshoot.`
);
setLocalDaemonUpdateDiagnostics(diagnostics);
return;
}
const action = managedStatus?.daemonRunning ? restartManagedDaemon : startManagedDaemon;
setLocalDaemonUpdateMessage(
"Local daemon update finished. Restart is required: run `paseo daemon restart` on this computer."
void action()
.then((status) => {
setManagedStatus(status);
setStatusMessage(
managedStatus?.daemonRunning ? "Daemon restarted." : "Daemon started."
);
if (result.stdout.trim().length > 0 || result.stderr.trim().length > 0) {
setLocalDaemonUpdateDiagnostics(diagnostics);
}
void getLocalDaemonVersion().then((versionResult) => {
setLocalDaemonVersion(versionResult.version);
setLocalDaemonVersionError(versionResult.error);
});
return loadManagedStatus();
})
.catch((error) => {
console.error("[Settings] Failed to update local daemon", error);
console.error("[Settings] Failed to change managed daemon state", error);
const message = error instanceof Error ? error.message : String(error);
setLocalDaemonUpdateMessage(
"Local daemon update failed before completion. Copy diagnostics below to troubleshoot."
);
setLocalDaemonUpdateDiagnostics(
buildDaemonUpdateDiagnostics({
exitCode: -1,
stdout: "",
stderr: message,
})
);
setStatusMessage(`${daemonActionLabel} failed: ${message}`);
})
.finally(() => {
setIsUpdatingLocalDaemon(false);
setIsRestartingDaemon(false);
});
})
.catch((error) => {
console.error("[Settings] Failed to open daemon update confirmation", error);
Alert.alert("Error", "Unable to open the daemon update confirmation dialog.");
console.error("[Settings] Failed to open managed daemon action confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
});
}, [isUpdatingLocalDaemon, showSection]);
}, [daemonActionLabel, isRestartingDaemon, loadManagedStatus, managedStatus?.daemonRunning, showSection]);
const handleCopyDaemonDiagnostics = useCallback(() => {
if (!localDaemonUpdateDiagnostics) {
const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) {
return;
}
void Clipboard.setStringAsync(localDaemonUpdateDiagnostics)
.then(() => {
Alert.alert("Copied", "Daemon update diagnostics copied.");
if (!settings.manageBuiltInDaemon) {
setIsUpdatingDaemonManagement(true);
setStatusMessage(null);
void updateSettings({ manageBuiltInDaemon: true })
.then(() => {
setStatusMessage("Paseo will resume managing the built-in daemon on startup.");
})
.catch((error) => {
console.error("[Settings] Failed to update built-in daemon management", error);
Alert.alert("Error", "Unable to update built-in daemon management.");
})
.finally(() => {
setIsUpdatingDaemonManagement(false);
});
return;
}
void confirmDialog({
title: "Pause built-in daemon",
message:
"This will stop the built-in daemon immediately and prevent Paseo from auto-starting it on launch. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
destructive: true,
})
.then((confirmed) => {
if (!confirmed) {
return;
}
setIsUpdatingDaemonManagement(true);
setStatusMessage(null);
const stopPromise = managedStatus?.daemonRunning
? stopManagedDaemon()
: Promise.resolve(managedStatus ?? null);
void stopPromise
.then(() => updateSettings({ manageBuiltInDaemon: false }))
.then(() => loadManagedStatus())
.then(() => {
setStatusMessage(
"Paseo paused the built-in daemon and will no longer auto-start it on launch."
);
})
.catch((error) => {
console.error("[Settings] Failed to pause built-in daemon management", error);
Alert.alert("Error", "Unable to pause built-in daemon management.");
})
.finally(() => {
setIsUpdatingDaemonManagement(false);
});
})
.catch((error) => {
console.error("[Settings] Failed to copy daemon update diagnostics", error);
Alert.alert("Error", "Unable to copy diagnostics.");
console.error("[Settings] Failed to open built-in daemon pause confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
});
}, [localDaemonUpdateDiagnostics]);
}, [
isUpdatingDaemonManagement,
loadManagedStatus,
managedStatus,
settings.manageBuiltInDaemon,
updateSettings,
]);
const handleToggleCliShim = useCallback(() => {
if (!showSection || isInstallingCli) {
return;
}
setIsInstallingCli(true);
const isInstalling = !managedStatus?.cliShimPath;
setCliStatusMessage(
isInstalling
? "A permissions popup may appear while Paseo installs the CLI globally."
: null
);
const action = managedStatus?.cliShimPath ? uninstallManagedCliShim : installManagedCliShim;
void action()
.then((result) => {
setCliStatusMessage(result.message);
if (result.manualInstructions) {
setCliInstallInstructions(result.manualInstructions);
setIsCliInstallModalOpen(true);
} else {
setCliInstallInstructions(null);
setIsCliInstallModalOpen(false);
}
return loadManagedStatus();
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setCliStatusMessage(`CLI install failed: ${message}`);
})
.finally(() => {
setIsInstallingCli(false);
});
}, [isInstallingCli, loadManagedStatus, managedStatus?.cliShimPath, showSection]);
const handleCopyCliInstallCommands = useCallback(() => {
if (!cliInstallInstructions?.commands) {
return;
}
void Clipboard.setStringAsync(cliInstallInstructions.commands)
.then(() => {
Alert.alert("Copied", "CLI install commands copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy CLI install commands", error);
Alert.alert("Error", "Unable to copy CLI install commands.");
});
}, [cliInstallInstructions?.commands]);
const handleCopyLogPath = useCallback(() => {
const logPath = managedLogs?.logPath ?? managedStatus?.logPath;
if (!logPath) {
return;
}
void Clipboard.setStringAsync(logPath)
.then(() => {
Alert.alert("Copied", "Log path copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy log path", error);
Alert.alert("Error", "Unable to copy log path.");
});
}, [managedLogs?.logPath, managedStatus?.logPath]);
const handleOpenLogs = useCallback(() => {
if (!managedLogs) {
return;
}
setIsLogsModalOpen(true);
}, [managedLogs]);
const handleOpenPairingModal = useCallback(() => {
if (isLoadingPairing) {
return;
}
setIsPairingModalOpen(true);
setIsLoadingPairing(true);
setPairingStatusMessage(null);
void getManagedDaemonPairing()
.then((pairing) => {
setPairingOffer(pairing);
if (!pairing.relayEnabled || !pairing.url) {
setPairingStatusMessage("Relay pairing is not available.");
}
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setPairingOffer(null);
setPairingStatusMessage(`Unable to load pairing offer: ${message}`);
})
.finally(() => {
setIsLoadingPairing(false);
});
}, [isLoadingPairing]);
const handleCopyPairingLink = useCallback(() => {
if (!pairingOffer?.url) {
return;
}
void Clipboard.setStringAsync(pairingOffer.url)
.then(() => {
Alert.alert("Copied", "Pairing link copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy pairing link", error);
Alert.alert("Error", "Unable to copy pairing link.");
});
}, [pairingOffer?.url]);
if (!showSection) {
return null;
@@ -138,32 +332,131 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Local daemon</Text>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Built-in daemon</Text>
<Pressable
accessibilityRole="link"
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
style={styles.sectionLink}
>
<Text style={styles.sectionLinkText}>Advanced settings</Text>
<ArrowUpRight size={14} color={styles.sectionLinkText.color} />
</Pressable>
</View>
<View style={styles.card}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Version</Text>
<Text style={styles.hintText}>{daemonVersionHint}</Text>
<Text style={styles.rowTitle}>Status</Text>
<Text style={styles.hintText}>Only the built-in managed daemon is shown here.</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
</View>
<Text style={styles.valueText}>{localDaemonVersionText}</Text>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Update daemon</Text>
<Text style={styles.rowTitle}>Daemon management</Text>
<Text style={styles.hintText}>
Updates the daemon on this computer only. Requires a restart.
{isDaemonManagementPaused
? "Paused. Paseo will not auto-start the built-in daemon on app launch."
: "Enabled. Paseo will start the built-in daemon automatically when needed."}
</Text>
{localDaemonUpdateMessage ? (
<Text style={styles.statusText}>{localDaemonUpdateMessage}</Text>
</View>
<Button
variant="outline"
size="sm"
style={styles.primaryActionButton}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
>
{isUpdatingDaemonManagement
? isDaemonManagementPaused
? "Resuming..."
: "Pausing..."
: isDaemonManagementPaused
? "Resume"
: "Pause"}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
<Text style={styles.hintText}>{daemonActionMessage}</Text>
{statusMessage ? (
<Text style={styles.statusText}>{statusMessage}</Text>
) : null}
</View>
<Button
variant="secondary"
variant="outline"
size="sm"
style={styles.primaryActionButton}
onPress={handleUpdateLocalDaemon}
disabled={isUpdatingLocalDaemon}
disabled={isRestartingDaemon}
>
{isUpdatingLocalDaemon ? "Updating..." : "Update daemon"}
{isRestartingDaemon
? managedStatus?.daemonRunning
? "Restarting..."
: "Starting..."
: daemonActionLabel}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Command line (CLI)</Text>
<Text style={styles.hintText}>
Adds the `paseo` command to your terminal.
</Text>
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
style={styles.secondaryActionButton}
onPress={handleToggleCliShim}
disabled={isInstallingCli}
>
{isInstallingCli
? "Working..."
: managedStatus?.cliShimPath
? "Uninstall CLI"
: "Install CLI"}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Log file</Text>
<Text style={styles.hintText}>
{managedLogs?.logPath ??
managedStatus?.logPath ??
"Log path unavailable."}
</Text>
</View>
<View style={styles.actionGroup}>
{(managedLogs?.logPath ?? managedStatus?.logPath) ? (
<Button variant="outline" size="sm" onPress={handleCopyLogPath}>
Copy path
</Button>
) : null}
<Button
variant="outline"
size="sm"
onPress={handleOpenLogs}
disabled={!managedLogs}
>
Open logs
</Button>
</View>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Pair device</Text>
<Text style={styles.hintText}>
Connect your phone to this computer.
</Text>
</View>
<Button variant="outline" size="sm" style={styles.secondaryActionButton} onPress={handleOpenPairingModal}>
Pair device
</Button>
</View>
</View>
@@ -171,25 +464,173 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
{daemonVersionMismatch ? (
<View style={styles.warningCard}>
<Text style={styles.warningText}>
Desktop app and local daemon versions differ. Keep both on the same version to avoid
stability issues or breaking changes.
App and daemon versions don't match. Update both to the same version for the best
experience.
</Text>
</View>
) : null}
{localDaemonUpdateDiagnostics ? (
<View style={styles.diagnosticsCard}>
<View style={styles.diagnosticsHeader}>
<Text style={styles.diagnosticsTitle}>Daemon update diagnostics</Text>
<Button variant="secondary" size="sm" onPress={handleCopyDaemonDiagnostics}>
Copy output
<AdaptiveModalSheet
visible={isCliInstallModalOpen}
onClose={() => setIsCliInstallModalOpen(false)}
title="Install CLI manually"
testID="managed-daemon-cli-install-dialog"
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>
A permissions popup should appear when Paseo installs the CLI globally. If it does not
complete, open a terminal and run the commands below.
</Text>
{cliInstallInstructions?.detail ? (
<Text style={styles.hintText}>{cliInstallInstructions.detail}</Text>
) : null}
<Text style={styles.codeBlock} selectable>
{cliInstallInstructions?.commands ?? ""}
</Text>
<View style={styles.modalActions}>
<Button variant="secondary" size="sm" onPress={() => setIsCliInstallModalOpen(false)}>
Close
</Button>
<Button size="sm" onPress={handleCopyCliInstallCommands}>
Copy commands
</Button>
</View>
<Text style={styles.diagnosticsText} selectable>
{localDaemonUpdateDiagnostics}
</View>
</AdaptiveModalSheet>
<AdaptiveModalSheet
visible={isPairingModalOpen}
onClose={() => setIsPairingModalOpen(false)}
title="Pair device"
testID="managed-daemon-pairing-dialog"
>
<PairingOfferDialogContent
isLoading={isLoadingPairing}
pairingOffer={pairingOffer}
statusMessage={pairingStatusMessage}
onCopyLink={handleCopyPairingLink}
/>
</AdaptiveModalSheet>
<AdaptiveModalSheet
visible={isLogsModalOpen}
onClose={() => setIsLogsModalOpen(false)}
title="Daemon logs"
testID="managed-daemon-logs-dialog"
snapPoints={["70%", "92%"]}
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>
{managedLogs?.logPath ??
managedStatus?.logPath ??
"Log path unavailable."}
</Text>
<Text style={styles.logOutput} selectable>
{managedLogs?.contents.length ? managedLogs.contents : "(log file is empty)"}
</Text>
</View>
) : null}
</AdaptiveModalSheet>
</View>
);
}
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
function PairingOfferDialogContent(input: {
isLoading: boolean;
pairingOffer: ManagedPairingOffer | null;
statusMessage: string | null;
onCopyLink: () => void;
}) {
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input;
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [qrError, setQrError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (!pairingOffer?.url) {
setQrDataUrl(null);
setQrError(null);
return () => {
cancelled = true;
};
}
setQrError(null);
setQrDataUrl(null);
void QRCode.toDataURL(pairingOffer.url, {
errorCorrectionLevel: "M",
margin: 1,
width: 320,
})
.then((dataUrl) => {
if (cancelled) {
return;
}
setQrDataUrl(dataUrl);
})
.catch((error) => {
if (cancelled) {
return;
}
setQrError(error instanceof Error ? error.message : String(error));
});
return () => {
cancelled = true;
};
}, [pairingOffer?.url]);
if (isLoading) {
return (
<View style={styles.pairingState}>
<ActivityIndicator size="small" />
<Text style={styles.hintText}>Loading pairing offer…</Text>
</View>
);
}
if (statusMessage) {
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>{statusMessage}</Text>
</View>
);
}
if (!pairingOffer?.url) {
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>Pairing offer unavailable.</Text>
</View>
);
}
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>
Scan this QR code in Paseo, or copy the pairing link below.
</Text>
<View style={styles.qrCard}>
{qrDataUrl ? (
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} />
) : qrError ? (
<Text style={styles.hintText}>QR unavailable: {qrError}</Text>
) : (
<ActivityIndicator size="small" />
)}
</View>
<Text style={styles.linkLabel}>Pairing link</Text>
<Text style={styles.linkText} selectable>
{pairingOffer.url}
</Text>
<View style={styles.modalActions}>
<Button variant="secondary" size="sm" onPress={onCopyLink}>
Copy link
</Button>
</View>
</View>
);
}
@@ -198,12 +639,26 @@ const styles = StyleSheet.create((theme) => ({
section: {
marginBottom: theme.spacing[6],
},
sectionHeader: {
alignItems: "center",
flexDirection: "row",
justifyContent: "space-between",
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
sectionLink: {
alignItems: "center",
flexDirection: "row",
gap: theme.spacing[1],
},
sectionLinkText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
card: {
backgroundColor: theme.colors.surface2,
@@ -227,6 +682,22 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
marginRight: theme.spacing[3],
},
actionGroup: {
flexDirection: "row",
gap: theme.spacing[2],
flexWrap: "wrap",
justifyContent: "flex-end",
},
statusValueGroup: {
alignItems: "flex-end",
gap: 2,
},
primaryActionButton: {
minWidth: 124,
},
secondaryActionButton: {
minWidth: 112,
},
rowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
@@ -236,6 +707,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
valueSubtext: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
hintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
@@ -259,28 +734,61 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.palette.amber[500],
fontSize: theme.fontSize.xs,
},
diagnosticsCard: {
marginTop: theme.spacing[3],
modalBody: {
gap: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
pairingState: {
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[6],
},
qrCard: {
alignItems: "center",
justifyContent: "center",
alignSelf: "center",
minHeight: 220,
minWidth: 220,
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[3],
gap: theme.spacing[2],
backgroundColor: theme.colors.surface0,
},
diagnosticsHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
qrImage: {
width: 220,
height: 220,
},
diagnosticsTitle: {
linkLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
diagnosticsText: {
linkText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 18,
},
logOutput: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
lineHeight: 18,
},
codeBlock: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
lineHeight: 18,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface0,
padding: theme.spacing[3],
},
modalActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
}));
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { parseCliShimResult } from "./managed-runtime";
describe("parseCliShimResult", () => {
it("parses manual install payloads from the desktop backend", () => {
expect(
parseCliShimResult({
status: "manualInstallRequired",
installed: false,
path: "/usr/local/bin/paseo",
message: "Install it manually.",
manualInstructions: {
title: "Install from Terminal",
detail: "Run these commands.",
commands: "sudo tee /usr/local/bin/paseo",
},
})
).toEqual({
status: "manualInstallRequired",
installed: false,
path: "/usr/local/bin/paseo",
message: "Install it manually.",
manualInstructions: {
title: "Install from Terminal",
detail: "Run these commands.",
commands: "sudo tee /usr/local/bin/paseo",
},
});
});
it("falls back to installed or removed when older payloads omit status", () => {
expect(
parseCliShimResult({
installed: true,
path: "/usr/local/bin/paseo",
message: "Installed.",
})
).toEqual({
status: "installed",
installed: true,
path: "/usr/local/bin/paseo",
message: "Installed.",
manualInstructions: null,
});
});
});
@@ -0,0 +1,282 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { getTauri, isTauriEnvironment } from "@/utils/tauri";
export type ManagedRuntimeStatus = {
runtimeId: string;
runtimeVersion: string;
runtimeRoot: string;
managedHome: string;
transportType: string;
transportPath: string;
diagnosticsRoot: string;
stateFilePath: string;
};
export type ManagedDaemonStatus = {
runtimeId: string;
runtimeVersion: string;
runtimeRoot: string;
managedHome: string;
transportType: string;
transportPath: string;
daemonPid: number | null;
daemonRunning: boolean;
daemonStatus: string;
logPath: string;
serverId: string | null;
hostname: string | null;
relayEnabled: boolean;
tcpEnabled: boolean;
tcpListen: string | null;
cliShimPath: string | null;
};
export type ManagedDaemonLogs = {
logPath: string;
contents: string;
};
export type ManagedPairingOffer = {
relayEnabled: boolean;
url: string | null;
qr: string | null;
};
export type CliShimResult = {
status:
| "installed"
| "removed"
| "elevationDenied"
| "automaticInstallUnavailable"
| "manualInstallRequired";
installed: boolean;
path: string | null;
message: string;
manualInstructions: CliManualInstructions | null;
};
export type CliManualInstructions = {
title: string;
detail: string;
commands: string;
};
export type ManagedTcpSettings = {
enabled: boolean;
host: string;
port: number;
};
export type LocalTransportTarget = {
transportType: "socket" | "pipe";
transportPath: string;
};
type LocalTransportEventPayload = {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function toStringOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function toNumberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function parseManagedRuntimeStatus(raw: unknown): ManagedRuntimeStatus {
if (!isRecord(raw)) {
throw new Error("Unexpected managed runtime status response.");
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? "",
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "",
managedHome: toStringOrNull(raw.managedHome) ?? "",
transportType: toStringOrNull(raw.transportType) ?? "socket",
transportPath: toStringOrNull(raw.transportPath) ?? "",
diagnosticsRoot: toStringOrNull(raw.diagnosticsRoot) ?? "",
stateFilePath: toStringOrNull(raw.stateFilePath) ?? "",
};
}
function parseManagedDaemonStatus(raw: unknown): ManagedDaemonStatus {
if (!isRecord(raw)) {
throw new Error("Unexpected managed daemon status response.");
}
return {
runtimeId: toStringOrNull(raw.runtimeId) ?? "",
runtimeVersion: toStringOrNull(raw.runtimeVersion) ?? "",
runtimeRoot: toStringOrNull(raw.runtimeRoot) ?? "",
managedHome: toStringOrNull(raw.managedHome) ?? "",
transportType: toStringOrNull(raw.transportType) ?? "socket",
transportPath: toStringOrNull(raw.transportPath) ?? "",
daemonPid: toNumberOrNull(raw.daemonPid),
daemonRunning: raw.daemonRunning === true,
daemonStatus: toStringOrNull(raw.daemonStatus) ?? "unknown",
logPath: toStringOrNull(raw.logPath) ?? "",
serverId: toStringOrNull(raw.serverId),
hostname: toStringOrNull(raw.hostname),
relayEnabled: raw.relayEnabled === true,
tcpEnabled: raw.tcpEnabled === true,
tcpListen: toStringOrNull(raw.tcpListen),
cliShimPath: toStringOrNull(raw.cliShimPath),
};
}
function parseManagedDaemonLogs(raw: unknown): ManagedDaemonLogs {
if (!isRecord(raw)) {
throw new Error("Unexpected managed daemon logs response.");
}
return {
logPath: toStringOrNull(raw.logPath) ?? "",
contents: typeof raw.contents === "string" ? raw.contents : "",
};
}
function parseManagedPairingOffer(raw: unknown): ManagedPairingOffer {
if (!isRecord(raw)) {
throw new Error("Unexpected managed daemon pairing response.");
}
return {
relayEnabled: raw.relayEnabled === true,
url: toStringOrNull(raw.url),
qr: toStringOrNull(raw.qr),
};
}
function parseCliManualInstructions(raw: unknown): CliManualInstructions | null {
if (!isRecord(raw)) {
return null;
}
return {
title: toStringOrNull(raw.title) ?? "",
detail: toStringOrNull(raw.detail) ?? "",
commands: toStringOrNull(raw.commands) ?? "",
};
}
export function parseCliShimResult(raw: unknown): CliShimResult {
if (!isRecord(raw)) {
throw new Error("Unexpected CLI shim response.");
}
return {
status:
(toStringOrNull(raw.status) as CliShimResult["status"] | null) ??
(raw.installed === true ? "installed" : "removed"),
installed: raw.installed === true,
path: toStringOrNull(raw.path),
message: toStringOrNull(raw.message) ?? "",
manualInstructions: parseCliManualInstructions(raw.manualInstructions),
};
}
export function shouldUseManagedDesktopDaemon(): boolean {
return isTauriEnvironment() && getTauri() !== null;
}
export async function getManagedRuntimeStatus(): Promise<ManagedRuntimeStatus> {
return parseManagedRuntimeStatus(await invokeDesktopCommand("managed_runtime_status"));
}
export async function getManagedDaemonStatus(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("managed_daemon_status"));
}
export async function startManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("start_managed_daemon"));
}
export async function stopManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("stop_managed_daemon"));
}
export async function restartManagedDaemon(): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(await invokeDesktopCommand("restart_managed_daemon"));
}
export async function getManagedDaemonLogs(): Promise<ManagedDaemonLogs> {
return parseManagedDaemonLogs(await invokeDesktopCommand("managed_daemon_logs"));
}
export async function getManagedDaemonPairing(): Promise<ManagedPairingOffer> {
return parseManagedPairingOffer(await invokeDesktopCommand("managed_daemon_pairing"));
}
export async function installManagedCliShim(): Promise<CliShimResult> {
return parseCliShimResult(await invokeDesktopCommand("install_cli_shim"));
}
export async function uninstallManagedCliShim(): Promise<CliShimResult> {
return parseCliShimResult(await invokeDesktopCommand("uninstall_cli_shim"));
}
export async function updateManagedDaemonTcpSettings(
settings: ManagedTcpSettings
): Promise<ManagedDaemonStatus> {
return parseManagedDaemonStatus(
await invokeDesktopCommand("update_managed_daemon_tcp_settings", { settings })
);
}
export type LocalTransportEventUnlisten = () => void;
export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void;
export async function listenToLocalTransportEvents(
handler: LocalTransportEventHandler
): Promise<LocalTransportEventUnlisten> {
const listen = getTauri()?.event?.listen;
if (typeof listen !== "function") {
throw new Error("Tauri event API is unavailable.");
}
const unlisten = await listen("local-daemon-transport-event", (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null;
if (!payload) {
return;
}
handler({
sessionId: toStringOrNull(payload.sessionId) ?? "",
kind: (toStringOrNull(payload.kind) ?? "error") as LocalTransportEventPayload["kind"],
text: toStringOrNull(payload.text),
binaryBase64: toStringOrNull(payload.binaryBase64),
code: toNumberOrNull(payload.code),
reason: toStringOrNull(payload.reason),
error: toStringOrNull(payload.error),
});
});
return typeof unlisten === "function" ? unlisten : () => {};
}
export async function openLocalTransportSession(target: LocalTransportTarget): Promise<string> {
const raw = await invokeDesktopCommand<unknown>("open_local_daemon_transport", target);
if (typeof raw !== "string" || raw.trim().length === 0) {
throw new Error("Unexpected local transport session response.");
}
return raw;
}
export async function sendLocalTransportMessage(input: {
sessionId: string;
text?: string;
binaryBase64?: string;
}): Promise<void> {
await invokeDesktopCommand("send_local_daemon_transport_message", {
sessionId: input.sessionId,
...(input.text ? { text: input.text } : {}),
...(input.binaryBase64 ? { binaryBase64: input.binaryBase64 } : {}),
});
}
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
await invokeDesktopCommand("close_local_daemon_transport", { sessionId });
}
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn<(_: string) => Promise<string | null>>(),
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
}));
describe("use-settings", () => {
beforeEach(() => {
vi.resetModules();
asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset();
});
it("defaults built-in daemon management to enabled when storage is empty", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result).toEqual(mod.DEFAULT_APP_SETTINGS);
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
mod.APP_SETTINGS_KEY,
JSON.stringify(mod.DEFAULT_APP_SETTINGS)
);
});
it("loads persisted built-in daemon management state", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
return JSON.stringify({
theme: "light",
manageBuiltInDaemon: false,
});
}
return null;
});
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result).toEqual({
theme: "light",
manageBuiltInDaemon: false,
});
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
});
+8 -3
View File
@@ -2,16 +2,18 @@ import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
const APP_SETTINGS_KEY = "@paseo:app-settings";
export const APP_SETTINGS_KEY = "@paseo:app-settings";
const LEGACY_SETTINGS_KEY = "@paseo:settings";
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
export interface AppSettings {
theme: "dark" | "light" | "auto";
manageBuiltInDaemon: boolean;
}
const DEFAULT_APP_SETTINGS: AppSettings = {
export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "dark",
manageBuiltInDaemon: true,
};
export interface UseAppSettingsReturn {
@@ -66,7 +68,7 @@ export function useAppSettings(): UseAppSettingsReturn {
};
}
async function loadSettingsFromStorage(): Promise<AppSettings> {
export async function loadSettingsFromStorage(): Promise<AppSettings> {
try {
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
if (stored) {
@@ -98,6 +100,9 @@ function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<App
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
result.theme = legacy.theme;
}
if (typeof legacy.manageBuiltInDaemon === "boolean") {
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
}
return result;
}
+18 -12
View File
@@ -163,7 +163,7 @@ function makeFetchAgentsEntry(input: {
function makeHost(input?: Partial<HostProfile>): HostProfile {
const direct: HostConnection = {
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
};
const relay: HostConnection = {
@@ -176,6 +176,12 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
return {
serverId: input?.serverId ?? "srv_test",
label: input?.label ?? "test host",
lifecycle: input?.lifecycle ?? {
managed: false,
managedRuntimeId: null,
managedRuntimeVersion: null,
associatedServerId: null,
},
connections: input?.connections ?? [direct, relay],
preferredConnectionId: input?.preferredConnectionId ?? direct.id,
createdAt: input?.createdAt ?? new Date(0).toISOString(),
@@ -227,7 +233,7 @@ describe("HostRuntimeController", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -262,7 +268,7 @@ describe("HostRuntimeController", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -438,7 +444,7 @@ describe("HostRuntimeController", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -577,7 +583,7 @@ describe("HostRuntimeController", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
{
@@ -667,7 +673,7 @@ describe("HostRuntimeController", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -723,7 +729,7 @@ describe("HostRuntimeController", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -789,7 +795,7 @@ describe("HostRuntimeStore", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -837,7 +843,7 @@ describe("HostRuntimeStore", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -885,7 +891,7 @@ describe("HostRuntimeStore", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -984,7 +990,7 @@ describe("HostRuntimeStore", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
@@ -1045,7 +1051,7 @@ describe("HostRuntimeStore", () => {
connections: [
{
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
},
],
+44 -8
View File
@@ -17,6 +17,10 @@ import {
type ConnectionCandidate,
type ConnectionProbeState,
} from "@/utils/connection-selection";
import {
buildLocalDaemonTransportUrl,
createTauriLocalDaemonTransportFactory,
} from "@/utils/managed-tauri-daemon-transport";
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore, type Agent } from "@/stores/session-store";
@@ -33,7 +37,9 @@ export type HostRuntimeConnectionStatus =
| "error";
export type ActiveConnection =
| { type: "direct"; endpoint: string; display: string }
| { type: "directTcp"; endpoint: string; display: string }
| { type: "directSocket"; endpoint: string; display: "socket" }
| { type: "directPipe"; endpoint: string; display: "pipe" }
| { type: "relay"; endpoint: string; display: "relay" };
export type HostRuntimeAgentDirectoryStatus =
@@ -179,9 +185,23 @@ function readFetchAgentsNextCursor(
}
function toActiveConnection(connection: HostConnection): ActiveConnection {
if (connection.type === "direct") {
if (connection.type === "directSocket") {
return {
type: "direct",
type: "directSocket",
endpoint: connection.path,
display: "socket",
};
}
if (connection.type === "directPipe") {
return {
type: "directPipe",
endpoint: connection.path,
display: "pipe",
};
}
if (connection.type === "directTcp") {
return {
type: "directTcp",
endpoint: connection.endpoint,
display: connection.endpoint,
};
@@ -421,11 +441,14 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
serverId: host.serverId,
connectionType: connection.type,
endpoint:
connection.type === "direct"
connection.type === "directTcp"
? connection.endpoint
: connection.type === "directSocket" || connection.type === "directPipe"
? connection.path
: connection.relayEndpoint,
});
const tauriTransportFactory = createTauriWebSocketTransportFactory();
const localTransportFactory = createTauriLocalDaemonTransportFactory();
const base = {
suppressSendErrors: true,
clientId,
@@ -433,18 +456,31 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
runtimeGeneration,
onDiagnosticsEvent: (event: DaemonClientDiagnosticsEvent) =>
recordDaemonClientDiagnostics(host.serverId, event),
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
};
if (connection.type === "direct") {
if (connection.type === "directSocket" || connection.type === "directPipe") {
return new DaemonClient({
...base,
...(localTransportFactory ? { transportFactory: localTransportFactory } : {}),
url: buildLocalDaemonTransportUrl({
transportType: connection.type === "directSocket" ? "socket" : "pipe",
transportPath: connection.path,
}),
});
}
if (connection.type === "directTcp") {
return new DaemonClient({
...base,
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
url: buildDaemonWebSocketUrl(connection.endpoint),
});
}
return new DaemonClient({
...base,
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint,
serverId: host.serverId,
@@ -24,7 +24,7 @@ function shouldSampleFastTransportEvent(): boolean {
export function recordHostRuntimeCreateClient(params: {
serverId: string;
connectionType: "direct" | "relay";
connectionType: "directTcp" | "directSocket" | "directPipe" | "relay";
endpoint: string;
}): void {
recordPerfDiagnosticMark(
+47 -23
View File
@@ -51,6 +51,42 @@ const delay = (ms: number) =>
}, ms);
});
function formatHostConnectionLabel(connection: HostConnection): string {
if (connection.type === "relay") {
return `Relay (${connection.relayEndpoint})`;
}
if (connection.type === "directSocket") {
return `Local (${connection.path})`;
}
if (connection.type === "directPipe") {
return `Local (${connection.path})`;
}
return `TCP (${connection.endpoint})`;
}
function formatActiveConnectionBadge(input: {
activeConnection: { type: HostConnection["type"]; display: string } | null;
theme: ReturnType<typeof useUnistyles>["theme"];
}) {
const { activeConnection, theme } = input;
if (!activeConnection) {
return null;
}
if (activeConnection.type === "relay") {
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
}
if (activeConnection.type === "directSocket") {
return { icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Local" };
}
if (activeConnection.type === "directPipe") {
return { icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Local" };
}
return {
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
};
}
const styles = StyleSheet.create((theme) => ({
loadingContainer: {
flex: 1,
@@ -669,7 +705,7 @@ export default function SettingsScreen() {
);
const restartConfirmationMessage =
"This will immediately stop the Paseo daemon process. The app will disconnect until it restarts.";
"This will restart the daemon. The app will reconnect automatically.";
if (isLoading) {
return (
@@ -1045,14 +1081,10 @@ function HostDetailModal({
? "rgba(248, 113, 113, 0.1)"
: "rgba(161, 161, 170, 0.1)";
const connectionBadge = (() => {
if (!activeConnection) return null;
if (activeConnection.type === "relay") {
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
}
return {
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
};
return formatActiveConnectionBadge({
activeConnection,
theme,
});
})();
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
@@ -1138,9 +1170,7 @@ function HostDetailModal({
latencyError={probe?.status === "unavailable"}
onRemove={() => {
const title =
conn.type === "relay"
? `Relay (${conn.relayEndpoint})`
: `Direct (${conn.endpoint})`;
formatHostConnectionLabel(conn);
setPendingRemoveConnection({ serverId: host.serverId, connectionId: conn.id, title });
}}
/>
@@ -1276,9 +1306,7 @@ function ConnectionRow({
}) {
const { theme } = useUnistyles();
const title =
connection.type === "relay"
? `Relay (${connection.relayEndpoint})`
: `Direct (${connection.endpoint})`;
formatHostConnectionLabel(connection);
const latencyText = (() => {
if (latencyLoading) return "...";
@@ -1363,14 +1391,10 @@ function DaemonCard({
? "rgba(248, 113, 113, 0.1)"
: "rgba(161, 161, 170, 0.1)";
const connectionBadge = (() => {
if (!activeConnection) return null;
if (activeConnection.type === "relay") {
return { icon: <Globe size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />, text: "Relay" };
}
return {
icon: <Monitor size={theme.iconSize.xs} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
};
return formatActiveConnectionBadge({
activeConnection,
theme,
});
})();
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
+1 -1
View File
@@ -246,7 +246,7 @@ type DownloadTarget = {
function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget {
const endpoint =
daemon?.connections.find((conn) => conn.type === "direct")?.endpoint ?? null;
daemon?.connections.find((conn) => conn.type === "directTcp")?.endpoint ?? null;
if (!endpoint) {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
@@ -7,7 +7,7 @@ import {
} from "./connection-selection";
function makeDirect(id: string, endpoint: string): HostConnection {
return { id, type: "direct", endpoint };
return { id, type: "directTcp", endpoint };
}
function makeRelay(
@@ -0,0 +1,127 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const managedRuntimeMock = vi.hoisted(() => {
let eventHandler: ((payload: {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
}) => void) | null = null;
const openLocalTransportSession = vi.fn<(...args: unknown[]) => Promise<string>>();
const listenToLocalTransportEvents = vi.fn(async (handler: typeof eventHandler extends ((...args: infer A) => any) | null ? (...args: A) => void : never) => {
eventHandler = handler;
return () => {
eventHandler = null;
};
});
const sendLocalTransportMessage = vi.fn(async () => undefined);
const closeLocalTransportSession = vi.fn(async () => undefined);
return {
openLocalTransportSession,
listenToLocalTransportEvents,
sendLocalTransportMessage,
closeLocalTransportSession,
emitEvent(payload: {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
binaryBase64?: string | null;
code?: number | null;
reason?: string | null;
error?: string | null;
}) {
eventHandler?.(payload);
},
};
});
vi.mock("@/desktop/managed-runtime/managed-runtime", () => ({
openLocalTransportSession: managedRuntimeMock.openLocalTransportSession,
listenToLocalTransportEvents: managedRuntimeMock.listenToLocalTransportEvents,
sendLocalTransportMessage: managedRuntimeMock.sendLocalTransportMessage,
closeLocalTransportSession: managedRuntimeMock.closeLocalTransportSession,
}));
describe("managed-tauri-daemon-transport", () => {
beforeEach(() => {
managedRuntimeMock.openLocalTransportSession.mockReset();
managedRuntimeMock.listenToLocalTransportEvents.mockClear();
managedRuntimeMock.sendLocalTransportMessage.mockClear();
managedRuntimeMock.closeLocalTransportSession.mockClear();
});
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
let resolveSession!: (sessionId: string) => void;
managedRuntimeMock.openLocalTransportSession.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
);
const mod = await import("./managed-tauri-daemon-transport");
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({
url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
});
const onOpen = vi.fn();
transport.onOpen(onOpen);
managedRuntimeMock.emitEvent({
sessionId: "local-session-1",
kind: "open",
});
expect(onOpen).not.toHaveBeenCalled();
resolveSession("local-session-1");
await Promise.resolve();
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("cleans up late async setup after the transport is closed", async () => {
let resolveSession!: (sessionId: string) => void;
let resolveListen!: (cleanup: () => void) => void;
const cleanup = vi.fn();
managedRuntimeMock.openLocalTransportSession.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSession = resolve;
})
);
managedRuntimeMock.listenToLocalTransportEvents.mockImplementation(
() =>
new Promise<() => void>((resolve) => {
resolveListen = resolve;
})
);
const mod = await import("./managed-tauri-daemon-transport");
const transportFactory = mod.createTauriLocalDaemonTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({
url: "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
});
transport.close();
resolveSession("local-session-2");
resolveListen(cleanup);
await Promise.resolve();
await Promise.resolve();
expect(managedRuntimeMock.closeLocalTransportSession).toHaveBeenCalledWith("local-session-2");
expect(cleanup).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,186 @@
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
import {
closeLocalTransportSession,
listenToLocalTransportEvents,
openLocalTransportSession,
sendLocalTransportMessage,
type LocalTransportTarget,
} from "@/desktop/managed-runtime/managed-runtime";
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
function encodeBinaryToBase64(data: Uint8Array | ArrayBuffer): string {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
let binary = "";
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]!);
}
return globalThis.btoa(binary);
}
function decodeBase64ToBytes(base64: string): Uint8Array {
const binary = globalThis.atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
export function buildLocalDaemonTransportUrl(target: LocalTransportTarget): string {
const url = new URL(`${LOCAL_TRANSPORT_SCHEME}//${target.transportType}`);
url.searchParams.set("path", target.transportPath);
return url.toString();
}
function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
const parsed = new URL(url);
if (parsed.protocol !== LOCAL_TRANSPORT_SCHEME) {
throw new Error(`Unsupported local transport URL: ${url}`);
}
const transportType = parsed.hostname;
const transportPath = parsed.searchParams.get("path")?.trim() ?? "";
if ((transportType !== "socket" && transportType !== "pipe") || !transportPath) {
throw new Error(`Invalid local transport target: ${url}`);
}
return {
transportType,
transportPath,
};
}
export function createTauriLocalDaemonTransportFactory(): DaemonTransportFactory | null {
return ({ url }) => {
const target = parseLocalDaemonTransportUrl(url);
let sessionId: string | null = null;
let unlisten: (() => void) | null = null;
let disposed = false;
let didEmitOpen = false;
const openHandlers = new Set<() => void>();
const closeHandlers = new Set<(event?: unknown) => void>();
const errorHandlers = new Set<(event?: unknown) => void>();
const messageHandlers = new Set<(data: unknown) => void>();
const emitOpen = () => {
if (didEmitOpen || disposed) {
return;
}
didEmitOpen = true;
for (const handler of openHandlers) {
handler();
}
};
const emitClose = (event?: unknown) => {
for (const handler of closeHandlers) {
handler(event);
}
};
const emitError = (event?: unknown) => {
for (const handler of errorHandlers) {
handler(event);
}
};
const emitMessage = (data: unknown) => {
for (const handler of messageHandlers) {
handler(data);
}
};
void listenToLocalTransportEvents((payload) => {
if (disposed || !sessionId || payload.sessionId !== sessionId) {
return;
}
if (payload.kind === "open") {
emitOpen();
return;
}
if (payload.kind === "message") {
if (payload.text) {
emitMessage({ data: payload.text });
return;
}
if (payload.binaryBase64) {
emitMessage({ data: decodeBase64ToBytes(payload.binaryBase64) });
}
return;
}
if (payload.kind === "close") {
emitClose(payload);
return;
}
emitError(payload.error ?? "Local daemon transport error");
})
.then((cleanup) => {
if (disposed) {
cleanup();
return;
}
unlisten = cleanup;
})
.catch((error) => {
emitError(error);
});
void openLocalTransportSession(target)
.then((id) => {
if (disposed) {
void closeLocalTransportSession(id).catch((error) => emitError(error));
return;
}
sessionId = id;
emitOpen();
})
.catch((error) => {
emitError(error);
});
const transport: DaemonTransport = {
send: (data) => {
if (!sessionId) {
return;
}
if (typeof data === "string") {
void sendLocalTransportMessage({ sessionId, text: data }).catch((error) =>
emitError(error)
);
return;
}
const binaryBase64 = encodeBinaryToBase64(
data instanceof ArrayBuffer ? data : new Uint8Array(data)
);
void sendLocalTransportMessage({ sessionId, binaryBase64 }).catch((error) =>
emitError(error)
);
},
close: () => {
disposed = true;
const currentSessionId = sessionId;
sessionId = null;
if (currentSessionId) {
void closeLocalTransportSession(currentSessionId).catch((error) => emitError(error));
}
unlisten?.();
unlisten = null;
},
onMessage: (handler) => {
messageHandlers.add(handler);
return () => messageHandlers.delete(handler);
},
onOpen: (handler) => {
openHandlers.add(handler);
return () => openHandlers.delete(handler);
},
onClose: (handler) => {
closeHandlers.add(handler);
return () => closeHandlers.delete(handler);
},
onError: (handler) => {
errorHandlers.add(handler);
return () => errorHandlers.delete(handler);
},
};
return transport;
};
}
@@ -71,12 +71,12 @@ describe("test-daemon-connection probe client identity", () => {
await mod.measureConnectionLatency({
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
});
await mod.measureConnectionLatency({
id: "direct:lan:6767",
type: "direct",
type: "directTcp",
endpoint: "lan:6767",
});
@@ -85,4 +85,18 @@ describe("test-daemon-connection probe client identity", () => {
expect(second?.clientId).toBe("cid_shared_probe_test");
expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
});
it("encodes the local socket target into the probe client config", async () => {
const mod = await import("./test-daemon-connection");
await mod.measureConnectionLatency({
id: "socket:/tmp/paseo.sock",
type: "directSocket",
path: "/tmp/paseo.sock",
});
expect(daemonClientMock.createdConfigs[0]?.url).toBe(
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"
);
});
});
@@ -3,6 +3,10 @@ import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { HostConnection } from "@/contexts/daemon-registry-context";
import { getOrCreateClientId } from "./client-id";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import {
buildLocalDaemonTransportUrl,
createTauriLocalDaemonTransportFactory,
} from "./managed-tauri-daemon-transport";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
function normalizeNonEmptyString(value: unknown): string | null {
@@ -47,14 +51,31 @@ async function buildClientConfig(
): Promise<DaemonClientConfig> {
const clientId = await getOrCreateClientId();
const tauriTransportFactory = createTauriWebSocketTransportFactory();
const localTransportFactory = createTauriLocalDaemonTransportFactory();
const base = {
clientId,
clientType: "mobile" as const,
suppressSendErrors: true,
...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}),
...(connection.type === "directSocket" || connection.type === "directPipe"
? localTransportFactory
? { transportFactory: localTransportFactory }
: {}
: tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
};
if (connection.type === "direct") {
if (connection.type === "directSocket" || connection.type === "directPipe") {
return {
...base,
url: buildLocalDaemonTransportUrl({
transportType: connection.type === "directSocket" ? "socket" : "pipe",
transportPath: connection.path,
}),
};
}
if (connection.type === "directTcp") {
return {
...base,
url: buildDaemonWebSocketUrl(connection.endpoint),
+9 -9
View File
@@ -66,7 +66,7 @@ export function createCli(): Command {
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--thinking <id>', 'Filter by thinking option ID')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
program
@@ -86,14 +86,14 @@ export function createCli(): Command {
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runRunCommand))
program
.command('attach')
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runAttachCommand)
program
@@ -104,7 +104,7 @@ export function createCli(): Command {
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
.option('--since <time>', 'Show logs since timestamp')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runLogsCommand)
program
@@ -114,7 +114,7 @@ export function createCli(): Command {
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runStopCommand))
program
@@ -124,7 +124,7 @@ export function createCli(): Command {
.option('--all', 'Delete all agents')
.option('--cwd <path>', 'Delete all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runDeleteCommand))
program
@@ -135,7 +135,7 @@ export function createCli(): Command {
.option('--no-wait', 'Return immediately without waiting for completion')
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runSendCommand))
program
@@ -143,7 +143,7 @@ export function createCli(): Command {
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runInspectCommand))
program
@@ -152,7 +152,7 @@ export function createCli(): Command {
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runWaitCommand))
// Top-level local daemon shortcuts
+12 -12
View File
@@ -30,7 +30,7 @@ export function createAgentCommand(): Command {
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--thinking <id>', 'Filter by thinking option ID')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
agent
@@ -47,14 +47,14 @@ export function createAgentCommand(): Command {
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runRunCommand))
agent
.command('attach')
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runAttachCommand)
agent
@@ -64,7 +64,7 @@ export function createAgentCommand(): Command {
.option('-f, --follow', 'Follow log output (streaming)')
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runLogsCommand)
agent
@@ -74,7 +74,7 @@ export function createAgentCommand(): Command {
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runStopCommand))
agent
@@ -84,7 +84,7 @@ export function createAgentCommand(): Command {
.option('--all', 'Delete all agents')
.option('--cwd <path>', 'Delete all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runDeleteCommand))
agent
@@ -94,7 +94,7 @@ export function createAgentCommand(): Command {
.argument('<prompt>', 'The message to send')
.option('--no-wait', 'Return immediately without waiting for completion')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runSendCommand))
agent
@@ -102,7 +102,7 @@ export function createAgentCommand(): Command {
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runInspectCommand))
agent
@@ -111,7 +111,7 @@ export function createAgentCommand(): Command {
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runWaitCommand))
// Advanced agent commands (less common operations)
@@ -122,7 +122,7 @@ export function createAgentCommand(): Command {
.argument('[mode]', 'Mode to set (required unless --list)')
.option('--list', 'List available modes for this agent')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runModeCommand))
agent
@@ -131,7 +131,7 @@ export function createAgentCommand(): Command {
.argument('<id>', 'Agent ID (or prefix)')
.option('--force', 'Force archive running agent (interrupts active run first)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runArchiveCommand))
agent
@@ -146,7 +146,7 @@ export function createAgentCommand(): Command {
[]
)
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runUpdateCommand))
return agent
@@ -289,7 +289,12 @@ export function resolveTcpHostFromListen(listen: string): string | null {
return null
}
if (normalized.startsWith('/') || normalized.startsWith('unix://')) {
if (
normalized.startsWith('/') ||
normalized.startsWith('unix://') ||
normalized.startsWith('pipe://') ||
normalized.startsWith('\\\\.\\pipe\\')
) {
return null
}
+19 -2
View File
@@ -4,14 +4,16 @@ import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from '@getpas
interface PairOptions {
home?: string
json?: boolean
}
export function pairCommand(): Command {
return new Command('pair')
.description('Print the daemon pairing QR code and link')
.option('--json', 'Output in JSON format')
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.action(async (options: PairOptions) => {
await runPairCommand(options)
.action(async (_options: PairOptions, command: Command) => {
await runPairCommand(command.optsWithGlobals() as PairOptions)
})
}
@@ -37,6 +39,21 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
process.exit(1)
}
if (options.json) {
process.stdout.write(
`${JSON.stringify(
{
relayEnabled: pairing.relayEnabled,
url: pairing.url,
qr: pairing.qr,
},
null,
2
)}\n`
)
return
}
const qrBlock = pairing.qr ? `${pairing.qr}\n` : ''
process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`)
}
+3 -3
View File
@@ -11,7 +11,7 @@ export function createPermitCommand(): Command {
.command('ls')
.description('List all pending permissions')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
permit
@@ -22,7 +22,7 @@ export function createPermitCommand(): Command {
.option('--all', 'Allow all pending permissions for this agent')
.option('--input <json>', 'Modified input parameters (JSON)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runAllowCommand))
permit
@@ -34,7 +34,7 @@ export function createPermitCommand(): Command {
.option('--message <msg>', 'Denial reason message')
.option('--interrupt', 'Stop agent after denial')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runDenyCommand))
return permit
+2 -2
View File
@@ -10,7 +10,7 @@ export function createProviderCommand(): Command {
.command('ls')
.description('List available providers and status')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
provider
@@ -19,7 +19,7 @@ export function createProviderCommand(): Command {
.argument('<provider>', 'Provider name (claude, codex, opencode)')
.option('--thinking', 'Include thinking option IDs for each model')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runModelsCommand))
return provider
+2 -2
View File
@@ -14,7 +14,7 @@ export function createSpeechCommand(): Command {
.command("models")
.description("List local speech model download status")
.option("--json", "Output in JSON format")
.option("--host <host>", "Daemon host:port (default: localhost:6767)")
.option("--host <host>", "Daemon host target (default: local socket/pipe, then localhost:6767)")
.action(withOutput(runSpeechModelsCommand));
speech
@@ -22,7 +22,7 @@ export function createSpeechCommand(): Command {
.description("Download local speech models")
.option("--model <id>", "Model ID to download (repeatable)", collectMultiple, [])
.option("--json", "Output in JSON format")
.option("--host <host>", "Daemon host:port (default: localhost:6767)")
.option("--host <host>", "Daemon host target (default: local socket/pipe, then localhost:6767)")
.action(withOutput(runSpeechDownloadCommand));
return speech;
+2 -2
View File
@@ -10,7 +10,7 @@ export function createWorktreeCommand(): Command {
.command('ls')
.description('List Paseo-managed git worktrees')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
worktree
@@ -18,7 +18,7 @@ export function createWorktreeCommand(): Command {
.description('Archive a worktree (removes worktree and associated branch)')
.argument('<name>', 'Worktree name or branch name')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runArchiveCommand))
return worktree
+197 -36
View File
@@ -1,4 +1,6 @@
import { DaemonClient } from '@getpaseo/server'
import { existsSync, readFileSync } from 'node:fs'
import { loadConfig, resolvePaseoHome, DaemonClient } from '@getpaseo/server'
import path from 'node:path'
import WebSocket from 'ws'
import { getOrCreateCliClientId } from './client-id.js'
@@ -9,20 +11,168 @@ export interface ConnectOptions {
const DEFAULT_HOST = 'localhost:6767'
const DEFAULT_TIMEOUT = 5000
const PID_FILENAME = 'paseo.pid'
type DaemonTarget =
| {
type: 'tcp'
url: string
}
| {
type: 'ipc'
url: string
socketPath: string
}
/**
* Get the daemon host from environment or options
*/
export function getDaemonHost(options?: ConnectOptions): string {
return options?.host ?? process.env.PASEO_HOST ?? DEFAULT_HOST
return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST
}
export function normalizeDaemonHost(raw: string): string | null {
const trimmed = raw.trim()
if (!trimmed) {
return null
}
if (
trimmed.startsWith('unix://') ||
trimmed.startsWith('pipe://') ||
trimmed.startsWith('\\\\.\\pipe\\')
) {
return trimmed.startsWith('\\\\.\\pipe\\') ? `pipe://${trimmed}` : trimmed
}
if (path.isAbsolute(trimmed)) {
return `unix://${trimmed}`
}
if (/^\d+$/.test(trimmed)) {
return `127.0.0.1:${trimmed}`
}
return trimmed.includes(':') ? trimmed : null
}
export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string {
return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST
}
function isIpcDaemonHost(host: string | null): host is string {
return host !== null && (host.startsWith('unix://') || host.startsWith('pipe://'))
}
function isTcpDaemonHost(host: string | null): host is string {
return host !== null && !isIpcDaemonHost(host)
}
function readPidSocketTarget(paseoHome: string): string | null {
const pidPath = path.join(paseoHome, PID_FILENAME)
if (!existsSync(pidPath)) {
return null
}
try {
const parsed = JSON.parse(readFileSync(pidPath, 'utf-8')) as { sockPath?: unknown }
return typeof parsed.sockPath === 'string' ? parsed.sockPath : null
} catch {
return null
}
}
function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? '')
if (isIpcDaemonHost(directEnvHost)) {
return directEnvHost
}
const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? '')
if (isIpcDaemonHost(pidHost)) {
return pidHost
}
const config = loadConfig(paseoHome, { env })
const configuredHost = normalizeDaemonHost(config.listen)
return isIpcDaemonHost(configuredHost) ? configuredHost : null
}
function resolveConfiguredTcpDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen)
if (!isTcpDaemonHost(configuredHost)) {
return null
}
return configuredHost === '127.0.0.1:6767' ? null : configuredHost
}
export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] {
const paseoHome = resolvePaseoHome(env)
const candidates: string[] = []
const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome)
if (configuredIpcHost) {
candidates.push(configuredIpcHost)
}
const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome)
if (configuredTcpHost) {
candidates.push(configuredTcpHost)
}
candidates.push(DEFAULT_HOST)
return Array.from(new Set(candidates))
}
function resolveDaemonHostCandidates(options?: ConnectOptions): string[] {
const explicitHost = options?.host ?? process.env.PASEO_HOST
if (explicitHost) {
return [explicitHost]
}
return resolveDefaultDaemonHosts()
}
export function resolveDaemonTarget(host: string): DaemonTarget {
const trimmed = host.trim()
if (
trimmed.startsWith('unix://') ||
trimmed.startsWith('pipe://') ||
trimmed.startsWith('\\\\.\\pipe\\')
) {
const socketPath = trimmed.startsWith('unix://')
? trimmed.slice('unix://'.length).trim()
: trimmed.startsWith('pipe://')
? trimmed.slice('pipe://'.length).trim()
: trimmed
if (!socketPath) {
throw new Error('Invalid IPC daemon target: missing socket path')
}
const isUnixSocket = trimmed.startsWith('unix://')
return {
type: 'ipc',
url: isUnixSocket
? `ws+unix://${socketPath}:/ws`
: 'ws://localhost/ws',
socketPath,
}
}
return {
type: 'tcp',
url: `ws://${trimmed}/ws`,
}
}
/**
* Create a WebSocket factory that works in Node.js
*/
function createNodeWebSocketFactory() {
return (url: string, options?: { headers?: Record<string, string> }) => {
return new WebSocket(url, { headers: options?.headers }) as unknown as {
return (
url: string,
options?: { headers?: Record<string, string>; socketPath?: string }
) => {
return new WebSocket(url, {
headers: options?.headers,
...(options?.socketPath ? { socketPath: options.socketPath } : {}),
}) as unknown as {
readyState: number
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
@@ -38,45 +188,56 @@ function createNodeWebSocketFactory() {
* Returns the connected client or throws if connection fails
*/
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
const host = getDaemonHost(options)
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
const clientId = await getOrCreateCliClientId()
const url = `ws://${host}/ws`
const hosts = resolveDaemonHostCandidates(options)
const nodeWebSocketFactory = createNodeWebSocketFactory()
let lastError: unknown = null
const client = new DaemonClient(
{
url,
clientId,
clientType: 'cli',
webSocketFactory: createNodeWebSocketFactory(),
reconnect: { enabled: false },
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
)
for (const host of hosts) {
const target = resolveDaemonTarget(host)
const client = new DaemonClient(
{
url: target.url,
clientId,
clientType: 'cli',
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
nodeWebSocketFactory(url, {
headers: config?.headers,
...(target.type === 'ipc' ? { socketPath: target.socketPath } : {}),
}),
reconnect: { enabled: false },
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
)
// Connect with timeout
const connectPromise = client.connect()
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Connection timeout after ${timeout}ms`))
}, timeout)
})
const connectPromise = client.connect()
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Connection timeout after ${timeout}ms`))
}, timeout)
})
try {
await Promise.race([connectPromise, timeoutPromise])
// Clear the timeout so it doesn't keep the event loop alive
if (timeoutHandle) {
clearTimeout(timeoutHandle)
try {
await Promise.race([connectPromise, timeoutPromise])
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
return client
} catch (err) {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
lastError = err
await client.close().catch(() => {})
}
return client
} catch (err) {
// Clear the timeout on error too
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
await client.close().catch(() => {})
throw err
}
if (lastError instanceof Error) {
throw lastError
}
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(', ')}`)
}
/**
+19 -6
View File
@@ -65,9 +65,22 @@ try {
console.log('✓ daemon status reports stopped when not running\n')
}
// Test 4: daemon status --json outputs valid JSON
// Test 4: daemon pair --json outputs valid JSON
{
console.log('Test 4: daemon status --json outputs JSON')
console.log('Test 4: daemon pair --json outputs JSON')
const result =
await $`PASEO_HOME=${paseoHome} npx paseo daemon pair --json`.nothrow()
assert.strictEqual(result.exitCode, 0, 'daemon pair --json should succeed')
const pairing = JSON.parse(result.stdout)
assert.strictEqual(pairing.relayEnabled, true, 'pairing should report relay enabled')
assert.match(pairing.url, /#offer=/, 'pairing URL should include offer fragment')
assert.strictEqual(typeof pairing.qr, 'string', 'pairing should include QR content')
console.log('✓ daemon pair --json outputs valid JSON\n')
}
// Test 5: daemon status --json outputs valid JSON
{
console.log('Test 5: daemon status --json outputs JSON')
const result =
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow()
assert.strictEqual(result.exitCode, 0, '--json status should succeed')
@@ -77,9 +90,9 @@ try {
console.log('✓ daemon status --json outputs valid JSON\n')
}
// Test 5: daemon stop handles daemon not running gracefully
// Test 6: daemon stop handles daemon not running gracefully
{
console.log('Test 5: daemon stop handles daemon not running')
console.log('Test 6: daemon stop handles daemon not running')
const result =
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow()
// Stop should succeed even if daemon is not running (idempotent).
@@ -92,9 +105,9 @@ try {
console.log('✓ daemon stop succeeds gracefully when daemon not running\n')
}
// Test 6: daemon restart starts daemon and can be stopped
// Test 7: daemon restart starts daemon and can be stopped
{
console.log('Test 6: daemon restart starts daemon and can be stopped')
console.log('Test 7: daemon restart starts daemon and can be stopped')
const result =
await $`PASEO_HOME=${paseoHome} npx paseo daemon restart --port ${String(port)}`.nothrow()
assert.strictEqual(result.exitCode, 0, 'restart should succeed even when previously stopped')
@@ -29,6 +29,8 @@ console.log('=== Local Daemon Utility Helpers ===\n')
console.log('Test 3: rejects unix socket listen values')
assert.strictEqual(resolveTcpHostFromListen('/tmp/paseo.sock'), null)
assert.strictEqual(resolveTcpHostFromListen('unix:///tmp/paseo.sock'), null)
assert.strictEqual(resolveTcpHostFromListen('pipe://\\\\.\\pipe\\paseo-managed-test'), null)
assert.strictEqual(resolveTcpHostFromListen('\\\\.\\pipe\\paseo-managed-test'), null)
console.log('✓ rejects unix socket listen values\n')
}
@@ -0,0 +1,111 @@
#!/usr/bin/env npx tsx
import assert from 'node:assert'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {
getDaemonHost,
normalizeDaemonHost,
resolveDaemonTarget,
resolveDefaultDaemonHosts,
} from '../src/utils/client.js'
console.log('=== CLI IPC Target Helpers ===\n')
{
console.log('Test 1: unix hosts resolve to ws+unix URLs')
const target = resolveDaemonTarget('unix:///tmp/paseo.sock')
assert.deepStrictEqual(target, {
type: 'ipc',
url: 'ws+unix:///tmp/paseo.sock:/ws',
socketPath: '/tmp/paseo.sock',
})
console.log('✓ unix hosts resolve to ws+unix URLs\n')
}
{
console.log('Test 2: pipe hosts preserve the Node socketPath transport form')
const target = resolveDaemonTarget('pipe://\\\\.\\pipe\\paseo-managed-test')
assert.deepStrictEqual(target, {
type: 'ipc',
url: 'ws://localhost/ws',
socketPath: '\\\\.\\pipe\\paseo-managed-test',
})
console.log('✓ pipe hosts preserve Node socketPath transport form\n')
}
{
console.log('Test 3: local unix socket paths normalize into IPC daemon targets')
assert.strictEqual(normalizeDaemonHost('/tmp/paseo.sock'), 'unix:///tmp/paseo.sock')
console.log('✓ local unix socket paths normalize into IPC daemon targets\n')
}
{
console.log('Test 4: default host resolution tries local IPC first, then localhost fallback')
const paseoHome = mkdtempSync(path.join(os.tmpdir(), 'paseo-client-targets-'))
try {
mkdirSync(paseoHome, { recursive: true })
writeFileSync(
path.join(paseoHome, 'paseo.pid'),
JSON.stringify({ pid: process.pid, sockPath: '/tmp/paseo-from-pid.sock' })
)
assert.deepStrictEqual(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome }), [
'unix:///tmp/paseo-from-pid.sock',
'localhost:6767',
])
const previousHome = process.env.PASEO_HOME
const previousHost = process.env.PASEO_HOST
process.env.PASEO_HOME = paseoHome
delete process.env.PASEO_HOST
assert.strictEqual(getDaemonHost(), 'unix:///tmp/paseo-from-pid.sock')
if (previousHome === undefined) delete process.env.PASEO_HOME
else process.env.PASEO_HOME = previousHome
if (previousHost === undefined) delete process.env.PASEO_HOST
else process.env.PASEO_HOST = previousHost
} finally {
rmSync(paseoHome, { recursive: true, force: true })
}
console.log('✓ default host resolution tries local IPC first, then localhost fallback\n')
}
{
console.log('Test 5: configured TCP host is preserved before the localhost fallback')
const paseoHome = mkdtempSync(path.join(os.tmpdir(), 'paseo-client-targets-tcp-'))
try {
assert.deepStrictEqual(
resolveDefaultDaemonHosts({
PASEO_HOME: paseoHome,
PASEO_LISTEN: '127.0.0.1:7777',
}),
['127.0.0.1:7777', 'localhost:6767']
)
} finally {
rmSync(paseoHome, { recursive: true, force: true })
}
console.log('✓ configured TCP host is preserved before the localhost fallback\n')
}
{
console.log('Test 6: local IPC still takes priority over configured TCP hosts')
const paseoHome = mkdtempSync(path.join(os.tmpdir(), 'paseo-client-targets-order-'))
try {
mkdirSync(paseoHome, { recursive: true })
writeFileSync(
path.join(paseoHome, 'paseo.pid'),
JSON.stringify({ pid: process.pid, sockPath: '/tmp/paseo-priority.sock' })
)
assert.deepStrictEqual(
resolveDefaultDaemonHosts({
PASEO_HOME: paseoHome,
PASEO_LISTEN: '127.0.0.1:7777',
}),
['unix:///tmp/paseo-priority.sock', '127.0.0.1:7777', 'localhost:6767']
)
} finally {
rmSync(paseoHome, { recursive: true, force: true })
}
console.log('✓ local IPC still takes priority over configured TCP hosts\n')
}
console.log('=== All CLI IPC target tests passed ===')
+5 -2
View File
@@ -4,8 +4,11 @@
"private": true,
"description": "Paseo desktop app (Tauri wrapper)",
"scripts": {
"dev": "tauri dev",
"build": "tauri build",
"build:managed-runtime": "node ./scripts/build-managed-runtime.mjs",
"prepare:managed-runtime": "npm --prefix ../.. run build:daemon && npm run build:managed-runtime",
"dev": "npm run prepare:managed-runtime && tauri dev",
"build": "npm --prefix ../.. run build:web --workspace=@getpaseo/app && npm run prepare:managed-runtime && tauri build",
"smoke:managed-daemon": "node ./scripts/managed-daemon-smoke.mjs",
"tauri": "tauri"
},
"devDependencies": {
@@ -0,0 +1,491 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const desktopRoot = path.join(repoRoot, "packages", "desktop");
const resourcesRoot = path.join(desktopRoot, "src-tauri", "resources", "managed-runtime");
const cacheRoot = path.join(desktopRoot, ".cache", "managed-runtime");
const packageVersion = JSON.parse(
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
).version;
const workspaces = [
{ name: "@getpaseo/relay", root: path.join(repoRoot, "packages", "relay") },
{ name: "@getpaseo/server", root: path.join(repoRoot, "packages", "server") },
{ name: "@getpaseo/cli", root: path.join(repoRoot, "packages", "cli") },
];
const ripgrepPlatformDirMap = {
darwin: {
arm64: "arm64-darwin",
x64: "x64-darwin",
},
linux: {
arm64: "arm64-linux",
x64: "x64-linux",
},
win32: {
arm64: "arm64-win32",
x64: "x64-win32",
},
};
async function rmSafe(target) {
await fs.rm(target, { recursive: true, force: true });
}
async function ensureDir(target) {
await fs.mkdir(target, { recursive: true });
}
async function copyDir(source, target) {
await ensureDir(path.dirname(target));
await fs.cp(source, target, { recursive: true, dereference: true, force: true });
}
async function copyFile(source, target, mode) {
await ensureDir(path.dirname(target));
await fs.copyFile(source, target);
if (mode != null) {
await fs.chmod(target, mode);
}
}
async function pathExists(target) {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
async function removeIfExists(target) {
if (await pathExists(target)) {
await rmSafe(target);
}
}
async function readToolVersions() {
const raw = await fs.readFile(path.join(repoRoot, ".tool-versions"), "utf8");
const entries = new Map();
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const [tool, ...rest] = trimmed.split(/\s+/);
if (!tool || rest.length === 0) {
continue;
}
entries.set(tool, rest.join(" "));
}
return entries;
}
function resolveNodeVersion(toolVersions) {
const raw = toolVersions.get("nodejs");
if (!raw) {
throw new Error("Missing nodejs entry in .tool-versions.");
}
const version = raw.trim().replace(/^v/, "");
if (!/^\d+\.\d+\.\d+$/.test(version)) {
throw new Error(`Unsupported nodejs version in .tool-versions: ${raw}`);
}
return version;
}
function resolveNodeArtifact(version) {
const platformMap = {
darwin: "darwin",
linux: "linux",
win32: "win",
};
const archMap = {
arm64: "arm64",
x64: "x64",
};
const platform = platformMap[process.platform];
const arch = archMap[process.arch];
if (!platform || !arch) {
throw new Error(`Managed runtime is not implemented for ${process.platform}-${process.arch}.`);
}
const baseName = `node-v${version}-${platform}-${arch}`;
const extension = process.platform === "win32" ? "zip" : process.platform === "linux" ? "tar.xz" : "tar.gz";
return {
version,
baseName,
archiveName: `${baseName}.${extension}`,
extension,
downloadUrl: `https://nodejs.org/dist/v${version}/${baseName}.${extension}`,
checksumsUrl: `https://nodejs.org/dist/v${version}/SHASUMS256.txt`,
};
}
async function downloadFile(url, target) {
await ensureDir(path.dirname(target));
runCommand("curl", [
"--fail",
"--location",
"--retry",
"3",
"--silent",
"--show-error",
"--output",
target,
url,
]);
}
async function sha256File(target) {
const hash = createHash("sha256");
hash.update(await fs.readFile(target));
return hash.digest("hex");
}
async function computeRuntimeContentHash(input) {
const hash = createHash("sha256");
hash.update(`packageVersion:${packageVersion}\n`);
hash.update(`nodeVersion:${input.nodeVersion}\n`);
hash.update(`platform:${process.platform}\n`);
hash.update(`arch:${process.arch}\n`);
for (const tarball of input.tarballs) {
hash.update(`workspace:${tarball.name}\n`);
hash.update(`filename:${path.basename(tarball.path)}\n`);
hash.update(`sha256:${await sha256File(tarball.path)}\n`);
}
return hash.digest("hex").slice(0, 12);
}
async function ensureNodeArchive(nodeArtifact) {
const versionCacheRoot = path.join(cacheRoot, `node-v${nodeArtifact.version}`);
const archivePath = path.join(versionCacheRoot, nodeArtifact.archiveName);
const checksumsPath = path.join(versionCacheRoot, "SHASUMS256.txt");
if (!(await pathExists(archivePath))) {
await downloadFile(nodeArtifact.downloadUrl, archivePath);
}
if (!(await pathExists(checksumsPath))) {
await downloadFile(nodeArtifact.checksumsUrl, checksumsPath);
}
const checksums = await fs.readFile(checksumsPath, "utf8");
const expectedLine = checksums
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.endsWith(` ${nodeArtifact.archiveName}`));
if (!expectedLine) {
throw new Error(`Missing checksum for ${nodeArtifact.archiveName} in SHASUMS256.txt`);
}
const expectedSha = expectedLine.split(/\s+/)[0];
const actualSha = await sha256File(archivePath);
if (actualSha !== expectedSha) {
throw new Error(
`Checksum mismatch for ${nodeArtifact.archiveName}: expected ${expectedSha}, got ${actualSha}`
);
}
return archivePath;
}
function runCommand(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: "pipe",
encoding: "utf8",
...options,
});
if (result.status !== 0) {
throw new Error(
[
`Command failed: ${command} ${args.join(" ")}`,
result.stdout?.trim(),
result.stderr?.trim(),
]
.filter(Boolean)
.join("\n")
);
}
return result;
}
async function extractNodeDistribution(archivePath, nodeArtifact) {
const extractionRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-node-"));
if (nodeArtifact.extension === "zip") {
runCommand("powershell", [
"-NoProfile",
"-Command",
`Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${extractionRoot.replace(/'/g, "''")}' -Force`,
]);
} else {
const tarFlag = nodeArtifact.extension === "tar.xz" ? "-xJf" : "-xzf";
runCommand("tar", [tarFlag, archivePath, "-C", extractionRoot]);
}
return {
extractionRoot,
extractedRoot: path.join(extractionRoot, nodeArtifact.baseName),
};
}
async function ensureWorkspaceBuilds() {
const requiredPaths = [
path.join(repoRoot, "packages", "relay", "dist"),
path.join(repoRoot, "packages", "server", "dist"),
path.join(repoRoot, "packages", "cli", "dist"),
];
for (const requiredPath of requiredPaths) {
if (!(await pathExists(requiredPath))) {
throw new Error(
`Managed runtime build is missing required path: ${requiredPath}. Run the daemon build first.`
);
}
}
}
async function packWorkspace(packageRoot, tarballRoot) {
await ensureDir(tarballRoot);
const result = runCommand("npm", ["pack", "--json", "--pack-destination", tarballRoot], {
cwd: packageRoot,
});
const [{ filename }] = JSON.parse(result.stdout.trim());
if (!filename) {
throw new Error(`npm pack did not produce a filename for ${packageRoot}`);
}
return path.join(tarballRoot, filename);
}
async function installPackedWorkspaces(runtimeRoot, bundledNodeRoot, tarballs) {
const nodeExecutable = process.platform === "win32"
? path.join(bundledNodeRoot, "node.exe")
: path.join(bundledNodeRoot, "bin", "node");
const npmCli = process.platform === "win32"
? path.join(bundledNodeRoot, "node_modules", "npm", "bin", "npm-cli.js")
: path.join(bundledNodeRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js");
const install = spawnSync(
nodeExecutable,
[
npmCli,
"install",
"--omit=dev",
"--no-package-lock",
"--no-save",
...tarballs,
],
{
cwd: runtimeRoot,
stdio: "inherit",
env: {
...process.env,
npm_config_audit: "false",
npm_config_fund: "false",
},
}
);
if (install.status !== 0) {
throw new Error(
`Managed runtime dependency install failed with exit code ${install.status ?? 1}.`
);
}
}
async function writeRuntimePackageJson(runtimeRoot) {
await fs.writeFile(
path.join(runtimeRoot, "package.json"),
JSON.stringify(
{
name: "paseo-managed-runtime",
private: true,
version: packageVersion,
},
null,
2
) + "\n",
"utf8"
);
}
async function pruneChildrenExcept(parent, keepNames) {
if (!(await pathExists(parent))) {
return;
}
const entries = await fs.readdir(parent);
await Promise.all(
entries
.filter((entry) => !keepNames.has(entry))
.map((entry) => rmSafe(path.join(parent, entry)))
);
}
async function pruneNodeDistribution(runtimeRoot) {
const nodeRoot = path.join(runtimeRoot, "node");
await Promise.all([
removeIfExists(path.join(nodeRoot, "include")),
removeIfExists(path.join(nodeRoot, "share")),
removeIfExists(path.join(nodeRoot, "CHANGELOG.md")),
]);
const nodeGlobalModulesRoot = path.join(nodeRoot, "lib", "node_modules");
if (await pathExists(nodeGlobalModulesRoot)) {
const keepNames = new Set(["npm"]);
await pruneChildrenExcept(nodeGlobalModulesRoot, keepNames);
}
}
async function pruneOnnxRuntime(runtimeRoot) {
const onnxRoot = path.join(runtimeRoot, "node_modules", "onnxruntime-node", "bin", "napi-v6");
if (!(await pathExists(onnxRoot))) {
return;
}
if (process.platform === "darwin") {
await removeIfExists(path.join(onnxRoot, "linux"));
await removeIfExists(path.join(onnxRoot, "win32"));
await pruneChildrenExcept(path.join(onnxRoot, "darwin"), new Set([process.arch]));
return;
}
if (process.platform === "linux") {
await removeIfExists(path.join(onnxRoot, "darwin"));
await removeIfExists(path.join(onnxRoot, "win32"));
await pruneChildrenExcept(path.join(onnxRoot, "linux"), new Set([process.arch]));
return;
}
if (process.platform === "win32") {
await removeIfExists(path.join(onnxRoot, "darwin"));
await removeIfExists(path.join(onnxRoot, "linux"));
await pruneChildrenExcept(path.join(onnxRoot, "win32"), new Set([process.arch]));
return;
}
}
async function pruneNodePty(runtimeRoot) {
const prebuildsRoot = path.join(runtimeRoot, "node_modules", "node-pty", "prebuilds");
const keepName = `${process.platform}-${process.arch}`;
await pruneChildrenExcept(prebuildsRoot, new Set([keepName]));
if (process.platform !== "win32") {
await removeIfExists(path.join(runtimeRoot, "node_modules", "node-pty", "third_party"));
}
}
async function pruneClaudeAgentSdk(runtimeRoot) {
const ripgrepRoot = path.join(
runtimeRoot,
"node_modules",
"@anthropic-ai",
"claude-agent-sdk",
"vendor",
"ripgrep"
);
const keepName = ripgrepPlatformDirMap[process.platform]?.[process.arch];
if (keepName) {
await pruneChildrenExcept(ripgrepRoot, new Set(["COPYING", keepName]));
}
}
async function pruneManagedRuntime(runtimeRoot) {
await Promise.all([
pruneNodeDistribution(runtimeRoot),
pruneOnnxRuntime(runtimeRoot),
pruneNodePty(runtimeRoot),
pruneClaudeAgentSdk(runtimeRoot),
]);
}
async function main() {
await ensureWorkspaceBuilds();
const toolVersions = await readToolVersions();
const nodeVersion = resolveNodeVersion(toolVersions);
const nodeArtifact = resolveNodeArtifact(nodeVersion);
await rmSafe(resourcesRoot);
await ensureDir(resourcesRoot);
const archivePath = await ensureNodeArchive(nodeArtifact);
const { extractionRoot, extractedRoot } = await extractNodeDistribution(archivePath, nodeArtifact);
const tarballRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-pack-"));
try {
const tarballs = [];
for (const workspace of workspaces) {
tarballs.push({
name: workspace.name,
path: await packWorkspace(workspace.root, tarballRoot),
});
}
const runtimeContentHash = await computeRuntimeContentHash({
nodeVersion,
tarballs,
});
const runtimeId =
`${packageVersion}-node-${nodeVersion}-${process.platform}-${process.arch}-${runtimeContentHash}`;
const runtimeRoot = path.join(resourcesRoot, runtimeId);
await ensureDir(runtimeRoot);
await copyDir(extractedRoot, path.join(runtimeRoot, "node"));
await writeRuntimePackageJson(runtimeRoot);
await installPackedWorkspaces(
runtimeRoot,
path.join(runtimeRoot, "node"),
tarballs.map((entry) => entry.path)
);
await pruneManagedRuntime(runtimeRoot);
const nodeRelativePath = process.platform === "win32"
? path.join("node", "node.exe")
: path.join("node", "bin", "node");
const manifest = {
runtimeId,
runtimeVersion: packageVersion,
platform: process.platform,
arch: process.arch,
createdAt: new Date().toISOString(),
nodeRelativePath,
cliEntrypointRelativePath: path.join(
"node_modules",
"@getpaseo",
"cli",
"dist",
"index.js"
),
cliShimRelativePath: path.join("node_modules", "@getpaseo", "cli", "bin", "paseo"),
serverRunnerRelativePath: path.join(
"node_modules",
"@getpaseo",
"server",
"dist",
"scripts",
"daemon-runner.js"
),
};
await fs.writeFile(
path.join(runtimeRoot, "runtime-manifest.json"),
JSON.stringify(manifest, null, 2) + "\n",
"utf8"
);
await fs.writeFile(
path.join(resourcesRoot, "current-runtime.json"),
JSON.stringify(
{
runtimeId,
runtimeVersion: packageVersion,
relativeRoot: runtimeId,
},
null,
2
) + "\n",
"utf8"
);
if (process.platform !== "win32") {
await fs.chmod(path.join(runtimeRoot, nodeRelativePath), 0o755);
}
} finally {
await rmSafe(extractionRoot);
await rmSafe(tarballRoot);
}
}
await main();
@@ -0,0 +1,711 @@
import assert from "node:assert/strict";
import { Buffer } from "node:buffer";
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import WebSocket from "ws";
import { createClientChannel } from "@getpaseo/relay/e2ee";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(new URL("../../..", import.meta.url).pathname);
const desktopRoot = path.join(repoRoot, "packages", "desktop");
const relayRoot = path.join(repoRoot, "packages", "relay");
const desktopPackageJson = JSON.parse(
await fs.readFile(path.join(desktopRoot, "package.json"), "utf8")
);
const currentRuntimeVersion = desktopPackageJson.version;
const currentRuntimePointer = JSON.parse(
await fs.readFile(
path.join(desktopRoot, "src-tauri", "resources", "managed-runtime", "current-runtime.json"),
"utf8"
)
);
const currentRuntimeId = currentRuntimePointer.runtimeId;
function resolvePackagedBinary() {
if (process.platform === "darwin") {
return path.join(
desktopRoot,
"src-tauri",
"target",
"release",
"bundle",
"macos",
"Paseo.app",
"Contents",
"MacOS",
"Paseo"
);
}
if (process.platform === "linux") {
return path.join(
desktopRoot,
"src-tauri",
"target",
"release",
"bundle",
"appimage",
`Paseo_${desktopPackageJson.version}_amd64.AppImage`
);
}
throw new Error(`Managed desktop smoke is not implemented for ${process.platform} yet.`);
}
async function pathExists(target) {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
async function snapshotTree(root) {
if (!(await pathExists(root))) {
return [];
}
const entries = [];
async function walk(current) {
const stat = await fs.stat(current);
const relative = path.relative(root, current) || ".";
entries.push({
relative,
kind: stat.isDirectory() ? "dir" : "file",
size: stat.size,
mtimeMs: stat.mtimeMs,
});
if (!stat.isDirectory()) {
return;
}
const children = await fs.readdir(current);
children.sort();
for (const child of children) {
await walk(path.join(current, child));
}
}
await walk(root);
entries.sort((left, right) => left.relative.localeCompare(right.relative));
return entries;
}
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function escapeForRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function runBinary(binaryPath, args, env) {
const { stdout, stderr } = await execFileAsync(binaryPath, args, {
env,
cwd: repoRoot,
maxBuffer: 10 * 1024 * 1024,
});
const trimmed = stdout.trim();
return {
stdout,
stderr,
json: trimmed ? JSON.parse(trimmed) : null,
};
}
async function runWorkspaceCli(args, env) {
const { stdout, stderr } = await execFileAsync(
process.execPath,
[path.join(repoRoot, "packages", "cli", "dist", "index.js"), ...args],
{
cwd: repoRoot,
env: {
...process.env,
...env,
},
maxBuffer: 10 * 1024 * 1024,
},
);
let json = null;
if (stdout.trim()) {
try {
json = JSON.parse(stdout.trim());
} catch {
json = null;
}
}
return { stdout, stderr, json };
}
async function runBundledRuntimeCli(runtimeRoot, managedHome, args, env) {
const manifest = JSON.parse(
await fs.readFile(path.join(runtimeRoot, "runtime-manifest.json"), "utf8")
);
const { stdout, stderr } = await execFileAsync(
path.join(runtimeRoot, manifest.nodeRelativePath),
[path.join(runtimeRoot, manifest.cliEntrypointRelativePath), ...args],
{
cwd: repoRoot,
env: {
...process.env,
...env,
PASEO_HOME: managedHome,
},
maxBuffer: 10 * 1024 * 1024,
}
);
return { stdout, stderr };
}
async function readDaemonStatus(home, env) {
const result = await runWorkspaceCli(["daemon", "status", "--home", home, "--json"], env);
return result.json ?? {};
}
async function pidListeningOnPort(port) {
try {
const { stdout } = await execFileAsync("lsof", ["-ti", `tcp:${port}`], {
maxBuffer: 1024 * 1024,
});
const pid = Number.parseInt(stdout.trim().split("\n")[0] ?? "", 10);
return Number.isFinite(pid) ? pid : null;
} catch {
return null;
}
}
async function getAvailablePort() {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate an ephemeral test port.")));
return;
}
const { port } = address;
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
});
}
function buildRelayWebSocketUrl({ endpoint, serverId, role }) {
const url = new URL(`ws://${endpoint}/ws`);
url.searchParams.set("serverId", serverId);
url.searchParams.set("role", role);
url.searchParams.set("v", "2");
return url.toString();
}
function parseOfferUrlFromCommandOutput(stdout) {
const match = stdout.match(/https?:\/\/\S+#offer=\S+/);
if (!match) {
throw new Error(`Failed to find pairing URL in output: ${stdout}`);
}
return match[0];
}
function decodeOfferFromFragmentUrl(url) {
const marker = "#offer=";
const index = url.indexOf(marker);
if (index === -1) {
throw new Error(`Pairing URL is missing ${marker}: ${url}`);
}
const encoded = url.slice(index + marker.length);
const raw = Buffer.from(encoded, "base64url").toString("utf8");
const offer = JSON.parse(raw);
if (offer?.v !== 2 || typeof offer?.serverId !== "string" || typeof offer?.daemonPublicKeyB64 !== "string") {
throw new Error(`Unexpected relay offer payload: ${raw}`);
}
return offer;
}
async function waitForRelayWebSocketReady(endpoint, timeoutMs) {
await waitFor(async () => {
const probeServerId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const url = buildRelayWebSocketUrl({
endpoint,
serverId: probeServerId,
role: "server",
});
const opened = await new Promise((resolve) => {
const ws = new WebSocket(url);
const timer = setTimeout(() => {
ws.terminate();
resolve(false);
}, 5_000);
ws.once("open", () => {
clearTimeout(timer);
ws.close(1000, "probe");
resolve(true);
});
ws.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
assert.equal(opened, true);
}, timeoutMs, "relay websocket endpoint to accept connections");
}
async function connectViaRelay(endpoint, offer) {
const stableClientId = `cid-managed-smoke-${Date.now().toString(36)}`;
const ws = new WebSocket(
buildRelayWebSocketUrl({
endpoint,
serverId: offer.serverId,
role: "client",
})
);
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timed out waiting for managed relay pong."));
}, 20_000);
const transport = {
send: (data) => ws.send(data),
close: (code, reason) => ws.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
ws.on("message", (data) => {
transport.onmessage?.(typeof data === "string" ? data : data.toString());
});
ws.on("close", (code, reason) => {
transport.onclose?.(code, reason.toString());
});
ws.on("error", (error) => {
transport.onerror?.(error);
});
ws.on("open", async () => {
try {
let pingSent = false;
let channelRef = null;
const channel = await createClientChannel(transport, offer.daemonPublicKeyB64, {
onmessage: (data) => {
try {
const payload = typeof data === "string" ? JSON.parse(data) : data;
if (payload?.type === "welcome") {
if (!pingSent && channelRef) {
pingSent = true;
void channelRef.send(JSON.stringify({ type: "ping" }));
}
return;
}
if (payload?.type === "pong") {
clearTimeout(timeout);
resolve(payload);
ws.close();
}
} catch (error) {
clearTimeout(timeout);
reject(error);
}
},
onerror: (error) => {
clearTimeout(timeout);
reject(error);
},
});
channelRef = channel;
await channel.send(
JSON.stringify({
type: "hello",
clientId: stableClientId,
clientType: "cli",
protocolVersion: 1,
})
);
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
});
}
function assertNoForbiddenPathsOrPorts(value, forbidden) {
const text =
typeof value === "string" ? value : JSON.stringify(value, null, 2);
for (const candidate of forbidden) {
assert.ok(
!text.includes(candidate),
`Unexpected forbidden managed smoke reference: ${candidate}`
);
}
}
async function ensurePackagedArtifact(binaryPath) {
try {
await execFileAsync("npm", ["run", "build"], {
cwd: desktopRoot,
env: process.env,
maxBuffer: 20 * 1024 * 1024,
});
} catch (error) {
const combined = `${error.stdout ?? ""}\n${error.stderr ?? ""}`;
const signingBlocked =
combined.includes("TAURI_SIGNING_PRIVATE_KEY") &&
(await pathExists(binaryPath));
if (!signingBlocked) {
throw error;
}
console.warn(
"[managed-smoke] continuing after Tauri updater signing failure because the packaged app artifact was already produced"
);
}
}
async function waitFor(assertion, timeoutMs, label) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
return await assertion();
} catch {
await sleep(250);
}
}
throw new Error(`Timed out waiting for ${label}`);
}
function logStep(label) {
console.log(`\n[managed-smoke] ${label}`);
}
const packagedBinary = resolvePackagedBinary();
await ensurePackagedArtifact(packagedBinary);
if (!(await pathExists(packagedBinary))) {
throw new Error(`Packaged desktop artifact not found: ${packagedBinary}`);
}
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-desktop-smoke-"));
const testRoot = path.join(tmpRoot, "managed-test-root");
const fakeHome = path.join(tmpRoot, "fake-home");
const fakePaseoHome = path.join(fakeHome, ".paseo");
const cliScratchHome = path.join(tmpRoot, "cli-scratch-home");
const externalHome = path.join(tmpRoot, "external-daemon-home");
const externalPort = await getAvailablePort();
const externalEndpoint = `127.0.0.1:${externalPort}`;
const relayPort = await getAvailablePort();
const relayEndpoint = `127.0.0.1:${relayPort}`;
const managedRuntimeDir = path.join(testRoot, "runtime");
await fs.mkdir(testRoot, { recursive: true });
await fs.mkdir(fakePaseoHome, { recursive: true });
await fs.mkdir(cliScratchHome, { recursive: true });
await fs.mkdir(externalHome, { recursive: true });
await fs.writeFile(path.join(fakePaseoHome, "sentinel.txt"), "do not touch\n", "utf8");
const fakePaseoSnapshotBefore = await snapshotTree(fakePaseoHome);
const managedEnv = {
...process.env,
HOME: fakeHome,
PASEO_HOME: cliScratchHome,
PASEO_DESKTOP_TEST_ROOT: testRoot,
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_RELAY_ENABLED: "true",
PASEO_RELAY_ENDPOINT: relayEndpoint,
PASEO_RELAY_PUBLIC_ENDPOINT: relayEndpoint,
PASEO_APP_BASE_URL: "https://app.paseo.test",
PASEO_PRIMARY_LAN_IP: "192.168.1.12",
CI: "true",
};
let externalPid = null;
let startedTemporaryExternalDaemon = false;
let relayProcess = null;
const forbiddenManagedReferences = ["127.0.0.1:6767", fakePaseoHome, managedRuntimeDir];
try {
logStep(`Starting isolated local relay on ${relayEndpoint}`);
relayProcess = spawn(process.platform === "win32" ? "npx.cmd" : "npx", [
"wrangler",
"dev",
"--local",
"--ip",
"127.0.0.1",
"--port",
String(relayPort),
"--live-reload=false",
"--show-interactive-dev-session=false",
], {
cwd: relayRoot,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
relayProcess.stdout?.on("data", (chunk) => {
process.stdout.write(`[managed-smoke relay] ${chunk}`);
});
relayProcess.stderr?.on("data", (chunk) => {
process.stderr.write(`[managed-smoke relay] ${chunk}`);
});
await waitFor(async () => {
await execFileAsync(process.execPath, ["-e", `require("node:net").connect(${relayPort}, "127.0.0.1").on("connect", function () { this.end(); process.exit(0); }).on("error", () => process.exit(1));`]);
}, 30_000, "relay HTTP endpoint to start");
await waitForRelayWebSocketReady(relayEndpoint, 60_000);
logStep(`Starting isolated external daemon on ${externalEndpoint}`);
startedTemporaryExternalDaemon = true;
await runWorkspaceCli(
["start", "--home", externalHome, "--listen", externalEndpoint, "--no-relay"],
managedEnv
);
const externalStatus = await waitFor(
async () => {
const status = await readDaemonStatus(externalHome, managedEnv);
assert.equal(status.status, "running");
assert.ok(typeof status.pid === "number" && status.pid > 0, "expected external daemon pid");
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
return status;
},
15_000,
"external daemon to start"
);
externalPid = externalStatus.pid;
assert.ok(externalPid, "external daemon pid should be present");
logStep("Bootstrapping managed runtime from packaged desktop binary");
const runtimeStatus = await runBinary(packagedBinary, ["--managed-headless", "runtime-status"], managedEnv);
assert.equal(runtimeStatus.json.runtimeId, currentRuntimeId);
assert.equal(runtimeStatus.json.runtimeVersion, currentRuntimeVersion);
assert.match(runtimeStatus.json.managedHome, new RegExp(`^${escapeForRegExp(testRoot)}`));
assert.match(
runtimeStatus.json.runtimeRoot,
new RegExp(escapeForRegExp(currentRuntimeId)),
"runtime status should point into the bundled runtime selected by current-runtime.json"
);
assert.equal(
runtimeStatus.json.runtimeRoot.startsWith(testRoot),
false,
"runtime status should not resolve into managed app data"
);
assert.ok(runtimeStatus.json.transportType === "socket" || runtimeStatus.json.transportType === "pipe");
assert.notEqual(runtimeStatus.json.transportPath, "127.0.0.1:6767");
assertNoForbiddenPathsOrPorts(runtimeStatus.json, forbiddenManagedReferences);
assert.equal(await pathExists(managedRuntimeDir), false, "managed app data should not gain a runtime tree");
const stateAfterRuntimeStatus = JSON.parse(
await fs.readFile(path.join(testRoot, "managed-state.json"), "utf8")
);
assert.equal(stateAfterRuntimeStatus.runtimeId, currentRuntimeId);
assert.equal(stateAfterRuntimeStatus.runtimeRoot, runtimeStatus.json.runtimeRoot);
const managedStart = await runBinary(packagedBinary, ["--managed-headless", "bootstrap"], managedEnv);
assert.equal(managedStart.json.daemonRunning, true);
assert.ok(managedStart.json.daemonPid, "managed daemon pid should exist");
assert.equal(managedStart.json.runtimeRoot, runtimeStatus.json.runtimeRoot);
assert.ok(
managedStart.json.transportType === "socket" || managedStart.json.transportType === "pipe",
"managed daemon should default to private IPC transport"
);
assert.notEqual(managedStart.json.transportPath, "127.0.0.1:6767");
assertNoForbiddenPathsOrPorts(managedStart.json, forbiddenManagedReferences);
assert.equal(await pathExists(managedRuntimeDir), false, "starting the daemon should not install a runtime copy");
const managedPid = managedStart.json.daemonPid;
const stateFile = path.join(testRoot, "managed-state.json");
assert.equal(await pathExists(stateFile), true, "managed state file should be written");
logStep("Verifying the managed daemon stays alive after the packaged command exits");
await sleep(1_500);
const persistedManagedStatus = await runBinary(
packagedBinary,
["--managed-headless", "daemon-status"],
managedEnv
);
assert.equal(persistedManagedStatus.json.daemonPid, managedPid);
assert.equal(persistedManagedStatus.json.daemonRunning, true);
assert.equal(persistedManagedStatus.json.relayEnabled, true);
assertNoForbiddenPathsOrPorts(persistedManagedStatus.json, forbiddenManagedReferences);
logStep("Installing CLI shim and verifying the bundled CLI target");
const cliInstall = await runBinary(
packagedBinary,
["--managed-headless", "install-cli-shim"],
managedEnv
);
const cliShimPath = cliInstall.json.path;
assert.ok(cliShimPath, "CLI shim path should be returned");
const cliShimInstalled = cliInstall.json.installed === true && (await pathExists(cliShimPath));
if (!cliShimInstalled) {
assert.ok(cliInstall.json.manualInstructions, "manual CLI install instructions should be returned");
assert.match(
cliInstall.json.manualInstructions.commands,
new RegExp(escapeForRegExp(runtimeStatus.json.runtimeRoot)),
"manual CLI install instructions should point at the bundled runtime"
);
assertNoForbiddenPathsOrPorts(cliInstall.json.manualInstructions, forbiddenManagedReferences);
}
const cliVersion = cliShimInstalled
? await execFileAsync(cliShimPath, ["--version"], {
env: managedEnv,
cwd: repoRoot,
maxBuffer: 1024 * 1024,
})
: await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot,
managedStart.json.managedHome,
["--version"],
managedEnv
);
assert.match(cliVersion.stdout.trim(), /^0\./);
const shimStatus = cliShimInstalled
? await execFileAsync(cliShimPath, ["daemon", "status", "--json"], {
env: managedEnv,
cwd: repoRoot,
maxBuffer: 1024 * 1024,
})
: await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot,
managedStart.json.managedHome,
["daemon", "status", "--json"],
managedEnv
);
const shimDaemonStatus = JSON.parse(shimStatus.stdout.trim());
assert.equal(shimDaemonStatus.pid, managedPid);
assertNoForbiddenPathsOrPorts(shimDaemonStatus, forbiddenManagedReferences);
logStep("Verifying relay connectivity still works after the desktop command has exited");
const relayPairing = cliShimInstalled
? await execFileAsync(
cliShimPath,
["daemon", "pair", "--home", managedStart.json.managedHome],
{
env: managedEnv,
cwd: repoRoot,
maxBuffer: 4 * 1024 * 1024,
}
)
: await runBundledRuntimeCli(
runtimeStatus.json.runtimeRoot,
managedStart.json.managedHome,
["daemon", "pair", "--home", managedStart.json.managedHome],
managedEnv
);
const relayOfferUrl = parseOfferUrlFromCommandOutput(relayPairing.stdout);
const relayOffer = decodeOfferFromFragmentUrl(relayOfferUrl);
assert.equal(relayOffer.relay?.endpoint, relayEndpoint);
const relayPong = await connectViaRelay(relayEndpoint, relayOffer);
assert.deepEqual(relayPong, { type: "pong" });
logStep("Reopening packaged desktop command path without spawning duplicate daemons");
const managedRestartless = await runBinary(
packagedBinary,
["--managed-headless", "bootstrap"],
managedEnv
);
assert.equal(managedRestartless.json.daemonPid, managedPid);
logStep("Verifying managed and external daemons coexist");
const externalStatusAfter = await readDaemonStatus(externalHome, managedEnv);
const externalPidAfter = externalStatusAfter.pid ?? null;
assert.equal(externalStatusAfter.status, "running");
assert.equal(externalPidAfter, externalPid);
await runWorkspaceCli(["ls", "--host", externalEndpoint, "--json"], managedEnv);
const managedStatus = await runBinary(
packagedBinary,
["--managed-headless", "daemon-status"],
managedEnv
);
assert.ok(managedStatus.json.serverId, "managed daemon should expose a server id");
assertNoForbiddenPathsOrPorts(managedStatus.json, forbiddenManagedReferences);
logStep("Enabling managed TCP exposure on an explicit non-default port");
const tcpEnabled = await runBinary(
packagedBinary,
[
"--managed-headless",
"update-tcp",
"--enabled",
"true",
"--host",
"127.0.0.1",
"--port",
"7771",
],
managedEnv
);
assert.equal(tcpEnabled.json.tcpEnabled, true);
assert.equal(tcpEnabled.json.transportType, "tcp");
assert.equal(tcpEnabled.json.transportPath, "127.0.0.1:7771");
assert.notEqual(tcpEnabled.json.transportPath, "127.0.0.1:6767");
assertNoForbiddenPathsOrPorts(tcpEnabled.json, [fakePaseoHome]);
logStep("Disabling managed TCP exposure and returning to private transport");
const tcpDisabled = await runBinary(
packagedBinary,
[
"--managed-headless",
"update-tcp",
"--enabled",
"false",
"--host",
"127.0.0.1",
"--port",
"7771",
],
managedEnv
);
assert.equal(tcpDisabled.json.tcpEnabled, false);
assert.notEqual(tcpDisabled.json.transportType, "tcp");
assertNoForbiddenPathsOrPorts(tcpDisabled.json, forbiddenManagedReferences);
logStep("Capturing diagnostics and verifying the fake ~/.paseo stayed untouched");
const fakePaseoSnapshotAfter = await snapshotTree(fakePaseoHome);
assert.deepEqual(fakePaseoSnapshotAfter, fakePaseoSnapshotBefore);
await fs.writeFile(
path.join(testRoot, "managed-daemon-smoke-diagnostics.json"),
JSON.stringify(
{
runtimeStatus: runtimeStatus.json,
managedStart: managedStart.json,
stateAfterRuntimeStatus,
persistedManagedStatus: persistedManagedStatus.json,
managedRestartless: managedRestartless.json,
cliInstall: cliInstall.json,
shimDaemonStatus,
relayEndpoint,
relayOfferUrl,
relayPong,
externalEndpoint,
externalPid,
externalPidAfter,
managedStatus: managedStatus.json,
tcpEnabled: tcpEnabled.json,
tcpDisabled: tcpDisabled.json,
},
null,
2
) + "\n",
"utf8"
);
console.log(`\n[managed-smoke] PASS (${testRoot})`);
} finally {
try {
await runBinary(packagedBinary, ["--managed-headless", "stop-daemon"], managedEnv);
} catch {}
try {
await runBinary(packagedBinary, ["--managed-headless", "uninstall-cli-shim"], managedEnv);
} catch {}
try {
if (startedTemporaryExternalDaemon) {
await runWorkspaceCli(["daemon", "stop", "--home", externalHome, "--json"]);
}
} catch {}
try {
relayProcess?.kill("SIGTERM");
} catch {}
}
+37 -2
View File
@@ -2632,6 +2632,9 @@ name = "paseo"
version = "0.1.18"
dependencies = [
"base64 0.22.1",
"dirs",
"futures-util",
"http",
"log",
"serde",
"serde_json",
@@ -2643,6 +2646,8 @@ dependencies = [
"tauri-plugin-opener",
"tauri-plugin-updater",
"tauri-plugin-websocket",
"tokio",
"tokio-tungstenite 0.24.0",
]
[[package]]
@@ -4342,7 +4347,7 @@ dependencies = [
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.28.0",
]
[[package]]
@@ -4604,6 +4609,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite 0.24.0",
]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
@@ -4616,7 +4633,7 @@ dependencies = [
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"tungstenite 0.28.0",
"webpki-roots 0.26.11",
]
@@ -4833,6 +4850,24 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.8.5",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.28.0"
+5 -1
View File
@@ -23,6 +23,9 @@ tauri-build = { version = "2.5.3", features = [] }
[dependencies]
base64 = "0.22"
dirs = "6"
futures-util = "0.3"
http = "1"
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
@@ -33,7 +36,8 @@ tauri-plugin-notification = "2"
tauri-plugin-opener = "2"
tauri-plugin-updater = "2"
tauri-plugin-websocket = "2"
tokio = { version = "1", features = ["net", "sync"] }
tokio-tungstenite = "0.24"
@@ -0,0 +1 @@
+177 -4
View File
@@ -1,16 +1,26 @@
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use serde::Serialize;
use serde_json::json;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
#[cfg(target_os = "macos")]
use tauri::menu::AboutMetadata;
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
use tauri::{AppHandle, Manager, WebviewWindow};
use tauri_plugin_updater::UpdaterExt;
mod runtime_manager;
use runtime_manager::{
close_local_daemon_transport, install_cli_shim, managed_daemon_logs, managed_daemon_pairing,
managed_daemon_status, managed_runtime_status, open_local_daemon_transport,
restart_managed_daemon, send_local_daemon_transport_message, start_managed_daemon,
stop_managed_daemon, uninstall_cli_shim, update_managed_daemon_tcp_settings,
LocalTransportState, ManagedTcpSettings,
};
// Store zoom as u64 bits (f64 * 100 as integer for atomic ops)
static ZOOM_LEVEL: AtomicU64 = AtomicU64::new(100);
@@ -64,6 +74,146 @@ struct AttachmentFileResult {
byte_size: u64,
}
#[derive(Debug, Clone)]
enum ManagedHeadlessCommand {
RuntimeStatus,
Bootstrap,
DaemonStatus,
StopDaemon,
InstallCliShim,
UninstallCliShim,
UpdateTcp(ManagedTcpSettings),
}
fn parse_managed_headless_command() -> Result<Option<ManagedHeadlessCommand>, String> {
let args = std::env::args().collect::<Vec<_>>();
let Some(flag_index) = args.iter().position(|value| value == "--managed-headless") else {
return Ok(None);
};
let command = args
.get(flag_index + 1)
.ok_or_else(|| "Missing command after --managed-headless".to_string())?;
let tail = args
.iter()
.skip(flag_index + 2)
.cloned()
.collect::<Vec<_>>();
match command.as_str() {
"runtime-status" => Ok(Some(ManagedHeadlessCommand::RuntimeStatus)),
"bootstrap" => Ok(Some(ManagedHeadlessCommand::Bootstrap)),
"daemon-status" => Ok(Some(ManagedHeadlessCommand::DaemonStatus)),
"stop-daemon" => Ok(Some(ManagedHeadlessCommand::StopDaemon)),
"install-cli-shim" => Ok(Some(ManagedHeadlessCommand::InstallCliShim)),
"uninstall-cli-shim" => Ok(Some(ManagedHeadlessCommand::UninstallCliShim)),
"update-tcp" => {
let mut enabled = false;
let mut host = "127.0.0.1".to_string();
let mut port = 7771_u16;
let mut index = 0_usize;
while index < tail.len() {
match tail[index].as_str() {
"--enabled" => {
enabled = tail
.get(index + 1)
.ok_or_else(|| "Missing value after --enabled".to_string())?
.parse::<bool>()
.map_err(|error| format!("Invalid --enabled value: {error}"))?;
index += 2;
}
"--host" => {
host = tail
.get(index + 1)
.ok_or_else(|| "Missing value after --host".to_string())?
.to_string();
index += 2;
}
"--port" => {
port = tail
.get(index + 1)
.ok_or_else(|| "Missing value after --port".to_string())?
.parse::<u16>()
.map_err(|error| format!("Invalid --port value: {error}"))?;
index += 2;
}
other => {
return Err(format!(
"Unknown --managed-headless update-tcp option: {other}"
));
}
}
}
Ok(Some(ManagedHeadlessCommand::UpdateTcp(
ManagedTcpSettings {
enabled,
host,
port,
},
)))
}
other => Err(format!("Unknown --managed-headless command: {other}")),
}
}
fn maybe_run_managed_headless_command(app: &AppHandle) -> Result<bool, String> {
let Some(command) = parse_managed_headless_command()? else {
return Ok(false);
};
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
let result = async {
let payload = match command {
ManagedHeadlessCommand::RuntimeStatus => {
json!(managed_runtime_status(app_handle.clone()).await?)
}
ManagedHeadlessCommand::Bootstrap => {
json!(start_managed_daemon(app_handle.clone()).await?)
}
ManagedHeadlessCommand::DaemonStatus => {
json!(managed_daemon_status(app_handle.clone()).await?)
}
ManagedHeadlessCommand::StopDaemon => {
json!(stop_managed_daemon(app_handle.clone()).await?)
}
ManagedHeadlessCommand::InstallCliShim => {
json!(install_cli_shim(app_handle.clone()).await?)
}
ManagedHeadlessCommand::UninstallCliShim => {
json!(uninstall_cli_shim(app_handle.clone()).await?)
}
ManagedHeadlessCommand::UpdateTcp(settings) => {
json!(update_managed_daemon_tcp_settings(app_handle.clone(), settings).await?)
}
};
Ok::<serde_json::Value, String>(payload)
}
.await;
match result {
Ok(payload) => {
println!(
"{}",
serde_json::to_string_pretty(&payload)
.unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
);
app_handle.exit(0);
}
Err(error) => {
eprintln!(
"{}",
serde_json::to_string_pretty(&json!({ "error": error })).unwrap_or_else(|_| {
r#"{"error":"managed headless command failed"}"#.to_string()
})
);
app_handle.exit(1);
}
}
});
Ok(true)
}
fn resolve_login_shell() -> String {
std::env::var("SHELL")
.ok()
@@ -100,7 +250,10 @@ fi"#;
LocalDaemonVersionResult {
version: None,
error: Some(if stderr.is_empty() {
format!("paseo --version exited with code {}", output.status.code().unwrap_or(1))
format!(
"paseo --version exited with code {}",
output.status.code().unwrap_or(1)
)
} else {
stderr
}),
@@ -413,7 +566,8 @@ async fn garbage_collect_attachment_files(
let entries = fs::read_dir(&attachment_dir)
.map_err(|error| format!("Failed to scan attachment directory: {error}"))?;
for entry in entries {
let entry = entry.map_err(|error| format!("Failed to read directory entry: {error}"))?;
let entry =
entry.map_err(|error| format!("Failed to read directory entry: {error}"))?;
let path = entry.path();
if !path.is_file() {
continue;
@@ -440,12 +594,26 @@ async fn garbage_collect_attachment_files(
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(LocalTransportState::default())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_websocket::init())
.invoke_handler(tauri::generate_handler![
managed_runtime_status,
managed_daemon_status,
start_managed_daemon,
stop_managed_daemon,
restart_managed_daemon,
managed_daemon_logs,
managed_daemon_pairing,
install_cli_shim,
uninstall_cli_shim,
update_managed_daemon_tcp_settings,
open_local_daemon_transport,
send_local_daemon_transport_message,
close_local_daemon_transport,
get_local_daemon_version,
run_local_daemon_update,
check_app_update,
@@ -457,6 +625,10 @@ pub fn run() {
garbage_collect_attachment_files
])
.setup(|app| {
if maybe_run_managed_headless_command(app.handle())? {
return Ok(());
}
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
@@ -488,7 +660,8 @@ pub fn run() {
copyright: app.config().bundle.copyright.clone(),
..Default::default()
};
let about = PredefinedMenuItem::about(app.handle(), None, Some(about_metadata))?;
let about =
PredefinedMenuItem::about(app.handle(), None, Some(about_metadata))?;
if submenu.remove_at(0)?.is_some() {
submenu.insert(&about, 0)?;
File diff suppressed because it is too large Load Diff
@@ -33,6 +33,9 @@
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"resources": [
"resources/**/*"
],
"targets": "all",
"icon": [
"icons/32x32.png",
@@ -1,10 +1,11 @@
import os from "node:os";
import path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { Writable } from "node:stream";
import pino from "pino";
import { describe, expect, test } from "vitest";
import { createPaseoDaemon, type PaseoDaemonConfig } from "./bootstrap.js";
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
@@ -75,4 +76,65 @@ describe("paseo daemon bootstrap", () => {
await rm(staticDir, { recursive: true, force: true });
}
});
test("parses Windows named pipes as managed IPC listen targets", () => {
expect(parseListenString(String.raw`\\.\pipe\paseo-managed-test`)).toEqual({
type: "pipe",
path: String.raw`\\.\pipe\paseo-managed-test`,
});
expect(parseListenString(`pipe://${String.raw`\\.\pipe\paseo-managed-test`}`)).toEqual({
type: "pipe",
path: String.raw`\\.\pipe\paseo-managed-test`,
});
});
test("emits a relay pairing offer for unix socket listeners", async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
await mkdir(path.dirname(socketPath), { recursive: true });
await mkdir(paseoHome, { recursive: true });
const lines: string[] = [];
const logger = pino(
{ level: "info" },
new Writable({
write(chunk, _encoding, callback) {
lines.push(chunk.toString("utf8"));
callback();
},
})
);
const config: PaseoDaemonConfig = {
listen: socketPath,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: true,
relayEndpoint: "127.0.0.1:9",
relayPublicEndpoint: "127.0.0.1:9",
appBaseUrl: "https://app.paseo.sh",
openai: undefined,
speech: undefined,
};
const daemon = await createPaseoDaemon(config, logger);
try {
await daemon.start();
expect(lines.some((line) => line.includes('"msg":"pairing_offer"'))).toBe(true);
} finally {
await daemon.stop().catch(() => undefined);
await daemon.agentManager.flush().catch(() => undefined);
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
});
});
+40 -36
View File
@@ -11,9 +11,16 @@ import type { Logger } from "pino";
type ListenTarget =
| { type: "tcp"; host: string; port: number }
| { type: "socket"; path: string };
| { type: "socket"; path: string }
| { type: "pipe"; path: string };
function parseListenString(listen: string): ListenTarget {
export function parseListenString(listen: string): ListenTarget {
if (listen.startsWith("\\\\.\\pipe\\") || listen.startsWith("pipe://")) {
return {
type: "pipe",
path: listen.startsWith("pipe://") ? listen.slice("pipe://".length) : listen,
};
}
// Unix socket: starts with / or ~ or contains .sock
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
return { type: "socket", path: listen };
@@ -570,46 +577,44 @@ export async function createPaseoDaemon(
const onListening = () => {
httpServer.off("error", onError);
const logAndResolve = async () => {
const relayEnabled = config.relayEnabled ?? true;
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
if (listenTarget.type === "tcp") {
logger.info(
{ host: listenTarget.host, port: listenTarget.port },
`Server listening on http://${listenTarget.host}:${listenTarget.port}`
);
const relayEnabled = config.relayEnabled ?? true;
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
if (relayEnabled) {
const offer = await createConnectionOfferV2({
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: { endpoint: relayPublicEndpoint },
});
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
logger.info({ url }, "pairing_offer");
}
if (relayEnabled) {
relayTransport?.stop().catch(() => undefined);
relayTransport = startRelayTransport({
logger,
attachSocket: (ws, metadata) => {
if (!wsServer) {
throw new Error("WebSocket server not initialized");
}
return wsServer.attachExternalSocket(ws, metadata);
},
relayEndpoint,
serverId,
daemonKeyPair: daemonKeyPair.keyPair,
});
}
} else {
logger.info({ path: listenTarget.path }, `Server listening on ${listenTarget.path}`);
}
if (relayEnabled) {
const offer = await createConnectionOfferV2({
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: { endpoint: relayPublicEndpoint },
});
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
logger.info({ url }, "pairing_offer");
relayTransport?.stop().catch(() => undefined);
relayTransport = startRelayTransport({
logger,
attachSocket: (ws, metadata) => {
if (!wsServer) {
throw new Error("WebSocket server not initialized");
}
return wsServer.attachExternalSocket(ws, metadata);
},
relayEndpoint,
serverId,
daemonKeyPair: daemonKeyPair.keyPair,
});
}
};
logAndResolve().then(resolve, reject);
@@ -620,8 +625,7 @@ export async function createPaseoDaemon(
if (listenTarget.type === "tcp") {
httpServer.listen(listenTarget.port, listenTarget.host);
} else {
// Remove stale socket file if it exists
if (existsSync(listenTarget.path)) {
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
unlinkSync(listenTarget.path);
}
httpServer.listen(listenTarget.path);
+1 -1
View File
@@ -5,7 +5,7 @@ import websitePackage from '../../package.json'
import '~/styles.css'
const desktopVersion = websitePackage.version
const macDownloadHref = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}/Paseo_${desktopVersion}_universal.dmg`
const macDownloadHref = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}/Paseo_${desktopVersion}_aarch64.dmg`
export const Route = createFileRoute('/')({
head: () => ({