@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.
- package/README.md +64 -0
- package/dist/args.js +29 -0
- package/dist/cli.js +76 -0
- package/dist/commands/auth.js +52 -0
- package/dist/commands/execute.js +40 -0
- package/dist/commands/guide.js +38 -0
- package/dist/commands/record.js +99 -0
- package/dist/commands/skills.js +69 -0
- package/dist/commands/struct.js +50 -0
- package/dist/commands/update.js +88 -0
- package/dist/commands/whoami.js +8 -0
- package/dist/commands/workstation.js +171 -0
- package/dist/config.js +131 -0
- package/dist/credentialBroker.js +340 -0
- package/dist/credentials.js +211 -0
- package/dist/install/agentsDir.js +35 -0
- package/dist/install/claude/commands.js +89 -0
- package/dist/install/claude/materialize.js +42 -0
- package/dist/install/installer.js +45 -0
- package/dist/install/targets/claude.js +154 -0
- package/dist/mcp.js +152 -0
- package/dist/output.js +37 -0
- package/dist/workstation/acp/acpAgents.js +1287 -0
- package/dist/workstation/acp/acpClient.js +691 -0
- package/dist/workstation/acp/acpSession.js +1047 -0
- package/dist/workstation/acp/adoption.js +396 -0
- package/dist/workstation/acp/auth.js +80 -0
- package/dist/workstation/acp/authUrlSniffer.js +52 -0
- package/dist/workstation/acp/binaryInstall.js +411 -0
- package/dist/workstation/acp/configOptions.js +257 -0
- package/dist/workstation/acp/contract.js +494 -0
- package/dist/workstation/acp/draftInterceptor.js +154 -0
- package/dist/workstation/acp/draftSubagents.js +38 -0
- package/dist/workstation/acp/installErrors.js +89 -0
- package/dist/workstation/acp/installProbe.js +363 -0
- package/dist/workstation/acp/permission.js +327 -0
- package/dist/workstation/acp/registry/catalog.js +29 -0
- package/dist/workstation/acp/registry/launch.js +128 -0
- package/dist/workstation/acp/registry/registryClient.js +160 -0
- package/dist/workstation/acp/registry/registryTypes.js +8 -0
- package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
- package/dist/workstation/acp/test/fakeAgent.js +435 -0
- package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
- package/dist/workstation/acp/timedCommand.js +47 -0
- package/dist/workstation/acp/uvxInstall.js +248 -0
- package/dist/workstation/acp/validate.js +134 -0
- package/dist/workstation/agentSessions.js +1628 -0
- package/dist/workstation/channels/agent.js +96 -0
- package/dist/workstation/channels/computer.js +91 -0
- package/dist/workstation/channels/management.js +231 -0
- package/dist/workstation/channels/url.js +42 -0
- package/dist/workstation/config.js +47 -0
- package/dist/workstation/connection/encryptedChannel.js +261 -0
- package/dist/workstation/connection/retryPolicy.js +14 -0
- package/dist/workstation/connection/supervisor.js +353 -0
- package/dist/workstation/crypto.js +63 -0
- package/dist/workstation/daemon.js +225 -0
- package/dist/workstation/daemonctl.js +95 -0
- package/dist/workstation/exec.js +112 -0
- package/dist/workstation/journal.js +257 -0
- package/dist/workstation/names.js +17 -0
- package/dist/workstation/oauth/browser.js +24 -0
- package/dist/workstation/oauth/deviceAuthorization.js +198 -0
- package/dist/workstation/osinfo.js +4 -0
- package/dist/workstation/protocol/agent.js +606 -0
- package/dist/workstation/protocol/common.js +136 -0
- package/dist/workstation/protocol/computer.js +31 -0
- package/dist/workstation/protocol/management.js +116 -0
- package/dist/workstation/register.js +42 -0
- package/dist/workstation/service/installer.js +362 -0
- package/dist/workstation/service/launchd.js +46 -0
- package/dist/workstation/service/loginShell.js +36 -0
- package/dist/workstation/service/systemd.js +48 -0
- package/dist/workstation/service/types.js +73 -0
- package/dist/workstation/supervision.js +52 -0
- package/dist/workstation/systemMetrics.js +271 -0
- package/dist/workstation/update/artifact.js +61 -0
- package/dist/workstation/update/coordinator.js +240 -0
- package/dist/workstation/update/npmInstaller.js +91 -0
- package/dist/workstation/update/redact.js +36 -0
- package/dist/workstation/update/runUpdate.js +215 -0
- package/dist/workstation/update/stateStore.js +87 -0
- package/dist/workstation/update/taskRegistry.js +142 -0
- package/dist/workstation/update/types.js +3 -0
- package/dist/workstation/update/watchdog.js +311 -0
- package/dist/workstation/version.js +12 -0
- package/package.json +39 -0
- package/skills/axom/SKILL.md +83 -0
- package/skills/axom-definition/SKILL.md +74 -0
- package/skills/axom-platform/SKILL.md +78 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHmac, createCipheriv, createDecipheriv, generateKeyPairSync, createPublicKey, createPrivateKey, diffieHellman, randomBytes as nodeRandomBytes, } from 'node:crypto';
|
|
2
|
+
const INFO = Buffer.from('axom-workstation-tunnel-v1');
|
|
3
|
+
// DER prefixes for raw X25519 keys (RFC 8410).
|
|
4
|
+
const PUB_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
|
|
5
|
+
const PRIV_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex');
|
|
6
|
+
export function randomBytes(n) {
|
|
7
|
+
return nodeRandomBytes(n);
|
|
8
|
+
}
|
|
9
|
+
export function generateKeyPair() {
|
|
10
|
+
const { publicKey, privateKey } = generateKeyPairSync('x25519');
|
|
11
|
+
const pubDer = publicKey.export({ type: 'spki', format: 'der' });
|
|
12
|
+
const privDer = privateKey.export({ type: 'pkcs8', format: 'der' });
|
|
13
|
+
return {
|
|
14
|
+
publicKey: pubDer.subarray(pubDer.length - 32),
|
|
15
|
+
privateKey: privDer.subarray(privDer.length - 32),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function rawToPublicKey(raw) {
|
|
19
|
+
return createPublicKey({ key: Buffer.concat([PUB_PREFIX, raw]), format: 'der', type: 'spki' });
|
|
20
|
+
}
|
|
21
|
+
function rawToPrivateKey(raw) {
|
|
22
|
+
return createPrivateKey({ key: Buffer.concat([PRIV_PREFIX, raw]), format: 'der', type: 'pkcs8' });
|
|
23
|
+
}
|
|
24
|
+
function hkdfSha256(ikm, salt, info, length) {
|
|
25
|
+
const prk = createHmac('sha256', salt).update(ikm).digest();
|
|
26
|
+
let t = Buffer.alloc(0);
|
|
27
|
+
const chunks = [];
|
|
28
|
+
let generated = 0;
|
|
29
|
+
let counter = 1;
|
|
30
|
+
while (generated < length) {
|
|
31
|
+
const h = createHmac('sha256', prk);
|
|
32
|
+
h.update(t);
|
|
33
|
+
h.update(info);
|
|
34
|
+
h.update(Buffer.from([counter]));
|
|
35
|
+
t = h.digest();
|
|
36
|
+
chunks.push(t);
|
|
37
|
+
generated += t.length;
|
|
38
|
+
counter++;
|
|
39
|
+
}
|
|
40
|
+
return Buffer.concat(chunks).subarray(0, length);
|
|
41
|
+
}
|
|
42
|
+
export function deriveSessionKey(privSelf, pubPeer, clientNonce, serverNonce) {
|
|
43
|
+
const shared = diffieHellman({
|
|
44
|
+
privateKey: rawToPrivateKey(privSelf),
|
|
45
|
+
publicKey: rawToPublicKey(pubPeer),
|
|
46
|
+
});
|
|
47
|
+
return hkdfSha256(shared, Buffer.concat([clientNonce, serverNonce]), INFO, 32);
|
|
48
|
+
}
|
|
49
|
+
export function seal(sessionKey, plaintext) {
|
|
50
|
+
const iv = randomBytes(12);
|
|
51
|
+
const cipher = createCipheriv('aes-256-gcm', sessionKey, iv);
|
|
52
|
+
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
53
|
+
const tag = cipher.getAuthTag();
|
|
54
|
+
return Buffer.concat([iv, ct, tag]);
|
|
55
|
+
}
|
|
56
|
+
export function open(sessionKey, frame) {
|
|
57
|
+
const iv = frame.subarray(0, 12);
|
|
58
|
+
const tag = frame.subarray(frame.length - 16);
|
|
59
|
+
const ct = frame.subarray(12, frame.length - 16);
|
|
60
|
+
const decipher = createDecipheriv('aes-256-gcm', sessionKey, iv);
|
|
61
|
+
decipher.setAuthTag(tag);
|
|
62
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
63
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { AGENT_WIRE_MIN_VERSION } from './protocol/agent.js';
|
|
4
|
+
import { clientDir, loadClientConfig, readKeys } from './config.js';
|
|
5
|
+
import { AgentSessionManager } from './agentSessions.js';
|
|
6
|
+
import { AcpAgents } from './acp/acpAgents.js';
|
|
7
|
+
import { ConnectionSupervisor } from './connection/supervisor.js';
|
|
8
|
+
import { ManagementChannel } from './channels/management.js';
|
|
9
|
+
import { ComputerChannel } from './channels/computer.js';
|
|
10
|
+
import { AgentChannel, dispatchAgentFrame as dispatchAgentChannelFrame } from './channels/agent.js';
|
|
11
|
+
import { TaskRegistry } from './update/taskRegistry.js';
|
|
12
|
+
import { createUpdateCoordinator } from './update/runUpdate.js';
|
|
13
|
+
import { resolveCliPath } from './daemonctl.js';
|
|
14
|
+
import { SystemMetricsSampler } from './systemMetrics.js';
|
|
15
|
+
/**
|
|
16
|
+
* The path `runDaemon` hands `createUpdateCoordinator` as `cliPath`, used to re-provision the OS
|
|
17
|
+
* service after an update (see runUpdate.ts's `provisionService`).
|
|
18
|
+
*
|
|
19
|
+
* Delegates to `resolveCliPath` (daemonctl.ts) rather than hand-rolling a second join: `daemon.ts`
|
|
20
|
+
* compiles to `dist/workstation/daemon.js`, the same depth as `dist/workstation/daemonctl.js`, so
|
|
21
|
+
* the package's real bin entry is one level *up* from either — not a sibling of `daemon.js`, which
|
|
22
|
+
* is where the old, now-removed `@platless/axom-workstation` package's `cli.js` used to live.
|
|
23
|
+
*
|
|
24
|
+
* Takes the directory explicitly, defaulting to this module's own compiled location, so a test can
|
|
25
|
+
* drive it against a directory that mirrors the compiled layout without depending on a prior build.
|
|
26
|
+
*/
|
|
27
|
+
export function daemonCliPath(daemonDir = path.dirname(fileURLToPath(import.meta.url))) {
|
|
28
|
+
return resolveCliPath(daemonDir);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Dispatch one `agent_*` frame, swallowing whatever the manager (and the agent behind it) throws.
|
|
32
|
+
*
|
|
33
|
+
* The websocket listener this runs under is an async callback: a rejection escaping it is an
|
|
34
|
+
* unhandled rejection, which by Node's default **kills the process** — taking exec, the heartbeat
|
|
35
|
+
* and every other session on this workstation with it. `prompt`/`answer`/`setConfigOption` are
|
|
36
|
+
* called synchronously on this path and end up in third-party agent output handling, so "the
|
|
37
|
+
* manager happens to catch everything today" is not a property worth resting a daemon on.
|
|
38
|
+
*
|
|
39
|
+
* Exported for the test that pins this behaviour.
|
|
40
|
+
*/
|
|
41
|
+
export async function dispatchAgentFrame(agents, frame) {
|
|
42
|
+
await dispatchAgentChannelFrame(agents, frame);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The per-connection agent-wire negotiation, lifted out of the websocket callback so it can be
|
|
46
|
+
* driven by a test.
|
|
47
|
+
*
|
|
48
|
+
* Worth its own seam because of what the one comparison inside it can do: gate on
|
|
49
|
+
* `AGENT_WIRE_VERSION` instead of {@link AGENT_WIRE_MIN_VERSION} and every workstation whose server
|
|
50
|
+
* is one version behind silently loses external agents entirely. That failure is invisible to any
|
|
51
|
+
* test whose two ends are built from the same constant — which is every same-version test there is —
|
|
52
|
+
* so the only thing that can catch it is feeding a deliberately *older* `ready` and checking that
|
|
53
|
+
* agent frames still flow.
|
|
54
|
+
*/
|
|
55
|
+
export function createAgentWireGate(agents, send) {
|
|
56
|
+
let supported = false;
|
|
57
|
+
return {
|
|
58
|
+
get supported() { return supported; },
|
|
59
|
+
onReady(serverAgentWire) {
|
|
60
|
+
// An older server cannot decode agent_* frames and drops the connection on receiving one, so
|
|
61
|
+
// below the floor the manager stays unbound and emits nothing.
|
|
62
|
+
//
|
|
63
|
+
// The floor is the *minimum*, not this build's own version: gating on an exact match would
|
|
64
|
+
// mean every wire bump switches external agents off for everyone who has not upgraded their
|
|
65
|
+
// server yet. The server's actual version is handed to the manager so the frames added after
|
|
66
|
+
// v1 can gate themselves on it.
|
|
67
|
+
const negotiated = serverAgentWire ?? 0;
|
|
68
|
+
supported = negotiated >= AGENT_WIRE_MIN_VERSION;
|
|
69
|
+
if (supported)
|
|
70
|
+
agents.bindSend(send, negotiated);
|
|
71
|
+
},
|
|
72
|
+
async handleAgentFrame(frame) {
|
|
73
|
+
if (!supported)
|
|
74
|
+
return;
|
|
75
|
+
await dispatchAgentFrame(agents, frame);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* How long an agent session may go without a single frame from the server and without producing a
|
|
81
|
+
* single event before the daemon closes it on its own.
|
|
82
|
+
*
|
|
83
|
+
* Six hours, because this timer is a garbage collector for *orphans*, not an inactivity policy. A
|
|
84
|
+
* session the server still knows about is refreshed by any traffic in either direction, so the only
|
|
85
|
+
* way to reach six hours of two-way silence is for the server to have forgotten the session (its
|
|
86
|
+
* table is in memory — a restart wipes it, and the `agent_close` for those refs is never sent) or
|
|
87
|
+
* for the workstation to have been offline when the run was archived. A human pause — thinking,
|
|
88
|
+
* lunch, a long unattended tool call, an approval card left open over a meeting — has to stay far
|
|
89
|
+
* inside the budget, because the cost of being wrong is killing a running agent and its unsaved
|
|
90
|
+
* work. Six hours is comfortably past any of those and still bounds an orphan's lifetime to well
|
|
91
|
+
* under a day, versus "until the daemon restarts" without it.
|
|
92
|
+
*/
|
|
93
|
+
export const IDLE_SESSION_TTL_MS = 6 * 60 * 60 * 1000;
|
|
94
|
+
/**
|
|
95
|
+
* How often the sweep runs. Ten minutes: the deadline is six hours, so the interval only sets how
|
|
96
|
+
* far past it an orphan may linger — 10min is ~3% slop, invisible against the TTL, while keeping
|
|
97
|
+
* the wakeups (and the map scan behind them) to 144 a day on a laptop that may be on battery.
|
|
98
|
+
*/
|
|
99
|
+
export const IDLE_SWEEP_INTERVAL_MS = 10 * 60 * 1000;
|
|
100
|
+
/**
|
|
101
|
+
* Starts the idle-session sweep and returns its stop function.
|
|
102
|
+
*
|
|
103
|
+
* Tied to the daemon's lifetime: started with it, cleared when it ends. The timer is `unref`'d so
|
|
104
|
+
* it can never be the reason the process stays alive, and the callback swallows its own failure —
|
|
105
|
+
* nobody awaits a timer callback, and an escaping rejection is an unhandled rejection, which Node
|
|
106
|
+
* answers by exiting.
|
|
107
|
+
*/
|
|
108
|
+
export function startIdleSweep(agents, ttlMs = IDLE_SESSION_TTL_MS, intervalMs = IDLE_SWEEP_INTERVAL_MS) {
|
|
109
|
+
const timer = setInterval(() => {
|
|
110
|
+
void (async () => {
|
|
111
|
+
try {
|
|
112
|
+
const swept = await agents.sweepIdle(ttlMs, Date.now());
|
|
113
|
+
if (swept.length > 0) {
|
|
114
|
+
// Worth a log line at warn: this closed agent subprocesses nothing else was going to.
|
|
115
|
+
console.warn(`[agent] closed ${swept.length} idle session(s) after ${ttlMs}ms: ${swept.join(', ')}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
120
|
+
console.error(`[agent] idle sweep failed: ${detail}`);
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
}, intervalMs);
|
|
124
|
+
timer.unref();
|
|
125
|
+
return () => clearInterval(timer);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Pure mapping from how this process was invoked to the update lifecycle it gets. Lifted out of
|
|
129
|
+
* `runDaemon` — which also loads client config, reads keys off disk and opens network connections
|
|
130
|
+
* — so a test can pin the mapping itself without booting the whole daemon. See `RunDaemonOptions`
|
|
131
|
+
* for why `supervisedByServiceManager` must be explicit rather than inferred from an absent key.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveServiceLifecycle(options) {
|
|
134
|
+
if (options.supervisedByServiceManager)
|
|
135
|
+
return 'provision-and-arm';
|
|
136
|
+
// The bare `axom workstation` command's own detected supervision only ever distinguishes
|
|
137
|
+
// 'container' (see runUpdate.ts's serviceLifecycle doc for that policy) from everything else,
|
|
138
|
+
// which all means 'none': `detectSupervision()` reports 'launchd' for any darwin process,
|
|
139
|
+
// supervised or not, so treating a merely *detected* launchd/systemd as license to
|
|
140
|
+
// provision-and-arm would hand that mode to a one-shot, unsupervised debugging invocation.
|
|
141
|
+
return options.supervision === 'container' ? 'container' : 'none';
|
|
142
|
+
}
|
|
143
|
+
export async function runDaemon(options = {}) {
|
|
144
|
+
// Last line of defence for the whole process. Node's default for an unhandled rejection is to
|
|
145
|
+
// *exit*, and this daemon is the only thing keeping every session on this workstation alive —
|
|
146
|
+
// dying because one async callback rejected is strictly worse than logging and carrying on.
|
|
147
|
+
// Registered once, on the daemon path only (the CLI's one-shot commands keep the default).
|
|
148
|
+
process.on('unhandledRejection', (reason) => {
|
|
149
|
+
const detail = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
|
|
150
|
+
console.error(`[daemon] unhandled rejection (ignored, daemon stays up): ${detail}`);
|
|
151
|
+
});
|
|
152
|
+
const dir = clientDir();
|
|
153
|
+
const cfg = await loadClientConfig(dir);
|
|
154
|
+
const { clientKey, serverPub } = await readKeys(dir);
|
|
155
|
+
const tasks = new TaskRegistry();
|
|
156
|
+
// Attaches both the server-driven update path (onUpdate below) and axom update's manual path to
|
|
157
|
+
// the same coordinator assembly — see runUpdate.ts.
|
|
158
|
+
const updates = createUpdateCoordinator({
|
|
159
|
+
dir, server: cfg.server, tasks,
|
|
160
|
+
cliPath: daemonCliPath(),
|
|
161
|
+
// launchd/systemd is configured with KeepAlive/Restart=always, and a container has its own
|
|
162
|
+
// restart policy. Exiting is therefore the atomic hand-off from the old package to the freshly
|
|
163
|
+
// installed daemon.
|
|
164
|
+
//
|
|
165
|
+
// Passed unconditionally on purpose: `createUpdateCoordinator` drops it for the one lifecycle
|
|
166
|
+
// that has nothing to hand off to (`'none'` — see `lifecyclePolicy().restartsOnHandoff`),
|
|
167
|
+
// alongside the other three policy decisions. Gating it here instead would put a fourth copy of
|
|
168
|
+
// the same string comparison at a call site, which is exactly where the first three were
|
|
169
|
+
// deliberately taken out of.
|
|
170
|
+
restart: async () => { process.exit(0); },
|
|
171
|
+
// The daemon is the service manager's own supervised process: it is the one caller that must
|
|
172
|
+
// rewrite the service definition and arm the rollback watchdog. See RunDaemonOptions and
|
|
173
|
+
// resolveServiceLifecycle above for how that treatment is keyed on *how this process was
|
|
174
|
+
// invoked*, not on what the host merely supports.
|
|
175
|
+
serviceLifecycle: resolveServiceLifecycle(options),
|
|
176
|
+
});
|
|
177
|
+
// One ACP client for every external agent; the roster it reports is the ACP agent registry, keyed
|
|
178
|
+
// by registry agent id (ruling R2) — the same key `agent_open.bridge` now carries.
|
|
179
|
+
const acp = new AcpAgents({ cacheDir: dir });
|
|
180
|
+
const agents = new AgentSessionManager(acp, null, path.join(dir, 'agents'), undefined, tasks);
|
|
181
|
+
let bridges = await acp.roster();
|
|
182
|
+
let supervisor;
|
|
183
|
+
// Plaintext WebSockets are only for an explicitly opted-in local development fixture. Production
|
|
184
|
+
// ticket handshakes must be WSS because the ticket is the data-channel key derivation material.
|
|
185
|
+
const allowInsecureWs = process.env.AXOM_WORKSTATION_ALLOW_INSECURE_WS === '1';
|
|
186
|
+
const computer = new ComputerChannel({ server: cfg.server, allowInsecureWs, taskRegistry: tasks });
|
|
187
|
+
const metrics = new SystemMetricsSampler(cfg.workspaces ?? []);
|
|
188
|
+
await metrics.start();
|
|
189
|
+
const management = new ManagementChannel({
|
|
190
|
+
server: cfg.server, workstationId: cfg.workstationId, workstation: cfg.workstationId, clientKey, serverPub,
|
|
191
|
+
bridges: () => bridges,
|
|
192
|
+
workspaces: cfg.workspaces,
|
|
193
|
+
metrics: () => metrics.snapshot(),
|
|
194
|
+
allowInsecureWs,
|
|
195
|
+
onReady: (ready) => updates.managementReady(ready.release, ready.generation),
|
|
196
|
+
onUpdate: (command) => updates.handle(command),
|
|
197
|
+
onExternalAgentsScan: async () => {
|
|
198
|
+
bridges = await acp.roster();
|
|
199
|
+
return bridges;
|
|
200
|
+
},
|
|
201
|
+
snapshot: () => ({ channels: supervisor.channelSnapshot(), ...tasks.snapshot(), update: updates.snapshot() }),
|
|
202
|
+
});
|
|
203
|
+
const agent = new AgentChannel({ server: cfg.server, agents, bridges: () => bridges, allowInsecureWs });
|
|
204
|
+
supervisor = new ConnectionSupervisor({
|
|
205
|
+
management, computer, agent,
|
|
206
|
+
// Timestamped here rather than inside the supervisor: the daemon owns this log's shape. The
|
|
207
|
+
// stamp is not decoration -- when the 2026-09-03 outage was diagnosed, the moment the tunnel
|
|
208
|
+
// died had to be recovered from the log *file's* mtime, because not one line carried a time.
|
|
209
|
+
log: (message) => console.log(`${new Date().toISOString()} ${message}`),
|
|
210
|
+
});
|
|
211
|
+
const stopIdleSweep = startIdleSweep(agents);
|
|
212
|
+
const shutdown = new AbortController();
|
|
213
|
+
const stop = () => shutdown.abort();
|
|
214
|
+
process.once('SIGINT', stop);
|
|
215
|
+
process.once('SIGTERM', stop);
|
|
216
|
+
try {
|
|
217
|
+
await supervisor.run(shutdown.signal);
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
process.removeListener('SIGINT', stop);
|
|
221
|
+
process.removeListener('SIGTERM', stop);
|
|
222
|
+
stopIdleSweep();
|
|
223
|
+
metrics.stop();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { clientDir, loadClientConfig } from './config.js';
|
|
5
|
+
import { createServiceManager } from './service/installer.js';
|
|
6
|
+
import { buildServicePath } from './service/types.js';
|
|
7
|
+
import { loginShellPath } from './service/loginShell.js';
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
/**
|
|
10
|
+
* The package's real npm bin entry (`package.json`'s `bin.axom`), given the directory a compiled
|
|
11
|
+
* `daemonctl.js` lives in.
|
|
12
|
+
*
|
|
13
|
+
* `daemonctl.js` compiles to `dist/workstation/daemonctl.js` (tsconfig: rootDir `src`, outDir
|
|
14
|
+
* `dist`), so the bin entry `dist/cli.js` is one level *up* from it — a sibling of `dist/`, not a
|
|
15
|
+
* file inside `dist/workstation/`. This value is embedded verbatim into the generated launchd
|
|
16
|
+
* plist / systemd unit as the program the OS service execs; see launchd.ts and systemd.ts.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveCliPath(daemonctlDir) {
|
|
19
|
+
return path.join(daemonctlDir, '..', 'cli.js');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The package's real npm bin entry, resolved exactly once from this module's own compiled
|
|
23
|
+
* location — never re-derived by a caller from its *own* `import.meta.url`. `resolveCliPath`'s
|
|
24
|
+
* contract ("give me the directory a compiled `daemonctl.js` lives in") is easy to satisfy with
|
|
25
|
+
* the wrong directory: a caller compiled to a different depth under `dist/` (a new command file,
|
|
26
|
+
* say) can pass its own directory and get a path that still looks plausible — same shape of bug
|
|
27
|
+
* already caught once for real, in `cliPath` (commit `1bd7f38dc`). Every caller that just wants
|
|
28
|
+
* "the CLI's own bin path" should import this constant instead of calling `resolveCliPath` itself.
|
|
29
|
+
*/
|
|
30
|
+
export const CLI_PATH = resolveCliPath(path.dirname(__filename));
|
|
31
|
+
/** Build the service context from the installed client, never from a caller-provided shell string. */
|
|
32
|
+
export function serviceContext() {
|
|
33
|
+
if (process.platform !== 'darwin' && process.platform !== 'linux') {
|
|
34
|
+
throw new Error('Axomwork Workstation user service supervision is supported only on macOS and Linux.');
|
|
35
|
+
}
|
|
36
|
+
const directory = path.dirname(__filename);
|
|
37
|
+
const nodeDirectory = path.dirname(process.execPath);
|
|
38
|
+
return {
|
|
39
|
+
platform: process.platform,
|
|
40
|
+
username: os.userInfo().username,
|
|
41
|
+
userId: process.getuid?.() ?? os.userInfo().uid,
|
|
42
|
+
homeDir: os.homedir(),
|
|
43
|
+
nodePath: process.execPath,
|
|
44
|
+
cliPath: CLI_PATH,
|
|
45
|
+
npmPath: process.env.AXOM_WORKSTATION_NPM_PATH ?? path.join(nodeDirectory, 'npm'),
|
|
46
|
+
// dist/workstation/ is nested under lib/node_modules/@platless/axom-cli; climb to npm's prefix.
|
|
47
|
+
npmPrefix: process.env.AXOM_WORKSTATION_NPM_PREFIX ?? path.resolve(directory, '../../../../../..'),
|
|
48
|
+
configDir: clientDir(),
|
|
49
|
+
pathEnv: buildServicePath(process.env.PATH, nodeDirectory),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const defaultDeps = {
|
|
53
|
+
service: (context) => createServiceManager(context ?? serviceContext()),
|
|
54
|
+
config: () => loadClientConfig(clientDir()),
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* User services own the daemon lifecycle. Deliberately no PID-file fallback remains: a stale
|
|
58
|
+
* PID can describe an unrelated process and cannot survive a reboot reliably.
|
|
59
|
+
*/
|
|
60
|
+
export function createDaemonController(overrides = {}) {
|
|
61
|
+
const deps = { ...defaultDeps, ...overrides };
|
|
62
|
+
return {
|
|
63
|
+
async install() {
|
|
64
|
+
// The definition is written from the login shell's PATH when one can be had, unioned with
|
|
65
|
+
// this process's own. In a terminal the two are the same; when the daemon re-provisions
|
|
66
|
+
// itself after an update they are not, and only the login shell knows where the user's CLIs
|
|
67
|
+
// are — see loginShellPath for why reusing the service's own PATH would make a machine
|
|
68
|
+
// reinstall its own blind spot.
|
|
69
|
+
const shellPath = await loginShellPath();
|
|
70
|
+
const base = serviceContext();
|
|
71
|
+
const context = shellPath
|
|
72
|
+
? { ...base, pathEnv: buildServicePath(`${shellPath}:${process.env.PATH ?? ''}`, path.dirname(base.nodePath)) }
|
|
73
|
+
: base;
|
|
74
|
+
await deps.service(context).install({ interactive: true });
|
|
75
|
+
},
|
|
76
|
+
async uninstall() { await deps.service().uninstall(); },
|
|
77
|
+
async start() { await deps.service().start(); },
|
|
78
|
+
async stop() { await deps.service().stop(); },
|
|
79
|
+
async restart() { await deps.service().restart(); },
|
|
80
|
+
async status() {
|
|
81
|
+
const state = await deps.service().status();
|
|
82
|
+
if (!state.running)
|
|
83
|
+
return { running: false };
|
|
84
|
+
const config = await deps.config().catch(() => null);
|
|
85
|
+
return { running: true, server: config?.server, workstationId: config?.workstationId };
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const controller = createDaemonController();
|
|
90
|
+
export const install = () => controller.install();
|
|
91
|
+
export const uninstall = () => controller.uninstall();
|
|
92
|
+
export const start = () => controller.start();
|
|
93
|
+
export const stop = () => controller.stop();
|
|
94
|
+
export const restart = () => controller.restart();
|
|
95
|
+
export const status = () => controller.status();
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const MAX_BYTES = 65536;
|
|
4
|
+
export function runExec(req, onOutput, signal) {
|
|
5
|
+
const running = startExec(req, onOutput);
|
|
6
|
+
const onAbort = () => { void running.cancel(); };
|
|
7
|
+
if (signal?.aborted)
|
|
8
|
+
onAbort();
|
|
9
|
+
else
|
|
10
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
11
|
+
void running.result.finally(() => signal?.removeEventListener('abort', onAbort));
|
|
12
|
+
return running.result;
|
|
13
|
+
}
|
|
14
|
+
/** Starts an exec in its own process group and exposes the same cancellation used by force update. */
|
|
15
|
+
export function startExec(req, onOutput) {
|
|
16
|
+
let cancel = async () => { };
|
|
17
|
+
const result = new Promise((resolve) => {
|
|
18
|
+
const env = { ...process.env, ...req.env };
|
|
19
|
+
if (req.pathPrepend.length > 0) {
|
|
20
|
+
env.PATH = [...req.pathPrepend, env.PATH ?? ''].filter(Boolean).join(path.delimiter);
|
|
21
|
+
}
|
|
22
|
+
// macOS and Linux create a new session/process group for a detached child. Every command it
|
|
23
|
+
// launches (pipelines and background jobs included) stays in that group, so cancellation does
|
|
24
|
+
// not leave descendants running after the shell parent is gone.
|
|
25
|
+
const child = spawn(req.shell, ['-lc', req.command], { cwd: req.cwd, env, detached: process.platform !== 'win32' });
|
|
26
|
+
let stdout = '';
|
|
27
|
+
let stderr = '';
|
|
28
|
+
let truncated = false;
|
|
29
|
+
let timedOut = false;
|
|
30
|
+
let cancelled = false;
|
|
31
|
+
let settled = false;
|
|
32
|
+
const capture = (buf, stream) => {
|
|
33
|
+
const text = buf.toString();
|
|
34
|
+
if (req.streaming && onOutput)
|
|
35
|
+
onOutput(stream, text);
|
|
36
|
+
if (stream === 'stdout') {
|
|
37
|
+
if (stdout.length >= MAX_BYTES) {
|
|
38
|
+
truncated = true;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (stdout.length + text.length > MAX_BYTES) {
|
|
42
|
+
stdout += text.slice(0, MAX_BYTES - stdout.length);
|
|
43
|
+
truncated = true;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
stdout += text;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
if (stderr.length >= MAX_BYTES) {
|
|
51
|
+
truncated = true;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (stderr.length + text.length > MAX_BYTES) {
|
|
55
|
+
stderr += text.slice(0, MAX_BYTES - stderr.length);
|
|
56
|
+
truncated = true;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
stderr += text;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
child.stdout.on('data', (b) => capture(b, 'stdout'));
|
|
64
|
+
child.stderr.on('data', (b) => capture(b, 'stderr'));
|
|
65
|
+
const terminateCommandGroup = () => {
|
|
66
|
+
if (child.pid && process.platform !== 'win32') {
|
|
67
|
+
try {
|
|
68
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// The group may have already exited between pid observation and signal delivery.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
child.kill('SIGKILL');
|
|
76
|
+
};
|
|
77
|
+
let timer;
|
|
78
|
+
if (req.timeoutSeconds && req.timeoutSeconds > 0) {
|
|
79
|
+
timer = setTimeout(() => { timedOut = true; terminateCommandGroup(); }, req.timeoutSeconds * 1000);
|
|
80
|
+
}
|
|
81
|
+
const abort = () => { cancelled = true; terminateCommandGroup(); };
|
|
82
|
+
cancel = async () => { abort(); };
|
|
83
|
+
child.on('close', (code) => {
|
|
84
|
+
if (settled)
|
|
85
|
+
return;
|
|
86
|
+
settled = true;
|
|
87
|
+
if (timer)
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
resolve({
|
|
90
|
+
t: 'exec_exit', id: req.id,
|
|
91
|
+
exitCode: timedOut || cancelled ? null : code,
|
|
92
|
+
stdout, stderr, timedOut, truncated,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
child.on('error', (err) => {
|
|
96
|
+
if (settled)
|
|
97
|
+
return;
|
|
98
|
+
settled = true;
|
|
99
|
+
if (timer)
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
resolve({
|
|
102
|
+
t: 'exec_exit', id: req.id,
|
|
103
|
+
exitCode: null,
|
|
104
|
+
stdout,
|
|
105
|
+
stderr: stderr + String(err?.message ?? err),
|
|
106
|
+
timedOut: false,
|
|
107
|
+
truncated,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
return { result, cancel: async () => cancel() };
|
|
112
|
+
}
|