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
Dominic 919e44b319 fix(server): start configured plugins concurrently (#8427)
The daemon accepts connections only after its configured plugins have
started, and PluginService.start started them one at a time, so each
plugin's build and process launch added to daemon startup. Configured
plugins now start together. Their providers and usage sources are still
published in config order: a plugin that finishes early waits for the
plugins listed before it, so when two plugins claim the same provider or
usage-source ID, the one listed first keeps it, as before. A failing
plugin still fails on its own.

Fixes #7979
2026-11-01 12:59:34 +00:00
paseo-bot[bot] 4fe44038e3 test(server): check subdirectory diffs before and after commit (#8466)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 12:35:50 +00:00
Dominic 9b0542d127 fix(server): retry a connected host that failed the capability check (#8423)
* fix(server): retry a connected host that failed the capability check

Every failure in a connected host's acceptance step was terminal: the
row went to "error" with no retry, and the status listener ignored
later reconnects. A host rejected with unsupported_capability stayed
unusable after it was updated, until the registration was saved again
or the managing daemon restarted. Capability and other non-identity
acceptance failures now go "offline" with nextRetryAt and retry with
capped backoff (1.5 s doubling to 30 s), so an updated host comes
online by itself. Self, duplicate, changed-identity and authentication
failures stay terminal.

Fixes #7971

* fix(server): keep a retried host's relay security floor and update guidance

A scheduled capability retry built a new transport factory, which
dropped the relay security floor learned from a protected handshake, so
the retry could negotiate legacy encryption and send credentials.
Retries now reuse the registration's transport factory (createHostClient
accepts one). The update guidance also stays visible while a retry
connects, the backoff resets after a successful acceptance, and
disabling or removing a host cancels its pending retry.
2026-11-01 10:59:33 +00:00
Dominic 8130147426 fix(app): stop the terminal from typing its version reply into the shell (#8419)
The browser terminal is a renderer and leaves protocol replies to the
daemon, but it still answered XTVERSION (CSI > q) and DECRQSS
(DCS $ q ... ST). Those replies went out through onData as if the user
had typed them, so a query from a program in the shell left text like
">|xterm.js(6.1.0-beta.292)" on the next input line. Both queries are
now swallowed with the other protocol queries.

Fixes #8111
2026-11-01 10:04:25 +00:00
Mohamed Boudra fa89738e3a chore(desktop): upgrade packaging tool and remove signing backport (#8450)
* chore(desktop): upgrade packaging tool and remove signing backport

* fix(nix): resolve the desktop workspace packaging tool
2026-11-01 14:46:52 +07:00
21 changed files with 1752 additions and 1452 deletions
-26
View File
@@ -135,32 +135,6 @@ jobs:
env: env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Backport electron-builder#10172 to the pinned build tool only.
# Remove this step when app-builder-lib is upgraded to a release containing it.
- name: Repair temporary keychain password in signing tool
shell: bash
run: |
node <<'KEYCHAIN_FIX'
const fs = require('node:fs');
const path = require('node:path');
const manifestPath = require.resolve('app-builder-lib/package.json');
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
}
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
let source = fs.readFileSync(sourcePath, 'utf8');
const replacements = [
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
];
for (const [before, after] of replacements) {
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
source = source.replace(before, after);
}
fs.writeFileSync(sourcePath, source);
KEYCHAIN_FIX
- name: Set desktop package version from tag - name: Set desktop package version from tag
shell: bash shell: bash
run: | run: |
+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.
+4 -1
View File
@@ -74,7 +74,10 @@ registrations remain inspectable; a changed destination requires explicit identi
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`. Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
operations; requesting this feature gives update guidance. operations; requesting this feature gives update guidance. The registry retries an older destination
with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and
authentication failures stay in `error` until an update changes the connection, `enabled`, or
`replaceIdentity`, or A restarts.
Disposing a routed API releases its observations and session. It does not remove the saved host or Disposing a routed API releases its observations and session. It does not remove the saved host or
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
+1 -1
View File
@@ -127,7 +127,7 @@ buildNpmPackage {
# The Nix output is not a distributable DMG, so leave it unsigned and # The Nix output is not a distributable DMG, so leave it unsigned and
# disable the hardened runtime that requires a matching signature. # disable the hardened runtime that requires a matching signature.
CSC_IDENTITY_AUTO_DISCOVERY=false \ CSC_IDENTITY_AUTO_DISCOVERY=false \
../../node_modules/.bin/electron-builder \ npm exec --offline -- electron-builder \
--config electron-builder.yml \ --config electron-builder.yml \
--dir \ --dir \
--mac \ --mac \
+1 -1
View File
@@ -1 +1 @@
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU= sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
+1018 -1295
View File
File diff suppressed because it is too large Load Diff
@@ -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)
}
} }
@@ -717,11 +717,13 @@ describe("terminal emulator runtime in a real browser", () => {
{ name: "DA1-zero", bytes: "\x1b[0c" }, { name: "DA1-zero", bytes: "\x1b[0c" },
{ name: "DA2", bytes: "\x1b[>c" }, { name: "DA2", bytes: "\x1b[>c" },
{ name: "DA3", bytes: "\x1b[=c" }, { name: "DA3", bytes: "\x1b[=c" },
{ name: "XTVERSION", bytes: "\x1b[>q" },
{ name: "DSR-5", bytes: "\x1b[5n" }, { name: "DSR-5", bytes: "\x1b[5n" },
{ name: "DSR-6", bytes: "\x1b[6n" }, { name: "DSR-6", bytes: "\x1b[6n" },
{ name: "DSR-?6", bytes: "\x1b[?6n" }, { name: "DSR-?6", bytes: "\x1b[?6n" },
{ name: "DECRQM", bytes: "\x1b[1$p" }, { name: "DECRQM", bytes: "\x1b[1$p" },
{ name: "DECRQM-?", bytes: "\x1b[?1$p" }, { name: "DECRQM-?", bytes: "\x1b[?1$p" },
{ name: "DECRQSS", bytes: "\x1bP$qm\x1b\\" },
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" }, { name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" }, { name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" }, { name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
@@ -647,6 +647,7 @@ export class TerminalEmulatorRuntime {
terminal.parser.registerCsiHandler({ final: "c" }, () => true); terminal.parser.registerCsiHandler({ final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true); terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true); terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: ">", final: "q" }, () => true);
terminal.parser.registerCsiHandler({ final: "n" }, () => true); terminal.parser.registerCsiHandler({ final: "n" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true); terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
terminal.parser.registerCsiHandler({ final: "R" }, () => true); terminal.parser.registerCsiHandler({ final: "R" }, () => true);
@@ -655,6 +656,7 @@ export class TerminalEmulatorRuntime {
{ prefix: "?", intermediates: "$", final: "p" }, { prefix: "?", intermediates: "$", final: "p" },
() => true, () => true,
); );
terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true);
for (const code of [10, 11, 12]) { for (const code of [10, 11, 12]) {
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?"); terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
} }
+4 -3
View File
@@ -204,15 +204,16 @@ export function createHostTransportFactory(
} }
export function createHostClient( export function createHostClient(
connection: string, connection: string,
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions, config: Omit<DaemonClientConfig, "url"> & HostTransportOptions,
budget?: HostTransportBudget, budget?: HostTransportBudget,
): DaemonClient { ): DaemonClient {
const target = parseHostTarget(connection); const target = parseHostTarget(connection);
const { webSocketAgent, ...clientConfig } = config; const { webSocketAgent, transportFactory, ...clientConfig } = config;
return new DaemonClient({ return new DaemonClient({
...clientConfig, ...clientConfig,
url: "ws://host-connector/ws", url: "ws://host-connector/ws",
...(target.password ? { password: target.password } : {}), ...(target.password ? { password: target.password } : {}),
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }), transportFactory:
transportFactory ?? createHostTransportFactory(connection, budget, { webSocketAgent }),
}); });
} }
+1 -1
View File
@@ -41,7 +41,7 @@
"@types/node": "24.6.0", "@types/node": "24.6.0",
"@types/ws": "^8.5.14", "@types/ws": "^8.5.14",
"electron": "44.2.0", "electron": "44.2.0",
"electron-builder": "26.8.1", "electron-builder": "26.18.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"unzip-crx-3": "^0.2.0", "unzip-crx-3": "^0.2.0",
"vitest": "^4.1.6", "vitest": "^4.1.6",
@@ -784,6 +784,211 @@ test("old capabilities fail before execution and authentication errors recover t
expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId()); expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId());
}); });
async function updatableDestination() {
let features: { connectedHosts: boolean; hostMultiplexing: boolean } | undefined;
let holding = false;
const held: (() => void)[] = [];
let connections = 0;
const http = createServer();
const ws = new WebSocketServer({ server: http });
ws.on("connection", (socket) => {
connections++;
socket.on("message", (data) => {
const frame = JSON.parse(data.toString());
if (frame.type === "ping") socket.send(JSON.stringify({ type: "pong" }));
if (frame.type !== "hello") return;
const reply = () =>
socket.send(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "srv_updated",
hostname: "updated",
version: "0.12.0",
...(features ? { features } : {}),
},
},
}),
);
if (holding) held.push(reply);
else reply();
});
});
http.listen(0, "127.0.0.1");
await once(http, "listening");
cleanup.push(async () => {
for (const socket of ws.clients) socket.terminate();
await new Promise<void>((resolve) => ws.close(() => resolve()));
await new Promise<void>((resolve) => http.close(() => resolve()));
});
const address = http.address();
if (!address || typeof address === "string") throw new Error("Missing destination port");
return {
port: address.port,
connections: () => connections,
hold: () => {
holding = true;
},
update: () => {
features = { connectedHosts: true, hostMultiplexing: true };
holding = false;
for (const reply of held.splice(0)) reply();
},
};
}
async function capabilityRetry(paseo: ReturnType<typeof createPaseoApi>): Promise<number> {
let retryAt = "";
await expect
.poll(
async () => {
const row = (await paseo.hosts.list()).hosts[0];
retryAt = row?.nextRetryAt ?? "";
return row;
},
{ timeout: 5000 },
)
.toMatchObject({
status: "offline",
nextRetryAt: expect.any(String),
error: { code: "unsupported_capability" },
});
return Date.parse(retryAt);
}
test("a destination updated past unsupported_capability keeps its guidance while retrying and comes online, while duplicate_host stays terminal", async () => {
const destination = await updatableDestination();
const a = await daemon();
const { paseo } = await api(a);
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
await capabilityRetry(paseo);
destination.hold();
await expect
.poll(async () => (await paseo.hosts.list()).hosts[0], { timeout: 5000 })
.toMatchObject({ status: "connecting", error: { code: "unsupported_capability" } });
await expect(paseo.hosts.client("srv_updated")).rejects.toMatchObject({
code: "unsupported_capability",
});
destination.update();
expect((await online(paseo)).hosts[0]).toMatchObject({
registrationId: host.registrationId,
serverId: "srv_updated",
nextRetryAt: null,
error: null,
});
await paseo.hosts.add({ connection: `ws://127.0.0.1:${destination.port}/ws` });
await expect
.poll(async () => (await paseo.hosts.list()).hosts[1], { timeout: 5000 })
.toMatchObject({ status: "error", nextRetryAt: null, error: { code: "duplicate_host" } });
});
test("disabling or removing a host cancels its pending capability retry", async () => {
const destination = await updatableDestination();
const a = await daemon();
const { paseo } = await api(a);
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
let retryAt = await capabilityRetry(paseo);
let connections = destination.connections();
await paseo.hosts.update(host.registrationId, { enabled: false });
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
expect(destination.connections()).toBe(connections);
await paseo.hosts.update(host.registrationId, { enabled: true });
retryAt = await capabilityRetry(paseo);
connections = destination.connections();
await paseo.hosts.remove(host.registrationId);
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
expect(destination.connections()).toBe(connections);
});
import {
createDaemonChannel,
exportPublicKey,
generateKeyPair,
openDaemonSecureChannel,
type Transport,
} from "@getpaseo/relay/e2ee";
test("a capability retry over a relay keeps the protected endpoint security it negotiated", async () => {
const keys = generateKeyPair();
const legacyReceived: unknown[] = [];
let connections = 0;
let retryClosed = false;
const http = createServer();
const ws = new WebSocketServer({ server: http });
ws.on("connection", (socket) => {
connections++;
const wire: Transport = {
send: (data) => socket.send(data),
close: (code, reason) => socket.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
socket.on("message", (data, isBinary) => {
const bytes = Buffer.from(data as Buffer);
wire.onmessage?.({
data: isBinary
? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
: bytes.toString(),
isBinary,
});
});
socket.on("close", (code, reason) => wire.onclose?.(code, reason.toString()));
if (connections > 1) {
socket.on("close", () => {
retryClosed = true;
});
void createDaemonChannel(wire, keys, {
onmessage: (data) => legacyReceived.push(data),
}).catch(() => {});
return;
}
const channel = openDaemonSecureChannel(wire, {
daemonKeyPair: keys,
events: {
onmessage: (data) => {
if (typeof data !== "string" || JSON.parse(data).type !== "hello") return;
void channel.then((secure) =>
secure.send(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "srv_relayed",
hostname: "relayed",
version: "0.12.0",
},
},
}),
),
);
},
},
});
void channel.catch(() => {});
});
http.listen(0, "127.0.0.1");
await once(http, "listening");
cleanup.push(async () => {
for (const socket of ws.clients) socket.terminate();
await new Promise<void>((resolve) => ws.close(() => resolve()));
await new Promise<void>((resolve) => http.close(() => resolve()));
});
const address = http.address();
if (!address || typeof address === "string") throw new Error("Missing relay port");
const a = await daemon();
const { paseo } = await api(a);
await paseo.hosts.add({
connection: `relay://127.0.0.1:${address.port}/srv_relayed?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`,
});
await capabilityRetry(paseo);
await expect.poll(() => retryClosed, { timeout: 15000 }).toBe(true);
expect(legacyReceived).toEqual([]);
});
test("an unreachable saved target becomes online after its daemon starts without being added again", async () => { test("an unreachable saved target becomes online after its daemon starts without being added again", async () => {
const reservation = createServer(); const reservation = createServer();
reservation.listen(0, "127.0.0.1"); reservation.listen(0, "127.0.0.1");
@@ -7,10 +7,12 @@ import { z } from "zod";
import { import {
createHostClient, createHostClient,
createHostChannelTransport, createHostChannelTransport,
createHostTransportFactory,
parseHostTarget, parseHostTarget,
HostTransportBudget, HostTransportBudget,
} from "@getpaseo/client/node"; } from "@getpaseo/client/node";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types";
import { import {
HostSummarySchema, HostSummarySchema,
HostAddSchema, HostAddSchema,
@@ -235,23 +237,30 @@ export class ConnectedHosts {
if (route.registrationId === id) if (route.registrationId === id)
route.close(code, "Host access was disabled, replaced or removed"); route.close(code, "Host access was disabled, replaced or removed");
} }
private start(row: Saved): void { private start(
row: Saved,
resume?: { failures: number; transports: DaemonTransportFactory },
): void {
if (!row.enabled || this.closed) return; if (!row.enabled || this.closed) return;
let stopped = false; const transports =
const client = createHostClient( resume?.transports ??
row.connection, createHostTransportFactory(row.connection, this.budget, {
{
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
connectTimeoutMs: 10000,
webSocketAgent: webSocketProxyAgent, webSocketAgent: webSocketProxyAgent,
}, });
this.budget, let stopped = false;
); let attempts = resume?.failures ?? 0;
let retry: ReturnType<typeof setTimeout> | undefined;
const client = createHostClient(row.connection, {
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
connectTimeoutMs: 10000,
transportFactory: transports,
});
const live: Live = { const live: Live = {
client, client,
accepted: false, accepted: false,
stop: () => { stop: () => {
stopped = true; stopped = true;
clearTimeout(retry);
void client.close(); void client.close();
}, },
}; };
@@ -285,7 +294,7 @@ export class ConnectedHosts {
if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true) if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true)
throw new HostError( throw new HostError(
"unsupported_capability", "unsupported_capability",
"Update the destination daemon to use connected hosts.", "Update the destination daemon to use connected hosts; retrying automatically.",
); );
// Pin and persist under the same writer as management to prevent discovery races. // Pin and persist under the same writer as management to prevent discovery races.
await this.mutate(async () => { await this.mutate(async () => {
@@ -304,16 +313,22 @@ export class ConnectedHosts {
if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return; if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return;
Object.assign(row, next); Object.assign(row, next);
live.accepted = true; live.accepted = true;
attempts = 0;
this.changed(); this.changed();
}); });
} catch (error) { } catch (error) {
if (stopped) return; if (stopped) return;
row.status = "error";
row.error = connectionFailure(error, client.authFailureReason !== null) ?? { row.error = connectionFailure(error, client.authFailureReason !== null) ?? {
code: "host_offline", code: "host_offline",
message: "Host connection failed", message: "Host connection failed",
}; };
row.nextRetryAt = null; if (terminalFailures.has(row.error.code)) {
row.status = "error";
row.nextRetryAt = null;
} else {
stopped = true;
retry = this.scheduleAcceptanceRetry(row, attempts, transports);
}
this.changed(); this.changed();
await client.close(); await client.close();
} }
@@ -329,18 +344,17 @@ export class ConnectedHosts {
if (route.registrationId === row.registrationId) route.close(); if (route.registrationId === row.registrationId) route.close();
row.status = state.status === "connecting" ? "connecting" : "offline"; row.status = state.status === "connecting" ? "connecting" : "offline";
row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null; row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null;
row.error = if (state.status === "disconnected")
state.status === "disconnected" row.error = connectionFailure(
? (connectionFailure( Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
Object.assign(new Error("Check the SSH configuration and daemon credentials"), { code: state.failureCode,
code: state.failureCode, }),
}), client.authFailureReason !== null,
client.authFailureReason !== null, ) ?? {
) ?? { code: "host_offline",
code: "host_offline", message: "Cannot reach host. Check the connection; retrying automatically.",
message: "Cannot reach host. Check the connection; retrying automatically.", };
}) else if (row.error?.code !== "unsupported_capability") row.error = null;
: null;
if (row.error?.code === "authentication_failed") { if (row.error?.code === "authentication_failed") {
row.status = "error"; row.status = "error";
row.nextRetryAt = null; row.nextRetryAt = null;
@@ -351,6 +365,18 @@ export class ConnectedHosts {
}); });
void client.connect().catch(() => {}); void client.connect().catch(() => {});
} }
private scheduleAcceptanceRetry(
row: Saved,
failures: number,
transports: DaemonTransportFactory,
): ReturnType<typeof setTimeout> {
const delay = acceptanceRetryDelayMs(failures);
row.status = "offline";
row.nextRetryAt = new Date(Date.now() + delay).toISOString();
const retry = setTimeout(() => this.start(row, { failures: failures + 1, transports }), delay);
retry.unref();
return retry;
}
async openRoute(input: { async openRoute(input: {
serverId: string; serverId: string;
scopeId: string; scopeId: string;
@@ -531,6 +557,20 @@ export class ConnectedHosts {
} }
} }
const ACCEPTANCE_RETRY_BASE_MS = 1500;
const ACCEPTANCE_RETRY_MAX_MS = 30_000;
function acceptanceRetryDelayMs(failures: number): number {
return Math.min(ACCEPTANCE_RETRY_BASE_MS * 2 ** failures, ACCEPTANCE_RETRY_MAX_MS);
}
const terminalFailures = new Set([
"server_id_mismatch",
"self_host",
"duplicate_host",
"authentication_failed",
]);
function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] { function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] {
if (error instanceof HostError) return { code: error.code, message: error.message }; if (error instanceof HostError) return { code: error.code, message: error.message };
if (error instanceof Error && "code" in error && error.code === "authentication_failed") if (error instanceof Error && "code" in error && error.code === "authentication_failed")
@@ -88,13 +88,9 @@ test.each(["uncommitted", "base", "commit"] as const)(
60_000, 60_000,
); );
test.each( test.each(["single", "batch", "rename"] as const)(
(["uncommitted", "base"] as const).flatMap((mode) => "%s diff includes hunks from a repository subdirectory before and after commit",
(["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })), async (shape) => {
),
)(
"$mode $shape diff includes hunks from a repository subdirectory",
async ({ mode, shape }) => {
const ctx = await createDaemonTestContext(); const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-")); const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
@@ -112,9 +108,7 @@ test.each(
} }
git(cwd, "add", "."); git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
if (mode === "base") { git(cwd, "checkout", "-b", "feature");
git(cwd, "checkout", "-b", "feature");
}
const filename = shape === "rename" ? "renamed.txt" : "normal.txt"; const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
if (shape === "rename") { if (shape === "rename") {
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`); git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
@@ -123,36 +117,38 @@ test.each(
if (shape === "batch") { if (shape === "batch") {
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n"); writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
} }
if (mode === "base") { async function expectSubdirectoryDiff(mode: "uncommitted" | "base") {
git(cwd, "add", "."); const compare = { mode, baseRef: "main" };
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
const liveDiff = await subscription.ready;
await subscription.release();
expect(rootDiff.error).toBeNull();
expect(subdirectoryDiff.error).toBeNull();
expect(liveDiff.error).toBeNull();
expect(liveDiff.files).toEqual(rootDiff.files);
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
expect(file).toMatchObject({
additions: 1,
deletions: 1,
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
});
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]),
);
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
} }
const compare = { mode, baseRef: "main" }; await expectSubdirectoryDiff("uncommitted");
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare); git(cwd, "add", ".");
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare); git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare); await expectSubdirectoryDiff("base");
const liveDiff = await subscription.ready;
await subscription.release();
expect(rootDiff.error).toBeNull();
expect(subdirectoryDiff.error).toBeNull();
expect(liveDiff.error).toBeNull();
expect(liveDiff.files).toEqual(rootDiff.files);
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
expect(file).toMatchObject({
additions: 1,
deletions: 1,
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
});
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]),
);
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
} finally { } finally {
await ctx.cleanup(); await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
@@ -20,6 +20,7 @@ import { DaemonConfigStore } from "../daemon-config-store.js";
import { PluginService } from "./index.js"; import { PluginService } from "./index.js";
import { BuiltinPluginLoader } from "./builtin/index.js"; import { BuiltinPluginLoader } from "./builtin/index.js";
import { ManagedPluginSources } from "./managed-source.js"; import { ManagedPluginSources } from "./managed-source.js";
import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
import { import {
startNpmRegistry, startNpmRegistry,
npmPluginPackages, npmPluginPackages,
@@ -179,6 +180,55 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) {
return { runtime, started, releaseStart, starts }; return { runtime, started, releaseStart, starts };
} }
function createGatedRuntime(providers: Record<string, readonly PluginProviderMetadata[]> = {}) {
const gates = new Map<string, PromiseWithResolvers<void>>();
const gate = (pluginId: string) => {
const existing = gates.get(pluginId);
if (existing) return existing;
const created = Promise.withResolvers<void>();
gates.set(pluginId, created);
return created;
};
const begun: string[] = [];
const running = new Set<string>();
const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
clearLogs: () => undefined,
getProviderRegistrations: (pluginId) =>
running.has(pluginId) ? (providers[pluginId] ?? []) : [],
startPlugin: async (pluginId) => {
begun.push(pluginId);
await gate(pluginId).promise;
running.add(pluginId);
},
stopPluginById: async (pluginId) => running.delete(pluginId),
stopAll: async () => {
running.clear();
},
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
return {
runtime,
begun,
finish: (pluginId: string) => gate(pluginId).resolve(),
fail: (pluginId: string, error: Error) => gate(pluginId).reject(error),
};
}
function nextNotification(service: PluginService, pluginId: string): Promise<void> {
return new Promise((resolve) => {
const unsubscribe = service.subscribe((notified) => {
if (notified !== pluginId) return;
unsubscribe();
resolve();
});
});
}
describe("PluginService", () => { describe("PluginService", () => {
it.each([ it.each([
{ shadow: false, runtime: "subprocess" }, { shadow: false, runtime: "subprocess" },
@@ -372,15 +422,127 @@ describe("PluginService", () => {
"startup-first": { source: "directory", path: first }, "startup-first": { source: "directory", path: first },
"startup-second": { source: "directory", path: second }, "startup-second": { source: "directory", path: second },
}); });
const snapshots: string[][] = []; const notifications: { pluginId: string; running: boolean }[] = [];
service.subscribe(() => snapshots.push(catalogIds(service))); service.subscribe((pluginId) =>
notifications.push({ pluginId, running: catalogIds(service).includes(pluginId) }),
);
await service.start(); await service.start();
expect(snapshots).toEqual([["startup-first"], ["startup-first", "startup-second"]]); expect(notifications).toEqual([
{ pluginId: "startup-first", running: true },
{ pluginId: "startup-second", running: true },
]);
expect(catalogIds(service)).toEqual(["startup-first", "startup-second"]);
await service.stopAllPlugins(); await service.stopAllPlugins();
}, 20_000); }, 20_000);
it("starts configured plugins concurrently and publishes them in config order", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const gated = createGatedRuntime();
const service = createService(
home,
{
alpha: { source: "directory", path: "/plugins/alpha" },
beta: { source: "directory", path: "/plugins/beta" },
gamma: { source: "directory", path: "/plugins/gamma" },
},
{ runtime: gated.runtime },
);
const notified: string[] = [];
service.subscribe((pluginId) => notified.push(pluginId));
let started = false;
const starting = (async () => {
await service.start();
started = true;
})();
await expect.poll(() => gated.begun).toEqual(["alpha", "beta", "gamma"]);
gated.finish("gamma");
const alphaPublished = nextNotification(service, "alpha");
gated.finish("alpha");
await alphaPublished;
expect(notified).toEqual(["alpha"]);
expect(started).toBe(false);
gated.finish("beta");
await starting;
expect(notified).toEqual(["alpha", "beta", "gamma"]);
expect(catalogIds(service)).toEqual(["alpha", "beta", "gamma"]);
await service.stopAllPlugins();
});
it("starts the other configured plugins when one fails while they are still starting", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const gated = createGatedRuntime();
const service = createService(
home,
{
broken: { source: "directory", path: "/plugins/broken" },
healthy: { source: "directory", path: "/plugins/healthy" },
},
{ runtime: gated.runtime },
);
const notified: string[] = [];
service.subscribe((pluginId) => notified.push(pluginId));
const starting = service.start();
await expect.poll(() => gated.begun).toEqual(["broken", "healthy"]);
const brokenPublished = nextNotification(service, "broken");
gated.fail("broken", new Error("startup exploded"));
await brokenPublished;
gated.finish("healthy");
await starting;
expect(notified).toEqual(["broken", "healthy"]);
expect(catalogIds(service)).toEqual(["healthy"]);
expect(await service.listPlugins()).toMatchObject([
{ id: "broken", status: "failed", error: "startup exploded" },
{ id: "healthy", status: "running" },
]);
await service.stopAllPlugins();
});
it("keeps a provider ID with the plugin listed first when both start at once", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const gated = createGatedRuntime({
first: [{ id: "shared-agent", label: "First agent" }],
second: [{ id: "shared-agent", label: "Second agent" }],
});
const service = createService(
home,
{
first: { source: "directory", path: "/plugins/first" },
second: { source: "directory", path: "/plugins/second" },
},
{ runtime: gated.runtime },
);
const starting = service.start();
await expect.poll(() => gated.begun).toEqual(["first", "second"]);
gated.finish("second");
gated.finish("first");
await starting;
expect(service.getProviderRegistrations()).toMatchObject([
{ id: "shared-agent", label: "First agent" },
]);
expect(catalogIds(service)).toEqual(["first"]);
expect(await service.listPlugins()).toMatchObject([
{ id: "first", status: "running" },
{
id: "second",
status: "failed",
error: 'Plugin second cannot register provider ID "shared-agent" twice',
},
]);
await service.stopAllPlugins();
});
it("uses an explicit config key, exposes reload failure, and retries from disk", async () => { it("uses an explicit config key, exposes reload failure, and retries from disk", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home); roots.push(home);
+20 -8
View File
@@ -230,11 +230,18 @@ export class PluginService {
} }
}); });
if (config.pluginsEnabled === true) { if (config.pluginsEnabled === true) {
for (const [pluginId, source] of Object.entries(config.plugins ?? {})) { let earlierPublished: Promise<unknown> = Promise.resolve();
if (source.enabled === false) continue; await Promise.all(
await this.startConfigured(pluginId); Object.entries(config.plugins ?? {})
this.notify(pluginId); .filter(([, source]) => source.enabled !== false)
} .map(async ([pluginId]) => {
const publishTurn = earlierPublished;
const starting = this.startConfigured(pluginId, publishTurn);
earlierPublished = Promise.all([publishTurn, starting]);
await starting;
this.notify(pluginId);
}),
);
} }
this.configStore.onFieldChange("pluginsEnabled", (value) => { this.configStore.onFieldChange("pluginsEnabled", (value) => {
this.handleGlobalSwitch(value === true); this.handleGlobalSwitch(value === true);
@@ -560,12 +567,12 @@ export class PluginService {
}).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins")); }).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins"));
} }
private async startConfigured(pluginId: string): Promise<void> { private async startConfigured(pluginId: string, publishTurn?: Promise<unknown>): Promise<void> {
const source = this.configStore.get().plugins?.[pluginId]; const source = this.configStore.get().plugins?.[pluginId];
if (!source || source.enabled === false || !this.canPublish(pluginId)) return; if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
this.errors.delete(pluginId); this.errors.delete(pluginId);
try { try {
await this.startPlugin(pluginId, source.path); await this.startPlugin(pluginId, source.path, publishTurn);
} catch (error) { } catch (error) {
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error); if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
} }
@@ -594,8 +601,13 @@ export class PluginService {
); );
} }
private async startPlugin(pluginId: string, sourcePath: string): Promise<void> { private async startPlugin(
pluginId: string,
sourcePath: string,
publishTurn?: Promise<unknown>,
): Promise<void> {
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId)); await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
await publishTurn;
try { try {
await this.publishProviderRegistrations(pluginId, sourcePath); await this.publishProviderRegistrations(pluginId, sourcePath);
this.publishUsageSources(pluginId); this.publishUsageSources(pluginId);
+39 -44
View File
@@ -680,51 +680,46 @@ test("macOS jobs use latest runners while preserving release architectures", ()
assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/); assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/);
}); });
test("macOS signing repair keeps certificate and keychain passwords separate", () => { test("Nix desktop packaging resolves the workspace-local builder", () => {
const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8"); const source = readFileSync(new URL("nix/desktop-package.nix", repoRoot), "utf8");
const match = workflow.match(/<<'KEYCHAIN_FIX'\n([\s\S]*?)\n KEYCHAIN_FIX/); const invocation = source.match(/^\s+([^\n]*electron-builder) \\\n/m)?.[1];
assert.ok(match, "the release workflow repairs the pinned signing tool"); assert.ok(invocation, "Nix invokes the desktop packaging tool");
const script = match[1].replace(/^ /gm, ""); const directory = mkdtempSync(join(tmpdir(), "nix-builder-resolution-"));
const original = [
"return await importCerts(keychainFile, certPaths, cscPasswords);",
"async function importCerts(keychainFile, paths, keyPasswords) {",
'["import", paths[i], "-P", password]',
'["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]',
"}",
].join("\n");
const directory = mkdtempSync(join(tmpdir(), "signing-repair-"));
try { try {
const packageDirectory = join(directory, "node_modules/app-builder-lib"); const desktop = join(directory, "packages/desktop");
mkdirSync(join(packageDirectory, "out/codeSign"), { recursive: true }); mkdirSync(join(desktop, "node_modules/.bin"), { recursive: true });
const manifest = join(packageDirectory, "package.json"); mkdirSync(join(desktop, "node_modules/electron-builder"));
const sourcePath = join(packageDirectory, "out/codeSign/macCodeSign.js"); writeFileSync(
for (const [version, source, succeeds] of [ join(desktop, "node_modules/electron-builder/package.json"),
["26.8.1", original, true], JSON.stringify({
["26.8.1", original.replace("cscPasswords);", "differentArguments);"), false], name: "electron-builder",
["26.18.0", original, false], version: "1.0.0",
]) { bin: { "electron-builder": "../.bin/electron-builder" },
writeFileSync(manifest, JSON.stringify({ name: "app-builder-lib", version })); }),
writeFileSync(sourcePath, source); );
const result = spawnSync(process.execPath, ["-e", script], { writeFileSync(
cwd: directory, join(directory, "package.json"),
encoding: "utf8", JSON.stringify({ private: true, workspaces: ["packages/desktop"] }),
}); );
const actual = readFileSync(sourcePath, "utf8"); writeFileSync(
if (succeeds) { join(desktop, "package.json"),
assert.equal(result.status, 0, result.stderr); JSON.stringify({
assert.ok( name: "desktop-fixture",
actual.includes("importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)"), private: true,
); devDependencies: { "electron-builder": "1.0.0" },
assert.ok( }),
actual.includes("importCerts(keychainFile, paths, keyPasswords, keychainPassword)"), );
); writeFileSync(
assert.ok(actual.includes('["import", paths[i], "-P", password]')); join(desktop, "node_modules/.bin/electron-builder"),
assert.ok(actual.includes('"-k", keychainPassword, keychainFile]')); '#!/bin/sh\nprintf "workspace-builder\\n"\n',
} else { { mode: 0o755 },
assert.notEqual(result.status, 0); );
assert.equal(actual, source, "unexpected tool inputs must fail without a partial write"); const result = spawnSync("sh", ["-c", `${invocation} --version`], {
} cwd: desktop,
} encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), "workspace-builder");
} finally { } finally {
rmSync(directory, { recursive: true, force: true }); rmSync(directory, { recursive: true, force: true });
} }