mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
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
This commit is contained in:
+10
-6
@@ -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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 <secret>", "Organization API key"),
|
||||
.option("--api-key <secret>", "Organization API key")
|
||||
.option("--permission <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));
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Record<string, string | undefined>>;
|
||||
@@ -80,6 +81,10 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {})
|
||||
daemon: environment.daemon,
|
||||
reporter: environment.reporter,
|
||||
});
|
||||
addHubPermissionsCommand(hub, {
|
||||
daemon: environment.daemon,
|
||||
reporter: environment.reporter,
|
||||
});
|
||||
addHubProjectsCommand(hub, {
|
||||
env: environment.env,
|
||||
credentials: environment.credentials,
|
||||
|
||||
@@ -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<string | undefined> = [];
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -191,8 +191,43 @@ export async function continueHubGuidedSetup(
|
||||
origin: string,
|
||||
environment: HubGuidedSetupEnvironment,
|
||||
): Promise<void> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
await runHubConnect(
|
||||
origin,
|
||||
{},
|
||||
{ permissions },
|
||||
{
|
||||
env: environment.env,
|
||||
credentials: environment.credentials,
|
||||
|
||||
@@ -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<PermissionRow> = {
|
||||
idField: "permission",
|
||||
columns: [
|
||||
{ header: "PERMISSION", field: "permission" },
|
||||
{ header: "DESCRIPTION", field: "description" },
|
||||
],
|
||||
};
|
||||
|
||||
export function runHubPermissionsList(
|
||||
options: HubPermissionsOptions,
|
||||
dependencies: HubPermissionsDependencies,
|
||||
): Promise<ListResult<PermissionRow>> {
|
||||
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("<permission>", "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;
|
||||
}
|
||||
@@ -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<HubRow> = {
|
||||
{ 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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<typeof DaemonPermissionSchema>;
|
||||
|
||||
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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> = 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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void> {},
|
||||
openSocket(_input: HubSocketCredentials, _events: HubSocketEvents): HubSocketConnection {
|
||||
return { close: () => undefined };
|
||||
|
||||
@@ -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<string>, 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<string>) => {
|
||||
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<string>) => {
|
||||
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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<typeof PendingSchema>;
|
||||
type ActiveRecord = z.infer<typeof ActiveSchema>;
|
||||
type DisconnectingRecord = z.infer<typeof DisconnectingSchema>;
|
||||
@@ -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<HubRelationshipStatus>;
|
||||
connect(input: {
|
||||
hubUrl: string;
|
||||
token: string;
|
||||
permissions: readonly string[];
|
||||
}): Promise<HubRelationshipStatus>;
|
||||
updatePermissions(input: {
|
||||
grant: readonly string[];
|
||||
revoke: readonly string[];
|
||||
}): Promise<HubRelationshipStatus>;
|
||||
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<void>;
|
||||
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<HubRelationshipStatus> {
|
||||
async connect(input: {
|
||||
hubUrl: string;
|
||||
token: string;
|
||||
permissions: readonly string[];
|
||||
}): Promise<HubRelationshipStatus> {
|
||||
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<HubRelationshipStatus> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown>),
|
||||
version: 2,
|
||||
relationship: {
|
||||
...relationshipWithoutScopes,
|
||||
permissions: permissionsForLegacyHubScopes(scopes),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<void>((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<void>((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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HubEnrollmentResult>;
|
||||
updatePermissions(input: HubPermissionUpdate): Promise<{ permissions: string[] }>;
|
||||
revoke(input: HubRevocation): Promise<void>;
|
||||
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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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<void>();
|
||||
private socketObserved = deferred<void>();
|
||||
private enrollmentRejection: 401 | 403 | null = null;
|
||||
private enrollmentScopes = ["hub.execution.*"];
|
||||
private enrollmentPermissions: string[] | null = null;
|
||||
private revokeFailures = 0;
|
||||
private revocationGate: Deferred<void> | null = null;
|
||||
private readonly relationships = new Set<string>();
|
||||
@@ -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<HubEnrollmentResult> {
|
||||
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<Record<string, unknown>> {
|
||||
return this.runCli(["hub", "permissions", "grant", permission]);
|
||||
}
|
||||
|
||||
async revokePermission(permission: string): Promise<Record<string, unknown>> {
|
||||
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 {
|
||||
|
||||
@@ -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<boolean> {
|
||||
return this.agentUpdates.includesLiveAgent(agent);
|
||||
}
|
||||
|
||||
public subscribesToTerminalDirectory(input: {
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<boolean> {
|
||||
return this.terminalController.hasDirectorySubscription(input);
|
||||
}
|
||||
|
||||
public publish(message: SessionOutboundMessage): void {
|
||||
this.emit(message);
|
||||
}
|
||||
|
||||
private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise<void> {
|
||||
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<void> {
|
||||
if (!this.authorization.allowsPermission("workspace.write")) {
|
||||
return;
|
||||
}
|
||||
if (binaryFrame.kind === "file_transfer") {
|
||||
await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame);
|
||||
return;
|
||||
|
||||
@@ -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: {} });
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface AgentUpdatesService {
|
||||
): void;
|
||||
clearSubscription(subscriptionId: string): void;
|
||||
hasSubscription(): boolean;
|
||||
includesLiveAgent(agent: ManagedAgent): Promise<boolean>;
|
||||
forwardLiveAgent(agent: ManagedAgent): Promise<void>;
|
||||
emitStoredRecord(record: StoredAgentRecord): Promise<AgentSnapshotPayload>;
|
||||
removeAgent(agentId: string): Promise<void>;
|
||||
@@ -243,6 +244,28 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
return subscription !== null;
|
||||
}
|
||||
|
||||
async function includesLiveAgent(agent: ManagedAgent): Promise<boolean> {
|
||||
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<AgentSnapshotPayload> {
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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({
|
||||
|
||||
@@ -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<WebSocketServerInternals>(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<WebSocketServerInternals>(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).",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -192,12 +192,13 @@ function createOpenSocket() {
|
||||
};
|
||||
}
|
||||
|
||||
function connectClient(server: VoiceAssistantWebSocketServer) {
|
||||
function connectClient(server: VoiceAssistantWebSocketServer, subscribed = true) {
|
||||
const ws = createOpenSocket();
|
||||
asInternals<{ sessions: Map<unknown, unknown> }>(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);
|
||||
|
||||
@@ -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<typeof setTimeout> | 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<string, unknown> | null;
|
||||
@@ -443,37 +449,17 @@ interface TrustedSessionConnectionBase {
|
||||
sockets: Set<WebSocketLike>;
|
||||
}
|
||||
|
||||
interface ReconnectableSessionConnection extends TrustedSessionConnectionBase {
|
||||
interface ReconnectableSessionConnection extends SessionConnectionBase {
|
||||
lifecycle: "reconnectable";
|
||||
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | 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<keyof HubConnection, TrustedLifecycleKey>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void>[] = [];
|
||||
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<void>((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<void> {
|
||||
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<string, unknown> | 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<void> {
|
||||
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<string, unknown>;
|
||||
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<TrustedSessionConnection>(
|
||||
this.externalSessionsByKey.values(),
|
||||
);
|
||||
const uniqueConnections = new Set<SessionConnection>(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<void> {
|
||||
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<string, string> {
|
||||
return {
|
||||
connectionId: identity.connectionId,
|
||||
@@ -2729,6 +2703,7 @@ function toConnectionLogFields(identity: WebSocketConnectionIdentity): Record<st
|
||||
...(identity.userAgent ? { userAgent: identity.userAgent } : {}),
|
||||
...(identity.remoteAddress ? { remoteAddress: identity.remoteAddress } : {}),
|
||||
...(identity.relayConnectionId ? { relayConnectionId: identity.relayConnectionId } : {}),
|
||||
...(identity.hubDaemonId ? { hubDaemonId: identity.hubDaemonId } : {}),
|
||||
...(identity.clientId ? { clientId: identity.clientId } : {}),
|
||||
...(identity.sessionId ? { sessionId: identity.sessionId } : {}),
|
||||
...(identity.appVersion ? { appVersion: identity.appVersion } : {}),
|
||||
@@ -2739,7 +2714,7 @@ function resolveConnectionPeer(
|
||||
requestMetadata: SocketRequestMetadata,
|
||||
metadata: ExternalSocketMetadata | undefined,
|
||||
): WebSocketConnectionIdentity["peer"] {
|
||||
if (metadata?.transport === "relay") return "external";
|
||||
if (metadata !== undefined) return "external";
|
||||
if (!requestMetadata.remoteAddress) return "local_ipc";
|
||||
return isLoopbackAddress(requestMetadata.remoteAddress) ? "loopback" : "external";
|
||||
}
|
||||
|
||||
@@ -617,11 +617,23 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => {
|
||||
|
||||
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();
|
||||
|
||||
@@ -178,6 +178,19 @@ export class TerminalSessionController {
|
||||
};
|
||||
}
|
||||
|
||||
async hasDirectorySubscription(input: { cwd: string; workspaceId?: string }): Promise<boolean> {
|
||||
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<void> | undefined {
|
||||
if (!isTerminalMessage(msg)) {
|
||||
return undefined;
|
||||
|
||||
Reference in New Issue
Block a user