@commonlyai/cli 0.1.67 → 0.1.68
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 +1 -1
- package/src/lib/adapters/claude.js +23 -4
- package/src/lib/adapters/codex.js +32 -5
- package/src/lib/upstream-refusal.js +157 -8
package/package.json
CHANGED
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
import { CREDENTIAL_FILE_VAR, CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
|
|
76
76
|
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
|
|
77
77
|
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
78
|
+
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
78
79
|
|
|
79
80
|
// See codex.js for the rationale on bumping the default + env override.
|
|
80
81
|
// Keeping both adapters in lockstep so any wrapper agent runtime has the
|
|
@@ -234,7 +235,7 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns) => {
|
|
|
234
235
|
];
|
|
235
236
|
};
|
|
236
237
|
|
|
237
|
-
const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
238
|
+
const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
238
239
|
const proc = spawnImpl(cmd, args, {
|
|
239
240
|
cwd,
|
|
240
241
|
env,
|
|
@@ -265,8 +266,19 @@ const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) =
|
|
|
265
266
|
// at all because of this. It is not only a diagnosability problem: the
|
|
266
267
|
// circuit breaker classifies from the error message, so a blank message
|
|
267
268
|
// downgrades a hard quota failure to RUNTIME and its shortest backoff.
|
|
269
|
+
//
|
|
270
|
+
// TASK-103: the tail goes through the same reader pi's refusal log uses.
|
|
271
|
+
// A non-JSON tail (which this normally is — the usage-limit line above is
|
|
272
|
+
// prose) is kept whole and only exact-match redacted; a JSON body is
|
|
273
|
+
// reduced to one named field. The status, when the tail names one, is
|
|
274
|
+
// attached as `error.status` so a refusal classifies by field rather than
|
|
275
|
+
// by regex.
|
|
268
276
|
const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join(' | ');
|
|
269
|
-
return reject(
|
|
277
|
+
return reject(adapterFailure('claude', detail, {
|
|
278
|
+
credentials,
|
|
279
|
+
exitCode: code,
|
|
280
|
+
limit: 2000,
|
|
281
|
+
}));
|
|
270
282
|
}
|
|
271
283
|
resolve(stdout);
|
|
272
284
|
});
|
|
@@ -688,6 +700,11 @@ export default {
|
|
|
688
700
|
cwd: ctx.cwd,
|
|
689
701
|
env,
|
|
690
702
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
703
|
+
// The values this spawn was handed, for the failure tail's exact-match
|
|
704
|
+
// check. Snapshot before the spawn: claude is subscription-authenticated
|
|
705
|
+
// and its provider key is not an env var here, so the seat credential is
|
|
706
|
+
// the value a tail can echo back.
|
|
707
|
+
credentials: spawnCredentials(spawnCtx),
|
|
691
708
|
spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
|
|
692
709
|
});
|
|
693
710
|
return { text: stdout.trim(), newSessionId: sessionId };
|
|
@@ -703,7 +720,7 @@ export default {
|
|
|
703
720
|
// session that misses the durable-state cue.
|
|
704
721
|
const freshPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { freshSession: true });
|
|
705
722
|
const retryBase = ['-p', freshPrompt, '--output-format', 'text', '--session-id', freshId, ...modelArgs];
|
|
706
|
-
const
|
|
723
|
+
const retryCtx = {
|
|
707
724
|
...ctx,
|
|
708
725
|
mcpConfigPath: mcpConfig?.file || null,
|
|
709
726
|
mcpConfigDir: mcpConfig?.dir || null,
|
|
@@ -714,13 +731,15 @@ export default {
|
|
|
714
731
|
mcpConfig?.expansionEnv,
|
|
715
732
|
mcpConfig?.credential?.path || null,
|
|
716
733
|
),
|
|
717
|
-
}
|
|
734
|
+
};
|
|
735
|
+
const retry = await prepareArgv(retryBase, retryCtx);
|
|
718
736
|
const stdout = await runClaude({
|
|
719
737
|
cmd: retry.cmd,
|
|
720
738
|
args: retry.args,
|
|
721
739
|
cwd: ctx.cwd,
|
|
722
740
|
env: retry.env,
|
|
723
741
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
742
|
+
credentials: spawnCredentials(retryCtx),
|
|
724
743
|
spawnImpl: ctx._spawnImpl,
|
|
725
744
|
});
|
|
726
745
|
return { text: stdout.trim(), newSessionId: freshId };
|
|
@@ -60,6 +60,7 @@ import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
|
|
|
60
60
|
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
|
|
61
61
|
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
62
62
|
import { isLegacySandboxTrust, normalizeSandboxTrust } from '../environment.js';
|
|
63
|
+
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
63
64
|
|
|
64
65
|
// Default timeout for a single codex spawn (exec mode).
|
|
65
66
|
//
|
|
@@ -412,8 +413,21 @@ const makeEventParser = () => {
|
|
|
412
413
|
};
|
|
413
414
|
};
|
|
414
415
|
|
|
415
|
-
|
|
416
|
-
|
|
416
|
+
/**
|
|
417
|
+
* Provider keys this adapter's route can authenticate with. `env_key` in
|
|
418
|
+
* `~/.codex/config.toml` names one of them (ADR-014 — the HTTPS layer is
|
|
419
|
+
* redirected, the CLI's semantics are not), and the adapter cannot know which
|
|
420
|
+
* one this seat uses, so it snapshots whichever are present. A failure tail that
|
|
421
|
+
* echoes a key must not reach a pod (TASK-103).
|
|
422
|
+
*/
|
|
423
|
+
const CODEX_PROVIDER_KEY_ENVS = [
|
|
424
|
+
'LITELLM_API_KEY',
|
|
425
|
+
'LITELLM_MASTER_KEY',
|
|
426
|
+
'OPENAI_API_KEY',
|
|
427
|
+
'CODEX_API_KEY',
|
|
428
|
+
];
|
|
429
|
+
|
|
430
|
+
const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
|
|
417
431
|
// defaults stdin to a fresh pipe. Codex 0.125.0's `exec` then blocks on
|
|
418
432
|
// `Reading additional input from stdin...` because it sees an open pipe
|
|
419
433
|
// and waits for input that never arrives. Interactive runs are fine because
|
|
@@ -442,11 +456,18 @@ const runCodex = ({ args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new
|
|
|
442
456
|
if (events.turnFailedMessage) {
|
|
443
457
|
// Surface the model-side failure message verbatim — the run loop posts
|
|
444
458
|
// it as the agent's reply so the user sees what went wrong rather than
|
|
445
|
-
// a generic "non-zero exit" error.
|
|
446
|
-
|
|
459
|
+
// a generic "non-zero exit" error. Verbatim is about the WORDING: the
|
|
460
|
+
// tail still goes through the shared reader (TASK-103), so a message that
|
|
461
|
+
// echoed a credential this spawn was handed is redacted before it can be
|
|
462
|
+
// posted into a pod, and a status it names is attached for the circuit
|
|
463
|
+
// breaker rather than left to a regex.
|
|
464
|
+
return reject(adapterFailure('codex turn failed', events.turnFailedMessage, {
|
|
465
|
+
credentials,
|
|
466
|
+
limit: 2000,
|
|
467
|
+
}));
|
|
447
468
|
}
|
|
448
469
|
if (code !== 0) {
|
|
449
|
-
return reject(
|
|
470
|
+
return reject(adapterFailure('codex', stderr.trim(), { credentials, exitCode: code, limit: 500 }));
|
|
450
471
|
}
|
|
451
472
|
resolve({ threadId: events.threadId });
|
|
452
473
|
});
|
|
@@ -554,6 +575,12 @@ export default {
|
|
|
554
575
|
cwd: ctx.cwd,
|
|
555
576
|
env: childEnv,
|
|
556
577
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
578
|
+
// The values this spawn was handed, for the failure tail's exact-match
|
|
579
|
+
// check: the seat credential and the provider key the route
|
|
580
|
+
// authenticates with. Snapshot HERE — `withholdRuntimeCredential` above
|
|
581
|
+
// already removed the token from the child copy, and after that there is
|
|
582
|
+
// nothing left to compare a tail against.
|
|
583
|
+
credentials: spawnCredentials(ctx, CODEX_PROVIDER_KEY_ENVS),
|
|
557
584
|
spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
|
|
558
585
|
});
|
|
559
586
|
|
|
@@ -29,14 +29,15 @@
|
|
|
29
29
|
* relayed in an upstream error is not detectable here, because the one
|
|
30
30
|
* inventory this module has is the set of values it was itself handed.
|
|
31
31
|
*
|
|
32
|
-
* Scope: this reader
|
|
33
|
-
* silent the way pi was: the claude adapter
|
|
34
|
-
* deliberately reports stdout with it, because a
|
|
35
|
-
* conditions there; the codex adapter rejects on
|
|
36
|
-
* exit 0. Their gap
|
|
37
|
-
* unscrubbed by the rules above, and
|
|
38
|
-
*
|
|
39
|
-
*
|
|
32
|
+
* Scope: this reader was wired into the pi adapter only. TASK-103 extended it
|
|
33
|
+
* to codex and claude, which are NOT silent the way pi was: the claude adapter
|
|
34
|
+
* rejects on a non-zero exit and deliberately reports stdout with it, because a
|
|
35
|
+
* `-p` run writes quota conditions there; the codex adapter rejects on
|
|
36
|
+
* `turnFailedMessage` even at exit 0. Their gap was the opposite one — the text
|
|
37
|
+
* they raised was the raw tail, unscrubbed by the rules above, and carried no
|
|
38
|
+
* HTTP status for the run loop to classify on. `scrubAdapterFailure` below is
|
|
39
|
+
* the shared reader they now both go through, so the two rules cannot drift
|
|
40
|
+
* apart between three adapters.
|
|
40
41
|
*/
|
|
41
42
|
|
|
42
43
|
/** The kept detail is truncated, ellipsis included, to this many characters. */
|
|
@@ -179,6 +180,154 @@ export const readUpstreamRefusal = (stdout, { credentials = [] } = {}) => {
|
|
|
179
180
|
return { status, detail: keptRefusalDetail(raw, { credentials }) };
|
|
180
181
|
};
|
|
181
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Is this text STRUCTURED upstream data — a JSON object or an array? The gate for
|
|
185
|
+
* the whole-tail fallback below, deliberately NOT `kept === null`, which is true
|
|
186
|
+
* for five different reasons (Vera 71350).
|
|
187
|
+
*
|
|
188
|
+
* An array counts, and that is a correction (Vera 71360, measured): `[{...}]` is
|
|
189
|
+
* structured data with fields this code does not know, which is exactly what rule
|
|
190
|
+
* 1 exists for, and treating it as prose shipped the whole body under exact-match
|
|
191
|
+
* alone. A JSON *string* is the opposite case and does NOT count — `"Too many
|
|
192
|
+
* requests"` is a whole-shape text with no fields to reduce, so it keeps its
|
|
193
|
+
* wording like any other prose.
|
|
194
|
+
*/
|
|
195
|
+
const isStructuredBody = (raw) => {
|
|
196
|
+
try {
|
|
197
|
+
const parsed = JSON.parse(String(raw ?? ''));
|
|
198
|
+
return parsed !== null && typeof parsed === 'object';
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A 3-digit number is read as a status only when the tail NAMES it as one:
|
|
206
|
+
* `status 429`, `HTTP 429`, `code: "429"` — or when it opens the tail or a ` | `
|
|
207
|
+
* segment followed by a colon, which is the shape claude's `stderr | stdout`
|
|
208
|
+
* join produces for a body that leads with its status.
|
|
209
|
+
*
|
|
210
|
+
* A bare `\b(\d{3})\b` scan is not enough, and the false positives are not
|
|
211
|
+
* hypothetical: `500 tokens`, `"max_tokens": 500` and `retry in 500 seconds`
|
|
212
|
+
* all read as 500, and `classifySpawnFailure` tests the status FIELD before the
|
|
213
|
+
* message — so a count misread as a status stands the fleet down on a refusal
|
|
214
|
+
* that never happened, which is worse than no status at all (Vera 71352).
|
|
215
|
+
*/
|
|
216
|
+
const STATUS_NAMED = /\b(?:status(?:_?code)?|code|http)\b\W{0,4}(\d{3})(?!\d)/gi;
|
|
217
|
+
// `HTTP/1.1 429 Too Many Requests` is the most canonical refusal shape there is,
|
|
218
|
+
// and the `\W{0,4}` gap above handles `HTTP 429` but cannot reach a VERSIONED
|
|
219
|
+
// line: the `1` in `/1.1` is a
|
|
220
|
+
// WORD character, so `HTTP` and `429` are not within four non-word characters of
|
|
221
|
+
// each other. It read as `status: null` — an unclassified runtime failure on the
|
|
222
|
+
// single most recognisable shape, which is the misclassification this row exists
|
|
223
|
+
// to remove (Vera 71359). Its own pattern, not a looser gap: widening the gap
|
|
224
|
+
// would start reading `/v1/models 500` as a status.
|
|
225
|
+
const STATUS_LINE = /\bHTTP\/\d(?:\.\d)?\s+(\d{3})(?!\d)/gi;
|
|
226
|
+
const STATUS_COLON = /(?:^|\|\s*)(\d{3})(?!\d)\s*:/g;
|
|
227
|
+
|
|
228
|
+
/** The first 4xx/5xx one of those shapes names, or null. */
|
|
229
|
+
const scanStatuses = (text, pattern) => {
|
|
230
|
+
for (const match of String(text ?? '').matchAll(pattern)) {
|
|
231
|
+
const value = Number(match[1]);
|
|
232
|
+
if (value >= 400 && value <= 599) return value;
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Find the status AND the body boundary, because they are the same question.
|
|
239
|
+
*
|
|
240
|
+
* Anchoring the status to a ` | ` segment start is not enough on its own: the
|
|
241
|
+
* body has to be taken from just past that status too, or a mid-tail read
|
|
242
|
+
* isolates the status and then hands the WHOLE joined tail to the body rules,
|
|
243
|
+
* which no longer look like a JSON body and so fall through to "keep it all".
|
|
244
|
+
* That is the difference between dropping a credential-echoing body and shipping
|
|
245
|
+
* the part of it that exact-match cannot see.
|
|
246
|
+
*
|
|
247
|
+
* `STATUS_COLON` is the only shape that moves the boundary, because it is the
|
|
248
|
+
* same shape as the anchored prefix (`429: {body}`), just found at a segment
|
|
249
|
+
* boundary — claude's `stderr | stdout` join. A status that appears inside a
|
|
250
|
+
* body (`{"error":{"code":"429"}}`) or as prose (`HTTP 503`) names the status
|
|
251
|
+
* (`HTTP/1.1 429`) but marks no boundary, so the whole text stays the body.
|
|
252
|
+
*/
|
|
253
|
+
const readStatusAndBody = (text) => {
|
|
254
|
+
const raw = String(text ?? '');
|
|
255
|
+
const prefixed = statusFromError(raw);
|
|
256
|
+
if (prefixed !== null) return { status: prefixed, body: bodyFromError(raw) };
|
|
257
|
+
// Only the colon shape moves the boundary — see the note above. A status LINE
|
|
258
|
+
// names the status but its reason phrase is prose, so the body stays the whole
|
|
259
|
+
// text and the exact-match rule protects it.
|
|
260
|
+
for (const match of raw.matchAll(STATUS_COLON)) {
|
|
261
|
+
const value = Number(match[1]);
|
|
262
|
+
if (value >= 400 && value <= 599) {
|
|
263
|
+
return { status: value, body: raw.slice(match.index + match[0].length).replace(/^\s*:?\s*/, '') };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const named = scanStatuses(raw, STATUS_NAMED);
|
|
267
|
+
return { status: named ?? scanStatuses(raw, STATUS_LINE), body: raw };
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The shared reader for an adapter's own failure tail (TASK-103).
|
|
272
|
+
*
|
|
273
|
+
* The two rules above are about a body an adapter captured, and they apply here
|
|
274
|
+
* too — but the tail is a different shape from pi's refusal, and the difference
|
|
275
|
+
* decides how each rule lands:
|
|
276
|
+
*
|
|
277
|
+
* - The tail is often NOT structured. Claude's `-p` mode writes its terminal
|
|
278
|
+
* condition as prose (`Claude usage limit reached. Your limit will reset at
|
|
279
|
+
* 11:40pm.`), and that line is the whole diagnostic value of reporting
|
|
280
|
+
* stdout at all — 361 consecutive failures on 2026-08-03 carried no reason
|
|
281
|
+
* because of it. So the keep-list applies only when the tail parses as a
|
|
282
|
+
* body; when it does not, the text is KEPT as a whole shape and the
|
|
283
|
+
* exact-match rule is what protects it (`redactKnownCredentials`, which
|
|
284
|
+
* substitutes rather than drops, precisely because a whole-shape text has no
|
|
285
|
+
* second field to fall back to).
|
|
286
|
+
*
|
|
287
|
+
* - A body that IS structured (a JSON object or array) is reduced to one named
|
|
288
|
+
* field, and a body whose field echoed a value this spawn was handed is
|
|
289
|
+
* DROPPED WHOLE — the same trade rule 2 makes everywhere. The fallback to the
|
|
290
|
+
* whole tail is gated on the SHAPE (is it structured at all), never on
|
|
291
|
+
* "the keep-list returned null": that null has
|
|
292
|
+
* four other causes, and one of them is the credential echo this line exists
|
|
293
|
+
* to drop. Gating on `kept === null` would emit the whole body for that case,
|
|
294
|
+
* which is precisely what rule 2 forbids — and exact-match cannot see a
|
|
295
|
+
* transformed shape (limit (a) in the header), so a key that arrived
|
|
296
|
+
* base64'd or percent-encoded would ship (Vera 71350/71351).
|
|
297
|
+
*
|
|
298
|
+
* Returns `{ status, detail }`: `status` is the 4xx/5xx the tail named, or null.
|
|
299
|
+
* The caller decides where the status goes; `classifySpawnFailure` reads it off
|
|
300
|
+
* `error.status` or out of the message text, so both a leading `429: …` prefix
|
|
301
|
+
* and an attached field classify the same way. `detail` is `''` when nothing of
|
|
302
|
+
* the body may be shown — the status is then the whole diagnostic.
|
|
303
|
+
*/
|
|
304
|
+
export const scrubAdapterFailure = (text, { credentials = [], limit = MAX_REFUSAL_DETAIL } = {}) => {
|
|
305
|
+
const raw = String(text ?? '').trim();
|
|
306
|
+
const { status, body } = readStatusAndBody(raw);
|
|
307
|
+
const kept = keptRefusalDetail(body, { credentials });
|
|
308
|
+
const detail = kept ?? (isStructuredBody(body) ? '' : redactKnownCredentials(body, credentials));
|
|
309
|
+
return { status, detail: detail.slice(0, limit) };
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The Error an adapter raises, built from its own failure tail.
|
|
314
|
+
*
|
|
315
|
+
* `status` is attached as a field, not only written into the text: the run
|
|
316
|
+
* loop's classifier tests `error.status === 429` before it looks at the
|
|
317
|
+
* message, and a refusal that is only readable by regex is one wording away
|
|
318
|
+
* from the misclassification that cost an hour on 2026-08-18 (see
|
|
319
|
+
* spawn-retry.js). The message still names the status so a human log says the
|
|
320
|
+
* same thing as the classifier's decision.
|
|
321
|
+
*/
|
|
322
|
+
export const adapterFailure = (label, text, { credentials = [], exitCode = null, limit } = {}) => {
|
|
323
|
+
const { status, detail } = scrubAdapterFailure(text, { credentials, limit });
|
|
324
|
+
const code = exitCode === null ? '' : ` exited with code ${exitCode}`;
|
|
325
|
+
const named = status === null ? '' : ` (upstream ${status})`;
|
|
326
|
+
const error = new Error(`${label}${code}${named}${detail ? `: ${detail}` : ''}`);
|
|
327
|
+
if (status !== null) error.status = status;
|
|
328
|
+
return error;
|
|
329
|
+
};
|
|
330
|
+
|
|
182
331
|
/**
|
|
183
332
|
* The seat-log clause. The status is always present; the detail only when the
|
|
184
333
|
* keep-list kept one. `grep 'upstream refused'` finds every refusal, which is
|