mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
919e44b319 | ||
|
|
4fe44038e3 | ||
|
|
9b0542d127 | ||
|
|
8130147426 | ||
|
|
fa89738e3a |
@@ -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: |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
|
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
|
||||||
|
|||||||
Generated
+1018
-1295
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() === "?");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user