diff --git a/packages/mcode-harness/README.md b/packages/mcode-harness/README.md index 076de8bcb..7e64c4ba4 100644 --- a/packages/mcode-harness/README.md +++ b/packages/mcode-harness/README.md @@ -8,7 +8,7 @@ One patch script (`patch-native.mjs`) patches the pinned native CLI source. The The native process, its ACP Session and the workspace tools share the Session's workspace as their working directory; native configuration, Skills and history stay in the private Session data directory. Builtin file tools are disabled, so project files are read and written through the bridge's tools. Native Session creation and loading connect the required workspace bridge and validate its tool inventory before preparation succeeds, using the existing Session-scoped connection pool and its normal request timeout. The adapter’s ACP request and Core preparation deadlines continue to bound startup. Turn discovery retains its existing shorter timeout. An unavailable or incomplete bridge fails preparation; optional MCP servers retain native lazy discovery. The expected inventory is generated from the worker's `--describe` output. Only the adapter registers this bridge; callers cannot supply its command, profile, working directory or environment. Native diff/undo capture is not provided by this path. Common Files and Artifacts use the same bound workspace. -For each tool call, the bridge starts `launch.mjs` with the Session's private profile (`workspace-profile.json`, written by the daemon). The launcher checks that the profile's `workspace` is a canonical absolute path, creates the `scratch` directory, and runs the worker in the workspace with the bridge's environment. An invalid profile rejects the call. +The bridge reads the Session's private profile (`workspace-profile.json`, written by the daemon) during initialization. Its tool executor checks that `workspace` is a canonical absolute path and creates the `scratch` directory before accepting calls. Each call starts the worker directly in that workspace with the bridge's environment. An invalid profile rejects bridge initialization. ## Build @@ -24,7 +24,7 @@ This standalone companion uses its own npm lock and is excluded from the root pn `subagent-snapshot.mjs` is a daemon-only reader of the private native SQLite history. It opens a read-only transaction, scopes recursive descendants to the bound root, and fails explicitly if a complete snapshot exceeds its bounds. It never executes a model or exposes native history to workspace tools. The adapter freezes root output and keeps the same ACP owner for bounded child settlement. A completed or idle task remains an active public Subagent; native abort is a Turn cancellation and never implies a closed Subagent. -The bridge owns each launcher until exit. MCP cancellation and transport shutdown stop all owned workers before releasing the bridge. The outer Runtime owns the native process group. Both boundaries require real Docker cancellation tests. +The bridge owns each worker until exit. MCP cancellation and transport shutdown stop all owned workers before releasing the bridge. The outer Runtime owns the native process group. Both boundaries require real Docker cancellation tests. For declared stdio MCP calls, the adapter captures the affected server identities before sending cancellation and retains identities from callbacks received while cancellation drains. A local failure remains unconfirmed within its Turn even if native reports the tool as finished. The native tool wrapper sets the private `details.oac_response_received` field only for a result returned by the MCP SDK, including a server's `isError` reply; the adapter validates the result identity before releasing that call's owner. It waits for the Runtime to close every selected server's process scope, including background descendants, then calls the private `oac/session/mcp/disconnect` ACP extension with the Session ID and those server names. The native owner disconnects only the selected Session connections and waits for their old transports' actual close events. Configurations remain installed for lazy reconnection on a later call; other MCP servers and the workspace bridge keep their connections. HTTP servers, unknown names and `oac_workspace` are rejected by this control operation. ACP initialization advertises `oac/mcp-lifecycle` version 3, which the adapter requires for a workspace or declared stdio MCP. The request uses the existing native Session, MCP service and connection pool; it adds no model or tool execution loop. diff --git a/packages/mcode-harness/bridge.mjs b/packages/mcode-harness/bridge.mjs index 89b9bd3b9..5e14bb4fb 100644 --- a/packages/mcode-harness/bridge.mjs +++ b/packages/mcode-harness/bridge.mjs @@ -5,9 +5,10 @@ import { readFileSync } from 'node:fs'; import { isAbsolute } from 'node:path'; import { ToolExecutor } from './tool-executor.mjs'; -const profile = process.argv[2]; -if (!profile || !isAbsolute(profile)) throw new Error('Private workspace profile is required'); -const workspace = JSON.parse(readFileSync(profile, 'utf8')).workspace; +const profilePath = process.argv[2]; +if (!profilePath || !isAbsolute(profilePath)) throw new Error('Private workspace profile is required'); +const profile = JSON.parse(readFileSync(profilePath, 'utf8')); +const workspace = profile.workspace; const definitions = JSON.parse(readFileSync(new URL('./dist/tools.json', import.meta.url), 'utf8')); const tools = new Map(definitions.map(tool => ['workspace_' + tool.name, tool])); const executor = new ToolExecutor(profile); diff --git a/packages/mcode-harness/cancellation.test.mjs b/packages/mcode-harness/cancellation.test.mjs index 13b0181a8..811c4c41a 100644 --- a/packages/mcode-harness/cancellation.test.mjs +++ b/packages/mcode-harness/cancellation.test.mjs @@ -31,10 +31,8 @@ for (const operation of ['cancel', 'close']) { if(name==='parent') spawn(process.execPath,['ticks.mjs','child'],{stdio:'inherit'}); setInterval(()=>appendFileSync(name+'.ticks','tick\\n'),20); `); - const profile = join(root, 'profile.json'); - await writeFile(profile, JSON.stringify({ workspace: root, scratch })); const { ToolExecutor } = await import(pathToFileURL(join(artifact, 'tool-executor.mjs'))); - const executor = new ToolExecutor(profile); + const executor = new ToolExecutor({ workspace: root, scratch }); t.after(() => executor.close()); const abort = new AbortController(); const finished = executor.execute('bash', { command: `${quote(process.execPath)} ticks.mjs parent` }, abort.signal); diff --git a/packages/mcode-harness/check.mjs b/packages/mcode-harness/check.mjs index e77cb1c92..5177845c3 100644 --- a/packages/mcode-harness/check.mjs +++ b/packages/mcode-harness/check.mjs @@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; if (!['linux', 'darwin'].includes(process.platform)) throw new Error('MiniMax native execution requires Linux or macOS'); -for (const file of ['bridge.mjs', 'launch.mjs', 'tool-executor.mjs', 'dist/worker.mjs', +for (const file of ['bridge.mjs', 'tool-executor.mjs', 'dist/worker.mjs', 'subagent-snapshot.mjs', 'native/cli.js', 'native-patch.json']) accessSync(new URL(file, import.meta.url)); for (const command of ['bash', 'rg']) execFileSync('which', [command], { stdio: 'ignore' }); diff --git a/packages/mcode-harness/launch.mjs b/packages/mcode-harness/launch.mjs deleted file mode 100644 index 4d3906f12..000000000 --- a/packages/mcode-harness/launch.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { spawn } from 'node:child_process'; -import { readFileSync, mkdirSync } from 'node:fs'; -import { dirname, join, isAbsolute, normalize } from 'node:path'; -import { fileURLToPath } from 'node:url'; -const here = dirname(fileURLToPath(import.meta.url)); -const profile = JSON.parse(readFileSync(process.argv[2], 'utf8')); -if (!isAbsolute(profile.workspace) || normalize(profile.workspace) !== profile.workspace) throw new Error('Invalid workspace profile'); -let child; -let cancelled=false; -const cancel=()=>{cancelled=true;child?.kill('SIGTERM');}; -process.on('SIGTERM',cancel);process.on('SIGINT',cancel); -try { - mkdirSync(profile.scratch,{recursive:true}); - if (cancelled) throw new Error('Cancelled before workspace tool start'); - child=spawn(process.execPath,[join(here,'dist/worker.mjs'),profile.workspace],{cwd:profile.workspace,env:process.env,stdio:['pipe','pipe','pipe']}); - process.stdin.pipe(child.stdin);child.stdout.pipe(process.stdout);child.stderr.pipe(process.stderr); - child.stdin.on('error',()=>cancel()); - const status=await new Promise((resolve,reject)=>{child.on('error',reject);child.on('close',resolve);}); - process.exitCode=cancelled?1:(status??1); -} catch(error) {process.stderr.write(String(error)+'\n');process.exitCode=1;} diff --git a/packages/mcode-harness/launch.test.mjs b/packages/mcode-harness/launch.test.mjs deleted file mode 100644 index 754132398..000000000 --- a/packages/mcode-harness/launch.test.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import {mkdtemp,mkdir,writeFile,copyFile,rm,realpath,stat} from 'node:fs/promises'; -import {tmpdir} from 'node:os'; -import {join} from 'node:path'; -import {ToolExecutor} from './tool-executor.mjs'; - -test('Runtime directly executes host worker with bound cwd and the bridge environment',async t=>{ - const root=await realpath(await mkdtemp(join(tmpdir(),'mcode-host-'))); - t.after(()=>rm(root,{recursive:true,force:true})); - await mkdir(join(root,'dist')); - const workspace=join(root,'workspace');await mkdir(workspace); - const launcher=join(root,'launch.mjs');await copyFile(new URL('./launch.mjs',import.meta.url),launcher); - await writeFile(join(root,'dist','worker.mjs'),`import {readFileSync} from 'node:fs';const request=JSON.parse(readFileSync(0,'utf8'));console.log(JSON.stringify({tool_name:request.tool,text:JSON.stringify({cwd:process.cwd(),value:process.env.OAC_TEST_BRIDGE_ENV}),content:[]}));`); - const profile=join(root,'profile.json'); - const scratch=join(root,'scratch'); - await writeFile(profile,JSON.stringify({workspace,scratch})); - process.env.OAC_TEST_BRIDGE_ENV='ordinary'; - t.after(()=>{delete process.env.OAC_TEST_BRIDGE_ENV;}); - const executor=new ToolExecutor(profile,launcher);t.after(()=>executor.close()); - const result=await executor.execute('bash',{}); - assert.deepEqual(JSON.parse(result.text),{cwd:workspace,value:'ordinary'}); - assert.ok((await stat(scratch)).isDirectory()); - for (const invalid of ['workspace', workspace+'/../workspace']) { - await writeFile(profile,JSON.stringify({workspace:invalid,scratch})); - await assert.rejects(executor.execute('bash',{})); - } -}); diff --git a/packages/mcode-harness/native.test.mjs b/packages/mcode-harness/native.test.mjs index ad34ac463..6da283f85 100644 --- a/packages/mcode-harness/native.test.mjs +++ b/packages/mcode-harness/native.test.mjs @@ -14,7 +14,7 @@ test('packaged native tools use writable scratch and retain large output', { assert.ok(isAbsolute(profile) && isAbsolute(artifact)); const config = JSON.parse(await readFile(profile, 'utf8')); const { ToolExecutor } = await import(pathToFileURL(join(artifact, 'tool-executor.mjs'))); - const executor = new ToolExecutor(profile); + const executor = new ToolExecutor(config); t.after(() => executor.close()); const temporary = await executor.execute('bash', { command: 'node -p "require(\'os\').tmpdir()"; f=$(mktemp) && printf TEMP_OK > "$f" && cat "$f" && rm "$f"', diff --git a/packages/mcode-harness/tool-executor.mjs b/packages/mcode-harness/tool-executor.mjs index 16de8ea1f..67189bf32 100644 --- a/packages/mcode-harness/tool-executor.mjs +++ b/packages/mcode-harness/tool-executor.mjs @@ -1,16 +1,21 @@ import { spawn } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { isAbsolute, normalize } from 'node:path'; import { fileURLToPath } from 'node:url'; const limit = 16 * 1024 * 1024; -const launcher = fileURLToPath(new URL('./launch.mjs', import.meta.url)); +const worker = fileURLToPath(new URL('./dist/worker.mjs', import.meta.url)); -// One bridge owns every launcher until its stdio and native tools have settled. +// One bridge owns every worker until its stdio and native tools have settled. export class ToolExecutor { #calls = new Set(); #closed = false; - constructor(profile, entrypoint = launcher) { - this.profile = profile; + constructor({ workspace, scratch }, entrypoint = worker) { + if (!isAbsolute(workspace) || normalize(workspace) !== workspace) + throw new Error('Invalid workspace profile'); + mkdirSync(scratch, { recursive: true }); + this.workspace = workspace; this.entrypoint = entrypoint; } @@ -19,7 +24,8 @@ export class ToolExecutor { signal?.throwIfAborted(); const request = JSON.stringify({ tool, input }); if (Buffer.byteLength(request) > limit) throw new Error('Workspace tool input exceeds limit'); - const child = spawn(process.execPath, [this.entrypoint, this.profile], { + const child = spawn(process.execPath, [this.entrypoint, this.workspace], { + cwd: this.workspace, env: process.env, stdio: ['pipe', 'pipe', 'pipe'], }); diff --git a/packages/mcode-harness/tool-executor.test.mjs b/packages/mcode-harness/tool-executor.test.mjs index 955c6854e..44a3b4596 100644 --- a/packages/mcode-harness/tool-executor.test.mjs +++ b/packages/mcode-harness/tool-executor.test.mjs @@ -11,9 +11,9 @@ async function fixture(t, body) { await mkdir(root, { recursive: true }); const dir = await mkdtemp(join(root, 'mcode-worker-')); t.after(() => rm(dir, { recursive: true, force: true })); - const script = join(dir, 'launcher.mjs'); + const script = join(dir, 'worker.mjs'); await writeFile(script, body); - const executor = new ToolExecutor(dir, script); + const executor = new ToolExecutor({ workspace: dir, scratch: join(dir, 'scratch') }, script); t.after(() => executor.close()); return { dir, executor }; } @@ -62,3 +62,24 @@ for (const shutdown of ['cancel', 'transport close']) { await assert.rejects(executor.execute('read', {}), /transport is closed/); }); } + +test('input limit and prior cancellation reject before worker start', async t => { + const { dir, executor } = await fixture(t, ` + import { writeFileSync } from 'node:fs'; + writeFileSync(process.argv[2]+'/started','1');`); + await assert.rejects(executor.execute('read', { path: 'x'.repeat(16 * 1024 * 1024) }), /input exceeds limit/); + const abort = new AbortController(); + abort.abort(); + await assert.rejects(executor.execute('read', {}, abort.signal), { name: 'AbortError' }); + await assert.rejects(access(join(dir, 'started')), { code: 'ENOENT' }); +}); + +test('output limit stops and joins the worker', async t => { + const { executor } = await fixture(t, ` + process.stdin.resume(); + process.stdin.on('end', () => { + process.stdout.write('x'.repeat(16 * 1024 * 1024 + 1)); + setInterval(() => {}, 1000); + });`); + await assert.rejects(executor.execute('read', {}), /output exceeds limit/); +}); diff --git a/packages/mcode-harness/workspace.test.mjs b/packages/mcode-harness/workspace.test.mjs new file mode 100644 index 000000000..be62d7211 --- /dev/null +++ b/packages/mcode-harness/workspace.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mkdtemp, mkdir, writeFile, rm, realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { ToolExecutor } from './tool-executor.mjs'; + +test('worker runs with the initialized workspace and bridge environment', async t => { + const parent = join(homedir(), '.oac', 'tests'); + await mkdir(parent, { recursive: true }); + const root = await realpath(await mkdtemp(join(parent, 'mcode-workspace-'))); + t.after(() => rm(root, { recursive: true, force: true })); + const workspace = join(root, 'workspace'); + await mkdir(workspace); + const worker = join(root, 'worker.mjs'); + await writeFile(worker, ` + import { readFileSync } from 'node:fs'; + const request = JSON.parse(readFileSync(0, 'utf8')); + console.log(JSON.stringify({tool_name:request.tool, + text:JSON.stringify({cwd:process.cwd(),root:process.argv[2],value:process.env.OAC_TEST_BRIDGE_ENV}),content:[]})); + `); + const scratch = join(root, 'scratch'); + process.env.OAC_TEST_BRIDGE_ENV = 'ordinary'; + t.after(() => { delete process.env.OAC_TEST_BRIDGE_ENV; }); + const profile = { workspace, scratch }; + const executor = new ToolExecutor(profile, worker); + t.after(() => executor.close()); + assert.ok((await stat(scratch)).isDirectory()); + profile.workspace = root; + const result = await executor.execute('bash', {}); + assert.deepEqual(JSON.parse(result.text), { cwd: workspace, root: workspace, value: 'ordinary' }); + for (const invalid of ['workspace', workspace + '/../workspace']) { + assert.throws(() => new ToolExecutor({ workspace: invalid, scratch }, worker), /Invalid workspace profile/); + } +}); diff --git a/scripts/build-mcode-harness.sh b/scripts/build-mcode-harness.sh index 59cb88f19..236ac4ba3 100644 --- a/scripts/build-mcode-harness.sh +++ b/scripts/build-mcode-harness.sh @@ -69,7 +69,7 @@ test "$(node "$native/cli.js" --version)" = "$version" artifact="$context/artifact" mkdir "$artifact" cp -R "$context/dist" "$context/node_modules" "$artifact/" -cp "$package/launch.mjs" "$package/bridge.mjs" "$package/check.mjs" "$package/tool-executor.mjs" "$package/source.json" "$artifact/" +cp "$package/bridge.mjs" "$package/check.mjs" "$package/tool-executor.mjs" "$package/source.json" "$artifact/" cp "$package/subagent-snapshot.mjs" "$artifact/" mkdir "$artifact/native" cp -R "$context/upstream/dist/." "$artifact/native/" diff --git a/scripts/build-mcode-runtime.sh b/scripts/build-mcode-runtime.sh index c019e89b5..9b54283d8 100644 --- a/scripts/build-mcode-runtime.sh +++ b/scripts/build-mcode-runtime.sh @@ -11,7 +11,7 @@ for directory in "$runtime_root" "$output" "$native" "$companion"; do done test -f "$companion/provenance.json" test -f "$companion/native-patch.json" -for file in launch.mjs bridge.mjs check.mjs tool-executor.mjs subagent-snapshot.mjs source.json; do +for file in bridge.mjs check.mjs tool-executor.mjs subagent-snapshot.mjs source.json; do if ! cmp -s "$companion/$file" "$repo_root/packages/mcode-harness/$file"; then printf 'MiniMax Code companion does not match the current source: %s\n' "$file" >&2 exit 1 diff --git a/scripts/core-distribution-manifest.test.py b/scripts/core-distribution-manifest.test.py index a6ab58165..492062289 100644 --- a/scripts/core-distribution-manifest.test.py +++ b/scripts/core-distribution-manifest.test.py @@ -121,7 +121,7 @@ def test_mcode_payload_rejects_stale_companion_at_the_same_version(self): (companion / "native/cli.js").write_text('console.log("0.4.12");\n') for receipt in ("provenance.json", "native-patch.json"): (companion / receipt).write_text("{}") - files = ("launch.mjs", "bridge.mjs", "check.mjs", "tool-executor.mjs", "subagent-snapshot.mjs", "source.json") + files = ("bridge.mjs", "check.mjs", "tool-executor.mjs", "subagent-snapshot.mjs", "source.json") for name in files: (companion / name).write_bytes((repository / "packages/mcode-harness" / name).read_bytes()) output = self.stage / "payload"