@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.
|
|
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": [
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
17
|
-
|
|
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 {
|
package/src/core/proxy/index.ts
CHANGED
|
@@ -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,
|