@zvada/agent-server 0.2.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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/package.json +87 -0
  4. package/src/client/client.ts +589 -0
  5. package/src/client/index.ts +18 -0
  6. package/src/client/transports.ts +84 -0
  7. package/src/core/agents/acp/acp-agent.ts +322 -0
  8. package/src/core/agents/acp/adapter.ts +260 -0
  9. package/src/core/agents/acp/client.ts +212 -0
  10. package/src/core/agents/acp/known-agents.ts +29 -0
  11. package/src/core/agents/acp/mappings.ts +136 -0
  12. package/src/core/agents/base.ts +145 -0
  13. package/src/core/agents/claude-code/adapter.ts +451 -0
  14. package/src/core/agents/claude-code/claude-agent.ts +235 -0
  15. package/src/core/agents/claude-code/generator-session.ts +344 -0
  16. package/src/core/agents/claude-code/options.ts +161 -0
  17. package/src/core/agents/claude-code/session-manager.ts +159 -0
  18. package/src/core/agents/codex-app-server/adapter.ts +214 -0
  19. package/src/core/agents/codex-app-server/client.ts +221 -0
  20. package/src/core/agents/codex-app-server/codex-app-server-agent.ts +385 -0
  21. package/src/core/agents/codex-items.ts +122 -0
  22. package/src/core/agents/codex-sdk/adapter.ts +204 -0
  23. package/src/core/agents/codex-sdk/codex-sdk-agent.ts +236 -0
  24. package/src/core/agents/config-fingerprint.ts +19 -0
  25. package/src/core/agents/error-classifier.ts +68 -0
  26. package/src/core/agents/registry.ts +40 -0
  27. package/src/core/agents/session-store.ts +72 -0
  28. package/src/core/agents/tool-meta.ts +68 -0
  29. package/src/core/agents/types.ts +54 -0
  30. package/src/core/index.ts +114 -0
  31. package/src/core/presets.ts +78 -0
  32. package/src/core/provision/extract.ts +31 -0
  33. package/src/core/provision/index.ts +10 -0
  34. package/src/core/provision/npm.ts +114 -0
  35. package/src/core/provision/pins.ts +51 -0
  36. package/src/core/provision/platform.ts +73 -0
  37. package/src/core/provision/provisioner.ts +478 -0
  38. package/src/core/proxy/anthropic-proxy.ts +69 -0
  39. package/src/core/proxy/api-key-store.ts +34 -0
  40. package/src/core/proxy/index.ts +7 -0
  41. package/src/core/runtime/agent-runtime.ts +363 -0
  42. package/src/core/runtime/event-processor.ts +218 -0
  43. package/src/core/runtime/event-sink.ts +37 -0
  44. package/src/core/utils/errors.ts +41 -0
  45. package/src/index.ts +4 -0
  46. package/src/protocol/async-queue.ts +68 -0
  47. package/src/protocol/config.ts +100 -0
  48. package/src/protocol/factories.ts +125 -0
  49. package/src/protocol/harness.ts +50 -0
  50. package/src/protocol/ids.ts +53 -0
  51. package/src/protocol/index.ts +16 -0
  52. package/src/protocol/lifecycle.ts +309 -0
  53. package/src/protocol/models.ts +45 -0
  54. package/src/protocol/part-input.ts +58 -0
  55. package/src/protocol/parts.ts +60 -0
  56. package/src/protocol/thinking.ts +32 -0
  57. package/src/protocol/tokens.ts +39 -0
  58. package/src/protocol/tool-state.ts +89 -0
  59. package/src/protocol/wire.ts +313 -0
  60. package/src/server/acp/binding.ts +163 -0
  61. package/src/server/acp/translate.ts +160 -0
  62. package/src/server/agent-server.ts +357 -0
  63. package/src/server/bin.ts +174 -0
  64. package/src/server/index.ts +24 -0
  65. package/src/server/install.ts +51 -0
  66. package/src/server/session-log.ts +66 -0
  67. package/src/server/transports.ts +149 -0
@@ -0,0 +1,478 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ realpathSync,
10
+ } from "node:fs";
11
+ import { chmod, mkdir, readdir, rename, rm } from "node:fs/promises";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { CliNotFoundError, CliProvisionError } from "../utils/errors.ts";
15
+ import { extractTarGz } from "./extract.ts";
16
+ import { DEFAULT_NPM_REGISTRY, downloadWithIntegrity, fetchDist } from "./npm.ts";
17
+ import { type CliTool, DEFAULT_PINS } from "./pins.ts";
18
+ import {
19
+ type PlatformInfo,
20
+ claudePackageSuffix,
21
+ codexPackageSuffix,
22
+ detectPlatform,
23
+ findOnPath,
24
+ } from "./platform.ts";
25
+
26
+ /**
27
+ * How CLIs are obtained (see DESIGN/README "CLI provisioning"):
28
+ * - `auto` (default): explicit override → host install → managed cache →
29
+ * download the pinned build. Zero-config everywhere, self-healing in bare
30
+ * sandboxes, no download when the machine already has the CLI.
31
+ * - `pinned`: override → managed cache → download. Never trusts host installs
32
+ * — every process in a fleet runs the exact tested CLI build.
33
+ * - `system`: override → host install only. Never downloads (air-gapped /
34
+ * "my machine, my CLIs").
35
+ */
36
+ export type ProvisionMode = "auto" | "pinned" | "system";
37
+
38
+ export interface ProvisionOptions {
39
+ /** Defaults to `$AGENT_SERVER_PROVISION`, then `auto`. */
40
+ mode?: ProvisionMode;
41
+ /**
42
+ * Where managed CLIs live. Defaults to `$AGENT_SERVER_CACHE_DIR`, then
43
+ * `$XDG_CACHE_HOME/agent-server/cli`, then `~/.cache/agent-server/cli`.
44
+ */
45
+ cacheDir?: string;
46
+ /** npm registry base URL. Defaults to `$AGENT_SERVER_NPM_REGISTRY`, then npmjs. */
47
+ registry?: string;
48
+ /** Version pins overriding this release's defaults (see `DEFAULT_PINS`). */
49
+ pins?: Partial<Record<CliTool, string>>;
50
+ /**
51
+ * Explicit CLI paths — the operator escape hatch. Defaults to
52
+ * `$CLAUDE_CLI_PATH` / `$CODEX_CLI_PATH`. Never sourced from the wire:
53
+ * letting a remote client pick the spawned executable would be RCE.
54
+ */
55
+ overrides?: Partial<Record<CliTool, string>>;
56
+ /** Progress/diagnostic lines. Defaults to stderr. */
57
+ log?: (line: string) => void;
58
+ }
59
+
60
+ /** Where a resolved CLI came from — logged once so runs are diagnosable. */
61
+ export interface ResolvedCli {
62
+ path: string;
63
+ source: "override" | "host" | "cache" | "downloaded";
64
+ version?: string;
65
+ }
66
+
67
+ /** Process/machine seams, injectable so the policy is testable offline. */
68
+ export interface ProvisionerDeps {
69
+ fetch?: typeof fetch;
70
+ platform?: PlatformInfo;
71
+ env?: Record<string, string | undefined>;
72
+ extract?: (tgzPath: string, destDir: string) => Promise<void>;
73
+ /** Replace host-install discovery (SDK sibling package / PATH scan). */
74
+ hostResolve?: (tool: CliTool) => ResolvedCli | undefined;
75
+ }
76
+
77
+ const OVERRIDE_ENV: Record<CliTool, string> = {
78
+ claude: "CLAUDE_CLI_PATH",
79
+ codex: "CODEX_CLI_PATH",
80
+ };
81
+
82
+ const MODES: readonly ProvisionMode[] = ["auto", "pinned", "system"];
83
+
84
+ /** What to download for one tool on one platform. */
85
+ interface ToolPackage {
86
+ name: string;
87
+ /** Exact version string the registry knows (codex encodes platform here). */
88
+ registryVersion: string;
89
+ /** Human/tool version (cache directory key). */
90
+ version: string;
91
+ suffix: string;
92
+ }
93
+
94
+ function defaultLog(line: string): void {
95
+ console.error(`[agent-server] ${line}`);
96
+ }
97
+
98
+ /**
99
+ * Locate an installed package by walking `node_modules` upward from `fromDir`,
100
+ * returning its real directory. Deliberately not `require.resolve`: ESM-only
101
+ * packages (codex-sdk) have no require condition and none of these packages
102
+ * export `./package.json`, so exports maps would veto perfectly good installs.
103
+ * The realpath matters under isolated stores (bun): a package's own deps are
104
+ * siblings of its *store* location, not of the symlink consumers see.
105
+ */
106
+ function findPackageDir(name: string, fromDir: string): string | undefined {
107
+ let dir = fromDir;
108
+ while (true) {
109
+ const candidate = join(dir, "node_modules", ...name.split("/"));
110
+ if (existsSync(join(candidate, "package.json"))) {
111
+ try {
112
+ return realpathSync(candidate);
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+ const parent = dirname(dir);
118
+ if (parent === dir) return undefined;
119
+ dir = parent;
120
+ }
121
+ }
122
+
123
+ /** Where this package lives on disk — the walk anchor. Virtual inside compiled
124
+ * binaries ($bunfs), where every walk misses and pins take over. NOTE: this
125
+ * must not mention `import.meta` — consumers bundle these sources to CJS,
126
+ * where that token is a parse error (and older bundlers don't rewrite it). */
127
+ function moduleDir(): string {
128
+ // CJS bundle output: anchor at the bundle location.
129
+ if (typeof __dirname !== "undefined") return __dirname;
130
+ try {
131
+ // Bun/ESM: resolve our own manifest through the module graph (Bun
132
+ // provides `require` in ESM; "./package.json" is an explicit export).
133
+ return dirname(require.resolve("@zvada/agent-server/package.json"));
134
+ } catch {
135
+ return process.cwd();
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Resolves the native CLIs the harnesses spawn (`claude`, `codex`) and — in
141
+ * managed modes — downloads the pinned builds from the npm registry into a
142
+ * local cache, verified against the registry's sha512 integrity and installed
143
+ * atomically (staging + rename), so concurrent processes converge on one copy.
144
+ *
145
+ * One instance per runtime; `resolve` is memoized per tool (a failure clears
146
+ * the memo so a transient network error doesn't poison the process).
147
+ */
148
+ export class CliProvisioner {
149
+ private readonly mode: ProvisionMode;
150
+ private readonly cacheDir: string;
151
+ private readonly registry: string;
152
+ /** Explicit pins only — see `versionFor` for the per-tool default chain. */
153
+ private readonly pins: Partial<Record<CliTool, string>>;
154
+ private readonly overrides: Partial<Record<CliTool, string>>;
155
+ private readonly log: (line: string) => void;
156
+ private readonly fetch: typeof fetch;
157
+ private readonly platform: PlatformInfo;
158
+ private readonly env: Record<string, string | undefined>;
159
+ private readonly extract: (tgzPath: string, destDir: string) => Promise<void>;
160
+ private readonly hostResolve: (tool: CliTool) => ResolvedCli | undefined;
161
+ private readonly memo = new Map<CliTool, Promise<ResolvedCli | undefined>>();
162
+ private cacheRootTrusted = false;
163
+
164
+ constructor(options: ProvisionOptions = {}, deps: ProvisionerDeps = {}) {
165
+ this.env = deps.env ?? process.env;
166
+ this.mode = options.mode ?? this.modeFromEnv();
167
+ this.cacheDir =
168
+ options.cacheDir ??
169
+ this.env.AGENT_SERVER_CACHE_DIR ??
170
+ join(this.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "agent-server", "cli");
171
+ this.registry = options.registry ?? this.env.AGENT_SERVER_NPM_REGISTRY ?? DEFAULT_NPM_REGISTRY;
172
+ this.pins = options.pins ?? {};
173
+ this.overrides = options.overrides ?? {};
174
+ this.log = options.log ?? defaultLog;
175
+ this.fetch = deps.fetch ?? fetch;
176
+ this.platform = deps.platform ?? detectPlatform();
177
+ this.extract = deps.extract ?? extractTarGz;
178
+ this.hostResolve = deps.hostResolve ?? ((tool) => this.defaultHostResolve(tool));
179
+ }
180
+
181
+ /**
182
+ * Resolve a CLI per the provisioning mode. `undefined` (claude in `system`
183
+ * mode only) means "let the harness's own resolution speak" — the Claude SDK
184
+ * finds its sibling platform package and has the clearest failure message.
185
+ */
186
+ resolve(tool: CliTool): Promise<ResolvedCli | undefined> {
187
+ const memoized = this.memo.get(tool);
188
+ if (memoized) return memoized;
189
+ const pending = this.doResolve(tool).then((resolved) => {
190
+ if (resolved) {
191
+ this.log(`${tool} CLI: ${resolved.path} (${resolved.source}${withVersion(resolved)})`);
192
+ }
193
+ return resolved;
194
+ });
195
+ this.memo.set(tool, pending);
196
+ pending.catch(() => this.memo.delete(tool));
197
+ return pending;
198
+ }
199
+
200
+ /** Warm the managed cache with the pinned build (the `install` command). */
201
+ async install(tool: CliTool): Promise<ResolvedCli> {
202
+ return this.ensureInstalled(tool);
203
+ }
204
+
205
+ private async doResolve(tool: CliTool): Promise<ResolvedCli | undefined> {
206
+ const override = this.overrides[tool] ?? this.env[OVERRIDE_ENV[tool]];
207
+ if (override) {
208
+ if (!existsSync(override)) {
209
+ throw new CliProvisionError(
210
+ `${tool} CLI override points to a missing file: ${override} ` +
211
+ `(from ${this.overrides[tool] ? "options.overrides" : `$${OVERRIDE_ENV[tool]}`})`,
212
+ );
213
+ }
214
+ return { path: override, source: "override" };
215
+ }
216
+
217
+ if (this.mode !== "pinned") {
218
+ const host = this.hostResolve(tool);
219
+ if (host) return host;
220
+ if (this.mode === "system") {
221
+ if (tool === "codex") {
222
+ throw new CliNotFoundError(
223
+ "codex",
224
+ `install it, set $${OVERRIDE_ENV.codex}, or use provision mode "auto"`,
225
+ );
226
+ }
227
+ return undefined;
228
+ }
229
+ }
230
+
231
+ return this.ensureInstalled(tool);
232
+ }
233
+
234
+ /**
235
+ * Host install discovery. Both tools prefer the JS SDK's own vendored
236
+ * platform package (the lockfile-paired build the SDK would spawn itself);
237
+ * codex additionally falls back to PATH for machines that installed the CLI
238
+ * system-wide without the SDK (the app-server harness needs no SDK).
239
+ */
240
+ private defaultHostResolve(tool: CliTool): ResolvedCli | undefined {
241
+ if (tool === "codex") {
242
+ const vendored = this.codexVendoredOnDisk();
243
+ if (vendored) return vendored;
244
+ const path = findOnPath("codex", this.env, this.platform.platform);
245
+ return path ? { path, source: "host" } : undefined;
246
+ }
247
+ const sdk = this.claudeSdkOnDisk();
248
+ if (!sdk) return undefined;
249
+ const suffix = claudePackageSuffix(this.platform);
250
+ const platformDir = findPackageDir(`@anthropic-ai/claude-agent-sdk-${suffix}`, sdk.dir);
251
+ if (!platformDir) return undefined;
252
+ const path = join(platformDir, this.claudeBinaryName(sdk.dir, suffix));
253
+ return existsSync(path) ? { path, source: "host", version: sdk.version } : undefined;
254
+ }
255
+
256
+ /**
257
+ * The codex build vendored by `@openai/codex-sdk` (its `@openai/codex`
258
+ * dependency ships `vendor/<target>/bin/codex`) — the same two-hop
259
+ * resolution the SDK's own `findCodexPath` performs, so managed resolution
260
+ * never shadows the pair the SDK was published against.
261
+ */
262
+ private codexVendoredOnDisk(): ResolvedCli | undefined {
263
+ try {
264
+ const sdkDir = findPackageDir("@openai/codex-sdk", moduleDir());
265
+ if (!sdkDir) return undefined;
266
+ const codexDir = findPackageDir("@openai/codex", sdkDir);
267
+ if (!codexDir) return undefined;
268
+ const suffix = codexPackageSuffix(this.platform);
269
+ const platformDir = findPackageDir(`@openai/codex-${suffix}`, codexDir);
270
+ if (!platformDir) return undefined;
271
+ const vendorRoot = join(platformDir, "vendor");
272
+ for (const entry of readdirSync(vendorRoot)) {
273
+ const path = this.binaryIn("codex", join(vendorRoot, entry));
274
+ if (path) {
275
+ const raw = (
276
+ JSON.parse(readFileSync(join(platformDir, "package.json"), "utf8")) as {
277
+ version?: string;
278
+ }
279
+ ).version;
280
+ const version = raw?.replace(`-${suffix}`, "");
281
+ return { path, source: "host", ...(version ? { version } : {}) };
282
+ }
283
+ }
284
+ return undefined;
285
+ } catch {
286
+ return undefined;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * The installed Claude SDK, when resolvable on disk. Its version drives both
292
+ * host resolution and the download pin, so the JS ↔ native-CLI pair can
293
+ * never skew. Unresolvable inside compiled binaries — pins take over there.
294
+ */
295
+ private claudeSdkOnDisk(): { dir: string; version?: string } | undefined {
296
+ try {
297
+ const dir = findPackageDir("@anthropic-ai/claude-agent-sdk", moduleDir());
298
+ if (!dir) return undefined;
299
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
300
+ version?: string;
301
+ };
302
+ return { dir, version: pkg.version };
303
+ } catch {
304
+ return undefined;
305
+ }
306
+ }
307
+
308
+ /** Binary filename from the SDK's manifest (`claude` / `claude.exe`). */
309
+ private claudeBinaryName(sdkDir: string, suffix: string): string {
310
+ try {
311
+ const manifest = JSON.parse(readFileSync(join(sdkDir, "manifest.json"), "utf8")) as {
312
+ platforms?: Record<string, { binary?: string }>;
313
+ };
314
+ return manifest.platforms?.[suffix]?.binary ?? "claude";
315
+ } catch {
316
+ return "claude";
317
+ }
318
+ }
319
+
320
+ /**
321
+ * The version to download: an explicit pin always wins; claude then prefers
322
+ * the on-disk SDK's version (the pair that actually runs) over the release
323
+ * default, which only decides inside compiled binaries.
324
+ *
325
+ * Deliberately mode-independent: even `pinned` follows the installed SDK
326
+ * for claude, because "deterministic" means the tested JS ↔ CLI *pair* —
327
+ * downloading the release-default CLI under a newer installed SDK would
328
+ * manufacture exactly the skew this module exists to prevent. Operators
329
+ * who want byte-identical fleets pin explicitly (and align the SDK).
330
+ */
331
+ private versionFor(tool: CliTool): string {
332
+ const explicit = this.pins[tool];
333
+ if (explicit) return explicit;
334
+ if (tool === "claude") return this.claudeSdkOnDisk()?.version ?? DEFAULT_PINS.claude;
335
+ return DEFAULT_PINS.codex;
336
+ }
337
+
338
+ private packageFor(tool: CliTool): ToolPackage {
339
+ const version = this.versionFor(tool);
340
+ if (tool === "claude") {
341
+ const suffix = claudePackageSuffix(this.platform);
342
+ return {
343
+ name: `@anthropic-ai/claude-agent-sdk-${suffix}`,
344
+ registryVersion: version,
345
+ version,
346
+ suffix,
347
+ };
348
+ }
349
+ const suffix = codexPackageSuffix(this.platform);
350
+ return { name: "@openai/codex", registryVersion: `${version}-${suffix}`, version, suffix };
351
+ }
352
+
353
+ /**
354
+ * First binary candidate under an installed directory. Regular files only
355
+ * (lstat): a symlink here could redirect the spawn — and the post-install
356
+ * `chmod` — at a path outside the tree we verified.
357
+ */
358
+ private binaryIn(tool: CliTool, dir: string): string | undefined {
359
+ const candidates =
360
+ tool === "claude"
361
+ ? ["claude", "claude.exe"]
362
+ : [join("bin", "codex"), join("bin", "codex.exe")];
363
+ for (const candidate of candidates) {
364
+ const path = join(dir, candidate);
365
+ try {
366
+ if (lstatSync(path).isFile()) return path;
367
+ } catch {
368
+ // not here — try the next candidate
369
+ }
370
+ }
371
+ return undefined;
372
+ }
373
+
374
+ /**
375
+ * Trust gate on the cache root, mirroring the Claude SDK's own temp-dir
376
+ * defense: install paths are predictable (public versions + platform), so
377
+ * under a shared/pre-created directory another local user could plant the
378
+ * binary we spawn. Create `0o700`, require our own uid, and tighten a
379
+ * looser mode (earlier runs created it with the umask). POSIX-only — on
380
+ * Windows the default cache locations are already per-user.
381
+ */
382
+ private ensureTrustedCacheRoot(): void {
383
+ if (this.cacheRootTrusted) return;
384
+ mkdirSync(this.cacheDir, { recursive: true, mode: 0o700 });
385
+ if (typeof process.getuid === "function") {
386
+ const stat = lstatSync(this.cacheDir);
387
+ if (!stat.isDirectory()) {
388
+ throw new CliProvisionError(
389
+ `Cache dir ${this.cacheDir} is not a directory (planted symlink?) — refusing to use it`,
390
+ );
391
+ }
392
+ if (stat.uid !== process.getuid()) {
393
+ throw new CliProvisionError(
394
+ `Cache dir ${this.cacheDir} is owned by uid ${stat.uid}, expected ${process.getuid()} — refusing to use it`,
395
+ );
396
+ }
397
+ if ((stat.mode & 0o777) !== 0o700) chmodSync(this.cacheDir, 0o700);
398
+ }
399
+ this.cacheRootTrusted = true;
400
+ }
401
+
402
+ /** The directory inside an extracted tarball that becomes the install. */
403
+ private async payloadDir(tool: CliTool, extractedRoot: string): Promise<string> {
404
+ if (tool === "claude") {
405
+ const dir = join(extractedRoot, "package");
406
+ if (existsSync(dir)) return dir;
407
+ throw new CliProvisionError("claude tarball has no package/ directory");
408
+ }
409
+ // codex vendors per-target trees: package/vendor/<rust-triple>/bin/codex.
410
+ // Install the whole tree — the CLI expects its vendored rg/zsh siblings.
411
+ const vendor = join(extractedRoot, "package", "vendor");
412
+ const entries = existsSync(vendor) ? await readdir(vendor) : [];
413
+ for (const entry of entries) {
414
+ const dir = join(vendor, entry);
415
+ if (this.binaryIn(tool, dir)) return dir;
416
+ }
417
+ throw new CliProvisionError("codex tarball has no vendor/<target>/bin/codex");
418
+ }
419
+
420
+ private async ensureInstalled(tool: CliTool): Promise<ResolvedCli> {
421
+ this.ensureTrustedCacheRoot();
422
+ const pkg = this.packageFor(tool);
423
+ const dir = join(this.cacheDir, tool, `${pkg.version}-${pkg.suffix}`);
424
+ const cached = this.binaryIn(tool, dir);
425
+ if (cached) return { path: cached, source: "cache", version: pkg.version };
426
+
427
+ this.log(`downloading ${tool} ${pkg.version} (${pkg.suffix}) from ${this.registry}…`);
428
+ const startedAt = Date.now();
429
+ const staging = join(
430
+ this.cacheDir,
431
+ ".tmp",
432
+ `${tool}-${pkg.version}-${process.pid}-${randomBytes(4).toString("hex")}`,
433
+ );
434
+ await mkdir(staging, { recursive: true });
435
+ try {
436
+ const dist = await fetchDist(this.registry, pkg.name, pkg.registryVersion, this.fetch);
437
+ const tgz = join(staging, "package.tgz");
438
+ const bytes = await downloadWithIntegrity(dist.tarball, tgz, dist.integrity, this.fetch);
439
+ const extracted = join(staging, "x");
440
+ await mkdir(extracted);
441
+ await this.extract(tgz, extracted);
442
+ const payload = await this.payloadDir(tool, extracted);
443
+
444
+ await mkdir(dirname(dir), { recursive: true });
445
+ try {
446
+ await rename(payload, dir);
447
+ } catch (error) {
448
+ // Lost an install race with another process. The directory is
449
+ // version-keyed, so the winner's tree is what we would have written.
450
+ if (!this.binaryIn(tool, dir)) throw error;
451
+ }
452
+ const path = this.binaryIn(tool, dir);
453
+ if (!path) {
454
+ throw new CliProvisionError(`downloaded ${pkg.name}@${pkg.registryVersion} has no binary`);
455
+ }
456
+ await chmod(path, 0o755);
457
+ const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
458
+ const mb = (bytes / 1024 / 1024).toFixed(0);
459
+ this.log(`installed ${tool} ${pkg.version} → ${dir} (${mb} MB in ${seconds}s)`);
460
+ return { path, source: "downloaded", version: pkg.version };
461
+ } finally {
462
+ await rm(staging, { recursive: true, force: true });
463
+ }
464
+ }
465
+
466
+ private modeFromEnv(): ProvisionMode {
467
+ const raw = this.env.AGENT_SERVER_PROVISION;
468
+ if (raw === undefined || raw === "") return "auto";
469
+ if ((MODES as readonly string[]).includes(raw)) return raw as ProvisionMode;
470
+ throw new CliProvisionError(
471
+ `Invalid $AGENT_SERVER_PROVISION: ${raw} (expected ${MODES.join(" | ")})`,
472
+ );
473
+ }
474
+ }
475
+
476
+ function withVersion(resolved: ResolvedCli): string {
477
+ return resolved.version ? ` ${resolved.version}` : "";
478
+ }
@@ -0,0 +1,69 @@
1
+ import { type ApiKeyStore, apiKeyStore } from "./api-key-store.ts";
2
+
3
+ /** Placeholder key handed to the agent subprocess; swapped for the real one here. */
4
+ export const PROXY_PLACEHOLDER_KEY = "sk-proxy-managed";
5
+
6
+ export interface AnthropicProxyOptions {
7
+ /** Store to resolve real credentials from. Defaults to the shared `apiKeyStore`. */
8
+ store?: ApiKeyStore;
9
+ /** Path prefix the proxy is mounted under. Default `/proxy/anthropic`. */
10
+ pathPrefix?: string;
11
+ }
12
+
13
+ /**
14
+ * A framework-agnostic, Web-standard `(Request) => Promise<Response>` handler
15
+ * implementing the BYOK Anthropic proxy. Mount it in any server (Bun, Hono,
16
+ * Workers, Node 18+). Requests come in at `{prefix}/{sessionId}/{...path}`; the
17
+ * handler looks up the real key for `sessionId`, swaps the `x-api-key` header,
18
+ * and forwards to the configured upstream — streaming the response through
19
+ * unchanged (SSE included).
20
+ *
21
+ * Point the agent at it with:
22
+ * ANTHROPIC_BASE_URL=http://localhost:PORT/proxy/anthropic/<sessionId>
23
+ * ANTHROPIC_API_KEY=sk-proxy-managed
24
+ * and register the real key via `apiKeyStore.set(sessionId, realKey)`.
25
+ */
26
+ export function createAnthropicProxy(
27
+ options: AnthropicProxyOptions = {},
28
+ ): (request: Request) => Promise<Response> {
29
+ const store = options.store ?? apiKeyStore;
30
+ const prefix = options.pathPrefix ?? "/proxy/anthropic";
31
+
32
+ return async function handle(request: Request): Promise<Response> {
33
+ const url = new URL(request.url);
34
+ const tail = url.pathname.startsWith(prefix)
35
+ ? url.pathname.slice(prefix.length).replace(/^\//, "")
36
+ : "";
37
+ const slash = tail.indexOf("/");
38
+ const sessionId = slash === -1 ? tail : tail.slice(0, slash);
39
+ const subPath = slash === -1 ? "" : tail.slice(slash);
40
+
41
+ const entry = sessionId ? store.get(sessionId) : undefined;
42
+ if (!entry) {
43
+ return new Response(
44
+ JSON.stringify({
45
+ type: "error",
46
+ error: { type: "authentication_error", message: "unknown proxy session" },
47
+ }),
48
+ { status: 401, headers: { "content-type": "application/json" } },
49
+ );
50
+ }
51
+
52
+ const headers = new Headers(request.headers);
53
+ headers.set("x-api-key", entry.apiKey);
54
+ headers.delete("authorization");
55
+ headers.delete("host");
56
+ headers.delete("content-length");
57
+
58
+ const init: RequestInit & { duplex?: "half" } = {
59
+ method: request.method,
60
+ headers,
61
+ body: request.body,
62
+ redirect: "manual",
63
+ };
64
+ // Required when forwarding a streaming request body on fetch.
65
+ if (request.body) init.duplex = "half";
66
+
67
+ return fetch(`${entry.upstreamBaseUrl}${subPath}${url.search}`, init);
68
+ };
69
+ }
@@ -0,0 +1,34 @@
1
+ /** A real upstream key + base URL, keyed by an opaque proxy session id. */
2
+ export interface ApiKeyEntry {
3
+ apiKey: string;
4
+ upstreamBaseUrl: string;
5
+ }
6
+
7
+ /**
8
+ * In-memory map of proxy-session → real credentials. The agent subprocess is
9
+ * given a placeholder key and pointed at the local proxy; the proxy swaps in the
10
+ * real key from this store before forwarding upstream, so the key never lives in
11
+ * the subprocess environment. (BYOK pattern from the echo/agnt sidecars.)
12
+ */
13
+ export class ApiKeyStore {
14
+ private readonly entries = new Map<string, ApiKeyEntry>();
15
+
16
+ set(sessionId: string, apiKey: string, upstreamBaseUrl = "https://api.anthropic.com"): void {
17
+ this.entries.set(sessionId, { apiKey, upstreamBaseUrl });
18
+ }
19
+
20
+ get(sessionId: string): ApiKeyEntry | undefined {
21
+ return this.entries.get(sessionId);
22
+ }
23
+
24
+ delete(sessionId: string): void {
25
+ this.entries.delete(sessionId);
26
+ }
27
+
28
+ clear(): void {
29
+ this.entries.clear();
30
+ }
31
+ }
32
+
33
+ /** Shared default store used by `createAnthropicProxy` when none is supplied. */
34
+ export const apiKeyStore = new ApiKeyStore();
@@ -0,0 +1,7 @@
1
+ // @agent-server/core/proxy — optional BYOK Anthropic proxy building block.
2
+ export { type ApiKeyEntry, ApiKeyStore, apiKeyStore } from "./api-key-store.ts";
3
+ export {
4
+ type AnthropicProxyOptions,
5
+ createAnthropicProxy,
6
+ PROXY_PLACEHOLDER_KEY,
7
+ } from "./anthropic-proxy.ts";