From adf80a9fe00088b12e7e8cc9330e92a95d3083a9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 30 Aug 2026 22:46:24 +0200 Subject: [PATCH] Keep Hub daemon access explicit (#4088) * feat(hub): make daemon authority explicit Connecting a daemon establishes presence without granting execution. Hub authority now uses semantic session permissions, with workflow execution granted explicitly and inspectable through the normal session protocol. Attention frames remain part of the existing agent and terminal subscriptions, keeping session output solicited. * test(server): match sentence-case tool labels --- docs/hub.md | 16 +- docs/permissions.md | 2 +- .../cli/src/commands/hub/commands.test.ts | 114 ++++++- packages/cli/src/commands/hub/connect.ts | 21 +- .../cli/src/commands/hub/daemon-client.ts | 12 +- packages/cli/src/commands/hub/index.ts | 5 + .../cli/src/commands/hub/init-flow.test.ts | 53 +++- .../cli/src/commands/hub/init-plan.test.ts | 2 +- packages/cli/src/commands/hub/init.ts | 57 +++- packages/cli/src/commands/hub/permissions.ts | 112 +++++++ .../cli/src/commands/hub/status-output.ts | 6 +- packages/client/src/daemon-client.ts | 27 +- packages/protocol/src/messages.hub.test.ts | 27 +- packages/protocol/src/messages.ts | 33 +- .../src/server/agent/activity-curator.test.ts | 6 +- .../src/server/authorization/index.test.ts | 6 + .../server/src/server/authorization/index.ts | 33 +- .../authorization/operation-permissions.ts | 2 + .../server/src/server/bootstrap.smoke.test.ts | 5 +- packages/server/src/server/bootstrap.ts | 34 ++- .../hub/execution-session.websocket.test.ts | 33 +- .../hub/relationship-controller.test.ts | 94 +++++- .../src/server/hub/relationship-controller.ts | 164 ++++++++-- .../server/hub/relationship-remote.test.ts | 104 ++++++- .../src/server/hub/relationship-remote.ts | 46 ++- .../hub/test-utils/relationship-harness.ts | 81 ++++- packages/server/src/server/session.ts | 31 ++ .../agent-updates-service.test.ts | 15 + .../agent-updates/agent-updates-service.ts | 24 ++ .../server/session/daemon/daemon-session.ts | 15 +- .../websocket-server.notifications.test.ts | 29 +- .../websocket-server.relay-reconnect.test.ts | 32 +- ...cket-server.terminal-notifications.test.ts | 22 +- .../server/src/server/websocket-server.ts | 289 ++++++++---------- .../terminal-session-controller.test.ts | 12 + .../terminal/terminal-session-controller.ts | 13 + 36 files changed, 1273 insertions(+), 304 deletions(-) create mode 100644 packages/cli/src/commands/hub/permissions.ts diff --git a/docs/hub.md b/docs/hub.md index 29b6738f9b..e1686a873d 100644 --- a/docs/hub.md +++ b/docs/hub.md @@ -17,17 +17,21 @@ 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 WebSocket without pairing again. The current foundation supports one Hub relationship per daemon. -Normal authenticated daemon sessions may run the `hub.management.daemon.connect`, -`hub.management.daemon.get_status`, and `hub.management.daemon.disconnect` RPCs. Hub connections -receive only `hub.execution.*` authority, so execution credentials cannot manage the relationship. +Normal authenticated daemon sessions may manage the daemon's Hub relationship and permissions. +Hub connections have no daemon permissions by default. Connecting gives Hub machine identity and +presence but no execution authority. The `hub.execute` permission lets workflows triggered from +GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents. Grant it +during interactive login or later with `paseo hub permissions grant hub.execute`. Relationships +created before this split migrate their legacy execution scope to `hub.execute`. Hub sessions cannot +manage their own relationship or permissions. ## Session grants and execution ownership Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub -connection receives `hub.execute`. The current enrollment and persistence format still carries -`hub.execution.*`; one compatibility boundary translates it before the session is created. A denied -request returns the ordinary `rpc_error` shape. See [permissions.md](permissions.md). +connection receives its persisted permissions. Legacy relationship files carrying +`hub.execution.*` migrate once to `hub.execute` at the persistence boundary. A denied request returns +the ordinary `rpc_error` shape. See [permissions.md](permissions.md). The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume, browser, binary, retained-session, or broadcast state. Its outbound execution events include only diff --git a/docs/permissions.md b/docs/permissions.md index 79ce6edcf8..93f013b698 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -56,4 +56,4 @@ The Hub authenticates as a service principal. Its locally selected grants decide Hub user and role identifiers remain opaque external subjects. The Hub may create and revoke linked daemon principals when granted `access.manage`; the daemon does not interpret accounts, organizations, or roles. -The current Hub enrollment protocol still exchanges `hub.execution.*`. The daemon translates that legacy value to `hub.execute` once at the connection boundary. Remove the translation when Hub enrollment exchanges semantic permissions. +Hub enrollment and permission updates exchange these semantic permissions directly. Legacy persisted Hub relationships that contain `hub.execution.*` migrate once to `hub.execute` when the daemon loads them; new relationships never persist or emit transport scopes as authority. diff --git a/packages/cli/src/commands/hub/commands.test.ts b/packages/cli/src/commands/hub/commands.test.ts index 166183ab9c..36f72bfea3 100644 --- a/packages/cli/src/commands/hub/commands.test.ts +++ b/packages/cli/src/commands/hub/commands.test.ts @@ -9,6 +9,7 @@ import { runHubLogin } from "./login.js"; import { runHubLogout } from "./logout.js"; import { runHubProjects } from "./projects.js"; import { createHubCommand } from "./index.js"; +import { runHubPermissionChange, runHubPermissionsList } from "./permissions.js"; import type { HubReporter } from "./reporter.js"; const quietReporter: HubReporter = { progress() {} }; @@ -25,6 +26,7 @@ describe("Hub commands", () => { "connect", "status", "disconnect", + "permissions", "projects", "export", "deploy", @@ -174,12 +176,87 @@ describe("Hub commands", () => { assert.deepEqual(observed, [{ origin: "https://hub.test", credential: "stored-human-secret" }]); assert.deepEqual(daemon.connections, [ - { origin: "https://hub.test", token: "one-time-enrollment-token-with-enough-length" }, + { + origin: "https://hub.test", + token: "one-time-enrollment-token-with-enough-length", + permissions: [], + }, ]); assert.equal(daemon.connections[0]?.token.includes("stored-human-secret"), false); assert.deepEqual(progress, ["Connecting this daemon to https://hub.test"]); }); + it("grants workflow execution only when explicitly requested", async () => { + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const daemon = new FakeDaemon("https://hub.test"); + + await runHubConnect( + "https://hub.test", + { permissions: ["hub.execute"] }, + { + env: {}, + credentials, + hub: { issueEnrollmentToken: async () => "one-time-token" }, + daemon: new FakeDaemonConnection(daemon), + reporter: quietReporter, + }, + ); + + assert.deepEqual(daemon.connections[0]?.permissions, ["hub.execute"]); + }); + + it("revokes a relationship when an older daemon widens connected-only access", async () => { + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const daemon = new FakeDaemon("https://hub.test"); + daemon.returnedPermissions = ["hub.execute"]; + + await assert.rejects( + runHubConnect( + "https://hub.test", + {}, + { + env: {}, + credentials, + hub: { issueEnrollmentToken: async () => "one-time-token" }, + daemon: new FakeDaemonConnection(daemon), + reporter: quietReporter, + }, + ), + /did not honor the requested Hub access/u, + ); + + assert.equal(daemon.disconnects, 1); + }); + + it("lists, grants, and revokes semantic Hub permissions without a wizard", async () => { + const daemon = new FakeDaemon("https://hub.test"); + daemon.returnedPermissions = []; + const connection = new FakeDaemonConnection(daemon); + const progress: string[] = []; + const dependencies = { + daemon: connection, + reporter: { progress: (message: string) => progress.push(message) }, + }; + + const empty = await runHubPermissionsList({}, dependencies); + assert.deepEqual(empty.data, []); + + await runHubPermissionChange("grant", "hub.execute", {}, dependencies); + const granted = await runHubPermissionsList({}, dependencies); + assert.deepEqual(granted.data, [ + { permission: "hub.execute", description: "Run agents for Hub automations" }, + ]); + + await runHubPermissionChange("revoke", "hub.execute", {}, dependencies); + assert.deepEqual((await runHubPermissionsList({}, dependencies)).data, []); + assert.deepEqual(progress, [ + "Granted hub.execute to https://hub.test", + "Revoked hub.execute from https://hub.test", + ]); + }); + it("projects reports its normalized destination before listing", async () => { const credentials = new MemoryCredentials(); const events: string[] = []; @@ -523,22 +600,39 @@ class FakeDaemonConnection implements HubDaemonConnection { } class FakeDaemon implements HubDaemonClient { - readonly connections: Array<{ origin: string; token: string }> = []; + readonly connections: Array<{ + origin: string; + token: string; + permissions: readonly string[]; + }> = []; disconnects = 0; readonly disconnectForces: boolean[] = []; disconnectError: Error | null = null; disconnectWarning: string | null = null; beforeDisconnect: (() => void) | null = null; + returnedPermissions: readonly string[] | null = null; constructor(private readonly origin: string) {} - async connectHub(origin: string, token: string) { - this.connections.push({ origin, token }); - return { status: hubStatus("connected", origin) }; + async connectHub(origin: string, token: string, permissions: readonly string[] = []) { + this.connections.push({ origin, token, permissions }); + return { + status: hubStatus("connected", origin, this.returnedPermissions ?? permissions), + }; + } + + async updateHubPermissions(input: { grant?: readonly string[]; revoke?: readonly string[] }) { + const current = new Set(this.returnedPermissions ?? []); + for (const permission of input.revoke ?? []) current.delete(permission); + for (const permission of input.grant ?? []) current.add(permission); + this.returnedPermissions = [...current]; + return { status: hubStatus("connected", this.origin, this.returnedPermissions) }; } async getHubStatus() { - return { status: hubStatus("connected", this.origin) }; + return { + status: hubStatus("connected", this.origin, this.returnedPermissions ?? ["hub.execute"]), + }; } async getProvidersSnapshot() { @@ -559,12 +653,16 @@ class FakeDaemon implements HubDaemonClient { async close() {} } -function hubStatus(state: string, origin: string | null): HubStatus { +function hubStatus( + state: string, + origin: string | null, + permissions: readonly string[] = ["hub.execute"], +): HubStatus { return { state, daemonId: state === "connected" ? "daemon-1" : null, hubOrigin: origin, - scopes: state === "connected" ? ["hub.execution.*"] : [], + permissions: state === "connected" ? [...permissions] : [], connectedAt: null, lastError: null, }; diff --git a/packages/cli/src/commands/hub/connect.ts b/packages/cli/src/commands/hub/connect.ts index 44f9a3c3ed..8f3fcf9bfc 100644 --- a/packages/cli/src/commands/hub/connect.ts +++ b/packages/cli/src/commands/hub/connect.ts @@ -14,6 +14,8 @@ interface HubConnectOptions { apiKey?: string; host?: string; json?: boolean; + permission?: readonly string[]; + permissions?: readonly string[]; } interface HubConnectDependencies { @@ -38,8 +40,18 @@ export async function runHubConnect( reportHubProgress(dependencies.reporter, options, `Connecting this daemon to ${origin}`); const credential = resolveHubCredential({ ...resolution, origin }); const token = await dependencies.hub.issueEnrollmentToken(origin, credential); + const permissions = options.permissions ?? options.permission ?? []; return withHubDaemon(dependencies.daemon, options.host, async (daemon) => { - const response = await daemon.connectHub(origin, token); + const response = await daemon.connectHub(origin, token, permissions); + if ( + response.status.hubOrigin !== null && + !samePermissions(response.status.permissions, permissions) + ) { + await daemon.disconnectHub(false).catch(() => undefined); + throw new Error( + "The daemon did not honor the requested Hub access. Update Paseo before connecting it.", + ); + } return hubStatusResult(response.status); }); } @@ -51,7 +63,8 @@ export function addHubConnectCommand(parent: Command, dependencies: HubConnectDe .command("connect") .description("Enroll this daemon with a Paseo Hub") .argument("[origin]", "Paseo Hub origin") - .option("--api-key ", "Organization API key"), + .option("--api-key ", "Organization API key") + .option("--permission ", "Grant daemon permission during connection"), ), ).action( withOutput(async (...args) => { @@ -61,3 +74,7 @@ export function addHubConnectCommand(parent: Command, dependencies: HubConnectDe }), ); } + +function samePermissions(actual: readonly string[], expected: readonly string[]): boolean { + return actual.length === expected.length && expected.every((scope) => actual.includes(scope)); +} diff --git a/packages/cli/src/commands/hub/daemon-client.ts b/packages/cli/src/commands/hub/daemon-client.ts index c7eefbd60a..3f53360ccd 100644 --- a/packages/cli/src/commands/hub/daemon-client.ts +++ b/packages/cli/src/commands/hub/daemon-client.ts @@ -5,7 +5,7 @@ export interface HubStatus { state: string; daemonId: string | null; hubOrigin: string | null; - scopes: string[]; + permissions: string[]; connectedAt: string | null; lastError: string | null; } @@ -15,7 +15,15 @@ export interface HubProvidersSnapshotOptions { } export interface HubDaemonClient { - connectHub(url: string, token: string): Promise<{ status: HubStatus }>; + connectHub( + url: string, + token: string, + permissions?: readonly string[], + ): Promise<{ status: HubStatus }>; + updateHubPermissions(input: { + grant?: readonly string[]; + revoke?: readonly string[]; + }): Promise<{ status: HubStatus }>; getHubStatus(): Promise<{ status: HubStatus }>; disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>; getProvidersSnapshot( diff --git a/packages/cli/src/commands/hub/index.ts b/packages/cli/src/commands/hub/index.ts index 063a72f65b..6bddbd1e70 100644 --- a/packages/cli/src/commands/hub/index.ts +++ b/packages/cli/src/commands/hub/index.ts @@ -20,6 +20,7 @@ import { processHubReporter, type HubReporter } from "./reporter.js"; import { hubStatusResult } from "./status-output.js"; import { addHubResolutionHelp } from "./help.js"; import { addHubInitCommand, continueHubGuidedSetup } from "./init.js"; +import { addHubPermissionsCommand } from "./permissions.js"; interface HubCommandEnvironment { env: Readonly>; @@ -80,6 +81,10 @@ export function createHubCommand(overrides: Partial = {}) daemon: environment.daemon, reporter: environment.reporter, }); + addHubPermissionsCommand(hub, { + daemon: environment.daemon, + reporter: environment.reporter, + }); addHubProjectsCommand(hub, { env: environment.env, credentials: environment.credentials, diff --git a/packages/cli/src/commands/hub/init-flow.test.ts b/packages/cli/src/commands/hub/init-flow.test.ts index 55b7dab57f..934c47b0f6 100644 --- a/packages/cli/src/commands/hub/init-flow.test.ts +++ b/packages/cli/src/commands/hub/init-flow.test.ts @@ -25,7 +25,7 @@ describe("Hub guided setup continuation", () => { const cwd = await temporaryDirectory(); const credentials = new MemoryCredentials(); const daemon = new SetupDaemon(); - const prompts = new PromptAnswers([true], [], []); + const prompts = new PromptAnswers([true, false], [], []); const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = []; const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls); @@ -42,9 +42,13 @@ describe("Hub guided setup continuation", () => { }, ); - assert.deepEqual(prompts.confirmations, ["Connect this daemon to this Hub?"]); + assert.deepEqual(prompts.confirmations, [ + "Connect this daemon to Paseo Hub?\n\nConnecting lets Hub identify this daemon and show whether it is online.\nIt does not allow Hub to create workspaces or run agents.", + "Allow Hub automations to run agents on this daemon?\n\nThis lets workflows triggered from GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents here.\n\nAgents can access files and run commands allowed by their workspace runtime.", + ]); assert.deepEqual(prompts.selections, []); assert.deepEqual(prompts.messages, [ + "Daemon connected with no permissions.\n\nEnable Hub automations later:\n paseo hub permissions grant hub.execute", "Configure triggers in Hub: https://hub.test/triggers\nOr scaffold triggers as code: paseo hub init", ]); assert.deepEqual(calls, [{ operation: "token", origin: "https://hub.test" }]); @@ -70,6 +74,26 @@ describe("Hub guided setup continuation", () => { ]); }); + it("does not reopen onboarding or widen permissions for an existing connection", async () => { + const cwd = await temporaryDirectory(); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const daemon = new SetupDaemon(); + await daemon.connectHub("https://hub.test", "existing-token", []); + const prompts = new PromptAnswers([], [], []); + + await continueHubGuidedSetup( + "https://hub.test", + setupEnvironment(cwd, credentials, daemon, prompts, []), + ); + + assert.deepEqual(prompts.confirmations, []); + assert.deepEqual(prompts.messages, [ + "This daemon is already connected to https://hub.test. Permissions: None.", + "Configure triggers in Hub: https://hub.test/triggers\nOr scaffold triggers as code: paseo hub init", + ]); + }); + it("does not require a Hub app connection during login", async () => { const cwd = await temporaryDirectory(); const credentials = new MemoryCredentials(); @@ -333,6 +357,7 @@ class SetupDaemon implements HubDaemonClient { connections = 0; readonly snapshotCwds: Array = []; private origin: string | null = null; + private currentPermissions: string[] = []; constructor( readonly providerEntries: readonly ProviderSnapshotEntry[] = [ @@ -355,14 +380,26 @@ class SetupDaemon implements HubDaemonClient { private readonly providerEntrySequence?: readonly (readonly ProviderSnapshotEntry[])[], ) {} - async connectHub(origin: string) { + async connectHub(origin: string, _token: string, permissions: readonly string[] = []) { this.connections += 1; this.origin = origin; - return { status: status(origin) }; + this.currentPermissions = [...permissions]; + return { status: status(origin, permissions) }; + } + + async updateHubPermissions(input: { grant?: readonly string[]; revoke?: readonly string[] }) { + const permissions = new Set(this.origin === null ? [] : this.currentPermissions); + for (const permission of input.revoke ?? []) permissions.delete(permission); + for (const permission of input.grant ?? []) permissions.add(permission); + this.currentPermissions = [...permissions]; + return { status: status(this.origin ?? "https://hub.test", this.currentPermissions) }; } async getHubStatus() { - return { status: this.origin === null ? disconnectedStatus() : status(this.origin) }; + return { + status: + this.origin === null ? disconnectedStatus() : status(this.origin, this.currentPermissions), + }; } async getProvidersSnapshot(options?: { cwd?: string }) { @@ -381,12 +418,12 @@ class SetupDaemon implements HubDaemonClient { async close() {} } -function status(origin: string): HubStatus { +function status(origin: string, permissions: readonly string[] = []): HubStatus { return { state: "connected", daemonId: "daemon-1", hubOrigin: origin, - scopes: [], + permissions: [...permissions], connectedAt: null, lastError: null, }; @@ -397,7 +434,7 @@ function disconnectedStatus(): HubStatus { state: "not_connected", daemonId: null, hubOrigin: null, - scopes: [], + permissions: [], connectedAt: null, lastError: null, }; diff --git a/packages/cli/src/commands/hub/init-plan.test.ts b/packages/cli/src/commands/hub/init-plan.test.ts index aa4ce9d244..44538eb3b1 100644 --- a/packages/cli/src/commands/hub/init-plan.test.ts +++ b/packages/cli/src/commands/hub/init-plan.test.ts @@ -68,7 +68,7 @@ describe("Hub init planning", () => { state: "connected", daemonId: "daemon-1", hubOrigin: "https://hub.test", - scopes: ["hub.execution.*"], + permissions: ["hub.execute"], connectedAt: null, lastError: null, }; diff --git a/packages/cli/src/commands/hub/init.ts b/packages/cli/src/commands/hub/init.ts index 16175e491a..c83ce12b09 100644 --- a/packages/cli/src/commands/hub/init.ts +++ b/packages/cli/src/commands/hub/init.ts @@ -191,8 +191,43 @@ export async function continueHubGuidedSetup( origin: string, environment: HubGuidedSetupEnvironment, ): Promise { - if (await requiredConfirm(environment, "Connect this daemon to this Hub?", true)) { - await ensureDaemonConnection(origin, environment, true); + const currentStatus = await withHubDaemon(environment.daemon, undefined, async (daemon) => + daemon.getHubStatus().then((response) => response.status), + ); + const current = resolveHubInitConnection(currentStatus, origin); + if (current.kind === "connected") { + reportMessage( + environment, + `This daemon is already connected to ${origin}. Permissions: ${ + currentStatus.permissions.join(", ") || "None" + }.`, + ); + } else if (current.kind === "pending") { + await waitForDaemonReady(origin, environment.daemon); + } else if (current.kind === "conflict") { + reportMessage( + environment, + `This daemon is connected to ${current.origin}. Disconnect it before connecting to ${origin}.`, + ); + } else if ( + await requiredConfirm( + environment, + "Connect this daemon to Paseo Hub?\n\nConnecting lets Hub identify this daemon and show whether it is online.\nIt does not allow Hub to create workspaces or run agents.", + true, + ) + ) { + const grantExecution = await requiredConfirm( + environment, + "Allow Hub automations to run agents on this daemon?\n\nThis lets workflows triggered from GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents here.\n\nAgents can access files and run commands allowed by their workspace runtime.", + false, + ); + await ensureDaemonConnection(origin, environment, true, grantExecution ? ["hub.execute"] : []); + if (!grantExecution) { + reportMessage( + environment, + "Daemon connected with no permissions.\n\nEnable Hub automations later:\n paseo hub permissions grant hub.execute", + ); + } } else { reportMessage( environment, @@ -252,12 +287,19 @@ async function ensureDaemonConnection( origin: string, environment: HubGuidedSetupEnvironment, confirmed = false, + permissions: readonly string[] = ["hub.execute"], ): Promise { const status = await withHubDaemon(environment.daemon, undefined, async (daemon) => daemon.getHubStatus().then((response) => response.status), ); const connection = resolveHubInitConnection(status, origin); if (connection.kind === "connected") { + if (permissions.includes("hub.execute") && !status.permissions.includes("hub.execute")) { + throw new HubCommandError( + "HUB_DAEMON_EXECUTION_NOT_ALLOWED", + "This daemon is connected to Hub but cannot run Hub automations. Run `paseo hub permissions grant hub.execute`, then run Hub init again.", + ); + } return connection.daemonId; } if (connection.kind === "pending") { @@ -271,20 +313,25 @@ async function ensureDaemonConnection( } if ( !confirmed && - !(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true)) + !(await requiredConfirm( + environment, + `Connect this daemon to ${origin} and allow Hub workflows to create and control workspaces and agents?`, + true, + )) ) { throw new HubInitCancelledError("A connected daemon is required to create the bundle."); } - return connectDaemon(origin, environment); + return connectDaemon(origin, environment, permissions); } async function connectDaemon( origin: string, environment: HubGuidedSetupEnvironment, + permissions: readonly string[] = ["hub.execute"], ): Promise { await runHubConnect( origin, - {}, + { permissions }, { env: environment.env, credentials: environment.credentials, diff --git a/packages/cli/src/commands/hub/permissions.ts b/packages/cli/src/commands/hub/permissions.ts new file mode 100644 index 0000000000..ca9214b3e3 --- /dev/null +++ b/packages/cli/src/commands/hub/permissions.ts @@ -0,0 +1,112 @@ +import type { Command } from "commander"; +import { withOutput, type ListResult, type OutputSchema } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import type { HubDaemonConnection, HubStatus } from "./daemon-client.js"; +import { withHubDaemon } from "./daemon-client.js"; +import { reportHubProgress, type HubReporter } from "./reporter.js"; +import { hubStatusResult } from "./status-output.js"; + +interface HubPermissionsOptions { + host?: string; + json?: boolean; +} + +interface HubPermissionsDependencies { + daemon: HubDaemonConnection; + reporter: HubReporter; +} + +interface PermissionRow { + permission: string; + description: string; +} + +const schema: OutputSchema = { + idField: "permission", + columns: [ + { header: "PERMISSION", field: "permission" }, + { header: "DESCRIPTION", field: "description" }, + ], +}; + +export function runHubPermissionsList( + options: HubPermissionsOptions, + dependencies: HubPermissionsDependencies, +): Promise> { + return withHubDaemon(dependencies.daemon, options.host, async (client) => { + const status = (await client.getHubStatus()).status; + requireConnectedHub(status); + return { + type: "list", + data: status.permissions.map((permission) => ({ + permission, + description: describePermission(permission), + })), + schema, + }; + }); +} + +export function runHubPermissionChange( + operation: "grant" | "revoke", + permission: string, + options: HubPermissionsOptions, + dependencies: HubPermissionsDependencies, +) { + return withHubDaemon(dependencies.daemon, options.host, async (client) => { + const current = (await client.getHubStatus()).status; + requireConnectedHub(current); + const response = await client.updateHubPermissions( + operation === "grant" ? { grant: [permission] } : { revoke: [permission] }, + ); + reportHubProgress( + dependencies.reporter, + options, + `${operation === "grant" ? "Granted" : "Revoked"} ${permission} ${ + operation === "grant" ? "to" : "from" + } ${response.status.hubOrigin}`, + ); + return hubStatusResult(response.status); + }); +} + +export function addHubPermissionsCommand( + parent: Command, + dependencies: HubPermissionsDependencies, +): void { + const permissions = parent + .command("permissions") + .description("Manage this Hub's daemon permissions"); + + addJsonAndDaemonHostOptions(permissions.command("list")).action( + withOutput(async (...args) => { + const options = args.at(-2) as HubPermissionsOptions; + return runHubPermissionsList(options, dependencies); + }), + ); + + for (const operation of ["grant", "revoke"] as const) { + addJsonAndDaemonHostOptions( + permissions.command(operation).argument("", "Daemon permission"), + ).action( + withOutput(async (...args) => { + const permission = args[0] as string; + const options = args.at(-2) as HubPermissionsOptions; + return runHubPermissionChange(operation, permission, options, dependencies); + }), + ); + } +} + +function requireConnectedHub(status: HubStatus): void { + if ( + status.hubOrigin === null || + (status.state !== "connected" && status.state !== "reconnecting") + ) { + throw new Error("This daemon is not connected to a Hub"); + } +} + +function describePermission(permission: string): string { + return permission === "hub.execute" ? "Run agents for Hub automations" : permission; +} diff --git a/packages/cli/src/commands/hub/status-output.ts b/packages/cli/src/commands/hub/status-output.ts index fcf4295193..cf353074f3 100644 --- a/packages/cli/src/commands/hub/status-output.ts +++ b/packages/cli/src/commands/hub/status-output.ts @@ -5,7 +5,7 @@ export interface HubRow { state: string; daemonId: string | null; hub: string | null; - scopes: string; + permissions: string; connectedAt: string | null; error: string | null; warning?: string; @@ -17,7 +17,7 @@ const schema: OutputSchema = { { header: "STATE", field: "state" }, { header: "HUB", field: "hub" }, { header: "DAEMON", field: "daemonId" }, - { header: "SCOPES", field: "scopes" }, + { header: "PERMISSIONS", field: "permissions" }, { header: "CONNECTED", field: "connectedAt" }, { header: "ERROR", field: "error" }, { header: "WARNING", field: "warning" }, @@ -36,7 +36,7 @@ export function hubStatusResult( state: status.state, daemonId: status.daemonId, hub: reportedHubOrigin, - scopes: status.scopes.join(", "), + permissions: status.permissions.join(", "), connectedAt: status.connectedAt, error: status.lastError, warning, diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 5c5c289eb7..0a194da2f9 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -309,7 +309,7 @@ export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteRe export interface DaemonClientConfig { url: string; clientId: string; - clientType?: "mobile" | "browser" | "cli" | "mcp"; + clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub"; appVersion?: string; runtimeGeneration?: number | null; password?: string; @@ -4680,15 +4680,36 @@ export class DaemonClient { }); } - async connectHub(hubUrl: string, token: string, requestId?: string) { + async connectHub( + hubUrl: string, + token: string, + permissions: readonly string[] = [], + requestId?: string, + ) { this.requireHubRelationshipSupport(); return this.sendCorrelatedSessionRequest({ requestId, - message: { type: "hub.management.daemon.connect.request", hubUrl, token }, + message: { type: "hub.management.daemon.connect.request", hubUrl, token, permissions }, responseType: "hub.management.daemon.connect.response", }); } + async updateHubPermissions( + input: { grant?: readonly string[]; revoke?: readonly string[] }, + requestId?: string, + ) { + this.requireHubRelationshipSupport(); + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "hub.management.daemon.permissions.update.request", + grant: input.grant ?? [], + revoke: input.revoke ?? [], + }, + responseType: "hub.management.daemon.permissions.update.response", + }); + } + async getHubStatus(requestId?: string) { this.requireHubRelationshipSupport(); return this.sendCorrelatedSessionRequest({ diff --git a/packages/protocol/src/messages.hub.test.ts b/packages/protocol/src/messages.hub.test.ts index 65cadb4db3..57f10ecdb9 100644 --- a/packages/protocol/src/messages.hub.test.ts +++ b/packages/protocol/src/messages.hub.test.ts @@ -335,9 +335,16 @@ describe("Hub session protocol", () => { requestId: "r1", hubUrl: "https://hub.example", token: "token", + permissions: [], }, { type: "hub.management.daemon.get_status.request", requestId: "r2" }, { type: "hub.management.daemon.disconnect.request", requestId: "r3", force: true }, + { + type: "hub.management.daemon.permissions.update.request", + requestId: "r4", + grant: ["hub.execute"], + revoke: [], + }, ])("accepts trusted management request $type", (message) => { expect(SessionInboundMessageSchema.parse(message)).toEqual(message); }); @@ -351,7 +358,7 @@ describe("Hub session protocol", () => { state: "connected", daemonId: "daemon-1", hubOrigin: "https://hub.example", - scopes: ["hub.execution.*"], + permissions: ["hub.execute"], connectedAt: "2026-07-13T00:00:00.000Z", lastError: null, }, @@ -365,7 +372,7 @@ describe("Hub session protocol", () => { state: "not_connected", daemonId: null, hubOrigin: null, - scopes: [], + permissions: [], connectedAt: null, lastError: null, }, @@ -379,13 +386,27 @@ describe("Hub session protocol", () => { state: "disconnecting", daemonId: "daemon-1", hubOrigin: "https://hub.example", - scopes: ["hub.execution.*"], + permissions: ["hub.execute"], connectedAt: null, lastError: "offline", }, warning: "pending", }, }, + { + type: "hub.management.daemon.permissions.update.response", + payload: { + requestId: "r4", + status: { + state: "connected", + daemonId: "daemon-1", + hubOrigin: "https://hub.example", + permissions: ["hub.execute"], + connectedAt: "2026-07-13T00:00:00.000Z", + lastError: null, + }, + }, + }, ])("accepts trusted management response $type", (message) => { expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); }); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 540f7af62c..35dd1e8ad0 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -93,6 +93,20 @@ export { // Mutable daemon config schemas (shared between server store and client) // --------------------------------------------------------------------------- +export const DAEMON_PERMISSIONS = [ + "daemon.read", + "daemon.manage", + "tunnel.manage", + "access.manage", + "workspace.read", + "workspace.write", + "workspace.manage", + "automation.manage", + "hub.execute", +] as const; +export const DaemonPermissionSchema = z.enum(DAEMON_PERMISSIONS); +export type DaemonPermission = z.infer; + const MutableDaemonProviderModelSchema = z .object({ id: z.string().min(1), @@ -1393,6 +1407,7 @@ export const HubManagementDaemonConnectRequestSchema = z.object({ requestId: z.string(), hubUrl: z.string(), token: z.string(), + permissions: z.array(DaemonPermissionSchema).default([]), }); export const HubManagementDaemonGetStatusRequestSchema = z.object({ type: z.literal("hub.management.daemon.get_status.request"), @@ -1403,6 +1418,12 @@ export const HubManagementDaemonDisconnectRequestSchema = z.object({ requestId: z.string(), force: z.boolean().optional(), }); +export const HubManagementDaemonPermissionsUpdateRequestSchema = z.object({ + type: z.literal("hub.management.daemon.permissions.update.request"), + requestId: z.string(), + grant: z.array(DaemonPermissionSchema).default([]), + revoke: z.array(DaemonPermissionSchema).default([]), +}); export const DiagnosticsRequestSchema = z.object({ type: z.literal("diagnostics.request"), @@ -3011,6 +3032,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ HubManagementDaemonConnectRequestSchema, HubManagementDaemonGetStatusRequestSchema, HubManagementDaemonDisconnectRequestSchema, + HubManagementDaemonPermissionsUpdateRequestSchema, DiagnosticsRequestSchema, PluginCatalogGetRequestSchema, PluginListRequestSchema, @@ -3325,6 +3347,8 @@ export const ServerInfoStatusPayloadSchema = z serverId: z.string().trim().min(1), hostname: ServerInfoHostnameSchema.optional(), version: ServerInfoVersionSchema.optional(), + // COMPAT(sessionPermissions): optional while clients support older daemons. + permissions: z.array(DaemonPermissionSchema).optional(), // COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16. desktopManaged: z.boolean().optional(), capabilities: ServerCapabilitiesFromUnknownSchema.optional(), @@ -4599,7 +4623,7 @@ export const HubRelationshipStatusSchema = z.object({ ]), daemonId: z.string().nullable(), hubOrigin: z.string().nullable(), - scopes: z.array(z.string()), + permissions: z.array(DaemonPermissionSchema), connectedAt: z.string().nullable(), lastError: z.string().nullable(), }); @@ -4619,6 +4643,10 @@ export const HubManagementDaemonDisconnectResponseSchema = z.object({ warning: z.string().optional(), }), }); +export const HubManagementDaemonPermissionsUpdateResponseSchema = z.object({ + type: z.literal("hub.management.daemon.permissions.update.response"), + payload: z.object({ requestId: z.string(), status: HubRelationshipStatusSchema }), +}); export const DaemonGetPairingOfferResponseSchema = z.object({ type: z.literal("daemon.get_pairing_offer.response"), @@ -6362,6 +6390,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ HubManagementDaemonConnectResponseSchema, HubManagementDaemonGetStatusResponseSchema, HubManagementDaemonDisconnectResponseSchema, + HubManagementDaemonPermissionsUpdateResponseSchema, DiagnosticsResponseSchema, GetDaemonConfigResponseMessageSchema, SetDaemonConfigResponseMessageSchema, @@ -6936,7 +6965,7 @@ export const WSPongMessageSchema = z.object({ export const WSHelloMessageSchema = z.object({ type: z.literal("hello"), clientId: z.string().min(1), - clientType: z.enum(["mobile", "browser", "cli", "mcp"]), + clientType: z.enum(["mobile", "browser", "cli", "mcp", "hub"]), protocolVersion: z.number().int(), appVersion: z.string().optional(), capabilities: z diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 2f76fe635c..7256a0669d 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -129,9 +129,9 @@ second line'`, const result = curateAgentActivity(timeline); - expect(result).toContain("[Exec Command]"); - expect(result).toContain("[Read File]"); - expect(result).toContain("[Web Search]"); + expect(result).toContain("[Exec command]"); + expect(result).toContain("[Read file]"); + expect(result).toContain("[Web search]"); expect(result).not.toContain("npm run lint"); expect(result).not.toContain("src/index.ts"); expect(result).not.toContain("zod union"); diff --git a/packages/server/src/server/authorization/index.test.ts b/packages/server/src/server/authorization/index.test.ts index 4d7f915393..6f5b2b717d 100644 --- a/packages/server/src/server/authorization/index.test.ts +++ b/packages/server/src/server/authorization/index.test.ts @@ -10,6 +10,7 @@ import { OWNER_PERMISSIONS, SessionAuthorization, permissionsForLegacyHubScopes, + parseDaemonPermissions, } from "./index.js"; function inboundOperationTypes(): SessionInboundMessage["type"][] { @@ -71,4 +72,9 @@ describe("SessionAuthorization", () => { ), ).toBe(true); }); + + test("permission parsing validates against the shared registry and removes duplicates", () => { + expect(parseDaemonPermissions(["hub.execute", "hub.execute"])).toEqual(["hub.execute"]); + expect(() => parseDaemonPermissions(["hub.execution.*"])).toThrow("Invalid daemon permission"); + }); }); diff --git a/packages/server/src/server/authorization/index.ts b/packages/server/src/server/authorization/index.ts index bb0e601bdf..7cddda6194 100644 --- a/packages/server/src/server/authorization/index.ts +++ b/packages/server/src/server/authorization/index.ts @@ -1,22 +1,23 @@ import type { SessionInboundMessage, SessionOutboundMessage } from "../messages.js"; +import { DAEMON_PERMISSIONS, type DaemonPermission } from "@getpaseo/protocol/messages"; import { requiredPermissionForInbound, requiredPermissionForOutbound, } from "./operation-permissions.js"; -export const DAEMON_PERMISSIONS = [ - "daemon.read", - "daemon.manage", - "tunnel.manage", - "access.manage", - "workspace.read", - "workspace.write", - "workspace.manage", - "automation.manage", - "hub.execute", -] as const; +export { DAEMON_PERMISSIONS, type DaemonPermission }; -export type DaemonPermission = (typeof DAEMON_PERMISSIONS)[number]; +const daemonPermissionSet: ReadonlySet = new Set(DAEMON_PERMISSIONS); + +export function isDaemonPermission(value: string): value is DaemonPermission { + return daemonPermissionSet.has(value); +} + +export function parseDaemonPermissions(values: readonly string[]): DaemonPermission[] { + const permissions = [...new Set(values)]; + if (!permissions.every(isDaemonPermission)) throw new Error("Invalid daemon permission"); + return permissions; +} export const OWNER_PERMISSIONS: readonly DaemonPermission[] = DAEMON_PERMISSIONS; @@ -39,6 +40,14 @@ export class SessionAuthorization { this.permissions = new Set(permissions); } + listPermissions(): DaemonPermission[] { + return [...this.permissions]; + } + + allowsPermission(permission: DaemonPermission): boolean { + return this.permissions.has(permission); + } + private allows(permission: DaemonPermission | null): boolean { return permission === null || this.permissions.has(permission); } diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index f91e0fe67c..624d356068 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -96,6 +96,7 @@ const INBOUND_PERMISSION = { "hub.management.daemon.connect.request": "tunnel.manage", "hub.management.daemon.disconnect.request": "tunnel.manage", "hub.management.daemon.get_status.request": "tunnel.manage", + "hub.management.daemon.permissions.update.request": "access.manage", import_agent_request: "workspace.write", kill_terminal_request: "workspace.write", list_available_editors_request: "workspace.read", @@ -305,6 +306,7 @@ const OUTBOUND_PERMISSION = { "hub.management.daemon.connect.response": "tunnel.manage", "hub.management.daemon.disconnect.response": "tunnel.manage", "hub.management.daemon.get_status.response": "tunnel.manage", + "hub.management.daemon.permissions.update.response": "access.manage", kill_terminal_response: "workspace.write", list_available_editors_response: "workspace.read", list_available_providers_response: "daemon.read", diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 47e3823a0b..54fb1e21d8 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -536,10 +536,13 @@ describe("paseo daemon bootstrap", () => { await enrollmentReleased; return { daemonId: input.daemonId, - scopes: input.scopes, + permissions: input.permissions, webSocketUrl: "wss://hub.test/daemon", }; }, + async updatePermissions(input) { + return { permissions: input.permissions }; + }, async revoke(_input: HubRevocation): Promise {}, openSocket(_input: HubSocketCredentials, _events: HubSocketEvents): HubSocketConnection { return { close: () => undefined }; diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index e3c62c9195..8188b7062f 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -671,7 +671,7 @@ export async function createPaseoDaemon( serviceProxy, onChange: createScriptStatusEmitter({ sessions: () => - wsServer?.listTrustedSessions().map((session) => ({ + wsServer?.listSessions().map((session) => ({ emit: (message) => session.emitServerMessage(message), })) ?? [], serviceProxy, @@ -951,7 +951,7 @@ export async function createPaseoDaemon( onWorkspaceArchived: teardownArchivedWorkspaceRuntime, onWorkspacesChanged: async (workspaceIds) => { await fanOutReconciledWorkspaceUpdates({ - sessions: wsServer?.listTrustedSessions() ?? [], + sessions: wsServer?.listSessions() ?? [], workspaceIds, logger, }); @@ -1015,20 +1015,20 @@ export async function createPaseoDaemon( }; const markWorkspaceArchivingExternal = (workspaceIds: Iterable, archivingAt: string) => { const workspaceIdList = Array.from(workspaceIds); - for (const session of wsServer?.listTrustedSessions() ?? []) { + for (const session of wsServer?.listSessions() ?? []) { session.markWorkspaceArchivingForExternalMutation(workspaceIdList, archivingAt); } }; const clearWorkspaceArchivingExternal = (workspaceIds: Iterable) => { const workspaceIdList = Array.from(workspaceIds); - for (const session of wsServer?.listTrustedSessions() ?? []) { + for (const session of wsServer?.listSessions() ?? []) { session.clearWorkspaceArchivingForExternalMutation(workspaceIdList); } }; const emitWorkspaceUpdatesExternal = async (workspaceIds: Iterable) => { const workspaceIdList = Array.from(workspaceIds); await Promise.all( - (wsServer?.listTrustedSessions() ?? []).map((session) => + (wsServer?.listSessions() ?? []).map((session) => session.emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIdList), ), ); @@ -1108,7 +1108,7 @@ export async function createPaseoDaemon( warmWorkspaceGitData: async (workspace) => { await Promise.all( wsServer - ?.listTrustedSessions() + ?.listSessions() .map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [], ); }, @@ -1206,7 +1206,27 @@ export async function createPaseoDaemon( createDaemonId: dependencies.createHubDaemonId, attachSocket: async (socket, options) => { if (!wsServer) throw new Error("WebSocket server is not running"); - await wsServer.attachHubSocket(socket, options); + await wsServer.attachExternalSocket( + socket, + { transport: "hub", hubDaemonId: options.daemonId }, + { + principalId: options.principalId, + permissions: options.permissions, + hubExecutionAgents: options.agents, + }, + options.sessionProtocol === "legacy" + ? { + type: "hello", + clientId: `hub:${options.daemonId}`, + clientType: "hub", + protocolVersion: 1, + } + : undefined, + ); + }, + updateAttachedPermissions: (principalId, permissions) => { + if (!wsServer) throw new Error("WebSocket server is not running"); + wsServer.updatePrincipalPermissions(principalId, permissions); }, createExecutionAgents: (daemonId) => new DaemonExecutions({ diff --git a/packages/server/src/server/hub/execution-session.websocket.test.ts b/packages/server/src/server/hub/execution-session.websocket.test.ts index 6a14cbae2b..925f1a6780 100644 --- a/packages/server/src/server/hub/execution-session.websocket.test.ts +++ b/packages/server/src/server/hub/execution-session.websocket.test.ts @@ -35,7 +35,7 @@ test("Hub retries one durable daemon execution across concurrency and reconstruc expect(reconstructed.durableAgentCount).toBe(1); }); -test("Hub denies trusted steering and browser dispatch", async () => { +test("Hub session applies its scoped authorization to ordinary protocol messages", async () => { const hub = await launchRelationship(); const localAgentId = await hub.createUnrelatedLocalAgent(); @@ -55,22 +55,34 @@ test("Hub denies trusted steering and browser dispatch", async () => { code: "access_denied", }); expect(hub.observedAgentIds()).not.toContain(localAgentId); - expect(hub.observedTrustedLifecycleMessages()).toEqual([]); + expect(hub.observedTrustedLifecycleMessages()).toEqual(["server_info"]); + expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]); }); -test("Hub sockets reject trusted hello and capabilities", async () => { +test("Hub completes the standard hello before rejecting a second hello", async () => { const hub = await launchRelationship(); + expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]); expect(hub.probeTrustedHello()).toBe(4002); }); -test("Hub sockets reject trusted binary frames", async () => { - const hub = await launchRelationship(); +test("legacy Hub wire behavior still enters the common Session bootstrap", async () => { + const launched = await HubRelationshipHarness.start(); + await launched.beginConnect().result; + launched.connectLatestLegacySocket(); + relationship = launched; - expect(hub.probeBinaryFrame()).toBe(4002); + expect(launched.observedTrustedLifecycleMessages()).toEqual(["server_info"]); + expect(launched.serverInfoPermissions()).toEqual([["hub.execute"]]); }); -test("Hub does not receive trusted broadcasts", async () => { +test("Hub binary frames enter the standard active-session path", async () => { + const hub = await launchRelationship(); + + expect(hub.probeBinaryFrame()).toBeNull(); +}); + +test("Hub receives standard server info but not broadcasts outside its scope", async () => { const hub = await launchRelationship(); const trustedBroadcasts = await hub.trustedBroadcastCount(); @@ -78,10 +90,10 @@ test("Hub does not receive trusted broadcasts", async () => { expect(trustedBroadcasts).toBe(0); expect(trustedStatus).toMatchObject({ pid: process.pid, relay: { enabled: false } }); - expect(hub.observedTrustedLifecycleMessages()).toEqual([]); + expect(hub.observedTrustedLifecycleMessages()).toEqual(["server_info"]); }); -test("Hub reconnects without retaining trusted session state", async () => { +test("Hub reconnects through the standard resumable session bootstrap", async () => { const hub = await launchRelationship(); const created = await hub.createOwnedConcurrently(); @@ -91,7 +103,8 @@ test("Hub reconnects without retaining trusted session state", async () => { executionId: "execution-1", agentId: created.first.agentId, }); - expect(hub.observedTrustedLifecycleMessages()).toEqual([]); + expect(hub.observedTrustedLifecycleMessages()).toEqual(["server_info", "server_info"]); + expect(hub.serverInfoPermissions()).toEqual([["hub.execute"], ["hub.execute"]]); }); test("Hub interrupts an owned running execution idempotently", async () => { diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index b4ecf9984e..0d091d66c1 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -98,27 +98,68 @@ describe("Hub relationship", () => { }); }); - test("Hub enrollment cannot widen the daemon's locally granted scopes", async () => { + test("new relationships can connect without granting Hub execution", async () => { relationship = await HubRelationshipHarness.start(); - relationship.returnEnrollmentScopes(["hub.execution.*", "*"]); + relationship.returnEnrollmentPermissions([]); - await relationship.beginConnect().result; + await relationship.beginConnect("registered-token", "https://hub.example", false).result; relationship.connectLatestSocket(); const responses = relationship.sendHubRequestOnLatest({ - type: "daemon.get_status.request", - requestId: "scope-escalation", + type: "hub.execution.agent.create.request", + requestId: "registered-execution", + executionId: "execution-1", + provider: "codex", + cwd: "/workspace", + prompt: "Do not run", }); - expect(relationship.relationshipFile()?.relationship.scopes).toEqual(["hub.execution.*"]); + expect(relationship.relationshipFile()?.relationship.permissions).toEqual([]); expect(responses).toContainEqual({ type: "rpc_error", payload: { - requestId: "scope-escalation", - requestType: "daemon.get_status.request", - error: "Session is not authorized for daemon.get_status.request", + requestId: "registered-execution", + requestType: "hub.execution.agent.create.request", + error: "Session is not authorized for hub.execution.agent.create.request", code: "access_denied", }, }); + + await relationship.grantPermission("hub.execute"); + expect(relationship.relationshipFile()?.relationship.permissions).toEqual(["hub.execute"]); + relationship.beginOwnedCreate("granted-execution", "execution-after-grant"); + await expect(relationship.ownedCreateResult("granted-execution")).resolves.toMatchObject({ + payload: { success: true, executionId: "execution-after-grant" }, + }); + + await relationship.revokePermission("hub.execute"); + expect(relationship.relationshipFile()?.relationship.permissions).toEqual([]); + const revokedResponses = relationship.sendHubRequestOnLatest({ + type: "hub.execution.agent.create.request", + requestId: "revoked-execution", + executionId: "execution-after-revoke", + provider: "codex", + cwd: "/workspace", + prompt: "Do not run", + }); + expect(revokedResponses).toContainEqual({ + type: "rpc_error", + payload: { + requestId: "revoked-execution", + requestType: "hub.execution.agent.create.request", + error: "Session is not authorized for hub.execution.agent.create.request", + code: "access_denied", + }, + }); + }); + + test("Hub enrollment cannot widen the daemon's locally granted permissions", async () => { + relationship = await HubRelationshipHarness.start(); + relationship.returnEnrollmentPermissions(["hub.execute", "daemon.manage"]); + + await relationship.beginConnect().result; + expect(relationship.relationshipFile()?.relationship.permissions).toEqual(["hub.execute"]); + expect((await relationship.status()).state).toBe("reconnecting"); + expect(relationship.socketAttempts()).toBe(0); }); test("a lost enrollment response reuses the exact ceremony", async () => { @@ -232,6 +273,33 @@ describe("Hub relationship", () => { }, ); + test("legacy execution scope migrates once to the semantic permission", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.corruptRelationshipFile( + JSON.stringify({ + version: 1, + state: "active", + relationship: { + daemonId: "daemon-legacy", + idempotencyKey: "ceremony-legacy", + hubOrigin: "https://hub.test", + createdAt: "2026-07-13T00:00:00.000Z", + scopes: ["hub.execution.*"], + }, + credential: { secret: "credential" }, + transport: { kind: "direct_websocket", webSocketUrl: "wss://hub.test/daemon" }, + }), + ); + + await relationship.startStoppedDaemon(); + + expect(relationship.relationshipFile()).toMatchObject({ + version: 2, + relationship: { permissions: ["hub.execute"] }, + }); + expect(await relationship.status()).toMatchObject({ permissions: "hub.execute" }); + }); + test.each([ ["a non-HTTP scheme", "ftp://hub.test"], ["embedded credentials", "https://user:password@hub.test"], @@ -264,12 +332,12 @@ describe("Hub relationship", () => { expect(relationship.enrollmentAttempts()).toEqual([]); }); - test("a persisted Hub relationship cannot widen its local execution scope", async () => { + test("a persisted Hub relationship cannot widen its local permissions", async () => { relationship = await HubRelationshipHarness.start(); await relationship.beginConnect().result; const persisted = relationship.relationshipFile(); expect(persisted).not.toBeNull(); - persisted!.relationship.scopes = ["*"]; + persisted!.relationship.permissions = ["*"]; await relationship.corruptRelationshipFile(JSON.stringify(persisted)); const socketAttemptsBeforeRestart = relationship.socketAttempts(); @@ -744,14 +812,14 @@ describe("Hub relationship", () => { state: "revoked", daemonId: enrollment.daemonId, hub: "https://hub.test", - scopes: "hub.execution.*", + permissions: "hub.execute", error: reason, }); expect(persisted?.state).toBe("revoked"); expect(persisted?.relationship).toMatchObject({ daemonId: enrollment.daemonId, hubOrigin: "https://hub.test", - scopes: ["hub.execution.*"], + permissions: ["hub.execute"], }); expect(persisted?.reason).toBe(reason); expect(persisted).not.toHaveProperty("credential"); diff --git a/packages/server/src/server/hub/relationship-controller.ts b/packages/server/src/server/hub/relationship-controller.ts index 8ce6f8050b..89a86ca6c3 100644 --- a/packages/server/src/server/hub/relationship-controller.ts +++ b/packages/server/src/server/hub/relationship-controller.ts @@ -5,6 +5,12 @@ import type pino from "pino"; import { z } from "zod"; import { ensurePrivateFile, writePrivateFileAtomicSync } from "../private-files.js"; import type { WebSocketLike } from "../websocket-server.js"; +import { + isDaemonPermission, + parseDaemonPermissions, + permissionsForLegacyHubScopes, + type DaemonPermission, +} from "../authorization/index.js"; import type { HubExecutionAgents } from "./daemon-executions.js"; import type { HubRelationshipRemote, @@ -15,8 +21,6 @@ import { HubEnrollmentRejectedError } from "./relationship-remote.js"; import { BoundedExponentialHubRetryPolicy } from "./relationship-retry.js"; const FILE_NAME = "hub-relationship.json"; -const HUB_EXECUTION_SCOPE = "hub.execution.*"; -const SCOPES = [HUB_EXECUTION_SCOPE] as const; const HubOriginSchema = z .string() .url() @@ -31,12 +35,22 @@ const HubOriginSchema = z } }); +const DaemonPermissionsSchema = z + .array(z.string()) + .superRefine((permissions, context) => { + for (const permission of permissions) { + if (!isDaemonPermission(permission)) { + context.addIssue({ code: "custom", message: `Invalid daemon permission: ${permission}` }); + } + } + }) + .transform(parseDaemonPermissions); const RelationshipSchema = z.object({ daemonId: z.string().min(1), idempotencyKey: z.string().min(1), hubOrigin: HubOriginSchema, createdAt: z.string(), - scopes: z.tuple([z.literal(HUB_EXECUTION_SCOPE)]), + permissions: DaemonPermissionsSchema, }); const SanitizedRelationshipSchema = RelationshipSchema.omit({ idempotencyKey: true }); const CredentialSchema = z.object({ secret: z.string().min(1) }); @@ -49,7 +63,7 @@ const TransportSchema = z.object({ .refine((value) => new URL(value).hash === ""), }); const PendingSchema = z.object({ - version: z.literal(1), + version: z.literal(2), state: z.literal("pending"), relationship: RelationshipSchema, credential: CredentialSchema, @@ -57,27 +71,27 @@ const PendingSchema = z.object({ identity: z.object({ serverId: z.string().min(1), daemonPublicKey: z.string().min(1) }), }); const ActiveSchema = z.object({ - version: z.literal(1), + version: z.literal(2), state: z.literal("active"), relationship: RelationshipSchema, credential: CredentialSchema, transport: TransportSchema, }); const DisconnectingSchema = z.object({ - version: z.literal(1), + version: z.literal(2), state: z.literal("disconnecting"), relationship: RelationshipSchema, credential: CredentialSchema, transport: TransportSchema.optional(), }); const RevokedSchema = z.object({ - version: z.literal(1), + version: z.literal(2), state: z.literal("revoked"), relationship: SanitizedRelationshipSchema, transport: TransportSchema.optional(), reason: z.string().optional(), }); -const RecordSchema = z +const CurrentRecordSchema = z .discriminatedUnion("state", [PendingSchema, ActiveSchema, DisconnectingSchema, RevokedSchema]) .superRefine((record, context) => { if (!("transport" in record) || !record.transport) return; @@ -91,6 +105,7 @@ const RecordSchema = z message: "Hub WebSocket URL must match the Hub origin", }); }); +const RecordSchema = z.preprocess(migrateLegacyRecord, CurrentRecordSchema); type PendingRecord = z.infer; type ActiveRecord = z.infer; type DisconnectingRecord = z.infer; @@ -109,13 +124,21 @@ export interface HubRelationshipStatus { state: HubConnectionState; daemonId: string | null; hubOrigin: string | null; - scopes: string[]; + permissions: DaemonPermission[]; connectedAt: string | null; lastError: string | null; } export interface HubRelationshipManagement { - connect(input: { hubUrl: string; token: string }): Promise; + connect(input: { + hubUrl: string; + token: string; + permissions: readonly string[]; + }): Promise; + updatePermissions(input: { + grant: readonly string[]; + revoke: readonly string[]; + }): Promise; status(): HubRelationshipStatus; disconnect(input: { force: boolean; @@ -147,8 +170,18 @@ export interface HubRelationshipControllerOptions { createDaemonId?: () => string; attachSocket: ( socket: WebSocketLike, - options: { daemonId: string; scopes: readonly string[]; agents: HubExecutionAgents }, + options: { + daemonId: string; + principalId: string; + permissions: readonly DaemonPermission[]; + agents: HubExecutionAgents; + sessionProtocol: "legacy" | "session-v1"; + }, ) => Promise; + updateAttachedPermissions: ( + principalId: string, + permissions: readonly DaemonPermission[], + ) => void; createExecutionAgents: (daemonId: string) => HubExecutionAgents; } @@ -241,13 +274,18 @@ export class HubRelationshipController implements HubRelationshipManagement { state: this.state, daemonId: this.record?.relationship.daemonId ?? null, hubOrigin: this.record?.relationship.hubOrigin ?? null, - scopes: this.record?.relationship.scopes.slice() ?? [], + permissions: this.record?.relationship.permissions.slice() ?? [], connectedAt: this.connectedAt, lastError: this.lastError, }; } - async connect(input: { hubUrl: string; token: string }): Promise { + async connect(input: { + hubUrl: string; + token: string; + permissions: readonly string[]; + }): Promise { + const permissions = parseDaemonPermissions(input.permissions); if (this.record?.state === "pending") { if (normalizeHubUrl(input.hubUrl) !== this.record.relationship.hubOrigin) { throw new Error("A pending Hub enrollment already exists for a different Hub"); @@ -264,14 +302,14 @@ export class HubRelationshipController implements HubRelationshipManagement { throw new Error("This daemon already has a Hub relationship"); } const pending: PendingRecord = { - version: 1, + version: 2, state: "pending", relationship: { daemonId: this.options.createDaemonId?.() ?? randomUUID(), idempotencyKey: randomUUID(), hubOrigin: normalizeHubUrl(input.hubUrl), createdAt: this.clock.now().toISOString(), - scopes: [...SCOPES], + permissions, }, credential: { secret: randomBytes(32).toString("base64url") }, enrollment: { token: input.token }, @@ -285,6 +323,37 @@ export class HubRelationshipController implements HubRelationshipManagement { return this.status(); } + async updatePermissions(input: { + grant: readonly string[]; + revoke: readonly string[]; + }): Promise { + if (!this.record || this.record.state !== "active") { + throw new Error("This daemon is not connected to a Hub"); + } + const grant = parseDaemonPermissions(input.grant); + const revoke = parseDaemonPermissions(input.revoke); + const permissions = parseDaemonPermissions([ + ...this.record.relationship.permissions.filter((permission) => !revoke.includes(permission)), + ...grant, + ]); + const result = await this.options.remote.updatePermissions({ + daemonId: this.record.relationship.daemonId, + hubOrigin: this.record.relationship.hubOrigin, + credential: this.record.credential.secret, + permissions, + }); + if (!samePermissions(result.permissions, permissions)) { + throw new Error("Hub permission response did not match the local grant"); + } + this.record = { + ...this.record, + relationship: { ...this.record.relationship, permissions }, + }; + this.persist(this.record); + this.options.updateAttachedPermissions(hubPrincipalId(this.record), permissions); + return this.status(); + } + async disconnect(input: { force: boolean; }): Promise<{ status: HubRelationshipStatus; warning?: string }> { @@ -299,7 +368,7 @@ export class HubRelationshipController implements HubRelationshipManagement { return { status: this.status() }; } const disconnecting: DisconnectingRecord = { - version: 1, + version: 2, state: "disconnecting", relationship: this.record.relationship, credential: this.record.credential, @@ -345,7 +414,7 @@ export class HubRelationshipController implements HubRelationshipManagement { serverId: pending.identity.serverId, daemonPublicKey: pending.identity.daemonPublicKey, credentialVerifier: verifier, - scopes: pending.relationship.scopes, + permissions: pending.relationship.permissions, }); const settled = request.then( () => undefined, @@ -357,12 +426,12 @@ export class HubRelationshipController implements HubRelationshipManagement { if (enrollmentGeneration !== this.enrollmentGeneration) return; if ( enrollment.daemonId !== pending.relationship.daemonId || - !enrollment.scopes.includes(HUB_EXECUTION_SCOPE) + !samePermissions(enrollment.permissions, pending.relationship.permissions) ) { throw new Error("Hub enrollment response did not match the pending relationship"); } const active: ActiveRecord = { - version: 1, + version: 2, state: "active", relationship: pending.relationship, credential: pending.credential, @@ -390,7 +459,8 @@ export class HubRelationshipController implements HubRelationshipManagement { const generation = ++this.generation; this.state = reconnecting ? "reconnecting" : "connecting"; const events: HubSocketEvents = { - connected: (socket) => this.socketConnected(generation, record, socket), + connected: (socket, sessionProtocol) => + this.socketConnected(generation, record, socket, sessionProtocol), rejected: (statusCode) => this.socketRejected(generation, statusCode), closed: (code) => this.socketClosed(generation, record, code), failed: (error) => this.socketFailed(generation, record, error), @@ -405,7 +475,12 @@ export class HubRelationshipController implements HubRelationshipManagement { ); } - private socketConnected(generation: number, record: ActiveRecord, socket: WebSocketLike): void { + private socketConnected( + generation: number, + record: ActiveRecord, + socket: WebSocketLike, + sessionProtocol: "legacy" | "session-v1", + ): void { if (generation !== this.generation) { socket.close(); return; @@ -416,8 +491,10 @@ export class HubRelationshipController implements HubRelationshipManagement { this.lastError = null; void this.options.attachSocket(socket, { daemonId: record.relationship.daemonId, - scopes: record.relationship.scopes, + principalId: hubPrincipalId(record), + permissions: record.relationship.permissions, agents: this.executionAgentsFor(record.relationship.daemonId), + sessionProtocol, }); } @@ -487,13 +564,13 @@ export class HubRelationshipController implements HubRelationshipManagement { this.cancelLifecycle(); if (!this.record) return; const revoked: RevokedRecord = { - version: 1, + version: 2, state: "revoked", relationship: { daemonId: this.record.relationship.daemonId, hubOrigin: this.record.relationship.hubOrigin, createdAt: this.record.relationship.createdAt, - scopes: this.record.relationship.scopes, + permissions: this.record.relationship.permissions, }, transport: "transport" in this.record ? this.record.transport : undefined, reason, @@ -536,7 +613,9 @@ export class HubRelationshipController implements HubRelationshipManagement { if (!existsSync(this.filePath)) return null; let record: HubRelationshipRecord; try { - record = RecordSchema.parse(JSON.parse(readFileSync(this.filePath, "utf8"))); + const raw = JSON.parse(readFileSync(this.filePath, "utf8")); + record = RecordSchema.parse(raw); + if (isLegacyRecord(raw)) this.persist(record); } catch (error) { const quarantinePath = path.join( path.dirname(this.filePath), @@ -554,3 +633,38 @@ export class HubRelationshipController implements HubRelationshipManagement { return record; } } + +function hubPrincipalId(record: ActiveRecord): string { + const credentialFingerprint = createHash("sha256") + .update(record.credential.secret) + .digest("base64url"); + return `hub:${record.relationship.daemonId}:${credentialFingerprint}`; +} + +function samePermissions(actual: readonly string[], expected: readonly string[]): boolean { + return actual.length === expected.length && expected.every((scope) => actual.includes(scope)); +} + +function isLegacyRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && Reflect.get(value, "version") === 1; +} + +function migrateLegacyRecord(value: unknown): unknown { + if (!isLegacyRecord(value)) return value; + const relationship = Reflect.get(value, "relationship"); + if (typeof relationship !== "object" || relationship === null) return value; + const scopes = Reflect.get(relationship, "scopes"); + if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string")) return value; + const { scopes: _legacyScopes, ...relationshipWithoutScopes } = relationship as Record< + string, + unknown + >; + return { + ...(value as Record), + version: 2, + relationship: { + ...relationshipWithoutScopes, + permissions: permissionsForLegacyHubScopes(scopes), + }, + }; +} diff --git a/packages/server/src/server/hub/relationship-remote.test.ts b/packages/server/src/server/hub/relationship-remote.test.ts index 8591ad8adc..452947d4c8 100644 --- a/packages/server/src/server/hub/relationship-remote.test.ts +++ b/packages/server/src/server/hub/relationship-remote.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; import { afterEach, expect, test } from "vitest"; -import { WebSocket } from "ws"; +import { WebSocket, WebSocketServer } from "ws"; import type { HubExecutionAgents } from "./daemon-executions.js"; import { HubRelationshipController, @@ -51,6 +51,87 @@ test("transient revocation failures remain retryable", async () => { ).rejects.toThrow("Hub revocation failed (503)"); }); +test("permission updates use the daemon credential and preserve semantic permissions", async () => { + let observed: { method?: string; authorization?: string; body?: unknown } = {}; + const server = createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += chunk; + observed = { + method: request.method, + authorization: request.headers.authorization, + body: JSON.parse(body), + }; + response + .writeHead(200, { "content-type": "application/json" }) + .end(JSON.stringify({ permissions: ["hub.execute"] })); + }); + openServers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + const hubOrigin = `http://127.0.0.1:${address.port}`; + + const result = await new DirectHubRelationshipRemote().updatePermissions({ + daemonId: "daemon-1", + hubOrigin, + credential: "daemon-secret", + permissions: ["hub.execute"], + }); + + expect(result).toEqual({ permissions: ["hub.execute"] }); + expect(observed).toEqual({ + method: "PATCH", + authorization: "Bearer daemon-secret", + body: { permissions: ["hub.execute"] }, + }); +}); + +test.each([ + { acknowledge: false, expected: "legacy" as const }, + { acknowledge: true, expected: "session-v1" as const }, +])( + "negotiates $expected Hub session startup during WebSocket upgrade", + async ({ acknowledge, expected }) => { + const server = createServer(); + const webSockets = new WebSocketServer({ noServer: true }); + if (acknowledge) { + webSockets.on("headers", (headers) => headers.push("x-paseo-session-protocol: 1")); + } + let offeredProtocol: string | string[] | undefined; + server.on("upgrade", (request, socket, head) => { + offeredProtocol = request.headers["x-paseo-session-protocol"]; + webSockets.handleUpgrade(request, socket, head, () => undefined); + }); + openServers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + const negotiated = deferred<"legacy" | "session-v1">(); + const socket = new DirectHubRelationshipRemote().openSocket( + { + daemonId: "daemon-1", + credential: "credential", + webSocketUrl: `ws://127.0.0.1:${address.port}/daemon`, + }, + { + connected: (_connected, protocol) => negotiated.resolve(protocol), + rejected: () => undefined, + closed: () => undefined, + failed: () => undefined, + }, + ); + + await expect(negotiated.promise).resolves.toBe(expected); + expect(offeredProtocol).toBe("1"); + socket.close(); + webSockets.close(); + }, +); + test.each([408, 429])("transient enrollment status %s remains retryable", async (status) => { const hubOrigin = await startHubReturning(status); const remote = new DirectHubRelationshipRemote(); @@ -65,7 +146,7 @@ test.each([408, 429])("transient enrollment status %s remains retryable", async serverId: "server-1", daemonPublicKey: "public-key", credentialVerifier: "verifier", - scopes: ["hub.execution.*"], + permissions: ["hub.execute"], }) .catch((caught: unknown) => caught); @@ -91,7 +172,7 @@ test("enrollment rejects a transport URL that cannot open a WebSocket", async () serverId: "server-1", daemonPublicKey: "public-key", credentialVerifier: "verifier", - scopes: ["hub.execution.*"], + permissions: ["hub.execute"], }), ).rejects.toThrow("Hub WebSocket URL must use ws or wss"); expect(enrolledHostname).toBe("test-daemon.local"); @@ -111,7 +192,7 @@ test("enrollment rejects a WebSocket URL with a fragment", async () => { serverId: "server-1", daemonPublicKey: "public-key", credentialVerifier: "verifier", - scopes: ["hub.execution.*"], + permissions: ["hub.execute"], }), ).rejects.toThrow("Hub WebSocket URL cannot include a fragment"); }); @@ -130,7 +211,7 @@ test("enrollment rejects a WebSocket outside the enrolled Hub authority", async serverId: "server-1", daemonPublicKey: "public-key", credentialVerifier: "verifier", - scopes: ["hub.execution.*"], + permissions: ["hub.execute"], }), ).rejects.toThrow("Hub WebSocket URL must match the Hub origin"); }); @@ -150,7 +231,7 @@ test.each(["enrollment", "revocation"])("%s HTTP calls are bounded", async (oper serverId: "server-1", daemonPublicKey: "public-key", credentialVerifier: "verifier", - scopes: ["hub.execution.*"], + permissions: ["hub.execute"], }) : remote.revoke({ daemonId: "daemon-1", @@ -311,7 +392,7 @@ async function startEnrollmentHub( response.writeHead(200, { "content-type": "application/json" }).end( JSON.stringify({ daemonId: enrollment.daemonId, - scopes: ["hub.execution.*"], + permissions: ["hub.execute"], webSocketUrl, }), ); @@ -402,7 +483,7 @@ class UpgradeRejectingHub { response.writeHead(200, { "content-type": "application/json" }).end( JSON.stringify({ daemonId: enrollment.daemonId, - scopes: ["hub.execution.*"], + permissions: ["hub.execute"], webSocketUrl: this.webSocketUrl, }), ); @@ -544,9 +625,14 @@ async function connectController( clock, retryPolicy: clock, attachSocket: async () => undefined, + updateAttachedPermissions: () => undefined, createExecutionAgents: () => unusedExecutionAgents, }); - await controller.connect({ hubUrl: hub.origin, token: "enrollment-token" }); + await controller.connect({ + hubUrl: hub.origin, + token: "enrollment-token", + permissions: ["hub.execute"], + }); return controller; } diff --git a/packages/server/src/server/hub/relationship-remote.ts b/packages/server/src/server/hub/relationship-remote.ts index a5321634fa..d0ae01089d 100644 --- a/packages/server/src/server/hub/relationship-remote.ts +++ b/packages/server/src/server/hub/relationship-remote.ts @@ -11,15 +11,22 @@ export interface HubEnrollment { serverId: string; daemonPublicKey: string; credentialVerifier: string; - scopes: string[]; + permissions: string[]; } export interface HubEnrollmentResult { daemonId: string; - scopes: string[]; + permissions: string[]; webSocketUrl: string; } +export interface HubPermissionUpdate { + daemonId: string; + hubOrigin: string; + credential: string; + permissions: string[]; +} + export interface HubRevocation { daemonId: string; hubOrigin: string; @@ -33,7 +40,7 @@ export interface HubSocketCredentials { } export interface HubSocketEvents { - connected(socket: WebSocketLike): void; + connected(socket: WebSocketLike, sessionProtocol: "legacy" | "session-v1"): void; rejected(statusCode: 401 | 403): void; closed(code: number): void; failed(error: Error): void; @@ -45,6 +52,7 @@ export interface HubSocketConnection { export interface HubRelationshipRemote { enroll(input: HubEnrollment): Promise; + updatePermissions(input: HubPermissionUpdate): Promise<{ permissions: string[] }>; revoke(input: HubRevocation): Promise; openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection; } @@ -58,7 +66,7 @@ export class HubEnrollmentRejectedError extends Error { const EnrollmentResultSchema = z.object({ daemonId: z.string(), - scopes: z.array(z.string()), + permissions: z.array(z.string()), webSocketUrl: z .string() .url() @@ -101,7 +109,7 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote { serverId: input.serverId, daemonPublicKey: input.daemonPublicKey, credentialVerifier: input.credentialVerifier, - scopes: input.scopes, + permissions: input.permissions, }), signal, }); @@ -117,6 +125,25 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote { }); } + async updatePermissions(input: HubPermissionUpdate): Promise<{ permissions: string[] }> { + return this.withRequestTimeout(async (signal) => { + const response = await fetch( + `${input.hubOrigin}/api/daemons/${encodeURIComponent(input.daemonId)}`, + { + method: "PATCH", + headers: { + "content-type": "application/json", + authorization: `Bearer ${input.credential}`, + }, + body: JSON.stringify({ permissions: input.permissions }), + signal, + }, + ); + if (!response.ok) throw new Error(`Hub permission update failed (${response.status})`); + return z.object({ permissions: z.array(z.string()) }).parse(await response.json()); + }); + } + async revoke(input: HubRevocation): Promise { await this.withRequestTimeout(async (signal) => { const response = await fetch( @@ -134,16 +161,23 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote { } openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection { + let sessionProtocol: "legacy" | "session-v1" = "legacy"; const socket = new WebSocket(input.webSocketUrl, { handshakeTimeout: this.requestTimeoutMs, headers: { authorization: `Bearer ${input.credential}`, "x-paseo-daemon-id": input.daemonId, + "x-paseo-session-protocol": "1", }, }); let settled = false; + socket.once("upgrade", (response) => { + if (response.headers["x-paseo-session-protocol"] === "1") { + sessionProtocol = "session-v1"; + } + }); socket.once("open", () => { - if (!settled) events.connected(socket as WebSocketLike); + if (!settled) events.connected(socket as WebSocketLike, sessionProtocol); }); socket.once("unexpected-response", (_request, response) => { if (settled) { diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index ec2d9c0e3f..d29dfe8062 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -85,7 +85,7 @@ interface PersistedRelationship { daemonId: string; idempotencyKey?: string; hubOrigin: string; - scopes: string[]; + permissions: string[]; }; credential?: { secret: string }; enrollment?: { token: string }; @@ -296,7 +296,7 @@ class InMemoryHubRelationships implements HubRelationshipRemote { private enrollmentObserved = deferred(); private socketObserved = deferred(); private enrollmentRejection: 401 | 403 | null = null; - private enrollmentScopes = ["hub.execution.*"]; + private enrollmentPermissions: string[] | null = null; private revokeFailures = 0; private revocationGate: Deferred | null = null; private readonly relationships = new Set(); @@ -313,13 +313,13 @@ class InMemoryHubRelationships implements HubRelationshipRemote { this.enrollmentRejection = statusCode; } - returnEnrollmentScopes(scopes: string[]): void { - this.enrollmentScopes = scopes.slice(); + returnEnrollmentPermissions(permissions: string[]): void { + this.enrollmentPermissions = permissions.slice(); } async enroll(input: HubEnrollment): Promise { this.enrollmentSnapshots.push(this.captureRelationship()); - this.enrollments.push({ ...input, scopes: input.scopes.slice() }); + this.enrollments.push({ ...input, permissions: input.permissions.slice() }); this.relationships.add(input.idempotencyKey); this.enrollmentObserved.resolve(); if (this.enrollmentRejection) { @@ -387,6 +387,15 @@ class InMemoryHubRelationships implements HubRelationshipRemote { } } + async updatePermissions(input: { + daemonId: string; + hubOrigin: string; + credential: string; + permissions: string[]; + }): Promise<{ permissions: string[] }> { + return { permissions: input.permissions.slice() }; + } + openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection { this.socketSnapshots.push(this.captureRelationship()); const attempt = { input: { ...input }, events, socket: new MemoryHubSocket() }; @@ -410,7 +419,7 @@ class InMemoryHubRelationships implements HubRelationshipRemote { private enrollmentResult(input: HubEnrollment): HubEnrollmentResult { return { daemonId: input.daemonId, - scopes: this.enrollmentScopes.slice(), + permissions: this.enrollmentPermissions?.slice() ?? input.permissions.slice(), webSocketUrl: SOCKET_URL, }; } @@ -505,9 +514,16 @@ export class HubRelationshipHarness { this.remote.holdEnrollment(); } - beginConnect(token = "ceremony-token", hubUrl = HUB_ORIGIN): CliProcess { + beginConnect(token = "ceremony-token", hubUrl = HUB_ORIGIN, execute = true): CliProcess { return { - result: this.runCli(["hub", "connect", hubUrl, "--api-key", `hub-contract-api-key:${token}`]), + result: this.runCli([ + "hub", + "connect", + hubUrl, + "--api-key", + `hub-contract-api-key:${token}`, + ...(execute ? ["--permission", "hub.execute"] : []), + ]), }; } @@ -519,6 +535,14 @@ export class HubRelationshipHarness { return this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]); } + async grantPermission(permission: string): Promise> { + return this.runCli(["hub", "permissions", "grant", permission]); + } + + async revokePermission(permission: string): Promise> { + return this.runCli(["hub", "permissions", "revoke", permission]); + } + beginDisconnect(force = false): CliProcess { return { result: this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]) }; } @@ -590,6 +614,7 @@ export class HubRelationshipHarness { requestId: "browser-hub-connect", hubUrl: HUB_ORIGIN, token: "browser-token", + permissions: [], }, }), ); @@ -606,8 +631,8 @@ export class HubRelationshipHarness { this.remote.completeEnrollment(); } - returnEnrollmentScopes(scopes: string[]): void { - this.remote.returnEnrollmentScopes(scopes); + returnEnrollmentPermissions(permissions: string[]): void { + this.remote.returnEnrollmentPermissions(permissions); } loseEnrollmentResponse(): void { @@ -652,7 +677,18 @@ export class HubRelationshipHarness { connectLatestSocket(): void { const socket = this.latestSocket(); - socket.events.connected(socket.socket); + socket.events.connected(socket.socket, "session-v1"); + socket.socket.receiveEnvelope({ + type: "hello", + clientId: `hub:${socket.input.daemonId}`, + clientType: "hub", + protocolVersion: 1, + }); + } + + connectLatestLegacySocket(): void { + const socket = this.latestSocket(); + socket.events.connected(socket.socket, "legacy"); } rejectLatestSocket(statusCode: 401 | 403): void { @@ -676,7 +712,13 @@ export class HubRelationshipHarness { connectSocket(index: number): void { const socket = this.remote.sockets[index]; if (!socket) throw new Error(`Socket ${index} does not exist`); - socket.events.connected(socket.socket); + socket.events.connected(socket.socket, "session-v1"); + socket.socket.receiveEnvelope({ + type: "hello", + clientId: `hub:${socket.input.daemonId}`, + clientType: "hub", + protocolVersion: 1, + }); } closeSocket(index: number, code: number): void { @@ -1143,6 +1185,16 @@ export class HubRelationshipHarness { ); } + serverInfoPermissions(): string[][] { + return this.remote.sockets.flatMap(({ socket }) => + socket.sent.flatMap((message) => + message.type === "status" && message.payload.status === "server_info" + ? [message.payload.permissions ?? []] + : [], + ), + ); + } + probeTrustedHello(): number | null { const socket = this.latestSocket().socket; socket.receiveEnvelope({ @@ -1234,7 +1286,10 @@ export class HubRelationshipHarness { } enrollmentAttempts(): HubEnrollment[] { - return this.remote.enrollments.map((input) => ({ ...input, scopes: input.scopes.slice() })); + return this.remote.enrollments.map((input) => ({ + ...input, + permissions: input.permissions.slice(), + })); } enrollmentInvocation(index = 0): RelationshipInvocationSnapshot { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8466cf027e..21a134f54d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1911,6 +1911,33 @@ export class Session { this.authorization.replacePermissions(permissions); } + public getPermissions(): DaemonPermission[] { + return this.authorization.listPermissions(); + } + + public allowsInbound(message: SessionInboundMessage): boolean { + return this.authorization.allowsInbound(message); + } + + public allowsPermission(permission: DaemonPermission): boolean { + return this.authorization.allowsPermission(permission); + } + + public subscribesToAgent(agent: ManagedAgent): Promise { + return this.agentUpdates.includesLiveAgent(agent); + } + + public subscribesToTerminalDirectory(input: { + cwd: string; + workspaceId?: string; + }): Promise { + return this.terminalController.hasDirectorySubscription(input); + } + + public publish(message: SessionOutboundMessage): void { + this.emit(message); + } + private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise { const promise = this.dispatchVoiceAndControlMessage(msg) ?? @@ -2341,6 +2368,7 @@ export class Session { case "hub.management.daemon.connect.request": case "hub.management.daemon.get_status.request": case "hub.management.daemon.disconnect.request": + case "hub.management.daemon.permissions.update.request": return this.daemonSession.handleHubRelationshipRequest(msg); case "diagnostics.request": return this.daemonSession.handleDiagnosticsRequest(msg); @@ -2635,6 +2663,9 @@ export class Session { } public async handleBinaryFrame(binaryFrame: BinaryFrame): Promise { + if (!this.authorization.allowsPermission("workspace.write")) { + return; + } if (binaryFrame.kind === "file_transfer") { await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame); return; diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts index 66d58042bf..c8b00332fe 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts @@ -617,6 +617,21 @@ describe("bootstrap buffering", () => { }); describe("subscription lifecycle", () => { + test("attention eligibility follows the active directory subscription filter", async () => { + const h = buildHarness(); + h.register(makeAgentPayload({ id: "matching", workspaceId: "ws-1", labels: { team: "a" } })); + h.register(makeAgentPayload({ id: "excluded", workspaceId: "ws-2", labels: { team: "b" } })); + + expect(await h.service.includesLiveAgent(h.managed("matching"))).toBe(false); + + h.service.beginSubscription({ subscriptionId: "sub", filter: { labels: { team: "a" } } }); + expect(await h.service.includesLiveAgent(h.managed("matching"))).toBe(true); + expect(await h.service.includesLiveAgent(h.managed("excluded"))).toBe(false); + + h.service.clearSubscription("sub"); + expect(await h.service.includesLiveAgent(h.managed("matching"))).toBe(false); + }); + test("flushBootstrapped is a no-op for a stale subscription id", async () => { const h = buildHarness(); h.service.beginSubscription({ subscriptionId: "sub", filter: {} }); diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.ts index 763275a395..61919378c5 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.ts @@ -49,6 +49,7 @@ export interface AgentUpdatesService { ): void; clearSubscription(subscriptionId: string): void; hasSubscription(): boolean; + includesLiveAgent(agent: ManagedAgent): Promise; forwardLiveAgent(agent: ManagedAgent): Promise; emitStoredRecord(record: StoredAgentRecord): Promise; removeAgent(agentId: string): Promise; @@ -243,6 +244,28 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU return subscription !== null; } + async function includesLiveAgent(agent: ManagedAgent): Promise { + const activeSubscription = subscription; + if (!activeSubscription) return false; + + const payload = await deps.enrichAgentPayload(toAgentPayload(agent)); + if (subscription !== activeSubscription || !deps.isProviderVisibleToClient(payload.provider)) { + return false; + } + const project = payload.workspaceId + ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) + : null; + return ( + subscription === activeSubscription && + project !== null && + matchesAgentUpdatesFilter({ + agent: payload, + project, + filter: activeSubscription.filter, + }) + ); + } + async function emitStoredRecord(record: StoredAgentRecord): Promise { const payload = deps.buildStoredAgentPayload(record); const sub = subscription; @@ -391,6 +414,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU flushBootstrapped, clearSubscription, hasSubscription, + includesLiveAgent, forwardLiveAgent, emitStoredRecord, removeAgent, diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index 1ef340ae6b..8c3aa2f167 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -110,7 +110,8 @@ export class DaemonSession { type: | "hub.management.daemon.connect.request" | "hub.management.daemon.get_status.request" - | "hub.management.daemon.disconnect.request"; + | "hub.management.daemon.disconnect.request" + | "hub.management.daemon.permissions.update.request"; } >, ): Promise { @@ -120,6 +121,7 @@ export class DaemonSession { const status = await this.hubRelationships.connect({ hubUrl: msg.hubUrl, token: msg.token, + permissions: msg.permissions, }); this.host.emit({ type: "hub.management.daemon.connect.response", @@ -127,6 +129,17 @@ export class DaemonSession { }); return; } + if (msg.type === "hub.management.daemon.permissions.update.request") { + const status = await this.hubRelationships.updatePermissions({ + grant: msg.grant, + revoke: msg.revoke, + }); + this.host.emit({ + type: "hub.management.daemon.permissions.update.response", + payload: { requestId: msg.requestId, status }, + }); + return; + } if (msg.type === "hub.management.daemon.disconnect.request") { const result = await this.hubRelationships.disconnect({ force: msg.force ?? false }); this.host.emit({ diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 9443187abb..15f4aa38d2 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -169,11 +169,13 @@ function createSessionWithActivity( appVisible: boolean; appVisibilityChangedAt?: Date; } | null, + subscribed = true, ) { return { getClientActivity: vi.fn(() => activity), supports: () => false, supportsForSource: () => false, + subscribesToAgent: vi.fn(async () => subscribed), }; } @@ -186,11 +188,12 @@ function connectClient( appVisible: boolean; appVisibilityChangedAt?: Date; } | null, + options: { subscribed?: boolean } = {}, ) { const ws = createOpenSocket(); asInternals(server).sessions.set(ws, { kind: "trusted", - session: createSessionWithActivity(activity), + session: createSessionWithActivity(activity, options.subscribed ?? true), clientId: "client-test", appVersion: null, connectionLogger: createLogger(), @@ -216,6 +219,30 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { vi.clearAllMocks(); }); + it("does not emit attention or include presence without an agent-directory subscription", async () => { + const { server, pushNotifications } = createServer(); + const now = new Date(); + const unsubscribed = connectClient( + server, + { + deviceType: "web", + appVisible: true, + focusedAgentId: "agent-1", + lastActivityAt: now, + }, + { subscribed: false }, + ); + + await asInternals(server).broadcastAgentAttention({ + agentId: "agent-1", + provider: "claude", + reason: "finished", + }); + + expect(unsubscribed.send).not.toHaveBeenCalled(); + expect(pushNotifications.sent).toHaveLength(1); + }); + it("uses assistant preview text for push notifications with markdown removed", async () => { const getLastAssistantMessage = vi.fn( async () => "**Done**. Updated `README.md` and [link](https://example.com).", diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 08d85b9a7d..3839263f52 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -56,6 +56,13 @@ const sessionMock = vi.hoisted(() => { clearAgentTimelineSubscription = vi.fn(); getClientActivity = vi.fn(() => null); getSessionId = vi.fn(() => "mock-session-id"); + getPermissions = vi.fn(() => this.args.permissions as string[]); + allowsInbound = vi.fn(() => true); + allowsPermission = vi.fn(() => true); + publish = vi.fn((message: unknown) => { + const onMessage = this.args.onMessage as ((message: unknown) => void) | undefined; + onMessage?.(message); + }); resetPeakInflight = vi.fn(() => {}); getRuntimeMetrics = vi.fn(() => ({ checkoutDiffTargetCount: 0, @@ -96,7 +103,7 @@ vi.mock("./push/index.js", () => ({ import { z } from "zod"; import { VoiceAssistantWebSocketServer } from "./websocket-server"; -import { parseServerInfoStatusPayload } from "./messages.js"; +import { DAEMON_PERMISSIONS, parseServerInfoStatusPayload } from "./messages.js"; import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js"; interface WebSocketServerInternals { @@ -676,6 +683,28 @@ describe("relay external socket reconnect behavior", () => { await server.close(); }); + test("isolates resumable sessions by principal while sharing hello bootstrap", async () => { + const server = createServer(); + const clientId = "shared-client-id"; + const ownerSocket = new MockSocket(); + const hubSocket = new MockSocket(); + + const ownerInfo = await attachRelayAndHello({ server, socket: ownerSocket, clientId }); + await server.attachExternalSocket( + hubSocket, + { transport: "hub", hubDaemonId: "daemon-1" }, + { principalId: "hub:daemon-1", permissions: ["hub.execute"] }, + ); + hubSocket.emit("message", JSON.stringify(createHelloMessage(clientId))); + const hubEnvelope = parseSentEnvelope(hubSocket.sent[0]); + const hubInfo = parseServerInfoStatusPayload(hubEnvelope.message?.payload); + + expect(sessionMock.instances).toHaveLength(2); + expect(ownerInfo.permissions).toEqual(DAEMON_PERMISSIONS); + expect(hubInfo?.permissions).toEqual(["hub.execute"]); + await server.close(); + }); + test("rejects session messages before hello", async () => { const server = createServer(); const socket = new MockSocket(); @@ -982,6 +1011,7 @@ describe("relay external socket reconnect behavior", () => { expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true); expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true); expect(serverInfo.features?.agentTurnIdentity).toBeUndefined(); + expect(serverInfo.permissions).toEqual(DAEMON_PERMISSIONS); await server.close(); }); diff --git a/packages/server/src/server/websocket-server.terminal-notifications.test.ts b/packages/server/src/server/websocket-server.terminal-notifications.test.ts index 8530c29b6f..0f4e7f25ff 100644 --- a/packages/server/src/server/websocket-server.terminal-notifications.test.ts +++ b/packages/server/src/server/websocket-server.terminal-notifications.test.ts @@ -192,12 +192,13 @@ function createOpenSocket() { }; } -function connectClient(server: VoiceAssistantWebSocketServer) { +function connectClient(server: VoiceAssistantWebSocketServer, subscribed = true) { const ws = createOpenSocket(); asInternals<{ sessions: Map }>(server).sessions.set(ws, { kind: "trusted", session: { getClientActivity: vi.fn(() => null), + subscribesToTerminalDirectory: vi.fn(async () => subscribed), }, clientId: "client-test", appVersion: null, @@ -274,6 +275,25 @@ describe("VoiceAssistantWebSocketServer terminal attention notifications", () => vi.clearAllMocks(); }); + it("does not emit attention without a matching terminal-directory subscription", async () => { + const { manager, emit } = createTerminalManager(); + const { server, pushNotifications } = createServer(manager); + const ws = connectClient(server, false); + + emit( + transition({ + previousState: "working", + previousChangedAt: 1000, + state: "idle", + changedAt: 11001, + }), + ); + await flushAsync(); + + expectNoTerminalAttentionMessage(ws); + expect(pushNotifications.sent).toHaveLength(1); + }); + it("broadcasts terminal_attention_required after working -> idle", async () => { const { manager, emit } = createTerminalManager(); const { server } = createServer(manager); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 008ec4e1f9..84e450ac33 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -98,11 +98,7 @@ import { import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; import { DirectorySyncService } from "./directory-sync/index.js"; -import { - OWNER_PERMISSIONS, - permissionsForLegacyHubScopes, - type DaemonPermission, -} from "./authorization/index.js"; +import { OWNER_PERMISSIONS, type DaemonPermission } from "./authorization/index.js"; import type { WorkspaceLabelService } from "./workspace-labels/index.js"; import { APPLICATION_SOCKET_LEASE_CHECK_INTERVAL_MS, @@ -117,20 +113,28 @@ import { const WS_CLOSE_DAEMON_AUTH_FAILED = 4401; export interface ExternalSocketMetadata { - transport: "relay"; + transport: "relay" | "hub"; externalSessionKey?: string; relayConnectionId?: string; + hubDaemonId?: string; +} + +export interface SessionAdmission { + principalId: string; + permissions: readonly DaemonPermission[]; + hubExecutionAgents?: HubExecutionAgents; } interface PendingConnection { connectionLogger: pino.Logger; helloTimeout: ReturnType | null; identity: WebSocketConnectionIdentity; + admission: SessionAdmission; } interface WebSocketConnectionIdentity { connectionId: string; - transport: "direct" | "relay"; + transport: "direct" | "relay" | "hub"; peer: "loopback" | "local_ipc" | "external"; browserOrigin: boolean; host?: string; @@ -138,6 +142,7 @@ interface WebSocketConnectionIdentity { userAgent?: string; remoteAddress?: string; relayConnectionId?: string; + hubDaemonId?: string; clientId?: string; sessionId?: string; appVersion?: string; @@ -433,9 +438,10 @@ export interface WebSocketLike { once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void; } -interface TrustedSessionConnectionBase { - kind: "trusted"; +interface SessionConnectionBase { session: Session; + principalId: string; + sessionKey: string; clientId: string; appVersion: string | null; clientCapabilities: Record | null; @@ -443,37 +449,17 @@ interface TrustedSessionConnectionBase { sockets: Set; } -interface ReconnectableSessionConnection extends TrustedSessionConnectionBase { +interface ReconnectableSessionConnection extends SessionConnectionBase { lifecycle: "reconnectable"; externalDisconnectCleanupTimeout: ReturnType | null; } -interface PluginSessionConnection extends TrustedSessionConnectionBase { +interface PluginSessionConnection extends SessionConnectionBase { lifecycle: "ephemeral-plugin"; pluginId: string; } -type TrustedSessionConnection = ReconnectableSessionConnection | PluginSessionConnection; - -interface HubConnection { - kind: "hub"; - session: Session; - daemonId: string; - connectionLogger: pino.Logger; - socket: WebSocketLike; -} - -type SessionConnection = TrustedSessionConnection | HubConnection; - -type TrustedLifecycleKey = - | "clientId" - | "appVersion" - | "clientCapabilities" - | "sockets" - | "externalDisconnectCleanupTimeout"; -type HubLifecycleOverlap = Extract; -const HUB_HAS_NO_TRUSTED_LIFECYCLE_STATE: HubLifecycleOverlap extends never ? true : never = true; -void HUB_HAS_NO_TRUSTED_LIFECYCLE_STATE; +type SessionConnection = ReconnectableSessionConnection | PluginSessionConnection; interface BrowserToolsRegistration { capabilitySignature: string; @@ -511,6 +497,10 @@ const WS_CLOSE_INCOMPATIBLE_PROTOCOL = 4003; const WS_CLOSE_SERVER_SHUTDOWN = 1001; const WS_PROTOCOL_VERSION = 1; const WS_RUNTIME_METRICS_FLUSH_MS = 30_000; +const OWNER_SESSION_ADMISSION: SessionAdmission = { + principalId: "owner", + permissions: OWNER_PERMISSIONS, +}; export class MissingDaemonVersionError extends Error { constructor() { @@ -929,26 +919,27 @@ export class VoiceAssistantWebSocketServer { } public broadcast(message: WSOutboundMessage): void { - const trustedSockets = [...this.sessions] - .filter(([, connection]) => connection.kind === "trusted") - .map(([ws]) => ws); - this.sendMessageToSockets(trustedSockets, message); + if (message.type === "session") { + for (const connection of new Set(this.sessions.values())) { + connection.session.publish(message.message); + } + return; + } + this.sendMessageToSockets(this.sessions.keys(), message); } - public listTrustedSessions(): Session[] { + public listSessions(): Session[] { return Array.from( new Set( - [...this.sessions.values(), ...this.externalSessionsByKey.values()] - .filter( - (connection): connection is TrustedSessionConnection => connection.kind === "trusted", - ) - .map((connection) => connection.session), + [...this.sessions.values(), ...this.externalSessionsByKey.values()].map( + (connection) => connection.session, + ), ), ); } public publishProjectUpdate(update: ProjectUpdate): void { - for (const session of this.listTrustedSessions()) { + for (const session of this.listSessions()) { void session .emitProjectUpdate(update) .catch((error) => this.logger.warn({ err: error }, "Failed to publish project update")); @@ -971,11 +962,13 @@ export class VoiceAssistantWebSocketServer { public async attachExternalSocket( ws: WebSocketLike, metadata?: ExternalSocketMetadata, + admission: SessionAdmission = OWNER_SESSION_ADMISSION, + initialHello?: WSHelloMessage, ): Promise { if (metadata?.transport === "relay") { this.incrementRuntimeCounter("relayExternalSocketAttached"); } - await this.attachSocket(ws, undefined, metadata); + await this.attachSocket(ws, undefined, metadata, false, admission, initialHello); } public async attachPluginSocket( @@ -1001,42 +994,21 @@ export class VoiceAssistantWebSocketServer { return { closed }; } - public async attachHubSocket( - ws: WebSocketLike, - options: { - daemonId: string; - scopes: readonly string[]; - agents: HubExecutionAgents; - }, - ): Promise { - if (this.connectionLifecycle !== "accepting") { - ws.close(WS_CLOSE_SERVER_SHUTDOWN, "Server shutting down"); - return; + public updatePrincipalPermissions( + principalId: string, + permissions: readonly DaemonPermission[], + ): void { + for (const pending of this.pendingConnections.values()) { + if (pending.admission.principalId === principalId) { + pending.admission = { ...pending.admission, permissions }; + } + } + for (const connection of new Set(this.externalSessionsByKey.values())) { + if (connection.principalId === principalId) { + connection.session.setPermissions(permissions); + this.syncBrowserToolsClientRegistration(connection); + } } - - const connectionLogger = this.logger.child({ - connectionKind: "hub", - daemonId: options.daemonId, - }); - const session = this.createSocketSession({ - clientId: `hub:${options.daemonId}`, - appVersion: null, - clientCapabilities: null, - permissions: permissionsForLegacyHubScopes(options.scopes), - connectionLogger, - onMessage: (message) => this.sendToClient(ws, wrapSessionMessage(message)), - hubExecutionAgents: options.agents, - }); - const connection: HubConnection = { - kind: "hub", - session, - daemonId: options.daemonId, - connectionLogger, - socket: ws, - }; - this.sessions.set(ws, connection); - this.bindSocketHandlers(ws); - connectionLogger.info("Hub session attached"); } public prepareForShutdown(): void { @@ -1085,18 +1057,13 @@ export class VoiceAssistantWebSocketServer { const cleanupPromises: Promise[] = []; for (const connection of uniqueConnections) { - if ( - connection.kind === "trusted" && - connection.lifecycle === "reconnectable" && - connection.externalDisconnectCleanupTimeout - ) { + if (connection.lifecycle === "reconnectable" && connection.externalDisconnectCleanupTimeout) { clearTimeout(connection.externalDisconnectCleanupTimeout); connection.externalDisconnectCleanupTimeout = null; } cleanupPromises.push(Promise.resolve(connection.session.cleanup())); - const sockets = connection.kind === "trusted" ? connection.sockets : [connection.socket]; - for (const ws of sockets) { + for (const ws of connection.sockets) { cleanupPromises.push( new Promise((resolve) => { // WebSocket.CLOSED = 3 @@ -1256,14 +1223,10 @@ export class VoiceAssistantWebSocketServer { } private sendToConnection(connection: SessionConnection, message: WSOutboundMessage): void { - const sockets = connection.kind === "trusted" ? connection.sockets : [connection.socket]; - this.sendMessageToSockets(sockets, message); + this.sendMessageToSockets(connection.sockets, message); } private sendBinaryToConnection(connection: SessionConnection, frame: Uint8Array): void { - if (connection.kind !== "trusted") { - return; - } for (const ws of connection.sockets) { this.sendBinaryToClient(ws, frame); } @@ -1274,6 +1237,8 @@ export class VoiceAssistantWebSocketServer { request?: unknown, metadata?: ExternalSocketMetadata, allowDuringStartup = false, + admission: SessionAdmission = OWNER_SESSION_ADMISSION, + initialHello?: WSHelloMessage, ): Promise { if ( this.connectionLifecycle === "stopping" || @@ -1296,6 +1261,7 @@ export class VoiceAssistantWebSocketServer { connectionLogger, helloTimeout: null, identity, + admission, }; const timeout = setTimeout(() => { if (this.pendingConnections.get(ws) !== pending) { @@ -1327,6 +1293,9 @@ export class VoiceAssistantWebSocketServer { }, "Client connected; awaiting hello", ); + if (initialHello) { + this.handleHello({ ws, message: initialHello, pending }); + } } private createSessionConnection(params: { @@ -1336,15 +1305,17 @@ export class VoiceAssistantWebSocketServer { clientCapabilities: Record | null; connectionLogger: pino.Logger; lifecycle: { kind: "reconnectable" } | { kind: "ephemeral-plugin"; pluginId: string }; - }): TrustedSessionConnection { - const { ws, clientId, appVersion, clientCapabilities, connectionLogger, lifecycle } = params; - let connection: TrustedSessionConnection | null = null; + admission: SessionAdmission; + }): SessionConnection { + const { ws, clientId, appVersion, clientCapabilities, connectionLogger, lifecycle, admission } = + params; + let connection: SessionConnection | null = null; const session = this.createSocketSession({ clientId, appVersion, clientCapabilities, - permissions: OWNER_PERMISSIONS, + permissions: admission.permissions, connectionLogger, onMessage: (msg) => { if (!connection) { @@ -1389,12 +1360,14 @@ export class VoiceAssistantWebSocketServer { onLifecycleIntent: (intent) => { this.onLifecycleIntent?.(intent); }, + hubExecutionAgents: admission.hubExecutionAgents, hubRelationships: this.hubRelationships ?? undefined, }); - const base: TrustedSessionConnectionBase = { - kind: "trusted", + const base: SessionConnectionBase = { session, + principalId: admission.principalId, + sessionKey: sessionConnectionKey(admission.principalId, clientId), clientId, appVersion, clientCapabilities, @@ -1424,7 +1397,7 @@ export class VoiceAssistantWebSocketServer { logger: options.connectionLogger.child({ module: "session" }), onWorkspaceRecovered: async (workspace) => { await Promise.all( - this.listTrustedSessions().map((activeSession) => + this.listSessions().map((activeSession) => activeSession.refreshRecoveredWorkspaceForExternalMutation(workspace), ), ); @@ -1564,7 +1537,8 @@ export class VoiceAssistantWebSocketServer { if (message.appVersion) { pending.identity.appVersion = message.appVersion; } - const existing = pluginId ? undefined : this.externalSessionsByKey.get(clientId); + const sessionKey = sessionConnectionKey(pending.admission.principalId, clientId); + const existing = pluginId ? undefined : this.externalSessionsByKey.get(sessionKey); if (existing) { this.resumeSession({ ws, message, pending, existing }); return; @@ -1579,14 +1553,15 @@ export class VoiceAssistantWebSocketServer { clientCapabilities: message.capabilities ?? null, connectionLogger, lifecycle: pluginId ? { kind: "ephemeral-plugin", pluginId } : { kind: "reconnectable" }, + admission: pending.admission, }); this.sessions.set(ws, connection); if (connection.lifecycle === "reconnectable") { - this.externalSessionsByKey.set(clientId, connection); + this.externalSessionsByKey.set(sessionKey, connection); } pending.identity.sessionId = connection.session.getSessionId(); this.syncBrowserToolsClientRegistration(connection); - this.sendToClient(ws, this.createServerInfoMessage()); + this.sendToClient(ws, this.createServerInfoMessage(connection.session)); connection.connectionLogger.info( { ...toConnectionLogFields(pending.identity), @@ -1630,7 +1605,7 @@ export class VoiceAssistantWebSocketServer { this.sessions.set(ws, existing); pending.identity.sessionId = existing.session.getSessionId(); this.syncBrowserToolsClientRegistration(existing); - this.sendToClient(ws, this.createServerInfoMessage()); + this.sendToClient(ws, this.createServerInfoMessage(existing.session)); pending.connectionLogger.info( { ...toConnectionLogFields(pending.identity), @@ -1641,12 +1616,13 @@ export class VoiceAssistantWebSocketServer { ); } - private buildServerInfoStatusPayload(): ServerInfoStatusPayload { + private buildServerInfoStatusPayload(session: Session): ServerInfoStatusPayload { return { status: "server_info", serverId: this.serverId, hostname: getHostname(), version: this.daemonVersion, + 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 } : {}), @@ -1785,12 +1761,12 @@ export class VoiceAssistantWebSocketServer { }; } - private createServerInfoMessage(): WSOutboundMessage { + private createServerInfoMessage(session: Session): WSOutboundMessage { return { type: "session", message: { type: "status", - payload: this.buildServerInfoStatusPayload(), + payload: this.buildServerInfoStatusPayload(session), }, }; } @@ -1806,7 +1782,9 @@ export class VoiceAssistantWebSocketServer { } private broadcastCapabilitiesUpdate(): void { - this.broadcast(this.createServerInfoMessage()); + for (const connection of new Set(this.sessions.values())) { + this.sendToConnection(connection, this.createServerInfoMessage(connection.session)); + } } private broadcastDaemonConfigChanged(config: MutableDaemonConfig): void { @@ -1892,22 +1870,12 @@ export class VoiceAssistantWebSocketServer { } this.sessions.delete(ws); - if (connection.kind === "hub") { - this.socketIdentities.delete(ws); - connection.connectionLogger.info( - { code: details.code, reason: stringifyCloseReason(details.reason) }, - "Hub session disconnected", - ); - connection.session.cleanup(); - this.finishPluginSocketCleanup(ws); - return; - } connection.sockets.delete(ws); connection.session.clearAgentTimelineSubscription(ws); this.socketIdentities.delete(ws); if (connection.sockets.size === 0) { - this.unregisterBrowserToolsClient(connection.clientId); + this.unregisterBrowserToolsClient(connection); if (connection.lifecycle === "ephemeral-plugin") { this.pluginSocketIds.delete(ws); await this.cleanupConnection(connection, "Plugin session disconnected"); @@ -1965,7 +1933,7 @@ export class VoiceAssistantWebSocketServer { } private async cleanupConnection( - connection: TrustedSessionConnection, + connection: SessionConnection, logMessage: string, ): Promise { this.incrementRuntimeCounter("sessionCleanup"); @@ -1980,12 +1948,12 @@ export class VoiceAssistantWebSocketServer { } connection.sockets.clear(); if (connection.lifecycle === "reconnectable") { - const existing = this.externalSessionsByKey.get(connection.clientId); + const existing = this.externalSessionsByKey.get(connection.sessionKey); if (existing === connection) { - this.externalSessionsByKey.delete(connection.clientId); + this.externalSessionsByKey.delete(connection.sessionKey); } } - this.unregisterBrowserToolsClient(connection.clientId); + this.unregisterBrowserToolsClient(connection); connection.connectionLogger.trace( { clientId: connection.clientId, totalSessions: this.sessions.size }, @@ -1994,45 +1962,51 @@ export class VoiceAssistantWebSocketServer { await connection.session.cleanup(); } - private syncBrowserToolsClientRegistration(connection: TrustedSessionConnection): void { + private syncBrowserToolsClientRegistration(connection: SessionConnection): void { if (!this.browserToolsBroker) { return; } + const registrationKey = connection.sessionKey; + if (!connection.session.allowsPermission("workspace.write")) { + this.unregisterBrowserToolsClient(registrationKey); + return; + } const browserHostCapability = getBrowserHostCapability(connection.clientCapabilities); if (!browserHostCapability) { - this.unregisterBrowserToolsClient(connection.clientId); + this.unregisterBrowserToolsClient(registrationKey); return; } const capabilitySignature = JSON.stringify(browserHostCapability); - const existing = this.browserToolsRegistrations.get(connection.clientId); + const existing = this.browserToolsRegistrations.get(registrationKey); if (existing?.capabilitySignature === capabilitySignature) { return; } if (existing) { - this.browserToolsRegistrations.delete(connection.clientId); + this.browserToolsRegistrations.delete(registrationKey); existing.unregister(); } const unregister = this.browserToolsBroker.registerClient({ - id: connection.clientId, + id: connection.principalId === "owner" ? connection.clientId : registrationKey, hostKind: browserHostCapability.hostKind, supportedCommands: browserHostCapability.supportedCommands, sendBrowserAutomationRequest: (request) => { this.sendToConnection(connection, wrapSessionMessage(request)); }, }); - this.browserToolsRegistrations.set(connection.clientId, { + this.browserToolsRegistrations.set(registrationKey, { capabilitySignature, unregister, }); } - private unregisterBrowserToolsClient(clientId: string): void { - const registration = this.browserToolsRegistrations.get(clientId); + private unregisterBrowserToolsClient(connection: SessionConnection | string): void { + const registrationKey = typeof connection === "string" ? connection : connection.sessionKey; + const registration = this.browserToolsRegistrations.get(registrationKey); if (!registration) { return; } - this.browserToolsRegistrations.delete(clientId); + this.browserToolsRegistrations.delete(registrationKey); registration.unregister(); } @@ -2070,7 +2044,7 @@ export class VoiceAssistantWebSocketServer { log.warn( { - clientId: activeConnection?.kind === "trusted" ? activeConnection.clientId : undefined, + clientId: activeConnection?.clientId, requestId: requestInfo?.requestId, requestType: requestInfo?.requestType, error: parsedMessage.error.message, @@ -2135,11 +2109,6 @@ export class VoiceAssistantWebSocketServer { } return true; } - if (activeConnection.kind === "hub") { - log.warn("Rejected binary frame on Hub session"); - ws.close(WS_CLOSE_INVALID_HELLO, "Binary frames are not supported on Hub sessions"); - return true; - } void Promise.resolve(activeConnection.session.handleBinaryFrame(decodedFrame)).catch( (error: unknown) => { this.handleRawMessageError({ @@ -2288,10 +2257,8 @@ export class VoiceAssistantWebSocketServer { let connectionFields: Record; if (identity) { connectionFields = toConnectionLogFields(identity); - } else if (activeConnection.kind === "trusted") { - connectionFields = { clientId: activeConnection.clientId }; } else { - connectionFields = { daemonId: activeConnection.daemonId }; + connectionFields = { clientId: activeConnection.clientId }; } activeConnection.connectionLogger.warn( { @@ -2301,10 +2268,11 @@ export class VoiceAssistantWebSocketServer { "ws_control_rpc_received", ); } - if ( - activeConnection.kind === "trusted" && - message.message.type === "browser.automation.execute.response" - ) { + if (message.message.type === "browser.automation.execute.response") { + if (!activeConnection.session.allowsInbound(message.message)) { + await activeConnection.session.handleMessage(message.message, ws); + return; + } this.browserToolsBroker?.receiveResponse(message.message as BrowserAutomationExecuteResponse); return; } @@ -2314,7 +2282,7 @@ export class VoiceAssistantWebSocketServer { const durationMs = performance.now() - startMs; this.recordRequestLatency(message.message.type, durationMs); - if (durationMs >= SLOW_REQUEST_THRESHOLD_MS && activeConnection.kind === "trusted") { + if (durationMs >= SLOW_REQUEST_THRESHOLD_MS) { activeConnection.connectionLogger.warn( { requestType: message.message.type, @@ -2426,9 +2394,7 @@ export class VoiceAssistantWebSocketServer { } private collectSessionRuntimeMetrics(): WebSocketRuntimeMetrics { - const uniqueConnections = new Set( - this.externalSessionsByKey.values(), - ); + const uniqueConnections = new Set(this.externalSessionsByKey.values()); let terminalDirectorySubscriptionCount = 0; let terminalSubscriptionCount = 0; let workspaceGitWatchedDirectoryCount = 0; @@ -2537,15 +2503,17 @@ export class VoiceAssistantWebSocketServer { provider: AgentProvider; reason: "finished" | "error" | "permission"; }): Promise { + const agent = this.agentManager.getAgent(params.agentId); + if (!agent?.workspaceId) { + return; + } const clientEntries: Array<{ ws: WebSocketLike; state: ClientPresenceState; }> = []; for (const [ws, connection] of this.sessions) { - if (connection.kind !== "trusted") { - continue; - } + if (!(await connection.session.subscribesToAgent(agent))) continue; clientEntries.push({ ws, state: this.getClientActivityState(connection.session), @@ -2554,10 +2522,6 @@ export class VoiceAssistantWebSocketServer { const allStates = clientEntries.map((e) => e.state); const nowMs = Date.now(); - const agent = this.agentManager.getAgent(params.agentId); - if (!agent?.workspaceId) { - return; - } const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId); const notification = buildAgentAttentionNotificationPayload({ reason: params.reason, @@ -2632,7 +2596,12 @@ export class VoiceAssistantWebSocketServer { }> = []; for (const [ws, connection] of this.sessions) { - if (connection.kind !== "trusted") { + if ( + !(await connection.session.subscribesToTerminalDirectory({ + cwd: params.cwd, + ...(params.workspaceId ? { workspaceId: params.workspaceId } : {}), + })) + ) { continue; } clientEntries.push({ @@ -2708,7 +2677,7 @@ function createWebSocketConnectionIdentity( ): WebSocketConnectionIdentity { return { connectionId: `conn_${randomUUID().replaceAll("-", "")}`, - transport: metadata?.transport === "relay" ? "relay" : "direct", + transport: metadata?.transport ?? "direct", peer: resolveConnectionPeer(requestMetadata, metadata), browserOrigin: requestMetadata.origin !== undefined, ...(requestMetadata.host ? { host: requestMetadata.host } : {}), @@ -2716,9 +2685,14 @@ function createWebSocketConnectionIdentity( ...(requestMetadata.userAgent ? { userAgent: requestMetadata.userAgent } : {}), ...(requestMetadata.remoteAddress ? { remoteAddress: requestMetadata.remoteAddress } : {}), ...(metadata?.relayConnectionId ? { relayConnectionId: metadata.relayConnectionId } : {}), + ...(metadata?.hubDaemonId ? { hubDaemonId: metadata.hubDaemonId } : {}), }; } +function sessionConnectionKey(principalId: string, clientId: string): string { + return JSON.stringify([principalId, clientId]); +} + function toConnectionLogFields(identity: WebSocketConnectionIdentity): Record { return { connectionId: identity.connectionId, @@ -2729,6 +2703,7 @@ function toConnectionLogFields(identity: WebSocketConnectionIdentity): Record { controller.dispatch({ type: "subscribe_terminals_request", cwd, workspaceId: "ws-a" }); controller.dispatch({ type: "subscribe_terminals_request", cwd, workspaceId: "ws-b" }); + await expect(controller.hasDirectorySubscription({ cwd, workspaceId: "ws-a" })).resolves.toBe( + true, + ); + await expect(controller.hasDirectorySubscription({ cwd, workspaceId: "ws-b" })).resolves.toBe( + true, + ); await flushMicrotasks(); outboundMessages.length = 0; // Tearing down workspace B must not drop workspace A's live subscription. controller.dispatch({ type: "unsubscribe_terminals_request", cwd, workspaceId: "ws-b" }); + await expect(controller.hasDirectorySubscription({ cwd, workspaceId: "ws-a" })).resolves.toBe( + true, + ); + await expect(controller.hasDirectorySubscription({ cwd, workspaceId: "ws-b" })).resolves.toBe( + false, + ); changedListener?.({ cwd, terminals: [{ id: "a", name: "A", cwd, workspaceId: "ws-a" }] }); await flushMicrotasks(); diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 05d544dcc2..50c4109d06 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -178,6 +178,19 @@ export class TerminalSessionController { }; } + async hasDirectorySubscription(input: { cwd: string; workspaceId?: string }): Promise { + const workspaceRoots = await this.listTerminalWorkspaceRoots(); + return Array.from(this.subscribedDirectories.values()).some((subscription) => { + if ( + subscription.workspaceId !== undefined && + subscription.workspaceId !== input.workspaceId + ) { + return false; + } + return this.terminalBelongsToRoot(subscription.cwd, input.cwd, workspaceRoots); + }); + } + dispatch(msg: SessionInboundMessage): Promise | undefined { if (!isTerminalMessage(msg)) { return undefined;