@lyupro/codex-bridge 0.6.2 → 0.6.4
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/CHANGELOG.md +101 -0
- package/README.md +1 -1
- package/cli/runs-inventory.mjs +5 -3
- package/cli/stop.mjs +2 -2
- package/cli/unlock.mjs +2 -2
- package/package.json +2 -2
- package/src/home/hooks/live-runs.mjs +19 -21
- package/src/home/hooks/reply-guard.mjs +5 -2
- package/src/home/hooks/shell-write-intent.mjs +320 -154
- package/src/home/hooks/worktree-lock.mjs +9 -2
- package/src/home/hooks/worktree-witness.mjs +21 -40
- package/src/home/lib/meta/events.mjs +4 -0
- package/src/home/lib/meta/launch-rows.mjs +45 -0
- package/src/home/lib/meta/reply.mjs +1 -32
- package/src/home/lib/meta/run-liveness.mjs +64 -0
- package/src/home/lib/meta/run-state.mjs +26 -34
- package/src/home/lib/meta/verdict.mjs +11 -0
- package/src/home/lib/runner/args.mjs +7 -4
- package/src/home/lib/runner/attach.mjs +5 -13
- package/src/home/lib/runner/codex-args.mjs +11 -3
- package/src/home/lib/runner/codex-cmd.mjs +104 -18
- package/src/home/lib/runner/git-state.mjs +6 -1
- package/src/home/lib/runner/launcher.mjs +20 -27
- package/src/home/lib/runner/preflight.mjs +35 -0
- package/src/home/lib/runner/sandbox-probe.mjs +109 -0
- package/src/home/lib/runner/scope-check.mjs +22 -2
- package/src/home/lib/write-meta.mjs +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,107 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.6.4] - 2026-09-21
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- A refusal that spends no quota no longer leaves a run folder behind. A second writing run against a
|
|
12
|
+
busy tree was refused correctly, but its folder was created first — and in a repository that hosts
|
|
13
|
+
the run folders, the live writer's witness then spent every tool call ordering the orchestrator to
|
|
14
|
+
revert a directory the tool itself had made, or to stop an honest run. Neither is a safe action. The
|
|
15
|
+
misplaced check was the symptom: refusals sat on two sides of the registration boundary with no rule
|
|
16
|
+
about which side was which, and two gates of the same class already disagreed — a dead sandbox
|
|
17
|
+
refused without a folder, a busy tree with one. The quota-free host checks now live in a module that
|
|
18
|
+
is handed no run directory, so a check added there cannot register a run, and a table test holds
|
|
19
|
+
every refusal with its side and the reason for that side. A busy tree and an unavailable Codex CLI
|
|
20
|
+
therefore write nothing at all: they no longer produce an `aborted_pre_start` folder, and their
|
|
21
|
+
exit code stays 1, because the order was correct and the host or the tree was not.
|
|
22
|
+
- The live witness and the final verdict judge one worktree by one definition. The witness read
|
|
23
|
+
`git status --porcelain` on its own while the verdict reads the recorded snapshot, so three kinds of
|
|
24
|
+
change were visible to one and invisible to the other: the run's own folder, the orchestrator's
|
|
25
|
+
environment writes, and gitignored working notes. The witness now composes the same chain as the
|
|
26
|
+
verdict, and its message says first that those three are already excluded — so what it lists is the
|
|
27
|
+
orchestrator's own edit — and only then offers the two actions. A structural test fails any hook that
|
|
28
|
+
spells a worktree query in an argument list; this drift went unnoticed for months and surfaced only
|
|
29
|
+
as a false accusation against a run that was doing its job.
|
|
30
|
+
- A file moved inside its own scope is no longer a stray. The shared snapshot was read with git's
|
|
31
|
+
rename detection on, which prints one row spelled `old => new` — not a path, and matching no scope
|
|
32
|
+
pattern. The witness named that token at the orchestrator, and the verdict would have failed an
|
|
33
|
+
honest build for a `git mv` it was told to make. The snapshot is now read with `--no-renames`: a
|
|
34
|
+
rename is a deletion plus an addition, two real paths, each judged on its own merits.
|
|
35
|
+
- A scope pattern that can only name a directory is refused before the run starts. A scope is compared
|
|
36
|
+
against file paths, never directories, so `muse/scripts` authorises one file that will never exist
|
|
37
|
+
while everything created inside the folder counts as a stray. Declared patterns escaped this by luck
|
|
38
|
+
— `git ls-files` prints no directories — but `--scope-new` is exempt from the existence check by
|
|
39
|
+
design, and a run ordered with `--scope-new muse/scripts` worked ten minutes, returned a filled
|
|
40
|
+
result, and was failed for the three files it created there. A trailing slash, a name that is a
|
|
41
|
+
directory on disk, and a glob-free name whose last segment has no extension are now refused before
|
|
42
|
+
the run folder exists, with the spelling that works named in the refusal. Patterns containing a glob
|
|
43
|
+
are never judged by these rules.
|
|
44
|
+
|
|
45
|
+
## [0.6.3] - 2026-09-17
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- A dead Codex sandbox refuses the run before it spends quota. On 2026-09-16 an unclean Windows
|
|
50
|
+
shutdown left the sandbox helper's state file as 22 zero bytes, and every delegated run after it
|
|
51
|
+
paid for a session in which not one command ran — one scout even came back `OK`. The launcher now
|
|
52
|
+
asks the sandbox to echo a marker before the run folder exists, and calls it dead only on double
|
|
53
|
+
evidence: the role's own flags and a flagless control both produce no marker, neither reports an
|
|
54
|
+
argument error, and the CLI still answers `--version`. Codex had already changed the shape of its
|
|
55
|
+
own sandbox command once, so anything the probe cannot judge lets the run start and is named in
|
|
56
|
+
the reply — a release that stops accepting the package's sandbox flags cannot turn the check off
|
|
57
|
+
silently. `stderr` is quoted for the operator and never parsed. The probe is bounded on every
|
|
58
|
+
path: a hung attempt stops the whole Codex tree rather than the `cmd.exe` in front of it, and a
|
|
59
|
+
capture that overflows is inconclusive, never dead. Windows and Linux are judged, each after both
|
|
60
|
+
outcomes were seen live on it; on Linux, where a dead sandbox is the ordinary state of a fresh
|
|
61
|
+
Ubuntu 23.10+ server, the refusal names the AppArmor profile that repairs it and warns against
|
|
62
|
+
lifting the restriction through `sysctl`. macOS is skipped — no outcome has been observed there.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- A scout that executed no command can no longer come back `OK`. On the dead sandbox of 2026-09-16
|
|
67
|
+
a scout was graded `OK` without reading a single file: its evidence explained that the files were
|
|
68
|
+
unreadable, and the coverage gate only checked that the evidence was a non-empty string. The
|
|
69
|
+
verdict now requires a fact before it judges coverage — at least one completed
|
|
70
|
+
`command_execution` in `events.jsonl`, with any status or exit code. Refused calls never enter the
|
|
71
|
+
stream, which is what makes the fact reliable; across 99 earlier successful scout runs it was
|
|
72
|
+
missing exactly twice, both on that sandbox.
|
|
73
|
+
- Quoted text no longer reads as shell commands to the worktree lock. The write-intent scanner read
|
|
74
|
+
quotes three different ways, and its raw split on `;` and `|` cut inside them: an `awk` script
|
|
75
|
+
containing "Do not touch any file outside" was refused on 2026-09-16 as a write to `any`, `file`
|
|
76
|
+
and `outside` — the fourth refusal on one root after `node -e`, `sed` and a heredoc body. One
|
|
77
|
+
lexer now owns quotes, escapes and command boundaries for all three consumers, drops comments, and
|
|
78
|
+
opens subshells and `$(…)` only where the shell does.
|
|
79
|
+
- An argument that happens to be a command name is not a write. `git log --grep rm -- README.md` and
|
|
80
|
+
`grep -rn touch src/` were refused while a build held the repository, because the scanner looked
|
|
81
|
+
for `rm` and `touch` in any word. A command name now counts only in command position — after
|
|
82
|
+
assignments, reserved words, leading redirections, a short list of launchers, and a `find -exec`
|
|
83
|
+
action up to its terminator.
|
|
84
|
+
- A stop at the deadline cannot block the launcher forever. `stopCodex()` gave `taskkill` no timeout,
|
|
85
|
+
so a stalled `taskkill` held the event loop and every settlement timer with it. It now has ten
|
|
86
|
+
seconds, after which the direct child is killed.
|
|
87
|
+
- The response guard judges a run by its own process, not by a bare pid. Without the run folder or
|
|
88
|
+
the recorded start time, a pid reused by another process after a reboot read as "the run is still
|
|
89
|
+
working", and the guard held the dispatcher on a dead run until the session's retry budget stopped
|
|
90
|
+
the session.
|
|
91
|
+
- The project list no longer prints the raw `running` of a run whose worker died before writing
|
|
92
|
+
`meta.json`. A row without a verdict shows what closing the run would write: `abandoned` for a
|
|
93
|
+
dead or foreign process, `unverified` when the end cannot be proven. The "live now" column keeps
|
|
94
|
+
its fresh-heartbeat policy.
|
|
95
|
+
|
|
96
|
+
### Changed
|
|
97
|
+
|
|
98
|
+
- One module decides whether a recorded run is still live. Seven places answered that question on
|
|
99
|
+
their own through pid-first wrappers whose other inputs were optional — which is how the response
|
|
100
|
+
guard came to ask with a bare pid and the project list never to ask at all, although
|
|
101
|
+
`markAbandoned()` already held the exact rule. `src/home/lib/meta/run-liveness.mjs` now owns it,
|
|
102
|
+
and `tests/one-liveness-judge.test.mjs` fails the suite when another module asks a process
|
|
103
|
+
identity, re-exports one under a new name, or declares a pid-first wrapper again.
|
|
104
|
+
- The rows a reply carries are rendered by one function. The Retention row had been copied into five
|
|
105
|
+
reply functions and was still missing from the runner's failure path, so a runner failure lost it;
|
|
106
|
+
a sixth copy for the sandbox row would have repeated that defect.
|
|
107
|
+
|
|
7
108
|
## [0.6.2] - 2026-09-07
|
|
8
109
|
|
|
9
110
|
### Fixed
|
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ If a global install and a clone coexist, you have two package copies. `codex-bri
|
|
|
62
62
|
- **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
|
|
63
63
|
- **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
|
|
64
64
|
|
|
65
|
-
The current suite contains **
|
|
65
|
+
The current suite contains **1177 automated tests: 1176 passing and 1 skipped**.
|
|
66
66
|
|
|
67
67
|
## Verify
|
|
68
68
|
|
package/cli/runs-inventory.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { liveRuns } from '../src/home/hooks/live-runs.mjs';
|
|
5
5
|
import { readJsonFileSync } from '../src/home/lib/json-file.mjs';
|
|
6
|
+
import { runLiveness } from '../src/home/lib/meta/run-liveness.mjs';
|
|
6
7
|
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
7
8
|
|
|
8
9
|
const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -108,7 +109,7 @@ function readRun(runDir, livePaths) {
|
|
|
108
109
|
return {
|
|
109
110
|
run: path.basename(runDir),
|
|
110
111
|
agent: text(facts?.agent),
|
|
111
|
-
verdict: live ? 'running' : text(meta ? meta.status : status
|
|
112
|
+
verdict: live ? 'running' : text(meta ? meta.status : runLiveness({ runDir, status }).state),
|
|
112
113
|
tokens: meta ? number(meta.tokens) : null,
|
|
113
114
|
size: recursiveSize(runDir),
|
|
114
115
|
timestamp: runTimestamp(path.basename(runDir), meta, status),
|
|
@@ -131,8 +132,9 @@ function publicRun(run) {
|
|
|
131
132
|
|
|
132
133
|
function buildProject(projectDir) {
|
|
133
134
|
const runDirs = directoryEntries(projectDir).map((entry) => path.join(projectDir, entry.name));
|
|
134
|
-
// live-runs owns
|
|
135
|
-
//
|
|
135
|
+
// live-runs owns the `live` column (a fresh heartbeat) after the 2026-08-05 live probe incident.
|
|
136
|
+
// A row without a verdict shows what closing it would write — the judge's `abandoned` rather than
|
|
137
|
+
// the raw `running` a dead run keeps in status.json forever (Plan_57 D27).
|
|
136
138
|
const livePaths = new Set(liveRuns(projectDir).map(({ dir }) => pathKey(dir)));
|
|
137
139
|
const details = runDirs.map((runDir) => readRun(runDir, livePaths));
|
|
138
140
|
// Sum what is known instead of blanking the column. One archived run without accounting used to
|
package/cli/stop.mjs
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { stopCodex } from '../src/home/lib/runner/codex-cmd.mjs';
|
|
4
4
|
import { git, worktreeSnapshot } from '../src/home/lib/runner/git-state.mjs';
|
|
5
|
+
import { runLiveness } from '../src/home/lib/meta/run-liveness.mjs';
|
|
5
6
|
import {
|
|
6
7
|
IDENTITY_ALIVE,
|
|
7
8
|
IDENTITY_FOREIGN,
|
|
8
9
|
IDENTITY_UNVERIFIED,
|
|
9
10
|
processAlive,
|
|
10
|
-
processIdentity,
|
|
11
11
|
} from '../src/home/lib/process-identity.mjs';
|
|
12
12
|
import { markAbandoned, readJson } from '../src/home/lib/write-meta.mjs';
|
|
13
13
|
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
@@ -72,7 +72,7 @@ export async function stop({
|
|
|
72
72
|
return result(1, `Run ${runDir} has no valid recorded pid; it was not changed.`);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
const identity =
|
|
75
|
+
const { identity } = runLiveness({ runDir, status, commandRunner });
|
|
76
76
|
if (identity === IDENTITY_UNVERIFIED || identity === IDENTITY_FOREIGN) {
|
|
77
77
|
return result(
|
|
78
78
|
1,
|
package/cli/unlock.mjs
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { formatSilence, heartbeatAge, HEARTBEAT_STALE_MS } from '../src/home/lib/heartbeat.mjs';
|
|
6
|
+
import { runLiveness } from '../src/home/lib/meta/run-liveness.mjs';
|
|
6
7
|
import { markAbandoned } from '../src/home/lib/meta/run-state.mjs';
|
|
7
8
|
import {
|
|
8
9
|
IDENTITY_ALIVE,
|
|
9
10
|
IDENTITY_UNVERIFIED,
|
|
10
|
-
processIdentity,
|
|
11
11
|
} from '../src/home/lib/process-identity.mjs';
|
|
12
12
|
import { git } from '../src/home/lib/runner/git-state.mjs';
|
|
13
13
|
import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
|
|
@@ -144,7 +144,7 @@ function inspectRuns(project, options) {
|
|
|
144
144
|
age: runAge(status.started_at, now),
|
|
145
145
|
silence: formatSilence(silenceAge),
|
|
146
146
|
silenceAge,
|
|
147
|
-
identity:
|
|
147
|
+
identity: runLiveness(identityOptions(dir, status, options, now)).identity,
|
|
148
148
|
state: status.state,
|
|
149
149
|
});
|
|
150
150
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyupro/codex-bridge",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "Claude Code-to-Codex delegation with 6 guards,
|
|
3
|
+
"version": "0.6.4",
|
|
4
|
+
"description": "Claude Code-to-Codex delegation with 6 guards, 1177 tests, zero runtime dependencies, and zero build steps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
7
7
|
"codex",
|
|
@@ -9,34 +9,17 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The heartbeat is required here and deliberately NOT in meta/run-state.mjs: releasing a lock
|
|
11
11
|
* early costs an operator one knowing edit, while closing a record early makes a second writer
|
|
12
|
-
* of its meta.json. See the comment above
|
|
12
|
+
* of its meta.json. See the comment above markAbandoned() in meta/run-state.mjs.
|
|
13
13
|
*/
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { heartbeatAge, isHeartbeatFresh } from '../lib/heartbeat.mjs';
|
|
17
17
|
import { readJsonFileSync } from '../lib/json-file.mjs';
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
IDENTITY_DEAD,
|
|
21
|
-
IDENTITY_FOREIGN,
|
|
22
|
-
processIdentity,
|
|
23
|
-
} from '../lib/process-identity.mjs';
|
|
18
|
+
import { runLiveness } from '../lib/meta/run-liveness.mjs';
|
|
19
|
+
import { IDENTITY_ALIVE } from '../lib/process-identity.mjs';
|
|
24
20
|
|
|
25
21
|
export const RECENT_RUN_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
|
|
26
22
|
|
|
27
|
-
/**
|
|
28
|
-
* The hook requires pid judgment plus a fresh heartbeat. Identity uncertainty stays live here
|
|
29
|
-
* (fail-open), while the identity module deliberately treats a missing heartbeat as unverified;
|
|
30
|
-
* isHeartbeatFresh() keeps its pre-Plan_20 missing-file compatibility for this separate question.
|
|
31
|
-
*/
|
|
32
|
-
// Plan_31 needs confirmed process identity before warning about paid work that TaskStop leaves
|
|
33
|
-
// behind; existing lock/reply guards retain the fail-open default for uncertain identities.
|
|
34
|
-
export const isPidAlive = (pid, runDir, status = {}, options = {}) => {
|
|
35
|
-
const identity = processIdentity({ runDir, status: { ...status, pid } });
|
|
36
|
-
if (options.requireConfirmedIdentity === true) return identity === IDENTITY_ALIVE;
|
|
37
|
-
return identity !== IDENTITY_DEAD && identity !== IDENTITY_FOREIGN;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
23
|
/**
|
|
41
24
|
* Match the package's repository comparison without resolving symlinks: Windows realpath adds
|
|
42
25
|
* `\\?\\` and UNC spellings, turning equal paths into unequal ones. A null result is uncertainty,
|
|
@@ -58,9 +41,24 @@ function isNonEmptyString(value) {
|
|
|
58
41
|
return typeof value === 'string' && Boolean(value.trim());
|
|
59
42
|
}
|
|
60
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The hook requires pid judgment plus a fresh heartbeat. Identity uncertainty stays live here
|
|
46
|
+
* (fail-open), while the identity module deliberately treats a missing heartbeat as unverified;
|
|
47
|
+
* isHeartbeatFresh() keeps its pre-Plan_20 missing-file compatibility for this separate question.
|
|
48
|
+
*/
|
|
61
49
|
function recognizedStatus(runDir, status, options) {
|
|
62
50
|
const heartbeatFresh = isHeartbeatFresh(runDir)
|
|
63
51
|
&& (options.requireConfirmedIdentity !== true || heartbeatAge(runDir) !== null);
|
|
52
|
+
// Judged last: an identity probe can spawn PowerShell, and every hook call scans every project,
|
|
53
|
+
// so a record already ruled out by its fields or a stale heartbeat must never pay for one.
|
|
54
|
+
const judged = () => {
|
|
55
|
+
const liveness = runLiveness({ runDir, status });
|
|
56
|
+
// Plan_31 needs confirmed process identity before warning about paid work that TaskStop leaves
|
|
57
|
+
// behind; existing lock/reply guards retain the fail-open default for uncertain identities.
|
|
58
|
+
return options.requireConfirmedIdentity === true
|
|
59
|
+
? liveness.identity === IDENTITY_ALIVE
|
|
60
|
+
: liveness.processMayBeAlive === true;
|
|
61
|
+
};
|
|
64
62
|
return Boolean(status)
|
|
65
63
|
&& typeof status === 'object'
|
|
66
64
|
&& !Array.isArray(status)
|
|
@@ -70,7 +68,7 @@ function recognizedStatus(runDir, status, options) {
|
|
|
70
68
|
&& isNonEmptyString(status.slug)
|
|
71
69
|
&& isNonEmptyString(status.repo)
|
|
72
70
|
&& heartbeatFresh
|
|
73
|
-
&&
|
|
71
|
+
&& judged();
|
|
74
72
|
}
|
|
75
73
|
|
|
76
74
|
function readStatus(runDir) {
|
|
@@ -33,9 +33,10 @@ import { AGENTS } from '../lib/agents.mjs';
|
|
|
33
33
|
import { runOrderMismatch, transcriptOrderId } from '../lib/dispatcher-order.mjs';
|
|
34
34
|
import { recognizeHostRefusal } from '../lib/host-refusal.mjs';
|
|
35
35
|
import { readJsonFileSync } from '../lib/json-file.mjs';
|
|
36
|
+
import { runLiveness } from '../lib/meta/run-liveness.mjs';
|
|
36
37
|
import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
|
|
37
38
|
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
38
|
-
import {
|
|
39
|
+
import { liveRuns, normalizePath, recentRuns } from './live-runs.mjs';
|
|
39
40
|
import { FORM, MAX_STATE_BLOCKS, STATE, takeTry } from './guard-tries.mjs';
|
|
40
41
|
import { parseReply } from './reply-parser.mjs';
|
|
41
42
|
import {
|
|
@@ -233,7 +234,9 @@ if (fs.existsSync(statusPath)) {
|
|
|
233
234
|
}
|
|
234
235
|
|
|
235
236
|
if (runStatus?.state === 'running') {
|
|
236
|
-
|
|
237
|
+
// Plan_57 D5/D27: a bare pid mistook a reused foreign process for this run's worker.
|
|
238
|
+
// The shared judge uses run identity and keeps unverified processes live (fail open).
|
|
239
|
+
if (runLiveness({ runDir, status: runStatus }).processMayBeAlive) {
|
|
237
240
|
blockState(
|
|
238
241
|
liveRunReason,
|
|
239
242
|
liveRunStop(MAX_STATE_BLOCKS, runStatus, runDir),
|