@commonlyai/cli 0.1.207 → 0.1.209
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/package.json +1 -1
- package/src/commands/verify.js +48 -0
- package/src/index.js +2 -0
- package/src/lib/adapters/codex.js +166 -10
package/package.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Repository verification entry point; scenario implementations live in backend. */
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { resolve, join } from 'path';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
export const SCENARIOS = {
|
|
7
|
+
wave1: '__tests__/service/verify.wave1.test.js',
|
|
8
|
+
'connector-grant': '__tests__/service/verify.connectorGrant.test.js',
|
|
9
|
+
};
|
|
10
|
+
const testDatabase = (name) => /^(test|verify)([-_0-9]|$)|[-_]test$/.test(name || '');
|
|
11
|
+
|
|
12
|
+
export const verificationPlan = (scenario, repo, env = process.env) => {
|
|
13
|
+
const suite = SCENARIOS[scenario];
|
|
14
|
+
if (!suite) throw new Error(`Unknown scenario: ${scenario}. Choose ${Object.keys(SCENARIOS).join(', ')}.`);
|
|
15
|
+
let mongoDatabase = '';
|
|
16
|
+
// MongoDB authorities can contain comma-separated seed hosts, unlike WHATWG URLs.
|
|
17
|
+
const mongoPath = /^mongodb(?:\+srv)?:\/\/[^/?#]+\/([^?#]*)/.exec(env.MONGO_URI || '');
|
|
18
|
+
try { mongoDatabase = decodeURIComponent(mongoPath?.[1] || ''); } catch { /* refused below */ }
|
|
19
|
+
if (!env.PG_HOST || !testDatabase(env.PG_DATABASE) || !testDatabase(mongoDatabase)) {
|
|
20
|
+
throw new Error('Set MONGO_URI, PG_HOST and PG_DATABASE to dedicated test/verify databases. Fixture setup clears their data.');
|
|
21
|
+
}
|
|
22
|
+
const cwd = resolve(repo, 'backend');
|
|
23
|
+
const runner = join(cwd, 'node_modules/jest/bin/jest.js');
|
|
24
|
+
if (!existsSync(join(cwd, suite)) || !existsSync(runner)) {
|
|
25
|
+
throw new Error('Use --repo with a Commonly checkout containing the scenario and installed backend dependencies.');
|
|
26
|
+
}
|
|
27
|
+
return { command: process.execPath, args: [runner, '--runInBand', '--forceExit', '--runTestsByPath', suite,
|
|
28
|
+
...(env.COMMONLY_VERIFY_CACHE_DIR ? ['--cacheDirectory', env.COMMONLY_VERIFY_CACHE_DIR] : [])],
|
|
29
|
+
options: { cwd, stdio: 'inherit', env: { ...env, INTEGRATION_TEST: 'true' } } };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const registerVerify = (program, run = spawnSync) => {
|
|
33
|
+
program.command('verify <scenario>')
|
|
34
|
+
.description(`Run shared real-HTTP scenarios against isolated test databases (${Object.keys(SCENARIOS).join(', ')})`)
|
|
35
|
+
.option('--repo <path>', 'Commonly checkout with backend test dependencies', process.cwd())
|
|
36
|
+
.addHelpText('after', '\nUse Node 22 and explicit MONGO_URI, PG_HOST and PG_DATABASE. Fixture databases are cleared.\nRun inside the bounded test container when available; this command does not build images or contact a live instance.\n')
|
|
37
|
+
.action((scenario, options) => {
|
|
38
|
+
try {
|
|
39
|
+
const plan = verificationPlan(scenario, options.repo);
|
|
40
|
+
const result = run(plan.command, plan.args, plan.options);
|
|
41
|
+
if (result.error) throw result.error;
|
|
42
|
+
process.exitCode = result.status === null ? 1 : result.status;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(error.message);
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
};
|
package/src/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { registerAgent } from './commands/agent.js';
|
|
|
20
20
|
import { registerDaemon } from './commands/daemon.js';
|
|
21
21
|
import { registerPod } from './commands/pod.js';
|
|
22
22
|
import { registerDev } from './commands/dev.js';
|
|
23
|
+
import { registerVerify } from './commands/verify.js';
|
|
23
24
|
|
|
24
25
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
26
|
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
|
|
@@ -51,6 +52,7 @@ registerPod(program);
|
|
|
51
52
|
|
|
52
53
|
// Local dev environment
|
|
53
54
|
registerDev(program);
|
|
55
|
+
registerVerify(program);
|
|
54
56
|
|
|
55
57
|
// Each subcommand owns its own `--instance <url>` flag; a program-level
|
|
56
58
|
// duplicate shadowed the subcommand value on commander v12, causing
|
|
@@ -43,7 +43,7 @@ import { spawnInOwnGroup, wantsOwnProcessGroup } from '@commonlyai/seats/process
|
|
|
43
43
|
import { requireSeatModel, seatModelRequired } from './seat-model.js';
|
|
44
44
|
import { spawn as childSpawn, spawnSync } from 'child_process';
|
|
45
45
|
import { createHash } from 'crypto';
|
|
46
|
-
import { accessSync, constants } from 'fs';
|
|
46
|
+
import { accessSync, constants, existsSync, realpathSync } from 'fs';
|
|
47
47
|
import {
|
|
48
48
|
chmod,
|
|
49
49
|
lstat,
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
mkdtemp,
|
|
52
52
|
readFile,
|
|
53
53
|
readlink,
|
|
54
|
+
realpath,
|
|
54
55
|
rename,
|
|
55
56
|
rm,
|
|
56
57
|
symlink,
|
|
@@ -58,10 +59,15 @@ import {
|
|
|
58
59
|
writeFile,
|
|
59
60
|
} from 'fs/promises';
|
|
60
61
|
import { homedir, tmpdir } from 'os';
|
|
62
|
+
import { createRequire } from 'module';
|
|
61
63
|
import {
|
|
64
|
+
basename,
|
|
65
|
+
delimiter,
|
|
62
66
|
dirname,
|
|
67
|
+
isAbsolute,
|
|
63
68
|
join,
|
|
64
69
|
resolve as pathResolve,
|
|
70
|
+
sep,
|
|
65
71
|
} from 'path';
|
|
66
72
|
import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
|
|
67
73
|
import { supportedGitHooks } from '../commit-attribution.js';
|
|
@@ -279,20 +285,116 @@ const statOrNull = async (path) => {
|
|
|
279
285
|
// therefore expose global private instructions even though shell reads of
|
|
280
286
|
// ~/.codex are denied. Give every public wrapper identity its own persistent
|
|
281
287
|
// Codex home for sessions/state, with only an auth symlink back to the
|
|
282
|
-
// operator credential.
|
|
283
|
-
// permission profile denies to model-generated commands
|
|
284
|
-
|
|
288
|
+
// operator credential. On macOS the symlink lives under ~/.commonly, which the
|
|
289
|
+
// permission profile denies to model-generated commands; on Linux (below) its
|
|
290
|
+
// target, ~/.codex, is denied instead.
|
|
291
|
+
//
|
|
292
|
+
// On Linux the home cannot sit under ~/.commonly. Codex (0.164 and later)
|
|
293
|
+
// runs its file helper by exec'ing `$CODEX_HOME/tmp/arg0-linux/.../
|
|
294
|
+
// codex-linux-sandbox` inside bwrap, and grants read on that one file, but a
|
|
295
|
+
// denied ancestor always wins, so the profile's ~/.commonly deny made every
|
|
296
|
+
// public session fail at bootstrap with "bwrap: execvp ... Permission denied".
|
|
297
|
+
// Outside every granted root the home is still unreadable to model commands:
|
|
298
|
+
// Codex binds the helper file alone, not the home around it (measured on
|
|
299
|
+
// codex-cli 0.164.0, where a home there reached the model call). That holds
|
|
300
|
+
// only while the workspace does not contain the home, so such a workspace is
|
|
301
|
+
// refused. macOS has no arg0 helper and keeps its homes where they were.
|
|
302
|
+
const LEGACY_CODEX_HOMES = (home) => join(home, '.commonly', 'codex-homes');
|
|
303
|
+
|
|
304
|
+
const contains = (outer, inner) => inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : outer + sep);
|
|
305
|
+
|
|
306
|
+
const stateHomeUnderCommonly = (ctx, home) => new Error(
|
|
307
|
+
`XDG_STATE_HOME (${ctx.env?.XDG_STATE_HOME}) puts the public Codex home under ${join(home, '.commonly')}, `
|
|
308
|
+
+ 'which the public sandbox denies on Linux. Point it elsewhere or unset it.',
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const publicCodexHomesRoot = (ctx) => {
|
|
312
|
+
const home = ctx._homeDir || homedir();
|
|
313
|
+
if ((ctx._platform || process.platform) !== 'linux') return LEGACY_CODEX_HOMES(home);
|
|
314
|
+
const stateHome = ctx.env?.XDG_STATE_HOME;
|
|
315
|
+
const root = join(stateHome && isAbsolute(stateHome) ? stateHome : join(home, '.local', 'state'),
|
|
316
|
+
'commonly', 'codex-homes');
|
|
317
|
+
// An override that lands back under the denied tree would bring the
|
|
318
|
+
// bootstrap failure above back with it. Checked here by name, and again
|
|
319
|
+
// through symlinks before anything is created (preparePublicCodexHome).
|
|
320
|
+
if (contains(pathResolve(home, '.commonly'), pathResolve(root))) throw stateHomeUnderCommonly(ctx, home);
|
|
321
|
+
return root;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// The real path of a path that may not exist yet: its nearest existing
|
|
325
|
+
// ancestor resolved, with the rest appended.
|
|
326
|
+
const realpathOfNearest = async (path) => {
|
|
327
|
+
const rest = [];
|
|
328
|
+
let current = pathResolve(path);
|
|
329
|
+
for (;;) {
|
|
330
|
+
try {
|
|
331
|
+
return join(await realpath(current), ...rest);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
// Only a missing path walks up. Anything else (EACCES, ELOOP) would leave
|
|
334
|
+
// the containment check comparing a path nobody resolved: fail closed.
|
|
335
|
+
if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') throw err;
|
|
336
|
+
const parent = dirname(current);
|
|
337
|
+
if (parent === current) return join(current, ...rest);
|
|
338
|
+
rest.unshift(basename(current));
|
|
339
|
+
current = parent;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const preparePublicCodexHome = async (ctx, childEnv) => {
|
|
285
345
|
const operatorHome = ctx.env?.CODEX_HOME
|
|
286
346
|
|| process.env.CODEX_HOME
|
|
287
347
|
|| join(homedir(), '.codex');
|
|
288
348
|
const identity = ctx.agentName || ctx.cwd || 'anonymous';
|
|
289
349
|
const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
|
|
290
|
-
const
|
|
291
|
-
|
|
350
|
+
const homesRoot = publicCodexHomesRoot(ctx);
|
|
351
|
+
const publicHome = ctx._publicCodexHome || join(homesRoot, identityHash);
|
|
292
352
|
if (pathResolve(publicHome) === pathResolve(operatorHome)) {
|
|
293
353
|
throw new Error('public codex home must be isolated from the operator CODEX_HOME');
|
|
294
354
|
}
|
|
295
355
|
|
|
356
|
+
// Checked before anything is created or moved, so a refused workspace
|
|
357
|
+
// never receives the home or a migrated seat's sessions.
|
|
358
|
+
if (!ctx._publicCodexHome && homesRoot !== LEGACY_CODEX_HOMES(ctx._homeDir || homedir())) {
|
|
359
|
+
const home = ctx._homeDir || homedir();
|
|
360
|
+
const [realRoot, realCommonly] = await Promise.all([realpathOfNearest(homesRoot), realpathOfNearest(join(home, '.commonly'))]);
|
|
361
|
+
if (contains(realCommonly, realRoot)) throw stateHomeUnderCommonly(ctx, home);
|
|
362
|
+
const workspacePath = pathResolve(ctx.cwd || process.cwd());
|
|
363
|
+
const [workspace, realHome] = await Promise.all([
|
|
364
|
+
// As in realpathOfNearest: only a missing workspace falls back to its own path.
|
|
365
|
+
realpath(workspacePath).catch((err) => {
|
|
366
|
+
if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return workspacePath;
|
|
367
|
+
throw err;
|
|
368
|
+
}),
|
|
369
|
+
realpathOfNearest(publicHome),
|
|
370
|
+
]);
|
|
371
|
+
if (contains(workspace, realHome)) {
|
|
372
|
+
throw new Error(`public codex agents cannot run in ${ctx.cwd}: it contains the agent's own `
|
|
373
|
+
+ `Codex home (${publicHome}), which the sandbox would then expose. Run it in a project folder.`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// A Linux seat that ran before the move keeps its sessions, so a resumed
|
|
378
|
+
// thread still resumes. Across filesystems it starts fresh instead, and
|
|
379
|
+
// says so: the old sessions stay where they were, untouched, and a copy
|
|
380
|
+
// that failed halfway would leave a home later runs never migrate again.
|
|
381
|
+
const legacyHome = join(LEGACY_CODEX_HOMES(ctx._homeDir || homedir()), identityHash);
|
|
382
|
+
if (!ctx._publicCodexHome && legacyHome !== publicHome && !(await statOrNull(publicHome))) {
|
|
383
|
+
const legacy = await statOrNull(legacyHome);
|
|
384
|
+
if (legacy?.isDirectory()) {
|
|
385
|
+
await mkdir(homesRoot, { recursive: true, mode: 0o700 });
|
|
386
|
+
try {
|
|
387
|
+
await rename(legacyHome, publicHome);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
if (err?.code !== 'EXDEV') throw err;
|
|
390
|
+
console.warn(
|
|
391
|
+
`[codex] ${legacyHome} is on another filesystem from ${publicHome}; starting a fresh Codex home. `
|
|
392
|
+
+ 'Earlier sessions stay in the old home and will not resume; move it there by hand to keep them.',
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
296
398
|
await mkdir(publicHome, { recursive: true, mode: 0o700 });
|
|
297
399
|
await chmod(publicHome, 0o700);
|
|
298
400
|
|
|
@@ -317,7 +419,53 @@ const preparePublicCodexHome = async (ctx) => {
|
|
|
317
419
|
// fail closed with its normal "not logged in" error.
|
|
318
420
|
await unlink(targetAuth);
|
|
319
421
|
}
|
|
320
|
-
|
|
422
|
+
// Model commands are sandboxed through the arg0 helper in this folder, a
|
|
423
|
+
// symlink to Codex's own binary, and unlike the file helper that path grants
|
|
424
|
+
// itself neither the folder nor the binary. Without both, every command
|
|
425
|
+
// fails with "bwrap: execvp ... No such file or directory" (measured on
|
|
426
|
+
// codex-cli 0.164.0 with a user-prefix npm install). Only where the home
|
|
427
|
+
// sits outside every deny can the folder's grant take effect.
|
|
428
|
+
const helperReadPaths = [];
|
|
429
|
+
if (homesRoot !== LEGACY_CODEX_HOMES(ctx._homeDir || homedir())) {
|
|
430
|
+
const helperDirectory = join(publicHome, 'tmp', 'arg0-linux');
|
|
431
|
+
await mkdir(helperDirectory, { recursive: true, mode: 0o700 });
|
|
432
|
+
helperReadPaths.push(helperDirectory);
|
|
433
|
+
// Looked up on the PATH the child gets, so the grant names the binary it runs.
|
|
434
|
+
const binary = (ctx._codexNativeBinary || codexNativeBinary)(childEnv);
|
|
435
|
+
if (binary) helperReadPaths.push(binary);
|
|
436
|
+
}
|
|
437
|
+
return { home: publicHome, helperReadPaths };
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// The native binary `codex` on PATH runs: itself when it is one, or the one
|
|
441
|
+
// the npm launcher (`bin/codex.js`) picks, resolved the way that launcher does.
|
|
442
|
+
// Only the Linux targets are named because only the Linux helper needs this.
|
|
443
|
+
const LINUX_CODEX_TARGETS = { x64: 'x86_64-unknown-linux-musl', arm64: 'aarch64-unknown-linux-musl' };
|
|
444
|
+
|
|
445
|
+
export const codexNativeBinary = (env) => {
|
|
446
|
+
const onPath = String(env?.PATH || '').split(delimiter).filter(Boolean)
|
|
447
|
+
.map((dir) => join(dir, 'codex'))
|
|
448
|
+
.find((candidate) => {
|
|
449
|
+
try {
|
|
450
|
+
accessSync(candidate, constants.X_OK);
|
|
451
|
+
return true;
|
|
452
|
+
} catch {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
if (!onPath) return null;
|
|
457
|
+
const real = realpathSync(onPath);
|
|
458
|
+
if (basename(real) !== 'codex.js') return real;
|
|
459
|
+
const target = LINUX_CODEX_TARGETS[process.arch];
|
|
460
|
+
if (!target) return null;
|
|
461
|
+
const packageRoot = dirname(dirname(real));
|
|
462
|
+
let vendorRoot = join(packageRoot, 'vendor');
|
|
463
|
+
try {
|
|
464
|
+
const platformPackage = createRequire(real).resolve(`@openai/codex-linux-${process.arch}/package.json`);
|
|
465
|
+
vendorRoot = join(dirname(platformPackage), 'vendor');
|
|
466
|
+
} catch { /* the launcher falls back to its own vendor folder too */ }
|
|
467
|
+
const binary = join(vendorRoot, target, 'bin', 'codex');
|
|
468
|
+
return existsSync(binary) ? realpathSync(binary) : null;
|
|
321
469
|
};
|
|
322
470
|
|
|
323
471
|
// A public seat ran with none of its declared skills: this adapter never read
|
|
@@ -385,6 +533,7 @@ const publicPermissionProfileFlags = (
|
|
|
385
533
|
mode,
|
|
386
534
|
commitAttributionEnv = null,
|
|
387
535
|
mcpReadDirectories = [],
|
|
536
|
+
codexHelperReadPaths = [],
|
|
388
537
|
) => {
|
|
389
538
|
if (!PUBLIC_SANDBOX_MODES.has(mode)) {
|
|
390
539
|
throw new Error(
|
|
@@ -427,6 +576,7 @@ const publicPermissionProfileFlags = (
|
|
|
427
576
|
...(commitHooksPath ? [`${toml(commitHooksPath)}="read"`] : []),
|
|
428
577
|
...originalHookPaths.map((hookPath) => `${toml(hookPath)}="read"`),
|
|
429
578
|
...mcpReadDirectories.map((path) => `${toml(path)}="read"`),
|
|
579
|
+
...codexHelperReadPaths.map((path) => `${toml(path)}="read"`),
|
|
430
580
|
].join(',');
|
|
431
581
|
const shellEnvironment = [
|
|
432
582
|
'PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_*',
|
|
@@ -477,6 +627,7 @@ const buildArgs = ({
|
|
|
477
627
|
effort = null,
|
|
478
628
|
commitAttributionEnv = null,
|
|
479
629
|
mcpReadDirectories = [],
|
|
630
|
+
codexHelperReadPaths = [],
|
|
480
631
|
}) => {
|
|
481
632
|
const publicSandbox = publicSandboxMode !== null;
|
|
482
633
|
// `--dangerously-bypass-approvals-and-sandbox` disables codex CLI's
|
|
@@ -500,6 +651,7 @@ const buildArgs = ({
|
|
|
500
651
|
publicSandboxMode,
|
|
501
652
|
commitAttributionEnv,
|
|
502
653
|
mcpReadDirectories,
|
|
654
|
+
codexHelperReadPaths,
|
|
503
655
|
),
|
|
504
656
|
]
|
|
505
657
|
: ['--dangerously-bypass-approvals-and-sandbox'];
|
|
@@ -712,6 +864,10 @@ export default {
|
|
|
712
864
|
const publicSandboxMode = sandbox?.trust === 'public'
|
|
713
865
|
? sandbox?.mode ?? 'workspace'
|
|
714
866
|
: null;
|
|
867
|
+
const childEnv = { ...(ctx.env || process.env), ...mcp.forwardedEnv };
|
|
868
|
+
const publicCodexHome = publicSandboxMode !== null
|
|
869
|
+
? await preparePublicCodexHome(ctx, childEnv)
|
|
870
|
+
: null;
|
|
715
871
|
const args = buildArgs({
|
|
716
872
|
sessionId: ctx.sessionId || null,
|
|
717
873
|
prompt: fullPrompt,
|
|
@@ -722,9 +878,9 @@ export default {
|
|
|
722
878
|
model: seatModel,
|
|
723
879
|
effort: ctx.environment?.effort,
|
|
724
880
|
commitAttributionEnv: ctx.commitAttributionEnv,
|
|
881
|
+
codexHelperReadPaths: publicCodexHome?.helperReadPaths || [],
|
|
725
882
|
});
|
|
726
|
-
|
|
727
|
-
// Derived from the process environment, so the bootstrap export has to be
|
|
883
|
+
// childEnv is derived from the process environment, so the bootstrap export has to be
|
|
728
884
|
// taken back out: the declaration above moved our server onto the file,
|
|
729
885
|
// and a value left here is a value codex's own children inherit. Kept only
|
|
730
886
|
// when the substitution genuinely needed it (`forwardedEnv` names that
|
|
@@ -737,7 +893,7 @@ export default {
|
|
|
737
893
|
Object.assign(childEnv, ctx.commitAttributionEnv);
|
|
738
894
|
}
|
|
739
895
|
if (publicSandboxMode !== null) {
|
|
740
|
-
childEnv.CODEX_HOME =
|
|
896
|
+
childEnv.CODEX_HOME = publicCodexHome.home;
|
|
741
897
|
await writePublicCodexInstructions(childEnv.CODEX_HOME, ctx);
|
|
742
898
|
}
|
|
743
899
|
|