mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f5224122a | ||
|
|
af27e28054 |
+2
-6
@@ -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.
|
||||
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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." };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
-248
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
-4
@@ -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"}}
|
||||
-111
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
-86
@@ -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();
|
||||
});
|
||||
});
|
||||
-157
@@ -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 } }],
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
-250
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
-16
@@ -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,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
-422
@@ -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
Reference in New Issue
Block a user