@lyupro/codex-bridge 0.3.1 → 0.5.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/CHANGELOG.md +92 -0
- package/README.md +88 -84
- package/bin/codex-bridge.mjs +38 -11
- package/cli/copy.mjs +7 -6
- package/cli/doctor.mjs +73 -12
- package/cli/hook-targets.mjs +39 -0
- package/cli/hook.mjs +34 -0
- package/cli/hosts.mjs +28 -4
- package/cli/install-record.mjs +287 -0
- package/cli/install.mjs +73 -39
- package/cli/invoked-directly.mjs +37 -0
- package/cli/manifest.mjs +90 -164
- package/cli/permissions.mjs +179 -0
- package/cli/projects.mjs +20 -5
- package/cli/prune-args.mjs +1 -1
- package/cli/remove-layout.mjs +59 -0
- package/cli/rules-owners.mjs +6 -8
- package/cli/runs-inventory.mjs +2 -1
- package/cli/settings-merge.mjs +182 -50
- package/cli/stop.mjs +33 -20
- package/cli/uninstall.mjs +64 -36
- package/cli/unlock.mjs +239 -0
- package/cli/update.mjs +109 -48
- package/package.json +2 -2
- package/scripts/check-file-size.mjs +8 -10
- package/scripts/isolated-roots.mjs +13 -0
- package/scripts/run-tests.mjs +14 -4
- package/src/agents/codex-build.md +17 -13
- package/src/agents/codex-review.md +15 -9
- package/src/agents/codex-scout.md +16 -11
- package/src/commands/env.md +4 -4
- package/src/commands/usage.md +8 -6
- package/src/heartbeat.mjs +1 -1
- package/src/hook-definitions.mjs +17 -4
- package/src/hooks/guard-tries.mjs +59 -0
- package/src/hooks/live-runs.mjs +31 -20
- package/src/hooks/order-gate.mjs +7 -2
- package/src/hooks/reply-guard.mjs +4 -60
- package/src/hooks/stop-guard.mjs +71 -0
- package/src/json-file.mjs +30 -0
- package/src/meta/chain.mjs +8 -6
- package/src/meta/paths.mjs +5 -9
- package/src/meta/pre-start.mjs +30 -0
- package/src/meta/run-state.mjs +31 -16
- package/src/process-identity.mjs +140 -0
- package/src/required-inputs.mjs +42 -0
- package/src/run-config.mjs +4 -8
- package/src/runner/args.mjs +26 -3
- package/src/runner/attach.mjs +32 -19
- package/src/runner/codex-cmd.mjs +1 -1
- package/src/runner/continuation.mjs +34 -3
- package/src/runner/launcher.mjs +54 -28
- package/src/runner/project-dir.mjs +4 -3
- package/src/runner/scope-check.mjs +159 -0
- package/src/runner/worker.mjs +13 -5
- package/src/stop-contract.mjs +15 -0
- package/src/write-meta.mjs +12 -1
- package/cli/sweep.mjs +0 -144
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,98 @@ 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
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.5.0] - 2026-08-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- A fifth host guard now catches `TaskStop` while a live Codex run is still writing to the
|
|
14
|
+
repository, names the run and points to `codex-bridge stop <run>` before allowing a repeated
|
|
15
|
+
request. `doctor` and `projects` also surface active runs, so stopping a dispatcher wrapper is
|
|
16
|
+
no longer mistaken for stopping its detached worker.
|
|
17
|
+
- `codex-bridge hook <name>` gives Claude Code a stable way to invoke each installed guard, and
|
|
18
|
+
`meta.json` now records the package version that produced a run. The usage report can therefore
|
|
19
|
+
separate historical runs made outside this runner from runs that should satisfy its sandbox
|
|
20
|
+
contract.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **Breaking:** the installed package layout now uses `agents/codex-bridge/` instead of
|
|
25
|
+
`agents/codex/`, `commands/codex-bridge/` instead of `commands/codex/`, and
|
|
26
|
+
`~/.lyupro/.codex-bridge/lib/` for the runner and guards. Slash commands are now
|
|
27
|
+
`/codex-bridge:usage` and `/codex-bridge:env`, and hooks in `settings.json` use
|
|
28
|
+
`codex-bridge hook <name>` when the matching package version is available. Run
|
|
29
|
+
`codex-bridge update` to migrate an existing installation and retire the old layout.
|
|
30
|
+
- Runs started without an explicit `--slug` now derive a readable folder name from the required
|
|
31
|
+
order id instead of grouping unrelated work under the dispatcher name. Existing run folders
|
|
32
|
+
remain discoverable by their order id and task fingerprint.
|
|
33
|
+
- The README now leads with the package's guarantees, verification and update guidance, and
|
|
34
|
+
limitations before the command reference, and the repository now publishes a security policy
|
|
35
|
+
and private vulnerability-reporting route.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Guards invoked through a global-install symlink no longer exit successfully without running;
|
|
40
|
+
the direct-invocation check now compares resolved files, so a silent guard cannot allow every
|
|
41
|
+
operation while `doctor` reports it healthy. Hook registration also chooses the short command
|
|
42
|
+
form only when the command on `PATH` is this package's exact version, rather than merely present.
|
|
43
|
+
- Retiring the previous layout no longer removes the shared Claude Code `agents/` and `commands/`
|
|
44
|
+
directories. Seeded operator files are moved only after byte verification, and an interrupted
|
|
45
|
+
migration is retried by the next `update` even when the new files are already current.
|
|
46
|
+
- A continuation grant that loses its `--continue` flag is now refused before attachment instead
|
|
47
|
+
of replaying an older run's verdict as though new work ran. Attachment output also states
|
|
48
|
+
explicitly that no new work was started.
|
|
49
|
+
- The test suite now isolates both installation roots, preventing tests from writing fixtures or
|
|
50
|
+
seeded configuration into the operator's real `~/.lyupro/.codex-bridge/` directory. Migration
|
|
51
|
+
dry runs also name the hook event and matcher instead of printing `undefined`.
|
|
52
|
+
|
|
53
|
+
## [0.4.0] - 2026-08-09
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- Scope patterns are checked before Codex starts. A pattern that cannot match anything — an
|
|
58
|
+
absolute or drive-qualified path, backslash separators, a `..` segment, or a spelling no
|
|
59
|
+
repository file answers to — is refused with no run folder and no quota spent, for all three
|
|
60
|
+
dispatchers. The file list comes from `git ls-files --cached --others --exclude-standard`, so
|
|
61
|
+
ignored output does not count as repository content. A file the run is meant to create is
|
|
62
|
+
declared with `--scope-new`, accepted only by `codex-build` and recorded in `worker.json`.
|
|
63
|
+
- `codex-bridge permissions` reports, adds, and removes the optional shell permission rules for
|
|
64
|
+
both CLI names and the clone entry point. The exact allow and deny strings merge into
|
|
65
|
+
`settings.json`, preserve foreign rules, and are also removed by `uninstall`; `doctor` reports
|
|
66
|
+
an absent or partial set as an optional warning.
|
|
67
|
+
|
|
68
|
+
### Changed
|
|
69
|
+
|
|
70
|
+
- `codex-bridge sweep` is now `codex-bridge unlock`. The default scope is the current repository;
|
|
71
|
+
a bare project name remains addressable, and `--all` is now required for the whole store. The
|
|
72
|
+
report shows age, silence, and `alive` / `dead` / `foreign` / `unverified` identity verdicts;
|
|
73
|
+
confirmed-alive records are refused with `codex-bridge stop <run>` instead of being closed.
|
|
74
|
+
|
|
75
|
+
### Fixed
|
|
76
|
+
|
|
77
|
+
- Run records now verify that a pid still belongs to the process that started the run, using the
|
|
78
|
+
process start time and a fresh heartbeat before falling back to an OS probe. A reused or
|
|
79
|
+
unverified pid is never signalled by `stop`; `codex-bridge unlock` remains the non-killing way to
|
|
80
|
+
close a record whose process identity cannot be confirmed.
|
|
81
|
+
- JSON files written by PowerShell with a UTF-8 BOM are now read consistently by the CLI and
|
|
82
|
+
installed hooks, and one command run creates at most one settings backup without deleting older
|
|
83
|
+
backups.
|
|
84
|
+
- A run a guard refused before Codex ever started no longer costs the next attempt a round trip. Such a folder now closes as `aborted_pre_start` instead of the same `failed` a twenty-minute paid run gets, and the chain gate, the continuation limit and the tree baseline all skip it: the same order id starts as a first run, silently, with no `continue:` grant. Reported from a parallel `codex-build` launch — the second run was correctly refused with "two writing runs in one tree are prohibited", and its one-millisecond folder then made the retry demand a grant for work that never happened. Folders written before this release are recognised by what `meta.json` records — `exit`, `session_id`, `events_bytes`, `stderr_bytes`, `tokens_reported` — rather than by whether `events.jsonl` is still on disk, because retention deletes that file from paid runs too. The folder itself is kept: it is the audit trail of who was refused and why.
|
|
85
|
+
- `chainBaseline()` takes the first run of the chain that actually started, so a pre-start folder sorting first no longer leaves the next pass without the `state-before.txt` its work is measured against.
|
|
86
|
+
- `uninstall --dry-run` names each hook by its own matcher. Three of the four hooks are PreToolUse,
|
|
87
|
+
and a lookup by event alone reported `Agent|Task` for the worktree lock and the prune guard too —
|
|
88
|
+
in the one line an operator reads to see what a removal is about to touch. The removal itself was
|
|
89
|
+
never affected: hooks are found by command, not by matcher.
|
|
90
|
+
- The order gate's refusal now shows the line it found. A caller who wrote
|
|
91
|
+
`scope (you may create/modify ONLY these):` was told scope was missing, re-read their own prompt,
|
|
92
|
+
saw it written there, and had nothing to act on; an autonomous run rewrites the same text forever.
|
|
93
|
+
The refusal names the candidate line under the input it belongs to and says why it was not
|
|
94
|
+
accepted — a qualifier between the label and the colon, a value that is still a placeholder, or no
|
|
95
|
+
separator at all — and stays silent when no candidate exists, because then "the input is missing"
|
|
96
|
+
is the truth. The parser accepts exactly what it accepted before: deriving scope from prose would
|
|
97
|
+
give away the only guarantee that a run cannot touch files nobody listed.
|
|
98
|
+
|
|
7
99
|
## [0.3.1] - 2026-08-07
|
|
8
100
|
|
|
9
101
|
### Added
|
package/README.md
CHANGED
|
@@ -1,106 +1,110 @@
|
|
|
1
1
|
# codex-bridge
|
|
2
2
|
|
|
3
|
-
Delegate implementation, reconnaissance and
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
**Delegate implementation, reconnaissance and review from Claude Code to Codex — five host guards, zero runtime dependencies, zero build steps.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@lyupro/codex-bridge)
|
|
6
|
+
[](https://www.npmjs.com/package/@lyupro/codex-bridge)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
## What it gives you
|
|
10
|
+
|
|
11
|
+
- **Runs survive the caller.** A detached worker keeps the run and its artifacts alive after the dispatcher exits.
|
|
12
|
+
- **Invalid work is rejected before quota is spent.** Scope, order, retry, branch, and continuation checks run before a run folder is created.
|
|
13
|
+
- **Verdicts come from artifacts.** CLI events, git state, and the declared report decide the result, not model claims about itself.
|
|
14
|
+
- **Identical retries attach safely.** Repeating the same command joins the active run instead of starting another one.
|
|
15
|
+
- **5 host guards enforce the boundary.** `reply-guard`, `order-gate`, `worktree-lock`, `prune-guard`, and `stop-guard` cover replies, orders, edits, deletion, and stopping.
|
|
16
|
+
- **Installation is identifiable and reversible.** Writes to `settings.json` are merged and backed up; uninstall removes recorded package files only.
|
|
17
|
+
- **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
|
|
18
|
+
- **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
|
|
19
|
+
|
|
20
|
+
The current suite contains **603 automated tests: 602 passing and 1 skipped**.
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- Node.js >= 24
|
|
25
|
+
- An authenticated [Codex CLI](https://developers.openai.com/codex/cli): `codex --version` and `codex login`
|
|
26
|
+
- Claude Code, which hosts the agents, commands, and guards
|
|
6
27
|
|
|
7
28
|
## Install
|
|
8
29
|
|
|
30
|
+
For a one-off user installation, global access, or a clone entry point:
|
|
31
|
+
|
|
9
32
|
```bash
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
codexb install # short name, same command
|
|
14
|
-
node bin/codex-bridge.mjs install # from a clone, same thing
|
|
15
|
-
node bin/codex-bridge.mjs install --scope project # into <repo>/.claude
|
|
16
|
-
node bin/codex-bridge.mjs update # bring an installation up to the current version
|
|
17
|
-
node bin/codex-bridge.mjs doctor # what is installed, where it points, is codex alive
|
|
18
|
-
node bin/codex-bridge.mjs uninstall # removes exactly what was installed
|
|
19
|
-
node bin/codex-bridge.mjs stop <run> # close a hanging run by hand, without hunting for a pid
|
|
20
|
-
node bin/codex-bridge.mjs read <run> # read a run: its events rendered as text, on demand
|
|
21
|
-
node bin/codex-bridge.mjs projects # what the run store holds: projects, runs, weight, tokens
|
|
22
|
-
node bin/codex-bridge.mjs prune <project> # reclaim disk space, transport only unless told otherwise
|
|
23
|
-
node bin/codex-bridge.mjs sweep [<project>] # close running records whose runner is gone
|
|
33
|
+
npm i -g @lyupro/codex-bridge # puts both command names on PATH
|
|
34
|
+
codex-bridge install # into ~/.claude and ~/.lyupro/.codex-bridge/
|
|
35
|
+
codexb install # the short name, same command
|
|
24
36
|
```
|
|
25
37
|
|
|
26
|
-
|
|
27
|
-
and `codexb update` copy host files from whichever copy launched the command, so `codexb update`
|
|
28
|
-
from `PATH` can silently revert a host that `npm run dev:install` from the clone just updated.
|
|
29
|
-
|
|
30
|
-
`install` copies the package into the host's `agents/codex/`, its slash commands into
|
|
31
|
-
`commands/codex/`, and registers its four hooks in `settings.json` — **merged**, so hooks that
|
|
32
|
-
are already there survive, and the file is backed up before every write. Run it twice and the
|
|
33
|
-
second run does nothing. `--dry-run` prints the plan and touches nothing.
|
|
34
|
-
|
|
35
|
-
`update` moves an existing installation to the current version. It knows the sha256 of every file
|
|
36
|
-
as it was installed, so it can tell a file you edited on the host from one left over by an older
|
|
37
|
-
version: outdated files are refreshed silently, files you changed by hand stop the run and are
|
|
38
|
-
named, and `--force` is what overwrites them. A file the package no longer ships is removed only
|
|
39
|
-
if you never touched it.
|
|
40
|
-
|
|
41
|
-
`uninstall` removes only what the install recorded: a file you put in `agents/codex/` yourself
|
|
42
|
-
stays, and `codex-runs/` is never touched — those are your run artifacts, not the package.
|
|
43
|
-
|
|
44
|
-
`stop` takes a run folder — a full path or a bare name from the current project's runs directory —
|
|
45
|
-
kills that run's whole process tree and closes the folder the way an abandoned run is closed: a
|
|
46
|
-
`FAIL` verdict naming what the run left in the worktree. A run that already has a verdict is left
|
|
47
|
-
alone. Runs end on their own time budget now, so this is for the case where something wedged
|
|
48
|
-
anyway.
|
|
49
|
-
|
|
50
|
-
Run artifacts live in `codex-runs/<project>/`, one folder per repository, marked with a
|
|
51
|
-
`.project.json` holding that repository's path. Two checkouts that share a directory name — two
|
|
52
|
-
different `api` — therefore get `api` and `api-2` instead of one mixed history. `doctor` prints
|
|
53
|
-
which folder the current repository writes to.
|
|
54
|
-
|
|
55
|
-
`projects` reads that store back: without an argument, one line per project — runs, weight, tokens
|
|
56
|
-
spent, how many are running right now; with a project name, one line per run. `prune` is the only
|
|
57
|
-
command in the package that destroys data, and it is built to be hard to fire by accident: without
|
|
58
|
-
`-f` it prints a plan and deletes nothing, `-f` still asks before each step, and with no terminal
|
|
59
|
-
it refuses outright, because deletion is an operator action and there is no flag around it.
|
|
60
|
-
By default it removes transport only — event streams and stderr, the megabytes — and leaves
|
|
61
|
-
accounting, reports and worktree snapshots alone; `--purge` is what takes a folder whole.
|
|
62
|
-
The same transport is dropped automatically from runs older than 30 days when a new run starts,
|
|
63
|
-
which the reply says out loud; `retention` in `run-config.json` changes the age or turns it off.
|
|
64
|
-
|
|
65
|
-
`sweep` is housekeeping for records left by a dead runner. With no project it scans the whole
|
|
66
|
-
store; with one project it limits the scan to that folder. It uses the same dead-pid decision as
|
|
67
|
-
start-of-run cleanup, never closes a live pid even when its heartbeat is stale, and never deletes
|
|
68
|
-
run artifacts. A stale live-pid record is named with the exact `codex-bridge stop <run>` command
|
|
69
|
-
needed to close it.
|
|
38
|
+
Two other entry points reach the same command: `npx @lyupro/codex-bridge install` for a one-off run without a global install, and `node bin/codex-bridge.mjs install` from a clone. Every command below works through all three.
|
|
70
39
|
|
|
71
|
-
|
|
40
|
+
Use `--scope project` to install the Claude Code-facing files under `<repo>/.claude`; the default user scope uses `~/.claude`.
|
|
72
41
|
|
|
73
|
-
|
|
74
|
-
(write access limited to a declared scope), and an independent review by a second model.
|
|
75
|
-
- **A runner that survives a dropped call.** The launcher hands the run to a detached
|
|
76
|
-
worker, so a killed dispatcher no longer leaves the work unexplainable: the run folder is
|
|
77
|
-
closed with artifacts either way.
|
|
78
|
-
- **A verdict that checks the report against the worktree.** The agent's own account of what
|
|
79
|
-
it changed is compared with what git says actually changed, and a mismatch is a failed run
|
|
80
|
-
rather than a nicer-sounding summary.
|
|
81
|
-
- **Four hooks that hold the rules the prompts only ask for**: a reply is refused when it does not
|
|
82
|
-
match the recorded state of the run, a dispatcher call is refused when it carries no order id, a
|
|
83
|
-
file edit is refused inside a repository a live run holds, and deletion of the run store is
|
|
84
|
-
refused when an agent is the one asking.
|
|
42
|
+
Installation has two roots:
|
|
85
43
|
|
|
86
|
-
|
|
44
|
+
- `~/.lyupro/.codex-bridge/` contains package runtime files, guards, configuration, conventions, and the installation record.
|
|
45
|
+
- The selected Claude Code root (`~/.claude/` or `<repo>/.claude/`) contains the three agents, two slash commands, merged hook registrations in `settings.json`, and run artifacts under `codex-runs/`.
|
|
46
|
+
|
|
47
|
+
Before every `settings.json` write, the existing file is backed up. Existing settings and foreign hooks are preserved. `--dry-run` prints the plan without writing.
|
|
48
|
+
|
|
49
|
+
If a global install and a clone coexist, you have two package copies. `codex-bridge update`, `codexb update`, and the clone entry point copy files from whichever copy launched the command; using the wrong one can replace a newer host installation with older files.
|
|
50
|
+
|
|
51
|
+
`codex-runs/` contains operator data. Uninstall never removes it.
|
|
52
|
+
|
|
53
|
+
## Verify
|
|
87
54
|
|
|
88
|
-
|
|
89
|
-
-
|
|
90
|
-
|
|
55
|
+
```bash
|
|
56
|
+
codex-bridge doctor
|
|
57
|
+
```
|
|
91
58
|
|
|
92
|
-
|
|
93
|
-
that is deliberate — installing from a clone stays one command, with no build step.
|
|
59
|
+
`doctor` reports the selected host, package source, installed files and guards, Codex availability, retention policy, permissions, live runs, and the current repository's run folder.
|
|
94
60
|
|
|
95
|
-
##
|
|
61
|
+
## Update
|
|
62
|
+
|
|
63
|
+
Update the global package, then refresh the selected host installation:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm i -g @lyupro/codex-bridge
|
|
67
|
+
codex-bridge update
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`update` refreshes unchanged package files, refuses to overwrite operator edits unless `--force` is supplied, and removes obsolete files only when their installed fingerprint is unchanged.
|
|
71
|
+
|
|
72
|
+
## Limitations
|
|
73
|
+
|
|
74
|
+
- **Runs are not isolated from one another.** They share one worktree; guards prevent concurrent writers instead of creating sandboxes.
|
|
75
|
+
- **A build run has a 25-minute deadline.** Work and artifacts survive a timeout, but the run may need an explicit continuation.
|
|
76
|
+
- **A dispatcher verdict is not the work outcome.** Judge completion from the worktree and tests as well as the recorded verdict.
|
|
77
|
+
|
|
78
|
+
Transport files from runs at least **30 days** old are pruned automatically when a new run starts. Configuration can change or disable that retention policy; reports, accounting, and worktree snapshots are retained.
|
|
79
|
+
|
|
80
|
+
## Command reference
|
|
81
|
+
|
|
82
|
+
| Command | Purpose |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `install [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Install into a Claude Code host. |
|
|
85
|
+
| `update [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Refresh a recorded installation. |
|
|
86
|
+
| `permissions [add\|remove] [--scope user\|project] [--host <path>]` | Inspect or manage optional shell rules. |
|
|
87
|
+
| `uninstall [--scope user\|project] [--host <path>] [--dry-run]` | Remove recorded package files while preserving run artifacts. |
|
|
88
|
+
| `doctor [--scope user\|project] [--host <path>]` | Diagnose the selected host and Codex connection. |
|
|
89
|
+
| `projects [<name>] [--json]` | List projects or runs in the run store. |
|
|
90
|
+
| `prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]` | Plan or perform operator-confirmed cleanup. |
|
|
91
|
+
| `unlock [<project>\|--all]` | Close records whose runner is gone. |
|
|
92
|
+
| `read <run>` | Render a run's structured event stream. |
|
|
93
|
+
| `stop <run>` | Stop a run and record a `FAIL` verdict. |
|
|
94
|
+
|
|
95
|
+
Both `codex-bridge` and `codexb` invoke the same command. Run `codex-bridge --help` for exact forms.
|
|
96
|
+
|
|
97
|
+
### Development
|
|
96
98
|
|
|
97
99
|
```bash
|
|
98
100
|
git clone https://github.com/lyupro/codex-bridge.git
|
|
99
101
|
cd codex-bridge
|
|
100
|
-
git config core.hooksPath .githooks
|
|
101
|
-
npm test
|
|
102
|
+
git config core.hooksPath .githooks
|
|
103
|
+
npm test
|
|
102
104
|
```
|
|
103
105
|
|
|
106
|
+
The Git hook enforces the 400-line source-file limit.
|
|
107
|
+
|
|
104
108
|
## License
|
|
105
109
|
|
|
106
|
-
MIT
|
|
110
|
+
[MIT](LICENSE)
|
package/bin/codex-bridge.mjs
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** Dispatches codex-bridge CLI arguments to focused command modules. */
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
3
|
import { diagnose, renderDoctor } from '../cli/doctor.mjs';
|
|
4
|
+
import { isInvokedDirectly } from '../cli/invoked-directly.mjs';
|
|
5
|
+
import { hook } from '../cli/hook.mjs';
|
|
6
6
|
import { resolveHost } from '../cli/hosts.mjs';
|
|
7
7
|
import { install } from '../cli/install.mjs';
|
|
8
8
|
import { projects } from '../cli/projects.mjs';
|
|
9
9
|
import { read } from '../cli/read.mjs';
|
|
10
10
|
import { packageInfo } from '../cli/manifest.mjs';
|
|
11
|
+
import { permissions } from '../cli/permissions.mjs';
|
|
11
12
|
import { prune } from '../cli/prune.mjs';
|
|
12
13
|
import { stop } from '../cli/stop.mjs';
|
|
13
|
-
import {
|
|
14
|
+
import { unlock } from '../cli/unlock.mjs';
|
|
14
15
|
import { uninstall } from '../cli/uninstall.mjs';
|
|
15
16
|
import { update } from '../cli/update.mjs';
|
|
16
17
|
|
|
@@ -19,14 +20,16 @@ export const HELP = `codex-bridge — Claude Code dispatchers for Codex
|
|
|
19
20
|
Usage:
|
|
20
21
|
codex-bridge install [--scope user|project] [--host <path>] [--dry-run] [--force]
|
|
21
22
|
codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]
|
|
23
|
+
codex-bridge permissions [add|remove] [--scope user|project] [--host <path>]
|
|
22
24
|
codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
|
|
23
25
|
codex-bridge doctor [--scope user|project] [--host <path>]
|
|
24
26
|
codex-bridge projects [<name>] [--json]
|
|
25
27
|
codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]
|
|
26
28
|
codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]
|
|
27
|
-
codex-bridge
|
|
29
|
+
codex-bridge unlock [<project>|--all]
|
|
28
30
|
codex-bridge read <run>
|
|
29
31
|
codex-bridge stop <run>
|
|
32
|
+
codex-bridge hook <name>
|
|
30
33
|
codexb <same command forms as codex-bridge>
|
|
31
34
|
codex-bridge --help
|
|
32
35
|
codex-bridge --version
|
|
@@ -34,13 +37,19 @@ Usage:
|
|
|
34
37
|
Commands:
|
|
35
38
|
install Install codex-bridge into the selected Claude Code host
|
|
36
39
|
update Update a recorded codex-bridge installation
|
|
40
|
+
permissions Show, add, or remove optional shell permission rules
|
|
37
41
|
uninstall Remove installed files while preserving run artifacts
|
|
38
42
|
doctor Diagnose the selected Claude Code host
|
|
39
43
|
projects List projects or runs from the run store
|
|
40
44
|
prune Remove archived transport, or purge selected run folders
|
|
41
|
-
|
|
45
|
+
unlock Close running records whose runner is gone
|
|
42
46
|
read Render a run's structured event stream
|
|
43
|
-
stop Stop a running Codex run and record FAIL
|
|
47
|
+
stop Stop a running Codex run and record FAIL
|
|
48
|
+
|
|
49
|
+
Hook dispatch:
|
|
50
|
+
codex-bridge hook <name> Dispatch a registered guard with stdin unchanged`;
|
|
51
|
+
|
|
52
|
+
const HOOK_COMMAND = 'hook';
|
|
44
53
|
|
|
45
54
|
function commandOptions(command, argv) {
|
|
46
55
|
const options = {};
|
|
@@ -90,6 +99,7 @@ export async function main(argv, io = console) {
|
|
|
90
99
|
io.log(result.output);
|
|
91
100
|
return result.exitCode;
|
|
92
101
|
}
|
|
102
|
+
if (command === HOOK_COMMAND) return hook(rest, io);
|
|
93
103
|
if (command === 'read') {
|
|
94
104
|
if (rest.length !== 1) {
|
|
95
105
|
io.error('codex-bridge read requires exactly one run folder (full path or bare name).');
|
|
@@ -110,7 +120,27 @@ export async function main(argv, io = console) {
|
|
|
110
120
|
return result.exitCode;
|
|
111
121
|
}
|
|
112
122
|
if (command === 'sweep') {
|
|
113
|
-
|
|
123
|
+
io.error('codex-bridge sweep was renamed to codex-bridge unlock; use the new command.');
|
|
124
|
+
return 2;
|
|
125
|
+
}
|
|
126
|
+
if (command === 'unlock') {
|
|
127
|
+
const result = unlock(rest);
|
|
128
|
+
io.log(result.output);
|
|
129
|
+
return result.exitCode;
|
|
130
|
+
}
|
|
131
|
+
if (command === 'permissions') {
|
|
132
|
+
let action;
|
|
133
|
+
let optionArgs = rest;
|
|
134
|
+
if (rest[0] && !rest[0].startsWith('-')) {
|
|
135
|
+
action = rest[0];
|
|
136
|
+
optionArgs = rest.slice(1);
|
|
137
|
+
}
|
|
138
|
+
if (action && !['add', 'remove'].includes(action)) {
|
|
139
|
+
throw new Error(`unknown permissions action "${action}"`);
|
|
140
|
+
}
|
|
141
|
+
const options = commandOptions(command, optionArgs);
|
|
142
|
+
const host = resolveHost(options);
|
|
143
|
+
const result = await permissions({ host, action });
|
|
114
144
|
io.log(result.output);
|
|
115
145
|
return result.exitCode;
|
|
116
146
|
}
|
|
@@ -130,10 +160,7 @@ export async function main(argv, io = console) {
|
|
|
130
160
|
return 2;
|
|
131
161
|
}
|
|
132
162
|
|
|
133
|
-
|
|
134
|
-
process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
135
|
-
|
|
136
|
-
if (invokedDirectly) {
|
|
163
|
+
if (isInvokedDirectly(process.argv[1], import.meta.url)) {
|
|
137
164
|
main(process.argv.slice(2))
|
|
138
165
|
.then((exitCode) => { process.exitCode = exitCode; })
|
|
139
166
|
.catch((err) => {
|
package/cli/copy.mjs
CHANGED
|
@@ -4,17 +4,18 @@ import path from 'node:path';
|
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { replacePlaceholders } from './manifest.mjs';
|
|
6
6
|
|
|
7
|
-
export async function plannedContent(item,
|
|
7
|
+
export async function plannedContent(item, installationRoot) {
|
|
8
|
+
const replacementRoot = item.installationRoot ?? installationRoot;
|
|
8
9
|
const source = await fs.readFile(item.source);
|
|
9
10
|
if (item.processing === 'copy') return source;
|
|
10
11
|
if (item.processing === 'placeholders') {
|
|
11
|
-
return Buffer.from(replacePlaceholders(source.toString('utf8'),
|
|
12
|
+
return Buffer.from(replacePlaceholders(source.toString('utf8'), replacementRoot));
|
|
12
13
|
}
|
|
13
14
|
throw new Error(`unknown install processing "${item.processing}"`);
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export async function targetMatches(item,
|
|
17
|
-
const expected = await plannedContent(item,
|
|
17
|
+
export async function targetMatches(item, installationRoot) {
|
|
18
|
+
const expected = await plannedContent(item, installationRoot);
|
|
18
19
|
try {
|
|
19
20
|
const actual = await fs.readFile(item.target);
|
|
20
21
|
return actual.equals(expected);
|
|
@@ -24,8 +25,8 @@ export async function targetMatches(item, agentsDir) {
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
export async function copyPlannedFile(item,
|
|
28
|
-
const content = await plannedContent(item,
|
|
28
|
+
export async function copyPlannedFile(item, installationRoot) {
|
|
29
|
+
const content = await plannedContent(item, installationRoot);
|
|
29
30
|
await fs.mkdir(path.dirname(item.target), { recursive: true });
|
|
30
31
|
const temporary = path.join(path.dirname(item.target), `.${path.basename(item.target)}.${randomUUID()}.tmp`);
|
|
31
32
|
try {
|
package/cli/doctor.mjs
CHANGED
|
@@ -9,11 +9,15 @@ import {
|
|
|
9
9
|
readInstallRecord,
|
|
10
10
|
packageInfo,
|
|
11
11
|
} from './manifest.mjs';
|
|
12
|
+
import { recordTarget } from './install-record.mjs';
|
|
13
|
+
import { inspectPermissions } from './permissions.mjs';
|
|
12
14
|
import { commandFor, inspectHook } from './settings-merge.mjs';
|
|
13
15
|
import { readRulesRegistry } from './rules-owners.mjs';
|
|
14
16
|
import { readRunConfig, retentionNotice } from '../src/run-config.mjs';
|
|
15
17
|
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
16
18
|
import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
|
|
19
|
+
import { allLiveRuns } from '../src/hooks/live-runs.mjs';
|
|
20
|
+
import { STOP_COMMAND_TEMPLATE } from '../src/stop-contract.mjs';
|
|
17
21
|
|
|
18
22
|
const WARNING = '\u001b[33m';
|
|
19
23
|
const RESET = '\u001b[0m';
|
|
@@ -41,7 +45,7 @@ function check(key, status, value) {
|
|
|
41
45
|
|
|
42
46
|
function retentionCheck(host) {
|
|
43
47
|
try {
|
|
44
|
-
const notice = retentionNotice(readRunConfig(
|
|
48
|
+
const notice = retentionNotice(readRunConfig(host.brandConfigPath));
|
|
45
49
|
return check('retention', notice.enabled ? 'warn' : 'ok', notice.text);
|
|
46
50
|
} catch (err) {
|
|
47
51
|
return check('retention', 'fail', `invalid configuration: ${err.message}`);
|
|
@@ -49,7 +53,7 @@ function retentionCheck(host) {
|
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
async function conventionsCheck(host) {
|
|
52
|
-
const file =
|
|
56
|
+
const file = host.brandConventionsPath;
|
|
53
57
|
let content;
|
|
54
58
|
try {
|
|
55
59
|
content = await fs.readFile(file, 'utf8');
|
|
@@ -101,6 +105,18 @@ function projectRunsCheck() {
|
|
|
101
105
|
return check('projectRuns', 'ok', `${path.resolve(resolved.dir)} (${note})`);
|
|
102
106
|
}
|
|
103
107
|
|
|
108
|
+
function liveRunsCheck() {
|
|
109
|
+
let count;
|
|
110
|
+
try {
|
|
111
|
+
count = allLiveRuns(runsRoot(), { requireConfirmedIdentity: true }).length;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return check('liveRuns', 'warn', `working-run count unavailable: ${err.message}`);
|
|
114
|
+
}
|
|
115
|
+
if (!count) return check('liveRuns', 'ok', '0 runs working right now');
|
|
116
|
+
const noun = count === 1 ? 'run' : 'runs';
|
|
117
|
+
return check('liveRuns', 'warn', `${count} ${noun} working right now; stop with ${STOP_COMMAND_TEMPLATE}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
104
120
|
export function probeCodex() {
|
|
105
121
|
const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex';
|
|
106
122
|
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'];
|
|
@@ -111,7 +127,25 @@ export function probeCodex() {
|
|
|
111
127
|
return { available: true, value: (result.stdout || result.stderr).trim() };
|
|
112
128
|
}
|
|
113
129
|
|
|
114
|
-
|
|
130
|
+
export function probeCodexBridge() {
|
|
131
|
+
const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex-bridge';
|
|
132
|
+
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex-bridge --version'] : ['--version'];
|
|
133
|
+
const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true });
|
|
134
|
+
if (result.error || result.status !== 0) {
|
|
135
|
+
return { available: false, value: (result.stderr || result.error?.message || 'not found').trim() };
|
|
136
|
+
}
|
|
137
|
+
return { available: true, value: (result.stdout || result.stderr).trim() };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function hookVersion(command, record, bridgeProbe) {
|
|
141
|
+
if (command.startsWith('codex-bridge hook ')) {
|
|
142
|
+
const result = bridgeProbe();
|
|
143
|
+
return result.available ? `global command ${result.value}` : `global command unavailable (${result.value})`;
|
|
144
|
+
}
|
|
145
|
+
return `installed copy ${record.name}@${record.version}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function hookChecks(host, record, bridgeProbe) {
|
|
115
149
|
return Promise.all(HOOK_DEFINITIONS.map(async (definition) => {
|
|
116
150
|
const key = `hook:${definition.event}`;
|
|
117
151
|
if (!record) return check(key, 'warn', 'cannot check before installation');
|
|
@@ -124,17 +158,27 @@ async function hookChecks(host, record) {
|
|
|
124
158
|
if (!recorded) {
|
|
125
159
|
return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
|
|
126
160
|
}
|
|
127
|
-
const expected =
|
|
161
|
+
const expected = recordTarget(host, recorded);
|
|
162
|
+
const fullCommand = commandFor(expected);
|
|
163
|
+
const shortCommand = `codex-bridge hook ${definition.name}`;
|
|
128
164
|
try {
|
|
129
165
|
const state = await inspectHook(host.settingsPath, {
|
|
130
166
|
event: definition.event,
|
|
131
167
|
matcher: definition.matcher,
|
|
132
|
-
command:
|
|
168
|
+
command: recorded.command || fullCommand,
|
|
169
|
+
alternateCommands: [fullCommand, shortCommand],
|
|
133
170
|
});
|
|
134
171
|
if (state.present) {
|
|
135
|
-
|
|
172
|
+
const command = state.matchedCommand || recorded.command || fullCommand;
|
|
173
|
+
const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
|
|
174
|
+
const reason = form === 'short'
|
|
175
|
+
? 'PATH command uses the globally installed package'
|
|
176
|
+
: 'full path uses the copy placed by the last install';
|
|
177
|
+
return check(key, 'ok', `${definition.event} matcher ${definition.matcher} -> ${expected} (${form} command; ${reason}; ${hookVersion(command, record, bridgeProbe)})`);
|
|
136
178
|
}
|
|
137
|
-
|
|
179
|
+
const command = recorded.command || fullCommand;
|
|
180
|
+
const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
|
|
181
|
+
return check(key, 'warn', `${definition.event} matcher ${definition.matcher} does not point to the installed ${definition.file} (${form} command; ${hookVersion(command, record, bridgeProbe)})`);
|
|
138
182
|
} catch (err) {
|
|
139
183
|
const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
|
|
140
184
|
return check(key, 'warn', `${definition.event} hook: ${value}`);
|
|
@@ -162,7 +206,22 @@ async function rulesCheck(host, record) {
|
|
|
162
206
|
: check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
|
|
163
207
|
}
|
|
164
208
|
|
|
165
|
-
|
|
209
|
+
async function permissionsCheck(host) {
|
|
210
|
+
try {
|
|
211
|
+
const status = await inspectPermissions(host.settingsPath);
|
|
212
|
+
// Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
|
|
213
|
+
// doctor does not turn a healthy installation red merely because hardening was not requested.
|
|
214
|
+
// The ask count is part of the line because a full set shadowed by `ask` reads as working and
|
|
215
|
+
// is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
|
|
216
|
+
const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
|
|
217
|
+
return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
|
|
218
|
+
`${status.state} (${status.present}/${status.total} own strings in allow/deny${shadow})`);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function diagnose({ host, codexProbe = probeCodex, bridgeProbe = probeCodexBridge, currentPackage } = {}) {
|
|
166
225
|
const checks = [sourceCheck()];
|
|
167
226
|
const hostExists = await exists(host.root);
|
|
168
227
|
checks.push(check('host', hostExists ? 'ok' : 'warn', `${host.root} (${host.scope}, ${hostExists ? 'exists' : 'absent'})`));
|
|
@@ -188,8 +247,8 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
188
247
|
|
|
189
248
|
const missingFiles = [];
|
|
190
249
|
if (record) {
|
|
191
|
-
for (const
|
|
192
|
-
if (!(await isFile(
|
|
250
|
+
for (const file of record.files) {
|
|
251
|
+
if (!(await isFile(recordTarget(host, file)))) missingFiles.push(`${file.root}/${file.path}`);
|
|
193
252
|
}
|
|
194
253
|
}
|
|
195
254
|
checks.push(check(
|
|
@@ -199,7 +258,8 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
199
258
|
));
|
|
200
259
|
const rules = await rulesCheck(host, record);
|
|
201
260
|
checks.push(rules);
|
|
202
|
-
checks.push(
|
|
261
|
+
checks.push(await permissionsCheck(host));
|
|
262
|
+
checks.push(...await hookChecks(host, record, bridgeProbe));
|
|
203
263
|
const retention = retentionCheck(host);
|
|
204
264
|
checks.push(retention);
|
|
205
265
|
const conventions = await conventionsCheck(host);
|
|
@@ -211,6 +271,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
211
271
|
checks.push(check('node', nodeMajor >= 24 ? 'ok' : 'fail', `${process.versions.node} (requires >=24)`));
|
|
212
272
|
checks.push(check('runsRoot', 'ok', path.resolve(runsRoot())));
|
|
213
273
|
const projectRuns = projectRunsCheck();
|
|
274
|
+
checks.push(liveRunsCheck());
|
|
214
275
|
checks.push(projectRuns);
|
|
215
276
|
|
|
216
277
|
return {
|
|
@@ -225,7 +286,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
225
286
|
export function renderDoctor(result) {
|
|
226
287
|
return result.checks.map(({ key, status, value }) => {
|
|
227
288
|
const rendered = `[${status}] ${key}: ${value}`;
|
|
228
|
-
return ['retention', 'conventions'].includes(key) && status === 'warn'
|
|
289
|
+
return ['retention', 'conventions', 'permissions', 'liveRuns'].includes(key) && status === 'warn'
|
|
229
290
|
? `${WARNING}${rendered}${RESET}`
|
|
230
291
|
: rendered;
|
|
231
292
|
}).join('\n');
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Describes where each guard is installed and how its registration will be spelled. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { HOOK_DEFINITIONS, installedHookPath } from './manifest.mjs';
|
|
4
|
+
import { commandFor, hookRegistration } from './settings-merge.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* install.mjs and update.mjs each carried their own copy of this, and the copies had already
|
|
8
|
+
* drifted: one returned `root: 'brand'` on every entry and the other did not. Two copies of the
|
|
9
|
+
* list that decides what goes into the operator's settings.json is the same shape of defect
|
|
10
|
+
* Plan_19 had to reconcile between the installer and the hooks.
|
|
11
|
+
*
|
|
12
|
+
* `packageVersion` is what keeps the registration honest: the short command form is only chosen
|
|
13
|
+
* when the codex-bridge on PATH reports this exact version, because on 2026-08-11 an install from
|
|
14
|
+
* the clone wrote `codex-bridge hook <name>` against a global 0.4.0 that has no such subcommand
|
|
15
|
+
* and took every guard on the machine down with it.
|
|
16
|
+
*/
|
|
17
|
+
export function hookTargets(host, env = process.env, packageVersion = null) {
|
|
18
|
+
return HOOK_DEFINITIONS.map((definition) => {
|
|
19
|
+
const target = installedHookPath(host, definition);
|
|
20
|
+
const registration = hookRegistration(definition.name, target, env, packageVersion);
|
|
21
|
+
const fallback = commandFor(target);
|
|
22
|
+
const alternate = registration.command === fallback
|
|
23
|
+
? `codex-bridge hook ${definition.name}`
|
|
24
|
+
: fallback;
|
|
25
|
+
return {
|
|
26
|
+
definition,
|
|
27
|
+
target,
|
|
28
|
+
root: 'brand',
|
|
29
|
+
relative: path.relative(host.brandRoot, target).split(path.sep).join('/'),
|
|
30
|
+
registration,
|
|
31
|
+
spec: {
|
|
32
|
+
event: definition.event,
|
|
33
|
+
matcher: definition.matcher,
|
|
34
|
+
command: registration.command,
|
|
35
|
+
alternateCommands: [alternate],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|