@platless/axom-cli 0.1.0 → 0.5.9
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/README.md +1 -1
- package/dist/authScopes.js +19 -0
- package/dist/axom-patch/cli/README.md +132 -0
- package/dist/axom-patch/cli/account-limits/README.md +27 -0
- package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
- package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
- package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
- package/dist/axom-patch/cli/account-limits/generate.py +120 -0
- package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
- package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
- package/dist/axom-patch/cli/claude-account-limits.json +37 -0
- package/dist/axom-patch/cli/codex-account-limits.json +37 -0
- package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
- package/dist/axom-patch/cli/nightly-task.md +152 -0
- package/dist/axom-patch/cli/pi-steering.json +37 -0
- package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +38 -0
- package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
- package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +61 -0
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +82 -0
- package/dist/axom-patch/cli/qoder-foreground-output.md +42 -0
- package/dist/axom-patch/cli/qoder-steering.json +52 -0
- package/dist/axom-patch/cli/qoder-steering.md +30 -0
- package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
- package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
- package/dist/axom-patch/cli/runtime.json +63 -0
- package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
- package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
- package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
- package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
- package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
- package/dist/axom-patch/cli/zcode-models.json +38 -0
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
- package/dist/cli.js +36 -1
- package/dist/commands/auth.js +4 -13
- package/dist/commands/execute.js +4 -4
- package/dist/commands/record.js +6 -6
- package/dist/commands/struct.js +4 -4
- package/dist/commands/workstation.js +2 -2
- package/dist/credentials.js +2 -2
- package/dist/install/claude/commands.js +13 -13
- package/dist/keepers/acp/keeper.mjs +3083 -0
- package/dist/keepers/acp/manifest.json +9 -0
- package/dist/keepers/terminal/keeper.mjs +8469 -0
- package/dist/keepers/terminal/manifest.json +9 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
- package/dist/mcp.js +32 -14
- package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
- package/dist/runtime-deps/qoder-sdk/package.json +8 -0
- package/dist/test/keeperKit.js +67 -0
- package/dist/workstation/acp/acpAgents.js +2514 -681
- package/dist/workstation/acp/acpClient.js +95 -318
- package/dist/workstation/acp/activityBusy.js +43 -0
- package/dist/workstation/acp/activityReporting.js +54 -0
- package/dist/workstation/acp/adoption.js +28 -356
- package/dist/workstation/acp/authUrlSniffer.js +36 -0
- package/dist/workstation/acp/binaryInstall.js +100 -27
- package/dist/workstation/acp/builds/gc.js +245 -0
- package/dist/workstation/acp/builds/install/binary.js +28 -0
- package/dist/workstation/acp/builds/install/npm.js +235 -0
- package/dist/workstation/acp/builds/install/uv.js +254 -0
- package/dist/workstation/acp/builds/migrate.js +195 -0
- package/dist/workstation/acp/builds/model.js +128 -0
- package/dist/workstation/acp/builds/nodeGate.js +100 -0
- package/dist/workstation/acp/builds/store.js +352 -0
- package/dist/workstation/acp/commandTableProbe.js +68 -0
- package/dist/workstation/acp/configOptions.js +103 -1
- package/dist/workstation/acp/delegationMcp.js +24 -0
- package/dist/workstation/acp/envSecrets.js +27 -0
- package/dist/workstation/acp/initializeResponse.js +285 -0
- package/dist/workstation/acp/installErrors.js +12 -2
- package/dist/workstation/acp/installProbe.js +1 -1
- package/dist/workstation/acp/managedPatches.js +144 -0
- package/dist/workstation/acp/outbox.js +330 -0
- package/dist/workstation/acp/patchAssets.js +80 -0
- package/dist/workstation/acp/permission.js +335 -37
- package/dist/workstation/acp/processor/checkpoint.js +87 -0
- package/dist/workstation/acp/processor/commands.js +35 -0
- package/dist/workstation/acp/processor/control.js +355 -0
- package/dist/workstation/acp/processor/emit.js +26 -0
- package/dist/workstation/acp/processor/jsonrpc.js +172 -0
- package/dist/workstation/acp/processor/memoryLog.js +49 -0
- package/dist/workstation/acp/processor/origins.js +48 -0
- package/dist/workstation/acp/processor/output.js +67 -0
- package/dist/workstation/acp/processor/projection.js +42 -0
- package/dist/workstation/acp/processor/record.js +128 -0
- package/dist/workstation/acp/processor/runtime.js +181 -0
- package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
- package/dist/workstation/acp/processor/session.js +525 -0
- package/dist/workstation/acp/processor/sideChannel.js +80 -0
- package/dist/workstation/acp/processor/state.js +74 -0
- package/dist/workstation/acp/processor/step.js +459 -0
- package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
- package/dist/workstation/acp/processor/test/harness.js +120 -0
- package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
- package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
- package/dist/workstation/acp/processor/transport/generations.js +76 -0
- package/dist/workstation/acp/processor/transport/keeper.js +130 -0
- package/dist/workstation/acp/processor/transport/local.js +269 -0
- package/dist/workstation/acp/processor/transport/types.js +78 -0
- package/dist/workstation/acp/processor/updates.js +364 -0
- package/dist/workstation/acp/processorSession.js +1512 -0
- package/dist/workstation/acp/qoderStartup.js +45 -0
- package/dist/workstation/acp/registry/catalog.js +385 -4
- package/dist/workstation/acp/registry/detect.js +55 -0
- package/dist/workstation/acp/registry/launch.js +61 -119
- package/dist/workstation/acp/sessionUpdateMapper.js +56 -27
- package/dist/workstation/acp/steering.js +58 -0
- package/dist/workstation/acp/switching.js +114 -0
- package/dist/workstation/acp/terminalLogin.js +171 -0
- package/dist/workstation/acp/terminalLoginSpec.js +41 -0
- package/dist/workstation/acp/test/adapterFixtures.js +256 -0
- package/dist/workstation/acp/test/daemonLife.js +106 -0
- package/dist/workstation/acp/test/fakeAgent.js +185 -9
- package/dist/workstation/acp/test/keeperWorld.js +267 -0
- package/dist/workstation/acp/test/memoryOutbox.js +55 -0
- package/dist/workstation/acp/test/raw/fixtures.js +196 -0
- package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
- package/dist/workstation/acp/test/raw/sanitize.js +344 -0
- package/dist/workstation/acp/test/raw/sources.js +249 -0
- package/dist/workstation/acp/test/realAdapter.js +377 -0
- package/dist/workstation/acp/test/scriptAgent.js +206 -0
- package/dist/workstation/acp/test/sessionFrames.js +42 -0
- package/dist/workstation/acp/timedCommand.js +4 -2
- package/dist/workstation/acp/uvxInstall.js +72 -122
- package/dist/workstation/acp/validate.js +33 -5
- package/dist/workstation/agentFlow.js +103 -0
- package/dist/workstation/agentSessions.js +1686 -348
- package/dist/workstation/channels/agent.js +14 -6
- package/dist/workstation/channels/computer.js +203 -7
- package/dist/workstation/channels/computerExecs.js +200 -0
- package/dist/workstation/channels/management.js +86 -6
- package/dist/workstation/config.js +3 -1
- package/dist/workstation/connection/encryptedChannel.js +88 -21
- package/dist/workstation/daemon.js +309 -6
- package/dist/workstation/daemonctl.js +28 -2
- package/dist/workstation/desktop/binding.js +36 -0
- package/dist/workstation/desktop/bridge.js +212 -0
- package/dist/workstation/desktop/flow-control.js +52 -0
- package/dist/workstation/desktop/focused-text.js +130 -0
- package/dist/workstation/desktop/protocol.js +35 -0
- package/dist/workstation/exec/attach.js +126 -0
- package/dist/workstation/exec/command.js +40 -0
- package/dist/workstation/exec/durable.js +517 -0
- package/dist/workstation/exec/fallback.js +106 -0
- package/dist/workstation/exec/policy.js +837 -0
- package/dist/workstation/exec/shaping.js +211 -0
- package/dist/workstation/exec.js +3 -112
- package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
- package/dist/workstation/files/adapters/toolRequests.js +121 -0
- package/dist/workstation/files/client.js +455 -0
- package/dist/workstation/files/errors.js +28 -0
- package/dist/workstation/files/fence.js +350 -0
- package/dist/workstation/files/find/fileIndex.js +153 -0
- package/dist/workstation/files/find/fuzzy.js +155 -0
- package/dist/workstation/files/host.js +122 -0
- package/dist/workstation/files/hostProtocol.js +2 -0
- package/dist/workstation/files/media/cache.js +620 -0
- package/dist/workstation/files/media/encoding.js +308 -0
- package/dist/workstation/files/media/estimator.js +100 -0
- package/dist/workstation/files/media/media.js +325 -0
- package/dist/workstation/files/media/packedAudio.js +23 -0
- package/dist/workstation/files/media/scheduler.js +551 -0
- package/dist/workstation/files/media/types.js +5 -0
- package/dist/workstation/files/scope.js +45 -0
- package/dist/workstation/files/structured.js +284 -0
- package/dist/workstation/files/text.js +356 -0
- package/dist/workstation/files/uploads.js +351 -0
- package/dist/workstation/journal.js +65 -236
- package/dist/workstation/keepers/acp/client.js +101 -0
- package/dist/workstation/keepers/acp/envelope.js +62 -0
- package/dist/workstation/keepers/acp/host.js +419 -0
- package/dist/workstation/keepers/acp/main.js +18 -0
- package/dist/workstation/keepers/acp/memoryStream.js +121 -0
- package/dist/workstation/keepers/acp/params.js +137 -0
- package/dist/workstation/keepers/acp/protocol.js +66 -0
- package/dist/workstation/keepers/acp/recover.js +120 -0
- package/dist/workstation/keepers/acp/session.js +584 -0
- package/dist/workstation/keepers/acp/stderr.js +154 -0
- package/dist/workstation/keepers/kit/activity.js +103 -0
- package/dist/workstation/keepers/kit/bootstrap.js +164 -0
- package/dist/workstation/keepers/kit/buildVersion.js +51 -0
- package/dist/workstation/keepers/kit/bundle.js +120 -0
- package/dist/workstation/keepers/kit/client.js +432 -0
- package/dist/workstation/keepers/kit/identity.js +21 -0
- package/dist/workstation/keepers/kit/ipc.js +112 -0
- package/dist/workstation/keepers/kit/launchConfig.js +8 -0
- package/dist/workstation/keepers/kit/launcher.js +249 -0
- package/dist/workstation/keepers/kit/lock.js +123 -0
- package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
- package/dist/workstation/keepers/kit/paths.js +75 -0
- package/dist/workstation/keepers/kit/protocol.js +43 -0
- package/dist/workstation/keepers/kit/server.js +518 -0
- package/dist/workstation/keepers/kit/streamLog.js +697 -0
- package/dist/workstation/keepers/legacy.js +296 -0
- package/dist/workstation/keepers/policy.js +576 -0
- package/dist/workstation/keepers/stop.js +34 -0
- package/dist/workstation/keepers/terminal/client.js +213 -0
- package/dist/workstation/keepers/terminal/exec.js +981 -0
- package/dist/workstation/keepers/terminal/generations.js +129 -0
- package/dist/workstation/keepers/terminal/keeper.js +62 -0
- package/dist/workstation/keepers/terminal/main.js +43 -0
- package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
- package/dist/workstation/keepers/terminal/params.js +27 -0
- package/dist/workstation/keepers/terminal/protocol.js +56 -0
- package/dist/workstation/keepers/terminal/pty.js +330 -0
- package/dist/workstation/oauth/deviceAuthorization.js +21 -12
- package/dist/workstation/protocol/accountLimits.js +47 -0
- package/dist/workstation/protocol/agent.js +201 -8
- package/dist/workstation/protocol/common.js +36 -0
- package/dist/workstation/protocol/computer.js +107 -7
- package/dist/workstation/protocol/management.js +50 -2
- package/dist/workstation/qoderAdapter/backgroundProcess.js +118 -0
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +193 -0
- package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
- package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
- package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
- package/dist/workstation/qoderAdapter/contract.js +78 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +36 -0
- package/dist/workstation/qoderAdapter/history.js +85 -0
- package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
- package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
- package/dist/workstation/qoderAdapter/main.js +95 -0
- package/dist/workstation/qoderAdapter/mapper.js +117 -0
- package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
- package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
- package/dist/workstation/qoderAdapter/permissions.js +61 -0
- package/dist/workstation/qoderAdapter/profile.js +93 -0
- package/dist/workstation/qoderAdapter/sdkDriver.js +48 -0
- package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
- package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
- package/dist/workstation/qoderAdapter/session.js +319 -0
- package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
- package/dist/workstation/qoderAdapter/subwork.js +85 -0
- package/dist/workstation/qoderAdapter/taskControl.js +38 -0
- package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
- package/dist/workstation/qoderAdapter/types.js +1 -0
- package/dist/workstation/qoderAdapter/usage.js +37 -0
- package/dist/workstation/register.js +4 -0
- package/dist/workstation/systemMetrics.js +110 -1
- package/dist/workstation/tools/browser.js +394 -0
- package/dist/workstation/tools/client.js +277 -0
- package/dist/workstation/tools/info.js +50 -0
- package/dist/workstation/tools/keeperProcess.js +124 -0
- package/dist/workstation/tools/router.js +55 -0
- package/dist/workstation/tools/terminalRouter.js +222 -0
- package/dist/workstation/tools/validation.js +86 -0
- package/dist/workstation/update/coordinator.js +32 -0
- package/dist/workstation/update/npmInstaller.js +2 -0
- package/dist/workstation/update/ptyPreflight.js +45 -0
- package/dist/workstation/update/taskRegistry.js +141 -23
- package/dist/workstation/update/watchdog.js +66 -0
- package/package.json +20 -7
- package/skills/axom/SKILL.md +55 -17
- package/skills/axom-definition/SKILL.md +32 -32
- package/skills/axom-platform/SKILL.md +39 -39
- package/dist/workstation/acp/acpSession.js +0 -1047
- package/dist/workstation/acp/contract.js +0 -494
- package/dist/workstation/acp/draftInterceptor.js +0 -154
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { integer, keys, string, validateRequest, TOOL_RESULT_BYTES, } from "./validation.js";
|
|
5
|
+
const TIMEOUT = 4500;
|
|
6
|
+
async function bounded(promise, timeout = TIMEOUT) {
|
|
7
|
+
let timer;
|
|
8
|
+
try {
|
|
9
|
+
return await Promise.race([
|
|
10
|
+
promise,
|
|
11
|
+
new Promise((_, reject) => {
|
|
12
|
+
timer = setTimeout(() => reject(Error("Browser operation timed out; mutation outcome may be unknown")), Math.max(1, timeout));
|
|
13
|
+
}),
|
|
14
|
+
]);
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** All ancestors are checked: mkdir recursive must never follow a planted profile symlink. */
|
|
21
|
+
async function ownedDirectory(dir, privateMode = false) {
|
|
22
|
+
const absolute = path.resolve(dir);
|
|
23
|
+
const parts = absolute.split(path.sep).filter(Boolean);
|
|
24
|
+
let current = path.parse(absolute).root;
|
|
25
|
+
const uid = process.getuid?.();
|
|
26
|
+
let protectedByOwner = false;
|
|
27
|
+
for (const part of parts) {
|
|
28
|
+
current = path.join(current, part);
|
|
29
|
+
await fs.mkdir(current, { mode: 0o700 }).catch((e) => {
|
|
30
|
+
if (e.code !== "EEXIST")
|
|
31
|
+
throw e;
|
|
32
|
+
});
|
|
33
|
+
const stat = await fs.lstat(current);
|
|
34
|
+
if (!stat.isDirectory() ||
|
|
35
|
+
stat.isSymbolicLink() ||
|
|
36
|
+
(!protectedByOwner &&
|
|
37
|
+
(stat.mode & 0o022) !== 0 &&
|
|
38
|
+
!(stat.mode & 0o1000)) ||
|
|
39
|
+
(protectedByOwner && uid !== undefined && stat.uid !== uid))
|
|
40
|
+
throw Error("Unsafe browser directory");
|
|
41
|
+
// An owned 0700 ancestor blocks other UIDs from reaching owned descendants,
|
|
42
|
+
// even if an existing config directory below that fence has group-write bits.
|
|
43
|
+
// Symlink and ownership checks still apply at every level.
|
|
44
|
+
if (uid !== undefined && stat.uid === uid && (stat.mode & 0o077) === 0)
|
|
45
|
+
protectedByOwner = true;
|
|
46
|
+
}
|
|
47
|
+
const stat = await fs.lstat(absolute);
|
|
48
|
+
if (process.getuid && stat.uid !== process.getuid())
|
|
49
|
+
throw Error("Browser directory is not owned by this user");
|
|
50
|
+
if (privateMode)
|
|
51
|
+
await fs.chmod(absolute, 0o700);
|
|
52
|
+
}
|
|
53
|
+
function allowedUrl(params) {
|
|
54
|
+
const url = string(params, "url", 8192, "about:blank");
|
|
55
|
+
if (url === "about:blank")
|
|
56
|
+
return url;
|
|
57
|
+
try {
|
|
58
|
+
const parsed = new URL(url);
|
|
59
|
+
if (["http:", "https:"].includes(parsed.protocol))
|
|
60
|
+
return parsed.href;
|
|
61
|
+
}
|
|
62
|
+
catch { }
|
|
63
|
+
throw Error("Invalid browser URL: use http(s) or about:blank");
|
|
64
|
+
}
|
|
65
|
+
function browserKey(params) {
|
|
66
|
+
const key = string(params, "key", 64);
|
|
67
|
+
if (!/^(?:(?:Control|Alt|Shift|Meta)\+)*(?:Enter|Backspace|Tab|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Delete|Home|End|PageUp|PageDown|[a-zA-Z0-9])$/.test(key))
|
|
68
|
+
throw Error("Invalid browser key");
|
|
69
|
+
return key;
|
|
70
|
+
}
|
|
71
|
+
export class BrowserCore {
|
|
72
|
+
options;
|
|
73
|
+
chromium;
|
|
74
|
+
error;
|
|
75
|
+
sessions = new Map();
|
|
76
|
+
queue = Promise.resolve();
|
|
77
|
+
disposed = false;
|
|
78
|
+
constructor(options, chromium, error) {
|
|
79
|
+
this.options = options;
|
|
80
|
+
this.chromium = chromium;
|
|
81
|
+
this.error = error;
|
|
82
|
+
}
|
|
83
|
+
static async create(options) {
|
|
84
|
+
try {
|
|
85
|
+
const { chromium } = await import("playwright-core");
|
|
86
|
+
const executablePath = options.executablePath ?? chromium.executablePath();
|
|
87
|
+
await fs.access(executablePath);
|
|
88
|
+
await ownedDirectory(options.home);
|
|
89
|
+
await ownedDirectory(options.profileRoot, true);
|
|
90
|
+
await ownedDirectory(path.join(options.home, "Downloads"));
|
|
91
|
+
return new BrowserCore({ ...options, executablePath }, chromium);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
return new BrowserCore(options, undefined, `Browser unavailable: ${error instanceof Error ? error.message : "missing optional browser dependency"}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
dispatch(method, params = {}) {
|
|
98
|
+
const task = this.queue.then(async () => {
|
|
99
|
+
if (this.disposed)
|
|
100
|
+
throw Error("Browser supervisor stopped");
|
|
101
|
+
validateRequest(method, params);
|
|
102
|
+
const result = await this.execute(method, params);
|
|
103
|
+
if (Buffer.byteLength(JSON.stringify(result)) > TOOL_RESULT_BYTES - 1024)
|
|
104
|
+
throw Error("Browser frame exceeds reply limit");
|
|
105
|
+
return result;
|
|
106
|
+
});
|
|
107
|
+
this.queue = task.catch(() => { });
|
|
108
|
+
return task;
|
|
109
|
+
}
|
|
110
|
+
descriptor(s) {
|
|
111
|
+
return {
|
|
112
|
+
id: s.id,
|
|
113
|
+
name: s.name,
|
|
114
|
+
url: s.page.url(),
|
|
115
|
+
controllerId: s.controllerId,
|
|
116
|
+
exited: s.exited,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async navigate(s, url, deadline = Date.now() + TIMEOUT) {
|
|
120
|
+
try {
|
|
121
|
+
await s.page.goto(url, {
|
|
122
|
+
waitUntil: "domcontentloaded",
|
|
123
|
+
timeout: Math.max(1, Math.min(TIMEOUT, deadline - Date.now())),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (String(error).includes("Download is starting"))
|
|
128
|
+
return;
|
|
129
|
+
await s.page
|
|
130
|
+
.goto("about:blank", { waitUntil: "commit", timeout: 500 })
|
|
131
|
+
.catch(() => { });
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
watch(s, page) {
|
|
136
|
+
page.on("dialog", (dialog) => {
|
|
137
|
+
s.notice = `${dialog.type()}: ${dialog.message().slice(0, 300)} (dismissed)`;
|
|
138
|
+
void dialog.dismiss().catch(() => { });
|
|
139
|
+
});
|
|
140
|
+
page.on("download", (download) => {
|
|
141
|
+
void this.download(s, download).catch((error) => {
|
|
142
|
+
s.notice = `Download failed: ${error.message}`;
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
page.on("close", () => {
|
|
146
|
+
if (s.page === page) {
|
|
147
|
+
const others = s.context.pages().filter((p) => !p.isClosed());
|
|
148
|
+
if (others.length) {
|
|
149
|
+
s.page = others[others.length - 1];
|
|
150
|
+
s.notice = "Active tab closed; switched to remaining tab";
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
s.exited = true;
|
|
154
|
+
s.controllerId = null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
page.on("crash", () => {
|
|
159
|
+
if (s.page === page) {
|
|
160
|
+
s.exited = true;
|
|
161
|
+
s.notice = "Browser page crashed; close and create a new session";
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
async download(s, download) {
|
|
166
|
+
const downloads = path.join(this.options.home, "Downloads");
|
|
167
|
+
await ownedDirectory(downloads);
|
|
168
|
+
const before = await fs.lstat(downloads);
|
|
169
|
+
const staging = await fs.mkdtemp(path.join(downloads, ".axom-browser-"));
|
|
170
|
+
await fs.chmod(staging, 0o700);
|
|
171
|
+
const temporary = path.join(staging, "download");
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
void download.cancel().catch(() => { });
|
|
174
|
+
}, 30000);
|
|
175
|
+
try {
|
|
176
|
+
await download.saveAs(temporary);
|
|
177
|
+
const failure = await download.failure();
|
|
178
|
+
if (failure)
|
|
179
|
+
throw Error(failure);
|
|
180
|
+
await ownedDirectory(downloads);
|
|
181
|
+
const after = await fs.lstat(downloads);
|
|
182
|
+
if (before.ino !== after.ino || before.dev !== after.dev)
|
|
183
|
+
throw Error("Downloads directory changed");
|
|
184
|
+
let suggested = download
|
|
185
|
+
.suggestedFilename()
|
|
186
|
+
.replace(/[\\/\x00-\x1f\x7f]/g, "_")
|
|
187
|
+
.replace(/^\.+/, "")
|
|
188
|
+
.slice(0, 160) || "download";
|
|
189
|
+
while (Buffer.byteLength(suggested) > 180)
|
|
190
|
+
suggested = [...suggested].slice(0, -1).join("");
|
|
191
|
+
const ext = path.extname(suggested), stem = suggested.slice(0, suggested.length - ext.length);
|
|
192
|
+
for (let n = 0; n < 100; n++) {
|
|
193
|
+
const name = n ? `${stem} (${n})${ext}` : suggested;
|
|
194
|
+
try {
|
|
195
|
+
await fs.link(temporary, path.join(downloads, name));
|
|
196
|
+
s.notice = `Downloaded: ${name}`;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error.code !== "EEXIST")
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
throw Error("Download filename collision limit reached");
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async execute(method, params) {
|
|
212
|
+
const deadline = Date.now() + 6000;
|
|
213
|
+
if (method === "tools.info") {
|
|
214
|
+
keys(params, []);
|
|
215
|
+
return {
|
|
216
|
+
browser: !!this.chromium,
|
|
217
|
+
...(this.error ? { browserError: this.error } : {}),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (!method.startsWith("browser."))
|
|
221
|
+
throw Error("Unsupported browser tool");
|
|
222
|
+
if (!this.chromium)
|
|
223
|
+
throw Error(this.error);
|
|
224
|
+
if (method === "browser.list") {
|
|
225
|
+
keys(params, []);
|
|
226
|
+
return {
|
|
227
|
+
sessions: [...this.sessions.values()].map((s) => this.descriptor(s)),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const id = string(params, "id", 128), clientId = string(params, "clientId", 128);
|
|
231
|
+
if (method === "browser.create") {
|
|
232
|
+
keys(params, ["id", "clientId", "url", "width", "height"]);
|
|
233
|
+
const url = allowedUrl(params), width = integer(params, "width", 320, 1600, 1024), height = integer(params, "height", 240, 1200, 768);
|
|
234
|
+
const existing = this.sessions.get(id);
|
|
235
|
+
if (existing)
|
|
236
|
+
return this.descriptor(existing);
|
|
237
|
+
if (this.sessions.size >= 2)
|
|
238
|
+
throw Error("Maximum browser session limit reached (2)");
|
|
239
|
+
await ownedDirectory(this.options.profileRoot, true);
|
|
240
|
+
const profile = path.join(this.options.profileRoot, createHash("sha256").update(id).digest("hex"));
|
|
241
|
+
await ownedDirectory(profile, true);
|
|
242
|
+
const context = await this.chromium.launchPersistentContext(profile, {
|
|
243
|
+
executablePath: this.options.executablePath,
|
|
244
|
+
headless: true,
|
|
245
|
+
acceptDownloads: true,
|
|
246
|
+
viewport: { width, height },
|
|
247
|
+
timeout: TIMEOUT,
|
|
248
|
+
...(this.options.proxy
|
|
249
|
+
? { proxy: { server: this.options.proxy } }
|
|
250
|
+
: {}),
|
|
251
|
+
args: process.platform === "linux" ? ["--no-sandbox"] : [],
|
|
252
|
+
});
|
|
253
|
+
context.setDefaultTimeout(TIMEOUT);
|
|
254
|
+
context.setDefaultNavigationTimeout(TIMEOUT);
|
|
255
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
256
|
+
const s = {
|
|
257
|
+
id,
|
|
258
|
+
name: "Browser",
|
|
259
|
+
context,
|
|
260
|
+
page,
|
|
261
|
+
controllerId: clientId,
|
|
262
|
+
width,
|
|
263
|
+
height,
|
|
264
|
+
notice: "",
|
|
265
|
+
exited: false,
|
|
266
|
+
};
|
|
267
|
+
this.watch(s, page);
|
|
268
|
+
context.on("page", (newPage) => {
|
|
269
|
+
this.watch(s, newPage);
|
|
270
|
+
s.page = newPage;
|
|
271
|
+
s.exited = false;
|
|
272
|
+
s.notice = "Switched to new tab";
|
|
273
|
+
if (context.pages().length > 8)
|
|
274
|
+
void context
|
|
275
|
+
.pages()[0]
|
|
276
|
+
.close()
|
|
277
|
+
.catch(() => { });
|
|
278
|
+
});
|
|
279
|
+
context.on("close", () => {
|
|
280
|
+
s.exited = true;
|
|
281
|
+
s.controllerId = null;
|
|
282
|
+
});
|
|
283
|
+
this.sessions.set(id, s);
|
|
284
|
+
try {
|
|
285
|
+
await this.navigate(s, url, deadline);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
this.sessions.delete(id);
|
|
289
|
+
await context.close();
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
return this.descriptor(s);
|
|
293
|
+
}
|
|
294
|
+
const s = this.sessions.get(id);
|
|
295
|
+
if (!s)
|
|
296
|
+
throw Error("Browser session not found");
|
|
297
|
+
if ([
|
|
298
|
+
"browser.attach",
|
|
299
|
+
"browser.takeover",
|
|
300
|
+
"browser.detach",
|
|
301
|
+
"browser.close",
|
|
302
|
+
"browser.frame",
|
|
303
|
+
].includes(method))
|
|
304
|
+
keys(params, ["id", "clientId"]);
|
|
305
|
+
if (method === "browser.attach") {
|
|
306
|
+
if (!s.controllerId)
|
|
307
|
+
s.controllerId = clientId;
|
|
308
|
+
return {
|
|
309
|
+
session: this.descriptor(s),
|
|
310
|
+
control: s.controllerId === clientId,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (method === "browser.takeover") {
|
|
314
|
+
s.controllerId = clientId;
|
|
315
|
+
return { ok: true };
|
|
316
|
+
}
|
|
317
|
+
if (method === "browser.detach") {
|
|
318
|
+
if (s.controllerId === clientId)
|
|
319
|
+
s.controllerId = null;
|
|
320
|
+
return { ok: true };
|
|
321
|
+
}
|
|
322
|
+
if (method === "browser.frame") {
|
|
323
|
+
const base = {
|
|
324
|
+
mime: "image/jpeg",
|
|
325
|
+
width: s.width,
|
|
326
|
+
height: s.height,
|
|
327
|
+
url: s.page.url(),
|
|
328
|
+
title: "",
|
|
329
|
+
control: s.controllerId === clientId,
|
|
330
|
+
exited: s.exited,
|
|
331
|
+
notice: s.notice,
|
|
332
|
+
};
|
|
333
|
+
if (s.exited)
|
|
334
|
+
return { ...base, data: "" };
|
|
335
|
+
const jpeg = await s.page.screenshot({
|
|
336
|
+
type: "jpeg",
|
|
337
|
+
quality: 70,
|
|
338
|
+
timeout: TIMEOUT,
|
|
339
|
+
animations: "disabled",
|
|
340
|
+
});
|
|
341
|
+
return {
|
|
342
|
+
...base,
|
|
343
|
+
title: (await bounded(s.page.title(), 1000)).slice(0, 512),
|
|
344
|
+
data: jpeg.toString("base64"),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
if (s.controllerId !== clientId)
|
|
348
|
+
throw Error("Browser is read-only; take control first");
|
|
349
|
+
if (method === "browser.close") {
|
|
350
|
+
await s.context.close();
|
|
351
|
+
this.sessions.delete(id);
|
|
352
|
+
return { ok: true };
|
|
353
|
+
}
|
|
354
|
+
if (s.exited)
|
|
355
|
+
throw Error("Browser exited");
|
|
356
|
+
if (method === "browser.navigate") {
|
|
357
|
+
keys(params, ["id", "clientId", "url"]);
|
|
358
|
+
await this.navigate(s, allowedUrl(params), deadline);
|
|
359
|
+
return { ok: true };
|
|
360
|
+
}
|
|
361
|
+
if (method === "browser.input") {
|
|
362
|
+
keys(params, [
|
|
363
|
+
"id",
|
|
364
|
+
"clientId",
|
|
365
|
+
"kind",
|
|
366
|
+
"x",
|
|
367
|
+
"y",
|
|
368
|
+
"deltaX",
|
|
369
|
+
"deltaY",
|
|
370
|
+
"key",
|
|
371
|
+
"text",
|
|
372
|
+
]);
|
|
373
|
+
const kind = string(params, "kind", 16);
|
|
374
|
+
if (kind === "click")
|
|
375
|
+
await bounded(s.page.mouse.click(integer(params, "x", 0, s.width - 1), integer(params, "y", 0, s.height - 1)));
|
|
376
|
+
else if (kind === "scroll")
|
|
377
|
+
await bounded(s.page.mouse.wheel(integer(params, "deltaX", -4000, 4000, 0), integer(params, "deltaY", -4000, 4000, 0)));
|
|
378
|
+
else if (kind === "key")
|
|
379
|
+
await bounded(s.page.keyboard.press(browserKey(params)));
|
|
380
|
+
else if (kind === "text")
|
|
381
|
+
await bounded(s.page.keyboard.insertText(string(params, "text", 64 * 1024)));
|
|
382
|
+
else
|
|
383
|
+
throw Error("Invalid browser input kind");
|
|
384
|
+
return { ok: true };
|
|
385
|
+
}
|
|
386
|
+
throw Error("Unsupported browser tool");
|
|
387
|
+
}
|
|
388
|
+
async dispose() {
|
|
389
|
+
this.disposed = true;
|
|
390
|
+
await this.queue;
|
|
391
|
+
await Promise.all([...this.sessions.values()].map((s) => s.context.close().catch(() => { })));
|
|
392
|
+
this.sessions.clear();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { resolveBrowserBinding, browserCapability } from '../desktop/binding.js';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { object, TOOL_REQUEST_BYTES, TOOL_RESULT_BYTES, validateRequest } from './validation.js';
|
|
10
|
+
/** Unix socket paths must remain below macOS/Linux's ~104-byte sockaddr_un limit. */
|
|
11
|
+
export function keeperPaths(options, temporaryDirectory = os.tmpdir(), platform = process.platform, kind = 'terminal') {
|
|
12
|
+
const identity = createHash('sha256').update(JSON.stringify([path.resolve(options.configDir), options.workstationId, options.server, ...(kind === 'browser' ? ['browser'] : [])])).digest('hex').slice(0, 32);
|
|
13
|
+
const namespace = `axom-${kind === 'browser' ? 'browser' : 'tools'}-${process.getuid?.() ?? 'user'}`;
|
|
14
|
+
// macOS /var/folders/.../T often exhausts sockaddr_un before adding a private namespace. Honor
|
|
15
|
+
// TMPDIR where it fits on Linux; the short system temporary root is checked for private ownership.
|
|
16
|
+
let dir = path.join(platform === 'darwin' ? '/tmp' : temporaryDirectory, namespace, identity);
|
|
17
|
+
if (Buffer.byteLength(path.join(dir, 'keeper.sock')) > 100)
|
|
18
|
+
dir = path.join('/tmp', namespace, identity);
|
|
19
|
+
// Browser ports are disjoint from terminal startup and the normal Linux ephemeral range.
|
|
20
|
+
return { dir, socket: path.join(dir, 'keeper.sock'), lock: path.join(dir, 'startup.lock'), pidFile: path.join(dir, 'keeper.pid.json'), lockPort: kind === 'browser' ? 10000 + Number.parseInt(identity.slice(0, 8), 16) % 8000 : 20000 + Number.parseInt(identity.slice(0, 8), 16) % 40000, unit: `axom-${kind === 'browser' ? 'browser' : 'tools'}-keeper-${identity}` };
|
|
21
|
+
}
|
|
22
|
+
export async function privateDirectory(dir) {
|
|
23
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
24
|
+
const stat = await fs.lstat(dir);
|
|
25
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()))
|
|
26
|
+
throw new Error('Unsafe keeper state directory');
|
|
27
|
+
await fs.chmod(dir, 0o700);
|
|
28
|
+
}
|
|
29
|
+
export function keeperRpc(socketPath, method, params, timeoutMs = 7000) {
|
|
30
|
+
const id = randomUUID();
|
|
31
|
+
const request = JSON.stringify({ id, method, params }) + '\n';
|
|
32
|
+
if (Buffer.byteLength(request) > TOOL_REQUEST_BYTES)
|
|
33
|
+
return Promise.reject(new Error('Tool request too large'));
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const socket = net.createConnection(socketPath);
|
|
36
|
+
let buffered = '';
|
|
37
|
+
let done = false;
|
|
38
|
+
const finish = (error, result) => {
|
|
39
|
+
if (done)
|
|
40
|
+
return;
|
|
41
|
+
done = true;
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
socket.destroy();
|
|
44
|
+
if (error)
|
|
45
|
+
reject(error);
|
|
46
|
+
else
|
|
47
|
+
resolve(result);
|
|
48
|
+
};
|
|
49
|
+
const timer = setTimeout(() => finish(new Error('Workstation tool request timed out; mutation outcome may be unknown')), timeoutMs);
|
|
50
|
+
socket.setEncoding('utf8');
|
|
51
|
+
socket.on('connect', () => socket.end(request));
|
|
52
|
+
socket.on('data', chunk => {
|
|
53
|
+
buffered += chunk;
|
|
54
|
+
if (Buffer.byteLength(buffered) > TOOL_RESULT_BYTES) {
|
|
55
|
+
finish(new Error('Keeper response too large'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const newline = buffered.indexOf('\n');
|
|
59
|
+
if (newline < 0)
|
|
60
|
+
return;
|
|
61
|
+
try {
|
|
62
|
+
const reply = JSON.parse(buffered.slice(0, newline));
|
|
63
|
+
if (!object(reply) || reply.id !== id || typeof reply.ok !== 'boolean' || !object(reply.result) || !(reply.error === null || typeof reply.error === 'string') || (reply.ok ? reply.error !== null : !reply.error))
|
|
64
|
+
throw new Error('Invalid keeper response');
|
|
65
|
+
finish(reply.ok ? undefined : new Error(reply.error), reply.result);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
finish(error instanceof Error ? error : new Error('Invalid keeper response'));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
socket.on('error', error => finish(error));
|
|
72
|
+
socket.on('close', () => finish(new Error('Workstation tools keeper disconnected')));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
76
|
+
function pidAlive(pid) {
|
|
77
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
78
|
+
return false;
|
|
79
|
+
try {
|
|
80
|
+
process.kill(pid, 0);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return error.code === 'EPERM';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A tools keeper client. The daemon only ever makes the browser kind (`browser`). The kind is required — no default — so nothing can
|
|
89
|
+
* start the retired terminal-kind tools keeper by accident (spec §9; terminals run in keeper-terminal, files in the daemon's files
|
|
90
|
+
* host); an explicit `terminal` client exists only for the tests that pin how the retired keeper's startup and tools.info behave.
|
|
91
|
+
* (`keeperPaths` keeps its terminal default: `keepers/legacy.ts` finds a legacy tools keeper an older CLI left running by it.)
|
|
92
|
+
*/
|
|
93
|
+
export class ToolsClient {
|
|
94
|
+
options;
|
|
95
|
+
kind;
|
|
96
|
+
paths;
|
|
97
|
+
starting;
|
|
98
|
+
browser;
|
|
99
|
+
constructor(options, kind) {
|
|
100
|
+
this.options = options;
|
|
101
|
+
this.kind = kind;
|
|
102
|
+
if (kind !== 'terminal' && kind !== 'browser')
|
|
103
|
+
throw new Error('ToolsClient needs an explicit kind (browser)');
|
|
104
|
+
this.paths = keeperPaths(options, undefined, undefined, kind);
|
|
105
|
+
}
|
|
106
|
+
async probe(timeoutMs = 250) {
|
|
107
|
+
try {
|
|
108
|
+
const reply = await keeperRpc(this.paths.socket, 'keeper.ping', {}, timeoutMs);
|
|
109
|
+
if (reply.identity !== this.paths.unit || !Number.isSafeInteger(reply.pid))
|
|
110
|
+
return null;
|
|
111
|
+
return { pid: reply.pid };
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async request(method, params) {
|
|
118
|
+
validateRequest(method, params);
|
|
119
|
+
const binding = resolveBrowserBinding(this.options.browserBinding);
|
|
120
|
+
if (!binding && method === 'tools.info') {
|
|
121
|
+
// Desktop sharing is optional. Checking terminal/files must not start a
|
|
122
|
+
// separate browser or require its dependencies when no VNC was configured.
|
|
123
|
+
const metadata = { browser: false, browserMode: 'shared', browserBinding: browserCapability(undefined) };
|
|
124
|
+
return this.kind === 'browser' ? metadata : { ...await this.localRequest(method, params), ...metadata };
|
|
125
|
+
}
|
|
126
|
+
if (binding && (method.startsWith('browser.') || method === 'tools.info')) {
|
|
127
|
+
const metadata = { browser: true, browserMode: 'shared', browserBinding: browserCapability(binding) };
|
|
128
|
+
if (method === 'browser.list')
|
|
129
|
+
return { ...metadata, sessions: [] };
|
|
130
|
+
if (method === 'tools.info')
|
|
131
|
+
return this.kind === 'browser' ? metadata : { ...await this.localRequest(method, params), ...metadata };
|
|
132
|
+
throw new Error('Shared browser uses the live desktop viewer; legacy browser session operations are unavailable');
|
|
133
|
+
}
|
|
134
|
+
if (this.kind === 'terminal' && (method.startsWith('browser.') || method === 'tools.info')) {
|
|
135
|
+
this.browser ??= new ToolsClient(this.options, 'browser');
|
|
136
|
+
if (method.startsWith('browser.'))
|
|
137
|
+
return this.browser.request(method, params);
|
|
138
|
+
const [terminal, browser] = await Promise.all([this.localRequest(method, params), this.browser.request(method, params).catch(error => ({ browser: false, browserError: error.message }))]);
|
|
139
|
+
const result = { ...terminal, ...browser, browserMode: 'legacy', browserBinding: browserCapability(undefined) };
|
|
140
|
+
if (result.browser)
|
|
141
|
+
delete result.browserError;
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
return this.localRequest(method, params);
|
|
145
|
+
}
|
|
146
|
+
async localRequest(method, params) {
|
|
147
|
+
const deadline = Date.now() + 7500;
|
|
148
|
+
await this.ensure(deadline);
|
|
149
|
+
// Never automatically retry a request: terminal input and file mutations may have executed
|
|
150
|
+
// even if their response was lost. Chunk retry is a caller decision with exact offset/data.
|
|
151
|
+
return keeperRpc(this.paths.socket, method, params, Math.max(1, deadline - Date.now()));
|
|
152
|
+
}
|
|
153
|
+
ensure(deadline) {
|
|
154
|
+
if (!this.starting) {
|
|
155
|
+
this.starting = this.start(deadline).finally(() => { this.starting = undefined; });
|
|
156
|
+
}
|
|
157
|
+
return this.starting;
|
|
158
|
+
}
|
|
159
|
+
async start(deadline) {
|
|
160
|
+
await privateDirectory(path.dirname(this.paths.dir));
|
|
161
|
+
await privateDirectory(this.paths.dir);
|
|
162
|
+
if (await this.probe())
|
|
163
|
+
return;
|
|
164
|
+
while (Date.now() < deadline - 500) {
|
|
165
|
+
if (await this.probe())
|
|
166
|
+
return;
|
|
167
|
+
let lock;
|
|
168
|
+
try {
|
|
169
|
+
lock = await this.acquireStartupLock();
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error.code !== 'EADDRINUSE')
|
|
173
|
+
throw error;
|
|
174
|
+
await delay(50);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
if (await this.probe())
|
|
179
|
+
return;
|
|
180
|
+
// A probe timeout is not process death. A busy emulator can delay ping; unlinking its live
|
|
181
|
+
// socket would orphan every PTY. The startup PID manifest is written before initialization.
|
|
182
|
+
while (await this.liveManifest()) {
|
|
183
|
+
if (await this.probe())
|
|
184
|
+
return;
|
|
185
|
+
if (Date.now() >= deadline - 300)
|
|
186
|
+
throw new Error('Tools keeper is alive but busy or unavailable; retry later');
|
|
187
|
+
await delay(50);
|
|
188
|
+
}
|
|
189
|
+
await fs.unlink(this.paths.socket).catch(error => { if (error.code !== 'ENOENT')
|
|
190
|
+
throw error; });
|
|
191
|
+
const launchConfig = Buffer.from(JSON.stringify({ socket: this.paths.socket, identity: this.paths.unit, home: this.options.home, roots: this.options.roots ?? [], kind: this.kind, profileRoot: path.join(path.resolve(this.options.configDir), 'browser-profiles', this.paths.unit), executablePath: this.options.browserExecutable ?? process.env.AXOM_BROWSER_EXECUTABLE, proxy: this.options.browserProxy ?? process.env.AXOM_BROWSER_PROXY })).toString('base64');
|
|
192
|
+
const script = fileURLToPath(new URL('./keeperProcess.js', import.meta.url));
|
|
193
|
+
if (script.endsWith('/src/workstation/tools/keeperProcess.js'))
|
|
194
|
+
throw new Error('Tools keeper requires built CLI distribution');
|
|
195
|
+
let isolated = false;
|
|
196
|
+
if (process.platform === 'linux' && this.options.useSystemd !== false && await this.systemdAvailable()) {
|
|
197
|
+
// setsid/detached cannot escape systemd's KillMode=control-group. A separate user service
|
|
198
|
+
// gives keeper/PTYS their own cgroup and allows the transport daemon to restart safely.
|
|
199
|
+
isolated = await this.systemdLaunch(script, launchConfig, deadline);
|
|
200
|
+
if (!isolated) {
|
|
201
|
+
// An existing user unit may still be starting. Never spawn a same-cgroup fallback
|
|
202
|
+
// on a systemd host, where a daemon restart would silently kill every live terminal.
|
|
203
|
+
while (Date.now() < deadline - 100) {
|
|
204
|
+
if (await this.probe())
|
|
205
|
+
return;
|
|
206
|
+
await delay(50);
|
|
207
|
+
}
|
|
208
|
+
throw new Error('Cannot start isolated tools keeper user service');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (!isolated) {
|
|
212
|
+
const child = spawn(process.execPath, [script, launchConfig], { detached: true, stdio: 'ignore' });
|
|
213
|
+
child.on('error', () => { });
|
|
214
|
+
child.unref();
|
|
215
|
+
}
|
|
216
|
+
while (Date.now() < deadline - 100) {
|
|
217
|
+
if (await this.probe())
|
|
218
|
+
return;
|
|
219
|
+
await delay(50);
|
|
220
|
+
}
|
|
221
|
+
throw new Error('Workstation tools keeper failed to start');
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
await new Promise(resolve => lock.close(() => resolve()));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
throw new Error('Workstation tools keeper startup is busy or timed out');
|
|
228
|
+
}
|
|
229
|
+
/** A kernel bind is an atomic cross-process lock with automatic release on process death.
|
|
230
|
+
* It carries no requests, and needs no stale-file unlink or ownership-blind finally cleanup.
|
|
231
|
+
* Rare identity-port collisions only serialize startup; all data still uses the private socket. */
|
|
232
|
+
acquireStartupLock() {
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
const lock = net.createServer(socket => socket.destroy());
|
|
235
|
+
lock.once('error', reject);
|
|
236
|
+
lock.listen({ host: '127.0.0.1', port: this.paths.lockPort, exclusive: true }, () => resolve(lock));
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
async liveManifest() {
|
|
240
|
+
try {
|
|
241
|
+
const stat = await fs.lstat(this.paths.pidFile);
|
|
242
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096)
|
|
243
|
+
throw new Error('Invalid tools keeper PID manifest');
|
|
244
|
+
const value = JSON.parse(await fs.readFile(this.paths.pidFile, 'utf8'));
|
|
245
|
+
if (value.identity !== this.paths.unit)
|
|
246
|
+
throw new Error('Tools keeper identity mismatch');
|
|
247
|
+
return pidAlive(value.pid);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (error.code === 'ENOENT')
|
|
251
|
+
return false;
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async systemdAvailable() {
|
|
256
|
+
return new Promise(resolve => {
|
|
257
|
+
const child = spawn('systemctl', ['--user', 'show-environment'], { stdio: 'ignore' });
|
|
258
|
+
let done = false;
|
|
259
|
+
const finish = (ok) => { if (done)
|
|
260
|
+
return; done = true; clearTimeout(timer); resolve(ok); };
|
|
261
|
+
const timer = setTimeout(() => { child.kill(); finish(false); }, 500);
|
|
262
|
+
child.on('error', () => finish(false));
|
|
263
|
+
child.on('exit', code => finish(code === 0));
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
async systemdLaunch(script, config, deadline) {
|
|
267
|
+
return new Promise(resolve => {
|
|
268
|
+
const child = spawn('systemd-run', ['--user', '--quiet', '--collect', `--unit=${this.paths.unit}`, '--property=Type=exec', '--property=Restart=on-failure', '--property=RestartSec=1', '--', process.execPath, script, config], { stdio: 'ignore' });
|
|
269
|
+
let done = false;
|
|
270
|
+
const finish = (ok) => { if (done)
|
|
271
|
+
return; done = true; clearTimeout(timer); resolve(ok); };
|
|
272
|
+
const timer = setTimeout(() => { child.kill(); finish(false); }, Math.max(1, Math.min(2000, deadline - Date.now() - 500)));
|
|
273
|
+
child.on('error', () => finish(false));
|
|
274
|
+
child.on('exit', code => finish(code === 0));
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|