mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 18:52:39 +00:00
feat(hosts): bind replica and session lifetime to origin authority
This commit is contained in:
@@ -768,6 +768,7 @@ export class AgentManager {
|
||||
private appendSystemPrompt: string;
|
||||
private onAgentAttention?: AgentAttentionCallback;
|
||||
private readonly agentArchivedListeners = new Set<AgentArchivedCallback>();
|
||||
private managedChildLifecycleOwner: AgentArchivedCallback | null = null;
|
||||
private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
|
||||
private logger: Logger;
|
||||
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
|
||||
@@ -874,10 +875,19 @@ export class AgentManager {
|
||||
return this.runJournal.get(agentId, submissionId);
|
||||
}
|
||||
|
||||
onAgentArchived(callback: AgentArchivedCallback): () => void {
|
||||
onAgentArchived(
|
||||
callback: AgentArchivedCallback,
|
||||
options?: { ownsManagedChildren: boolean },
|
||||
): () => void {
|
||||
if (options?.ownsManagedChildren) {
|
||||
if (this.managedChildLifecycleOwner)
|
||||
throw new Error("Managed child lifecycle already has an owner");
|
||||
this.managedChildLifecycleOwner = callback;
|
||||
}
|
||||
this.agentArchivedListeners.add(callback);
|
||||
return () => {
|
||||
this.agentArchivedListeners.delete(callback);
|
||||
if (this.managedChildLifecycleOwner === callback) this.managedChildLifecycleOwner = null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1904,6 +1914,7 @@ export class AgentManager {
|
||||
const parentAgentId = parent.id;
|
||||
const records = await registry.list();
|
||||
for (const record of records) {
|
||||
if (this.managedChildLifecycleOwner && !record.internal && !record.owner) continue;
|
||||
if (record.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
@@ -3683,6 +3694,7 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
await this.runJournal.restore(resolvedAgentId);
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
this.agents.set(resolvedAgentId, managed);
|
||||
registered = true;
|
||||
@@ -4395,22 +4407,14 @@ export class AgentManager {
|
||||
|
||||
const flags: StreamEventFlags = { shouldDispatchEvent: true, shouldNotifyWaiters: true };
|
||||
|
||||
const runResult = this.getRunResult(
|
||||
agent.id,
|
||||
eventTurnId,
|
||||
const checkpoint = this.captureLiveRunCheckpoint(
|
||||
agent,
|
||||
event,
|
||||
options?.fromHistory === true,
|
||||
eventTurnId,
|
||||
options,
|
||||
terminalDisposition,
|
||||
);
|
||||
if (!options?.fromHistory) {
|
||||
if (terminalDisposition !== "stale")
|
||||
await this.runJournal.capture(agent.id, eventTurnId, event, runResult).catch((error) => {
|
||||
this.logger.error(
|
||||
{ err: error, agentId: agent.id, turnId: eventTurnId },
|
||||
"Failed to persist provider checkpoint",
|
||||
);
|
||||
});
|
||||
}
|
||||
if (checkpoint) await checkpoint;
|
||||
const dispatchPromise = this.dispatchStreamEventByType({
|
||||
agent,
|
||||
event,
|
||||
@@ -4442,6 +4446,23 @@ export class AgentManager {
|
||||
return flags.shouldNotifyWaiters;
|
||||
}
|
||||
|
||||
private captureLiveRunCheckpoint(
|
||||
agent: ActiveManagedAgent,
|
||||
event: AgentStreamEvent,
|
||||
turnId: string | undefined,
|
||||
options: HandleStreamEventOptions | undefined,
|
||||
disposition: ActiveTurnTerminalDisposition,
|
||||
): Promise<void> | undefined {
|
||||
if (options?.fromHistory || disposition === "stale") return undefined;
|
||||
const result = this.getRunResult(agent.id, turnId, event, false, disposition);
|
||||
return this.runJournal.capture(agent.id, turnId, event, result)?.catch((error) => {
|
||||
this.logger.error(
|
||||
{ err: error, agentId: agent.id, turnId },
|
||||
"Failed to persist provider checkpoint",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private getRunResult(
|
||||
agentId: string,
|
||||
turnId: string | undefined,
|
||||
|
||||
@@ -289,6 +289,7 @@ test("finish notifications tell the parent the child's last assistant message",
|
||||
source: {
|
||||
kind: "agent-notification",
|
||||
agentId: "child-agent",
|
||||
serverId: "srv_test",
|
||||
title: "Child Agent",
|
||||
event: "finished",
|
||||
},
|
||||
@@ -325,6 +326,7 @@ test("closing a watched child notifies the caller", async () => {
|
||||
source: {
|
||||
kind: "agent-notification",
|
||||
agentId: "child-agent",
|
||||
serverId: "srv_test",
|
||||
title: "Child Agent",
|
||||
event: "closed",
|
||||
},
|
||||
|
||||
@@ -1386,3 +1386,129 @@ test("MCP provider discovery uses the selected host profiles and models", async
|
||||
await b.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("real MCP discovery preserves custom catalog modes, disabled-provider isolation, and draft settings", async () => {
|
||||
const clients = createTestAgentClients();
|
||||
const base = clients.codex!;
|
||||
let disabledAvailabilityChecks = 0;
|
||||
let disabledCatalogFetches = 0;
|
||||
const drafts: AgentSessionConfig[] = [];
|
||||
const dynamicModes = [{ id: "dynamic", label: "Dynamic", description: "Runtime mode" }];
|
||||
const custom: AgentClient = {
|
||||
...base,
|
||||
provider: "zai",
|
||||
capabilities: base.capabilities,
|
||||
createSession: (...args) => base.createSession(...args),
|
||||
resumeSession: (...args) => base.resumeSession(...args),
|
||||
isAvailable: async () => true,
|
||||
fetchCatalog: async () => ({
|
||||
models: [{ provider: "zai", id: "custom-model", label: "Custom model", isDefault: true }],
|
||||
modes: dynamicModes,
|
||||
}),
|
||||
listFeatures: async (config) => {
|
||||
drafts.push(config);
|
||||
return [
|
||||
{
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
value: config.featureValues?.fast_mode === true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
const disabled: AgentClient = {
|
||||
...base,
|
||||
provider: "codex",
|
||||
capabilities: base.capabilities,
|
||||
createSession: (...args) => base.createSession(...args),
|
||||
resumeSession: (...args) => base.resumeSession(...args),
|
||||
isAvailable: async () => {
|
||||
disabledAvailabilityChecks++;
|
||||
return true;
|
||||
},
|
||||
fetchCatalog: async (options) => {
|
||||
disabledCatalogFetches++;
|
||||
return base.fetchCatalog(options);
|
||||
},
|
||||
};
|
||||
const destination = await createTestPaseoDaemon({
|
||||
agentClients: { ...clients, codex: disabled, zai: custom },
|
||||
providerOverrides: {
|
||||
codex: { enabled: false },
|
||||
zai: { extends: "codex", label: "ZAI", enabled: true },
|
||||
},
|
||||
agentProfiles: [],
|
||||
});
|
||||
const mcp = await createMcpClient(`http://127.0.0.1:${destination.port}/mcp/agents`);
|
||||
try {
|
||||
const response = await mcp.callTool({ name: "list_providers", args: {} });
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
const providers = recordArr(getStructuredContent(response)?.providers);
|
||||
expect(providers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "zai",
|
||||
label: "ZAI",
|
||||
enabled: true,
|
||||
status: "available",
|
||||
modes: dynamicModes,
|
||||
}),
|
||||
expect.objectContaining({ id: "codex", enabled: false, status: "unavailable", modes: [] }),
|
||||
]),
|
||||
);
|
||||
const modelText =
|
||||
response.content?.map((item) => Reflect.get(item, "text") ?? "").join("\n") ?? "";
|
||||
expect(modelText).toContain(`providers_count=${providers.length}`);
|
||||
expect(modelText).toContain(
|
||||
`providers_ids=${providers.map((provider) => provider.id).join(",")}`,
|
||||
);
|
||||
expect(modelText).toContain('"providers"');
|
||||
expect(await callToolStructured(mcp, "list_profiles")).toMatchObject({ profiles: [] });
|
||||
await expectToolError(mcp, "list_models", { provider: "codex" }, /disabled/i);
|
||||
await expectToolError(
|
||||
mcp,
|
||||
"inspect_provider",
|
||||
{ provider: "codex", cwd: destination.staticDir },
|
||||
/disabled/i,
|
||||
);
|
||||
expect(disabledAvailabilityChecks).toBe(0);
|
||||
expect(disabledCatalogFetches).toBe(0);
|
||||
const inspected = await callToolStructured(mcp, "inspect_provider", {
|
||||
provider: "zai/custom-model",
|
||||
cwd: destination.staticDir,
|
||||
settings: { modeId: "dynamic", thinkingOptionId: "high", features: { fast_mode: true } },
|
||||
});
|
||||
expect(inspected).toMatchObject({
|
||||
provider: "zai",
|
||||
label: "ZAI",
|
||||
selectedModel: "custom-model",
|
||||
modes: dynamicModes,
|
||||
features: [{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true }],
|
||||
});
|
||||
expect(drafts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
provider: "zai",
|
||||
cwd: destination.staticDir,
|
||||
model: "custom-model",
|
||||
modeId: "dynamic",
|
||||
thinkingOptionId: "high",
|
||||
featureValues: { fast_mode: true },
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await mcp.close();
|
||||
await destination.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("real MCP without configured profiles returns an empty list", async () => {
|
||||
const destination = await createTestPaseoDaemon();
|
||||
const mcp = await createMcpClient(`http://127.0.0.1:${destination.port}/mcp/agents`);
|
||||
try {
|
||||
expect(await callToolStructured(mcp, "list_profiles")).toMatchObject({ profiles: [] });
|
||||
} finally {
|
||||
await mcp.close();
|
||||
await destination.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -67,8 +67,6 @@ import { createTerminalManager, type TerminalManager } from "../../terminal/term
|
||||
import { createWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index";
|
||||
import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonConfigStore } from "../daemon-config-store.js";
|
||||
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
|
||||
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
|
||||
import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js";
|
||||
@@ -4844,7 +4842,7 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
});
|
||||
expect(childClient.sessions[0]!.prompts).toHaveLength(1);
|
||||
expect(parseAgentMessage(childClient.sessions[0]!.prompts[0])).toMatchObject({
|
||||
source: { kind: "agent-message", agentId: parent.id },
|
||||
source: { kind: "agent-message", serverId: "srv_test", agentId: parent.id },
|
||||
text: "Ongoing task",
|
||||
});
|
||||
expect(parentClient.sessions[0]!.prompts).toHaveLength(0);
|
||||
@@ -5039,7 +5037,7 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
await vi.waitFor(() => expect(childSession.prompts).toHaveLength(1));
|
||||
expect(parseAgentMessage(childSession.prompts[0])).toEqual({
|
||||
id: expect.any(String),
|
||||
source: { kind: "agent-message", agentId: parent.id },
|
||||
source: { kind: "agent-message", serverId: "srv_test", agentId: parent.id },
|
||||
text: "Follow up",
|
||||
});
|
||||
acknowledgeTurnStart();
|
||||
@@ -6273,202 +6271,6 @@ describe("schedule_logs MCP tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider listing MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it("returns providers from the registry, including custom providers", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const provStub = createProviderSnapshotManagerStub();
|
||||
provStub.listRegisteredProviderIds.mockReturnValue(["claude", "zai"]);
|
||||
provStub.listProviders.mockResolvedValue([
|
||||
buildSnapshotEntry({
|
||||
provider: "claude",
|
||||
label: "Claude",
|
||||
description: "Test provider",
|
||||
modes: [{ id: "default", label: "Default", description: "Built-in mode" }],
|
||||
}),
|
||||
buildSnapshotEntry({
|
||||
provider: "zai" as AgentProvider,
|
||||
label: "ZAI",
|
||||
description: "Custom Claude profile",
|
||||
defaultModeId: "default",
|
||||
modes: [{ id: "default", label: "Default", description: "Custom mode" }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: provStub.manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_providers");
|
||||
const response = await tool.handler({});
|
||||
const modelVisibleText = String(response.content[0]?.text);
|
||||
|
||||
expect(response.structuredContent).toEqual({
|
||||
providers: [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
description: "Test provider",
|
||||
enabled: true,
|
||||
status: "available",
|
||||
modes: [{ id: "default", label: "Default", description: "Built-in mode" }],
|
||||
},
|
||||
{
|
||||
id: "zai",
|
||||
label: "ZAI",
|
||||
status: "available",
|
||||
description: "Custom Claude profile",
|
||||
enabled: true,
|
||||
modes: [{ id: "default", label: "Default", description: "Custom mode" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(modelVisibleText).toContain("providers_count=2");
|
||||
expect(modelVisibleText).toContain("providers_ids=claude,zai");
|
||||
expect(modelVisibleText).toContain('"providers"');
|
||||
});
|
||||
|
||||
it("returns provider modes from the shared snapshot catalog", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const provStub = createProviderSnapshotManagerStub();
|
||||
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
|
||||
provStub.listProviders.mockResolvedValue([
|
||||
buildSnapshotEntry({
|
||||
provider: "codex",
|
||||
label: "Codex",
|
||||
modes: [{ id: "dynamic", label: "Dynamic", description: "Runtime mode" }],
|
||||
}),
|
||||
]);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: provStub.manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_providers");
|
||||
|
||||
const response = await tool.handler({});
|
||||
|
||||
expect(response.structuredContent.providers).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "codex",
|
||||
modes: [{ id: "dynamic", label: "Dynamic", description: "Runtime mode" }],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns disabled providers with metadata without checking availability", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const provStub = createProviderSnapshotManagerStub();
|
||||
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
|
||||
provStub.listProviders.mockResolvedValue([
|
||||
buildSnapshotEntry({
|
||||
provider: "codex",
|
||||
label: "Codex",
|
||||
description: "OpenAI coding agent",
|
||||
enabled: false,
|
||||
modes: [{ id: "read-only", label: "Read Only", description: "No edits" }],
|
||||
}),
|
||||
]);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: provStub.manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_providers");
|
||||
const response = await tool.handler({});
|
||||
|
||||
expect(response.structuredContent).toEqual({
|
||||
providers: [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description: "OpenAI coding agent",
|
||||
enabled: false,
|
||||
status: "unavailable",
|
||||
modes: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function daemonConfigStoreStub(agentProfiles?: AgentProfile[]): Pick<DaemonConfigStore, "get"> {
|
||||
const config = MutableDaemonConfigSchema.parse({
|
||||
relay: { enabled: true },
|
||||
mcp: { injectIntoAgents: true },
|
||||
...(agentProfiles !== undefined ? { agentProfiles } : {}),
|
||||
});
|
||||
return { get: () => config };
|
||||
}
|
||||
|
||||
describe("agent profile listing MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it("returns configured profiles, including notes", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const profiles: AgentProfile[] = [
|
||||
{
|
||||
id: "ui-profile",
|
||||
name: "UI work",
|
||||
provider: "claude",
|
||||
model: "claude-test-model",
|
||||
modeId: "bypassPermissions",
|
||||
thinkingOptionId: "high",
|
||||
featureValues: { fast_mode: true },
|
||||
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
|
||||
},
|
||||
];
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
daemonConfigStore: daemonConfigStoreStub(profiles),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_profiles");
|
||||
|
||||
const response = await tool.handler({});
|
||||
|
||||
expect(response.structuredContent).toEqual({ profiles });
|
||||
});
|
||||
|
||||
it("returns an empty array when no profiles are configured", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
daemonConfigStore: daemonConfigStoreStub(),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_profiles");
|
||||
|
||||
const response = await tool.handler({});
|
||||
|
||||
expect(response.structuredContent).toEqual({ profiles: [] });
|
||||
});
|
||||
|
||||
it("returns an empty array when no daemon config store is provided", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_profiles");
|
||||
|
||||
const response = await tool.handler({});
|
||||
|
||||
expect(response.structuredContent).toEqual({ profiles: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider MCP tools", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
@@ -6483,113 +6285,6 @@ describe("provider MCP tools", () => {
|
||||
|
||||
expect(lookupTool(server, "list_provider_features")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("inspects provider features for a draft agent configuration", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.listDraftFeatures.mockResolvedValue([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
const provStub = createProviderSnapshotManagerStub();
|
||||
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
|
||||
const codexEntry = buildSnapshotEntry({
|
||||
provider: "codex",
|
||||
label: "Codex",
|
||||
description: "OpenAI coding agent",
|
||||
modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }],
|
||||
});
|
||||
provStub.listProviders.mockResolvedValue([codexEntry]);
|
||||
provStub.getProvider.mockResolvedValue(codexEntry);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: provStub.manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "inspect_provider");
|
||||
const input = {
|
||||
provider: "codex/gpt-5.4",
|
||||
cwd: "~/repo",
|
||||
settings: {
|
||||
modeId: "full-access",
|
||||
thinkingOptionId: "high",
|
||||
features: { fast_mode: true },
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync(input);
|
||||
expect(parsed.success).toBe(true);
|
||||
|
||||
const response = await tool.handler(input);
|
||||
|
||||
expect(spies.agentManager.listDraftFeatures).toHaveBeenCalledWith({
|
||||
provider: "codex",
|
||||
cwd: expect.stringContaining("repo"),
|
||||
modeId: "full-access",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
expect(response.structuredContent).toEqual({
|
||||
provider: "codex",
|
||||
label: "Codex",
|
||||
description: "OpenAI coding agent",
|
||||
enabled: true,
|
||||
status: "available",
|
||||
modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }],
|
||||
selectedModel: "gpt-5.4",
|
||||
features: [
|
||||
{
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects disabled providers without fetching models", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const provStub = createProviderSnapshotManagerStub();
|
||||
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
|
||||
provStub.listModels.mockRejectedValue(new Error("Provider 'codex' is disabled"));
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: provStub.manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_models");
|
||||
|
||||
await expect(tool.handler({ provider: "codex" })).rejects.toThrow(
|
||||
"Provider 'codex' is disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("inspect_provider rejects disabled providers without fetching models", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const provStub = createProviderSnapshotManagerStub();
|
||||
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
|
||||
provStub.getProvider.mockResolvedValue(
|
||||
buildSnapshotEntry({ provider: "codex", label: "Codex", enabled: false }),
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: provStub.manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "inspect_provider");
|
||||
|
||||
await expect(tool.handler({ provider: "codex", cwd: "~/repo" })).rejects.toThrow(
|
||||
"Provider 'codex' is disabled",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("speak MCP tool", () => {
|
||||
|
||||
@@ -60,6 +60,10 @@ export class AgentRuns {
|
||||
private identity(agentId: string, submissionId: string): string {
|
||||
return JSON.stringify([agentId, submissionId]);
|
||||
}
|
||||
/** Restore turn ownership before subscribing to live provider evidence. */
|
||||
restore(agentId: string): Promise<void> {
|
||||
return this.mutate(() => this.load(agentId));
|
||||
}
|
||||
private async load(agentId: string): Promise<void> {
|
||||
if (this.loadedAgents.has(agentId)) return;
|
||||
let records: StoredRun[] = [];
|
||||
@@ -240,10 +244,15 @@ export class AgentRuns {
|
||||
turnId: string | undefined,
|
||||
event: AgentStreamEvent,
|
||||
result: string,
|
||||
): Promise<void> {
|
||||
if (!turnId) return Promise.resolve();
|
||||
): Promise<void> | undefined {
|
||||
if (!turnId) return undefined;
|
||||
if (
|
||||
this.loadedAgents.has(agentId) &&
|
||||
!this.submissionsByTurn.has(this.identity(agentId, turnId))
|
||||
)
|
||||
return undefined;
|
||||
const checkpoint = checkpointFor(event, result);
|
||||
if (!checkpoint) return Promise.resolve();
|
||||
if (!checkpoint) return undefined;
|
||||
return this.mutate(async () => {
|
||||
await this.load(agentId);
|
||||
const submissions = this.submissionsByTurn.get(this.identity(agentId, turnId));
|
||||
|
||||
@@ -1779,6 +1779,7 @@ export async function createPaseoDaemon(
|
||||
orchestrationSkills,
|
||||
workspaceLabelService,
|
||||
);
|
||||
await wsServer.initializeRelationships();
|
||||
pluginRuntime.bindPaseoSessionHost(wsServer);
|
||||
await pluginRuntime.start();
|
||||
providerSnapshotManager.settlePluginProviders();
|
||||
|
||||
@@ -1708,6 +1708,180 @@ test("parent capability owns local and fleet membership, idempotent creation and
|
||||
expect(recovered.address).toEqual(first.address);
|
||||
});
|
||||
|
||||
test("startup upgrade adopts existing ordinary local children once without treating later labels as membership", async () => {
|
||||
const { AgentStorage } = await import("../agent/agent-storage.js");
|
||||
const { default: pino } = await import("pino");
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-legacy-membership-"));
|
||||
cleanup.push(() => rm(root, { recursive: true, force: true }));
|
||||
const storage = new AgentStorage(join(root, ".paseo", "agents"), pino({ level: "silent" }));
|
||||
const parentId = crypto.randomUUID();
|
||||
const childId = crypto.randomUUID();
|
||||
const sameWorkspaceId = crypto.randomUUID();
|
||||
const otherWorkspaceId = crypto.randomUUID();
|
||||
const openTabId = crypto.randomUUID();
|
||||
const foreignId = crypto.randomUUID();
|
||||
const nullParentId = crypto.randomUUID();
|
||||
const executedId = crypto.randomUUID();
|
||||
const internalParentId = crypto.randomUUID();
|
||||
const internalChildId = crypto.randomUUID();
|
||||
const seed = async (
|
||||
id: string,
|
||||
overrides: Partial<import("../agent/agent-storage.js").StoredAgentRecord> = {},
|
||||
) => {
|
||||
await storage.upsert({
|
||||
id,
|
||||
provider: "codex",
|
||||
cwd: root,
|
||||
workspaceId: "legacy-workspace",
|
||||
config: { model: "test" },
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastStatus: "closed",
|
||||
labels: {},
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
const labels = { "paseo.parent-agent-id": parentId };
|
||||
await seed(parentId);
|
||||
await seed(childId, { labels });
|
||||
await seed(sameWorkspaceId, { labels });
|
||||
await seed(otherWorkspaceId, { labels, workspaceId: "other-workspace" });
|
||||
await seed(openTabId, { labels: { ...labels, "paseo.open-agent-tab.retained": "true" } });
|
||||
await seed(foreignId, { labels, parent: { serverId: "another-server", agentId: parentId } });
|
||||
await seed(nullParentId, { labels, parent: null });
|
||||
await seed(executedId, {
|
||||
labels,
|
||||
owner: { kind: "daemon", daemonId: "hub", executionId: "execution" },
|
||||
});
|
||||
await seed(internalParentId, { internal: true });
|
||||
await seed(internalChildId, { labels: { "paseo.parent-agent-id": internalParentId } });
|
||||
const first = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
cleanup.push(() => first.close());
|
||||
const origin = await api(first);
|
||||
const parent = origin.paseo.agents.ref(parentId);
|
||||
const rows = (await parent.subagents.list()).rows;
|
||||
expect(rows.map((row) => row.child.agentId).sort()).toEqual(
|
||||
[childId, sameWorkspaceId, otherWorkspaceId, openTabId].sort(),
|
||||
);
|
||||
expect(
|
||||
rows.every((row) => row.child.serverId === parent.serverId && row.phase === "attached"),
|
||||
).toBe(true);
|
||||
expect((await first.daemon.agentStorage.get(childId))?.parent).toEqual(parent.address);
|
||||
await parent.subagents.forget({ serverId: parent.serverId, agentId: childId });
|
||||
await parent.archive();
|
||||
await expect
|
||||
.poll(async () => (await first.daemon.agentStorage.get(sameWorkspaceId))?.archivedAt)
|
||||
.toEqual(expect.any(String));
|
||||
await expect
|
||||
.poll(async () => (await first.daemon.agentStorage.get(otherWorkspaceId))?.parent)
|
||||
.toBeNull();
|
||||
await expect
|
||||
.poll(async () => (await first.daemon.agentStorage.get(openTabId))?.parent)
|
||||
.toBeNull();
|
||||
// Forget leaves destination lifecycle alone; the old label cascade must not run too.
|
||||
expect((await first.daemon.agentStorage.get(childId))?.archivedAt).toBeUndefined();
|
||||
expect((await first.daemon.agentStorage.get(otherWorkspaceId))?.archivedAt).toBeUndefined();
|
||||
expect((await first.daemon.agentStorage.get(openTabId))?.archivedAt).toBeUndefined();
|
||||
const injectedId = crypto.randomUUID();
|
||||
await first.daemon.agentStorage.upsert({
|
||||
...(await first.daemon.agentStorage.get(childId))!,
|
||||
id: injectedId,
|
||||
parent: undefined,
|
||||
});
|
||||
await first.close();
|
||||
const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
cleanup.push(() => restarted.close());
|
||||
const restored = await api(restarted);
|
||||
const restoredRows = (await restored.paseo.agents.ref(parentId).subagents.list()).rows;
|
||||
expect(restoredRows.map((row) => row.child.agentId).sort()).toEqual(
|
||||
[sameWorkspaceId, otherWorkspaceId, openTabId].sort(),
|
||||
);
|
||||
expect(restoredRows.every((row) => ["archived", "detached"].includes(row.phase))).toBe(true);
|
||||
expect((await restarted.daemon.agentStorage.get(injectedId))?.parent).toBeUndefined();
|
||||
});
|
||||
|
||||
test("an interrupted legacy upgrade resumes its bounded candidates before admitting new writes", async () => {
|
||||
const { AgentStorage } = await import("../agent/agent-storage.js");
|
||||
const { Subagents } = await import("../subagents/index.js");
|
||||
const { getOrCreateServerId } = await import("../server-id.js");
|
||||
const { default: pino } = await import("pino");
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-interrupted-legacy-"));
|
||||
cleanup.push(() => rm(root, { recursive: true, force: true }));
|
||||
const home = join(root, ".paseo");
|
||||
await mkdir(home);
|
||||
const serverId = getOrCreateServerId(home);
|
||||
const storage = new AgentStorage(join(home, "agents"), pino({ level: "silent" }));
|
||||
const parentId = crypto.randomUUID();
|
||||
const childIds = [crypto.randomUUID(), crypto.randomUUID()];
|
||||
for (const id of [parentId, ...childIds])
|
||||
await storage.upsert({
|
||||
id,
|
||||
provider: "codex",
|
||||
cwd: root,
|
||||
workspaceId: "legacy-workspace",
|
||||
config: { model: "test" },
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastStatus: "closed",
|
||||
labels: id === parentId ? {} : { "paseo.parent-agent-id": parentId },
|
||||
});
|
||||
let qualifications = 0;
|
||||
const interrupted = new Subagents({
|
||||
home,
|
||||
serverId,
|
||||
agents: {
|
||||
list: () => storage.list(),
|
||||
get: (id) => storage.get(id),
|
||||
upsert: async (record) => {
|
||||
if (++qualifications === 2) throw new Error("Injected qualification write failure");
|
||||
await storage.upsert(record);
|
||||
},
|
||||
},
|
||||
resolveAuthority: async () => ({
|
||||
principalId: "owner",
|
||||
permissions: ["daemon.read", "workspace.read", "workspace.write"],
|
||||
}),
|
||||
runtime: async () => {
|
||||
throw new Error("Upgrade must never acquire a client or submit creation");
|
||||
},
|
||||
});
|
||||
await expect(interrupted.initialize()).rejects.toThrow("Injected qualification write failure");
|
||||
const pending = JSON.parse(await readFile(join(home, "subagents.json"), "utf8"));
|
||||
expect(pending.legacyUpgrade).toMatchObject({ complete: false, candidates: expect.any(Array) });
|
||||
expect(pending.legacyUpgrade.candidates).toHaveLength(2);
|
||||
expect((await storage.list()).filter((record) => record.parent)).toHaveLength(1);
|
||||
const injectedId = crypto.randomUUID();
|
||||
await storage.upsert({
|
||||
...(await storage.get(childIds[0]!))!,
|
||||
id: injectedId,
|
||||
parent: undefined,
|
||||
});
|
||||
const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
cleanup.push(() => restarted.close());
|
||||
const origin = await api(restarted);
|
||||
const parent = origin.paseo.agents.ref(parentId);
|
||||
expect((await parent.subagents.list()).rows.map((row) => row.child.agentId).sort()).toEqual(
|
||||
childIds.toSorted(),
|
||||
);
|
||||
for (const id of childIds)
|
||||
expect((await restarted.daemon.agentStorage.get(id))?.parent).toEqual(parent.address);
|
||||
expect((await restarted.daemon.agentStorage.get(injectedId))?.parent).toBeUndefined();
|
||||
const complete = JSON.parse(await readFile(join(home, "subagents.json"), "utf8"));
|
||||
expect(complete.legacyUpgrade.complete).toBe(true);
|
||||
expect(complete.legacyUpgrade.candidates).toEqual(pending.legacyUpgrade.candidates);
|
||||
const archived: string[] = [];
|
||||
const release = restarted.daemon.agentManager.onAgentArchived((id) => {
|
||||
archived.push(id);
|
||||
});
|
||||
try {
|
||||
await parent.archive();
|
||||
await expect.poll(() => archived.toSorted()).toEqual([parentId, ...childIds].toSorted());
|
||||
expect((await restarted.daemon.agentStorage.get(injectedId))?.archivedAt).toBeUndefined();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
test("retained derived sessions cannot supply durable principal authority", async () => {
|
||||
const a = await daemon();
|
||||
const owner = await api(a);
|
||||
@@ -3188,15 +3362,30 @@ test.each([false, true])(
|
||||
};
|
||||
for (const attempt of [1, 2]) {
|
||||
const requestId = "legacy-original";
|
||||
socket.send(JSON.stringify({ type: "session", message: { ...request, requestId } }));
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
const receipt = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
socket.off("message", received);
|
||||
reject(
|
||||
new Error(
|
||||
`Timed out waiting for legacy creation receipt ${attempt}: ${JSON.stringify(messages)}`,
|
||||
),
|
||||
);
|
||||
}, 20_000);
|
||||
function received() {
|
||||
if (
|
||||
messages.filter(
|
||||
(message) => message.type === "status" && message.payload.requestId === requestId,
|
||||
).length,
|
||||
)
|
||||
.toBe(attempt);
|
||||
).length !== attempt
|
||||
)
|
||||
return;
|
||||
clearTimeout(timeout);
|
||||
socket.off("message", received);
|
||||
resolve();
|
||||
}
|
||||
socket.on("message", received);
|
||||
});
|
||||
socket.send(JSON.stringify({ type: "session", message: { ...request, requestId } }));
|
||||
await receipt;
|
||||
const response = messages.find(
|
||||
(message) => message.type === "status" && message.payload.requestId === requestId,
|
||||
);
|
||||
@@ -3225,3 +3414,158 @@ test.each([false, true])(
|
||||
expect(prompts).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
test("internal connection leases share ordinary host resolution and preserve borrowed origin and sibling lifetime", async () => {
|
||||
const { acquireHostConnection } = await import("@getpaseo/client/internal/host-connection");
|
||||
const a = await daemon();
|
||||
const b = await daemon();
|
||||
const origin = await api(a);
|
||||
const direct = await api(b);
|
||||
await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
const directory = await online(origin.paseo);
|
||||
const controller = new AbortController();
|
||||
const [self, first, sibling] = await Promise.all([
|
||||
acquireHostConnection(origin.client, directory.self.serverId, controller.signal),
|
||||
acquireHostConnection(origin.client, b.daemon.getServerId(), controller.signal),
|
||||
acquireHostConnection(origin.client, b.daemon.getServerId(), controller.signal),
|
||||
]);
|
||||
cleanup.push(() => sibling.release());
|
||||
cleanup.push(() => first.release());
|
||||
expect(self.client.getServerId()).toBe(origin.client.getServerId());
|
||||
await self.release();
|
||||
expect(await origin.paseo.hosts.list()).toMatchObject({
|
||||
self: { serverId: directory.self.serverId },
|
||||
});
|
||||
const firstApi = createPaseoApi(first.client);
|
||||
const siblingApi = createPaseoApi(sibling.client);
|
||||
cleanup.push(() => siblingApi.dispose());
|
||||
cleanup.push(() => firstApi.dispose());
|
||||
await first.release();
|
||||
await expect(firstApi.workspaces.list()).rejects.toThrow();
|
||||
expect((await siblingApi.workspaces.list()).entries).toEqual([]);
|
||||
await origin.paseo.hosts.update(directory.hosts[0]!.registrationId, { enabled: false });
|
||||
await expect(siblingApi.workspaces.list()).rejects.toThrow();
|
||||
expect((await direct.paseo.workspaces.list()).entries).toEqual([]);
|
||||
controller.abort();
|
||||
await sibling.release();
|
||||
const aborted = new AbortController();
|
||||
aborted.abort();
|
||||
await expect(
|
||||
acquireHostConnection(origin.client, directory.self.serverId, aborted.signal),
|
||||
).rejects.toThrow("disposed");
|
||||
expect((await origin.paseo.hosts.list()).self.serverId).toBe(directory.self.serverId);
|
||||
});
|
||||
|
||||
test("released internal self connection capability rejects captured operations while the borrowed origin stays usable", async () => {
|
||||
const { acquireHostConnection } = await import("@getpaseo/client/internal/host-connection");
|
||||
const a = await daemon();
|
||||
const origin = await api(a);
|
||||
const control = new AbortController();
|
||||
const self = await acquireHostConnection(origin.client, a.daemon.getServerId(), control.signal);
|
||||
const scoped = createPaseoApi(self.client);
|
||||
cleanup.push(() => scoped.dispose());
|
||||
await self.release();
|
||||
await expect(scoped.workspaces.list()).rejects.toMatchObject({ code: "DAEMON_CONNECTION_LOST" });
|
||||
expect((await origin.paseo.workspaces.list()).entries).toEqual([]);
|
||||
});
|
||||
|
||||
test("nested self scopes preserve the consumed hop while a direct self scope reaches its registered destination", async () => {
|
||||
const a = await daemon();
|
||||
const b = await daemon();
|
||||
const c = await daemon();
|
||||
const origin = await api(a);
|
||||
const directB = await api(b);
|
||||
const wire = await hostWire(b);
|
||||
await origin.paseo.hosts.add({ connection: wire.connection });
|
||||
await online(origin.paseo);
|
||||
await directB.paseo.hosts.add({ connection: `127.0.0.1:${c.port}` });
|
||||
await online(directB.paseo);
|
||||
const selfA = await origin.paseo.hosts.client(a.daemon.getServerId());
|
||||
cleanup.push(() => selfA.dispose());
|
||||
const viaB = await selfA.hosts.client(b.daemon.getServerId());
|
||||
cleanup.push(() => viaB.dispose());
|
||||
const nestedB = await viaB.hosts.client(b.daemon.getServerId());
|
||||
cleanup.push(() => nestedB.dispose());
|
||||
const twiceB = await nestedB.hosts.client(b.daemon.getServerId());
|
||||
cleanup.push(() => twiceB.dispose());
|
||||
expect((await twiceB.workspaces.list()).entries).toEqual([]);
|
||||
await expect(twiceB.hosts.client(c.daemon.getServerId())).rejects.toMatchObject({
|
||||
code: "unsupported_capability",
|
||||
});
|
||||
const directSelfB = await directB.paseo.hosts.client(b.daemon.getServerId());
|
||||
cleanup.push(() => directSelfB.dispose());
|
||||
const allowedC = await directSelfB.hosts.client(c.daemon.getServerId());
|
||||
cleanup.push(() => allowedC.dispose());
|
||||
expect((await allowedC.workspaces.list()).entries).toEqual([]);
|
||||
expect(wire.connections()).toBe(1);
|
||||
await nestedB.dispose();
|
||||
expect((await viaB.workspaces.list()).entries).toEqual([]);
|
||||
expect((await origin.paseo.workspaces.list()).entries).toEqual([]);
|
||||
});
|
||||
|
||||
test("owned self session close settles pending RPC and observation and leaves sibling and physical origin usable", async () => {
|
||||
const { acquireHostConnection } = await import("@getpaseo/client/internal/host-connection");
|
||||
const a = await daemon();
|
||||
const wire = await hostWire(a);
|
||||
const origin = createHostClient(wire.connection, {
|
||||
clientId: crypto.randomUUID(),
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
cleanup.push(() => origin.close());
|
||||
await origin.connect();
|
||||
const originApi = createPaseoApi(origin);
|
||||
cleanup.push(() => originApi.dispose());
|
||||
const control = new AbortController();
|
||||
const self = await acquireHostConnection(origin, a.daemon.getServerId(), control.signal);
|
||||
cleanup.push(() => self.release());
|
||||
const sibling = await acquireHostConnection(
|
||||
origin,
|
||||
a.daemon.getServerId(),
|
||||
new AbortController().signal,
|
||||
);
|
||||
cleanup.push(() => sibling.release());
|
||||
const observed = self.client.observeHosts();
|
||||
const siblingObserved = sibling.client.observeHosts();
|
||||
let updates = 0;
|
||||
let siblingUpdates = 0;
|
||||
observed.subscribe({
|
||||
snapshot() {},
|
||||
update() {
|
||||
updates++;
|
||||
},
|
||||
});
|
||||
siblingObserved.subscribe({
|
||||
snapshot() {},
|
||||
update() {
|
||||
siblingUpdates++;
|
||||
},
|
||||
});
|
||||
await Promise.all([observed.ready, siblingObserved.ready]);
|
||||
const states: string[] = [];
|
||||
self.client.subscribeConnectionStatus((state) => states.push(state.status));
|
||||
const identity = self.client.getServerId();
|
||||
const capturedFetch = self.client.fetchWorkspaces.bind(self.client);
|
||||
const held = wire.holdResponses((message) => message.type === "fetch_workspaces_response");
|
||||
const pending = capturedFetch();
|
||||
void pending.catch(() => {});
|
||||
await held.ready;
|
||||
try {
|
||||
await self.release();
|
||||
await expect(pending).rejects.toThrow();
|
||||
expect(states.at(-1)).toBe("disposed");
|
||||
expect(self.client.getServerId()).toBe(identity);
|
||||
const rejected = capturedFetch();
|
||||
expect(rejected).toBeInstanceOf(Promise);
|
||||
await expect(rejected).rejects.toMatchObject({ code: "DAEMON_CONNECTION_LOST" });
|
||||
held.release();
|
||||
await originApi.hosts.add({ connection: "127.0.0.1:1", label: "After scope release" });
|
||||
await expect.poll(() => siblingUpdates).toBeGreaterThan(0);
|
||||
expect(updates).toBe(0);
|
||||
expect((await sibling.client.fetchWorkspaces()).entries).toEqual([]);
|
||||
expect((await originApi.workspaces.list()).entries).toEqual([]);
|
||||
control.abort();
|
||||
await self.release();
|
||||
} finally {
|
||||
held.release();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,16 +16,23 @@ export class DaemonLinkEndpoint {
|
||||
constructor(
|
||||
private readonly parent: {
|
||||
identity: string;
|
||||
serverId?: string;
|
||||
assertScopeAdmission?(): void;
|
||||
permissions(): readonly DaemonPermission[];
|
||||
routed(): boolean;
|
||||
bufferedAmount(): number | null;
|
||||
delivery: SessionDelivery;
|
||||
admit(socket: VirtualSessionSocket, permissions: readonly DaemonPermission[]): Promise<void>;
|
||||
admit(
|
||||
socket: VirtualSessionSocket,
|
||||
permissions: readonly DaemonPermission[],
|
||||
context: { kind: "scope" | "destination-link" },
|
||||
): Promise<void>;
|
||||
},
|
||||
) {}
|
||||
handles(message: SessionInboundMessage): boolean {
|
||||
return (
|
||||
message.type === "hosts.link.open.request" ||
|
||||
(message.type === "hosts.route.open.request" && message.serverId === this.parent.serverId) ||
|
||||
(message.type === "hosts.route.frame.request" && this.channels.has(message.routeId)) ||
|
||||
(message.type === "hosts.route.close.request" &&
|
||||
((!!message.routeId && this.channels.has(message.routeId)) ||
|
||||
@@ -33,105 +40,11 @@ export class DaemonLinkEndpoint {
|
||||
);
|
||||
}
|
||||
async dispatch(message: SessionInboundMessage): Promise<void> {
|
||||
if (message.type === "hosts.link.open.request") {
|
||||
const error = (code: string, text: string) =>
|
||||
this.parent.delivery.reply({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: message.requestId,
|
||||
requestType: message.type,
|
||||
code,
|
||||
error: text,
|
||||
},
|
||||
});
|
||||
if (this.closed || this.parent.routed()) {
|
||||
error("unsupported_capability", "Connected hosts support one hop");
|
||||
return;
|
||||
}
|
||||
if (this.channels.size >= 128) {
|
||||
error("resource_limit", "Too many host channels");
|
||||
return;
|
||||
}
|
||||
let socket: VirtualSessionSocket;
|
||||
const owner = this.parent.delivery.begin("host-link", undefined, () => {
|
||||
this.channels.delete(owner.id);
|
||||
socket?.close();
|
||||
});
|
||||
const id = owner.id;
|
||||
// Capture actual authenticated authority. The caller hello can only reduce it.
|
||||
const ceiling = [...this.parent.permissions()];
|
||||
socket = new VirtualSessionSocket(
|
||||
async (data) => {
|
||||
const output: SessionOutboundMessage | null =
|
||||
typeof data === "string"
|
||||
? {
|
||||
type: "hosts.route.frame",
|
||||
payload: { subscriptionId: id, data, binary: false },
|
||||
}
|
||||
: null;
|
||||
const frame = typeof data === "string" ? null : encodeHostRouteFrame(id, data);
|
||||
const bytes = output
|
||||
? Buffer.byteLength(JSON.stringify({ type: "session", message: output }))
|
||||
: frame!.byteLength;
|
||||
const buffered = this.parent.bufferedAmount();
|
||||
// Account for the exact escaped outer frame, retaining control-frame headroom.
|
||||
if (buffered === null || buffered + bytes > 64 * 1024 * 1024 - 65536) {
|
||||
socket.close(1009, "Host channel output limit reached");
|
||||
throw new Error("Host channel output limit reached");
|
||||
}
|
||||
try {
|
||||
if (output) await owner.emitAndWait(output);
|
||||
else await owner.emitBinaryAndWait(frame!);
|
||||
} catch (failure) {
|
||||
socket.close();
|
||||
throw failure;
|
||||
}
|
||||
},
|
||||
(code, reason) => {
|
||||
this.channels.delete(id);
|
||||
let failure = "host_offline";
|
||||
if (code === 1009) failure = "resource_limit";
|
||||
if (code === 1008) failure = "permission_denied";
|
||||
owner.emit({
|
||||
type: "hosts.route.closed",
|
||||
payload: {
|
||||
subscriptionId: id,
|
||||
code: failure,
|
||||
message: reason,
|
||||
},
|
||||
});
|
||||
void owner.release();
|
||||
},
|
||||
() => this.parent.bufferedAmount() ?? 0,
|
||||
(data) => {
|
||||
const hello = JSON.parse(data);
|
||||
if (hello.type !== "hello" || typeof hello.clientId !== "string")
|
||||
throw new Error("Expected hello");
|
||||
return JSON.stringify({
|
||||
...hello,
|
||||
auth: undefined,
|
||||
routed: true,
|
||||
clientId: `link-${createHash("sha256")
|
||||
.update(JSON.stringify([this.parent.identity, hello.clientId]))
|
||||
.digest("hex")}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
this.channels.set(id, { scopeId: message.channelId, socket });
|
||||
try {
|
||||
await this.parent.admit(socket, ceiling);
|
||||
if (this.closed || socket.readyState !== 1) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
this.parent.delivery.reply({
|
||||
type: "hosts.link.open.response",
|
||||
payload: { requestId: message.requestId, routeId: id },
|
||||
});
|
||||
} catch {
|
||||
socket.close();
|
||||
error("host_offline", "Could not admit host channel");
|
||||
}
|
||||
if (
|
||||
message.type === "hosts.link.open.request" ||
|
||||
(message.type === "hosts.route.open.request" && message.serverId === this.parent.serverId)
|
||||
) {
|
||||
await this.openChannel(message);
|
||||
return;
|
||||
}
|
||||
if (message.type === "hosts.route.frame.request" && this.channels.has(message.routeId)) {
|
||||
@@ -146,6 +59,127 @@ export class DaemonLinkEndpoint {
|
||||
}
|
||||
return;
|
||||
}
|
||||
private async openChannel(
|
||||
message: Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: "hosts.link.open.request" | "hosts.route.open.request" }
|
||||
>,
|
||||
): Promise<void> {
|
||||
const selfScope = message.type === "hosts.route.open.request";
|
||||
const error = (code: string, text: string) =>
|
||||
this.parent.delivery.reply({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: message.requestId,
|
||||
requestType: message.type,
|
||||
code,
|
||||
error: text,
|
||||
},
|
||||
});
|
||||
if (this.closed || (!selfScope && this.parent.routed())) {
|
||||
error("unsupported_capability", "Connected hosts support one hop");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.parent.assertScopeAdmission?.();
|
||||
} catch (failure) {
|
||||
error(
|
||||
"unsupported_capability",
|
||||
failure instanceof Error ? failure.message : "Unsupported scoped admission",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.channels.size >= 128) {
|
||||
error("resource_limit", "Too many host channels");
|
||||
return;
|
||||
}
|
||||
let socket: VirtualSessionSocket;
|
||||
const owner = this.parent.delivery.begin("host-link", undefined, () => {
|
||||
this.channels.delete(owner.id);
|
||||
socket?.close();
|
||||
});
|
||||
const id = owner.id;
|
||||
// Capture actual authenticated authority. The caller hello can only reduce it.
|
||||
const ceiling = [...this.parent.permissions()];
|
||||
socket = new VirtualSessionSocket(
|
||||
async (data) => {
|
||||
const output: SessionOutboundMessage | null =
|
||||
typeof data === "string"
|
||||
? {
|
||||
type: "hosts.route.frame",
|
||||
payload: { subscriptionId: id, data, binary: false },
|
||||
}
|
||||
: null;
|
||||
const frame = typeof data === "string" ? null : encodeHostRouteFrame(id, data);
|
||||
const bytes = output
|
||||
? Buffer.byteLength(JSON.stringify({ type: "session", message: output }))
|
||||
: frame!.byteLength;
|
||||
const buffered = this.parent.bufferedAmount();
|
||||
// Account for the exact escaped outer frame, retaining control-frame headroom.
|
||||
if (buffered === null || buffered + bytes > 64 * 1024 * 1024 - 65536) {
|
||||
socket.close(1009, "Host channel output limit reached");
|
||||
throw new Error("Host channel output limit reached");
|
||||
}
|
||||
try {
|
||||
if (output) await owner.emitAndWait(output);
|
||||
else await owner.emitBinaryAndWait(frame!);
|
||||
} catch (failure) {
|
||||
socket.close();
|
||||
throw failure;
|
||||
}
|
||||
},
|
||||
(code, reason) => {
|
||||
this.channels.delete(id);
|
||||
let failure = "host_offline";
|
||||
if (code === 1009) failure = "resource_limit";
|
||||
if (code === 1008) failure = "permission_denied";
|
||||
owner.emit({
|
||||
type: "hosts.route.closed",
|
||||
payload: {
|
||||
subscriptionId: id,
|
||||
code: failure,
|
||||
message: reason,
|
||||
},
|
||||
});
|
||||
void owner.release();
|
||||
},
|
||||
() => this.parent.bufferedAmount() ?? 0,
|
||||
(data) => {
|
||||
const hello = JSON.parse(data);
|
||||
if (hello.type !== "hello" || typeof hello.clientId !== "string")
|
||||
throw new Error("Expected hello");
|
||||
return JSON.stringify({
|
||||
...hello,
|
||||
auth: undefined,
|
||||
routed: selfScope ? this.parent.routed() : true,
|
||||
sessionPermissions: ceiling.filter(
|
||||
(permission) =>
|
||||
!hello.sessionPermissions || hello.sessionPermissions.includes(permission),
|
||||
),
|
||||
clientId: `link-${createHash("sha256")
|
||||
.update(JSON.stringify([this.parent.identity, hello.clientId]))
|
||||
.digest("hex")}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
this.channels.set(id, { scopeId: selfScope ? message.scopeId : message.channelId, socket });
|
||||
try {
|
||||
await this.parent.admit(socket, ceiling, {
|
||||
kind: selfScope ? "scope" : "destination-link",
|
||||
});
|
||||
if (this.closed || socket.readyState !== 1) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
this.parent.delivery.reply({
|
||||
type: selfScope ? "hosts.route.open.response" : "hosts.link.open.response",
|
||||
payload: { requestId: message.requestId, routeId: id },
|
||||
});
|
||||
} catch {
|
||||
socket.close();
|
||||
error("host_offline", "Could not admit host channel");
|
||||
}
|
||||
}
|
||||
binary(frame: HostRouteFrame): boolean {
|
||||
const socket = this.channels.get(frame.routeId)?.socket;
|
||||
if (!socket) return false;
|
||||
|
||||
@@ -62,6 +62,24 @@ test("Hub execute can steer ordinary agents while unrelated administration stays
|
||||
expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]);
|
||||
});
|
||||
|
||||
test("execution-scoped admission cannot acquire an ordinary self session", async () => {
|
||||
const hub = await launchRelationship();
|
||||
expect(
|
||||
await hub.requestOrdinary({
|
||||
type: "hosts.route.open.request",
|
||||
requestId: "execution-self-scope",
|
||||
serverId: hub.serverId(),
|
||||
scopeId: "execution-self-scope",
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: "rpc_error",
|
||||
payload: { requestId: "execution-self-scope", code: "unsupported_capability" },
|
||||
});
|
||||
// Rejection leaves the originating execution capability usable.
|
||||
const created = await hub.createOwnedConcurrently();
|
||||
expect(created.duplicate.agentId).toBe(created.first.agentId);
|
||||
});
|
||||
|
||||
test("Hub execute can title the workspace created with an agent", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const created = await hub.requestOrdinary({
|
||||
@@ -165,11 +183,23 @@ test("ordinary Hub create and message retries do not duplicate agents or prompts
|
||||
};
|
||||
expect(await hub.requestOrdinary({ ...message, requestId: "message-first" })).toEqual({
|
||||
type: "send_agent_message_response",
|
||||
payload: { requestId: "message-first", agentId, accepted: true, error: null },
|
||||
payload: {
|
||||
requestId: "message-first",
|
||||
agentId,
|
||||
accepted: true,
|
||||
error: null,
|
||||
dispatch: { disposition: "turn_started", tracking: "turn" },
|
||||
},
|
||||
});
|
||||
expect(await hub.requestOrdinary({ ...message, requestId: "message-duplicate" })).toEqual({
|
||||
type: "send_agent_message_response",
|
||||
payload: { requestId: "message-duplicate", agentId, accepted: true, error: null },
|
||||
payload: {
|
||||
requestId: "message-duplicate",
|
||||
agentId,
|
||||
accepted: true,
|
||||
error: null,
|
||||
dispatch: { disposition: "turn_started", tracking: "turn" },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
hub
|
||||
|
||||
@@ -1212,6 +1212,10 @@ export class HubRelationshipHarness {
|
||||
);
|
||||
}
|
||||
|
||||
serverId(): string {
|
||||
return this.daemon!.getServerId();
|
||||
}
|
||||
|
||||
probeTrustedHello(): number | null {
|
||||
const socket = this.latestSocket().socket;
|
||||
socket.receiveEnvelope({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { HostDirectory } from "@getpaseo/protocol/hosts";
|
||||
import { shouldDetachFromArchivedParent } from "@getpaseo/protocol/agent-lifecycle";
|
||||
import { hasOpenAgentTab } from "@getpaseo/protocol/agent-labels";
|
||||
import { hasOpenAgentTab, getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import { WorkspaceAddressSchema, type WorkspaceAddress } from "@getpaseo/protocol/addresses";
|
||||
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { writeJsonFileAtomic } from "../atomic-file.js";
|
||||
import type { HostRuntimeAuthority } from "../host-runtime/index.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "../agent/agent-storage.js";
|
||||
import { buildStoredAgentPayload } from "../agent/agent-projections.js";
|
||||
|
||||
/** Stored authority contains identity and a ceiling; live admission belongs to the host. */
|
||||
export interface SubagentAuthority {
|
||||
@@ -31,11 +33,9 @@ const AuthoritySchema = z.object({
|
||||
sourceId: z.string(),
|
||||
permissionCeiling: z.array(DaemonPermissionSchema),
|
||||
});
|
||||
const RecordSchema = z.object({
|
||||
const RelationshipStateSchema = z.object({
|
||||
row: SubagentRelationshipSchema,
|
||||
authority: AuthoritySchema,
|
||||
creation: SubagentCreateInputSchema,
|
||||
fingerprint: z.string(),
|
||||
confirmed: z.enum(["attached", "detached", "archived", "canceled"]).nullable(),
|
||||
intent: z.object({
|
||||
revision: z.number().int().nonnegative(),
|
||||
@@ -44,8 +44,24 @@ const RecordSchema = z.object({
|
||||
authority: AuthoritySchema,
|
||||
}),
|
||||
});
|
||||
const RecordSchema = z.discriminatedUnion("provenance", [
|
||||
RelationshipStateSchema.extend({
|
||||
provenance: z.literal("creation"),
|
||||
creation: SubagentCreateInputSchema,
|
||||
fingerprint: z.string(),
|
||||
}),
|
||||
RelationshipStateSchema.extend({
|
||||
provenance: z.literal("legacy-local-lifecycle"),
|
||||
creation: z.null(),
|
||||
fingerprint: z.null(),
|
||||
}),
|
||||
]);
|
||||
type RelationshipRecord = z.infer<typeof RecordSchema>;
|
||||
type RelationshipIntent = RelationshipRecord["intent"];
|
||||
const LedgerSchema = z.object({
|
||||
records: z.array(RecordSchema),
|
||||
legacyUpgrade: z.object({ complete: z.boolean(), candidates: z.array(z.string()) }),
|
||||
});
|
||||
|
||||
/** Authoritative parent membership, independent of views and destination metadata. */
|
||||
export class Subagents {
|
||||
@@ -69,11 +85,13 @@ export class Subagents {
|
||||
private mutation: Promise<unknown> = Promise.resolve();
|
||||
private readonly ready: Promise<void>;
|
||||
private closed = false;
|
||||
private legacyUpgrade = { complete: false, candidates: [] as string[] };
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
home: string;
|
||||
serverId: string;
|
||||
agents: Pick<AgentStorage, "list" | "get" | "upsert">;
|
||||
runtime(authority: HostRuntimeAuthority): Promise<HostRuntime>;
|
||||
resolveAuthority(principalId: string): ReturnType<HostRuntimeAuthority["resolveAdmission"]>;
|
||||
},
|
||||
@@ -83,17 +101,99 @@ export class Subagents {
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
let records: RelationshipRecord[];
|
||||
let records: RelationshipRecord[] = [];
|
||||
let existing = false;
|
||||
try {
|
||||
records = z.array(RecordSchema).parse(JSON.parse(await readFile(this.path(), "utf8")));
|
||||
const ledger = LedgerSchema.parse(JSON.parse(await readFile(this.path(), "utf8")));
|
||||
records = ledger.records;
|
||||
this.legacyUpgrade = ledger.legacyUpgrade;
|
||||
existing = true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
||||
throw error;
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
for (const record of records) {
|
||||
record.row.freshness = "stale";
|
||||
this.records.set(record.row.id, record);
|
||||
}
|
||||
if (!this.legacyUpgrade.complete) await this.upgradeLegacyMembership(existing);
|
||||
}
|
||||
initialize(): Promise<void> {
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
private async upgradeLegacyMembership(existing: boolean): Promise<void> {
|
||||
if (!existing) {
|
||||
const agents = await this.options.agents.list();
|
||||
const byId = new Map(agents.map((agent) => [agent.id, agent]));
|
||||
const admission = await this.options.resolveAuthority("owner");
|
||||
const authority: z.infer<typeof AuthoritySchema> = {
|
||||
principalId: "owner",
|
||||
sourceId: "owner",
|
||||
permissionCeiling: admission.permissions.filter((permission) =>
|
||||
["daemon.read", "workspace.read", "workspace.write"].includes(permission),
|
||||
),
|
||||
};
|
||||
for (const child of agents) {
|
||||
const parentId = getParentAgentIdFromLabels(child.labels);
|
||||
const parent = parentId ? byId.get(parentId) : undefined;
|
||||
if (
|
||||
!legacyManagedAgent(child) ||
|
||||
child.archivedAt ||
|
||||
!parent ||
|
||||
!legacyManagedAgent(parent) ||
|
||||
child.parent !== undefined
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
[...this.records.values()].some(
|
||||
(record) =>
|
||||
record.row.child.serverId === this.options.serverId &&
|
||||
record.row.child.agentId === child.id,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const id = createHash("sha256")
|
||||
.update(JSON.stringify(["legacy-local-lifecycle", this.options.serverId, child.id]))
|
||||
.digest("hex");
|
||||
const address = { serverId: this.options.serverId, agentId: parent.id };
|
||||
this.records.set(id, {
|
||||
row: {
|
||||
id,
|
||||
parent: address,
|
||||
child: { serverId: this.options.serverId, agentId: child.id },
|
||||
...(child.workspaceId
|
||||
? { workspace: { serverId: this.options.serverId, workspaceId: child.workspaceId } }
|
||||
: {}),
|
||||
phase: "attached",
|
||||
freshness: "stale",
|
||||
observedAt: null,
|
||||
error: null,
|
||||
snapshot: buildStoredAgentPayload({ ...child, parent: address }, [child.provider]),
|
||||
},
|
||||
provenance: "legacy-local-lifecycle",
|
||||
creation: null,
|
||||
fingerprint: null,
|
||||
authority,
|
||||
confirmed: "attached",
|
||||
intent: { kind: "attached", revision: 0, authority },
|
||||
});
|
||||
this.legacyUpgrade.candidates.push(id);
|
||||
}
|
||||
// Persist the bounded candidate set before qualifying agent metadata. A restart
|
||||
// resumes these candidates, never a new scan of subsequently edited labels.
|
||||
await this.persist();
|
||||
}
|
||||
for (const id of this.legacyUpgrade.candidates) {
|
||||
const record = this.records.get(id)!;
|
||||
const child = await this.options.agents.get(record.row.child.agentId);
|
||||
if (!child) continue;
|
||||
if (child.parent === undefined)
|
||||
await this.options.agents.upsert({ ...child, parent: record.row.parent });
|
||||
else if (!child.parent || !sameAddress(child.parent, record.row.parent))
|
||||
throw new Error("Legacy child parent changed during membership upgrade");
|
||||
}
|
||||
this.legacyUpgrade.complete = true;
|
||||
await this.persist();
|
||||
}
|
||||
private path(): string {
|
||||
return join(this.options.home, "subagents.json");
|
||||
@@ -109,7 +209,10 @@ export class Subagents {
|
||||
}
|
||||
private async persist(): Promise<void> {
|
||||
for (const record of this.records.values()) record.row.phase = relationshipPhase(record);
|
||||
await writeJsonFileAtomic(this.path(), [...this.records.values()]);
|
||||
await writeJsonFileAtomic(this.path(), {
|
||||
records: [...this.records.values()],
|
||||
legacyUpgrade: this.legacyUpgrade,
|
||||
});
|
||||
for (const changed of this.listeners) changed();
|
||||
}
|
||||
async assertHostRemovable(serverId: string): Promise<void> {
|
||||
@@ -262,6 +365,7 @@ export class Subagents {
|
||||
return existing;
|
||||
}
|
||||
const fresh: RelationshipRecord = {
|
||||
provenance: "creation",
|
||||
row: {
|
||||
id,
|
||||
parent,
|
||||
@@ -309,10 +413,11 @@ export class Subagents {
|
||||
return running;
|
||||
}
|
||||
private creationKey(record: RelationshipRecord): string {
|
||||
return record.creation.input.idempotencyKey ?? `subagent:${record.row.id}`;
|
||||
return creationRequest(record).input.idempotencyKey ?? `subagent:${record.row.id}`;
|
||||
}
|
||||
private async createChild(record: RelationshipRecord): Promise<AgentAddress> {
|
||||
const { row, creation } = record;
|
||||
const { row } = record;
|
||||
const creation = creationRequest(record);
|
||||
const creationIntent = record.intent;
|
||||
try {
|
||||
const runtime = await this.runtime(record.authority);
|
||||
@@ -655,7 +760,7 @@ export class Subagents {
|
||||
return null;
|
||||
}
|
||||
const observation = api.creations.observe({
|
||||
kind: record.creation.workspace ? "workspace" : "agent",
|
||||
kind: creationRequest(record).workspace ? "workspace" : "agent",
|
||||
idempotencyKey: this.creationKey(record),
|
||||
});
|
||||
let latest: CreationSnapshot | null = null;
|
||||
@@ -705,12 +810,13 @@ export class Subagents {
|
||||
const child = await runtime.agent(record.row.child);
|
||||
let current = await child.refresh();
|
||||
if (!current) {
|
||||
const creation = creationRequest(record);
|
||||
const destination = await runtime.client(record.row.child.serverId);
|
||||
try {
|
||||
if (record.intent !== intent) return;
|
||||
const cancellation = await destination.creations.cancel({
|
||||
...(record.creation.workspace
|
||||
? { kind: "workspace" as const, workspaceId: record.creation.input.workspaceId! }
|
||||
...(creation.workspace
|
||||
? { kind: "workspace" as const, workspaceId: creation.input.workspaceId! }
|
||||
: { kind: "agent" as const }),
|
||||
idempotencyKey: this.creationKey(record),
|
||||
agentId: record.row.child.agentId,
|
||||
@@ -842,3 +948,16 @@ function terminalPhase(phase: RelationshipRecord["row"]["phase"]): boolean {
|
||||
phase === "detached" || phase === "archived" || phase === "forgotten" || phase === "canceled"
|
||||
);
|
||||
}
|
||||
|
||||
function legacyManagedAgent(agent: StoredAgentRecord): boolean {
|
||||
return !agent.internal && !agent.owner;
|
||||
}
|
||||
|
||||
function creationRequest(record: RelationshipRecord): SubagentCreateInput {
|
||||
if (record.provenance !== "creation" || !record.creation)
|
||||
throw failure(
|
||||
"agent_not_found",
|
||||
"The adopted existing child is absent; there is no creation to replay or cancel",
|
||||
);
|
||||
return record.creation;
|
||||
}
|
||||
|
||||
@@ -755,6 +755,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.subagents = new Subagents({
|
||||
home: paseoHome,
|
||||
serverId,
|
||||
agents: this.agentStorage,
|
||||
runtime: (authority) => this.createHostRuntime(authority),
|
||||
resolveAuthority: (principalId) => this.resolveRelationshipAdmission(principalId),
|
||||
});
|
||||
@@ -766,13 +767,16 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.logger.warn({ err: error }, "Failed to reconcile subagent host availability"),
|
||||
);
|
||||
});
|
||||
this.stopAgentArchived = this.agentManager.onAgentArchived(async (agentId) => {
|
||||
const record = await this.agentStorage.get(agentId);
|
||||
await this.subagents.parentArchived(
|
||||
{ serverId, agentId },
|
||||
record?.workspaceId ? { serverId, workspaceId: record.workspaceId } : undefined,
|
||||
);
|
||||
});
|
||||
this.stopAgentArchived = this.agentManager.onAgentArchived(
|
||||
async (agentId) => {
|
||||
const record = await this.agentStorage.get(agentId);
|
||||
await this.subagents.parentArchived(
|
||||
{ serverId, agentId },
|
||||
record?.workspaceId ? { serverId, workspaceId: record.workspaceId } : undefined,
|
||||
);
|
||||
},
|
||||
{ ownsManagedChildren: true },
|
||||
);
|
||||
this.passwordHash = auth?.password;
|
||||
this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot;
|
||||
this.daemonConfigStore = daemonConfigStore;
|
||||
@@ -1158,6 +1162,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.connectionLifecycle = "stopping";
|
||||
}
|
||||
|
||||
public initializeRelationships(): Promise<void> {
|
||||
return this.subagents.initialize();
|
||||
}
|
||||
|
||||
public beginAcceptingConnections(): void {
|
||||
if (this.connectionLifecycle === "starting") {
|
||||
this.connectionLifecycle = "accepting";
|
||||
@@ -2029,6 +2037,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
connectedHosts: true,
|
||||
fleetSubagents: true,
|
||||
hostMultiplexing: true,
|
||||
hostSessionScopes: true,
|
||||
usageSources: true,
|
||||
ownedSubscriptions: true,
|
||||
agentRequestReceipts: true,
|
||||
@@ -2632,15 +2641,30 @@ export class VoiceAssistantWebSocketServer {
|
||||
if (!link) {
|
||||
link = new DaemonLinkEndpoint({
|
||||
identity: JSON.stringify([connection.principalId, connection.clientId]),
|
||||
serverId: this.serverId,
|
||||
assertScopeAdmission: () => {
|
||||
if (connection.hubExecutionAgents)
|
||||
throw Object.assign(new Error("Execution-scoped host acquisition is not supported"), {
|
||||
code: "unsupported_capability",
|
||||
});
|
||||
},
|
||||
permissions: () => connection.session.getPermissions(),
|
||||
routed: () => connection.session.isRouted(),
|
||||
bufferedAmount: () => ws.bufferedAmount ?? null,
|
||||
delivery: connection.session.delivery,
|
||||
admit: (socket, permissions) =>
|
||||
this.attachSocket(socket, undefined, undefined, false, {
|
||||
admit: (socket, permissions, context) => {
|
||||
if (connection.hubExecutionAgents)
|
||||
throw new Error("Execution-scoped host acquisition is not supported");
|
||||
this.ownedSockets.add(socket);
|
||||
if (context.kind === "scope")
|
||||
this.ownedSocketSources.set(socket, connection.session.getHostSourceId());
|
||||
return this.attachSocket(socket, undefined, undefined, false, {
|
||||
principalId: connection.principalId,
|
||||
permissions,
|
||||
}),
|
||||
permissions: permissions.filter((permission) =>
|
||||
connection.session.getPermissions().includes(permission),
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
this.hostLinks.set(ws, link);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user