@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,271 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync, statfsSync, statSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ const execFileAsync = promisify(execFile);
8
+ export const STORAGE_TOPOLOGY_REFRESH_MS = 10 * 60 * 1000;
9
+ function cpuTotals(cpus) {
10
+ return cpus.reduce((sum, cpu) => {
11
+ const times = Object.values(cpu.times).reduce((total, value) => total + value, 0);
12
+ return { idle: sum.idle + cpu.times.idle, total: sum.total + times };
13
+ }, { idle: 0, total: 0 });
14
+ }
15
+ function linuxAvailableMemory(readFile) {
16
+ try {
17
+ const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(readFile('/proc/meminfo', 'utf8'));
18
+ return match ? Number(match[1]) * 1024 : null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ function linuxMemoryPressure(readFile) {
25
+ try {
26
+ const line = /^some\s+(.+)$/m.exec(readFile('/proc/pressure/memory', 'utf8'))?.[1];
27
+ const avg10 = line ? /(?:^|\s)avg10=(\d+(?:\.\d+)?)(?:\s|$)/.exec(line)?.[1] : undefined;
28
+ if (avg10 === undefined)
29
+ return null;
30
+ return Math.max(0, Math.min(1, Number(avg10) / 100));
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ function stablePoolId(key) {
37
+ return createHash('sha256').update(key).digest('hex').slice(0, 16);
38
+ }
39
+ async function defaultIdentifyStorage(platform, cwd, device) {
40
+ try {
41
+ if (platform === 'darwin') {
42
+ const mounted = await execFileAsync('/bin/df', ['-P', cwd], {
43
+ timeout: 1_000,
44
+ env: { ...process.env, LC_ALL: 'C' },
45
+ });
46
+ const mountFields = mounted.stdout.trim().split('\n').at(-1)?.trim().split(/\s+/) ?? [];
47
+ const mountPoint = mountFields.slice(5).join(' ') || path.parse(cwd).root;
48
+ const { stdout } = await execFileAsync('/usr/sbin/diskutil', ['info', mountPoint], {
49
+ timeout: 1_000,
50
+ env: { ...process.env, LC_ALL: 'C' },
51
+ });
52
+ const container = /^\s*APFS Container Reference:\s*(\S+)\s*$/m.exec(stdout)?.[1];
53
+ const volume = /^\s*Volume Name:\s*(.+?)\s*$/m.exec(stdout)?.[1];
54
+ return container
55
+ ? { key: `apfs:${container}`, label: `APFS Container ${container}` }
56
+ : { key: `dev:${device}`, label: volume };
57
+ }
58
+ if (platform === 'linux') {
59
+ const { stdout } = await execFileAsync('findmnt', ['-n', '-P', '-o', 'FSTYPE,SOURCE,UUID,LABEL,TARGET', '--target', cwd], {
60
+ timeout: 2_000,
61
+ env: { ...process.env, LC_ALL: 'C' },
62
+ });
63
+ const fields = Object.fromEntries([...stdout.matchAll(/(\w+)="([^"]*)"/g)].map((match) => [match[1], match[2]]));
64
+ if (fields.FSTYPE === 'btrfs' && fields.UUID)
65
+ return { key: `btrfs:${fields.UUID}`, label: fields.SOURCE };
66
+ if (fields.FSTYPE === 'zfs' && fields.SOURCE) {
67
+ const pool = fields.SOURCE.split('/')[0];
68
+ return { key: `zfs:${pool}`, label: pool };
69
+ }
70
+ return { key: `dev:${device}`, label: fields.LABEL || fields.TARGET || fields.SOURCE };
71
+ }
72
+ }
73
+ catch {
74
+ // Resource-pool recognition is best-effort. Device identity below is the conservative fallback.
75
+ }
76
+ return { key: `dev:${device}` };
77
+ }
78
+ async function defaultDarwinAvailableMemory() {
79
+ try {
80
+ const { stdout } = await execFileAsync('/usr/bin/vm_stat', [], { timeout: 2_000 });
81
+ const pageSize = Number(/page size of (\d+) bytes/.exec(stdout)?.[1]);
82
+ if (!Number.isFinite(pageSize) || pageSize <= 0)
83
+ return null;
84
+ const pages = (label) => Number(new RegExp(`^${label}:\\s+(\\d+)\\.`, 'm').exec(stdout)?.[1] ?? 0);
85
+ return (pages('Pages free') + pages('Pages inactive') + pages('Pages speculative')) * pageSize;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ async function defaultDarwinMemoryPressure() {
92
+ try {
93
+ const { stdout } = await execFileAsync('/usr/bin/memory_pressure', ['-Q'], { timeout: 2_000 });
94
+ const freePercent = Number(/System-wide memory free percentage:\s*(\d+(?:\.\d+)?)%/.exec(stdout)?.[1]);
95
+ if (!Number.isFinite(freePercent))
96
+ return null;
97
+ return Math.max(0, Math.min(1, 1 - freePercent / 100));
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ /**
104
+ * Low-overhead host sampler. CPU and memory are read only while a heartbeat is being built; storage
105
+ * topology is discovered out of band and capacity reads use the cached representative paths.
106
+ */
107
+ export class SystemMetricsSampler {
108
+ workspaces;
109
+ deps;
110
+ previousCpu = null;
111
+ storage = [];
112
+ refreshTimer = null;
113
+ refreshInFlight = null;
114
+ darwinMemoryRefreshInFlight = null;
115
+ darwinMemoryAvailable = null;
116
+ darwinMemoryPressure = null;
117
+ constructor(workspaces, deps = {
118
+ platform: process.platform,
119
+ cpus: os.cpus,
120
+ loadavg: os.loadavg,
121
+ totalmem: os.totalmem,
122
+ freemem: os.freemem,
123
+ readFile: readFileSync,
124
+ statfs: statfsSync,
125
+ stat: statSync,
126
+ identifyStorage: (cwd, device) => defaultIdentifyStorage(process.platform, cwd, device),
127
+ darwinAvailableMemory: defaultDarwinAvailableMemory,
128
+ darwinMemoryPressure: defaultDarwinMemoryPressure,
129
+ }) {
130
+ this.workspaces = workspaces;
131
+ this.deps = deps;
132
+ }
133
+ async start() {
134
+ await Promise.all([this.refreshTopology(), this.refreshDarwinMemory()]);
135
+ this.refreshTimer = setInterval(() => this.refreshTopologyInBackground(), STORAGE_TOPOLOGY_REFRESH_MS);
136
+ this.refreshTimer.unref();
137
+ }
138
+ stop() {
139
+ if (this.refreshTimer)
140
+ clearInterval(this.refreshTimer);
141
+ this.refreshTimer = null;
142
+ }
143
+ snapshot() {
144
+ try {
145
+ this.refreshDarwinMemoryInBackground();
146
+ const cpus = this.deps.cpus();
147
+ const currentCpu = cpuTotals(cpus);
148
+ const previousCpu = this.previousCpu;
149
+ this.previousCpu = currentCpu;
150
+ const deltaTotal = previousCpu ? currentCpu.total - previousCpu.total : 0;
151
+ const deltaIdle = previousCpu ? currentCpu.idle - previousCpu.idle : 0;
152
+ const cpuUtilization = previousCpu && deltaTotal > 0
153
+ ? Math.max(0, Math.min(1, 1 - (deltaIdle / deltaTotal)))
154
+ : null;
155
+ const loadAverages = this.deps.platform !== 'win32' ? this.deps.loadavg() : null;
156
+ const cpuLoad1 = loadAverages ? Math.max(0, loadAverages[0]) : null;
157
+ const cpuLoad5 = loadAverages ? Math.max(0, loadAverages[1]) : null;
158
+ const cpuLoad15 = loadAverages ? Math.max(0, loadAverages[2]) : null;
159
+ const cpuLogicalCount = cpus.length > 0 ? cpus.length : null;
160
+ const memoryTotalBytes = this.deps.totalmem();
161
+ const memoryAvailableBytes = Math.max(0, Math.min(memoryTotalBytes, this.deps.platform === 'linux'
162
+ ? (linuxAvailableMemory(this.deps.readFile) ?? this.deps.freemem())
163
+ : (this.darwinMemoryAvailable ?? this.deps.freemem())));
164
+ const memoryPressure = this.deps.platform === 'linux'
165
+ ? linuxMemoryPressure(this.deps.readFile)
166
+ : (this.deps.platform === 'darwin' ? this.darwinMemoryPressure : null);
167
+ const pools = new Map();
168
+ for (const pool of this.storage) {
169
+ try {
170
+ const stats = this.deps.statfs(pool.cwd);
171
+ const totalBytes = Number(stats.blocks) * Number(stats.bsize);
172
+ const availableBytes = Number(stats.bavail) * Number(stats.bsize);
173
+ pools.set(pool.key, {
174
+ id: stablePoolId(pool.key),
175
+ label: pool.label,
176
+ workspaces: pool.workspaces,
177
+ totalBytes,
178
+ availableBytes,
179
+ });
180
+ }
181
+ catch {
182
+ // A removed/unmounted workspace must not suppress CPU and memory metrics.
183
+ }
184
+ }
185
+ return {
186
+ cpuUtilization,
187
+ cpuLoad1,
188
+ cpuLoad5,
189
+ cpuLoad15,
190
+ cpuLogicalCount,
191
+ memoryPressure,
192
+ memoryTotalBytes,
193
+ memoryAvailableBytes,
194
+ storagePools: [...pools.values()],
195
+ };
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ }
201
+ refreshTopologyInBackground() {
202
+ if (this.refreshInFlight)
203
+ return;
204
+ const refresh = this.refreshTopology();
205
+ this.refreshInFlight = refresh;
206
+ void refresh.finally(() => {
207
+ if (this.refreshInFlight === refresh)
208
+ this.refreshInFlight = null;
209
+ });
210
+ }
211
+ refreshDarwinMemoryInBackground() {
212
+ if (this.deps.platform !== 'darwin' || this.darwinMemoryRefreshInFlight)
213
+ return;
214
+ const refresh = this.refreshDarwinMemory();
215
+ this.darwinMemoryRefreshInFlight = refresh;
216
+ void refresh.finally(() => {
217
+ if (this.darwinMemoryRefreshInFlight === refresh)
218
+ this.darwinMemoryRefreshInFlight = null;
219
+ });
220
+ }
221
+ async refreshDarwinMemory() {
222
+ if (this.deps.platform !== 'darwin')
223
+ return;
224
+ const [available, pressure] = await Promise.all([
225
+ this.deps.darwinAvailableMemory().catch(() => null),
226
+ this.deps.darwinMemoryPressure().catch(() => null),
227
+ ]);
228
+ this.darwinMemoryAvailable = available;
229
+ this.darwinMemoryPressure = pressure;
230
+ }
231
+ async refreshTopology() {
232
+ const candidates = this.workspaces.map(({ name, cwd }) => {
233
+ try {
234
+ this.deps.statfs(cwd);
235
+ const device = String(this.deps.stat(cwd).dev);
236
+ return { name, cwd, device };
237
+ }
238
+ catch {
239
+ return null;
240
+ }
241
+ }).filter((candidate) => candidate !== null);
242
+ const representatives = new Map();
243
+ candidates.forEach(({ cwd, device }) => {
244
+ if (!representatives.has(device))
245
+ representatives.set(device, { cwd, device });
246
+ });
247
+ const identities = new Map(await Promise.all([...representatives.values()].map(async ({ cwd, device }) => [
248
+ device,
249
+ await this.deps.identifyStorage(cwd, device).catch(() => null),
250
+ ])));
251
+ const identified = candidates.map(({ name, cwd, device }) => {
252
+ try {
253
+ const identity = identities.get(device);
254
+ const key = identity?.key ?? `dev:${device}`;
255
+ return { name, cwd, key, label: identity?.label || path.parse(cwd).root || cwd };
256
+ }
257
+ catch {
258
+ return null;
259
+ }
260
+ });
261
+ const grouped = new Map();
262
+ for (const entry of identified.filter((candidate) => candidate !== null)) {
263
+ const existing = grouped.get(entry.key);
264
+ if (existing)
265
+ existing.workspaces.push(entry.name);
266
+ else
267
+ grouped.set(entry.key, { cwd: entry.cwd, key: entry.key, label: entry.label, workspaces: [entry.name] });
268
+ }
269
+ this.storage = [...grouped.values()];
270
+ }
271
+ }
@@ -0,0 +1,61 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+ import { createWriteStream, promises as fs } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { Readable } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ function expectedDigest(sha512) {
8
+ if (!/^[0-9a-f]{128}$/i.test(sha512))
9
+ throw new Error('Artifact SHA-512 digest is invalid');
10
+ return Buffer.from(sha512, 'hex');
11
+ }
12
+ function safeRelease(release, server) {
13
+ if (!Number.isSafeInteger(release.size) || release.size < 0)
14
+ throw new Error('Artifact size is invalid');
15
+ const configured = new URL(server);
16
+ if ((configured.protocol !== 'http:' && configured.protocol !== 'https:') || configured.username || configured.password) {
17
+ throw new Error('Configured server URL must be an HTTP(S) origin without credentials');
18
+ }
19
+ const url = new URL(release.url, configured);
20
+ // The release descriptor arrives over the authenticated management connection, but keeping the
21
+ // download on that exact origin prevents an injected relative/absolute URL from turning the
22
+ // client into a credentialed downloader. HTTP remains supported for private-network development
23
+ // only when the configured management server itself is HTTP; SHA-512 still verifies the bytes.
24
+ if (url.origin !== configured.origin || url.protocol !== configured.protocol) {
25
+ throw new Error('Artifact URL must use the configured server origin');
26
+ }
27
+ if (url.username || url.password)
28
+ throw new Error('Artifact URL must not contain credentials');
29
+ return url.toString();
30
+ }
31
+ /** Downloads to a private directory and returns only after the signed size and SHA-512 match. */
32
+ export async function downloadVerified(release, deps) {
33
+ const url = safeRelease(release, deps.server);
34
+ const expected = expectedDigest(release.sha512);
35
+ const fetch = deps.fetch ?? globalThis.fetch;
36
+ const directory = await fs.mkdtemp(path.join(deps.temporaryDirectory ?? tmpdir(), 'axom-workstation-update-'));
37
+ const archive = path.join(directory, 'client.tgz');
38
+ try {
39
+ const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(60_000) });
40
+ if (!response.ok || !response.body)
41
+ throw new Error(`Artifact download failed with HTTP ${response.status}`);
42
+ const contentLength = response.headers.get('content-length');
43
+ if (contentLength !== null && (!/^\d+$/.test(contentLength) || Number(contentLength) !== release.size))
44
+ throw new Error('Artifact size mismatch');
45
+ const hash = createHash('sha512');
46
+ let size = 0;
47
+ const source = Readable.fromWeb(response.body);
48
+ source.on('data', (chunk) => { size += chunk.length; hash.update(chunk); });
49
+ await pipeline(source, createWriteStream(archive, { mode: 0o600, flags: 'wx' }));
50
+ if (size !== release.size)
51
+ throw new Error('Artifact size mismatch');
52
+ const actual = hash.digest();
53
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
54
+ throw new Error('Artifact SHA-512 mismatch');
55
+ return { path: archive, size, cleanup: async () => { await fs.rm(directory, { recursive: true, force: true }); } };
56
+ }
57
+ catch (error) {
58
+ await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined);
59
+ throw error;
60
+ }
61
+ }
@@ -0,0 +1,240 @@
1
+ import { downloadVerified } from './artifact.js';
2
+ import { redactPaths } from './redact.js';
3
+ import { isTerminalPhase } from './types.js';
4
+ /**
5
+ * No historical workstation task-duration distribution exists yet. Thirty minutes deliberately
6
+ * gives interactive agent turns and long shell commands generous time to finish, while keeping a
7
+ * stalled task from blocking an update forever. Callers/tests can override this bound.
8
+ */
9
+ export const DEFAULT_UPDATE_DRAIN_TIMEOUT_MS = 30 * 60_000;
10
+ function failure(error) {
11
+ const message = error instanceof Error ? error.message : String(error);
12
+ // Individual components already redact their own errors. This is a final limit for unexpected
13
+ // errors before they become a heartbeat field.
14
+ return redactPaths(message.replace(/https?:\/\/\S+/gi, '[redacted-url]').replace(/(?:token|password|secret)=\S+/gi, '$1=[redacted]')).slice(0, 512);
15
+ }
16
+ /** Serializes server update commands and persists every externally visible phase transition. */
17
+ export class UpdateCoordinator {
18
+ options;
19
+ status = { phase: 'SUCCEEDED' };
20
+ initialized = null;
21
+ active = null;
22
+ constructor(options) {
23
+ this.options = options;
24
+ }
25
+ snapshot() { return { ...this.status }; }
26
+ async snapshotAsync() { await this.ensureInitialized(); return this.snapshot(); }
27
+ async handle(command) {
28
+ await this.ensureInitialized();
29
+ // Server command IDs are idempotency keys, including after a completed/failed attempt and a
30
+ // process restart. Re-running a terminal command could silently downgrade a repaired client.
31
+ if (this.status.commandId === command.commandId)
32
+ return this.snapshot();
33
+ if (this.active) {
34
+ return { phase: 'UPDATE_IN_PROGRESS', commandId: this.status.commandId, version: this.status.version, force: this.status.force };
35
+ }
36
+ if (!isTerminalPhase(this.status.phase)) {
37
+ return { phase: 'UPDATE_IN_PROGRESS', commandId: this.status.commandId, version: this.status.version, force: this.status.force };
38
+ }
39
+ // Asked before anything starts, because every phase of an update is persisted: a state
40
+ // directory that cannot be written turns the first transition into a raw `ENOENT ... open`
41
+ // that names nothing an operator can act on. This is the workstation saying which directory
42
+ // has gone instead. See UpdateStateStore.assertWritable for why it is a real write.
43
+ try {
44
+ await this.options.state.assertWritable();
45
+ }
46
+ catch (error) {
47
+ const status = { ...this.base(command, 'FAILED'), error: failure(error) };
48
+ this.status = status;
49
+ // Persisting is the very thing that just proved impossible. Try anyway -- a directory that
50
+ // recovered between the probe and here should keep its history -- and never let that attempt
51
+ // replace the error that explains the failure.
52
+ await this.options.state.save(status).catch(() => undefined);
53
+ try {
54
+ this.options.onPhase?.(status);
55
+ }
56
+ catch { /* a broken logger must not fail this */ }
57
+ return this.snapshot();
58
+ }
59
+ this.active = this.execute(command);
60
+ try {
61
+ return await this.active;
62
+ }
63
+ finally {
64
+ this.active = null;
65
+ }
66
+ }
67
+ /**
68
+ * `management_ready` has already passed the encrypted proof-of-key exchange. Only that path can
69
+ * satisfy a restart, and it must describe the exact artifact that this command installed.
70
+ */
71
+ async managementReady(release, generation) {
72
+ await this.ensureInitialized();
73
+ const readiness = this.options.readiness;
74
+ if (!release)
75
+ return;
76
+ // Caching must not hold the authenticated channel hostage. The cache itself accepts only its
77
+ // running version and independently re-verifies the resulting archive digest.
78
+ if (release.version === this.options.releaseCache?.clientVersion) {
79
+ void this.options.releaseCache.promoteAuthenticatedRelease(release).catch(() => undefined);
80
+ }
81
+ if (!readiness)
82
+ return;
83
+ if (this.status.phase !== 'RESTARTING')
84
+ return;
85
+ if (!this.status.commandId || this.status.version !== readiness.clientVersion)
86
+ return;
87
+ if (release.version !== this.status.version || release.sha512 !== this.status.releaseSha512)
88
+ return;
89
+ await readiness.write({ commandId: this.status.commandId, version: this.status.version, generation, readyAt: new Date().toISOString() });
90
+ // The authenticated Ready above proves the replacement daemon is running the exact release.
91
+ // Persist the terminal result as well as the watchdog marker: after a server restart there is
92
+ // no in-memory operation left to infer completion from a later RESTARTING heartbeat.
93
+ await this.transition({ ...this.status, phase: 'SUCCEEDED' });
94
+ }
95
+ async ensureInitialized() {
96
+ if (!this.initialized)
97
+ this.initialized = this.options.state.load().then(async (saved) => {
98
+ if (!saved)
99
+ return;
100
+ const reconcile = this.options.reconcileRestartOnBoot;
101
+ if (reconcile && saved.phase === 'RESTARTING' && saved.version !== reconcile.runningVersion) {
102
+ // A container came back on the image's version: the npm layer that held the update is
103
+ // gone. Surface that as a terminal failure instead of heartbeating RESTARTING forever —
104
+ // there is no rollback and no watchdog to resolve it any other way in this lifecycle.
105
+ this.status = { ...saved, phase: 'FAILED', error: `container recreated, image version ${reconcile.runningVersion} restored` };
106
+ await this.options.state.save(this.status);
107
+ // Reported like any other transition: this *is* the container lifecycle's only way for an
108
+ // interrupted update to resolve, so it is exactly what an operator reading `docker logs`
109
+ // after a recreation needs to see.
110
+ try {
111
+ this.options.onPhase?.(this.status);
112
+ }
113
+ catch { /* a broken logger must not fail a boot */ }
114
+ return;
115
+ }
116
+ this.status = saved;
117
+ });
118
+ await this.initialized;
119
+ }
120
+ async transition(status) {
121
+ this.status = status;
122
+ await this.options.state.save(status);
123
+ // After the persist, and swallowed: a logger is a witness to the update, never a participant
124
+ // that can fail one.
125
+ try {
126
+ this.options.onPhase?.(status);
127
+ }
128
+ catch { /* a broken logger must not fail an update */ }
129
+ return this.snapshot();
130
+ }
131
+ base(command, phase) {
132
+ return { phase, commandId: command.commandId, version: command.release.version, releaseSha512: command.release.sha512, force: command.force };
133
+ }
134
+ async execute(command) {
135
+ let artifact = null;
136
+ let rollback = null;
137
+ let drainCancellation = null;
138
+ try {
139
+ await this.transition(this.base(command, 'REQUESTED'));
140
+ let drained = null;
141
+ if (command.force) {
142
+ await this.options.tasks.stopAll();
143
+ }
144
+ else {
145
+ // Close admission before the persisted transition can yield: no command may enter in the
146
+ // gap between announcing DRAINING and establishing the barrier it promises.
147
+ this.options.tasks.accepting = false;
148
+ await this.transition(this.base(command, 'DRAINING'));
149
+ drainCancellation = new AbortController();
150
+ drained = this.options.tasks.waitForIdle(this.options.drainTimeoutMs ?? DEFAULT_UPDATE_DRAIN_TIMEOUT_MS, drainCancellation.signal);
151
+ }
152
+ await this.transition(this.base(command, 'DOWNLOADING'));
153
+ if (this.options.downloader)
154
+ artifact = await this.options.downloader(command.release);
155
+ else {
156
+ if (!this.options.artifact)
157
+ throw new Error('Update artifact download requires the configured server URL');
158
+ artifact = await downloadVerified(command.release, this.options.artifact);
159
+ }
160
+ await this.transition(this.base(command, 'VERIFYING'));
161
+ if (drained && !await drained) {
162
+ this.options.tasks.accepting = true;
163
+ return this.transition({ ...this.base(command, 'BUSY'), ...this.options.tasks.snapshot() });
164
+ }
165
+ // This is intentionally after a verified new archive exists but before npm mutates the
166
+ // installed package. A missing/invalid last-known-good archive must leave the client alone.
167
+ rollback = await this.options.rollback?.prepare(command) ?? null;
168
+ await this.transition(this.base(command, 'INSTALLING'));
169
+ try {
170
+ await this.options.installer.install(artifact.path);
171
+ }
172
+ catch (installError) {
173
+ try {
174
+ await rollback?.restore?.();
175
+ }
176
+ catch (restoreError) {
177
+ throw new Error(`npm install failed and restore failed: ${failure(installError)}; ${failure(restoreError)}`);
178
+ }
179
+ throw installError;
180
+ }
181
+ await rollback?.arm();
182
+ // RESTARTING is persisted *before* provisioning, not after, because provisioning is what
183
+ // bounces the service: on macOS it boots this very daemon out and kickstarts its
184
+ // replacement, and that replacement reads this file. Persisting it afterwards would leave
185
+ // the successor loading INSTALLING and heartbeating a non-terminal phase forever, which the
186
+ // server never resolves and which blocks every later update command.
187
+ const restarting = await this.transition(this.base(command, 'RESTARTING'));
188
+ // Deliberately not inside the try/catch that restores the package: a service definition we
189
+ // failed to rewrite does not make the installed package wrong, and rolling npm back over a
190
+ // provisioning failure would be a much bigger hammer than the problem.
191
+ //
192
+ // It does, however, fail the update. `provisionService` boots the old job out before writing
193
+ // the replacement, so "provisioning failed" can mean the service is gone from the service
194
+ // manager entirely — a strictly worse end state than a rolled-back update, and the one that
195
+ // was being reported as SUCCEEDED. The restart is skipped for the same reason: exiting is
196
+ // only a hand-off when something is left to restart us, and we no longer know that.
197
+ try {
198
+ await this.options.provisionService?.();
199
+ }
200
+ catch (provisionError) {
201
+ this.options.onProvisionFailure?.(provisionError);
202
+ if (!command.force)
203
+ this.options.tasks.accepting = true;
204
+ return this.transition({ ...this.base(command, 'FAILED'), error: failure(provisionError) });
205
+ }
206
+ if (!this.options.restart) {
207
+ // Nothing to hand off to (`restartsOnHandoff: false`, i.e. the `'none'` lifecycle): this
208
+ // process keeps running the old code until a person restarts it, which is exactly what
209
+ // that lifecycle promises — "install, then print a hint". So the update ends *here*, and
210
+ // everything RESTARTING made moot by exiting has to be undone instead:
211
+ //
212
+ // * admission, closed before DRAINING, would otherwise stay closed for the life of the
213
+ // process and refuse every later shell/agent request with "Workstation is not
214
+ // accepting new work" (taskRegistry.ts). Not for a forced update, whose `stopAll()`
215
+ // closed it deliberately and permanently — same rule as the BUSY and FAILED paths.
216
+ // * RESTARTING is not terminal, so leaving it persisted would answer every future
217
+ // update command with UPDATE_IN_PROGRESS — and `'none'` does no boot reconciliation,
218
+ // so a manual restart would not clear it either.
219
+ //
220
+ // SUCCEEDED is the honest phase: the install did succeed. What has not happened is the
221
+ // restart, which the caller is the one positioned to say — `axom update` prints it, and
222
+ // the server-driven case sees the new version only after the operator restarts.
223
+ if (!command.force)
224
+ this.options.tasks.accepting = true;
225
+ return this.transition(this.base(command, 'SUCCEEDED'));
226
+ }
227
+ await this.options.restart();
228
+ return restarting;
229
+ }
230
+ catch (error) {
231
+ if (!command.force)
232
+ this.options.tasks.accepting = true;
233
+ return this.transition({ ...this.base(command, 'FAILED'), error: failure(error) });
234
+ }
235
+ finally {
236
+ drainCancellation?.abort();
237
+ await artifact?.cleanup().catch(() => undefined);
238
+ }
239
+ }
240
+ }
@@ -0,0 +1,91 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { redactPaths, redactSecrets } from './redact.js';
3
+ const STDERR_LIMIT = 4096;
4
+ function absolute(value, field) {
5
+ if (!value.startsWith('/') || /[\u0000-\u001f\u007f]/.test(value))
6
+ throw new Error(`${field} must be an absolute safe path`);
7
+ }
8
+ /** Removes data that is neither useful nor safe to publish as an update status. */
9
+ export function sanitizeNpmError(value) {
10
+ return redactPaths(redactSecrets(value)).slice(0, STDERR_LIMIT).trim();
11
+ }
12
+ /** Installs only a previously verified local archive. No command ever reaches a shell. */
13
+ export class NpmInstaller {
14
+ options;
15
+ spawn;
16
+ constructor(options) {
17
+ this.options = options;
18
+ absolute(options.npmPath, 'npmPath');
19
+ absolute(options.npmPrefix, 'npmPrefix');
20
+ this.spawn = options.spawn ?? nodeSpawn;
21
+ }
22
+ async install(archive) {
23
+ absolute(archive, 'archive');
24
+ // No `--prefix`: npm installs into its own configured global prefix, which is what puts `axom`
25
+ // on the user's PATH. Forcing a private prefix put the binary somewhere nothing looks, so every
26
+ // machine needed a hand-made symlink that nothing in this package created or documented.
27
+ // `npmPrefix` survives for the writability pre-check, the service definition, and rollback —
28
+ // it is derived from where this package actually sits, so it still names npm's real prefix.
29
+ const args = ['install', '--global', archive, '--ignore-scripts', '--no-audit', '--no-fund'];
30
+ await new Promise((resolve, reject) => {
31
+ let stderr = '';
32
+ let child;
33
+ try {
34
+ child = this.spawn(this.options.npmPath, args, { shell: false, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
35
+ }
36
+ catch (error) {
37
+ reject(new Error(`npm install could not start: ${sanitizeNpmError(error instanceof Error ? error.message : String(error))}`));
38
+ return;
39
+ }
40
+ child.stderr?.on('data', (chunk) => { if (stderr.length < STDERR_LIMIT)
41
+ stderr += chunk.toString().slice(0, STDERR_LIMIT - stderr.length); });
42
+ child.once('error', (error) => reject(new Error(`npm install could not start: ${sanitizeNpmError(error.message)}`)));
43
+ child.once('close', (code, signal) => {
44
+ if (code === 0)
45
+ resolve();
46
+ else
47
+ reject(new Error(`npm install failed: ${sanitizeNpmError(stderr) || `exit ${code ?? signal ?? 'unknown'}`}`));
48
+ });
49
+ });
50
+ }
51
+ /** Packs the installed package locally; `--json` gives npm's own name/version validation input. */
52
+ async pack(packageRoot, destination) {
53
+ absolute(packageRoot, 'packageRoot');
54
+ absolute(destination, 'destination');
55
+ const args = ['pack', packageRoot, '--pack-destination', destination, '--json'];
56
+ return new Promise((resolve, reject) => {
57
+ let stdout = '';
58
+ let stderr = '';
59
+ let child;
60
+ try {
61
+ child = this.spawn(this.options.npmPath, args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
62
+ }
63
+ catch (error) {
64
+ reject(new Error(`npm pack could not start: ${sanitizeNpmError(error instanceof Error ? error.message : String(error))}`));
65
+ return;
66
+ }
67
+ child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
68
+ child.stderr?.on('data', (chunk) => { if (stderr.length < STDERR_LIMIT)
69
+ stderr += chunk.toString().slice(0, STDERR_LIMIT - stderr.length); });
70
+ child.once('error', (error) => reject(new Error(`npm pack could not start: ${sanitizeNpmError(error.message)}`)));
71
+ child.once('close', (code) => {
72
+ if (code !== 0) {
73
+ reject(new Error(`npm pack failed: ${sanitizeNpmError(stderr) || `exit ${code ?? 'unknown'}`}`));
74
+ return;
75
+ }
76
+ try {
77
+ const parsed = JSON.parse(stdout);
78
+ const entry = Array.isArray(parsed) && parsed.length === 1 ? parsed[0] : null;
79
+ if (!entry || typeof entry.filename !== 'string' || typeof entry.name !== 'string' || typeof entry.version !== 'string')
80
+ throw new Error('npm pack returned invalid metadata');
81
+ const archive = `${destination}/${entry.filename}`;
82
+ absolute(archive, 'packed archive');
83
+ resolve({ path: archive, name: entry.name, version: entry.version });
84
+ }
85
+ catch (error) {
86
+ reject(error instanceof Error ? error : new Error(String(error)));
87
+ }
88
+ });
89
+ });
90
+ }
91
+ }