diff --git a/docs/hub.md b/docs/hub.md index 8c6493504a..b4df317e76 100644 --- a/docs/hub.md +++ b/docs/hub.md @@ -28,12 +28,15 @@ manage their own relationship or permissions. ## Session grants and agent operations Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted -`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival, -agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is +`hub.execute` permission authorizes ordinary agent creation, workspace titling, messaging, +cancellation, archival, agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals, browser control, and permission management still require their own permissions. See [permissions.md](permissions.md). +Creating an agent may create a directory workspace. The same `hub.execute` session can title that +workspace through `workspace.title.set.request` without `workspace.manage`. + Clients using this contract check `server_info.features.hubAgentRpc` and `server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently fall back to creating a fresh agent when continuation was requested. diff --git a/docs/permissions.md b/docs/permissions.md index f4c8f6b048..93a70d6fb4 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre | `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts | | `workspace.manage` | Create, rename, archive, and remove projects and workspaces | | `automation.manage` | Schedules, heartbeats, and loops | -| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery | +| `hub.execute` | Agent lifecycle, workspace titling, observation, and recovery | Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce. diff --git a/packages/server/src/server/authorization/index.test.ts b/packages/server/src/server/authorization/index.test.ts index b711cb66c0..5230e51261 100644 --- a/packages/server/src/server/authorization/index.test.ts +++ b/packages/server/src/server/authorization/index.test.ts @@ -78,11 +78,15 @@ describe("SessionAuthorization", () => { const authorization = new SessionAuthorization(["hub.execute"]); for (const type of [ "create_agent_request", - "send_agent_message_request", + "workspace.title.set.request", + "fetch_agents_request", "fetch_agent_request", "agent.timeline.set_subscription.request", + "send_agent_message_request", "workspace.recovery.inspect.request", "workspace.recovery.restore.request", + "archive_workspace_request", + "cancel_agent_request", ] as const) { expect(authorization.allowsInbound(inboundMessage(type))).toBe(true); } @@ -90,8 +94,17 @@ describe("SessionAuthorization", () => { "status", "agent_update", "agent_stream", + "workspace_update", + "rpc_error", + "workspace.title.set.response", + "fetch_agents_response", + "fetch_agent_response", + "agent.timeline.set_subscription.response", "send_agent_message_response", + "workspace.recovery.inspect.response", "workspace.recovery.restore.response", + "archive_workspace_response", + "cancel_agent_response", ] as const) { expect(authorization.allowsOutbound(outboundMessage(type))).toBe(true); } diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 490cf872c7..7ce3947fa8 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -207,7 +207,7 @@ const INBOUND_PERMISSION = { "workspace.script.list.request": "workspace.read", "workspace.script.start.request": "workspace.write", "workspace.script.stop.request": "workspace.write", - "workspace.title.set.request": "workspace.manage", + "workspace.title.set.request": ["workspace.manage", "hub.execute"], workspace_setup_status_request: "workspace.read", "workspace.setup.run.request": "workspace.write", write_project_config_request: "workspace.write", @@ -430,7 +430,7 @@ const OUTBOUND_PERMISSION = { "workspace.script.list.response": "workspace.read", "workspace.script.start.response": "workspace.write", "workspace.script.stop.response": "workspace.write", - "workspace.title.set.response": "workspace.manage", + "workspace.title.set.response": ["workspace.manage", "hub.execute"], workspace_setup_progress: "workspace.read", workspace_setup_status_response: "workspace.read", "workspace.setup.run.response": "workspace.write", diff --git a/packages/server/src/server/hub/execution-session.websocket.test.ts b/packages/server/src/server/hub/execution-session.websocket.test.ts index 11e973c136..9154ea57fb 100644 --- a/packages/server/src/server/hub/execution-session.websocket.test.ts +++ b/packages/server/src/server/hub/execution-session.websocket.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, test } from "vitest"; +import type { SessionOutboundMessage } from "../messages.js"; import { HubRelationshipHarness } from "./test-utils/relationship-harness.js"; let relationship: HubRelationshipHarness | null = null; @@ -16,6 +17,13 @@ async function launchRelationship(): Promise { return launched; } +function createdAgentWorkspaceId(message: SessionOutboundMessage): string { + if (message.type !== "status" || message.payload.status !== "agent_created") + throw new Error("Agent was not created"); + if (!message.payload.agent.workspaceId) throw new Error("Workspace was not created"); + return message.payload.agent.workspaceId; +} + test("Hub retries one durable daemon execution across concurrency and reconstruction", async () => { const hub = await launchRelationship(); @@ -54,6 +62,46 @@ test("Hub execute can steer ordinary agents while unrelated administration stays expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]); }); +test("Hub execute can title the workspace created with an agent", async () => { + const hub = await launchRelationship(); + const created = await hub.requestOrdinary({ + type: "create_agent_request", + requestId: "create-for-title", + config: { provider: "codex", cwd: hub.repoRoot() }, + }); + expect(created).toMatchObject({ + type: "status", + payload: { status: "agent_created", agent: { workspaceId: expect.any(String) } }, + }); + const workspaceId = createdAgentWorkspaceId(created); + + expect( + await hub.requestOrdinary({ + type: "workspace.title.set.request", + requestId: "title-created-workspace", + workspaceId, + title: "Hub execution", + }), + ).toMatchObject({ + type: "workspace.title.set.response", + payload: { workspaceId, accepted: true, title: "Hub execution", error: null }, + }); + + expect( + await hub.requestOrdinary({ + type: "fetch_workspaces_request", + requestId: "read-created-workspace", + }), + ).toMatchObject({ + type: "fetch_workspaces_response", + payload: { + entries: expect.arrayContaining([ + expect.objectContaining({ id: workspaceId, title: "Hub execution" }), + ]), + }, + }); +}); + test("ordinary Hub create and message retries do not duplicate agents or prompts", async () => { const hub = await launchRelationship(); const create = {