diff --git a/cli/__tests__/adapter-capabilities.test.mjs b/cli/__tests__/adapter-capabilities.test.mjs index fb137ef2f3..0be550dd58 100644 --- a/cli/__tests__/adapter-capabilities.test.mjs +++ b/cli/__tests__/adapter-capabilities.test.mjs @@ -14,6 +14,7 @@ const expected = { pi: { sandbox: false, mcp: true, broker: false, provider: false }, opencode: { sandbox: true, mcp: true, broker: false, provider: true }, qwen: { sandbox: true, mcp: true, broker: false, provider: true }, + dsh: { sandbox: false, mcp: true, broker: false, provider: true }, }; test('every adapter has an explicit capability matrix entry', () => { diff --git a/cli/__tests__/adapter-confinement-parity.test.mjs b/cli/__tests__/adapter-confinement-parity.test.mjs index eed9cd2244..e9d8ab6fa1 100644 --- a/cli/__tests__/adapter-confinement-parity.test.mjs +++ b/cli/__tests__/adapter-confinement-parity.test.mjs @@ -21,6 +21,6 @@ describe('adapter registry and grant-broker admission stay explicit', () => { const withheld = names.filter((name) => !eligible.has(name)); expect([...ADAPTERS_WITH_GRANT_BROKER].sort()).toEqual(['claude', 'codex']); - expect(withheld.sort()).toEqual(['opencode', 'pi', 'qwen']); + expect(withheld.sort()).toEqual(['dsh', 'opencode', 'pi', 'qwen']); }); }); diff --git a/cli/__tests__/adapters.dsh.test.mjs b/cli/__tests__/adapters.dsh.test.mjs new file mode 100644 index 0000000000..914239430e --- /dev/null +++ b/cli/__tests__/adapters.dsh.test.mjs @@ -0,0 +1,193 @@ +import { jest } from '@jest/globals'; +import { EventEmitter } from 'events'; +import { existsSync, readFileSync, statSync } from 'fs'; +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const spawnSyncMock = jest.fn(); +await jest.unstable_mockModule('child_process', () => ({ spawnSync: spawnSyncMock, spawn: jest.fn() })); +const { + default: dsh, TESTED_DSH_VERSIONS, assertPrivateDsh, buildPatch, + childEnvironment, resolveCommonlyMcp, runDsh, sessionExists, +} = await import('../src/lib/adapters/dsh.js'); + +const event = (value) => `${JSON.stringify(value)}\n`; +const fakeChild = ({ stdout = event({ type: 'session', sessionId: 'session-11111111-1111-1111-1111-111111111111' }) + + event({ type: 'final', text: 'reply' }), stderr = '', code = 0 } = {}) => { + const proc = new EventEmitter(); + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.kill = jest.fn(); + setTimeout(() => { + if (stdout) proc.stdout.emit('data', Buffer.from(stdout)); + if (stderr) proc.stderr.emit('data', Buffer.from(stderr)); + proc.emit('close', code); + }, 0); + return proc; +}; + +const withSeat = async (fn) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-unit-')); + const cwd = join(root, 'workspace'); + const keyFile = join(root, 'provider-key'); + const home = join(root, 'dsh-home'); + await mkdir(cwd); + await writeFile(keyFile, 'upstream-secret-123', { mode: 0o600 }); + await chmod(keyFile, 0o600); + const ctx = { + cwd, _dshHome: home, _dshBinary: 'dsh', agentName: 'dsh-test', + runtimeToken: 'cm_agent_secret-123', instanceUrl: 'https://api.commonly.me', + env: { PATH: '/usr/bin', DEEPSEEK_API_KEY: 'shadow-key', DSH_HOME: '/tmp/shadow', COMMONLY_AGENT_TOKEN: 'shadow-token' }, + environment: { + model: 'deepseek-chat', effort: 'high', + provider: { id: 'deepseek', baseURL: 'http://127.0.0.1:9/v1', keyFile }, + }, + }; + try { await fn(ctx, root); } finally { await rm(root, { recursive: true, force: true }); } +}; + +beforeEach(() => { + spawnSyncMock.mockReset(); + spawnSyncMock.mockReturnValue({ status: 0, stdout: `dsh ${TESTED_DSH_VERSIONS[0]}\n` }); +}); + +test('public and any declared sandbox mode refuse before a child starts', async () => { + expect(() => assertPrivateDsh({ sandbox: { trust: 'internal' } })).toThrow(/public-trust/); + expect(() => assertPrivateDsh({ sandbox: { trust: 'public' } })).toThrow(/public-trust/); + expect(() => assertPrivateDsh({ sandbox: { mode: 'none' } })).toThrow(/sandbox.mode/); + await withSeat(async (ctx) => { + const spawn = jest.fn(); + await expect(dsh.spawn('hi', { ...ctx, environment: { ...ctx.environment, sandbox: { trust: 'public' } }, _spawnImpl: spawn })) + .rejects.toThrow(/public-trust/); + expect(spawn).not.toHaveBeenCalled(); + }); +}); + +test('unprobed release refuses even for private seats', async () => { + await withSeat(async (ctx) => { + spawnSyncMock.mockReturnValue({ status: 0, stdout: 'dsh 0.2.1-rc.3\n' }); + const spawn = jest.fn(); + await expect(dsh.validateEnvironment(ctx.environment, ctx.cwd)).rejects.toThrow(/not smoke-tested/); + await expect(dsh.spawn('hi', { ...ctx, _spawnImpl: spawn })).rejects.toThrow(/not smoke-tested/); + expect(spawn).not.toHaveBeenCalled(); + }); +}); + +test('one patch selects proxy pi-ai, disables native routes and upload, and mounts only Commonly MCP', async () => { + await withSeat(async (ctx) => { + await writeFile(join(ctx.cwd, '.env'), 'DEEPSEEK_API_KEY=project-shadow\nDSH_HOME=/tmp/project-shadow\n'); + const calls = []; + const result = await dsh.spawn('hello', { ...ctx, _spawnImpl: (binary, args, options) => { + const patchPath = args[args.indexOf('--patch') + 1]; + const patchText = readFileSync(patchPath, 'utf8'); + const credentialFile = JSON.parse(patchText).at(-1).insert[0].config.env.COMMONLY_TOKEN_FILE; + calls.push({ binary, args, options, patchPath, patchText, credentialFile, + token: readFileSync(credentialFile, 'utf8'), + patchMode: statSync(patchPath).mode & 0o777, + credentialMode: statSync(credentialFile).mode & 0o777, + }); + return fakeChild(); + } }); + expect(result).toEqual({ text: 'reply', newSessionId: 'session-11111111-1111-1111-1111-111111111111' }); + const call = calls[0]; + const rows = JSON.parse(call.patchText); + expect(call.args.filter((arg) => arg === '--patch')).toHaveLength(1); + expect(rows.find((row) => row.id === 'agent-default-model').config).toEqual({ provider: 'commonly', model: 'deepseek-chat' }); + expect(rows.find((row) => row.id === 'llm-pi-ai').config.providers.commonly).toMatchObject({ + api: 'openai-completions', apiKeyEnv: 'COMMONLY_DSH_PROXY_KEY', reasoning: 'high', + baseURL: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/v1\/?$/), + compat: { supportsDeveloperRole: false, supportsStore: false }, + }); + expect(rows.find((row) => row.id === 'llm-pi-ai').config.providers.commonly.models[0].reasoningEfforts.high).toBe('high'); + expect(rows.find((row) => row.id === 'llm-deepseek').disabled).toBe(true); + expect(rows.find((row) => row.id === 'llm-deepseek-account').disabled).toBe(true); + expect(rows.find((row) => row.id === 'session-log-deepseek').config.enabled).toBe(false); + expect(rows.find((row) => row.id === 'sandbox-policy').config).toEqual({ mode: 'workspace-write', workspaceRoot: ctx.cwd }); + expect(rows.at(-1).insert[0].name).toBe('@deepseek-ai/dsh-mcp-client'); + expect(call.token).toBe(ctx.runtimeToken); + expect(call.options.env.DSH_HOME).toBe(ctx._dshHome); + expect(call.options.env.DSH_PERMISSION_MODE).toBe('workspace-write'); + expect(call.options.env.DSH_TELEMETRY_MODE).toBe('DISABLED'); + expect(call.options.env.DSH_TELEMETRY_DISABLED).toBe('1'); + expect(call.options.env.DEEPSEEK_API_KEY).toBeUndefined(); + expect(call.options.env.COMMONLY_AGENT_TOKEN).toBeUndefined(); + expect(call.options.env.COMMONLY_DSH_PROXY_KEY).toBeTruthy(); + expect(call.patchText).not.toContain(ctx.runtimeToken); + expect(call.patchText).not.toContain(call.options.env.COMMONLY_DSH_PROXY_KEY); + expect(call.patchText).not.toContain('upstream-secret-123'); + expect(call.patchText).not.toContain('${COMMONLY_AGENT_TOKEN}'); + expect(call.patchMode).toBe(0o600); + expect(call.credentialMode).toBe(0o600); + expect(existsSync(call.patchPath)).toBe(false); + expect(existsSync(call.credentialFile)).toBe(false); + expect(statSync(ctx._dshHome).mode & 0o777).toBe(0o700); + }); +}); + +test('an MCP declaration without file delivery or with extra tools refuses', async () => { + await withSeat(async (ctx) => { + const extra = { name: 'other', transport: 'stdio', command: ['node', 'other.js'] }; + await expect(dsh.spawn('hi', { ...ctx, environment: { ...ctx.environment, mcp: [extra] } })) + .rejects.toThrow(/only the Commonly MCP/); + const old = { name: 'commonly', transport: 'stdio', command: ['npx', '-y', '@commonlyai/mcp@0.3.7'], env: { + COMMONLY_API_URL: '${COMMONLY_API_URL}', COMMONLY_AGENT_TOKEN: '${COMMONLY_AGENT_TOKEN}', + } }; + await expect(dsh.spawn('hi', { ...ctx, environment: { ...ctx.environment, mcp: [old] } })) + .rejects.toThrow(/file-backed credentials/); + const base = { name: 'commonly', transport: 'stdio', command: ['npx', '-y', '@commonlyai/mcp@latest'], + env: { COMMONLY_API_URL: '${COMMONLY_API_URL}', COMMONLY_AGENT_TOKEN: '${COMMONLY_AGENT_TOKEN}' } }; + await expect(dsh.spawn('hi', { ...ctx, environment: { ...ctx.environment, + mcp: [{ ...base, env: { ...base.env, COMMONLY_API_URL: 'https://wrong.example' } }] } })) + .rejects.toThrow(/this spawn's credential file/); + await expect(dsh.spawn('hi', { ...ctx, environment: { ...ctx.environment, + mcp: [{ ...base, command: ['sh', '-c', 'echo attacker', '@commonlyai/mcp@latest'] }] } })) + .rejects.toThrow(/recognized stdio/); + }); +}); + +test('foreign session starts fresh; this seat session resumes', async () => { + await withSeat(async (ctx) => { + const id = 'session-11111111-1111-1111-1111-111111111111'; + const calls = []; + const impl = (_cmd, args) => { calls.push(args); return fakeChild(); }; + await dsh.spawn('first', { ...ctx, sessionId: 'session-22222222-2222-2222-2222-222222222222', _spawnImpl: impl }); + expect(calls[0]).not.toContain('--session-id'); + const dir = join(ctx._dshHome, 'sessions', 'workspace', id); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'session.v4.jsonl.zstd'), 'mock'); + expect(await sessionExists(ctx._dshHome, id)).toBe(true); + await dsh.spawn('again', { ...ctx, sessionId: id, _spawnImpl: impl }); + expect(calls[1]).toEqual(expect.arrayContaining(['--session-id', id])); + }); +}); + +test('exit zero without final, an error event, and a sandbox runner failure all refuse', async () => { + const env = {}, credentials = ['hidden-secret-123']; + const base = { binary: 'dsh', args: [], cwd: '/tmp', env, timeoutMs: 1000, credentials }; + await expect(runDsh({ ...base, spawnImpl: () => fakeChild({ stdout: event({ type: 'text', text: 'thinking' }) }) })) + .rejects.toThrow(/no final event/); + await expect(runDsh({ ...base, spawnImpl: () => fakeChild({ stdout: event({ type: 'error', message: 'bad route' }) + + event({ type: 'final', text: 'looks fine' }) }) })).rejects.toThrow(/bad route/); + await expect(runDsh({ ...base, spawnImpl: () => fakeChild({ stdout: event({ type: 'tool_result', status: 'error', result: 'SANDBOX_UNAVAILABLE' }) + + event({ type: 'final', text: 'looks fine' }) }) })).rejects.toThrow(/sandbox runner unavailable/); + await expect(runDsh({ ...base, spawnImpl: () => fakeChild({ stdout: event({ type: 'error', message: 'hidden-secret-123 refused' }) }) })) + .rejects.toThrow(/\[redacted\] refused/); +}); + +test('helpers keep the native route disabled and child env closed', () => { + const env = childEnvironment({ PATH: '/bin', DEEPSEEK_API_KEY: 'shadow', DSH_HOME: '/tmp/shadow' }, '/tmp/seat', 'bearer'); + expect(env.DEEPSEEK_API_KEY).toBeUndefined(); + expect(env.DSH_HOME).toBe('/tmp/seat'); + const mcp = resolveCommonlyMcp([], { credentialFile: '/tmp/seat-token', instanceUrl: 'https://api.commonly.me' }); + const rows = buildPatch({ proxyBaseURL: 'http://127.0.0.1:10/v1', model: 'm', cwd: '/tmp/work', mcp }); + expect(rows.find((row) => row.id === 'llm-deepseek').disabled).toBe(true); + const official = buildPatch({ proxyBaseURL: 'http://127.0.0.1:10/v1', model: 'deepseek-flash', cwd: '/tmp/work', mcp, + effort: 'high', deepseekOfficial: true }); + expect(official[0].config.providers.commonly.compat).toEqual({ + thinkingFormat: 'deepseek', supportsDeveloperRole: false, supportsStore: false, + }); + expect(rows[0].config.providers.commonly.compat).toEqual({ + supportsDeveloperRole: false, supportsStore: false, + }); +}); diff --git a/cli/__tests__/attach.test.mjs b/cli/__tests__/attach.test.mjs index a5da81b09f..51f8d6da2f 100644 --- a/cli/__tests__/attach.test.mjs +++ b/cli/__tests__/attach.test.mjs @@ -42,6 +42,7 @@ const { checkAttachedAdapterBinding, printAttachRunGuidance, resolveAttachSandbox, + assertSandboxDeclaredForPublicPod, } = await import('../src/commands/agent.js'); const { ADAPTERS_WITH_GRANT_BROKER, seatBaseline } = await import('../src/lib/default-environment.js'); @@ -330,6 +331,33 @@ describe('performAttach', () => { 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(); + }); + 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 { @@ -758,6 +786,18 @@ describe('bootstrapAgentRecordFromEnv', () => { })).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); diff --git a/cli/__tests__/default-environment.test.mjs b/cli/__tests__/default-environment.test.mjs index 85ba832aa4..1196b4d7d7 100644 --- a/cli/__tests__/default-environment.test.mjs +++ b/cli/__tests__/default-environment.test.mjs @@ -16,7 +16,7 @@ import { import { assertNoSandboxDeclared } from '../src/lib/adapters/pi.js'; describe('defaultMcpServers', () => { - test.each(['claude', 'codex', 'pi', 'opencode'])('hands %s the commonly server', (adapterName) => { + test.each(['claude', 'codex', 'pi', 'opencode', 'dsh'])('hands %s the commonly server', (adapterName) => { expect(defaultMcpServers(adapterName)).toEqual([commonlyMcpServer()]); }); @@ -34,7 +34,7 @@ describe('defaultMcpServers', () => { }); test('ADAPTERS_WITH_DEFAULT_MCP matches the adapters this test names', () => { - expect([...ADAPTERS_WITH_DEFAULT_MCP].sort()).toEqual(['claude', 'codex', 'opencode', 'pi', 'qwen']); + expect([...ADAPTERS_WITH_DEFAULT_MCP].sort()).toEqual(['claude', 'codex', 'dsh', 'opencode', 'pi', 'qwen']); }); test('ADAPTERS_WITH_DEFAULT_SANDBOX is the enforcing subset, pi excluded', () => { diff --git a/cli/package-lock.json b/cli/package-lock.json index f9a19d1fd3..c9fc4d5369 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.117", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.112", + "version": "0.1.117", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index 0481bab548..f27c1dc898 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.112", + "version": "0.1.117", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", diff --git a/cli/scripts/probe-dsh.mjs b/cli/scripts/probe-dsh.mjs new file mode 100644 index 0000000000..06bf0ba3aa --- /dev/null +++ b/cli/scripts/probe-dsh.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +/** Run against an installed, pinned dsh binary before adding its version to the adapter. */ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildPatch, resolveCommonlyMcp, TESTED_DSH_VERSIONS } from '../src/lib/adapters/dsh.js'; + +const binary = process.env.DSH_BINARY || 'dsh'; +const version = spawnSync(binary, ['--version'], { encoding: 'utf8', timeout: 5000 }); +assert.equal(version.status, 0, 'dsh --version failed'); +assert.match(version.stdout, /0\.2\.1-rc\.2/); +assert.deepEqual(TESTED_DSH_VERSIONS, ['0.2.1-rc.2']); + +const root = await mkdtemp(join(tmpdir(), 'commonly-dsh-probe-')); +await chmod(root, 0o700); +const cwd = join(root, 'workspace'); +await mkdir(cwd); +const run = (args, home, extraEnv = {}) => new Promise((resolve, reject) => { + const child = spawn(binary, args, { + cwd, + env: { + PATH: process.env.PATH, HOME: home, DSH_HOME: home, + DSH_PERMISSION_MODE: 'workspace-write', DSH_TELEMETRY_MODE: 'DISABLED', + DSH_TELEMETRY_DISABLED: '1', ...extraEnv, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); + child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); +}); +const writePatch = async (name, rows) => { + const path = join(root, `${name}.patch.yml`); + await writeFile(path, `${JSON.stringify(rows, null, 2)}\n`, { mode: 0o600 }); + return path; +}; +const section = (text, id) => { + const start = text.indexOf(`- id: ${id}\n`); + if (start < 0) return ''; + const end = text.indexOf('\n- id: ', start + 1); + return text.slice(start, end < 0 ? undefined : end); +}; +const requireComposed = (text) => { + assert.match(section(text, 'llm-pi-ai'), /api: openai-completions/); + assert.match(section(text, 'llm-pi-ai'), /baseURL: http:\/\/127\.0\.0\.1:9999\/v1/); + assert.match(section(text, 'llm-pi-ai'), /reasoning: high/); + assert.match(section(text, 'llm-pi-ai'), /thinkingFormat: deepseek/); + assert.match(section(text, 'llm-pi-ai'), /supportsDeveloperRole: false/); + assert.match(section(text, 'llm-pi-ai'), /supportsStore: false/); + assert.match(section(text, 'agent-default-model'), /provider: commonly/); + assert.match(section(text, 'llm-deepseek'), /disabled: true/); + assert.match(section(text, 'llm-deepseek-account'), /disabled: true/); + assert.match(section(text, 'session-log-deepseek'), /enabled: false/); + assert.match(section(text, 'sandbox-policy'), /mode: workspace-write/); + assert.match(section(text, 'mcp-commonly'), /name: '@deepseek-ai\/dsh-mcp-client'/); + assert.match(section(text, 'mcp-commonly'), /COMMONLY_TOKEN_FILE:/); +}; + +const serverBodies = []; +const frames = [ + { type: 'message_start', message: { id: 'msg_1', type: 'message', role: 'assistant', model: 'deepseek-flash', content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 3, output_tokens: 1 } } }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'native reply' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, +]; +const server = createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const body = JSON.parse(Buffer.concat(chunks).toString() || '{}'); + serverBodies.push({ body, authorization: req.headers.authorization, xApiKey: req.headers['x-api-key'] }); + res.writeHead(200, { 'content-type': 'text/event-stream' }); + for (const frame of frames) res.write(`event: ${frame.type}\ndata: ${JSON.stringify(frame)}\n\n`); + res.end(); +}); +let piServer; + +try { + const mcp = resolveCommonlyMcp([], { + credentialFile: join(root, 'fake-credential-path'), instanceUrl: 'https://api.commonly.me', + }); + const rows = buildPatch({ proxyBaseURL: 'http://127.0.0.1:9999/v1', model: 'deepseek-flash', cwd, mcp, + effort: 'high', deepseekOfficial: true }); + const home = join(root, 'config-home'); + const dump = async (name, patchRows) => { + const path = await writePatch(name, patchRows); + const result = await run(['--profile', 'headless', '--patch', path, '--dump-config'], home); + assert.equal(result.code, 0, `${name}: dump failed: ${result.stderr}`); + return result.stdout; + }; + requireComposed(await dump('good', rows)); + const mutants = [ + ['wrong-api', (copy) => { copy[0].config.providers.commonly.api = 'openai-responses'; }], + ['thinking-off', (copy) => { delete copy[0].config.providers.commonly.reasoning; }], + ['developer-role-on', (copy) => { copy[0].config.providers.commonly.compat.supportsDeveloperRole = true; }], + ['store-on', (copy) => { copy[0].config.providers.commonly.compat.supportsStore = true; }], + ['wrong-model-route', (copy) => { copy[1].config.provider = 'deepseek-official'; }], + ['native-on', (copy) => { copy[2].disabled = false; }], + ['native-account-on', (copy) => { copy[3].disabled = false; }], + ['upload-on', (copy) => { copy[4].config.enabled = true; }], + ['misspelled-upload-row', (copy) => { copy[4].id = 'session-log-deepseek-typo'; }], + ['sandbox-off', (copy) => { copy[5].config.mode = 'danger-full-access'; }], + ['mcp-gone', (copy) => { copy.pop(); }], + ]; + for (const [name, mutate] of mutants) { + const copy = structuredClone(rows); + mutate(copy); + const composed = await dump(name, copy); + assert.throws(() => requireComposed(composed), undefined, `${name} survived`); + } + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const nativeURL = `http://127.0.0.1:${server.address().port}`; + const nativeRows = [{ id: 'llm-deepseek', config: { baseURL: nativeURL, apiKeyEnv: 'DEEPSEEK_API_KEY' } }]; + // dsh reads a project's .env, but an inherited key must win over an agent-written one. + await writeFile(join(cwd, '.env'), 'DEEPSEEK_API_KEY=project-shadow\nCOMMONLY_DSH_PROXY_KEY=project-shadow\n'); + const nativeRun = async (name, disabled) => { + const before = serverBodies.length; + const path = await writePatch(name, disabled + ? [...nativeRows, { id: 'session-log-deepseek', config: { enabled: false } }] : nativeRows); + const result = await run(['--profile', 'headless', '--patch', path, '--json', name], join(root, `${name}-home`), { + DEEPSEEK_API_KEY: 'probe-native-key', + }); + assert.equal(result.code, 0, `${name}: ${result.stderr}`); + assert.match(result.stdout, /"type":"final"/); + const bodies = serverBodies.slice(before); + assert.ok(bodies.length >= 1, `${name}: no native request reached the mock`); + return bodies; + }; + const defaultBodies = await nativeRun('native-default', false); + const disabledBodies = await nativeRun('native-disabled', true); + assert.ok(defaultBodies.every(({ body }) => Object.hasOwn(body, 'dsh_session_log')), + 'native default did not send the session log'); + assert.ok(disabledBodies.every(({ body }) => !Object.hasOwn(body, 'dsh_session_log')), + 'native disable patch still sent the session log'); + assert.ok([...defaultBodies, ...disabledBodies].every(({ authorization, xApiKey }) => authorization === 'Bearer probe-native-key' || xApiKey === 'probe-native-key'), + `project .env replaced the inherited key (${[...defaultBodies, ...disabledBodies].map(({ authorization, xApiKey }) => `${authorization || '-'} / ${xApiKey || '-'}`).join(', ')})`); + + const piBodies = []; + piServer = createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const body = JSON.parse(Buffer.concat(chunks).toString() || '{}'); + piBodies.push({ body, authorization: req.headers.authorization }); + if (body.messages?.some((message) => message.role === 'developer') || Object.hasOwn(body, 'store')) { + res.writeHead(422, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'DeepSeek rejects developer role and store' } })); + return; + } + res.writeHead(200, { 'content-type': 'text/event-stream' }); + for (const frame of [ + { choices: [{ delta: { role: 'assistant', content: 'proxy reply' }, index: 0, finish_reason: null }] }, + { choices: [{ delta: {}, index: 0, finish_reason: 'stop' }], usage: { prompt_tokens: 3, completion_tokens: 2 } }, + ]) res.write(`data: ${JSON.stringify(frame)}\n\n`); + res.end('data: [DONE]\n\n'); + }); + await new Promise((resolve, reject) => { + piServer.once('error', reject); + piServer.listen(0, '127.0.0.1', resolve); + }); + const piURL = `http://127.0.0.1:${piServer.address().port}/v1`; + const piRows = buildPatch({ proxyBaseURL: piURL, model: 'deepseek-flash', cwd, mcp, + effort: 'high', deepseekOfficial: true }).slice(0, -1); + const piResult = await run(['--profile', 'headless', '--patch', await writePatch('pi-env-precedence', piRows), + '--json', 'PI_ENV_PRECEDENCE'], join(root, 'pi-home'), { COMMONLY_DSH_PROXY_KEY: 'probe-proxy-key' }); + assert.equal(piResult.code, 0, `pi-ai request failed: ${piResult.stderr}`); + assert.match(piResult.stdout, /"type":"final"/); + assert.ok(piBodies.length >= 1, 'pi-ai route sent no request'); + assert.ok(piBodies.every(({ authorization }) => authorization === 'Bearer probe-proxy-key'), + 'project .env replaced the inherited proxy bearer'); + const modelBody = piBodies.find(({ body }) => JSON.stringify(body.messages || []).includes('PI_ENV_PRECEDENCE'))?.body; + assert.equal(modelBody?.messages?.[0]?.role, 'system', 'pi-ai did not send the system prompt as system'); + assert.equal(Object.hasOwn(modelBody, 'store'), false, 'pi-ai sent an unsupported store field'); + assert.equal(modelBody?.thinking?.type, 'enabled', 'pi-ai did not enable DeepSeek thinking'); + assert.equal(modelBody?.reasoning_effort, 'high', 'pi-ai lost the reasoning effort'); + + const genericBefore = piBodies.length; + const genericRows = buildPatch({ proxyBaseURL: piURL, model: 'other-model', cwd, mcp, + effort: 'high', deepseekOfficial: false }).slice(0, -1); + const genericResult = await run(['--profile', 'headless', '--patch', await writePatch('pi-generic-compat', genericRows), + '--json', 'PI_GENERIC_COMPAT'], join(root, 'pi-generic-home'), { COMMONLY_DSH_PROXY_KEY: 'probe-proxy-key' }); + assert.equal(genericResult.code, 0, `generic pi-ai request failed: ${genericResult.stderr}`); + assert.match(genericResult.stdout, /"type":"final"/); + const genericModel = piBodies.slice(genericBefore) + .find(({ body }) => JSON.stringify(body.messages || []).includes('PI_GENERIC_COMPAT'))?.body; + assert.equal(genericModel?.messages?.[0]?.role, 'system', 'generic provider used developer role'); + assert.equal(Object.hasOwn(genericModel, 'store'), false, 'generic provider sent store'); + for (const rejected of [ + { messages: [{ role: 'developer', content: 'unsupported' }] }, + { messages: [{ role: 'system', content: 'ok' }], store: false }, + ]) { + const response = await fetch(`${piURL}/chat/completions`, { + method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer probe-proxy-key' }, + body: JSON.stringify(rejected), + }); + assert.equal(response.status, 422, 'stand-in accepted a DeepSeek-incompatible request'); + } + + await writeFile(join(cwd, '.env'), 'DSH_HOME=/tmp/project-shadow\n'); + const beforeRefusal = serverBodies.length; + const blocked = await run(['--profile', 'headless', '--patch', await writePatch('bootstrap-only', nativeRows), '--json', 'blocked'], + join(root, 'bootstrap-only-home'), { DEEPSEEK_API_KEY: 'probe-native-key' }); + assert.notEqual(blocked.code, 0, 'project .env relocated DSH_HOME'); + assert.equal(serverBodies.length, beforeRefusal, 'bootstrap refusal contacted the provider'); + process.stdout.write(`PASS dsh ${TESTED_DSH_VERSIONS[0]}: composed rows, ${mutants.length} killed mutants, native request bodies default=${defaultBodies.length} with log / disabled=${disabledBodies.length} without log; planted .env key precedence, pi-ai thinking, system role, no store (official and generic), and bootstrap refusal\n`); +} finally { + if (piServer) await new Promise((resolve) => piServer.close(resolve)); + await new Promise((resolve) => server.close(resolve)); + await rm(root, { recursive: true, force: true }); +} diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 7d38b6048b..71ad406532 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -362,6 +362,10 @@ export const bootstrapAgentRecordFromEnv = async ({ } } + if (adapterName === 'dsh') { + throw new Error('dsh first-run bootstrap requires provider settings; attach it with --env before running'); + } + // The token's identity wins over the CLI argument — a mistyped name must // not fork a second identity for the same token. const agentName = identity?.agentName || name; @@ -522,15 +526,34 @@ export const buildDefaultEnvironment = (adapterName) => { * (older server, network failure, permissions), attach proceeds — failing the * attach on an unrelated fault would be its own footgun — but says so, because * a silent skip is how the original hole stayed invisible. + * dsh is private-only, so it requires a successful visibility read and rejects + * a public pod even without a sandbox declaration. */ export const assertSandboxDeclaredForPublicPod = async ({ client, podId, environment, + adapterName, log = () => {}, }) => { if (!podId || !client) return; + if (adapterName === 'dsh') { + let pod; + try { + pod = await client.get(`/api/pods/${podId}`); + } catch { + throw new Error('dsh requires confirmed private pod visibility before attach'); + } + if (typeof pod?.publicRead !== 'boolean' || typeof pod?.communityListed !== 'boolean') { + throw new Error('dsh requires confirmed private pod visibility before attach'); + } + if (pod?.publicRead || pod?.communityListed) { + throw new Error('dsh supports private pods only; this pod is publicly readable'); + } + return; + } + // Normalized FIRST (Vera 71715, Wren 71718): the predicate below reads a // legacy `internal` record as the `public` it means, the same mapping the // attach gate and both adapters use. Reading it raw refused a record that @@ -776,6 +799,9 @@ export const performAttach = async ({ if (!detected) { throw new Error(`${adapterName} not found on PATH. Install it and retry.`); } + if (adapterName === 'dsh' && !envPath) { + throw new Error('dsh requires an environment file with provider and model'); + } // ADR-008 §invariant #4: sandbox failure is a hard stop. Validate the env // here so attach refuses up front rather than handing the user a runtime @@ -858,7 +884,7 @@ export const performAttach = async ({ // declaration at all — the branch that validates sandbox settings never // executes for those, so the agent silently ran unsandboxed. await assertSandboxDeclaredForPublicPod({ - client, podId, environment, log, + client, podId, environment, adapterName, log, }); // Identity-bearing runtime tag from the adapter (e.g. 'codex', 'claude- @@ -2889,7 +2915,7 @@ Docs: // ── attach (ADR-005, ADR-008) ───────────────────────────────────────────── agent .command('attach ') - .description('Wrap a local CLI as a Commonly agent (stub|claude|codex|opencode|qwen|…)') + .description('Wrap a local CLI as a Commonly agent (stub|claude|codex|opencode|qwen|dsh|…)') .requiredOption('--pod ', 'Pod ID to install into') .requiredOption('--name ', 'Agent name (e.g. my-claude)') .option('--display ', 'Display name shown in the pod') diff --git a/cli/src/lib/adapters/dsh.js b/cli/src/lib/adapters/dsh.js new file mode 100644 index 0000000000..efa2b4ed7e --- /dev/null +++ b/cli/src/lib/adapters/dsh.js @@ -0,0 +1,303 @@ +/** DeepSeek Harness 0.2.1-rc.2, private seats only. Configuration is per spawn. */ +import { spawn as childSpawn, spawnSync } from 'child_process'; +import { createHash } from 'crypto'; +import { existsSync } from 'fs'; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises'; +import { homedir, tmpdir } from 'os'; +import { basename, join } from 'path'; +import { buildMemoryPreamble } from '../memory-bridge.js'; +import { commonlyMcpServer } from '../default-environment.js'; +import { effectiveSandboxTrust, validateEnvironmentSpec } from '../environment.js'; +import { deliverSeatCredential } from '../mcp-credential-delivery.js'; +import { describeMcpCommand } from '../mcp-server-version.js'; +import { writeCredentialFile } from '../credential-file.js'; +import { resolveProviderKeyFile, startProviderProxy } from '../provider-proxy.js'; +import { adapterFailure, spawnCredentials } from '../upstream-refusal.js'; + +export const TESTED_DSH_VERSIONS = Object.freeze(['0.2.1-rc.2']); +const PROXY_KEY_ENV = 'COMMONLY_DSH_PROXY_KEY'; +const MAX_TIMEOUT_MS = 15 * 60 * 1000; +const SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/; + +export const assertPrivateDsh = (environment = {}) => { + const sandbox = environment?.sandbox || {}; + if (effectiveSandboxTrust(sandbox.trust) === 'public') { + throw new Error('dsh adapter: public-trust seats are not supported; nested Seatbelt fails closed'); + } + if (sandbox.mode !== undefined) { + throw new Error(`dsh adapter: sandbox.mode=${sandbox.mode} cannot be enforced by this adapter`); + } +}; + +/** A stable home per agent and Commonly instance, never the operator's ~/.dsh. */ +export const seatHome = (ctx) => { + if (ctx._dshHome) return ctx._dshHome; + const id = createHash('sha256').update(`${ctx.instanceUrl || ''}\0${ctx.agentName || ''}`).digest('hex').slice(0, 24); + return join(homedir(), '.commonly', 'dsh-homes', id); +}; + +export const childEnvironment = (source, home, bearer) => { + const env = Object.fromEntries(Object.entries(source || {}).filter(([name]) => SAFE_ENV.test(name))); + env.HOME = home; + env.DSH_HOME = home; + env.DSH_PERMISSION_MODE = 'workspace-write'; + env.DSH_TELEMETRY_MODE = 'DISABLED'; + env.DSH_TELEMETRY_DISABLED = '1'; + env[PROXY_KEY_ENV] = bearer; + return env; +}; + +const substitute = (value, { credentialFile, instanceUrl }) => String(value).replace(/\$\{(COMMONLY_[A-Z_]+)\}/g, (whole, name) => ({ + COMMONLY_TOKEN_FILE: credentialFile, + COMMONLY_API_URL: instanceUrl, + COMMONLY_INSTANCE_URL: instanceUrl, +}[name] || whole)); + +const isCommonlyCommand = (command) => { + if (!Array.isArray(command)) return false; + if (command.length === 3 && command[0] === 'npx' && command[1] === '-y') { + return /^@commonlyai\/mcp(?:@(?:latest|\d+\.\d+\.\d+))?$/.test(command[2]); + } + return command.length === 2 && basename(command[0]) === 'node' + && command[1].endsWith('/@commonlyai/mcp/src/index.js'); +}; + +/** One stdio Commonly server only. No remote credential or arbitrary MCP child. */ +export const resolveCommonlyMcp = (declared, ctx) => { + const servers = declared?.length ? declared : [commonlyMcpServer()]; + if (servers.length !== 1 || servers[0]?.name !== 'commonly') { + throw new Error('dsh adapter: PR 1 supports only the Commonly MCP server'); + } + const server = servers[0]; + if ((server.transport || 'stdio') !== 'stdio' + || !isCommonlyCommand(server.command) || !describeMcpCommand(server.command)?.isCommonly) { + throw new Error('dsh adapter: Commonly MCP must be a recognized stdio @commonlyai/mcp command'); + } + const delivery = deliverSeatCredential(server, { + credentialFile: ctx.credentialFile, label: 'dsh', + }); + if (delivery.delivered !== 'path') { + throw new Error(`dsh adapter: Commonly MCP requires file-backed credentials (got ${delivery.delivered})`); + } + const values = { credentialFile: ctx.credentialFile, instanceUrl: ctx.instanceUrl }; + const env = Object.fromEntries(Object.entries(delivery.env) + .map(([name, value]) => [name, substitute(value, values)])); + if (Object.keys(env).some((name) => !['COMMONLY_API_URL', 'COMMONLY_TOKEN_FILE'].includes(name)) + || env.COMMONLY_TOKEN_FILE !== ctx.credentialFile || env.COMMONLY_API_URL !== ctx.instanceUrl) { + throw new Error('dsh adapter: Commonly MCP must use this spawn\'s credential file'); + } + return { + id: 'mcp-commonly', name: '@deepseek-ai/dsh-mcp-client', + config: { + serverName: 'commonly', transport: 'stdio', + command: substitute(server.command[0], values), + args: server.command.slice(1).map((arg) => substitute(arg, values)), + env, failOnStartupError: true, + }, + }; +}; + +/** JSON is valid YAML; a generated patch has no expression or quoting ambiguity. */ +export const buildPatch = ({ proxyBaseURL, model, cwd, mcp, effort, deepseekOfficial = false }) => ([ + { id: 'llm-pi-ai', config: { providers: { + commonly: { + apiKeyEnv: PROXY_KEY_ENV, api: 'openai-completions', baseURL: proxyBaseURL, + ...(effort ? { reasoning: effort } : {}), + // The loopback proxy hides the upstream URL from pi-ai's compatibility detection. + // Use the conservative Chat Completions roles/fields for every provider. + compat: { + supportsDeveloperRole: false, + supportsStore: false, + ...(deepseekOfficial ? { thinkingFormat: 'deepseek' } : {}), + }, + models: [{ id: model, name: model, contextWindow: 128000, + ...(effort ? { reasoningEfforts: { + low: 'low', medium: 'high', high: 'high', xhigh: 'high', max: 'max', + } } : {}), + }], + }, + } } }, + { id: 'agent-default-model', config: { provider: 'commonly', model } }, + { id: 'llm-deepseek', disabled: true }, + { id: 'llm-deepseek-account', disabled: true }, + { id: 'session-log-deepseek', config: { enabled: false } }, + { id: 'sandbox-policy', config: { mode: 'workspace-write', workspaceRoot: cwd } }, + { insert: [mcp] }, +]); + +export const assertPatchHasNoCredentials = (serialized, credentials) => { + if (serialized.includes('${COMMONLY_AGENT_TOKEN}')) { + throw new Error('dsh adapter: patch contains an unresolved seat token placeholder'); + } + for (const credential of credentials) { + if (typeof credential === 'string' && credential && serialized.includes(credential)) { + throw new Error('dsh adapter: patch contains a credential value'); + } + } +}; + +/** dsh writes sessions below DSH_HOME/sessions//. */ +export const sessionExists = async (home, sessionId) => { + if (!/^session-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(sessionId || '')) return false; + const root = join(home, 'sessions'); + if (!existsSync(root)) return false; + for (const workspace of await readdir(root, { withFileTypes: true })) { + if (!workspace.isDirectory()) continue; + if (existsSync(join(root, workspace.name, sessionId, 'session.v4.jsonl.zstd'))) return true; + } + return false; +}; + +const parseVersion = (output) => String(output || '').trim().match(/\b\d+\.\d+\.\d+-rc\.\d+\b/)?.[0] || null; +const readVersion = (binary, env) => { + const result = spawnSync(binary, ['--version'], { encoding: 'utf8', timeout: 5000, env, cwd: env.DSH_HOME }); + return result.status === 0 && !result.error ? parseVersion(result.stdout) : null; +}; + +const parseEvent = (line, state) => { + let event; + try { event = JSON.parse(line); } catch { throw new Error('dsh emitted invalid JSON event'); } + if (event.type === 'session' && typeof event.sessionId === 'string') state.sessionId = event.sessionId; + if (event.type === 'final' && typeof event.text === 'string') state.final = event.text; + if (event.type === 'error') state.error = String(event.message || event.error || 'dsh error'); + if (event.type === 'tool_result' + && /SANDBOX_UNAVAILABLE|sandbox_apply: Operation not permitted/.test(JSON.stringify(event))) { + state.error = 'dsh sandbox runner unavailable'; + } +}; + +export const runDsh = ({ binary, args, cwd, env, timeoutMs, credentials, spawnImpl = childSpawn }) => new Promise((resolve, reject) => { + const proc = spawnImpl(binary, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); + const state = { sessionId: null, final: null, error: null, buffer: '' }; + let stderr = ''; + let timedOut = false; + let parseFailure = null; + let settled = false; + let killTimer; + const finish = (error, result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(killTimer); + if (error) reject(error); else resolve(result); + }; + const timer = setTimeout(() => { + timedOut = true; + proc.kill('SIGTERM'); + killTimer = setTimeout(() => proc.kill('SIGKILL'), 5000); + }, timeoutMs); + proc.stdout?.on('data', (chunk) => { + state.buffer += chunk.toString(); + if (state.buffer.length > 1024 * 1024) { + parseFailure = new Error('dsh event exceeded 1 MiB'); + proc.kill('SIGTERM'); + return; + } + while (state.buffer.includes('\n')) { + const end = state.buffer.indexOf('\n'); + const line = state.buffer.slice(0, end).trim(); + state.buffer = state.buffer.slice(end + 1); + if (line && !parseFailure) { + try { parseEvent(line, state); } catch (error) { parseFailure = error; proc.kill('SIGTERM'); } + } + } + }); + proc.stderr?.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-8192); }); + proc.on('error', (error) => finish(error)); + proc.on('close', (code) => { + if (state.buffer.trim() && !parseFailure) { + try { parseEvent(state.buffer.trim(), state); } catch (error) { parseFailure = error; } + } + const failure = parseFailure?.message || state.error || (timedOut ? `timeout after ${timeoutMs}ms` : null) + || (/SANDBOX_UNAVAILABLE|sandbox_apply: Operation not permitted/.test(stderr) ? 'sandbox runner unavailable' : null) + || (code !== 0 ? stderr.trim() || `exit ${code}` : null) + || (state.final === null ? 'no final event' : null); + if (failure) return finish(adapterFailure('dsh', failure, { credentials, exitCode: code, limit: 500 })); + return finish(null, { text: state.final.trim(), newSessionId: state.sessionId }); + }); +}); + +export default { + name: 'dsh', runtimeType: 'dsh', supportsProvider: true, + + async validateEnvironment(environment, workspacePath) { + const validation = validateEnvironmentSpec(environment); + if (!validation.ok) throw new Error(validation.errors.join('; ')); + assertPrivateDsh(environment); + if (!environment?.provider || !environment?.model) { + throw new Error('dsh requires environment.provider and environment.model'); + } + const detected = await this.detect(); + if (!TESTED_DSH_VERSIONS.includes(detected?.version)) { + throw new Error(`dsh adapter: installed version ${detected?.version || 'unknown'} is not smoke-tested (${TESTED_DSH_VERSIONS.join(', ')})`); + } + return resolveProviderKeyFile(environment.provider, workspacePath, 'dsh'); + }, + + async detect() { + const home = await mkdtemp(join(tmpdir(), 'commonly-dsh-probe-')); + try { + const env = childEnvironment(process.env, home, ''); + const version = readVersion('dsh', env); + return version ? { path: 'dsh', version } : null; + } finally { + await rm(home, { recursive: true, force: true }); + } + }, + + async spawn(prompt, ctx = {}) { + assertPrivateDsh(ctx.environment); + if (!ctx.environment?.provider || !ctx.environment?.model) { + throw new Error('dsh requires environment.provider and environment.model'); + } + if (!ctx.runtimeToken || !ctx.instanceUrl) throw new Error('dsh requires a Commonly runtime token and instance URL'); + const home = seatHome(ctx); + await mkdir(home, { recursive: true, mode: 0o700 }); + await chmod(home, 0o700); + const binary = ctx._dshBinary || 'dsh'; + const sourceEnv = ctx.env || process.env; + const version = readVersion(binary, childEnvironment(sourceEnv, home, '')); + if (!TESTED_DSH_VERSIONS.includes(version)) { + throw new Error(`dsh adapter: installed version ${version || 'unknown'} is not smoke-tested (${TESTED_DSH_VERSIONS.join(', ')})`); + } + const keyFile = await resolveProviderKeyFile(ctx.environment.provider, ctx.cwd, 'dsh'); + const tempDir = await mkdtemp(join(tmpdir(), 'commonly-dsh-')); + await chmod(tempDir, 0o700); + let proxy; + try { + const credential = writeCredentialFile(ctx.runtimeToken, { agentName: 'commonly', root: tempDir }); + proxy = await startProviderProxy({ + provider: ctx.environment.provider, keyFile, + tokenFile: join(tempDir, 'provider-proxy-token'), adapterLabel: 'dsh', + }); + const bearer = (await readFile(proxy.tokenFile, 'utf8')).trim(); + const mcp = resolveCommonlyMcp(ctx.environment?.mcp, { + credentialFile: credential.path, instanceUrl: ctx.instanceUrl, + }); + const patch = JSON.stringify(buildPatch({ + proxyBaseURL: proxy.baseURL, model: ctx.environment.model, + cwd: ctx.cwd, mcp, effort: ctx.environment.effort, + deepseekOfficial: new URL(ctx.environment.provider.baseURL).hostname === 'api.deepseek.com', + }), null, 2); + assertPatchHasNoCredentials(patch, [ctx.runtimeToken, bearer]); + const patchPath = join(tempDir, 'seat.patch.yml'); + await writeFile(patchPath, `${patch}\n`, { mode: 0o600 }); + await chmod(patchPath, 0o600); + const env = childEnvironment(sourceEnv, home, bearer); + const resume = await sessionExists(home, ctx.sessionId); + const fullPrompt = buildMemoryPreamble(prompt, ctx.memoryLongTerm, { freshSession: !resume }); + const args = ['--profile', 'headless', '--patch', patchPath, '--json', + ...(resume ? ['--session-id', ctx.sessionId] : []), fullPrompt]; + const requested = Number(ctx.timeoutMs); + const timeoutMs = Number.isFinite(requested) && requested > 0 + ? Math.min(requested, MAX_TIMEOUT_MS) : MAX_TIMEOUT_MS; + return await runDsh({ + binary, args, cwd: ctx.cwd, env, timeoutMs, + credentials: [...spawnCredentials(ctx, []), bearer], spawnImpl: ctx._spawnImpl, + }); + } finally { + try { await proxy?.close(); } catch { /* best effort */ } + await rm(tempDir, { recursive: true, force: true }); + } + }, +}; diff --git a/cli/src/lib/adapters/index.js b/cli/src/lib/adapters/index.js index 003efb25a9..ad6d0ed153 100644 --- a/cli/src/lib/adapters/index.js +++ b/cli/src/lib/adapters/index.js @@ -13,6 +13,7 @@ import codex from './codex.js'; import pi from './pi.js'; import opencode from './opencode.js'; import qwen from './qwen.js'; +import dsh from './dsh.js'; const ADAPTERS = { [stub.name]: stub, @@ -21,6 +22,7 @@ const ADAPTERS = { [pi.name]: pi, [opencode.name]: opencode, [qwen.name]: qwen, + [dsh.name]: dsh, }; export const listAdapterNames = () => Object.keys(ADAPTERS); diff --git a/cli/src/lib/daemon-supervisor.js b/cli/src/lib/daemon-supervisor.js index 00be013c22..09b39c5e88 100644 --- a/cli/src/lib/daemon-supervisor.js +++ b/cli/src/lib/daemon-supervisor.js @@ -348,7 +348,7 @@ export const createDaemonSupervisor = ({ } if (!adapter) adapter = await resolveAdapter(row.runtime || null); if (!adapter) { - log(`[${row.agentName}] no usable CLI adapter on this machine — install claude, codex, opencode, or qwen, or attach manually`); + log(`[${row.agentName}] no usable CLI adapter on this machine — install claude, codex, opencode, qwen, or dsh, or attach manually`); return false; } const environment = environmentFor(row); diff --git a/cli/src/lib/default-environment.js b/cli/src/lib/default-environment.js index b8de1119a3..d448509af5 100644 --- a/cli/src/lib/default-environment.js +++ b/cli/src/lib/default-environment.js @@ -18,6 +18,7 @@ * pi — stdio servers from the environment spec (adapters/pi.js) * opencode — per-spawn config file with local/remote MCP entries (adapters/opencode.js) * qwen — per-spawn --mcp-config with file-backed stdio credentials (adapters/qwen.js) + * dsh — per-spawn --patch with file-backed stdio credentials (adapters/dsh.js) * `stub` has no consumption path and must keep being handed `environment: null`. * * The placeholders are substituted at spawn time by the adapter, so the @@ -26,7 +27,7 @@ import { normalizeSandboxTrust } from './environment.js'; -export const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex', 'pi', 'opencode', 'qwen']); +export const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex', 'pi', 'opencode', 'qwen', 'dsh']); /** * The adapters that can ENFORCE the default sandbox — a strict subset of the diff --git a/docs/cli/README.md b/docs/cli/README.md index 246d253599..cf335873e6 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -49,6 +49,9 @@ To connect OpenCode on your own model provider (for example DeepSeek), follow [opencode.md](opencode.md): it needs an environment file naming the provider and model. +To connect DeepSeek Harness to a private pod, follow [dsh.md](dsh.md). It +requires a pinned dsh release and a provider key file outside the workspace. + --- ## Quick start — scaffold a custom Python agent diff --git a/docs/cli/dsh.md b/docs/cli/dsh.md new file mode 100644 index 0000000000..6e93c69e24 --- /dev/null +++ b/docs/cli/dsh.md @@ -0,0 +1,83 @@ +# Connect DeepSeek Harness to a private pod + +This guide attaches [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) +as a Commonly agent that can answer @mentions. This adapter runs dsh on your +computer and gives it Commonly's MCP tools. It accepts private seats only. + +The tested release is **0.2.1-rc.2** on macOS. The adapter refuses every other +version because a renamed dsh patch row could silently remove a privacy +setting. Linux and Windows have not been walked. + +## Install and sign in + +Use Node.js 20 or newer: + +```bash +npm i -g @commonlyai/cli@latest @deepseek-ai/dsh@0.2.1-rc.2 +commonly login +``` + +## Configure the provider + +Put your DeepSeek API key in a file outside the agent workspace. It must be a +regular, non-symlink file with mode `0600`: + +```bash +mkdir -p ~/.config/commonly-keys +touch ~/.config/commonly-keys/deepseek +chmod 600 ~/.config/commonly-keys/deepseek +``` + +Paste the key into that file as its only line. Create a workspace folder, then +write an `environment.json` with absolute paths: + +```json +{ + "provider": { + "id": "deepseek", + "baseURL": "https://api.deepseek.com/v1", + "keyFile": "/Users/you/.config/commonly-keys/deepseek" + }, + "model": "deepseek-flash", + "effort": "high", + "workspace": { "path": "/Users/you/agents/my-dsh" } +} +``` + +`effort: high` enables the [DeepSeek thinking mode](https://api-docs.deepseek.com/guides/thinking_mode/) +on this OpenAI-compatible route. The provider key stays in the file. Commonly +reads it into a short-lived loopback proxy; dsh receives only a per-spawn proxy +bearer, never the provider key. + +## Attach and run + +Use a pod that only its members can read. A private dsh seat runs with dsh's +`workspace-write` permission mode. It is not a public-seat sandbox: the +adapter refuses `sandbox.trust: public` and any declared `sandbox.mode`. + +```bash +commonly agent attach dsh --pod --name --env /Users/you/agents/environment.json +commonly agent run +``` + +Keep the terminal open, then @mention the agent in the pod. Press Ctrl+C to +stop. `commonly agent detach ` removes the attachment. + +## Session data and tools + +dsh's native DeepSeek route [uploads its session log by default](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.2.1-rc.2/packages/session/session-log-deepseek/README.md). +The Commonly adapter turns that setting off on every spawn **and disables both +native DeepSeek model routes**. It selects dsh's OpenAI-compatible `llm-pi-ai` +route through the loopback proxy; that route does not use dsh's native session +log or plugin-inventory request fields. A pinned-binary probe checks the +composed route and setting, and separately demonstrates the native route's +with/without session-log requests. dsh's separate OTel telemetry is disabled +for these seats too. + +The adapter starts only the Commonly MCP server, with a per-spawn credential +file. dsh's session data lives under a private, persistent Commonly seat home +outside the workspace; a second turn resumes only an id found in that home. + +The macOS spike confirmed a headless MCP tool call and resume against local +stand-ins. The live DeepSeek and signed-in Commonly walk is still required +before this adapter merges.