@platless/axom-cli 0.1.0 → 0.3.13
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 +110 -0
- package/dist/axom-patch/cli/pi-steering.json +37 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +34 -0
- package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +49 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +81 -0
- package/dist/axom-patch/cli/qoder-foreground-output.md +41 -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 +37 -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/cli.js +9 -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/mcp.js +32 -14
- package/dist/workstation/acp/acpAgents.js +447 -166
- package/dist/workstation/acp/acpClient.js +232 -18
- package/dist/workstation/acp/acpSession.js +502 -74
- package/dist/workstation/acp/activityBusy.js +43 -0
- package/dist/workstation/acp/activityReporting.js +54 -0
- package/dist/workstation/acp/adoption.js +107 -49
- package/dist/workstation/acp/authUrlSniffer.js +36 -0
- package/dist/workstation/acp/contract.js +11 -6
- package/dist/workstation/acp/delegationMcp.js +24 -0
- package/dist/workstation/acp/draftInterceptor.js +9 -0
- package/dist/workstation/acp/managedPatches.js +228 -0
- package/dist/workstation/acp/patchAssets.js +63 -0
- package/dist/workstation/acp/permission.js +300 -34
- package/dist/workstation/acp/qoderStartup.js +45 -0
- package/dist/workstation/acp/registry/catalog.js +263 -4
- package/dist/workstation/acp/registry/detect.js +55 -0
- package/dist/workstation/acp/sessionScope.js +19 -0
- package/dist/workstation/acp/sessionUpdateMapper.js +39 -21
- package/dist/workstation/acp/terminalLogin.js +197 -0
- package/dist/workstation/acp/test/fakeAgent.js +185 -9
- package/dist/workstation/acp/validate.js +33 -5
- package/dist/workstation/agentSessions.js +532 -44
- package/dist/workstation/channels/agent.js +7 -1
- package/dist/workstation/channels/computer.js +152 -4
- package/dist/workstation/channels/management.js +3 -3
- package/dist/workstation/config.js +3 -1
- package/dist/workstation/connection/encryptedChannel.js +88 -21
- package/dist/workstation/daemon.js +25 -3
- 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/fileOps.js +277 -0
- package/dist/workstation/filePaths.js +174 -0
- package/dist/workstation/fileWorker.js +81 -0
- package/dist/workstation/fileWorkerClient.js +121 -0
- package/dist/workstation/oauth/deviceAuthorization.js +21 -12
- package/dist/workstation/protocol/agent.js +154 -5
- package/dist/workstation/protocol/common.js +6 -0
- package/dist/workstation/protocol/computer.js +92 -3
- package/dist/workstation/qoderAdapter/backgroundProcess.js +106 -0
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +262 -0
- package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
- package/dist/workstation/qoderAdapter/contract.js +78 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +30 -0
- package/dist/workstation/qoderAdapter/history.js +87 -0
- package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
- package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
- package/dist/workstation/qoderAdapter/main.js +93 -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 +49 -0
- package/dist/workstation/qoderAdapter/session.js +319 -0
- package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
- package/dist/workstation/qoderAdapter/subwork.js +77 -0
- package/dist/workstation/qoderAdapter/taskControl.js +38 -0
- package/dist/workstation/qoderAdapter/taskEvent.js +26 -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/tools/browser.js +394 -0
- package/dist/workstation/tools/client.js +269 -0
- package/dist/workstation/tools/core.js +50 -0
- package/dist/workstation/tools/files.js +525 -0
- package/dist/workstation/tools/keeperProcess.js +114 -0
- package/dist/workstation/tools/terminal.js +214 -0
- package/dist/workstation/tools/validation.js +50 -0
- package/dist/workstation/update/coordinator.js +29 -0
- package/dist/workstation/update/npmInstaller.js +2 -0
- package/dist/workstation/update/ptyPreflight.js +24 -0
- package/dist/workstation/update/taskRegistry.js +44 -20
- package/dist/workstation/update/watchdog.js +66 -0
- package/package.json +14 -4
- package/skills/axom/SKILL.md +55 -17
- package/skills/axom-definition/SKILL.md +32 -32
- package/skills/axom-platform/SKILL.md +39 -39
|
@@ -78,7 +78,13 @@ export async function dispatchAgentChannelFrames(messages, send, agents, close =
|
|
|
78
78
|
}
|
|
79
79
|
continue;
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
// Start handlers in wire order, but keep receiving while their RPCs / disk I/O wait.
|
|
82
|
+
// A probe can take longer than an open's 30s or a config change's 10s deadline; awaiting it
|
|
83
|
+
// here makes every unrelated session time out while heartbeats still report a healthy socket.
|
|
84
|
+
// The manager owns open deduplication, close-during-open tombstones and per-session journal
|
|
85
|
+
// ordering. Answers, interrupts and closes must also reach it while an earlier RPC is pending.
|
|
86
|
+
// dispatchAgentFrame catches rejections, including for these detached handlers.
|
|
87
|
+
void dispatchAgentFrame(agents, frame);
|
|
82
88
|
}
|
|
83
89
|
}
|
|
84
90
|
catch (error) {
|
|
@@ -1,17 +1,27 @@
|
|
|
1
|
+
import { DesktopBridge } from '../desktop/bridge.js';
|
|
2
|
+
import { resolveBrowserBinding } from '../desktop/binding.js';
|
|
1
3
|
import { createHash } from 'node:crypto';
|
|
2
4
|
import { connectEncryptedChannel } from '../connection/encryptedChannel.js';
|
|
3
5
|
import { runExec } from '../exec.js';
|
|
4
6
|
import { decodeComputerFrame, encodeComputerFrame } from '../protocol/computer.js';
|
|
5
7
|
import { workstationChannelUrl } from './url.js';
|
|
6
8
|
import { TaskRegistry } from '../update/taskRegistry.js';
|
|
9
|
+
import { createComputerDirSafely, listComputerDirSafely, readComputerFileSafely, writeComputerFileSafely } from '../fileWorkerClient.js';
|
|
10
|
+
import { computerFileError } from '../filePaths.js';
|
|
11
|
+
import { ToolsClient } from '../tools/client.js';
|
|
12
|
+
import { object } from '../tools/validation.js';
|
|
7
13
|
/**
|
|
8
14
|
* Consumes computer frames without serializing long-running commands behind one another.
|
|
9
15
|
* All command failures and post-close sends are contained here because this is called detached
|
|
10
16
|
* from the socket connection loop.
|
|
11
17
|
*/
|
|
12
|
-
export async function dispatchComputerFrames(messages, send, execute, tasks = new TaskRegistry()) {
|
|
18
|
+
export async function dispatchComputerFrames(messages, send, execute, tasks = new TaskRegistry(), files = {}, tools, desktop) {
|
|
13
19
|
const active = new Map();
|
|
14
|
-
const
|
|
20
|
+
const activeTools = new Set();
|
|
21
|
+
let open = true;
|
|
22
|
+
let wireVersion = 1;
|
|
23
|
+
const safeSend = (frame) => { if (!open)
|
|
24
|
+
return; try {
|
|
15
25
|
send(frame);
|
|
16
26
|
}
|
|
17
27
|
catch { /* socket already closed */ } };
|
|
@@ -51,15 +61,149 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
|
|
|
51
61
|
}
|
|
52
62
|
})();
|
|
53
63
|
};
|
|
64
|
+
const runFile = (request) => {
|
|
65
|
+
// Read/write landed in wire v2; directory listing and creation arrived with v3.
|
|
66
|
+
const minWireVersion = request.t === 'file_ls_request' || request.t === 'file_mkdir_request' ? 3 : 2;
|
|
67
|
+
const fail = (code, message) => {
|
|
68
|
+
if (request.t === 'file_read_request')
|
|
69
|
+
safeSend({ t: 'file_read_result', id: request.id, error: { code, message } });
|
|
70
|
+
else if (request.t === 'file_write_request')
|
|
71
|
+
safeSend({ t: 'file_write_result', id: request.id, error: { code, message } });
|
|
72
|
+
else if (request.t === 'file_ls_request')
|
|
73
|
+
safeSend({ t: 'file_ls_result', id: request.id, error: { code, message } });
|
|
74
|
+
else
|
|
75
|
+
safeSend({ t: 'file_mkdir_result', id: request.id, error: { code, message } });
|
|
76
|
+
};
|
|
77
|
+
if (wireVersion < minWireVersion) {
|
|
78
|
+
fail('unsupported_protocol', `Computer file operations require wire v${minWireVersion}`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (!tasks.accepting) {
|
|
82
|
+
fail('workstation_offline', 'Workstation is not accepting new work');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
active.get(request.id)?.abort();
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
active.set(request.id, controller);
|
|
88
|
+
void (async () => {
|
|
89
|
+
try {
|
|
90
|
+
const data = await tasks.withShellTask(async (shutdown) => {
|
|
91
|
+
const onShutdown = () => controller.abort();
|
|
92
|
+
if (shutdown.aborted)
|
|
93
|
+
controller.abort();
|
|
94
|
+
else
|
|
95
|
+
shutdown.addEventListener('abort', onShutdown, { once: true });
|
|
96
|
+
try {
|
|
97
|
+
if (request.t === 'file_read_request') {
|
|
98
|
+
return await (files.readFile ?? readComputerFileSafely)({ root: request.root, path: request.path, startLine: request.startLine, maxLines: request.maxLines }, controller.signal);
|
|
99
|
+
}
|
|
100
|
+
if (request.t === 'file_write_request') {
|
|
101
|
+
return await (files.writeFile ?? writeComputerFileSafely)({ root: request.root, path: request.path, content: request.content, mode: request.mode, expectedSha256: request.expectedSha256 }, controller.signal);
|
|
102
|
+
}
|
|
103
|
+
if (request.t === 'file_ls_request') {
|
|
104
|
+
return await (files.listDir ?? listComputerDirSafely)({ root: request.root, path: request.path, startAfter: request.startAfter, maxEntries: request.maxEntries }, controller.signal);
|
|
105
|
+
}
|
|
106
|
+
return await (files.createDir ?? createComputerDirSafely)({ root: request.root, path: request.path, parents: request.parents }, controller.signal);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
shutdown.removeEventListener('abort', onShutdown);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
if (active.get(request.id) !== controller)
|
|
113
|
+
return;
|
|
114
|
+
if (request.t === 'file_read_request')
|
|
115
|
+
safeSend({ t: 'file_read_result', id: request.id, data: data });
|
|
116
|
+
else if (request.t === 'file_write_request')
|
|
117
|
+
safeSend({ t: 'file_write_result', id: request.id, data: data });
|
|
118
|
+
else if (request.t === 'file_ls_request')
|
|
119
|
+
safeSend({ t: 'file_ls_result', id: request.id, data: data });
|
|
120
|
+
else
|
|
121
|
+
safeSend({ t: 'file_mkdir_result', id: request.id, data: data });
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (active.get(request.id) !== controller)
|
|
125
|
+
return;
|
|
126
|
+
const mapped = computerFileError(error);
|
|
127
|
+
if (controller.signal.aborted) {
|
|
128
|
+
const mutation = request.t === 'file_write_request' || request.t === 'file_mkdir_request';
|
|
129
|
+
fail(mutation ? 'outcome_unknown' : 'workstation_offline', request.t === 'file_write_request' ? 'Write outcome is unknown after cancellation'
|
|
130
|
+
: request.t === 'file_mkdir_request' ? 'Directory creation outcome is unknown after cancellation'
|
|
131
|
+
: 'Read was cancelled');
|
|
132
|
+
}
|
|
133
|
+
else
|
|
134
|
+
fail(mapped.code, mapped.message);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
if (active.get(request.id) === controller)
|
|
138
|
+
active.delete(request.id);
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
};
|
|
142
|
+
const runTool = (request) => {
|
|
143
|
+
const fail = (error) => safeSend({ t: 'tool_result', id: request.id, ok: false, result: {}, error: (error || 'Workstation tool failed').slice(0, 1024) });
|
|
144
|
+
if (wireVersion < 3) {
|
|
145
|
+
fail('Workstation tools require computer wire v3');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (!tasks.accepting) {
|
|
149
|
+
fail('Workstation is not accepting new work');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (!tools && request.method !== 'browser.sharedText') {
|
|
153
|
+
fail('Workstation tools keeper is not configured');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (activeTools.has(request.id)) {
|
|
157
|
+
fail('Duplicate tool request id');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (activeTools.size >= 16) {
|
|
161
|
+
fail('Too many simultaneous tool requests');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
activeTools.add(request.id);
|
|
165
|
+
void (async () => {
|
|
166
|
+
let timer;
|
|
167
|
+
try {
|
|
168
|
+
const result = await tasks.withShellTask(() => Promise.race([
|
|
169
|
+
request.method === 'browser.sharedText' ? (desktop ? desktop.insertText(request.params) : Promise.reject(Error('Shared desktop unavailable'))) : tools(request.method, request.params),
|
|
170
|
+
new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error('Workstation tool timed out; mutation outcome may be unknown')), 7500); }),
|
|
171
|
+
]));
|
|
172
|
+
if (!object(result))
|
|
173
|
+
throw new Error('Invalid workstation tool result');
|
|
174
|
+
safeSend({ t: 'tool_result', id: request.id, ok: true, result, error: null });
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
fail(error instanceof Error ? error.message : 'Workstation tool failed');
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
if (timer)
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
activeTools.delete(request.id);
|
|
183
|
+
}
|
|
184
|
+
})();
|
|
185
|
+
};
|
|
54
186
|
try {
|
|
55
187
|
for await (const frame of messages) {
|
|
56
|
-
if (frame.t
|
|
188
|
+
if (frame.t.startsWith('desktop_'))
|
|
189
|
+
desktop?.onFrame(frame);
|
|
190
|
+
else if (frame.t === 'tool_request')
|
|
191
|
+
runTool(frame);
|
|
192
|
+
else if (frame.t === 'exec_request')
|
|
57
193
|
run(frame);
|
|
58
194
|
else if (frame.t === 'exec_cancel')
|
|
59
195
|
active.get(frame.id)?.abort();
|
|
196
|
+
else if (frame.t === 'computer_ready') {
|
|
197
|
+
wireVersion = frame.wireVersion;
|
|
198
|
+
desktop?.ready(frame.generation, frame.wireVersion);
|
|
199
|
+
}
|
|
200
|
+
else if (frame.t === 'file_read_request' || frame.t === 'file_write_request' || frame.t === 'file_ls_request' || frame.t === 'file_mkdir_request')
|
|
201
|
+
runFile(frame);
|
|
60
202
|
}
|
|
61
203
|
}
|
|
62
204
|
finally {
|
|
205
|
+
open = false;
|
|
206
|
+
desktop?.close();
|
|
63
207
|
for (const controller of active.values())
|
|
64
208
|
controller.abort();
|
|
65
209
|
}
|
|
@@ -68,9 +212,12 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
|
|
|
68
212
|
export class ComputerChannel {
|
|
69
213
|
options;
|
|
70
214
|
tasks;
|
|
215
|
+
tools;
|
|
71
216
|
constructor(options) {
|
|
72
217
|
this.options = options;
|
|
73
218
|
this.tasks = options.taskRegistry ?? new TaskRegistry();
|
|
219
|
+
const client = options.toolsConfig ? new ToolsClient(options.toolsConfig) : undefined;
|
|
220
|
+
this.tools = options.tools ?? (client ? (method, params) => client.request(method, params) : undefined);
|
|
74
221
|
}
|
|
75
222
|
activeTaskCount() { return this.tasks.snapshot().activeShellTasks; }
|
|
76
223
|
async connect(ticket, signal) {
|
|
@@ -86,6 +233,7 @@ export class ComputerChannel {
|
|
|
86
233
|
return { closed: channel.closed, close: () => channel.close() };
|
|
87
234
|
}
|
|
88
235
|
async dispatch(channel) {
|
|
89
|
-
|
|
236
|
+
const desktop = new DesktopBridge(resolveBrowserBinding(this.options.browserBinding ?? this.options.toolsConfig?.browserBinding), frame => channel.send(frame), () => channel.bufferedAmount);
|
|
237
|
+
await dispatchComputerFrames(channel.messages, (frame) => channel.send(frame), this.options.runExec ?? runExec, this.tasks, this.options, this.tools, desktop);
|
|
90
238
|
}
|
|
91
239
|
}
|
|
@@ -8,7 +8,7 @@ import { osInfo } from '../osinfo.js';
|
|
|
8
8
|
import { workstationChannelUrl } from './url.js';
|
|
9
9
|
const protocols = {
|
|
10
10
|
MANAGEMENT: { min: MANAGEMENT_WIRE_MIN_VERSION, max: MANAGEMENT_WIRE_VERSION },
|
|
11
|
-
COMPUTER: { min: 1, max:
|
|
11
|
+
COMPUTER: { min: 1, max: 4 }, AGENT: { min: AGENT_WIRE_MIN_VERSION, max: AGENT_WIRE_VERSION },
|
|
12
12
|
};
|
|
13
13
|
/** Exported so tests can assert the wire frame's `clientPackage` without opening a socket. */
|
|
14
14
|
export function buildHelloFrame(options, clientNonce) {
|
|
@@ -180,8 +180,8 @@ export class ManagementChannel {
|
|
|
180
180
|
}),
|
|
181
181
|
};
|
|
182
182
|
}
|
|
183
|
-
heartbeat(seq, sentAtEpochMs) {
|
|
184
|
-
const snapshot = this.options.snapshot();
|
|
183
|
+
async heartbeat(seq, sentAtEpochMs) {
|
|
184
|
+
const snapshot = await this.options.snapshot();
|
|
185
185
|
return {
|
|
186
186
|
t: 'heartbeat', seq, sentAtEpochMs, clientVersion: CLIENT_VERSION,
|
|
187
187
|
channels: {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveBrowserBinding } from './desktop/binding.js';
|
|
1
2
|
import { promises as fs } from 'node:fs';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
@@ -33,7 +34,8 @@ export async function loadClientConfig(dir) {
|
|
|
33
34
|
if (!parsed?.server || !parsed.workstationId) {
|
|
34
35
|
throw new Error('Invalid workstation config: server and workstationId are required; run axom auth again');
|
|
35
36
|
}
|
|
36
|
-
|
|
37
|
+
const browserBinding = parsed.browserBinding ? resolveBrowserBinding(parsed.browserBinding, {}) : undefined;
|
|
38
|
+
return { ...(browserBinding ? { browserBinding } : {}), server: parsed.server, workstationId: parsed.workstationId, ...(Array.isArray(parsed.workspaces) ? { workspaces: parsed.workspaces } : {}) };
|
|
37
39
|
}
|
|
38
40
|
export async function writeKeys(dir, clientKeyB64, serverPubB64) {
|
|
39
41
|
await fs.mkdir(dir, { recursive: true });
|
|
@@ -67,6 +67,8 @@ export function connectEncryptedChannel(options) {
|
|
|
67
67
|
let openTimer;
|
|
68
68
|
let nextSequence = 1;
|
|
69
69
|
const outstandingAcks = new Set();
|
|
70
|
+
let lastReceivedAt = timers.now();
|
|
71
|
+
let maxHeartbeatLagMs = 0;
|
|
70
72
|
let resolveClosed;
|
|
71
73
|
let removeAbortListener = () => { };
|
|
72
74
|
const closed = new Promise((resolve) => { resolveClosed = resolve; });
|
|
@@ -103,30 +105,87 @@ export function connectEncryptedChannel(options) {
|
|
|
103
105
|
socket.send(crypto.seal(sessionKey, Buffer.from(options.encode(frame))));
|
|
104
106
|
};
|
|
105
107
|
const scheduleHeartbeat = () => {
|
|
108
|
+
const dueAt = timers.now() + heartbeatMs;
|
|
106
109
|
heartbeatTimer = timers.setTimeout(() => {
|
|
107
110
|
if (settled)
|
|
108
111
|
return;
|
|
112
|
+
maxHeartbeatLagMs = Math.max(maxHeartbeatLagMs, timers.now() - dueAt);
|
|
109
113
|
const sequence = nextSequence++;
|
|
110
114
|
// Register before send: a synchronous server acknowledgement/work frame emitted from
|
|
111
|
-
// `send` must observe and satisfy this precise liveness period.
|
|
115
|
+
// `send` must observe and satisfy this precise liveness period. This happens synchronously,
|
|
116
|
+
// before `options.heartbeat` is even awaited below, so that guarantee holds regardless of
|
|
117
|
+
// how long building the frame takes.
|
|
112
118
|
outstandingAcks.add(sequence);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
119
|
+
// `options.heartbeat` may be async (management's reconciles on-disk update state first), so
|
|
120
|
+
// the rest of this tick's work moves into a promise chain. There is deliberately no separate
|
|
121
|
+
// "did we close while the frame was being built" guard between the await and `send`: `settled`
|
|
122
|
+
// and `state` are only ever flipped together, synchronously, inside `finish()`, so `send`'s own
|
|
123
|
+
// `state !== 'OPEN'` check already covers every case such a guard could -- a second copy of the
|
|
124
|
+
// same condition here would be unreachable, untestable dead code, not defense in depth.
|
|
125
|
+
void (async () => {
|
|
126
|
+
let frame;
|
|
127
|
+
try {
|
|
128
|
+
frame = await options.heartbeat(sequence, timers.now());
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
// Unlike a `send` failure (below), this can come from `options.heartbeat` itself doing
|
|
132
|
+
// real work before it returns a frame -- management's reconciles on-disk update state
|
|
133
|
+
// first -- so a rejection here can be the *only* sign a whole heartbeat cycle silently
|
|
134
|
+
// gave up. Log it rather than let the channel simply close with no trace of why.
|
|
135
|
+
console.error(`[encrypted-channel] heartbeat frame build failed, closing: ${error instanceof Error ? error.message : String(error)}`);
|
|
136
|
+
outstandingAcks.delete(sequence);
|
|
137
|
+
close();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
send(frame);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
console.error(`[encrypted-channel] heartbeat send failed, closing: ${error instanceof Error ? error.message : String(error)}`);
|
|
145
|
+
outstandingAcks.delete(sequence);
|
|
146
|
+
close();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// `ws.send` can synchronously deliver close/error through a test double or a transport
|
|
150
|
+
// implementation. Never resurrect liveness state after that terminal callback.
|
|
151
|
+
if (settled)
|
|
152
|
+
return;
|
|
153
|
+
if (outstandingAcks.size >= missedAcks) {
|
|
154
|
+
console.warn(`${new Date(timers.now()).toISOString()} [encrypted-channel] ${options.url.pathname} closing after ` +
|
|
155
|
+
`${outstandingAcks.size} unconfirmed heartbeats; silentMs=${Math.max(0, timers.now() - lastReceivedAt)}, ` +
|
|
156
|
+
`timerLagMs=${maxHeartbeatLagMs}`);
|
|
157
|
+
close();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
scheduleHeartbeat();
|
|
161
|
+
})().catch((error) => {
|
|
162
|
+
// Nothing above is expected to throw past its own try/catch except `close()`'s
|
|
163
|
+
// `socket.close()` (a caller-supplied `WebSocketLike`) and `scheduleHeartbeat`'s
|
|
164
|
+
// `timers.setTimeout` -- both call into code this module does not control. Before
|
|
165
|
+
// `options.heartbeat` could be async, the equivalent synchronous throw escaped this
|
|
166
|
+
// `setTimeout` callback as an uncaught exception, which Node turns into a process exit that
|
|
167
|
+
// the supervisor restarts: loud and recoverable. Now it is a promise rejection instead, and
|
|
168
|
+
// `daemon.ts` registers a process-wide `unhandledRejection` handler that deliberately logs
|
|
169
|
+
// and keeps the daemon alive -- exactly wrong here, because an uncaught throw from
|
|
170
|
+
// `scheduleHeartbeat` in particular leaves `settled` false and `state` stuck at `'OPEN'`
|
|
171
|
+
// with nothing left to ever arm another heartbeat: the channel reports healthy forever while
|
|
172
|
+
// it has silently stopped. Force it terminal by hand instead of trusting whichever call
|
|
173
|
+
// above almost got there. `finish()` alone is synchronous and safe to call unconditionally
|
|
174
|
+
// (and idempotent if `close()` already ran before throwing). It does call two
|
|
175
|
+
// caller-supplied functions -- `timers.clearTimeout` and `removeAbortListener` (which wraps
|
|
176
|
+
// `AbortSignal.removeEventListener` when `options.signal` was given) -- so it is not
|
|
177
|
+
// literally incapable of throwing; it is safe here because production wiring for both is a
|
|
178
|
+
// real timer API and a real `AbortSignal`, neither of which throws from clearing/removing.
|
|
179
|
+
// The socket close is only ever best-effort from here, specifically so
|
|
180
|
+
// this handler's own failure stays impossible even if the very thing that just threw was
|
|
181
|
+
// `socket.close()` itself.
|
|
182
|
+
console.error(`[encrypted-channel] heartbeat cycle failed unrecoverably: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
183
|
+
finish();
|
|
184
|
+
try {
|
|
185
|
+
socket.close();
|
|
186
|
+
}
|
|
187
|
+
catch { /* best-effort; already reported above */ }
|
|
188
|
+
});
|
|
130
189
|
}, heartbeatMs);
|
|
131
190
|
};
|
|
132
191
|
return new Promise((resolve, reject) => {
|
|
@@ -183,7 +242,7 @@ export function connectEncryptedChannel(options) {
|
|
|
183
242
|
throw new Error('Encrypted channel needs a session key or handshake');
|
|
184
243
|
opened = true;
|
|
185
244
|
if (options.initialFrame)
|
|
186
|
-
send(options.initialFrame());
|
|
245
|
+
send(await options.initialFrame());
|
|
187
246
|
// An initial proof may synchronously trigger a socket close/error. `opened` permits the
|
|
188
247
|
// proof frame itself, but the public connection is not published until this guard passes.
|
|
189
248
|
if (settled || connectionSettled)
|
|
@@ -193,6 +252,7 @@ export function connectEncryptedChannel(options) {
|
|
|
193
252
|
disarmOpenTimer();
|
|
194
253
|
resolve({
|
|
195
254
|
send,
|
|
255
|
+
get bufferedAmount() { return socket.bufferedAmount ?? 0; },
|
|
196
256
|
messages,
|
|
197
257
|
close,
|
|
198
258
|
closed,
|
|
@@ -226,14 +286,20 @@ export function connectEncryptedChannel(options) {
|
|
|
226
286
|
// be the only server traffic while a task runs. Invalid frames take the catch path and never
|
|
227
287
|
// get to satisfy liveness.
|
|
228
288
|
outstandingAcks.clear();
|
|
289
|
+
lastReceivedAt = timers.now();
|
|
290
|
+
maxHeartbeatLagMs = 0;
|
|
229
291
|
if (!acknowledged)
|
|
230
292
|
messages.push(frame);
|
|
231
293
|
}
|
|
232
294
|
catch {
|
|
295
|
+
console.warn(`${new Date(timers.now()).toISOString()} [encrypted-channel] ${options.url.pathname} closing after an invalid encrypted frame`);
|
|
233
296
|
close();
|
|
234
297
|
}
|
|
235
298
|
});
|
|
236
|
-
socket.on('close', () => {
|
|
299
|
+
socket.on('close', (code, reason) => {
|
|
300
|
+
if (!settled)
|
|
301
|
+
console.warn(`${new Date(timers.now()).toISOString()} [encrypted-channel] ${options.url.pathname} peer closed; ` +
|
|
302
|
+
`code=${code ?? 'unknown'}, reason=${reason?.toString() || 'none'}`);
|
|
237
303
|
if (!connectionSettled) {
|
|
238
304
|
connectionSettled = true;
|
|
239
305
|
finish();
|
|
@@ -242,9 +308,10 @@ export function connectEncryptedChannel(options) {
|
|
|
242
308
|
}
|
|
243
309
|
finish();
|
|
244
310
|
});
|
|
245
|
-
socket.on('error', () => {
|
|
311
|
+
socket.on('error', (error) => {
|
|
246
312
|
if (settled)
|
|
247
313
|
return;
|
|
314
|
+
console.warn(`${new Date(timers.now()).toISOString()} [encrypted-channel] ${options.url.pathname} socket error: ${error?.message ?? 'unknown'}`);
|
|
248
315
|
const rejectOpening = !connectionSettled;
|
|
249
316
|
// Mark this before close(): ws and deterministic fakes can emit close synchronously.
|
|
250
317
|
if (rejectOpening)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { AGENT_WIRE_MIN_VERSION } from './protocol/agent.js';
|
|
4
|
-
import { clientDir, loadClientConfig, readKeys } from './config.js';
|
|
4
|
+
import { clientDir, loadClientConfig, readKeys, updateServiceContext } from './config.js';
|
|
5
5
|
import { AgentSessionManager } from './agentSessions.js';
|
|
6
6
|
import { AcpAgents } from './acp/acpAgents.js';
|
|
7
7
|
import { ConnectionSupervisor } from './connection/supervisor.js';
|
|
@@ -12,6 +12,7 @@ import { TaskRegistry } from './update/taskRegistry.js';
|
|
|
12
12
|
import { createUpdateCoordinator } from './update/runUpdate.js';
|
|
13
13
|
import { resolveCliPath } from './daemonctl.js';
|
|
14
14
|
import { SystemMetricsSampler } from './systemMetrics.js';
|
|
15
|
+
import { preflightOptionalPty } from './update/ptyPreflight.js';
|
|
15
16
|
/**
|
|
16
17
|
* The path `runDaemon` hands `createUpdateCoordinator` as `cliPath`, used to re-provision the OS
|
|
17
18
|
* service after an update (see runUpdate.ts's `provisionService`).
|
|
@@ -140,6 +141,22 @@ export function resolveServiceLifecycle(options) {
|
|
|
140
141
|
// provision-and-arm would hand that mode to a one-shot, unsupervised debugging invocation.
|
|
141
142
|
return options.supervision === 'container' ? 'container' : 'none';
|
|
142
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Builds the object `ManagementChannelOptions.snapshot` reports on every management heartbeat.
|
|
146
|
+
*
|
|
147
|
+
* Lifted out of `runDaemon` into its own function -- rather than staying the closure literal
|
|
148
|
+
* passed inline to `new ManagementChannel({ snapshot: ... })` -- specifically so a test can drive
|
|
149
|
+
* it directly. `runDaemon` boots a whole daemon (real config, real keys, a real `ManagementChannel`
|
|
150
|
+
* with no seam for injecting a socket), so before this extraction the *only* thing that ever
|
|
151
|
+
* verified `update:` here was the boot-*reconciled* phase (`updates.snapshotAsync()`), rather than
|
|
152
|
+
* the raw on-disk one (`updates.snapshot()`), was `tsc` accepting the `await`. `tsc` cannot tell
|
|
153
|
+
* `snapshotAsync()` and `snapshot()` apart by behavior -- both type-check -- so a future edit that
|
|
154
|
+
* quietly swaps one for the other would compile clean and wedge the server's view of every
|
|
155
|
+
* workstation the next time one restarts mid-update.
|
|
156
|
+
*/
|
|
157
|
+
export async function buildManagementSnapshot(supervisor, tasks, updates) {
|
|
158
|
+
return { channels: supervisor.channelSnapshot(), ...tasks.snapshot(), update: await updates.snapshotAsync() };
|
|
159
|
+
}
|
|
143
160
|
export async function runDaemon(options = {}) {
|
|
144
161
|
// Last line of defence for the whole process. Node's default for an unhandled rejection is to
|
|
145
162
|
// *exit*, and this daemon is the only thing keeping every session on this workstation alive —
|
|
@@ -152,6 +169,9 @@ export async function runDaemon(options = {}) {
|
|
|
152
169
|
const dir = clientDir();
|
|
153
170
|
const cfg = await loadClientConfig(dir);
|
|
154
171
|
const { clientKey, serverPub } = await readKeys(dir);
|
|
172
|
+
// Repair the first fixed release even when it was installed by an older ignore-scripts updater,
|
|
173
|
+
// before a newly started terminal keeper snapshots native dependency availability.
|
|
174
|
+
await preflightOptionalPty(updateServiceContext(dir));
|
|
155
175
|
const tasks = new TaskRegistry();
|
|
156
176
|
// Attaches both the server-driven update path (onUpdate below) and axom update's manual path to
|
|
157
177
|
// the same coordinator assembly — see runUpdate.ts.
|
|
@@ -183,7 +203,9 @@ export async function runDaemon(options = {}) {
|
|
|
183
203
|
// Plaintext WebSockets are only for an explicitly opted-in local development fixture. Production
|
|
184
204
|
// ticket handshakes must be WSS because the ticket is the data-channel key derivation material.
|
|
185
205
|
const allowInsecureWs = process.env.AXOM_WORKSTATION_ALLOW_INSECURE_WS === '1';
|
|
186
|
-
const computer = new ComputerChannel({ server: cfg.server, allowInsecureWs, taskRegistry: tasks
|
|
206
|
+
const computer = new ComputerChannel({ server: cfg.server, allowInsecureWs, taskRegistry: tasks,
|
|
207
|
+
toolsConfig: { configDir: dir, workstationId: cfg.workstationId, server: cfg.server, roots: (cfg.workspaces ?? []).map(workspace => workspace.cwd), browserBinding: cfg.browserBinding },
|
|
208
|
+
});
|
|
187
209
|
const metrics = new SystemMetricsSampler(cfg.workspaces ?? []);
|
|
188
210
|
await metrics.start();
|
|
189
211
|
const management = new ManagementChannel({
|
|
@@ -198,7 +220,7 @@ export async function runDaemon(options = {}) {
|
|
|
198
220
|
bridges = await acp.roster();
|
|
199
221
|
return bridges;
|
|
200
222
|
},
|
|
201
|
-
snapshot: () => (
|
|
223
|
+
snapshot: () => buildManagementSnapshot(supervisor, tasks, updates),
|
|
202
224
|
});
|
|
203
225
|
const agent = new AgentChannel({ server: cfg.server, agents, bridges: () => bridges, allowInsecureWs });
|
|
204
226
|
supervisor = new ConnectionSupervisor({
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
function endpoint(text, kind) {
|
|
3
|
+
let url;
|
|
4
|
+
try {
|
|
5
|
+
url = new URL(text);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
throw new Error('Invalid browser endpoint');
|
|
9
|
+
}
|
|
10
|
+
// Require literal loopback authorities, before WHATWG canonicalizes short/hex IPv4.
|
|
11
|
+
if (!/^(?:tcp|ws|wss|http|https):\/\/(?:127\.0\.0\.1|\[::1\]):\d+(?:\/|$)/.test(text) || url.username || url.password || url.search || url.hash || !url.port || Number(url.port) < 1 || Number(url.port) > 65535)
|
|
12
|
+
throw new Error('Browser endpoints require an explicit loopback host and port without credentials');
|
|
13
|
+
if (kind === 'vnc' ? !['tcp:', 'ws:', 'wss:'].includes(url.protocol) : !['http:', 'https:'].includes(url.protocol))
|
|
14
|
+
throw new Error('Unsupported browser endpoint transport');
|
|
15
|
+
if (url.protocol === 'tcp:' && url.pathname && url.pathname !== '/')
|
|
16
|
+
throw new Error('TCP endpoint cannot have a path');
|
|
17
|
+
return url.toString();
|
|
18
|
+
}
|
|
19
|
+
export function resolveBrowserBinding(config, env = process.env) {
|
|
20
|
+
if (config && (typeof config !== 'object' || typeof config.vncEndpoint !== 'string' || !config.vncEndpoint))
|
|
21
|
+
throw new Error('Invalid browser binding config');
|
|
22
|
+
const vnc = env.AXOM_BROWSER_VNC_URL ?? config?.vncEndpoint;
|
|
23
|
+
if (!vnc)
|
|
24
|
+
return undefined;
|
|
25
|
+
const binding = { bindingId: config?.bindingId ?? 'default', displayName: config?.displayName ?? 'Shared browser', vncEndpoint: endpoint(vnc, 'vnc'), cdpEndpoint: env.AXOM_BROWSER_CDP_URL ?? config?.cdpEndpoint, downloadsRoot: config?.downloadsRoot };
|
|
26
|
+
if (typeof binding.bindingId !== 'string' || !binding.bindingId || binding.bindingId.length > 128 || typeof binding.displayName !== 'string' || !binding.displayName || binding.displayName.length > 128)
|
|
27
|
+
throw new Error('Invalid browser binding metadata');
|
|
28
|
+
if (binding.cdpEndpoint)
|
|
29
|
+
binding.cdpEndpoint = endpoint(binding.cdpEndpoint, 'cdp');
|
|
30
|
+
if (binding.downloadsRoot !== undefined && (typeof binding.downloadsRoot !== 'string' || !path.isAbsolute(binding.downloadsRoot) || binding.downloadsRoot.includes('\0')))
|
|
31
|
+
throw new Error('Browser downloadsRoot must be an explicit absolute directory');
|
|
32
|
+
return binding;
|
|
33
|
+
}
|
|
34
|
+
export function browserCapability(binding) {
|
|
35
|
+
return { bindingId: binding?.bindingId ?? null, displayName: binding?.displayName ?? null, configured: !!binding, cdp: !!binding?.cdpEndpoint };
|
|
36
|
+
}
|