Skip to content
Source
Sign inSign up

[Bug]: OSC 8 file URI targets are resolved as relative filesystem paths #36005

Send to my agent
Opencoygeek(GitHub profile, opens in a new tab) opened this issue 0 comments

Summary

The terminal sends explicit file hyperlinks through its relative filesystem-path resolver, producing a different target under the terminal's launch directory. This affects both local file URIs and URIs with a remote hostname. The bundled engine preserves the original URI correctly; a focused adapter reproduction establishes the incorrect rewrite before file validation. A URI should retain its scheme and authority when handled, or be declined as unsupported, without being interpreted as a relative filename.

Before submitting

  • Related issues: related terminal text-link history is listed under References; no exact duplicate found in open and closed issues or PRs.
  • Removed credentials, tokens, private paths, and sensitive data.

Area

apps/web

Actual behavior

A focused run with the vendored WASM and production GhosttyTerminalCore, isTerminalUrl, and resolvePathLinkTarget functions returns the exact explicit OSC 8 URI from hyperlinkAt, then rewrites file:///var/tmp/report.txt to /workspace/project/file:///var/tmp/report.txt. A target with authority, file://remote.invalid/var/tmp/report.txt, becomes /workspace/project/file://remote.invalid/var/tmp/report.txt. These are synthetic fixture values, not machine paths or hostnames. The HTTP(S) control preserves its complete URI, including query and fragment; an OSC 8 with empty URI closes the preceding link.

The downstream UI behavior is source-established, not browser-tested: with file-read and editor permissions, terminal link validation requests metadata for this rewritten pathname; a missing rewritten pathname fails validation even if the file named by the URI exists. Activation uses the same resolver. No claim is made that an external editor was launched during this investigation.

Steps to reproduce

  1. Use a checkout at the commit in Version or commit, with its vendored WASM files present. No dependency installation or server is needed.
  2. Save the script below as repro.mjs and run node --disable-warning=ExperimentalWarning repro.mjs /path/to/checkout using Node with node:module stripTypeScriptTypes support.
  3. Observe the two file URI targets prefixed with the synthetic launch cwd, while the HTTP(S) control remains unchanged.

The final adapter reproduction ran once with both file variants failing and the HTTP(S), parameter, and empty-URI-close controls passing. This is a component reproduction plus a source trace of its UI callers, not an integrated browser or packaged-desktop reproduction.

The script loads the checkout's production TypeScript as in-memory modules after removing types and resolving bundler-only asset imports to the same vendored WASM bytes. It uses the real keyboard support module and a source-extracted constant to avoid installing the monorepo. It feeds OSC 8 one character at a time and uses the production URL predicate and path resolver selected by the drawer. It creates disposable HOME, TMPDIR, and T3CODE_HOME directories under mkdtemp, removes them on exit, and leaves the checkout unchanged. Exit 1 reports the URI being prefixed with the cwd; exit 0 reports no such rewrite; exit 2 reports setup or control failure.

Reproduction script: repro.mjs
import { readFile, mkdtemp, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
import { stripTypeScriptTypes } from 'node:module';
if (!process.argv[2]) { console.log('RESULT: inconclusive'); process.exit(2); }
const root = resolve(process.argv[2]);
const scratch = await mkdtemp(join(tmpdir(), 'osc-probe-'));
process.env.HOME = join(scratch, 'home');
process.env.TMPDIR = join(scratch, 'tmp');
process.env.T3CODE_HOME = join(scratch, 't3');
await Promise.all([process.env.HOME,process.env.TMPDIR,process.env.T3CODE_HOME].map(p=>mkdir(p)));
let status = 2;
const read = p => readFile(join(root,p),'utf8');
const data = source => 'data:text/javascript;base64,' + Buffer.from(stripTypeScriptTypes(source,{mode:'strip'})).toString('base64');
try {
  console.log('Node', process.version, process.platform, process.arch);
  console.log('Ghostty', (await read('native/libghostty-vt/VERSION')).trim());
  const matching = data(await read('packages/shared/src/keybindingMatching.ts'));
  const map = (await read('packages/shared/src/keybindings.ts')).match(/export const US_SHIFTED_TO_UNSHIFTED_KEYS[\s\S]*?\n\]\);/)[0];
  const keyShared = data(`${map}\nexport {isMacPlatform} from ${JSON.stringify(matching)};`);
  const keys = data((await read('apps/web/src/terminal/ghostty/keyCodes.ts')).replace('"@t3tools/shared/keybindings"',JSON.stringify(keyShared)));
  const wasm = async name => 'data:application/wasm;base64,' + (await readFile(join(root,'apps/web/src/terminal/ghostty/vendor',name))).toString('base64');
  const runtime = data((await read('apps/web/src/terminal/ghostty/runtime.ts'))
    .replace('import ghosttyWasmUrl from "./vendor/ghostty-vt.wasm?url";', 'const ghosttyWasmUrl = '+JSON.stringify(await wasm('ghostty-vt.wasm'))+';')
    .replace('import ghosttyWritePtyWasmUrl from "./vendor/ghostty-write-pty.wasm?url&no-inline";', 'const ghosttyWritePtyWasmUrl = '+JSON.stringify(await wasm('ghostty-write-pty.wasm'))+';'));
  const coreUrl = data((await read('apps/web/src/terminal/ghostty/core.ts')).replace('"./keyCodes"',JSON.stringify(keys)).replace('"./runtime"',JSON.stringify(runtime)));
  const {GhosttyTerminalCore} = await import(coreUrl);
  const pathUrl = data(await read('packages/shared/src/path.ts'));
  const {resolvePathLinkTarget} = await import(data((await read('packages/shared/src/fileLinks.ts')).replace('"./path.ts"',JSON.stringify(pathUrl))));
  const urlFunction = (await read('apps/web/src/terminal-links.ts')).match(/export function isTerminalUrl\(value: string\): boolean \{[\s\S]*?\n\}/)[0];
  const {isTerminalUrl} = await import(data(urlFunction));
  const theme = {foreground:{r:255,g:255,b:255},background:{r:0,g:0,b:0},cursor:{r:255,g:255,b:255}};
  const writes = [];
  const core = await GhosttyTerminalCore.create(40,4,8,16,theme,s=>writes.push(s));
  const check = (condition,message) => {if(!condition) throw Error(message);};
  let defects = 0;
  try {
    for (const uri of ['https://example.test/a?q=1#part','file:///var/tmp/report.txt','file://remote.invalid/var/tmp/report.txt']) {
      core.resetAndWrite('');
      const sequence = `\x1b]8;id=sample;${uri}\x1b\\label\x1b]8;;\x1b\\X`;
      for (const char of sequence) core.write(char);
      core.snapshot();
      const target = core.hyperlinkAt(0,0);
      check(target===uri,'engine URI or parameter control failed');
      check(core.hyperlinkAt(5,0)===null,'empty-URI close control failed');
      const routed = isTerminalUrl(target) ? target : resolvePathLinkTarget(target,'/workspace/project');
      console.log(JSON.stringify({target,routed,closed:core.hyperlinkAt(5,0)}));
      if (uri.startsWith('https:')) check(routed===uri,'HTTP control failed');
      else if (routed.startsWith('/workspace/project/')) defects++;
    }
    status=defects>0?1:0;
  } finally {core.dispose();}
} catch(error) {console.log('SETUP:',error.message.slice(0,200));status=2;}
finally {await rm(scratch,{recursive:true,force:true});}
console.log(status===1?'RESULT: defect reproduced':status===0?'RESULT: expected behavior':'RESULT: inconclusive');
process.exitCode=status;

Expected behavior

The explicit hyperlink target is a URI, rather than the visible label or a relative filename. The OSC 8 proposal's syntax and file-URI-hostname sections describe URI targets and require distinguishing remote file authorities; see the OSC 8 proposal. The file URI syntax in RFC 8089 separates the scheme, authority, and absolute path. T3 already reads explicit hyperlink URIs through the engine API. If T3 handles a file URI, it must preserve the URI's meaning and authority; declining an unsupported target is also valid. Neither case should resolve the serialized URI as a relative filename under the launch directory.

Impact

Minor bug or occasional failure.

Impact: P2 - Explicit terminal file hyperlinks are resolved to the wrong target, preventing normal validation and file navigation. The failure is bounded to link handling; terminal input and command execution remain usable.

Observed scope is the pinned web adapter on macOS arm64. Desktop shares these source paths but was not launched; native mobile and Windows were not tested. The safe alternative is to navigate to the intended file manually; this was not exercised in the probe. This report does not request automatic recognition of plain-text file URLs or new support for arbitrary URI schemes.

Version or commit

T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93. Vendored Ghostty revision 9f62873bf195e4d8a762d768a1405a5f2f7b1697.

Environment

Node v26.12.1, darwin arm64. The reproduction runs the production adapter and bundled WASM in Node; no browser, desktop, or server process was started.

Interface

UI: integrated thread terminal, with a direct Library/component reproduction of its hyperlink extraction and resolution boundary.

Earliest failure stage

Core processing: URI classification and target resolution before file metadata validation.

Evidence

Node v26.12.1 darwin arm64
Ghostty 9f62873bf195e4d8a762d768a1405a5f2f7b1697
{"target":"https://example.test/a?q=1#part","routed":"https://example.test/a?q=1#part","closed":null}
{"target":"file:///var/tmp/report.txt","routed":"/workspace/project/file:///var/tmp/report.txt","closed":null}
{"target":"file://remote.invalid/var/tmp/report.txt","routed":"/workspace/project/file://remote.invalid/var/tmp/report.txt","closed":null}
RESULT: defect reproduced
  • Expected source: the OSC 8 URI and file-authority contracts cited above, plus T3's explicit-link URI API in apps/web/src/terminal/ghostty/core.ts#L787-L811.
  • Failure source: the observed output above and the drawer's metadata-resolution path in apps/web/src/components/ThreadTerminalDrawer.tsx#L525-L556.
  • Evidence provenance: observed for the WASM/adapter output, with static evidence for the UI validation and activation call path.
  • Local verification: reproduced at the component boundary; integrated UI activation was not run.
  • Reproduction completeness: complete for the incorrect target rewrite; browser presentation and external-editor behavior were not exercised.

The supported entry point is PTY output containing ESC ] 8 ; id=sample ; file:///var/tmp/report.txt ST, followed by label and the empty-URI close. The engine returns the exact target from apps/web/src/terminal/ghostty/core.ts#L787-L811. The surface passes that target as link.text instead of the label in apps/web/src/terminal/ghostty/surface.ts#L2202-L2232. The drawer supplies its actual link handlers in apps/web/src/components/ThreadTerminalDrawer.tsx#L708-L724, classifies only HTTP(S) as URLs in apps/web/src/terminal-links.ts#L149-L151, and sends other targets through packages/shared/src/fileLinks.ts#L91-L107 in both metadata validation and apps/web/src/components/ThreadTerminalDrawer.tsx#L969-L1011. The resolver treats file: as a nonabsolute path. With ordinary filesystem/editor permissions, these calls are reachable; no unsupported host callback or optional extension UI is assumed.

Restoration check

For the same explicit local and remote file URI inputs, no metadata or editor request should target a pathname made by prefixing the launch cwd to the serialized URI. Handling or declining the URI must retain its scheme/authority meaning. The HTTP(S) target must remain intact, id parameters must not enter the URI, and the empty URI must still close the link. The script checks the rewrite and these controls; an integrated follow-up can additionally observe the actual metadata/editor requests.

References

  • https://github.com/pingdotgg/t3code/pull/28189 — addresses accidental path matches inside plain-text scheme URLs. Explicit OSC 8 targets bypass that extractor, so its no-link handling does not address this resolver path.
  • https://github.com/pingdotgg/t3code/issues/1322 — concerns assistant/chat output links; its closure points to chat Markdown handling, a separate caller from terminal OSC 8 activation.
  1. tanstack-source added labelsbug

Sign in to join this conversation.