diff --git a/packages/mcode-harness/README.md b/packages/mcode-harness/README.md index 860887bce..1f34ca32e 100644 --- a/packages/mcode-harness/README.md +++ b/packages/mcode-harness/README.md @@ -2,7 +2,7 @@ This companion package lets the agent host run MiniMax Code with OpenAgentCore's workspace. MiniMax Code keeps its own ACP Session, model loop and history. The package supplies a trusted MCP server (`bridge.mjs`), which the daemon registers as `oac_workspace` (its MCP server info names it `oac-workspace`). It exposes six native MiniMax Code tools rooted at the Session's workspace: `workspace_read`, `workspace_write`, `workspace_edit`, `workspace_bash`, `workspace_grep` and `workspace_glob`. The bridge and its tools run beside the CLI in the Session's agent-host view, where Bash, `rg` and `git` run in the sandbox; the package adds no inner sandbox. The [maintainer guide](../../docs/maintainers.md#runtime-images-and-helpers) owns the agent-host image build; [Harness qualification](../../contracts/agents-api/harness-onboarding.md#qualify-the-view) owns deployment evidence. -One patch script (`patch-native.mjs`) makes four edits to the pinned native CLI source. The SQLite task-admission transaction enforces the daemon's Subagent concurrency limit before child work starts; foreground, background, nested and idle-child append admissions share that transaction, and terminal native tasks release capacity. ACP initialization reports `oac/subagents` metadata: its version, the applied workspace tool policy and the admission limit. The native tool catalog applies the `protected-mcp-v1` tool gate described under [Subagents and cancellation](#subagents-and-cancellation). Under the same policy, the CLI ignores the workspace's project `.mcp.json`, so the Session's MCP comes only from the daemon. No second model or scheduling loop is introduced. Hosted public execution is not qualified by this package alone. +One patch script (`patch-native.mjs`) patches the pinned native CLI source. The SQLite task-admission transaction enforces the daemon's Subagent concurrency limit before child work starts; foreground, background, nested and idle-child append admissions share that transaction, and terminal native tasks release capacity. ACP initialization reports `oac/subagents` metadata: its version, the applied workspace tool policy and the admission limit. The native tool catalog applies the `protected-mcp-v1` tool gate described under [Subagents and cancellation](#subagents-and-cancellation). Under the same policy, the CLI ignores the workspace's project `.mcp.json`, so the Session's MCP comes only from the daemon. No second model or scheduling loop is introduced. Hosted public execution is not qualified by this package alone. ## Workspace tools @@ -18,7 +18,7 @@ For each tool call, the bridge starts `launch.mjs` with the Session's private pr MCODE_NATIVE_SOURCE=/absolute/upstream/checkout MCODE_CLI_DIR=/absolute/pinned/package bash scripts/build-mcode-harness.sh ``` -This standalone companion uses its own npm lock and is excluded from the root pnpm workspace. The build archives the exact source revision, bundles its native tools and installs pinned MCP dependencies. The same source archive builds the CLI with its upstream build script and lockfile; the pinned package supplies only native runtime dependencies. `native-patch.json` in the artifact records the upstream revision and exact patch hashes, and `provenance.json` the hash of every artifact file. The artifact lands in `MCODE_HARNESS_BUILD_DIR`, or a new `${OAC_DEV_HOME:-$HOME/.oac}/build/mcode-harness-` directory. Runtime packaging uses this single CLI artifact and installs it immutably at `/opt/mcode-harness`. +This standalone companion uses its own npm lock and is excluded from the root pnpm workspace. The build archives the exact source revision, bundles its native tools and installs pinned MCP dependencies. The build verifies tool guidance with the actual patched native prompt renderer and templates before compiling the CLI with its upstream build script and lockfile; the pinned package supplies only native runtime dependencies. `native-patch.json` in the artifact records the upstream revision and exact patch hashes, and `provenance.json` the hash of every artifact file. The artifact lands in `MCODE_HARNESS_BUILD_DIR`, or a new `${OAC_DEV_HOME:-$HOME/.oac}/build/mcode-harness-` directory. Runtime packaging uses this single CLI artifact and installs it immutably at `/opt/mcode-harness`. ## Subagents and cancellation @@ -26,13 +26,13 @@ This standalone companion uses its own npm lock and is excluded from the root pn 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 daemon sets the protected `protected-mcp-v1` tool policy independently of the concurrency limit. The native catalog applies it to root and child profiles, withholding direct native filesystem and process tools. The Session-private `oac_workspace` MCP server supplies the authorized workspace tools to workers. Other MCP servers keep their native selection rules. Native Explore and Verifier profiles keep their stricter native capability ceiling. ACP initialization reports the applied policy and admission limit; enabled Subagents reject an unpatched CLI before accepting model input. +The daemon sets the protected `protected-mcp-v1` tool policy independently of the concurrency limit. The native catalog applies it to root and child profiles, withholding direct native filesystem and process tools. The Session-private `oac_workspace` MCP server supplies the authorized workspace tools to workers. Other MCP servers keep their native selection rules. The same authored policy filters native prompt capabilities, so the ACP root and child templates name builtin workspace tools only when they are available; MCP tool names and schemas come from their declarations. Native Explore and Verifier profiles keep their stricter native capability ceiling. ACP initialization reports the applied policy and admission limit; enabled Subagents reject an unpatched CLI before accepting model input. Native tool schemas are retained. Text and image results use standard MCP content; video results are rejected. The pinned native CLI may add task and Skill utility tools, so qualification must inspect the actual inventory rather than assume exactly six. ## Tests -`make check-mcode-harness` runs the package's Node tests and syntax checks. Qualify changes with the [Harness acceptance checklist](../../contracts/agents-api/harness-onboarding.md#qualify-the-adapter); synthetic probes and native model runs do not complete public Files/Artifacts or independent Core acceptance. +`make check-mcode-harness` runs the package's Node tests and syntax checks. The native prompt regression requires `MCODE_SOURCE` pointing to the pinned, patched native source with upstream dependencies installed; the companion build always runs it. It checks ordinary native guidance, disabled local tools and protected root and child capabilities without a model. Qualify changes with the [Harness acceptance checklist](../../contracts/agents-api/harness-onboarding.md#qualify-the-adapter); synthetic probes and native model runs do not complete public Files/Artifacts or independent Core acceptance. For the packaged Linux regression, provide an operator-owned private profile and artifact directory, then run `native.test.mjs` inside the qualified Docker Runtime: diff --git a/packages/mcode-harness/native-prompt.test.mjs b/packages/mcode-harness/native-prompt.test.mjs new file mode 100644 index 000000000..19d16c58c --- /dev/null +++ b/packages/mcode-harness/native-prompt.test.mjs @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +// The native build supplies its pinned source and dependencies. No model is used. +const root = process.env.MCODE_SOURCE; +test('native prompt guidance follows the protected tool policy', { skip: !root }, async () => { + const require = createRequire(join(root, 'package.json')); + const { build } = require('esbuild'); + const capabilities = join(root, 'packages/config/src/agent-capabilities.ts'); + const renderer = join(root, 'packages/local-runtime-v2/src/service/agent/builtin/prompt-renderer.ts'); + const paths = JSON.parse(readFileSync(join(root, 'tsconfig.standalone.json'))).compilerOptions.paths; + const result = await build({ + stdin: { contents: `export * from ${JSON.stringify(capabilities)}; export * from ${JSON.stringify(renderer)};`, resolveDir: root }, + bundle: true, write: false, platform: 'node', format: 'esm', nodePaths: [join(root, 'node_modules')], + banner: { js: `import { createRequire } from 'node:module'; const require = createRequire(${JSON.stringify(join(root, 'package.json'))});` }, + plugins: [{ name: 'native-prompt-imports', setup(builder) { + builder.onResolve({ filter: /^@mavis\// }, ({ path }) => { + // Import the same config implementation without unrelated barrel exports. + if (path === '@mavis/config') return { path: capabilities }; + if (paths[path]) return { path: join(root, paths[path][0]) }; + }); + } }], + }); + const native = await import('data:text/javascript;base64,' + Buffer.from(result.outputFiles[0].text).toString('base64')); + const templates = new Map(['AGENT_CONTEXT.md.hbs', 'tui/SYSTEM.md.hbs', 'explore.md.hbs'].map(file => + [file, readFileSync(join(root, 'packages/local-runtime-v2/assets/agents/_v2', file), 'utf8')])); + const context = (tools, child = false) => ({ + ...native.createBuiltinPromptContext({ capabilities: native.resolveAgentCapabilities({ tools }), + promptProfile: 'tui', surface: child ? 'task-child' : 'cli' }, {}), + layer: { base: true, worker: false, root: false, branch: false }, + }); + const render = (file, value) => native.renderBuiltinTemplate(templates.get(file), value, file); + const previous = process.env.OAC_RUNTIME_MCODE_TOOL_POLICY; + try { + delete process.env.OAC_RUNTIME_MCODE_TOOL_POLICY; + const normal = context(undefined); + assert.equal(normal.tools.bash, true); + for (const file of ['AGENT_CONTEXT.md.hbs', 'tui/SYSTEM.md.hbs']) { + assert.match(render(file, normal), /Prefer dedicated tools over `bash` whenever one fits\. Use `grep`/); + assert.match(render(file, normal), /Reserve `bash` for shell-only operations/); + } + const withoutBash = context(['read', 'grep', 'glob']); + for (const file of templates.keys()) assert.doesNotMatch(render(file, withoutBash), /`bash`/); + + process.env.OAC_RUNTIME_MCODE_TOOL_POLICY = 'protected-mcp-v1'; + for (const value of [context([]), context(undefined), context(['bash', 'read', 'grep', 'glob'], true)]) { + for (const name of ['bash', 'read', 'write', 'edit', 'grep', 'glob']) { + assert.equal(value.tools[name], false, name); + assert.equal(native.isProtectedWorkspaceToolAllowed(name, 'builtin'), false, name); + } + for (const file of templates.keys()) assert.doesNotMatch(render(file, value), /`(?:bash|read|write|edit|grep|glob)`/, file); + } + assert.equal(native.isProtectedWorkspaceToolAllowed('web_search', 'builtin-matrix'), false); + for (const name of ['skill', 'task', 'task_append', 'task_query', 'task_output', 'task_stop']) { + assert.equal(native.isProtectedWorkspaceToolAllowed(name, 'builtin'), true, name); + } + // The policy does not rename, alias or restrict configured MCP definitions. + for (const name of ['mcp__oac_workspace__workspace_bash', 'mcp__other__lookup']) { + assert.equal(native.isProtectedWorkspaceToolAllowed(name, 'configured'), true, name); + } + } finally { + if (previous === undefined) delete process.env.OAC_RUNTIME_MCODE_TOOL_POLICY; + else process.env.OAC_RUNTIME_MCODE_TOOL_POLICY = previous; + } +}); diff --git a/packages/mcode-harness/patch-native.mjs b/packages/mcode-harness/patch-native.mjs index 3c38b73d0..c0ddc1be2 100644 --- a/packages/mcode-harness/patch-native.mjs +++ b/packages/mcode-harness/patch-native.mjs @@ -25,6 +25,23 @@ if (acp.split(marker).length !== 2) throw new Error('Pinned native ACP initializ writeFileSync(acpPath, acp.replace(marker, " _meta: {\n 'oac/subagents': { version: 1, workspaceTools: process.env.OAC_RUNTIME_MCODE_TOOL_POLICY ?? null, maxConcurrent: Number(process.env.OAC_RUNTIME_MCODE_MAX_SUBAGENTS ?? 0) },\n 'minimax-code/extensions': {")); const catalogPath = join(root, 'packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-tool-catalog.ts'); const catalog = readFileSync(catalogPath, 'utf8'); +const capabilityPath = join(root, 'packages/config/src/agent-capabilities.ts'); +const capabilities = readFileSync(capabilityPath, 'utf8'); +const enabled = ' return capabilities.tools === undefined || capabilities.tools.includes(toolName);'; +if (capabilities.split(enabled).length !== 2) throw new Error('Pinned native tool capability predicate changed'); +writeFileSync(capabilityPath, capabilities.replace(enabled, + " return isProtectedWorkspaceToolAllowed(toolName, 'builtin') && (capabilities.tools === undefined || capabilities.tools.includes(toolName));") + ` +// The protected workspace policy applies to both tool admission and prompt capabilities. +export function isProtectedWorkspaceToolAllowed(toolName: string, source: string): boolean { + if (process.env.OAC_RUNTIME_MCODE_TOOL_POLICY !== 'protected-mcp-v1') return true; + if (source === 'builtin-matrix') return false; + return source !== 'builtin' || ['skill', 'task', 'task_append', 'task_query', 'task_output', 'task_stop'].includes(toolName); +} +`); +const configIndexPath = join(root, 'packages/config/src/index.ts'); +const configIndex = readFileSync(configIndexPath, 'utf8'); +if (configIndex.split(' isAgentBuiltinToolEnabled,').length !== 2) throw new Error('Pinned native config exports changed'); +writeFileSync(configIndexPath, configIndex.replace(' isAgentBuiltinToolEnabled,', ' isAgentBuiltinToolEnabled,\n isProtectedWorkspaceToolAllowed,')); const gate = " if (!isFeatureEnabled(ceiling, toolName)) return false;"; if (catalog.split(gate).length !== 2) throw new Error('Pinned native tool capability gate changed'); const selectorGate = " if (!input.selector.allowsTool(input.tool.def.name, input.options.selectorAlias)) return false;\n if (!isMcpServerAllowed(input.selector, input.options)) return false;"; @@ -33,11 +50,29 @@ const withWorkspace = catalog.replace(selectorGate, ` const protectedWorkspace input.source === 'configured' && input.options.mcp === true && input.options.serverName === 'oac_workspace'; if (!protectedWorkspace && !input.selector.allowsTool(input.tool.def.name, input.options.selectorAlias)) return false; if (!protectedWorkspace && !isMcpServerAllowed(input.selector, input.options)) return false;`); -writeFileSync(catalogPath, withWorkspace.replace(gate, ` if (process.env.OAC_RUNTIME_MCODE_TOOL_POLICY === 'protected-mcp-v1') { - if (source === 'builtin' && toolName !== 'skill' && !DELEGATION_TOOL_NAMES.has(toolName) && !TASK_CONTROL_TOOL_NAMES.has(toolName)) return false; - if (source === 'builtin-matrix') return false; - } +writeFileSync(catalogPath, "import { isProtectedWorkspaceToolAllowed } from '@mavis/config';\n" + withWorkspace.replace(gate, ` if (!isProtectedWorkspaceToolAllowed(toolName, source)) return false; ${gate}`)); +const rendererPath = join(root, 'packages/local-runtime-v2/src/service/agent/builtin/prompt-renderer.ts'); +const renderer = readFileSync(rendererPath, 'utf8'); +if (renderer.split(' tools,').length !== 2) throw new Error('Pinned native prompt context changed'); +writeFileSync(rendererPath, renderer.replace(' tools,', ` tools, + nativeWorkspaceTools: tools.bash && tools.grep && tools.glob && tools.read && tools.edit && tools.write,`)); +for (const file of ['AGENT_CONTEXT.md.hbs', 'tui/SYSTEM.md.hbs']) { + const path = join(root, 'packages/local-runtime-v2/assets/agents/_v2', file); + const template = readFileSync(path, 'utf8'); + const guidance = /- Prefer dedicated tools over `bash` whenever one fits\.[\s\S]*?no available dedicated tool can complete the task\./g; + const matches = [...template.matchAll(guidance)]; + if (matches.length !== (file === 'AGENT_CONTEXT.md.hbs' ? 2 : 1)) throw new Error('Pinned native workspace guidance changed'); + writeFileSync(path, template.replace(guidance, paragraph => '{{#if nativeWorkspaceTools}}' + paragraph + '{{else}}- Prefer available dedicated tools for file operations and search. Use the tools declared for this turn.{{/if}}') + .replace('- For unfamiliar project-specific concepts, search the workspace with `grep` or `glob` first.', '{{#if tools.grep}}{{#if tools.glob}}- For unfamiliar project-specific concepts, search the workspace with `grep` or `glob` first.{{/if}}{{/if}}')); +} +const explorePath = join(root, 'packages/local-runtime-v2/assets/agents/_v2/explore.md.hbs'); +const explore = readFileSync(explorePath, 'utf8'); +const bashAdvice = 'Use `bash` only to inspect existing code or Git state. '; +const backgroundAdvice = 'Explore does not have `task_output`, so do not use `run_in_background` with\n`bash`. Run each Bash command in the foreground and return its result.'; +if (!explore.includes('# Read-only Bash') || !explore.includes(bashAdvice) || !explore.includes(backgroundAdvice)) throw new Error('Pinned native Explore guidance changed'); +writeFileSync(explorePath, explore.replace('# Read-only Bash', '# Read-only {{#if tools.bash}}Bash{{else}}execution{{/if}}').replace(bashAdvice, '{{#if tools.bash}}' + bashAdvice + '{{/if}}') + .replace(backgroundAdvice, '{{#if tools.bash}}' + backgroundAdvice + '{{/if}}')); const projectPath = join(root, 'packages/local-runtime-v2/src/service/mcp/project-mcp.service.ts'); const project = readFileSync(projectPath, 'utf8'); const projectGate = ' if (!context?.workspaceRoot || !context.sessionId) return {};'; diff --git a/scripts/build-mcode-harness.sh b/scripts/build-mcode-harness.sh index 5beb5d82b..b9b99e1ab 100644 --- a/scripts/build-mcode-harness.sh +++ b/scripts/build-mcode-harness.sh @@ -47,6 +47,7 @@ test "$(node "$native/cli.js" --version)" = "$version" MCODE_SOURCE="$context/upstream" node patch-native.mjs cd upstream corepack pnpm install --frozen-lockfile + MCODE_SOURCE="$context/upstream" node --test "$context/native-prompt.test.mjs" node scripts/build.mjs ) (