Skip to content

ESC k <text> ESC \ (screen/tmux title sequence) leaks payload as visible text #153

Description

@Fisher-Wang

Problem

When the WASM parser encounters ESC k <text> ESC \, it logs unimplemented ESC action: ESC k and then renders <text> onto the terminal grid, silently consuming the trailing ESC \. This is the GNU screen / tmux title-setting extension — the payload should never appear on the display.

OSC 0; / 2; title-setting in the same build works correctly (payload is consumed, not painted), so this is specific to the ESC k code path.

Expected result

0: ""
1: "demo.txt"

Neither title string (/tmp, ls) should appear on the grid.

Actual result

0: "/tmp"
1: "lsdemo.txt"

Both title strings leak. The second one is jammed into the first line of visible output, which on a real shell manifests as the running command's name concatenated with whatever it prints.

Console (per term.write() call):

[ghostty-vt] warning(stream): unimplemented ESC action: ESC  k
[ghostty-vt] warning(stream): unimplemented ESC action: ESC  k

Also verified:

  • Same result whether term.write() is called with Uint8Array or with a plain string.
  • Same build correctly consumes ESC ] 0 ; /tmp BEL (OSC 0) without leaking.

Reproduction

Standalone. ghostty-web@0.4.0, headless Chromium 146, macOS 15 arm64, Node 25.

mkdir ghostty-repro && cd ghostty-repro
npm init -y >/dev/null
npm install ghostty-web puppeteer
# save the file below as repro.mjs, then:
node repro.mjs

repro.mjs:

import http from "node:http";
import fs from "node:fs/promises";
import path from "node:path";
import puppeteer from "puppeteer";

const ROOT = path.dirname(new URL(import.meta.url).pathname);
const PORT = 5765;

// WASM needs to load over http(s), not file://, so serve the installed
// ghostty-web package from a tiny local static server.
const server = http.createServer(async (req, res) => {
  const url = req.url === "/" ? "/index.html" : req.url;
  const local = url.startsWith("/ghostty-web/")
    ? path.join(ROOT, "node_modules/ghostty-web", url.slice("/ghostty-web/".length))
    : path.join(ROOT, url);
  try {
    const data = await fs.readFile(local);
    const ext = path.extname(local);
    res.writeHead(200, {
      "Content-Type":
        ext === ".js" || ext === ".mjs" ? "text/javascript" :
        ext === ".wasm" ? "application/wasm" :
        ext === ".html" ? "text/html" : "application/octet-stream",
    });
    res.end(data);
  } catch {
    res.writeHead(404).end();
  }
});
await new Promise(r => server.listen(PORT, r));

await fs.writeFile(
  path.join(ROOT, "index.html"),
  `<!doctype html><meta charset="utf-8"><body><div id="t"></div></body>`,
);

const browser = await puppeteer.launch({ headless: "new" });
const page = await browser.newPage();
page.on("console", m => {
  const t = m.text();
  if (t.includes("unimplemented") || t.includes("warning")) console.log("  [page]", t);
});

await page.goto(`http://localhost:${PORT}/`);

const lines = await page.evaluate(async () => {
  const mod = await import("/ghostty-web/dist/ghostty-web.js");
  const ghostty = await mod.Ghostty.load();
  const term = new mod.Terminal({ cols: 80, rows: 5, ghostty });
  term.open(document.getElementById("t"));

  // ESC k /tmp ESC \       (screen/tmux title-set #1)
  // \r\n
  // ESC k ls  ESC \        (screen/tmux title-set #2)
  // demo.txt\r\n           (visible body)
  term.write(new Uint8Array([
    0x1b, 0x6b, 0x2f, 0x74, 0x6d, 0x70, 0x1b, 0x5c,
    0x0d, 0x0a,
    0x1b, 0x6b, 0x6c, 0x73, 0x1b, 0x5c,
    0x64, 0x65, 0x6d, 0x6f, 0x2e, 0x74, 0x78, 0x74, 0x0d, 0x0a,
  ]));

  await new Promise(r => setTimeout(r, 150));

  const out = [];
  const active = term.buffer.active;
  for (let y = 0; y < term.rows; y++) {
    const line = active.getLine(y);
    if (line) out.push(line.translateToString(true));
  }
  return out;
});

console.log("\nRendered buffer:");
lines.forEach((l, i) => console.log(`  ${i}: ${JSON.stringify(l)}`));

await browser.close();
server.close();

Running it prints:

  [page] [ghostty-vt] warning(stream): unimplemented ESC action: ESC  k
  [page] [ghostty-vt] warning(stream): unimplemented ESC action: ESC  k

Rendered buffer:
  0: "/tmp"
  1: "lsdemo.txt"
  2: ""
  3: ""
  4: ""

Suggested fix

Recognize ESC k <text> ESC \ as the screen/tmux title-setting extension and consume it without rendering the payload. Routing <text> to a title-change listener (as OSC 0/2 already do) would be a bonus but isn't required — silently discarding is enough to fix the visible bug.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

acceptedHuman-approved for implementationbugConfirmed or likely defecttriage:doneMux triage report has been posted

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions