@commonlyai/cli 0.1.64 → 0.1.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
import { detectBwrap } from '../lib/sandbox/bwrap.js';
|
|
43
43
|
import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
|
|
44
44
|
import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
|
|
45
|
+
import { describeUpstreamRefusal } from '../lib/upstream-refusal.js';
|
|
45
46
|
import {
|
|
46
47
|
DEFAULT_HOOK_TIMEOUT_MS,
|
|
47
48
|
clampHookTimeoutMs,
|
|
@@ -1249,13 +1250,22 @@ export const performRun = ({
|
|
|
1249
1250
|
const heartbeatControlReply = (event.type === 'heartbeat' || event.payload?.hasHeartbeat === true)
|
|
1250
1251
|
&& /^(HEARTBEAT_OK|HEARTBEAT_NOOP)$/i.test(replyText);
|
|
1251
1252
|
const silentReply = !replyText || replyText === 'NO_REPLY' || heartbeatControlReply;
|
|
1253
|
+
// A refused model route is not a silent turn. pi reports the refusal on its
|
|
1254
|
+
// own JSON stream and exits 0 with no text, so before this the seat log read
|
|
1255
|
+
// `no wrapper-post (empty output)` and the cause — a 429 budget message — was
|
|
1256
|
+
// nowhere (TASK-096; two and a half days of this on one seat).
|
|
1257
|
+
const upstream = result.upstream || null;
|
|
1252
1258
|
let delivered = agentPostedItself;
|
|
1253
1259
|
let deliveryRefusal = null;
|
|
1254
1260
|
|
|
1255
1261
|
if (event.type === 'agent.ask') {
|
|
1256
1262
|
if (silentReply) {
|
|
1257
1263
|
const reason = heartbeatControlReply ? replyText : (replyText || 'empty output');
|
|
1258
|
-
|
|
1264
|
+
// The same naming rule as the chat path below: an ask that went
|
|
1265
|
+
// unanswered because the route refused must not read as the agent
|
|
1266
|
+
// having nothing to say.
|
|
1267
|
+
if (upstream) log(`[${event.type}] ${describeUpstreamRefusal(upstream)} — no private response`);
|
|
1268
|
+
else log(`[${event.type}] no private response (${reason})`);
|
|
1259
1269
|
} else {
|
|
1260
1270
|
try {
|
|
1261
1271
|
await client.post(
|
|
@@ -1304,6 +1314,11 @@ export const performRun = ({
|
|
|
1304
1314
|
+ `(matched message ${suppressedBy.id} by ${suppressedBy.author} `
|
|
1305
1315
|
+ `via ${suppressedBy.basis})`,
|
|
1306
1316
|
);
|
|
1317
|
+
} else if (upstream) {
|
|
1318
|
+
// The status is always named; the body only when the keep-list kept one.
|
|
1319
|
+
// No retry is added: the adapter's own ladder has already run, and a
|
|
1320
|
+
// refused route is refused on the next attempt too.
|
|
1321
|
+
log(`[${event.type}] ${describeUpstreamRefusal(upstream)} — nothing posted this turn`);
|
|
1307
1322
|
} else {
|
|
1308
1323
|
log(`[${event.type}] no wrapper-post (${reason}) — nothing posted this turn`);
|
|
1309
1324
|
}
|
|
@@ -1394,6 +1409,21 @@ export const performRun = ({
|
|
|
1394
1409
|
},
|
|
1395
1410
|
};
|
|
1396
1411
|
}
|
|
1412
|
+
// A refusal is not a decline. `no_action` with no reason means "the agent
|
|
1413
|
+
// chose not to answer" and hands a human wake to one remaining listener;
|
|
1414
|
+
// nothing about the agent's choice happened here — the model never ran — so
|
|
1415
|
+
// say what did happen, and let the event close rather than fan a
|
|
1416
|
+
// route-wide refusal out across the rest of the fleet.
|
|
1417
|
+
if (!delivered && upstream) {
|
|
1418
|
+
return {
|
|
1419
|
+
outcome: 'no_action',
|
|
1420
|
+
reason: `upstream-refused-${upstream.status}`,
|
|
1421
|
+
details: {
|
|
1422
|
+
status: upstream.status,
|
|
1423
|
+
...(upstream.detail ? { detail: upstream.detail } : {}),
|
|
1424
|
+
},
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1397
1427
|
return { outcome: delivered ? 'posted' : 'no_action' };
|
|
1398
1428
|
};
|
|
1399
1429
|
|
package/src/lib/adapters/pi.js
CHANGED
|
@@ -38,6 +38,10 @@ import { mkdir, readdir, writeFile } from 'fs/promises';
|
|
|
38
38
|
import { homedir } from 'os';
|
|
39
39
|
import { dirname, join } from 'path';
|
|
40
40
|
import { fileURLToPath } from 'url';
|
|
41
|
+
import {
|
|
42
|
+
readUpstreamRefusal,
|
|
43
|
+
spawnCredentials,
|
|
44
|
+
} from '../upstream-refusal.js';
|
|
41
45
|
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
42
46
|
import { GRANT_BROKER_REFUSAL, isGrantBrokerUrl } from './pi-mcp-client.mjs';
|
|
43
47
|
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
|
|
@@ -326,7 +330,7 @@ export const extractReply = (stdout) => {
|
|
|
326
330
|
return { text, sawAssistant, errors };
|
|
327
331
|
};
|
|
328
332
|
|
|
329
|
-
const runPi = ({ args, cwd, env, payload, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
333
|
+
const runPi = ({ args, cwd, env, payload, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
330
334
|
let stdout = '';
|
|
331
335
|
let stderr = '';
|
|
332
336
|
let timedOut = false;
|
|
@@ -352,6 +356,11 @@ const runPi = ({ args, cwd, env, payload, timeoutMs, spawnImpl = childSpawn }) =
|
|
|
352
356
|
clearTimeout(timer);
|
|
353
357
|
if (timedOut) return reject(new Error(`pi timed out after ${timeoutMs}ms`));
|
|
354
358
|
const reply = extractReply(stdout);
|
|
359
|
+
// A refused model route is NOT an error event: pi retries the ladder itself
|
|
360
|
+
// and exits 0 with no assistant text, which is why this read has to happen
|
|
361
|
+
// on the stream rather than on the exit code (TASK-096, measured 2026-09-22).
|
|
362
|
+
const upstream = readUpstreamRefusal(stdout, { credentials });
|
|
363
|
+
if (upstream) reply.upstream = upstream;
|
|
355
364
|
if (code !== 0 && !reply.sawAssistant) {
|
|
356
365
|
const tail = (reply.errors.join(' | ') || stderr).trim().slice(-600);
|
|
357
366
|
return reject(new Error(`pi exited ${code}: ${tail}`));
|
|
@@ -383,6 +392,12 @@ export default {
|
|
|
383
392
|
if (!baseEnv[provider.apiKeyEnv]) {
|
|
384
393
|
throw new Error(`pi adapter: ${provider.apiKeyEnv} is not set — the seat's environment must carry the provider key (LiteLLM virtual key)`);
|
|
385
394
|
}
|
|
395
|
+
// The values this spawn is handed, for the refusal log's exact-match check:
|
|
396
|
+
// the provider key the route authenticates with, and the seat credential the
|
|
397
|
+
// bridge carries. Snapshot them HERE — `withholdRuntimeCredential` removes
|
|
398
|
+
// the token from the child copy below, and after that there is nothing left
|
|
399
|
+
// to compare a refusal against.
|
|
400
|
+
const credentials = spawnCredentials(ctx, [provider.apiKeyEnv]);
|
|
386
401
|
|
|
387
402
|
// Per-seat pi home: models.json holds the provider by env reference.
|
|
388
403
|
const home = seatHome(ctx);
|
|
@@ -437,11 +452,13 @@ export default {
|
|
|
437
452
|
env: childEnv,
|
|
438
453
|
payload: servers.length ? JSON.stringify(servers) : undefined,
|
|
439
454
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
455
|
+
credentials,
|
|
440
456
|
spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
|
|
441
457
|
});
|
|
442
458
|
// Empty text with a clean exit is a silent turn; the run loop treats it
|
|
443
|
-
// as NO_REPLY-shaped and re-delivers on its own rules.
|
|
444
|
-
|
|
459
|
+
// as NO_REPLY-shaped and re-delivers on its own rules. Empty text that
|
|
460
|
+
// came with a refusal is named instead — see upstream-refusal.js.
|
|
461
|
+
return { text: reply.text, newSessionId: sessionId, upstream: reply.upstream ?? null };
|
|
445
462
|
} finally {
|
|
446
463
|
// Best effort: the bridge has already read what it needs by the time the
|
|
447
464
|
// turn ends, and a token file that outlives its turn is a token file that
|
|
@@ -17,13 +17,15 @@
|
|
|
17
17
|
* fixed path would widen the window to "since the first spawn" and would let two
|
|
18
18
|
* concurrent seats share one credential by accident.
|
|
19
19
|
*
|
|
20
|
+
* The caller names the root; this module never invents one, because the only
|
|
21
|
+
* root it could invent is a directory no adapter cleans up.
|
|
22
|
+
*
|
|
20
23
|
* The reader side is `readToken` in `commonly-mcp/src/client.js`, which resolves
|
|
21
24
|
* fd, then file, then environment — by declaration, and refuses to fall through
|
|
22
25
|
* from a declared source that cannot be read.
|
|
23
26
|
*/
|
|
24
27
|
import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
25
28
|
import { dirname, join } from 'node:path';
|
|
26
|
-
import { homedir } from 'node:os';
|
|
27
29
|
import { randomBytes } from 'node:crypto';
|
|
28
30
|
|
|
29
31
|
/** The variable a child reads to find the credential. Never carries the token. */
|
|
@@ -36,25 +38,37 @@ export const CREDENTIAL_FILE_VAR = 'COMMONLY_TOKEN_FILE';
|
|
|
36
38
|
*/
|
|
37
39
|
export const CREDENTIAL_KEY = 'COMMONLY_AGENT_TOKEN';
|
|
38
40
|
|
|
39
|
-
/** Default root: inside the CLI's own state directory, not a world-readable /tmp. */
|
|
40
|
-
export const credentialRoot = () => join(homedir(), '.commonly', 'credentials');
|
|
41
|
-
|
|
42
41
|
/**
|
|
43
|
-
* Write `token` to a fresh 0600 file and return its path, or null
|
|
44
|
-
* no token to write (a seat bootstrapping without one).
|
|
42
|
+
* Write `token` to a fresh 0600 file under `root` and return its path, or null
|
|
43
|
+
* when there is no token to write (a seat bootstrapping without one).
|
|
44
|
+
*
|
|
45
|
+
* `root` is REQUIRED. It used to default to the CLI's own state directory, which
|
|
46
|
+
* meant a caller that forgot it wrote a live credential into
|
|
47
|
+
* `~/.commonly/credentials` — a directory no adapter cleans, because that is not
|
|
48
|
+
* where adapters put theirs. Measured 2026-09-20: 32 such directories on the
|
|
49
|
+
* fleet host, 24 of them written by this repo's own harnesses, all holding a
|
|
50
|
+
* seat credential. A missing root is now an error instead of a silent write into
|
|
51
|
+
* the operator's home. The token check stays first, so "no token, nothing
|
|
52
|
+
* written" remains true without a root.
|
|
45
53
|
*
|
|
46
54
|
* `fs` is injectable so tests can assert the mode and the path shape without
|
|
47
55
|
* writing into the operator's home.
|
|
48
56
|
*/
|
|
49
57
|
export const writeCredentialFile = (token, {
|
|
50
58
|
agentName = 'agent',
|
|
51
|
-
root
|
|
59
|
+
root,
|
|
52
60
|
fs = { mkdirSync, writeFileSync, chmodSync, rmSync },
|
|
53
61
|
now = () => Date.now(),
|
|
54
62
|
random = () => randomBytes(4).toString('hex'),
|
|
55
63
|
} = {}) => {
|
|
56
64
|
const value = typeof token === 'string' ? token.trim() : '';
|
|
57
65
|
if (!value) return null;
|
|
66
|
+
if (typeof root !== 'string' || !root.trim()) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'writeCredentialFile requires an explicit root: an omitted root used to fall back to '
|
|
69
|
+
+ '~/.commonly/credentials, where nothing sweeps the credential files it writes',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
58
72
|
const dir = join(root, `${agentName}-${now()}-${random()}`);
|
|
59
73
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
60
74
|
const path = join(dir, 'token');
|
|
@@ -44,9 +44,20 @@ export const CREDENTIAL_FILE_PLACEHOLDER = '${COMMONLY_TOKEN_FILE}';
|
|
|
44
44
|
* a field the adapter substitutes LITERALLY has no file channel, so `keepsValue`
|
|
45
45
|
* is passed in by the adapter rather than inferred, and the spawn that keeps a
|
|
46
46
|
* secret says so in its own warning.
|
|
47
|
+
*
|
|
48
|
+
* The PATH comes out too when there is no file for THIS spawn. The runtime
|
|
49
|
+
* environment is derived from `process.env`, so a launcher whose own process was
|
|
50
|
+
* spawned by another seat inherits that seat's `COMMONLY_TOKEN_FILE` and hands
|
|
51
|
+
* it to the runtime and every MCP child below it — a path to a credential this
|
|
52
|
+
* launcher did not mint. The value was already deleted here for that reason; the
|
|
53
|
+
* path is the same leak with a smaller blast radius.
|
|
47
54
|
*/
|
|
48
55
|
export const withholdRuntimeCredential = (env, { credentialFile = null, keepsValue = false } = {}) => {
|
|
49
|
-
if (credentialFile)
|
|
56
|
+
if (credentialFile) {
|
|
57
|
+
env[CREDENTIAL_FILE_VAR] = credentialFile;
|
|
58
|
+
} else {
|
|
59
|
+
delete env[CREDENTIAL_FILE_VAR];
|
|
60
|
+
}
|
|
50
61
|
if (!keepsValue) delete env[CREDENTIAL_KEY];
|
|
51
62
|
return env;
|
|
52
63
|
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TASK-096 — name a refused model route, instead of reporting the silence it
|
|
3
|
+
* causes.
|
|
4
|
+
*
|
|
5
|
+
* Measured 2026-09-22 against pi 0.84.1 with a route answering 429 (the shape
|
|
6
|
+
* that made a seat look broken for two and a half days): pi runs its own retry
|
|
7
|
+
* ladder, reports the refusal on `message_end`/`auto_retry_end` JSON events, and
|
|
8
|
+
* EXITS 0 with no assistant text. `extractReply` in the pi adapter collected only
|
|
9
|
+
* `type: "error"` events — of which this produces none — so the wrapper logged
|
|
10
|
+
* `no wrapper-post (empty output)` and the cause sat unread in the stream.
|
|
11
|
+
*
|
|
12
|
+
* Two rules govern the text that reaches a seat log:
|
|
13
|
+
*
|
|
14
|
+
* 1. KEEP-LIST, not strip-list (Vera 70876, Wren 70877). A strip-list removes
|
|
15
|
+
* only the shapes we thought of, and a 401 can echo the key back. So a body
|
|
16
|
+
* contributes one NAMED field or nothing at all.
|
|
17
|
+
* 2. Exact-match refusal for credentials. If the kept text contains a value
|
|
18
|
+
* this spawn was handed, the text is dropped — a known value, not a guessed
|
|
19
|
+
* shape (Wren 70877).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The kept detail is truncated, ellipsis included, to this many characters. */
|
|
23
|
+
export const MAX_REFUSAL_DETAIL = 256;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The status an adapter prefixes to its error text: `"429: {…}"` -> `429`.
|
|
27
|
+
* Null for anything that is not a 4xx/5xx, so a message that merely starts with
|
|
28
|
+
* a number ("4040 tokens over budget") is not read as a status.
|
|
29
|
+
*
|
|
30
|
+
* The separator pi uses is NOT fixed — measured 2026-09-22, the same pi 0.84.1
|
|
31
|
+
* emits `"429: {"message":…}"` for a JSON body and `"502 <html>…"` for an
|
|
32
|
+
* HTML one. Both forms are matched here; a parser that required the colon would
|
|
33
|
+
* have kept `502 <html>…` as the whole "body" and then dropped it as non-JSON,
|
|
34
|
+
* reporting the status alone with no sign that the body was never read.
|
|
35
|
+
*/
|
|
36
|
+
const STATUS_PREFIX = /^\s*(\d{3})(?!\d)\s*:?\s*/;
|
|
37
|
+
|
|
38
|
+
export const statusFromError = (value) => {
|
|
39
|
+
const match = STATUS_PREFIX.exec(String(value ?? ''));
|
|
40
|
+
if (!match) return null;
|
|
41
|
+
const status = Number(match[1]);
|
|
42
|
+
return status >= 400 && status <= 599 ? status : null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The body the adapter carried after its status prefix, or the whole text when
|
|
47
|
+
* there is no status to strip. `"429: {"message":"a: b"}"` -> `{"message":"a: b"}`;
|
|
48
|
+
* `"502 <html>"` -> `<html>`.
|
|
49
|
+
*/
|
|
50
|
+
export const bodyFromError = (value) => {
|
|
51
|
+
const text = String(value ?? '');
|
|
52
|
+
if (statusFromError(text) === null) return text.trim();
|
|
53
|
+
return text.replace(STATUS_PREFIX, '').trim();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Replace credential values this spawn was handed with a marker.
|
|
58
|
+
*
|
|
59
|
+
* This is the exact-match half, for text that is kept for its whole shape rather
|
|
60
|
+
* than filtered to one field — an adapter's stderr tail, say, whose diagnostic
|
|
61
|
+
* value is in the lines around the secret. Values shorter than eight characters
|
|
62
|
+
* are ignored: redacting a one-character "credential" would corrupt ordinary
|
|
63
|
+
* prose, and no issued credential is that short.
|
|
64
|
+
*/
|
|
65
|
+
export const redactKnownCredentials = (text, credentials = []) => {
|
|
66
|
+
let out = String(text ?? '');
|
|
67
|
+
for (const value of credentials) {
|
|
68
|
+
if (typeof value !== 'string' || value.length < 8) continue;
|
|
69
|
+
out = out.split(value).join('[redacted]');
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The keep-list. Given the raw body an adapter captured, keep at most one named
|
|
76
|
+
* field of it and only when that field is a string:
|
|
77
|
+
*
|
|
78
|
+
* `error.message` first (the OpenAI-shaped error), then a top-level `message`
|
|
79
|
+
* (which is the shape LiteLLM's budget refusal actually uses — the motivating
|
|
80
|
+
* case; a keep-list that kept only `error.message` would name the status and
|
|
81
|
+
* still hide the budget line that started this row).
|
|
82
|
+
*
|
|
83
|
+
* Everything else — HTML, a non-JSON body, a JSON body with neither field, a
|
|
84
|
+
* body echoing a known credential — contributes nothing. Returns null rather
|
|
85
|
+
* than a partial guess.
|
|
86
|
+
*/
|
|
87
|
+
export const keptRefusalDetail = (raw, { credentials = [] } = {}) => {
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(String(raw ?? ''));
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
95
|
+
|
|
96
|
+
const candidate = parsed?.error?.message ?? parsed?.message;
|
|
97
|
+
if (typeof candidate !== 'string') return null;
|
|
98
|
+
|
|
99
|
+
const text = candidate.replace(/\s+/g, ' ').trim();
|
|
100
|
+
if (!text) return null;
|
|
101
|
+
|
|
102
|
+
const redacted = redactKnownCredentials(text, credentials);
|
|
103
|
+
// `[redacted]` appearing is the signal that the body echoed something this
|
|
104
|
+
// spawn was handed. The status is still worth keeping; the text is not.
|
|
105
|
+
if (redacted !== text) return null;
|
|
106
|
+
|
|
107
|
+
return text.length > MAX_REFUSAL_DETAIL
|
|
108
|
+
? `${text.slice(0, MAX_REFUSAL_DETAIL - 1)}…`
|
|
109
|
+
: text;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read a pi JSON stream and return the refusal it ended on, or null.
|
|
114
|
+
*
|
|
115
|
+
* `auto_retry_end` is the terminal word — it names the status and the body after
|
|
116
|
+
* the ladder has finished — so it wins over the per-attempt `message_end`
|
|
117
|
+
* events that precede it. A run that refused on its first attempt and never
|
|
118
|
+
* retried still reports through `message_end`.
|
|
119
|
+
*/
|
|
120
|
+
export const readUpstreamRefusal = (stdout, { credentials = [] } = {}) => {
|
|
121
|
+
let status = null;
|
|
122
|
+
let raw = null;
|
|
123
|
+
|
|
124
|
+
for (const line of String(stdout ?? '').split('\n')) {
|
|
125
|
+
if (!line.trim()) continue;
|
|
126
|
+
let event;
|
|
127
|
+
try {
|
|
128
|
+
event = JSON.parse(line);
|
|
129
|
+
} catch {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (event?.type === 'auto_retry_end' && event.success === false) {
|
|
134
|
+
const candidate = event.finalError ?? event.errorMessage;
|
|
135
|
+
const attemptStatus = statusFromError(candidate);
|
|
136
|
+
if (attemptStatus) {
|
|
137
|
+
status = attemptStatus;
|
|
138
|
+
raw = bodyFromError(candidate);
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const message = event?.message;
|
|
144
|
+
if (
|
|
145
|
+
event?.type === 'message_end'
|
|
146
|
+
&& message?.role === 'assistant'
|
|
147
|
+
&& message.stopReason === 'error'
|
|
148
|
+
&& status === null
|
|
149
|
+
) {
|
|
150
|
+
const attemptStatus = statusFromError(message.errorMessage);
|
|
151
|
+
if (attemptStatus) {
|
|
152
|
+
status = attemptStatus;
|
|
153
|
+
raw = bodyFromError(message.errorMessage);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (status === null) return null;
|
|
159
|
+
return { status, detail: keptRefusalDetail(raw, { credentials }) };
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The seat-log clause. The status is always present; the detail only when the
|
|
164
|
+
* keep-list kept one. `grep 'upstream refused'` finds every refusal, which is
|
|
165
|
+
* the property the old `no wrapper-post (empty output)` line did not have.
|
|
166
|
+
*/
|
|
167
|
+
export const describeUpstreamRefusal = (refusal) => {
|
|
168
|
+
if (!refusal || typeof refusal.status !== 'number') return null;
|
|
169
|
+
return refusal.detail
|
|
170
|
+
? `upstream refused ${refusal.status}: ${refusal.detail}`
|
|
171
|
+
: `upstream refused ${refusal.status}`;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The credential values this spawn was handed, for the exact-match check in
|
|
176
|
+
* `redactKnownCredentials`. `envNames` are the adapter's own provider keys; the
|
|
177
|
+
* caller supplies them because only the adapter knows which key its route uses.
|
|
178
|
+
*/
|
|
179
|
+
export const spawnCredentials = (ctx = {}, envNames = []) => {
|
|
180
|
+
const env = ctx.env || process.env || {};
|
|
181
|
+
return [
|
|
182
|
+
ctx.runtimeToken,
|
|
183
|
+
...envNames.map((name) => env[name]),
|
|
184
|
+
].filter((value) => typeof value === 'string' && value.length >= 8);
|
|
185
|
+
};
|