@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,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
|
+
}
|