mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 00:14:54 +00:00
feat(hosts): connect to saved hosts through a daemon
This commit is contained in:
@@ -6,6 +6,13 @@ type OutboundOperation = SessionOutboundMessage["type"];
|
||||
export type PermissionRequirement = DaemonPermission | readonly DaemonPermission[] | null;
|
||||
|
||||
const INBOUND_PERMISSION = {
|
||||
"hosts.list.request": "daemon.read",
|
||||
"hosts.add.request": "tunnel.manage",
|
||||
"hosts.update.request": "tunnel.manage",
|
||||
"hosts.remove.request": "tunnel.manage",
|
||||
"hosts.route.open.request": null,
|
||||
"hosts.route.frame.request": null,
|
||||
"hosts.route.close.request": null,
|
||||
abort_request: "workspace.write",
|
||||
"agent.config.apply.request": ["workspace.write", "hub.execute"],
|
||||
"agent.detach.request": "workspace.write",
|
||||
@@ -216,6 +223,14 @@ const INBOUND_PERMISSION = {
|
||||
} as const satisfies Record<InboundOperation, PermissionRequirement>;
|
||||
|
||||
const OUTBOUND_PERMISSION = {
|
||||
"hosts.list.response": "daemon.read",
|
||||
"hosts.changed": "daemon.read",
|
||||
"hosts.add.response": "tunnel.manage",
|
||||
"hosts.update.response": "tunnel.manage",
|
||||
"hosts.remove.response": "tunnel.manage",
|
||||
"hosts.route.open.response": null,
|
||||
"hosts.route.frame": null,
|
||||
"hosts.route.closed": null,
|
||||
"agent.create.response": ["workspace.write", "hub.execute"],
|
||||
"agent.create.update": ["workspace.write", "hub.execute"],
|
||||
"workspace.create.update": "workspace.manage",
|
||||
|
||||
@@ -0,0 +1,688 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { createPaseoApi } from "@getpaseo/client";
|
||||
import { createHostClient } from "@getpaseo/client/node";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import type { HostDirectory } from "@getpaseo/protocol/hosts";
|
||||
|
||||
const cleanup: (() => Promise<unknown>)[] = [];
|
||||
afterEach(async () => {
|
||||
for (const close of cleanup.splice(0).toReversed()) await close();
|
||||
});
|
||||
async function daemon(): Promise<TestPaseoDaemon> {
|
||||
const instance = await createTestPaseoDaemon();
|
||||
cleanup.push(() => instance.close());
|
||||
return instance;
|
||||
}
|
||||
async function api(instance: TestPaseoDaemon) {
|
||||
const client = createHostClient(`127.0.0.1:${instance.port}`, {
|
||||
clientId: crypto.randomUUID(),
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
await client.connect();
|
||||
const paseo = createPaseoApi(client);
|
||||
cleanup.push(async () => {
|
||||
await paseo.dispose();
|
||||
await client.close();
|
||||
});
|
||||
return { paseo, client };
|
||||
}
|
||||
async function online(paseo: ReturnType<typeof createPaseoApi>): Promise<HostDirectory> {
|
||||
let directory!: HostDirectory;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
directory = await paseo.hosts.list();
|
||||
return directory.hosts[0]?.status;
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
.toBe("online");
|
||||
return directory;
|
||||
}
|
||||
|
||||
test("SDK saves unreachable hosts, rejects syntax and preserves source identity without exposing secrets", async () => {
|
||||
const a = await daemon();
|
||||
const { paseo } = await api(a);
|
||||
await expect(paseo.hosts.add({ connection: "ssh://-bad" })).rejects.toMatchObject({
|
||||
code: "invalid_connection",
|
||||
});
|
||||
const saved = await paseo.hosts.add({
|
||||
connection: "tcp://127.0.0.1:1?password=private-secret",
|
||||
label: "Unavailable",
|
||||
metadata: { team: "infra" },
|
||||
sourceKey: "stable",
|
||||
});
|
||||
expect(saved).toMatchObject({
|
||||
registrationId: expect.stringMatching(/^hst_/),
|
||||
serverId: null,
|
||||
label: "Unavailable",
|
||||
source: { id: "owner", key: "stable" },
|
||||
metadata: { team: "infra" },
|
||||
enabled: true,
|
||||
});
|
||||
expect(JSON.stringify(await paseo.hosts.list())).not.toContain("private-secret");
|
||||
await paseo.hosts.update(saved.registrationId, { enabled: false });
|
||||
expect(
|
||||
await paseo.hosts.add({
|
||||
connection: "tcp://127.0.0.1:1?password=private-secret",
|
||||
label: "Unavailable",
|
||||
metadata: { team: "infra" },
|
||||
sourceKey: "stable",
|
||||
}),
|
||||
).toMatchObject({ registrationId: saved.registrationId, enabled: false, status: "disabled" });
|
||||
await expect(
|
||||
paseo.hosts.add({ connection: "127.0.0.1:2", sourceKey: "stable" }),
|
||||
).rejects.toMatchObject({ code: "source_conflict" });
|
||||
await paseo.hosts.remove(saved.registrationId);
|
||||
expect((await paseo.hosts.list()).hosts).toEqual([]);
|
||||
});
|
||||
|
||||
test("ordinary routed APIs isolate scopes and enforce one-hop, disable and origin disposal", async () => {
|
||||
const b = await daemon();
|
||||
const a = await daemon();
|
||||
const { paseo } = await api(a);
|
||||
const observation = paseo.hosts.observe();
|
||||
expect((await observation.ready).hosts).toEqual([]);
|
||||
const saved = await paseo.hosts.add({ connection: `127.0.0.1:${b.port}`, label: "B" });
|
||||
const directory = await online(paseo);
|
||||
const serverId = directory.hosts[0]!.serverId!;
|
||||
const first = await paseo.hosts.client(serverId);
|
||||
const second = await paseo.hosts.client(serverId);
|
||||
const workspaces = await second.workspaces.list({ subscribe: {} });
|
||||
expect(workspaces.entries).toEqual([]);
|
||||
await first.dispose();
|
||||
expect((await second.workspaces.list()).entries).toEqual([]);
|
||||
await expect(second.hosts.client(directory.self.serverId)).rejects.toMatchObject({
|
||||
code: "unsupported_capability",
|
||||
});
|
||||
await paseo.hosts.update(saved.registrationId, { enabled: false });
|
||||
await expect(paseo.hosts.client(serverId)).rejects.toMatchObject({ code: "host_disabled" });
|
||||
await paseo.hosts.update(saved.registrationId, { enabled: true });
|
||||
await online(paseo);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
return (await second.workspaces.list()).entries.length;
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
)
|
||||
.toBe(0);
|
||||
await second.dispose();
|
||||
await observation.release();
|
||||
await paseo.dispose();
|
||||
});
|
||||
|
||||
test("routed authority is bounded by the caller, with isolated simultaneous sessions", async () => {
|
||||
const b = await daemon();
|
||||
const a = await daemon();
|
||||
const owner = await api(a);
|
||||
await owner.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
const directory = await online(owner.paseo);
|
||||
const serverId = directory.hosts[0]!.serverId!;
|
||||
const viewerClient = createHostClient(`127.0.0.1:${a.port}`, {
|
||||
clientId: "overlapping",
|
||||
sessionPermissions: ["daemon.read", "workspace.read"],
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
await viewerClient.connect();
|
||||
const viewer = createPaseoApi(viewerClient);
|
||||
cleanup.push(async () => {
|
||||
await viewer.dispose();
|
||||
await viewerClient.close();
|
||||
});
|
||||
const remote = await viewer.hosts.client(serverId);
|
||||
expect((await remote.workspaces.list()).entries).toEqual([]);
|
||||
await expect(
|
||||
remote.workspaces.create({ source: { kind: "directory", path: b.paseoHome } }),
|
||||
).rejects.toMatchObject({ code: "permission_denied" });
|
||||
await expect(viewer.hosts.add({ connection: "127.0.0.1:1" })).rejects.toMatchObject({
|
||||
code: "permission_denied",
|
||||
});
|
||||
expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1);
|
||||
await remote.dispose();
|
||||
});
|
||||
|
||||
test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => {
|
||||
const b = await daemon();
|
||||
const a = await daemon();
|
||||
const admin = await api(a);
|
||||
await admin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
const serverId = (await online(admin.paseo)).hosts[0]!.serverId!;
|
||||
const remote = await admin.paseo.hosts.client(serverId);
|
||||
const workspace = await remote.workspaces.create({
|
||||
source: { kind: "directory", path: b.staticDir },
|
||||
});
|
||||
const one = await workspace.terminals.create({
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
|
||||
name: "First",
|
||||
});
|
||||
const two = await workspace.terminals.create({
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
|
||||
name: "Second",
|
||||
});
|
||||
const firstOrigin = await api(a);
|
||||
const secondOrigin = await api(a);
|
||||
const first = await RoutePeer.open(firstOrigin.client, serverId);
|
||||
const second = await RoutePeer.open(secondOrigin.client, serverId);
|
||||
await first.subscribeTerminal(one.id);
|
||||
await second.subscribeTerminal(two.id);
|
||||
first.input("FIRST_ONLY\n");
|
||||
second.input("SECOND_ONLY\n");
|
||||
await expect.poll(() => first.output()).toContain("FIRST_ONLY");
|
||||
await expect.poll(() => second.output()).toContain("SECOND_ONLY");
|
||||
expect(first.output()).not.toContain("SECOND_ONLY");
|
||||
expect(second.output()).not.toContain("FIRST_ONLY");
|
||||
await first.close();
|
||||
second.input("STILL_SECOND\n");
|
||||
await expect.poll(() => second.output()).toContain("STILL_SECOND");
|
||||
await second.close();
|
||||
await one.kill();
|
||||
await two.kill();
|
||||
await remote.dispose();
|
||||
});
|
||||
|
||||
import {
|
||||
decodeTerminalStreamFrame,
|
||||
encodeTerminalStreamFrame,
|
||||
TerminalStreamOpcode,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import {
|
||||
WSOutboundMessageSchema,
|
||||
type SessionOutboundMessage,
|
||||
type SessionInboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
class RoutePeer {
|
||||
readonly messages: SessionOutboundMessage[] = [];
|
||||
readonly bytes: Uint8Array[] = [];
|
||||
private slot = -1;
|
||||
private detach = () => {};
|
||||
private constructor(
|
||||
private readonly origin: DaemonClient,
|
||||
private readonly routeId: string,
|
||||
) {
|
||||
this.detach = origin.subscribeRawMessages((message) => {
|
||||
if (message.type !== "hosts.route.frame" || message.payload.subscriptionId !== routeId)
|
||||
return;
|
||||
if (message.payload.binary) this.bytes.push(Buffer.from(message.payload.data, "base64"));
|
||||
else {
|
||||
const frame = WSOutboundMessageSchema.parse(JSON.parse(message.payload.data));
|
||||
if (frame.type === "session") this.messages.push(frame.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
static async open(origin: DaemonClient, serverId: string) {
|
||||
const peer = new RoutePeer(
|
||||
origin,
|
||||
await origin.openHostRoute(serverId, "same-untrusted-scope"),
|
||||
);
|
||||
origin.sendHostFrame(
|
||||
peer.routeId,
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
clientId: "same-untrusted-client",
|
||||
clientType: "cli",
|
||||
protocolVersion: 1,
|
||||
capabilities: { owned_subscriptions: true },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
peer.messages.filter(
|
||||
(message) => message.type === "status" && message.payload.status === "server_info",
|
||||
).length,
|
||||
)
|
||||
.toBe(1);
|
||||
return peer;
|
||||
}
|
||||
send(message: SessionInboundMessage) {
|
||||
this.origin.sendHostFrame(this.routeId, JSON.stringify({ type: "session", message }), false);
|
||||
}
|
||||
async subscribeTerminal(terminalId: string) {
|
||||
this.send({ type: "subscribe_terminal_request", terminalId, requestId: "same-request" });
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
this.messages.filter((message) => message.type === "subscribe_terminal_response").length,
|
||||
)
|
||||
.toBe(1);
|
||||
const response = this.messages.find(
|
||||
(message) => message.type === "subscribe_terminal_response",
|
||||
);
|
||||
if (response?.type !== "subscribe_terminal_response" || response.payload.slot === null)
|
||||
throw new Error("Missing terminal response");
|
||||
this.slot = response.payload.slot!;
|
||||
}
|
||||
input(text: string) {
|
||||
this.origin.sendHostFrame(
|
||||
this.routeId,
|
||||
Buffer.from(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
slot: this.slot,
|
||||
payload: text,
|
||||
}),
|
||||
).toString("base64"),
|
||||
true,
|
||||
);
|
||||
}
|
||||
output() {
|
||||
return this.bytes
|
||||
.flatMap((bytes) => {
|
||||
const frame = decodeTerminalStreamFrame(bytes);
|
||||
return frame?.opcode === TerminalStreamOpcode.Output
|
||||
? [new TextDecoder().decode(frame.payload)]
|
||||
: [];
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
async close() {
|
||||
this.detach();
|
||||
this.origin.closeHostRoute(this.routeId);
|
||||
}
|
||||
}
|
||||
|
||||
import { mkdir, writeFile, stat, mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolveDaemonVersion } from "../daemon-version.js";
|
||||
|
||||
test("identity discovery failures remain inspectable and changing a pinned destination requires explicit replacement", async () => {
|
||||
const a = await daemon();
|
||||
const b = await daemon();
|
||||
const c = await daemon();
|
||||
const { paseo } = await api(a);
|
||||
const self = await paseo.hosts.add({ connection: `127.0.0.1:${a.port}` });
|
||||
await expect.poll(async () => (await paseo.hosts.list()).hosts[0]!.error?.code).toBe("self_host");
|
||||
expect((await paseo.hosts.list()).hosts[0]!.serverId).toBe(a.daemon.getServerId());
|
||||
await paseo.hosts.remove(self.registrationId);
|
||||
const original = await paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
const bId = (await online(paseo)).hosts[0]!.serverId;
|
||||
const duplicate = await paseo.hosts.add({ connection: `ws://127.0.0.1:${b.port}/ws` });
|
||||
await expect
|
||||
.poll(async () => (await paseo.hosts.list()).hosts[1]!.error?.code)
|
||||
.toBe("duplicate_host");
|
||||
expect((await paseo.hosts.list()).hosts[1]!.serverId).toBe(bId);
|
||||
await paseo.hosts.remove(duplicate.registrationId);
|
||||
await paseo.hosts.update(original.registrationId, { connection: `127.0.0.1:${c.port}` });
|
||||
await expect
|
||||
.poll(async () => (await paseo.hosts.list()).hosts[0]!.error?.code)
|
||||
.toBe("server_id_mismatch");
|
||||
expect((await paseo.hosts.list()).hosts[0]!.serverId).toBe(bId);
|
||||
await paseo.hosts.update(original.registrationId, {
|
||||
connection: `127.0.0.1:${c.port}`,
|
||||
replaceIdentity: true,
|
||||
});
|
||||
expect((await online(paseo)).hosts[0]!.serverId).toBe(c.daemon.getServerId());
|
||||
});
|
||||
|
||||
test("disabled host configuration and source survive a fresh daemon instance", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-host-restart-"));
|
||||
cleanup.push(() => rm(root, { recursive: true, force: true }));
|
||||
const a = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
cleanup.push(() => a.close());
|
||||
const { paseo } = await api(a);
|
||||
const host = await paseo.hosts.add({
|
||||
connection: "tcp://127.0.0.1:1?password=secret",
|
||||
label: "Persistent",
|
||||
metadata: { team: "infra" },
|
||||
sourceKey: "stable",
|
||||
});
|
||||
await paseo.hosts.update(host.registrationId, { enabled: false });
|
||||
expect((await stat(join(a.paseoHome, "hosts.json"))).mode & 0o777).toBe(0o600);
|
||||
await a.close();
|
||||
const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
cleanup.push(() => restarted.close());
|
||||
const next = await api(restarted);
|
||||
expect((await next.paseo.hosts.list()).hosts).toEqual([
|
||||
expect.objectContaining({
|
||||
registrationId: host.registrationId,
|
||||
label: "Persistent",
|
||||
metadata: { team: "infra" },
|
||||
source: { id: "owner", key: "stable" },
|
||||
enabled: false,
|
||||
status: "disabled",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("server plugins use ordinary remote SDK operations and retain qualified source ownership on B", async () => {
|
||||
const b = await daemon();
|
||||
const a = await daemon();
|
||||
const { paseo, client } = await api(a);
|
||||
await paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
const serverId = (await online(paseo)).hosts[0]!.serverId!;
|
||||
const directory = join(a.staticDir, "host-plugin");
|
||||
await mkdir(directory);
|
||||
await writeFile(
|
||||
join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "connected-host-test",
|
||||
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
join(directory, "index.server.ts"),
|
||||
`
|
||||
import { defineRpc } from "@getpaseo/plugin";
|
||||
import { z } from "zod";
|
||||
const operate = defineRpc({ name: "operate", input: z.object({ serverId: z.string(), path: z.string() }), output: z.object({ workspaceId: z.string(), registrationId: z.string(), sourceId: z.string() }) });
|
||||
export default function contribute(server) {
|
||||
server.handle(operate, async (input, context) => {
|
||||
const remote = await context.paseo.hosts.client(input.serverId);
|
||||
try {
|
||||
const workspace = await remote.workspaces.create({ source: { kind: "directory", path: input.path }, title: "Plugin remote workspace" });
|
||||
const host = await remote.hosts.add({ connection: "127.0.0.1:1", sourceKey: "plugin-stable", metadata: { team: "plugin" } });
|
||||
return { workspaceId: workspace.id, registrationId: host.registrationId, sourceId: host.source.id };
|
||||
} finally { await remote.dispose(); }
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
const result = await client.invokePluginRpc("connected-host-test", "operate", {
|
||||
serverId,
|
||||
path: b.staticDir,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
workspaceId: expect.stringMatching(/^wks_/),
|
||||
registrationId: expect.stringMatching(/^hst_/),
|
||||
sourceId: `route:owner:${a.daemon.getServerId()}:plugin%3Aconnected-host-test`,
|
||||
});
|
||||
const direct = await api(b);
|
||||
expect((await direct.paseo.workspaces.list()).entries).toHaveLength(1);
|
||||
expect((await direct.paseo.hosts.list()).hosts[0]).toMatchObject({
|
||||
source: {
|
||||
id: `route:owner:${a.daemon.getServerId()}:plugin%3Aconnected-host-test`,
|
||||
key: "plugin-stable",
|
||||
},
|
||||
metadata: { team: "plugin" },
|
||||
});
|
||||
await client.removePlugin("connected-host-test");
|
||||
expect((await direct.paseo.workspaces.list()).entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
test("CLI host commands manage the selected daemon and emit secret-free structured output", async () => {
|
||||
const a = await daemon();
|
||||
const cli = async (...args: string[]) =>
|
||||
JSON.parse(
|
||||
(
|
||||
await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
|
||||
"--host",
|
||||
`127.0.0.1:${a.port}`,
|
||||
"host",
|
||||
...args,
|
||||
"--json",
|
||||
],
|
||||
{ timeout: 20000 },
|
||||
)
|
||||
).stdout,
|
||||
);
|
||||
const saved = await cli(
|
||||
"add",
|
||||
"tcp://127.0.0.1:1?password=cli-secret",
|
||||
"--label",
|
||||
"CLI host",
|
||||
"--meta",
|
||||
"team=infra",
|
||||
);
|
||||
expect(saved).toMatchObject({
|
||||
label: "CLI host",
|
||||
metadata: { team: "infra" },
|
||||
registrationId: expect.stringMatching(/^hst_/),
|
||||
});
|
||||
expect(JSON.stringify(await cli("ls"))).not.toContain("cli-secret");
|
||||
expect(
|
||||
await cli(
|
||||
"update",
|
||||
saved.registrationId,
|
||||
"--meta",
|
||||
"team=tools",
|
||||
"--meta",
|
||||
"other=value",
|
||||
"--remove-meta",
|
||||
"team",
|
||||
),
|
||||
).toMatchObject({ metadata: { other: "value" } });
|
||||
expect(await cli("disable", saved.registrationId)).toMatchObject({
|
||||
status: "disabled",
|
||||
enabled: false,
|
||||
});
|
||||
expect(await cli("inspect", saved.registrationId)).toMatchObject({
|
||||
enabled: false,
|
||||
targetDescription: "tcp://127.0.0.1:1",
|
||||
});
|
||||
expect(await cli("enable", saved.registrationId)).toMatchObject({ enabled: true });
|
||||
await cli("rm", saved.registrationId);
|
||||
expect(await cli("ls")).toEqual([]);
|
||||
}, 60000);
|
||||
|
||||
test("resuming an A session with narrower grants terminates existing routes and restores observations with current authority", async () => {
|
||||
const b = await daemon();
|
||||
const a = await daemon();
|
||||
const admin = await api(a);
|
||||
await admin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
const serverId = (await online(admin.paseo)).hosts[0]!.serverId!;
|
||||
const origin = createHostClient(`127.0.0.1:${a.port}`, {
|
||||
clientId: "resumed-authority",
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
await origin.connect();
|
||||
const paseo = createPaseoApi(origin);
|
||||
cleanup.push(async () => {
|
||||
await paseo.dispose();
|
||||
await origin.close();
|
||||
});
|
||||
const remote = await paseo.hosts.client(serverId);
|
||||
const observed = await remote.workspaces.list({ subscribe: {} });
|
||||
const snapshots: unknown[] = [];
|
||||
observed.subscription.subscribe({
|
||||
snapshot: (snapshot) => snapshots.push(snapshot),
|
||||
update() {},
|
||||
});
|
||||
const restricted = createHostClient(`127.0.0.1:${a.port}`, {
|
||||
clientId: "resumed-authority",
|
||||
sessionPermissions: ["daemon.read", "workspace.read"],
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
await restricted.connect();
|
||||
cleanup.push(() => restricted.close());
|
||||
await expect.poll(() => snapshots.length, { timeout: 15000 }).toBe(2);
|
||||
await expect(
|
||||
remote.workspaces.create({ source: { kind: "directory", path: b.staticDir } }),
|
||||
).rejects.toMatchObject({ code: "permission_denied" });
|
||||
expect((await remote.workspaces.list()).entries).toEqual([]);
|
||||
await remote.dispose();
|
||||
});
|
||||
|
||||
import { WebSocketServer } from "ws";
|
||||
import { createServer } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { hashSync } from "bcryptjs";
|
||||
|
||||
test("old capabilities fail before execution and authentication errors recover through credential replacement", async () => {
|
||||
const http = createServer();
|
||||
const ws = new WebSocketServer({ server: http });
|
||||
const operations: string[] = [];
|
||||
ws.on("connection", (socket) =>
|
||||
socket.on("message", (data) => {
|
||||
const frame = JSON.parse(data.toString());
|
||||
if (frame.type === "hello")
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: "srv_old",
|
||||
hostname: "old",
|
||||
version: "0.1.0",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
else if (frame.type === "session") operations.push(frame.message.type);
|
||||
}),
|
||||
);
|
||||
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 old daemon port");
|
||||
const old = createHostClient(`127.0.0.1:${address.port}`, {
|
||||
clientId: "old-capability",
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
await old.connect();
|
||||
cleanup.push(() => old.close());
|
||||
await expect(createPaseoApi(old).hosts.add({ connection: "127.0.0.1:1" })).rejects.toMatchObject({
|
||||
code: "unsupported_capability",
|
||||
});
|
||||
expect(operations).toEqual([]);
|
||||
const a = await daemon();
|
||||
const { paseo } = await api(a);
|
||||
const registration = await paseo.hosts.add({ connection: `127.0.0.1:${address.port}` });
|
||||
await expect
|
||||
.poll(async () => (await paseo.hosts.list()).hosts[0]!.error?.code)
|
||||
.toBe("unsupported_capability");
|
||||
await expect(paseo.hosts.client("srv_old")).rejects.toMatchObject({
|
||||
code: "unsupported_capability",
|
||||
});
|
||||
expect(operations).toEqual([]);
|
||||
await paseo.hosts.remove(registration.registrationId);
|
||||
const b = await createTestPaseoDaemon({ auth: { password: hashSync("right-password", 4) } });
|
||||
cleanup.push(() => b.close());
|
||||
const saved = await paseo.hosts.add({
|
||||
connection: `tcp://127.0.0.1:${b.port}?password=wrong-password`,
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await paseo.hosts.list()).hosts[0]!.error?.code)
|
||||
.toBe("authentication_failed");
|
||||
expect(JSON.stringify(await paseo.hosts.list())).not.toContain("wrong-password");
|
||||
await paseo.hosts.update(saved.registrationId, {
|
||||
connection: `tcp://127.0.0.1:${b.port}?password=right-password`,
|
||||
});
|
||||
expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId());
|
||||
});
|
||||
|
||||
test("an unreachable saved target becomes online after its daemon starts without being added again", async () => {
|
||||
const reservation = createServer();
|
||||
reservation.listen(0, "127.0.0.1");
|
||||
await once(reservation, "listening");
|
||||
const address = reservation.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing reserved port");
|
||||
await new Promise<void>((resolve) => reservation.close(() => resolve()));
|
||||
const a = await daemon();
|
||||
const { paseo } = await api(a);
|
||||
const host = await paseo.hosts.add({ connection: `127.0.0.1:${address.port}` });
|
||||
await expect.poll(async () => (await paseo.hosts.list()).hosts[0]!.status).toBe("offline");
|
||||
const b = await createTestPaseoDaemon({ listenPort: address.port });
|
||||
cleanup.push(() => b.close());
|
||||
expect((await online(paseo)).hosts[0]).toMatchObject({
|
||||
registrationId: host.registrationId,
|
||||
serverId: b.daemon.getServerId(),
|
||||
status: "online",
|
||||
nextRetryAt: null,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("losing a sent mutation result identifies the destination and never queues unavailable operations", async () => {
|
||||
const b = await daemon();
|
||||
const a = await daemon();
|
||||
const { paseo, client } = await api(a);
|
||||
await paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
const serverId = (await online(paseo)).hosts[0]!.serverId!;
|
||||
const remote = await paseo.hosts.client(serverId);
|
||||
const pending = remote.config.patch({ pluginsEnabled: false });
|
||||
const result = pending.catch((error: unknown) => error);
|
||||
await client.close();
|
||||
expect(await result).toMatchObject({ code: "outcome_unknown", serverId });
|
||||
await expect(remote.workspaces.list()).rejects.toMatchObject({ code: "host_offline", serverId });
|
||||
await expect(remote.terminals.list()).rejects.toMatchObject({ code: "host_offline", serverId });
|
||||
await expect(
|
||||
remote.workspaces.create({
|
||||
source: { kind: "directory", path: "/tmp" },
|
||||
workspaceId: "offline-never-created",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "host_offline", serverId });
|
||||
await remote.dispose();
|
||||
});
|
||||
|
||||
test("disposing an origin during routed handshake settles acquisition and closes only its pending session", async () => {
|
||||
const http = createServer();
|
||||
const ws = new WebSocketServer({ server: http });
|
||||
let connections = 0;
|
||||
let routeHello = false;
|
||||
let routeClosed = false;
|
||||
ws.on("connection", (socket) => {
|
||||
const monitor = ++connections === 1;
|
||||
socket.on("close", () => {
|
||||
if (!monitor) routeClosed = true;
|
||||
});
|
||||
socket.on("message", (data) => {
|
||||
const frame = JSON.parse(data.toString());
|
||||
if (frame.type !== "hello") return;
|
||||
if (monitor)
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: "srv_pending",
|
||||
hostname: "pending",
|
||||
version: "0.11.0",
|
||||
features: { connectedHosts: true, ownedSubscriptions: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
else routeHello = true;
|
||||
});
|
||||
});
|
||||
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 pending endpoint port");
|
||||
const a = await daemon();
|
||||
const { paseo } = await api(a);
|
||||
await paseo.hosts.add({ connection: `127.0.0.1:${address.port}` });
|
||||
await online(paseo);
|
||||
const pending = paseo.hosts.client("srv_pending");
|
||||
const result = pending.catch((error: unknown) => error);
|
||||
await expect.poll(() => routeHello).toBe(true);
|
||||
await paseo.dispose();
|
||||
expect(await result).toBeInstanceOf(Error);
|
||||
await expect.poll(() => routeClosed).toBe(true);
|
||||
expect(ws.clients.size).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,531 @@
|
||||
export { ConnectedHostSession } from "./session.js";
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createHostClient,
|
||||
createHostTransportFactory,
|
||||
parseHostTarget,
|
||||
HostTransportBudget,
|
||||
} from "@getpaseo/client/node";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
HostSummarySchema,
|
||||
HostAddSchema,
|
||||
HostUpdateSchema,
|
||||
type HostDirectory,
|
||||
type HostSummary,
|
||||
type HostAdd,
|
||||
type HostUpdate,
|
||||
} from "@getpaseo/protocol/hosts";
|
||||
import type { DaemonPermission, WSHelloMessage } from "@getpaseo/protocol/messages";
|
||||
|
||||
export class HostError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly serverId?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
const SavedSchema = HostSummarySchema.extend({ connection: z.string() });
|
||||
type Saved = z.infer<typeof SavedSchema>;
|
||||
export interface HostActor {
|
||||
sourceId: string;
|
||||
manageSources: boolean;
|
||||
}
|
||||
interface Live {
|
||||
stop(): void;
|
||||
}
|
||||
interface Route {
|
||||
registrationId: string;
|
||||
close(code?: string, message?: string): void;
|
||||
}
|
||||
|
||||
/** Daemon-owned configuration, identity pinning, retry and routed session lifetime. */
|
||||
export class ConnectedHosts {
|
||||
private readonly budget = new HostTransportBudget();
|
||||
private rows: Saved[] = [];
|
||||
private readonly live = new Map<string, Live>();
|
||||
private readonly routes = new Set<Route>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
private readonly ready: Promise<void>;
|
||||
private closed = false;
|
||||
constructor(
|
||||
private readonly home: string,
|
||||
private readonly self: HostDirectory["self"],
|
||||
) {
|
||||
this.ready = this.load();
|
||||
void this.ready.catch(() => {});
|
||||
}
|
||||
private async load(): Promise<void> {
|
||||
try {
|
||||
this.rows = z
|
||||
.array(SavedSchema)
|
||||
.parse(JSON.parse(await readFile(join(this.home, "hosts.json"), "utf8")));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
|
||||
throw new Error("Cannot read saved connected hosts", { cause: error });
|
||||
}
|
||||
if (this.closed) return;
|
||||
for (const row of this.rows) {
|
||||
row.status = row.enabled ? "connecting" : "disabled";
|
||||
row.nextRetryAt = null;
|
||||
this.start(row);
|
||||
}
|
||||
}
|
||||
async list(): Promise<HostDirectory> {
|
||||
await this.ready;
|
||||
return {
|
||||
self: { ...this.self },
|
||||
hosts: this.rows.map(({ connection: _secret, ...row }) => structuredClone(row)),
|
||||
};
|
||||
}
|
||||
observe(changed: () => void): () => void {
|
||||
this.listeners.add(changed);
|
||||
return () => this.listeners.delete(changed);
|
||||
}
|
||||
private changed(): void {
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
private async persist(rows: Saved[]): Promise<void> {
|
||||
await mkdir(this.home, { recursive: true });
|
||||
const temp = join(this.home, `.hosts-${randomUUID()}.tmp`);
|
||||
try {
|
||||
await writeFile(temp, JSON.stringify(rows), { mode: 0o600 });
|
||||
await rename(temp, join(this.home, "hosts.json"));
|
||||
} finally {
|
||||
await rm(temp, { force: true });
|
||||
}
|
||||
}
|
||||
private mutate<T>(run: () => Promise<T>): Promise<T> {
|
||||
const result = this.queue.then(async () => {
|
||||
await this.ready;
|
||||
if (this.closed) throw new HostError("host_offline", "Daemon is stopping");
|
||||
return run();
|
||||
});
|
||||
this.queue = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
private summary(row: Saved): HostSummary {
|
||||
const { connection: _secret, ...summary } = row;
|
||||
return structuredClone(summary);
|
||||
}
|
||||
add(input: HostAdd, actor: HostActor): Promise<HostSummary> {
|
||||
return this.mutate(async () => {
|
||||
input = HostAddSchema.parse(input);
|
||||
const target = parseHostTarget(input.connection);
|
||||
if (this.rows.length >= 128)
|
||||
throw new HostError("resource_limit", "Remove a host before adding another (limit 128)");
|
||||
const source = { id: actor.sourceId, key: input.sourceKey ?? randomUUID() };
|
||||
const existing = this.rows.find(
|
||||
(row) => row.source.id === source.id && row.source.key === source.key,
|
||||
);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.connection === input.connection &&
|
||||
existing.label === (input.label ?? null) &&
|
||||
JSON.stringify(Object.entries(existing.metadata).sort()) ===
|
||||
JSON.stringify(Object.entries(input.metadata ?? {}).sort())
|
||||
)
|
||||
return this.summary(existing);
|
||||
throw new HostError(
|
||||
"source_conflict",
|
||||
"This source key already exists. Update its registration explicitly.",
|
||||
);
|
||||
}
|
||||
const row: Saved = {
|
||||
registrationId: `hst_${randomUUID()}`,
|
||||
serverId: null,
|
||||
connection: input.connection,
|
||||
label: input.label ?? null,
|
||||
metadata: input.metadata ?? {},
|
||||
source,
|
||||
enabled: true,
|
||||
status: "connecting",
|
||||
targetDescription: target.description,
|
||||
lastOnlineAt: null,
|
||||
nextRetryAt: null,
|
||||
error: null,
|
||||
};
|
||||
await this.persist([...this.rows, row]);
|
||||
this.rows.push(row);
|
||||
this.start(row);
|
||||
this.changed();
|
||||
return this.summary(row);
|
||||
});
|
||||
}
|
||||
update(id: string, patch: HostUpdate, actor: HostActor): Promise<HostSummary> {
|
||||
return this.mutate(async () => {
|
||||
patch = HostUpdateSchema.parse(patch);
|
||||
const previous = this.require(id);
|
||||
if (
|
||||
(patch.connection !== undefined || patch.metadata !== undefined || patch.replaceIdentity) &&
|
||||
actor.sourceId !== previous.source.id &&
|
||||
!actor.manageSources
|
||||
)
|
||||
throw new HostError(
|
||||
"permission_denied",
|
||||
"Only the source or access manager can replace its connection and metadata",
|
||||
);
|
||||
if (patch.replaceIdentity && patch.connection === undefined)
|
||||
throw new HostError(
|
||||
"invalid_connection",
|
||||
"Identity replacement requires a replacement connection",
|
||||
);
|
||||
const row: Saved = {
|
||||
...previous,
|
||||
...(patch.label !== undefined ? { label: patch.label } : {}),
|
||||
...(patch.metadata !== undefined ? { metadata: patch.metadata } : {}),
|
||||
...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
|
||||
};
|
||||
if (patch.connection !== undefined) {
|
||||
row.connection = patch.connection;
|
||||
row.targetDescription = parseHostTarget(patch.connection).description;
|
||||
}
|
||||
if (patch.replaceIdentity) row.serverId = null;
|
||||
const restart =
|
||||
patch.connection !== undefined || patch.enabled !== undefined || patch.replaceIdentity;
|
||||
if (restart) {
|
||||
row.status = row.enabled ? "connecting" : "disabled";
|
||||
row.error = null;
|
||||
row.nextRetryAt = null;
|
||||
}
|
||||
const rows = this.rows.map((entry) => (entry === previous ? row : entry));
|
||||
await this.persist(rows);
|
||||
if (restart) this.stop(id, row.enabled ? "host_offline" : "host_disabled");
|
||||
Object.assign(previous, row);
|
||||
this.rows = rows.map((entry) => (entry === row ? previous : entry));
|
||||
if (restart) this.start(previous);
|
||||
this.changed();
|
||||
return this.summary(previous);
|
||||
});
|
||||
}
|
||||
remove(id: string, actor: HostActor): Promise<void> {
|
||||
return this.mutate(async () => {
|
||||
const row = this.require(id);
|
||||
if (actor.sourceId !== row.source.id && !actor.manageSources)
|
||||
throw new HostError(
|
||||
"permission_denied",
|
||||
"Only the source or access manager can remove this host. Disable it to stop access.",
|
||||
);
|
||||
const rows = this.rows.filter((entry) => entry !== row);
|
||||
await this.persist(rows);
|
||||
this.stop(id, "host_unknown");
|
||||
this.rows = rows;
|
||||
this.changed();
|
||||
});
|
||||
}
|
||||
private require(id: string): Saved {
|
||||
const row = this.rows.find((entry) => entry.registrationId === id);
|
||||
if (!row) throw new HostError("host_unknown", "Unknown host registration");
|
||||
return row;
|
||||
}
|
||||
private stop(id: string, code = "host_offline"): void {
|
||||
this.live.get(id)?.stop();
|
||||
this.live.delete(id);
|
||||
for (const route of this.routes)
|
||||
if (route.registrationId === id)
|
||||
route.close(code, "Host access was disabled, replaced or removed");
|
||||
}
|
||||
private start(row: Saved): void {
|
||||
if (!row.enabled || this.closed) return;
|
||||
let client: DaemonClient | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let stopped = false;
|
||||
let connecting = false;
|
||||
const live: Live = {
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
void client?.close();
|
||||
},
|
||||
};
|
||||
this.live.set(row.registrationId, live);
|
||||
const retry = () => {
|
||||
if (stopped || connecting || timer || row.status === "error") return;
|
||||
row.status = "offline";
|
||||
row.error = {
|
||||
code: "host_offline",
|
||||
message: "Cannot reach host. Check the connection; retrying automatically.",
|
||||
};
|
||||
row.nextRetryAt = new Date(Date.now() + 5000).toISOString();
|
||||
this.changed();
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
void attempt();
|
||||
}, 5000);
|
||||
timer.unref();
|
||||
};
|
||||
const attempt = async () => {
|
||||
if (stopped) return;
|
||||
await client?.close();
|
||||
client = createHostClient(row.connection, {
|
||||
clientId: `host-monitor-${this.self.serverId}-${row.registrationId}`,
|
||||
reconnect: { enabled: false },
|
||||
connectTimeoutMs: 10000,
|
||||
});
|
||||
client.subscribeConnectionStatus((state) => {
|
||||
if (!stopped && state.status === "disconnected") {
|
||||
for (const route of this.routes)
|
||||
if (route.registrationId === row.registrationId) route.close();
|
||||
retry();
|
||||
}
|
||||
});
|
||||
connecting = true;
|
||||
row.status = "connecting";
|
||||
row.nextRetryAt = null;
|
||||
this.changed();
|
||||
try {
|
||||
await client.connect();
|
||||
if (stopped) return;
|
||||
const info = client.getLastServerInfoMessage();
|
||||
if (!info) throw new HostError("host_offline", "No daemon identity received");
|
||||
if (row.serverId && info.serverId !== row.serverId)
|
||||
throw new HostError(
|
||||
"server_id_mismatch",
|
||||
"Daemon identity changed. Verify the destination and explicitly replace its identity.",
|
||||
);
|
||||
if (!row.serverId)
|
||||
await this.mutate(async () => {
|
||||
if (stopped) return;
|
||||
const discovered = { ...row, serverId: info.serverId };
|
||||
await this.persist(this.rows.map((entry) => (entry === row ? discovered : entry)));
|
||||
row.serverId = info.serverId;
|
||||
});
|
||||
if (info.serverId === this.self.serverId)
|
||||
throw new HostError(
|
||||
"self_host",
|
||||
"This connection reaches this daemon. Remove it or replace the connection.",
|
||||
);
|
||||
if (this.rows.find((entry) => entry.serverId === info.serverId) !== row)
|
||||
throw new HostError(
|
||||
"duplicate_host",
|
||||
"Another registration already owns this daemon. Remove the duplicate registration.",
|
||||
);
|
||||
if (info.features?.connectedHosts !== true)
|
||||
throw new HostError(
|
||||
"unsupported_capability",
|
||||
"Update the destination daemon to use connected hosts.",
|
||||
);
|
||||
// Pin and persist under the same writer as management to prevent discovery races.
|
||||
await this.mutate(async () => {
|
||||
if (stopped) return;
|
||||
if (this.rows.find((entry) => entry.serverId === info.serverId) !== row)
|
||||
throw new HostError("duplicate_host", "Another registration already owns this daemon");
|
||||
const next = {
|
||||
...row,
|
||||
serverId: info.serverId,
|
||||
status: "online" as const,
|
||||
lastOnlineAt: new Date().toISOString(),
|
||||
error: null,
|
||||
nextRetryAt: null,
|
||||
};
|
||||
await this.persist(this.rows.map((entry) => (entry === row ? next : entry)));
|
||||
Object.assign(row, next);
|
||||
this.changed();
|
||||
});
|
||||
} catch (error) {
|
||||
if (stopped) return;
|
||||
const failure = connectionFailure(error, client.authFailureReason !== null);
|
||||
if (failure) {
|
||||
row.status = "error";
|
||||
row.error = failure;
|
||||
row.nextRetryAt = null;
|
||||
this.changed();
|
||||
}
|
||||
await client.close();
|
||||
} finally {
|
||||
connecting = false;
|
||||
if (!stopped && !client.isConnected) retry();
|
||||
}
|
||||
};
|
||||
void attempt();
|
||||
}
|
||||
async openRoute(input: {
|
||||
serverId: string;
|
||||
scopeId: string;
|
||||
sourceId: string;
|
||||
originSessionId: string;
|
||||
permissions: readonly DaemonPermission[];
|
||||
signal: AbortSignal;
|
||||
frame(data: string, binary: boolean): void;
|
||||
ended(code: string, message: string): void;
|
||||
}): Promise<{ send(data: string, binary: boolean): void; close(): void }> {
|
||||
await this.ready;
|
||||
const row = this.rows.find((entry) => entry.serverId === input.serverId);
|
||||
if (!row) throw new HostError("host_unknown", `Unknown host ${input.serverId}`, input.serverId);
|
||||
if (!row.enabled)
|
||||
throw new HostError(
|
||||
"host_disabled",
|
||||
`Enable host ${input.serverId} before using it`,
|
||||
input.serverId,
|
||||
);
|
||||
if (row.status !== "online")
|
||||
throw new HostError(
|
||||
row.error?.code ?? "host_offline",
|
||||
row.error?.message ?? `Host ${input.serverId} is offline`,
|
||||
input.serverId,
|
||||
);
|
||||
if (this.routes.size >= 128) throw new HostError("resource_limit", "Too many routed sessions");
|
||||
const transport = createHostTransportFactory(
|
||||
row.connection,
|
||||
this.budget,
|
||||
)({ url: "ws://host-route/ws" });
|
||||
let closed = false;
|
||||
let verified = false;
|
||||
let helloSent = false;
|
||||
const timer = setTimeout(() => route.close("host_offline", "Host handshake timed out"), 15000);
|
||||
timer.unref();
|
||||
const route: Route = {
|
||||
registrationId: row.registrationId,
|
||||
close: (code = "host_offline", message = `Host ${input.serverId} disconnected`) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
input.signal.removeEventListener("abort", abort);
|
||||
clearTimeout(timer);
|
||||
this.routes.delete(route);
|
||||
try {
|
||||
transport.close();
|
||||
} finally {
|
||||
failed(new HostError(code, message, input.serverId));
|
||||
input.ended(code, message);
|
||||
}
|
||||
},
|
||||
};
|
||||
this.routes.add(route);
|
||||
const abort = () =>
|
||||
route.close("host_offline", "Origin session closed or its authority changed");
|
||||
input.signal.addEventListener("abort", abort, { once: true });
|
||||
let opened!: () => void;
|
||||
let failed!: (error: unknown) => void;
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
opened = resolve;
|
||||
failed = reject;
|
||||
});
|
||||
if (input.signal.aborted) {
|
||||
route.close();
|
||||
}
|
||||
transport.onOpen(opened);
|
||||
transport.onError((error) => {
|
||||
const authenticationFailed =
|
||||
error instanceof Error && "code" in error && error.code === "authentication_failed";
|
||||
const code = authenticationFailed ? "authentication_failed" : "host_offline";
|
||||
const message = authenticationFailed
|
||||
? "Host authentication failed. Check the SSH configuration and daemon credentials."
|
||||
: "Host transport failed";
|
||||
failed(new HostError(code, message, input.serverId));
|
||||
route.close(code, message);
|
||||
});
|
||||
transport.onClose(() => {
|
||||
failed(new HostError("host_offline", "Host transport closed"));
|
||||
route.close();
|
||||
});
|
||||
transport.onMessage((data, binary) => {
|
||||
if (closed) return;
|
||||
if (!verified) {
|
||||
if (binary) {
|
||||
route.close();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const envelope = JSON.parse(String(data));
|
||||
if (envelope.type === "hello.rejected") {
|
||||
route.close("authentication_failed", "Replace the host credentials");
|
||||
return;
|
||||
}
|
||||
const info =
|
||||
envelope.type === "session" && envelope.message.type === "status"
|
||||
? envelope.message.payload
|
||||
: null;
|
||||
if (info?.status !== "server_info") return;
|
||||
if (info.serverId !== row.serverId) {
|
||||
route.close(
|
||||
"server_id_mismatch",
|
||||
"Host identity changed; explicit replacement required",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (info.features?.connectedHosts !== true) {
|
||||
route.close("unsupported_capability", "Update the destination daemon");
|
||||
return;
|
||||
}
|
||||
verified = true;
|
||||
clearTimeout(timer);
|
||||
} catch {
|
||||
route.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
input.frame(
|
||||
binary ? Buffer.from(data as Uint8Array).toString("base64") : String(data),
|
||||
binary,
|
||||
);
|
||||
});
|
||||
await ready;
|
||||
if (closed) throw new HostError("host_offline", "Host closed during connection");
|
||||
return {
|
||||
close: () => route.close(),
|
||||
send: (data, binary) => {
|
||||
if (closed) throw new HostError("host_offline", "Host is offline");
|
||||
if (!helloSent) {
|
||||
if (binary) throw new HostError("invalid_message", "Expected hello");
|
||||
const hello = JSON.parse(data) as WSHelloMessage;
|
||||
if (hello.type !== "hello") throw new HostError("invalid_message", "Expected hello");
|
||||
const target = parseHostTarget(row.connection);
|
||||
const clientId = `route-${createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify([
|
||||
this.self.serverId,
|
||||
input.sourceId,
|
||||
input.originSessionId,
|
||||
input.scopeId,
|
||||
]),
|
||||
)
|
||||
.digest("hex")}`;
|
||||
transport.send(
|
||||
JSON.stringify({
|
||||
...hello,
|
||||
clientId,
|
||||
routed: true,
|
||||
producer: { serverId: this.self.serverId, sourceId: input.sourceId },
|
||||
sessionPermissions: input.permissions,
|
||||
auth: target.password ? { kind: "password", password: target.password } : undefined,
|
||||
}),
|
||||
);
|
||||
helloSent = true;
|
||||
return;
|
||||
}
|
||||
if (!verified) throw new HostError("host_offline", "Host identity has not been verified");
|
||||
try {
|
||||
transport.send(binary ? Buffer.from(data, "base64") : data);
|
||||
} catch {
|
||||
route.close("resource_limit", "Host buffer limit reached or transport unavailable");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
await this.ready;
|
||||
await this.queue;
|
||||
for (const id of this.live.keys()) this.stop(id);
|
||||
for (const route of this.routes) route.close();
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] {
|
||||
if (error instanceof HostError) return { code: error.code, message: error.message };
|
||||
if (error instanceof Error && "code" in error && error.code === "authentication_failed")
|
||||
return { code: "authentication_failed", message: error.message };
|
||||
if (authenticationFailed)
|
||||
return {
|
||||
code: "authentication_failed",
|
||||
message: "Host rejected the credentials. Replace the connection credentials.",
|
||||
};
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type {
|
||||
SessionInboundMessage,
|
||||
SessionOutboundMessage,
|
||||
DaemonPermission,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { ConnectedHosts, HostError, type HostActor } from "./index.js";
|
||||
import type { SessionDelivery } from "../session/owned-subscriptions/index.js";
|
||||
|
||||
type HostRequest<T extends SessionInboundMessage["type"]> = Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: T }
|
||||
>;
|
||||
interface SessionRoute {
|
||||
scopeId: string;
|
||||
source: object;
|
||||
send(data: string, binary: boolean): void;
|
||||
release(): Promise<void>;
|
||||
fail(code: string, message: string): void;
|
||||
}
|
||||
|
||||
/** Adapts host capabilities to source-owned session delivery. */
|
||||
export class ConnectedHostSession {
|
||||
private readonly routes = new Map<string, SessionRoute>();
|
||||
constructor(
|
||||
private readonly hosts: ConnectedHosts,
|
||||
private readonly delivery: SessionDelivery,
|
||||
private readonly actor: HostActor,
|
||||
private readonly permissions: () => DaemonPermission[],
|
||||
private readonly emit: (message: SessionOutboundMessage) => void,
|
||||
private readonly routed: () => boolean,
|
||||
private readonly originSessionId: string,
|
||||
private readonly bufferedAmount: (source: object) => number | null,
|
||||
) {}
|
||||
setSource(sourceId: string): void {
|
||||
this.actor.sourceId = sourceId;
|
||||
}
|
||||
dispatch(message: SessionInboundMessage): Promise<void> | undefined {
|
||||
if (!message.type.startsWith("hosts.")) return;
|
||||
return this.handle(message);
|
||||
}
|
||||
revoke(): void {
|
||||
for (const route of this.routes.values())
|
||||
route.fail(
|
||||
"permission_denied",
|
||||
"Caller authority changed. Reconnecting with current grants.",
|
||||
);
|
||||
void this.delivery.releaseFamily("host-route");
|
||||
}
|
||||
private async handle(message: SessionInboundMessage): Promise<void> {
|
||||
try {
|
||||
if (this.routed() && message.type.startsWith("hosts.route."))
|
||||
throw new HostError(
|
||||
"unsupported_capability",
|
||||
"Connected hosts support one hop. Connect to the managing daemon to select a host.",
|
||||
);
|
||||
await this.execute(message);
|
||||
} catch (error) {
|
||||
this.fail(message, error);
|
||||
}
|
||||
}
|
||||
private fail(message: SessionInboundMessage, error: unknown): void {
|
||||
let failure = new HostError(
|
||||
"host_error",
|
||||
"Host operation failed. Check the connection and retry.",
|
||||
);
|
||||
if (error instanceof HostError) failure = error;
|
||||
else if (error instanceof Error && "code" in error && error.code === "invalid_connection")
|
||||
failure = new HostError("invalid_connection", error.message);
|
||||
if (message.type === "hosts.route.frame.request") {
|
||||
const route = this.routes.get(message.routeId);
|
||||
if (route && route.source === this.delivery.currentSource)
|
||||
route.fail(failure.code, failure.message);
|
||||
return;
|
||||
}
|
||||
if (!("requestId" in message) || !message.requestId) return;
|
||||
this.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: message.requestId,
|
||||
requestType: message.type,
|
||||
code: failure.code,
|
||||
error: failure.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
private async execute(message: SessionInboundMessage): Promise<void> {
|
||||
const actor = { ...this.actor, manageSources: this.permissions().includes("access.manage") };
|
||||
switch (message.type) {
|
||||
case "hosts.list.request":
|
||||
return this.list(message);
|
||||
case "hosts.add.request":
|
||||
this.emit({
|
||||
type: "hosts.add.response",
|
||||
payload: {
|
||||
requestId: message.requestId,
|
||||
host: await this.hosts.add(message.input, actor),
|
||||
},
|
||||
});
|
||||
return;
|
||||
case "hosts.update.request":
|
||||
this.emit({
|
||||
type: "hosts.update.response",
|
||||
payload: {
|
||||
requestId: message.requestId,
|
||||
host: await this.hosts.update(message.registrationId, message.patch, actor),
|
||||
},
|
||||
});
|
||||
return;
|
||||
case "hosts.remove.request":
|
||||
await this.hosts.remove(message.registrationId, actor);
|
||||
this.emit({ type: "hosts.remove.response", payload: { requestId: message.requestId } });
|
||||
return;
|
||||
case "hosts.route.open.request":
|
||||
return this.open(message);
|
||||
case "hosts.route.frame.request": {
|
||||
const route = this.routes.get(message.routeId);
|
||||
if (!route || route.source !== this.delivery.currentSource)
|
||||
throw new HostError("host_unknown", "Unknown routed session");
|
||||
route.send(message.data, message.binary);
|
||||
return;
|
||||
}
|
||||
case "hosts.route.close.request": {
|
||||
const route = message.routeId
|
||||
? this.routes.get(message.routeId)
|
||||
: [...this.routes.values()].find(
|
||||
(entry) =>
|
||||
entry.scopeId === message.scopeId && entry.source === this.delivery.currentSource,
|
||||
);
|
||||
if (route && route.source === this.delivery.currentSource) await route.release();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
private async list(message: HostRequest<"hosts.list.request">): Promise<void> {
|
||||
if (!message.observe) {
|
||||
this.emit({
|
||||
type: "hosts.list.response",
|
||||
payload: { requestId: message.requestId, directory: await this.hosts.list() },
|
||||
});
|
||||
return;
|
||||
}
|
||||
let stop = () => {};
|
||||
const owner = this.delivery.begin("hosts", undefined, () => stop());
|
||||
try {
|
||||
stop = this.hosts.observe(() => {
|
||||
void this.hosts
|
||||
.list()
|
||||
.then((directory) => {
|
||||
owner.emit({ type: "hosts.changed", payload: { subscriptionId: owner.id, directory } });
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => owner.release());
|
||||
});
|
||||
this.emit({
|
||||
type: "hosts.list.response",
|
||||
payload: {
|
||||
requestId: message.requestId,
|
||||
subscriptionId: owner.id,
|
||||
directory: await this.hosts.list(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await owner.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
private async open(message: HostRequest<"hosts.route.open.request">): Promise<void> {
|
||||
if (this.delivery.subscriptionIds(this.delivery.currentSource!, "host-route").length >= 32)
|
||||
throw new HostError("resource_limit", "Too many routed sessions for this caller");
|
||||
let close = () => {};
|
||||
const owner = this.delivery.begin("host-route", undefined, () => {
|
||||
this.routes.delete(owner.id);
|
||||
close();
|
||||
});
|
||||
const fail = (code: string, text: string) => {
|
||||
owner.emit({
|
||||
type: "hosts.route.closed",
|
||||
payload: { subscriptionId: owner.id, code, message: text },
|
||||
});
|
||||
void owner.release();
|
||||
};
|
||||
this.routes.set(owner.id, {
|
||||
scopeId: message.scopeId,
|
||||
source: owner.source,
|
||||
send: () => {
|
||||
throw new HostError("host_offline", "Host is connecting");
|
||||
},
|
||||
release: owner.release,
|
||||
fail,
|
||||
});
|
||||
try {
|
||||
const route = await this.hosts.openRoute({
|
||||
serverId: message.serverId,
|
||||
scopeId: message.scopeId,
|
||||
sourceId: this.actor.sourceId,
|
||||
originSessionId: this.originSessionId,
|
||||
permissions: this.permissions(),
|
||||
signal: owner.signal,
|
||||
frame: (data, binary) => {
|
||||
const size = Buffer.byteLength(data) + 512;
|
||||
const buffered = this.bufferedAmount(owner.source);
|
||||
if (buffered === null || buffered + size > 32 * 1024 * 1024) {
|
||||
fail("resource_limit", "The receiving client cannot keep up with host output");
|
||||
return;
|
||||
}
|
||||
owner.emit({
|
||||
type: "hosts.route.frame",
|
||||
payload: { subscriptionId: owner.id, data, binary },
|
||||
});
|
||||
},
|
||||
ended: fail,
|
||||
});
|
||||
close = route.close;
|
||||
if (owner.signal.aborted) {
|
||||
route.close();
|
||||
return;
|
||||
}
|
||||
this.routes.set(owner.id, {
|
||||
scopeId: message.scopeId,
|
||||
source: owner.source,
|
||||
send: route.send,
|
||||
release: owner.release,
|
||||
fail,
|
||||
});
|
||||
this.emit({
|
||||
type: "hosts.route.open.response",
|
||||
payload: { requestId: message.requestId, routeId: owner.id },
|
||||
});
|
||||
} catch (error) {
|
||||
await owner.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AgentDirectory } from "./agent/directory.js";
|
||||
import { ConnectedHostSession } from "./connected-hosts/index.js";
|
||||
import type { ConnectedHosts } from "./connected-hosts/index.js";
|
||||
import { searchTimeline } from "./agent/chat-search/index.js";
|
||||
import type { BrowserToolsBroker } from "./browser-tools/broker.js";
|
||||
import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities";
|
||||
@@ -442,6 +444,9 @@ const nodeSessionFileSystem: SessionFileSystem = {
|
||||
type AgentMcpTransportFactory = () => Promise<unknown>;
|
||||
|
||||
export interface SessionOptions {
|
||||
connectedHosts?: ConnectedHosts;
|
||||
sourceId?: string;
|
||||
routed?: boolean;
|
||||
browserToolsBroker?: BrowserToolsBroker | null;
|
||||
clientId: string;
|
||||
permissions: readonly DaemonPermission[];
|
||||
@@ -452,6 +457,7 @@ export interface SessionOptions {
|
||||
onBinaryMessage?: (frame: Uint8Array) => void;
|
||||
onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise<void>;
|
||||
getTransportBufferedAmount?: (source?: object) => number | null;
|
||||
getConnectedHostBufferedAmount?: () => number | null;
|
||||
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
|
||||
onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
publishScriptStatusUpdate?: (message: ScriptStatusUpdateMessage) => void;
|
||||
@@ -674,6 +680,22 @@ interface ClientActivity {
|
||||
}
|
||||
|
||||
export class Session {
|
||||
private readonly connectedHostSession: ConnectedHostSession | null;
|
||||
private routed = false;
|
||||
isRouted(): boolean {
|
||||
return this.routed;
|
||||
}
|
||||
getPrincipalId(): string {
|
||||
return this.hostPrincipalId;
|
||||
}
|
||||
private hostPrincipalId: string;
|
||||
setHostSource(sourceId: string): void {
|
||||
this.hostPrincipalId = sourceId;
|
||||
this.connectedHostSession?.setSource(sourceId);
|
||||
}
|
||||
setRouted(routed: boolean): void {
|
||||
this.routed = routed;
|
||||
}
|
||||
readonly delivery = new SessionDelivery(
|
||||
(source, message) => {
|
||||
if (!this.authorization.allowsOutbound(message)) return;
|
||||
@@ -864,9 +886,12 @@ export class Session {
|
||||
this.browserToolsBroker = options.browserToolsBroker;
|
||||
this.clientId = clientId;
|
||||
this.authorization = new SessionAuthorization(permissions);
|
||||
|
||||
this.appVersion = appVersion ?? null;
|
||||
this.clientCapabilities = parseClientCapabilities(clientCapabilities);
|
||||
this.sessionId = uuidv4();
|
||||
this.hostPrincipalId = hostSource(options);
|
||||
this.connectedHostSession = createHostSession(options, this);
|
||||
this.onMessage = onMessage;
|
||||
this.onMessageToSource = onMessageToSource ?? null;
|
||||
this.onBinaryMessage = onBinaryMessage ?? null;
|
||||
@@ -2173,7 +2198,7 @@ export class Session {
|
||||
requestId,
|
||||
requestType: msg.type,
|
||||
error: `Session is not authorized for ${msg.type}`,
|
||||
code: "access_denied",
|
||||
code: msg.type.startsWith("hosts.") ? "permission_denied" : "access_denied",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2220,6 +2245,7 @@ export class Session {
|
||||
|
||||
public setPermissions(permissions: readonly DaemonPermission[]): void {
|
||||
this.authorization.replacePermissions(permissions);
|
||||
this.connectedHostSession?.revoke();
|
||||
if (!this.authorization.allowsPermission("workspace.write")) {
|
||||
void this.delivery
|
||||
.releaseFamily("browser-host")
|
||||
@@ -2374,11 +2400,10 @@ export class Session {
|
||||
|
||||
private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise<void> {
|
||||
const promise =
|
||||
this.connectedHostSession?.dispatch(msg) ??
|
||||
this.dispatchSubscriptionMessage(msg, source) ??
|
||||
this.dispatchVoiceAndControlMessage(msg) ??
|
||||
this.dispatchAgentRewindMessage(msg, source) ??
|
||||
this.dispatchAgentRelationshipMessage(msg) ??
|
||||
this.dispatchAgentTimelineMessage(msg, source) ??
|
||||
this.dispatchAgentObservationMessage(msg, source) ??
|
||||
this.dispatchHubExecutionMessage(msg) ??
|
||||
this.dispatchCreationMessage(msg, source) ??
|
||||
this.dispatchAgentLifecycleMessage(msg) ??
|
||||
@@ -2396,6 +2421,17 @@ export class Session {
|
||||
if (promise) await promise;
|
||||
}
|
||||
|
||||
private dispatchAgentObservationMessage(
|
||||
msg: SessionInboundMessage,
|
||||
source?: object,
|
||||
): Promise<void> | undefined {
|
||||
return (
|
||||
this.dispatchAgentRewindMessage(msg, source) ??
|
||||
this.dispatchAgentRelationshipMessage(msg) ??
|
||||
this.dispatchAgentTimelineMessage(msg, source)
|
||||
);
|
||||
}
|
||||
|
||||
private dispatchWorkspaceLifecycleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
return (
|
||||
this.dispatchWorkspaceStateMessage(msg) ??
|
||||
@@ -8765,3 +8801,21 @@ function legacyWantsEvent(
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function createHostSession(options: SessionOptions, session: Session): ConnectedHostSession | null {
|
||||
if (!options.connectedHosts) return null;
|
||||
return new ConnectedHostSession(
|
||||
options.connectedHosts,
|
||||
session.delivery,
|
||||
{ sourceId: options.sourceId ?? "owner", manageSources: false },
|
||||
() => session.getPermissions(),
|
||||
(message) => session.publish(message),
|
||||
() => session.isRouted(),
|
||||
session.getSessionId(),
|
||||
() => options.getConnectedHostBufferedAmount?.() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
function hostSource(options: SessionOptions): string {
|
||||
return options.sourceId ?? "owner";
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ConnectedHosts } from "./connected-hosts/index.js";
|
||||
import { stat } from "node:fs/promises";
|
||||
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||
import { CreationService } from "./creation/index.js";
|
||||
@@ -437,6 +438,7 @@ export interface WebSocketLike {
|
||||
}
|
||||
|
||||
interface SessionConnectionBase {
|
||||
permissionCeiling?: readonly DaemonPermission[];
|
||||
session: Session;
|
||||
principalId: string;
|
||||
sessionKey: string;
|
||||
@@ -460,6 +462,7 @@ interface PluginSessionConnection extends SessionConnectionBase {
|
||||
type SessionConnection = ReconnectableSessionConnection | PluginSessionConnection;
|
||||
|
||||
interface SocketSessionOptions {
|
||||
sourceId?: string;
|
||||
clientId: string;
|
||||
appVersion: string | null;
|
||||
clientCapabilities: Record<string, unknown> | null;
|
||||
@@ -550,6 +553,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly workspaceAutoName: WorkspaceAutoName;
|
||||
private readonly downloadTokenStore: DownloadTokenStore;
|
||||
private readonly paseoHome: string;
|
||||
private readonly connectedHosts: ConnectedHosts;
|
||||
private readonly passwordHash: string | undefined;
|
||||
private readonly credentialSource: DaemonAuthConfig | undefined;
|
||||
private readonly worktreesRoot: string | undefined;
|
||||
@@ -701,6 +705,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.workspaceAutoName = workspaceAutoName;
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.paseoHome = paseoHome;
|
||||
this.connectedHosts = new ConnectedHosts(paseoHome, { serverId, label: getHostName() });
|
||||
this.passwordHash = auth?.password;
|
||||
this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot;
|
||||
this.daemonConfigStore = daemonConfigStore;
|
||||
@@ -1054,7 +1059,11 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
for (const connection of new Set(this.externalSessionsByKey.values())) {
|
||||
if (connection.principalId === principalId) {
|
||||
connection.session.setPermissions(permissions);
|
||||
connection.session.setPermissions(
|
||||
connection.permissionCeiling
|
||||
? permissions.filter((permission) => connection.permissionCeiling!.includes(permission))
|
||||
: permissions,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1071,6 +1080,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
public async close(): Promise<void> {
|
||||
this.prepareForShutdown();
|
||||
await this.connectedHosts.close();
|
||||
this.unsubscribeSpeechReadiness?.();
|
||||
this.unsubscribeSpeechReadiness = null;
|
||||
this.unsubscribeDaemonConfigChange?.();
|
||||
@@ -1394,6 +1404,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
appVersion,
|
||||
clientCapabilities,
|
||||
permissions: admission.permissions,
|
||||
sourceId:
|
||||
lifecycle.kind === "ephemeral-plugin"
|
||||
? `plugin:${lifecycle.pluginId}`
|
||||
: admission.principalId,
|
||||
connectionLogger,
|
||||
onMessage: (msg) => {
|
||||
if (!connection) {
|
||||
@@ -1463,6 +1477,16 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
private createSocketSession(options: SocketSessionOptions): Session {
|
||||
return new Session({
|
||||
connectedHosts: this.connectedHosts,
|
||||
getConnectedHostBufferedAmount: () => {
|
||||
let total = 0;
|
||||
for (const socket of this.sessions.keys()) {
|
||||
if (typeof socket.bufferedAmount !== "number") return null;
|
||||
total += socket.bufferedAmount;
|
||||
}
|
||||
return total;
|
||||
},
|
||||
sourceId: options.sourceId,
|
||||
browserToolsBroker: this.browserToolsBroker,
|
||||
clientId: options.clientId,
|
||||
appVersion: options.appVersion,
|
||||
@@ -1616,8 +1640,16 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
this.clearPendingConnection(ws);
|
||||
const admitted = pending.admission;
|
||||
let admitted = pending.admission;
|
||||
if (!admitted) throw new Error("Admitted hello has no session admission");
|
||||
if (message.sessionPermissions)
|
||||
admitted = {
|
||||
...admitted,
|
||||
permissions: admitted.permissions.filter((permission) =>
|
||||
message.sessionPermissions!.includes(permission),
|
||||
),
|
||||
};
|
||||
pending.admission = admitted;
|
||||
pending.identity.clientId = clientId;
|
||||
if (message.appVersion) {
|
||||
pending.identity.appVersion = message.appVersion;
|
||||
@@ -1640,6 +1672,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
lifecycle: pluginId ? { kind: "ephemeral-plugin", pluginId } : { kind: "reconnectable" },
|
||||
admission: admitted,
|
||||
});
|
||||
connection.permissionCeiling = message.sessionPermissions;
|
||||
connection.session.setRouted(message.routed === true);
|
||||
this.assignHostSource(connection, message, admitted.principalId, pluginId);
|
||||
this.sessions.set(ws, connection);
|
||||
if (connection.lifecycle === "reconnectable") {
|
||||
this.externalSessionsByKey.set(sessionKey, connection);
|
||||
@@ -1755,6 +1790,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
clearTimeout(existing.externalDisconnectCleanupTimeout);
|
||||
existing.externalDisconnectCleanupTimeout = null;
|
||||
}
|
||||
existing.permissionCeiling = message.sessionPermissions;
|
||||
existing.session.setPermissions(pending.admission!.permissions);
|
||||
existing.session.setRouted(message.routed === true);
|
||||
this.assignHostSource(existing, message, pending.admission!.principalId);
|
||||
const newAppVersion = message.appVersion ?? null;
|
||||
if (newAppVersion && newAppVersion !== existing.appVersion) {
|
||||
existing.appVersion = newAppVersion;
|
||||
@@ -1785,6 +1824,21 @@ export class VoiceAssistantWebSocketServer {
|
||||
);
|
||||
}
|
||||
|
||||
private assignHostSource(
|
||||
connection: SessionConnection,
|
||||
hello: WSHelloMessage,
|
||||
principalId: string,
|
||||
pluginId?: string,
|
||||
): void {
|
||||
if (pluginId) return;
|
||||
const producer = hello.producer;
|
||||
const sourceId =
|
||||
hello.routed && producer
|
||||
? `route:${[principalId, producer.serverId, producer.sourceId].map(encodeURIComponent).join(":")}`
|
||||
: principalId;
|
||||
connection.session.setHostSource(sourceId);
|
||||
}
|
||||
|
||||
private buildServerInfoStatusPayload(session: Session): ServerInfoStatusPayload {
|
||||
return {
|
||||
status: "server_info",
|
||||
@@ -1792,11 +1846,13 @@ export class VoiceAssistantWebSocketServer {
|
||||
serverId: this.serverId,
|
||||
hostname: getHostName(),
|
||||
version: this.daemonVersion,
|
||||
principalId: session.getPrincipalId(),
|
||||
permissions: session.getPermissions(),
|
||||
// COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16.
|
||||
desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true,
|
||||
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
|
||||
features: {
|
||||
connectedHosts: true,
|
||||
usageSources: true,
|
||||
ownedSubscriptions: true,
|
||||
agentRequestReceipts: true,
|
||||
@@ -2086,6 +2142,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.finishPluginSocketCleanup(ws);
|
||||
return;
|
||||
}
|
||||
if (connection.session.isRouted()) {
|
||||
await this.cleanupConnection(connection, "Routed session disconnected");
|
||||
return;
|
||||
}
|
||||
this.incrementRuntimeCounter("sessionDisconnectedWaitingReconnect");
|
||||
if (connection.externalDisconnectCleanupTimeout) {
|
||||
clearTimeout(connection.externalDisconnectCleanupTimeout);
|
||||
|
||||
Reference in New Issue
Block a user