@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,411 @@
1
+ /**
2
+ * Download, verify and unpack a `binary`-distributed ACP adapter — the counterpart to
3
+ * `installProbe.ts` (npx) and `uvxInstall.ts` (uvx), and the piece that makes those adapters
4
+ * installable at all. Before this module, `registry/launch.ts` refused every `binary` agent
5
+ * outright; on the live registry snapshot this task was built against, that is 16 of 39 agents.
6
+ *
7
+ * ## The shape of a `binary` distribution
8
+ *
9
+ * It is not one archive. `RegistryAgent.distribution.binary` (`BinaryDistribution`,
10
+ * `registryTypes.ts`) is a `Partial<Record<..six platform triples.., BinaryTarget>>` — up to six
11
+ * entries keyed `darwin-aarch64` / `darwin-x86_64` / `linux-aarch64` / `linux-x86_64` /
12
+ * `windows-aarch64` / `windows-x86_64`, and missing entries are normal: on the snapshot this was
13
+ * verified against, coverage across the 18 agents that ship a binary distribution is
14
+ * darwin-aarch64 18/18, linux-x86_64 18/18, windows-x86_64 18/18, darwin-x86_64 16/18,
15
+ * linux-aarch64 16/18, and windows-aarch64 only 9/18. "No archive for this platform" is therefore a
16
+ * real, expected outcome for roughly a third of these agents on some machines, not an edge case —
17
+ * {@link installBinary} reports it as its own answer rather than an error, and never even calls
18
+ * `fetch` when it is the answer.
19
+ *
20
+ * Each target carries `archive` (a URL), `cmd` (a path *inside* the unpacked archive — `./goose`,
21
+ * `./dist-package/cursor-agent`, `agy_acp_server.par`), optional `args`/`env`, and — for roughly
22
+ * half the entries, `antigravity-acp` and `cursor` among them — no `sha256` at all.
23
+ *
24
+ * ## The checksum rule
25
+ *
26
+ * When the registry publishes a `sha256`, it must match or the install fails outright and nothing
27
+ * is written under {@link InstallBinaryOptions.root}. When it publishes none, the install proceeds
28
+ * anyway — refusing would make half the binary-distributed catalogue permanently uninstallable —
29
+ * but the result says so via `checksumVerified: false`. Nothing here may report an unverified
30
+ * download as checked; that is precisely the distinction {@link InstallOutcome} exists to preserve.
31
+ *
32
+ * ## Layout: several versions can coexist
33
+ *
34
+ * Installs land at `<root>/<registryAgent>/<version>/`, one directory per version, the same
35
+ * side-by-side layout npm's own `_npx` cache keeps for npx-distributed adapters. Picking one
36
+ * installed version when several are present reuses `installProbe.ts`'s
37
+ * {@link pickInstalledVersion} rather than reimplementing that rule a second time — see
38
+ * {@link installedBinaryVersion}.
39
+ *
40
+ * ## Unpacking without a new dependency
41
+ *
42
+ * Three archive formats are in use (`.tar.gz`, `.tar.bz2`, `.zip`) and this deliberately does not
43
+ * add a tar/zip library to do it: every `tar` this was checked against — GNU tar (Linux) and the
44
+ * bsdtar that ships as `/usr/bin/tar` on macOS and as `tar.exe` on Windows since 10 (1803) — reads
45
+ * the compression off the archive's own magic bytes for `-xf`, no `-z`/`-j` needed, which covers
46
+ * both tar formats on every platform uniformly — though on a slim Linux image "reads the
47
+ * compression" still means shelling out to `bzip2`/`xz`, which a `-slim` base does not ship;
48
+ * `docker/axom-workstation/Dockerfile` installs them for exactly this reason. `.zip` tries `unzip`
49
+ * first (which is *not* on every image either — this repo's own workstation image lacked it, and
50
+ * 33 of the registry's binary targets are `.zip`) and falls back to `tar -xf` — which is what lets a
51
+ * Windows host extract a `.zip` via its bundled bsdtar even with no `unzip` anywhere on it; when
52
+ * *both* fail on a `.zip` (a Linux workstation without `unzip`, where GNU tar cannot read zip at
53
+ * all), the error names what is missing instead of surfacing GNU tar's confusing parse failure.
54
+ * See {@link extractArchive}.
55
+ *
56
+ * ## Not every `archive` is an archive
57
+ *
58
+ * A live registry snapshot has four `binary` entries (`sigit` among them) whose `archive` field
59
+ * points at a bare, unpacked executable — a raw ELF or PE, not a `.tar.gz`/`.zip` — because that is
60
+ * literally what the upstream project publishes as a GitHub release asset. Extraction is decided
61
+ * per target rather than assumed: a recognised archive extension settles it outright; anything else
62
+ * is sniffed by its first bytes (gzip/bzip2/xz/zip magic) once it is on disk, because a URL with no
63
+ * extension at all could still be a tarball and guessing from the URL alone would repeat this same
64
+ * mistake. Whatever is not an archive by either test is placed as-is at
65
+ * `<installDir>/<basename of the archive URL>` and marked executable — that filename is exactly
66
+ * what `BinaryTarget.cmd` refers to. See {@link isArchiveFile}.
67
+ */
68
+ import { createHash } from 'node:crypto';
69
+ import { createWriteStream, promises as fs, readdirSync } from 'node:fs';
70
+ import { tmpdir } from 'node:os';
71
+ import path from 'node:path';
72
+ import { Readable } from 'node:stream';
73
+ import { pipeline } from 'node:stream/promises';
74
+ import { pickInstalledVersion } from './installProbe.js';
75
+ import { runTimedCommand } from './timedCommand.js';
76
+ const PLATFORM_SEGMENT = {
77
+ darwin: 'darwin',
78
+ linux: 'linux',
79
+ win32: 'windows',
80
+ };
81
+ const ARCH_SEGMENT = {
82
+ arm64: 'aarch64',
83
+ x64: 'x86_64',
84
+ };
85
+ /**
86
+ * Maps Node's own `process.platform`/`process.arch` vocabulary onto one of the registry's six
87
+ * platform-triple keys, or `undefined` for anything the registry does not describe at all (every
88
+ * platform Node runs on that is not one of the three OSes above, or an architecture other than the
89
+ * two 64-bit ones the registry ships for).
90
+ */
91
+ export function platformTriple(platform, arch) {
92
+ const os = PLATFORM_SEGMENT[platform];
93
+ const cpu = ARCH_SEGMENT[arch];
94
+ return os && cpu ? `${os}-${cpu}` : undefined;
95
+ }
96
+ /** The archive target for one platform triple, or `undefined` when the registry has none — normal, see the module doc. */
97
+ export function binaryTargetFor(agent, triple) {
98
+ return agent.distribution.binary?.[triple];
99
+ }
100
+ /**
101
+ * How many redirects one archive download may follow. Five is well past what any real host needs
102
+ * (GitHub uses exactly one) and still bounds a redirect loop, which is the thing the old
103
+ * `redirect: 'error'` policy was reaching for — see {@link fetchArchive}.
104
+ */
105
+ const MAX_ARCHIVE_REDIRECTS = 5;
106
+ /** The redirect statuses a download must follow. 304 is deliberately absent: it is not a redirect. */
107
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
108
+ /**
109
+ * The version of `registryAgent` installed under `root`, chosen the same way `installProbe.ts`'s
110
+ * `npxCacheVersion` chooses among several npx-cached versions:
111
+ * `pinned` when it is among the directories found (so an exact match is never shadowed by a newer
112
+ * sibling), else the greatest by semver, unparseable versions sorting last by string.
113
+ *
114
+ * `pinned` is optional and normally omitted by `registry/launch.ts` (a `BinaryTarget` carries no
115
+ * per-target version to pin against — the registry pins the *agent*, via `RegistryAgent.version`,
116
+ * not the archive), which is why an installed binary agent with only one version on disk is the
117
+ * common case this resolves without needing it at all.
118
+ *
119
+ * Reads the directory once; a `root` or `<root>/<registryAgent>` that does not exist yet (nothing
120
+ * installed) is not an error — it answers `undefined`, the same as an empty directory.
121
+ */
122
+ export function installedBinaryVersion(registryAgent, root, pinned) {
123
+ let entries;
124
+ try {
125
+ entries = readdirSync(path.join(root, registryAgent), { withFileTypes: true });
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ const versions = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
131
+ return pickInstalledVersion(versions, pinned);
132
+ }
133
+ /**
134
+ * Fetches `url`, following redirects itself, up to {@link MAX_ARCHIVE_REDIRECTS} hops.
135
+ *
136
+ * ## Why this follows redirects at all
137
+ *
138
+ * It used to pass `redirect: 'error'`, copied from `artifact.ts`'s `downloadVerified` — "refuse
139
+ * redirects rather than follow them silently". That reasoning does not survive contact with the
140
+ * registry: a `BinaryTarget.archive` is a third-party URL, and on the live snapshot **14 of the 19
141
+ * agents publishing a `linux-x86_64` archive host it on `github.com`**, whose release-asset URLs
142
+ * answer 302 to `release-assets.githubusercontent.com` on every single request. `redirect: 'error'`
143
+ * therefore did not harden those downloads, it made them impossible — `amp-acp`, `goose`, `junie`,
144
+ * `kilo`, `kimi`, `opencode`, `vtcode` and seven more failed with a bare `fetch failed`, and the
145
+ * reader was left to configure something no configuration could fix.
146
+ *
147
+ * ## What is kept from that policy
148
+ *
149
+ * Both halves of what refusing was actually buying, made explicit instead of implicit:
150
+ *
151
+ * - **A loop cannot run forever.** The hop count is capped here, and the `AbortSignal` is created
152
+ * once by the caller and passed to every hop, so {@link ARCHIVE_DOWNLOAD_TIMEOUT_MS} remains the
153
+ * budget for the whole chain rather than resetting per hop.
154
+ * - **No silent downgrade.** Every hop must land on `https:`. A redirect to `http:` — or to any
155
+ * other scheme — is refused rather than followed, which is the case "silently" was really about.
156
+ *
157
+ * The checksum rule is unchanged and orthogonal: where the registry publishes a `sha256`, it is
158
+ * verified against whatever the final hop delivered, so a redirect cannot substitute other bytes.
159
+ * Error text never repeats a redirect target — GitHub's carries a signed URL token.
160
+ */
161
+ async function fetchArchive(url, fetchImpl, signal) {
162
+ let current = url;
163
+ for (let hop = 0; hop <= MAX_ARCHIVE_REDIRECTS; hop += 1) {
164
+ const response = await fetchImpl(current, { redirect: 'manual', signal });
165
+ if (!REDIRECT_STATUSES.has(response.status))
166
+ return { ok: true, response };
167
+ const location = response.headers.get('location');
168
+ if (!location)
169
+ return { ok: false, error: `HTTP ${response.status} carried no Location header.` };
170
+ let next;
171
+ try {
172
+ next = new URL(location, current);
173
+ }
174
+ catch {
175
+ return { ok: false, error: `HTTP ${response.status} carried a Location that is not a usable URL.` };
176
+ }
177
+ if (next.protocol !== 'https:') {
178
+ return { ok: false, error: `refused to follow a redirect to a non-HTTPS location (${next.protocol}).` };
179
+ }
180
+ current = next.toString();
181
+ }
182
+ return { ok: false, error: `more than ${MAX_ARCHIVE_REDIRECTS} redirects.` };
183
+ }
184
+ /**
185
+ * Whole-request budget for one archive download, body included — the `AbortSignal` handed to
186
+ * `fetch` aborts the response stream too, not just the headers.
187
+ *
188
+ * Matches `installProbe.ts`'s `ADAPTER_INSTALL_TIMEOUT_MS` deliberately: until this module was
189
+ * wired into `AcpAgents.install` it had no production caller at all, and the 60s it carried while
190
+ * unreachable would have cut off the very archives it exists to fetch (binary adapters are whole
191
+ * agents — `opencode`, `cursor-agent`, `goose` — not thin wrappers). The budget is here to bound a
192
+ * download that will never finish, not one that is merely large.
193
+ */
194
+ const ARCHIVE_DOWNLOAD_TIMEOUT_MS = 300_000;
195
+ /**
196
+ * Budget for unpacking one already-downloaded archive with `tar`/`unzip` — the step after the
197
+ * download above, previously run via blocking `execFileSync` with no bound at all.
198
+ *
199
+ * Matches {@link ARCHIVE_DOWNLOAD_TIMEOUT_MS} deliberately, the same way that constant matches
200
+ * `installProbe.ts`'s `ADAPTER_INSTALL_TIMEOUT_MS`: extraction is normally seconds even for a large
201
+ * archive (it is local disk I/O, not a network transfer), but "normally fast" is not a bound — a
202
+ * wedged filesystem or a pathological archive must not be able to hang this daemon's event loop for
203
+ * longer than the download step right above it is already allowed to take.
204
+ */
205
+ const ARCHIVE_EXTRACT_TIMEOUT_MS = 300_000;
206
+ function errorMessage(err) {
207
+ return err instanceof Error ? err.message : String(err);
208
+ }
209
+ function hasEntries(dir) {
210
+ try {
211
+ return readdirSync(dir).length > 0;
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ }
217
+ /** The archive's own file name off its URL, for the scratch download file — falls back to a fixed name if the URL is unparseable. */
218
+ function archiveFileName(url) {
219
+ try {
220
+ return path.basename(new URL(url).pathname) || 'archive';
221
+ }
222
+ catch {
223
+ return path.basename(url) || 'archive';
224
+ }
225
+ }
226
+ const ARCHIVE_EXTENSIONS = ['.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz', '.zip'];
227
+ /** Magic-byte prefixes for the archive formats this module ever has to extract. */
228
+ const ARCHIVE_SIGNATURES = [
229
+ [0x1f, 0x8b], // gzip
230
+ [0x42, 0x5a, 0x68], // bzip2
231
+ [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00], // xz
232
+ [0x50, 0x4b, 0x03, 0x04], // zip
233
+ ];
234
+ function matchesArchiveSignature(head) {
235
+ return ARCHIVE_SIGNATURES.some((sig) => head.length >= sig.length && sig.every((byte, i) => head[i] === byte));
236
+ }
237
+ /**
238
+ * Whether the file at `archivePath` (already downloaded and checksum-verified) is an archive at
239
+ * all — see the module doc's "Not every `archive` is an archive" section. A recognised extension on
240
+ * `sourceUrl` settles it without touching the file; otherwise the first six bytes on disk are
241
+ * sniffed for gzip/bzip2/xz/zip magic, because an extension-less URL can still be a real tarball.
242
+ */
243
+ async function isArchiveFile(archivePath, sourceUrl) {
244
+ const lowerName = archiveFileName(sourceUrl).toLowerCase();
245
+ if (ARCHIVE_EXTENSIONS.some((ext) => lowerName.endsWith(ext)))
246
+ return true;
247
+ const handle = await fs.open(archivePath, 'r');
248
+ try {
249
+ const head = Buffer.alloc(6);
250
+ const { bytesRead } = await handle.read(head, 0, head.length, 0);
251
+ return matchesArchiveSignature(head.subarray(0, bytesRead));
252
+ }
253
+ finally {
254
+ await handle.close();
255
+ }
256
+ }
257
+ const runCommand = async (command, args, timeoutMs) => {
258
+ const result = await runTimedCommand(command, args, timeoutMs);
259
+ if (result.ok)
260
+ return;
261
+ const error = new Error(result.message);
262
+ if (result.code)
263
+ error.code = result.code;
264
+ throw error;
265
+ };
266
+ /**
267
+ * `.zip` tries `unzip` first, then falls back to `tar -xf` (bsdtar reads zip; GNU tar does not) —
268
+ * see the module doc. When `unzip` is missing *and* the `tar` fallback also fails, that combination
269
+ * means this machine simply cannot open a zip (a bare Linux box with neither), so the error names
270
+ * both tools and what to install instead of surfacing GNU tar's opaque "not a tar archive" message.
271
+ */
272
+ async function extractZip(archivePath, destDir, exec, timeoutMs) {
273
+ let unzipMissing = false;
274
+ try {
275
+ await exec('unzip', ['-o', '-q', archivePath, '-d', destDir], timeoutMs);
276
+ return;
277
+ }
278
+ catch (err) {
279
+ if (err.code !== 'ENOENT')
280
+ throw err;
281
+ unzipMissing = true;
282
+ // No `unzip` on this machine — fall through to `tar`, which (bsdtar on macOS, and Windows's
283
+ // bundled tar.exe) reads `.zip` too.
284
+ }
285
+ try {
286
+ await exec('tar', ['-xf', archivePath, '-C', destDir], timeoutMs);
287
+ }
288
+ catch (err) {
289
+ if (!unzipMissing)
290
+ throw err;
291
+ throw new Error("Cannot extract this .zip archive: neither 'unzip' nor a zip-capable 'tar' (bsdtar) is " +
292
+ "available on this machine. Install 'unzip' (e.g. 'apt-get install unzip' or " +
293
+ "'apk add unzip') and retry.");
294
+ }
295
+ }
296
+ /**
297
+ * Unpacks one already-downloaded, already-verified archive into `destDir`. See the module doc's
298
+ * "Unpacking without a new dependency" section for why this shells out to `tar`/`unzip` rather than
299
+ * parsing archive formats in-process. `timeoutMs` is {@link ARCHIVE_EXTRACT_TIMEOUT_MS} in
300
+ * production; see that constant for why it matches the download budget above it.
301
+ */
302
+ async function extractArchive(archivePath, destDir, exec, timeoutMs) {
303
+ if (archivePath.toLowerCase().endsWith('.zip')) {
304
+ await extractZip(archivePath, destDir, exec, timeoutMs);
305
+ return;
306
+ }
307
+ await exec('tar', ['-xf', archivePath, '-C', destDir], timeoutMs);
308
+ }
309
+ /**
310
+ * Fetch, verify and unpack `agent`'s binary adapter for this machine's platform triple. Never
311
+ * starts the agent — the counterpart to `installNpxPackage`/`installUvTool`'s "download, don't run"
312
+ * contract.
313
+ *
314
+ * ## The three outcomes the brief for this task requires, and why each looks the way it does
315
+ *
316
+ * - **No archive for this platform triple**: `binaryTargetFor` finds nothing, `fetch` is never
317
+ * called, `ok: false`. Checked before any I/O — the point is that a machine on
318
+ * `windows-aarch64` (9/18 coverage — see the module doc) never opens a socket over an archive
319
+ * that was never going to exist.
320
+ * - **Registry publishes a `sha256` and the bytes don't match**: the archive is downloaded into a
321
+ * scratch temp directory and hashed while it streams to disk, entirely separate from
322
+ * {@link InstallBinaryOptions.root}. A mismatch returns `ok: false` *before* `installDir` is ever
323
+ * created — nothing under `root` exists that did not exist before the call — and only the scratch
324
+ * directory (never inside `root`) needs cleaning up, which the `finally` below always does.
325
+ * - **Registry publishes no `sha256`**: the install proceeds — this is not a reason to refuse, see
326
+ * the module doc — and the result carries `checksumVerified: false` so nothing downstream can
327
+ * mistake "we didn't check" for "we checked and it passed".
328
+ *
329
+ * `installDir` already holding files (a previous successful install of this exact version) short-
330
+ * circuits to `alreadyInstalled: true` before touching the network at all; `checksumVerified` on
331
+ * that path is `false` because this call verified nothing — it is a fact about *this call*, not a
332
+ * claim about what a previous one did.
333
+ */
334
+ export async function installBinary(agent, opts) {
335
+ const platform = opts.platform ?? process.platform;
336
+ const arch = opts.arch ?? process.arch;
337
+ const triple = platformTriple(platform, arch);
338
+ const target = triple ? binaryTargetFor(agent, triple) : undefined;
339
+ if (!target) {
340
+ return { ok: false, error: `Agent '${agent.id}' publishes no archive for ${triple ?? `${platform}-${arch}`}.` };
341
+ }
342
+ const version = agent.version;
343
+ const installDir = path.join(opts.root, agent.id, version);
344
+ if (hasEntries(installDir)) {
345
+ return { ok: true, checksumVerified: false, alreadyInstalled: true, version, installDir };
346
+ }
347
+ const fetchImpl = opts.fetch ?? globalThis.fetch;
348
+ if (!fetchImpl) {
349
+ return { ok: false, error: 'No fetch implementation is available to download the adapter archive.' };
350
+ }
351
+ const workDir = await fs.mkdtemp(path.join(opts.temporaryDirectory ?? tmpdir(), 'axom-acp-binary-'));
352
+ try {
353
+ const archivePath = path.join(workDir, archiveFileName(target.archive));
354
+ let response;
355
+ try {
356
+ // Not reusing `artifact.ts`'s `downloadVerified` — it is shaped around a single trusted,
357
+ // same-origin management server and a signed `size`/SHA-512 descriptor (`ClientReleaseInfo`),
358
+ // whereas a `BinaryTarget.archive` is an arbitrary third-party URL verified by an optional
359
+ // SHA-256 the registry supplies. Different trust model, so the policy is stated here: one
360
+ // whole-chain budget, a capped number of hops, and HTTPS at every one. See `fetchArchive` for
361
+ // why following redirects is required rather than merely permitted.
362
+ const fetched = await fetchArchive(target.archive, fetchImpl, AbortSignal.timeout(ARCHIVE_DOWNLOAD_TIMEOUT_MS));
363
+ if (!fetched.ok) {
364
+ return { ok: false, error: `Failed to download adapter archive for '${agent.id}': ${fetched.error}` };
365
+ }
366
+ response = fetched.response;
367
+ }
368
+ catch (err) {
369
+ return { ok: false, error: `Failed to download adapter archive for '${agent.id}': ${errorMessage(err)}` };
370
+ }
371
+ if (!response.ok || !response.body) {
372
+ return { ok: false, error: `Failed to download adapter archive for '${agent.id}': HTTP ${response.status}.` };
373
+ }
374
+ const hash = createHash('sha256');
375
+ const source = Readable.fromWeb(response.body);
376
+ source.on('data', (chunk) => hash.update(chunk));
377
+ await pipeline(source, createWriteStream(archivePath, { mode: 0o600 }));
378
+ if (target.sha256) {
379
+ const digest = hash.digest('hex');
380
+ if (digest.toLowerCase() !== target.sha256.toLowerCase()) {
381
+ return {
382
+ ok: false,
383
+ error: `Downloaded archive for '${agent.id}' failed sha256 verification: the registry pins ` +
384
+ `${target.sha256}, the download hashed to ${digest}.`,
385
+ };
386
+ }
387
+ }
388
+ await fs.mkdir(installDir, { recursive: true });
389
+ try {
390
+ if (await isArchiveFile(archivePath, target.archive)) {
391
+ await extractArchive(archivePath, installDir, opts.exec ?? runCommand, ARCHIVE_EXTRACT_TIMEOUT_MS);
392
+ }
393
+ else {
394
+ // Not an archive at all — the download *is* the program (e.g. `sigit`'s Linux/Windows
395
+ // targets, see the module doc). Place it under the same name `BinaryTarget.cmd` refers to
396
+ // and mark it executable; `chmod` is a no-op on Windows but harmless there.
397
+ const destPath = path.join(installDir, archiveFileName(target.archive));
398
+ await fs.copyFile(archivePath, destPath);
399
+ await fs.chmod(destPath, 0o755);
400
+ }
401
+ }
402
+ catch (err) {
403
+ await fs.rm(installDir, { recursive: true, force: true }).catch(() => undefined);
404
+ return { ok: false, error: `Failed to unpack adapter archive for '${agent.id}': ${errorMessage(err)}` };
405
+ }
406
+ return { ok: true, checksumVerified: Boolean(target.sha256), alreadyInstalled: false, version, installDir };
407
+ }
408
+ finally {
409
+ await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
410
+ }
411
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * daemon 保留 id:agent 只给 `modes`(`SessionModeState`, types.gen.d.ts:2561-2580)而**读者手上还
3
+ * 没有这个控件**时合成。什么算"已经有了"由 {@link modesAlreadyRepresented} 说了算——包括 agent 自己
4
+ * 就用这个 id 的情形(agent 的赢,什么都不合成)。
5
+ */
6
+ export const SYNTHESIZED_MODES_CONFIG_ID = 'acp:modes';
7
+ function toSelectOption(o) {
8
+ return { value: o.value, name: o.name, ...(o.description != null ? { description: o.description } : {}) };
9
+ }
10
+ /** A sanitized `SessionMode` (`types.gen.d.ts:2590-2613`) uses `id`, not `value` — mapped to {@link SelectOptionWire}'s shape. */
11
+ function modeToSelectOption(mode) {
12
+ return toSelectOption({ value: mode.id, name: mode.name, description: mode.description });
13
+ }
14
+ /**
15
+ * True for anything a property can safely be read off — i.e. not `null`/`undefined` and not a
16
+ * primitive. This is the one runtime check every boundary in this file is built on: reading a
17
+ * field off `null` (`entry.group`, `option.id`, `modes.availableModes`, …) throws *before* any
18
+ * `typeof` check ever sees a value, which is exactly the hole this file exists to close.
19
+ */
20
+ function isRecord(value) {
21
+ return typeof value === 'object' && value !== null;
22
+ }
23
+ /**
24
+ * Maps every item through `fn`, or `null` the moment any one of them does — "every item is
25
+ * representable, or none of them are kept". This is what makes a malformed entry anywhere in an
26
+ * `options` array take down the *whole* option (via {@link toWire} returning `null`) rather than a
27
+ * partially-valid array silently missing one entry, which would be a different (and unannounced)
28
+ * shape than what the agent actually reported.
29
+ */
30
+ function mapAllOrNothing(items, fn) {
31
+ const out = [];
32
+ for (const item of items) {
33
+ const mapped = fn(item);
34
+ if (mapped === null)
35
+ return null;
36
+ out.push(mapped);
37
+ }
38
+ return out;
39
+ }
40
+ /**
41
+ * Validates and narrows one raw entry that is supposed to be a `SessionConfigSelectOption`
42
+ * (`types.gen.d.ts:2676-2699`). `null` when it isn't representable as {@link SelectOptionWire} —
43
+ * not a record at all, `value`/`name` missing or not strings, or a `description` present but not
44
+ * a nullable string. Takes `unknown`, not {@link RawEntry}: this is called on elements of a
45
+ * *nested* `options` array the top-level boundary in {@link normalizeConfigOptions} never looks
46
+ * at, so nothing upstream has established these are records — `isRecord` here is the only thing
47
+ * standing between a `null` element and a thrown `TypeError`.
48
+ */
49
+ function toSelectOptionSafe(entry) {
50
+ if (!isRecord(entry))
51
+ return null;
52
+ if (typeof entry.value !== 'string' || typeof entry.name !== 'string')
53
+ return null;
54
+ if (entry.description != null && typeof entry.description !== 'string')
55
+ return null;
56
+ return toSelectOption({ value: entry.value, name: entry.name, description: entry.description });
57
+ }
58
+ /**
59
+ * Same validation, for one raw `SessionConfigSelectGroup` entry (`types.gen.d.ts:2703-2726`): a
60
+ * record whose `group`/`name` are strings and whose `options` is an array that itself validates
61
+ * entry-by-entry via {@link toSelectOptionSafe} — a group with a malformed member is exactly as
62
+ * unrepresentable as a group missing `options` altogether, so both drop the whole option via the
63
+ * same `null`.
64
+ *
65
+ * No `isRecord` check on `entry` itself here (unlike {@link toSelectOptionSafe}) — not an
66
+ * oversight, a proven invariant: this is only ever reached from `toWire`'s
67
+ * `mapAllOrNothing(raw, toSelectGroupSafe)`, which is only taken when `groupedCount === raw.length`
68
+ * — i.e. every element of `raw` already passed `isRecord(entry) && typeof entry.group === 'string'`
69
+ * to be counted into `groupedCount` in the first place. A repeated check here could never be false;
70
+ * keeping one anyway would be dead code that reads as protection where none is needed, and could
71
+ * mislead a future caller into believing this function is safe to call on unvalidated input the way
72
+ * {@link toSelectOptionSafe} is. If a second call site is ever added, it must re-earn this contract
73
+ * (or this function must regain the check) rather than assume it for free.
74
+ */
75
+ function toSelectGroupSafe(entry) {
76
+ const record = entry;
77
+ if (typeof record.group !== 'string' || typeof record.name !== 'string' || !Array.isArray(record.options))
78
+ return null;
79
+ const options = mapAllOrNothing(record.options, toSelectOptionSafe);
80
+ if (options === null)
81
+ return null;
82
+ return { group: record.group, name: record.name, options };
83
+ }
84
+ /**
85
+ * 一条 ACP `SessionConfigOption`(types.gen.d.ts:2617-2648)→ 隧道形状。调用者({@link normalizeConfigOptions})
86
+ * 已经保证 `option` 本身是一条 record —— 这里不再重复检查。`id`/`name` 必须是字符串,否则整条丢弃
87
+ * (`null`);`description`/`category` 存在但不是字符串时只丢字段本身(它们本来就是 optional),
88
+ * 不连累整条;`_meta` 一律丢弃;不认识的 `type` 也返回 null。
89
+ */
90
+ function toWire(option) {
91
+ if (typeof option.id !== 'string' || typeof option.name !== 'string')
92
+ return null;
93
+ const base = {
94
+ id: option.id,
95
+ name: option.name,
96
+ ...(typeof option.description === 'string' ? { description: option.description } : {}),
97
+ ...(typeof option.category === 'string' ? { category: option.category } : {}),
98
+ };
99
+ if (option.type === 'boolean') {
100
+ if (typeof option.currentValue !== 'boolean')
101
+ return null;
102
+ return { ...base, type: 'boolean', currentValue: option.currentValue };
103
+ }
104
+ if (option.type === 'select') {
105
+ if (typeof option.currentValue !== 'string' || !Array.isArray(option.options))
106
+ return null;
107
+ const raw = option.options;
108
+ // `SessionConfigSelectOptions` (types.gen.d.ts:2672) is a union: a flat list of
109
+ // `SessionConfigSelectOption` (:2676-2699), OR a list of `SessionConfigSelectGroup`
110
+ // (:2703-2726) — never a mix of both. Grouped entries carry a string `group`; ungrouped ones
111
+ // don't. `isRecord` guards the property read itself: a `null`/primitive array element is
112
+ // neither shape and must not throw just for being asked "do you have a `group`?". An array
113
+ // that mixes the two shapes can't be represented as either arm of the union, so the whole
114
+ // option is dropped (`null`) rather than emitting some entries one way and others the other.
115
+ const groupedCount = raw.filter((entry) => isRecord(entry) && typeof entry.group === 'string').length;
116
+ if (groupedCount !== 0 && groupedCount !== raw.length)
117
+ return null;
118
+ const options = groupedCount > 0 ? mapAllOrNothing(raw, toSelectGroupSafe) : mapAllOrNothing(raw, toSelectOptionSafe);
119
+ if (options === null)
120
+ return null;
121
+ return { ...base, type: 'select', currentValue: option.currentValue, options };
122
+ }
123
+ return null;
124
+ }
125
+ /**
126
+ * One raw `SessionMode` entry (`types.gen.d.ts:2590-2613`), sanitized — `null` when it isn't a
127
+ * record, or `id`/`name` are missing or not strings, or a `description` is present but not a
128
+ * nullable string.
129
+ */
130
+ function sanitizeMode(entry) {
131
+ if (!isRecord(entry))
132
+ return null;
133
+ if (typeof entry.id !== 'string' || typeof entry.name !== 'string')
134
+ return null;
135
+ if (entry.description != null && typeof entry.description !== 'string')
136
+ return null;
137
+ return { id: entry.id, name: entry.name, description: entry.description };
138
+ }
139
+ /**
140
+ * Boundary-sanitizes `modes` (`SessionModeState`, types.gen.d.ts:2561-2580) into what
141
+ * {@link normalizeConfigOptions} needs in order to synthesize `acp:modes`, or `null` when it
142
+ * cannot: `modes` itself isn't a record, `currentModeId` isn't a string, `availableModes` isn't an
143
+ * array, or — once every entry is individually sanitized via {@link sanitizeMode} — nothing usable
144
+ * is left. Unlike an `options` array's entries ({@link mapAllOrNothing}'s all-or-nothing rule), one
145
+ * bad mode entry does not disqualify the good ones next to it; only a `modes` that yields zero
146
+ * usable entries is treated as though the agent reported no `modes` at all — no synthesis, no
147
+ * throw, and never a picker with nothing in it.
148
+ */
149
+ function sanitizeModes(modes) {
150
+ if (!isRecord(modes) || typeof modes.currentModeId !== 'string' || !Array.isArray(modes.availableModes))
151
+ return null;
152
+ const availableModes = modes.availableModes.map(sanitizeMode).filter((m) => m !== null);
153
+ if (availableModes.length === 0)
154
+ return null;
155
+ return { currentModeId: modes.currentModeId, availableModes };
156
+ }
157
+ /**
158
+ * A `select` option's choices when they are the *flat* arm of `SessionConfigSelectOptions`
159
+ * (`types.gen.d.ts:2672`), or `null` for anything else — a `boolean` option, or a grouped
160
+ * (`SessionConfigSelectGroup`) list. Grouped is `null` rather than flattened on purpose: a modes
161
+ * picker is always flat ({@link modeToSelectOption} over `availableModes`), so a grouped option can
162
+ * never be the same control, and flattening it would only invent a comparison that has no meaning.
163
+ *
164
+ * Discriminating on `'group' in entry` is the same rule {@link toWire} enforced on the way in
165
+ * (`groupedCount === raw.length` or `0`, never a mix), so one probe of the first entry would do —
166
+ * `every` is used anyway because that invariant lives in a different function and reading one entry
167
+ * would silently depend on it.
168
+ */
169
+ export function flatChoicesOf(option) {
170
+ if (option.type !== 'select')
171
+ return null;
172
+ const entries = option.options;
173
+ if (!entries.every((entry) => !('group' in entry)))
174
+ return null;
175
+ return entries;
176
+ }
177
+ /** Two flat choice lists offering exactly the same things, in the same order, under the same labels. */
178
+ function sameChoices(a, b) {
179
+ return a.length === b.length && a.every((choice, i) => choice.value === b[i].value && choice.name === b[i].name);
180
+ }
181
+ /**
182
+ * "This list already gives the reader a control for these modes" — the whole condition under which
183
+ * nothing is synthesized. Three ways it can be true, and the third is the one measured on a real
184
+ * workstation:
185
+ *
186
+ * 1. **An option in `category: "mode"`.** ACP's own category for a permission policy; Claude Code
187
+ * declares one, so it never got a synthesized twin.
188
+ * 2. **An option already using the reserved {@link SYNTHESIZED_MODES_CONFIG_ID}.** The agent's own
189
+ * always wins over the daemon's stand-in for it, whatever it calls itself.
190
+ * 3. **An option whose choices are, entry for entry, the same list the modes picker would offer.**
191
+ * `pi-acp` maps its thinking levels onto ACP session modes *and* declares them as its own
192
+ * `thought_level` option: `id: 'thought_level'` and the synthesized `acp:modes` came back
193
+ * byte-identical down to the labels (`off/minimal/low/medium/high/xhigh`, all named
194
+ * `Thinking: …`), so the page drew the same control twice — once correctly labelled *Thinking*,
195
+ * once mislabelled *Mode* with a thinking value selected.
196
+ *
197
+ * **Why (3) cannot misfire on an agent whose modes genuinely are a permission policy.** It fires
198
+ * only when the agent itself already publishes a `select` offering the *identical* `value` sequence
199
+ * *and* the identical `name` for each of them. Values alone would be too weak — an unrelated
200
+ * on/off-shaped setting could collide with an on/off-shaped mode set — so the reader-visible labels
201
+ * have to coincide too, and a permission policy's labels ("Always ask", "Accept edits", "Bypass
202
+ * permissions") do not coincide entry-for-entry with a model list, a reasoning-effort list, or a
203
+ * toggle unless that list *is* the mode picker under another id. And an agent that declares a real
204
+ * permission policy has already been caught by (1) before this is ever reached: `category: "mode"`
205
+ * is what ACP asks such an option to carry.
206
+ *
207
+ * The empty list is explicitly not a match: `sameChoices([], [])` is `true`, and an agent may
208
+ * legitimately report a `select` with no choices yet (see the "empty options array" case in the
209
+ * tests). `choices.length === 0` can only arise from a `modes` that {@link sanitizeModes} would
210
+ * have rejected anyway, so requiring a non-empty list costs nothing and removes a way for an empty
211
+ * placeholder option to swallow a real mode picker.
212
+ */
213
+ export function modesAlreadyRepresented(options, choices) {
214
+ if (options.some((o) => o.category === 'mode' || o.id === SYNTHESIZED_MODES_CONFIG_ID))
215
+ return true;
216
+ if (choices.length === 0)
217
+ return false;
218
+ return options.some((o) => {
219
+ const own = flatChoicesOf(o);
220
+ return own !== null && sameChoices(own, choices);
221
+ });
222
+ }
223
+ /**
224
+ * 唯一一条归一化规则:ACP 的 `configOptions` + `modes` → 隧道的 `SessionConfigOptionWire[]`。
225
+ *
226
+ * **This function is the trust boundary for agent-supplied session config data.** Nothing upstream
227
+ * of it validates a `session/new`/`session/load` response before handing it here (checked against
228
+ * the SDK's own source — `ClientContext.request`'s `mapResponse` is unset for `session/new` and
229
+ * `session/set_config_option`, and is a bare `response ?? {}` identity for `session/load`; no zod
230
+ * schema runs against any of the three), so the declared TS parameter types are a *hope*, not a
231
+ * *guarantee*, about what an external agent process actually sent over stdio. Past this point,
232
+ * downstream code may assume: every element {@link toWire} receives is a non-null record (never
233
+ * `null`/`undefined`/a primitive — `configOptions` itself is always treated as an array even if the
234
+ * agent sent something else entirely); and `cleanModes`, if not `null`, is a record with a string
235
+ * `currentModeId` and a non-empty array of mode entries each carrying a string `id` and `name`.
236
+ * Nothing below this function re-derives either guarantee — that is the point of establishing it
237
+ * once, here.
238
+ */
239
+ export function normalizeConfigOptions(configOptions, modes) {
240
+ const rawOptions = Array.isArray(configOptions) ? configOptions.filter(isRecord) : [];
241
+ const out = rawOptions.map(toWire).filter((o) => o !== null);
242
+ const cleanModes = sanitizeModes(modes);
243
+ if (cleanModes) {
244
+ const choices = cleanModes.availableModes.map(modeToSelectOption);
245
+ if (!modesAlreadyRepresented(out, choices)) {
246
+ out.push({
247
+ id: SYNTHESIZED_MODES_CONFIG_ID,
248
+ name: 'Mode',
249
+ category: 'mode',
250
+ type: 'select',
251
+ currentValue: cleanModes.currentModeId,
252
+ options: choices,
253
+ });
254
+ }
255
+ }
256
+ return out;
257
+ }