/** * attach.test.mjs — ADR-005 Phase 1a * * Covers the pure attach core + the token-file persistence helpers. * - performAttach: detect → publish → install → mint runtime token * - saveAgentToken / loadAgentToken: ~/.commonly/tokens/.json round trip * * We stub `homedir` so all token writes land in a throwaway temp dir, and pass * a hand-rolled `client` with jest.fn() for get/post — no HTTP. */ import { jest } from '@jest/globals'; import os from 'os'; import path from 'path'; import fs from 'fs'; const tokensTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-attach-test-')); await jest.unstable_mockModule('os', () => { const actual = os; return { ...actual, default: { ...actual, homedir: () => tokensTmpDir }, homedir: () => tokensTmpDir, }; }); const { performAttach, setWakeOnMessage, updateAgentConfiguration, saveAgentToken, loadAgentToken, buildDefaultEnvironment, bootstrapAgentRecordFromEnv, parseAllowGrants, checkRunAllowRequest, NO_AGENT_CLI_CODE, savedSeatLacksDefaultSandbox, BOOTSTRAP_ADAPTER_DETECT_ORDER, runtimeAdapterForInstallation, adapterRequiresServerBinding, serverAdapterBindingRefusal, checkAttachedAdapterBinding, printAttachRunGuidance, resolveAttachSandbox, assertSandboxDeclaredForPublicPod, } = await import('../src/commands/agent.js'); const { ADAPTERS_WITH_GRANT_BROKER, seatBaseline } = await import('../src/lib/default-environment.js'); describe('resolveAttachSandbox — the sandbox an attach runs under (TASK-113)', () => { test('a legacy internal trust resolves a confining mode here, on both hosts', () => { // Named for what this returns — a DECLARATION — not for confinement itself: // the adapters re-derive from the environment and confine either way. What // was wrong is that this gate's verdict contradicted theirs. expect(resolveAttachSandbox({ environment: { sandbox: { trust: 'internal' } }, adapterName: 'claude', platform: 'darwin', })).toEqual({ mode: 'workspace', trust: 'public' }); expect(resolveAttachSandbox({ environment: { sandbox: { trust: 'internal' } }, adapterName: 'claude', platform: 'linux', })).toEqual({ mode: 'bwrap', trust: 'public' }); }); test('a legacy internal trust beside mode none is refused, not silently run', () => { expect(() => resolveAttachSandbox({ environment: { sandbox: { mode: 'none', trust: 'internal' } }, })).toThrow(/refusing to attach unsandboxed/); }); test("a legacy internal record beside mode 'workspace' attaches, it does not throw (Vera 71675)", () => { // The raw compare failed CLOSED here: `sandboxTrust !== 'public'` was true, // so attach refused a record both adapters confine — the adapter suites pin // that on this exact shape (adapters.claude.environment.test.mjs:444 spawns // sandbox-exec; adapters.codex.test.mjs:257 asserts no bypass flag). for (const adapterName of ['claude', 'codex']) { expect(resolveAttachSandbox({ environment: { sandbox: { mode: 'workspace', trust: 'internal' } }, adapterName, platform: 'darwin', })).toEqual({ mode: 'workspace', trust: 'public' }); } }); test('that same record is still refused on an adapter that cannot honour it', () => { // The support check moved into the derivation must not become a no-op. expect(() => resolveAttachSandbox({ environment: { sandbox: { mode: 'workspace', trust: 'internal' } }, adapterName: 'pi', })).toThrow(/requires public trust and an adapter with sandbox enforcement/); }); test('an explicit mode is taken as declared, and a public one is guarded', () => { const workspace = resolveAttachSandbox({ environment: { sandbox: { mode: 'read-only', trust: 'internal' } }, adapterName: 'claude', platform: 'darwin', }); expect(workspace).toEqual({ mode: 'read-only', trust: 'public' }); expect(() => resolveAttachSandbox({ environment: { sandbox: { mode: 'none', trust: 'public' } }, })).toThrow(/refusing to attach unsandboxed/); }); test('an absent or unrecognised trust is left alone and is not refused', () => { // The regression witness: this gate must keep allowing a seat that never // asked to be confined, or every private agent stops attaching. expect(resolveAttachSandbox({ environment: { model: 'gpt-5.4' } })) .toEqual({ mode: 'none', trust: undefined }); expect(resolveAttachSandbox({ environment: { sandbox: { mode: 'none', trust: 'private' } }, })).toEqual({ mode: 'none', trust: 'private' }); }); }); const { default: dsh } = await import('../src/lib/adapters/dsh.js'); describe("resolveAttachSandbox — dsh's confined shape (D106)", () => { const confined = { sandbox: { mode: 'workspace', network: { policy: 'restricted' } } }; test('attach dsh admits the one shape the dsh adapter enforces, with no trust', () => { // Before this, the trust-keyed gate refused mode:'workspace' without public // trust, while dsh throws on public or workspace trust: no env passed both. expect(resolveAttachSandbox({ environment: confined, adapterName: 'dsh', adapter: dsh })) .toEqual({ mode: 'workspace', trust: undefined }); }); test('dsh still refuses every other sandbox shape at attach', () => { for (const sandbox of [ { mode: 'workspace' }, { mode: 'workspace', network: { policy: 'open' } }, { mode: 'workspace', network: { policy: 'restricted' }, extra: 1 }, { mode: 'read-only', network: { policy: 'restricted' } }, { mode: 'workspace', network: { policy: 'restricted' }, trust: 'public' }, { mode: 'workspace', network: { policy: 'restricted' }, trust: 'workspace' }, ]) { expect(() => resolveAttachSandbox({ environment: { sandbox }, adapterName: 'dsh', adapter: dsh })) .toThrow(/dsh adapter/); } }); test('the same env is still refused for adapters without their own confined shape', () => { for (const adapterName of ['claude', 'codex', 'opencode', 'qwen', 'pi']) { expect(() => resolveAttachSandbox({ environment: confined, adapterName, adapter: { name: adapterName } })) .toThrow(/requires public trust and an adapter with sandbox enforcement/); } }); test('without the adapter object, the old refusal still holds for dsh', () => { expect(() => resolveAttachSandbox({ environment: confined, adapterName: 'dsh' })) .toThrow(/requires public trust and an adapter with sandbox enforcement/); }); }); describe('setWakeOnMessage', () => { const record = { agentName: 'juno', podId: 'pod-9', instanceId: 'default', runtimeToken: 'cm_agent_j', }; test('PATCHes the installation config through the registry route', async () => { const client = { patch: jest.fn(async () => ({ success: true })) }; const result = await setWakeOnMessage({ client, record, enabled: true }); expect(client.patch).toHaveBeenCalledWith( '/api/registry/pods/pod-9/agents/juno', { instanceId: 'default', config: { wakeOnMessage: { enabled: true } } }, ); expect(result).toEqual({ agentName: 'juno', podId: 'pod-9', instanceId: 'default', enabled: true, }); }); test('off sends enabled:false (not a delete) so the read side sees an explicit value', async () => { const client = { patch: jest.fn(async () => ({ success: true })) }; await setWakeOnMessage({ client, record, enabled: false }); expect(client.patch.mock.calls[0][1].config.wakeOnMessage).toEqual({ enabled: false }); }); test('refuses a token record without a pod', async () => { const client = { patch: jest.fn() }; await expect(setWakeOnMessage({ client, record: { agentName: 'x' }, enabled: true })) .rejects.toThrow(/podId/); expect(client.patch).not.toHaveBeenCalled(); }); }); describe('updateAgentConfiguration', () => { const record = { agentName: 'juno', podId: 'pod-9', instanceId: 'writer', runtimeToken: 'cm_agent_j', }; test('page key edits only model controls and preserves the local environment', async () => { const client = { get: jest.fn(), patch: jest.fn(async () => ({ podId: 'owned-pod' })) }; const environment = { workspace: { path: './own' }, sandbox: { trust: 'public', mode: 'workspace' }, model: 'old' }; const result = await updateAgentConfiguration({ client, record: { ...record, environment }, runtimeKey: true, model: 'new', effort: 'high' }); expect(client.get).not.toHaveBeenCalled(); expect(client.patch).toHaveBeenCalledWith('/api/agents/runtime/configuration', { agentName: 'juno', instanceId: 'writer', model: 'new', effort: 'high', }); expect(result.environment).toEqual({ ...environment, model: 'new', effort: 'high' }); expect(result.podId).toBe('owned-pod'); }); test.each([{ adapter: 'claude' }, { envPath: '/tmp/env.json' }])('page key cannot edit broader runtime declarations: %j', async (options) => { const client = { patch: jest.fn() }; await expect(updateAgentConfiguration({ client, record, runtimeKey: true, model: 'new', ...options })).rejects.toThrow('owner login'); expect(client.patch).not.toHaveBeenCalled(); }); test('reuses the registry PATCH route for runtime controls and full env specs', async () => { const client = { get: jest.fn(async () => ({ writablePodId: 'pod-9' })), patch: jest.fn(async () => ({ success: true })) }; const environment = { version: 1, workspace: { path: './workspace' }, sandbox: { mode: 'workspace', trust: 'internal' }, mcp: [{ name: 'commonly', command: ['npx', 'commonly-mcp'] }], effort: 'xhigh', model: 'gpt-5.4', }; await expect(updateAgentConfiguration({ client, record, model: 'gpt-5.4', effort: 'xhigh', envPath: '/tmp/runtime.json', parseEnv: jest.fn(async () => environment), })).resolves.toEqual({ agentName: 'juno', podId: 'pod-9', instanceId: 'writer', changed: ['runtime', 'environment'], environment, }); expect(client.patch).toHaveBeenCalledWith( '/api/registry/pods/pod-9/agents/juno', { instanceId: 'writer', config: { runtime: { model: 'gpt-5.4', effort: 'xhigh' }, environment, }, }, ); }); test('keeps the complete provider block local while PATCHing environment fields', async () => { const client = { get: jest.fn(async () => ({ writablePodId: 'pod-9' })), patch: jest.fn(async () => ({ success: true })) }; const environment = { model: 'gpt-5.4', provider: { id: 'litellm', baseURL: 'https://llm.example.test/v1', keyFile: '/Users/kai/.config/commonly/llm-key', }, }; const result = await updateAgentConfiguration({ client, record: { ...record, adapter: 'opencode' }, envPath: '/tmp/provider-env.json', parseEnv: jest.fn(async () => environment), }); expect(client.patch.mock.calls[0][1].config.environment).toEqual({ model: 'gpt-5.4' }); expect(result.environment).toEqual(environment); }); test('refuses provider configuration when the local token record is not OpenCode-bound', async () => { const client = { get: jest.fn(async () => ({ writablePodId: 'pod-9' })), patch: jest.fn() }; await expect(updateAgentConfiguration({ client, record, envPath: '/tmp/provider-env.json', parseEnv: jest.fn(async () => ({ model: 'gpt-5.4', provider: { id: 'litellm', baseURL: 'https://llm.example.test/v1', keyFile: '/tmp/provider-key', }, })), })).rejects.toThrow(/requires a provider-capable adapter/); expect(client.patch).not.toHaveBeenCalled(); }); test('agent config writes the adapter binding explicitly', async () => { const client = { get: jest.fn(async () => ({ writablePodId: 'pod-9' })), patch: jest.fn(async () => ({ success: true })) }; const adapterRegistry = { listAdapterNames: () => ['opencode'], getAdapter: () => ({ detect: async () => ({ path: '/bin/opencode', version: '1.18.35' }) }), }; await updateAgentConfiguration({ client, record, adapter: 'opencode', adapterRegistry }); expect(client.patch).toHaveBeenCalledWith( '/api/registry/pods/pod-9/agents/juno', { instanceId: 'writer', config: { runtime: { adapter: 'opencode' } } }, ); }); test('rejects an empty edit and an incomplete token record before making a request', async () => { const client = { get: jest.fn(async () => ({ writablePodId: 'pod-9' })), patch: jest.fn() }; await expect(updateAgentConfiguration({ client, record })).rejects.toThrow(/at least one/); await expect(updateAgentConfiguration({ client, record, effort: 'turbo' })) .rejects.toThrow(/effort must be one of/); await expect(updateAgentConfiguration({ client, record: { podId: 'pod-9' }, model: 'opus' })) .rejects.toThrow(/agentName/); expect(client.patch).not.toHaveBeenCalled(); }); test('updates an existing declared environment when only model/effort flags change', async () => { const client = { get: jest.fn(async () => ({ writablePodId: 'pod-9' })), patch: jest.fn(async () => ({ success: true })) }; const localRecord = { ...record, environment: { workspace: { path: './workspace' }, sandbox: { mode: 'workspace', trust: 'internal' }, model: 'old-model', effort: 'medium', }, }; await updateAgentConfiguration({ client, record: localRecord, model: 'new-model', effort: 'high' }); expect(client.patch.mock.calls[0][1].config.environment).toEqual({ ...localRecord.environment, model: 'new-model', effort: 'high', }); }); test('materializes runtime edits into the declared environment when none exists', async () => { const client = { get: jest.fn(async () => ({ writablePodId: 'pod-9' })), patch: jest.fn(async () => ({ success: true })) }; const result = await updateAgentConfiguration({ client, record, model: 'new-model', effort: 'high' }); expect(result.environment).toEqual({ model: 'new-model', effort: 'high' }); expect(client.patch.mock.calls[0][1].config).toEqual({ runtime: { model: 'new-model', effort: 'high' }, environment: { model: 'new-model', effort: 'high' }, }); }); test('validates and persists a detected adapter without leaking it into ADR-008 environment', async () => { const client = { get: jest.fn(async () => ({ writablePodId: 'pod-9' })), patch: jest.fn(async () => ({ success: true })) }; const adapter = { detect: jest.fn(async () => ({ path: '/usr/local/bin/claude' })) }; const result = await updateAgentConfiguration({ client, record, adapter: 'claude', adapterRegistry: { getAdapter: jest.fn((name) => (name === 'claude' ? adapter : null)), listAdapterNames: jest.fn(() => ['claude', 'codex']), }, }); expect(result).toEqual({ agentName: 'juno', podId: 'pod-9', instanceId: 'writer', changed: ['runtime'], adapter: 'claude', }); expect(adapter.detect).toHaveBeenCalledTimes(1); expect(client.patch).toHaveBeenCalledWith( '/api/registry/pods/pod-9/agents/juno', { instanceId: 'writer', config: { runtime: { adapter: 'claude' } } }, ); }); test('rejects an unknown or unavailable adapter before PATCH', async () => { const client = { get: jest.fn(async () => ({ writablePodId: 'pod-9' })), patch: jest.fn() }; const adapter = { detect: jest.fn(async () => null) }; const adapterRegistry = { getAdapter: jest.fn((name) => (name === 'claude' ? adapter : null)), listAdapterNames: jest.fn(() => ['claude', 'codex']), }; await expect(updateAgentConfiguration({ client, record, adapter: 'unknown', adapterRegistry })) .rejects.toThrow(/Unknown adapter/); await expect(updateAgentConfiguration({ client, record, adapter: 'claude', adapterRegistry })) .rejects.toThrow(/not found on PATH/); expect(client.patch).not.toHaveBeenCalled(); }); test('resolves a managed seat from the server source instead of its attention pod', async () => { const client = { get: jest.fn(async () => ({ runtimeManaged: true, runtimeSourcePodId: 'source-pod', writablePodId: 'other-pod' })), patch: jest.fn(async () => ({})), }; const result = await updateAgentConfiguration({ client, record, model: 'new-model' }); expect(client.get).toHaveBeenCalledWith('/api/registry/agents/juno/manage-settings?instanceId=writer'); expect(client.patch.mock.calls[0][0]).toBe('/api/registry/pods/source-pod/agents/juno'); expect(result.podId).toBe('source-pod'); }); test('a stale or absent local pod is not used when the server has a writable installation', async () => { const client = { get: jest.fn(async () => ({ writablePodId: 'real-pod' })), patch: jest.fn(async () => ({})), }; await updateAgentConfiguration({ client, record: { ...record, podId: undefined }, model: 'new-model' }); expect(client.patch.mock.calls[0][0]).toBe('/api/registry/pods/real-pod/agents/juno'); }); test.each([ {}, { writablePodId: null }, { runtimeManaged: true, runtimeSourcePodId: null, writablePodId: 'other-pod' }, ])('refuses a missing server target without PATCHing the token pod: %j', async (settings) => { const client = { get: jest.fn(async () => settings), patch: jest.fn() }; await expect(updateAgentConfiguration({ client, record, model: 'new-model' })) .rejects.toThrow(/No writable runtime installation/); expect(client.patch).not.toHaveBeenCalled(); }); test('a failed installation lookup never falls back to the token pod', async () => { const client = { get: jest.fn(async () => { throw new Error('Forbidden'); }), patch: jest.fn() }; await expect(updateAgentConfiguration({ client, record, model: 'new-model' })).rejects.toThrow('Forbidden'); expect(client.patch).not.toHaveBeenCalled(); }); }); const makeClient = ({ publishOk = true, runtimeToken = null } = {}) => { const post = jest.fn(async (route, body) => { if (route === '/api/registry/publish') { if (!publishOk) throw new Error('already published'); return { ok: true }; } if (route === '/api/registry/install') { return { installation: { agentName: body.agentName, instanceId: 'default', podId: body.podId, }, ...(runtimeToken ? { runtimeToken } : {}), }; } if (route.endsWith('/runtime-tokens')) { return { token: 'cm_agent_from_tokens_route' }; } throw new Error(`unexpected POST to ${route}`); }); return { post, get: jest.fn(async (route) => (route === '/api/registry/install/memory-handoff' ? { installTimeMemoryHandoff: true } : undefined)) }; }; describe('performAttach', () => { beforeEach(() => { fs.rmSync(path.join(tokensTmpDir, '.commonly'), { recursive: true, force: true }); }); test('dsh attach refuses an unconfigured seat before publishing', async () => { const client = makeClient(); await expect(performAttach({ client, adapterName: 'dsh', agentName: 'dsh-seat', podId: 'pod-private', adapterRegistry: { getAdapter: () => ({ name: 'dsh', detect: async () => ({ path: '/bin/dsh', version: '0.2.1-rc.2' }) }), listAdapterNames: () => ['dsh'], }, })).rejects.toThrow(/requires an environment file/); expect(client.post).not.toHaveBeenCalled(); }); test('dsh attach requires confirmed private pod visibility', async () => { const client = { get: jest.fn(async () => ({ publicRead: true, communityListed: false })) }; await expect(assertSandboxDeclaredForPublicPod({ client, podId: 'pod-public', adapterName: 'dsh' })) .rejects.toThrow(/private pods only/); client.get.mockRejectedValueOnce(new Error('visibility unavailable')); await expect(assertSandboxDeclaredForPublicPod({ client, podId: 'pod-unknown', adapterName: 'dsh' })) .rejects.toThrow(/confirmed private pod visibility/); client.get.mockResolvedValueOnce({}); await expect(assertSandboxDeclaredForPublicPod({ client, podId: 'pod-unknown', adapterName: 'dsh' })) .rejects.toThrow(/confirmed private pod visibility/); client.get.mockResolvedValueOnce({ publicRead: false, communityListed: false }); await expect(assertSandboxDeclaredForPublicPod({ client, podId: 'pod-private', adapterName: 'dsh' })) .resolves.toBeUndefined(); }); // D106: performAttach must hand the adapter to resolveAttachSandbox, or the // confined dsh shape meets the trust-keyed refusal. Past that gate it reaches // the Seatbelt step, which logs on macOS and refuses on Linux; either proves // the shape was admitted. test('attach --env admits confined dsh through to the Seatbelt step (D106)', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-dsh-attach-')); try { const keyFile = path.join(root, 'provider-key'); const envPath = path.join(root, 'environment.json'); fs.writeFileSync(keyFile, 'provider-key', { mode: 0o600 }); fs.writeFileSync(envPath, JSON.stringify({ version: 1, workspace: { path: path.join(root, 'workspace') }, model: 'deepseek-chat', provider: { id: 'litellm', baseURL: 'https://llm.example.test/v1', keyFile }, sandbox: { mode: 'workspace', network: { policy: 'restricted' } }, })); const client = makeClient({ runtimeToken: 'cm_agent_dsh' }); client.get = jest.fn(async () => ({ publicRead: false, communityListed: false })); const logs = []; const outcome = await performAttach({ client, adapterName: 'dsh', agentName: 'dsh-seat', podId: 'pod-private', envPath, log: (line) => logs.push(line), adapterRegistry: { getAdapter: () => ({ name: 'dsh', supportsProvider: true, detect: async () => ({ path: '/usr/local/bin/dsh', version: '0.2.1-rc.2' }), validateEnvironment: async () => {}, confinesOwnSandbox: dsh.confinesOwnSandbox, }), listAdapterNames: () => ['dsh'], }, }).then(() => null, (error) => error); if (outcome) { expect(outcome.message).not.toMatch(/requires public trust/); expect(outcome.message).toMatch(/requires macOS Seatbelt/); } else { expect(logs).toContain('sandbox: confined dsh workspace via macOS Seatbelt (restricted network)'); } } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test('attach --env gives provider-backed Qwen the same Commonly MCP baseline as the daemon', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-qwen-attach-')); try { const keyFile = path.join(root, 'provider-key'); const envPath = path.join(root, 'environment.json'); fs.writeFileSync(keyFile, 'provider-key', { mode: 0o600 }); fs.writeFileSync(envPath, JSON.stringify({ version: 1, workspace: { path: path.join(root, 'workspace') }, model: 'gpt-5.4', provider: { id: 'litellm', baseURL: 'https://llm.example.test/v1', keyFile }, mcp: [{ name: 'other', transport: 'http', url: 'https://other.example.test/mcp' }], })); const client = makeClient({ runtimeToken: 'cm_agent_qwen' }); const result = await performAttach({ client, adapterName: 'qwen', agentName: 'qwen-seat', podId: 'pod-qwen', envPath, adapterRegistry: { getAdapter: () => ({ name: 'qwen', supportsProvider: true, detect: async () => ({ path: '/usr/local/bin/qwen', version: '0.25.1' }), validateEnvironment: async () => {}, }), listAdapterNames: () => ['qwen'], }, }); const installed = client.post.mock.calls.find(([route]) => route === '/api/registry/install')[1]; expect(result.environment.mcp.map(({ name }) => name)).toEqual(['other', 'commonly']); expect(result.environment.mcp[1].env.COMMONLY_AGENT_TOKEN).toBe('${COMMONLY_AGENT_TOKEN}'); expect(installed.config.environment.mcp).toEqual(result.environment.mcp); expect(installed.config.environment).not.toHaveProperty('provider'); expect(result.environment.mcp).toEqual(seatBaseline({ mcp: result.environment.mcp.slice(0, 1) }, 'qwen').mcp); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test('detects the adapter, publishes, installs, and returns a runtime token', async () => { const client = makeClient({ runtimeToken: 'cm_agent_from_install' }); const result = await performAttach({ client, adapterName: 'stub', agentName: 'my-stub', podId: 'pod-1', displayName: 'My Stub', }); expect(result.runtimeToken).toBe('cm_agent_from_install'); expect(result.wrappedCli).toBe('stub'); expect(result.installation.agentName).toBe('my-stub'); expect(result.instanceId).toBe('default'); expect(result.detected.path).toBe('(builtin)'); // Post-refactor: runtimeType is per-adapter (e.g. 'stub', 'claude-code', // 'codex'); the legacy `wrappedCli` slot is folded into `runtimeType` and // 'host: byo' distinguishes a CLI-attached agent from a hosted one of the // same identity. The stub adapter declares runtimeType='stub'. expect(client.post).toHaveBeenCalledWith( '/api/registry/publish', expect.objectContaining({ manifest: expect.objectContaining({ name: 'my-stub', runtimeType: 'stub', }), }), ); expect(client.post).toHaveBeenCalledWith( '/api/registry/install', expect.objectContaining({ agentName: 'my-stub', podId: 'pod-1', config: expect.objectContaining({ runtime: expect.objectContaining({ runtimeType: 'stub', adapter: 'stub', host: 'byo', }), }), }), ); }); test('attach sends the handoff in the install request and consumes its result', async () => { const client = makeClient({ runtimeToken: 'cm_agent_local' }); const post = client.post; client.post = jest.fn(async (route, body) => { const result = await post(route, body); return route === '/api/registry/install' ? { ...result, memoryCopy: { copied: true } } : result; }); const result = await performAttach({ client, adapterName: 'stub', agentName: 'local', podId: 'pod-1', memoryFrom: 'hosted', memoryInstance: 'source-1' }); expect(result.runtimeToken).toBe('cm_agent_local'); expect(client.post).toHaveBeenCalledWith('/api/registry/install', expect.objectContaining({ resumeExisting: true, memoryFrom: { agentName: 'hosted', instanceId: 'source-1' }, })); expect(client.get.mock.calls.some(([route]) => route.startsWith('/api/agent-memory'))).toBe(false); }); test.each([undefined, {}, { installTimeMemoryHandoff: false }])('an older server refuses before publish or install: %j', async (capability) => { const client = makeClient(); client.get.mockResolvedValue(capability); await expect(performAttach({ client, adapterName: 'stub', agentName: 'local', podId: 'pod-1', memoryFrom: 'hosted' })) .rejects.toThrow(/upgrade the server/); expect(client.post).not.toHaveBeenCalled(); }); test('a missing capability endpoint refuses before publish or install', async () => { const client = makeClient(); client.get.mockRejectedValue(new Error('Not found')); await expect(performAttach({ client, adapterName: 'stub', agentName: 'local', podId: 'pod-1', memoryFrom: 'hosted' })) .rejects.toThrow('Not found'); expect(client.post).not.toHaveBeenCalled(); }); test('a refused install-time copy preserves the saved token and never mints another', async () => { saveAgentToken('local', { agentName: 'local', runtimeToken: 'cm_agent_old' }); const client = makeClient(); const post = client.post; client.post = jest.fn(async (route, body) => { if (route === '/api/registry/install') throw new Error('Local agent already has different memory'); return post(route, body); }); await expect(performAttach({ client, adapterName: 'stub', agentName: 'local', podId: 'pod-1', memoryFrom: 'hosted' })) .rejects.toThrow(/different memory/); expect(loadAgentToken('local').runtimeToken).toBe('cm_agent_old'); expect(client.post.mock.calls.some(([route]) => route.endsWith('/runtime-tokens'))).toBe(false); }); test('omits config.wakeOnMessage by default (ADR-018 ambient wake is opt-in)', async () => { const client = makeClient({ runtimeToken: 'cm_agent_x' }); await performAttach({ client, adapterName: 'stub', agentName: 'quiet', podId: 'pod-1', }); const installBody = client.post.mock.calls.find(([route]) => route === '/api/registry/install')[1]; expect(installBody.config).not.toHaveProperty('wakeOnMessage'); }); test('--wake-on-message sets config.wakeOnMessage.enabled on install', async () => { const client = makeClient({ runtimeToken: 'cm_agent_x' }); await performAttach({ client, adapterName: 'stub', agentName: 'ambient', podId: 'pod-1', wakeOnMessage: true, }); expect(client.post).toHaveBeenCalledWith( '/api/registry/install', expect.objectContaining({ config: expect.objectContaining({ wakeOnMessage: { enabled: true } }), }), ); }); test('keeps the provider block in the local attach result, not the install request', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-provider-attach-')); try { const workspace = path.join(root, 'workspace'); const keyFile = path.join(root, 'provider-key'); const envPath = path.join(root, 'environment.json'); fs.mkdirSync(workspace, { recursive: true }); fs.writeFileSync(keyFile, 'provider-key', { mode: 0o600 }); fs.chmodSync(keyFile, 0o600); fs.writeFileSync(envPath, JSON.stringify({ version: 1, workspace: { path: workspace }, model: 'gpt-5.4', provider: { id: 'litellm', baseURL: 'https://llm.example.test/v1', keyFile, }, })); const client = makeClient({ runtimeToken: 'cm_agent_provider' }); const result = await performAttach({ client, adapterName: 'opencode', agentName: 'provider-seat', podId: 'pod-provider', envPath, adapterRegistry: { getAdapter: () => ({ name: 'opencode', supportsProvider: true, detect: async () => ({ path: '/usr/local/bin/opencode', version: '1.18.35' }), validateEnvironment: async () => {}, }), listAdapterNames: () => ['opencode'], }, }); const install = client.post.mock.calls.find(([route]) => route === '/api/registry/install')[1]; expect(install.config.environment).not.toHaveProperty('provider'); expect(result.environment.provider.keyFile).toBe(keyFile); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test('refuses a provider block when the selected adapter is not OpenCode', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-provider-adapter-')); try { const envPath = path.join(root, 'environment.json'); fs.writeFileSync(envPath, JSON.stringify({ version: 1, model: 'gpt-5.4', provider: { id: 'litellm', baseURL: 'https://llm.example.test/v1', keyFile: '/tmp/provider-key', }, })); const client = makeClient({ runtimeToken: 'cm_agent_provider' }); await expect(performAttach({ client, adapterName: 'stub', agentName: 'provider-seat', podId: 'pod-provider', envPath, adapterRegistry: { getAdapter: () => ({ name: 'stub', detect: async () => ({ version: '1.0' }) }), listAdapterNames: () => ['stub'], }, })).rejects.toThrow(/requires a provider-capable adapter/); expect(client.post).not.toHaveBeenCalled(); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test('falls back to /runtime-tokens when install does not return runtimeToken', async () => { const client = makeClient({ runtimeToken: null }); const result = await performAttach({ client, adapterName: 'stub', agentName: 'my-stub', podId: 'pod-2', }); expect(result.runtimeToken).toBe('cm_agent_from_tokens_route'); // force:true is required so the server clears the User row's hashed // token (preserved across detach per ADR-001 identity-continuity) and // mints a fresh raw token. Without force:true, re-attach after detach // gets {existing:true} with no token. See registry.runtime-tokens.test.js // and the 2026-04-17 detach+reattach race fix. expect(client.post).toHaveBeenCalledWith( '/api/registry/pods/pod-2/agents/my-stub/runtime-tokens', { force: true }, ); }); test('swallows publish errors (agent may already be published) and continues', async () => { const client = makeClient({ publishOk: false, runtimeToken: 'cm_agent_ok' }); const result = await performAttach({ client, adapterName: 'stub', agentName: 'my-stub', podId: 'pod-3', }); expect(result.runtimeToken).toBe('cm_agent_ok'); // publish was attempted and swallowed; install still happened expect(client.post).toHaveBeenCalledWith( '/api/registry/install', expect.any(Object), ); }); test('rejects unknown adapter names', async () => { const client = makeClient(); await expect(performAttach({ client, adapterName: 'does-not-exist', agentName: 'x', podId: 'p', })).rejects.toThrow(/Unknown adapter/); expect(client.post).not.toHaveBeenCalled(); }); // Note: an integration test that exercised the full performAttach flow // with adapterName='claude' was removed when wiring CLI tests into CI // (#450/PR #453). It depended on `claude --version` succeeding on PATH, // which CI runners do not have. The behavior is fully covered by the // adapter-agnostic stub-adapter test above + the buildDefaultEnvironment // unit tests in the second describe block. test('stub adapter (no MCP support) attaches without a default environment', async () => { const client = makeClient({ runtimeToken: 'cm_agent_stub' }); await performAttach({ client, adapterName: 'stub', agentName: 'my-stub-no-mcp', podId: 'pod-stub', }); const installCall = client.post.mock.calls.find(([route]) => route === '/api/registry/install'); expect(installCall).toBeDefined(); // Default env is gated to adapters that read --mcp-config; stub omits it. expect(installCall[1].config.environment).toBeUndefined(); }); }); describe('buildDefaultEnvironment', () => { test('returns null for adapters with no MCP consumption path (stub)', () => { expect(buildDefaultEnvironment('stub')).toBeNull(); expect(buildDefaultEnvironment('does-not-exist')).toBeNull(); }); test.each(['claude', 'codex', 'pi'])('returns a single mcp entry for %s with placeholder env values', (adapterName) => { // codex joined the set after the 2026-07-22 as-operator attribution // incident: an MCP-less codex agent has no sanctioned posting tool and // falls back to whatever it finds in the shell (the operator's CLI // profile — posting AS the human). pi joined with the C4 run (TASK-048), // where the same gap showed up on a daemon-provisioned seat. const env = buildDefaultEnvironment(adapterName); expect(env.mcp).toHaveLength(1); expect(env.mcp[0].name).toBe('commonly'); expect(env.mcp[0].transport).toBe('stdio'); // Placeholders are substituted at spawn-time by the adapter; the env file // itself MUST stay free of secrets so it can be checked in. expect(env.mcp[0].env.COMMONLY_AGENT_TOKEN).toBe('${COMMONLY_AGENT_TOKEN}'); expect(env.mcp[0].env.COMMONLY_API_URL).toBe('${COMMONLY_API_URL}'); }); }); describe('saveAgentToken / loadAgentToken', () => { beforeEach(() => { fs.rmSync(path.join(tokensTmpDir, '.commonly'), { recursive: true, force: true }); }); test('persists and reads back the token record, stamping savedAt', () => { saveAgentToken('my-stub', { agentName: 'my-stub', instanceId: 'default', podId: 'pod-1', instanceUrl: 'http://localhost:5000', runtimeToken: 'cm_agent_xyz', adapter: 'stub', }); const loaded = loadAgentToken('my-stub'); expect(loaded.runtimeToken).toBe('cm_agent_xyz'); expect(loaded.adapter).toBe('stub'); expect(loaded.podId).toBe('pod-1'); expect(loaded.savedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); // On-disk file is at ~/.commonly/tokens/.json const file = path.join(tokensTmpDir, '.commonly', 'tokens', 'my-stub.json'); expect(fs.existsSync(file)).toBe(true); }); test('loadAgentToken returns null when the file does not exist', () => { expect(loadAgentToken('never-attached')).toBeNull(); }); }); // ── #913: bootstrap the token record from env vars on first `agent run` ───── describe('bootstrapAgentRecordFromEnv', () => { const identityResponse = { agentName: 'smoke-agent', instanceId: 'default', installations: [ { podId: 'pod-dm', podType: 'agent-admin', instanceId: 'default', status: 'active', type: 'dm' }, { podId: 'pod-main', podType: 'chat', instanceId: 'default', status: 'active', type: 'installation' }, ], }; const makeRegistry = ({ claudeFound = true, codexFound = true, piFound = false, opencodeFound = false, } = {}) => ({ getAdapter: (n) => ({ claude: { name: 'claude', detect: async () => (claudeFound ? { path: '/bin/claude', version: '1' } : null) }, codex: { name: 'codex', detect: async () => (codexFound ? { path: '/bin/codex', version: '1' } : null) }, pi: { name: 'pi', detect: async () => (piFound ? { path: '/bin/pi', version: '1' } : null) }, opencode: { name: 'opencode', detect: async () => (opencodeFound ? { path: '/bin/opencode', version: '1' } : null) }, }[n] || null), listAdapterNames: () => ['stub', 'claude', 'codex', 'pi', 'opencode'], }); const makeFactory = (response = identityResponse) => { const get = jest.fn(async (route) => { if (route === '/api/agents/runtime/installations') return response; throw new Error(`unexpected GET ${route}`); }); return jest.fn(() => ({ get })); }; test('returns null when COMMONLY_AGENT_TOKEN is not set', async () => { const record = await bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: {} }); expect(record).toBeNull(); }); test('rejects a non-runtime token instead of sending it anywhere', async () => { const factory = makeFactory(); await expect(bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'eyJhbGciOi-user-jwt' }, clientFactory: factory, })).rejects.toThrow(/cm_agent_/); expect(factory).not.toHaveBeenCalled(); }); test('synthesizes a full record from the installations endpoint + detected adapter', async () => { const factory = makeFactory(); const record = await bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test', }, clientFactory: factory, adapterRegistry: makeRegistry(), }); expect(factory).toHaveBeenCalledWith({ instance: 'https://api.example.test', token: 'cm_agent_abc123' }); expect(record.agentName).toBe('smoke-agent'); expect(record.instanceId).toBe('default'); // podId prefers the real installation over the agent-admin DM row expect(record.podId).toBe('pod-main'); expect(record.instanceUrl).toBe('https://api.example.test'); expect(record.runtimeToken).toBe('cm_agent_abc123'); expect(record.adapter).toBe('claude'); expect(record.workspacePath).toBeNull(); // environment matches what attach would have written for the same adapter expect(record.environment.mcp[0].name).toBe('commonly'); expect(record.environment.mcp[0].env.COMMONLY_AGENT_TOKEN).toBe('${COMMONLY_AGENT_TOKEN}'); // and carries the daemon's default sandbox: nobody declared one, so the // seat is public and its mode resolves per host at spawn. expect(record.environment.sandbox).toEqual({ trust: 'public' }); }); // TASK-294: a claude seat whose every pod is confirmed private reads the // directory it was started from; anything less keeps the public default. describe('first-run workspace trust', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'commonly-bootstrap-ws-')); const home = path.join(root, 'home'); const repo = path.join(home, 'code', 'repo'); fs.mkdirSync(repo, { recursive: true }); afterAll(() => fs.rmSync(root, { recursive: true, force: true })); const withVisibility = (publicRead) => ({ ...identityResponse, installations: identityResponse.installations.map((row) => ({ ...row, publicRead, communityListed: false, joinPolicy: 'invite-only', })), }); const run = async ({ response, startDir = repo, registry = makeRegistry(), allow, platform = 'darwin', } = {}) => { const lines = []; const record = await bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: makeFactory(response), adapterRegistry: registry, log: (line) => lines.push(line), startDir, home, platform, ...(allow ? { allow } : {}), }); return { record, lines }; }; test('every pod private and a project start dir: the seat reads that project', async () => { const { record, lines } = await run({ response: withVisibility(false) }); expect(record.adapter).toBe('claude'); expect(record.environment.sandbox).toEqual({ trust: 'workspace' }); expect(record.workspacePath).toBe(fs.realpathSync(repo)); expect(record.environment.mcp[0].name).toBe('commonly'); expect(lines.some((line) => line.includes(fs.realpathSync(repo)))).toBe(true); }); test('one publicly readable pod keeps the public default and says why', async () => { const response = withVisibility(false); response.installations[1] = { ...response.installations[1], publicRead: true }; const { record, lines } = await run({ response }); expect(record.environment.sandbox).toEqual({ trust: 'public' }); expect(record.workspacePath).toBeNull(); expect(lines.some((line) => /scratch folder.*publicly readable/.test(line))).toBe(true); }); test('a server that reports no visibility keeps the public default', async () => { const { record } = await run({ response: identityResponse }); expect(record.environment.sandbox).toEqual({ trust: 'public' }); expect(record.workspacePath).toBeNull(); }); test('started from $HOME keeps the public default and says where to start instead', async () => { const { record, lines } = await run({ response: withVisibility(false), startDir: home }); expect(record.environment.sandbox).toEqual({ trust: 'public' }); expect(record.workspacePath).toBeNull(); expect(lines.some((line) => /your home directory\. To let it read a project/.test(line))).toBe(true); }); // #2784 runs a workspace seat under bwrap on Linux; agent run checks that // the host can start the jail before the token file is written. test('on Linux, every pod private: the seat reads that project, and takes its grants', async () => { const { record } = await run({ response: withVisibility(false), platform: 'linux' }); expect(record.environment.sandbox).toEqual({ trust: 'workspace' }); expect(record.workspacePath).toBe(fs.realpathSync(repo)); const granted = await run({ response: withVisibility(false), platform: 'linux', allow: ['write'] }); expect(granted.record.environment.sandbox).toEqual({ trust: 'workspace', allow: ['write'] }); }); test('on Linux a public pod keeps the public default, which agent run then refuses for Claude', async () => { const { record, lines } = await run({ response: withVisibility(true), platform: 'linux' }); expect(record.environment.sandbox).toEqual({ trust: 'public' }); expect(record.workspacePath).toBeNull(); expect(lines.some((line) => /scratch folder, because a pod it answers in is publicly readable/ .test(line))).toBe(true); }); test('on native Windows the seat keeps the public default and says why', async () => { const { record, lines } = await run({ response: withVisibility(false), platform: 'win32' }); expect(record.environment.sandbox).toEqual({ trust: 'public' }); expect(record.workspacePath).toBeNull(); expect(lines.some((line) => /scratch folder, because an agent works in its folder only on macOS and Linux/ .test(line))).toBe(true); await expect(run({ response: withVisibility(false), platform: 'win32', allow: ['write'] })) .rejects.toThrow(/--allow write applies only .* only on macOS and Linux for now/); }); test('a codex seat keeps its baseline and hears nothing about workspaces', async () => { const { record, lines } = await run({ response: withVisibility(false), registry: makeRegistry({ claudeFound: false }), }); expect(record.adapter).toBe('codex'); expect(record.workspacePath).toBeNull(); expect(record.environment.sandbox?.trust).not.toBe('workspace'); expect(lines.some((line) => /scratch folder|can read/.test(line))).toBe(false); }); test('a write grant lands on the record, and the line says its commands reach the network', async () => { const { record, lines } = await run({ response: withVisibility(false), allow: ['write'] }); expect(record.environment.sandbox).toEqual({ trust: 'workspace', allow: ['write'] }); const said = lines.find((line) => line.includes(fs.realpathSync(repo))); expect(said).toMatch(/can read and edit files in .* and run commands there\./); expect(said).toMatch(/commands can reach the network/); expect(said).toMatch(/\.git stays read-only, so it cannot commit/); }); test('a web grant alone reads, runs read-only git and fetches pages, and still cannot edit', async () => { const { record, lines } = await run({ response: withVisibility(false), allow: ['web'] }); expect(record.environment.sandbox).toEqual({ trust: 'workspace', allow: ['web'] }); expect(lines.some((line) => /run read-only git there, and fetch web pages; it cannot edit files\./ .test(line))).toBe(true); }); test('both grants are named in one line', async () => { const { record, lines } = await run({ response: withVisibility(false), allow: ['write', 'web'] }); expect(record.environment.sandbox).toEqual({ trust: 'workspace', allow: ['write', 'web'] }); expect(lines.some((line) => /run commands there, and fetch web pages\. Its commands can reach the network/ .test(line))).toBe(true); }); test('grants on a seat that will not work in its folder are refused with the reason', async () => { const response = withVisibility(false); response.installations[1] = { ...response.installations[1], publicRead: true }; await expect(run({ response, allow: ['write'] })) .rejects.toThrow(/--allow write applies only to an agent that works in the folder it starts in.*scratch folder, because .*publicly readable/); }); test('grants on a codex seat are refused rather than dropped', async () => { await expect(run({ response: withVisibility(false), registry: makeRegistry({ claudeFound: false }), allow: ['web'], })).rejects.toThrow(/--allow web applies only to an agent that works in the folder it starts in/); }); }); // TASK-294: `run --allow`, read once when the token file is written. describe('run --allow', () => { test('takes write and web, comma-separated, in a fixed order, once each', () => { expect(parseAllowGrants(undefined)).toEqual([]); expect(parseAllowGrants('web,write')).toEqual(['write', 'web']); expect(parseAllowGrants(' write , write,')).toEqual(['write']); }); test.each(['', ',', 'bash', 'write,net', 'Write'])('refuses %j rather than dropping it', (value) => { expect(() => parseAllowGrants(value)).toThrow(/--allow takes write and\/or web, comma-separated/); }); const saved = (allow) => ({ agentName: 'smoke-agent', environment: { sandbox: { trust: 'workspace', ...(allow ? { allow } : {}) } }, }); test('a run without --allow keeps whatever the token file holds', () => { expect(checkRunAllowRequest({ record: saved(['write']), requested: [] })).toEqual({ ok: true }); }); test('a run that asks for the grants the token file holds goes ahead', () => { expect(checkRunAllowRequest({ record: saved(['write', 'web']), requested: ['write', 'web'] })) .toEqual({ ok: true }); }); test('a run that asks for other grants is refused with the way to change them', () => { const none = checkRunAllowRequest({ record: saved(), requested: ['write'] }); expect(none.ok).toBe(false); expect(none.message).toMatch(/'smoke-agent' was set up with no --allow; --allow write was not applied\./); expect(none.message).toMatch(/commonly agent detach smoke-agent --force, then re-run .* --allow write/); const fewer = checkRunAllowRequest({ record: saved(['write', 'web']), requested: ['write'] }); expect(fewer.ok).toBe(false); expect(fewer.message).toMatch(/was set up with --allow write,web; --allow write was not applied/); }); }); test('a pi seat bootstrapped from env gets the kernel server but no sandbox block, which pi refuses', async () => { const record = await bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: makeFactory({ ...identityResponse, installations: identityResponse.installations.map((row) => ({ ...row, runtimeAdapter: 'pi' })), }), adapterRegistry: makeRegistry({ claudeFound: false, codexFound: false, piFound: true }), }); expect(record.adapter).toBe('pi'); expect(record.environment.mcp[0].name).toBe('commonly'); expect(record.environment).not.toHaveProperty('sandbox'); }); test('falls through the detect order when the first CLI is absent, and says so', async () => { const lines = []; const record = await bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: makeFactory(), adapterRegistry: makeRegistry({ claudeFound: false }), log: (line) => lines.push(line), }); expect(record.adapter).toBe('codex'); // A Claude Code user who gets a Codex agent must be told, not left to notice. expect(lines).toContain('claude not found on PATH, so this agent runs with codex. To choose, pass --adapter .'); }); test('the first CLI found runs with nothing to report', async () => { const lines = []; await bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: makeFactory(), adapterRegistry: makeRegistry(), log: (line) => lines.push(line), }); expect(lines.filter((line) => /not found on PATH/.test(line))).toEqual([]); }); test('no CLI at all is an error carrying a code the run action can recognise', async () => { await expect(bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: makeFactory(), adapterRegistry: makeRegistry({ claudeFound: false, codexFound: false }), })).rejects.toMatchObject({ code: NO_AGENT_CLI_CODE }); }); test('dsh cannot bootstrap from a bare runtime token without provider settings', async () => { const adapterRegistry = { getAdapter: () => ({ detect: async () => ({ path: '/bin/dsh', version: '0.2.1-rc.2' }) }), listAdapterNames: () => ['dsh'], }; await expect(bootstrapAgentRecordFromEnv({ name: 'dsh-seat', adapterOverride: 'dsh', adapterRegistry, env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: makeFactory(), })).rejects.toThrow(/attach it with --env/); }); test('a saved seat lacks the default sandbox when it has no workspace and its environment enforces none, or is absent', () => { const saved = (overrides) => ({ adapter: 'claude', workspacePath: null, environment: { mcp: [] }, ...overrides }); expect(savedSeatLacksDefaultSandbox(saved())).toBe(true); expect(savedSeatLacksDefaultSandbox(saved({ environment: { mcp: [], sandbox: { trust: 'public' } } }))).toBe(false); expect(savedSeatLacksDefaultSandbox(saved({ environment: { mcp: [], sandbox: { mode: 'workspace' } } }))).toBe(false); expect(savedSeatLacksDefaultSandbox(saved({ workspacePath: '/work/seat' }))).toBe(false); expect(savedSeatLacksDefaultSandbox(saved({ adapter: 'pi' }))).toBe(false); // Attached without an environment file: it runs unconfined too (TASK-288). expect(savedSeatLacksDefaultSandbox(saved({ environment: null }))).toBe(true); expect(savedSeatLacksDefaultSandbox(saved({ environment: undefined }))).toBe(true); expect(savedSeatLacksDefaultSandbox(saved({ adapter: 'pi', environment: null }))).toBe(false); }); test('OpenCode is not selected by automatic bootstrap detection', async () => { expect(BOOTSTRAP_ADAPTER_DETECT_ORDER).toEqual(['claude', 'codex', 'pi']); await expect(bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: makeFactory(), adapterRegistry: makeRegistry({ claudeFound: false, codexFound: false, opencodeFound: true }), })).rejects.toThrow(/No supported agent CLI found on PATH/); }); test('an explicit OpenCode bootstrap requires the server to declare that adapter for the selected install', async () => { const declared = makeFactory({ agentName: 'smoke-agent', instanceId: 'default', installations: [{ podId: 'pod-main', podType: 'chat', instanceId: 'default', status: 'active', type: 'installation', runtimeAdapter: 'OpenCode', }], }); const record = await bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: declared, adapterRegistry: makeRegistry({ opencodeFound: true }), adapterOverride: 'opencode', }); expect(record.adapter).toBe('opencode'); const undeclared = makeFactory({ ...identityResponse, installations: identityResponse.installations.map((row) => ({ ...row, runtimeAdapter: null })), }); await expect(bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: undeclared, adapterRegistry: makeRegistry({ opencodeFound: true }), adapterOverride: 'opencode', })).rejects.toThrow(/sign in with commonly login, then run commonly agent attach opencode --pod pod-main --name smoke-agent \[--env \]/); }); test('an older server without runtimeAdapter projection says to upgrade instead of re-attaching', async () => { const refusal = serverAdapterBindingRefusal({ adapterName: 'opencode', agentName: 'smoke-agent', installations: identityResponse.installations, podId: 'pod-main', instanceId: 'default', }); expect(refusal).toMatch(/server predates runtime adapter bindings/i); expect(refusal).toMatch(/upgrade the server/i); expect(refusal).not.toMatch(/agent attach/); }); test('an explicit pi bootstrap requires a matching server declaration', async () => { const declared = makeFactory({ agentName: 'smoke-agent', instanceId: 'default', installations: [{ podId: 'pod-main', podType: 'chat', instanceId: 'default', status: 'active', type: 'installation', runtimeAdapter: 'PI', }], }); const record = await bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: declared, adapterRegistry: makeRegistry({ piFound: true }), adapterOverride: 'pi', }); expect(record.adapter).toBe('pi'); const undeclared = makeFactory({ ...identityResponse, installations: identityResponse.installations.map((row) => ({ ...row, runtimeAdapter: null })), }); await expect(bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: undeclared, adapterRegistry: makeRegistry({ piFound: true }), adapterOverride: 'pi', })).rejects.toThrow(/Adapter 'pi' is not declared.*commonly agent attach pi --pod pod-main --name smoke-agent/); await expect(bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: makeFactory({ ...identityResponse, installations: identityResponse.installations.map((row) => ({ ...row, runtimeAdapter: null })), }), adapterRegistry: makeRegistry({ claudeFound: false, codexFound: false, piFound: true }), })).rejects.toThrow(/Adapter 'pi' is not declared.*commonly agent attach pi --pod pod-main --name smoke-agent/); }); test('attach checks the installed binding and withholds the Run with hint when the server is old', async () => { const get = jest.fn(async () => ({ installations: identityResponse.installations })); const clientFactory = jest.fn(() => ({ get })); const binding = await checkAttachedAdapterBinding({ adapterName: 'opencode', agentName: 'smoke-agent', podId: 'pod-main', instanceId: 'default', instanceUrl: 'https://api.example.test', runtimeToken: 'cm_agent_abc123', clientFactory, }); const log = jest.fn(); const warn = jest.fn(); printAttachRunGuidance({ agentName: 'smoke-agent', binding, log, warn }); expect(clientFactory).toHaveBeenCalledWith({ instance: 'https://api.example.test', token: 'cm_agent_abc123', }); expect(get).toHaveBeenCalledWith('/api/agents/runtime/installations'); expect(binding.canRun).toBe(false); expect(log).not.toHaveBeenCalledWith(expect.stringContaining('Run with:')); expect(warn).toHaveBeenCalledWith(expect.stringContaining('predates runtime adapter bindings')); }); test.each([ { description: 'an empty list', installations: [] }, { description: 'a missing list' }, { description: 'no installation rows', installations: [{ podId: 'pod-main', type: 'directMessage' }] }, ])('attach does not call an unverifiable binding state an old server ($description)', async ({ installations }) => { const response = installations === undefined ? {} : { installations }; const clientFactory = jest.fn(() => ({ get: jest.fn(async () => response) })); const binding = await checkAttachedAdapterBinding({ adapterName: 'opencode', agentName: 'smoke-agent', podId: 'pod-main', instanceId: 'default', instanceUrl: 'https://api.example.test', runtimeToken: 'cm_agent_abc123', clientFactory, }); const log = jest.fn(); const warn = jest.fn(); printAttachRunGuidance({ agentName: 'smoke-agent', binding, log, warn }); expect(binding.canRun).toBe(false); expect(log).not.toHaveBeenCalledWith(expect.stringContaining('Run with:')); expect(warn).toHaveBeenCalledWith(expect.stringContaining('Could not verify the server adapter binding')); expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('predates runtime adapter bindings')); }); test('attach prints Run with only after the server confirms the selected adapter', async () => { const clientFactory = jest.fn(() => ({ get: jest.fn(async () => ({ installations: [{ podId: 'pod-main', instanceId: 'writer', type: 'installation', runtimeAdapter: ' OpenCode ', }], })), })); const binding = await checkAttachedAdapterBinding({ adapterName: 'opencode', agentName: 'smoke-agent', podId: 'pod-main', instanceId: 'writer', instanceUrl: 'https://api.example.test', runtimeToken: 'cm_agent_abc123', clientFactory, }); const log = jest.fn(); const warn = jest.fn(); printAttachRunGuidance({ agentName: 'smoke-agent', binding, log, warn }); expect(binding.canRun).toBe(true); expect(log).toHaveBeenCalledWith(expect.stringContaining('Run with: commonly agent run smoke-agent')); expect(warn).not.toHaveBeenCalled(); }); test('the server-binding rule follows the grant-broker allowlist, with stub exempt', () => { expect(adapterRequiresServerBinding('pi')).toBe(true); expect(adapterRequiresServerBinding('opencode')).toBe(true); expect(adapterRequiresServerBinding('claude')).toBe(false); expect(adapterRequiresServerBinding('codex')).toBe(false); expect(adapterRequiresServerBinding('stub')).toBe(false); const futureBrokerAdapter = 'task184-test-adapter'; ADAPTERS_WITH_GRANT_BROKER.add(futureBrokerAdapter); try { expect(adapterRequiresServerBinding(futureBrokerAdapter)).toBe(false); } finally { ADAPTERS_WITH_GRANT_BROKER.delete(futureBrokerAdapter); } }); test('reads the declared adapter only from the matching active installation', () => { const installations = [ { podId: 'pod-dm', instanceId: 'writer', type: 'dm', runtimeAdapter: 'claude' }, { podId: 'pod-other', instanceId: 'writer', type: 'installation', runtimeAdapter: 'codex' }, { podId: 'pod-main', instanceId: 'other', type: 'installation', runtimeAdapter: 'claude' }, { podId: 'pod-main', instanceId: 'writer', type: 'installation', runtimeAdapter: ' OpenCode ' }, ]; expect(runtimeAdapterForInstallation({ installations, podId: 'pod-main', instanceId: 'writer' })) .toBe('opencode'); expect(runtimeAdapterForInstallation({ installations, podId: 'pod-main', instanceId: 'missing' })) .toBeNull(); }); test('errors with install guidance when no CLI is on PATH', async () => { await expect(bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: makeFactory(), adapterRegistry: makeRegistry({ claudeFound: false, codexFound: false }), })).rejects.toThrow(/No supported agent CLI found on PATH/); }); test('honors --adapter override and validates it', async () => { const record = await bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: makeFactory(), adapterRegistry: makeRegistry(), adapterOverride: 'codex', }); expect(record.adapter).toBe('codex'); await expect(bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: makeFactory(), adapterRegistry: makeRegistry(), adapterOverride: 'gemini', })).rejects.toThrow(/Unknown adapter 'gemini'/); }); test("the token's identity wins over a mistyped CLI argument", async () => { const log = jest.fn(); const record = await bootstrapAgentRecordFromEnv({ name: 'smoke-agnet', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: makeFactory(), adapterRegistry: makeRegistry(), log, }); expect(record.agentName).toBe('smoke-agent'); expect(log).toHaveBeenCalledWith(expect.stringContaining("token belongs to 'smoke-agent'")); }); test('wraps identity-resolution failures with the URL and a copy hint', async () => { const get = jest.fn(async () => { throw new Error('HTTP 401'); }); await expect(bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: () => ({ get }), adapterRegistry: makeRegistry(), })).rejects.toThrow(/against https:\/\/api\.example\.test: HTTP 401/); }); // ── the multi-installation projection (TASK-019) ─────────────────────────── // A runtime token carries an IDENTITY, and one identity can hold several // installations. `bootstrapAgentRecordFromEnv` collapses that list into ONE // local record, and the collapse is positional: // first {type:'installation', status:'active'} → else installations[0] → else null // Only the first link was covered (the DM-first case above). The other two // decide whether the seat boots against the pod it was installed into or // against whatever the payload happened to list first, so each is pinned // separately here. const project = (installations, identity = {}) => bootstrapAgentRecordFromEnv({ name: 'smoke-agent', env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, clientFactory: makeFactory({ agentName: 'smoke-agent', instanceId: 'default', installations, ...identity, }), adapterRegistry: makeRegistry(), }); test('projection: the FIRST active installation wins when an identity holds several', async () => { const record = await project([ { podId: 'pod-a', podType: 'chat', instanceId: 'default', status: 'active', type: 'installation' }, { podId: 'pod-b', podType: 'chat', instanceId: 'default', status: 'active', type: 'installation' }, ]); // Not "one of" — the first. Nothing in the payload orders these rows, so // which pod a multi-installation seat projects is currently insertion order // (the server's rows come from an un-sorted `AgentInstallation.find`, // agentsRuntime.ts `/installations` ← agentRuntimeAuth.ts:121/:186). // Pinned so a change to that preference has to be deliberate. expect(record.podId).toBe('pod-a'); }); test('projection: with no installation rows at all, the first DM row becomes the pod', async () => { const record = await project([ { podId: 'pod-dm', podType: 'agent-admin', instanceId: 'default', status: 'active', type: 'dm' }, ]); // Reachable: the endpoint appends DM rows after installations and never // invents an installation row, so a token with no active installation but // an agent-admin pod lands here. expect(record.podId).toBe('pod-dm'); }); test('projection: an empty, missing or non-array list yields no pod and the identity instanceId', async () => { for (const installations of [[], undefined, { podId: 'x' }]) { // eslint-disable-next-line no-await-in-loop const record = await project(installations, { instanceId: 'laptop-1' }); expect(record.podId).toBeNull(); expect(record.instanceId).toBe('laptop-1'); } }); test('projection: the identity instanceId outranks the chosen row\'s, which is only a fallback', async () => { const record = await project( [{ podId: 'pod-a', podType: 'chat', instanceId: 'row-instance', status: 'active', type: 'installation' }], { instanceId: undefined }, ); expect(record.instanceId).toBe('row-instance'); const withIdentity = await project( [{ podId: 'pod-a', podType: 'chat', instanceId: 'row-instance', status: 'active', type: 'installation' }], { instanceId: 'identity-instance' }, ); expect(withIdentity.instanceId).toBe('identity-instance'); }); test('projection: an installation that is not active is skipped in favour of one that is', async () => { const record = await project([ { podId: 'pod-dm', podType: 'agent-admin', instanceId: 'default', status: 'active', type: 'dm' }, { podId: 'pod-stale', podType: 'chat', instanceId: 'default', status: 'inactive', type: 'installation' }, { podId: 'pod-live', podType: 'chat', instanceId: 'default', status: 'active', type: 'installation' }, ]); // This is the case that makes the `status === 'active'` term load-bearing: // drop it and the projection picks pod-stale, the older installation. expect(record.podId).toBe('pod-live'); }); test('projection: with no active installation the FIRST row is projected regardless of status', async () => { const record = await project([ { podId: 'pod-stale', podType: 'chat', instanceId: 'default', status: 'inactive', type: 'installation' }, ]); // The fallback branch, stated for what it is: a row that is not active is // still projected rather than refused. Unreachable from the live endpoint // today — both auth paths query `status: 'active'` (agentRuntimeAuth.ts:121 // for a User-row token, :186 for an installation-bound one) — so this pins // the CLI's behaviour if that endpoint ever widens, where the failure would // otherwise be silent: a seat booting against a pod whose installation is // not live. expect(record.podId).toBe('pod-stale'); }); }); describe('resolveAttachSandbox — workspace trust (TASK-294)', () => { test('a claude seat resolves the same host mode a public one does', () => { expect(resolveAttachSandbox({ environment: { sandbox: { trust: 'workspace' } }, adapterName: 'claude', platform: 'darwin', })).toEqual({ mode: 'workspace', trust: 'workspace' }); expect(resolveAttachSandbox({ environment: { sandbox: { trust: 'workspace' } }, adapterName: 'claude', platform: 'linux', })).toEqual({ mode: 'bwrap', trust: 'workspace' }); }); test('every other adapter is refused before anything is published', () => { for (const adapterName of ['codex', 'opencode', 'qwen', 'pi', 'goose', 'dsh']) { expect(() => resolveAttachSandbox({ environment: { sandbox: { trust: 'workspace' } }, adapterName, platform: 'darwin', })).toThrow(new RegExp(`^${adapterName} adapter: sandbox\\.trust=workspace is claude-only for now`)); } }); test('beside mode none it is refused, never run unsandboxed', () => { expect(() => resolveAttachSandbox({ environment: { sandbox: { trust: 'workspace', mode: 'none' } }, adapterName: 'claude', })).toThrow(/sandbox\.trust=workspace requires an enforced sandbox mode/); }); });