@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -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
- log(`[${event.type}] no private response (${reason})`);
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
 
@@ -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
- return { text: reply.text, newSessionId: sessionId };
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 when there is
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 = credentialRoot(),
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) env[CREDENTIAL_FILE_VAR] = 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
+ };