flowviant 0.114.2 → 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.
@@ -33,9 +33,13 @@
33
33
  * sentence a turn settles `nothing` with when the proof was not written;
34
34
  * - `offRuntime(rt)`: the sentence when the chosen CLI cannot run the posture.
35
35
  */
36
- /** The mockup, 3D-model, presentation and write-up kinds run on Claude: the one
37
- * CLI that declares their postures (`design`, `research`). */
38
- const CLAUDE_ONLY = 'mockup, 3D model, presentation and write-up cards run on Claude on this machine';
36
+ /** The mockup, 3D-model and presentation kinds run under the `design` posture,
37
+ * which Claude and — since 0.115.0 — Codex declare (2026-09-29, measured:
38
+ * runtimeCodex.mjs `codexDesignFence`). */
39
+ const DESIGN_CLIS = 'mockup, 3D model and presentation cards run on Claude or Codex on this machine';
40
+ /** The write-up kind runs on Claude alone: Codex cannot fence the web it needs
41
+ * apart from the whole-box reads its sandbox allows (same place). */
42
+ const CLAUDE_ONLY = 'write-up cards run on Claude on this machine';
39
43
  /** The image kind runs on Codex: the one CLI that declares its posture, because
40
44
  * the pictures come from Codex's own image tool on its own login. */
41
45
  const CODEX_ONLY = 'image cards run on Codex on this machine';
@@ -52,7 +56,7 @@ export const AGENT_TASK_KINDS = Object.freeze({
52
56
  match: /\.html?$/i,
53
57
  missing: 'the turn ended without writing a mockup under .flowviant/artifacts/',
54
58
  }),
55
- offRuntime: () => CLAUDE_ONLY,
59
+ offRuntime: () => DESIGN_CLIS,
56
60
  }),
57
61
  /**
58
62
  * THE 3D-MODEL KIND (0.105.0; one page since 0.107.0): a three.js page that
@@ -69,7 +73,7 @@ export const AGENT_TASK_KINDS = Object.freeze({
69
73
  match: /\.html?$/i,
70
74
  missing: 'the turn ended without writing a 3D model page under .flowviant/artifacts/',
71
75
  }),
72
- offRuntime: () => CLAUDE_ONLY,
76
+ offRuntime: () => DESIGN_CLIS,
73
77
  }),
74
78
  /**
75
79
  * THE IMAGE KIND (0.114.0): one or more raster pictures, generated by
@@ -97,7 +101,7 @@ export const AGENT_TASK_KINDS = Object.freeze({
97
101
  match: /\.html?$/i,
98
102
  missing: 'the turn ended without writing a deck under .flowviant/artifacts/',
99
103
  }),
100
- offRuntime: () => CLAUDE_ONLY,
104
+ offRuntime: () => DESIGN_CLIS,
101
105
  }),
102
106
  research: Object.freeze({
103
107
  posture: 'research',
@@ -74,7 +74,7 @@
74
74
  * that cannot show an artifact must not have the CLI told it will.
75
75
  */
76
76
 
77
- import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync } from 'node:fs';
77
+ import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, rmSync } from 'node:fs';
78
78
  import { createHash } from 'node:crypto';
79
79
  import { join } from 'node:path';
80
80
  import { cutKeepingExtension } from './safeFileName.mjs';
@@ -138,6 +138,48 @@ export function ensureArtifactDir(placeDir) {
138
138
  return real(parent) === true && real(dir) === true;
139
139
  }
140
140
 
141
+ /**
142
+ * A FENCED TURN'S OWN SCRATCH — `<place>/.flowviant/tmp`, beside the artifacts
143
+ * directory, under the `.flowviant/` git already ignores (0.115.0, 2026-09-29).
144
+ *
145
+ * MEASURED on codex-cli 0.156.1: under an artifacts-only profile `/tmp` is
146
+ * read-only, and a shell here-document needs a temp file (zsh writes it under
147
+ * `$TMPPREFIX`, bash and `mktemp` under `$TMPDIR`) — so `cat > page.html
148
+ * <<EOF` failed "can't create temp file for here document: read-only file
149
+ * system", left an empty page, and the turn stopped to ask why the artifacts
150
+ * directory was read-only. Codex's design fence grants this directory and
151
+ * points both variables at it (runtimeCodex.mjs `codexDesignFence`), so the
152
+ * shell works like a shell; nothing here is uploaded, committed or read back.
153
+ *
154
+ * Made EMPTY before each turn (a previous turn's scratch is nobody's), and
155
+ * only as a real directory under a real `.flowviant` — the artifacts
156
+ * directory's rule, for its reason: a fence grant on a path through a
157
+ * committed symlink is a write grant on its target. False creates nothing
158
+ * that matters, and the caller refuses the turn in words.
159
+ */
160
+ export const FENCE_TMP_DIR = '.flowviant/tmp';
161
+ export function ensureFenceScratch(placeDir) {
162
+ const parent = join(placeDir, '.flowviant');
163
+ const dir = join(placeDir, FENCE_TMP_DIR);
164
+ const kind = (p) => {
165
+ try {
166
+ const st = lstatSync(p);
167
+ return st.isDirectory() && !st.isSymbolicLink() ? 'dir' : 'other';
168
+ } catch (e) {
169
+ return e?.code === 'ENOENT' ? 'none' : 'other';
170
+ }
171
+ };
172
+ if (kind(parent) !== 'dir' || kind(dir) === 'other') return false;
173
+ try {
174
+ // A real directory (checked above): its contents go, never through a link.
175
+ if (kind(dir) === 'dir') rmSync(dir, { recursive: true, force: true });
176
+ mkdirSync(dir);
177
+ } catch {
178
+ return false;
179
+ }
180
+ return kind(parent) === 'dir' && kind(dir) === 'dir';
181
+ }
182
+
141
183
  export function artifactTypeFor(name) {
142
184
  const dot = String(name).lastIndexOf('.');
143
185
  if (dot <= 0) return null;
@@ -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
+ }
@@ -1111,8 +1111,22 @@ ${ARTIFACT_CONTRACT_TAIL({
1111
1111
  * WHICH CONTRACT A TURN RUNS UNDER, by the card's kind. Absent or unknown is
1112
1112
  * code, and code is `SYSTEM_AGENT` — the same object, so a code turn's system
1113
1113
  * prompt is byte-for-byte what it was before kinds existed.
1114
+ *
1115
+ * …AND WHO IT IS SPOKEN TO (0.115.0, 2026-09-29). The mockup, 3D-model and
1116
+ * deck contracts open "You are the human's own Claude" because Claude ran them
1117
+ * alone; Codex runs them now (`codexDesignFence`), and a contract that calls
1118
+ * Codex Claude is the prompt telling the agent something false about itself.
1119
+ * `runtimeLabel` is the CLI's own name ("Codex"); absent, or Claude's, the
1120
+ * contract is the pinned string itself, byte for byte. Only that opening
1121
+ * names a CLI — nothing else in these contracts is Claude's (no tool names,
1122
+ * no flags), so nothing else changes.
1114
1123
  */
1115
- export const SYSTEM_AGENT_FOR = (taskKind) => AGENT_CONTRACTS[agentTaskKindOf(taskKind)];
1124
+ const CLAUDE_OPENING = "You are the human's own Claude,";
1125
+ export const SYSTEM_AGENT_FOR = (taskKind, runtimeLabel = null) => {
1126
+ const contract = AGENT_CONTRACTS[agentTaskKindOf(taskKind)];
1127
+ if (!runtimeLabel || /^claude\b/i.test(runtimeLabel) || !contract.startsWith(CLAUDE_OPENING)) return contract;
1128
+ return `You are the human's own ${runtimeLabel},${contract.slice(CLAUDE_OPENING.length)}`;
1129
+ };
1116
1130
 
1117
1131
  /**
1118
1132
  * THE STRINGS EACH KIND IS HANDED, keyed by the kinds in agentTaskKinds.mjs —
@@ -123,6 +123,23 @@ export function runTurn(opts) {
123
123
  resolve('');
124
124
  return;
125
125
  }
126
+ /**
127
+ * THE PERSON'S LOGIN, FOR A POSTURE THAT READS NONE OF THEIR CONFIG
128
+ * (2026-09-29, codexPersonal.mjs). Asked of the runtime that has one
129
+ * (Codex; Claude reads its own inside its adapter), with the environment
130
+ * this turn's CLI will run in — its CODEX_HOME is the agent's own when the
131
+ * lane keeps one — so the file read is the file the CLI would have read.
132
+ * Its `args` go to the adapter; its `env`, the literal secrets those name,
133
+ * to the child below and never to argv.
134
+ */
135
+ const personal =
136
+ rt.personal?.({
137
+ profile: turnProfile.adapterProfile,
138
+ vaultDir,
139
+ model,
140
+ effort,
141
+ env: { ...process.env, ...(mcpEnv ?? {}) },
142
+ }) ?? null;
126
143
  // Pin the model — never inherit the user's global default (which for Claude
127
144
  // may be a 1M/long-context tier their subscription can't bill autonomous
128
145
  // work on). A per-task override (chosen in the app, validated server-side
@@ -139,6 +156,7 @@ export function runTurn(opts) {
139
156
  // which posture a turn is running under. There is no precedence chain left
140
157
  // to get wrong: the caller named one profile (turnProfile.mjs).
141
158
  const args = rt.args({
159
+ personal,
142
160
  prompt,
143
161
  system,
144
162
  model,
@@ -237,7 +255,12 @@ export function runTurn(opts) {
237
255
  // this environment, and handing it a curated one signs it out. What it
238
256
  // REMOVES is this daemon's machine credential and, for a turn nobody is
239
257
  // sitting at, every way to a window — see `cliEnv`.
240
- env: cliEnv(mcpEnv, { display: display === true, browserHome: browser?.dir }),
258
+ // The login's literal secrets first, so nothing of the lane's (its MCP
259
+ // token, its CODEX_HOME) can be overwritten by one.
260
+ env: cliEnv(personal?.env ? { ...personal.env, ...(mcpEnv ?? {}) } : mcpEnv, {
261
+ display: display === true,
262
+ browserHome: browser?.dir,
263
+ }),
241
264
  });
242
265
  if (browser) {
243
266
  child.once('close', browser.cleanup);
@@ -52,11 +52,14 @@ export const CLAUDE_RUNTIME = {
52
52
  * "consult" and "plan" currently mean. That is a statement about where the
53
53
  * contract was written, not a claim that only Claude could ever satisfy it.
54
54
  */
55
- // `design` and `research` (0.97.0) are Claude's alone: each is an
56
- // `--allowedTools` list with a PATH-SCOPED write (claudePosture.mjs), and neither
57
- // codex's sandbox modes nor agy's flags were measured to express "write
58
- // only this directory". A runtime that does not declare them is refused
59
- // such a card before spawn, in words — never handed a build turn instead.
55
+ // `design` and `research` (0.97.0): each is an `--allowedTools` list with a
56
+ // PATH-SCOPED write (claudePosture.mjs). `research` is Claude's alone;
57
+ // `design` is Codex's too since 0.115.0 (2026-09-29), whose permission
58
+ // profile was measured to write only the artifacts directory
59
+ // (runtimeCodex.mjs `codexDesignFence`). agy's flags cannot express "write
60
+ // only this directory" (runtimeAntigravity.mjs). A runtime that does not
61
+ // declare a posture is refused such a card before spawn, in words — never
62
+ // handed a build turn instead.
60
63
  profiles: ['build', 'wiki', 'consult', 'plan', 'design', 'research'],
61
64
  /**
62
65
  * THE EFFORTS THIS CLI SPELLS — the daemon's copy of the server's
@@ -12,8 +12,30 @@
12
12
  import { isAbsolute, join } from 'node:path';
13
13
  import { SAFE } from './config.mjs';
14
14
  import { parseCodexLine } from './runtimeEvents.mjs';
15
- import { ARTIFACT_DIR } from './artifacts.mjs';
15
+ import { ARTIFACT_DIR, FENCE_TMP_DIR } from './artifacts.mjs';
16
+ import { codexPersonalTurn } from './codexPersonal.mjs';
16
17
 
18
+ /**
19
+ * FLOWVIANT'S OWN TOOLS NEED NO APPROVAL FROM A CHAT NOBODY CAN ANSWER
20
+ * (2026-09-29). The owner's Codex New task chat staged nothing: "flowviant
21
+ * rejected the tool calls because approval is required and this session's
22
+ * policy forbids it". MEASURED on codex-cli 0.156.1 with a fake MCP server
23
+ * whose one tool carries no annotations (Flowviant's carry none): under the
24
+ * capture chat's read-only argv the call failed "MCP tool call requires
25
+ * approval, but approval policy is never" and never reached the server; under
26
+ * the build posture (workspace-write) the same call ran unasked. With
27
+ * `mcp_servers.flowviant.default_tools_approval_mode="approve"` beside the
28
+ * read-only argv it ran — and in the same turn a shell write still failed
29
+ * "Read-only file system" and an escalation still failed "approval policy is
30
+ * Never; reject command".
31
+ *
32
+ * So the approval is this ONE server's, never a blanket `approval_policy`:
33
+ * what its tools may do is the per-turn token's scope, which the server
34
+ * enforces (a capture token stages and reads; it files nothing), and every
35
+ * other door — the shell, file writes, escalation, any other server — keeps
36
+ * the posture's own rule. Wherever the server is mounted: the capture chat
37
+ * needs it, and a Terminal tab's build posture already ran the calls.
38
+ */
17
39
  function codexMcp(token, mcpUrl) {
18
40
  return {
19
41
  dir: null, // nothing written — the token rides in the environment
@@ -22,11 +44,23 @@ function codexMcp(token, mcpUrl) {
22
44
  `mcp_servers.flowviant.url="${mcpUrl}"`,
23
45
  '-c',
24
46
  'mcp_servers.flowviant.bearer_token_env_var="FLOWVIANT_MCP_TOKEN"',
47
+ '-c',
48
+ 'mcp_servers.flowviant.default_tools_approval_mode="approve"',
25
49
  ],
26
50
  env: { FLOWVIANT_MCP_TOKEN: token },
27
51
  };
28
52
  }
29
53
 
54
+ /**
55
+ * THE POSTURES THAT READ NONE OF THE PERSON'S CONFIG — the branches of `args()`
56
+ * below that pass `--ignore-user-config`, said once so the adapter and its
57
+ * `personal` hook cannot disagree about which turns carry the person's login
58
+ * back (codexPersonal.mjs). A wiki turn with no vault is a build turn there,
59
+ * and so here.
60
+ */
61
+ export const codexIgnoresUserConfig = ({ profile = 'build', vaultDir } = {}) =>
62
+ profile === 'consult' || profile === 'plan' || profile === 'image' || profile === 'design' || (profile === 'wiki' && Boolean(vaultDir));
63
+
30
64
  /**
31
65
  * WHAT A FENCED CODEX TURN MAY NOT REACH FOR (0.114.0). codex-cli 0.156.1
32
66
  * ships `browser_use` (and its external and full-CDP forms), an in-app
@@ -71,8 +105,13 @@ export const CODEX_RUNTIME = {
71
105
  * AND `image` (0.114.0), which ONLY Codex declares: the image card's fence —
72
106
  * see the branch in `args()`. It is the one kind posture that is not
73
107
  * Claude's, because the pictures come from Codex's own image tool.
108
+ *
109
+ * AND `design` (0.115.0), shared with Claude: the mockup, 3D-model and deck
110
+ * cards' fence, expressed as `codexDesignFence` below. Research is NOT
111
+ * declared: its web and Codex's whole-box reads cannot be fenced apart
112
+ * (see that function).
74
113
  */
75
- profiles: ['build', 'consult', 'wiki', 'plan', 'image'],
114
+ profiles: ['build', 'consult', 'wiki', 'plan', 'image', 'design'],
76
115
  /** `model_reasoning_effort` tops out at `xhigh` — there is no `max`, and a
77
116
  * queued or skewed job naming it would be an argument Codex refuses. */
78
117
  efforts: ['low', 'medium', 'high', 'xhigh'],
@@ -102,7 +141,7 @@ export const CODEX_RUNTIME = {
102
141
  * placed before it. Appending them after the positional is the kind of argv
103
142
  * that parses today and stops parsing on some future clap upgrade.
104
143
  */
105
- args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools }) {
144
+ args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null }) {
106
145
  // Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
107
146
  // agy moves) — codex has no adoptable store wired yet. Reaching here
108
147
  // with an adopt id is a wiring mistake upstream, and it fails loudly on
@@ -163,6 +202,10 @@ export const CODEX_RUNTIME = {
163
202
 
164
203
  // Sub-agents would be a second turn whose posture nobody here chose.
165
204
  a.push('-c', 'features.multi_agent=false', '-c', 'features.goals=false', ...NO_REACH_OUT);
205
+ // Image generation ships ON in codex-cli 0.156.1 even under
206
+ // --ignore-user-config (measured 2026-09-29): a turn that asked for no
207
+ // picture is handed no tool that spends image credits.
208
+ a.push('--disable', 'image_generation');
166
209
 
167
210
  // HERMETIC. Without these a user's ~/.codex/config.toml, a project
168
211
  // `.rules` execpolicy file, or their own MCP servers can widen a posture
@@ -190,6 +233,10 @@ export const CODEX_RUNTIME = {
190
233
  a.push('-c', 'sandbox_mode="read-only"');
191
234
  a.push('-c', 'tools.web_search=false', '-c', 'web_search="disabled"');
192
235
  a.push('-c', 'features.multi_agent=false', '-c', 'features.goals=false', ...NO_REACH_OUT);
236
+ // Image generation ships ON in codex-cli 0.156.1 even under
237
+ // --ignore-user-config (measured 2026-09-29): a turn that asked for no
238
+ // picture is handed no tool that spends image credits.
239
+ a.push('--disable', 'image_generation');
193
240
  a.push('--ignore-user-config', '--ignore-rules');
194
241
  } else if (profile === 'wiki' && vaultDir) {
195
242
  // THE CARTOGRAPHER, AND THIS ONE IS STRICTER THAN CLAUDE'S.
@@ -217,6 +264,7 @@ export const CODEX_RUNTIME = {
217
264
  // Selected by config: `codex exec` (0.156.1) has no `-P`, so every Codex
218
265
  // wiki turn was refused before it started (0.114.0).
219
266
  a.push('-c', 'default_permissions="flowviantwiki"');
267
+ a.push('--disable', 'image_generation');
220
268
  a.push(...NO_REACH_OUT);
221
269
  a.push('--ignore-user-config', '--ignore-rules');
222
270
  } else if (profile === 'image') {
@@ -261,7 +309,12 @@ export const CODEX_RUNTIME = {
261
309
  a.push('-c', 'permissions.flowviantimage.extends=":read-only"');
262
310
  // The table WHOLE (the wiki branch's reason: a dotted key splits on the
263
311
  // dots inside the path), the path as a TOML basic string.
264
- a.push('-c', `permissions.flowviantimage.filesystem={${JSON.stringify(artifacts)}="write"}`);
312
+ // …and the fence's own scratch (2026-09-29, the design fence's
313
+ // measurement: a `cat > x <<EOF` needs a temp file, and /tmp is
314
+ // read-only in here), named for the shells through TMPDIR/TMPPREFIX.
315
+ const scratch = join(cwd, FENCE_TMP_DIR);
316
+ a.push('-c', `permissions.flowviantimage.filesystem={${JSON.stringify(artifacts)}="write",${JSON.stringify(scratch)}="write"}`);
317
+ a.push('-c', `shell_environment_policy.set={TMPDIR=${JSON.stringify(scratch)},TMPPREFIX=${JSON.stringify(join(scratch, 'zsh'))}}`);
265
318
  a.push('-c', 'default_permissions="flowviantimage"');
266
319
  a.push(...NO_REACH_OUT);
267
320
  // Nobody is on this end of the pipe to approve an escalation.
@@ -272,6 +325,10 @@ export const CODEX_RUNTIME = {
272
325
  a.push('-c', 'tools.web_search=false', '-c', 'web_search="disabled"');
273
326
  a.push('-c', 'features.multi_agent=false', '-c', 'features.goals=false', ...NO_REACH_OUT);
274
327
  a.push('--ignore-user-config', '--ignore-rules');
328
+ } else if (profile === 'design') {
329
+ // A MOCKUP, 3D-MODEL OR DECK CARD (0.115.0): the fence is
330
+ // `codexDesignFence`'s, argued and measured at its definition below.
331
+ a.push(...codexDesignFence(cwd));
275
332
  } else {
276
333
  // The daemon's build posture, mapped: SAFE keeps writes inside the
277
334
  // workspace, the default lets the agent run its own tests and git
@@ -286,13 +343,117 @@ export const CODEX_RUNTIME = {
286
343
  a.push('-c', `sandbox_mode="${SAFE ? 'workspace-write' : 'danger-full-access'}"`);
287
344
  }
288
345
 
346
+ /**
347
+ * …AND THE PERSON'S LOGIN RIDES BACK BESIDE THE FLAG (2026-09-29,
348
+ * codexPersonal.mjs): where the login lives, the provider and endpoint it
349
+ * signs into, and the model an unpinned turn runs — `-c` pairs after
350
+ * `--ignore-user-config`, never a key that widens the posture above.
351
+ * Keyed on the flag itself, so a fenced branch added later carries the
352
+ * login the day it ignores the file. `personal` is runTurn's, read where
353
+ * the turn's Codex will look (the `personal` hook below); a caller that
354
+ * hands none gets the posture as it was.
355
+ */
356
+ if (a.includes('--ignore-user-config')) a.push(...(personal?.args ?? []));
357
+
289
358
  a.push(...mcp, ...resultSchemaArgs);
290
359
  a.push(`${system}\n\n---\n\n${prompt}`);
291
360
  return a;
292
361
  },
362
+ /**
363
+ * THE PERSON'S LOGIN FOR A FENCED TURN, read once per spawn by runTurn from
364
+ * the turn's own environment (its CODEX_HOME — an agent's own home when the
365
+ * lane keeps one): `{ args, env }`, the `-c` pairs `args()` places and the
366
+ * literal secrets they name, which ride the child's environment, never argv.
367
+ * Null for a turn that reads the person's config itself.
368
+ */
369
+ personal: ({ profile, vaultDir, env, model, effort } = {}) =>
370
+ codexIgnoresUserConfig({ profile, vaultDir }) ? codexPersonalTurn({ env, model, effort }) : null,
293
371
  parse: parseCodexLine,
294
372
  /** `codex exec --output-schema <file>` constrains the FINAL message to a
295
373
  * JSON Schema. Only needed on the mediated path; the direct path reports
296
374
  * through MCP tool calls, which are already structured. */
297
375
  resultSchema: (path) => ['--output-schema', path],
298
376
  };
377
+
378
+ /**
379
+ * THE MOCKUP, 3D-MODEL AND DECK CARDS' FENCE ON CODEX (0.115.0; 2026-09-29, the
380
+ * owner: "its saying 3d models run on claude only but thats not true").
381
+ *
382
+ * The image fence's shape without the image tool: a permission profile that
383
+ * reads the repo and writes ONLY `<worktree>/.flowviant/artifacts/`, selected
384
+ * by `default_permissions` (`codex exec` and `exec resume` refuse `-P` on
385
+ * 0.156.1), no network, no web search, no sub-agents, no browser, computer or
386
+ * apps, nobody's config or rules, and no approvals to wait on. Nothing about
387
+ * the card's contract needs more: every one of the three is one HTML page, and
388
+ * a page's CDN scripts load in the viewer, never on this machine.
389
+ *
390
+ * MEASURED on codex-cli 0.156.1 (Linux, bubblewrap), with gpt-6-luna at low
391
+ * effort, in a scratch git repository (2026-09-29), with exactly this argv:
392
+ * · the WRITE FENCE holds for both of Codex's ways to write: its
393
+ * `apply_patch` of `src/escape-patch.txt` was refused by Codex itself
394
+ * ("patch rejected: writing outside of the project; rejected by user
395
+ * approval settings"), and `echo x > src/escape-shell.txt` and
396
+ * `echo x > /tmp/fv-escape.txt` each failed "read-only file system";
397
+ * `apply_patch` of `.flowviant/artifacts/ok-patch.txt` landed;
398
+ * · NO NETWORK: `curl https://example.com` failed "Could not resolve host";
399
+ * NO WEB: the model reported no search tool;
400
+ * · THE TOOL LIST was `apply_patch`, `clock__curr_time`, `exec_command`,
401
+ * `view_image`, `write_stdin` — and `image_gen__imagegen` WITHOUT
402
+ * `--disable image_generation`: image generation ships ON in 0.156.1 even
403
+ * under `--ignore-user-config`, so it is switched off here by name, and a
404
+ * mockup never spends the person's image credits;
405
+ * · THE CONTRACTS DELIVER: the real mockup, 3D-model and deck contracts each
406
+ * produced their one page under `.flowviant/artifacts/` (the model page
407
+ * with its `glb-ready`/`export-glb` handshake and GLTFExporter, the deck
408
+ * with its sections, arrow keys and print rules) and changed no repository
409
+ * file (`git status` clean but for the ignored directory);
410
+ * · THE SCRATCH IS LOAD-BEARING: a deck turn that wrote its page with a
411
+ * here-document got "can't create temp file for here document: read-only
412
+ * file system", left a 0-byte page and stopped to ask; with the scratch
413
+ * granted and named (below) the same here-document wrote the page, and a
414
+ * resumed turn (`exec resume <id>` with this argv, as `CODEX_RUNTIME.args`
415
+ * builds it) edited it in place.
416
+ *
417
+ * STATED, not closed: Codex's permission profile READS the whole box, where
418
+ * Claude's design list reads only the worktree and the knowledge library. It
419
+ * cannot be narrowed on 0.156.1 — a `"none"` entry over the home directory
420
+ * hides Codex's own sandbox helper when Codex is installed there ("bwrap:
421
+ * execvp …/codex: No such file or directory"), and masking a second FILE
422
+ * fails every command ("bwrap: Can't write data to file …: Bad file
423
+ * descriptor"). With the network and the web shut, the one road out is the
424
+ * artifact itself, which is scrubbed of the machine's known secret values and
425
+ * of any Flowviant credential by shape on the way up — the image fence's
426
+ * standing (0.114.0), and why RESEARCH is not declared here: a write-up needs
427
+ * the web, and a turn that can read `.env` and `~/.flowviant/credentials.json`
428
+ * while holding `web_search` (both measured readable under this profile with
429
+ * `web_search="live"`) is the exfiltration Claude's research list was fenced
430
+ * to prevent.
431
+ */
432
+ export function codexDesignFence(cwd) {
433
+ if (!cwd || !isAbsolute(cwd)) {
434
+ throw new Error("a design turn needs its worktree to fence — none reached codex's builder");
435
+ }
436
+ const artifacts = join(cwd, ARTIFACT_DIR);
437
+ // THE TURN'S OWN SCRATCH (artifacts.mjs `ensureFenceScratch`, made empty by
438
+ // the lane before spawn): `/tmp` is read-only in here, and a here-document
439
+ // needs a temp file — measured, a `cat > page.html <<EOF` failed "can't
440
+ // create temp file for here document: read-only file system" and the turn
441
+ // stopped to ask. Granted, and named by the two variables the shells read
442
+ // (`TMPDIR`; zsh's `TMPPREFIX`), set for every command through
443
+ // `shell_environment_policy.set` — measured to reach the sandboxed shell.
444
+ const scratch = join(cwd, FENCE_TMP_DIR);
445
+ return [
446
+ '-c', 'permissions.flowviantdesign.extends=":read-only"',
447
+ // The table WHOLE and the path a TOML basic string — the wiki branch's
448
+ // reason: a dotted key splits on the dots inside the path.
449
+ '-c', `permissions.flowviantdesign.filesystem={${JSON.stringify(artifacts)}="write",${JSON.stringify(scratch)}="write"}`,
450
+ '-c', `shell_environment_policy.set={TMPDIR=${JSON.stringify(scratch)},TMPPREFIX=${JSON.stringify(join(scratch, 'zsh'))}}`,
451
+ '-c', 'default_permissions="flowviantdesign"',
452
+ '-c', 'approval_policy="never"',
453
+ '--disable', 'image_generation',
454
+ '-c', 'tools.web_search=false', '-c', 'web_search="disabled"',
455
+ '-c', 'features.multi_agent=false', '-c', 'features.goals=false',
456
+ ...NO_REACH_OUT,
457
+ '--ignore-user-config', '--ignore-rules',
458
+ ];
459
+ }
@@ -277,8 +277,15 @@ function humanizeCodexItem(item = {}, cwd = '') {
277
277
  const verb = first.kind === 'add' ? '+' : first.kind === 'delete' ? '-' : '~';
278
278
  return { kind: 'write', path: p, label: `${verb} ${tail || 'file'}` };
279
279
  }
280
- case 'mcp_tool_call':
281
- return { kind: 'tool', label: `${item.server ?? 'mcp'}.${item.tool ?? ''}` };
280
+ case 'mcp_tool_call': {
281
+ // A FAILED CALL KEEPS ITS OWN WORDS (2026-09-29), beside a label that is
282
+ // unchanged: codex-cli 0.156.1 closes a call it would not run as
283
+ // `status: "failed"`, `error.message` "MCP tool call requires approval,
284
+ // but approval policy is never" (measured), and the tab lane says so
285
+ // in words (codexRelay.mjs) instead of leaving a chat that staged nothing.
286
+ const error = String(item.error?.message ?? '');
287
+ return { kind: 'tool', label: `${item.server ?? 'mcp'}.${item.tool ?? ''}`, ...(error ? { error } : {}) };
288
+ }
282
289
  case 'web_search':
283
290
  return { kind: 'search', label: `search ${oneLine(item.query, 60)}` };
284
291
  default:
@@ -22,8 +22,9 @@
22
22
  * - design / research / image — a non-code card's posture
23
23
  * (agentTaskKinds.mjs), refused on any runtime that does not
24
24
  * DECLARE it, because every other adapter's fallback is its
25
- * build branch. design and research are Claude Code's; image
26
- * (0.114.0) is Codex's.
25
+ * build branch. research is Claude Code's; design is
26
+ * Claude Code's and, since 0.115.0, Codex's; image (0.114.0)
27
+ * is Codex's.
27
28
  *
28
29
  * Claude's permission list for each name lives beside the lists themselves
29
30
  * (`claudePermFor`, claudePosture.mjs); this module owns the vocabulary and the
@@ -37,7 +37,7 @@ import { CODEX_THREAD_RE } from './workSessionRuntime.mjs';
37
37
  import { brainFor } from './workBrain.mjs';
38
38
  import { begunTurnRefusal } from './workAgentTurnBegun.mjs';
39
39
  import { agentTurnSettlement } from './workAgentTurnOutcome.mjs';
40
- import { ensureArtifactDir } from './artifacts.mjs';
40
+ import { ensureArtifactDir, ensureFenceScratch } from './artifacts.mjs';
41
41
  import { agentBrowserHome } from './noWindowEnv.mjs';
42
42
 
43
43
  export function createAgentTurnExecution({
@@ -176,7 +176,8 @@ export function createAgentTurnExecution({
176
176
  * posture, which can write ONLY `.flowviant/artifacts/` (claudePosture.mjs).
177
177
  *
178
178
  * A RUNTIME THAT CANNOT EXPRESS THE POSTURE IS REFUSED, NOT IMPROVISED.
179
- * Codex and antigravity declare neither (runtimeCodex.mjs,
179
+ * Antigravity declares none of them and Codex declares `design` (since
180
+ * 0.115.0) and `image` but not `research` (runtimeCodex.mjs,
180
181
  * runtimeAntigravity.mjs), and the only
181
182
  * alternative to refusing is running the card as a BUILD turn with
182
183
  * permissions skipped — an agent writing code for an ask that was a
@@ -248,6 +249,23 @@ export function createAgentTurnExecution({
248
249
  });
249
250
  return;
250
251
  }
252
+ /**
253
+ * …AND ITS SCRATCH BESIDE IT, EMPTY (0.115.0): a fenced shell's temp files
254
+ * (a here-document, `mktemp`) go under `.flowviant/tmp`, which Codex's
255
+ * design fence grants because `/tmp` is read-only in there
256
+ * (`ensureFenceScratch`). Made for every file kind — one rule, no kind
257
+ * named — and refused in words where it cannot stand as a real directory.
258
+ */
259
+ if (kind.artifact && !ensureFenceScratch(wt)) {
260
+ await postAgentTurn({
261
+ turnId,
262
+ outcome: 'nothing',
263
+ answer: "this worktree's .flowviant/tmp is not a real directory, so the fenced turn has no scratch to work in",
264
+ branch,
265
+ worktree: wt,
266
+ });
267
+ return;
268
+ }
251
269
 
252
270
  // ONE AGENT IS ONE DIRECTORY, so the CLI's own cwd-keyed resume is exactly
253
271
  // right here — the ambiguity that forced per-tab session pinning in the
@@ -469,7 +487,9 @@ export function createAgentTurnExecution({
469
487
  // The knowledge paragraph, when this box holds a library — the same
470
488
  // composer the tabs use, so an agent and a tab can never be told two
471
489
  // different things about the same directory.
472
- system: withProjectContext(SYSTEM_AGENT_FOR(taskKind), {
490
+ // Spoken to the CLI that runs it (0.115.0): a mockup on Codex is
491
+ // told it is Codex, never Claude.
492
+ system: withProjectContext(SYSTEM_AGENT_FOR(taskKind, RUNTIMES[rt]?.label), {
473
493
  knowledgeDir: knowledgeDirFor(repoRoot),
474
494
  // ARTIFACTS (0.94.0), while the server can show one — an agent's
475
495
  // land on its page, under the facts row.
@@ -42,6 +42,7 @@ import { REPO_PLACE } from './workPlaces.mjs';
42
42
  import { CODEX_THREAD_RE, AGY_CONV_RE } from './workSessionRuntime.mjs';
43
43
  import { createToolLog } from './workToolLog.mjs';
44
44
  import { createCommandAudit } from './workCommandAudit.mjs';
45
+ import { APPROVAL_REFUSED, codexApprovalSentence, codexLoginFailure, codexLoginSentence, withRelay } from './codexRelay.mjs';
45
46
 
46
47
  /**
47
48
  * THE ONE SENTENCE A PLAN TURN'S SYSTEM PROMPT GAINS (0.97.0), composed here
@@ -625,6 +626,11 @@ export function createWorkSessionTurns({
625
626
  let out;
626
627
  let seenThreadId = null; // codex's conversation id, off thread.started
627
628
  let seenClaudeSession = null; // claude's own conversation id, off system.init
629
+ // What a Codex turn's own events say went wrong (codexRelay.mjs):
630
+ // whether the agent said anything at all, and each tool call Codex
631
+ // refused for want of an approval, by `server.tool`.
632
+ let codexSpoke = false;
633
+ const approvalRefused = [];
628
634
  const spawned = []; // this turn's children, for the teardown registry
629
635
  // THE TURN'S TOOL LOG — see workToolLog.mjs for its shape rules.
630
636
  const turnLog = createToolLog(dir.wt);
@@ -733,6 +739,12 @@ export function createWorkSessionTurns({
733
739
  onActivity: (a) => {
734
740
  narrator.line(a?.label);
735
741
  auditCommand(a);
742
+ if (a?.kind === 'tool' && APPROVAL_REFUSED.test(a.error ?? '')) approvalRefused.push(a.label);
743
+ },
744
+ // One Codex agent MESSAGE (runtimeEvents.mjs `answer`): the turn
745
+ // spoke, so a 401 in its words is its words, not its failure.
746
+ onAnswer: (a) => {
747
+ if (String(a ?? '').trim()) codexSpoke = true;
736
748
  },
737
749
  // The structured twin of the line above — see toolLog.
738
750
  onToolEvent: pushToolEvent,
@@ -949,17 +961,31 @@ export function createWorkSessionTurns({
949
961
  rt.id === 'claude' && seenClaudeSession
950
962
  ? 'Claude Code ran in this conversation and returned no words — send the message again'
951
963
  : 'the turn produced no output on the machine — its CLI may be signed out; try again';
964
+ /**
965
+ * A CODEX TURN SAYS WHAT ITS OWN WORDS MEASURED (2026-09-29,
966
+ * codexRelay.mjs). A turn that never spoke and whose output is a
967
+ * 401 was not answered: it fails, its sentence first and Codex's
968
+ * retry log kept under it — it used to settle as the chat's REPLY.
969
+ * Tool calls Codex refused for want of an approval are named above
970
+ * whatever the agent said, so a chat that staged nothing says why.
971
+ */
972
+ const loginFailure = rt.id === 'codex' && !codexSpoke ? codexLoginFailure(answer) : null;
973
+ const refusedLine = rt.id === 'codex' ? codexApprovalSentence(approvalRefused) : null;
974
+ const spoke = answer.length > 0 && !loginFailure;
952
975
  await settleTurn(job.id, {
953
- ok: answer.length > 0,
954
- answer:
955
- answer.length > 0
956
- ? // Scrub: a reply can quote config or env-adjacent code.
957
- envScrub(answer).slice(0, 16000)
958
- : noWords,
976
+ ok: spoke,
977
+ answer: loginFailure
978
+ ? withRelay(codexLoginSentence(loginFailure), answer)
979
+ : answer.length > 0
980
+ ? refusedLine
981
+ ? withRelay(refusedLine, answer)
982
+ : // Scrub: a reply can quote config or env-adjacent code.
983
+ envScrub(answer).slice(0, 16000)
984
+ : (refusedLine ?? noWords),
959
985
  // Only a turn that actually SPOKE proves a conversation lives
960
986
  // here. Recording the path unconditionally is how a crashed first
961
987
  // turn used to brick resume for the session's whole life.
962
- ...(answer.length > 0 ? { sessionRef: dir.wt } : {}),
988
+ ...(spoke ? { sessionRef: dir.wt } : {}),
963
989
  // The turn's tool log, in final form — the durable copy that lands
964
990
  // on the settled message (the live copy on the record is cleared
965
991
  // at settle). Already scrubbed at collection.
@@ -967,7 +993,8 @@ export function createWorkSessionTurns({
967
993
  ...(usage ? { usage } : {}),
968
994
  ...(model ? { model } : {}),
969
995
  });
970
- if (answer.length > 0) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
996
+ if (spoke) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
997
+ else if (loginFailure) warn('codex answered 401 for this session turn — settled as failed, in words');
971
998
  else warn('session turn produced no output — settled as failed');
972
999
  } catch (e) {
973
1000
  await settleTurn(job.id, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.114.2",
3
+ "version": "0.115.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {