refactor(ipc): split preflight and SSH registry out of the ipcMain modules (#15927)

* refactor(preflight): split agent detection out of the ipcMain registration

First of the IPC extractions the revised design requires. `src/main/ipc/preflight.ts`
mixed 285 lines of agent/tool detection with 35 lines of `ipcMain.handle`
registration, and the runtime calls that detection during normal operation
(`orca-runtime.ts:573`, plus the preflight RPC methods). So the runtime dragged
`ipcMain` into its graph to reach pure logic.

Detection moves to `src/main/preflight/agent-detection.ts` — named for what it
contains, per AGENTS.md. `ipc/preflight.ts` keeps only the handler registration and
re-exports the domain module so existing importers are unaffected. The runtime and
its RPC methods now import the domain module directly.

Ratchet baseline 36 → 35: `src/main/ipc/preflight.ts` is no longer reachable from
the runtime. The gate detected the improvement and refused to pass until the
baseline tightened, which is the behaviour it was built for.

Verified: 2 files / 1,187 tests pass across every suite touching preflight;
`pnpm typecheck` clean; `oxlint` clean.

* refactor(ssh): split the SSH target registry out of the ipcMain module

Second IPC extraction, and by far the biggest win: this removes **eight** modules
from the runtime's Electron graph, taking the ratchet baseline 35 → 27.

The runtime needed five thin accessors from `src/main/ipc/ssh.ts` —
`connectRegisteredSshTarget`, `getRegisteredSshState`, `listRegisteredSshTargets`,
`listRegisteredRemovedSshTargetLabels`, `getActiveMultiplexer`. Each is a one-line
read over module-level state. Importing them dragged in `ipcMain`, `powerMonitor`
and a `BrowserWindow` accessor — and, transitively, `ipc/pty.ts` (8,031 lines),
`ssh-browse`, `ssh-passphrase`, `ssh-relay-deploy`, `ssh-remote-cli-host-passthrough`,
`wsl-hook-relay-launch` and `user-data-path`.

`src/main/ssh/ssh-target-registry.ts` now holds that state plus its accessors.
`registerSshHandlers` populates it; the runtime reads it. The indirection is kept
deliberately: SSH providers register after construction and may reconnect, so
callers must resolve the current generation rather than freeze one.
`ipc/ssh.ts` re-exports all five, so non-test importers are unaffected.

`connectRegisteredSshTarget` still throws `ssh_handlers_not_registered` when no
handler layer registered — a headless host must fail loudly rather than report a
target as unreachable, which would read as `exited` (see ssh-execution-boundary.md).

Verified: 9 files / 59 tests across the ssh, automations and trust-preset suites;
orca-runtime.test.ts 1,183 pass; `pnpm typecheck` clean; `oxlint` clean.

* refactor(host): resolve the app root through the port in fork-reachable modules

`parcel-watcher-entry-path.ts` and `session-scanner-service-entry-path.ts` read the
app root via `require('electron').app` inside a try/catch that already returns null
when Electron is absent. They were therefore correct under plain Node at runtime and
only failed the *static* text check — which is real, not pedantic: the comment in
`ports/port-scan-command-client.ts:19` records that the plain-node-entry-guard fails
on that literal text, try/catch or not.

`hasAppEnvironment() ? getAppEnvironment() : null` gives the identical "no app root
here" answer without the text. That restores `hasAppEnvironment`, which an earlier
commit in this stack deleted as unused — it now has the caller it was waiting for.

Ratchet baseline 27 → 25.

Verified: 74 files / 458 tests; `pnpm typecheck` clean; `oxlint` clean.

* test(ssh): mock the SSH target registry alongside the ipc/ssh mock

Thirty-eight suites mocked `vi.mock('./ssh')` for `getActiveMultiplexer`. That
factory went inert when production started importing the accessor from
`../ssh/ssh-target-registry`, so the real module loaded and the assertions drifted.

Adds a companion registry mock returning the same stub, plus a
`sshTargetRegistryModuleMock` builder beside the existing `sshModuleMock` so the
shared harness stays one place. No assertion changed.

Found by a full-suite run: the targeted ssh/runtime suites were green while
30 tests in ipc/worktrees and ipc/repos were not.

* refactor(runtime): read app paths and the packaged flag through the port

`orca-runtime.ts` is the last module in its own graph that imports `electron`
directly. Nineteen of its uses were `app.getPath` (12) and `app.isPackaged` (7) —
exactly what the AppEnvironment port already covers.

Also removes a dead `const { app } = require('electron')` inside
`getOrchestrationDb`. It was left unused once the path came from the port, and it
is precisely the dynamic-require pattern `plain-node-entry-guard.ts` exists to
catch, sitting in the runtime's own constructor path.

What still binds `orca-runtime.ts` to Electron is now three sites, not nineteen:
`new Notification(...)` (one), `BrowserWindow.fromId` (one), and the
`ipcMain.on('terminal:tabCreateReply')` renderer round-trip — which is the browser
tab path, and the same one that would hang a headless host for ten seconds.

Two suites drove `electronMocks.app.isPackaged` directly; they now install a fake
AppEnvironment reading the same mutable field, so their per-test toggles work
unchanged and no assertion moved.

Verified: 376 files / 4,717 tests across src/main/runtime; typecheck and oxlint clean.

* test(serve): add the built-artifact terminal round-trip acceptance smoke

"The server started" proves almost nothing. Terminal creation dispatches into
OrcaRuntimeService, and without an installed headless PTY controller that path
falls through to a renderer reply that never arrives and times out after ten
seconds. A boot probe, a port bind, and a `host.platform` call all pass against a
server whose terminals are dead — which is exactly the gap the design doc's own
boot proof was retracted for.

This boots the BUILT `out/main/index.js --serve`, parses its ready payload, pairs a
real client over the advertised endpoint, lists worktrees, creates a terminal, runs
a command through the PTY, asserts the output comes back, and asserts clean
shutdown. It drives nothing but the public pairing + RPC surface, so the same
script is the acceptance gate a future Node-only backend must pass unchanged.

The sentinel invokes `process.execPath` rather than `echo`, because the shell
differs per platform and node does not.

Verified both directions: passes against the real server, and fails with an
actionable message when the command produces no output — a smoke that cannot fail
is worthless.

* fix(ssh): fail loudly when the multiplexer resolver was never installed

`getActiveMultiplexer` resolves through a resolver that `ipc/ssh.ts` installs at
module scope. A process that never loads the SSH layer — which is the whole point
of the Node-only backend — would get `undefined` from every call.

`undefined` already means something specific here: "not connected". So a missing
resolver and a disconnected target were indistinguishable, and a host with no SSH
layer would quietly report every target as not connected. That is the
unverifiable-reported-as-exited conflation `docs/reference/ssh-execution-boundary.md`
exists to prevent — the doc is explicit that absence of contact is never evidence
of absence of the thing.

A missing resolver is a wiring error, not a connection state, so it throws, matching
what `connectRegisteredSshTarget` already does for unregistered handlers.

Verified: 432 files / 4,759 tests across ipc, ssh, preflight, automations and trust
presets; typecheck and oxlint clean.

* refactor(pty): stop faking a BrowserWindow for the headless PTY path

`registerHeadlessPtyRuntime` passed `registerPtyHandlers` a stub object cast to
`BrowserWindow` whose `isDestroyed()` returned true and whose `webContents.send`
was a no-op — a window-shaped thing that lied about being a window, purely to
satisfy the type. Adversarial review named it as the same "looks fine, silently
returns a lie" pattern this codebase rejects elsewhere, and it is the shape that
keeps `electron` on a path that otherwise needs none.

`registerPtyHandlers` now takes `BrowserWindow | null`. An absent renderer is
semantically identical to a destroyed one — all 42 call sites already guarded on
`isDestroyed()` and skipped — so `src/main/ipc/pty-renderer-surface.ts` states that
directly: `isRendererGone`, `sendToRenderer`, `rendererWebContents`. The compound
`isDestroyed() || webContents.isDestroyed()` guards collapse into one predicate.

`isPtyWriteEventFromMainWindow` becomes null-tolerant and fails closed: with no
renderer no sender can legitimately match, so every write is rejected. Those
handlers cannot fire headless today, but failing closed is the right answer if that
ever changes.

This is the precondition for installing a PTY controller without Electron, which is
what a Node-only backend needs and what `terminal.create` actually calls.

Verified: 129 files / 2,473 tests across ipc/pty, providers and orca-runtime; the
built-artifact acceptance smoke still passes end-to-end (boot → pair →
terminal.create → sentinel → close), which is the check that matters most here
since this changes the headless PTY path itself; typecheck and oxlint clean.

* refactor(pty): read app paths and the packaged flag through the port

Follows the fake-window removal. `ipc/pty.ts` had nine `app.*` reads — all
`getPath`, `getVersion` or `isPackaged` — which the AppEnvironment port already
covers. The `BrowserWindow` import was also dead after the null-window change.

What still binds this file to Electron is now `ipcMain` (75 uses, all handler
registration) and `powerMonitor` (2). That is a clean statement of the remaining
job: split logic from registration, the same shape already applied to preflight
and the SSH registry.

Test wiring: the shared `pty-ipc-suite-environment` beforeEach installs a fake
AppEnvironment that reads through the existing `vi.mock('electron')` app object
rather than freezing values — suites toggle `app.isPackaged` mid-test to exercise
dev-mode spawn paths, so the port has to observe the same mutable field. One edit
in the shared harness covers every pty suite.

Verified: 128 files / 1,290 tests across ipc/pty and providers; the built-artifact
acceptance smoke passes; typecheck and oxlint clean; ratchet unchanged at 25.

* refactor(pty): inject the ipcMain surface so the PTY module loads without Electron

This closes the round-3 blocker: "the doc never says how orcad installs
setPtyController without Electron."

`registerPtyHandlers` owns the `RuntimePtyController` that `terminal.create`
actually spawns through — the thing a Node backend needs and cannot get from the
provider thunks. The module was otherwise host-agnostic already; the only thing
pinning 8,031 lines to Electron was a static `ipcMain` / `powerMonitor` import used
purely to register renderer handlers that no headless host will ever receive.

`src/main/ipc/pty-host-bindings.ts` makes those surfaces settable, defaulting to
no-ops. Unlike AppEnvironment and SecretStore, the default does NOT throw: a host
with no renderer legitimately has nothing to register against, so not registering
handlers nobody can call is correct rather than a hidden downgrade. The desktop
installs the real objects in `attach-main-window-services` before its handlers run.

Also converts the remaining electron import to a top-level `import type`. oxlint's
`no-import-type-side-effects` caught that inline `type` specifiers still leave a
side-effect import — precisely the "type-only is not enough if esbuild still emits
require('electron')" trap a reviewer flagged.

**`src/main/ipc/pty.ts` now bundles with zero `require("electron")`.** A Node entry
can call `registerPtyHandlers(null, runtime, …)` and get a working PTY controller.

Verified: 128 files / 1,290 tests across ipc/pty and providers; the built-artifact
acceptance smoke passes end-to-end — which is the check that matters, since this
changes how every PTY handler registers; typecheck and oxlint clean.

* fix(pty-bindings): drop two unused eslint-disable directives

CI runs oxlint with unused-disable reporting; the two
`@typescript-eslint/no-explicit-any` suppressions I added were never triggered by
any enabled rule, so they failed static analysis as dead directives. The `any[]`
rest args stay — they mirror electron's own IpcMain signature, and narrowing them
would reject the real object at the desktop call site.

Verified with the exact CI invocation: `oxlint --format github` reports 0 warnings,
0 errors across the repo.

* fix(pty): install the host bindings per process, not per window

A real regression my own change introduced, caught by the SSH docker E2E
(`paired-startup-exec-readiness` — "recovers startup exec through a headed paired
desktop owner"). It reproduced on rerun, so it was not a flake.

`setPtyHostBindings` was called inside `attachMainWindowServices`, i.e. when a
window attaches. But `registerHeadlessPtyRuntime` (index.ts:3163) calls
`registerPtyHandlers` on the serve path *before* any window exists — so those
handlers registered against the no-op default and never reached the real `ipcMain`.
A paired desktop owner then attached to a runtime whose PTY handlers were wired to
nothing.

The bindings describe the *host*, not the *window*: an Electron main process always
has `ipcMain`, whether or not a window is open. Installing them beside
`setAppEnvironment`/`setSecretStore` at the top of bootstrap fixes both paths.

Verified: 128 files / 1,290 tests; the built-artifact acceptance smoke passes;
typecheck clean; `oxlint --format github` (the exact CI invocation) reports 0/0.

* feat(orcad): de-electron the runtime core and add the Node entry + build gate

**`src/main/runtime/orca-runtime.ts` — 41,048 lines — no longer imports electron.**
Its last three sites go through `runtime-desktop-surface.ts`: a native notification,
the authoritative-window lookup, and the one `ipcMain` channel used by the
renderer-backed tab-create fallback. All three are unreachable without a renderer —
`createTerminal` already takes the background branch when no window exists (#10333) —
so a Node host installs none and the runtime relays notifications to paired clients,
which is the better destination anyway. Ratchet 25 → 24.

Adds `src/main/orcad/orcad-entry.ts`: Node host adapters plus a `startOrcad` that
constructs the runtime, installs the PTY controller via `registerPtyHandlers(null, …)`,
and serves RPC. It sets two defaults the constructor gets wrong for a headless host —
`canRecoverPersistentLocalPtys: false` (no daemon here) and
`getDesktopWindowStatus: 'blocked'` (a Node host can never be promoted to a desktop
window, which is what `'openable'` claims).

Adds `config/scripts/build-orcad.mjs`, which **currently fails, on purpose**: 25
modules still import electron (browser and speech clusters, plugins, jira/proxy,
filesystem-watcher, and four `require('electron').app` one-liners). It names them.

Two bugs found while building it, both worth recording:
- The first bundle looked clean and was not. `electron` was bundleable, so esbuild
  rewrote the metafile `path` to the resolved file under node_modules and a check for
  `path === 'electron'` passed while the package was in the bundle — it failed at
  runtime with electron's own installer message. The check now reads `original`, and
  electron is marked external so a residual import fails loudly instead.
- `jsonc-parser`'s UMD build breaks the bundle at load; aliased to its ESM entry, the
  same fix `build-relay.mjs` already carries.

Verified: desktop unchanged — the built-artifact acceptance smoke passes, runtime/pty/
provider suites green, typecheck clean, `oxlint --format github` 0/0.

* refactor(host): drop the last two require('electron') app lookups

`computer/sidecar-client.ts` and `ports/port-scan-command-client.ts` read the app
root through `require('electron').app` inside a try/catch. Both were already correct
under plain Node at runtime — they return null when it throws — but the literal text
fails the plain-Node entry guard regardless, which is why port-scan carried a comment
warning it must never become reachable from a fork entry.

Reading the AppEnvironment port gives the identical "no app root here" answer without
the text, so that warning is now obsolete and the comment says so.

Ratchet 24 → 22. Every remaining entry is a real coupling: the browser cluster (15,
which variant B does not ship), speech (2), plugins (2), and jira/proxy-settings (2,
needing an HttpClient port for Chromium session partitions).

Verified: 25 files / 209 tests; acceptance smoke passes; typecheck and
`oxlint --format github` clean.

* docs(orcad): record that the ratchet under-counts orcad's graph

The ratchet reports 22 electron importers; the orcad build reports 23. The extra is
agent-hooks/wsl-hook-relay-launch.ts, and the cause is a gap in the gate rather than
a rounding error: the ratchet measures what orca-runtime + runtime-rpc reach, while
orcad's entry also imports ipc/pty directly to install the PTY controller.

Once orcad ships it must become a ratchet entry point, or the two numbers drift and
the gate quietly stops covering the artifact it exists for.

* refactor(runtime): inject the browser commands factory

Drops 14 modules from the runtime's Electron graph in one change — the whole Chromium
browser cluster. Ratchet 22 → 8.

`OrcaRuntimeService` constructed `RuntimeBrowserCommands` as a field initializer, and
that construction is what pulled in `BrowserWindow`, `session`, `webContents` and the
cookie jars. Importing the class for its *type* is free; only building it costs.

So the class import becomes `import type`, and the instance comes from
`runtime-browser-commands-factory.ts`. The desktop installs the real factory at the
Electron entry. **All ~80 existing `this.browserCommands.*.bind(...)` delegations are
untouched** — a review round specifically warned that rewriting those was the
expensive, risky part, and this avoids it entirely.

With no factory installed, browser commands reject per call with `browser_unavailable`
rather than resolving to a stub that silently succeeds. The runtime already filters
browser capabilities out of `getStatus()` when no backend exists, so clients do not
offer the affordance in the first place.

Also corrects a stale comment in `pty-renderer-surface.ts` that still described the
fake window as present tense; it was deleted two commits ago.

Verified: 451 files / 5,513 tests across `src/main/browser` and `src/main/runtime` —
the entire browser automation suite; the built-artifact acceptance smoke passes;
`pnpm typecheck` and `oxlint --format github` clean.

* refactor(host): extract the plugin client list and port two app lookups

Ratchet 8 → 5.

- `listPluginsForClients` moves to `src/main/plugins/plugin-client-list.ts`. It needed
  only three `plugins/*` helpers, none of them Electron — it was colocated with
  `ipcMain.handle` registrations, so the runtime's `plugins.list` RPC dragged all of
  Electron in to call a function that reads a lockfile. Same shape as preflight.
  Dropping it also releases `ipc/plugin-marketplaces.ts`.
- `agent-hooks/wsl-hook-relay-launch.ts` and `speech/stt-service.ts` read `getAppPath`
  and `isPackaged` through the AppEnvironment port.

The five that remain are all genuinely Chromium and need the HttpClient port or a
watcher split, not another mechanical swap: `browser/cdp-bridge` (webContents),
`ipc/filesystem-watcher` (ipcMain), `jira/authenticated-request` and
`network/proxy-settings` (net + session partitions), `speech/model-manager`
(`net.request`, which honors app proxy settings that Node https does not — replacing
it is a behaviour change, not a rename).

Verified: 219 files / 1,922 tests across plugins, speech, agent-hooks and the runtime
RPC methods; the built-artifact acceptance smoke passes; typecheck and
`oxlint --format github` clean.

* refactor(network): resolve the default proxy session lazily

Ratchet 5 → 4.

`proxy-settings.ts` needed exactly one Electron value: `session.defaultSession`, as
the fallback when a caller does not pass `options.proxySession`. Callers could already
inject a session; only the default was hard-wired. It now comes from a settable
resolver, so the module loads under plain Node.

**A resolver rather than a Session, because a Session eagerly throws.** The first
attempt installed `session.defaultSession` directly in pre-ready bootstrap and broke
startup outright — `TypeError: Session can only be received when app is ready`. The
acceptance smoke caught it before commit. Deferring to first use is always after ready.

Behaviour with no session is not a degradation: there is no Chromium proxy config to
discover, so `resolveProxy` is skipped and the environment variables become the whole
answer rather than a fallback. Applying rules to a session that does not exist is
likewise skipped; settings are still honoured because outbound requests read the env.

This reaches past Jira — a review round noted `ensureElectronProxyFromEnvironment` is
also on the Claude HTTP path via `oauth-refresh.ts` and `rate-limits/claude-fetcher.ts`.

Verified: 48 files / 526 tests across network, jira and rate-limits; the
built-artifact acceptance smoke passes; typecheck and `oxlint --format github` clean.

* fix(index): merge the duplicate proxy-settings import

CI's code-quality lint (`oxlint --config config/oxlint-code-quality-native-plugins.json
--deny-warnings`) flags a module imported twice in one file. My earlier insertion added
a second `./network/proxy-settings` import beside the existing one.

Verified with CI's exact invocation: exit 0.

* refactor(network): add the HttpClient port and lift BrowserError out of cdp-bridge

Ratchet 4 → 2.

Two unrelated couplings, both of the same shape — a small thing living inside a
Chromium-heavy file.

`BrowserError` is a seven-line error class with no dependencies, but it lived in
`browser/cdp-bridge.ts`, which imports `webContents`. The runtime catches that type on
paths with nothing to do with CDP, so one import kept a Node host from loading the
runtime at all. Moved to `browser/browser-error.ts`; cdp-bridge re-exports it.

`jira/authenticated-request.ts` fetches through `net.fetch` and reads
`session.defaultSession`. `network/http-client.ts` makes both settable. This one is a
**named port rather than a silent fallback, because the fallback is not transparent**:
Electron's net follows Chromium session/proxy state, avoids undici's stale keep-alive
sockets after a VPN path change, and sends a Chrome user agent that Jira's XSRF check
depends on. A Node host gets `globalThis.fetch`, reads proxy config from the
environment, and sends Node's user agent. That difference is documented at the port.

`session.defaultSession` is read per call, not captured at install — it throws before
the app is ready, which is the mistake the previous commit made and the acceptance
smoke caught.

Test wiring: `jira/client.test.ts` installs the port *inside* `loadClientModule`, after
its `vi.resetModules()`, since the reset gives the module a fresh singleton.

Verified: 461 files / 5,616 tests across jira, browser, network and runtime; the
built-artifact acceptance smoke passes; typecheck, `oxlint --format github` and the
code-quality lint with `--deny-warnings` all clean.

* fix(http-client): register the Node fetch fallback with the call-site audit

`global-fetch-call-site-audit.test.ts` guards every global-fetch use, because the
global runs on undici where an unread response body can crash the whole process
(orca#8695). The HttpClient port's Node fallback is a new such call site and was
unregistered — the guard caught it in a full-suite run.

Registered with the reasoning, and the port's doc comment now states the body-safety
contract explicitly: it hands the Response straight to its caller and never inspects
it, so the consume/cancel obligation stays exactly where it already was — with the
caller, unchanged from when they called Electron's net directly.

Two comments elsewhere mentioned the global by name and tripped the line scan as false
positives; reworded to describe the behaviour rather than name the API.

Verified: audit passes; typecheck and `oxlint --format github` clean.

* fix(app-environment): read hasAppEnvironment through the realm slot
This commit is contained in:
Neil
2026-08-22 21:34:39 -07:00
committed by GitHub
parent 6785dc092d
commit f975035809
92 changed files with 1797 additions and 669 deletions
-34
View File
@@ -3,39 +3,5 @@
# This list may only SHRINK. Adding an entry means the runtime got less
# portable; migrate the module behind a host port instead (src/main/host/).
src/main/agent-hooks/wsl-hook-relay-launch.ts
src/main/ai-vault/session-scanner-service-entry-path.ts
src/main/browser/browser-cookie-clear-store.ts
src/main/browser/browser-cookie-import.ts
src/main/browser/browser-download-destination.ts
src/main/browser/browser-guest-context-menu.ts
src/main/browser/browser-guest-renderer-target.ts
src/main/browser/browser-manager.ts
src/main/browser/browser-media-access.ts
src/main/browser/browser-session-cookie-staging.ts
src/main/browser/browser-session-partition-policies.ts
src/main/browser/browser-session-registry.ts
src/main/browser/browser-webauthn-account-picker.ts
src/main/browser/cdp-bridge.ts
src/main/browser/popup-origin-bar-window.ts
src/main/computer/sidecar-client.ts
src/main/ipc/browser-tab-registration-wait.ts
src/main/ipc/filesystem-watcher.ts
src/main/ipc/parcel-watcher-entry-path.ts
src/main/ipc/plugin-marketplaces.ts
src/main/ipc/plugins.ts
src/main/ipc/preflight.ts
src/main/ipc/pty.ts
src/main/ipc/ssh-browse.ts
src/main/ipc/ssh-passphrase.ts
src/main/ipc/ssh.ts
src/main/jira/authenticated-request.ts
src/main/network/proxy-settings.ts
src/main/persistence/loading-store/user-data-path.ts
src/main/ports/port-scan-command-client.ts
src/main/runtime/orca-runtime-browser.ts
src/main/runtime/orca-runtime.ts
src/main/speech/model-manager.ts
src/main/speech/stt-service.ts
src/main/ssh/ssh-relay-deploy.ts
src/main/ssh/ssh-remote-cli-host-passthrough.ts
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* Bundle `orcad` — the Orca runtime served from plain Node, no Electron.
*
* Variant B (see docs/design/node-only-runtime-backend.html): the browser-pane and
* speech clusters are excluded. That is not a size optimisation — those modules are
* the only ones that statically import `node:sqlite`, so dropping them is what keeps
* the host Node floor at 18 instead of 22.5+.
*/
import { build } from 'esbuild'
import { mkdirSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import process from 'node:process'
const ROOT = join(import.meta.dirname, '..', '..')
const OUT_DIR = join(ROOT, 'out', 'orcad')
const ENTRY = join(ROOT, 'src', 'main', 'orcad', 'orcad-entry.ts')
// Native addons must exist on the host; they cannot be bundled.
// `electron` is external so a residual import fails loudly at require() time rather
// than silently bundling the npm package's installer shim, which is what happened the
// first time and made the bundle look clean while it was not.
const EXTERNAL = [
'electron',
'node-pty',
'@parcel/watcher',
'better-sqlite3',
'keytar',
'fsevents',
'cpu-features'
]
/** Why: the UMD build's relative dynamic requires do not bundle. Same fix build-relay.mjs uses. */
const jsoncParserEsm = {
name: 'jsonc-parser-esm',
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /^jsonc-parser$/ }, () => ({
path: join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js')
}))
}
}
/** Why: optional native deps reference prebuilt .node files that may not exist here. */
const externalNativeAddons = {
name: 'external-native-addons',
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true }))
}
}
rmSync(OUT_DIR, { recursive: true, force: true })
mkdirSync(OUT_DIR, { recursive: true })
const result = await build({
entryPoints: [ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(OUT_DIR, 'orcad.js'),
external: EXTERNAL,
plugins: [jsoncParserEsm, externalNativeAddons],
metafile: true,
minify: true,
sourcemap: false,
define: { 'process.env.NODE_ENV': '"production"' },
logLevel: 'error'
})
const output = Object.values(result.metafile.outputs).find((o) => o.entryPoint)
// Why check `original` and not just `path`: when electron is bundleable, esbuild
// rewrites `path` to the resolved file under node_modules and the naive check passes
// while the package is very much in the bundle.
const electronImporters = new Set()
for (const [file, info] of Object.entries(result.metafile.inputs)) {
for (const imported of info.imports ?? []) {
const specifier = imported.original ?? imported.path
if (specifier === 'electron' || specifier.startsWith('electron/')) {
electronImporters.add(file)
}
}
}
if (electronImporters.size > 0) {
console.error(
`[build-orcad] ${electronImporters.size} module(s) in the bundle import electron:
${[...electronImporters].map((f) => ` - ${f}`).join('\n')}`
)
// Why this can exceed the ratchet baseline: the ratchet measures the graph reachable
// from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to
// install the PTY controller. Once orcad ships, it should become a ratchet entry
// point so the two numbers cannot drift.
process.exitCode = 1
} else {
console.log(
`[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron imports.`
)
}
@@ -0,0 +1,216 @@
/**
* Boots the BUILT headless runtime server (`out/main/index.js --serve`), pairs a real
* client to it over the advertised endpoint, creates a terminal, runs a command in it,
* and asserts the output comes back — then shuts down.
*
* Why this exists: "the server started" proves almost nothing. The runtime dispatches
* terminal creation into OrcaRuntimeService, and without an installed headless PTY
* controller that path falls through to a renderer reply that never arrives and times
* out after ten seconds. A boot probe, a port bind, and a `host.platform` call all pass
* against a server whose terminals are dead. Only a PTY round trip catches it.
*
* This is also the acceptance gate for a future Node-only backend
* (docs/design/node-only-runtime-backend.html): the same script should pass against
* `orcad` unchanged, because it drives nothing but the public pairing + RPC surface.
*
* Hard assertions (fail the job):
* - the server emits its ready payload with a pairing offer,
* - a paired client can list worktrees and create a terminal,
* - a command run in that terminal produces its output,
* - the server exits when asked.
*/
import { spawn, spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { randomBytes } from 'node:crypto'
import process from 'node:process'
const projectDir = resolve(import.meta.dirname, '../..')
const serveEntry = join(projectDir, 'out', 'main', 'index.js')
const READY_TIMEOUT_MS = 120_000
const OUTPUT_TIMEOUT_MS = 30_000
const SHUTDOWN_TIMEOUT_MS = 15_000
// Why a random high port: a fixed one collides with a developer's own `orca serve`.
const PORT = 6800 + Math.floor(Number(process.env.ORCA_SMOKE_PORT_OFFSET ?? '0'))
function log(message) {
process.stdout.write(`[serve-terminal-smoke] ${message}\n`)
}
function fail(message) {
process.stderr.write(`[serve-terminal-smoke] FAIL: ${message}\n`)
process.exitCode = 1
}
/** The `orca` CLI, driven with an explicit pairing code so it targets this server only. */
function orca(pairingCode, args) {
const result = spawnSync('orca', [...args, '--pairing-code', pairingCode, '--json'], {
encoding: 'utf8',
// Why not shell:true — argument encoding is handled by spawnSync; a shell would
// re-split the pairing code, which is base64url and can contain '='.
shell: false
})
if (result.error) {
throw new Error(`orca ${args[0]} failed to spawn: ${result.error.message}`)
}
const line = (result.stdout ?? '').trim()
if (!line.startsWith('{')) {
throw new Error(`orca ${args.join(' ')} produced no JSON:\n${result.stdout}\n${result.stderr}`)
}
const parsed = JSON.parse(line)
if (parsed.ok === false) {
throw new Error(
`orca ${args.join(' ')} returned ${parsed.error?.code}: ${parsed.error?.message}`
)
}
return parsed.result
}
function waitForReady(child) {
return new Promise((resolvePromise, rejectPromise) => {
let buffered = ''
const timer = setTimeout(
() => rejectPromise(new Error(`no ready payload within ${READY_TIMEOUT_MS}ms`)),
READY_TIMEOUT_MS
)
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk) => {
buffered += chunk
for (const line of buffered.split('\n')) {
if (!line.startsWith('{')) {
continue
}
try {
const payload = JSON.parse(line)
if (payload.type === 'orca_server_ready') {
clearTimeout(timer)
resolvePromise(payload)
return
}
} catch {
// Partial line; wait for the rest.
}
}
})
child.on('exit', (code) => {
clearTimeout(timer)
rejectPromise(new Error(`server exited with ${code} before signalling ready`))
})
})
}
function pairingCodeFrom(payload) {
const url = payload?.pairing?.url
if (!url) {
throw new Error('ready payload carried no pairing offer')
}
const code = new URL(url).searchParams.get('code')
if (!code) {
throw new Error(`pairing url had no code: ${url}`)
}
return code
}
async function waitForNonce(pairingCode, terminalHandle, nonce) {
const deadline = Date.now() + OUTPUT_TIMEOUT_MS
while (Date.now() < deadline) {
const read = orca(pairingCode, ['terminal', 'read', '--terminal', terminalHandle])
const tail = (read?.terminal?.tail ?? []).map((entry) => String(entry)).join('\n')
if (tail.includes(nonce)) {
return true
}
await new Promise((r) => setTimeout(r, 1_000))
}
return false
}
async function main() {
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-serve-smoke-'))
log(`booting ${serveEntry} on port ${PORT} with userData ${userDataDir}`)
const child = spawn(
process.env.ORCA_SMOKE_ELECTRON ?? 'npx',
process.env.ORCA_SMOKE_ELECTRON
? [
serveEntry,
'--serve',
'--serve-port',
String(PORT),
'--serve-json',
`--user-data-dir=${userDataDir}`
]
: [
'electron',
serveEntry,
'--serve',
'--serve-port',
String(PORT),
'--serve-json',
`--user-data-dir=${userDataDir}`
],
{ stdio: ['ignore', 'pipe', 'pipe'] }
)
try {
const ready = await waitForReady(child)
log(`ready: ${ready.advertisedEndpoint}`)
const pairingCode = pairingCodeFrom(ready)
const worktrees = orca(pairingCode, ['worktree', 'list'])?.worktrees ?? []
if (worktrees.length === 0) {
throw new Error('paired client saw no worktrees; cannot create a terminal')
}
log(`paired client sees ${worktrees.length} worktree(s)`)
const terminal = orca(pairingCode, [
'terminal',
'create',
'--worktree',
worktrees[0].id
])?.terminal
if (!terminal?.handle) {
throw new Error('terminal.create returned no handle')
}
log(`created ${terminal.handle}`)
// Why invoke node rather than `echo`: the shell differs per platform, node does not.
const nonce = `ORCA_SMOKE_${randomBytes(8).toString('hex')}`
orca(pairingCode, [
'terminal',
'send',
'--terminal',
terminal.handle,
'--text',
`"${process.execPath}" -e "console.log('${nonce}')"`,
'--enter'
])
if (!(await waitForNonce(pairingCode, terminal.handle, nonce))) {
throw new Error(
`terminal produced no output containing ${nonce} within ${OUTPUT_TIMEOUT_MS}ms — ` +
`the server started and answered RPC, but its PTY path is dead`
)
}
log('terminal round trip OK')
} catch (error) {
fail(error instanceof Error ? error.message : String(error))
} finally {
child.kill('SIGTERM')
const exited = await Promise.race([
new Promise((r) => child.on('exit', () => r(true))),
new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS))
])
if (!exited) {
child.kill('SIGKILL')
fail(`server did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`)
}
rmSync(userDataDir, { recursive: true, force: true })
}
if (!process.exitCode) {
log('PASS')
}
}
await main()
+1
View File
@@ -30,6 +30,7 @@
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs",
"smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs",
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
"generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write",
"verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check",
@@ -6,7 +6,7 @@
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { app } from 'electron'
import { getAppEnvironment } from '../../shared/app-environment'
import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
import {
@@ -43,7 +43,7 @@ export function resolveWslHookRelayBundle(): WslHookRelayBundle | null {
candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', 'wsl'))
}
try {
const appPath = app.getAppPath()
const appPath = getAppEnvironment().getAppPath()
candidates.push(join(appPath, 'resources', 'relay', 'wsl'))
candidates.push(join(appPath, 'out', 'relay', 'wsl'))
} catch {
@@ -1,14 +1,14 @@
import { existsSync } from 'node:fs'
import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment'
import { join } from 'node:path'
type ElectronAppPath = { getAppPath(): string; isPackaged: boolean }
type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean }
// Why the port and not require('electron'): this module is reachable from plain-Node
// fork entries, where the literal text require("electron") fails the build guard even
// inside a try/catch. hasAppEnvironment() gives the same "no app root here" answer.
function loadElectronApp(): ElectronAppPath | null {
try {
return require('electron').app ?? null
} catch {
return null
}
return hasAppEnvironment() ? getAppEnvironment() : null
}
export function resolveAiVaultServiceEntryPath(
@@ -47,6 +47,6 @@ export function resolveAiVaultServiceEntryPathWithoutApp(
export function getAiVaultServiceEntryPath(): string {
const app = loadElectronApp()
return app
? resolveAiVaultServiceEntryPath(app.getAppPath(), app.isPackaged)
? resolveAiVaultServiceEntryPath(app.getAppPath(), app.isPackaged())
: resolveAiVaultServiceEntryPathWithoutApp(process.cwd(), process.resourcesPath)
}
@@ -7,7 +7,7 @@ import {
updateExternalAutomation
} from './external-manager'
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
import { getActiveMultiplexer } from '../ipc/ssh'
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
import type { Store } from '../persistence'
import type * as Fs from 'node:fs'
@@ -41,7 +41,7 @@ vi.mock('fs', async () => {
}
})
vi.mock('../ipc/ssh', () => ({
vi.mock('../ssh/ssh-target-registry', () => ({
getActiveMultiplexer: vi.fn()
}))
+1 -1
View File
@@ -17,7 +17,7 @@ import type {
} from '../../shared/automations-types'
import type { SshTarget } from '../../shared/ssh-types'
import type { Store } from '../persistence'
import { getActiveMultiplexer } from '../ipc/ssh'
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store'
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
import {
+16
View File
@@ -0,0 +1,16 @@
/**
* The error every browser command rejects with.
*
* Why its own module: this is seven lines with no dependencies, but it lived in
* `cdp-bridge.ts`, which imports `webContents` and drags the whole Chromium cluster
* along. The runtime catches this type on paths that have nothing to do with CDP, so
* that one import kept a Node host from loading the runtime at all.
*/
export class BrowserError extends Error {
constructor(
readonly code: string,
message: string
) {
super(message)
}
}
@@ -1,4 +1,6 @@
import { mkdtempSync } from 'node:fs'
import { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser'
import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createConnection } from 'node:net'
@@ -280,6 +282,10 @@ describe('Browser automation pipeline (integration)', () => {
const RENDERER_WC_ID = 1
beforeEach(async () => {
// Why: constructing the browser commands is what pulls the Chromium cluster in, so
// production installs this at the Electron entry. Suites that exercise browser
// automation install it too; a Node host installs none and the RPCs reject.
setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host))
activeGuestHarness = createMockGuest(GUEST_WC_ID, 'https://example.com', 'Example Domain')
const { guest } = activeGuestHarness
activeGuest = guest
+4 -8
View File
@@ -48,17 +48,13 @@ import {
import { insertTextThroughCdp } from './browser-text-insertion'
import type { BrowserManager } from './browser-manager'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { BrowserError } from './browser-error'
const CAPTURE_LOG_LIMIT = 1000
export class BrowserError extends Error {
constructor(
readonly code: string,
message: string
) {
super(message)
}
}
// Why re-exported: moved to ./browser-error so the runtime can catch it without
// pulling Chromium in. Existing importers of this path keep working.
export { BrowserError } from './browser-error'
type TabState = {
navigationId: string | null
+7 -3
View File
@@ -1,4 +1,5 @@
import { fork, type ChildProcess } from 'node:child_process'
import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment'
import { join } from 'node:path'
import type {
ComputerActionResult,
@@ -107,12 +108,15 @@ function getComputerSidecarEntryPath(): string {
return join(basePath, 'out', 'main', 'computer-sidecar.js')
}
// Why the port and not require('electron'): the literal text fails the plain-Node
// entry guard even inside a try/catch, and hasAppEnvironment() gives the same
// "no app root here" answer without it.
function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null {
try {
return require('electron').app
} catch {
if (!hasAppEnvironment()) {
return null
}
const environment = getAppEnvironment()
return { getAppPath: () => environment.getAppPath(), isPackaged: environment.isPackaged() }
}
class ComputerSidecarProcess {
@@ -27,6 +27,11 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
['main/runtime/relay/relay-region-preference.ts', 3],
['main/source-control/hosted-review-api-request.ts', 1],
['main/speech/openai-transcription-client.ts', 1],
// Main HTTP port: one type declaration plus the Node fallback call. The fallback
// returns the Response to its caller without inspecting it, so the consume/cancel
// obligation stays with the caller — unchanged from when those callers used
// Electron's net directly.
['main/network/http-client.ts', 2],
// fetch appears only inside injected-page script source strings, not as a
// call this process makes
['main/amp/agent-status-plugin-source.ts', 1],
@@ -0,0 +1,6 @@
import { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser'
import type { RuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory'
/** The desktop factory. Importing this file is what pulls in the Chromium browser cluster. */
export const electronRuntimeBrowserCommandsFactory: RuntimeBrowserCommandsFactory = (host) =>
new RuntimeBrowserCommands(host)
+14
View File
@@ -0,0 +1,14 @@
import { net, session } from 'electron'
import type { MainHttpClient } from '../network/http-client'
/**
* The desktop HTTP client: Chromium's network stack, which follows session and proxy
* state and sends a Chrome user agent.
*
* `session.defaultSession` throws before the app is ready, so it is read per call
* rather than captured at install time.
*/
export const electronHttpClient: MainHttpClient = {
fetch: (url, init) => net.fetch(url, init),
proxySession: () => session.defaultSession
}
@@ -0,0 +1,20 @@
import { BrowserWindow, ipcMain, Notification } from 'electron'
import type { RuntimeDesktopSurface } from '../runtime/runtime-desktop-surface'
/** The desktop implementation of the runtime's optional desktop facilities. */
export const electronRuntimeDesktopSurface: RuntimeDesktopSurface = {
showNotification: ({ title, body }) => {
if (!Notification.isSupported()) {
return false
}
new Notification({ title, body }).show()
return true
},
findWindowById: (id) => BrowserWindow.fromId(id),
onIpc: (channel, listener) => {
ipcMain.on(channel, listener as Parameters<typeof ipcMain.on>[1])
},
removeIpcListener: (channel, listener) => {
ipcMain.removeListener(channel, listener as Parameters<typeof ipcMain.removeListener>[1])
}
}
+43 -2
View File
@@ -2,7 +2,16 @@
import { existsSync, statSync } from 'node:fs'
import { isAbsolute, join } from 'node:path'
import os from 'node:os'
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, powerMonitor, type Tray } from 'electron'
import {
app,
BrowserWindow,
dialog,
ipcMain,
nativeTheme,
powerMonitor,
type Tray,
session
} from 'electron'
import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice'
import { electronApp, is } from '@electron-toolkit/utils'
import {
@@ -13,6 +22,13 @@ import {
} from './persistence'
import { setAppEnvironment } from '../shared/app-environment'
import { ElectronAppEnvironment } from './host/electron-app-environment'
import { setPtyHostBindings } from './ipc/pty-host-bindings'
import { electronRuntimeDesktopSurface } from './host/electron-runtime-desktop-surface'
import { setRuntimeDesktopSurface } from './runtime/runtime-desktop-surface'
import { electronRuntimeBrowserCommandsFactory } from './host/electron-browser-commands'
import { setRuntimeBrowserCommandsFactory } from './runtime/runtime-browser-commands-factory'
import { electronHttpClient } from './host/electron-http-client'
import { setMainHttpClient } from './network/http-client'
import { setSecretStore } from '../shared/secret-store'
import { ElectronSecretStore } from './host/electron-secret-store'
import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence'
@@ -354,7 +370,10 @@ import {
} from '../shared/runtime-types'
import { LocalPtyProvider } from './providers/local-pty-provider'
import { KeybindingService } from './keybindings/keybinding-service'
import { applyElectronProxySettings } from './network/proxy-settings'
import {
applyElectronProxySettings,
setDefaultProxySessionResolver
} from './network/proxy-settings'
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
import { CliInstaller } from './cli/cli-installer'
import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher'
@@ -861,6 +880,28 @@ if (hasSingleInstanceLock) {
// the app.setName ordering the userData captures below depend on.
setAppEnvironment(new ElectronAppEnvironment())
setSecretStore(new ElectronSecretStore())
// Why at process level, not per-window: pty.ts registers against injected surfaces so
// it can load without electron, and an Electron main process always has ipcMain —
// whether a window exists is irrelevant. Installing this in attachMainWindowServices
// meant `orca serve` registered its PTY handlers against no-ops before any window
// attached, so a paired desktop owner never received them.
setPtyHostBindings({ ipc: ipcMain, power: powerMonitor })
// Why also at process level: the runtime's notification, window-lookup and
// tab-create-reply channel are desktop-only. A Node host installs none and the
// runtime routes notifications to paired clients instead.
setRuntimeDesktopSurface(electronRuntimeDesktopSurface)
// Why here: constructing RuntimeBrowserCommands is what pulls the Chromium browser
// cluster into the graph. The desktop installs it; a Node host installs none and every
// browser RPC rejects, which capability filtering already tells clients about.
setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory)
// Why here: proxy-settings only needed electron for `session.defaultSession`. The
// desktop supplies it; a Node host has no Chromium proxy config to consult, so the
// environment variables are the whole answer there.
setDefaultProxySessionResolver(() => session.defaultSession)
// Why here: integrations use Chromium's network stack on the desktop. A Node host
// falls back to the platform default, which is a real behavioural difference (proxy
// read from the environment, Node's user agent) rather than a transparent swap.
setMainHttpClient(electronHttpClient)
// Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime.
const shouldCoupleToDevParent = is.dev && !isServeMode
installDevParentDisconnectQuit(shouldCoupleToDevParent)
+7 -7
View File
@@ -1,14 +1,14 @@
import { existsSync } from 'node:fs'
import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment'
import { join } from 'node:path'
type ElectronAppPath = { getAppPath(): string; isPackaged: boolean }
type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean }
// Why the port and not require('electron'): this module is reachable from plain-Node
// fork entries, where the literal text require("electron") fails the build guard even
// inside a try/catch. hasAppEnvironment() gives the same "no app root here" answer.
function loadElectronApp(): ElectronAppPath | null {
try {
return require('electron').app ?? null
} catch {
return null
}
return hasAppEnvironment() ? getAppEnvironment() : null
}
export function resolveWatcherProcessEntryPath(
@@ -53,7 +53,7 @@ export function resolveWatcherProcessEntryPathWithoutApp(
export function getWatcherProcessEntryPath(): string {
const app = loadElectronApp()
if (app) {
return resolveWatcherProcessEntryPath(app.getAppPath(), app.isPackaged)
return resolveWatcherProcessEntryPath(app.getAppPath(), app.isPackaged())
}
return resolveWatcherProcessEntryPathWithoutApp(process.cwd(), process.resourcesPath)
}
+4 -8
View File
@@ -14,7 +14,6 @@ import {
removeInstalledPlugin
} from '../plugins/plugin-install'
import { applyPluginConsent, applyPluginEnablement } from '../plugins/plugin-enablement'
import { buildPluginList, type PluginListEntry } from '../plugins/plugin-list-projection'
import type { PluginService } from '../plugins/plugin-service'
import { bindPluginPanelOwnerLifecycle } from '../plugins/plugin-panel-owner-lifecycle'
import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest'
@@ -68,13 +67,10 @@ const removeArgsSchema = z.object({
})
const logsArgsSchema = z.object({ pluginKey: z.string().min(1) })
export async function listPluginsForClients(
pluginService: PluginService
): Promise<PluginListEntry[]> {
await pluginService.whenReady()
const lock = await readPluginLockfile(getUserPluginsDir(pluginService.options.userDataPath))
return buildPluginList(pluginService, lock)
}
// Why re-exported: moved to ../plugins/plugin-client-list so the runtime RPC can reach
// it without ipcMain. Existing importers of this path keep working.
export { listPluginsForClients } from '../plugins/plugin-client-list'
import { listPluginsForClients } from '../plugins/plugin-client-list'
export function canRemoveInstalledPlugin(
pluginService: PluginService,
@@ -70,6 +70,9 @@ vi.mock('../pty/windows-environment-path', () => ({
vi.mock('./ssh', () => ({
getActiveMultiplexer: getActiveMultiplexerMock
}))
vi.mock('../ssh/ssh-target-registry', () => ({
getActiveMultiplexer: getActiveMultiplexerMock
}))
vi.mock('../bitbucket/client', () => ({
getBitbucketAuthStatus: getBitbucketAuthStatusMock
@@ -1,4 +1,4 @@
import { getActiveMultiplexer } from './ssh'
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
export type RemoteWindowsTerminalCapabilities = {
wslAvailable: boolean
+14 -286
View File
@@ -1,293 +1,21 @@
import { invalidateWslGuestEnvironment } from '../wsl/wsl-guest-environment'
import { ipcMain } from 'electron'
import type {
PathSource,
ShellHydrationFailureReason
} from '../../shared/shell-path-hydration-types'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
import { getAzureDevOpsAuthStatus } from '../azure-devops/client'
import { getBitbucketAuthStatus } from '../bitbucket/client'
import { getGiteaAuthStatus } from '../gitea/client'
import { _resetKnownHostsCache } from '../gitlab/gl-utils'
import { mergePersistedWindowsPathAsync } from '../pty/windows-environment-path'
import { getActiveMultiplexer } from './ssh'
import { detectWslCommandsOnPath, type WslPreflightTarget } from './preflight-wsl-agent-detection'
import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection'
import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target'
import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path'
import {
execCommandInWsl,
execLocalPreflightCommand,
isCommandAvailable,
isCommandOnPath,
shellQuote
} from './preflight-command-exec'
import {
detectInstalledAgentsWithShellPathHydration,
detectRemoteAgents,
detectRemoteWindowsTerminalCapabilities,
type RemoteWindowsTerminalCapabilities
} from './preflight-remote-windows-terminal-capabilities'
import {
getTuiAgentDetectionProbeCommands,
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
resolveDetectedTuiAgentIds
} from './tui-agent-detection-commands'
refreshShellPathAndDetectAgents,
runPreflightCheck
} from '../preflight/agent-detection'
import type {
PreflightRuntimeContext,
PreflightStatus,
RemoteWindowsTerminalCapabilities
} from '../preflight/agent-detection'
export type PreflightStatus = {
git: { installed: boolean }
gh: { installed: boolean; authenticated: boolean }
// Why: optional so existing renderer call sites that only render git/gh
// status keep typechecking. Consumers that surface GitLab-specific
// affordances (the GitLab tab in the source picker, MR list, etc.)
// gate on `glab?.authenticated`.
glab?: { installed: boolean; authenticated: boolean }
bitbucket?: { configured: boolean; authenticated: boolean; account: string | null }
azureDevOps?: {
configured: boolean
authenticated: boolean
account: string | null
baseUrl: string | null
tokenConfigured: boolean
}
gitea?: {
configured: boolean
authenticated: boolean
account: string | null
baseUrl: string | null
tokenConfigured: boolean
}
}
export { detectRemoteWindowsTerminalCapabilities }
export type { RemoteWindowsTerminalCapabilities }
// Why: cache the result so repeated Landing mounts don't re-spawn processes.
// The check only runs once per app session — relaunch to re-check.
let cached: PreflightStatus | null = null
/** @internal - tests need a clean preflight cache between cases. */
export function _resetPreflightCache(): void {
cached = null
}
function uniqueAgentIds(ids: Iterable<string>): string[] {
return [...new Set(ids)]
}
async function detectCommandRuntime(
command: string,
context?: PreflightRuntimeContext
): Promise<{ installed: boolean; wslTarget?: WslPreflightTarget }> {
const wslTarget = getPreflightWslTarget(context)
if (wslTarget) {
return (await isCommandAvailable(command, wslTarget))
? { installed: true, wslTarget }
: { installed: false }
}
if (await isCommandAvailable(command)) {
return { installed: true }
}
return { installed: false }
}
export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise<string[]> {
const wslTarget = getPreflightWslTarget(context)
if (wslTarget) {
const foundCommands = await detectWslCommandsOnPath(
wslTarget,
getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, 'wsl')
)
return resolveDetectedTuiAgentIds(KNOWN_TUI_AGENT_DETECTION_COMMANDS, foundCommands, 'wsl')
}
const probeCommands = getTuiAgentDetectionProbeCommands(
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
process.platform
)
const pathChecks = await Promise.all(
probeCommands.map(async (cmd) => ({
cmd,
installedOnPath: await isCommandOnPath(cmd)
}))
)
const missedCommands = pathChecks.filter((check) => !check.installedOnPath).map(({ cmd }) => cmd)
// Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution
// computes user install dirs once instead of blocking once per missed CLI.
const installDirCommands = detectCommandsInInstallDirs(missedCommands)
const foundCommands = new Set(
pathChecks
.filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd))
.map(({ cmd }) => cmd)
)
return resolveDetectedTuiAgentIds(
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
foundCommands,
process.platform
)
}
export async function detectInstalledAgentsWithShellPathHydration(
context?: PreflightRuntimeContext
): Promise<string[]> {
await hydrateShellPathForAgentDetection(context)
return detectInstalledAgents(context)
}
export type RefreshAgentsResult = {
/** Agents detected after hydrating PATH from the user's login shell. */
agents: string[]
/** PATH segments that were added this refresh (empty if nothing new). */
addedPathSegments: string[]
/** True when the shell spawn succeeded. False = relied on existing PATH. */
shellHydrationOk: boolean
/** Whether `detectInstalledAgents` ran against shell-hydrated PATH or only
* the seed list from `patchPackagedProcessPath`. Drives the on_path:false
* triage in tile A on dashboard 1562016. */
pathSource: PathSource
/** Why hydration failed (or `'none'` on success). Typed against the shared
* alias so the IPC boundary stays in lockstep with the renderer-visible
* enum on `onboardingAgentPickedSchema`. */
pathFailureReason: ShellHydrationFailureReason
}
/**
* Re-spawn the user's login shell to refresh process.env.PATH, then re-run
* agent detection. Called by the Agents settings pane when the user clicks
* Refresh — handles the "installed a new CLI, Orca doesn't see it yet" case
* without requiring an app restart.
*/
export async function refreshShellPathAndDetectAgents(
context?: PreflightRuntimeContext
): Promise<RefreshAgentsResult> {
const wslTarget = getPreflightWslTarget(context)
if (wslTarget) {
// Why invalidate first: the guest PATH is cached per distro for the process
// lifetime, so Refresh would otherwise re-read the pre-install PATH and
// keep reporting a just-installed CLI as absent -- the exact case this
// function exists to handle.
invalidateWslGuestEnvironment(wslTarget.distro)
const agents = await detectInstalledAgents(context)
return {
agents,
addedPathSegments: [],
shellHydrationOk: true,
pathSource: 'sync_seed_only',
pathFailureReason: 'none'
}
}
const hydration = await hydrateShellPath({ force: true })
const added = hydration.ok ? mergePathSegments(hydration.segments) : []
const agents = await detectInstalledAgents(context)
return {
agents,
addedPathSegments: added,
shellHydrationOk: hydration.ok,
pathSource: hydration.ok ? 'shell_hydrate' : 'sync_seed_only',
pathFailureReason: hydration.failureReason
}
}
export async function detectRemoteAgents(args: { connectionId: string }): Promise<string[]> {
const mux = getActiveMultiplexer(args.connectionId)
if (!mux || mux.isDisposed()) {
// Why: remote agent detection is passive UI polling. A disconnected host has
// no detectable agents until reconnect, but should not spam IPC errors.
return []
}
const result = (await mux.request('preflight.detectAgents', {
commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS
})) as { agents: string[] }
return uniqueAgentIds(result.agents)
}
async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise<boolean> {
try {
await (wslTarget
? execCommandInWsl(wslTarget, `${shellQuote('gh')} auth status`)
: execLocalPreflightCommand('gh', ['auth', 'status']))
// Why: for plain-text `gh auth status`, exit 0 means gh did not detect any
// authentication issues for the checked hosts/accounts.
return true
} catch (error) {
// Why: some environments may surface partial command output on the thrown
// error object. Keep a compatibility fallback so we avoid a false auth
// warning if success markers are present despite a non-zero result.
const stdout = (error as { stdout?: string }).stdout ?? ''
const stderr = (error as { stderr?: string }).stderr ?? ''
const output = `${stdout}\n${stderr}`
return output.includes('Logged in') || output.includes('Active account: true')
}
}
// Why: parallel to isGhAuthenticated for the glab CLI. glab writes auth
// status to stderr in some versions and stdout in others; check both.
async function isGlabAuthenticated(wslTarget?: WslPreflightTarget): Promise<boolean> {
try {
await (wslTarget
? execCommandInWsl(wslTarget, `${shellQuote('glab')} auth status`)
: execLocalPreflightCommand('glab', ['auth', 'status']))
return true
} catch (error) {
const stdout = (error as { stdout?: string }).stdout ?? ''
const stderr = (error as { stderr?: string }).stderr ?? ''
const output = `${stdout}\n${stderr}`
return output.includes('Logged in')
}
}
export async function runPreflightCheck(
force = false,
context?: PreflightRuntimeContext
): Promise<PreflightStatus> {
const wslTarget = getPreflightWslTarget(context)
const cacheable = !wslTarget
if (cacheable && cached && !force) {
return cached
}
if (process.platform === 'win32' && !wslTarget) {
await mergePersistedWindowsPathAsync(process.env, { forceRefresh: force })
}
if (force) {
// Why: the GitLab known-hosts cache (gl-utils) is populated lazily on the
// first GitLab request and never invalidated within a session. A user who
// runs `glab auth login` for a self-hosted host after Orca starts would
// otherwise see "No GitLab project found" until app relaunch. The Re-check
// path in IntegrationsPane forces preflight, so piggyback on that signal
// to refresh the host list too.
_resetKnownHostsCache()
}
const [gitProbe, ghProbe, glabProbe] = await Promise.all([
detectCommandRuntime('git', context),
detectCommandRuntime('gh', context),
detectCommandRuntime('glab', context)
])
const [ghAuthenticated, glabAuthenticated, bitbucket, azureDevOps, gitea] = await Promise.all([
ghProbe.installed ? isGhAuthenticated(ghProbe.wslTarget) : Promise.resolve(false),
glabProbe.installed ? isGlabAuthenticated(glabProbe.wslTarget) : Promise.resolve(false),
getBitbucketAuthStatus(),
getAzureDevOpsAuthStatus(),
getGiteaAuthStatus()
])
const result = {
git: { installed: gitProbe.installed },
gh: { installed: ghProbe.installed, authenticated: ghAuthenticated },
glab: { installed: glabProbe.installed, authenticated: glabAuthenticated },
bitbucket,
azureDevOps,
gitea
}
if (cacheable) {
cached = result
}
return result
}
// Why this file is thin: everything above the handler layer moved to
// ../preflight/agent-detection so the runtime can call it without ipcMain.
// Re-exported here so existing importers of `ipc/preflight` keep working.
export * from '../preflight/agent-detection'
export function registerPreflightHandlers(): void {
ipcMain.handle(
+68
View File
@@ -0,0 +1,68 @@
import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron'
/**
* The host facilities the PTY handlers register against.
*
* Why injected rather than imported: `registerPtyHandlers` owns the PTY controller
* that `terminal.create` actually spawns through, and a Node-only backend needs that
* controller. Everything else in the module is already host-agnostic — the only thing
* pinning it to Electron was a static `ipcMain` / `powerMonitor` import used purely to
* register renderer handlers that no headless host will ever receive.
*
* The desktop passes the real Electron objects. A headless host passes nothing and
* gets no-ops, which is honest: there is no renderer to answer, so registering is a
* no-op rather than a lie about having registered.
*/
/**
* Deliberately `any[]` on the rest args, matching Electron's own `IpcMain` signature:
* a narrower type here would not accept the real object, and widening at the call site
* would need a cast that hides genuine mismatches.
*/
export type PtyIpcSurface = {
handle(channel: string, listener: (event: IpcMainInvokeEvent, ...args: any[]) => unknown): void
on(channel: string, listener: (event: IpcMainEvent, ...args: any[]) => void): void
removeHandler(channel: string): void
removeAllListeners(channel: string): void
}
export type PtyPowerSurface = {
on(event: 'suspend' | 'resume', listener: () => void): void
}
/** Why not optional-chaining at 75 call sites: one object keeps the call sites unchanged. */
export const noopPtyIpcSurface: PtyIpcSurface = {
handle: () => {},
on: () => {},
removeHandler: () => {},
removeAllListeners: () => {}
}
export const noopPtyPowerSurface: PtyPowerSurface = {
on: () => {}
}
let currentIpc: PtyIpcSurface = noopPtyIpcSurface
let currentPower: PtyPowerSurface = noopPtyPowerSurface
/**
* Install the host surfaces once at startup. Defaults are no-ops rather than a throw,
* unlike AppEnvironment/SecretStore: a host with no renderer legitimately has nothing to
* register against, and silently not registering handlers nobody can call is correct
* rather than a hidden downgrade.
*/
export function setPtyHostBindings(bindings: {
ipc?: PtyIpcSurface
power?: PtyPowerSurface
}): void {
currentIpc = bindings.ipc ?? noopPtyIpcSurface
currentPower = bindings.power ?? noopPtyPowerSurface
}
export function getPtyIpc(): PtyIpcSurface {
return currentIpc
}
export function getPtyPower(): PtyPowerSurface {
return currentPower
}
+23
View File
@@ -1,4 +1,8 @@
import { afterEach, beforeEach, vi } from 'vitest'
import * as electron from 'electron'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { setPtyHostBindings } from './pty-host-bindings'
import { testPtyIpcSurface } from './pty-ipc-test-surface'
import type { Mock } from 'vitest'
import {
handleMock,
@@ -89,6 +93,25 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment {
const envScope = createPtyIpcProcessEnvScope()
beforeEach(() => {
// Why here: pty.ts registers against injected surfaces now, so the mocked ipcMain
// must be installed for the shared `handlers` map to keep capturing registrations.
setPtyHostBindings({ ipc: testPtyIpcSurface() })
// Why here: pty.ts reads app paths and the packaged flag through the AppEnvironment
// port now, so the shared vi.mock('electron') app object alone is inert. Back the
// port with the same mocks so every suite's existing expectations still hold.
// Why read through the electron mock instead of hardcoding: suites toggle
// `app.isPackaged` mid-test to exercise dev-mode spawn paths, so the port must
// observe the same mutable field rather than freeze a value at install time.
const electronAppMock = (
vi.mocked(electron) as unknown as {
app: { isPackaged: boolean; getPath: (name: string) => string; getVersion: () => string }
}
).app
installFakeAppEnvironment({
getPath: (name) => electronAppMock.getPath(name),
isPackaged: () => electronAppMock.isPackaged,
getVersion: () => electronAppMock.getVersion()
})
envScope.applyTestEnvDefaults()
handlers.clear()
handleMock.mockReset()
+21
View File
@@ -0,0 +1,21 @@
import type { PtyIpcSurface } from './pty-host-bindings'
import {
handleMock,
onMock,
removeAllListenersMock,
removeHandlerMock
} from './pty-ipc-mock-registry'
/**
* The registration surface pty suites drive. Production injects Electron's `ipcMain`;
* suites inject this so the existing `handlers` map keeps capturing registrations
* exactly as it did when the module imported `ipcMain` directly.
*/
export function testPtyIpcSurface(): PtyIpcSurface {
return {
handle: handleMock as unknown as PtyIpcSurface['handle'],
on: onMock as unknown as PtyIpcSurface['on'],
removeHandler: removeHandlerMock,
removeAllListeners: removeAllListenersMock
}
}
+37
View File
@@ -0,0 +1,37 @@
import type { BrowserWindow, WebContents } from 'electron'
/**
* The renderer surface the PTY handlers talk to, which may not exist.
*
* Why: `orca serve` — and a future Node-only backend — run the same PTY handlers
* with no window. That used to be faked: `registerHeadlessPtyRuntime` built a
* `BrowserWindow` whose `isDestroyed()` returned true and whose `webContents.send`
* was a no-op, purely to satisfy the type — the "looks fine, silently lies" shape
* this codebase rejects elsewhere, and what forced an `electron` value import into a
* path that needs none. It now passes `null`.
*
* An absent renderer is semantically identical to a destroyed one — every call site
* already guards on `isDestroyed()` and skips — so model it as `null` and say so.
*/
/** True when there is no renderer, or it is gone. Callers already treat these the same. */
export function isRendererGone(window: BrowserWindow | null): boolean {
return window === null || window.isDestroyed()
}
/** Send to the renderer if one is listening. Absent renderer drops the message, as a destroyed one does. */
export function sendToRenderer(
window: BrowserWindow | null,
channel: string,
payload?: unknown
): void {
if (isRendererGone(window)) {
return
}
window!.webContents.send(channel, payload)
}
/** The renderer's WebContents, or null. Used for identity checks and listener registration. */
export function rendererWebContents(window: BrowserWindow | null): WebContents | null {
return isRendererGone(window) ? null : window!.webContents
}
@@ -11,8 +11,10 @@ describe('PTY startup barrier ordering', () => {
const runtimeSpawnStart = source.indexOf('spawn: async (args) => {')
const runtimeSpawnEnd = source.indexOf(' write:', runtimeSpawnStart)
const runtimeSpawn = source.slice(runtimeSpawnStart, runtimeSpawnEnd)
const rendererSpawnStart = source.indexOf("ipcMain.handle(\n 'pty:spawn'")
const rendererSpawnEnd = source.indexOf("ipcMain.handle(\n 'pty:kill'", rendererSpawnStart)
// Why `ipc.` and not `ipcMain.`: the module registers against an injected surface now
// (pty-host-bindings) so it can run without electron. The ordering this asserts is unchanged.
const rendererSpawnStart = source.indexOf("ipc.handle(\n 'pty:spawn'")
const rendererSpawnEnd = source.indexOf("ipc.handle(\n 'pty:kill'", rendererSpawnStart)
const rendererSpawn = source.slice(rendererSpawnStart, rendererSpawnEnd)
for (const spawnBlock of [runtimeSpawn, rendererSpawn]) {
+161 -162
View File
@@ -1,16 +1,11 @@
/* eslint-disable max-lines -- Why: PTY IPC is centralized in one main-process module so spawn env scoping, lifecycle cleanup, process inspection, and renderer IPC stay behind one audited boundary. */
import { join, delimiter } from 'node:path'
import { getAppEnvironment } from '../../shared/app-environment'
import { getPtyIpc, getPtyPower, type PtyPowerSurface } from './pty-host-bindings'
import { isRendererGone, rendererWebContents, sendToRenderer } from './pty-renderer-surface'
import { randomUUID } from 'node:crypto'
import { statSync } from 'node:fs'
import {
type BrowserWindow,
type IpcMainEvent,
type IpcMainInvokeEvent,
type WebContents,
ipcMain,
app,
powerMonitor
} from 'electron'
import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron'
export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { PtyBindingSourceExpectation, Store } from '../persistence'
@@ -2280,16 +2275,16 @@ let lastPowerResumeAtMs: number | null = null
let powerSignalBreadcrumbsInstalled = false
// Why: both field freeze variants correlate with display sleep; suspend/resume timestamps let breadcrumbs line up against the wake.
function installPowerSignalBreadcrumbs(): void {
function installPowerSignalBreadcrumbs(power: PtyPowerSurface): void {
if (powerSignalBreadcrumbsInstalled) {
return
}
powerSignalBreadcrumbsInstalled = true
powerMonitor.on('suspend', () => {
power.on('suspend', () => {
lastPowerSuspendAtMs = Date.now()
mainDeliveryBreadcrumbs.record('power-suspend')
})
powerMonitor.on('resume', () => {
power.on('resume', () => {
lastPowerResumeAtMs = Date.now()
mainDeliveryBreadcrumbs.record('power-resume')
})
@@ -2432,7 +2427,7 @@ export function unbindLocalProviderListeners(): void {
// ─── IPC Registration ───────────────────────────────────────────────
export function registerPtyHandlers(
mainWindow: BrowserWindow,
mainWindow: BrowserWindow | null,
runtime?: OrcaRuntimeService,
getSelectedCodexHomePath?: GetSelectedCodexHomePath,
getSettings?: () => GlobalSettings,
@@ -2448,12 +2443,16 @@ export function registerPtyHandlers(
onPtyExit?: (id: string, exitSequence: number) => void
}
): void {
const ipc = getPtyIpc()
// Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure.
clearRendererDispatcherReadyWatchdog()
resetRendererDeliveryAccountingForLifecycleReset = () => {}
invalidatePendingPtyDrainPriority = () => {}
invalidatePendingPtyDrainPolicy = () => {}
registerRendererLifecycleResetHandlers(mainWindow.webContents)
const rendererContents = rendererWebContents(mainWindow)
if (rendererContents) {
registerRendererLifecycleResetHandlers(rendererContents)
}
const getLocalPtyStartupPromise = (connectionId?: string | null): Promise<void> | undefined => {
if (connectionId) {
@@ -2473,32 +2472,32 @@ export function registerPtyHandlers(
}
// Remove prior handlers so re-registration (e.g. macOS re-activate creating a new window) doesn't double-register.
ipcMain.removeHandler('pty:spawn')
ipcMain.removeHandler('pty:kill')
ipcMain.removeHandler('pty:listSessions')
ipcMain.removeHandler('pty:hasPty')
ipcMain.removeHandler('pty:hasChildProcesses')
ipcMain.removeHandler('pty:getForegroundProcess')
ipcMain.removeHandler('pty:inspectProcess')
ipcMain.removeHandler('pty:confirmForegroundProcess')
ipcMain.removeHandler('pty:getCwd')
ipcMain.removeHandler('pty:getSize')
ipcMain.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities')
ipcMain.removeHandler('pty:declarePendingPaneSerializer')
ipcMain.removeHandler('pty:settlePaneSerializer')
ipcMain.removeHandler('pty:clearPendingPaneSerializer')
ipcMain.removeHandler('pty:reportRendererSerializerReady')
ipcMain.removeHandler('pty:getMainBufferSnapshot')
ipcMain.removeHandler('pty:sideEffectSnapshot')
ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot')
ipcMain.removeHandler('pty:resetRendererDeliveryDebug')
ipcMain.removeHandler('pty:reportRendererDeliveryState')
ipcMain.removeHandler('pty:writeAccepted')
ipcMain.removeAllListeners('pty:write')
ipcMain.removeAllListeners('pty:ackColdRestore')
ipcMain.removeAllListeners('pty:ackData')
ipcMain.removeAllListeners('pty:deliveryResyncResponse')
ipcMain.removeAllListeners('pty:serializeBuffer:response')
ipc.removeHandler('pty:spawn')
ipc.removeHandler('pty:kill')
ipc.removeHandler('pty:listSessions')
ipc.removeHandler('pty:hasPty')
ipc.removeHandler('pty:hasChildProcesses')
ipc.removeHandler('pty:getForegroundProcess')
ipc.removeHandler('pty:inspectProcess')
ipc.removeHandler('pty:confirmForegroundProcess')
ipc.removeHandler('pty:getCwd')
ipc.removeHandler('pty:getSize')
ipc.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities')
ipc.removeHandler('pty:declarePendingPaneSerializer')
ipc.removeHandler('pty:settlePaneSerializer')
ipc.removeHandler('pty:clearPendingPaneSerializer')
ipc.removeHandler('pty:reportRendererSerializerReady')
ipc.removeHandler('pty:getMainBufferSnapshot')
ipc.removeHandler('pty:sideEffectSnapshot')
ipc.removeHandler('pty:getRendererDeliveryDebugSnapshot')
ipc.removeHandler('pty:resetRendererDeliveryDebug')
ipc.removeHandler('pty:reportRendererDeliveryState')
ipc.removeHandler('pty:writeAccepted')
ipc.removeAllListeners('pty:write')
ipc.removeAllListeners('pty:ackColdRestore')
ipc.removeAllListeners('pty:ackData')
ipc.removeAllListeners('pty:deliveryResyncResponse')
ipc.removeAllListeners('pty:serializeBuffer:response')
// Why: only LocalPtyProvider needs main-process hook injection; daemon-backed providers spawn subprocesses internally.
if (localProvider instanceof LocalPtyProvider) {
@@ -2527,9 +2526,9 @@ export function registerPtyHandlers(
const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath
const ptySettings = getSettings?.()
const env = buildPtyHostEnv(id, baseEnv, {
isPackaged: app.isPackaged,
isPackaged: getAppEnvironment().isPackaged(),
resourcesPath: process.resourcesPath,
userDataPath: app.getPath('userData'),
userDataPath: getAppEnvironment().getPath('userData'),
selectedCodexHomePath,
skipCodexHomeEnv,
stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({
@@ -2879,13 +2878,13 @@ export function registerPtyHandlers(
})
}
perPty.sort((a, b) => b.inFlightChars + b.pendingChars - (a.inFlightChars + a.pendingChars))
const windowAlive = !mainWindow.isDestroyed()
const windowAlive = !isRendererGone(mainWindow)
return {
appVersion: app.getVersion(),
appVersion: getAppEnvironment().getVersion(),
mainUptimeMs: Math.round(process.uptime() * 1000),
windowFocused: windowAlive ? mainWindow.isFocused() : null,
windowVisible: windowAlive ? mainWindow.isVisible() : null,
windowMinimized: windowAlive ? mainWindow.isMinimized() : null,
windowFocused: windowAlive ? mainWindow!.isFocused() : null,
windowVisible: windowAlive ? mainWindow!.isVisible() : null,
windowMinimized: windowAlive ? mainWindow!.isMinimized() : null,
msSinceLastPowerSuspend: lastPowerSuspendAtMs === null ? null : now - lastPowerSuspendAtMs,
msSinceLastPowerResume: lastPowerResumeAtMs === null ? null : now - lastPowerResumeAtMs,
perPty: perPty.slice(0, DELIVERY_DIAGNOSTICS_MAX_PTYS),
@@ -3082,7 +3081,7 @@ export function registerPtyHandlers(
// Why: data for a fully gated PTY signals delivery may be stuck on lost ACKs (e.g. dropped across suspend); ask the renderer for authoritative totals instead of a wall-clock guess.
function requestDeliveryResyncForGatedPty(): void {
if (deliveryResyncOutstandingRequestId !== null || mainWindow.isDestroyed()) {
if (deliveryResyncOutstandingRequestId !== null || isRendererGone(mainWindow)) {
return
}
deliveryResyncRequestSerial += 1
@@ -3104,7 +3103,7 @@ export function registerPtyHandlers(
})
}, PTY_DELIVERY_RESYNC_TIMEOUT_MS)
deliveryResyncTimer.unref?.()
mainWindow.webContents.send('pty:requestDeliveryResync', { requestId })
sendToRenderer(mainWindow, 'pty:requestDeliveryResync', { requestId })
}
// Why write off: bytes sent but never received after a confirmed wedge are gone (no ACK can repay them); hand back restore markers so panes repaint from the snapshot.
@@ -3188,7 +3187,7 @@ export function registerPtyHandlers(
rendererInFlightTotalChars += charCount
recordPtyRendererDeliveryPressure(id)
try {
mainWindow.webContents.send('pty:data', payload)
sendToRenderer(mainWindow, 'pty:data', payload)
} catch (error) {
const current = rendererDeliveryAccountingByPty.get(id)
if (current) {
@@ -3275,10 +3274,10 @@ export function registerPtyHandlers(
reason: PtyModelRestoreReason,
markerSeq: number | undefined
): void {
if (mainWindow.isDestroyed()) {
if (isRendererGone(mainWindow)) {
return
}
mainWindow.webContents.send('pty:modelRestoreNeeded', {
sendToRenderer(mainWindow, 'pty:modelRestoreNeeded', {
id,
reason,
...(typeof markerSeq === 'number' ? { markerSeq } : {})
@@ -3475,13 +3474,13 @@ export function registerPtyHandlers(
function armDispatcherReadyWatchdog(): void {
clearDispatcherReadyWatchdog()
if (mainWindow.isDestroyed()) {
if (isRendererGone(mainWindow)) {
return
}
// Why: one-shot self-heal — force the gate open if the reloaded page never signals ready, so a dropped handshake can't hold it forever. Unref'd so it can't keep the process alive.
dispatcherReadyWatchdogTimer = setTimeout(() => {
dispatcherReadyWatchdogTimer = null
if (rendererPtyDispatcherReady || mainWindow.isDestroyed()) {
if (rendererPtyDispatcherReady || isRendererGone(mainWindow)) {
return
}
rendererPtyDispatcherReady = true
@@ -3494,7 +3493,7 @@ export function registerPtyHandlers(
function flushPendingData(): void {
flushTimer = null
if (mainWindow.isDestroyed()) {
if (isRendererGone(mainWindow)) {
// Why release now: bookkeeping is being wiped, so no future drain can resume these producers — local shells would wedge.
producerFlowControl.releaseAll()
clearDeliveryResyncProbe()
@@ -3688,7 +3687,7 @@ export function registerPtyHandlers(
}
function preparePtyExitForRenderer(payload: { id: string; code: number }): (() => void) | null {
if (mainWindow.isDestroyed()) {
if (isRendererGone(mainWindow)) {
sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed')
return () => {}
}
@@ -3749,7 +3748,7 @@ export function registerPtyHandlers(
}
function finalizePtyExitForRenderer(payload: { id: string; code: number }): void {
if (mainWindow.isDestroyed()) {
if (isRendererGone(mainWindow)) {
rendererCreditBeforeExitByPty.delete(payload.id)
return
}
@@ -3777,7 +3776,7 @@ export function registerPtyHandlers(
schedulePendingDataAfterCreditReport(true)
}
}
mainWindow.webContents.send('pty:exit', {
sendToRenderer(mainWindow, 'pty:exit', {
...payload,
...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {})
})
@@ -3798,8 +3797,8 @@ export function registerPtyHandlers(
}
function sendPtySpawnedToRenderer(id: string): void {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:spawned', { id })
if (!isRendererGone(mainWindow)) {
sendToRenderer(mainWindow, 'pty:spawned', { id })
}
}
@@ -3817,7 +3816,7 @@ export function registerPtyHandlers(
const preservesSeq = !payload.transformed && rawLength === payload.data.length
const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined
const projectionId = projection?.identity.projectionSemanticsId
if (mainWindow.isDestroyed()) {
if (isRendererGone(mainWindow)) {
if (projectionId) {
sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed')
}
@@ -4065,14 +4064,10 @@ export function registerPtyHandlers(
// just the pane whose write happened to detect the dead endpoint (STA-2373).
localWriteUnavailableUnsub =
localProvider.onWriteUnavailable?.((payload) => {
if (
mainWindow.isDestroyed() ||
(typeof mainWindow.webContents.isDestroyed === 'function' &&
mainWindow.webContents.isDestroyed())
) {
if (isRendererGone(mainWindow)) {
return
}
mainWindow.webContents.send('pty:writeUnavailable', { id: payload.id })
sendToRenderer(mainWindow, 'pty:writeUnavailable', { id: payload.id })
}) ?? null
// Daemon keep-tail thinning facts, in byte order with onData: markers flip transient-fact scan authority; a gap forces renderer restore from the snapshot.
@@ -4165,7 +4160,7 @@ export function registerPtyHandlers(
pending.resolve(result)
}
ipcMain.on(
ipc.on(
'pty:serializeBuffer:response',
(
_event,
@@ -4226,7 +4221,7 @@ export function registerPtyHandlers(
ptyId: string,
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
): Promise<SerializeResult> {
if (mainWindow.isDestroyed()) {
if (isRendererGone(mainWindow)) {
return Promise.resolve(null)
}
@@ -4244,7 +4239,7 @@ export function registerPtyHandlers(
if (opts) {
payload.opts = opts
}
mainWindow.webContents.send('pty:serializeBuffer:request', payload)
sendToRenderer(mainWindow, 'pty:serializeBuffer:request', payload)
})
}
@@ -4261,9 +4256,13 @@ export function registerPtyHandlers(
}
rendererGateResetLoadHandler = resetRendererPtyDeliveryGateState
rendererGateResetGoneHandler = resetRendererPtyDeliveryGateState
rendererGateResetWebContents = mainWindow.webContents
mainWindow.webContents.on('did-finish-load', rendererGateResetLoadHandler)
mainWindow.webContents.on('render-process-gone', rendererGateResetGoneHandler)
// Why guarded: with no renderer there is nothing to reset a gate for, and nothing
// that will ever emit these. Registering against a fake window was the old workaround.
if (rendererContents) {
rendererGateResetWebContents = rendererContents
rendererContents.on('did-finish-load', rendererGateResetLoadHandler)
rendererContents.on('render-process-gone', rendererGateResetGoneHandler)
}
// Why: only LocalPtyProvider PTYs (main-process) can be orphaned on reload; daemon sessions survive by design and cleanup would kill them.
clearDidFinishLoadHandler()
@@ -4272,14 +4271,16 @@ export function registerPtyHandlers(
didFinishLoadHandler = () => {
// Why: always advance to keep the generation monotonic, but skip the sweep on crash/freeze-recovery reload — it would kill live local PTYs before session restore (#5787).
const generation = lp.advanceGeneration()
if (options?.isRecoveryReloadInFlight?.(mainWindow.webContents.id)) {
if (rendererContents && options?.isRecoveryReloadInFlight?.(rendererContents.id)) {
return
}
// Why: the retained provider onExit callback is the only physical-exit proof; it clears ownership after the OS reaps it.
lp.killOrphanedPtys(generation - 1)
}
didFinishLoadWebContents = mainWindow.webContents
mainWindow.webContents.on('did-finish-load', didFinishLoadHandler)
if (rendererContents) {
didFinishLoadWebContents = rendererContents
rendererContents.on('did-finish-load', didFinishLoadHandler)
}
}
const assertFolderWorkspacePtyPathUsable = async (
@@ -4801,13 +4802,13 @@ export function registerPtyHandlers(
settings: ptySettings
})
if (isDaemonHostSpawn && sessionId && !preAdoptedStablePane) {
if (!isSafePtySessionId(sessionId, app.getPath('userData'))) {
if (!isSafePtySessionId(sessionId, getAppEnvironment().getPath('userData'))) {
throw new Error('Invalid PTY session id')
}
env = buildPtyHostEnv(sessionId, env ?? {}, {
isPackaged: app.isPackaged,
isPackaged: getAppEnvironment().isPackaged(),
resourcesPath: process.resourcesPath,
userDataPath: app.getPath('userData'),
userDataPath: getAppEnvironment().getPath('userData'),
selectedCodexHomePath,
skipCodexHomeEnv,
stripInheritedOrcaCodexHome,
@@ -5868,7 +5869,7 @@ export function registerPtyHandlers(
},
clearBuffer: async (ptyId) => {
// Why: desktop xterm and daemon/SSH providers hold separate buffers; clear both so mobile resubscribe can't resurrect cleared history.
mainWindow.webContents.send('pty:clearBuffer:request', { ptyId })
sendToRenderer(mainWindow, 'pty:clearBuffer:request', { ptyId })
try {
await getProviderForPty(ptyId).clearBuffer(ptyId)
} catch {
@@ -5942,7 +5943,7 @@ export function registerPtyHandlers(
return Math.max(0, Math.min(50_000, Math.floor(value)))
}
ipcMain.handle(
ipc.handle(
'pty:getMainBufferSnapshot',
async (
_event,
@@ -6006,22 +6007,22 @@ export function registerPtyHandlers(
)
// Why: main owns side effects, so this replay restores title state only — never historical bells/completions (no-attention-replay rule, terminal-side-effect-authority.md).
ipcMain.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => {
ipc.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => {
if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) {
return null
}
return runtime.getTerminalSideEffectSnapshot(args.id)
})
installPowerSignalBreadcrumbs()
ipcMain.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => {
installPowerSignalBreadcrumbs(getPtyPower())
ipc.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => {
return getPtyRendererDeliveryDebugSnapshot()
})
ipcMain.handle('pty:resetRendererDeliveryDebug', (): void => {
ipc.handle('pty:resetRendererDeliveryDebug', (): void => {
resetPtyRendererDeliveryDebug()
})
ipcMain.handle(
ipc.handle(
'pty:spawn',
async (
_event,
@@ -6544,16 +6545,16 @@ export function registerPtyHandlers(
}
const sessionIdForEnv = effectiveSessionId
// Why: this id reaches filesystem paths; reject traversal/separators so a crafted IPC payload can't escape the expected roots.
if (!isSafePtySessionId(sessionIdForEnv, app.getPath('userData'))) {
if (!isSafePtySessionId(sessionIdForEnv, getAppEnvironment().getPath('userData'))) {
throw new Error('Invalid PTY session id')
}
// Why: clone before mutating so injections don't leak back into args.env (renderer may reuse it).
env = { ...baseEnv }
try {
buildPtyHostEnv(sessionIdForEnv, env, {
isPackaged: app.isPackaged,
isPackaged: getAppEnvironment().isPackaged(),
resourcesPath: process.resourcesPath,
userDataPath: app.getPath('userData'),
userDataPath: getAppEnvironment().getPath('userData'),
selectedCodexHomePath,
skipCodexHomeEnv,
stripInheritedOrcaCodexHome,
@@ -7232,15 +7233,10 @@ export function registerPtyHandlers(
)
const reportUnavailablePtyWrite = (id: string, error: unknown): void => {
if (
!isPtyWriteUnavailableError(error) ||
mainWindow.isDestroyed() ||
(typeof mainWindow.webContents.isDestroyed === 'function' &&
mainWindow.webContents.isDestroyed())
) {
if (!isPtyWriteUnavailableError(error) || isRendererGone(mainWindow)) {
return
}
mainWindow.webContents.send('pty:writeUnavailable', { id })
sendToRenderer(mainWindow, 'pty:writeUnavailable', { id })
}
const writePtyProviderInputWithinLimit = (
@@ -7331,12 +7327,16 @@ export function registerPtyHandlers(
(value as { cols: number }).cols > 0 &&
(value as { rows: number }).rows > 0
// Why null-tolerant: with no renderer there is no sender that can legitimately match,
// so every write is rejected. These handlers cannot fire headless anyway — ipcMain has
// nothing to deliver from — but failing closed is the right answer if that ever changes.
const isPtyWriteEventFromMainWindow = (
event: IpcMainEvent | IpcMainInvokeEvent,
mainWebContents: WebContents
mainWebContents: WebContents | null
): boolean =>
mainWebContents !== null &&
event.sender === mainWebContents &&
!mainWindow.isDestroyed() &&
!isRendererGone(mainWindow) &&
!(typeof mainWebContents.isDestroyed === 'function' && mainWebContents.isDestroyed())
const writePtyInput = (args: PtyWritePayload): boolean | Promise<boolean> => {
@@ -7388,8 +7388,11 @@ export function registerPtyHandlers(
const hostViewportClaimTails = new Map<string, Promise<boolean>>()
ipcMain.on('pty:write', (event, args: unknown) => {
if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) {
ipc.on('pty:write', (event, args: unknown) => {
if (
!isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) ||
!isPtyWritePayload(args)
) {
return
}
const claimTail = hostViewportClaimTails.get(args.id)
@@ -7399,8 +7402,11 @@ export function registerPtyHandlers(
}
writePtyInput(args)
})
ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise<boolean> => {
if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) {
ipc.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise<boolean> => {
if (
!isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) ||
!isPtyWritePayload(args)
) {
return false
}
const claimTail = hostViewportClaimTails.get(args.id)
@@ -7409,10 +7415,10 @@ export function registerPtyHandlers(
: writePtyInputAccepted(args)
})
ipcMain.removeAllListeners('pty:claimViewport')
ipcMain.on('pty:claimViewport', (event, args: unknown) => {
ipc.removeAllListeners('pty:claimViewport')
ipc.on('pty:claimViewport', (event, args: unknown) => {
if (
!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) ||
!isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) ||
!runtime ||
!isPtyViewportClaimPayload(args)
) {
@@ -7436,9 +7442,9 @@ export function registerPtyHandlers(
})
})
// Why: resize is fire-and-forget — ipcMain.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply.
ipcMain.removeAllListeners('pty:resize')
ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => {
// Why: resize is fire-and-forget — ipc.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply.
ipc.removeAllListeners('pty:resize')
ipc.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => {
// Why: after a desktop-fit override change the renderer's safeFit cascade re-measures ALL panes (background ones at full width), so suppress every pty:resize in this window to avoid corrupting PTY dimensions.
if (runtime?.isResizeSuppressed()) {
return
@@ -7478,13 +7484,13 @@ export function registerPtyHandlers(
})
// Why: pty:reportGeometry is a measurement-only sibling of pty:resize — it refreshes the restore-target cache (never resizes) so mobile-fit hold learns real desktop dims even while resize is blocked. See docs/mobile-fit-hold.md.
ipcMain.removeAllListeners('pty:reportGeometry')
ipcMain.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => {
ipc.removeAllListeners('pty:reportGeometry')
ipc.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => {
runtime?.recordRendererGeometry(args.id, args.cols, args.rows)
})
// Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold-restore cache after the renderer consumed it; no-op for non-daemon providers.
ipcMain.on('pty:ackColdRestore', (_event, args: { id: string }) => {
ipc.on('pty:ackColdRestore', (_event, args: { id: string }) => {
const provider = tryGetProviderForPty(args.id)
if (provider && 'ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') {
provider.ackColdRestore(args.id)
@@ -7492,7 +7498,7 @@ export function registerPtyHandlers(
})
// Why: renderer ACKs bound main→renderer delivery without stopping PTY ingestion — agent/status consumers still see every chunk via the provider/runtime path.
ipcMain.on(
ipc.on(
'pty:ackData',
(_event, args: { id: string; charCount?: number; processedChars?: number }) => {
lastAckReceivedAtMs = Date.now()
@@ -7512,7 +7518,7 @@ export function registerPtyHandlers(
}
)
ipcMain.on(
ipc.on(
'pty:deliveryResyncResponse',
(_event, args: { requestId: number; processedCharsByPty: Record<string, number> }) => {
if (
@@ -7540,7 +7546,7 @@ export function registerPtyHandlers(
)
// Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0) kills every main→renderer push channel while invoke survives, so the resync rides here plus a write-off lane.
ipcMain.handle(
ipc.handle(
'pty:reportRendererDeliveryState',
(_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => {
// Extra repair lane for the lost-ACK variant: identical max-merge to the resync response, so a heal is only reached when merging cannot drain.
@@ -7583,10 +7589,10 @@ export function registerPtyHandlers(
)
// Why: renderer signals its pty:data listener is live; until then sends are held so boot-window bytes can't drop into a listener-less page and pin the gate.
ipcMain.removeAllListeners('pty:rendererDispatcherReady')
ipcMain.on('pty:rendererDispatcherReady', (event) => {
ipc.removeAllListeners('pty:rendererDispatcherReady')
ipc.on('pty:rendererDispatcherReady', (event) => {
// Why: the reconcile below destructively clears delivery accounting, so a straggler handshake from a dying window must not reset the new window.
if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents)) {
if (!isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow))) {
return
}
// Why: a handshake while the gate is already open means a page load whose lifecycle reset was missed; clear the dead page's stale accounting so it can't permanently gate survivors.
@@ -7600,8 +7606,8 @@ export function registerPtyHandlers(
schedulePendingDataFlush(0)
})
ipcMain.removeAllListeners('pty:setActiveRendererPty')
ipcMain.on('pty:setActiveRendererPty', (_event, args: { id: string; active: boolean }) => {
ipc.removeAllListeners('pty:setActiveRendererPty')
ipc.on('pty:setActiveRendererPty', (_event, args: { id: string; active: boolean }) => {
if (typeof args.id !== 'string' || !args.id) {
return
}
@@ -7617,8 +7623,8 @@ export function registerPtyHandlers(
invalidatePendingPtyDrainPriority(args.id)
})
ipcMain.removeAllListeners('pty:setRendererPtyVisible')
ipcMain.on('pty:setRendererPtyVisible', (_event, args: { id: string; visible: boolean }) => {
ipc.removeAllListeners('pty:setRendererPtyVisible')
ipc.on('pty:setRendererPtyVisible', (_event, args: { id: string; visible: boolean }) => {
if (typeof args.id !== 'string' || !args.id) {
return
}
@@ -7633,8 +7639,8 @@ export function registerPtyHandlers(
syncPtyBackgroundedDelivery(args.id, 'visibility-report')
})
ipcMain.removeAllListeners('pty:setHiddenRendererPty')
ipcMain.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => {
ipc.removeAllListeners('pty:setHiddenRendererPty')
ipc.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => {
if (typeof args.id !== 'string' || !args.id) {
return
}
@@ -7678,8 +7684,8 @@ export function registerPtyHandlers(
}
})
ipcMain.removeAllListeners('pty:terminalViewAttributes')
ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => {
ipc.removeAllListeners('pty:terminalViewAttributes')
ipc.on('pty:terminalViewAttributes', (_event, args: unknown) => {
// Why validate-or-drop: a malformed palette gives a wrong color reply that breaks TUI theme detection worse than the silent-until-first-push default.
const attributes = validateTerminalViewAttributes(args)
if (attributes) {
@@ -7687,8 +7693,8 @@ export function registerPtyHandlers(
}
})
ipcMain.removeAllListeners('pty:setPtyDeliveryInterest')
ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => {
ipc.removeAllListeners('pty:setPtyDeliveryInterest')
ipc.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => {
if (typeof args.id !== 'string' || !args.id) {
return
}
@@ -7701,15 +7707,15 @@ export function registerPtyHandlers(
}
})
ipcMain.removeAllListeners('pty:signal')
ipcMain.on('pty:signal', (_event, args: { id: string; signal: string }) => {
ipc.removeAllListeners('pty:signal')
ipc.on('pty:signal', (_event, args: { id: string; signal: string }) => {
tryGetProviderForPty(args.id)
?.sendSignal(args.id, args.signal)
.catch(() => {})
})
ipcMain.removeAllListeners('pty:clearBuffer')
ipcMain.on('pty:clearBuffer', (_event, args: { id: string }) => {
ipc.removeAllListeners('pty:clearBuffer')
ipc.on('pty:clearBuffer', (_event, args: { id: string }) => {
// Why: clear PTY-side state (ConPTY/daemon/SSH buffer) so the next prompt repaint doesn't land at a stale cursor row.
tryGetProviderForPty(args.id)
?.clearBuffer(args.id)
@@ -7717,7 +7723,7 @@ export function registerPtyHandlers(
runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {})
})
ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => {
ipc.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => {
if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) {
// Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider.
throw new Error('Invalid PTY provider id')
@@ -7767,7 +7773,7 @@ export function registerPtyHandlers(
}
})
ipcMain.handle('pty:listSessions', async (): Promise<PtyListedSession[]> => {
ipc.handle('pty:listSessions', async (): Promise<PtyListedSession[]> => {
const deduped = new Map<string, PtyListedSession>()
const admission = new PtyProcessListAdmission()
await visitPtyProcessListingsInBatches(
@@ -7799,7 +7805,7 @@ export function registerPtyHandlers(
return Array.from(deduped.values())
})
ipcMain.handle(
ipc.handle(
'pty:getAuthoritativeBufferSnapshotCapabilities',
async (_event, args: { ids?: unknown }) => {
const ids = Array.isArray(args?.ids) ? args.ids.slice(0, 512) : []
@@ -7847,7 +7853,7 @@ export function registerPtyHandlers(
}
)
ipcMain.handle('pty:hasPty', async (_event, args: { id: string }): Promise<boolean | null> => {
ipc.handle('pty:hasPty', async (_event, args: { id: string }): Promise<boolean | null> => {
if (typeof args?.id !== 'string' || args.id.startsWith('remote:')) {
// Why: same routing hazard pty:kill guards against — ptyOwnership never holds
// a runtime terminal handle and parseAppSshPtyId ignores it, so the lookup
@@ -7871,17 +7877,14 @@ export function registerPtyHandlers(
}
})
ipcMain.handle(
'pty:hasChildProcesses',
async (_event, args: { id: string }): Promise<boolean> => {
if (!hasPtyProviderForInspection(args.id)) {
return false
}
return getProviderForPty(args.id).hasChildProcesses(args.id)
ipc.handle('pty:hasChildProcesses', async (_event, args: { id: string }): Promise<boolean> => {
if (!hasPtyProviderForInspection(args.id)) {
return false
}
)
return getProviderForPty(args.id).hasChildProcesses(args.id)
})
ipcMain.handle(
ipc.handle(
'pty:getForegroundProcess',
async (_event, args: { id: string }): Promise<string | null> => {
if (!hasPtyProviderForInspection(args.id)) {
@@ -7891,11 +7894,11 @@ export function registerPtyHandlers(
}
)
ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) =>
ipc.handle('pty:inspectProcess', async (_event, args: { id: string }) =>
inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id)
)
ipcMain.handle(
ipc.handle(
'pty:confirmForegroundProcess',
async (_event, args: { id: string }): Promise<string | null> => {
if (!hasPtyProviderForInspection(args.id)) {
@@ -7908,7 +7911,7 @@ export function registerPtyHandlers(
)
// Why: Cmd+D split needs the live shell cwd so the new pane inherits it (not the worktree root); '' means unknown/unresolvable (Windows) → renderer falls through.
ipcMain.handle('pty:getCwd', async (_event, args: { id: string }): Promise<string> => {
ipc.handle('pty:getCwd', async (_event, args: { id: string }): Promise<string> => {
try {
return await getProviderForPty(args.id).getCwd(args.id)
} catch {
@@ -7917,7 +7920,7 @@ export function registerPtyHandlers(
})
// Why: prefer the provider's APPLIED size over the requested ptySizes so the renderer's resume drift-check can spot a dropped resize; null means "cannot confirm" → re-forward once.
ipcMain.handle(
ipc.handle(
'pty:getSize',
async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => {
const provider = tryGetProviderForPty(args.id)
@@ -7936,7 +7939,7 @@ export function registerPtyHandlers(
)
// Pre-signal handshake handlers (declare→spawn→settle/clear); see docs/mobile-prefer-renderer-scrollback.md and `pendingByPaneKey` above.
ipcMain.handle(
ipc.handle(
'pty:declarePendingPaneSerializer',
async (event, args: { paneKey?: unknown }): Promise<number> => {
if (!isValidPaneKey(args.paneKey)) {
@@ -7946,7 +7949,7 @@ export function registerPtyHandlers(
}
)
ipcMain.handle(
ipc.handle(
'pty:settlePaneSerializer',
async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise<void> => {
if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') {
@@ -7962,7 +7965,7 @@ export function registerPtyHandlers(
}
)
ipcMain.handle(
ipc.handle(
'pty:clearPendingPaneSerializer',
async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise<void> => {
if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') {
@@ -7973,7 +7976,7 @@ export function registerPtyHandlers(
}
)
ipcMain.handle(
ipc.handle(
'pty:reportRendererSerializerReady',
async (_event, args: { ptyId?: unknown }): Promise<void> => {
if (
@@ -8001,17 +8004,13 @@ export function registerHeadlessPtyRuntime(
onPtyExit?: (id: string, exitSequence: number) => void
}
): void {
// Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals.
const headlessWindow = {
isDestroyed: () => true,
webContents: {
send: () => {},
on: () => {},
removeListener: () => {}
}
} as unknown as BrowserWindow
// Why null and not a stub window: headless `orca serve` has no renderer but needs the
// same PTY handlers so remote clients can drive terminals. This used to pass a fake
// BrowserWindow whose isDestroyed() returned true — a window-shaped object that lied
// about being a window. `null` says the same thing honestly, and keeps `electron` out
// of this path entirely.
registerPtyHandlers(
headlessWindow,
null,
runtime,
getSelectedCodexHomePath,
getSettings,
@@ -26,6 +26,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
@@ -26,6 +26,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
@@ -30,6 +30,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
+1
View File
@@ -29,6 +29,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
+1
View File
@@ -29,6 +29,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
@@ -26,6 +26,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
@@ -26,6 +26,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
+1
View File
@@ -27,6 +27,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
)
vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks))
vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks))
import { registerRepoHandlers } from './repos'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
@@ -1,4 +1,4 @@
import { getActiveMultiplexer } from './ssh'
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
const SSH_CONNECTION_UNAVAILABLE_MESSAGE =
'SSH connection is not available. Please reconnect and try again.'
+62 -60
View File
@@ -9,7 +9,6 @@ import {
} from '../ssh/ssh-config-host-picker'
import type { SshConnection, SshConnectionCallbacks } from '../ssh/ssh-connection'
import { SshConnectionManager } from '../ssh/ssh-connection-manager'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session'
import type {
SshAiVaultRelayListParams,
@@ -30,6 +29,29 @@ import type {
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants'
import { quitTeardownStartGate } from '../quit-teardown-start-gate'
import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host'
import {
getSshTargetRegistryStore,
setSshActiveMultiplexerResolver,
setSshTargetRegistryHandlers,
setSshTargetRegistryStore
} from '../ssh/ssh-target-registry'
// Why at module scope: this resolver is pure state lookup with no handler lifecycle, so
// installing it on import keeps it correct even before registerSshHandlers runs.
setSshActiveMultiplexerResolver(
(connectionId) => activeSessions.get(connectionId)?.getMux() ?? undefined
)
// Why re-exported: the registry moved to ../ssh/ssh-target-registry so the runtime can
// read it without pulling ipcMain in, but many existing importers reference these from
// here. Re-exporting keeps them working without a repo-wide rename.
export {
connectRegisteredSshTarget,
getActiveMultiplexer,
getRegisteredSshState,
listRegisteredRemovedSshTargetLabels,
listRegisteredSshTargets
} from '../ssh/ssh-target-registry'
import { isAuthError } from '../ssh/ssh-connection-utils'
import { createCancelledConnectAttemptError } from '../ssh/ssh-connect-attempt-cancellation'
import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset'
@@ -62,11 +84,8 @@ import {
rotateSshProviderAuthority
} from '../ssh/ssh-provider-authority'
let sshStore: SshConnectionStore | null = null
let connectionManager: SshConnectionManager | null = null
let portForwardManager: SshPortForwardManager | null = null
let registeredConnectSshTarget: ((targetId: string) => Promise<SshConnectionState>) | null = null
let registeredGetSshState: ((targetId: string) => SshConnectionState | undefined) | null = null
let persistedStore: Store | null = null
let advertisedUrlWatcherUnsubscribe: (() => void) | null = null
let powerMonitorUnsubscribe: (() => void) | null = null
@@ -103,27 +122,6 @@ function getCurrentMainWindow(): BrowserWindow | null {
return currentGetMainWindow()
}
export async function connectRegisteredSshTarget(targetId: string): Promise<SshConnectionState> {
if (!registeredConnectSshTarget) {
throw new Error('ssh_handlers_not_registered')
}
return registeredConnectSshTarget(targetId)
}
export function getRegisteredSshState(targetId: string): SshConnectionState | undefined {
return registeredGetSshState?.(targetId)
}
/** Public targets for runtime RPC clients — same list the desktop renderer gets. */
export function listRegisteredSshTargets(): SshTarget[] {
return sshStore?.listTargets() ?? []
}
/** Removed-target id → last known label, for ghost-host display on paired clients. */
export function listRegisteredRemovedSshTargetLabels(): Record<string, string> {
return sshStore?.listRemovedTargetLabels() ?? {}
}
export async function disconnectRegisteredSshTarget(targetId: string): Promise<void> {
invalidateConnectAttempt(targetId)
await runTargetLifecycle(targetId, () =>
@@ -132,10 +130,10 @@ export async function disconnectRegisteredSshTarget(targetId: string): Promise<v
}
export async function removeRegisteredSshTarget(targetId: string): Promise<void> {
if (!sshStore) {
const store = getSshTargetRegistryStore()
if (!store) {
return
}
const store = sshStore
invalidateConnectAttempt(targetId)
await runTargetLifecycle(targetId, async () => {
try {
@@ -528,7 +526,9 @@ function persistPortForwards(targetId: string): void {
remotePort: f.remotePort,
label: f.label
}))
sshStore!.updateTarget(targetId, { portForwards: saved.length > 0 ? saved : undefined })
getSshTargetRegistryStore()!.updateTarget(targetId, {
portForwards: saved.length > 0 ? saved : undefined
})
}
// Why: keep forwards that failed to restore in the persisted list so they retry on next reconnect instead of being silently dropped.
@@ -536,7 +536,7 @@ function persistPortForwardsWithUnrestored(targetId: string): void {
const active = portForwardManager!.listForwards(targetId)
const activeKeys = new Set(active.map((f) => `${f.localPort}:${f.remoteHost}:${f.remotePort}`))
const existing = sshStore!.getTarget(targetId)?.portForwards ?? []
const existing = getSshTargetRegistryStore()!.getTarget(targetId)?.portForwards ?? []
const unrestored = existing.filter(
(pf) => !activeKeys.has(`${pf.localPort}:${pf.remoteHost}:${pf.remotePort}`)
)
@@ -550,14 +550,16 @@ function persistPortForwardsWithUnrestored(targetId: string): void {
})),
...unrestored
]
sshStore!.updateTarget(targetId, { portForwards: saved.length > 0 ? saved : undefined })
getSshTargetRegistryStore()!.updateTarget(targetId, {
portForwards: saved.length > 0 ? saved : undefined
})
}
async function restorePortForwards(
targetId: string,
getMainWindow: () => BrowserWindow | null
): Promise<void> {
const target = sshStore!.getTarget(targetId)
const target = getSshTargetRegistryStore()!.getTarget(targetId)
if (!target?.portForwards?.length) {
return
}
@@ -765,7 +767,7 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks {
}
// Why: allow reconnect from both 'ready' and 'reconnecting'; without the latter, a failed relay deploy would permanently brick the session.
if (shouldReconnectRelay) {
const target = sshStore?.getTarget(targetId)
const target = getSshTargetRegistryStore()?.getTarget(targetId)
const conn = connectionManager?.getConnection(targetId)
if (conn) {
void session.reconnect(conn, relayGracePeriodForTarget(target))
@@ -804,7 +806,7 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void {
if (!c) {
return
}
const t = sshStore?.getTarget(tid)
const t = getSshTargetRegistryStore()?.getTarget(tid)
// Why: bounded exponential backoff — without it, a remote bug that closes every fresh --connect channel becomes an infinite relay-deploy loop.
const state = relayLostBackoff.get(tid) ?? {
@@ -963,7 +965,7 @@ export function registerSshHandlers(
currentGetMainWindow = getMainWindow
currentRuntime = runtime
sshStore = new SshConnectionStore(store)
setSshTargetRegistryStore(new SshConnectionStore(store))
persistedStore = store
registerAdvertisedUrlRefresh(getCurrentMainWindow)
@@ -997,11 +999,12 @@ export function registerSshHandlers(
// Why: add/import can re-adopt workspaces orphaned on a removed target id (see ssh-target-readoption); the renderer must refresh its repo list to surface them.
function takeRepoReadoptions(): SshRepoReadoption[] {
if (!sshStore || sshStore.lastRepoReadoptions.length === 0) {
const store = getSshTargetRegistryStore()
if (!store || store.lastRepoReadoptions.length === 0) {
return []
}
const repoReadoptions = sshStore.lastRepoReadoptions
sshStore.lastRepoReadoptions = []
const repoReadoptions = store.lastRepoReadoptions
store.lastRepoReadoptions = []
for (const targetId of new Set(
repoReadoptions.flatMap(({ oldTargetId, newTargetId }) => [oldTargetId, newTargetId])
)) {
@@ -1015,15 +1018,15 @@ export function registerSshHandlers(
}
ipcMain.handle('ssh:listTargets', () => {
return sshStore!.listTargets()
return getSshTargetRegistryStore()!.listTargets()
})
ipcMain.handle('ssh:listRemovedTargetLabels', () => {
return sshStore!.listRemovedTargetLabels()
return getSshTargetRegistryStore()!.listRemovedTargetLabels()
})
ipcMain.handle('ssh:addTarget', (_event, args: { target: Omit<SshTarget, 'id'> }) => {
const target = sshStore!.addTarget(args.target)
const target = getSshTargetRegistryStore()!.addTarget(args.target)
// Why: re-adding a removed host can re-adopt orphaned workspaces; refresh the renderer's repo list so they move back onto the live host.
const repoReadoptions = takeRepoReadoptions()
return { target, repoReadoptions }
@@ -1032,7 +1035,7 @@ export function registerSshHandlers(
ipcMain.handle(
'ssh:updateTarget',
(_event, args: { id: string; updates: Partial<Omit<SshTarget, 'id'>> }) => {
return sshStore!.updateTarget(args.id, args.updates)
return getSshTargetRegistryStore()!.updateTarget(args.id, args.updates)
}
)
@@ -1041,7 +1044,7 @@ export function registerSshHandlers(
})
ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => {
const targets = sshStore!.importFromSshConfig(args)
const targets = getSshTargetRegistryStore()!.importFromSshConfig(args)
const repoReadoptions = takeRepoReadoptions()
return { targets, repoReadoptions }
})
@@ -1050,9 +1053,9 @@ export function registerSshHandlers(
// mutate the target store (bulk sync stays on Settings → Import).
ipcMain.handle('ssh:listConfigHosts', (_event, args?: SshConfigHostListArgs) => {
return listUserSshConfigHostSummaries(
sshStore!.listTargets(),
getSshTargetRegistryStore()!.listTargets(),
args?.query,
sshStore!.listSuppressedSshConfigAliases(),
getSshTargetRegistryStore()!.listSuppressedSshConfigAliases(),
{ refresh: args?.refresh === true }
)
})
@@ -1115,8 +1118,10 @@ export function registerSshHandlers(
}
}
registeredConnectSshTarget = connectTarget
registeredGetSshState = (targetId: string) => getPublicSshState(targetId)
setSshTargetRegistryHandlers({
connect: connectTarget,
getState: (targetId: string) => getPublicSshState(targetId)
})
ipcMain.handle('ssh:connect', async (_event, args: { targetId: string }) => {
return connectTarget(args.targetId)
@@ -1126,7 +1131,7 @@ export function registerSshHandlers(
targetId: string,
replacePendingTransport = false
): Promise<SshConnectionState> {
const target = sshStore!.getTarget(targetId)
const target = getSshTargetRegistryStore()!.getTarget(targetId)
if (!target) {
throw new Error(`SSH target "${targetId}" not found`)
}
@@ -1284,7 +1289,9 @@ export function registerSshHandlers(
// Why: persist whether this connect needed a credential so startup can partition targets into eager vs deferred without re-probing keys.
const requiredPassphrase = credentialRequestedForTarget.has(targetId)
credentialRequestedForTarget.delete(targetId)
sshStore!.updateTarget(targetId, { lastRequiredPassphrase: requiredPassphrase })
getSshTargetRegistryStore()!.updateTarget(targetId, {
lastRequiredPassphrase: requiredPassphrase
})
return getPublicSshState(targetId)!
}
@@ -1416,7 +1423,7 @@ export function registerSshHandlers(
return existingReset
}
const target = sshStore!.getTarget(args.targetId)
const target = getSshTargetRegistryStore()!.getTarget(args.targetId)
if (!target) {
throw new Error(`SSH target "${args.targetId}" not found`)
}
@@ -1441,7 +1448,7 @@ export function registerSshHandlers(
// Why: auto-connect callers need to know whether connecting will prompt; true when the last connect required a credential and no live conn has it cached.
ipcMain.handle('ssh:needsPassphrasePrompt', (_event, args: { targetId: string }) => {
const target = sshStore!.getTarget(args.targetId)
const target = getSshTargetRegistryStore()!.getTarget(args.targetId)
if (!target?.lastRequiredPassphrase) {
return false
}
@@ -1450,7 +1457,7 @@ export function registerSshHandlers(
})
ipcMain.handle('ssh:testConnection', async (_event, args: { targetId: string }) => {
const target = sshStore!.getTarget(args.targetId)
const target = getSshTargetRegistryStore()!.getTarget(args.targetId)
if (!target) {
throw new Error(`SSH target "${args.targetId}" not found`)
}
@@ -1601,7 +1608,7 @@ export function registerSshHandlers(
return enrichDetected(args.targetId, ports)
})
return { connectionManager, sshStore }
return { connectionManager, sshStore: getSshTargetRegistryStore() as SshConnectionStore }
}
export function getSshConnectionManager(): SshConnectionManager | null {
@@ -1788,18 +1795,13 @@ export async function resetSshHandlerStateForTests(): Promise<void> {
portForwardManager?.dispose()
connectionManager = null
portForwardManager = null
sshStore = null
setSshTargetRegistryStore(null)
persistedStore = null
registeredConnectSshTarget = null
registeredGetSshState = null
setSshTargetRegistryHandlers({ connect: null, getState: null })
currentGetMainWindow = () => null
currentRuntime = undefined
}
export function getSshConnectionStore(): SshConnectionStore | null {
return sshStore
}
export function getActiveMultiplexer(connectionId: string): SshChannelMultiplexer | undefined {
return activeSessions.get(connectionId)?.getMux() ?? undefined
return getSshTargetRegistryStore()
}
@@ -47,6 +47,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -49,6 +49,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -41,6 +41,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -36,6 +36,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -44,6 +44,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -52,6 +52,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -38,6 +38,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -41,6 +41,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -36,6 +36,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -43,6 +43,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -52,6 +52,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -49,6 +49,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -42,6 +42,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -61,6 +61,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -49,6 +49,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -42,6 +42,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -50,6 +50,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -44,6 +44,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -40,6 +40,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -39,6 +39,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -35,6 +35,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -36,6 +36,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -36,6 +36,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -43,6 +43,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -41,6 +41,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -45,6 +45,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -43,6 +43,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
@@ -183,6 +183,13 @@ export const sshModuleMock = () => ({
getActiveMultiplexer: getActiveMultiplexerMock
})
// Why a second builder: getActiveMultiplexer moved to ../ssh/ssh-target-registry so the
// runtime could reach it without ipcMain. Production imports it from there now, so a
// vi.mock('./ssh') factory alone is inert.
export const sshTargetRegistryModuleMock = () => ({
getActiveMultiplexer: getActiveMultiplexerMock
})
export const hooksModuleMock = () => ({
getEffectiveHooks: getEffectiveHooksMock,
loadHooks: loadHooksMock,
@@ -55,6 +55,9 @@ vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
+8 -5
View File
@@ -1,5 +1,5 @@
import { net, session } from 'electron'
import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings'
import { getMainHttpClient } from '../network/http-client'
import { withSpan } from '../observability/tracer'
import type { JiraAuthType, JiraSite } from '../../shared/jira-types'
@@ -58,8 +58,10 @@ async function jiraFetch(url: string, init: RequestInit): Promise<Response> {
'jira.request',
async (span) => {
span.setAttribute('jira.siteUrl', new URL(url).origin)
const httpClient = getMainHttpClient()
const proxySession = httpClient.proxySession()
await ensureElectronProxyFromEnvironment({
proxySession: session.defaultSession,
...(proxySession ? { proxySession } : {}),
probeUrl: url
}).catch((error) => {
span.addEvent('jira.proxySetupFailed', {
@@ -68,9 +70,10 @@ async function jiraFetch(url: string, init: RequestInit): Promise<Response> {
})
})
try {
// Why: Electron's network stack follows Chromium proxy/session state,
// avoiding undici's stale keep-alive sockets after VPN path changes.
return await net.fetch(url, init)
// Why the port: on the desktop this is Electron's net.fetch, which follows
// Chromium proxy/session state and avoids undici's stale keep-alive sockets
// after VPN path changes. A host without Chromium gets Node's fetch instead.
return await httpClient.fetch(url, init)
} catch (error) {
span.setAttribute(
'jira.transportErrorName',
+8
View File
@@ -101,6 +101,14 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) {
}
}
}))
// Why here and not in beforeEach: vi.resetModules() above gives the http-client module
// a fresh singleton, so the port must be installed on that instance. The electron net
// mock alone is inert now that Jira fetches through the port.
const { setMainHttpClient } = await import('../network/http-client')
setMainHttpClient({
fetch: (url, init) => netFetchMock(url, init),
proxySession: () => ({ resolveProxy: resolveProxyMock, setProxy: setProxyMock }) as never
})
const { setSecretStore } = await import('../../shared/secret-store')
setSecretStore({
isEncryptionAvailable: () => options.encryptionAvailable ?? false,
+41
View File
@@ -0,0 +1,41 @@
import type { Session } from 'electron'
/**
* Outbound HTTP for main-process integrations.
*
* Why a port: the desktop uses Electron's Chromium-backed network stack it follows
* session/proxy state, avoids undici's stale keep-alive sockets after a VPN path change,
* and sends a Chrome user agent that some APIs (Jira's XSRF check) depend on. None of
* that exists on a host with no Chromium.
*
* The Node default is the platform global. That is a real behavioural difference, not a
* transparent swap, which is why this is a named port rather than a silent fallback:
* a Node host reads proxy configuration from the environment instead of from Chromium,
* and sends Node's user agent.
*
* Body safety (orca#8695): the global uses undici, where an unread response body can
* crash the process. This port hands the Response straight to its caller and never
* inspects it, so the consume/cancel obligation stays exactly where it already was
* with the caller, unchanged from when they called Electron's net directly.
*/
export type MainHttpClient = {
fetch(url: string, init?: RequestInit): Promise<Response>
/** The Chromium session whose proxy state applies, or null on a host without one. */
proxySession(): Session | null
}
const nodeHttpClient: MainHttpClient = {
fetch: (url, init) => globalThis.fetch(url, init),
proxySession: () => null
}
let current: MainHttpClient = nodeHttpClient
export function setMainHttpClient(client: MainHttpClient | null): void {
current = client ?? nodeHttpClient
}
export function getMainHttpClient(): MainHttpClient {
return current
}
+49 -9
View File
@@ -1,4 +1,38 @@
import { session } from 'electron'
import type { Session } from 'electron'
/**
* The default proxy session, or null on a host with no Chromium.
*
* Why settable: `session.defaultSession` is the only Electron value this module needs,
* and callers already accept an explicit `options.proxySession`. Making the *default*
* injectable lets the module load under plain Node, where there is no Chromium proxy
* config to consult and the environment variables are the whole answer.
*/
let resolveDefaultProxySession: (() => Session | null) | null = null
/**
* Why a resolver and not a Session: `session.defaultSession` throws until the Electron
* app is ready, and this is installed during pre-ready bootstrap. Passing a getter
* defers the access to first use, which is always after ready.
*/
export function setDefaultProxySessionResolver(resolve: (() => Session | null) | null): void {
resolveDefaultProxySession = resolve
}
function defaultProxySession(): Session | null {
return resolveDefaultProxySession?.() ?? null
}
/** Apply proxy rules only when a Chromium session exists; a Node host has none to configure. */
async function setSessionProxyIfPresent(
proxySession: ProxySession | Session | null,
config: Parameters<typeof setSessionProxy>[1]
): Promise<void> {
if (!proxySession) {
return
}
await setSessionProxy(proxySession as ProxySession, config)
}
import {
getProxyBypassRulesFromEnvironment,
getProxyUrlFromEnvironment,
@@ -50,8 +84,12 @@ export async function ensureElectronProxyFromEnvironment(
return lastAppliedProxyConfig
}
const proxySession = options.proxySession ?? session.defaultSession
const resolved = await proxySession.resolveProxy(options.probeUrl ?? PROXY_PROBE_URL)
const proxySession = options.proxySession ?? defaultProxySession()
// Why not bail: with no Chromium session there is no system proxy to discover, so the
// environment variables below are the complete answer rather than a fallback.
const resolved = proxySession
? await proxySession.resolveProxy(options.probeUrl ?? PROXY_PROBE_URL)
: 'DIRECT'
if (resolved !== 'DIRECT') {
return { source: 'system' }
}
@@ -65,7 +103,7 @@ export async function ensureElectronProxyFromEnvironment(
}
const bypassRules = getProxyBypassRulesFromEnvironment(options.env ?? process.env)
await setSessionProxy(proxySession, {
await setSessionProxyIfPresent(proxySession, {
mode: 'fixed_servers',
proxyRules: proxy.value,
...(bypassRules ? { proxyBypassRules: bypassRules } : {})
@@ -86,20 +124,22 @@ export async function applyElectronProxySettings(
probeUrl?: string
} = {}
): Promise<ProxyApplyResult> {
const proxySession = options.proxySession ?? session.defaultSession
const proxySession = options.proxySession ?? defaultProxySession()
const proxy = normalizeProxyUrl(settings.httpProxyUrl)
if (!proxy.ok) {
return ensureElectronProxyFromEnvironment({
proxySession,
...(proxySession ? { proxySession } : {}),
env: options.env,
force: lastAppliedProxyConfig !== null,
probeUrl: options.probeUrl
}).then((result) => (result.source === 'none' ? { source: 'invalid-settings' } : result))
}
// Why guarded: applying proxy rules to a Chromium session is meaningless with no
// Chromium. The settings are still honoured — outbound requests read the environment.
if (proxy.value) {
const bypassRules = normalizeProxyBypassRules(settings.httpProxyBypassRules)
await setSessionProxy(proxySession, {
await setSessionProxyIfPresent(proxySession, {
mode: 'fixed_servers',
proxyRules: proxy.value,
...(bypassRules ? { proxyBypassRules: bypassRules } : {})
@@ -113,11 +153,11 @@ export async function applyElectronProxySettings(
}
if (lastAppliedProxyConfig !== null) {
await setSessionProxy(proxySession, { mode: 'system' })
await setSessionProxyIfPresent(proxySession, { mode: 'system' })
lastAppliedProxyConfig = null
}
return ensureElectronProxyFromEnvironment({
proxySession,
...(proxySession ? { proxySession } : {}),
env: options.env,
force: true,
probeUrl: options.probeUrl
+118
View File
@@ -0,0 +1,118 @@
/**
* `orcad` the Orca runtime served from plain Node, with no Electron.
*
* Installs the Node host adapters, constructs the same `OrcaRuntimeService` the
* desktop uses, installs a PTY controller via `registerPtyHandlers(null, …)`, and
* serves runtime RPC. See docs/design/node-only-runtime-backend.html.
*
* The desktop-only surfaces are deliberately left uninstalled: no notifications, no
* renderer window, no browser panes. Those are declared rather than faked see
* `runtime-desktop-surface.ts` and `pty-host-bindings.ts`.
*/
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import process from 'node:process'
import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment'
import { setSecretStore, type SecretStore } from '../../shared/secret-store'
/** XDG-ish data root. `$ORCA_USER_DATA` wins so a smoke test can isolate state. */
function resolveUserDataPath(): string {
const explicit = process.env.ORCA_USER_DATA
if (explicit) {
return explicit
}
const xdg = process.env.XDG_DATA_HOME
return xdg ? join(xdg, 'Orca') : join(homedir(), '.orca')
}
function createNodeAppEnvironment(): AppEnvironment {
const userData = resolveUserDataPath()
const quitHandlers: (() => void)[] = []
// Why SIGTERM/SIGINT: this is the Node equivalent of electron's will-quit, and the
// runtime's teardown (daemon disconnect, PTY kill, store flush) hangs off it.
const runQuitHandlers = (): void => {
for (const handler of quitHandlers.splice(0)) {
try {
handler()
} catch (error) {
console.error('[orcad] shutdown handler failed:', error)
}
}
}
process.once('SIGTERM', () => {
runQuitHandlers()
process.exit(0)
})
process.once('SIGINT', () => {
runQuitHandlers()
process.exit(0)
})
return {
getPath: (name) => (name === 'home' ? homedir() : name === 'temp' ? tmpdir() : userData),
getAppPath: () => process.cwd(),
getVersion: () => process.env.ORCA_VERSION ?? '0.0.0-orcad',
isPackaged: () => true,
onWillQuit: (handler) => quitHandlers.push(handler),
exit: (code = 0) => process.exit(code),
// Why []: there are no Chromium processes on this host to measure.
getAppMetrics: () => []
}
}
/**
* Why not silently plaintext: `isEncryptionAvailable() === false` already makes every
* caller fall back to unsealed storage, which is a security posture, not a detail.
* `describeUnavailable()` gives the reason a client can surface.
*/
function createNodeSecretStore(): SecretStore {
return {
isEncryptionAvailable: () => false,
encryptString: () => {
throw new Error('orcad_secret_sealing_unavailable')
},
decryptString: () => {
throw new Error('orcad_secret_sealing_unavailable')
},
describeUnavailable: () =>
'This host has no OS keyring, so credentials are stored unencrypted. Pair from a desktop to manage secrets, or install and unlock a keyring.'
}
}
export function installOrcadHostAdapters(): void {
setAppEnvironment(createNodeAppEnvironment())
setSecretStore(createNodeSecretStore())
}
/** Boot the runtime and serve RPC. Returns once the transport is listening. */
export async function startOrcad(options: { port?: number } = {}): Promise<void> {
installOrcadHostAdapters()
const { OrcaRuntimeService } = await import('../runtime/orca-runtime')
const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc')
const { registerPtyHandlers } = await import('../ipc/pty')
const { getAppEnvironment } = await import('../../shared/app-environment')
const runtime = new OrcaRuntimeService(null, undefined, {
// Why false: this host does not run the terminal daemon, so persistent local PTYs
// cannot be recovered. The constructor defaults this to true, which would claim a
// capability orcad does not have.
canRecoverPersistentLocalPtys: () => false,
// Why 'blocked': `'openable'` means a desktop window can be opened here, which is
// what powers serve→desktop promotion. A Node host can never do that, and the
// constructor's default would advertise it.
getDesktopWindowStatus: () => 'blocked'
})
// Why null: no renderer. This installs the RuntimePtyController that terminal.create
// spawns through — the whole reason this module had to stop importing electron.
registerPtyHandlers(null, runtime)
const rpc = new OrcaRuntimeRpcServer({
runtime,
userDataPath: getAppEnvironment().getPath('userData'),
enableWebSocket: true,
exposeNetworkByDefault: true,
...(options.port !== undefined ? { wsPort: options.port, preferPinnedWsPort: true } : {})
} as never)
await rpc.start()
}
+17
View File
@@ -0,0 +1,17 @@
import { getUserPluginsDir } from './plugin-discovery'
import { readPluginLockfile } from './plugin-install'
import { buildPluginList, type PluginListEntry } from './plugin-list-projection'
import type { PluginService } from './plugin-service'
/**
* The plugin list paired clients see. Split out of `ipc/plugins.ts` so the runtime's
* `plugins.list` RPC can reach it without dragging `ipcMain` into its module graph
* the same reason preflight and the SSH registry moved.
*/
export async function listPluginsForClients(
pluginService: PluginService
): Promise<PluginListEntry[]> {
await pluginService.whenReady()
const lock = await readPluginLockfile(getUserPluginsDir(pluginService.options.userDataPath))
return buildPluginList(pluginService, lock)
}
+5 -10
View File
@@ -1,4 +1,5 @@
import { existsSync } from 'node:fs'
import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment'
import { join } from 'node:path'
import { Worker } from 'node:worker_threads'
import {
@@ -16,9 +17,9 @@ import {
// the duplicated ~150 lines are cheaper than a premature shared abstraction, so
// a third adopter should extract one.
//
// This module contains the literal text require('electron'), so it must never
// become reachable from a plain-Node fork entry (build-plugins/
// plain-node-entry-guard.ts fails the build on that text, try/catch or not).
// This module used to contain the literal text require('electron'), which fails the
// plain-Node entry guard even inside a try/catch. It reads the AppEnvironment port
// instead, so it is now safe to reach from a fork entry.
// Why: the worker's own loop absorbs the spawn stall, so the client only needs
// a backstop for a wedged thread. Kept at 30s because a scan sits on the
@@ -324,14 +325,8 @@ export function resolveWorkerEntryPath(layout: WorkerEntryLayout): string {
}
function currentWorkerEntryLayout(): WorkerEntryLayout {
let app: { isPackaged: boolean } | null = null
try {
app = require('electron').app ?? null
} catch {
app = null
}
return {
isPackaged: app?.isPackaged === true,
isPackaged: hasAppEnvironment() && getAppEnvironment().isPackaged(),
resourcesPath: process.resourcesPath,
moduleDir: __dirname
}
+304
View File
@@ -0,0 +1,304 @@
/**
* Agent/tool preflight detection. Split out of `ipc/preflight.ts` so the Orca
* runtime which calls `detectInstalledAgentsWithShellPathHydration` and
* `detectRemoteAgents` during normal operation can reach this logic without
* dragging `ipcMain` into its module graph. The Electron handler registration
* stays in `ipc/preflight.ts` and imports from here.
*/
import type {
PathSource,
ShellHydrationFailureReason
} from '../../shared/shell-path-hydration-types'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
import { getAzureDevOpsAuthStatus } from '../azure-devops/client'
import { getBitbucketAuthStatus } from '../bitbucket/client'
import { getGiteaAuthStatus } from '../gitea/client'
import { _resetKnownHostsCache } from '../gitlab/gl-utils'
import { mergePersistedWindowsPathAsync } from '../pty/windows-environment-path'
import { getActiveMultiplexer } from '../ssh/ssh-target-registry'
import {
detectWslCommandsOnPath,
type WslPreflightTarget
} from '../ipc/preflight-wsl-agent-detection'
import { detectCommandsInInstallDirs } from '../ipc/local-agent-install-dir-detection'
import {
getPreflightWslTarget,
type PreflightRuntimeContext
} from '../ipc/preflight-runtime-target'
export type { PreflightRuntimeContext }
import { hydrateShellPathForAgentDetection } from '../ipc/agent-detection-shell-path'
import {
execCommandInWsl,
execLocalPreflightCommand,
isCommandAvailable,
isCommandOnPath,
shellQuote
} from '../ipc/preflight-command-exec'
import {
detectRemoteWindowsTerminalCapabilities,
type RemoteWindowsTerminalCapabilities
} from '../ipc/preflight-remote-windows-terminal-capabilities'
import {
getTuiAgentDetectionProbeCommands,
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
resolveDetectedTuiAgentIds
} from '../ipc/tui-agent-detection-commands'
import { invalidateWslGuestEnvironment } from '../wsl/wsl-guest-environment'
export type PreflightStatus = {
git: { installed: boolean }
gh: { installed: boolean; authenticated: boolean }
// Why: optional so existing renderer call sites that only render git/gh
// status keep typechecking. Consumers that surface GitLab-specific
// affordances (the GitLab tab in the source picker, MR list, etc.)
// gate on `glab?.authenticated`.
glab?: { installed: boolean; authenticated: boolean }
bitbucket?: { configured: boolean; authenticated: boolean; account: string | null }
azureDevOps?: {
configured: boolean
authenticated: boolean
account: string | null
baseUrl: string | null
tokenConfigured: boolean
}
gitea?: {
configured: boolean
authenticated: boolean
account: string | null
baseUrl: string | null
tokenConfigured: boolean
}
}
export { detectRemoteWindowsTerminalCapabilities }
export type { RemoteWindowsTerminalCapabilities }
// Why: cache the result so repeated Landing mounts don't re-spawn processes.
// The check only runs once per app session — relaunch to re-check.
let cached: PreflightStatus | null = null
/** @internal - tests need a clean preflight cache between cases. */
export function _resetPreflightCache(): void {
cached = null
}
function uniqueAgentIds(ids: Iterable<string>): string[] {
return [...new Set(ids)]
}
async function detectCommandRuntime(
command: string,
context?: PreflightRuntimeContext
): Promise<{ installed: boolean; wslTarget?: WslPreflightTarget }> {
const wslTarget = getPreflightWslTarget(context)
if (wslTarget) {
return (await isCommandAvailable(command, wslTarget))
? { installed: true, wslTarget }
: { installed: false }
}
if (await isCommandAvailable(command)) {
return { installed: true }
}
return { installed: false }
}
export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise<string[]> {
const wslTarget = getPreflightWslTarget(context)
if (wslTarget) {
const foundCommands = await detectWslCommandsOnPath(
wslTarget,
getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, 'wsl')
)
return resolveDetectedTuiAgentIds(KNOWN_TUI_AGENT_DETECTION_COMMANDS, foundCommands, 'wsl')
}
const probeCommands = getTuiAgentDetectionProbeCommands(
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
process.platform
)
const pathChecks = await Promise.all(
probeCommands.map(async (cmd) => ({
cmd,
installedOnPath: await isCommandOnPath(cmd)
}))
)
const missedCommands = pathChecks.filter((check) => !check.installedOnPath).map(({ cmd }) => cmd)
// Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution
// computes user install dirs once instead of blocking once per missed CLI.
const installDirCommands = detectCommandsInInstallDirs(missedCommands)
const foundCommands = new Set(
pathChecks
.filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd))
.map(({ cmd }) => cmd)
)
return resolveDetectedTuiAgentIds(
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
foundCommands,
process.platform
)
}
export async function detectInstalledAgentsWithShellPathHydration(
context?: PreflightRuntimeContext
): Promise<string[]> {
await hydrateShellPathForAgentDetection(context)
return detectInstalledAgents(context)
}
export type RefreshAgentsResult = {
/** Agents detected after hydrating PATH from the user's login shell. */
agents: string[]
/** PATH segments that were added this refresh (empty if nothing new). */
addedPathSegments: string[]
/** True when the shell spawn succeeded. False = relied on existing PATH. */
shellHydrationOk: boolean
/** Whether `detectInstalledAgents` ran against shell-hydrated PATH or only
* the seed list from `patchPackagedProcessPath`. Drives the on_path:false
* triage in tile A on dashboard 1562016. */
pathSource: PathSource
/** Why hydration failed (or `'none'` on success). Typed against the shared
* alias so the IPC boundary stays in lockstep with the renderer-visible
* enum on `onboardingAgentPickedSchema`. */
pathFailureReason: ShellHydrationFailureReason
}
/**
* Re-spawn the user's login shell to refresh process.env.PATH, then re-run
* agent detection. Called by the Agents settings pane when the user clicks
* Refresh handles the "installed a new CLI, Orca doesn't see it yet" case
* without requiring an app restart.
*/
export async function refreshShellPathAndDetectAgents(
context?: PreflightRuntimeContext
): Promise<RefreshAgentsResult> {
const wslTarget = getPreflightWslTarget(context)
if (wslTarget) {
// Why invalidate first: the guest PATH is cached per distro for the process
// lifetime, so Refresh would otherwise re-read the pre-install PATH and
// keep reporting a just-installed CLI as absent -- the exact case this
// function exists to handle.
invalidateWslGuestEnvironment(wslTarget.distro)
const agents = await detectInstalledAgents(context)
return {
agents,
addedPathSegments: [],
shellHydrationOk: true,
pathSource: 'sync_seed_only',
pathFailureReason: 'none'
}
}
const hydration = await hydrateShellPath({ force: true })
const added = hydration.ok ? mergePathSegments(hydration.segments) : []
const agents = await detectInstalledAgents(context)
return {
agents,
addedPathSegments: added,
shellHydrationOk: hydration.ok,
pathSource: hydration.ok ? 'shell_hydrate' : 'sync_seed_only',
pathFailureReason: hydration.failureReason
}
}
export async function detectRemoteAgents(args: { connectionId: string }): Promise<string[]> {
const mux = getActiveMultiplexer(args.connectionId)
if (!mux || mux.isDisposed()) {
// Why: remote agent detection is passive UI polling. A disconnected host has
// no detectable agents until reconnect, but should not spam IPC errors.
return []
}
const result = (await mux.request('preflight.detectAgents', {
commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS
})) as { agents: string[] }
return uniqueAgentIds(result.agents)
}
async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise<boolean> {
try {
await (wslTarget
? execCommandInWsl(wslTarget, `${shellQuote('gh')} auth status`)
: execLocalPreflightCommand('gh', ['auth', 'status']))
// Why: for plain-text `gh auth status`, exit 0 means gh did not detect any
// authentication issues for the checked hosts/accounts.
return true
} catch (error) {
// Why: some environments may surface partial command output on the thrown
// error object. Keep a compatibility fallback so we avoid a false auth
// warning if success markers are present despite a non-zero result.
const stdout = (error as { stdout?: string }).stdout ?? ''
const stderr = (error as { stderr?: string }).stderr ?? ''
const output = `${stdout}\n${stderr}`
return output.includes('Logged in') || output.includes('Active account: true')
}
}
// Why: parallel to isGhAuthenticated for the glab CLI. glab writes auth
// status to stderr in some versions and stdout in others; check both.
async function isGlabAuthenticated(wslTarget?: WslPreflightTarget): Promise<boolean> {
try {
await (wslTarget
? execCommandInWsl(wslTarget, `${shellQuote('glab')} auth status`)
: execLocalPreflightCommand('glab', ['auth', 'status']))
return true
} catch (error) {
const stdout = (error as { stdout?: string }).stdout ?? ''
const stderr = (error as { stderr?: string }).stderr ?? ''
const output = `${stdout}\n${stderr}`
return output.includes('Logged in')
}
}
export async function runPreflightCheck(
force = false,
context?: PreflightRuntimeContext
): Promise<PreflightStatus> {
const wslTarget = getPreflightWslTarget(context)
const cacheable = !wslTarget
if (cacheable && cached && !force) {
return cached
}
if (process.platform === 'win32' && !wslTarget) {
await mergePersistedWindowsPathAsync(process.env, { forceRefresh: force })
}
if (force) {
// Why: the GitLab known-hosts cache (gl-utils) is populated lazily on the
// first GitLab request and never invalidated within a session. A user who
// runs `glab auth login` for a self-hosted host after Orca starts would
// otherwise see "No GitLab project found" until app relaunch. The Re-check
// path in IntegrationsPane forces preflight, so piggyback on that signal
// to refresh the host list too.
_resetKnownHostsCache()
}
const [gitProbe, ghProbe, glabProbe] = await Promise.all([
detectCommandRuntime('git', context),
detectCommandRuntime('gh', context),
detectCommandRuntime('glab', context)
])
const [ghAuthenticated, glabAuthenticated, bitbucket, azureDevOps, gitea] = await Promise.all([
ghProbe.installed ? isGhAuthenticated(ghProbe.wslTarget) : Promise.resolve(false),
glabProbe.installed ? isGlabAuthenticated(glabProbe.wslTarget) : Promise.resolve(false),
getBitbucketAuthStatus(),
getAzureDevOpsAuthStatus(),
getGiteaAuthStatus()
])
const result = {
git: { installed: gitProbe.installed },
gh: { installed: ghProbe.installed, authenticated: ghAuthenticated },
glab: { installed: glabProbe.installed, authenticated: glabAuthenticated },
bitbucket,
azureDevOps,
gitea
}
if (cacheable) {
cached = result
}
return result
}
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { setPtyHostBindings } from '../ipc/pty-host-bindings'
const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
@@ -82,6 +83,16 @@ describe('PTY provider dispatch', () => {
onMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => {
handlers.set(channel, handler)
})
// Why: pty.ts registers against an injected surface now, so the mocked ipcMain must
// be installed for this suite's own `handlers` map to capture registrations.
setPtyHostBindings({
ipc: {
handle: handleMock,
on: onMock,
removeHandler: removeHandlerMock,
removeAllListeners: removeAllListenersMock
}
})
registerPtyHandlers(mainWindow as never)
}
+1 -1
View File
@@ -5,7 +5,7 @@ const mocks = vi.hoisted(() => ({
getSshFilesystemProvider: vi.fn()
}))
vi.mock('./ipc/ssh', () => ({
vi.mock('./ssh/ssh-target-registry', () => ({
getActiveMultiplexer: mocks.getActiveMultiplexer
}))
+1 -1
View File
@@ -1,6 +1,6 @@
import type { AgentTrustPreset } from './agent-trust-presets'
import { upsertProjectTrustLevelInContent } from './codex/config-toml-trust'
import { getActiveMultiplexer } from './ipc/ssh'
import { getActiveMultiplexer } from './ssh/ssh-target-registry'
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
import type { IFilesystemProvider } from './providers/types'
import {
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import type { AgentSkillShareRequest } from '../../shared/agent-skill-sharing-contract'
import { getDefaultSettings } from '../../shared/constants'
import type { DiscoveredSkill } from '../../shared/skills'
@@ -120,6 +121,7 @@ function runtimeWithCloud(options: {
}
beforeEach(async () => {
installFakeAppEnvironment({ getPath: () => mocks.userDataPath })
testRoot = await mkdtemp(join(tmpdir(), 'orca-agent-skill-share-'))
mocks.userDataPath = testRoot
})
+27 -2
View File
@@ -1,5 +1,9 @@
/* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { RuntimeBrowserCommands } from './orca-runtime-browser'
import { setRuntimeBrowserCommandsFactory } from './runtime-browser-commands-factory'
import { setRuntimeDesktopSurface } from './runtime-desktop-surface'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import type * as GitUsernameModule from '../git/git-username'
import { performance } from 'node:perf_hooks'
import { EventEmitter } from 'node:events'
@@ -456,12 +460,12 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
unregisterSshGitProvider: unregisterSshGitProviderMock
}))
vi.mock('../ipc/ssh', () => ({
vi.mock('../ssh/ssh-target-registry', () => ({
getActiveMultiplexer: getActiveMultiplexerMock,
getRegisteredSshState: () => ({ remotePlatform: 'linux' })
}))
vi.mock('../ipc/preflight', () => ({
vi.mock('../preflight/agent-detection', () => ({
detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock,
detectRemoteAgents: detectRemoteAgentsMock
}))
@@ -675,8 +679,29 @@ vi.mock('../git/git-username', async () => {
})
function resetRuntimeTestMocks(): void {
// Why: constructing the browser commands is what pulls the Chromium cluster in, so
// production installs this at the Electron entry. A Node host installs none and the
// browser RPCs reject rather than silently succeeding.
setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host))
// Why: the runtime's notification, window lookup and tab-create-reply channel are
// injected now, so the electron mock alone is inert. Back the surface with the same
// mocks so every existing expectation still holds.
setRuntimeDesktopSurface({
showNotification: () => true,
findWindowById: (id) => electronMocks.BrowserWindow.fromId(id) as never,
onIpc: (channel, listener) => electronMocks.ipcMain.on(channel, listener as never),
removeIpcListener: (channel, listener) =>
electronMocks.ipcMain.removeListener(channel, listener as never)
})
resetPlatform()
electronMocks.app.isPackaged = false
// Why here and not the electron mock: the runtime reads paths and the packaged flag
// through the AppEnvironment port now, so the electron mock alone is inert. Reading
// electronMocks.app keeps the existing per-test toggles below working unchanged.
installFakeAppEnvironment({
getPath: () => electronMocks.app.getPath(),
isPackaged: () => electronMocks.app.isPackaged
})
clearConfiguredWorktreeSharedDirectoriesCacheForTests()
_resetTerminalViewAttributesForTest()
advertisedUrlWatcher.clear()
+51 -46
View File
@@ -371,7 +371,7 @@ import {
type ExecutionHostId
} from '../../shared/execution-host'
import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup'
import { getRegisteredSshState } from '../ipc/ssh'
import { getRegisteredSshState } from '../ssh/ssh-target-registry'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
@@ -570,7 +570,10 @@ import {
} from '../../shared/tui-agent-config'
import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout'
import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner'
import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight'
import {
detectInstalledAgentsWithShellPathHydration,
detectRemoteAgents
} from '../preflight/agent-detection'
import {
markCodexProjectTrusted,
markCopilotFolderTrusted,
@@ -653,7 +656,8 @@ import {
} from '../ports/workspace-port-ownership'
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
import type { AutomationService } from '../automations/service'
import { RuntimeBrowserCommands } from './orca-runtime-browser'
import type { RuntimeBrowserCommands } from './orca-runtime-browser'
import { createRuntimeBrowserCommands } from './runtime-browser-commands-factory'
import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency'
import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity'
import {
@@ -727,11 +731,13 @@ import {
} from '../../shared/claude-agent-teams-tmux-compat'
import { joinWorktreeRelativePath } from './runtime-relative-paths'
import { collectMemorySnapshot } from '../memory/collector'
import { app, BrowserWindow, ipcMain, Notification } from 'electron'
import type { BrowserWindow } from 'electron'
import { getAppEnvironment } from '../../shared/app-environment'
import { getRuntimeDesktopSurface } from './runtime-desktop-surface'
import { RendererPublicationThrottle } from '../window/renderer-publication-throttle'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { BrowserBackend } from '../browser/browser-backend'
import { BrowserError } from '../browser/cdp-bridge'
import { BrowserError } from '../browser/browser-error'
import {
getPRForBranch,
getPRForBranchOutcome,
@@ -3921,7 +3927,7 @@ export class OrcaRuntimeService {
return
}
await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, {
shouldHydrateShellPath: app.isPackaged,
shouldHydrateShellPath: getAppEnvironment().isPackaged(),
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) => {
const current = this.store?.getSettings()
@@ -4229,13 +4235,12 @@ export class OrcaRuntimeService {
return { projectId, workspaceMode: 'new_per_run', workspaceId: null }
}
// Why: lazy initialization — the DB path depends on Electron's userData
// which may not be finalized until after app.ready. Also allows unit tests
// to inject an in-memory DB without touching the filesystem.
// Why: lazy initialization — the DB path depends on userData, which on the desktop
// is not finalized until after app.ready. Also allows unit tests to inject an
// in-memory DB without touching the filesystem.
getOrchestrationDb(): OrchestrationDb {
if (!this._orchestrationDb) {
const { app } = require('electron')
const dbPath = join(app.getPath('userData'), 'orchestration.db')
const dbPath = join(getAppEnvironment().getPath('userData'), 'orchestration.db')
this._orchestrationDb = new OrchestrationDb(dbPath)
this.ensureOrchestrationFederationRelay()
this.scheduleRestoredMessageRepoints()
@@ -4996,7 +5001,10 @@ export class OrcaRuntimeService {
signal?: AbortSignal
): Promise<AgentSkillShareOperation> {
const selectedSkills = selectDiscoveredSkills(discoveredSkills, request.skillSelectors)
const operationRoot = join(app.getPath('userData'), 'agent-skill-share-operations')
const operationRoot = join(
getAppEnvironment().getPath('userData'),
'agent-skill-share-operations'
)
const cloud = this.requireSkillCloudService()
const preparations = new SkillSharePreparationService(
operationRoot,
@@ -5005,7 +5013,7 @@ export class OrcaRuntimeService {
createShare: (packageId, input) => cloud.createShare(packageId, input)
},
{
installStateDirectory: join(app.getPath('userData'), 'skill-installs')
installStateDirectory: join(getAppEnvironment().getPath('userData'), 'skill-installs')
}
)
let preparationId: string | null = null
@@ -5212,7 +5220,7 @@ export class OrcaRuntimeService {
if (sshTarget) {
return installSkillBundleOnSshHost({
provider: sshTarget.provider,
userDataPath: app.getPath('userData'),
userDataPath: getAppEnvironment().getPath('userData'),
request: {
...request,
destination:
@@ -5221,14 +5229,14 @@ export class OrcaRuntimeService {
: request.destination
},
workspace: sshTarget.workspace,
requireHttps: app.isPackaged,
requireHttps: getAppEnvironment().isPackaged(),
signal: controller.signal,
onProgress: reportProgress
})
}
await this.skillTransactionRecovery
const allowedDownloadOrigins = ['https://storage.googleapis.com']
if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
if (!getAppEnvironment().isPackaged() && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
allowedDownloadOrigins.push(
...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',')
.map((origin) => origin.trim())
@@ -5237,9 +5245,9 @@ export class OrcaRuntimeService {
}
return await executeSkillBundleInstallRequest(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: app.getPath('userData'),
stateDirectory: getAppEnvironment().getPath('userData'),
allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)],
requireHttps: app.isPackaged,
requireHttps: getAppEnvironment().isPackaged(),
resolveStagedUpload: (uploadId, identity) =>
this.requireSkillUploadSessions().take(uploadId, identity),
detectProviders: detectInstalledAgentsWithShellPathHydration,
@@ -5276,7 +5284,7 @@ export class OrcaRuntimeService {
if (sshTarget) {
return installSkillOnSshHost({
provider: sshTarget.provider,
userDataPath: app.getPath('userData'),
userDataPath: getAppEnvironment().getPath('userData'),
request: {
...request,
destination:
@@ -5285,13 +5293,13 @@ export class OrcaRuntimeService {
: request.destination
},
workspace: sshTarget.workspace,
requireHttps: app.isPackaged,
requireHttps: getAppEnvironment().isPackaged(),
signal
})
}
await this.skillTransactionRecovery
const allowedDownloadOrigins = ['https://storage.googleapis.com']
if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
if (!getAppEnvironment().isPackaged() && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
allowedDownloadOrigins.push(
...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',')
.map((origin) => origin.trim())
@@ -5300,9 +5308,9 @@ export class OrcaRuntimeService {
}
return executeSkillInstallRequest(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: app.getPath('userData'),
stateDirectory: getAppEnvironment().getPath('userData'),
allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)],
requireHttps: app.isPackaged,
requireHttps: getAppEnvironment().isPackaged(),
resolveStagedUpload: (uploadId, identity) =>
this.requireSkillUploadSessions().take(uploadId, identity),
detectProviders: detectInstalledAgentsWithShellPathHydration,
@@ -5333,7 +5341,7 @@ export class OrcaRuntimeService {
await this.skillTransactionRecovery
return previewSharedSkillInstall(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: app.getPath('userData'),
stateDirectory: getAppEnvironment().getPath('userData'),
detectProviders: detectInstalledAgentsWithShellPathHydration,
resolveProviderRootOverrides: (destination) =>
this.resolveSkillProviderRootOverrides(destination)
@@ -5361,7 +5369,7 @@ export class OrcaRuntimeService {
const runtimeId = this.getStatus().runtimeId
return previewSharedSkillBundleInstall(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: app.getPath('userData'),
stateDirectory: getAppEnvironment().getPath('userData'),
detectProviders: detectInstalledAgentsWithShellPathHydration,
resolveProviderRootOverrides: (destination) =>
this.resolveSkillProviderRootOverrides(destination)
@@ -5387,7 +5395,7 @@ export class OrcaRuntimeService {
await this.skillTransactionRecovery
return removeSharedSkillInstall(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: app.getPath('userData'),
stateDirectory: getAppEnvironment().getPath('userData'),
detectProviders: detectInstalledAgentsWithShellPathHydration,
resolveProviderRootOverrides: (destination) =>
this.resolveSkillProviderRootOverrides(destination)
@@ -5406,7 +5414,7 @@ export class OrcaRuntimeService {
await this.skillTransactionRecovery
const runtimeId = this.getStatus().runtimeId
const [installs, worktrees] = await Promise.all([
listManagedSkillInstalls(join(app.getPath('userData'), 'skill-installs'), {
listManagedSkillInstalls(join(getAppEnvironment().getPath('userData'), 'skill-installs'), {
observeReceipt: async (receipt) => {
if (!receipt.wslDistro) {
return nativeSkillInstallFilesystem.observeSkill(
@@ -5702,7 +5710,11 @@ export class OrcaRuntimeService {
throw new Error('skill-upload-service-disposed')
}
this.skillUploadSessions ??= new SkillUploadSessionService(
join(app.getPath('userData'), 'skill-installs', SKILL_UPLOAD_STAGING_ROOT_NAME)
join(
getAppEnvironment().getPath('userData'),
'skill-installs',
SKILL_UPLOAD_STAGING_ROOT_NAME
)
)
return this.skillUploadSessions
}
@@ -14352,13 +14364,9 @@ export class OrcaRuntimeService {
const body = input.body ?? ''
let delivered = false
try {
if (Notification.isSupported()) {
new Notification({ title, body }).show()
delivered = true
}
delivered = getRuntimeDesktopSurface().showNotification({ title, body })
} catch {
// Headless serve has no notification display; the mobile relay below
// still runs.
// A host with no notification display still relays to paired clients below.
}
this.dispatchMobileNotification({ type: 'notification', source: 'plugin', title, body })
return { delivered }
@@ -28390,7 +28398,7 @@ export class OrcaRuntimeService {
// creates the tab and replies with the tabId so we can resolve the handle.
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
const timer = setTimeout(() => {
ipcMain.removeListener('terminal:tabCreateReply', handler)
getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler)
reject(new Error('Terminal creation timed out'))
}, 10_000)
@@ -28402,14 +28410,14 @@ export class OrcaRuntimeService {
return
}
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler)
if (r.error) {
reject(new Error(r.error))
} else {
resolve({ tabId: r.tabId!, title: r.title ?? launchOpts.title ?? '' })
}
}
ipcMain.on('terminal:tabCreateReply', handler)
getRuntimeDesktopSurface().onIpc('terminal:tabCreateReply', handler)
win.webContents.send('terminal:requestTabCreate', {
requestId,
worktreeId,
@@ -28761,7 +28769,7 @@ export class OrcaRuntimeService {
const requestId = randomUUID()
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
const timer = setTimeout(() => {
ipcMain.removeListener('terminal:tabCreateReply', handler)
getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler)
opts.signal?.removeEventListener('abort', onAbort)
reject(new Error('Terminal creation timed out'))
}, 10_000)
@@ -28769,7 +28777,7 @@ export class OrcaRuntimeService {
// its shell) stays alive for the host and mirrors on reconnect (#7718).
const onAbort = (): void => {
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler)
reject(new Error('client_disconnected'))
}
@@ -28781,7 +28789,7 @@ export class OrcaRuntimeService {
return
}
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler)
opts.signal?.removeEventListener('abort', onAbort)
if (r.error) {
reject(new Error(r.error))
@@ -28790,7 +28798,7 @@ export class OrcaRuntimeService {
}
}
opts.signal?.addEventListener('abort', onAbort, { once: true })
ipcMain.on('terminal:tabCreateReply', handler)
getRuntimeDesktopSurface().onIpc('terminal:tabCreateReply', handler)
win.webContents.send('terminal:requestTabCreate', {
requestId,
worktreeId,
@@ -38095,7 +38103,7 @@ export class OrcaRuntimeService {
// ── Browser automation ──
private readonly browserCommands = new RuntimeBrowserCommands({
private readonly browserCommands = createRuntimeBrowserCommands({
getAgentBrowserBridge: () => this.agentBrowserBridge,
resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector),
getAuthoritativeWindow: () => this.getAuthoritativeWindow(),
@@ -38572,10 +38580,7 @@ export class OrcaRuntimeService {
if (this.authoritativeWindowId === null) {
return null
}
if (!BrowserWindow?.fromId) {
return null
}
const win = BrowserWindow.fromId(this.authoritativeWindowId)
const win = getRuntimeDesktopSurface().findWindowById(this.authoritativeWindowId)
return win && !win.isDestroyed() ? win : null
}
}
@@ -1,5 +1,5 @@
import { z } from 'zod'
import { getRegisteredSshState, listRegisteredSshTargets } from '../../../ipc/ssh'
import { getRegisteredSshState, listRegisteredSshTargets } from '../../../ssh/ssh-target-registry'
import { getPublicSshState } from '../../public-ssh-state'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
+1 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod'
import { defineMethod, type RpcContext, type RpcMethod } from '../core'
import type { PluginPanelEntry } from '../../../../shared/plugins/plugin-panel-bridge'
import { listPluginsForClients } from '../../../ipc/plugins'
import { listPluginsForClients } from '../../../plugins/plugin-client-list'
import type { PluginListEntry } from '../../../plugins/plugin-list-projection'
import type { PluginService } from '../../../plugins/plugin-service'
import {
@@ -18,7 +18,7 @@ const {
runPreflightCheckMock: vi.fn()
}))
vi.mock('../../../ipc/preflight', () => ({
vi.mock('../../../preflight/agent-detection', () => ({
detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock,
detectRemoteAgents: detectRemoteAgentsMock,
detectRemoteWindowsTerminalCapabilities: detectRemoteWindowsTerminalCapabilitiesMock,
+1 -1
View File
@@ -6,7 +6,7 @@ import {
detectInstalledAgentsWithShellPathHydration,
refreshShellPathAndDetectAgents,
runPreflightCheck
} from '../../../ipc/preflight'
} from '../../../preflight/agent-detection'
const PreflightCheck = z.object({
force: z.boolean().optional()
+1 -1
View File
@@ -16,7 +16,7 @@ const {
listRegisteredRemovedSshTargetLabelsMock: vi.fn()
}))
vi.mock('../../../ipc/ssh', () => ({
vi.mock('../../../ssh/ssh-target-registry', () => ({
connectRegisteredSshTarget: connectRegisteredSshTargetMock,
getRegisteredSshState: getRegisteredSshStateMock,
listRegisteredSshTargets: listRegisteredSshTargetsMock,
+1 -1
View File
@@ -4,7 +4,7 @@ import {
getRegisteredSshState,
listRegisteredRemovedSshTargetLabels,
listRegisteredSshTargets
} from '../../../ipc/ssh'
} from '../../../ssh/ssh-target-registry'
import { defineMethod, type RpcMethod } from '../core'
import { getPublicSshError, getPublicSshState } from '../../public-ssh-state'
@@ -0,0 +1,53 @@
import type { RuntimeBrowserCommandHost, RuntimeBrowserCommands } from './orca-runtime-browser'
/**
* How `OrcaRuntimeService` obtains its browser-automation commands.
*
* Why a factory rather than a direct import: `orca-runtime-browser.ts` reaches the
* whole Chromium cluster `BrowserWindow`, `session`, `webContents`, cookie jars
* 15 modules that a Node host cannot load at all. Importing the class for its *type*
* is free; constructing it is what drags the cluster in.
*
* The desktop installs the real factory. A Node host installs none and every browser
* RPC rejects with `browser_unavailable`, which the runtime already advertises through
* capability filtering clients do not offer the affordance.
*
* Deliberately NOT a stub object with silently-succeeding methods: that is the
* "looks fine, returns a lie" shape this codebase rejects. Absent means rejected.
*/
export type RuntimeBrowserCommandsFactory = (
host: RuntimeBrowserCommandHost
) => RuntimeBrowserCommands
let currentFactory: RuntimeBrowserCommandsFactory | null = null
export function setRuntimeBrowserCommandsFactory(
factory: RuntimeBrowserCommandsFactory | null
): void {
currentFactory = factory
}
/**
* Build the commands, or a rejecting proxy when this host has no browser. The proxy
* throws per call rather than at construction so the runtime still starts the
* capability is simply not advertised.
*/
export function createRuntimeBrowserCommands(
host: RuntimeBrowserCommandHost
): RuntimeBrowserCommands {
if (currentFactory) {
return currentFactory(host)
}
return new Proxy({} as RuntimeBrowserCommands, {
get: (_target, property) => {
if (property === 'then') {
// Why: an awaited undefined must not look like a thenable.
return undefined
}
return () => {
throw new Error(`browser_unavailable: ${String(property)} needs a desktop host`)
}
}
})
}
@@ -0,0 +1,42 @@
import type { BrowserWindow, IpcMainEvent } from 'electron'
/**
* The desktop facilities `OrcaRuntimeService` uses, which a Node host does not have.
*
* Three sites, all optional by nature: a native notification toast, a lookup of the
* authoritative renderer window, and one ipcMain channel used only by the
* renderer-backed tab-create fallback. With no renderer that fallback is unreachable
* `createTerminal` already takes the background spawn branch when there is no
* authoritative window (#10333) so a Node host needs none of them.
*
* Defaults are inert rather than throwing, for the same reason as the PTY bindings: a
* host with no desktop legitimately has nothing here, and that is not a downgrade.
* Where absence IS user-visible a notification that would have been shown the
* runtime already routes to paired clients, which is the better destination anyway.
*/
export type RuntimeDesktopSurface = {
/** Show a native notification. Returns false when the host cannot, so callers can say so. */
showNotification(input: { title: string; body: string }): boolean
/** The renderer window with this id, or null when there is no desktop. */
findWindowById(id: number): BrowserWindow | null
onIpc(channel: string, listener: (event: IpcMainEvent, ...args: never[]) => void): void
removeIpcListener(channel: string, listener: (...args: never[]) => void): void
}
const inertDesktopSurface: RuntimeDesktopSurface = {
showNotification: () => false,
findWindowById: () => null,
onIpc: () => {},
removeIpcListener: () => {}
}
let current: RuntimeDesktopSurface = inertDesktopSurface
export function setRuntimeDesktopSurface(surface: RuntimeDesktopSurface | null): void {
current = surface ?? inertDesktopSurface
}
export function getRuntimeDesktopSurface(): RuntimeDesktopSurface {
return current
}
+3 -3
View File
@@ -3,7 +3,7 @@ timeout teardown must stay co-located so dictation lifecycle state cannot drift.
import { Worker } from 'node:worker_threads'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { app } from 'electron'
import { getAppEnvironment } from '../../shared/app-environment'
import { getCatalogModel } from './model-catalog'
import type { ModelManager } from './model-manager'
import { OpenAiTranscriptionSession } from './openai-transcription-client'
@@ -482,7 +482,7 @@ export class SttService {
}
private getWorkerPath(): string {
if (app.isPackaged) {
if (getAppEnvironment().isPackaged()) {
return join(process.resourcesPath, 'app.asar', 'out', 'main', 'stt-worker.js')
}
return join(__dirname, 'stt-worker.js')
@@ -564,7 +564,7 @@ export class SttService {
? 'sherpa-onnx-win-x64'
: `sherpa-onnx-${process.platform}-${process.arch}`
if (app.isPackaged) {
if (getAppEnvironment().isPackaged()) {
const resourcesNodeModule = join(process.resourcesPath, 'node_modules', nativePkg)
if (existsSync(resourcesNodeModule)) {
return resourcesNodeModule
+89
View File
@@ -0,0 +1,89 @@
import type { SshConnectionStore } from './ssh-connection-store'
import type { SshChannelMultiplexer } from './ssh-channel-multiplexer'
import type { SshConnectionState, SshTarget } from '../../shared/ssh-types'
/**
* The SSH target/state registry, split out of `ipc/ssh.ts`.
*
* Why: the Orca runtime reads registered SSH targets and state during normal
* operation, but `ipc/ssh.ts` also owns `ipcMain`, `powerMonitor` and a
* `BrowserWindow` accessor. Importing four thin accessors dragged all of Electron
* into the runtime's module graph.
*
* This holds only the registry: the store plus the two callbacks the handler layer
* installs. `registerSshHandlers` populates it; the runtime reads it. Keeping the
* indirection (rather than the runtime holding a manager directly) is deliberate
* SSH providers register after construction and may reconnect, so callers must
* resolve the current generation rather than freeze one.
*/
let sshStore: SshConnectionStore | null = null
let registeredConnectSshTarget: ((targetId: string) => Promise<SshConnectionState>) | null = null
let registeredGetSshState: ((targetId: string) => SshConnectionState | undefined) | null = null
export function setSshTargetRegistryStore(store: SshConnectionStore | null): void {
sshStore = store
}
export function getSshTargetRegistryStore(): SshConnectionStore | null {
return sshStore
}
export function setSshTargetRegistryHandlers(handlers: {
connect: ((targetId: string) => Promise<SshConnectionState>) | null
getState: ((targetId: string) => SshConnectionState | undefined) | null
}): void {
registeredConnectSshTarget = handlers.connect
registeredGetSshState = handlers.getState
}
export async function connectRegisteredSshTarget(targetId: string): Promise<SshConnectionState> {
if (!registeredConnectSshTarget) {
// Why this still throws: a headless host that never registered handlers must fail
// loudly rather than report a target as unreachable, which would read as `exited`.
throw new Error('ssh_handlers_not_registered')
}
return registeredConnectSshTarget(targetId)
}
export function getRegisteredSshState(targetId: string): SshConnectionState | undefined {
return registeredGetSshState?.(targetId)
}
/** Public targets for runtime RPC clients — same list the desktop renderer gets. */
export function listRegisteredSshTargets(): SshTarget[] {
return sshStore?.listTargets() ?? []
}
/** Removed-target id → last known label, for ghost-host display on paired clients. */
export function listRegisteredRemovedSshTargetLabels(): Record<string, string> {
return sshStore?.listRemovedTargetLabels() ?? {}
}
let registeredGetActiveMultiplexer:
| ((connectionId: string) => SshChannelMultiplexer | undefined)
| null = null
export function setSshActiveMultiplexerResolver(
resolve: ((connectionId: string) => SshChannelMultiplexer | undefined) | null
): void {
registeredGetActiveMultiplexer = resolve
}
/**
* The live channel multiplexer for a connection, or undefined when the target is not
* connected. Undefined means "not connected", never "the connection died" callers
* must not read absence here as an `exited` verdict (docs/reference/ssh-execution-boundary.md).
*
* Why it throws when no resolver is installed rather than returning undefined: that
* case is a wiring error, not a connection state, and the two are indistinguishable to
* callers. A host that never loaded the SSH layer would otherwise report every target as
* quietly "not connected" which is precisely the unverifiable-reported-as-exited
* conflation the execution-boundary doc exists to prevent.
*/
export function getActiveMultiplexer(connectionId: string): SshChannelMultiplexer | undefined {
if (!registeredGetActiveMultiplexer) {
throw new Error('ssh_active_multiplexer_resolver_not_installed')
}
return registeredGetActiveMultiplexer(connectionId)
}
+8
View File
@@ -68,6 +68,14 @@ export function setAppEnvironment(environment: AppEnvironment): void {
slot()[SLOT] = environment
}
/**
* Whether an environment is installed. For callers that must work in BOTH the desktop
* and a plain-Node fork those legitimately have no app root and want null, not a throw.
*/
export function hasAppEnvironment(): boolean {
return read() !== null
}
export function getAppEnvironment(): AppEnvironment {
const current = read()
if (!current) {