diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8e3e8ea0..dd1656ef 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -274,6 +274,8 @@ core 只允许声明了 `host.ssh` capability 的 provider 使用 `host.ssh` dis ssh-like 远端命令如果出现 `kex_exchange_identification`、`Connection closed by remote host`、provider session timeout 或 exit code 255,CLI 会在原始 stderr 后追加一行 `UNIDESK_SSH_HINT { ... }`。该 JSON 不回显原始远端命令,只包含 `code=ssh-like-command-friction`、`trigger`、`try` 和 `triage`;`try` 固定指向 stdin script 形态,避免把一次 ssh-like 解析/握手摩擦误读成 D601 SSH 整体不可用。`ssh`/`trans`/`tran` 运行时硬超时会输出 `UNIDESK_SSH_RUNTIME_TIMEOUT { ... }` 或 wrapper 层 `UNIDESK_TRAN_TIMEOUT_HINT { ... }`;这不是远端业务失败,而是调用方需要改成短查询/轮询。`ssh`/`trans`/`tran` 只有在运行耗时超过默认 10000ms 时才会在 stderr 追加一行 `UNIDESK_SSH_TIMING { ... }`,且 `level=warning`;正常短调用不输出 timing 噪声。慢成功命令也必须保留该 warning,因为它是 provider session、远端命令成本、helper bootstrap 和 `trans`/`tran`/远端 patch 性能回归的重要监控信号。warning 包含 `elapsedMs`、`elapsedSeconds`、`transport`、`invocationKind` 和 `exitCode`,提示优先排查 provider/session 延迟、远端命令自身耗时、helper bootstrap 或工具层回归。阈值可用 `UNIDESK_SSH_SLOW_WARNING_MS=` 临时调节,提示同样不回显原始远端命令。 +非交互 `ssh`/`trans`/`tran` 远端命令的流式 stdout 默认有本地输出上限,避免远端日志、PowerShell JSON 或错误对象一次性输出过大导致上下文被淹没;交互登录 shell 不套该上限。超过上限时,CLI 只继续读取远端流并把完整内容写入 `/tmp/unidesk-cli-output/*.stdout.bin`,本地 stderr 追加 `UNIDESK_SSH_STDOUT_TRUNCATED { ... }`,其中包含 `thresholdBytes`、`observedBytesAtTruncation`、`dumpPath` 和 `dumpError`;stdout 本身只保留上限内的开头内容。默认上限是 256KiB,可用 `UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES=` 或 `UNIDESK_TRAN_STDOUT_STREAM_MAX_BYTES=` 临时调整,最小 4KiB,最大 16MiB。该机制只做渐进披露和完整 dump,不替代远端命令失败判断;看到该 hint 时应优先改成 `tail`、分页或更窄的结构化查询。 + `trans ` 透传只在当前 operation 需要 helper 时才注入 `/tmp/unidesk-ssh-tools`,普通 `argv`、`script`、`kubectl`、`logs` 和默认 `apply-patch` 等路径不得传输无关工具源码。`apply-patch-v1` 只注入 `apply_patch`;`glob` 只注入 `glob`;`skills`/`skill discover` 只注入 `skill-discover`。`apply_patch` 接受标准 `*** Begin Patch` / `*** End Patch` patch 格式,便于通过 SSH 透传编辑远端仓库文件;远端存在 `perl` 时必须走快速精确匹配路径,避免大文件 hunk 被 sh 模式匹配拖成几十秒,缺少 `perl` 时才退回 sh-only 实现。`glob` 和 `skill-discover` 需要远端 `python3`。注入工具只写 `/tmp/unidesk-ssh-tools`,不修改目标仓库。 远端文本 patch 默认使用 `apply-patch` 的 v2 引擎:它不把 hunk 解析交给远端 shell/perl helper,而是在本地按行序列匹配,支持长中文/Unicode 行、纯新增 hunk、低上下文插入和 `@@` 上下文定位,再把完整新内容写回远端。v2 的文件操作提交顺序按 Codex 标准 `apply_patch` 语义执行:空 patch 会失败;删除不存在的文件会失败;`Add File` 可覆盖已有文件;`Move to` 可覆盖目标文件;当大 patch 后续 hunk 不匹配时,已成功提交的前序文件操作会保留,并在错误详情中记录 `partialChanges`,调用方应基于当前文件内容继续补一个更小的 patch,而不是期待全量事务回滚。若 stderr 报 `failed to find expected lines` 且显示 partial context match,尤其是大块/函数替换,调用方必须先重读目标文件当前块,再用更少稳定上下文、`@@ ` 或多个小 hunk 重试;该失败不构成改用 `download`/`upload`、远端脚本整文件替换或 `apply-patch-v1` 的理由。`apply_patch` 旧 helper 默认拒绝低上下文 update hunk:空搜索/纯插入无锚点、只在插入点前有上下文而没有插入点后上下文、或同一 hunk search 在目标文件中匹配多个位置时,都会结构化失败并提示补充上下文。成功应用时每个 hunk 会在 stderr 输出 `apply_patch: hunk N matched path:line`,用于复核实际落点;只有人工确认确实需要旧 helper 行为或 `--allow-loose` 时,才显式调用 `apply-patch-v1 --allow-loose`。 diff --git a/docs/reference/windows-passthrough.md b/docs/reference/windows-passthrough.md index b7365bec..ed262ed8 100644 --- a/docs/reference/windows-passthrough.md +++ b/docs/reference/windows-passthrough.md @@ -28,6 +28,8 @@ trans :win skills --limit 20 `:win skills` 是 Windows 用户 skill 发现入口,默认读取当前 Windows 用户 `%USERPROFILE%\.agents\skills`,输出 JSON 中包含 `roots`、`counts` 和每个 skill 的 `name`、`path`、`skillFile`、`description`。需要同时读取 `%USERPROFILE%\.codex\skills` 时使用 `--scope all`;只看 Codex skill 时用 `--scope codex`。该入口用于替代手写 `cmd dir`、`powershell Get-ChildItem` 或宽泛扫描用户目录。 +`trans :win ps` 会把用户脚本写入 Windows 临时 `.ps1`,并在脚本前注入 UTF-8 与 JSON 安全 prelude。该 prelude 只在本次脚本作用域内覆盖 `ConvertTo-Json`,先把常见 PowerShell ETS 扩展属性剥离为普通 JSON 值,再调用 `Microsoft.PowerShell.Utility\ConvertTo-Json` 输出;这样 `Get-Content -Tail` 得到的字符串放入 `[pscustomobject]` 后不会把 `PSPath`、`PSDrive`、`PSProvider` 和 provider 类型对象图一起序列化。若确实需要原始 PowerShell cmdlet 行为,可在脚本中显式调用 `Microsoft.PowerShell.Utility\ConvertTo-Json`。 + ## Windows Long-Lived Process Detach `trans :win cmd ...` 和 `trans script -- powershell.exe ...` 适合短命令、只读探测和有界 skill 调用,不适合直接启动 Windows 长驻进程。Windows `cmd start`、`cmd /c ... &`、PowerShell `Start-Process -PassThru` 或带 stdout/stderr 重定向的子进程,仍可能被 provider-gateway/SSH broker 按子进程树或继承句柄等待;结果是远端进程其实已启动,但 `trans` 会持续占用 provider session,后续 D601/G14 高频调用被 provider session lock 串行排队。 diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index 97232314..07bcd812 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -8,6 +8,7 @@ import { summarizeMicroserviceHealthResponse, summarizeMicroserviceObservation, import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf"; import { buildWindowsPowerShellInvocation, + createSshStdoutForwarder, formatSshFailureHint, formatSshRuntimeTimeoutHint, formatSshRuntimeTimingHint, @@ -985,6 +986,10 @@ async function runRemoteSshWebSocket( return await new Promise((resolve) => { const rawMode = parsed.remoteCommand === null && process.stdin.isTTY && typeof process.stdin.setRawMode === "function"; + const stdoutForwarder = parsed.remoteCommand === null ? null : createSshStdoutForwarder({ + invocation, + transport: "frontend-websocket", + }); let timedOut = false; const openTimer = setTimeout(() => { if (sessionReady || settled) return; @@ -1068,7 +1073,12 @@ async function runRemoteSshWebSocket( if (message.type === "ssh.data") { const chunk = Buffer.from(String(message.data ?? ""), message.encoding === "base64" ? "base64" : "utf8"); if (message.stream === "stderr") process.stderr.write(chunk); - else process.stdout.write(chunk); + else if (stdoutForwarder === null) { + process.stdout.write(chunk); + } else { + const hint = stdoutForwarder.write(chunk); + if (hint !== null) process.stderr.write(hint); + } return; } if (message.type === "ssh.error") { diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts new file mode 100644 index 00000000..47f64fcc --- /dev/null +++ b/scripts/src/ssh.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { + createSshStdoutForwarder, + formatSshStdoutTruncationHint, + parseSshInvocation, + sshStdoutStreamMaxBytes, + sshStdoutTruncationHint, + windowsPowerShellScriptPrelude, +} from "./ssh"; + +describe("ssh windows PowerShell safety prelude", () => { + test("shadows ConvertTo-Json and strips filesystem ETS metadata", () => { + const prelude = windowsPowerShellScriptPrelude("C:\\test"); + + expect(prelude).toContain("function ConvertTo-UniDeskPlainJsonValue"); + expect(prelude).toContain("function ConvertTo-Json"); + expect(prelude).toContain("'PSPath','PSParentPath','PSChildName','PSDrive','PSProvider','ReadCount'"); + expect(prelude).toContain("Microsoft.PowerShell.Utility\\ConvertTo-Json"); + expect(prelude).toContain("Set-Location -LiteralPath 'C:\\test'"); + }); +}); + +describe("ssh stdout bounded streaming", () => { + test("uses bounded defaults and clamps env override", () => { + expect(sshStdoutStreamMaxBytes({} as NodeJS.ProcessEnv)).toBe(256 * 1024); + expect(sshStdoutStreamMaxBytes({ UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES: "8192" } as NodeJS.ProcessEnv)).toBe(8192); + expect(sshStdoutStreamMaxBytes({ UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES: "1" } as NodeJS.ProcessEnv)).toBe(4096); + expect(sshStdoutStreamMaxBytes({ UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES: String(64 * 1024 * 1024) } as NodeJS.ProcessEnv)).toBe(16 * 1024 * 1024); + }); + + test("formats truncation hint without echoing remote command", () => { + const invocation = parseSshInvocation("D601:win", ["ps"]); + expect(invocation.parsed.remoteCommand).not.toBeNull(); + const hint = sshStdoutTruncationHint({ + invocation, + transport: "backend-core-broker", + thresholdBytes: 4096, + observedBytesAtTruncation: 5000, + dumpPath: "/tmp/unidesk-cli-output/sample.stdout.bin", + }); + const formatted = formatSshStdoutTruncationHint(hint); + + expect(formatted.startsWith("UNIDESK_SSH_STDOUT_TRUNCATED ")).toBe(true); + const payload = JSON.parse(formatted.slice("UNIDESK_SSH_STDOUT_TRUNCATED ".length)) as Record; + expect(payload.code).toBe("ssh-stdout-truncated"); + expect(payload.providerId).toBe("D601"); + expect(payload.route).toBe("D601:win"); + expect(payload.thresholdBytes).toBe(4096); + expect(formatted).not.toContain("Get-Content"); + }); + + test("forwarder bounds stdout and emits one truncation hint", () => { + const invocation = parseSshInvocation("D601:win", ["ps"]); + const forwarded: Buffer[] = []; + const stdout = { + write(chunk: string | Buffer): boolean { + forwarded.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return true; + }, + } as NodeJS.WritableStream; + const forwarder = createSshStdoutForwarder({ + invocation, + transport: "frontend-websocket", + maxBytes: 5, + stdout, + }); + + expect(forwarder.write(Buffer.from("abc"))).toBeNull(); + const hint = forwarder.write(Buffer.from("defgh")); + expect(hint).not.toBeNull(); + expect(forwarder.write(Buffer.from("ijkl"))).toBeNull(); + expect(Buffer.concat(forwarded).toString("utf8")).toBe("abcde"); + expect(hint).toContain("UNIDESK_SSH_STDOUT_TRUNCATED"); + expect(hint).toContain("\"transport\":\"frontend-websocket\""); + }); +}); diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 977a2248..ef8042c2 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -1,5 +1,8 @@ import { spawn } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { type UniDeskConfig, repoRoot } from "./config"; import { decodeApplyPatchV2BulkRead, @@ -88,6 +91,22 @@ export interface SshRuntimeTimeoutHint { note: string; } +export interface SshStdoutTruncationHint { + code: "ssh-stdout-truncated"; + level: "warning"; + providerId: string; + route: string; + transport: "backend-core-broker" | "frontend-websocket"; + invocationKind: SshInvocationKind; + thresholdBytes: number; + observedBytesAtTruncation: number; + dumpPath: string | null; + dumpError: string | null; + message: string; + action: string; + note: string; +} + const argvQuotedSshSubcommands = new Set(["git", "rg", "grep", "sed", "nl", "stat", "du", "ls", "cat", "head", "tail", "wc", "pwd"]); const nativeK3sKubeconfig = "/etc/rancher/k3s/k3s.yaml"; const windowsBridgeCwd = "/mnt/c/Windows"; @@ -96,6 +115,10 @@ const windowsCmdExeNativePath = "C:\\Windows\\System32\\cmd.exe"; const defaultSshSlowWarningMs = 10_000; const defaultSshRuntimeTimeoutMs = 60_000; const maxSshRuntimeTimeoutMs = 60_000; +const defaultSshStdoutStreamMaxBytes = 256 * 1024; +const minSshStdoutStreamMaxBytes = 4 * 1024; +const maxSshStdoutStreamMaxBytes = 16 * 1024 * 1024; +const sshStdoutDumpDir = join(tmpdir(), "unidesk-cli-output"); export const sshShellCompatibilityPrelude = 'printf(){ if [ "${1+x}" = x ] && [ "$1" = "-v" ] && [ -n "${BASH_VERSION:-}" ]; then command printf "$@"; return $?; fi; if [ "${1+x}" = x ] && [ "$1" = "--" ]; then shift; fi; command printf -- "$@"; }'; export const sshUserToolPathPrelude = [ 'for unidesk_path_dir in "$HOME/.bun/bin" "$HOME/.local/bin" "$HOME/bin" "/root/.bun/bin"; do', @@ -1236,7 +1259,58 @@ function buildWindowsCmdStdinLauncherScript(cwd: string | null): string { ].join(" "); } -function windowsPowerShellScriptPrelude(cwd: string | null): string { +const windowsPowerShellJsonSafetyPreludeLines = [ + "function ConvertTo-UniDeskPlainJsonValue {", + " param([object]$Value, [int]$Depth = 6, [int]$Level = 0)", + " if ($null -eq $Value) { return $null }", + " if ($Value -is [string]) { return [string]$Value }", + " if ($Value -is [char]) { return [string]$Value }", + " if ($Value -is [bool] -or $Value -is [byte] -or $Value -is [sbyte] -or $Value -is [int16] -or $Value -is [uint16] -or $Value -is [int] -or $Value -is [uint32] -or $Value -is [long] -or $Value -is [uint64] -or $Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) { return $Value }", + " if ($Value -is [datetime]) { return $Value.ToUniversalTime().ToString('o') }", + " if ($Level -ge $Depth) { return [string]$Value }", + " if ($Value -is [System.Collections.IDictionary]) {", + " $map = [ordered]@{}", + " foreach ($key in $Value.Keys) { $map[[string]$key] = ConvertTo-UniDeskPlainJsonValue -Value $Value[$key] -Depth $Depth -Level ($Level + 1) }", + " return $map", + " }", + " if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {", + " $items = New-Object System.Collections.ArrayList", + " foreach ($item in $Value) { [void]$items.Add((ConvertTo-UniDeskPlainJsonValue -Value $item -Depth $Depth -Level ($Level + 1))) }", + " return $items.ToArray()", + " }", + " $skip = @('PSPath','PSParentPath','PSChildName','PSDrive','PSProvider','ReadCount')", + " $props = @($Value.PSObject.Properties | Where-Object { @('NoteProperty','Property','AliasProperty') -contains $_.MemberType.ToString() -and -not ($skip -contains $_.Name) })", + " if ($props.Count -eq 0) { return [string]$Value }", + " $out = [ordered]@{}", + " foreach ($prop in $props) {", + " try { $out[$prop.Name] = ConvertTo-UniDeskPlainJsonValue -Value $prop.Value -Depth $Depth -Level ($Level + 1) }", + " catch { $out[$prop.Name] = '' }", + " }", + " return $out", + "}", + "function ConvertTo-Json {", + " [CmdletBinding()]", + " param(", + " [Parameter(ValueFromPipeline=$true)] [object]$InputObject,", + " [int]$Depth = 2,", + " [switch]$Compress,", + " [switch]$EnumsAsStrings", + " )", + " begin { $items = New-Object System.Collections.ArrayList }", + " process { [void]$items.Add($InputObject) }", + " end {", + " if ($items.Count -eq 0) { $value = $null } elseif ($items.Count -eq 1) { $value = $items[0] } else { $value = $items.ToArray() }", + " $plain = ConvertTo-UniDeskPlainJsonValue -Value $value -Depth $Depth -Level 0", + " if ($Compress) {", + " Microsoft.PowerShell.Utility\\ConvertTo-Json -InputObject $plain -Depth $Depth -Compress", + " } else {", + " Microsoft.PowerShell.Utility\\ConvertTo-Json -InputObject $plain -Depth $Depth", + " }", + " }", + "}", +]; + +export function windowsPowerShellScriptPrelude(cwd: string | null): string { return [ "$ErrorActionPreference = 'Stop'", "$ProgressPreference = 'SilentlyContinue'", @@ -1245,6 +1319,7 @@ function windowsPowerShellScriptPrelude(cwd: string | null): string { "$OutputEncoding = [System.Text.UTF8Encoding]::new()", "$env:PYTHONUTF8 = '1'", "$env:PYTHONIOENCODING = 'utf-8'", + ...windowsPowerShellJsonSafetyPreludeLines, ...(cwd === null ? [] : [`Set-Location -LiteralPath ${powerShellSingleQuote(cwd)}`]), ].join("\r\n") + "\r\n"; } @@ -2274,6 +2349,118 @@ export function formatSshRuntimeTimeoutHint(hint: SshRuntimeTimeoutHint): string return `UNIDESK_SSH_RUNTIME_TIMEOUT ${JSON.stringify(hint)}\n`; } +export function sshStdoutStreamMaxBytes(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES ?? env.UNIDESK_TRAN_STDOUT_STREAM_MAX_BYTES; + const parsed = raw === undefined ? NaN : Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return defaultSshStdoutStreamMaxBytes; + return Math.min(maxSshStdoutStreamMaxBytes, Math.max(minSshStdoutStreamMaxBytes, Math.trunc(parsed))); +} + +export function sshStdoutTruncationHint(options: { + invocation: ParsedSshInvocation; + transport: SshStdoutTruncationHint["transport"]; + thresholdBytes: number; + observedBytesAtTruncation: number; + dumpPath: string | null; + dumpError?: string | null; +}): SshStdoutTruncationHint { + return { + code: "ssh-stdout-truncated", + level: "warning", + providerId: safeProviderId(options.invocation.providerId), + route: options.invocation.route.raw, + transport: options.transport, + invocationKind: options.invocation.parsed.invocationKind, + thresholdBytes: options.thresholdBytes, + observedBytesAtTruncation: options.observedBytesAtTruncation, + dumpPath: options.dumpPath, + dumpError: options.dumpError ?? null, + message: `ssh stdout exceeded ${options.thresholdBytes} bytes; stdout is bounded and the complete stream is written to a local dump when possible.`, + action: "Inspect the dump path, or rerun a narrower remote command with tail/paging instead of emitting full logs or huge JSON.", + note: "This hint is written to stderr and intentionally does not echo the original remote command.", + }; +} + +export function formatSshStdoutTruncationHint(hint: SshStdoutTruncationHint): string { + return `UNIDESK_SSH_STDOUT_TRUNCATED ${JSON.stringify(hint)}\n`; +} + +function sshStdoutDumpPath(invocation: ParsedSshInvocation): string { + mkdirSync(sshStdoutDumpDir, { recursive: true, mode: 0o700 }); + const timestamp = new Date().toISOString().replace(/[:.]/gu, "-"); + const suffix = randomBytes(4).toString("hex"); + const slug = `${invocation.providerId}-${invocation.route.raw}-${invocation.route.plane}` + .replace(/[^A-Za-z0-9._-]+/gu, "-") + .replace(/^-+|-+$/gu, "") + .slice(0, 80) || "ssh"; + return join(sshStdoutDumpDir, `${timestamp}-${process.pid}-${suffix}-${slug}.stdout.bin`); +} + +export function createSshStdoutForwarder(options: { + invocation: ParsedSshInvocation; + transport: SshStdoutTruncationHint["transport"]; + maxBytes?: number; + stdout?: NodeJS.WritableStream; +}): { write: (chunk: Buffer) => string | null } { + const stdout = options.stdout ?? process.stdout; + const maxBytes = options.maxBytes ?? sshStdoutStreamMaxBytes(); + let observedBytes = 0; + let forwardedBytes = 0; + let truncated = false; + let dumpPath: string | null = null; + let dumpError: string | null = null; + const bufferedChunks: Buffer[] = []; + + const appendDump = (chunk: Buffer): void => { + if (dumpError !== null) return; + try { + if (dumpPath === null) { + dumpPath = sshStdoutDumpPath(options.invocation); + writeFileSync(dumpPath, Buffer.alloc(0), { mode: 0o600 }); + for (const buffered of bufferedChunks) appendFileSync(dumpPath, buffered); + bufferedChunks.length = 0; + } + appendFileSync(dumpPath, chunk); + } catch (error) { + dumpError = error instanceof Error ? error.message : String(error); + dumpPath = null; + } + }; + + return { + write(chunk: Buffer): string | null { + observedBytes += chunk.length; + if (!truncated && observedBytes <= maxBytes) { + bufferedChunks.push(Buffer.from(chunk)); + stdout.write(chunk); + forwardedBytes += chunk.length; + return null; + } + + if (!truncated) { + truncated = true; + const remaining = Math.max(0, maxBytes - forwardedBytes); + if (remaining > 0) { + stdout.write(chunk.subarray(0, remaining)); + forwardedBytes += remaining; + } + appendDump(chunk); + return formatSshStdoutTruncationHint(sshStdoutTruncationHint({ + invocation: options.invocation, + transport: options.transport, + thresholdBytes: maxBytes, + observedBytesAtTruncation: observedBytes, + dumpPath, + dumpError, + })); + } + + appendDump(chunk); + return null; + }, + }; +} + function brokerSource(): string { return String.raw` const open = JSON.parse(process.argv[2] || process.argv[1] || "{}"); @@ -2753,7 +2940,21 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk; stderrTail = (stderrTail + text).slice(-16_384); }; - child.stdout.pipe(process.stdout); + if (parsed.remoteCommand === null) { + child.stdout.pipe(process.stdout); + } else { + const stdoutForwarder = createSshStdoutForwarder({ + invocation, + transport: "backend-core-broker", + }); + child.stdout.on("data", (chunk: Buffer) => { + const hint = stdoutForwarder.write(chunk); + if (hint !== null) { + appendStderrTail(hint); + process.stderr.write(hint); + } + }); + } child.stderr.on("data", (chunk: Buffer) => { appendStderrTail(chunk); process.stderr.write(chunk);