diff --git a/config/oxlint-anti-slop.json b/config/oxlint-anti-slop.json index 68343fd62025..8024edced0ef 100644 --- a/config/oxlint-anti-slop.json +++ b/config/oxlint-anti-slop.json @@ -59,8 +59,10 @@ // mock-descendant-sweep.ts (daemon and relay) is a test-only side-effect shim: its whole body // is one vi.mock that keeps mock PTY PIDs away from the host process table, and it exists so // 60 suites do not each inline the same hoisted factory. It is never imported by product code. + // daemon-init-reset-modules-pin.ts re-applies those factories after resetModules so bun does + // not load the real ipc/pty barrel; also never imported by product code. { - "files": ["**/mock-descendant-sweep.ts"], + "files": ["**/mock-descendant-sweep.ts", "**/daemon-init-reset-modules-pin.ts"], "rules": { "anti-slop/no-module-mocking": "off" } diff --git a/config/scripts/orcad-e2e-routing.test.mjs b/config/scripts/orcad-e2e-routing.test.mjs index 018eda14a755..63fe127ed686 100644 --- a/config/scripts/orcad-e2e-routing.test.mjs +++ b/config/scripts/orcad-e2e-routing.test.mjs @@ -125,7 +125,7 @@ it('routes managed target-owner guards and menus to the real launch regression', 'tests/e2e/helpers/orcad-convert-flow.ts', 'tests/e2e/helpers/orcad-convert-host.ts', 'tests/e2e/helpers/orcad-upgrade-profile.ts', - 'tests/e2e/helpers/docker-ssh-relay-target.ts' + 'tests/e2e/helpers/docker-ssh-host-target.ts' ], ORCAD_OPEN_IN_OWNER_E2E_SPEC ) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 6addbe03624a..d634a5f378c0 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -144,7 +144,7 @@ export const PR_E2E_SOURCE_ROUTES = [ id: 'ssh.orcad-browser-drop-owner', specs: ['tests/e2e/ssh-orcad-browser-drop-owner.spec.ts'], matches: (file) => - /^tests\/e2e\/helpers\/(?:orcad-convert-(?:flow|host)|orcad-upgrade-profile|docker-ssh-relay-target|browser-split-(?:guest-probes|page-server))\.ts$/.test( + /^tests\/e2e\/helpers\/(?:orcad-managed-workspace|orcad-convert-(?:flow|host)|orcad-upgrade-profile|docker-ssh-host-target|browser-split-(?:guest-probes|page-server))\.ts$/.test( file ) || (isProductSource(file) && @@ -156,7 +156,7 @@ export const PR_E2E_SOURCE_ROUTES = [ id: 'ssh.orcad-open-in-owner', specs: ['tests/e2e/ssh-orcad-open-in-owner.spec.ts'], matches: (file) => - /^tests\/e2e\/helpers\/(?:orcad-convert-(?:flow|host)|orcad-upgrade-profile|docker-ssh-relay-target)\.ts$/.test( + /^tests\/e2e\/helpers\/(?:orcad-managed-workspace|orcad-convert-(?:flow|host)|orcad-upgrade-profile|docker-ssh-host-target)\.ts$/.test( file ) || (isProductSource(file) && @@ -168,7 +168,7 @@ export const PR_E2E_SOURCE_ROUTES = [ id: 'ssh.orcad-terminal-root-owner', specs: ['tests/e2e/ssh-orcad-terminal-root-owner.spec.ts'], matches: (file) => - /^tests\/e2e\/helpers\/(?:terminal-workspace-root-link|orcad-convert-(?:flow|host)|orcad-upgrade-profile|docker-ssh-relay-target)\.ts$/.test( + /^tests\/e2e\/helpers\/(?:terminal-workspace-root-link|orcad-managed-workspace|orcad-convert-(?:flow|host)|orcad-upgrade-profile|docker-ssh-host-target)\.ts$/.test( file ) || (isProductSource(file) && @@ -208,7 +208,7 @@ export const PR_E2E_SOURCE_ROUTES = [ specs: ['tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts'], matches: (file) => file === 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts' || - /^tests\/e2e\/helpers\/docker-ssh-relay-(?:image|target)\.ts$/.test(file) || + /^tests\/e2e\/helpers\/docker-ssh-host-(?:image|target)\.ts$/.test(file) || (isProductSource(file) && /^src\/main\/(?:browser\/(?:ssh-browser-network-execution-route|browser-network-deferred-socket|browser-network-execution-route|system-ssh-socks-client-socket)|ssh\/system-ssh-dynamic-forward-process)\.ts$/.test( file diff --git a/config/scripts/ssh-browser-e2e-routing.test.mjs b/config/scripts/ssh-browser-e2e-routing.test.mjs index e0ffd2f8ad92..10d18e766314 100644 --- a/config/scripts/ssh-browser-e2e-routing.test.mjs +++ b/config/scripts/ssh-browser-e2e-routing.test.mjs @@ -32,13 +32,13 @@ it('executes both Docker network routes in a Node job with their opt-in enabled' 'src/main/browser/browser-network-execution-route.ts', 'src/main/browser/system-ssh-socks-client-socket.ts', 'src/main/ssh/system-ssh-dynamic-forward-process.ts', - 'tests/e2e/helpers/docker-ssh-relay-target.ts', - 'tests/e2e/helpers/docker-ssh-relay-image.ts' + 'tests/e2e/helpers/docker-ssh-host-target.ts', + 'tests/e2e/helpers/docker-ssh-host-image.ts' ]) { expect(selectPrE2eSpecs([changed])).toContain(spec) } expect(selectPrE2eSpecs(['src/renderer/src/components/Unrelated.tsx'])).not.toContain(spec) - expect(selectPrE2eSpecs(['tests/e2e/helpers/docker-ssh-relay-terminal-tabs.ts'])).not.toContain( + expect(selectPrE2eSpecs(['tests/e2e/helpers/docker-ssh-host-terminal-tabs.ts'])).not.toContain( spec ) }) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 9c0e49f6455c..8330e78c658a 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 103m + + downloads: 104m @@ -15,7 +15,7 @@ downloads downloads - 103m - 103m + 104m + 104m diff --git a/docs/audits/SYNC-NOTES-2026-10-11d.md b/docs/audits/SYNC-NOTES-2026-10-11d.md new file mode 100644 index 000000000000..f1b478b7b602 --- /dev/null +++ b/docs/audits/SYNC-NOTES-2026-10-11d.md @@ -0,0 +1,73 @@ +# Sync notes: stablyai/orca `main` into `fork-main` (2026-10-11d) + +Branch: `sync/upstream-2026-10-11d` from `origin/fork-main` (`cc1230a91d3`, PR #19). +Merge: `upstream/main` @ `b4aec8a8961` (`git merge --no-ff`). +Merge commit: `1e2fb380435`. Merge-base was `4468a5375f2`. + +This is a follow-up to PR #19 (`sync/upstream-2026-10-11c`). Do not merge this +PR. Do not cut tags. Do not touch Coolify. Karl GO still stands to cut +`v1.4.224-0` after this PR merges. + +## Policy + +- Keep fork features listed in `config/fork-features.yml`. +- Re-apply #15/#17/#18/#19 resolutions where upstream re-touched the same files. +- Upstream wins for files the fork does not customize. +- Same relay-conflict discipline as 11c: SSH docker fixtures stay on the + host names (`docker-ssh-host`); no leftover `docker-ssh-relay` paths. + +## Upstream commits (5, oldest first) + +- `bdc0921718b` test(e2e): one posixShellQuote; docker-ssh-host fixture names (#27449) +- `10d51086716` feat(runtime): accept a declared spawn intent from paired clients (#27452) +- `c9a9ad33b8e` test(e2e): one managed-host setup path and skip gate for the ssh-orcad specs (#27465) +- `3b49ee40db4` Update README downloads badge +- `b4aec8a8961` fix(sidebar): stop a removed folder workspace from leaving a blank row (#26376) + +## Conflict resolutions + +None. `git merge --no-ff upstream/main` auto-merged five overlapping files. +Verified each auto-merge kept fork deltas and added only the upstream +`spawnIntent` / `terminal.spawn-intent.v1` pieces: + +- `src/shared/protocol-version.ts` — kept + `SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY` and + `...FORK_TERMINAL_AND_AGENT_LAUNCH_RUNTIME_CAPABILITIES`; added + `TERMINAL_SPAWN_INTENT_RUNTIME_CAPABILITY` after paired-parking. +- `src/shared/rpc-contract/agent-session-params.ts` — optional + `spawnIntent: SpawnIntentParam`. +- `src/shared/agent-session-host-authority.ts` — optional `spawnIntent` on + `RuntimeEnsureAgentSessionRequest`. +- `src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts` — + forward `params.spawnIntent` into ensure. +- `src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts` — + forward `request.spawnIntent` into create. + +## Auto-merged fork semantics kept + +- `PET_SPEAK_METHODS` in `src/main/runtime/rpc/methods/index.ts` +- `pet.speak.subscribe` as a `'none'` relayed stream carrier +- Fork `orcad-host-adapters.ts` extraction +- Automation `agentFlags` / `activeView` (untouched by these five commits) + +## CI follow-up + +- Shard 3/5 bun: `daemon-init-provider-installation` first test + (`re-binds listeners after the first daemon provider is installed`) + finished in ~2.3s with `setLocalPtyProviderMock` at 0 calls. bun's first + import after `vi.resetModules()` missed the hoisted `../ipc/pty` mock, so + the real PTY barrel loaded. Re-pin that mock (plus `node:fs` / `node:net`) + after reset and load `ipc/pty` before `daemon-provider-init`. The `vi.doMock` + calls live in test-only `daemon-init-reset-modules-pin.ts` (anti-slop + `no-module-mocking` is file-scoped off there, like `mock-descendant-sweep.ts`). + +## Light local checks (this box, via `heavy-run`, glibc Node 22) + +- `pnpm install --ignore-scripts` then `pnpm --filter @orca/process-host build`. +- `tsc --noEmit -p config/tsconfig.tc.cli.json`: clean. +- vitest `--config config/vitest.config.ts --maxWorkers=1`: + - spawn-intent + host-spawn-admission + rpc errors: 73 passed. + - worktree / folder-workspace metadata: 25 passed. + - e2e routing + global-setup-cli-artifact: 15 passed. + - pet-speak replay + mobile method routing: 4 passed. +- Full `pnpm tc` (node+web) and electron-builder not run locally. diff --git a/src/main/daemon/daemon-init-dependency-mocks.ts b/src/main/daemon/daemon-init-dependency-mocks.ts index 6d541aad52c4..46ba84934a79 100644 --- a/src/main/daemon/daemon-init-dependency-mocks.ts +++ b/src/main/daemon/daemon-init-dependency-mocks.ts @@ -166,6 +166,10 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { // Why: bun aliases `fs` to `node:fs`; doc-preview-file-access reads constants at import. constants: { O_NOFOLLOW: 0, O_NONBLOCK: 0 }, mkdirSync: vi.fn<(...args: unknown[]) => void>(), + chmodSync: vi.fn<(...args: unknown[]) => void>(), + lstatSync: vi.fn(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }), existsSync: (p: string) => probeSocketExistsMock(p) || p.includes('.pid'), unlinkSync: unlinkSyncMock, readFileSync: readFileSyncMock, diff --git a/src/main/daemon/daemon-init-fresh-import.ts b/src/main/daemon/daemon-init-fresh-import.ts index d63815e88ee6..ecb903b86845 100644 --- a/src/main/daemon/daemon-init-fresh-import.ts +++ b/src/main/daemon/daemon-init-fresh-import.ts @@ -1,9 +1,18 @@ import { vi } from 'vitest' import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' import type { DaemonInitMockState } from './daemon-init-test-harness' +import { + pinDaemonInitMocksAfterReset, + type DaemonInitPinFactories +} from './daemon-init-reset-modules-pin' + +export type { DaemonInitPinFactories } /** Resets every mock plus the module registry, then re-imports daemon-init so its module-level spawner/adapter/restartInFlight start fresh. */ -export async function importFreshDaemonInit(state: DaemonInitMockState) { +export async function importFreshDaemonInit( + state: DaemonInitMockState, + pinFactories?: DaemonInitPinFactories +) { const { getPathMock, getAppPathMock, @@ -124,12 +133,13 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) { exit: () => {}, getAppMetrics: () => [] } as unknown as AppEnvironment) + if (pinFactories) { + await pinDaemonInitMocksAfterReset(pinFactories) + } // Why: import after resetModules so module-level spawner/adapter/restartInFlight start fresh — needed to test first-init and the coalescer. - const [init, restart, providerState, cleanup] = await Promise.all([ - import('./daemon-provider-init'), - import('./daemon-provider-restart'), - import('./daemon-provider-state'), - import('./daemon-protocol-cleanup') - ]) + const init = await import('./daemon-provider-init') + const restart = await import('./daemon-provider-restart') + const providerState = await import('./daemon-provider-state') + const cleanup = await import('./daemon-protocol-cleanup') return { ...init, ...restart, ...providerState, ...cleanup } } diff --git a/src/main/daemon/daemon-init-provider-installation.test.ts b/src/main/daemon/daemon-init-provider-installation.test.ts index 2de5fd5be84e..9fbb0524a121 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -31,10 +31,12 @@ const { ) vi.mock('fs', () => moduleFactories.fs()) +vi.mock('node:fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) ) vi.mock('net', () => moduleFactories.net()) +vi.mock('node:net', () => moduleFactories.net()) vi.mock('./daemon-health', () => moduleFactories.daemonHealth()) vi.mock('./daemon-pid-identity', () => moduleFactories.daemonPidIdentity()) vi.mock('./daemon-tcc-attribution', () => moduleFactories.daemonTccAttribution()) diff --git a/src/main/daemon/daemon-init-reset-modules-pin.ts b/src/main/daemon/daemon-init-reset-modules-pin.ts new file mode 100644 index 000000000000..b25b0fdb3453 --- /dev/null +++ b/src/main/daemon/daemon-init-reset-modules-pin.ts @@ -0,0 +1,27 @@ +import { vi } from 'vitest' + +/** Factories the test file's hoisted `vi.mock` calls already registered; re-applied after resetModules. */ +export type DaemonInitPinFactories = { + fs: () => object + net: () => object + ipcPty: () => object + daemonSpawner: () => object + daemonPtyAdapter: () => object +} + +/** + * bun's first import after resetModules can miss hoisted vi.mock factories (real ipc/pty + * barrel loads, setLocalPtyProviderMock stays at 0). Re-pin, then load ipc/pty first. + */ +export async function pinDaemonInitMocksAfterReset( + factories: DaemonInitPinFactories +): Promise { + vi.doMock('fs', factories.fs) + vi.doMock('node:fs', factories.fs) + vi.doMock('net', factories.net) + vi.doMock('node:net', factories.net) + vi.doMock('../ipc/pty', factories.ipcPty) + vi.doMock('./daemon-spawner', factories.daemonSpawner) + vi.doMock('./daemon-pty-adapter', factories.daemonPtyAdapter) + await import('../ipc/pty') +} diff --git a/src/main/daemon/daemon-init-test-harness.ts b/src/main/daemon/daemon-init-test-harness.ts index 6060ed9b759d..2e7a4f469154 100644 --- a/src/main/daemon/daemon-init-test-harness.ts +++ b/src/main/daemon/daemon-init-test-harness.ts @@ -222,11 +222,19 @@ export function createDaemonInitMocks(): DaemonInitMocks { }) } + const moduleFactories = createDaemonInitModuleFactories(state) return { ...state, ...createNetConnectStubs(state), mockConnectedAdoptionClientOnce, - importFresh: () => importFreshDaemonInit(state), - moduleFactories: createDaemonInitModuleFactories(state) + importFresh: () => + importFreshDaemonInit(state, { + fs: moduleFactories.fs, + net: moduleFactories.net, + ipcPty: moduleFactories.ipcPty, + daemonSpawner: moduleFactories.daemonSpawner, + daemonPtyAdapter: moduleFactories.daemonPtyAdapter + }), + moduleFactories } } diff --git a/src/main/daemon/daemon-pty-adapter-spawn-intent.test.ts b/src/main/daemon/daemon-pty-adapter-spawn-intent.test.ts index b50c57f89401..c5271789627b 100644 --- a/src/main/daemon/daemon-pty-adapter-spawn-intent.test.ts +++ b/src/main/daemon/daemon-pty-adapter-spawn-intent.test.ts @@ -7,6 +7,22 @@ function createPayloads(requestSpy: { mock: { calls: unknown[][] } }): unknown[] return requestSpy.mock.calls.filter(([type]) => type === 'createOrAttach').map(([, p]) => p) } +const CLAIM = { + claim: { + digestVersion: 1 as const, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' as const + }, + surface: { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_intent_claim' + } +} + describe('daemon spawn intent wire', () => { it('forwards a declared intent on create and omits it on attach-only', async () => { const ensureConnectedSpy = vi @@ -40,6 +56,69 @@ describe('daemon spawn intent wire', () => { } }) + it('never forwards interactive for a claimed resume, which an older daemon would not gate', async () => { + const ensureConnectedSpy = vi + .spyOn(DaemonClient.prototype, 'ensureConnected') + .mockResolvedValue() + const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({ + isNew: true, + pid: null, + shellState: 'unsupported', + snapshot: null + }) + const adapter = new DaemonPtyAdapter({ + socketPath: '/tmp/orca-intent-claim.sock', + tokenPath: '/tmp/orca-intent-claim.token' + }) + try { + await adapter + .spawn({ + sessionId: 'claimed', + cols: 80, + rows: 24, + spawnIntent: 'interactive', + agentSessionEnsure: CLAIM + }) + .catch(() => {}) + await adapter.spawn({ sessionId: 'shell', cols: 80, rows: 24, spawnIntent: 'interactive' }) + const [claimed, shell] = createPayloads(requestSpy) + expect(claimed).toHaveProperty('spawnIntent', 'automatic-restore') + expect(shell).toHaveProperty('spawnIntent', 'interactive') + } finally { + adapter.dispose() + requestSpy.mockRestore() + ensureConnectedSpy.mockRestore() + } + }) + + it('passes a deferral back as-is instead of treating the daemon as gone', async () => { + const ensureConnectedSpy = vi + .spyOn(DaemonClient.prototype, 'ensureConnected') + .mockResolvedValue() + const requestSpy = vi + .spyOn(DaemonClient.prototype, 'request') + .mockRejectedValue(new Error('terminal_spawn_deferred:paused')) + const adapter = new DaemonPtyAdapter({ + socketPath: '/tmp/orca-intent-deferred.sock', + tokenPath: '/tmp/orca-intent-deferred.token' + }) + try { + await expect( + adapter.spawn({ + sessionId: 'deferred', + cols: 80, + rows: 24, + spawnIntent: 'automatic-restore' + }) + ).rejects.toThrow('terminal_spawn_deferred:paused') + expect(createPayloads(requestSpy)).toHaveLength(1) + } finally { + adapter.dispose() + requestSpy.mockRestore() + ensureConnectedSpy.mockRestore() + } + }) + it('reads the launcher flag and observes for anything else', () => { expect(readTerminalSpawnAdmissionArg(['node', 'entry', '--spawn-admission', 'enforce'])).toBe( 'enforce' diff --git a/src/main/daemon/daemon-pty-spawn-request.ts b/src/main/daemon/daemon-pty-spawn-request.ts index 11f8b397dd6c..beebb61bca85 100644 --- a/src/main/daemon/daemon-pty-spawn-request.ts +++ b/src/main/daemon/daemon-pty-spawn-request.ts @@ -1,3 +1,4 @@ +import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { ColdRestorePayload } from './cold-restore-payload-cache' import type { DaemonAuditObservation } from './daemon-audit-classifier' import { @@ -18,6 +19,11 @@ import { import type { CreateOrAttachResult, GetSnapshotResult } from './types' import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' +/** A daemon kept running across an update trusts `interactive` even for a claimed resume. */ +function forwardedSpawnIntent(intent: SpawnIntent, claim: unknown): SpawnIntent { + return intent === 'interactive' && claim !== undefined ? 'automatic-restore' : intent +} + export type DaemonPtySpawnContext = { opts: PtySpawnOptions operation: PendingDaemonSpawnOperation @@ -132,7 +138,9 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { ...(this.supportsStartupIngress && !context.attachOnly && opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), - ...(!context.attachOnly && opts.spawnIntent ? { spawnIntent: opts.spawnIntent } : {}), + ...(!context.attachOnly && opts.spawnIntent + ? { spawnIntent: forwardedSpawnIntent(opts.spawnIntent, opts.agentSessionEnsure) } + : {}), ...(!context.attachOnly && opts.agentSessionEnsure ? { agentSessionEnsure: opts.agentSessionEnsure } : {}) diff --git a/src/main/folder-workspace-meta-presence.ts b/src/main/folder-workspace-meta-presence.ts new file mode 100644 index 000000000000..8da412ee7fc4 --- /dev/null +++ b/src/main/folder-workspace-meta-presence.ts @@ -0,0 +1,43 @@ +import type { ExecutionHostId } from '../shared/execution-host' +import { isFolderRepo } from '../shared/repo-kind' +import type { Repo } from '../shared/repo-types' +import type { WorktreeMeta } from '../shared/worktree/meta-types' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, getRepoIdFromWorktreeId } from '../shared/worktree/id' + +type FolderWorkspaceMetaReader = { + getRepos: () => Repo[] + getWorktreeMeta: (worktreeId: string) => WorktreeMeta | undefined + getWorktreeMetaForHost?: ( + worktreeId: string, + executionHostId: ExecutionHostId + ) => WorktreeMeta | undefined +} + +/** + * Why (#22712): a folder workspace instance exists only as its meta row, so a metadata write that + * lands after its removal would mint a blank row the sidebar lists under the project name. + */ +export function isRemovedFolderWorkspaceInstance( + store: FolderWorkspaceMetaReader, + worktreeId: string, + executionHostId?: ExecutionHostId +): boolean { + const repoId = getRepoIdFromWorktreeId(worktreeId) + // Same-id owners can sit at different paths on different hosts, so match any folder owner. + const isFolderInstance = store + .getRepos() + .some( + (repo) => + repo.id === repoId && + isFolderRepo(repo) && + worktreeId.startsWith(`${repo.id}::${repo.path}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) + ) + if (!isFolderInstance) { + return false + } + const meta = + executionHostId && store.getWorktreeMetaForHost + ? store.getWorktreeMetaForHost(worktreeId, executionHostId) + : store.getWorktreeMeta(worktreeId) + return !meta +} diff --git a/src/main/ipc/pty/ipc/spawn-options-spawn-intent.test.ts b/src/main/ipc/pty/ipc/spawn-options-spawn-intent.test.ts new file mode 100644 index 000000000000..be12e27230b3 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-options-spawn-intent.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { buildPtyIpcSpawnOptions } from './spawn-options' +import { createPtyIpcSpawnState } from './spawn-state' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +async function resolveSpawnIntent(args: PtySpawnIpcArgs): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: buildPtyIpcSpawnOptions only reads the members stubbed here; the rest belong to later spawn stages this test never runs. + const deps = { + transitionSpawnHiddenRendererPtyDeliveryState: vi.fn(), + syncPtyBackgroundedDelivery: vi.fn(), + sendPtySpawnedToRenderer: vi.fn(), + getSettings: () => getDefaultSettings('/tmp'), + runtime: { registerPreAllocatedHandleForPty: vi.fn() } + } as unknown as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, args) + ctx.env = {} + await buildPtyIpcSpawnOptions(ctx) + return ctx.spawnOptions.spawnIntent +} + +const PANE = { cols: 80, rows: 24 } + +describe('renderer pty spawn: declared spawn intent', () => { + it('hands a known intent to the provider', async () => { + await expect(resolveSpawnIntent({ ...PANE, spawnIntent: 'user-resume' })).resolves.toBe( + 'user-resume' + ) + }) + + it('treats a missing or unknown intent as undeclared', async () => { + await expect(resolveSpawnIntent(PANE)).resolves.toBeUndefined() + await expect(resolveSpawnIntent({ ...PANE, spawnIntent: 'urgent' })).resolves.toBeUndefined() + await expect( + resolveSpawnIntent({ ...PANE, spawnIntent: 'interactive' }) + ).resolves.toBeUndefined() + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 55cce7903408..726d53e1ee36 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -1,6 +1,7 @@ import { getAppEnvironment } from '../../../../shared/app-environment' import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir' import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isRestoreSpawnIntent } from '../../../../shared/host-spawn-admission' import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' import { PI_PROCESS_OWNER_ENV_KEYS } from '../../../pty/pi-process-owner-env' import { CODEX_HOME_ENV_KEYS } from '../host-env/codex-home' @@ -116,6 +117,9 @@ export async function buildPtyIpcSpawnOptions( if (args.worktreeId !== undefined) { ctx.spawnOptions.worktreeId = args.worktreeId } + if (isRestoreSpawnIntent(args.spawnIntent)) { + ctx.spawnOptions.spawnIntent = args.spawnIntent + } const trustWrite = applyAgentWorkspaceTrustToSpawn({ launchAgent: args.launchAgent, worktreeId: args.worktreeId, diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index a80a7dbf7706..6c454bf1a954 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -56,6 +56,8 @@ export type PtySpawnIpcArgs = { replacesPtyId?: string // Untyped on purpose: parseTerminalPanePlacement is the one check, and drops what it rejects. placement?: unknown + // Untyped on purpose: isRestoreSpawnIntent is the one check; anything else is undeclared. + spawnIntent?: unknown // Why: renderer-threaded launch telemetry (telemetry-plan.md§Agent launch semantics); loosely typed because the main-side schema validator is the single enforcement point. telemetry?: { agent_kind?: unknown diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index ac05460961e0..9988c3d539d7 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -227,6 +227,9 @@ export async function buildRuntimePtySpawnOptions( if (!ctx.preAdoptedStablePane && args.agentSessionCreateOperationId) { ctx.spawnOptions.agentSessionCreateOperationId = args.agentSessionCreateOperationId } + if (!ctx.preAdoptedStablePane && args.spawnIntent) { + ctx.spawnOptions.spawnIntent = args.spawnIntent + } if (args.signal) { ctx.spawnOptions.signal = args.signal } diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index 72e9da172a92..b586de7c0de1 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -80,15 +80,31 @@ function makeDeps(): PtyRuntimeControllerDeps { } /** Runs the preflight and option build the way `spawnPtyFromRuntimeController` sequences them. */ -async function resolveSpawnShell(shellOverride: string | undefined): Promise { - const args: RuntimePtySpawnArgs = { cols: 120, rows: 40, shellOverride } +async function resolveSpawnOptions( + args: RuntimePtySpawnArgs +): Promise['spawnOptions']> { const ctx = createRuntimePtySpawnState(makeDeps(), args) await prepareRuntimePtySpawn(ctx) await buildRuntimePtySpawnOptions(ctx) ctx.finishTerminalInstall() - return ctx.spawnOptions.shellOverride + return ctx.spawnOptions +} + +async function resolveSpawnShell(shellOverride: string | undefined): Promise { + return (await resolveSpawnOptions({ cols: 120, rows: 40, shellOverride })).shellOverride } +describe('runtime pty spawn options: declared spawn intent', () => { + it("hands a paired client's intent to the provider and omits it when undeclared", async () => { + await expect( + resolveSpawnOptions({ cols: 120, rows: 40, spawnIntent: 'automatic-restore' }) + ).resolves.toMatchObject({ spawnIntent: 'automatic-restore' }) + await expect(resolveSpawnOptions({ cols: 120, rows: 40 })).resolves.not.toHaveProperty( + 'spawnIntent' + ) + }) +}) + /** * Behavioural twin of `pty-spawn-shell-override-parity.test.ts`: a local Windows runtime spawn * (`terminal create --shell`, headless serve) must hand the caller's shell to the provider, not diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index aeaa36a45579..888a51658f5e 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -1,3 +1,4 @@ +import type { SpawnIntent } from '../../../../shared/host-spawn-admission' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types' import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' @@ -119,6 +120,7 @@ export type RuntimePtySpawnArgs = { surface: AgentSessionSurfaceBinding } agentSessionCreateOperationId?: string + spawnIntent?: SpawnIntent signal?: AbortSignal refuseSleptWorktree?: boolean onPtySpawnCommitted?: () => void diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 617ca05f42a2..e0e7b6cc0e4a 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -7,7 +7,9 @@ import { getPullRequestPushTargetMock, gitExecFileAsyncMock } from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { handlers, harnessRepo, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { mergeWorktreeMetaForWrite } from '../persistence/loading-store/worktree-meta-write-normalization' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import { ORIGIN_HEAD_COMPONENT, ORIGIN_REMOTE_URL, @@ -254,6 +256,81 @@ describe('registerWorktreeHandlers', () => { expect(runtimeStub.notifyWorktreesChangedForRemoteClients).toHaveBeenCalledWith('repo-1') }) + describe('a folder workspace removed while its terminal is still bound (#22712)', () => { + const folderRepo = { ...harnessRepo, kind: 'folder' as const } + const workspaceId = `${harnessRepo.id}::${harnessRepo.path}::workspace:0b6f6d2e-1c1d-4c55-9a51-3f1c0f4f2a10` + + // Backs the store mocks with one meta table so writes, removal and listing see the same rows. + function useFolderMetaTable(): Record { + const table: Record = {} + store.getRepo.mockReturnValue(folderRepo) + store.getRepos.mockReturnValue([folderRepo]) + store.getWorktreeMeta.mockImplementation((id: string) => table[id]) + store.getAllWorktreeMeta.mockImplementation(() => table) + store.setWorktreeMeta.mockImplementation((id: string, meta: Partial) => { + table[id] = mergeWorktreeMetaForWrite(table[id], meta) + return table[id] + }) + store.removeWorktreeMeta.mockImplementation((id: string) => { + delete table[id] + }) + table[workspaceId] = mergeWorktreeMetaForWrite(undefined, { + displayName: 'Feature work', + createdAt: 1 + }) + return table + } + + it('does not bring the workspace back as a blank row when the terminal reports activity', async () => { + const table = useFolderMetaTable() + + await handlers['worktrees:remove'](null, { worktreeId: workspaceId }) + expect(table[workspaceId]).toBeUndefined() + // The renderer still lists the row until its teardown runs, so a PTY reattach in that + // window stamps activity on the removed id (bumpWorktreeActivity -> updateMeta). + handlers['worktrees:updateMeta'](null, { + worktreeId: workspaceId, + updates: { lastActivityAt: 2 } + }) + handlers['worktrees:updateMeta'](null, { + worktreeId: workspaceId, + executionHostId: 'local', + updates: { isUnread: true } + }) + + const rows = await handlers['worktrees:list'](null, { repoId: folderRepo.id }) + expect(rows).not.toContainEqual(expect.objectContaining({ id: workspaceId })) + expect(table[workspaceId]).toBeUndefined() + }) + + it('refuses the write when a same-id owner on another host sits at a different path', () => { + const table = useFolderMetaTable() + delete table[workspaceId] + const otherOwner = { ...folderRepo, path: '/elsewhere/repo' } + store.getRepo.mockReturnValue(otherOwner) + store.getRepos.mockReturnValue([otherOwner, folderRepo]) + + handlers['worktrees:updateMeta'](null, { + worktreeId: workspaceId, + executionHostId: 'ssh:build-box', + updates: { lastActivityAt: 2 } + }) + + expect(table[workspaceId]).toBeUndefined() + }) + + it('still persists metadata updates for a folder workspace that exists', () => { + const table = useFolderMetaTable() + + handlers['worktrees:updateMeta'](null, { + worktreeId: workspaceId, + updates: { lastActivityAt: 2 } + }) + + expect(table[workspaceId]).toMatchObject({ displayName: 'Feature work', lastActivityAt: 2 }) + }) + }) + it('persists display-name provenance at the host boundary', () => { store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) diff --git a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts index c0600107a18e..0fe85e098842 100644 --- a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts +++ b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts @@ -13,6 +13,7 @@ import { parseWorktreeId } from '../../worktree-logic' import { planWorktreeSortOrderUpdates } from '../../../../shared/worktree/sort-order-update' import { readBranchRenameFailureOutputForDisplay } from '../../../agent-hooks/branch-rename-failure-output' import { normalizeLinkedWorkItemFields } from '../ipc-context-schemas' +import { isRemovedFolderWorkspaceInstance } from '../../../folder-workspace-meta-presence' import { listDesktopLineageForHost } from './host-lineage-listing' import type { WorktreeIpcContext } from '../worktree-ipc-context' @@ -47,6 +48,10 @@ export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): v } : validatedUpdates const sanitizedUpdates = stripOrcaProvenanceMetaUpdates(updates) + // Why: a late write from a terminal still bound to a removed folder workspace (#22712). + if (isRemovedFolderWorkspaceInstance(store, args.worktreeId, executionHostId)) { + return null + } const meta = executionHostId ? store.setWorktreeMetaForHost(args.worktreeId, executionHostId, sanitizedUpdates) : store.setWorktreeMeta(args.worktreeId, sanitizedUpdates) diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 97fdc3de949d..42c5782c4113 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -1,4 +1,5 @@ import type { TerminalCreateOptions } from './runtime-terminal-contracts' +import type { SpawnIntent } from '../../shared/host-spawn-admission' export type { TerminalCreateOptions } export type { Worktree } from '../../shared/worktree/types' @@ -40,12 +41,14 @@ export const BACKGROUND_TERMINAL_SPAWN_FLAGS = { persistHostSessionBinding: true } as const -/** Caller-scoped spawn guards: abort when the caller disconnects; never wake a slept worktree. */ -export function callerSpawnGuards( - opts: Pick -): { signal?: AbortSignal; refuseSleptWorktree?: true } { +/** Caller-scoped spawn fields: abort when the caller disconnects; never wake a slept worktree; + * the caller's declared fork intent, which the host's gate validates. */ +export function callerSpawnScope( + opts: Pick +): { signal?: AbortSignal; refuseSleptWorktree?: true; spawnIntent?: SpawnIntent } { return { ...(opts.signal ? { signal: opts.signal } : {}), - ...(opts.refuseSleptWorktree ? { refuseSleptWorktree: true } : {}) + ...(opts.refuseSleptWorktree ? { refuseSleptWorktree: true } : {}), + ...(opts.spawnIntent ? { spawnIntent: opts.spawnIntent } : {}) } } diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 03d7583e6c84..35c4667720da 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -164,7 +164,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate ...(launchOpts.agentSessionCreateOperationId ? { agentSessionCreateOperationId: launchOpts.agentSessionCreateOperationId } : {}), - ...dependencies.callerSpawnGuards(launchOpts), + ...dependencies.callerSpawnScope(launchOpts), ...(launchOpts.onPtySpawnCommitted ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}), diff --git a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts index 4159070a4fe4..6443b5e50b09 100644 --- a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts +++ b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts @@ -171,6 +171,7 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim tabId: request.placement?.tabId, leafId: request.placement?.leafId, agentSessionClaim: claim, + ...(request.spawnIntent ? { spawnIntent: request.spawnIntent } : {}), signal: _caller.signal }) return { diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts index fc1e83abdfad..d568281d00e9 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts @@ -362,6 +362,65 @@ describe('OrcaRuntimeService', () => { ) }) + it("forwards a paired client's declared spawn intent to the fork gate", async () => { + const spawn = vi.fn(async (options) => ({ + id: 'pty-intent', + agentSessionEnsure: { + disposition: 'created' as const, + owner: { + claim: options.agentSessionEnsure!.claim, + generation: 'generation-1', + phase: 'live' as const, + ptyId: 'pty-intent', + surface: options.agentSessionEnsure!.surface + } + } + })) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.ensureAgentSession({ + kind: 'explicit', + worktree: `id:${TEST_WORKTREE_ID}`, + agent: 'claude', + providerSession: { key: 'session_id', id: 'provider-session-intent' }, + spawnIntent: 'automatic-restore' + }) + expect(spawn).toHaveBeenLastCalledWith( + expect.objectContaining({ spawnIntent: 'automatic-restore' }) + ) + + await runtime.ensureAgentSession({ + kind: 'explicit', + worktree: `id:${TEST_WORKTREE_ID}`, + agent: 'claude', + providerSession: { key: 'session_id', id: 'provider-session-undeclared' } + }) + expect(spawn.mock.lastCall?.[0]).not.toHaveProperty('spawnIntent') + }) + + it('passes a deferred fork back unchanged so the client can keep the pane sleeping', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockRejectedValue(new Error('terminal_spawn_deferred:live_cap')), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + command: 'claude --resume provider-session-1', + spawnIntent: 'automatic-restore' + }) + ).rejects.toThrow('terminal_spawn_deferred:live_cap') + }) + it('builds structured fresh drafts with supported launch preferences on the host', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent-draft' }) const runtime = new OrcaRuntimeService({ diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index fa9106dc2875..1030b009579b 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -30,6 +30,26 @@ class LineageError extends Error { } describe('mapRuntimeError', () => { + it('names a deferred fork with its own code and reason', () => { + expect( + mapRuntimeError( + 'req_1', + { runtimeId: 'runtime-1' }, + new Error('terminal_spawn_deferred:paused') + ) + ).toMatchObject({ + ok: false, + error: { + code: 'terminal_spawn_deferred', + message: 'terminal_spawn_deferred:paused', + data: { reason: 'paused' } + } + }) + expect( + mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, new Error('terminal_spawn_deferred:x')) + ).toMatchObject({ error: { code: 'runtime_error' } }) + }) + it('preserves the stable skill failure category and retryability across RPC', () => { expect( mapRuntimeError( diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 3c8dd53f5c22..c845f87526dc 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -35,6 +35,10 @@ import { AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE } from '../../../shared/agent-launc import { AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE } from '../../../shared/agent-launch-session-already-exists' import { AGENT_LAUNCH_TAB_CLOSED_CODE } from '../../../shared/agent-launch-tab-closed' import { AGENT_LAUNCH_TARGET_FORBIDDEN_CODE } from '../../../shared/agent-launch-target-forbidden' +import { + parseTerminalSpawnDeferral, + TERMINAL_SPAWN_DEFERRED_CODE +} from '../../../shared/host-spawn-admission' export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess { return { @@ -226,6 +230,11 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow if (RUNTIME_PASSTHROUGH_CODES.has(message)) { return errorResponse(id, meta, message, message) } + const deferral = parseTerminalSpawnDeferral(message) + if (deferral) { + // Why: only a caller that sent spawnIntent can be deferred, so the code is new to no client. + return errorResponse(id, meta, TERMINAL_SPAWN_DEFERRED_CODE, message, { reason: deferral }) + } const skillInstallFailure = classifySkillInstallFailureCode(message) if (skillInstallFailure) { return errorResponse( diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 2ceda8458f28..fb1057c2a6f5 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -78,6 +78,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ presentation, tabId: params.tabId, leafId: params.leafId, + ...(params.spawnIntent ? { spawnIntent: params.spawnIntent } : {}), ...(preAllocatedHandle ? { preAllocatedHandle } : {}) }) ) diff --git a/src/main/runtime/runtime-managed-worktree-metadata.test.ts b/src/main/runtime/runtime-managed-worktree-metadata.test.ts index 46c8683927f6..4cfd3f45efc7 100644 --- a/src/main/runtime/runtime-managed-worktree-metadata.test.ts +++ b/src/main/runtime/runtime-managed-worktree-metadata.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { Worktree } from '../../shared/worktree/types' import { updateRuntimeManagedWorktreeMetadata } from './runtime-managed-worktree-metadata' import type { ResolvedWorktree } from './runtime-worktree-path-identity' @@ -15,7 +16,12 @@ describe('updateRuntimeManagedWorktreeMetadata', () => { } as unknown as ResolvedWorktree const setWorktreeMeta = vi.fn() const setWorktreeMetaForHost = vi.fn() - const store = { setWorktreeMeta, setWorktreeMetaForHost } as unknown as RuntimeStore + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the update only reads these store members. + const store = { + getRepos: () => [], + setWorktreeMeta, + setWorktreeMetaForHost + } as unknown as RuntimeStore const ports = { resolveWorktree: vi.fn(async () => worktree), validateParent: vi.fn(), @@ -37,4 +43,60 @@ describe('updateRuntimeManagedWorktreeMetadata', () => { }) expect(setWorktreeMeta).not.toHaveBeenCalled() }) + + describe('a folder workspace removed while its resolution was in flight (#22712)', () => { + const folderRepo = { id: 'repo-1', path: '/workspace/app', displayName: 'app', kind: 'folder' } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the update only reads id, repoId, hostId and instanceId. + const worktree = { + id: 'repo-1::/workspace/app::workspace:0b6f6d2e-1c1d-4c55-9a51-3f1c0f4f2a10', + repoId: 'repo-1', + hostId: 'local', + path: '/workspace/app', + instanceId: 'instance-1' + } as unknown as ResolvedWorktree + + function run(meta: Partial | undefined) { + const setWorktreeMeta = vi.fn() + const setWorktreeMetaForHost = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the update only reads these store members. + const store = { + getRepos: () => [folderRepo], + getWorktreeMeta: () => meta, + getWorktreeMetaForHost: () => meta, + setWorktreeMeta, + setWorktreeMetaForHost + } as unknown as RuntimeStore + const result = updateRuntimeManagedWorktreeMetadata({ + selector: `id:${worktree.id}`, + updates: { lastActivityAt: 2 }, + store, + ports: { + resolveWorktree: vi.fn(async () => worktree), + validateParent: vi.fn(), + invalidateResolved: vi.fn(), + invalidateScan: vi.fn(), + notifyChanged: vi.fn(), + showWorktree: vi.fn(async () => worktree as unknown as Worktree) + } + }) + return { result, setWorktreeMeta, setWorktreeMetaForHost } + } + + it('refuses to recreate the removed workspace as a blank row', async () => { + const { result, setWorktreeMeta, setWorktreeMetaForHost } = run(undefined) + + await expect(result).rejects.toThrow('selector_not_found') + expect(setWorktreeMeta).not.toHaveBeenCalled() + expect(setWorktreeMetaForHost).not.toHaveBeenCalled() + }) + + it('still writes to a folder workspace that exists', async () => { + const { result, setWorktreeMetaForHost } = run({ instanceId: 'instance-1' }) + + await result + expect(setWorktreeMetaForHost).toHaveBeenCalledWith(worktree.id, 'local', { + lastActivityAt: 2 + }) + }) + }) }) diff --git a/src/main/runtime/runtime-managed-worktree-metadata.ts b/src/main/runtime/runtime-managed-worktree-metadata.ts index b8deeb3a7839..0ee206ed7e02 100644 --- a/src/main/runtime/runtime-managed-worktree-metadata.ts +++ b/src/main/runtime/runtime-managed-worktree-metadata.ts @@ -4,6 +4,7 @@ import { worktreeWorkspaceKey } from '../../shared/workspace-scope' import { splitWorktreeId } from '../../shared/worktree/id' import { planWorktreeSortOrderUpdates } from '../../shared/worktree/sort-order-update' import { stripOrcaProvenanceMetaUpdates } from '../worktree-removal-safety' +import { isRemovedFolderWorkspaceInstance } from '../folder-workspace-meta-presence' import type { RuntimeStore } from './runtime-store-contract' import { RuntimeLineageError } from './runtime-worktree-lineage-resolution' import type { ResolvedWorktree } from './runtime-worktree-path-identity' @@ -91,6 +92,10 @@ export async function updateRuntimeManagedWorktreeMetadata(args: { } const metadataUpdates = stripOrcaProvenanceMetaUpdates(persisted) const executionHostId = worktree.identity?.executionHostId ?? worktree.hostId + // Why: a resolution snapshot taken before a removal still returns the workspace (#22712). + if (isRemovedFolderWorkspaceInstance(args.store, worktree.id, executionHostId)) { + throw new Error('selector_not_found') + } if (executionHostId && args.store.setWorktreeMetaForHost) { args.store.setWorktreeMetaForHost(worktree.id, executionHostId, metadataUpdates) } else { diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 80a85821c143..18427424b7d6 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -1,3 +1,4 @@ +import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { AgentSessionClaimedSpawnResult, AgentSessionExecutionClaim, @@ -80,6 +81,7 @@ export type RuntimePtyController = { surface: AgentSessionSurfaceBinding } agentSessionCreateOperationId?: string + spawnIntent?: SpawnIntent signal?: AbortSignal refuseSleptWorktree?: boolean onPtySpawnCommitted?: () => void diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index acf457c50138..e26957976492 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -36,6 +36,7 @@ export type RuntimeStore = { getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] setWorktreeMetaForHost?: Store['setWorktreeMetaForHost'] + getWorktreeMetaForHost?: Store['getWorktreeMetaForHost'] removeWorktreeMeta: Store['removeWorktreeMeta'] getWorktreeLineage?: Store['getWorktreeLineage'] getAllWorktreeLineage?: Store['getAllWorktreeLineage'] diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 3bee3fc6ed0f..ca70eedd030a 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -1,3 +1,4 @@ +import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' import type { AgentLaunchPreferences, @@ -90,6 +91,8 @@ export type TerminalCreateOptions = { persistHostSessionBinding?: boolean agentSessionClaim?: AgentSessionExecutionClaim agentSessionCreateOperationId?: string + /** The paired client's declared intent; the host's fork gate validates and may defer it. */ + spawnIntent?: SpawnIntent signal?: AbortSignal /** Refuse (`worktree_terminals_sleeping`) instead of waking a worktree the host has put to sleep. */ refuseSleptWorktree?: boolean diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 1dc52c949e9c..a18814771009 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -1,3 +1,4 @@ +import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -57,6 +58,8 @@ export type PtyApi = { replacesPtyId?: string // Which tab and leaf a fresh PTY joins; older mains ignore it. placement?: TerminalPanePlacement + // Visible restores are `user-resume`, hidden ones `automatic-restore`; the fork gate validates. + spawnIntent?: SpawnIntent // Why: main fires `agent_started` only on spawn success, so launch metadata rides this field (telemetry-plan.md §Agent launch semantics). telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }) => Promise<{ diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 3b996d61ea23..500ae1b9f9df 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -1,3 +1,4 @@ +import type { SpawnIntent } from '../../shared/host-spawn-admission' import { ipcRenderer } from 'electron' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' @@ -53,6 +54,8 @@ export const ptySessionControlApi = { replacesPtyId?: string // Which tab and leaf a fresh PTY joins; older mains ignore it. placement?: TerminalPanePlacement + // Visible restores are `user-resume`, hidden ones `automatic-restore`; the fork gate validates. + spawnIntent?: SpawnIntent // Why: loose typing on purpose — renderer owns launch metadata, main owns whether the launch happened and validates (telemetry-plan.md §Agent launch semantics). telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }): Promise<{ diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index 260e95691291..a0b171227223 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -126,7 +126,7 @@ export type WorktreeApi = { worktreeId: string executionHostId?: ExecutionHostId updates: Partial - }) => Promise + }) => Promise listLineage: () => Promise<{ lineage: Record workspaceLineage?: Record diff --git a/src/shared/agent-session-host-authority.ts b/src/shared/agent-session-host-authority.ts index 1129785428f7..3dde6e4adf02 100644 --- a/src/shared/agent-session-host-authority.ts +++ b/src/shared/agent-session-host-authority.ts @@ -8,6 +8,7 @@ import type { RuntimeTerminalCreate, RuntimeTerminalPresentation } from './runti import { isTerminalLeafId } from './stable-pane-id' import { isValidTerminalTabId } from './terminal-tab-id' import type { TuiAgent } from './tui-agent' +import type { SpawnIntent } from './host-spawn-admission' export { AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY as AGENT_SESSION_HOST_AUTHORITY_CAPABILITY } from './protocol-version' @@ -119,6 +120,7 @@ export type RuntimeEnsureAgentSessionRequest = launchPreferences?: AgentLaunchPreferences presentation?: RuntimeTerminalPresentation placement?: { tabId?: string; leafId?: string } + spawnIntent?: SpawnIntent } export type RuntimeEnsureAgentSessionResult = { diff --git a/src/shared/host-spawn-admission.test.ts b/src/shared/host-spawn-admission.test.ts index bf1a78ecd644..d33ed63336d7 100644 --- a/src/shared/host-spawn-admission.test.ts +++ b/src/shared/host-spawn-admission.test.ts @@ -71,12 +71,27 @@ describe('resolveSpawnCharge', () => { ).toBe('user') expect(resolveSpawnCharge(state, f, LIMITS, LIMITS.userResumeBucketWindowMs)).toBe('user') }) + + it('does not let a claimed agent resume declare itself interactive', () => { + const state = createSpawnAdmissionState(LIMITS, 0) + expect(resolveSpawnCharge(state, facts({ declaredIntent: 'interactive' }), LIMITS, 0)).toBe( + 'automatic' + ) + expect( + resolveSpawnCharge( + state, + facts({ declaredIntent: 'interactive', hasAgentSessionClaim: false }), + LIMITS, + 0 + ) + ).toBe('interactive') + }) }) describe('decideSpawnAdmission', () => { it('always admits interactive forks, even under critical pressure and pause', () => { const state = createSpawnAdmissionState(LIMITS, 0) - const f = facts({ declaredIntent: 'interactive' }) + const f = facts({ declaredIntent: 'interactive', hasAgentSessionClaim: false }) expect( decide(state, f, 0, 'critical', { kind: 'paused', reason: 'operator', since: 0 }) ).toEqual({ kind: 'admit', charge: 'interactive' }) @@ -186,11 +201,12 @@ describe('decideSpawnAdmission', () => { : roll < 0.7 ? 'automatic-restore' : null, - clientId: rng() < 0.5 ? 'a' : 'b' + clientId: rng() < 0.5 ? 'a' : 'b', + hasAgentSessionClaim: roll >= 0.2 || rng() < 0.5 }) const pressure: HostPressureLevel = rng() < 0.05 ? 'critical' : 'ok' const decision = decide(state, f, now, pressure) - if (f.declaredIntent === 'interactive') { + if (f.declaredIntent === 'interactive' && !f.hasAgentSessionClaim) { expect(decision).toEqual({ kind: 'admit', charge: 'interactive' }) } if (f.declaredIntent === null && pressure !== 'critical') { diff --git a/src/shared/host-spawn-admission.ts b/src/shared/host-spawn-admission.ts index 7cf02ae74b77..37e61fff8291 100644 --- a/src/shared/host-spawn-admission.ts +++ b/src/shared/host-spawn-admission.ts @@ -26,21 +26,35 @@ export type SpawnRequestFacts = { deadlineAt: number } -export const SPAWN_INTENTS: readonly SpawnIntent[] = [ +export const SPAWN_INTENTS = [ 'interactive', 'user-resume', 'automatic-restore', 'automation' -] +] as const satisfies readonly SpawnIntent[] export function isSpawnIntent(value: unknown): value is SpawnIntent { return typeof value === 'string' && SPAWN_INTENTS.some((intent) => intent === value) } +/** The only intents a client may declare. `interactive` skips the gate, and a daemon kept running + * across an update still trusts it, so it is never accepted from a client. */ +export const RESTORE_SPAWN_INTENTS = [ + 'user-resume', + 'automatic-restore' +] as const satisfies readonly SpawnIntent[] + +export type RestoreSpawnIntent = (typeof RESTORE_SPAWN_INTENTS)[number] + +export function isRestoreSpawnIntent(value: unknown): value is RestoreSpawnIntent { + return typeof value === 'string' && RESTORE_SPAWN_INTENTS.some((intent) => intent === value) +} + export type SpawnDeferralReason = 'paused' | 'live_cap' | 'pressure' | 'deadline' /** Sent only to requests that declared an intent; the fork never started. */ -export const TERMINAL_SPAWN_DEFERRED_PREFIX = 'terminal_spawn_deferred:' +export const TERMINAL_SPAWN_DEFERRED_CODE = 'terminal_spawn_deferred' +export const TERMINAL_SPAWN_DEFERRED_PREFIX = `${TERMINAL_SPAWN_DEFERRED_CODE}:` /** An undeclared caller at critical pressure; deliberately not a "daemon gone" message, * which would make the caller replace a healthy daemon. */ export const TERMINAL_SPAWN_REFUSED_MESSAGE = 'terminal_spawn_refused: host under pressure' @@ -136,8 +150,10 @@ export function resolveSpawnCharge( // Why a claim alone: a remote client's `--resume` arrives on a fresh terminal id with no // history seed, so requiring one let every reconnect storm through. An old caller is only // ever paced, so a user-started agent costs at most a short wait during a storm. + const inferred = facts.hasAgentSessionClaim ? 'automatic-restore' : 'interactive' + // Why: `interactive` skips the gate entirely, so a claimed resume cannot declare its way past it. const intent = - facts.declaredIntent ?? (facts.hasAgentSessionClaim ? 'automatic-restore' : 'interactive') + facts.declaredIntent === 'interactive' ? inferred : (facts.declaredIntent ?? inferred) if (intent === 'interactive') { return 'interactive' } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 8099a5cd7741..9003ad59baeb 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -139,6 +139,9 @@ export const TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY = 'terminal.prompt-deli // Why: paired clients may unmount xterm only when the host can return a // bounded, sequenced scrollback snapshot for lossless reveal. export const TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY = 'terminal.paired-parking.v1' as const +// Why: terminal.ensureAgentSession params are strict, so an older host rejects `spawnIntent`; a +// host that advertises this also may answer a declared restore with `terminal_spawn_deferred`. +export const TERMINAL_SPAWN_INTENT_RUNTIME_CAPABILITY = 'terminal.spawn-intent.v1' as const // Why: older hosts lack the targeted settings RPCs and strip agentPrompt from // terminal creation, so mobile must hide Quick Commands unless both are present. export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-commands.v1' as const @@ -382,6 +385,7 @@ export const RUNTIME_CAPABILITIES = [ TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY, TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY, TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY, + TERMINAL_SPAWN_INTENT_RUNTIME_CAPABILITY, TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY, WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/agent-session-params.ts b/src/shared/rpc-contract/agent-session-params.ts index cd0e4f5f6457..be4365f16760 100644 --- a/src/shared/rpc-contract/agent-session-params.ts +++ b/src/shared/rpc-contract/agent-session-params.ts @@ -11,6 +11,7 @@ import type { RuntimeEnsureAgentSessionRequest } from '../agent-session-host-authority' import { isTuiAgent } from '../tui-agent-config' +import { RESTORE_SPAWN_INTENTS } from '../host-spawn-admission' export const MAX_WORKTREE_SELECTOR_LENGTH = 32_768 @@ -71,6 +72,9 @@ export const LaunchPreferences = z export const PromptDelivery = z.enum(['auto-submit', 'draft']) +/** Sent only to hosts advertising `terminal.spawn-intent.v1`; presence opts into deferral. */ +export const SpawnIntentParam = z.enum(RESTORE_SPAWN_INTENTS) + export const AgentArgs = z .string() .refine( @@ -134,7 +138,8 @@ export const ExplicitEnsure = z agentArgs: AgentArgs.optional(), launchPreferences: LaunchPreferences.optional(), presentation: Presentation.optional(), - placement: Placement.optional() + placement: Placement.optional(), + spawnIntent: SpawnIntentParam.optional() }) .strict() .superRefine((value, context) => { diff --git a/src/shared/rpc-contract/agent-session-spawn-intent-params.test.ts b/src/shared/rpc-contract/agent-session-spawn-intent-params.test.ts new file mode 100644 index 000000000000..5edbd7a7c3a1 --- /dev/null +++ b/src/shared/rpc-contract/agent-session-spawn-intent-params.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { EnsureAgentSessionParams } from './agent-session-params' +import { TerminalCreateParams } from './terminal-unary-params' +import { RUNTIME_CAPABILITIES, TERMINAL_SPAWN_INTENT_RUNTIME_CAPABILITY } from '../protocol-version' + +const explicitResume = { + kind: 'explicit', + worktree: 'id:repo::/tmp/wt', + agent: 'claude', + providerSession: { key: 'session_id', id: 'provider-session-1' } +} as const + +describe('spawnIntent on terminal-creating RPCs', () => { + it('is advertised so clients only send it to hosts that accept it', () => { + expect(RUNTIME_CAPABILITIES).toContain(TERMINAL_SPAWN_INTENT_RUNTIME_CAPABILITY) + }) + + it('accepts a declared intent on an explicit resume and keeps it', () => { + const parsed = EnsureAgentSessionParams.parse({ + ...explicitResume, + spawnIntent: 'automatic-restore' + }) + expect(parsed).toMatchObject({ spawnIntent: 'automatic-restore' }) + }) + + it('still accepts an explicit resume without one (older clients)', () => { + expect(EnsureAgentSessionParams.safeParse(explicitResume).success).toBe(true) + }) + + it('never accepts interactive, which a daemon kept across an update would let skip the gate', () => { + expect( + EnsureAgentSessionParams.safeParse({ ...explicitResume, spawnIntent: 'interactive' }).success + ).toBe(false) + expect(TerminalCreateParams.safeParse({ spawnIntent: 'interactive' }).success).toBe(false) + }) + + it('rejects an unknown intent instead of treating it as undeclared', () => { + expect( + EnsureAgentSessionParams.safeParse({ + ...explicitResume, + spawnIntent: 'urgent' + }).success + ).toBe(false) + }) + + it('keeps a declared intent on terminal.create', () => { + expect(TerminalCreateParams.parse({ spawnIntent: 'user-resume' })).toMatchObject({ + spawnIntent: 'user-resume' + }) + }) +}) diff --git a/src/shared/rpc-contract/terminal-unary-params.ts b/src/shared/rpc-contract/terminal-unary-params.ts index c4bbdc581b21..fe241b27a96a 100644 --- a/src/shared/rpc-contract/terminal-unary-params.ts +++ b/src/shared/rpc-contract/terminal-unary-params.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' import { isTuiAgent } from '../tui-agent-config' +import { SpawnIntentParam } from './agent-session-params' import { canonicalizeWindowsShellOverride, isSupportedWindowsShellOverride, @@ -193,6 +194,7 @@ export const TerminalCreateParams = z.object({ presentation: z.enum(['background', 'focused']).optional(), tabId: OptionalString, leafId: OptionalString, + spawnIntent: SpawnIntentParam.optional(), // Why refused at the boundary rather than at spawn: only the host knows the allowlist, and a // relay-side throw reaches the caller as an opaque spawn failure after the round trip. shell: z diff --git a/tests/e2e/fixtures/docker-ssh-relay/Dockerfile b/tests/e2e/fixtures/docker-ssh-host/Dockerfile similarity index 100% rename from tests/e2e/fixtures/docker-ssh-relay/Dockerfile rename to tests/e2e/fixtures/docker-ssh-host/Dockerfile diff --git a/tests/e2e/global-setup-cli-artifact.unit.test.ts b/tests/e2e/global-setup-cli-artifact.unit.test.ts index 555b6a9c7d16..d9adcdf0a4cf 100644 --- a/tests/e2e/global-setup-cli-artifact.unit.test.ts +++ b/tests/e2e/global-setup-cli-artifact.unit.test.ts @@ -15,7 +15,7 @@ vi.mock('node:fs', () => ({ realpathSync: (value: string) => value, writeFileSync: fixture.write })) -vi.mock('./helpers/docker-ssh-relay-image', () => ({ prepareDockerSshRelayImage: vi.fn() })) +vi.mock('./helpers/docker-ssh-host-image', () => ({ prepareDockerSshHostImage: vi.fn() })) import globalSetup, { workerTestRepositoryPathFile } from './global-setup' diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index a0070b251860..77b24614743d 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -15,7 +15,7 @@ import { randomUUID } from 'node:crypto' import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' import path from 'node:path' import os from 'node:os' -import { prepareDockerSshRelayImage } from './helpers/docker-ssh-relay-image' +import { prepareDockerSshHostImage } from './helpers/docker-ssh-host-image' export const E2E_TEST_REPO_PATH_FILE_ENV = 'ORCA_E2E_TEST_REPO_PATH_FILE' const RUN_TEST_REPO_PATH_FILE = @@ -117,7 +117,7 @@ export default function globalSetup(): void { } if (process.env.ORCA_E2E_SSH_DOCKER === '1') { console.error('[e2e] Preparing Docker OpenSSH fixture image...') - prepareDockerSshRelayImage(root) + prepareDockerSshHostImage(root) } // ── 2. Create a seeded test git repo ─────────────────────────────── diff --git a/tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts b/tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts index 4aa41b7ee174..a892c16f2d7f 100644 --- a/tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts +++ b/tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts @@ -8,6 +8,7 @@ import { expect, test } from './helpers/orca-app' import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' import { launchPairedWebClient } from './helpers/paired-electron-client' import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' const MIN_EXHAUSTED_ACK_BYTES = 400 * 1024 const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-headless-stalled-stream-')) @@ -40,15 +41,11 @@ test.afterAll(() => { rmSync(scratch, { recursive: true, force: true }) }) -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - function fixtureCommand(): string { const command = [process.execPath, fixturePath] return process.platform === 'win32' ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') - : command.map(shellQuote).join(' ') + : command.map(quotePosixShell).join(' ') } async function callRuntime(page: Page, method: string, params: unknown): Promise { diff --git a/tests/e2e/helpers/docker-ssh-relay-image.ts b/tests/e2e/helpers/docker-ssh-host-image.ts similarity index 85% rename from tests/e2e/helpers/docker-ssh-relay-image.ts rename to tests/e2e/helpers/docker-ssh-host-image.ts index 9b7515bc5469..773ed9418378 100644 --- a/tests/e2e/helpers/docker-ssh-relay-image.ts +++ b/tests/e2e/helpers/docker-ssh-host-image.ts @@ -30,20 +30,20 @@ function hashDockerFixtureDirectory(fixtureDir: string): string { } function fixtureImage(root: string): string { - const fixtureDir = path.join(root, 'tests', 'e2e', 'fixtures', 'docker-ssh-relay') + const fixtureDir = path.join(root, 'tests', 'e2e', 'fixtures', 'docker-ssh-host') const digest = hashDockerFixtureDirectory(fixtureDir) - return `orca-e2e-ssh-relay:${digest}` + return `orca-e2e-ssh-host:${digest}` } -export function getDockerSshRelayImage(): string { +export function getDockerSshHostImage(): string { return process.env.ORCA_E2E_SSH_DOCKER_IMAGE ?? fixtureImage(process.cwd()) } -export function prepareDockerSshRelayImage(root: string): void { +export function prepareDockerSshHostImage(root: string): void { if (process.env.ORCA_E2E_SSH_DOCKER_IMAGE) { return } - const fixtureDir = path.join(root, 'tests', 'e2e', 'fixtures', 'docker-ssh-relay') + const fixtureDir = path.join(root, 'tests', 'e2e', 'fixtures', 'docker-ssh-host') const image = fixtureImage(root) execFileSync( 'docker', @@ -52,7 +52,7 @@ export function prepareDockerSshRelayImage(root: string): void { ) } -export function ensureDockerSshRelayImage(root: string): void { +export function ensureDockerSshHostImage(root: string): void { if (process.env.ORCA_E2E_SSH_DOCKER_IMAGE) { return } @@ -60,5 +60,5 @@ export function ensureDockerSshRelayImage(root: string): void { if (spawnSync('docker', ['image', 'inspect', image], { stdio: 'ignore' }).status === 0) { return } - prepareDockerSshRelayImage(root) + prepareDockerSshHostImage(root) } diff --git a/tests/e2e/helpers/docker-ssh-relay-target.ts b/tests/e2e/helpers/docker-ssh-host-target.ts similarity index 58% rename from tests/e2e/helpers/docker-ssh-relay-target.ts rename to tests/e2e/helpers/docker-ssh-host-target.ts index 9d96859968ee..abb6c763ccfd 100644 --- a/tests/e2e/helpers/docker-ssh-relay-target.ts +++ b/tests/e2e/helpers/docker-ssh-host-target.ts @@ -3,15 +3,14 @@ import { randomUUID } from 'node:crypto' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { getDockerSshRelayImage } from './docker-ssh-relay-image' +import { getDockerSshHostImage } from './docker-ssh-host-image' +import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' import type { TestInfo } from '@stablyai/playwright-test' -export const DOCKER_SSH_RELAY_REMOTE_REPO_PATH = '/tmp/orca-docker-relay-perf-repo' -export const DOCKER_SSH_PROXY_JUMP_REMOTE_REPO_PATH = '/tmp/orca-docker-proxy-jump-repo' -export const DOCKER_SSH_SECOND_HUB_REMOTE_REPO_PATH = '/tmp/orca-docker-second-hub-repo' +export const DOCKER_SSH_HOST_REMOTE_REPO_PATH = '/tmp/orca-docker-relay-perf-repo' -export type DockerSshRelayTarget = { +export type DockerSshHostTarget = { containerName: string containerIp: string host: string @@ -32,12 +31,8 @@ function tryRun(command: string, args: string[], opts: { timeoutMs?: number } = spawnSync(command, args, { stdio: 'ignore', timeout: opts.timeoutMs ?? 10_000 }) } -export function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'` -} - -export function execDockerSshRelayTargetCommand( - target: DockerSshRelayTarget, +export function execDockerSshHostTargetCommand( + target: DockerSshHostTarget, command: string ): string { return run('docker', ['exec', target.containerName, 'bash', '-lc', command], { @@ -45,8 +40,8 @@ export function execDockerSshRelayTargetCommand( }) } -export function execDockerSshRelayTargetControlCommand( - target: DockerSshRelayTarget, +export function execDockerSshHostTargetControlCommand( + target: DockerSshHostTarget, command: string ): string { return run('docker', [ @@ -60,7 +55,7 @@ export function execDockerSshRelayTargetControlCommand( ]) } -function sshArgs(target: DockerSshRelayTarget, command: string): string[] { +function sshArgs(target: DockerSshHostTarget, command: string): string[] { return [ '-i', target.identityFile, @@ -79,7 +74,7 @@ function sshArgs(target: DockerSshRelayTarget, command: string): string[] { ] } -function waitForSsh(target: DockerSshRelayTarget): void { +function waitForSsh(target: DockerSshHostTarget): void { const deadline = Date.now() + 90_000 let lastError = '' while (Date.now() < deadline) { @@ -103,42 +98,25 @@ function waitForSsh(target: DockerSshRelayTarget): void { ) } -export function dockerSshRelayRepoSentinel(target: DockerSshRelayTarget, repoPath: string): string { - return `${target.containerName}:${repoPath}` -} - -function seedRemoteRepo(target: DockerSshRelayTarget, repoPath: string): void { - const sentinel = dockerSshRelayRepoSentinel(target, repoPath) - execDockerSshRelayTargetCommand( +function seedRemoteRepo(target: DockerSshHostTarget, repoPath: string): void { + const sentinel = `${target.containerName}:${repoPath}` + execDockerSshHostTargetCommand( target, [ - `rm -rf ${shellQuote(repoPath)}`, - `mkdir -p ${shellQuote(repoPath)}`, - `cd ${shellQuote(repoPath)}`, + `rm -rf ${quotePosixShell(repoPath)}`, + `mkdir -p ${quotePosixShell(repoPath)}`, + `cd ${quotePosixShell(repoPath)}`, 'git init', 'git config user.email e2e@test.local', 'git config user.name "Orca Docker SSH E2E"', - `printf '%s\\n' ${shellQuote(sentinel)} > .orca-e2e-destination-id`, - `printf '%s\\n' ${shellQuote(`remote relay ${sentinel}`)} > README.md`, + `printf '%s\\n' ${quotePosixShell(sentinel)} > .orca-e2e-destination-id`, + `printf '%s\\n' ${quotePosixShell(`remote relay ${sentinel}`)} > README.md`, 'git add README.md .orca-e2e-destination-id', 'git commit -m initial' ].join(' && ') ) } -/** - * The fixture image ships Debian's `/etc/bash.bashrc` with the xterm title block commented out and - * an all-comments `/root/.bashrc`, so its shell never emits OSC 0. Orca derives a tab title from - * that sequence, so without this every SSH tab keeps its `Terminal N` placeholder no matter how - * healthy the shell is. Opt in from specs that assert on titles; a real user's shell sets one. - */ -export function enableDockerSshRelayTargetShellTitle(target: DockerSshRelayTarget): void { - execDockerSshRelayTargetControlCommand( - target, - `printf '%s\\n' ${shellQuote(String.raw`PS1="\[\e]0;\u@\h: \w\a\]$PS1"`)} >> /root/.bashrc` - ) -} - /** * Attempts a `direct-tcpip` channel to a closed loopback port using the host's own ssh client. * @@ -146,7 +124,7 @@ export function enableDockerSshRelayTargetShellTitle(target: DockerSshRelayTarge * reports a connect failure (port 9 is closed on any sane host), while one running * `AllowTcpForwarding no` refuses the channel itself with "administratively prohibited". */ -function probeDockerSshRelayTargetForwarding(target: DockerSshRelayTarget): string { +function probeDockerSshHostTargetForwarding(target: DockerSshHostTarget): string { const result = spawnSync( 'ssh', [ @@ -181,15 +159,15 @@ function probeDockerSshRelayTargetForwarding(target: DockerSshRelayTarget): stri * this before the app connects. Returns once a real ssh client has confirmed the refusal, so a * config that silently failed to apply surfaces here rather than as a confusing assertion later. */ -export function blockDockerSshRelayTargetTcpForwarding(target: DockerSshRelayTarget): void { - execDockerSshRelayTargetControlCommand( +export function blockDockerSshHostTargetTcpForwarding(target: DockerSshHostTarget): void { + execDockerSshHostTargetControlCommand( target, "printf '%s\\n' 'AllowTcpForwarding no' >> /etc/ssh/sshd_config; kill -HUP 1" ) const deadline = Date.now() + 60_000 let lastProbe = '' while (Date.now() < deadline) { - lastProbe = probeDockerSshRelayTargetForwarding(target) + lastProbe = probeDockerSshHostTargetForwarding(target) if (/administratively prohibited/i.test(lastProbe)) { return } @@ -200,58 +178,20 @@ export function blockDockerSshRelayTargetTcpForwarding(target: DockerSshRelayTar ) } -/** - * Makes the target unreachable over SSH while the container, its fixtures and `docker exec` stay - * up: sshd re-binds to the container's loopback, so the published port stops reaching it. Only new - * connections are refused; an established session lives until its client closes it. Returns once - * a real ssh client fails to connect, so a config that silently failed to apply surfaces here. - */ -export function refuseDockerSshRelayTargetConnections(target: DockerSshRelayTarget): void { - // Why HUP, not a restart: see blockDockerSshRelayTargetTcpForwarding — PID 1 is sshd. - execDockerSshRelayTargetControlCommand( - target, - "printf '%s\\n' 'ListenAddress 127.0.0.1' >> /etc/ssh/sshd_config; kill -HUP 1" - ) - const deadline = Date.now() + 60_000 - let lastProbe = '' - while (Date.now() < deadline) { - const result = spawnSync('ssh', sshArgs(target, 'true'), { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 15_000 - }) - lastProbe = result.stderr || result.stdout || `exit ${result.status}` - // Why the auth check: a refusal must look like a dead host, never a credential prompt. - if (result.status !== 0 && !/permission denied/i.test(lastProbe)) { - return - } - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500) - } - throw new Error(`sshd kept accepting connections after ListenAddress 127.0.0.1: ${lastProbe}`) -} - -export function writeDockerSshRelayTargetFile( - target: DockerSshRelayTarget, +export function writeDockerSshHostTargetFile( + target: DockerSshHostTarget, filePath: string, contents: string ): void { - execDockerSshRelayTargetCommand( + execDockerSshHostTargetCommand( target, - `printf '%s' ${shellQuote(contents)} > ${shellQuote(filePath)}` + `printf '%s' ${quotePosixShell(contents)} > ${quotePosixShell(filePath)}` ) } -/** Why not `writeDockerSshRelayTargetFile`: that one passes the contents as a shell argument, so a - * payload the size of a real repository's path list exceeds ARG_MAX before it reaches the shell. */ -export function copyFileIntoDockerSshRelayTarget( - target: DockerSshRelayTarget, - localPath: string, - remotePath: string -): void { - run('docker', ['cp', localPath, `${target.containerName}:${remotePath}`], { timeoutMs: 120_000 }) -} - -export function startDockerSshRelayTarget(testInfo: TestInfo): DockerSshRelayTarget { +export function startDockerSshHostTarget( + testInfo: Pick +): DockerSshHostTarget { const host = process.env.ORCA_E2E_SSH_TARGET_HOST?.trim() || '127.0.0.1' if (host === 'localhost' || host === '::1' || host.startsWith('127.')) { if (process.env.ORCA_E2E_SSH_TARGET_HOST) { @@ -264,7 +204,7 @@ export function startDockerSshRelayTarget(testInfo: TestInfo): DockerSshRelayTar run('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', identityFile, '-q']) const publicKey = readFileSync(`${identityFile}.pub`, 'utf8').trim() const containerName = `orca-ssh-e2e-${testInfo.workerIndex}-${Date.now()}-${randomUUID().slice(0, 8)}` - let target: DockerSshRelayTarget | null = null + let target: DockerSshHostTarget | null = null try { tryRun('docker', ['rm', '-f', containerName]) @@ -279,7 +219,7 @@ export function startDockerSshRelayTarget(testInfo: TestInfo): DockerSshRelayTar `${bindHost}::22`, '-e', `AUTHORIZED_KEY=${publicKey}`, - getDockerSshRelayImage(), + getDockerSshHostImage(), 'bash', '-lc', [ @@ -308,19 +248,17 @@ export function startDockerSshRelayTarget(testInfo: TestInfo): DockerSshRelayTar } target = { containerName, containerIp, host, identityFile, port, tempDir } waitForSsh(target) - seedRemoteRepo(target, DOCKER_SSH_RELAY_REMOTE_REPO_PATH) - seedRemoteRepo(target, DOCKER_SSH_PROXY_JUMP_REMOTE_REPO_PATH) - seedRemoteRepo(target, DOCKER_SSH_SECOND_HUB_REMOTE_REPO_PATH) + seedRemoteRepo(target, DOCKER_SSH_HOST_REMOTE_REPO_PATH) return target } catch (error) { - cleanupDockerSshRelayTarget( + cleanupDockerSshHostTarget( target ?? { containerName, containerIp: '', host, identityFile, port: 0, tempDir } ) throw error } } -export function cleanupDockerSshRelayTarget(target: DockerSshRelayTarget | null): void { +export function cleanupDockerSshHostTarget(target: DockerSshHostTarget | null): void { if (!target) { return } diff --git a/tests/e2e/helpers/host-created-terminal-retention-oracle.ts b/tests/e2e/helpers/host-created-terminal-retention-oracle.ts index 6e068575ecf7..9017954168b1 100644 --- a/tests/e2e/helpers/host-created-terminal-retention-oracle.ts +++ b/tests/e2e/helpers/host-created-terminal-retention-oracle.ts @@ -15,6 +15,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { expect, type Page } from '@stablyai/playwright-test' +import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' export type RuntimeRpcCall = (method: string, params: unknown) => Promise @@ -90,16 +91,12 @@ export function writeRetentionFixture(directory: string): string { return fixturePath } -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - export function retentionFixtureCommand(fixturePath: string, sinkPath: string): string { const command = [process.execPath, fixturePath, sinkPath] return process.platform === 'win32' ? // PowerShell needs the call operator when the executable is quoted; cmd.exe also accepts it. `& ${command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ')}` - : command.map(shellQuote).join(' ') + : command.map(quotePosixShell).join(' ') } export function readSink(sinkPath: string): string { diff --git a/tests/e2e/helpers/orcad-convert-host.ts b/tests/e2e/helpers/orcad-convert-host.ts index 0d8c4eeea6df..800043b41080 100644 --- a/tests/e2e/helpers/orcad-convert-host.ts +++ b/tests/e2e/helpers/orcad-convert-host.ts @@ -8,13 +8,13 @@ import type { TestInfo } from '@stablyai/playwright-test' import type { SshTargetCreateInput } from '../../../src/shared/ssh-types' import { parseWindowsHostCellDescriptor } from '../../../src/main/ssh/ssh-windows-host-cells' import { - blockDockerSshRelayTargetTcpForwarding, - cleanupDockerSshRelayTarget, - DOCKER_SSH_RELAY_REMOTE_REPO_PATH, - execDockerSshRelayTargetCommand, - execDockerSshRelayTargetControlCommand, - startDockerSshRelayTarget -} from './docker-ssh-relay-target' + blockDockerSshHostTargetTcpForwarding, + cleanupDockerSshHostTarget, + DOCKER_SSH_HOST_REMOTE_REPO_PATH, + execDockerSshHostTargetCommand, + execDockerSshHostTargetControlCommand, + startDockerSshHostTarget +} from './docker-ssh-host-target' export const ORCAD_CONVERT_HOST_ENV = 'ORCA_E2E_ORCAD_CONVERT_HOST' @@ -50,7 +50,7 @@ echo "$killed" export function startOrcadConvertHost(source: string, testInfo: TestInfo): OrcadConvertHost { const label = `orcad convert E2E ${Date.now()}` if (source === 'docker') { - const target = startDockerSshRelayTarget(testInfo) + const target = startDockerSshHostTarget(testInfo) return { input: { label, @@ -61,14 +61,14 @@ export function startOrcadConvertHost(source: string, testInfo: TestInfo): Orcad identitiesOnly: true, relayGracePeriodSeconds: 1 }, - remoteRepoPath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + remoteRepoPath: DOCKER_SSH_HOST_REMOTE_REPO_PATH, remoteFolderPath: '/tmp', - cleanup: () => cleanupDockerSshRelayTarget(target), - blockTcpForwarding: () => blockDockerSshRelayTargetTcpForwarding(target), - exec: (command) => execDockerSshRelayTargetCommand(target, command), + cleanup: () => cleanupDockerSshHostTarget(target), + blockTcpForwarding: () => blockDockerSshHostTargetTcpForwarding(target), + exec: (command) => execDockerSshHostTargetCommand(target, command), dropSshConnections: () => Number( - execDockerSshRelayTargetControlCommand(target, DROP_SSH_CONNECTIONS).split('\n').at(-1) + execDockerSshHostTargetControlCommand(target, DROP_SSH_CONNECTIONS).split('\n').at(-1) ), setFrozen: (frozen) => execFileSync('docker', [frozen ? 'pause' : 'unpause', target.containerName], { diff --git a/tests/e2e/helpers/orcad-managed-workspace.ts b/tests/e2e/helpers/orcad-managed-workspace.ts index b17112155dd5..b62c7ee850d2 100644 --- a/tests/e2e/helpers/orcad-managed-workspace.ts +++ b/tests/e2e/helpers/orcad-managed-workspace.ts @@ -4,19 +4,27 @@ * managed-host journey specs share, driven through the tab bar and the keyboard. */ import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test' -import { expect } from './orca-app' +import { expect, test } from './orca-app' import { createRestartSession } from './orca-restart' import { startOrcadConvertHost, type OrcadConvertHost } from './orcad-convert-host' import { seedRelayEraProfile, type RelayEraProfile } from './orcad-upgrade-profile' import { convertAndRetain } from './orcad-convert-flow' import { readPairedPaneContent } from './paired-host-terminal' -import { waitForSessionReady } from './store' +import { waitForSessionReady, waitForStartupWorktreeRefresh } from './store' import { dismissTransientAnnouncement } from './ssh-config-host-picker' import { focusActiveTerminalInput } from './terminal' import { toRuntimeExecutionHostId } from '../../../src/shared/execution-host' export const ORCAD_TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE +/** Skips the calling spec file unless the Docker SSH host and the orcad template are available. */ +export function skipUnlessManagedDockerHost(): void { + test.skip( + !ORCAD_TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', + 'Needs Docker and server template' + ) +} + export type ManagedHostFixture = { host: OrcadConvertHost session: ReturnType @@ -53,32 +61,75 @@ export type ManagedWorkspace = { environmentId: string } -/** Leaves the managed repository active; each launch is recorded on `fixture` for teardown. */ -export async function launchManagedWorkspace( - fixture: ManagedHostFixture, +export type ManagedProfileOptions = { + /** Overrides the host's repository, e.g. a same-path desktop collision. */ + repoPath?: string /** Overrides the host's folder workspace, e.g. to keep /tmp outside every workspace. */ - options: { folderPath?: string } = {} + folderPath?: string + /** Runs on the profile-creating launch, before the SSH profile is seeded. */ + beforeSeed?: (page: Page) => Promise + /** Runs as soon as the converting launch starts, before the session is ready. */ + onLaunch?: (page: Page, seeded: RelayEraProfile) => void | Promise +} + +/** Seeds a relay-era profile and converts it; each launch is recorded on `fixture` for teardown. */ +export async function launchConvertedProfile( + fixture: ManagedHostFixture, + options: ManagedProfileOptions = {} ): Promise { const { host, session } = fixture // The first launch only creates the profile the saved SSH host is written into. const first = await session.launch() fixture.app = first.app await waitForSessionReady(first.page) + await options.beforeSeed?.(first.page) await session.close(first.app) fixture.app = null const seeded = seedRelayEraProfile(session.userDataDir, host.input, { - repoPath: host.remoteRepoPath, + repoPath: options.repoPath ?? host.remoteRepoPath, folderPath: options.folderPath ?? host.remoteFolderPath }) const { app, page } = await session.launch() fixture.app = app + await options.onLaunch?.(page, seeded) await waitForSessionReady(page) await convertAndRetain(page, session.userDataDir, seeded) const environmentId = await managedEnvironmentId(page, seeded.targetId) - await activateManagedWorkspace(page, seeded.worktreeId, environmentId) return { app, page, seeded, environmentId } } +/** Like `launchConvertedProfile`, and leaves the managed repository active. */ +export async function launchManagedWorkspace( + fixture: ManagedHostFixture, + options: ManagedProfileOptions = {} +): Promise { + const workspace = await launchConvertedProfile(fixture, options) + await activateManagedWorkspace( + workspace.page, + workspace.seeded.worktreeId, + workspace.environmentId + ) + return workspace +} + +/** Quits the live launch and relaunches the same profile, waiting for startup to settle. */ +export async function relaunchManagedApp( + fixture: ManagedHostFixture, + onLaunch?: (page: Page) => void | Promise +): Promise { + if (!fixture.app) { + throw new Error('No live launch to close') + } + await fixture.session.close(fixture.app) + fixture.app = null + const { app, page } = await fixture.session.launch() + fixture.app = app + await onLaunch?.(page) + await waitForSessionReady(page) + await waitForStartupWorktreeRefresh(page) + return page +} + export async function managedEnvironmentId(page: Page, targetId: string): Promise { const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( (entry) => entry.orcadDeployment?.sshTargetId === targetId diff --git a/tests/e2e/helpers/paired-terminal-parking-fixture.ts b/tests/e2e/helpers/paired-terminal-parking-fixture.ts index f7632869d3e7..2a0e792e4359 100644 --- a/tests/e2e/helpers/paired-terminal-parking-fixture.ts +++ b/tests/e2e/helpers/paired-terminal-parking-fixture.ts @@ -1,6 +1,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' +import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' const FILL_ROWS = 6_000 const FLOOD_ROWS = 4_000 @@ -8,15 +9,11 @@ const TITLE_FRAME_INTERVAL_MS = 70 const TITLE_FRAME_COUNT = 30 const TITLE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - function fixtureCommand(fixturePath: string, marker: string): string { const command = [process.execPath, fixturePath, marker] return process.platform === 'win32' ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') - : command.map(shellQuote).join(' ') + : command.map(quotePosixShell).join(' ') } export function createPairedTerminalParkingFixture(): { diff --git a/tests/e2e/helpers/remote-session-bulk-open-fixture.ts b/tests/e2e/helpers/remote-session-bulk-open-fixture.ts index 359add80b4ed..8109d1de497d 100644 --- a/tests/e2e/helpers/remote-session-bulk-open-fixture.ts +++ b/tests/e2e/helpers/remote-session-bulk-open-fixture.ts @@ -1,16 +1,13 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' - -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} +import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' function fixtureCommand(fixturePath: string, marker: string): string { const command = [process.execPath, fixturePath, marker] return process.platform === 'win32' ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') - : command.map(shellQuote).join(' ') + : command.map(quotePosixShell).join(' ') } /** diff --git a/tests/e2e/helpers/ssh-remote-only-browser-fixture.ts b/tests/e2e/helpers/ssh-remote-only-browser-fixture.ts index d6c7ad440aac..e03746440f8a 100644 --- a/tests/e2e/helpers/ssh-remote-only-browser-fixture.ts +++ b/tests/e2e/helpers/ssh-remote-only-browser-fixture.ts @@ -1,10 +1,10 @@ import { - execDockerSshRelayTargetCommand, - execDockerSshRelayTargetControlCommand, - shellQuote, - writeDockerSshRelayTargetFile, - type DockerSshRelayTarget -} from './docker-ssh-relay-target' + execDockerSshHostTargetCommand, + execDockerSshHostTargetControlCommand, + writeDockerSshHostTargetFile, + type DockerSshHostTarget +} from './docker-ssh-host-target' +import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' /** * An HTTP fixture that exists only inside the SSH container, on a hostname only the @@ -59,17 +59,17 @@ const SERVER_SOURCE = [ ].join('\n') /** Publishes the remote-only origin inside the container and waits for it to accept sockets. */ -export function startSshRemoteOnlyBrowserFixture(target: DockerSshRelayTarget): void { - execDockerSshRelayTargetCommand( +export function startSshRemoteOnlyBrowserFixture(target: DockerSshHostTarget): void { + execDockerSshHostTargetCommand( target, - `grep -q ${shellQuote(SSH_REMOTE_ONLY_HOST)} /etc/hosts || printf '127.0.0.1 %s\\n' ${shellQuote( + `grep -q ${quotePosixShell(SSH_REMOTE_ONLY_HOST)} /etc/hosts || printf '127.0.0.1 %s\\n' ${quotePosixShell( SSH_REMOTE_ONLY_HOST )} >> /etc/hosts` ) - writeDockerSshRelayTargetFile(target, SERVER_PATH, SERVER_SOURCE) - execDockerSshRelayTargetCommand( + writeDockerSshHostTargetFile(target, SERVER_PATH, SERVER_SOURCE) + execDockerSshHostTargetCommand( target, - `rm -f ${shellQuote(REQUEST_LOG_PATH)}; nohup node ${shellQuote( + `rm -f ${quotePosixShell(REQUEST_LOG_PATH)}; nohup node ${quotePosixShell( SERVER_PATH )} >/tmp/sta-4150-ssh-browser-fixture.log 2>&1 /dev/null || true` + `cat ${quotePosixShell(REQUEST_LOG_PATH)} 2>/dev/null || true` ) return contents .split('\n') .filter((line) => line.trim().length > 0) .map((line) => JSON.parse(line) as SshRemoteOnlyRequest) } - -/** - * Kills the container's established SSH sessions without touching the listener, so the - * transport really dies and the same target can be reconnected. - * - * `pgrep -f '^sshd: '` matches only accepted-connection processes: the daemon's own command - * line is `/usr/sbin/sshd -D -e`, and the shell running this command starts with `bash`. - */ -export function killSshRelayTargetTransport(target: DockerSshRelayTarget): number { - const killed = execDockerSshRelayTargetControlCommand( - target, - "pids=$(pgrep -f '^sshd: ' || true); for pid in $pids; do kill -9 $pid || true; done; printf '%s' \"$(printf '%s\\n' $pids | grep -c . || true)\"" - ) - return Number(killed.trim() || '0') -} diff --git a/tests/e2e/helpers/startup-exec-readiness-oracle.ts b/tests/e2e/helpers/startup-exec-readiness-oracle.ts index 7aa56e388634..3bb438628252 100644 --- a/tests/e2e/helpers/startup-exec-readiness-oracle.ts +++ b/tests/e2e/helpers/startup-exec-readiness-oracle.ts @@ -10,6 +10,7 @@ import { toWebTerminalSurfaceTabId } from '../../../src/shared/terminal-surface- import { expect } from './orca-app' import { getTerminalContent, waitForActivePanePtyId } from './terminal' import { readFreshTerminalInventory } from './terminal-inventory-observation' +import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' const RECOVERY_DEADLINE_MS = 8_000 @@ -39,8 +40,8 @@ function execProfileContents( const guard = `ORCA_STA4067_EXEC_${runId.replaceAll(/[^A-Za-z0-9_]/g, '_')}` const barrierScript = barrier ? [ - `: > ${shellQuote(barrier.startedPath)}`, - `while [[ ! -e ${shellQuote(barrier.releasePath)} ]]; do sleep 0.02; done` + `: > ${quotePosixShell(barrier.startedPath)}`, + `while [[ ! -e ${quotePosixShell(barrier.releasePath)} ]]; do sleep 0.02; done` ].join('\n ') : '' return `if [[ -z "\${${guard}:-}" ]]; then @@ -59,10 +60,6 @@ export function zshExecProfileContents(runId: string, barrier?: BashExecProfileB return execProfileContents(runId, ZSH_EXEC_LINE, barrier) } -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - function splitMarker(marker: string): [string, string] { const midpoint = Math.floor(marker.length / 2) return [marker.slice(0, midpoint), marker.slice(midpoint)] @@ -70,7 +67,7 @@ function splitMarker(marker: string): [string, string] { function markerCommand(marker: string): string { const [left, right] = splitMarker(marker) - return `printf '%s%s\\n' ${shellQuote(left)} ${shellQuote(right)}` + return `printf '%s%s\\n' ${quotePosixShell(left)} ${quotePosixShell(right)}` } function count(text: string, marker: string): number { @@ -150,7 +147,7 @@ export async function createStartupExecTerminal( ): Promise { const startupMarker = `STA4067_STARTUP_READY_${runId}` const command = [ - `printf '%s|%s\\n' "$$" "$(tty)" > ${shellQuote(ledgerPath)}`, + `printf '%s|%s\\n' "$$" "$(tty)" > ${quotePosixShell(ledgerPath)}`, markerCommand(startupMarker) ].join('; ') const created = await callStartupExecRuntime<{ diff --git a/tests/e2e/helpers/terminal-workspace-root-link.ts b/tests/e2e/helpers/terminal-workspace-root-link.ts index 2798cd458fa4..c59478f0e5b6 100644 --- a/tests/e2e/helpers/terminal-workspace-root-link.ts +++ b/tests/e2e/helpers/terminal-workspace-root-link.ts @@ -1,7 +1,7 @@ import type { Page, TestInfo } from '@stablyai/playwright-test' import { expect } from './orca-app' -import { shellQuote } from './docker-ssh-relay-target' import { focusActiveTerminalInput, getTerminalContent } from './terminal' +import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' export async function openTerminalWorkspaceRootLink( page: Page, @@ -13,7 +13,7 @@ export async function openTerminalWorkspaceRootLink( await focusActiveTerminalInput(page) const readyMarker = `ROOT_LINK_PRINTED_${Date.now()}` await page.keyboard.insertText( - String.raw`printf '\n%s\n%s\n' ${shellQuote(rootPath)} ${shellQuote(readyMarker)}` + String.raw`printf '\n%s\n%s\n' ${quotePosixShell(rootPath)} ${quotePosixShell(readyMarker)}` ) await page.keyboard.press('Enter') await expect diff --git a/tests/e2e/host-parked-pane-remote-viewer.spec.ts b/tests/e2e/host-parked-pane-remote-viewer.spec.ts index f2f8dd7fb397..de722116b12f 100644 --- a/tests/e2e/host-parked-pane-remote-viewer.spec.ts +++ b/tests/e2e/host-parked-pane-remote-viewer.spec.ts @@ -46,6 +46,7 @@ import { } from './helpers/paired-electron-client' import { focusActiveTerminalInput } from './helpers/terminal' import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' const PARK_DELAY_MS = 30_000 const PAINT_BUDGET_MS = 20_000 @@ -81,15 +82,11 @@ test.afterAll(() => { // Why: the HOST must park quickly too, so its launch env carries the override. test.use({ orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARK_DELAY_MS) } }) -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - function fixtureCommand(sinkPath: string): string { const command = [process.execPath, fixturePath, sinkPath] return process.platform === 'win32' ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') - : command.map(shellQuote).join(' ') + : command.map(quotePosixShell).join(' ') } function readSink(sinkPath: string): string { diff --git a/tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts b/tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts index 0374e7b83161..707c9c9946fb 100644 --- a/tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts +++ b/tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts @@ -39,6 +39,7 @@ import { type PairedElectronClient } from './helpers/paired-electron-client' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' /** What a user would accept for "my terminal is back" after reopening the app. */ const RESTORE_BUDGET_MS = 60_000 @@ -72,15 +73,11 @@ test.afterAll(() => { rmSync(scratch, { recursive: true, force: true }) }) -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - function fixtureCommand(sinkPath: string): string { const command = [process.execPath, fixturePath, sinkPath] return process.platform === 'win32' ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') - : command.map(shellQuote).join(' ') + : command.map(quotePosixShell).join(' ') } function readSinkLines(sinkPath: string): string[] { diff --git a/tests/e2e/paired-remote-terminal-host-quit-reconnect-input.spec.ts b/tests/e2e/paired-remote-terminal-host-quit-reconnect-input.spec.ts index 5bd8d4906824..cb02e03d678d 100644 --- a/tests/e2e/paired-remote-terminal-host-quit-reconnect-input.spec.ts +++ b/tests/e2e/paired-remote-terminal-host-quit-reconnect-input.spec.ts @@ -30,6 +30,7 @@ import { holdWindowGraphPublication, releaseWindowGraphPublication } from './helpers/hold-window-graph-publication' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-host-quit-input-')) const fixturePath = path.join(scratch, 'paired-host-quit-terminal.mjs') @@ -59,16 +60,12 @@ test.afterAll(() => { rmSync(scratch, { recursive: true, force: true }) }) -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - function fixtureCommand(sinkPath: string): string { const command = [process.execPath, fixturePath, sinkPath] return process.platform === 'win32' ? // Why: the isolated Windows profile starts PowerShell, which parses a leading quoted path as a string, not a command. `& ${command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ')}` - : command.map(shellQuote).join(' ') + : command.map(quotePosixShell).join(' ') } function seededRepoPathOrSkip(): string { diff --git a/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts index b895170529fc..9e1c1de712ce 100644 --- a/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts +++ b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts @@ -16,6 +16,7 @@ import { } from './helpers/paired-electron-client' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' import { waitForTabParked } from './helpers/terminal-hidden-parking' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' const PARK_DELAY_MS = 2_000 const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-host-restart-background-')) @@ -47,15 +48,11 @@ test.afterAll(() => { rmSync(scratch, { recursive: true, force: true }) }) -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - function fixtureCommand(sinkPath: string): string { const command = [process.execPath, fixturePath, sinkPath] return process.platform === 'win32' ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') - : command.map(shellQuote).join(' ') + : command.map(quotePosixShell).join(' ') } function seededRepoPathOrSkip(): string { diff --git a/tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts b/tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts index e868149db8b1..86126bc054bc 100644 --- a/tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts +++ b/tests/e2e/paired-remote-terminal-initial-snapshot-scrollback.spec.ts @@ -25,6 +25,7 @@ import { readPairedPaneContent, waitForPairedPaneMarker } from './helpers/paired-host-terminal' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' const LINE_COUNT = 300 const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-initial-scrollback-')) @@ -48,7 +49,7 @@ function fixtureCommand(): string { const command = [process.execPath, fixturePath] return process.platform === 'win32' ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') - : command.map((value) => `'${value.replaceAll("'", `'\\''`)}'`).join(' ') + : command.map(quotePosixShell).join(' ') } test('a desktop client opening a remote terminal receives its scrollback, not just the screen', async ({ diff --git a/tests/e2e/paired-remote-terminal-lossy-initial-snapshot.spec.ts b/tests/e2e/paired-remote-terminal-lossy-initial-snapshot.spec.ts index 0747e3e6bea1..48cb5c18d2eb 100644 --- a/tests/e2e/paired-remote-terminal-lossy-initial-snapshot.spec.ts +++ b/tests/e2e/paired-remote-terminal-lossy-initial-snapshot.spec.ts @@ -9,6 +9,7 @@ import { createRuntimeDesktopPairingOffer, launchPairedElectronClient } from './helpers/paired-electron-client' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-lossy-snapshot-')) const fixturePath = path.join(scratch, 'lossy-snapshot-terminal.mjs') @@ -29,15 +30,11 @@ test.use({ orcaAppExtraEnv: { ORCA_E2E_FORCE_REMOTE_TERMINAL_INITIAL_SNAPSHOT_TRUNCATED: '1' } }) -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - function fixtureCommand(marker: string): string { const command = [process.execPath, fixturePath, marker] return process.platform === 'win32' ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') - : command.map(shellQuote).join(' ') + : command.map(quotePosixShell).join(' ') } async function callRuntime( diff --git a/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts b/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts index fda18fc74c64..1cb35438189b 100644 --- a/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts +++ b/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts @@ -17,6 +17,7 @@ import { } from './helpers/paired-electron-client' import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal' import { readFreshTerminalInventory } from './helpers/terminal-inventory-observation' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-materialize-')) const fixturePath = path.join(scratch, 'materialize-terminal.mjs') @@ -49,15 +50,11 @@ test.afterAll(() => { rmSync(scratch, { recursive: true, force: true }) }) -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - function fixtureCommand(): string { const command = [process.execPath, fixturePath, processedInputPath] return process.platform === 'win32' ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') - : command.map(shellQuote).join(' ') + : command.map(quotePosixShell).join(' ') } async function callRuntime( diff --git a/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts b/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts index 7c413c061263..1b9e87d50b28 100644 --- a/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts +++ b/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts @@ -37,6 +37,7 @@ import { } from './helpers/paired-electron-client' import { focusActiveTerminalInput } from './helpers/terminal' import { waitForTabParked } from './helpers/terminal-hidden-parking' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' const PARK_DELAY_MS = 2_000 const LIVE_PAINT_BUDGET_MS = 12_000 @@ -75,15 +76,11 @@ test.afterAll(() => { rmSync(scratch, { recursive: true, force: true }) }) -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - function fixtureCommand(sinkPath: string): string { const command = [process.execPath, fixturePath, sinkPath] return process.platform === 'win32' ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') - : command.map(shellQuote).join(' ') + : command.map(quotePosixShell).join(' ') } function readSink(sinkPath: string): string { diff --git a/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts b/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts index 608c31111af2..b91818bff365 100644 --- a/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts +++ b/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts @@ -55,6 +55,7 @@ import { import { focusActiveTerminalInput } from './helpers/terminal' import { waitForTabParked } from './helpers/terminal-hidden-parking' import { readPersistedProfileState } from './helpers/persisted-profile-state' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' const PARK_DELAY_MS = 2_000 const PAINT_BUDGET_MS = 30_000 @@ -88,7 +89,7 @@ function fixtureCommand(): string { const command = [process.execPath, fixturePath] return process.platform === 'win32' ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') - : command.map((value) => `'${value.replaceAll("'", `'\\''`)}'`).join(' ') + : command.map(quotePosixShell).join(' ') } type OnDiskPartitionReading = { diff --git a/tests/e2e/paired-remote-terminal-parked-scrollback-survives.spec.ts b/tests/e2e/paired-remote-terminal-parked-scrollback-survives.spec.ts index e7ca5fc566a7..cc8e62de335b 100644 --- a/tests/e2e/paired-remote-terminal-parked-scrollback-survives.spec.ts +++ b/tests/e2e/paired-remote-terminal-parked-scrollback-survives.spec.ts @@ -49,6 +49,7 @@ import { } from './helpers/paired-host-terminal' import { focusActiveTerminalInput } from './helpers/terminal' import { waitForTabParked } from './helpers/terminal-hidden-parking' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' const PARK_DELAY_MS = 2_000 const PAINT_BUDGET_MS = 30_000 @@ -81,15 +82,11 @@ test.afterAll(() => { rmSync(scratch, { recursive: true, force: true }) }) -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - function fixtureCommand(): string { const command = [process.execPath, fixturePath] return process.platform === 'win32' ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') - : command.map(shellQuote).join(' ') + : command.map(quotePosixShell).join(' ') } type ParkKind = 'ordinary' | 'force' diff --git a/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts b/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts index 9cd76f0d3e54..81335f1b9913 100644 --- a/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts +++ b/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts @@ -14,6 +14,7 @@ import { launchPairedWebClient } from './helpers/paired-electron-client' import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-probe-gap-')) const fixturePath = path.join(scratch, 'probe-gap-terminal.mjs') @@ -44,15 +45,11 @@ test.afterAll(() => { rmSync(scratch, { recursive: true, force: true }) }) -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - function fixtureCommand(): string { const command = [process.execPath, fixturePath, processedInputPath] return process.platform === 'win32' ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') - : command.map(shellQuote).join(' ') + : command.map(quotePosixShell).join(' ') } async function callRuntime(page: Page, method: string, params: unknown): Promise { diff --git a/tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts b/tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts index 79c7145cd22a..aeed931b7e86 100644 --- a/tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts +++ b/tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts @@ -11,6 +11,7 @@ import { launchPairedWebClient } from './helpers/paired-electron-client' import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' const RETENTION_PARK_DELAY_MS = 100 const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-truncated-tail-')) @@ -46,15 +47,11 @@ test.use({ } }) -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - function fixtureCommand(marker: string): string { const command = [process.execPath, fixturePath, marker] return process.platform === 'win32' ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') - : command.map(shellQuote).join(' ') + : command.map(quotePosixShell).join(' ') } async function callRuntime(page: Page, method: string, params: unknown): Promise { diff --git a/tests/e2e/remote-agent-session-focus-authority.spec.ts b/tests/e2e/remote-agent-session-focus-authority.spec.ts index f8f9facd446f..a1594f34fa3d 100644 --- a/tests/e2e/remote-agent-session-focus-authority.spec.ts +++ b/tests/e2e/remote-agent-session-focus-authority.spec.ts @@ -9,6 +9,7 @@ import { } from './helpers/paired-electron-client' import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' import type { RuntimeTerminalSummary } from '../../src/shared/runtime-types' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' type ClientMirror = { activeTabId: string | null @@ -45,15 +46,11 @@ test.afterAll(() => { rmSync(scratch, { recursive: true, force: true }) }) -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - function fixtureCommand(scriptPath: string, ...args: string[]): string { const command = [process.execPath, scriptPath, ...args] return process.platform === 'win32' ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') - : command.map(shellQuote).join(' ') + : command.map(quotePosixShell).join(' ') } function countAgentSpawns(): number { diff --git a/tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts b/tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts index b0e782689e86..a2099fee4232 100644 --- a/tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts +++ b/tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts @@ -3,18 +3,17 @@ import { readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import { Client } from 'ssh2' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import type { TestInfo } from '@stablyai/playwright-test' import type { SshConnection } from '../../src/main/ssh/ssh-connection' import type { SshProviderEpoch } from '../../src/shared/ssh-types' -import { ensureDockerSshRelayImage } from './helpers/docker-ssh-relay-image' +import { ensureDockerSshHostImage } from './helpers/docker-ssh-host-image' import { - cleanupDockerSshRelayTarget, - execDockerSshRelayTargetCommand, - shellQuote, - startDockerSshRelayTarget, - type DockerSshRelayTarget -} from './helpers/docker-ssh-relay-target' + cleanupDockerSshHostTarget, + execDockerSshHostTargetCommand, + startDockerSshHostTarget, + type DockerSshHostTarget +} from './helpers/docker-ssh-host-target' import { resolveSshBrowserNetworkExecutionRoute } from '../../src/main/browser/ssh-browser-network-execution-route' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' const runDocker = process.env.ORCA_RUN_DOCKER_SSH_BROWSER_E2E === '1' const executionHost = { @@ -25,13 +24,13 @@ const executionHost = { } describe.runIf(runDocker)('SSH browser network execution route Docker journey', () => { - let target: DockerSshRelayTarget | null = null + let target: DockerSshHostTarget | null = null let client: Client | null = null beforeAll(async () => { - ensureDockerSshRelayImage(process.cwd()) - target = startDockerSshRelayTarget({ workerIndex: 0 } as TestInfo) - execDockerSshRelayTargetCommand( + ensureDockerSshHostImage(process.cwd()) + target = startDockerSshHostTarget({ workerIndex: 0 }) + execDockerSshHostTargetCommand( target, "grep -q 'remote-only.internal' /etc/hosts || printf '127.0.0.1 remote-only.internal\\n' >> /etc/hosts" ) @@ -39,9 +38,9 @@ describe.runIf(runDocker)('SSH browser network execution route Docker journey', "const http=require('http')", "http.createServer((_request,response)=>response.end('sta-4150-remote-only')).listen(18080,'127.0.0.1')" ].join(';') - execDockerSshRelayTargetCommand( + execDockerSshHostTargetCommand( target, - `nohup node -e ${shellQuote(server)} >/tmp/sta-4150-http.log 2>&1 /tmp/sta-4150-http.log 2>&1 {socket.destroy();if(Date.now()>=deadline)process.exit(1);setTimeout(probe,25)})}", 'probe()' ].join(';') - execDockerSshRelayTargetCommand(target, `node -e ${shellQuote(waitForServer)}`) + execDockerSshHostTargetCommand(target, `node -e ${quotePosixShell(waitForServer)}`) client = new Client() await new Promise((resolve, reject) => { client!.once('ready', resolve) @@ -70,7 +69,7 @@ describe.runIf(runDocker)('SSH browser network execution route Docker journey', if (client) { client.end() } - cleanupDockerSshRelayTarget(target) + cleanupDockerSshHostTarget(target) }) it('resolves and connects the exact remote-only domain on the SSH host', async () => { diff --git a/tests/e2e/ssh-orcad-browser-capabilities.spec.ts b/tests/e2e/ssh-orcad-browser-capabilities.spec.ts index fbe6e29e3d11..0930ef7f7629 100644 --- a/tests/e2e/ssh-orcad-browser-capabilities.spec.ts +++ b/tests/e2e/ssh-orcad-browser-capabilities.spec.ts @@ -4,10 +4,10 @@ import { waitForSessionReady } from './helpers/store' import { startOrcadConvertHost } from './helpers/orcad-convert-host' import { reconnect, serverCall } from './helpers/orcad-convert-flow' import { runtimeAdvertisesBrowserClientHosting } from '../../src/shared/browser-client-hosting-eligibility' +import { ORCAD_TEMPLATE, skipUnlessManagedDockerHost } from './helpers/orcad-managed-workspace' -const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE -test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template') -test.use({ orcaAppExtraEnv: { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE } }) +skipUnlessManagedDockerHost() +test.use({ orcaAppExtraEnv: { ORCA_ORCAD_TEMPLATE_PATH: ORCAD_TEMPLATE } }) test('a backendless managed host does not promise client-hosted browser creation', async ({ orcaPage: page diff --git a/tests/e2e/ssh-orcad-browser-drop-owner.spec.ts b/tests/e2e/ssh-orcad-browser-drop-owner.spec.ts index 471646d634d9..88de5b66bdad 100644 --- a/tests/e2e/ssh-orcad-browser-drop-owner.spec.ts +++ b/tests/e2e/ssh-orcad-browser-drop-owner.spec.ts @@ -1,16 +1,16 @@ import { writeFileSync } from 'node:fs' import path from 'node:path' -import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import type { Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' -import { createRestartSession } from './helpers/orca-restart' -import { startOrcadConvertHost } from './helpers/orcad-convert-host' -import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' -import { convertAndRetain, serverCall } from './helpers/orcad-convert-flow' -import { waitForSessionReady } from './helpers/store' +import { serverCall } from './helpers/orcad-convert-flow' import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' import { openFileExplorer } from './helpers/file-explorer' -import { shellQuote } from './helpers/docker-ssh-relay-target' import { startBrowserSplitPageServer } from './helpers/browser-split-page-server' +import { + launchConvertedProfile, + skipUnlessManagedDockerHost, + withManagedHost +} from './helpers/orcad-managed-workspace' import { navigateGuest, waitForGuestUrl, @@ -18,9 +18,9 @@ import { } from './helpers/browser-split-guest-probes' import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' import { getWorktreeHostIdentity } from '../../src/shared/worktree/host-qualified-identity' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' -const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE -test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs owned Docker host') +skipUnlessManagedDockerHost() test.skip(process.platform === 'win32', 'Owned POSIX host collision fixture') async function dropExplorerFile(page: Page, name: string, tabId: string) { @@ -78,47 +78,31 @@ test('managed Files drag cannot load a desktop same-path HTML file', async ({ const fileName = 'DROP_OWNER.html' const filePath = path.join(testRepoPath, fileName) writeFileSync(filePath, 'DESKTOP_BROWSER_DROP_OWNER') - const host = startOrcadConvertHost('docker', testInfo) const server = await startBrowserSplitPageServer() - const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) - let app: ElectronApplication | null = null - try { + await withManagedHost(testInfo, async (fixture) => { + const { host } = fixture if (!host.exec) { throw new Error('Missing owned host control') } host.exec( - `mkdir -p ${shellQuote(path.dirname(testRepoPath))} && git clone --quiet ${shellQuote(host.remoteRepoPath)} ${shellQuote(testRepoPath)} && printf '%s' 'REMOTE_BROWSER_DROP_OWNER' > ${shellQuote(filePath)}` + `mkdir -p ${quotePosixShell(path.dirname(testRepoPath))} && git clone --quiet ${quotePosixShell(host.remoteRepoPath)} ${quotePosixShell(testRepoPath)} && printf '%s' 'REMOTE_BROWSER_DROP_OWNER' > ${quotePosixShell(filePath)}` ) host.exec( - `printf '%s' REMOTE_DROP_READY > ${shellQuote(path.join(testRepoPath, 'REMOTE_DROP_READY.txt'))}` + `printf '%s' REMOTE_DROP_READY > ${quotePosixShell(path.join(testRepoPath, 'REMOTE_DROP_READY.txt'))}` ) - const first = await session.launch() - app = first.app - await waitForSessionReady(first.page) - const nativeId = await first.page.evaluate(async (repoPath) => { - const result = await window.api.repos.add({ path: repoPath }) - if ('error' in result) { - throw new Error(result.error) - } - return `${result.repo.id}::${repoPath}` - }, testRepoPath) - await session.close(app) - app = null - const seeded = seedRelayEraProfile(session.userDataDir, host.input, { + let nativeId = '' + const { page, seeded, environmentId } = await launchConvertedProfile(fixture, { repoPath: testRepoPath, - folderPath: host.remoteFolderPath + beforeSeed: async (page) => { + nativeId = await page.evaluate(async (repoPath) => { + const result = await window.api.repos.add({ path: repoPath }) + if ('error' in result) { + throw new Error(result.error) + } + return `${result.repo.id}::${repoPath}` + }, testRepoPath) + } }) - const launched = await session.launch() - app = launched.app - const page = launched.page - await waitForSessionReady(page) - await convertAndRetain(page, session.userDataDir, seeded) - const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( - (entry) => entry.orcadDeployment?.sshTargetId === seeded.targetId - ) - if (!environment) { - throw new Error('Missing actual managed environment') - } await dismissTransientAnnouncement(page) const nativeIdentity = getWorktreeHostIdentity({ id: nativeId, hostId: 'local' }) await page.locator(`[data-worktree-host-identity="${nativeIdentity}"]:visible`).click() @@ -156,7 +140,7 @@ test('managed Files drag cannot load a desktop same-path HTML file', async ({ await waitForGuestIdle(page, floatingId) const remoteIdentity = getWorktreeHostIdentity({ id: seeded.worktreeId, - hostId: toRuntimeExecutionHostId(environment.id) + hostId: toRuntimeExecutionHostId(environmentId) }) await page.locator(`[data-worktree-host-identity="${remoteIdentity}"]:visible`).click() await openFileExplorer(page) @@ -166,10 +150,10 @@ test('managed Files drag cannot load a desktop same-path HTML file', async ({ ).toBeVisible() expect( await page.evaluate(() => window.__store?.getState().activeWorkspaceExecutionHostId) - ).toBe(toRuntimeExecutionHostId(environment.id)) + ).toBe(toRuntimeExecutionHostId(environmentId)) console.log( '[independent-managed-source]', - await serverCall(page, environment.id, 'files.read', { + await serverCall(page, environmentId, 'files.read', { worktree: `id:${seeded.worktreeId}`, relativePath: fileName }) @@ -179,7 +163,7 @@ test('managed Files drag cannot load a desktop same-path HTML file', async ({ expect( JSON.parse(remotePayload['application/x-orca-workspace-file-source'] ?? '{}') ).toMatchObject({ - executionHostId: toRuntimeExecutionHostId(environment.id), + executionHostId: toRuntimeExecutionHostId(environmentId), workspaceId: seeded.worktreeId }) await waitForGuestIdle(page, floatingId) @@ -190,15 +174,5 @@ test('managed Files drag cannot load a desktop same-path HTML file', async ({ page.getByText('Open in Orca Browser is only available for local files.', { exact: true }) ).toBeVisible() expect((await guestText(page, floatingId)).url).toBe(server.pageUrl('float', 2)) - } finally { - try { - if (app) { - await session.close(app) - } - await session.dispose() - await server.close() - } finally { - host.cleanup() - } - } + }).finally(() => server.close()) }) diff --git a/tests/e2e/ssh-orcad-browser-routing.spec.ts b/tests/e2e/ssh-orcad-browser-routing.spec.ts index a3af5dc0feca..5549570460ea 100644 --- a/tests/e2e/ssh-orcad-browser-routing.spec.ts +++ b/tests/e2e/ssh-orcad-browser-routing.spec.ts @@ -5,10 +5,10 @@ import { expect, test } from './helpers/orca-app' import { createRestartSession } from './helpers/orca-restart' import { waitForSessionReady, waitForStartupWorktreeRefresh } from './helpers/store' import { - cleanupDockerSshRelayTarget, - DOCKER_SSH_RELAY_REMOTE_REPO_PATH, - startDockerSshRelayTarget -} from './helpers/docker-ssh-relay-target' + cleanupDockerSshHostTarget, + DOCKER_SSH_HOST_REMOTE_REPO_PATH, + startDockerSshHostTarget +} from './helpers/docker-ssh-host-target' import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' import { startSshRemoteOnlyBrowserFixture, @@ -24,9 +24,9 @@ import { deriveBrowserRoutePartition } from '../../src/main/browser/browser-rout import { sshExecutionHostStorageIdentity } from '../../src/main/browser/browser-execution-host-storage-identity' import { reconnect } from './helpers/orcad-convert-flow' import { navigateGuest } from './helpers/browser-split-guest-probes' +import { ORCAD_TEMPLATE, skipUnlessManagedDockerHost } from './helpers/orcad-managed-workspace' -const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE -test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template') +skipUnlessManagedDockerHost() /** The partition a relay-era build kept this host's browser storage in (local-ssh-browser-partitions). */ function relayEraPartition(targetId: string): string { @@ -58,7 +58,7 @@ async function guestMarker(page: Page, tabId: string): Promise { test('retained and new browser tabs keep SSH routing and login cookies after managed conversion', async (// oxlint-disable-next-line no-empty-pattern -- Owns app launch. {}, testInfo) => { test.setTimeout(5 * 60_000) - const target = startDockerSshRelayTarget(testInfo) + const target = startDockerSshHostTarget(testInfo) const scratch = createRetentionFixtureDirectory() const template = path.join(scratch, 'template') const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: template }) @@ -82,7 +82,7 @@ test('retained and new browser tabs keep SSH routing and login cookies after man identitiesOnly: true, relayGracePeriodSeconds: 1 }, - { repoPath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, folderPath: '/tmp' } + { repoPath: DOCKER_SSH_HOST_REMOTE_REPO_PATH, folderPath: '/tmp' } ) const launched = await session.launch() app = launched.app @@ -147,7 +147,7 @@ test('retained and new browser tabs keep SSH routing and login cookies after man value: SSH_REMOTE_ONLY_COOKIE_VALUE } ) - cpSync(TEMPLATE!, template, { recursive: true }) + cpSync(ORCAD_TEMPLATE!, template, { recursive: true }) const server = await reconnect(page, remote.targetId) console.log('[browser-conversion-connect]', server) expect(JSON.parse(server)).toMatchObject({ kind: 'managed' }) @@ -366,7 +366,7 @@ test('retained and new browser tabs keep SSH routing and login cookies after man await session.close(app) } await session.dispose() - cleanupDockerSshRelayTarget(target) + cleanupDockerSshHostTarget(target) rmSync(scratch, { recursive: true, force: true }) } }) diff --git a/tests/e2e/ssh-orcad-browser-service-status.spec.ts b/tests/e2e/ssh-orcad-browser-service-status.spec.ts index cd11f50de70f..d3f95b589291 100644 --- a/tests/e2e/ssh-orcad-browser-service-status.spec.ts +++ b/tests/e2e/ssh-orcad-browser-service-status.spec.ts @@ -5,10 +5,10 @@ import { expect, test } from './helpers/orca-app' import { createRestartSession } from './helpers/orca-restart' import { waitForSessionReady } from './helpers/store' import { - cleanupDockerSshRelayTarget, - DOCKER_SSH_RELAY_REMOTE_REPO_PATH, - startDockerSshRelayTarget -} from './helpers/docker-ssh-relay-target' + cleanupDockerSshHostTarget, + DOCKER_SSH_HOST_REMOTE_REPO_PATH, + startDockerSshHostTarget +} from './helpers/docker-ssh-host-target' import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' import { startSshRemoteOnlyBrowserFixture, @@ -16,14 +16,14 @@ import { } from './helpers/ssh-remote-only-browser-fixture' import { createRetentionFixtureDirectory } from './helpers/host-created-terminal-retention-oracle' import { reconnect, serverCall } from './helpers/orcad-convert-flow' +import { ORCAD_TEMPLATE, skipUnlessManagedDockerHost } from './helpers/orcad-managed-workspace' -const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE -test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template') +skipUnlessManagedDockerHost() test('an unavailable browser on a responding managed host does not report a server outage', async (// oxlint-disable-next-line no-empty-pattern -- Owns app launch. {}, testInfo) => { test.setTimeout(5 * 60_000) - const target = startDockerSshRelayTarget(testInfo) + const target = startDockerSshHostTarget(testInfo) const scratch = createRetentionFixtureDirectory() const template = path.join(scratch, 'template') const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: template }) @@ -47,7 +47,7 @@ test('an unavailable browser on a responding managed host does not report a serv identitiesOnly: true, relayGracePeriodSeconds: 1 }, - { repoPath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, folderPath: '/tmp' } + { repoPath: DOCKER_SSH_HOST_REMOTE_REPO_PATH, folderPath: '/tmp' } ) const launched = await session.launch() app = launched.app @@ -58,7 +58,7 @@ test('an unavailable browser on a responding managed host does not report a serv } }) await waitForSessionReady(page) - cpSync(TEMPLATE!, template, { recursive: true }) + cpSync(ORCAD_TEMPLATE!, template, { recursive: true }) const server = await reconnect(page, remote.targetId) console.log('[browser-conversion-connect]', server) expect(JSON.parse(server)).toMatchObject({ kind: 'managed' }) @@ -168,7 +168,7 @@ test('an unavailable browser on a responding managed host does not report a serv await session.close(app) } await session.dispose() - cleanupDockerSshRelayTarget(target) + cleanupDockerSshHostTarget(target) rmSync(scratch, { recursive: true, force: true }) } }) diff --git a/tests/e2e/ssh-orcad-editor-ownership.spec.ts b/tests/e2e/ssh-orcad-editor-ownership.spec.ts index e4ac2ace8cde..38d0f8c290a4 100644 --- a/tests/e2e/ssh-orcad-editor-ownership.spec.ts +++ b/tests/e2e/ssh-orcad-editor-ownership.spec.ts @@ -1,87 +1,63 @@ -import type { ElectronApplication } from '@stablyai/playwright-test' +import type { Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' -import { createRestartSession } from './helpers/orca-restart' -import { startOrcadConvertHost } from './helpers/orcad-convert-host' -import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' -import { convertAndRetain, serverCall } from './helpers/orcad-convert-flow' -import { waitForSessionReady, waitForStartupWorktreeRefresh } from './helpers/store' -import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' -import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' +import { serverCall } from './helpers/orcad-convert-flow' +import { + activateManagedWorkspace, + launchManagedWorkspace, + relaunchManagedApp, + skipUnlessManagedDockerHost, + withManagedHost +} from './helpers/orcad-managed-workspace' -const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE -test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template') +skipUnlessManagedDockerHost() + +async function reportEditorRecords(page: Page, worktreeId: string): Promise { + page.on('console', (message) => { + if (message.text().startsWith('[editor-records]')) { + console.log(message.text()) + } + }) + await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('Missing renderer store') + } + const report = (state: ReturnType) => { + console.log( + '[editor-records]', + JSON.stringify({ + files: state.openFiles.filter((file) => file.worktreeId === worktreeId), + tabs: state.unifiedTabsByWorktree[worktreeId], + activeHost: state.activeWorkspaceExecutionHostId, + trace: new Error('Editor file mutation').stack + }) + ) + } + report(store.getState()) + store.subscribe((state, previous) => { + if (state.openFiles !== previous.openFiles) { + report(state) + } + }) + }, worktreeId) +} test('converted editor has one file record per document', async (// oxlint-disable-next-line no-empty-pattern -- This exercise owns its app launch. {}, testInfo) => { test.setTimeout(4 * 60_000) - const host = startOrcadConvertHost('docker', testInfo) - const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) - let app: ElectronApplication | null = null - try { - const first = await session.launch() - app = first.app - await waitForSessionReady(first.page) - await session.close(app) - app = null + await withManagedHost(testInfo, async (fixture) => { + const { host } = fixture const marker = `REMOTE_MARKDOWN_${Date.now()}` host.exec!( `printf '# ${marker}\n\nHost-owned Markdown.\n' > '${host.remoteRepoPath}/README.md'` ) - const seeded = seedRelayEraProfile(session.userDataDir, host.input, { - repoPath: host.remoteRepoPath, - folderPath: host.remoteFolderPath - }) - const launched = await session.launch() - app = launched.app - const page = launched.page - page.on('console', (message) => { - if (message.text().startsWith('[editor-records]')) { - console.log(message.text()) - } + const { page, seeded, environmentId } = await launchManagedWorkspace(fixture, { + onLaunch: (page, seeded) => reportEditorRecords(page, seeded.worktreeId) }) - await page.evaluate((worktreeId) => { - const store = window.__store - if (!store) { - throw new Error('Missing renderer store') - } - const report = (state: ReturnType) => { - console.log( - '[editor-records]', - JSON.stringify({ - files: state.openFiles.filter((file) => file.worktreeId === worktreeId), - tabs: state.unifiedTabsByWorktree[worktreeId], - activeHost: state.activeWorkspaceExecutionHostId, - trace: new Error('Editor file mutation').stack - }) - ) - } - report(store.getState()) - store.subscribe((state, previous) => { - if (state.openFiles !== previous.openFiles) { - report(state) - } - }) - }, seeded.worktreeId) - await waitForSessionReady(page) - await convertAndRetain(page, session.userDataDir, seeded) - const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( - (e) => e.orcadDeployment?.sshTargetId === seeded.targetId - ) - if (!environment) { - throw new Error('Missing managed environment') - } - await page.evaluate( - ({ id, hostId }) => window.__store?.getState().setActiveWorktree(id, hostId), - { - id: seeded.worktreeId, - hostId: toRuntimeExecutionHostId(environment.id) - } - ) - await dismissTransientAnnouncement(page) await expect(page.locator('.rich-markdown-editor')).toContainText(marker, { timeout: 30_000 }) console.log( '[host-tabs]', - await serverCall(page, environment.id, 'session.tabs.list', { + await serverCall(page, environmentId, 'session.tabs.list', { worktree: `id:${seeded.worktreeId}` }) ) @@ -104,24 +80,12 @@ test('converted editor has one file record per document', async (// oxlint-disab .poll(() => host.exec!(`cat '${seeded.sessionFilePath}'`), { timeout: 30_000 }) .toContain(savedMarker) console.log('[saved-on-host]', host.exec!(`cat '${seeded.sessionFilePath}'`)) - await session.close(app) - app = null - const restarted = await session.launch() - app = restarted.app - await waitForSessionReady(restarted.page) - await waitForStartupWorktreeRefresh(restarted.page) - await restarted.page.evaluate( - ({ id, hostId }) => window.__store?.getState().setActiveWorktree(id, hostId), - { - id: seeded.worktreeId, - hostId: toRuntimeExecutionHostId(environment.id) - } - ) - await dismissTransientAnnouncement(restarted.page) - await expect(restarted.page.locator('.rich-markdown-editor')).toContainText(savedMarker, { + const restarted = await relaunchManagedApp(fixture) + await activateManagedWorkspace(restarted, seeded.worktreeId, environmentId) + await expect(restarted.locator('.rich-markdown-editor')).toContainText(savedMarker, { timeout: 30_000 }) - const restoredFiles = await restarted.page.evaluate( + const restoredFiles = await restarted.evaluate( (worktreeId) => window.__store?.getState().openFiles.filter((file) => file.worktreeId === worktreeId), seeded.worktreeId @@ -130,12 +94,6 @@ test('converted editor has one file record per document', async (// oxlint-disab expect(restoredFiles?.filter((file) => file.filePath === seeded.sessionFilePath)).toHaveLength( 1 ) - await restarted.page.screenshot({ path: testInfo.outputPath('editor-restored.png') }) - } finally { - if (app) { - await session.close(app) - } - await session.dispose() - host.cleanup() - } + await restarted.screenshot({ path: testInfo.outputPath('editor-restored.png') }) + }) }) diff --git a/tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts b/tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts index 6541bfb536c8..1faae5c39010 100644 --- a/tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts +++ b/tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts @@ -1,15 +1,15 @@ -import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import type { Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' -import { createRestartSession } from './helpers/orca-restart' -import { startOrcadConvertHost } from './helpers/orcad-convert-host' -import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' -import { convertAndRetain, serverCall } from './helpers/orcad-convert-flow' -import { waitForSessionReady, waitForStartupWorktreeRefresh } from './helpers/store' -import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' -import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' +import { serverCall } from './helpers/orcad-convert-flow' +import { + activateManagedWorkspace, + launchConvertedProfile, + relaunchManagedApp, + skipUnlessManagedDockerHost, + withManagedHost +} from './helpers/orcad-managed-workspace' -const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE -test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template') +skipUnlessManagedDockerHost() function captureRendererMessages(page: Page): void { page.on('console', (message) => { @@ -27,57 +27,23 @@ for (const recovery of ['conversion', 'server restart'] as const) { test(`remote editors receive host file changes after ${recovery}`, async (// oxlint-disable-next-line no-empty-pattern -- This exercise owns its app launch. {}, testInfo) => { test.setTimeout(4 * 60_000) - const host = startOrcadConvertHost('docker', testInfo) - const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) - let app: ElectronApplication | null = null - try { + await withManagedHost(testInfo, async (fixture) => { + const { host } = fixture if (!host.exec) { throw new Error('Docker controls are unavailable') } const execute = host.exec - const first = await session.launch() - app = first.app - await waitForSessionReady(first.page) - await session.close(app) - app = null const marker = `REMOTE_MARKDOWN_${Date.now()}` execute( `printf '# ${marker}\n\nHost-owned Markdown.\n\n[[LATE]]\n' > '${host.remoteRepoPath}/README.md'` ) - const seeded = seedRelayEraProfile(session.userDataDir, host.input, { - repoPath: host.remoteRepoPath, - folderPath: host.remoteFolderPath - }) - const launched = await session.launch() - app = launched.app - let page = launched.page - captureRendererMessages(page) - await waitForSessionReady(page) - await convertAndRetain(page, session.userDataDir, seeded) - const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( - (e) => e.orcadDeployment?.sshTargetId === seeded.targetId - ) - if (!environment) { - throw new Error('Missing managed environment') - } - if (recovery === 'server restart') { - await session.close(app) - app = null - const restarted = await session.launch() - app = restarted.app - page = restarted.page - captureRendererMessages(page) - await waitForSessionReady(page) - await waitForStartupWorktreeRefresh(page) - } - await page.evaluate( - ({ id, hostId }) => window.__store?.getState().setActiveWorktree(id, hostId), - { - id: seeded.worktreeId, - hostId: toRuntimeExecutionHostId(environment.id) - } - ) - await dismissTransientAnnouncement(page) + const converted = await launchConvertedProfile(fixture, { onLaunch: captureRendererMessages }) + const { seeded, environmentId } = converted + const page = + recovery === 'server restart' + ? await relaunchManagedApp(fixture, captureRendererMessages) + : converted.page + await activateManagedWorkspace(page, seeded.worktreeId, environmentId) await expect(page.locator('.rich-markdown-editor')).toContainText(marker, { timeout: 30_000 }) await page.evaluate(() => { window.addEventListener('orca:worktree-file-change', (event) => { @@ -122,7 +88,7 @@ for (const recovery of ['conversion', 'server restart'] as const) { }) return response.ok ? JSON.stringify(response.result) : '' }, - { id: environment.id, worktreeId: seeded.worktreeId } + { id: environmentId, worktreeId: seeded.worktreeId } ), { timeout: 180_000 } ) @@ -132,7 +98,7 @@ for (const recovery of ['conversion', 'server restart'] as const) { execute('cat /root/.orca-remote/orcad-*/.orcad-pid').trim() ) } - const content = await serverCall(page, environment.id, 'files.read', { + const content = await serverCall(page, environmentId, 'files.read', { worktree: `id:${seeded.worktreeId}`, relativePath: 'README.md' }) @@ -151,7 +117,7 @@ for (const recovery of ['conversion', 'server restart'] as const) { timeout: 15_000 }) execute(`printf 'watcher recovered\\n' > '${host.remoteRepoPath}/WATCH_RECOVERED.txt'`) - const created = await serverCall(page, environment.id, 'files.read', { + const created = await serverCall(page, environmentId, 'files.read', { worktree: `id:${seeded.worktreeId}`, relativePath: 'WATCH_RECOVERED.txt' }) @@ -159,12 +125,6 @@ for (const recovery of ['conversion', 'server restart'] as const) { expect(created).toContain('watcher recovered') await resumed } - } finally { - if (app) { - await session.close(app) - } - await session.dispose() - host.cleanup() - } + }) }) } diff --git a/tests/e2e/ssh-orcad-explorer-selected-host.spec.ts b/tests/e2e/ssh-orcad-explorer-selected-host.spec.ts index 93742f1ede0b..4d15f299efda 100644 --- a/tests/e2e/ssh-orcad-explorer-selected-host.spec.ts +++ b/tests/e2e/ssh-orcad-explorer-selected-host.spec.ts @@ -9,9 +9,9 @@ import { waitForSessionReady, waitForStartupWorktreeRefresh } from './helpers/st import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' import { folderWorkspaceKey } from '../../src/shared/workspace-scope' +import { ORCAD_TEMPLATE, skipUnlessManagedDockerHost } from './helpers/orcad-managed-workspace' -const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE -test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template') +skipUnlessManagedDockerHost() for (const selectedHost of ['A', 'B'] as const) { test(`same-ID folder explorer follows selected host ${selectedHost}`, async (// oxlint-disable-next-line no-empty-pattern -- This exercise owns its app launch. @@ -19,8 +19,10 @@ for (const selectedHost of ['A', 'B'] as const) { test.setTimeout(5 * 60_000) const hostA = startOrcadConvertHost('docker', testInfo) const hostB = startOrcadConvertHost('docker', testInfo) - const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) - const otherSession = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) + const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: ORCAD_TEMPLATE! }) + const otherSession = createRestartSession(testInfo, { + ORCA_ORCAD_TEMPLATE_PATH: ORCAD_TEMPLATE! + }) let app: ElectronApplication | null = null let otherApp: ElectronApplication | null = null try { diff --git a/tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts b/tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts index 22be50340978..756209586705 100644 --- a/tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts +++ b/tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts @@ -1,16 +1,16 @@ -import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import type { Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' -import { createRestartSession } from './helpers/orca-restart' -import { startOrcadConvertHost } from './helpers/orcad-convert-host' -import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' -import { convertAndRetain, serverCall } from './helpers/orcad-convert-flow' -import { waitForSessionReady, waitForStartupWorktreeRefresh } from './helpers/store' -import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' -import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' +import { serverCall } from './helpers/orcad-convert-flow' +import { + activateManagedWorkspace, + launchConvertedProfile, + relaunchManagedApp, + skipUnlessManagedDockerHost, + withManagedHost +} from './helpers/orcad-managed-workspace' import { folderWorkspaceKey } from '../../src/shared/workspace-scope' -const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE -test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template') +skipUnlessManagedDockerHost() function captureRendererMessages(page: Page): void { page.on('console', (message) => { @@ -28,44 +28,19 @@ for (const workspace of ['worktree', 'folder'] as const) { test(`remote ${workspace} explorer receives host-created files after server restart`, async (// oxlint-disable-next-line no-empty-pattern -- This exercise owns its app launch. {}, testInfo) => { test.setTimeout(5 * 60_000) - const host = startOrcadConvertHost('docker', testInfo) - const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) - let app: ElectronApplication | null = null - try { + await withManagedHost(testInfo, async (fixture) => { + const { host } = fixture if (!host.exec) { throw new Error('Docker controls are unavailable') } const execute = host.exec - const first = await session.launch() - app = first.app - await waitForSessionReady(first.page) - await session.close(app) - app = null const folderPath = '/tmp/orca-explorer-folder' execute(`mkdir -p '${folderPath}'`) - const seeded = seedRelayEraProfile(session.userDataDir, host.input, { - repoPath: host.remoteRepoPath, - folderPath + const { seeded, environmentId } = await launchConvertedProfile(fixture, { + folderPath, + onLaunch: captureRendererMessages }) - const launched = await session.launch() - app = launched.app - captureRendererMessages(launched.page) - await waitForSessionReady(launched.page) - await convertAndRetain(launched.page, session.userDataDir, seeded) - const environment = ( - await launched.page.evaluate(() => window.api.runtimeEnvironments.list()) - ).find((entry) => entry.orcadDeployment?.sshTargetId === seeded.targetId) - if (!environment) { - throw new Error('Missing managed environment') - } - await session.close(app) - app = null - const restarted = await session.launch() - app = restarted.app - const page = restarted.page - captureRendererMessages(page) - await waitForSessionReady(page) - await waitForStartupWorktreeRefresh(page) + const page = await relaunchManagedApp(fixture, captureRendererMessages) const folderId = await page.evaluate( (path) => window.__store?.getState().folderWorkspaces.find((folder) => folder.folderPath === path) @@ -77,11 +52,7 @@ for (const workspace of ['worktree', 'folder'] as const) { } const worktreeId = workspace === 'folder' ? folderWorkspaceKey(folderId) : seeded.worktreeId const rootPath = workspace === 'folder' ? folderPath : host.remoteRepoPath - await page.evaluate( - ({ id, hostId }) => window.__store?.getState().setActiveWorktree(id, hostId), - { id: worktreeId, hostId: toRuntimeExecutionHostId(environment.id) } - ) - await dismissTransientAnnouncement(page) + await activateManagedWorkspace(page, worktreeId, environmentId) await page.evaluate(() => { window.addEventListener('orca:worktree-file-change', (event) => { if (event instanceof CustomEvent) { @@ -121,7 +92,7 @@ for (const workspace of ['worktree', 'folder'] as const) { }) return response.ok ? JSON.stringify(response.result) : '' }, - { id: environment.id, worktreeId } + { id: environmentId, worktreeId } ), { timeout: 180_000 } ) @@ -137,7 +108,7 @@ for (const workspace of ['worktree', 'folder'] as const) { timeout: 30_000 }) execute(`printf 'watcher recovered\n' > '${rootPath}/WATCH_RECOVERED.txt'`) - const created = await serverCall(page, environment.id, 'files.read', { + const created = await serverCall(page, environmentId, 'files.read', { worktree: `id:${worktreeId}`, relativePath: 'WATCH_RECOVERED.txt' }) @@ -153,12 +124,6 @@ for (const workspace of ['worktree', 'folder'] as const) { timeout: 10_000 }) await page.screenshot({ path: testInfo.outputPath('remote-explorer-refreshed.png') }) - } finally { - if (app) { - await session.close(app) - } - await session.dispose() - host.cleanup() - } + }) }) } diff --git a/tests/e2e/ssh-orcad-idle-exit.spec.ts b/tests/e2e/ssh-orcad-idle-exit.spec.ts index cdfb78e060ae..12aa5424cfd4 100644 --- a/tests/e2e/ssh-orcad-idle-exit.spec.ts +++ b/tests/e2e/ssh-orcad-idle-exit.spec.ts @@ -16,12 +16,12 @@ import { createRestartSession } from './helpers/orca-restart' import { reconnect } from './helpers/orcad-convert-flow' import { ORCAD_CONVERT_HOST_ENV } from './helpers/orcad-convert-host' import { - cleanupDockerSshRelayTarget, - DOCKER_SSH_RELAY_REMOTE_REPO_PATH, - execDockerSshRelayTargetCommand, - startDockerSshRelayTarget, - type DockerSshRelayTarget -} from './helpers/docker-ssh-relay-target' + cleanupDockerSshHostTarget, + DOCKER_SSH_HOST_REMOTE_REPO_PATH, + execDockerSshHostTargetCommand, + startDockerSshHostTarget, + type DockerSshHostTarget +} from './helpers/docker-ssh-host-target' import { ORCAD_E2E_IDLE_TIMEOUT_ENV } from '../../src/shared/orcad-idle-exit' const HOST = process.env[ORCAD_CONVERT_HOST_ENV] @@ -31,8 +31,8 @@ const IDLE_TIMEOUT_MS = 15_000 const RECORD = '/root/.orca/orcad-idle-stop.json' /** PIDs of running orcad slots; empty once every slot has exited. */ -function runningOrcadPids(target: DockerSshRelayTarget): string[] { - return execDockerSshRelayTargetCommand( +function runningOrcadPids(target: DockerSshHostTarget): string[] { + return execDockerSshHostTargetCommand( target, 'for f in /root/.orca-remote/orcad-*/.orcad-pid; do pid=$(cat "$f" 2>/dev/null) && ' + 'kill -0 "$pid" 2>/dev/null && echo "$pid"; done; true' @@ -42,8 +42,8 @@ function runningOrcadPids(target: DockerSshRelayTarget): string[] { .filter(Boolean) } -function readIdleStopRecord(target: DockerSshRelayTarget): unknown { - const raw = execDockerSshRelayTargetCommand(target, `cat ${RECORD} 2>/dev/null || true`).trim() +function readIdleStopRecord(target: DockerSshHostTarget): unknown { + const raw = execDockerSshHostTargetCommand(target, `cat ${RECORD} 2>/dev/null || true`).trim() return raw ? JSON.parse(raw) : null } @@ -54,7 +54,7 @@ test('a managed orcad stops after idling and starts again on the next connect', `Set ${ORCAD_CONVERT_HOST_ENV}=docker and ORCA_E2E_ORCAD_CONVERT_TEMPLATE` ) test.setTimeout(15 * 60_000) - const target = startDockerSshRelayTarget(testInfo) + const target = startDockerSshHostTarget(testInfo) const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE_SOURCE!, [ORCAD_E2E_IDLE_TIMEOUT_ENV]: String(IDLE_TIMEOUT_MS) @@ -80,7 +80,7 @@ test('a managed orcad stops after idling and starts again on the next connect', .catch((error: unknown) => { // The server logs what kept it up; without it a timeout explains nothing. console.error( - execDockerSshRelayTargetCommand(target, 'tail -n 40 /root/.orca-remote/orcad-*/orcad.log') + execDockerSshHostTargetCommand(target, 'tail -n 40 /root/.orca-remote/orcad-*/orcad.log') ) throw error }) @@ -111,13 +111,13 @@ test('a managed orcad stops after idling and starts again on the next connect', await session.close(app) } await session.dispose() - cleanupDockerSshRelayTarget(target) + cleanupDockerSshHostTarget(target) } }) async function connectManagedHost( page: Page, - target: DockerSshRelayTarget + target: DockerSshHostTarget ): Promise<{ targetId: string; environmentId: string }> { const connected = await page.evaluate( async (input) => { @@ -157,8 +157,8 @@ async function callEnvironment(page: Page, environmentId: string, method: string } /** The bracket keeps pgrep from matching the shell that runs it. */ -function processAlive(target: DockerSshRelayTarget, marker: string): boolean { - const found = execDockerSshRelayTargetCommand( +function processAlive(target: DockerSshHostTarget, marker: string): boolean { + const found = execDockerSshHostTargetCommand( target, `pgrep -f '[s]leep ${marker}' >/dev/null && echo yes || true` ) @@ -172,7 +172,7 @@ test('a live managed process that does not answer remains unverifiable', async ( 'Requires the owned Docker SSH host and template' ) test.setTimeout(5 * 60_000) - const target = startDockerSshRelayTarget(testInfo) + const target = startDockerSshHostTarget(testInfo) const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE_SOURCE! }) let app: ElectronApplication | null = null let pausedPid: string | null = null @@ -187,7 +187,7 @@ test('a live managed process that does not answer remains unverifiable', async ( expect(pids).toHaveLength(1) const pid = pids[0] expect(pid).toMatch(/^\d+$/) - execDockerSshRelayTargetCommand(target, `kill -STOP ${pid}`) + execDockerSshHostTargetCommand(target, `kill -STOP ${pid}`) pausedPid = pid const held = await page.evaluate((targetId) => window.api.ssh.connect({ targetId }), targetId) console.log( @@ -200,7 +200,7 @@ test('a live managed process that does not answer remains unverifiable', async ( environmentId, serving: { state: 'unverifiable' } }) - execDockerSshRelayTargetCommand(target, `kill -CONT ${pid}`) + execDockerSshHostTargetCommand(target, `kill -CONT ${pid}`) pausedPid = null const recovered = JSON.parse(await reconnect(page, targetId)) console.log( @@ -215,13 +215,13 @@ test('a live managed process that does not answer remains unverifiable', async ( expect(runningOrcadPids(target)).toEqual([pid]) } finally { if (pausedPid) { - execDockerSshRelayTargetCommand(target, `kill -CONT ${pausedPid}`) + execDockerSshHostTargetCommand(target, `kill -CONT ${pausedPid}`) } if (app) { await session.close(app) } await session.dispose() - cleanupDockerSshRelayTarget(target) + cleanupDockerSshHostTarget(target) } }) @@ -232,7 +232,7 @@ test('a killed managed orcad comes back with its terminal, and starts fresh afte `Set ${ORCAD_CONVERT_HOST_ENV}=docker and ORCA_E2E_ORCAD_CONVERT_TEMPLATE` ) test.setTimeout(15 * 60_000) - const target = startDockerSshRelayTarget(testInfo) + const target = startDockerSshHostTarget(testInfo) const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE_SOURCE! }) let app: ElectronApplication | null = null try { @@ -245,19 +245,19 @@ test('a killed managed orcad comes back with its terminal, and starts fresh afte const marker = String(800_000 + Math.floor(Math.random() * 100_000)) expect( await callEnvironment(page, environmentId, 'repo.add', { - path: DOCKER_SSH_RELAY_REMOTE_REPO_PATH + path: DOCKER_SSH_HOST_REMOTE_REPO_PATH }) ).toMatchObject({ ok: true }) expect( await callEnvironment(page, environmentId, 'terminal.create', { - worktree: `path:${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}`, + worktree: `path:${DOCKER_SSH_HOST_REMOTE_REPO_PATH}`, command: `exec sleep ${marker}` }) ).toMatchObject({ ok: true }) await expect.poll(() => processAlive(target, marker), { timeout: 60_000 }).toBe(true) // Killed: the daemon keeps the terminal, and the next call starts orcad, which adopts it. - execDockerSshRelayTargetCommand( + execDockerSshHostTargetCommand( target, 'kill -TERM $(cat /root/.orca-remote/orcad-*/.orcad-pid)' ) @@ -272,8 +272,8 @@ test('a killed managed orcad comes back with its terminal, and starts fresh afte expect(processAlive(target, marker)).toBe(true) // A reboot takes orcad, the daemon and its terminal; the next connect starts both fresh. - execDockerSshRelayTargetCommand(target, "pkill -KILL -f '/root/[.]orca-remote/' || true") - execDockerSshRelayTargetCommand(target, `pkill -KILL -f '[s]leep ${marker}' || true`) + execDockerSshHostTargetCommand(target, "pkill -KILL -f '/root/[.]orca-remote/' || true") + execDockerSshHostTargetCommand(target, `pkill -KILL -f '[s]leep ${marker}' || true`) await expect.poll(() => runningOrcadPids(target), { timeout: 60_000 }).toEqual([]) const connected = JSON.parse(await reconnect(page, targetId)) expect(connected).toMatchObject({ kind: 'managed', environmentId }) @@ -288,6 +288,6 @@ test('a killed managed orcad comes back with its terminal, and starts fresh afte await session.close(app) } await session.dispose() - cleanupDockerSshRelayTarget(target) + cleanupDockerSshHostTarget(target) } }) diff --git a/tests/e2e/ssh-orcad-markdown-conversion.spec.ts b/tests/e2e/ssh-orcad-markdown-conversion.spec.ts index 93f2b43e28b5..624989c9527b 100644 --- a/tests/e2e/ssh-orcad-markdown-conversion.spec.ts +++ b/tests/e2e/ssh-orcad-markdown-conversion.spec.ts @@ -1,69 +1,43 @@ -import type { ElectronApplication } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' -import { createRestartSession } from './helpers/orca-restart' -import { startOrcadConvertHost } from './helpers/orcad-convert-host' -import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' -import { convertAndRetain, serverCall } from './helpers/orcad-convert-flow' -import { waitForSessionReady } from './helpers/store' -import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' -import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' +import { serverCall } from './helpers/orcad-convert-flow' +import { + launchManagedWorkspace, + skipUnlessManagedDockerHost, + withManagedHost +} from './helpers/orcad-managed-workspace' -const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE -test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template') +skipUnlessManagedDockerHost() test('retained Markdown lists documents on its managed owner after conversion', async (// oxlint-disable-next-line no-empty-pattern -- This exercise owns its app launch. {}, testInfo) => { test.setTimeout(4 * 60_000) - const host = startOrcadConvertHost('docker', testInfo) - const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) - let app: ElectronApplication | null = null - try { - const first = await session.launch() - app = first.app - await waitForSessionReady(first.page) - await session.close(app) - app = null + await withManagedHost(testInfo, async (fixture) => { + const { host } = fixture const marker = `REMOTE_MARKDOWN_${Date.now()}` host.exec!( `printf '# ${marker}\n\nHost-owned Markdown.\n\n[[NOTES]]\n' > '${host.remoteRepoPath}/README.md'` ) host.exec!(`printf '# SIBLING_${marker}\n' > '${host.remoteRepoPath}/NOTES.md'`) - const seeded = seedRelayEraProfile(session.userDataDir, host.input, { - repoPath: host.remoteRepoPath, - folderPath: host.remoteFolderPath - }) - const launched = await session.launch() - app = launched.app - const page = launched.page const errors: string[] = [] let failureScreenshot: Promise | undefined - page.on('console', (message) => { - if (message.type() === 'error') { - errors.push(message.text()) - console.log('[renderer-error]', message.text()) - if (message.text().includes('Failed to list markdown documents') && !failureScreenshot) { - failureScreenshot = (async () => { - await page.screenshot({ path: testInfo.outputPath('markdown-list-failure.png') }) - })() - } + const { page, seeded, environmentId } = await launchManagedWorkspace(fixture, { + onLaunch: (page) => { + page.on('console', (message) => { + if (message.type() === 'error') { + errors.push(message.text()) + console.log('[renderer-error]', message.text()) + if ( + message.text().includes('Failed to list markdown documents') && + !failureScreenshot + ) { + failureScreenshot = (async () => { + await page.screenshot({ path: testInfo.outputPath('markdown-list-failure.png') }) + })() + } + } + }) } }) - await waitForSessionReady(page) - await convertAndRetain(page, session.userDataDir, seeded) - const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( - (e) => e.orcadDeployment?.sshTargetId === seeded.targetId - ) - if (!environment) { - throw new Error('Missing managed environment') - } - await page.evaluate( - ({ id, hostId }) => window.__store?.getState().setActiveWorktree(id, hostId), - { - id: seeded.worktreeId, - hostId: toRuntimeExecutionHostId(environment.id) - } - ) - await dismissTransientAnnouncement(page) await expect(page.locator('.rich-markdown-editor')).toContainText(marker, { timeout: 30_000 }) console.log( '[markdown-conversion]', @@ -76,7 +50,7 @@ test('retained Markdown lists documents on its managed owner after conversion', await failureScreenshot expect(errors.filter((e) => e.includes('Failed to list markdown documents'))).toEqual([]) expect( - await serverCall(page, environment.id, 'files.listMarkdownDocuments', { + await serverCall(page, environmentId, 'files.listMarkdownDocuments', { worktree: `id:${seeded.worktreeId}` }) ).toContain('NOTES.md') @@ -96,7 +70,7 @@ test('retained Markdown lists documents on its managed owner after conversion', { filePath: seeded.sessionFilePath, worktreeId: seeded.worktreeId, - environmentId: environment.id + environmentId } ) const previewLink = page @@ -110,11 +84,5 @@ test('retained Markdown lists documents on its managed owner after conversion', ).toBeVisible() expect(errors.filter((e) => e.includes('Failed to list markdown documents'))).toEqual([]) await page.screenshot({ path: testInfo.outputPath('markdown-converted-ready.png') }) - } finally { - if (app) { - await session.close(app) - } - await session.dispose() - host.cleanup() - } + }) }) diff --git a/tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts b/tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts index 6fd2a0f0bddf..97e314b5623c 100644 --- a/tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts +++ b/tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts @@ -1,63 +1,34 @@ -import type { ElectronApplication } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' -import { createRestartSession } from './helpers/orca-restart' -import { startOrcadConvertHost } from './helpers/orcad-convert-host' -import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' -import { convertAndRetain, serverCall } from './helpers/orcad-convert-flow' -import { waitForSessionReady } from './helpers/store' -import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' -import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' +import { serverCall } from './helpers/orcad-convert-flow' +import { + launchManagedWorkspace, + skipUnlessManagedDockerHost, + withManagedHost +} from './helpers/orcad-managed-workspace' -const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE -test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template') +skipUnlessManagedDockerHost() test('retained rich Markdown links refresh after remote document metadata arrives', async (// oxlint-disable-next-line no-empty-pattern -- This exercise owns its app launch. {}, testInfo) => { test.setTimeout(4 * 60_000) - const host = startOrcadConvertHost('docker', testInfo) - const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) - let app: ElectronApplication | null = null - try { - const first = await session.launch() - app = first.app - await waitForSessionReady(first.page) - await session.close(app) - app = null + await withManagedHost(testInfo, async (fixture) => { + const { host } = fixture const marker = `REMOTE_MARKDOWN_${Date.now()}` host.exec!( `printf '# ${marker}\n\nHost-owned Markdown.\n\n[[NOTES]]\n' > '${host.remoteRepoPath}/README.md'` ) host.exec!(`printf '# SIBLING_${marker}\n' > '${host.remoteRepoPath}/NOTES.md'`) - const seeded = seedRelayEraProfile(session.userDataDir, host.input, { - repoPath: host.remoteRepoPath, - folderPath: host.remoteFolderPath - }) - const launched = await session.launch() - app = launched.app - const page = launched.page - page.on('console', (message) => { - if (message.type() === 'error') { - console.log('[renderer-error]', message.text()) + const { page, seeded, environmentId } = await launchManagedWorkspace(fixture, { + onLaunch: (page) => { + page.on('console', (message) => { + if (message.type() === 'error') { + console.log('[renderer-error]', message.text()) + } + }) } }) - await waitForSessionReady(page) - await convertAndRetain(page, session.userDataDir, seeded) - const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( - (e) => e.orcadDeployment?.sshTargetId === seeded.targetId - ) - if (!environment) { - throw new Error('Missing managed environment') - } - await page.evaluate( - ({ id, hostId }) => window.__store?.getState().setActiveWorktree(id, hostId), - { - id: seeded.worktreeId, - hostId: toRuntimeExecutionHostId(environment.id) - } - ) - await dismissTransientAnnouncement(page) await expect(page.locator('.rich-markdown-editor')).toContainText(marker, { timeout: 30_000 }) - const documents = await serverCall(page, environment.id, 'files.listMarkdownDocuments', { + const documents = await serverCall(page, environmentId, 'files.listMarkdownDocuments', { worktree: `id:${seeded.worktreeId}` }) console.log('[host-documents]', documents) @@ -76,13 +47,7 @@ test('retained rich Markdown links refresh after remote document metadata arrive `${host.remoteRepoPath}/NOTES.md` ) console.log('[rich-link-opened-file]', JSON.stringify(opened)) - expect(opened?.runtimeEnvironmentId).toBe(environment.id) + expect(opened?.runtimeEnvironmentId).toBe(environmentId) await page.screenshot({ path: testInfo.outputPath('markdown-converted-ready.png') }) - } finally { - if (app) { - await session.close(app) - } - await session.dispose() - host.cleanup() - } + }) }) diff --git a/tests/e2e/ssh-orcad-markdown-live-documents.spec.ts b/tests/e2e/ssh-orcad-markdown-live-documents.spec.ts index 2b8c71d74f15..8268aa4b10ff 100644 --- a/tests/e2e/ssh-orcad-markdown-live-documents.spec.ts +++ b/tests/e2e/ssh-orcad-markdown-live-documents.spec.ts @@ -1,80 +1,44 @@ -import type { ElectronApplication } from '@stablyai/playwright-test' +import type { Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' -import { createRestartSession } from './helpers/orca-restart' -import { startOrcadConvertHost } from './helpers/orcad-convert-host' -import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' -import { convertAndRetain, serverCall } from './helpers/orcad-convert-flow' -import { waitForSessionReady, waitForStartupWorktreeRefresh } from './helpers/store' -import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' -import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' +import { serverCall } from './helpers/orcad-convert-flow' +import { + activateManagedWorkspace, + launchConvertedProfile, + relaunchManagedApp, + skipUnlessManagedDockerHost, + withManagedHost +} from './helpers/orcad-managed-workspace' -const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE -test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template') +skipUnlessManagedDockerHost() + +function captureRendererMessages(page: Page, label: string): void { + page.on('console', (message) => { + if ( + message.type() === 'error' || + message.type() === 'warning' || + message.text().startsWith('[file-change-event]') + ) { + console.log(label, message.text()) + } + }) +} test('open remote Markdown previews discover host-created documents', async (// oxlint-disable-next-line no-empty-pattern -- This exercise owns its app launch. {}, testInfo) => { test.setTimeout(4 * 60_000) - const host = startOrcadConvertHost('docker', testInfo) - const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) - let app: ElectronApplication | null = null - try { - const first = await session.launch() - app = first.app - await waitForSessionReady(first.page) - await session.close(app) - app = null + await withManagedHost(testInfo, async (fixture) => { + const { host } = fixture const marker = `REMOTE_MARKDOWN_${Date.now()}` host.exec!( `printf '# ${marker}\n\nHost-owned Markdown.\n\n[[LATE]]\n' > '${host.remoteRepoPath}/README.md'` ) - const seeded = seedRelayEraProfile(session.userDataDir, host.input, { - repoPath: host.remoteRepoPath, - folderPath: host.remoteFolderPath - }) - const launched = await session.launch() - app = launched.app - let page = launched.page - page.on('console', (message) => { - if ( - message.type() === 'error' || - message.type() === 'warning' || - message.text().startsWith('[file-change-event]') - ) { - console.log('[renderer-error]', message.text()) - } - }) - await waitForSessionReady(page) - await convertAndRetain(page, session.userDataDir, seeded) - const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( - (e) => e.orcadDeployment?.sshTargetId === seeded.targetId - ) - if (!environment) { - throw new Error('Missing managed environment') - } - await session.close(app) - app = null - const restarted = await session.launch() - app = restarted.app - page = restarted.page - page.on('console', (message) => { - if ( - message.type() === 'error' || - message.type() === 'warning' || - message.text().startsWith('[file-change-event]') - ) { - console.log('[renderer-event]', message.text()) - } + const { seeded, environmentId } = await launchConvertedProfile(fixture, { + onLaunch: (page) => captureRendererMessages(page, '[renderer-error]') }) - await waitForSessionReady(page) - await waitForStartupWorktreeRefresh(page) - await page.evaluate( - ({ id, hostId }) => window.__store?.getState().setActiveWorktree(id, hostId), - { - id: seeded.worktreeId, - hostId: toRuntimeExecutionHostId(environment.id) - } + const page = await relaunchManagedApp(fixture, (page) => + captureRendererMessages(page, '[renderer-event]') ) - await dismissTransientAnnouncement(page) + await activateManagedWorkspace(page, seeded.worktreeId, environmentId) await expect(page.locator('.rich-markdown-editor')).toContainText(marker, { timeout: 30_000 }) await page.evaluate( ({ filePath, worktreeId, environmentId }) => { @@ -89,7 +53,7 @@ test('open remote Markdown previews discover host-created documents', async (// { filePath: seeded.sessionFilePath, worktreeId: seeded.worktreeId, - environmentId: environment.id + environmentId } ) const link = page.locator('.markdown-preview').getByRole('link', { name: 'LATE', exact: true }) @@ -103,7 +67,7 @@ test('open remote Markdown previews discover host-created documents', async (// }) }) host.exec!(`printf '# LATE_${marker}\n' > '${host.remoteRepoPath}/LATE.md'`) - const documents = await serverCall(page, environment.id, 'files.listMarkdownDocuments', { + const documents = await serverCall(page, environmentId, 'files.listMarkdownDocuments', { worktree: `id:${seeded.worktreeId}` }) console.log('[host-created-documents]', documents) @@ -115,7 +79,7 @@ test('open remote Markdown previews discover host-created documents', async (// console.log('[resolved-preview-link]', await link.getAttribute('class')) await page.screenshot({ path: testInfo.outputPath('live-document-created.png') }) host.exec!(`rm '${host.remoteRepoPath}/LATE.md'`) - const removed = await serverCall(page, environment.id, 'files.listMarkdownDocuments', { + const removed = await serverCall(page, environmentId, 'files.listMarkdownDocuments', { worktree: `id:${seeded.worktreeId}` }) console.log('[host-removed-documents]', removed) @@ -123,11 +87,5 @@ test('open remote Markdown previews discover host-created documents', async (// await expect(page.getByText('LATE.md', { exact: true })).not.toBeVisible({ timeout: 30_000 }) await expect(link).toHaveClass(/markdown-doc-link-broken/, { timeout: 10_000 }) await page.screenshot({ path: testInfo.outputPath('live-document-removed.png') }) - } finally { - if (app) { - await session.close(app) - } - await session.dispose() - host.cleanup() - } + }) }) diff --git a/tests/e2e/ssh-orcad-open-in-owner.spec.ts b/tests/e2e/ssh-orcad-open-in-owner.spec.ts index c4a5695255ed..f446020d7f33 100644 --- a/tests/e2e/ssh-orcad-open-in-owner.spec.ts +++ b/tests/e2e/ssh-orcad-open-in-owner.spec.ts @@ -9,24 +9,20 @@ import { } from 'node:fs' import os from 'node:os' import path from 'node:path' -import type { ElectronApplication } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' -import { createRestartSession } from './helpers/orca-restart' -import { startOrcadConvertHost } from './helpers/orcad-convert-host' -import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' -import { convertAndRetain, serverCall } from './helpers/orcad-convert-flow' -import { waitForSessionReady } from './helpers/store' +import { serverCall } from './helpers/orcad-convert-flow' +import { + launchConvertedProfile, + skipUnlessManagedDockerHost, + withManagedHost +} from './helpers/orcad-managed-workspace' import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' -import { shellQuote } from './helpers/docker-ssh-relay-target' import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' import { folderWorkspaceKey } from '../../src/shared/workspace-scope' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' -const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE - -test.skip( - !TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1' || process.platform === 'win32', - 'Needs a POSIX recorder, Docker, and a server template' -) +skipUnlessManagedDockerHost() +test.skip(process.platform === 'win32', 'Needs a POSIX recorder') for (const surface of [ 'source-control', @@ -52,55 +48,39 @@ for (const surface of [ // This recorder writes argv/content and exits; it never opens or focuses a window. writeFileSync( recorder, - `#!/bin/sh\nprintf 'DESKTOP_PID=%s\\nARG=%s\\n' "$$" "$1" > ${shellQuote(receiptPath)}\nif [ -d "$1" ]; then cat "$1/README.md"; else cat "$1"; fi >> ${shellQuote(receiptPath)}\n` + `#!/bin/sh\nprintf 'DESKTOP_PID=%s\\nARG=%s\\n' "$$" "$1" > ${quotePosixShell(receiptPath)}\nif [ -d "$1" ]; then cat "$1/README.md"; else cat "$1"; fi >> ${quotePosixShell(receiptPath)}\n` ) chmodSync(recorder, 0o755) const receipt = (): string => (existsSync(receiptPath) ? readFileSync(receiptPath, 'utf8') : '') - const host = startOrcadConvertHost('docker', testInfo) - const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) - let app: ElectronApplication | null = null - try { + await withManagedHost(testInfo, async (fixture) => { + const { host } = fixture if (!host.exec) { throw new Error('Missing owned Docker controls') } const execute = host.exec execute( - `mkdir -p ${shellQuote(owned)} && git clone --quiet ${shellQuote(host.remoteRepoPath)} ${shellQuote(repoPath)} && printf '%s\\n' ${shellQuote(`# ${remoteMarker}`)} > ${shellQuote(filePath)} && mkdir -p ${shellQuote(folderPath)} && printf '%s\\n' ${shellQuote(`# ${remoteMarker}`)} > ${shellQuote(path.join(folderPath, 'README.md'))}` + `mkdir -p ${quotePosixShell(owned)} && git clone --quiet ${quotePosixShell(host.remoteRepoPath)} ${quotePosixShell(repoPath)} && printf '%s\\n' ${quotePosixShell(`# ${remoteMarker}`)} > ${quotePosixShell(filePath)} && mkdir -p ${quotePosixShell(folderPath)} && printf '%s\\n' ${quotePosixShell(`# ${remoteMarker}`)} > ${quotePosixShell(path.join(folderPath, 'README.md'))}` ) - const first = await session.launch() - app = first.app - await waitForSessionReady(first.page) - expect( - await first.page.evaluate( - async ({ filePath, recorder }) => - window.api.shell.openInExternalEditor({ - path: filePath, - command: recorder, - ownerHostId: 'local' - }), - { filePath, recorder } - ) - ).toEqual({ ok: true }) - await expect.poll(receipt).toContain(localMarker) - console.log('[local-recorder-control]', receipt()) - rmSync(receiptPath) - await session.close(app) - app = null - const seeded = seedRelayEraProfile(session.userDataDir, host.input, { + const { page, seeded, environmentId } = await launchConvertedProfile(fixture, { repoPath, - folderPath + folderPath, + beforeSeed: async (page) => { + expect( + await page.evaluate( + async ({ filePath, recorder }) => + window.api.shell.openInExternalEditor({ + path: filePath, + command: recorder, + ownerHostId: 'local' + }), + { filePath, recorder } + ) + ).toEqual({ ok: true }) + await expect.poll(receipt).toContain(localMarker) + console.log('[local-recorder-control]', receipt()) + rmSync(receiptPath) + } }) - const launched = await session.launch() - app = launched.app - const page = launched.page - await waitForSessionReady(page) - await convertAndRetain(page, session.userDataDir, seeded) - const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( - (entry) => entry.orcadDeployment?.sshTargetId === seeded.targetId - ) - if (!environment) { - throw new Error('Missing managed environment') - } const readConvertedFolderId = () => page.evaluate( (folderPath) => @@ -119,7 +99,7 @@ for (const surface of [ const workspaceId = convertedFolderId ? folderWorkspaceKey(convertedFolderId) : seeded.worktreeId - const content = await serverCall(page, environment.id, 'files.read', { + const content = await serverCall(page, environmentId, 'files.read', { worktree: `id:${workspaceId}`, relativePath: 'README.md' }) @@ -138,7 +118,7 @@ for (const surface of [ state.setRightSidebarTab('explorer') state.setRightSidebarOpen(true) }, - { id: workspaceId, hostId: toRuntimeExecutionHostId(environment.id), recorder } + { id: workspaceId, hostId: toRuntimeExecutionHostId(environmentId), recorder } ) expect( await page.evaluate(() => window.__store?.getState().settings?.activeRuntimeEnvironmentId) @@ -189,16 +169,6 @@ for (const surface of [ await page.screenshot({ path: testInfo.outputPath(`${surface}-editor-owner-corrected.png`) }) - } finally { - try { - if (app) { - await session.close(app) - } - await session.dispose() - } finally { - host.cleanup() - rmSync(owned, { recursive: true, force: true }) - } - } + }).finally(() => rmSync(owned, { recursive: true, force: true })) }) } diff --git a/tests/e2e/ssh-orcad-terminal-journeys.spec.ts b/tests/e2e/ssh-orcad-terminal-journeys.spec.ts index c9cc0f982e01..99b8f2cc4924 100644 --- a/tests/e2e/ssh-orcad-terminal-journeys.spec.ts +++ b/tests/e2e/ssh-orcad-terminal-journeys.spec.ts @@ -7,11 +7,11 @@ * closed while the host is disconnected stays closed. * 3. Faults: a pane outlives a dropped SSH link and a frozen host, and answers input after both. */ -import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import type { Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' import { reconnect } from './helpers/orcad-convert-flow' import { readPairedPaneContent } from './helpers/paired-host-terminal' -import { waitForSessionReady, waitForStartupWorktreeRefresh } from './helpers/store' +import { waitForSessionReady } from './helpers/store' import { activateManagedWorkspace, echoMarker, @@ -19,27 +19,18 @@ import { expectTerminalAnswers, launchManagedWorkspace, openTerminalTab, - ORCAD_TEMPLATE, + relaunchManagedApp, selectTerminalTab, + skipUnlessManagedDockerHost, terminalTabIds, typeInTerminal, waitForPane, withManagedHost } from './helpers/orcad-managed-workspace' -test.skip( - !ORCAD_TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', - 'Needs Docker and server template' -) +skipUnlessManagedDockerHost() test.skip(process.platform === 'win32', 'The Docker SSH host uses POSIX tooling') -function relaunchable(app: ElectronApplication | null): ElectronApplication { - if (!app) { - throw new Error('No live launch to close') - } - return app -} - function sshStatus(page: Page, targetId: string): Promise { return page.evaluate( (id) => window.__store?.getState().sshConnectionStates.get(id)?.status ?? null, @@ -142,13 +133,7 @@ test('managed tabs restore exactly once, and a tab closed while disconnected sta expect(expected).toEqual(expect.arrayContaining(opened)) for (let cycle = 0; cycle < 2; cycle += 1) { - await fixture.session.close(relaunchable(fixture.app)) - fixture.app = null - const relaunched = await fixture.session.launch() - fixture.app = relaunched.app - page = relaunched.page - await waitForSessionReady(page) - await waitForStartupWorktreeRefresh(page) + page = await relaunchManagedApp(fixture) await activateManagedWorkspace(page, seeded.worktreeId, environmentId) expect(await settledTabIds(page, seeded.worktreeId), `relaunch ${cycle + 1}`).toEqual( expected @@ -175,14 +160,9 @@ test('managed tabs restore exactly once, and a tab closed while disconnected sta await reconnect(page, seeded.targetId) const remaining = expected.filter((id) => id !== closed) expect(await settledTabIds(page, seeded.worktreeId)).toEqual(remaining) - await fixture.session.close(relaunchable(fixture.app)) - fixture.app = null - const final = await fixture.session.launch() - fixture.app = final.app - await waitForSessionReady(final.page) - await waitForStartupWorktreeRefresh(final.page) - await activateManagedWorkspace(final.page, seeded.worktreeId, environmentId) - expect(await settledTabIds(final.page, seeded.worktreeId)).toEqual(remaining) + const final = await relaunchManagedApp(fixture) + await activateManagedWorkspace(final, seeded.worktreeId, environmentId) + expect(await settledTabIds(final, seeded.worktreeId)).toEqual(remaining) }) }) diff --git a/tests/e2e/ssh-orcad-terminal-root-owner.spec.ts b/tests/e2e/ssh-orcad-terminal-root-owner.spec.ts index 61c1c3b8ecd6..ea2e36b4f29a 100644 --- a/tests/e2e/ssh-orcad-terminal-root-owner.spec.ts +++ b/tests/e2e/ssh-orcad-terminal-root-owner.spec.ts @@ -1,19 +1,19 @@ import { execFileSync } from 'node:child_process' import { mkdirSync, writeFileSync } from 'node:fs' import path from 'node:path' -import type { ElectronApplication } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' -import { createRestartSession } from './helpers/orca-restart' -import { startOrcadConvertHost } from './helpers/orcad-convert-host' -import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' -import { convertAndRetain, serverCall } from './helpers/orcad-convert-flow' -import { waitForSessionReady } from './helpers/store' +import { serverCall } from './helpers/orcad-convert-flow' +import { + launchConvertedProfile, + skipUnlessManagedDockerHost, + withManagedHost +} from './helpers/orcad-managed-workspace' import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker' import { openFileExplorer } from './helpers/file-explorer' -import { shellQuote } from './helpers/docker-ssh-relay-target' import { openTerminalWorkspaceRootLink } from './helpers/terminal-workspace-root-link' import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' import { getWorktreeHostIdentity } from '../../src/shared/worktree/host-qualified-identity' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' import { focusActiveTerminalInput, @@ -21,8 +21,7 @@ import { waitForActivePanePtyId } from './helpers/terminal' -const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE -test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs owned Docker host') +skipUnlessManagedDockerHost() test.skip(process.platform === 'win32', 'Owned POSIX host collision fixture') test('a managed terminal path link cannot switch to a desktop workspace at the same path', async ({ @@ -36,49 +35,36 @@ test('a managed terminal path link cannot switch to a desktop workspace at the s mkdirSync(intermediate) execFileSync('git', ['init', '-q', intermediate]) writeFileSync(path.join(intermediate, 'INTERMEDIATE_OWNER.txt'), 'INTERMEDIATE\n') - const host = startOrcadConvertHost('docker', testInfo) - const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! }) - let app: ElectronApplication | null = null - try { + await withManagedHost(testInfo, async (fixture) => { + const { host } = fixture if (!host.exec) { throw new Error('Missing owned host control') } const execute = host.exec execute( - `mkdir -p ${shellQuote(path.dirname(testRepoPath))} && git clone --quiet ${shellQuote(host.remoteRepoPath)} ${shellQuote(testRepoPath)} && printf 'REMOTE_COMMAND_FILE_OWNER\n' > ${shellQuote(filePath)}` - ) - const first = await session.launch() - app = first.app - await waitForSessionReady(first.page) - const ids = await first.page.evaluate( - async (paths) => { - const resultIds: string[] = [] - for (const repoPath of paths) { - const result = await window.api.repos.add({ path: repoPath }) - if ('error' in result) { - throw new Error(result.error) - } - resultIds.push(`${result.repo.id}::${repoPath}`) - } - return resultIds - }, - [testRepoPath, intermediate] + `mkdir -p ${quotePosixShell(path.dirname(testRepoPath))} && git clone --quiet ${quotePosixShell(host.remoteRepoPath)} ${quotePosixShell(testRepoPath)} && printf 'REMOTE_COMMAND_FILE_OWNER\n' > ${quotePosixShell(filePath)}` ) - await session.close(app) - app = null - const seeded = seedRelayEraProfile(session.userDataDir, host.input, { + let ids: string[] = [] + const { page, seeded, environmentId } = await launchConvertedProfile(fixture, { repoPath: testRepoPath, - folderPath: host.remoteFolderPath + beforeSeed: async (page) => { + ids = await page.evaluate( + async (paths) => { + const resultIds: string[] = [] + for (const repoPath of paths) { + const result = await window.api.repos.add({ path: repoPath }) + if ('error' in result) { + throw new Error(result.error) + } + resultIds.push(`${result.repo.id}::${repoPath}`) + } + return resultIds + }, + [testRepoPath, intermediate] + ) + } }) - const launched = await session.launch() - app = launched.app - const page = launched.page - await waitForSessionReady(page) - await convertAndRetain(page, session.userDataDir, seeded) - const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( - (entry) => entry.orcadDeployment?.sshTargetId === seeded.targetId - ) - if (!environment || !ids[0] || !ids[1]) { + if (!ids[0] || !ids[1]) { throw new Error('Missing owned workspace') } const markerDirectory = path.join(testRepoPath, 'OWNER_SUBDIRECTORY') @@ -88,7 +74,7 @@ test('a managed terminal path link cannot switch to a desktop workspace at the s 'DESKTOP_TERMINAL_DIRECTORY_OWNER\n' ) execute( - `mkdir -p ${shellQuote(markerDirectory)} && printf 'REMOTE_TERMINAL_DIRECTORY_OWNER\n' > ${shellQuote(path.join(markerDirectory, 'TERMINAL_OWNER.txt'))}` + `mkdir -p ${quotePosixShell(markerDirectory)} && printf 'REMOTE_TERMINAL_DIRECTORY_OWNER\n' > ${quotePosixShell(path.join(markerDirectory, 'TERMINAL_OWNER.txt'))}` ) await dismissTransientAnnouncement(page) const localIdentity = getWorktreeHostIdentity({ id: ids[0], hostId: 'local' }) @@ -134,7 +120,7 @@ test('a managed terminal path link cannot switch to a desktop workspace at the s ).toBeVisible() const remoteIdentity = getWorktreeHostIdentity({ id: seeded.worktreeId, - hostId: toRuntimeExecutionHostId(environment.id) + hostId: toRuntimeExecutionHostId(environmentId) }) await page.locator(`[data-worktree-host-identity="${remoteIdentity}"]:visible`).click() await openFileExplorer(page) @@ -154,7 +140,7 @@ test('a managed terminal path link cannot switch to a desktop workspace at the s await expect(remoteFile).toBeVisible() console.log( '[actual-remote-source]', - await serverCall(page, environment.id, 'files.read', { + await serverCall(page, environmentId, 'files.read', { worktree: `id:${seeded.worktreeId}`, relativePath: fileName }) @@ -173,11 +159,11 @@ test('a managed terminal path link cannot switch to a desktop workspace at the s .toContain('REMOTE_TERMINAL_DIRECTORY_OWNER') console.log('[actual-managed-terminal]', await getTerminalContent(page)) execute( - `mkdir -p ${shellQuote(intermediate)} && printf 'REMOTE_LINK_DIRECTORY_OWNER\n' > ${shellQuote(path.join(intermediate, 'LINK_DIRECTORY_OWNER.txt'))}` + `mkdir -p ${quotePosixShell(intermediate)} && printf 'REMOTE_LINK_DIRECTORY_OWNER\n' > ${quotePosixShell(path.join(intermediate, 'LINK_DIRECTORY_OWNER.txt'))}` ) console.log( '[actual-host-link-directory-marker]', - execute(`cat ${shellQuote(path.join(intermediate, 'LINK_DIRECTORY_OWNER.txt'))}`) + execute(`cat ${quotePosixShell(path.join(intermediate, 'LINK_DIRECTORY_OWNER.txt'))}`) ) const foreignTarget = await openTerminalWorkspaceRootLink(page, testInfo, intermediate) const popover = page.locator('[data-terminal-link-action-popover]') @@ -205,13 +191,13 @@ test('a managed terminal path link cannot switch to a desktop workspace at the s }) console.log('[actual-selection-after-managed-link]', route) await page.screenshot({ path: testInfo.outputPath('managed-terminal-root-link-selection.png') }) - expect(route.activeWorkspaceExecutionHostId).toBe(toRuntimeExecutionHostId(environment.id)) + expect(route.activeWorkspaceExecutionHostId).toBe(toRuntimeExecutionHostId(environmentId)) expect(route.activeWorktreeId).toBe(seeded.worktreeId) await expect(foreignSwitch).toHaveCount(0) await popover.getByRole('button', { name: /^Open file/ }).click() console.log( '[managed-directory-click-host-read]', - await serverCall(page, environment.id, 'files.read', { + await serverCall(page, environmentId, 'files.read', { worktree: `id:${seeded.worktreeId}`, relativePath: 'intermediate-command-repo/LINK_DIRECTORY_OWNER.txt' }) @@ -224,13 +210,13 @@ test('a managed terminal path link cannot switch to a desktop workspace at the s } finally { await page.keyboard.up(primaryModifier) } - await serverCall(page, environment.id, 'files.read', { + await serverCall(page, environmentId, 'files.read', { worktree: `id:${seeded.worktreeId}`, relativePath: 'intermediate-command-repo/LINK_DIRECTORY_OWNER.txt' }) await expect .poll(() => page.evaluate(() => window.__store?.getState().activeWorkspaceExecutionHostId)) - .toBe(toRuntimeExecutionHostId(environment.id)) + .toBe(toRuntimeExecutionHostId(environmentId)) await expect .poll(() => page.evaluate(() => window.__store?.getState().activeWorktreeId)) .toBe(seeded.worktreeId) @@ -247,7 +233,7 @@ test('a managed terminal path link cannot switch to a desktop workspace at the s await ownRoot.getByRole('button', { name: /^Switch workspace/ }).click() await expect .poll(() => page.evaluate(() => window.__store?.getState().activeWorkspaceExecutionHostId)) - .toBe(toRuntimeExecutionHostId(environment.id)) + .toBe(toRuntimeExecutionHostId(environmentId)) await expect .poll(() => page.evaluate(() => window.__store?.getState().activeWorktreeId)) .toBe(seeded.worktreeId) @@ -258,14 +244,5 @@ test('a managed terminal path link cannot switch to a desktop workspace at the s host: window.__store?.getState().activeWorkspaceExecutionHostId })) ) - } finally { - try { - if (app) { - await session.close(app) - } - await session.dispose() - } finally { - host.cleanup() - } - } + }) }) diff --git a/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts b/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts index af04ff2453b0..c051ae0a20d4 100644 --- a/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts +++ b/tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts @@ -4,23 +4,20 @@ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import path from 'node:path' import { expect, test } from './helpers/orca-app' -import { ensureDockerSshRelayImage } from './helpers/docker-ssh-relay-image' +import { ensureDockerSshHostImage } from './helpers/docker-ssh-host-image' import { - cleanupDockerSshRelayTarget, - execDockerSshRelayTargetCommand, - shellQuote, - startDockerSshRelayTarget, - DOCKER_SSH_RELAY_REMOTE_REPO_PATH, - type DockerSshRelayTarget -} from './helpers/docker-ssh-relay-target' + cleanupDockerSshHostTarget, + execDockerSshHostTargetCommand, + startDockerSshHostTarget, + DOCKER_SSH_HOST_REMOTE_REPO_PATH, + type DockerSshHostTarget +} from './helpers/docker-ssh-host-target' import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' -import { ORCAD_TEMPLATE } from './helpers/orcad-managed-workspace' +import { ORCAD_TEMPLATE, skipUnlessManagedDockerHost } from './helpers/orcad-managed-workspace' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' /** A recipe VM's SSH root, served by a managed orcad the first connect deploys. */ -test.skip( - !ORCAD_TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', - 'Needs Docker and server template' -) +skipUnlessManagedDockerHost() test.skip(process.platform === 'win32', 'The recipe scripts are POSIX shell') test.use({ seedTestRepo: false, @@ -32,11 +29,11 @@ test('adopts a recipe-provisioned SSH root without creating a linked worktree', }, testInfo) => { // The first connect deploys the VM's managed server before the root is adopted. test.setTimeout(420_000) - let target: DockerSshRelayTarget | null = null + let target: DockerSshHostTarget | null = null const sourceRepo = mkdtempSync(path.join(tmpdir(), 'orca-provisioned-root-source-')) try { - ensureDockerSshRelayImage(process.cwd()) - target = startDockerSshRelayTarget(testInfo) + ensureDockerSshHostImage(process.cwd()) + target = startDockerSshHostTarget(testInfo) const expectedRefHead = seedRecipeRepo(sourceRepo, target) await waitForSessionReady(orcaPage) const sourceRepoId = await addRecipeRepo(orcaPage, sourceRepo) @@ -80,28 +77,28 @@ test('adopts a recipe-provisioned SSH root without creating a linked worktree', const adopted = await findAdopted() await ensureTerminalVisible(orcaPage) expect(adopted).toMatchObject({ - path: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + path: DOCKER_SSH_HOST_REMOTE_REPO_PATH, isMainWorktree: true, ephemeralVmCheckoutMode: 'provisioned-root' }) // The VM's managed server holds the workspace; no relay serves it. expect(adopted?.hostId).toMatch(/^runtime:/) expect( - execDockerSshRelayTargetCommand( + execDockerSshHostTargetCommand( target, - `git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} worktree list --porcelain | grep -c '^worktree '` + `git -C ${quotePosixShell(DOCKER_SSH_HOST_REMOTE_REPO_PATH)} worktree list --porcelain | grep -c '^worktree '` ) ).toBe('1') expect( - execDockerSshRelayTargetCommand( + execDockerSshHostTargetCommand( target, - `git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} branch --show-current` + `git -C ${quotePosixShell(DOCKER_SSH_HOST_REMOTE_REPO_PATH)} branch --show-current` ) ).toBe(workspaceName) expect( - execDockerSshRelayTargetCommand( + execDockerSshHostTargetCommand( target, - `git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} rev-parse HEAD` + `git -C ${quotePosixShell(DOCKER_SSH_HOST_REMOTE_REPO_PATH)} rev-parse HEAD` ) ).toBe(expectedRefHead) @@ -130,9 +127,9 @@ test('adopts a recipe-provisioned SSH root without creating a linked worktree', ) .toBe(false) const destroyed = target - expect(() => execDockerSshRelayTargetCommand(destroyed, 'true')).toThrow() + expect(() => execDockerSshHostTargetCommand(destroyed, 'true')).toThrow() } finally { - cleanupDockerSshRelayTarget(target) + cleanupDockerSshHostTarget(target) rmSync(sourceRepo, { recursive: true, force: true }) } }) @@ -151,11 +148,11 @@ async function addRecipeRepo(page: Parameters[0], re }, repoPath) } -function seedRecipeRepo(repoPath: string, target: DockerSshRelayTarget): string { +function seedRecipeRepo(repoPath: string, target: DockerSshHostTarget): string { const createScript = path.join(repoPath, 'create.sh') const destroyScript = path.join(repoPath, 'destroy.sh') // The recipe's isolated HOME must still address the engine that owns the fixture container. - const docker = `docker --config ${shellQuote(process.env.DOCKER_CONFIG ?? path.join(homedir(), '.docker'))}` + const docker = `docker --config ${quotePosixShell(process.env.DOCKER_CONFIG ?? path.join(homedir(), '.docker'))}` writeFileSync( createScript, `#!/usr/bin/env bash @@ -165,9 +162,9 @@ set -euo pipefail [ -n "\${ORCA_REPO_REF:-}" ] [ -n "\${ORCA_REPO_REF_HEAD:-}" ] [ -n "\${ORCA_REPO_BRANCH:-}" ] -${docker} exec ${shellQuote(target.containerName)} git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} cat-file -e "$ORCA_REPO_REF_HEAD^{commit}" -${docker} exec ${shellQuote(target.containerName)} git -C ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} checkout -B "$ORCA_REPO_BRANCH" "$ORCA_REPO_REF_HEAD" >&2 -node -e 'console.log(JSON.stringify({schemaVersion:2,checkoutMode:"provisioned-root",connection:{type:"ssh",projectRoot:process.argv[1],target:{label:"Docker provisioned root",host:process.argv[2],port:Number(process.argv[3]),username:"root",identityFile:process.argv[4],identitiesOnly:true}}}))' ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} ${shellQuote(target.host)} ${target.port} ${shellQuote(target.identityFile)} +${docker} exec ${quotePosixShell(target.containerName)} git -C ${quotePosixShell(DOCKER_SSH_HOST_REMOTE_REPO_PATH)} cat-file -e "$ORCA_REPO_REF_HEAD^{commit}" +${docker} exec ${quotePosixShell(target.containerName)} git -C ${quotePosixShell(DOCKER_SSH_HOST_REMOTE_REPO_PATH)} checkout -B "$ORCA_REPO_BRANCH" "$ORCA_REPO_REF_HEAD" >&2 +node -e 'console.log(JSON.stringify({schemaVersion:2,checkoutMode:"provisioned-root",connection:{type:"ssh",projectRoot:process.argv[1],target:{label:"Docker provisioned root",host:process.argv[2],port:Number(process.argv[3]),username:"root",identityFile:process.argv[4],identitiesOnly:true}}}))' ${quotePosixShell(DOCKER_SSH_HOST_REMOTE_REPO_PATH)} ${quotePosixShell(target.host)} ${target.port} ${quotePosixShell(target.identityFile)} ` ) writeFileSync( @@ -175,7 +172,7 @@ node -e 'console.log(JSON.stringify({schemaVersion:2,checkoutMode:"provisioned-r `#!/usr/bin/env bash set -euo pipefail cat >/dev/null -${docker} rm -f ${shellQuote(target.containerName)} >/dev/null +${docker} rm -f ${quotePosixShell(target.containerName)} >/dev/null ` ) chmodSync(createScript, 0o755) @@ -202,18 +199,18 @@ ${docker} rm -f ${shellQuote(target.containerName)} >/dev/null cwd: repoPath, encoding: 'utf8' }).trim() - execDockerSshRelayTargetCommand( + execDockerSshHostTargetCommand( target, - `rm -rf ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)} && mkdir -p ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)}` + `rm -rf ${quotePosixShell(DOCKER_SSH_HOST_REMOTE_REPO_PATH)} && mkdir -p ${quotePosixShell(DOCKER_SSH_HOST_REMOTE_REPO_PATH)}` ) execFileSync('docker', [ 'cp', `${repoPath}${path.sep}.`, - `${target.containerName}:${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}` + `${target.containerName}:${DOCKER_SSH_HOST_REMOTE_REPO_PATH}` ]) - execDockerSshRelayTargetCommand( + execDockerSshHostTargetCommand( target, - `chown -R root:root ${shellQuote(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)}` + `chown -R root:root ${quotePosixShell(DOCKER_SSH_HOST_REMOTE_REPO_PATH)}` ) return expectedRefHead } diff --git a/tests/e2e/ssh-orcad-workspace-tools.spec.ts b/tests/e2e/ssh-orcad-workspace-tools.spec.ts index 7725e1fead8d..bbb10fce8d55 100644 --- a/tests/e2e/ssh-orcad-workspace-tools.spec.ts +++ b/tests/e2e/ssh-orcad-workspace-tools.spec.ts @@ -5,7 +5,6 @@ */ import { expect, test } from './helpers/orca-app' import { reconnect } from './helpers/orcad-convert-flow' -import { shellQuote } from './helpers/docker-ssh-relay-target' import { callEnvironment, createPairedHostTerminal } from './helpers/paired-host-terminal' import { reserveLocalPort } from './helpers/reserve-local-port' import { openTerminalWorkspaceRootLink } from './helpers/terminal-workspace-root-link' @@ -14,22 +13,20 @@ import { expectTerminalAnswers, launchManagedWorkspace, openTerminalTab, - ORCAD_TEMPLATE, + skipUnlessManagedDockerHost, typeInTerminal, withManagedHost } from './helpers/orcad-managed-workspace' +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' -test.skip( - !ORCAD_TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', - 'Needs Docker and server template' -) +skipUnlessManagedDockerHost() test.skip(process.platform === 'win32', 'The Docker SSH host uses POSIX tooling') test('Quick Open finds a file in a monorepo-sized managed workspace', async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session. {}, testInfo) => { test.setTimeout(10 * 60_000) await withManagedHost(testInfo, async (fixture) => { - const repo = shellQuote(fixture.host.remoteRepoPath) + const repo = quotePosixShell(fixture.host.remoteRepoPath) // ~22.6k tracked paths: past the size whose single reply once overflowed the transport (#12547). fixture.host.exec!( [ @@ -70,7 +67,7 @@ for (const acrossReconnect of [false, true]) { const script = `require('node:http').createServer((_q, r) => r.end(${JSON.stringify(marker)})).listen(${remotePort}, '127.0.0.1')` // Started from the workspace so the scan attributes it to this workspace. fixture.host.exec!( - `cd ${shellQuote(fixture.host.remoteRepoPath)} && nohup node -e ${shellQuote(script)} >/tmp/orca-http.log 2>&1 < /dev/null &` + `cd ${quotePosixShell(fixture.host.remoteRepoPath)} && nohup node -e ${quotePosixShell(script)} >/tmp/orca-http.log 2>&1 < /dev/null &` ) // The scan the Ports panel shows for a workspace on this host. await expect @@ -120,7 +117,7 @@ test('a terminal link opens an image outside the managed workspace', async (// o 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFklEQVR4AWN8z8DwnwEJMDGgAcICAO2mBAXmO4drAAAAAElFTkSuQmCC' await withManagedHost(testInfo, async (fixture) => { fixture.host.exec!( - `printf '%s' ${shellQuote(imageBase64)} | base64 -d > ${shellQuote(imagePath)}` + `printf '%s' ${quotePosixShell(imageBase64)} | base64 -d > ${quotePosixShell(imagePath)}` ) // Why /root: the default folder workspace is /tmp, which would make the image a workspace file. const { page, seeded } = await launchManagedWorkspace(fixture, { folderPath: '/root' }) @@ -154,7 +151,7 @@ test('a managed terminal reports agent status from hooks and from Pi-style title '--data-urlencode "paneKey=${ORCA_PANE_KEY}" --data-urlencode "tabId=${ORCA_TAB_ID}"', '--data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}"', '--data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}"', - `--data-urlencode ${shellQuote(`payload=${payload}`)} >/tmp/orca-hook.log 2>&1 ) &` + `--data-urlencode ${quotePosixShell(`payload=${payload}`)} >/tmp/orca-hook.log 2>&1 ) &` ].join(' ') ) await expect diff --git a/tests/e2e/terminal-node-command.ts b/tests/e2e/terminal-node-command.ts index c85dd8136983..26dfd5c96889 100644 --- a/tests/e2e/terminal-node-command.ts +++ b/tests/e2e/terminal-node-command.ts @@ -1,11 +1,8 @@ import { quotePowerShellLiteral } from '../../src/shared/powershell-native-argument' - -function quotePosixShellArg(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'` -} +import { quotePosixShell } from '../../src/shared/wsl-login-shell-command' function quoteTerminalArg(value: string): string { - return process.platform === 'win32' ? quotePowerShellLiteral(value) : quotePosixShellArg(value) + return process.platform === 'win32' ? quotePowerShellLiteral(value) : quotePosixShell(value) } export function nodeTerminalCommand(args: readonly string[]): string {