Let Hub title workspaces created by its executions (#5302)

* fix(server): let Hub title created workspaces

Agent creation can provision a workspace under hub.execute, but a separate title request required workspace.manage. Permit the same execution authority for title changes and their responses, and lock the Hub request contract with socket and permission tests.

* test(server): keep Hub title repro free of conditional flow
This commit is contained in:
Mohamed Boudra
2026-09-23 23:15:06 +02:00
committed by GitHub
parent d615d3d424
commit 067937bace
5 changed files with 70 additions and 6 deletions
+5 -2
View File
@@ -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.
+1 -1
View File
@@ -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.
@@ -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);
}
@@ -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",
@@ -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<HubRelationshipHarness> {
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 = {