diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 0b0cdce65b..c32d337421 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -986,164 +986,3 @@ 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'); - }); -}); diff --git a/cli/package-lock.json b/cli/package-lock.json index f9a19d1fd3..5b93ce4522 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.112", + "version": "0.1.113", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.112", + "version": "0.1.113", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index 0481bab548..53b2d6bbc8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.112", + "version": "0.1.113", "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 b349155fd7..c89fc6859b 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -258,59 +258,6 @@ 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, @@ -690,7 +637,6 @@ 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) @@ -728,7 +674,6 @@ const prepareArgv = async (innerArgv, ctx) => { innerArgv = [ ...innerArgv, ...buildPublicClaudePolicyArgs(allowedPatterns), - ...(ctx.publicSkillArgs || []), ]; } else if (allowedPatterns.length > 0) { innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns]; @@ -834,10 +779,8 @@ 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 @@ -849,7 +792,6 @@ 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 @@ -861,7 +803,6 @@ 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, @@ -886,7 +827,6 @@ export default { mcpConfigDir: mcpConfig?.dir || null, mcpReadDirectories: mcpConfig?.mcpReadDirectories || [], publicClaudeState, - publicSkillArgs, claudeEnv, }; const { cmd, args, env } = await prepareArgv(baseArgs, spawnCtx); @@ -927,7 +867,6 @@ export default { mcpConfigDir: mcpConfig?.dir || null, mcpReadDirectories: mcpConfig?.mcpReadDirectories || [], publicClaudeState, - publicSkillArgs, claudeEnv: buildClaudeEnv( ctx.env, publicClaudeState,