Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 4f5224122a fix(codex): invoke an inline skill steered into a running turn 2026-09-26 19:54:47 +02:00
paseo-bot[bot] af27e28054 fix(codex): invoke a skill picked from autocomplete after prose
Composer autocomplete inserts a Codex skill as /name anywhere in the
prompt, but only a prompt starting with /name was sent to Codex as a
skill input item. A skill placed after other text reached Codex as plain
text and was never injected.

Resolve /name mentions of enabled skills in the prompt into skill input
items and $name text, the same shape the leading form already sends.

Closes #5008
2026-09-26 17:31:15 +02:00
143 changed files with 1283 additions and 9088 deletions
+2 -6
View File
@@ -42,15 +42,11 @@ The daemon requires a valid cryptographic handshake before processing any comman
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
## Local daemon trust boundary
By default, the daemon binds to `127.0.0.1`. With no password configured, anything that can reach the daemon socket can control the daemon. Loopback is reachable by other users on the machine and by some forwarding tools.
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
The daemon supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). WebSocket clients send the password in `hello`; the daemon sends no session data before admission. Direct connections still accept bearer headers and WebSocket bearer subprotocols for older clients. HTTP stays bearer-header based. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt; `/api/files/download` and `/mcp/agents` use their own capability tokens.
The daemon writes a new `$PASEO_HOME/local-credential` on every run with mode `0600` and removes it on shutdown. The CLI and desktop main process read it only for the daemon whose PID lock `listen` matches their connection target. A same-user process can read this credential, so the password protects against network clients and other OS users, not processes running as the daemon user. Protect `$PASEO_HOME` accordingly. Relay traffic remains end-to-end encrypted independently of password admission.
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
-2
View File
@@ -48,7 +48,6 @@ $PASEO_HOME/
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
├── paseo.pid # Daemon PID lock file
├── local-credential # Per-run local client credential (mode 0600)
├── daemon.log # Default log file (path configurable)
├── agents/
│ └── {sanitized-cwd}/
@@ -558,7 +557,6 @@ These small files are not validated as full Zod schemas but are persisted under
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
| `local-credential` | 32 random bytes encoded as base64url text | Rotated before each listen and deleted on shutdown; mode `0600`. |
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
---
-2
View File
@@ -10,8 +10,6 @@ Two contracts follow from it.
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
`hello` carries an optional credential. The daemon accepts a client protocol version from its minimum through newer client versions, selects the lower of client and daemon maximum, and reports it as optional `server_info.protocolVersion`. A `hello.rejected` frame precedes an auth close only when the client sent `hello.auth` or advertised `hello_rejection`; older clients receive the existing WebSocket close code and reason. This avoids sending them a new top-level message their validator does not recognize.
- New fields are `.optional()` with a sensible default.
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
-2
View File
@@ -79,8 +79,6 @@ A provider that can register runtime tools directly should set `supportsNativePa
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
Pi extension adapters live under `packages/server/src/server/agent/providers/pi/extensions/<extension>/`. Each adapter turns Pi RPC facts into Paseo tool, timeline, subagent, or question mappings through the [extension contract](../packages/server/src/server/agent/providers/pi/extensions/contract.ts). To add one, create its directory, add one entry to `extensions/registry.ts`, and test it with fixtures captured from the real extension in Pi that record package, version, source commit, Pi version, and capture date. `agent.ts`, `history-mapper.ts`, and `tool-call-mapper.ts` never name an extension.
Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
+1 -1
View File
@@ -1 +1 @@
sha256-EU28KEISx1lV1wKSiB3npPER1g+hTMD64JuDWxDjHPE=
sha256-UXnB6q5tubKpTs+A5+u/NLSzc8ZK6rAsQs+kEphEKd8=
@@ -2,7 +2,6 @@ import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import { TEST_HOST_LABEL } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { getServerId } from "../support/helpers/server-id";
import {
expectSettingsHeader,
@@ -19,12 +18,6 @@ import {
expectRetiredSidebarSectionsAbsent,
expectHostPageVisible,
seedSavedSettingsHosts,
selectSettingsHost,
expectHostRejectedWithReAddGuidance,
expectNoHostPasswordControls,
removeHostFromHostPage,
addDirectHostFromSettings,
expectHostOnlineWithoutError,
} from "../support/helpers/settings";
test.describe("Settings host page", () => {
@@ -96,47 +89,6 @@ test.describe("Settings host page", () => {
await expect(updateButton).toBeEnabled();
});
test("a host rejected for its password reconnects after being removed and added again with it", async ({
page,
}) => {
const password = "e2e host password";
const daemon = await startIsolatedHostDaemon("srv_e2e_password_host", {
environment: { ...process.env, PASEO_PASSWORD: password },
});
try {
await seedSavedSettingsHosts(page, [
{
serverId: getServerId(),
label: TEST_HOST_LABEL,
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
{
serverId: daemon.serverId,
label: "Password host",
endpoint: `127.0.0.1:${daemon.port}`,
},
]);
await page.reload();
await openSettings(page);
await selectSettingsHost(page, daemon.serverId);
await openHostSection(page, daemon.serverId, "host");
await test.step("the host page shows the reason and the re-add guidance, with no password UI", async () => {
await expectHostRejectedWithReAddGuidance(page, "Password required");
await expectNoHostPasswordControls(page);
});
await test.step("removing the host and adding it again with the password brings it online", async () => {
await removeHostFromHostPage(page, daemon.serverId);
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: daemon.port, password });
await openHostSection(page, daemon.serverId, "host");
await expectHostOnlineWithoutError(page);
});
} finally {
await daemon.close();
}
});
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
page,
}) => {
+1 -32
View File
@@ -91,15 +91,12 @@ export async function selectHostConnectionType(
export async function addDirectHostFromSettings(
page: Page,
input: { host: string; port: number; password?: string },
input: { host: string; port: number },
): Promise<void> {
await openAddHostFlow(page);
await selectHostConnectionType(page, "direct");
await page.getByTestId("direct-host-input").fill(input.host);
await page.getByTestId("direct-port-input").fill(String(input.port));
if (input.password) {
await page.getByTestId("direct-password-input").fill(input.password);
}
await page.getByTestId("direct-host-submit").click();
await expect(page.getByTestId("add-host-modal")).toHaveCount(0, { timeout: 30_000 });
}
@@ -421,31 +418,3 @@ export async function expectLocalHostEntryFirst(page: Page, _serverId: string):
await expect(picker).toBeVisible();
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
}
export async function expectHostRejectedWithReAddGuidance(
page: Page,
reason: string,
): Promise<void> {
const error = page.getByTestId("host-connection-error");
await expect(error).toContainText(reason);
await expect(error).toContainText(
"Remove this host and add it again with the password this daemon asks for.",
);
}
export async function expectNoHostPasswordControls(page: Page): Promise<void> {
await expect(page.getByText("Password", { exact: true })).toHaveCount(0);
await expect(page.locator('input[type="password"]')).toHaveCount(0);
await expect(page.getByRole("button", { name: /password/i })).toHaveCount(0);
}
export async function removeHostFromHostPage(page: Page, serverId: string): Promise<void> {
await page.getByTestId("host-page-remove-host-button").click();
await page.getByTestId("remove-host-confirm").click();
await expect(page).not.toHaveURL(new RegExp(`/settings/hosts/${serverId}`));
}
export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
}
+26 -14
View File
@@ -105,6 +105,7 @@ import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
useHostRegistryLoaded,
useHostMutations,
useHostRuntimeClient,
useHostRuntimeIsConnected,
useHosts,
@@ -665,11 +666,13 @@ function MobileGestureWrapper({
}
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { upsertConnectionFromOfferUrl } = useHostMutations();
return (
<AppearanceProvider>
<VoiceProvider>
<DesktopWindowControlsSync />
<OfferLinkListener />
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<HostSessionManager />
<FaviconStatusSync />
{children}
@@ -696,36 +699,45 @@ function DesktopWindowControlsSync() {
return null;
}
function OfferLinkListener() {
function OfferLinkListener({
upsertDaemonFromOfferUrl,
}: {
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<unknown>;
}) {
const router = useRouter();
useEffect(() => {
let cancelled = false;
const handleUrl = async (url: string | null) => {
const handleUrl = (url: string | null) => {
if (!url) return;
if (!url.includes("#offer=") && !url.includes("#connect=") && !url.startsWith("relay://"))
return;
try {
const result = await getHostRuntimeStore().importConnectionLink(url, "openProject");
if (!cancelled && result.status === "connected") router.replace(buildOpenProjectRoute());
} catch (error) {
console.warn("[OfferLinkListener] Pairing link failed", error);
}
if (!url.includes("#offer=")) return;
void upsertDaemonFromOfferUrl(url)
.then((profile) => {
if (cancelled) return;
const serverId = (profile as { serverId?: unknown } | null)?.serverId;
if (typeof serverId !== "string" || !serverId) return;
router.replace(buildOpenProjectRoute());
return;
})
.catch((error) => {
if (cancelled) return;
console.warn("[Linking] Failed to import pairing offer", error);
});
};
void Linking.getInitialURL()
.then((url) => handleUrl(url))
.then(handleUrl)
.catch(() => undefined);
const subscription = Linking.addEventListener("url", (event) => {
void handleUrl(event.url);
handleUrl(event.url);
});
return () => {
cancelled = true;
subscription.remove();
};
}, [router]);
}, [router, upsertDaemonFromOfferUrl]);
return null;
}
+43 -36
View File
@@ -1,16 +1,18 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { Alert, Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useHostMutations } from "@/runtime/host-runtime";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
import { BackHeader } from "@/components/headers/back-header";
import { PairLinkModal } from "@/components/pair-link-modal";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -112,8 +114,7 @@ function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
const raw = typeof result.data === "string" ? result.data.trim() : "";
if (!raw) return null;
if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://"))
return raw;
if (raw.includes("#offer=")) return raw;
return null;
}
@@ -127,11 +128,11 @@ export default function PairScanScreen() {
source?: string;
}>();
const source = typeof params.source === "string" ? params.source : "settings";
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const [passwordOfferUrl, setPasswordOfferUrl] = useState<string | null>(null);
const lastScannedRef = useRef<string | null>(null);
const navigateToPairedHost = useCallback(
(serverId: string) => {
@@ -159,34 +160,48 @@ export default function PairScanScreen() {
}, [permission, requestPermission]);
const handleScan = useCallback(
(result: BarcodeScanningResult) => {
async (result: BarcodeScanningResult) => {
if (isPairing) return;
const offerUrl = extractOfferUrlFromScan(result);
if (!offerUrl) return;
const store = getHostRuntimeStore();
if (passwordOfferUrl) return;
setIsPairing(true);
setScanError(null);
void store
.importConnectionLink(offerUrl, source === "onboarding" ? "hostRoot" : "hostSettings")
.then((outcome) => {
if (outcome.status === "connected") navigateToPairedHost(outcome.serverId);
else setPasswordOfferUrl(offerUrl);
return outcome;
})
.catch((error) => setScanError(error instanceof Error ? error.message : String(error)))
.finally(() => setIsPairing(false));
if (lastScannedRef.current === offerUrl) return;
lastScannedRef.current = offerUrl;
try {
setIsPairing(true);
const idx = offerUrl.indexOf("#offer=");
const encoded = offerUrl.slice(idx + "#offer=".length).trim();
const offerPayload = decodeOfferFragmentPayload(encoded);
const offer = ConnectionOfferSchema.parse(offerPayload);
const { client, hostname } = await connectToDaemon(
{
id: "probe",
type: "relay",
relayEndpoint: normalizeHostPort(offer.relay.endpoint),
useTls: offer.relay.useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
},
{ serverId: offer.serverId },
);
await client.close().catch(() => undefined);
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
navigateToPairedHost(profile.serverId);
} catch (error) {
lastScannedRef.current = null;
const message = error instanceof Error ? error.message : t("pairing.scan.unableToPair");
Alert.alert(t("pairing.scan.errorTitle"), message);
} finally {
setIsPairing(false);
}
},
[isPairing, navigateToPairedHost, passwordOfferUrl, source],
[isPairing, navigateToPairedHost, t, upsertDaemonFromOfferUrl],
);
const handleRouterBack = useCallback(() => router.back(), [router]);
const closePasswordModal = useCallback(() => setPasswordOfferUrl(null), []);
const savePasswordPairing = useCallback(
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
[navigateToPairedHost],
);
const handleRequestPermission = useCallback(() => {
void requestPermission();
}, [requestPermission]);
@@ -248,18 +263,10 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBR]} />
</View>
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
{scanError ? <Text style={helperTextStyle}>{scanError}</Text> : null}
</View>
</View>
)}
</View>
<PairLinkModal
visible={passwordOfferUrl !== null}
initialUrl={passwordOfferUrl ?? undefined}
initialPasswordRequired
onClose={closePasswordModal}
onSaved={savePasswordPairing}
/>
</View>
);
}
@@ -112,7 +112,6 @@ function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogB
return (
<View style={styles.centered}>
<Alert
size="md"
variant="error"
title={t("changelog.error.title")}
description={t("changelog.error.description")}
+6 -62
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useCallback, useMemo, useReducer, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -11,13 +11,9 @@ import {
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import {
DaemonConnectionTestError,
getConnectionAuthFailureReason,
} from "@/utils/test-daemon-connection";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "./pair-link-credentials";
const FLEX_ONE_STYLE = { flex: 1 } as const;
@@ -254,8 +250,8 @@ function buildConnectionFailureCopy(input: {
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (getConnectionAuthFailureReason(error)) {
detail = error instanceof Error ? error.message : raw;
if (raw === "Incorrect password" || raw === "Password required") {
detail = raw;
} else if (rawLower.includes("timed out")) {
detail = labels.timedOut;
} else if (
@@ -297,8 +293,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { probeAndUpsertDirectConnection, probeAndUpsertConnectionFromOfferUrl } =
useHostMutations();
const { probeAndUpsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const [isSaving, setIsSaving] = useState(false);
@@ -311,7 +306,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [advancedUri, setAdvancedUri] = useState("");
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const advancedTarget = useRef(new PairingTargetTracker("", true));
const clearInput = useCallback(() => {
setHost("");
@@ -321,7 +315,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsPasswordVisible(false);
setIsAdvancedOpen(false);
setAdvancedUri("");
advancedTarget.current = new PairingTargetTracker("", true);
bumpInputResetKey();
}, []);
@@ -372,45 +365,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
(onCancel ?? onClose)();
}, [isSaving, clearInput, onCancel, onClose]);
const handleSaveRelay = useCallback(
async (relayUri: string) => {
try {
setIsSaving(true);
setErrorMessage("");
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
relayUri,
password || undefined,
);
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : directConnectionLabels.invalidConnection,
);
} finally {
setIsSaving(false);
}
},
[
daemons,
directConnectionLabels.invalidConnection,
handleClose,
onSaved,
password,
probeAndUpsertConnectionFromOfferUrl,
],
);
const handleSave = useCallback(async () => {
if (isSaving) return;
const relayUri = isAdvancedOpen ? advancedUri.trim() : "";
if (relayUri.startsWith("relay://") || relayUri.includes("#connect=")) {
await handleSaveRelay(relayUri);
return;
}
let connection: PreparedDirectConnection;
try {
connection = prepareDirectConnection(
@@ -465,12 +422,10 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsSaving(false);
}
}, [
advancedUri,
daemons,
directConnectionLabels,
handleClose,
host,
isAdvancedOpen,
isMobile,
isSaving,
onSaved,
@@ -478,7 +433,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
port,
probeAndUpsertDirectConnection,
t,
handleSaveRelay,
useTls,
]);
@@ -490,15 +444,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
void handleSave();
}, [handleSave]);
const handleChangeAdvancedUri = useCallback((next: string) => {
if (advancedTarget.current.changeUrl(next)) {
setPassword("");
bumpInputResetKey();
setErrorMessage("");
}
setAdvancedUri(next);
}, []);
const handleToggleUseTls = useCallback(() => {
if (isSaving) return;
setUseTls((current) => !current);
@@ -510,7 +455,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const handleToggleAdvanced = useCallback(() => {
if (!isAdvancedOpen) {
advancedTarget.current = new PairingTargetTracker("", true);
try {
setAdvancedUri(
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
@@ -668,7 +612,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
initialValue={advancedUri}
resetKey={`direct-host-uri-${inputResetKey}`}
onChangeText={handleChangeAdvancedUri}
onChangeText={setAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
@@ -1,38 +0,0 @@
import { describe, expect, it } from "vitest";
import { PairingTargetTracker } from "./pair-link-credentials";
describe("pairing target password", () => {
it("clears the password and hides its input when switching hosts", () => {
const first = "relay://relay.example:443/srv_a?key=AAAA&ssl=true";
const second = "relay://relay.example:443/srv_b?key=BBBB&ssl=true";
const target = new PairingTargetTracker(first);
expect(target.changeUrl("relay://relay.example:443/")).toBe(false);
expect(target.changeUrl(second)).toBe(true);
expect(target.changeUrl(second)).toBe(false);
});
it("clears the direct form credential when its advanced URI changes to a relay target", () => {
const target = new PairingTargetTracker("", true);
expect(target.changeUrl("relay://relay.example:443/srv_new?key=BBBB&ssl=true")).toBe(true);
});
it("keeps the password when a relay URI receives harmless whitespace", () => {
const uri = "relay://relay.example:443/srv_a?key=AAAA&ssl=true";
const target = new PairingTargetTracker(uri);
expect(target.changeUrl(`${uri} `)).toBe(false);
});
it("recognizes a different host in an offer link after an incomplete edit", () => {
const offer = Buffer.from(
JSON.stringify({
v: 2,
serverId: "srv_b",
daemonPublicKeyB64: "BBBB",
relay: { endpoint: "relay.example:443" },
}),
).toString("base64url");
const target = new PairingTargetTracker("relay://relay.example:443/srv_a?key=AAAA");
expect(target.changeUrl("https://app.paseo.sh/#offer=")).toBe(false);
expect(target.changeUrl(`https://app.paseo.sh/#offer=${offer}`)).toBe(true);
});
});
@@ -1,33 +0,0 @@
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
function pairingTarget(url: string): string | null {
try {
const trimmed = url.trim();
const offer =
trimmed.startsWith("relay://") || trimmed.includes("#connect=")
? parseRelayConnectionUri(trimmed).offer
: parseConnectionOfferFromUrl(trimmed);
return offer ? `${offer.serverId}:${offer.daemonPublicKeyB64}` : null;
} catch {
// Keep the last valid target while the user edits an incomplete URI.
return null;
}
}
/** Tracks the last valid host so harmless edits never clear its password. */
export class PairingTargetTracker {
private currentTarget: string | null;
constructor(initialUrl = "", startsFromDirectForm = false) {
this.currentTarget = pairingTarget(initialUrl) ?? (startsFromDirectForm ? "direct-form" : null);
}
changeUrl(nextUrl: string): boolean {
const nextTarget = pairingTarget(nextUrl);
if (!nextTarget) return false;
const changed = this.currentTarget !== null && this.currentTarget !== nextTarget;
this.currentTarget = nextTarget;
return changed;
}
}
+66 -122
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -6,27 +6,15 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
import { PairingTargetTracker } from "./pair-link-credentials";
import { Button } from "@/components/ui/button";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
const FLEX_ONE_STYLE = { flex: 1 } as const;
function parsedHostLabel(input: string): string {
try {
if (input.startsWith("relay://") || input.includes("#connect=")) {
return parseRelayConnectionUri(input).offer.serverId;
}
return parseConnectionOfferFromUrl(input)?.serverId ?? "host";
} catch {
return "host";
}
}
const styles = StyleSheet.create((theme) => ({
helper: {
color: theme.colors.foregroundMuted,
@@ -62,8 +50,6 @@ const styles = StyleSheet.create((theme) => ({
export interface PairLinkModalProps {
visible: boolean;
initialUrl?: string;
initialPasswordRequired?: boolean;
onClose: () => void;
onCancel?: () => void;
onSaved?: (result: {
@@ -74,57 +60,21 @@ export interface PairLinkModalProps {
}) => void;
}
export function PairLinkModal({
visible,
initialUrl,
initialPasswordRequired = false,
onClose,
onCancel,
onSaved,
}: PairLinkModalProps) {
return (
<PairLinkModalContent
key={`${visible}:${initialUrl ?? ""}:${initialPasswordRequired}`}
visible={visible}
initialUrl={initialUrl}
initialPasswordRequired={initialPasswordRequired}
onClose={onClose}
onCancel={onCancel}
onSaved={onSaved}
/>
);
}
function PairLinkModalContent({
visible,
initialUrl,
initialPasswordRequired = false,
onClose,
onCancel,
onSaved,
}: PairLinkModalProps) {
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { probeAndUpsertConnectionFromOfferUrl } = useHostMutations();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const offerUrlRef = useRef(initialUrl ?? "");
const targetTracker = useRef(new PairingTargetTracker(initialUrl));
const offerUrlRef = useRef("");
const inputRef = useRef<EditingTextInputHandle>(null);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [password, setPassword] = useState("");
const [needsPassword, setNeedsPassword] = useState(initialPasswordRequired);
const [passwordResetKey, resetPasswordInput] = useReducer((key: number) => key + 1, 0);
const clearInput = useCallback(() => {
offerUrlRef.current = "";
targetTracker.current = new PairingTargetTracker();
inputRef.current?.replaceText("");
setPassword("");
setNeedsPassword(false);
resetPasswordInput();
}, []);
const pairIcon = useMemo(
@@ -146,63 +96,74 @@ function PairLinkModalContent({
(onCancel ?? onClose)();
}, [isSaving, clearInput, onCancel, onClose]);
const handleSave = useCallback(
async (input?: string) => {
if (isSaving) return;
const raw = (input ?? offerUrlRef.current).trim();
if (!raw) {
setErrorMessage(t("pairing.link.errors.required"));
return;
}
if (!raw.includes("#offer=") && !raw.startsWith("relay://") && !raw.includes("#connect=")) {
setErrorMessage(t("pairing.link.errors.missingOffer"));
return;
}
const handleSave = useCallback(async () => {
if (isSaving) return;
const raw = offerUrlRef.current.trim();
if (!raw) {
setErrorMessage(t("pairing.link.errors.required"));
return;
}
if (!raw.includes("#offer=")) {
setErrorMessage(t("pairing.link.errors.missingOffer"));
return;
}
const parsedOffer = (() => {
try {
setIsSaving(true);
setErrorMessage("");
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
raw,
password || undefined,
);
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const message =
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
setErrorMessage(message);
if (getConnectionAuthFailureReason(error)) {
setNeedsPassword(true);
return;
const idx = raw.indexOf("#offer=");
const encoded = raw.slice(idx + "#offer=".length).trim();
if (!encoded) {
throw new Error(t("pairing.link.errors.emptyOffer"));
}
const payload = decodeOfferFragmentPayload(encoded);
return ConnectionOfferSchema.parse(payload);
} catch (error) {
const message = error instanceof Error ? error.message : t("pairing.link.errors.invalid");
setErrorMessage(message);
if (!isMobile) {
Alert.alert(t("pairing.link.alert.failedTitle"), message);
}
} finally {
setIsSaving(false);
return null;
}
},
[
daemons,
handleClose,
isMobile,
isSaving,
onSaved,
password,
t,
probeAndUpsertConnectionFromOfferUrl,
],
);
})();
if (!parsedOffer) {
return;
}
try {
setIsSaving(true);
setErrorMessage("");
const { client, hostname } = await connectToDaemon(
{
id: "probe",
type: "relay",
relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint),
useTls: parsedOffer.relay.useTls,
daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64,
},
{ serverId: parsedOffer.serverId },
);
await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined);
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const message =
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
setErrorMessage(message);
if (!isMobile) {
Alert.alert(t("pairing.link.alert.failedTitle"), message);
}
} finally {
setIsSaving(false);
}
}, [daemons, handleClose, isMobile, isSaving, onSaved, t, upsertDaemonFromOfferUrl]);
const handleChangeOfferUrl = useCallback((next: string) => {
if (targetTracker.current.changeUrl(next)) {
setPassword("");
setNeedsPassword(false);
resetPasswordInput();
setErrorMessage("");
}
offerUrlRef.current = next;
}, []);
@@ -225,7 +186,6 @@ function PairLinkModalContent({
<Text style={styles.label}>{t("pairing.link.label")}</Text>
<AdaptiveTextInput
ref={inputRef}
initialValue={initialUrl}
testID="pair-link-input"
nativeID="pair-link-input"
accessibilityLabel={t("pairing.link.label")}
@@ -241,22 +201,6 @@ function PairLinkModalContent({
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View>
{needsPassword ? (
<View style={styles.field}>
<Text style={styles.label}>
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
</Text>
<AdaptiveTextInput
testID="pair-link-password-input"
resetKey={`pair-link-password-${passwordResetKey}`}
accessibilityLabel={t("pairing.hostPassword.label")}
onChangeText={setPassword}
secureTextEntry
style={styles.input}
/>
</View>
) : null}
<View style={styles.actions}>
<Button
style={FLEX_ONE_STYLE}
@@ -2,7 +2,6 @@ import { describe, expect, test } from "vitest";
import {
areQuestionsAnswered,
buildQuestionFormAnswers,
buildQuestionFormResponse,
parseQuestionFormQuestions,
questionShowsTextInput,
resolveDismissLabel,
@@ -148,44 +147,4 @@ describe("question form card core", () => {
Response: "custom",
});
});
test("submits each question's answer as structured labels so commas stay unambiguous", () => {
const input = {
questions: [
{
question: "Which colors?",
header: "Colors",
options: [{ label: "Red, bright" }, { label: "Blue" }],
multiSelect: true,
allowOther: true,
},
{
question: "Which city?",
header: "City",
options: [{ label: "Paris, France" }, { label: "Rome" }],
allowOther: true,
},
],
};
const questions = parseQuestionFormQuestions(input);
if (!questions) throw new Error("questions did not parse");
expect(
buildQuestionFormResponse(
input,
questions,
{ 0: new Set([0, 1]), 1: new Set([1]) },
{ 0: " Green, dark ", 1: "Lyon, France" },
),
).toEqual({
behavior: "allow",
questionAnswers: [
{ selected: ["Red, bright", "Blue"], text: "Green, dark" },
{ selected: [], text: "Lyon, France" },
],
updatedInput: {
...input,
answers: { Colors: "Red, bright, Blue, Green, dark", City: "Lyon, France" },
},
});
});
});
@@ -1,5 +1,3 @@
import type { AgentPermissionResponse, AgentQuestionAnswer } from "@getpaseo/protocol/agent-types";
export interface QuestionOption {
label: string;
description?: string;
@@ -103,60 +101,35 @@ export function areQuestionsAnswered(
);
}
export function buildQuestionFormResponse(
input: Record<string, unknown> | undefined,
questions: QuestionFormQuestion[],
selections: QuestionSelections,
otherTexts: QuestionOtherTexts,
): AgentPermissionResponse {
return {
behavior: "allow",
questionAnswers: buildQuestionAnswers(questions, selections, otherTexts),
updatedInput: {
...input,
// COMPAT(questionAnswers): added in v0.9.3, remove after 2027-03-26 once daemon floor >= v0.9.3.
// Older daemons read only these text answers; newer daemons derive them from questionAnswers.
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
},
};
}
function buildQuestionAnswers(
questions: QuestionFormQuestion[],
selections: QuestionSelections,
otherTexts: QuestionOtherTexts,
): AgentQuestionAnswer[] {
return questions.map((q, i) => {
const selected = selections[i];
const otherText = otherTexts[i]?.trim();
const labels = selected ? Array.from(selected).map((idx) => q.options[idx].label) : [];
if (questionShowsTextInput(q)) {
if (otherText && otherText.length > 0) {
// Multi-select keeps the checked options alongside the custom answer, the way
// Claude Code's own AskUserQuestion UI does. Single-select replaces the option.
return { selected: q.multiSelect ? labels : [], text: otherText };
}
if (q.allowEmpty && q.options.length === 0) {
return { selected: [], text: "" };
}
}
return { selected: labels };
});
}
export function buildQuestionFormAnswers(
questions: QuestionFormQuestion[],
selections: QuestionSelections,
otherTexts: QuestionOtherTexts,
): Record<string, string> {
const answers: Record<string, string> = {};
buildQuestionAnswers(questions, selections, otherTexts).forEach((answer, i) => {
if (answer.selected.length === 0 && answer.text === undefined) return;
answers[questions[i].header] = [...answer.selected, ...(answer.text ? [answer.text] : [])].join(
", ",
);
});
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
const selected = selections[i];
const otherText = otherTexts[i]?.trim();
const labels = selected ? Array.from(selected).map((idx) => q.options[idx].label) : [];
if (questionShowsTextInput(q)) {
if (otherText && otherText.length > 0) {
// Multi-select keeps the checked options and appends the custom answer, the way
// Claude Code's own AskUserQuestion UI does. Single-select replaces the option.
answers[q.header] = q.multiSelect ? [...labels, otherText].join(", ") : otherText;
continue;
}
if (q.allowEmpty && q.options.length === 0) {
answers[q.header] = "";
continue;
}
}
if (labels.length > 0) {
answers[q.header] = labels.join(", ");
}
}
return answers;
}
@@ -12,7 +12,7 @@ import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
import {
areQuestionsAnswered,
buildQuestionFormResponse,
buildQuestionFormAnswers,
isQuestionAnswered,
parseQuestionFormQuestions,
questionShowsTextInput,
@@ -402,9 +402,13 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
const handleSubmit = useCallback(() => {
if (!questions || !allAnswered || isResponding) return;
setRespondingAction("submit");
onRespond(
buildQuestionFormResponse(permission.request.input, questions, selections, otherTexts),
);
onRespond({
behavior: "allow",
updatedInput: {
...permission.request.input,
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
},
});
}, [
questions,
allAnswered,
@@ -419,9 +423,13 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
if (!questions) return;
setRespondingAction("dismiss");
if (shouldSubmitEmptyOnDismiss(questions)) {
onRespond(
buildQuestionFormResponse(permission.request.input, questions, selections, otherTexts),
);
onRespond({
behavior: "allow",
updatedInput: {
...permission.request.input,
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
},
});
return;
}
onRespond({
+65 -135
View File
@@ -2,21 +2,14 @@ import { AlertTriangle, CheckCircle2, Info, XCircle, type LucideIcon } from "luc
import { type ReactNode, useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
type ButtonControlSize,
buttonIconSize,
createControlGeometry,
} from "@/components/ui/control-geometry";
import { hexColorWithAlpha } from "@/utils/color";
export type AlertVariant = "default" | "info" | "success" | "warning" | "error";
export type AlertSize = ButtonControlSize;
export interface AlertProps {
title?: string;
description?: ReactNode;
variant?: AlertVariant;
size?: AlertSize;
icon?: ReactNode;
children?: ReactNode;
testID?: string;
}
@@ -32,93 +25,50 @@ export function Alert({
title,
description,
variant = "default",
size = "md",
icon,
children,
testID,
}: AlertProps) {
const { theme } = useUnistyles();
const accentColor = resolveAccentColor(variant, theme);
const borderColor =
variant === "success" || !accentColor
? theme.colors.border
: hexColorWithAlpha(accentColor, 0.5);
const borderColor = variant === "success" ? theme.colors.border : accentColor;
const sized = resolveSizeStyles(size);
const containerStyle = [styles.container, sized.container, { borderColor }];
const titleStyle = [styles.title, sized.text, accentColor ? { color: accentColor } : null];
const iconSize = buttonIconSize[size];
const containerStyle = useMemo(
() => [styles.container, borderColor ? { borderColor } : null],
[borderColor],
);
const titleStyle = useMemo(
() => [styles.title, accentColor ? { color: accentColor } : null],
[accentColor],
);
const resolvedIcon = useMemo(() => {
if (icon !== undefined) return icon;
if (variant === "default") return null;
const Icon = VARIANT_ICON[variant];
return <Icon size={iconSize} color={accentColor ?? theme.colors.foreground} />;
}, [variant, theme, accentColor, iconSize]);
return <Icon size={theme.iconSize.sm} color={accentColor ?? theme.colors.foreground} />;
}, [icon, variant, theme, accentColor]);
let descriptionContent: ReactNode = null;
if (typeof description === "string" && description !== "") {
descriptionContent = <Text style={[styles.description, sized.text]}>{description}</Text>;
} else if (description != null && description !== "") {
descriptionContent = <View style={styles.descriptionSlot}>{description}</View>;
}
const leadContent = title ? <Text style={titleStyle}>{title}</Text> : descriptionContent;
const belowLead = title ? descriptionContent : null;
const hasBody = belowLead !== null || Boolean(children);
const hasDescription = description != null && description !== "";
return (
<View style={containerStyle} testID={testID} accessibilityRole="alert">
<View style={[styles.lead, sized.lead]}>
{resolvedIcon ? (
<View style={[styles.iconSlot, sized.iconSlot]}>{resolvedIcon}</View>
{resolvedIcon ? <View style={styles.iconSlot}>{resolvedIcon}</View> : null}
<View style={styles.body}>
{title ? <Text style={titleStyle}>{title}</Text> : null}
{hasDescription && typeof description === "string" ? (
<Text style={styles.description}>{description}</Text>
) : null}
{leadContent ? <View style={styles.leadText}>{leadContent}</View> : null}
{hasDescription && typeof description !== "string" ? (
<View style={styles.descriptionSlot}>{description}</View>
) : null}
{children ? <View style={styles.actions}>{children}</View> : null}
</View>
{hasBody ? (
<View style={resolvedIcon ? sized.indent : null}>
{belowLead}
{children ? <View style={styles.actions}>{children}</View> : null}
</View>
) : null}
</View>
);
}
function resolveSizeStyles(size: AlertSize) {
if (size === "xs") {
return {
container: styles.containerXs,
text: styles.textXs,
lead: styles.leadXs,
iconSlot: styles.iconSlotXs,
indent: styles.indentXs,
};
}
if (size === "sm") {
return {
container: styles.containerSm,
text: styles.textSm,
lead: styles.leadSm,
iconSlot: styles.iconSlotSm,
indent: styles.indentSm,
};
}
if (size === "lg") {
return {
container: styles.containerLg,
text: styles.textLg,
lead: styles.leadLg,
iconSlot: styles.iconSlotLg,
indent: styles.indentLg,
};
}
return {
container: styles.containerMd,
text: styles.textMd,
lead: styles.leadMd,
iconSlot: styles.iconSlotMd,
indent: styles.indentMd,
};
}
function resolveAccentColor(
variant: AlertVariant,
theme: ReturnType<typeof useUnistyles>["theme"],
@@ -130,63 +80,43 @@ function resolveAccentColor(
return null;
}
const styles = StyleSheet.create((theme) => {
const { alert } = createControlGeometry(theme);
return {
container: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: "transparent",
},
containerXs: alert.xs.container,
containerSm: alert.sm.container,
containerMd: alert.md.container,
containerLg: alert.lg.container,
lead: {
flexDirection: "row",
alignItems: "center",
},
iconSlot: {
alignItems: "center",
},
leadText: {
flex: 1,
minWidth: 0,
},
leadXs: alert.xs.lead,
leadSm: alert.sm.lead,
leadMd: alert.md.lead,
leadLg: alert.lg.lead,
iconSlotXs: alert.xs.iconSlot,
iconSlotSm: alert.sm.iconSlot,
iconSlotMd: alert.md.iconSlot,
iconSlotLg: alert.lg.iconSlot,
indentXs: alert.xs.indent,
indentSm: alert.sm.indent,
indentMd: alert.md.indent,
indentLg: alert.lg.indent,
textXs: alert.xs.text,
textSm: alert.sm.text,
textMd: alert.md.text,
textLg: alert.lg.text,
title: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
},
description: {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
descriptionSlot: {
flexShrink: 1,
minWidth: 0,
gap: theme.spacing[2],
},
actions: {
flexDirection: "row",
gap: theme.spacing[2],
marginTop: theme.spacing[2],
},
};
});
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[3],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: "transparent",
borderRadius: theme.borderRadius.xl,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
iconSlot: {
paddingTop: 2,
},
body: {
flex: 1,
minWidth: 0,
gap: theme.spacing[1],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
descriptionSlot: {
flexShrink: 1,
minWidth: 0,
gap: theme.spacing[2],
},
actions: {
flexDirection: "row",
gap: theme.spacing[2],
marginTop: theme.spacing[2],
},
}));
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buttonControlHeight,
buttonIconSize,
createControlGeometry,
getControlInteractionPhase,
} from "@/components/ui/control-geometry";
@@ -12,7 +11,6 @@ const theme = {
md: 6,
lg: 8,
xl: 12,
"2xl": 16,
full: 9999,
},
borderWidth: {
@@ -32,13 +30,10 @@ const theme = {
},
spacing: {
0: 0,
0.5: 2,
1: 4,
2: 8,
3: 12,
4: 16,
6: 24,
8: 32,
},
} as unknown as Theme;
@@ -162,41 +157,4 @@ describe("control geometry", () => {
geometry.buttonMd.paddingHorizontal,
);
});
it("gives alert title and description one font size and a tight gap per size", () => {
const { alert } = createControlGeometry(theme);
expect(alert.xs.text).toEqual({ fontSize: 12 });
expect(alert.sm.text).toEqual({ fontSize: 14 });
expect(alert.md.text).toEqual(alert.sm.text);
expect(alert.lg.text).toEqual(alert.sm.text);
for (const size of ["xs", "sm", "md", "lg"] as const) {
expect(alert[size].container.gap).toBeLessThanOrEqual(4);
}
});
it("starts alert text below the lead row at the lead text's left edge", () => {
const { alert } = createControlGeometry(theme);
for (const size of ["xs", "sm", "md", "lg"] as const) {
expect(alert[size].iconSlot.width).toBe(buttonIconSize[size]);
expect(alert[size].indent.marginLeft).toBe(alert[size].iconSlot.width + alert[size].lead.gap);
}
});
it("keeps sm alerts as roomy and round as the original alert, and scales the rest around it", () => {
const { alert } = createControlGeometry(theme);
expect(alert.sm.container).toMatchObject({
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 16,
});
expect(alert.xs.container.paddingVertical).toBeLessThan(alert.sm.container.paddingVertical);
expect(alert.md.container.paddingVertical).toBeGreaterThan(alert.sm.container.paddingVertical);
expect(alert.lg.container.paddingVertical).toBeGreaterThan(alert.md.container.paddingVertical);
for (const size of ["xs", "sm", "md", "lg"] as const) {
expect(alert[size].container.borderRadius).toBeGreaterThanOrEqual(12);
}
});
});
@@ -87,77 +87,6 @@ function fieldVerticalPadding(
return (controlHeight - lineHeight - borderWidth * 2) / 2;
}
/**
* Alerts share the button's size names, text size, and icon size (`buttonIconSize`).
* The icon and the first text sit in one centered lead row; everything below is indented
* by the icon slot plus its gap so it starts at the lead text's left edge. Title and
* description use one font size; only the container gap separates them.
*/
function createAlertGeometry(theme: Theme) {
function alertSize(input: {
size: ButtonControlSize;
fontSize: number;
paddingVertical: number;
paddingHorizontal: number;
borderRadius: number;
iconGap: number;
textGap: number;
}) {
const iconSize = buttonIconSize[input.size];
return {
container: {
paddingVertical: input.paddingVertical,
paddingHorizontal: input.paddingHorizontal,
borderRadius: input.borderRadius,
gap: input.textGap,
},
lead: { gap: input.iconGap },
iconSlot: { width: iconSize },
indent: { marginLeft: iconSize + input.iconGap },
text: { fontSize: input.fontSize },
};
}
return {
xs: alertSize({
size: "xs",
fontSize: theme.fontSize.sm,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.xl,
iconGap: theme.spacing[2],
textGap: 0,
}),
sm: alertSize({
size: "sm",
fontSize: theme.fontSize.base,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius["2xl"],
iconGap: theme.spacing[3],
textGap: theme.spacing[0.5],
}),
md: alertSize({
size: "md",
fontSize: theme.fontSize.base,
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[6],
borderRadius: theme.borderRadius["2xl"],
iconGap: theme.spacing[3],
textGap: theme.spacing[0.5],
}),
lg: alertSize({
size: "lg",
fontSize: theme.fontSize.base,
paddingVertical: theme.spacing[6],
paddingHorizontal: theme.spacing[8],
borderRadius: theme.borderRadius["2xl"],
iconGap: theme.spacing[4],
textGap: theme.spacing[1],
}),
} satisfies Record<ButtonControlSize, unknown>;
}
export function getControlInteractionPhase(
state: ControlInteractionState,
): ControlInteractionPhase {
@@ -218,7 +147,6 @@ export function createControlGeometry(theme: Theme) {
fontSize: theme.fontSize.base,
lineHeight: fieldTextMdLineHeight,
};
const alert = createAlertGeometry(theme);
const switchControl = {
minHeight: CONTROL_HEIGHTS.compact,
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
@@ -251,7 +179,6 @@ export function createControlGeometry(theme: Theme) {
buttonTextXs: {
fontSize: theme.fontSize.sm,
},
alert,
formTextInputSm: {
...fieldControlSm,
...fieldTextSm,
@@ -131,7 +131,6 @@ export function DesktopNotificationsSection() {
</View>
{testNotificationState.status === "success" ? (
<Alert
size="sm"
variant="success"
title={t("settings.notifications.sentTitle")}
description={t("settings.notifications.sentDescription")}
@@ -140,7 +139,6 @@ export function DesktopNotificationsSection() {
) : null}
{testNotificationState.status === "error" ? (
<Alert
size="sm"
variant="error"
title={t("settings.notifications.sendFailedTitle")}
description={testNotificationState.message}
@@ -169,7 +169,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
const { t } = useTranslation();
return (
<Alert size="sm" variant="error" description={message}>
<Alert variant="error" description={message}>
<Button variant="outline" size="sm" leftIcon={RotateCw} onPress={onRetry}>
{t("pairing.device.retry")}
</Button>
@@ -197,11 +197,9 @@ function RelayConsent(props: PairDeviceBodyProps) {
accessibilityLabel={t("pairing.device.relayDocsAccessibility")}
/>
</View>
{props.enableError ? (
<Alert size="sm" variant="error" description={props.enableError.message} />
) : null}
{props.enableError ? <Alert variant="error" description={props.enableError.message} /> : null}
{!props.canConfigureRelay ? (
<Alert size="sm" variant="warning" description={t("pairing.device.updateRequired")} />
<Alert variant="warning" description={t("pairing.device.updateRequired")} />
) : null}
<View style={styles.actions}>
<Button variant="secondary" style={FLEX_ONE_STYLE} onPress={props.onClose}>
@@ -264,7 +262,7 @@ function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
{props.copied ? t("pairing.device.copied") : t("pairing.device.copy")}
</Button>
</View>
<Alert size="sm" variant="warning" description={t("pairing.device.securityWarning")} />
<Alert variant="warning" description={t("pairing.device.securityWarning")} />
</View>
);
}
@@ -1,29 +0,0 @@
import type { HostConnection } from "@/types/host-connection";
import { getDesktopHost } from "@/desktop/host";
function connectionListen(connection: HostConnection): string | null {
switch (connection.type) {
case "directTcp":
return connection.endpoint;
case "directSocket":
return `unix://${connection.path}`;
case "directPipe":
return `pipe://${connection.path}`;
case "remoteSsh":
case "relay":
return null;
}
}
// Desktop main checks that this target matches the live desktop-managed daemon.
// A plain browser has no bridge and falls back to its saved host password.
export async function readDesktopManagedLocalCredential(
connection: HostConnection,
invoke: (listen: string) => Promise<unknown> = async (listen) =>
getDesktopHost()?.invoke?.("desktop_local_credential", { listen }),
): Promise<string | undefined> {
const listen = connectionListen(connection);
if (!listen) return undefined;
const credential = await invoke(listen);
return typeof credential === "string" ? credential : undefined;
}
@@ -11,7 +11,6 @@ import { defaultHostAppearance } from "@/hosts/appearance";
function makeHost(): HostProfile {
return {
serverId: "srv-secret",
password: "tcp-password",
label: "Secret host",
appearance: defaultHostAppearance(),
lifecycle: {},
@@ -24,6 +23,7 @@ function makeHost(): HostProfile {
type: "directTcp",
endpoint: "secret.example.test:6767",
useTls: true,
password: "tcp-password",
},
{
id: "relay:relay.secret.test:443",
@@ -125,11 +125,11 @@ export function redactAppDiagnosticReport(report: string, hosts: HostProfile[]):
function collectSensitiveHostValues(hosts: HostProfile[]): string[] {
const values = new Set<string>();
for (const host of hosts) {
if (host.password) values.add(host.password);
for (const connection of host.connections) {
values.add(connection.id);
if (connection.type === "directTcp") {
values.add(connection.endpoint);
if (connection.password) values.add(connection.password);
} else if (connection.type === "relay") {
values.add(connection.relayEndpoint);
values.add(connection.daemonPublicKeyB64);
-7
View File
@@ -1613,10 +1613,6 @@ export const ar: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "كلمة المرور لـ {{host}}",
label: "كلمة مرور المضيف",
},
connectionMethods: {
title: "إضافة اتصال",
direct: {
@@ -2298,9 +2294,6 @@ export const ar: TranslationResources = {
},
},
host: {
password: {
guidance: "أزل هذا المضيف ثم أضفه مرة أخرى بكلمة المرور التي يطلبها هذا الخادم.",
},
appearance: {
title: "المظهر",
name: {
-7
View File
@@ -1638,10 +1638,6 @@ export const en = {
},
},
pairing: {
hostPassword: {
title: "Password for {{host}}",
label: "Host password",
},
connectionMethods: {
title: "Add connection",
direct: {
@@ -2417,9 +2413,6 @@ export const en = {
},
},
host: {
password: {
guidance: "Remove this host and add it again with the password this daemon asks for.",
},
appearance: {
title: "Appearance",
name: {
-7
View File
@@ -1659,10 +1659,6 @@ export const es: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "Contraseña de {{host}}",
label: "Contraseña del host",
},
connectionMethods: {
title: "Agregar conexión",
direct: {
@@ -2352,9 +2348,6 @@ export const es: TranslationResources = {
},
},
host: {
password: {
guidance: "Elimina este host y vuelve a añadirlo con la contraseña que pide este daemon.",
},
appearance: {
title: "Apariencia",
name: {
-8
View File
@@ -1663,10 +1663,6 @@ export const fr: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "Mot de passe pour {{host}}",
label: "Mot de passe de l’hôte",
},
connectionMethods: {
title: "Ajouter une connexion",
direct: {
@@ -2356,10 +2352,6 @@ export const fr: TranslationResources = {
},
},
host: {
password: {
guidance:
"Supprimez cet hôte puis ajoutez-le à nouveau avec le mot de passe demandé par ce daemon.",
},
appearance: {
title: "Apparence",
name: {
-8
View File
@@ -1630,10 +1630,6 @@ export const ja: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "{{host}} のパスワード",
label: "ホストのパスワード",
},
connectionMethods: {
title: "接続を追加",
direct: {
@@ -2319,10 +2315,6 @@ export const ja: TranslationResources = {
},
},
host: {
password: {
guidance:
"このホストを削除し、このデーモンが求めるパスワードを入力して追加し直してください。",
},
appearance: {
title: "外観",
name: {
-7
View File
@@ -1623,10 +1623,6 @@ export const ko: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "{{host}}의 비밀번호",
label: "호스트 비밀번호",
},
connectionMethods: {
title: "연결 추가",
direct: {
@@ -2308,9 +2304,6 @@ export const ko: TranslationResources = {
},
},
host: {
password: {
guidance: "이 호스트를 제거한 뒤 이 데몬이 요구하는 비밀번호로 다시 추가하세요.",
},
appearance: {
title: "모양",
name: {
-7
View File
@@ -1644,10 +1644,6 @@ export const ptBR: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "Senha de {{host}}",
label: "Senha do host",
},
connectionMethods: {
title: "Adicionar conexão",
direct: {
@@ -2334,9 +2330,6 @@ export const ptBR: TranslationResources = {
},
},
host: {
password: {
guidance: "Remova este host e adicione-o novamente com a senha que este daemon pede.",
},
appearance: {
title: "Aparência",
name: {
-8
View File
@@ -1642,10 +1642,6 @@ export const ru: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "Пароль для {{host}}",
label: "Пароль хоста",
},
connectionMethods: {
title: "Добавить подключение",
direct: {
@@ -2338,10 +2334,6 @@ export const ru: TranslationResources = {
},
},
host: {
password: {
guidance:
"Удалите этот хост и добавьте его снова с паролем, который запрашивает этот демон.",
},
appearance: {
title: "Оформление",
name: {
-7
View File
@@ -1596,10 +1596,6 @@ export const zhCN: TranslationResources = {
},
},
pairing: {
hostPassword: {
title: "{{host}} 的密码",
label: "主机密码",
},
connectionMethods: {
title: "添加连接",
direct: {
@@ -2272,9 +2268,6 @@ export const zhCN: TranslationResources = {
},
},
host: {
password: {
guidance: "移除此主机,然后使用此守护进程要求的密码重新添加。",
},
appearance: {
title: "外观",
name: {
@@ -11,7 +11,7 @@ import {
import type { DaemonStartResult, StartDaemonIfEnabledInput } from "@/runtime/daemon-start-service";
describe("startHostRuntimeBootstrap", () => {
it("boots the host registry and starts the managed-daemon decision independently", async () => {
it("boots the host registry and starts the managed-daemon decision as one operation", async () => {
const events: string[] = [];
const shouldStartDaemon = async () => true;
const store = {
@@ -40,10 +40,9 @@ describe("startHostRuntimeBootstrap", () => {
expect(events).toEqual(["boot", "daemon-start-decision"]);
expect(await receivedDecisions[0]).toBe(true);
expect(events).toEqual(["boot", "daemon-start-decision"]);
});
it("loads the registry before evaluating managed-daemon startup", async () => {
it("waits for the host registry to load before evaluating managed-daemon startup", async () => {
const events: string[] = [];
let resolveBoot!: () => void;
const booted = new Promise<void>((resolve) => {
+1 -2
View File
@@ -145,7 +145,6 @@ function BlockedSetupNotice({
return (
<>
<Alert
size="sm"
variant="warning"
title={t("workspace.setup.blocked.title")}
description={t("workspace.setup.blocked.description", {
@@ -159,7 +158,7 @@ function BlockedSetupNotice({
</Button>
) : null}
</Alert>
{runError ? <Alert size="sm" variant="error" description={runError} /> : null}
{runError ? <Alert variant="error" description={runError} /> : null}
</>
);
}
@@ -63,12 +63,7 @@ function ProviderUsageBody({
if (view.kind === "error") {
return (
<Alert
size="sm"
variant="error"
title={providerUsageCopy.errorTitle}
description={view.message}
>
<Alert variant="error" title={providerUsageCopy.errorTitle} description={view.message}>
<Button variant="outline" size="sm" onPress={onRefresh}>
{providerUsageCopy.retry}
</Button>
@@ -325,15 +325,6 @@ describe("upsertDesktopDaemonConnection", () => {
expect(fake.upserts).toEqual([]);
});
it("keeps an already registered host without waiting for a listen address", async () => {
const fake = createFakeStore([makeRelayOnlyHost("srv_desktop")]);
const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ listen: null }));
expect(result).toEqual({ ok: true });
expect(fake.upserts).toEqual([]);
});
it("rejects a missing listen address without upserting", async () => {
const fake = createFakeStore();
@@ -27,6 +27,7 @@ export async function upsertDesktopDaemonConnection(
if (store.getHosts().some((host) => host.serverId === serverId)) {
return { ok: true };
}
const listenAddress = daemon.listen?.trim() ?? "";
if (!listenAddress) {
return { ok: false, error: "Desktop daemon did not return a listen address." };
+3 -386
View File
@@ -1,16 +1,12 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AppStateStatus } from "react-native";
import {
bindHostRuntimeAppState,
startHostRuntimeBootstrap,
} from "@/navigation/host-runtime-bootstrap";
import { bindHostRuntimeAppState } from "@/navigation/host-runtime-bootstrap";
import type {
DaemonClient,
ConnectionState,
FetchAgentsEntry,
FetchAgentsOptions,
} from "@getpaseo/client/internal/daemon-client";
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
@@ -30,31 +26,11 @@ import {
import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
import { subscriptionFixture } from "./subscription-fixture";
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
it("requests the managed connection credential through desktop main without a web hint", async () => {
const requests: string[] = [];
const connection: HostConnection = {
id: "direct:localhost:6767",
type: "directTcp",
endpoint: "localhost:6767",
};
let token = "local-token";
const invoke = async (listen: string) => {
requests.push(listen);
return token;
};
expect(await readDesktopManagedLocalCredential(connection, invoke)).toBe("local-token");
token = "rotated-token";
expect(await readDesktopManagedLocalCredential(connection, invoke)).toBe("rotated-token");
expect(requests).toEqual(["localhost:6767", "localhost:6767"]);
});
class FakeDaemonClient {
private state: ConnectionState = { status: "idle" };
private listeners = new Set<(status: ConnectionState) => void>();
private error: string | null = null;
authFailureReason: "password_required" | "incorrect_password" | null = null;
private heartbeatRttMs: number | null = null;
private latencyMeasurementFailure: Error | null = null;
private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = [];
@@ -405,7 +381,6 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
return {
serverId: input?.serverId ?? "srv_test",
...(input?.password ? { password: input.password } : {}),
label: input?.label ?? "test host",
appearance: input?.appearance ?? defaultHostAppearance(),
lifecycle: input?.lifecycle ?? {},
@@ -584,21 +559,6 @@ function onceHostListMatches(store: HostRuntimeStore, predicate: () => boolean):
});
}
function onceHostSnapshotMatches(
store: HostRuntimeStore,
serverId: string,
predicate: (snapshot: ReturnType<HostRuntimeStore["getSnapshot"]>) => boolean,
): Promise<void> {
if (predicate(store.getSnapshot(serverId))) return Promise.resolve();
return new Promise((resolve) => {
const unsubscribe = store.subscribe(serverId, () => {
if (!predicate(store.getSnapshot(serverId))) return;
unsubscribe();
resolve();
});
});
}
class BrowserClientLifecycle {
public active: Array<{ serverId: string; connectionId: string }> = [];
@@ -3390,7 +3350,7 @@ describe("HostRuntimeStore", () => {
}
});
it("upsertDirectConnection stores SSL on the connection and password on the host", async () => {
it("upsertDirectConnection stores SSL and password settings", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
@@ -3418,9 +3378,9 @@ describe("HostRuntimeStore", () => {
type: "directTcp",
endpoint: "example.paseo.test:7443",
useTls: true,
password: "shared-secret",
},
]);
expect(host?.password).toBe("shared-secret");
store.syncHosts([]);
});
@@ -3597,33 +3557,6 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("probes a pairing link immediately and saves only after admission", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => {
if (host.password !== "correct-password")
throw new DaemonAuthenticationError("password_required");
return {
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: "paired host",
};
},
getClientId: async () => "cid_pairing",
},
});
const offerUrl = encodeOfferUrl(makeOffer());
await expect(store.probeAndUpsertConnectionFromOfferUrl(offerUrl)).rejects.toThrow(
"Password required",
);
expect(store.getHosts()).toHaveLength(0);
const result = await store.probeAndUpsertConnectionFromOfferUrl(offerUrl, "correct-password");
expect(result.serverId).toBe("srv_offer");
expect(store.getHosts()[0]?.password).toBe("correct-password");
store.syncHosts([]);
});
it("preserves the existing host label when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({
deps: {
@@ -3692,322 +3625,6 @@ describe("readInitialDaemonConnectionHint", () => {
});
describe("HostRuntimeStore initial connection hint bootstrap", () => {
it("starts saved remote-host probes while desktop daemon startup is still pending", async () => {
const host = makeHost({
serverId: "srv_remote_saved",
connections: [
{ id: "direct:remote.example:6799", type: "directTcp", endpoint: "remote.example:6799" },
],
});
const storage = createMemoryHostRuntimeStorage();
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
let remoteProbeStarted = false;
let finishDesktopStart: (() => void) | undefined;
const desktopStartPending = new Promise<void>((resolve) => {
finishDesktopStart = resolve;
});
const store = new HostRuntimeStore({
storage,
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async () => {
remoteProbeStarted = true;
return {
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label,
};
},
getClientId: async () => "cid_remote_saved",
},
});
startHostRuntimeBootstrap({
store,
shouldStartDaemon: true,
daemonStartService: {
startIfEnabled: async () => {
await desktopStartPending;
return { ok: true };
},
},
});
try {
await vi.waitFor(() => expect(remoteProbeStarted).toBe(true), { timeout: 400 });
expect(store.isHostRegistryLoaded()).toBe(true);
} finally {
finishDesktopStart?.();
await store.boot();
store.syncHosts([]);
}
});
it("resolves a local credential for a reopened saved desktop host connection", async () => {
const host = makeHost({
serverId: "srv_desktop_reopened",
connections: [{ id: "direct:localhost:6799", type: "directTcp", endpoint: "localhost:6799" }],
});
const storage = createMemoryHostRuntimeStorage();
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
const credentialRequests: string[] = [];
const store = new HostRuntimeStore({
storage,
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host: probeHost, connection }) => {
expect(probeHost.password).toBeUndefined();
const credential = await readDesktopManagedLocalCredential(connection, async (listen) => {
credentialRequests.push(listen);
return listen === "localhost:6799" ? "local-token" : null;
});
expect(credential).toBe("local-token");
return {
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label,
};
},
getClientId: async () => "cid_desktop_reopened",
},
});
startHostRuntimeBootstrap({
store,
shouldStartDaemon: true,
daemonStartService: {
startIfEnabled: async () => ({ ok: true }),
},
});
await waitForHostOnline(store, host.serverId);
expect(credentialRequests).toContain("localhost:6799");
store.syncHosts([]);
});
it("requires a new password and stops reconnecting after a previously connected client is rejected", async () => {
const client = makeConnectedProbeClient(5);
const host = makeHost({
serverId: "srv_changed_password",
password: "old-password",
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
});
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => client as unknown as DaemonClient,
connectToDaemon: async () => ({
client: client as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label,
}),
getClientId: async () => "cid_changed_password",
},
});
store.syncHosts([host], {
initialConnectionByServerId: new Map([
[
host.serverId,
{
connectionId: host.connections[0]!.id,
existingClient: client as unknown as DaemonClient,
},
],
]),
});
await waitForHostOnline(store, host.serverId);
client.authFailureReason = "incorrect_password";
client.setConnectionState({ status: "disconnected", reason: "Incorrect password" });
await onceHostSnapshotMatches(
store,
host.serverId,
(snapshot) => snapshot?.authFailureReason === "incorrect_password",
);
expect(store.getSnapshot(host.serverId)?.lastError).toBe("Incorrect password");
expect(client.reconnectEnabledChanges.at(-1)).toBe(false);
store.syncHosts([]);
});
it("tries another connection after the active connection rejects its password", async () => {
const relayClient = makeConnectedProbeClient(5);
const directClient = makeConnectedProbeClient(5);
const host = makeHost({
serverId: "srv_rejected_relay_with_local_direct",
password: "stale-password",
preferredConnectionId: "relay:remote:443",
connections: [
{
id: "relay:remote:443",
type: "relay",
relayEndpoint: "remote:443",
daemonPublicKeyB64: "pk_test_offer",
},
{ id: "direct:localhost:6799", type: "directTcp", endpoint: "localhost:6799" },
],
});
const probeAttempts: string[] = [];
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ connection }) => {
probeAttempts.push(connection.id);
if (connection.type === "relay")
throw new DaemonAuthenticationError("incorrect_password");
return {
client: directClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label,
};
},
getClientId: async () => "cid_rejected_relay_with_local_direct",
},
});
store.syncHosts([host], {
initialConnectionByServerId: new Map([
[
host.serverId,
{
connectionId: "relay:remote:443",
existingClient: relayClient as unknown as DaemonClient,
},
],
]),
});
await waitForHostOnline(store, host.serverId);
relayClient.authFailureReason = "incorrect_password";
relayClient.setConnectionState({ status: "disconnected", reason: "Incorrect password" });
await onceHostSnapshotMatches(
store,
host.serverId,
(snapshot) => snapshot?.authFailureReason === "incorrect_password",
);
await store.runProbeCycleNow(host.serverId);
await onceHostSnapshotMatches(
store,
host.serverId,
(snapshot) =>
snapshot?.connectionStatus === "online" &&
snapshot.activeConnectionId === "direct:localhost:6799",
);
expect(probeAttempts).toContain("direct:localhost:6799");
expect(probeAttempts).not.toContain("relay:remote:443");
store.syncHosts([]);
});
it("keeps five rejected hosts red with independent password errors", async () => {
let probeAttempts = 0;
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async () => {
probeAttempts += 1;
throw new DaemonAuthenticationError("password_required");
},
getClientId: async () => "cid_five_rejected_hosts",
},
});
for (let index = 1; index <= 5; index += 1) {
const serverId = `srv_rejected_${index}`;
await store.upsertDirectConnection({ serverId, endpoint: `host-${index}:6767` });
await onceHostSnapshotMatches(
store,
serverId,
(snapshot) => snapshot?.authFailureReason === "password_required",
);
}
for (let index = 1; index <= 5; index += 1) {
const snapshot = store.getSnapshot(`srv_rejected_${index}`);
expect(snapshot?.connectionStatus).toBe("error");
expect(snapshot?.lastError).toBe("Password required");
}
const attemptsBeforeRetry = probeAttempts;
await store.runProbeCycleNow("srv_rejected_1");
expect(probeAttempts).toBe(attemptsBeforeRetry);
store.syncHosts([]);
});
it("imports a pairing link after a password retry in the add flow", async () => {
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => {
if (host.password !== "correct-password")
throw new DaemonAuthenticationError("password_required");
return {
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: "paired",
};
},
getClientId: async () => "cid_link_import",
},
});
const link = "relay://relay.example:443/srv_pair?key=AAAA&ssl=true";
await expect(store.importConnectionLink(link, "hostRoot")).resolves.toEqual({
status: "password_required",
});
expect(store.getHosts()).toHaveLength(0);
await store.probeAndUpsertConnectionFromOfferUrl(link, "correct-password");
expect(store.getHosts()[0]).toMatchObject({
serverId: "srv_pair",
password: "correct-password",
});
store.syncHosts([]);
});
it("imports a deep link without waiting for admission, then leaves a rejected saved host red", async () => {
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async () => {
throw new DaemonAuthenticationError("password_required");
},
getClientId: async () => "cid_deep_link",
},
});
const link = "relay://relay.example:443/srv_deep?key=AAAA&ssl=true";
await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({
status: "connected",
serverId: "srv_deep",
});
expect(store.getHosts()).toHaveLength(1);
await onceHostSnapshotMatches(
store,
"srv_deep",
(snapshot) => snapshot?.authFailureReason === "password_required",
);
expect(store.getSnapshot("srv_deep")?.connectionStatus).toBe("error");
store.syncHosts([]);
});
it("saves and reconnects a rejected saved host after changing its password", async () => {
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => {
if (host.password !== "correct-password")
throw new DaemonAuthenticationError("password_required");
return {
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label,
};
},
getClientId: async () => "cid_test_password_setting",
},
});
await store.upsertDirectConnection({ serverId: "srv_test", endpoint: "lan:6767" });
await onceHostSnapshotMatches(
store,
"srv_test",
(snapshot) => snapshot?.authFailureReason === "password_required",
);
expect(store.getSnapshot("srv_test")?.connectionStatus).toBe("error");
await store.setHostPassword("srv_test", "correct-password");
await waitForHostOnline(store, "srv_test");
expect(store.getHosts()[0]?.password).toBe("correct-password");
expect(store.getSnapshot("srv_test")?.authFailureReason).toBeNull();
await store.setHostPassword("srv_test", "");
expect(store.getHosts()[0]?.password).toBeUndefined();
store.syncHosts([]);
});
it("attempts the explicit initial connection hint before default localhost bootstrap", async () => {
const seenProbes: { endpoint: string; useTls?: boolean }[] = [];
const store = new HostRuntimeStore({
+20 -176
View File
@@ -3,7 +3,6 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import equal from "fast-deep-equal/es6";
import {
DaemonClient,
DaemonAuthenticationError,
type DaemonClientConfig,
type ConnectionState,
type FetchAgentsOptions,
@@ -24,14 +23,13 @@ import {
buildRelayWebSocketUrl,
decodeOfferFragmentPayload,
normalizeHostPort,
parseRelayConnectionUri,
shouldUseTlsForDefaultHostedRelay,
} from "@/utils/daemon-endpoints";
import { resolveAppVersion } from "@/utils/app-version";
import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { isWeb } from "@/constants/platform";
import { connectToDaemon, getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { getOrCreateClientId } from "@/utils/client-id";
import { z } from "zod";
import { readValidatedJson, readValidatedString } from "@/storage/validated-storage";
@@ -45,7 +43,6 @@ import {
createDesktopDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
import { getDesktopHost } from "@/desktop/host";
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import {
@@ -85,7 +82,6 @@ import { revokePushNotifications } from "@/push-notifications";
import { createAppWebSocketFactory } from "./websocket-factory";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type PairingNavigationTarget = "openProject" | "hostRoot" | "hostSettings";
export type HostRegistryStatus = "loading" | "ready";
export type ActiveConnection =
@@ -110,7 +106,6 @@ export interface HostRuntimeSnapshot {
connectionStatus: HostRuntimeConnectionStatus;
client: DaemonClient | null;
lastError: string | null;
authFailureReason?: "password_required" | "incorrect_password" | null;
lastOnlineAt: string | null;
agentDirectoryStatus: HostRuntimeAgentDirectoryStatus;
agentDirectoryError: string | null;
@@ -522,8 +517,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
capabilities: appCapabilities,
trace: nativePerformanceTrace,
providerSnapshots: "wire",
...(host.password ? { password: host.password } : {}),
localCredential: () => readDesktopManagedLocalCredential(connection),
} satisfies Omit<DaemonClientConfig, "url">;
if (connection.type === "directSocket" || connection.type === "directPipe") {
return new DaemonClient({
@@ -557,6 +550,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
url: buildDaemonWebSocketUrl(connection.endpoint, {
useTls: connection.useTls ?? false,
}),
...(connection.password ? { password: connection.password } : {}),
});
}
return new DaemonClient({
@@ -576,8 +570,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
connectToDaemon: ({ host, connection, timeoutMs }) =>
connectToDaemon(connection, {
...(host.serverId ? { serverId: host.serverId } : {}),
...(host.password ? { password: host.password } : {}),
localCredential: () => readDesktopManagedLocalCredential(connection),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
capabilities: appCapabilities,
trace: nativePerformanceTrace,
@@ -625,7 +617,6 @@ export class HostRuntimeController {
private switchRequestVersion = 0;
private probeRequestVersion = 0;
private probeCycleInFlight: Promise<void> | null = null;
private readonly authRejectedConnectionIds = new Set<string>();
constructor(input: {
host: HostProfile;
@@ -714,12 +705,6 @@ export class HostRuntimeController {
}
async updateHost(host: HostProfile): Promise<void> {
const passwordChanged = this.host.password !== host.password;
if (passwordChanged) {
this.probeRequestVersion += 1;
this.authRejectedConnectionIds.clear();
this.updateSnapshot({ authFailureReason: null });
}
const activeConnectionId = this.snapshot.activeConnectionId;
const previousActiveConnection = findConnectionById(this.host, activeConnectionId);
this.host = host;
@@ -729,13 +714,11 @@ export class HostRuntimeController {
activeConnectionId &&
previousActiveConnection &&
nextActiveConnection &&
(!equal(previousActiveConnection, nextActiveConnection) || passwordChanged)
!equal(previousActiveConnection, nextActiveConnection)
) {
this.connectionLastProbedAt.delete(activeConnectionId);
await this.switchToConnection({ connectionId: activeConnectionId });
}
if (passwordChanged) this.connectionLastProbedAt.clear();
if (passwordChanged) await this.probeCycleInFlight;
await this.runProbeCycleNow();
}
@@ -825,7 +808,6 @@ export class HostRuntimeController {
const hasActiveOnlineConnection = isOnline && activeConnectionId !== null;
const connectionsToProbe = this.host.connections.filter((connection) => {
if (this.authRejectedConnectionIds.has(connection.id)) return false;
const lastProbed = this.connectionLastProbedAt.get(connection.id);
if (lastProbed == null) {
return true;
@@ -861,7 +843,6 @@ export class HostRuntimeController {
this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) });
let remaining = connectionsToProbe.length;
let probeAuthFailure: "password_required" | "incorrect_password" | null = null;
let activationLock: Promise<void> | null = null;
const publishProbeState = (): void => {
@@ -912,18 +893,11 @@ export class HostRuntimeController {
connectionId: nextConnectionId,
expectedProbeVersion: requestVersion,
});
} else if (probeAuthFailure) {
const message = new DaemonAuthenticationError(probeAuthFailure).message;
this.applyConnectionEvent({ type: "connect_failed", message });
this.updateSnapshot({
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
authFailureReason: probeAuthFailure,
});
}
return;
}
if (this.isConnectionUnavailable(currentActiveConnectionId, activeProbe)) {
if (activeProbe?.status === "unavailable") {
const nextConnectionId = selectBestConnection({
candidates: buildConnectionCandidates(this.host),
probeByConnectionId,
@@ -1056,13 +1030,8 @@ export class HostRuntimeController {
latencyMs: rttMs,
});
publishProbeState();
} catch (error) {
} catch {
if (this.isCurrentProbeRequest(requestVersion)) {
const authFailure = getConnectionAuthFailureReason(error);
if (authFailure) {
this.authRejectedConnectionIds.add(connection.id);
probeAuthFailure = authFailure;
}
probeByConnectionId.set(connection.id, {
status: "unavailable",
latencyMs: null,
@@ -1080,13 +1049,6 @@ export class HostRuntimeController {
});
}
private isConnectionUnavailable(
connectionId: string,
probe: ConnectionProbeState | null | undefined,
): boolean {
return probe?.status === "unavailable" || this.authRejectedConnectionIds.has(connectionId);
}
private updateSnapshot(patch: HostRuntimeSnapshotPatch): void {
const preservedPatch: HostRuntimeSnapshotPatch = { ...patch };
let hasChanged = this.host.serverId !== this.snapshot.serverId;
@@ -1295,8 +1257,6 @@ export class HostRuntimeController {
const failConnection = async (error: unknown) => {
if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return;
const authFailure = getConnectionAuthFailureReason(error);
if (authFailure) this.authRejectedConnectionIds.add(connection.id);
this.unsubscribeClientStatus?.();
this.unsubscribeClientStatus = null;
this.unsubscribeClientHandlers?.();
@@ -1306,29 +1266,12 @@ export class HostRuntimeController {
this.updateSnapshot({
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
client: null,
authFailureReason: authFailure,
});
if (authFailure) {
const probes = new Map(this.snapshot.probeByConnectionId);
probes.set(connection.id, { status: "unavailable", latencyMs: null });
this.updateSnapshot({ probeByConnectionId: probes });
for (const candidate of this.host.connections) {
if (!this.authRejectedConnectionIds.has(candidate.id)) {
this.connectionLastProbedAt.delete(candidate.id);
}
}
}
try {
await client.close();
} catch {
/* Preserve the compatibility/connection error. */
}
if (
authFailure &&
this.host.connections.some((candidate) => !this.authRejectedConnectionIds.has(candidate.id))
) {
void this.runProbeCycleNow();
}
};
try {
if (!existingClient) await client.connect();
@@ -1337,16 +1280,11 @@ export class HostRuntimeController {
this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null;
this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => {
if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return;
if (client.authFailureReason) {
void failConnection(new DaemonAuthenticationError(client.authFailureReason));
return;
}
this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError });
this.updateSnapshot({
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
...this.buildAgentDirectoryStatusPatch(),
client,
authFailureReason: null,
});
});
} catch (error) {
@@ -1407,19 +1345,6 @@ function readConfiguredLocalDaemonOverride(): string | null {
return value && value.length > 0 ? value : null;
}
function parseOfferConnectionUrl(input: string): { offer: ConnectionOffer; password?: string } {
if (input.trim().startsWith("relay://") || input.includes("#connect=")) {
return parseRelayConnectionUri(input);
}
const marker = "#offer=";
const idx = input.indexOf(marker);
if (idx === -1) throw new Error("Missing #offer= fragment");
const encoded = input.slice(idx + marker.length).trim();
if (!encoded) throw new Error("Offer payload is empty");
const payload = decodeOfferFragmentPayload(encoded);
return { offer: ConnectionOfferSchema.parse(payload) };
}
export function hasConfiguredLocalDaemonOverride(): boolean {
return readConfiguredLocalDaemonOverride() !== null;
}
@@ -1570,14 +1495,6 @@ export class HostRuntimeStore {
normalizedProfiles.length = 0;
break;
}
// COMPAT(connectionPassword): added in v0.9.1, remove after 2027-03-24 with stored-password migration.
if (
entry.connections.some(
(connection) => connection.type === "directTcp" && connection.password,
)
) {
shouldPersistHosts = true;
}
normalizedProfiles.push(profile);
}
profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId));
@@ -1800,12 +1717,12 @@ export class HostRuntimeStore {
return this.upsertHostConnection({
serverId: input.serverId,
label: input.label,
password,
connection: {
id: `direct:${endpoint}`,
type: "directTcp",
endpoint,
useTls: input.useTls ?? false,
...(password ? { password } : {}),
},
existingClient: input.existingClient,
});
@@ -1813,7 +1730,6 @@ export class HostRuntimeStore {
async probeAndUpsertConnection(input: {
connection: HostConnection;
password?: string;
label?: string;
timeoutMs?: number;
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
@@ -1822,7 +1738,6 @@ export class HostRuntimeStore {
}
const probeHost: HostProfile = {
serverId: "",
...(input.password ? { password: input.password } : {}),
label: input.label ?? input.connection.id,
appearance: defaultHostAppearance(),
lifecycle: {},
@@ -1839,7 +1754,6 @@ export class HostRuntimeStore {
const profile = await this.upsertHostConnection({
serverId,
label: input.label ?? hostname ?? undefined,
password: input.password,
connection: input.connection,
existingClient: client,
});
@@ -1856,12 +1770,12 @@ export class HostRuntimeStore {
const password = input.password?.trim();
return this.probeAndUpsertConnection({
label: input.label,
password,
connection: {
id: `direct:${endpoint}`,
type: "directTcp",
endpoint,
useTls: input.useTls ?? false,
...(password ? { password } : {}),
},
});
}
@@ -1884,7 +1798,6 @@ export class HostRuntimeStore {
useTls?: boolean;
daemonPublicKeyB64: string;
label?: string;
password?: string;
}): Promise<HostProfile> {
const relayEndpoint = normalizeHostPort(input.relayEndpoint);
const useTls = input.useTls ?? false;
@@ -1893,7 +1806,7 @@ export class HostRuntimeStore {
throw new Error("daemonPublicKeyB64 is required");
}
const explicitUseTls = input.useTls !== undefined;
const profile = await this.upsertHostConnection({
return this.upsertHostConnection({
serverId: input.serverId,
label: input.label,
connection: {
@@ -1904,18 +1817,9 @@ export class HostRuntimeStore {
daemonPublicKeyB64,
},
});
if (input.password) {
await this.setHostPassword(profile.serverId, input.password);
return this.hosts.find((host) => host.serverId === profile.serverId) ?? profile;
}
return profile;
}
async upsertConnectionFromOffer(
offer: ConnectionOffer,
label?: string,
password?: string,
): Promise<HostProfile> {
async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise<HostProfile> {
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
return this.upsertRelayConnection({
@@ -1924,67 +1828,25 @@ export class HostRuntimeStore {
useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
label,
password,
});
}
async upsertConnectionFromOfferUrl(
offerUrlOrFragment: string,
label?: string,
password?: string,
): Promise<HostProfile> {
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
return this.upsertConnectionFromOffer(parsed.offer, label, password ?? parsed.password);
}
async probeAndUpsertConnectionFromOfferUrl(
offerUrlOrFragment: string,
password?: string,
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
const offer = parsed.offer;
const credential = password ?? parsed.password;
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
const relayEndpoint = normalizeHostPort(offer.relay.endpoint);
const connection: HostConnection = {
id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
type: "relay",
relayEndpoint,
useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
};
const probeHost: HostProfile = {
serverId: offer.serverId,
...(credential ? { password: credential } : {}),
label: offer.serverId,
appearance: defaultHostAppearance(),
lifecycle: {},
connections: [connection],
preferredConnectionId: connection.id,
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
};
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
await client.close().catch(() => undefined);
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, credential);
return { profile, serverId: offer.serverId, hostname };
}
async importConnectionLink(
url: string,
target: PairingNavigationTarget,
): Promise<{ status: "connected"; serverId: string } | { status: "password_required" }> {
if (target === "openProject") {
const profile = await this.upsertConnectionFromOfferUrl(url);
return { status: "connected", serverId: profile.serverId };
const marker = "#offer=";
const idx = offerUrlOrFragment.indexOf(marker);
if (idx === -1) {
throw new Error("Missing #offer= fragment");
}
try {
const { serverId } = await this.probeAndUpsertConnectionFromOfferUrl(url);
return { status: "connected", serverId };
} catch (error) {
if (!getConnectionAuthFailureReason(error)) throw error;
return { status: "password_required" };
const encoded = offerUrlOrFragment.slice(idx + marker.length).trim();
if (!encoded) {
throw new Error("Offer payload is empty");
}
const payload = decodeOfferFragmentPayload(encoded);
const offer = ConnectionOfferSchema.parse(payload);
return this.upsertConnectionFromOffer(offer, label);
}
async upsertConnectionFromListen(input: {
@@ -2024,13 +1886,6 @@ export class HostRuntimeStore {
await this.updateHost(serverId, (host) => ({ ...host, label }));
}
async setHostPassword(serverId: string, password: string): Promise<void> {
await this.updateHost(serverId, (host) => {
const { password: _previous, ...rest } = host;
return password ? { ...rest, password } : rest;
});
}
async setHostColor(serverId: string, color: HostColor): Promise<void> {
await this.updateHostAppearance(serverId, (host) => ({
...host,
@@ -2108,7 +1963,6 @@ export class HostRuntimeStore {
private async upsertHostConnection(input: {
serverId: string;
label?: string;
password?: string;
connection: HostConnection;
existingClient?: DaemonClient;
}): Promise<HostProfile> {
@@ -2118,7 +1972,6 @@ export class HostRuntimeStore {
serverId: input.serverId,
label: input.label,
connection: input.connection,
password: input.password,
now,
});
this.setHostsAndSync(next, {
@@ -2752,18 +2605,12 @@ export interface HostMutations {
useTls?: boolean;
daemonPublicKeyB64: string;
label?: string;
password?: string;
}) => Promise<HostProfile>;
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>;
upsertConnectionFromOfferUrl: (
offerUrlOrFragment: string,
label?: string,
password?: string,
) => Promise<HostProfile>;
probeAndUpsertConnectionFromOfferUrl: (
offerUrlOrFragment: string,
password?: string,
) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
renameHost: (serverId: string, label: string) => Promise<void>;
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
@@ -2780,10 +2627,7 @@ export function useHostMutations(): HostMutations {
probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input),
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
upsertConnectionFromOfferUrl: (url, label, password) =>
store.upsertConnectionFromOfferUrl(url, label, password),
probeAndUpsertConnectionFromOfferUrl: (url, password) =>
store.probeAndUpsertConnectionFromOfferUrl(url, password),
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
renameHost: (serverId, label) => store.renameHost(serverId, label),
setHostColor: (serverId, color) => store.setHostColor(serverId, color),
setHostBadgeDisplay: (serverId, badgeDisplay) =>
@@ -419,7 +419,7 @@ function ReadFailureCallout({ kind, error, onReload }: ReadFailureCalloutProps)
});
return (
<View style={styles.errorBlock}>
<Alert size="md" testID={testID} variant="error" title={title} description={description}>
<Alert testID={testID} variant="error" title={title} description={description}>
<Button testID={`${testID}-action-0`} onPress={onReload} variant="outline" size="sm">
{t("settings.project.actions.reload")}
</Button>
@@ -702,7 +702,6 @@ function ProjectConfigForm({
>
{hasUncommittedWorktreeSetupChanges ? (
<Alert
size="sm"
variant="warning"
title={t("settings.project.worktree.uncommittedTitle")}
description={t("settings.project.worktree.uncommittedDescription")}
@@ -777,7 +776,6 @@ function ProjectConfigForm({
{isStale ? (
<View style={styles.calloutWrap}>
<Alert
size="sm"
testID="stale-callout"
variant="error"
title={t("settings.project.writeFailures.staleTitle")}
@@ -798,7 +796,6 @@ function ProjectConfigForm({
{isWriteFailed ? (
<View style={styles.calloutWrap}>
<Alert
size="sm"
testID="write-failed-callout"
variant="error"
title={t("settings.project.writeFailures.failedTitle")}
@@ -226,23 +226,12 @@ function HostStatusBadges({ serverId }: { serverId: string }) {
}
function HostConnectionError({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const snapshot = useHostRuntimeSnapshot(serverId);
const lastError = snapshot?.lastError ?? null;
const connectionError =
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
if (!connectionError) return null;
return (
<View style={styles.connectionError}>
<InlineAlert
size="sm"
variant="error"
title={connectionError}
description={snapshot?.authFailureReason ? t("settings.host.password.guidance") : undefined}
testID="host-connection-error"
/>
</View>
);
return <Text style={styles.errorText}>{connectionError}</Text>;
}
export function HostConnectionsPage({ serverId }: { serverId: string }) {
@@ -382,7 +371,6 @@ export function HostSettingsPage({
</View>
<HostStatusBadges serverId={serverId} />
<HostConnectionError serverId={serverId} />
<HostAppearanceSection host={host} />
@@ -847,7 +835,6 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
</View>
{updateState.status === "complete" ? (
<InlineAlert
size="sm"
variant="success"
title={t("desktop.daemon.lifecycle.workerUpdated", {
version: updateState.workerVersion,
@@ -858,7 +845,6 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
{updateState.status === "failed" ? (
<View style={styles.updateFailure}>
<InlineAlert
size="sm"
variant="error"
title={updateState.title}
description={updateState.message}
@@ -1756,8 +1742,10 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
connectionError: {
marginBottom: theme.spacing[6],
errorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.sm,
marginBottom: theme.spacing[2],
},
connectionLatency: {
fontSize: theme.fontSize.base,
@@ -230,7 +230,6 @@ function PluginLogsSheet({
if (logs.isError) {
content = (
<Alert
size="sm"
variant="error"
title={t("settings.plugins.logs.errorTitle")}
description={errorMessage(logs.error)}
@@ -413,7 +412,6 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
if (pageState === "offline") {
return (
<Alert
size="md"
variant="warning"
title={t("settings.plugins.states.offlineTitle")}
description={t("settings.plugins.states.offlineDescription")}
@@ -421,14 +419,13 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
);
}
if (pageState === "unsupported") {
return <Alert size="md" variant="warning" title={t("settings.plugins.states.updateTitle")} />;
return <Alert variant="warning" title={t("settings.plugins.states.updateTitle")} />;
}
let catalogContent = <Alert size="sm" title={t("settings.plugins.states.loading")} />;
let catalogContent = <Alert title={t("settings.plugins.states.loading")} />;
if (pageState === "error") {
catalogContent = (
<Alert
size="sm"
variant="error"
title={t("settings.plugins.states.errorTitle")}
description={errorMessage(plugins.error)}
@@ -472,7 +469,6 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
<View>
<SettingsSection title={t("settings.plugins.title")}>
<Alert
size="sm"
variant="warning"
title={t("settings.plugins.trustedTitle")}
description={t("settings.plugins.trustedDescription")}
@@ -512,15 +508,10 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
</Button>
</View>
) : (
<Alert
size="sm"
variant="warning"
title={t("settings.plugins.states.sourceUpdateTitle")}
/>
<Alert variant="warning" title={t("settings.plugins.states.sourceUpdateTitle")} />
)}
{feedback ? (
<Alert
size="sm"
variant={feedback.kind}
title={feedback.message}
testID="plugin-management-feedback"
+2 -13
View File
@@ -76,15 +76,6 @@ describe("normalizeStoredHostProfile", () => {
expect(profile?.connections[0]).not.toHaveProperty("password");
});
it("moves a stored direct TCP password to the host profile", () => {
const profile = normalizeStoredHostProfile({
serverId: "srv_legacy",
connections: [{ type: "directTcp", endpoint: "localhost:6767", password: "old-secret" }],
});
expect(profile?.password).toBe("old-secret");
expect(profile?.connections[0]).not.toHaveProperty("password");
});
it("preserves legacy relay ids when TLS is absent", () => {
const profile = normalizeStoredHostProfile({
serverId: "srv_relay",
@@ -237,28 +228,26 @@ describe("upsertHostConnectionInProfiles", () => {
type: "directTcp",
endpoint: "example.test:6767",
useTls: false,
password: "old-secret",
};
const existing: HostProfile = {
...makeHost("srv_known"),
password: "old-secret",
connections: [existingConnection],
preferredConnectionId: existingConnection.id,
};
const replacement: HostConnection = {
...existingConnection,
useTls: true,
password: "new-secret",
};
const [profile] = upsertHostConnectionInProfiles({
profiles: [existing],
serverId: "srv_known",
connection: replacement,
password: "new-secret",
});
expect(profile.connections).toEqual([replacement]);
expect("password" in profile.connections[0]!).toBe(false);
expect(profile.password).toBe("new-secret");
expect(profile.preferredConnectionId).toBe(replacement.id);
});
});
+26 -66
View File
@@ -4,7 +4,7 @@ import {
} from "@getpaseo/protocol/daemon-endpoints";
import {
DirectTcpHostConnectionSchema,
type DirectTcpHostConnection as WireDirectTcpHostConnection,
type DirectTcpHostConnection,
} from "@getpaseo/protocol/host-connection-schema";
import {
DEFAULT_SSH_DAEMON_PORT,
@@ -18,8 +18,7 @@ import {
} from "@/hosts/appearance";
import { z } from "zod";
export { DirectTcpHostConnectionSchema };
export type DirectTcpHostConnection = Omit<WireDirectTcpHostConnection, "password">;
export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection };
export interface DirectSocketHostConnection {
id: string;
@@ -60,7 +59,6 @@ export type HostLifecycle = Record<string, never>;
export interface HostProfile {
serverId: string;
password?: string;
label: string;
appearance: HostAppearance;
lifecycle: HostLifecycle;
@@ -127,7 +125,11 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
}
if (left.type === "directTcp" && right.type === "directTcp") {
return left.endpoint === right.endpoint && (left.useTls ?? false) === (right.useTls ?? false);
return (
left.endpoint === right.endpoint &&
(left.useTls ?? false) === (right.useTls ?? false) &&
left.password === right.password
);
}
if (left.type === "directSocket" && right.type === "directSocket") {
return left.path === right.path;
@@ -184,40 +186,11 @@ function upsertHostConnectionById(
return next;
}
function hasProfileChanged(input: {
matchingCount: number;
previous: HostProfile;
serverId: string;
password?: string;
createdAt: string;
label: string;
preferredConnectionId: string;
lifecycle: HostLifecycle;
connections: HostConnection[];
}): boolean {
const { previous, connections } = input;
return (
input.matchingCount > 1 ||
previous.serverId !== input.serverId ||
(input.password !== undefined && input.password !== previous.password) ||
input.createdAt !== previous.createdAt ||
input.label !== previous.label ||
input.preferredConnectionId !== previous.preferredConnectionId ||
!hostLifecycleEquals(previous.lifecycle, input.lifecycle) ||
connections.length !== previous.connections.length ||
connections.some((candidate, index) => {
const old = previous.connections[index];
return !old || !hostConnectionEquals(candidate, old);
})
);
}
export function upsertHostConnectionInProfiles(input: {
profiles: HostProfile[];
serverId: string;
label?: string;
connection: HostConnection;
password?: string;
now?: string;
}): HostProfile[] {
const serverId = input.serverId.trim();
@@ -226,17 +199,13 @@ export function upsertHostConnectionInProfiles(input: {
}
const now = input.now ?? new Date().toISOString();
const password = input.password;
const normalizedConnection = input.connection;
const labelTrimmed = input.label?.trim() ?? "";
const derivedLabel = labelTrimmed || serverId;
const existing = input.profiles;
const matchingIndexes = existing.reduce<number[]>((matches, daemon, index) => {
if (
daemon.serverId === serverId ||
daemon.connections.some((existingConnection) =>
hostConnectionEquals(existingConnection, normalizedConnection),
)
daemon.connections.some((connection) => hostConnectionEquals(connection, input.connection))
) {
matches.push(index);
}
@@ -246,12 +215,11 @@ export function upsertHostConnectionInProfiles(input: {
if (matchingIndexes.length === 0) {
const profile: HostProfile = {
serverId,
...(password ? { password } : {}),
label: derivedLabel,
appearance: defaultHostAppearance(),
lifecycle: defaultLifecycle(),
connections: [normalizedConnection],
preferredConnectionId: normalizedConnection.id,
connections: [input.connection],
preferredConnectionId: input.connection.id,
createdAt: now,
updatedAt: now,
};
@@ -262,7 +230,7 @@ export function upsertHostConnectionInProfiles(input: {
const prev = matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0];
const nextConnections = upsertHostConnectionById(
matchedProfiles.flatMap((daemon) => daemon.connections),
normalizedConnection,
input.connection,
);
const nextLifecycle = prev.lifecycle;
const nextLabel = prev.label === prev.serverId ? derivedLabel : prev.label;
@@ -270,22 +238,23 @@ export function upsertHostConnectionInProfiles(input: {
prev.preferredConnectionId &&
nextConnections.some((connection) => connection.id === prev.preferredConnectionId)
? prev.preferredConnectionId
: normalizedConnection.id;
: input.connection.id;
const nextCreatedAt = matchedProfiles.reduce(
(earliest, daemon) => (daemon.createdAt < earliest ? daemon.createdAt : earliest),
prev.createdAt,
);
const changed = hasProfileChanged({
matchingCount: matchingIndexes.length,
previous: prev,
serverId,
password,
createdAt: nextCreatedAt,
label: nextLabel,
preferredConnectionId: nextPreferredConnectionId,
lifecycle: nextLifecycle,
connections: nextConnections,
});
const changed =
matchingIndexes.length > 1 ||
prev.serverId !== serverId ||
nextCreatedAt !== prev.createdAt ||
nextLabel !== prev.label ||
nextPreferredConnectionId !== prev.preferredConnectionId ||
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
nextConnections.length !== prev.connections.length ||
nextConnections.some((connection, index) => {
const previousConnection = prev.connections[index];
return !previousConnection || !hostConnectionEquals(connection, previousConnection);
});
if (!changed) {
return existing;
@@ -293,7 +262,6 @@ export function upsertHostConnectionInProfiles(input: {
const nextProfile: HostProfile = {
...prev,
...(password ? { password } : {}),
serverId,
label: nextLabel,
lifecycle: nextLifecycle,
@@ -418,7 +386,6 @@ const StoredHostConnectionSchema = z.discriminatedUnion("type", [
]);
const StoredHostProfileSchema = z.strictObject({
serverId: z.string().trim().min(1),
password: z.string().optional(),
label: z.string().optional(),
appearance: HostAppearanceSchema.optional(),
lifecycle: z.strictObject({}).optional(),
@@ -434,13 +401,13 @@ function normalizeStoredConnection(connection: StoredHostConnection): HostConnec
if (connection.type === "directTcp") {
try {
const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(connection.endpoint));
const parsed = DirectTcpHostConnectionSchema.parse({
return DirectTcpHostConnectionSchema.parse({
id: `direct:${endpoint}`,
type: "directTcp",
endpoint,
useTls: connection.useTls,
...(connection.password !== undefined ? { password: connection.password } : {}),
});
return { id: parsed.id, type: parsed.type, endpoint: parsed.endpoint, useTls: parsed.useTls };
} catch {
return null;
}
@@ -492,12 +459,6 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
}
const record = result.data;
const serverId = record.serverId;
// COMPAT(connectionPassword): added in v0.9.1, remove after 2027-03-24 once stored direct passwords have migrated.
const legacyPassword = record.connections.find(
(connection) => connection.type === "directTcp" && connection.password,
);
const password =
record.password ?? (legacyPassword?.type === "directTcp" ? legacyPassword.password : undefined);
const connections = record.connections
.map((connection) => normalizeStoredConnection(connection))
@@ -517,7 +478,6 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
return {
serverId,
...(password ? { password } : {}),
label,
appearance: record.appearance ?? defaultHostAppearance(),
lifecycle: defaultLifecycle(),
@@ -5,8 +5,6 @@ import {
extractHostPortFromWebSocketUrl,
normalizeHostPort,
parseConnectionUri,
parseRelayConnectionUri,
serializeRelayConnectionUri,
parseHostPort,
serializeConnectionUri,
serializeConnectionUriForStorage,
@@ -24,8 +22,6 @@ export {
extractHostPortFromWebSocketUrl,
normalizeHostPort,
parseConnectionUri,
parseRelayConnectionUri,
serializeRelayConnectionUri,
parseHostPort,
serializeConnectionUri,
serializeConnectionUriForStorage,
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
class FakeDaemonClient implements DaemonProbeClient {
@@ -170,15 +169,16 @@ describe("test-daemon-connection connectToDaemon", () => {
});
});
it("passes the host password into the client config", async () => {
it("passes direct TCP connection passwords into the client config", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
},
{ password: "shared-secret" },
undefined,
probe.deps,
);
await result.client.close();
@@ -242,7 +242,7 @@ describe("test-daemon-connection connectToDaemon", () => {
it("surfaces auth rejection as an incorrect password", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
probe.failNextConnection(
new DaemonAuthenticationError("incorrect_password"),
new Error("Transport closed (code 4001)"),
"Transport closed (code 4001)",
);
@@ -252,13 +252,13 @@ describe("test-daemon-connection connectToDaemon", () => {
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "wrong-secret",
},
{ password: "wrong-secret" },
undefined,
probe.deps,
),
).rejects.toMatchObject({
message: "Incorrect password",
authFailureReason: "incorrect_password",
});
});
@@ -272,8 +272,9 @@ describe("test-daemon-connection connectToDaemon", () => {
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
},
{ password: "shared-secret" },
undefined,
probe.deps,
),
).rejects.toMatchObject({
@@ -1,9 +1,4 @@
import {
DaemonAuthenticationError,
DaemonClient,
getDaemonAuthFailureReason,
type DaemonAuthFailureReason,
} from "@getpaseo/client/internal/daemon-client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
import type { HostConnection } from "@/types/host-connection";
import { getOrCreateClientId } from "./client-id";
@@ -21,7 +16,6 @@ import type { DesktopDaemonTransportTarget } from "@/desktop/daemon/desktop-daem
export interface DaemonProbeClient {
readonly lastError: string | null;
readonly authFailureReason?: DaemonAuthFailureReason | null;
connect(): Promise<void>;
close(): Promise<void>;
getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null;
@@ -90,53 +84,41 @@ function pickBestReason(reason: string | null, lastError: string | null): string
return "Unable to connect";
}
function isIncorrectPasswordFailure(input: {
config: DaemonClientConfig;
reason: string | null;
lastError: string | null;
}): boolean {
if (!input.config.password) {
return false;
}
const details = [input.reason, input.lastError].filter(Boolean).join("\n").toLowerCase();
return (
details.includes("401") ||
details.includes("4001") ||
details.includes("unauthorized") ||
details.includes("code 1006")
);
}
export class DaemonConnectionTestError extends Error {
reason: string | null;
lastError: string | null;
authFailureReason: DaemonAuthFailureReason | null;
constructor(
message: string,
details: {
reason: string | null;
lastError: string | null;
authFailureReason?: DaemonAuthFailureReason | null;
},
) {
constructor(message: string, details: { reason: string | null; lastError: string | null }) {
super(message);
this.name = "DaemonConnectionTestError";
this.reason = details.reason;
this.lastError = details.lastError;
this.authFailureReason = details.authFailureReason ?? null;
}
}
export function getConnectionAuthFailureReason(error: unknown): DaemonAuthFailureReason | null {
if (error instanceof DaemonConnectionTestError) return error.authFailureReason;
return getDaemonAuthFailureReason(error);
}
function resolveConnectionCredentials(
connection: HostConnection,
options:
| { password?: string; localCredential?: DaemonClientConfig["localCredential"] }
| undefined,
): Pick<DaemonClientConfig, "password" | "localCredential"> {
const password = options?.password;
return {
...(password ? { password } : {}),
...(options?.localCredential ? { localCredential: options.localCredential } : {}),
};
}
export async function buildClientConfig(
connection: HostConnection,
serverId?: string,
options?: {
capabilities?: DaemonClientConfig["capabilities"];
trace?: DaemonClientConfig["trace"];
password?: string;
localCredential?: DaemonClientConfig["localCredential"];
},
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
@@ -154,7 +136,6 @@ export async function buildClientConfig(
appVersion: deps.resolveAppVersion() ?? undefined,
suppressSendErrors: true,
reconnect: { enabled: false },
...resolveConnectionCredentials(connection, options),
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
...(options?.trace ? { trace: options.trace } : {}),
...((connection.type === "directSocket" || connection.type === "directPipe") &&
@@ -186,6 +167,7 @@ export async function buildClientConfig(
return {
...base,
url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }),
...(connection.password ? { password: connection.password } : {}),
};
}
@@ -263,13 +245,11 @@ export function connectAndProbe(
error instanceof Error ? error.message : String(error),
);
const lastError = normalizeNonEmptyString(client.lastError);
const authFailureReason =
getDaemonAuthFailureReason(error) ?? client.authFailureReason ?? null;
const message = authFailureReason
? new DaemonAuthenticationError(authFailureReason).message
const message = isIncorrectPasswordFailure({ config, reason, lastError })
? "Incorrect password"
: pickBestReason(reason, lastError);
void client.close().catch(() => undefined);
reject(new DaemonConnectionTestError(message, { reason, lastError, authFailureReason }));
reject(new DaemonConnectionTestError(message, { reason, lastError }));
});
},
);
@@ -278,8 +258,6 @@ export function connectAndProbe(
interface ProbeOptions {
serverId?: string;
timeoutMs?: number;
password?: string;
localCredential?: DaemonClientConfig["localCredential"];
capabilities?: DaemonClientConfig["capabilities"];
trace?: DaemonClientConfig["trace"];
}
@@ -136,8 +136,8 @@ test("managed two-home restart retains its supervisor and never routes ordinary
const beforeA = await f.liveStatus(a);
const beforeB = await f.liveStatus(b, poisoned);
if (process.platform !== "win32") {
for (const home of [a, b]) expect((await stat(home)).mode & 0o777).toBe(0o700);
expect(existsSync(path.join(f.root, ".paseo"))).toBe(false);
for (const home of [a, b, path.join(f.root, ".paseo")])
expect((await stat(home)).mode & 0o777).toBe(0o700);
}
const repoB = path.join(f.root, "project-b");
@@ -197,7 +197,7 @@ test("removed flags and ambiguous targets fail before side effects; observation
}
}, 30_000);
test("local status uses the credential and reports the server id of a password-protected daemon", async () => {
test("status reports the server id of a password-protected daemon to a caller without the password", async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
@@ -209,7 +209,7 @@ test("local status uses the credential and reports the server id of a password-p
await f.ok(["start", "--home", home, "--timeout", "30"]);
const authenticated = await f.liveStatus(home, { PASEO_PASSWORD: "secret" });
expect(await f.ok(["daemon", "status", "--home", home])).toMatchObject({
connectedDaemon: "reachable",
connectedDaemon: "auth_required",
serverId: authenticated.serverId,
});
} finally {
+1 -7
View File
@@ -12,8 +12,6 @@ import { connectToDaemon } from "../../utils/client.js";
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { addJsonAndDaemonHostOptions, withGlobalOptions } from "../../utils/command-options.js";
import { formatPairingInstructions } from "../../output/pairing.js";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import { serializeRelayConnectionUri } from "@getpaseo/protocol/daemon-endpoints";
interface PairOptions {
daemonTarget: DaemonTarget;
@@ -197,13 +195,10 @@ function outputPairingResult(
return;
}
const offer = parseConnectionOfferFromUrl(pairing.url);
const connectionUri = offer ? serializeRelayConnectionUri({ offer }) : null;
if (options.json) {
output.writeStdout(
`${JSON.stringify(
{ relayEnabled: pairing.relayEnabled, url: pairing.url, qr: pairing.qr, connectionUri },
{ relayEnabled: pairing.relayEnabled, url: pairing.url, qr: pairing.qr },
null,
2,
)}\n`,
@@ -215,7 +210,6 @@ function outputPairingResult(
formatPairingInstructions({
url: pairing.url,
qr: pairing.qr,
connectionUri,
columns: output.columns,
}),
);
-13
View File
@@ -30,17 +30,4 @@ describe("formatPairingInstructions", () => {
expect(output).toContain("terminal width could not be detected");
expect(output.split("\n")).toContain(URL);
});
it("redacts a password in a printed connection URI", () => {
const output = formatPairingInstructions({
qr: null,
url: URL,
connectionUri: "relay://relay.paseo.sh:443/srv_test?key=public&password=private-value",
});
expect(output).toContain(
"relay://relay.paseo.sh:443/srv_test?key=public&password=%5Bredacted%5D",
);
expect(output).not.toContain("private-value");
});
});
+2 -20
View File
@@ -3,7 +3,6 @@ const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
interface PairingInstructions {
url: string;
qr: string | null;
connectionUri?: string | null;
columns?: number;
}
@@ -33,23 +32,6 @@ function formatQr(qr: string | null, columns: number | undefined): string {
return qr;
}
function redactConnectionUri(uri: string): string {
try {
const parsed = new URL(uri);
if (parsed.searchParams.has("password")) {
parsed.searchParams.set("password", "[redacted]");
}
return parsed.toString();
} catch {
return "Invalid connection URI";
}
}
export function formatPairingInstructions({
url,
qr,
columns,
connectionUri,
}: PairingInstructions): string {
return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}${connectionUri ? `\n\nConnection URI:\n${redactConnectionUri(connectionUri)}` : ""}\n\nTreat this pairing link like a password. Anyone with it can access this daemon.\n`;
export function formatPairingInstructions({ url, qr, columns }: PairingInstructions): string {
return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n\nTreat this pairing link like a password. Anyone with it can access this daemon.\n`;
}
+5 -29
View File
@@ -2,7 +2,6 @@ import {
waitForDaemonReady,
resolvePaseoHome,
type DaemonInstance,
readLocalCredentialForTarget,
} from "@getpaseo/server/daemon-control";
import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js";
export type { DaemonTarget } from "./daemon-target.js";
@@ -29,12 +28,6 @@ export interface ConnectOptions {
timeout?: number;
instance?: DaemonInstance;
}
export function resolveClientPaseoHome(
target: DaemonTarget,
env: Readonly<Record<string, string | undefined>> = process.env,
): string {
return target.kind === "instance" ? target.home : resolvePaseoHome(env);
}
const DEFAULT_TIMEOUT = 15000;
type TransportTarget =
| { type: "tcp"; url: string }
@@ -179,16 +172,6 @@ export function resolveDaemonPassword(host: string): string | undefined {
return fromEnv && fromEnv.length > 0 ? fromEnv : undefined;
}
export function resolveDaemonCredential(
host: string,
home: string,
): { kind: "password"; password: string } | { kind: "localCredential"; token: string } | null {
const password = resolveDaemonPassword(host);
if (password) return { kind: "password", password };
const token = readLocalCredentialForTarget(home, host);
return token ? { kind: "localCredential", token } : null;
}
/**
* Create a WebSocket factory that works in Node.js
*/
@@ -210,8 +193,7 @@ function createNodeWebSocketFactory() {
*/
async function tryConnectHost(
host: string,
credential: ReturnType<typeof resolveDaemonCredential>,
home: string,
password: string | undefined,
clientId: string,
timeout: number,
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
@@ -222,10 +204,7 @@ async function tryConnectHost(
clientId,
clientType: "cli",
appVersion: resolveCliVersion(),
...(credential?.kind === "password" ? { password: credential.password } : {}),
...(credential?.kind === "localCredential"
? { localCredential: () => readLocalCredentialForTarget(home, host) ?? undefined }
: {}),
password,
connectTimeoutMs: timeout,
webSocketFactory: (
url: string,
@@ -308,8 +287,7 @@ async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonCli
instance: options.instance,
})
).listen;
const home = resolveClientPaseoHome(options.target);
const clientId = await getOrCreateCliClientId(home);
const clientId = await getOrCreateCliClientId(resolvePaseoHome({}));
const nodeWebSocketFactory = createNodeWebSocketFactory();
if (explicitHost?.trim().startsWith("ssh://")) {
@@ -318,8 +296,7 @@ async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonCli
const password = resolveDaemonPassword(explicitHost);
const result = await tryConnectHost(
tunnel.endpoint,
password ? { kind: "password", password } : null,
home,
password,
clientId,
Math.max(1, deadline - Date.now()),
nodeWebSocketFactory,
@@ -353,8 +330,7 @@ async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonCli
const result = await tryConnectHost(
explicitHost,
resolveDaemonCredential(explicitHost, home),
home,
resolveDaemonPassword(explicitHost),
clientId,
Math.max(1, deadline - Date.now()),
nodeWebSocketFactory,
@@ -1,9 +1,5 @@
import { test, expect } from "vitest";
import { selectDaemonTarget, describeDaemonTarget } from "./daemon-target.js";
import { resolveDaemonCredential, resolveClientPaseoHome } from "./client.js";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
test("explicit selectors win over both environment selectors", () => {
const env = { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" };
@@ -16,15 +12,6 @@ test("explicit selectors win over both environment selectors", () => {
expect(() => selectDaemonTarget({ home: "/tmp/b", host: "chosen:23456" }, {})).toThrow();
});
test("endpoint connections resolve local credentials from PASEO_HOME", () => {
expect(
resolveClientPaseoHome(
{ kind: "endpoint", host: "localhost:6767" },
{ PASEO_HOME: "/tmp/custom-home" },
),
).toBe("/tmp/custom-home");
});
test("local operations ignore routing environment but reject an explicit endpoint", () => {
expect(
selectDaemonTarget({}, { PASEO_HOME: "/tmp/b", PASEO_HOST: "unused:12345" }, true),
@@ -43,27 +30,3 @@ test("endpoint descriptions redact pairing material and credentials", () => {
describeDaemonTarget({ kind: "endpoint", host: "https://app.paseo.sh/#offer=private" }),
).not.toContain("private");
});
test("CLI selects an explicit password before a matching local credential and never sends the latter remotely", async () => {
const home = await mkdtemp(join(tmpdir(), "paseo-cli-credential-"));
const previousPassword = process.env.PASEO_PASSWORD;
delete process.env.PASEO_PASSWORD;
try {
const token = "a".repeat(43);
await writeFile(join(home, "paseo.pid"), JSON.stringify({ listen: "127.0.0.1:6767" }));
await writeFile(join(home, "local-credential"), token);
expect(resolveDaemonCredential("tcp://localhost:6767", home)).toEqual({
kind: "localCredential",
token,
});
expect(resolveDaemonCredential("tcp://remote.example:6767", home)).toBeNull();
expect(resolveDaemonCredential("tcp://localhost:6767?password=explicit", home)).toEqual({
kind: "password",
password: "explicit",
});
} finally {
if (previousPassword === undefined) delete process.env.PASEO_PASSWORD;
else process.env.PASEO_PASSWORD = previousPassword;
await rm(home, { recursive: true, force: true });
}
});
@@ -5,7 +5,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Command } from "commander";
import { isBearerTokenValidAsync } from "@getpaseo/server/auth";
import { isBearerTokenValid } from "@getpaseo/server/auth";
import { runLocalPaseo } from "./helpers/local-cli.ts";
import {
runSetPasswordCommand,
@@ -61,10 +61,7 @@ try {
assert.notStrictEqual(config.daemon.auth.password, "shared-secret");
assert.match(config.daemon.auth.password, /^\$2[aby]\$12\$/);
assert.strictEqual(
await isBearerTokenValidAsync({
password: config.daemon.auth.password,
token: "shared-secret",
}),
isBearerTokenValid({ password: config.daemon.auth.password, token: "shared-secret" }),
true,
);
console.log("✓ set-password writes bcrypt hash without clobbering config\n");
@@ -84,7 +81,7 @@ try {
assert.strictEqual(result.data.action, "password_set");
assert.strictEqual(
await isBearerTokenValidAsync({ password: config.daemon.auth.password, token: "new-secret" }),
isBearerTokenValid({ password: config.daemon.auth.password, token: "new-secret" }),
true,
);
console.log("✓ command accepts matching confirmation\n");
@@ -15,7 +15,7 @@ const daemon = await startTestDaemon({
try {
{
console.log("Test 1: local status uses the daemon's local credential");
console.log("Test 1: status reports password requirement without marking daemon unreachable");
const result = await runLocalPaseo(["daemon", "status", "--json"], {
PASEO_HOME: daemon.paseoHome,
PASEO_HOST: "",
@@ -26,11 +26,12 @@ try {
const status = JSON.parse(result.stdout);
assert.strictEqual(status.localDaemon, "running");
assert.strictEqual(status.connectedDaemon, "reachable");
assert.strictEqual(status.connectedDaemon, "auth_required");
assert(!("runningAgents" in status), "status should not fetch agent counts");
assert(!("idleAgents" in status), "status should not fetch agent counts");
assert.doesNotMatch(status.note ?? "", /Password required|not reachable/i);
console.log("✓ missing password uses local credential\n");
assert.match(status.note, /Password required/i);
assert.doesNotMatch(status.note, /not reachable/i);
console.log("✓ missing password reports auth_required\n");
}
{
@@ -10,10 +10,10 @@ const daemon = await startTestDaemon({
env: { PASEO_PASSWORD: "shared-secret" },
});
async function lsError(password: string, remoteClient = false) {
async function lsError(password: string) {
const result = await runLocalPaseo(["ls", "--json"], {
PASEO_HOME: remoteClient ? "" : daemon.paseoHome,
PASEO_HOST: remoteClient ? `127.0.0.1:${daemon.port}` : "",
PASEO_HOME: daemon.paseoHome,
PASEO_HOST: "",
PASEO_PASSWORD: password,
});
assert.notStrictEqual(result.exitCode, 0, "ls should fail without a valid password");
@@ -26,13 +26,13 @@ async function lsError(password: string, remoteClient = false) {
try {
{
console.log("Test 1: a client outside the daemon home needs PASEO_PASSWORD");
const error = await lsError("", true);
console.log("Test 1: missing password asks for PASEO_PASSWORD, not a daemon start");
const error = await lsError("");
assert.strictEqual(error.code, "AUTH_REQUIRED");
assert.match(error.message, /Password required/);
assert.match(error.details, /PASEO_PASSWORD/);
assert.doesNotMatch(error.details, /daemon start/);
console.log("✓ client without the local credential is asked for PASEO_PASSWORD\n");
console.log("✓ missing password points at PASEO_PASSWORD\n");
}
{
-1
View File
@@ -125,7 +125,6 @@ export class ConnectionSubscriptions extends OwnedSubscriptions {
// Protocol support belongs to the installed client. Only browser hosting needs
// a resource supplied by the caller. Keep this exhaustive as the protocol evolves.
export const DEFAULT_CLIENT_CAPABILITIES = {
[CLIENT_CAPS.helloRejection]: true,
[CLIENT_CAPS.ownedSubscriptions]: true,
[CLIENT_CAPS.allProviders]: true,
[CLIENT_CAPS.selectiveAgentTimeline]: true,
@@ -20,19 +20,12 @@ export function createRelayE2eeTransportFactory(args: {
daemonPublicKeyB64: string;
logger: TransportLogger;
}): DaemonTransportFactory {
return ({ url }) => {
const base = args.baseFactory({ url });
return ({ url, headers }) => {
const base = args.baseFactory({ url, headers });
return createEncryptedTransport(base, args.daemonPublicKeyB64, args.logger);
};
}
export function createRelayTransportFactory(
baseFactory: DaemonTransportFactory,
): DaemonTransportFactory {
// The relay upgrade precedes E2EE. Only the encrypted hello may carry daemon credentials.
return ({ url }) => baseFactory({ url });
}
export function createEncryptedTransport(
base: DaemonTransport,
daemonPublicKeyB64: string,
@@ -17,7 +17,6 @@ export {
export {
createEncryptedTransport,
createRelayE2eeTransportFactory,
createRelayTransportFactory,
} from "./daemon-client-relay-e2ee-transport.js";
export {
bindWsHandler,
-137
View File
@@ -854,142 +854,6 @@ test("passes password as HTTP bearer header and WebSocket subprotocol", async ()
});
});
test("keeps relay upgrade credentials out of the socket request", async () => {
const mock = createMockTransport();
const requests: Array<{ url: string; headers?: Record<string, string>; protocols?: string[] }> =
[];
const client = new DaemonClient({
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
clientId: "clsk_relay_auth_test",
password: "shared-secret",
authHeader: "Bearer shared-secret",
e2ee: { enabled: true, daemonPublicKeyB64: "daemon-public-key" },
reconnect: { enabled: false },
transportFactory: (request) => {
requests.push(request);
return mock.transport;
},
});
clients.push(client);
void client.connect();
await vi.waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]).toEqual({ url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2" });
});
test("refuses relay password auth without an encrypted hello", async () => {
const requests: unknown[] = [];
const client = new DaemonClient({
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
clientId: "clsk_unencrypted_relay_test",
password: "shared-secret",
connectTimeoutMs: 50,
reconnect: { enabled: false },
transportFactory: (request) => {
requests.push(request);
return createMockTransport().transport;
},
});
clients.push(client);
await expect(client.connect()).rejects.toThrow("Relay credentials require E2EE");
expect(requests).toEqual([]);
});
test("stops reconnecting after a password rejection on an established connection", async () => {
const socket = createMockTransport();
let attempts = 0;
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_reconnect_auth_test",
password: "old-secret",
reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 },
transportFactory: () => {
attempts += 1;
return socket.transport;
},
});
clients.push(client);
const connected = client.connect();
socket.triggerOpen();
await connected;
socket.triggerMessage(JSON.stringify({ type: "hello.rejected", reason: "incorrect_password" }));
socket.triggerClose({ code: 4003, reason: "Incorrect password" });
await new Promise((resolve) => setTimeout(resolve, 30));
expect(client.authFailureReason).toBe("incorrect_password");
expect(attempts).toBe(1);
});
test("sends a password containing spaces in hello without an invalid WebSocket subprotocol", async () => {
const mock = createMockTransport();
const transportFactory = vi.fn(() => mock.transport);
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
password: "two words",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory,
});
clients.push(client);
const connected = client.connect();
mock.triggerOpen({ preserveSent: true });
await connected;
expect(transportFactory).toHaveBeenCalledWith({
url: "ws://test",
headers: {},
});
expect(JSON.parse(assertStr(mock.sent[0]))).toMatchObject({
type: "hello",
auth: { kind: "password", password: "two words" },
});
});
test("uses a local credential over a saved password when the desktop bridge provides one", async () => {
const mock = createMockTransport();
const transportFactory = vi.fn(() => mock.transport);
const client = new DaemonClient({
url: "ws://test",
clientId: "desktop-managed",
password: "stale-password",
localCredential: async () => "current-local-token",
reconnect: { enabled: false },
transportFactory,
});
clients.push(client);
const connected = client.connect();
await vi.waitFor(() => expect(transportFactory).toHaveBeenCalled());
mock.triggerOpen({ preserveSent: true });
await connected;
expect(transportFactory).toHaveBeenCalledWith({ url: "ws://test", headers: {} });
expect(JSON.parse(assertStr(mock.sent[0]))).toMatchObject({
type: "hello",
auth: { kind: "localCredential", token: "current-local-token" },
});
});
test("uses the saved host password when the desktop bridge has no credential for the target", async () => {
const mock = createMockTransport();
const transportFactory = vi.fn(() => mock.transport);
const localCredential = vi.fn(async () => undefined);
const client = new DaemonClient({
url: "ws://remote-host",
clientId: "remote-saved-host",
password: "saved-password",
localCredential,
reconnect: { enabled: false },
transportFactory,
});
clients.push(client);
const connected = client.connect();
await vi.waitFor(() => expect(transportFactory).toHaveBeenCalled());
mock.triggerOpen({ preserveSent: true });
await connected;
expect(localCredential).toHaveBeenCalledOnce();
expect(JSON.parse(assertStr(mock.sent[0]))).toMatchObject({
type: "hello",
auth: { kind: "password", password: "saved-password" },
});
});
test("advertises client capabilities in hello", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -1036,7 +900,6 @@ test("advertises client capabilities in hello", async () => {
timeline_notifications: true,
plugin_timeline_items: true,
workspace_setup_blocked: true,
hello_rejection: true,
browser_host: {
supportedCommands: ["list_tabs"],
hostKind: "desktop app",
+21 -121
View File
@@ -151,7 +151,6 @@ import {
} from "@getpaseo/protocol/binary-frames/index";
import {
createRelayE2eeTransportFactory,
createRelayTransportFactory,
createWebSocketTransportFactory,
decodeMessageData,
defaultWebSocketFactory,
@@ -217,68 +216,6 @@ function normalizePassword(value: string | undefined): string | null {
return value.length > 0 ? value : null;
}
function compatibleBearerPassword(password: string | null): string | null {
return password && /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(password) ? password : null;
}
type HelloAuth =
| { kind: "password"; password: string }
| { kind: "localCredential"; token: string }
| undefined;
export type DaemonAuthFailureReason = "password_required" | "incorrect_password";
export class DaemonAuthenticationError extends Error {
readonly reason: DaemonAuthFailureReason;
constructor(reason: DaemonAuthFailureReason) {
super(reason === "password_required" ? "Password required" : "Incorrect password");
this.name = "DaemonAuthenticationError";
this.reason = reason;
}
}
export function getDaemonAuthFailureReason(error: unknown): DaemonAuthFailureReason | null {
return error instanceof DaemonAuthenticationError ? error.reason : null;
}
function authFailureFromLegacyClose(event: unknown): DaemonAuthFailureReason | null {
if (!event || typeof event !== "object" || !("reason" in event)) return null;
if (event.reason === "Password required") return "password_required";
if (event.reason === "Incorrect password") return "incorrect_password";
return null;
}
function chooseConnectionAuth(
config: DaemonClientConfig,
localCredential: string | undefined,
): { helloAuth: HelloAuth; headers: Record<string, string>; protocols?: string[] } {
const password = normalizePassword(config.password);
let helloAuth: HelloAuth;
if (localCredential) helloAuth = { kind: "localCredential", token: localCredential };
else if (password) helloAuth = { kind: "password", password };
const headers: Record<string, string> = {};
const compatibleBearer = localCredential ? null : compatibleBearerPassword(password);
// COMPAT(headerAuth): added in v0.9.1, remove after 2027-03-24.
if (compatibleBearer) headers.Authorization = `Bearer ${compatibleBearer}`;
else if (!localCredential && config.authHeader) headers.Authorization = config.authHeader;
return {
helloAuth,
headers,
...(compatibleBearer ? { protocols: [`paseo.bearer.${compatibleBearer}`] } : {}),
};
}
function resolveConnectionAuth(
config: DaemonClientConfig,
): ReturnType<typeof chooseConnectionAuth> | Promise<ReturnType<typeof chooseConnectionAuth>> {
const resolution = config.localCredential?.();
if (resolution instanceof Promise) {
return resolution.then((credential) => chooseConnectionAuth(config, credential));
}
return chooseConnectionAuth(config, resolution);
}
function extractCorrelatedResponseIdentity(input: unknown): CorrelatedResponseIdentity | null {
if (!input || typeof input !== "object") {
return null;
@@ -393,7 +330,6 @@ export interface DaemonClientConfig {
appVersion?: string;
runtimeGeneration?: number | null;
password?: string;
localCredential?: () => string | undefined | Promise<string | undefined>;
authHeader?: string;
suppressSendErrors?: boolean;
transportFactory?: DaemonTransportFactory;
@@ -1211,7 +1147,6 @@ export class DaemonClient {
failed: (error) => this.logger.error({ err: error }, "Subscription failed"),
});
private transport: DaemonTransport | null = null;
private helloAuth: HelloAuth;
private transportCleanup: Array<() => void> = [];
private rawMessageListeners: Set<(message: SessionOutboundMessage) => void> = new Set();
private messageHandlers: Map<
@@ -1231,7 +1166,6 @@ export class DaemonClient {
private connectResolve: (() => void) | null = null;
private connectReject: ((error: Error) => void) | null = null;
private lastErrorValue: string | null = null;
private authFailureReasonValue: DaemonAuthFailureReason | null = null;
private connectionState: ConnectionState = { status: "idle" };
private readonly terminalStreams = new TerminalStreamRouter();
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
@@ -1323,7 +1257,7 @@ export class DaemonClient {
return this.connectPromise;
}
private async attemptConnect(): Promise<void> {
private attemptConnect(): void {
if (this.connectionState.status === "disposed") {
this.rejectConnect(new Error("Daemon client is disposed"));
return;
@@ -1343,31 +1277,33 @@ export class DaemonClient {
this.reconnectTimeout = null;
}
const headers: Record<string, string> = {};
const password = normalizePassword(this.config.password);
if (password) {
headers.Authorization = `Bearer ${password}`;
} else if (this.config.authHeader) {
headers.Authorization = this.config.authHeader;
}
const protocols = password ? [`paseo.bearer.${password}`] : undefined;
try {
const resolution = resolveConnectionAuth(this.config);
const selected = resolution instanceof Promise ? await resolution : resolution;
if (!this.shouldReconnect) return;
this.helloAuth = selected.helloAuth;
// Reconnect can overlap with browser close/error delivery ordering.
// Always dispose previous transport before constructing the next one.
this.disposeTransport();
const baseTransportFactory =
this.config.transportFactory ??
createWebSocketTransportFactory(this.config.webSocketFactory ?? defaultWebSocketFactory);
const isRelayTransport = isRelayClientWebSocketUrl(this.config.url);
const shouldUseRelayE2ee = this.config.e2ee?.enabled === true && isRelayTransport;
this.assertEncryptedRelayAuth(selected.helloAuth, isRelayTransport);
const shouldUseRelayE2ee =
this.config.e2ee?.enabled === true && isRelayClientWebSocketUrl(this.config.url);
let transportFactory = isRelayTransport
? createRelayTransportFactory(baseTransportFactory)
: baseTransportFactory;
let transportFactory = baseTransportFactory;
if (shouldUseRelayE2ee) {
const daemonPublicKeyB64 = this.config.e2ee?.daemonPublicKeyB64;
if (!daemonPublicKeyB64) {
throw new Error("daemonPublicKeyB64 is required for relay E2EE");
}
transportFactory = createRelayE2eeTransportFactory({
baseFactory: transportFactory,
baseFactory: baseTransportFactory,
daemonPublicKeyB64,
logger: this.logger,
});
@@ -1375,12 +1311,11 @@ export class DaemonClient {
const transportUrl = this.resolveTransportUrlForAttempt();
const transport = transportFactory({
url: transportUrl,
headers: selected.headers,
...(selected.protocols ? { protocols: selected.protocols } : {}),
headers,
...(protocols ? { protocols } : {}),
});
this.transport = transport;
this.lastServerInfoMessage = null;
this.authFailureReasonValue = null;
this.updateConnectionState(
{
@@ -1411,7 +1346,7 @@ export class DaemonClient {
this.pendingGenericTransportErrorTimeout = null;
}
this.lastErrorValue = null;
void this.sendHelloMessage();
this.sendHelloMessage();
}),
transport.onClose((event) => {
this.resetConnectTimeout();
@@ -1419,10 +1354,7 @@ export class DaemonClient {
clearTimeout(this.pendingGenericTransportErrorTimeout);
this.pendingGenericTransportErrorTimeout = null;
}
this.authFailureReasonValue ??= authFailureFromLegacyClose(event);
const reason = this.authFailureReasonValue
? new DaemonAuthenticationError(this.authFailureReasonValue).message
: describeTransportClose(event);
const reason = describeTransportClose(event);
if (reason) {
this.lastErrorValue = reason;
}
@@ -1488,12 +1420,6 @@ export class DaemonClient {
}
}
private assertEncryptedRelayAuth(helloAuth: HelloAuth, isRelayTransport: boolean): void {
if (!isRelayTransport || !helloAuth || this.config.e2ee?.enabled === true) return;
this.setReconnectEnabled(false);
throw new Error("Relay credentials require E2EE");
}
private resolveConnect(): void {
if (this.connectResolve) {
this.connectResolve();
@@ -1611,10 +1537,6 @@ export class DaemonClient {
return this.lastErrorValue;
}
get authFailureReason(): DaemonAuthFailureReason | null {
return this.authFailureReasonValue;
}
getLastLivenessRttMs(): number | null {
return this.lastLivenessRttMs;
}
@@ -6071,9 +5993,8 @@ export class DaemonClient {
return this.config.url;
}
private async sendHelloMessage(): Promise<void> {
const transport = this.transport;
if (!transport) {
private sendHelloMessage(): void {
if (!this.transport) {
this.scheduleReconnect({
reason: "Transport unavailable before hello",
event: "HELLO_TRANSPORT_MISSING",
@@ -6083,14 +6004,11 @@ export class DaemonClient {
}
try {
if (this.transport !== transport) return;
const auth = this.helloAuth;
this.sendJsonMessage("hello", "hello", {
type: "hello",
clientId: this.config.clientId,
clientType: this.config.clientType ?? "cli",
protocolVersion: 1,
...(auth ? { auth } : {}),
capabilities: {
...DEFAULT_CLIENT_CAPABILITIES,
...this.config.capabilities,
@@ -6236,18 +6154,6 @@ export class DaemonClient {
return;
}
if (parsed.data.type === "hello.rejected") {
const reasonMessage = {
password_required: "Password required",
incorrect_password: "Incorrect password",
incompatible_protocol: "Incompatible protocol version",
};
this.lastErrorValue = reasonMessage[parsed.data.reason];
this.authFailureReasonValue =
parsed.data.reason === "incompatible_protocol" ? null : parsed.data.reason;
return;
}
this.traceInstant("paseo.ws.message.inbound", {
envelopeType: "session",
messageType: parsed.data.message.type,
@@ -6446,19 +6352,13 @@ export class DaemonClient {
this.terminalStreams.clearSlots();
this.lastServerInfoMessage = null;
if (this.authFailureReasonValue) this.setReconnectEnabled(false);
if (wasDisposed) {
this.rejectConnect(new Error(reason ?? "Daemon client is disposed"));
return;
}
this.emitDisconnectedStateForReconnect(reason, input);
if (!this.shouldReconnect || this.config.reconnect?.enabled === false) {
this.rejectConnect(
this.authFailureReasonValue
? new DaemonAuthenticationError(this.authFailureReasonValue)
: new Error(reason ?? "Transport disconnected before connect"),
);
this.rejectConnect(new Error(reason ?? "Transport disconnected before connect"));
return;
}
@@ -1,5 +1,5 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { hostname, tmpdir } from "node:os";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -51,9 +51,8 @@ vi.mock("electron-log/main", () => ({
},
}));
vi.mock("@getpaseo/server/daemon-control", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
resolvePaseoHome: () => mocks.paseoHome,
vi.mock("@getpaseo/server/daemon-control", () => ({
resolvePaseoHome: vi.fn(() => mocks.paseoHome),
spawnProcess: mocks.spawnProcess,
}));
@@ -124,25 +123,4 @@ describe("daemon-manager commands", () => {
currentVersion: "1.2.3",
});
});
it("returns a local credential only for its live managed daemon listen", async () => {
mkdirSync(mocks.paseoHome);
const token = "a".repeat(43);
writeFileSync(path.join(mocks.paseoHome, "local-credential"), `${token}\n`, { mode: 0o600 });
const lock = {
pid: process.pid,
startedAt: new Date().toISOString(),
hostname: hostname(),
uid: process.getuid?.() ?? 0,
listen: "127.0.0.1:6799",
desktopManaged: true,
};
const lockPath = path.join(mocks.paseoHome, "paseo.pid");
writeFileSync(lockPath, JSON.stringify(lock));
const handler = createDaemonCommandHandlers().desktop_local_credential;
expect(await handler({ listen: "localhost:6799" })).toBe(token);
expect(await handler({ listen: "remote:6799" })).toBeNull();
writeFileSync(lockPath, JSON.stringify({ ...lock, desktopManaged: false }));
expect(await handler({ listen: "localhost:6799" })).toBeNull();
});
});
@@ -9,7 +9,6 @@ import {
stopDaemonInstance,
readDaemonInstance,
isSameDaemonInstance,
readLocalCredentialForTarget,
type DaemonInstance,
} from "@getpaseo/server/daemon-control";
import {
@@ -400,11 +399,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
runningUnderARM64Translation: isRunningUnderARM64Translation(),
}),
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
desktop_local_credential: async (args) => {
const instance = await readDaemonInstance(getPaseoHome());
if (!instance?.desktopManaged || typeof args?.listen !== "string") return null;
return readLocalCredentialForTarget(getPaseoHome(), args.listen);
},
start_desktop_daemon: () => startDaemon(),
stop_desktop_daemon: (args) =>
stopDesktopDaemon(
-10
View File
@@ -466,22 +466,12 @@ export interface AgentPermissionRequest {
metadata?: AgentMetadata;
}
/**
* The answer to one question of a `question` permission request, aligned by index with
* `input.questions`. `selected` holds the chosen option labels; `text` holds a typed answer.
*/
export interface AgentQuestionAnswer {
selected: string[];
text?: string;
}
export type AgentPermissionResponse =
| {
behavior: "allow";
selectedActionId?: string;
updatedInput?: AgentMetadata;
updatedPermissions?: AgentPermissionUpdate[];
questionAnswers?: AgentQuestionAnswer[];
}
| {
behavior: "deny";
@@ -1,5 +1,4 @@
export const CLIENT_CAPS = {
helloRejection: "hello_rejection",
// COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy ownership after 2027-03-09.
ownedSubscriptions: "owned_subscriptions",
// Clients outside the Paseo app version series can explicitly accept provider IDs.
@@ -7,8 +7,6 @@ import {
extractHostPortFromWebSocketUrl,
normalizeRelayProtocolVersion,
parseConnectionUri,
parseRelayConnectionUri,
serializeRelayConnectionUri,
serializeConnectionUri,
serializeConnectionUriForStorage,
shouldUseTlsForDefaultHostedRelay,
@@ -213,22 +211,3 @@ describe("shouldUseTlsForDefaultHostedRelay", () => {
expect(shouldUseTlsForDefaultHostedRelay("not-an-endpoint")).toBe(false);
});
});
describe("relay connection URI", () => {
test("round-trips the offer and password through a direct URI and connect wrapper", () => {
const parts = {
offer: {
v: 2 as const,
serverId: "srv_test",
daemonPublicKeyB64: "abc+/=",
relay: { endpoint: "relay.paseo.sh:443", useTls: true },
},
password: "two words",
};
const uri = serializeRelayConnectionUri(parts);
expect(parseRelayConnectionUri(uri)).toEqual(parts);
expect(
parseRelayConnectionUri(`https://app.paseo.sh/#connect=${encodeURIComponent(uri)}`),
).toEqual(parts);
});
});
-51
View File
@@ -1,5 +1,3 @@
import type { ConnectionOfferV2 } from "./connection-offer.js";
export interface HostPortParts {
host: string;
port: number;
@@ -14,55 +12,6 @@ export interface ParsedConnectionUri extends ConnectionUriParts {
password?: string;
}
export interface ParsedRelayConnectionUri {
offer: ConnectionOfferV2;
password?: string;
}
export function parseRelayConnectionUri(input: string): ParsedRelayConnectionUri {
const trimmed = input.trim();
const connectIndex = trimmed.indexOf("#connect=");
const uri =
connectIndex >= 0
? decodeURIComponent(trimmed.slice(connectIndex + "#connect=".length))
: trimmed;
let url: URL;
try {
url = new URL(uri);
} catch {
throw new Error("Invalid relay connection URI");
}
if (url.protocol !== "relay:" || !url.hostname || !url.port || url.username || url.password) {
throw new Error("Invalid relay connection URI");
}
const serverId = decodeURIComponent(url.pathname.slice(1));
const key = url.searchParams.get("key");
if (!serverId || !key || serverId.includes("/")) {
throw new Error("Relay connection URI requires a server id and key");
}
const endpoint = normalizeHostPort(url.host);
const password = url.searchParams.get("password") || undefined;
return {
offer: {
v: 2,
serverId,
daemonPublicKeyB64: key,
relay: { endpoint, useTls: url.searchParams.get("ssl") === "true" },
},
...(password ? { password } : {}),
};
}
export function serializeRelayConnectionUri(parts: ParsedRelayConnectionUri): string {
const url = new URL(
`relay://${parts.offer.relay.endpoint}/${encodeURIComponent(parts.offer.serverId)}`,
);
url.searchParams.set("key", parts.offer.daemonPublicKeyB64);
if (parts.offer.relay.useTls) url.searchParams.set("ssl", "true");
if (parts.password) url.searchParams.set("password", parts.password);
return url.toString();
}
export type RelayRole = "server" | "client";
export type RelayProtocolVersion = "1" | "2";
-21
View File
@@ -456,11 +456,6 @@ const AgentPermissionActionSchema = z.object({
intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(),
});
const AgentQuestionAnswerSchema = z.object({
selected: z.array(z.string()),
text: z.string().optional(),
});
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
z.discriminatedUnion("behavior", [
z.object({
@@ -468,7 +463,6 @@ export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
selectedActionId: z.string().optional(),
updatedInput: z.record(z.string(), z.unknown()).optional(),
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
questionAnswers: z.array(AgentQuestionAnswerSchema).optional(),
}),
z.object({
behavior: z.literal("deny"),
@@ -3520,7 +3514,6 @@ const ServerCapabilitiesFromUnknownSchema = z
export const ServerInfoStatusPayloadSchema = z
.object({
status: z.literal("server_info"),
protocolVersion: z.number().int().optional(),
serverId: z.string().trim().min(1),
hostname: ServerInfoHostnameSchema.optional(),
version: ServerInfoVersionSchema.optional(),
@@ -7418,17 +7411,10 @@ export const WSHelloMessageSchema = z.object({
clientId: z.string().min(1),
clientType: z.enum(["mobile", "browser", "cli", "mcp", "hub"]),
protocolVersion: z.number().int(),
auth: z
.discriminatedUnion("kind", [
z.object({ kind: z.literal("password"), password: z.string() }),
z.object({ kind: z.literal("localCredential"), token: z.string() }),
])
.optional(),
appVersion: z.string().optional(),
capabilities: z
.object({
voice: z.boolean().optional(),
[CLIENT_CAPS.helloRejection]: z.boolean().optional(),
pushNotifications: z.boolean().optional(),
[CLIENT_CAPS.explicitEventSubscriptions]: z.boolean().optional(),
[CLIENT_CAPS.allProviders]: z.boolean().optional(),
@@ -7464,12 +7450,6 @@ export const WSSessionOutboundSchema = z.object({
message: SessionOutboundMessageSchema,
});
export const WSHelloRejectedMessageSchema = z.object({
type: z.literal("hello.rejected"),
reason: z.enum(["password_required", "incorrect_password", "incompatible_protocol"]),
accepts: z.array(z.literal("password")),
});
// Complete WebSocket message schemas
export const WSInboundMessageSchema = z.discriminatedUnion("type", [
WSPingMessageSchema,
@@ -7481,7 +7461,6 @@ export const WSInboundMessageSchema = z.discriminatedUnion("type", [
export const WSOutboundMessageSchema = z.discriminatedUnion("type", [
WSPongMessageSchema,
WSSessionOutboundSchema,
WSHelloRejectedMessageSchema,
]);
export type WSInboundMessage = z.infer<typeof WSInboundMessageSchema>;
@@ -54,7 +54,6 @@ import {
type ImportableProviderSession,
type ListImportableSessionsOptions,
} from "./agent-sdk-types.js";
import { resolveQuestionAnswers } from "./question-answers.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import type { AgentOwner } from "./agent-owner.js";
@@ -2982,11 +2981,7 @@ export class AgentManager {
agent.inFlightPermissionResponses.add(requestId);
try {
const request = agent.pendingPermissions.get(requestId);
const result = await agent.session.respondToPermission(
requestId,
request ? resolveQuestionAnswers(request, response) : response,
);
const result = await agent.session.respondToPermission(requestId, response);
agent.pendingPermissions.delete(requestId);
try {
@@ -504,22 +504,12 @@ export interface AgentPermissionRequest {
metadata?: AgentMetadata;
}
/**
* The answer to one question of a `question` permission request, aligned by index with
* `input.questions`. `selected` holds the chosen option labels; `text` holds a typed answer.
*/
export interface AgentQuestionAnswer {
selected: string[];
text?: string;
}
export type AgentPermissionResponse =
| {
behavior: "allow";
selectedActionId?: string;
updatedInput?: AgentMetadata;
updatedPermissions?: AgentPermissionUpdate[];
questionAnswers?: AgentQuestionAnswer[];
}
| {
behavior: "deny";
@@ -242,6 +242,54 @@ describe("Codex active-turn steering admission", () => {
appServer.assertNoErrors();
});
test("a steer resolves a Codex skill mentioned after prose into app-server skill input", async () => {
const appServer = createFakeCodexAppServer({
"skills/list": () => ({
data: [
{
cwd: "/workspace/project",
skills: [
{
name: "paseo-implement",
description: "Execute an existing Paseo plan.",
path: "/workspace/skills/paseo-implement/SKILL.md",
},
],
errors: [],
},
],
}),
"turn/steer": () => ({ turn: { id: "native-A" } }),
});
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
await expect(
session.steerActiveTurn!("Then use /paseo-implement to complete it.", {
expectedTurnId: paseoTurnId,
}),
).resolves.toEqual({ status: "accepted" });
const steer = appServer.requests().find((request) => request.method === "turn/steer");
expect(steer?.params).toEqual(
expect.objectContaining({
input: [
{
type: "skill",
name: "paseo-implement",
path: "/workspace/skills/paseo-implement/SKILL.md",
},
{
type: "text",
text: "Then use $paseo-implement to complete it.",
text_elements: [],
},
],
}),
);
await session.close();
appServer.assertNoErrors();
});
test("a clearing steer denies every pending permission through its provider handler", async () => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => ({ turn: { id: "native-A" } }),
@@ -2233,6 +2281,59 @@ describe("Codex app-server provider", () => {
);
});
test("resolves a Codex skill mentioned after prose into app-server skill input", async () => {
const session = createSession();
const request = vi.fn(async (method: string) => {
if (method === "skills/list") {
return {
data: [
{
cwd: "/tmp/codex-question-test",
skills: [
{
name: "paseo-implement",
description: "Execute an existing Paseo plan.",
path: "/tmp/skills/paseo-implement/SKILL.md",
},
],
errors: [],
},
],
};
}
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
await session.startTurn("Review the accepted plan, then use /paseo-implement to complete it.");
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
expect(turnStartCall?.[1]).toEqual(
expect.objectContaining({
input: [
{
type: "skill",
name: "paseo-implement",
path: "/tmp/skills/paseo-implement/SKILL.md",
},
{
type: "text",
text: "Review the accepted plan, then use $paseo-implement to complete it.",
text_elements: [],
},
],
}),
);
});
test("lists project skill commands when app-server receives the project cwd in cwds", async () => {
const commands = await listCommandsFromFakeCodex([
{
@@ -5042,7 +5143,6 @@ describe("Codex app-server provider", () => {
await session.respondToPermission("permission-call-question-2", {
behavior: "allow",
questionAnswers: [{ selected: ["Tea"] }],
updatedInput: {
answers: {
Drink: "Tea",
@@ -5062,7 +5162,6 @@ describe("Codex app-server provider", () => {
requestId: "permission-call-question-2",
resolution: {
behavior: "allow",
questionAnswers: [{ selected: ["Tea"] }],
updatedInput: {
answers: {
Drink: "Tea",
@@ -5102,33 +5201,6 @@ describe("Codex app-server provider", () => {
});
});
test("answers multi-select questions with option labels that contain commas", async () => {
const session = createSession();
const pendingResponse = asInternals(session).handleToolApprovalRequest({
itemId: "call-question-3",
threadId: "thread-1",
turnId: "turn-1",
questions: [
{
id: "colors",
header: "Colors",
question: "Which colors?",
multiSelect: true,
options: [{ label: "Red, bright" }, { label: "Blue" }],
},
],
});
await session.respondToPermission("permission-call-question-3", {
behavior: "allow",
questionAnswers: [{ selected: ["Red, bright", "Blue"], text: "Green, dark" }],
});
await expect(pendingResponse).resolves.toEqual({
answers: { colors: { answers: ["Red, bright", "Blue", "Green, dark"] } },
});
});
test("emits a synthetic plan approval permission after a successful Codex plan turn", () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
@@ -38,7 +38,6 @@ import {
type ProviderRefreshContext,
type ResolveAgentDefaultModeInput,
} from "../agent-sdk-types.js";
import { questionAnswerValues } from "../question-answers.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import { runProviderRefreshActivity } from "../provider-refresh-deadline.js";
import type { Logger } from "pino";
@@ -1265,21 +1264,39 @@ export function mapCodexQuestionRequestToToolCall(params: {
};
}
function mapCodexQuestionResponse(params: {
function mapCodexQuestionResponseByHeader(params: {
questions: CodexQuestionPrompt[];
response: AgentPermissionResponse;
}): Record<string, { answers: string[] }> | null {
if (params.response.behavior !== "allow" || !params.response.questionAnswers) {
if (params.response.behavior !== "allow") {
return null;
}
const questionAnswers = params.response.questionAnswers;
const updatedInputRecord = toObjectRecord(params.response.updatedInput);
const answersRecord = toObjectRecord(updatedInputRecord?.answers);
if (!answersRecord) {
return null;
}
const answers: Record<string, { answers: string[] }> = {};
params.questions.forEach((question, index) => {
const values = questionAnswerValues(questionAnswers[index]);
for (const question of params.questions) {
const rawAnswer = answersRecord[question.header];
if (typeof rawAnswer !== "string") {
continue;
}
const normalizedAnswer = rawAnswer.trim();
if (!normalizedAnswer) {
continue;
}
const values = question.multiSelect
? normalizedAnswer
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
: [normalizedAnswer];
if (values.length > 0) {
answers[question.id] = { answers: values };
}
});
}
return Object.keys(answers).length > 0 ? answers : null;
}
@@ -3152,6 +3169,26 @@ function enabledCodexSkills(
return Array.from(skillsByName.values());
}
// Composer autocomplete inserts skills as `/name` anywhere in the prompt, while
// Codex only injects a skill it receives as a skill input item.
const SKILL_MENTION_PATTERN = /(^|\s)\/([^\s/"']+)/g;
function resolveCodexSkillMentions(
text: string,
skills: ReadonlyArray<{ name: string; path: string }>,
): { skills: CodexSkillPromptBlock[]; text: string } {
const mentioned = new Map<string, CodexSkillPromptBlock>();
const rewritten = text.replace(SKILL_MENTION_PATTERN, (mention, prefix: string, name: string) => {
const skill = skills.find((entry) => entry.name === name);
if (!skill) {
return mention;
}
mentioned.set(skill.name, { type: "skill", name: skill.name, path: skill.path });
return `${prefix}$${skill.name}`;
});
return { skills: Array.from(mentioned.values()), text: rewritten };
}
type CodexPromptContentBlock = AgentPromptContentBlock | CodexSkillPromptBlock;
type CodexPromptInput = string | CodexPromptContentBlock[];
interface CodexTextElement {
@@ -4046,6 +4083,18 @@ export class CodexAppServerAgentSession implements AgentSession {
return args ? `$${commandName} ${args}` : `$${commandName}`;
}
private async attachMentionedSkills(prompt: AgentPromptInput): Promise<CodexPromptInput> {
if (typeof prompt !== "string" || !prompt.includes("/")) {
return prompt;
}
await this.loadSkills();
const resolved = resolveCodexSkillMentions(prompt, this.cachedSkills ?? []);
if (resolved.skills.length === 0) {
return prompt;
}
return [...resolved.skills, { type: "text", text: resolved.text }];
}
private async buildTurnStartParams(
prompt: CodexPromptInput,
options?: AgentRunOptions,
@@ -4249,7 +4298,7 @@ export class CodexAppServerAgentSession implements AgentSession {
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
const effectivePrompt = slashCommand
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
: prompt;
: await this.attachMentionedSkills(prompt);
if (this.currentThreadId) {
await this.ensureThreadLoaded();
@@ -4316,7 +4365,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
return { status: "unavailable" };
}
const input = await this.buildUserInput(prompt);
const input = await this.buildUserInput(await this.attachMentionedSkills(prompt));
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
return { status: "unavailable" };
}
@@ -4587,7 +4636,7 @@ export class CodexAppServerAgentSession implements AgentSession {
? pendingRequest.metadata.itemId
: requestId;
if (response.behavior === "allow") {
const mappedAnswers = mapCodexQuestionResponse({
const mappedAnswers = mapCodexQuestionResponseByHeader({
questions,
response,
});
@@ -332,7 +332,7 @@ describe("OpenCode auto_accept feature", () => {
await session.respondToPermission("question-1", {
behavior: "allow",
questionAnswers: [{ selected: ["Proceed"] }],
updatedInput: { answers: { Decision: "Proceed" } },
});
expect(openCodeClient.calls.questionReply).toHaveLength(1);
@@ -386,7 +386,7 @@ describe("OpenCode auto_accept feature", () => {
await session.respondToPermission("question-1", {
behavior: "allow",
questionAnswers: [{ selected: [], text: "Use another answer" }],
updatedInput: { answers: { Decision: "Use another answer" } },
});
expect(openCodeClient.calls.questionReply).toEqual([
@@ -5296,7 +5296,7 @@ describe("OpenCode provider subagent contract", () => {
await parent.respondToPermission("question_provider_child", {
behavior: "allow",
questionAnswers: [{ selected: ["A"] }],
updatedInput: { answers: { Path: "A" } },
});
expect(parentClient.calls.questionReply).toContainEqual(
expect.objectContaining({
@@ -5308,50 +5308,6 @@ describe("OpenCode provider subagent contract", () => {
await parent.close();
});
test("replies to questions with option labels and custom answers that contain commas", async () => {
const { parent, openCode } = await createParentSession("ses_parent_comma_question");
openCode.emitEvent({
type: "question.asked",
properties: {
id: "question_comma",
sessionID: "ses_parent_comma_question",
questions: [
{
question: "Which colors?",
header: "Colors",
multiple: true,
options: [
{ label: "Red, bright", description: "Warm" },
{ label: "Blue", description: "Cool" },
],
},
{
question: "Which city?",
header: "City",
options: [{ label: "Paris, France", description: "Europe" }],
},
],
},
});
await vi.waitFor(() => expect(parent.getPendingPermissions()).toHaveLength(1));
await parent.respondToPermission("question_comma", {
behavior: "allow",
questionAnswers: [
{ selected: ["Red, bright", "Blue"], text: "Green, dark" },
{ selected: ["Paris, France"] },
],
});
expect(openCode.calls.questionReply).toContainEqual(
expect.objectContaining({
requestID: "question_comma",
answers: [["Red, bright", "Blue", "Green, dark"], ["Paris, France"]],
}),
);
await parent.close();
});
test("emits the same child status when descriptor semantics change", async () => {
const { parent, openCode } = await createParentSession("ses_parent_child_changed_status");
const events: AgentStreamEvent[] = [];
@@ -57,7 +57,6 @@ import {
type ToolCallDetail,
type ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import { questionAnswerValues } from "../question-answers.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import {
raceProviderRefreshAbort,
@@ -4958,10 +4957,19 @@ class OpenCodeAgentSession implements AgentSession {
directory,
});
} else {
const answersRecord = readOpenCodeRecord(response.updatedInput?.answers);
const questions = Array.isArray(pending.input?.questions) ? pending.input.questions : [];
const answers = questions.map((_, index) =>
questionAnswerValues(response.questionAnswers?.[index]),
);
const answers = questions.map((item) => {
const header = readNonEmptyString(readOpenCodeRecord(item)?.header);
const rawAnswer = header ? readNonEmptyString(answersRecord?.[header]) : null;
if (!rawAnswer) {
return [];
}
return rawAnswer
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
});
await this.client.question.reply({
requestID: requestId,
@@ -35,23 +35,17 @@ describe("OpenCode v2 questions", () => {
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
expect(session.getPendingPermissions()).toEqual([
expect.objectContaining({
input: {
questions: expect.arrayContaining([
expect.objectContaining({ header: "features", multiSelect: true }),
]),
},
}),
]);
expect(session.getPendingPermissions()).toHaveLength(1);
await session.respondToPermission("question", {
behavior: "allow",
questionAnswers: [
{ selected: ["Blue"] },
{ selected: ["Search"], text: "Web, images" },
{ selected: [], text: "3" },
{ selected: [], text: "false" },
],
updatedInput: {
answers: {
color: "Blue",
features: ["Search"],
count: "3",
enabled: "false",
},
},
});
expect(answers).toEqual([
{
@@ -59,7 +53,7 @@ describe("OpenCode v2 questions", () => {
formID: "question",
answer: {
color: "blue-id",
features: ["search-id", "Web, images"],
features: ["search-id"],
count: 3,
enabled: false,
},
@@ -4,11 +4,9 @@ import type { V2Api } from "./api.js";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentQuestionAnswer,
AgentSessionConfig,
AgentStreamEvent,
} from "../../../agent-sdk-types.js";
import { questionAnswerValues } from "../../../question-answers.js";
export class SessionPermissions {
private readonly pending = new Map<string, AgentPermissionRequest>();
@@ -41,13 +39,16 @@ export class SessionPermissions {
if (response.behavior === "deny")
await this.client.session.form.cancel({ sessionID: form.sessionID, formID: form.id });
else {
const answers = response.questionAnswers;
const raw = response.updatedInput?.answers;
const answer: Record<string, FormValue> = {};
if (!answers) throw new Error("OpenCode question response requires answers");
form.fields.forEach((field, index) => {
const normalized = formAnswer(field, answers[index]);
if (!raw || typeof raw !== "object" || Array.isArray(raw))
throw new Error("OpenCode question response requires answers");
for (const field of form.fields) {
const value: unknown =
Reflect.get(raw, field.key) ?? Reflect.get(raw, field.title ?? field.key);
const normalized = formAnswer(field, value);
if (normalized !== undefined) answer[field.key] = normalized;
});
}
await this.client.session.form.reply({
sessionID: form.sessionID,
formID: form.id,
@@ -102,7 +103,7 @@ export class SessionPermissions {
header: field.key,
question: field.title ?? field.key,
options: "options" in field ? field.options : undefined,
multiSelect: field.type === "multiselect",
multiple: field.type === "multiselect",
})),
},
};
@@ -123,13 +124,9 @@ function permissionReply(response: AgentPermissionResponse): "reject" | "once" |
return response.selectedActionId === "always" ? "always" : "once";
}
function formAnswer(
field: FormInfo["fields"][number],
answer: AgentQuestionAnswer | undefined,
): FormValue | undefined {
const values = questionAnswerValues(answer);
const [value] = values;
if (value === undefined || field.type === "external") return undefined;
function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValue | undefined {
if (value === undefined) return undefined;
if (field.type === "external") return undefined;
const labelValue = (label: string) => {
const options = "options" in field ? field.options : undefined;
return (
@@ -139,15 +136,21 @@ function formAnswer(
);
};
if (field.type === "multiselect") {
return values.map(labelValue);
} else if (field.type === "string") {
if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string"))
return value.map(labelValue);
} else if (field.type === "string" && typeof value === "string") {
return labelValue(value);
} else if (field.type === "boolean") {
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
} else if (field.type === "number" || field.type === "integer") {
const numeric = value.trim() ? Number(value) : Number.NaN;
if (Number.isFinite(numeric) && (field.type !== "integer" || Number.isInteger(numeric)))
const numeric = typeof value === "string" && value.trim() ? Number(value) : value;
if (
typeof numeric === "number" &&
Number.isFinite(numeric) &&
(field.type !== "integer" || Number.isInteger(numeric))
)
return numeric;
}
throw new Error(`Invalid answer for OpenCode question ${field.key}`);
@@ -24,9 +24,6 @@ import {
transformPiModels,
} from "./agent.js";
import { FakePi } from "./test-utils/fake-pi.js";
import { HIDDEN_GOAL_CONTINUATION } from "./test-utils/hidden-custom-message.js";
import { createPiExtensionHost } from "./extensions/index.js";
import { PiExtensionHost } from "./extensions/host.js";
import type { PiModel, PiThinkingLevel } from "./rpc-types.js";
import type { PiUsagePollScheduler } from "./usage-poller.js";
@@ -420,103 +417,6 @@ class SessionEvents {
}
describe("PiRpcAgentSession", () => {
test("completes a turn and answers a dialog when an adapter throws", async () => {
const { pi, session, events } = await createSession();
Object.assign(session, {
extensionHost: createPiExtensionHost(undefined, [
{
id: "throwing-test-adapter",
createSession: () => ({
mapToolCall: () => {
throw new Error("tool failed");
},
onToolStart: () => {
throw new Error("start failed");
},
onToolEnd: () => {
throw new Error("end failed");
},
mapDialog: () => {
throw new Error("dialog failed");
},
respondToPermission: () => {
throw new Error("response failed");
},
}),
},
]),
});
const fakeSession = pi.latestSession();
await session.startTurn("run");
fakeSession.emit({
type: "tool_execution_start",
toolCallId: "x",
toolName: "other",
args: {},
});
fakeSession.emit({
type: "tool_execution_end",
toolCallId: "x",
toolName: "other",
result: { content: [{ type: "text", text: "ok" }] },
isError: false,
});
fakeSession.emit({
type: "extension_ui_request",
id: "ui-1",
method: "select",
title: "Pick",
options: ["A", "B"],
});
const permission = await events.nextPermissionRequest();
expect(permission.request.kind).toBe("question");
await session.respondToPermission("ui-1", {
behavior: "allow",
updatedInput: { answers: { Response: "B" } },
});
expect(fakeSession.extensionUiResponses).toEqual([{ id: "ui-1", response: { value: "B" } }]);
fakeSession.finishTurn();
await events.nextTurnCompletion();
expect(events.timelineItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "tool_call",
name: "other",
status: "completed",
detail: expect.objectContaining({ type: "unknown" }),
}),
]),
);
});
test("close resolves after rejected child hydration", async () => {
const { pi, session } = await createSession();
Object.assign(session, {
extensionHost: new PiExtensionHost(
[
{
id: "child-test",
createSession: () => ({
mapToolCall: () => ({ childSessions: [{ id: "child-1", file: "unused" }] }),
}),
},
],
undefined,
2 * 1024 * 1024,
async () => {
throw new Error("read failed");
},
),
});
await session.startTurn("run");
pi.latestSession().emit({
type: "tool_execution_start",
toolCallId: "x",
toolName: "other",
args: {},
});
await expect(session.close()).resolves.toBeUndefined();
});
test("bridges Pi RPC select extension UI requests through question permissions", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
@@ -860,6 +760,61 @@ describe("PiRpcAgentSession", () => {
]);
});
test("streams Pi task calls as sub-agent cards with lifecycle status", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
await session.startTurn("delegate this");
fakeSession.emit({
type: "tool_execution_start",
toolCallId: "task-1",
toolName: "task",
args: {
agent: "explore",
task: "Trace the Pi provider tool mapper",
},
});
fakeSession.emit({
type: "tool_execution_end",
toolCallId: "task-1",
toolName: "task",
result: { content: [{ type: "text", text: "Found the mapper." }] },
isError: false,
});
fakeSession.finishTurn();
await events.nextTurnCompletion();
expect(events.timelineItems()).toEqual([
{
type: "tool_call",
callId: "task-1",
name: "task",
status: "running",
detail: {
type: "sub_agent",
subAgentType: "explore",
description: "Trace the Pi provider tool mapper",
log: "",
},
error: null,
},
{
type: "tool_call",
callId: "task-1",
name: "task",
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "explore",
description: "Trace the Pi provider tool mapper",
log: "Found the mapper.",
},
error: null,
},
]);
});
test("keeps one generated message id when Pi omits message start and response id", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
@@ -1081,17 +1036,6 @@ describe("PiRpcAgentSession", () => {
]);
});
test("hides Pi custom messages that the extension marks display: false", async () => {
const { pi, events } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.emit({ type: "agent_start" });
fakeSession.emit({ type: "turn_start" });
fakeSession.emit({ type: "message_end", message: HIDDEN_GOAL_CONTINUATION });
expect(events.timelineItems()).toEqual([]);
});
test("canceling a silent Pi extension command leaves the session usable", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
@@ -39,6 +39,7 @@ import {
type ListImportableSessionsOptions,
type ProviderCatalog,
type ProviderRefreshContext,
type ToolCallDetail,
} from "../../agent-sdk-types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js";
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
@@ -59,13 +60,12 @@ import {
toDiagnosticErrorMessage,
} from "../diagnostic-utils.js";
import {
getCustomMessageText,
getUserMessageText,
streamPiHistory,
type PiCapturedUserMessageEntry,
} from "./history-mapper.js";
import { materializeProviderImage } from "../provider-image-output.js";
import { PiCliRuntime } from "./cli-runtime.js";
import { createPiExtensionHost, type PiExtensionEventOutput } from "./extensions/index.js";
import { revertPiConversation } from "./rewind.js";
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js";
@@ -84,6 +84,7 @@ import {
mapToolDetail,
parseToolArgs,
parseToolResult,
resolveToolCallName,
type PiToolResult,
type PiTrackedToolCall,
} from "./tool-call-mapper.js";
@@ -100,6 +101,9 @@ const PASEO_PI_COMMAND_RESULT_MARKER = "PASEO_COMMAND_RESULT";
const DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS = 30_000;
const DEFAULT_PI_RPC_TIMEOUT_MS = 60_000;
const QUESTION_RESPONSE_HEADER = "Response";
const QUESTION_COMMENT_HEADER = "Comment";
const PI_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response...";
const COMBINED_ASK_USER_METADATA = "ask_user_select_optional_comment";
export const PiProviderParamsSchema = z
.object({
@@ -282,9 +286,22 @@ interface PendingExtensionResult {
timer: NodeJS.Timeout;
}
interface ActiveAskUserDialog {
allowComment: boolean;
allowFreeform: boolean;
allowMultiple: boolean;
}
interface PendingCombinedAskUserResponse {
comment: string;
freeform: string | null;
}
interface ExtensionUiMappingOptions {
provider?: AgentProvider;
label?: string;
combineOptionalComment?: boolean;
allowFreeform?: boolean;
}
interface PiSlashCommandInvocation {
@@ -871,6 +888,21 @@ function parseCapturedEntries(value: unknown): PiCapturedEntry[] {
});
}
function optionalBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function readActiveAskUserDialog(toolName: string, args: unknown): ActiveAskUserDialog | null {
if (toolName !== "ask_user" || !isRecord(args)) {
return null;
}
return {
allowComment: optionalBoolean(args.allowComment) ?? false,
allowFreeform: optionalBoolean(args.allowFreeform) ?? true,
allowMultiple: optionalBoolean(args.allowMultiple) ?? false,
};
}
function isOptionalInputPlaceholder(placeholder: string | undefined): boolean {
return /\boptional\b|\bskip\b/i.test(placeholder ?? "");
}
@@ -891,6 +923,10 @@ function readStringArray(value: unknown): string[] {
: [];
}
function isPiAskUserFreeformOption(option: string): boolean {
return option === PI_ASK_USER_FREEFORM_SENTINEL;
}
function mapExtensionUiRequestToPermission(
event: Extract<PiRuntimeEvent, { type: "extension_ui_request" }>,
options: ExtensionUiMappingOptions = {},
@@ -900,6 +936,15 @@ function mapExtensionUiRequestToPermission(
switch (event.method) {
case "select": {
const selectOptions = readStringArray(event.options);
if (options.combineOptionalComment) {
return buildCombinedAskUserQuestionPermission(event, {
provider,
label,
question: optionalString(event.title) ?? "Select an option",
options: selectOptions,
allowFreeform: options.allowFreeform === true,
});
}
return buildExtensionUiQuestionPermission(event, {
provider,
label,
@@ -1017,6 +1062,63 @@ function buildExtensionUiQuestionPermission(
};
}
function buildCombinedAskUserQuestionPermission(
event: Extract<PiRuntimeEvent, { type: "extension_ui_request" }>,
input: {
provider: AgentProvider;
label: string;
question: string;
options: string[];
allowFreeform: boolean;
},
): AgentPermissionRequest {
const visibleOptions = input.options.filter((option) => !isPiAskUserFreeformOption(option));
const allowOther = input.allowFreeform || visibleOptions.length !== input.options.length;
return {
id: event.id,
provider: input.provider,
name: `${input.label} ask_user`,
kind: "question",
title: input.question,
input: {
questions: [
{
question: input.question,
header: QUESTION_RESPONSE_HEADER,
options: visibleOptions.map((label) => ({ label })),
multiSelect: false,
...(allowOther ? { allowOther: true } : {}),
},
{
question: "Optional comment",
header: QUESTION_COMMENT_HEADER,
options: [],
multiSelect: false,
placeholder: "Optional comment (press Enter to skip)...",
allowEmpty: true,
},
],
},
metadata: {
extensionUiMethod: event.method,
answerHeader: QUESTION_RESPONSE_HEADER,
commentHeader: QUESTION_COMMENT_HEADER,
combinedAskUser: COMBINED_ASK_USER_METADATA,
selectOptions: visibleOptions,
...(allowOther ? { freeformSentinel: PI_ASK_USER_FREEFORM_SENTINEL } : {}),
},
};
}
function permissionAnswer(input: AgentMetadata | undefined, header: string): string | null {
const answers = isRecord(input?.answers) ? input.answers : null;
if (!answers) {
return null;
}
const answer = answers[header];
return typeof answer === "string" ? answer : null;
}
function firstPermissionAnswer(input: AgentMetadata | undefined): string | null {
const answers = isRecord(input?.answers) ? input.answers : null;
if (!answers) {
@@ -1026,6 +1128,39 @@ function firstPermissionAnswer(input: AgentMetadata | undefined): string | null
return typeof first === "string" ? first : null;
}
function isCombinedAskUserPermission(request: AgentPermissionRequest): boolean {
return request.metadata?.combinedAskUser === COMBINED_ASK_USER_METADATA;
}
function buildCombinedAskUserSelectionResponse(
request: AgentPermissionRequest,
response: AgentPermissionResponse,
): {
uiResponse: { value?: string; cancelled?: boolean };
pendingResponse: PendingCombinedAskUserResponse | null;
} {
if (response.behavior === "deny") {
return { uiResponse: { cancelled: true }, pendingResponse: null };
}
const answer = permissionAnswer(response.updatedInput, QUESTION_RESPONSE_HEADER);
if (answer === null) {
return { uiResponse: { cancelled: true }, pendingResponse: null };
}
const selectOptions = readStringArray(request.metadata?.selectOptions);
const freeformSentinel = optionalString(request.metadata?.freeformSentinel);
const isFreeform = Boolean(freeformSentinel) && !selectOptions.includes(answer);
const comment = permissionAnswer(response.updatedInput, QUESTION_COMMENT_HEADER) ?? "";
return {
uiResponse: { value: isFreeform ? freeformSentinel : answer },
pendingResponse: {
comment,
freeform: isFreeform ? answer : null,
},
};
}
function buildExtensionUiResponse(
request: AgentPermissionRequest,
response: AgentPermissionResponse,
@@ -1125,7 +1260,8 @@ export class PiRpcAgentSession implements AgentSession {
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly activeToolCalls = new Map<string, PiTrackedToolCall>();
private readonly pendingExtensionUiRequests = new Map<string, AgentPermissionRequest>();
private readonly extensionHost: ReturnType<typeof createPiExtensionHost>;
private activeAskUserDialog: ActiveAskUserDialog | null = null;
private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null;
private activeTurnId: string | null = null;
private activeClientMessageId: string | null = null;
private activeAssistantMessageId: string | null = null;
@@ -1150,8 +1286,6 @@ export class PiRpcAgentSession implements AgentSession {
private readonly logger: Logger;
private readonly usagePoller: PiUsagePoller;
private closed = false;
private readonly closeController = new AbortController();
private readonly pendingExtensionHydrations = new Set<Promise<void>>();
// Pi publishes the terminal before acknowledging abort. Autonomous runs have no
// turn ID; retain their errors too until the cancellation request settles.
private interruptingTurn: { turnId: string | undefined; error: string | null } | null = null;
@@ -1167,7 +1301,6 @@ export class PiRpcAgentSession implements AgentSession {
this.cleanup = options.cleanup;
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
this.logger = options.logger;
this.extensionHost = createPiExtensionHost(this.logger);
this.usagePoller = new PiUsagePoller({
scheduler: options.usagePollScheduler,
readStats: () => this.runtimeSession.getSessionStats(),
@@ -1352,10 +1485,6 @@ export class PiRpcAgentSession implements AgentSession {
this.provider,
await this.runtimeSession.getMessages(),
this.contextUserEntries,
{},
// At most eight 2 MiB child files per replay; later cards retain their summaries.
createPiExtensionHost(this.logger, undefined, 16 * 1024 * 1024),
this.closeController.signal,
);
}
@@ -1393,11 +1522,15 @@ export class PiRpcAgentSession implements AgentSession {
}
this.pendingExtensionUiRequests.delete(requestId);
const mapped = this.extensionHost.respondToPermission(request, response);
for (const reply of mapped?.responses ?? [
{ id: requestId, response: buildExtensionUiResponse(request, response) },
]) {
this.runtimeSession.respondToExtensionUiRequest(reply.id, reply.response);
if (isCombinedAskUserPermission(request)) {
const combined = buildCombinedAskUserSelectionResponse(request, response);
this.pendingCombinedAskUserResponse = combined.pendingResponse;
this.runtimeSession.respondToExtensionUiRequest(requestId, combined.uiResponse);
} else {
this.runtimeSession.respondToExtensionUiRequest(
requestId,
buildExtensionUiResponse(request, response),
);
}
this.emit({
type: "permission_resolved",
@@ -1522,12 +1655,10 @@ export class PiRpcAgentSession implements AgentSession {
return;
}
this.closed = true;
this.closeController.abort();
this.usagePoller.close();
try {
await this.runtimeSession.close();
} finally {
await Promise.all(this.pendingExtensionHydrations);
this.rejectAllExtensionResults(new Error("Pi session closed"));
this.cleanup?.();
}
@@ -1596,24 +1727,6 @@ export class PiRpcAgentSession implements AgentSession {
}
}
private emitExtensionOutput(output: PiExtensionEventOutput | undefined, turnId?: string): void {
if (!output || this.closed) return;
for (const event of output.events) {
this.emit(event.type === "timeline" ? { ...event, turnId } : event);
}
const pending = output.hydration
.then((events) => {
if (this.closeController.signal.aborted) return;
for (const event of events) this.emit(event);
return undefined;
})
.catch((error) => {
this.logger.warn({ err: error }, "Pi extension hydration failed");
});
this.pendingExtensionHydrations.add(pending);
void pending.then(() => this.pendingExtensionHydrations.delete(pending));
}
private currentTurnIdForEvent(): string | undefined {
return this.activeTurnId ?? undefined;
}
@@ -1956,16 +2069,20 @@ export class PiRpcAgentSession implements AgentSession {
return;
}
const mapped = this.extensionHost.mapDialog(event, this.provider);
if (mapped?.type === "response") {
this.runtimeSession.respondToExtensionUiRequest(event.id, mapped.response);
if (this.respondToCombinedAskUserFollowUp(event)) {
return;
}
if (mapped?.type === "deferred") return;
const request =
mapped?.type === "permission"
? mapped.request
: mapExtensionUiRequestToPermission(event, { provider: this.provider, label: "Pi" });
const shouldCombineOptionalComment =
event.method === "select" &&
this.activeAskUserDialog?.allowComment === true &&
this.activeAskUserDialog.allowMultiple === false;
const request = mapExtensionUiRequestToPermission(event, {
provider: this.provider,
label: "Pi",
combineOptionalComment: shouldCombineOptionalComment,
allowFreeform: this.activeAskUserDialog?.allowFreeform,
});
if (!request) {
return;
}
@@ -1979,6 +2096,33 @@ export class PiRpcAgentSession implements AgentSession {
});
}
private respondToCombinedAskUserFollowUp(
event: Extract<PiRuntimeEvent, { type: "extension_ui_request" }>,
): boolean {
const pending = this.pendingCombinedAskUserResponse;
if (!pending || event.method !== "input") {
return false;
}
const placeholder = optionalString(event.placeholder);
if (pending.freeform !== null && !isOptionalInputPlaceholder(placeholder)) {
this.pendingCombinedAskUserResponse = {
...pending,
freeform: null,
};
this.runtimeSession.respondToExtensionUiRequest(event.id, { value: pending.freeform });
return true;
}
if (isOptionalInputPlaceholder(placeholder)) {
this.pendingCombinedAskUserResponse = null;
this.runtimeSession.respondToExtensionUiRequest(event.id, { value: pending.comment });
return true;
}
return false;
}
private handleCommandOutput(textValue: unknown): void {
if (!this.activeTurnId) {
return;
@@ -2102,25 +2246,7 @@ export class PiRpcAgentSession implements AgentSession {
case "tool_execution_start": {
const toolCall = parseToolArgs(event.toolName, event.args);
this.activeToolCalls.set(event.toolCallId, toolCall);
const request = this.extensionHost.onToolStart(
{
callId: event.toolCallId,
toolName: event.toolName,
args: toolCall.args,
status: "running",
result: null,
},
this.provider,
);
if (request) {
this.pendingExtensionUiRequests.set(request.id, request);
this.emit({
type: "permission_requested",
provider: this.provider,
request,
turnId: this.currentTurnIdForEvent(),
});
}
this.activeAskUserDialog = readActiveAskUserDialog(event.toolName, event.args);
this.emitToolCallEvent(event.toolCallId, toolCall, "running", null, null);
return;
}
@@ -2204,16 +2330,14 @@ export class PiRpcAgentSession implements AgentSession {
this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null);
this.activeToolCalls.delete(event.toolCallId);
if (event.toolName === "ask_user") {
this.activeAskUserDialog = null;
this.pendingCombinedAskUserResponse = null;
}
const result = parseToolResult(event.result);
const error = event.isError ? event.result : null;
const status = event.isError ? "failed" : "completed";
this.extensionHost.onToolEnd({
callId: event.toolCallId,
toolName: toolCall.toolName,
args: toolCall.args,
status,
result,
});
this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error);
}
@@ -2298,9 +2422,7 @@ export class PiRpcAgentSession implements AgentSession {
return;
}
if (event.message.role === "custom") {
const customMapping = this.extensionHost.mapCustomMessage(event.message);
this.emitExtensionOutput(customMapping, turnId);
const text = getCustomMessageText(event.message);
const text = getUserMessageText(event.message.content);
if (text) {
this.emit({
type: "timeline",
@@ -2324,21 +2446,14 @@ export class PiRpcAgentSession implements AgentSession {
error: unknown,
): boolean {
const turnId = this.currentTurnIdForEvent();
const mapping = this.extensionHost.mapToolCall({
callId: toolCallId,
toolName: toolCall.toolName,
args: toolCall.args,
status,
result,
});
const detail = mapping?.detail ?? mapToolDetail(toolCall, result);
const detail = this.mapToolDetail(toolCallId, toolCall, result);
if (!detail) {
return false;
}
const baseItem = {
type: "tool_call" as const,
callId: toolCallId,
name: mapping?.name ?? toolCall.toolName,
name: resolveToolCallName(toolCall, result),
detail,
};
const item =
@@ -2349,10 +2464,17 @@ export class PiRpcAgentSession implements AgentSession {
turnId,
item,
});
this.emitExtensionOutput(mapping, turnId);
return true;
}
private mapToolDetail(
_toolCallId: string,
toolCall: PiTrackedToolCall,
result: PiToolResult,
): ToolCallDetail | null {
return mapToolDetail(toolCall, result);
}
private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void {
const errorMessage = latestPiErrorMessage(messages);
if (
@@ -1,59 +0,0 @@
import { open } from "node:fs/promises";
import { limitAgentTimelineItemContent } from "../../../agent-timeline-content.js";
import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js";
import { PiHistoryMapper } from "../history-mapper.js";
import type { PiAgentMessage } from "../rpc-types.js";
const MAX_BYTES = 2 * 1024 * 1024;
const MAX_ITEMS = 200;
/** Child session files are immutable once the completed result exposes their path. */
export async function mapPiChildSession(
id: string,
file: string,
maxBytes = MAX_BYTES,
): Promise<ProviderSubagentInputEvent[]> {
try {
const handle = await open(file, "r");
try {
const fileSize = (await handle.stat()).size;
const size = Math.min(fileSize, MAX_BYTES, maxBytes);
if (!size) return [];
const buffer = Buffer.alloc(size);
const { bytesRead } = await handle.read(buffer, 0, size, 0);
const text = buffer.toString("utf8", 0, bytesRead);
const completeText = bytesRead < fileSize ? text.slice(0, text.lastIndexOf("\n")) : text;
return parseChildTimeline(id, completeText);
} finally {
await handle.close();
}
} catch {
return [];
}
}
function parseChildTimeline(id: string, text: string): ProviderSubagentInputEvent[] {
const mapper = new PiHistoryMapper("pi");
const events: ProviderSubagentInputEvent[] = [];
for (const line of text.split("\n")) {
if (!line || events.length >= MAX_ITEMS) break;
let entry: { message?: PiAgentMessage; timestamp?: string };
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (!entry.message || typeof entry.message !== "object" || !("role" in entry.message)) continue;
for (const mapped of mapper.mapMessages([entry.message])) {
if (mapped.type !== "timeline") continue;
events.push({
type: "timeline",
id,
item: limitAgentTimelineItemContent(mapped.item),
...(entry.timestamp ? { timestamp: entry.timestamp } : {}),
});
if (events.length >= MAX_ITEMS) break;
}
}
return events;
}
@@ -1,66 +0,0 @@
import type {
AgentPermissionRequest,
AgentPermissionResponse,
ToolCallDetail,
AgentTimelineItem,
} from "../../../agent-sdk-types.js";
import type { PiRuntimeEvent } from "../rpc-types.js";
import type { PiToolResult } from "../tool-call-mapper.js";
import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js";
import type { PiAgentMessage } from "../rpc-types.js";
export interface PiExtensionToolCall {
callId: string;
toolName: string;
args: unknown;
status: "running" | "completed" | "failed";
result: PiToolResult;
}
export interface PiExtensionToolMapping {
name?: string;
detail?: ToolCallDetail;
timeline?: AgentTimelineItem[];
subagents?: ProviderSubagentInputEvent[];
/** Completed Pi child sessions to hydrate through the normal Pi history mapper. */
childSessions?: Array<{ id: string; file: string }>;
}
export interface PiExtensionCustomMapping {
subagents: ProviderSubagentInputEvent[];
childSessions?: Array<{ id: string; file: string }>;
}
export type PiExtensionDialog = Extract<PiRuntimeEvent, { type: "extension_ui_request" }>;
export interface PiExtensionUiResponse {
value?: string;
cancelled?: boolean;
confirmed?: boolean;
}
export type PiExtensionDialogMapping =
| { type: "permission"; request: AgentPermissionRequest }
| { type: "response"; response: PiExtensionUiResponse }
| { type: "deferred" };
export interface PiExtensionUiReply {
responses: Array<{ id: string; response: PiExtensionUiResponse }>;
}
export interface PiExtensionSession {
mapToolCall?(call: PiExtensionToolCall): PiExtensionToolMapping | undefined;
mapCustomMessage?(
message: Extract<PiAgentMessage, { role: "custom" }>,
): PiExtensionCustomMapping | undefined;
onToolStart?(call: PiExtensionToolCall, provider: string): AgentPermissionRequest | undefined;
onToolEnd?(call: PiExtensionToolCall): void;
mapDialog?(dialog: PiExtensionDialog, provider: string): PiExtensionDialogMapping | undefined;
respondToPermission?(
request: AgentPermissionRequest,
response: AgentPermissionResponse,
): PiExtensionUiReply | undefined;
}
export interface PiExtension {
id: string;
createSession(): PiExtensionSession;
}
@@ -1,248 +0,0 @@
{
"provenance": {
"package": "@gotgenes/pi-subagents",
"version": "21.7.6",
"sourceCommit": "1ac6bc6a36e6da5d00f1cd2482f1b971f5ea1fdf",
"piVersion": "0.84.4",
"captureDate": "2026-09-23"
},
"events": [
{
"type": "tool_execution_start",
"toolCallId": "call_403820",
"toolName": "subagent",
"args": {
"description": "list files",
"subagent_type": "general-purpose",
"prompt": "list files in this empty workspace",
"run_in_background": true
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_403820",
"toolName": "subagent",
"result": {
"content": [
{
"type": "text",
"text": "Agent started in background.\nAgent ID: b913f6d2-2b1e-4be\nType: Agent\nDescription: list files\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work."
}
],
"details": {
"displayName": "Agent",
"description": "list files",
"subagentType": "general-purpose",
"tags": ["twin", "background"],
"toolUses": 0,
"tokens": "",
"durationMs": 0,
"status": "background",
"agentId": "b913f6d2-2b1e-4be"
}
},
"isError": false
},
{
"type": "message_end",
"message": {
"role": "custom",
"customType": "subagent-notification",
"content": "<task-notification>\n<task-id>b913f6d2-2b1e-4be</task-id>\n<tool-use-id>call_403820</tool-use-id>\n<output-file>/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl</output-file>\n<status>Done</status>\n<summary>Subagent \"list files\" completed</summary>\n<result>The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.</result>\n<usage><total_tokens>8843</total_tokens><tool_uses>1</tool_uses><context_percent>1</context_percent><duration_ms>3714</duration_ms></usage>\n</task-notification>\nFull transcript available at: /tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl\nCall get_subagent_result(\"b913f6d2-2b1e-4be\") to collect the full result.",
"display": true,
"details": {
"id": "b913f6d2-2b1e-4be",
"description": "list files",
"status": "completed",
"toolUses": 1,
"turnCount": 3,
"totalTokens": 8843,
"durationMs": 3714,
"outputFile": "/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl",
"resultPreview": "The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty."
},
"timestamp": 1790197700057
}
},
{
"type": "tool_execution_start",
"toolCallId": "call_380752",
"toolName": "get_subagent_result",
"args": {
"agent_id": "b913f6d2-2b1e-4be"
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_380752",
"toolName": "get_subagent_result",
"result": {
"content": [
{
"type": "text",
"text": "Agent: b913f6d2-2b1e-4be\nType: Agent | Status: completed | Tool uses: 1 | 8.8k token | Context: 1% | Duration: 3.7s\nDescription: list files\n\nThe workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.\n\nFull transcript available at: /tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl"
}
],
"details": {
"agentId": "b913f6d2-2b1e-4be",
"displayName": "Agent",
"status": "completed",
"description": "list files",
"toolUses": 1,
"tokens": "8.8k token",
"contextPercent": 0.6055831909179688,
"compactionCount": 0,
"duration": "3.7s",
"preview": "The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.",
"verbose": false,
"transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl"
}
},
"isError": false
}
],
"messages": [
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_403820",
"name": "subagent",
"arguments": {
"description": "list files",
"subagent_type": "general-purpose",
"prompt": "list files in this empty workspace",
"run_in_background": true
}
}
],
"api": "openai-completions",
"provider": "openrouter",
"model": "google/gemini-3.6-flash",
"usage": {
"input": 6590,
"output": 46,
"cacheRead": 0,
"cacheWrite": 0,
"reasoning": 0,
"totalTokens": 6636,
"cost": {
"input": 0.0049425,
"output": 0.0001725,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0.005115
}
},
"stopReason": "toolUse",
"timestamp": 1790197694784,
"responseId": "gen-1790197695-1oeKbQMynKXs4RNlIMQ7",
"rawStopReason": "tool_calls"
},
{
"role": "toolResult",
"toolCallId": "call_403820",
"toolName": "subagent",
"content": [
{
"type": "text",
"text": "Agent started in background.\nAgent ID: b913f6d2-2b1e-4be\nType: Agent\nDescription: list files\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work."
}
],
"details": {
"displayName": "Agent",
"description": "list files",
"subagentType": "general-purpose",
"tags": ["twin", "background"],
"toolUses": 0,
"tokens": "",
"durationMs": 0,
"status": "background",
"agentId": "b913f6d2-2b1e-4be"
},
"isError": false,
"timestamp": 1790197696346
},
{
"role": "custom",
"customType": "subagent-notification",
"content": "<task-notification>\n<task-id>b913f6d2-2b1e-4be</task-id>\n<tool-use-id>call_403820</tool-use-id>\n<output-file>/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl</output-file>\n<status>Done</status>\n<summary>Subagent \"list files\" completed</summary>\n<result>The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.</result>\n<usage><total_tokens>8843</total_tokens><tool_uses>1</tool_uses><context_percent>1</context_percent><duration_ms>3714</duration_ms></usage>\n</task-notification>\nFull transcript available at: /tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl\nCall get_subagent_result(\"b913f6d2-2b1e-4be\") to collect the full result.",
"display": true,
"details": {
"id": "b913f6d2-2b1e-4be",
"description": "list files",
"status": "completed",
"toolUses": 1,
"turnCount": 3,
"totalTokens": 8843,
"durationMs": 3714,
"outputFile": "/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl",
"resultPreview": "The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty."
},
"timestamp": 1790197700057
},
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_380752",
"name": "get_subagent_result",
"arguments": {
"agent_id": "b913f6d2-2b1e-4be"
}
}
],
"api": "openai-completions",
"provider": "openrouter",
"model": "google/gemini-3.6-flash",
"usage": {
"input": 3383,
"output": 36,
"cacheRead": 3846,
"cacheWrite": 0,
"reasoning": 0,
"totalTokens": 7265,
"cost": {
"input": 0.00253725,
"output": 0.000135,
"cacheRead": 0.00028845,
"cacheWrite": 0,
"total": 0.0029607
}
},
"stopReason": "toolUse",
"timestamp": 1790197700058,
"responseId": "gen-1790197700-cKcLwp60V13TphEI0fPk",
"rawStopReason": "tool_calls"
},
{
"role": "toolResult",
"toolCallId": "call_380752",
"toolName": "get_subagent_result",
"content": [
{
"type": "text",
"text": "Agent: b913f6d2-2b1e-4be\nType: Agent | Status: completed | Tool uses: 1 | 8.8k token | Context: 1% | Duration: 3.7s\nDescription: list files\n\nThe workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.\n\nFull transcript available at: /tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl"
}
],
"details": {
"agentId": "b913f6d2-2b1e-4be",
"displayName": "Agent",
"status": "completed",
"description": "list files",
"toolUses": 1,
"tokens": "8.8k token",
"contextPercent": 0.6055831909179688,
"compactionCount": 0,
"duration": "3.7s",
"preview": "The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.",
"verbose": false,
"transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl"
},
"isError": false,
"timestamp": 1790197701316
}
]
}
@@ -1,4 +0,0 @@
{"type":"message","id":"813a565d","parentId":"2837e00c","timestamp":"2026-09-23T21:08:17.356Z","message":{"role":"user","content":[{"type":"text","text":"list files in this empty workspace"}],"timestamp":1790197697355}}
{"type":"message","id":"869bc287","parentId":"813a565d","timestamp":"2026-09-23T21:08:18.797Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"","thinkingSignature":"[{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a19A4eeufqsAJet9RoM5pf8bwBMUrJ4+R+m99YFQJ3pNYkkRTPI+5rHRCLH4sAKmyqJiSFTcFR+qKfVFQN91nm2RkncAczuy2UwYJ+mcvU1bG42y9Ca3gcGSZcBhP6IU6cu4KdfkqeEuBCx+bZQwylVRZlpiarhlv+fLQD/nE+m9KeFW+eZUJrIkd6nZYIdK0xAGVAxcbjDCh4rbZKMzCE7RB0G7adwu4ZxY2Sw1IVmIXz2Hgob+U1BXcm1YYNutIIDCN3q343v7QBEvmYpCm/ugM4C4KUrZHet8wudZUGmnAxDkVJxzBUjeUdmJiLDbJ/uCuRwjrvmxnaixsq0yeiSq4EisboKHtgWTMflaAAary+ClXQgY8sh00LjLQdO++Cp+Sn3per2ehlGW7680KBncXSDNiZXcjPJPRju6TRYseddNfdJdW6eDelqm6/GuBKX9hc8cVUT33kdkOqQDHbcHCw==\",\"format\":\"google-gemini-v1\",\"id\":\"call_342819\",\"index\":0}]"},{"type":"toolCall","id":"call_342819","name":"ls","arguments":{}}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":6230,"output":76,"cacheRead":0,"cacheWrite":0,"reasoning":68,"totalTokens":6306,"cost":{"input":0.0046725000000000004,"output":0.000285,"cacheRead":0,"cacheWrite":0,"total":0.0049575}},"stopReason":"toolUse","timestamp":1790197697356,"responseId":"gen-1790197697-mVNTI1th8irocnyQ3PSP","rawStopReason":"tool_calls"}}
{"type":"message","id":"e2c2c160","parentId":"869bc287","timestamp":"2026-09-23T21:08:18.799Z","message":{"role":"toolResult","toolCallId":"call_342819","toolName":"ls","content":[{"type":"text","text":"(empty directory)"}],"isError":false,"timestamp":1790197698799}}
{"type":"message","id":"97009824","parentId":"e2c2c160","timestamp":"2026-09-23T21:08:20.054Z","message":{"role":"assistant","content":[{"type":"text","text":"The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty."}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":2500,"output":37,"cacheRead":3813,"cacheWrite":0,"reasoning":16,"totalTokens":6350,"cost":{"input":0.0018750000000000001,"output":0.00013875,"cacheRead":0.000285975,"cacheWrite":0,"total":0.0022997250000000003}},"stopReason":"stop","timestamp":1790197698800,"responseId":"gen-1790197698-HsngQmcltNO0wKvZKyQe","rawStopReason":"stop"}}
@@ -1,111 +0,0 @@
{
"provenance": {
"package": "@gotgenes/pi-subagents",
"version": "21.7.6",
"sourceCommit": "1ac6bc6a36e6da5d00f1cd2482f1b971f5ea1fdf",
"piVersion": "0.84.4",
"captureDate": "2026-09-23"
},
"events": [
{
"type": "tool_execution_start",
"toolCallId": "call_502323",
"toolName": "subagent",
"args": {
"subagent_type": "general-purpose",
"description": "List workspace files",
"prompt": "list the files in this empty workspace."
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_502323",
"toolName": "subagent",
"result": {
"content": [
{
"type": "text",
"text": "Agent completed in 4.7s (1 tool uses, 9.0k token).\nAgent ID: 848583f5-6749-4a1\n\nThe workspace is empty."
}
],
"details": {
"displayName": "Agent",
"description": "List workspace files",
"subagentType": "general-purpose",
"tags": ["twin"],
"toolUses": 1,
"tokens": "9.0k token",
"turnCount": 3,
"durationMs": 4723,
"status": "completed",
"agentId": "848583f5-6749-4a1"
}
},
"isError": false
}
],
"messages": [
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_502323",
"name": "subagent",
"arguments": {
"subagent_type": "general-purpose",
"description": "List workspace files",
"prompt": "list the files in this empty workspace."
}
}
],
"api": "openai-completions",
"provider": "openrouter",
"model": "google/gemini-3.6-flash",
"usage": {
"input": 6584,
"output": 96,
"cacheRead": 0,
"cacheWrite": 0,
"reasoning": 55,
"totalTokens": 6680,
"cost": {
"input": 0.0049380000000000005,
"output": 0.00036,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0.005298000000000001
}
},
"stopReason": "toolUse",
"timestamp": 1790197658835,
"responseId": "gen-1790197658-Jx3ylOivC0gHsP3uBU0f",
"rawStopReason": "tool_calls"
},
{
"role": "toolResult",
"toolCallId": "call_502323",
"toolName": "subagent",
"content": [
{
"type": "text",
"text": "Agent completed in 4.7s (1 tool uses, 9.0k token).\nAgent ID: 848583f5-6749-4a1\n\nThe workspace is empty."
}
],
"details": {
"displayName": "Agent",
"description": "List workspace files",
"subagentType": "general-purpose",
"tags": ["twin"],
"toolUses": 1,
"tokens": "9.0k token",
"turnCount": 3,
"durationMs": 4723,
"status": "completed",
"agentId": "848583f5-6749-4a1"
},
"isError": false,
"timestamp": 1790197665254
}
]
}
@@ -1,86 +0,0 @@
import { describe, expect, test } from "vitest";
import { fileURLToPath } from "node:url";
import { createPiExtensionHost } from "../index.js";
import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js";
import { streamPiHistory } from "../../history-mapper.js";
describe("@gotgenes/pi-subagents adapter", () => {
test("maps captured foreground lifecycle live and on replay", async () => {
const events = await verifySubagentFixture(
readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)),
);
expect(
events
.filter((event) => event.event.type === "upsert")
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
).toEqual(["running", "completed"]);
});
test("correlates a background notification and result collection", async () => {
const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
const file = (
source.messages.find((message) => message.role === "custom") as {
details: { outputFile: string };
}
).details.outputFile;
const fixture = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url), {
from: file,
to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)),
});
const events = await verifySubagentFixture(fixture);
const upserts = events
.filter((event) => event.event.type === "upsert")
.map((event) => (event.event.type === "upsert" ? event.event : null));
expect(upserts.map((event) => event?.status)).toEqual([
"running",
"running",
"completed",
"completed",
]);
expect(new Set(upserts.map((event) => event?.id)).size).toBe(1);
expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0);
});
test("keeps claimed notification text visible in history", async () => {
const fixture = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
const text = fixture.messages.find((message) => message.role === "custom")?.content;
const events = [];
for await (const event of streamPiHistory("pi", fixture.messages)) events.push(event);
expect(events).toContainEqual({
type: "timeline",
provider: "pi",
item: { type: "assistant_message", text },
});
expect(events.some((event) => event.type === "provider_subagent")).toBe(true);
});
test("shares the subagent tool name with Nico without claiming Nico's args", () => {
const host = createPiExtensionHost();
const nico = host.mapToolCall({
callId: "nico",
toolName: "subagent",
args: { agent: "scout", task: "Inspect" },
status: "running",
result: null,
});
const gotgenes = host.mapToolCall({
callId: "gotgenes",
toolName: "subagent",
args: { subagent_type: "general-purpose", prompt: "Inspect" },
status: "running",
result: null,
});
expect(nico?.detail).toEqual(
expect.objectContaining({ type: "sub_agent", subAgentType: "scout" }),
);
expect(gotgenes?.detail).toEqual(
expect.objectContaining({ type: "sub_agent", subAgentType: "general-purpose" }),
);
expect(
host.mapToolCall({
callId: "foreign",
toolName: "subagent",
args: { description: "Inspect" },
status: "running",
result: null,
}),
).toBeUndefined();
});
});
@@ -1,157 +0,0 @@
import { z } from "zod";
import { extractTextFromToolResult } from "../../tool-call-mapper.js";
import type { PiExtension, PiExtensionToolCall } from "../contract.js";
const SpawnArgs = z
.object({
subagent_type: z.string().trim().min(1),
prompt: z.string().trim().min(1),
description: z.string().optional(),
})
.passthrough();
const FollowupArgs = z.object({ agent_id: z.string().trim().min(1) }).passthrough();
const Details = z
.object({
agentId: z.string().trim().min(1),
status: z.string(),
displayName: z.string().optional(),
description: z.string().optional(),
transcriptPath: z.string().trim().min(1).optional(),
})
.passthrough();
const Notification = z
.object({
id: z.string().trim().min(1),
status: z.string(),
description: z.string().optional(),
outputFile: z.string().trim().min(1).optional(),
})
.passthrough();
const status = (value: string): "running" | "completed" | "failed" | "canceled" => {
if (value === "completed") return "completed";
if (value === "error") return "failed";
if (value === "aborted" || value === "stopped") return "canceled";
return "running";
};
export const gotgenesPiSubagents: PiExtension = {
id: "@gotgenes/pi-subagents",
createSession: () => {
const callsByAgent = new Map<string, string>();
const readSessions = new Set<string>();
const mapSpawn = (call: PiExtensionToolCall) => {
const args = SpawnArgs.safeParse(call.args);
if (!args.success) return undefined;
const details = Details.safeParse(
typeof call.result === "object" ? call.result?.details : null,
);
const title = args.data.subagent_type;
const description = args.data.description ?? args.data.prompt;
const detail = {
type: "sub_agent" as const,
subAgentType: title,
description,
log: extractTextFromToolResult(call.result)?.trim() ?? "",
};
if (call.status === "running")
return {
detail,
subagents: [
{
type: "upsert" as const,
id: call.callId,
title,
description,
toolCallId: call.callId,
status: "running" as const,
},
],
};
if (!details.success)
return call.status === "failed"
? {
detail,
subagents: [{ type: "upsert" as const, id: call.callId, status: "failed" as const }],
}
: { detail };
callsByAgent.set(details.data.agentId, call.callId);
return {
detail,
subagents: [
{
type: "upsert" as const,
id: call.callId,
title,
description,
toolCallId: call.callId,
status: status(details.data.status),
},
],
};
};
const mapFollowup = (call: PiExtensionToolCall) => {
const args = FollowupArgs.safeParse(call.args);
if (!args.success) return undefined;
const id = callsByAgent.get(args.data.agent_id);
if (!id) return undefined;
const details = Details.safeParse(
typeof call.result === "object" ? call.result?.details : null,
);
const detail = {
type: "sub_agent" as const,
description: details.success ? details.data.description : undefined,
log: extractTextFromToolResult(call.result)?.trim() ?? "",
};
if (call.status === "running" || !details.success) return { detail };
const file = details.data.transcriptPath;
const childSessions =
file && status(details.data.status) !== "running" && !readSessions.has(file)
? [{ id, file }]
: [];
if (childSessions.length && file) readSessions.add(file);
return {
detail,
subagents: [{ type: "upsert" as const, id, status: status(details.data.status) }],
childSessions,
};
};
return {
mapToolCall(call) {
// This fork's result details resemble tintinweb's, but its spawn tool is `subagent` and its
// follow-up result shape differs. Keep each parser at its own package boundary.
if (call.toolName === "subagent") return mapSpawn(call);
if (call.toolName === "get_subagent_result" || call.toolName === "steer_subagent")
return mapFollowup(call);
return undefined;
},
mapCustomMessage(message) {
if (
message.customType !== "subagent-notification" &&
message.customType !== "subagent-update"
)
return undefined;
const details = Notification.safeParse(message.details);
if (!details.success) return undefined;
const id = callsByAgent.get(details.data.id);
if (!id) return undefined;
const file = details.data.outputFile;
const childSessions =
file && status(details.data.status) !== "running" && !readSessions.has(file)
? [{ id, file }]
: [];
if (childSessions.length && file) readSessions.add(file);
return {
subagents: [
{
type: "upsert",
id,
description: details.data.description,
status: status(details.data.status),
},
],
childSessions,
};
},
};
},
};
@@ -1,151 +0,0 @@
import type { Logger } from "pino";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentStreamEvent,
} from "../../../agent-sdk-types.js";
import type { PiAgentMessage } from "../rpc-types.js";
import { mapPiChildSession } from "./child-session.js";
import type {
PiExtension,
PiExtensionCustomMapping,
PiExtensionDialog,
PiExtensionDialogMapping,
PiExtensionSession,
PiExtensionToolCall,
PiExtensionToolMapping,
PiExtensionUiReply,
} from "./contract.js";
export type PiExtensionOutput<T> = T & {
events: AgentStreamEvent[];
hydration: Promise<AgentStreamEvent[]>;
};
export type PiExtensionEventOutput = PiExtensionOutput<
PiExtensionToolMapping | PiExtensionCustomMapping
>;
interface Session {
id: string;
adapter: PiExtensionSession;
}
export class PiExtensionHost {
private readonly sessions: Session[] = [];
private remainingHydrationBytes: number;
constructor(
extensions: readonly PiExtension[],
private readonly logger?: Pick<Logger, "warn">,
hydrationByteBudget = Number.POSITIVE_INFINITY,
private readonly readChildSession: typeof mapPiChildSession = mapPiChildSession,
) {
this.remainingHydrationBytes = hydrationByteBudget;
for (const extension of extensions) {
const adapter = this.safe(extension.id, "createSession", () => extension.createSession());
if (adapter) this.sessions.push({ id: extension.id, adapter });
}
}
private safe<T>(id: string, operation: string, call: () => T): T | undefined {
try {
return call();
} catch (error) {
this.logger?.warn({ err: error, extensionId: id, operation }, "Pi extension adapter failed");
return undefined;
}
}
mapToolCall(call: PiExtensionToolCall): PiExtensionOutput<PiExtensionToolMapping> | undefined {
for (const { id, adapter } of this.sessions) {
const mapping = this.safe(id, "mapToolCall", () => adapter.mapToolCall?.(call));
if (mapping) {
const prepared = this.safe(id, "prepare", () => this.prepare(id, mapping));
if (prepared) return prepared;
}
}
return undefined;
}
mapCustomMessage(
message: Extract<PiAgentMessage, { role: "custom" }>,
): PiExtensionOutput<PiExtensionCustomMapping> | undefined {
for (const { id, adapter } of this.sessions) {
const mapping = this.safe(id, "mapCustomMessage", () => adapter.mapCustomMessage?.(message));
if (mapping) {
const prepared = this.safe(id, "prepare", () => this.prepare(id, mapping));
if (prepared) return prepared;
}
}
return undefined;
}
private prepare<T extends PiExtensionToolMapping | PiExtensionCustomMapping>(
id: string,
mapping: T,
): PiExtensionOutput<T> {
const events: AgentStreamEvent[] = [
...("timeline" in mapping ? (mapping.timeline ?? []) : []).map(
(item): AgentStreamEvent => ({ type: "timeline", provider: "pi", item }),
),
...(mapping.subagents ?? []).map(
(event): AgentStreamEvent => ({ type: "provider_subagent", provider: "pi", event }),
),
];
const hydration = Promise.all(
(mapping.childSessions ?? []).map(async ({ id: childId, file }) => {
const bytes = Math.min(this.remainingHydrationBytes, 2 * 1024 * 1024);
if (bytes <= 0) return [];
this.remainingHydrationBytes -= bytes;
return (await this.readChildSession(childId, file, bytes)).map(
(event): AgentStreamEvent => ({ type: "provider_subagent", provider: "pi", event }),
);
}),
)
.then((groups) => groups.flat())
.catch((error): AgentStreamEvent[] => {
this.logger?.warn(
{ err: error, extensionId: id, operation: "hydrate" },
"Pi extension adapter failed",
);
return [];
});
return { ...mapping, events, hydration };
}
onToolStart(call: PiExtensionToolCall, provider = "pi"): AgentPermissionRequest | undefined {
let request: AgentPermissionRequest | undefined;
for (const { id, adapter } of this.sessions) {
const candidate = this.safe(id, "onToolStart", () => adapter.onToolStart?.(call, provider));
request ??= candidate;
}
return request;
}
onToolEnd(call: PiExtensionToolCall): void {
for (const { id, adapter } of this.sessions) {
this.safe(id, "onToolEnd", () => adapter.onToolEnd?.(call));
}
}
mapDialog(dialog: PiExtensionDialog, provider: string): PiExtensionDialogMapping | undefined {
for (const { id, adapter } of this.sessions) {
const mapping = this.safe(id, "mapDialog", () => adapter.mapDialog?.(dialog, provider));
if (mapping) return mapping;
}
return undefined;
}
respondToPermission(
request: AgentPermissionRequest,
response: AgentPermissionResponse,
): PiExtensionUiReply | undefined {
for (const { id, adapter } of this.sessions) {
const mapped = this.safe(id, "respondToPermission", () =>
adapter.respondToPermission?.(request, response),
);
if (mapped) return mapped;
}
return undefined;
}
}
@@ -1,203 +0,0 @@
import { describe, expect, test } from "vitest";
import { createPiExtensionHost } from "./index.js";
import { PiHistoryMapper } from "../history-mapper.js";
import { parseToolArgs, parseToolResult } from "../tool-call-mapper.js";
import type { PiExtension } from "./contract.js";
import { PiExtensionHost } from "./host.js";
const throwingAdapter: PiExtension = {
id: "throwing-test-adapter",
createSession: () => ({
mapToolCall: () => {
throw new Error("tool failed");
},
mapDialog: () => {
throw new Error("dialog failed");
},
respondToPermission: () => {
throw new Error("response failed");
},
onToolStart: () => {
throw new Error("start failed");
},
onToolEnd: () => {
throw new Error("end failed");
},
mapCustomMessage: () => {
throw new Error("custom failed");
},
}),
};
describe("Pi extension host", () => {
test("declines a throwing adapter and preserves generic history", async () => {
const host = createPiExtensionHost(undefined, [throwingAdapter]);
const mapper = new PiHistoryMapper("pi", [], {}, host);
const events = mapper.mapMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call-1", name: "other", arguments: {} }],
},
{
role: "toolResult",
toolCallId: "call-1",
toolName: "other",
content: [{ type: "text", text: "ok" }],
},
{ role: "assistant", content: [{ type: "text", text: "done" }] },
]);
expect(events.filter((event) => event.type === "timeline")).toHaveLength(3);
expect(await mapper.hydrate()).toEqual([]);
});
test("declines throwing lifecycle and dialog hooks", () => {
const host = createPiExtensionHost(undefined, [throwingAdapter]);
const call = {
callId: "x",
toolName: "other",
args: {},
status: "running" as const,
result: null,
};
expect(host.onToolStart(call)).toBeUndefined();
expect(() => host.onToolEnd(call)).not.toThrow();
expect(
host.mapDialog(
{
type: "extension_ui_request",
id: "ui-1",
method: "select",
title: "Pick",
options: ["A"],
},
"pi",
),
).toBeUndefined();
expect(
host.respondToPermission(
{ id: "ui-1", provider: "pi", kind: "question", title: "Pick", input: { questions: [] } },
{ behavior: "allow" },
),
).toBeUndefined();
expect(
host.mapCustomMessage({ role: "custom", customType: "other", content: "hello" }),
).toBeUndefined();
});
test("rejected child hydration emits no events", async () => {
const host = new PiExtensionHost(
[
{
id: "child",
createSession: () => ({
mapToolCall: () => ({ childSessions: [{ id: "child-1", file: "unused" }] }),
}),
},
],
undefined,
2 * 1024 * 1024,
async () => {
throw new Error("read failed");
},
);
const output = host.mapToolCall({
callId: "x",
toolName: "other",
args: {},
status: "completed",
result: null,
});
expect(await output?.hydration).toEqual([]);
});
test("limits total child reads during replay", async () => {
const reads: string[] = [];
const host = new PiExtensionHost(
[
{
id: "child",
createSession: () => ({
mapToolCall: () => ({
childSessions: Array.from({ length: 10 }, (_, index) => ({
id: String(index),
file: String(index),
})),
}),
}),
},
],
undefined,
16 * 1024 * 1024,
async (id) => {
reads.push(id);
return [];
},
);
const output = host.mapToolCall({
callId: "x",
toolName: "other",
args: {},
status: "completed",
result: null,
});
await output?.hydration;
expect(reads).toHaveLength(8);
});
test("claims a tool, declines an unrelated one, and maps history like live events", () => {
const host = createPiExtensionHost();
const args = { agent: "scout", task: "Inspect files" };
const result = parseToolResult({ content: [{ type: "text", text: "Found two files" }] });
const live = host.mapToolCall({
callId: "call-1",
toolName: "subagent",
args,
status: "completed",
result,
});
expect(live).toEqual(
expect.objectContaining({
detail: {
type: "sub_agent",
subAgentType: "scout",
description: "Inspect files",
log: "Found two files",
},
}),
);
expect(
host.mapToolCall({
callId: "call-2",
toolName: "unrelated",
args: {},
status: "completed",
result,
}),
).toBeUndefined();
const mapper = new PiHistoryMapper("pi");
const events = mapper.mapMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call-1", name: "subagent", arguments: args }],
},
{
role: "toolResult",
toolCallId: "call-1",
toolName: "subagent",
content: [{ type: "text", text: "Found two files" }],
},
]);
const completed = events.find(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "completed",
);
expect(
completed?.type === "timeline" && completed.item.type === "tool_call"
? completed.item.detail
: null,
).toEqual(live?.detail);
expect(parseToolArgs("subagent", args).toolName).toBe("subagent");
});
});
@@ -1,15 +0,0 @@
import { PiExtensionHost } from "./host.js";
import { piExtensions } from "./registry.js";
import type { Logger } from "pino";
import type { PiExtension } from "./contract.js";
export type { PiExtensionHost } from "./host.js";
export type { PiExtensionEventOutput } from "./host.js";
export function createPiExtensionHost(
logger?: Pick<Logger, "warn">,
extensions: readonly PiExtension[] = piExtensions,
hydrationByteBudget?: number,
): PiExtensionHost {
return new PiExtensionHost(extensions, logger, hydrationByteBudget);
}
@@ -1,70 +0,0 @@
import { describe, expect, test } from "vitest";
import { createPiExtensionHost } from "../index.js";
describe("pi-ask-user adapter", () => {
test("combines selection and comment, then answers the follow-up dialog", () => {
const host = createPiExtensionHost();
host.onToolStart({
callId: "ask-1",
toolName: "ask_user",
args: { allowComment: true, allowFreeform: false },
status: "running",
result: null,
});
const mapped = host.mapDialog(
{
type: "extension_ui_request",
id: "select-1",
method: "select",
title: "Pick one",
options: ["A", "B"],
},
"pi",
);
expect(mapped?.type).toBe("permission");
if (mapped?.type !== "permission") throw new Error("Expected a combined permission");
expect(mapped.request.input?.questions).toHaveLength(2);
expect(
host.respondToPermission(mapped.request, {
behavior: "allow",
updatedInput: { answers: { Response: "B", Comment: "Looks good" } },
}),
).toEqual({ responses: [{ id: "select-1", response: { value: "B" } }] });
expect(
host.mapDialog(
{
type: "extension_ui_request",
id: "comment-1",
method: "input",
placeholder: "Optional comment (press Enter to skip)...",
},
"pi",
),
).toEqual({ type: "response", response: { value: "Looks good" } });
});
test("declines unrelated dialogs and cancels denied selection", () => {
const host = createPiExtensionHost();
expect(
host.mapDialog(
{ type: "extension_ui_request", id: "other", method: "select", options: ["A"] },
"pi",
),
).toBeUndefined();
host.onToolStart({
callId: "ask-2",
toolName: "ask_user",
args: { allowComment: true },
status: "running",
result: null,
});
const mapped = host.mapDialog(
{ type: "extension_ui_request", id: "select-2", method: "select", options: ["A"] },
"pi",
);
if (mapped?.type !== "permission") throw new Error("Expected a combined permission");
expect(host.respondToPermission(mapped.request, { behavior: "deny" })).toEqual({
responses: [{ id: "select-2", response: { cancelled: true } }],
});
});
});
@@ -1,158 +0,0 @@
import { z } from "zod";
import type {
AgentMetadata,
AgentPermissionRequest,
AgentPermissionResponse,
} from "../../../../agent-sdk-types.js";
import type { PiExtension, PiExtensionDialog, PiExtensionUiReply } from "../contract.js";
const QUESTION_RESPONSE_HEADER = "Response";
const QUESTION_COMMENT_HEADER = "Comment";
const FREEFORM_SENTINEL = "✏️ Type custom response...";
const COMBINED_METADATA = "ask_user_select_optional_comment";
const Args = z
.object({
allowComment: z.boolean().catch(false),
allowFreeform: z.boolean().catch(true),
allowMultiple: z.boolean().catch(false),
})
.passthrough();
const Select = z
.object({ title: z.string().optional(), options: z.array(z.string()).optional() })
.passthrough();
const Input = z.object({ placeholder: z.string().optional() }).passthrough();
function answer(input: AgentMetadata | undefined, header: string): string | null {
const answers = input?.answers;
if (!answers || typeof answers !== "object" || Array.isArray(answers)) return null;
const value = Reflect.get(answers, header);
return typeof value === "string" ? value : null;
}
function optionalInput(placeholder: string | undefined): boolean {
return /\boptional\b|\bskip\b/i.test(placeholder ?? "");
}
function combinedPermission(
dialog: PiExtensionDialog,
provider: string,
options: string[],
allowFreeform: boolean,
question: string,
): AgentPermissionRequest {
const visibleOptions = options.filter((option) => option !== FREEFORM_SENTINEL);
const allowOther = allowFreeform || visibleOptions.length !== options.length;
return {
id: dialog.id,
provider,
name: "Pi ask_user",
kind: "question",
title: question,
input: {
questions: [
{
question,
header: QUESTION_RESPONSE_HEADER,
options: visibleOptions.map((label) => ({ label })),
multiSelect: false,
...(allowOther ? { allowOther: true } : {}),
},
{
question: "Optional comment",
header: QUESTION_COMMENT_HEADER,
options: [],
multiSelect: false,
placeholder: "Optional comment (press Enter to skip)...",
allowEmpty: true,
},
],
},
metadata: {
extensionUiMethod: dialog.method,
answerHeader: QUESTION_RESPONSE_HEADER,
commentHeader: QUESTION_COMMENT_HEADER,
combinedAskUser: COMBINED_METADATA,
selectOptions: visibleOptions,
...(allowOther ? { freeformSentinel: FREEFORM_SENTINEL } : {}),
},
};
}
export const piAskUser: PiExtension = {
id: "pi-ask-user",
createSession() {
let active: z.infer<typeof Args> | null = null;
let pending: { comment: string; freeform: string | null } | null = null;
return {
onToolStart(call) {
active = call.toolName === "ask_user" ? (Args.safeParse(call.args).data ?? null) : null;
return undefined;
},
onToolEnd(call) {
if (call.toolName === "ask_user") {
active = null;
pending = null;
}
},
mapDialog(dialog, provider) {
if (pending && dialog.method === "input") {
const input = Input.safeParse(dialog);
const placeholder = input.success ? input.data.placeholder : undefined;
if (pending.freeform !== null && !optionalInput(placeholder)) {
const value = pending.freeform;
pending = { ...pending, freeform: null };
return { type: "response", response: { value } };
}
if (optionalInput(placeholder)) {
const value = pending.comment;
pending = null;
return { type: "response", response: { value } };
}
}
if (dialog.method !== "select" || !active?.allowComment || active.allowMultiple)
return undefined;
const select = Select.safeParse(dialog);
const options = select.success ? (select.data.options ?? []) : [];
const question = select.success
? (select.data.title ?? "Select an option")
: "Select an option";
return {
type: "permission",
request: combinedPermission(dialog, provider, options, active.allowFreeform, question),
};
},
respondToPermission(
request: AgentPermissionRequest,
response: AgentPermissionResponse,
): PiExtensionUiReply | undefined {
if (request.metadata?.combinedAskUser !== COMBINED_METADATA) return undefined;
if (response.behavior === "deny") {
pending = null;
return { responses: [{ id: request.id, response: { cancelled: true } }] };
}
const selected = answer(response.updatedInput, QUESTION_RESPONSE_HEADER);
if (selected === null) {
pending = null;
return { responses: [{ id: request.id, response: { cancelled: true } }] };
}
const selectOptions = Array.isArray(request.metadata?.selectOptions)
? request.metadata.selectOptions.filter(
(item): item is string => typeof item === "string",
)
: [];
const sentinel =
typeof request.metadata?.freeformSentinel === "string"
? request.metadata.freeformSentinel
: undefined;
const isFreeform = Boolean(sentinel) && !selectOptions.includes(selected);
pending = {
comment: answer(response.updatedInput, QUESTION_COMMENT_HEADER) ?? "",
freeform: isFreeform ? selected : null,
};
return {
responses: [{ id: request.id, response: { value: isFreeform ? sentinel : selected } }],
};
},
};
},
};
@@ -1,250 +0,0 @@
{
"provenance": {
"package": "@earendil-works/pi-coding-agent examples/extensions/todo.ts",
"version": "0.87.1",
"sourceCommit": "7fd564cbb78f35f3de14d5382fea692b87ec4026",
"piVersion": "0.84.4",
"captureDate": "2026-09-23",
"mode": "pi --mode rpc; OpenRouter google/gemini-3.6-flash"
},
"events": [
{
"type": "tool_execution_start",
"toolCallId": "call_433291",
"toolName": "todo",
"args": {
"action": "add",
"text": "Inspect workspace"
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_433291",
"toolName": "todo",
"result": {
"content": [
{
"type": "text",
"text": "Added todo #1: Inspect workspace"
}
],
"details": {
"action": "add",
"todos": [
{
"id": 1,
"text": "Inspect workspace",
"done": false
}
],
"nextId": 2
}
},
"isError": false
},
{
"type": "tool_execution_start",
"toolCallId": "call_453655",
"toolName": "todo",
"args": {
"action": "add",
"text": "Summarize findings"
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_453655",
"toolName": "todo",
"result": {
"content": [
{
"type": "text",
"text": "Added todo #2: Summarize findings"
}
],
"details": {
"action": "add",
"todos": [
{
"id": 1,
"text": "Inspect workspace",
"done": false
},
{
"id": 2,
"text": "Summarize findings",
"done": false
}
],
"nextId": 3
}
},
"isError": false
},
{
"type": "tool_execution_start",
"toolCallId": "call_525797",
"toolName": "todo",
"args": {
"action": "toggle",
"id": 1
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_525797",
"toolName": "todo",
"result": {
"content": [
{
"type": "text",
"text": "Todo #1 completed"
}
],
"details": {
"action": "toggle",
"todos": [
{
"id": 1,
"text": "Inspect workspace",
"done": true
},
{
"id": 2,
"text": "Summarize findings",
"done": false
}
],
"nextId": 3
}
},
"isError": false
}
],
"messages": [
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_433291",
"name": "todo",
"arguments": {
"action": "add",
"text": "Inspect workspace"
}
}
]
},
{
"role": "toolResult",
"toolCallId": "call_433291",
"toolName": "todo",
"content": [
{
"type": "text",
"text": "Added todo #1: Inspect workspace"
}
],
"details": {
"action": "add",
"todos": [
{
"id": 1,
"text": "Inspect workspace",
"done": false
}
],
"nextId": 2
},
"isError": false,
"timestamp": 1790195609694
},
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_453655",
"name": "todo",
"arguments": {
"action": "add",
"text": "Summarize findings"
}
}
]
},
{
"role": "toolResult",
"toolCallId": "call_453655",
"toolName": "todo",
"content": [
{
"type": "text",
"text": "Added todo #2: Summarize findings"
}
],
"details": {
"action": "add",
"todos": [
{
"id": 1,
"text": "Inspect workspace",
"done": false
},
{
"id": 2,
"text": "Summarize findings",
"done": false
}
],
"nextId": 3
},
"isError": false,
"timestamp": 1790195610962
},
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_525797",
"name": "todo",
"arguments": {
"action": "toggle",
"id": 1
}
}
]
},
{
"role": "toolResult",
"toolCallId": "call_525797",
"toolName": "todo",
"content": [
{
"type": "text",
"text": "Todo #1 completed"
}
],
"details": {
"action": "toggle",
"todos": [
{
"id": 1,
"text": "Inspect workspace",
"done": true
},
{
"id": 2,
"text": "Summarize findings",
"done": false
}
],
"nextId": 3
},
"isError": false,
"timestamp": 1790195612441
}
]
}
@@ -1,16 +0,0 @@
import { expect, test } from "vitest";
import { capturedToolCall, verifyTaskFixture } from "../task-fixture-test.js";
import { piExampleTodo } from "./index.js";
test("Pi example todo maps captured RPC tasks through live and history", async () => {
await verifyTaskFixture(new URL("./fixtures/rpc-session.json", import.meta.url), [
"pending",
"pending,pending",
"completed,pending",
]);
});
test("example todo declines rpiv-todo's shape", () => {
const call = capturedToolCall(new URL("../rpiv-todo/fixtures/rpc-session.json", import.meta.url));
expect(piExampleTodo.createSession().mapToolCall?.(call)).toBeUndefined();
});
@@ -1,41 +0,0 @@
import { z } from "zod";
import type { PiExtension } from "../contract.js";
const Details = z.object({
action: z.enum(["list", "add", "toggle", "clear"]),
nextId: z.number().int(),
todos: z.array(z.object({ id: z.number().int(), text: z.string(), done: z.boolean() })),
error: z.string().optional(),
});
export const piExampleTodo: PiExtension = {
id: "pi-example-todo",
createSession: () => ({
mapToolCall(call) {
if (
call.toolName !== "todo" ||
call.status !== "completed" ||
!call.result ||
typeof call.result === "string"
)
return undefined;
const parsed = Details.safeParse(call.result.details);
if (!parsed.success) return undefined;
return {
timeline: parsed.data.error
? []
: [
{
type: "todo",
items: parsed.data.todos.map((todo) => ({
id: String(todo.id),
text: todo.text,
status: todo.done ? "completed" : "pending",
completed: todo.done,
})),
},
],
};
},
}),
};
@@ -1,422 +0,0 @@
{
"provenance": {
"package": "pi-goal-x",
"version": "0.31.8",
"sourceCommit": "f366b5cdf36e9329c980f8c44b0d300c910e2f41",
"piVersion": "0.84.4",
"captureDate": "2026-09-23",
"mode": "pi --mode rpc; OpenRouter google/gemini-3.6-flash"
},
"events": [
{
"type": "tool_execution_start",
"toolCallId": "call_476725",
"toolName": "create_goal",
"args": {
"objective": "Inspect the empty workspace"
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_476725",
"toolName": "create_goal",
"result": {
"content": [
{
"type": "text",
"text": "Goal confirmed and created.\n\nFinalized goal:\n\nInspect the empty workspace\n\nGoal details:\nBudget: none\nGoal: Inspect the empty workspace\nStatus: running\nAuto-continue: on\nTime spent: 0s\nTokens used: 0 tokens\nFile: .pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md\n1 other open goal remain in .pi/goals — this goal is now the session focus."
}
],
"details": {
"version": 3,
"goal": {
"id": "muekaval-uhhfg8",
"objective": "Inspect the empty workspace",
"status": "active",
"autoContinue": true,
"usage": {
"tokensUsed": 0,
"activeSeconds": 0
},
"sisyphus": false,
"revision": 0,
"createdAt": "2026-09-23T20:35:06.093Z",
"updatedAt": "2026-09-23T20:35:06.093Z",
"scheduler": {
"version": 1,
"owner": "01a0cffa-99a6-7e64-888a-debde9407a25",
"generation": "048961d8-6a89-471e-9562-0c09798cc070",
"used": 0,
"phase": "idle",
"repairUsed": false
},
"activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md"
}
},
"terminate": true,
"addedToolNames": ["update_goal", "set_goal_tasks"]
},
"isError": false
},
{
"type": "tool_execution_start",
"toolCallId": "call_434120",
"toolName": "set_goal_tasks",
"args": {
"tasks": [
{
"title": "Inspect workspace",
"id": "task-1"
},
{
"title": "Summarize findings",
"id": "task-2"
}
]
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_434120",
"toolName": "set_goal_tasks",
"result": {
"content": [
{
"type": "text",
"text": "Task list set and confirmed. 2 tasks."
}
],
"details": {
"version": 3,
"goal": {
"id": "muekaval-uhhfg8",
"objective": "Inspect the empty workspace",
"status": "active",
"autoContinue": true,
"usage": {
"tokensUsed": 6287,
"activeSeconds": 0
},
"sisyphus": false,
"revision": 5,
"createdAt": "2026-09-23T20:35:06.093Z",
"updatedAt": "2026-09-23T20:35:08.560Z",
"scheduler": {
"version": 1,
"owner": "01a0cffa-99a6-7e64-888a-debde9407a25",
"generation": "3eb8953c-0d95-44d9-b319-b4c4ab9d2cfe",
"used": 0,
"phase": "idle",
"repairUsed": false
},
"activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md",
"taskList": {
"tasks": [
{
"id": "task-1",
"title": "Inspect workspace",
"status": "pending"
},
{
"id": "task-2",
"title": "Summarize findings",
"status": "pending"
}
],
"blockCompletion": false,
"proposedAt": "2026-09-23T20:35:08.559Z"
}
}
},
"terminate": true,
"addedToolNames": ["update_goal_task"]
},
"isError": false
},
{
"type": "tool_execution_start",
"toolCallId": "call_23432",
"toolName": "update_goal_task",
"args": {
"updates": [
{
"status": "start",
"task_id": "task-1"
},
{
"status": "complete",
"task_id": "task-1"
}
]
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_23432",
"toolName": "update_goal_task",
"result": {
"content": [
{
"type": "text",
"text": "task-1 start; task-1 complete. 1/2 tasks complete."
}
],
"details": {
"version": 3,
"goal": {
"id": "muekaval-uhhfg8",
"objective": "Inspect the empty workspace",
"status": "active",
"autoContinue": true,
"usage": {
"tokensUsed": 13828,
"activeSeconds": 2
},
"sisyphus": false,
"createdAt": "2026-09-23T20:35:06.093Z",
"updatedAt": "2026-09-23T20:35:10.977Z",
"activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md",
"revision": 11,
"scheduler": {
"version": 1,
"owner": "01a0cffa-99a6-7e64-888a-debde9407a25",
"generation": "d4598e43-6c04-47ab-a004-0f335da65426",
"used": 0,
"phase": "idle",
"repairUsed": false
},
"taskList": {
"tasks": [
{
"id": "task-1",
"title": "Inspect workspace",
"status": "complete",
"completedAt": "2026-09-23T20:35:10.976Z"
},
{
"id": "task-2",
"title": "Summarize findings",
"status": "pending"
}
],
"blockCompletion": false,
"proposedAt": "2026-09-23T20:35:08.559Z"
}
}
}
},
"isError": false
}
],
"messages": [
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_476725",
"name": "create_goal",
"arguments": {
"objective": "Inspect the empty workspace"
}
}
]
},
{
"role": "toolResult",
"toolCallId": "call_476725",
"toolName": "create_goal",
"content": [
{
"type": "text",
"text": "Goal confirmed and created.\n\nFinalized goal:\n\nInspect the empty workspace\n\nGoal details:\nBudget: none\nGoal: Inspect the empty workspace\nStatus: running\nAuto-continue: on\nTime spent: 0s\nTokens used: 0 tokens\nFile: .pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md\n1 other open goal remain in .pi/goals — this goal is now the session focus."
}
],
"details": {
"version": 3,
"goal": {
"id": "muekaval-uhhfg8",
"objective": "Inspect the empty workspace",
"status": "active",
"autoContinue": true,
"usage": {
"tokensUsed": 0,
"activeSeconds": 0
},
"sisyphus": false,
"revision": 0,
"createdAt": "2026-09-23T20:35:06.093Z",
"updatedAt": "2026-09-23T20:35:06.093Z",
"scheduler": {
"version": 1,
"owner": "01a0cffa-99a6-7e64-888a-debde9407a25",
"generation": "048961d8-6a89-471e-9562-0c09798cc070",
"used": 0,
"phase": "idle",
"repairUsed": false
},
"activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md"
}
},
"addedToolNames": ["update_goal", "set_goal_tasks"],
"isError": false,
"timestamp": 1790195706099
},
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_434120",
"name": "set_goal_tasks",
"arguments": {
"tasks": [
{
"title": "Inspect workspace",
"id": "task-1"
},
{
"title": "Summarize findings",
"id": "task-2"
}
]
}
}
]
},
{
"role": "toolResult",
"toolCallId": "call_434120",
"toolName": "set_goal_tasks",
"content": [
{
"type": "text",
"text": "Task list set and confirmed. 2 tasks."
}
],
"details": {
"version": 3,
"goal": {
"id": "muekaval-uhhfg8",
"objective": "Inspect the empty workspace",
"status": "active",
"autoContinue": true,
"usage": {
"tokensUsed": 6287,
"activeSeconds": 0
},
"sisyphus": false,
"revision": 5,
"createdAt": "2026-09-23T20:35:06.093Z",
"updatedAt": "2026-09-23T20:35:08.560Z",
"scheduler": {
"version": 1,
"owner": "01a0cffa-99a6-7e64-888a-debde9407a25",
"generation": "3eb8953c-0d95-44d9-b319-b4c4ab9d2cfe",
"used": 0,
"phase": "idle",
"repairUsed": false
},
"activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md",
"taskList": {
"tasks": [
{
"id": "task-1",
"title": "Inspect workspace",
"status": "pending"
},
{
"id": "task-2",
"title": "Summarize findings",
"status": "pending"
}
],
"blockCompletion": false,
"proposedAt": "2026-09-23T20:35:08.559Z"
}
}
},
"addedToolNames": ["update_goal_task"],
"isError": false,
"timestamp": 1790195708561
},
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_23432",
"name": "update_goal_task",
"arguments": {
"updates": [
{
"status": "start",
"task_id": "task-1"
},
{
"status": "complete",
"task_id": "task-1"
}
]
}
}
]
},
{
"role": "toolResult",
"toolCallId": "call_23432",
"toolName": "update_goal_task",
"content": [
{
"type": "text",
"text": "task-1 start; task-1 complete. 1/2 tasks complete."
}
],
"details": {
"version": 3,
"goal": {
"id": "muekaval-uhhfg8",
"objective": "Inspect the empty workspace",
"status": "active",
"autoContinue": true,
"usage": {
"tokensUsed": 13828,
"activeSeconds": 2
},
"sisyphus": false,
"createdAt": "2026-09-23T20:35:06.093Z",
"updatedAt": "2026-09-23T20:35:10.977Z",
"activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md",
"revision": 11,
"scheduler": {
"version": 1,
"owner": "01a0cffa-99a6-7e64-888a-debde9407a25",
"generation": "d4598e43-6c04-47ab-a004-0f335da65426",
"used": 0,
"phase": "idle",
"repairUsed": false
},
"taskList": {
"tasks": [
{
"id": "task-1",
"title": "Inspect workspace",
"status": "complete",
"completedAt": "2026-09-23T20:35:10.976Z"
},
{
"id": "task-2",
"title": "Summarize findings",
"status": "pending"
}
],
"blockCompletion": false,
"proposedAt": "2026-09-23T20:35:08.559Z"
}
}
},
"isError": false,
"timestamp": 1790195710978
}
]
}
@@ -1,15 +0,0 @@
import { expect, test } from "vitest";
import { capturedToolCall, verifyTaskFixture } from "../task-fixture-test.js";
import { piGoalX } from "./index.js";
test("pi-goal-x maps captured RPC task tree through live and history", async () => {
await verifyTaskFixture(new URL("./fixtures/rpc-session.json", import.meta.url), [
"pending,pending",
"completed,pending",
]);
});
test("pi-goal-x declines a foreign goal shape", () => {
const call = capturedToolCall(new URL("../rpiv-todo/fixtures/rpc-session.json", import.meta.url));
expect(piGoalX.createSession().mapToolCall?.({ ...call, toolName: "get_goal" })).toBeUndefined();
});
@@ -1,73 +0,0 @@
import { z } from "zod";
import type { AgentTaskItem } from "../../../../agent-sdk-types.js";
import type { PiExtension } from "../contract.js";
interface GoalTask {
id: string;
title: string;
status: "pending" | "complete" | "skipped";
subtasks?: GoalTask[];
}
const Task: z.ZodType<GoalTask> = z.lazy(() =>
z.object({
id: z.string(),
title: z.string(),
status: z.enum(["pending", "complete", "skipped"]),
subtasks: z.array(Task).optional(),
}),
);
const Details = z.object({
version: z.literal(3),
goal: z
.object({
currentTaskId: z.string().optional(),
taskList: z.object({ tasks: z.array(Task) }).optional(),
})
.nullable(),
});
const toolNames = new Set([
"create_goal",
"get_goal",
"update_goal",
"set_goal_tasks",
"update_goal_task",
]);
function toItems(tasks: GoalTask[], currentTaskId?: string): AgentTaskItem[] {
return tasks.flatMap((task) => {
let status: AgentTaskItem["status"] = "pending";
if (task.status !== "pending") status = "completed";
else if (task.id === currentTaskId) status = "in_progress";
return [
{
id: task.id,
text: task.title,
status,
completed: task.status !== "pending",
},
...toItems(task.subtasks ?? [], currentTaskId),
];
});
}
export const piGoalX: PiExtension = {
id: "pi-goal-x",
createSession: () => ({
mapToolCall(call) {
if (
!toolNames.has(call.toolName) ||
call.status !== "completed" ||
!call.result ||
typeof call.result === "string"
)
return undefined;
const parsed = Details.safeParse(call.result.details);
if (!parsed.success) return undefined;
const goal = parsed.data.goal;
if (!goal?.taskList) return undefined;
return {
timeline: [{ type: "todo", items: toItems(goal.taskList.tasks, goal.currentTaskId) }],
};
},
}),
};
@@ -1,49 +0,0 @@
import { describe, expect, test } from "vitest";
import { createPiExtensionHost } from "../index.js";
import {
parseToolArgs,
parseToolResult,
type PiToolResult,
type PiTrackedToolCall,
} from "../../tool-call-mapper.js";
function mapping(toolCall: PiTrackedToolCall, result: PiToolResult) {
return createPiExtensionHost().mapToolCall({
callId: "test-call",
toolName: toolCall.toolName,
args: toolCall.args,
status: result ? "completed" : "running",
result,
});
}
function resolveToolCallName(toolCall: PiTrackedToolCall, result: PiToolResult) {
return mapping(toolCall, result)?.name ?? toolCall.toolName;
}
describe("pi-mcp-adapter adapter", () => {
test("normalizes Pi MCP proxy calls from requested tool args while running", () => {
const toolCall = parseToolArgs("mcp", {
tool: "paseo_list_models",
args: '{"provider":"pi"}',
});
expect(resolveToolCallName(toolCall, null)).toBe("paseo.list_models");
});
test("normalizes Pi MCP proxy calls from result details when completed", () => {
const toolCall = parseToolArgs("mcp", {
tool: "paseo_list_models",
args: '{"provider":"pi"}',
});
const result = parseToolResult({
content: [{ type: "text", text: "(empty result)" }],
details: {
mode: "call",
server: "paseo",
tool: "list_models",
},
});
expect(resolveToolCallName(toolCall, result)).toBe("paseo.list_models");
});
});

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