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.
- package/README.md +9 -0
- package/bin/lib/agentTaskKinds.mjs +10 -6
- package/bin/lib/artifacts.mjs +43 -1
- package/bin/lib/claudePersonal.mjs +145 -0
- package/bin/lib/codexPersonal.mjs +467 -0
- package/bin/lib/codexRelay.mjs +63 -0
- package/bin/lib/prompts.mjs +74 -22
- package/bin/lib/resumeLost.mjs +16 -2
- package/bin/lib/runTurn.mjs +24 -1
- package/bin/lib/runtimeClaude.mjs +44 -11
- package/bin/lib/runtimeCodex.mjs +165 -4
- package/bin/lib/runtimeEvents.mjs +9 -2
- package/bin/lib/turnProfile.mjs +3 -2
- package/bin/lib/workAgentTurnExecution.mjs +23 -3
- package/bin/lib/workSessionTurns.mjs +145 -30
- package/package.json +1 -1
|
@@ -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
|
+
}
|