Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andluckxine 138d9d63ab fix(android): keep panel overscroll inside inverted timelines
Android forwards a nested panel’s unconsumed scroll and fling to its inverted parent, moving the timeline in the opposite visual direction. Keep that motion inside the panel while preserving ordinary parent scrolling and nested-scroll registration.

Co-authored-by: luckxine <91582901+luckxine@users.noreply.github.com>
2026-11-01 14:32:01 +00:00
60 changed files with 452 additions and 3775 deletions
+11 -1
View File
@@ -174,7 +174,7 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`. This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
## Inverted timeline selection ## Inverted timeline scrolling
Android focus and selection visibility requests must not reposition inverted timelines. The Android focus and selection visibility requests must not reposition inverted timelines. The
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for `modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
@@ -183,6 +183,16 @@ Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotat
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll `scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
commands still work; non-inverted scroll views keep Android's default behavior. commands still work; non-inverted scroll views keep Android's default behavior.
Nested panels also keep their edge overshoot and unconsumed flings inside the panel instead of
moving the inverted timeline in the opposite direction. Nested-scroll registration and scrolling
inside the panel are preserved. Run the native selection and nested-scroll regressions after prebuild:
```bash
cd packages/app/android
./gradlew :paseo-scroll:connectedDebugAndroidTest \
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
Register this package before React Native's core package through its Expo config plugin. Normal Register this package before React Native's core package through its Expo config plugin. Normal
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
not change the shipped scroll view. Keep this behavior in the app's compiled native module. not change the shipped scroll view. Keep this behavior in the app's compiled native module.
+3 -3
View File
@@ -374,10 +374,10 @@ changes both `usePaseo()` and `useRpc()` to that host's installation. An offline
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess. installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation. Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
Host handles and host-targeted clients are owned by `packages/app/src/plugins/hosts`. Its `projectHost` is the secret boundary; host target input uses the shared `parseHostTarget` grammar. Per-installation Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation
bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not
resolve ownership through a mutable current-plugin global. Keep observation ownership in this module resolve ownership through a mutable current-plugin global. Keep observation ownership in this module
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#hosts). and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
@@ -396,7 +396,7 @@ in-memory bundles usable, and a later catalog retries persistence.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#hosts). query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
Workspace panels, Command Center items, and client slash commands are client contributions. The Workspace panels, Command Center items, and client slash commands are client contributions. The
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
@@ -3,7 +3,7 @@ import {
composerLocator, composerLocator,
expectComposerDraft, expectComposerDraft,
expectComposerVisible, expectComposerVisible,
fillComposerDraft, submitMessage,
typeIntoFocusedComposer, typeIntoFocusedComposer,
} from "../support/helpers/composer"; } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -12,14 +12,8 @@ function userMessages(page: Page) {
return page.getByTestId("user-message"); return page.getByTestId("user-message");
} }
async function sendDraftWhenReady(page: Page) {
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await pressInComposer(page, "Enter");
}
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) { async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
await fillComposerDraft(page, text); await submitMessage(page, text);
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 }); await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, ""); await expectComposerDraft(page, "");
} }
@@ -55,7 +49,7 @@ test.describe("Composer recall", () => {
await pressInComposer(page, "ArrowUp"); await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login"); await expectComposerDraft(page, "add tests for login");
await sendDraftWhenReady(page); await pressInComposer(page, "Enter");
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 }); await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login"); await expect(userMessages(page).last()).toContainText("add tests for login");
} finally { } finally {
@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name="sh.paseo.scroll.NestedScrollActivity" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,134 @@
package sh.paseo.scroll
import android.app.Activity
import android.content.Intent
import android.os.SystemClock
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.JavaOnlyArray
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.uimanager.ReactStylesDiffMap
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
import com.facebook.soloader.SoLoader
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
class NestedScrollActivity : Activity()
@RunWith(AndroidJUnit4::class)
class NestedScrollTest {
@Test fun invertedTimelineDoesNotConsumePanelOvershoot() = withNestedScroll(inverted = true) { parent, child ->
val before = parent.scrollY
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.scrollTo(0, 600)
assertEquals(600, child.scrollY)
assertTrue(child.dispatchNestedScroll(0, 0, 0, 100, null))
assertEquals("Panel overshoot must not move the inverted timeline", before, parent.scrollY)
child.scrollTo(0, 0)
assertTrue(child.dispatchNestedScroll(0, 0, 0, -100, null))
assertEquals("Overshoot at either edge must stay inside the panel", before, parent.scrollY)
child.stopNestedScroll()
}
@Test fun ordinaryParentStillConsumesPanelOvershoot() = withNestedScroll(inverted = false) { parent, child ->
val before = parent.scrollY
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.scrollTo(0, 600)
assertEquals(600, child.scrollY)
assertTrue(child.dispatchNestedScroll(0, 0, 0, 100, null))
assertEquals(before + 100, parent.scrollY)
child.stopNestedScroll()
}
@Test fun panelCanStillScrollInsideInvertedTimeline() = withNestedScroll(inverted = true) { parent, child ->
val before = parent.scrollY
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.scrollTo(0, 100)
assertTrue(child.dispatchNestedScroll(0, 100, 0, 0, null))
assertEquals(100, child.scrollY)
assertEquals(before, parent.scrollY)
child.stopNestedScroll()
parent.scrollTo(0, before + 100)
assertEquals(before + 100, parent.scrollY)
}
@Test fun invertedTimelineAbsorbsUnconsumedPanelFling() = checkFling(inverted = true)
@Test fun ordinaryParentStillAcceptsUnconsumedPanelFling() = checkFling(inverted = false)
private fun checkFling(inverted: Boolean) = withNestedScroll(
inverted = inverted,
afterFrames = { parent ->
if (inverted) assertEquals("Panel fling must not move the inverted timeline", 400, parent.scrollY)
else assertTrue("Control fling must move an ordinary parent", parent.scrollY > 400)
}
) { parent, child ->
parent.scrollTo(0, 400)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.scrollTo(0, 600)
assertEquals(600, child.scrollY)
assertTrue(child.dispatchNestedFling(0f, 1500f, false))
child.stopNestedScroll()
}
private fun withNestedScroll(
inverted: Boolean,
afterFrames: ((ReactScrollView) -> Unit)? = null,
check: (ReactScrollView, ReactScrollView) -> Unit
) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val activity = instrumentation.startActivitySync(
Intent(instrumentation.context, NestedScrollActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
lateinit var parent: ReactScrollView
try {
instrumentation.runOnMainSync {
val appContext = instrumentation.targetContext
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
SoLoader.init(appContext, OpenSourceMergedSoMapping)
val reactContext = ScrollTestContext(appContext)
val context = ThemedReactContext(reactContext, appContext)
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
parent = manager.createViewInstance(context)
val child = manager.createViewInstance(context).apply { isNestedScrollingEnabled = true }
val panelContent = View(context)
child.addView(panelContent, FrameLayout.LayoutParams(300, 800))
val content = FrameLayout(context)
content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 500 })
parent.addView(content, FrameLayout.LayoutParams(300, 2000))
panelContent.measure(exact(300), exact(800))
panelContent.layout(0, 0, 300, 800)
child.measure(exact(300), exact(200))
child.layout(0, 500, 300, 700)
content.measure(exact(300), exact(2000))
content.layout(0, 0, 300, 2000)
parent.measure(exact(300), exact(400))
parent.layout(0, 0, 300, 400)
// The same transform React Native applies to an inverted FlatList on Android.
manager.updateProperties(parent, ReactStylesDiffMap(JavaOnlyMap.of(
"transform", JavaOnlyArray.of(JavaOnlyMap.of("scale", if (inverted) -1.0 else 1.0))
)))
activity.setContentView(parent, ViewGroup.LayoutParams(300, 400))
parent.scrollTo(0, 400)
check(parent, child)
}
if (afterFrames != null) {
// Let the framework's real fling animation advance, with an ordinary-parent control.
SystemClock.sleep(500)
instrumentation.runOnMainSync { afterFrames(parent) }
}
} finally {
instrumentation.runOnMainSync { activity.finish() }
}
}
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
}
@@ -8,7 +8,6 @@ import android.widget.FrameLayout
import android.widget.TextView import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.BridgeReactContext
import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyArray
import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.soloader.OpenSourceMergedSoMapping import com.facebook.react.soloader.OpenSourceMergedSoMapping
@@ -70,7 +69,7 @@ class ScrollSelectionTest {
val appContext = instrumentation.targetContext val appContext = instrumentation.targetContext
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext) DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
SoLoader.init(appContext, OpenSourceMergedSoMapping) SoLoader.init(appContext, OpenSourceMergedSoMapping)
val reactContext = BridgeReactContext(appContext) val reactContext = ScrollTestContext(appContext)
val context = ThemedReactContext(reactContext, appContext) val context = ThemedReactContext(reactContext, appContext)
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
val scroll = manager.createViewInstance(context) val scroll = manager.createViewInstance(context)
@@ -0,0 +1,19 @@
package sh.paseo.scroll;
import android.content.Context;
import com.facebook.react.bridge.BridgeReactContext;
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcherProvider;
/** Native-only tests use RN's dispatcher for a context without a JS instance. */
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
ScrollTestContext(Context context) {
super(context);
}
@Override
public EventDispatcher getEventDispatcher() {
return BlackHoleEventDispatcher.INSTANCE;
}
}
@@ -2,6 +2,7 @@ package sh.paseo.scroll
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.Rect import android.graphics.Rect
import android.view.View
import com.facebook.react.ReactPackage import com.facebook.react.ReactPackage
import com.facebook.react.ViewManagerOnDemandReactPackage import com.facebook.react.ViewManagerOnDemandReactPackage
import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.NativeModule
@@ -30,13 +31,30 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) { private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
private val transformValues = FloatArray(9) private val transformValues = FloatArray(9)
private val isInverted: Boolean
get() {
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
matrix.getValues(transformValues)
return transformValues[Matrix.MSCALE_Y] < 0
}
// Both RN focus handling and Android selection visibility requests use this calculation. // Both RN focus handling and Android selection visibility requests use this calculation.
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline // Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
// movement to user gestures and explicit scroll commands. // movement to user gestures and explicit scroll commands.
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int { override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY. if (isInverted) return 0
matrix.getValues(transformValues)
if (transformValues[Matrix.MSCALE_Y] < 0) return 0
return super.computeScrollDeltaToGetChildRectOnScreen(rect) return super.computeScrollDeltaToGetChildRectOnScreen(rect)
} }
// A nested panel's remaining delta uses its own visual direction. Consuming it in
// an inverted timeline moves the timeline the opposite way when the panel hits an edge.
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int) {
if (isInverted) return
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed)
}
override fun onNestedFling(target: View, velocityX: Float, velocityY: Float, consumed: Boolean): Boolean {
if (isInverted && !consumed) return true
return super.onNestedFling(target, velocityX, velocityY, consumed)
}
} }
@@ -1,42 +0,0 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it } from "vitest";
import { HostPickerOption } from "./host-picker";
const actions: string[] = [];
function select() {
actions.push("select");
}
function openSettings() {
actions.push("settings");
}
it("keeps host selection and settings as separate accessible buttons", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
actions.length = 0;
act(() =>
root.render(
<HostPickerOption
serverId="host-a"
label="Host A"
showActiveConnection={false}
active={false}
onPress={select}
onOpenHostSettings={openSettings}
/>,
),
);
const host = container.querySelector('button[aria-label="Host A"]');
const settings = container.querySelector('button[aria-label="Open Host A settings"]');
expect(host).not.toBeNull();
expect(settings).not.toBeNull();
expect(host?.contains(settings!)).toBe(false);
act(() => (settings as HTMLElement).click());
expect(actions).toEqual(["settings"]);
act(() => (host as HTMLElement).click());
expect(actions).toEqual(["settings", "select"]);
act(() => root.unmount());
container.remove();
});
@@ -8,7 +8,6 @@ import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/comb
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useHostRuntimeSnapshot, type ActiveConnection } from "@/runtime/host-runtime"; import { useHostRuntimeSnapshot, type ActiveConnection } from "@/runtime/host-runtime";
import { orderHostsLocalFirst } from "@/types/host-connection"; import { orderHostsLocalFirst } from "@/types/host-connection";
import { isWeb } from "@/constants/platform";
import { import {
ADD_HOST_OPTION_ID, ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID, ALL_HOSTS_OPTION_ID,
@@ -90,33 +89,35 @@ export function HostPickerOption({
); );
const trailingSlot = useMemo(() => { const trailingSlot = useMemo(() => {
if (!onOpenHostSettings) return undefined; if (!onOpenHostSettings) return undefined;
return <View style={styles.settingsSlot} />; return (
}, [onOpenHostSettings]); <Pressable
onPress={handleSettingsPress}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Open ${label} settings`}
>
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
);
}, [
handleSettingsPress,
label,
onOpenHostSettings,
theme.colors.foregroundMuted,
theme.iconSize.sm,
]);
return ( return (
<View style={styles.optionContainer}> <ComboboxItem
<ComboboxItem label={label}
label={label} description={connectionLabel}
description={connectionLabel} leadingSlot={leadingSlot}
leadingSlot={leadingSlot} trailingSlot={trailingSlot}
trailingSlot={trailingSlot} selected={selected}
selected={selected} active={active}
active={active} onPress={onPress}
onPress={onPress} testID={testID}
testID={testID} />
/>
{onOpenHostSettings ? (
<Pressable
style={styles.settingsAction}
onPress={handleSettingsPress}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Open ${label} settings`}
>
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
</View>
); );
} }
@@ -318,15 +319,6 @@ export function HostPicker({
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
optionContainer: { position: "relative" },
settingsSlot: { width: theme.iconSize.sm, height: theme.iconSize.sm },
settingsAction: {
position: "absolute",
right: theme.spacing[3] + (isWeb ? 0 : theme.spacing[1]),
top: 0,
bottom: 0,
justifyContent: "center",
},
statusDotSlot: { statusDotSlot: {
width: theme.iconSize.sm, width: theme.iconSize.sm,
height: theme.iconSize.sm, height: theme.iconSize.sm,
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { executeDaemonManagementToggle } from "./daemon-management-toggle"; import { executeDaemonManagementToggle } from "./daemon-management-toggle";
import { resolveDesktopDaemonServerId, type DesktopDaemonStatus } from "./desktop-daemon"; import type { DesktopDaemonStatus } from "./desktop-daemon";
const runningManagedStatus: DesktopDaemonStatus = { const runningManagedStatus: DesktopDaemonStatus = {
serverId: "desktop", serverId: "desktop",
@@ -54,29 +54,6 @@ function makeDeps(overrides?: {
}; };
} }
it("retains the desktop identity when the daemon is stopped or status fails", async () => {
expect(
await resolveDesktopDaemonServerId({
getStatus: async () => ({ ...stoppedStatus, serverId: "" }),
getSavedId: async () => "desktop",
}),
).toBe("desktop");
expect(
await resolveDesktopDaemonServerId({
getStatus: async () => {
throw new Error("offline");
},
getSavedId: async () => "desktop",
}),
).toBe("desktop");
expect(
await resolveDesktopDaemonServerId({
getStatus: async () => runningManagedStatus,
getSavedId: async () => "old",
}),
).toBe("desktop");
});
describe("executeDaemonManagementToggle", () => { describe("executeDaemonManagementToggle", () => {
describe("enable path (currentlyManaging: false)", () => { describe("enable path (currentlyManaging: false)", () => {
it("persists the new setting then starts the daemon", async () => { it("persists the new setting then starts the daemon", async () => {
@@ -186,28 +186,6 @@ export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand("desktop_daemon_status")); return parseDesktopDaemonStatus(await invokeDesktopCommand("desktop_daemon_status"));
} }
/** Resolve the identity even when the daemon has stopped. */
export async function resolveDesktopDaemonServerId(
deps: {
getStatus(): Promise<DesktopDaemonStatus>;
getSavedId(): Promise<string | null>;
} = {
getStatus: getDesktopDaemonStatus,
getSavedId: () => invokeDesktopCommand<string | null>("desktop_daemon_identity"),
},
): Promise<string | null> {
try {
const id = (await deps.getStatus()).serverId.trim();
if (id) return id;
} catch {
/* The persisted identity remains available if the status request fails. */
}
const id = await deps.getSavedId();
if (id === null) return null;
if (typeof id !== "string") throw new Error("Invalid desktop daemon identity");
return id.trim() || null;
}
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> { export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon")); return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon"));
} }
@@ -132,19 +132,12 @@ function harness() {
return { return {
hosts: createPluginHosts( hosts: createPluginHosts(
{ {
addHost: async () => ({ status: "cancelled" }),
getHosts: () => [], getHosts: () => [],
getSnapshot: () => null, getSnapshot: () => null,
subscribeAll: () => () => {}, subscribeAll: () => () => {},
subscribeHostList: () => () => {}, subscribeHostList: () => () => {},
renameHost: async () => {},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async () => {},
removeConnection: async () => {},
}, },
installation.lifetime.signal, installation.lifetime.signal,
{ openSettings() {}, isManagedLocalHost: async () => false },
), ),
paseo: installation.paseo, paseo: installation.paseo,
i18n: installation.i18n, i18n: installation.i18n,
+1 -12
View File
@@ -1,9 +1,5 @@
import { createPlayAudio, type AudioEngine } from "@/audio"; import { createPlayAudio, type AudioEngine } from "@/audio";
import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { getHostRuntimeStore } from "@/runtime/host-runtime";
import {
resolveDesktopDaemonServerId,
shouldUseDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { createPluginHosts } from "./hosts"; import { createPluginHosts } from "./hosts";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client"; import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
import { import {
@@ -30,14 +26,7 @@ export function createPluginClientRuntime(
...capabilities, ...capabilities,
i18n: installation.i18n, i18n: installation.i18n,
playAudio: createPlayAudio(audio, installation.lifetime.signal), playAudio: createPlayAudio(audio, installation.lifetime.signal),
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal, { hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
openSettings(serverId) {
createPluginNavigation({ serverId, workspaceId: null }).openHostSettings(serverId);
},
async isManagedLocalHost(serverId) {
return shouldUseDesktopDaemon() && (await resolveDesktopDaemonServerId()) === serverId;
},
}),
addComposerPill(contribution) { addComposerPill(contribution) {
return pluginButtonStore.addComposerPill(installation, contribution); return pluginButtonStore.addComposerPill(installation, contribution);
}, },
+2 -15
View File
@@ -819,14 +819,6 @@ it("binds imported getters to each originating installation across delayed callb
hosts: { hosts: {
getSnapshot: () => [], getSnapshot: () => [],
subscribe: () => () => {}, subscribe: () => () => {},
observeHosts: () => {
calls.push(`${installation}/observe`);
return () => {};
},
addHost: async (input) => {
calls.push(`${installation}/add:${input.connection}`);
return { status: "cancelled" };
},
getPaseoClient(serverId) { getPaseoClient(serverId) {
calls.push(`${installation}/${serverId}`); calls.push(`${installation}/${serverId}`);
return runtime.paseo; return runtime.paseo;
@@ -834,12 +826,11 @@ it("binds imported getters to each originating installation across delayed callb
}, },
}); });
const source = bundle(` const source = bundle(`
const { getPaseoClient, observeHosts, addHost } = require("@getpaseo/plugin/client"); const { getPaseoClient } = require("@getpaseo/plugin/client");
getPaseoClient("entry-host"); getPaseoClient("entry-host");
observeHosts(() => {});
plugin.addCommandCenterItem({ plugin.addCommandCenterItem({
id: "read", title: "Read", icon: "Server", context: "global", id: "read", title: "Read", icon: "Server", context: "global",
onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); await addHost({ connection: "example:6767" }); }, onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); },
}); });
`); `);
const first = runPluginClientBundle("same-id", source, hostRuntime("first")); const first = runPluginClientBundle("same-id", source, hostRuntime("first"));
@@ -849,13 +840,9 @@ it("binds imported getters to each originating installation across delayed callb
await second.commandCenterItems[0].onSelect({} as never); await second.commandCenterItems[0].onSelect({} as never);
expect(calls).toEqual([ expect(calls).toEqual([
"first/entry-host", "first/entry-host",
"first/observe",
"second/entry-host", "second/entry-host",
"second/observe",
"first/target-host", "first/target-host",
"first/add:example:6767",
"second/target-host", "second/target-host",
"second/add:example:6767",
]); ]);
await first.cleanup(); await first.cleanup();
await second.cleanup(); await second.cleanup();
-4
View File
@@ -520,10 +520,6 @@ export function runPluginClientBundle(
useSettings, useSettings,
openExternalUrl, openExternalUrl,
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId), getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
addHost: (input: Parameters<typeof runtime.hosts.addHost>[0]) =>
runtime.hosts.addHost(input),
observeHosts: (listener: Parameters<typeof runtime.hosts.observeHosts>[0]) =>
runtime.hosts.observeHosts(listener),
useHosts: () => useHosts: () =>
React.useSyncExternalStore( React.useSyncExternalStore(
runtime.hosts.subscribe, runtime.hosts.subscribe,
+7 -227
View File
@@ -1,66 +1,17 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createPluginHosts, type PluginHostsSource } from "./index"; import { createPluginHosts, type PluginHostsSource } from "./index";
import { resolveDesktopDaemonServerId } from "@/desktop/daemon/desktop-daemon";
function registry() { function registry() {
const hosts = [ const hosts = [
{ { serverId: "a", label: "Alpha", password: "secret" },
serverId: "a", { serverId: "b", label: "Beta", password: "secret" },
label: "Alpha",
password: "secret",
appearance: { color: "none" as const, badgeDisplay: null },
connections: [
{ id: "one", type: "directTcp" as const, endpoint: "localhost:6767", useTls: false },
{ id: "two", type: "directSocket" as const, path: "/tmp/test" },
],
lifecycle: {},
preferredConnectionId: null,
createdAt: "today",
updatedAt: "today",
},
{
serverId: "b",
label: "Beta",
password: "secret",
appearance: { color: "none" as const, badgeDisplay: null },
connections: [],
lifecycle: {},
preferredConnectionId: null,
createdAt: "today",
updatedAt: "today",
},
]; ];
const snapshots = new Map< const snapshots = new Map<string, NonNullable<ReturnType<PluginHostsSource["getSnapshot"]>>>();
string,
{ connectionStatus: "online" | "offline" | "connecting"; client: DaemonClient | null }
>();
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
const source: PluginHostsSource = { const source: PluginHostsSource = {
addHost: async () => ({ status: "cancelled" }),
getHosts: () => hosts, getHosts: () => hosts,
getSnapshot: (id) => { getSnapshot: (id) => snapshots.get(id) ?? null,
const value = snapshots.get(id);
return value
? { ...value, activeConnectionId: null, lastError: null, lastOnlineAt: null }
: null;
},
renameHost: async (id, label) => {
hosts.find((candidate) => candidate.serverId === id)!.label = label;
},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async (id) => {
const index = hosts.findIndex((host) => host.serverId === id);
if (index >= 0) hosts.splice(index, 1);
for (const l of listeners) l();
},
removeConnection: async (id, connectionId) => {
const host = hosts.find((candidate) => candidate.serverId === id)!;
host.connections = host.connections.filter((c) => c.id !== connectionId);
if (!host.connections.length) await source.removeHost(id);
else for (const l of listeners) l();
},
subscribeAll(listener) { subscribeAll(listener) {
listeners.add(listener); listeners.add(listener);
return () => { return () => {
@@ -75,10 +26,7 @@ function registry() {
}, },
}; };
const lifetime = new AbortController(); const lifetime = new AbortController();
const runtime = createPluginHosts(source, lifetime.signal, { const runtime = createPluginHosts(source, lifetime.signal);
openSettings() {},
isManagedLocalHost: async () => false,
});
return { return {
hosts, hosts,
snapshots, snapshots,
@@ -100,7 +48,7 @@ function connection(id: string) {
describe("plugin host access", () => { describe("plugin host access", () => {
it("publishes credential-free configured hosts and only changes snapshots when summaries change", () => { it("publishes credential-free configured hosts and only changes snapshots when summaries change", () => {
const h = registry(); const h = registry();
expect(h.runtime.getSnapshot()).toMatchObject([ expect(h.runtime.getSnapshot()).toEqual([
{ serverId: "a", label: "Alpha", status: "offline" }, { serverId: "a", label: "Alpha", status: "offline" },
{ serverId: "b", label: "Beta", status: "offline" }, { serverId: "b", label: "Beta", status: "offline" },
]); ]);
@@ -120,171 +68,6 @@ describe("plugin host access", () => {
expect(h.listeners.size).toBe(0); expect(h.listeners.size).toBe(0);
}); });
it("keeps handles stable, protects credentials, and stops observations on unload", async () => {
const h = registry();
const seen: unknown[] = [];
const stop = h.runtime.observeHosts((hosts) => seen.push(hosts));
const first = h.runtime.getSnapshot()[0];
function walk(value: unknown): void {
if (typeof value === "string") expect(value).not.toContain("secret");
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
expect(key.toLowerCase()).not.toBe("password");
walk(child);
}
}
walk(h.runtime.getSnapshot());
h.hosts[0].label = "New name";
h.publish();
expect(h.runtime.getSnapshot()[0]).toBe(first);
expect(first.label).toBe("New name");
expect(seen).toHaveLength(2);
h.lifetime.abort();
h.publish();
expect(seen).toHaveLength(2);
await expect(first.remove()).rejects.toThrow("Plugin has stopped");
stop();
});
it("redacts saved passwords from mutation errors", async () => {
const h = registry();
h.source.renameHost = async () => {
throw new Error("Failed for secret");
};
await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow("secret");
h.lifetime.abort();
});
it("isolates a throwing observer from other observers and registry mutations", async () => {
const h = registry();
let calls = 0;
h.runtime.observeHosts(() => {
if (calls++ > 0) throw new Error("plugin listener failed");
});
let delivered = 0;
h.runtime.observeHosts(() => {
delivered++;
});
await expect(h.runtime.getSnapshot()[0].removeConnection("one")).resolves.toBeUndefined();
expect(delivered).toBe(2);
expect(h.runtime.getSnapshot()[0].connections).toHaveLength(1);
h.lifetime.abort();
});
it("refuses to remove the last connection of the desktop daemon", async () => {
const h = registry();
const lifetime = new AbortController();
const runtime = createPluginHosts(h.source, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async (id) => id === "a",
});
const host = runtime.getSnapshot()[0];
await host.removeConnection("one");
await expect(host.removeConnection("two")).rejects.toThrow("desktop daemon");
expect(host.connections).toHaveLength(1);
lifetime.abort();
h.lifetime.abort();
});
it("returns the projected handle after adding a host", async () => {
const h = registry();
h.source.addHost = async () => ({ status: "saved", serverId: "a" });
const result = await h.runtime.addHost({ connection: "tcp://example:6767?password=secret" });
expect(result.status).toBe("saved");
if (result.status !== "saved") throw new Error("expected saved host");
expect(result.host).toBe(h.runtime.getSnapshot()[0]);
expect(JSON.stringify(result)).not.toContain("secret");
h.lifetime.abort();
await expect(h.runtime.addHost({ connection: "example:6767" })).rejects.toThrow(
"Plugin has stopped",
);
});
it("passes cancellation and authentication results without exposing credentials", async () => {
const h = registry();
for (const result of [
{ status: "cancelled" as const },
{ status: "password_required" as const, reason: "incorrect_password" as const },
]) {
h.source.addHost = async () => result;
const returned = await h.runtime.addHost({
connection: "tcp://example:6767?password=secret",
});
expect(returned).toEqual(result);
for (const [key, value] of Object.entries(returned)) {
expect(key).not.toBe("password");
expect(value).not.toBe("secret");
}
}
h.lifetime.abort();
});
it("redacts encoded saved passwords in mutation errors", async () => {
const h = registry();
h.hosts[0].password = "two words";
h.source.renameHost = async () => {
throw new Error("failed for two%20words");
};
await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow(
"two%20words",
);
h.lifetime.abort();
});
it("never exposes supplied passwords in add errors", async () => {
const h = registry();
h.source.addHost = async () => {
throw new Error("secret was rejected");
};
await expect(
h.runtime.addHost({ connection: "tcp://example:6767?password=secret" }),
).rejects.not.toThrow("secret");
h.lifetime.abort();
});
it("removes a connection and then the last host connection", async () => {
const h = registry();
const host = h.runtime.getSnapshot()[0];
await host.removeConnection("one");
expect(host.connections.map((item) => item.id)).toEqual(["two"]);
await host.removeConnection("two");
expect(h.runtime.getSnapshot().map((entry) => entry.serverId)).toEqual(["b"]);
h.lifetime.abort();
});
it("rejects removal of the managed desktop daemon", async () => {
const h = registry();
const lifetime = new AbortController();
const runtime = createPluginHosts(h.source, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async (id) => id === "a",
});
await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon");
expect(runtime.getSnapshot()).toHaveLength(2);
lifetime.abort();
h.lifetime.abort();
});
it("protects the desktop host after its daemon has stopped", async () => {
const h = registry();
const lifetime = new AbortController();
const runtime = createPluginHosts(h.source, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async (id) =>
id ===
(await resolveDesktopDaemonServerId({
getStatus: async () => {
throw new Error("daemon stopped");
},
getSavedId: async () => "a",
})),
});
await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon");
expect(runtime.getSnapshot()).toHaveLength(2);
lifetime.abort();
h.lifetime.abort();
});
it("rejects unknown and disconnected targets without falling through to another host", () => { it("rejects unknown and disconnected targets without falling through to another host", () => {
const h = registry(); const h = registry();
h.snapshots.set("a", { connectionStatus: "online", client: connection("a") }); h.snapshots.set("a", { connectionStatus: "online", client: connection("a") });
@@ -299,10 +82,7 @@ describe("plugin host access", () => {
h.snapshots.set("b", { connectionStatus: "online", client }); h.snapshots.set("b", { connectionStatus: "online", client });
h.publish(); h.publish();
const otherLifetime = new AbortController(); const otherLifetime = new AbortController();
const other = createPluginHosts(h.source, otherLifetime.signal, { const other = createPluginHosts(h.source, otherLifetime.signal);
openSettings() {},
isManagedLocalHost: async () => false,
});
const api = h.runtime.getPaseoClient("b"); const api = h.runtime.getPaseoClient("b");
expect(api).toBe(h.runtime.getPaseoClient("b")); expect(api).toBe(h.runtime.getPaseoClient("b"));
expect(api).not.toBe(other.getPaseoClient("b")); expect(api).not.toBe(other.getPaseoClient("b"));
+17 -319
View File
@@ -1,288 +1,31 @@
import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { import type { PluginHostSummary } from "@getpaseo/plugin/client";
PluginHost,
PluginHostConnection,
PluginHostPatch,
PluginHostAddInput,
PluginHostAddResult,
} from "@getpaseo/plugin/client";
import type { HostProfile } from "@/types/host-connection";
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
import {
parseHostPort,
serializeConnectionUri,
serializeRelayConnectionUri,
shouldUseTlsForDefaultHostedRelay,
} from "@getpaseo/protocol/daemon-endpoints";
import { parseHostTarget, serializeSocketHostTarget } from "@getpaseo/protocol/host-target";
import {
DEFAULT_SSH_DAEMON_PORT,
serializeSshTransportUri,
} from "@getpaseo/protocol/ssh-transport";
type HostSnapshot = Pick<
HostRuntimeSnapshot,
| "connectionStatus"
| "client"
| "activeConnectionId"
| "lastError"
| "authFailureReason"
| "lastOnlineAt"
>;
export interface PluginHostsSource { export interface PluginHostsSource {
addHost( getHosts(): readonly { serverId: string; label: string }[];
input: PluginHostAddInput, getSnapshot(serverId: string): {
signal?: AbortSignal, connectionStatus: PluginHostSummary["status"];
): Promise< client: DaemonClient | null;
| { status: "saved"; serverId: string } } | null;
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
| { status: "cancelled" }
>;
getHosts(): readonly HostProfile[];
getSnapshot(serverId: string): HostSnapshot | null;
subscribeAll(listener: () => void): () => void; subscribeAll(listener: () => void): () => void;
subscribeHostList(listener: () => void): () => void; subscribeHostList(listener: () => void): () => void;
renameHost(serverId: string, label: string): Promise<void>;
setHostColor(serverId: string, color: PluginHost["appearance"]["color"]): Promise<void>;
setHostBadgeDisplay(
serverId: string,
badge: PluginHost["appearance"]["badgeDisplay"],
): Promise<void>;
removeHost(serverId: string): Promise<void>;
removeConnection(serverId: string, connectionId: string): Promise<void>;
}
function redactSecret(message: string, secret: string | undefined): string {
if (!secret) return message;
for (const value of [
secret,
encodeURIComponent(secret),
new URLSearchParams({ password: secret }).toString().slice(9),
]) {
message = message.split(value).join("[redacted]");
}
return message;
}
type ProjectedHost = Omit<PluginHost, "update" | "remove" | "removeConnection" | "openSettings">;
function projectHost(profile: HostProfile, snapshot: HostSnapshot | null): ProjectedHost {
const connections: PluginHostConnection[] = profile.connections.map((connection) => {
switch (connection.type) {
case "directTcp":
return {
id: connection.id,
type: connection.type,
endpoint: connection.endpoint,
useTls: connection.useTls ?? false,
uri: serializeConnectionUri({
...parseHostPort(connection.endpoint),
useTls: connection.useTls ?? false,
}),
};
case "directSocket":
return {
id: connection.id,
type: connection.type,
path: connection.path,
uri: serializeSocketHostTarget("unix", connection.path),
};
case "directPipe":
return {
id: connection.id,
type: connection.type,
path: connection.path,
uri: serializeSocketHostTarget("pipe", connection.path),
};
case "remoteSsh":
return {
id: connection.id,
type: connection.type,
host: connection.host,
...(connection.sshPort === undefined ? {} : { sshPort: connection.sshPort }),
...(connection.daemonPort === undefined ? {} : { daemonPort: connection.daemonPort }),
uri: serializeSshTransportUri({
host: connection.host,
sshPort: connection.sshPort,
daemonPort: connection.daemonPort ?? DEFAULT_SSH_DAEMON_PORT,
}),
};
case "relay": {
const useTls =
connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint);
return {
id: connection.id,
type: connection.type,
relayEndpoint: connection.relayEndpoint,
useTls,
daemonPublicKeyB64: connection.daemonPublicKeyB64,
...(connection.minimumSecurityMode
? { minimumSecurityMode: connection.minimumSecurityMode }
: {}),
uri: serializeRelayConnectionUri({
offer: {
v: 2,
serverId: profile.serverId,
daemonPublicKeyB64: connection.daemonPublicKeyB64,
relay: { endpoint: connection.relayEndpoint, useTls },
...(connection.minimumSecurityMode
? { minimumSecurityMode: connection.minimumSecurityMode }
: {}),
},
}),
};
}
case "routed":
return {
id: connection.id,
type: connection.type,
originServerId: connection.originServerId,
uri: null,
};
}
});
const error = snapshot?.lastError ?? null;
return {
serverId: profile.serverId,
label: profile.label,
status: snapshot?.connectionStatus ?? "offline",
appearance: { color: profile.appearance.color, badgeDisplay: profile.appearance.badgeDisplay },
connections,
activeConnectionId: snapshot?.activeConnectionId ?? null,
error: error ? redactSecret(error, profile.password) : null,
authFailureReason: snapshot?.authFailureReason ?? null,
lastOnlineAt: snapshot?.lastOnlineAt ?? null,
createdAt: profile.createdAt,
updatedAt: profile.updatedAt,
};
} }
/** Each evaluated installation owns its borrowed APIs and registry subscriptions. */ /** Each evaluated installation owns its borrowed APIs and registry subscriptions. */
export function createPluginHosts( export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal) {
source: PluginHostsSource,
signal: AbortSignal,
options: {
openSettings(serverId: string): void;
isManagedLocalHost(serverId: string): Promise<boolean>;
},
) {
const clients = new Map< const clients = new Map<
string, string,
{ client: DaemonClient; api: PaseoApi; lifetime: AbortController } { client: DaemonClient; api: PaseoApi; lifetime: AbortController }
>(); >();
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
let snapshot: readonly PluginHost[] = []; let snapshot: readonly PluginHostSummary[] = [];
let serialized = ""; function readHosts(): readonly PluginHostSummary[] {
const entries = new Map<string, { data: ProjectedHost; handle: PluginHost }>(); return source.getHosts().map(({ serverId, label }) => ({
function assertHost(id: string) { serverId,
if (signal.aborted) throw new Error("Plugin has stopped"); label,
if (!source.getHosts().some((host) => host.serverId === id)) status: source.getSnapshot(serverId)?.connectionStatus ?? "offline",
throw new Error(`Unknown Paseo host: ${id}`); }));
}
async function perform(id: string, action: () => Promise<void>): Promise<void> {
const secret = source.getHosts().find((host) => host.serverId === id)?.password;
try {
await action();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// A cause would expose the original error, including any password.
// oxlint-disable-next-line preserve-caught-error
throw new Error(redactSecret(message, secret));
}
}
function readHosts(): { hosts: PluginHost[]; key: string } {
const projected = source
.getHosts()
.map((profile) => projectHost(profile, source.getSnapshot(profile.serverId)));
const ids = new Set(projected.map((host) => host.serverId));
for (const id of entries.keys()) if (!ids.has(id)) entries.delete(id);
const hosts = projected.map((data) => {
let entry = entries.get(data.serverId);
if (!entry) {
const id = data.serverId;
const read = () => entry!.data;
const handle: PluginHost = {
get serverId() {
return read().serverId;
},
get label() {
return read().label;
},
get status() {
return read().status;
},
get appearance() {
return read().appearance;
},
get connections() {
return read().connections;
},
get activeConnectionId() {
return read().activeConnectionId;
},
get error() {
return read().error;
},
get authFailureReason() {
return read().authFailureReason;
},
get lastOnlineAt() {
return read().lastOnlineAt;
},
get createdAt() {
return read().createdAt;
},
get updatedAt() {
return read().updatedAt;
},
async update(patch: PluginHostPatch) {
await perform(id, async () => {
assertHost(id);
if (patch.label !== undefined) await source.renameHost(id, patch.label);
if (patch.appearance?.color !== undefined) {
assertHost(id);
await source.setHostColor(id, patch.appearance.color);
}
if (patch.appearance?.badgeDisplay !== undefined) {
assertHost(id);
await source.setHostBadgeDisplay(id, patch.appearance.badgeDisplay);
}
});
},
async remove() {
await perform(id, async () => {
assertHost(id);
if (await options.isManagedLocalHost(id))
throw new Error("Cannot remove the desktop daemon host");
assertHost(id);
await source.removeHost(id);
});
},
async removeConnection(connectionId) {
await perform(id, async () => {
assertHost(id);
const isManaged = await options.isManagedLocalHost(id);
assertHost(id);
const host = source.getHosts().find((candidate) => candidate.serverId === id)!;
if (
isManaged &&
host.connections.length === 1 &&
host.connections[0].id === connectionId
) {
throw new Error("Cannot remove the desktop daemon host");
}
await source.removeConnection(id, connectionId);
});
},
openSettings() {
assertHost(id);
options.openSettings(id);
},
};
entry = { data, handle };
entries.set(id, entry);
} else entry.data = data;
return entry.handle;
});
return { hosts, key: JSON.stringify(projected) };
} }
function resolve(serverId: string): DaemonClient { function resolve(serverId: string): DaemonClient {
if (signal.aborted) throw new Error("Plugin has stopped"); if (signal.aborted) throw new Error("Plugin has stopped");
@@ -306,16 +49,11 @@ export function createPluginHosts(
} }
} }
const next = readHosts(); const next = readHosts();
if (serialized === next.key) return; if (JSON.stringify(snapshot) === JSON.stringify(next)) return;
serialized = next.key; snapshot = next;
snapshot = next.hosts;
for (const listener of listeners) listener(); for (const listener of listeners) listener();
} }
{ snapshot = readHosts();
const initial = readHosts();
snapshot = initial.hosts;
serialized = initial.key;
}
const unsubscribeRuntime = source.subscribeAll(refresh); const unsubscribeRuntime = source.subscribeAll(refresh);
const unsubscribeHosts = source.subscribeHostList(refresh); const unsubscribeHosts = source.subscribeHostList(refresh);
function stop() { function stop() {
@@ -329,46 +67,6 @@ export function createPluginHosts(
else signal.addEventListener("abort", stop, { once: true }); else signal.addEventListener("abort", stop, { once: true });
return { return {
getSnapshot: () => snapshot, getSnapshot: () => snapshot,
async addHost(input: PluginHostAddInput): Promise<PluginHostAddResult> {
if (signal.aborted) throw new Error("Plugin has stopped");
let result: Awaited<ReturnType<PluginHostsSource["addHost"]>>;
try {
result = await source.addHost(input, signal);
} catch (error) {
let secret: string | undefined;
try {
secret = parseHostTarget(input.connection).password;
} catch {
/* Invalid input has no parsed password. */
}
const message = redactSecret(
error instanceof Error ? error.message : String(error),
secret,
);
// The original error can contain the supplied credential.
// oxlint-disable-next-line preserve-caught-error
throw new Error(message);
}
if (result.status !== "saved") return result;
const host = snapshot.find((item) => item.serverId === result.serverId);
if (!host) throw new Error(`Unknown Paseo host: ${result.serverId}`);
return { status: "saved", host };
},
observeHosts(listener: (hosts: readonly PluginHost[]) => void) {
if (signal.aborted) throw new Error("Plugin has stopped");
const notify = () => {
try {
listener(snapshot);
} catch (error) {
console.error("[Plugins] Host observer failed", error);
}
};
listeners.add(notify);
notify();
return () => {
listeners.delete(notify);
};
},
subscribe(listener: () => void) { subscribe(listener: () => void) {
if (signal.aborted) return () => {}; if (signal.aborted) return () => {};
listeners.add(listener); listeners.add(listener);
+1 -5
View File
@@ -1,4 +1,3 @@
import { buildSettingsHostRoute } from "@/utils/host-routes";
import { buildPluginSettingsRoute } from "./settings/routes"; import { buildPluginSettingsRoute } from "./settings/routes";
import { router } from "expo-router"; import { router } from "expo-router";
import type { PluginPanelLocation } from "@getpaseo/plugin/client"; import type { PluginPanelLocation } from "@getpaseo/plugin/client";
@@ -10,7 +9,7 @@ import type { PluginNavigation } from "./actions";
export function createPluginNavigation(input: { export function createPluginNavigation(input: {
serverId: string; serverId: string;
workspaceId: string | null; workspaceId: string | null;
}): PluginNavigation & { openHostSettings(serverId: string): void } { }): PluginNavigation {
const { serverId, workspaceId } = input; const { serverId, workspaceId } = input;
function placement(location: PluginPanelLocation) { function placement(location: PluginPanelLocation) {
if (location !== "explorer") return undefined; if (location !== "explorer") return undefined;
@@ -21,9 +20,6 @@ export function createPluginNavigation(input: {
return { mode: "pane" as const, paneId }; return { mode: "pane" as const, paneId };
} }
return { return {
openHostSettings(hostId) {
router.push(buildSettingsHostRoute(hostId));
},
openSettings(pluginId, screenId) { openSettings(pluginId, screenId) {
router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)); router.push(buildPluginSettingsRoute(serverId, pluginId, screenId));
}, },
@@ -43,19 +43,12 @@ function registry(
return { return {
hosts: createPluginHosts( hosts: createPluginHosts(
{ {
addHost: async () => ({ status: "cancelled" }),
getHosts: () => [], getHosts: () => [],
getSnapshot: () => null, getSnapshot: () => null,
subscribeAll: () => () => {}, subscribeAll: () => () => {},
subscribeHostList: () => () => {}, subscribeHostList: () => () => {},
renameHost: async () => {},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async () => {},
removeConnection: async () => {},
}, },
installation.lifetime.signal, installation.lifetime.signal,
{ openSettings() {}, isManagedLocalHost: async () => false },
), ),
paseo: installation.paseo, paseo: installation.paseo,
i18n: installation.i18n, i18n: installation.i18n,
@@ -18,19 +18,12 @@ function registry(version: string) {
return { return {
hosts: createPluginHosts( hosts: createPluginHosts(
{ {
addHost: async () => ({ status: "cancelled" }),
getHosts: () => [], getHosts: () => [],
getSnapshot: () => null, getSnapshot: () => null,
subscribeAll: () => () => {}, subscribeAll: () => () => {},
subscribeHostList: () => () => {}, subscribeHostList: () => () => {},
renameHost: async () => {},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async () => {},
removeConnection: async () => {},
}, },
installation.lifetime.signal, installation.lifetime.signal,
{ openSettings() {}, isManagedLocalHost: async () => false },
), ),
paseo: installation.paseo, paseo: installation.paseo,
i18n: installation.i18n, i18n: installation.i18n,
+10 -24
View File
@@ -55,38 +55,24 @@ export class HostConfirmations {
} }
/** Resolves true without asking when the link matches a saved host exactly. */ /** Resolves true without asking when the link matches a saved host exactly. */
confirmLink( confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise<boolean> {
offer: ConnectionOffer,
savedHosts: HostProfile[],
signal?: AbortSignal,
): Promise<boolean> {
const connection = relayConnectionFromOffer(offer); const connection = relayConnectionFromOffer(offer);
const savedHost = savedHosts.find((host) => host.serverId === offer.serverId); const savedHost = savedHosts.find((host) => host.serverId === offer.serverId);
if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true); if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true);
if (signal?.aborted) return Promise.resolve(false); return this.ask({
return this.ask( id: this.nextRequestId++,
{ serverId: offer.serverId,
id: this.nextRequestId++, keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
serverId: offer.serverId, relayEndpoint: connection.relayEndpoint,
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64), kind: savedHost ? "changedConnection" : "newHost",
relayEndpoint: connection.relayEndpoint, });
kind: savedHost ? "changedConnection" : "newHost",
},
signal,
);
} }
private ask(request: HostConfirmationRequest, signal?: AbortSignal): Promise<boolean> { private ask(request: HostConfirmationRequest): Promise<boolean> {
// One question at a time: a newer one cancels the unanswered one. // One question at a time: a newer one cancels the unanswered one.
this.pending?.resolve(false); this.pending?.resolve(false);
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
const cancel = () => this.answer(request.id, false); this.pending = { request, resolve };
const finish = (approved: boolean) => {
signal?.removeEventListener("abort", cancel);
resolve(approved);
};
this.pending = { request, resolve: finish };
signal?.addEventListener("abort", cancel, { once: true });
this.emit(); this.emit();
}); });
} }
+1 -294
View File
@@ -16,7 +16,7 @@ import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type { HostConnection, HostProfile } from "@/types/host-connection"; import type { HostConnection, HostProfile } from "@/types/host-connection";
import { defaultHostAppearance, selectHostBadges } from "@/hosts/appearance"; import { defaultHostAppearance } from "@/hosts/appearance";
import { useSessionStore, type Agent } from "@/stores/session-store"; import { useSessionStore, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent"; import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
@@ -33,7 +33,6 @@ import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
import { subscriptionFixture } from "./subscription-fixture"; import { subscriptionFixture } from "./subscription-fixture";
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential"; import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
import type { HostConfirmationRequest } from "./host-confirmation"; import type { HostConfirmationRequest } from "./host-confirmation";
import { createPluginHosts } from "@/plugins/hosts";
it("requests the managed connection credential through desktop main without a web hint", async () => { it("requests the managed connection credential through desktop main without a web hint", async () => {
const requests: string[] = []; const requests: string[] = [];
@@ -4779,295 +4778,3 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
expect(store.getHosts()).toHaveLength(0); expect(store.getHosts()).toHaveLength(0);
}); });
}); });
describe("adding hosts from plugin connection targets", () => {
it("does not save an unauthenticated target and retries with the password", async () => {
const store = createPairingStore({ password: "secret", serverIdForHost: () => "srv_direct" });
const requests = answerHostConfirmations(store, true);
expect(await store.addHost({ connection: "example.com:6767" })).toEqual({
status: "password_required",
reason: "password_required",
});
expect(store.getHosts()).toHaveLength(0);
expect(
await store.addHost({ connection: "tcp://example.com:6767?password=secret", label: "Build" }),
).toEqual({ status: "saved", serverId: "srv_direct" });
expect(store.getHosts()[0]).toMatchObject({
label: "Build",
password: "secret",
connections: [{ type: "directTcp", endpoint: "example.com:6767" }],
});
expect(requests).toHaveLength(0);
await waitForHostOnline(store, "srv_direct");
store.syncHosts([]);
});
it("persists the supplied password and reconnects after the app restarts", async () => {
const storage = createMemoryHostRuntimeStorage({ "@paseo:e2e": "1" });
const first = createPairingStore({
storage,
password: "secret",
serverIdForHost: () => "srv_direct",
});
const lifetime = new AbortController();
const plugin = createPluginHosts(first, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const result = await plugin.addHost({ connection: "tcp://example.com:6767?password=secret" });
expect(result.status).toBe("saved");
expect(JSON.stringify(result)).not.toContain("secret");
const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]");
expect(persisted[0].password).toBe("secret");
lifetime.abort();
first.syncHosts([]);
const attempts: string[] = [];
const restarted = createPairingStore({
storage,
password: "secret",
connectAttempts: attempts,
serverIdForHost: () => "srv_direct",
});
await restarted.boot();
await waitForHostOnline(restarted, "srv_direct");
expect(attempts).toContain("direct:example.com:6767");
expect(restarted.getHosts()[0].password).toBe("secret");
const restartedLifetime = new AbortController();
const restartedPlugin = createPluginHosts(restarted, restartedLifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
expect(JSON.stringify(restartedPlugin.getSnapshot())).not.toContain("secret");
restartedLifetime.abort();
restarted.syncHosts([]);
});
it("asks before saving a relay target and respects rejection", async () => {
const uri = "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true";
const rejected = createPairingStore();
const requests = answerHostConfirmations(rejected, false);
expect(await rejected.addHost({ connection: uri })).toEqual({ status: "cancelled" });
expect(requests).toHaveLength(1);
expect(rejected.getHosts()).toHaveLength(0);
const accepted = createPairingStore();
const acceptedRequests = answerHostConfirmations(accepted, true);
expect(await accepted.addHost({ connection: uri, label: "Relay" })).toEqual({
status: "saved",
serverId: "srv_offer",
});
expect(acceptedRequests).toHaveLength(1);
expect(accepted.getHosts()[0]).toMatchObject({
label: "Relay",
connections: [{ type: "relay" }],
});
accepted.syncHosts([]);
});
it.each([
["ssh://deploy@build:2222?daemonPort=7777", "remoteSsh"],
["unix:///tmp/paseo.sock", "directSocket"],
["pipe://mypipe", "directPipe"],
["wss://example.com/ws", "directTcp"],
])("probes and saves %s", async (connection, type) => {
const store = createPairingStore({ serverIdForHost: () => "srv_target" });
expect(await store.addHost({ connection })).toEqual({
status: "saved",
serverId: "srv_target",
});
expect(store.getHosts()[0].connections[0].type).toBe(type);
store.syncHosts([]);
});
it("shares host changes between plugin handles, host settings, and sidebar badges", async () => {
const store = createPairingStore({
serverIdForHost: (host) => {
const connection = host.connections[0];
return connection?.type === "directTcp" ? connection.endpoint : "";
},
});
await store.addHost({ connection: "first:6767", label: "First" });
await store.addHost({ connection: "second:6767", label: "Second" });
await waitForHostOnline(store, "first:6767");
await waitForHostOnline(store, "second:6767");
const lifetime = new AbortController();
const plugin = createPluginHosts(store, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
expect(
plugin.getSnapshot().map((host) => [host.label, host.status, host.connections[0].uri]),
).toEqual([
["First", "online", "tcp://first:6767"],
["Second", "online", "tcp://second:6767"],
]);
await plugin.getSnapshot()[0].update({ label: "Build", appearance: { color: "sky" } });
expect(store.getHosts()[0]).toMatchObject({ label: "Build", appearance: { color: "sky" } });
expect(
selectHostBadges({ hosts: store.getHosts(), localServerId: null, enabled: true }).get(
"first:6767",
),
).toMatchObject({ label: "Build", color: "sky" });
lifetime.abort();
store.syncHosts([]);
});
it.each([
"tcp://example.com:6767?ssl=true",
"ssh://deploy@build:2222?daemonPort=7777",
"unix:///tmp/paseo.sock",
"relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true",
])("recreates the projected connection on another device: %s", async (connection) => {
const first = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" });
answerHostConfirmations(first, true);
const result = await first.addHost({ connection });
if (result.status !== "saved") throw new Error("expected saved host");
const lifetime = new AbortController();
const plugin = createPluginHosts(first, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const uri = plugin.getSnapshot().find((host) => host.serverId === result.serverId)
?.connections[0].uri;
if (!uri) throw new Error("expected connection URI");
const second = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" });
answerHostConfirmations(second, true);
expect(await second.addHost({ connection: uri })).toEqual({
status: "saved",
serverId: result.serverId,
});
expect(second.getHosts()[0].connections).toEqual(first.getHosts()[0].connections);
lifetime.abort();
first.syncHosts([]);
second.syncHosts([]);
});
it("does not save an in-flight addition after its plugin stops", async () => {
const started = createDeferred<void>();
const probe = createDeferred<{
client: DaemonClient;
serverId: string;
hostname: string | null;
}>();
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async () => {
started.resolve(undefined);
return probe.promise;
},
getClientId: async () => "test",
readInitialConnectionHint: () => null,
},
});
const lifetime = new AbortController();
const plugin = createPluginHosts(store, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const adding = plugin.addHost({ connection: "example.com:6767" });
await started.promise;
lifetime.abort();
probe.resolve({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: "srv",
hostname: null,
});
await expect(adding).rejects.toThrow("Plugin has stopped");
expect(store.getHosts()).toHaveLength(0);
store.syncHosts([]);
});
it("reports a stopped plugin when an aborted probe rejects authentication", async () => {
const started = createDeferred<void>();
const probe = createDeferred<{
client: DaemonClient;
serverId: string;
hostname: string | null;
}>();
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async () => {
started.resolve(undefined);
return probe.promise;
},
getClientId: async () => "test",
readInitialConnectionHint: () => null,
},
});
const lifetime = new AbortController();
const plugin = createPluginHosts(store, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const adding = plugin.addHost({ connection: "example.com:6767" });
await started.promise;
lifetime.abort();
probe.reject(new DaemonAuthenticationError("password_required"));
await expect(adding).rejects.toThrow("Plugin has stopped");
expect(store.getHosts()).toHaveLength(0);
store.syncHosts([]);
});
it("dismisses an unanswered confirmation when the plugin stops", async () => {
const store = createPairingStore();
const lifetime = new AbortController();
const plugin = createPluginHosts(store, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const pending = createDeferred<void>();
const unsubscribe = store.subscribeHostConfirmation(() => {
if (store.getPendingHostConfirmation()) pending.resolve(undefined);
});
const adding = plugin.addHost({
connection: "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true",
});
await pending.promise;
lifetime.abort();
await expect(adding).rejects.toThrow("Plugin has stopped");
expect(store.getPendingHostConfirmation()).toBeNull();
expect(store.getHosts()).toHaveLength(0);
unsubscribe();
store.syncHosts([]);
});
it("returns incorrect_password without saving and redacts failed probe errors", async () => {
let failure: "auth" | "network" = "auth";
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async () => {
if (failure === "auth") throw new DaemonAuthenticationError("incorrect_password");
throw new Error("connection failed for secret");
},
getClientId: async () => "test",
readInitialConnectionHint: () => null,
},
});
expect(await store.addHost({ connection: "tcp://example.com:6767?password=secret" })).toEqual({
status: "password_required",
reason: "incorrect_password",
});
expect(store.getHosts()).toHaveLength(0);
failure = "network";
await expect(
store.addHost({ connection: "tcp://example.com:6767?password=secret" }),
).rejects.not.toThrow("secret");
expect(store.getHosts()).toHaveLength(0);
store.syncHosts([]);
});
it("rejects websocket paths and query options the app cannot represent", async () => {
const store = createPairingStore();
await expect(store.addHost({ connection: "wss://example.com/custom" })).rejects.toThrow(
"unsupported",
);
await expect(
store.addHost({ connection: "wss://example.com/ws?token=secret" }),
).rejects.toThrow("unsupported");
expect(store.getHosts()).toHaveLength(0);
store.syncHosts([]);
});
});
+5 -123
View File
@@ -3,7 +3,6 @@ import { watchdog } from "@/watchdog";
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts"; import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
import type { OwnedSubscription } from "@getpaseo/client"; import type { OwnedSubscription } from "@getpaseo/client";
import type { HostDirectory } from "@getpaseo/protocol/hosts"; import type { HostDirectory } from "@getpaseo/protocol/hosts";
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
import { useSyncExternalStore, useMemo } from "react"; import { useSyncExternalStore, useMemo } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import equal from "fast-deep-equal/es6"; import equal from "fast-deep-equal/es6";
@@ -1613,48 +1612,6 @@ interface AgentDirectoryRefreshInput {
page?: FetchAgentsOptions["page"]; page?: FetchAgentsOptions["page"];
} }
function directHostTarget(target: HostTarget, input: string): HostConnection {
let connection: HostConnection;
if (target.kind === "ssh") {
connection = createRemoteSshHostConnection(target.ssh);
} else {
if (/^wss?:\/\//.test(input.trim())) {
const url = new URL(target.url);
if (url.pathname !== "/ws" || url.search)
throw new Error("WebSocket path or query is unsupported by the app");
}
switch (target.connection.type) {
case "directTcp": {
const endpoint = normalizeHostPort(target.connection.endpoint);
connection = {
id: `direct:${endpoint}`,
type: "directTcp",
endpoint,
useTls: target.connection.useTls,
};
break;
}
case "directSocket":
connection = {
id: `socket:${target.connection.path}`,
type: "directSocket",
path: target.connection.path,
};
break;
case "directPipe":
connection = {
id: `pipe:${target.connection.path}`,
type: "directPipe",
path: target.connection.path,
};
break;
case "relay":
throw new Error("Unexpected relay target");
}
}
return connection;
}
export class HostRuntimeStore { export class HostRuntimeStore {
private controllers = new Map<string, HostRuntimeController>(); private controllers = new Map<string, HostRuntimeController>();
private hostObservations = new Map< private hostObservations = new Map<
@@ -2007,66 +1964,6 @@ export class HostRuntimeStore {
); );
} }
/** Add a saved host using the shared connection grammar. */
async addHost(
input: {
connection: string;
label?: string;
},
signal?: AbortSignal,
): Promise<
| { status: "saved"; serverId: string }
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
| { status: "cancelled" }
> {
const target = parseHostTarget(input.connection);
await this.loadRegistry();
try {
if (signal?.aborted) throw new Error("Plugin has stopped");
if (target.kind === "socket" && target.connection.type === "relay") {
const approved = await this.confirmLink(target.connection.offer, signal);
if (signal?.aborted) throw new Error("Plugin has stopped");
if (!approved) return { status: "cancelled" };
const result = await this.probeAndUpsertOffer(
target.connection.offer,
target.password,
input.label,
signal,
);
return { status: "saved", serverId: result.serverId };
}
const connection = directHostTarget(target, input.connection);
const result = await this.probeAndUpsertConnection({
connection,
password: target.password,
label: input.label,
signal,
});
return { status: "saved", serverId: result.serverId };
} catch (error) {
if (signal?.aborted) {
// The failed probe can include a credential; do not attach it as a cause.
// oxlint-disable-next-line preserve-caught-error
throw new Error("Plugin has stopped");
}
const reason = getConnectionAuthFailureReason(error);
if (reason) return { status: "password_required", reason };
let message = error instanceof Error ? error.message : String(error);
if (target.password) {
for (const value of [
target.password,
encodeURIComponent(target.password),
new URLSearchParams({ password: target.password }).toString().slice(9),
]) {
if (value) message = message.split(value).join("[redacted]");
}
}
// The original error can contain a credential; do not expose it as a cause.
// oxlint-disable-next-line preserve-caught-error
throw new Error(message);
}
}
async upsertDirectConnection(input: { async upsertDirectConnection(input: {
serverId: string; serverId: string;
endpoint: string; endpoint: string;
@@ -2096,7 +1993,6 @@ export class HostRuntimeStore {
password?: string; password?: string;
label?: string; label?: string;
timeoutMs?: number; timeoutMs?: number;
signal?: AbortSignal;
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { }): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
if (input.connection.type === "relay") { if (input.connection.type === "relay") {
throw new Error("Cannot probe a relay connection without a server id."); throw new Error("Cannot probe a relay connection without a server id.");
@@ -2117,10 +2013,6 @@ export class HostRuntimeStore {
connection: input.connection, connection: input.connection,
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
}); });
if (input.signal?.aborted) {
await client.close().catch(() => undefined);
throw new Error("Plugin has stopped");
}
const profile = await this.upsertHostConnection({ const profile = await this.upsertHostConnection({
serverId, serverId,
label: input.label ?? hostname ?? undefined, label: input.label ?? hostname ?? undefined,
@@ -2170,9 +2062,9 @@ export class HostRuntimeStore {
// A pairing link (a URL, a QR code, or a pasted link) asks the user to // A pairing link (a URL, a QR code, or a pasted link) asks the user to
// confirm when it adds a new host or changes a saved host's key, relay, or // confirm when it adds a new host or changes a saved host's key, relay, or
// TLS setting. The answer needs the saved hosts, so it waits for them to load. // TLS setting. The answer needs the saved hosts, so it waits for them to load.
private async confirmLink(offer: ConnectionOffer, signal?: AbortSignal): Promise<boolean> { private async confirmLink(offer: ConnectionOffer): Promise<boolean> {
await this.loadRegistry(); await this.loadRegistry();
return this.hostConfirmations.confirmLink(offer, this.hosts, signal); return this.hostConfirmations.confirmLink(offer, this.hosts);
} }
getPendingHostConfirmation(): HostConfirmationRequest | null { getPendingHostConfirmation(): HostConfirmationRequest | null {
@@ -2252,8 +2144,6 @@ export class HostRuntimeStore {
private async probeAndUpsertOffer( private async probeAndUpsertOffer(
offer: ConnectionOffer, offer: ConnectionOffer,
password: string | undefined, password: string | undefined,
label?: string,
signal?: AbortSignal,
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { ): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
const connection = relayConnectionFromOffer(offer); const connection = relayConnectionFromOffer(offer);
const probeHost: HostProfile = { const probeHost: HostProfile = {
@@ -2269,12 +2159,7 @@ export class HostRuntimeStore {
}; };
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection }); const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
await client.close().catch(() => undefined); await client.close().catch(() => undefined);
if (signal?.aborted) throw new Error("Plugin has stopped"); const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, password);
const profile = await this.upsertConnectionFromOffer(
offer,
label ?? hostname ?? undefined,
password,
);
return { profile, serverId: offer.serverId, hostname }; return { profile, serverId: offer.serverId, hostname };
} }
@@ -2336,10 +2221,7 @@ export class HostRuntimeStore {
})); }));
} }
async setHostBadgeDisplay( async setHostBadgeDisplay(serverId: string, badgeDisplay: HostBadgeDisplay): Promise<void> {
serverId: string,
badgeDisplay: HostBadgeDisplay | null,
): Promise<void> {
await this.updateHostAppearance(serverId, (host) => ({ await this.updateHostAppearance(serverId, (host) => ({
...host, ...host,
appearance: { ...host.appearance, badgeDisplay }, appearance: { ...host.appearance, badgeDisplay },
@@ -3152,7 +3034,7 @@ export interface HostMutations {
beginLinkPairing: () => LinkPairing; beginLinkPairing: () => LinkPairing;
renameHost: (serverId: string, label: string) => Promise<void>; renameHost: (serverId: string, label: string) => Promise<void>;
setHostColor: (serverId: string, color: HostColor) => Promise<void>; setHostColor: (serverId: string, color: HostColor) => Promise<void>;
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay | null) => Promise<void>; setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
removeHost: (serverId: string) => Promise<void>; removeHost: (serverId: string) => Promise<void>;
removeConnection: (serverId: string, connectionId: string) => Promise<void>; removeConnection: (serverId: string, connectionId: string) => Promise<void>;
} }
@@ -152,12 +152,6 @@ describe("daemon-manager commands", () => {
expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled(); expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled();
}); });
it("reads the desktop daemon identity after it has stopped", async () => {
mkdirSync(mocks.paseoHome);
writeFileSync(path.join(mocks.paseoHome, "server-id"), "srv_existing\n");
expect(await createDaemonCommandHandlers().desktop_daemon_identity()).toBe("srv_existing");
});
it("reports an errored daemon when the local daemon state cannot be read", async () => { it("reports an errored daemon when the local daemon state cannot be read", async () => {
mkdirSync(mocks.paseoHome); mkdirSync(mocks.paseoHome);
writeFileSync(path.join(mocks.paseoHome, "paseo.pid"), "garbage"); writeFileSync(path.join(mocks.paseoHome, "paseo.pid"), "garbage");
@@ -476,14 +476,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
runningUnderARM64Translation: isRunningUnderARM64Translation(), runningUnderARM64Translation: isRunningUnderARM64Translation(),
}), }),
desktop_daemon_status: () => resolveDesktopDaemonStatus(), desktop_daemon_status: () => resolveDesktopDaemonStatus(),
desktop_daemon_identity: () => {
try {
return readFileSync(path.join(getPaseoHome(), "server-id"), "utf8").trim() || null;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
},
desktop_local_credential: async (args) => { desktop_local_credential: async (args) => {
const instance = await readDaemonInstance(getPaseoHome()); const instance = await readDaemonInstance(getPaseoHome());
if (!instance?.desktopManaged || typeof args?.listen !== "string") return null; if (!instance?.desktopManaged || typeof args?.listen !== "string") return null;
-12
View File
@@ -92,18 +92,6 @@ function typeErrors(code: string): string[] {
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
} }
describe("plugin SDK import boundaries", () => { describe("plugin SDK import boundaries", () => {
it("exposes host handles and object-shaped additions from the client entry", () => {
expect(
typeErrors(`
import { addHost, observeHosts, useHosts, type PluginHost } from "@getpaseo/plugin/client";
const hosts: readonly PluginHost[] = useHosts();
const stop = observeHosts((value) => { void value[0]?.openSettings(); });
void addHost({ connection: "localhost:6767", label: "Local" });
void hosts; stop();
`),
).toEqual([]);
});
it("publishes process capabilities without their Windows implementation helpers", () => { it("publishes process capabilities without their Windows implementation helpers", () => {
expect(Object.keys(server).sort()).toEqual([ expect(Object.keys(server).sort()).toEqual([
"defineAgentTool", "defineAgentTool",
-98
View File
@@ -1,98 +0,0 @@
import type { PluginHostSummary } from "./index.js";
export type PluginHostStatus = PluginHostSummary["status"];
export type PluginHostColor =
| "none"
| "violet"
| "sky"
| "emerald"
| "orange"
| "pink"
| "indigo"
| "teal"
| "red"
| "amber"
| "blue";
export type PluginHostBadgeDisplay = "name" | "icon" | "hidden";
export interface PluginHostAppearance {
readonly color: PluginHostColor;
/** Null uses the automatic local or remote default. */
readonly badgeDisplay: PluginHostBadgeDisplay | null;
}
/** Saved connection settings without credentials. Routed connections have no URI. */
export type PluginHostConnection =
| {
readonly id: string;
readonly type: "directTcp";
readonly uri: string;
readonly endpoint: string;
readonly useTls: boolean;
}
| {
readonly id: string;
readonly type: "directSocket";
readonly uri: string;
readonly path: string;
}
| {
readonly id: string;
readonly type: "directPipe";
readonly uri: string;
readonly path: string;
}
| {
readonly id: string;
readonly type: "remoteSsh";
readonly uri: string;
readonly host: string;
readonly sshPort?: number;
readonly daemonPort?: number;
}
| {
readonly id: string;
readonly type: "relay";
readonly uri: string;
readonly relayEndpoint: string;
readonly useTls: boolean;
readonly daemonPublicKeyB64: string;
readonly minimumSecurityMode?: "protected";
}
| {
readonly id: string;
readonly type: "routed";
readonly uri: null;
readonly originServerId: string;
};
export interface PluginHostPatch {
label?: string;
appearance?: Partial<PluginHostAppearance>;
}
export interface PluginHost extends PluginHostSummary {
readonly appearance: PluginHostAppearance;
readonly connections: readonly PluginHostConnection[];
readonly activeConnectionId: string | null;
readonly error: string | null;
readonly authFailureReason: "password_required" | "incorrect_password" | null;
readonly lastOnlineAt: string | null;
readonly createdAt: string;
readonly updatedAt: string;
update(patch: PluginHostPatch): Promise<void>;
/** Removes the host; throws for the desktop daemon host. */
remove(): Promise<void>;
/** Removing the last connection removes the host. */
removeConnection(connectionId: string): Promise<void>;
openSettings(): void;
}
export interface PluginHostAddInput {
/** Shared host connection grammar; may contain a password for the probe. */
connection: string;
label?: string;
}
export type PluginHostAddResult =
| { status: "saved"; host: PluginHost }
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
| { status: "cancelled" };
export declare function useHosts(): readonly PluginHost[];
export declare function observeHosts(listener: (hosts: readonly PluginHost[]) => void): () => void;
export declare function addHost(input: PluginHostAddInput): Promise<PluginHostAddResult>;
+1 -14
View File
@@ -86,20 +86,7 @@ export interface PluginHostSummary {
readonly status: "idle" | "connecting" | "online" | "offline" | "error"; readonly status: "idle" | "connecting" | "online" | "offline" | "error";
} }
/** Live configured hosts. Supplied by the app's client bundle loader. */ /** Live configured hosts. Supplied by the app's client bundle loader. */
export declare const useHosts: typeof import("./hosts.js").useHosts; export declare function useHosts(): readonly PluginHostSummary[];
export declare const observeHosts: typeof import("./hosts.js").observeHosts;
export declare const addHost: typeof import("./hosts.js").addHost;
export type {
PluginHost,
PluginHostStatus,
PluginHostColor,
PluginHostBadgeDisplay,
PluginHostAppearance,
PluginHostConnection,
PluginHostPatch,
PluginHostAddInput,
PluginHostAddResult,
} from "./hosts.js";
/** Borrow an online host's API under this installation's lifetime. */ /** Borrow an online host's API under this installation's lifetime. */
export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi; export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi;
/** Open an absolute HTTP(S) URL using the client platform’s external opener. */ /** Open an absolute HTTP(S) URL using the client platform’s external opener. */
-50
View File
@@ -1,55 +1,5 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { parseHostTarget } from "./host-target.js"; import { parseHostTarget } from "./host-target.js";
import { serializeSshTransportUri } from "./ssh-transport.js";
import { serializeSocketHostTarget } from "./host-target.js";
import {
parseConnectionUri,
serializeConnectionUri,
serializeRelayConnectionUri,
} from "./daemon-endpoints.js";
test("SSH and local socket targets round trip through their serializers", () => {
const ssh = parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777");
if (ssh.kind !== "ssh") throw new Error("expected SSH");
expect(parseHostTarget(serializeSshTransportUri(ssh.ssh))).toEqual(ssh);
for (const [type, path] of [
["unix", "/tmp/paseo.sock"],
["pipe", "\\\\.\\pipe\\paseo"],
] as const) {
const target = parseHostTarget(`${type}://${path}`);
expect(parseHostTarget(serializeSocketHostTarget(type, path))).toEqual(target);
}
});
test("TCP and relay targets round trip through credential-free serializers", () => {
const tcp = parseConnectionUri("tcp://example.com:6767?ssl=true&password=secret");
const tcpUri = serializeConnectionUri(tcp);
expect(tcpUri).not.toContain("secret");
expect(parseHostTarget(tcpUri)).toMatchObject({
connection: { type: "directTcp", endpoint: "example.com:6767", useTls: true },
});
const relay = parseHostTarget(
"relay://relay.example:443/server?key=public&ssl=true&password=secret",
);
if (relay.kind !== "socket" || relay.connection.type !== "relay")
throw new Error("expected relay");
const relayUri = serializeRelayConnectionUri({ offer: relay.connection.offer });
expect(relayUri).not.toContain("secret");
expect(parseHostTarget(relayUri)).toMatchObject({ connection: relay.connection });
});
test.each([
["example.com:6767", { type: "directTcp", endpoint: "example.com:6767", useTls: false }],
[
"tcp://example.com:6767?ssl=true",
{ type: "directTcp", endpoint: "example.com:6767", useTls: true },
],
["wss://example.com/ws", { type: "directTcp", endpoint: "example.com:443", useTls: true }],
["unix:///tmp/paseo.sock", { type: "directSocket", path: "/tmp/paseo.sock" }],
["pipe://mypipe", { type: "directPipe", path: "mypipe" }],
])("%s exposes its structured connection", (input, connection) => {
expect(parseHostTarget(input)).toMatchObject({ kind: "socket", connection });
});
test.each([ test.each([
["127.0.0.1:6767", "tcp://127.0.0.1:6767"], ["127.0.0.1:6767", "tcp://127.0.0.1:6767"],
+1 -26
View File
@@ -14,23 +14,12 @@ export type HostTarget =
| { | {
kind: "socket"; kind: "socket";
url: string; url: string;
connection:
| { type: "directTcp"; endpoint: string; useTls: boolean }
| { type: "directSocket"; path: string }
| { type: "directPipe"; path: string }
| { type: "relay"; offer: ConnectionOffer };
socketPath?: string; socketPath?: string;
password?: string; password?: string;
offer?: ConnectionOffer; offer?: ConnectionOffer;
description: string; description: string;
}; };
/** Serialize a local socket target in the shared host grammar. */
export function serializeSocketHostTarget(type: "unix" | "pipe", path: string): string {
if (!path || path === "~") throw new Error("Invalid socket path");
return `${type}://${path}`;
}
/** The shared saved-host and CLI grammar. Errors never echo credential-bearing input. */ /** The shared saved-host and CLI grammar. Errors never echo credential-bearing input. */
export function parseHostTarget(input: string): HostTarget { export function parseHostTarget(input: string): HostTarget {
try { try {
@@ -68,9 +57,6 @@ function parse(input: string): HostTarget {
const pipe = input.startsWith("pipe://") || input.startsWith("\\\\.\\pipe\\"); const pipe = input.startsWith("pipe://") || input.startsWith("\\\\.\\pipe\\");
return { return {
kind: "socket", kind: "socket",
connection: pipe
? { type: "directPipe", path: socketPath }
: { type: "directSocket", path: socketPath },
socketPath, socketPath,
url: pipe ? "ws://localhost/ws" : `ws+unix://${socketPath}:/ws`, url: pipe ? "ws://localhost/ws" : `ws+unix://${socketPath}:/ws`,
description: `${pipe ? "pipe" : "unix"}://${socketPath}`, description: `${pipe ? "pipe" : "unix"}://${socketPath}`,
@@ -81,7 +67,6 @@ function parse(input: string): HostTarget {
const endpoint = validatedEndpoint(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input); const endpoint = validatedEndpoint(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input);
return { return {
kind: "socket", kind: "socket",
connection: { type: "directTcp", endpoint, useTls: false },
url: buildDaemonWebSocketUrl(endpoint, { useTls: false }), url: buildDaemonWebSocketUrl(endpoint, { useTls: false }),
description: `tcp://${endpoint}`, description: `tcp://${endpoint}`,
}; };
@@ -90,7 +75,6 @@ function relayTarget(offer: ConnectionOffer, password?: string): HostTarget {
const endpoint = validatedEndpoint(offer.relay.endpoint); const endpoint = validatedEndpoint(offer.relay.endpoint);
return { return {
kind: "socket", kind: "socket",
connection: { type: "relay", offer },
url: buildRelayWebSocketUrl({ url: buildRelayWebSocketUrl({
endpoint, endpoint,
serverId: offer.serverId, serverId: offer.serverId,
@@ -113,7 +97,6 @@ function tcpTarget(input: string): HostTarget {
); );
return { return {
kind: "socket", kind: "socket",
connection: { type: "directTcp", endpoint, useTls: parts.useTls },
url: buildDaemonWebSocketUrl(endpoint, { useTls: parts.useTls }), url: buildDaemonWebSocketUrl(endpoint, { useTls: parts.useTls }),
password: parts.password, password: parts.password,
description: `tcp://${endpoint}`, description: `tcp://${endpoint}`,
@@ -126,15 +109,7 @@ function websocketTarget(input: string): HostTarget {
// Unknown query parameters may themselves contain credentials; discovery shows only the origin. // Unknown query parameters may themselves contain credentials; discovery shows only the origin.
if (url.pathname === "/") url.pathname = "/ws"; if (url.pathname === "/") url.pathname = "/ws";
url.searchParams.delete("password"); url.searchParams.delete("password");
const useTls = url.protocol === "wss:"; return { kind: "socket", url: url.toString(), password, description: url.origin };
const endpoint = `${url.hostname}:${url.port || (useTls ? "443" : "80")}`;
return {
kind: "socket",
connection: { type: "directTcp", endpoint, useTls },
url: url.toString(),
password,
description: url.origin,
};
} }
function sshTarget(input: string): HostTarget { function sshTarget(input: string): HostTarget {
-13
View File
@@ -60,19 +60,6 @@ export function parseSshTransportUri(value: string): SshTransportTarget {
}; };
} }
/** Serialize an SSH target without daemon credentials. */
export function serializeSshTransportUri(target: SshTransportTarget): string {
const host = validateSshHost(target.host);
const separator = host.lastIndexOf("@");
const username = separator < 0 ? "" : `${encodeURIComponent(host.slice(0, separator))}@`;
const hostname = host.slice(separator + 1);
const authority = hostname.includes(":") ? `[${hostname}]` : hostname;
const port = target.sshPort === undefined ? "" : `:${validatePort(target.sshPort, "SSH port")}`;
const daemonPort = validatePort(target.daemonPort, "Daemon port");
const query = daemonPort === DEFAULT_SSH_DAEMON_PORT ? "" : `?daemonPort=${daemonPort}`;
return `ssh://${username}${authority}${port}${query}`;
}
export function buildSshTunnelArgs(target: SshTransportTarget): string[] { export function buildSshTunnelArgs(target: SshTransportTarget): string[] {
const host = validateSshHost(target.host); const host = validateSshHost(target.host);
const daemonPort = validatePort(target.daemonPort, "Daemon port"); const daemonPort = validatePort(target.daemonPort, "Daemon port");
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration", "test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e", "test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts", "test:integration:real": "vitest run real.e2e.test.ts",
@@ -78,7 +78,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: OPENCODE_MODES, availableModes: OPENCODE_MODES,
}), }),
).toThrow( ).toThrow(
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode in settings.modeId. Available modes for 'opencode': build, plan", "cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan",
); );
}); });
@@ -92,7 +92,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: CODEX_MODES, availableModes: CODEX_MODES,
}), }),
).toThrow( ).toThrow(
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access", "cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
); );
}); });
@@ -180,7 +180,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
targetUnattendedMode: "full-access", targetUnattendedMode: "full-access",
}), }),
).toThrow( ).toThrow(
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access", "cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
); );
}); });
@@ -195,7 +195,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
targetUnattendedMode: undefined, targetUnattendedMode: undefined,
}), }),
).toThrow( ).toThrow(
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode in settings.modeId. Available modes for 'zai-custom': unknown", "cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode. Available modes for 'zai-custom': unknown",
); );
}); });
@@ -77,7 +77,7 @@ export function resolveAndValidateCreateAgentMode(
} }
throw new Error( throw new Error(
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode in settings.modeId. Available modes for '${targetProvider}': ${listModes(availableModes)}`, `cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
); );
} }
@@ -1,190 +0,0 @@
import { expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryFactory } from "./query.js";
const LIMIT = "You've hit your session limit · resets 5:20pm (Asia/Singapore)";
interface ScriptedTurn {
// An API error reply as Claude Code writes it: a synthetic assistant message with `error`.
errorReply?: string;
// Assistant text the model wrote before or after it.
before?: string;
after?: string;
isError: boolean;
usage?: {
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
totalCostUsd: number;
};
}
// A query dependency that speaks Claude Code's stream interface, one scripted turn per prompt.
function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory {
return ({ prompt }) => {
let index = 0;
const assistant = (id: string, text: string, error?: string) => ({
type: "assistant",
session_id: "error-session",
parent_tool_use_id: null,
uuid: id,
message: {
id,
role: "assistant",
model: error ? "<synthetic>" : "claude-opus-5-5",
content: [{ type: "text", text }],
stop_reason: "stop_sequence",
usage: { input_tokens: 0, output_tokens: 0 },
},
...(error ? { error } : {}),
});
const messages = (async function* () {
if (typeof prompt === "string") throw new Error("Expected streaming input");
for await (const _message of prompt) {
const turn = turns[index];
index += 1;
yield {
type: "system",
subtype: "init",
session_id: "error-session",
permissionMode: "default",
model: "claude-opus-5-5",
};
if (turn.before) yield assistant(`before-${index}`, turn.before);
if (turn.errorReply) yield assistant(`error-${index}`, turn.errorReply, "rate_limit");
if (turn.after) yield assistant(`after-${index}`, turn.after);
yield {
type: "result",
subtype: "success",
session_id: "error-session",
duration_ms: 1,
duration_api_ms: 0,
is_error: turn.isError,
num_turns: 1,
result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""),
stop_reason: "stop_sequence",
total_cost_usd: turn.usage?.totalCostUsd ?? 0,
usage: {
input_tokens: turn.usage?.inputTokens ?? 0,
output_tokens: turn.usage?.outputTokens ?? 0,
cache_read_input_tokens: turn.usage?.cachedInputTokens ?? 0,
},
modelUsage: {},
permission_denials: [],
uuid: `result-${index}`,
};
}
})();
return Object.assign(messages, {
close: () => {
void messages.return();
},
interrupt: async () => undefined,
setModel: async () => undefined,
setPermissionMode: async () => undefined,
supportedModels: async () => [],
supportedCommands: async () => [],
}) as ReturnType<ClaudeQueryFactory>;
};
}
async function runTurns(turns: ScriptedTurn[]) {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: scriptedQuery(turns),
resolveBinary: async () => process.execPath,
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
try {
const results = [];
for (const [index] of turns.entries()) {
results.push(await collectSessionTurnEvents(session, `turn ${index + 1}`));
}
return results;
} finally {
await session.close();
}
}
function assistantTexts(events: Awaited<ReturnType<typeof runTurns>>[number]): string[] {
return events.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
);
}
function terminal(events: Awaited<ReturnType<typeof runTurns>>[number]) {
return events.find(
(event) =>
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled",
);
}
test("a turn Claude Code ends on an API error fails with the error, shown once", async () => {
const [limited, overloaded, next] = await runTurns([
{ errorReply: LIMIT, isError: true },
{
before: "Checking the deploy doc.",
errorReply: "API Error: 529 Overloaded",
isError: true,
},
{ after: "Done.", isError: false },
]);
expect(terminal(limited)).toMatchObject({
type: "turn_failed",
error: LIMIT,
});
expect(assistantTexts(limited)).toEqual([]);
expect(terminal(overloaded)).toMatchObject({
type: "turn_failed",
error: "API Error: 529 Overloaded",
});
expect(assistantTexts(overloaded)).toEqual(["Checking the deploy doc."]);
// The session keeps working after a failed turn.
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(next)).toEqual(["Done."]);
});
test("a failed turn retains its final token counts and cost", async () => {
const usage = {
inputTokens: 120,
cachedInputTokens: 80,
outputTokens: 30,
totalCostUsd: 0.012,
};
const [failed] = await runTurns([
{ before: "Checking the deploy doc.", errorReply: LIMIT, isError: true, usage },
]);
expect(failed).toContainEqual(
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
);
expect(terminal(failed)).toMatchObject({ type: "turn_failed", error: LIMIT });
expect(assistantTexts(failed)).toEqual(["Checking the deploy doc."]);
});
test("an error reply the turn moves past is still shown", async () => {
const [recovered, completed] = await runTurns([
{
errorReply: "API Error: 529 Overloaded",
after: "Recovered.",
isError: false,
},
{ errorReply: LIMIT, isError: false },
]);
expect(terminal(recovered)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(recovered)).toEqual(["API Error: 529 Overloaded", "Recovered."]);
expect(terminal(completed)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(completed)).toEqual([LIMIT]);
});
@@ -1,4 +1,3 @@
import { constants as bufferConstants } from "node:buffer";
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
@@ -237,64 +236,4 @@ describe("importing a renamed Claude session", () => {
await expect(importSession(sessionId)).rejects.toThrow(); await expect(importSession(sessionId)).rejects.toThrow();
}); });
test("reads the rename from a transcript too large to hold as one string", async () => {
const sessionId = "oversized-transcript";
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
await fs.mkdir(projectDir, { recursive: true });
const transcript = path.join(projectDir, `${sessionId}.jsonl`);
// A hole stands in for the long session this used to fail on: the file only
// has to be longer than a string can be, and the rename sits at its end.
await fs.writeFile(transcript, "");
await fs.truncate(transcript, bufferConstants.MAX_STRING_LENGTH + 1);
await fs.appendFile(
transcript,
`\n${[
userRecord({ sessionId, content: "Trace the slow query" }),
{ type: "custom-title", customTitle: "Slow query hunt", sessionId },
]
.map((record) => JSON.stringify(record))
.join("\n")}\n`,
);
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Slow query hunt");
});
test("reads the rename from behind more records than one read covers", async () => {
const sessionId = "late-records-rename";
await writeSession({
sessionId,
records: [
userRecord({ sessionId, content: "Review this project" }),
{ type: "custom-title", customTitle: "Scrollback review", sessionId },
// One record longer than a single read, then enough records to leave the
// rename several reads back from the end of the file.
userRecord({ sessionId, content: "x".repeat(128 * 1024) }),
...Array.from({ length: 64 }, (_, index) =>
userRecord({ sessionId, content: `${"y".repeat(4 * 1024)} ${index}` }),
),
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Scrollback review");
});
test("imports a large record after the last rename without repeatedly copying it", async () => {
const sessionId = "large-record-after-rename";
await writeSession({
sessionId,
records: [
{ type: "custom-title", customTitle: "Large attachment review", sessionId },
userRecord({ sessionId, content: "x".repeat(96 * 1024 * 1024) }),
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Large attachment review");
});
}); });
@@ -1,48 +0,0 @@
import pino from "pino";
import { describe, expect, test } from "vitest";
import { ClaudeAgentClient } from "./agent.js";
describe("Claude runtime info before a turn", () => {
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
test("reports the explicitly configured model before the first turn", async () => {
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-haiku-5-5",
});
try {
expect(await session.getRuntimeInfo()).toMatchObject({ model: "claude-haiku-5-5" });
} finally {
await session.close();
}
});
test("reports the persisted configured model after rebuilding an idle session", async () => {
const session = await client.resumeSession({
provider: "claude",
sessionId: "runtime-model-before-turn",
metadata: { cwd: process.cwd(), model: "claude-haiku-5-5" },
});
try {
expect(await session.getRuntimeInfo()).toMatchObject({
sessionId: "runtime-model-before-turn",
model: "claude-haiku-5-5",
});
expect((await session.getRuntimeInfo()).model).toBe("claude-haiku-5-5");
expect((await session.getRuntimeInfo()).extra?.runtimeModel).toBeUndefined();
} finally {
await session.close();
}
});
test("leaves the model unknown when Claude will choose its default", async () => {
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
expect((await session.getRuntimeInfo()).model).toBeNull();
} finally {
await session.close();
}
});
});
@@ -2389,12 +2389,6 @@ class ClaudeAgentSession implements AgentSession {
private mainTurnInFlight = false; private mainTurnInFlight = false;
private foregroundHasVisibleActivity = false; private foregroundHasVisibleActivity = false;
private activeTurnHasAssistantText = false; private activeTurnHasAssistantText = false;
/**
* The reply Claude Code writes when a turn stops on an API error ("You've hit your session
* limit", "API Error: 529 ..."), held back until the result says whether the turn failed: a
* failed turn shows the same text as its error, so showing both would repeat it.
*/
private withheldErrorReply: AgentTimelineItem[] = [];
private readonly contextUsage: ClaudeContextUsageState; private readonly contextUsage: ClaudeContextUsageState;
private userMessageIds: string[] = []; private userMessageIds: string[] = [];
private readonly emittedUserMessageIds = new Set<string>(); private readonly emittedUserMessageIds = new Set<string>();
@@ -2466,7 +2460,7 @@ class ClaudeAgentSession implements AgentSession {
const info: AgentRuntimeInfo = { const info: AgentRuntimeInfo = {
provider: "claude", provider: "claude",
sessionId: this.claudeSessionId, sessionId: this.claudeSessionId,
model: this.lastOptionsModel ?? this.config.model ?? null, model: this.lastOptionsModel,
modeId: this.currentMode ?? null, modeId: this.currentMode ?? null,
...(this.lastRuntimeModel ...(this.lastRuntimeModel
? { ? {
@@ -2537,7 +2531,6 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundTurnId = turnId; this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false; this.foregroundHasVisibleActivity = false;
this.activeTurnHasAssistantText = false; this.activeTurnHasAssistantText = false;
this.withheldErrorReply = [];
this.contextUsage.beginTurn(); this.contextUsage.beginTurn();
this.transitionTurnState("foreground", "foreground turn started"); this.transitionTurnState("foreground", "foreground turn started");
this.clearRecentStderr(); this.clearRecentStderr();
@@ -4426,21 +4419,6 @@ class ClaudeAgentSession implements AgentSession {
}) satisfies AgentStreamEvent, }) satisfies AgentStreamEvent,
); );
if (!readClaudeParentToolUseId(message)) {
if (message.type === "assistant" && message.error) {
this.withheldErrorReply.push(...assistantTimelineEvents.map((event) => event.item));
return messageEvents;
}
if (
message.type === "assistant" ||
message.type === "user" ||
message.type === "stream_event"
) {
const released: AgentStreamEvent[] = [];
this.releaseWithheldErrorReply(released);
return [...released, ...messageEvents, ...assistantTimelineEvents];
}
}
return [...messageEvents, ...assistantTimelineEvents]; return [...messageEvents, ...assistantTimelineEvents];
} }
@@ -4988,23 +4966,6 @@ class ClaudeAgentSession implements AgentSession {
events: AgentStreamEvent[], events: AgentStreamEvent[],
): void { ): void {
const usage = this.convertUsage(message); const usage = this.convertUsage(message);
// Claude Code reports a turn that stopped on an API error (a usage limit, an overloaded or
// rejected request) as `success` with `is_error`, carrying the error text in `result`.
if (message.subtype === "success" && message.is_error) {
const withheld = this.withheldErrorReply;
this.withheldErrorReply = [];
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const withheldText = withheld
.map((item) => (item.type === "assistant_message" ? item.text : ""))
.join("\n")
.trim();
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
events.push(...this.sidechainTracker.finishAll("failed"));
events.push(this.buildTurnFailedEvent(resultText || withheldText || "Claude run failed"));
return;
}
const releasedErrorReply = this.withheldErrorReply.length > 0;
this.releaseWithheldErrorReply(events);
if (message.subtype === "success") { if (message.subtype === "success") {
events.push(...this.sidechainTracker.finishAll("completed")); events.push(...this.sidechainTracker.finishAll("completed"));
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …") // Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
@@ -5014,12 +4975,7 @@ class ClaudeAgentSession implements AgentSession {
// accounting from provider gateways does not duplicate streamed output. // accounting from provider gateways does not duplicate streamed output.
const resultText = typeof message.result === "string" ? message.result.trim() : ""; const resultText = typeof message.result === "string" ? message.result.trim() : "";
const outputTokens = message.usage?.output_tokens; const outputTokens = message.usage?.output_tokens;
if ( if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
resultText.length > 0 &&
outputTokens === 0 &&
!this.activeTurnHasAssistantText &&
!releasedErrorReply
) {
events.push({ events.push({
type: "timeline", type: "timeline",
provider: "claude", provider: "claude",
@@ -5041,14 +4997,6 @@ class ClaudeAgentSession implements AgentSession {
events.push(this.buildTurnFailedEvent(errorMessage)); events.push(this.buildTurnFailedEvent(errorMessage));
} }
private releaseWithheldErrorReply(events: AgentStreamEvent[]): void {
if (this.withheldErrorReply.length === 0) return;
for (const item of this.withheldErrorReply) {
events.push({ type: "timeline", item, provider: "claude" });
}
this.withheldErrorReply = [];
}
private createClaudeSessionChangedNotice( private createClaudeSessionChangedNotice(
oldSessionId: string, oldSessionId: string,
newSessionId: string, newSessionId: string,
@@ -6905,79 +6853,40 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
} }
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title"; const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
const CLAUDE_CUSTOM_TITLE_NEEDLE = Buffer.from(`"${CLAUDE_CUSTOM_TITLE_TYPE}"`);
const CLAUDE_TITLE_SCAN_CHUNK_BYTES = 64 * 1024;
function readClaudeCustomTitleFromRecord(record: Buffer): string | null {
// The needle also matches a prompt that quotes it, so a record still has to
// parse as a `custom-title` entry carrying a usable name. Records without the
// needle are never decoded, so a long one costs a byte search and nothing more.
if (!record.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(record.toString("utf8").trim());
} catch {
return null;
}
const entry = toObjectRecord(parsed);
if (!entry || entry.isSidechain || entry.type !== CLAUDE_CUSTOM_TITLE_TYPE) {
return null;
}
return normalizeClaudeSessionTitle(
typeof entry.customTitle === "string" ? entry.customTitle : null,
);
}
/** /**
* The `custom-title` record is re-emitted every time the session is saved, so * The `custom-title` record is re-emitted every time the session is saved, so
* the current name is the last usable one in the file. Reading backwards a chunk * the current name is the last usable one in the file. Searching backwards finds it without
* at a time finds it without walking the transcript, which matters because * walking the transcript, which matters because sessions reach tens of
* sessions reach tens of megabytes, and never holds the file as one string, so a * megabytes.
* transcript past V8's maximum string length still imports.
*/ */
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> { function readLastClaudeCustomTitle(content: string): string | null {
const handle = await fsPromises.open(filePath, "r"); const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
try { let at = content.lastIndexOf(needle);
const stats = await handle.stat(); while (at >= 0) {
if (!stats.isFile()) { const lineStart = content.lastIndexOf("\n", at) + 1;
// Opening a directory succeeds on Windows even though reading it does not, const lineEnd = content.indexOf("\n", at);
// so reject the way reading the transcript as a file would. const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd);
throw Object.assign( let parsed: unknown;
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`), try {
{ code: "EISDIR", path: filePath }, parsed = JSON.parse(line.trim());
} catch {
parsed = undefined;
}
const entry = toObjectRecord(parsed);
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
const title = normalizeClaudeSessionTitle(
typeof entry.customTitle === "string" ? entry.customTitle : null,
); );
} if (title) {
let end = stats.size; return title;
// Retain unfinished record pieces in reverse file order. Assemble each
// complete record once, so a large tool/image record costs linear work.
let pieces: Buffer[] = [];
while (end > 0) {
const length = Math.min(CLAUDE_TITLE_SCAN_CHUNK_BYTES, end);
const chunk = Buffer.alloc(length);
await handle.read(chunk, 0, length, end - length);
let recordEnd = chunk.length;
for (
let at = chunk.lastIndexOf(10);
at >= 0;
at = at === 0 ? -1 : chunk.lastIndexOf(10, at - 1)
) {
pieces.push(chunk.subarray(at + 1, recordEnd));
const record = Buffer.concat(pieces.toReversed());
const title = readClaudeCustomTitleFromRecord(record);
if (title) return title;
pieces = [];
recordEnd = at;
} }
if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd));
end -= length;
} }
// The first record in the file has no newline in front of it. // The needle also matches a prompt that quotes it, and a record can carry no
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed())); // usable name, so keep looking backwards for one that does.
} finally { at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
await handle.close();
} }
return null;
} }
function isErrnoException(error: unknown): error is NodeJS.ErrnoException { function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
@@ -6989,8 +6898,9 @@ async function readClaudeRenamedSessionTitle(input: {
sessionId: string; sessionId: string;
configDir: string; configDir: string;
}): Promise<string | null> { }): Promise<string | null> {
let content: string;
try { try {
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input)); content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
} catch (error) { } catch (error) {
// A session with no transcript on disk has no rename to carry over. Every // A session with no transcript on disk has no rename to carry over. Every
// other read failure is a real fault and stays explicit. // other read failure is a real fault and stays explicit.
@@ -6999,6 +6909,7 @@ async function readClaudeRenamedSessionTitle(input: {
} }
throw error; throw error;
} }
return readLastClaudeCustomTitle(content);
} }
function normalizeImportablePromptPreview(text: string): string | null { function normalizeImportablePromptPreview(text: string): string | null {
@@ -150,15 +150,6 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea
const TURN_START_TIMEOUT_MS = 90 * 1000; const TURN_START_TIMEOUT_MS = 90 * 1000;
const INTERRUPT_TIMEOUT_MS = 2_000; const INTERRUPT_TIMEOUT_MS = 2_000;
// `turn/interrupt` ends a turn but leaves the shell commands it started running.
// Each terminate is bounded by the first constant. A turn start waits for the
// interrupted turn's commands to end, at most the interrupt's own bound plus one
// terminate's and a margin, so it gives up only when a request outlives its timeout.
const INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS = 2_000;
const INTERRUPTED_TURN_CLEANUP_WAIT_MS =
INTERRUPT_TIMEOUT_MS + INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS + 500;
// Turns whose commands are remembered; older turns are forgotten first.
const INTERRUPTED_TURN_TRACKED_TURNS = 16;
const CODEX_PROVIDER = "codex" as const; const CODEX_PROVIDER = "codex" as const;
// These native provider identities belong to Codex, not to an older custom // These native provider identities belong to Codex, not to an older custom
// Paseo provider. Never include them in a custom provider's legacy import scan. // Paseo provider. Never include them in a custom provider's legacy import scan.
@@ -3485,14 +3476,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private pendingFileChangeOutputDeltas = new Map<string, string[]>(); private pendingFileChangeOutputDeltas = new Map<string, string[]>();
private pendingAssistantMessageBoundary = false; private pendingAssistantMessageBoundary = false;
private terminalCommandByProcessId = new Map<string, string>(); private terminalCommandByProcessId = new Map<string, string>();
// The root thread's still-running command process ids, keyed by the native turn that
// started them. Codex's `thread/backgroundTerminals/list` carries no turn, so
// `item/started` is the only record of which turn owns a process.
private terminalProcessIdsByTurnId = new Map<
string,
{ processIds: Set<string>; terminatedProcessIds: Set<string>; interruptAccepted: boolean }
>();
private interruptedTurnCleanup: Promise<void> | null = null;
private pendingUnlabeledTerminalInteractions = new Map< private pendingUnlabeledTerminalInteractions = new Map<
string, string,
Array<{ callId: string; stdin: string | null }> Array<{ callId: string; stdin: string | null }>
@@ -4448,9 +4431,6 @@ export class CodexAppServerAgentSession implements AgentSession {
hasDeveloperInstructions: turnStart.hasDeveloperInstructions, hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
hasCodexConfig: turnStart.hasCodexConfig, hasCodexConfig: turnStart.hasCodexConfig,
}); });
// The interrupted turn's commands end before this turn begins, so none of them
// outlives it into the new turn.
await this.waitForInterruptedTurnCleanup();
if (pendingStart.cancelRequested) { if (pendingStart.cancelRequested) {
throw new Error("Codex turn start was interrupted before reaching Codex"); throw new Error("Codex turn start was interrupted before reaching Codex");
} }
@@ -5056,27 +5036,15 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) { if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn"); throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
} }
const client = this.client;
const threadId = this.currentThreadId;
const interruptRequest = client.request(
"turn/interrupt",
{
threadId,
turnId,
},
INTERRUPT_TIMEOUT_MS,
);
// Once Codex accepts the interrupt, end the commands this turn started. Registered
// before the await, so a turn start racing this interrupt waits for it. An already-idle
// turn was not interrupted, and what it left running stays.
this.trackInterruptedTurnCleanup(
interruptRequest.then(
() => this.endInterruptedTurnProcesses(client, threadId, turnId),
() => undefined,
),
);
try { try {
await interruptRequest; await this.client.request(
"turn/interrupt",
{
threadId: this.currentThreadId,
turnId,
},
INTERRUPT_TIMEOUT_MS,
);
} catch (error) { } catch (error) {
if (!isCodexAlreadyIdleInterrupt(error)) { if (!isCodexAlreadyIdleInterrupt(error)) {
throw error; throw error;
@@ -5117,137 +5085,6 @@ export class CodexAppServerAgentSession implements AgentSession {
return !this.client.hasPendingRequests(); return !this.client.hasPendingRequests();
} }
// Remember which turn started a command, so an interrupt ends that turn's commands and
// no other turn's. Only the root thread's: a subagent's commands live on its own thread.
// A command that starts after its turn's interrupt was accepted is ended as soon as it
// is seen.
private recordTurnTerminalProcess(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): void {
const processId = parsed.item.processId;
if (
parsed.source !== "item" ||
!parsed.turnId ||
!parsed.threadId ||
parsed.threadId !== this.currentThreadId ||
typeof processId !== "string" ||
!processId ||
normalizeCodexThreadItemType(
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
) !== "commandExecution"
) {
return;
}
const record = this.terminalProcessIdsByTurnId.get(parsed.turnId);
if (parsed.kind === "item_completed") {
// Only a completed command has exited. A failed one can still be running:
// Codex reports an interrupted turn's live command as failed.
if (parsed.item.status === "completed") record?.processIds.delete(processId);
return;
}
const current = record ?? this.getTurnTerminalRecord(parsed.turnId);
current.processIds.add(processId);
if (current.interruptAccepted && this.client) {
this.trackInterruptedTurnCleanup(
this.endInterruptedTurnProcesses(this.client, parsed.threadId, parsed.turnId),
);
}
}
// An accepted interrupt needs a record even before any of its turn's commands is seen:
// an `item/started` can still be queued behind an earlier notification's conversion.
private getTurnTerminalRecord(turnId: string) {
const existing = this.terminalProcessIdsByTurnId.get(turnId);
if (existing) return existing;
const record = {
processIds: new Set<string>(),
terminatedProcessIds: new Set<string>(),
interruptAccepted: false,
};
this.terminalProcessIdsByTurnId.set(turnId, record);
for (const oldestTurnId of this.terminalProcessIdsByTurnId.keys()) {
if (this.terminalProcessIdsByTurnId.size <= INTERRUPTED_TURN_TRACKED_TURNS) break;
this.terminalProcessIdsByTurnId.delete(oldestTurnId);
}
return record;
}
// A later sweep joins the pending one, so a turn start waits for both.
private trackInterruptedTurnCleanup(cleanup: Promise<void>): void {
const tracked = Promise.all([this.interruptedTurnCleanup, cleanup])
.then(() => undefined)
.finally(() => {
if (this.interruptedTurnCleanup === tracked) this.interruptedTurnCleanup = null;
});
this.interruptedTurnCleanup = tracked;
}
// Terminate each process the interrupted turn started, by its own id, once each.
// `thread/backgroundTerminals/clean` is never used: it ends every terminal on the thread,
// an earlier turn's included. A process already gone answers `terminated: false`. Never
// rejects; failures are logged.
private async endInterruptedTurnProcesses(
client: CodexAppServerClient,
threadId: string | null,
turnId: string,
): Promise<void> {
if (!threadId) return;
const record = this.getTurnTerminalRecord(turnId);
record.interruptAccepted = true;
const processIds = [...record.processIds].filter(
(processId) => !record.terminatedProcessIds.has(processId),
);
for (const processId of processIds) record.terminatedProcessIds.add(processId);
const logContext = { agentId: this.agentId, provider: CODEX_PROVIDER, threadId, turnId };
await Promise.all(
processIds.map(async (processId) => {
try {
const response = await client.request(
"thread/backgroundTerminals/terminate",
{ threadId, processId },
INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS,
);
this.logger.info(
{ ...logContext, processId, terminated: toObjectRecord(response)?.terminated ?? null },
"provider.codex.interrupt.terminal_terminated",
);
} catch (error) {
this.logger.warn(
{ ...logContext, processId, err: error },
"provider.codex.interrupt.terminal_terminate_failed",
);
}
}),
);
}
// A new turn starts only after every pending sweep of interrupted turns' commands has
// ended, or after the bound, whichever is first.
private async waitForInterruptedTurnCleanup(): Promise<void> {
if (!this.interruptedTurnCleanup) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const timedOut = await Promise.race([
(async () => {
while (this.interruptedTurnCleanup) await this.interruptedTurnCleanup;
return false;
})(),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(true), INTERRUPTED_TURN_CLEANUP_WAIT_MS);
}),
]);
clearTimeout(timer);
if (timedOut) {
this.logger.warn(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
waitMs: INTERRUPTED_TURN_CLEANUP_WAIT_MS,
},
"provider.codex.interrupt.terminal_cleanup_timed_out",
);
}
}
async close(): Promise<void> { async close(): Promise<void> {
this.closed = true; this.closed = true;
this.clearPendingPermissions(); this.clearPendingPermissions();
@@ -6372,11 +6209,6 @@ export class CodexAppServerAgentSession implements AgentSession {
return; return;
} }
this.completePendingRootCompactions(); this.completePendingRootCompactions();
// What a turn that was not interrupted leaves running, it left on purpose; no later
// interrupt may end it.
if (parsed.status !== "interrupted" && this.currentTurnId) {
this.terminalProcessIdsByTurnId.delete(this.currentTurnId);
}
if (parsed.status === "failed") { if (parsed.status === "failed") {
this.emitEvent({ this.emitEvent({
type: "turn_failed", type: "turn_failed",
@@ -6781,7 +6613,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private async handleItemCompletedNotification( private async handleItemCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>, parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
): Promise<void> { ): Promise<void> {
this.recordTurnTerminalProcess(parsed);
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*` // Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
// and canonical `item/*`. Render ordinary items only from the canonical // and canonical `item/*`. Render ordinary items only from the canonical
// channel, but accept a legacy-only child announcement so it can establish // channel, but accept a legacy-only child announcement so it can establish
@@ -6944,7 +6775,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private async handleItemStartedNotification( private async handleItemStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>, parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
): Promise<void> { ): Promise<void> {
this.recordTurnTerminalProcess(parsed);
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) { if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return; return;
} }
@@ -1,374 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { AgentSession } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
waitForNextEvent,
waitForTimelineToolCall,
} from "./codex/test-utils/fake-app-server.js";
const THREAD = "thread-1";
const TERMINATE = "thread/backgroundTerminals/terminate";
type Handler = (params: unknown) => unknown;
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function paramsOf(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
let sessions: AgentSession[] = [];
afterEach(async () => {
for (const session of sessions) await session.close().catch(() => undefined);
sessions = [];
});
/**
* A Codex app-server whose interrupt ends the turn the way Codex does: an `interrupted`
* turn completion, then the response. `terminate` answers per process.
*/
function createHarness(
options: {
terminate?: (processId: string) => unknown;
interruptResponse?: (completeTurn: () => void) => unknown;
} = {},
) {
const handlers: Record<string, Handler> = {
"turn/interrupt": () => {
const completeTurn = () =>
appServer.completeTurn({ threadId: THREAD, status: "interrupted" });
if (options.interruptResponse) return options.interruptResponse(completeTurn);
completeTurn();
return {};
},
[TERMINATE]: (params) =>
options.terminate?.(String(paramsOf(params).processId)) ?? { terminated: true },
};
const appServer: FakeCodexAppServer = createFakeCodexAppServer(handlers);
const session = new CodexAppServerAgentSession(
{
provider: "codex",
cwd: "/workspace/project",
modeId: "auto",
model: "gpt-5.4",
},
null,
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
function requests(method: string): Record<string, unknown>[] {
return appServer
.requests()
.filter((message) => message.method === method && "id" in message)
.map((message) => paramsOf(message.params));
}
function terminated(): string[] {
return requests(TERMINATE)
.map((params) => String(params.processId))
.sort();
}
// Starts a turn and lets Codex identify it as `nativeTurnId`.
async function startTurn(prompt: string, nativeTurnId: string): Promise<void> {
const before = requests("turn/start").length;
const started = session.startTurn(prompt);
await vi.waitFor(() => expect(requests("turn/start")).toHaveLength(before + 1), {
timeout: 5_000,
});
const turnStarted = waitForNextEvent(session, "turn_started");
appServer.startsTurn({ threadId: THREAD, turnId: nativeTurnId });
await started;
await turnStarted;
}
async function runsCommand(params: { turnId: string; processId: string }): Promise<void> {
const itemId = `exec-${params.processId}`;
const shown = waitForTimelineToolCall(session, itemId);
appServer.startsTerminalCommand({
threadId: THREAD,
turnId: params.turnId,
itemId,
processId: params.processId,
command: `sleep ${params.processId}`,
});
await shown;
}
async function completesTurn(): Promise<void> {
const completed = waitForNextEvent(session, "turn_completed");
appServer.completeTurn({ threadId: THREAD });
await completed;
}
// Interrupts the running turn and waits for the session to report it canceled, as a
// caller does before it starts the next turn: Codex notifications are handled in order
// but asynchronously, so the interrupt's answer can arrive before the turn has ended.
async function interrupts(): Promise<void> {
const canceled = waitForNextEvent(session, "turn_canceled");
await session.interrupt();
await canceled;
}
return {
appServer,
session,
requests,
terminated,
startTurn,
runsCommand,
completesTurn,
interrupts,
};
}
// Turn A leaves a server running on purpose and completes. Turn B starts two
// commands, and a subagent on its own thread starts a third, then B is interrupted.
async function interruptTurnB(harness: ReturnType<typeof createHarness>): Promise<void> {
await harness.startTurn("start a server", "native-A");
await harness.runsCommand({ turnId: "native-A", processId: "101" });
await harness.completesTurn();
await harness.startTurn("run two commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
// A subagent thread's command shows no row here; the next command's row shows it
// was read, because notifications are read in order.
harness.appServer.startsTerminalCommand({
threadId: "child-thread",
turnId: "native-B",
itemId: "exec-209",
processId: "209",
command: "sleep 209",
});
await harness.runsCommand({ turnId: "native-B", processId: "202" });
await harness.interrupts();
}
describe("an interrupted Codex turn's commands end before the next turn", () => {
test("terminates exactly the processes the interrupted turn started, by id, and never cleans", async () => {
const harness = createHarness();
await interruptTurnB(harness);
await harness.startTurn("correction", "native-C");
expect(harness.terminated()).toEqual(["201", "202"]);
expect(harness.requests(TERMINATE).every((params) => params.threadId === THREAD)).toBe(true);
// `clean` would end every terminal on the thread, an earlier turn's included.
expect(
harness.appServer
.requests()
.filter((message) => String(message.method).startsWith("thread/backgroundTerminals/"))
.map((message) => message.method),
).toEqual([TERMINATE, TERMINATE]);
harness.appServer.assertNoErrors();
});
test("leaves the next turn's commands running when Codex starts that turn before acknowledging the interrupt", async () => {
const acknowledge = deferred();
const harness = createHarness({
// Codex ends B, starts a turn of its own (a goal continuation), and only then
// answers the interrupt.
interruptResponse: (completeTurn) => {
completeTurn();
return acknowledge.promise.then(() => ({}));
},
});
await harness.startTurn("run two commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.runsCommand({ turnId: "native-B", processId: "202" });
const interrupting = harness.session.interrupt();
await vi.waitFor(() => expect(harness.requests("turn/interrupt")).toHaveLength(1));
const autonomousTurn = waitForNextEvent(harness.session, "turn_started");
harness.appServer.startsTurn({ threadId: THREAD, turnId: "native-C" });
await autonomousTurn;
await harness.runsCommand({ turnId: "native-C", processId: "301" });
acknowledge.resolve();
await interrupting;
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(harness.terminated()).toEqual(["201", "202"]);
});
test("sends the next turn/start only after the terminates are answered", async () => {
const answer = deferred();
const harness = createHarness({
terminate: () => answer.promise.then(() => ({ terminated: true })),
});
await interruptTurnB(harness);
const next = harness.startTurn("correction", "native-C");
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(harness.requests("turn/start")).toHaveLength(2);
answer.resolve();
await next;
expect(harness.requests("turn/start")).toHaveLength(3);
});
test.each(["initial", "late"] as const)(
"tracks both cleanup sweeps when the %s answer arrives first",
async (answeredFirst) => {
const first = deferred();
const second = deferred();
const harness = createHarness({
terminate: (processId) =>
(processId === "201" ? first : second).promise.then(() => ({ terminated: true })),
});
await harness.startTurn("run commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.interrupts();
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201"]));
const next = harness.startTurn("correction", "native-C");
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
// The next turn is already waiting when another process is announced.
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-202",
processId: "202",
command: "sleep 202",
});
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "duplicate-202",
processId: "202",
command: "sleep 202",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
(answeredFirst === "initial" ? first : second).resolve();
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
(answeredFirst === "initial" ? second : first).resolve();
await next;
expect(harness.terminated()).toEqual(["201", "202"]);
// Accepted state survives the completed sweep and a newer foreground turn.
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-203",
processId: "203",
command: "sleep 203",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202", "203"]));
harness.appServer.assertNoErrors();
},
);
test("keeps the first sweep pending when a late sweep answers before the next start", async () => {
const first = deferred();
const harness = createHarness({
terminate: (processId) =>
processId === "201"
? first.promise.then(() => ({ terminated: true }))
: { terminated: true },
});
await harness.startTurn("run commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.interrupts();
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-202",
processId: "202",
command: "sleep 202",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
const next = harness.startTurn("correction", "native-C");
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
first.resolve();
await next;
harness.appServer.assertNoErrors();
});
test("forgets completed commands but retains failed commands at interrupt", async () => {
const harness = createHarness();
await harness.startTurn("run commands", "native-B");
for (const processId of ["201", "202"]) {
await harness.runsCommand({ turnId: "native-B", processId });
harness.appServer.child.stdout.write(
JSON.stringify({
method: "item/completed",
params: {
threadId: THREAD,
turnId: "native-B",
item: {
id: `exec-${processId}`,
type: "commandExecution",
processId,
command: `sleep ${processId}`,
status: processId === "201" ? "completed" : "failed",
exitCode: processId === "201" ? 0 : null,
},
},
}) + "\n",
);
}
// A following command's row is a barrier for both completion handlers.
await harness.runsCommand({ turnId: "native-B", processId: "203" });
await harness.interrupts();
await harness.startTurn("correction", "native-C");
expect(harness.terminated()).toEqual(["202", "203"]);
harness.appServer.assertNoErrors();
});
test("retains accepted interrupts for only the latest sixteen turns", async () => {
const harness = createHarness();
for (let index = 0; index < 17; index++) {
await harness.startTurn("run a command", `native-${index}`);
await harness.interrupts();
}
await harness.startTurn("correction", "native-current");
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-0",
itemId: "evicted-201",
processId: "201",
command: "sleep 201",
});
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-16",
itemId: "retained-202",
processId: "202",
command: "sleep 202",
});
await harness.runsCommand({ turnId: "native-current", processId: "301" });
expect(harness.terminated()).toEqual(["202"]);
harness.appServer.assertNoErrors();
});
test("a failing or unanswered terminate does not block or fail the next turn", async () => {
const harness = createHarness({
// 201 is refused while the next turn waits; 202 is never answered.
terminate: (processId) =>
processId === "201"
? new Promise((resolve) => setTimeout(resolve, 300)).then(() => ({
__jsonRpcError: { code: -32600, message: "invalid background terminal process id" },
}))
: new Promise(() => {}),
});
await interruptTurnB(harness);
const startedAt = Date.now();
await harness.startTurn("correction", "native-C");
expect(Date.now() - startedAt).toBeLessThan(5_000);
expect(harness.requests("turn/start")).toHaveLength(3);
expect(harness.terminated()).toEqual(["201", "202"]);
}, 15_000);
});
@@ -4,102 +4,6 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import type { V2Api } from "./api.js"; import type { V2Api } from "./api.js";
import { OpenCodeV2AgentClient } from "./agent.js"; import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js"; import { V2Harness } from "../test-utils/v2-harness.js";
import type { AgentPermissionResponse } from "../../../agent-sdk-types.js";
describe("OpenCode v2 denial reasons", () => {
test.each<{
response: AgentPermissionResponse;
decision: "reject" | "once" | "always";
message?: string;
}>([
{
response: { behavior: "deny", message: "Use the existing file instead." },
decision: "reject",
message: "Use the existing file instead.",
},
{ response: { behavior: "deny" }, decision: "reject" },
{ response: { behavior: "allow" }, decision: "once" },
{ response: { behavior: "allow", selectedActionId: "always" }, decision: "always" },
])("preserves a permission response: $response", async ({ response, decision, message }) => {
const harness = new V2Harness();
let pending = true;
harness.api.permission.list = async () =>
pending
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
: [];
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
harness.api.permission.reply = async (input) => {
replies.push(input);
pending = false;
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
await session.respondToPermission("approval", response);
expect(replies).toEqual([
{
sessionID: "session",
requestID: "approval",
decision,
...(message === undefined ? {} : { message }),
},
]);
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
}
});
test.each([true, false])(
"explains cards superseded by a steer when clearing is %s",
async (clearPendingPermissions) => {
const harness = new V2Harness();
harness.autoComplete = false;
let pending = true;
harness.api.permission.list = async () =>
pending
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
: [];
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
harness.api.permission.reply = async (input) => {
replies.push(input);
pending = false;
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
const { turnId } = await session.startTurn("Run pwd");
await expect.poll(() => harness.prompts).toEqual(["Run pwd"]);
await expect(
session.steerActiveTurn!("go ahead", { expectedTurnId: turnId, clearPendingPermissions }),
).resolves.toEqual({ status: "accepted" });
expect(harness.prompts).toEqual(["Run pwd", "go ahead"]);
expect(replies).toEqual(
clearPendingPermissions
? [
{
sessionID: "session",
requestID: "approval",
decision: "reject",
message:
"The user answered with a message instead of approving. Their message follows.",
},
]
: [],
);
expect(session.getPendingPermissions()).toHaveLength(clearPendingPermissions ? 0 : 1);
} finally {
await session.close();
}
},
);
});
describe("OpenCode v2 questions", () => { describe("OpenCode v2 questions", () => {
test("maps selected labels to native values and parses numeric and boolean answers", async () => { test("maps selected labels to native values and parses numeric and boolean answers", async () => {
@@ -68,9 +68,6 @@ export class SessionPermissions {
sessionID: this.permissionOwners.get(requestId) ?? this.id, sessionID: this.permissionOwners.get(requestId) ?? this.id,
requestID: requestId, requestID: requestId,
decision: permissionReply(response), decision: permissionReply(response),
...(response.behavior === "deny" && response.message !== undefined
? { message: response.message }
: {}),
}); });
} }
this.resolvePending(requestId, response); this.resolvePending(requestId, response);
@@ -100,11 +100,7 @@ export class OpenCodeV2Session implements AgentSession {
), ),
clearPermissions: async () => { clearPermissions: async () => {
for (const request of this.permissions.list()) for (const request of this.permissions.list())
await this.permissions.respondToPermission(request.id, { await this.permissions.respondToPermission(request.id, { behavior: "deny" });
behavior: "deny",
message:
"The user answered with a message instead of approving. Their message follows.",
});
}, },
}); });
this.usage = new SessionUsage({ this.usage = new SessionUsage({
@@ -1590,7 +1590,7 @@ function createToolCatalog(
{ {
title: "Create agent", title: "Create agent",
description: description:
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. For a different provider, set settings.modeId from inspect_provider; omission is accepted for targets with no modes and for unattended execution supported by the target. Do not guess; call list_providers and list_models first if uncertain.", "Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.",
inputSchema: createAgentInputSchema, inputSchema: createAgentInputSchema,
outputSchema: { outputSchema: {
agentId: z.string(), agentId: z.string(),
@@ -183,7 +183,7 @@ test("ordinary routed APIs isolate scopes and enforce one-hop, disable and origi
await paseo.dispose(); await paseo.dispose();
}); });
test("routed authority isolates simultaneous sessions and preserves ordinary permission RPC errors", async () => { test("routed authority is bounded by the caller, with isolated simultaneous sessions", async () => {
const b = await daemon(); const b = await daemon();
const a = await daemon(); const a = await daemon();
const owner = await api(a); const owner = await api(a);
@@ -210,16 +210,33 @@ test("routed authority isolates simultaneous sessions and preserves ordinary per
code: "permission_denied", code: "permission_denied",
}); });
expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1); expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1);
const directClient = createHostClient(`127.0.0.1:${b.port}`, { await remote.dispose();
clientId: crypto.randomUUID(), });
sessionPermissions: ["daemon.read", "workspace.read"],
reconnect: { enabled: false }, test("SDK host clients preserve ordinary permission RPC errors", async () => {
}); const b = await daemon();
await directClient.connect(); const a = await daemon();
const direct = createPaseoApi(directClient); const owner = await api(a);
await owner.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
const serverId = (await online(owner.paseo)).hosts[0]!.serverId!;
const clients = [a, b].map((host) =>
createHostClient(`127.0.0.1:${host.port}`, {
clientId: crypto.randomUUID(),
sessionPermissions: ["daemon.read", "workspace.read"],
reconnect: { enabled: false },
}),
);
for (const client of clients) {
cleanup.push(() => client.close());
await client.connect();
}
const origin = createPaseoApi(clients[0]!);
const direct = createPaseoApi(clients[1]!);
const remote = await origin.hosts.client(serverId);
cleanup.push(async () => { cleanup.push(async () => {
await remote.dispose();
await direct.dispose(); await direct.dispose();
await directClient.close(); await origin.dispose();
}); });
const input = { source: { kind: "directory" as const, path: b.staticDir } }; const input = { source: { kind: "directory" as const, path: b.staticDir } };
const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as { const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as {
@@ -230,7 +247,6 @@ test("routed authority isolates simultaneous sessions and preserves ordinary per
}; };
expect(directError.code).toBe("access_denied"); expect(directError.code).toBe("access_denied");
expect(remoteError.code).toBe(directError.code); expect(remoteError.code).toBe(directError.code);
await remote.dispose();
}); });
test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => { test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => {
@@ -1,67 +0,0 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { expect, test } from "vitest";
import { createDaemonTestContext } from "../test-utils/index.js";
test("an attended MCP caller can discover and supply a cross-provider child mode", async () => {
const artifacts = path.join(process.cwd(), ".artifacts");
await mkdir(artifacts, { recursive: true });
const root = await mkdtemp(path.join(artifacts, "create-agent-mode-"));
const ctx = await createDaemonTestContext({ paseoHomeRoot: root, mcpEnabled: true });
const mcp = new Client({ name: "mode-contract-test", version: "1" });
try {
const parent = await ctx.client.createAgent({
provider: "claude",
cwd: root,
modeId: "default",
title: "Attended parent",
});
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${ctx.daemon.port}/mcp/agents?callerAgentId=${parent.id}`),
),
);
const { tools } = await mcp.listTools();
const create = tools.find((tool) => tool.name === "create_agent");
expect.soft(create?.description).toContain("settings.modeId");
const args = {
provider: "codex/gpt-5.4-mini",
title: "Cross-provider child",
initialPrompt: "Reply with OK",
};
const refused = await mcp.callTool({ name: "create_agent", arguments: args });
expect(refused.isError).toBe(true);
expect.soft(refused.content).toEqual([
expect.objectContaining({
type: "text",
text: expect.stringContaining("Pass an explicit mode in settings.modeId"),
}),
]);
expect((await ctx.client.fetchAgents({ subscribe: {} })).entries).toHaveLength(1);
const inspected = await mcp.callTool({
name: "inspect_provider",
arguments: { provider: "codex" },
});
expect(inspected.isError).not.toBe(true);
expect(inspected.structuredContent).toMatchObject({
modes: expect.arrayContaining([expect.objectContaining({ id: "auto" })]),
});
const created = await mcp.callTool({
name: "create_agent",
arguments: { ...args, settings: { modeId: "auto" } },
});
expect(created.isError).not.toBe(true);
expect(created.structuredContent).toMatchObject({ type: "codex", currentModeId: "auto" });
const childId = (created.structuredContent as { agentId: string }).agentId;
expect((await ctx.client.fetchAgent(childId))?.agent.currentModeId).toBe("auto");
expect((await ctx.client.waitForFinish(childId, 5000)).status).toBe("idle");
} finally {
await mcp.close();
await ctx.cleanup();
await rm(root, { recursive: true, force: true });
}
}, 30_000);
@@ -102,52 +102,53 @@ describe("daemon E2E", () => {
}, },
); );
test("downloads ASCII and Unicode filenames through one workspace", async () => { test.each([
const cwd = tmpCwd(); { fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
try { {
const agent = await ctx.client.createAgent({ fileName: "café.txt",
provider: "codex", disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
model: CODEX_TEST_MODEL, },
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, {
cwd, fileName: "中文报告 (最终版).txt",
title: "Download Token Test Agent", disposition:
}); "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
},
expect(agent.id).toMatch( {
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, fileName: "報告書.md",
); disposition:
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
for (const { fileName, disposition } of [ },
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' }, {
{ fileName: "보고서.txt",
fileName: "café.txt", disposition:
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", "attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
}, },
{ {
fileName: "中文报告 (最终版).txt", fileName: "report-📄.txt",
disposition: disposition:
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
}, },
{ ])(
fileName: "報告書.md", "issues token over WS and downloads $fileName via HTTP",
disposition: async ({ fileName, disposition }) => {
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md", const cwd = tmpCwd();
}, try {
{
fileName: "보고서.txt",
disposition:
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
},
{
fileName: "report-📄.txt",
disposition:
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
},
]) {
const filePath = path.join(cwd, fileName); const filePath = path.join(cwd, fileName);
const fileContents = `download test payload: ${fileName}`; const fileContents = "download test payload";
writeFileSync(filePath, fileContents, "utf-8"); writeFileSync(filePath, fileContents, "utf-8");
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Download Token Test Agent",
});
expect(agent.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName); const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
expect(tokenResponse.error).toBeNull(); expect(tokenResponse.error).toBeNull();
@@ -166,11 +167,12 @@ describe("daemon E2E", () => {
const body = await response.text(); const body = await response.text();
expect(body).toBe(fileContents); expect(body).toBe(fileContents);
} finally {
rmSync(cwd, { recursive: true, force: true });
} }
} finally { },
rmSync(cwd, { recursive: true, force: true }); 60000,
} );
}, 60000);
test("rejects invalid token", async () => { test("rejects invalid token", async () => {
const response = await fetch( const response = await fetch(
@@ -1,129 +0,0 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import type { FormInfo } from "@opencode/client";
import { expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { OpenCodeV2AgentClient } from "../agent/providers/opencode/v2/agent.js";
import { V2Harness } from "../agent/providers/opencode/test-utils/v2-harness.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
async function createQuestionHarness() {
const artifacts = path.resolve(".artifacts");
await mkdir(artifacts, { recursive: true });
const root = await mkdtemp(path.join(artifacts, "peer-permissions-"));
const runtime = new V2Harness();
runtime.autoComplete = false;
runtime.api.model.list = async () => ({ location: runtime.info.location, data: [] });
runtime.api.provider.list = async () => ({
location: runtime.info.location,
data: [{ id: "test", name: "Test", activation: "enabled", package: "test" }],
});
let form: FormInfo | null = null;
const answers: Array<Record<string, unknown>> = [];
runtime.api.session.form.list = async () => (form ? [form] : []);
runtime.api.session.form.cancel = async () => {
form = null;
runtime.finishExecution("interrupted");
};
runtime.api.session.form.reply = async ({ answer }) => {
answers.push(answer);
form = null;
runtime.finishExecution();
};
// A steer is admitted to the existing execution, rather than starting another one.
runtime.prompt = async (input) => {
runtime.prompts.push(input.text);
if (runtime.prompts.length === 1) runtime.startExecution();
};
const provider = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: runtime.runtime,
});
const daemon = await createTestPaseoDaemon({
agentClients: { ...createTestAgentClients(), opencode: provider },
paseoHomeRoot: path.join(root, "daemon"),
staticDir: path.join(root, "static"),
cleanup: false,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const recipient = await client.createAgent({
provider: "opencode",
cwd: root,
title: "Waiting for a human answer",
});
const sender = await client.createAgent({ provider: "claude", cwd: root });
await client.sendMessage(recipient.id, "Ask which color to use");
await expect.poll(() => runtime.prompts).toHaveLength(1);
form = {
id: "color-question",
sessionID: "session",
title: "Color",
fields: [{ key: "color", title: "Color", type: "string" }],
};
runtime.push({ id: "question-created", created: 2, type: "form.created", data: { form } });
await client.waitForAgentUpsert(recipient.id, (agent) =>
(agent.pendingPermissions ?? []).some((request) => request.id === "color-question"),
);
return {
client,
recipient,
sender,
answers,
async close() {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
},
};
} catch (error) {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
throw error;
}
}
test("an agent-sourced steer leaves the question pending and answerable", async () => {
const { client, recipient, sender, answers, close } = await createQuestionHarness();
try {
await client.sendAgentMessage(recipient.id, "Peer status check", {
sourceAgentId: sender.id,
});
// The public wait includes admitted message delivery before reporting permission status.
const waiting = await client.waitForFinish(recipient.id);
expect(waiting.status).toBe("permission");
expect(waiting.final?.pendingPermissions).toMatchObject([
{ id: "color-question", kind: "question" },
]);
await client.respondToPermission(recipient.id, "color-question", {
behavior: "allow",
updatedInput: { answers: { Color: "Blue" } },
});
await client.waitForAgentUpsert(
recipient.id,
(agent) => (agent.pendingPermissions ?? []).length === 0 && agent.status === "idle",
);
expect(answers).toEqual([{ color: "Blue" }]);
} finally {
await close();
}
});
test("a human steer still dismisses the question", async () => {
const { client, recipient, answers, close } = await createQuestionHarness();
try {
await client.sendAgentMessage(recipient.id, "Use red instead", { activeTurnBehavior: "steer" });
const finished = await client.waitForFinish(recipient.id);
expect(finished.status).toBe("idle");
expect(finished.final?.pendingPermissions ?? []).toEqual([]);
expect(answers).toEqual([]);
} finally {
await close();
}
});
+1 -1
View File
@@ -8318,7 +8318,7 @@ export class Session {
}, },
activeTurnBehavior: msg.activeTurnBehavior ?? "steer", activeTurnBehavior: msg.activeTurnBehavior ?? "steer",
sessionMode: msg.sessionMode, sessionMode: msg.sessionMode,
clearPendingPermissions: false, clearPendingPermissions: true,
}); });
deliveryStatus = "queued"; deliveryStatus = "queued";
queued(); queued();
@@ -1,4 +1,3 @@
import { flushGitCommandTrace } from "../utils/git-command-trace.js";
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
@@ -731,103 +730,3 @@ test("Git event identity retains a symlink's literal target before canonicalizin
rmSync(root, { recursive: true, force: true }); rmSync(root, { recursive: true, force: true });
} }
}); });
test.skipIf(process.platform !== "linux")(
"missing folders stop degraded Git polling and explicit refreshes, then recover",
async () => {
const artifacts = path.resolve(".artifacts");
mkdirSync(artifacts, { recursive: true });
const root = mkdtempSync(path.join(artifacts, "workspace-git-missing-"));
const cwd = path.join(root, "repo");
const initializeRepo = (branch: string) => {
mkdirSync(cwd);
execFileSync("git", ["init", "-b", branch], { cwd, stdio: "pipe" });
writeFileSync(path.join(cwd, "README.md"), "fixture\n");
execFileSync("git", ["add", "."], { cwd, stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.invalid",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"fixture",
],
{ cwd, stdio: "pipe" },
);
};
initializeRepo("main");
// Exceed the real Linux observer's directory limit to enter degraded polling.
// No watcher errors or Git responses are injected.
for (let index = 0; index < 5100; index++) {
mkdirSync(path.join(cwd, `generated-${index}`));
}
const trace = path.join(root, "git.jsonl");
writeFileSync(trace, "");
const previousTrace = process.env.PASEO_GIT_TRACE_FILE;
process.env.PASEO_GIT_TRACE_FILE = trace;
let degraded = false;
let suspended = false;
const logger = pino(
{ level: "warn" },
{
write(line) {
const message = JSON.parse(line).msg;
if (message === "Failed to start working tree watcher; using degraded polling")
degraded = true;
if (
message ===
"Workspace folder no longer exists; suspended git observation until it is back"
)
suspended = true;
},
},
);
const service = new WorkspaceGitServiceImpl({ logger, paseoHome: path.join(root, "home") });
const subscription = service.registerWorkspace({ cwd }, () => {});
const commandCount = async () => {
await flushGitCommandTrace();
return readFileSync(trace, "utf8").trim().split("\n").filter(Boolean).length;
};
try {
await expect
.poll(() => degraded && service.getMetrics().workspaceRefreshInFlightCount === 0, {
timeout: 15_000,
})
.toBe(true);
rmSync(cwd, { recursive: true, force: true });
await expect.poll(() => suspended, { timeout: 15_000 }).toBe(true);
await expect.poll(() => service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
const commandsWhileMissing = await commandCount();
await service.getSnapshot(cwd, {
force: true,
includeForge: false,
reason: "refresh-button",
});
await service.refresh(cwd);
expect(await commandCount()).toBe(commandsWhileMissing);
await new Promise((resolve) => setTimeout(resolve, 16_000));
expect(await commandCount()).toBe(commandsWhileMissing);
initializeRepo("restored");
const snapshot = await service.getSnapshot(cwd, {
force: true,
includeForge: false,
reason: "refresh-button",
});
expect(snapshot.git).toMatchObject({ isGit: true, currentBranch: "restored" });
await expect.poll(() => service.getMetrics().repositoryWorkspaceLinkCount).toBe(1);
} finally {
subscription.unsubscribe();
await service.dispose();
if (previousTrace === undefined) delete process.env.PASEO_GIT_TRACE_FILE;
else process.env.PASEO_GIT_TRACE_FILE = previousTrace;
rmSync(root, { recursive: true, force: true });
}
},
60_000,
);
@@ -244,7 +244,6 @@ function createService(
filterEvents: (events) => events, filterEvents: (events) => events,
verify: vi.fn(async () => {}), verify: vi.fn(async () => {}),
})), })),
workspaceFolderExists: vi.fn(async () => true),
...overrides, ...overrides,
} as never, } as never,
}); });
@@ -1,7 +1,5 @@
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path, { join } from "node:path"; import path, { join } from "node:path";
import type pino from "pino"; import type pino from "pino";
@@ -12,14 +10,11 @@ import type {
PullRequestStatusResult, PullRequestStatusResult,
} from "../utils/checkout-git.js"; } from "../utils/checkout-git.js";
import { import {
BACKGROUND_GIT_FETCH_INTERVAL_MS,
WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS,
WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY, WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
WORKSPACE_GIT_REFRESH_CONCURRENCY, WORKSPACE_GIT_REFRESH_CONCURRENCY,
WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS, WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS,
WorkspaceGitServiceImpl, WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot, type WorkspaceGitRuntimeSnapshot,
workspaceFolderExists,
} from "./workspace-git-service.js"; } from "./workspace-git-service.js";
const REPO_CWD = path.resolve("/tmp/repo"); const REPO_CWD = path.resolve("/tmp/repo");
@@ -28,7 +23,6 @@ function createLogger() {
const logger = { const logger = {
child: () => logger, child: () => logger,
debug: vi.fn(), debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(), warn: vi.fn(),
}; };
return logger; return logger;
@@ -257,7 +251,7 @@ function createGitHubServiceStub(): ForgeService {
interface CreateServiceTestOptions { interface CreateServiceTestOptions {
subscribe?: ReturnType<typeof vi.fn>; subscribe?: ReturnType<typeof vi.fn>;
getCheckoutStatus?: ReturnType<typeof vi.fn>; getCheckoutStatus?: ReturnType<typeof vi.fn>;
getCheckoutSnapshotFacts?: (cwd: string) => Promise<CheckoutSnapshotFacts>; getCheckoutSnapshotFacts?: ReturnType<typeof vi.fn>;
getCheckoutShortstat?: ReturnType<typeof vi.fn>; getCheckoutShortstat?: ReturnType<typeof vi.fn>;
getCheckoutWorktreeState?: ReturnType<typeof vi.fn>; getCheckoutWorktreeState?: ReturnType<typeof vi.fn>;
getPullRequestStatus?: ReturnType<typeof vi.fn>; getPullRequestStatus?: ReturnType<typeof vi.fn>;
@@ -267,7 +261,6 @@ interface CreateServiceTestOptions {
runGitFetch?: ReturnType<typeof vi.fn>; runGitFetch?: ReturnType<typeof vi.fn>;
runGitCommand?: ReturnType<typeof vi.fn>; runGitCommand?: ReturnType<typeof vi.fn>;
getWorkspaceGitSelfHealPhaseMs?: (cwd: string) => number; getWorkspaceGitSelfHealPhaseMs?: (cwd: string) => number;
workspaceFolderExists?: (cwd: string) => Promise<boolean>;
now?: () => Date; now?: () => Date;
} }
@@ -301,7 +294,6 @@ function buildDefaultTestServiceDeps() {
signal: null, signal: null,
})), })),
getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000), getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000),
workspaceFolderExists: vi.fn(async () => true),
createWatcherLivenessCanary: vi.fn(() => ({ createWatcherLivenessCanary: vi.fn(() => ({
path: "", path: "",
filterEvents: (events) => events, filterEvents: (events) => events,
@@ -332,145 +324,6 @@ function createService(options?: CreateServiceTestOptions) {
}); });
} }
type WatchCallback = (error: Error | null, events: Array<{ path: string; type: "update" }>) => void;
interface FolderBackedGitOptions {
facts?: (cwd: string) => CheckoutSnapshotFacts;
unwatchableDirectories?: string[];
heldWatch?: { directory: string; release: Promise<void> };
}
function createFolderBackedGit(options?: FolderBackedGitOptions) {
const watchCallbacks = new Map<string, WatchCallback>();
const subscribe = vi.fn(async (directory: string, callback: WatchCallback) => {
if (!existsSync(directory) || options?.unwatchableDirectories?.includes(directory)) {
throw new Error(`Cannot watch ${directory}`);
}
if (options?.heldWatch?.directory === directory) {
await options.heldWatch.release;
}
watchCallbacks.set(directory, callback);
return createAsyncSubscription();
});
const getCheckoutSnapshotFacts = vi.fn(
async (cwd: string): Promise<CheckoutSnapshotFacts> =>
existsSync(cwd) ? (options?.facts ?? createCheckoutSnapshotFacts)(cwd) : { isGit: false },
);
const getCheckoutStatus = vi.fn(async (cwd: string) =>
existsSync(cwd) ? createCheckoutStatus(cwd) : ({ isGit: false } as const),
);
const getCheckoutShortstat = vi.fn(async (_cwd: string) => ({ additions: 1, deletions: 0 }));
const getCheckoutWorktreeState = vi.fn(async (_cwd: string) => ({
isDirty: false,
diffStat: { additions: 1, deletions: 0 },
}));
const runGitCommand = vi.fn(async (args: string[], runOptions: { cwd: string }) => ({
stdout: args[0] === "rev-parse" ? `${runOptions.cwd}\n` : "",
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const runGitFetch = vi.fn(async (_cwd: string) => ({ changes: [], error: null }));
const callsIn = (fake: ReturnType<typeof vi.fn>, cwd: string) =>
fake.mock.calls.filter(([calledCwd]) => calledCwd === cwd).length;
return {
deps: {
subscribe,
getCheckoutSnapshotFacts,
getCheckoutStatus,
getCheckoutShortstat,
getCheckoutWorktreeState,
runGitCommand,
runGitFetch,
workspaceFolderExists: async (cwd: string) => existsSync(cwd),
now: () => new Date(),
},
watchCallbackFor(directory: string): WatchCallback {
const callback = watchCallbacks.get(directory);
if (!callback) {
throw new Error(`No watcher was started for ${directory}`);
}
return callback;
},
watchAttemptsFor: (directory: string) => callsIn(subscribe, directory),
pollsOf: (cwd: string) => callsIn(getCheckoutShortstat, cwd),
fetchesIn: (cwd: string) => callsIn(runGitFetch, cwd),
gitCommandsIn(cwd: string): number {
const cwdCommands = [
getCheckoutSnapshotFacts,
getCheckoutStatus,
getCheckoutShortstat,
getCheckoutWorktreeState,
runGitFetch,
].reduce((count, fake) => count + callsIn(fake, cwd), 0);
const commandsRunInCwd = runGitCommand.mock.calls.filter(
([, runOptions]) => runOptions.cwd === cwd,
).length;
return cwdCommands + commandsRunInCwd;
},
};
}
function linkedWorktreeFacts(gitCommonDir: string) {
return (cwd: string): CheckoutSnapshotFacts => ({
...createCheckoutSnapshotFacts(cwd),
absoluteGitDir: join(gitCommonDir, "worktrees", path.basename(cwd)),
gitCommonDir,
});
}
const SETTLE_ATTEMPTS = 1_000;
async function settle(assertion: () => void): Promise<void> {
let lastFailure: unknown = null;
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
try {
assertion();
return;
} catch (failure) {
lastFailure = failure;
}
await vi.advanceTimersByTimeAsync(0);
}
throw lastFailure;
}
async function waitForRefreshesToSettle(service: WorkspaceGitServiceImpl): Promise<void> {
await settle(() => {
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
}
async function waitForObservationToSettle(
service: WorkspaceGitServiceImpl,
repositoryWorkspaceLinkCount: number,
): Promise<void> {
await settle(() => {
const metrics = service.getMetrics();
expect(metrics.repositoryWorkspaceLinkCount).toBe(repositoryWorkspaceLinkCount);
expect(metrics.workspaceObservationSetupInFlightCount).toBe(0);
expect(metrics.workspaceRefreshInFlightCount).toBe(0);
});
}
async function advanceThroughDegradedPolls(service: WorkspaceGitServiceImpl): Promise<void> {
for (const delayMs of [5_000, 10_000, 20_000, 40_000, 60_000, 60_000]) {
await vi.advanceTimersByTimeAsync(delayMs);
await waitForRefreshesToSettle(service);
}
}
async function advanceThroughWorkingTreeRecoveryAttempts(
service: WorkspaceGitServiceImpl,
): Promise<void> {
for (const delayMs of [30_000, 60_000, 120_000]) {
await vi.advanceTimersByTimeAsync(delayMs);
await waitForRefreshesToSettle(service);
}
}
describe("WorkspaceGitServiceImpl", () => { describe("WorkspaceGitServiceImpl", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -1700,460 +1553,3 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose(); service.dispose();
}); });
}); });
describe("WorkspaceGitServiceImpl with workspace folders that no longer exist", () => {
const temporaryFolders: string[] = [];
async function createTemporaryFolder(): Promise<string> {
const folder = await mkdtemp(join(os.tmpdir(), "paseo-workspace-git-"));
temporaryFolders.push(folder);
return folder;
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(async () => {
vi.useRealTimers();
await Promise.all(
temporaryFolders.splice(0).map((folder) => rm(folder, { recursive: true, force: true })),
);
});
test("polling stops for a workspace whose folder no longer exists", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const service = createService(git.deps);
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
const gitCommandsAtDeletion = git.gitCommandsIn(workspace);
await advanceThroughDegradedPolls(service);
expect(git.gitCommandsIn(workspace)).toBe(gitCommandsAtDeletion);
expect(service.getMetrics()).toMatchObject({
repositoryTargetCount: 0,
workingTreeWatchTargetCount: 0,
});
expect(vi.getTimerCount()).toBe(1);
subscription.unsubscribe();
await service.dispose();
});
test("a deleted worktree leaves its shared repository polling and fetch to the surviving worktree", async () => {
const gitCommonDir = join(await createTemporaryFolder(), ".git");
const deletedWorktree = await createTemporaryFolder();
const survivingWorktree = await createTemporaryFolder();
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
let survivingWorktreeEdits = 0;
git.deps.getCheckoutShortstat.mockImplementation(async (cwd: string) => ({
additions: cwd === survivingWorktree ? ++survivingWorktreeEdits : 1,
deletions: 0,
}));
const service = createService(git.deps);
const deletedSubscription = service.registerWorkspace({ cwd: deletedWorktree }, vi.fn());
await waitForObservationToSettle(service, 1);
const survivingSubscription = service.registerWorkspace({ cwd: survivingWorktree }, vi.fn());
await waitForObservationToSettle(service, 2);
await rm(deletedWorktree, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(60_000);
await waitForRefreshesToSettle(service);
const deletedWorktreeCommands = git.gitCommandsIn(deletedWorktree);
for (let tick = 0; tick < 3; tick += 1) {
const survivingWorktreePolls = git.pollsOf(survivingWorktree);
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => {
expect(git.pollsOf(survivingWorktree)).toBe(survivingWorktreePolls + 1);
});
}
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
const fetchesBefore = git.deps.runGitFetch.mock.calls.length;
await vi.advanceTimersByTimeAsync(2 * BACKGROUND_GIT_FETCH_INTERVAL_MS);
expect(git.deps.runGitFetch.mock.calls.slice(fetchesBefore).map(([cwd]) => cwd)).toEqual([
survivingWorktree,
survivingWorktree,
]);
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
deletedSubscription.unsubscribe();
survivingSubscription.unsubscribe();
await service.dispose();
});
test("deleting every worktree of a repository stops its polling and background fetch", async () => {
const gitCommonDir = join(await createTemporaryFolder(), ".git");
const firstWorktree = await createTemporaryFolder();
const secondWorktree = await createTemporaryFolder();
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
const service = createService(git.deps);
const firstSubscription = service.registerWorkspace({ cwd: firstWorktree }, vi.fn());
await waitForObservationToSettle(service, 1);
const secondSubscription = service.registerWorkspace({ cwd: secondWorktree }, vi.fn());
await waitForObservationToSettle(service, 2);
await rm(firstWorktree, { recursive: true, force: true });
await rm(secondWorktree, { recursive: true, force: true });
const commandsAtDeletion = [
git.gitCommandsIn(firstWorktree),
git.gitCommandsIn(secondWorktree),
];
await advanceThroughDegradedPolls(service);
expect([git.gitCommandsIn(firstWorktree), git.gitCommandsIn(secondWorktree)]).toEqual(
commandsAtDeletion,
);
expect(service.getMetrics()).toMatchObject({
repositoryTargetCount: 0,
workingTreeWatchTargetCount: 0,
});
expect(vi.getTimerCount()).toBe(2);
firstSubscription.unsubscribe();
secondSubscription.unsubscribe();
await service.dispose();
});
test("working tree fallback polling and watcher recovery stop for a workspace whose folder no longer exists", async () => {
const workspace = await createTemporaryFolder();
await mkdir(join(workspace, ".git"));
const git = createFolderBackedGit();
const service = createService(git.deps);
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
git.watchCallbackFor(workspace)(new Error("watched directory was deleted"), []);
await vi.advanceTimersByTimeAsync(1_000);
await waitForRefreshesToSettle(service);
const commandsAfterWatcherError = git.gitCommandsIn(workspace);
const watchAttemptsAfterWatcherError = git.watchAttemptsFor(workspace);
await advanceThroughDegradedPolls(service);
expect(git.gitCommandsIn(workspace)).toBe(commandsAfterWatcherError);
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError);
expect(vi.getTimerCount()).toBe(1);
subscription.unsubscribe();
await service.dispose();
});
test("a workspace whose folder comes back resumes polling on its next subscription", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const service = createService(git.deps);
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
const commandsWhileMissing = git.gitCommandsIn(workspace);
await advanceThroughDegradedPolls(service);
expect(git.gitCommandsIn(workspace)).toBe(commandsWhileMissing);
await mkdir(workspace);
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
const pollsAfterResume = git.pollsOf(workspace);
await vi.advanceTimersByTimeAsync(5_000);
await waitForRefreshesToSettle(service);
expect(git.pollsOf(workspace)).toBe(pollsAfterResume + 1);
firstSubscription.unsubscribe();
secondSubscription.unsubscribe();
await service.dispose();
});
test("a suspended workspace costs no git while its folder is missing, even when a refresh is requested", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const service = createService(git.deps);
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
await advanceThroughDegradedPolls(service);
const commandsWhileMissing = git.gitCommandsIn(workspace);
for (let probe = 0; probe < 10; probe += 1) {
await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
await waitForRefreshesToSettle(service);
}
service.scheduleRefreshForCwd(workspace);
service.onWorkspaceStateMayHaveChanged(workspace);
await vi.advanceTimersByTimeAsync(5_000);
await waitForRefreshesToSettle(service);
expect(git.gitCommandsIn(workspace)).toBe(commandsWhileMissing);
expect(vi.getTimerCount()).toBe(1);
subscription.unsubscribe();
await service.dispose();
});
test("a suspended worktree resumes polling and its repository fetch once its folder is back, without a new subscription", async () => {
const gitCommonDir = join(await createTemporaryFolder(), ".git");
const worktree = await createTemporaryFolder();
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
const service = createService(git.deps);
const subscription = service.registerWorkspace({ cwd: worktree }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(worktree, { recursive: true, force: true });
await advanceThroughDegradedPolls(service);
await waitForObservationToSettle(service, 0);
await mkdir(worktree);
const fetchesBeforeRestore = git.fetchesIn(worktree);
await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
await waitForObservationToSettle(service, 1);
expect(git.fetchesIn(worktree)).toBe(fetchesBeforeRestore + 1);
const pollsAfterRestore = git.pollsOf(worktree);
await vi.advanceTimersByTimeAsync(5_000);
await waitForRefreshesToSettle(service);
expect(git.pollsOf(worktree)).toBe(pollsAfterRestore + 1);
subscription.unsubscribe();
await service.dispose();
});
test("a forced snapshot resumes a suspended workspace whose folder is back", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const service = createService(git.deps);
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
await advanceThroughDegradedPolls(service);
await waitForObservationToSettle(service, 0);
await mkdir(workspace);
const snapshot = await service.getSnapshot(workspace, {
force: true,
reason: "refresh-button",
});
await waitForObservationToSettle(service, 1);
expect(snapshot.git.isGit).toBe(true);
subscription.unsubscribe();
await service.dispose();
});
test("a resumed workspace rebuilds observation from fresh facts, not the non-git facts cached while its folder was gone", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const service = createService(git.deps);
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
service.onWorkspaceStateMayHaveChanged(workspace);
await vi.advanceTimersByTimeAsync(1_000);
await waitForRefreshesToSettle(service);
expect((await service.getSnapshot(workspace)).git.isGit).toBe(false);
await advanceThroughDegradedPolls(service);
await waitForObservationToSettle(service, 0);
await mkdir(workspace);
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
expect((await service.getSnapshot(workspace)).git.isGit).toBe(true);
firstSubscription.unsubscribe();
secondSubscription.unsubscribe();
await service.dispose();
});
test("resuming waits for an observation setup that was still running when the folder vanished", async () => {
const workspace = await createTemporaryFolder();
const gitDir = join(workspace, ".git");
await mkdir(gitDir);
const metadataWatchRelease = createDeferred<void>();
const git = createFolderBackedGit({
unwatchableDirectories: [workspace],
heldWatch: { directory: gitDir, release: metadataWatchRelease.promise },
});
const service = createService(git.deps);
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await settle(() => {
expect(git.watchAttemptsFor(gitDir)).toBe(1);
});
await rm(workspace, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => {
expect(service.getMetrics()).toMatchObject({
repositoryTargetCount: 0,
workingTreeWatchTargetCount: 0,
});
});
await mkdir(workspace);
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
metadataWatchRelease.resolve();
await waitForObservationToSettle(service, 1);
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
firstSubscription.unsubscribe();
secondSubscription.unsubscribe();
await service.dispose();
});
test("an open working tree watch neither notifies nor recovers while its folder is missing, and picks up once it is back", async () => {
const workspace = await createTemporaryFolder();
await mkdir(join(workspace, ".git"));
const git = createFolderBackedGit();
const service = createService(git.deps);
const workspaceSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
const diffListener = vi.fn();
const diffWatch = await service.requestWorkingTreeWatch(workspace, diffListener);
await rm(workspace, { recursive: true, force: true });
git.watchCallbackFor(workspace)(new Error("watched directory was deleted"), []);
await vi.advanceTimersByTimeAsync(1_000);
await waitForRefreshesToSettle(service);
const commandsAfterWatcherError = git.gitCommandsIn(workspace);
const notificationsAfterWatcherError = diffListener.mock.calls.length;
const watchAttemptsAfterWatcherError = git.watchAttemptsFor(workspace);
await advanceThroughWorkingTreeRecoveryAttempts(service);
expect(git.gitCommandsIn(workspace)).toBe(commandsAfterWatcherError);
expect(diffListener.mock.calls.length).toBe(notificationsAfterWatcherError);
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError);
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
await mkdir(join(workspace, ".git"), { recursive: true });
await vi.advanceTimersByTimeAsync(60_000);
await waitForRefreshesToSettle(service);
expect(diffListener.mock.calls.length).toBe(notificationsAfterWatcherError + 1);
await vi.advanceTimersByTimeAsync(240_000);
await waitForRefreshesToSettle(service);
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError + 1);
const notificationsAfterResubscribe = diffListener.mock.calls.length;
await vi.advanceTimersByTimeAsync(120_000);
await waitForRefreshesToSettle(service);
expect(diffListener.mock.calls.length).toBe(notificationsAfterResubscribe);
diffWatch.unsubscribe();
workspaceSubscription.unsubscribe();
await service.dispose();
});
test("a refresh crossing suspension preserves the cached snapshot and drops queued work", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const started = createDeferred<void>();
const release = createDeferred<void>();
let holdNextRead = false;
let factsReads = 0;
const service = createService({
...git.deps,
getCheckoutSnapshotFacts: async (cwd: string) => {
factsReads++;
if (holdNextRead) {
holdNextRead = false;
started.resolve();
await release.promise;
}
return git.deps.getCheckoutSnapshotFacts(cwd);
},
});
const updates: WorkspaceGitRuntimeSnapshot[] = [];
const subscription = service.registerWorkspace({ cwd: workspace }, (snapshot) =>
updates.push(snapshot),
);
await waitForObservationToSettle(service, 1);
const cached = await service.getSnapshot(workspace);
holdNextRead = true;
const active = service.getSnapshot(workspace, {
force: true,
includeForge: false,
reason: "overlapping-refresh",
});
await started.promise;
const queued = service.getSnapshot(workspace, {
force: true,
includeForge: true,
reason: "queued-refresh",
});
await rm(workspace, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => expect(service.getMetrics().repositoryWorkspaceLinkCount).toBe(0));
const readsAtSuspension = factsReads;
const updatesAtSuspension = updates.length;
release.resolve();
expect(await active).toEqual(cached);
expect(await queued).toEqual(cached);
expect(await service.getSnapshot(workspace)).toEqual(cached);
expect(updates).toHaveLength(updatesAtSuspension);
expect(factsReads).toBe(readsAtSuspension);
subscription.unsubscribe();
await service.dispose();
});
test("restoration settles a suspended refresh before reloading facts", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const staleFacts = createDeferred<CheckoutSnapshotFacts>();
const started = createDeferred<void>();
let holdNextRead = false;
const service = createService({
...git.deps,
getCheckoutSnapshotFacts: async (cwd: string) => {
if (holdNextRead) {
holdNextRead = false;
started.resolve();
return staleFacts.promise;
}
return git.deps.getCheckoutSnapshotFacts(cwd);
},
});
const updates: WorkspaceGitRuntimeSnapshot[] = [];
const subscription = service.registerWorkspace({ cwd: workspace }, (snapshot) =>
updates.push(snapshot),
);
await waitForObservationToSettle(service, 1);
const cached = await service.getSnapshot(workspace);
holdNextRead = true;
const active = service.getSnapshot(workspace, {
force: true,
includeForge: false,
reason: "overlapping-refresh",
});
await started.promise;
await rm(workspace, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => expect(service.getMetrics().repositoryWorkspaceLinkCount).toBe(0));
const updatesAtSuspension = updates.length;
await mkdir(workspace);
const restored = service.getSnapshot(workspace, {
force: true,
includeForge: false,
reason: "folder-returned",
});
await vi.advanceTimersByTimeAsync(0);
staleFacts.resolve({ isGit: false });
expect(await active).toEqual(cached);
expect((await restored).git.isGit).toBe(true);
expect(updates.slice(updatesAtSuspension).every((snapshot) => snapshot.git.isGit)).toBe(true);
await waitForObservationToSettle(service, 1);
subscription.unsubscribe();
await service.dispose();
});
test("workspaceFolderExists reports a missing folder and a path under a file as missing", async () => {
const folder = await createTemporaryFolder();
const file = join(folder, "file.txt");
await writeFile(file, "");
expect(await workspaceFolderExists(folder)).toBe(true);
expect(await workspaceFolderExists(join(folder, "missing"))).toBe(false);
expect(await workspaceFolderExists(join(file, "nested"))).toBe(false);
});
});
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { readFile, stat } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path"; import { basename, dirname, join, resolve } from "node:path";
import { LRUCache } from "lru-cache"; import { LRUCache } from "lru-cache";
import { CheckoutDiffCache } from "./checkout-diff-cache.js"; import { CheckoutDiffCache } from "./checkout-diff-cache.js";
@@ -74,7 +74,7 @@ import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js"; import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js";
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
export const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
const FETCH_METADATA_ECHO_TTL_MS = 5_000; const FETCH_METADATA_ECHO_TTL_MS = 5_000;
export const WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS = 60_000; export const WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS = 60_000;
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000; const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
@@ -384,7 +384,6 @@ interface WorkspaceGitServiceDependencies {
runGitCommand: typeof runGitCommand; runGitCommand: typeof runGitCommand;
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs; getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs;
createWatcherLivenessCanary: typeof createWatcherLivenessCanary; createWatcherLivenessCanary: typeof createWatcherLivenessCanary;
workspaceFolderExists: (cwd: string) => Promise<boolean>;
now: () => Date; now: () => Date;
} }
@@ -436,8 +435,6 @@ interface WorkspaceGitTarget {
repoGitRoot: string | null; repoGitRoot: string | null;
observationSetupPromise: Promise<void> | null; observationSetupPromise: Promise<void> | null;
observationSetupComplete: boolean; observationSetupComplete: boolean;
folderMissing: boolean;
folderRestorePromise: Promise<void> | null;
closed: boolean; closed: boolean;
} }
@@ -574,21 +571,10 @@ function buildDefaultWorkspaceGitServiceDeps(
runGitCommand, runGitCommand,
getWorkspaceGitSelfHealPhaseMs: getWorkspaceGitObservationReensurePhaseMs, getWorkspaceGitSelfHealPhaseMs: getWorkspaceGitObservationReensurePhaseMs,
createWatcherLivenessCanary, createWatcherLivenessCanary,
workspaceFolderExists,
now: () => new Date(), now: () => new Date(),
}; };
} }
export async function workspaceFolderExists(cwd: string): Promise<boolean> {
try {
await stat(cwd);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return code !== "ENOENT" && code !== "ENOTDIR";
}
}
function resolveWorkspaceGitServiceDeps( function resolveWorkspaceGitServiceDeps(
subscribe: SubscribeToFileChanges, subscribe: SubscribeToFileChanges,
deps: Partial<WorkspaceGitServiceDependencies> | undefined, deps: Partial<WorkspaceGitServiceDependencies> | undefined,
@@ -674,9 +660,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const cwd = resolve(params.cwd); const cwd = resolve(params.cwd);
const target = this.ensureWorkspaceTarget(cwd); const target = this.ensureWorkspaceTarget(cwd);
target.listeners.add(listener); target.listeners.add(listener);
if (target.folderMissing) {
void this.resumeWorkspaceIfFolderRestored(target);
}
if (target.listeners.size === 1) { if (target.listeners.size === 1) {
this.startWorkspaceSubscriptionTimers(target); this.startWorkspaceSubscriptionTimers(target);
} }
@@ -768,9 +751,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (!request.force && target.latestSnapshot) { if (!request.force && target.latestSnapshot) {
return target.latestSnapshot; return target.latestSnapshot;
} }
if (target.folderMissing) {
await this.resumeWorkspaceIfFolderRestored(target);
}
return this.requestWorkspaceSnapshot(target, request); return this.requestWorkspaceSnapshot(target, request);
} }
@@ -992,9 +972,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.assertNotDisposed(); this.assertNotDisposed();
cwd = resolve(cwd); cwd = resolve(cwd);
const target = this.ensureWorkspaceTarget(cwd); const target = this.ensureWorkspaceTarget(cwd);
if (target.folderMissing) {
await this.resumeWorkspaceIfFolderRestored(target);
}
await this.refreshWorkspaceTarget(target, { await this.refreshWorkspaceTarget(target, {
force: false, force: false,
refreshStructure: true, refreshStructure: true,
@@ -1028,14 +1005,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.assertNotDisposed(); this.assertNotDisposed();
cwd = resolve(cwd); cwd = resolve(cwd);
const target = this.workspaceTargets.get(cwd); const target = this.workspaceTargets.get(cwd);
if (!target) { if (target) {
return; this.scheduleWorkspaceRefresh(target, { queueIfBusy: false });
} }
if (target.folderMissing) {
void this.resumeWorkspaceIfFolderRestored(target);
return;
}
this.scheduleWorkspaceRefresh(target, { queueIfBusy: false });
} }
onWorkspaceStateMayHaveChanged(cwd: string): void { onWorkspaceStateMayHaveChanged(cwd: string): void {
@@ -1046,10 +1018,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return; return;
} }
this.invalidateForge(normalizedCwd); this.invalidateForge(normalizedCwd);
if (target.folderMissing) {
void this.resumeWorkspaceIfFolderRestored(target);
return;
}
this.scheduleWorkspaceRefresh(target, { this.scheduleWorkspaceRefresh(target, {
force: true, force: true,
includeForge: true, includeForge: true,
@@ -1259,8 +1227,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
repoGitRoot: null, repoGitRoot: null,
observationSetupPromise: null, observationSetupPromise: null,
observationSetupComplete: false, observationSetupComplete: false,
folderMissing: false,
folderRestorePromise: null,
closed: false, closed: false,
}; };
@@ -1392,7 +1358,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean { private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
return ( return (
!target.closed && !target.closed &&
!target.folderMissing &&
target.listeners.size > 0 && target.listeners.size > 0 &&
this.workspaceTargets.get(target.cwd) === target this.workspaceTargets.get(target.cwd) === target
); );
@@ -1647,110 +1612,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
workspaceTarget: WorkspaceGitTarget, workspaceTarget: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest, request: WorkspaceGitRefreshRequest,
): Promise<boolean> { ): Promise<boolean> {
if (await this.suspendWorkspaceIfFolderMissing(workspaceTarget)) {
return false;
}
const fingerprintBefore = workspaceTarget.latestFingerprint; const fingerprintBefore = workspaceTarget.latestFingerprint;
await this.refreshWorkspaceTarget(workspaceTarget, request); await this.refreshWorkspaceTarget(workspaceTarget, request);
return workspaceTarget.latestFingerprint !== fingerprintBefore; return workspaceTarget.latestFingerprint !== fingerprintBefore;
} }
private async suspendWorkspaceIfFolderMissing(target: WorkspaceGitTarget): Promise<boolean> {
if (target.folderMissing) {
return true;
}
if (await this.deps.workspaceFolderExists(target.cwd)) {
return false;
}
this.suspendWorkspaceWithMissingFolder(target);
return true;
}
private suspendWorkspaceWithMissingFolder(target: WorkspaceGitTarget): void {
if (target.folderMissing || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
target.folderMissing = true;
if (target.refreshState.status === "in-flight") {
target.refreshState.queued = null;
}
this.detachWorkspaceFromRepoTarget(target);
target.repoGitRoot = null;
this.stopWorkspaceObservation(target);
target.observationSetupComplete = false;
this.scheduleMissingFolderProbe(target);
this.logger.warn(
{ cwd: target.cwd },
"Workspace folder no longer exists; suspended git observation until it is back",
);
}
private isSuspendedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
return (
target.folderMissing && !target.closed && this.workspaceTargets.get(target.cwd) === target
);
}
private scheduleMissingFolderProbe(target: WorkspaceGitTarget): void {
if (
!this.isSuspendedWorkspaceTarget(target) ||
target.observationReensureTimer ||
target.folderRestorePromise
) {
return;
}
target.observationReensureTimer = setTimeout(() => {
target.observationReensureTimer = null;
void this.resumeWorkspaceIfFolderRestored(target);
}, WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
}
private resumeWorkspaceIfFolderRestored(target: WorkspaceGitTarget): Promise<void> {
if (!target.folderMissing) {
return Promise.resolve();
}
target.folderRestorePromise ??= this.resumeWorkspaceWithRestoredFolder(target).finally(() => {
target.folderRestorePromise = null;
this.scheduleMissingFolderProbe(target);
});
return target.folderRestorePromise;
}
private async resumeWorkspaceWithRestoredFolder(target: WorkspaceGitTarget): Promise<void> {
if (!(await this.deps.workspaceFolderExists(target.cwd))) {
return;
}
await target.observationSetupPromise;
if (target.refreshState.status === "in-flight") {
await target.refreshState.promise.catch(() => undefined);
}
if (!this.isSuspendedWorkspaceTarget(target)) {
return;
}
target.latestFacts = null;
target.folderMissing = false;
await this.refreshWorkspaceTarget(target, {
force: false,
refreshStructure: true,
refreshWorktree: true,
includeForge: false,
reason: "workspace-folder-restored",
queueIfBusy: true,
movedRemoteRefs: new Set(),
});
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
target.observationSetupComplete = false;
if (target.observationReensureTimer) {
clearTimeout(target.observationReensureTimer);
target.observationReensureTimer = null;
}
this.logger.info({ cwd: target.cwd }, "Workspace folder is back; resumed git observation");
this.startWorkspaceSubscriptionTimers(target);
this.scheduleWorkspaceObservationSetup(target);
}
private startWorkingTreeWatchFallback( private startWorkingTreeWatchFallback(
target: WorkingTreeWatchTarget, target: WorkingTreeWatchTarget,
reason: WorkingTreeWatchFallbackReason, reason: WorkingTreeWatchFallbackReason,
@@ -1788,9 +1654,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return changed; return changed;
}), }),
); );
if (await this.deps.workspaceFolderExists(target.watchPath)) { this.notifyWorkingTreeConsumers(target);
this.notifyWorkingTreeConsumers(target);
}
if (!target.closed && (target.subscription === null || target.repoRoot === null)) { if (!target.closed && (target.subscription === null || target.repoRoot === null)) {
target.fallbackPollQuietTickCount = changes.some(Boolean) target.fallbackPollQuietTickCount = changes.some(Boolean)
? 0 ? 0
@@ -1867,10 +1731,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (target.closed || target.subscription) { if (target.closed || target.subscription) {
return; return;
} }
if (!(await this.deps.workspaceFolderExists(target.watchPath))) {
this.scheduleWorkingTreeWatchRecovery(target);
return;
}
await this.refreshWorkingTreeIgnoredDirectories(target); await this.refreshWorkingTreeIgnoredDirectories(target);
if (target.closed || target.subscription) { if (target.closed || target.subscription) {
return; return;
@@ -2926,7 +2786,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} }
private updateForgePrStatusPollForTarget(target: WorkspaceGitTarget): void { private updateForgePrStatusPollForTarget(target: WorkspaceGitTarget): void {
if (!this.isActiveObservedWorkspaceTarget(target)) { if (target.listeners.size === 0) {
this.stopForgePrStatusPollForTarget(target); this.stopForgePrStatusPollForTarget(target);
return; return;
} }
@@ -3189,9 +3049,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target: WorkspaceGitTarget, target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest, request: WorkspaceGitRefreshRequest,
): Promise<WorkspaceGitRuntimeSnapshot> { ): Promise<WorkspaceGitRuntimeSnapshot> {
if (target.folderMissing) {
return Promise.resolve(target.latestSnapshot ?? buildNotGitSnapshot(target.cwd));
}
if (target.refreshState.status === "in-flight") { if (target.refreshState.status === "in-flight") {
const active = target.refreshState.request; const active = target.refreshState.request;
const addsWork = const addsWork =
@@ -3334,19 +3191,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
try { try {
const runRefreshGitCommand = createRunGitCommand(`workspace-refresh:${request.reason}`); const runRefreshGitCommand = createRunGitCommand(`workspace-refresh:${request.reason}`);
const admittedSnapshot = await this.workspaceRefreshLimit(() => { const admittedSnapshot = await this.workspaceRefreshLimit(() => {
if ( if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
target.closed ||
target.folderMissing ||
this.workspaceTargets.get(target.cwd) !== target
) {
return null; return null;
} }
return this.refreshSnapshot(target, request, runRefreshGitCommand); return this.refreshSnapshot(target, request, runRefreshGitCommand);
}); });
if (target.folderMissing) {
failure = null;
break;
}
if (!admittedSnapshot) { if (!admittedSnapshot) {
break; break;
} }
@@ -3360,10 +3209,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
failure = { error }; failure = { error };
} }
if (target.folderMissing) {
failure = null;
break;
}
if (this.disposed || target.closed || this.workspaceTargets.get(target.cwd) !== target) { if (this.disposed || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
break; break;
} }
@@ -3659,7 +3504,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
snapshot: WorkspaceGitRuntimeSnapshot, snapshot: WorkspaceGitRuntimeSnapshot,
options?: { forceEmit?: boolean }, options?: { forceEmit?: boolean },
): void { ): void {
if (target.closed || target.folderMissing || this.workspaceTargets.get(target.cwd) !== target) { if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return; return;
} }
target.latestSnapshot = snapshot; target.latestSnapshot = snapshot;
@@ -3815,25 +3660,21 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} }
private removeWorkspaceTarget(target: WorkspaceGitTarget): void { private removeWorkspaceTarget(target: WorkspaceGitTarget): void {
this.detachWorkspaceFromRepoTarget(target); if (target.repoGitRoot) {
const repoTarget = this.repoTargets.get(target.repoGitRoot);
repoTarget?.workspaceKeys.delete(target.cwd);
if (repoTarget && repoTarget.workspaceKeys.size === 0) {
this.closeRepoTarget(repoTarget);
this.repoTargets.delete(target.repoGitRoot);
} else if (repoTarget?.cwd === target.cwd) {
repoTarget.cwd = repoTarget.workspaceKeys.values().next().value ?? repoTarget.cwd;
}
}
this.closeWorkspaceTarget(target); this.closeWorkspaceTarget(target);
this.workspaceTargets.delete(target.cwd); this.workspaceTargets.delete(target.cwd);
} }
private detachWorkspaceFromRepoTarget(target: WorkspaceGitTarget): void {
if (!target.repoGitRoot) {
return;
}
const repoTarget = this.repoTargets.get(target.repoGitRoot);
repoTarget?.workspaceKeys.delete(target.cwd);
if (repoTarget && repoTarget.workspaceKeys.size === 0) {
this.closeRepoTarget(repoTarget);
this.repoTargets.delete(target.repoGitRoot);
} else if (repoTarget?.cwd === target.cwd) {
repoTarget.cwd = repoTarget.workspaceKeys.values().next().value ?? repoTarget.cwd;
}
}
private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void { private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void {
const target = this.workingTreeWatchTargets.get(cwd); const target = this.workingTreeWatchTargets.get(cwd);
if (!target) { if (!target) {
@@ -3878,11 +3719,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private closeWorkspaceTarget(target: WorkspaceGitTarget): void { private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
target.closed = true; target.closed = true;
this.stopWorkspaceObservation(target);
target.listeners.clear();
}
private stopWorkspaceObservation(target: WorkspaceGitTarget): void {
if (target.workingTreeWatchTarget) { if (target.workingTreeWatchTarget) {
this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd); this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd);
target.workingTreeWatchTarget = null; target.workingTreeWatchTarget = null;
@@ -3891,12 +3727,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
clearTimeout(target.debounceTimer); clearTimeout(target.debounceTimer);
target.debounceTimer = null; target.debounceTimer = null;
} }
target.pendingDebounceRequest = null;
if (target.observationReensureTimer) { if (target.observationReensureTimer) {
clearTimeout(target.observationReensureTimer); clearTimeout(target.observationReensureTimer);
target.observationReensureTimer = null; target.observationReensureTimer = null;
} }
this.stopForgePrStatusPollForTarget(target); this.stopForgePrStatusPollForTarget(target);
target.listeners.clear();
} }
private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void { private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
@@ -1,6 +1,5 @@
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { import {
chmodSync,
existsSync, existsSync,
mkdirSync, mkdirSync,
mkdtempSync, mkdtempSync,
@@ -55,7 +54,6 @@ function makeSources(bundleSource = "export {};\n"): {
mkdirSync(native, { recursive: true }); mkdirSync(native, { recursive: true });
writeFileSync(path.join(native, "pty.node"), "native"); writeFileSync(path.join(native, "pty.node"), "native");
writeFileSync(path.join(native, "spawn-helper"), "#!/bin/sh\n", { mode: 0o755 }); writeFileSync(path.join(native, "spawn-helper"), "#!/bin/sh\n", { mode: 0o755 });
chmodSync(path.join(native, "spawn-helper"), 0o755);
// Another platform's build is not copied. // Another platform's build is not copied.
mkdirSync(path.join(nodePtyDirectory, "prebuilds", "other-platform")); mkdirSync(path.join(nodePtyDirectory, "prebuilds", "other-platform"));
return { bundleDirectory, nodePtyDirectory }; return { bundleDirectory, nodePtyDirectory };
+15 -67
View File
@@ -1,32 +1,16 @@
import { import {
addHost,
getPaseoClient, getPaseoClient,
useHosts, useHosts,
usePaseo, usePaseo,
type PluginSurfaceProps, type PluginSurfaceProps,
type PluginHost,
} from "@getpaseo/plugin/client"; } from "@getpaseo/plugin/client";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Pressable, Text, TextInput, View } from "react-native"; import { Pressable, Text, View } from "react-native";
export function Hosts({ theme }: PluginSurfaceProps) { export function Hosts({ theme }: PluginSurfaceProps) {
const hosts = useHosts(); const hosts = useHosts();
const selected = usePaseo(); const selected = usePaseo();
const [result, setResult] = useState(""); const [result, setResult] = useState("");
const [connection, setConnection] = useState("");
const add = useCallback(async () => {
try {
const outcome = await addHost({ connection });
if (outcome.status === "saved") setResult(`Added ${outcome.host.label}`);
else if (outcome.status === "cancelled") setResult("Host addition cancelled");
else setResult("Add ?password= and retry");
} catch (error) {
setResult(error instanceof Error ? error.message : String(error));
}
}, [connection]);
const addPressed = useCallback(() => {
void add();
}, [add]);
const list = useCallback( const list = useCallback(
async (serverId?: string) => { async (serverId?: string) => {
try { try {
@@ -49,63 +33,27 @@ export function Hosts({ theme }: PluginSurfaceProps) {
const listSelected = useCallback(() => { const listSelected = useCallback(() => {
void list(); void list();
}, [list]); }, [list]);
const rows = hosts.map((host) => ({
serverId: host.serverId,
label: host.label,
status: host.status,
onPress() {
void list(host.serverId);
},
}));
return ( return (
<View style={styles.root}> <View style={styles.root}>
<TextInput
value={connection}
onChangeText={setConnection}
placeholder="host:port, ssh://user@host, or a pairing link"
/>
<Pressable accessibilityRole="button" onPress={addPressed}>
<Text style={styles.text}>Add host</Text>
</Pressable>
<Pressable accessibilityRole="button" onPress={listSelected}> <Pressable accessibilityRole="button" onPress={listSelected}>
<Text style={styles.text}>List selected host agents</Text> <Text style={styles.text}>List selected host agents</Text>
</Pressable> </Pressable>
{hosts.map((host) => ( {rows.map((host) => (
<HostRow key={host.serverId} host={host} color={theme.colors.foreground} list={list} /> <Pressable key={host.serverId} accessibilityRole="button" onPress={host.onPress}>
<Text style={styles.text}>
{host.label}: {host.status}
</Text>
</Pressable>
))} ))}
<Text style={styles.text}>{result}</Text> <Text style={styles.text}>{result}</Text>
</View> </View>
); );
} }
function HostRow({
host,
color,
list,
}: {
host: PluginHost;
color: string;
list: (serverId: string) => Promise<void>;
}) {
const style = useMemo(() => ({ color }), [color]);
const rowStyle = useMemo(() => ({ gap: 4 }), []);
const listAgents = useCallback(() => void list(host.serverId), [host, list]);
const update = useCallback(
() => void host.update({ label: `${host.label} (build)`, appearance: { color: "sky" } }),
[host],
);
const open = useCallback(() => host.openSettings(), [host]);
const remove = useCallback(() => void host.remove(), [host]);
return (
<View style={rowStyle}>
<Text style={style}>
{host.label}: {host.status} ·{" "}
{host.connections.map((connection) => connection.uri ?? "via another host").join(", ")}
</Text>
<Pressable accessibilityRole="button" onPress={listAgents}>
<Text style={style}>List agents</Text>
</Pressable>
<Pressable accessibilityRole="button" onPress={update}>
<Text style={style}>Rename and color</Text>
</Pressable>
<Pressable accessibilityRole="button" onPress={open}>
<Text style={style}>Host settings</Text>
</Pressable>
<Pressable accessibilityRole="button" onPress={remove}>
<Text style={style}>Remove host</Text>
</Pressable>
</View>
);
}
+1 -5
View File
@@ -1,6 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client"; import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
import { observeHosts } from "@getpaseo/plugin/client";
import { SidebarRow } from "@getpaseo/plugin/client/ui"; import { SidebarRow } from "@getpaseo/plugin/client/ui";
import { Hosts } from "./client/hosts"; import { Hosts } from "./client/hosts";
@@ -12,8 +11,5 @@ function HostsItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
export default function contribute(client: PluginClientContext) { export default function contribute(client: PluginClientContext) {
client.addScreen({ id: "main", title: "Host agents", Component: Hosts }); client.addScreen({ id: "main", title: "Host agents", Component: Hosts });
client.addSidebarHeaderItem({ id: "hosts", title: "Host agents", Component: HostsItem }); client.addSidebarHeaderItem({ id: "hosts", title: "Host agents", Component: HostsItem });
return observeHosts((hosts) => { return () => {};
for (const host of hosts)
if (host.authFailureReason) console.log(`${host.label} requires attention`);
});
} }
+1 -1
View File
@@ -1 +1 @@
{ "id": "host-agents", "requirements": { "paseo": ">=0.15.0" } } { "id": "host-agents", "requirements": { "paseo": ">=0.8.0" } }
+11 -33
View File
@@ -2078,7 +2078,7 @@ function PullRequestAction({ theme }: PluginSurfaceProps) {
The returned API covers projects, workspaces, agents, terminals, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection. The returned API covers projects, workspaces, agents, terminals, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection.
### Hosts ### Discover hosts and target another host
Use `useHosts()` to display configured hosts and `getPaseoClient(serverId)` in an action callback Use `useHosts()` to display configured hosts and `getPaseoClient(serverId)` in an action callback
to run SDK operations on one of them: to run SDK operations on one of them:
@@ -2124,36 +2124,14 @@ export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactE
} }
``` ```
`useHosts(): readonly PluginHost[]` includes offline hosts and updates when projected fields change. `useHosts(): readonly PluginHostSummary[]` includes offline hosts and updates when hosts,
`observeHosts(listener)` immediately delivers the same list and returns an unsubscribe function. labels, or statuses change.
Set `requirements.paseo` in the manifest to the release that ships these host capabilities.
| Host field | Meaning | | Summary field | Type or values | Meaning |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | ------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
| `serverId` | ID to pass to `getPaseoClient`. | | `serverId` | `string` | ID to pass to `getPaseoClient`. |
| `label` | Display name. | | `label` | `string` | Host's display name. |
| `status` | `idle`, `connecting`, `online`, `offline`, or `error`. SDK calls require `online`. | | `status` | `"idle"`, `"connecting"`, `"online"`, `"offline"`, `"error"` | Current app connection status. SDK calls require `"online"`. |
| `appearance` | `color` and `badgeDisplay`; `null` badge display uses the local or remote default. |
| `connections` | Saved connections with an ID, type, credential-free `uri`, and type-specific settings. Routed connections have a null URI. |
| `activeConnectionId` | ID of the active connection, or null. |
| `error` | Current connection error, or null. |
| `authFailureReason` | `password_required`, `incorrect_password`, or null. |
| `lastOnlineAt`, `createdAt`, `updatedAt` | ISO timestamps; `lastOnlineAt` can be null. |
Call `host.update({ label, appearance })`, `host.removeConnection(id)`, `host.remove()`,
or `host.openSettings()` to manage a host. Updating `appearance.badgeDisplay` to null restores
the automatic default. Removing the last connection removes the host. The desktop daemon host
cannot be removed by a plugin.
Use `addHost({ connection, label? })` with the [shared connection grammar](../cli.md#connected-hosts).
A connection may include `?password=`. The password is used for the probe and saved locally; plugins
cannot read it back. Relay targets and pairing links ask the user to confirm.
| Result | Meaning |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `{ status: "saved", host }` | The host or connection was saved. |
| `{ status: "password_required", reason }` | Nothing was saved; supply a password and retry. The reason is `password_required` or `incorrect_password`. |
| `{ status: "cancelled" }` | The user declined the confirmation. |
`getPaseoClient(serverId: string): PaseoApi` borrows the host's authenticated app connection. `getPaseoClient(serverId: string): PaseoApi` borrows the host's authenticated app connection.
Call it in client entry code or callbacks; it opens no socket and does not require the plugin Call it in client entry code or callbacks; it opens no socket and does not require the plugin
@@ -2167,13 +2145,13 @@ on the target daemon. Acquire the API when performing an action to use the curre
| Connection settings change or the app switches connections, including automatic failover | The old API is released. Call `getPaseoClient(serverId)` again and recreate subscriptions. | | Connection settings change or the app switches connections, including automatic failover | The old API is released. Call `getPaseoClient(serverId)` again and recreate subscriptions. |
| Host is removed | Its API is released; the removed ID is unknown. | | Host is removed | Its API is released; the removed ID is unknown. |
| `client.dispose()` | Releases that API and its observations. A later getter call returns a fresh API over the app connection. Disposing the old API again leaves the new API usable. | | `client.dispose()` | Releases that API and its observations. A later getter call returns a fresh API over the app connection. Disposing the old API again leaves the new API usable. |
| Originating plugin unloads | Borrowed APIs and host observations are released. Later host handle calls throw `Plugin has stopped`. | | Originating plugin unloads | All its borrowed APIs and observations are released, including those targeting other hosts. Retained handles cannot outlive the installation. |
| Surface host selection changes | `usePaseo()` follows the selected host. An explicitly acquired API keeps its original target. | | Surface host selection changes | `usePaseo()` follows the selected host. An explicitly acquired API keeps its original target. |
You can also release individual subscriptions through the normal SDK API. You can also release individual subscriptions through the normal SDK API.
Plugins see connection targets and daemon identities but cannot read saved passwords. Borrowed APIs Plugins are trusted app code; cross-host access is intentional. Summaries contain no connection
provide no connection lifecycle controls. See the URLs or credentials, and borrowed APIs provide no connection lifecycle controls. See the
[host agents example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts). [host agents example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts).
## Add plugin-specific backend behavior ## Add plugin-specific backend behavior
+9 -28
View File
@@ -1779,7 +1779,7 @@ function PullRequestAction({ theme }: PluginSurfaceProps) {
返回的 API 涵盖项目、工作区、Agent、终端、Provider 和 daemon 配置。其方法请参阅 [SDK API 参考](/docs/sdk/reference)。这里刻意没有连接生命周期方法,因为连接由 Paseo 负责。 返回的 API 涵盖项目、工作区、Agent、终端、Provider 和 daemon 配置。其方法请参阅 [SDK API 参考](/docs/sdk/reference)。这里刻意没有连接生命周期方法,因为连接由 Paseo 负责。
### 主机 {#hosts} ### 发现主机并指定另一台主机 {#discover-hosts-and-target-another-host}
使用 `useHosts()` 显示已配置的主机,并在操作回调中使用 `getPaseoClient(serverId)` 对其中一台主机 使用 `useHosts()` 显示已配置的主机,并在操作回调中使用 `getPaseoClient(serverId)` 对其中一台主机
执行 SDK 操作: 执行 SDK 操作:
@@ -1825,33 +1825,13 @@ export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactE
} }
``` ```
`useHosts(): readonly PluginHost[]` 包括离线主机,并在可见字段变化时更新。`observeHosts(listener)` `useHosts(): readonly PluginHostSummary[]` 包括离线的主机,并在主机、标签或状态变化时更新。
立即提供相同的列表,并返回取消订阅函数。请将清单中的 `requirements.paseo` 设为提供这些主机能力的发布版本。
| 主机字段 | 含义 | | 摘要字段 | 类型或取值 | 含义 |
| ---------------------------------------- | -------------------------------------------------------------------------------------- | | ---------- | ------------------------------------------------------------ | --------------------------------------------- |
| `serverId` | 传给 `getPaseoClient` 的 ID。 | | `serverId` | `string` | 传给 `getPaseoClient` 的 ID。 |
| `label` | 显示名称。 | | `label` | `string` | 主机的显示名称。 |
| `status` | `idle`、`connecting`、`online`、`offline` 或 `error`。SDK 调用需要 `online`。 | | `status` | `"idle"`、`"connecting"`、`"online"`、`"offline"`、`"error"` | 当前应用的连接状态。SDK 调用需要 `"online"`。 |
| `appearance` | `color` 和 `badgeDisplay`;`null` 使用本地主机或远程主机的默认显示方式。 |
| `connections` | 已保存的连接,包括 ID、类型、不含凭据的 `uri` 和类型专属设置。路由连接的 URI 为 null。 |
| `activeConnectionId` | 活动连接的 ID,或 null。 |
| `error` | 当前连接错误,或 null。 |
| `authFailureReason` | `password_required`、`incorrect_password` 或 null。 |
| `lastOnlineAt`、`createdAt`、`updatedAt` | ISO 时间戳;`lastOnlineAt` 可以为 null。 |
调用 `host.update({ label, appearance })`、`host.removeConnection(id)`、`host.remove()` 或
`host.openSettings()` 管理主机。将 `appearance.badgeDisplay` 更新为 null 可恢复自动默认值。
移除最后一个连接会移除主机。插件不能移除桌面 daemon 主机。
使用 `addHost({ connection, label? })` 和[共享连接语法](../cli.md#connected-hosts)。连接字符串可以包含
`?password=`。密码用于探测并保存在本地,插件无法读回密码。中继目标和配对链接需要用户确认。
| 结果 | 含义 |
| ----------------------------------------- | ------------------------------------------------------------------------------------ |
| `{ status: "saved", host }` | 主机或连接已保存。 |
| `{ status: "password_required", reason }` | 未保存任何内容;提供密码后重试。原因是 `password_required` 或 `incorrect_password`。 |
| `{ status: "cancelled" }` | 用户拒绝了确认。 |
`getPaseoClient(serverId: string): PaseoApi` 借用该主机已认证的应用连接。请在客户端入口代码或回调中 `getPaseoClient(serverId: string): PaseoApi` 借用该主机已认证的应用连接。请在客户端入口代码或回调中
调用它;它不会打开 socket,也不要求目标 daemon 上安装了该插件。在执行操作时再获取 API,以便使用当前的 调用它;它不会打开 socket,也不要求目标 daemon 上安装了该插件。在执行操作时再获取 API,以便使用当前的
@@ -1869,7 +1849,8 @@ export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactE
你也可以通过普通的 SDK API 释放单个订阅。 你也可以通过普通的 SDK API 释放单个订阅。
插件可以查看连接目标和 daemon 身份,但不能读取已保存的密码。借用的 API 不提供连接生命周期控制。请参阅[主机 Agent 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts)。 插件是受信任的应用代码;跨主机访问是有意设计的。摘要中不包含连接 URL 或凭据,借用的 API 也不提供连接
生命周期控制。请参阅[主机 Agent 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts)。
## 添加插件特有的后端行为 {#add-plugin-specific-backend-behavior} ## 添加插件特有的后端行为 {#add-plugin-specific-backend-behavior}