mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 12:25:05 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f3967d288 | ||
|
|
ade902f195 |
@@ -1,5 +1,12 @@
|
||||
import { STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs";
|
||||
|
||||
// A refused fetch proves the previous bridge stopped listening. Runtimes report
|
||||
// it differently: Node puts the code on `cause`, Bun puts it on the error.
|
||||
export function isRefused(error) {
|
||||
const code = error?.code ?? error?.cause?.code;
|
||||
return code === "ECONNREFUSED" || code === "ConnectionRefused";
|
||||
}
|
||||
|
||||
// OpenCode owns this plugin for the lifetime of a location. Paseo supplies only
|
||||
// live, per-session bindings over the authenticated OpenCode RPC channel.
|
||||
export async function registerSharedBridge(context) {
|
||||
@@ -130,12 +137,7 @@ export async function registerSharedBridge(context) {
|
||||
} catch (error) {
|
||||
// Refusal or rejected credentials mean this endpoint no longer
|
||||
// serves the previous binding. Other failures are inconclusive.
|
||||
if (
|
||||
error?.status !== 401 &&
|
||||
error?.cause?.code !== "ECONNREFUSED" &&
|
||||
error?.code !== "ConnectionRefused"
|
||||
)
|
||||
throw error;
|
||||
if (error?.status !== 401 && !isRefused(error)) throw error;
|
||||
}
|
||||
if (active)
|
||||
return { error: "OpenCode session is already bound to another Paseo bridge" };
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
@@ -167,3 +169,88 @@ test("a shared plugin binds Paseo sessions without involving unrelated sessions"
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a shared plugin rebinds a session after the previous bridge stops listening", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-shared-bridge-refused-"));
|
||||
const previous = createServer((_request, response) => {
|
||||
response.writeHead(200, { "Content-Type": "application/json", Connection: "close" });
|
||||
response.end(JSON.stringify({ tools: [] }));
|
||||
});
|
||||
await new Promise<void>((resolve) => previous.listen(0, "127.0.0.1", resolve));
|
||||
const previousPort = (previous.address() as AddressInfo).port;
|
||||
const current = new OpenCodeBridge({ paseoHome: home, logger: createTestLogger() });
|
||||
current.setManifestCatalog({ tools: new Map() });
|
||||
let rpc: Record<string, (input: Binding) => Promise<unknown>> = {};
|
||||
let dispose: (() => Promise<void>) | undefined;
|
||||
let releaseCurrent: (() => void) | undefined;
|
||||
try {
|
||||
const plugin = {
|
||||
package: await current.materializeV2Plugin(),
|
||||
options: { baseUrl: `http://127.0.0.1:${previousPort}`, token: "previous-bridge" },
|
||||
};
|
||||
const module = await import(
|
||||
pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href
|
||||
);
|
||||
dispose = await module.default.setup({
|
||||
options: {},
|
||||
location: { directory: home },
|
||||
provider: { reload: async () => {} },
|
||||
model: { reload: async () => {} },
|
||||
agent: { reload: async () => {} },
|
||||
command: { reload: async () => {} },
|
||||
skill: { reload: async () => {} },
|
||||
rpc: {
|
||||
register: async (_definition: unknown, handlers: typeof rpc) => {
|
||||
rpc = handlers;
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
},
|
||||
tool: { transform: async () => ({ dispose: async () => {} }) },
|
||||
session: {
|
||||
context: async () => [],
|
||||
get: async () => ({ parentID: "" }),
|
||||
hook: async () => ({ dispose: async () => {} }),
|
||||
},
|
||||
});
|
||||
await expect(rpc.bind({ sessionID: "paseo", ...plugin.options })).resolves.toEqual({});
|
||||
await current.start();
|
||||
// The restarted daemon owns the agent again, so its bridge serves this session.
|
||||
releaseCurrent = current.bindSession({ sessionId: "paseo", env: {} });
|
||||
await new Promise<void>((resolve) => {
|
||||
previous.close(() => resolve());
|
||||
previous.closeAllConnections();
|
||||
});
|
||||
await expect(
|
||||
rpc.bind({ sessionID: "paseo", ...current.sharedV2Connection() }),
|
||||
).resolves.toEqual({});
|
||||
} finally {
|
||||
await dispose?.();
|
||||
releaseCurrent?.();
|
||||
await current.close();
|
||||
await new Promise<void>((resolve) => previous.close(() => resolve()));
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a refused connection is the only probe failure that clears a binding", async () => {
|
||||
const source = new URL("./shared-bridge-plugin.mjs", import.meta.url).href;
|
||||
const { isRefused } = (await import(source)) as { isRefused: (error: unknown) => boolean };
|
||||
const nodeShape = Object.assign(new Error("fetch failed"), {
|
||||
cause: Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }),
|
||||
});
|
||||
const olderBunShape = Object.assign(new TypeError("Unable to connect"), {
|
||||
code: "ConnectionRefused",
|
||||
});
|
||||
// OpenCode 2.2.5 runs on Bun 1.4.4, which carries the code on the error and
|
||||
// leaves `cause` unset. Node puts the same failure on `cause` instead.
|
||||
const bunShape = Object.assign(
|
||||
new TypeError("ECONNREFUSED: Unable to connect. Is the computer able to access the url?"),
|
||||
{ code: "ECONNREFUSED" },
|
||||
);
|
||||
const inconclusive = Object.assign(new Error("request timed out"), { code: "ETIMEDOUT" });
|
||||
expect(isRefused(nodeShape)).toBe(true);
|
||||
expect(isRefused(olderBunShape)).toBe(true);
|
||||
expect(isRefused(bunShape)).toBe(true);
|
||||
expect(isRefused(inconclusive)).toBe(false);
|
||||
expect(isRefused(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1238,6 +1238,9 @@ test("a failed parent lookup is retried by the pull request status poll, keeping
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
now: () => new Date(nowMs),
|
||||
// Observing the workspace starts a background fetch of origin. A real one would run ssh
|
||||
// against the alias host in the checkout, and can outlive disposal and hold the folder.
|
||||
runGitFetch: async () => ({ changes: [], error: null }),
|
||||
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
|
||||
forgeOverrides: {
|
||||
// The adapter is real; only the poll is driven by the test instead of a timer.
|
||||
|
||||
Reference in New Issue
Block a user