From 8c07e7f9282887fe834efe62b41e29e11788657e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 11:45:03 +0000 Subject: [PATCH 1/2] fix(server): setup scripts complete with shell line editors Co-authored-by: Abid A Gafoor <40262245+sgaabdu4@users.noreply.github.com> --- .../project/ProjectSetupScriptRunner.test.ts | 135 +++++++++++++++++- .../src/project/ProjectSetupScriptRunner.ts | 51 ++++++- 2 files changed, 178 insertions(+), 8 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index f383436cdb439..aa383bfd141f4 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,16 +1,20 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, expect, it } from "@effect/vitest"; import { ProjectId, TerminalNotRunningError, type TerminalEvent, type TerminalSessionSnapshot, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { TestClock } from "effect/testing"; import * as ServerSettings from "../serverSettings.ts"; @@ -38,6 +42,7 @@ const openSnapshot = (history: string): TerminalSessionSnapshot => ({ }); const makeHarness = Effect.fn(function* (options: { + readonly command?: string; readonly history?: string; readonly opening?: Deferred.Deferred; readonly shell?: string | null; @@ -87,7 +92,7 @@ const makeHarness = Effect.fn(function* (options: { { id: "setup", name: "Setup", - command: "vp i", + command: options.command ?? "vp i", icon: "configure", runOnWorktreeCreate: true, }, @@ -101,6 +106,7 @@ const makeHarness = Effect.fn(function* (options: { const setup = Effect.fn(function* ( options: { + readonly command?: string; readonly history?: string; readonly shell?: string | null; } = {}, @@ -135,7 +141,7 @@ describe("ProjectSetupScriptRunner", () => { yield* emit({ ...event, type: "activity", hasRunningSubprocess: false, label: "zsh" }); yield* emit({ ...event, type: "output", data: "\r\n% \x1b[?2004h" }); - expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + expect(yield* Queue.take(writes)).toContain("vp i"); }), ); @@ -143,7 +149,7 @@ describe("ProjectSetupScriptRunner", () => { Effect.gen(function* () { const { writes } = yield* setup(); yield* TestClock.adjust("3 seconds"); - expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + expect(yield* Queue.take(writes)).toContain("vp i"); }), ); @@ -165,7 +171,7 @@ describe("ProjectSetupScriptRunner", () => { yield* emit({ ...event, type: "restarted", snapshot: openSnapshot("") }); // The restart lands inside a quiet window, so the next full window decides. yield* TestClock.adjust("6 seconds"); - expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + expect(yield* Queue.take(writes)).toContain("vp i"); }), ); @@ -198,3 +204,122 @@ describe("ProjectSetupScriptRunner", () => { }), ); }); + +const runObservedSetup = Effect.fnUntraced(function* (command: string, stdin = "") { + const { writes, outputLines, emit, completion } = yield* setup({ command }); + yield* emit({ ...event, type: "output", data: "\x1b[?2004h" }); + const submitted = yield* Queue.take(writes); + const cwd = yield* HostProcess.WorkingDirectory; + const env = yield* HostProcess.Environment; + + // Enter becomes a newline at the terminal boundary; transport assertions below + // cover line-editor submissions and canonical TTY limits that bash -c bypasses. + const child = yield* Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make( + "/bin/bash", + ["--noprofile", "--norc", "-c", submitted.replaceAll("\r", "\n")], + { + cwd, + env: { PATH: env.PATH }, + stdin: Stream.make(new TextEncoder().encode(stdin)), + }, + ), + ); + return yield* Effect.all( + { + stdout: handle.stdout.pipe(Stream.decodeText(), Stream.mkString), + stderr: handle.stderr.pipe(Stream.decodeText(), Stream.mkString), + exitCode: handle.exitCode, + }, + { concurrency: "unbounded" }, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + yield* emit({ ...event, type: "output", data: submitted + "\n" }); + yield* emit({ ...event, type: "output", data: child.stdout + child.stderr }); + yield* emit({ ...event, type: "closed" }); + return { + submitted, + lines: yield* Queue.clear(outputLines), + exitCode: (yield* completion).exitCode, + }; +}); + +describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("POSIX setup completion", () => { + it.live("submits one line while preserving multiline shell syntax and literal text", () => + Effect.gen(function* () { + const result = yield* runObservedSetup( + [ + "printf '%s\\n' \"it's ready\" 'literal \\n $(printf wrong) `printf wrong` café'", + "cat <<'END'", + "$literal \\path 雪", + "END", + "printf '%s\\n' done # trailing comment", + ].join("\r\n"), + ); + assert.equal(result.submitted.at(-1), "\r"); + assert.notMatch(result.submitted.slice(0, -1), /[\r\n]/); + assert.deepEqual(result.lines, [ + "it's ready", + "literal \\n $(printf wrong) `printf wrong` café", + "$literal \\path 雪", + "done", + ]); + assert.equal(result.exitCode, 0); + }), + ); + + it.live("completes long scripts without exceeding canonical terminal line limits", () => + Effect.gen(function* () { + const expected = Array.from( + { length: 120 }, + (_, index) => `line-${index}: café 雪 'quoted' \\path`, + ); + const command = expected + .map((line) => `printf '%s\\n' "${line.replaceAll("\\", "\\\\")}"`) + .join("\n"); + const utf8 = new TextEncoder(); + assert.isAbove(utf8.encode(command).byteLength, 4_096); + + const result = yield* runObservedSetup(command); + assert.deepEqual(result.lines, expected); + assert.equal(result.exitCode, 0); + assert.equal(result.submitted.at(-1), "\r"); + assert.notInclude(result.submitted, "\n"); + // The runner can write before the shell switches the terminal out of canonical mode. + for (const line of result.submitted.slice(0, -1).split("\r")) { + assert.isBelow(utf8.encode(line).byteLength, 1_024); + } + }), + ); + + it.live("leaves stdin available to the script without consuming completion commands", () => + Effect.gen(function* () { + const result = yield* runObservedSetup( + "read -r value\nprintf '%s\\n' \"$value\"", + "from stdin \\path\n", + ); + assert.deepEqual(result.lines, ["from stdin \\path"]); + assert.equal(result.exitCode, 0); + }), + ); + + it.live("preserves a trailing backslash-newline even when losing it would still exit zero", () => + Effect.gen(function* () { + const result = yield* runObservedSetup("printf '%s\\n' direct\\\n"); + assert.deepEqual(result.lines, ["direct"]); + assert.equal(result.exitCode, 0); + }), + ); + + it.live.each([ + { name: "an explicit exit", command: "exit 7", exitCode: 7 }, + { name: "a syntax error", command: "if then", exitCode: 2 }, + ])("reports a nonzero completion for $name", ({ command, exitCode }) => + Effect.gen(function* () { + const result = yield* runObservedSetup(command); + assert.equal(result.exitCode, exitCode); + }), + ); +}); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 96b574f2f8955..8b60ede606edd 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -176,7 +176,6 @@ function resolveCompletionShell(shell: string): CompletionShell | null { return "posix"; } -// A block with its own closing line protects the sentinel from trailing comments, heredocs and scripts reading stdin. function wrapCommandForCompletion( command: string, shell: CompletionShell, @@ -188,8 +187,54 @@ function wrapCommandForCompletion( return `$global:LASTEXITCODE = $null; & {\r${body}\r}; if ($null -ne $LASTEXITCODE) { $__t3c = $LASTEXITCODE } elseif ($?) { $__t3c = 0 } else { $__t3c = 1 }; Write-Host "${sentinel}$__t3c"`; case "fish": return `begin\r${body}\rend; printf '\\n${sentinel}%s\\n' $status`; - case "posix": - return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`; + case "posix": { + const normalized = command.replace(/\r\n/g, "\n"); + const encode = (text: string) => { + let encoded = ""; + for (const character of text) { + const code = character.charCodeAt(0); + if (character === "'") { + encoded += "'\\''"; + } else if (character === "\\" || code <= 0x1f || code === 0x7f) { + encoded += `\\0${code.toString(8).padStart(3, "0")}`; + } else { + encoded += character; + } + } + return encoded; + }; + // The suffix preserves trailing newlines; the subshell contains exits and syntax errors. + const evaluate = (payload: string) => + `( __t3_command=$(printf '%b_' ${payload}); eval "\${__t3_command%_}" ); printf '\\n${sentinel}%s\\n' "$?"`; + const inline = evaluate(`'${encode(normalized)}'`); + const utf8 = new TextEncoder(); + const maxLineBytes = 1_023; + if (utf8.encode(` ${inline}\r`).byteLength <= maxLineBytes) return inline; + + // Before readline starts, canonical TTY limits apply. Line editors also need each + // submission to be complete, so accumulate encoded text with standalone assignments. + const append = '__t3_command="$__t3_command"'; + const maxChunkBytes = maxLineBytes - utf8.encode(` ${append}''\r`).byteLength; + const lines: string[] = []; + let prefix = "__t3_command="; + let chunk = ""; + let chunkBytes = 0; + for (const character of normalized) { + const encoded = encode(character); + const bytes = utf8.encode(encoded).byteLength; + if (chunkBytes + bytes > maxChunkBytes) { + lines.push(`${prefix}'${chunk}'`); + prefix = append; + chunk = ""; + chunkBytes = 0; + } + chunk += encoded; + chunkBytes += bytes; + } + lines.push(`${prefix}'${chunk}'`, `${evaluate('"$__t3_command"')}; unset __t3_command`); + // Each physical line gets the same history suppression as a short command. + return lines.join("\r "); + } } } From d1c9df20f924cad5dd80d500954d3763de6f6423 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 08:42:10 +0000 Subject: [PATCH 2/2] refactor(server): move the POSIX setup wrapper into its own function Drops the stdin test: a pipe into bash -c does not model a terminal's stdin, so it could not catch the regression it named. Co-Authored-By: Claude Opus 5.5 --- .../project/ProjectSetupScriptRunner.test.ts | 14 +-- .../src/project/ProjectSetupScriptRunner.ts | 101 +++++++++--------- 2 files changed, 54 insertions(+), 61 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 97068e33304c4..2dcf239cb04b5 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -232,7 +232,7 @@ describe("ProjectSetupScriptRunner", () => { ); }); -const runObservedSetup = Effect.fnUntraced(function* (command: string, stdin = "") { +const runObservedSetup = Effect.fnUntraced(function* (command: string) { const { writes, outputLines, emit, completion } = yield* setup({ command }); yield* emit({ ...event, type: "output", data: "\x1b[?2004h" }); const submitted = yield* Queue.take(writes); @@ -250,7 +250,6 @@ const runObservedSetup = Effect.fnUntraced(function* (command: string, stdin = " { cwd, env: { PATH: env.PATH }, - stdin: Stream.make(new TextEncoder().encode(stdin)), }, ), ); @@ -321,17 +320,6 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("POSIX setup co }), ); - it.live("leaves stdin available to the script without consuming completion commands", () => - Effect.gen(function* () { - const result = yield* runObservedSetup( - "read -r value\nprintf '%s\\n' \"$value\"", - "from stdin \\path\n", - ); - assert.deepEqual(result.lines, ["from stdin \\path"]); - assert.equal(result.exitCode, 0); - }), - ); - it.live("preserves a trailing backslash-newline even when losing it would still exit zero", () => Effect.gen(function* () { const result = yield* runObservedSetup("printf '%s\\n' direct\\\n"); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 8b2bc50226b9e..7a5940818e0f0 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -177,6 +177,57 @@ function resolveCompletionShell(shell: string): CompletionShell | null { return "posix"; } +const utf8 = new TextEncoder(); +// Canonical TTY input (before a line editor starts reading) caps a line below 1 KiB on Linux. +const POSIX_MAX_LINE_BYTES = 1_023; +const POSIX_APPEND = '__t3_command="$__t3_command"'; + +function encodePosixScript(text: string): string { + let encoded = ""; + for (const character of text) { + const code = character.charCodeAt(0); + if (character === "'") { + encoded += "'\\''"; + } else if (character === "\\" || code <= 0x1f || code === 0x7f) { + encoded += `\\0${code.toString(8).padStart(3, "0")}`; + } else { + encoded += character; + } + } + return encoded; +} + +// Line editors run each submitted line on its own, so every line must be a complete command. +function wrapPosixCommand(command: string, sentinel: string): string { + const normalized = command.replace(/\r\n/g, "\n"); + // The `_` suffix keeps trailing newlines; the subshell contains exits and syntax errors. + const evaluate = (payload: string) => + `( __t3_command=$(printf '%b_' ${payload}); eval "\${__t3_command%_}" ); printf '\\n${sentinel}%s\\n' "$?"`; + const inline = evaluate(`'${encodePosixScript(normalized)}'`); + if (utf8.encode(` ${inline}\r`).byteLength <= POSIX_MAX_LINE_BYTES) return inline; + + const maxChunkBytes = POSIX_MAX_LINE_BYTES - utf8.encode(` ${POSIX_APPEND}''\r`).byteLength; + const lines: string[] = []; + let prefix = "__t3_command="; + let chunk = ""; + let chunkBytes = 0; + for (const character of normalized) { + const encoded = encodePosixScript(character); + const bytes = utf8.encode(encoded).byteLength; + if (chunkBytes + bytes > maxChunkBytes) { + lines.push(`${prefix}'${chunk}'`); + prefix = POSIX_APPEND; + chunk = ""; + chunkBytes = 0; + } + chunk += encoded; + chunkBytes += bytes; + } + lines.push(`${prefix}'${chunk}'`, `${evaluate('"$__t3_command"')}; unset __t3_command`); + // The leading space gives every line the history suppression a short command gets. + return lines.join("\r "); +} + function wrapCommandForCompletion( command: string, shell: CompletionShell, @@ -202,54 +253,8 @@ function wrapCommandForCompletion( `print $"\\n${sentinel}($__t3c)"`, ].join("; "); } - case "posix": { - const normalized = command.replace(/\r\n/g, "\n"); - const encode = (text: string) => { - let encoded = ""; - for (const character of text) { - const code = character.charCodeAt(0); - if (character === "'") { - encoded += "'\\''"; - } else if (character === "\\" || code <= 0x1f || code === 0x7f) { - encoded += `\\0${code.toString(8).padStart(3, "0")}`; - } else { - encoded += character; - } - } - return encoded; - }; - // The suffix preserves trailing newlines; the subshell contains exits and syntax errors. - const evaluate = (payload: string) => - `( __t3_command=$(printf '%b_' ${payload}); eval "\${__t3_command%_}" ); printf '\\n${sentinel}%s\\n' "$?"`; - const inline = evaluate(`'${encode(normalized)}'`); - const utf8 = new TextEncoder(); - const maxLineBytes = 1_023; - if (utf8.encode(` ${inline}\r`).byteLength <= maxLineBytes) return inline; - - // Before readline starts, canonical TTY limits apply. Line editors also need each - // submission to be complete, so accumulate encoded text with standalone assignments. - const append = '__t3_command="$__t3_command"'; - const maxChunkBytes = maxLineBytes - utf8.encode(` ${append}''\r`).byteLength; - const lines: string[] = []; - let prefix = "__t3_command="; - let chunk = ""; - let chunkBytes = 0; - for (const character of normalized) { - const encoded = encode(character); - const bytes = utf8.encode(encoded).byteLength; - if (chunkBytes + bytes > maxChunkBytes) { - lines.push(`${prefix}'${chunk}'`); - prefix = append; - chunk = ""; - chunkBytes = 0; - } - chunk += encoded; - chunkBytes += bytes; - } - lines.push(`${prefix}'${chunk}'`, `${evaluate('"$__t3_command"')}; unset __t3_command`); - // Each physical line gets the same history suppression as a short command. - return lines.join("\r "); - } + case "posix": + return wrapPosixCommand(command, sentinel); } }