Skip to content

Commit 3583293

Browse files
committed
Merge remote-tracking branch 'upstream/main' into issue-269-2e25dfa54dec
2 parents 3a44341 + 664f237 commit 3583293

45 files changed

Lines changed: 1185 additions & 281 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Terminal query suppression experiment
2+
3+
## Issue (GitHub #271)
4+
5+
The web terminal renders raw escape sequences such as
6+
`^[]10;rgb:f4f4/f7f7/fbfb^[\` and `^[[?1;2c` inside Claude Code's
7+
prompt area, which makes navigation and rendering look broken.
8+
9+
## Root cause
10+
11+
TUI applications (Claude Code, Ultraplan, etc.) probe the terminal with
12+
queries like:
13+
14+
- `\x1b]10;?\x1b\\` – ask for the foreground color (OSC 10).
15+
- `\x1b]11;?\x1b\\` – ask for the background color (OSC 11).
16+
- `\x1b]12;?\x1b\\` – ask for the cursor color (OSC 12).
17+
- `\x1b]4;<n>;?\x1b\\` – ask for an indexed palette color (OSC 4).
18+
- `\x1b[c` – primary device attributes query (DA1).
19+
- `\x1b[>c` – secondary device attributes (DA2).
20+
- `\x1b[=c` – tertiary device attributes (DA3).
21+
- `\x1b[6n` – cursor position report (CPR).
22+
23+
`xterm.js@5.3.0` responds to all of those out-of-the-box. Because the
24+
web terminal is fronted by `xterm.js`, the responses are emitted via
25+
`Terminal.onData` and we forward them to the host PTY as user input.
26+
Claude Code receives these bytes as keystrokes inside its prompt loop
27+
and renders them verbatim, which is exactly what the screenshot in the
28+
issue shows.
29+
30+
## Fix
31+
32+
We install a small parser shim immediately after instantiating the
33+
`Terminal` (see `terminal-query-suppression.ts`):
34+
35+
- For `OSC 4/10/11/12` we intercept the handler chain. If the payload
36+
contains a `?` segment (query), we return `true` to consume the
37+
sequence without invoking xterm's default handler that would
38+
otherwise reply. Plain "set color" payloads return `false` so the
39+
default handler still applies the requested theme change.
40+
- For DA1/DA2/DA3 (`CSI ... c`) and CPR (`CSI ... n`) we always
41+
return `true` so xterm never reports back to the PTY. None of the
42+
features that depend on those responses are useful for our headless
43+
web frontend.
44+
45+
The handlers are returned as disposables so callers (and the unit
46+
tests) can roll the registration back without touching `xterm`'s
47+
internal parser state.
48+
49+
## Manual reproduction notes
50+
51+
1. Start the web build (`bun run docker-git -- browser`) and open the
52+
web terminal.
53+
2. Inside the container run a TUI that probes color (for example
54+
`bash -c 'printf "\\033]10;?\\033\\\\"'`).
55+
3. Without the fix the printed escape sequence is echoed back into the
56+
prompt as garbage. With the fix nothing is echoed.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Terminal Query Suppression Verification</title>
5+
<link rel="stylesheet" href="/node_modules/xterm/css/xterm.css" />
6+
</head>
7+
<body>
8+
<div id="before-host" style="background:#080a0d;padding:8px;border:1px solid #333"></div>
9+
<h3 style="color:#fff">After suppression installed</h3>
10+
<div id="after-host" style="background:#080a0d;padding:8px;border:1px solid #333"></div>
11+
<pre id="result" style="color:#fff;background:#222;padding:8px"></pre>
12+
<script type="module">
13+
import { Terminal } from "/node_modules/xterm/lib/xterm.js"
14+
import { installTerminalQuerySuppression } from "/packages/app/src/web/terminal-query-suppression.ts"
15+
16+
const collect = (terminal) => {
17+
const collected = []
18+
terminal.onData((data) => { collected.push(data) })
19+
return collected
20+
}
21+
22+
const beforeTerminal = new Terminal()
23+
beforeTerminal.open(document.getElementById("before-host"))
24+
const beforeData = collect(beforeTerminal)
25+
26+
const afterTerminal = new Terminal()
27+
afterTerminal.open(document.getElementById("after-host"))
28+
installTerminalQuerySuppression(afterTerminal)
29+
const afterData = collect(afterTerminal)
30+
31+
const queries = [
32+
"\x1b]10;?\x1b\\",
33+
"\x1b]11;?\x1b\\",
34+
"\x1b]12;?\x1b\\",
35+
"\x1b]4;1;?\x1b\\",
36+
"\x1b[c",
37+
"\x1b[>c",
38+
"\x1b[=c",
39+
"\x1b[6n"
40+
]
41+
42+
let done = 0
43+
queries.forEach((q) => {
44+
beforeTerminal.write(q, () => { done++; render() })
45+
afterTerminal.write(q, () => { done++; render() })
46+
})
47+
48+
function render() {
49+
if (done < queries.length * 2) return
50+
const summary = {
51+
before: beforeData.map((s) => Array.from(s).map((c) => c.charCodeAt(0).toString(16)).join(" ")),
52+
after: afterData.map((s) => Array.from(s).map((c) => c.charCodeAt(0).toString(16)).join(" "))
53+
}
54+
document.getElementById("result").textContent = JSON.stringify(summary, null, 2)
55+
}
56+
</script>
57+
</body>
58+
</html>

packages/api/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# @effect-template/api
2+
3+
## 0.1.1
4+
5+
### Patch Changes
6+
7+
- Updated dependencies [[`bda7e84`](https://github.com/ProverCoderAI/docker-git/commit/bda7e84f761c922557d1e286ccb0c39b8627b580)]:
8+
- @effect-template/lib@1.1.0

packages/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@effect-template/api",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"private": true,
55
"description": "docker-git API controller",
66
"main": "dist/src/main.js",

packages/api/src/services/terminal-image-fetch-core.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,17 +103,39 @@ const normalizeTerminalImagePath = (path: string): TerminalImagePathNormalizatio
103103
}
104104
}
105105

106-
export const planTerminalImageFetch = (path: string): TerminalImageFetchPlan => {
106+
export type TerminalImageFetchOptions = {
107+
readonly baseDir?: string
108+
}
109+
110+
const isAbsolutePosixPath = (value: string): boolean => value.startsWith("/")
111+
112+
const joinBaseDirAndRelativePath = (baseDir: string, relativePath: string): string => {
113+
const trimmedBase = baseDir.replace(/\/+$/u, "")
114+
return `${trimmedBase}/${relativePath}`
115+
}
116+
117+
export const planTerminalImageFetch = (
118+
path: string,
119+
options: TerminalImageFetchOptions = {}
120+
): TerminalImageFetchPlan => {
107121
if (typeof path !== "string" || path.length === 0) {
108122
return { _tag: "InvalidTerminalImageFetch", message: "Image path is required." }
109123
}
110124
const normalized = normalizeTerminalImagePath(path)
111125
if (normalized._tag === "InvalidTerminalImagePath") {
112126
return { _tag: "InvalidTerminalImageFetch", message: normalized.message }
113127
}
114-
const containerPath = normalized.path
115-
if (!containerPath.startsWith("/")) {
116-
return { _tag: "InvalidTerminalImageFetch", message: "Image path must be absolute." }
128+
const normalizedPath = normalized.path
129+
let containerPath = normalizedPath
130+
if (!isAbsolutePosixPath(containerPath)) {
131+
const baseDir = options.baseDir
132+
if (baseDir === undefined || !isAbsolutePosixPath(baseDir)) {
133+
return { _tag: "InvalidTerminalImageFetch", message: "Image path must be absolute." }
134+
}
135+
if (invalidCharacterPattern.test(baseDir) || traversalPattern.test(baseDir)) {
136+
return { _tag: "InvalidTerminalImageFetch", message: "Image base directory is invalid." }
137+
}
138+
containerPath = joinBaseDirAndRelativePath(baseDir, containerPath)
117139
}
118140
if (invalidCharacterPattern.test(containerPath)) {
119141
return { _tag: "InvalidTerminalImageFetch", message: "Image path contains invalid characters." }

packages/api/src/services/terminal-sessions.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ type TerminalRecord = {
5959
projectDisplayName: string
6060
projectId: string
6161
projectKey: string
62+
projectTargetDir: string
6263
prepared: ReturnType<typeof prepareProjectSsh>
6364
}
6465

@@ -581,7 +582,8 @@ const registerRecord = (
581582
projectKey: string,
582583
projectDisplayName: string,
583584
prepared: ReturnType<typeof prepareProjectSsh>,
584-
projectContainerName: string
585+
projectContainerName: string,
586+
projectTargetDir: string
585587
): TerminalSession => {
586588
const session: TerminalSession = {
587589
attachedClients: 0,
@@ -600,6 +602,7 @@ const registerRecord = (
600602
projectDisplayName,
601603
projectId,
602604
projectKey,
605+
projectTargetDir,
603606
pty: null,
604607
session,
605608
sockets: new Set<WebSocket>()
@@ -662,7 +665,8 @@ export const createTerminalSession = (
662665
project.projectKey,
663666
project.displayName,
664667
prepared,
665-
projectItem.containerName
668+
projectItem.containerName,
669+
projectItem.targetDir
666670
)
667671
yield* _(emitTerminalSessionCreated(projectId, session.id, options.requestId))
668672
return { project, session }
@@ -681,7 +685,8 @@ export const createTerminalSession = (
681685
project.projectKey,
682686
project.displayName,
683687
prepared,
684-
reachableProjectItem.containerName
688+
reachableProjectItem.containerName,
689+
reachableProjectItem.targetDir
685690
)
686691
yield* _(emitTerminalSessionCreated(projectId, session.id, options.requestId))
687692
yield* _(emitTerminalStatus(projectId, "ssh.post-start", "Post-start self-heal continues in background"))
@@ -786,7 +791,7 @@ export const readProjectTerminalImage = (
786791
Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` }))
787792
)
788793
}
789-
const plan = planTerminalImageFetch(imagePath)
794+
const plan = planTerminalImageFetch(imagePath, { baseDir: record.projectTargetDir })
790795
if (plan._tag === "InvalidTerminalImageFetch") {
791796
return yield* _(Effect.fail(new ApiBadRequestError({ message: plan.message })))
792797
}

packages/api/tests/terminal-image-fetch-core.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,53 @@ describe("terminal image fetch core", () => {
103103
_tag: "InvalidTerminalImageFetch"
104104
})
105105
})
106+
107+
it("resolves a relative path against the provided absolute base directory", () => {
108+
expect(
109+
planTerminalImageFetch(
110+
"app/docs/screenshots/issue-237/proof/pr238-proof-06-skiller-submodule-status.png",
111+
{ baseDir: "/home/dev" }
112+
)
113+
).toEqual({
114+
_tag: "ValidTerminalImageFetch",
115+
containerPath: "/home/dev/app/docs/screenshots/issue-237/proof/pr238-proof-06-skiller-submodule-status.png",
116+
mediaType: "image/png"
117+
})
118+
})
119+
120+
it("ignores trailing slashes on the base directory when resolving a relative path", () => {
121+
expect(planTerminalImageFetch("photos/cover.jpg", { baseDir: "/home/dev/" })).toEqual({
122+
_tag: "ValidTerminalImageFetch",
123+
containerPath: "/home/dev/photos/cover.jpg",
124+
mediaType: "image/jpeg"
125+
})
126+
})
127+
128+
it("still rejects relative paths when no base directory is supplied", () => {
129+
expect(planTerminalImageFetch("app/cover.png")).toEqual({
130+
_tag: "InvalidTerminalImageFetch",
131+
message: "Image path must be absolute."
132+
})
133+
})
134+
135+
it("rejects relative paths when the supplied base directory is not absolute", () => {
136+
expect(planTerminalImageFetch("app/cover.png", { baseDir: "home/dev" })).toEqual({
137+
_tag: "InvalidTerminalImageFetch",
138+
message: "Image path must be absolute."
139+
})
140+
})
141+
142+
it("rejects an unsafe base directory containing traversal segments", () => {
143+
expect(planTerminalImageFetch("cover.png", { baseDir: "/home/dev/../etc" })).toEqual({
144+
_tag: "InvalidTerminalImageFetch",
145+
message: "Image base directory is invalid."
146+
})
147+
})
148+
149+
it("rejects relative paths that contain traversal segments after resolution", () => {
150+
expect(planTerminalImageFetch("../etc/cover.png", { baseDir: "/home/dev" })).toMatchObject({
151+
_tag: "InvalidTerminalImageFetch",
152+
message: "Image path must not contain '.' or '..' segments."
153+
})
154+
})
106155
})

packages/app/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# @prover-coder-ai/docker-git
22

3+
## 1.1.0
4+
5+
### Minor Changes
6+
7+
- [#264](https://github.com/ProverCoderAI/docker-git/pull/264) [`bda7e84`](https://github.com/ProverCoderAI/docker-git/commit/bda7e84f761c922557d1e286ccb0c39b8627b580) Thanks [@konard](https://github.com/konard)! - Add configurable CPU and RAM limits for the MCP Playwright sidecar container, separate from the main service container. Exposed via `--playwright-cpu`/`--playwright-cpus` and `--playwright-ram`/`--playwright-memory` CLI flags. Defaults to 30% of host resources, falling back to the main service limits when not set.
8+
39
## 1.0.87
410

511
### Patch Changes

packages/app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@prover-coder-ai/docker-git",
3-
"version": "1.0.87",
3+
"version": "1.1.0",
44
"description": "docker-git Bun and Gridland CLI plus browser frontend",
55
"main": "dist/src/docker-git/main.js",
66
"bin": {

packages/app/src/docker-git/cli/parser-apply.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export const parseApply = (
2222
const { projectDir, raw } = yield* _(parseProjectDirWithOptions(args))
2323
const cpuLimit = yield* _(normalizeCpuLimit(raw.cpuLimit, "--cpu"))
2424
const ramLimit = yield* _(normalizeRamLimit(raw.ramLimit, "--ram"))
25+
const playwrightCpuLimit = yield* _(normalizeCpuLimit(raw.playwrightCpuLimit, "--playwright-cpu"))
26+
const playwrightRamLimit = yield* _(normalizeRamLimit(raw.playwrightRamLimit, "--playwright-ram"))
2527
return {
2628
_tag: "Apply",
2729
projectDir,
@@ -31,6 +33,8 @@ export const parseApply = (
3133
claudeTokenLabel: raw.claudeTokenLabel,
3234
cpuLimit,
3335
ramLimit,
36+
playwrightCpuLimit,
37+
playwrightRamLimit,
3438
enableMcpPlaywright: raw.enableMcpPlaywright
3539
}
3640
})

0 commit comments

Comments
 (0)