codex-workspace-codegraph-mcp 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ # Architecture
2
+
3
+ ```text
4
+ MCP client
5
+ ├─ newline-delimited JSON-RPC over stdio
6
+ └─ token-authenticated JSON-RPC POST over HTTP
7
+
8
+
9
+ McpServer
10
+ ├─ initialize / ping / tools / prompts / resources
11
+ ├─ Codex-inspired local ToolRegistry
12
+ │ ├─ Workspace path + realpath confinement
13
+ │ ├─ Reads, search, metadata and directory traversal
14
+ │ ├─ Atomic writes, exact edits and transactional apply_patch
15
+ │ ├─ Exec risk classification and approval routing
16
+ │ ├─ Git read tools
17
+ │ └─ In-memory plan state
18
+ ├─ FeedbackManager
19
+ │ ├─ Simplified Chinese Web UI
20
+ │ ├─ text and image feedback
21
+ │ ├─ validation commands
22
+ │ └─ high-risk approvals
23
+ └─ CodeGraphProxy
24
+ └─ codegraph serve --mcp over newline-delimited stdio
25
+ ```
26
+
27
+ ## No Codex dependency
28
+
29
+ There is no Codex adapter, Codex child process, account state, API client, model call, thread, turn, or authentication code. The term “Codex-inspired” refers only to local coding-agent tool design and workflow conventions studied from the Apache-2.0 repository.
30
+
31
+ ## Transports
32
+
33
+ ### stdio
34
+
35
+ Each JSON-RPC message is one UTF-8 JSON line. stdout is reserved for protocol messages; diagnostics use stderr.
36
+
37
+ ### HTTP
38
+
39
+ The server exposes:
40
+
41
+ ```text
42
+ POST /mcp/<random-token>
43
+ GET /health
44
+ ```
45
+
46
+ The token may alternatively be supplied as a Bearer token. Incoming browser origins are checked against same-origin/loopback values plus the optional `CWMCP_ALLOWED_ORIGINS` allowlist, and unsupported MCP protocol-version headers are rejected. The HTTP implementation is stateless and returns JSON responses rather than SSE streams. This is deliberate because TryCloudflare Quick Tunnels do not support SSE.
47
+
48
+ ## Tool aggregation
49
+
50
+ Local tools are defined by `ToolRegistry`. CodeGraph tools are requested dynamically with `tools/list`; name collisions are exposed as `codegraph__<name>`. Tool calls are forwarded unchanged through `tools/call`.
51
+
52
+ If CodeGraph cannot start, local tools remain available. A later `tools/list` retries startup.
53
+
54
+ ## Workspace safety
55
+
56
+ Every user path resolves against a configured real workspace root. Existing targets use `realpath`; new targets validate their nearest existing ancestor. Both lexical and real paths must remain inside the workspace.
57
+
58
+ `apply_patch` parses all operations first, builds a virtual final file state, validates contexts and path transitions, then writes final files through temporary files. Original file snapshots are retained in memory for best-effort rollback.
59
+
60
+ ## Feedback state
61
+
62
+ The WebUI starts once and shows all pending sessions. MCP feedback calls wait on an in-memory Promise for up to 172800 seconds. Browser polling retrieves pending sessions and submits text, image attachments, approval decisions, or validation-command requests.
63
+
64
+ Attachments are stored outside the repository under the user's home directory.
65
+
66
+ ## Tunnel launcher
67
+
68
+ The launcher starts one HTTP MCP process and two `cloudflared tunnel --url` processes, because Quick Tunnel assigns one random hostname to one local origin. It parses both `trycloudflare.com` URLs from process output and appends tokenized MCP/WebUI paths.
69
+
70
+ An explicit empty temporary cloudflared config is used to avoid interference from a user-level named-tunnel configuration.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "codex-workspace-codegraph-mcp",
3
+ "version": "0.2.0",
4
+ "description": "Codex-inspired local workspace tools, Chinese interactive feedback, CodeGraph retrieval, and Cloudflare tunnel launcher for MCP.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "engines": {
8
+ "node": ">=22.5.0"
9
+ },
10
+ "bin": {
11
+ "codex-workspace-codegraph-mcp": "bin/codex-workspace-mcp.js",
12
+ "codex-workspace-mcp": "bin/codex-workspace-mcp.js",
13
+ "codex-workspace-mcp-tunnel": "bin/start-tunnel.js"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "src",
18
+ "scripts",
19
+ "docs",
20
+ "AGENTS.md",
21
+ "README.md",
22
+ "README.en.md",
23
+ "SECURITY.md",
24
+ "THIRD_PARTY_NOTICES.md",
25
+ "VALIDATION.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "start": "node bin/codex-workspace-mcp.js",
30
+ "start:http": "node bin/codex-workspace-mcp.js --transport http",
31
+ "tunnel": "node bin/start-tunnel.js",
32
+ "check": "find bin src test -name '*.js' -type f -exec node --check {} +",
33
+ "test": "node --test test/*.test.js",
34
+ "smoke": "node test/smoke.js",
35
+ "verify": "npm run check && npm test && npm run smoke && npm pack --dry-run"
36
+ },
37
+ "dependencies": {
38
+ "@colbymchenry/codegraph": "^1.4.1"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/lllli223/codex-workspace-codegraph-mcp.git"
43
+ },
44
+ "keywords": [
45
+ "mcp",
46
+ "codex",
47
+ "codegraph",
48
+ "interactive-feedback",
49
+ "cloudflared",
50
+ "coding-agent"
51
+ ]
52
+ }
@@ -0,0 +1,2 @@
1
+ $ErrorActionPreference = "Stop"
2
+ node (Join-Path $PSScriptRoot "..\bin\start-tunnel.js") @args
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env sh
2
+ set -eu
3
+ exec node "$(dirname "$0")/../bin/start-tunnel.js" "$@"
@@ -0,0 +1,236 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { fileExists, randomToken } from './utils.js';
5
+ import { runProcess } from './process.js';
6
+
7
+ const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
8
+
9
+ function localCodegraphBinary() {
10
+ return path.resolve(MODULE_DIR, '..', 'node_modules', '.bin', process.platform === 'win32' ? 'codegraph.cmd' : 'codegraph');
11
+ }
12
+
13
+ export class CodeGraphProxy {
14
+ constructor(config, workspace, feedback) {
15
+ this.config = config;
16
+ this.workspace = workspace;
17
+ this.feedback = feedback;
18
+ this.child = null;
19
+ this.buffer = '';
20
+ this.pending = new Map();
21
+ this.nextId = 1;
22
+ this.startPromise = null;
23
+ this.tools = [];
24
+ this.lastError = null;
25
+ }
26
+
27
+ async resolveCommand() {
28
+ const local = localCodegraphBinary();
29
+ if (await fileExists(local)) return local;
30
+ return this.config.codegraphCommand;
31
+ }
32
+
33
+ async start() {
34
+ if (!this.config.codegraphEnabled) return false;
35
+ if (this.child && !this.child.killed) return true;
36
+ if (this.startPromise) return this.startPromise;
37
+ this.startPromise = this.#startInternal().finally(() => { this.startPromise = null; });
38
+ return this.startPromise;
39
+ }
40
+
41
+ async #startInternal() {
42
+ const command = await this.resolveCommand();
43
+ this.lastError = null;
44
+ const child = spawn(command, this.config.codegraphArgs, {
45
+ cwd: this.workspace.root,
46
+ env: { ...process.env, CODEGRAPH_MCP_TOOLS: this.config.codegraphTools },
47
+ windowsHide: true,
48
+ stdio: ['pipe', 'pipe', 'pipe'],
49
+ });
50
+ this.child = child;
51
+ child.stdout.setEncoding('utf8');
52
+ child.stderr.setEncoding('utf8');
53
+ child.stdout.on('data', (chunk) => this.#onStdout(chunk));
54
+ child.stderr.on('data', (chunk) => {
55
+ if (!this.config.quiet) process.stderr.write(`[codegraph] ${chunk}`);
56
+ });
57
+ child.on('error', (error) => this.#failAll(error));
58
+ child.on('close', (code, signal) => {
59
+ const error = new Error(`CodeGraph 子进程退出: code=${code} signal=${signal}`);
60
+ this.#failAll(error);
61
+ this.child = null;
62
+ });
63
+
64
+ await new Promise((resolve, reject) => {
65
+ const timer = setTimeout(() => reject(new Error(`无法启动 CodeGraph 命令: ${command}`)), 5000);
66
+ child.once('spawn', () => { clearTimeout(timer); resolve(); });
67
+ child.once('error', (error) => { clearTimeout(timer); reject(error); });
68
+ });
69
+
70
+ const initialize = await this.request('initialize', {
71
+ protocolVersion: '2025-11-25',
72
+ capabilities: { roots: { listChanged: false } },
73
+ clientInfo: { name: 'codex-workspace-codegraph-mcp', version: '0.2.0' },
74
+ }, 20_000);
75
+ this.notify('notifications/initialized', {});
76
+ if (!initialize?.serverInfo && !this.config.quiet) console.error('[codegraph] 初始化响应未包含 serverInfo');
77
+ await this.refreshTools();
78
+ return true;
79
+ }
80
+
81
+ #onStdout(chunk) {
82
+ this.buffer += chunk;
83
+ while (true) {
84
+ const newline = this.buffer.indexOf('\n');
85
+ if (newline < 0) break;
86
+ const line = this.buffer.slice(0, newline).trim();
87
+ this.buffer = this.buffer.slice(newline + 1);
88
+ if (!line) continue;
89
+ let message;
90
+ try { message = JSON.parse(line); }
91
+ catch (error) {
92
+ this.lastError = `CodeGraph 输出无效 JSON: ${error.message}`;
93
+ continue;
94
+ }
95
+ this.#onMessage(message);
96
+ }
97
+ }
98
+
99
+ #onMessage(message) {
100
+ if (message.id !== undefined && (message.result !== undefined || message.error !== undefined)) {
101
+ const pending = this.pending.get(String(message.id));
102
+ if (!pending) return;
103
+ this.pending.delete(String(message.id));
104
+ clearTimeout(pending.timer);
105
+ if (message.error) pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
106
+ else pending.resolve(message.result);
107
+ return;
108
+ }
109
+ if (message.id !== undefined && message.method) {
110
+ this.#handleChildRequest(message).catch((error) => {
111
+ this.#send({ jsonrpc: '2.0', id: message.id, error: { code: -32603, message: error.message } });
112
+ });
113
+ }
114
+ }
115
+
116
+ async #handleChildRequest(message) {
117
+ if (message.method === 'roots/list') {
118
+ return this.#send({
119
+ jsonrpc: '2.0', id: message.id,
120
+ result: { roots: [{ uri: pathToFileURL(this.workspace.root).href, name: path.basename(this.workspace.root) }] },
121
+ });
122
+ }
123
+ if (message.method === 'ping') return this.#send({ jsonrpc: '2.0', id: message.id, result: {} });
124
+ if (message.method === 'elicitation/create') {
125
+ const result = await this.feedback.requestFeedback({
126
+ summary: message.params?.message || 'CodeGraph 请求用户输入',
127
+ details: JSON.stringify(message.params || {}, null, 2),
128
+ });
129
+ return this.#send({ jsonrpc: '2.0', id: message.id, result: { action: 'accept', content: { feedback: result.feedback } } });
130
+ }
131
+ this.#send({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `未支持的 CodeGraph 客户端请求: ${message.method}` } });
132
+ }
133
+
134
+ #send(message) {
135
+ if (!this.child?.stdin?.writable) throw new Error('CodeGraph 子进程不可写');
136
+ this.child.stdin.write(`${JSON.stringify(message)}\n`);
137
+ }
138
+
139
+ notify(method, params) {
140
+ this.#send({ jsonrpc: '2.0', method, params });
141
+ }
142
+
143
+ request(method, params = {}, timeoutMs = 30_000) {
144
+ return new Promise((resolve, reject) => {
145
+ const id = this.nextId++;
146
+ const timer = setTimeout(() => {
147
+ this.pending.delete(String(id));
148
+ reject(new Error(`CodeGraph RPC 超时: ${method}`));
149
+ }, timeoutMs);
150
+ timer.unref();
151
+ this.pending.set(String(id), { resolve, reject, timer });
152
+ try { this.#send({ jsonrpc: '2.0', id, method, params }); }
153
+ catch (error) {
154
+ clearTimeout(timer);
155
+ this.pending.delete(String(id));
156
+ reject(error);
157
+ }
158
+ });
159
+ }
160
+
161
+ async refreshTools() {
162
+ try {
163
+ if (!this.child) await this.start();
164
+ const result = await this.request('tools/list', {}, 30_000);
165
+ this.tools = Array.isArray(result?.tools) ? result.tools : [];
166
+ return this.tools;
167
+ } catch (error) {
168
+ this.lastError = error.message;
169
+ this.tools = [];
170
+ return [];
171
+ }
172
+ }
173
+
174
+ async listTools() {
175
+ if (!this.config.codegraphEnabled) return [];
176
+ if (!this.child) await this.start().catch((error) => { this.lastError = error.message; });
177
+ if (!this.tools.length && this.child) await this.refreshTools();
178
+ return this.tools;
179
+ }
180
+
181
+ async callTool(name, args) {
182
+ if (!this.child) await this.start();
183
+ return this.request('tools/call', { name, arguments: args || {} }, 10 * 60 * 1000);
184
+ }
185
+
186
+ async runCli(args, options = {}) {
187
+ const command = await this.resolveCommand();
188
+ const cwd = options.cwd ? await this.workspace.resolve(options.cwd) : this.workspace.root;
189
+ const result = await runProcess(command, args, {
190
+ cwd,
191
+ env: { ...process.env, CODEGRAPH_MCP_TOOLS: this.config.codegraphTools },
192
+ timeoutMs: options.timeoutMs || 10 * 60 * 1000,
193
+ maxOutputBytes: this.config.maxOutputBytes,
194
+ });
195
+ return {
196
+ command: [command, ...args],
197
+ cwd: this.workspace.relative(cwd),
198
+ exit_code: result.code,
199
+ timed_out: result.timedOut,
200
+ truncated: result.truncated,
201
+ stdout: result.stdout,
202
+ stderr: result.stderr,
203
+ };
204
+ }
205
+
206
+ status() {
207
+ return {
208
+ enabled: this.config.codegraphEnabled,
209
+ running: Boolean(this.child && !this.child.killed),
210
+ tools: this.tools.map((tool) => tool.name),
211
+ last_error: this.lastError,
212
+ command: this.config.codegraphCommand,
213
+ args: this.config.codegraphArgs,
214
+ };
215
+ }
216
+
217
+ #failAll(error) {
218
+ this.lastError = error.message;
219
+ for (const pending of this.pending.values()) {
220
+ clearTimeout(pending.timer);
221
+ pending.reject(error);
222
+ }
223
+ this.pending.clear();
224
+ }
225
+
226
+ async close() {
227
+ if (!this.child) return;
228
+ const child = this.child;
229
+ this.child = null;
230
+ child.kill('SIGTERM');
231
+ await new Promise((resolve) => {
232
+ const timer = setTimeout(resolve, 2000);
233
+ child.once('close', () => { clearTimeout(timer); resolve(); });
234
+ });
235
+ }
236
+ }
package/src/config.js ADDED
@@ -0,0 +1,35 @@
1
+ import path from 'node:path';
2
+ import { asBoolean, clampInteger, parseArgs, randomToken } from './utils.js';
3
+
4
+ export function loadConfig(argv = []) {
5
+ const args = parseArgs(argv);
6
+ const workspace = path.resolve(String(args.workspace || process.env.CWMCP_WORKSPACE || process.cwd()));
7
+ const transport = String(args.transport || process.env.CWMCP_TRANSPORT || 'stdio').toLowerCase();
8
+ if (!['stdio', 'http'].includes(transport)) {
9
+ throw new Error(`不支持的 transport: ${transport}`);
10
+ }
11
+
12
+ const accessToken = String(args.token || process.env.CWMCP_ACCESS_TOKEN || randomToken());
13
+ return {
14
+ workspace,
15
+ transport,
16
+ host: String(args.host || process.env.CWMCP_HOST || '127.0.0.1'),
17
+ mcpPort: clampInteger(args['mcp-port'] || process.env.CWMCP_MCP_PORT, 1, 65535, 8765),
18
+ webHost: String(args['web-host'] || process.env.CWMCP_WEB_HOST || '127.0.0.1'),
19
+ webPort: clampInteger(args['web-port'] || process.env.CWMCP_WEB_PORT, 1, 65535, 8766),
20
+ accessToken,
21
+ allowedOrigins: String(args['allowed-origins'] || process.env.CWMCP_ALLOWED_ORIGINS || '')
22
+ .split(',').map((value) => value.trim()).filter(Boolean),
23
+ quiet: asBoolean(args.quiet || process.env.CWMCP_QUIET, false),
24
+ openBrowser: !asBoolean(args['no-browser'] || process.env.CWMCP_NO_BROWSER, false),
25
+ sandboxMode: String(args.sandbox || process.env.CWMCP_SANDBOX || 'workspace-write'),
26
+ approvalPolicy: String(args['approval-policy'] || process.env.CWMCP_APPROVAL_POLICY || 'on-request'),
27
+ commandTimeoutMs: clampInteger(process.env.CWMCP_COMMAND_TIMEOUT_MS, 1000, 3_600_000, 120_000),
28
+ maxOutputBytes: clampInteger(process.env.CWMCP_MAX_OUTPUT_BYTES, 32_768, 8_388_608, 1_048_576),
29
+ maxReadBytes: clampInteger(process.env.CWMCP_MAX_READ_BYTES, 4096, 16_777_216, 2_097_152),
30
+ codegraphEnabled: !asBoolean(args['disable-codegraph'] || process.env.CWMCP_DISABLE_CODEGRAPH, false),
31
+ codegraphCommand: String(process.env.CWMCP_CODEGRAPH_COMMAND || 'codegraph'),
32
+ codegraphArgs: ['serve', '--mcp'],
33
+ codegraphTools: String(process.env.CODEGRAPH_MCP_TOOLS || 'explore,node,search,callers,callees,impact,files,status'),
34
+ };
35
+ }
package/src/exec.js ADDED
@@ -0,0 +1,119 @@
1
+ import path from 'node:path';
2
+ import { runProcess } from './process.js';
3
+ import { clampInteger, redactEnvironment } from './utils.js';
4
+
5
+ const HIGH_RISK_PATTERNS = [
6
+ /(^|\s)rm\s+-[^\n]*r[^\n]*f/i,
7
+ /(^|\s)(sudo\s+)?(shutdown|reboot|halt|poweroff|mkfs|fdisk|parted)\b/i,
8
+ /(^|\s)dd\s+[^\n]*\bof=/i,
9
+ /git\s+(reset\s+--hard|clean\s+-|push\s+[^\n]*--force|checkout\s+--)/i,
10
+ /(^|\s)(npm|pnpm|yarn)\s+publish\b/i,
11
+ /(^|\s)(curl|wget)[^\n|]*\|\s*(sh|bash|zsh|pwsh|powershell)\b/i,
12
+ /(^|\s)(chmod|chown)\s+-R\b/i,
13
+ /(^|\s)(del|rmdir)\s+\/s\b/i,
14
+ /Remove-Item\b[^\n]*-Recurse[^\n]*-Force/i,
15
+ ];
16
+
17
+ const READ_ONLY_COMMANDS = new Set(['rg', 'grep', 'find', 'ls', 'dir', 'cat', 'head', 'tail', 'pwd', 'which', 'where', 'git']);
18
+
19
+ function displayCommand(command, shell) {
20
+ if (Array.isArray(command)) return command.map((value) => JSON.stringify(String(value))).join(' ');
21
+ return String(command || '');
22
+ }
23
+
24
+ function assessRisk(command, shell) {
25
+ const display = displayCommand(command, shell);
26
+ if (HIGH_RISK_PATTERNS.some((pattern) => pattern.test(display))) {
27
+ return { level: 'high', reason: '命令包含破坏性、系统级或不可逆操作模式' };
28
+ }
29
+ const executable = Array.isArray(command) ? path.basename(String(command[0] || '')).toLowerCase() : '';
30
+ if (executable === 'git') {
31
+ const subcommand = String(command[1] || '').toLowerCase();
32
+ if (['status', 'diff', 'log', 'show', 'rev-parse', 'branch'].includes(subcommand)) return { level: 'low', reason: '只读 Git 命令' };
33
+ return { level: 'medium', reason: 'Git 命令可能修改仓库状态' };
34
+ }
35
+ if (!shell && READ_ONLY_COMMANDS.has(executable)) return { level: 'low', reason: '常见只读命令' };
36
+ if (shell) return { level: 'medium', reason: '通过 shell 执行,可能包含组合副作用' };
37
+ return { level: 'medium', reason: '通用进程可能修改工作区或访问外部资源' };
38
+ }
39
+
40
+ export class ExecService {
41
+ constructor(config, workspace, feedback) {
42
+ this.config = config;
43
+ this.workspace = workspace;
44
+ this.feedback = feedback;
45
+ }
46
+
47
+ async execute(input = {}) {
48
+ const shell = input.shell === true || typeof input.command === 'string';
49
+ const command = input.command;
50
+ if (!(typeof command === 'string' && command.trim()) && !(Array.isArray(command) && command.length > 0)) {
51
+ throw new Error('command 必须是非空字符串或 argv 数组');
52
+ }
53
+ const cwd = await this.workspace.resolve(input.cwd || '.');
54
+ const risk = assessRisk(command, shell);
55
+
56
+ if (this.config.sandboxMode === 'read-only' && risk.level !== 'low') {
57
+ throw new Error('当前 sandbox=read-only,拒绝可能修改状态的命令');
58
+ }
59
+
60
+ const needsApproval = this.config.approvalPolicy === 'always'
61
+ || (this.config.approvalPolicy === 'on-request' && risk.level === 'high')
62
+ || (this.config.approvalPolicy === 'untrusted' && risk.level !== 'low');
63
+ if (needsApproval) {
64
+ const approval = await this.feedback.requestApproval({
65
+ summary: `是否批准执行命令:${displayCommand(command, shell)}`,
66
+ details: `风险级别:${risk.level}\n判断原因:${risk.reason}\n执行目录:${this.workspace.relative(cwd)}`,
67
+ cwd: this.workspace.relative(cwd),
68
+ });
69
+ if (approval.decision !== 'approve') throw new Error(`用户未批准命令执行:${approval.feedback || approval.status}`);
70
+ } else if (this.config.approvalPolicy === 'never' && risk.level === 'high') {
71
+ throw new Error(`approval-policy=never,拒绝高风险命令:${risk.reason}`);
72
+ }
73
+
74
+ const timeoutMs = clampInteger(input.timeout_ms, 1000, 3_600_000, this.config.commandTimeoutMs);
75
+ const environment = { ...process.env };
76
+ if (input.env && typeof input.env === 'object' && !Array.isArray(input.env)) {
77
+ for (const [key, value] of Object.entries(input.env)) {
78
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`环境变量名无效: ${key}`);
79
+ environment[key] = String(value);
80
+ }
81
+ }
82
+
83
+ let executable;
84
+ let args;
85
+ if (Array.isArray(command)) {
86
+ executable = String(command[0]);
87
+ args = command.slice(1).map(String);
88
+ } else if (process.platform === 'win32') {
89
+ executable = process.env.ComSpec || 'cmd.exe';
90
+ args = ['/d', '/s', '/c', command];
91
+ } else {
92
+ executable = process.env.SHELL || '/bin/sh';
93
+ args = ['-lc', command];
94
+ }
95
+
96
+ const startedAt = Date.now();
97
+ const result = await runProcess(executable, args, {
98
+ cwd,
99
+ env: environment,
100
+ timeoutMs,
101
+ maxOutputBytes: this.config.maxOutputBytes,
102
+ input: input.stdin === undefined ? undefined : String(input.stdin),
103
+ });
104
+ return {
105
+ command: displayCommand(command, shell),
106
+ argv: [executable, ...args],
107
+ cwd: this.workspace.relative(cwd),
108
+ risk,
109
+ exit_code: result.code,
110
+ signal: result.signal,
111
+ timed_out: result.timedOut,
112
+ truncated: result.truncated,
113
+ duration_ms: Date.now() - startedAt,
114
+ stdout: result.stdout,
115
+ stderr: result.stderr,
116
+ environment_overrides: redactEnvironment(input.env || {}),
117
+ };
118
+ }
119
+ }