Compare commits

...
Author SHA1 Message Date
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
15 changed files with 1561 additions and 1446 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: |
+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
@@ -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 });
} }