mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
Merge branch 'main' into brennanb2025/coercion-swap-window-r1
Resolve inspect.ts: #17011 moved pty:kill into renderer-kill.ts, which keeps its inline barrier await. The swap-window helper and both gates stay.
This commit is contained in:
@@ -112,7 +112,6 @@ All stable kinds (`patch`, `minor`, `major`) are computed off the latest _stable
|
||||
|
||||
The scheduled 2x/day RC cron in [`release-rc.yml`](../../actions/workflows/release-rc.yml) is independent and continues to run automatically from `main`.
|
||||
|
||||
|
||||
## Release Channels
|
||||
|
||||
The public Homebrew cask tracks stable desktop releases:
|
||||
|
||||
@@ -308,7 +308,6 @@ jobs:
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
- name: Publish daily macOS artifacts
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
|
||||
@@ -290,7 +290,6 @@ jobs:
|
||||
including back to Stable, works in-app from there."
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
- name: Publish hourly macOS artifacts
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
|
||||
@@ -34,8 +34,7 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
|
||||
@@ -844,6 +844,16 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Restore golden test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
tests/e2e/golden-source-control-open-diff.spec.ts \
|
||||
tests/e2e/golden-terminal-file-link.spec.ts
|
||||
|
||||
- name: Install native build tools
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||||
@@ -946,6 +956,16 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Restore skill-sharing test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
src/main/skills/skill-freshness-inventory.test.ts \
|
||||
src/main/skills/skill-provider-runtime-roots.test.ts
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
@@ -978,13 +998,26 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Install Ubuntu 20.04 prerequisites
|
||||
run: apt-get update && apt-get install -y build-essential ca-certificates git python3
|
||||
run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Trust the checked-out workspace in the job container
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Restore skill-sharing test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
src/main/skills/skill-freshness-inventory.test.ts \
|
||||
src/main/skills/skill-provider-runtime-roots.test.ts
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
@@ -7,13 +7,15 @@ All UI work — layout, color, typography, spacing, component selection, UX beha
|
||||
Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation.
|
||||
|
||||
# Style
|
||||
|
||||
## Reuse Before Reimplementing
|
||||
|
||||
Before writing new logic at any scale — a function, component, IPC channel, state store, or whole subsystem/flow — check whether an existing implementation already does the job (or nearly does). Extend or generalize it instead of building a parallel version; only write from scratch when nothing fits. Keep the check proportionate: a quick search for trivial code, a real one before building anything substantial.
|
||||
|
||||
## Concise/Brief Non-obvious Comments ONLY
|
||||
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
* BE CONCISE. 1 LINE if possible
|
||||
|
||||
- DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
- BE CONCISE. 1 LINE if possible
|
||||
|
||||
## Lint Rules: Do Not Disable Max Lines
|
||||
|
||||
@@ -32,6 +34,7 @@ Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuf
|
||||
- **Lint**: `oxlint`, or `pnpm run check:code-quality:changed` for changed files (full `pnpm lint` is slow); format with `pnpm format`
|
||||
|
||||
# Considerations
|
||||
|
||||
## Worktree Safety
|
||||
|
||||
Always use the primary working directory (the worktree) for all file reads and edits. Never follow absolute paths from subagent results that point to the main repo.
|
||||
|
||||
@@ -262,6 +262,7 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
|
||||
</p>
|
||||
|
||||
## Signed Builds
|
||||
|
||||
Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org).
|
||||
|
||||
## License
|
||||
|
||||
@@ -2148,9 +2148,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/cli/runtime/serve-signal-exit-diagnostic.test.ts",
|
||||
"assertions": [
|
||||
"late update handoff failure cannot rearm termination after child exit"
|
||||
]
|
||||
"assertions": ["late update handoff failure cannot rearm termination after child exit"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/serve-update-handoff.test.ts",
|
||||
@@ -3335,7 +3333,7 @@
|
||||
"durationSeconds": 1.5,
|
||||
"summary": "175/175 at the relocated paths on the unified tool authority, replacing the 2026-08-27 record of the same command at 174/174. That run killed twelve of fourteen mutants as sole deltas — dropping the document branch from the one authority door, removing either browsing-registration refusal or the mint-side refusal that is its converse, deleting the registration notification or widening it to the worktree-wide and any-tab waits, disposing revoked state by grant instead of by page, dropping the hosting-renderer or destroyed-contents check, rebuilding the renderer tool target from the grant, and deleting the teardown identity check under a re-mint — and reported two as ill-posed rather than as kills. The relocation changed no file it names except their directory."
|
||||
},
|
||||
{
|
||||
{
|
||||
"date": "2026-08-27",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
@@ -3380,7 +3378,7 @@
|
||||
"durationSeconds": 0.17,
|
||||
"summary": "22/22 on the candidate that stops a previewed document writing into this desktop's Downloads folder. The hole was read out of the shipping path, not guessed: the preview partition took the shared browser partition policies whole, including will-download, and a preview guest joins no browser-page registry, so handleGuestWillDownload resolved no owner context, routeBrowserClientDownload answered local for a non-client-hosted contents, and the item got a reserved Downloads name with no prompt and no tab to attribute it to. Downloads are now the one policy the preview partition does not inherit. Red-green with each mutant as the sole delta: making the installer ignore the deny option routes the preview partition's download to the router again, and making the preview protocol installer stop asking for the deny leaves the option unset. The absence half is paired with a presence precondition in the same run — an ordinary browsing partition installed beside the preview one in one test still reaches the download router, so a deny that fenced everything, or a download path that had been removed entirely, would fail rather than pass quietly."
|
||||
},
|
||||
{
|
||||
{
|
||||
"date": "2026-08-27",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
@@ -3443,7 +3441,7 @@
|
||||
"durationSeconds": 0.36,
|
||||
"summary": "The four surfaces the trusted-click route is built from passed 63/63: the preload's click policy over a real document, including an untrusted dispatched click, an untrusted middle click, an SVG animated href resolved against the document so a relative, rooted or fragment SVG link behaves exactly like the identical HTML anchor, fragment and percent-encoded fragment targets and a trusted middle click; the guest policy's deny-only sinks, its refusal of a navigation that arrives before a document has bound the guest to a grant, and its report gate for an unfocused guest, an unregistered sender, a missing or revoked grant and non-web URLs; will-attach-webview pinning the preview preload in both directions; and the click channel handing main its own sender without a trusted-renderer check."
|
||||
},
|
||||
{
|
||||
{
|
||||
"date": "2026-08-26",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
@@ -3470,7 +3468,7 @@
|
||||
"durationSeconds": 66,
|
||||
"summary": "The journey passed 3/3 again under --repeat-each=3 on the candidate that clears grants on every fresh shell document and gates the mint channel on the trusted renderer, so neither change disturbs a live preview: the document still rendered, both browser counts held at baseline, and the target=_blank press still routed out as an Orca browser tab."
|
||||
},
|
||||
{
|
||||
{
|
||||
"date": "2026-08-26",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
@@ -16840,9 +16838,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/agent-browser-bridge-tab-routing.test.ts",
|
||||
"assertions": [
|
||||
"closing a tab retires the exact named agent-browser session"
|
||||
]
|
||||
"assertions": ["closing a tab retires the exact named agent-browser session"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/startup/serve-signal-handlers.test.ts",
|
||||
|
||||
@@ -132,12 +132,16 @@ function compileNodePty(dir) {
|
||||
return built
|
||||
}
|
||||
console.log('[orcad-prebuilds] compiling node-pty from patched source ...')
|
||||
const result = spawnSync(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['node-gyp', 'rebuild'], {
|
||||
cwd: dir,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
windowsHide: true
|
||||
})
|
||||
const result = spawnSync(
|
||||
process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||
['node-gyp', 'rebuild'],
|
||||
{
|
||||
cwd: dir,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.status})`)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
slotName
|
||||
} from './build-orcad-prebuilds.mjs'
|
||||
|
||||
const PATCHED_BINDING_GYP = "'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']"
|
||||
const PATCHED_BINDING_GYP =
|
||||
"'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']"
|
||||
const PATCHED_PTY_CC = '__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);'
|
||||
|
||||
const dirs = []
|
||||
@@ -107,8 +108,9 @@ describe('mergeManifest', () => {
|
||||
|
||||
it('does not duplicate a slot rebuilt twice', () => {
|
||||
const once = mergeManifest(null, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' })
|
||||
expect(mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots)
|
||||
.toEqual(['darwin-arm64'])
|
||||
expect(
|
||||
mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots
|
||||
).toEqual(['darwin-arm64'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const NODE_PTY_JOB_EXPORTS = [
|
||||
'listJobProcessIds',
|
||||
'terminateJob',
|
||||
'assignCurrentProcessToJob'
|
||||
]
|
||||
const NODE_PTY_JOB_EXPORTS = ['listJobProcessIds', 'terminateJob', 'assignCurrentProcessToJob']
|
||||
|
||||
function assertNodePtyJobOwnership({ nativeName, native, platform = process.platform }) {
|
||||
if (platform !== 'win32' || nativeName !== 'conpty') {
|
||||
|
||||
@@ -10,6 +10,17 @@ const releaseWorkflow = parse(
|
||||
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
|
||||
describe('release E2E dispatch contract', () => {
|
||||
it('validates immutable tags with the current golden test harness', () => {
|
||||
const restoreStep = releaseWorkflow.jobs['terminal-rendering-golden'].steps.find(
|
||||
(step) => step.name === 'Restore golden test harness from the workflow ref'
|
||||
)
|
||||
|
||||
expect(restoreStep.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
|
||||
expect(restoreStep.run).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
|
||||
expect(restoreStep.run).toContain('golden-source-control-open-diff.spec.ts')
|
||||
expect(restoreStep.run).toContain('golden-terminal-file-link.spec.ts')
|
||||
})
|
||||
|
||||
it('dispatches tag-scoped E2E only after publication', () => {
|
||||
const dispatchJob = releaseWorkflow.jobs['post-release-e2e']
|
||||
const dispatchStep = dispatchJob.steps.find((step) => step.name === 'Dispatch tag-scoped E2E')
|
||||
|
||||
@@ -44,6 +44,48 @@ describe('skill-sharing release workflow', () => {
|
||||
expect(command).toContain('src/shared/skill-bundle-install-contract.test.ts')
|
||||
})
|
||||
|
||||
it('installs the archive tool required by Electron on the Linux floor', () => {
|
||||
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
|
||||
const prerequisites = stepNamed(linux, 'Install Ubuntu 20.04 prerequisites')
|
||||
|
||||
expect(prerequisites.run).toMatch(/apt-get install[^\n]*\bunzip\b/)
|
||||
})
|
||||
|
||||
it('trusts only the checked-out workspace before container git operations', () => {
|
||||
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
|
||||
const trustWorkspace = stepNamed(linux, 'Trust the checked-out workspace in the job container')
|
||||
const restoreHarness = stepNamed(
|
||||
linux,
|
||||
'Restore skill-sharing test harness from the workflow ref'
|
||||
)
|
||||
const safeDirectoryCommands = linux.steps
|
||||
.filter((step) => typeof step.run === 'string' && step.run.includes('safe.directory'))
|
||||
.map((step) => step.run)
|
||||
|
||||
expect(trustWorkspace.run).toBe('git config --global --add safe.directory "$GITHUB_WORKSPACE"')
|
||||
expect(linux.steps.indexOf(trustWorkspace)).toBeLessThan(linux.steps.indexOf(restoreHarness))
|
||||
expect(safeDirectoryCommands).toEqual([
|
||||
'git config --global --add safe.directory "$GITHUB_WORKSPACE"'
|
||||
])
|
||||
})
|
||||
|
||||
it('validates immutable tags with the current skill-sharing test harness', () => {
|
||||
for (const jobName of [
|
||||
'skill-sharing-release-gate',
|
||||
'skill-sharing-linux-floor-release-gate'
|
||||
]) {
|
||||
const restore = stepNamed(
|
||||
workflow.jobs[jobName],
|
||||
'Restore skill-sharing test harness from the workflow ref'
|
||||
)
|
||||
|
||||
expect(restore.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
|
||||
expect(restore.run).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
|
||||
expect(restore.run).toContain('skill-freshness-inventory.test.ts')
|
||||
expect(restore.run).toContain('skill-provider-runtime-roots.test.ts')
|
||||
}
|
||||
})
|
||||
|
||||
it('archives bounded machine-readable evidence from every platform', () => {
|
||||
for (const jobName of [
|
||||
'skill-sharing-release-gate',
|
||||
|
||||
+14
-14
@@ -137,20 +137,20 @@ Browse `src/renderer/src/components/ui/` for the full list. Most wrap a Radix UI
|
||||
|
||||
When a control has multiple plausible primitives, use this fork:
|
||||
|
||||
| You want… | Reach for | Don't use |
|
||||
| ------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------- |
|
||||
| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
|
||||
| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
|
||||
| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
|
||||
| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
|
||||
| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
|
||||
| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
|
||||
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
|
||||
| Single choice from a known list | `Select` | Custom listbox |
|
||||
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
|
||||
| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
|
||||
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
|
||||
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
|
||||
| You want… | Reach for | Don't use |
|
||||
| ------------------------------------------------------------ | ------------------------------------------ | ------------------------------------- |
|
||||
| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
|
||||
| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
|
||||
| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
|
||||
| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
|
||||
| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
|
||||
| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
|
||||
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
|
||||
| Single choice from a known list | `Select` | Custom listbox |
|
||||
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
|
||||
| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
|
||||
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
|
||||
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
|
||||
|
||||
If you find yourself styling around a primitive (`<Popover>` to act like a `<Dialog>`, or vice versa), stop and reconsider — the focus-management semantics differ and a future contributor will be misled by the mismatch.
|
||||
|
||||
|
||||
@@ -17,23 +17,59 @@ differ on other Debian-derived releases.
|
||||
|
||||
## Ubuntu and Debian prerequisites
|
||||
|
||||
Install the AppImage runtime dependency and Xvfb:
|
||||
Install the CLI tools, Xvfb, and the shared libraries Electron links against.
|
||||
A minimal server or container image ships none of the Electron libraries, and
|
||||
`orca serve` then fails before Electron starts:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl file jq xvfb zlib1g-dev
|
||||
sudo apt-get install -y \
|
||||
curl file jq xvfb zlib1g-dev ca-certificates git \
|
||||
libgtk-3-0t64 libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 libgbm1 libasound2t64 \
|
||||
libxtst6 libcups2t64 libdrm2 libxkbcommon0 libpango-1.0-0 libcairo2 libatspi2.0-0t64 \
|
||||
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libxrender1 libx11-xcb1 \
|
||||
libxcb-dri3-0 libxss1
|
||||
```
|
||||
|
||||
On Ubuntu 22.04, install `libfuse2` to execute the AppImage through FUSE. On
|
||||
Ubuntu 24.04 and Debian, the equivalent package may be `libfuse2t64`. FUSE is
|
||||
That command is for Ubuntu 24.04 and newer and Debian 13 and newer. Those
|
||||
releases carried out the 64-bit `time_t` transition, which renamed six of the
|
||||
packages with a `t64` suffix. On Ubuntu 20.04, Ubuntu 22.04, and Debian 12,
|
||||
substitute the unsuffixed names:
|
||||
|
||||
- `libgtk-3-0t64` becomes `libgtk-3-0`
|
||||
- `libatk1.0-0t64` becomes `libatk1.0-0`
|
||||
- `libatk-bridge2.0-0t64` becomes `libatk-bridge2.0-0`
|
||||
- `libasound2t64` becomes `libasound2`
|
||||
- `libcups2t64` becomes `libcups2`
|
||||
- `libatspi2.0-0t64` becomes `libatspi2.0-0`
|
||||
|
||||
The other names are identical on every supported release. The substitution is not
|
||||
symmetric, so use the list that matches the release. A `t64` name on Ubuntu 20.04,
|
||||
Ubuntu 22.04, or Debian 12 fails immediately with `E: Unable to locate package
|
||||
libgtk-3-0t64`. In the other direction the old names mostly still resolve, because
|
||||
each renamed package declares `Provides:` its unsuffixed name — except `libasound2`
|
||||
on Ubuntu 24.04, where `liboss4-salsa-asound2` in `universe` claims that name too.
|
||||
apt will not choose between two providers and exits with `E: Package 'libasound2'
|
||||
has no installation candidate`, which aborts the entire install line and leaves none
|
||||
of the libraries installed.
|
||||
|
||||
On Ubuntu 20.04 and 22.04, install `libfuse2` to execute the AppImage through
|
||||
FUSE. On Ubuntu 24.04 and Debian 13 the package is `libfuse2t64`, though the plain
|
||||
`libfuse2` name also resolves there because nothing else provides it. FUSE is
|
||||
optional: without it, use the AppImage's supported extraction path:
|
||||
|
||||
```bash
|
||||
cd /opt/orca
|
||||
./orca-linux.AppImage --appimage-extract
|
||||
sudo chmod -R a+rX /opt/orca/squashfs-root
|
||||
/opt/orca/squashfs-root/AppRun serve --port 6768
|
||||
```
|
||||
|
||||
The `chmod` is required whenever the extraction runs as a different user than
|
||||
the server: `--appimage-extract` creates `squashfs-root` as `drwx------` owned by
|
||||
the extracting user, so anyone else — including a dedicated service user — cannot
|
||||
even traverse it, and the run fails before Electron starts.
|
||||
|
||||
Docker commonly has no FUSE device. Use `--appimage-extract` once or
|
||||
`--appimage-extract-and-run`; neither requires a privileged container. The
|
||||
extract-and-run wrapper can print extracted paths before Orca starts, so
|
||||
@@ -144,8 +180,15 @@ AppImage, but must not be able to replace it or the rollback artifacts.
|
||||
sudo useradd --system --create-home --shell /usr/sbin/nologin orca
|
||||
sudo chown root:root /opt/orca /opt/orca/orca-linux.AppImage
|
||||
sudo chmod 755 /opt/orca /opt/orca/orca-linux.AppImage
|
||||
# Only if you ran --appimage-extract: extraction leaves squashfs-root root-only.
|
||||
sudo chmod -R a+rX /opt/orca/squashfs-root
|
||||
```
|
||||
|
||||
The last line matters because the two halves of this guide combine badly without
|
||||
it. `--appimage-extract` writes `squashfs-root` as `drwx------ root root`, so the
|
||||
`orca` service user cannot read or traverse the extracted tree and the unit fails
|
||||
at startup. `chmod 755 /opt/orca` alone does not reach into it.
|
||||
|
||||
For most hosts, one `orca serve` service is enough because Orca starts Xvfb on
|
||||
display `:99` when no display exists:
|
||||
|
||||
@@ -834,11 +877,12 @@ refuse to run there and print the command to run on the machine you want.
|
||||
- GPU or DRI warnings on a VPS: keep `LIBGL_ALWAYS_SOFTWARE=1` in the service
|
||||
environment.
|
||||
- Chromium sandbox errors: confirm the service is running as the non-root
|
||||
`orca` user and that `/opt/orca` is readable by that user.
|
||||
`orca` user and that `/opt/orca` is readable by that user, including
|
||||
`/opt/orca/squashfs-root` if you extracted the AppImage.
|
||||
- Clients cannot connect: make sure `--pairing-address` is an address reachable
|
||||
from the client, and make sure firewalls allow the selected `--port`.
|
||||
- Journal shows `Another Orca instance is already running for this userData
|
||||
profile` and the unit exits `3`: another process already owns the profile, so
|
||||
profile` and the unit exits `3`: another process already owns the profile, so
|
||||
`RestartPreventExitStatus=3` leaves the unit `failed` on purpose. Find the
|
||||
owner with `systemctl status orca-serve` and `pgrep -af orca`. Stop it (or
|
||||
keep it and leave the unit down), then run
|
||||
@@ -857,4 +901,7 @@ refuse to run there and print the command to run on the machine you want.
|
||||
`sudo systemctl reset-failed orca-serve.service` first.
|
||||
- Diagnosing other missing libraries: extract the AppImage without launching it
|
||||
with `./orca-linux.AppImage --appimage-extract`, then run
|
||||
`ldd squashfs-root/orca` to list any shared libraries the host is missing.
|
||||
`ldd squashfs-root/orca-ide` to list any shared libraries the host is missing.
|
||||
The Electron binary is `orca-ide`, not `orca`; `ldd` on a path that does not
|
||||
exist prints nothing and exits cleanly, which reads as a clean result in
|
||||
exactly the situation where you are hunting a missing library.
|
||||
|
||||
@@ -25,11 +25,11 @@ broke launch on Ubuntu 20.04 ([#9902](https://github.com/stablyai/orca/issues/99
|
||||
The specific trap is glibc's 2.32–2.34 "libpthread/libutil merge", which moved
|
||||
several long-stable functions into libc under brand-new symbol versions:
|
||||
|
||||
| Symbol | New version | node-pty use |
|
||||
| ----------------- | ------------- | ----------------------- |
|
||||
| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
|
||||
| `openpty` | `GLIBC_2.34` | allocate the pty |
|
||||
| `forkpty` | `GLIBC_2.34` | fork the shell |
|
||||
| Symbol | New version | node-pty use |
|
||||
| ----------------- | ------------ | ----------------------- |
|
||||
| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
|
||||
| `openpty` | `GLIBC_2.34` | allocate the pty |
|
||||
| `forkpty` | `GLIBC_2.34` | fork the shell |
|
||||
|
||||
Electron itself (glibc 2.25) and the other bundled native modules
|
||||
(`sherpa-onnx`, `@parcel/watcher`, both prebuilt on old glibc) stay well under
|
||||
|
||||
@@ -10,12 +10,12 @@ Design background: `docs/design/shipping-orcad.html` §00c and §04.
|
||||
|
||||
A deployment is **orcad** plus **the terminal daemon**.
|
||||
|
||||
| | orcad | terminal daemon |
|
||||
| --- | --- | --- |
|
||||
| Started by | the supervisor | orcad, detached |
|
||||
| Owns | RPC, git, worktrees, persistence | every local PTY |
|
||||
| Lifetime | one supervised run | **outlives orcad** |
|
||||
| Endpoint | `ws://<bind>:<port>` | `<data-root>/daemon/daemon-v<N>.sock` |
|
||||
| | orcad | terminal daemon |
|
||||
| ---------- | -------------------------------- | ------------------------------------- |
|
||||
| Started by | the supervisor | orcad, detached |
|
||||
| Owns | RPC, git, worktrees, persistence | every local PTY |
|
||||
| Lifetime | one supervised run | **outlives orcad** |
|
||||
| Endpoint | `ws://<bind>:<port>` | `<data-root>/daemon/daemon-v<N>.sock` |
|
||||
|
||||
The daemon outliving orcad is the property the whole peer model is recommended for
|
||||
(`docs/reference/ssh-execution-boundary.md`): daemon-backed PTYs stay `live` across a runtime
|
||||
@@ -52,13 +52,13 @@ The data root is `$ORCA_USER_DATA`, else `$XDG_DATA_HOME/Orca`, else `~/.orca`.
|
||||
Before the profile index or the store is touched, orcad takes `<data-root>/orcad.lock`.
|
||||
It refuses to start when:
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) |
|
||||
| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened |
|
||||
| `orcad_instance_lock_held` | another live orcad owns this root |
|
||||
| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity |
|
||||
| `orcad_data_root_unusable` | the root cannot be created, stat'd or written |
|
||||
| Code | Meaning |
|
||||
| -------------------------------------- | ------------------------------------------------------------- |
|
||||
| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) |
|
||||
| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened |
|
||||
| `orcad_instance_lock_held` | another live orcad owns this root |
|
||||
| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity |
|
||||
| `orcad_data_root_unusable` | the root cannot be created, stat'd or written |
|
||||
|
||||
A root that is merely too permissive and that we own is tightened to `0700` rather than
|
||||
refused — orcad stores credentials there unsealed (no OS keyring on this host), so the goal
|
||||
@@ -91,14 +91,15 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to
|
||||
and exits 1, so the failure stays attributable instead of arriving as an unlogged kill.
|
||||
- **Exit codes.**
|
||||
|
||||
| Code | Meaning | Supervisor should |
|
||||
| --- | --- | --- |
|
||||
| 0 | clean shutdown | restart per policy |
|
||||
| 1 | startup or shutdown failure | restart with backoff |
|
||||
| 78 | configuration fault (bind address, data root, instance lock) | **not** restart |
|
||||
| Code | Meaning | Supervisor should |
|
||||
| ---- | ------------------------------------------------------------ | -------------------- |
|
||||
| 0 | clean shutdown | restart per policy |
|
||||
| 1 | startup or shutdown failure | restart with backoff |
|
||||
| 78 | configuration fault (bind address, data root, instance lock) | **not** restart |
|
||||
|
||||
78 is `EX_CONFIG`. Put it in systemd's `RestartPreventExitStatus`: restarting on a data
|
||||
root owned by someone else is a restart-spin, not a recovery.
|
||||
|
||||
- **Logs.** orcad writes human-readable diagnostics to **stderr** and its readiness contract
|
||||
to **stdout**; the supervisor owns capture and rotation. The daemon, being detached, writes
|
||||
its own NDJSON lifecycle log to `<data-root>/logs/daemon.log` (suppressed by
|
||||
@@ -110,7 +111,7 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to
|
||||
- **Launch.** Forked detached from `daemon-entry.js` beside `orcad.js`, with its own PID
|
||||
record, token and socket under `<data-root>/daemon`.
|
||||
- **Adoption before spawn.** A daemon already answering the endpoint is adopted, not
|
||||
replaced, unless it is unhealthy, foreign, or built from a superseded bundle *and* owns no
|
||||
replaced, unless it is unhealthy, foreign, or built from a superseded bundle _and_ owns no
|
||||
live sessions. Replacing a healthy daemon kills its PTYs, so code freshness always defers
|
||||
to live work.
|
||||
- **Restart.** The adapter respawns the daemon on death, transparently to callers.
|
||||
|
||||
@@ -113,7 +113,7 @@ getting that wrong turns a skew into a false "nothing is blocked".
|
||||
unverifiable, the pane was unreadable, or the agent probe did not answer in time.
|
||||
|
||||
A new client against an old host sees the field absent, which is why absence must read as
|
||||
*unknown* and never as *not waiting*. Collapsing absent into `null` at any hop — including a
|
||||
_unknown_ and never as _not waiting_. Collapsing absent into `null` at any hop — including a
|
||||
convenience `?? null` in an RPC handler — makes an old or unreachable peer indistinguishable
|
||||
from a healthy idle worker, which is the exact failure the field exists to remove.
|
||||
|
||||
@@ -180,7 +180,7 @@ has to be there before the first snapshot is interpreted, which is earlier than
|
||||
renderer could wait on. Every other viewer — a second desktop, the web client, which installs no
|
||||
page renderer at all, the dashboard pop-out, which is deliberately left unstamped — keeps tracking
|
||||
the host, which is the only reason a mirrored viewer shows anything but its first snapshot
|
||||
forever. Improving what a *second* client sees still means fixing the publish, not the carve-out;
|
||||
forever. Improving what a _second_ client sees still means fixing the publish, not the carve-out;
|
||||
the carve-out no longer stands in the way of it.
|
||||
|
||||
The two failure fields above are deliberately left on the looser `placement?.kind !== 'client'`
|
||||
|
||||
@@ -28,11 +28,11 @@ Three corrections to the first draft:
|
||||
verified. The ssh2 proxy-spawn at `:697` is effectively unreachable. Good news for migration, and
|
||||
the first draft's motivating example was simply wrong.
|
||||
- **Agent forwarding was overstated.** `agentForward` is gated on the user's `ForwardAgent yes`
|
||||
(`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent *auth*, whose
|
||||
(`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent _auth_, whose
|
||||
signatures bind the session id and cannot be replayed onward. The risk applies to users who opted
|
||||
into `ForwardAgent`, not everyone.
|
||||
- **Credential theft was understated, and the relay claim was backwards.** `isAgentFallbackError`
|
||||
treats *any* auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
|
||||
treats _any_ auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
|
||||
publickey walks the user to the password prompt (`ssh-connection.ts:844`) and the private-key
|
||||
**passphrase** prompt (`:834`), and `cachedPassword` is replayed without prompting on every
|
||||
reconnect (`:709`). Meanwhile the relay upload matters less than assumed — the attacker already
|
||||
@@ -65,7 +65,7 @@ Two consequences to own rather than discover:
|
||||
|
||||
There is no `ssh`-only way out of this: `-F /dev/null` does NOT invert the exclusion, it reports
|
||||
built-in defaults, so a probe built on it looks permissive on every machine. Verified against
|
||||
OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — *could*
|
||||
OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — _could_
|
||||
the site config be restricting host keys — where anything ambiguous (unreadable, an unresolvable
|
||||
`Include`, the directive present at all) keeps the refusal. Only a site config that demonstrably
|
||||
says nothing about host keys clears it, which is what stops the rule punishing every devcontainer,
|
||||
@@ -130,16 +130,16 @@ connect, training them to dismiss the one warning that matters.
|
||||
|
||||
> **Corrected against a live client.** The premise above is wrong about OpenSSH, though the
|
||||
> conclusion survives. `check_key_in_hostkeys` is not type-scoped at all: ANY non-marker entry for
|
||||
> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — known_hosts
|
||||
> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — `known_hosts`
|
||||
> holding only `ssh-rsa` against an ed25519-only server prints `IDENTIFICATION HAS CHANGED` and
|
||||
> refuses. So ssh does not avoid the false alarm by scoping; it avoids the *situation* via
|
||||
> refuses. So ssh does not avoid the false alarm by scoping; it avoids the _situation_ via
|
||||
> `order_hostkeyalgs`, and hard-fails when the situation arises anyway. Our split into `mismatch`
|
||||
> and `unknown-type-known-host` therefore only chooses the wording — both refuse, which is ssh's
|
||||
> action. What the ordering below buys us is what it buys ssh: the situation mostly never arises.
|
||||
|
||||
**But scoping alone is a downgrade vector, and this is the correction that most changes the design.**
|
||||
OpenSSH is safe here only because `order_hostkeyalgs()` reorders the client's proposed host-key
|
||||
algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the *client's* order
|
||||
algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the _client's_ order
|
||||
priority — so a server cannot choose a type the client deprioritised. ssh2 negotiates ed25519 first
|
||||
regardless. An attacker who cannot forge the RSA key on file simply presents ed25519 and receives a
|
||||
friendly first-contact prompt instead of a hard failure.
|
||||
@@ -147,7 +147,7 @@ friendly first-contact prompt instead of a hard failure.
|
||||
Therefore: **set ssh2's `algorithms.serverHostKey` to lead with the key types already known for that
|
||||
host.** Type scoping without algorithm ordering is not a safe design.
|
||||
|
||||
And when the presented type is unknown *while other types are known for this host*, that is
|
||||
And when the presented type is unknown _while other types are known for this host_, that is
|
||||
`unknown-type-known-host` — never a plain TOFU prompt. It must say we already hold a different key
|
||||
for this host.
|
||||
|
||||
@@ -193,7 +193,7 @@ means nothing is known.
|
||||
|
||||
### D5. Recovery must not live in the failure dialog
|
||||
|
||||
A "forget this host key" button *in* the mismatch dialog is D4's rejected "trust anyway" with one
|
||||
A "forget this host key" button _in_ the mismatch dialog is D4's rejected "trust anyway" with one
|
||||
extra click. Recovery lives in target settings: a separate, deliberate surface, no auto-retry, and it
|
||||
shows the stored fingerprint so the user is choosing knowingly.
|
||||
|
||||
@@ -201,7 +201,7 @@ Offer it only when **our** store is what disagreed; when `known_hosts` disagrees
|
||||
record cannot unblock the connect. Messages, written to avoid naming internals:
|
||||
|
||||
> **Ours disagreed** — "The host key for `build-01` changed since you last connected from Orca. If you
|
||||
> rebuilt or reprovisioned this machine, this is expected." → *Forget the saved key* / *Cancel*
|
||||
> rebuilt or reprovisioned this machine, this is expected." → _Forget the saved key_ / _Cancel_
|
||||
|
||||
> **`known_hosts` disagreed** — "The host key for `build-01` does not match the entry in
|
||||
> `~/.ssh/known_hosts`. `ssh` and `git` will refuse this host too. Run `ssh-keygen -R build-01`." →
|
||||
@@ -239,7 +239,7 @@ machines, two targets can name one machine, and a re-created target must not los
|
||||
The store is a **dedicated file**, not the main persistence blob (`persistence.ts:7088`): a settings
|
||||
restore or rollback must not silently reset trust. Accept and mismatch events are logged.
|
||||
|
||||
`hostKeyFingerprint` is now security-relevant *and* wire-relevant — it is an isolation namespace sent
|
||||
`hostKeyFingerprint` is now security-relevant _and_ wire-relevant — it is an isolation namespace sent
|
||||
to the host (`ssh-relay-session.ts:1298`, `managed-hook-owner-identity.ts:187`). It is `undefined` on
|
||||
the system transport, so **no trust logic may key off it**, and its format must not change (see
|
||||
Traps).
|
||||
@@ -247,11 +247,11 @@ Traps).
|
||||
## Phasing — ship the defence before the dialog
|
||||
|
||||
Review made the case that the riskiest part of this change is not the security model but the modal.
|
||||
Startup restore fires eager connects for *all* previously-active targets in parallel (`App.tsx:1041`)
|
||||
Startup restore fires eager connects for _all_ previously-active targets in parallel (`App.tsx:1041`)
|
||||
with a 15s timeout, while a prompt would live 120s — N unknown hosts means N stacked dialogs
|
||||
outliving the timeout that already deferred them. Runtime-owned ephemeral VMs
|
||||
(`ephemeral-vm-runtime-ssh.ts:31`) dial a freshly provisioned host with a brand-new key on every
|
||||
launch. Paired-web connects run on the *host desktop* (`runtime/rpc/methods/ssh.ts:32`), so the
|
||||
launch. Paired-web connects run on the _host desktop_ (`runtime/rpc/methods/ssh.ts:32`), so the
|
||||
dialog would open on someone else's screen while the web user watches a spinner.
|
||||
|
||||
**Phase 1 — no new modal.** Consult `known_hosts` + our store. `match` connects. `unknown` persists
|
||||
@@ -301,10 +301,11 @@ Each of these makes the fix silently do nothing. All confirmed in our tree.
|
||||
## Scope
|
||||
|
||||
**In scope, corrected:** IPv6 literals and `[host]:port` bracket parsing. Review was right that this
|
||||
is a *parser* requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
|
||||
is a _parser_ requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
|
||||
`unknown`, which is the prompt-training harm D3 exists to avoid.
|
||||
|
||||
**Out of scope, with consequences stated:**
|
||||
|
||||
- **`CheckHostIP`** — OpenSSH defaults it off; we form candidates from the hostname only.
|
||||
- **WSL** — `src/main/ssh/` has no WSL awareness; a distro's `known_hosts` is unreachable, so WSL
|
||||
users get first-contact treatment for hosts they already verified.
|
||||
@@ -337,7 +338,7 @@ verified by running an OpenSSH 10.2p1 client against a real `sshd` on `127.0.0.1
|
||||
its verdict:
|
||||
|
||||
- **The bare-host fallback pass never reports a change.** With `StrictHostKeyChecking=accept-new`, a
|
||||
bare line holding a *different* key, dialed on a non-default port, made ssh connect and append a
|
||||
bare line holding a _different_ key, dialed on a non-default port, made ssh connect and append a
|
||||
new `[127.0.0.1]:2222` line — first contact, no `IDENTIFICATION HAS CHANGED`. Reporting `mismatch`
|
||||
on that pass would refuse hosts ssh connects to happily, and would have looked like the cautious
|
||||
choice.
|
||||
@@ -355,16 +356,16 @@ subtly wrong.**
|
||||
Fixed after review:
|
||||
|
||||
1. **Our own store was type-downgradable.** The inline lookup filtered by key type first and could
|
||||
only answer match/mismatch/unknown, so a record of a *different* type read as `unknown`. D3's
|
||||
only answer match/mismatch/unknown, so a record of a _different_ type read as `unknown`. D3's
|
||||
downgrade, applied to the records we create ourselves. Stored types now also feed the algorithm
|
||||
ordering — without that the guard is only half present.
|
||||
2. **We keyed on the Orca label.** `ssh -G` echoes its own argument back as `hostname` when no Host
|
||||
block matches, so for a manual target `resolved.hostname` *is* the label — the one name D2
|
||||
block matches, so for a manual target `resolved.hostname` _is_ the label — the one name D2
|
||||
forbids. We consulted no entries at all.
|
||||
3. **A refused key still walked the credential ladder.** ssh2 reports a denial as a generic auth
|
||||
failure, so we went on to prompt for the passphrase and hand it to the host we had just refused.
|
||||
Rejections are now a typed error recognised before any fallback.
|
||||
4. **Fail-closed nearly became fail-always.** "No readable known_hosts" counted a *missing* file the
|
||||
4. **Fail-closed nearly became fail-always.** "No readable `known_hosts`" counted a _missing_ file the
|
||||
same as an unreadable one, so a profile that had never connected — everyone's first run — would
|
||||
have been refused, and the suite passed only because dev machines have a `known_hosts`.
|
||||
5. **Ephemeral runtimes were refused for a policy they cannot satisfy.** The carve-out sat below the
|
||||
@@ -404,7 +405,7 @@ tooltip, and the terminal reconnect overlay never asked for it at all. Still ope
|
||||
`getPublicSshState`, so a paired-web client always sees exactly `SSH connection unavailable`.
|
||||
Pre-existing, but it makes the Phase 2 dialog message unreachable there without a change. Note the
|
||||
redaction is not web-only: any target owned by a paired runtime environment is redacted, so a
|
||||
*desktop* user viewing a remote-Orca-server-owned host gets the same generic string.
|
||||
_desktop_ user viewing a remote-Orca-server-owned host gets the same generic string.
|
||||
- **RPC fail-fast** becomes load-bearing the moment the dialog exists (see Phasing).
|
||||
|
||||
**Known gaps that Phase 1 accepts, listed so they are choices and not surprises:**
|
||||
|
||||
@@ -15,7 +15,10 @@ since removed.
|
||||
table. It wraps a Toolhelp32 snapshot from `@vscode/windows-process-tree`.
|
||||
|
||||
```ts
|
||||
import { readWindowsProcessTable, readWindowsProcessTableFresh } from '../windows/windows-process-table'
|
||||
import {
|
||||
readWindowsProcessTable,
|
||||
readWindowsProcessTableFresh
|
||||
} from '../windows/windows-process-table'
|
||||
```
|
||||
|
||||
- `readWindowsProcessTable()` — shared TTL cache. Use for anything periodic.
|
||||
@@ -31,11 +34,11 @@ survived its own teardown (#9045).
|
||||
|
||||
Measured on Windows 11 with 1050 processes (p50 / p95):
|
||||
|
||||
| | p50 | p95 |
|
||||
| --- | --- | --- |
|
||||
| pid + ppid + name | 15.9 ms | 17.5 ms |
|
||||
| + memory + command line | 30.6 ms | 33.7 ms |
|
||||
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
|
||||
| | p50 | p95 |
|
||||
| -------------------------------- | ------- | ------- |
|
||||
| pid + ppid + name | 15.9 ms | 17.5 ms |
|
||||
| + memory + command line | 30.6 ms | 33.7 ms |
|
||||
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
|
||||
|
||||
Those CIM numbers are from a 1050-process host. The scan scales with process
|
||||
count: on a 1486-process Windows SSH host it measured **1.36 s** and produced
|
||||
@@ -62,7 +65,7 @@ Two guards, and they work together:
|
||||
the module refuses every further read until that read's callback fires.
|
||||
|
||||
The wedge used to be a 30 s cooldown that let one probe through per window. That
|
||||
bounded the *rate* of new callbacks but not the total: a permanently wedged
|
||||
bounded the _rate_ of new callbacks but not the total: a permanently wedged
|
||||
reader retained one more closure every 30 s for as long as the app ran, and each
|
||||
probe also blocked its caller for the full 3 s deadline first. Gating on the
|
||||
outstanding read instead bounds retention at exactly one callback, and gives up
|
||||
@@ -90,7 +93,7 @@ checked on a real Windows SSH host with 1486 processes:
|
||||
|
||||
**Installing it normally rebuilds from source, and that build fails.** The
|
||||
tarball carries a `binding.gyp`, so npm runs `node-gyp rebuild` regardless of
|
||||
what is already compiled inside it. On a host that *already had* MSVC Build
|
||||
what is already compiled inside it. On a host that _already had_ MSVC Build
|
||||
Tools 2022 installed, that build still failed:
|
||||
|
||||
```
|
||||
@@ -103,7 +106,7 @@ would then break outright rather than degrade: `installNativeDeps` throws on
|
||||
failure, and the toolchain-skip retry is gated to Linux.
|
||||
|
||||
**Skipping the build and using the shipped binary returns a truncated table.**
|
||||
Contrary to what this file used to claim, the published 0.8.0 tarball *does*
|
||||
Contrary to what this file used to claim, the published 0.8.0 tarball _does_
|
||||
contain `build/Release/windows_process_tree.node` — an MSVC build directory that
|
||||
looks accidentally published (`.obj` and `.tlog` files ship with it). It is
|
||||
N-API, so it loads on any modern Node. But it predates our patch and still has
|
||||
@@ -151,14 +154,14 @@ it as an optional relay artifact.
|
||||
`config/scripts/build-windows-process-tree-relay-addon.mjs` builds it from the
|
||||
source pnpm has already patched, on a Windows runner, and refuses to run if
|
||||
any patch hunk is missing — the Spectre hunk fails loudly, the 1024-process
|
||||
hunk fails *silently*, and the relative gyp path dies at configure on Windows.
|
||||
hunk fails _silently_, and the relative gyp path dies at configure on Windows.
|
||||
The source is checked rather than the install trusted. It also reads the PE
|
||||
machine field of the output, because a cross-build that quietly emitted host
|
||||
arch would ship a binary the target cannot load.
|
||||
|
||||
Windows arm64 cross-compiles from the x64 runner — verified on real hardware,
|
||||
producing `IMAGE_FILE_MACHINE_ARM64` (0xaa64) against x64's 0x8664. It needs the
|
||||
optional *MSVC v143 ARM64 build tools* component; without it node-gyp fails with
|
||||
optional _MSVC v143 ARM64 build tools_ component; without it node-gyp fails with
|
||||
`MSB8020`, which is why the addon build runs before the long packaging step.
|
||||
`ORCA_REQUIRE_RELAY_NATIVE_ADDONS` is a per-arch list so a future arch can be
|
||||
added best-effort before it is promoted to required.
|
||||
@@ -258,7 +261,7 @@ The per-PTY job deliberately does **not** set
|
||||
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Measured on Windows 11: with that flag,
|
||||
releasing the handle when the shell exits also kills whatever the user left
|
||||
running, so typing `exit` in a pane reaped a `start /b` server that used to
|
||||
survive. The job exists to make an *explicit* teardown exact, not to redefine
|
||||
survive. The job exists to make an _explicit_ teardown exact, not to redefine
|
||||
what a clean exit means.
|
||||
|
||||
Reaping a dead daemon's shells (#9195, #10415) is therefore a **second, nested
|
||||
@@ -283,7 +286,7 @@ kill-on-close job on the app, which is exactly what the crash-survival
|
||||
guarantee forbids.
|
||||
|
||||
Once the shell exits, node-pty drops its handle record and closes the job, so a
|
||||
terminated tree reports `null` rather than `[]`. Null means *unverifiable* in
|
||||
terminated tree reports `null` rather than `[]`. Null means _unverifiable_ in
|
||||
the sense of [`ssh-execution-boundary.md`](./ssh-execution-boundary.md) — no job
|
||||
support, not a ConPTY, or no longer tracked. It is never evidence that
|
||||
processes died.
|
||||
|
||||
@@ -25,7 +25,7 @@ scripts:
|
||||
|
||||
## Why the script declares it, not the terminal preference
|
||||
|
||||
`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the
|
||||
`terminalWindowsShell` says which shell _interactive terminals_ open in. It says nothing about the
|
||||
language a project's setup script is written in. Deriving the runner from it had two consequences:
|
||||
|
||||
- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`,
|
||||
|
||||
@@ -35,7 +35,7 @@ whichever poller arrives first afterwards pays a full-fleet `git worktree list`
|
||||
fan-out. The many high-frequency callers — `listTerminals` without a selector,
|
||||
`showTerminal`, `getWorktreePs`, `listManagedWorktrees`,
|
||||
`resolveWorktreeSelector`, orchestration authority refresh — only determine
|
||||
*who* pays, not *how often*. Steady-state subprocess volume is therefore
|
||||
_who_ pays, not _how often_. Steady-state subprocess volume is therefore
|
||||
`repos / 30 s`, independent of poll rate, which is exactly the observed
|
||||
~1 sweep / 30.5 s.
|
||||
|
||||
@@ -86,16 +86,16 @@ common directory without a subprocess (read `.git`; if it is a `gitdir:` file,
|
||||
follow it and then its `commondir`; if `.git` is absent, treat `repoPath` as a
|
||||
bare gitdir), then records:
|
||||
|
||||
| Input | External change it catches |
|
||||
| --- | --- |
|
||||
| sorted entry names of `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
|
||||
| existence of `repoPath` | main checkout deleted |
|
||||
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
|
||||
| `<commonDir>/reftable` mtime + size | a tip moved under the reftable backend |
|
||||
| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
|
||||
| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
|
||||
| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
|
||||
| per entry: `locked` presence | `worktree lock` / `unlock` |
|
||||
| Input | External change it catches |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| sorted entry names of `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
|
||||
| existence of `repoPath` | main checkout deleted |
|
||||
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
|
||||
| `<commonDir>/reftable` mtime + size | a tip moved under the reftable backend |
|
||||
| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
|
||||
| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
|
||||
| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
|
||||
| per entry: `locked` presence | `worktree lock` / `unlock` |
|
||||
| per entry: existence of the path named by `gitdir` | a worktree directory deleted with `rm -rf` (flips `prunable`) |
|
||||
|
||||
"per checkout" covers the main worktree and each linked worktree. Reading the
|
||||
@@ -159,21 +159,21 @@ rescans. Capturing after the scan would let that mutation be masked forever.
|
||||
|
||||
Untouched. `invalidateWorktreeScanCacheForRepo` deletes the entry (fingerprint
|
||||
included) and bumps the generation, so every event-driven path still forces a
|
||||
real scan on the next read. The fingerprint only ever *extends* an entry that
|
||||
real scan on the next read. The fingerprint only ever _extends_ an entry that
|
||||
the TTL alone would have refreshed.
|
||||
|
||||
## Freshness budget
|
||||
|
||||
| Change | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
|
||||
| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
|
||||
| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
|
||||
| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
|
||||
| External `rm -rf <worktree>` | ≤ 30 s | ≤ 30 s |
|
||||
| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
|
||||
| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
|
||||
| SSH / WSL repos, folder workspaces | unchanged | unchanged |
|
||||
| Change | Before | After |
|
||||
| --------------------------------------------------------------------- | ----------------- | ----------------- |
|
||||
| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
|
||||
| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
|
||||
| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
|
||||
| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
|
||||
| External `rm -rf <worktree>` | ≤ 30 s | ≤ 30 s |
|
||||
| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
|
||||
| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
|
||||
| SSH / WSL repos, folder workspaces | unchanged | unchanged |
|
||||
|
||||
The two regressions are bounded by the reconciliation interval and are both
|
||||
changes Orca does not make itself.
|
||||
@@ -185,10 +185,10 @@ thread" in the naive sense — but they are not equally free there. Measured on
|
||||
macOS with a 1 ms interval sampling event-loop lag while each ran 30 times
|
||||
against a repo with 20 linked worktrees:
|
||||
|
||||
| | wall per call | main-thread stall per call | worst single stall |
|
||||
| --- | --- | --- | --- |
|
||||
| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
|
||||
| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
|
||||
| | wall per call | main-thread stall per call | worst single stall |
|
||||
| ------------------- | ------------- | -------------------------- | ------------------ |
|
||||
| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
|
||||
| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
|
||||
|
||||
`fs/promises` dispatches to libuv's threadpool, so ~99 % of the probe's latency
|
||||
is off-thread. Spawning Git does not: `uv_spawn`, fd and pipe setup, and stdout
|
||||
@@ -212,10 +212,10 @@ sparse-checkout probes already do.
|
||||
reported steady state — 10 idle local repos, a caller polling at 1 Hz for 30
|
||||
simulated minutes — and counts `git worktree list` invocations:
|
||||
|
||||
| | `git worktree list` per 30 min | per hour |
|
||||
| --- | --- | --- |
|
||||
| TTL only (before) | 600 | 1,200 |
|
||||
| fingerprint gate (after) | 60 | 120 |
|
||||
| | `git worktree list` per 30 min | per hour |
|
||||
| ------------------------ | ------------------------------ | -------- |
|
||||
| TTL only (before) | 600 | 1,200 |
|
||||
| fingerprint gate (after) | 60 | 120 |
|
||||
|
||||
A 90 % reduction, with the remainder being the bounded reconciliation. Repos
|
||||
with genuine external activity keep rescanning at the 30 s cadence because the
|
||||
@@ -227,7 +227,7 @@ Extrapolating to the original trace's shape (10 repos, 3 h 27 min): 4,272
|
||||
## Rejected alternatives
|
||||
|
||||
**Raise `WORKTREE_SCAN_CACHE_TTL_MS` to 5 min.** One line, same subprocess
|
||||
reduction, but it degrades *every* external-change latency to 5 min, including
|
||||
reduction, but it degrades _every_ external-change latency to 5 min, including
|
||||
the common "I ran `git worktree add` in a terminal" case. The fingerprint buys
|
||||
the same reduction without that regression.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ exactly the case a POSIX script uses to mean a literal dollar.
|
||||
Build argv with `buildWslExecArgs()` in `src/shared/wsl-login-shell-command.ts`. A test walks the
|
||||
tree and fails if the `--` form reappears.
|
||||
|
||||
The `--` inside `sh -s -- <path>` is a *shell* argument separator and is unrelated; leave it alone.
|
||||
The `--` inside `sh -s -- <path>` is a _shell_ argument separator and is unrelated; leave it alone.
|
||||
|
||||
## 2. Machine-read output must be fenced
|
||||
|
||||
|
||||
@@ -4,24 +4,24 @@ Three layers of coverage, because each catches what the others structurally cann
|
||||
|
||||
## 1. Unit — runs everywhere, every PR
|
||||
|
||||
| Suite | Pins |
|
||||
|---|---|
|
||||
| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
|
||||
| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
|
||||
| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
|
||||
| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
|
||||
| Suite | Pins |
|
||||
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
|
||||
| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
|
||||
| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
|
||||
| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
|
||||
|
||||
## 2. Ratchets — the goalposts, enforced continuously
|
||||
|
||||
| Guard | Measures |
|
||||
|---|---|
|
||||
| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
|
||||
| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
|
||||
| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
|
||||
| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
|
||||
| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
|
||||
| Guard | Measures |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
|
||||
| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
|
||||
| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
|
||||
| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
|
||||
| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
|
||||
|
||||
Each fails on a **new** offender *and* on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
|
||||
Each fails on a **new** offender _and_ on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
|
||||
|
||||
## 3. Real-binary — the assertions nothing else can make
|
||||
|
||||
@@ -48,4 +48,4 @@ Recorded rather than implied, because a guard that looks complete is worse than
|
||||
|
||||
### Verifying a guard change
|
||||
|
||||
Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that *reduced* the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
|
||||
Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that _reduced_ the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
|
||||
|
||||
@@ -115,7 +115,7 @@ the resulting patch is silently wrong — the failure mode is a `.mjs` that is
|
||||
`forbiddenBuildScripts` in the manifest encodes this and the generator refuses
|
||||
to run a build step that names one of those scripts.
|
||||
|
||||
The generator also builds the *unmodified* commit first and asserts that it
|
||||
The generator also builds the _unmodified_ commit first and asserts that it
|
||||
reproduces the published `lib/` byte for byte before it emits anything. A
|
||||
toolchain or build-order problem therefore surfaces as an explicit "did not
|
||||
reproduce the published bundles" error rather than as 7 MB of mystery diff.
|
||||
|
||||
@@ -89,20 +89,20 @@ issues `adb shell input` events; AVD names resolve to running adb serials.
|
||||
Use `--json` for agent-friendly output. Coordinates are **normalized 0..1**
|
||||
(top-left origin) — never pixels; Orca converts using the live screen size.
|
||||
|
||||
| Goal | Command | Notes |
|
||||
|----------------------------|----------------------------------------------------------------|-------|
|
||||
| List devices + AVDs | `ORCA emulator devices --json` | Cross-platform; shows iOS + Android with a platform column, booted vs shutdown. |
|
||||
| Single tap | `ORCA emulator tap <x> <y> --device <serial>` | Normalized 0..1. Preferred for single taps. |
|
||||
| Swipe / gesture | `ORCA emulator gesture '<json>' --device <serial>` | adb approximates the path by its endpoints (start→end). |
|
||||
| Type text | `ORCA emulator type "user@example.com" --device <serial>` | US ASCII; spaces handled. No newlines. |
|
||||
| Hardware button | `ORCA emulator button back --device <serial>` | home, back, recents, power, volume_up, volume_down. |
|
||||
| Rotate | `ORCA emulator rotate landscape_left --device <serial>` | Sets user_rotation (disables auto-rotate). |
|
||||
| Install an APK | `ORCA emulator install ./app-debug.apk --reinstall --device <serial>` | `--reinstall` passes `-r`. |
|
||||
| Launch an app | `ORCA emulator launch com.acme.app --activity .MainActivity --device <serial>` | Omit `--activity` to launch the default LAUNCHER activity. |
|
||||
| Grant a permission | `ORCA emulator permissions grant com.acme.app android.permission.CAMERA --device <serial>` | grant / revoke / reset. |
|
||||
| Accessibility tree | `ORCA emulator ax --device <serial> --json` | `uiautomator dump` parsed to a node tree. |
|
||||
| Logcat (one-shot) | `ORCA emulator logcat --lines 200 --device <serial>` | Dumps recent lines; parsed to entries. |
|
||||
| Raw adb shell | `ORCA emulator exec --command "getprop ro.build.version.sdk" --device <serial>` | Runs `adb -s <serial> shell <command>`. |
|
||||
| Goal | Command | Notes |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||
| List devices + AVDs | `ORCA emulator devices --json` | Cross-platform; shows iOS + Android with a platform column, booted vs shutdown. |
|
||||
| Single tap | `ORCA emulator tap <x> <y> --device <serial>` | Normalized 0..1. Preferred for single taps. |
|
||||
| Swipe / gesture | `ORCA emulator gesture '<json>' --device <serial>` | adb approximates the path by its endpoints (start→end). |
|
||||
| Type text | `ORCA emulator type "user@example.com" --device <serial>` | US ASCII; spaces handled. No newlines. |
|
||||
| Hardware button | `ORCA emulator button back --device <serial>` | home, back, recents, power, volume_up, volume_down. |
|
||||
| Rotate | `ORCA emulator rotate landscape_left --device <serial>` | Sets user_rotation (disables auto-rotate). |
|
||||
| Install an APK | `ORCA emulator install ./app-debug.apk --reinstall --device <serial>` | `--reinstall` passes `-r`. |
|
||||
| Launch an app | `ORCA emulator launch com.acme.app --activity .MainActivity --device <serial>` | Omit `--activity` to launch the default LAUNCHER activity. |
|
||||
| Grant a permission | `ORCA emulator permissions grant com.acme.app android.permission.CAMERA --device <serial>` | grant / revoke / reset. |
|
||||
| Accessibility tree | `ORCA emulator ax --device <serial> --json` | `uiautomator dump` parsed to a node tree. |
|
||||
| Logcat (one-shot) | `ORCA emulator logcat --lines 200 --device <serial>` | Dumps recent lines; parsed to entries. |
|
||||
| Raw adb shell | `ORCA emulator exec --command "getprop ro.build.version.sdk" --device <serial>` | Runs `adb -s <serial> shell <command>`. |
|
||||
|
||||
## Critical gotchas (teach agents)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ shell-neutral for POSIX shells, PowerShell, and cmd.exe.
|
||||
- The agent should use Orca's preview pane instead of external Simulator.app or raw serve-sim URLs.
|
||||
|
||||
**When NOT to use**
|
||||
|
||||
- Android emulators → use the `orca-emulator-android` skill (same `ORCA emulator` namespace, cross-platform via adb/emulator).
|
||||
- Building or installing the app itself → use `xcodebuild`, `xcrun simctl install`, `expo run:ios`, etc. (launch the app, then use `ORCA emulator` to drive it).
|
||||
- In-app debugging (state, network, views) → use the app's own tools or the browser pane if it's a webview.
|
||||
@@ -75,6 +76,7 @@ An active emulator "session" for the worktree is required for most commands. Use
|
||||
```
|
||||
|
||||
Orca owns:
|
||||
|
||||
- Starting/stopping the serve-sim helper (via --detach or direct).
|
||||
- Per-worktree "active" emulator (like active browser tab).
|
||||
- Explicit targeting with `--worktree`, `--device`, `--emulator <id>`.
|
||||
@@ -82,26 +84,26 @@ Orca owns:
|
||||
|
||||
Agents use the Orca executable chosen above (on PATH in Orca terminals) and never have to manage PIDs, state files in /tmp, or raw WS URLs themselves.
|
||||
|
||||
**For `pnpm dev` testing:** run `pnpm build:cli` first (rebuilds the CLI + ensures the `orca-dev` shim points at *this* worktree). Then inside the dev app use `orca-dev emulator ...` (or the direct `./config/scripts/orca-dev.mjs emulator ...` from the repo root). The orchestration preambles and dev launchers automatically select the dev command name so the CLI reaches your in-memory EmulatorBridge / runtime. Plain `orca` reaches a packaged install instead.
|
||||
**For `pnpm dev` testing:** run `pnpm build:cli` first (rebuilds the CLI + ensures the `orca-dev` shim points at _this_ worktree). Then inside the dev app use `orca-dev emulator ...` (or the direct `./config/scripts/orca-dev.mjs emulator ...` from the repo root). The orchestration preambles and dev launchers automatically select the dev command name so the CLI reaches your in-memory EmulatorBridge / runtime. Plain `orca` reaches a packaged install instead.
|
||||
|
||||
## Common operations
|
||||
|
||||
Use `--json` for agent-friendly output. Commands are workspace-scoped by default (current worktree's active emulator).
|
||||
|
||||
| Goal | Command | Notes |
|
||||
|-----------------------------|----------------------------------------------|-------|
|
||||
| List available / running | `ORCA emulator list [--worktree <sel>]` | Shows Orca-managed + raw serve-sim streams. Use output for explicit --device/--emulator. |
|
||||
| Attach / make active | `ORCA emulator attach "iPhone 16 Pro" [--worktree <sel>] [--focus]` | Starts helper if needed (serve-sim --detach). Sets active for unqualified commands. --focus optional (does not auto-steal UI focus by default). |
|
||||
| Single tap | `ORCA emulator tap <x> <y> [--device <id>]` | Normalized 0..1 coords. **Preferred over gesture for simple taps.** |
|
||||
| Multi-step gesture | `ORCA emulator gesture '<json>'` | See gestures reference (begin/move/end). Use tap for singles. |
|
||||
| Type text | `ORCA emulator type "text" [--device <id>]` | US ASCII only. Supports stdin/file via exec if needed. |
|
||||
| Hardware button | `ORCA emulator button home [--device <id>]` | home, swipe_home, app_switcher, lock, siri, side_button. |
|
||||
| Rotate device | `ORCA emulator rotate landscape_left` | Remembers orientation for subsequent gestures. |
|
||||
| Camera injection | `ORCA emulator camera com.acme.App --webcam` | Or --file, placeholder. Hot-swap with switch. May (re)launch app. |
|
||||
| Permissions | `ORCA emulator permissions grant camera com.acme.App` | grant/revoke/reset/list. See full subcommand help. |
|
||||
| Accessibility tree | `ORCA emulator ax [--device <id>]` | Raw serve-sim AX node tree (labels, roles, nested children, capped at 500 nodes; frames normalized 0..1 with top-left origin — tap an element at its frame center: x+width/2, y+height/2). Needs an active session. |
|
||||
| Raw / advanced | `ORCA emulator exec --command "tap 0.5 0.7"` | Or "ca-debug blended on", "memory-warning", full serve-sim subcommands (no "serve-sim" prefix needed in the command string). Bridge injects active device context. |
|
||||
| Stop | `ORCA emulator kill [--device <id>]` | Or let pane close / Orca quit clean up. |
|
||||
| Goal | Command | Notes |
|
||||
| ------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| List available / running | `ORCA emulator list [--worktree <sel>]` | Shows Orca-managed + raw serve-sim streams. Use output for explicit --device/--emulator. |
|
||||
| Attach / make active | `ORCA emulator attach "iPhone 16 Pro" [--worktree <sel>] [--focus]` | Starts helper if needed (serve-sim --detach). Sets active for unqualified commands. --focus optional (does not auto-steal UI focus by default). |
|
||||
| Single tap | `ORCA emulator tap <x> <y> [--device <id>]` | Normalized 0..1 coords. **Preferred over gesture for simple taps.** |
|
||||
| Multi-step gesture | `ORCA emulator gesture '<json>'` | See gestures reference (begin/move/end). Use tap for singles. |
|
||||
| Type text | `ORCA emulator type "text" [--device <id>]` | US ASCII only. Supports stdin/file via exec if needed. |
|
||||
| Hardware button | `ORCA emulator button home [--device <id>]` | home, swipe_home, app_switcher, lock, siri, side_button. |
|
||||
| Rotate device | `ORCA emulator rotate landscape_left` | Remembers orientation for subsequent gestures. |
|
||||
| Camera injection | `ORCA emulator camera com.acme.App --webcam` | Or --file, placeholder. Hot-swap with switch. May (re)launch app. |
|
||||
| Permissions | `ORCA emulator permissions grant camera com.acme.App` | grant/revoke/reset/list. See full subcommand help. |
|
||||
| Accessibility tree | `ORCA emulator ax [--device <id>]` | Raw serve-sim AX node tree (labels, roles, nested children, capped at 500 nodes; frames normalized 0..1 with top-left origin — tap an element at its frame center: x+width/2, y+height/2). Needs an active session. |
|
||||
| Raw / advanced | `ORCA emulator exec --command "tap 0.5 0.7"` | Or "ca-debug blended on", "memory-warning", full serve-sim subcommands (no "serve-sim" prefix needed in the command string). Bridge injects active device context. |
|
||||
| Stop | `ORCA emulator kill [--device <id>]` | Or let pane close / Orca quit clean up. |
|
||||
|
||||
Most support `--worktree <selector>` and explicit `--device <udid|name>` or `--emulator <id>` (from list) for targeting.
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ a long time, or need the user at the keyboard. Never create an Orca workspace or
|
||||
- **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user
|
||||
has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).
|
||||
- **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth
|
||||
token`; §5).
|
||||
token`; §5).
|
||||
3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in
|
||||
place before any paid step.
|
||||
4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:
|
||||
@@ -135,6 +135,13 @@ shape is §7a; key points:
|
||||
- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).
|
||||
- Clone with the git token via `GIT_ASKPASS` (§5).
|
||||
- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.
|
||||
- **Never snapshot a machine on which the Orca runtime has already run.** The first `orca serve` creates
|
||||
the runtime's user-data dir, and everything in it gets baked into the image and shared by every VM
|
||||
booted from it: the pairing keypair and device-token registry (`orca-devices.json`,
|
||||
`orca-e2ee-keypair.json`), `agent-session-authority.key`, and the build box's logs, terminal history
|
||||
and orchestration db. Confirmed: two VMs from one such snapshot emitted **identical `deviceToken` and
|
||||
`pairedDeviceId`**. Snapshot **before** the runtime has ever run, or `rm -rf` the whole user-data dir
|
||||
(`~/.config/orca` on Linux) first — deleting a named file list will drift as Orca adds state.
|
||||
- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.
|
||||
|
||||
---
|
||||
@@ -163,6 +170,10 @@ their own terminal, or via the Claude Code harness bang-prefix (`! <cmd>`, with
|
||||
`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login
|
||||
finishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.
|
||||
|
||||
This layer inherits §3's rule: if you started `orca serve` on the base or auth sandbox to smoke-test it,
|
||||
delete the runtime's user-data dir (`~/.config/orca` on Linux) before re-snapshotting, or every workspace
|
||||
booted from this image shares one pairing identity and one `agent-session-authority.key`.
|
||||
|
||||
If the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).
|
||||
|
||||
For disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the
|
||||
@@ -221,9 +232,9 @@ reserve stdout for the final JSON and log progress to stderr. Include a shared `
|
||||
|
||||
- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user
|
||||
invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env
|
||||
bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`
|
||||
bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`
|
||||
or require WSL/Git-Bash and point `orca.yaml` at the right launcher.
|
||||
- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so
|
||||
- **Remote-side** (commands you `exec` _inside_ the Linux VM) always runs in the VM's Linux shell, so
|
||||
bash is fine there regardless of the user's OS.
|
||||
|
||||
### 7a. Base-snapshot (`<provider>-base-snapshot.sh`) — Phase 2
|
||||
@@ -303,7 +314,11 @@ There is **no `--host` flag**. `--project-root` must be an absolute directory on
|
||||
keeps serving:
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 1, "pairingCode": "<orca pairing URL>", "projectRoot": "<the --project-root you passed>" }
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"pairingCode": "<orca pairing URL>",
|
||||
"projectRoot": "<the --project-root you passed>"
|
||||
}
|
||||
```
|
||||
|
||||
`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set
|
||||
@@ -498,7 +513,7 @@ git checkout -B "$ORCA_REPO_BRANCH" "$ORCA_REPO_REF_HEAD"
|
||||
|
||||
Fail if the requested schema is not `2`; do not silently fall back to the ordinary recipe shape.
|
||||
|
||||
**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no
|
||||
**Networking → which `target` fields to set** (how _your desktop_ reaches the box — there is no
|
||||
`orca serve` URL in SSH mode):
|
||||
|
||||
- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).
|
||||
@@ -511,7 +526,7 @@ Fail if the requested schema is not `2`; do not silently fall back to the ordina
|
||||
reconnect grace window.
|
||||
|
||||
**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the
|
||||
recipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and
|
||||
recipe** (there's no base image to bake; the host _is_ the base). Run the §7f Phase-2 install steps and
|
||||
the §7f Phase-3 `<agent> login --device-auth` **directly over SSH on the host** (interactive, e.g.
|
||||
`ssh -t user@host '<agent> login --device-auth'`). After that the host stays ready across workspaces.
|
||||
|
||||
@@ -615,7 +630,7 @@ $ErrorActionPreference = 'Stop'
|
||||
# progress/errors → Write-Error / the error stream, never stdout.
|
||||
```
|
||||
|
||||
The remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.
|
||||
The remote-side commands you run _inside_ the Linux VM stay bash regardless of the desktop OS.
|
||||
|
||||
---
|
||||
|
||||
@@ -702,10 +717,10 @@ each stage so you can self-diagnose without asking the user to relay logs:
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"checks": [ { "id": "recipe.provision", "status": "fail", "message": "…" } ],
|
||||
"checks": [{ "id": "recipe.provision", "status": "fail", "message": "…" }],
|
||||
"provisionTranscript": {
|
||||
"provision": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…", "parseError": "…" },
|
||||
"destroy": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…" }
|
||||
"destroy": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…" }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -743,7 +758,7 @@ startup-only `docker run` before the full clone/install path.
|
||||
- **Agent verified as "not logged in" despite a good login.** `codex login status` (and similar) print
|
||||
"Logged in …" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you
|
||||
grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi
|
||||
'logged in'`, which also matches "not logged in".
|
||||
'logged in'`, which also matches "not logged in".
|
||||
- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container
|
||||
port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a
|
||||
URL + code the user opens on the host.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,9 +2,14 @@ function __orca_shell_ready_marker --on-event fish_prompt
|
||||
builtin printf "\033]777;orca-shell-ready\007"
|
||||
functions -e __orca_shell_ready_marker
|
||||
end
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
# Why captured: an unquoted (type -t codex) expands to zero words when codex is
|
||||
# absent, leaving "test = file" — fish then errors instead of failing closed.
|
||||
# Quoting in place is not the fix; fish never substitutes inside double quotes.
|
||||
set -l __orca_codex_type (type -t codex 2>/dev/null)
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test "$__orca_codex_type" = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
end
|
||||
end
|
||||
end
|
||||
set -e __orca_codex_type
|
||||
@@ -2,9 +2,14 @@ function __orca_shell_ready_marker --on-event fish_prompt
|
||||
builtin printf "\033]777;orca-shell-ready\007"
|
||||
functions -e __orca_shell_ready_marker
|
||||
end
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
# Why captured: an unquoted (type -t codex) expands to zero words when codex is
|
||||
# absent, leaving "test = file" — fish then errors instead of failing closed.
|
||||
# Quoting in place is not the fix; fish never substitutes inside double quotes.
|
||||
set -l __orca_codex_type (type -t codex 2>/dev/null)
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test "$__orca_codex_type" = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
end
|
||||
end
|
||||
end
|
||||
set -e __orca_codex_type
|
||||
@@ -8,14 +8,19 @@ import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const { spawnMock, isPwshAvailableMock, resolveAgentForegroundProcessMock, readConptyMock, jobReadableMock } =
|
||||
vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
isPwshAvailableMock: vi.fn(),
|
||||
resolveAgentForegroundProcessMock: vi.fn(),
|
||||
readConptyMock: vi.fn(),
|
||||
jobReadableMock: vi.fn()
|
||||
}))
|
||||
const {
|
||||
spawnMock,
|
||||
isPwshAvailableMock,
|
||||
resolveAgentForegroundProcessMock,
|
||||
readConptyMock,
|
||||
jobReadableMock
|
||||
} = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
isPwshAvailableMock: vi.fn(),
|
||||
resolveAgentForegroundProcessMock: vi.fn(),
|
||||
readConptyMock: vi.fn(),
|
||||
jobReadableMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node-pty', () => ({ spawn: spawnMock }))
|
||||
vi.mock('../pwsh', () => ({ isPwshAvailable: isPwshAvailableMock }))
|
||||
|
||||
@@ -48,14 +48,18 @@ export function normalizeGitLabIssueListArgs(args: {
|
||||
state?: unknown
|
||||
assignee?: unknown
|
||||
limit?: unknown
|
||||
page?: unknown
|
||||
}): {
|
||||
state: GitLabIssueListState
|
||||
assignee: '@me' | undefined
|
||||
limit: number
|
||||
page: number
|
||||
} {
|
||||
return {
|
||||
state: normalizeGitLabIssueListState(args.state),
|
||||
assignee: normalizeGitLabIssueAssignee(args.assignee),
|
||||
limit: normalizeGitLabPositiveInteger(args.limit, 20, 100)
|
||||
limit: normalizeGitLabPositiveInteger(args.limit, 20, 100),
|
||||
// Why: GitLab is 1-based; TaskPage maps 0-based UI pages onto this (#13357).
|
||||
page: normalizeGitLabPositiveInteger(args.page, 1, 10_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
GITLAB_ADMISSION_TIMEOUT_MS,
|
||||
getIssueProjectRef,
|
||||
parseGlabJsonList,
|
||||
parseGlabPaginationHeader,
|
||||
isMissingJobLogError,
|
||||
getGlabKnownHosts,
|
||||
getProjectRef,
|
||||
@@ -907,3 +908,24 @@ describe('getGlabKnownHosts', () => {
|
||||
unregisterSshGitProvider(connectionId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseGlabPaginationHeader', () => {
|
||||
it('reads a usable header value', () => {
|
||||
expect(parseGlabPaginationHeader('25', 1)).toBe(25)
|
||||
expect(parseGlabPaginationHeader(' 9 ', 1)).toBe(9)
|
||||
})
|
||||
|
||||
it('returns undefined for an absent or unparseable header', () => {
|
||||
expect(parseGlabPaginationHeader(undefined, 0)).toBeUndefined()
|
||||
expect(parseGlabPaginationHeader('', 0)).toBeUndefined()
|
||||
expect(parseGlabPaginationHeader('abc', 0)).toBeUndefined()
|
||||
})
|
||||
|
||||
// Why: the minimum is what lets issues.ts tell "x-total: 0" (derive one page) apart from an
|
||||
// absent header (probe for a next page), and what makes x-total-pages: 0 fall through.
|
||||
it('rejects values below the minimum', () => {
|
||||
expect(parseGlabPaginationHeader('0', 1)).toBeUndefined()
|
||||
expect(parseGlabPaginationHeader('0', 0)).toBe(0)
|
||||
expect(parseGlabPaginationHeader('-3', 0)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,7 +29,12 @@ export type {
|
||||
ProjectRef,
|
||||
ResolvedIssueSource
|
||||
} from './gitlab-project-ref-resolution'
|
||||
export { parseGlabApiResponse, parseGlabJsonList, type GlabApiResponse } from './glab-api-response'
|
||||
export {
|
||||
parseGlabApiResponse,
|
||||
parseGlabJsonList,
|
||||
parseGlabPaginationHeader,
|
||||
type GlabApiResponse
|
||||
} from './glab-api-response'
|
||||
|
||||
const MAX_CONCURRENT = 4
|
||||
export const GITLAB_ADMISSION_TIMEOUT_MS = 30_000
|
||||
@@ -86,7 +91,7 @@ export function release(): void {
|
||||
|
||||
export async function glabApiWithHeaders(
|
||||
args: string[],
|
||||
options?: { cwd?: string }
|
||||
options?: Parameters<typeof glabExecFileAsync>[1]
|
||||
): Promise<GlabApiResponse> {
|
||||
const { stdout } = await glabExecFileAsync(['api', '-i', ...args], options)
|
||||
return parseGlabApiResponse(stdout)
|
||||
|
||||
@@ -25,6 +25,18 @@ export function parseGlabApiResponse(stdout: string): GlabApiResponse {
|
||||
return { body, headers }
|
||||
}
|
||||
|
||||
/** A GitLab pagination header, or undefined when absent, unparseable, or below `minimum`. */
|
||||
export function parseGlabPaginationHeader(
|
||||
value: string | undefined,
|
||||
minimum: number
|
||||
): number | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isFinite(parsed) && parsed >= minimum ? parsed : undefined
|
||||
}
|
||||
|
||||
/** A non-list body carrying no GitLab error text — opaque data, so there is nothing to classify. */
|
||||
export class GlabNonListResponseError extends Error {}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type * as GlUtils from './gl-utils'
|
||||
|
||||
const {
|
||||
glabExecFileAsyncMock,
|
||||
glabApiWithHeadersMock,
|
||||
getIssueProjectRefMock,
|
||||
resolveIssueSourceMock,
|
||||
getGlabKnownHostsMock,
|
||||
@@ -10,6 +11,7 @@ const {
|
||||
releaseMock
|
||||
} = vi.hoisted(() => ({
|
||||
glabExecFileAsyncMock: vi.fn(),
|
||||
glabApiWithHeadersMock: vi.fn(),
|
||||
getIssueProjectRefMock: vi.fn(),
|
||||
resolveIssueSourceMock: vi.fn(),
|
||||
getGlabKnownHostsMock: vi.fn(),
|
||||
@@ -22,6 +24,7 @@ vi.mock('./gl-utils', async () => {
|
||||
return {
|
||||
...actual,
|
||||
glabExecFileAsync: glabExecFileAsyncMock,
|
||||
glabApiWithHeaders: glabApiWithHeadersMock,
|
||||
getIssueProjectRef: getIssueProjectRefMock,
|
||||
resolveIssueSource: resolveIssueSourceMock,
|
||||
getGlabKnownHosts: getGlabKnownHostsMock,
|
||||
@@ -37,6 +40,7 @@ import { listAssignableUsers, listLabels } from './project-label-and-member-look
|
||||
describe('gitlab issue operations', () => {
|
||||
beforeEach(() => {
|
||||
glabExecFileAsyncMock.mockReset()
|
||||
glabApiWithHeadersMock.mockReset()
|
||||
getIssueProjectRefMock.mockReset()
|
||||
resolveIssueSourceMock.mockReset()
|
||||
getGlabKnownHostsMock.mockReset()
|
||||
@@ -86,7 +90,6 @@ describe('gitlab issue operations', () => {
|
||||
labels: []
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: '[]' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
iid: 924,
|
||||
@@ -104,6 +107,7 @@ describe('gitlab issue operations', () => {
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: 'bug\nfrontend\n' })
|
||||
.mockResolvedValueOnce({ stdout: '{"id":1,"username":"octo","avatar_url":""}\n' })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
|
||||
|
||||
await getIssue('/repo-root', 923, null, localGitOptions)
|
||||
await listIssues('/repo-root', 5, undefined, 'opened', undefined, null, localGitOptions)
|
||||
@@ -137,6 +141,12 @@ describe('gitlab issue operations', () => {
|
||||
expect(glabExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe(
|
||||
true
|
||||
)
|
||||
expect(glabApiWithHeadersMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'projects/stablyai%2Forca/issues?page=1&per_page=5&order_by=updated_at&sort=desc&state=opened'
|
||||
],
|
||||
{ cwd: '/repo-root', ...localGitOptions }
|
||||
)
|
||||
})
|
||||
|
||||
it('encodes nested group paths', async () => {
|
||||
@@ -157,22 +167,65 @@ describe('gitlab issue operations', () => {
|
||||
|
||||
it('lists issues with state=opened ordering', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({
|
||||
body: '[]',
|
||||
headers: { 'x-total': '123', 'x-total-pages': '25' }
|
||||
})
|
||||
|
||||
await expect(listIssues('/repo-root', 5)).resolves.toEqual({ items: [] })
|
||||
await expect(listIssues('/repo-root', 5)).resolves.toEqual({ items: [], totalPages: 25 })
|
||||
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect(glabApiWithHeadersMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'api',
|
||||
'projects/stablyai%2Forca/issues?per_page=5&order_by=updated_at&sort=desc&state=opened'
|
||||
'projects/stablyai%2Forca/issues?page=1&per_page=5&order_by=updated_at&sort=desc&state=opened'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards an explicit page into the issues API path after localGitOptions', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
|
||||
|
||||
await expect(
|
||||
listIssues('/repo-root', 50, undefined, 'opened', undefined, null, {}, 3)
|
||||
).resolves.toMatchObject({ totalPages: 3 })
|
||||
|
||||
expect(glabApiWithHeadersMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'projects/stablyai%2Forca/issues?page=3&per_page=50&order_by=updated_at&sort=desc&state=opened'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
})
|
||||
|
||||
it('derives total pages from x-total when x-total-pages is unavailable', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: { 'x-total': '11' } })
|
||||
|
||||
await expect(listIssues('/repo-root', 5)).resolves.toMatchObject({ totalPages: 3 })
|
||||
})
|
||||
|
||||
it('keeps a next-page probe when a proxy strips pagination headers', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({
|
||||
body: JSON.stringify(
|
||||
Array.from({ length: 5 }, (_, index) => ({
|
||||
iid: index + 1,
|
||||
title: `Issue ${index + 1}`,
|
||||
state: 'opened',
|
||||
web_url: `https://gitlab.com/stablyai/orca/-/issues/${index + 1}`,
|
||||
labels: []
|
||||
}))
|
||||
),
|
||||
headers: {}
|
||||
})
|
||||
|
||||
await expect(listIssues('/repo-root', 5)).resolves.toMatchObject({ totalPages: 2 })
|
||||
})
|
||||
|
||||
it('surfaces a permission_denied error instead of collapsing to empty', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
|
||||
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
|
||||
|
||||
const result = await listIssues('/repo-root', 5)
|
||||
|
||||
@@ -182,8 +235,9 @@ describe('gitlab issue operations', () => {
|
||||
|
||||
it('reports the body instead of ".map is not a function" when the API returns a non-array', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ data: [], total: 0 })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({
|
||||
body: JSON.stringify({ data: [], total: 0 }),
|
||||
headers: {}
|
||||
})
|
||||
|
||||
const result = await listIssues('/repo-root', 5)
|
||||
@@ -196,8 +250,9 @@ describe('gitlab issue operations', () => {
|
||||
|
||||
it('reports a GitLab error envelope by its own message', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ message: '403 Forbidden' })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({
|
||||
body: JSON.stringify({ message: '403 Forbidden' }),
|
||||
headers: {}
|
||||
})
|
||||
|
||||
const result = await listIssues('/repo-root', 5)
|
||||
@@ -230,7 +285,7 @@ describe('gitlab issue operations', () => {
|
||||
|
||||
it('threads connectionId into getGlabKnownHosts for listIssues', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
|
||||
|
||||
await listIssues('/repo-root', 5, undefined, 'opened', undefined, 'conn-7')
|
||||
|
||||
|
||||
@@ -3,13 +3,15 @@ import type { GitLabCommentResult, GitLabIssueInfo, MRComment } from '../../shar
|
||||
import type { IssueSourcePreference } from '../../shared/repo-types'
|
||||
import { mapGitLabIssueInfo } from './mappers'
|
||||
// prettier-ignore
|
||||
import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListFetchError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, parseGlabJsonList, type LocalGitExecOptions, type ProjectRef } from './gl-utils'
|
||||
import { glabApiWithHeaders, glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListFetchError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, parseGlabJsonList, parseGlabPaginationHeader, type LocalGitExecOptions, type ProjectRef } from './gl-utils'
|
||||
import { encodedProject } from './project-path-encoding'
|
||||
|
||||
// Why: parallel to GitHub's IssueListResult — distinguishes a successful-
|
||||
// empty listing from a failed fetch.
|
||||
export type IssueListResult = {
|
||||
items: GitLabIssueInfo[]
|
||||
/** 0 when the listing failed — the caller keeps its current pager instead of collapsing it. */
|
||||
totalPages: number
|
||||
error?: ClassifiedError
|
||||
}
|
||||
|
||||
@@ -74,8 +76,11 @@ export async function listIssues(
|
||||
state: IssueListState = 'opened',
|
||||
assignee?: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
page = 1
|
||||
): Promise<IssueListResult> {
|
||||
const currentPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1
|
||||
const perPage = Number.isFinite(limit) ? Math.max(1, Math.trunc(limit)) : 20
|
||||
const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
@@ -94,6 +99,7 @@ export async function listIssues(
|
||||
if (!projectRef) {
|
||||
return {
|
||||
items: [],
|
||||
totalPages: 0,
|
||||
error: {
|
||||
type: 'not_found',
|
||||
message: 'Could not resolve a GitLab project for this repository.'
|
||||
@@ -104,24 +110,33 @@ export async function listIssues(
|
||||
try {
|
||||
const stateParam = state === 'all' ? '' : `&state=${state}`
|
||||
const scopeParam = assignee === '@me' ? '&scope=assigned_to_me' : ''
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
const { body, headers } = await glabApiWithHeaders(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}`
|
||||
`projects/${encodedProject(projectRef.path)}/issues?page=${currentPage}&per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${scopeParam}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
|
||||
)
|
||||
const data = parseGlabJsonList<Record<string, unknown>>(stdout)
|
||||
const data = parseGlabJsonList<Record<string, unknown>>(body)
|
||||
const headerTotalCount = parseGlabPaginationHeader(headers['x-total'], 0)
|
||||
// Why: a proxy can strip both headers, so a full page advertises one more to probe (#13357);
|
||||
// TaskPage retreats if that probe comes back empty.
|
||||
const probedTotalPages = data.length < perPage ? currentPage : currentPage + 1
|
||||
// Why: GitLab's project issues endpoint returns true issues only
|
||||
// (MRs are a separate endpoint), so no equivalent of GitHub's
|
||||
// pull_request filter is needed here.
|
||||
return {
|
||||
items: data.map((d) => mapGitLabIssueInfo(d as Parameters<typeof mapGitLabIssueInfo>[0]))
|
||||
items: data.map((d) => mapGitLabIssueInfo(d as Parameters<typeof mapGitLabIssueInfo>[0])),
|
||||
totalPages:
|
||||
parseGlabPaginationHeader(headers['x-total-pages'], 1) ??
|
||||
(headerTotalCount === undefined
|
||||
? probedTotalPages
|
||||
: Math.max(1, Math.ceil(headerTotalCount / perPage)))
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
items: [],
|
||||
totalPages: 0,
|
||||
error: classifyListFetchError(err)
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
glabRepoExecOptions,
|
||||
glabExecFileAsync,
|
||||
parseGlabJsonList,
|
||||
parseGlabPaginationHeader,
|
||||
release,
|
||||
resolveIssueSource,
|
||||
type LocalGitExecOptions
|
||||
@@ -130,11 +131,11 @@ export async function listMergeRequests(
|
||||
items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)),
|
||||
page,
|
||||
perPage,
|
||||
totalCount: parseHeaderInt(headers['x-total'], 0),
|
||||
totalCount: parseGlabPaginationHeader(headers['x-total'], 0) ?? 0,
|
||||
// Why: GitLab may omit x-total-pages for 'all' or large per_page; fall back to ceil(total/perPage).
|
||||
totalPages:
|
||||
parseHeaderInt(headers['x-total-pages'], 0) ||
|
||||
Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage))
|
||||
parseGlabPaginationHeader(headers['x-total-pages'], 1) ??
|
||||
Math.max(1, Math.ceil((parseGlabPaginationHeader(headers['x-total'], 0) ?? 0) / perPage))
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -150,14 +151,6 @@ export async function listMergeRequests(
|
||||
}
|
||||
}
|
||||
|
||||
function parseHeaderInt(value: string | undefined, fallback: number): number {
|
||||
if (!value) {
|
||||
return fallback
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a work item (MR or issue) by explicit project ref + iid + type.
|
||||
* Used by the paste-URL flow, where the URL determines the project directly.
|
||||
|
||||
@@ -44,10 +44,12 @@ export function registerGitLabIssueHandlers(store: Store): void {
|
||||
state?: 'opened' | 'closed' | 'all'
|
||||
assignee?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
const limit = normalizeGitLabPositiveInteger(args.limit, 20, 100)
|
||||
const page = normalizeGitLabPositiveInteger(args.page, 1, 10_000)
|
||||
const state = normalizeGitLabIssueListState(args.state)
|
||||
const assignee = normalizeGitLabIssueAssignee(args.assignee)
|
||||
const result = await listIssues(
|
||||
@@ -57,7 +59,8 @@ export function registerGitLabIssueHandlers(store: Store): void {
|
||||
state,
|
||||
assignee,
|
||||
repoConnectionId(repo),
|
||||
...localGitOptionArgs(store, repo)
|
||||
localGitOptionArgs(store, repo)[0] ?? {},
|
||||
page
|
||||
)
|
||||
// Why: Tasks page expects GitLabWorkItem[] so it can share row
|
||||
// rendering with MRs. Map IssueInfo → WorkItem here so the renderer
|
||||
@@ -74,7 +77,11 @@ export function registerGitLabIssueHandlers(store: Store): void {
|
||||
author: issue.author ?? null,
|
||||
repoId: repo.id
|
||||
}))
|
||||
return { items: workItems, ...(result.error ? { error: result.error } : {}) }
|
||||
return {
|
||||
items: workItems,
|
||||
totalPages: result.totalPages,
|
||||
...(result.error ? { error: result.error } : {})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -353,7 +353,7 @@ describe('GitLab IPC handlers', () => {
|
||||
]
|
||||
listMergeRequestsMock.mockResolvedValue({ items: [] })
|
||||
listWorkItemsMock.mockResolvedValue({ items: [] })
|
||||
listIssuesMock.mockResolvedValue({ items: [] })
|
||||
listIssuesMock.mockResolvedValue({ items: [], totalPages: 3 })
|
||||
getIssueMock.mockResolvedValue(null)
|
||||
createIssueMock.mockResolvedValue({ ok: true, number: 1, url: 'https://gitlab.example/1' })
|
||||
updateIssueMock.mockResolvedValue({ ok: true })
|
||||
@@ -385,10 +385,11 @@ describe('GitLab IPC handlers', () => {
|
||||
page: 1,
|
||||
perPage: 20
|
||||
})
|
||||
await ipcHandlers.get('gitlab:listIssues')?.(null, {
|
||||
const issueListResult = await ipcHandlers.get('gitlab:listIssues')?.(null, {
|
||||
repoPath: '/local/orca',
|
||||
state: 'opened',
|
||||
limit: 20
|
||||
limit: 20,
|
||||
page: 3
|
||||
})
|
||||
await ipcHandlers.get('gitlab:issue')?.(null, { repoPath: '/local/orca', number: 7 })
|
||||
await ipcHandlers.get('gitlab:createIssue')?.(null, {
|
||||
@@ -430,6 +431,7 @@ describe('GitLab IPC handlers', () => {
|
||||
null,
|
||||
localGitOptions
|
||||
)
|
||||
expect(issueListResult).toMatchObject({ totalPages: 3 })
|
||||
expect(listWorkItemsMock).toHaveBeenCalledWith(
|
||||
'/local/orca',
|
||||
'opened',
|
||||
@@ -447,7 +449,8 @@ describe('GitLab IPC handlers', () => {
|
||||
'opened',
|
||||
undefined,
|
||||
null,
|
||||
localGitOptions
|
||||
localGitOptions,
|
||||
3
|
||||
)
|
||||
expect(getIssueMock).toHaveBeenCalledWith('/local/orca', 7, null, localGitOptions)
|
||||
expect(createIssueMock).toHaveBeenCalledWith(
|
||||
|
||||
@@ -317,7 +317,8 @@ describe('registerPtyHandlers', () => {
|
||||
upsertSshRemotePtyLease: vi.fn(),
|
||||
persistPtyBinding: vi.fn(),
|
||||
removeSshRemotePtyLease: vi.fn(),
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
|
||||
@@ -286,7 +286,7 @@ describe('registerPtyHandlers', () => {
|
||||
getProfiles: vi.fn()
|
||||
} as never)
|
||||
const shutdown = vi.fn(async () => undefined)
|
||||
const store = { markSshRemotePtyLease: vi.fn() }
|
||||
const store = { markSshRemotePtyLease: vi.fn(), clearSshRemotePtyKillIntent: vi.fn() }
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
onPtyExit: vi.fn()
|
||||
@@ -357,7 +357,7 @@ describe('registerPtyHandlers', () => {
|
||||
getDefaultShell: vi.fn(),
|
||||
getProfiles: vi.fn()
|
||||
} as never)
|
||||
const store = { markSshRemotePtyLease: vi.fn() }
|
||||
const store = { markSshRemotePtyLease: vi.fn(), clearSshRemotePtyKillIntent: vi.fn() }
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
onPtyExit: vi.fn()
|
||||
@@ -385,7 +385,8 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
it('marks a detached SSH lease terminated when runtime controller kill has no provider', async () => {
|
||||
const store = {
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
@@ -417,7 +418,8 @@ describe('registerPtyHandlers', () => {
|
||||
it('keeps a rejected SSH PTY unverifiable after kill shutdown fails transiently', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const store = {
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
|
||||
@@ -462,7 +462,8 @@ describe('registerPtyHandlers', () => {
|
||||
throw new Error('SSH_SESSION_EXPIRED: remote-pty')
|
||||
})
|
||||
const store = {
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
registerSshPtyProvider('ssh-1', {
|
||||
spawn: sshSpawn,
|
||||
@@ -514,7 +515,8 @@ describe('registerPtyHandlers', () => {
|
||||
throw new Error('SSH_SESSION_EXPIRED: remote-pty')
|
||||
})
|
||||
const store = {
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
registerSshPtyProvider('ssh-1', {
|
||||
spawn: sshSpawn,
|
||||
|
||||
@@ -72,7 +72,8 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
})
|
||||
const store = {
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
registerSshPtyProvider('ssh-1', {
|
||||
spawn: sshSpawn,
|
||||
@@ -136,7 +137,8 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
it('does not tombstone an SSH lease when explicit kill shutdown fails transiently', async () => {
|
||||
const store = {
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
registerSshPtyProvider('ssh-1', {
|
||||
spawn: vi.fn(),
|
||||
@@ -188,7 +190,8 @@ describe('registerPtyHandlers', () => {
|
||||
it('marks an SSH lease terminated after runtime controller kill succeeds', async () => {
|
||||
const shutdown = vi.fn(async () => undefined)
|
||||
const store = {
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
@@ -408,7 +411,8 @@ describe('registerPtyHandlers', () => {
|
||||
vi.useFakeTimers()
|
||||
const shutdown = vi.fn(async () => undefined)
|
||||
const store = {
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
|
||||
@@ -119,7 +119,8 @@ describe('registerPtyHandlers', () => {
|
||||
persistPtyBinding: vi.fn(),
|
||||
upsertSshRemotePtyLease: vi.fn(),
|
||||
removeSshRemotePtyLease: vi.fn(),
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
@@ -477,7 +478,8 @@ describe('registerPtyHandlers', () => {
|
||||
upsertSshRemotePtyLease: vi.fn(),
|
||||
persistPtyBinding: vi.fn(),
|
||||
removeSshRemotePtyLease: vi.fn(),
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
let controller: RuntimeSpawnController | null = null
|
||||
const runtime = {
|
||||
|
||||
@@ -156,7 +156,8 @@ describe('registerPtyHandlers', () => {
|
||||
upsertSshRemotePtyLease: vi.fn(),
|
||||
persistPtyBinding: vi.fn(),
|
||||
removeSshRemotePtyLease: vi.fn(),
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
let controller: RuntimeSpawnController | null = null
|
||||
const runtime = {
|
||||
@@ -373,7 +374,8 @@ describe('registerPtyHandlers', () => {
|
||||
throw new Error('disk full')
|
||||
}),
|
||||
removeSshRemotePtyLease: vi.fn(),
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
let controller: RuntimeSpawnController | null = null
|
||||
const runtime = {
|
||||
@@ -469,7 +471,8 @@ describe('registerPtyHandlers', () => {
|
||||
upsertSshRemotePtyLease: vi.fn(),
|
||||
persistPtyBinding: vi.fn(),
|
||||
removeSshRemotePtyLease: vi.fn(),
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
let controller: RuntimeSpawnController | null = null
|
||||
const runtime = {
|
||||
|
||||
@@ -110,7 +110,8 @@ describe('registerPtyHandlers', () => {
|
||||
upsertSshRemotePtyLease: vi.fn(),
|
||||
persistPtyBinding: vi.fn(),
|
||||
removeSshRemotePtyLease: vi.fn(),
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
let controller: RuntimeSpawnController | null = null
|
||||
const runtime = {
|
||||
@@ -215,7 +216,8 @@ describe('registerPtyHandlers', () => {
|
||||
throw new Error('disk full')
|
||||
}),
|
||||
removeSshRemotePtyLease: vi.fn(),
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -302,7 +302,7 @@ describe('registerPtyHandlers', () => {
|
||||
getProfiles: vi.fn()
|
||||
} as never)
|
||||
const sshShutdown = vi.fn(async () => undefined)
|
||||
const store = { markSshRemotePtyLease: vi.fn() }
|
||||
const store = { markSshRemotePtyLease: vi.fn(), clearSshRemotePtyKillIntent: vi.fn() }
|
||||
registerSshPtyProvider('ssh-1', {
|
||||
spawn: vi.fn(),
|
||||
write: vi.fn(),
|
||||
@@ -367,7 +367,7 @@ describe('registerPtyHandlers', () => {
|
||||
getDefaultShell: vi.fn(),
|
||||
getProfiles: vi.fn()
|
||||
} as never)
|
||||
const store = { markSshRemotePtyLease: vi.fn() }
|
||||
const store = { markSshRemotePtyLease: vi.fn(), clearSshRemotePtyKillIntent: vi.fn() }
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
undefined,
|
||||
@@ -386,7 +386,8 @@ describe('registerPtyHandlers', () => {
|
||||
const store = {
|
||||
upsertSshRemotePtyLease: vi.fn(),
|
||||
persistPtyBinding: vi.fn(),
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
const provider = {
|
||||
spawn: vi.fn(async () => ({ id: 'remote-pty' })),
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import { SSH_SESSION_EXPIRED_ERROR } from '../providers/ssh-pty-errors'
|
||||
import {
|
||||
registerPtyHandlers,
|
||||
registerSshPtyProvider,
|
||||
unregisterSshPtyProvider,
|
||||
deletePtyOwnership,
|
||||
setPtyOwnership,
|
||||
restorePtyIncarnation
|
||||
} from './pty'
|
||||
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
|
||||
vi.mock('node:child_process', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../opencode/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../mimo/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/server', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
|
||||
)
|
||||
vi.mock('../pi/titlebar-extension-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
|
||||
)
|
||||
vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock()))
|
||||
vi.mock('../wsl', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../telemetry/client', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
|
||||
)
|
||||
vi.mock('../telemetry/classify-error', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
|
||||
)
|
||||
vi.mock('../cli/linux-terminal-orca-cli-shim', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
|
||||
)
|
||||
vi.mock('../memory/pty-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-pane-account-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
|
||||
)
|
||||
|
||||
const SCOPED_PTY_ID = 'ssh:ssh-1@@pty-7'
|
||||
|
||||
function createKillStore() {
|
||||
return {
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
recordSshRemotePtyKillIntent: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function sshProviderStub(shutdown: () => Promise<void>) {
|
||||
return {
|
||||
shutdown: vi.fn(shutdown),
|
||||
onExit: vi.fn(() => () => {}),
|
||||
onData: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
listProcesses: vi.fn(async () => [])
|
||||
} as never
|
||||
}
|
||||
|
||||
function installController(handlers: Map<string, never>) {
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
markPtyStopRequested: vi.fn(),
|
||||
markPtyLivenessUnverifiable: vi.fn(),
|
||||
onPtyExit: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
return { runtime }
|
||||
}
|
||||
|
||||
describe('undelivered SSH stops', () => {
|
||||
const { handlers, mainWindow } = setupPtyIpcSuite()
|
||||
|
||||
function install(store: ReturnType<typeof createKillStore>): {
|
||||
kill: (ptyId: string) => boolean
|
||||
stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise<boolean>
|
||||
markReversibleStops: (ptyIds: readonly string[]) => () => void
|
||||
runtime: ReturnType<typeof installController>['runtime']
|
||||
} {
|
||||
const { runtime } = installController(handlers as never)
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
|
||||
kill: (ptyId: string) => boolean
|
||||
stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise<boolean>
|
||||
markReversibleStops: (ptyIds: readonly string[]) => () => void
|
||||
}
|
||||
return {
|
||||
kill: controller.kill,
|
||||
stopAndWait: controller.stopAndWait,
|
||||
markReversibleStops: controller.markReversibleStops,
|
||||
runtime
|
||||
}
|
||||
}
|
||||
|
||||
// The leak: the shutdown died on the transport, the verdict is correctly `unverifiable`, and
|
||||
// before this fix nothing retried — the remote shell survived forever.
|
||||
it('records the stop when the shutdown RPC rejects with a transport failure', async () => {
|
||||
const store = createKillStore()
|
||||
registerSshPtyProvider(
|
||||
'ssh-1',
|
||||
sshProviderStub(async () => {
|
||||
throw new Error('socket closed')
|
||||
})
|
||||
)
|
||||
setPtyOwnership(SCOPED_PTY_ID, 'ssh-1')
|
||||
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-a')
|
||||
const { kill, runtime } = install(store)
|
||||
|
||||
try {
|
||||
expect(kill(SCOPED_PTY_ID)).toBe(true)
|
||||
await vi.waitFor(() => expect(store.recordSshRemotePtyKillIntent).toHaveBeenCalled())
|
||||
expect(store.recordSshRemotePtyKillIntent).toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
'pty-7',
|
||||
expect.objectContaining({ incarnationId: 'inc-a', attempts: 0 })
|
||||
)
|
||||
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
|
||||
SCOPED_PTY_ID,
|
||||
'socket closed'
|
||||
)
|
||||
} finally {
|
||||
unregisterSshPtyProvider('ssh-1')
|
||||
deletePtyOwnership(SCOPED_PTY_ID)
|
||||
}
|
||||
})
|
||||
|
||||
// The offline close: no provider is registered, so the relay is never even asked. The lease is
|
||||
// tombstoned locally, and without the record the remote shell has nothing left to retire it.
|
||||
it('records the stop when no provider is registered to deliver it', async () => {
|
||||
const store = createKillStore()
|
||||
setPtyOwnership(SCOPED_PTY_ID, 'ssh-1')
|
||||
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-b')
|
||||
const { kill } = install(store)
|
||||
|
||||
try {
|
||||
expect(kill(SCOPED_PTY_ID)).toBe(false)
|
||||
expect(store.recordSshRemotePtyKillIntent).toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
'pty-7',
|
||||
expect.objectContaining({ incarnationId: 'inc-b' })
|
||||
)
|
||||
} finally {
|
||||
deletePtyOwnership(SCOPED_PTY_ID)
|
||||
}
|
||||
})
|
||||
|
||||
it('records nothing when the shutdown RPC is delivered', async () => {
|
||||
const store = createKillStore()
|
||||
registerSshPtyProvider(
|
||||
'ssh-1',
|
||||
sshProviderStub(async () => {})
|
||||
)
|
||||
setPtyOwnership(SCOPED_PTY_ID, 'ssh-1')
|
||||
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-c')
|
||||
const { kill } = install(store)
|
||||
|
||||
try {
|
||||
expect(kill(SCOPED_PTY_ID)).toBe(true)
|
||||
await vi.waitFor(() => expect(store.markSshRemotePtyLease).toHaveBeenCalled())
|
||||
expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled()
|
||||
// And it does not retire one either. A resolved RPC is not a death certificate — the relay
|
||||
// answers identically for a PTY it never had — so retirement is left to the replay, which
|
||||
// only acts on a live inventory. Clearing here would be the mechanism by which a still-valid
|
||||
// order is destroyed on an unconfirmed success.
|
||||
expect(store.clearSshRemotePtyKillIntent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
unregisterSshPtyProvider('ssh-1')
|
||||
deletePtyOwnership(SCOPED_PTY_ID)
|
||||
}
|
||||
})
|
||||
|
||||
// Worktree sleep stops through stopAndWait and marks those stops reversible: when one does not
|
||||
// land, the pane stays live and the user keeps using it. A durable order recorded here would come
|
||||
// back on a later handshake and kill that terminal out from under them.
|
||||
it('records nothing for a reversible stopAndWait that could not be delivered', async () => {
|
||||
const store = createKillStore()
|
||||
registerSshPtyProvider(
|
||||
'ssh-1',
|
||||
sshProviderStub(async () => {
|
||||
throw new Error('socket closed')
|
||||
})
|
||||
)
|
||||
setPtyOwnership(SCOPED_PTY_ID, 'ssh-1')
|
||||
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-e')
|
||||
const { stopAndWait } = install(store)
|
||||
|
||||
try {
|
||||
await expect(stopAndWait(SCOPED_PTY_ID, { keepHistory: true })).resolves.toBe(false)
|
||||
expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
unregisterSshPtyProvider('ssh-1')
|
||||
deletePtyOwnership(SCOPED_PTY_ID)
|
||||
}
|
||||
})
|
||||
|
||||
// The path the UI actually takes. `window.api.pty.kill` -> `pty:kill`, a separate implementation
|
||||
// from the runtime controller, and the one #12447 describes.
|
||||
it('records the stop when the renderer pty:kill shutdown rejects', async () => {
|
||||
const store = createKillStore()
|
||||
registerSshPtyProvider(
|
||||
'ssh-1',
|
||||
sshProviderStub(async () => {
|
||||
throw new Error('socket closed')
|
||||
})
|
||||
)
|
||||
setPtyOwnership(SCOPED_PTY_ID, 'ssh-1')
|
||||
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-r1')
|
||||
install(store)
|
||||
|
||||
try {
|
||||
await expect(handlers.get('pty:kill')!(null, { id: SCOPED_PTY_ID })).rejects.toThrow(
|
||||
'socket closed'
|
||||
)
|
||||
expect(store.recordSshRemotePtyKillIntent).toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
'pty-7',
|
||||
expect.objectContaining({ incarnationId: 'inc-r1' })
|
||||
)
|
||||
} finally {
|
||||
unregisterSshPtyProvider('ssh-1')
|
||||
deletePtyOwnership(SCOPED_PTY_ID)
|
||||
}
|
||||
})
|
||||
|
||||
it('records the stop when renderer pty:kill finds no provider to deliver it', async () => {
|
||||
const store = createKillStore()
|
||||
setPtyOwnership(SCOPED_PTY_ID, 'ssh-1')
|
||||
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-r2')
|
||||
install(store)
|
||||
|
||||
try {
|
||||
await handlers.get('pty:kill')!(null, { id: SCOPED_PTY_ID })
|
||||
expect(store.recordSshRemotePtyKillIntent).toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
'pty-7',
|
||||
expect.objectContaining({ incarnationId: 'inc-r2' })
|
||||
)
|
||||
} finally {
|
||||
deletePtyOwnership(SCOPED_PTY_ID)
|
||||
}
|
||||
})
|
||||
|
||||
// Pane hibernation is the reversible caller on this IPC and is the only one passing keepHistory.
|
||||
it('records nothing for a renderer pty:kill that keeps history', async () => {
|
||||
const store = createKillStore()
|
||||
registerSshPtyProvider(
|
||||
'ssh-1',
|
||||
sshProviderStub(async () => {
|
||||
throw new Error('socket closed')
|
||||
})
|
||||
)
|
||||
setPtyOwnership(SCOPED_PTY_ID, 'ssh-1')
|
||||
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-r3')
|
||||
install(store)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handlers.get('pty:kill')!(null, { id: SCOPED_PTY_ID, keepHistory: true })
|
||||
).rejects.toThrow('socket closed')
|
||||
expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
unregisterSshPtyProvider('ssh-1')
|
||||
deletePtyOwnership(SCOPED_PTY_ID)
|
||||
}
|
||||
})
|
||||
|
||||
it('records nothing while a reversible stop owns the PTY', async () => {
|
||||
const store = createKillStore()
|
||||
registerSshPtyProvider(
|
||||
'ssh-1',
|
||||
sshProviderStub(async () => {
|
||||
throw new Error('socket closed')
|
||||
})
|
||||
)
|
||||
setPtyOwnership(SCOPED_PTY_ID, 'ssh-1')
|
||||
restorePtyIncarnation(SCOPED_PTY_ID, 'inc-f')
|
||||
const { kill, markReversibleStops } = install(store)
|
||||
const release = markReversibleStops([SCOPED_PTY_ID])
|
||||
|
||||
try {
|
||||
kill(SCOPED_PTY_ID)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
release()
|
||||
unregisterSshPtyProvider('ssh-1')
|
||||
deletePtyOwnership(SCOPED_PTY_ID)
|
||||
}
|
||||
})
|
||||
|
||||
it('records nothing for a local PTY, which has no later host to ask', async () => {
|
||||
const store = createKillStore()
|
||||
setPtyOwnership('local-pty', null)
|
||||
restorePtyIncarnation('local-pty', 'inc-d')
|
||||
const { kill } = install(store)
|
||||
|
||||
try {
|
||||
kill('local-pty')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
deletePtyOwnership('local-pty')
|
||||
}
|
||||
})
|
||||
|
||||
// No incarnation means no fence, and an unfenced order can only be discarded or guessed at.
|
||||
it('records nothing when the PTY incarnation was never learned', async () => {
|
||||
const store = createKillStore()
|
||||
registerSshPtyProvider(
|
||||
'ssh-1',
|
||||
sshProviderStub(async () => {
|
||||
throw new Error(`${SSH_SESSION_EXPIRED_ERROR}-transport`)
|
||||
})
|
||||
)
|
||||
setPtyOwnership('ssh:ssh-1@@pty-8', 'ssh-1')
|
||||
const { kill } = install(store)
|
||||
|
||||
try {
|
||||
kill('ssh:ssh-1@@pty-8')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
unregisterSshPtyProvider('ssh-1')
|
||||
deletePtyOwnership('ssh:ssh-1@@pty-8')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,4 @@
|
||||
import { getPtyIpc } from '../../pty-host-bindings'
|
||||
import type { Store } from '../../../persistence'
|
||||
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
|
||||
import type { IPtyProvider } from '../../../providers/types'
|
||||
import { parseAppSshPtyId } from '../../../providers/ssh-pty-id'
|
||||
import { inspectPtyProviderProcessForRenderer } from '../../../providers/pty-process-inspection'
|
||||
import {
|
||||
@@ -9,7 +6,6 @@ import {
|
||||
visitPtyProcessListingsInBatches
|
||||
} from '../../../providers/pty-process-list-admission'
|
||||
import type { PtyListedSession } from '../../../../shared/pty-listed-session'
|
||||
import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict'
|
||||
import { ptyOwnership } from '../provider/ownership-state'
|
||||
import {
|
||||
getProviderForPty,
|
||||
@@ -18,7 +14,6 @@ import {
|
||||
sshProviders,
|
||||
tryGetProviderForPty
|
||||
} from '../provider/registry'
|
||||
import { finishPtyShutdown, isPtyAlreadyGoneError } from '../provider/liveness'
|
||||
import { ptySizes } from '../delivery/visibility-state'
|
||||
import { isValidPaneKey } from '../pane/key-state'
|
||||
import {
|
||||
@@ -29,95 +24,21 @@ import {
|
||||
} from '../pane/serializer-state'
|
||||
|
||||
export function installPtyInspectIpcHandlers(deps: {
|
||||
store?: Store
|
||||
runtime?: OrcaRuntimeService
|
||||
getLocalPtyProviderStartupPromise: (connectionId?: string | null) => Promise<void> | undefined
|
||||
shutdownProviderAndDetectExit: (
|
||||
provider: IPtyProvider,
|
||||
id: string,
|
||||
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
|
||||
) => Promise<boolean>
|
||||
rememberSyntheticKillExit: (id: string) => void
|
||||
sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void
|
||||
}): void {
|
||||
const ipcMain = getPtyIpc()
|
||||
const {
|
||||
store,
|
||||
runtime,
|
||||
getLocalPtyProviderStartupPromise,
|
||||
shutdownProviderAndDetectExit,
|
||||
rememberSyntheticKillExit,
|
||||
sendPtyExitToRenderer
|
||||
} = deps
|
||||
const { getLocalPtyProviderStartupPromise } = deps
|
||||
|
||||
// Why: wait for daemon startup before selecting the local provider for an id
|
||||
// the swap may re-own (#7742); ids owned by an SSH connection never wait.
|
||||
// pty:kill inlines this — its listener teardown is ordering-sensitive and
|
||||
// must not gain even a no-barrier microtask.
|
||||
// renderer-kill.ts inlines this — pty:kill's listener teardown is
|
||||
// ordering-sensitive and must not gain even a no-barrier microtask.
|
||||
const awaitSwapWindow = async (id: string): Promise<void> => {
|
||||
await getLocalPtyProviderStartupPromise(
|
||||
ptyOwnership.get(id) ?? parseAppSshPtyId(id)?.connectionId
|
||||
)
|
||||
}
|
||||
|
||||
ipcMain.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')
|
||||
}
|
||||
runtime?.markPtyStopRequested?.(args.id)
|
||||
const ownedConnectionId = ptyOwnership.get(args.id)
|
||||
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null
|
||||
const connectionId = ownedConnectionId ?? parsedSshId?.connectionId
|
||||
// Why: wait for daemon startup before selecting the local provider, else a fallback shutdown falsely succeeds and orphans a restored daemon PTY (#7742).
|
||||
const startupPromise = getLocalPtyProviderStartupPromise(connectionId)
|
||||
if (startupPromise) {
|
||||
await startupPromise
|
||||
}
|
||||
const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(args.id)
|
||||
if (!provider && connectionId) {
|
||||
// Why: detached SSH PTYs intentionally keep ownership after their
|
||||
// provider is unregistered; hydrated app-scoped ids can also arrive
|
||||
// before ownership is rebuilt. Tombstone instead of falling back local.
|
||||
const incarnationId = finishPtyShutdown(args.id, connectionId, store)
|
||||
runtime?.markPtyLivenessUnverifiable?.(args.id, SSH_PROVIDER_UNREGISTERED_REASON)
|
||||
runtime?.onPtyExit(args.id, -1, incarnationId)
|
||||
rememberSyntheticKillExit(args.id)
|
||||
sendPtyExitToRenderer({
|
||||
id: args.id,
|
||||
code: -1,
|
||||
...(incarnationId ? { incarnationId } : {})
|
||||
})
|
||||
return
|
||||
}
|
||||
const shutdownProvider = provider ?? getProviderForPty(args.id)
|
||||
let providerExitObserved = false
|
||||
try {
|
||||
providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, {
|
||||
immediate: true,
|
||||
keepHistory: args.keepHistory ?? false
|
||||
})
|
||||
} catch (err) {
|
||||
if (!isPtyAlreadyGoneError(err)) {
|
||||
// Why: a failed shutdown can leave the process alive (SSH relay grace window / local daemon); keep ownership/lease state so the user can retry.
|
||||
throw err
|
||||
}
|
||||
/* session already dead — cleanup below handles the rest */
|
||||
}
|
||||
// Why: some shutdown paths do not emit onExit through the provider listener.
|
||||
// Explicit cleanup is idempotent and covers already-dead PTYs.
|
||||
const incarnationId = finishPtyShutdown(args.id, connectionId, store)
|
||||
if (!providerExitObserved) {
|
||||
runtime?.onPtyExit(args.id, -1, incarnationId)
|
||||
rememberSyntheticKillExit(args.id)
|
||||
sendPtyExitToRenderer({
|
||||
id: args.id,
|
||||
code: -1,
|
||||
...(incarnationId ? { incarnationId } : {})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('pty:listSessions', async (): Promise<PtyListedSession[]> => {
|
||||
const deduped = new Map<string, PtyListedSession>()
|
||||
const admission = new PtyProcessListAdmission()
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { getPtyIpc } from '../../pty-host-bindings'
|
||||
import type { Store } from '../../../persistence'
|
||||
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
|
||||
import type { IPtyProvider } from '../../../providers/types'
|
||||
import { parseAppSshPtyId } from '../../../providers/ssh-pty-id'
|
||||
import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict'
|
||||
import { ptyOwnership } from '../provider/ownership-state'
|
||||
import { getProviderForPty, sshProviders, tryGetProviderForPty } from '../provider/registry'
|
||||
import { finishPtyShutdown, isPtyAlreadyGoneError } from '../provider/liveness'
|
||||
import { recordUndeliveredSshPtyKill } from '../runtime/undelivered-ssh-kill'
|
||||
|
||||
export type PtyKillIpcDeps = {
|
||||
store?: Store
|
||||
runtime?: OrcaRuntimeService
|
||||
getLocalPtyProviderStartupPromise: (connectionId?: string | null) => Promise<void> | undefined
|
||||
shutdownProviderAndDetectExit: (
|
||||
provider: IPtyProvider,
|
||||
id: string,
|
||||
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
|
||||
) => Promise<boolean>
|
||||
rememberSyntheticKillExit: (id: string) => void
|
||||
sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void
|
||||
}
|
||||
|
||||
/** `pty:kill` — the route the renderer takes when the user closes a terminal tab, and a separate
|
||||
* implementation from `killPtyFromRuntimeController`. Both have to record an undelivered SSH stop,
|
||||
* and this is the one ordinary tab close actually reaches. */
|
||||
export function installPtyKillIpcHandler(deps: PtyKillIpcDeps): void {
|
||||
const ipcMain = getPtyIpc()
|
||||
const {
|
||||
store,
|
||||
runtime,
|
||||
getLocalPtyProviderStartupPromise,
|
||||
shutdownProviderAndDetectExit,
|
||||
rememberSyntheticKillExit,
|
||||
sendPtyExitToRenderer
|
||||
} = deps
|
||||
|
||||
ipcMain.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')
|
||||
}
|
||||
runtime?.markPtyStopRequested?.(args.id)
|
||||
const ownedConnectionId = ptyOwnership.get(args.id)
|
||||
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null
|
||||
const connectionId = ownedConnectionId ?? parsedSshId?.connectionId
|
||||
// Why: wait for daemon startup before selecting the local provider, else a fallback shutdown falsely succeeds and orphans a restored daemon PTY (#7742).
|
||||
const startupPromise = getLocalPtyProviderStartupPromise(connectionId)
|
||||
if (startupPromise) {
|
||||
await startupPromise
|
||||
}
|
||||
// Why stated rather than inferred: this IPC serves both the ordinary tab close and pane
|
||||
// hibernation, and only hibernation passes keepHistory. Recording a replayable kill for a
|
||||
// hibernating pane would destroy it on the next handshake.
|
||||
const reversible = args.keepHistory === true
|
||||
const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(args.id)
|
||||
if (!provider && connectionId) {
|
||||
// Why: detached SSH PTYs intentionally keep ownership after their
|
||||
// provider is unregistered; hydrated app-scoped ids can also arrive
|
||||
// before ownership is rebuilt. Tombstone instead of falling back local.
|
||||
const incarnationId = finishPtyShutdown(args.id, connectionId, store)
|
||||
// The relay was never asked, so the remote shell is still running. Keep the order.
|
||||
recordUndeliveredSshPtyKill({
|
||||
store,
|
||||
ptyId: args.id,
|
||||
connectionId,
|
||||
reversible,
|
||||
incarnationId
|
||||
})
|
||||
runtime?.markPtyLivenessUnverifiable?.(args.id, SSH_PROVIDER_UNREGISTERED_REASON)
|
||||
runtime?.onPtyExit(args.id, -1, incarnationId)
|
||||
rememberSyntheticKillExit(args.id)
|
||||
sendPtyExitToRenderer({
|
||||
id: args.id,
|
||||
code: -1,
|
||||
...(incarnationId ? { incarnationId } : {})
|
||||
})
|
||||
return
|
||||
}
|
||||
const shutdownProvider = provider ?? getProviderForPty(args.id)
|
||||
let providerExitObserved = false
|
||||
try {
|
||||
providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, {
|
||||
immediate: true,
|
||||
keepHistory: args.keepHistory ?? false
|
||||
})
|
||||
} catch (err) {
|
||||
if (!isPtyAlreadyGoneError(err)) {
|
||||
// Why: a failed shutdown can leave the process alive (SSH relay grace window / local daemon); keep ownership/lease state so the user can retry.
|
||||
// The renderer has already discarded the tab, so nothing here retries — the durable order
|
||||
// is what lets the next handshake to this host finish the close.
|
||||
recordUndeliveredSshPtyKill({ store, ptyId: args.id, connectionId, reversible })
|
||||
throw err
|
||||
}
|
||||
/* session already dead — cleanup below handles the rest */
|
||||
}
|
||||
// Why: some shutdown paths do not emit onExit through the provider listener.
|
||||
// Explicit cleanup is idempotent and covers already-dead PTYs.
|
||||
const incarnationId = finishPtyShutdown(args.id, connectionId, store)
|
||||
if (!providerExitObserved) {
|
||||
runtime?.onPtyExit(args.id, -1, incarnationId)
|
||||
rememberSyntheticKillExit(args.id)
|
||||
sendPtyExitToRenderer({
|
||||
id: args.id,
|
||||
code: -1,
|
||||
...(incarnationId ? { incarnationId } : {})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -143,6 +143,10 @@ export function finishPtyShutdown(
|
||||
const incarnationId = ptyIncarnationById.get(id)
|
||||
clearProviderPtyState(id)
|
||||
if (connectionId) {
|
||||
// Deliberately does NOT retire a recorded undelivered stop. Some callers reach here having
|
||||
// asked the host and some having never asked it, so retiring from this one place would be a
|
||||
// contract every call site has to know about — and the one that forgot would silently drop a
|
||||
// kill order. Retirement is left to the replay, which only acts on host evidence.
|
||||
store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated')
|
||||
}
|
||||
ptyOwnership.delete(id)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { localProvider } from './provider/registry'
|
||||
import { finishPtyShutdown } from './provider/liveness'
|
||||
import type { GetSelectedCodexHomePath, PrepareClaudeAuth } from './host-env/types'
|
||||
import { installPtyInspectIpcHandlers } from './ipc/inspect'
|
||||
import { installPtyKillIpcHandler } from './ipc/renderer-kill'
|
||||
import { installPtyWriteIpcHandlers } from './ipc/write'
|
||||
import { installPtySpawnIpcHandler } from './ipc/spawn'
|
||||
import { installPtyRuntimeController } from './runtime/controller'
|
||||
@@ -256,7 +257,8 @@ export function registerPtyHandlers(
|
||||
clearHiddenRendererResizeOutput: session.clearHiddenRendererResizeOutput
|
||||
})
|
||||
installPtyResizeVisibilityIpc(session)
|
||||
installPtyInspectIpcHandlers({
|
||||
installPtyInspectIpcHandlers({ getLocalPtyProviderStartupPromise })
|
||||
installPtyKillIpcHandler({
|
||||
store,
|
||||
runtime,
|
||||
getLocalPtyProviderStartupPromise,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { parseAppSshPtyId } from '../../../providers/ssh-pty-id'
|
||||
import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state'
|
||||
import { getProvider, getProviderForPty } from '../provider/registry'
|
||||
import { isPtyAlreadyGoneError, delay, verifyPtyStopped } from '../provider/liveness'
|
||||
import { recordUndeliveredSshPtyKill } from './undelivered-ssh-kill'
|
||||
import type { PtyRuntimeControllerDeps } from './controller-deps'
|
||||
|
||||
export function killPtyFromRuntimeController(
|
||||
@@ -18,12 +19,22 @@ export function killPtyFromRuntimeController(
|
||||
rememberSyntheticKillExit,
|
||||
sendPtyExitToRenderer,
|
||||
finishPtyShutdown,
|
||||
retiredRejectedPtyIds
|
||||
retiredRejectedPtyIds,
|
||||
reversibleStopOwnersByPtyId
|
||||
} = deps
|
||||
runtime?.markPtyStopRequested?.(ptyId)
|
||||
let connectionId: string | null | undefined = ptyOwnership.get(ptyId)
|
||||
const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null
|
||||
connectionId ??= parsedSshId?.connectionId
|
||||
const recordUndelivered = (incarnationId?: string): void => {
|
||||
recordUndeliveredSshPtyKill({
|
||||
store,
|
||||
ptyId,
|
||||
connectionId,
|
||||
reversible: reversibleStopOwnersByPtyId.has(ptyId),
|
||||
incarnationId
|
||||
})
|
||||
}
|
||||
const killWithCurrentProvider = (): boolean => {
|
||||
let provider: IPtyProvider
|
||||
try {
|
||||
@@ -34,6 +45,8 @@ export function killPtyFromRuntimeController(
|
||||
// provider was unregistered. Tombstone the lease so reconnect does
|
||||
// not revive a terminal the user explicitly closed.
|
||||
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
|
||||
// The relay was never asked, so the remote shell is still running. Keep the order.
|
||||
recordUndelivered(incarnationId)
|
||||
runtime?.onPtyExit(ptyId, -1, incarnationId)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({
|
||||
@@ -90,6 +103,9 @@ export function killPtyFromRuntimeController(
|
||||
}
|
||||
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
|
||||
}
|
||||
// Outside the `retired` guard: the remote process outlives this client's bookkeeping
|
||||
// either way, and the intent is what the next handshake replays.
|
||||
recordUndelivered()
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -109,6 +125,7 @@ export function killPtyFromRuntimeController(
|
||||
}
|
||||
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
|
||||
}
|
||||
recordUndelivered()
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -191,6 +208,14 @@ export function markReversibleStopsFromRuntimeController(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately records no undelivered-stop intent, unlike `killPtyFromRuntimeController`.
|
||||
*
|
||||
* Only a caller that gives the PTY up for good may leave a replayable kill order behind. This one
|
||||
* hands its failures back instead: worktree sleep marks these stops reversible and leaves the pane
|
||||
* live and usable when one does not land, so a durable order recorded here would come back on a
|
||||
* later handshake and destroy a terminal the user had gone back to using.
|
||||
*/
|
||||
export async function stopAndWaitPtyFromRuntimeController(
|
||||
deps: PtyRuntimeControllerDeps,
|
||||
ptyId: string,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Store } from '../../../persistence'
|
||||
import { ptyIncarnationById } from '../provider/ownership-state'
|
||||
import { getRelayPtyId } from '../provider/registry'
|
||||
|
||||
export type UndeliveredSshPtyKill = {
|
||||
store: Store | undefined
|
||||
ptyId: string
|
||||
connectionId: string | null | undefined
|
||||
/** Whether this caller may still undo the stop. Every caller states it rather than having it
|
||||
* inferred, because both directions are bugs: `true` on a real close re-opens the leak, and
|
||||
* `false` on a reversible one lets a later handshake kill a pane the user went back to using.
|
||||
* Worktree sleep is the reversible case, on both the runtime and the renderer route. */
|
||||
reversible: boolean
|
||||
/** Pass what `finishPtyShutdown` returned: it clears the live map, so a caller that already ran
|
||||
* it can no longer look the incarnation up. */
|
||||
incarnationId?: string
|
||||
now?: number
|
||||
}
|
||||
|
||||
/** Durably records a stop this client asked for on an SSH host and could not confirm, so the next
|
||||
* handshake to that host can replay it. Without this the remote PTY — a child of the detached
|
||||
* relay daemon, not of the ssh channel — outlives the failed RPC forever.
|
||||
*
|
||||
* The silent no-ops are the safety rules, not defensive padding:
|
||||
* - **reversible**: see above.
|
||||
* - **no `connectionId`**: a local PTY's owner is this process, so a failed kill has no later host
|
||||
* to ask; there is nothing to replay against.
|
||||
* - **no incarnation**: the replay fence is the host-minted PTY incarnation, and a relay renumbers
|
||||
* from `pty-1` on every start. An order we could never safely aim can only be discarded later,
|
||||
* or worse, guessed at.
|
||||
* - **an id naming another connection**: `getRelayPtyId` throws on those, and this runs inside
|
||||
* promise `.catch` handlers where that would surface as an unhandled rejection. */
|
||||
export function recordUndeliveredSshPtyKill(args: UndeliveredSshPtyKill): void {
|
||||
const { store, ptyId, connectionId } = args
|
||||
if (!store || !connectionId || args.reversible) {
|
||||
return
|
||||
}
|
||||
const incarnationId = args.incarnationId ?? ptyIncarnationById.get(ptyId)
|
||||
if (!incarnationId) {
|
||||
return
|
||||
}
|
||||
let relayPtyId: string
|
||||
try {
|
||||
relayPtyId = getRelayPtyId(connectionId, ptyId)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
store.recordSshRemotePtyKillIntent(connectionId, relayPtyId, {
|
||||
requestedAt: args.now ?? Date.now(),
|
||||
incarnationId,
|
||||
attempts: 0
|
||||
})
|
||||
}
|
||||
@@ -31,6 +31,11 @@ export type SshLeaseStoreMock = {
|
||||
markSshRemotePtyLeasesForShutdown: Mock
|
||||
markSshRemotePtyLeasesAttachedAsync: Mock
|
||||
removeSshRemotePtyLeases: Mock
|
||||
getSshRemotePtyKillIntents: Mock
|
||||
pruneExpiredSshRemotePtyKillIntents: Mock
|
||||
recordSshRemotePtyKillIntent: Mock
|
||||
clearSshRemotePtyKillIntent: Mock
|
||||
noteSshRemotePtyKillReplayAttempt: Mock
|
||||
}
|
||||
|
||||
export type MockBrowserWindow = { isDestroyed: () => boolean; webContents: { send: Mock } }
|
||||
@@ -102,7 +107,12 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness {
|
||||
markSshRemotePtyLeasesAsync: vi.fn(),
|
||||
markSshRemotePtyLeasesForShutdown: vi.fn(),
|
||||
markSshRemotePtyLeasesAttachedAsync: vi.fn(),
|
||||
removeSshRemotePtyLeases: vi.fn()
|
||||
removeSshRemotePtyLeases: vi.fn(),
|
||||
getSshRemotePtyKillIntents: vi.fn().mockReturnValue([]),
|
||||
pruneExpiredSshRemotePtyKillIntents: vi.fn(),
|
||||
recordSshRemotePtyKillIntent: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn(),
|
||||
noteSshRemotePtyKillReplayAttempt: vi.fn()
|
||||
}
|
||||
const mockWindow = {
|
||||
isDestroyed: () => false,
|
||||
|
||||
@@ -117,11 +117,7 @@ export function parseUnmetGlibcVersion(loaderError: string): string | null {
|
||||
}
|
||||
|
||||
/** `NODE_MODULE_VERSION 115 ... requires NODE_MODULE_VERSION 127` -> { built: '115', host: '127' }. */
|
||||
export function parseNodeAbiMismatch(
|
||||
loaderError: string
|
||||
): { built: string; host: string } | null {
|
||||
const match = loaderError.match(
|
||||
/NODE_MODULE_VERSION\s+(\d+)\D+NODE_MODULE_VERSION\s+(\d+)/
|
||||
)
|
||||
export function parseNodeAbiMismatch(loaderError: string): { built: string; host: string } | null {
|
||||
const match = loaderError.match(/NODE_MODULE_VERSION\s+(\d+)\D+NODE_MODULE_VERSION\s+(\d+)/)
|
||||
return match ? { built: match[1], host: match[2] } : null
|
||||
}
|
||||
|
||||
@@ -27,7 +27,12 @@ export type PrebuiltSlotManifest = {
|
||||
|
||||
export type PrebuiltSlotOutcome =
|
||||
| { installed: true; slot: string; spawnHelper: boolean }
|
||||
| { installed: false; slot: string; why: 'no-slot' | 'no-prebuilds-dir' | 'abi-mismatch'; detail?: string }
|
||||
| {
|
||||
installed: false
|
||||
slot: string
|
||||
why: 'no-slot' | 'no-prebuilds-dir' | 'abi-mismatch'
|
||||
detail?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a deployment's prebuilds live: beside the bundle that is running. `argv[1]` is
|
||||
@@ -55,7 +60,9 @@ export function readPrebuiltSlotManifest(prebuildsDir: string): PrebuiltSlotMani
|
||||
module: typeof manifest.module === 'string' ? manifest.module : 'node-pty',
|
||||
version: manifest.version,
|
||||
nodeAbi: manifest.nodeAbi,
|
||||
slots: Array.isArray(manifest.slots) ? manifest.slots.filter((s) => typeof s === 'string') : []
|
||||
slots: Array.isArray(manifest.slots)
|
||||
? manifest.slots.filter((s) => typeof s === 'string')
|
||||
: []
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { rmSync, mkdtempSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { testState, createStore } from './persistence-test-harness'
|
||||
import {
|
||||
MAX_SSH_PENDING_PTY_KILLS_PER_TARGET,
|
||||
SSH_PENDING_PTY_KILL_TTL_MS
|
||||
} from '../shared/ssh-pending-pty-kill'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => testState.dir },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'),
|
||||
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) }))
|
||||
|
||||
const NOW = 1_800_000_000_000
|
||||
|
||||
describe('Store SSH pending PTY kills', () => {
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// The whole point of the record: a laptop closed mid-failure must not lose the kill order.
|
||||
it('survives an app restart', async () => {
|
||||
const store = await createStore()
|
||||
store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-1', state: 'attached' })
|
||||
store.recordSshRemotePtyKillIntent('ssh-1', 'pty-1', {
|
||||
requestedAt: NOW,
|
||||
incarnationId: 'inc-a',
|
||||
attempts: 0
|
||||
})
|
||||
store.flush()
|
||||
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getSshRemotePtyKillIntents('ssh-1', NOW)).toEqual([
|
||||
{ ptyId: 'pty-1', intent: { requestedAt: NOW, incarnationId: 'inc-a', attempts: 0 } }
|
||||
])
|
||||
})
|
||||
|
||||
// A kill issued while the provider was already unregistered writes no lease of its own, and that
|
||||
// offline close is the case most likely to strand a remote shell.
|
||||
it('records an intent for a PTY that has no lease row yet', async () => {
|
||||
const store = await createStore()
|
||||
store.recordSshRemotePtyKillIntent('ssh-1', 'pty-9', {
|
||||
requestedAt: NOW,
|
||||
incarnationId: 'inc-z',
|
||||
attempts: 0
|
||||
})
|
||||
store.flush()
|
||||
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getSshRemotePtyKillIntents('ssh-1', NOW).map((e) => e.ptyId)).toEqual(['pty-9'])
|
||||
})
|
||||
|
||||
// The row it invents must not be reattachable: the user closed this PTY, and reattach fences only
|
||||
// on paneKey/tabId — neither of which a row created here carries.
|
||||
it('creates the missing lease terminated so reattach cannot adopt it', async () => {
|
||||
const store = await createStore()
|
||||
store.recordSshRemotePtyKillIntent('ssh-1', 'pty-9', {
|
||||
requestedAt: NOW,
|
||||
incarnationId: 'inc-z',
|
||||
attempts: 0
|
||||
})
|
||||
|
||||
const lease = store.getSshRemotePtyLeases('ssh-1')[0]
|
||||
expect(lease?.state).toBe('terminated')
|
||||
expect(lease?.tabId).toBeUndefined()
|
||||
expect(lease?.leafId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deletes expired intents durably rather than only filtering them on read', async () => {
|
||||
const store = await createStore()
|
||||
store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-1', state: 'attached' })
|
||||
store.recordSshRemotePtyKillIntent('ssh-1', 'pty-1', {
|
||||
requestedAt: NOW,
|
||||
incarnationId: 'inc-a',
|
||||
attempts: 0
|
||||
})
|
||||
store.pruneExpiredSshRemotePtyKillIntents('ssh-1', NOW + SSH_PENDING_PTY_KILL_TTL_MS + 1)
|
||||
store.flush()
|
||||
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getSshRemotePtyLeases('ssh-1')[0]?.pendingKill).toBeUndefined()
|
||||
// Ageing out observes nothing about the process, so the lease state is left exactly as it was.
|
||||
expect(reloaded.getSshRemotePtyLeases('ssh-1')[0]?.state).toBe('attached')
|
||||
})
|
||||
|
||||
it('leaves an intent that is still inside its TTL alone', async () => {
|
||||
const store = await createStore()
|
||||
store.recordSshRemotePtyKillIntent('ssh-1', 'pty-1', {
|
||||
requestedAt: NOW,
|
||||
incarnationId: 'inc-a',
|
||||
attempts: 0
|
||||
})
|
||||
store.pruneExpiredSshRemotePtyKillIntents('ssh-1', NOW + SSH_PENDING_PTY_KILL_TTL_MS)
|
||||
|
||||
expect(store.getSshRemotePtyKillIntents('ssh-1', NOW)).toHaveLength(1)
|
||||
})
|
||||
|
||||
// The replay reads every lease state on purpose: the close path tombstones the lease locally and
|
||||
// the remote process is still running, so a terminated lease is exactly where an order lives.
|
||||
it('keeps an intent on a lease its own kill path tombstoned as terminated', async () => {
|
||||
const store = await createStore()
|
||||
store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-1', state: 'attached' })
|
||||
store.markSshRemotePtyLease('ssh-1', 'pty-1', 'terminated')
|
||||
store.recordSshRemotePtyKillIntent('ssh-1', 'pty-1', {
|
||||
requestedAt: NOW,
|
||||
incarnationId: 'inc-a',
|
||||
attempts: 0
|
||||
})
|
||||
|
||||
expect(store.getSshRemotePtyKillIntents('ssh-1', NOW)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('retires an intent without touching the lease state', async () => {
|
||||
const store = await createStore()
|
||||
store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-1', state: 'attached' })
|
||||
store.recordSshRemotePtyKillIntent('ssh-1', 'pty-1', {
|
||||
requestedAt: NOW,
|
||||
incarnationId: 'inc-a',
|
||||
attempts: 0
|
||||
})
|
||||
store.clearSshRemotePtyKillIntent('ssh-1', 'pty-1')
|
||||
store.flush()
|
||||
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getSshRemotePtyKillIntents('ssh-1', NOW)).toEqual([])
|
||||
expect(reloaded.getSshRemotePtyLeases('ssh-1')[0]?.state).toBe('attached')
|
||||
})
|
||||
|
||||
it('drops intents past the TTL on read', async () => {
|
||||
const store = await createStore()
|
||||
store.recordSshRemotePtyKillIntent('ssh-1', 'pty-1', {
|
||||
requestedAt: NOW,
|
||||
incarnationId: 'inc-a',
|
||||
attempts: 0
|
||||
})
|
||||
expect(
|
||||
store.getSshRemotePtyKillIntents('ssh-1', NOW + SSH_PENDING_PTY_KILL_TTL_MS)
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
store.getSshRemotePtyKillIntents('ssh-1', NOW + SSH_PENDING_PTY_KILL_TTL_MS + 1)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('caps pending kills per target so an unreachable host cannot grow the store', async () => {
|
||||
const store = await createStore()
|
||||
const total = MAX_SSH_PENDING_PTY_KILLS_PER_TARGET + 50
|
||||
for (let index = 0; index < total; index++) {
|
||||
store.recordSshRemotePtyKillIntent('ssh-1', `pty-${index}`, {
|
||||
requestedAt: NOW + index,
|
||||
incarnationId: `inc-${index}`,
|
||||
attempts: 0
|
||||
})
|
||||
}
|
||||
store.flush()
|
||||
|
||||
const reloaded = await createStore()
|
||||
const persisted = reloaded
|
||||
.getSshRemotePtyLeases('ssh-1')
|
||||
.filter((lease) => lease.pendingKill !== undefined)
|
||||
expect(persisted).toHaveLength(MAX_SSH_PENDING_PTY_KILLS_PER_TARGET)
|
||||
// Newest kept: the oldest orders are the ones least likely to still name a live process.
|
||||
expect(persisted.some((lease) => lease.ptyId === `pty-${total - 1}`)).toBe(true)
|
||||
expect(persisted.some((lease) => lease.ptyId === 'pty-0')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not let a repeated close extend the TTL, and carries attempts forward', async () => {
|
||||
const store = await createStore()
|
||||
store.recordSshRemotePtyKillIntent('ssh-1', 'pty-1', {
|
||||
requestedAt: NOW,
|
||||
incarnationId: 'inc-a',
|
||||
attempts: 0
|
||||
})
|
||||
store.noteSshRemotePtyKillReplayAttempt('ssh-1', 'pty-1')
|
||||
store.recordSshRemotePtyKillIntent('ssh-1', 'pty-1', {
|
||||
requestedAt: NOW + 5000,
|
||||
incarnationId: 'inc-a',
|
||||
attempts: 0
|
||||
})
|
||||
|
||||
expect(store.getSshRemotePtyKillIntents('ssh-1', NOW)[0]?.intent).toEqual({
|
||||
requestedAt: NOW,
|
||||
incarnationId: 'inc-a',
|
||||
attempts: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('scopes intents to their own target', async () => {
|
||||
const store = await createStore()
|
||||
store.recordSshRemotePtyKillIntent('ssh-1', 'pty-1', {
|
||||
requestedAt: NOW,
|
||||
incarnationId: 'inc-a',
|
||||
attempts: 0
|
||||
})
|
||||
expect(store.getSshRemotePtyKillIntents('ssh-2', NOW)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SshTarget
|
||||
} from '../../../shared/ssh-types'
|
||||
import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../../shared/ssh-types'
|
||||
import { normalizeSshPendingPtyKill } from '../../../shared/ssh-pending-pty-kill'
|
||||
|
||||
export type LegacySshTarget = SshTarget & {
|
||||
remoteWorkspaceSyncEnabled?: unknown
|
||||
@@ -60,9 +61,11 @@ export function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease |
|
||||
return null
|
||||
}
|
||||
const now = Date.now()
|
||||
const pendingKill = normalizeSshPendingPtyKill(raw.pendingKill)
|
||||
return {
|
||||
targetId: raw.targetId,
|
||||
ptyId: raw.ptyId,
|
||||
...(pendingKill ? { pendingKill } : {}),
|
||||
...(typeof raw.worktreeId === 'string' ? { worktreeId: raw.worktreeId } : {}),
|
||||
...(typeof raw.tabId === 'string' ? { tabId: raw.tabId } : {}),
|
||||
...(typeof raw.leafId === 'string' && raw.leafId.length <= 256 ? { leafId: raw.leafId } : {}),
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import {
|
||||
MAX_SSH_PENDING_PTY_KILLS_PER_TARGET,
|
||||
isSshPendingPtyKillExpired,
|
||||
pendingSshPtyKillEntries,
|
||||
prunePendingSshPtyKills,
|
||||
type SshPendingPtyKill,
|
||||
type SshPendingPtyKillEntry
|
||||
} from '../../../shared/ssh-pending-pty-kill'
|
||||
import type { SshRemotePtyLease } from '../../../shared/ssh-types'
|
||||
import type { SshPtyLeaseOperations } from './ssh-pty-lease-operations'
|
||||
|
||||
/** Every recorded-but-undelivered stop for a target, newest first, TTL-filtered and capped.
|
||||
* Returned with stored (target-local) relay pty ids, which is what `pty.shutdown` takes. */
|
||||
export function getSshRemotePtyKillIntents(
|
||||
state: PersistedState,
|
||||
targetId: string,
|
||||
now: number
|
||||
): SshPendingPtyKillEntry[] {
|
||||
const leases = (state.sshRemotePtyLeases ?? []).filter((lease) => lease.targetId === targetId)
|
||||
return prunePendingSshPtyKills(pendingSshPtyKillEntries(leases), now)
|
||||
}
|
||||
|
||||
/** Deletes orders past their TTL, durably.
|
||||
*
|
||||
* The read path filters them out too, but filtering alone would leave the field on disk forever
|
||||
* and make the cap the only thing that ever reclaimed it. This is the TTL retirement path, and it
|
||||
* deliberately leaves `state` alone: an order aging out observes nothing about the process. */
|
||||
export function pruneExpiredSshRemotePtyKillIntents(
|
||||
operations: SshPtyLeaseOperations,
|
||||
targetId: string,
|
||||
now: number
|
||||
): void {
|
||||
let changed = false
|
||||
for (const lease of operations.state.sshRemotePtyLeases ?? []) {
|
||||
if (
|
||||
lease.targetId === targetId &&
|
||||
lease.pendingKill &&
|
||||
isSshPendingPtyKillExpired(lease.pendingKill, now)
|
||||
) {
|
||||
delete lease.pendingKill
|
||||
lease.updatedAt = now
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
operations.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops the oldest intents past the cap so an unreachable target cannot grow the store. Runs over
|
||||
* the whole target rather than the arriving id, because the cap is what bounds the target. */
|
||||
function capPendingKillsForTarget(
|
||||
leases: SshRemotePtyLease[],
|
||||
targetId: string,
|
||||
now: number
|
||||
): void {
|
||||
const scoped = leases.filter((lease) => lease.targetId === targetId && lease.pendingKill)
|
||||
if (scoped.length <= MAX_SSH_PENDING_PTY_KILLS_PER_TARGET) {
|
||||
return
|
||||
}
|
||||
const kept = new Set(
|
||||
prunePendingSshPtyKills(pendingSshPtyKillEntries(scoped), now).map((entry) => entry.ptyId)
|
||||
)
|
||||
for (const lease of scoped) {
|
||||
if (!kept.has(lease.ptyId)) {
|
||||
delete lease.pendingKill
|
||||
lease.updatedAt = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Records a stop this client asked for and could not confirm.
|
||||
*
|
||||
* Creates the lease when none exists — a kill that found no provider registered writes no lease of
|
||||
* its own, and that offline close is the case most likely to strand a remote process. It is
|
||||
* created `terminated`, carrying no pane identity, because the user closed this PTY: reattach must
|
||||
* not adopt it. That is a routing tombstone and not a claim the process died — the process
|
||||
* question is exactly what `pendingKill` is now tracking, and the replay reads every lease state. */
|
||||
export function recordSshRemotePtyKillIntent(
|
||||
operations: SshPtyLeaseOperations,
|
||||
targetId: string,
|
||||
ptyId: string,
|
||||
intent: SshPendingPtyKill
|
||||
): void {
|
||||
const relayPtyId = operations.toStoredPtyId(targetId, ptyId)
|
||||
const now = intent.requestedAt
|
||||
operations.state.sshRemotePtyLeases ??= []
|
||||
const leases = operations.state.sshRemotePtyLeases
|
||||
const existing = leases.find((entry) => entry.targetId === targetId && entry.ptyId === relayPtyId)
|
||||
if (existing) {
|
||||
// Why keep the earliest requestedAt: the TTL bounds how long the intent may chase the host, and
|
||||
// a repeated close must not extend it. Attempts carry over so replays stay countable.
|
||||
existing.pendingKill = {
|
||||
...intent,
|
||||
requestedAt: Math.min(existing.pendingKill?.requestedAt ?? now, now),
|
||||
attempts: existing.pendingKill?.attempts ?? intent.attempts
|
||||
}
|
||||
existing.updatedAt = now
|
||||
} else {
|
||||
leases.push({
|
||||
targetId,
|
||||
ptyId: relayPtyId,
|
||||
state: 'terminated',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
pendingKill: intent
|
||||
})
|
||||
}
|
||||
capPendingKillsForTarget(leases, targetId, now)
|
||||
operations.flush()
|
||||
}
|
||||
|
||||
/** Retires the intent. Deliberately does not touch `state`: the caller decides whether it earned a
|
||||
* `terminated` tombstone, because only some retirement paths observed the host. */
|
||||
export function clearSshRemotePtyKillIntent(
|
||||
operations: SshPtyLeaseOperations,
|
||||
targetId: string,
|
||||
ptyId: string
|
||||
): void {
|
||||
const relayPtyId = operations.toStoredPtyId(targetId, ptyId)
|
||||
const lease = (operations.state.sshRemotePtyLeases ?? []).find(
|
||||
(entry) => entry.targetId === targetId && entry.ptyId === relayPtyId
|
||||
)
|
||||
if (!lease?.pendingKill) {
|
||||
return
|
||||
}
|
||||
delete lease.pendingKill
|
||||
lease.updatedAt = Date.now()
|
||||
operations.flush()
|
||||
}
|
||||
|
||||
/** Counts one replay against the intent so a target that never answers stays visible in the record
|
||||
* without changing what it is allowed to do. */
|
||||
export function noteSshRemotePtyKillReplayAttempt(
|
||||
operations: SshPtyLeaseOperations,
|
||||
targetId: string,
|
||||
ptyId: string
|
||||
): void {
|
||||
const relayPtyId = operations.toStoredPtyId(targetId, ptyId)
|
||||
const lease = (operations.state.sshRemotePtyLeases ?? []).find(
|
||||
(entry) => entry.targetId === targetId && entry.ptyId === relayPtyId
|
||||
)
|
||||
if (!lease?.pendingKill) {
|
||||
return
|
||||
}
|
||||
lease.pendingKill = { ...lease.pendingKill, attempts: lease.pendingKill.attempts + 1 }
|
||||
lease.updatedAt = Date.now()
|
||||
operations.flush()
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
import type {
|
||||
SshPendingPtyKill,
|
||||
SshPendingPtyKillEntry
|
||||
} from '../../../shared/ssh-pending-pty-kill'
|
||||
import type { SshPtyConsumerRecovery, SshRemotePtyLease } from '../../../shared/ssh-types'
|
||||
import {
|
||||
clearSshRemotePtyKillIntent as clearSshRemotePtyKillIntentOperation,
|
||||
getSshRemotePtyKillIntents as getSshRemotePtyKillIntentsOperation,
|
||||
noteSshRemotePtyKillReplayAttempt as noteSshRemotePtyKillReplayAttemptOperation,
|
||||
pruneExpiredSshRemotePtyKillIntents as pruneExpiredSshRemotePtyKillIntentsOperation,
|
||||
recordSshRemotePtyKillIntent as recordSshRemotePtyKillIntentOperation
|
||||
} from '../leasing-ssh-ptys/ssh-pty-kill-intent-operations'
|
||||
import {
|
||||
getSshRemotePtyLeases as getSshRemotePtyLeasesOperation,
|
||||
markSshRemotePtyLease as markSshRemotePtyLeaseOperation,
|
||||
@@ -120,6 +131,30 @@ export class SshLeaseRecoveryOperations {
|
||||
removeSshRemotePtyLeases(targetId: string): void {
|
||||
removeSshRemotePtyLeasesOperation(getSshPtyLeaseOperations(this), targetId)
|
||||
}
|
||||
|
||||
getSshRemotePtyKillIntents(targetId: string, now = Date.now()): SshPendingPtyKillEntry[] {
|
||||
return getSshRemotePtyKillIntentsOperation(
|
||||
this[sshLeaseRecoveryOperationsContext].runtime.state,
|
||||
targetId,
|
||||
now
|
||||
)
|
||||
}
|
||||
|
||||
recordSshRemotePtyKillIntent(targetId: string, ptyId: string, intent: SshPendingPtyKill): void {
|
||||
recordSshRemotePtyKillIntentOperation(getSshPtyLeaseOperations(this), targetId, ptyId, intent)
|
||||
}
|
||||
|
||||
clearSshRemotePtyKillIntent(targetId: string, ptyId: string): void {
|
||||
clearSshRemotePtyKillIntentOperation(getSshPtyLeaseOperations(this), targetId, ptyId)
|
||||
}
|
||||
|
||||
noteSshRemotePtyKillReplayAttempt(targetId: string, ptyId: string): void {
|
||||
noteSshRemotePtyKillReplayAttemptOperation(getSshPtyLeaseOperations(this), targetId, ptyId)
|
||||
}
|
||||
|
||||
pruneExpiredSshRemotePtyKillIntents(targetId: string, now = Date.now()): void {
|
||||
pruneExpiredSshRemotePtyKillIntentsOperation(getSshPtyLeaseOperations(this), targetId, now)
|
||||
}
|
||||
}
|
||||
|
||||
export function getSshPtyConsumerRecoveryOperations(
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -17,13 +19,69 @@ import {
|
||||
getPowerShellCodexShellLaunchPreflight,
|
||||
resolveCodexShellLaunchPreflightCommand
|
||||
} from './codex-shell-launch-preflight'
|
||||
import { fishRequirementViolation, resolveFishBinary } from '../../shared/fish-binary-requirement'
|
||||
|
||||
const roots: string[] = []
|
||||
const zshAvailable = existsSync('/bin/zsh')
|
||||
const bashAvailable = existsSync('/bin/bash')
|
||||
const fishAvailable = spawnSync('fish', ['--version']).status === 0
|
||||
// Why the shared lookup: it also finds a Homebrew fish that is off PATH, and it
|
||||
// carries the ORCA_REQUIRE_FISH contract asserted below.
|
||||
const fishLookup = resolveFishBinary()
|
||||
const fishAvailable = fishLookup.available
|
||||
const pwshAvailable =
|
||||
spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-Command', 'exit 0']).status === 0
|
||||
// Sandboxing needs an absolute path to symlink; the lookup may report a bare name.
|
||||
const fishBinary = fishLookup.available
|
||||
? isAbsolute(fishLookup.path)
|
||||
? fishLookup.path
|
||||
: resolveOnPath(fishLookup.path)
|
||||
: null
|
||||
|
||||
/** Absolute path of an executable file on the host PATH, or null. */
|
||||
function resolveOnPath(name: string): string | null {
|
||||
for (const dir of (process.env.PATH ?? '').split(delimiter)) {
|
||||
if (!dir) {
|
||||
continue
|
||||
}
|
||||
const candidate = join(dir, name)
|
||||
try {
|
||||
if (statSync(candidate).isFile()) {
|
||||
return candidate
|
||||
}
|
||||
} catch {
|
||||
// Not in this directory.
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Sandbox whose bin is the *entire* PATH, so codex provably cannot resolve.
|
||||
*
|
||||
* Why symlink fish in rather than append its directory: fish routinely ships
|
||||
* alongside a real codex (both land in Homebrew's bin), which would silently
|
||||
* turn the absent-codex regression below into a no-op on a normal dev machine. */
|
||||
function createFishSandbox(prefix: string): { bin: string; preflight: string; marker: string } {
|
||||
if (!fishBinary) {
|
||||
throw new Error('fish ran but could not be resolved to a file to symlink')
|
||||
}
|
||||
const root = mkdtempSync(join(tmpdir(), prefix))
|
||||
roots.push(root)
|
||||
const bin = join(root, 'bin')
|
||||
mkdirSync(bin)
|
||||
symlinkSync(fishBinary, join(bin, 'fish'))
|
||||
const marker = join(root, 'preflight-ran')
|
||||
const preflight = join(bin, 'orca-preflight')
|
||||
writeExecutable(preflight, `#!/bin/sh\nprintf ran > ${JSON.stringify(marker)}\n`)
|
||||
return { bin, preflight, marker }
|
||||
}
|
||||
|
||||
// Reports Orca's own wrapper (not a user-defined codex function) and any capture leak.
|
||||
const FISH_STATE_PROBE = `if functions -q codex; and functions codex | string match -q '*prepare-codex*'
|
||||
echo -n wrapper=YES
|
||||
else
|
||||
echo -n wrapper=NO
|
||||
end
|
||||
echo " var=[$__orca_codex_type]"`
|
||||
|
||||
function writeExecutable(path: string, content: string): void {
|
||||
writeFileSync(path, content)
|
||||
@@ -242,6 +300,66 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', ()
|
||||
|
||||
expect(output.trim()).toBe('custom-codex')
|
||||
})
|
||||
|
||||
// Always runs, so the fish lane cannot report green with the #16893 regression
|
||||
// unexercised — the skips below would otherwise hide a missing binary.
|
||||
it('has fish when CI demanded it', () => {
|
||||
expect(fishRequirementViolation(fishLookup)).toBeNull()
|
||||
})
|
||||
|
||||
// Regression for #16893: an unquoted `(type -t codex)` expands to zero words when
|
||||
// codex is absent, so `test` saw `= file` (2 args) and printed "Missing argument
|
||||
// at index 3" on every fish pane launch. Needs a valid executable
|
||||
// ORCA_CODEX_LAUNCH_PREFLIGHT so the `and` chain reaches the second `test`, and
|
||||
// the real `-l -C` launch shape both shell-ready call sites use.
|
||||
it.skipIf(!fishAvailable)('stays silent and installs no wrapper when codex is absent', () => {
|
||||
const { bin, preflight } = createFishSandbox('orca-codex-fish-absent-')
|
||||
|
||||
const result = spawnSync(
|
||||
join(bin, 'fish'),
|
||||
['--no-config', '-l', '-C', getFishCodexShellLaunchPreflight(), '-c', FISH_STATE_PROBE],
|
||||
{ encoding: 'utf-8', env: { PATH: bin, ORCA_CODEX_LAUNCH_PREFLIGHT: preflight } }
|
||||
)
|
||||
|
||||
expect(result.stderr).not.toContain('Missing argument')
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout.trim()).toBe('wrapper=NO var=[]')
|
||||
})
|
||||
|
||||
it.skipIf(!fishAvailable)('wraps codex and runs the preflight when codex is a real file', () => {
|
||||
const { bin, preflight, marker } = createFishSandbox('orca-codex-fish-present-')
|
||||
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf "real codex $*"\n')
|
||||
|
||||
const output = execFileSync(
|
||||
join(bin, 'fish'),
|
||||
['--no-config', '-l', '-C', getFishCodexShellLaunchPreflight(), '-c', 'codex hi'],
|
||||
{ encoding: 'utf-8', env: { PATH: bin, ORCA_CODEX_LAUNCH_PREFLIGHT: preflight } }
|
||||
)
|
||||
|
||||
expect(output.trim()).toBe('real codex hi')
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
})
|
||||
|
||||
it.skipIf(!fishAvailable)('leaves a codex alias unwrapped', () => {
|
||||
const { bin, preflight } = createFishSandbox('orca-codex-fish-alias-')
|
||||
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf "real codex"\n')
|
||||
|
||||
const result = spawnSync(
|
||||
join(bin, 'fish'),
|
||||
[
|
||||
'--no-config',
|
||||
'-l',
|
||||
'-C',
|
||||
`alias codex='echo aliased'\n${getFishCodexShellLaunchPreflight()}`,
|
||||
'-c',
|
||||
FISH_STATE_PROBE
|
||||
],
|
||||
{ encoding: 'utf-8', env: { PATH: bin, ORCA_CODEX_LAUNCH_PREFLIGHT: preflight } }
|
||||
)
|
||||
|
||||
expect(result.stderr).not.toContain('Missing argument')
|
||||
expect(result.stdout.trim()).toBe('wrapper=NO var=[]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PowerShell Codex shell launch preflight', () => {
|
||||
|
||||
@@ -85,12 +85,17 @@ unset __orca_codex_binary
|
||||
}
|
||||
|
||||
export function getFishCodexShellLaunchPreflight(): string {
|
||||
return `if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
return `# Why captured: an unquoted (type -t codex) expands to zero words when codex is
|
||||
# absent, leaving "test = file" — fish then errors instead of failing closed.
|
||||
# Quoting in place is not the fix; fish never substitutes inside double quotes.
|
||||
set -l __orca_codex_type (type -t codex 2>/dev/null)
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test "$__orca_codex_type" = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
end
|
||||
end`
|
||||
end
|
||||
set -e __orca_codex_type`
|
||||
}
|
||||
|
||||
export function getPowerShellCodexShellLaunchPreflight(): string {
|
||||
|
||||
@@ -47634,7 +47634,8 @@ describe('OrcaRuntimeService', () => {
|
||||
updatedAt: '2026-05-22T00:00:00Z',
|
||||
author: 'alex'
|
||||
}
|
||||
]
|
||||
],
|
||||
totalPages: 3
|
||||
})
|
||||
listGitLabTodosMock.mockResolvedValue([])
|
||||
listGitLabLabelsMock.mockResolvedValue(['bug', 'frontend'])
|
||||
@@ -47679,7 +47680,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
await runtime.listGitLabRepoMRs(TEST_REPO_ID, 'closed', 2, 25, 'ambiguous selector')
|
||||
await runtime.listGitLabRepoWorkItems(TEST_REPO_ID, 'closed', 2, 25, 'ambiguous selector')
|
||||
const issues = await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'opened', '@me', 50)
|
||||
const issues = await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'opened', '@me', 50, 3)
|
||||
await runtime.listGitLabRepoTodos(TEST_REPO_ID)
|
||||
await runtime.listGitLabRepoLabels(TEST_REPO_ID)
|
||||
await runtime.createGitLabRepoIssue(TEST_REPO_ID, 'New issue', 'Body')
|
||||
@@ -47734,7 +47735,9 @@ describe('OrcaRuntimeService', () => {
|
||||
'origin',
|
||||
'opened',
|
||||
'@me',
|
||||
'ssh-1'
|
||||
'ssh-1',
|
||||
{},
|
||||
3
|
||||
)
|
||||
expect(issues.items).toEqual([
|
||||
{
|
||||
@@ -47750,6 +47753,7 @@ describe('OrcaRuntimeService', () => {
|
||||
repoId: TEST_REPO_ID
|
||||
}
|
||||
])
|
||||
expect(issues).toMatchObject({ totalPages: 3 })
|
||||
expect(listGitLabTodosMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1')
|
||||
expect(listGitLabLabelsMock).toHaveBeenCalledWith('/remote/repo', 'origin', 'ssh-1')
|
||||
expect(createGitLabIssueMock).toHaveBeenCalledWith(
|
||||
@@ -47920,7 +47924,8 @@ describe('OrcaRuntimeService', () => {
|
||||
'opened',
|
||||
undefined,
|
||||
null,
|
||||
localGitOptions
|
||||
localGitOptions,
|
||||
1
|
||||
)
|
||||
expect(listGitLabTodosMock).toHaveBeenCalledWith(TEST_REPO_PATH, null, localGitOptions)
|
||||
expect(listGitLabLabelsMock).toHaveBeenCalledWith(
|
||||
@@ -48127,9 +48132,21 @@ describe('OrcaRuntimeService', () => {
|
||||
it('normalizes runtime GitLab issue list arguments like the desktop IPC path', async () => {
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
|
||||
await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'closed', 'someone-else' as never, 250.8)
|
||||
await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'all', '@me', 0.7)
|
||||
await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'unexpected' as never, '@me', Number.NaN)
|
||||
await runtime.listGitLabRepoIssues(
|
||||
TEST_REPO_ID,
|
||||
'closed',
|
||||
'someone-else' as never,
|
||||
250.8,
|
||||
20_000
|
||||
)
|
||||
await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'all', '@me', 0.7, 0)
|
||||
await runtime.listGitLabRepoIssues(
|
||||
TEST_REPO_ID,
|
||||
'unexpected' as never,
|
||||
'@me',
|
||||
Number.NaN,
|
||||
Number.NaN
|
||||
)
|
||||
|
||||
expect(listGitLabIssuesMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -48138,7 +48155,9 @@ describe('OrcaRuntimeService', () => {
|
||||
undefined,
|
||||
'closed',
|
||||
undefined,
|
||||
null
|
||||
null,
|
||||
{},
|
||||
10_000
|
||||
)
|
||||
expect(listGitLabIssuesMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
@@ -48147,7 +48166,9 @@ describe('OrcaRuntimeService', () => {
|
||||
undefined,
|
||||
'all',
|
||||
'@me',
|
||||
null
|
||||
null,
|
||||
{},
|
||||
1
|
||||
)
|
||||
expect(listGitLabIssuesMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
@@ -48156,7 +48177,9 @@ describe('OrcaRuntimeService', () => {
|
||||
undefined,
|
||||
'opened',
|
||||
'@me',
|
||||
null
|
||||
null,
|
||||
{},
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -23078,13 +23078,16 @@ export class OrcaRuntimeService {
|
||||
repoSelector: string,
|
||||
state?: GitLabIssueListState,
|
||||
assignee?: string,
|
||||
limit?: number
|
||||
limit?: number,
|
||||
page?: number
|
||||
): Promise<{
|
||||
items: GitLabWorkItem[]
|
||||
totalPages: number
|
||||
error?: Awaited<ReturnType<typeof listGitLabIssues>>['error']
|
||||
}> {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
const normalized = normalizeGitLabIssueListArgs({ state, assignee, limit })
|
||||
const normalized = normalizeGitLabIssueListArgs({ state, assignee, limit, page })
|
||||
// Why: page is after localGitOptions; never spread optional args before it (#13538).
|
||||
const result = await listGitLabIssues(
|
||||
repo.path,
|
||||
normalized.limit,
|
||||
@@ -23092,7 +23095,8 @@ export class OrcaRuntimeService {
|
||||
normalized.state,
|
||||
normalized.assignee,
|
||||
repo.connectionId ?? null,
|
||||
...this.getLocalGitExecutionOptionArgs(repo)
|
||||
this.getLocalGitExecutionOptionArgs(repo)[0] ?? {},
|
||||
normalized.page
|
||||
)
|
||||
// Why: web runtime mirrors the desktop preload contract, where GitLab
|
||||
// issue rows share the GitLabWorkItem shape with MRs on TaskPage.
|
||||
@@ -23108,7 +23112,11 @@ export class OrcaRuntimeService {
|
||||
author: issue.author ?? null,
|
||||
repoId: repo.id
|
||||
}))
|
||||
return { items, ...(result.error ? { error: result.error } : {}) }
|
||||
return {
|
||||
items,
|
||||
totalPages: result.totalPages,
|
||||
...(result.error ? { error: result.error } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async listGitLabRepoTodos(
|
||||
|
||||
@@ -64,7 +64,8 @@ describe('gitlab RPC methods', () => {
|
||||
repo: 'id:repo-1',
|
||||
state: 'opened',
|
||||
assignee: '@me',
|
||||
limit: 50
|
||||
limit: 50,
|
||||
page: 2
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
@@ -202,7 +203,7 @@ describe('gitlab RPC methods', () => {
|
||||
25,
|
||||
'bug'
|
||||
)
|
||||
expect(runtime.listGitLabRepoIssues).toHaveBeenCalledWith('id:repo-1', 'opened', '@me', 50)
|
||||
expect(runtime.listGitLabRepoIssues).toHaveBeenCalledWith('id:repo-1', 'opened', '@me', 50, 2)
|
||||
expect(runtime.createGitLabRepoIssue).toHaveBeenCalledWith('id:repo-1', 'Fix bug', 'Details')
|
||||
expect(runtime.listGitLabRepoTodos).toHaveBeenCalledWith('id:repo-1')
|
||||
expect(runtime.listGitLabRepoLabels).toHaveBeenCalledWith('id:repo-1')
|
||||
@@ -309,9 +310,17 @@ describe('gitlab RPC methods', () => {
|
||||
'id:repo-1',
|
||||
'closed',
|
||||
undefined,
|
||||
100
|
||||
100,
|
||||
1
|
||||
)
|
||||
expect(runtime.listGitLabRepoIssues).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'id:repo-1',
|
||||
'opened',
|
||||
'@me',
|
||||
1,
|
||||
1
|
||||
)
|
||||
expect(runtime.listGitLabRepoIssues).toHaveBeenNthCalledWith(2, 'id:repo-1', 'opened', '@me', 1)
|
||||
})
|
||||
|
||||
// Regression for #7732: the WS/relay transports close the connection on frames
|
||||
|
||||
@@ -36,7 +36,8 @@ const WorkItemsList = RepoSelector.extend({
|
||||
const IssuesList = RepoSelector.extend({
|
||||
state: z.unknown().optional(),
|
||||
assignee: OptionalString,
|
||||
limit: OptionalFiniteNumber
|
||||
limit: OptionalFiniteNumber,
|
||||
page: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
const CreateIssue = RepoSelector.extend({
|
||||
@@ -182,7 +183,8 @@ export const GITLAB_METHODS: RpcMethod[] = [
|
||||
params.repo,
|
||||
normalized.state,
|
||||
normalized.assignee,
|
||||
normalized.limit
|
||||
normalized.limit,
|
||||
normalized.page
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -157,7 +157,11 @@ async function writeSkillLockHash(homeDir: string, skillFolderHash: string): Pro
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((root) => rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('read-only skill freshness inventory', () => {
|
||||
@@ -907,5 +911,5 @@ describe('read-only skill freshness inventory', () => {
|
||||
expect(inventory.installations).toEqual([
|
||||
expect.objectContaining({ name: 'orca-cli', status: 'current', topology: 'canonical-copy' })
|
||||
])
|
||||
})
|
||||
}, 90_000)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveDefaultHermesSkillsRoot,
|
||||
@@ -10,14 +10,16 @@ import {
|
||||
|
||||
describe('skill provider runtime roots', () => {
|
||||
it('maps Claude and Grok config homes to their global skill roots', () => {
|
||||
const claudeRoot = resolve('/srv/claude')
|
||||
const grokRoot = resolve('/srv/grok')
|
||||
expect(
|
||||
resolveEnvironmentSkillProviderRoots({
|
||||
CLAUDE_CONFIG_DIR: join('/srv', 'claude'),
|
||||
GROK_HOME: join('/srv', 'grok')
|
||||
CLAUDE_CONFIG_DIR: claudeRoot,
|
||||
GROK_HOME: grokRoot
|
||||
})
|
||||
).toEqual({
|
||||
claude: join('/srv', 'claude', 'skills'),
|
||||
grok: join('/srv', 'grok', 'skills')
|
||||
claude: join(claudeRoot, 'skills'),
|
||||
grok: join(grokRoot, 'skills')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,14 +29,16 @@ describe('skill provider runtime roots', () => {
|
||||
GROK_HOME: '../grok'
|
||||
})
|
||||
expect(roots).toEqual({})
|
||||
expect(withClaudeSkillProviderRoot(roots, join('/managed', 'claude'))).toEqual({
|
||||
claude: join('/managed', 'claude', 'skills')
|
||||
const managedClaudeRoot = resolve('/managed/claude')
|
||||
expect(withClaudeSkillProviderRoot(roots, managedClaudeRoot)).toEqual({
|
||||
claude: join(managedClaudeRoot, 'skills')
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a relocated HERMES_HOME to its skill root and ignores relative ones', () => {
|
||||
expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: join('/srv', 'hermes') })).toBe(
|
||||
join('/srv', 'hermes', 'skills')
|
||||
const hermesRoot = resolve('/srv/hermes')
|
||||
expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: hermesRoot })).toBe(
|
||||
join(hermesRoot, 'skills')
|
||||
)
|
||||
expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: '../hermes' })).toBeNull()
|
||||
expect(resolveEnvironmentHermesSkillsRoot({})).toBeNull()
|
||||
@@ -52,13 +56,14 @@ describe('skill provider runtime roots', () => {
|
||||
})
|
||||
|
||||
it('defaults the Hermes skills root under LOCALAPPDATA on Windows', () => {
|
||||
const localAppData = resolve('/local')
|
||||
const resolved = resolveDefaultHermesSkillsRoot({
|
||||
homeDir: join('/users', 'alice'),
|
||||
platform: 'win32',
|
||||
env: { LOCALAPPDATA: join('/local') },
|
||||
directoryExists: (candidate) => candidate === join('/local', 'hermes')
|
||||
env: { LOCALAPPDATA: localAppData },
|
||||
directoryExists: (candidate) => candidate === join(localAppData, 'hermes')
|
||||
})
|
||||
expect(resolved).toBe(join('/local', 'hermes', 'skills'))
|
||||
expect(resolved).toBe(join(localAppData, 'hermes', 'skills'))
|
||||
})
|
||||
|
||||
it('keeps a pre-LOCALAPPDATA Windows dotfolder install discoverable', () => {
|
||||
@@ -72,13 +77,14 @@ describe('skill provider runtime roots', () => {
|
||||
})
|
||||
|
||||
it('prefers the LOCALAPPDATA tree on Windows when both layouts exist', () => {
|
||||
const localAppData = resolve('/local')
|
||||
const resolved = resolveDefaultHermesSkillsRoot({
|
||||
homeDir: join('/users', 'alice'),
|
||||
platform: 'win32',
|
||||
env: { LOCALAPPDATA: join('/local') },
|
||||
env: { LOCALAPPDATA: localAppData },
|
||||
directoryExists: () => true
|
||||
})
|
||||
expect(resolved).toBe(join('/local', 'hermes', 'skills'))
|
||||
expect(resolved).toBe(join(localAppData, 'hermes', 'skills'))
|
||||
})
|
||||
|
||||
it('falls back to the dotfolder when Windows exposes no usable LOCALAPPDATA', () => {
|
||||
|
||||
@@ -163,4 +163,3 @@ export function formatMissingToolchainError(
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,5 @@ export function orcadGcPinnedDirNames(
|
||||
const versions = [record.active, record.previous, daemonEntryVersion ?? null].filter(
|
||||
(v): v is string => typeof v === 'string' && v.length > 0
|
||||
)
|
||||
return [...new Set(versions)].map((version) =>
|
||||
remoteInstallDirName(ORCAD_INSTALL_MODEL, version)
|
||||
)
|
||||
return [...new Set(versions)].map((version) => remoteInstallDirName(ORCAD_INSTALL_MODEL, version))
|
||||
}
|
||||
|
||||
@@ -209,7 +209,9 @@ describe('deployOrcad', () => {
|
||||
const result = await deployOrcad(options())
|
||||
expect(result).toMatchObject({ code: 'orcad_activation_daemon_degraded' })
|
||||
expect(
|
||||
vi.mocked(writeRelayFile).mock.calls.some((call) => String(call[2]).endsWith('orcad-active.json'))
|
||||
vi
|
||||
.mocked(writeRelayFile)
|
||||
.mock.calls.some((call) => String(call[2]).endsWith('orcad-active.json'))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -32,10 +32,7 @@ import {
|
||||
type OrcadActivationRecord,
|
||||
type OrcadStateSnapshot
|
||||
} from './orcad-activation-record'
|
||||
import {
|
||||
orcadActivationPath,
|
||||
readOrcadActivationRecord
|
||||
} from './orcad-activation-record-store'
|
||||
import { orcadActivationPath, readOrcadActivationRecord } from './orcad-activation-record-store'
|
||||
import { evaluateOrcadActivation, type OrcadActivationVerdict } from './orcad-activation-gate'
|
||||
import { planOrcadUpdate, type OrcadTerminalCensus } from './orcad-update-plan'
|
||||
import {
|
||||
@@ -111,9 +108,11 @@ async function installOrcadBundle(
|
||||
fullVersion: string,
|
||||
remoteDir: string
|
||||
): Promise<void> {
|
||||
if (await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, {
|
||||
signal: options.signal
|
||||
})) {
|
||||
if (
|
||||
await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, {
|
||||
signal: options.signal
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
await acquireInstallLock(options.conn, remoteDir, options.host, { signal: options.signal })
|
||||
@@ -151,7 +150,12 @@ async function captureSnapshot(
|
||||
takenAt: Date
|
||||
): Promise<OrcadStateSnapshot | null> {
|
||||
const dirName = orcadSnapshotDirName(fullVersion, takenAt.getTime())
|
||||
const snapshotDir = joinRemotePath(options.host, baseDir(options), ORCAD_STATE_SNAPSHOT_DIR, dirName)
|
||||
const snapshotDir = joinRemotePath(
|
||||
options.host,
|
||||
baseDir(options),
|
||||
ORCAD_STATE_SNAPSHOT_DIR,
|
||||
dirName
|
||||
)
|
||||
const capture = parseOrcadSnapshotCapture(
|
||||
await exec(
|
||||
options,
|
||||
@@ -287,9 +291,12 @@ export async function deployOrcad(options: OrcadDeployOptions): Promise<OrcadDep
|
||||
record.active
|
||||
)
|
||||
const stopped = parseOrcadStopOutcome(
|
||||
await exec(options, stopOrcadCommand(options.host, outgoingDir, {
|
||||
waitSeconds: STOP_WAIT_SECONDS
|
||||
}))
|
||||
await exec(
|
||||
options,
|
||||
stopOrcadCommand(options.host, outgoingDir, {
|
||||
waitSeconds: STOP_WAIT_SECONDS
|
||||
})
|
||||
)
|
||||
)
|
||||
if (!orcadStopFreedTheHost(stopped)) {
|
||||
return {
|
||||
|
||||
@@ -78,7 +78,9 @@ describe('orcad GC', () => {
|
||||
currentDirAbsPath: '/home/u/.orca-remote/orcad-0.2.0+bb',
|
||||
record: emptyOrcadActivationRecord()
|
||||
})
|
||||
const listCommand = mockExec.mock.calls.map((call) => String(call[1])).find((c) => c.includes('find'))
|
||||
const listCommand = mockExec.mock.calls
|
||||
.map((call) => String(call[1]))
|
||||
.find((c) => c.includes('find'))
|
||||
expect(listCommand).toContain("-name 'orcad-*'")
|
||||
expect(listCommand).not.toContain("-name 'relay-*'")
|
||||
})
|
||||
|
||||
@@ -25,10 +25,7 @@ import type { ServeReadiness } from '../server/serve-readiness'
|
||||
export const ORCAD_READINESS_FILENAME = '.orcad-readiness'
|
||||
/** Stderr, including the bind-exposure line and every supervision message. */
|
||||
export const ORCAD_LOG_FILENAME = 'orcad.log'
|
||||
export {
|
||||
ORCAD_PID_FILENAME,
|
||||
OrcadRemoteLaunchUnsupportedError
|
||||
} from './orcad-remote-host-support'
|
||||
export { ORCAD_PID_FILENAME, OrcadRemoteLaunchUnsupportedError } from './orcad-remote-host-support'
|
||||
|
||||
export type OrcadLaunchSpec = {
|
||||
remoteInstallDir: string
|
||||
@@ -51,7 +48,9 @@ export type OrcadLaunchSpec = {
|
||||
export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSpec): string {
|
||||
assertPosixHost(host)
|
||||
const dir = shellEscape(spec.remoteInstallDir)
|
||||
const readiness = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_READINESS_FILENAME))
|
||||
const readiness = shellEscape(
|
||||
joinRemotePath(host, spec.remoteInstallDir, ORCAD_READINESS_FILENAME)
|
||||
)
|
||||
const log = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_LOG_FILENAME))
|
||||
const pidFile = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_PID_FILENAME))
|
||||
const entry = shellEscape(joinRemotePath(host, spec.remoteInstallDir, 'orcad.js'))
|
||||
|
||||
@@ -13,10 +13,7 @@ vi.mock('./ssh-relay-install-transfers', () => ({
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import { writeRelayFile } from './ssh-relay-install-transfers'
|
||||
import { rollbackOrcad, type OrcadRollbackOptions } from './orcad-remote-rollback'
|
||||
import {
|
||||
emptyOrcadActivationRecord,
|
||||
type OrcadActivationRecord
|
||||
} from './orcad-activation-record'
|
||||
import { emptyOrcadActivationRecord, type OrcadActivationRecord } from './orcad-activation-record'
|
||||
import { getRemoteHostPlatform } from './ssh-remote-platform'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
|
||||
|
||||
@@ -109,9 +109,7 @@ async function readStateWritesSinceActivation(
|
||||
return newest === null ? null : newest >= activatedAtSeconds
|
||||
}
|
||||
|
||||
export async function rollbackOrcad(
|
||||
options: OrcadRollbackOptions
|
||||
): Promise<OrcadRollbackResult> {
|
||||
export async function rollbackOrcad(options: OrcadRollbackOptions): Promise<OrcadRollbackResult> {
|
||||
const now = options.now ?? ((): Date => new Date())
|
||||
const snapshotPresent = options.record.snapshot
|
||||
? (
|
||||
@@ -183,11 +181,7 @@ export async function rollbackOrcad(
|
||||
}
|
||||
}
|
||||
|
||||
const targetDir = computeRemoteInstallDir(
|
||||
ORCAD_INSTALL_MODEL,
|
||||
options.remoteHome,
|
||||
safety.target
|
||||
)
|
||||
const targetDir = computeRemoteInstallDir(ORCAD_INSTALL_MODEL, options.remoteHome, safety.target)
|
||||
await exec(
|
||||
options,
|
||||
orcadLaunchCommand(options.host, {
|
||||
|
||||
@@ -143,9 +143,9 @@ describe('liveness and stop commands, run for real', () => {
|
||||
const exited = new Promise<NodeJS.Signals | null>((resolve) =>
|
||||
child.once('exit', (_code, signal) => resolve(signal))
|
||||
)
|
||||
expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10 })))).toBe(
|
||||
'stopped'
|
||||
)
|
||||
expect(
|
||||
parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10 })))
|
||||
).toBe('stopped')
|
||||
expect(await exited).toBe('SIGTERM')
|
||||
expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD')
|
||||
} finally {
|
||||
|
||||
@@ -211,7 +211,9 @@ async function isCandidateSafeToRemove(
|
||||
if (!(await isRelayInstallLockStale(conn, lockDir, host))) {
|
||||
return false
|
||||
}
|
||||
process.stderr.write?.(`[${model.id}] GC: lock at ${lockDir} is stale; treating as recoverable\n`)
|
||||
process.stderr.write?.(
|
||||
`[${model.id}] GC: lock at ${lockDir} is stale; treating as recoverable\n`
|
||||
)
|
||||
}
|
||||
|
||||
// Legacy dirs predate .install-complete; skip the sentinel and rely on the live-socket probe alone.
|
||||
@@ -231,7 +233,6 @@ async function isCandidateSafeToRemove(
|
||||
return !(await options.isDirLive(dir))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The relay's GC, bound to its own namespace and its own liveness probe (a live unix socket
|
||||
* or Windows pipe inside the version dir).
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { replayPendingSshPtyKills } from './ssh-pending-pty-kill-replay'
|
||||
import { SshPtyAbsentFromRelayError } from '../providers/ssh-pty-errors'
|
||||
import type { SshPendingPtyKillEntry } from '../../shared/ssh-pending-pty-kill'
|
||||
import {
|
||||
isSshPendingPtyKillExpired,
|
||||
prunePendingSshPtyKills,
|
||||
SSH_PENDING_PTY_KILL_TTL_MS
|
||||
} from '../../shared/ssh-pending-pty-kill'
|
||||
import type { Store } from '../persistence'
|
||||
import type { IPtyProvider } from '../providers/types'
|
||||
|
||||
const TARGET = 'ssh-1'
|
||||
const NOW = 1_800_000_000_000
|
||||
|
||||
type HostPty = { relayPtyId: string; incarnationId?: string }
|
||||
|
||||
/** Models the real store rather than returning a fixed list: `getSshRemotePtyKillIntents` applies
|
||||
* the same TTL filter production does, and the prune actually deletes. A stub that ignored `now`
|
||||
* would make every TTL assertion below pass for the wrong reason. */
|
||||
function createStoreStub(entries: SshPendingPtyKillEntry[]): {
|
||||
store: Store
|
||||
cleared: string[]
|
||||
terminated: string[]
|
||||
expired: string[]
|
||||
attempts: string[]
|
||||
remaining: () => string[]
|
||||
} {
|
||||
let backing = [...entries]
|
||||
const cleared: string[] = []
|
||||
const terminated: string[] = []
|
||||
const expired: string[] = []
|
||||
const attempts: string[] = []
|
||||
const store = {
|
||||
getSshRemotePtyKillIntents: vi.fn((_target: string, now: number) =>
|
||||
prunePendingSshPtyKills(backing, now)
|
||||
),
|
||||
pruneExpiredSshRemotePtyKillIntents: vi.fn((_target: string, now: number) => {
|
||||
backing = backing.filter((item) => !isSshPendingPtyKillExpired(item.intent, now))
|
||||
}),
|
||||
clearSshRemotePtyKillIntent: vi.fn((_target: string, ptyId: string) => {
|
||||
backing = backing.filter((item) => item.ptyId !== ptyId)
|
||||
cleared.push(ptyId)
|
||||
}),
|
||||
markSshRemotePtyLease: vi.fn((_target: string, ptyId: string, state: string) => {
|
||||
if (state === 'terminated') {
|
||||
terminated.push(ptyId)
|
||||
} else if (state === 'expired') {
|
||||
expired.push(ptyId)
|
||||
}
|
||||
}),
|
||||
noteSshRemotePtyKillReplayAttempt: vi.fn((_target: string, ptyId: string) => {
|
||||
attempts.push(ptyId)
|
||||
})
|
||||
} as unknown as Store
|
||||
return {
|
||||
store,
|
||||
cleared,
|
||||
terminated,
|
||||
expired,
|
||||
attempts,
|
||||
remaining: () => backing.map((item) => item.ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
/** A relay that lists `hostPtys`, and drops an id from that listing once it is shut down. */
|
||||
function createProviderStub(hostPtys: HostPty[], overrides: Partial<IPtyProvider> = {}) {
|
||||
const live = new Map(hostPtys.map((pty) => [pty.relayPtyId, pty.incarnationId]))
|
||||
const shutdown = vi.fn(async (appPtyId: string) => {
|
||||
live.delete(appPtyId.replace(`ssh:${TARGET}@@`, ''))
|
||||
})
|
||||
const listProcesses = vi.fn(async () =>
|
||||
Array.from(live, ([relayPtyId, incarnationId]) => ({
|
||||
id: `ssh:${TARGET}@@${relayPtyId}`,
|
||||
...(incarnationId ? { incarnationId } : {})
|
||||
}))
|
||||
)
|
||||
return {
|
||||
provider: { listProcesses, shutdown, ...overrides } as unknown as IPtyProvider,
|
||||
listProcesses,
|
||||
shutdown
|
||||
}
|
||||
}
|
||||
|
||||
function entry(relayPtyId: string, incarnationId: string, requestedAt = NOW) {
|
||||
return { ptyId: relayPtyId, intent: { requestedAt, incarnationId, attempts: 0 } }
|
||||
}
|
||||
|
||||
describe('replayPendingSshPtyKills', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('does not talk to the host when nothing is pending', async () => {
|
||||
const { store } = createStoreStub([])
|
||||
const { provider, listProcesses } = createProviderStub([])
|
||||
await replayPendingSshPtyKills({
|
||||
targetId: TARGET,
|
||||
store,
|
||||
provider,
|
||||
shouldContinue: () => true,
|
||||
now: () => NOW
|
||||
})
|
||||
expect(listProcesses).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// This is the leak: yesterday nothing retried, and the shell stayed up on the user's box.
|
||||
it('replays the undelivered stop against the same PTY the kill was aimed at', async () => {
|
||||
const { store, cleared, terminated } = createStoreStub([entry('pty-1', 'inc-a')])
|
||||
const { provider, shutdown } = createProviderStub([
|
||||
{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }
|
||||
])
|
||||
await replayPendingSshPtyKills({
|
||||
targetId: TARGET,
|
||||
store,
|
||||
provider,
|
||||
shouldContinue: () => true,
|
||||
now: () => NOW
|
||||
})
|
||||
expect(shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-1', { immediate: true })
|
||||
expect(cleared).toEqual(['pty-1'])
|
||||
expect(terminated).toEqual(['pty-1'])
|
||||
})
|
||||
|
||||
// #16970: a redeployed relay renumbers from pty-1, so this id now names someone else's shell.
|
||||
it('refuses to kill a recycled relay id and expires the lease that named it', async () => {
|
||||
const { store, cleared, terminated, expired } = createStoreStub([entry('pty-1', 'inc-a')])
|
||||
const { provider, shutdown } = createProviderStub([
|
||||
{ relayPtyId: 'pty-1', incarnationId: 'inc-fresh' }
|
||||
])
|
||||
await replayPendingSshPtyKills({
|
||||
targetId: TARGET,
|
||||
store,
|
||||
provider,
|
||||
shouldContinue: () => true,
|
||||
now: () => NOW
|
||||
})
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
expect(cleared).toEqual(['pty-1'])
|
||||
// Declining to kill is only half of it: the reattach one step later fences on paneKey/tabId and
|
||||
// never on incarnation, so an untouched lease would bind the user's old pane to that stranger.
|
||||
expect(expired).toEqual(['pty-1'])
|
||||
// `expired`, not `terminated` — losing the route is not evidence the shell died.
|
||||
expect(terminated).toEqual([])
|
||||
})
|
||||
|
||||
it('retires on the host reporting the PTY absent, with the tombstone that earns', async () => {
|
||||
const { store, cleared, terminated } = createStoreStub([entry('pty-1', 'inc-a')])
|
||||
const { provider, shutdown } = createProviderStub([
|
||||
{ relayPtyId: 'pty-2', incarnationId: 'inc-b' }
|
||||
])
|
||||
await replayPendingSshPtyKills({
|
||||
targetId: TARGET,
|
||||
store,
|
||||
provider,
|
||||
shouldContinue: () => true,
|
||||
now: () => NOW
|
||||
})
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
expect(cleared).toEqual(['pty-1'])
|
||||
expect(terminated).toEqual(['pty-1'])
|
||||
})
|
||||
|
||||
// The TTL is owned by the durable prune, not by a branch in the decision function. This asserts
|
||||
// the order is actually deleted and never aimed at whatever holds the id now — and it can only
|
||||
// pass if the prune honours `now`, because the store stub applies the real TTL rules.
|
||||
it('deletes an order past its TTL instead of replaying it, and never touches the host', async () => {
|
||||
const { store, cleared, terminated, expired, remaining } = createStoreStub([
|
||||
entry('pty-1', 'inc-a')
|
||||
])
|
||||
const { provider, shutdown, listProcesses } = createProviderStub([
|
||||
{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }
|
||||
])
|
||||
await replayPendingSshPtyKills({
|
||||
targetId: TARGET,
|
||||
store,
|
||||
provider,
|
||||
shouldContinue: () => true,
|
||||
now: () => NOW + SSH_PENDING_PTY_KILL_TTL_MS + 1
|
||||
})
|
||||
expect(remaining()).toEqual([])
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
expect(listProcesses).not.toHaveBeenCalled()
|
||||
// Ageing out observes nothing, so it may not move the lease either way.
|
||||
expect(cleared).toEqual([])
|
||||
expect(terminated).toEqual([])
|
||||
expect(expired).toEqual([])
|
||||
})
|
||||
|
||||
// #16763's lesson: `isPtyAlreadyGoneError` matches message text such as /Session not found/i,
|
||||
// which a transport failure could wear. A tombstone must never rest on that — only on a listing.
|
||||
it('does not tombstone on a shutdown error that merely looks like absence', async () => {
|
||||
const { store, cleared, terminated, remaining } = createStoreStub([entry('pty-1', 'inc-a')])
|
||||
const { provider } = createProviderStub([{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }], {
|
||||
shutdown: vi.fn(async () => {
|
||||
throw new SshPtyAbsentFromRelayError('SSH_SESSION_EXPIRED: pty-1')
|
||||
})
|
||||
})
|
||||
await replayPendingSshPtyKills({
|
||||
targetId: TARGET,
|
||||
store,
|
||||
provider,
|
||||
shouldContinue: () => true,
|
||||
now: () => NOW
|
||||
})
|
||||
expect(cleared).toEqual([])
|
||||
expect(terminated).toEqual([])
|
||||
expect(remaining()).toEqual(['pty-1'])
|
||||
})
|
||||
|
||||
// Best-effort background work on the connect path: a disk hiccup must not fail the connection.
|
||||
it('never rejects into its caller when persistence throws', async () => {
|
||||
const { store } = createStoreStub([entry('pty-1', 'inc-a')])
|
||||
vi.mocked(store.noteSshRemotePtyKillReplayAttempt).mockImplementation(() => {
|
||||
throw new Error('disk full')
|
||||
})
|
||||
const { provider } = createProviderStub([{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }])
|
||||
await expect(
|
||||
replayPendingSshPtyKills({
|
||||
targetId: TARGET,
|
||||
store,
|
||||
provider,
|
||||
shouldContinue: () => true,
|
||||
now: () => NOW
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('never rejects into its caller when the very first store read throws', async () => {
|
||||
const { store } = createStoreStub([entry('pty-1', 'inc-a')])
|
||||
vi.mocked(store.pruneExpiredSshRemotePtyKillIntents).mockImplementation(() => {
|
||||
throw new Error('disk full')
|
||||
})
|
||||
const { provider } = createProviderStub([{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }])
|
||||
await expect(
|
||||
replayPendingSshPtyKills({
|
||||
targetId: TARGET,
|
||||
store,
|
||||
provider,
|
||||
shouldContinue: () => true,
|
||||
now: () => NOW
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the order when the replayed stop is itself unverifiable', async () => {
|
||||
const { store, cleared, terminated, attempts } = createStoreStub([entry('pty-1', 'inc-a')])
|
||||
const { provider } = createProviderStub([{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }], {
|
||||
shutdown: vi.fn(async () => {
|
||||
throw new Error('socket closed')
|
||||
})
|
||||
})
|
||||
await replayPendingSshPtyKills({
|
||||
targetId: TARGET,
|
||||
store,
|
||||
provider,
|
||||
shouldContinue: () => true,
|
||||
now: () => NOW
|
||||
})
|
||||
expect(attempts).toEqual(['pty-1'])
|
||||
expect(cleared).toEqual([])
|
||||
expect(terminated).toEqual([])
|
||||
})
|
||||
|
||||
// `pty.shutdown` returns the same empty success for a PTY the relay never had, so only a fresh
|
||||
// inventory that omits the id is a death certificate.
|
||||
it('keeps the order when the host still lists the PTY after a resolved shutdown', async () => {
|
||||
const { store, cleared, terminated } = createStoreStub([entry('pty-1', 'inc-a')])
|
||||
const { provider } = createProviderStub([{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }], {
|
||||
shutdown: vi.fn(async () => {})
|
||||
})
|
||||
await replayPendingSshPtyKills({
|
||||
targetId: TARGET,
|
||||
store,
|
||||
provider,
|
||||
shouldContinue: () => true,
|
||||
now: () => NOW
|
||||
})
|
||||
expect(cleared).toEqual([])
|
||||
expect(terminated).toEqual([])
|
||||
})
|
||||
|
||||
// Wire skew: a host predating the published PTY incarnation cannot answer the fence.
|
||||
it('defers against a host that publishes no PTY incarnation', async () => {
|
||||
const { store, cleared, terminated } = createStoreStub([entry('pty-1', 'inc-a')])
|
||||
const { provider, shutdown } = createProviderStub([{ relayPtyId: 'pty-1' }])
|
||||
await replayPendingSshPtyKills({
|
||||
targetId: TARGET,
|
||||
store,
|
||||
provider,
|
||||
shouldContinue: () => true,
|
||||
now: () => NOW
|
||||
})
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
expect(cleared).toEqual([])
|
||||
expect(terminated).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps every order when the inventory itself fails', async () => {
|
||||
const { store, cleared, terminated } = createStoreStub([
|
||||
entry('pty-1', 'inc-a'),
|
||||
entry('pty-2', 'inc-b')
|
||||
])
|
||||
const { provider } = createProviderStub([], {
|
||||
listProcesses: vi.fn(async () => {
|
||||
throw new Error('relay unreachable')
|
||||
})
|
||||
})
|
||||
await replayPendingSshPtyKills({
|
||||
targetId: TARGET,
|
||||
store,
|
||||
provider,
|
||||
shouldContinue: () => true,
|
||||
now: () => NOW
|
||||
})
|
||||
expect(cleared).toEqual([])
|
||||
expect(terminated).toEqual([])
|
||||
})
|
||||
|
||||
it('delivers nothing once the connection attempt is superseded', async () => {
|
||||
const { store, cleared } = createStoreStub([entry('pty-1', 'inc-a'), entry('pty-2', 'inc-b')])
|
||||
const { provider, shutdown } = createProviderStub([
|
||||
{ relayPtyId: 'pty-1', incarnationId: 'inc-a' },
|
||||
{ relayPtyId: 'pty-2', incarnationId: 'inc-b' }
|
||||
])
|
||||
await replayPendingSshPtyKills({
|
||||
targetId: TARGET,
|
||||
store,
|
||||
provider,
|
||||
shouldContinue: () => false,
|
||||
now: () => NOW
|
||||
})
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
expect(cleared).toEqual([])
|
||||
})
|
||||
|
||||
// Bounded by wave, not by order: one inventory per wave of 4 plus one to prove the batch. The
|
||||
// per-wave read is what keeps a stop's identity evidence within one round trip of the stop.
|
||||
it('reads one inventory per wave plus one to confirm, not two per pending stop', async () => {
|
||||
const pending = Array.from({ length: 12 }, (_, index) => entry(`pty-${index}`, `inc-${index}`))
|
||||
const { store, terminated } = createStoreStub(pending)
|
||||
const { provider, listProcesses, shutdown } = createProviderStub(
|
||||
pending.map((item) => ({ relayPtyId: item.ptyId, incarnationId: item.intent.incarnationId }))
|
||||
)
|
||||
await replayPendingSshPtyKills({
|
||||
targetId: TARGET,
|
||||
store,
|
||||
provider,
|
||||
shouldContinue: () => true,
|
||||
now: () => NOW
|
||||
})
|
||||
expect(shutdown).toHaveBeenCalledTimes(12)
|
||||
expect(listProcesses).toHaveBeenCalledTimes(12 / 4 + 1)
|
||||
expect(terminated).toHaveLength(12)
|
||||
})
|
||||
|
||||
it('costs one inventory and one confirmation for a single pending stop', async () => {
|
||||
const { store } = createStoreStub([entry('pty-1', 'inc-a')])
|
||||
const { provider, listProcesses } = createProviderStub([
|
||||
{ relayPtyId: 'pty-1', incarnationId: 'inc-a' }
|
||||
])
|
||||
await replayPendingSshPtyKills({
|
||||
targetId: TARGET,
|
||||
store,
|
||||
provider,
|
||||
shouldContinue: () => true,
|
||||
now: () => NOW
|
||||
})
|
||||
expect(listProcesses).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { Store } from '../persistence'
|
||||
import type { IPtyProvider } from '../providers/types'
|
||||
import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
|
||||
import {
|
||||
decideSshPendingPtyKill,
|
||||
type SshPendingPtyKillEntry,
|
||||
type SshPendingPtyKillObservation,
|
||||
type SshPendingPtyKillRetirement
|
||||
} from '../../shared/ssh-pending-pty-kill'
|
||||
|
||||
export type SshPendingPtyKillReplayArgs = {
|
||||
targetId: string
|
||||
store: Store
|
||||
provider: IPtyProvider
|
||||
shouldContinue: () => boolean
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
/** Stops issued per inventory read. Every stop in a wave is dispatched concurrently, immediately
|
||||
* after the read that fenced it, so no kill is ever aimed with evidence older than one round trip. */
|
||||
const REPLAY_WAVE_SIZE = 4
|
||||
|
||||
type HostInventory = Map<string, string | undefined>
|
||||
|
||||
/** Relay pty id -> the incarnation the host published for it, `undefined` when it published none. */
|
||||
async function readHostInventory(args: SshPendingPtyKillReplayArgs): Promise<HostInventory> {
|
||||
const processes = await args.provider.listProcesses()
|
||||
const inventory: HostInventory = new Map()
|
||||
for (const process of processes) {
|
||||
inventory.set(toRelaySshPtyId(args.targetId, process.id), process.incarnationId)
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
|
||||
/** Retires one order.
|
||||
*
|
||||
* Both tombstoning reasons are derived from a successful `pty.listProcesses`, never from an error
|
||||
* predicate: `isPtyAlreadyGoneError` matches on message text (`/Session not found/i`), which a
|
||||
* transport failure could wear, and a tombstone written on that would bury a live remote shell.
|
||||
* A recycled id gets `expired`, not `terminated` — the client has lost its route to that lease,
|
||||
* which is all that was observed; nothing says the shell it named ever died. */
|
||||
function retire(
|
||||
args: SshPendingPtyKillReplayArgs,
|
||||
relayPtyId: string,
|
||||
reason: SshPendingPtyKillRetirement
|
||||
): void {
|
||||
args.store.clearSshRemotePtyKillIntent(args.targetId, relayPtyId)
|
||||
if (reason === 'host-reports-absent' || reason === 'stop-confirmed') {
|
||||
args.store.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated')
|
||||
} else if (reason === 'relay-id-recycled') {
|
||||
// Why this must happen: the reattach one step later filters on lease state and fences only on
|
||||
// paneKey/tabId, never on incarnation. Leaving this lease active hands the user's old pane to
|
||||
// whatever process now holds the recycled id.
|
||||
args.store.markSshRemotePtyLease(args.targetId, relayPtyId, 'expired')
|
||||
}
|
||||
console.log(
|
||||
`[ssh-pending-kill] retired stop for ${args.targetId}/${relayPtyId} (${reason.replace(/-/g, ' ')})`
|
||||
)
|
||||
}
|
||||
|
||||
function observe(inventory: HostInventory, relayPtyId: string): SshPendingPtyKillObservation {
|
||||
return {
|
||||
hostListsPty: inventory.has(relayPtyId),
|
||||
hostIncarnationId: inventory.get(relayPtyId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies every recorded order against one inventory, and returns the ones it says are safe to
|
||||
* stop. Retirement side effects happen here, against that same fresh evidence. */
|
||||
function selectReplayTargets(
|
||||
args: SshPendingPtyKillReplayArgs,
|
||||
inventory: HostInventory,
|
||||
now: number,
|
||||
/** Orders already dispatched this pass. Skipped whole: the shutdown they were issued is what
|
||||
* removes them from the next inventory, so re-deciding would retire them a second time. */
|
||||
attempted: ReadonlySet<string>
|
||||
): SshPendingPtyKillEntry[] {
|
||||
const replayable: SshPendingPtyKillEntry[] = []
|
||||
for (const entry of args.store.getSshRemotePtyKillIntents(args.targetId, now)) {
|
||||
if (attempted.has(entry.ptyId)) {
|
||||
continue
|
||||
}
|
||||
const decision = decideSshPendingPtyKill(entry.intent, observe(inventory, entry.ptyId), now)
|
||||
if (decision.action === 'retire') {
|
||||
retire(args, entry.ptyId, decision.reason)
|
||||
} else if (decision.action === 'defer') {
|
||||
console.warn(
|
||||
`[ssh-pending-kill] deferring stop for ${args.targetId}/${entry.ptyId}: ${decision.reason}`
|
||||
)
|
||||
} else {
|
||||
replayable.push(entry)
|
||||
}
|
||||
}
|
||||
return replayable
|
||||
}
|
||||
|
||||
/** Issues one recorded stop. Returns false when it did not reach the host, in which case the order
|
||||
* stays: a rejected RPC observes nothing, and the next handshake's inventory will answer.
|
||||
*
|
||||
* Re-checks the fence here rather than trusting the selection pass, so the identity proof and the
|
||||
* irreversible call sit next to each other and cannot drift apart if this loop is ever reshaped.
|
||||
* It still cannot be made atomic — `pty.shutdown` carries no incarnation, so only the host could
|
||||
* refuse a stale kill. See the residual risk note in the PR. */
|
||||
async function deliverReplay(
|
||||
args: SshPendingPtyKillReplayArgs,
|
||||
entry: SshPendingPtyKillEntry,
|
||||
inventory: HostInventory,
|
||||
now: number
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
decideSshPendingPtyKill(entry.intent, observe(inventory, entry.ptyId), now).action !== 'replay'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
args.store.noteSshRemotePtyKillReplayAttempt(args.targetId, entry.ptyId)
|
||||
try {
|
||||
await args.provider.shutdown(toAppSshPtyId(args.targetId, entry.ptyId), { immediate: true })
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[ssh-pending-kill] replay for ${args.targetId}/${entry.ptyId} is unverifiable: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Confirms delivered stops against one fresh inventory.
|
||||
*
|
||||
* Why re-list at all: the relay answers `pty.shutdown` for a PTY it never had with the same empty
|
||||
* success, so a resolved RPC alone is not a death certificate. Absence from a live listing is. */
|
||||
async function confirmDelivered(
|
||||
args: SshPendingPtyKillReplayArgs,
|
||||
awaitingProof: readonly string[]
|
||||
): Promise<void> {
|
||||
if (awaitingProof.length === 0) {
|
||||
return
|
||||
}
|
||||
const inventory = await readHostInventory(args)
|
||||
for (const relayPtyId of awaitingProof) {
|
||||
if (!inventory.has(relayPtyId)) {
|
||||
retire(args, relayPtyId, 'stop-confirmed')
|
||||
} else {
|
||||
console.warn(
|
||||
`[ssh-pending-kill] ${args.targetId}/${relayPtyId} is still live after a replayed stop`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Replays every stop this client could not deliver to `targetId`, now that it is reachable again.
|
||||
*
|
||||
* Runs before reattach so a PTY that dies here is never re-adopted as a live pane. Costs nothing
|
||||
* when nothing is pending. When something is, it re-reads the inventory once per wave rather than
|
||||
* once per batch: the fence and the stops it authorises are then never more than one round trip
|
||||
* apart, which is as tight as this can get while `pty.shutdown` carries no incarnation of its own.
|
||||
*
|
||||
* Never throws. It is best-effort work on the connect path, and `establish()` treats a throw here
|
||||
* as a failed connection. */
|
||||
export async function replayPendingSshPtyKills(args: SshPendingPtyKillReplayArgs): Promise<void> {
|
||||
const now = args.now ?? Date.now
|
||||
try {
|
||||
// Inside the guard with everything else: these touch persistence, and a disk hiccup must not
|
||||
// turn best-effort cleanup into a failed SSH connection.
|
||||
args.store.pruneExpiredSshRemotePtyKillIntents(args.targetId, now())
|
||||
if (args.store.getSshRemotePtyKillIntents(args.targetId, now()).length === 0) {
|
||||
return
|
||||
}
|
||||
const attempted = new Set<string>()
|
||||
const awaitingProof: string[] = []
|
||||
while (args.shouldContinue()) {
|
||||
// No inventory means no fence, and an unfenced replay can kill a shell nobody asked to close.
|
||||
const inventory = await readHostInventory(args)
|
||||
const selected = selectReplayTargets(args, inventory, now(), attempted)
|
||||
const wave = selected.slice(0, REPLAY_WAVE_SIZE)
|
||||
if (wave.length === 0) {
|
||||
break
|
||||
}
|
||||
for (const entry of wave) {
|
||||
attempted.add(entry.ptyId)
|
||||
}
|
||||
const delivered = await Promise.all(
|
||||
wave.map(async (entry) =>
|
||||
(await deliverReplay(args, entry, inventory, now())) ? entry.ptyId : null
|
||||
)
|
||||
)
|
||||
for (const relayPtyId of delivered) {
|
||||
if (relayPtyId !== null) {
|
||||
awaitingProof.push(relayPtyId)
|
||||
}
|
||||
}
|
||||
if (selected.length <= REPLAY_WAVE_SIZE) {
|
||||
// This wave took everything the inventory offered, so another read would only confirm that.
|
||||
break
|
||||
}
|
||||
}
|
||||
if (args.shouldContinue()) {
|
||||
await confirmDelivered(args, awaitingProof)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[ssh-pending-kill] replay pass on ${args.targetId} stopped early; stops stay pending: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,12 @@ function createSession(targetId: string): InstanceType<typeof SshRelaySession> {
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
markSshRemotePtyLeases: vi.fn(),
|
||||
markSshRemotePtyLeasesAsync: vi.fn(),
|
||||
markSshRemotePtyLeasesAttachedAsync: vi.fn()
|
||||
markSshRemotePtyLeasesAttachedAsync: vi.fn(),
|
||||
getSshRemotePtyKillIntents: vi.fn().mockReturnValue([]),
|
||||
pruneExpiredSshRemotePtyKillIntents: vi.fn(),
|
||||
recordSshRemotePtyKillIntent: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn(),
|
||||
noteSshRemotePtyKillReplayAttempt: vi.fn()
|
||||
} as unknown as Store
|
||||
const portForwardManager = {
|
||||
removeAllForwards: vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { SshRelaySession } from './ssh-relay-session'
|
||||
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
|
||||
|
||||
const { muxRequestMock, openConsumerSessionMock, listProcessesMock, shutdownMock } = vi.hoisted(
|
||||
() => ({
|
||||
muxRequestMock: vi.fn(),
|
||||
openConsumerSessionMock: vi.fn(),
|
||||
listProcessesMock: vi.fn(),
|
||||
shutdownMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() }))
|
||||
vi.mock('./ssh-pty-consumer-session', () => ({
|
||||
openSshPtyConsumerSession: openConsumerSessionMock
|
||||
}))
|
||||
vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({
|
||||
acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined),
|
||||
acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined),
|
||||
allocateSshPtyProviderGeneration: vi.fn(() => 41),
|
||||
beginSshPtyOutputGenerationMigration: vi.fn(() => ({
|
||||
byPty: new Map(),
|
||||
completion: Promise.resolve()
|
||||
})),
|
||||
closeSshPtyOutputGeneration: vi.fn(),
|
||||
getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []),
|
||||
applySshPtySourceCancellationProof: vi.fn(() => true),
|
||||
applySshPtySourceRecoveryCancellationProof: vi.fn(() => true),
|
||||
installSshPtySourceAckPublisher: vi.fn(() => () => {}),
|
||||
installSshPtySourceCancellationPublisher: vi.fn(() => () => {})
|
||||
}))
|
||||
vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') }))
|
||||
vi.mock('./ssh-channel-multiplexer', () => ({
|
||||
SshChannelMultiplexer: class MockSshChannelMultiplexer {
|
||||
notify = vi.fn()
|
||||
notifyWithSettlement = vi.fn()
|
||||
request = muxRequestMock
|
||||
onNotification = vi.fn().mockReturnValue(() => {})
|
||||
onNotificationByMethod = vi.fn().mockReturnValue(() => {})
|
||||
onRequest = vi.fn().mockReturnValue(() => {})
|
||||
onDispose = vi.fn().mockReturnValue(() => {})
|
||||
dispose = vi.fn()
|
||||
isDisposed = vi.fn().mockReturnValue(false)
|
||||
}
|
||||
}))
|
||||
vi.mock('../providers/ssh-pty-provider', () => ({
|
||||
SshPtyProvider: class MockSshPtyProvider {
|
||||
onData = vi.fn().mockReturnValue(() => {})
|
||||
onReplay = vi.fn().mockReturnValue(() => {})
|
||||
onExit = vi.fn().mockReturnValue(() => {})
|
||||
attach = vi.fn().mockResolvedValue(undefined)
|
||||
attachForReconnect = vi.fn().mockResolvedValue({})
|
||||
setPtyDeliveryPauseAdapter = vi.fn()
|
||||
dispose = vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('../providers/ssh-filesystem-provider', () => ({
|
||||
SshFilesystemProvider: class MockSshFilesystemProvider {
|
||||
dispose = vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('../providers/ssh-git-provider', () => ({ SshGitProvider: class MockSshGitProvider {} }))
|
||||
vi.mock('../ipc/pty', () => ({
|
||||
registerSshPtyProvider: vi.fn(),
|
||||
unregisterSshPtyProvider: vi.fn(),
|
||||
getSshPtyProvider: vi.fn().mockReturnValue({
|
||||
dispose: vi.fn(),
|
||||
attach: vi.fn().mockResolvedValue(undefined),
|
||||
attachForReconnect: vi.fn().mockResolvedValue({}),
|
||||
listProcesses: listProcessesMock,
|
||||
shutdown: shutdownMock
|
||||
}),
|
||||
getPtyIdsForConnection: vi.fn().mockReturnValue([]),
|
||||
clearPtyOwnershipForConnection: vi.fn(),
|
||||
clearProviderPtyState: vi.fn(),
|
||||
deletePtyOwnership: vi.fn(),
|
||||
setPtyOwnership: vi.fn(),
|
||||
restorePtyIncarnation: vi.fn(),
|
||||
isCurrentPtyExit: vi.fn(() => true)
|
||||
}))
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
registerSshFilesystemProvider: vi.fn(),
|
||||
unregisterSshFilesystemProvider: vi.fn(),
|
||||
getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() })
|
||||
}))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
registerSshGitProvider: vi.fn(),
|
||||
unregisterSshGitProvider: vi.fn()
|
||||
}))
|
||||
|
||||
const NOW = 1_800_000_000_000
|
||||
|
||||
describe('SshRelaySession pending PTY kill replay', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
openConsumerSessionMock.mockImplementation(async (_mux, options) => ({
|
||||
mode: 'legacy-fallback',
|
||||
clientInstanceId: options.clientInstanceId,
|
||||
serverBuildId: 'test-relay-build'
|
||||
}))
|
||||
muxRequestMock.mockReset()
|
||||
muxRequestMock.mockResolvedValue([])
|
||||
mockDeploySuccess()
|
||||
})
|
||||
|
||||
it('replays a stop the client could not deliver, before reattach reads the leases', async () => {
|
||||
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
|
||||
const order: string[] = []
|
||||
vi.mocked(mockStore.getSshRemotePtyKillIntents).mockReturnValue([
|
||||
{ ptyId: 'pty-3', intent: { requestedAt: NOW, incarnationId: 'inc-a', attempts: 0 } }
|
||||
])
|
||||
vi.mocked(mockStore.getSshRemotePtyLeases).mockImplementation(() => {
|
||||
order.push('read-leases')
|
||||
return []
|
||||
})
|
||||
let live = true
|
||||
listProcessesMock.mockImplementation(async () =>
|
||||
live ? [{ id: 'ssh:target-1@@pty-3', incarnationId: 'inc-a' }] : []
|
||||
)
|
||||
shutdownMock.mockImplementation(async (id: string) => {
|
||||
order.push(`shutdown:${id}`)
|
||||
live = false
|
||||
})
|
||||
|
||||
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
|
||||
await session.establish(mockConn)
|
||||
|
||||
expect(shutdownMock).toHaveBeenCalledWith('ssh:target-1@@pty-3', { immediate: true })
|
||||
expect(order).toEqual(['shutdown:ssh:target-1@@pty-3', 'read-leases'])
|
||||
expect(mockStore.clearSshRemotePtyKillIntent).toHaveBeenCalledWith('target-1', 'pty-3')
|
||||
expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-3', 'terminated')
|
||||
})
|
||||
|
||||
it('costs no host round trip when nothing is pending', async () => {
|
||||
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
|
||||
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
|
||||
await session.establish(mockConn)
|
||||
|
||||
expect(listProcessesMock).not.toHaveBeenCalled()
|
||||
expect(shutdownMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -107,7 +107,12 @@ function createMockDeps(): {
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
markSshRemotePtyLeases: vi.fn(),
|
||||
markSshRemotePtyLeasesAsync: vi.fn(),
|
||||
markSshRemotePtyLeasesAttachedAsync: vi.fn()
|
||||
markSshRemotePtyLeasesAttachedAsync: vi.fn(),
|
||||
getSshRemotePtyKillIntents: vi.fn().mockReturnValue([]),
|
||||
pruneExpiredSshRemotePtyKillIntents: vi.fn(),
|
||||
recordSshRemotePtyKillIntent: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn(),
|
||||
noteSshRemotePtyKillReplayAttempt: vi.fn()
|
||||
} as unknown as Store
|
||||
const mockPortForward = {
|
||||
removeAllForwards: vi.fn()
|
||||
|
||||
@@ -26,6 +26,11 @@ export function createMockDeps(): SshRelaySessionTestDeps {
|
||||
markSshRemotePtyLeasesAsync: vi.fn(),
|
||||
markSshRemotePtyLeasesForShutdown: vi.fn(),
|
||||
markSshRemotePtyLeasesAttachedAsync: vi.fn(),
|
||||
getSshRemotePtyKillIntents: vi.fn().mockReturnValue([]),
|
||||
pruneExpiredSshRemotePtyKillIntents: vi.fn(),
|
||||
recordSshRemotePtyKillIntent: vi.fn(),
|
||||
clearSshRemotePtyKillIntent: vi.fn(),
|
||||
noteSshRemotePtyKillReplayAttempt: vi.fn(),
|
||||
persistPtyBinding: vi.fn()
|
||||
} as unknown as Store
|
||||
const mockPortForward = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { BrowserWindow } from 'electron'
|
||||
import { deployAndLaunchRelay } from './ssh-relay-deploy'
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error'
|
||||
import { replayPendingSshPtyKills } from './ssh-pending-pty-kill-replay'
|
||||
import { SshChannelMultiplexer } from './ssh-channel-multiplexer'
|
||||
import { SshPtyProvider } from '../providers/ssh-pty-provider'
|
||||
import type { SshPtyAttachResult } from '../providers/ssh-pty-session-reattach'
|
||||
@@ -2252,6 +2253,23 @@ export class SshRelaySession {
|
||||
mux: SshChannelMultiplexer,
|
||||
shouldContinue: () => boolean
|
||||
): Promise<void> {
|
||||
const ptyProvider = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined
|
||||
const providerGeneration = this.activePtyProviderGeneration
|
||||
if (!ptyProvider || providerGeneration === null || this.mux !== mux) {
|
||||
return
|
||||
}
|
||||
// Why before the lease read: a stop the user asked for and this client could not deliver is
|
||||
// replayed here, and a confirmed one tombstones its lease — so it must land before the filter
|
||||
// below decides what to reattach, or the reattach revives a PTY that is about to die.
|
||||
await replayPendingSshPtyKills({
|
||||
targetId: this.targetId,
|
||||
store: this.store,
|
||||
provider: ptyProvider,
|
||||
shouldContinue
|
||||
})
|
||||
if (!shouldContinue()) {
|
||||
return
|
||||
}
|
||||
const activeLeases = this.store
|
||||
.getSshRemotePtyLeases(this.targetId)
|
||||
.filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired')
|
||||
@@ -2276,11 +2294,6 @@ export class SshRelaySession {
|
||||
...leasedPtyIds
|
||||
])
|
||||
)
|
||||
const ptyProvider = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined
|
||||
const providerGeneration = this.activePtyProviderGeneration
|
||||
if (!ptyProvider || providerGeneration === null || this.mux !== mux) {
|
||||
return
|
||||
}
|
||||
let nextPtyIndex = 0
|
||||
const worker = async (): Promise<void> => {
|
||||
while (shouldContinue()) {
|
||||
|
||||
@@ -92,7 +92,11 @@ export function computeRemoteInstallDir(
|
||||
pathFlavor === 'windows'
|
||||
? getRemoteHostPlatform('win32-x64')
|
||||
: getRemoteHostPlatform('linux-x64')
|
||||
return joinRemotePath(host, remoteHome, ...remoteInstallDirSegments(model, fullVersion, pathFlavor))
|
||||
return joinRemotePath(
|
||||
host,
|
||||
remoteHome,
|
||||
...remoteInstallDirSegments(model, fullVersion, pathFlavor)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,8 +67,14 @@ export type GitLabApi = {
|
||||
state?: 'opened' | 'closed' | 'all'
|
||||
assignee?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
}
|
||||
) => Promise<{ items: GitLabWorkItem[]; error?: ClassifiedError }>
|
||||
) => Promise<{
|
||||
items: GitLabWorkItem[]
|
||||
/** Optional while paired with a host older than issue pagination. */
|
||||
totalPages?: number
|
||||
error?: ClassifiedError
|
||||
}>
|
||||
createIssue: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
title: string
|
||||
|
||||
@@ -48,6 +48,8 @@ export type PtyApi = {
|
||||
telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind }
|
||||
}) => Promise<{
|
||||
id: string
|
||||
/** Which lifetime of `id` this reply named; absent when the execution host predates the field. */
|
||||
incarnationId?: string
|
||||
launchAgent?: TuiAgent
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
snapshot?: string
|
||||
@@ -196,7 +198,13 @@ export type PtyApi = {
|
||||
/** Title-only replay snapshot for (re)attach; attention facts never replay. */
|
||||
getSideEffectSnapshot: (id: string) => Promise<TerminalSideEffectBatch | null>
|
||||
onExit: (
|
||||
callback: (data: { id: string; code: number; preserveRendererBinding?: boolean }) => void
|
||||
callback: (data: {
|
||||
id: string
|
||||
code: number
|
||||
preserveRendererBinding?: boolean
|
||||
/** Which lifetime of `id` died; absent when the execution host predates the field. */
|
||||
incarnationId?: string
|
||||
}) => void
|
||||
) => () => void
|
||||
onSpawned: (callback: (data: { id: string }) => void) => () => void
|
||||
onSerializeBufferRequest: (
|
||||
|
||||
@@ -56,8 +56,9 @@ export const glApi = {
|
||||
state?: 'opened' | 'closed' | 'all'
|
||||
assignee?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
}
|
||||
): Promise<{ items: unknown[]; error?: unknown }> =>
|
||||
): Promise<{ items: unknown[]; totalPages?: number; error?: unknown }> =>
|
||||
ipcRenderer.invoke('gitlab:listIssues', args),
|
||||
|
||||
createIssue: (
|
||||
|
||||
+18
-3
@@ -1058,6 +1058,8 @@ const api = {
|
||||
telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind }
|
||||
}): Promise<{
|
||||
id: string
|
||||
/** Which lifetime of `id` this reply named; absent when the execution host predates the field. */
|
||||
incarnationId?: string
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
snapshot?: string
|
||||
snapshotCols?: number
|
||||
@@ -1304,10 +1306,23 @@ const api = {
|
||||
ipcRenderer.invoke('pty:sideEffectSnapshot', { id }),
|
||||
|
||||
onExit: (
|
||||
callback: (data: { id: string; code: number; preserveRendererBinding?: boolean }) => void
|
||||
callback: (data: {
|
||||
id: string
|
||||
code: number
|
||||
preserveRendererBinding?: boolean
|
||||
/** Which lifetime of `id` died; absent when the execution host predates the field. */
|
||||
incarnationId?: string
|
||||
}) => void
|
||||
): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { id: string; code: number }) =>
|
||||
callback(data)
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: {
|
||||
id: string
|
||||
code: number
|
||||
preserveRendererBinding?: boolean
|
||||
incarnationId?: string
|
||||
}
|
||||
) => callback(data)
|
||||
ipcRenderer.on('pty:exit', listener)
|
||||
return () => ipcRenderer.removeListener('pty:exit', listener)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event'
|
||||
import type { AgentHookSource } from '../shared/agent-hook-relay'
|
||||
|
||||
export type CachedPaneEnvelopeMeta = { source: AgentHookSource; env?: string; version?: string }
|
||||
|
||||
export type CachedPaneReplaySelection = {
|
||||
event: AgentHookEventPayload
|
||||
meta: CachedPaneEnvelopeMeta
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses which cached pane statuses a reconnecting client may be re-told about, and drops the rest.
|
||||
*
|
||||
* Two panes are refused, for opposite reasons:
|
||||
* - a pane whose surface has been retired — the tab is gone, so replaying its status would hand the
|
||||
* client a live agent pane nobody owns, which is what drives an auto-resume onto a transcript an
|
||||
* orphaned agent is still writing;
|
||||
* - a pane whose envelope metadata is missing — the status cache and the metadata map are populated
|
||||
* and cleared in lockstep, so a key present in one and not the other is drift. Skip rather than
|
||||
* guess a source that mis-tags the replay downstream.
|
||||
*/
|
||||
export function selectReplayableCachedPanes(input: {
|
||||
cachedByPaneKey: ReadonlyMap<string, AgentHookEventPayload>
|
||||
metaByPaneKey: ReadonlyMap<string, CachedPaneEnvelopeMeta>
|
||||
isPaneSurfaceRetired: (paneKey: string) => boolean
|
||||
dropPane: (paneKey: string) => void
|
||||
}): CachedPaneReplaySelection[] {
|
||||
const selected: CachedPaneReplaySelection[] = []
|
||||
// Snapshot: dropping a retired pane mutates the map being read.
|
||||
for (const [paneKey, event] of Array.from(input.cachedByPaneKey.entries())) {
|
||||
if (input.isPaneSurfaceRetired(paneKey)) {
|
||||
input.dropPane(paneKey)
|
||||
continue
|
||||
}
|
||||
const meta = input.metaByPaneKey.get(paneKey)
|
||||
if (meta) {
|
||||
selected.push({ event, meta })
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
/** How many panes keep a cached status. Bounds a long-lived relay's per-pane state. */
|
||||
export const MAX_CACHED_PANES = 256
|
||||
|
||||
/** Drop the longest-idle panes until the cache is back under its cap. Map insertion order is
|
||||
* recency (writers delete-then-set), so the first key is always the oldest. */
|
||||
export function evictCachedPanesOverCap(
|
||||
cachedByPaneKey: ReadonlyMap<string, unknown>,
|
||||
dropPane: (paneKey: string) => void,
|
||||
maxPanes: number = MAX_CACHED_PANES
|
||||
): void {
|
||||
while (cachedByPaneKey.size > maxPanes) {
|
||||
const oldest = cachedByPaneKey.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
return
|
||||
}
|
||||
dropPane(oldest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { RelayAgentHookServer } from './agent-hook-server'
|
||||
import { RelayAgentHookRuntime } from './relay-agent-hook-runtime'
|
||||
import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces'
|
||||
import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay'
|
||||
import { makePaneKey } from '../shared/stable-pane-id'
|
||||
import type { PtyHandler, PtySurfaceRetiredListener } from './pty-handler'
|
||||
import type { RelayDispatcher } from './dispatcher'
|
||||
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const PANE_KEY = makePaneKey('tab-1', LEAF_ID)
|
||||
|
||||
function hookMeta(paneKey: string): string {
|
||||
return Buffer.from([paneKey, 'tab-1', '', 'wt-1', 'remote', '1'].join('\x1f')).toString('base64')
|
||||
}
|
||||
|
||||
async function postHook(
|
||||
server: RelayAgentHookServer,
|
||||
paneKey: string,
|
||||
prompt = 'hi'
|
||||
): Promise<number> {
|
||||
const { port, token } = server.getCoordinates()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': token,
|
||||
'X-Orca-Agent-Hook-Meta-Encoding': 'base64',
|
||||
'X-Orca-Agent-Hook-Meta': hookMeta(paneKey)
|
||||
},
|
||||
body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt })
|
||||
})
|
||||
return res.status
|
||||
}
|
||||
|
||||
type CachedPanes = { state: { lastStatusByPaneKey: Map<string, unknown> } }
|
||||
|
||||
function cachedPaneKeys(server: RelayAgentHookServer): string[] {
|
||||
return [...(server as unknown as CachedPanes).state.lastStatusByPaneKey.keys()]
|
||||
}
|
||||
|
||||
describe('relay hook forwarding for a retired pane surface', () => {
|
||||
let dir: string
|
||||
let servers: RelayAgentHookServer[]
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'relay-retired-pane-'))
|
||||
servers = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const server of servers) {
|
||||
server.stop()
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function startServer(options: {
|
||||
forward: (envelope: AgentHookRelayEnvelope) => void
|
||||
isPaneSurfaceRetired?: (paneKey: string) => boolean
|
||||
}): Promise<RelayAgentHookServer> {
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, ...options })
|
||||
servers.push(server)
|
||||
await server.start({ publishEndpoint: false })
|
||||
return server
|
||||
}
|
||||
|
||||
it('forwards and caches a post from a pane that still has a tab', async () => {
|
||||
const retired = new RetiredPaneSurfaceRegistry()
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = await startServer({
|
||||
forward,
|
||||
isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey)
|
||||
})
|
||||
|
||||
expect(await postHook(server, PANE_KEY)).toBe(204)
|
||||
|
||||
expect(forward).toHaveBeenCalledTimes(1)
|
||||
expect(cachedPaneKeys(server)).toEqual([PANE_KEY])
|
||||
})
|
||||
|
||||
it('does not advertise an orphan agent whose tab was closed', async () => {
|
||||
const retired = new RetiredPaneSurfaceRegistry()
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = await startServer({
|
||||
forward,
|
||||
isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey)
|
||||
})
|
||||
|
||||
expect(await postHook(server, PANE_KEY, 'first')).toBe(204)
|
||||
forward.mockClear()
|
||||
|
||||
// The user closes the tab; the agent inside the pane shell survives and keeps posting.
|
||||
retired.retire(PANE_KEY)
|
||||
expect(await postHook(server, PANE_KEY, 'orphan')).toBe(204)
|
||||
|
||||
expect(forward).not.toHaveBeenCalled()
|
||||
// The pre-close status must go too, or a reconnect replays it as a live agent pane.
|
||||
expect(cachedPaneKeys(server)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not replay a cached status for a pane retired after it was cached', async () => {
|
||||
const retired = new RetiredPaneSurfaceRegistry()
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = await startServer({
|
||||
forward,
|
||||
isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey)
|
||||
})
|
||||
|
||||
expect(await postHook(server, PANE_KEY)).toBe(204)
|
||||
forward.mockClear()
|
||||
retired.retire(PANE_KEY)
|
||||
|
||||
expect(server.replayCachedPayloadsForPanes()).toBe(0)
|
||||
expect(forward).not.toHaveBeenCalled()
|
||||
expect(cachedPaneKeys(server)).toEqual([])
|
||||
})
|
||||
|
||||
it('forwards again once the pane key is bound by a new PTY', async () => {
|
||||
const retired = new RetiredPaneSurfaceRegistry()
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = await startServer({
|
||||
forward,
|
||||
isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey)
|
||||
})
|
||||
|
||||
retired.retire(PANE_KEY)
|
||||
expect(await postHook(server, PANE_KEY, 'orphan')).toBe(204)
|
||||
expect(forward).not.toHaveBeenCalled()
|
||||
|
||||
retired.restore(PANE_KEY)
|
||||
expect(await postHook(server, PANE_KEY, 'reopened')).toBe(204)
|
||||
|
||||
expect(forward).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('an embedder that supplies no retirement source keeps forwarding everything', async () => {
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = await startServer({ forward })
|
||||
|
||||
expect(await postHook(server, PANE_KEY)).toBe(204)
|
||||
|
||||
expect(forward).toHaveBeenCalledTimes(1)
|
||||
expect(cachedPaneKeys(server)).toEqual([PANE_KEY])
|
||||
})
|
||||
})
|
||||
|
||||
describe('RelayAgentHookRuntime wiring', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'relay-retired-pane-runtime-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('routes hook admission through the PTY handler and drops the cache on retirement', async () => {
|
||||
const retired = new RetiredPaneSurfaceRegistry()
|
||||
const surfaceRetiredListeners: PtySurfaceRetiredListener[] = []
|
||||
const ptyHandler = {
|
||||
addEnvAugmenter: vi.fn(),
|
||||
setExitListener: vi.fn(),
|
||||
setSurfaceRetiredListener: vi.fn((listener: PtySurfaceRetiredListener | null) => {
|
||||
if (listener) {
|
||||
surfaceRetiredListeners.push(listener)
|
||||
}
|
||||
}),
|
||||
isPaneSurfaceRetired: (paneKey: string) => retired.isRetired(paneKey)
|
||||
} as unknown as PtyHandler
|
||||
// Zero attached clients: publishAgentHookEnvelope returns before touching any sink.
|
||||
const dispatcher = {
|
||||
onRequest: vi.fn(),
|
||||
activeClientIds: () => [] as number[]
|
||||
} as unknown as RelayDispatcher
|
||||
|
||||
const runtime = new RelayAgentHookRuntime(
|
||||
dispatcher,
|
||||
ptyHandler,
|
||||
join(dir, 'relay.sock'),
|
||||
join(dir, 'hooks')
|
||||
)
|
||||
await runtime.start()
|
||||
try {
|
||||
const server = (runtime as unknown as { hookServer: RelayAgentHookServer }).hookServer
|
||||
expect(await postHook(server, PANE_KEY)).toBe(204)
|
||||
expect(cachedPaneKeys(server)).toEqual([PANE_KEY])
|
||||
|
||||
// The PTY handler retires the surface: the runtime must drop the cached status immediately,
|
||||
// rather than waiting for a process exit a surviving shell never produces.
|
||||
retired.retire(PANE_KEY)
|
||||
expect(surfaceRetiredListeners).toHaveLength(1)
|
||||
surfaceRetiredListeners[0]({ id: 'pty-1', paneKey: PANE_KEY })
|
||||
expect(cachedPaneKeys(server)).toEqual([])
|
||||
|
||||
expect(await postHook(server, PANE_KEY, 'orphan')).toBe(204)
|
||||
expect(cachedPaneKeys(server)).toEqual([])
|
||||
} finally {
|
||||
runtime.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -41,11 +41,13 @@ import {
|
||||
import { buildRelayHookPtyEnv, defaultEndpointDir } from './agent-hook-endpoint-coordinates'
|
||||
import { buildRelayHookEnvelope, hookBodyEnv, hookBodyVersion } from './agent-hook-envelope-build'
|
||||
import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler'
|
||||
import {
|
||||
evictCachedPanesOverCap,
|
||||
selectReplayableCachedPanes
|
||||
} from './agent-hook-cached-pane-status'
|
||||
|
||||
export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void
|
||||
|
||||
const MAX_CACHED_PANES = 256
|
||||
|
||||
export type RelayHookServerOptions = {
|
||||
/** Where to put endpoint.env / endpoint.cmd. Defaults to `$HOME/.orca-relay/agent-hooks`. */
|
||||
endpointDir?: string
|
||||
@@ -56,6 +58,13 @@ export type RelayHookServerOptions = {
|
||||
/** Preferred bind port. WSL relay passes the Windows listener's port so env-sourced client coords stay truthful; falls back to :0 if occupied. Defaults to :0. */
|
||||
preferredPort?: number
|
||||
forward: RelayHookForward
|
||||
/**
|
||||
* True when the host has been told this pane's tab is gone and no PTY has re-bound the paneKey.
|
||||
* Posts from such a pane come from a process the user already closed, so they describe no surface
|
||||
* any client owns. Defaults to "never retired", which is the pre-existing behaviour — a listener
|
||||
* with no PTY handler behind it (the WSL relay) keeps forwarding everything.
|
||||
*/
|
||||
isPaneSurfaceRetired?: (paneKey: string) => boolean
|
||||
}
|
||||
|
||||
export type RelayHookServerStartOptions = {
|
||||
@@ -81,6 +90,7 @@ export class RelayAgentHookServer {
|
||||
{ source: AgentHookSource; env?: string; version?: string }
|
||||
>()
|
||||
private forward: RelayHookForward
|
||||
private isPaneSurfaceRetired: (paneKey: string) => boolean
|
||||
private fixedToken: string | undefined
|
||||
private preferredPort: number
|
||||
private portFallbackApplied = false
|
||||
@@ -93,6 +103,7 @@ export class RelayAgentHookServer {
|
||||
this.fixedToken = options.token
|
||||
this.preferredPort = options.preferredPort ?? 0
|
||||
this.forward = options.forward
|
||||
this.isPaneSurfaceRetired = options.isPaneSurfaceRetired ?? (() => false)
|
||||
this.retryScheduler = new AgentHookResultRetryScheduler({
|
||||
state: this.state,
|
||||
env: this.env,
|
||||
@@ -198,19 +209,18 @@ export class RelayAgentHookServer {
|
||||
/** Request-driven replay: re-forwards each cached paneKey payload as a fresh notification. Forwards are
|
||||
* issued before the request handler returns, so the response trails all replayed notifications. */
|
||||
replayCachedPayloadsForPanes(): number {
|
||||
let count = 0
|
||||
for (const [paneKey, event] of this.state.lastStatusByPaneKey.entries()) {
|
||||
const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey)
|
||||
// Why: invariant — status-cache keys always have meta; if it drifted, skip rather than guess a source that mis-tags downstream.
|
||||
if (!meta) {
|
||||
continue
|
||||
}
|
||||
const replayable = selectReplayableCachedPanes({
|
||||
cachedByPaneKey: this.state.lastStatusByPaneKey,
|
||||
metaByPaneKey: this.lastEnvelopeMetaByPaneKey,
|
||||
isPaneSurfaceRetired: this.isPaneSurfaceRetired,
|
||||
dropPane: (paneKey) => this.clearPaneState(paneKey)
|
||||
})
|
||||
for (const { event, meta } of replayable) {
|
||||
this.forward(
|
||||
buildRelayHookEnvelope(event, meta.source, meta.env, meta.version, { isReplay: true })
|
||||
)
|
||||
count++
|
||||
}
|
||||
return count
|
||||
return replayable.length
|
||||
}
|
||||
|
||||
/** Drop a paneKey's cached entries on PTY exit so a terminated pane can't resurface as a ghost event on reconnect. */
|
||||
@@ -301,6 +311,14 @@ export class RelayAgentHookServer {
|
||||
version?: string,
|
||||
options: { isReplay?: boolean } = {}
|
||||
): void {
|
||||
// Why: this post came from a process still running inside a pane whose tab the user closed.
|
||||
// Caching or forwarding it makes every connected client advertise a live, resumable agent pane
|
||||
// that no tab owns — the advertisement that ends up auto-typing a second `--resume` onto a
|
||||
// transcript the orphan is still writing (#12447). Drop the stale cache with it.
|
||||
if (this.isPaneSurfaceRetired(event.paneKey)) {
|
||||
this.clearPaneState(event.paneKey)
|
||||
return
|
||||
}
|
||||
if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) {
|
||||
this.retryScheduler.clearAssistantMessageRetry(event.paneKey)
|
||||
}
|
||||
@@ -313,13 +331,7 @@ export class RelayAgentHookServer {
|
||||
this.state.lastStatusByPaneKey.set(event.paneKey, cachedEvent)
|
||||
this.lastEnvelopeMetaByPaneKey.delete(event.paneKey)
|
||||
this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version })
|
||||
while (this.state.lastStatusByPaneKey.size > MAX_CACHED_PANES) {
|
||||
const oldest = this.state.lastStatusByPaneKey.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
this.clearPaneState(oldest)
|
||||
}
|
||||
evictCachedPanesOverCap(this.state.lastStatusByPaneKey, (key) => this.clearPaneState(key))
|
||||
this.forward(buildRelayHookEnvelope(event, source, env, version, options))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({
|
||||
mockPtySpawn: vi.fn(),
|
||||
mockCreateShellPromptReadinessProbe: vi.fn(),
|
||||
mockPtyInstance: {
|
||||
pid: process.pid,
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node-pty', () => ({
|
||||
spawn: mockPtySpawn
|
||||
}))
|
||||
|
||||
vi.mock('../main/pty/posix-pty-process-groups', () => ({
|
||||
forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback())
|
||||
}))
|
||||
|
||||
vi.mock('../main/shell-prompt-readiness-probe', () => ({
|
||||
createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe
|
||||
}))
|
||||
|
||||
import * as ptyShellUtils from './pty-shell-utils'
|
||||
import type { PtyHandler } from './pty-handler'
|
||||
import {
|
||||
IMMEDIATE_PTY_EXIT_TIMEOUT_MS,
|
||||
SHUTDOWN_REAP_MAX_SWEEPS,
|
||||
SHUTDOWN_REAP_VERIFY_DELAY_MS
|
||||
} from './pty-handler'
|
||||
import {
|
||||
beginPtyHandlerTest,
|
||||
createPtyRequestHelpers,
|
||||
endPtyHandlerTest
|
||||
} from './pty-handler-test-harness'
|
||||
import type { MockDispatcher } from './pty-handler-test-harness'
|
||||
|
||||
const PANE_KEY = 'tab-agent:22222222-2222-4222-8222-222222222222'
|
||||
|
||||
const AGENT_SESSION_ENSURE = {
|
||||
claim: {
|
||||
digestVersion: 1,
|
||||
keyId: 'claim-key',
|
||||
identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
agent: 'claude'
|
||||
},
|
||||
surface: {
|
||||
worktreeId: 'repo::/tmp/worktree',
|
||||
tabId: '11111111-1111-4111-8111-111111111111',
|
||||
leafId: '22222222-2222-4222-8222-222222222222',
|
||||
terminalHandle: 'term_claimed'
|
||||
}
|
||||
}
|
||||
|
||||
type ProcessSummary = {
|
||||
id: string
|
||||
agentSessionOwners?: unknown[]
|
||||
}
|
||||
|
||||
describe('PtyHandler retires a closed pane surface', () => {
|
||||
let dispatcher: MockDispatcher
|
||||
let handler: PtyHandler
|
||||
let originalPlatform: PropertyDescriptor | undefined
|
||||
|
||||
const { spawnPty } = createPtyRequestHelpers(() => dispatcher)
|
||||
|
||||
async function spawnAgentPane(
|
||||
params: Record<string, unknown> = {}
|
||||
): Promise<{ id: string; term: typeof mockPtyInstance }> {
|
||||
const term = { ...mockPtyInstance, kill: vi.fn(), onData: vi.fn(), onExit: vi.fn() }
|
||||
mockPtySpawn.mockReturnValue(term)
|
||||
const spawned = await spawnPty({
|
||||
env: { ORCA_PANE_KEY: PANE_KEY },
|
||||
agentSessionEnsure: AGENT_SESSION_ENSURE,
|
||||
...params
|
||||
})
|
||||
return { id: spawned.id, term }
|
||||
}
|
||||
|
||||
async function listProcesses(): Promise<ProcessSummary[]> {
|
||||
return (await dispatcher.callRequest('pty.listProcesses', {})) as ProcessSummary[]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({
|
||||
mockPtySpawn,
|
||||
mockPtyInstance,
|
||||
mockCreateShellPromptReadinessProbe
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await endPtyHandlerTest(handler, originalPlatform)
|
||||
})
|
||||
|
||||
it('marks the pane surface retired as soon as its tab is closed', async () => {
|
||||
const retired: { id: string; paneKey: string }[] = []
|
||||
handler.setSurfaceRetiredListener((event) => retired.push(event))
|
||||
const { id } = await spawnAgentPane()
|
||||
|
||||
expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(false)
|
||||
|
||||
await dispatcher.callRequest('pty.shutdown', { id })
|
||||
|
||||
expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(true)
|
||||
expect(retired).toEqual([{ id, paneKey: PANE_KEY }])
|
||||
})
|
||||
|
||||
it('retires the surface even when the shell survives the kill request', async () => {
|
||||
vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true)
|
||||
const { id } = await spawnAgentPane()
|
||||
|
||||
await dispatcher.callRequest('pty.shutdown', { id })
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
SHUTDOWN_REAP_VERIFY_DELAY_MS * (SHUTDOWN_REAP_MAX_SWEEPS + 2)
|
||||
)
|
||||
|
||||
// The process could not be verified gone, so the claim is deliberately kept — releasing it is
|
||||
// what would let a reopened project fork a second agent onto the same transcript.
|
||||
expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(true)
|
||||
const sessions = await listProcesses()
|
||||
expect(sessions.map((session) => session.id)).toEqual([id])
|
||||
})
|
||||
|
||||
it('re-issues the force kill instead of letting a detached shell outlive its tab', async () => {
|
||||
vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true)
|
||||
const { id, term } = await spawnAgentPane()
|
||||
|
||||
await dispatcher.callRequest('pty.shutdown', { id })
|
||||
// The graceful path's own armed fallback fires first; count only what the sweep adds.
|
||||
await vi.advanceTimersByTimeAsync(SHUTDOWN_REAP_VERIFY_DELAY_MS - 1)
|
||||
const killsBeforeSweep = term.kill.mock.calls.filter(([signal]) => signal === 'SIGKILL').length
|
||||
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
SHUTDOWN_REAP_VERIFY_DELAY_MS * (SHUTDOWN_REAP_MAX_SWEEPS + 2)
|
||||
)
|
||||
|
||||
const killsAfterSweep = term.kill.mock.calls.filter(([signal]) => signal === 'SIGKILL').length
|
||||
expect(killsAfterSweep - killsBeforeSweep).toBe(SHUTDOWN_REAP_MAX_SWEEPS)
|
||||
})
|
||||
|
||||
it('never re-closes a ConPTY handle on Windows, where the first kill is already final', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true)
|
||||
const { id, term } = await spawnAgentPane()
|
||||
|
||||
// The immediate path is the one that reaches ConPTY's kill without setting gracefulKillSent.
|
||||
const shutdown = dispatcher.callRequest('pty.shutdown', { id, immediate: true })
|
||||
const rejected = expect(shutdown).rejects.toThrow('Timed out waiting for PTY process exit')
|
||||
await vi.advanceTimersByTimeAsync(IMMEDIATE_PTY_EXIT_TIMEOUT_MS)
|
||||
await rejected
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
SHUTDOWN_REAP_VERIFY_DELAY_MS * (SHUTDOWN_REAP_MAX_SWEEPS + 2)
|
||||
)
|
||||
|
||||
expect(term.kill.mock.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('retires the relay session once the sweep proves the process is gone', async () => {
|
||||
const exits: { id: string; paneKey?: string }[] = []
|
||||
handler.setExitListener((event) => exits.push(event))
|
||||
const alive = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true)
|
||||
const { id } = await spawnAgentPane()
|
||||
|
||||
expect((await listProcesses())[0]?.agentSessionOwners).toHaveLength(1)
|
||||
|
||||
await dispatcher.callRequest('pty.shutdown', { id })
|
||||
alive.mockReturnValue(false)
|
||||
await vi.advanceTimersByTimeAsync(SHUTDOWN_REAP_VERIFY_DELAY_MS + 1)
|
||||
|
||||
// The sweep itself must retire it — node-pty produced no exit, and nothing here polls a listing.
|
||||
expect(exits).toEqual([{ id, paneKey: PANE_KEY }])
|
||||
expect(await listProcesses()).toEqual([])
|
||||
})
|
||||
|
||||
it('stops advertising an agent session whose process died without a node-pty exit', async () => {
|
||||
const alive = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true)
|
||||
await spawnAgentPane()
|
||||
|
||||
expect((await listProcesses())[0]?.agentSessionOwners).toHaveLength(1)
|
||||
|
||||
// No shutdown, no onExit — the shell just went away. The relay has to look.
|
||||
alive.mockReturnValue(false)
|
||||
|
||||
expect(await listProcesses()).toEqual([])
|
||||
})
|
||||
|
||||
it('restores the surface when a new PTY binds the same pane key', async () => {
|
||||
const { id } = await spawnAgentPane()
|
||||
await dispatcher.callRequest('pty.shutdown', { id })
|
||||
expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(true)
|
||||
|
||||
const term = { ...mockPtyInstance, kill: vi.fn(), onData: vi.fn(), onExit: vi.fn() }
|
||||
mockPtySpawn.mockReturnValue(term)
|
||||
await spawnPty({ env: { ORCA_PANE_KEY: PANE_KEY } })
|
||||
|
||||
expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(false)
|
||||
})
|
||||
|
||||
it('restores the surface when a client reattaches to a shut-down PTY that survived', async () => {
|
||||
vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true)
|
||||
const { id } = await spawnAgentPane()
|
||||
await dispatcher.callRequest('pty.shutdown', { id })
|
||||
expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(true)
|
||||
|
||||
await dispatcher.callRequest('pty.attach', { id })
|
||||
|
||||
expect(handler.isPaneSurfaceRetired(PANE_KEY)).toBe(false)
|
||||
})
|
||||
|
||||
it('shutting down a pane with no pane key retires nothing', async () => {
|
||||
const retired: { id: string; paneKey: string }[] = []
|
||||
handler.setSurfaceRetiredListener((event) => retired.push(event))
|
||||
const term = { ...mockPtyInstance, kill: vi.fn(), onData: vi.fn(), onExit: vi.fn() }
|
||||
mockPtySpawn.mockReturnValue(term)
|
||||
const spawned = await spawnPty({})
|
||||
|
||||
await dispatcher.callRequest('pty.shutdown', { id: spawned.id })
|
||||
|
||||
expect(retired).toEqual([])
|
||||
})
|
||||
})
|
||||
+152
-9
@@ -17,6 +17,7 @@ import {
|
||||
listShellProfiles
|
||||
} from './pty-shell-utils'
|
||||
import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch'
|
||||
import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces'
|
||||
import { addWslEnvKeys } from '../shared/wsl-env'
|
||||
import { SHELL_STARTUP_FEATURE_ENV } from '../main/shell-startup-features'
|
||||
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
|
||||
@@ -151,6 +152,9 @@ type ManagedPty = {
|
||||
buffered: RecentPtyOutputBuffer
|
||||
/** Timer for SIGKILL fallback after a graceful SIGTERM shutdown. */
|
||||
killTimer?: ReturnType<typeof setTimeout>
|
||||
/** Timer for the post-shutdown reap sweep: re-probes liveness after a kill request instead of
|
||||
* assuming the request landed. */
|
||||
reapTimer?: ReturnType<typeof setTimeout>
|
||||
/** True once disposeManagedPty has run; blocks double-dispose and makes post-dispose calls fail "not found" not silently. */
|
||||
disposed?: boolean
|
||||
/** True once external cleanup observers have been notified. */
|
||||
@@ -242,6 +246,10 @@ function disposeManagedPty(managed: ManagedPty): void {
|
||||
clearTimeout(managed.killTimer)
|
||||
managed.killTimer = undefined
|
||||
}
|
||||
if (managed.reapTimer) {
|
||||
clearTimeout(managed.reapTimer)
|
||||
managed.reapTimer = undefined
|
||||
}
|
||||
// Why: neutralize pty.kill before destroy() so UnixTerminal's async 'close' SIGHUP can't hit a recycled pid.
|
||||
// Windows exempt: its destroy() IS a kill() (via _deferNoArgs), so neutralizing leaks the ConPTY agent.
|
||||
if (process.platform !== 'win32') {
|
||||
@@ -258,6 +266,9 @@ function disposeManagedPty(managed: ManagedPty): void {
|
||||
}
|
||||
const DEFAULT_GRACE_TIME_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000
|
||||
export const IMMEDIATE_PTY_EXIT_TIMEOUT_MS = 8_000
|
||||
/** Longer than the 5s armed SIGKILL fallback, so the first sweep observes the post-kill state. */
|
||||
export const SHUTDOWN_REAP_VERIFY_DELAY_MS = 6_000
|
||||
export const SHUTDOWN_REAP_MAX_SWEEPS = 3
|
||||
export const MAX_RELAY_PTY_SESSIONS = 50
|
||||
export const REPLAY_BUFFER_MAX = 100 * 1024
|
||||
const PTY_OUTPUT_BATCH_INTERVAL_MS = 8
|
||||
@@ -389,6 +400,10 @@ function sanitizeEnvToDelete(value: unknown): string[] {
|
||||
|
||||
export type PtyExitListener = (event: { id: string; paneKey?: string }) => void
|
||||
|
||||
/** Notified when a client retires a pane's surface (`pty.shutdown`). Deliberately separate from
|
||||
* {@link PtyExitListener}: retirement says the tab is gone, never that the process exited. */
|
||||
export type PtySurfaceRetiredListener = (event: { id: string; paneKey: string }) => void
|
||||
|
||||
type PtyIdentity = { paneKey?: string; tabId?: string }
|
||||
|
||||
/**
|
||||
@@ -443,6 +458,8 @@ export class PtyHandler {
|
||||
private reloadPtyModuleFromDisk = false
|
||||
// Why: single optional slot is intentional — callers compose externally; a throw is swallowed so it can't block cleanup.
|
||||
private exitListener: PtyExitListener | null = null
|
||||
private surfaceRetiredListener: PtySurfaceRetiredListener | null = null
|
||||
private readonly retiredPaneSurfaces = new RetiredPaneSurfaceRegistry()
|
||||
private ptyPoolEmptyListener: (() => void) | null = null
|
||||
private ptyPoolActiveListener: (() => void) | null = null
|
||||
// Why: augment environment on every spawn so PTYs receive current hook coordinates.
|
||||
@@ -571,6 +588,19 @@ export class PtyHandler {
|
||||
this.exitListener = listener
|
||||
}
|
||||
|
||||
/** Subscribe to pane-surface retirement (relay-hook server uses this to drop the pane's cached
|
||||
* agent status the moment the tab goes away, rather than waiting for a process exit that a
|
||||
* surviving shell may never produce). */
|
||||
setSurfaceRetiredListener(listener: PtySurfaceRetiredListener | null): void {
|
||||
this.surfaceRetiredListener = listener
|
||||
}
|
||||
|
||||
/** True when the client has told this host the pane's tab is gone and no PTY has re-bound the
|
||||
* paneKey since. Nothing this pane emits can belong to a surface any client still owns. */
|
||||
isPaneSurfaceRetired(paneKey: string): boolean {
|
||||
return this.retiredPaneSurfaces.isRetired(paneKey)
|
||||
}
|
||||
|
||||
/** Notified when the last PTY leaves the pool, so the relay can re-arm its idle grace. */
|
||||
onPtyPoolEmpty(listener: () => void): () => void {
|
||||
this.ptyPoolEmptyListener = listener
|
||||
@@ -786,6 +816,12 @@ export class PtyHandler {
|
||||
private wireAndStore(managed: ManagedPty): void {
|
||||
managed.physicalExit = new PhysicalExitTracker()
|
||||
this.ptys.set(managed.id, managed)
|
||||
// Why: a PTY joining the pool under this paneKey means the surface exists again (reopened pane
|
||||
// or revive), so a prior retirement no longer describes anything and must not mute its hooks.
|
||||
const boundPaneKey = managed.paneKey ?? managed.attachIdentity?.paneKey
|
||||
if (boundPaneKey) {
|
||||
this.retiredPaneSurfaces.restore(boundPaneKey)
|
||||
}
|
||||
// Why: a second announce covers any store whose admission window has already closed.
|
||||
this.notifyPoolListener(this.ptyPoolActiveListener, 'pty-pool-active')
|
||||
const emitIngressData = (emission: PtyIngressEmission): void => {
|
||||
@@ -1778,14 +1814,7 @@ export class PtyHandler {
|
||||
|
||||
// Why: verify liveness because shells can exit without node-pty onExit.
|
||||
if (managed.pty.pid && !isProcessAlive(managed.pty.pid)) {
|
||||
managed.physicalExit?.markExited()
|
||||
this.releaseRelayIngress(managed)
|
||||
this.flushPtyOutput(id)
|
||||
this.notifyExitListener(managed)
|
||||
this.agentSessionOwners.release(managed.id)
|
||||
disposeManagedPty(managed)
|
||||
this.removePty(id)
|
||||
this.clearPtyFlowState(id)
|
||||
this.reapExitedPty(managed)
|
||||
throw new Error(`PTY "${id}" not found`)
|
||||
}
|
||||
|
||||
@@ -1801,6 +1830,14 @@ export class PtyHandler {
|
||||
throw new Error(`PTY "${id}" not found (identity mismatch)`)
|
||||
}
|
||||
|
||||
// Why: an accepted attach is a client surface for this pane, so a prior retirement no longer
|
||||
// describes anything — without this a reattach to a shut-down-but-surviving PTY would keep the
|
||||
// pane's agent hooks muted for the rest of the daemon's life.
|
||||
const attachedPaneKey = managed.paneKey ?? managed.attachIdentity?.paneKey
|
||||
if (attachedPaneKey) {
|
||||
this.retiredPaneSurfaces.restore(attachedPaneKey)
|
||||
}
|
||||
|
||||
managed.startupIngress?.snapshotBarrier()
|
||||
let sourceRecovery = parseSourceRecoveryRequest(params.sourceRecovery)
|
||||
if (
|
||||
@@ -1917,6 +1954,17 @@ export class PtyHandler {
|
||||
if (!managed) {
|
||||
return
|
||||
}
|
||||
// Why: `pty.shutdown` is the only authoritative statement this host ever gets that a tab is
|
||||
// gone. Record it before the kill request, because the kill is the part that can fail: an agent
|
||||
// that survives teardown otherwise keeps posting hooks the relay forwards as a live agent pane
|
||||
// with no tab, and that advertisement is what drives a second `--resume` onto its transcript.
|
||||
// Why gated on an actual retirement: the teardown below is fire-and-forget — one SIGTERM, one
|
||||
// armed SIGKILL, and nobody ever looks again. Re-probing is what stops a retired pane's shell
|
||||
// outliving its tab; a PTY with no pane surface has nothing to outlive, so its long-standing
|
||||
// teardown contract is left exactly as it was.
|
||||
if (this.retirePaneSurface(managed)) {
|
||||
this.armShutdownReapSweep(managed, SHUTDOWN_REAP_MAX_SWEEPS)
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
this.releaseStartupCommand(managed)
|
||||
@@ -1930,6 +1978,93 @@ export class PtyHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/** Record that this pane's client surface is gone, and tell the hook server so the pane's cached
|
||||
* agent status stops being replayed to reconnecting clients. Returns false when there is no pane
|
||||
* surface to retire. */
|
||||
private retirePaneSurface(managed: ManagedPty): boolean {
|
||||
const paneKey = managed.paneKey ?? managed.attachIdentity?.paneKey
|
||||
if (!paneKey) {
|
||||
return false
|
||||
}
|
||||
this.retiredPaneSurfaces.retire(paneKey)
|
||||
const listener = this.surfaceRetiredListener
|
||||
if (!listener) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
listener({ id: managed.id, paneKey })
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[pty-handler] surface-retired listener threw: ${err instanceof Error ? err.message : String(err)}\n`
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* After a retirement, verify the host actually reaped the shell rather than trusting the kill
|
||||
* request. Two outcomes matter and both are bookkeeping the relay previously never did:
|
||||
*
|
||||
* - the pid is gone but node-pty never produced `onExit` — retire the record here, so the entry
|
||||
* and its agent-session owners stop being published by `pty.listProcesses`;
|
||||
* - the pid is still alive — re-issue the force kill instead of leaving a detached shell (and the
|
||||
* agent inside it) outliving its tab for the life of the daemon.
|
||||
*
|
||||
* Bounded: after {@link SHUTDOWN_REAP_MAX_SWEEPS} the owner claim is deliberately *retained*.
|
||||
* Releasing a claim we cannot prove dead is what lets a reopened project spawn a second agent
|
||||
* over one transcript; keeping it makes the next `pty.spawn` adopt this PTY instead.
|
||||
*/
|
||||
private armShutdownReapSweep(managed: ManagedPty, attemptsRemaining: number): void {
|
||||
if (managed.reapTimer) {
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
managed.reapTimer = undefined
|
||||
if (this.ptys.get(managed.id) !== managed || managed.disposed) {
|
||||
return
|
||||
}
|
||||
const pid = managed.pty.pid
|
||||
if (pid && !isProcessAlive(pid)) {
|
||||
this.reapExitedPty(managed)
|
||||
return
|
||||
}
|
||||
if (attemptsRemaining <= 0) {
|
||||
process.stderr.write(
|
||||
`[pty-handler] retired pane PTY ${managed.id} still alive after force kill; ownership retained as unverifiable\n`
|
||||
)
|
||||
return
|
||||
}
|
||||
// Why POSIX-only: a SIGKILL that returned success is not proof of death there — the group
|
||||
// probe can degrade to a root-pid kill, leaving the agent running under a shell nobody is
|
||||
// watching. On Windows ConPTY's kill is already force-final and closing its handle twice is
|
||||
// the hazard disposeManagedPty guards against, so the probe above is the whole sweep.
|
||||
if (process.platform !== 'win32') {
|
||||
managed.forceKillSent = false
|
||||
try {
|
||||
this.requestForceKill(managed)
|
||||
} catch {
|
||||
/* Re-probed on the next sweep; a transient failure must not end the escalation. */
|
||||
}
|
||||
}
|
||||
this.armShutdownReapSweep(managed, attemptsRemaining - 1)
|
||||
}, SHUTDOWN_REAP_VERIFY_DELAY_MS)
|
||||
timer.unref?.()
|
||||
managed.reapTimer = timer
|
||||
}
|
||||
|
||||
/** Retire every record for a PTY whose process is proven gone. Shared by the attach probe, the
|
||||
* listing probe and the post-shutdown sweep so the three cannot drift on what "gone" retires. */
|
||||
private reapExitedPty(managed: ManagedPty): void {
|
||||
managed.physicalExit?.markExited()
|
||||
this.releaseRelayIngress(managed)
|
||||
this.flushPtyOutput(managed.id)
|
||||
this.notifyExitListener(managed)
|
||||
this.agentSessionOwners.release(managed.id)
|
||||
disposeManagedPty(managed)
|
||||
this.removePty(managed.id)
|
||||
this.clearPtyFlowState(managed.id)
|
||||
}
|
||||
|
||||
private async sendSignal(params: Record<string, unknown>): Promise<void> {
|
||||
const id = params.id as string
|
||||
const signal = params.signal as string
|
||||
@@ -2101,7 +2236,15 @@ export class PtyHandler {
|
||||
|
||||
private async listProcesses(): Promise<PtyProcessSummary[]> {
|
||||
const results: PtyProcessSummary[] = []
|
||||
for (const [id, managed] of this.ptys) {
|
||||
// Why (SSH-v3 P2 — the host is the authoritative liveness source, so it has to look): this
|
||||
// listing is what publishes `agentSessionOwners`, i.e. "there is a live agent session here you
|
||||
// can adopt". A shell can exit without node-pty's onExit, and an unverified entry advertised
|
||||
// that session forever. Snapshot the map because reaping mutates it.
|
||||
for (const [id, managed] of Array.from(this.ptys)) {
|
||||
if (managed.disposed || (managed.pty.pid && !isProcessAlive(managed.pty.pid))) {
|
||||
this.reapExitedPty(managed)
|
||||
continue
|
||||
}
|
||||
const title =
|
||||
(await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)) || 'shell'
|
||||
results.push({
|
||||
|
||||
@@ -30,7 +30,10 @@ export class RelayAgentHookRuntime {
|
||||
) {
|
||||
this.hookServer = new RelayAgentHookServer({
|
||||
endpointDir: endpointDir ?? endpointDirForRelaySocket(sockPath),
|
||||
forward: (envelope) => publishAgentHookEnvelope(dispatcher, envelope)
|
||||
forward: (envelope) => publishAgentHookEnvelope(dispatcher, envelope),
|
||||
// Why: the PTY handler is the only component that knows which panes still have a client
|
||||
// surface, so it — not the client — decides whether a hook post describes a live pane.
|
||||
isPaneSurfaceRetired: (paneKey) => ptyHandler.isPaneSurfaceRetired(paneKey)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,6 +66,12 @@ export class RelayAgentHookRuntime {
|
||||
}
|
||||
this.pluginOverlay.clearOverlay(paneKey ?? id)
|
||||
})
|
||||
// Why: the exit listener above only fires on proof of process death, which a shell that
|
||||
// survives teardown never produces. Drop the pane's cached status the moment its tab goes, so a
|
||||
// reconnecting client cannot be handed a replay of an agent nobody owns.
|
||||
this.ptyHandler.setSurfaceRetiredListener(({ paneKey }) => {
|
||||
this.hookServer.clearPaneState(paneKey)
|
||||
})
|
||||
}
|
||||
|
||||
private buildPluginEnvironment(context: Parameters<PtyEnvAugmenter>[0]): Record<string, string> {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RETIRED_PANE_SURFACE_LIMIT, RetiredPaneSurfaceRegistry } from './retired-pane-surfaces'
|
||||
|
||||
describe('RetiredPaneSurfaceRegistry', () => {
|
||||
it('records a retired pane and forgets it once the surface exists again', () => {
|
||||
const registry = new RetiredPaneSurfaceRegistry()
|
||||
|
||||
expect(registry.isRetired('pane-a')).toBe(false)
|
||||
registry.retire('pane-a')
|
||||
expect(registry.isRetired('pane-a')).toBe(true)
|
||||
|
||||
registry.restore('pane-a')
|
||||
expect(registry.isRetired('pane-a')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores empty pane keys rather than retiring a catch-all entry', () => {
|
||||
const registry = new RetiredPaneSurfaceRegistry()
|
||||
|
||||
registry.retire('')
|
||||
expect(registry.size).toBe(0)
|
||||
expect(registry.isRetired('')).toBe(false)
|
||||
})
|
||||
|
||||
it('caps growth by evicting the longest-retired pane', () => {
|
||||
const registry = new RetiredPaneSurfaceRegistry()
|
||||
|
||||
for (let index = 0; index < RETIRED_PANE_SURFACE_LIMIT + 25; index += 1) {
|
||||
registry.retire(`pane-${index}`)
|
||||
}
|
||||
|
||||
expect(registry.size).toBe(RETIRED_PANE_SURFACE_LIMIT)
|
||||
expect(registry.isRetired('pane-0')).toBe(false)
|
||||
expect(registry.isRetired('pane-24')).toBe(false)
|
||||
expect(registry.isRetired('pane-25')).toBe(true)
|
||||
expect(registry.isRetired(`pane-${RETIRED_PANE_SURFACE_LIMIT + 24}`)).toBe(true)
|
||||
})
|
||||
|
||||
it('re-retiring refreshes recency so an active pane is not evicted first', () => {
|
||||
const registry = new RetiredPaneSurfaceRegistry()
|
||||
|
||||
registry.retire('pane-oldest')
|
||||
for (let index = 0; index < RETIRED_PANE_SURFACE_LIMIT - 1; index += 1) {
|
||||
registry.retire(`pane-${index}`)
|
||||
}
|
||||
registry.retire('pane-oldest')
|
||||
registry.retire('pane-overflow')
|
||||
|
||||
expect(registry.isRetired('pane-oldest')).toBe(true)
|
||||
expect(registry.isRetired('pane-0')).toBe(false)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user