diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 7120ca099f0..5fd0d404030 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -27,6 +27,8 @@ The dashboard does not ask you to enter a token. It probes the host first, and i The Add Child sheet explains that the Child uses this Home's providers over SSH and lists the prerequisites above. Host aliases come from `~/.ssh/config` on the Home running OpenCodex, not necessarily the computer displaying the browser. If discovery succeeds with no hosts, add a `Host` entry like this, then choose **Rescan hosts**. You can also enter an existing alias manually; selecting or entering an alias enables **Test connection**. The sheet links to this guide. +Host discovery also follows top-level `Include` directives. Parentheses and braces in an included filename stay literal when combined with wildcards: for example, `Include conf.d/*(1).conf` finds `backup(1).conf`, and `Include conf.d/shard-{1..3}-*.conf` looks for filenames containing `{1..3}`. An `Include` inside a `Host` or `Match` block is conditional and is not used for discovery. + ```sshconfig Host devbox HostName devbox.example.com diff --git a/src/link/ssh-config.ts b/src/link/ssh-config.ts index 4e910588bb6..e82ac48926a 100644 --- a/src/link/ssh-config.ts +++ b/src/link/ssh-config.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve, sep } from "node:path"; import { isSshAlias } from "./ssh-argv"; /** @@ -117,11 +117,43 @@ export interface LoadHostCandidatesOptions { glob?: (pattern: string) => string[]; } +/** OpenSSH Include uses glob(..., GLOB_TILDE): parentheses and braces are literal. */ +function normalizeIncludeGlob(pattern: string): string { + let normalized = ""; + for (let i = 0; i < pattern.length; i += 1) { + const char = pattern[i]!; + if (char === "\\" && i + 1 < pattern.length) { + normalized += char + pattern[++i]; + continue; + } + if (char === "[") { + let end = i + 1; + if (pattern[end] === "!" || pattern[end] === "^") end += 1; + if (pattern[end] === "]") end += 1; + for (; end < pattern.length; end += 1) { + if (pattern[end] === "\\") end += 1; + else if (pattern[end] === "]") break; + } + if (end < pattern.length) { + normalized += pattern.slice(i, end + 1); + i = end; + continue; + } + // Keep a malformed suffix intact: generated quoting must not close its class. + return normalized + pattern.slice(i); + } + // Character classes work on both Bun versions, without enabling 1.4.4's extglobs. + normalized += char === "(" ? "[(]" : char === "{" ? "[{]" : char; + } + return normalized; +} + function defaultGlob(pattern: string): string[] { if (!/[*?[]/.test(pattern)) return [pattern]; const base = dirname(pattern.slice(0, pattern.search(/[*?[]/) + 1)); - const relative = pattern.slice(base.length + 1); - return [...new Bun.Glob(relative).scanSync({ cwd: base, absolute: true, onlyFiles: true })].sort(); + // Only native Windows separators become slashes; POSIX backslash escapes stay intact. + const relative = pattern.slice(base.length + 1).split(sep).join("/"); + return [...new Bun.Glob(normalizeIncludeGlob(relative)).scanSync({ cwd: base, absolute: true, onlyFiles: true })].sort(); } /** diff --git a/structure/remote-link.md b/structure/remote-link.md index ca7404d3b16..2130303fc44 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -8,7 +8,7 @@ Every remote `ocx` call goes through `remoteOcxArgv`, a quoted POSIX `sh -c` scr `src/link/remote-platform.ts` recognises a Windows Child from one remote result: exit 125, a MINGW/MSYS/CYGWIN/Windows `uname -s`, or the cmd.exe or PowerShell "is not recognized" refusal of a POSIX command when the SSH shell has no POSIX sh. Any other non-transport failure (not 0, 125, 127 or 255) gets one locale-independent follow-up, the fixed `cmd /c echo %OS%` (`WINDOWS_OS_PROBE`) against the same strict host key, which prints `Windows_NT` under cmd and PowerShell in any display language. Enrollment maps all three to `remote_platform_unsupported` at probe or host confirmation, before host trust, key issuance or a tunnel, and the refusal names Hub mode (`ocx hub invite`, `ocx connect`). `ocx link cleanup` (`src/cli/link-cleanup.ts`) applies the same classifier to existing Home-initiated records over their pinned SSH trust and only reports: a Windows Child gets the recovery order disconnect on the Child, forced revoke on the Home, then Hub mode, because an ordinary revoke's remote `ocx disconnect` goes through the POSIX prelude that exits 125. Coverage: `tests/cli/cli-link-cleanup.test.ts`, `tests/server/link-enrollment.test.ts`. -`src/link/ssh-config.ts` lists host candidates from `~/.ssh/config`. Arguments are split with the rules of OpenSSH's `argv_split`. Pattern hosts, `Match` blocks and aliases that fail the alias check produce no candidates, and only top-level `Include` directives are followed, because an include inside a `Host` or `Match` block is conditional. A candidate is an offer, not trust. +`src/link/ssh-config.ts` lists host candidates from `~/.ssh/config`. Arguments are split with the rules of OpenSSH's `argv_split`. Pattern hosts, `Match` blocks and aliases that fail the alias check produce no candidates, and only top-level `Include` directives are followed, because an include inside a `Host` or `Match` block is conditional. Include discovery shields literal parentheses and braces from Bun's extended-glob and brace expansion, preserving existing escapes and character classes; wildcard matching, traversal options and lexical ordering remain with `Bun.Glob`. Coverage: `tests/clients/link-ssh-config.test.ts`. A candidate is an offer, not trust. `src/link/ssh-runner.ts` drains tunnel stdout and stderr with a 64 KiB cap per stream. After the SSH process exits, either reader that has not reached EOF gets 250 ms to consume remaining bytes, then is cancelled and its lock released without waiting for the underlying cancellation promise. Already consumed diagnostics are retained for host-key, auth and network classification, so an inherited pipe cannot indefinitely hold the supervisor's exit classification or pause retries and the outage clock. This grace applies to spawned tunnels; bounded one-shot commands retain their command timeout. diff --git a/tests/clients/link-ssh-config.test.ts b/tests/clients/link-ssh-config.test.ts index 50b4e42e0e3..7a2dfa914f3 100644 --- a/tests/clients/link-ssh-config.test.ts +++ b/tests/clients/link-ssh-config.test.ts @@ -1,7 +1,7 @@ import { afterEach, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { loadHostCandidates, parseHostCandidates, splitSshArgs } from "../../src/link/ssh-config"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -77,6 +77,50 @@ test("top-level Include follows relative globs but ignores conditional includes" ]); }); +for (const fixture of [ + { name: "star before parentheses", pattern: "conf.d/*(1).conf", files: ["conf.d/backup(1).conf", "conf.d/1.conf"], selected: [0] }, + { name: "question mark before parentheses", pattern: "conf.d/?(work).conf", files: ["conf.d/x(work).conf", "conf.d/work.conf"], selected: [0] }, + { name: "plus before parentheses", pattern: "conf.d/*+(work).conf", files: ["conf.d/a+(work).conf", "conf.d/awork.conf"], selected: [0] }, + { name: "at sign before parentheses", pattern: "conf.d/*@(work).conf", files: ["conf.d/a@(work).conf", "conf.d/awork.conf"], selected: [0] }, + { name: "exclamation mark before parentheses", pattern: "conf.d/*!(work).conf", files: ["conf.d/a!(work).conf", "conf.d/aother.conf"], selected: [0] }, + { name: "numeric brace range", pattern: "conf.d/shard-{1..3}-*.conf", files: ["conf.d/shard-{1..3}-a.conf", "conf.d/shard-1-a.conf"], selected: [0] }, + { name: "brace alternatives", pattern: "conf.d/{work,home}-*.conf", files: ["conf.d/{work,home}-a.conf", "conf.d/work-a.conf", "conf.d/home-a.conf"], selected: [0] }, + { name: "literal directory after a wildcard", pattern: "conf.*/@(work)/*.conf", files: ["conf.a/@(work)/a.conf", "conf.a/work/a.conf"], selected: [0] }, + { name: "ordinary question marks and classes", pattern: "conf.d/[ab]?.conf", files: ["conf.d/a1.conf", "conf.d/b2.conf", "conf.d/c3.conf", "conf.d/a12.conf"], selected: [0, 1] }, + { name: "parentheses and braces inside a class", pattern: "conf.d/[({]*.conf", files: ["conf.d/(a.conf", "conf.d/{b.conf", "conf.d/c.conf"], selected: [0, 1] }, + { name: "a leading closing bracket inside a class", pattern: "conf.d/[]({]*.conf", files: ["conf.d/(a.conf", "conf.d/]b.conf", "conf.d/{c.conf", "conf.d/d.conf"], selected: [0, 1, 2] }, + { name: "a negated class", pattern: "conf.d/[!({]*.conf", files: ["conf.d/(a.conf", "conf.d/{b.conf", "conf.d/c.conf"], selected: [2] }, + { name: "already quoted parentheses", pattern: "conf.d/*[(]1[)].conf", files: ["conf.d/backup(1).conf", "conf.d/1.conf"], selected: [0] }, + { name: "already quoted braces", pattern: "conf.d/*[{]1..3[}].conf", files: ["conf.d/shard{1..3}.conf", "conf.d/shard1.conf"], selected: [0] }, + { name: "an unclosed class cannot consume generated quoting", pattern: "conf.d/[[[ab*(c).conf", files: ["conf.d/bc).conf", "conf.d/c.conf"], selected: [] }, +]) { + test(`Include glob compatibility: ${fixture.name}`, () => { + const home = tempHome("glob"); + const sshDir = join(home, ".ssh"); + mkdirSync(sshDir, { recursive: true }); + writeFileSync(join(sshDir, "config"), `Include ${fixture.pattern}\n`); + for (const [index, file] of fixture.files.entries()) { + const path = join(sshDir, file); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `Host host${index}.example.test\n`); + } + expect(loadHostCandidates({ home }).map(candidate => candidate.alias)).toEqual( + fixture.selected.map(index => `host${index}.example.test`), + ); + }); +} + +test.skipIf(process.platform === "win32")("Include preserves an escaped parenthesis in a POSIX glob", () => { + const home = tempHome("escaped-glob"); + const sshDir = join(home, ".ssh"); + const includeDir = join(sshDir, "conf.d"); + mkdirSync(includeDir, { recursive: true }); + writeFileSync(join(sshDir, "config"), String.raw`Include conf.d/*\(1).conf`); + writeFileSync(join(includeDir, "backup(1).conf"), "Host literal.example.test\n"); + writeFileSync(join(includeDir, "1.conf"), "Host expanded.example.test\n"); + expect(loadHostCandidates({ home }).map(candidate => candidate.alias)).toEqual(["literal.example.test"]); +}); + test("Include recursion stops at the OpenSSH nesting limit", () => { const home = tempHome("recursive"); const sshDir = join(home, ".ssh");