From da2ce067e4f4c3b1d88dd0165c94950e7c594f48 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:05:49 -0700 Subject: [PATCH 1/3] fix(cli): a public Claude seat gets its declared skills as prompt text Every self-serve seat is public (COMMONLY_DEFAULT_SANDBOX), and a public Claude spawn passes --disable-slash-commands ("Disable all skills") and --setting-sources '' (#754). So the Commonly skill buildDefaultEnvironment mounts into the seat's workspace never loaded: a Bring your own page seat answered "I don't see a skill named commonly, or any skills list or Skill tool" on 2026-10-19, and the same workspace with --setting-sources project found it. The flags stay, so built-in skills stay off and no pod text can invoke a slash command. The declared skills' SKILL.md bodies, frontmatter stripped, ride as --append-system-prompt on public spawns and on the fresh-session retry, capped at 96 KiB in total (Linux caps one argv string at 128 KiB). Private seats keep loading skills natively. CLI 0.1.109. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG --- .../adapters.claude.environment.test.mjs | 119 ++++++++++++++++++ cli/package-lock.json | 4 +- cli/package.json | 2 +- cli/src/lib/adapters/claude.js | 61 +++++++++ 4 files changed, 183 insertions(+), 3 deletions(-) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index c32d337421..2b7ea7d24b 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -986,3 +986,122 @@ 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(); + }); + + 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'); + }); +}); diff --git a/cli/package-lock.json b/cli/package-lock.json index 52de63678b..6eb0bde1f6 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.108", + "version": "0.1.109", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.108", + "version": "0.1.109", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index e8c454c624..58cfe6b954 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.108", + "version": "0.1.109", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index c89fc6859b..b349155fd7 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -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 /.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, @@ -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) @@ -674,6 +728,7 @@ const prepareArgv = async (innerArgv, ctx) => { innerArgv = [ ...innerArgv, ...buildPublicClaudePolicyArgs(allowedPatterns), + ...(ctx.publicSkillArgs || []), ]; } else if (allowedPatterns.length > 0) { innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns]; @@ -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 @@ -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 @@ -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, @@ -827,6 +886,7 @@ export default { mcpConfigDir: mcpConfig?.dir || null, mcpReadDirectories: mcpConfig?.mcpReadDirectories || [], publicClaudeState, + publicSkillArgs, claudeEnv, }; const { cmd, args, env } = await prepareArgv(baseArgs, spawnCtx); @@ -867,6 +927,7 @@ export default { mcpConfigDir: mcpConfig?.dir || null, mcpReadDirectories: mcpConfig?.mcpReadDirectories || [], publicClaudeState, + publicSkillArgs, claudeEnv: buildClaudeEnv( ctx.env, publicClaudeState, From 15a1dd0288bb419a5a367ae68a1672781542c10c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:22:42 -0700 Subject: [PATCH 2/3] chore(cli): take 0.1.112; 0.1.109-0.1.111 are claimed by the older open #2320, #2321 and #2326 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG --- cli/package-lock.json | 4 ++-- cli/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 6eb0bde1f6..f9a19d1fd3 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.109", + "version": "0.1.112", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.109", + "version": "0.1.112", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index 58cfe6b954..0481bab548 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.109", + "version": "0.1.112", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", From 6b6f2920d91bf33227411a2de67c7e0981bb6b6f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:51:05 -0700 Subject: [PATCH 3/3] test(cli): pin the bwrap branch's skill carry and the 96 KiB cap Vera on #2354: every new arm forced darwin, so dropping the bwrap branch's spread alone stayed green, and removing the cap stayed green. A Linux arm now drives the bwrap branch with a declared skill, and a cap arm declares a 100 KiB skill before a small one: the big one is skipped, the small one inlines, and the argument stays under 128 KiB. Both mutations now fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG --- .../adapters.claude.environment.test.mjs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 2b7ea7d24b..0b0cdce65b 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1082,6 +1082,48 @@ describe('claude adapter — a public seat gets its declared skills as prompt te 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) => {