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.
- package/bin/lib/agentTaskKinds.mjs +10 -6
- package/bin/lib/artifacts.mjs +43 -1
- package/bin/lib/codexPersonal.mjs +467 -0
- package/bin/lib/codexRelay.mjs +63 -0
- package/bin/lib/prompts.mjs +15 -1
- package/bin/lib/runTurn.mjs +24 -1
- package/bin/lib/runtimeClaude.mjs +8 -5
- 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 +35 -8
- package/package.json +1 -1
|
@@ -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
|
|
37
|
-
*
|
|
38
|
-
|
|
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: () =>
|
|
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: () =>
|
|
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: () =>
|
|
104
|
+
offRuntime: () => DESIGN_CLIS,
|
|
101
105
|
}),
|
|
102
106
|
research: Object.freeze({
|
|
103
107
|
posture: 'research',
|
package/bin/lib/artifacts.mjs
CHANGED
|
@@ -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
|
+
}
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -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
|
-
|
|
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 —
|
package/bin/lib/runTurn.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
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
|
package/bin/lib/runtimeCodex.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
package/bin/lib/turnProfile.mjs
CHANGED
|
@@ -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.
|
|
26
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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:
|
|
954
|
-
answer:
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
...(
|
|
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 (
|
|
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.
|
|
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": {
|