@platless/axom-cli 0.1.0

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.
Files changed (90) hide show
  1. package/README.md +64 -0
  2. package/dist/args.js +29 -0
  3. package/dist/cli.js +76 -0
  4. package/dist/commands/auth.js +52 -0
  5. package/dist/commands/execute.js +40 -0
  6. package/dist/commands/guide.js +38 -0
  7. package/dist/commands/record.js +99 -0
  8. package/dist/commands/skills.js +69 -0
  9. package/dist/commands/struct.js +50 -0
  10. package/dist/commands/update.js +88 -0
  11. package/dist/commands/whoami.js +8 -0
  12. package/dist/commands/workstation.js +171 -0
  13. package/dist/config.js +131 -0
  14. package/dist/credentialBroker.js +340 -0
  15. package/dist/credentials.js +211 -0
  16. package/dist/install/agentsDir.js +35 -0
  17. package/dist/install/claude/commands.js +89 -0
  18. package/dist/install/claude/materialize.js +42 -0
  19. package/dist/install/installer.js +45 -0
  20. package/dist/install/targets/claude.js +154 -0
  21. package/dist/mcp.js +152 -0
  22. package/dist/output.js +37 -0
  23. package/dist/workstation/acp/acpAgents.js +1287 -0
  24. package/dist/workstation/acp/acpClient.js +691 -0
  25. package/dist/workstation/acp/acpSession.js +1047 -0
  26. package/dist/workstation/acp/adoption.js +396 -0
  27. package/dist/workstation/acp/auth.js +80 -0
  28. package/dist/workstation/acp/authUrlSniffer.js +52 -0
  29. package/dist/workstation/acp/binaryInstall.js +411 -0
  30. package/dist/workstation/acp/configOptions.js +257 -0
  31. package/dist/workstation/acp/contract.js +494 -0
  32. package/dist/workstation/acp/draftInterceptor.js +154 -0
  33. package/dist/workstation/acp/draftSubagents.js +38 -0
  34. package/dist/workstation/acp/installErrors.js +89 -0
  35. package/dist/workstation/acp/installProbe.js +363 -0
  36. package/dist/workstation/acp/permission.js +327 -0
  37. package/dist/workstation/acp/registry/catalog.js +29 -0
  38. package/dist/workstation/acp/registry/launch.js +128 -0
  39. package/dist/workstation/acp/registry/registryClient.js +160 -0
  40. package/dist/workstation/acp/registry/registryTypes.js +8 -0
  41. package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
  42. package/dist/workstation/acp/test/fakeAgent.js +435 -0
  43. package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
  44. package/dist/workstation/acp/timedCommand.js +47 -0
  45. package/dist/workstation/acp/uvxInstall.js +248 -0
  46. package/dist/workstation/acp/validate.js +134 -0
  47. package/dist/workstation/agentSessions.js +1628 -0
  48. package/dist/workstation/channels/agent.js +96 -0
  49. package/dist/workstation/channels/computer.js +91 -0
  50. package/dist/workstation/channels/management.js +231 -0
  51. package/dist/workstation/channels/url.js +42 -0
  52. package/dist/workstation/config.js +47 -0
  53. package/dist/workstation/connection/encryptedChannel.js +261 -0
  54. package/dist/workstation/connection/retryPolicy.js +14 -0
  55. package/dist/workstation/connection/supervisor.js +353 -0
  56. package/dist/workstation/crypto.js +63 -0
  57. package/dist/workstation/daemon.js +225 -0
  58. package/dist/workstation/daemonctl.js +95 -0
  59. package/dist/workstation/exec.js +112 -0
  60. package/dist/workstation/journal.js +257 -0
  61. package/dist/workstation/names.js +17 -0
  62. package/dist/workstation/oauth/browser.js +24 -0
  63. package/dist/workstation/oauth/deviceAuthorization.js +198 -0
  64. package/dist/workstation/osinfo.js +4 -0
  65. package/dist/workstation/protocol/agent.js +606 -0
  66. package/dist/workstation/protocol/common.js +136 -0
  67. package/dist/workstation/protocol/computer.js +31 -0
  68. package/dist/workstation/protocol/management.js +116 -0
  69. package/dist/workstation/register.js +42 -0
  70. package/dist/workstation/service/installer.js +362 -0
  71. package/dist/workstation/service/launchd.js +46 -0
  72. package/dist/workstation/service/loginShell.js +36 -0
  73. package/dist/workstation/service/systemd.js +48 -0
  74. package/dist/workstation/service/types.js +73 -0
  75. package/dist/workstation/supervision.js +52 -0
  76. package/dist/workstation/systemMetrics.js +271 -0
  77. package/dist/workstation/update/artifact.js +61 -0
  78. package/dist/workstation/update/coordinator.js +240 -0
  79. package/dist/workstation/update/npmInstaller.js +91 -0
  80. package/dist/workstation/update/redact.js +36 -0
  81. package/dist/workstation/update/runUpdate.js +215 -0
  82. package/dist/workstation/update/stateStore.js +87 -0
  83. package/dist/workstation/update/taskRegistry.js +142 -0
  84. package/dist/workstation/update/types.js +3 -0
  85. package/dist/workstation/update/watchdog.js +311 -0
  86. package/dist/workstation/version.js +12 -0
  87. package/package.json +39 -0
  88. package/skills/axom/SKILL.md +83 -0
  89. package/skills/axom-definition/SKILL.md +74 -0
  90. package/skills/axom-platform/SKILL.md +78 -0
@@ -0,0 +1,96 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { connectEncryptedChannel } from '../connection/encryptedChannel.js';
3
+ import { AGENT_WIRE_MIN_VERSION, AGENT_WIRE_RESUME_OFFER_VERSION, AGENT_WIRE_VERSION, decodeAgentFrame, encodeAgentFrame, outboundAgentFrameRejection, } from '../protocol/agent.js';
4
+ import { bridgesForWire } from '../protocol/common.js';
5
+ import { workstationChannelUrl } from './url.js';
6
+ /** Binds daemon-scoped sessions only to the encrypted agent channel's sender. */
7
+ export class AgentChannel {
8
+ options;
9
+ constructor(options) {
10
+ this.options = options;
11
+ }
12
+ async connect(ticket, signal) {
13
+ // Validate transport before ticket KDF work: an insecure endpoint must not even receive access
14
+ // to material that could become the channel encryption key.
15
+ const url = workstationChannelUrl(this.options.server, 'agent', this.options.allowInsecureWs);
16
+ const key = (this.options.deriveTicketKey ?? ((value) => createHash('sha256').update(value).digest()))(ticket);
17
+ const channel = await connectEncryptedChannel({
18
+ url, sessionKey: key,
19
+ encode: encodeAgentFrame, decode: decodeAgentFrame,
20
+ heartbeat: (seq, sentAtEpochMs) => agentHeartbeat(seq, sentAtEpochMs, this.options.agents),
21
+ isHeartbeatAck: (frame, seq) => frame.t === 'heartbeat_ack' && frame.seq === seq,
22
+ signal,
23
+ handshake: async (wire) => { wire.sendText(encodeAgentFrame(buildAgentHelloFrame(ticket, this.options.bridges))); return key; },
24
+ });
25
+ // Hoisted rather than inlined twice: `closeAll` compares it by identity to decide whether the
26
+ // binding it is being asked to drop is still this connection's. See AgentSessionManager.closeAll.
27
+ const send = (frame) => channel.send(frame);
28
+ void dispatchAgentChannelFrames(channel.messages, send, this.options.agents, () => channel.close());
29
+ void channel.closed.then(() => this.options.agents.closeAll(send));
30
+ return { closed: channel.closed, close: () => channel.close() };
31
+ }
32
+ }
33
+ /**
34
+ * One heartbeat, carrying this daemon's session inventory when there is one and the server can read
35
+ * it.
36
+ *
37
+ * **The inventory is dropped rather than sent unvalidated.** Every other outbound agent frame goes
38
+ * through `AgentSessionManager.emit`, which round-trips it through this side's own decoder before it
39
+ * reaches the socket; the heartbeat does not — it is produced inside the encrypted channel's
40
+ * liveness timer and sent directly. A malformed inventory would therefore reach the server as an
41
+ * undecodable frame, and the server's recovery classifies a frame with no top-level `sessionRef` as
42
+ * unattributable and counts it: eight in a row and it closes the channel. Trading liveness for an
43
+ * optional diagnostic would be a bad bargain, so the check runs here and a rejected inventory
44
+ * degrades to the bare heartbeat this client has always sent — loudly, because a rejection means a
45
+ * seq allocator produced something that is not a whole number, and that is worth finding.
46
+ */
47
+ export function agentHeartbeat(seq, sentAtEpochMs, agents) {
48
+ const bare = { t: 'heartbeat', seq, sentAtEpochMs };
49
+ const sessions = agents.heartbeatInventory();
50
+ if (sessions === undefined)
51
+ return bare;
52
+ const withInventory = { t: 'heartbeat', seq, sentAtEpochMs, sessions };
53
+ const rejection = outboundAgentFrameRejection(withInventory);
54
+ if (!rejection)
55
+ return withInventory;
56
+ console.error(`[agent] heartbeat inventory dropped, sending liveness only: ${rejection}`);
57
+ return bare;
58
+ }
59
+ /** Exported so the roster this hello carries can be asserted without opening a socket. */
60
+ export function buildAgentHelloFrame(ticket, bridges) {
61
+ return { t: 'agent_hello', ticket, bridges: bridgesForWire(bridges(), 'agent_hello') };
62
+ }
63
+ /** Negotiates one agent connection and emits exactly one reconnect offer when the server supports it. */
64
+ export async function dispatchAgentChannelFrames(messages, send, agents, close = () => { }) {
65
+ let offered = false;
66
+ try {
67
+ for await (const frame of messages) {
68
+ if (frame.t === 'agent_ready') {
69
+ if (frame.agentWire < AGENT_WIRE_MIN_VERSION) {
70
+ close();
71
+ return;
72
+ }
73
+ const negotiated = Math.min(frame.agentWire, AGENT_WIRE_VERSION);
74
+ agents.bindSend(send, negotiated);
75
+ if (!offered && negotiated >= AGENT_WIRE_RESUME_OFFER_VERSION) {
76
+ offered = true;
77
+ send({ t: 'agent_resume_offer', sessions: await agents.resumeOfferSessions() });
78
+ }
79
+ continue;
80
+ }
81
+ await dispatchAgentFrame(agents, frame);
82
+ }
83
+ }
84
+ catch (error) {
85
+ console.error(`[agent] channel dispatch failed: ${error instanceof Error ? error.message : String(error)}`);
86
+ close();
87
+ }
88
+ }
89
+ export async function dispatchAgentFrame(agents, frame) {
90
+ try {
91
+ await agents.handleFrame(frame);
92
+ }
93
+ catch (error) {
94
+ console.error(`[agent] handling ${frame.t} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
95
+ }
96
+ }
@@ -0,0 +1,91 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { connectEncryptedChannel } from '../connection/encryptedChannel.js';
3
+ import { runExec } from '../exec.js';
4
+ import { decodeComputerFrame, encodeComputerFrame } from '../protocol/computer.js';
5
+ import { workstationChannelUrl } from './url.js';
6
+ import { TaskRegistry } from '../update/taskRegistry.js';
7
+ /**
8
+ * Consumes computer frames without serializing long-running commands behind one another.
9
+ * All command failures and post-close sends are contained here because this is called detached
10
+ * from the socket connection loop.
11
+ */
12
+ export async function dispatchComputerFrames(messages, send, execute, tasks = new TaskRegistry()) {
13
+ const active = new Map();
14
+ const safeSend = (frame) => { try {
15
+ send(frame);
16
+ }
17
+ catch { /* socket already closed */ } };
18
+ const run = (request) => {
19
+ if (!tasks.accepting) {
20
+ safeSend({ t: 'exec_exit', id: request.id, exitCode: null, stdout: '', stderr: 'Workstation is not accepting new work', timedOut: false, truncated: false });
21
+ return;
22
+ }
23
+ const controller = new AbortController();
24
+ // A duplicate id cannot own two processes: replacing it first ensures cancel is correlated to
25
+ // the most recently accepted request while the older promise still cleans up its own entry.
26
+ active.get(request.id)?.abort();
27
+ active.set(request.id, controller);
28
+ void (async () => {
29
+ try {
30
+ const exit = await tasks.withShellTask(async (shutdown) => {
31
+ const onShutdown = () => controller.abort();
32
+ if (shutdown.aborted)
33
+ controller.abort();
34
+ else
35
+ shutdown.addEventListener('abort', onShutdown, { once: true });
36
+ try {
37
+ return await execute(request, request.streaming ? (stream, chunk) => safeSend({ t: 'exec_output', id: request.id, stream, chunk }) : undefined, controller.signal);
38
+ }
39
+ finally {
40
+ shutdown.removeEventListener('abort', onShutdown);
41
+ }
42
+ });
43
+ safeSend(exit);
44
+ }
45
+ catch (error) {
46
+ safeSend({ t: 'exec_exit', id: request.id, exitCode: null, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false, truncated: false });
47
+ }
48
+ finally {
49
+ if (active.get(request.id) === controller)
50
+ active.delete(request.id);
51
+ }
52
+ })();
53
+ };
54
+ try {
55
+ for await (const frame of messages) {
56
+ if (frame.t === 'exec_request')
57
+ run(frame);
58
+ else if (frame.t === 'exec_cancel')
59
+ active.get(frame.id)?.abort();
60
+ }
61
+ }
62
+ finally {
63
+ for (const controller of active.values())
64
+ controller.abort();
65
+ }
66
+ }
67
+ /** Owns only computer-use dispatch; it never sees management or agent frames. */
68
+ export class ComputerChannel {
69
+ options;
70
+ tasks;
71
+ constructor(options) {
72
+ this.options = options;
73
+ this.tasks = options.taskRegistry ?? new TaskRegistry();
74
+ }
75
+ activeTaskCount() { return this.tasks.snapshot().activeShellTasks; }
76
+ async connect(ticket, signal) {
77
+ const channel = await connectEncryptedChannel({
78
+ url: workstationChannelUrl(this.options.server, 'computer', this.options.allowInsecureWs), sessionKey: createHash('sha256').update(ticket).digest(),
79
+ encode: encodeComputerFrame, decode: decodeComputerFrame,
80
+ heartbeat: (seq, sentAtEpochMs) => ({ t: 'heartbeat', seq, sentAtEpochMs }),
81
+ isHeartbeatAck: (frame, seq) => frame.t === 'heartbeat_ack' && frame.seq === seq,
82
+ signal,
83
+ handshake: async (wire) => { wire.sendText(encodeComputerFrame({ t: 'computer_hello', ticket })); return createHash('sha256').update(ticket).digest(); },
84
+ });
85
+ void this.dispatch(channel);
86
+ return { closed: channel.closed, close: () => channel.close() };
87
+ }
88
+ async dispatch(channel) {
89
+ await dispatchComputerFrames(channel.messages, (frame) => channel.send(frame), this.options.runExec ?? runExec, this.tasks);
90
+ }
91
+ }
@@ -0,0 +1,231 @@
1
+ import { deriveSessionKey, randomBytes } from '../crypto.js';
2
+ import { connectEncryptedChannel } from '../connection/encryptedChannel.js';
3
+ import { CLIENT_PACKAGE_NAME, CLIENT_VERSION } from '../version.js';
4
+ import { decodeManagementFrame, encodeManagementFrame, MANAGEMENT_WIRE_DRAINING_VERSION, MANAGEMENT_WIRE_LOAD_AVERAGES_VERSION, MANAGEMENT_WIRE_METRICS_VERSION, MANAGEMENT_WIRE_MIN_VERSION, MANAGEMENT_WIRE_PRESSURE_VERSION, MANAGEMENT_WIRE_VERSION, } from '../protocol/management.js';
5
+ import { bridgesForWire } from '../protocol/common.js';
6
+ import { AGENT_WIRE_MIN_VERSION, AGENT_WIRE_VERSION } from '../protocol/agent.js';
7
+ import { osInfo } from '../osinfo.js';
8
+ import { workstationChannelUrl } from './url.js';
9
+ const protocols = {
10
+ MANAGEMENT: { min: MANAGEMENT_WIRE_MIN_VERSION, max: MANAGEMENT_WIRE_VERSION },
11
+ COMPUTER: { min: 1, max: 1 }, AGENT: { min: AGENT_WIRE_MIN_VERSION, max: AGENT_WIRE_VERSION },
12
+ };
13
+ /** Exported so tests can assert the wire frame's `clientPackage` without opening a socket. */
14
+ export function buildHelloFrame(options, clientNonce) {
15
+ return {
16
+ t: 'management_hello', workstationId: options.workstationId, workstation: options.workstation,
17
+ clientNonce, clientVersion: CLIENT_VERSION, protocols, bridges: helloBridges(options.bridges),
18
+ clientPackage: CLIENT_PACKAGE_NAME,
19
+ };
20
+ }
21
+ /**
22
+ * The roster this hello may carry. Nothing an agent advertises is allowed to cost the handshake —
23
+ * see {@link bridgesForWire} for why that is not a theoretical concern.
24
+ */
25
+ function helloBridges(bridges) {
26
+ const roster = bridges?.();
27
+ return roster === undefined ? undefined : bridgesForWire(roster, 'management_hello');
28
+ }
29
+ /** Exported so the roster this answer carries can be asserted without driving a whole channel. */
30
+ export function buildScanResultFrame(commandId, bridges) {
31
+ return {
32
+ t: 'external_agents_scan_result',
33
+ commandId,
34
+ bridges: bridgesForWire(bridges, 'external_agents_scan_result'),
35
+ };
36
+ }
37
+ /**
38
+ * A newly handled command inherits its Server-generated id. An already-running command keeps the
39
+ * id persisted from its original Server frame, so a Server restart cannot misattribute progress.
40
+ */
41
+ export function toClientUpdateStatusFrame(command, status, serverManagementWire) {
42
+ const compatible = statusForManagementWire(status, serverManagementWire);
43
+ return { t: 'client_update_status', status: { ...compatible, commandId: compatible.commandId ?? command.commandId } };
44
+ }
45
+ /**
46
+ * Keeps the new persisted/current-client `DRAINING` state off a peer until its negotiated
47
+ * management wire proves it understands that enum value. Before `management_ready` there is no
48
+ * negotiated version yet, so the proof heartbeat uses the same legacy-safe `REQUESTED` view.
49
+ */
50
+ export function statusForManagementWire(status, serverManagementWire) {
51
+ if (status.phase === 'DRAINING' && (serverManagementWire ?? 0) < MANAGEMENT_WIRE_DRAINING_VERSION) {
52
+ return { ...status, phase: 'REQUESTED' };
53
+ }
54
+ return status;
55
+ }
56
+ export function metricsForManagementWire(metrics, serverManagementWire) {
57
+ if (!metrics)
58
+ return metrics;
59
+ const selectedWire = serverManagementWire ?? 0;
60
+ if (selectedWire >= MANAGEMENT_WIRE_LOAD_AVERAGES_VERSION) {
61
+ const { cpuLoadNormalized: _cpuLoadNormalized, ...rawLoad } = metrics;
62
+ return rawLoad;
63
+ }
64
+ const { cpuLoad1, cpuLoad5: _cpuLoad5, cpuLoad15: _cpuLoad15, cpuLogicalCount, cpuLoadNormalized: _cpuLoadNormalized, ...withoutLoad } = metrics;
65
+ if (selectedWire >= MANAGEMENT_WIRE_PRESSURE_VERSION) {
66
+ return {
67
+ ...withoutLoad,
68
+ cpuLoadNormalized: cpuLoad1 != null && cpuLogicalCount != null && cpuLogicalCount > 0
69
+ ? Math.max(0, Math.min(1, cpuLoad1 / cpuLogicalCount))
70
+ : null,
71
+ };
72
+ }
73
+ const { memoryPressure: _memoryPressure, ...legacy } = withoutLoad;
74
+ return legacy;
75
+ }
76
+ /**
77
+ * Runs one server update command and reports its final status back over [channel] — but only as a
78
+ * fast path, and only if the channel is still open to carry it.
79
+ *
80
+ * Deliberately detached from the frame loop that calls it: an update runs for minutes and on macOS
81
+ * ends by booting the daemon's own process out of launchd, so handling it inline stalled every
82
+ * ticket/scan frame behind it — and by the time the final status was ready the channel had usually
83
+ * closed, which turned the report into an unhandled "Encrypted channel is not open" rejection. The
84
+ * heartbeat's persisted update state is the authoritative report; nothing is lost when this send
85
+ * is skipped or fails.
86
+ *
87
+ * Exported for tests; production's only caller is `routeMessages`.
88
+ */
89
+ export function dispatchUpdateCommand(channel, command, onUpdate, serverManagementWire) {
90
+ return onUpdate(command)
91
+ .then((status) => {
92
+ if (channel.state !== 'OPEN')
93
+ return;
94
+ try {
95
+ channel.send(toClientUpdateStatusFrame(command, status, serverManagementWire));
96
+ }
97
+ catch { /* the next heartbeat carries it */ }
98
+ })
99
+ .catch(() => { });
100
+ }
101
+ /**
102
+ * The server nonce to derive this session's key from.
103
+ *
104
+ * Hand-rolled rather than run through {@link decodeManagementFrame}: neither frame at this point
105
+ * has been encrypted yet, and neither is a member of the set that decoder guards.
106
+ */
107
+ export function serverNonceFromChallenge(text) {
108
+ const decoded = JSON.parse(text);
109
+ if (!decoded || typeof decoded !== 'object')
110
+ throw new Error('Unexpected management nonce challenge');
111
+ const frame = decoded;
112
+ // The server names a fault in the hello this client just sent, then closes. Carrying its words
113
+ // through means the daemon log says what is wrong instead of "closed before opening", which is
114
+ // all a silent close could ever produce and is indistinguishable from an unreachable server.
115
+ if (frame.t === 'management_rejected' && typeof frame.reason === 'string') {
116
+ throw new Error(`Management server refused this workstation: ${frame.reason}`);
117
+ }
118
+ if (frame.t !== 'welcome' || typeof frame.serverNonce !== 'string') {
119
+ throw new Error('Unexpected management nonce challenge');
120
+ }
121
+ return Buffer.from(frame.serverNonce, 'base64');
122
+ }
123
+ /** Authenticates the management channel and serializes its ticket requests. */
124
+ export class ManagementChannel {
125
+ options;
126
+ /** Undefined until this connection receives management_ready; reset for every reconnect. */
127
+ serverManagementWire;
128
+ constructor(options) {
129
+ this.options = options;
130
+ }
131
+ async connect(signal) {
132
+ this.serverManagementWire = undefined;
133
+ const nonce = randomBytes(16);
134
+ let initialHeartbeat = true;
135
+ const channel = await connectEncryptedChannel({
136
+ url: workstationChannelUrl(this.options.server, 'management', this.options.allowInsecureWs),
137
+ signal,
138
+ encode: encodeManagementFrame,
139
+ decode: decodeManagementFrame,
140
+ heartbeat: (seq, sentAtEpochMs) => this.heartbeat(seq, sentAtEpochMs),
141
+ initialFrame: () => {
142
+ initialHeartbeat = false;
143
+ return this.heartbeat(0, Date.now());
144
+ },
145
+ isHeartbeatAck: (frame, seq) => frame.t === 'heartbeat_ack' && frame.seq === seq,
146
+ handshake: async (wire) => {
147
+ wire.sendText(encodeManagementFrame(buildHelloFrame(this.options, nonce.toString('base64'))));
148
+ const welcome = await wire.receive();
149
+ if (welcome.isBinary)
150
+ throw new Error('Management server did not send a nonce challenge');
151
+ return deriveSessionKey(this.options.clientKey, this.options.serverPub, nonce, serverNonceFromChallenge(welcome.data.toString()));
152
+ },
153
+ });
154
+ // `initialFrame` exists to satisfy the server's proof-of-key requirement before Ready. Kept as
155
+ // a runtime guard too, so a future encrypted-channel refactor cannot silently remove it.
156
+ if (initialHeartbeat)
157
+ throw new Error('Management proof heartbeat was not sent');
158
+ const ready = await this.nextReady(channel);
159
+ this.serverManagementWire = ready.protocols.MANAGEMENT;
160
+ // A readiness record is evidence from this encrypted, proof-of-key management generation — it
161
+ // is never driven by the local browser/device-authorization flow.
162
+ await this.options.onReady?.({ generation: ready.generation, release: ready.release });
163
+ const pending = new Map();
164
+ void this.routeMessages(channel, pending);
165
+ return {
166
+ generation: ready.generation,
167
+ closed: channel.closed,
168
+ close: () => channel.close(),
169
+ ticket: (kind) => new Promise((resolve, reject) => {
170
+ const queue = pending.get(kind) ?? [];
171
+ queue.push({ resolve, reject });
172
+ pending.set(kind, queue);
173
+ try {
174
+ channel.send({ t: 'channel_ticket_request', channel: kind });
175
+ }
176
+ catch (error) {
177
+ queue.pop();
178
+ reject(error instanceof Error ? error : new Error(String(error)));
179
+ }
180
+ }),
181
+ };
182
+ }
183
+ heartbeat(seq, sentAtEpochMs) {
184
+ const snapshot = this.options.snapshot();
185
+ return {
186
+ t: 'heartbeat', seq, sentAtEpochMs, clientVersion: CLIENT_VERSION,
187
+ channels: {
188
+ MANAGEMENT: snapshot.channels.management,
189
+ COMPUTER: snapshot.channels.computer,
190
+ AGENT: snapshot.channels.agent,
191
+ },
192
+ activeShellTasks: snapshot.activeShellTasks, activeAgentTasks: snapshot.activeAgentTasks,
193
+ protocols, os: osInfo(),
194
+ ...(this.options.metrics && (this.serverManagementWire ?? 0) >= MANAGEMENT_WIRE_METRICS_VERSION
195
+ ? { metrics: metricsForManagementWire(this.options.metrics(), this.serverManagementWire) }
196
+ : {}),
197
+ ...(snapshot.update ? { update: statusForManagementWire(snapshot.update, this.serverManagementWire) } : {}),
198
+ };
199
+ }
200
+ async nextReady(channel) {
201
+ for await (const frame of channel.messages)
202
+ if (frame.t === 'management_ready')
203
+ return frame;
204
+ throw new Error('Management channel closed before ready');
205
+ }
206
+ async routeMessages(channel, pending) {
207
+ try {
208
+ for await (const frame of channel.messages) {
209
+ if (frame.t === 'channel_ticket') {
210
+ const queue = pending.get(frame.channel);
211
+ queue?.shift()?.resolve(frame.ticket);
212
+ continue;
213
+ }
214
+ if (frame.t === 'client_update' && this.options.onUpdate) {
215
+ // Deliberately not awaited — see dispatchUpdateCommand for why inline handling both
216
+ // stalled this loop for the whole update and threw the final status away.
217
+ void dispatchUpdateCommand(channel, frame, this.options.onUpdate, this.serverManagementWire);
218
+ continue;
219
+ }
220
+ if (frame.t === 'external_agents_scan' && this.options.onExternalAgentsScan) {
221
+ channel.send(buildScanResultFrame(frame.commandId, await this.options.onExternalAgentsScan()));
222
+ }
223
+ }
224
+ }
225
+ finally {
226
+ for (const queue of pending.values())
227
+ for (const request of queue)
228
+ request.reject(new Error('Management channel closed before ticket response'));
229
+ }
230
+ }
231
+ }
@@ -0,0 +1,42 @@
1
+ const TRUSTED_PLAINTEXT_NAMES = new Set([
2
+ 'localhost',
3
+ 'host.docker.internal',
4
+ 'gateway.docker.internal',
5
+ ]);
6
+ function ipv4Octets(hostname) {
7
+ const parts = hostname.split('.');
8
+ if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part)))
9
+ return null;
10
+ const octets = parts.map(Number);
11
+ return octets.every((octet) => octet <= 255) ? octets : null;
12
+ }
13
+ /** Deliberately classifies literal names/IPs only: DNS answers must never downgrade transport security. */
14
+ function isTrustedPlaintextHost(rawHostname) {
15
+ const hostname = rawHostname.toLowerCase().replace(/^\[|\]$/g, '');
16
+ if (TRUSTED_PLAINTEXT_NAMES.has(hostname) || hostname.endsWith('.localhost'))
17
+ return true;
18
+ if (hostname === '::1' || hostname.startsWith('fe80:'))
19
+ return true;
20
+ const octets = ipv4Octets(hostname);
21
+ if (octets === null)
22
+ return false;
23
+ const [first, second] = octets;
24
+ return first === 10
25
+ || first === 127
26
+ || first === 169 && second === 254
27
+ || first === 192 && second === 168
28
+ || first === 172 && second >= 16 && second <= 31;
29
+ }
30
+ /** Builds a workstation channel URL, requiring TLS except for explicitly trusted private-network endpoints. */
31
+ export function workstationChannelUrl(server, channel, allowInsecureWs = false) {
32
+ const url = new URL(server);
33
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
34
+ throw new Error('Workstation server URL must use HTTP or HTTPS');
35
+ }
36
+ if (url.protocol === 'http:' && !allowInsecureWs && !isTrustedPlaintextHost(url.hostname)) {
37
+ throw new Error('Insecure workstation WebSocket transport is disabled; use HTTPS/WSS');
38
+ }
39
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
40
+ url.pathname = `${url.pathname.replace(/\/$/, '')}/workstation/ws/${channel}`;
41
+ return url;
42
+ }
@@ -0,0 +1,47 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import yaml from 'js-yaml';
6
+ export function updateServiceContext(configDir = clientDir(), overrides = {}) {
7
+ // config.ts lives in dist/workstation/ after packaging, so two levels up is the installed package root.
8
+ const packageRoot = overrides.packageRoot ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
9
+ const nodePath = overrides.nodePath ?? process.execPath;
10
+ return {
11
+ configDir,
12
+ platform: overrides.platform ?? (process.platform === 'darwin' ? 'darwin' : 'linux'),
13
+ nodePath,
14
+ npmPath: overrides.npmPath ?? process.env.AXOM_WORKSTATION_NPM_PATH ?? path.join(path.dirname(nodePath), 'npm'),
15
+ // packageRoot is under lib/node_modules/@platless; climb to npm's global prefix.
16
+ npmPrefix: overrides.npmPrefix ?? process.env.AXOM_WORKSTATION_NPM_PREFIX ?? path.resolve(packageRoot, '../../../..'),
17
+ packageRoot,
18
+ };
19
+ }
20
+ /** Global per-machine client directory: ~/.axom/workstation */
21
+ export function clientDir() {
22
+ // User services render the current user's config directory explicitly. Respect it so a
23
+ // service manager with a different HOME never creates a second workstation identity.
24
+ return process.env.AXOM_WORKSTATION_CONFIG_DIR ?? path.join(os.homedir(), '.axom', 'workstation');
25
+ }
26
+ export async function saveClientConfig(dir, cfg) {
27
+ await fs.mkdir(dir, { recursive: true });
28
+ await fs.writeFile(path.join(dir, 'config.yml'), yaml.dump(cfg), { mode: 0o600 });
29
+ }
30
+ export async function loadClientConfig(dir) {
31
+ const raw = await fs.readFile(path.join(dir, 'config.yml'), 'utf8');
32
+ const parsed = yaml.load(raw);
33
+ if (!parsed?.server || !parsed.workstationId) {
34
+ throw new Error('Invalid workstation config: server and workstationId are required; run axom auth again');
35
+ }
36
+ return { server: parsed.server, workstationId: parsed.workstationId, ...(Array.isArray(parsed.workspaces) ? { workspaces: parsed.workspaces } : {}) };
37
+ }
38
+ export async function writeKeys(dir, clientKeyB64, serverPubB64) {
39
+ await fs.mkdir(dir, { recursive: true });
40
+ await fs.writeFile(path.join(dir, 'client.key'), clientKeyB64, { mode: 0o600 });
41
+ await fs.writeFile(path.join(dir, 'server.pub'), serverPubB64);
42
+ }
43
+ export async function readKeys(dir) {
44
+ const clientKey = Buffer.from((await fs.readFile(path.join(dir, 'client.key'), 'utf8')).trim(), 'base64');
45
+ const serverPub = Buffer.from((await fs.readFile(path.join(dir, 'server.pub'), 'utf8')).trim(), 'base64');
46
+ return { clientKey, serverPub };
47
+ }