@dynamicagents/plugins 0.14.0 → 0.16.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 (141) hide show
  1. package/README.md +14 -14
  2. package/dist/claude-code/cards.d.ts +2 -0
  3. package/dist/claude-code/cards.js +11 -0
  4. package/dist/claude-code/config.d.ts +10 -83
  5. package/dist/claude-code/config.js +0 -2
  6. package/dist/claude-code/credentials.d.ts +32 -50
  7. package/dist/claude-code/credentials.js +115 -74
  8. package/dist/claude-code/daemon/index.d.ts +24 -0
  9. package/dist/claude-code/daemon/index.js +203 -0
  10. package/dist/claude-code/egress.d.ts +35 -117
  11. package/dist/claude-code/egress.js +80 -325
  12. package/dist/claude-code/events.d.ts +14 -17
  13. package/dist/claude-code/events.js +19 -15
  14. package/dist/claude-code/harness-wire.d.ts +45 -0
  15. package/dist/claude-code/harness-wire.js +47 -0
  16. package/dist/claude-code/index.d.ts +19 -29
  17. package/dist/claude-code/index.js +17 -25
  18. package/dist/claude-code/language-model.d.ts +19 -0
  19. package/dist/claude-code/language-model.js +71 -0
  20. package/dist/claude-code/model.d.ts +77 -70
  21. package/dist/claude-code/model.js +327 -327
  22. package/dist/claude-code/placeholder.d.ts +12 -0
  23. package/dist/claude-code/placeholder.js +12 -0
  24. package/dist/claude-code/spec.d.ts +4 -4
  25. package/dist/claude-code/spec.js +4 -4
  26. package/dist/computer/client.d.ts +88 -0
  27. package/dist/computer/client.js +6 -0
  28. package/dist/computer/context.d.ts +7 -7
  29. package/dist/computer/context.js +14 -18
  30. package/dist/computer/gate.d.ts +4 -23
  31. package/dist/computer/gate.js +1 -37
  32. package/dist/computer/index.d.ts +44 -43
  33. package/dist/computer/index.js +29 -50
  34. package/dist/computer/paths.d.ts +5 -27
  35. package/dist/computer/paths.js +7 -30
  36. package/dist/computer/proxy.d.ts +17 -31
  37. package/dist/computer/proxy.js +9 -44
  38. package/dist/computer/render.d.ts +3 -40
  39. package/dist/computer/render.js +3 -36
  40. package/dist/computer/sandbox.d.ts +55 -0
  41. package/dist/computer/sandbox.js +236 -0
  42. package/dist/computer/shell.d.ts +36 -0
  43. package/dist/computer/shell.js +41 -0
  44. package/dist/computer/tools-exec.js +3 -9
  45. package/dist/computer/tools-file.d.ts +1 -1
  46. package/dist/computer/tools-file.js +1 -1
  47. package/dist/{repo → repo-github}/checkout.d.ts +3 -4
  48. package/dist/{repo → repo-github}/checkout.js +3 -4
  49. package/dist/{repo → repo-github}/context.d.ts +14 -32
  50. package/dist/{repo → repo-github}/context.js +46 -81
  51. package/dist/{repo → repo-github}/index.d.ts +30 -13
  52. package/dist/{repo → repo-github}/index.js +25 -6
  53. package/dist/repo-github/mcp.d.ts +95 -0
  54. package/dist/repo-github/mcp.js +107 -0
  55. package/dist/repo-github/sandbox-egress.d.ts +68 -0
  56. package/dist/repo-github/sandbox-egress.js +272 -0
  57. package/dist/repo-github/sandbox-git.d.ts +30 -0
  58. package/dist/repo-github/sandbox-git.js +113 -0
  59. package/dist/{repo → repo-github}/tools-checks.js +43 -4
  60. package/dist/{repo → repo-github}/tools-clone.js +2 -21
  61. package/dist/{repo → repo-github}/tools-pr.d.ts +0 -1
  62. package/dist/{repo → repo-github}/tools-pr.js +85 -20
  63. package/dist/{repo → repo-github}/tools-review.d.ts +6 -0
  64. package/dist/{repo → repo-github}/tools-review.js +62 -176
  65. package/dist/{repo → repo-github}/url.d.ts +11 -0
  66. package/dist/{repo → repo-github}/url.js +12 -1
  67. package/dist/{workspace → sandbox}/advisory.d.ts +16 -35
  68. package/dist/{workspace → sandbox}/advisory.js +2 -29
  69. package/dist/sandbox/agent-exec.d.ts +31 -0
  70. package/dist/sandbox/agent-exec.js +62 -0
  71. package/dist/sandbox/ca-trust.d.ts +9 -0
  72. package/dist/sandbox/ca-trust.js +21 -0
  73. package/dist/sandbox/constants.d.ts +22 -0
  74. package/dist/sandbox/constants.js +22 -0
  75. package/dist/sandbox/daemon/composite.d.ts +9 -0
  76. package/dist/sandbox/daemon/composite.js +35 -0
  77. package/dist/sandbox/daemon/index.d.ts +47 -0
  78. package/dist/sandbox/daemon/index.js +116 -0
  79. package/dist/sandbox/egress.d.ts +81 -0
  80. package/dist/sandbox/egress.js +109 -0
  81. package/dist/sandbox/exec.d.ts +57 -0
  82. package/dist/sandbox/exec.js +52 -0
  83. package/dist/sandbox/format.d.ts +2 -0
  84. package/dist/sandbox/format.js +7 -0
  85. package/dist/sandbox/header.d.ts +50 -0
  86. package/dist/sandbox/header.js +94 -0
  87. package/dist/sandbox/index.d.ts +17 -0
  88. package/dist/sandbox/index.js +17 -0
  89. package/dist/{workspace/install.d.ts → sandbox/install-plan.d.ts} +2 -2
  90. package/dist/{workspace/install.js → sandbox/install-plan.js} +2 -2
  91. package/dist/sandbox/name.d.ts +27 -0
  92. package/dist/sandbox/name.js +32 -0
  93. package/dist/sandbox/object.d.ts +244 -0
  94. package/dist/sandbox/object.js +1139 -0
  95. package/dist/scratch/index.d.ts +1 -9
  96. package/dist/scratch/index.js +2 -18
  97. package/package.json +24 -20
  98. package/dist/claude-code/github.d.ts +0 -39
  99. package/dist/claude-code/github.js +0 -82
  100. package/dist/claude-code/run.d.ts +0 -491
  101. package/dist/claude-code/run.js +0 -913
  102. package/dist/claude-code/session.d.ts +0 -131
  103. package/dist/claude-code/session.js +0 -288
  104. package/dist/workspace/ca-trust.d.ts +0 -95
  105. package/dist/workspace/ca-trust.js +0 -312
  106. package/dist/workspace/container-deps.d.ts +0 -58
  107. package/dist/workspace/container-deps.js +0 -249
  108. package/dist/workspace/container-fault.d.ts +0 -38
  109. package/dist/workspace/container-fault.js +0 -73
  110. package/dist/workspace/deps-snapshot.d.ts +0 -110
  111. package/dist/workspace/deps-snapshot.js +0 -424
  112. package/dist/workspace/exec.d.ts +0 -94
  113. package/dist/workspace/exec.js +0 -153
  114. package/dist/workspace/format.d.ts +0 -36
  115. package/dist/workspace/format.js +0 -57
  116. package/dist/workspace/git-host.d.ts +0 -79
  117. package/dist/workspace/git-host.js +0 -226
  118. package/dist/workspace/git-identity.d.ts +0 -57
  119. package/dist/workspace/git-identity.js +0 -147
  120. package/dist/workspace/index.d.ts +0 -50
  121. package/dist/workspace/index.js +0 -58
  122. package/dist/workspace/install-job.d.ts +0 -170
  123. package/dist/workspace/install-job.js +0 -934
  124. package/dist/workspace/object.d.ts +0 -522
  125. package/dist/workspace/object.js +0 -1541
  126. package/dist/workspace/open.d.ts +0 -95
  127. package/dist/workspace/open.js +0 -63
  128. package/dist/workspace/read.d.ts +0 -16
  129. package/dist/workspace/read.js +0 -22
  130. package/dist/workspace/shell.d.ts +0 -45
  131. package/dist/workspace/shell.js +0 -80
  132. package/dist/workspace/sync.d.ts +0 -111
  133. package/dist/workspace/sync.js +0 -124
  134. package/dist/workspace/wake.d.ts +0 -21
  135. package/dist/workspace/wake.js +0 -10
  136. /package/dist/{repo → repo-github}/tools-checks.d.ts +0 -0
  137. /package/dist/{repo → repo-github}/tools-clone.d.ts +0 -0
  138. /package/dist/{repo → repo-github}/tools-worktree.d.ts +0 -0
  139. /package/dist/{repo → repo-github}/tools-worktree.js +0 -0
  140. /package/dist/{repo → repo-github}/tools-worktrees.d.ts +0 -0
  141. /package/dist/{repo → repo-github}/tools-worktrees.js +0 -0
@@ -1,327 +1,71 @@
1
- import { credentialPool, readRefusal } from "./credentials.js";
2
- import { GITHUB_HOSTS, githubAuthorization, githubError, presentsPlaceholder } from "./github.js";
1
+ import { ANTHROPIC_PLACEHOLDER_HOST } from "./harness-wire.js";
2
+ import { readRefusal, sharedPool } from "./credentials.js";
3
3
  /**
4
- * `@dynamicagents/plugins/claude-code` — the container's only way out.
4
+ * The Anthropic credential, swapped in on the Worker side of a sandbox.
5
5
  *
6
- * `egress: { mode: "http-gateway", gateway }` makes `computerd` intercept every
7
- * outbound request in the container and tunnel it back to the workspace Durable
8
- * Object, which reconstructs it against its original absolute URL and hands it
9
- * here. So this function sits on the **Worker side of the trust boundary** and
10
- * sees the whole of what the container tries to reach.
6
+ * The CLI holds a placeholder OAuth token, this sets the pool's lead as the
7
+ * Bearer, and a refusal that spends or rejects the credential moves the lead
8
+ * and tells the CLI to retry on the next one — `./credentials.ts` reads the
9
+ * refusal. A sandbox's egress entrypoint is stateless, so the pool lives in the
10
+ * sandbox the request came from, reached through `call.sandbox()` as a
11
+ * {@link CredentialLease}.
11
12
  *
12
- * Intercepting HTTPS means terminating it, which has a cost this file does not
13
- * pay and the container does: the runtime presents its own certificate, so an
14
- * image that has not installed the interception CA has no working HTTPS client
15
- * at all — not even `npm`. Nothing here can fix that from the Worker side; the
16
- * image's entrypoint has to, and the README's "Your image must trust the
17
- * interception CA" section is the whole of what that takes.
18
- *
19
- * That position is what makes three otherwise-hard things easy, and they are the
20
- * reason this file exists rather than a plain allowlist:
21
- *
22
- * 1. **The real credential never enters the container.** Claude Code is launched
23
- * with a placeholder; the swap happens here. A `postinstall` script in a
24
- * cloned repository can read every environment variable the container has and
25
- * still learn nothing — which is the repository's standing rule ("hand it the
26
- * action, not the credential") applied to a process nobody can constrain. A
27
- * GitHub token, when configured, is held the same way — see `./github.ts`.
28
- * 2. **Any restriction that is applied is total.** Not a policy the container
29
- * cooperates with; the only route out. Restriction is **off by default** —
30
- * see {@link EgressConfig.restrictToHosts} for why.
31
- * 3. **Exhaustion is seen, so it can be routed around.** This is the only place
32
- * Anthropic's actual response is visible, and therefore the only place that
33
- * can learn a subscription bucket is empty. See {@link EgressConfig.store}
34
- * and `credentials.ts`.
35
- *
36
- * ## What this deliberately does not do
37
- *
38
- * It does not meter, and a budget gate must not come back: an estimate of spend
39
- * is a guess about a bucket nobody can read, moves only once per run, and the
40
- * bucket announces the answer exactly — see the docblock in `credentials.ts`.
41
- *
42
- * And it does not, by default, **bound exfiltration**. The container holds the
43
- * checkout, and with no restriction configured it can send it anywhere. That is
44
- * a deliberate default rather than an oversight — the reasoning is on
45
- * {@link EgressConfig.restrictToHosts} — and what it means for a reader is that
46
- * the containment here is "the container holds no credential", full stop. Do not
47
- * read a host restriction that is switched off as a boundary that exists.
13
+ * The CLI reaches this over plain HTTP to a placeholder host the harness
14
+ * intercepts, so nothing in the container terminates TLS. The daemon points
15
+ * the CLI at it (`./daemon/index.ts`), after the deployment's own environment,
16
+ * so the rule sets no `env`.
48
17
  */
49
18
  /** Anthropic's API host — the one destination that gets a credential. */
50
19
  export const ANTHROPIC_HOST = "api.anthropic.com";
51
- /**
52
- * Hosts that name **this** side of the boundary, refused whatever the policy.
53
- *
54
- * The reason is a difference between the two egress modes that is easy to miss.
55
- * Under `mode: "direct"` the container's traffic leaves from the container's own
56
- * network position. Under `http-gateway` **the Worker makes the request**, so an
57
- * unrestricted policy hands the container the Worker's reach rather than its
58
- * own — and `computer.internal` is the loopback the intercept itself rides on.
59
- * Bouncing a request back into it is never a legitimate fetch, so it is refused
60
- * before any policy is consulted.
61
- *
62
- * A workspace that overrides `egressHost` on its `ContainerBackend` should add
63
- * that name here; the default is what the backend uses when nothing says
64
- * otherwise.
65
- */
66
- const NEVER_ALLOWED = new Set([
67
- "computer.internal",
68
- "localhost",
69
- "127.0.0.1",
70
- "::1",
71
- "0.0.0.0"
72
- ]);
73
- /**
74
- * The hostname a rule should be matched against, not the one the URL reports.
75
- *
76
- * `URL.hostname` is not canonical for this purpose in two ways that both defeat
77
- * an exact-match set:
78
- *
79
- * - **IPv6 keeps its brackets.** `new URL("http://[::1]/").hostname` is
80
- * `"[::1]"`, so a set holding `"::1"` never matches.
81
- * - **A trailing dot survives.** `computer.internal.` and `api.anthropic.com.`
82
- * are the same destinations as their undotted forms and compare unequal.
83
- *
84
- * The second one cuts both ways and is the more dangerous: an undotted
85
- * comparison against {@link ANTHROPIC_HOST} would send `api.anthropic.com.` down
86
- * the *non-Anthropic* branch, which strips the credential — fail-safe, but it
87
- * also means a spent bucket is never noticed on that spelling.
88
- */
89
- function canonicalHost(url) {
90
- let host = url.hostname.toLowerCase();
91
- if (host.startsWith("[") && host.endsWith("]"))
92
- host = host.slice(1, -1);
93
- if (host.endsWith("."))
94
- host = host.slice(0, -1);
95
- return host;
96
- }
97
- /** Headers that carry a credential, stripped from anything not Anthropic. */
98
- const CREDENTIAL_HEADERS = [
99
- "authorization",
100
- "x-api-key",
101
- "proxy-authorization"
102
- ];
103
- /**
104
- * What a rotated request is told to wait.
105
- *
106
- * One second, not zero: the point is to get the client retrying promptly onto
107
- * the credential that just became the lead, not to remove its backoff. Claude
108
- * Code honours `retry-after`, so leaving the upstream value in place — four
109
- * hours, on a spent 5-hour bucket — would stall the session rather than rotate
110
- * it. This rewrite is what turns the client's own retry into the rotation, and
111
- * it is why nothing here has to buffer a request body.
112
- */
20
+ /** The one-second retry that lands a rotated request on the next credential. */
113
21
  const ROTATED_RETRY_AFTER = "1";
114
- /** How much of an unrecognised body to keep in the log. */
22
+ /** How much of a refusal's body the log keeps. */
115
23
  const CAPTURE_BYTES = 2048;
116
- /** The Anthropic error shape, so the client recognises what it is being told. */
117
- function apiError(type, message, status, headers = {}) {
118
- return new Response(JSON.stringify({ type: "error", error: { type, message } }), {
119
- status,
120
- headers: { "content-type": "application/json", ...headers }
121
- });
122
- }
123
- /** Every header on a response, for the capture log. */
124
- function headerMap(headers) {
125
- const out = {};
126
- headers.forEach((value, key) => {
127
- out[key] = value;
128
- });
129
- return out;
130
- }
131
- /**
132
- * Build the `Fetcher` a `WorkspaceEgressPolicy` takes.
133
- *
134
- * A real object rather than a cast: `Fetcher` requires `connect` as well as
135
- * `fetch`, and container egress is HTTP only, so `connect` throws a sentence
136
- * naming why instead of being absent. If a future `computerd` ever opens a raw
137
- * socket through this policy, the failure says so rather than arriving as
138
- * `undefined is not a function` several frames away.
139
- */
140
- export function claudeCodeEgress(config) {
141
- // Resolved once. `undefined` is unrestricted; a list — including an empty one
142
- // — is a restriction that always admits Anthropic.
143
- const allowed = config.restrictToHosts === undefined
144
- ? undefined
145
- : new Set([ANTHROPIC_HOST, ...config.restrictToHosts]);
146
- const tag = config.label
147
- ? `claude-code-egress:${config.label}`
148
- : "claude-code-egress";
149
- const now = config.now ?? Date.now;
150
- const pool = credentialPool({
151
- credentials: config.credentials,
152
- store: config.store,
153
- ...(config.now ? { now: config.now } : {})
154
- });
155
- /**
156
- * What the client is told when no credential in the pool is usable.
157
- *
158
- * Two different answers, because they are two different situations and the
159
- * client's correct reaction differs. A pool with a `retryAt` **will** recover
160
- * on its own, so it is an honest `429` carrying the real wait — the client
161
- * backs off, and if the wait outlives the session the run ends cleanly having
162
- * said why. A pool with none will not: every entry was rejected as invalid, or
163
- * none is configured. That needs an operator, and a `429` there would put the
164
- * client in a retry loop against a wall for the rest of the session.
165
- */
24
+ /** The egress rule of a sandbox that runs Claude Code. */
25
+ export function claudeCodeEgress(options = {}) {
26
+ const tag = options.label ?? "claude-code-egress";
27
+ const now = options.now ?? Date.now;
28
+ const poolOf = options.pool ??
29
+ ((call) => call.sandbox());
166
30
  const spent = (lead) => {
167
31
  if (lead.retryAt === undefined) {
168
32
  console.error(`[${tag}] no usable credential, and none will recover on its own`);
169
- return apiError("authentication_error", "no Anthropic credential in this deployment's pool is usable and none " +
170
- "will recover on its own — every entry was rejected as invalid, or " +
171
- "none is configured", 500);
33
+ return apiError("authentication_error", "no Anthropic credential in this deployment's pool is usable and none will recover " +
34
+ "on its own — every entry was rejected as invalid, or none is configured", 500);
172
35
  }
173
36
  const waitMs = lead.retryAt - now();
174
37
  console.error(`[${tag}] every credential in the pool is rate limited`, {
175
38
  retryAt: new Date(lead.retryAt).toISOString()
176
39
  });
177
40
  return apiError("rate_limit_error", "every Anthropic credential in this deployment's pool is rate limited; " +
178
- `the earliest resets at ${new Date(lead.retryAt).toISOString()}`, 429,
179
- // The **real** wait, not the rotation's one second. There is nothing to
180
- // rotate to, so shortening it here would only spin the client.
181
- { "retry-after": String(Math.max(1, Math.ceil(waitMs / 1000))) });
41
+ `the earliest resets at ${new Date(lead.retryAt).toISOString()}`, 429, { "retry-after": String(Math.max(1, Math.ceil(waitMs / 1000))) });
182
42
  };
183
- const handle = async (request) => {
184
- let url;
185
- try {
186
- url = new URL(request.url);
187
- }
188
- catch {
189
- return apiError("invalid_request_error", "unparseable egress URL", 400);
190
- }
191
- const host = canonicalHost(url);
192
- // Before any policy: this side of the boundary is never a destination.
193
- if (NEVER_ALLOWED.has(host)) {
194
- console.warn(`[${tag}] refused a request aimed back at the gateway`, {
195
- host,
196
- method: request.method
197
- });
198
- return apiError("permission_error", `${url.hostname} names the egress gateway itself and is never ` +
199
- "reachable from inside the workspace", 403);
200
- }
201
- if (allowed && !allowed.has(host)) {
202
- // Logged, because this is the line that makes a failing install
203
- // intelligible. A container that cannot reach its registry produces a
204
- // hundred lines of npm output and no mention of egress.
205
- console.warn(`[${tag}] refused a request outside the host restriction`, {
206
- host,
207
- method: request.method
208
- });
209
- return apiError("permission_error", `${host} is outside this workspace's egress host restriction`, 403);
210
- }
211
- if (config.githubToken && GITHUB_HOSTS.has(host)) {
212
- return await toGitHub(request, url, config.githubToken);
213
- }
214
- const headers = new Headers(request.headers);
215
- if (host !== ANTHROPIC_HOST) {
216
- // The placeholder must not leave the boundary either. It is worthless to
217
- // whoever receives it, but a credential-shaped header sent to a third
218
- // party is a credential leak in every log it lands in.
219
- for (const name of CREDENTIAL_HEADERS)
220
- headers.delete(name);
221
- return await fetch(new Request(url, new Request(request, { headers })));
222
- }
223
- /**
224
- * Anthropic over TLS, and only that, from here down.
225
- *
226
- * Checked **before** a credential is read, let alone attached. Nothing stops
227
- * a process in the container asking for `http://api.anthropic.com/…`, and
228
- * this function would otherwise fetch it — putting a real subscription token
229
- * on the wire in plaintext, from the Worker, at the request of code the
230
- * workspace does not trust. The host matched; the scheme is the rest of the
231
- * question.
232
- */
233
- if (url.protocol !== "https:") {
234
- console.warn(`[${tag}] refused a plaintext request to Anthropic`, {
235
- protocol: url.protocol,
236
- method: request.method
237
- });
238
- return apiError("permission_error", `${ANTHROPIC_HOST} is reachable over https only; a credential is never ` +
239
- "attached to a plaintext request", 403);
240
- }
43
+ const handle = async (call) => {
44
+ const pool = poolOf(call);
241
45
  const lead = await pool.lead();
242
46
  if (!lead.ok)
243
47
  return spent(lead);
244
- /**
245
- * The swap, and it is the whole point of the file.
246
- *
247
- * `authorization` is set and `x-api-key` removed because that is the shape
248
- * the sanctioned client sends: `authorization: Bearer …` with no
249
- * `x-api-key` at all. Everything else is forwarded
250
- * untouched: the `anthropic-beta` list (which carries
251
- * `claude-code-20250219` and `oauth-2025-04-20`, almost certainly part of
252
- * what marks the request as coming from the client) and the `user-agent`
253
- * are not ours to normalise or reorder.
254
- *
255
- * Claude Code also sends an unauthenticated `HEAD /api/hello` preflight.
256
- * It gets a credential too, harmlessly, and nothing keys on the path: a
257
- * bucket that is empty is empty for every endpoint.
258
- */
259
- headers.set("authorization", `Bearer ${lead.token}`);
48
+ // `authorization` set and `x-api-key` removed: the shape the sanctioned
49
+ // client sends. Everything else — the `anthropic-beta` list, the user agent
50
+ // — is forwarded untouched; it is not ours to normalise.
51
+ const headers = new Headers(call.request.headers);
52
+ headers.delete("host");
53
+ headers.delete("proxy-authorization");
260
54
  headers.delete("x-api-key");
261
- const response = await fetch(new Request(url, new Request(request, { headers })));
262
- // The overwhelming majority: a normal answer, streamed straight back with
263
- // its body untouched. Nothing below this line runs on the hot path.
55
+ headers.set("authorization", `Bearer ${lead.token}`);
56
+ const hasBody = call.request.method !== "GET" && call.request.method !== "HEAD";
57
+ const response = await fetch(call.target, {
58
+ method: call.request.method,
59
+ headers,
60
+ ...(hasBody ? { body: call.request.body } : {}),
61
+ redirect: "manual"
62
+ });
264
63
  if (response.status !== 429 &&
265
64
  response.status !== 401 &&
266
65
  response.status !== 403) {
267
66
  return response;
268
67
  }
269
- return await rotate(request, response, lead.id, lead.index);
270
- };
271
- /**
272
- * A request to GitHub: anonymous unless it presents the placeholder,
273
- * which is swapped for the real token over https only.
274
- *
275
- * `redirect: "manual"`, so the token rides only the request it was swapped
276
- * into: a redirect goes back to the client, whose next request crosses this
277
- * gateway again and is judged on its own host.
278
- */
279
- const toGitHub = async (request, url, githubToken) => {
280
- const headers = new Headers(request.headers);
281
- const presented = presentsPlaceholder(headers.get("authorization"));
282
- for (const name of CREDENTIAL_HEADERS)
283
- headers.delete(name);
284
- if (!presented) {
285
- return await fetch(new Request(url, new Request(request, { headers })));
286
- }
287
- if (url.protocol !== "https:") {
288
- console.warn(`[${tag}] refused a plaintext request to GitHub`, {
289
- host: url.hostname,
290
- method: request.method
291
- });
292
- return githubError(`${url.hostname} is reachable over https only; a credential is never ` +
293
- "attached to a plaintext request", 403);
294
- }
295
- const token = githubToken();
296
- if (!token) {
297
- console.error(`[${tag}] a session asked for GitHub with no token set`);
298
- return githubError("no GitHub token is configured for this deployment, so nothing can " +
299
- "be done as its account", 401);
300
- }
301
- headers.set("authorization", githubAuthorization(presented, token));
302
- return await fetch(new Request(url, new Request(request, { headers, redirect: "manual" })));
303
- };
304
- /**
305
- * Read a refusal, move the lead if it earned one, and shape what the client
306
- * sees so its own retry lands on the new credential.
307
- */
308
- const rotate = async (request, response,
309
- /** Which credential sent this — see `CredentialPool.spend`. */
310
- id,
311
- /** Its slot, for the log line only. An operator reads "1", not a hash. */
312
- index) => {
313
- const at = now();
314
- const verdict = readRefusal(response, at);
315
- /**
316
- * Capture the whole thing, every time.
317
- *
318
- * **No genuine subscription-exhaustion `429` has been observed through this
319
- * path** — a raw-API refusal is a different response — so the rules in
320
- * `readRefusal` are inferences from the documented API rate limits. This log
321
- * is the instrument that replaces
322
- * them with a fact, and it costs nothing: these statuses are rare, and the
323
- * body of one is a few hundred bytes.
324
- */
68
+ const verdict = readRefusal(response, now());
325
69
  const body = await response
326
70
  .clone()
327
71
  .text()
@@ -329,49 +73,60 @@ export function claudeCodeEgress(config) {
329
73
  .catch(() => "<unreadable>");
330
74
  console.warn(`[${tag}] Anthropic refused a request`, {
331
75
  status: response.status,
332
- method: request.method,
333
- credential: index,
76
+ method: call.request.method,
77
+ credential: lead.index,
334
78
  verdict: verdict?.kind ?? "unrecognised",
335
- headers: headerMap(response.headers),
79
+ headers: Object.fromEntries(response.headers),
336
80
  body
337
81
  });
338
- // Unrecognised, or an ordinary slow-down: not this credential's fault, so
339
- // forward it exactly as it came. Rotating on a speed bump would retire a
340
- // working credential for whatever window the header happened to name.
82
+ // Unrecognised, or an ordinary slow-down: not this credential's fault.
341
83
  if (!verdict || verdict.kind === "transient")
342
84
  return response;
343
85
  const next = verdict.kind === "invalid"
344
- ? await pool.reject(id)
345
- : await pool.spend(id, verdict.resetAt);
86
+ ? await pool.reject(lead.id)
87
+ : await pool.spend(lead.id, verdict.resetAt);
346
88
  if (!next.ok)
347
89
  return spent(next);
348
90
  console.warn(`[${tag}] rotated to the next credential`, {
349
- from: index,
91
+ from: lead.index,
350
92
  to: next.index,
351
93
  reason: verdict.kind
352
94
  });
353
- /**
354
- * A `429` with a one-second `retry-after`, whatever the upstream status was.
355
- *
356
- * The status is rewritten for the `invalid` case and that is deliberate: a
357
- * client does not retry a `401`, so passing one through would fail the run
358
- * on a credential we have already stopped using. From the client's point of
359
- * view "this did not work, try again shortly" is exactly true — the next
360
- * attempt carries a different credential — and the operator-facing detail is
361
- * in the log line above, which names the rejection explicitly.
362
- */
95
+ // A 429 whatever the upstream said: a client does not retry a 401, and the
96
+ // next attempt carries a different credential.
363
97
  return apiError("rate_limit_error", verdict.kind === "invalid"
364
- ? "that Anthropic credential was rejected; the gateway has moved to " +
365
- "the next one in the pool — retry"
366
- : "that Anthropic credential's limit is reached; the gateway has moved " +
367
- "to the next one in the pool — retry", 429, { "retry-after": ROTATED_RETRY_AFTER });
98
+ ? "that Anthropic credential was rejected; the egress has moved to the next one in the pool — retry"
99
+ : "that Anthropic credential's limit is reached; the egress has moved to the next one in the pool — retry", 429, { "retry-after": ROTATED_RETRY_AFTER });
368
100
  };
369
101
  return {
370
- fetch: (input, init) => handle(new Request(input, init)),
371
- connect() {
372
- throw new Error("the claude-code egress gateway is HTTP only; a raw socket cannot be " +
373
- "credential-swapped or rotated, so it is refused rather than " +
374
- "silently passed through");
102
+ name: "claude-code",
103
+ http: { [ANTHROPIC_PLACEHOLDER_HOST]: `https://${ANTHROPIC_HOST}` },
104
+ handle
105
+ };
106
+ }
107
+ function apiError(type, message, status, headers = {}) {
108
+ return new Response(JSON.stringify({ type: "error", error: { type, message } }), {
109
+ status,
110
+ headers: { "content-type": "application/json", ...headers }
111
+ });
112
+ }
113
+ /**
114
+ * The pool a sandbox object holds, for its RPCs to answer {@link CredentialLease}
115
+ * with — and `admit`, which a session start calls: a mark lasts one session.
116
+ */
117
+ export function credentialHost(options) {
118
+ const key = options.key ?? "claude-credentials";
119
+ const store = {
120
+ read: async () => (await options.storage.get(key)) ?? [],
121
+ write: async (states) => {
122
+ await options.storage.put(key, states);
375
123
  }
376
124
  };
125
+ const pool = sharedPool({ credentials: options.credentials, store });
126
+ return {
127
+ lead: () => pool.lead(),
128
+ spend: (id, resetAt) => pool.spend(id, resetAt),
129
+ reject: (id) => pool.reject(id),
130
+ admit: () => pool.reopen()
131
+ };
377
132
  }
@@ -53,20 +53,16 @@ export interface ClaudeCodeUsage {
53
53
  * What the client knows about the subscription bucket it is drawing on.
54
54
  *
55
55
  * The one thing on this stream that is about the *deployment* rather than the
56
- * run. This plugin's entry point describes the 5-hour and weekly limits as
57
- * routed around rather than predicted, because the gateway only learns a bucket
58
- * is empty when Anthropic refuses a request — this is the client saying so in
59
- * advance, on every session.
56
+ * run. It is read from the headers of Anthropic's own responses, so it says
57
+ * nothing the egress gateway has not already seen — it is what lets the parent
58
+ * hear it.
60
59
  *
61
60
  * `resetsAt` is **seconds**, not milliseconds, which is the one field here it is
62
61
  * possible to get silently wrong: a value handed to `Date` unconverted lands in
63
- * January 1970, and a credential marked spent until then reads as usable.
62
+ * January 1970.
64
63
  */
65
64
  export interface RateLimitInfo {
66
- /**
67
- * {@link RATE_LIMIT_OK} on a healthy bucket. Any other value is
68
- * **unclassified** — no consumer should read one as exhaustion.
69
- */
65
+ /** One of {@link RATE_LIMIT_STATUSES}, or a value nobody has named yet. */
70
66
  status: string;
71
67
  /** Unix **seconds** at which this bucket refills. */
72
68
  resetsAt?: number;
@@ -236,17 +232,18 @@ export declare const LIVE_SLACK_MS = 5000;
236
232
  * block is timed.
237
233
  */
238
234
  export declare function parseStream(buffer: string, clock?: ParseClock): ParsedStream;
235
+ /** The status a healthy bucket reports. */
236
+ export declare const RATE_LIMIT_OK = "allowed";
239
237
  /**
240
- * The status a healthy bucket reports, and the only one ever observed.
238
+ * Every status the client has reported: {@link RATE_LIMIT_OK},
239
+ * `allowed_warning` once the bucket is nearly spent, and `rejected` once
240
+ * Anthropic has refused a request.
241
241
  *
242
- * **Every other value is unclassified, not a refusal.** Nothing here knows what
243
- * a non-`allowed` status means, and the one place that decides whether a
244
- * credential is spent — `readRateLimitEvent` in this package's `credentials.ts`
245
- * — recognises none of them, deliberately: retiring a working credential on a
246
- * guess is the expensive half of that trade. What an unrecognised status earns
247
- * is a note and a log, so somebody can name it.
242
+ * **None of them retires a credential.** `rejected` follows a refusal the
243
+ * egress gateway has already read off the response itself — see `readRefusal`
244
+ * in `./credentials.ts` — so acting on it again would add nothing.
248
245
  */
249
- export declare const RATE_LIMIT_OK = "allowed";
246
+ export declare const RATE_LIMIT_STATUSES: ReadonlySet<string>;
250
247
  /**
251
248
  * Turn events into progress notes for the parent's transcript.
252
249
  *
@@ -366,17 +366,22 @@ function readTime(value) {
366
366
  function isRealDate(ms) {
367
367
  return Number.isFinite(ms) && !Number.isNaN(new Date(ms).getTime());
368
368
  }
369
+ /** The status a healthy bucket reports. */
370
+ export const RATE_LIMIT_OK = "allowed";
369
371
  /**
370
- * The status a healthy bucket reports, and the only one ever observed.
372
+ * Every status the client has reported: {@link RATE_LIMIT_OK},
373
+ * `allowed_warning` once the bucket is nearly spent, and `rejected` once
374
+ * Anthropic has refused a request.
371
375
  *
372
- * **Every other value is unclassified, not a refusal.** Nothing here knows what
373
- * a non-`allowed` status means, and the one place that decides whether a
374
- * credential is spent — `readRateLimitEvent` in this package's `credentials.ts`
375
- * — recognises none of them, deliberately: retiring a working credential on a
376
- * guess is the expensive half of that trade. What an unrecognised status earns
377
- * is a note and a log, so somebody can name it.
376
+ * **None of them retires a credential.** `rejected` follows a refusal the
377
+ * egress gateway has already read off the response itself — see `readRefusal`
378
+ * in `./credentials.ts` — so acting on it again would add nothing.
378
379
  */
379
- export const RATE_LIMIT_OK = "allowed";
380
+ export const RATE_LIMIT_STATUSES = new Set([
381
+ RATE_LIMIT_OK,
382
+ "allowed_warning",
383
+ "rejected"
384
+ ]);
380
385
  /** Read a `result` line. Total by construction — a missing field reads as zero. */
381
386
  function readResult(event) {
382
387
  const apiErrorStatus = event.api_error_status;
@@ -504,10 +509,9 @@ function describe(event) {
504
509
  * session — one note per `rate_limit_event` would be the noisiest thing on
505
510
  * the stream and would say "allowed" each time.
506
511
  *
507
- * The test is inverted deliberately: an unrecognised status produces a
508
- * note rather than silence. The statuses are a vendor's and only
509
- * {@link RATE_LIMIT_OK} has been seen, so a new one meaning "nearly out"
510
- * should surface as prose somebody reads, not be assumed benign.
512
+ * The test is inverted deliberately: anything but {@link RATE_LIMIT_OK}
513
+ * is a note — a bucket nearly spent or refused, and a status nobody has
514
+ * named yet, which should surface as prose rather than be assumed benign.
511
515
  */
512
516
  return event.info.status === RATE_LIMIT_OK
513
517
  ? undefined
@@ -517,9 +521,9 @@ function describe(event) {
517
521
  ? ""
518
522
  : ` until ${new Date(event.info.resetsAt * 1000).toISOString()}`)
519
523
  };
520
- // `init`'s session id is a handle as well as news: it reaches the host on
521
- // the drain's cursor — see `DrainCursor.sessionId` in ./run.ts — where
522
- // something can record it and resume with it.
524
+ // `init`'s session id is a handle as well as news: the daemon's parser
525
+ // hands it to the host — see `./daemon/index.ts` — where something can
526
+ // record it and resume with it.
523
527
  case "init":
524
528
  return initCard(event);
525
529
  case "result":
@@ -0,0 +1,45 @@
1
+ import type { NoteData } from "@dynamicagents/core/subagent";
2
+ import type { ClaudeCodeResult, RateLimitInfo } from "./events.js";
3
+ /**
4
+ * What the daemon in a sandbox image and the host model must spell
5
+ * identically, in one module both import. The harness stores a turn's messages
6
+ * and its outcome, and nothing else the daemon reports, so everything the host
7
+ * reads travels as one of those.
8
+ */
9
+ /**
10
+ * How one turn of a session ended. The harness keeps an operation's outcome as
11
+ * one string — the text of a turn that was answered, or the reason one was not
12
+ * — so the whole end travels in it: the CLI's own `result` line, the
13
+ * subscription bucket it last reported, its session id, and for a process that
14
+ * died with no result, its exit code and the end of its stderr.
15
+ */
16
+ export interface RunEnd {
17
+ result?: ClaudeCodeResult;
18
+ rateLimit?: RateLimitInfo;
19
+ sessionId?: string;
20
+ /** Only when there is no `result`: the process ended without one. */
21
+ exitCode?: number | null;
22
+ stderr?: string;
23
+ }
24
+ /** The host the CLI is pointed at by `ANTHROPIC_BASE_URL`, which the sandbox intercepts. */
25
+ export declare const ANTHROPIC_PLACEHOLDER_HOST = "anthropic.harness.internal";
26
+ export declare function encodeRunEnd(end: RunEnd): string;
27
+ /** The end in `text`, or `undefined` for a string the daemon did not write — the harness's own `container_lost`, say. */
28
+ export declare function decodeRunEnd(text: string | undefined): RunEnd | undefined;
29
+ /**
30
+ * One transcript note, as one message's only text part.
31
+ *
32
+ * The daemon makes the note (`./events.ts` `toProgress`) rather than sending
33
+ * the event, because a card clips its sections and an event does not: a tool's
34
+ * output is whatever the tool printed, and the harness stores every message in
35
+ * a row of the object's SQLite.
36
+ */
37
+ export declare function encodeNote(note: NoteData): string;
38
+ export declare function decodeNote(text: string): NoteData | undefined;
39
+ /**
40
+ * The CLI's session id, as its own message: the handle a host records the
41
+ * moment the `init` line names it, since a session that dies before its end
42
+ * reports no other.
43
+ */
44
+ export declare function encodeSessionId(sessionId: string): string;
45
+ export declare function decodeSessionId(text: string): string | undefined;