@ctrl-spc/cs 0.6.0 → 0.7.1
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/dist/agents.js +70 -5
- package/dist/autostart.js +15 -1
- package/dist/browser.js +386 -0
- package/dist/codebases.js +15 -0
- package/dist/codex-home.js +562 -0
- package/dist/companion.js +9 -12
- package/dist/config.js +23 -0
- package/dist/daemon.js +33 -3
- package/dist/env.js +42 -0
- package/dist/failure-reason.js +98 -0
- package/dist/index.js +1 -1
- package/dist/mcp.js +7627 -298
- package/dist/orchestrator.js +6011 -0
- package/dist/panel3/answer.js +166 -0
- package/dist/panel3/checkout.js +29 -0
- package/dist/panel3/cli.js +83 -0
- package/dist/panel3/client.js +181 -0
- package/dist/panel3/coordinator.js +18 -0
- package/dist/panel3/presence.js +162 -0
- package/dist/panel3/prompt.js +945 -0
- package/dist/panel3/run.js +2516 -0
- package/dist/panel3/say.js +262 -0
- package/dist/panel3/secrets.js +98 -0
- package/dist/panel3/session.js +128 -0
- package/dist/panel3/show.js +997 -0
- package/dist/panel3/spawn.js +565 -0
- package/dist/panel3/tools.js +1906 -0
- package/dist/presence.js +178 -6
- package/dist/win-shell.js +162 -0
- package/dist/work-context.js +1484 -0
- package/dist/workflows.js +68 -0
- package/package.json +3 -2
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { configDir } from './config.js';
|
|
6
|
+
/**
|
|
7
|
+
* 18c SLICE 8 — A CODEX WORKER IS ISOLATED LIKE A CLAUDE WORKER.
|
|
8
|
+
*
|
|
9
|
+
* THE GAP THIS CLOSES, measured on this Mac rather than reasoned about. A codex
|
|
10
|
+
* worker was spawned with `['exec', '--skip-git-repo-check']` and nothing else,
|
|
11
|
+
* so it inherited the developer's ENTIRE agent configuration. Asked to list the
|
|
12
|
+
* tools it could call, a plain `codex exec` on this machine answered with
|
|
13
|
+
* `mcp__supabase_main__execute_sql`, `mcp__postman__*`, `mcp__filesystem__*`,
|
|
14
|
+
* `mcp__memory__*`, `mcp__netlify__*`, `mcp__node_repl__*`, `mcp__moflo__*` and
|
|
15
|
+
* `mcp__super_harness__*` — eight servers this product never granted, one of
|
|
16
|
+
* them holding a live service-role token for the very database the product
|
|
17
|
+
* writes to. Asked whether it had been given an instruction file from outside
|
|
18
|
+
* the working directory, it quoted `~/.codex/AGENTS.md` back verbatim.
|
|
19
|
+
*
|
|
20
|
+
* That is the same hole `--strict-mcp-config` and `--setting-sources ''` close
|
|
21
|
+
* for claude, and "nothing was created" cannot be said honestly about a run
|
|
22
|
+
* configured by something the product never granted.
|
|
23
|
+
*
|
|
24
|
+
* ═══ WHY A SEEDED HOME, AND NOT ANY OF THE FOUR THINGS TRIED BEFORE ═══
|
|
25
|
+
*
|
|
26
|
+
* Codex offered no argv for an inline MCP config in the Mac runtime this module
|
|
27
|
+
* serves (codex-cli 0.147.0-alpha.6.5), and four mechanisms were measured as
|
|
28
|
+
* failing there. None of them is retried here:
|
|
29
|
+
*
|
|
30
|
+
* * `-c mcp_servers."ctrl-spc".url=...` — `-c` cannot CREATE or override an
|
|
31
|
+
* MCP server in this build. Re-measured for this slice with
|
|
32
|
+
* `--ignore-user-config -c 'mcp_servers.ctrl-spc={url=...}'`: the run
|
|
33
|
+
* completed and answered the tool-list question with `[]`. The override is
|
|
34
|
+
* accepted on the command line and produces no server.
|
|
35
|
+
* * `--ignore-user-config` — drops this product's own `[mcp_servers.ctrl-spc]`
|
|
36
|
+
* along with the user's, leaving zero ctrl-spc tools.
|
|
37
|
+
* * a per-run `CODEX_HOME` holding ONLY a one-server config.toml — hangs,
|
|
38
|
+
* because codex will not start against a home with none of its own state.
|
|
39
|
+
* * a `-c` override plus a `--mcp-config` file (Slice 5's Windows walk) — hung
|
|
40
|
+
* twice, and testing wiring no user has is not a test.
|
|
41
|
+
*
|
|
42
|
+
* The newer Windows Desktop runtime now supports creating the server through
|
|
43
|
+
* `-c`. Panel v3 uses that platform-specific route in `panel3/spawn.ts`, keeping
|
|
44
|
+
* the installed home because Windows sandbox setup is bound to its ACL state.
|
|
45
|
+
*
|
|
46
|
+
* THE THIRD ONE WAS ALMOST RIGHT, AND ITS DIAGNOSIS NAMED THE FIX. "Codex will
|
|
47
|
+
* not start against a home with none of its own state" is a statement about what
|
|
48
|
+
* the home is MISSING, not about per-run homes being impossible. The one piece
|
|
49
|
+
* of state codex cannot manufacture is the credential: `codex exec --help` says
|
|
50
|
+
* of `--ignore-user-config` that "auth still uses `CODEX_HOME`", which is the
|
|
51
|
+
* binary telling us where it insists on reading auth from. Everything else in
|
|
52
|
+
* that directory — the model cache, the session store, the sqlite state files,
|
|
53
|
+
* its own `.system` skills — codex creates for itself on first run.
|
|
54
|
+
*
|
|
55
|
+
* So the home is SEEDED with exactly one file copied from the user's own
|
|
56
|
+
* (`auth.json`) and one file this product writes (`config.toml`). Measured on
|
|
57
|
+
* this Mac: the run completes, it does not hang, and the tool list comes back as
|
|
58
|
+
* this product's tools and NOTHING else. The `~/.codex/AGENTS.md` that a plain
|
|
59
|
+
* run quoted verbatim is not seen, and none of the user's personal skills under
|
|
60
|
+
* `~/.codex/skills` is present — only the `.system` skills codex installs into
|
|
61
|
+
* any home for itself.
|
|
62
|
+
*
|
|
63
|
+
* ═══ THE ONE THING THIS DOES NOT COVER, STATED RATHER THAN GLOSSED ═══
|
|
64
|
+
*
|
|
65
|
+
* `~/.agents/skills` is a SECOND skill root, resolved from the user's HOME
|
|
66
|
+
* rather than from `$CODEX_HOME`, and nothing reaches it: not the per-run home,
|
|
67
|
+
* not `--ignore-user-config`, and not `[features] skill_search = false` (all
|
|
68
|
+
* three measured). An isolated run is therefore still handed a CATALOG naming
|
|
69
|
+
* the skills there — on this Mac, `ctrl-spc` (this product's own) and
|
|
70
|
+
* `impeccable`.
|
|
71
|
+
*
|
|
72
|
+
* WHY THAT IS RECORDED AND NOT PAPERED OVER. It is a list of names and paths
|
|
73
|
+
* rather than injected content: the run is not given those files' contents, is
|
|
74
|
+
* not instructed by them, and reads one only if it chooses to. The isolation
|
|
75
|
+
* that Story 6 asks for — the tool surface, the instruction file, the permission
|
|
76
|
+
* posture — holds, and the acceptance run confirmed all three. But "the tool
|
|
77
|
+
* surface is exactly this product's" and "nothing of the user's is visible" are
|
|
78
|
+
* different claims, and only the first is true. The honest fix is a `HOME`
|
|
79
|
+
* override, which is not taken because `HOME` is where codex also finds its
|
|
80
|
+
* credential, its cache and the user's shell environment, and breaking those to
|
|
81
|
+
* hide two skill names would trade a real capability for a cosmetic one.
|
|
82
|
+
*
|
|
83
|
+
* ═══ THE SECOND THING THE HOME BUYS, WHICH IS WORTH AS MUCH ═══
|
|
84
|
+
*
|
|
85
|
+
* A per-run home means a per-run `config.toml`, and a per-run `config.toml`
|
|
86
|
+
* means the server URL is OURS TO WRITE. That is the per-run channel codex was
|
|
87
|
+
* said not to have: `todo_id` goes on the URL exactly as `workerMcpConfig` puts
|
|
88
|
+
* it there for claude, so a codex tool call names its own request instead of the
|
|
89
|
+
* machine's oldest live worker. Measured with two codex runs started at the same
|
|
90
|
+
* second against one server: the ALPHA run's tool call arrived carrying
|
|
91
|
+
* `todo_id=RUN-ALPHA` and the BETA run's carried `todo_id=RUN-BETA`. That is the
|
|
92
|
+
* precise limitation `machineRunTodoId` documents as unfixable and hands to this
|
|
93
|
+
* slice.
|
|
94
|
+
*
|
|
95
|
+
* ═══ WHY NOT ONE SHARED ISOLATED HOME ═══
|
|
96
|
+
*
|
|
97
|
+
* Because the URL in it is per-run. Two concurrent runs sharing one home would
|
|
98
|
+
* race on the same `config.toml` and one would read the other's request id,
|
|
99
|
+
* which is the bug this is fixing wearing a different hat.
|
|
100
|
+
*
|
|
101
|
+
* ═══ WHAT A LEFTOVER HOME ACTUALLY COSTS, MEASURED ═══
|
|
102
|
+
*
|
|
103
|
+
* An earlier version of this comment called a leftover home "inert (two small
|
|
104
|
+
* files plus codex's own cache)". **That was measurably wrong**, and gate A
|
|
105
|
+
* caught it. After ONE real run the directory is 32 MB and contains, besides the
|
|
106
|
+
* credential copy, `sessions/**\/rollout-*.jsonl` — codex's full session
|
|
107
|
+
* transcript, which holds the entire conversation and absolute local paths.
|
|
108
|
+
*
|
|
109
|
+
* Two things follow, and both are done rather than noted. `--ephemeral` is on
|
|
110
|
+
* the argv (`codex exec --help`: "Run without persisting session files to
|
|
111
|
+
* disk"), measured to leave zero rollout files while the answer is still
|
|
112
|
+
* captured, so the transcript is never written in the first place. And the sweep
|
|
113
|
+
* cannot be the only thing standing between a crash and a stranded credential,
|
|
114
|
+
* so `sweepStrandedCodexHomes` runs at daemon startup beside the rest of the
|
|
115
|
+
* crash recovery.
|
|
116
|
+
*/
|
|
117
|
+
/** The file naming the process that built a home and is responsible for it.
|
|
118
|
+
* Read by the startup sweep to decide whether the home still has an owner. */
|
|
119
|
+
const OWNER_FILE = 'ctrl-spc-owner.json';
|
|
120
|
+
/** The parent of every per-run home. Homes under it are swept individually, and
|
|
121
|
+
* only when their owning process is gone — see `sweepStrandedCodexHomes`. */
|
|
122
|
+
export function codexHomesRoot() {
|
|
123
|
+
return join(configDir(), 'codex-homes');
|
|
124
|
+
}
|
|
125
|
+
/** Persistent isolated homes belong only to panel3 conversation owners. They
|
|
126
|
+
* are separate from `codexHomesRoot()` so the per-process PID sweep can keep
|
|
127
|
+
* its existing contract without deleting a resumable owner conversation. */
|
|
128
|
+
export function panel3CodexOwnerHomesRoot() {
|
|
129
|
+
return join(configDir(), 'panel3-codex-owner-homes');
|
|
130
|
+
}
|
|
131
|
+
export function panel3CodexOwnerHomePath(ownerRunId) {
|
|
132
|
+
const key = ownerRunId.replace(/[^a-zA-Z0-9-]/g, '_');
|
|
133
|
+
return join(panel3CodexOwnerHomesRoot(), key);
|
|
134
|
+
}
|
|
135
|
+
/** Where this run's throwaway codex home lives. Inside the product's own config
|
|
136
|
+
* dir, beside `scratch/`, so it is the user's own directory and it is obvious
|
|
137
|
+
* what wrote it. Keyed by request so two concurrent runs never share one. */
|
|
138
|
+
export function codexRunHomePath(runTodoId) {
|
|
139
|
+
/* THE KEY IS SANITISED, NOT TRUSTED. A request id is a uuid today and nothing
|
|
140
|
+
in the type says so, and this string becomes a DIRECTORY NAME that a
|
|
141
|
+
credential is copied into. Anything outside the safe set becomes `_`, and
|
|
142
|
+
the dot is then handled separately: allowing `.` keeps a uuid readable but
|
|
143
|
+
would let `..` through as a whole path segment, which `join` would resolve
|
|
144
|
+
into a parent. A leading dot cannot survive, so `..` cannot either.
|
|
145
|
+
|
|
146
|
+
A RUN WITH NO REQUEST STILL GETS ITS OWN HOME, and this used to be a lie.
|
|
147
|
+
The key was the constant `'no-request'`, so every concurrent run without a
|
|
148
|
+
request shared ONE directory — precisely the race the paragraph above cites
|
|
149
|
+
as the reason for keying by request at all, reintroduced by the fallback
|
|
150
|
+
(gate A). There is nothing stable to key on in that case, so a random
|
|
151
|
+
suffix is the honest answer: uniqueness is what the key is for, and
|
|
152
|
+
readability is worth nothing for a directory that has no request to be read
|
|
153
|
+
against. */
|
|
154
|
+
const key = runTodoId
|
|
155
|
+
? runTodoId.replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^\.+/, '_')
|
|
156
|
+
: `no-request-${randomUUID()}`;
|
|
157
|
+
return join(codexHomesRoot(), key);
|
|
158
|
+
}
|
|
159
|
+
/** The user's own codex home, resolved exactly as codex resolves it. */
|
|
160
|
+
export function userCodexHome() {
|
|
161
|
+
return process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
162
|
+
}
|
|
163
|
+
/** The URL a run's `config.toml` points at, however the caller said it. */
|
|
164
|
+
function mcpUrl(server, runTodoId) {
|
|
165
|
+
return 'url' in server ? server.url : codexWorkerMcpUrl(server, runTodoId);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The `config.toml` this product hands a codex worker. ONE server, named exactly
|
|
169
|
+
* as the tools and the guidance name it, and nothing else.
|
|
170
|
+
*
|
|
171
|
+
* `default_tools_approval_mode = "approve"` is not optional and is not a
|
|
172
|
+
* loosening. Without it headless `codex exec` CANCELS every MCP tool call
|
|
173
|
+
* client-side, with a zero-second duration and nothing in any server log
|
|
174
|
+
* (.bugs/20260808-codex-mcp-tools-cancelled). `ensureCodexMcpApproval` in mcp.ts
|
|
175
|
+
* exists to keep putting this key back into the USER's config after every
|
|
176
|
+
* `codex mcp add` destroys it; here the file is ours and never rewritten, so the
|
|
177
|
+
* key simply stays.
|
|
178
|
+
*
|
|
179
|
+
* `features.apps = false` is the last of the inherited surface, and it is not
|
|
180
|
+
* config-file state — it comes from the signed-in ChatGPT account. Measured: a
|
|
181
|
+
* seeded home WITHOUT this line still listed `mcp__codex_apps__github_*`,
|
|
182
|
+
* `mcp__codex_apps__codex_document_control_*` and the rest, so a worker could
|
|
183
|
+
* open pull requests on the user's GitHub through an account connector the
|
|
184
|
+
* product never granted. WITH it, the tool list is this product's tools and
|
|
185
|
+
* nothing else. It is disabled rather than allow-listed because codex offers no
|
|
186
|
+
* per-server allow list; `false` is the only knob, and an allow list is what
|
|
187
|
+
* `--strict-mcp-config` + `--allowedTools` amount to for claude.
|
|
188
|
+
*/
|
|
189
|
+
export function codexRunConfigToml(server, runTodoId, runtimePlatform = process.platform,
|
|
190
|
+
/* ═══ CODEX'S OWN SUBAGENT TOOL, WHICH v3 CLOSES AND v2 DOES NOT. ═══
|
|
191
|
+
*
|
|
192
|
+
* v3 enforces "depth stops at three" in the TOOL SURFACE, never in a prompt.
|
|
193
|
+
* Claude's half of that is `CODE_TOOLS` excluding `Task` in
|
|
194
|
+
* `panel3/spawn.ts`. Codex's own subagent tool was left open, and a level 2
|
|
195
|
+
* codex worker reached for it instead of this product's `dispatch` tool;
|
|
196
|
+
* no level 3 run was ever started through the record.
|
|
197
|
+
*
|
|
198
|
+
* Codex 0.148 made `multi_agent` the stable feature switch and changed
|
|
199
|
+
* `[agents]` into a role table. The old `[agents] enabled = false` now makes
|
|
200
|
+
* the current Desktop runtime reject the whole config before a run starts.
|
|
201
|
+
* `features.multi_agent = false` is therefore the surface-level absence rule
|
|
202
|
+
* on the current runtime, matching the documented configuration contract.
|
|
203
|
+
*
|
|
204
|
+
* v2's claude workers are deliberately still granted `Task` (see
|
|
205
|
+
* `orchestrator.ts`), so this is an EXPLICIT argument rather than a change to
|
|
206
|
+
* what v2 gets: the default keeps today's behaviour (subagents allowed), and
|
|
207
|
+
* only v3's `startAgent` passes it off. Never inferred from `server`'s shape:
|
|
208
|
+
* v2 and v3 both pass `{ url }` here, so the shape cannot tell them apart.
|
|
209
|
+
*/
|
|
210
|
+
subagentsEnabled = true, persistentPanelOwner = false) {
|
|
211
|
+
const lines = [
|
|
212
|
+
'# Written by CTRL+SPC for ONE codex run. Not the user\'s config; never read',
|
|
213
|
+
'# by anything but the worker this was written for.',
|
|
214
|
+
/* The Windows Desktop bundle can lag the account's server-selected default
|
|
215
|
+
model. When that default requires a newer client, every headless worker
|
|
216
|
+
exits before its first action. Pin the current broadly-supported model
|
|
217
|
+
only for that bundled runtime; other platforms keep Codex's own model
|
|
218
|
+
selection. */
|
|
219
|
+
...(runtimePlatform === 'win32' ? ['model = "gpt-5.5"', ''] : []),
|
|
220
|
+
...(persistentPanelOwner ? [
|
|
221
|
+
'sandbox_mode = "workspace-write"',
|
|
222
|
+
'',
|
|
223
|
+
'[sandbox_workspace_write]',
|
|
224
|
+
'network_access = true',
|
|
225
|
+
'',
|
|
226
|
+
] : []),
|
|
227
|
+
'[features]',
|
|
228
|
+
/* The account's own connectors, which no config.toml grants and no argv
|
|
229
|
+
removes. See the block comment above. */
|
|
230
|
+
'apps = false',
|
|
231
|
+
...(!subagentsEnabled ? ['multi_agent = false'] : []),
|
|
232
|
+
'',
|
|
233
|
+
];
|
|
234
|
+
/* Native Windows must name its sandbox implementation. Without this block,
|
|
235
|
+
codex silently resolves a requested workspace-write run as read-only and
|
|
236
|
+
refuses every network command. `unelevated` is the documented fallback for
|
|
237
|
+
a headless process: `elevated` opens a UAC consent dialog that a daemon has
|
|
238
|
+
nobody present to approve. This block is Windows-only. macOS keeps its
|
|
239
|
+
existing Seatbelt configuration exactly. */
|
|
240
|
+
if (runtimePlatform === 'win32') {
|
|
241
|
+
lines.push('[windows]', 'sandbox = "unelevated"',
|
|
242
|
+
/* 0.148 defaults both Windows sandboxes onto a private desktop. A
|
|
243
|
+
daemon-spawned headless worker has no desktop handshake to complete,
|
|
244
|
+
so its command runner waits on the pipe until the turn fails. The
|
|
245
|
+
documented compatibility mode keeps the same sandbox boundaries and
|
|
246
|
+
runs the command on Winsta0\\Default instead. */
|
|
247
|
+
'sandbox_private_desktop = false', '');
|
|
248
|
+
}
|
|
249
|
+
/* No server means the tools server is not running, and the worker then runs
|
|
250
|
+
with NO ctrl-spc tools rather than with whatever the user's config happens
|
|
251
|
+
to hold — the same honest-absence rule `headlessAgentArgs` already applies
|
|
252
|
+
to claude's `--mcp-config`. Writing no `[mcp_servers]` block at all is what
|
|
253
|
+
makes that true: the home has no other source of servers. */
|
|
254
|
+
if (server) {
|
|
255
|
+
const url = mcpUrl(server, runTodoId);
|
|
256
|
+
lines.push('[mcp_servers.ctrl-spc]', `url = ${JSON.stringify(url)}`,
|
|
257
|
+
/* See the block comment above: without this every tool call is cancelled
|
|
258
|
+
client-side before it leaves the worker. */
|
|
259
|
+
'default_tools_approval_mode = "approve"', '');
|
|
260
|
+
}
|
|
261
|
+
return lines.join('\n');
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The URL a codex worker reaches this server on. Deliberately the SAME shape
|
|
265
|
+
* `workerMcpUrl` builds for claude — token, then `todo_id` when there is a
|
|
266
|
+
* request — because the server reads the query string the same way for both and
|
|
267
|
+
* two spellings of one URL is how they drift apart.
|
|
268
|
+
*
|
|
269
|
+
* Kept here rather than imported from orchestrator.ts to avoid a cycle; the pair
|
|
270
|
+
* is pinned identical by a test rather than by hope.
|
|
271
|
+
*/
|
|
272
|
+
export function codexWorkerMcpUrl(server, runTodoId) {
|
|
273
|
+
const query = runTodoId
|
|
274
|
+
? `token=${server.token}&todo_id=${encodeURIComponent(runTodoId)}`
|
|
275
|
+
: `token=${server.token}`;
|
|
276
|
+
return `http://127.0.0.1:${server.port}/mcp?${query}`;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Build this run's isolated codex home and return its path, or null when it
|
|
280
|
+
* cannot be built.
|
|
281
|
+
*
|
|
282
|
+
* NULL IS A REAL ANSWER AND THE CALLER MUST HONOUR IT. The one thing that can
|
|
283
|
+
* fail is the credential copy: no `auth.json` in the user's home means the user
|
|
284
|
+
* has never signed codex in, and a home without it is the case that HANGS. So a
|
|
285
|
+
* missing credential returns null and the caller runs codex the old way — an
|
|
286
|
+
* un-isolated run that works beats a hung one that looks safe, and a run that
|
|
287
|
+
* hangs is indistinguishable from a slow one for thirty minutes.
|
|
288
|
+
*
|
|
289
|
+
* IT IS BUILT FROM EMPTY EVERY RUN, not merely overwritten. The URL carries the
|
|
290
|
+
* request id and the token can rotate, so a stale `config.toml` would point a
|
|
291
|
+
* worker at the wrong request or a dead port. Overwriting the two files was not
|
|
292
|
+
* enough: a RETRY of the same request resolves the same key, so the previous
|
|
293
|
+
* attempt's session state, sqlite files and caches were inherited by the run
|
|
294
|
+
* meant to be a fresh start (gate A). The directory is removed first, so "this
|
|
295
|
+
* run's home" means what it says. The expensive parts are codex's own and it
|
|
296
|
+
* recreates them; the two files this writes are under a kilobyte.
|
|
297
|
+
*/
|
|
298
|
+
export function ensureCodexRunHome(server, runTodoId,
|
|
299
|
+
/* ═══ 18d SLICE 4 — THE DIRECTORY KEY, WHEN IT MUST DIFFER FROM THE REQUEST.
|
|
300
|
+
═══
|
|
301
|
+
|
|
302
|
+
The home was keyed by request id alone, which is right while one request
|
|
303
|
+
means one run. A HANDOFF breaks that: the continuation carries the SAME
|
|
304
|
+
request id, so it resolved to the same directory as the run it is replacing
|
|
305
|
+
— and this function starts by `rmSync`-ing the directory. Two ways to lose:
|
|
306
|
+
the continuation deletes the outgoing run's home from under it, or the
|
|
307
|
+
outgoing run's `removeCodexRunHome` deletes the continuation's.
|
|
308
|
+
|
|
309
|
+
The caller passes a per-DISPATCH key, so two runs of one request never share
|
|
310
|
+
a directory. `runTodoId` still goes into the config's MCP url unchanged,
|
|
311
|
+
because that is the request the worker is genuinely working. */
|
|
312
|
+
homeKey = runTodoId,
|
|
313
|
+
/* ═══ THE SUBAGENT SWITCH, DEFAULTED TO v2's BEHAVIOUR. ═══ Passed straight
|
|
314
|
+
through to `codexRunConfigToml` (see its comment for the measurement
|
|
315
|
+
table). Defaults `true` so v2's callers, which never pass this, are
|
|
316
|
+
byte-identical to before; only v3's `startAgent` passes `false`. */
|
|
317
|
+
subagentsEnabled = true, runtimePlatform = process.platform) {
|
|
318
|
+
const source = join(userCodexHome(), 'auth.json');
|
|
319
|
+
/* No credential, no isolated home. See the block comment above: this is the
|
|
320
|
+
exact shape that hangs, and hanging is worse than inheriting. */
|
|
321
|
+
if (!existsSync(source))
|
|
322
|
+
return null;
|
|
323
|
+
const home = codexRunHomePath(homeKey);
|
|
324
|
+
try {
|
|
325
|
+
/* FROM EMPTY. See above: a retry of the same request would otherwise start
|
|
326
|
+
inside the failed attempt's leftovers. */
|
|
327
|
+
rmSync(home, { recursive: true, force: true });
|
|
328
|
+
/* 0o700 UNCONDITIONALLY. The files inside are written 0600, but `auth.json`
|
|
329
|
+
arrives through `copyFileSync`, which PRESERVES THE SOURCE MODE — so the
|
|
330
|
+
credential copy is only as private as the original happened to be. The
|
|
331
|
+
directory being owner-only is what makes that irrelevant, and it is set
|
|
332
|
+
explicitly rather than left to the umask (gate A: the homes were 0755).
|
|
333
|
+
`mode` is ignored by `mkdirSync` when the directory already exists, which
|
|
334
|
+
is why the `chmodSync` below is not redundant. */
|
|
335
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
336
|
+
chmodSync(home, 0o700);
|
|
337
|
+
/* COPIED, NOT SYMLINKED. Codex rewrites `auth.json` when it refreshes a
|
|
338
|
+
token, and a symlink would make a throwaway home's refresh land in the
|
|
339
|
+
user's real one — a per-run directory that writes back into `~/.codex` is
|
|
340
|
+
not isolated in the direction that matters. */
|
|
341
|
+
copyFileSync(source, join(home, 'auth.json'));
|
|
342
|
+
writeFileSync(join(home, 'config.toml'), codexRunConfigToml(server, runTodoId, runtimePlatform, subagentsEnabled), { mode: 0o600 });
|
|
343
|
+
/* WHO OWNS THIS HOME, so the startup sweep can tell a stranded directory
|
|
344
|
+
from one a LIVE run is using. Written FIRST-ish and always, because the
|
|
345
|
+
sweep treats an ownerless home as strandable: the only way to produce one
|
|
346
|
+
is a crash between the `mkdirSync` above and this line, which is a couple
|
|
347
|
+
of syscalls with no child spawned yet, so there is nothing live to lose.
|
|
348
|
+
Two processes share this root (the terminal daemon and the companion) and
|
|
349
|
+
neither can see the other's children, so the pid is the only thing that
|
|
350
|
+
crosses the gap. See `sweepStrandedCodexHomes`. */
|
|
351
|
+
writeFileSync(join(home, OWNER_FILE), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), { mode: 0o600 });
|
|
352
|
+
return home;
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
/* Same reasoning as the missing credential: an isolated home we cannot write
|
|
356
|
+
is not a reason to fail the user's run. */
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Create or refresh the isolated home for one durable panel3 Level 2 owner.
|
|
362
|
+
* Unlike `ensureCodexRunHome`, this never removes the directory: Codex's native
|
|
363
|
+
* session files inside it are the state a later activation resumes. Only the
|
|
364
|
+
* credential copy and current sealed MCP configuration are refreshed.
|
|
365
|
+
*/
|
|
366
|
+
export function ensurePanel3CodexOwnerHome(server, ownerRunId, runtimePlatform = process.platform) {
|
|
367
|
+
const source = join(userCodexHome(), 'auth.json');
|
|
368
|
+
if (!existsSync(source))
|
|
369
|
+
return null;
|
|
370
|
+
const home = panel3CodexOwnerHomePath(ownerRunId);
|
|
371
|
+
try {
|
|
372
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
373
|
+
chmodSync(home, 0o700);
|
|
374
|
+
copyFileSync(source, join(home, 'auth.json'));
|
|
375
|
+
writeFileSync(join(home, 'config.toml'), codexRunConfigToml(server, null, runtimePlatform, false, true), { mode: 0o600 });
|
|
376
|
+
return home;
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/** Product-owned persistent state only. Windows owners use the installed Codex
|
|
383
|
+
* home, so this path is normally absent there and removal remains harmless. */
|
|
384
|
+
export function removePanel3CodexOwnerHome(ownerRunId) {
|
|
385
|
+
try {
|
|
386
|
+
rmSync(panel3CodexOwnerHomePath(ownerRunId), { recursive: true, force: true });
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// Reconciliation retries. Cleanup must never turn a completed turn into a failure.
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/** Valid directory names only; callers reconcile each id against the signed-in
|
|
393
|
+
* owner row before retaining it. */
|
|
394
|
+
export function listPanel3CodexOwnerHomeIds() {
|
|
395
|
+
try {
|
|
396
|
+
return readdirSync(panel3CodexOwnerHomesRoot(), { withFileTypes: true })
|
|
397
|
+
.filter((entry) => entry.isDirectory())
|
|
398
|
+
.map((entry) => entry.name);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Delete a run's home once the run is over.
|
|
406
|
+
*
|
|
407
|
+
* IT TAKES THE PATH, NOT THE REQUEST ID, and that is load-bearing rather than
|
|
408
|
+
* stylistic. `codexRunHomePath(null)` now mints a RANDOM key so that concurrent
|
|
409
|
+
* requestless runs cannot share a directory, so recomputing the path here would
|
|
410
|
+
* delete a directory that never existed and leave the real one behind forever.
|
|
411
|
+
* The caller holds the path `ensureCodexRunHome` returned; it passes that.
|
|
412
|
+
*
|
|
413
|
+
* IT IS NOT MERE TIDINESS, and the comment here used to say it was. A leftover
|
|
414
|
+
* home is 32 MB after one run and holds the credential copy plus (before
|
|
415
|
+
* `--ephemeral`) the full session transcript with absolute local paths. This is
|
|
416
|
+
* the primary reclaim; `sweepStrandedCodexHomes` is the backstop for the paths
|
|
417
|
+
* that never reach it.
|
|
418
|
+
*
|
|
419
|
+
* Best-effort in the sense that it never throws: a run that has already produced
|
|
420
|
+
* the user's answer must not fail because a directory would not delete.
|
|
421
|
+
*/
|
|
422
|
+
export function removeCodexRunHome(home) {
|
|
423
|
+
try {
|
|
424
|
+
rmSync(home, { recursive: true, force: true });
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
/* Never allowed to affect a run that has already ended. The startup sweep is
|
|
428
|
+
what catches whatever this could not remove. */
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Remove per-run codex homes whose OWNING PROCESS IS GONE.
|
|
433
|
+
*
|
|
434
|
+
* WHY A STARTUP SWEEP EXISTS AT ALL (gate A, blocker 3). The per-run sweep runs
|
|
435
|
+
* when a run ends, and there are ways a run does not end: SIGKILL, a panic, an
|
|
436
|
+
* OS restart, launchd restarting a crashed daemon, and `daemon.ts`'s own
|
|
437
|
+
* shutdown, which calls `process.exit(0)` without awaiting in-flight spawns.
|
|
438
|
+
* Every one strands a directory holding a copy of the user's credential, and
|
|
439
|
+
* nothing else in the product would ever look at it again.
|
|
440
|
+
*
|
|
441
|
+
* ═══ WHY IT IS NOT WHOLESALE, WHICH IS WHAT IT USED TO BE ═══
|
|
442
|
+
*
|
|
443
|
+
* The first version deleted the whole root, borrowing `recoverStrandedWorkers`'s
|
|
444
|
+
* argument: "if this daemon is starting, nothing it started is still running".
|
|
445
|
+
* **That argument does not transfer, and the re-review proved the harm.** That
|
|
446
|
+
* function is scoped by `machine_id` to rows this daemon owns on a SERVER; this
|
|
447
|
+
* is an unscoped `rmSync` of a SHARED LOCAL directory. TWO INDEPENDENT PROCESSES
|
|
448
|
+
* call `startPresence()`: the terminal daemon (`cs start` → `runDaemon`) and the
|
|
449
|
+
* companion (`cs open`, again after browser sign-in). The companion has a port
|
|
450
|
+
* guard; the daemon has NO cross-process guard at all. Both resolve the same
|
|
451
|
+
* `configDir()`, so `cs start` while the companion is mid-run — or launchd
|
|
452
|
+
* restarting a crashed daemon while the companion survives — deleted a LIVE
|
|
453
|
+
* worker's `$CODEX_HOME` underneath it. That is precisely the harm the `settle`
|
|
454
|
+
* fix removed, reintroduced at a new trigger.
|
|
455
|
+
*
|
|
456
|
+
* ═══ WHY THE OWNER PID, AND NOT AGE ═══
|
|
457
|
+
*
|
|
458
|
+
* Age was the obvious candidate and it is NOT SAFE AT ANY THRESHOLD I could
|
|
459
|
+
* defend, which was measured rather than assumed. During a live 40-second codex
|
|
460
|
+
* run, sampled every five seconds: the home directory's own mtime went stale
|
|
461
|
+
* almost immediately (codex writes to files INSIDE it, which does not touch the
|
|
462
|
+
* parent's mtime), and the newest mtime ANYWHERE IN THE TREE still reached ~29
|
|
463
|
+
* seconds while the run was plainly alive, because codex holds sqlite handles
|
|
464
|
+
* open without flushing. Mtime measures "when codex last flushed", not "is this
|
|
465
|
+
* run alive". A thinking agent mid-tool-call is silent BY DESIGN here — the
|
|
466
|
+
* elapsed-time kill was retired for exactly that reason (user ruling
|
|
467
|
+
* 2026-08-06, "no agent that is actively working should ever be killed
|
|
468
|
+
* automatically") — so any age threshold is a bet against a long tool call.
|
|
469
|
+
*
|
|
470
|
+
* The owner pid asks the real question instead. `processIsAlive` is the same
|
|
471
|
+
* check `reapDeadWorkers` already trusts to decide a worker really died, and it
|
|
472
|
+
* treats EPERM as alive, so a home owned by the OTHER process (a different uid,
|
|
473
|
+
* or simply not ours to signal) is correctly read as still owned.
|
|
474
|
+
*
|
|
475
|
+
* A RECYCLED PID CAN ONLY MAKE THIS TOO CAUTIOUS, NEVER TOO EAGER. If a dead
|
|
476
|
+
* owner's pid has been reused by something unrelated, the home is kept and
|
|
477
|
+
* reclaimed on a later start; the failure direction is a leftover directory, not
|
|
478
|
+
* a deleted live one. That is the right way round for a directory holding a
|
|
479
|
+
* credential in use.
|
|
480
|
+
*
|
|
481
|
+
* A HOME WITH NO OWNER FILE IS SWEPT, and there are TWO independent reasons that
|
|
482
|
+
* is safe rather than one. Within a build, an unstamped home can only come from
|
|
483
|
+
* a crash between `mkdirSync` and the owner stamp, which is a window of two
|
|
484
|
+
* syscalls with no child spawned yet, so there is nothing live to protect.
|
|
485
|
+
* Across builds, the usual worry would be a home written by an OLDER version
|
|
486
|
+
* that did not stamp — and there is no such version: this module was created on
|
|
487
|
+
* the branch that also introduced the stamp, and has never been merged or
|
|
488
|
+
* released, so no build exists anywhere that can produce a live unstamped home.
|
|
489
|
+
*
|
|
490
|
+
* Best-effort and never throws: a daemon must start even if a directory will
|
|
491
|
+
* not delete. A home that survives is retried on the next start, and the run
|
|
492
|
+
* that reuses its key rebuilds it from empty anyway.
|
|
493
|
+
*/
|
|
494
|
+
export function sweepStrandedCodexHomes(
|
|
495
|
+
/** Injected so a test can drive the liveness decision without real processes.
|
|
496
|
+
* Defaults to the same check `reapDeadWorkers` uses. */
|
|
497
|
+
isAlive = defaultProcessIsAlive) {
|
|
498
|
+
let entries;
|
|
499
|
+
try {
|
|
500
|
+
entries = readdirSync(codexHomesRoot());
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
/* No root yet (the common case on a first start), or unreadable. Nothing to
|
|
504
|
+
do either way, and startup must not be blocked. */
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
for (const entry of entries) {
|
|
508
|
+
const home = join(codexHomesRoot(), entry);
|
|
509
|
+
try {
|
|
510
|
+
if (codexRunHomeHasLiveOwner(home, isAlive))
|
|
511
|
+
continue;
|
|
512
|
+
rmSync(home, { recursive: true, force: true });
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
/* One home that will not delete must not stop the others, and must not
|
|
516
|
+
stop the daemon starting. Retried next start. */
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Does this home still belong to a running process? Anything unreadable or
|
|
522
|
+
* unparseable counts as ownerless — see the sweep's comment on the two-syscall
|
|
523
|
+
* window that is the only way to produce one.
|
|
524
|
+
*
|
|
525
|
+
* `pid > 0` IS LOAD-BEARING, NOT A PLAUSIBILITY CHECK, and it was written as one
|
|
526
|
+
* before the gate pointed this out. `process.kill(0, 0)` does not ask "is pid 0
|
|
527
|
+
* alive": POSIX reads pid 0 as THE CALLER'S OWN PROCESS GROUP, and `kill(-N, 0)`
|
|
528
|
+
* targets process GROUP N. Both SUCCEED — measured on this machine — so without
|
|
529
|
+
* this a home stamped `{"pid":0}` would read as owned forever and could never be
|
|
530
|
+
* reclaimed. The guard keeps a corrupt or truncated stamp from reaching `kill`
|
|
531
|
+
* with a value that means something entirely different from "this process".
|
|
532
|
+
*
|
|
533
|
+
* `Number.isInteger` is belt-and-braces by comparison and is kept as such: node
|
|
534
|
+
* throws `ERR_INVALID_ARG_TYPE` on `1.5` and `NaN`, which `defaultProcessIsAlive`
|
|
535
|
+
* already turns into "dead" via its non-EPERM catch. It is here so the intent is
|
|
536
|
+
* stated at the point of the decision rather than resting on the catch, and a
|
|
537
|
+
* mutation removing it survives the suite for that reason.
|
|
538
|
+
*/
|
|
539
|
+
function codexRunHomeHasLiveOwner(home, isAlive) {
|
|
540
|
+
let pid;
|
|
541
|
+
try {
|
|
542
|
+
pid = JSON.parse(readFileSync(join(home, OWNER_FILE), 'utf8')).pid;
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && isAlive(pid);
|
|
548
|
+
}
|
|
549
|
+
/** `process.kill(pid, 0)` signals nothing and only tests for existence. EPERM
|
|
550
|
+
* means it exists and belongs to someone else, which still answers "alive" —
|
|
551
|
+
* so only ESRCH ("no such process") is death. Duplicated from
|
|
552
|
+
* `orchestrator.processIsAlive` rather than imported, because orchestrator.ts
|
|
553
|
+
* imports THIS module and the cycle would be the only thing gained. */
|
|
554
|
+
function defaultProcessIsAlive(pid) {
|
|
555
|
+
try {
|
|
556
|
+
process.kill(pid, 0);
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
catch (err) {
|
|
560
|
+
return err.code === 'EPERM';
|
|
561
|
+
}
|
|
562
|
+
}
|
package/dist/companion.js
CHANGED
|
@@ -6,7 +6,7 @@ import { companionToken, getMachineIdentity, readSession, clearSession, readCode
|
|
|
6
6
|
import { getClient, signIn, NotLoggedIn } from './supabase.js';
|
|
7
7
|
import { startPresence, stopPresence } from './presence.js';
|
|
8
8
|
import { detectAgents } from './agents.js';
|
|
9
|
-
import {
|
|
9
|
+
import { agentToolsBadgeState } from './mcp.js';
|
|
10
10
|
import { loadProjects, saveMapping } from './projects.js';
|
|
11
11
|
import { listCodebases, addCodebase, reportLocated, removeCodebase, NotHostedRemoteError } from './codebases.js';
|
|
12
12
|
import { hostedRemoteIdentity } from './git-remote.js';
|
|
@@ -156,18 +156,15 @@ function str(value) {
|
|
|
156
156
|
}
|
|
157
157
|
/** The passive "Agent tools" badge state for the companion (feature 05). Phase 3
|
|
158
158
|
* gives it an honest lifecycle — connecting / connected / couldn't-connect — on
|
|
159
|
-
* top of the signed-out and no-agent off-states. The branching
|
|
160
|
-
*
|
|
161
|
-
* live signals
|
|
162
|
-
*
|
|
159
|
+
* top of the signed-out and no-agent off-states. The branching lives in
|
|
160
|
+
* `agentToolsBadgeState` (mcp.ts, unit-testable): the pure `badgeReason` over
|
|
161
|
+
* this process's live signals, plus — when THIS process doesn't own the tools
|
|
162
|
+
* port (bug 20260730 split-brain) — a cached /health probe and the on-disk
|
|
163
|
+
* agent registrations, so a healthy server owned by another cs process never
|
|
164
|
+
* reads as "failed". This only gathers signed-in + installed agents. */
|
|
163
165
|
function agentToolsState(signedIn) {
|
|
164
166
|
const installed = detectAgents().filter((a) => a === 'claude' || a === 'codex');
|
|
165
|
-
return
|
|
166
|
-
signedIn,
|
|
167
|
-
installed,
|
|
168
|
-
serverRunning: toolsServerStatus().running,
|
|
169
|
-
status: agentRegStatus(),
|
|
170
|
-
});
|
|
167
|
+
return agentToolsBadgeState({ signedIn, installed });
|
|
171
168
|
}
|
|
172
169
|
async function handle(req, res, token) {
|
|
173
170
|
if (!allowedHost(req.headers.host)) {
|
|
@@ -221,7 +218,7 @@ async function handleApi(req, res, path, query) {
|
|
|
221
218
|
machineName: machine.name,
|
|
222
219
|
platform: process.platform,
|
|
223
220
|
version: VERSION,
|
|
224
|
-
agentTools: agentToolsState(email !== null),
|
|
221
|
+
agentTools: await agentToolsState(email !== null),
|
|
225
222
|
});
|
|
226
223
|
return;
|
|
227
224
|
}
|
package/dist/config.js
CHANGED
|
@@ -10,6 +10,29 @@ export function configDir() {
|
|
|
10
10
|
function filePath(name) {
|
|
11
11
|
return join(configDir(), name);
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* 18b SLICE 2 — WHERE A RUN THAT NEEDS NO REPOSITORY STARTS.
|
|
15
|
+
*
|
|
16
|
+
* NEVER THE FILESYSTEM ROOT, and never `$HOME`. Passing no cwd to `spawn` means
|
|
17
|
+
* "inherit the parent's", and the parent is a launchd login item whose cwd is
|
|
18
|
+
* `/` — so a write-capable agent answering "what is on this project's board"
|
|
19
|
+
* started at the root of the user's disk. Nothing in that run needs a directory
|
|
20
|
+
* at all, which is exactly why it should be given one that contains nothing.
|
|
21
|
+
*
|
|
22
|
+
* An empty directory inside the config dir is the cheapest honest answer: it
|
|
23
|
+
* exists (so `spawn` cannot fail on chdir), it is the user's own, it is empty
|
|
24
|
+
* (so a stray relative write lands somewhere inert and visible rather than in a
|
|
25
|
+
* real tree), and it needs no cleanup between runs.
|
|
26
|
+
*
|
|
27
|
+
* ponytail: one shared directory, not one per run. Per-run temp dirs would need
|
|
28
|
+
* a lifecycle nothing here has; if two concurrent no-checkout runs ever need to
|
|
29
|
+
* be isolated from each other, mkdtemp per run is the upgrade.
|
|
30
|
+
*/
|
|
31
|
+
export function scratchDir() {
|
|
32
|
+
const path = join(configDir(), 'scratch');
|
|
33
|
+
mkdirSync(path, { recursive: true });
|
|
34
|
+
return path;
|
|
35
|
+
}
|
|
13
36
|
function writeJson(name, value) {
|
|
14
37
|
mkdirSync(configDir(), { recursive: true });
|
|
15
38
|
const path = filePath(name);
|