Skip to content

fix(ssh): bound SSH config include expansion and globbing - #10887

Open
nwparker wants to merge 11 commits into
oom-slice-7-ssh-fsfrom
oom-slice-8-ssh-config
Open

nwparker wants to merge 11 commits into
oom-slice-7-ssh-fsfrom
oom-slice-8-ssh-config

Conversation

@nwparker

@nwparker nwparker commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Slice 8 of the re-landed OOM work. Base is oom-slice-7-ssh-fs (#10878) — review only the top commits.

ELI5

Your ~/.ssh/config can say Include conf.d/*.conf, and those files can include more files. Orca reads that whole tree into one big string before parsing it.

Nothing capped the result. Three ways that gets large:

  • Expansion. Only active recursion is blocked, so the same 1 MB file can be included 20 times — legally — and become 20 MB of output.
  • Globbing. Include conf.d/*.conf called globSync, which builds an array of every match before anything is capped. The 256-match cap was applied after the full list already existed.
  • Decoding. The size check ran against bytes on disk. Invalid UTF-8 decodes to U+FFFD at 3 bytes out per byte in, so a 400 KiB binary passed a 1 MiB check and landed as 1.2 MB of text.

Now expansion runs against a budget, globbing walks the tree one directory at a time and only ever retains 256 matches, and the size check also runs against the decoded result.

And when a ceiling does drop part of your config, the import says so. That second part is the follow-up commit; see below.

What changed

Expansion budget (ssh-config-expansion-budget.ts, new)

  • Ceilings on expanded bytes (16 MiB), expanded lines (200,000), per-file bytes (1 MiB), file count (1,024), glob matches (256), nesting depth (16), and total source bytes (16 MiB).
  • Reads go through the shared bounded file reader, and the per-file ceiling is re-checked against the decoded string, not just the stat size.
  • The walk stops at truncation instead of continuing to pull files into the cache with their output discarded.

Bounded globbing (ssh-config-bounded-glob.ts, new)

  • Replaces globSync with a segment-by-segment opendirSync walk that inserts into a sorted array capped at 256. Peak retention is the cap, not the match count. The directory handle closes on every exit path.
  • Patterns deeper than 64 segments are refused rather than walked.

Truncation is reported to the user (follow-up commit)

  • Every drop path records a typed reason on the budget; the reason travels expander → parser → connection store → IPC → both renderer import surfaces.
  • A partial import raises toast.warning naming the ceiling instead of toast.success("Synced N servers"). See "The bug this second commit fixes" below.

Proof the OOM fix works

Every bound was mutation-tested — the bound is removed and the src/main/ssh suite re-run. All 12 fail:

Removed bound Result
expandedBytes 2 tests fail
expandedLines 1 test fails
fileBytes 2 tests fail
files 1 test fails
globMatches 1 test fails
nestingDepth 1 test fails
sourceBytes 1 test fails
Truncation stops the walk 1 test fails
Pre-read stat size check 1 test fails
Post-read decoded size check 1 test fails
Bounded glob match array 1 test fails
MAX_GLOB_PATTERN_SEGMENTS suite hangs — see below

The segment cap is load-bearing beyond memory. Removing it doesn't fail a test, it hangs: the 65-wildcard pattern in that test roots at / and becomes a full filesystem walk to depth 65. Killed at 120s on the file alone and at 14 minutes on the full suite.

Four coverage gaps this found. The first three were vacuous in the same way, and are the reason this slice's tests changed:

  1. expandedBytes and expandedLines could each be raised to MAX_SAFE_INTEGER with the suite green. The tests seeded the budget relative to the constant (expandedBytes - 1), so the assertion held at any value — including one that is no bound at all. Replaced with two tests that drive the real expander and assert against the literal ceiling.
  2. The post-read decoded-size check could be deleted with the suite green. Nothing exercised a file whose decoded size exceeded its on-disk size.
  3. Stopping the walk at truncation could be deleted with the suite green.

The bug this second commit fixes

Review of the first commit surfaced a defect in the bounds themselves, so it's fixed here rather than left as an accepted regression.

What went wrong. The ceilings drop configuration — that's their job — but loadUserSshConfig() returned the surviving prefix as an ordinary SshConfigHost[]. Every caller treated it as the complete config. So:

  • You have Host prod-db defined in conf.d/40-databases.conf.
  • Your config tree trips a ceiling before reaching that file.
  • Import reports "Synced 12 servers" — a success toast. prod-db is simply absent.
  • The only signal was a console.warn in the main process, which no user sees.

A host that Orca silently declined to read is indistinguishable from a host you never configured. That's a worse failure than the OOM: the crash is loud, this is silent and looks correct.

Confirmed as introduced by this slice, not pre-existing. At the merge base (f10b6de2c7), ssh-config-expansion-budget.ts and ssh-config-bounded-glob.ts do not exist, and the expander had only a glob-match cap and a per-file size cap. The four ceilings that can drop whole host definitions — nesting depth, expanded output, source bytes, file count — are all introduced by this slice.

The fix. SshConfigTruncationReason is declared once in src/shared/ssh-types.ts and threaded end to end:

budget (records every drop)
  → expander returns { content, truncatedBy }
    → loadUserSshConfig returns { hosts, truncatedBy }
      → connection store carries it as lastConfigTruncation
        → ssh:importConfig forwards it
          → SshPane + AddRemoteHostDialog warn instead of reporting success

Details worth a reviewer's attention:

  • Recording happens before the dedupe guard. warnOnce logs a given ceiling once, but a second drop from the same ceiling still registers, so the reason set is complete by construction.
  • The stat-size and decoded-size rejections record separately. They're distinct code paths and each can drop a file alone.
  • Truncation outranks both success reports. A partial import that happens to sync some hosts, and a partial import that syncs none, are both wrong to report as "synced" / "already in sync". Two tests pin this.
  • An unrecognized reason produces silence, not an empty warning. Main and renderer version independently; a newer main sending a reason this renderer has no copy for would otherwise render "Some hosts were skipped because ." One test pins this.
  • The truncated branch of the Add-Host dialog deliberately does not close the dialog, so you can still add the host that went missing.

Mutation-tested, 9 mutations, all fail:

Mutation Tests failing
warnOnce stops recording the reason 3
Expander returns truncatedBy: null 5
Parser drops the reason on the way out 1
Connection store stops carrying it 2
IPC stops forwarding it 1
Glob overflow stops recording 1
Per-file stat rejection stops recording 1
Outcome ignores truncation (success toast wins) 2
Unknown-reason guard removed 1

The end-to-end test drives loadUserSshConfig() itself with a real config on disk rather than calling the bounded helper directly — deleting any link in the chain above fails it. That's deliberate: a test that calls the helper directly stays green when the wiring is removed.

A semantic difference found by differential testing

The old and new resolvers were compared across 22 include patterns over a fixture tree (nested dirs, hidden files, spaces, bracket/question-mark literals, a symlinked directory). Four diverged — all four because Node's globSync and the ssh client's glob(3) genuinely disagree:

Pattern globSync This PR POSIX glob(3)
conf.d/**/*.conf 4 matches (recursive) 1 1
**/*.conf 9 (recursive, skips symlink) 4 4
*/*.conf 3 (skips symlink) 4 4
*/*/*.conf 2 (skips symlink) 3 3

The third column is the shell's own expansion with globstar off, i.e. plain POSIX glob(3) — the same semantics ssh uses to resolve Include. It matches this PR on all four. So ** is two wildcards in one segment rather than a recursive descent, and a symlinked directory is traversed. Under the old behavior Orca could read include files that ssh would not, and skip ones it would. Both cases are now pinned by tests.

This is a real behavior change, called out below.

Proof of zero regressions

  • First commit: src/main/ssh + src/main/providers + src/shared — 4,787 passed, 0 failed (16 skipped).
  • At the current tip (after merging current origin/main): src/main/ssh + src/main/ipc/ssh.test.ts + the new renderer lib test + the persistence tests — 1,495 passed, 0 failed (10 skipped).
  • Typecheck clean on both tsconfig.node and tsconfig.web.
  • Lint: 0 warnings / 0 errors — the repo baseline moved from 27/0 to 0/0 when fix(quality): enforce performance-safe lint baseline #11074 landed on main; this branch matches it exactly.

loadUserSshConfig()'s return type changed from SshConfigHost[] to SshConfigLoadResult, so every call site and its tests were updated; the type checker enumerates them exhaustively. SshPane.tsx crossed the 400-line max-lines limit as a result, so the two toast reporters were extracted to ssh-config-import-report.ts rather than disabling the rule.

User-facing behavior changes

  1. Include patterns now resolve the way ssh resolves them. ** no longer descends recursively, and symlinked directories are now traversed. Anyone whose config relies on ** recursing will see fewer files included — but ssh was already not reading those files, so the config was already not behaving as it appeared to in Orca. Nothing in this repo uses ** in an Include.

  2. A config tree expanding past 16 MiB or 200,000 lines is truncated — and the import now warns you that hosts were skipped, rather than reporting success. Reached only by pathological amplification; an ordinary config with a conf.d directory is nowhere near it.

  3. An include matching more than 256 files processes the first 256 sorted matches and warns. The cap already existed; it's now applied during the walk rather than after materializing every match, and it now surfaces in the UI.

  4. A partial import raises a warning toast naming the ceiling instead of a success toast. New copy, added to en.json only, matching how strings are added elsewhere in the repo.

Made with Orca 🐋

Include expansion had no ceiling on its result. The same file may legally be
included many times (only active recursion is blocked), so a 1 MB config can
amplify into tens of megabytes of expanded text.

Add an expansion budget covering expanded bytes and lines, per-file bytes,
file count, nesting depth, and total source bytes, and stop the walk at
truncation rather than continuing to read files whose output is discarded.

Replace globSync with a segment-by-segment directory walk that retains only
the 256-match cap instead of materializing every match first, and refuse
patterns deeper than 64 segments before walking them.

Re-check the per-file ceiling against the decoded string: invalid UTF-8
decodes to U+FFFD at 3 bytes out per byte in, so a 400 KiB file passed the
1 MiB stat check and landed as 1.2 MB of text.

The walk now matches POSIX glob(3), which is what ssh itself uses to resolve
Include: `**` is two wildcards in one segment rather than a recursive descent,
and symlinked directories are traversed. globSync differed on both.
nwparker and others added 10 commits July 27, 2026 18:17
* fix(skills): require updater registration

* fix(skills): reject incomplete updater registrations

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
* fix(quality): clear safe existing lint findings

* fix(quality): keep lint cleanup allocation-free

* fix(quality): enforce performance-safe baseline

* test(terminal): drain deferred confirmation cleanup
The SSH config include ceilings drop configuration when a limit is hit —
a host defined past the ceiling simply never appears. `loadUserSshConfig()`
returned the surviving prefix as an ordinary array, so the import surface
reported "Synced N servers" for a partial import and the only signal was a
`console.warn` the user never sees.

Thread a typed reason from the budget through the expander, parser, store,
and IPC to both renderer import surfaces, which now warn rather than claim
success. Every drop path records; a repeat drop still registers even though
the warning itself is deduped.

The ceilings themselves are unchanged — this reports what they already do.

Co-authored-by: Orca <help@stably.ai>
Refresh the persisted Windows PATH during preflight without blocking Electron's main thread. Bound and deduplicate registry reads, preserve the last good cache on failure, skip host refresh for WSL, and add Windows regression coverage.
# Conflicts:
#	src/cli/handlers/agent-hooks.ts
#	src/cli/runtime/transport.ts
#	src/shared/ephemeral-vm-recipes.ts
@OrcaWin

OrcaWin commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator

Still valid on main: ssh-config-include-expander.ts still uses globSync before capping at 256 matches and has no total expansion budget. Its base PR #10878 was closed because the relay filesystem code it touched was removed, so this needs a rebase directly onto main (the current diff shows many unrelated files).

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants