Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,15 @@ jobs:
- name: Test changed runnable UI behavior
run: >-
node --import tsx --test
packages/ui/src/components/preview-isolation.test.ts
packages/ui/src/components/session-list-projection.test.ts
packages/ui/src/components/session-list-visibility.test.ts
packages/ui/src/components/unified-picker-path.test.ts
packages/ui/src/lib/hooks/use-app-session-capture.test.ts
packages/ui/src/lib/hooks/use-foreground-refresh.test.ts
packages/ui/src/lib/launch-errors.test.ts
packages/ui/src/lib/message-selection-position.test.ts
packages/ui/src/lib/native/directory-path.test.ts
packages/ui/src/lib/trailing-resync.test.ts
packages/ui/src/stores/abort-created-workspace-cleanup.test.ts
packages/ui/src/stores/app-session-reconciliation.test.ts
Expand All @@ -127,6 +130,7 @@ jobs:
packages/ui/src/stores/session-generation-recovery.test.ts
packages/ui/src/stores/session-metadata.test.ts
packages/ui/src/stores/session-pagination.test.ts
packages/ui/src/stores/session-tree.test.ts
packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts

- name: Test restore ownership integration
Expand All @@ -136,6 +140,9 @@ jobs:
packages/ui/src/stores/permission-lifecycle.test.ts
packages/ui/src/stores/session-request-authority.test.ts
packages/ui/src/stores/session-send-lifecycle.test.ts
packages/ui/src/stores/session-worktree-binding.test.ts
packages/ui/src/stores/worktree-deletion.test.ts
packages/ui/src/stores/opencode-workspaces.test.ts

- name: Test server
run: node --import tsx --test "packages/server/src/**/*.test.ts"
Expand Down Expand Up @@ -174,11 +181,15 @@ jobs:
- name: Test Windows server spawn behavior
run: node --import tsx --test packages/server/src/workspaces/__tests__/spawn.test.ts

- name: Test Electron native behavior on Windows
run: npm run test:native --workspace @neuralnomads/codenomad-electron-app

- name: Prepare Tauri test resources
run: >-
npm run dev:prep --workspace @codenomad/tauri-app &&
node -e "require('fs').mkdirSync('packages/tauri-app/src-tauri/resources/server',{recursive:true})"

- name: Test Tauri crate on Windows
working-directory: packages/tauri-app/src-tauri
run: cargo test --locked
# ponytail: cross-host tests spawn real Node/PowerShell processes; avoid runner contention.
run: cargo test --locked -- --test-threads=1
8 changes: 6 additions & 2 deletions packages/electron-app/electron/main/client-state-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import { setupClientStateIPC } from "./client-state-ipc"
function harness() {
const handlers = new Map<string, (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown>()
const listeners = new Map<string, (...args: unknown[]) => void>()
const frame = { url: "http://127.0.0.1:3000/app" }
const frame = { url: "http://127.0.0.1:3000/?launch=desktop" }
const webContents = {
mainFrame: frame,
getURL: () => "http://127.0.0.1:3000/app",
getURL: () => frame.url,
on: (event: string, listener: (...args: unknown[]) => void) => listeners.set(event, listener),
}
const window = { isDestroyed: () => false, webContents }
Expand Down Expand Up @@ -50,6 +50,10 @@ test("IPC channels enforce the current main sender, frame, origin, and token", a
{ sender: h.webContents, senderFrame: { url: h.frame.url } },
{ sender: h.webContents, senderFrame: { ...h.frame, url: "https://example.com" } },
]) await assert.rejects(h.handlers.get("client-state:load")!(invalid as never, "token") as Promise<unknown>)

h.frame.url = "http://127.0.0.1:3000/previews/token"
await assert.rejects(h.handlers.get("client-state:claimAccess")!(event as never, "preview-token") as Promise<unknown>, /renderer document/)
await assert.rejects(h.handlers.get("client-state:load")!(event as never, "token") as Promise<unknown>, /renderer document/)
})

test("only the registered current window can reset renderer authority", () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/electron-app/electron/main/client-state-ipc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { BrowserWindow, IpcMainInvokeEvent } from "electron"
import type { ClientStateManager } from "./client-state"
import { shouldResetRendererAccessTokenForNavigation } from "./client-state-navigation"
import { isAllowedRendererOrigin } from "./renderer-origin"
import { isAllowedRendererDocument, isAllowedRendererOrigin } from "./renderer-origin"

interface IPCRegistrar {
handle(channel: string, listener: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown): void
Expand All @@ -19,11 +19,11 @@ function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow | n

const currentUrl = mainWindow.webContents.getURL()
if (
!isAllowedRendererOrigin(currentUrl, allowedOrigins) ||
!isAllowedRendererOrigin(event.senderFrame.url, allowedOrigins) ||
!isAllowedRendererDocument(currentUrl, allowedOrigins) ||
!isAllowedRendererDocument(event.senderFrame.url, allowedOrigins) ||
new URL(currentUrl).origin !== new URL(event.senderFrame.url).origin
) {
throw new Error("Client state IPC is not available to the current renderer origin")
throw new Error("Client state IPC is not available to the current renderer document")
}
}

Expand Down
4 changes: 3 additions & 1 deletion packages/electron-app/electron/main/client-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ function harness(t: test.TestContext, initial?: object) {
return { create, directory, statePath, fail: (value: boolean) => { failing = value }, writes: () => writes }
}

test("renderer access is exclusive per document and resettable", async (t) => {
test("renderer capability rejects missing, wrong, and stale document tokens", async (t) => {
const manager = harness(t, { version: 1, restoreEnabled: true }).create()
assert.throws(() => manager.claimClientStateAccess(""), /nonempty string/)
assert.throws(() => manager.assertRendererAccessToken(undefined), /nonempty string/)
assert.throws(() => manager.assertRendererAccessToken("unclaimed"), /has not been claimed/)
assert.equal(manager.claimClientStateAccess("document-1"), true)
assert.equal(manager.claimClientStateAccess("document-1"), true)
assert.throws(() => manager.claimClientStateAccess("document-2"), /does not match/)
assert.throws(() => manager.assertRendererAccessToken("document-2"), /has not been claimed/)
manager.assertRendererAccessToken("document-1")
assert.equal(await manager.saveClientState({ saved: true }), true)
manager.resetRendererAccessToken()
Expand Down
31 changes: 29 additions & 2 deletions packages/electron-app/electron/main/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, shell, type OpenDialogOptions } from "electron"
import fs from "fs"
import type { ClientStateManager } from "./client-state"
import { requestMicrophoneAccess } from "./permissions"
import type { CliProcessManager, CliStatus } from "./process-manager"
import { isManagedMainFrame, openManagedWorktreeDirectory } from "./worktree-directory"

let wakeLockId: number | null = null

Expand All @@ -18,7 +20,7 @@ interface DialogOpenResult {
paths: string[]
}

export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager, clientStateManager: ClientStateManager) {
cliManager.on("status", (status: CliStatus) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:status", status)
Expand Down Expand Up @@ -88,6 +90,31 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return directories
})

ipcMain.handle("filesystem:openDirectory", async (event, accessToken: unknown, instanceId: unknown, worktreeSlug: unknown): Promise<{ ok: boolean }> => {
const renderer = { sender: event.sender, senderFrame: event.senderFrame }
const authority = await cliManager.captureReadyAuthority()
const baseUrl = authority?.url
if (!authority || !baseUrl || !isManagedMainFrame(renderer, mainWindow, baseUrl)) {
throw new Error("Directory opening is unavailable from this renderer")
}
clientStateManager.assertRendererAccessToken(accessToken)
if (typeof instanceId !== "string" || typeof worktreeSlug !== "string") throw new Error("Invalid worktree")
await openManagedWorktreeDirectory({
baseUrl,
instanceId,
worktreeSlug,
fetch: (url, init) => mainWindow.webContents.session.fetch(url, init),
openPath: (directory) => cliManager.withReadyAuthority(authority, () => {
if (!isManagedMainFrame(renderer, mainWindow, baseUrl)) {
throw new Error("Directory opening is unavailable from this renderer")
}
clientStateManager.assertRendererAccessToken(accessToken)
return shell.openPath(directory)
}),
})
return { ok: true }
})

ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
const next = Boolean(enabled)
if (next) {
Expand Down
12 changes: 9 additions & 3 deletions packages/electron-app/electron/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { ClientStateLifecycle } from "./client-state-lifecycle"
import { ClientStateNavigationController } from "./client-state-navigation"
import { setupCliIPC } from "./ipc"
import { configureMediaPermissionHandlers, isAllowedRendererOrigin } from "./permissions"
import { resolveConfiguredRendererOrigins } from "./renderer-origin"
import { isAllowedMainWindowDocument, resolveConfiguredRendererOrigins } from "./renderer-origin"
import { CliProcessManager } from "./process-manager"
import {
clampWindowBounds,
Expand Down Expand Up @@ -235,6 +235,8 @@ function shouldOpenExternally(url: string, window?: BrowserWindow | null): boole
}

function setupNavigationGuards(window: BrowserWindow, navigationController?: ClientStateNavigationController) {
const isTrustedLocalDocument = (url: string) => !navigationController
|| isAllowedMainWindowDocument(url, getAllowedRendererOrigins(window))
const handleExternal = (url: string) => {
shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error))
}
Expand All @@ -244,13 +246,15 @@ function setupNavigationGuards(window: BrowserWindow, navigationController?: Cli
handleExternal(url)
return { action: "deny" }
}
return { action: "allow" }
return { action: isTrustedLocalDocument(url) ? "allow" : "deny" }
})

window.webContents.on("will-navigate", (event, url) => {
if (shouldOpenExternally(url, window)) {
event.preventDefault()
handleExternal(url)
} else if (!isTrustedLocalDocument(url)) {
event.preventDefault()
} else if (navigationController) {
event.preventDefault()
void navigationController.navigate((target) => target.loadURL(url)).catch((error) => {
Expand All @@ -265,6 +269,8 @@ function setupNavigationGuards(window: BrowserWindow, navigationController?: Cli
if (shouldOpenExternally(url, window)) {
event.preventDefault()
handleExternal(url)
} else if (!isTrustedLocalDocument(url)) {
event.preventDefault()
}
})
}
Expand Down Expand Up @@ -446,7 +452,7 @@ function createWindow() {
void navigationController.navigate((target) => target.webContents.reloadIgnoringCache())
},
})
setupCliIPC(window, cliManager)
setupCliIPC(window, cliManager, clientStateManager)
bindClientStateWindow(window)
clientStateLifecycle.attachMainWindow(window, windowStateTracker)

Expand Down
50 changes: 50 additions & 0 deletions packages/electron-app/electron/main/process-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export interface CliStatus {
error?: string
}

export interface CliReadyAuthority {
generation: number
pid: number
startIdentity: string
url: string
}

export interface CliLogEntry {
stream: "stdout" | "stderr"
message: string
Expand Down Expand Up @@ -145,6 +152,7 @@ export class CliProcessManager extends EventEmitter {
private requestedStop = false
private shutdownStatus: "complete" | "incomplete" | null = null
private lifecycle = new SerializedLifecycle()
private generation = 0

start(options: StartOptions): Promise<CliStatus> {
return this.lifecycle.enqueue(() => this.startNow(options))
Expand Down Expand Up @@ -172,6 +180,7 @@ export class CliProcessManager extends EventEmitter {
await this.stopNow()
if (this.child) throw new Error("CLI process did not exit before restart")
}
this.generation++

this.stdoutBuffer = ""
this.stderrBuffer = ""
Expand Down Expand Up @@ -234,6 +243,7 @@ export class CliProcessManager extends EventEmitter {

child.on("exit", (code, signal) => {
if (this.child !== child) return
this.generation++
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
Expand Down Expand Up @@ -265,6 +275,7 @@ export class CliProcessManager extends EventEmitter {
}

private async stopNow(): Promise<void> {
this.generation++
const child = this.child
if (!child) {
this.updateStatus({ state: "stopped" })
Expand Down Expand Up @@ -348,6 +359,45 @@ export class CliProcessManager extends EventEmitter {
return { ...this.status }
}

async captureReadyAuthority(): Promise<CliReadyAuthority | undefined> {
const generation = this.generation
const child = this.child
const status = this.status
const pid = child?.pid
if (this.requestedStop || status.state !== "ready" || !pid || !status.url || !this.childStartIdentity) return undefined
const startIdentity = await this.childStartIdentity
if (!startIdentity) return undefined
const liveIdentity = await getProcessStartIdentityAsync(pid, 1_500)
if (liveIdentity !== startIdentity || generation !== this.generation || child !== this.child || this.status.state !== "ready") return undefined
return { generation, pid, startIdentity, url: status.url }
}

async validateReadyAuthority(authority: CliReadyAuthority): Promise<boolean> {
const child = this.child
if (this.requestedStop || this.generation !== authority.generation || this.status.state !== "ready"
|| this.status.url !== authority.url || child?.pid !== authority.pid || !this.childStartIdentity) return false
const [capturedIdentity, liveIdentity] = await Promise.all([
this.childStartIdentity,
getProcessStartIdentityAsync(authority.pid, 1_500),
])
return capturedIdentity === authority.startIdentity && liveIdentity === authority.startIdentity
&& this.generation === authority.generation && child === this.child && this.status.state === "ready"
}

private isReadyAuthorityCurrent(authority: CliReadyAuthority): boolean {
return !this.requestedStop && this.generation === authority.generation && this.status.state === "ready"
&& this.status.url === authority.url && this.child?.pid === authority.pid
}

withReadyAuthority<T>(authority: CliReadyAuthority, dispatch: () => T | Promise<T>): Promise<T> {
return this.lifecycle.enqueue(async () => {
if (!await this.validateReadyAuthority(authority) || !this.isReadyAuthorityCurrent(authority)) {
throw new Error("Directory opening is unavailable from this renderer")
}
return await dispatch()
})
}

getAuthCookieName(): string {
return this.authCookieName
}
Expand Down
65 changes: 65 additions & 0 deletions packages/electron-app/electron/main/process-stop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,3 +540,68 @@ test("incomplete shutdown status remains terminal", async () => {
hooks.deregister()
}
})

test("folder authority is denied after a CLI generation change or exit", async () => {
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "electron") {
return { shortCircuit: true, url: "data:text/javascript,export const app={isPackaged:false,getAppPath(){return ''}}" }
}
return nextResolve(specifier, context)
},
})
try {
const [{ CliProcessManager }, { getProcessStartIdentityAsync }] = await Promise.all([
import("./process-manager"),
import("./client-state-process-identity"),
])
const identity = await getProcessStartIdentityAsync(process.pid, 1_500)
assert.ok(identity)
const manager = new CliProcessManager() as any
manager.generation = 7
manager.requestedStop = false
manager.child = { pid: process.pid }
manager.childStartIdentity = Promise.resolve(identity)
manager.status = { state: "ready", pid: process.pid, url: "http://127.0.0.1:43123" }
const authority = await manager.captureReadyAuthority()
assert.ok(authority)

manager.generation++
assert.equal(await manager.validateReadyAuthority(authority), false)
manager.generation = authority.generation
manager.status = { state: "stopped" }
manager.child = undefined
assert.equal(await manager.validateReadyAuthority(authority), false)
} finally {
hooks.deregister()
}
})

test("folder authority rotation during final validation prevents native dispatch", async () => {
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "electron") {
return { shortCircuit: true, url: "data:text/javascript,export const app={isPackaged:false,getAppPath(){return ''}}" }
}
return nextResolve(specifier, context)
},
})
try {
const { CliProcessManager } = await import("./process-manager")
const manager = new CliProcessManager() as any
const authority = { generation: 7, pid: process.pid, startIdentity: "identity", url: "http://127.0.0.1:43123" }
manager.generation = authority.generation
manager.requestedStop = false
manager.child = { pid: authority.pid }
manager.status = { state: "ready", pid: authority.pid, url: authority.url }
manager.validateReadyAuthority = async () => {
manager.generation++
return true
}
let dispatched = false
await assert.rejects(manager.withReadyAuthority(authority, () => { dispatched = true }), /unavailable from this renderer/)
assert.equal(dispatched, false)
} finally {
hooks.deregister()
}
})
Loading
Loading