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
161 changes: 161 additions & 0 deletions cli/__tests__/adapters.claude.environment.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -986,3 +986,164 @@ describe('claude adapter — ctx.environment', () => {
expect(calls[0].opts.env.COMMONLY_AGENT_TOKEN).toBeUndefined();
});
});

// A public seat runs with `--disable-slash-commands` ("Disable all skills") and
// `--setting-sources ''`, so the skill this adapter mounts never loaded there:
// a Bring your own page seat said "I don't see a skill named commonly" on
// 2026-10-19, and the same workspace with `--setting-sources project` found it.
// The declared text now rides as an appended system prompt while both flags stay.
describe('claude adapter — a public seat gets its declared skills as prompt text', () => {
let cwd;
let skillSrc;
let publicState;
const originalPlatform = process.platform;
const appended = (args) => {
const at = args.indexOf('--append-system-prompt');
return at === -1 ? null : args[at + 1];
};

beforeEach(() => {
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-skill-prompt-'));
publicState = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-public-state-'));
skillSrc = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-skill-src-')), 'house-rules');
fs.mkdirSync(skillSrc);
fs.writeFileSync(
path.join(skillSrc, 'SKILL.md'),
'---\nname: house-rules\ndescription: How to behave in a pod.\n---\n\n# Being a member\n\nOrient before you post.\n',
'utf8',
);
Object.defineProperty(process, 'platform', { value: 'darwin' });
spawnSync.mockImplementation((cmd) => (
cmd === 'which' ? { status: 0, stdout: '/usr/bin/true\n' } : { status: 0, stdout: '' }
));
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
spawnSync.mockReset();
for (const dir of [cwd, publicState, path.dirname(skillSrc)]) fs.rmSync(dir, { recursive: true, force: true });
});

const publicSpawn = (impl, extra = {}) => claude.spawn('hi', {
agentName: 'public-skill-test',
sessionId: null,
cwd,
env: { PATH: process.env.PATH },
environment: {
sandbox: { mode: 'workspace', trust: 'public' },
skills: { claude: [skillSrc] },
},
_publicClaudeState: publicState,
_spawnImpl: impl,
...extra,
});

test('the skill body is appended, without its frontmatter, and skills stay disabled', async () => {
const { impl, calls } = makeSpawnImpl();
await publicSpawn(impl);

expect(calls).toHaveLength(1);
const { args } = calls[0];
const text = appended(args);
expect(text).toContain('## Skill: house-rules');
expect(text).toContain('# Being a member\n\nOrient before you post.');
expect(text).not.toContain('description: How to behave');
expect(args).toContain('--disable-slash-commands');
expect(args[args.indexOf('--setting-sources') + 1]).toBe('');
});

test('a private seat loads skills natively and gets no appended prompt', async () => {
const { impl, calls } = makeSpawnImpl();
await claude.spawn('hi', {
sessionId: null,
cwd,
environment: { skills: { claude: [skillSrc] } },
_spawnImpl: impl,
});

expect(appended(calls[0].args)).toBeNull();
expect(calls[0].args).not.toContain('--disable-slash-commands');
});

test('a public seat that declares no skills gets no appended prompt', async () => {
const { impl, calls } = makeSpawnImpl();
await publicSpawn(impl, {
environment: { sandbox: { mode: 'workspace', trust: 'public' } },
});

expect(appended(calls[0].args)).toBeNull();
});

test('a declared skill with no SKILL.md is skipped, not fatal', async () => {
fs.rmSync(path.join(skillSrc, 'SKILL.md'));
const { impl, calls } = makeSpawnImpl();
await publicSpawn(impl);

expect(calls).toHaveLength(1);
expect(appended(calls[0].args)).toBeNull();
});

// Vera on #2354: every arm above forces darwin, so dropping the bwrap
// branch's carry alone stayed green. Linux public seats are bwrap seats.
test('a public Linux (bwrap) seat carries the skill text inside the namespace', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
const { impl, calls } = makeSpawnImpl();
await claude.spawn('hi', {
agentName: 'public-skill-test',
sessionId: null,
cwd,
env: { PATH: process.env.PATH },
environment: { sandbox: { trust: 'public' }, skills: { claude: [skillSrc] } },
_spawnImpl: impl,
_detectBwrap: () => ({ available: true, path: 'bwrap' }),
});

expect(calls).toHaveLength(1);
expect(calls[0].cmd).toBe('bwrap');
const inner = calls[0].args.slice(calls[0].args.indexOf('--') + 1);
expect(appended(inner)).toContain('## Skill: house-rules');
expect(inner).toContain('--disable-slash-commands');
});

// Linux caps one argv string at 128 KiB, so the inlined total is capped at
// 96 KiB: a skill that would cross it is skipped, and later ones still fit.
test('a skill over the 96 KiB total is skipped, and a later smaller one still inlines', async () => {
const big = path.join(path.dirname(skillSrc), 'big-skill');
fs.mkdirSync(big);
fs.writeFileSync(path.join(big, 'SKILL.md'), `---\nname: big-skill\n---\n\n${'x'.repeat(100 * 1024)}\n`, 'utf8');
const { impl, calls } = makeSpawnImpl();
await publicSpawn(impl, {
environment: {
sandbox: { mode: 'workspace', trust: 'public' },
skills: { claude: [big, skillSrc] },
},
});

const text = appended(calls[0].args);
expect(text).not.toContain('## Skill: big-skill');
expect(text).toContain('## Skill: house-rules');
expect(Buffer.byteLength(text, 'utf8')).toBeLessThan(128 * 1024);
});

test('the fresh-session retry carries the same skill text', async () => {
const calls = [];
const impl = (cmd, args, opts) => {
calls.push({ cmd, args, opts });
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = jest.fn();
const first = calls.length === 1;
setTimeout(() => {
if (first) proc.stderr.emit('data', Buffer.from('Error: Session ID abc-123 is already in use.'));
else proc.stdout.emit('data', Buffer.from('recovered'));
proc.emit('close', first ? 1 : 0);
}, 0);
return proc;
};
await publicSpawn(impl, { sessionId: 'abc-123' });

expect(calls).toHaveLength(2);
expect(appended(calls[1].args)).toBe(appended(calls[0].args));
expect(appended(calls[1].args)).toContain('## Skill: house-rules');
});
});
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.108",
"version": "0.1.112",
"license": "Apache-2.0",
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
"type": "module",
Expand Down
61 changes: 61 additions & 0 deletions cli/src/lib/adapters/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,59 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns) => {
];
};

// A public seat's skills, as appended system-prompt text.
//
// `--disable-slash-commands` above is Claude's switch for ALL skills, and
// `--setting-sources ''` drops the project source the mounted copies live in.
// So the skills this adapter mounts into <cwd>/.claude/skills never load in a
// public seat: measured 2026-10-19 on a Bring your own page seat, which
// answered "I don't see a skill named commonly, or any skills list or Skill
// tool"; the same workspace with `--setting-sources project` found it. Every
// self-serve seat is public (COMMONLY_DEFAULT_SANDBOX), so every outsider's
// Claude ran without the house rules buildDefaultEnvironment mounts for it.
// Re-enabling skills would also hand a public seat the built-in ones
// (computer-use, the browser skills), so the flags stay and the declared text
// is delivered as prompt instead. Inline rather than a file: nothing new has to
// 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 runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
const proc = spawnImpl(cmd, args, {
cwd,
Expand Down Expand Up @@ -637,6 +690,7 @@ const prepareArgv = async (innerArgv, ctx) => {
innerArgv = [
...innerArgv,
...buildPublicClaudePolicyArgs(allowedPatterns),
...(ctx.publicSkillArgs || []),
];
const claudeBin = resolveClaudePath(claudeEnv);
// Only stdio servers have a `command`; an HTTP entry (the grant broker)
Expand Down Expand Up @@ -674,6 +728,7 @@ const prepareArgv = async (innerArgv, ctx) => {
innerArgv = [
...innerArgv,
...buildPublicClaudePolicyArgs(allowedPatterns),
...(ctx.publicSkillArgs || []),
];
} else if (allowedPatterns.length > 0) {
innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
Expand Down Expand Up @@ -779,8 +834,10 @@ export default {
];
const baseArgs = ['-p', fullPrompt, '--output-format', 'text', sessionFlag, sessionId, ...modelArgs];

let mountedSkills = [];
if (ctx.environment && ctx.cwd) {
const skills = await mountSkills(ctx.environment, ctx.cwd);
mountedSkills = skills.mounted;
if (skills.conflicted.length > 0) {
for (const c of skills.conflicted) {
// eslint-disable-next-line no-console
Expand All @@ -792,6 +849,7 @@ export default {

let mcpConfig = null;
let publicClaudeState = null;
let publicSkillArgs = [];
try {
// Trust is normalized at entry (legacy `internal` reads as public). The
// MCP policy follows trust alone, whatever confines the seat: a public
Expand All @@ -803,6 +861,7 @@ export default {
if (publicNativeSandbox) {
publicClaudeState = await preparePublicClaudeState(ctx);
}
if (publicTrust) publicSkillArgs = await publicSkillPromptArgs(mountedSkills);
if (Array.isArray(ctx.environment?.mcp) && ctx.environment.mcp.length > 0) {
mcpConfig = await createMcpConfig(ctx.environment.mcp, {
runtimeToken: ctx.runtimeToken,
Expand All @@ -827,6 +886,7 @@ export default {
mcpConfigDir: mcpConfig?.dir || null,
mcpReadDirectories: mcpConfig?.mcpReadDirectories || [],
publicClaudeState,
publicSkillArgs,
claudeEnv,
};
const { cmd, args, env } = await prepareArgv(baseArgs, spawnCtx);
Expand Down Expand Up @@ -867,6 +927,7 @@ export default {
mcpConfigDir: mcpConfig?.dir || null,
mcpReadDirectories: mcpConfig?.mcpReadDirectories || [],
publicClaudeState,
publicSkillArgs,
claudeEnv: buildClaudeEnv(
ctx.env,
publicClaudeState,
Expand Down
Loading