@zvada/agent-server 0.3.0 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvada/agent-server",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Harness-agnostic agent execution engine: run Claude Code, Codex (SDK/CLI + app-server), and any ACP agent behind one interface with a normalized event stream, multi-turn sessions, and resume. Root export is the wire contract; /core, /server, /client are the seats.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,7 +25,13 @@
25
25
  "engines": {
26
26
  "bun": ">=1.2.0"
27
27
  },
28
- "files": ["src", "docs", "README.md", "CHANGELOG.md", "LICENSE"],
28
+ "files": [
29
+ "src",
30
+ "docs",
31
+ "README.md",
32
+ "CHANGELOG.md",
33
+ "LICENSE"
34
+ ],
29
35
  "publishConfig": {
30
36
  "access": "public"
31
37
  },
@@ -1,6 +1,9 @@
1
1
  import { type ChildProcess, spawn as nodeSpawn } from "node:child_process";
2
2
  import { StringDecoder } from "node:string_decoder";
3
3
 
4
+ /** Cap the retained stderr tail — enough for the CLI's error, never a log flood. */
5
+ const STDERR_TAIL_MAX = 2048;
6
+
4
7
  /** A JSON-RPC notification (server → client, no id). */
5
8
  export interface CodexNotification {
6
9
  method: string;
@@ -54,6 +57,11 @@ export class CodexAppServerClient {
54
57
  private readonly closeHandlers = new Set<(error?: Error) => void>();
55
58
  private requestHandler?: ServerRequestHandler;
56
59
  private exited = false;
60
+ /** Bounded rolling tail of the subprocess's stderr — folded into the exit
61
+ * error so failures carry the CLI's own explanation. */
62
+ private stderrTail = "";
63
+ /** Chunk-boundary-safe UTF-8 decoding for the stderr tail; reset per spawn. */
64
+ private stderrDecoder = new StringDecoder("utf8");
57
65
 
58
66
  constructor(private readonly opts: CodexAppServerClientOptions = {}) {}
59
67
 
@@ -140,14 +148,32 @@ export class CodexAppServerClient {
140
148
  stdio: ["pipe", "pipe", "pipe"],
141
149
  });
142
150
  this.proc = proc;
151
+ this.stderrTail = "";
152
+ this.stderrDecoder = new StringDecoder("utf8");
143
153
  proc.stdout?.on("data", (chunk: Buffer) => this.onStdout(chunk));
144
154
  proc.stdin?.on("error", (err) => this.onExit(err));
145
155
  proc.stdout?.on("error", (err) => this.onExit(err));
146
- proc.stderr?.on("data", () => {});
156
+ // Keep a bounded tail of stderr: it is the ONLY place the CLI explains
157
+ // itself (a missing/expired login prints there and exits 1). Discarding
158
+ // it reduced every failure to "exited (code=1)" — indistinguishable
159
+ // from a crash for the integrator staring at the turn error. Decoded
160
+ // via StringDecoder so a UTF-8 sequence split across chunks survives.
161
+ proc.stderr?.on("data", (chunk: Buffer) => {
162
+ this.stderrTail = (this.stderrTail + this.stderrDecoder.write(chunk)).slice(-STDERR_TAIL_MAX);
163
+ });
147
164
  proc.on("error", (err) => this.onExit(err));
148
- proc.on("exit", (code, signal) =>
149
- this.onExit(new Error(`codex app-server exited (code=${code} signal=${signal})`)),
150
- );
165
+ // Build the exit error on CLOSE, not exit: `exit` can fire before the
166
+ // stderr stream flushes its final data, and the final flush is usually
167
+ // the line that says WHY. `close` is guaranteed after both.
168
+ proc.on("close", (code, signal) => {
169
+ this.stderrTail = (this.stderrTail + this.stderrDecoder.end()).slice(-STDERR_TAIL_MAX);
170
+ const tail = this.stderrTail.trim().replace(/\s+/g, " ");
171
+ this.onExit(
172
+ new Error(
173
+ `codex app-server exited (code=${code} signal=${signal})${tail ? `: ${tail}` : ""}`,
174
+ ),
175
+ );
176
+ });
151
177
  }
152
178
 
153
179
  private onStdout(chunk: Buffer): void {
@@ -4,6 +4,26 @@ import { type ApiKeyStore, apiKeyStore } from "./api-key-store.ts";
4
4
  /** Placeholder key handed to the agent subprocess; swapped for the real one here. */
5
5
  export const PROXY_PLACEHOLDER_KEY = "sk-proxy-managed";
6
6
 
7
+ /**
8
+ * The `anthropic-beta` capability that must accompany OAuth bearer traffic.
9
+ * Anthropic's gateway guidance: proxies carrying subscription-OAuth requests
10
+ * must advertise this capability or the upstream rejects the bearer token.
11
+ */
12
+ export const ANTHROPIC_OAUTH_BETA = "oauth-2025-04-20";
13
+
14
+ /** Append a capability to `anthropic-beta` without clobbering existing ones. */
15
+ function appendAnthropicBeta(headers: Headers, capability: string): void {
16
+ const existing = headers.get("anthropic-beta");
17
+ const values = existing
18
+ ? existing
19
+ .split(",")
20
+ .map((value) => value.trim())
21
+ .filter(Boolean)
22
+ : [];
23
+ if (!values.includes(capability)) values.push(capability);
24
+ headers.set("anthropic-beta", values.join(","));
25
+ }
26
+
7
27
  /** Minimal fetch shape (`globalThis.fetch` qualifies; injectable for tests/platforms). */
8
28
  export type FetchLike = (
9
29
  input: string | URL | Request,
@@ -68,10 +88,19 @@ export function createAnthropicProxy(
68
88
  }
69
89
 
70
90
  const headers = new Headers(request.headers);
71
- headers.set("x-api-key", entry.apiKey);
72
- headers.delete("authorization");
73
91
  headers.delete("host");
74
92
  headers.delete("content-length");
93
+ if (entry.kind === "oauth") {
94
+ // Subscription token: bearer auth + the OAuth capability. The
95
+ // x-api-key the CLI sent carries only the placeholder — it must go,
96
+ // or the upstream sees two conflicting credentials.
97
+ headers.delete("x-api-key");
98
+ headers.set("authorization", `Bearer ${entry.apiKey}`);
99
+ appendAnthropicBeta(headers, ANTHROPIC_OAUTH_BETA);
100
+ } else {
101
+ headers.set("x-api-key", entry.apiKey);
102
+ headers.delete("authorization");
103
+ }
75
104
 
76
105
  const init: RequestInit & { duplex?: "half" } = {
77
106
  method: request.method,
@@ -1,20 +1,52 @@
1
- /** A real upstream key + base URL, keyed by an opaque proxy session id. */
1
+ /** How a stored credential is presented to the upstream API. */
2
+ export type ApiCredentialKind = "api_key" | "oauth";
3
+
4
+ /** A real upstream credential + base URL, keyed by an opaque proxy session id. */
2
5
  export interface ApiKeyEntry {
3
6
  apiKey: string;
4
7
  upstreamBaseUrl: string;
8
+ /**
9
+ * `api_key` → sent as `x-api-key` (Anthropic API keys).
10
+ * `oauth` → sent as `Authorization: Bearer` with the OAuth capability
11
+ * advertised in `anthropic-beta` (Claude subscription tokens from
12
+ * `claude setup-token`, `sk-ant-oat01-…`).
13
+ */
14
+ kind: ApiCredentialKind;
15
+ }
16
+
17
+ export interface ApiKeyStoreSetOptions {
18
+ upstreamBaseUrl?: string;
19
+ kind?: ApiCredentialKind;
5
20
  }
6
21
 
7
22
  /**
8
23
  * In-memory map of proxy-session → real credentials. The agent subprocess is
9
24
  * 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.)
25
+ * real credential from this store before forwarding upstream, so it never lives
26
+ * in the subprocess environment. (BYOK pattern from the echo/agnt sidecars.)
27
+ * That no-exfiltration property is exactly why subscription OAuth tokens ride
28
+ * the same store instead of the child env: the child keeps the placeholder
29
+ * either way, only the upstream header shape differs.
12
30
  */
13
31
  export class ApiKeyStore {
14
32
  private readonly entries = new Map<string, ApiKeyEntry>();
15
33
 
16
- set(sessionId: string, apiKey: string, upstreamBaseUrl = "https://api.anthropic.com"): void {
17
- this.entries.set(sessionId, { apiKey, upstreamBaseUrl });
34
+ set(
35
+ sessionId: string,
36
+ apiKey: string,
37
+ // A plain string third argument (the base URL) remains supported: the
38
+ // pre-kind signature every existing sidecar call site uses.
39
+ optionsOrBaseUrl: string | ApiKeyStoreSetOptions = {},
40
+ ): void {
41
+ const options =
42
+ typeof optionsOrBaseUrl === "string"
43
+ ? { upstreamBaseUrl: optionsOrBaseUrl }
44
+ : optionsOrBaseUrl;
45
+ this.entries.set(sessionId, {
46
+ apiKey,
47
+ upstreamBaseUrl: options.upstreamBaseUrl ?? "https://api.anthropic.com",
48
+ kind: options.kind ?? "api_key",
49
+ });
18
50
  }
19
51
 
20
52
  get(sessionId: string): ApiKeyEntry | undefined {
@@ -1,6 +1,13 @@
1
1
  // @zvada/agent-server/core/proxy — optional BYOK Anthropic proxy building block.
2
- export { type ApiKeyEntry, ApiKeyStore, apiKeyStore } from "./api-key-store.ts";
3
2
  export {
3
+ type ApiCredentialKind,
4
+ type ApiKeyEntry,
5
+ ApiKeyStore,
6
+ type ApiKeyStoreSetOptions,
7
+ apiKeyStore,
8
+ } from "./api-key-store.ts";
9
+ export {
10
+ ANTHROPIC_OAUTH_BETA,
4
11
  type AnthropicProxyOptions,
5
12
  createAnthropicProxy,
6
13
  PROXY_PLACEHOLDER_KEY,