flowviant 0.114.1 → 0.115.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.
@@ -0,0 +1,467 @@
1
+ /**
2
+ * A FENCED CODEX TURN STILL SIGNS IN AS THE PERSON'S CODEX (2026-09-29).
3
+ *
4
+ * The owner's New task chat on Codex failed on their WSL machine with
5
+ * `401 Unauthorized: Missing bearer or basic authentication in header` — Codex
6
+ * sent the request with no login at all — while their Codex agents' build
7
+ * turns ran. Every fenced Codex posture (consult, plan and the capture chat,
8
+ * wiki, image) passes `--ignore-user-config` so no personal config can widen a
9
+ * posture asserted on the person's behalf (runtimeCodex.mjs). Codex 0.156.1's
10
+ * own words for that flag: "Do not load `$CODEX_HOME/config.toml`; auth still
11
+ * uses `CODEX_HOME`". The FILE of credentials is still found; everything
12
+ * config.toml says about WHERE the login lives and WHICH endpoint it signs
13
+ * into is dropped with the rest. MEASURED on codex-cli 0.156.1 with scratch
14
+ * CODEX_HOMEs and a local fake Responses endpoint (~/.codex untouched):
15
+ * · `cli_auth_credentials_store = "keyring"` (or "auto" where a keyring
16
+ * answers): `codex login` stores the login in the OS keyring and writes no
17
+ * auth.json. Under the flag the store is the default "file", the file is
18
+ * not there, and the request went out with no Authorization header;
19
+ * `-c cli_auth_credentials_store="keyring"` beside the flag put the bearer
20
+ * back.
21
+ * · a custom `model_provider` with its `[model_providers.<id>]` table: under
22
+ * the flag the turn went to api.openai.com/v1/responses with no login —
23
+ * word for word the owner's error; the provider's keys as `-c` pairs put
24
+ * it back on the person's endpoint with its `env_key` bearer.
25
+ * · `openai_base_url` routes the built-in provider and `chatgpt_base_url` a
26
+ * ChatGPT login's workspace discovery; both were dropped by the flag.
27
+ *
28
+ * So each fenced posture carries back ONLY what makes the CLI the person's
29
+ * CLI — how it signs in, which provider and endpoint it reaches, and the
30
+ * model it runs when the turn pins none — as `-c` overrides beside the flag.
31
+ * An ALLOWLIST, the Claude mirror's rule (claudePersonal.mjs): sandbox and
32
+ * approval settings, permission profiles, the shell environment policy, MCP
33
+ * servers, features, tools and web search, projects, hooks, profiles and
34
+ * instructions never ride, and a key a later Codex adds never rides by
35
+ * default. A config that does not parse carries nothing, which is the turn as
36
+ * it ran before this.
37
+ *
38
+ * A LITERAL SECRET NEVER RIDES ARGV. Every process on the box can read
39
+ * another's command line, and the daemon already keeps its own MCP token in
40
+ * the environment for that reason (runtimeCodex.mjs `codexMcp`). A header
41
+ * value or a bearer token written into config.toml rides in the turn's
42
+ * ENVIRONMENT instead, named by the CLI's own indirection (`env_http_headers`,
43
+ * `env_key`). The environment is kept from the box's other users; argv is
44
+ * not. It is NOT kept from the turn's own model, and cannot usefully be: `env`
45
+ * in a fenced turn's shell listed every variable Codex was started with (a
46
+ * provider key and FLOWVIANT_MCP_TOKEN among them — measured), Codex's shell
47
+ * snapshot put back a name `shell_environment_policy.exclude` had removed
48
+ * (measured), and the read-only sandbox reads `~/.codex/config.toml`, where
49
+ * the literal is written, anyway.
50
+ *
51
+ * STATED, NOT CLOSED: a login chosen by a legacy `profile = "…"` table is not
52
+ * carried (profiles never ride), and Codex keys a keyring login by its
53
+ * CODEX_HOME's path, so a keyring login is invisible from an agent's own
54
+ * CODEX_HOME whichever posture it runs (measured: another home with the same
55
+ * config read "Not logged in").
56
+ */
57
+ import { readFileSync, statSync } from 'node:fs';
58
+ import { homedir } from 'node:os';
59
+ import { join } from 'node:path';
60
+ import { toml } from './projectToolRuntimes.mjs';
61
+
62
+ /** Where the login lives and which endpoint it signs into — top-level keys,
63
+ * each confirmed in codex-cli 0.156.1 (`preferred_auth_method` is not one:
64
+ * 0.156.1 has no such key, so it is not carried). */
65
+ export const CODEX_LOGIN_KEPT = Object.freeze([
66
+ 'cli_auth_credentials_store',
67
+ 'forced_login_method',
68
+ 'forced_chatgpt_workspace_id',
69
+ 'chatgpt_base_url',
70
+ 'openai_base_url',
71
+ ]);
72
+
73
+ /**
74
+ * The selected provider's table, field by field (0.156.1's ModelProviderInfo):
75
+ * its name and endpoint, how it authenticates (`env_key`, the command helper
76
+ * `auth`, `aws`, `gateway_oauth`, `requires_openai_auth`, headers), and how it
77
+ * is reached. Not `supports_standalone_web_search` — web search is exactly
78
+ * what a fenced turn switches off — and not `model_catalog_url`.
79
+ */
80
+ export const CODEX_PROVIDER_KEPT = Object.freeze([
81
+ 'name',
82
+ 'base_url',
83
+ 'env_key',
84
+ 'env_key_instructions',
85
+ 'experimental_bearer_token',
86
+ 'auth',
87
+ 'aws',
88
+ 'gateway_oauth',
89
+ 'requires_openai_auth',
90
+ 'wire_api',
91
+ 'query_params',
92
+ 'http_headers',
93
+ 'env_http_headers',
94
+ 'request_max_retries',
95
+ 'stream_max_retries',
96
+ 'stream_idle_timeout_ms',
97
+ 'websocket_connect_timeout_ms',
98
+ 'supports_websockets',
99
+ ]);
100
+
101
+ /** The environment names a literal secret rides under (see above). */
102
+ export const CODEX_BEARER_ENV = 'FLOWVIANT_CODEX_BEARER_TOKEN';
103
+ export const CODEX_HEADER_ENV_PREFIX = 'FLOWVIANT_CODEX_SECRET_HEADER_';
104
+
105
+ /** Far past any real config.toml; a bigger one is not read. */
106
+ const CONFIG_MAX_BYTES = 1024 * 1024;
107
+ /** Arrays and inline tables nest no deeper than this in a file we read. */
108
+ const MAX_DEPTH = 32;
109
+
110
+ /** A number, date or time, kept as written so it is carried as written. */
111
+ export class TomlRaw {
112
+ constructor(text) {
113
+ this.text = text;
114
+ }
115
+ }
116
+ const newTable = () => Object.create(null);
117
+ const isTable = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && !(v instanceof TomlRaw);
118
+
119
+ const BARE = /[A-Za-z0-9_-]+/y;
120
+ // First match wins, so the forms that begin like an integer come first.
121
+ const SCALAR =
122
+ /[+-]?(?:inf|nan)|0x[0-9A-Fa-f](?:_?[0-9A-Fa-f])*|0o[0-7](?:_?[0-7])*|0b[01](?:_?[01])*|\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:[Zz]|[+-]\d{2}:\d{2})?)?|\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?|[+-]?\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?/y;
123
+
124
+ /**
125
+ * A BOUNDED TOML READER — the repo carries no TOML library, and the one file
126
+ * it reads is Codex's own config. Whole documents: tables, arrays of tables
127
+ * (the daemon's own `[[skills.config]]` in an agent's home), dotted and quoted
128
+ * keys, the four string forms, arrays, inline tables, booleans, and numbers,
129
+ * dates and times kept as written. Anything else throws, and the caller
130
+ * carries nothing — a partial read is never a license to guess.
131
+ */
132
+ export function parseCodexToml(text) {
133
+ const src = String(text).replace(/^/, '');
134
+ const n = src.length;
135
+ let i = 0;
136
+ const fail = (why) => {
137
+ throw new Error(`config.toml: ${why} at offset ${i}`);
138
+ };
139
+ const ws = () => {
140
+ while (i < n && (src[i] === ' ' || src[i] === '\t')) i++;
141
+ };
142
+ const comment = () => {
143
+ if (src[i] === '#') while (i < n && src[i] !== '\n') i++;
144
+ };
145
+ const newline = () => {
146
+ if (src[i] === '\n') return (i += 1), true;
147
+ if (src[i] === '\r' && src[i + 1] === '\n') return (i += 2), true;
148
+ return false;
149
+ };
150
+ /** Blank lines, comments and whitespace, as between array items. */
151
+ const gap = () => {
152
+ for (;;) {
153
+ ws();
154
+ comment();
155
+ if (!newline()) return;
156
+ }
157
+ };
158
+ const endOfLine = () => {
159
+ ws();
160
+ comment();
161
+ if (i < n && !newline()) fail('expected the end of the line');
162
+ };
163
+ const hexChar = (len) => {
164
+ const h = src.slice(i, i + len);
165
+ if (h.length !== len || !/^[0-9A-Fa-f]+$/.test(h)) fail('bad escape');
166
+ i += len;
167
+ const cp = parseInt(h, 16);
168
+ if (cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) fail('bad escape');
169
+ return String.fromCodePoint(cp);
170
+ };
171
+ const escape = () => {
172
+ const e = src[i + 1];
173
+ i += 2;
174
+ switch (e) {
175
+ case 'b': return '\b';
176
+ case 't': return '\t';
177
+ case 'n': return '\n';
178
+ case 'f': return '\f';
179
+ case 'r': return '\r';
180
+ case 'e': return '\x1b';
181
+ case '"': return '"';
182
+ case '\\': return '\\';
183
+ case 'x': return hexChar(2);
184
+ case 'u': return hexChar(4);
185
+ case 'U': return hexChar(8);
186
+ default: return fail('bad escape');
187
+ }
188
+ };
189
+ const basicString = () => {
190
+ i += 1;
191
+ let s = '';
192
+ for (;;) {
193
+ if (i >= n) fail('unterminated string');
194
+ const ch = src[i];
195
+ if (ch === '"') return (i += 1), s;
196
+ if (ch === '\n' || ch === '\r') fail('newline in a string');
197
+ if (ch === '\\') s += escape();
198
+ else (s += ch), (i += 1);
199
+ }
200
+ };
201
+ const literalString = () => {
202
+ const end = src.indexOf("'", i + 1);
203
+ if (end < 0) fail('unterminated string');
204
+ const s = src.slice(i + 1, end);
205
+ if (/[\r\n]/.test(s)) fail('newline in a string');
206
+ i = end + 1;
207
+ return s;
208
+ };
209
+ const LINE_END_BACKSLASH = /\\[ \t]*\r?\n/y;
210
+ const multiline = (quote, escapes) => {
211
+ i += 3;
212
+ newline(); // a newline straight after the opening quotes is not content
213
+ let s = '';
214
+ for (;;) {
215
+ if (i >= n) fail('unterminated string');
216
+ if (src.startsWith(quote.repeat(3), i)) {
217
+ let q = 3;
218
+ while (q < 5 && src[i + q] === quote) q++; // up to two belong to the content
219
+ s += quote.repeat(q - 3);
220
+ i += q;
221
+ return s;
222
+ }
223
+ if (escapes && src[i] === '\\') {
224
+ LINE_END_BACKSLASH.lastIndex = i;
225
+ if (LINE_END_BACKSLASH.test(src)) {
226
+ i = LINE_END_BACKSLASH.lastIndex;
227
+ while (i < n && /[ \t\r\n]/.test(src[i])) i++;
228
+ } else s += escape();
229
+ } else (s += src[i]), (i += 1);
230
+ }
231
+ };
232
+ const key = () => {
233
+ const parts = [];
234
+ for (;;) {
235
+ ws();
236
+ if (src[i] === '"') {
237
+ if (src.startsWith('"""', i)) fail('a multi-line key');
238
+ parts.push(basicString());
239
+ } else if (src[i] === "'") {
240
+ if (src.startsWith("'''", i)) fail('a multi-line key');
241
+ parts.push(literalString());
242
+ } else {
243
+ BARE.lastIndex = i;
244
+ const m = BARE.exec(src);
245
+ if (!m) fail('expected a key');
246
+ parts.push(m[0]);
247
+ i = BARE.lastIndex;
248
+ }
249
+ ws();
250
+ if (src[i] !== '.') return parts;
251
+ i += 1;
252
+ }
253
+ };
254
+ const assign = (target, path, v) => {
255
+ let t = target;
256
+ for (const k of path.slice(0, -1)) {
257
+ if (!Object.hasOwn(t, k)) t[k] = newTable();
258
+ else if (!isTable(t[k])) fail(`"${k}" is not a table`);
259
+ t = t[k];
260
+ }
261
+ const last = path.at(-1);
262
+ if (Object.hasOwn(t, last)) fail(`"${last}" is defined twice`);
263
+ t[last] = v;
264
+ };
265
+ let depth = 0;
266
+ const nested = (open) => {
267
+ if (++depth > MAX_DEPTH) fail('nested too deep');
268
+ try {
269
+ return open();
270
+ } finally {
271
+ depth -= 1;
272
+ }
273
+ };
274
+ const array = () => {
275
+ i += 1;
276
+ const out = [];
277
+ for (;;) {
278
+ gap();
279
+ if (src[i] === ']') return (i += 1), out;
280
+ out.push(value());
281
+ gap();
282
+ if (src[i] === ',') i += 1;
283
+ else if (src[i] === ']') return (i += 1), out;
284
+ else fail('expected , or ]');
285
+ }
286
+ };
287
+ const inlineTable = () => {
288
+ i += 1;
289
+ const t = newTable();
290
+ for (;;) {
291
+ gap();
292
+ if (src[i] === '}') return (i += 1), t;
293
+ keyValue(t);
294
+ gap();
295
+ if (src[i] === ',') i += 1;
296
+ else if (src[i] === '}') return (i += 1), t;
297
+ else fail('expected , or }');
298
+ }
299
+ };
300
+ const word = (w) => src.startsWith(w, i) && !/[A-Za-z0-9_-]/.test(src[i + w.length] ?? '');
301
+ const value = () => {
302
+ const ch = src[i];
303
+ if (ch === '"') return src.startsWith('"""', i) ? multiline('"', true) : basicString();
304
+ if (ch === "'") return src.startsWith("'''", i) ? multiline("'", false) : literalString();
305
+ if (ch === '[') return nested(array);
306
+ if (ch === '{') return nested(inlineTable);
307
+ if (word('true')) return (i += 4), true;
308
+ if (word('false')) return (i += 5), false;
309
+ SCALAR.lastIndex = i;
310
+ const m = SCALAR.exec(src);
311
+ if (!m) fail('a value this reader does not know');
312
+ i = SCALAR.lastIndex;
313
+ if (/[A-Za-z0-9_.:+-]/.test(src[i] ?? '')) fail('a value this reader does not know');
314
+ return new TomlRaw(m[0]);
315
+ };
316
+ const keyValue = (target) => {
317
+ const path = key();
318
+ if (src[i] !== '=') fail('expected =');
319
+ i += 1;
320
+ ws();
321
+ assign(target, path, value());
322
+ };
323
+
324
+ const root = newTable();
325
+ let current = root;
326
+ for (;;) {
327
+ gap();
328
+ if (i >= n) return root;
329
+ if (src[i] !== '[') {
330
+ keyValue(current);
331
+ endOfLine();
332
+ continue;
333
+ }
334
+ const many = src[i + 1] === '[';
335
+ i += many ? 2 : 1;
336
+ const path = key();
337
+ if (many ? !src.startsWith(']]', i) : src[i] !== ']') fail('an unclosed table header');
338
+ i += many ? 2 : 1;
339
+ endOfLine();
340
+ let t = root;
341
+ for (const k of path.slice(0, -1)) {
342
+ if (!Object.hasOwn(t, k)) t[k] = newTable();
343
+ const v = Array.isArray(t[k]) ? t[k].at(-1) : t[k];
344
+ if (!isTable(v)) fail(`"${k}" is not a table`);
345
+ t = v;
346
+ }
347
+ const last = path.at(-1);
348
+ if (many) {
349
+ if (!Object.hasOwn(t, last)) t[last] = [];
350
+ if (!Array.isArray(t[last])) fail(`"${last}" is not an array of tables`);
351
+ current = newTable();
352
+ t[last].push(current);
353
+ } else {
354
+ if (!Object.hasOwn(t, last)) t[last] = newTable();
355
+ else if (!isTable(t[last])) fail(`"${last}" is not a table`);
356
+ current = t[last];
357
+ }
358
+ }
359
+ }
360
+
361
+ /**
362
+ * The person's config.toml, where the turn's Codex would look —
363
+ * `$CODEX_HOME/config.toml` (an agent's own home when its lane keeps one),
364
+ * else `~/.codex/config.toml` — parsed. Null when it is absent, too big or
365
+ * not a document this reader can read whole.
366
+ */
367
+ export function readPersonalCodexConfig(env = process.env) {
368
+ const home = env.CODEX_HOME || join(env.HOME || homedir(), '.codex');
369
+ const path = join(home, 'config.toml');
370
+ try {
371
+ if (statSync(path).size > CONFIG_MAX_BYTES) return null;
372
+ return parseCodexToml(readFileSync(path, 'utf8'));
373
+ } catch {
374
+ return null;
375
+ }
376
+ }
377
+
378
+ /** One value as TOML, as a `-c` override's value is parsed. */
379
+ const bareKeyOr = (k) => (/^[A-Za-z0-9_-]+$/.test(k) ? k : toml(k));
380
+ export function tomlValue(v) {
381
+ if (typeof v === 'string') return toml(v);
382
+ if (typeof v === 'boolean') return String(v);
383
+ if (v instanceof TomlRaw) return v.text;
384
+ if (Array.isArray(v)) return `[${v.map(tomlValue).join(',')}]`;
385
+ if (isTable(v)) return `{${Object.keys(v).map((k) => `${bareKeyOr(k)}=${tomlValue(v[k])}`).join(',')}}`;
386
+ throw new Error('not a TOML value');
387
+ }
388
+
389
+ const isString = (v) => typeof v === 'string';
390
+ const isStringList = (v) => Array.isArray(v) && v.every(isString);
391
+ const nonEmpty = (v) => typeof v === 'string' && v.trim() !== '';
392
+
393
+ /**
394
+ * The selected provider's kept fields, with its literal secrets moved into
395
+ * `env` (see the header). Mutates nothing it was handed.
396
+ */
397
+ function keptProvider(table, env, turnEnv) {
398
+ const out = newTable();
399
+ for (const k of CODEX_PROVIDER_KEPT) if (Object.hasOwn(table, k)) out[k] = table[k];
400
+ if (isString(out.experimental_bearer_token)) {
401
+ // Codex puts this token before `env_key`, so taking `env_key`'s place
402
+ // keeps the person's precedence.
403
+ env[CODEX_BEARER_ENV] = out.experimental_bearer_token;
404
+ out.env_key = CODEX_BEARER_ENV;
405
+ }
406
+ delete out.experimental_bearer_token;
407
+ if (isTable(out.http_headers)) {
408
+ const byEnv = isTable(out.env_http_headers) ? { ...out.env_http_headers } : newTable();
409
+ let n = 0;
410
+ for (const [header, literal] of Object.entries(out.http_headers)) {
411
+ if (!isString(literal)) continue;
412
+ // An env header whose variable is set already wins over the literal in
413
+ // Codex; only where it would not does the literal ride.
414
+ if (Object.hasOwn(byEnv, header) && nonEmpty(turnEnv[byEnv[header]])) continue;
415
+ const name = `${CODEX_HEADER_ENV_PREFIX}${n++}`;
416
+ env[name] = literal;
417
+ byEnv[header] = name;
418
+ }
419
+ if (Object.keys(byEnv).length) out.env_http_headers = Object.assign(newTable(), byEnv);
420
+ }
421
+ delete out.http_headers;
422
+ return out;
423
+ }
424
+
425
+ /**
426
+ * What a fenced turn carries of `config`: `args` — `-c key=value` pairs for
427
+ * the posture's argv — and `env`, the literal secrets they name.
428
+ *
429
+ * · the login keys, when they hold what Codex takes (a string; the workspace
430
+ * id may be a list of strings) — a wrong-typed one is skipped, never
431
+ * guessed at;
432
+ * · `model_provider` and that provider's table, whole-table form, so an id
433
+ * with a dot in it survives (`-c` splits a dotted path on every dot); only
434
+ * the SELECTED provider — the built-in `openai` cannot be overridden (the
435
+ * CLI refuses the file), and an unselected table signs nothing in;
436
+ * · `model` and `model_reasoning_effort` ONLY where the turn pins none — an
437
+ * explicit pin is the argv's own `--model` / effort override and wins. An
438
+ * unpinned fenced turn then runs on "Machine default" as the person's CLI
439
+ * means it, and a custom provider gets a model it serves.
440
+ */
441
+ export function codexPersonalCarry(config, { model = null, effort = null, env: turnEnv = {} } = {}) {
442
+ const args = [];
443
+ const env = {};
444
+ if (!isTable(config)) return { args, env };
445
+ const push = (k, v) => args.push('-c', `${k}=${tomlValue(v)}`);
446
+ for (const k of CODEX_LOGIN_KEPT) {
447
+ const v = config[k];
448
+ if (isString(v) || (k === 'forced_chatgpt_workspace_id' && isStringList(v))) push(k, v);
449
+ }
450
+ const id = config.model_provider;
451
+ if (isString(id) && id) {
452
+ const table = isTable(config.model_providers) ? config.model_providers[id] : undefined;
453
+ if (isTable(table)) {
454
+ const kept = keptProvider(table, env, turnEnv);
455
+ if (Object.keys(kept).length) push('model_providers', Object.assign(newTable(), { [id]: kept }));
456
+ }
457
+ push('model_provider', id);
458
+ }
459
+ if (!model && nonEmpty(config.model)) push('model', config.model);
460
+ if (!effort && nonEmpty(config.model_reasoning_effort)) push('model_reasoning_effort', config.model_reasoning_effort);
461
+ return { args, env };
462
+ }
463
+
464
+ /** The carry for one turn: the config its Codex would read, reduced. */
465
+ export function codexPersonalTurn({ env = process.env, model = null, effort = null } = {}) {
466
+ return codexPersonalCarry(readPersonalCodexConfig(env), { model, effort, env });
467
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * WHAT A CODEX TURN'S OWN WORDS SAY WENT WRONG, SAID IN THE MACHINE'S WORDS
3
+ * (2026-09-29) — for the Terminal tab and the New task chat, whose reply is
4
+ * whatever the turn printed.
5
+ *
6
+ * Two failures the owner met on a Codex New task chat, both measured on
7
+ * codex-cli 0.156.1:
8
+ * · NO LOGIN. The request went out with no credentials and the provider
9
+ * refused it: `unexpected status 401 Unauthorized: Missing bearer or basic
10
+ * authentication in header`, five retries on the websocket and five on
11
+ * HTTPS, then `turn.failed` — and the chat's reply was that retry log. A
12
+ * login the provider REFUSED reads `unexpected status 401 Unauthorized:
13
+ * Incorrect API key provided: …` instead (measured with a made-up key).
14
+ * · A TOOL CALL NOBODY COULD APPROVE. An MCP tool Codex holds to need
15
+ * approval, under an approval policy that cannot ask, fails the call with
16
+ * `MCP tool call requires approval, but approval policy is never` — and
17
+ * the chat said only whatever the model made of it, with nothing staged.
18
+ *
19
+ * Literal matches on those sentences, like cliLimit.mjs: a trigger, never a
20
+ * classifier. The sentence names what was measured and the one remedy the
21
+ * machine's own CLI offers; Codex's own words stay beside it.
22
+ */
23
+ import { scrub as envScrub } from './uplinkScrub.mjs';
24
+ import { CODEX_RUNTIME } from './runtimeCodex.mjs';
25
+
26
+ const NO_LOGIN = /unexpected status 401 Unauthorized: Missing bearer or basic authentication/;
27
+ const REFUSED_LOGIN = /unexpected status 401 Unauthorized/;
28
+ export const APPROVAL_REFUSED = /MCP tool call requires approval, but approval policy is never/;
29
+
30
+ /** 'missing' | 'refused' | null — what a turn's output says of its login. */
31
+ export function codexLoginFailure(out) {
32
+ const s = String(out ?? '');
33
+ if (NO_LOGIN.test(s)) return 'missing';
34
+ if (REFUSED_LOGIN.test(s)) return 'refused';
35
+ return null;
36
+ }
37
+
38
+ /** The sentence for `codexLoginFailure`'s answer; `login` is the registry's
39
+ * own sign-in command. */
40
+ export function codexLoginSentence(failure, login = CODEX_RUNTIME.login) {
41
+ if (failure === 'missing')
42
+ return `Codex isn't signed in for this chat on the machine — it sent the request with no login, and the provider refused it (401 Unauthorized). Run \`${login}\` there, then send the message again.`;
43
+ if (failure === 'refused')
44
+ return `Codex's login on the machine was refused for this chat (401 Unauthorized) — run \`${login}\` there, then send the message again.`;
45
+ return null;
46
+ }
47
+
48
+ /** The sentence for tool calls Codex refused for want of an approval, named
49
+ * as the turn named them (`server.tool`). Null when there were none. */
50
+ export function codexApprovalSentence(refused) {
51
+ const names = [...new Set((refused ?? []).filter(Boolean))];
52
+ const n = (refused ?? []).length;
53
+ if (!n) return null;
54
+ const which = names.length ? ` (${names.join(', ')})` : '';
55
+ return `Codex on the machine refused ${n} tool call${n === 1 ? '' : 's'} in this turn${which} — "MCP tool call requires approval, but approval policy is never" — so nothing ${n === 1 ? 'it' : 'they'} would have done was done.`;
56
+ }
57
+
58
+ /** A reply with the sentence first and Codex's own words kept under it,
59
+ * scrubbed like every reply and cut to the lane's cap. */
60
+ export function withRelay(sentence, raw, cap = 16000) {
61
+ const words = envScrub(String(raw ?? '')).trim();
62
+ return (words ? `${sentence}\n\n${words}` : sentence).slice(0, cap);
63
+ }