@dynamicagents/plugins 0.14.0 → 0.15.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 +14 -14
- package/dist/claude-code/cards.d.ts +2 -0
- package/dist/claude-code/cards.js +11 -0
- package/dist/claude-code/config.d.ts +10 -83
- package/dist/claude-code/config.js +0 -2
- package/dist/claude-code/credentials.d.ts +32 -50
- package/dist/claude-code/credentials.js +115 -74
- package/dist/claude-code/daemon/index.d.ts +24 -0
- package/dist/claude-code/daemon/index.js +203 -0
- package/dist/claude-code/egress.d.ts +35 -117
- package/dist/claude-code/egress.js +80 -325
- package/dist/claude-code/events.d.ts +14 -17
- package/dist/claude-code/events.js +19 -15
- package/dist/claude-code/harness-wire.d.ts +45 -0
- package/dist/claude-code/harness-wire.js +47 -0
- package/dist/claude-code/index.d.ts +19 -29
- package/dist/claude-code/index.js +17 -25
- package/dist/claude-code/language-model.d.ts +19 -0
- package/dist/claude-code/language-model.js +71 -0
- package/dist/claude-code/model.d.ts +77 -70
- package/dist/claude-code/model.js +327 -327
- package/dist/claude-code/placeholder.d.ts +12 -0
- package/dist/claude-code/placeholder.js +12 -0
- package/dist/claude-code/spec.d.ts +4 -4
- package/dist/claude-code/spec.js +4 -4
- package/dist/computer/client.d.ts +88 -0
- package/dist/computer/client.js +6 -0
- package/dist/computer/context.d.ts +7 -7
- package/dist/computer/context.js +14 -18
- package/dist/computer/gate.d.ts +4 -23
- package/dist/computer/gate.js +1 -37
- package/dist/computer/index.d.ts +44 -43
- package/dist/computer/index.js +29 -50
- package/dist/computer/paths.d.ts +5 -27
- package/dist/computer/paths.js +7 -30
- package/dist/computer/proxy.d.ts +17 -31
- package/dist/computer/proxy.js +9 -44
- package/dist/computer/render.d.ts +3 -40
- package/dist/computer/render.js +3 -36
- package/dist/computer/sandbox.d.ts +55 -0
- package/dist/computer/sandbox.js +236 -0
- package/dist/computer/shell.d.ts +36 -0
- package/dist/computer/shell.js +41 -0
- package/dist/computer/tools-exec.js +3 -9
- package/dist/computer/tools-file.d.ts +1 -1
- package/dist/computer/tools-file.js +1 -1
- package/dist/{repo → repo-github}/checkout.d.ts +3 -4
- package/dist/{repo → repo-github}/checkout.js +3 -4
- package/dist/{repo → repo-github}/context.d.ts +14 -32
- package/dist/{repo → repo-github}/context.js +46 -81
- package/dist/{repo → repo-github}/index.d.ts +30 -13
- package/dist/{repo → repo-github}/index.js +25 -6
- package/dist/repo-github/mcp.d.ts +95 -0
- package/dist/repo-github/mcp.js +107 -0
- package/dist/repo-github/sandbox-egress.d.ts +68 -0
- package/dist/repo-github/sandbox-egress.js +272 -0
- package/dist/repo-github/sandbox-git.d.ts +30 -0
- package/dist/repo-github/sandbox-git.js +113 -0
- package/dist/{repo → repo-github}/tools-checks.js +43 -4
- package/dist/{repo → repo-github}/tools-clone.js +2 -21
- package/dist/{repo → repo-github}/tools-pr.d.ts +0 -1
- package/dist/{repo → repo-github}/tools-pr.js +85 -20
- package/dist/{repo → repo-github}/tools-review.d.ts +6 -0
- package/dist/{repo → repo-github}/tools-review.js +62 -176
- package/dist/{repo → repo-github}/url.d.ts +11 -0
- package/dist/{repo → repo-github}/url.js +12 -1
- package/dist/{workspace → sandbox}/advisory.d.ts +16 -35
- package/dist/{workspace → sandbox}/advisory.js +2 -29
- package/dist/sandbox/agent-exec.d.ts +31 -0
- package/dist/sandbox/agent-exec.js +62 -0
- package/dist/sandbox/ca-trust.d.ts +9 -0
- package/dist/sandbox/ca-trust.js +21 -0
- package/dist/sandbox/constants.d.ts +22 -0
- package/dist/sandbox/constants.js +22 -0
- package/dist/sandbox/daemon/composite.d.ts +9 -0
- package/dist/sandbox/daemon/composite.js +35 -0
- package/dist/sandbox/daemon/index.d.ts +47 -0
- package/dist/sandbox/daemon/index.js +116 -0
- package/dist/sandbox/egress.d.ts +81 -0
- package/dist/sandbox/egress.js +109 -0
- package/dist/sandbox/exec.d.ts +57 -0
- package/dist/sandbox/exec.js +52 -0
- package/dist/sandbox/format.d.ts +2 -0
- package/dist/sandbox/format.js +7 -0
- package/dist/sandbox/header.d.ts +50 -0
- package/dist/sandbox/header.js +94 -0
- package/dist/sandbox/index.d.ts +17 -0
- package/dist/sandbox/index.js +17 -0
- package/dist/{workspace/install.d.ts → sandbox/install-plan.d.ts} +2 -2
- package/dist/{workspace/install.js → sandbox/install-plan.js} +2 -2
- package/dist/sandbox/name.d.ts +27 -0
- package/dist/sandbox/name.js +32 -0
- package/dist/sandbox/object.d.ts +244 -0
- package/dist/sandbox/object.js +1139 -0
- package/dist/scratch/index.d.ts +1 -9
- package/dist/scratch/index.js +2 -18
- package/package.json +22 -18
- package/dist/claude-code/github.d.ts +0 -39
- package/dist/claude-code/github.js +0 -82
- package/dist/claude-code/run.d.ts +0 -491
- package/dist/claude-code/run.js +0 -913
- package/dist/claude-code/session.d.ts +0 -131
- package/dist/claude-code/session.js +0 -288
- package/dist/workspace/ca-trust.d.ts +0 -95
- package/dist/workspace/ca-trust.js +0 -312
- package/dist/workspace/container-deps.d.ts +0 -58
- package/dist/workspace/container-deps.js +0 -249
- package/dist/workspace/container-fault.d.ts +0 -38
- package/dist/workspace/container-fault.js +0 -73
- package/dist/workspace/deps-snapshot.d.ts +0 -110
- package/dist/workspace/deps-snapshot.js +0 -424
- package/dist/workspace/exec.d.ts +0 -94
- package/dist/workspace/exec.js +0 -153
- package/dist/workspace/format.d.ts +0 -36
- package/dist/workspace/format.js +0 -57
- package/dist/workspace/git-host.d.ts +0 -79
- package/dist/workspace/git-host.js +0 -226
- package/dist/workspace/git-identity.d.ts +0 -57
- package/dist/workspace/git-identity.js +0 -147
- package/dist/workspace/index.d.ts +0 -50
- package/dist/workspace/index.js +0 -58
- package/dist/workspace/install-job.d.ts +0 -170
- package/dist/workspace/install-job.js +0 -934
- package/dist/workspace/object.d.ts +0 -522
- package/dist/workspace/object.js +0 -1541
- package/dist/workspace/open.d.ts +0 -95
- package/dist/workspace/open.js +0 -63
- package/dist/workspace/read.d.ts +0 -16
- package/dist/workspace/read.js +0 -22
- package/dist/workspace/shell.d.ts +0 -45
- package/dist/workspace/shell.js +0 -80
- package/dist/workspace/sync.d.ts +0 -111
- package/dist/workspace/sync.js +0 -124
- package/dist/workspace/wake.d.ts +0 -21
- package/dist/workspace/wake.js +0 -10
- /package/dist/{repo → repo-github}/tools-checks.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-clone.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-worktree.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-worktree.js +0 -0
- /package/dist/{repo → repo-github}/tools-worktrees.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-worktrees.js +0 -0
|
@@ -1,327 +1,71 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { ANTHROPIC_PLACEHOLDER_HOST } from "./harness-wire.js";
|
|
2
|
+
import { readRefusal, sharedPool } from "./credentials.js";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* The Anthropic credential, swapped in on the Worker side of a sandbox.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
22
|
+
/** How much of a refusal's body the log keeps. */
|
|
115
23
|
const CAPTURE_BYTES = 2048;
|
|
116
|
-
/** The
|
|
117
|
-
function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
"
|
|
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 (
|
|
184
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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:
|
|
79
|
+
headers: Object.fromEntries(response.headers),
|
|
336
80
|
body
|
|
337
81
|
});
|
|
338
|
-
// Unrecognised, or an ordinary slow-down: not this credential's fault
|
|
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
|
-
|
|
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
|
|
365
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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.
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
* **
|
|
243
|
-
*
|
|
244
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
* **
|
|
373
|
-
*
|
|
374
|
-
*
|
|
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
|
|
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:
|
|
508
|
-
* note
|
|
509
|
-
*
|
|
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:
|
|
521
|
-
// the
|
|
522
|
-
//
|
|
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;
|