Skip to content

Commit 0388500

Browse files
oratisclaude
andauthored
feat(core): M5.1 — plugin subprocess + JSON-RPC bridge + capability passing (#15)
What ships ---------- - plugins/runtime/subprocess.ts (~270 lines) · PluginSubprocess class — spawn plugin's index.js as separate node process · JSON-RPC stdio bridge — newline-framed, request/response correlated by id · Capability methods exposed to plugin (via plugin → host RPC): - fs_read(path) / fs_write(path, content) - bash(command) - fetch(url, opts) - log(msg) · Token validation on every RPC (defense against unauthorized callers) · DEEPSEEK_API_KEY / DEEPSEEK_AUTH_TOKEN env vars STRIPPED in child env (plugin cannot exfil host credentials) · spawnAllPlugins(plugins, host) / shutdownAllPlugins() bulk helpers · generatePluginToken() — collision-resistant token generator What's exposed in @deepcode/core - PluginSubprocess / spawnAllPlugins / shutdownAllPlugins / generatePluginToken (+ RpcRequest/Response, SpawnAllOpts types) Tests (5 new, 321 total) ------------------------ - starts subprocess + clean shutdown - plugin can request fs_read via RPC, host bridge fires + returns - wrong token rejected (host bridge NOT fired) - generatePluginToken yields unique values - DEEPSEEK_API_KEY env var stripped in plugin process Verified -------- pnpm typecheck → green pnpm test → 313 passed / 8 skipped / 0 failed (was 308) What's intentionally NOT in this PR (per docs/design/plugin-security.md) ------------------------------------------------------------------------ - OS-level sandbox wrapping (bwrap/sandbox-exec around plugin process): M5.1-ext. Currently the plugin can still e.g. open arbitrary network connections — the token + env-strip protects host CREDENTIALS but not the broader threat surface. - GitHub URL install ("gh:user/repo") - npm install - Marketplace ed25519 signature verification - Revoke list pull/enforcement - Wiring spawned plugins into the live ToolRegistry / hook chain — currently the subprocess infrastructure exists but the REPL doesn't yet spawn-and- register installed plugins. That last-mile wire-up is M5.2. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e91aaed commit 0388500

4 files changed

Lines changed: 487 additions & 1 deletion

File tree

packages/core/src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ export {
174174
type ConnectAllResult,
175175
} from './mcp/index.js';
176176

177-
// Plugins (M5 — manifest + hash pinning + local install + discovery)
177+
// Plugins (M5 — manifest + hash pin; M5.1 — subprocess runtime + RPC bridge)
178178
export {
179179
installLocal,
180180
discoverPlugins,
@@ -184,12 +184,20 @@ export {
184184
saveTrustState,
185185
pluginsDir,
186186
trustFilePath,
187+
PluginSubprocess,
188+
spawnAllPlugins,
189+
shutdownAllPlugins,
190+
generatePluginToken,
187191
type PluginManifest,
188192
type InstalledPlugin,
189193
type PluginTrust,
190194
type TrustState,
191195
type InstallOptions,
192196
type DiscoverOptions,
197+
type RpcRequest,
198+
type RpcResponse,
199+
type PluginSubprocessOpts,
200+
type SpawnAllOpts,
193201
} from './plugins/index.js';
194202

195203
// Sub-agents (M4 — .deepcode/agents/*.md)

packages/core/src/plugins/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,14 @@ export {
3636
type InstallOptions,
3737
type DiscoverOptions,
3838
} from './manifest.js';
39+
40+
export {
41+
PluginSubprocess,
42+
spawnAllPlugins,
43+
shutdownAllPlugins,
44+
generatePluginToken,
45+
type RpcRequest,
46+
type RpcResponse,
47+
type PluginSubprocessOpts,
48+
type SpawnAllOpts,
49+
} from './runtime/subprocess.js';
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { promises as fs } from 'node:fs';
2+
import { mkdtemp, rm } from 'node:fs/promises';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6+
import type { InstalledPlugin } from '../manifest.js';
7+
import { generatePluginToken, PluginSubprocess } from './subprocess.js';
8+
9+
async function fakePlugin(dir: string, indexJs: string): Promise<InstalledPlugin> {
10+
await fs.mkdir(dir, { recursive: true });
11+
await fs.writeFile(
12+
join(dir, 'plugin.json'),
13+
JSON.stringify({ name: 'p', version: '0.0.1' }),
14+
'utf8',
15+
);
16+
await fs.writeFile(join(dir, 'index.js'), indexJs, 'utf8');
17+
return {
18+
manifest: { name: 'p', version: '0.0.1' },
19+
path: dir,
20+
sourceHash: 'h',
21+
enabled: true,
22+
};
23+
}
24+
25+
describe('PluginSubprocess', () => {
26+
let pluginDir: string;
27+
beforeEach(async () => {
28+
pluginDir = await mkdtemp(join(tmpdir(), 'dc-plug-sub-'));
29+
});
30+
afterEach(async () => {
31+
await rm(pluginDir, { recursive: true, force: true });
32+
});
33+
34+
it('starts a subprocess and stops it cleanly', async () => {
35+
const plugin = await fakePlugin(
36+
pluginDir,
37+
`// minimal: read stdin, never send anything
38+
const rl = require('node:readline').createInterface({ input: process.stdin });
39+
rl.on('line', () => {});
40+
`,
41+
);
42+
const sub = new PluginSubprocess({
43+
plugin,
44+
token: 't',
45+
host: {
46+
fs_read: async () => '',
47+
fs_write: async () => {},
48+
bash: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
49+
fetch: async () => '',
50+
},
51+
});
52+
await sub.start();
53+
await sub.stop();
54+
}, 10000);
55+
56+
it('plugin can request fs_read via RPC and receives result', async () => {
57+
const plugin = await fakePlugin(
58+
pluginDir,
59+
`// plugin: ask host to fs_read('/etc/hostname'), then exit
60+
const TOKEN = process.env.DEEPCODE_PLUGIN_TOKEN;
61+
process.stdout.write(JSON.stringify({
62+
id: 'r1',
63+
method: 'fs_read',
64+
params: { token: TOKEN, path: '/etc/hostname' }
65+
}) + '\\n');
66+
let buf = '';
67+
process.stdin.on('data', (c) => {
68+
buf += c.toString();
69+
let nl = buf.indexOf('\\n');
70+
if (nl !== -1) {
71+
const line = buf.slice(0, nl);
72+
const msg = JSON.parse(line);
73+
if (msg.id === 'r1') {
74+
// Echo back so the host can see the result via stderr (for testability)
75+
process.stderr.write('plugin received: ' + JSON.stringify(msg.result) + '\\n');
76+
process.exit(0);
77+
}
78+
}
79+
});
80+
`,
81+
);
82+
let fsReadCalled = false;
83+
const sub = new PluginSubprocess({
84+
plugin,
85+
token: 't-secret',
86+
host: {
87+
fs_read: async (path: string) => {
88+
fsReadCalled = true;
89+
expect(path).toBe('/etc/hostname');
90+
return 'fake-hostname';
91+
},
92+
fs_write: async () => {},
93+
bash: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
94+
fetch: async () => '',
95+
},
96+
});
97+
await sub.start();
98+
// Wait briefly for plugin to exchange + exit
99+
await new Promise((r) => setTimeout(r, 500));
100+
await sub.stop();
101+
expect(fsReadCalled).toBe(true);
102+
}, 10000);
103+
104+
it('rejects RPC with wrong token', async () => {
105+
// Plugin tries fs_read without supplying the correct token in params
106+
const plugin = await fakePlugin(
107+
pluginDir,
108+
`process.stdout.write(JSON.stringify({
109+
id: 'r1',
110+
method: 'fs_read',
111+
params: { token: 'WRONG-TOKEN', path: '/x' }
112+
}) + '\\n');
113+
let buf = '';
114+
process.stdin.on('data', (c) => {
115+
buf += c.toString();
116+
const nl = buf.indexOf('\\n');
117+
if (nl !== -1) {
118+
const msg = JSON.parse(buf.slice(0, nl));
119+
process.stderr.write('reply: ' + JSON.stringify(msg) + '\\n');
120+
process.exit(0);
121+
}
122+
});
123+
`,
124+
);
125+
let fsReadCalled = false;
126+
const sub = new PluginSubprocess({
127+
plugin,
128+
token: 'real-token',
129+
host: {
130+
fs_read: async () => {
131+
fsReadCalled = true;
132+
return 'should not happen';
133+
},
134+
fs_write: async () => {},
135+
bash: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
136+
fetch: async () => '',
137+
},
138+
});
139+
await sub.start();
140+
await new Promise((r) => setTimeout(r, 500));
141+
await sub.stop();
142+
expect(fsReadCalled).toBe(false);
143+
}, 10000);
144+
145+
it('generatePluginToken returns unique values', () => {
146+
const tokens = new Set(Array.from({ length: 50 }, () => generatePluginToken()));
147+
expect(tokens.size).toBe(50);
148+
});
149+
150+
it('strips DeepSeek API key env vars in child process', async () => {
151+
const plugin = await fakePlugin(
152+
pluginDir,
153+
`// Print whether DEEPSEEK_API_KEY env var leaked
154+
const leaked = process.env.DEEPSEEK_API_KEY || '';
155+
process.stderr.write('LEAKED=[' + leaked + ']');
156+
process.exit(0);
157+
`,
158+
);
159+
process.env.DEEPSEEK_API_KEY = 'sk-test-secret';
160+
const stderrChunks: string[] = [];
161+
const origStderrWrite = process.stderr.write.bind(process.stderr);
162+
process.stderr.write = ((chunk: string | Buffer): boolean => {
163+
stderrChunks.push(chunk.toString());
164+
return true;
165+
}) as typeof process.stderr.write;
166+
try {
167+
const sub = new PluginSubprocess({
168+
plugin,
169+
token: 't',
170+
host: {
171+
fs_read: async () => '',
172+
fs_write: async () => {},
173+
bash: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
174+
fetch: async () => '',
175+
},
176+
});
177+
await sub.start();
178+
await new Promise((r) => setTimeout(r, 500));
179+
await sub.stop();
180+
} finally {
181+
process.stderr.write = origStderrWrite;
182+
delete process.env.DEEPSEEK_API_KEY;
183+
}
184+
const combined = stderrChunks.join('');
185+
// Key should NOT have made it through
186+
expect(combined).toContain('LEAKED=[]');
187+
expect(combined).not.toContain('sk-test-secret');
188+
}, 10000);
189+
});

0 commit comments

Comments
 (0)