Expand material support and checkpoint the completed building toolkit

Expose the runtime block/item registry through compact material search and
single-material descriptions. Preserve private block-entity data through
checked edits and durable undo without sending payloads to model context.

Include the completed terrain tools, isolated world/map plugin, station lift,
ACP streaming and guidance fixes, local camera auto-connect, construction
scripts, and their public documentation, references and verification records.
Active station decoration and private runtime data remain outside this commit.

Validation: 145 Maven tests, 47 Bridge tests, successful camera Gradle build,
and isolated Paper verification of all 1,196 block defaults plus 5,392
independent property cases for placement, same-material edits and restoration.
This commit is contained in:
Emil
2026-09-13 16:59:13 +03:00
parent 0290559abf
commit 89c53d7058
179 changed files with 37719 additions and 162 deletions
+29 -7
View File
@@ -33,17 +33,23 @@ Optional settings:
- `MCB_ACP_COMMAND`: path to an alternative ACP agent. Otherwise, the current Node executable and pinned `codex-acp` are used.
- `MCB_ACP_ARGS`: JSON array of arguments, with no shell interpretation.
- `MCB_STATE_DIR`: state directory, defaulting to `.state/chat` relative to the working directory.
- `MCB_CODEX_HOME`: explicit path to a separate Codex home for sign-in. If a different `config.toml` already exists there, the bridge refuses to start and preserves that file; use a separate empty directory rather than the normal Codex profile.
- `MCB_CODEX_HOME`: explicit path to a separate Codex home for sign-in. The exact older bridge-managed config is migrated with a `config.toml.before-code-mode-host` backup and without changing sign-in. If a custom `config.toml` exists there, the bridge refuses to start and preserves that file; use a separate empty directory rather than the normal Codex profile.
The child process receives only allowlisted environment variables, separate HOME/XDG/CODEX_HOME directories, and a minimal configuration. Settings request `read-only`, `on-request`, user review, no network inside the command sandbox, and disabled shell, apps, browser, computer use, hooks, plugins, and additional agents. Sources and settings are in `src/security.ts`. Inspection of the pinned CLI confirmed that `shell_tool` and the listed integrations were disabled; that CLI keeps `unified_exec` enabled even when explicitly disabled, which doctor reports.
There is a limitation in `codex-acp` 1.11.0 itself: its `read-only` mode sends a `workspace-write` sandbox with networking disabled for every turn, rather than a literal read-only sandbox. As a result, the individual session directory and temporary paths may remain writable. The code does not claim complete OS isolation and has not yet been verified on a real model turn. Bridge/ACP/MCP processes also remain trusted local programs; Paper permissions are checked separately by the server.
The bundled Code Mode host is enabled because models with `tool_mode=code_mode_only` need it to invoke MCP, even when `features.code_mode=false`. Disabling the host leaves text chat working but makes tool calls fail with `code-mode host is disabled`. This host does not enable the separately disabled shell or integrations.
The Bridge denies additional permission requests and reports this in the game. Interactive permission approval through Minecraft is not implemented. It does not advertise ACP file or terminal capabilities. Paper's administrator token is not passed to the child agent; MCP receives a separate restricted agent token. Check configuration with doctor after version changes or when system-wide Codex policies are present. The dynamically supplied Minecraft MCP override does not set `default_tools_approval_mode`: the pinned adapter passes only command/args/env and replaces the corresponding configuration table. MCP permission behavior therefore remains a check for the first real turn after user sign-in; a successful build and initialize do not prove that model-driven building already works.
There is a limitation in `codex-acp` 1.11.0 itself: its `read-only` mode sends a `workspace-write` sandbox with networking disabled for every turn, rather than a literal read-only sandbox. As a result, the individual session directory and temporary paths may remain writable. The code does not claim complete OS isolation; successful MCP calls do not prove a complete operating-system sandbox. Bridge/ACP/MCP processes also remain trusted local programs; Paper permissions are checked separately by the server.
During an active owner request, the Bridge grants a one-use approval only for one of its 19 known Minecraft tools. It requires a matching tool-call notification from the current adapter and session, the exact Minecraft server/tool identity, MCP approval metadata, and an `allow_once` option. Unknown tools, other servers, shell requests, stale calls, and persistent approvals remain denied. Paper independently checks the owner, project region, protected parts, and expected block states. The administrator token stays in the Bridge; MCP receives the restricted agent token. Interactive approval for additional capabilities is not implemented.
Explicit Minecraft MCP startup failures stop the request with a safe error message. Raw adapter diagnostics and tool arguments are not displayed in game. A missing failure notification alone does not establish readiness; verify an actual tool call after setup.
## MCP
Tools: `project_context`, `region_inspect`, `build_prepare`, `build_apply`, `operation_status`, `operation_cancel`, `operation_undo_prepare`, `part_get`, `part_define`, `camera_list`, `camera_capture`, `asset_list`, `schematic_export`, `schematic_import_prepare`.
Tools: `project_context`, `material_search`, `material_describe`, `region_inspect`, `build_prepare`, `build_apply`, `operation_status`, `operation_cancel`, `operation_undo_prepare`, `part_get`, `part_define`, `camera_list`, `camera_capture`, `asset_list`, `schematic_export`, `schematic_import_prepare`, `terrain_preview`, `terrain_prepare`, `terrain_brush_prepare`.
`project_context` contains only a material catalog summary. `material_search` filters IDs with a query of at most 96 characters, a `kind` of `block` (default), `item` or `all`, and a bounded page size (default 16, maximum 32). Its optional cursor is at most 100 characters and is bound to the catalog version and filter. `material_describe` accepts one exact namespaced Minecraft ID and returns its default state, separate allowed values for each property and compact behavior hints. Item-only entries have no placeable block state. Search first, describe 1–3 selected materials, then reuse those results while the catalog version is unchanged. [Complete workflow and capability boundaries](../docs/MATERIALS.md).
Version 1 recipe:
@@ -61,11 +67,13 @@ Version 1 recipe:
An optional `part_id` in `build_prepare` restricts writes to the exact mask of a registered part; extensions use a separate part.
Tools send data to Paper for final validation and computation. The bridge does not store blocks or write to the world. MCP accepts one level of `repeat`; deeply nested repeats, arbitrary code, arches, and general transforms are not advertised yet. Supported block states and limits come from `project_context`.
Tools send data to Paper for final validation and computation. The bridge does not store blocks or write to the world. MCP accepts one level of `repeat`; deeply nested repeats, arbitrary code, arches, and general transforms are not advertised yet. Limits come from `project_context`; exact block properties come from `material_describe`. Ordinary recipes accept all registered vanilla block states, including fluids and waterlogged states, in strings of at most 1024 characters. The state-string grammar excludes raw NBT.
For a plan based on an earlier `region_inspect` block read, `expected_blocks` must cover every desired position exactly once. Copy any returned `snapshot_id` unchanged alongside `pos` and `state`; block entities require that 64-character lowercase SHA-256 digest so a change to their extra data also causes a conflict. Same-material state edits preserve existing block-entity data. New block entities use their defaults; recipes do not configure sign text or inventories.
`build_prepare` returns `plan_id` and `plan_hash`. Pass both to `build_apply` with a stable `idempotency_key`. After a timeout, writing may already have started: check `operation_status` first and reuse the same key. The bridge does not automatically retry writes.
The local `.schem` library supports up to 64 files, Sponge v2, dense regions of at most 4096 blocks, and rotations of 0/90/180/270°. Place files manually in the `schematics` directory inside the Paper plugin's data directory. `asset_list` returns metadata without invented previews; `schematic_export` saves a region and returns its ID; `schematic_import_prepare` creates a normal checked plan that is then applied through `build_apply`. Paths, entities, block entities, and unsupported blocks are rejected. The strict codec currently supports the original 61-material subset; the ten newer decorative materials in the building palette are not yet supported by `.schem`.
The local `.schem` library supports up to 64 files, Sponge v2, dense regions of at most 4096 blocks, and rotations of 0/90/180/270°. Place files manually in the `schematics` directory inside the Paper plugin's data directory. `asset_list` returns metadata without invented previews; `schematic_export` saves a region and returns its ID; `schematic_import_prepare` creates a normal checked plan that is then applied through `build_apply`. Registered block states use the runtime validator and rotation behavior. Arbitrary paths, entity/block-entity NBT payloads and unknown states are rejected. Export refuses block entities, including empty ones, rather than silently discarding their data. A palette entry for a block-entity block type without an NBT payload can be imported through the normal default/preservation rules. Preserve complete landscaped builds with world checkpoints and checked voxel recipes.
`camera_capture` returns `pending` and `captureId`; a request with `capture_id` reads the result. Only `completed` with a real image becomes MCP `ImageContent`. If the camera is absent or the capture is not ready, no image is fabricated. Ordinary text responses are limited to 64 KiB; reading an oversized region fails with a request to reduce its size. HTTP has time, input-size, and streaming response-size limits.
@@ -75,14 +83,28 @@ One active turn per project, with up to eight queued messages. Different project
Each request includes the player position, viewing direction, and targeted block supplied by the server, when available. Message output is limited to four messages per second per player.
Streamed text is collected into complete sentences or lines. Long passages are split at word boundaries into messages of at most 240 characters; a single longer word is split without breaking a Unicode surrogate pair. An unfinished short phrase waits for more text or the end of the turn. Delivery is serialized, so slow HTTP responses cannot turn individual tokens into separate chat messages or let the completion marker overtake the reply.
The ACP session ID and latest compact summary are persisted by atomically replacing `session.json` with permissions `0600`. After restarting, the bridge tries `session/load`; if that fails, it starts a new conversation with the summary and explicitly reports the fallback. Replayed history is not shown in chat. An unfinished previous turn is marked separately; operations already started must be checked on Paper. The summary stores the latest request and outcome and does not replace `project_context`.
`/ai stop` must both set the server's write-stop flag and deliver a `type:"cancel"` event to the bridge. ACP cancels generation; blocks already changed remain in the journal. An interrupted agent that does not respond to cancellation within five seconds is terminated. On each launch, the bridge creates a `client_id` and passes it to `chat_poll`. A changed ID lets Paper stop active writes and finish outstanding leased requests while notifying the user; those requests are not replayed automatically. Paper's incoming message queue is currently in memory. After `/ai stop`, the server keeps writing paused until a new owner request or `/ai resume`.
## Verification
`npm test` runs HTTP tests and a real stdio MCP handshake, and uses a separate mock ACP process to test sessions, summaries, history suppression, permission denial, and cancellation. Queue tests verify serialization within a project and independence between projects. The real pinned `codex-acp` also passed a free `initialize`: ACP v1, `loadSession: true`, and authentication methods `api-key` and `chat-gpt` before environment restrictions. A repeat check in a separate home also passed; with `NO_BROWSER=1`, the adapter advertises only `api-key`, while ChatGPT sign-in uses the separate `login` helper. This does not prove ChatGPT authentication, model quality, or Minecraft behavior: those require the complete system running against a real server/client.
`npm test` runs HTTP tests and a real stdio MCP handshake, and uses a separate mock ACP process to test sessions, summaries, history suppression, permission denial, and cancellation. Queue tests verify serialization within a project and independence between projects. The real pinned `codex-acp` also passed a free `initialize`: ACP v1, `loadSession: true`, and authentication methods `api-key` and `chat-gpt` before environment restrictions. A repeat check in a separate home also passed; with `NO_BROWSER=1`, the adapter advertises only `api-key`, while ChatGPT sign-in uses the separate `login` helper. The initial handshake did not invoke a model. Subsequently, dedicated ChatGPT sign-in and real ACP model calls to `project_context` and `region_inspect` were verified against the running Paper server. A real model also prepared and applied one oak-plank block at (24, -60, -45), inspected it, prepared and applied checked undo, and verified air. A local test proxy restricted writes to that one cell and its recorded undo. Ordinary automated tests still use mocks and do not invoke a model.
The optional `node test/live-paper.mjs` runs only against a separate real test Paper server: it checks a hollow cube, applying again with the same key, `.schem` export, the asset library, undo, import at the same anchor, a second undo back to 27 air blocks, bounds, and a truthful unavailable-camera response. It leaves the exported test asset in the local library. This test changes the world and is not part of ordinary `npm test`.
The original APIs were checked against the [ACP SDK](https://github.com/agentclientprotocol/typescript-sdk), [codex-acp](https://github.com/agentclientprotocol/codex-acp), [MCP SDK](https://github.com/modelcontextprotocol/typescript-sdk), [Codex configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference), and [Codex MCP documentation](https://learn.chatgpt.com/docs/extend/mcp?surface=cli).
## Terrain toolkit
Deterministic terrain recipes now support hills, ridges, plateaus, dry basins/channels and terraces. `terrain_preview` returns a native heightmap and cached recipe ID; `terrain_prepare` creates a checked tile plan for the existing apply/undo pipeline. Offline previews and bounded resumable batches are available through `scripts/terrain.py`. See [Terraforming tools](../docs/TERRAFORMING.md) for examples, safety semantics and scale limits.
`terrain_brush_prepare` additionally edits existing terrain relatively: raise/lower, flatten and snapshot-based smoothing, with soft edges, preserved columns, native before/after previews and checked read dependencies. See the relative-brush section of the terraforming guide.
## Shared building guidance
`src/building-guidance.ts` is the shared source for material discovery, terrain art direction and decoration used by MCP server instructions and the ACP agent. It prioritizes focused material searches and reuse of defaults, deliberate silhouettes, calm walking areas, localized mountain detail, organic foundations, slope-aware materials, preservation and real camera verification. Tool descriptions state the exact schema boundaries: the current MCP terrain recipe offers amplitude/scale value noise and a limited palette; the advanced `shacraft-natural-v1` profile and initial water generation are operator-configured features. Ordinary building plans can place registered fluid states; terrain brushes still cannot scan through water.
ACP stores a fingerprint of successfully delivered instructions with its session state. An older or missing fingerprint causes the updated guidance to be sent on the next prompt in a resumed session, preserving history. Once delivered, unchanged guidance is not repeated on every turn or restart. Transport failures leave the old fingerprint so delivery can be retried.
+136 -20
View File
@@ -8,6 +8,9 @@ import { fileURLToPath } from 'node:url';
import { client, ndJsonStream, PROTOCOL_VERSION, type ClientConnection, type McpServer, type SessionNotification } from '@agentclientprotocol/sdk';
import { agentEnvironment, codexPaths, prepareCodexHome } from './security.js';
import { BackendError } from './backend.js';
import { AGENT_INSTRUCTIONS as INSTRUCTIONS } from './building-guidance.js';
const INSTRUCTIONS_HASH = createHash('sha256').update(INSTRUCTIONS).digest('hex');
export { agentEnvironment } from './security.js';
const require = createRequire(import.meta.url);
@@ -20,9 +23,11 @@ export interface AcpOptions {
timeoutMs?: number; startupTimeoutMs?: number;
env?: NodeJS.ProcessEnv;
}
interface SavedSession { sessionId: string; summary: string; interrupted: boolean }
interface SavedSession { sessionId: string; summary: string; interrupted: boolean; instructionsHash?: string }
const INSTRUCTIONS = `You are the Minecraft builder for the current authorized project. Respond in the player's language using short game-chat messages. Use only minecraft-builder-mcp to inspect and edit the world. Begin each new task with project_context; read relevant world data before designing. Only implemented server capabilities may be used. Prepare compact geometry, inspect statistics, apply using stable idempotency keys, and poll operation_status. Preserve manual edits: conflict requires localized redesign or the user's decision, never blindly overwrite fresh snapshots. A cancelled or failed operation may have partial writes. Camera requests are asynchronous; poll capture_id and inspect actual image. If unavailable, explicitly say visually unverified. Never use console commands, shell, files, or other MCP servers to modify Minecraft. Do not claim any action completed without server evidence.`;
const MCP_STARTUP_MESSAGE = 'Minecraft MCP не запустился: инструменты строительства недоступны. Запрос остановлен; проверь настройки и процесс моста.';
const MINECRAFT_TOOLS = new Set(['project_context', 'material_search', 'material_describe', 'region_inspect', 'build_prepare', 'build_apply', 'operation_status', 'operation_cancel', 'operation_undo_prepare', 'part_get', 'part_define', 'camera_list', 'camera_capture', 'asset_list', 'schematic_export', 'schematic_import_prepare', 'terrain_preview', 'terrain_prepare', 'terrain_brush_prepare']);
export class CodexSession implements AgentSession {
private child?: ChildProcessWithoutNullStreams;
@@ -33,12 +38,15 @@ export class CodexSession implements AgentSession {
private cancelled = false;
private buffer = '';
private finalText = '';
private lastSent = 0;
private sentChars = 0;
private delivery: Promise<void> = Promise.resolve();
private firstPrompt = true;
private instructionsHash = '';
private summary = '';
private reportReset = false;
private reportInterrupted = false;
private readonly mcpStartupFailures = new Set<string>();
private readonly minecraftCalls = new Set<string>();
private readonly dir: string;
private readonly stateFile: string;
constructor(private readonly message: Pick<ChatMessage, 'projectId' | 'playerId'>, private readonly options: AcpOptions) {
@@ -58,19 +66,32 @@ export class CodexSession implements AgentSession {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') this.reportReset = true;
}
this.summary = saved?.summary.slice(0, 5000) ?? '';
this.instructionsHash = saved?.instructionsHash ?? '';
this.reportInterrupted = saved?.interrupted ?? false;
const executable = this.options.command ?? process.execPath;
const args = this.options.args ?? (this.options.command ? [] : [require.resolve('@agentclientprotocol/codex-acp')]);
this.mcpStartupFailures.clear();
const child = spawn(executable, args, { cwd: this.dir, stdio: ['pipe','pipe','pipe'], env: agentEnvironment(this.options.env ?? process.env, paths), shell: false, detached: process.platform !== 'win32' });
this.child = child;
// Adapter stderr may contain prompts or secrets. Drain it without logging raw content.
child.stderr.resume();
const app = client({ name: 'minecraft-builder-mcp-chat' });
app.onRequest('session/request_permission', async () => {
app.onRequest('session/request_permission', async ({ params }) => {
// Only correlate a one-use approval to a known Minecraft call advertised
// by this adapter in this active owner turn. Paper enforces the scope.
if (this.child === child && this.active && !this.cancelled && !this.mcpStartupError() && params.sessionId === this.sessionId
&& params._meta?.is_mcp_tool_approval === true
&& this.minecraftCalls.has(params.toolCall.toolCallId)
&& params.options.some(option => option.kind === 'allow_once' && option.optionId === 'allow_once')) {
this.minecraftCalls.delete(params.toolCall.toolCallId);
return { outcome: { outcome: 'selected', optionId: 'allow_once' } };
}
await this.currentReply?.('Codex запросил дополнительное разрешение. Оно отклонено: подтверждение через игровой чат пока не реализовано.', false, true);
return { outcome: { outcome: 'cancelled' } };
});
app.onNotification('session/update', ({ params }) => this.onUpdate(params));
app.onNotification('session/update', ({ params }) => {
if (this.child === child) return this.onUpdate(params);
});
this.connection = app.connect(ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout) as unknown as ReadableStream<Uint8Array>));
const connection = this.connection;
child.once('error', () => connection.close(new Error('Cannot start the ACP process. Check MCB_ACP_COMMAND.')));
@@ -102,55 +123,114 @@ export class CodexSession implements AgentSession {
this.sessionId = created.sessionId; configOptions = created.configOptions; this.firstPrompt = true;
if (saved) this.reportReset = true;
}
const mcpError = this.mcpStartupError();
if (mcpError) throw mcpError;
if (this.options.model) {
const config = configOptions?.find(option => option.category === 'model' || option.id === 'model');
if (!config) throw new Error('Agent did not advertise a model selector; unset MCB_MODEL or use a compatible adapter.');
await connection.agent.request('session/set_config_option', { sessionId: this.sessionId, configId: config.id, value: this.options.model });
}
await this.save(false);
const lateMcpError = this.mcpStartupError();
if (lateMcpError) throw lateMcpError;
} catch (error) { this.close(); throw error; }
finally { clearTimeout(setupTimeout); }
}
private mcpStartupError(): BackendError | undefined {
return this.sessionId && this.mcpStartupFailures.has(this.sessionId)
? new BackendError('mcp_unavailable', MCP_STARTUP_MESSAGE) : undefined;
}
private async onUpdate(params: SessionNotification): Promise<void> {
// History replay from session/load is suppressed; another player's chat never receives it.
if (!this.active || params.sessionId !== this.sessionId || !this.currentReply) return;
const update = params.update;
// codex-acp 1.11.0 synthesizes this reserved startup event separately from
// thread history. It can arrive before session/new or session/load returns.
// Never expose its raw content: startup errors may contain tokens or paths.
if (update.sessionUpdate === 'tool_call' && update.toolCallId === 'mcp_startup.minecraft-builder-mcp'
&& update.title === 'mcp__minecraft-builder-mcp__startup' && update.kind === 'other' && update.status === 'failed') {
this.mcpStartupFailures.add(params.sessionId);
const error = this.mcpStartupError();
if (error && this.active) this.connection?.close(error);
return;
}
// History replay from session/load is suppressed; another player's chat never receives it.
if (!this.active || params.sessionId !== this.sessionId || !this.currentReply || this.mcpStartupError()) return;
if (update.sessionUpdate === 'tool_call') {
const input = update.rawInput as Record<string, unknown> | undefined;
if (update._meta?.is_mcp_tool_call === true && update.kind === 'execute'
&& (update.status === 'pending' || update.status === 'in_progress')
&& input?.server === 'minecraft-builder-mcp' && typeof input.tool === 'string'
&& MINECRAFT_TOOLS.has(input.tool) && update.title === `mcp.minecraft-builder-mcp.${input.tool}`
&& this.minecraftCalls.size < 128) this.minecraftCalls.add(update.toolCallId);
} else if (update.sessionUpdate === 'tool_call_update'
&& (update.status === 'completed' || update.status === 'failed')) this.minecraftCalls.delete(update.toolCallId);
if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') {
this.finalText = (this.finalText + update.content.text).slice(0, 8000);
this.buffer += update.content.text;
if (this.buffer.length >= 180 || Date.now() - this.lastSent >= 1500) await this.flush(false);
const remaining = Math.max(0, 8000 - this.sentChars - this.buffer.length);
this.buffer += update.content.text.slice(0, safeTextEnd(update.content.text, remaining));
await this.flush(false);
}
// Deliberately do not forward thought chunks, tool arguments, or raw terminal output.
}
private async flush(done: boolean): Promise<void> {
const remaining = Math.max(0, 8000 - this.sentChars);
const clean = this.buffer.replace(/[\u0000-\u001f\u007f§]/g, ' ').trim().slice(0, remaining);
this.buffer = '';
if (clean) {
for (let offset = 0; offset < clean.length; offset += 240) await this.currentReply?.(clean.slice(offset, offset + 240), false);
const reply = this.currentReply;
if (!reply) return;
const { messages, remainder } = chatMessages(this.buffer, done);
// Reserve text synchronously: ACP notifications can arrive while a reply is
// awaiting HTTP delivery. They must not flush or account for the same text.
this.buffer = remainder;
for (const message of messages) {
const clean = message.slice(0, safeTextEnd(message, Math.max(0, 8000 - this.sentChars)));
if (!clean) continue;
this.sentChars += clean.length;
this.delivery = this.delivery.then(() => reply(clean, false));
}
this.lastSent = Date.now();
if (done) await this.currentReply?.(this.cancelled ? 'Остановлено. Уже изменённые блоки остаются в истории.' : this.sentChars ? '' : 'Ход Codex завершён без текстового ответа; состояние мира доступно через /ai status.', true);
if (done) {
const status = this.cancelled ? 'Остановлено. Уже изменённые блоки остаются в истории.' : this.sentChars ? '' : 'Ход Codex завершён без текстового ответа; состояние мира доступно через /ai status.';
this.delivery = this.delivery.then(() => reply(status, true));
}
await this.delivery;
}
async prompt(text: string, reply: Reply): Promise<void> {
if (this.active) throw new Error('This ACP session already has an active turn.');
this.currentReply = reply; this.cancelled = false;
await this.start();
try {
await this.start();
const mcpError = this.mcpStartupError();
if (mcpError) throw mcpError;
} catch (error) {
if (error instanceof BackendError && error.code === 'mcp_unavailable') await reply(MCP_STARTUP_MESSAGE, false, true);
this.currentReply = undefined;
throw error;
}
if (this.cancelled) { await reply('Запрос остановлен до отправки Codex.', true); this.currentReply = undefined; return; }
if (this.reportReset) { await reply('Начат новый диалог Codex с сохранённой краткой сводкой проекта.', false); this.reportReset = false; }
if (this.reportInterrupted) { await reply('Предыдущий ход был прерван перезапуском. Проверю состояние операций перед новым строительством.', false); this.reportInterrupted = false; }
this.active = true; this.buffer = ''; this.finalText = ''; this.sentChars = 0;
const prefix = this.firstPrompt ? `${INSTRUCTIONS}\n${this.summary ? `Previous compact summary (historical, verify world): ${this.summary}\n` : ''}\nPlayer request:\n` : '';
this.active = true; this.buffer = ''; this.finalText = ''; this.sentChars = 0; this.delivery = Promise.resolve(); this.minecraftCalls.clear();
const prefix = (this.firstPrompt || this.instructionsHash !== INSTRUCTIONS_HASH) ? `${INSTRUCTIONS}\n${this.summary ? `Previous compact summary (historical, verify world): ${this.summary}\n` : ''}\nPlayer request:\n` : '';
await this.save(true);
const timeout = setTimeout(() => { void this.cancel().catch(() => this.close()); }, this.options.timeoutMs ?? 15 * 60_000);
try {
const startupError = this.mcpStartupError();
if (startupError) throw startupError;
const result = await this.connection!.agent.request('session/prompt', { sessionId: this.sessionId!, prompt: [{ type: 'text', text: `${prefix}${text}` }] });
const mcpError = this.mcpStartupError();
if (mcpError) throw mcpError;
this.firstPrompt = false;
this.instructionsHash = INSTRUCTIONS_HASH;
this.cancelled ||= result.stopReason === 'cancelled';
this.summary = `Last player request: ${text.slice(0,2000)}\nLast agent response: ${this.finalText.slice(0,3000)}`;
await this.save(false);
const lateMcpError = this.mcpStartupError();
if (lateMcpError) throw lateMcpError;
await this.flush(true);
} catch (error) {
const mcpError = this.mcpStartupError();
if (mcpError) {
await this.delivery;
await reply(MCP_STARTUP_MESSAGE, false, true);
throw mcpError;
}
throw error;
} finally { clearTimeout(timeout); this.active = false; this.currentReply = undefined; }
}
async cancel(): Promise<void> {
@@ -164,7 +244,7 @@ export class CodexSession implements AgentSession {
}
private async save(interrupted: boolean): Promise<void> {
const temp = `${this.stateFile}.tmp`;
await writeFile(temp, JSON.stringify({ sessionId: this.sessionId, summary: this.summary, interrupted }), { mode: 0o600 });
await writeFile(temp, JSON.stringify({ sessionId: this.sessionId, summary: this.summary, interrupted, instructionsHash: this.instructionsHash }), { mode: 0o600 });
await rename(temp, this.stateFile);
}
close(): void {
@@ -178,6 +258,42 @@ export class CodexSession implements AgentSession {
}
}
/** Keep unfinished words until more tokens arrive; never emit a token on a timer. */
function chatMessages(buffer: string, done: boolean): { messages: string[]; remainder: string } {
let text = buffer.replace(/[\u0000-\u0009\u000b-\u001f\u007f§]/g, ' ').trimStart();
const messages: string[] = [];
while (text) {
const limit = safeTextEnd(text, 240);
// A following separator confirms the sentence boundary. A punctuation token
// alone may still be followed by closing quotes or another punctuation mark.
const sentence = /[.!?…]["'»”\])]*(?=\s)|\n/u.exec(text);
let end = sentence ? sentence.index + sentence[0].length : 0;
if (!end || end > limit) {
if (text.length <= limit) {
if (!done) break;
end = text.length;
} else {
end = 0;
for (let i = limit; i > 0; i--) {
if (/\s/u.test(text[i]!)) { end = i; break; }
}
if (!end) end = limit; // A single overlong word still needs a bounded message.
}
}
const message = text.slice(0, end).replace(/\s+/gu, ' ').trim();
text = text.slice(end).trimStart();
if (message) messages.push(message);
}
return { messages, remainder: text };
}
function safeTextEnd(text: string, max: number): number {
let end = Math.min(text.length, max);
const last = text.charCodeAt(end - 1);
if (end < text.length && last >= 0xd800 && last <= 0xdbff) end--;
return end;
}
function terminateAgentTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void {
try {
// The adapter launches Codex and MCP children. On Unix all are in our own process group.
+12
View File
@@ -0,0 +1,12 @@
/** Shared guidance for MCP clients and the in-game ACP agent. Keep capability claims exact. */
export const MATERIAL_GUIDANCE = `Material discovery: project_context contains only a catalog summary. All vanilla block states registered by the running server are available to ordinary build recipes, including water; item-only materials can be discovered but cannot be placed as blocks. Use material_search with a focused query, then material_describe for 1–3 chosen materials to obtain exact default states and allowed property values. Reuse those results while the catalog version is unchanged; do not enumerate the registry or repeatedly describe known materials. Omitted properties use server defaults. Block states do not accept raw NBT or configure inventories/sign text; same-material edits preserve existing block-entity data. Pass through region_inspect snapshot_id hashes in expected_blocks to detect changes to that data. Terrain recipes and brushes retain their separate limited palettes and fluid restrictions.`;
export const TERRAIN_GUIDANCE = `Terrain art direction: compose a readable skyline and calm playable valleys before adding detail. Prefer broad low-frequency relief, localized ridged mountain detail and modest coordinate warping when the available generator supports them. Independent seeded noise layers reduce repetition; more octaves alone do not fix bad composition. Keep fine detail weak along walking routes. Preserve sightlines, water courses and space for architecture. Avoid large rectangular plateaus unless explicitly requested: adapt buildings to the landscape and flatten only their actual foundations with wide, irregular transitions. Preserve existing terrain instead of repeatedly flattening whole districts. Use rock on steep slopes and soil/grass on gentle ground; avoid grass-and-dirt contour stripes across cliffs. Grade routes and entrances separately; natural-looking terrain is not automatically walkable.
Capability boundary: MCP terrain recipes currently expose three-octave value noise through amplitude/scale only. OpenSimplex2S, ridged noise and domain warping are available in the separately configured shacraft-natural-v1 initial-world profile, NOT as terrain_preview parameters or callable MCP tools. Do not invent noise, spline, erosion, biome or fluid APIs. MCP terrain tools do not place water; the initial-world generator can fill lakes/rivers at a configured level. Brushes reject water in their scan. Preserve waterways and their beds; request operator-side generation work if the task requires unsupported capabilities. There is no hydraulic erosion simulation or initial-generation block undo.
Workflow: inspect a small relevant region, preserve structures and their foundations, preview composition, then apply bounded checked plans. A target heightmap is not a Minecraft screenshot. Inspect terrain from an overview and a player-height view when the camera is available; check silhouettes, chunk seams, shores, materials and route slopes. Poll pending captures by capture_id until completed or a reported error; report that error rather than claiming visual verification. On a shared camera client, ask the player to close menus and stay still. Distinguish proposed, prepared, server-applied and visually inspected work. Keep summaries compact; reuse terrain_id and operation IDs instead of dumping blocks or regenerating equivalent recipes.`;
export const DECORATION_GUIDANCE = `Decoration art direction: Establish purpose and silhouette before adding detail. Use a coherent palette, grouped texture variation and structural depth; avoid random high-contrast block noise. Preserve quiet surfaces, walking clearance, entrances and important sightlines. Build one small prototype, inspect it, then repeat its motifs with controlled variation. Discover selected materials and exact block states through material_search and material_describe. Verify orientation, attachment, support and stable leaves; decorative partial blocks do not guarantee containment. Keep unfinished rooms closed. Inspect actual captures from player height and an overview, in daylight and at night when available; state which views or lighting conditions remain unverified. Confirm server-applied changes before reporting completion. Documented decoration recipes are design specifications, not callable tools; translate selected details into supported bounded plans and preserve manual edits.`;
export const AGENT_BASE = `You are the Minecraft builder for the current authorized project. Respond in the player's language using short game-chat messages. Use only minecraft-builder-mcp to inspect and edit the world. Begin each new task with project_context; read relevant world data before designing. Only implemented server capabilities may be used. Prepare compact geometry, inspect statistics, apply using stable idempotency keys, and poll operation_status. Preserve manual edits: conflict requires localized redesign or the user's decision, never blindly overwrite fresh snapshots. A cancelled or failed operation may have partial writes. Camera requests are asynchronous; poll capture_id and inspect actual image. If unavailable, explicitly say visually unverified. Never use console commands, shell, files, or other MCP servers to modify Minecraft. Do not claim any action completed without server evidence.`;
export const AGENT_INSTRUCTIONS = `${AGENT_BASE}\n\n${MATERIAL_GUIDANCE}\n\n${TERRAIN_GUIDANCE}\n\n${DECORATION_GUIDANCE}`;
+79 -5
View File
@@ -1,4 +1,5 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { mkdir, open, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { delimiter, dirname, join, resolve } from 'node:path';
// These are supported by the pinned Codex 0.153.4 runtime and official configuration reference.
@@ -11,12 +12,82 @@ export const CODEX_CONFIG = {
shell_tool: false, unified_exec: false, shell_snapshot: false,
apps: false, hooks: false, multi_agent: false, plugins: false, remote_plugin: false,
browser_use: false, browser_use_external: false, browser_use_full_cdp_access: false,
computer_use: false, image_generation: false, code_mode: false, code_mode_host: false,
// Models whose catalog tool_mode is code_mode_only need this host even with code_mode=false.
// The host executes tool orchestration; it does not enable shell, apps, or other integrations.
computer_use: false, image_generation: false, code_mode: false, code_mode_host: true,
skill_mcp_dependency_install: false,
},
};
export const CODEX_CONFIG_TOML = `# Managed by minecraft-builder-mcp; use a dedicated CODEX_HOME.\nsandbox_mode = "read-only"\napproval_policy = "on-request"\napprovals_reviewer = "user"\ncli_auth_credentials_store = "file"\nallow_login_shell = false\nweb_search = "disabled"\n\n[sandbox_workspace_write]\nnetwork_access = false\nwritable_roots = []\nexclude_slash_tmp = true\nexclude_tmpdir_env_var = true\n\n[shell_environment_policy]\ninherit = "none"\n\n[features]\n${Object.entries(CODEX_CONFIG.features).map(([key,value]) => `${key} = ${value}`).join('\n')}\n`;
// Frozen previous managed configuration: migration must never recognize arbitrary user settings.
const PRE_CODE_MODE_HOST_CONFIG = `# Managed by minecraft-builder-mcp; use a dedicated CODEX_HOME.
sandbox_mode = "read-only"
approval_policy = "on-request"
approvals_reviewer = "user"
cli_auth_credentials_store = "file"
allow_login_shell = false
web_search = "disabled"
[sandbox_workspace_write]
network_access = false
writable_roots = []
exclude_slash_tmp = true
exclude_tmpdir_env_var = true
[shell_environment_policy]
inherit = "none"
[features]
shell_tool = false
unified_exec = false
shell_snapshot = false
apps = false
hooks = false
multi_agent = false
plugins = false
remote_plugin = false
browser_use = false
browser_use_external = false
browser_use_full_cdp_access = false
computer_use = false
image_generation = false
code_mode = false
code_mode_host = false
skill_mcp_dependency_install = false
`;
function differentConfig(): Error {
return new Error('MCB_CODEX_HOME contains a different config.toml. Choose a dedicated empty Codex home or the bridge-managed home; existing settings will not be overwritten.');
}
async function migrateCodeModeHost(configPath: string): Promise<void> {
const backupPath = `${configPath}.before-code-mode-host`;
try {
const backup = await open(backupPath, 'wx', 0o600);
try { await backup.writeFile(PRE_CODE_MODE_HOST_CONFIG); await backup.sync(); }
finally { await backup.close(); }
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
if (await readFile(backupPath, 'utf8') !== PRE_CODE_MODE_HOST_CONFIG) {
throw new Error('The managed Codex config backup already contains different settings; it will not be overwritten.');
}
}
const temporaryPath = `${configPath}.${randomUUID()}.tmp`;
try {
const temporary = await open(temporaryPath, 'wx', 0o600);
try { await temporary.writeFile(CODEX_CONFIG_TOML); await temporary.sync(); }
finally { await temporary.close(); }
// Recheck after preparing the backup; do not replace settings edited during migration.
const current = await readFile(configPath, 'utf8');
if (current === CODEX_CONFIG_TOML) return;
if (current !== PRE_CODE_MODE_HOST_CONFIG) throw differentConfig();
await rename(temporaryPath, configPath);
} finally {
await unlink(temporaryPath).catch(error => { if (error.code !== 'ENOENT') throw error; });
}
}
export interface CodexPaths { home: string; codexHome: string }
export function codexPaths(stateDir: string, codexHome?: string): CodexPaths {
const state = resolve(stateDir);
@@ -29,7 +100,8 @@ export async function prepareCodexHome(paths: CodexPaths): Promise<void> {
try { existing = await readFile(configPath, 'utf8'); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
if (existing !== undefined && existing !== CODEX_CONFIG_TOML) {
throw new Error('MCB_CODEX_HOME contains a different config.toml. Choose a dedicated empty Codex home or the bridge-managed home; existing settings will not be overwritten.');
if (existing !== PRE_CODE_MODE_HOST_CONFIG) throw differentConfig();
await migrateCodeModeHost(configPath);
}
if (existing === undefined) {
try { await writeFile(configPath, CODEX_CONFIG_TOML, { flag: 'wx', mode: 0o600 }); }
@@ -62,14 +134,16 @@ export function securityReport(paths: CodexPaths) {
configuredSandbox: 'read-only', adapterMode: 'read-only', adapterVersion: '1.11.0',
adapterTurnSandbox: 'workspace-write', approvalPolicy: 'on-request', approvalsReviewer: 'user',
sandboxedCommandNetwork: false, shellToolRequested: false, unifiedExecRequested: false,
codeModeHostRequested: true, codeModeHostRequiredForModelToolMode: 'code_mode_only',
pinnedCliFeatureProbe: { codexVersion: '0.153.4', platform: 'linux', shell_tool: false, unified_exec: true },
inheritedMcpServers: false, acpFilesystem: false, acpTerminal: false,
runtimeVerified: false,
limitations: [
'Pinned Codex reports unified_exec enabled even when disabled explicitly; shell_tool is disabled. A real model tool-availability check has not run.',
'Pinned Codex reports unified_exec enabled even when disabled explicitly; shell_tool is disabled. Doctor does not invoke a model to verify effective tool access; use an explicit end-to-end test.',
'Models with tool_mode=code_mode_only require the bundled code-mode host even with features.code_mode=false; enabling that host does not enable the separately disabled integrations.',
'codex-acp 1.11.0 overrides turn sandbox to workspace-write even in its read-only mode; the session directory and temporary paths may remain writable.',
'Sandboxed-command network restrictions do not sandbox the bridge, ACP adapter or MCP server processes; these remain trusted local programs.',
'No real model turn or end-to-end sandbox probe has run; configuration and initialization alone do not prove OS-level isolation.',
'No end-to-end sandbox probe has run; authenticated text replies and configuration alone do not prove OS-level isolation.',
'Administrator-managed Codex settings and repository-local configuration can also apply; use the dedicated project/state directory and inspect doctor output.',
],
};
+34
View File
@@ -0,0 +1,34 @@
import { z } from 'zod';
const coordinate = z.number().int().min(-30_000_000).max(30_000_000);
const point = z.object({ x: coordinate, z: coordinate }).strict();
const position = z.object({ x: coordinate, y: z.number().int().min(-4096).max(4096), z: coordinate }).strict();
const elevation = z.number().finite().min(-4096).max(4096);
const falloff = z.number().finite().min(1).max(2048);
const material = z.enum(['minecraft:stone','minecraft:andesite','minecraft:granite','minecraft:diorite','minecraft:deepslate',
'minecraft:cobbled_deepslate','minecraft:dirt','minecraft:grass_block','minecraft:moss_block','minecraft:sandstone','minecraft:terracotta']);
const feature = z.discriminatedUnion('type', [
z.object({ type: z.literal('hill'), center: point, radius: z.number().min(1).max(2048), height: elevation, falloff }).strict(),
z.object({ type: z.literal('basin'), center: point, radius: z.number().min(1).max(2048), height: elevation, falloff }).strict(),
z.object({ type: z.literal('plateau'), min: point, max: point, height: elevation, falloff }).strict(),
z.object({ type: z.literal('ridge'), points: z.array(point).min(2).max(32), width: z.number().min(1).max(1024), height: elevation, falloff }).strict(),
z.object({ type: z.literal('channel'), points: z.array(point).min(2).max(32), width: z.number().min(1).max(1024), height: elevation, falloff }).strict(),
z.object({ type: z.literal('terrace'), step: z.number().min(1).max(128), strength: z.number().min(0).max(1) }).strict(),
]);
export const terrainRecipe = z.object({
version: z.literal(1), min: position, max: position, base_height: z.number().int().min(-4096).max(4096),
seed: z.number().int().min(-2147483648).max(2147483647), mode: z.enum(['sculpt','fill','cut']),
noise: z.object({ amplitude: z.number().min(0).max(256), scale: z.number().min(1).max(4096) }).strict(),
palette: z.object({ rock: material, soil: material, surface: material, soil_depth: z.number().int().min(0).max(16) }).strict(),
features: z.array(feature).max(64), preserve: z.array(z.object({ min: position, max: position }).strict()).max(64),
}).strict();
export const terrainBrush = z.object({
min: position, max: position, center: point, radius: z.number().int().min(1).max(16),
action: z.enum(['raise','lower','flatten','smooth']),
amount: z.number().int().min(1).max(32).optional(), height: z.number().int().min(-4096).max(4096).optional(),
strength: z.number().min(0).max(1).default(1), falloff: z.number().min(0).max(1).default(0.5),
smooth_radius: z.number().int().min(1).max(3).optional(),
preserve: z.array(z.object({min: position,max: position}).strict()).max(64).default([]),
}).strict();
+17 -9
View File
@@ -1,11 +1,14 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { terrainRecipe, terrainBrush } from './terrain-schema.js';
import { DECORATION_GUIDANCE, MATERIAL_GUIDANCE, TERRAIN_GUIDANCE } from './building-guidance.js';
import { BackendError, type RpcBackend } from './backend.js';
const id = z.string().min(1).max(200);
const position = z.object({ x: z.number().int().min(-30_000_000).max(30_000_000), y: z.number().int().min(-4096).max(4096), z: z.number().int().min(-30_000_000).max(30_000_000) }).strict();
const block = z.string().regex(/^minecraft:[a-z0-9_]+(?:\[[a-z0-9_=,]+\])?$/).max(200);
const block = z.string().regex(/^minecraft:[a-z0-9_]+(?:\[[a-z0-9_=,]+\])?$/).max(1024);
const materialId = z.string().regex(/^minecraft:[a-z0-9_]+$/).max(128);
// Deliberate small declarative language: the Paper compiler enforces all resource limits again.
const shape = z.discriminatedUnion('type', [
z.object({ type: z.literal('box'), min: position, max: position, block, hollow: z.boolean().optional() }).strict(),
@@ -22,7 +25,7 @@ export function toolResult(result: unknown): CallToolResult {
if (result && typeof result === 'object' && 'imageBase64' in result) {
const { imageBase64, mimeType, ...rest } = result as Record<string, unknown>;
if (rest.status !== 'completed' || typeof imageBase64 !== 'string' || imageBase64.length > 12_000_000 || !['image/png', 'image/jpeg'].includes(String(mimeType)) || !/^[A-Za-z0-9+/]*={0,2}$/.test(imageBase64)) {
throw new BackendError('invalid_image', 'Camera returned an invalid or oversized capture.');
throw new BackendError('invalid_image', 'Backend returned an invalid or oversized image.');
}
metadata = rest;
content.push({ type: 'image', data: imageBase64, mimeType: String(mimeType) });
@@ -35,7 +38,7 @@ export function toolResult(result: unknown): CallToolResult {
export function createMcpServer(backend: RpcBackend): McpServer {
const server = new McpServer({ name: 'minecraft-builder-mcp', version: '0.1.0' }, { instructions:
'Build only through these tools in the server-authorized project. Start with project_context and region_inspect. Prepare a compact recipe, inspect its summary, then apply with the returned plan ID/hash and a stable unique idempotency key. Conflicts preserve manual edits: never re-read and blindly overwrite them. Query operation_status until terminal; cancellation can leave partial edits. Camera unavailable means visually unverified. Never claim success from preparation alone.' });
'Build only through these tools in the server-authorized project. Start with project_context and region_inspect. Prepare a compact recipe, inspect its summary, then apply with the returned plan ID/hash and a stable unique idempotency key. Conflicts preserve manual edits: never re-read and blindly overwrite them. Query operation_status until terminal; cancellation can leave partial edits. Camera unavailable means visually unverified. Never claim success from preparation alone. For terrain, terrain_preview caches one declarative recipe and returns a target heightmap, not a world capture. Reuse terrain_id with terrain_prepare tile_index; review each tile summary and apply through build_apply. Keep the recipe locally for restart recovery. Stop on conflicts; never regenerate a conflicting plan to force it through. Terrain tools do not place water.' + '\n\n' + MATERIAL_GUIDANCE + '\n\n' + TERRAIN_GUIDANCE + '\n\n' + DECORATION_GUIDANCE });
function register(name: string, description: string, inputSchema: z.ZodRawShape, readOnly: boolean, idempotent = false) {
server.registerTool(name, { description, inputSchema, annotations: { readOnlyHint: readOnly, destructiveHint: !readOnly, idempotentHint: idempotent, openWorldHint: false } }, async (args) => {
try { return toolResult(await backend.call(name, args as Record<string, unknown>)); }
@@ -46,19 +49,24 @@ export function createMcpServer(backend: RpcBackend): McpServer {
}
});
}
register('project_context', 'Get authorized world, project, area, capabilities, supported blocks, and operation summaries. No full-world dump.', {}, true, true);
register('region_inspect', 'Inspect a bounded inclusive region. Prefer summary; blocks detail is only for a small local area. Both min and max are required; inspect a small section of the project area.', { min: position, max: position, detail: z.enum(['summary', 'blocks']).default('summary') }, true, true);
register('build_prepare', 'Prepare immutable geometry without changing the world. Use supported recipe operations from project_context. Returns plan_id, plan_hash and compact statistics.', { recipe, part_id: id.optional(), dependencies: z.array(position).max(512).optional() }, false);
register('project_context', 'Get authorized world, project, area, capabilities, a compact material catalog summary, and operation summaries. No registry or full-world dump.', {}, true, true);
register('material_search', 'Search the running server material catalog by name. Returns a bounded page of IDs and block/item flags, without block properties. Prefer a focused query; reuse catalog version and page only when needed. Item-only materials cannot be placed as blocks.', { query: z.string().max(96).optional(), kind: z.enum(['block', 'item', 'all']).default('block'), limit: z.number().int().min(1).max(32).default(16), cursor: z.string().min(1).max(100).optional() }, true, true);
register('material_describe', 'Get one exact material ID, its default block state and allowed property values from the running server. Describe only 1–3 selected materials, then reuse their defaults; item-only materials have no placeable block state. This does not expose inventory, entity or NBT editing.', { id: materialId }, true, true);
register('region_inspect', 'Inspect a bounded inclusive region. Prefer summary; blocks detail is only for a small local area and includes opaque snapshot_id hashes for block entities, without their NBT. Both min and max are required; inspect a small section of the project area.', { min: position, max: position, detail: z.enum(['summary', 'blocks']).default('summary') }, true, true);
register('build_prepare', 'Prepare immutable geometry without changing the world. Use supported recipe operations from project_context. Returns plan_id, plan_hash and compact statistics. Optional expected_blocks must cover every desired position exactly once; preserve each returned snapshot_id to detect block-entity data changes. The server rejects a changed caller snapshot atomically before preparation. Same-material state edits preserve existing block-entity data; recipes do not configure inventories, sign text or raw NBT.', { recipe, part_id: id.optional(), dependencies: z.array(position).max(512).optional(), expected_blocks: z.array(z.object({ pos: position, state: block, snapshot_id: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict()).min(1).max(4096).optional() }, false);
register('build_apply', 'Apply a prepared plan with compare-before-write protection. Reuse the SAME idempotency_key after uncertain transport outcome; query project_context to recover an unknown operation ID, then operation_status first.', { plan_id: id, plan_hash: id, idempotency_key: id }, false, true);
register('operation_status', 'Get exact state, changed count, conflicts and completion. A terminal cancelled/conflict state can include partial writes.', { operation_id: id }, true, true);
register('operation_cancel', 'Request cancellation before the next server batch. Already applied changes remain journaled.', { operation_id: id }, false, true);
register('operation_undo_prepare', 'Prepare checked undo of a recorded operation. Manual edits after construction become conflicts. Apply returned undo plan with build_apply.', { operation_id: id }, false);
register('part_get', 'Get named part metadata and protected status, without expanding every block.', { part_id: id }, true, true);
register('part_define', 'Register an exact part mask from a completed operation. Server rejects unsupported membership or overlap.', { name: z.string().min(1).max(64), operation_id: id }, false);
register('camera_list', 'List saved camera poses and whether a camera client is connected.', {}, true, true);
register('camera_list', 'List saved camera poses and camera configuration. This does not prove that the observer is online or ready.', {}, true, true);
register('camera_capture', 'Request a real capture by saved camera_id or pose. A pending response returns captureId; poll using capture_id. Only completed results contain an image. Camera unavailable is not a successful visual check.', { camera_id: id.optional(), pose: pose.optional(), after_operation_id: id.optional(), capture_id: id.optional() }, true);
register('asset_list', 'List up to 64 local schematic assets with dimensions and metadata, optionally filtered by query. No network library or generated thumbnails. Place .schem files manually in the plugin data/schematics directory; asset IDs never accept arbitrary paths.', { query: z.string().max(64).optional() }, true, true);
register('schematic_export', 'Export a dense inclusive region of at most 4096 supported blocks as a local Sponge v2 .schem asset. Optional origin is the clipboard anchor. Returns an asset ID and metadata; files remain in the plugin data/schematics directory.', { name: z.string().min(1).max(64).regex(/^[^\u0000-\u001f\u007f]+$/), min: position, max: position, origin: position.optional() }, false);
register('schematic_import_prepare', 'Prepare a checked import of a local .schem asset at target, optionally rotating 0/90/180/270 degrees. Rejects entities, block entities and unsupported blocks. Returns a normal plan; inspect it and use build_apply to edit the world.', { asset_id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/), target: position, rotation: z.union([z.literal(0),z.literal(90),z.literal(180),z.literal(270)]).default(0) }, false);
register('schematic_export', 'Export a dense inclusive region of at most 4096 registered block states as a local Sponge v2 .schem asset. Rejects block entities because their extra data cannot yet be exported. Optional origin is the clipboard anchor. Returns an asset ID and metadata; files remain in the plugin data/schematics directory.', { name: z.string().min(1).max(64).regex(/^[^\u0000-\u001f\u007f]+$/), min: position, max: position, origin: position.optional() }, false);
register('schematic_import_prepare', 'Prepare a checked import of a local .schem asset at target, optionally rotating 0/90/180/270 degrees. Accepts registered block states; rejects entity and block-entity NBT payloads. Returns a normal plan; inspect it and use build_apply to edit the world.', { asset_id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/), target: position, rotation: z.union([z.literal(0),z.literal(90),z.literal(180),z.literal(270)]).default(0) }, false);
register('terrain_preview', 'Preview and cache a deterministic terrain recipe without reading or editing the world. Returns a native heightmap image, terrain_id and tile_count. North (-Z) is up. Features run in order: hill/ridge add height; plateau blends to an absolute height; basin/channel only lower to an absolute height; terrace quantizes elevations. Radius/width is the flat core radius/half-width, falloff is the smooth outer bank. Noise is three-octave value noise with amplitude/scale only; no warp or ridged parameters. Large rectangular plateaus should be reserved for explicit architectural needs; prefer organic landforms and small blended foundations. Noise uses the seed and world coordinates. Preserve boxes omit writes. sculpt fills below and clears above the field; fill only targets below; cut only clears above. Save the recipe: cache holds 32 recipes until restart. Bounds are inclusive; large concepts can be previewed outside the current project.', { recipe: terrainRecipe, resolution: z.number().int().min(32).max(256).default(128) }, true, true);
register('terrain_prepare', 'Prepare ONE bounded terrain tile from a previously previewed terrain_id. tile_index is zero-based; X advances first, then Z, then Y. Returns a normal immutable plan or status=empty; no world writes. Replaces only natural terrain/air; bedrock, structures and unsupported blocks reject the tile. Use preserve boxes for existing work, including natural-material builds. Apply with build_apply and poll operation_status; undo with operation_undo_prepare. Each tile uses current live contents and existing area/loaded-chunk/protected-part checks. Multi-tile changes are not atomic.', { terrain_id: z.string().regex(/^[a-f0-9]{64}$/), tile_index: z.number().int().min(0).max(134_217_727) }, false);
register('terrain_brush_prepare', 'Read EXISTING terrain and prepare a relative brush with a native before/after/delta preview. No world edits until build_apply. action raise/lower requires amount; flatten requires absolute height; smooth averages the ORIGINAL snapshot using smooth_radius (1..3, default 1). strength 0..1 scales displacement; falloff 0..1 is the outer fraction of the radius (0=hard edge). min/max is the inclusive scan window: <=4096 cells, full circular footprint plus smoothing halo, terrain in every column, air above both old and new surfaces, enough depth for cutting. No structures/fluids in scan; smaller brushes for constrained sites. All scanned cells become checked dependencies. Preserve boxes skip an intersecting edited column. Reuses existing surface and subsoil; it does not automatically expose rock on cliffs. Use small local refinements, keep routes gentle and avoid flattening whole districts. Returns plan_state=prepared with normal plan ID/hash or plan_state=empty; review and apply, then poll status. Never reprepare to force a conflicting brush through.', { brush: terrainBrush }, false);
return server;
}
+129 -1
View File
@@ -1,10 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { setTimeout as pause } from 'node:timers/promises';
import { CodexSession, agentEnvironment } from '../dist/acp.js';
import { longReply, overlongWord } from './fixtures/streamed-replies.mjs';
async function fixture(t){
const stateDir=await mkdtemp(join(tmpdir(),'mcb-acp-'));t.after(()=>rm(stateDir,{recursive:true,force:true}));
const log=join(stateDir,'protocol.jsonl');
@@ -12,6 +14,34 @@ async function fixture(t){
const records=async()=> (await readFile(log,'utf8').catch(()=>'' )).trim().split('\n').filter(Boolean).map(JSON.parse);
return {options,records};
}
test('updated terrain and material guidance reaches resumed ACP sessions once without resetting history',async t=>{
const {options,records}=await fixture(t);
let session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
await session.prompt('first request',async()=>{});session.close();
const key=createHash('sha256').update('p\0u').digest('hex').slice(0,32);
const file=join(options.stateDir,key,'session.json');
const saved=JSON.parse(await readFile(file,'utf8'));saved.instructionsHash='older-prompt';
await writeFile(file,JSON.stringify(saved));
session=new CodexSession({projectId:'p',playerId:'u'},options);
await session.prompt('after update',async()=>{});
await session.prompt('next request',async()=>{});session.close();
session=new CodexSession({projectId:'p',playerId:'u'},options);
await session.prompt('same guidance after restart',async()=>{});
const calls=await records();const prompts=calls.filter(v=>v.method==='session/prompt').map(v=>v.params.prompt[0].text);
assert.ok(prompts[1].includes('shacraft-natural-v1'));
assert.ok(prompts[1].includes('Avoid large rectangular plateaus'));
assert.ok(prompts[1].includes('NOT as terrain_preview parameters'));
for(const prompt of [prompts[0],prompts[1]]) {
assert.ok(prompt.includes('project_context contains only a catalog summary'));
assert.ok(prompt.includes('material_search'));
assert.ok(prompt.includes('material_describe for 1–3 chosen materials'));
assert.ok(prompt.includes('do not enumerate the registry'));
assert.ok(prompt.includes('item-only materials can be discovered but cannot be placed'));
}
assert.equal(prompts[2],'next request');assert.equal(prompts[3],'same guidance after restart');
assert.equal(calls.filter(v=>v.method==='session/new').length,1);
assert.equal(calls.filter(v=>v.method==='session/load').length,2);
});
test('ACP initializes, injects scoped MCP, preserves sessions, suppresses private replay and thoughts',async t=>{
const {options,records}=await fixture(t);const messages=[];
let session=new CodexSession({projectId:'project',playerId:'player'},options);t.after(()=>session.close());
@@ -34,6 +64,38 @@ test('permission requests fail closed and explain in chat',async t=>{
assert.ok(messages.some(text=>text.includes('отклонено')));
assert.equal((await records()).find(item=>item.id==='approval').result.outcome.outcome,'cancelled');
});
test('MCP startup failure before session creation stops the prompt and redacts diagnostics',async t=>{
const {options,records}=await fixture(t);options.args.push('mcp-fail-new');const messages=[];
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
await assert.rejects(session.prompt('Must not run',async text=>messages.push(text)),error=>error.code==='mcp_unavailable');
assert.ok(!(await records()).some(item=>item.method==='session/prompt'));
assert.deepEqual(messages,['Minecraft MCP не запустился: инструменты строительства недоступны. Запрос остановлен; проверь настройки и процесс моста.']);
assert.ok(!messages.join(' ').includes('agent-secret'));
});
test('MCP startup failure during load stops the new turn while historical failures stay private',async t=>{
const {options,records}=await fixture(t);const messages=[];
let session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
await session.prompt('First turn',async()=>{});session.close();options.args.push('mcp-fail-load');
session=new CodexSession({projectId:'p',playerId:'u'},options);
await assert.rejects(session.prompt('Must not run',async text=>messages.push(text)),error=>error.code==='mcp_unavailable');
assert.equal((await records()).filter(item=>item.method==='session/prompt').length,1);
assert.ok(messages.some(text=>text.includes('Minecraft MCP не запустился')));
assert.ok(!messages.join(' ').includes('PRIVATE'));
});
test('MCP startup failures from another session do not block the selected session',async t=>{
const {options}=await fixture(t);options.args.push('mcp-fail-other-session');const messages=[];
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
await session.prompt('Continue selected session',async text=>messages.push(text));
assert.ok(messages.some(text=>text.includes('Built turn 1')));
assert.ok(!messages.some(text=>text.includes('MCP не запустился')));
});
test('an asynchronous MCP startup failure interrupts an active turn with a safe explanation',async t=>{
const {options}=await fixture(t);const messages=[];
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
await assert.rejects(session.prompt('mcp-fail-active',async(text,done)=>messages.push({text,done})),error=>error.code==='mcp_unavailable');
assert.ok(messages.some(item=>item.text.includes('Minecraft MCP не запустился')));
assert.ok(!messages.some(item=>item.done || item.text.includes('agent-secret')));
});
test('ACP cancel completes active prompt without ending the whole daemon',async t=>{
const {options,records}=await fixture(t);const messages=[];
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
@@ -52,3 +114,69 @@ test('cancel during startup does not send a prompt after initialization complete
const work=session.prompt('must-not-send',async()=>{});await session.cancel();await work;
assert.ok(!(await records()).some(item=>item.method==='session/prompt'));
});
test('delayed token streams keep words intact and flush the next turn independently',async t=>{
const {options}=await fixture(t);
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
const first=[];
await session.prompt('stream-greeting',async(text,done)=>first.push({text,done}));
const visible=items=>items.filter(item=>item.text).map(item=>item.text).join(' ').replace(/\s+/g,' ').trim();
assert.equal(visible(first),'Привет! Что построим?','ACP token boundaries must not become game-chat word boundaries');
assert.equal(first.at(-1).done,true);
assert.equal(first.filter(item=>item.done).length,1);
await pause(1600);
const second=[];
await session.prompt('stream-follow-up',async(text,done)=>second.push({text,done}));
assert.equal(visible(second),'Понятно. Проверяю освещение','a new prompt resets buffering and completion flushes an unfinished sentence');
assert.equal(second.at(-1).done,true);
assert.equal(second.filter(item=>item.done).length,1);
});
test('slow chat delivery preserves streamed text order and completes after the final reply',async t=>{
const {options}=await fixture(t);
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
const messages=[];let inFlight=0;let maxInFlight=0;let calls=0;
await session.prompt('stream-slow-delivery',async(text,done)=>{
const call=++calls;
maxInFlight=Math.max(maxInFlight,++inFlight);
// A slower first HTTP reply must not let later chunks or the done marker overtake it.
await pause(call===1 ? 100 : 3);
messages.push({text,done});
inFlight--;
});
assert.equal(inFlight,0,'prompt completion must await all chat replies');
assert.equal(maxInFlight,1,'chat replies must be serialized');
assert.equal(messages.at(-1).done,true);
assert.equal(messages.filter(item=>item.done).length,1);
const visible=messages.filter(item=>item.text).map(item=>item.text);
assert.ok(visible.length>1,'long replies need multiple Minecraft chat messages');
assert.ok(visible.every(text=>text.length<=240),'chat messages must respect the UTF-16 length bound');
assert.ok(visible.every(text=>text.isWellFormed()),'chunking must not split a surrogate pair');
assert.equal(visible.join(' ').replace(/\s+/g,' ').trim(),longReply,'visible text must retain every word exactly once and in order');
});
test('a word longer than one chat message is bounded without splitting emoji',async t=>{
const {options}=await fixture(t);
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
const messages=[];
await session.prompt('stream-overlong-word',async(text,done)=>messages.push({text,done}));
const visible=messages.filter(item=>item.text).map(item=>item.text);
assert.ok(visible.length>1);
assert.ok(visible.every(text=>text.length<=240 && text.isWellFormed()));
assert.equal(visible.join(''),overlongWord,'an unavoidable long-word split must preserve all Unicode text');
assert.equal(messages.at(-1).done,true);
});
test('only correlated Minecraft tools receive one-use approval in the active session',async t=>{
for(const mode of ['valid','material-search','material-describe','terrain-preview','terrain-prepare','terrain-brush','foreign','unknown','wrong-session','uncorrelated','completed','no-meta','persistent']) {
await t.test(mode,async t=>{
const {options,records}=await fixture(t);const messages=[];
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
await session.prompt('mcp-approval-'+mode,async text=>messages.push(text));
const response=(await records()).find(item=>item.id==='approval').result.outcome;
assert.deepEqual(response,['valid','material-search','material-describe','terrain-preview','terrain-prepare','terrain-brush'].includes(mode)?{outcome:'selected',optionId:'allow_once'}:{outcome:'cancelled'});
assert.equal(messages.some(text=>text.includes('отклонено')),!['valid','material-search','material-describe','terrain-preview','terrain-prepare','terrain-brush'].includes(mode));
});
}
});
+32 -1
View File
@@ -1,21 +1,52 @@
import { createInterface } from 'node:readline';
import { appendFileSync } from 'node:fs';
import { setTimeout as pause } from 'node:timers/promises';
import { greetingChunks, followUpChunks, longReply, overlongWord, tokenChunks } from './streamed-replies.mjs';
const send = value => process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\n');
const startupFailure = (sessionId = 'session-one') => send({method:'session/update',params:{sessionId,update:{sessionUpdate:'tool_call',toolCallId:'mcp_startup.minecraft-builder-mcp',title:'mcp__minecraft-builder-mcp__startup',kind:'other',status:'failed',content:[{type:'content',content:{type:'text',text:'PRIVATE STARTUP DETAIL agent-secret'}}]}}});
let promptId;let cancelled=false;let turn=0;
for await (const line of createInterface({input:process.stdin})) {
const msg=JSON.parse(line);
if(process.argv[2]) appendFileSync(process.argv[2],JSON.stringify(msg)+'\n');
if(msg.method==='initialize') send({id:msg.id,result:{protocolVersion:1,agentCapabilities:{loadSession:true},authMethods:[]}});
else if(msg.method==='session/new') send({id:msg.id,result:{sessionId:'session-one'}});
else if(msg.method==='session/new') {
if(process.argv[3]==='mcp-fail-new') startupFailure();
if(process.argv[3]==='mcp-fail-other-session') startupFailure('unrelated-session');
send({id:msg.id,result:{sessionId:'session-one'}});
}
else if(msg.method==='session/load') {
send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:'PRIVATE HISTORY'}}}});
send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'tool_call',toolCallId:'old-mcp-tool-call',title:'mcp__minecraft-builder-mcp__project_context',kind:'other',status:'failed',content:[{type:'content',content:{type:'text',text:'PRIVATE HISTORY FAILED TOOL'}}]}}});
if(process.argv[3]==='mcp-fail-load') startupFailure();
send({id:msg.id,result:{}});
}
else if(msg.method==='session/prompt') {
turn++;promptId=msg.id;
const content=msg.params.prompt[0].text;
if(content.includes('mcp-fail-active')) {startupFailure();continue;}
if(content.includes('wait-for-cancel')) continue;
if(content.includes('mcp-approval-')) {
const mode=content.match(/mcp-approval-([a-z-]+)/)?.[1];
const server=mode==='foreign'?'other-server':'minecraft-builder-mcp';
const tool=mode==='unknown'?'run_shell':mode==='material-search'?'material_search':mode==='material-describe'?'material_describe':mode==='terrain-preview'?'terrain_preview':mode==='terrain-prepare'?'terrain_prepare':mode==='terrain-brush'?'terrain_brush_prepare':'build_prepare';
const sid=mode==='wrong-session'?'other-session':'session-one';
if(mode!=='uncorrelated') send({method:'session/update',params:{sessionId:sid,update:{sessionUpdate:'tool_call',toolCallId:'mc-call',kind:'execute',title:`mcp.${server}.${tool}`,status:'in_progress',rawInput:{server,tool,arguments:{}},_meta:{is_mcp_tool_call:true}}}});
if(mode==='completed') send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'tool_call_update',toolCallId:'mc-call',status:'completed'}}});
send({id:'approval',method:'session/request_permission',params:{sessionId:'session-one',toolCall:{toolCallId:'mc-call',kind:'execute',status:'pending'},...(mode==='no-meta'?{}:{_meta:{is_mcp_tool_approval:true}}),options:[{optionId:mode==='persistent'?'allow_always':'allow_once',name:'Allow',kind:mode==='persistent'?'allow_always':'allow_once'}]}});continue;
}
if(content.includes('request-permission')) {send({id:'approval',method:'session/request_permission',params:{sessionId:'session-one',toolCall:{toolCallId:'dangerous',title:'Permission',kind:'execute'},options:[{optionId:'yes',name:'Allow',kind:'allow_once'}]}});continue;}
if(content.includes('stream-greeting') || content.includes('stream-follow-up') || content.includes('stream-slow-delivery') || content.includes('stream-overlong-word')) {
const greeting = content.includes('stream-greeting');
const chunks = greeting ? greetingChunks : content.includes('stream-follow-up') ? followUpChunks : tokenChunks(content.includes('stream-overlong-word') ? overlongWord : longReply);
// Real models can think for seconds before emitting their first incomplete token.
if(greeting) await pause(1600);
for(const text of chunks) {
send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text}}}});
await pause(greeting ? 12 : 1);
}
send({id:msg.id,result:{stopReason:'end_turn'}});
continue;
}
send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'agent_thought_chunk',content:{type:'text',text:'SECRET THOUGHT'}}}});
send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:`Built turn ${turn}.`}}}});
send({id:msg.id,result:{stopReason:'end_turn'}});
+17
View File
@@ -0,0 +1,17 @@
export const greetingChunks = ['Пр', 'ивет! ', 'Что пост', 'роим?'];
export const followUpChunks = ['По', 'нятно. ', 'Проверяю ', 'осве', 'щение'];
export const longReply = 'Осматриваю зал. ' + Array.from({ length: 28 }, (_, i) =>
`Арка${i + 1} цела, фонарь${i + 1} 🏮 установлен ровно`
).join(', ') + '. Проверка завершена';
export const overlongWord = 'А'.repeat(239) + '🏮'.repeat(150) + 'конец';
export function tokenChunks(text) {
const sizes = [2, 3, 7, 4, 11];
const chunks = [];
for (let offset = 0, i = 0; offset < text.length; i++) {
const end = Math.min(text.length, offset + sizes[i % sizes.length]);
chunks.push(text.slice(offset, end));
offset = end;
}
return chunks;
}
+61
View File
@@ -0,0 +1,61 @@
// Opt-in isolated Paper test: relative edits, native preview, halo conflicts, full restoration.
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { setTimeout as pause } from 'node:timers/promises';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const client=new Client({name:'brush-live-test',version:'1'});
const transport=new StdioClientTransport({command:process.execPath,args:[resolve('dist/mcp.js')],env:Object.fromEntries(Object.entries(process.env).filter(([k,v])=>v!==undefined&&k!=='MCB_TOKEN')),stderr:'pipe'});
async function call(name,args={},allowError=false){for(let i=0;i<100;i++){
const result=await client.callTool({name,arguments:args});const data=JSON.parse(result.content.find(c=>c.type==='text').text);
if(result.isError&&data.code==='busy'){await pause(100);continue;}
if(result.isError&&!allowError)throw new Error(`${name}: ${data.code}: ${data.message}`);
return {data,result};
}throw new Error('Still busy');}
async function done(id){for(let i=0;i<1000;i++){const {data}=await call('operation_status',{operation_id:id});if(['applied','conflict','cancelled','failed','recovery_required'].includes(data.status))return data;await pause(50);}throw new Error(`Still running: ${id}`);}
async function apply(plan){const args={plan_id:plan.plan_id,plan_hash:plan.plan_hash,idempotency_key:'brush-test-'+randomUUID()};const {data}=await call('build_apply',args);return await done(data.operation_id);}
async function undo(id){const {data}=await call('operation_undo_prepare',{operation_id:id});const result=await apply(data);assert.equal(result.status,'applied');}
const min={x:-4,y:80,z:-4},max={x:4,y:92,z:4};
async function air(){const {data}=await call('region_inspect',{min,max,detail:'summary'});assert.deepEqual(data.palette,{'minecraft:air':1053});}
async function surface(){const {data}=await call('region_inspect',{min:{x:0,y:80,z:0},max:{x:0,y:92,z:0},detail:'blocks'});return Math.max(...data.blocks.filter(b=>b.state!=='minecraft:air').map(b=>b.pos.y));}
const brush={min,max,center:{x:0,z:0},radius:3,strength:1,falloff:0.5};
let base;
try{
await client.connect(transport);
assert.ok((await call('project_context')).data.capabilities.includes('terrain_brush_prepare'));await air();
const {data:basePlan}=await call('build_prepare',{recipe:{version:1,operations:[
{type:'box',min,max:{x:4,y:83,z:4},block:'minecraft:stone'},
{type:'box',min:{x:-4,y:84,z:-4},max:{x:4,y:84,z:4},block:'minecraft:grass_block'},
]}});base=await apply(basePlan);assert.equal(base.status,'applied');assert.equal(await surface(),84);
for(const [action,extra,expected] of [['raise',{amount:3},87],['lower',{amount:2},82],['flatten',{height:86},86]]){
const {data:plan,result}=await call('terrain_brush_prepare',{brush:{...brush,action,...extra}});
assert.equal(plan.plan_state,'prepared');assert.equal(plan.dependency_blocks,1053);assert.equal(plan.world_edited,false);assert.equal(await surface(),84);
const png=result.content.find(c=>c.type==='image');assert.ok(png);
if(action==='raise')await writeFile('../docs/references/terrain-brush-live-preview.png',Buffer.from(png.data,'base64'));
const applied=await apply(plan);assert.equal(applied.status,'applied');assert.equal(await surface(),expected);
await undo(applied.operation_id);assert.equal(await surface(),84);
console.log(JSON.stringify({action,status:'passed',written:applied.written,center_after:expected,undo_height:84}));
}
const {data:spikePlan}=await call('terrain_brush_prepare',{brush:{...brush,radius:1,action:'raise',amount:4,falloff:0}});const spike=await apply(spikePlan);assert.equal(spike.status,'applied');
const {data:smoothPlan}=await call('terrain_brush_prepare',{brush:{...brush,action:'smooth',smooth_radius:1}});const smooth=await apply(smoothPlan);assert.equal(smooth.status,'applied');assert.ok(await surface()<88);await undo(smooth.operation_id);assert.equal(await surface(),88);
const spikeMin={x:-1,y:84,z:-1},spikeMax={x:1,y:88,z:1};
const spikeSnapshot=(await call('region_inspect',{min:spikeMin,max:spikeMax,detail:'blocks'})).data.blocks;
for(const b of spikeSnapshot){const inside=b.pos.x*b.pos.x+b.pos.z*b.pos.z<=1;assert.equal(b.state,inside?(b.pos.y===88?'minecraft:grass_block[snowy=false]':'minecraft:stone'):(b.pos.y===84?'minecraft:grass_block[snowy=false]':'minecraft:air'));}
const {data:removeSpike}=await call('build_prepare',{recipe:{version:1,operations:[{type:'box',min:{x:-1,y:85,z:-1},max:spikeMax,block:'minecraft:air'},{type:'box',min:spikeMin,max:{x:1,y:84,z:1},block:'minecraft:grass_block'}]}});
assert.equal((await apply(removeSpike)).status,'applied');assert.equal(await surface(),84);
console.log(JSON.stringify({action:'smooth',status:'passed',written:smooth.written}));
const {data:stale}=await call('terrain_brush_prepare',{brush:{...brush,action:'raise',amount:2}});
const point={x:4,y:86,z:0};const {data:foreignPlan}=await call('build_prepare',{recipe:{version:1,operations:[{type:'box',min:point,max:point,block:'minecraft:gold_block'}]}});const foreign=await apply(foreignPlan);assert.equal(foreign.status,'applied');
const conflict=await apply(stale);assert.equal(conflict.status,'conflict');assert.equal(conflict.written,0);
const denied=await call('terrain_brush_prepare',{brush:{...brush,action:'raise',amount:2}},true);assert.ok(denied.result.isError);await undo(foreign.operation_id);
const {data:empty}=await call('terrain_brush_prepare',{brush:{...brush,action:'flatten',height:84}});assert.equal(empty.plan_state,'empty');assert.ok(!empty.plan_id);
// Base ownership has been superseded by checked brush undos. Verify every fixture cell,
// then prepare a fresh checked cleanup of this isolated fixture; never force the old undo.
const baseMax={x:4,y:84,z:4};
const restored=(await call('region_inspect',{min,max:baseMax,detail:'blocks'})).data.blocks;
assert.equal(restored.length,405);for(const b of restored)assert.equal(b.state,b.pos.y===84?'minecraft:grass_block[snowy=false]':'minecraft:stone');
const {data:cleanup}=await call('build_prepare',{recipe:{version:1,operations:[{type:'box',min,max:baseMax,block:'minecraft:air'}]}});
assert.equal((await apply(cleanup)).status,'applied');base=undefined;await air();console.log('LIVE BRUSH MCP PASSED; full fixture restored to air');
}finally{if(base)console.error(`Fixture needs inspection; base operation: ${base.operation_id}`);await client.close();}
+58
View File
@@ -0,0 +1,58 @@
// Opt-in live test in an isolated all-air fixture. No credentials are printed.
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import { readFile } from 'node:fs/promises';
import { setTimeout as pause } from 'node:timers/promises';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const client=new Client({name:'terrain-live-test',version:'1'});
const transport=new StdioClientTransport({command:process.execPath,args:[resolve('dist/mcp.js')],env:Object.fromEntries(Object.entries(process.env).filter(([k,v])=>v!==undefined&&k!=='MCB_TOKEN')),stderr:'pipe'});
async function call(name,args={},allowError=false){for(let i=0;i<100;i++){
const result=await client.callTool({name,arguments:args});const data=JSON.parse(result.content.find(c=>c.type==='text').text);
if(result.isError&&data.code==='busy'){await pause(100);continue;}
if(result.isError&&!allowError)throw new Error(`${name}: ${data.code}: ${data.message}`);
return {data,result};
}throw new Error('Still busy');}
async function done(id){for(let i=0;i<1000;i++){const {data}=await call('operation_status',{operation_id:id});if(['applied','conflict','cancelled','failed','recovery_required'].includes(data.status))return data;await pause(50);}throw new Error(`Still running: ${id}`);}
async function apply(plan){const args={plan_id:plan.plan_id,plan_hash:plan.plan_hash,idempotency_key:'terrain-test-'+randomUUID()};const {data}=await call('build_apply',args);const result=await done(data.operation_id);return {args,result};}
async function undo(id){const {data}=await call('operation_undo_prepare',{operation_id:id});const {result}=await apply(data);assert.equal(result.status,'applied');return result;}
const recipe=JSON.parse(await readFile('../examples/terrain/small-hill.json','utf8'));
const {min,max}=recipe;
const inspect=async()=> (await call('region_inspect',{min,max,detail:'blocks'})).data.blocks;
const air=blocks=>assert.ok(blocks.every(b=>b.state==='minecraft:air'));
try{
await client.connect(transport);
const {data:context}=await call('project_context');assert.ok(context.capabilities.includes('terrain_prepare'));
air(await inspect());
const {data:preview,result:previewResult}=await call('terrain_preview',{recipe});
assert.equal(preview.kind,'terrain_heightmap_preview');assert.equal(preview.world_verified,false);assert.equal(preview.tile_count,1);
const png=previewResult.content.find(c=>c.type==='image');assert.ok(png);assert.equal(Buffer.from(png.data,'base64').subarray(1,4).toString(),'PNG');
air(await inspect());
const {data:plan}=await call('terrain_prepare',{terrain_id:preview.terrain_id,tile_index:0});air(await inspect());assert.ok(plan.changed_blocks>0);
const {args,result}=await apply(plan);assert.equal(result.status,'applied');
assert.equal((await call('build_apply',args)).data.operation_id,result.operation_id);
const built=await inspect();assert.ok(built.some(b=>b.state.startsWith('minecraft:grass_block')));assert.ok(built.some(b=>b.state==='minecraft:dirt'));
assert.equal(built.filter(b=>b.state!=='minecraft:air').length,result.written);
assert.equal((await call('terrain_prepare',{terrain_id:preview.terrain_id,tile_index:0})).data.status,'empty');
await undo(result.operation_id);air(await inspect());
console.log(JSON.stringify({check:'terrain-native-preview-prepare-apply-idempotency-undo',status:'passed',written:result.written}));
const {data:stale}=await call('terrain_prepare',{terrain_id:preview.terrain_id,tile_index:0});
const {data:goldPlan}=await call('build_prepare',{recipe:{version:1,operations:[{type:'box',min,max:min,block:'minecraft:gold_block'}]}});
const {result:gold}=await apply(goldPlan);assert.equal(gold.status,'applied');
const denied=await call('terrain_prepare',{terrain_id:preview.terrain_id,tile_index:0},true);assert.equal(denied.data.code,'protected_terrain');
const {result:conflict}=await apply(stale);assert.equal(conflict.status,'conflict');assert.equal(conflict.written,0);
const masked=structuredClone(recipe);masked.preserve=[{min,max:min}];
const {data:maskedPreview}=await call('terrain_preview',{recipe:masked});
const {data:maskedPlan}=await call('terrain_prepare',{terrain_id:maskedPreview.terrain_id,tile_index:0});
const {result:maskedDone}=await apply(maskedPlan);assert.equal(maskedDone.status,'applied');
assert.equal((await call('region_inspect',{min,max:min,detail:'blocks'})).data.blocks[0].state,'minecraft:gold_block');
await undo(maskedDone.operation_id);await undo(gold.operation_id);air(await inspect());
console.log(JSON.stringify({check:'protected-existing-build-preserve-mask-and-stale-plan-conflict',status:'passed'}));
const empty=structuredClone(recipe);empty.preserve=[{min,max}];
const {data:emptyPreview}=await call('terrain_preview',{recipe:empty});assert.equal((await call('terrain_prepare',{terrain_id:emptyPreview.terrain_id,tile_index:0})).data.status,'empty');
const outside=structuredClone(recipe);outside.min.x=200;outside.max.x=207;
const {data:outsidePreview}=await call('terrain_preview',{recipe:outside});assert.equal((await call('terrain_prepare',{terrain_id:outsidePreview.terrain_id,tile_index:0},true)).data.code,'out_of_bounds');
assert.equal((await call('terrain_prepare',{terrain_id:'0'.repeat(64),tile_index:0},true)).data.code,'not_found');
air(await inspect());console.log('LIVE TERRAIN MCP PASSED; test area restored to air');
}finally{await client.close();}
+72 -1
View File
@@ -19,7 +19,9 @@ test('real stdio MCP lists tools, validates recipes, calls backend and returns c
const client=new Client({name:'test',version:'1'});
t.after(async()=>{await client.close();backend.closeAllConnections();backend.close();});
await client.connect(transport);
const list=await client.listTools();assert.equal(list.tools.length,14);
assert.ok(client.getInstructions().includes('Avoid large rectangular plateaus'));
assert.ok(client.getInstructions().includes('NOT as terrain_preview parameters'));
const list=await client.listTools();assert.equal(list.tools.length,19);
assert.ok(list.tools.some(tool=>tool.name==='schematic_import_prepare'));assert.ok(!list.tools.some(tool=>tool.name==='chat_poll'));
const context=await client.callTool({name:'project_context',arguments:{player_id:'forged'}});
assert.equal(JSON.parse(context.content[0].text).project_id,'project');
@@ -35,6 +37,75 @@ test('real stdio MCP lists tools, validates recipes, calls backend and returns c
const invalidRotation=await client.callTool({name:'schematic_import_prepare',arguments:{asset_id:'asset',target:{x:0,y:64,z:0},rotation:45}});assert.equal(invalidRotation.isError,true);assert.equal(seen.length,beforeAssets);
await client.callTool({name:'schematic_import_prepare',arguments:{asset_id:'asset',target:{x:0,y:64,z:0},rotation:90}});assert.equal(seen.at(-1).params.rotation,90);
await client.callTool({name:'asset_list',arguments:{query:'tower'}});assert.equal(seen.at(-1).params.query,'tower');
const recipe={version:1,min:{x:0,y:0,z:0},max:{x:31,y:31,z:31},base_height:8,seed:1,mode:'sculpt',noise:{amplitude:3,scale:16},palette:{rock:'minecraft:stone',soil:'minecraft:dirt',surface:'minecraft:grass_block',soil_depth:2},features:[{type:'plateau',min:{x:2,z:2},max:{x:8,z:8},height:14,falloff:4}],preserve:[]};
await client.callTool({name:'terrain_preview',arguments:{recipe}});assert.equal(seen.at(-1).method,'terrain_preview');assert.equal(seen.at(-1).params.resolution,128);
await client.callTool({name:'terrain_prepare',arguments:{terrain_id:'a'.repeat(64),tile_index:1}});assert.equal(seen.at(-1).method,'terrain_prepare');
const beforeTerrain=seen.length;
for(const args of [{recipe:{...recipe,script:'execute'}},{recipe:{...recipe,features:[{type:'channel',points:[],width:2,falloff:3,height:0}]}},{recipe:{...recipe,palette:{...recipe.palette,rock:'minecraft:water'}}}]) assert.equal((await client.callTool({name:'terrain_preview',arguments:args})).isError,true);
assert.equal((await client.callTool({name:'terrain_prepare',arguments:{terrain_id:'../../file',tile_index:-1}})).isError,true);
assert.equal(seen.length,beforeTerrain);
const brush={min:{x:-4,y:0,z:-4},max:{x:4,y:12,z:4},center:{x:0,z:0},radius:3,action:'raise',amount:2};
await client.callTool({name:'terrain_brush_prepare',arguments:{brush}});
assert.equal(seen.at(-1).method,'terrain_brush_prepare');assert.equal(seen.at(-1).params.brush.falloff,0.5);
const beforeBrush=seen.length;
for(const bad of [{...brush,radius:50},{...brush,strength:2},{...brush,action:'execute'},{...brush,script:'run'}])assert.equal((await client.callTool({name:'terrain_brush_prepare',arguments:{brush:bad}})).isError,true);
assert.equal(seen.length,beforeBrush);
});
test('material discovery uses bounded read-only calls and never expands registry in schemas or instructions', async t => {
const seen=[];
const backend=createServer(async(req,res)=>{
let body='';for await(const chunk of req) body+=chunk;
const rpc=JSON.parse(body);seen.push(rpc);
const result=rpc.method==='material_search'
?{catalog_version:'v1',query:rpc.params.query??'',kind:rpc.params.kind,total:2,results:[{id:'minecraft:copper_door',block:true,item:true}],next_cursor:'next-page'}
:rpc.method==='material_describe'
?{catalog_version:'v1',id:'minecraft:copper_door',block:true,item:true,placeable:true,default_state:'minecraft:copper_door[facing=north,half=lower,hinge=left,open=false,powered=false]',properties:{facing:['north','south','east','west'],half:['lower','upper'],hinge:['left','right'],open:['false','true'],powered:['false','true']}}
:{status:'prepared'};
res.setHeader('content-type','application/json');res.end(JSON.stringify({ok:true,result}));
});backend.listen(0,'127.0.0.1');await once(backend,'listening');
const transport=new StdioClientTransport({command:process.execPath,args:[resolve('dist/mcp.js')],env:{MCB_BACKEND_URL:`http://127.0.0.1:${backend.address().port}`,MCB_AGENT_TOKEN:'agent',MCB_PROJECT_ID:'project',MCB_PLAYER_ID:'player'},stderr:'pipe'});
const client=new Client({name:'materials-test',version:'1'});
t.after(async()=>{await client.close();backend.closeAllConnections();backend.close();});
await client.connect(transport);
const list=await client.listTools();
const discovery=list.tools.filter(tool=>tool.name.startsWith('material_'));
assert.deepEqual(discovery.map(tool=>tool.name),['material_search','material_describe']);
for(const tool of discovery){assert.equal(tool.annotations.readOnlyHint,true);assert.equal(tool.annotations.idempotentHint,true);assert.equal(tool.annotations.destructiveHint,false);}
assert.ok(client.getInstructions().includes('project_context contains only a catalog summary'));
assert.ok(client.getInstructions().includes('do not enumerate the registry'));
assert.ok(!JSON.stringify(discovery).includes('minecraft:copper_door'),'registry entries must be discovered, not embedded in the MCP tool catalog');
const found=await client.callTool({name:'material_search',arguments:{query:'copper door'}});
assert.equal(seen.at(-1).method,'material_search');assert.equal(seen.at(-1).params.limit,16);assert.equal(seen.at(-1).params.kind,'block');
assert.equal(JSON.parse(found.content[0].text).results[0].id,'minecraft:copper_door');
await client.callTool({name:'material_search',arguments:{query:'copper door',kind:'all',limit:32,cursor:'next-page'}});
assert.equal(seen.at(-1).params.cursor,'next-page');assert.equal(seen.at(-1).params.kind,'all');
const described=JSON.parse((await client.callTool({name:'material_describe',arguments:{id:'minecraft:copper_door'}})).content[0].text);
assert.equal(seen.at(-1).method,'material_describe');assert.deepEqual(described.properties.half,['lower','upper']);
const beforeInvalid=seen.length;
for(const arguments_ of [{query:'x'.repeat(97)},{kind:'entity'},{limit:0},{limit:33},{limit:1.5},{cursor:''},{cursor:'x'.repeat(101)}]){
assert.equal((await client.callTool({name:'material_search',arguments:arguments_})).isError,true);
}
for(const id of ['copper_door','minecraft:stone[foo=bar]','minecraft:chest{Items:[]}','other:stone','minecraft:../stone',`minecraft:${'x'.repeat(119)}`]){
assert.equal((await client.callTool({name:'material_describe',arguments:{id}})).isError,true);
}
assert.equal(seen.length,beforeInvalid,'invalid discovery arguments must be rejected before backend transport');
const box=block=>({version:1,operations:[{type:'box',min:{x:0,y:64,z:0},max:{x:0,y:64,z:0},block}]});
for(const block of ['minecraft:copper_door[facing=west,half=lower,hinge=left,open=false,powered=false]','minecraft:water[level=0]']){
assert.ok(!(await client.callTool({name:'build_prepare',arguments:{recipe:box(block)}})).isError,'ordinary recipes must not retain the old material allowlist');
}
const snapshot={pos:{x:0,y:64,z:0},state:'minecraft:chest[facing=north,type=single,waterlogged=false]',snapshot_id:'a'.repeat(64)};
assert.ok(!(await client.callTool({name:'build_prepare',arguments:{recipe:box('minecraft:chest[facing=west]'),expected_blocks:[snapshot]}})).isError);
assert.equal(seen.at(-1).params.expected_blocks[0].snapshot_id,snapshot.snapshot_id,'block-entity snapshot digest must survive MCP transport unchanged');
const beforeBadSnapshot=seen.length;
for(const snapshot_id of ['a'.repeat(63),'A'.repeat(64),'not-a-snapshot']){
assert.equal((await client.callTool({name:'build_prepare',arguments:{recipe:box('minecraft:stone'),expected_blocks:[{...snapshot,snapshot_id}]}})).isError,true);
}
assert.equal(seen.length,beforeBadSnapshot);
const beforeUnsafe=seen.length;
for(const block of ['minecraft:command_block{Command:"say hello"}',`minecraft:stone[p=${'x'.repeat(1024)}]`]){
assert.equal((await client.callTool({name:'build_prepare',arguments:{recipe:box(block)}})).isError,true);
}
assert.equal(seen.length,beforeUnsafe,'state length and no-NBT boundaries must hold before backend transport');
});
test('camera image is a native MCP image rather than a text context dump',()=>{
const result=toolResult({status:'completed',captureId:'c1',imageBase64:'YWJj',mimeType:'image/png'});
+56 -2
View File
@@ -1,9 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {mkdtemp,readFile,writeFile,rm} from 'node:fs/promises';
import {mkdtemp,readFile,writeFile,readdir,rm,stat} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {codexPaths,prepareCodexHome,CODEX_CONFIG_TOML,securityReport} from '../dist/security.js';
import {codexPaths,prepareCodexHome,CODEX_CONFIG,CODEX_CONFIG_TOML,securityReport} from '../dist/security.js';
const oldManagedConfig=CODEX_CONFIG_TOML.replace('code_mode_host = true\n','code_mode_host = false\n');
test('dedicated home gets known config and never overwrites existing user configuration',async t=>{
const root=await mkdtemp(join(tmpdir(),'mcb-security-'));t.after(()=>rm(root,{recursive:true,force:true}));
const paths=codexPaths(root);await prepareCodexHome(paths);
@@ -13,9 +14,62 @@ test('dedicated home gets known config and never overwrites existing user config
await assert.rejects(prepareCodexHome(paths),/will not be overwritten/);
assert.equal(await readFile(join(paths.codexHome,'config.toml'),'utf8'),'sandbox_mode = "danger-full-access"\n');
});
test('migrates only the known old managed config atomically, backs it up and preserves authentication',async t=>{
const root=await mkdtemp(join(tmpdir(),'mcb-security-'));t.after(()=>rm(root,{recursive:true,force:true}));
const paths=codexPaths(root);await prepareCodexHome(paths);
const configPath=join(paths.codexHome,'config.toml');
const authPath=join(paths.codexHome,'auth.json');
const syntheticAuth='{"test_fixture":"preserve-me-byte-for-byte"}\n';
await writeFile(configPath,oldManagedConfig);await writeFile(authPath,syntheticAuth,{mode:0o600});
await prepareCodexHome(paths);
assert.equal(await readFile(configPath,'utf8'),CODEX_CONFIG_TOML);
assert.equal(await readFile(`${configPath}.before-code-mode-host`,'utf8'),oldManagedConfig);
assert.equal(await readFile(authPath,'utf8'),syntheticAuth);
if(process.platform!=='win32'){
assert.equal((await stat(configPath)).mode&0o777,0o600);
assert.equal((await stat(`${configPath}.before-code-mode-host`)).mode&0o777,0o600);
}
await prepareCodexHome(paths);
assert.equal(await readFile(configPath,'utf8'),CODEX_CONFIG_TOML);
assert.equal(await readFile(`${configPath}.before-code-mode-host`,'utf8'),oldManagedConfig);
assert.equal(await readFile(authPath,'utf8'),syntheticAuth);
assert.equal((await readdir(paths.codexHome)).filter(name=>name.endsWith('.tmp')).length,0);
});
test('refuses even small custom changes to old managed configuration without creating a backup',async t=>{
const root=await mkdtemp(join(tmpdir(),'mcb-security-'));t.after(()=>rm(root,{recursive:true,force:true}));
const paths=codexPaths(root);await prepareCodexHome(paths);
const configPath=join(paths.codexHome,'config.toml');
for(const custom of [oldManagedConfig+'# my settings\n',oldManagedConfig.replace('shell_tool = false','shell_tool = true')]){
await writeFile(configPath,custom);
await assert.rejects(prepareCodexHome(paths),/will not be overwritten/);
assert.equal(await readFile(configPath,'utf8'),custom);
await assert.rejects(readFile(`${configPath}.before-code-mode-host`),{code:'ENOENT'});
}
});
test('resumes with an exact previous backup but refuses to overwrite a different backup',async t=>{
const root=await mkdtemp(join(tmpdir(),'mcb-security-'));t.after(()=>rm(root,{recursive:true,force:true}));
const paths=codexPaths(root);await prepareCodexHome(paths);
const configPath=join(paths.codexHome,'config.toml');const backupPath=`${configPath}.before-code-mode-host`;
await writeFile(configPath,oldManagedConfig);await writeFile(backupPath,'different backup\n');
await assert.rejects(prepareCodexHome(paths),/backup.*will not be overwritten/);
assert.equal(await readFile(configPath,'utf8'),oldManagedConfig);
assert.equal(await readFile(backupPath,'utf8'),'different backup\n');
await writeFile(backupPath,oldManagedConfig);await prepareCodexHome(paths);
assert.equal(await readFile(configPath,'utf8'),CODEX_CONFIG_TOML);
assert.equal(await readFile(backupPath,'utf8'),oldManagedConfig);
});
test('code-mode host is enabled without enabling the separately restricted integrations',()=>{
assert.equal(CODEX_CONFIG.features.code_mode_host,true);
for(const [feature,enabled] of Object.entries(CODEX_CONFIG.features)){
if(feature!=='code_mode_host')assert.equal(enabled,false,feature);
}
assert.equal(CODEX_CONFIG.sandbox_mode,'read-only');assert.equal(CODEX_CONFIG.web_search,'disabled');
});
test('doctor security report describes upstream workspace-write limitation honestly',()=>{
const report=securityReport(codexPaths('/state'));
assert.equal(report.configuredSandbox,'read-only');assert.equal(report.adapterTurnSandbox,'workspace-write');assert.equal(report.runtimeVerified,false);
assert.equal(report.pinnedCliFeatureProbe.unified_exec,true);
assert.equal(report.codeModeHostRequested,true);assert.equal(report.codeModeHostRequiredForModelToolMode,'code_mode_only');
assert.ok(report.limitations.some(text=>text.includes('Doctor does not invoke a model')));
assert.ok(report.limitations.some(text=>text.includes('temporary paths')));
});