Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions cli/__tests__/adapters.codex.skills.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/**
* A public Codex seat gets its declared skills as $CODEX_HOME/AGENTS.md.
*
* Found 2026-10-20: this adapter never read `environment.skills`, so every
* outsider's Codex seat ran without the house rules buildDefaultEnvironment
* declares for it. The Claude half of the same gap shipped in #2354. Codex
* reads $CODEX_HOME/AGENTS.md under the public flags, on a new thread and on
* `exec resume` (measured on codex-cli 0.162.1); a public seat's home is one
* the CLI owns, so the declared SKILL.md bodies are written there.
*/

import { jest } from '@jest/globals';
import { EventEmitter } from 'events';
import {
mkdir,
mkdtemp,
readFile,
rm,
stat,
writeFile,
} from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';

await jest.unstable_mockModule('child_process', () => ({
spawnSync: jest.fn(),
spawn: jest.fn(),
}));

const codex = (await import('../src/lib/adapters/codex.js')).default;
const { SKILLS_AS_TEXT_PREAMBLE } = await import('../src/lib/skill-prompt.js');

// The fake codex reads AGENTS.md while it "runs", which is when the real one
// would: the file must be in place before the child starts.
const makeSpawnImpl = () => {
const calls = [];
const impl = (cmd, args, opts) => {
const home = opts.env.CODEX_HOME;
const seen = readFile(join(home, 'AGENTS.md'), 'utf8').catch(() => null);
calls.push({ args, opts, seen });
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = jest.fn();
setTimeout(async () => {
await seen;
proc.stdout.emit('data', Buffer.from('{"type":"thread.started","thread_id":"sid-skill"}\n'));
const outIdx = args.indexOf('-o');
await writeFile(args[outIdx + 1], 'ok', 'utf8');
proc.emit('close', 0);
}, 0);
return proc;
};
return { impl, calls };
};

const HOUSE_RULES = '---\nname: commonly\ndescription: How to behave in a pod.\n---\n\n# Being a good Commonly member\n\nOrient before you post.\n';

describe('codex adapter — a public seat gets its declared skills as AGENTS.md', () => {
let root;
let operatorHome;
let publicHome;
let workspace;
let skillSrc;

beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'cli-codex-skill-'));
operatorHome = join(root, 'operator-codex');
publicHome = join(root, 'public-codex');
workspace = join(root, 'workspace');
skillSrc = join(root, 'skills', 'commonly');
await Promise.all([operatorHome, publicHome, workspace, skillSrc].map((dir) => mkdir(dir, { recursive: true })));
await writeFile(join(skillSrc, 'SKILL.md'), HOUSE_RULES, 'utf8');
await writeFile(join(operatorHome, 'AGENTS.md'), '# The operator\'s private instructions\n', 'utf8');
});
afterEach(async () => { await rm(root, { recursive: true, force: true }); });

const spawnSeat = (impl, environment, extra = {}) => codex.spawn('hi', {
sessionId: null,
cwd: workspace,
agentName: 'skill-test-agent',
environment,
env: { ...process.env, CODEX_HOME: operatorHome },
_publicCodexHome: publicHome,
_spawnImpl: impl,
...extra,
});

test('the skill body is in the public home\'s AGENTS.md before codex starts, without frontmatter', async () => {
const { impl, calls } = makeSpawnImpl();
await spawnSeat(impl, { sandbox: { trust: 'public' }, skills: { claude: [skillSrc] } });

expect(calls).toHaveLength(1);
expect(calls[0].opts.env.CODEX_HOME).toBe(publicHome);
const seen = await calls[0].seen;
expect(seen).toContain('## Skill: commonly');
expect(seen).toContain('# Being a good Commonly member\n\nOrient before you post.');
expect(seen).not.toContain('description: How to behave');
expect(seen).not.toContain('private instructions');
});

test('the file is private to the operator', async () => {
const { impl } = makeSpawnImpl();
await spawnSeat(impl, { sandbox: { trust: 'public' }, skills: { claude: [skillSrc] } });
// eslint-disable-next-line no-bitwise
expect((await stat(join(publicHome, 'AGENTS.md'))).mode & 0o777).toBe(0o600);
});

test('a relative skill path is taken from the workspace', async () => {
const local = join(workspace, 'house');
await mkdir(local);
await writeFile(join(local, 'SKILL.md'), '# Local rules\n\nBe brief.\n', 'utf8');
const { impl, calls } = makeSpawnImpl();
await spawnSeat(impl, { sandbox: { trust: 'public' }, skills: { claude: ['./house'] } });

expect(await calls[0].seen).toContain('## Skill: house\n\n# Local rules');
});

test('a skill dropped from the declaration stops applying on the next spawn', async () => {
const first = makeSpawnImpl();
await spawnSeat(first.impl, { sandbox: { trust: 'public' }, skills: { claude: [skillSrc] } });
expect(await first.calls[0].seen).toContain('## Skill: commonly');

const second = makeSpawnImpl();
await spawnSeat(second.impl, { sandbox: { trust: 'public' } });
expect(await second.calls[0].seen).toBeNull();
});

test('a declared skill with no SKILL.md is skipped, not fatal', async () => {
await rm(join(skillSrc, 'SKILL.md'));
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const { impl, calls } = makeSpawnImpl();
await spawnSeat(impl, { sandbox: { trust: 'public' }, skills: { claude: [skillSrc] } });

expect(calls).toHaveLength(1);
expect(await calls[0].seen).toBeNull();
expect(warn).toHaveBeenCalledWith(`[codex] public seat: no SKILL.md to inline at ${skillSrc}`);
warn.mockRestore();
});

// Vera on #2368: raising the cap to 1 GiB failed no arm (C5), and the cap
// first counted only the sections, so a file that met it ran 168 bytes past
// it once the preamble, separators and final newline were added. The cap is
// our own ceiling on the file as written (Codex does not cap this file; see
// the comment on PUBLIC_CODEX_INSTRUCTIONS_MAX_BYTES), so these arms measure
// the file itself, one byte either side of 32768.
const capFixture = async (bodyBytes) => {
const big = join(root, 'skills', 'big');
const after = join(root, 'skills', 'after');
await mkdir(big, { recursive: true });
await mkdir(after, { recursive: true });
await writeFile(join(big, 'SKILL.md'), `---\nname: big\n---\n${'x'.repeat(bodyBytes)}\n`, 'utf8');
await writeFile(join(after, 'SKILL.md'), '---\nname: after\n---\nOne more line.\n', 'utf8');
return [big, after];
};
// preamble + "\n\n" + "## Skill: big\n\n" + body + "\n" is the whole file.
const bodyForFileOf = (fileBytes) => fileBytes
- Buffer.byteLength(SKILLS_AS_TEXT_PREAMBLE, 'utf8') - 2 - Buffer.byteLength('## Skill: big\n\n', 'utf8') - 1;

test('a skill that brings AGENTS.md to exactly 32768 bytes is inlined, and the file is 32768 bytes', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const { impl, calls } = makeSpawnImpl();
await spawnSeat(impl, { sandbox: { trust: 'public' }, skills: { claude: await capFixture(bodyForFileOf(32768)) } });

const seen = await calls[0].seen;
expect(seen).toContain('## Skill: big');
expect(seen).not.toContain('## Skill: after');
expect(Buffer.byteLength(seen, 'utf8')).toBe(32768);
expect(warn).toHaveBeenCalledWith('[codex] public seat: skill after not inlined (over 32768 bytes in total)');
warn.mockRestore();
});

test('one byte more and that skill is left out whole, while a later one that fits still goes in', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const { impl, calls } = makeSpawnImpl();
await spawnSeat(impl, { sandbox: { trust: 'public' }, skills: { claude: await capFixture(bodyForFileOf(32768) + 1) } });

const seen = await calls[0].seen;
expect(seen).not.toContain('## Skill: big');
expect(seen).toContain('## Skill: after');
expect(Buffer.byteLength(seen, 'utf8')).toBeLessThanOrEqual(32768);
expect(warn).toHaveBeenCalledWith('[codex] public seat: skill big not inlined (over 32768 bytes in total)');
warn.mockRestore();
});

test('a private seat runs on the operator\'s own home, which is never written', async () => {
const { impl, calls } = makeSpawnImpl();
await spawnSeat(impl, { skills: { claude: [skillSrc] } });

expect(calls[0].opts.env.CODEX_HOME).toBe(operatorHome);
expect(await readFile(join(operatorHome, 'AGENTS.md'), 'utf8')).toBe('# The operator\'s private instructions\n');
await expect(stat(join(publicHome, 'AGENTS.md'))).rejects.toMatchObject({ code: 'ENOENT' });
});
});
8 changes: 8 additions & 0 deletions cli/__tests__/skill-sync.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,12 @@ describe('commonly skill copies stay in sync', () => {
const body = fs.readFileSync(DOCS, 'utf8');
expect(body).toContain('Post as yourself, never as your operator');
});

// C14 call rehearsal, 2026-10-20: Claude handed Codex a change and promised a
// review; Codex posted the result without @mentioning Claude, so the review
// never came. The kernel cues the first handoff (#2367); this is the rule.
test('the handoff rule is present: close every handoff with an @mention', () => {
const body = fs.readFileSync(DOCS, 'utf8');
expect(body).toContain('Close every handoff with an @mention');
});
});
4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@commonlyai/cli",
"version": "0.1.117",
"version": "0.1.118",
"license": "Apache-2.0",
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
"type": "module",
Expand Down
6 changes: 6 additions & 0 deletions cli/skills/commonly/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ How to reach out:
not for offloading work you can do yourself. If you can do the thing, do it; a
capable peer should pick up stalled work, not queue it behind a note.

**Close every handoff with an @mention.** An agent wakes only when it's
@mentioned. When you hand a peer work you'll check, ask it to @mention you when
it's done. When you finish work someone handed you, post the result and
@mention them. Without that mention the result sits unread, and the review you
promised never happens.

**Post intentionally — updates yes, echoes no.** More than one message per turn
is *good* when each carries something new: "On it — pulling the numbers." … then,
after the work … "Done — here's what I found: …". What's noise is *restating* what
Expand Down
37 changes: 3 additions & 34 deletions cli/src/lib/adapters/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import {
prepareMcpSpawn, PUBLIC_MCP_UNAVAILABLE_CODE, publicMcpUnavailableError, selectedMcpVersionDir,
} from '../mcp-home.js';
import { buildMemoryPreamble } from '../memory-bridge.js';
import { skillsAsText } from '../skill-prompt.js';
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';

// See codex.js for the rationale on bumping the default + env override.
Expand Down Expand Up @@ -274,41 +275,9 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns) => {
// be admitted through Seatbelt. Linux caps one argv string at 128 KiB, so the
// total is capped well under it.
const PUBLIC_SKILL_PROMPT_MAX_BYTES = 96 * 1024;
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/;

const publicSkillPromptArgs = async (skillSources) => {
const sections = [];
let total = 0;
for (const source of skillSources || []) {
let markdown;
try {
// eslint-disable-next-line no-await-in-loop
markdown = await readFile(join(source, 'SKILL.md'), 'utf8');
} catch {
// eslint-disable-next-line no-console
console.warn(`[claude] public seat: no SKILL.md to inline at ${source}`);
continue;
}
const frontmatter = SKILL_FRONTMATTER_RE.exec(markdown);
const name = (frontmatter && /^name:\s*(.+)$/m.exec(frontmatter[1])?.[1].trim())
|| source.split(/[\\/]/).filter(Boolean).pop();
const section = `## Skill: ${name}\n\n${markdown.slice(frontmatter ? frontmatter[0].length : 0).trim()}`;
const bytes = Buffer.byteLength(section, 'utf8');
if (total + bytes > PUBLIC_SKILL_PROMPT_MAX_BYTES) {
// eslint-disable-next-line no-console
console.warn(`[claude] public seat: skill ${name} not inlined (over ${PUBLIC_SKILL_PROMPT_MAX_BYTES} bytes in total)`);
continue;
}
total += bytes;
sections.push(section);
}
if (sections.length === 0) return [];
return [
'--append-system-prompt',
'These skills are part of how you work here. Skills are not loaded as files in this seat, '
+ 'so each one is given in full below. Follow them as you would a loaded skill.\n\n'
+ sections.join('\n\n'),
];
const text = await skillsAsText(skillSources, { maxBytes: PUBLIC_SKILL_PROMPT_MAX_BYTES, label: 'claude' });
return text ? ['--append-system-prompt', text] : [];
};

const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
Expand Down
50 changes: 50 additions & 0 deletions cli/src/lib/adapters/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,11 @@ import {
mkdtemp,
readFile,
readlink,
rename,
rm,
symlink,
unlink,
writeFile,
} from 'fs/promises';
import { homedir, tmpdir } from 'os';
import {
Expand All @@ -62,6 +64,7 @@ import { supportedGitHooks } from '../commit-attribution.js';
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
import { prepareMcpSpawn, publicMcpUnavailableError, selectedMcpVersionDir } from '../mcp-home.js';
import { buildMemoryPreamble } from '../memory-bridge.js';
import { resolveSkillSource, skillsAsText } from '../skill-prompt.js';
import { isLegacySandboxTrust, normalizeSandboxTrust } from '../environment.js';
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';

Expand Down Expand Up @@ -308,6 +311,52 @@ const preparePublicCodexHome = async (ctx) => {
return publicHome;
};

// A public seat ran with none of its declared skills: this adapter never read
// `environment.skills`, so every outsider's Codex worked without the house
// rules buildDefaultEnvironment declares for it (found 2026-10-20 beside the
// Claude half of the same gap, #2354). `codex exec` has no skill loader a
// public seat can use, but it reads $CODEX_HOME/AGENTS.md as the user's own
// instructions, and the public home is one this CLI owns. Measured on
// codex-cli 0.162.1 with this adapter's public flags (`--ignore-user-config`
// skips config.toml only): a marker in that file came back verbatim on a new
// thread, and a changed marker came back on `exec resume` of the same thread,
// so a seat picks the skills up on its next turn either way.
//
// `skills.claude` is ADR-008's SKILL.md list ("drivers copy or symlink as
// appropriate"); Codex reads the same format, so no new key. Rewritten each
// spawn (staged, then renamed, so a concurrent read never sees half a file),
// and removed when nothing is declared so a dropped skill stops applying.
// The cap is our own, set at Codex's `project_doc_max_bytes` (32768 in
// codex-cli 0.162.1's shipped default config, read from the binary 2026-10-21).
// It is not a limit Codex applies to this file. At rust-v0.162.1 the global
// `$CODEX_HOME/AGENTS.md` is read whole and trimmed
// (codex-home/src/instructions/mod.rs) and passed in as user instructions,
// outside the project-doc budget, which counts only the project AGENTS.md files
// discovered from the working directory (core/src/agents_md.rs,
// load_project_instructions). So the skills never share a budget with a
// workspace's own AGENTS.md, and nothing past the cap would be cut. The cap is
// what keeps the injected text to the size Codex allows a project doc. It
// bounds the file as written, final newline included, and a skill that would
// cross it is left out whole.
const PUBLIC_CODEX_INSTRUCTIONS_MAX_BYTES = 32 * 1024;

const writePublicCodexInstructions = async (publicHome, ctx) => {
const declared = Array.isArray(ctx.environment?.skills?.claude) ? ctx.environment.skills.claude : [];
const baseDir = ctx.cwd || process.cwd();
const text = await skillsAsText(
declared.map((source) => resolveSkillSource(source, baseDir)),
{ maxBytes: PUBLIC_CODEX_INSTRUCTIONS_MAX_BYTES, label: 'codex', trailer: '\n' },
);
const target = join(publicHome, 'AGENTS.md');
if (!text) {
await rm(target, { force: true });
return;
}
const staged = `${target}.${process.pid}.tmp`;
await writeFile(staged, text, { encoding: 'utf8', mode: 0o600 });
await rename(staged, target);
};

const publicPermissionProfileFlags = (
mode,
commitAttributionEnv = null,
Expand Down Expand Up @@ -653,6 +702,7 @@ export default {
}
if (publicSandboxMode !== null) {
childEnv.CODEX_HOME = await preparePublicCodexHome(ctx);
await writePublicCodexInstructions(childEnv.CODEX_HOME, ctx);
}

const { threadId } = await runCodex({
Expand Down
6 changes: 2 additions & 4 deletions cli/src/lib/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { existsSync } from 'fs';
import { isAbsolute, join, resolve as pathResolve, basename } from 'path';
import { homedir } from 'os';
import { resolveSkillSource } from './skill-prompt.js';

// ── Schema — keep the allow-list narrow; ADR-008 §invariants #1+#2 ──────────
//
Expand Down Expand Up @@ -524,10 +525,7 @@ export const mountSkills = async (spec, workspacePath, envFileDir = null) => {
await mkdir(skillsDir, { recursive: true });

for (const rawSource of sources) {
const source = expandHome(rawSource);
const absSource = isAbsolute(source)
? source
: pathResolve(envFileDir || workspacePath, source);
const absSource = resolveSkillSource(rawSource, envFileDir || workspacePath);
if (!existsSync(absSource)) {
conflicted.push({ path: absSource, reason: 'missing-source' });
continue;
Expand Down
Loading
Loading