@vellumai/assistant 0.12.2-staging.2 → 0.12.2-staging.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/desktop-browser-cli.md +33 -0
- package/openapi.yaml +17 -5
- package/package.json +1 -1
- package/scripts/smoke-desktop-browser-cli.ts +279 -0
- package/src/__tests__/config-schema.test.ts +1 -1
- package/src/__tests__/config-sounds-sync.test.ts +23 -0
- package/src/__tests__/conversation-error.test.ts +4 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +6 -4
- package/src/__tests__/external-plugin-loader.test.ts +52 -0
- package/src/__tests__/headless-browser-mode.test.ts +48 -0
- package/src/__tests__/host-cu-proxy.test.ts +116 -18
- package/src/__tests__/list-all-apps.test.ts +40 -8
- package/src/__tests__/mcp-auth-routes.test.ts +99 -0
- package/src/__tests__/mcp-client-auth.test.ts +54 -0
- package/src/__tests__/mcp-health-check.test.ts +32 -32
- package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
- package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
- package/src/__tests__/skills.test.ts +10 -0
- package/src/__tests__/user-plugin-loader.test.ts +21 -0
- package/src/apps/app-store.ts +6 -5
- package/src/browser/operations.ts +2 -1
- package/src/browser/types.ts +7 -0
- package/src/browser/virtual-desktop-target.ts +34 -0
- package/src/calls/__tests__/progress-narration.test.ts +23 -0
- package/src/calls/__tests__/voice-control-protocol.test.ts +7 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
- package/src/calls/progress-narration.ts +15 -2
- package/src/calls/voice-control-protocol.ts +34 -4
- package/src/calls/voice-session-bridge.ts +23 -0
- package/src/cli/commands/__tests__/browser.test.ts +84 -3
- package/src/cli/commands/browser.help.ts +43 -0
- package/src/cli/commands/browser.ts +52 -8
- package/src/cli/commands/plugins.ts +16 -5
- package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
- package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
- package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
- package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
- package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
- package/src/cli/lib/bundled-marketplace.json +13 -0
- package/src/cli/lib/install-from-github.ts +17 -18
- package/src/cli/lib/install-from-platform.ts +14 -9
- package/src/cli/lib/list-installed-plugins.ts +21 -35
- package/src/cli/lib/plugin-details.ts +21 -8
- package/src/cli/lib/uninstall-plugin.ts +59 -1
- package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
- package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
- package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
- package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
- package/src/config/feature-flag-registry.json +2 -2
- package/src/config/schemas/__tests__/voice.test.ts +2 -2
- package/src/config/schemas/mcp.ts +9 -7
- package/src/config/schemas/monitoring.ts +9 -0
- package/src/config/schemas/voice.ts +2 -2
- package/src/config/skills.ts +13 -21
- package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
- package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
- package/src/daemon/conversation-client-surface.ts +26 -0
- package/src/daemon/conversation-runtime-assembly.ts +1 -1
- package/src/daemon/conversation-tool-setup.ts +31 -29
- package/src/daemon/host-cu-proxy.ts +51 -26
- package/src/daemon/host-proxy-preactivation.ts +2 -0
- package/src/daemon/mcp-reload-service.ts +7 -0
- package/src/daemon/message-types/sync.ts +1 -0
- package/src/daemon/providers-setup.ts +4 -0
- package/src/desktop/__tests__/fake-desktop.ts +1 -0
- package/src/desktop/desktop-automation-lease.test.ts +208 -0
- package/src/desktop/desktop-automation-lease.ts +270 -0
- package/src/desktop/desktop-browser-client.test.ts +413 -0
- package/src/desktop/desktop-browser-client.ts +430 -0
- package/src/desktop/desktop-browser-cursor.ts +40 -0
- package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
- package/src/desktop/desktop-browser-endpoint.ts +145 -0
- package/src/desktop/desktop-browser-operations.ts +130 -0
- package/src/desktop/desktop-chrome-session.test.ts +48 -0
- package/src/desktop/desktop-chrome-session.ts +58 -8
- package/src/desktop/desktop-dependencies.test.ts +29 -0
- package/src/desktop/desktop-dependencies.ts +26 -5
- package/src/desktop/desktop-display.ts +9 -0
- package/src/desktop/desktop-panel-config.ts +14 -1
- package/src/desktop/desktop-session-manager.test.ts +86 -3
- package/src/desktop/desktop-session-manager.ts +144 -24
- package/src/desktop/desktop-stream-bridge.ts +1 -1
- package/src/desktop/virtual-desktop-feature.ts +18 -0
- package/src/desktop/virtual-desktop-platform.test.ts +61 -0
- package/src/i18n/__tests__/i18n.test.ts +9 -5
- package/src/i18n/messages.ts +13 -4
- package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
- package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
- package/src/ipc/assistant-server.ts +15 -1
- package/src/ipc/cli-client.ts +13 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +22 -0
- package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
- package/src/live-voice/__tests__/protocol.test.ts +11 -3
- package/src/live-voice/__tests__/session-controls.test.ts +34 -0
- package/src/live-voice/live-voice-metrics.ts +9 -0
- package/src/live-voice/live-voice-session.ts +3 -0
- package/src/live-voice/protocol.ts +10 -1
- package/src/live-voice/session-controls.ts +28 -0
- package/src/mcp/__tests__/credential-target.test.ts +59 -0
- package/src/mcp/__tests__/effective-config.test.ts +11 -0
- package/src/mcp/__tests__/manager-state.test.ts +128 -0
- package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
- package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
- package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
- package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
- package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
- package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
- package/src/mcp/client.ts +64 -16
- package/src/mcp/credential-target.ts +105 -0
- package/src/mcp/effective-config.ts +42 -15
- package/src/mcp/manager.ts +61 -8
- package/src/mcp/mcp-auth-orchestrator.ts +17 -4
- package/src/mcp/mcp-auth-state.ts +38 -6
- package/src/mcp/mcp-oauth-provider.ts +101 -33
- package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
- package/src/monitoring/mount-watch.ts +348 -0
- package/src/monitoring/worker.ts +9 -0
- package/src/oauth/seed-providers.ts +11 -0
- package/src/onboarding/checkin-event.ts +2 -1
- package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
- package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
- package/src/plugins/external-plugin-loader.ts +49 -86
- package/src/plugins/installed-plugin-dirs.ts +6 -4
- package/src/plugins/mcp-servers.ts +9 -15
- package/src/plugins/mtime-cache.ts +2 -1
- package/src/plugins/user-loader.ts +2 -1
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
- package/src/providers/inference/endpoint-probe.ts +27 -12
- package/src/providers/openai/responses-provider.ts +9 -2
- package/src/providers/opencode/client.test.ts +167 -0
- package/src/providers/opencode/client.ts +73 -4
- package/src/runtime/AGENTS.md +4 -0
- package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
- package/src/runtime/http-server.ts +6 -6
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
- package/src/runtime/routes/browser-context.ts +39 -0
- package/src/runtime/routes/browser-routes.ts +16 -47
- package/src/runtime/routes/browser-tabs-routes.ts +72 -29
- package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
- package/src/runtime/routes/desktop-setup-routes.ts +7 -5
- package/src/runtime/routes/mcp-auth-routes.ts +106 -94
- package/src/runtime/routes/plugins-routes.ts +14 -1
- package/src/runtime/sync/resource-sync-events.ts +4 -0
- package/src/tools/browser/browser-execution.ts +44 -13
- package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
- package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
- package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
- package/src/tools/computer-use/definitions.ts +2 -3
- package/src/util/abort-reasons.ts +12 -1
- package/src/util/plugin-manifest.ts +203 -0
- package/src/desktop/desktop-feature.test.ts +0 -18
- package/src/desktop/desktop-feature.ts +0 -19
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { afterEach, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { DesktopAutomationLease } from "./desktop-automation-lease.js";
|
|
4
|
+
import { DesktopDependencyInstaller } from "./desktop-dependencies.js";
|
|
5
|
+
import type { DesktopSessionManager } from "./desktop-session-manager.js";
|
|
6
|
+
|
|
7
|
+
const context = {
|
|
8
|
+
conversationId: "conv-123",
|
|
9
|
+
sourceActorPrincipalId: "user-123",
|
|
10
|
+
trustClass: "guardian" as const,
|
|
11
|
+
workingDir: "/tmp",
|
|
12
|
+
};
|
|
13
|
+
const cleanups: DesktopAutomationLease[] = [];
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await Promise.all(
|
|
16
|
+
cleanups
|
|
17
|
+
.splice(0)
|
|
18
|
+
.map((lease) => lease.runBrowser(context, operation, true)),
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
function fixture() {
|
|
22
|
+
let enabled = true;
|
|
23
|
+
let ready = true;
|
|
24
|
+
const setup = Promise.withResolvers<void>();
|
|
25
|
+
const install = mock(() => setup.promise);
|
|
26
|
+
const installer = new DesktopDependencyInstaller({
|
|
27
|
+
supported: () => true,
|
|
28
|
+
ready: () => ready,
|
|
29
|
+
install,
|
|
30
|
+
notify: async () => {},
|
|
31
|
+
});
|
|
32
|
+
const ensureReady = mock((signal: AbortSignal) =>
|
|
33
|
+
installer.ensureReady(signal),
|
|
34
|
+
);
|
|
35
|
+
const started = mock(async () => {});
|
|
36
|
+
const released = mock(() => {});
|
|
37
|
+
const releaseBrowser = mock(async () => {});
|
|
38
|
+
const lease = new DesktopAutomationLease({
|
|
39
|
+
enabled: () => enabled,
|
|
40
|
+
ready: () => ready,
|
|
41
|
+
ensureReady,
|
|
42
|
+
manager: () =>
|
|
43
|
+
({
|
|
44
|
+
browser: { release: releaseBrowser },
|
|
45
|
+
acquireAutomationSlot: () => ({ ok: true }),
|
|
46
|
+
releaseAutomationSlot: released,
|
|
47
|
+
ensureDesktopRunning: started,
|
|
48
|
+
}) as unknown as DesktopSessionManager,
|
|
49
|
+
});
|
|
50
|
+
cleanups.push(lease);
|
|
51
|
+
return {
|
|
52
|
+
lease,
|
|
53
|
+
ensureReady,
|
|
54
|
+
install,
|
|
55
|
+
failSetup: () => setup.reject(new Error("download failed")),
|
|
56
|
+
completeSetup: () => {
|
|
57
|
+
ready = true;
|
|
58
|
+
setup.resolve();
|
|
59
|
+
},
|
|
60
|
+
started,
|
|
61
|
+
released,
|
|
62
|
+
releaseBrowser,
|
|
63
|
+
disable: () => {
|
|
64
|
+
enabled = false;
|
|
65
|
+
},
|
|
66
|
+
uninstall: () => {
|
|
67
|
+
ready = false;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const operation = async () => ({ content: "ok", isError: false });
|
|
72
|
+
|
|
73
|
+
test("browser session reuses one automation slot and isolates conversations", async () => {
|
|
74
|
+
const f = fixture();
|
|
75
|
+
await f.lease.runBrowser(context, operation);
|
|
76
|
+
await f.lease.runBrowser(context, operation);
|
|
77
|
+
expect(f.started).toHaveBeenCalledTimes(1);
|
|
78
|
+
await expect(
|
|
79
|
+
f.lease.runBrowser({ ...context, conversationId: "conv-456" }, operation),
|
|
80
|
+
).rejects.toThrow("Another conversation");
|
|
81
|
+
expect(f.released).not.toHaveBeenCalled();
|
|
82
|
+
await f.lease.runBrowser(context, operation, true);
|
|
83
|
+
expect(f.released).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
for (const lose of ["disable", "uninstall"] as const) {
|
|
87
|
+
test(`${lose} blocks startup, reuse and startup races`, async () => {
|
|
88
|
+
const unavailable = fixture();
|
|
89
|
+
unavailable[lose]();
|
|
90
|
+
if (lose === "uninstall") {
|
|
91
|
+
unavailable.ensureReady.mockRejectedValueOnce(new Error("setup failed"));
|
|
92
|
+
}
|
|
93
|
+
await expect(
|
|
94
|
+
unavailable.lease.runBrowser(context, operation),
|
|
95
|
+
).rejects.toThrow();
|
|
96
|
+
expect(unavailable.started).not.toHaveBeenCalled();
|
|
97
|
+
const race = fixture();
|
|
98
|
+
race.started.mockImplementationOnce(async () => race[lose]());
|
|
99
|
+
const callback = mock(operation);
|
|
100
|
+
await expect(race.lease.runBrowser(context, callback)).rejects.toThrow();
|
|
101
|
+
expect(callback).not.toHaveBeenCalled();
|
|
102
|
+
expect(race.released).toHaveBeenCalledTimes(1);
|
|
103
|
+
const reused = fixture();
|
|
104
|
+
await reused.lease.runBrowser(context, operation);
|
|
105
|
+
reused[lose]();
|
|
106
|
+
await expect(reused.lease.runBrowser(context, callback)).rejects.toThrow();
|
|
107
|
+
expect(callback).not.toHaveBeenCalled();
|
|
108
|
+
expect(reused.released).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test("cancellation stops running and queued browser commands without blocking a new session", async () => {
|
|
113
|
+
const f = fixture();
|
|
114
|
+
const started = Promise.withResolvers<void>();
|
|
115
|
+
const abort = new AbortController();
|
|
116
|
+
const running = f.lease
|
|
117
|
+
.runBrowser({ ...context, signal: abort.signal }, async (signal) => {
|
|
118
|
+
started.resolve();
|
|
119
|
+
await new Promise<void>((_resolve, reject) => {
|
|
120
|
+
signal.addEventListener("abort", () => reject(new Error("aborted")), {
|
|
121
|
+
once: true,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
return operation();
|
|
125
|
+
})
|
|
126
|
+
.catch((error: Error) => error);
|
|
127
|
+
await started.promise;
|
|
128
|
+
const callback = mock(operation);
|
|
129
|
+
const queued = f.lease.runBrowser(context, callback);
|
|
130
|
+
abort.abort();
|
|
131
|
+
expect(await running).toBeInstanceOf(Error);
|
|
132
|
+
expect((await queued).isError).toBe(true);
|
|
133
|
+
expect(callback).not.toHaveBeenCalled();
|
|
134
|
+
await f.lease.runBrowser(context, callback);
|
|
135
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("failed browser cleanup retains ownership until release succeeds", async () => {
|
|
139
|
+
const f = fixture();
|
|
140
|
+
await f.lease.runBrowser(context, operation);
|
|
141
|
+
f.releaseBrowser.mockRejectedValueOnce(new Error("Chrome busy"));
|
|
142
|
+
await expect(f.lease.runBrowser(context, operation, true)).rejects.toThrow(
|
|
143
|
+
"Chrome busy",
|
|
144
|
+
);
|
|
145
|
+
expect(f.released).not.toHaveBeenCalled();
|
|
146
|
+
await f.lease.runBrowser(context, operation, true);
|
|
147
|
+
expect(f.released).toHaveBeenCalledTimes(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("first browser call waits for one shared install then executes exactly once", async () => {
|
|
151
|
+
const f = fixture();
|
|
152
|
+
f.uninstall();
|
|
153
|
+
const callback = mock(operation);
|
|
154
|
+
const first = f.lease.runBrowser(context, callback);
|
|
155
|
+
await Bun.sleep(0);
|
|
156
|
+
expect(f.install).toHaveBeenCalledTimes(1);
|
|
157
|
+
expect(f.started).not.toHaveBeenCalled();
|
|
158
|
+
expect(callback).not.toHaveBeenCalled();
|
|
159
|
+
f.completeSetup();
|
|
160
|
+
expect((await first).isError).toBe(false);
|
|
161
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
162
|
+
await f.lease.runBrowser(context, callback);
|
|
163
|
+
expect(f.install).toHaveBeenCalledTimes(1);
|
|
164
|
+
expect(f.started).toHaveBeenCalledTimes(1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
for (const interrupt of ["cancel", "disable", "failure"] as const) {
|
|
168
|
+
test(`${interrupt} during setup prevents a delayed browser action`, async () => {
|
|
169
|
+
const f = fixture();
|
|
170
|
+
f.uninstall();
|
|
171
|
+
const abort = new AbortController();
|
|
172
|
+
const callback = mock(operation);
|
|
173
|
+
const result = f.lease
|
|
174
|
+
.runBrowser({ ...context, signal: abort.signal }, callback)
|
|
175
|
+
.catch((error: unknown) => error);
|
|
176
|
+
await Bun.sleep(0);
|
|
177
|
+
if (interrupt === "cancel") {
|
|
178
|
+
abort.abort();
|
|
179
|
+
} else if (interrupt === "disable") {
|
|
180
|
+
f.disable();
|
|
181
|
+
} else {
|
|
182
|
+
f.failSetup();
|
|
183
|
+
}
|
|
184
|
+
expect(await result).toBeInstanceOf(Error);
|
|
185
|
+
f.completeSetup();
|
|
186
|
+
await Bun.sleep(0);
|
|
187
|
+
expect(f.started).not.toHaveBeenCalled();
|
|
188
|
+
expect(callback).not.toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
test("disabled, unidentified, cancelled and released browser calls cannot install", async () => {
|
|
193
|
+
const f = fixture();
|
|
194
|
+
f.uninstall();
|
|
195
|
+
const cancelled = new AbortController();
|
|
196
|
+
cancelled.abort();
|
|
197
|
+
for (const caller of [
|
|
198
|
+
{ ...context, trustClass: "unknown" as const },
|
|
199
|
+
{ ...context, sourceActorPrincipalId: undefined },
|
|
200
|
+
{ ...context, signal: cancelled.signal },
|
|
201
|
+
]) {
|
|
202
|
+
await expect(f.lease.runBrowser(caller, operation)).rejects.toThrow();
|
|
203
|
+
}
|
|
204
|
+
await f.lease.runBrowser(context, operation, true);
|
|
205
|
+
f.disable();
|
|
206
|
+
await expect(f.lease.runBrowser(context, operation)).rejects.toThrow();
|
|
207
|
+
expect(f.ensureReady).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { getConfig } from "../config/loader.js";
|
|
2
|
+
import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
|
|
3
|
+
import { getLogger } from "../util/logger.js";
|
|
4
|
+
import { desktopDependencyInstaller } from "./desktop-dependencies.js";
|
|
5
|
+
import {
|
|
6
|
+
type DesktopSessionManager,
|
|
7
|
+
type DesktopViewer,
|
|
8
|
+
getDesktopSessionManager,
|
|
9
|
+
} from "./desktop-session-manager.js";
|
|
10
|
+
import { isVirtualDesktopEnabled } from "./virtual-desktop-feature.js";
|
|
11
|
+
|
|
12
|
+
const log = getLogger("desktop-automation-lease");
|
|
13
|
+
const IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
14
|
+
const MAX_ACTIONS = 100;
|
|
15
|
+
type Owner = {
|
|
16
|
+
conversationId: string;
|
|
17
|
+
actorId: string;
|
|
18
|
+
abort: AbortController;
|
|
19
|
+
holder: DesktopViewer;
|
|
20
|
+
removeAbortListener: () => void;
|
|
21
|
+
actions: number;
|
|
22
|
+
lastActivity: number;
|
|
23
|
+
desktopLost: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export class DesktopAutomationLease {
|
|
27
|
+
private owner: Owner | null = null;
|
|
28
|
+
private generation = 0;
|
|
29
|
+
private tail: Promise<unknown> = Promise.resolve();
|
|
30
|
+
private watchdog: ReturnType<typeof setInterval> | undefined;
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
private readonly deps: {
|
|
34
|
+
enabled: () => boolean;
|
|
35
|
+
ready: () => boolean;
|
|
36
|
+
ensureReady: (signal: AbortSignal) => Promise<void>;
|
|
37
|
+
manager: () => DesktopSessionManager;
|
|
38
|
+
} = {
|
|
39
|
+
enabled: () => isVirtualDesktopEnabled(getConfig()),
|
|
40
|
+
ready: () => desktopDependencyInstaller.getStatus().state === "ready",
|
|
41
|
+
ensureReady: (signal) => desktopDependencyInstaller.ensureReady(signal),
|
|
42
|
+
manager: getDesktopSessionManager,
|
|
43
|
+
},
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
private exclusive<T>(run: () => Promise<T>): Promise<T> {
|
|
47
|
+
const next = this.tail.then(run, run);
|
|
48
|
+
this.tail = next.catch(() => {});
|
|
49
|
+
return next;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private assertAvailable(): void {
|
|
53
|
+
if (!this.deps.enabled()) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"Virtual desktop browser automation is available only on enabled platform-hosted assistants",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (!this.deps.ready()) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"Open the Virtual desktop panel and wait for automatic installation to finish before using desktop browser automation",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async release(): Promise<void> {
|
|
66
|
+
const owner = this.owner;
|
|
67
|
+
if (!owner) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
owner.abort.abort();
|
|
71
|
+
owner.removeAbortListener();
|
|
72
|
+
clearInterval(this.watchdog);
|
|
73
|
+
this.watchdog = undefined;
|
|
74
|
+
if (!owner.desktopLost) {
|
|
75
|
+
await this.deps.manager().browser.release();
|
|
76
|
+
}
|
|
77
|
+
this.deps.manager().releaseAutomationSlot(owner.holder);
|
|
78
|
+
this.owner = null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private cancel(owner: Owner): void {
|
|
82
|
+
if (this.owner !== owner) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.generation += 1;
|
|
86
|
+
owner.abort.abort();
|
|
87
|
+
void this.exclusive(() => this.release()).catch((err) =>
|
|
88
|
+
log.warn({ err }, "Desktop browser session cleanup failed"),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private bindCancellation(owner: Owner, signal?: AbortSignal): void {
|
|
93
|
+
owner.removeAbortListener();
|
|
94
|
+
const cancel = () => this.cancel(owner);
|
|
95
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
96
|
+
owner.removeAbortListener = () =>
|
|
97
|
+
signal?.removeEventListener("abort", cancel);
|
|
98
|
+
if (signal?.aborted) {
|
|
99
|
+
cancel();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async claim(context: ToolContext): Promise<Owner> {
|
|
104
|
+
const holder: DesktopViewer = {
|
|
105
|
+
onDesktopLost: () => {
|
|
106
|
+
owner.desktopLost = true;
|
|
107
|
+
this.cancel(owner);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
const owner: Owner = {
|
|
111
|
+
conversationId: context.conversationId,
|
|
112
|
+
actorId: context.sourceActorPrincipalId!,
|
|
113
|
+
abort: new AbortController(),
|
|
114
|
+
holder,
|
|
115
|
+
actions: 0,
|
|
116
|
+
lastActivity: Date.now(),
|
|
117
|
+
desktopLost: false,
|
|
118
|
+
removeAbortListener: () => {},
|
|
119
|
+
};
|
|
120
|
+
const slot = this.deps.manager().acquireAutomationSlot(holder);
|
|
121
|
+
if (!slot.ok) {
|
|
122
|
+
throw new Error("The desktop is busy or shutting down");
|
|
123
|
+
}
|
|
124
|
+
this.owner = owner;
|
|
125
|
+
this.bindCancellation(owner, context.signal);
|
|
126
|
+
const cancel = () => this.cancel(owner);
|
|
127
|
+
this.watchdog = setInterval(() => {
|
|
128
|
+
try {
|
|
129
|
+
if (
|
|
130
|
+
Date.now() - owner.lastActivity > IDLE_TIMEOUT_MS ||
|
|
131
|
+
!this.deps.enabled() ||
|
|
132
|
+
!this.deps.ready()
|
|
133
|
+
) {
|
|
134
|
+
cancel();
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
log.warn({ err }, "Desktop browser session availability check failed");
|
|
138
|
+
cancel();
|
|
139
|
+
}
|
|
140
|
+
}, 1_000);
|
|
141
|
+
this.watchdog.unref?.();
|
|
142
|
+
try {
|
|
143
|
+
await this.deps.manager().ensureDesktopRunning();
|
|
144
|
+
owner.abort.signal.throwIfAborted();
|
|
145
|
+
this.assertAvailable();
|
|
146
|
+
await this.deps.manager().browser.release();
|
|
147
|
+
return owner;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
await this.release().catch((cleanupError) =>
|
|
150
|
+
log.warn(
|
|
151
|
+
{ err: cleanupError },
|
|
152
|
+
"Desktop browser session cleanup failed",
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
runBrowser(
|
|
160
|
+
context: ToolContext,
|
|
161
|
+
operation: (signal: AbortSignal) => Promise<ToolExecutionResult>,
|
|
162
|
+
done = false,
|
|
163
|
+
): Promise<ToolExecutionResult> {
|
|
164
|
+
const generation = this.generation;
|
|
165
|
+
return this.exclusive(async () => {
|
|
166
|
+
if (
|
|
167
|
+
context.trustClass !== "guardian" ||
|
|
168
|
+
!context.sourceActorPrincipalId ||
|
|
169
|
+
!context.conversationId
|
|
170
|
+
) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
"Desktop browser session requires an identified guardian conversation",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (
|
|
176
|
+
this.owner &&
|
|
177
|
+
(this.owner.conversationId !== context.conversationId ||
|
|
178
|
+
this.owner.actorId !== context.sourceActorPrincipalId)
|
|
179
|
+
) {
|
|
180
|
+
throw new Error("Another conversation is controlling the desktop");
|
|
181
|
+
}
|
|
182
|
+
if (done) {
|
|
183
|
+
await this.release();
|
|
184
|
+
return { content: "Desktop browser session released.", isError: false };
|
|
185
|
+
}
|
|
186
|
+
context.signal?.throwIfAborted();
|
|
187
|
+
try {
|
|
188
|
+
if (
|
|
189
|
+
!this.owner &&
|
|
190
|
+
generation === this.generation &&
|
|
191
|
+
this.deps.enabled() &&
|
|
192
|
+
!this.deps.ready()
|
|
193
|
+
) {
|
|
194
|
+
const abort = new AbortController();
|
|
195
|
+
const signal = AbortSignal.any([
|
|
196
|
+
abort.signal,
|
|
197
|
+
...(context.signal ? [context.signal] : []),
|
|
198
|
+
]);
|
|
199
|
+
const timeout = setTimeout(
|
|
200
|
+
() =>
|
|
201
|
+
abort.abort(
|
|
202
|
+
new Error(
|
|
203
|
+
"Virtual desktop setup is taking too long. Check installation progress in the Virtual desktop panel. No browser action was performed.",
|
|
204
|
+
),
|
|
205
|
+
),
|
|
206
|
+
8 * 60_000,
|
|
207
|
+
);
|
|
208
|
+
const watchdog = setInterval(() => {
|
|
209
|
+
if (!this.deps.enabled()) {
|
|
210
|
+
abort.abort(
|
|
211
|
+
new Error("Virtual desktop was disabled during setup"),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}, 1_000);
|
|
215
|
+
try {
|
|
216
|
+
await this.deps.ensureReady(signal);
|
|
217
|
+
signal.throwIfAborted();
|
|
218
|
+
} finally {
|
|
219
|
+
clearTimeout(timeout);
|
|
220
|
+
clearInterval(watchdog);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
this.assertAvailable();
|
|
224
|
+
} catch (error) {
|
|
225
|
+
await this.release();
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
if (generation !== this.generation) {
|
|
229
|
+
return {
|
|
230
|
+
content:
|
|
231
|
+
"Desktop browser session was interrupted. Take a fresh snapshot before continuing.",
|
|
232
|
+
isError: true,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (this.owner) {
|
|
236
|
+
this.bindCancellation(this.owner, context.signal);
|
|
237
|
+
}
|
|
238
|
+
const owner = this.owner ?? (await this.claim(context));
|
|
239
|
+
const signal = context.signal
|
|
240
|
+
? AbortSignal.any([context.signal, owner.abort.signal])
|
|
241
|
+
: owner.abort.signal;
|
|
242
|
+
owner.lastActivity = Date.now();
|
|
243
|
+
signal.throwIfAborted();
|
|
244
|
+
try {
|
|
245
|
+
this.assertAvailable();
|
|
246
|
+
if (++owner.actions > MAX_ACTIONS) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
"Desktop action limit reached. Finish this session before continuing.",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const result = await operation(signal);
|
|
252
|
+
signal.throwIfAborted();
|
|
253
|
+
if (result.isError) {
|
|
254
|
+
await this.release();
|
|
255
|
+
}
|
|
256
|
+
return result;
|
|
257
|
+
} catch (err) {
|
|
258
|
+
await this.release().catch((cleanupError) =>
|
|
259
|
+
log.warn(
|
|
260
|
+
{ err: cleanupError },
|
|
261
|
+
"Desktop browser session cleanup failed",
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export const desktopAutomationLease = new DesktopAutomationLease();
|