Compare commits

..

10 Commits

Author SHA1 Message Date
claude-code-best
8246ffa392 fix: prevent null output and string render crashes in MessagesBoundary
UserToolSuccessMessage now requires parsedOutput.success before trusting
data, and guards toolResult against non-object values before calling
renderToolResultMessage. String renderedMessage is wrapped in <Text> so
multi-line tool reports (e.g. GoalTool usage report) don't crash Ink.

Defense in depth added to VaultHttpFetchTool/UI (matches the existing
pattern in LocalMemoryRecallTool and GoalTool) and to the
mapToolResultToToolResultBlockParam of both vault/memory tools.

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
2026-06-26 05:47:52 +08:00
claude-code-best
0753dafccc Merge pull request #1284 from wsolarq11/fix/messages-boundary-null-error
fix: prevent null reference reading 'error' in MessagesBoundary
2026-06-25 17:18:00 +08:00
claude-code-best
10327e0fab Merge pull request #1285 from Ericcc-Ma/fix/acp-posix-paths
fix(acp): use POSIX path semantics for ACP wire format paths
2026-06-25 17:17:40 +08:00
hongye
106fd5043e fix(acp): use POSIX path semantics for ACP wire format paths
ToolCallLocation.path and Diff.path emitted over ACP must be absolute
and POSIX-style per the v1 spec (tool-calls.mdx:304-306). The previous
code used platform-specific `node:path` which on Windows prepends the
drive letter (e.g. "D:\...") to POSIX-style inputs like
"/Users/test/project" — silently corrupting paths emitted to ACP
clients on Windows hosts. CI runs on Linux so the latent issue went
undetected. Switch to `node:path/posix` so the path normalisation is
host-OS independent.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 15:29:26 +08:00
wsolarq11
c0945164d3 fix: remove semicolons for biome compliance
Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-06-25 14:51:14 +08:00
wsolarq11
10855cf735 merge upstream/main 2026-06-25 14:50:41 +08:00
claude-code-best
751efb827c chore: 更新 bun i 2026-06-25 14:06:58 +08:00
wsolarq11
2c801502dc fix: prevent null reference reading 'error' in MessagesBoundary
normalizeMessages and toSDKMessages access message.error in flatMap
callbacks executed during React render (via useMemo). When a null
element is present in the messages array at runtime (e.g., from
corrupted JSONL deserialization or streaming race conditions),
null.error throws "Cannot read properties of null (reading 'error')".

Add null guards to flatMap/forEach callbacks and optional chaining
on message.error access points in normalizeMessages and
toSDKMessages.

Fixes: React Rendering Error in MessagesBoundary

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-06-24 13:06:40 +08:00
xiaoFjun-eng
d3121f0dfb Fix type (#1274)
* 完善所有用到的type对象,并添加中文注释

* 补充遗失的type

* 修改pipe模式卡死的问题,增加trycatch捕获错误信息。

* 修改为英文报错信息。
2026-06-23 19:47:09 +08:00
claude-code-best
8f6d4f88dd chore(workflow-engine): 封包发布到 npm
- 移除 private,补全 exports/types/files/publishConfig/license/repository 等
- 添加 LICENSE (MIT) 与 README
- 添加 scripts/build.ts + tsconfig.build.json,用 tsc emit 输出 dist/**/*.js + .d.ts
  (Bun bundle + external zod 会丢失 createWorkflowTool/workflowInputSchema/persistInlineScript 符号,改用 tsc emit)
- 修 src/index.ts 的 WORKFLOW_TOOL_NAME 重复 export;tool/* 的 named re-export 改为 import + 再 export

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
2026-06-22 20:09:24 +08:00
20 changed files with 319 additions and 39 deletions

View File

@@ -352,6 +352,7 @@
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/sdk": "^0.81.0", "@anthropic-ai/sdk": "^0.81.0",
"bun-types": "latest",
}, },
}, },
}, },
@@ -1644,7 +1645,7 @@
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bun-types": ["bun-types@1.3.12", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.12.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bundle-name": ["bundle-name@4.1.0", "https://registry.npmmirror.com/bundle-name/-/bundle-name-4.1.0.tgz", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bundle-name": ["bundle-name@4.1.0", "https://registry.npmmirror.com/bundle-name/-/bundle-name-4.1.0.tgz", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -3338,6 +3339,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@types/bun/bun-types": ["bun-types@1.3.12", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.12.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"aggregate-error/indent-string": ["indent-string@4.0.0", "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], "aggregate-error/indent-string": ["indent-string@4.0.0", "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-code-best", "name": "claude-code-best",
"version": "2.8.1", "version": "2.8.2",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal", "description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module", "type": "module",
"author": "claude-code-best <claude-code-best@proton.me>", "author": "claude-code-best <claude-code-best@proton.me>",

View File

@@ -137,7 +137,10 @@ export const GoalTool = buildTool({
return `Updating goal: ${input.status}${input.reason ? `${input.reason}` : ''}` return `Updating goal: ${input.status}${input.reason ? `${input.reason}` : ''}`
}, },
renderToolResultMessage(output: Output) { renderToolResultMessage(output: Output) {
if (output.error) return `Goal error: ${output.error}` if (!output) {
return null
}
if (output?.error) return `Goal error: ${output.error}`
if (output.report) return output.report if (output.report) return output.report
if (output.goal) { if (output.goal) {
return `Goal "${output.goal.objective}" — ${output.goal.status}` return `Goal "${output.goal.objective}" — ${output.goal.status}`

View File

@@ -547,7 +547,7 @@ export const LocalMemoryRecallTool = buildTool({
type: 'tool_result', type: 'tool_result',
tool_use_id: toolUseID, tool_use_id: toolUseID,
content: jsonStringify(output), content: jsonStringify(output),
is_error: output.error !== undefined, is_error: output?.error !== undefined,
} }
}, },
} satisfies ToolDef<InputSchema, Output>) } satisfies ToolDef<InputSchema, Output>)

View File

@@ -33,6 +33,9 @@ export function renderToolResultMessage(
_progressMessagesForMessage: ProgressMessage<ToolProgressData>[], _progressMessagesForMessage: ProgressMessage<ToolProgressData>[],
{ verbose }: { verbose: boolean }, { verbose }: { verbose: boolean },
): React.ReactNode { ): React.ReactNode {
// Defense in depth: framework validates via outputSchema, but resumed
// transcripts can still produce null here via deserialization edge cases.
if (!output) return null;
if (output.error) { if (output.error) {
return ( return (
<MessageResponse height={1}> <MessageResponse height={1}>

View File

@@ -409,7 +409,7 @@ export const VaultHttpFetchTool = buildTool({
type: 'tool_result', type: 'tool_result',
tool_use_id: toolUseID, tool_use_id: toolUseID,
content: jsonStringify(output), content: jsonStringify(output),
is_error: output.error !== undefined, is_error: output?.error !== undefined,
} }
}, },
} satisfies ToolDef<InputSchema, Output>) } satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 claude-code-best
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,71 @@
# @claude-code-best/workflow-engine
Deterministic JS script orchestration engine for multi-agent workflows. The core layer has zero runtime dependencies and talks to the outside world exclusively through **port adapters** — you bring your own agent backend, journal store, and progress sink.
## Why
When you orchestrate multiple LLM agents, you want the orchestration itself to be **deterministic, replayable, and testable**. This engine runs a plain JS script (compiled by Bun's transpiler) with primitives like `agent()`, `phase()`, `parallel()` and `pipeline()`. The non-deterministic parts (the LLM, the file system, the clock) are isolated behind ports, so the same script produces the same journal on every replay.
## Installation
```bash
bun add @claude-code-best/workflow-engine
# or
npm install @claude-code-best/workflow-engine
```
Runtime peer requirements: `ajv` and `zod` are pulled in automatically as dependencies.
## Minimal example
```ts
import {
createFileJournalStore,
createHostHandle,
runWorkflow,
type WorkflowPorts,
} from '@claude-code-best/workflow-engine'
const script = `
export const meta = { name: 'hello', description: 'minimal demo' }
phase('Greet')
const reply = await agent({ prompt: 'Say hi in one short sentence.' })
emit('result', { reply })
`
const ports: WorkflowPorts = {
// Provide your own agent runner + journal + progress emitter.
// See examples/smoke.ts for a complete Anthropic SDK wiring.
} as WorkflowPorts
const handle = createHostHandle()
await runWorkflow({
script,
ports,
workflowDir: '.wfe/runs/hello',
hostHandle: handle,
})
```
For a fully wired end-to-end example with the Anthropic SDK, see [`examples/smoke.ts`](./examples/smoke.ts).
## Core primitives
- `agent(params)` — call the configured AgentRunner; supports structured-output via JSON Schema.
- `phase(name)` — declare a logical phase (display + progress grouping).
- `parallel([...])` — barrier-style fan-out with bounded concurrency.
- `pipeline(stream, fn)` — streaming pipeline with per-item hooks.
- `emit(type, payload)` — emit a progress event to the host.
- `log.*` / hooks / budgets — see the TypeScript definitions for the full surface.
## Building from source
```bash
bun install # from the repo root
bun run build # outputs dist/index.js + dist/**/*.d.ts
bun test # 178 tests
```
## License
MIT © claude-code-best

View File

@@ -1,19 +1,69 @@
{ {
"name": "@claude-code-best/workflow-engine", "name": "@claude-code-best/workflow-engine",
"version": "0.1.0", "version": "0.1.0",
"private": true, "description": "Deterministic JS script orchestration engine for multi-agent workflows. Zero core-layer runtime dependencies; talks to the world via port adapters.",
"type": "module", "type": "module",
"main": "./src/index.ts", "license": "MIT",
"types": "./src/index.ts", "author": "claude-code-best <claude-code-best@proton.me>",
"homepage": "https://github.com/claude-code-best/claude-code/tree/main/packages/workflow-engine#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/claude-code-best/claude-code.git",
"directory": "packages/workflow-engine"
},
"bugs": {
"url": "https://github.com/claude-code-best/claude-code/issues"
},
"keywords": [
"workflow",
"orchestration",
"multi-agent",
"claude",
"automation",
"scripting",
"deterministic"
],
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": { "exports": {
".": "./src/index.ts", ".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"files": [
"dist",
"src",
"!src/**/__tests__",
"!src/**/*.test.ts",
"scripts/build.ts",
"tsconfig.json",
"tsconfig.build.json",
"README.md",
"LICENSE"
],
"sideEffects": false,
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public"
},
"dependencies": { "dependencies": {
"ajv": "^8.18.0", "ajv": "^8.18.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/sdk": "^0.81.0" "@anthropic-ai/sdk": "^0.81.0",
"bun-types": "latest"
},
"scripts": {
"build": "bun run scripts/build.ts",
"typecheck": "tsc --noEmit",
"test": "bun test",
"prepublishOnly": "bun run test && bun run build"
} }
} }

View File

@@ -0,0 +1,21 @@
import { mkdir, rm } from 'node:fs/promises'
const ROOT = new URL('../', import.meta.url)
const DIST = new URL('../dist/', import.meta.url)
await rm(DIST, { recursive: true, force: true })
await mkdir(DIST, { recursive: true })
// Emit dist/**/*.js + dist/**/*.d.ts (+ maps) via tsc.
const proc = Bun.spawn(['bunx', 'tsc', '-p', 'tsconfig.build.json'], {
cwd: ROOT.pathname,
stdout: 'inherit',
stderr: 'inherit',
})
const exitCode = await proc.exited
if (exitCode !== 0) {
console.error('tsc emit failed')
process.exit(exitCode)
}
console.log('✓ build complete')

View File

@@ -16,10 +16,16 @@ export * from './engine/context.js'
export * from './engine/hooks.js' export * from './engine/hooks.js'
export * from './engine/runWorkflow.js' export * from './engine/runWorkflow.js'
export * from './progress/events.js' export * from './progress/events.js'
export { import {
createWorkflowTool, createWorkflowTool,
type WorkflowToolDescriptor, type WorkflowToolDescriptor,
} from './tool/WorkflowTool.js' } from './tool/WorkflowTool.js'
export { workflowInputSchema, type WorkflowInput } from './tool/schema.js' import { workflowInputSchema, type WorkflowInput } from './tool/schema.js'
export { persistInlineScript } from './tool/persistInline.js' import { persistInlineScript } from './tool/persistInline.js'
export { WORKFLOW_TOOL_NAME } from './tool/constants.js' export {
createWorkflowTool,
type WorkflowToolDescriptor,
workflowInputSchema,
type WorkflowInput,
persistInlineScript,
}

View File

@@ -0,0 +1,21 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"declarationMap": false,
"sourceMap": false,
"outDir": "dist",
"rootDir": "src",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ESNext"
},
"include": ["src/**/*"],
"exclude": [
"node_modules",
"src/**/__tests__/**",
"examples/**",
"scripts/**"
]
}

View File

@@ -67,7 +67,14 @@ export function UserToolSuccessMessage({
if (parsedOutput && !parsedOutput.success) { if (parsedOutput && !parsedOutput.success) {
return null; return null;
} }
const toolResult = parsedOutput?.data ?? message.toolUseResult; // Only trust schema-validated output. Fall back to raw toolUseResult only
// when it's a non-null object — schemas without outputSchema, or successful
// parses that yield null/undefined data, must not reach renderToolResultMessage
// (tool UIs access output.error / output.action on first line and crash).
const toolResult = parsedOutput?.success ? parsedOutput.data : message.toolUseResult;
if (!toolResult || typeof toolResult !== 'object') {
return null;
}
// Collapse diff display for old messages (verbose/ctrl+o overrides) // Collapse diff display for old messages (verbose/ctrl+o overrides)
const effectiveStyle = shouldCollapseDiffs && !verbose ? 'condensed' : style; const effectiveStyle = shouldCollapseDiffs && !verbose ? 'condensed' : style;
@@ -88,6 +95,11 @@ export function UserToolSuccessMessage({
return null; return null;
} }
// Ink requires text strings to be inside <Text>. Tools that return plain
// multi-line strings (e.g. GoalTool's usage report) crash without the wrap.
// React elements from UI.tsx files pass through unchanged.
const wrappedMessage = typeof renderedMessage === 'string' ? <Text>{renderedMessage}</Text> : renderedMessage;
// Tools that return '' from userFacingName opt out of tool chrome and // Tools that return '' from userFacingName opt out of tool chrome and
// render like plain assistant text. Skip the tool-result width constraint // render like plain assistant text. Skip the tool-result width constraint
// so MarkdownTable's SAFETY_MARGIN=4 (tuned for the assistant-text 2-col // so MarkdownTable's SAFETY_MARGIN=4 (tuned for the assistant-text 2-col
@@ -97,7 +109,7 @@ export function UserToolSuccessMessage({
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<Box flexDirection="column" width={rendersAsAssistantText ? undefined : width}> <Box flexDirection="column" width={rendersAsAssistantText ? undefined : width}>
{renderedMessage} {wrappedMessage}
{feature('BASH_CLASSIFIER') {feature('BASH_CLASSIFIER')
? classifierRule && ( ? classifierRule && (
<MessageResponse height={1}> <MessageResponse height={1}>

View File

@@ -1,5 +1,12 @@
// Pure path-normalisation helper used by toolInfo / toolResults / forwarding. // Pure path-normalisation helper used by toolInfo / toolResults / forwarding.
import { isAbsolute, resolve } from 'node:path' //
// POSIX semantics are used so that emitted paths are platform-independent:
// ACP v1 spec (tool-calls.mdx:304-306) requires ToolCallLocation.path /
// Diff.path to be absolute, and the wire format is POSIX-style regardless of
// the host OS. Using the platform-specific `node:path` here would prepend the
// Windows drive letter (e.g. "D:\...") to POSIX-style inputs like
// "/Users/test/project" — silently corrupting paths emitted to ACP clients.
import { isAbsolute, resolve } from 'node:path/posix'
/** /**
* Normalises an emitted file path against the session cwd so that * Normalises an emitted file path against the session cwd so that

View File

@@ -172,7 +172,9 @@ export function sanitizeTitle(text: string): string {
// ── Path display helpers ────────────────────────────────────────── // ── Path display helpers ──────────────────────────────────────────
import * as path from 'node:path' // POSIX semantics so paths are normalised consistently regardless of host OS.
// ACP paths are always POSIX-style (see bridge/paths.ts for the same rationale).
import * as path from 'node:path/posix'
/** /**
* Convert an absolute file path to a project-relative path for display. * Convert an absolute file path to a project-relative path for display.
@@ -186,7 +188,7 @@ export function toDisplayPath(filePath: string, cwd?: string): string {
resolvedFile.startsWith(resolvedCwd + path.sep) || resolvedFile.startsWith(resolvedCwd + path.sep) ||
resolvedFile === resolvedCwd resolvedFile === resolvedCwd
) { ) {
return path.relative(resolvedCwd, resolvedFile).replaceAll('\\', '/') return path.relative(resolvedCwd, resolvedFile)
} }
return filePath return filePath
} }

View File

@@ -94,10 +94,20 @@ export async function setup(
// (SessionStart in particular) can spawn and snapshot process.env. // (SessionStart in particular) can spawn and snapshot process.env.
if (feature('UDS_INBOX')) { if (feature('UDS_INBOX')) {
const m = await import('./utils/udsMessaging.js') const m = await import('./utils/udsMessaging.js')
await m.startUdsMessaging( try {
messagingSocketPath ?? m.getDefaultUdsSocketPath(), await m.startUdsMessaging(
{ isExplicit: messagingSocketPath !== undefined }, messagingSocketPath ?? m.getDefaultUdsSocketPath(),
) { isExplicit: messagingSocketPath !== undefined },
)
} catch (error) {
logError(error)
console.error(
chalk.red(
`Error: Failed to start messaging socket (UDS_INBOX): ${errorMessage(error)}`,
),
)
process.exit(1)
}
} }
} }

View File

@@ -21,6 +21,8 @@ import {
MAX_UDS_INBOX_BYTES, MAX_UDS_INBOX_BYTES,
MAX_UDS_FRAME_BYTES, MAX_UDS_FRAME_BYTES,
MAX_UDS_CLIENTS, MAX_UDS_CLIENTS,
MAX_UNIX_SOCKET_PATH_LENGTH,
assertValidUnixSocketPath,
formatUdsAddress, formatUdsAddress,
parseUdsTarget, parseUdsTarget,
sendUdsMessage, sendUdsMessage,
@@ -34,11 +36,23 @@ let previousConfigDir: string | undefined
let tempConfigDir = '' let tempConfigDir = ''
function socketPath(label: string): string { function socketPath(label: string): string {
const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}-${label}` const suffix = `${process.pid}-${Math.random().toString(16).slice(2)}-${label}`
if (process.platform === 'win32') { if (process.platform === 'win32') {
return `\\\\.\\pipe\\claude-code-test-${suffix}` return `\\\\.\\pipe\\claude-code-test-${suffix}`
} }
return join(tmpdir(), 'claude-code-test', `${suffix}.sock`) const base =
process.platform === 'darwin'
? '/tmp/claude-uds-test'
: join(tmpdir(), 'cc-uds-test')
return join(base, `${suffix}.sock`)
}
function shortTestDir(prefix: string): string {
const id = `${process.pid}-${Math.random().toString(16).slice(2)}`
if (process.platform === 'darwin') {
return join('/tmp', `${prefix}-${id}`)
}
return join(tmpdir(), `${prefix}-${id}`)
} }
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
@@ -499,6 +513,27 @@ describe('UDS inbox retention', () => {
expect(getDefaultUdsSocketPath()).not.toBe(firstPath) expect(getDefaultUdsSocketPath()).not.toBe(firstPath)
}) })
test('default socket path stays within AF_UNIX length limit', () => {
const path = getDefaultUdsSocketPath()
if (process.platform === 'win32') return
expect(Buffer.byteLength(path, 'utf8')).toBeLessThanOrEqual(
MAX_UNIX_SOCKET_PATH_LENGTH,
)
expect(() => assertValidUnixSocketPath(path)).not.toThrow()
})
test('rejects socket paths longer than AF_UNIX limit', () => {
if (process.platform === 'win32') return
const longPath = `/tmp/${'x'.repeat(MAX_UNIX_SOCKET_PATH_LENGTH)}.sock`
expect(() => assertValidUnixSocketPath(longPath)).toThrow(/max 104/)
})
test('default socket path can bind on Node.js', async () => {
const path = getDefaultUdsSocketPath()
await startUdsMessaging(path, { isExplicit: true })
await stopUdsMessaging()
})
test('rejects oversized receiver responses before retaining them', async () => { test('rejects oversized receiver responses before retaining them', async () => {
const path = socketPath('oversized-response') const path = socketPath('oversized-response')
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
@@ -688,10 +723,7 @@ describe('UDS inbox retention', () => {
}) })
test('fails closed when an explicit socket parent is not private', async () => { test('fails closed when an explicit socket parent is not private', async () => {
const parent = join( const parent = shortTestDir('uds-sp')
tmpdir(),
`uds-socket-parent-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
)
await mkdir(parent, { recursive: true, mode: 0o755 }) await mkdir(parent, { recursive: true, mode: 0o755 })
await chmod(parent, 0o755) await chmod(parent, 0o755)
@@ -707,10 +739,7 @@ describe('UDS inbox retention', () => {
}) })
test('fails closed when an explicit socket parent is a file', async () => { test('fails closed when an explicit socket parent is a file', async () => {
const parentFile = join( const parentFile = shortTestDir('uds-spf')
tmpdir(),
`uds-socket-parent-file-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
)
await writeFile(parentFile, 'not a directory', 'utf-8') await writeFile(parentFile, 'not a directory', 'utf-8')
try { try {

View File

@@ -753,6 +753,7 @@ export function normalizeMessages(messages: Message[]): NormalizedMessage[] {
// and remains true for all subsequent messages in the normalization process. // and remains true for all subsequent messages in the normalization process.
let isNewChain = false let isNewChain = false
return messages.flatMap(message => { return messages.flatMap(message => {
if (!message) return []
switch (message.type) { switch (message.type) {
case 'assistant': { case 'assistant': {
const aMsg = message as AssistantMessage const aMsg = message as AssistantMessage
@@ -776,7 +777,7 @@ export function normalizeMessages(messages: Message[]): NormalizedMessage[] {
isVirtual: message.isVirtual, isVirtual: message.isVirtual,
requestId: message.requestId, requestId: message.requestId,
uuid, uuid,
error: message.error, error: message?.error,
isApiErrorMessage: message.isApiErrorMessage, isApiErrorMessage: message.isApiErrorMessage,
advisorModel: message.advisorModel, advisorModel: message.advisorModel,
} as NormalizedAssistantMessage } as NormalizedAssistantMessage
@@ -2376,6 +2377,7 @@ export function normalizeMessagesForAPI(
}, },
) )
.forEach(message => { .forEach(message => {
if (!message) return []
switch (message.type) { switch (message.type) {
case 'system': { case 'system': {
// local_command system messages need to be included as user messages // local_command system messages need to be included as user messages

View File

@@ -29,6 +29,7 @@ export function toInternalMessages(
messages: readonly DeepImmutable<SDKMessage>[], messages: readonly DeepImmutable<SDKMessage>[],
): Message[] { ): Message[] {
return messages.flatMap(message => { return messages.flatMap(message => {
if (!message) return []
switch (message.type) { switch (message.type) {
case 'assistant': case 'assistant':
return [ return [
@@ -127,6 +128,7 @@ export function fromSDKCompactMetadata(
export function toSDKMessages(messages: Message[]): SDKMessage[] { export function toSDKMessages(messages: Message[]): SDKMessage[] {
return messages.flatMap((message): SDKMessage[] => { return messages.flatMap((message): SDKMessage[] => {
if (!message) return []
switch (message.type) { switch (message.type) {
case 'assistant': case 'assistant':
return [ return [
@@ -138,7 +140,7 @@ export function toSDKMessages(messages: Message[]): SDKMessage[] {
session_id: getSessionId(), session_id: getSessionId(),
parent_tool_use_id: null, parent_tool_use_id: null,
uuid: message.uuid, uuid: message.uuid,
error: message.error, error: message?.error,
}, },
] ]
case 'user': case 'user':

View File

@@ -85,13 +85,26 @@ export const MAX_UDS_CLIENTS = 128
export const UDS_AUTH_TIMEOUT_MS = 2_000 export const UDS_AUTH_TIMEOUT_MS = 2_000
export const UDS_IDLE_TIMEOUT_MS = 30_000 export const UDS_IDLE_TIMEOUT_MS = 30_000
/** macOS/BSD AF_UNIX `sun_path` limit (bytes, excluding NUL). */
export const MAX_UNIX_SOCKET_PATH_LENGTH = 104
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public API — socket path helpers // Public API — socket path helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function assertValidUnixSocketPath(path: string): void {
if (process.platform === 'win32') return
const byteLength = Buffer.byteLength(path, 'utf8')
if (byteLength > MAX_UNIX_SOCKET_PATH_LENGTH) {
throw new Error(
`[udsMessaging] socket path is ${byteLength} bytes (max ${MAX_UNIX_SOCKET_PATH_LENGTH}): ${path}`,
)
}
}
/** /**
* Default socket path based on PID, placed in a tmpdir subdirectory so it * Default socket path based on PID. Uses a flat file under a short temp
* survives across config-home changes and avoids polluting ~/.claude. * directory so the path stays within the AF_UNIX limit on macOS.
* *
* On Windows, Node.js requires named pipe paths in the `\\.\pipe\` namespace; * On Windows, Node.js requires named pipe paths in the `\\.\pipe\` namespace;
* file-system paths like `C:\...\Temp\x.sock` cause EACCES. Bun handles both * file-system paths like `C:\...\Temp\x.sock` cause EACCES. Bun handles both
@@ -99,17 +112,19 @@ export const UDS_IDLE_TIMEOUT_MS = 30_000
*/ */
export function getDefaultUdsSocketPath(): string { export function getDefaultUdsSocketPath(): string {
if (defaultSocketPath) return defaultSocketPath if (defaultSocketPath) return defaultSocketPath
const nonce = randomBytes(16).toString('hex') const nonce = randomBytes(8).toString('hex')
if (process.platform === 'win32') { if (process.platform === 'win32') {
defaultSocketPath = `\\\\.\\pipe\\claude-code-${process.pid}-${nonce}` defaultSocketPath = `\\\\.\\pipe\\claude-code-${process.pid}-${nonce}`
return defaultSocketPath return defaultSocketPath
} }
defaultSocketPath = join( defaultSocketPath = join(
tmpdir(), tmpdir(),
'claude-code-socks', 'cc-socks',
`${process.pid}-${nonce}`, `${process.pid}-${nonce}`,
'messaging.sock', 'messaging.sock',
) )
assertValidUnixSocketPath(defaultSocketPath)
return defaultSocketPath return defaultSocketPath
} }
@@ -416,6 +431,8 @@ export async function startUdsMessaging(
return return
} }
assertValidUnixSocketPath(path)
// Ensure parent directory exists (skip on Windows — pipe paths aren't files) // Ensure parent directory exists (skip on Windows — pipe paths aren't files)
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
await ensureSocketParent(path) await ensureSocketParent(path)