@vellumai/assistant 0.12.2-staging.2 → 0.12.2-staging.4
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/acp/session-snapshot.ts +260 -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/acp/SKILL.md +1 -1
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- 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/notifications/__tests__/decision-engine.test.ts +175 -0
- package/src/notifications/decision-engine.ts +43 -8
- 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__/acp-routes.test.ts +19 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
- package/src/runtime/routes/acp-routes.ts +17 -161
- 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/acp/status.test.ts +276 -21
- package/src/tools/acp/status.ts +98 -32
- 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,130 @@
|
|
|
1
|
+
import { executeBrowserOperation } from "../browser/operations.js";
|
|
2
|
+
import type { BrowserOperation } from "../browser/types.js";
|
|
3
|
+
import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
|
|
4
|
+
import { desktopAutomationLease } from "./desktop-automation-lease.js";
|
|
5
|
+
import { getDesktopSessionManager } from "./desktop-session-manager.js";
|
|
6
|
+
|
|
7
|
+
export function executeDesktopBrowserOperation(
|
|
8
|
+
operation: BrowserOperation,
|
|
9
|
+
input: Record<string, unknown>,
|
|
10
|
+
context: ToolContext,
|
|
11
|
+
): Promise<ToolExecutionResult> {
|
|
12
|
+
if (
|
|
13
|
+
input.target_client_id ||
|
|
14
|
+
(input.browser_mode &&
|
|
15
|
+
input.browser_mode !== "auto" &&
|
|
16
|
+
input.browser_mode !== "cdp-inspect")
|
|
17
|
+
) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
"--virtual-desktop targets the streamed Chrome directly. Omit other browser targets and modes.",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
if (input.use_active_tab) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"Use assistant browser --virtual-desktop tabs list and tabs select to choose a streamed browser tab.",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
if (operation === "wait_for_download") {
|
|
28
|
+
throw new Error(
|
|
29
|
+
"Download waiting is unavailable for the streamed desktop browser",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return desktopAutomationLease.runBrowser(
|
|
33
|
+
context,
|
|
34
|
+
async (signal) => {
|
|
35
|
+
const cdpClient = await getDesktopSessionManager().browser.client(
|
|
36
|
+
context.conversationId,
|
|
37
|
+
signal,
|
|
38
|
+
);
|
|
39
|
+
if (operation === "status") {
|
|
40
|
+
await cdpClient.listTabs();
|
|
41
|
+
return {
|
|
42
|
+
isError: false,
|
|
43
|
+
content: JSON.stringify({
|
|
44
|
+
requestedMode: "desktop",
|
|
45
|
+
recommendedMode: "desktop",
|
|
46
|
+
modes: [
|
|
47
|
+
{
|
|
48
|
+
mode: "desktop",
|
|
49
|
+
available: true,
|
|
50
|
+
summary:
|
|
51
|
+
"Virtual desktop Chrome is ready. Use assistant browser --virtual-desktop commands.",
|
|
52
|
+
userActions: [],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const browser = getDesktopSessionManager().browser;
|
|
59
|
+
const generation = browser.generation;
|
|
60
|
+
const result = await executeBrowserOperation(operation, input, {
|
|
61
|
+
...context,
|
|
62
|
+
signal,
|
|
63
|
+
cdpClient,
|
|
64
|
+
});
|
|
65
|
+
if (operation === "snapshot" && generation !== browser.generation) {
|
|
66
|
+
browser.invalidateSnapshot();
|
|
67
|
+
return {
|
|
68
|
+
isError: true,
|
|
69
|
+
content:
|
|
70
|
+
"The page changed during the snapshot. Take a fresh snapshot before acting.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
},
|
|
75
|
+
operation === "detach" || operation === "close",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function executeDesktopBrowserTabs(
|
|
80
|
+
params: {
|
|
81
|
+
command: "list" | "select" | "new" | "close";
|
|
82
|
+
tabId?: number;
|
|
83
|
+
url?: string;
|
|
84
|
+
},
|
|
85
|
+
context: ToolContext,
|
|
86
|
+
): Promise<ToolExecutionResult> {
|
|
87
|
+
return desktopAutomationLease.runBrowser(context, async (signal) => {
|
|
88
|
+
const cdpClient = await getDesktopSessionManager().browser.client(
|
|
89
|
+
context.conversationId,
|
|
90
|
+
signal,
|
|
91
|
+
);
|
|
92
|
+
let result: unknown;
|
|
93
|
+
if (params.command === "list") {
|
|
94
|
+
const tabs = await cdpClient.listTabs();
|
|
95
|
+
result = {
|
|
96
|
+
ok: true,
|
|
97
|
+
tabs: [
|
|
98
|
+
...tabs.filter((tab) => tab.active),
|
|
99
|
+
...tabs.filter((tab) => !tab.active),
|
|
100
|
+
].slice(0, 100),
|
|
101
|
+
};
|
|
102
|
+
} else if (params.command === "new") {
|
|
103
|
+
const created = await cdpClient.send<{ tabId: number }>(
|
|
104
|
+
"Vellum.createTab",
|
|
105
|
+
{},
|
|
106
|
+
signal,
|
|
107
|
+
);
|
|
108
|
+
if (params.url) {
|
|
109
|
+
const navigated = await executeBrowserOperation(
|
|
110
|
+
"navigate",
|
|
111
|
+
{ url: params.url },
|
|
112
|
+
{ ...context, signal, cdpClient },
|
|
113
|
+
);
|
|
114
|
+
if (navigated.isError) {
|
|
115
|
+
return navigated;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
result = { ok: true, ...created };
|
|
119
|
+
} else {
|
|
120
|
+
if (params.tabId === undefined) {
|
|
121
|
+
throw new Error("A tab ID is required");
|
|
122
|
+
}
|
|
123
|
+
result =
|
|
124
|
+
params.command === "select"
|
|
125
|
+
? { ok: true, tab: await cdpClient.selectTab(params.tabId) }
|
|
126
|
+
: { ok: true, ...(await cdpClient.closeTab(params.tabId)) };
|
|
127
|
+
}
|
|
128
|
+
return { isError: false, content: JSON.stringify(result) };
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { afterEach, expect, test } from "bun:test";
|
|
11
|
+
|
|
12
|
+
import { configureDesktopChromeFrame } from "./desktop-chrome-session.js";
|
|
13
|
+
|
|
14
|
+
const directories: string[] = [];
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const directory of directories.splice(0)) {
|
|
18
|
+
rmSync(directory, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test.each(["{truncated", "[]", '{"browser":[]}'])(
|
|
23
|
+
"leaves unreadable or malformed preferences untouched: %s",
|
|
24
|
+
(contents) => {
|
|
25
|
+
const directory = mkdtempSync(join(tmpdir(), "desktop-frame-"));
|
|
26
|
+
directories.push(directory);
|
|
27
|
+
mkdirSync(join(directory, "Default"));
|
|
28
|
+
const path = join(directory, "Default", "Preferences");
|
|
29
|
+
writeFileSync(path, contents);
|
|
30
|
+
|
|
31
|
+
expect(() => configureDesktopChromeFrame(directory)).toThrow();
|
|
32
|
+
expect(readFileSync(path, "utf8")).toBe(contents);
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
test("does not rewrite an already configured profile", () => {
|
|
37
|
+
const directory = mkdtempSync(join(tmpdir(), "desktop-frame-"));
|
|
38
|
+
directories.push(directory);
|
|
39
|
+
mkdirSync(join(directory, "Default"));
|
|
40
|
+
const path = join(directory, "Default", "Preferences");
|
|
41
|
+
const contents =
|
|
42
|
+
'{ "browser": { "custom_chrome_frame": true }, "profile": { "exit_type": "Crashed" } }';
|
|
43
|
+
writeFileSync(path, contents);
|
|
44
|
+
|
|
45
|
+
configureDesktopChromeFrame(directory);
|
|
46
|
+
|
|
47
|
+
expect(readFileSync(path, "utf8")).toBe(contents);
|
|
48
|
+
});
|
|
@@ -1,7 +1,60 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { basename, dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { isPlainObject } from "../util/object.js";
|
|
3
12
|
|
|
4
13
|
export function shouldRestoreDesktopChromeSession(profileDir: string): boolean {
|
|
14
|
+
return (
|
|
15
|
+
readProfileMetadata(chromePreferencesPath(profileDir))?.exit_type ===
|
|
16
|
+
"Crashed"
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Configure Chrome's own title bar before the browser or dock starts. */
|
|
21
|
+
export function configureDesktopChromeFrame(profileDir: string): void {
|
|
22
|
+
const path = chromePreferencesPath(profileDir);
|
|
23
|
+
let preferences: Record<string, unknown> = {};
|
|
24
|
+
try {
|
|
25
|
+
const value: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
26
|
+
if (!isPlainObject(value)) {
|
|
27
|
+
throw new Error("Desktop Chrome preferences must be a JSON object");
|
|
28
|
+
}
|
|
29
|
+
preferences = value;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const browser = preferences.browser ?? {};
|
|
36
|
+
if (!isPlainObject(browser)) {
|
|
37
|
+
throw new Error("Desktop Chrome browser preferences must be a JSON object");
|
|
38
|
+
}
|
|
39
|
+
if (browser.custom_chrome_frame === true) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
preferences.browser = { ...browser, custom_chrome_frame: true };
|
|
43
|
+
const directory = dirname(path);
|
|
44
|
+
mkdirSync(directory, { recursive: true });
|
|
45
|
+
const staging = mkdtempSync(join(directory, ".vellum-frame-"));
|
|
46
|
+
try {
|
|
47
|
+
const temporaryPath = join(staging, "Preferences");
|
|
48
|
+
writeFileSync(temporaryPath, JSON.stringify(preferences) + "\n", {
|
|
49
|
+
mode: 0o600,
|
|
50
|
+
});
|
|
51
|
+
renameSync(temporaryPath, path);
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(staging, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function chromePreferencesPath(profileDir: string): string {
|
|
5
58
|
const lastUsed = readProfileMetadata(
|
|
6
59
|
join(profileDir, "Local State"),
|
|
7
60
|
)?.last_used;
|
|
@@ -13,10 +66,7 @@ export function shouldRestoreDesktopChromeSession(profileDir: string): boolean {
|
|
|
13
66
|
basename(lastUsed) === lastUsed
|
|
14
67
|
? lastUsed
|
|
15
68
|
: "Default";
|
|
16
|
-
return (
|
|
17
|
-
readProfileMetadata(join(profileDir, profileName, "Preferences"))
|
|
18
|
-
?.exit_type === "Crashed"
|
|
19
|
-
);
|
|
69
|
+
return join(profileDir, profileName, "Preferences");
|
|
20
70
|
}
|
|
21
71
|
|
|
22
72
|
function readProfileMetadata(
|
|
@@ -24,9 +74,9 @@ function readProfileMetadata(
|
|
|
24
74
|
): { last_used?: unknown; exit_type?: unknown } | undefined {
|
|
25
75
|
try {
|
|
26
76
|
const value: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
27
|
-
if (value
|
|
77
|
+
if (isPlainObject(value)) {
|
|
28
78
|
const profile = value.profile;
|
|
29
|
-
if (
|
|
79
|
+
if (isPlainObject(profile)) {
|
|
30
80
|
return profile;
|
|
31
81
|
}
|
|
32
82
|
}
|
|
@@ -100,3 +100,32 @@ describe("desktop dependency installation", () => {
|
|
|
100
100
|
expect(f.install).not.toHaveBeenCalled();
|
|
101
101
|
});
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
test("browser and viewer share installation while a cancelled waiter stops immediately", async () => {
|
|
105
|
+
const f = setup();
|
|
106
|
+
const abort = new AbortController();
|
|
107
|
+
const browser = f.installer
|
|
108
|
+
.ensureReady(abort.signal)
|
|
109
|
+
.catch((error: unknown) => error);
|
|
110
|
+
const viewer = f.installer.ensureReady();
|
|
111
|
+
await flush();
|
|
112
|
+
expect(f.install).toHaveBeenCalledTimes(1);
|
|
113
|
+
abort.abort();
|
|
114
|
+
expect(await browser).toBeInstanceOf(Error);
|
|
115
|
+
expect(f.installer.getStatus().state).toBe("installing");
|
|
116
|
+
f.finish();
|
|
117
|
+
await viewer;
|
|
118
|
+
expect(f.installer.getStatus().state).toBe("ready");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("failed setup reports the stage without running an automatic retry", async () => {
|
|
122
|
+
const f = setup();
|
|
123
|
+
const result = f.installer.ensureReady().catch((error: Error) => error);
|
|
124
|
+
await flush();
|
|
125
|
+
f.progress();
|
|
126
|
+
f.fail();
|
|
127
|
+
const error = await result;
|
|
128
|
+
expect(error).toBeInstanceOf(Error);
|
|
129
|
+
expect((error as Error).message).toContain("failed during chrome");
|
|
130
|
+
expect(f.install).toHaveBeenCalledTimes(1);
|
|
131
|
+
});
|
|
@@ -156,7 +156,7 @@ export class DesktopDependencyInstaller {
|
|
|
156
156
|
})
|
|
157
157
|
.catch((err: unknown) => {
|
|
158
158
|
log.warn({ err }, "Desktop installation failed");
|
|
159
|
-
this.status = { state: "failed" };
|
|
159
|
+
this.status = { state: "failed", stage: this.status?.stage };
|
|
160
160
|
})
|
|
161
161
|
.finally(() => {
|
|
162
162
|
this.installing = null;
|
|
@@ -166,11 +166,32 @@ export class DesktopDependencyInstaller {
|
|
|
166
166
|
return this.status;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
async ensureReady(): Promise<void> {
|
|
169
|
+
async ensureReady(signal?: AbortSignal): Promise<void> {
|
|
170
|
+
signal?.throwIfAborted();
|
|
170
171
|
this.start();
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
let onAbort: (() => void) | undefined;
|
|
173
|
+
try {
|
|
174
|
+
await new Promise<void>((resolve, reject) => {
|
|
175
|
+
onAbort = () => reject(signal?.reason);
|
|
176
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
177
|
+
void Promise.resolve(this.installing).then(() => resolve(), reject);
|
|
178
|
+
});
|
|
179
|
+
signal?.throwIfAborted();
|
|
180
|
+
const status = this.getStatus();
|
|
181
|
+
if (status.state === "unsupported") {
|
|
182
|
+
throw new Error(
|
|
183
|
+
"Virtual desktop installation is unsupported on this assistant.",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (status.state !== "ready") {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Virtual desktop setup failed during ${status.stage ?? "installation"}. Open the Virtual desktop panel to retry. Do not launch Chrome manually.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
if (onAbort) {
|
|
193
|
+
signal?.removeEventListener("abort", onAbort);
|
|
194
|
+
}
|
|
174
195
|
}
|
|
175
196
|
}
|
|
176
197
|
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { pathToFileURL } from "node:url";
|
|
11
11
|
|
|
12
|
+
import { desktopChromeArguments } from "./desktop-browser-endpoint.js";
|
|
13
|
+
|
|
12
14
|
// Absolute icon paths work without an installed icon theme.
|
|
13
15
|
const TERMINAL_ICON_BASE64 = [
|
|
14
16
|
"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAACBklEQVR42u2bP2vCQBjG/T",
|
|
@@ -32,6 +34,7 @@ export interface DesktopPanelConfigRequest {
|
|
|
32
34
|
/** Profile the launcher shares with that browser, so it reuses the window. */
|
|
33
35
|
readonly chromiumProfileDir: string;
|
|
34
36
|
readonly terminalPath: string;
|
|
37
|
+
readonly debugPort?: number;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
/** Generate managed launchers and seed the desktop dock on first use. */
|
|
@@ -60,7 +63,17 @@ export function writeDesktopPanelConfig(
|
|
|
60
63
|
// Chrome includes its profile path in WM_CLASS.
|
|
61
64
|
windowClass: `google-chrome (${request.chromiumProfileDir})`,
|
|
62
65
|
icon: browserIcon,
|
|
63
|
-
exec:
|
|
66
|
+
exec: [
|
|
67
|
+
request.chromiumPath,
|
|
68
|
+
...desktopChromeArguments(
|
|
69
|
+
request.chromiumProfileDir,
|
|
70
|
+
request.debugPort,
|
|
71
|
+
),
|
|
72
|
+
]
|
|
73
|
+
.map(
|
|
74
|
+
(arg) => `"${arg.replace(/[\\"`$]/g, "\\$&").replace(/%/g, "%%")}"`,
|
|
75
|
+
)
|
|
76
|
+
.join(" "),
|
|
64
77
|
}),
|
|
65
78
|
);
|
|
66
79
|
writeFileSync(
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from "node:fs";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
-
import { afterAll, describe, expect, test } from "bun:test";
|
|
10
|
+
import { afterAll, describe, expect, spyOn, test } from "bun:test";
|
|
11
11
|
|
|
12
12
|
import { waitFor } from "../__tests__/helpers/wait-for.js";
|
|
13
13
|
import { sleep } from "../util/retry.js";
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
newViewer,
|
|
20
20
|
settle,
|
|
21
21
|
} from "./__tests__/fake-desktop.js";
|
|
22
|
+
import * as browserEndpoint from "./desktop-browser-endpoint.js";
|
|
22
23
|
import {
|
|
23
24
|
DESKTOP_VNC_PORT,
|
|
24
25
|
type DesktopChildRole,
|
|
@@ -543,7 +544,12 @@ describe("DesktopSessionManager process tree", () => {
|
|
|
543
544
|
JSON.stringify({ profile: { last_used: profileName } }),
|
|
544
545
|
);
|
|
545
546
|
const preferences = join(dir, profileName, "Preferences");
|
|
546
|
-
const
|
|
547
|
+
const original = {
|
|
548
|
+
profile: { exit_type: "Crashed" },
|
|
549
|
+
browser: { custom_chrome_frame: false, show_home_button: true },
|
|
550
|
+
session: { restore_on_startup: 1 },
|
|
551
|
+
};
|
|
552
|
+
const crashed = JSON.stringify(original);
|
|
547
553
|
writeFileSync(preferences, crashed);
|
|
548
554
|
h.manager.acquireViewerSlot(newViewer().viewer);
|
|
549
555
|
await h.manager.ensureDesktopRunning();
|
|
@@ -553,7 +559,10 @@ describe("DesktopSessionManager process tree", () => {
|
|
|
553
559
|
expect(h.child("browser").request.cmd).toContain(
|
|
554
560
|
"--hide-crash-restore-bubble",
|
|
555
561
|
);
|
|
556
|
-
expect(readFileSync(preferences, "utf8")).
|
|
562
|
+
expect(JSON.parse(readFileSync(preferences, "utf8"))).toEqual({
|
|
563
|
+
...original,
|
|
564
|
+
browser: { ...original.browser, custom_chrome_frame: true },
|
|
565
|
+
});
|
|
557
566
|
|
|
558
567
|
h.child("browser").exit(1);
|
|
559
568
|
await settle();
|
|
@@ -728,3 +737,77 @@ describe("DesktopSessionManager viewer slot", () => {
|
|
|
728
737
|
expect(h.count("x-server")).toBe(1);
|
|
729
738
|
});
|
|
730
739
|
});
|
|
740
|
+
|
|
741
|
+
describe("desktop automation lifecycle", () => {
|
|
742
|
+
test("an automation session outlives viewer disconnect and idles after both release", async () => {
|
|
743
|
+
const f = newManager({ exitOnTerm: true });
|
|
744
|
+
const viewer = newViewer();
|
|
745
|
+
const automation = newViewer();
|
|
746
|
+
expect(f.manager.acquireViewerSlot(viewer.viewer)).toEqual({ ok: true });
|
|
747
|
+
expect(f.manager.acquireAutomationSlot(automation.viewer)).toEqual({
|
|
748
|
+
ok: true,
|
|
749
|
+
});
|
|
750
|
+
await f.manager.ensureDesktopRunning();
|
|
751
|
+
f.manager.releaseViewerSlot(viewer.viewer);
|
|
752
|
+
await sleep(LINGER_MS + 20);
|
|
753
|
+
expect(f.terminated()).toHaveLength(0);
|
|
754
|
+
f.manager.releaseAutomationSlot(automation.viewer);
|
|
755
|
+
await waitFor(() => f.terminated().length > 0);
|
|
756
|
+
await f.manager.destroy();
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("shutdown notifies both the viewer and automation owner", async () => {
|
|
760
|
+
const f = newManager({ exitOnTerm: true });
|
|
761
|
+
const viewer = newViewer();
|
|
762
|
+
const automation = newViewer();
|
|
763
|
+
f.manager.acquireViewerSlot(viewer.viewer);
|
|
764
|
+
f.manager.acquireAutomationSlot(automation.viewer);
|
|
765
|
+
await f.manager.ensureDesktopRunning();
|
|
766
|
+
await f.manager.destroy();
|
|
767
|
+
expect(viewer.lost[0]?.code).toBe(1001);
|
|
768
|
+
expect(automation.lost[0]?.code).toBe(1001);
|
|
769
|
+
expect(f.manager.acquireAutomationSlot(newViewer().viewer)).toEqual(
|
|
770
|
+
SHUTTING_DOWN,
|
|
771
|
+
);
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test.each([undefined, 4321])(
|
|
776
|
+
"browser control uses the reopened dock PID %s or launches Chrome when absent",
|
|
777
|
+
async (dockPid) => {
|
|
778
|
+
const h = newManager({ exitOnTerm: true });
|
|
779
|
+
const owner = newViewer().viewer;
|
|
780
|
+
h.manager.acquireViewerSlot(owner);
|
|
781
|
+
await h.manager.ensureDesktopRunning();
|
|
782
|
+
await settle();
|
|
783
|
+
h.child("browser").exit(0);
|
|
784
|
+
await settle();
|
|
785
|
+
h.manager.acquireAutomationSlot(owner);
|
|
786
|
+
const abort = new AbortController();
|
|
787
|
+
const find = spyOn(
|
|
788
|
+
browserEndpoint,
|
|
789
|
+
"findDesktopBrowserPid",
|
|
790
|
+
).mockResolvedValue(dockPid);
|
|
791
|
+
const discover = spyOn(
|
|
792
|
+
browserEndpoint,
|
|
793
|
+
"discoverDesktopBrowser",
|
|
794
|
+
).mockImplementation(async () => {
|
|
795
|
+
abort.abort();
|
|
796
|
+
throw new Error("Reached verified discovery");
|
|
797
|
+
});
|
|
798
|
+
try {
|
|
799
|
+
await expect(
|
|
800
|
+
h.manager.browser.client("conv-123", abort.signal),
|
|
801
|
+
).rejects.toThrow("Reached verified discovery");
|
|
802
|
+
expect(h.count("browser")).toBe(dockPid ? 1 : 2);
|
|
803
|
+
expect(discover.mock.calls[0]?.[0]).toBe(
|
|
804
|
+
dockPid ?? h.child("browser").pid,
|
|
805
|
+
);
|
|
806
|
+
expect(find).toHaveBeenCalledTimes(1);
|
|
807
|
+
} finally {
|
|
808
|
+
find.mockRestore();
|
|
809
|
+
discover.mockRestore();
|
|
810
|
+
await h.manager.destroy();
|
|
811
|
+
}
|
|
812
|
+
},
|
|
813
|
+
);
|