diff --git a/main/services/terminal-spawn-helper.ts b/main/services/terminal-spawn-helper.ts new file mode 100644 index 0000000..f0d1508 --- /dev/null +++ b/main/services/terminal-spawn-helper.ts @@ -0,0 +1,36 @@ +import * as fs from "fs/promises"; +import * as path from "path"; + +/** + * Mirror node-pty's runtime rewrite from Electron's virtual ASAR path to the + * real unpacked directory. Electron's patched `stat` can report an unpacked + * placeholder inside `app.asar` as a regular non-executable file, but `chmod` + * on that virtual path fails with ENOTDIR. Normalize before either operation. + */ +export function resolveNodePtyDiskPackageDir(packageDir: string): string { + return packageDir + .replace(/([/\\])app\.asar([/\\])/u, "$1app.asar.unpacked$2") + .replace(/([/\\])node_modules\.asar([/\\])/u, "$1node_modules.asar.unpacked$2"); +} + +/** + * Resolve the real filesystem helpers below a node-pty package directory. + * + * The directory-reader seam lets the regression test exercise the exact + * production lookup without requiring an Electron ASAR mount. Normalization + * deliberately happens before readdir so neither discovery nor chmod can ever + * target Electron's virtual `app.asar` path. + */ +export async function resolveNodePtySpawnHelperPaths( + packageDir: string, + readDirectory: (directory: string) => Promise = fs.readdir, +): Promise { + const prebuildsDir = path.join(resolveNodePtyDiskPackageDir(packageDir), "prebuilds"); + let entries: readonly string[]; + try { + entries = await readDirectory(prebuildsDir); + } catch { + return []; + } + return entries.map((entry) => path.join(prebuildsDir, entry, "spawn-helper")); +} diff --git a/main/services/terminal.test.ts b/main/services/terminal.test.ts index aec197e..5926a3a 100644 --- a/main/services/terminal.test.ts +++ b/main/services/terminal.test.ts @@ -4,6 +4,10 @@ import { mkdtemp, open, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { IPty, spawn } from "node-pty"; +import { + resolveNodePtyDiskPackageDir, + resolveNodePtySpawnHelperPaths, +} from "./terminal-spawn-helper.js"; import { TerminalService } from "./terminal.js"; import type { RendererDocumentOwner } from "./renderer-document-owner.js"; @@ -15,14 +19,17 @@ function deferred() { return { promise, resolve }; } -function ownerState(documentId = "document-1") { +function ownerState(documentId = "document-1", id = 42) { let destroyed = false; const listeners = new Set<() => void>(); + const sent: Array<{ channel: string; payload: unknown }> = []; const owner: RendererDocumentOwner = { - id: 42, + id, documentId, isDestroyed: () => destroyed, - send: () => undefined, + send: (channel, payload) => { + sent.push({ channel, payload }); + }, onInvalidated: (listener) => { listeners.add(listener); if (destroyed) listener(); @@ -31,6 +38,7 @@ function ownerState(documentId = "document-1") { }; return { owner, + sent, destroy: () => { destroyed = true; for (const listener of [...listeners]) listener(); @@ -40,17 +48,38 @@ function ownerState(documentId = "document-1") { function fakePty() { let killed = false; + let dataListener: ((data: string) => void) | undefined; + let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined; + const writes: string[] = []; + const resizes: Array<{ cols: number; rows: number }> = []; const pty = { pid: 999_999, kill: () => { killed = true; }, - onData: () => ({ dispose: () => undefined }), - onExit: () => ({ dispose: () => undefined }), - resize: () => undefined, - write: () => undefined, + onData: (listener: (data: string) => void) => { + dataListener = listener; + return { dispose: () => undefined }; + }, + onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => { + exitListener = listener; + return { dispose: () => undefined }; + }, + resize: (cols: number, rows: number) => { + resizes.push({ cols, rows }); + }, + write: (data: string) => { + writes.push(data); + }, } as unknown as IPty; - return { pty, killed: () => killed }; + return { + pty, + killed: () => killed, + writes, + resizes, + emitData: (data: string) => dataListener?.(data), + emitExit: (exitCode = 0, signal?: number) => exitListener?.({ exitCode, signal }), + }; } test("renderer destruction during terminal revalidation prevents spawn", async () => { @@ -225,6 +254,99 @@ test("a missing prebuilds directory is a no-op (node-pty picks its own path)", a assert.equal(typeof session.id, "string"); }); +test("packaged spawn-helper checks target the unpacked filesystem path", () => { + const packaged = path.join( + "/Applications", + "Aiden Agent.app", + "Contents", + "Resources", + "app.asar", + "node_modules", + "node-pty", + ); + const unpacked = resolveNodePtyDiskPackageDir(packaged); + + assert.equal( + unpacked, + path.join( + "/Applications", + "Aiden Agent.app", + "Contents", + "Resources", + "app.asar.unpacked", + "node_modules", + "node-pty", + ), + ); + assert.equal(resolveNodePtyDiskPackageDir(unpacked), unpacked, "rewrite must be idempotent"); +}); + +test("production spawn-helper discovery reads only the unpacked ASAR directory", async () => { + const packaged = path.join( + "/Applications", + "Aiden Agent.app", + "Contents", + "Resources", + "app.asar", + "node_modules", + "node-pty", + ); + const reads: string[] = []; + const helpers = await resolveNodePtySpawnHelperPaths(packaged, async (directory) => { + reads.push(directory); + return ["darwin-arm64", "darwin-x64"]; + }); + const unpackedPrebuilds = path.join( + "/Applications", + "Aiden Agent.app", + "Contents", + "Resources", + "app.asar.unpacked", + "node_modules", + "node-pty", + "prebuilds", + ); + + assert.deepEqual(reads, [unpackedPrebuilds]); + assert.deepEqual(helpers, [ + path.join(unpackedPrebuilds, "darwin-arm64", "spawn-helper"), + path.join(unpackedPrebuilds, "darwin-x64", "spawn-helper"), + ]); + assert.equal( + helpers.some((helper) => /app\.asar[/\\]/u.test(helper)), + false, + ); +}); + +test("spawn-helper discovery handles node_modules.asar and absent prebuilds", async () => { + const packaged = path.join( + "/Applications", + "Aiden Agent.app", + "Contents", + "Resources", + "node_modules.asar", + "node-pty", + ); + const reads: string[] = []; + const helpers = await resolveNodePtySpawnHelperPaths(packaged, async (directory) => { + reads.push(directory); + throw new Error("missing"); + }); + + assert.deepEqual(helpers, []); + assert.deepEqual(reads, [ + path.join( + "/Applications", + "Aiden Agent.app", + "Contents", + "Resources", + "node_modules.asar.unpacked", + "node-pty", + "prebuilds", + ), + ]); +}); + test("a spawn-helper that is already executable is left untouched", async () => { const dir = await mkdtemp(path.join(tmpdir(), "pty-helper-ok-")); const helper = path.join(dir, "spawn-helper"); @@ -357,3 +479,134 @@ test("persisted history seeds a reopened terminal buffer", async () => { await service.flushHistory(); assert.equal(flushAllCount, 1); }); + +test("terminal sessions cover input, resize, output, snapshot, history, and natural exit", async () => { + const owner = ownerState(); + const child = fakePty(); + const appended: Array<{ workspaceId: string; data: string }> = []; + let flushCount = 0; + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + spawnPty: (() => child.pty) as typeof spawn, + historyStore: { + read: async () => "restored\n", + append: (workspaceId, data) => appended.push({ workspaceId, data }), + flush: async () => { + flushCount += 1; + }, + }, + }); + const session = await service.create("workspace-1", "/tmp", owner.owner); + + assert.deepEqual(service.snapshot(session.id, owner.owner), { + buffer: "restored\n", + sequence: 1, + }); + service.write(session.id, "printf ready\\n", owner.owner); + assert.deepEqual(child.writes, ["printf ready\\n"]); + assert.throws(() => service.write(session.id, "", owner.owner), /non-empty message/u); + assert.throws( + () => service.write(session.id, "x".repeat(64_001), owner.owner), + /smaller than 64 KB/u, + ); + service.resize(session.id, 999, "invalid", owner.owner); + assert.deepEqual(child.resizes, [{ cols: 500, rows: 30 }]); + + child.emitData("live output\n"); + assert.deepEqual(service.snapshot(session.id, owner.owner), { + buffer: "restored\nlive output\n", + sequence: 2, + }); + assert.deepEqual(appended, [{ workspaceId: "workspace-1", data: "live output\n" }]); + assert.deepEqual(owner.sent[owner.sent.length - 1], { + channel: "terminal:data", + payload: { sessionId: session.id, sequence: 2, data: "live output\n" }, + }); + + child.emitExit(7, 15); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(flushCount, 1); + assert.deepEqual(owner.sent[owner.sent.length - 1], { + channel: "terminal:exit", + payload: { sessionId: session.id, exitCode: 7, signal: 15 }, + }); + assert.throws(() => service.workspaceId(session.id, owner.owner), /unavailable/u); +}); + +test("terminal teardown is scoped by workspace and renderer web contents", async () => { + const firstOwner = ownerState("document-1", 41); + const secondOwner = ownerState("document-2", 42); + const children = [fakePty(), fakePty(), fakePty()]; + let spawnIndex = 0; + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + spawnPty: (() => children[spawnIndex++]!.pty) as typeof spawn, + }); + const first = await service.create("workspace-a", "/tmp", firstOwner.owner); + const second = await service.create("workspace-b", "/tmp", firstOwner.owner); + const third = await service.create("workspace-b", "/tmp", secondOwner.owner); + + service.closeForWorkspace("workspace-a"); + assert.equal(children[0]!.killed(), true); + assert.equal(children[1]!.killed(), false); + assert.equal(children[2]!.killed(), false); + assert.throws(() => service.snapshot(first.id, firstOwner.owner), /unavailable/u); + assert.equal(service.workspaceId(second.id, firstOwner.owner), "workspace-b"); + assert.equal(service.workspaceId(third.id, secondOwner.owner), "workspace-b"); + + service.closeForWebContents(firstOwner.owner.id); + assert.equal(children[1]!.killed(), true); + assert.equal(children[2]!.killed(), false); + service.close(third.id, secondOwner.owner); + assert.equal(children[2]!.killed(), true); + assert.deepEqual(secondOwner.sent[secondOwner.sent.length - 1], { + channel: "terminal:exit", + payload: { sessionId: third.id, exitCode: null, signal: "SIGHUP" }, + }); +}); + +test("terminal enforces the per-document session cap and history initialization order", async () => { + const owner = ownerState(); + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + spawnPty: (() => fakePty().pty) as typeof spawn, + }); + for (let index = 0; index < 8; index += 1) { + await service.create(`workspace-${index}`, "/tmp", owner.owner); + } + await assert.rejects( + service.create("workspace-over-limit", "/tmp", owner.owner), + /maximum of 8 terminal sessions/u, + ); + assert.throws( + () => + service.installHistoryStore({ + read: async () => "", + append: () => undefined, + flush: async () => undefined, + }), + /before opening a terminal/u, + ); +}); + +test("history flush falls back to one flush per active workspace", async () => { + const owner = ownerState(); + const flushed: string[] = []; + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + spawnPty: (() => fakePty().pty) as typeof spawn, + historyStore: { + read: async () => "", + append: () => undefined, + flush: async (workspaceId) => { + flushed.push(workspaceId); + }, + }, + }); + await service.create("workspace-1", "/tmp", owner.owner); + await service.create("workspace-1", "/tmp", owner.owner); + await service.create("workspace-2", "/tmp", owner.owner); + + await service.flushHistory(); + assert.deepEqual(flushed.sort(), ["workspace-1", "workspace-2"]); +}); diff --git a/main/services/terminal.ts b/main/services/terminal.ts index 8783403..f92ba10 100644 --- a/main/services/terminal.ts +++ b/main/services/terminal.ts @@ -8,6 +8,7 @@ import { accessSync, constants as fsConstants } from "node:fs"; import { createRequire } from "module"; import { spawn, type IPty } from "node-pty"; import type { RendererDocumentOwner } from "./renderer-document-owner.js"; +import { resolveNodePtySpawnHelperPaths } from "./terminal-spawn-helper.js"; const MAX_INPUT_CHARS = 64_000; const MAX_BUFFER_CHARS = 200_000; @@ -478,9 +479,10 @@ export class TerminalService { * * node-pty 1.1.0 loads the helper from `prebuilds/-/spawn-helper` * via `utils.loadNativeModule`, and in a packaged Electron app the same file - * lives under `app.asar.unpacked`. We resolve from `node-pty/package.json` and - * enumerate every `prebuilds/*` directory so a wrong-arch guess, a Rosetta run, - * or an extra prebuild still gets fixed up. + * lives under `app.asar.unpacked`. We resolve from `node-pty/package.json`, move + * to the real unpacked directory before any filesystem operation, and enumerate + * every `prebuilds/*` directory so a wrong-arch guess, a Rosetta run, or an extra + * prebuild still gets fixed up. */ async function defaultSpawnHelperPaths(): Promise { const require = createRequire(import.meta.url); @@ -492,25 +494,7 @@ async function defaultSpawnHelperPaths(): Promise { // spawn will surface the underlying error. return []; } - const prebuildsDir = path.join(packageDir, "prebuilds"); - let entries: string[]; - try { - entries = await fs.readdir(prebuildsDir); - } catch { - return []; - } - const helpers: string[] = []; - for (const entry of entries) { - helpers.push(path.join(prebuildsDir, entry, "spawn-helper")); - // Packaged apps unpack node-pty to app.asar.unpacked; mirror node-pty's - // own helperPath rewrite so the on-disk copy there is fixed too. - if (packageDir.includes("app.asar")) { - helpers.push( - path.join(prebuildsDir, entry, "spawn-helper").replace("app.asar", "app.asar.unpacked"), - ); - } - } - return helpers; + return resolveNodePtySpawnHelperPaths(packageDir); } async function ensureHelperExecutable(helper: string): Promise { diff --git a/package.json b/package.json index 854556b..c03b611 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "dev:electron": "wait-on http-get://127.0.0.1:4143/main-window.html && node scripts/prepare-macos-dev-runtime.mjs --run", "dev:brand": "node scripts/prepare-macos-dev-runtime.mjs", "lint": "eslint .", - "pretest": "npm run build:worktree-remover && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:compaction && npm run test:subagents", + "pretest": "npm run build:worktree-remover && npm run test:terminal:coverage && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:compaction && npm run test:subagents", "pretest:coverage": "npm run build:worktree-remover && npm run build:subagent-run-store && npm run test:preflight && npm run test:scheduled && npm run test:google-provider && npm run test:config-recovery && npm run test:command-system && npm run test:slash-commands && npm run test:compaction && npm run test:subagents", "test:preflight": "npm run test:artificial-analysis && npm run test:model-pad && tsx --test main/services/appearance-preview-core.test.ts main/services/generation-timeline.test.ts main/services/local-runtime-status.test.ts main/services/mcp-tool-result.test.ts renderer/components/activity-feed.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/thinking-control.test.tsx renderer/lib/agent-steps.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/pill-appearance.test.ts renderer/lib/streaming-motion-contract.test.ts renderer/lib/streaming-reveal.test.ts renderer/lib/voice-recorder-core.test.ts renderer/pill-preload-channels.test.ts renderer/shared/anthropic-thinking.test.ts renderer/shared/app-update.test.ts renderer/shared/claim-check.test.ts renderer/shared/codex-thinking.test.ts renderer/shared/google-thinking.test.ts renderer/shared/provider-deployment.test.ts", "test:branding": "tsx --test main/runtime-mode.test.ts main/runtime-profile-core.test.ts main/runtime-profile-bootstrap.test.ts main/services/app-updater-core.test.ts && node --test scripts/prepare-ci-release.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/check-release-consumers.test.mjs", @@ -71,6 +71,7 @@ "test:e2e": "npm run type-check:e2e && npm run build && playwright test --config=playwright.config.ts --fail-on-flaky-tests", "test:e2e:list": "playwright test --config=playwright.config.ts --list", "test:e2e:live:lmstudio": "npm run type-check:e2e && npm run build && AIDEN_E2E_LIVE_LMSTUDIO=1 playwright test --config=playwright.config.ts", + "test:terminal:coverage": "tsx --test --experimental-test-coverage --test-coverage-include=main/services/terminal-spawn-helper.ts --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 main/services/terminal.test.ts && tsx --test --experimental-test-coverage --test-coverage-include=main/services/terminal.ts --test-coverage-lines=95 --test-coverage-branches=80 --test-coverage-functions=90 main/services/terminal.test.ts", "test:compaction": "tsx --test main/services/pi-compaction-core.test.ts", "test": "tsx --test main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:worktree-remover:native && npm run test:computer-use:native", "test:coverage": "tsx --test --experimental-test-coverage main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", diff --git a/scripts/verify-macos-package.mjs b/scripts/verify-macos-package.mjs index dc380e4..0993aee 100644 --- a/scripts/verify-macos-package.mjs +++ b/scripts/verify-macos-package.mjs @@ -48,6 +48,10 @@ const reviewedHelperInfoPlistPath = path.join( "Info.plist", ); const PACKAGED_MODELS_DEV_ENTRY = "resources/model-capabilities.json"; +const REQUIRED_NODE_PTY_HELPER_ENTRIES = Object.freeze([ + "node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper", + "node_modules/node-pty/prebuilds/darwin-x64/spawn-helper", +]); const EXPECTED_COMPUTER_USE_HELPER_TREE = Object.freeze( [ ["Contents", "directory"], @@ -137,6 +141,55 @@ export async function verifyPackagedModelCatalogResources(appAsar) { validateModelsDevSnapshot(snapshot); } +export function assertPackagedNodePtyHelperEntries(entries) { + const normalized = new Set( + entries.map((entry) => entry.replaceAll("\\", "/").replace(/^\//u, "")), + ); + const missing = REQUIRED_NODE_PTY_HELPER_ENTRIES.filter((entry) => !normalized.has(entry)); + if (missing.length > 0) { + throw new Error( + `Packaged app.asar is missing node-pty spawn-helper entries: ${missing.join(", ")}`, + ); + } +} + +export function assertNodePtySpawnHelperMode(mode, file) { + const permissions = mode & 0o777; + if (permissions !== 0o755) { + throw new Error( + `Expected node-pty spawn-helper mode 0755 for ${file}, received 0${permissions.toString(8)}`, + ); + } +} + +/** + * Verify the exact packaged runtime contract used by TerminalService: both + * macOS helpers are ASAR-unpacked regular files with executable permissions. + */ +export async function verifyPackagedNodePtyResources(appAsar) { + await assertRegularFile(appAsar); + const packageEntries = listPackage(appAsar, { isPack: false }); + assertPackagedNodePtyHelperEntries(packageEntries); + for (const entryPath of REQUIRED_NODE_PTY_HELPER_ENTRIES) { + const entry = statFile(appAsar, entryPath, false); + if ( + !entry || + entry.unpacked !== true || + typeof entry.size !== "number" || + entry.size <= 0 || + "files" in entry || + "link" in entry + ) { + throw new Error( + `Packaged node-pty spawn-helper must be an unpacked regular file: ${entryPath}`, + ); + } + const helper = path.join(`${appAsar}.unpacked`, entryPath); + const info = await assertRegularFile(helper); + assertNodePtySpawnHelperMode(info.mode, helper); + } +} + export function assertComputerUseExecutableMode(mode, file) { const permissions = mode & 0o777; if (permissions !== 0o755) { @@ -478,6 +531,7 @@ export async function verifyMacPackage(appPath) { await assertRegularFile(file); } await verifyPackagedModelCatalogResources(appAsar); + await verifyPackagedNodePtyResources(appAsar); await verifyExactComputerUseHelperTree(paths.helperApp); assertComputerUseExecutableMode((await lstat(paths.broker)).mode, paths.broker); assertComputerUseExecutableMode((await lstat(paths.driver)).mode, paths.driver); diff --git a/scripts/verify-macos-package.test.mjs b/scripts/verify-macos-package.test.mjs index babde9b..7333ed0 100644 --- a/scripts/verify-macos-package.test.mjs +++ b/scripts/verify-macos-package.test.mjs @@ -1,7 +1,7 @@ /* global Buffer */ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, realpath, rm, symlink, unlink, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, realpath, rm, symlink, unlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -20,12 +20,15 @@ import { assertMacOSArchitectureMinimum, assertMatchingHostCodeHashes, assertPackagedModelCatalogEntries, + assertPackagedNodePtyHelperEntries, + assertNodePtySpawnHelperMode, assertSamePackagedArtifactIdentity, assertHardenedRuntime, assertRegularFile, requiresReleaseVerification, verifyExactComputerUseHelperTree, verifyPackagedModelCatalogResources, + verifyPackagedNodePtyResources, verifyReviewedComputerUseInfoPlist, } from "./verify-macos-package.mjs"; @@ -181,6 +184,78 @@ test("package verifier rejects directory, symlink, and unpacked catalog entries" } }); +test("package verifier requires unpacked executable node-pty helpers for both macOS architectures", async () => { + const expectedEntries = [ + "/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper", + "/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper", + ]; + assert.doesNotThrow(() => assertPackagedNodePtyHelperEntries(expectedEntries)); + assert.throws( + () => assertPackagedNodePtyHelperEntries(expectedEntries.slice(0, 1)), + /missing node-pty spawn-helper entries.*darwin-x64/u, + ); + assert.doesNotThrow(() => assertNodePtySpawnHelperMode(0o100755, "spawn-helper")); + assert.throws( + () => assertNodePtySpawnHelperMode(0o100644, "spawn-helper"), + /spawn-helper mode 0755/u, + ); + + const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), "aiden-node-pty-asar-")); + const root = await realpath(temporaryRoot); + const source = path.join(root, "source"); + const armHelper = path.join( + source, + "node_modules", + "node-pty", + "prebuilds", + "darwin-arm64", + "spawn-helper", + ); + const x64Helper = path.join( + source, + "node_modules", + "node-pty", + "prebuilds", + "darwin-x64", + "spawn-helper", + ); + const unpackedAsar = path.join(root, "unpacked.asar"); + const packedAsar = path.join(root, "packed.asar"); + try { + await Promise.all([ + mkdir(path.dirname(armHelper), { recursive: true }), + mkdir(path.dirname(x64Helper), { recursive: true }), + ]); + await Promise.all([ + writeFile(armHelper, "arm helper", { mode: 0o755 }), + writeFile(x64Helper, "x64 helper", { mode: 0o755 }), + ]); + await createPackageWithOptions(source, unpackedAsar, { + unpack: "**/node-pty/prebuilds/**/spawn-helper", + }); + await assert.doesNotReject(verifyPackagedNodePtyResources(unpackedAsar)); + + const packagedX64Helper = path.join( + `${unpackedAsar}.unpacked`, + "node_modules", + "node-pty", + "prebuilds", + "darwin-x64", + "spawn-helper", + ); + await chmod(packagedX64Helper, 0o644); + await assert.rejects(verifyPackagedNodePtyResources(unpackedAsar), /spawn-helper mode 0755/u); + + await createPackage(source, packedAsar); + await assert.rejects( + verifyPackagedNodePtyResources(packedAsar), + /must be an unpacked regular file/u, + ); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); + test("package verifier rejects additional helper Info.plist keys", async () => { const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), "aiden-cua-package-plist-")); const root = await realpath(temporaryRoot); diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index 40d1fd4..b6bd405 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -20,6 +20,7 @@ export const E2E_MODEL_ID = "aiden-e2e-vision"; export const E2E_MODEL_DISPLAY_NAME = "Aiden E2E Vision"; export const E2E_PROFILE_NAME = "E2E Local User"; export const E2E_ASSISTANT_RESPONSE = "Deterministic E2E response received."; +export const E2E_WORKSPACE_ID = "aiden-e2e-workspace"; export const LIVE_LM_STUDIO_ACCEPTANCE = process.env.AIDEN_E2E_LIVE_LMSTUDIO === "1"; // Playwright's config loader currently resolves its test package through the @@ -94,12 +95,14 @@ export type AidenE2e = { userDataDir: string; configDir: string; rootDir: string; + workspaceDir: string; lmStudio: LmStudioEndpoint; relaunch: () => Promise; }; type AidenE2eOptions = { portableConfigSeed: PortableConfigSeed; + workspaceSeed: boolean; }; type MockLmStudio = LmStudioEndpoint & { @@ -304,6 +307,24 @@ async function seedPortableConfig( }); } +async function seedWorkspace(userDataDir: string, workspaceDir: string): Promise { + const now = Date.now(); + await writePrivateJson(path.join(userDataDir, "config.json"), { + workspaces: [ + { + id: E2E_WORKSPACE_ID, + name: "Aiden E2E workspace", + folderPath: workspaceDir, + permission: "full", + createdAt: now, + updatedAt: now, + }, + ], + seeded: true, + aidenDirMigratedAt: now, + }); +} + /** Wait for the one main window without assuming its initial route or title. */ export async function firstAidenWindow(app: ElectronApplication): Promise { const page = await app.firstWindow(); @@ -547,7 +568,8 @@ function formatFailure(error: unknown): string { export const test = base.extend({ portableConfigSeed: ["lmstudio", { option: true }], - aiden: async ({ browserName: _browserName, portableConfigSeed }, use, testInfo) => { + workspaceSeed: [false, { option: true }], + aiden: async ({ browserName: _browserName, portableConfigSeed, workspaceSeed }, use, testInfo) => { let rootDir: string | undefined; let mock: MockLmStudio | undefined; let app: ElectronApplication | undefined; @@ -562,6 +584,7 @@ export const test = base.extend({ const testXdgCacheDir = path.join(testRootDir, "xdg-cache"); const testXdgConfigDir = path.join(testRootDir, "xdg-config"); const testXdgDataDir = path.join(testRootDir, "xdg-data"); + const testWorkspaceDir = path.join(testRootDir, "workspace"); rootDir = testRootDir; await Promise.all([ mkdir(testUserDataDir, { recursive: true, mode: 0o700 }), @@ -569,7 +592,9 @@ export const test = base.extend({ mkdir(testXdgCacheDir, { recursive: true, mode: 0o700 }), mkdir(testXdgConfigDir, { recursive: true, mode: 0o700 }), mkdir(testXdgDataDir, { recursive: true, mode: 0o700 }), + mkdir(testWorkspaceDir, { recursive: true, mode: 0o700 }), ]); + if (workspaceSeed) await seedWorkspace(testUserDataDir, testWorkspaceDir); mock = LIVE_LM_STUDIO_ACCEPTANCE ? undefined : await startMockLmStudio(); const lmStudio: LmStudioEndpoint = mock ?? { @@ -635,6 +660,7 @@ export const test = base.extend({ userDataDir: testUserDataDir, configDir: testConfigDir, rootDir: testRootDir, + workspaceDir: testWorkspaceDir, lmStudio, relaunch: async () => { const previous = app; diff --git a/tests/e2e/terminal.spec.ts b/tests/e2e/terminal.spec.ts new file mode 100644 index 0000000..ac61379 --- /dev/null +++ b/tests/e2e/terminal.spec.ts @@ -0,0 +1,51 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { E2E_WORKSPACE_ID, expect, finishLmStudioOnboarding, test } from "./fixtures"; + +test.use({ workspaceSeed: true }); + +test("workspace terminal opens a real PTY, runs a shell command, and persists output", async ({ + aiden, +}) => { + const { page } = aiden; + await finishLmStudioOnboarding(page); + + const toggle = page.getByRole("button", { name: "Show terminal" }); + await expect(toggle).toBeEnabled(); + await toggle.click(); + + const drawer = page.locator('.terminal-drawer[data-state="open"]'); + await expect(drawer).toBeVisible(); + await expect(drawer.getByText("Terminal 1", { exact: true })).toBeVisible(); + const hideTerminal = drawer.getByRole("button", { name: "Hide terminal" }); + await expect(hideTerminal).toBeVisible(); + + await drawer.locator(".xterm-screen").click(); + await page.keyboard.type("echo $((314159+271828)); pwd"); + await page.keyboard.press("Enter"); + + const historyFile = path.join( + aiden.userDataDir, + "terminal-history", + `${createHash("sha256").update(E2E_WORKSPACE_ID).digest("hex")}.log`, + ); + await expect + .poll( + async () => { + try { + return await readFile(historyFile, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; + } + }, + { timeout: 15_000 }, + ) + .toContain("585987"); + await expect.poll(() => readFile(historyFile, "utf8")).toContain(aiden.workspaceDir); + + await hideTerminal.click(); + await expect(page.getByRole("button", { name: "Show terminal" })).toBeVisible(); + await expect(page.locator(".terminal-drawer")).toHaveCount(0); +});