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
81 changes: 60 additions & 21 deletions cli/__tests__/adapters.claude.environment.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Asserts the claude adapter honours ctx.environment:
* - sandbox.mode='bwrap' → spawn binary is `bwrap`, not `claude`
* - mcp[] → --mcp-config <path> added to inner argv,
* and the JSON file written under <cwd>/.commonly/
* and a mode-0600 temp file outside the workspace
* - skills.claude[] → linkSkills called against the workspace
*
* Uses ctx._spawnImpl (the same test seam as adapters.claude.test.mjs) so
Expand Down Expand Up @@ -38,11 +38,24 @@ const fakeChild = ({ stdout = '', code = 0 } = {}) => {
return proc;
};

const makeSpawnImpl = () => {
const makeSpawnImpl = ({ code = 0 } = {}) => {
const calls = [];
const impl = (cmd, args, opts) => {
calls.push({ cmd, args, opts });
return fakeChild({ stdout: 'ok' });
const configIndex = args.indexOf('--mcp-config');
const configPath = configIndex === -1 ? null : args[configIndex + 1];
const config = configPath
? JSON.parse(fs.readFileSync(configPath, 'utf8'))
: null;
calls.push({
cmd,
args,
opts,
configPath,
config,
configMode: configPath ? fs.statSync(configPath).mode & 0o777 : null,
configDirMode: configPath ? fs.statSync(path.dirname(configPath)).mode & 0o777 : null,
});
return fakeChild({ stdout: 'ok', code });
};
return { impl, calls };
};
Expand All @@ -56,7 +69,7 @@ describe('claude adapter — ctx.environment', () => {
fs.rmSync(cwd, { recursive: true, force: true });
});

test('writes <cwd>/.commonly/mcp-config.json and adds --mcp-config when env.mcp declared', async () => {
test('keeps MCP config outside the workspace at 0600 for only the spawn lifetime', async () => {
const { impl, calls } = makeSpawnImpl();
const environment = {
mcp: [
Expand All @@ -72,19 +85,45 @@ describe('claude adapter — ctx.environment', () => {
_spawnImpl: impl,
});

const cfgPath = path.join(cwd, '.commonly', 'mcp-config.json');
expect(fs.existsSync(cfgPath)).toBe(true);
const parsed = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
expect(calls).toHaveLength(1);
const {
args: innerArgs,
configPath: cfgPath,
config: parsed,
configMode,
configDirMode,
} = calls[0];
expect(path.isAbsolute(cfgPath)).toBe(true);
expect(path.relative(cwd, cfgPath).startsWith('..')).toBe(true);
expect(configMode).toBe(0o600);
expect(configDirMode).toBe(0o700);
expect(parsed.mcpServers.github).toMatchObject({ type: 'http', url: 'http://localhost:3000/github-mcp' });
expect(parsed.mcpServers['local-db']).toMatchObject({
type: 'stdio', command: 'postgres-mcp', args: ['--db', 'mydb'],
});

expect(calls).toHaveLength(1);
const innerArgs = calls[0].args;
expect(innerArgs).toContain('--mcp-config');
const idx = innerArgs.indexOf('--mcp-config');
expect(innerArgs[idx + 1]).toBe(cfgPath);
expect(fs.existsSync(path.join(cwd, '.commonly'))).toBe(false);
expect(fs.existsSync(cfgPath)).toBe(false);
expect(fs.existsSync(path.dirname(cfgPath))).toBe(false);
});

test('removes the transient MCP config when the claude spawn fails', async () => {
const { impl, calls } = makeSpawnImpl({ code: 1 });

await expect(claude.spawn('hi', {
sessionId: null,
cwd,
environment: {
mcp: [{ name: 'commonly', transport: 'stdio', command: ['commonly-mcp'] }],
},
_spawnImpl: impl,
})).rejects.toThrow(/claude exited with code 1/);

expect(calls).toHaveLength(1);
expect(fs.existsSync(calls[0].configPath)).toBe(false);
expect(fs.existsSync(path.dirname(calls[0].configPath))).toBe(false);
});

test('symlinks skills.claude entries into <cwd>/.claude/skills/', async () => {
Expand Down Expand Up @@ -144,7 +183,7 @@ describe('claude adapter — ctx.environment', () => {
// from values it already has on hand (the saved token record).

test('${COMMONLY_AGENT_TOKEN} in MCP env values is substituted with ctx.runtimeToken', async () => {
const { impl } = makeSpawnImpl();
const { impl, calls } = makeSpawnImpl();
const environment = {
mcp: [
{
Expand All @@ -167,7 +206,7 @@ describe('claude adapter — ctx.environment', () => {
instanceUrl: 'https://api-dev.commonly.me',
_spawnImpl: impl,
});
const cfg = JSON.parse(fs.readFileSync(path.join(cwd, '.commonly', 'mcp-config.json'), 'utf8'));
const cfg = calls[0].config;
expect(cfg.mcpServers.commonly.env.COMMONLY_AGENT_TOKEN).toBe('cm_agent_real_token_12345');
expect(cfg.mcpServers.commonly.env.COMMONLY_API_URL).toBe('https://api-dev.commonly.me');
// Substitution is literal — interpolation works inside larger strings.
Expand All @@ -177,7 +216,7 @@ describe('claude adapter — ctx.environment', () => {
});

test('${COMMONLY_INSTANCE_URL} alias substitutes to the same value as ${COMMONLY_API_URL}', async () => {
const { impl } = makeSpawnImpl();
const { impl, calls } = makeSpawnImpl();
await claude.spawn('hi', {
sessionId: null,
cwd,
Expand All @@ -186,12 +225,12 @@ describe('claude adapter — ctx.environment', () => {
instanceUrl: 'http://localhost:5000',
_spawnImpl: impl,
});
const cfg = JSON.parse(fs.readFileSync(path.join(cwd, '.commonly', 'mcp-config.json'), 'utf8'));
const cfg = calls[0].config;
expect(cfg.mcpServers.x.env.U).toBe('http://localhost:5000');
});

test('placeholders in command args + url are also substituted', async () => {
const { impl } = makeSpawnImpl();
const { impl, calls } = makeSpawnImpl();
await claude.spawn('hi', {
sessionId: null,
cwd,
Expand All @@ -213,13 +252,13 @@ describe('claude adapter — ctx.environment', () => {
instanceUrl: 'https://api-dev.commonly.me',
_spawnImpl: impl,
});
const cfg = JSON.parse(fs.readFileSync(path.join(cwd, '.commonly', 'mcp-config.json'), 'utf8'));
const cfg = calls[0].config;
expect(cfg.mcpServers['sse-server'].url).toBe('https://api-dev.commonly.me/mcp/sse');
expect(cfg.mcpServers['arg-server'].args).toEqual(['--token', 'cm_agent_x']);
});

test('unknown ${COMMONLY_*} placeholders are left intact (so misspellings surface as MCP errors, not silent empties)', async () => {
const { impl } = makeSpawnImpl();
const { impl, calls } = makeSpawnImpl();
await claude.spawn('hi', {
sessionId: null,
cwd,
Expand All @@ -235,12 +274,12 @@ describe('claude adapter — ctx.environment', () => {
instanceUrl: 'http://localhost:5000',
_spawnImpl: impl,
});
const cfg = JSON.parse(fs.readFileSync(path.join(cwd, '.commonly', 'mcp-config.json'), 'utf8'));
const cfg = calls[0].config;
expect(cfg.mcpServers.x.env.TYPO).toBe('${COMMONLY_AGNT_TOKEN}');
});

test('substitution is a no-op when ctx.runtimeToken / instanceUrl are absent (literal env values pass through)', async () => {
const { impl } = makeSpawnImpl();
const { impl, calls } = makeSpawnImpl();
await claude.spawn('hi', {
sessionId: null,
cwd,
Expand All @@ -255,7 +294,7 @@ describe('claude adapter — ctx.environment', () => {
_spawnImpl: impl,
// Note: no runtimeToken, no instanceUrl.
});
const cfg = JSON.parse(fs.readFileSync(path.join(cwd, '.commonly', 'mcp-config.json'), 'utf8'));
const cfg = calls[0].config;
expect(cfg.mcpServers.x.env.LITERAL).toBe('plain-string');
// Empty token → placeholder left intact (not substituted with empty string).
expect(cfg.mcpServers.x.env.PLACEHOLDER).toBe('${COMMONLY_AGENT_TOKEN}');
Expand Down
121 changes: 119 additions & 2 deletions cli/__tests__/adapters.codex.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@

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

Expand Down Expand Up @@ -171,14 +178,124 @@ describe('codex adapter — spawn()', () => {
.filter(Boolean);
expect(cFlags).toEqual([
'mcp_servers.commonly.command="npx"',
'mcp_servers.commonly.default_tools_approval_mode="approve"',
'mcp_servers.commonly.args=["-y","@commonlyai/mcp@latest"]',
'mcp_servers.commonly.env={COMMONLY_API_URL = "https://api.example.test", COMMONLY_AGENT_TOKEN = "cm_agent_secret"}',
'mcp_servers.commonly.env={COMMONLY_API_URL = "https://api.example.test"}',
'mcp_servers.commonly.env_vars=["COMMONLY_AGENT_TOKEN"]',
]);
expect(args.join(' ')).not.toContain('cm_agent_secret');
expect(calls[0].opts.env.COMMONLY_AGENT_TOKEN).toBe('cm_agent_secret');
// Overrides must precede the prompt (last arg) and not disturb -o pairing.
expect(findOutputFile(args)).toBeTruthy();
expect(args[args.length - 1]).toBe('hi');
});

test('public workspace mode uses a deny-by-default permission profile and never the legacy sandbox or bypass', async () => {
const operatorHome = await mkdtemp(join(tmpdir(), 'commonly-codex-operator-home-'));
const publicHome = await mkdtemp(join(tmpdir(), 'commonly-codex-public-home-'));
const operatorAuth = join(operatorHome, 'auth.json');
await writeFile(operatorAuth, '{"test":true}', 'utf8');
const { impl, calls } = makeSpawnImpl({
stdoutChunks: ['{"type":"thread.started","thread_id":"sid-public"}\n'],
outputContents: 'ok',
});

await codex.spawn('work safely', {
sessionId: null,
cwd: '/tmp/public-agent-workspace',
environment: {
sandbox: { mode: 'workspace', trust: 'public' },
},
env: { ...process.env, CODEX_HOME: operatorHome },
agentName: 'public-test-agent',
_publicCodexHome: publicHome,
_spawnImpl: impl,
});

const args = calls[0].args;
expect(calls[0].opts.env.CODEX_HOME).toBe(publicHome);
expect((await lstat(join(publicHome, 'auth.json'))).isSymbolicLink()).toBe(true);
expect(await readlink(join(publicHome, 'auth.json'))).toBe(operatorAuth);
expect(await lstat(publicHome).then((stat) => stat.mode & 0o777)).toBe(0o700);
await expect(lstat(join(publicHome, 'AGENTS.md'))).rejects.toMatchObject({ code: 'ENOENT' });
expect(args.slice(0, 3)).toEqual(['--ask-for-approval', 'never', 'exec']);
expect(args).toContain('--ignore-user-config');
expect(args).toContain('--ignore-rules');
expect(args).not.toContain('--sandbox');
expect(args).not.toContain('--dangerously-bypass-approvals-and-sandbox');
const cFlags = args
.map((a, i) => (a === '-c' ? args[i + 1] : null))
.filter(Boolean);
expect(cFlags).toContain('default_permissions="commonly_public"');
const filesystem = cFlags.find((flag) => flag.startsWith(
'permissions.commonly_public.filesystem=',
));
expect(filesystem).toContain('":minimal"="read"');
expect(filesystem).toContain('":workspace_roots"={"."="write"');
expect(filesystem).toContain('".commonly/**"="deny"');
expect(filesystem).toContain('".codex/**"="deny"');
expect(filesystem).not.toContain('".commonly"="deny"');
expect(filesystem).not.toContain('".codex"="deny"');
for (const secretPath of [
'~/.commonly',
'~/.claude',
'~/.codex',
'~/.ssh',
'~/.aws',
'~/.config',
'/private/tmp',
]) {
expect(filesystem).toContain(`"${secretPath}"="deny"`);
}
expect(cFlags).toContain('permissions.commonly_public.network.enabled=false');
expect(cFlags).toContain(
'shell_environment_policy.include_only=["PATH","HOME","TMPDIR","LANG","LC_*"]',
);
});

test('public read-only mode keeps the workspace read-only and applies on resume', async () => {
const operatorHome = await mkdtemp(join(tmpdir(), 'commonly-codex-operator-home-'));
const publicHome = await mkdtemp(join(tmpdir(), 'commonly-codex-public-home-'));
const { impl, calls } = makeSpawnImpl({
stdoutChunks: ['{"type":"thread.started","thread_id":"sid-public"}\n'],
outputContents: 'ok',
});

await codex.spawn('inspect safely', {
sessionId: 'sid-public',
environment: {
sandbox: { mode: 'read-only', trust: 'public' },
},
env: { ...process.env, CODEX_HOME: operatorHome },
_publicCodexHome: publicHome,
_spawnImpl: impl,
});

const args = calls[0].args;
expect(args.slice(0, 5)).toEqual([
'--ask-for-approval', 'never', 'exec', 'resume', 'sid-public',
]);
const filesystemIndex = args.findIndex((arg) => (
typeof arg === 'string'
&& arg.startsWith('permissions.commonly_public.filesystem=')
));
expect(filesystemIndex).toBeGreaterThan(-1);
expect(args[filesystemIndex]).toContain('":workspace_roots"={"."="read"');
expect(args).not.toContain('--dangerously-bypass-approvals-and-sandbox');
});

test('public trust fails closed when no enforced public sandbox mode is declared', async () => {
const { impl } = makeSpawnImpl({
stdoutChunks: ['{"type":"turn.completed"}\n'],
outputContents: 'should not run',
});

await expect(codex.spawn('x', {
environment: { sandbox: { trust: 'public' } },
_spawnImpl: impl,
})).rejects.toThrow(/require sandbox.mode=workspace or read-only/);
});

test('no environment.mcp → no -c flags (argv unchanged for MCP-less agents)', async () => {
const { impl, calls } = makeSpawnImpl({
stdoutChunks: ['{"type":"turn.completed"}\n'],
Expand Down
23 changes: 23 additions & 0 deletions cli/__tests__/bwrap.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,29 @@ describe('wrapArgvWithBwrap', () => {
expect(argv[idx + 1]).toBe('/home/user/.claude');
});

itLinux('binds exact adapter-owned transient inputs read-only', () => {
const argv = wrapArgvWithBwrap(
['claude'],
{},
{
workspacePath: '/tmp/ws',
readOnlyPaths: ['/tmp/commonly-claude-mcp-abc123'],
},
);
const idx = argv.indexOf('/tmp/commonly-claude-mcp-abc123');
expect(idx).toBeGreaterThan(-1);
expect(argv[idx - 1]).toBe('--ro-bind');
expect(argv[idx + 1]).toBe('/tmp/commonly-claude-mcp-abc123');
});

itLinux('rejects relative adapter-owned transient paths', () => {
expect(() => wrapArgvWithBwrap(
['claude'],
{},
{ workspacePath: '/tmp/ws', readOnlyPaths: ['relative/config'] },
)).toThrow(/readOnlyPaths entries must be absolute/);
});

itLinux('expands ~ in read-outside / write-outside paths', async () => {
// Surfaced live during 2026-04-17 demo validation: `~/.local/bin` was
// declared in read-outside but bwrap saw the literal `~` and the
Expand Down
17 changes: 17 additions & 0 deletions cli/__tests__/environment.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,23 @@ describe('validateEnvironmentSpec', () => {
expect(res.errors.join(' ')).toMatch(/sandbox.mode/);
});

test('accepts host-neutral public sandbox intent for the codex adapter', () => {
expect(validateEnvironmentSpec({
sandbox: { mode: 'workspace', trust: 'public' },
})).toEqual({ ok: true, errors: [] });
expect(validateEnvironmentSpec({
sandbox: { mode: 'read-only', trust: 'public' },
})).toEqual({ ok: true, errors: [] });
});

test('rejects bad sandbox.trust', () => {
const res = validateEnvironmentSpec({
sandbox: { mode: 'workspace', trust: 'sometimes' },
});
expect(res.ok).toBe(false);
expect(res.errors.join(' ')).toMatch(/sandbox.trust/);
});

test('rejects bad sandbox.network.policy', () => {
const res = validateEnvironmentSpec({
sandbox: { network: { policy: 'kinda-restricted' } },
Expand Down
Loading
Loading