@lyupro/codex-bridge 0.3.1 → 0.4.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 +48 -0
- package/README.md +19 -6
- package/bin/codex-bridge.mjs +27 -4
- package/cli/doctor.mjs +18 -1
- package/cli/install.mjs +8 -2
- package/cli/manifest.mjs +20 -9
- package/cli/permissions.mjs +179 -0
- package/cli/prune-args.mjs +1 -1
- package/cli/rules-owners.mjs +6 -8
- package/cli/runs-inventory.mjs +2 -1
- package/cli/settings-merge.mjs +93 -45
- package/cli/stop.mjs +33 -20
- package/cli/uninstall.mjs +23 -10
- package/cli/unlock.mjs +239 -0
- package/package.json +1 -1
- package/scripts/check-file-size.mjs +8 -10
- package/src/agents/codex-build.md +2 -0
- package/src/agents/codex-review.md +2 -0
- package/src/agents/codex-scout.md +2 -0
- package/src/heartbeat.mjs +1 -1
- package/src/hooks/live-runs.mjs +15 -14
- package/src/hooks/order-gate.mjs +7 -2
- package/src/hooks/reply-guard.mjs +4 -3
- 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 +16 -2
- package/src/runner/attach.mjs +23 -17
- package/src/runner/codex-cmd.mjs +1 -1
- package/src/runner/launcher.mjs +40 -16
- 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/write-meta.mjs +8 -1
- package/cli/sweep.mjs +0 -144
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,54 @@ 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.4.0] - 2026-08-09
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Scope patterns are checked before Codex starts. A pattern that cannot match anything — an
|
|
14
|
+
absolute or drive-qualified path, backslash separators, a `..` segment, or a spelling no
|
|
15
|
+
repository file answers to — is refused with no run folder and no quota spent, for all three
|
|
16
|
+
dispatchers. The file list comes from `git ls-files --cached --others --exclude-standard`, so
|
|
17
|
+
ignored output does not count as repository content. A file the run is meant to create is
|
|
18
|
+
declared with `--scope-new`, accepted only by `codex-build` and recorded in `worker.json`.
|
|
19
|
+
- `codex-bridge permissions` reports, adds, and removes the optional shell permission rules for
|
|
20
|
+
both CLI names and the clone entry point. The exact allow and deny strings merge into
|
|
21
|
+
`settings.json`, preserve foreign rules, and are also removed by `uninstall`; `doctor` reports
|
|
22
|
+
an absent or partial set as an optional warning.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- `codex-bridge sweep` is now `codex-bridge unlock`. The default scope is the current repository;
|
|
27
|
+
a bare project name remains addressable, and `--all` is now required for the whole store. The
|
|
28
|
+
report shows age, silence, and `alive` / `dead` / `foreign` / `unverified` identity verdicts;
|
|
29
|
+
confirmed-alive records are refused with `codex-bridge stop <run>` instead of being closed.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Run records now verify that a pid still belongs to the process that started the run, using the
|
|
34
|
+
process start time and a fresh heartbeat before falling back to an OS probe. A reused or
|
|
35
|
+
unverified pid is never signalled by `stop`; `codex-bridge unlock` remains the non-killing way to
|
|
36
|
+
close a record whose process identity cannot be confirmed.
|
|
37
|
+
- JSON files written by PowerShell with a UTF-8 BOM are now read consistently by the CLI and
|
|
38
|
+
installed hooks, and one command run creates at most one settings backup without deleting older
|
|
39
|
+
backups.
|
|
40
|
+
- 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.
|
|
41
|
+
- `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.
|
|
42
|
+
- `uninstall --dry-run` names each hook by its own matcher. Three of the four hooks are PreToolUse,
|
|
43
|
+
and a lookup by event alone reported `Agent|Task` for the worktree lock and the prune guard too —
|
|
44
|
+
in the one line an operator reads to see what a removal is about to touch. The removal itself was
|
|
45
|
+
never affected: hooks are found by command, not by matcher.
|
|
46
|
+
- The order gate's refusal now shows the line it found. A caller who wrote
|
|
47
|
+
`scope (you may create/modify ONLY these):` was told scope was missing, re-read their own prompt,
|
|
48
|
+
saw it written there, and had nothing to act on; an autonomous run rewrites the same text forever.
|
|
49
|
+
The refusal names the candidate line under the input it belongs to and says why it was not
|
|
50
|
+
accepted — a qualifier between the label and the colon, a value that is still a placeholder, or no
|
|
51
|
+
separator at all — and stays silent when no candidate exists, because then "the input is missing"
|
|
52
|
+
is the truth. The parser accepts exactly what it accepted before: deriving scope from prose would
|
|
53
|
+
give away the only guarantee that a run cannot touch files nobody listed.
|
|
54
|
+
|
|
7
55
|
## [0.3.1] - 2026-08-07
|
|
8
56
|
|
|
9
57
|
### Added
|
package/README.md
CHANGED
|
@@ -14,13 +14,16 @@ codexb install # short name, same command
|
|
|
14
14
|
node bin/codex-bridge.mjs install # from a clone, same thing
|
|
15
15
|
node bin/codex-bridge.mjs install --scope project # into <repo>/.claude
|
|
16
16
|
node bin/codex-bridge.mjs update # bring an installation up to the current version
|
|
17
|
+
node bin/codex-bridge.mjs permissions # show optional shell permission rules
|
|
18
|
+
node bin/codex-bridge.mjs permissions add # add the complete allow/deny rule set
|
|
19
|
+
node bin/codex-bridge.mjs permissions remove # remove only codex-bridge's rule strings
|
|
17
20
|
node bin/codex-bridge.mjs doctor # what is installed, where it points, is codex alive
|
|
18
21
|
node bin/codex-bridge.mjs uninstall # removes exactly what was installed
|
|
19
22
|
node bin/codex-bridge.mjs stop <run> # close a hanging run by hand, without hunting for a pid
|
|
20
23
|
node bin/codex-bridge.mjs read <run> # read a run: its events rendered as text, on demand
|
|
21
24
|
node bin/codex-bridge.mjs projects # what the run store holds: projects, runs, weight, tokens
|
|
22
25
|
node bin/codex-bridge.mjs prune <project> # reclaim disk space, transport only unless told otherwise
|
|
23
|
-
node bin/codex-bridge.mjs
|
|
26
|
+
node bin/codex-bridge.mjs unlock [<project>|--all] # close records whose runner is gone
|
|
24
27
|
```
|
|
25
28
|
|
|
26
29
|
When a global install and a clone coexist, there are two copies of the package. `codex-bridge update`
|
|
@@ -38,6 +41,13 @@ version: outdated files are refreshed silently, files you changed by hand stop t
|
|
|
38
41
|
named, and `--force` is what overwrites them. A file the package no longer ships is removed only
|
|
39
42
|
if you never touched it.
|
|
40
43
|
|
|
44
|
+
`permissions` manages the optional shell rules in the selected host's `settings.json`. `add`
|
|
45
|
+
merges the exact allow and deny strings for both CLI names, the clone entry point, Bash and
|
|
46
|
+
PowerShell; it preserves foreign rules and is safe to repeat. `remove` takes back those exact
|
|
47
|
+
strings from `allow`, `deny`, or `ask`, so an operator-authored lookalike is left alone. With no
|
|
48
|
+
action it reports whether the rule set is installed, partial, or absent. The command accepts the
|
|
49
|
+
same `--scope` and `--host` selectors as `install`, and `uninstall` removes the same strings too.
|
|
50
|
+
|
|
41
51
|
`uninstall` removes only what the install recorded: a file you put in `agents/codex/` yourself
|
|
42
52
|
stays, and `codex-runs/` is never touched — those are your run artifacts, not the package.
|
|
43
53
|
|
|
@@ -62,11 +72,14 @@ accounting, reports and worktree snapshots alone; `--purge` is what takes a fold
|
|
|
62
72
|
The same transport is dropped automatically from runs older than 30 days when a new run starts,
|
|
63
73
|
which the reply says out loud; `retention` in `run-config.json` changes the age or turns it off.
|
|
64
74
|
|
|
65
|
-
`
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
`unlock` is housekeeping for records left by a dead runner. With no argument it acts on the current
|
|
76
|
+
repository; a bare project name limits the scan to that folder, and `--all` explicitly scans the
|
|
77
|
+
whole store. It uses the same process-identity decision as start-of-run cleanup, closes only
|
|
78
|
+
`dead` or `foreign` records, and reports each record's age, silence duration, and identity verdict.
|
|
79
|
+
A confirmed `alive` record is refused rather than closed and is named with the exact
|
|
80
|
+
`codex-bridge stop <run>` command needed to end it; `unverified` records stay untouched with the
|
|
81
|
+
reason visible. The command never deletes run artifacts. The old spelling `sweep` is recognized and
|
|
82
|
+
refused with the rename to `unlock`, rather than treated as an unknown command.
|
|
70
83
|
|
|
71
84
|
## What it gives you
|
|
72
85
|
|
package/bin/codex-bridge.mjs
CHANGED
|
@@ -8,9 +8,10 @@ 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,12 +20,13 @@ 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>
|
|
30
32
|
codexb <same command forms as codex-bridge>
|
|
@@ -34,11 +36,12 @@ Usage:
|
|
|
34
36
|
Commands:
|
|
35
37
|
install Install codex-bridge into the selected Claude Code host
|
|
36
38
|
update Update a recorded codex-bridge installation
|
|
39
|
+
permissions Show, add, or remove optional shell permission rules
|
|
37
40
|
uninstall Remove installed files while preserving run artifacts
|
|
38
41
|
doctor Diagnose the selected Claude Code host
|
|
39
42
|
projects List projects or runs from the run store
|
|
40
43
|
prune Remove archived transport, or purge selected run folders
|
|
41
|
-
|
|
44
|
+
unlock Close running records whose runner is gone
|
|
42
45
|
read Render a run's structured event stream
|
|
43
46
|
stop Stop a running Codex run and record FAIL`;
|
|
44
47
|
|
|
@@ -110,7 +113,27 @@ export async function main(argv, io = console) {
|
|
|
110
113
|
return result.exitCode;
|
|
111
114
|
}
|
|
112
115
|
if (command === 'sweep') {
|
|
113
|
-
|
|
116
|
+
io.error('codex-bridge sweep was renamed to codex-bridge unlock; use the new command.');
|
|
117
|
+
return 2;
|
|
118
|
+
}
|
|
119
|
+
if (command === 'unlock') {
|
|
120
|
+
const result = unlock(rest);
|
|
121
|
+
io.log(result.output);
|
|
122
|
+
return result.exitCode;
|
|
123
|
+
}
|
|
124
|
+
if (command === 'permissions') {
|
|
125
|
+
let action;
|
|
126
|
+
let optionArgs = rest;
|
|
127
|
+
if (rest[0] && !rest[0].startsWith('-')) {
|
|
128
|
+
action = rest[0];
|
|
129
|
+
optionArgs = rest.slice(1);
|
|
130
|
+
}
|
|
131
|
+
if (action && !['add', 'remove'].includes(action)) {
|
|
132
|
+
throw new Error(`unknown permissions action "${action}"`);
|
|
133
|
+
}
|
|
134
|
+
const options = commandOptions(command, optionArgs);
|
|
135
|
+
const host = resolveHost(options);
|
|
136
|
+
const result = await permissions({ host, action });
|
|
114
137
|
io.log(result.output);
|
|
115
138
|
return result.exitCode;
|
|
116
139
|
}
|
package/cli/doctor.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
readInstallRecord,
|
|
10
10
|
packageInfo,
|
|
11
11
|
} from './manifest.mjs';
|
|
12
|
+
import { inspectPermissions } from './permissions.mjs';
|
|
12
13
|
import { commandFor, inspectHook } from './settings-merge.mjs';
|
|
13
14
|
import { readRulesRegistry } from './rules-owners.mjs';
|
|
14
15
|
import { readRunConfig, retentionNotice } from '../src/run-config.mjs';
|
|
@@ -162,6 +163,21 @@ async function rulesCheck(host, record) {
|
|
|
162
163
|
: check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
|
|
163
164
|
}
|
|
164
165
|
|
|
166
|
+
async function permissionsCheck(host) {
|
|
167
|
+
try {
|
|
168
|
+
const status = await inspectPermissions(host.settingsPath);
|
|
169
|
+
// Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
|
|
170
|
+
// doctor does not turn a healthy installation red merely because hardening was not requested.
|
|
171
|
+
// The ask count is part of the line because a full set shadowed by `ask` reads as working and
|
|
172
|
+
// is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
|
|
173
|
+
const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
|
|
174
|
+
return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
|
|
175
|
+
`${status.state} (${status.present}/${status.total} own strings in allow/deny${shadow})`);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
165
181
|
export async function diagnose({ host, codexProbe = probeCodex, currentPackage } = {}) {
|
|
166
182
|
const checks = [sourceCheck()];
|
|
167
183
|
const hostExists = await exists(host.root);
|
|
@@ -199,6 +215,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
199
215
|
));
|
|
200
216
|
const rules = await rulesCheck(host, record);
|
|
201
217
|
checks.push(rules);
|
|
218
|
+
checks.push(await permissionsCheck(host));
|
|
202
219
|
checks.push(...await hookChecks(host, record));
|
|
203
220
|
const retention = retentionCheck(host);
|
|
204
221
|
checks.push(retention);
|
|
@@ -225,7 +242,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
225
242
|
export function renderDoctor(result) {
|
|
226
243
|
return result.checks.map(({ key, status, value }) => {
|
|
227
244
|
const rendered = `[${status}] ${key}: ${value}`;
|
|
228
|
-
return ['retention', 'conventions'].includes(key) && status === 'warn'
|
|
245
|
+
return ['retention', 'conventions', 'permissions'].includes(key) && status === 'warn'
|
|
229
246
|
? `${WARNING}${rendered}${RESET}`
|
|
230
247
|
: rendered;
|
|
231
248
|
}).join('\n');
|
package/cli/install.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
writeInstallRecord,
|
|
15
15
|
} from './manifest.mjs';
|
|
16
16
|
import { copyPlannedFile, targetMatches } from './copy.mjs';
|
|
17
|
-
import { commandFor, inspectHook, mergeHook } from './settings-merge.mjs';
|
|
17
|
+
import { commandFor, inspectHook, mergeHook, withSettingsRun } from './settings-merge.mjs';
|
|
18
18
|
import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
|
|
19
19
|
import { readRunConfig, retentionNotice } from '../src/run-config.mjs';
|
|
20
20
|
|
|
@@ -58,7 +58,7 @@ function retentionOutput(line, output) {
|
|
|
58
58
|
return `${output}\n${line}`;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
async function installInRun({ host, dryRun = false, force = false, packageRoot } = {}) {
|
|
62
62
|
// Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
|
|
63
63
|
await readRulesRegistry(host);
|
|
64
64
|
const configuredRetentionLine = retentionLine(host);
|
|
@@ -163,3 +163,9 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
163
163
|
output: retentionOutput(configuredRetentionLine, `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`),
|
|
164
164
|
};
|
|
165
165
|
}
|
|
166
|
+
|
|
167
|
+
export async function install(options = {}) {
|
|
168
|
+
const host = options?.host;
|
|
169
|
+
if (!host?.settingsPath) return installInRun(options);
|
|
170
|
+
return withSettingsRun(host.settingsPath, () => installInRun(options));
|
|
171
|
+
}
|
package/cli/manifest.mjs
CHANGED
|
@@ -11,9 +11,23 @@ import {
|
|
|
11
11
|
renderRequiredInputs,
|
|
12
12
|
} from '../src/required-inputs.mjs';
|
|
13
13
|
import { renderNoSelfExecution } from '../src/no-self-execution.mjs';
|
|
14
|
+
import { readJsonFile } from '../src/json-file.mjs';
|
|
14
15
|
|
|
15
16
|
export { HOOK_DEFINITIONS };
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Finds the definition a recorded hook belongs to. The event alone does not identify one: three of
|
|
20
|
+
* the four hooks are PreToolUse, so `find(d => d.event === hook.event)` returns the order gate for
|
|
21
|
+
* all three. `doctor` was fixed for exactly this and kept its own inline matching; `uninstall` was
|
|
22
|
+
* not, and its dry-run announced matcher `Agent|Task` for the worktree lock and the prune guard —
|
|
23
|
+
* the operator's only view of what a removal is about to touch. One function, so a third caller
|
|
24
|
+
* cannot repeat it.
|
|
25
|
+
*/
|
|
26
|
+
export function definitionForRecordedHook(hook) {
|
|
27
|
+
return HOOK_DEFINITIONS.find((definition) => definition.event === hook?.event
|
|
28
|
+
&& path.basename(String(hook?.path ?? '')) === definition.file);
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
18
32
|
export const PACKAGE_ROOT = path.resolve(HERE, '..');
|
|
19
33
|
export const INSTALL_RECORD_NAME = '.codex-bridge-install.json';
|
|
@@ -49,7 +63,7 @@ export function replacePlaceholders(content, agentsDir) {
|
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
export async function packageInfo(packageRoot = PACKAGE_ROOT) {
|
|
52
|
-
const parsed =
|
|
66
|
+
const parsed = await readJsonFile(path.join(packageRoot, 'package.json'));
|
|
53
67
|
return { name: parsed.name, version: parsed.version };
|
|
54
68
|
}
|
|
55
69
|
|
|
@@ -191,19 +205,16 @@ export function normalizeInstallRecord(record) {
|
|
|
191
205
|
}
|
|
192
206
|
|
|
193
207
|
export async function readInstallRecord(host) {
|
|
194
|
-
let
|
|
208
|
+
let parsed;
|
|
195
209
|
try {
|
|
196
|
-
|
|
210
|
+
parsed = await readJsonFile(installRecordPath(host));
|
|
197
211
|
} catch (err) {
|
|
198
212
|
if (err.code === 'ENOENT') return null;
|
|
213
|
+
if (err.cause) {
|
|
214
|
+
throw new Error(`invalid installation record JSON: ${err.cause.message}`, { cause: err.cause });
|
|
215
|
+
}
|
|
199
216
|
throw err;
|
|
200
217
|
}
|
|
201
|
-
let parsed;
|
|
202
|
-
try {
|
|
203
|
-
parsed = JSON.parse(raw);
|
|
204
|
-
} catch (err) {
|
|
205
|
-
throw new Error(`invalid installation record JSON: ${err.message}`);
|
|
206
|
-
}
|
|
207
218
|
const record = normalizeInstallRecord(parsed);
|
|
208
219
|
// Earlier versions installed the config as a package file and recorded it. Dropping it here
|
|
209
220
|
// is what stops the next update from calling the operator's edited config an orphan and
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/** Manages the optional shell permission rules for every codex-bridge command spelling. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { CLI_NAMES } from '../src/cli-names.mjs';
|
|
5
|
+
import { SHELL_TOOLS } from '../src/hook-definitions.mjs';
|
|
6
|
+
import { readSettings, updateSettings, withSettingsRun } from './settings-merge.mjs';
|
|
7
|
+
|
|
8
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const entryPoint = fileURLToPath(new URL('../bin/codex-bridge.mjs', import.meta.url));
|
|
10
|
+
const nodeCommand = `node ${path.relative(packageRoot, entryPoint).split(path.sep).join('/')}`;
|
|
11
|
+
const commandNames = Object.freeze([...CLI_NAMES, nodeCommand]);
|
|
12
|
+
const formsFor = (command) => [command, `${command}:*`];
|
|
13
|
+
|
|
14
|
+
function buildRules(commandTransform) {
|
|
15
|
+
return Object.freeze(SHELL_TOOLS.flatMap((tool) => commandNames.flatMap((command) =>
|
|
16
|
+
formsFor(commandTransform(command)).map((form) => `${tool}(${form})`))));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The executable names stay owned by cli-names.mjs; only the clone entry point is derived here.
|
|
21
|
+
* Plan_22 records the incident because repeating the node spelling in CLI_NAMES would make the
|
|
22
|
+
* source list answer two different questions and let a future alias bypass one of them.
|
|
23
|
+
*
|
|
24
|
+
* The deny half subtracts `prune` from the allow half rather than guarding it: `Bash(codex-bridge:*)`
|
|
25
|
+
* would otherwise hand out the deletion this package refuses to give an agent. Its refusal is never
|
|
26
|
+
* seen in a live session — a PreToolUse hook answers before permissions are consulted, and
|
|
27
|
+
* prune-guard is installed always while these rules are optional. The 2026-08-09 run held that
|
|
28
|
+
* observation open as a checklist item until it turned out to be the answer, not the obstacle.
|
|
29
|
+
*/
|
|
30
|
+
export const PERMISSION_RULES = Object.freeze({
|
|
31
|
+
allow: buildRules((command) => command),
|
|
32
|
+
deny: buildRules((command) => `${command} prune`),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const allRules = new Set([...PERMISSION_RULES.allow, ...PERMISSION_RULES.deny]);
|
|
36
|
+
const ruleLists = Object.freeze(['allow', 'deny', 'ask']);
|
|
37
|
+
const activeLists = Object.freeze(['allow', 'deny']);
|
|
38
|
+
const totalRules = PERMISSION_RULES.allow.length + PERMISSION_RULES.deny.length;
|
|
39
|
+
|
|
40
|
+
function listValue(settings, name) {
|
|
41
|
+
const list = settings?.permissions?.[name];
|
|
42
|
+
return Array.isArray(list) ? list : [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function matchingRules(list, rules) {
|
|
46
|
+
return rules.filter((rule) => list.includes(rule)).length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A count of strings in allow and deny is not the state of the set, because `ask` outranks `allow`:
|
|
51
|
+
* a copy sitting there keeps asking the question the rule was added to end. The live run of
|
|
52
|
+
* Plan_22-1 caught doctor reporting `installed (24/24)` over exactly that arrangement — the number
|
|
53
|
+
* was true and the conclusion drawn from it was false. Every consumer reads the state, so the
|
|
54
|
+
* shadow has to live in the state rather than in one command's wording.
|
|
55
|
+
*/
|
|
56
|
+
function permissionStatus(settings) {
|
|
57
|
+
const counts = Object.fromEntries(activeLists.map((name) => [
|
|
58
|
+
name,
|
|
59
|
+
matchingRules(listValue(settings, name), PERMISSION_RULES[name]),
|
|
60
|
+
]));
|
|
61
|
+
const askCount = matchingRules(listValue(settings, 'ask'), [...allRules]);
|
|
62
|
+
const present = counts.allow + counts.deny;
|
|
63
|
+
const complete = present === totalRules;
|
|
64
|
+
const state = askCount
|
|
65
|
+
? (complete ? 'shadowed by ask' : 'partially installed, shadowed by ask')
|
|
66
|
+
: complete ? 'installed' : present === 0 ? 'absent' : 'partially installed';
|
|
67
|
+
return {
|
|
68
|
+
state,
|
|
69
|
+
complete,
|
|
70
|
+
counts,
|
|
71
|
+
askCount,
|
|
72
|
+
present,
|
|
73
|
+
total: totalRules,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ensureList(settings, name) {
|
|
78
|
+
if (settings.permissions === undefined) settings.permissions = {};
|
|
79
|
+
if (!settings.permissions || typeof settings.permissions !== 'object'
|
|
80
|
+
|| Array.isArray(settings.permissions)) {
|
|
81
|
+
throw new TypeError('settings.permissions must be an object');
|
|
82
|
+
}
|
|
83
|
+
if (settings.permissions[name] === undefined) settings.permissions[name] = [];
|
|
84
|
+
if (!Array.isArray(settings.permissions[name])) {
|
|
85
|
+
throw new TypeError(`settings.permissions.${name} must be an array`);
|
|
86
|
+
}
|
|
87
|
+
return settings.permissions[name];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function countRemovable(settings) {
|
|
91
|
+
return ruleLists.reduce((total, name) => total + listValue(settings, name)
|
|
92
|
+
.filter((entry) => allRules.has(entry)).length, 0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function inspectPermissions(settingsPath) {
|
|
96
|
+
const state = await readSettings(settingsPath);
|
|
97
|
+
return { ...state, ...permissionStatus(state.settings) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function addPermissionRules(settingsPath) {
|
|
101
|
+
return updateSettings(settingsPath, (settings) => {
|
|
102
|
+
let added = 0;
|
|
103
|
+
for (const name of activeLists) {
|
|
104
|
+
const list = ensureList(settings, name);
|
|
105
|
+
for (const rule of PERMISSION_RULES[name]) {
|
|
106
|
+
if (list.includes(rule)) continue;
|
|
107
|
+
list.push(rule);
|
|
108
|
+
added += 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { changed: added > 0, added };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function removePermissionRules(settingsPath, { dryRun = false } = {}) {
|
|
116
|
+
if (dryRun) {
|
|
117
|
+
const state = await readSettings(settingsPath);
|
|
118
|
+
return { changed: false, removed: countRemovable(state.settings) };
|
|
119
|
+
}
|
|
120
|
+
return updateSettings(settingsPath, (settings) => {
|
|
121
|
+
let removed = 0;
|
|
122
|
+
for (const name of ruleLists) {
|
|
123
|
+
const list = settings?.permissions?.[name];
|
|
124
|
+
if (!Array.isArray(list)) continue;
|
|
125
|
+
settings.permissions[name] = list.filter((entry) => {
|
|
126
|
+
if (!allRules.has(entry)) return true;
|
|
127
|
+
removed += 1;
|
|
128
|
+
return false;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return { changed: removed > 0, removed };
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function statusOutput(status) {
|
|
136
|
+
const moved = status.askCount
|
|
137
|
+
? `; ${status.askCount} own string(s) in ask, which outranks allow`
|
|
138
|
+
: '';
|
|
139
|
+
return `Permissions: ${status.state} (${status.present}/${status.total} own strings in allow/deny${moved}).`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function permissionsInRun({ host, action } = {}) {
|
|
143
|
+
if (!host?.settingsPath) throw new TypeError('permissions requires a resolved host');
|
|
144
|
+
if (!action) {
|
|
145
|
+
const status = await inspectPermissions(host.settingsPath);
|
|
146
|
+
return { exitCode: 0, output: statusOutput(status), ...status };
|
|
147
|
+
}
|
|
148
|
+
if (action === 'add') {
|
|
149
|
+
const result = await addPermissionRules(host.settingsPath);
|
|
150
|
+
// A string the operator moved into `ask` outranks the copy `add` just put into `allow`, so the
|
|
151
|
+
// command would report a complete set while the questions it was run to end keep appearing.
|
|
152
|
+
// `ask` stays untouched — it is a hand-made decision — but silence about it is a false success.
|
|
153
|
+
const status = await inspectPermissions(host.settingsPath);
|
|
154
|
+
const conflict = status.askCount
|
|
155
|
+
? ` ${status.askCount} own string(s) also sit in ask, which outranks allow; they were left there.`
|
|
156
|
+
: '';
|
|
157
|
+
return {
|
|
158
|
+
exitCode: 0,
|
|
159
|
+
output: `Added ${result.added} permission rule string${result.added === 1 ? '' : 's'}.${conflict}`,
|
|
160
|
+
...result,
|
|
161
|
+
askCount: status.askCount,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (action === 'remove') {
|
|
165
|
+
const result = await removePermissionRules(host.settingsPath);
|
|
166
|
+
return {
|
|
167
|
+
exitCode: 0,
|
|
168
|
+
output: `Removed ${result.removed} permission rule string${result.removed === 1 ? '' : 's'}.`,
|
|
169
|
+
...result,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
throw new Error(`unknown permissions action "${action}"`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function permissions(options = {}) {
|
|
176
|
+
const host = options?.host;
|
|
177
|
+
if (!host?.settingsPath) return permissionsInRun(options);
|
|
178
|
+
return withSettingsRun(host.settingsPath, () => permissionsInRun(options));
|
|
179
|
+
}
|
package/cli/prune-args.mjs
CHANGED
|
@@ -112,7 +112,7 @@ export function parsePruneArgs(argv = []) {
|
|
|
112
112
|
if (invalid) return invalid;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
// The 30-day default belongs to the gentle
|
|
115
|
+
// The 30-day default belongs to the gentle cleanup, which searches history for weight to shed.
|
|
116
116
|
// A purge is never implicitly aged: `prune sbx2 --purge` means that folder, now. Defaulting it
|
|
117
117
|
// made the plan's own fourth scenario unreachable — a young project answered "nothing to
|
|
118
118
|
// remove", which reads as a broken command, not as a refusal.
|
package/cli/rules-owners.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { normalizeRepoPath } from '../src/runner/project-dir.mjs';
|
|
6
|
+
import { readJsonFile } from '../src/json-file.mjs';
|
|
6
7
|
|
|
7
8
|
export const RULES_REGISTRY_NAME = '.codex-bridge-rules.json';
|
|
8
9
|
export const RULES_REGISTRY_VERSION = 1;
|
|
@@ -40,19 +41,16 @@ function validateRegistry(parsed) {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
async function loadRulesRegistry(host) {
|
|
43
|
-
let
|
|
44
|
+
let parsed;
|
|
44
45
|
try {
|
|
45
|
-
|
|
46
|
+
parsed = await readJsonFile(rulesRegistryPath(host));
|
|
46
47
|
} catch (err) {
|
|
47
48
|
if (err.code === 'ENOENT') return null;
|
|
49
|
+
if (err.cause) {
|
|
50
|
+
throw new Error(`invalid rules ownership registry JSON: ${err.cause.message}`, { cause: err.cause });
|
|
51
|
+
}
|
|
48
52
|
throw err;
|
|
49
53
|
}
|
|
50
|
-
let parsed;
|
|
51
|
-
try {
|
|
52
|
-
parsed = JSON.parse(raw);
|
|
53
|
-
} catch (err) {
|
|
54
|
-
throw new Error(`invalid rules ownership registry JSON: ${err.message}`);
|
|
55
|
-
}
|
|
56
54
|
return { raw: parsed, registry: validateRegistry(parsed) };
|
|
57
55
|
}
|
|
58
56
|
|
package/cli/runs-inventory.mjs
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { liveRuns } from '../src/hooks/live-runs.mjs';
|
|
5
|
+
import { readJsonFileSync } from '../src/json-file.mjs';
|
|
5
6
|
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
6
7
|
|
|
7
8
|
const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
8
9
|
|
|
9
10
|
function readRecord(file) {
|
|
10
11
|
try {
|
|
11
|
-
const value =
|
|
12
|
+
const value = readJsonFileSync(file);
|
|
12
13
|
return record(value) ? value : null;
|
|
13
14
|
} catch {
|
|
14
15
|
return null;
|