mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 10:57:07 +00:00
fix(hub): correct relationship trust boundaries
Authenticated daemon sessions own relationship management regardless of transport. The separate Hub session remains operation-allowlisted, rejects malformed execution inputs, and uses bounded outbound handshakes.
This commit is contained in:
+3
-5
@@ -15,11 +15,9 @@ The daemon persists a relationship ID and private connection credential before e
|
|||||||
relationship is independent of its current transport, so a future transport can replace the direct
|
relationship is independent of its current transport, so a future transport can replace the direct
|
||||||
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
|
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
|
||||||
|
|
||||||
Only a trusted session arriving through a server-observed local, non-browser daemon transport may
|
Normal authenticated daemon sessions may run the `hub.relationship.connect`,
|
||||||
run the `hub.relationship.connect`, `hub.relationship.get_status`, and
|
`hub.relationship.get_status`, and `hub.relationship.disconnect` RPCs regardless of transport.
|
||||||
`hub.relationship.disconnect` RPCs. Remote, relay, browser-origin, and Hub sessions cannot manage
|
The restricted Hub session cannot manage the relationship.
|
||||||
the relationship. The CLI also rejects an explicit non-loopback connect target before sending the
|
|
||||||
one-time token. A hello message's `clientType` is descriptive and grants no authority.
|
|
||||||
|
|
||||||
## Hub session scope
|
## Hub session scope
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import { assertLocalHubManagementTarget } from "./index.js";
|
|
||||||
|
|
||||||
describe("Hub CLI target authority", () => {
|
|
||||||
test.each(["relay.paseo.test:443", "tcp://10.0.0.5:6767?ssl=true"])(
|
|
||||||
"rejects non-local explicit target %s before connect",
|
|
||||||
(host) => {
|
|
||||||
expect(() => assertLocalHubManagementTarget(host, {})).toThrow(
|
|
||||||
"Hub relationship management requires a local daemon target",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
undefined,
|
|
||||||
"localhost:6767",
|
|
||||||
"127.0.0.1:6767",
|
|
||||||
"[::1]:6767",
|
|
||||||
"[0:0:0:0:0:0:0:1]:6767",
|
|
||||||
"unix:///tmp/paseo.sock",
|
|
||||||
])("accepts local target %s", (host) => {
|
|
||||||
expect(() => assertLocalHubManagementTarget(host, {})).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects a non-local PASEO_HOST target", () => {
|
|
||||||
expect(() =>
|
|
||||||
assertLocalHubManagementTarget(undefined, { PASEO_HOST: "hub.test:6767" }),
|
|
||||||
).toThrow("Hub relationship management requires a local daemon target");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { withOutput, type ListResult, type OutputSchema } from "../../output/index.js";
|
import { withOutput, type ListResult, type OutputSchema } from "../../output/index.js";
|
||||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||||
import { connectToDaemon, resolveDaemonTarget } from "../../utils/client.js";
|
import { connectToDaemon } from "../../utils/client.js";
|
||||||
|
|
||||||
interface HubRow {
|
interface HubRow {
|
||||||
state: string;
|
state: string;
|
||||||
@@ -66,26 +66,6 @@ async function withClient<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertLocalHubManagementTarget(
|
|
||||||
host: string | undefined,
|
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
|
||||||
): void {
|
|
||||||
const explicitHost = host ?? env.PASEO_HOST;
|
|
||||||
if (!explicitHost) return;
|
|
||||||
const target = resolveDaemonTarget(explicitHost);
|
|
||||||
if (target.type === "ipc") return;
|
|
||||||
const hostname = new URL(target.url).hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
||||||
if (
|
|
||||||
hostname === "localhost" ||
|
|
||||||
hostname === "::1" ||
|
|
||||||
hostname === "0:0:0:0:0:0:0:1" ||
|
|
||||||
/^127(?:\.\d{1,3}){3}$/.test(hostname)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error("Hub relationship management requires a local daemon target");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createHubCommand(): Command {
|
export function createHubCommand(): Command {
|
||||||
const hub = new Command("hub").description("Manage this daemon's Paseo Hub relationship");
|
const hub = new Command("hub").description("Manage this daemon's Paseo Hub relationship");
|
||||||
addJsonAndDaemonHostOptions(
|
addJsonAndDaemonHostOptions(
|
||||||
@@ -94,7 +74,6 @@ export function createHubCommand(): Command {
|
|||||||
withOutput(async (...args) => {
|
withOutput(async (...args) => {
|
||||||
const url = args[0] as string;
|
const url = args[0] as string;
|
||||||
const options = args.at(-2) as { token: string; host?: string };
|
const options = args.at(-2) as { token: string; host?: string };
|
||||||
assertLocalHubManagementTarget(options.host);
|
|
||||||
return withClient(options.host, async (client) =>
|
return withClient(options.host, async (client) =>
|
||||||
result((await client.connectHub(url, options.token)).status),
|
result((await client.connectHub(url, options.token)).status),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class InMemoryHubExecutions implements HubExecutions {
|
|||||||
private readonly listeners = new Set<(event: OwnedAgentEvent) => void>();
|
private readonly listeners = new Set<(event: OwnedAgentEvent) => void>();
|
||||||
private reconcileError: Error | null = null;
|
private reconcileError: Error | null = null;
|
||||||
private creates = 0;
|
private creates = 0;
|
||||||
|
private reconciles = 0;
|
||||||
|
|
||||||
async create(_input: HubAgentCreateInput): Promise<OwnedAgentSnapshot> {
|
async create(_input: HubAgentCreateInput): Promise<OwnedAgentSnapshot> {
|
||||||
this.creates++;
|
this.creates++;
|
||||||
@@ -24,6 +25,7 @@ class InMemoryHubExecutions implements HubExecutions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async reconcile(_executionId: string): Promise<OwnedAgentSnapshot | null> {
|
async reconcile(_executionId: string): Promise<OwnedAgentSnapshot | null> {
|
||||||
|
this.reconciles++;
|
||||||
if (this.reconcileError) throw this.reconcileError;
|
if (this.reconcileError) throw this.reconcileError;
|
||||||
return { executionId: "execution-1", agent: createAgentSnapshot() };
|
return { executionId: "execution-1", agent: createAgentSnapshot() };
|
||||||
}
|
}
|
||||||
@@ -48,6 +50,16 @@ class InMemoryHubExecutions implements HubExecutions {
|
|||||||
createCount(): number {
|
createCount(): number {
|
||||||
return this.creates;
|
return this.creates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reconcileCount(): number {
|
||||||
|
return this.reconciles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HubCreateInput {
|
||||||
|
executionId?: string;
|
||||||
|
prompt: string;
|
||||||
|
cwd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class HubSessionBoundary {
|
class HubSessionBoundary {
|
||||||
@@ -75,11 +87,11 @@ class HubSessionBoundary {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createWith(input: { prompt: string; cwd: string }): Promise<void> {
|
async createWith(input: HubCreateInput): Promise<void> {
|
||||||
await this.session.handleMessage({
|
await this.session.handleMessage({
|
||||||
type: "hub.agent.create.request",
|
type: "hub.agent.create.request",
|
||||||
requestId: "create-1",
|
requestId: "create-1",
|
||||||
executionId: "execution-1",
|
executionId: input.executionId ?? "execution-1",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
...input,
|
...input,
|
||||||
@@ -90,6 +102,18 @@ class HubSessionBoundary {
|
|||||||
return this.executions.createCount();
|
return this.executions.createCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reconcileWith(executionId: string): Promise<void> {
|
||||||
|
await this.session.handleMessage({
|
||||||
|
type: "hub.execution.reconcile.request",
|
||||||
|
requestId: "reconcile-1",
|
||||||
|
executionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcileCount(): number {
|
||||||
|
return this.executions.reconcileCount();
|
||||||
|
}
|
||||||
|
|
||||||
deliveredMessages(): SessionOutboundMessage[] {
|
deliveredMessages(): SessionOutboundMessage[] {
|
||||||
return this.messages.slice();
|
return this.messages.slice();
|
||||||
}
|
}
|
||||||
@@ -151,6 +175,59 @@ test.each([
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("blank Hub execution IDs reject create and reconcile before execution ownership", async () => {
|
||||||
|
const hub = new HubSessionBoundary();
|
||||||
|
|
||||||
|
await hub.createWith({ executionId: " ", prompt: "Do the work", cwd: "/workspace" });
|
||||||
|
await hub.reconcileWith("\t");
|
||||||
|
|
||||||
|
expect(hub.createCount()).toBe(0);
|
||||||
|
expect(hub.reconcileCount()).toBe(0);
|
||||||
|
expect(hub.deliveredMessages()).toEqual([
|
||||||
|
{
|
||||||
|
type: "hub.agent.create.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "create-1",
|
||||||
|
executionId: " ",
|
||||||
|
agentId: null,
|
||||||
|
agent: null,
|
||||||
|
success: false,
|
||||||
|
error: "Hub agent executionId cannot be blank",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "hub.execution.reconcile.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "reconcile-1",
|
||||||
|
executionId: "\t",
|
||||||
|
agentId: null,
|
||||||
|
agent: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("relative Hub working directories reject create before execution ownership", async () => {
|
||||||
|
const hub = new HubSessionBoundary();
|
||||||
|
|
||||||
|
await hub.createWith({ prompt: "Do the work", cwd: "repo" });
|
||||||
|
|
||||||
|
expect(hub.createCount()).toBe(0);
|
||||||
|
expect(hub.deliveredMessages()).toEqual([
|
||||||
|
{
|
||||||
|
type: "hub.agent.create.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "create-1",
|
||||||
|
executionId: "execution-1",
|
||||||
|
agentId: null,
|
||||||
|
agent: null,
|
||||||
|
success: false,
|
||||||
|
error: "Hub agent cwd must be absolute",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
function createAgentSnapshot(): AgentSnapshotPayload {
|
function createAgentSnapshot(): AgentSnapshotPayload {
|
||||||
return AgentSnapshotPayloadSchema.parse({
|
return AgentSnapshotPayloadSchema.parse({
|
||||||
id: "agent-1",
|
id: "agent-1",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isAbsolute } from "node:path";
|
||||||
import type {
|
import type {
|
||||||
HubAgentCreateRequest,
|
HubAgentCreateRequest,
|
||||||
HubExecutionReconcileRequest,
|
HubExecutionReconcileRequest,
|
||||||
@@ -49,8 +50,10 @@ export class HubSession {
|
|||||||
|
|
||||||
private async createAgent(message: HubAgentCreateRequest): Promise<void> {
|
private async createAgent(message: HubAgentCreateRequest): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
requireNonBlankHubAgentField("executionId", message.executionId);
|
||||||
requireNonBlankHubAgentField("prompt", message.prompt);
|
requireNonBlankHubAgentField("prompt", message.prompt);
|
||||||
requireNonBlankHubAgentField("cwd", message.cwd);
|
requireNonBlankHubAgentField("cwd", message.cwd);
|
||||||
|
if (!isAbsolute(message.cwd)) throw new Error("Hub agent cwd must be absolute");
|
||||||
const result = await this.executions.create({
|
const result = await this.executions.create({
|
||||||
executionId: message.executionId,
|
executionId: message.executionId,
|
||||||
provider: message.provider,
|
provider: message.provider,
|
||||||
@@ -94,6 +97,7 @@ export class HubSession {
|
|||||||
private async reconcile(message: HubExecutionReconcileRequest): Promise<void> {
|
private async reconcile(message: HubExecutionReconcileRequest): Promise<void> {
|
||||||
let result: Awaited<ReturnType<HubExecutions["reconcile"]>> = null;
|
let result: Awaited<ReturnType<HubExecutions["reconcile"]>> = null;
|
||||||
try {
|
try {
|
||||||
|
requireNonBlankHubAgentField("executionId", message.executionId);
|
||||||
result = await this.executions.reconcile(message.executionId);
|
result = await this.executions.reconcile(message.executionId);
|
||||||
} catch {
|
} catch {
|
||||||
// Reconcile has no error variant on the wire. A terminal empty response
|
// Reconcile has no error variant on the wire. A terminal empty response
|
||||||
@@ -133,7 +137,10 @@ export class HubSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireNonBlankHubAgentField(field: "prompt" | "cwd", value: string): void {
|
function requireNonBlankHubAgentField(
|
||||||
|
field: "executionId" | "prompt" | "cwd",
|
||||||
|
value: string,
|
||||||
|
): void {
|
||||||
if (value.trim().length === 0) {
|
if (value.trim().length === 0) {
|
||||||
throw new Error(`Hub agent ${field} cannot be blank`);
|
throw new Error(`Hub agent ${field} cannot be blank`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,60 +54,28 @@ describe("Hub relationship", () => {
|
|||||||
expect(relationship.enrollmentAttempts()).toEqual([]);
|
expect(relationship.enrollmentAttempts()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an external socket cannot manage Hub relationships even when it claims to be the CLI", async () => {
|
test("an authenticated external socket can manage the Hub relationship", async () => {
|
||||||
relationship = await HubRelationshipHarness.start();
|
relationship = await HubRelationshipHarness.start();
|
||||||
|
|
||||||
const denials = await relationship.attemptExternalManagementAsCli();
|
const responses = await relationship.manageRelationshipFromExternalSocket();
|
||||||
|
|
||||||
expect(denials).toEqual([
|
expect(responses.map((response) => response.type)).toEqual([
|
||||||
{
|
"hub.relationship.connect.response",
|
||||||
type: "rpc_error",
|
"hub.relationship.get_status.response",
|
||||||
payload: {
|
"hub.relationship.disconnect.response",
|
||||||
requestId: "external-hub-connect",
|
|
||||||
requestType: "hub.relationship.connect.request",
|
|
||||||
error: "Hub relationship management requires a local daemon connection",
|
|
||||||
code: "local_management_required",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "rpc_error",
|
|
||||||
payload: {
|
|
||||||
requestId: "external-hub-status",
|
|
||||||
requestType: "hub.relationship.get_status.request",
|
|
||||||
error: "Hub relationship management requires a local daemon connection",
|
|
||||||
code: "local_management_required",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "rpc_error",
|
|
||||||
payload: {
|
|
||||||
requestId: "external-hub-disconnect",
|
|
||||||
requestType: "hub.relationship.disconnect.request",
|
|
||||||
error: "Hub relationship management requires a local daemon connection",
|
|
||||||
code: "local_management_required",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
expect(relationship.enrollmentAttempts()).toEqual([]);
|
expect(relationship.enrollmentAttempts()).toHaveLength(1);
|
||||||
expect(relationship.relationshipFile()).toBeNull();
|
expect(relationship.relationshipFile()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a loopback browser socket cannot manage Hub relationships by claiming to be the CLI", async () => {
|
test("an authenticated browser socket can manage the Hub relationship", async () => {
|
||||||
relationship = await HubRelationshipHarness.start();
|
relationship = await HubRelationshipHarness.start();
|
||||||
|
|
||||||
const denial = await relationship.attemptLoopbackBrowserConnectAsCli();
|
const response = await relationship.connectFromBrowserSocket();
|
||||||
|
|
||||||
expect(denial).toEqual({
|
expect(response.type).toBe("hub.relationship.connect.response");
|
||||||
type: "rpc_error",
|
expect(relationship.enrollmentAttempts()).toHaveLength(1);
|
||||||
payload: {
|
expect(relationship.relationshipFile()?.state).toBe("active");
|
||||||
requestId: "browser-hub-connect",
|
|
||||||
requestType: "hub.relationship.connect.request",
|
|
||||||
error: "Hub relationship management requires a local daemon connection",
|
|
||||||
code: "local_management_required",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(relationship.enrollmentAttempts()).toEqual([]);
|
|
||||||
expect(relationship.relationshipFile()).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("persists private generated authority before enrollment and active before dialing", async () => {
|
test("persists private generated authority before enrollment and active before dialing", async () => {
|
||||||
|
|||||||
@@ -197,6 +197,30 @@ test("a transient socket response releases the failed upgrade and reports one co
|
|||||||
expect(hub.openConnections()).toBe(0);
|
expect(hub.openConnections()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a stalled socket opening handshake times out and releases the connection", async () => {
|
||||||
|
const hub = await UpgradeRejectingHub.start(["stall"]);
|
||||||
|
const outcomes = new SocketOutcomes();
|
||||||
|
const remote = new DirectHubRelationshipRemote({ requestTimeoutMs: 25 });
|
||||||
|
|
||||||
|
const socket = remote.openSocket(
|
||||||
|
{
|
||||||
|
relationshipId: "relationship-1",
|
||||||
|
webSocketUrl: hub.webSocketUrl,
|
||||||
|
credential: "credential",
|
||||||
|
},
|
||||||
|
outcomes.events,
|
||||||
|
) as HubSocketConnection & WebSocket;
|
||||||
|
|
||||||
|
expect(await outcomes.next()).toEqual({
|
||||||
|
type: "failed",
|
||||||
|
message: "Opening handshake has timed out",
|
||||||
|
});
|
||||||
|
expect(await outcomes.afterCleanup()).toEqual([
|
||||||
|
{ type: "failed", message: "Opening handshake has timed out" },
|
||||||
|
]);
|
||||||
|
expect(socket.readyState).toBe(WebSocket.CLOSED);
|
||||||
|
});
|
||||||
|
|
||||||
test.each([401, 403] as const)(
|
test.each([401, 403] as const)(
|
||||||
"controller treats socket authentication status %s as terminal without redialing",
|
"controller treats socket authentication status %s as terminal without redialing",
|
||||||
async (status) => {
|
async (status) => {
|
||||||
@@ -375,7 +399,7 @@ class UpgradeRejectingHub {
|
|||||||
private attemptObserved = deferred<void>();
|
private attemptObserved = deferred<void>();
|
||||||
private attempts = 0;
|
private attempts = 0;
|
||||||
|
|
||||||
private constructor(private readonly statuses: number[]) {
|
private constructor(private readonly statuses: Array<number | "stall">) {
|
||||||
this.server.on("upgrade", (_request, socket) => {
|
this.server.on("upgrade", (_request, socket) => {
|
||||||
const attempt = ++this.attempts;
|
const attempt = ++this.attempts;
|
||||||
this.attemptObserved.resolve();
|
this.attemptObserved.resolve();
|
||||||
@@ -388,6 +412,10 @@ class UpgradeRejectingHub {
|
|||||||
released.resolve();
|
released.resolve();
|
||||||
});
|
});
|
||||||
const status = this.statuses[attempt - 1] ?? this.statuses.at(-1) ?? 503;
|
const status = this.statuses[attempt - 1] ?? this.statuses.at(-1) ?? 503;
|
||||||
|
if (status === "stall") {
|
||||||
|
socket.resume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (status === 0) {
|
if (status === 0) {
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
return;
|
return;
|
||||||
@@ -398,7 +426,7 @@ class UpgradeRejectingHub {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static async start(statuses: number[]): Promise<UpgradeRejectingHub> {
|
static async start(statuses: Array<number | "stall">): Promise<UpgradeRejectingHub> {
|
||||||
const hub = new UpgradeRejectingHub(statuses);
|
const hub = new UpgradeRejectingHub(statuses);
|
||||||
openUpgradeHubs.push(hub);
|
openUpgradeHubs.push(hub);
|
||||||
openServers.push(hub.server);
|
openServers.push(hub.server);
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
|
|||||||
|
|
||||||
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection {
|
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection {
|
||||||
const socket = new WebSocket(input.webSocketUrl, {
|
const socket = new WebSocket(input.webSocketUrl, {
|
||||||
|
handshakeTimeout: this.requestTimeoutMs,
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${input.credential}`,
|
authorization: `Bearer ${input.credential}`,
|
||||||
"x-paseo-relationship-id": input.relationshipId,
|
"x-paseo-relationship-id": input.relationshipId,
|
||||||
@@ -164,6 +165,7 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
|
|||||||
socket.once("error", (error) => {
|
socket.once("error", (error) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
socket.terminate();
|
||||||
events.failed(error);
|
events.failed(error);
|
||||||
});
|
});
|
||||||
return socket;
|
return socket;
|
||||||
|
|||||||
@@ -512,7 +512,7 @@ export class HubRelationshipHarness {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async attemptExternalManagementAsCli(): Promise<SessionOutboundMessage[]> {
|
async manageRelationshipFromExternalSocket(): Promise<SessionOutboundMessage[]> {
|
||||||
const address = Object.values(networkInterfaces())
|
const address = Object.values(networkInterfaces())
|
||||||
.flat()
|
.flat()
|
||||||
.find((candidate) => candidate?.family === "IPv4" && !candidate.internal)?.address;
|
.find((candidate) => candidate?.family === "IPv4" && !candidate.internal)?.address;
|
||||||
@@ -544,7 +544,7 @@ export class HubRelationshipHarness {
|
|||||||
return responses;
|
return responses;
|
||||||
}
|
}
|
||||||
|
|
||||||
async attemptLoopbackBrowserConnectAsCli(): Promise<SessionOutboundMessage> {
|
async connectFromBrowserSocket(): Promise<SessionOutboundMessage> {
|
||||||
const target = this.daemon?.getListenTarget();
|
const target = this.daemon?.getListenTarget();
|
||||||
if (!target || target.type !== "tcp") throw new Error("Daemon did not bind TCP");
|
if (!target || target.type !== "tcp") throw new Error("Daemon did not bind TCP");
|
||||||
const socket = await this.openClaimedCliSocket(`ws://127.0.0.1:${target.port}/ws`, {
|
const socket = await this.openClaimedCliSocket(`ws://127.0.0.1:${target.port}/ws`, {
|
||||||
|
|||||||
@@ -395,15 +395,6 @@ export interface SessionFileSystem {
|
|||||||
isDirectory(path: string): Promise<boolean>;
|
isDirectory(path: string): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionPeer =
|
|
||||||
| {
|
|
||||||
transport: "direct";
|
|
||||||
peer: "loopback" | "local_ipc" | "external";
|
|
||||||
browserOrigin: boolean;
|
|
||||||
}
|
|
||||||
| { transport: "relay"; peer: "external" }
|
|
||||||
| { transport: "internal"; peer: "internal" };
|
|
||||||
|
|
||||||
const nodeSessionFileSystem: SessionFileSystem = {
|
const nodeSessionFileSystem: SessionFileSystem = {
|
||||||
async isDirectory(path) {
|
async isDirectory(path) {
|
||||||
const stats = await stat(path).catch(() => null);
|
const stats = await stat(path).catch(() => null);
|
||||||
@@ -515,34 +506,6 @@ function parseClientCapabilities(
|
|||||||
return new Set(result);
|
return new Set(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
type HubRelationshipManagementRequest = Extract<
|
|
||||||
SessionInboundMessage,
|
|
||||||
{
|
|
||||||
type:
|
|
||||||
| "hub.relationship.connect.request"
|
|
||||||
| "hub.relationship.get_status.request"
|
|
||||||
| "hub.relationship.disconnect.request";
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
function isHubRelationshipManagementRequest(
|
|
||||||
message: SessionInboundMessage,
|
|
||||||
): message is HubRelationshipManagementRequest {
|
|
||||||
return (
|
|
||||||
message.type === "hub.relationship.connect.request" ||
|
|
||||||
message.type === "hub.relationship.get_status.request" ||
|
|
||||||
message.type === "hub.relationship.disconnect.request"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function canManageHubRelationships(peer: SessionPeer): boolean {
|
|
||||||
return (
|
|
||||||
peer.transport === "direct" &&
|
|
||||||
!peer.browserOrigin &&
|
|
||||||
(peer.peer === "loopback" || peer.peer === "local_ipc")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AgentTimelineProjectionSelection {
|
interface AgentTimelineProjectionSelection {
|
||||||
timeline: AgentTimelineFetchResult;
|
timeline: AgentTimelineFetchResult;
|
||||||
entries: TimelineProjectionEntry[];
|
entries: TimelineProjectionEntry[];
|
||||||
@@ -1386,10 +1349,7 @@ export class Session {
|
|||||||
/**
|
/**
|
||||||
* Main entry point for processing session messages
|
* Main entry point for processing session messages
|
||||||
*/
|
*/
|
||||||
public async handleMessage(
|
public async handleMessage(msg: SessionInboundMessage): Promise<void> {
|
||||||
msg: SessionInboundMessage,
|
|
||||||
peer: SessionPeer = { transport: "internal", peer: "internal" },
|
|
||||||
): Promise<void> {
|
|
||||||
this.inflightRequests++;
|
this.inflightRequests++;
|
||||||
if (this.inflightRequests > this.peakInflightRequests) {
|
if (this.inflightRequests > this.peakInflightRequests) {
|
||||||
this.peakInflightRequests = this.inflightRequests;
|
this.peakInflightRequests = this.inflightRequests;
|
||||||
@@ -1403,7 +1363,7 @@ export class Session {
|
|||||||
"agent.session.inbound",
|
"agent.session.inbound",
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await this.dispatchInboundMessage(msg, peer);
|
await this.dispatchInboundMessage(msg);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
this.sessionLogger.error({ err }, "Error handling message");
|
this.sessionLogger.error({ err }, "Error handling message");
|
||||||
@@ -1441,22 +1401,7 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async dispatchInboundMessage(
|
private async dispatchInboundMessage(msg: SessionInboundMessage): Promise<void> {
|
||||||
msg: SessionInboundMessage,
|
|
||||||
peer: SessionPeer,
|
|
||||||
): Promise<void> {
|
|
||||||
if (isHubRelationshipManagementRequest(msg) && !canManageHubRelationships(peer)) {
|
|
||||||
this.emit({
|
|
||||||
type: "rpc_error",
|
|
||||||
payload: {
|
|
||||||
requestId: msg.requestId,
|
|
||||||
requestType: msg.type,
|
|
||||||
error: "Hub relationship management requires a local daemon connection",
|
|
||||||
code: "local_management_required",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const promise =
|
const promise =
|
||||||
this.dispatchVoiceAndControlMessage(msg) ??
|
this.dispatchVoiceAndControlMessage(msg) ??
|
||||||
this.dispatchAgentRewindMessage(msg) ??
|
this.dispatchAgentRewindMessage(msg) ??
|
||||||
|
|||||||
@@ -30,12 +30,7 @@ import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frame
|
|||||||
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
||||||
import type { HostnamesConfig } from "./hostnames.js";
|
import type { HostnamesConfig } from "./hostnames.js";
|
||||||
import { isHostnameAllowed } from "./hostnames.js";
|
import { isHostnameAllowed } from "./hostnames.js";
|
||||||
import {
|
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
|
||||||
Session,
|
|
||||||
type SessionLifecycleIntent,
|
|
||||||
type SessionPeer,
|
|
||||||
type SessionRuntimeMetrics,
|
|
||||||
} from "./session.js";
|
|
||||||
import { HubSession } from "./hub/hub-session.js";
|
import { HubSession } from "./hub/hub-session.js";
|
||||||
import type { HubRelationshipManagement } from "./hub/relationship-controller.js";
|
import type { HubRelationshipManagement } from "./hub/relationship-controller.js";
|
||||||
import type { HubExecutions } from "./hub/relationship-owned-executions.js";
|
import type { HubExecutions } from "./hub/relationship-owned-executions.js";
|
||||||
@@ -1849,11 +1844,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startMs = performance.now();
|
const startMs = performance.now();
|
||||||
const identity = this.socketIdentities.get(ws);
|
await activeConnection.session.handleMessage(message.message);
|
||||||
const peer = identity
|
|
||||||
? toSessionPeer(identity)
|
|
||||||
: ({ transport: "internal", peer: "internal" } satisfies SessionPeer);
|
|
||||||
await activeConnection.session.handleMessage(message.message, peer);
|
|
||||||
const durationMs = performance.now() - startMs;
|
const durationMs = performance.now() - startMs;
|
||||||
this.recordRequestLatency(message.message.type, durationMs);
|
this.recordRequestLatency(message.message.type, durationMs);
|
||||||
|
|
||||||
@@ -2254,17 +2245,6 @@ function resolveConnectionPeer(
|
|||||||
return isLoopbackAddress(requestMetadata.remoteAddress) ? "loopback" : "external";
|
return isLoopbackAddress(requestMetadata.remoteAddress) ? "loopback" : "external";
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSessionPeer(identity: WebSocketConnectionIdentity): SessionPeer {
|
|
||||||
if (identity.transport === "relay") {
|
|
||||||
return { transport: "relay", peer: "external" };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
transport: "direct",
|
|
||||||
peer: identity.peer,
|
|
||||||
browserOrigin: identity.browserOrigin,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLoopbackAddress(address: string): boolean {
|
function isLoopbackAddress(address: string): boolean {
|
||||||
const normalized = address.toLowerCase();
|
const normalized = address.toLowerCase();
|
||||||
if (normalized === "::1" || normalized === "0:0:0:0:0:0:0:1") return true;
|
if (normalized === "::1" || normalized === "0:0:0:0:0:0:0:1") return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user