-
Notifications
You must be signed in to change notification settings - Fork 9.1k
feat: add build:cli-dev and emit-cli-dev.ts #2249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,3 +25,8 @@ plan/ | |
| .tmp | ||
| .tmp-* | ||
| temp_reference/ | ||
|
|
||
| # Free-Code Step-1 cli-dev (generated) | ||
| cli | ||
| cli-dev | ||
| cli-dev.stamp.json | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| #!/usr/bin/env bun | ||
| /** | ||
| * emit-cli-dev.ts — Write ./cli-dev thin Node launcher → bin/openclaude → dist/cli.mjs | ||
| * Free-Code Port Step-1 (OC-native path). NOT FC bun --compile. | ||
| */ | ||
| import { chmodSync, existsSync, writeFileSync } from 'fs' | ||
| import { resolve } from 'path' | ||
|
|
||
| const root = resolve(import.meta.dir, '..') | ||
| const dist = resolve(root, 'dist/cli.mjs') | ||
| const bin = resolve(root, 'bin/openclaude') | ||
| const out = resolve(root, 'cli-dev') | ||
| const stamp = resolve(root, 'cli-dev.stamp.json') | ||
|
|
||
| if (!existsSync(dist)) { | ||
| console.error('emit-cli-dev: missing dist/cli.mjs — run bun run scripts/build.ts first') | ||
| process.exit(1) | ||
| } | ||
| if (!existsSync(bin)) { | ||
| console.error('emit-cli-dev: missing bin/openclaude') | ||
| process.exit(1) | ||
| } | ||
|
|
||
| const wrapper = `#!/usr/bin/env node | ||
| // Generated by scripts/emit-cli-dev.ts — OpenClaude experimental cli-dev entry | ||
| import { spawn } from 'node:child_process' | ||
| import { fileURLToPath } from 'node:url' | ||
| import { dirname, join } from 'node:path' | ||
|
|
||
| const here = dirname(fileURLToPath(import.meta.url)) | ||
| const launcher = join(here, 'bin', 'openclaude') | ||
| const child = spawn(process.execPath, [launcher, ...process.argv.slice(2)], { | ||
| stdio: 'inherit', | ||
| env: { | ||
| ...process.env, | ||
| CLAUDE_CODE_EXPERIMENTAL_BUILD: process.env.CLAUDE_CODE_EXPERIMENTAL_BUILD ?? 'true', | ||
| }, | ||
| }) | ||
| child.on('exit', (code, signal) => { | ||
| if (signal) process.kill(process.pid, signal) | ||
| process.exit(code ?? 1) | ||
| }) | ||
| ` | ||
|
|
||
| writeFileSync(out, wrapper, 'utf8') | ||
| chmodSync(out, 0o755) | ||
| const meta = { | ||
| emitted_at: new Date().toISOString(), | ||
| mechanism: 'oc-native-preprocess', | ||
| points_to: 'bin/openclaude -> dist/cli.mjs', | ||
| experimental: true, | ||
| } | ||
| writeFileSync(stamp, JSON.stringify(meta, null, 2) + '\n') | ||
| console.log(`emit-cli-dev: wrote ${out}`) | ||
| console.log(`emit-cli-dev: stamp ${stamp}`) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,10 @@ import { getMainLoopModel } from '../utils/model/model.js' | |
| import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js' | ||
| import { setCwd } from '../utils/Shell.js' | ||
| import { jsonStringify } from '../utils/slowOperations.js' | ||
| import { | ||
| sanitizeMcpImageData, | ||
| truncateMcpToolText, | ||
| } from '../utils/mcpToolResultSanitize.js' | ||
| import { getErrorParts } from '../utils/toolErrors.js' | ||
| import { zodToJsonSchema } from '../utils/zodToJsonSchema.js' | ||
|
|
||
|
|
@@ -199,25 +203,32 @@ export async function startMCPServer( | |
| let content: CallToolResult['content'] | ||
| const data = finalResult.data as string | { type: string; text?: string; source?: { type: string; media_type: string; data: string } }[] | unknown | ||
|
|
||
| // Mill hosts (GB/Cursor) re-inject CallTool content into the next | ||
| // model request. Sanitize NUL/invalid UTF-8 and cap size so post-Bash | ||
| // follow-ups do not hit provider "token parsing" 500s. | ||
| if (typeof data === 'string') { | ||
| content = [{ type: 'text', text: data }] | ||
| content = [{ type: 'text', text: truncateMcpToolText(data) }] | ||
| } else if (Array.isArray(data)) { | ||
| content = data.flatMap((block: unknown) => { | ||
| // Boundary data — defensively skip primitives/null instead of crashing. | ||
| if (!block || typeof block !== 'object') return [] | ||
| const b = block as { type?: unknown; text?: unknown; source?: { type?: unknown; media_type?: unknown; data?: unknown } } | ||
| if (b.type === 'text') { | ||
| return [{ type: 'text', text: String(b.text ?? '') } as CallToolResult['content'][number]] | ||
| return [{ type: 'text', text: truncateMcpToolText(String(b.text ?? '')) } as CallToolResult['content'][number]] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Apply the output budget to the complete response. If a tool returns many text blocks, each block receives its own 🤖 Prompt for AI Agents |
||
| } | ||
| if (b.type === 'image' && b.source && typeof b.source.data === 'string' && typeof b.source.media_type === 'string') { | ||
| return [{ type: 'image', data: b.source.data, mimeType: b.source.media_type } as CallToolResult['content'][number]] | ||
| const imageData = sanitizeMcpImageData(b.source.data) | ||
| if (!imageData) { | ||
| return [{ type: 'text', text: '[MCP image omitted: invalid base64]' } as CallToolResult['content'][number]] | ||
| } | ||
| return [{ type: 'image', data: imageData, mimeType: b.source.media_type } as CallToolResult['content'][number]] | ||
| } | ||
| // eslint-disable-next-line custom-rules/no-top-level-side-effects, no-console | ||
| console.warn(`Unmapped content block type from tool ${name}: ${String(b.type ?? 'unknown')}`) | ||
| return [{ type: 'text', text: jsonStringify(block) } as CallToolResult['content'][number]] | ||
| return [{ type: 'text', text: truncateMcpToolText(jsonStringify(block)) } as CallToolResult['content'][number]] | ||
| }) as CallToolResult['content'] | ||
| } else { | ||
| content = [{ type: 'text', text: jsonStringify(data) }] | ||
| content = [{ type: 'text', text: truncateMcpToolText(jsonStringify(data)) }] | ||
| } | ||
|
|
||
| return { | ||
|
|
@@ -248,7 +259,7 @@ export async function startMCPServer( | |
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: errorText, | ||
| text: truncateMcpToolText(errorText), | ||
| }, | ||
| ], | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import { afterEach, describe, expect, it } from 'bun:test' | ||
| import { | ||
| sanitizeMcpImageData, | ||
| sanitizeMcpToolText, | ||
| truncateMcpToolText, | ||
| } from './mcpToolResultSanitize.js' | ||
|
|
||
| afterEach(() => { | ||
| delete process.env.BASH_MAX_OUTPUT_LENGTH | ||
| }) | ||
|
Comment on lines
+8
to
+10
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Restore the prior environment value. If 🤖 Prompt for AI AgentsSource: Path instructions |
||
|
|
||
| describe('sanitizeMcpToolText', () => { | ||
| it('strips NUL bytes', () => { | ||
| expect(sanitizeMcpToolText('ok\u0000more')).toBe('okmore') | ||
| }) | ||
|
|
||
| it('preserves normal UTF-8', () => { | ||
| expect(sanitizeMcpToolText('café 你好')).toBe('café 你好') | ||
| }) | ||
|
|
||
| it('round-trips lone surrogates into replacement chars', () => { | ||
| // Lone high surrogate — invalid UTF-8 when encoded | ||
| const lone = 'a\uD800b' | ||
| const out = sanitizeMcpToolText(lone) | ||
| expect(out.includes('\u0000')).toBe(false) | ||
| expect(out.startsWith('a')).toBe(true) | ||
| expect(out.endsWith('b')).toBe(true) | ||
| }) | ||
| }) | ||
|
|
||
| describe('truncateMcpToolText', () => { | ||
| it('leaves short clean text unchanged', () => { | ||
| expect(truncateMcpToolText('ok')).toBe('ok') | ||
| }) | ||
|
|
||
| it('truncates above BASH_MAX_OUTPUT_LENGTH and keeps marker', () => { | ||
| process.env.BASH_MAX_OUTPUT_LENGTH = '32' | ||
| const long = 'x'.repeat(100) | ||
| const out = truncateMcpToolText(long) | ||
| expect(out.startsWith('x'.repeat(32))).toBe(true) | ||
| expect(out).toContain('MCP tool result truncated at 32 chars') | ||
| expect(out.length).toBeLessThan(long.length + 200) | ||
| }) | ||
|
|
||
| it('sanitizes before truncating', () => { | ||
| process.env.BASH_MAX_OUTPUT_LENGTH = '8' | ||
| const out = truncateMcpToolText('ab\u0000cd\u0000efghijklmnop') | ||
| expect(out.startsWith('abcdefgh')).toBe(true) | ||
| expect(out.includes('\u0000')).toBe(false) | ||
| }) | ||
| }) | ||
|
|
||
| describe('sanitizeMcpImageData', () => { | ||
| it('accepts valid base64', () => { | ||
| const raw = Buffer.from('hi').toString('base64') | ||
| expect(sanitizeMcpImageData(raw)).toBe(raw) | ||
| }) | ||
|
|
||
| it('rejects NULs / non-base64', () => { | ||
| expect(sanitizeMcpImageData('abc\u0000def')).toBeNull() | ||
| expect(sanitizeMcpImageData('!!!not-b64!!!')).toBeNull() | ||
| expect(sanitizeMcpImageData('')).toBeNull() | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,56 @@ | ||||||
| import { | ||||||
| BASH_MAX_OUTPUT_UPPER_LIMIT, | ||||||
| getMaxOutputLength, | ||||||
| } from './shell/outputLimits.js' | ||||||
|
|
||||||
| /** | ||||||
| * Make MCP CallTool text safe for mill hosts (Grok Build / Cursor) that | ||||||
| * re-inject tool results into the next chat/completions request. | ||||||
| * | ||||||
| * Grok cli-chat-proxy has returned HTTP 500 "Internal error during token | ||||||
| * parsing" after Bash/tool loops when the follow-up body carried NULs or | ||||||
| * invalid UTF-8. Strip NULs and re-encode as well-formed UTF-8. | ||||||
| */ | ||||||
| export function sanitizeMcpToolText(text: string): string { | ||||||
| // JS strings can hold NULs and lone surrogates; both break some tokenizers. | ||||||
| const withoutNuls = text.replace(/\u0000/g, '') | ||||||
| return Buffer.from(withoutNuls, 'utf8').toString('utf8') | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Cap MCP CallTool text returned to mill hosts. Bash already soft-caps | ||||||
| * stdout via getMaxOutputLength(), but MCP serve bypasses REPL | ||||||
| * applyToolResultBudget and may jsonStringify object results with no | ||||||
| * host-side hard cap. Override with BASH_MAX_OUTPUT_LENGTH (default 30000, | ||||||
| * upper 150000). | ||||||
| */ | ||||||
| export function truncateMcpToolText(text: string): string { | ||||||
| const sanitized = sanitizeMcpToolText(text) | ||||||
| const max = getMaxOutputLength() | ||||||
| if (sanitized.length <= max) return sanitized | ||||||
| return ( | ||||||
| sanitized.slice(0, max) + | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Preserve well-formed text after truncation. If Proposed fix- sanitized.slice(0, max) +
+ sanitizeMcpToolText(sanitized.slice(0, max)) +📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| `\n\n... [MCP tool result truncated at ${max} chars; set BASH_MAX_OUTPUT_LENGTH (max ${BASH_MAX_OUTPUT_UPPER_LIMIT}) to raise] ...` | ||||||
| ) | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Validate/normalize image base64 from tool results. Invalid payloads are | ||||||
| * dropped (null) so they cannot poison the mill follow-up request. | ||||||
| */ | ||||||
| export function sanitizeMcpImageData(data: string): string | null { | ||||||
| // NULs in base64 are always corrupt — drop the image rather than strip | ||||||
| // and accidentally accept a spliced payload. | ||||||
| if (data.includes('\u0000')) return null | ||||||
| const cleaned = data.replace(/\s+/g, '') | ||||||
| if (!cleaned) return null | ||||||
| if (!/^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) return null | ||||||
| try { | ||||||
| const buf = Buffer.from(cleaned, 'base64') | ||||||
| if (buf.length === 0) return null | ||||||
| // Reject clearly truncated/corrupt padding by round-tripping | ||||||
| return buf.toString('base64') | ||||||
|
Comment on lines
+49
to
+52
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '36,60p' src/utils/mcpToolResultSanitize.tsRepository: Twigpine/openclaude Length of output: 899 🏁 Script executed: node - <<'JS'
const inputs = ['Zm9vA', 'Zm9v=', 'Zm9v==', 'AA=', 'Zg=', 'Zg==', 'Zm9v', '====', '='];
const pattern = /^[A-Za-z0-9+/]*={0,2}$/;
for (const input of inputs) {
const matches = pattern.test(input);
const buf = Buffer.from(input, 'base64');
console.log(JSON.stringify({
input,
matches,
decodedHex: buf.toString('hex'),
decodedLength: buf.length,
reencoded: buf.toString('base64')
}));
}
JSRepository: Twigpine/openclaude Length of output: 944 Reject base64 inputs with invalid length or padding. The pattern accepts inputs such as Compare the re-encoded buffer with the canonical form of 🤖 Prompt for AI Agents |
||||||
| } catch { | ||||||
| return null | ||||||
| } | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
git ls-files src/cli git check-ignore -v --no-index -- src/cli/new-file /cli rg -n '(^|[^[:alnum:]_-])cli([^[:alnum:]_-]|$)' .gitignore package.json scriptsRepository: Twigpine/openclaude
Length of output: 8056
🏁 Script executed:
Repository: Twigpine/openclaude
Length of output: 2698
Anchor the
cliignore rule to the repository root.The unanchored
clirule also ignores new files undersrc/cli. Git can therefore omit new source files from normalgit addoperations. Change the rule to/cliso the root artifact remains ignored without hiding source additions.Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents