Connect your Paseo daemon to Hub (#2035)

* feat(hub): connect daemons to Paseo Hub

Make Hub an explicit daemon-owned relationship with local-only management and scoped access to Hub-owned executions.

* fix(hub): harden relationship boundaries

* fix(hub): harden relationship lifecycle

* fix(hub): isolate CLI test entrypoint

* fix(hub): run CLI tests from workspace source

* fix(hub): settle failed relationship connections

* fix(hub): resume interrupted owned turns

Provider session rehydration does not continue foreground work lost during daemon shutdown. Persist narrowly scoped Hub execution intent and replay only an interrupted running initial turn.

* fix(hub): harden relationship lifecycle

Keep optional Hub authority from blocking daemon startup, revoke ambiguous enrollments durably, and close owned agents when their relationship no longer exists. Reject remote CLI connect targets before transmitting enrollment authority.

* fix(hub): stop replaying interrupted turns

Daemon restart cannot safely guarantee prompt idempotency across providers. Persist the normal closed session state while retaining Hub relationship, execution, and agent identity.

* fix(hub): fail creates when prompts cannot start

* fix: make Hub lifecycle cleanup deterministic

* fix(hub): preserve fresh enrollment authority

* fix(hub): contain enrollment retry failures

* fix(hub): reject invalid socket transport URLs

* fix(hub): bind socket transport to Hub authority

* fix(hub): close relationship lifecycle gaps

* test(hub): stabilize lifecycle coverage on Windows

* fix(hub): close execution authority races

* fix(hub): return relationship command errors

* fix(app): preserve workspace navigation compatibility

* fix(hub): correct relationship trust boundaries

Authenticated daemon sessions own relationship management regardless of transport. The separate Hub session remains operation-allowlisted, rejects malformed execution inputs, and uses bounded outbound handshakes.

* refactor(hub): authorize execution through sessions

* fix(hub): validate persisted origins

* fix(hub): retain local execution grants

* fix(hub): enforce session scope boundaries

Make session authority explicit and mutable without adding scope negotiation. Fence persisted Hub scopes and retire in-flight execution authority during cleanup and re-enrollment.

* fix(server): preserve main session compatibility
This commit is contained in:
Mohamed Boudra
2026-07-17 20:20:57 +02:00
committed by GitHub
parent 39cb3dbb9c
commit a414f8ea85
46 changed files with 5799 additions and 80 deletions

View File

@@ -0,0 +1,210 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";
import {
HubMessageCorrelationError,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
parseHubExecutionOutboundMessage,
} from "./messages.js";
const agent = {
id: "agent-1",
provider: "codex",
cwd: "/workspace",
model: null,
createdAt: "2026-07-13T00:00:00.000Z",
updatedAt: "2026-07-13T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
labels: {},
};
// Frozen at the Hub create request shape shipped before worktree and autoArchive.
const PreviousHubAgentCreateRequestSchema = z.object({
type: z.literal("hub.execution.agent.create.request"),
requestId: z.string(),
executionId: z.string(),
provider: z.string(),
cwd: z.string(),
workspaceId: z.string().optional(),
prompt: z.string(),
model: z.string().optional(),
modeId: z.string().optional(),
thinkingOptionId: z.string().optional(),
featureValues: z.record(z.string(), z.unknown()).optional(),
env: z.record(z.string(), z.string()).optional(),
});
describe("Hub session protocol", () => {
test("accepts the Hub execution create request", () => {
const message = {
type: "hub.execution.agent.create.request",
requestId: "request-1",
executionId: "execution-1",
provider: "codex",
cwd: "/workspace",
prompt: "Implement the requested change",
modeId: "code",
};
expect(SessionInboundMessageSchema.parse(message)).toEqual(message);
});
test.each([
undefined,
{ mode: "branch-off", newBranch: "hub-work", base: "main" },
{ mode: "checkout-branch", branch: "existing-work" },
{ mode: "checkout-pr", prNumber: 42 },
])("accepts Hub create worktree target %#", (worktree) => {
const message = {
type: "hub.execution.agent.create.request",
requestId: "hub-worktree",
executionId: "execution-worktree",
provider: "codex",
cwd: "/repo",
prompt: "Work in the requested target",
...(worktree ? { worktree, autoArchive: true } : {}),
};
expect(SessionInboundMessageSchema.parse(message)).toEqual(message);
});
test("the previous Hub create parser ignores additive worktree and auto-archive fields", () => {
const newRequest = {
type: "hub.execution.agent.create.request" as const,
requestId: "hub-worktree",
executionId: "execution-worktree",
provider: "codex",
cwd: "/repo",
prompt: "Work in the requested target",
worktree: { mode: "branch-off", newBranch: "hub-work", base: "main" },
autoArchive: true,
};
expect(PreviousHubAgentCreateRequestSchema.parse(newRequest)).toEqual({
type: "hub.execution.agent.create.request",
requestId: "hub-worktree",
executionId: "execution-worktree",
provider: "codex",
cwd: "/repo",
prompt: "Work in the requested target",
});
});
test.each([
{
type: "hub.execution.agent.create.response",
payload: {
requestId: "request-1",
executionId: "execution-1",
agentId: "agent-1",
agent,
success: true,
error: null,
},
},
{
type: "hub.execution.agent.update",
payload: { executionId: "execution-1", agentId: "agent-1", agent },
},
{
type: "hub.execution.agent.stream",
payload: {
executionId: "execution-1",
agentId: "agent-1",
event: { type: "turn_started", provider: "codex" },
},
},
])("accepts outbound variant $type", (message) => {
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
expect(parseHubExecutionOutboundMessage(message)).toEqual(message);
});
test("rejects a Hub update whose correlated agent ids disagree", () => {
const malformed = {
type: "hub.execution.agent.update",
payload: { executionId: "execution-1", agentId: "agent-2", agent },
};
expect(SessionOutboundMessageSchema.safeParse(malformed).success).toBe(true);
expect(() => parseHubExecutionOutboundMessage(malformed)).toThrow(HubMessageCorrelationError);
});
test.each([
{
type: "hub.management.daemon.connect.request",
requestId: "r1",
hubUrl: "https://hub.example",
token: "token",
},
{ type: "hub.management.daemon.get_status.request", requestId: "r2" },
{ type: "hub.management.daemon.disconnect.request", requestId: "r3", force: true },
])("accepts trusted management request $type", (message) => {
expect(SessionInboundMessageSchema.parse(message)).toEqual(message);
});
test.each([
{
type: "hub.management.daemon.connect.response",
payload: {
requestId: "r1",
status: {
state: "connected",
daemonId: "daemon-1",
hubOrigin: "https://hub.example",
scopes: ["hub.execution.*"],
connectedAt: "2026-07-13T00:00:00.000Z",
lastError: null,
},
},
},
{
type: "hub.management.daemon.get_status.response",
payload: {
requestId: "r2",
status: {
state: "not_connected",
daemonId: null,
hubOrigin: null,
scopes: [],
connectedAt: null,
lastError: null,
},
},
},
{
type: "hub.management.daemon.disconnect.response",
payload: {
requestId: "r3",
status: {
state: "disconnecting",
daemonId: "daemon-1",
hubOrigin: "https://hub.example",
scopes: ["hub.execution.*"],
connectedAt: null,
lastError: "offline",
},
warning: "pending",
},
},
])("accepts trusted management response $type", (message) => {
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
});
});

View File

@@ -1135,6 +1135,22 @@ export const DaemonGetPairingOfferRequestSchema = z.object({
requestId: z.string(),
});
export const HubManagementDaemonConnectRequestSchema = z.object({
type: z.literal("hub.management.daemon.connect.request"),
requestId: z.string(),
hubUrl: z.string(),
token: z.string(),
});
export const HubManagementDaemonGetStatusRequestSchema = z.object({
type: z.literal("hub.management.daemon.get_status.request"),
requestId: z.string(),
});
export const HubManagementDaemonDisconnectRequestSchema = z.object({
type: z.literal("hub.management.daemon.disconnect.request"),
requestId: z.string(),
force: z.boolean().optional(),
});
export const DiagnosticsRequestSchema = z.object({
type: z.literal("diagnostics.request"),
requestId: z.string(),
@@ -2312,7 +2328,27 @@ export const CaptureTerminalRequestSchema = z.object({
requestId: z.string(),
});
export const HubExecutionAgentCreateRequestSchema = z.object({
type: z.literal("hub.execution.agent.create.request"),
requestId: z.string(),
executionId: z.string(),
provider: z.string(),
cwd: z.string(),
prompt: z.string(),
workspaceId: z.string().optional(),
model: z.string().optional(),
modeId: z.string().optional(),
thinkingOptionId: z.string().optional(),
featureValues: z.record(z.string(), z.unknown()).optional(),
env: z.record(z.string(), z.string()).optional(),
worktree: CreateAgentWorktreeTargetSchema.optional(),
autoArchive: z.boolean().optional(),
});
export type HubExecutionAgentCreateRequest = z.infer<typeof HubExecutionAgentCreateRequestSchema>;
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
HubExecutionAgentCreateRequestSchema,
BrowserAutomationExecuteResponseSchema,
VoiceAudioChunkMessageSchema,
AbortRequestMessageSchema,
@@ -2337,6 +2373,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
WaitForFinishRequestSchema,
DaemonGetStatusRequestSchema,
DaemonGetPairingOfferRequestSchema,
HubManagementDaemonConnectRequestSchema,
HubManagementDaemonGetStatusRequestSchema,
HubManagementDaemonDisconnectRequestSchema,
DiagnosticsRequestSchema,
GetDaemonConfigRequestMessageSchema,
SetDaemonConfigRequestMessageSchema,
@@ -2669,6 +2708,8 @@ export const ServerInfoStatusPayloadSchema = z
providerSubagents: z.boolean().optional(),
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
workspacePinning: z.boolean().optional(),
// COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X.
hubRelationship: z.boolean().optional(),
// COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15.
projectGithubClone: z.boolean().optional(),
// COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15.
@@ -3598,6 +3639,38 @@ export const DaemonGetStatusResponseSchema = z.object({
.passthrough(),
});
export const HubRelationshipStatusSchema = z.object({
state: z.enum([
"not_connected",
"connecting",
"connected",
"reconnecting",
"disconnecting",
"revoked",
]),
daemonId: z.string().nullable(),
hubOrigin: z.string().nullable(),
scopes: z.array(z.string()),
connectedAt: z.string().nullable(),
lastError: z.string().nullable(),
});
export const HubManagementDaemonConnectResponseSchema = z.object({
type: z.literal("hub.management.daemon.connect.response"),
payload: z.object({ requestId: z.string(), status: HubRelationshipStatusSchema }),
});
export const HubManagementDaemonGetStatusResponseSchema = z.object({
type: z.literal("hub.management.daemon.get_status.response"),
payload: z.object({ requestId: z.string(), status: HubRelationshipStatusSchema }),
});
export const HubManagementDaemonDisconnectResponseSchema = z.object({
type: z.literal("hub.management.daemon.disconnect.response"),
payload: z.object({
requestId: z.string(),
status: HubRelationshipStatusSchema,
warning: z.string().optional(),
}),
});
export const DaemonGetPairingOfferResponseSchema = z.object({
type: z.literal("daemon.get_pairing_offer.response"),
payload: z
@@ -4837,9 +4910,76 @@ export const DaemonUpdateProgressMessageSchema = z.object({
}),
});
export const HubExecutionAgentCreateResponseSchema = z.object({
type: z.literal("hub.execution.agent.create.response"),
payload: z.object({
requestId: z.string(),
executionId: z.string(),
agentId: z.string().nullable(),
agent: AgentSnapshotPayloadSchema.nullable(),
success: z.boolean(),
error: z.string().nullable(),
}),
});
export const HubExecutionAgentUpdateSchema = z.object({
type: z.literal("hub.execution.agent.update"),
payload: z.object({
executionId: z.string(),
agentId: z.string(),
agent: AgentSnapshotPayloadSchema,
}),
});
export const HubExecutionAgentStreamSchema = z.object({
type: z.literal("hub.execution.agent.stream"),
payload: z.object({
executionId: z.string(),
agentId: z.string(),
event: AgentStreamEventPayloadSchema,
}),
});
export type HubExecutionAgentCreateResponse = z.infer<typeof HubExecutionAgentCreateResponseSchema>;
export type HubExecutionAgentUpdate = z.infer<typeof HubExecutionAgentUpdateSchema>;
export type HubExecutionAgentStream = z.infer<typeof HubExecutionAgentStreamSchema>;
export const HubExecutionOutboundMessageSchema = z.discriminatedUnion("type", [
HubExecutionAgentCreateResponseSchema,
HubExecutionAgentUpdateSchema,
HubExecutionAgentStreamSchema,
]);
export type HubExecutionOutboundMessage = z.infer<typeof HubExecutionOutboundMessageSchema>;
export class HubMessageCorrelationError extends Error {
constructor(messageType: HubExecutionOutboundMessage["type"]) {
super(`Hub message ${messageType} has mismatched agent correlation`);
this.name = "HubMessageCorrelationError";
}
}
export function parseHubExecutionOutboundMessage(value: unknown): HubExecutionOutboundMessage {
const message = HubExecutionOutboundMessageSchema.parse(value);
const payload = message.payload;
if (
"agent" in payload &&
payload.agent !== null &&
"agentId" in payload &&
payload.agentId !== null &&
payload.agent.id !== payload.agentId
) {
throw new HubMessageCorrelationError(message.type);
}
return message;
}
export type DaemonUpdateProgressMessage = z.infer<typeof DaemonUpdateProgressMessageSchema>;
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
HubExecutionAgentCreateResponseSchema,
HubExecutionAgentUpdateSchema,
HubExecutionAgentStreamSchema,
BrowserAutomationExecuteRequestSchema,
ActivityLogMessageSchema,
AssistantChunkMessageSchema,
@@ -4892,6 +5032,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SetVoiceModeResponseMessageSchema,
DaemonGetStatusResponseSchema,
DaemonGetPairingOfferResponseSchema,
HubManagementDaemonConnectResponseSchema,
HubManagementDaemonGetStatusResponseSchema,
HubManagementDaemonDisconnectResponseSchema,
DiagnosticsResponseSchema,
GetDaemonConfigResponseMessageSchema,
SetDaemonConfigResponseMessageSchema,