@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/cli/manifest.mjs
CHANGED
|
@@ -1,24 +1,52 @@
|
|
|
1
|
-
/** Defines install mappings and
|
|
1
|
+
/** Defines the package install mappings and renders files before they are copied. */
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
// The hook table lives under src/ because the installed gate reads it too: cli/ is not copied
|
|
7
|
-
// into a host, so a definition kept here would be invisible to the very hook it describes.
|
|
8
6
|
import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
|
|
9
7
|
import {
|
|
10
8
|
renderRequiredInputSummary,
|
|
11
9
|
renderRequiredInputs,
|
|
12
10
|
} from '../src/required-inputs.mjs';
|
|
13
11
|
import { renderNoSelfExecution } from '../src/no-self-execution.mjs';
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
import { renderStopSummary } from '../src/stop-contract.mjs';
|
|
13
|
+
import { readJsonFile } from '../src/json-file.mjs';
|
|
14
|
+
import {
|
|
15
|
+
INSTALL_RECORD_NAME,
|
|
16
|
+
RULES_NAME,
|
|
17
|
+
definitionForRecordedHook,
|
|
18
|
+
fileEntry,
|
|
19
|
+
installRecordPath,
|
|
20
|
+
legacyInstallRecordPath,
|
|
21
|
+
normalizeInstallRecord,
|
|
22
|
+
readInstallRecord,
|
|
23
|
+
recordFileKey,
|
|
24
|
+
recordMatchesPackage,
|
|
25
|
+
recordTarget,
|
|
26
|
+
validateInstallRecord,
|
|
27
|
+
writeInstallRecord,
|
|
28
|
+
} from './install-record.mjs';
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
HOOK_DEFINITIONS,
|
|
32
|
+
INSTALL_RECORD_NAME,
|
|
33
|
+
RULES_NAME,
|
|
34
|
+
definitionForRecordedHook,
|
|
35
|
+
installRecordPath,
|
|
36
|
+
legacyInstallRecordPath,
|
|
37
|
+
normalizeInstallRecord,
|
|
38
|
+
readInstallRecord,
|
|
39
|
+
recordFileKey,
|
|
40
|
+
recordMatchesPackage,
|
|
41
|
+
recordTarget,
|
|
42
|
+
validateInstallRecord,
|
|
43
|
+
writeInstallRecord,
|
|
44
|
+
};
|
|
16
45
|
|
|
17
46
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
18
47
|
export const PACKAGE_ROOT = path.resolve(HERE, '..');
|
|
19
|
-
export const INSTALL_RECORD_NAME = '.codex-bridge-install.json';
|
|
20
|
-
export const RULES_NAME = 'codex-bridge.rules';
|
|
21
48
|
const RULES_SOURCE = `src/rules/${RULES_NAME}`;
|
|
49
|
+
|
|
22
50
|
/**
|
|
23
51
|
* Files the operator owns once they exist. Seeded on first install so the defaults are
|
|
24
52
|
* visible and editable, then never written, never compared, never removed: run-config.json
|
|
@@ -27,47 +55,54 @@ const RULES_SOURCE = `src/rules/${RULES_NAME}`;
|
|
|
27
55
|
* update — the same way overwriting a .env would.
|
|
28
56
|
*/
|
|
29
57
|
export const SEEDED_SOURCES = Object.freeze(['src/run-config.json', 'src/conventions.md']);
|
|
30
|
-
|
|
31
|
-
new Set(SEEDED_SOURCES.map((source) => posix(
|
|
32
|
-
path.relative(host.root, path.join(host.agentsDir, path.relative('src', source))),
|
|
33
|
-
)));
|
|
58
|
+
|
|
34
59
|
export const INSTALL_TABLE = Object.freeze([
|
|
35
|
-
{ source: 'src/agents/*.md', target: 'agentsDir', processing: 'placeholders' },
|
|
36
|
-
{ source: 'src/commands/*.md', target: 'commandsDir', processing: 'placeholders' },
|
|
37
|
-
{ source: 'src/**', target: '
|
|
60
|
+
{ source: 'src/agents/*.md', root: 'claude', target: 'agentsDir', processing: 'placeholders' },
|
|
61
|
+
{ source: 'src/commands/*.md', root: 'claude', target: 'commandsDir', processing: 'placeholders' },
|
|
62
|
+
{ source: 'src/hooks/**', root: 'brand', target: 'brandHooksDir', processing: 'copy' },
|
|
63
|
+
{ source: 'src/**', root: 'brand', target: 'brandRunnerDir', processing: 'copy' },
|
|
64
|
+
// The runner reads its own version out of the package.json beside it, so meta.json can say
|
|
65
|
+
// which code wrote a run (Plan_34). In the clone that file sits one level above src/; without
|
|
66
|
+
// this line the installed layout has nothing one level above lib/, and every run on a real host
|
|
67
|
+
// would die on import while the whole suite stayed green against the clone.
|
|
68
|
+
{ source: 'package.json', root: 'brand', target: 'brandRoot', processing: 'copy' },
|
|
38
69
|
]);
|
|
39
70
|
|
|
40
71
|
const posix = (value) => value.split(path.sep).join('/');
|
|
41
72
|
|
|
42
|
-
export function replacePlaceholders(content,
|
|
73
|
+
export function replacePlaceholders(content, installationRoot) {
|
|
43
74
|
const agentType = content.match(/^name:\s*([^\r\n]+)$/m)?.[1].trim();
|
|
44
75
|
return content
|
|
45
|
-
.replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(
|
|
76
|
+
.replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(installationRoot)))
|
|
46
77
|
.replaceAll('{{CODEX_REQUIRED_INPUTS_SUMMARY}}', renderRequiredInputSummary(agentType))
|
|
47
78
|
.replaceAll('{{CODEX_REQUIRED_INPUTS}}', renderRequiredInputs(agentType))
|
|
48
|
-
.replaceAll('{{CODEX_NO_SELF_EXECUTION}}', renderNoSelfExecution())
|
|
79
|
+
.replaceAll('{{CODEX_NO_SELF_EXECUTION}}', renderNoSelfExecution())
|
|
80
|
+
.replaceAll('{{CODEX_STOP_SUMMARY}}', renderStopSummary());
|
|
49
81
|
}
|
|
50
82
|
|
|
51
83
|
export async function packageInfo(packageRoot = PACKAGE_ROOT) {
|
|
52
|
-
const parsed =
|
|
84
|
+
const parsed = await readJsonFile(path.join(packageRoot, 'package.json'));
|
|
53
85
|
return { name: parsed.name, version: parsed.version };
|
|
54
86
|
}
|
|
55
87
|
|
|
56
|
-
export function installRecordPath(host) {
|
|
57
|
-
return path.join(host.agentsDir, INSTALL_RECORD_NAME);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
88
|
export function installedHookPath(host, definition) {
|
|
61
|
-
return path.join(host.
|
|
89
|
+
return path.join(host.brandHooksDir, definition.file);
|
|
62
90
|
}
|
|
63
91
|
|
|
64
92
|
/** Where each seeded file comes from and where it goes; contents are copied verbatim. */
|
|
65
93
|
export function seedPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
66
|
-
return SEEDED_SOURCES.map((source) =>
|
|
67
|
-
source
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
94
|
+
return SEEDED_SOURCES.map((source) => {
|
|
95
|
+
const target = source.endsWith('run-config.json')
|
|
96
|
+
? host.brandConfigPath
|
|
97
|
+
: host.brandConventionsPath;
|
|
98
|
+
return {
|
|
99
|
+
source: path.join(packageRoot, source),
|
|
100
|
+
target,
|
|
101
|
+
root: 'brand',
|
|
102
|
+
relativeToRoot: posix(path.relative(host.brandRoot, target)),
|
|
103
|
+
processing: 'copy',
|
|
104
|
+
};
|
|
105
|
+
});
|
|
71
106
|
}
|
|
72
107
|
|
|
73
108
|
export function rulesPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
@@ -87,140 +122,24 @@ export async function fileFingerprint(absolutePath) {
|
|
|
87
122
|
}
|
|
88
123
|
}
|
|
89
124
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
*/
|
|
95
|
-
export function recordMatchesPackage(record, plan, currentPackage, fingerprints, ruleState) {
|
|
96
|
-
return Boolean(record)
|
|
97
|
-
&& record.name === currentPackage.name
|
|
98
|
-
&& record.version === currentPackage.version
|
|
99
|
-
&& record.files.length === plan.length
|
|
100
|
-
&& record.files.every((file, index) => file === plan[index].relativeToHost)
|
|
101
|
-
&& Boolean(record.fingerprints)
|
|
102
|
-
&& plan.every((item) => record.fingerprints[item.relativeToHost] === fingerprints.get(item.relativeToHost))
|
|
103
|
-
&& (!ruleState || (record.rules?.path === ruleState.path
|
|
104
|
-
&& record.rules.fingerprint === ruleState.fingerprint));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function validateInstallRecord(record) {
|
|
108
|
-
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
109
|
-
throw new Error('installation record must be an object');
|
|
110
|
-
}
|
|
111
|
-
const strings = ['name', 'version', 'installedAt', 'mode'];
|
|
112
|
-
for (const key of strings) {
|
|
113
|
-
if (typeof record[key] !== 'string' || !record[key]) throw new Error(`installation record has invalid ${key}`);
|
|
114
|
-
}
|
|
115
|
-
if (record.mode !== 'copy') throw new Error('installation record mode must be copy');
|
|
116
|
-
if (Number.isNaN(Date.parse(record.installedAt))) throw new Error('installation record installedAt is invalid');
|
|
117
|
-
if (!Array.isArray(record.files) || !record.files.length || record.files.some((file) => typeof file !== 'string' || !file)) {
|
|
118
|
-
throw new Error('installation record files must be a non-empty list of strings');
|
|
119
|
-
}
|
|
120
|
-
if (record.files.some((file) => path.isAbsolute(file) || file.split(/[\\/]/).some((part) => part === '..' || part === '.'))) {
|
|
121
|
-
throw new Error('installation record files must stay relative to the host root');
|
|
122
|
-
}
|
|
123
|
-
if (new Set(record.files).size !== record.files.length) {
|
|
124
|
-
throw new Error('installation record files must not contain duplicates');
|
|
125
|
-
}
|
|
126
|
-
// Run artifacts are the user's data, not ours. Refusing them here means neither uninstall nor
|
|
127
|
-
// update needs its own guard against deleting a run folder someone listed as an installed file.
|
|
128
|
-
if (record.files.some((file) => file === 'codex-runs' || file.split(/[\\/]/)[0] === 'codex-runs')) {
|
|
129
|
-
throw new Error('installation record files must not name run artifacts under codex-runs');
|
|
130
|
-
}
|
|
131
|
-
if (record.fingerprints !== undefined) {
|
|
132
|
-
if (!record.fingerprints || typeof record.fingerprints !== 'object' || Array.isArray(record.fingerprints)) {
|
|
133
|
-
throw new Error('installation record fingerprints must be an object');
|
|
134
|
-
}
|
|
135
|
-
const fingerprintKeys = Object.keys(record.fingerprints);
|
|
136
|
-
if (fingerprintKeys.length !== record.files.length || fingerprintKeys.some((file) => !record.files.includes(file))) {
|
|
137
|
-
throw new Error('installation record fingerprints keys must exactly match files');
|
|
138
|
-
}
|
|
139
|
-
if (Object.values(record.fingerprints).some((fingerprint) =>
|
|
140
|
-
typeof fingerprint !== 'string' || !/^[a-f0-9]{64}$/i.test(fingerprint))) {
|
|
141
|
-
throw new Error('installation record fingerprints must be 64-character hexadecimal SHA256 strings');
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
if (record.rules !== undefined) {
|
|
145
|
-
if (!record.rules || typeof record.rules !== 'object' || Array.isArray(record.rules)) {
|
|
146
|
-
throw new Error('installation record rules must be an object');
|
|
147
|
-
}
|
|
148
|
-
if (typeof record.rules.path !== 'string' || !record.rules.path) {
|
|
149
|
-
throw new Error('installation record rules path must be a non-empty string');
|
|
150
|
-
}
|
|
151
|
-
if (path.basename(record.rules.path) !== RULES_NAME) {
|
|
152
|
-
throw new Error(`installation record rules path must name ${RULES_NAME}`);
|
|
153
|
-
}
|
|
154
|
-
if (typeof record.rules.fingerprint !== 'string'
|
|
155
|
-
|| !/^[a-f0-9]{64}$/i.test(record.rules.fingerprint)) {
|
|
156
|
-
throw new Error('installation record rules fingerprint must be a 64-character hexadecimal SHA256 string');
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
if (record.hooks !== undefined && record.hook !== undefined) {
|
|
160
|
-
throw new Error('installation record must use hooks instead of hook');
|
|
161
|
-
}
|
|
162
|
-
const hooks = record.hooks !== undefined ? record.hooks : record.hook !== undefined ? [record.hook] : null;
|
|
163
|
-
if (!Array.isArray(hooks) || !hooks.length) {
|
|
164
|
-
throw new Error('installation record hooks must be a non-empty list');
|
|
165
|
-
}
|
|
166
|
-
for (const hook of hooks) {
|
|
167
|
-
if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
|
|
168
|
-
throw new Error('installation record hook must be an object');
|
|
169
|
-
}
|
|
170
|
-
const definitions = HOOK_DEFINITIONS.filter((entry) => entry.event === hook.event);
|
|
171
|
-
if (!definitions.length || typeof hook.path !== 'string' || !hook.path) {
|
|
172
|
-
throw new Error('installation record hook must identify a supported event and its path');
|
|
173
|
-
}
|
|
174
|
-
if (path.isAbsolute(hook.path) || hook.path.split(/[\\/]/).includes('..')) {
|
|
175
|
-
throw new Error('installation record hook path must stay relative to the host root');
|
|
176
|
-
}
|
|
177
|
-
const definition = definitions.find((entry) => path.basename(hook.path) === entry.file);
|
|
178
|
-
if (!definition || !record.files.includes(hook.path)) {
|
|
179
|
-
const files = definitions.map((entry) => entry.file).join(' or ');
|
|
180
|
-
throw new Error(`installation record hook path must name the installed ${files}`);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return record;
|
|
125
|
+
function targetBase(host, mapping) {
|
|
126
|
+
const base = host[mapping.target];
|
|
127
|
+
if (!base) throw new Error(`host has no ${mapping.target} install target`);
|
|
128
|
+
return base;
|
|
184
129
|
}
|
|
185
130
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (record.hooks !== undefined) return record;
|
|
189
|
-
const { hook, ...withoutLegacyHook } = record;
|
|
190
|
-
return { ...withoutLegacyHook, hooks: [hook] };
|
|
131
|
+
function rootFor(host, mapping) {
|
|
132
|
+
return mapping.root === 'brand' ? host.brandRoot : host.root;
|
|
191
133
|
}
|
|
192
134
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
raw = await fs.readFile(installRecordPath(host), 'utf8');
|
|
197
|
-
} catch (err) {
|
|
198
|
-
if (err.code === 'ENOENT') return null;
|
|
199
|
-
throw err;
|
|
200
|
-
}
|
|
201
|
-
let parsed;
|
|
202
|
-
try {
|
|
203
|
-
parsed = JSON.parse(raw);
|
|
204
|
-
} catch (err) {
|
|
205
|
-
throw new Error(`invalid installation record JSON: ${err.message}`);
|
|
135
|
+
function targetRelative(packageRoot, source, mapping) {
|
|
136
|
+
if (mapping.processing === 'placeholders' || mapping.source === 'package.json') {
|
|
137
|
+
return path.basename(source);
|
|
206
138
|
}
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const seeded = seededTargets(host);
|
|
212
|
-
record.files = record.files.filter((file) => !seeded.has(file));
|
|
213
|
-
if (record.fingerprints) {
|
|
214
|
-
record.fingerprints = Object.fromEntries(
|
|
215
|
-
Object.entries(record.fingerprints).filter(([file]) => !seeded.has(file)),
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
return record;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export async function writeInstallRecord(host, record) {
|
|
222
|
-
const normalized = normalizeInstallRecord(record);
|
|
223
|
-
await fs.writeFile(installRecordPath(host), `${JSON.stringify(normalized, null, 2)}\n`);
|
|
139
|
+
const sourceRoot = mapping.source === 'src/hooks/**'
|
|
140
|
+
? path.join(packageRoot, 'src', 'hooks')
|
|
141
|
+
: path.join(packageRoot, 'src');
|
|
142
|
+
return path.relative(sourceRoot, source);
|
|
224
143
|
}
|
|
225
144
|
|
|
226
145
|
export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
@@ -228,22 +147,29 @@ export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
|
228
147
|
const claimedSources = [];
|
|
229
148
|
for (const mapping of INSTALL_TABLE) {
|
|
230
149
|
for await (const relative of fs.glob(mapping.source, { cwd: packageRoot, exclude: claimedSources })) {
|
|
231
|
-
if (posix(relative) === RULES_SOURCE) continue;
|
|
232
|
-
if (SEEDED_SOURCES.includes(posix(relative))) continue;
|
|
150
|
+
if (posix(relative) === RULES_SOURCE || SEEDED_SOURCES.includes(posix(relative))) continue;
|
|
233
151
|
const source = path.join(packageRoot, relative);
|
|
234
152
|
if (!(await fs.stat(source)).isFile()) continue;
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const target = path.join(host[mapping.target], targetRelative);
|
|
153
|
+
const relativeToTarget = posix(targetRelative(packageRoot, source, mapping));
|
|
154
|
+
const target = path.join(targetBase(host, mapping), relativeToTarget);
|
|
155
|
+
const relativeToRoot = posix(path.relative(rootFor(host, mapping), target));
|
|
239
156
|
files.push({
|
|
240
157
|
source,
|
|
241
158
|
target,
|
|
159
|
+
root: mapping.root,
|
|
160
|
+
relativeToRoot,
|
|
242
161
|
relativeToHost: posix(path.relative(host.root, target)),
|
|
243
162
|
processing: mapping.processing,
|
|
163
|
+
installationRoot: mapping.processing === 'placeholders'
|
|
164
|
+
? host.brandRunnerDir
|
|
165
|
+
: rootFor(host, mapping),
|
|
244
166
|
});
|
|
245
167
|
}
|
|
246
168
|
claimedSources.push(mapping.source);
|
|
247
169
|
}
|
|
248
|
-
return files.sort((a, b) => a.
|
|
170
|
+
return files.sort((a, b) => recordFileKey(a).localeCompare(recordFileKey(b)));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function targetForPlanItem(host, item) {
|
|
174
|
+
return recordTarget(host, fileEntry({ root: item.root, path: item.relativeToRoot }));
|
|
249
175
|
}
|
|
@@ -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/projects.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/** Implements the projects command. */
|
|
2
2
|
import { listProjectRuns, listProjects } from './runs-inventory.mjs';
|
|
3
3
|
import { renderTable } from './table.mjs';
|
|
4
|
+
import { allLiveRuns } from '../src/hooks/live-runs.mjs';
|
|
5
|
+
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
6
|
+
import { STOP_COMMAND_TEMPLATE } from '../src/stop-contract.mjs';
|
|
4
7
|
|
|
5
8
|
// The table renders whatever it is given; how a byte count or a timestamp reads is this command's
|
|
6
9
|
// business. Raw bytes and ISO milliseconds are technically exact and practically unreadable — an
|
|
@@ -75,9 +78,21 @@ function parseArgs(args) {
|
|
|
75
78
|
return { json, projectName: positional[0] || null };
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Silence when nothing is running: this line exists to interrupt the operator who believes every
|
|
83
|
+
* run is stopped, and a "0 runs" line printed under every listing is the fastest way to teach them
|
|
84
|
+
* not to read it.
|
|
85
|
+
*/
|
|
86
|
+
function workingRunsLine(root) {
|
|
87
|
+
const count = allLiveRuns(root, { requireConfirmedIdentity: true }).length;
|
|
88
|
+
if (!count) return '';
|
|
89
|
+
const noun = count === 1 ? 'run' : 'runs';
|
|
90
|
+
return `\n${count} ${noun} working right now; stop with ${STOP_COMMAND_TEMPLATE}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function output(rows, columns, options, root) {
|
|
79
94
|
if (options.json) return JSON.stringify(rows, null, 2);
|
|
80
|
-
return renderTable(columns, rows, options.terminalWidth)
|
|
95
|
+
return `${renderTable(columns, rows, options.terminalWidth)}${workingRunsLine(root)}`;
|
|
81
96
|
}
|
|
82
97
|
|
|
83
98
|
/** Lists projects or runs for one project. */
|
|
@@ -86,7 +101,7 @@ export function projects(argv = [], options = {}) {
|
|
|
86
101
|
if (parsed.error) return { exitCode: 2, output: parsed.error };
|
|
87
102
|
|
|
88
103
|
const { projectName } = parsed;
|
|
89
|
-
const root = options.runsRootPath;
|
|
104
|
+
const root = options.runsRootPath || runsRoot();
|
|
90
105
|
const renderOptions = { json: parsed.json, terminalWidth: options.terminalWidth };
|
|
91
106
|
|
|
92
107
|
if (projectName) {
|
|
@@ -97,9 +112,9 @@ export function projects(argv = [], options = {}) {
|
|
|
97
112
|
output: `codex-bridge projects: unknown project "${projectName}"`,
|
|
98
113
|
};
|
|
99
114
|
}
|
|
100
|
-
return { exitCode: 0, output: output(rows, RUN_COLUMNS, renderOptions) };
|
|
115
|
+
return { exitCode: 0, output: output(rows, RUN_COLUMNS, renderOptions, root) };
|
|
101
116
|
}
|
|
102
117
|
|
|
103
118
|
const rows = listProjects(root);
|
|
104
|
-
return { exitCode: 0, output: output(rows, PROJECT_COLUMNS, renderOptions) };
|
|
119
|
+
return { exitCode: 0, output: output(rows, PROJECT_COLUMNS, renderOptions, root) };
|
|
105
120
|
}
|
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.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Removes emptied directories of an installed layout without walking out of the package's own. */
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* These four helpers existed as two copies — one in uninstall.mjs, one in update.mjs — and the
|
|
7
|
+
* copies had already drifted before this file was written: update's removeEmptyParents inlined the
|
|
8
|
+
* readdir/rmdir pair instead of calling removeEmpty, and only uninstall's removeEmptyLayout
|
|
9
|
+
* guarded a missing directory. Nothing failed, which is exactly the problem: the same divergence
|
|
10
|
+
* between the installer's file list and the hook's list went unnoticed until Plan_19 had to
|
|
11
|
+
* reconcile them. Removal logic decides what disappears from the operator's ~/.claude, so it gets
|
|
12
|
+
* one definition.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export async function removeEmpty(directory) {
|
|
16
|
+
try {
|
|
17
|
+
if ((await fs.readdir(directory)).length === 0) await fs.rmdir(directory);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (err.code !== 'ENOENT') throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function removeEmptyParents(target, boundary) {
|
|
24
|
+
let current = path.dirname(target);
|
|
25
|
+
while (current !== boundary && current.startsWith(`${boundary}${path.sep}`)) {
|
|
26
|
+
await removeEmpty(current);
|
|
27
|
+
current = path.dirname(current);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The directory an emptied-parent walk must stop at. Plan_25 gave the host four package-owned
|
|
33
|
+
* directories at once — the current agents and commands subdirectories and the two the previous
|
|
34
|
+
* layout used — and a walk that started inside one of them but stopped at host.root would delete
|
|
35
|
+
* the operator's own emptied directories on the way up.
|
|
36
|
+
*/
|
|
37
|
+
export function claudeBoundary(host, target) {
|
|
38
|
+
const directories = [
|
|
39
|
+
host.agentsDir,
|
|
40
|
+
host.commandsDir,
|
|
41
|
+
host.legacyAgentsDir,
|
|
42
|
+
host.legacyCommandsDir,
|
|
43
|
+
].filter(Boolean);
|
|
44
|
+
return directories.find((directory) => target === directory
|
|
45
|
+
|| target.startsWith(`${directory}${path.sep}`)) || host.root;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Takes down one emptied package-owned layout directory — and stops there.
|
|
50
|
+
*
|
|
51
|
+
* It deliberately does not walk into the parent: the parents here are `~/.claude/agents` and
|
|
52
|
+
* `~/.claude/commands`, which belong to Claude Code and are shared with every other agent the
|
|
53
|
+
* operator has. An operator whose only agents were ours would have had those directories deleted
|
|
54
|
+
* out from under Claude Code by an uninstall that was asked to remove our files. Directories
|
|
55
|
+
* *inside* our own are a different case and are still walked, bounded by claudeBoundary().
|
|
56
|
+
*/
|
|
57
|
+
export async function removeEmptyLayout(directory) {
|
|
58
|
+
await removeEmpty(directory);
|
|
59
|
+
}
|
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
|
|