diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 265883486..03485500f 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -21,7 +21,7 @@ npm run make:rpm -w @propr/desktop ``` Desktop development, typecheck, package, and make commands build required renderer workspace dependencies through -`desktop:prepare`, in dependency order (`@propr/shared` then `@propr/client`). They do not depend on previously +`desktop:prepare`, in dependency order (`@propr/shared`, `@propr/client`, `@propr/local-setup`, then `@propr/cli`). They do not depend on previously generated workspace `dist` directories. Development renderer URLs are accepted only when Electron Forge supplies an HTTP loopback URL. Packaged builds load @@ -37,8 +37,8 @@ CI runs both checks directly from the committed lockfile before installing or ex ## Security boundary -The renderer has no Node.js integration and receives only the typed `window.proprDesktop` bridge. It exposes metadata, -validated external-browser opening, profiles, encrypted credentials, lifecycle placeholders, and validated deep-link +The renderer has no Node.js integration and receives only the typed `window.proprDesktop` and +`window.__PROPR_DESKTOP__` bridges. They expose metadata, validated external-browser opening, profiles, encrypted credentials, lifecycle control, guided setup, and validated deep-link events. It never exposes a shell, command runner, arbitrary IPC call, or filesystem path/API. Profile metadata is stored in an app-owned, permission-restricted JSON file. Credential values are encrypted with @@ -47,5 +47,19 @@ Electron `safeStorage` before they are written separately. If OS encryption is u fallback. Profiles remain usable because they contain only a display label and validated API endpoint. `propr://connect` and `propr://open` are the only accepted deep-link actions. A single-instance lock routes later -activations to the existing window. Local lifecycle methods intentionally return `not-implemented`; this scaffold does -not download, install, start, or execute ProPR runtime components. +activations to the existing window. Desktop pairing and active-profile request authentication remain in Electron main; +the renderer never receives the device secret or instance bearer token. + +## Local setup + +Linux presents the guided setup wizard and binds it to the shared `@propr/local-setup` engine. Progress and recovery +state are redacted before crossing IPC and persisted without prompt secrets, allowing a safely re-runnable setup to +resume after restart. The packaged app carries the same launcher manifest, orchestrator, and stack template as the CLI. + +The desktop runtime root has one stable pathname: `/desktop/local-stack`. Its `.env`, `data`, +`logs`, and `repos` children are the only desktop-managed stack locations and the only app-data paths handed to +Docker. The app validates owner-only, link-free ancestry before setup and every lifecycle start or restart. Native +directory selection is not a runtime-root feature; import/export will require a separate one-shot workflow if added. + +macOS and Windows present remote connections as the supported path and explain that the local installer is Linux-only. +They do not show Docker Desktop installation or lifecycle actions. diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index a2d291851..096e62e97 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -6,12 +6,19 @@ import { MakerZIP } from '@electron-forge/maker-zip'; import { VitePlugin } from '@electron-forge/plugin-vite'; import { flipFuses, FuseV1Options, FuseVersion } from '@electron/fuses'; import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const cliAsset = (path: string): string => fileURLToPath(new URL(`../../packages/cli/dist/${path}`, import.meta.url)); const config: ForgeConfig = { packagerConfig: { asar: true, name: 'propr-desktop', executableName: 'propr-desktop', + extraResource: [ + cliAsset('orchestrator'), + cliAsset('assets'), + ], }, rebuildConfig: {}, hooks: { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c82d40083..2be673b58 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -10,11 +10,12 @@ "type": "module", "main": ".vite/build/main.cjs", "scripts": { - "prepare:renderer": "npm run build -w @propr/shared && npm run build -w @propr/client", + "prepare:renderer": "npm run build -w @propr/shared && npm run build -w @propr/client && npm run build -w @propr/local-setup && npm run build -w @propr/cli", "predev": "npm run prepare:renderer", "dev": "electron-forge start", "pretypecheck": "npm run prepare:renderer", "typecheck": "tsc --noEmit", + "pretest": "npm run prepare:renderer", "test": "tsx --test src/**/*.test.ts", "prepackage": "npm run prepare:renderer", "package": "electron-forge package", @@ -26,6 +27,12 @@ "premake:rpm": "npm run prepare:renderer", "make:rpm": "PROPR_DESKTOP_ENABLE_RPM=1 electron-forge make --targets @electron-forge/maker-rpm" }, + "dependencies": { + "@propr/cli": "*", + "@propr/client": "*", + "@propr/local-setup": "*", + "@propr/shared": "*" + }, "devDependencies": { "@electron-forge/cli": "8.0.0-alpha.10", "@electron-forge/maker-deb": "8.0.0-alpha.10", diff --git a/apps/desktop/src/desktop-host.ts b/apps/desktop/src/desktop-host.ts new file mode 100644 index 000000000..80b587015 --- /dev/null +++ b/apps/desktop/src/desktop-host.ts @@ -0,0 +1,107 @@ +import { ConfigManager } from '@propr/cli/dist/config/index.js'; +import { loginWithGithubCli } from '@propr/cli/dist/auth/githubLogin.js'; +import { configureStackTemplatePath } from '@propr/cli/dist/commands/initStack.js'; +import { createDefaultActions } from '@propr/cli/dist/commands/setup/hostActions.js'; +import { configureOrchestratorAssetPath, getHostConfig } from '@propr/cli/dist/orchestrator/index.js'; +import { localhostServiceUrl } from '@propr/cli/dist/utils/dockerPort.js'; +import { dirname, join, resolve } from 'node:path'; +import type { SetupActions } from '@propr/local-setup'; +import type { LocalLifecycleHost } from './lifecycle'; +import { bindRootOperations, RootDirectoryAuthority } from './setup-capabilities'; + +export interface DesktopLocalHost { + actions: SetupActions; + config: ConfigManager; + lifecycle: LocalLifecycleHost; + resolveApiBaseUrl(rootDir: string, signal?: AbortSignal): Promise; +} + +/** Bind the portable setup engine to the same launcher used by the CLI. */ +export async function createDesktopLocalHost(resourcesPath?: string, defaultRootDir?: string, appDataDir = defaultRootDir ? dirname(defaultRootDir) : undefined): Promise { + if (resourcesPath) { + configureOrchestratorAssetPath(join(resourcesPath, 'orchestrator', 'orchestrator.mjs')); + configureStackTemplatePath(join(resourcesPath, 'assets', 'env.example.txt')); + } + const config = new ConfigManager(); + await config.init(); + const defaultActions = createDefaultActions(config); + const actions: SetupActions = { + ...defaultActions, + async loginWithGithub({ onLog, signal } = {}) { + // A packaged GUI has no controlling terminal. Reuse an existing gh + // session, but leave an actionable recovery step instead of launching an + // invisible interactive process when the user is not signed in. + const result = await loginWithGithubCli(config, { interactive: false, onLog, signal }); + if (!result.ok) onLog?.(result.message); + return result.ok; + }, + async startStack(params) { + params.signal?.throwIfAborted(); + params.assertRootAuthority?.(); + const { orch, cfg } = await getHostConfig({ configManager: config, root: params.rootDir, readRoot: params.rootOperationsDir }); + params.assertRootAuthority?.(); + const recovered = await orch.recoverStackAsync(cfg, { + ui: params.ui ?? config.getUiEnabled() ?? true, + docs: params.docs ?? cfg.docsEnabled, + signal: params.signal, + onLog: params.onLog, + assertRootAuthority: params.assertRootAuthority, + }); + params.assertRootAuthority?.(); + if (!recovered.recovered) await defaultActions.startStack(params); + }, + }; + + const root = (): string => { + if (!defaultRootDir) throw new Error('No fixed local ProPR runtime root is configured'); + return resolve(defaultRootDir); + }; + + const withFixedRoot = async (operation: (authority: RootDirectoryAuthority, displayRoot: string) => Promise): Promise => { + const displayRoot = root(); + const authority = RootDirectoryAuthority.open(displayRoot, true, appDataDir); + try { return await operation(authority, displayRoot); } finally { authority.close(); } + }; + + return { + actions, + config, + async resolveApiBaseUrl(rootDir, signal) { + return withFixedRoot(async (authority, displayRoot) => { + if (resolve(rootDir) !== displayRoot) throw new Error('The local profile root is not the fixed desktop runtime root'); + signal?.throwIfAborted(); + authority.validate(); + const { cfg } = await getHostConfig({ configManager: config, root: displayRoot, readRoot: authority.operationPath() }); + authority.validate(); + signal?.throwIfAborted(); + return localhostServiceUrl(cfg.apiPort); + }); + }, + lifecycle: { + async running(signal) { + return withFixedRoot(async (authority, displayRoot) => { + signal?.throwIfAborted(); + authority.validate(); + const { orch, cfg } = await getHostConfig({ configManager: config, root: displayRoot, readRoot: authority.operationPath() }); + authority.validate(); + return orch.isLifecycleStackRunningAsync(cfg, { signal, assertRootAuthority: () => authority.validate() }); + }); + }, + async start(signal) { + await withFixedRoot((authority, displayRoot) => bindRootOperations(actions, displayRoot, authority).startStack({ rootDir: displayRoot, signal })); + }, + async stop(signal) { + await withFixedRoot(async (authority, displayRoot) => { + signal?.throwIfAborted(); + authority.validate(); + const { orch, cfg } = await getHostConfig({ configManager: config, root: displayRoot, readRoot: authority.operationPath() }); + authority.validate(); + const { failed } = await orch.stopLifecycleStackAsync(cfg, { signal, assertRootAuthority: () => authority.validate() }); + authority.validate(); + signal?.throwIfAborted(); + if (failed.length) throw new Error(`Could not stop ${failed.join(', ')}`); + }); + }, + }, + }; +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 93245534b..b20b23856 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -2,8 +2,10 @@ import type { App, IpcMain, IpcMainInvokeEvent, Session } from 'electron'; import { shell } from 'electron'; import { logoutDesktopSession } from './desktop-session'; import type { DesktopLogger } from './logger'; +import type { DesktopOperationCoordinator } from './operation-coordinator'; import type { LocalLifecycleController } from './lifecycle'; import type { ProfileStore } from './profile-store'; +import type { DesktopSetupController } from './setup-controller'; import { isSafeExternalUrl, isTrustedRendererUrl } from './security'; import { IPC_CHANNELS } from './shared/contract'; @@ -12,10 +14,12 @@ interface RegisterIpcOptions { ipcMain: IpcMain; profiles: ProfileStore; lifecycle: LocalLifecycleController; + setup: DesktopSetupController; logger: DesktopLogger; desktopSession: Session; devServerUrl: string | undefined; packagedRendererUrl: string; + coordinator: DesktopOperationCoordinator; } type Handler = (event: IpcMainInvokeEvent, ...args: any[]) => unknown; @@ -35,7 +39,7 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): void => { return await handler(event, ...args); } catch (error) { options.logger.log('error', 'desktop.ipc.failed', { channel, error }); - throw error; + throw new Error('Desktop operation failed. Review the protected desktop log for details.'); } }); }; @@ -57,11 +61,33 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): void => { handle(IPC_CHANNELS.profilesSave, (_event, input) => options.profiles.save(input)); handle(IPC_CHANNELS.profilesRemove, (_event, profileId) => options.profiles.remove(profileId)); handle(IPC_CHANNELS.profilesSetActive, (_event, profileId) => options.profiles.setActive(profileId)); - handle(IPC_CHANNELS.credentialsRead, (_event, profileId) => options.profiles.readCredential(profileId)); - handle(IPC_CHANNELS.credentialsWrite, (_event, profileId, value) => options.profiles.writeCredential(profileId, value)); - handle(IPC_CHANNELS.credentialsRemove, (_event, profileId) => options.profiles.removeCredential(profileId)); - handle(IPC_CHANNELS.lifecycleStatus, () => options.lifecycle.status()); - handle(IPC_CHANNELS.lifecycleStart, () => options.lifecycle.start()); - handle(IPC_CHANNELS.lifecycleStop, () => options.lifecycle.stop()); - handle(IPC_CHANNELS.lifecycleRestart, () => options.lifecycle.restart()); + handle(IPC_CHANNELS.lifecycleStatus, () => options.coordinator.run('status', signal => options.lifecycle.status(signal))); + handle(IPC_CHANNELS.lifecycleStart, () => options.coordinator.run('start', signal => options.lifecycle.start(signal))); + handle(IPC_CHANNELS.lifecycleStop, () => options.coordinator.run('stop', signal => options.lifecycle.stop(signal))); + handle(IPC_CHANNELS.lifecycleRestart, () => options.coordinator.run('restart', signal => options.lifecycle.restart(signal))); + handle(IPC_CHANNELS.discovery, () => []); + handle(IPC_CHANNELS.setupStatus, (_event, ...args) => { + if (args.length) throw new Error('Invalid local setup status request'); + return options.setup.status(); + }); + handle(IPC_CHANNELS.setupStart, (_event, ...args) => { + if (args.length !== 1) throw new Error('Invalid local setup start request'); + return options.coordinator.run('setup', signal => options.setup.start(args[0], signal)); + }); + handle(IPC_CHANNELS.setupRetry, (_event, ...args) => { + if (args.length > 1) throw new Error('Invalid local setup retry request'); + return options.coordinator.run('setup', signal => options.setup.retry(args[0], signal)); + }); + handle(IPC_CHANNELS.setupCancel, (_event, ...args) => { + if (args.length) throw new Error('Invalid local setup cancellation request'); + return options.coordinator.cancel(() => options.setup.cancel()); + }); + handle(IPC_CHANNELS.setupSelectPrivateKey, (_event, ...args) => { + if (args.length) throw new Error('Invalid private-key selection request'); + return options.coordinator.run('setup', signal => options.setup.selectPrivateKey(signal)); + }); + handle(IPC_CHANNELS.setupAcquireWebhookSecret, (_event, ...args) => { + if (args.length) throw new Error('Invalid webhook-secret acquisition request'); + return options.coordinator.run('setup', signal => options.setup.acquireWebhookSecret(signal)); + }); }; diff --git a/apps/desktop/src/lifecycle.test.ts b/apps/desktop/src/lifecycle.test.ts new file mode 100644 index 000000000..4c4f8c279 --- /dev/null +++ b/apps/desktop/src/lifecycle.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { LocalLifecycleController } from './lifecycle'; + +describe('desktop local lifecycle presentation boundary', () => { + it('keeps raw host diagnostics in main and returns only a fixed bounded status', async () => { + const diagnostics: unknown[] = []; + const controller = new LocalLifecycleController({ + async running() { throw new Error('docker /home/alice/stack/.env TOKEN=sentinel'); }, + async start() { throw new Error('HostConfig.Binds=/home/alice/stack'); }, + async stop() {}, + }, (_event, fields) => diagnostics.push(fields)); + const status = await controller.status(); + assert.equal(status.state, 'error'); + assert.ok((status.detail?.length ?? 0) < 160); + assert.doesNotMatch(status.detail ?? '', /alice|HostConfig|TOKEN|sentinel/); + await assert.rejects(controller.start(), error => { + assert.ok(error instanceof Error); + assert.doesNotMatch(error.message, /alice|HostConfig|TOKEN|sentinel/); + return true; + }); + assert.equal(diagnostics.length, 2); + assert.match(((diagnostics[0] as { error: Error }).error).message, /alice/); + assert.match(((diagnostics[1] as { error: Error }).error).message, /HostConfig/); + }); +}); diff --git a/apps/desktop/src/lifecycle.ts b/apps/desktop/src/lifecycle.ts index a302635fc..d4c0fcd28 100644 --- a/apps/desktop/src/lifecycle.ts +++ b/apps/desktop/src/lifecycle.ts @@ -1,26 +1,56 @@ import type { LocalLifecycleOperationResult, LocalLifecycleStatus } from './shared/contract'; -/** - * Stable renderer-facing lifecycle boundary. Runtime installation and process - * control are deliberately absent until the user-approved setup work lands. - */ +export interface LocalLifecycleHost { + running(signal?: AbortSignal): Promise; + start(signal?: AbortSignal): Promise; + stop(signal?: AbortSignal): Promise; +} + +const lifecycleFailure = 'Local runtime operation failed. Review the protected desktop log for details.'; + export class LocalLifecycleController { #status: LocalLifecycleStatus = { state: 'disconnected' }; + readonly #host?: LocalLifecycleHost; + readonly #diagnose?: (event: string, fields: Record) => void; - status(): LocalLifecycleStatus { + constructor(host?: LocalLifecycleHost, diagnose?: (event: string, fields: Record) => void) { + this.#host = host; + this.#diagnose = diagnose; + } + + async status(signal?: AbortSignal): Promise { + if (!this.#host) return { ...this.#status }; + try { + this.#status = { state: await this.#host.running(signal) ? 'connected' : 'disconnected' }; + } catch (error) { + this.#diagnose?.('desktop.lifecycle.status_failed', { error }); + this.#status = { state: 'error', detail: lifecycleFailure }; + } return { ...this.#status }; } - start(): LocalLifecycleOperationResult { - return this.#unsupported(); + async start(signal?: AbortSignal): Promise { + return this.#operate('starting', 'connected', () => this.#host?.start(signal)); } - stop(): LocalLifecycleOperationResult { - return this.#unsupported(); + async stop(signal?: AbortSignal): Promise { + return this.#operate('stopping', 'disconnected', () => this.#host?.stop(signal)); } - restart(): LocalLifecycleOperationResult { - return this.#unsupported(); + async restart(signal?: AbortSignal): Promise { + if (!this.#host) return this.#unsupported(); + this.#status = { state: 'stopping' }; + try { + await this.#host.stop(signal); + this.#status = { state: 'starting' }; + await this.#host.start(signal); + this.#status = { state: 'connected' }; + return { ok: true, status: { ...this.#status } }; + } catch (error) { + this.#diagnose?.('desktop.lifecycle.restart_failed', { error }); + this.#status = { state: 'error', detail: lifecycleFailure }; + throw new Error(lifecycleFailure); + } } async shutdown(): Promise { @@ -37,4 +67,22 @@ export class LocalLifecycleController { }, }; } + + async #operate( + transitional: 'starting' | 'stopping', + completed: 'connected' | 'disconnected', + operation: () => Promise | undefined, + ): Promise { + if (!this.#host) return this.#unsupported(); + this.#status = { state: transitional }; + try { + await operation(); + this.#status = { state: completed }; + return { ok: true, status: { ...this.#status } }; + } catch (error) { + this.#diagnose?.(`desktop.lifecycle.${transitional}_failed`, { error }); + this.#status = { state: 'error', detail: lifecycleFailure }; + throw new Error(lifecycleFailure); + } + } } diff --git a/apps/desktop/src/logger.ts b/apps/desktop/src/logger.ts index a50fd9bbe..97e00a373 100644 --- a/apps/desktop/src/logger.ts +++ b/apps/desktop/src/logger.ts @@ -1,5 +1,6 @@ import { appendFile, mkdir } from 'node:fs/promises'; import { dirname } from 'node:path'; +import { redactDesktopValue } from './secret-redaction'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; @@ -7,10 +8,6 @@ export interface DesktopLogger { log(level: LogLevel, event: string, fields?: Record): void; } -const serializeError = (value: unknown): unknown => value instanceof Error - ? { name: value.name, message: value.message, stack: value.stack } - : value; - export const createDesktopLogger = (logPath: string): DesktopLogger => { let pending = Promise.resolve(); const log = (level: LogLevel, event: string, fields: Record = {}) => { @@ -18,7 +15,7 @@ export const createDesktopLogger = (logPath: string): DesktopLogger => { timestamp: new Date().toISOString(), level, event, - ...Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, serializeError(value)])), + ...redactDesktopValue(fields) as Record, }); const consoleMethod = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; consoleMethod(record); @@ -27,7 +24,7 @@ export const createDesktopLogger = (logPath: string): DesktopLogger => { await mkdir(dirname(logPath), { recursive: true, mode: 0o700 }); await appendFile(logPath, `${record}\n`, { encoding: 'utf8', mode: 0o600 }); }) - .catch(error => console.error(JSON.stringify({ level: 'error', event: 'desktop.log.write_failed', error: serializeError(error) }))); + .catch(error => console.error(JSON.stringify({ level: 'error', event: 'desktop.log.write_failed', error: redactDesktopValue(error) }))); }; return { log }; }; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d121bd8d8..9a5b2c9dc 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,12 +1,17 @@ import { isAbsolute, join, relative, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { app, BrowserWindow, ipcMain, net, protocol, safeStorage, session, shell } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, net, protocol, safeStorage, session, shell } from 'electron'; import { DESKTOP_RENDERER_ORIGIN } from '@propr/shared'; import { DeepLinkDelivery } from './deep-link-delivery'; +import { createDesktopLocalHost } from './desktop-host'; import { registerIpcHandlers } from './ipc'; import { LocalLifecycleController } from './lifecycle'; import { createDesktopLogger, type DesktopLogger } from './logger'; +import { DesktopOperationCoordinator } from './operation-coordinator'; import { ProfileStore, type EncryptionProvider } from './profile-store'; +import { DesktopSetupController } from './setup-controller'; +import { promptForWebhookSecret } from './secure-secret-prompt'; +import { redactDesktopValue } from './secret-redaction'; import { deepLinkFromArguments, isSafeExternalUrl, @@ -34,11 +39,13 @@ const deepLinkDelivery = new DeepLinkDelivery( ); let logger: DesktopLogger | null = null; let shutdownStarted = false; +let setupController: DesktopSetupController | null = null; +const operationCoordinator = new DesktopOperationCoordinator(); const log = (level: 'debug' | 'info' | 'warn' | 'error', event: string, fields?: Record) => logger ? logger.log(level, event, fields) - : console.error(JSON.stringify({ timestamp: new Date().toISOString(), level, event, ...fields })); + : console.error(JSON.stringify(redactDesktopValue({ timestamp: new Date().toISOString(), level, event, ...fields }))); process.on('uncaughtExceptionMonitor', error => { log('error', 'desktop.main_process.uncaught_exception', { error }); @@ -147,7 +154,7 @@ const createMainWindow = async (): Promise => { await readyToShow; const preloadBridgeExposed = await window.webContents.executeJavaScript( - "typeof window.proprDesktop === 'object' && window.proprDesktop !== null", + "typeof window.proprDesktop === 'object' && window.proprDesktop !== null && typeof window.__PROPR_DESKTOP__ === 'object'", ); if (preloadBridgeExposed !== true) { throw new Error('Desktop preload bridge was not exposed to the renderer'); @@ -218,16 +225,61 @@ if (!hasSingleInstanceLock) { decrypt: value => safeStorage.decryptString(value), }; const profiles = new ProfileStore(app.getPath('userData'), encryption); - const lifecycle = new LocalLifecycleController(); + const defaultRootDir = join(app.getPath('userData'), 'desktop', 'local-stack'); + const localHost = await createDesktopLocalHost(app.isPackaged ? process.resourcesPath : undefined, defaultRootDir, app.getPath('userData')); + const lifecycle = new LocalLifecycleController( + process.platform === 'linux' ? localHost.lifecycle : undefined, + (event, fields) => log('error', event, fields), + ); + setupController = new DesktopSetupController({ + actions: localHost.actions, + platform: process.platform, + appDataDir: app.getPath('userData'), + statePath: join(app.getPath('userData'), 'desktop', 'setup-state.json'), + defaultRootDir, + keyStorageDir: join(app.getPath('userData'), 'desktop', 'setup-keys'), + async selectPrivateKey() { + const options = { + title: 'Choose the GitHub App private key', + properties: ['openFile'] as Array<'openFile'>, + filters: [{ name: 'Private keys', extensions: ['pem', 'key'] }], + }; + const selected = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); + return selected.canceled ? null : selected.filePaths[0] ?? null; + }, + promptWebhookSecret: promptForWebhookSecret, + resolveApiBaseUrl: localHost.resolveApiBaseUrl, + async registerProfile({ name, apiBaseUrl }, signal) { + signal?.throwIfAborted(); + const existing = (await profiles.list()).profiles.find(profile => profile.apiBaseUrl === apiBaseUrl); + signal?.throwIfAborted(); + const saved = await profiles.save({ id: existing?.id, label: name, apiBaseUrl }, signal); + signal?.throwIfAborted(); + return { + id: saved.id, + name: saved.label, + baseUrl: saved.apiBaseUrl, + kind: 'local', + lastConnectedAt: saved.updatedAt, + }; + }, + emit(snapshot) { + const target = mainWindow; + if (target && !target.isDestroyed()) target.webContents.send(IPC_CHANNELS.setupProgress, snapshot); + }, + diagnose(event, fields) { log('error', event, fields); }, + }); registerIpcHandlers({ app, ipcMain, profiles, lifecycle, + setup: setupController, logger, desktopSession: session.defaultSession, devServerUrl, packagedRendererUrl, + coordinator: operationCoordinator, }); mainWindow = await createMainWindow(); deepLinkDelivery.setWindow(mainWindow); @@ -245,7 +297,9 @@ if (!hasSingleInstanceLock) { if (shutdownStarted) return; event.preventDefault(); shutdownStarted = true; - void lifecycle.shutdown().finally(() => { + void operationCoordinator.shutdown(async () => { + await Promise.all([lifecycle.shutdown(), setupController?.shutdown()]); + }).finally(() => { log('info', 'desktop.app.shutdown'); app.quit(); }); diff --git a/apps/desktop/src/operation-coordinator.test.ts b/apps/desktop/src/operation-coordinator.test.ts new file mode 100644 index 000000000..0fbcd319e --- /dev/null +++ b/apps/desktop/src/operation-coordinator.test.ts @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { DesktopOperationCoordinator, coordinatorBusyError, coordinatorShutdownError } from './operation-coordinator'; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +}; + +describe('desktop main-process operation coordinator', () => { + it('rejects setup-vs-lifecycle races before the second host action', async () => { + const coordinator = new DesktopOperationCoordinator(); + const release = deferred(); + let lifecycleActions = 0; + const setup = coordinator.run('setup', async () => release.promise); + await assert.rejects(coordinator.run('start', async () => { lifecycleActions += 1; }), new RegExp(coordinatorBusyError)); + assert.equal(lifecycleActions, 0); + release.resolve(); + await setup; + }); + + it('allows cancellation only for setup and awaits its cleanup settlement', async () => { + const coordinator = new DesktopOperationCoordinator(); + const cleaned = deferred(); + let cancelCalled = false; + const setup = coordinator.run('setup', signal => new Promise(resolve => { + const abort = () => { void cleaned.promise.then(resolve); }; + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + })); + const cancellation = coordinator.cancel(async () => { cancelCalled = true; await cleaned.promise; }); + await Promise.resolve(); + assert.equal(cancelCalled, true); + let settled = false; + void cancellation.then(() => { settled = true; }); + await Promise.resolve(); + assert.equal(settled, false); + cleaned.resolve(); + await Promise.all([setup, cancellation]); + }); + + it('coalesces concurrent cancellation requests into one cleanup', async () => { + const coordinator = new DesktopOperationCoordinator(); + const cleaned = deferred(); + const setup = coordinator.run('setup', signal => new Promise(resolve => { + const abort = () => resolve(); + if (signal.aborted) abort(); else signal.addEventListener('abort', abort, { once: true }); + })); + let cleanupCalls = 0; + const cancel = () => { cleanupCalls += 1; return cleaned.promise; }; + const first = coordinator.cancel(cancel); + const second = coordinator.cancel(cancel); + await setup; + assert.equal(cleanupCalls, 1); + cleaned.resolve(); + await Promise.all([first, second]); + }); + + it('makes shutdown idempotent, aborts active work, and rejects late operations', async () => { + const coordinator = new DesktopOperationCoordinator(); + let aborted = false; + const active = coordinator.run('stop', signal => new Promise(resolve => { + const abort = () => { aborted = true; resolve(); }; + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + })); + let cleanup = 0; + const shutdown = coordinator.shutdown(async () => { cleanup += 1; }); + assert.equal(coordinator.shutdown(async () => { cleanup += 10; }), shutdown); + await Promise.all([active, shutdown]); + assert.equal(aborted, true); + assert.equal(cleanup, 1); + await assert.rejects(coordinator.run('start', async () => undefined), new RegExp(coordinatorShutdownError)); + }); + + it('runs shutdown cleanup only after the aborted host operation settles', async () => { + const coordinator = new DesktopOperationCoordinator(); + const release = deferred(); + let cleanupStarted = false; + const active = coordinator.run('start', signal => new Promise(resolve => { + const abort = () => { void release.promise.then(resolve); }; + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + })); + const shutdown = coordinator.shutdown(async () => { cleanupStarted = true; }); + await Promise.resolve(); + assert.equal(cleanupStarted, false); + release.resolve(); + await Promise.all([active, shutdown]); + assert.equal(cleanupStarted, true); + }); + + it('awaits in-flight cancellation cleanup before shutdown cleanup', async () => { + const coordinator = new DesktopOperationCoordinator(); + const cancelled = deferred(); + const setup = coordinator.run('setup', signal => new Promise(resolve => { + const abort = () => resolve(); + if (signal.aborted) abort(); else signal.addEventListener('abort', abort, { once: true }); + })); + const cancel = coordinator.cancel(() => cancelled.promise); + let shutdownCleanup = false; + const shutdown = coordinator.shutdown(async () => { shutdownCleanup = true; }); + await setup; + await Promise.resolve(); + assert.equal(shutdownCleanup, false); + cancelled.resolve(); + await Promise.all([cancel, shutdown]); + assert.equal(shutdownCleanup, true); + }); + + it('settles cancel-vs-shutdown races only after shared setup cleanup', async () => { + const coordinator = new DesktopOperationCoordinator(); + const cleanup = deferred(); + const setup = coordinator.run('setup', signal => new Promise(resolve => { + const abort = () => { void cleanup.promise.then(resolve); }; + if (signal.aborted) abort(); else signal.addEventListener('abort', abort, { once: true }); + })); + const cancel = coordinator.cancel(() => cleanup.promise); + const shutdown = coordinator.shutdown(() => cleanup.promise); + let settled = false; + void Promise.all([cancel, shutdown]).then(() => { settled = true; }); + await Promise.resolve(); + assert.equal(settled, false); + cleanup.resolve(); + await Promise.all([setup, cancel, shutdown]); + }); +}); diff --git a/apps/desktop/src/operation-coordinator.ts b/apps/desktop/src/operation-coordinator.ts new file mode 100644 index 000000000..5c7a11656 --- /dev/null +++ b/apps/desktop/src/operation-coordinator.ts @@ -0,0 +1,65 @@ +export type DesktopHostOperation = 'setup' | 'start' | 'stop' | 'restart' | 'status' | 'cancel'; + +export const coordinatorBusyError = 'Another local runtime operation is already in progress.'; +export const coordinatorShutdownError = 'ProPR Desktop is shutting down.'; + +interface ActiveOperation { + kind: DesktopHostOperation; + controller: AbortController; + promise: Promise; +} + +/** Single main-process gate for every local setup/lifecycle host action. */ +export class DesktopOperationCoordinator { + #active: ActiveOperation | null = null; + #cancellation: Promise | null = null; + #shutdown: Promise | null = null; + + run(kind: DesktopHostOperation, operation: (signal: AbortSignal) => Promise): Promise { + if (this.#shutdown) return Promise.reject(new Error(coordinatorShutdownError)); + if (this.#active) return Promise.reject(new Error(coordinatorBusyError)); + const controller = new AbortController(); + const active = { kind, controller, promise: Promise.resolve() } as ActiveOperation; + const promise = Promise.resolve().then(() => operation(controller.signal)).finally(() => { + if (this.#active === active) this.#active = null; + }); + active.promise = promise; + this.#active = active; + return promise; + } + + async cancel(cancelSetup: () => Promise): Promise { + if (this.#shutdown) throw new Error(coordinatorShutdownError); + if (this.#cancellation) return this.#cancellation; + const cancellation = (async () => { + const active = this.#active; + if (!active) return this.run('cancel', async () => cancelSetup()); + if (active.kind !== 'setup') throw new Error(coordinatorBusyError); + active.controller.abort(); + const cleanup = cancelSetup(); + await Promise.allSettled([active.promise, cleanup]); + return cleanup; + })(); + this.#cancellation = cancellation; + try { + return await cancellation; + } finally { + if (this.#cancellation === cancellation) this.#cancellation = null; + } + } + + shutdown(cleanup: () => Promise): Promise { + if (this.#shutdown) return this.#shutdown; + const active = this.#active; + const cancellation = this.#cancellation; + active?.controller.abort(); + this.#shutdown = (async () => { + await Promise.allSettled([ + ...(active ? [active.promise] : []), + ...(cancellation ? [cancellation] : []), + ]); + await cleanup(); + })(); + return this.#shutdown; + } +} diff --git a/apps/desktop/src/preload-bridge.test.ts b/apps/desktop/src/preload-bridge.test.ts index 81db36bef..8a2659e80 100644 --- a/apps/desktop/src/preload-bridge.test.ts +++ b/apps/desktop/src/preload-bridge.test.ts @@ -1,22 +1,23 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { createDesktopBridge, type PreloadIpc } from './preload-bridge'; +import { createDesktopBridge, createDesktopRendererBridge, probeLocalDesktopProfile, type PreloadIpc } from './preload-bridge'; import { IPC_CHANNELS } from './shared/contract'; +import { PROPR_API_COMPATIBILITY } from '@propr/shared'; class FakeIpc implements PreloadIpc { readonly invocations: Array<{ channel: string; args: unknown[] }> = []; - readonly listeners = new Map void>(); + readonly listeners = new Map void>(); async invoke(channel: string, ...args: unknown[]): Promise { this.invocations.push({ channel, args }); return undefined; } - on(channel: string, listener: (event: unknown, value: string) => void): void { + on(channel: string, listener: (event: unknown, value: any) => void): void { this.listeners.set(channel, listener); } - removeListener(channel: string, listener: (event: unknown, value: string) => void): void { + removeListener(channel: string, listener: (event: unknown, value: any) => void): void { if (this.listeners.get(channel) === listener) this.listeners.delete(channel); } } @@ -24,19 +25,18 @@ class FakeIpc implements PreloadIpc { describe('desktop preload bridge', () => { it('exposes only the narrow frozen namespaces', () => { const bridge = createDesktopBridge(new FakeIpc()); - assert.deepEqual(Object.keys(bridge).sort(), ['app', 'auth', 'credentials', 'external', 'lifecycle', 'profiles', 'storage']); + assert.deepEqual(Object.keys(bridge).sort(), ['app', 'auth', 'external', 'lifecycle', 'profiles', 'storage']); assert.equal(Object.isFrozen(bridge), true); assert.equal(Object.values(bridge).every(Object.isFrozen), true); assert.equal('fs' in bridge, false); assert.equal('exec' in bridge, false); }); - it('maps profile and credential operations to fixed channels', async () => { + it('maps profile operations to fixed channels without a credential namespace', async () => { const ipc = new FakeIpc(); const bridge = createDesktopBridge(ipc); await bridge.auth.logout('http://localhost:4000'); await bridge.profiles.save({ label: 'Local', apiBaseUrl: 'http://localhost:4000' }); - await bridge.credentials.write('profile-1', 'secret'); await bridge.lifecycle.start(); assert.deepEqual(ipc.invocations, [ { channel: IPC_CHANNELS.authLogout, args: ['http://localhost:4000'] }, @@ -44,11 +44,30 @@ describe('desktop preload bridge', () => { channel: IPC_CHANNELS.profilesSave, args: [{ label: 'Local', apiBaseUrl: 'http://localhost:4000' }], }, - { channel: IPC_CHANNELS.credentialsWrite, args: ['profile-1', 'secret'] }, { channel: IPC_CHANNELS.lifecycleStart, args: [] }, ]); }); + it('exposes setup through fixed invocations and strips Electron events from progress', async () => { + const ipc = new FakeIpc(); + const bridge = createDesktopRendererBridge(ipc, 'linux'); + const received: unknown[] = []; + bridge.localSetup.onProgress(snapshot => received.push(snapshot)); + const request = { + sessionId: '00000000-0000-4000-8000-000000000000', root: { mode: 'default' as const }, reinitialize: false, agents: [], + github: { mode: 'demo' as const }, intake: { mode: 'keep' as const }, whitelist: null, repository: null, + }; + await bridge.localSetup.start(request); + ipc.listeners.get(IPC_CHANNELS.setupProgress)?.( + { sender: 'must-not-leak' }, + { phase: 'running', capability: { supported: true, kind: 'local', platform: 'linux' }, sessionId: request.sessionId, logs: [] }, + ); + + assert.deepEqual(ipc.invocations, [{ channel: IPC_CHANNELS.setupStart, args: [request] }]); + assert.deepEqual(received, [{ phase: 'running', capability: { supported: true, kind: 'local', platform: 'linux' }, sessionId: request.sessionId, logs: [] }]); + assert.equal('invoke' in bridge, false); + }); + it('does not expose Electron event objects to deep-link listeners', () => { const ipc = new FakeIpc(); const bridge = createDesktopBridge(ipc); @@ -60,6 +79,37 @@ describe('desktop preload bridge', () => { assert.equal(ipc.listeners.has(IPC_CHANNELS.deepLink), true); }); + it('probes completed local profiles through the injectable connection boundary', async () => { + const profile = { id: 'local', name: 'This computer', baseUrl: 'http://127.0.0.1:4000', kind: 'local' as const }; + const requests: string[] = []; + const result = await probeLocalDesktopProfile(profile, async input => { + requests.push(input.toString()); + return new Response(JSON.stringify({ apiCompatibility: PROPR_API_COMPATIBILITY, version: '0.8.15' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + assert.deepEqual(requests, ['http://127.0.0.1:4000/api/compatibility']); + assert.equal(result.status, 'ready'); + + const injected = async () => ({ status: 'ready' as const, version: 'injected' }); + const bridge = createDesktopRendererBridge(new FakeIpc(), 'linux', injected); + assert.deepEqual(await bridge.connection.probe(profile), { status: 'ready', version: 'injected' }); + }); + + it('keeps remote probing out of the local setup lane and bounds local failures', async () => { + const remote = await probeLocalDesktopProfile({ id: 'remote', name: 'Remote', baseUrl: 'https://example.com', kind: 'remote' }, async () => { + throw new Error('must not fetch'); + }); + assert.deepEqual(remote, { status: 'offline', message: 'Remote connections are not included in local setup.' }); + const local = await probeLocalDesktopProfile({ id: 'local', name: 'Local', baseUrl: 'http://localhost:4000', kind: 'local' }, async () => { + throw new Error(`/home/alice/secret ${'x'.repeat(10_000)}`); + }); + assert.equal(local.status, 'offline'); + assert.ok((local.message?.length ?? 0) < 200); + assert.doesNotMatch(local.message ?? '', /alice|secret|home/); + }); + it('buffers startup and second-instance deep links until the renderer subscribes', () => { const ipc = new FakeIpc(); const bridge = createDesktopBridge(ipc); diff --git a/apps/desktop/src/preload-bridge.ts b/apps/desktop/src/preload-bridge.ts index 3bba8300e..2e5af05c3 100644 --- a/apps/desktop/src/preload-bridge.ts +++ b/apps/desktop/src/preload-bridge.ts @@ -1,10 +1,19 @@ -import type { DesktopBridge } from './shared/contract'; +import type { + DesktopConnectionResult, + DesktopBridge, + DesktopPlatformView, + DesktopProfile, + DesktopProfileView, + DesktopRendererBridge, + DesktopSetupSnapshot, +} from './shared/contract'; import { IPC_CHANNELS } from './shared/contract'; +import { evaluateProprApiCompatibility } from '@propr/shared'; export interface PreloadIpc { invoke(channel: string, ...args: unknown[]): Promise; - on(channel: string, listener: (event: unknown, value: string) => void): void; - removeListener(channel: string, listener: (event: unknown, value: string) => void): void; + on(channel: string, listener: (event: unknown, value: any) => void): void; + removeListener(channel: string, listener: (event: unknown, value: any) => void): void; } const invoke = (ipc: PreloadIpc, channel: string, ...args: unknown[]): Promise => @@ -45,11 +54,6 @@ export const createDesktopBridge = (ipc: PreloadIpc): DesktopBridge => { remove: (profileId) => invoke(ipc, IPC_CHANNELS.profilesRemove, profileId), setActive: (profileId) => invoke(ipc, IPC_CHANNELS.profilesSetActive, profileId), }, - credentials: { - read: (profileId) => invoke(ipc, IPC_CHANNELS.credentialsRead, profileId), - write: (profileId, value) => invoke(ipc, IPC_CHANNELS.credentialsWrite, profileId, value), - remove: (profileId) => invoke(ipc, IPC_CHANNELS.credentialsRemove, profileId), - }, lifecycle: { status: () => invoke(ipc, IPC_CHANNELS.lifecycleStatus), start: () => invoke(ipc, IPC_CHANNELS.lifecycleStart), @@ -61,3 +65,105 @@ export const createDesktopBridge = (ipc: PreloadIpc): DesktopBridge => { Object.values(bridge).forEach(Object.freeze); return Object.freeze(bridge); }; + +const platformView = (platform: NodeJS.Platform): DesktopPlatformView => + platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux'; + +const isLoopback = (baseUrl: string): boolean => { + try { + const hostname = new URL(baseUrl).hostname.toLowerCase(); + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'; + } catch { + return false; + } +}; + +const profileView = (profile: DesktopProfile): DesktopProfileView => ({ + id: profile.id, + name: profile.label, + baseUrl: profile.apiBaseUrl, + kind: isLoopback(profile.apiBaseUrl) ? 'local' : 'remote', + lastConnectedAt: profile.updatedAt, +}); + +const bounded = (value: string, maximum = 512): string => value.slice(0, maximum); + +/** Local-only probe seam; PR #1977 owns remote authentication and transport. */ +export const probeLocalDesktopProfile = async ( + profile: DesktopProfileView, + fetchImpl: typeof fetch = globalThis.fetch, +): Promise => { + if (profile.kind !== 'local' || !isLoopback(profile.baseUrl)) { + return { status: 'offline', message: 'Remote connections are not included in local setup.' }; + } + try { + const response = await fetchImpl(`${profile.baseUrl}/api/compatibility`, { + credentials: 'include', + cache: 'no-store', + signal: AbortSignal.timeout(8_000), + }); + if (response.status === 401 || response.status === 403) { + return { status: 'authentication-required', message: 'Sign in to continue to this local instance.' }; + } + if (response.status === 404) return { status: 'ready' }; + if (!response.ok) return { status: 'offline', message: `The local instance returned HTTP ${response.status}.` }; + const metadata = await response.json() as { apiCompatibility?: string; version?: string }; + const compatibility = evaluateProprApiCompatibility(metadata); + const version = compatibility.apiVersion ? bounded(compatibility.apiVersion, 64) : undefined; + if (compatibility.compatible || compatibility.reason === 'missing') return { status: 'ready', version }; + return { status: 'incompatible', message: bounded(compatibility.message), version }; + } catch { + return { status: 'offline', message: 'ProPR Desktop could not reach this local instance. Check that it is running and try again.' }; + } +}; + +/** Build the shared renderer adapter without exposing raw IPC or credentials. */ +export const createDesktopRendererBridge = ( + ipc: PreloadIpc, + platform: NodeJS.Platform = process.platform, + connectionProbe: (profile: DesktopProfileView) => Promise = probeLocalDesktopProfile, +): DesktopRendererBridge => { + const progressListeners = new Set<(snapshot: DesktopSetupSnapshot) => void>(); + ipc.on(IPC_CHANNELS.setupProgress, (_event, snapshot: DesktopSetupSnapshot) => { + progressListeners.forEach(listener => listener(snapshot)); + }); + + const bridge: DesktopRendererBridge = { + isDesktop: true, + platform: platformView(platform), + profiles: { + list: async () => { + const result = await invoke<{ profiles: DesktopProfile[] }>(ipc, IPC_CHANNELS.profilesList); + return result.profiles.map(profileView); + }, + save: async (profile) => { + await invoke(ipc, IPC_CHANNELS.profilesSave, { + id: profile.id, + label: profile.name, + apiBaseUrl: profile.baseUrl, + }); + }, + remove: (profileId) => invoke(ipc, IPC_CHANNELS.profilesRemove, profileId), + getActiveId: async () => (await invoke<{ activeProfileId: string | null }>(ipc, IPC_CHANNELS.profilesList)).activeProfileId, + setActiveId: (profileId) => invoke(ipc, IPC_CHANNELS.profilesSetActive, profileId), + }, + discovery: { discover: () => invoke(ipc, IPC_CHANNELS.discovery) }, + authentication: { authenticate: async () => { throw new Error('Remote pairing is not included in local setup.'); } }, + externalBrowser: { open: (url) => invoke(ipc, IPC_CHANNELS.openExternal, url) }, + localSetup: { + status: () => invoke(ipc, IPC_CHANNELS.setupStatus), + start: (request) => invoke(ipc, IPC_CHANNELS.setupStart, request), + retry: (request) => invoke(ipc, IPC_CHANNELS.setupRetry, request), + cancel: () => invoke(ipc, IPC_CHANNELS.setupCancel), + selectPrivateKey: () => invoke(ipc, IPC_CHANNELS.setupSelectPrivateKey), + acquireWebhookSecret: () => invoke(ipc, IPC_CHANNELS.setupAcquireWebhookSecret), + onProgress: (listener) => { + progressListeners.add(listener); + return () => progressListeners.delete(listener); + }, + }, + connection: { probe: connectionProbe }, + }; + Object.values(bridge).filter(value => typeof value === 'object').forEach(Object.freeze); + return Object.freeze(bridge); +}; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index ba4f4d45b..b535ac3ad 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron'; -import { createDesktopBridge } from './preload-bridge'; +import { createDesktopBridge, createDesktopRendererBridge } from './preload-bridge'; contextBridge.exposeInMainWorld('proprDesktop', createDesktopBridge(ipcRenderer)); +contextBridge.exposeInMainWorld('__PROPR_DESKTOP__', createDesktopRendererBridge(ipcRenderer)); diff --git a/apps/desktop/src/profile-store.ts b/apps/desktop/src/profile-store.ts index 4115c1f92..c80bbbbdf 100644 --- a/apps/desktop/src/profile-store.ts +++ b/apps/desktop/src/profile-store.ts @@ -2,8 +2,6 @@ import { randomUUID } from 'node:crypto'; import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { - CredentialReadResult, - CredentialWriteResult, DesktopProfile, DesktopProfileInput, DesktopProfileList, @@ -14,6 +12,9 @@ import { normalizeApiBaseUrl } from './security'; const PROFILE_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/; const MAX_CREDENTIAL_LENGTH = 65_536; +type CredentialReadResult = { available: false; value: null } | { available: true; value: string | null }; +type CredentialWriteResult = { stored: true } | { stored: false; reason: 'encryption-unavailable' }; + interface PersistedState { version: 1; activeProfileId: string | null; @@ -121,10 +122,12 @@ export class ProfileStore { }; } - save(input: DesktopProfileInput): Promise { + save(input: DesktopProfileInput, signal?: AbortSignal): Promise { return this.#mutate(async () => { + signal?.throwIfAborted(); const normalized = normalizedProfileInput(input); const state = await this.#readState(); + signal?.throwIfAborted(); const existing = state.profiles.find(profile => profile.id === normalized.id); const now = new Date().toISOString(); const profile: DesktopProfile = { @@ -133,7 +136,7 @@ export class ProfileStore { updatedAt: now, }; state.profiles = [...state.profiles.filter(item => item.id !== profile.id), profile]; - await this.#writeState(state); + await this.#writeState(state, signal); return { ...profile }; }); } @@ -213,11 +216,19 @@ export class ProfileStore { } } - async #writeState(state: PersistedState): Promise { + async #writeState(state: PersistedState, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); await this.#ensureDirectories(); + signal?.throwIfAborted(); const temporary = `${this.#statePath}.${process.pid}.tmp`; - await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); - await rename(temporary, this.#statePath); + try { + await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + signal?.throwIfAborted(); + await rename(temporary, this.#statePath); + } catch (error) { + await unlink(temporary).catch(() => undefined); + throw error; + } await chmod(this.#statePath, 0o600).catch(() => undefined); } diff --git a/apps/desktop/src/secret-redaction.test.ts b/apps/desktop/src/secret-redaction.test.ts new file mode 100644 index 000000000..805512e97 --- /dev/null +++ b/apps/desktop/src/secret-redaction.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { redactDesktopValue } from './secret-redaction'; + +describe('desktop secret boundary redaction', () => { + it('redacts credentials, key material and paths, authorization, and environment assignments recursively', () => { + const value = redactDesktopValue({ + tokenLine: 'token=ghp_1234567890abcdef', + authorizationLine: 'Authorization: Bearer relay-credential-value', + environment: 'GH_WEBHOOK_SECRET=webhook-value HOST_GH_PRIVATE_KEY=/home/me/github-app.pem', + docker: 'HostConfig.Binds=["/mnt/runtime/propr-data:/var/lib/propr"] SAFE_MODE=development', + key: '-----BEGIN PRIVATE KEY-----\nprivate-key-content\n-----END PRIVATE KEY-----', + nested: new Error('failed at /home/me/keys/github-app.pem'), + }); + const serialized = JSON.stringify(value); + for (const secret of ['ghp_1234567890abcdef', 'relay-credential-value', 'webhook-value', '/home/me/github-app.pem', '/mnt/runtime/propr-data', 'development', 'private-key-content']) { + assert.doesNotMatch(serialized, new RegExp(secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } + assert.match(serialized, /REDACTED/); + }); + + it('supports exact contextual redaction for unstructured webhook secrets and private-key paths', () => { + const secret = 'totally-arbitrary-webhook-value'; + const path = '/secure/custom-name.bin'; + const serialized = JSON.stringify(redactDesktopValue(new Error(`${secret} ${path}`), 0, [secret, path])); + assert.doesNotMatch(serialized, /totally-arbitrary|custom-name/); + }); +}); diff --git a/apps/desktop/src/secret-redaction.ts b/apps/desktop/src/secret-redaction.ts new file mode 100644 index 000000000..ce1ddb842 --- /dev/null +++ b/apps/desktop/src/secret-redaction.ts @@ -0,0 +1,43 @@ +const REDACTED = '[REDACTED]'; +const REDACTED_PATH = '[REDACTED_PATH]'; + +const redactString = (value: string): string => value + .replace(/-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*?-----END [^-\r\n]*PRIVATE KEY-----/gi, REDACTED) + .replace(/\bBearer\s+[^\s,;"']+/gi, `Bearer ${REDACTED}`) + .replace(/\bgh[pousr]_[A-Za-z0-9_]{8,}\b/g, REDACTED) + .replace(/\b((?:authorization|token|secret|password|private[_-]?key|webhook[_-]?secret)\s*[=:]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, `$1${REDACTED}`) + .replace(/\b((?:GH|GITHUB|PROPR|HOST)_[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PRIVATE_KEY)[A-Z0-9_]*\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s]+)/g, `$1${REDACTED}`) + .replace(/\b([A-Z][A-Z0-9_]{1,63}\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/g, `$1${REDACTED}`) + .replace(/(?:\/[A-Za-z0-9._~ -]+)+\/(?:[^\s"']*?(?:private[-_]?key|github[-_]?app)[^\s"']*|[^\s"']+\.(?:pem|key))\b/gi, REDACTED) + .replace(/(^|[\s"'(=:[,{])\/(?!\/)[^\s"'(),;\]}]+/g, `$1${REDACTED_PATH}`) + .replace(/(^|[\s"'(=])[A-Za-z]:\\(?:[^\s"')]+\\)*[^\s"')]+/g, `$1${REDACTED_PATH}`); + +export const redactDesktopText = (value: string, secrets: readonly string[] = []): string => { + let redacted = value; + for (const secret of secrets) { + if (secret.length >= 3) redacted = redacted.split(secret).join(REDACTED); + } + return redactString(redacted).slice(0, 8_192); +}; + +export const redactDesktopValue = (value: unknown, depth = 0, secrets: readonly string[] = []): unknown => { + if (depth > 12) return '[TRUNCATED]'; + if (typeof value === 'string') return redactDesktopText(value, secrets); + if (value instanceof Error) { + return { + name: redactDesktopText(value.name, secrets), + message: redactDesktopText(value.message, secrets), + stack: value.stack ? redactDesktopText(value.stack, secrets) : undefined, + }; + } + if (Array.isArray(value)) return value.slice(0, 500).map(item => redactDesktopValue(item, depth + 1, secrets)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value as Record).slice(0, 500).map(([key, item]) => [ + key, + /(?:authorization|token|secret|password|private.?key)/i.test(key) ? REDACTED : redactDesktopValue(item, depth + 1, secrets), + ])); + } + return value; +}; + +export const safeRendererError = 'Local setup failed unexpectedly. Review the protected desktop log for details.'; diff --git a/apps/desktop/src/secure-secret-prompt.ts b/apps/desktop/src/secure-secret-prompt.ts new file mode 100644 index 000000000..5d2e173a4 --- /dev/null +++ b/apps/desktop/src/secure-secret-prompt.ts @@ -0,0 +1,50 @@ +import { spawn } from 'node:child_process'; + +interface PromptCommand { + command: string; + args: string[]; +} + +const commands: PromptCommand[] = [ + { command: 'zenity', args: ['--password', '--title=ProPR Desktop', '--text=Enter the GitHub webhook signing secret'] }, + { command: 'kdialog', args: ['--password', 'Enter the GitHub webhook signing secret', '--title', 'ProPR Desktop'] }, +]; + +const runPrompt = ({ command, args }: PromptCommand, signal?: AbortSignal): Promise<{ unavailable: boolean; value: string | null }> => + new Promise((resolve, reject) => { + signal?.throwIfAborted(); + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); + let output = Buffer.alloc(0); + const abort = () => { + child.kill('SIGKILL'); + reject(signal?.reason instanceof Error ? signal.reason : Object.assign(new Error('The native secret prompt was cancelled.'), { name: 'AbortError' })); + }; + signal?.addEventListener('abort', abort, { once: true }); + child.once('close', () => signal?.removeEventListener('abort', abort)); + child.stdout.on('data', (chunk: Buffer) => { + output = Buffer.concat([output, chunk]); + if (output.length > 2048) child.kill('SIGKILL'); + }); + child.once('error', error => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') resolve({ unavailable: true, value: null }); + else reject(new Error('The native secret prompt failed.')); + }); + child.once('close', code => { + if (code === 1) return resolve({ unavailable: false, value: null }); + if (code !== 0 || output.length > 2048) return reject(new Error('The native secret prompt failed.')); + const value = output.toString('utf8').replace(/[\r\n]+$/, ''); + if (!value || value.length > 512 || /[\0\r\n]/.test(value)) return reject(new Error('The native secret prompt returned an invalid value.')); + resolve({ unavailable: false, value }); + }); + }); + +/** Acquire a one-shot secret in Electron main without sending its bytes through renderer IPC. */ +export async function promptForWebhookSecret(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + for (const command of commands) { + const result = await runPrompt(command, signal); + signal?.throwIfAborted(); + if (!result.unavailable) return result.value; + } + throw new Error('No supported native secret prompt is installed. Install zenity or kdialog and try again.'); +} diff --git a/apps/desktop/src/setup-capabilities.ts b/apps/desktop/src/setup-capabilities.ts new file mode 100644 index 000000000..e49a0273d --- /dev/null +++ b/apps/desktop/src/setup-capabilities.ts @@ -0,0 +1,384 @@ +import { randomBytes } from 'node:crypto'; +import { + closeSync, + constants, + fchmodSync, + fstatSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + unlinkSync, + type BigIntStats, +} from 'node:fs'; +import { lstat, realpath, stat } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { + ensurePrivateDirectory, + writePrivateFileAtomic, +} from '@propr/local-setup'; +import type { DesktopFilesystemSelection, DesktopSecretSelection } from './shared/contract'; +import type { SetupActions } from '@propr/local-setup'; + +type SelectionKind = 'private-key'; + +interface SelectionRecord { + kind: SelectionKind; + sessionId: string; + originalPath: string; + canonicalPath: string; + device: bigint; + inode: bigint; + expiresAt: number; +} + +interface SecretRecord { + sessionId: string; + value: string; + expiresAt: number; +} + +const MAX_KEY_BYTES = 1024 * 1024; +const TTL_MS = 5 * 60_000; +const O_CLOEXEC = (constants as unknown as Record).O_CLOEXEC ?? (process.platform === 'linux' ? 0o2000000 : 0); + +export class SetupCapabilityError extends Error { + constructor(message = 'The selected file, directory, or secret is no longer approved. Select it again.') { + super(message); + this.name = 'SetupCapabilityError'; + } +} + +const safePath = (value: string): string => { + if (!isAbsolute(value) || value.includes('\0')) throw new SetupCapabilityError(); + return resolve(value); +}; + +const assertOwner = (uid: bigint): void => { + if (typeof process.getuid === 'function' && uid !== BigInt(process.getuid())) throw new SetupCapabilityError('The selection must be owned by the current user.'); +}; + +export class RootDirectoryAuthority { + readonly path: string; + readonly #privateBoundary: string; + readonly #descriptor: number; + readonly #device: bigint; + readonly #inode: bigint; + readonly #operationPath: string; + #closed = false; + + private constructor(path: string, privateBoundary: string, descriptor: number, device: bigint, inode: bigint) { + this.path = path; + this.#privateBoundary = privateBoundary; + this.#descriptor = descriptor; + this.#device = device; + this.#inode = inode; + this.#operationPath = `/proc/${process.pid}/fd/${descriptor}`; + } + + static open(path: string, create = false, privateBoundary = dirname(path)): RootDirectoryAuthority { + const canonical = safePath(path); + const boundary = safePath(privateBoundary); + ensurePrivateAncestry(boundary, canonical, create); + const descriptor = openSync(canonical, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | O_CLOEXEC); + try { + const info = fstatSync(descriptor, { bigint: true }); + if (!info.isDirectory()) throw new SetupCapabilityError('The approved setup root is not a directory.'); + assertOwner(info.uid); + return new RootDirectoryAuthority(canonical, boundary, descriptor, info.dev, info.ino); + } catch (error) { + closeSync(descriptor); + throw error; + } + } + + validate(): void { + if (this.#closed) throw new SetupCapabilityError('The setup directory authority expired. Select it again.'); + ensurePrivateAncestry(this.#privateBoundary, this.path, false); + const anchored = fstatSync(this.#descriptor, { bigint: true }); + let current; + try { current = lstatSync(this.path, { bigint: true }); } catch { + throw new SetupCapabilityError('The selected setup directory changed. Select it again.'); + } + if (!anchored.isDirectory() || !current.isDirectory() || current.isSymbolicLink() + || anchored.dev !== this.#device || anchored.ino !== this.#inode + || current.dev !== this.#device || current.ino !== this.#inode + || realpathSync(this.path) !== this.path) { + throw new SetupCapabilityError('The selected setup directory changed. Select it again.'); + } + assertOwner(current.uid); + for (const name of ['.env', 'data', 'logs', 'repos']) { + const child = join(this.#operationPath, name); + let info; + try { info = lstatSync(child, { bigint: true }); } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + if (info.isSymbolicLink()) throw new SetupCapabilityError('The setup directory contains an unsafe managed path.'); + assertOwner(info.uid); + if (name === '.env') { + if (!info.isFile() || info.nlink !== 1n) throw new SetupCapabilityError('The setup environment must be a non-linked regular file.'); + enforceModeNoFollow(child, info, 0o600, false); + } else { + const anchoredRoot = realpathSync(this.#operationPath); + const childRelative = relative(anchoredRoot, realpathSync(child)); + if (!info.isDirectory() || childRelative.startsWith('..') || isAbsolute(childRelative)) { + throw new SetupCapabilityError('The setup directory contains an unsafe managed path.'); + } + enforceModeNoFollow(child, info, 0o700, true); + } + } + } + + /** Stable main-process-only path for descriptor-relative managed operations. */ + operationPath(): string { + this.validate(); + return this.#operationPath; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + closeSync(this.#descriptor); + } +} + +/** + * Establish and revalidate the fixed runtime root beneath Electron's app-data + * boundary. Every app-owned component is an owner-only real directory; links + * and path replacement are rejected before a Docker lifecycle handoff. + */ +function ensurePrivateAncestry(boundaryPath: string, rootPath: string, create: boolean): void { + const boundary = resolve(boundaryPath); + const root = resolve(rootPath); + const suffix = relative(boundary, root); + if (!suffix || suffix.startsWith('..') || isAbsolute(suffix)) throw new SetupCapabilityError('The fixed setup root is outside the app-data boundary.'); + const components = suffix ? suffix.split(sep).filter(Boolean) : []; + let cursor = boundary; + const paths = [boundary, ...components.map(component => (cursor = join(cursor, component)))]; + for (let index = 0; index < paths.length; index += 1) { + const current = paths[index]; + let info; + try { + info = lstatSync(current, { bigint: true }); + } catch (error) { + if (!create || (error as NodeJS.ErrnoException).code !== 'ENOENT' || index === 0) throw error; + mkdirSync(current, { mode: 0o700 }); + info = lstatSync(current, { bigint: true }); + } + if (!info.isDirectory() || info.isSymbolicLink() || realpathSync(current) !== current) { + throw new SetupCapabilityError('The fixed setup root ancestry must contain only real directories.'); + } + assertOwner(info.uid); + enforceModeNoFollow(current, info, 0o700, true); + } +} + +function enforceModeNoFollow( + path: string, + expected: BigIntStats, + mode: number, + directory: boolean, +): void { + const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | O_CLOEXEC | (directory ? constants.O_DIRECTORY : 0)); + try { + const opened = fstatSync(descriptor, { bigint: true }); + if (opened.dev !== expected.dev || opened.ino !== expected.ino + || (directory ? !opened.isDirectory() : !opened.isFile())) { + throw new SetupCapabilityError('The fixed setup root identity changed during validation.'); + } + assertOwner(opened.uid); + if ((opened.mode & 0o777n) !== BigInt(mode)) fchmodSync(descriptor, mode); + } finally { + closeSync(descriptor); + } +} + +/** + * Bind setup host actions to the held Linux directory descriptor. Only display + * paths cross the setup engine; host I/O receives the descriptor-rooted path, + * and Docker gets a fresh authority assertion at each container handoff. + */ +export function bindRootOperations( + actions: SetupActions, + displayRoot: string, + authority: RootDirectoryAuthority, +): SetupActions { + const guard = () => authority.validate(); + const operationRoot = authority.operationPath(); + const mapPath = (value: string, from: string, to: string): string => value === from || value.startsWith(`${from}${sep}`) + ? `${to}${value.slice(from.length)}` + : value; + const transform = (value: unknown, from: string, to: string): unknown => { + if (typeof value === 'string') return mapPath(value, from, to); + if (typeof value === 'function') { + return (...args: unknown[]) => Reflect.apply(value, undefined, args.map(argument => transform(argument, to, from))); + } + if (Array.isArray(value)) return value.map(item => transform(item, from, to)); + if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) { + return Object.fromEntries(Object.entries(value as Record).map(([key, item]) => [key, transform(item, from, to)])); + } + return value; + }; + const descriptorActions = new Set([ + 'runChecks', + 'inspectStackInit', + 'inspectDatastoreAdministrators', + 'scaffoldStack', + 'readEnvVars', + 'applyEnvSelection', + 'clearEnvKeys', + 'detectGithubAuthMode', + 'prepareAgentCredentialDir', + ]); + const rootedObjectActions = new Set(['pullImages', 'checkBackendHealth']); + const rootedTrailingOptionIndex = new Map([ + ['isStackRunning', 2], + ['addRepository', 3], + ['resolveUiUrl', 2], + ['saveWhitelistSetting', 3], + ['listAgents', 2], + ['addAgent', 3], + ['loginAgent', 3], + ['validateAgents', 3], + ]); + const toOperation = (value: unknown) => transform(value, displayRoot, operationRoot); + const toDisplay = (value: unknown) => transform(value, operationRoot, displayRoot); + return new Proxy(actions, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value !== 'function') return value; + return (...args: unknown[]) => { + guard(); + const descriptorRelative = typeof property === 'string' && descriptorActions.has(property); + const operationArgs = descriptorRelative ? args.map(toOperation) : args; + if (typeof property === 'string' && rootedObjectActions.has(property) && operationArgs[0] && typeof operationArgs[0] === 'object') { + operationArgs[0] = { ...(operationArgs[0] as Record), rootOperationsDir: operationRoot, assertRootAuthority: guard }; + } + const trailingIndex = typeof property === 'string' ? rootedTrailingOptionIndex.get(property) : undefined; + if (trailingIndex !== undefined) { + operationArgs[trailingIndex] = { ...((operationArgs[trailingIndex] as Record | undefined) ?? {}), rootOperationsDir: operationRoot, assertRootAuthority: guard }; + } + if (property === 'startStack' && operationArgs[0] && typeof operationArgs[0] === 'object') { + operationArgs[0] = { ...(operationArgs[0] as Record), rootOperationsDir: operationRoot, assertRootAuthority: guard }; + } + const result = Reflect.apply(value, target, operationArgs); + if (result && typeof (result as PromiseLike).then === 'function') { + return Promise.resolve(result).then( + output => { guard(); return toDisplay(output); }, + error => { guard(); throw error; }, + ); + } + guard(); + return toDisplay(result); + }; + }, + }); +} + +export class SetupSecretCapabilities { + readonly #records = new Map(); + readonly #now: () => number; + + constructor(now: () => number = Date.now) { this.#now = now; } + + issue(sessionId: string, value: string): DesktopSecretSelection { + if (!value || value.length > 512 || /[\0\r\n]/.test(value)) throw new SetupCapabilityError('The webhook secret is invalid.'); + const capability = randomBytes(32).toString('base64url'); + this.#records.set(capability, { sessionId, value, expiresAt: this.#now() + TTL_MS }); + return { capability, label: 'Secret entered' }; + } + + validate(capability: string, sessionId: string): void { + const record = this.#records.get(capability); + if (!record || record.sessionId !== sessionId || record.expiresAt < this.#now()) throw new SetupCapabilityError(); + } + + consume(capability: string, sessionId: string): string { + this.validate(capability, sessionId); + const record = this.#records.get(capability)!; + this.#records.delete(capability); + return record.value; + } + + clear(): void { this.#records.clear(); } +} + +export class SetupFilesystemCapabilities { + readonly #records = new Map(); + readonly #now: () => number; + + constructor(now: () => number = Date.now) { this.#now = now; } + + async issue(kind: SelectionKind, sessionId: string, selectedPath: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const originalPath = safePath(selectedPath); + const before = await lstat(originalPath, { bigint: true }); + signal?.throwIfAborted(); + if (before.isSymbolicLink()) throw new SetupCapabilityError('Symbolic-link selections are not allowed.'); + if (!before.isFile()) throw new SetupCapabilityError(); + assertOwner(before.uid); + if ((before.mode & 0o077n) !== 0n) throw new SetupCapabilityError('The private-key file must not be accessible by group or other users.'); + if (before.nlink !== 1n || before.size <= 0n || before.size > BigInt(MAX_KEY_BYTES)) throw new SetupCapabilityError('The private-key file size or link count is invalid.'); + const canonicalPath = await realpath(originalPath); + signal?.throwIfAborted(); + if (canonicalPath !== originalPath) throw new SetupCapabilityError('Selections containing symbolic links are not allowed.'); + const canonical = await stat(canonicalPath, { bigint: true }); + signal?.throwIfAborted(); + if (canonical.dev !== before.dev || canonical.ino !== before.ino) throw new SetupCapabilityError(); + const capability = randomBytes(32).toString('base64url'); + signal?.throwIfAborted(); + this.#records.set(capability, { kind, sessionId, originalPath, canonicalPath, device: before.dev, inode: before.ino, expiresAt: this.#now() + TTL_MS }); + return { capability, label: basename(canonicalPath) }; + } + + #take(capability: string, kind: SelectionKind, sessionId: string): SelectionRecord { + const record = this.#records.get(capability); + this.#records.delete(capability); + if (!record || record.kind !== kind || record.sessionId !== sessionId || record.expiresAt < this.#now()) throw new SetupCapabilityError(); + return record; + } + + async validate(capability: string, kind: SelectionKind, sessionId: string): Promise { + const record = this.#records.get(capability); + if (!record || record.kind !== kind || record.sessionId !== sessionId || record.expiresAt < this.#now()) throw new SetupCapabilityError(); + const current = await lstat(record.originalPath, { bigint: true }).catch(() => null); + if (!current || current.isSymbolicLink() || current.dev !== record.device || current.ino !== record.inode + || !current.isFile()) throw new SetupCapabilityError(); + if (await realpath(record.originalPath) !== record.canonicalPath) throw new SetupCapabilityError(); + if ((current.mode & 0o077n) !== 0n || current.nlink !== 1n || current.size <= 0n || current.size > BigInt(MAX_KEY_BYTES)) throw new SetupCapabilityError(); + return record.canonicalPath; + } + + async consumePrivateKey(capability: string, sessionId: string, keyStorageDir: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const record = this.#take(capability, 'private-key', sessionId); + signal?.throwIfAborted(); + ensurePrivateDirectory(keyStorageDir); + signal?.throwIfAborted(); + const descriptor = openSync(record.originalPath, constants.O_RDONLY | constants.O_NOFOLLOW | O_CLOEXEC); + try { + const current = fstatSync(descriptor, { bigint: true }); + if (!current.isFile() || current.dev !== record.device || current.ino !== record.inode || current.nlink !== 1n + || current.uid !== BigInt(process.getuid?.() ?? Number(current.uid)) || (current.mode & 0o077n) !== 0n + || current.size <= 0n || current.size > BigInt(MAX_KEY_BYTES)) throw new SetupCapabilityError(); + const bytes = readFileSync(descriptor); + const ownedPath = join(resolve(keyStorageDir), `${randomBytes(24).toString('hex')}.pem`); + signal?.throwIfAborted(); + writePrivateFileAtomic(ownedPath, bytes, { signal }); + try { + signal?.throwIfAborted(); + } catch (error) { + unlinkSync(ownedPath); + throw error; + } + return ownedPath; + } finally { + closeSync(descriptor); + } + } + + consume(capabilities: string[]): void { for (const capability of capabilities) this.#records.delete(capability); } + clear(): void { this.#records.clear(); } +} diff --git a/apps/desktop/src/setup-controller.test.ts b/apps/desktop/src/setup-controller.test.ts new file mode 100644 index 000000000..a4fb727f5 --- /dev/null +++ b/apps/desktop/src/setup-controller.test.ts @@ -0,0 +1,676 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { chmod, mkdir, mkdtemp, readFile, readdir, rename, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { writePrivateFileAtomic, type SetupActions } from '@propr/local-setup'; +import { DesktopSetupController } from './setup-controller'; + +const fakeActions = (): SetupActions => { + const env: Record = {}; + return { + async runChecks({ root }) { + return { rootDir: root!, anyFail: false, results: [{ name: 'Docker daemon', group: 'Docker', status: 'ok', detail: 'ready' }] }; + }, + inspectStackInit(rootDir) { + return { rootDir, envExists: false, dirs: { data: false, logs: false, repos: false }, initialized: false }; + }, + async inspectDatastoreAdministrators() { return { status: 'absent' }; }, + async scaffoldStack({ root }) { + return { rootDir: root!, envCreated: true, envSkipped: false, envBackedUp: false, dirsCreated: ['data', 'logs', 'repos'], dirsSkipped: [] }; + }, + async persistStackRoot() {}, + readEnvVars() { return { ...env }; }, + applyEnvSelection(_root, values, options) { + const written: string[] = []; + const skipped: string[] = []; + for (const [key, value] of Object.entries(values)) { + if (!options?.overwrite && env[key]) skipped.push(key); + else { env[key] = value; written.push(key); } + } + return { written, skipped }; + }, + clearEnvKeys(_root, keys) { keys.forEach(key => delete env[key]); }, + detectGithubAuthMode() { return { mode: env.PROPR_DEMO_MODE === 'true' ? 'demo' : env.GH_AUTH_MODE === 'relay' ? 'relay' : env.GH_AUTH_MODE === 'app' ? 'app' : 'none', warnings: [] }; }, + prepareAgentCredentialDir() {}, + async pullImages({ onLog }) { + onLog?.('token=must-not-cross-ipc'); + return { pulledCore: ['api'], pulledAgents: [], failedCore: [], failedAgents: [] }; + }, + async isStackRunning() { return false; }, + async startStack() {}, + async checkBackendHealth() { return { healthy: true, detail: 'API healthy' }; }, + async addRepository() {}, + async resolveUiUrl() { return 'http://127.0.0.1:5173'; }, + async openUrl() {}, + async saveWhitelistSetting() {}, + hasGithubToken() { return false; }, + async fetchRelayInstallations() { return { username: 'owner', installations: [] }; }, + async enrollRelay() { return { relayUrl: 'https://connect.propr.dev', token: 'secret' }; }, + async loginWithGithub() { return false; }, + async listAgents() { return []; }, + async addAgent() {}, + async loginableAgents() { return []; }, + async loginAgent() { return { available: false, success: false }; }, + async validateAgents() { return []; }, + }; +}; + +describe('desktop local setup controller', () => { + it('runs the injected host adapter, redacts progress, persists resume state, and registers the healthy profile', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-setup-')); + const statePath = join(directory, 'setup.json'); + const snapshots: string[] = []; + const controller = new DesktopSetupController({ + actions: fakeActions(), + platform: 'linux', + statePath, + defaultRootDir: join(directory, 'stack'), + selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', + registerProfile: async ({ name, apiBaseUrl }) => ({ id: 'local', name, baseUrl: apiBaseUrl, kind: 'local' }), + emit: snapshot => snapshots.push(snapshot.phase), + }); + + const { sessionId } = await controller.status(); + const result = await controller.start({ + sessionId, + root: { mode: 'default' }, + reinitialize: false, + agents: [], + github: { mode: 'demo' }, + intake: { mode: 'keep' }, + whitelist: null, + repository: null, + }); + + assert.equal(result.phase, 'completed'); + assert.equal(result.profile?.baseUrl, 'http://127.0.0.1:4000'); + assert.match(result.logs.join('\n'), /\[REDACTED\]/); + assert.doesNotMatch(result.logs.join('\n'), /must-not-cross-ipc/); + assert.ok(snapshots.includes('running')); + const persisted = await readFile(statePath, 'utf8'); + assert.doesNotMatch(persisted, /must-not-cross-ipc/); + assert.doesNotMatch(persisted, /PROPR_DEMO_MODE/); + }); + + it('reports remote-only capability on non-Linux hosts without invoking setup actions', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-unsupported-')); + const controller = new DesktopSetupController({ + actions: {} as SetupActions, + platform: 'darwin', + statePath: join(directory, 'setup.json'), + defaultRootDir: join(directory, 'stack'), + selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => { throw new Error('not called'); }, + registerProfile: async () => { throw new Error('not called'); }, + emit() {}, + }); + + const status = await controller.status(); + assert.equal(status.phase, 'unsupported'); + assert.equal(status.capability.kind, 'remote-only'); + await assert.rejects(async () => controller.start({} as never), /Invalid local setup request|not supported/); + }); + + it('awaits aborted host work before publishing cancelled and permits retry only after settlement', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-cancel-')); + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + let stopped = false; + let registered = false; + const actions = fakeActions(); + actions.runChecks = ({ root, signal }) => new Promise(resolve => { + entered(); + signal?.addEventListener('abort', () => { + stopped = true; + resolve({ rootDir: root!, anyFail: false, results: [] }); + }, { once: true }); + }); + const controller = new DesktopSetupController({ + actions, platform: 'linux', statePath: join(directory, 'state.json'), defaultRootDir: join(directory, 'stack'), + selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', + registerProfile: async () => { registered = true; throw new Error('must not run'); }, emit() {}, + }); + const { sessionId } = await controller.status(); + const running = controller.start({ sessionId, root: { mode: 'default' }, reinitialize: false, agents: [], github: { mode: 'demo' }, intake: { mode: 'keep' }, whitelist: null, repository: null }); + await started; + await assert.rejects(controller.retry(), /already running/); + const cancelled = await controller.cancel(); + assert.equal(stopped, true); + assert.equal(cancelled.phase, 'cancelled'); + assert.equal((await running).phase, 'cancelled'); + assert.equal(registered, false); + }); + + it('does not consume or copy a key or secret for an already-aborted setup boundary', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-pre-abort-')); + const keyPath = join(directory, 'selected.pem'); + const keyStorageDir = join(directory, 'owned-keys'); + await writeFile(keyPath, 'private-key-sentinel', { mode: 0o600 }); + let hostActions = 0; + const actions = fakeActions(); + actions.runChecks = async ({ root }) => { hostActions += 1; return { rootDir: root!, anyFail: false, results: [] }; }; + const controller = new DesktopSetupController({ + actions, platform: 'linux', statePath: join(directory, 'state.json'), defaultRootDir: join(directory, 'stack'), keyStorageDir, + selectPrivateKey: async () => keyPath, promptWebhookSecret: async () => 'webhook-secret-sentinel', + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => { throw new Error('not called'); }, emit() {}, + }); + const status = await controller.status(); + const key = await controller.selectPrivateKey(); + const secret = await controller.acquireWebhookSecret(); + assert.ok(key && secret); + const abort = new AbortController(); + abort.abort(); + await assert.rejects(controller.start({ + sessionId: status.sessionId, root: { mode: 'default' }, reinitialize: false, agents: [], + github: { mode: 'app', appId: '123', installationId: '456', privateKeyCapability: key.capability }, + intake: { mode: 'direct_webhook', secretCapability: secret.capability }, whitelist: null, repository: null, + }, abort.signal), error => (error as Error).name === 'AbortError'); + assert.equal(hostActions, 0); + assert.deepEqual(await readdir(keyStorageDir).catch(error => (error as NodeJS.ErrnoException).code === 'ENOENT' ? [] : Promise.reject(error)), []); + assert.equal(await readFile(keyPath, 'utf8'), 'private-key-sentinel'); + }); + + it('does not issue a key or secret capability across an abort boundary', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-selection-abort-')); + const keyPath = join(directory, 'selected.pem'); + await writeFile(keyPath, 'private-key-sentinel', { mode: 0o600 }); + const selectionAbort = new AbortController(); + const secretAbort = new AbortController(); + const controller = new DesktopSetupController({ + actions: fakeActions(), platform: 'linux', statePath: join(directory, 'state.json'), defaultRootDir: join(directory, 'stack'), + selectPrivateKey: async () => { selectionAbort.abort(); return keyPath; }, + promptWebhookSecret: async () => { secretAbort.abort(); return 'webhook-secret-sentinel'; }, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => { throw new Error('not called'); }, emit() {}, + }); + await assert.rejects(controller.selectPrivateKey(selectionAbort.signal), error => (error as Error).name === 'AbortError'); + await assert.rejects(controller.acquireWebhookSecret(secretAbort.signal), error => (error as Error).name === 'AbortError'); + }); + + it('pins relay enrollment to the official relay and rejects attacker-controlled URL fields', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-relay-')); + const seen: unknown[] = []; + const actions = fakeActions(); + actions.hasGithubToken = () => true; + actions.fetchRelayInstallations = async params => { + seen.push(params); + return { username: 'octocat', installations: [{ installation_id: 42, account_login: 'integry', account_type: 'Organization' }] }; + }; + actions.enrollRelay = async params => { + seen.push(params); + return { relayUrl: params.relayUrl!, token: 'ghr_super-secret-relay-token' }; + }; + const controller = new DesktopSetupController({ + actions, platform: 'linux', statePath: join(directory, 'state.json'), defaultRootDir: join(directory, 'stack'), + selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => ({ id: 'local', name: 'Local', baseUrl: 'http://127.0.0.1:4000', kind: 'local' }), emit() {}, + }); + const { sessionId } = await controller.status(); + const request = { sessionId, root: { mode: 'default' as const }, reinitialize: false, agents: [], github: { mode: 'relay' as const }, intake: { mode: 'polling' as const }, whitelist: ['octocat'], repository: null }; + await controller.start(request); + assert.ok(seen.length >= 2); + assert.equal(seen.every(value => JSON.stringify(value).includes('https://webhook.propr.dev/v1')), true); + assert.doesNotMatch(JSON.stringify(seen), /attacker|authorization/i); + await assert.rejects(async () => controller.start({ ...request, github: { mode: 'relay', relayUrl: 'https://attacker.invalid' } } as never), /Invalid/); + }); + + it('aborts and settles blocked host work during shutdown', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-shutdown-')); + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + let stopped = false; + const actions = fakeActions(); + actions.runChecks = ({ root, signal }) => new Promise(resolve => { + entered(); + signal?.addEventListener('abort', () => { stopped = true; resolve({ rootDir: root!, anyFail: false, results: [] }); }, { once: true }); + }); + const controller = new DesktopSetupController({ + actions, platform: 'linux', statePath: join(directory, 'state.json'), defaultRootDir: join(directory, 'stack'), + selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => { throw new Error('not called'); }, emit() {}, + }); + const status = await controller.status(); + const run = controller.start({ sessionId: status.sessionId, root: { mode: 'default' }, reinitialize: false, agents: [], github: { mode: 'demo' }, intake: { mode: 'keep' }, whitelist: null, repository: null }); + await started; + await controller.shutdown(); + assert.equal(stopped, true); + assert.equal((await run).phase, 'cancelled'); + }); + + it('threads cancellation into deferred profile registration and suppresses the late write', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-profile-cancel-')); + let entered!: () => void; + const registering = new Promise(resolve => { entered = resolve; }); + let registered = false; + const controller = new DesktopSetupController({ + actions: fakeActions(), platform: 'linux', statePath: join(directory, 'state.json'), defaultRootDir: join(directory, 'stack'), + selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', + registerProfile: async (_profile, signal) => { + entered(); + await new Promise((resolve, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true })); + registered = true; + return { id: 'late', name: 'Late', baseUrl: 'http://127.0.0.1:4000', kind: 'local' }; + }, emit() {}, + }); + const status = await controller.status(); + const run = controller.start({ sessionId: status.sessionId, root: { mode: 'default' }, reinitialize: false, agents: [], github: { mode: 'demo' }, intake: { mode: 'keep' }, whitelist: null, repository: null }); + await registering; + const result = await controller.cancel(); + assert.equal(result.phase, 'cancelled'); + assert.equal((await run).phase, 'cancelled'); + assert.equal(registered, false); + }); + + it('reports residual rollback as a fixed failure even when startup was cancelled', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-residual-cleanup-')); + const external = new AbortController(); + const diagnostics: unknown[] = []; + const actions = fakeActions(); + actions.startStack = async () => { + external.abort(); + throw Object.assign( + new AggregateError([new Error('cancelled'), new Error('residual propr-ui at /host/private')], 'raw cleanup detail'), + { code: 'PROPR_SETUP_CLEANUP_INCOMPLETE' }, + ); + }; + const controller = new DesktopSetupController({ + actions, platform: 'linux', statePath: join(directory, 'state.json'), defaultRootDir: join(directory, 'stack'), + selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => { throw new Error('not called'); }, emit() {}, + diagnose: (_event, fields) => diagnostics.push(fields), + }); + const status = await controller.status(); + const result = await controller.start({ + sessionId: status.sessionId, root: { mode: 'default' }, reinitialize: false, agents: [], + github: { mode: 'demo' }, intake: { mode: 'keep' }, whitelist: null, repository: null, + }, external.signal); + assert.equal(result.phase, 'failed'); + assert.match(result.error ?? '', /cleanup is incomplete/); + assert.doesNotMatch(JSON.stringify(result), /propr-ui|host\/private|raw cleanup detail/); + assert.match(JSON.stringify(diagnostics), /REDACTED/); + assert.doesNotMatch(JSON.stringify(diagnostics), /host\/private/); + }); + + it('persists every non-secret choice and requires secret reconfiguration after restart', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-resume-')); + const keyPath = join(directory, 'github-app.pem'); + const keyContents = '-----BEGIN PRIVATE KEY-----\nultra-secret-key-content\n-----END PRIVATE KEY-----'; + await writeFile(keyPath, keyContents, { mode: 0o600 }); + await chmod(keyPath, 0o600); + const statePath = join(directory, 'state.json'); + const options = { + actions: fakeActions(), platform: 'linux' as const, statePath, defaultRootDir: join(directory, 'stack'), + selectPrivateKey: async () => keyPath, + promptWebhookSecret: async () => 'arbitrary-webhook-value', + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => ({ id: 'local', name: 'Local', baseUrl: 'http://127.0.0.1:4000', kind: 'local' as const }), emit() {}, + }; + const first = new DesktopSetupController(options); + const status = await first.status(); + const key = await first.selectPrivateKey(); + const secret = await first.acquireWebhookSecret(); + assert.ok(key); + assert.ok(secret); + await first.start({ + sessionId: status.sessionId, root: { mode: 'default' }, reinitialize: true, agents: ['claude'], + github: { mode: 'app', appId: '123', installationId: '456', privateKeyCapability: key.capability }, + intake: { mode: 'direct_webhook', secretCapability: secret.capability }, whitelist: [], repository: { fullName: 'integry/propr', alias: 'propr', baseBranch: 'main' }, + }); + const persisted = await readFile(statePath, 'utf8'); + assert.doesNotMatch(persisted, /arbitrary-webhook-value|ultra-secret-key-content|github-app\.pem/); + assert.match(persisted, /"agents": \[\s*"claude"/); + assert.match(persisted, /"fullName": "integry\/propr"/); + + const restarted = new DesktopSetupController({ ...options, sessionId: '11111111-1111-4111-8111-111111111111' }); + const resumed = await restarted.status(); + assert.equal(resumed.reconfigurationRequired, true); + assert.equal(resumed.resume?.reconfigurationStage, 'github'); + assert.deepEqual(resumed.resume?.whitelist, []); + assert.deepEqual(resumed.resume?.repository, { fullName: 'integry/propr', alias: 'propr', baseBranch: 'main' }); + await assert.rejects(restarted.retry(), /Re-enter the github/); + }); + + it('recomputes platform support after shared concurrent hydration instead of trusting Linux state', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-hydration-')); + const statePath = join(directory, 'state.json'); + const linux = new DesktopSetupController({ + actions: fakeActions(), platform: 'linux', statePath, defaultRootDir: join(directory, 'stack'), selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => ({ id: 'local', name: 'Local', baseUrl: 'http://127.0.0.1:4000', kind: 'local' }), emit() {}, + }); + const current = await linux.status(); + await linux.start({ sessionId: current.sessionId, root: { mode: 'default' }, reinitialize: false, agents: [], github: { mode: 'demo' }, intake: { mode: 'keep' }, whitelist: null, repository: null }); + + const concurrentSession = '33333333-3333-4333-8333-333333333333'; + const rehydrated = new DesktopSetupController({ + actions: fakeActions(), platform: 'linux', statePath, defaultRootDir: join(directory, 'stack'), sessionId: concurrentSession, selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => ({ id: 'local', name: 'Local', baseUrl: 'http://127.0.0.1:4000', kind: 'local' }), emit() {}, + }); + const [hydratedStatus, hydratedStart] = await Promise.all([ + rehydrated.status(), + rehydrated.start({ sessionId: concurrentSession, root: { mode: 'resume' }, reinitialize: false, agents: [], github: { mode: 'demo' }, intake: { mode: 'keep' }, whitelist: null, repository: null }), + ]); + assert.equal(hydratedStatus.capability.supported, true); + assert.equal(hydratedStart.phase, 'completed'); + + const sessionId = '22222222-2222-4222-8222-222222222222'; + const darwin = new DesktopSetupController({ + actions: {} as SetupActions, platform: 'darwin', statePath, defaultRootDir: join(directory, 'stack'), sessionId, selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => { throw new Error('not called'); }, registerProfile: async () => { throw new Error('not called'); }, emit() {}, + }); + const [one, two] = await Promise.all([darwin.status(), darwin.status()]); + assert.equal(one.phase, 'unsupported'); + assert.deepEqual(one.capability, two.capability); + await assert.rejects(darwin.start({ sessionId, root: { mode: 'resume' }, reinitialize: false, agents: [], github: { mode: 'demo' }, intake: { mode: 'keep' }, whitelist: null, repository: null }), /not supported/); + }); + + it('surfaces persistence failure as resume unavailable', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-persist-fail-')); + const blocker = join(directory, 'not-a-directory'); + await writeFile(blocker, 'block'); + const controller = new DesktopSetupController({ + actions: fakeActions(), platform: 'linux', statePath: join(blocker, 'state.json'), defaultRootDir: join(directory, 'stack'), + selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => ({ id: 'local', name: 'Local', baseUrl: 'http://127.0.0.1:4000', kind: 'local' }), emit() {}, + }); + const status = await controller.status(); + const result = await controller.start({ sessionId: status.sessionId, root: { mode: 'default' }, reinitialize: false, agents: [], github: { mode: 'demo' }, intake: { mode: 'keep' }, whitelist: null, repository: null }); + assert.equal(result.resumeAvailable, false); + assert.match(result.error ?? '', /Resume after restart is unavailable/); + }); + + it('rejects managed paths that escape the fixed app-owned root', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-contained-root-')); + const root = join(directory, 'default'); + const outside = join(directory, 'outside'); + await mkdir(root); await mkdir(outside); await symlink(outside, join(root, 'data')); + const controller = new DesktopSetupController({ + actions: fakeActions(), platform: 'linux', statePath: join(directory, 'state.json'), defaultRootDir: join(directory, 'default'), + selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => { throw new Error('not called'); }, emit() {}, + }); + const status = await controller.status(); + const result = await controller.start({ sessionId: status.sessionId, root: { mode: 'default' }, reinitialize: false, agents: [], github: { mode: 'demo' }, intake: { mode: 'keep' }, whitelist: null, repository: null }); + assert.equal(result.phase, 'failed'); + assert.doesNotMatch(result.error ?? '', new RegExp(outside)); + }); + + it('uses a generic renderer error while retaining only sanitized protected diagnostics', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-generic-error-')); + const actions = fakeActions(); + const diagnostics: unknown[] = []; + const controller = new DesktopSetupController({ + actions, platform: 'linux', statePath: join(directory, 'state.json'), defaultRootDir: join(directory, 'stack'), + selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => { throw new Error('profile failure included ghp_1234567890abcdef and Authorization: Bearer relay-auth-value'); }, emit() {}, + diagnose: (_event, fields) => diagnostics.push(fields), + }); + const status = await controller.status(); + const result = await controller.start({ sessionId: status.sessionId, root: { mode: 'default' }, reinitialize: false, agents: [], github: { mode: 'demo' }, intake: { mode: 'keep' }, whitelist: null, repository: null }); + assert.match(result.error ?? '', /failed unexpectedly/); + const serialized = JSON.stringify(diagnostics); + assert.doesNotMatch(serialized, /ghp_1234567890abcdef|relay-auth-value/); + assert.match(serialized, /REDACTED/); + }); + + it('quit and reopen resumes against the fixed root without any directory reselection', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-fixed-resume-')); + const root = join(directory, 'default'); + const statePath = join(directory, 'state.json'); + const first = new DesktopSetupController({ + actions: fakeActions(), platform: 'linux', statePath, defaultRootDir: join(directory, 'default'), + selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => ({ id: 'local', name: 'Local', baseUrl: 'http://127.0.0.1:4000', kind: 'local' }), emit() {}, + }); + const status = await first.status(); + await first.start({ sessionId: status.sessionId, root: { mode: 'default' }, reinitialize: false, agents: [], github: { mode: 'demo' }, intake: { mode: 'keep' }, whitelist: null, repository: null }); + await first.shutdown(); + + let actions = 0; + const replacementActions = fakeActions(); + replacementActions.runChecks = async ({ root: checked }) => { actions += 1; return { rootDir: checked!, anyFail: false, results: [] }; }; + const restarted = new DesktopSetupController({ + actions: replacementActions, platform: 'linux', statePath, defaultRootDir: join(directory, 'default'), + selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => ({ id: 'local', name: 'Local', baseUrl: 'http://127.0.0.1:4000', kind: 'local' }), emit() {}, + }); + const resumed = await restarted.status(); + assert.equal(resumed.rootDir, '[REDACTED_PATH]'); + assert.equal(resumed.resume?.reconfigurationStage, undefined); + assert.equal((await restarted.retry()).phase, 'completed'); + assert.ok(actions > 0); + }); + + it('never reads or mounts a formerly chosen replacement directory', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-no-custom-root-')); + const fixedRoot = join(directory, 'fixed'); + const chosenRoot = join(directory, 'chosen'); + const sentinel = 'CHOSEN_REPLACEMENT_SENTINEL_UNCHANGED'; + await mkdir(chosenRoot, { mode: 0o700 }); + await writeFile(join(chosenRoot, '.env'), sentinel, { mode: 0o600 }); + const observedRoots: string[] = []; + const actions = fakeActions(); + actions.runChecks = async ({ root }) => { observedRoots.push(root!); return { rootDir: root!, anyFail: false, results: [] }; }; + actions.startStack = async ({ rootDir, assertRootAuthority }) => { observedRoots.push(rootDir); assertRootAuthority?.(); }; + const controller = new DesktopSetupController({ + actions, platform: 'linux', statePath: join(directory, 'state.json'), defaultRootDir: fixedRoot, + selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => ({ id: 'local', name: 'Local', baseUrl: 'http://127.0.0.1:4000', kind: 'local' }), emit() {}, + }); + const status = await controller.status(); + const result = await controller.start({ sessionId: status.sessionId, root: { mode: 'default' }, reinitialize: false, agents: [], github: { mode: 'demo' }, intake: { mode: 'keep' }, whitelist: null, repository: null }); + assert.equal(result.phase, 'completed'); + assert.equal(await readFile(join(chosenRoot, '.env'), 'utf8'), sentinel); + assert.equal(observedRoots.some(value => value.startsWith(chosenRoot)), false); + assert.ok(observedRoots.includes(fixedRoot)); + }); + + it('copies a consumed private key once and never reopens a swapped chooser pathname', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-key-copy-')); + const keyPath = join(directory, 'app.pem'); + const original = 'ORIGINAL_PRIVATE_KEY_BYTES'; + const replacement = 'REPLACEMENT_MUST_NOT_BE_READ'; + await writeFile(keyPath, original, { mode: 0o600 }); + let release!: () => void; + let entered!: () => void; + const atChecks = new Promise(resolve => { entered = resolve; }); + const continueChecks = new Promise(resolve => { release = resolve; }); + let mountedPath: string | undefined; + const actions = fakeActions(); + actions.runChecks = async ({ root }) => { + entered(); await continueChecks; + return { rootDir: root!, anyFail: false, results: [{ name: 'Docker daemon', group: 'Docker', status: 'ok', detail: 'ready' }] }; + }; + const baseApply = actions.applyEnvSelection; + actions.applyEnvSelection = (root, values, options, signal) => { + if (values.HOST_GH_PRIVATE_KEY) mountedPath = values.HOST_GH_PRIVATE_KEY; + return baseApply(root, values, options, signal); + }; + const controller = new DesktopSetupController({ + actions, platform: 'linux', statePath: join(directory, 'state.json'), defaultRootDir: join(directory, 'stack'), keyStorageDir: join(directory, 'owned-keys'), + selectPrivateKey: async () => keyPath, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => { throw new Error('not called'); }, emit() {}, + }); + const status = await controller.status(); + const selected = await controller.selectPrivateKey(); + assert.ok(selected); + const running = controller.start({ + sessionId: status.sessionId, root: { mode: 'default' }, reinitialize: false, agents: [], + github: { mode: 'app', appId: '1', installationId: '2', privateKeyCapability: selected.capability }, + intake: { mode: 'polling' }, whitelist: null, repository: null, + }); + await atChecks; + await rename(keyPath, `${keyPath}.original`); + await writeFile(keyPath, replacement, { mode: 0o600 }); + release(); + await running; + assert.ok(mountedPath); + assert.notEqual(mountedPath, keyPath); + assert.equal(await readFile(mountedPath, 'utf8'), original); + assert.doesNotMatch(await readFile(mountedPath, 'utf8'), /REPLACEMENT/); + }); + + it('keeps an atomic env commit descriptor-relative when the fixed root is renamed and replaced', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-root-commit-')); + const selectedRoot = join(directory, 'fixed'); + const originalRoot = join(directory, 'fixed-original'); + const sentinel = 'REPLACEMENT_SENTINEL_MUST_SURVIVE'; + await mkdir(selectedRoot, { mode: 0o700 }); + const emitted: unknown[] = []; + let swapped = false; + let operationRoot = ''; + const actions = fakeActions(); + actions.applyEnvSelection = (rootDir, values, _options, signal) => { + operationRoot = rootDir; + writePrivateFileAtomic(join(rootDir, '.env'), Object.entries(values).map(([key, value]) => `${key}=${value}`).join('\n'), { + signal, + beforeRename() { + if (swapped) return; + swapped = true; + renameSync(selectedRoot, originalRoot); + mkdirSync(selectedRoot, { mode: 0o700 }); + writeFileSync(join(selectedRoot, '.env'), sentinel, { mode: 0o600 }); + }, + }); + return { written: Object.keys(values), skipped: [] }; + }; + const controller = new DesktopSetupController({ + actions, platform: 'linux', statePath: join(directory, 'state.json'), defaultRootDir: selectedRoot, + selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => { throw new Error('not called'); }, emit: snapshot => emitted.push(snapshot), + }); + const status = await controller.status(); + const result = await controller.start({ + sessionId: status.sessionId, root: { mode: 'default' }, reinitialize: false, agents: [], + github: { mode: 'demo' }, intake: { mode: 'keep' }, whitelist: null, repository: null, + }); + assert.equal(result.phase, 'failed'); + assert.match(operationRoot, new RegExp(`^/proc/${process.pid}/fd/[0-9]+$`)); + assert.equal(readFileSync(join(selectedRoot, '.env'), 'utf8'), sentinel); + assert.match(readFileSync(join(originalRoot, '.env'), 'utf8'), /PROPR_DEMO_MODE=true/); + assert.doesNotMatch(JSON.stringify({ result, emitted }), new RegExp(`/proc/${process.pid}/fd/`)); + assert.equal((await controller.retry()).phase, 'failed', 'retry starts only after the failed run settled'); + await controller.shutdown(); + }); + + it('hands Docker only the stable fixed root and fails if that identity is replaced', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-root-docker-')); + const selectedRoot = join(directory, 'fixed'); + const originalRoot = join(directory, 'fixed-original'); + const sentinel = 'DO_NOT_READ_OR_BIND_REPLACEMENT'; + await mkdir(selectedRoot, { mode: 0o700 }); + let launched = false; + let daemonRoot = ''; + let operationsRoot = ''; + const actions = fakeActions(); + actions.startStack = async params => { + daemonRoot = params.rootDir; + operationsRoot = params.rootOperationsDir ?? ''; + renameSync(selectedRoot, originalRoot); + mkdirSync(selectedRoot, { mode: 0o700 }); + writeFileSync(join(selectedRoot, '.env'), sentinel, { mode: 0o600 }); + params.assertRootAuthority?.(); + launched = true; + }; + const controller = new DesktopSetupController({ + actions, platform: 'linux', statePath: join(directory, 'state.json'), defaultRootDir: selectedRoot, + selectPrivateKey: async () => null, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => { throw new Error('not called'); }, emit() {}, + }); + const status = await controller.status(); + const result = await controller.start({ + sessionId: status.sessionId, root: { mode: 'default' }, reinitialize: false, agents: [], + github: { mode: 'demo' }, intake: { mode: 'keep' }, whitelist: null, repository: null, + }); + assert.equal(result.phase, 'failed'); + assert.equal(launched, false); + assert.equal(daemonRoot, selectedRoot); + assert.doesNotMatch(daemonRoot, /(?:^|\/)proc\/|(?:^|\/)dev\/fd/); + assert.match(operationsRoot, new RegExp(`^/proc/${process.pid}/fd/[0-9]+$`)); + assert.equal(readFileSync(join(selectedRoot, '.env'), 'utf8'), sentinel); + assert.equal((await controller.retry()).phase, 'failed', 'retry starts only after the failed run settled'); + await controller.shutdown(); + }); + + it('threads the descriptor read root and authority guard through every config consumer', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-root-consumers-')); + const stableRoot = join(directory, 'stack'); + const seen = new Map(); + const record = (name: string, stable: string, boundary?: { rootOperationsDir?: string; assertRootAuthority?(): void }) => { + boundary?.assertRootAuthority?.(); + seen.set(name, { stable, read: boundary?.rootOperationsDir, guarded: Boolean(boundary?.assertRootAuthority) }); + }; + const actions = fakeActions(); + actions.pullImages = async params => { + record('pull', params.rootDir, params); + return { pulledCore: ['api'], pulledAgents: ['agent'], failedCore: [], failedAgents: [] }; + }; + let statusCalls = 0; + actions.isStackRunning = async (rootDir, _signal, boundary) => { record('status', rootDir, boundary); return statusCalls++ > 0; }; + actions.checkBackendHealth = async params => { record('health', params.rootDir, params); return { healthy: true, detail: 'healthy' }; }; + actions.resolveUiUrl = async (rootDir, _signal, boundary) => { record('ui', rootDir, boundary); return 'http://127.0.0.1:5173'; }; + actions.saveWhitelistSetting = async (rootDir, _users, _signal, boundary) => { record('settings', rootDir, boundary); }; + actions.addRepository = async (_selection, rootDir, _signal, boundary) => { record('repo', rootDir, boundary); }; + actions.listAgents = async (rootDir, _signal, boundary) => { record('agents-list', rootDir, boundary); return []; }; + actions.addAgent = async (rootDir, _options, _signal, boundary) => { record('agents-add', rootDir, boundary); }; + actions.validateAgents = async (rootDir, _types, _signal, boundary) => { record('agents-validate', rootDir, boundary); return []; }; + const controller = new DesktopSetupController({ + actions, platform: 'linux', statePath: join(directory, 'state.json'), defaultRootDir: stableRoot, + selectPrivateKey: async () => null, + resolveApiBaseUrl: async rootDir => { assert.equal(rootDir, stableRoot); return 'http://127.0.0.1:4000'; }, + registerProfile: async () => ({ id: 'local', name: 'Local', baseUrl: 'http://127.0.0.1:4000', kind: 'local' }), emit() {}, + }); + const { sessionId } = await controller.status(); + const result = await controller.start({ + sessionId, root: { mode: 'default' }, reinitialize: false, agents: ['claude'], + github: { mode: 'demo' }, intake: { mode: 'keep' }, whitelist: ['octocat'], + repository: { fullName: 'integry/propr' }, + }); + assert.equal(result.phase, 'completed'); + for (const name of ['pull', 'status', 'health', 'ui', 'settings', 'repo', 'agents-list', 'agents-add', 'agents-validate']) { + const value = seen.get(name); + assert.ok(value, `${name} was not called`); + assert.equal(value.stable, stableRoot); + assert.match(value.read ?? '', new RegExp(`^/proc/${process.pid}/fd/[0-9]+$`)); + assert.equal(value.guarded, true); + } + await controller.shutdown(); + }); + + it('keeps native webhook secret bytes out of snapshots, resume state, logs, errors, and diagnostics', async () => { + const sentinel = 'SENTINEL_NATIVE_SECRET_9f08c7'; + const directory = await mkdtemp(join(tmpdir(), 'propr-desktop-secret-boundary-')); + const emitted: unknown[] = []; + const diagnostics: unknown[] = []; + const actions = fakeActions(); + actions.hasGithubToken = () => true; + actions.inspectDatastoreAdministrators = async () => ({ status: 'has-admin' }); + actions.pullImages = async ({ onLog }) => { + onLog?.(`progress ${sentinel}`); + return { pulledCore: ['api'], pulledAgents: [], failedCore: [], failedAgents: [] }; + }; + actions.startStack = async () => { throw new Error(`daemon failure ${sentinel}`); }; + const statePath = join(directory, 'state.json'); + const controller = new DesktopSetupController({ + actions, platform: 'linux', statePath, defaultRootDir: join(directory, 'stack'), + selectPrivateKey: async () => null, promptWebhookSecret: async () => sentinel, + resolveApiBaseUrl: async () => 'http://127.0.0.1:4000', registerProfile: async () => { throw new Error('not called'); }, emit: snapshot => emitted.push(snapshot), + diagnose: (_event, fields) => diagnostics.push(fields), + }); + const status = await controller.status(); + const secret = await controller.acquireWebhookSecret(); + assert.ok(secret); + assert.doesNotMatch(JSON.stringify(secret), new RegExp(sentinel)); + const result = await controller.start({ + sessionId: status.sessionId, root: { mode: 'default' }, reinitialize: false, agents: [], + github: { mode: 'keep' }, + intake: { mode: 'direct_webhook', secretCapability: secret.capability }, whitelist: null, repository: null, + }); + const rendererVisible = JSON.stringify({ result, emitted, diagnostics, persisted: await readFile(statePath, 'utf8') }); + assert.doesNotMatch(rendererVisible, new RegExp(sentinel)); + assert.match(rendererVisible, /REDACTED/); + await assert.rejects(controller.retry(), /Re-enter the intake/); + }); +}); diff --git a/apps/desktop/src/setup-controller.ts b/apps/desktop/src/setup-controller.ts new file mode 100644 index 000000000..901099a4a --- /dev/null +++ b/apps/desktop/src/setup-controller.ts @@ -0,0 +1,521 @@ +import { randomUUID } from 'node:crypto'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import { + readPrivateFile, + rethrowCancellation, + writePrivateFileAtomic, + getLocalSetupCapability, + retrySetup, + runSetup, + type GithubAuthDecision, + type SetupActions, + type SetupRunResult, +} from '@propr/local-setup'; +import { DEFAULT_PROPR_GH_RELAY_URL } from '@propr/shared'; +import { redactDesktopValue, safeRendererError } from './secret-redaction'; +import { bindRootOperations, RootDirectoryAuthority, SetupFilesystemCapabilities, SetupSecretCapabilities } from './setup-capabilities'; +import { parseDesktopSetupRequest, SetupRequestError } from './setup-schema'; +import type { + DesktopFilesystemSelection, + DesktopProfileView, + DesktopSetupRequest, + DesktopSetupResumeView, + DesktopSetupSnapshot, + DesktopSecretSelection, +} from './shared/contract'; + +type ResumePlan = DesktopSetupResumeView; + +interface PersistedSetupState { + version: 3; + phase: Exclude; + rootDir: string; + lastStepId?: string; + resume: ResumePlan; +} + +interface ResolvedRequest { + publicRequest: DesktopSetupRequest; + rootDir: string; + privateKeyPath?: string; + webhookSecret?: string; + rootAuthority: RootDirectoryAuthority; +} + +export interface DesktopSetupControllerOptions { + actions: SetupActions; + platform?: NodeJS.Platform; + statePath: string; + appDataDir?: string; + defaultRootDir: string; + keyStorageDir?: string; + selectPrivateKey(signal?: AbortSignal): Promise; + promptWebhookSecret?(signal?: AbortSignal): Promise; + resolveApiBaseUrl(rootDir: string, signal?: AbortSignal): Promise; + registerProfile(profile: { name: string; apiBaseUrl: string }, signal?: AbortSignal): Promise; + emit(snapshot: DesktopSetupSnapshot): void; + diagnose?(event: string, fields: Record): void; + sessionId?: string; +} + +const PHASES = new Set(['idle', 'running', 'interrupted', 'cancelled', 'failed', 'completed']); +const STEPS = new Set(['check', 'init-stack', 'pull-images', 'configure-agents', 'github-auth', 'intake', 'start-stack', 'enable-agents', 'whitelist', 'repo', 'launch-ui']); + +const terminalPhase = (result: SetupRunResult): DesktopSetupSnapshot['phase'] => result.completed ? 'completed' : result.cancelled ? 'cancelled' : 'failed'; + +const isCleanupIncomplete = (error: unknown): boolean => Boolean( + error && typeof error === 'object' + && (error as { code?: unknown }).code === 'PROPR_SETUP_CLEANUP_INCOMPLETE', +); + +const cleanupIncompleteRendererError = 'Setup stopped, but local runtime cleanup is incomplete. Review the protected desktop log before retrying.'; + +const assertPath = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 4_096 && isAbsolute(value) && !value.includes('\0'); + +const parseResumePlan = (value: unknown): ResumePlan => { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid resume plan'); + const plan = value as Record; + if (Object.keys(plan).some(key => !['reinitialize', 'agents', 'github', 'intake', 'whitelist', 'repository', 'reconfigurationStage'].includes(key))) throw new Error('Invalid resume plan'); + const github = plan.github as Record | undefined; + const intake = plan.intake as Record | undefined; + if (!github || !intake) throw new Error('Invalid resume plan'); + const githubKeys = github.mode === 'app' ? ['mode', 'appId', 'installationId', 'reconfigurationRequired'] : ['mode']; + const intakeKeys = intake.mode === 'direct_webhook' ? ['mode', 'reconfigurationRequired'] : ['mode']; + if (Object.keys(github).length !== githubKeys.length || Object.keys(github).some(key => !githubKeys.includes(key)) + || Object.keys(intake).length !== intakeKeys.length || Object.keys(intake).some(key => !intakeKeys.includes(key))) throw new Error('Invalid resume plan'); + const synthetic = parseDesktopSetupRequest({ + sessionId: randomUUID(), + root: { mode: 'default' }, + reinitialize: plan.reinitialize, + agents: plan.agents, + github: github?.mode === 'app' + ? { mode: 'app', appId: github.appId, installationId: github.installationId, privateKeyCapability: 'A'.repeat(43) } + : github, + intake: intake?.mode === 'direct_webhook' ? { mode: 'direct_webhook', secretCapability: 'A'.repeat(43) } : intake, + whitelist: plan.whitelist, + repository: plan.repository, + }); + if (github?.mode === 'app' && github.reconfigurationRequired !== true) throw new Error('Invalid resume plan'); + if (intake?.mode === 'direct_webhook' && intake.reconfigurationRequired !== true) throw new Error('Invalid resume plan'); + const expectedStage = github?.mode === 'app' ? 'github' : intake?.mode === 'direct_webhook' ? 'intake' : undefined; + if (plan.reconfigurationStage !== expectedStage) throw new Error('Invalid resume plan'); + return { + reinitialize: synthetic.reinitialize, + agents: synthetic.agents, + github: github as unknown as ResumePlan['github'], + intake: intake as unknown as ResumePlan['intake'], + whitelist: synthetic.whitelist, + repository: synthetic.repository, + ...(expectedStage ? { reconfigurationStage: expectedStage } : {}), + }; +}; + +const parsePersisted = (contents: string): PersistedSetupState => { + if (contents.length > 1024 * 1024) throw new Error('Setup state is too large'); + const value = JSON.parse(contents) as Record; + if (!value || value.version !== 3 || !PHASES.has(String(value.phase)) || !assertPath(value.rootDir)) throw new Error('Invalid setup state'); + if (value.lastStepId !== undefined && (typeof value.lastStepId !== 'string' || !STEPS.has(value.lastStepId))) throw new Error('Invalid setup state'); + if (Object.keys(value).some(key => !['version', 'phase', 'rootDir', 'lastStepId', 'resume'].includes(key))) throw new Error('Invalid setup state'); + return { + version: 3, + phase: value.phase as PersistedSetupState['phase'], + rootDir: resolve(value.rootDir as string), + ...(value.lastStepId ? { lastStepId: value.lastStepId as string } : {}), + resume: parseResumePlan(value.resume), + }; +}; + +const resumeView = (plan: ResumePlan): DesktopSetupResumeView => ({ + reinitialize: plan.reinitialize, + agents: [...plan.agents], + github: structuredClone(plan.github), + intake: structuredClone(plan.intake), + whitelist: plan.whitelist ? [...plan.whitelist] : null, + repository: plan.repository ? { ...plan.repository } : null, + ...(plan.reconfigurationStage ? { reconfigurationStage: plan.reconfigurationStage } : {}), +}); + +export class DesktopSetupController { + readonly #options: DesktopSetupControllerOptions; + readonly #sessionId: string; + readonly #filesystem = new SetupFilesystemCapabilities(); + readonly #secrets = new SetupSecretCapabilities(); + #abortController: AbortController | null = null; + #activeSecrets: string[] = []; + #busy = false; + #currentRun: Promise | null = null; + #hydration: Promise | null = null; + #persistQueue = Promise.resolve(); + #persistFailed = false; + #resume: ResumePlan | null = null; + #runtimeRetry: ResolvedRequest | null = null; + #result: SetupRunResult | null = null; + #snapshot: DesktopSetupSnapshot; + + constructor(options: DesktopSetupControllerOptions) { + this.#options = options; + this.#sessionId = options.sessionId ?? randomUUID(); + const capability = this.#capability(); + this.#snapshot = { + phase: capability.supported ? 'idle' : 'unsupported', + capability, + sessionId: this.#sessionId, + logs: [], + rootDir: resolve(options.defaultRootDir), + resumeAvailable: false, + ...(capability.supported ? {} : { error: capability.reason }), + }; + } + + async status(): Promise { + await this.#load(); + this.#enforceCapability(false); + return this.#copy(); + } + + async selectPrivateKey(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + await this.#load(); + signal?.throwIfAborted(); + this.#enforceCapability(true); + try { + const selected = await this.#options.selectPrivateKey(signal); + signal?.throwIfAborted(); + const issued = selected ? await this.#filesystem.issue('private-key', this.#sessionId, selected, signal) : null; + signal?.throwIfAborted(); + return issued; + } catch (error) { + if (signal?.aborted) signal.throwIfAborted(); + rethrowCancellation(error); + this.#diagnose('desktop.setup.private_key_selection_failed', { error }); + throw new Error(safeRendererError); + } + } + + async acquireWebhookSecret(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + await this.#load(); + signal?.throwIfAborted(); + this.#enforceCapability(true); + try { + if (!this.#options.promptWebhookSecret) throw new SetupRequestError('A secure native secret prompt is unavailable.'); + const value = await this.#options.promptWebhookSecret(signal); + signal?.throwIfAborted(); + return value === null ? null : this.#secrets.issue(this.#sessionId, value); + } catch (error) { + if (signal?.aborted) signal.throwIfAborted(); + rethrowCancellation(error); + this.#diagnose('desktop.setup.webhook_secret_prompt_failed', { error }); + throw new Error(safeRendererError); + } + } + + start(input: unknown, externalSignal?: AbortSignal): Promise { + return this.#begin(parseDesktopSetupRequest(input), false, externalSignal); + } + + async retry(input?: unknown, externalSignal?: AbortSignal): Promise { + externalSignal?.throwIfAborted(); + await this.#load(); + externalSignal?.throwIfAborted(); + this.#enforceCapability(true); + if (input !== undefined) return this.#begin(parseDesktopSetupRequest(input), true, externalSignal); + if (this.#resume?.reconfigurationStage === 'github' || this.#resume?.reconfigurationStage === 'intake') { + throw new SetupRequestError(`Re-enter the ${this.#resume.reconfigurationStage} configuration before retrying.`); + } + if (this.#runtimeRetry) { + const rootAuthority = RootDirectoryAuthority.open(this.#options.defaultRootDir, true, this.#appDataDir()); + try { + externalSignal?.throwIfAborted(); + return await this.#beginResolved({ ...this.#runtimeRetry, rootDir: resolve(this.#options.defaultRootDir), rootAuthority }, true, externalSignal); + } finally { + if (this.#runtimeRetry?.rootAuthority !== rootAuthority) rootAuthority.close(); + } + } + if (!this.#resume) throw new SetupRequestError('There is no local setup to resume'); + if (this.#resume.reconfigurationStage) throw new SetupRequestError(`Re-enter the ${this.#resume.reconfigurationStage} configuration before retrying.`); + const request = parseDesktopSetupRequest({ + sessionId: this.#sessionId, + root: { mode: 'resume' }, + reinitialize: this.#resume.reinitialize, + agents: this.#resume.agents, + github: this.#resume.github, + intake: this.#resume.intake, + whitelist: this.#resume.whitelist, + repository: this.#resume.repository, + }); + return this.#begin(request, true, externalSignal); + } + + async cancel(): Promise { + this.#abortController?.abort(); + if (this.#currentRun) await this.#currentRun.catch(() => undefined); + return this.#copy(); + } + + async shutdown(): Promise { + this.#abortController?.abort(); + await this.#currentRun?.catch(() => undefined); + await this.#persistQueue; + this.#filesystem.clear(); + this.#secrets.clear(); + this.#runtimeRetry?.rootAuthority.close(); + } + + async #begin(request: DesktopSetupRequest, retry: boolean, externalSignal?: AbortSignal): Promise { + externalSignal?.throwIfAborted(); + await this.#load(); + externalSignal?.throwIfAborted(); + this.#enforceCapability(true); + if (this.#busy || this.#currentRun) throw new SetupRequestError('Local setup is already running'); + this.#busy = true; + let openedAuthority: RootDirectoryAuthority | undefined; + try { + if (request.sessionId !== this.#sessionId) throw new SetupRequestError('The setup session expired. Start again.'); + if (request.github.mode === 'app') { + await this.#filesystem.validate(request.github.privateKeyCapability, 'private-key', this.#sessionId); + externalSignal?.throwIfAborted(); + } + if (request.intake.mode === 'direct_webhook') this.#secrets.validate(request.intake.secretCapability, this.#sessionId); + if (request.root.mode === 'resume' && !this.#resume) throw new SetupRequestError('There is no local setup to resume.'); + const rootDir = resolve(this.#options.defaultRootDir); + const rootAuthority = RootDirectoryAuthority.open(rootDir, true, this.#appDataDir()); + openedAuthority = rootAuthority; + let privateKeyPath: string | undefined; + if (request.github.mode === 'app') { + privateKeyPath = await this.#filesystem.consumePrivateKey( + request.github.privateKeyCapability, + this.#sessionId, + this.#options.keyStorageDir ?? `${this.#options.statePath}.keys`, + externalSignal, + ); + } + externalSignal?.throwIfAborted(); + const webhookSecret = request.intake.mode === 'direct_webhook' + ? this.#secrets.consume(request.intake.secretCapability, this.#sessionId) + : undefined; + externalSignal?.throwIfAborted(); + return await this.#beginResolved({ publicRequest: request, rootDir, rootAuthority, privateKeyPath, webhookSecret }, retry, externalSignal); + } finally { + if (!this.#currentRun) { + if (openedAuthority && this.#runtimeRetry?.rootAuthority !== openedAuthority) openedAuthority.close(); + this.#busy = false; + } + } + } + + async #beginResolved(resolved: ResolvedRequest, retry: boolean, externalSignal?: AbortSignal): Promise { + this.#enforceCapability(true); + if (this.#currentRun) throw new SetupRequestError('Local setup is already running'); + const runController = new AbortController(); + if (externalSignal?.aborted) runController.abort(externalSignal.reason); + else externalSignal?.addEventListener('abort', () => runController.abort(externalSignal.reason), { once: true }); + runController.signal.throwIfAborted(); + this.#busy = true; + if (this.#runtimeRetry && this.#runtimeRetry.rootAuthority !== resolved.rootAuthority) this.#runtimeRetry.rootAuthority.close(); + this.#resume = this.#resumePlan(resolved); + this.#runtimeRetry = resolved; + this.#activeSecrets = [resolved.privateKeyPath, resolved.webhookSecret].filter((value): value is string => Boolean(value)); + this.#abortController = runController; + this.#snapshot = { + phase: 'running', + capability: this.#capability(), + sessionId: this.#sessionId, + rootDir: resolved.rootDir, + state: this.#snapshot.state, + logs: retry ? [...this.#snapshot.logs, 'Retrying setup with a fresh host inspection…'].slice(-200) : [], + resume: resumeView(this.#resume), + resumeAvailable: false, + }; + this.#publish(); + const operation = this.#run(resolved, retry); + this.#currentRun = operation; + try { + return await operation; + } finally { + this.#currentRun = null; + this.#abortController = null; + this.#busy = false; + } + } + + async #run(resolved: ResolvedRequest, retry: boolean): Promise { + const signal = this.#abortController!.signal; + const reporter = { + onState: (state: SetupRunResult['state']) => { + this.#snapshot = { ...this.#snapshot, rootDir: state.rootDir, state }; + this.#publish(); + }, + onLog: (line: string) => { + this.#snapshot = { ...this.#snapshot, logs: [...this.#snapshot.logs, line].slice(-200) }; + this.#publish(); + }, + }; + try { + const result = retry && this.#result + ? await retrySetup(this.#result, { actions: this.#boundActions(resolved), prompts: this.#prompts(resolved), reporter, platform: this.#platform(), signal }) + : await runSetup({ root: resolved.rootDir, actions: this.#boundActions(resolved), prompts: this.#prompts(resolved), reporter, platform: this.#platform(), signal }); + this.#result = result; + signal.throwIfAborted(); + let profile: DesktopProfileView | undefined; + if (result.completed) { + resolved.rootAuthority.validate(); + const apiBaseUrl = await this.#options.resolveApiBaseUrl(resolved.rootDir, signal); + resolved.rootAuthority.validate(); + signal.throwIfAborted(); + profile = await this.#options.registerProfile({ name: 'This computer', apiBaseUrl }, signal); + signal.throwIfAborted(); + } + this.#snapshot = { ...this.#snapshot, phase: terminalPhase(result), rootDir: result.rootDir, state: result.state, errors: result.errors, profile }; + } catch (error) { + const cleanupIncomplete = isCleanupIncomplete(error); + const cancelled = signal.aborted && !cleanupIncomplete; + if (!cancelled) this.#diagnose('desktop.setup.run_failed', { error }); + this.#snapshot = { + ...this.#snapshot, + phase: cancelled ? 'cancelled' : 'failed', + error: cancelled ? 'Setup was cancelled.' : cleanupIncomplete ? cleanupIncompleteRendererError : safeRendererError, + }; + } + this.#publish(); + await this.#persistQueue; + return this.#copy(); + } + + #prompts(resolved: ResolvedRequest) { + const request = resolved.publicRequest; + return { + resolveStackRoot: async () => ({ rootDir: resolved.rootDir, reinitialize: request.reinitialize }), + selectAgents: async () => [...request.agents], + configureGithubAuth: async (): Promise => { + switch (request.github.mode) { + case 'keep': return { keep: true }; + case 'demo': return { mode: 'demo', vars: { PROPR_DEMO_MODE: 'true' } }; + case 'relay': return { mode: 'relay', enrollRelay: { relayUrl: DEFAULT_PROPR_GH_RELAY_URL } }; + case 'app': + if (!resolved.privateKeyPath) throw new SetupRequestError('Select the GitHub App private key again.'); + return { mode: 'app', vars: { PROPR_DEMO_MODE: 'false', GH_AUTH_MODE: 'app', GH_APP_ID: request.github.appId, HOST_GH_PRIVATE_KEY: resolved.privateKeyPath, GH_INSTALLATION_ID: request.github.installationId } }; + } + }, + confirmGithubLogin: async () => true, + confirmGithubAppInstall: async () => true, + confirmGithubAppInstalled: async () => false, + configureIntake: async () => request.intake.mode === 'keep' ? { keep: true } : request.intake.mode === 'direct_webhook' + ? { mode: request.intake.mode, webhookSecret: resolved.webhookSecret } + : { mode: request.intake.mode }, + confirmStartStack: async () => true, + confirmAgentLogin: async ({ candidates }: { candidates: string[] }) => candidates.filter(candidate => request.agents.includes(candidate)), + configureWhitelist: async () => request.whitelist, + addRepository: async () => request.repository, + launchUi: async () => false, + }; + } + + #boundActions(resolved: ResolvedRequest): SetupActions { + return bindRootOperations(this.#options.actions, resolved.rootDir, resolved.rootAuthority); + } + + #resumePlan(resolved: ResolvedRequest): ResumePlan { + const request = resolved.publicRequest; + const github: ResumePlan['github'] = request.github.mode === 'app' + ? { mode: 'app', appId: request.github.appId, installationId: request.github.installationId, reconfigurationRequired: true } + : structuredClone(request.github); + const intake: ResumePlan['intake'] = request.intake.mode === 'direct_webhook' + ? { mode: 'direct_webhook', reconfigurationRequired: true } + : structuredClone(request.intake); + return { + reinitialize: request.reinitialize, + agents: [...request.agents], + github, + intake, + whitelist: request.whitelist ? [...request.whitelist] : null, + repository: request.repository ? { ...request.repository } : null, + ...(request.github.mode === 'app' ? { reconfigurationStage: 'github' as const } : request.intake.mode === 'direct_webhook' ? { reconfigurationStage: 'intake' as const } : {}), + }; + } + + #appDataDir(): string { + return resolve(this.#options.appDataDir ?? dirname(this.#options.defaultRootDir)); + } + + #platform(): NodeJS.Platform { + return this.#options.platform ?? process.platform; + } + + #capability() { + return getLocalSetupCapability(this.#platform()); + } + + #enforceCapability(throwWhenUnsupported: boolean): void { + const capability = this.#capability(); + this.#snapshot = { ...this.#snapshot, capability, sessionId: this.#sessionId, phase: capability.supported ? this.#snapshot.phase === 'unsupported' ? 'idle' : this.#snapshot.phase : 'unsupported', ...(capability.supported ? {} : { error: capability.reason }) }; + if (!capability.supported && throwWhenUnsupported) throw new SetupRequestError(capability.reason); + } + + async #load(): Promise { + this.#hydration ??= this.#hydrate(); + await this.#hydration; + } + + async #hydrate(): Promise { + try { + const contents = readPrivateFile(this.#options.statePath); + if (!contents) throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + const parsed = parsePersisted(contents.toString('utf8')); + if (parsed.rootDir !== resolve(this.#options.defaultRootDir)) throw new Error('Saved setup root is not the fixed desktop runtime root'); + this.#resume = parsed.resume; + const interrupted = parsed.phase === 'running'; + this.#snapshot = { + ...this.#snapshot, + phase: interrupted ? 'interrupted' : parsed.phase, + rootDir: parsed.rootDir, + logs: [], + resume: resumeView(parsed.resume), + resumeAvailable: true, + reconfigurationRequired: Boolean(parsed.resume.reconfigurationStage), + ...(interrupted ? { error: 'Setup was interrupted when ProPR Desktop closed. Review the saved choices to continue.' } : {}), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + this.#diagnose('desktop.setup.hydration_failed', { error }); + this.#snapshot = { ...this.#snapshot, resumeAvailable: false, error: 'Previous setup progress could not be loaded. Resume is unavailable.' }; + } + } + this.#enforceCapability(false); + } + + #publish(): void { + this.#options.emit(this.#copy()); + if (!this.#resume || this.#persistFailed) return; + const persisted: PersistedSetupState = { + version: 3, + phase: this.#snapshot.phase === 'unsupported' ? 'idle' : this.#snapshot.phase, + rootDir: resolve(this.#options.defaultRootDir), + lastStepId: this.#snapshot.state?.steps.find(step => step.status === 'active')?.id, + resume: this.#resume, + }; + this.#persistQueue = this.#persistQueue.then(async () => { + const signal = this.#abortController?.signal; + signal?.throwIfAborted(); + // PersistedSetupState is an allowlisted, secret-free main-process schema. + // Keep its fixed root usable for hydration; renderer copies and desktop + // diagnostics apply path redaction independently. + writePrivateFileAtomic(this.#options.statePath, `${JSON.stringify(persisted, null, 2)}\n`, { signal }); + this.#snapshot = { ...this.#snapshot, resumeAvailable: true }; + }).catch(error => { + if ((error as Error).name === 'AbortError' || (error as NodeJS.ErrnoException).code === 'ABORT_ERR') return; + this.#persistFailed = true; + this.#diagnose('desktop.setup.persistence_failed', { error }); + this.#snapshot = { ...this.#snapshot, resumeAvailable: false, error: 'Setup progress could not be saved. Resume after restart is unavailable.' }; + this.#options.emit(this.#copy()); + }); + } + + #copy(): DesktopSetupSnapshot { + return redactDesktopValue(structuredClone(this.#snapshot), 0, this.#activeSecrets) as DesktopSetupSnapshot; + } + + #diagnose(event: string, fields: Record): void { + this.#options.diagnose?.(event, redactDesktopValue(fields, 0, this.#activeSecrets) as Record); + } +} diff --git a/apps/desktop/src/setup-schema.ts b/apps/desktop/src/setup-schema.ts new file mode 100644 index 000000000..bdab7e13e --- /dev/null +++ b/apps/desktop/src/setup-schema.ts @@ -0,0 +1,83 @@ +import type { DesktopSetupRequest } from './shared/contract'; + +const AGENTS = new Set(['claude', 'codex', 'antigravity', 'opencode', 'vibe']); +const CAPABILITY = /^[A-Za-z0-9_-]{32,128}$/; +const SESSION = /^[0-9a-f]{8}-[0-9a-f-]{27,40}$/i; +const INTEGER = /^[1-9][0-9]{0,19}$/; +const USERNAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/; +const REPOSITORY_NAME = /^[A-Za-z0-9_.-]{1,100}$/; +const BRANCH = /^(?!\/|.*(?:\.\.|@\{|\\|\s|[~^:?*\[]|\/\/|\.$|\.lock$))[A-Za-z0-9._/-]{1,255}$/; +const ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +export class SetupRequestError extends Error { + constructor(message = 'Invalid local setup request') { + super(message); + this.name = 'SetupRequestError'; + } +} + +const record = (value: unknown): Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new SetupRequestError(); + return value as Record; +}; + +const exact = (value: Record, required: string[], optional: string[] = []): void => { + const allowed = new Set([...required, ...optional]); + if (required.some(key => !(key in value)) || Object.keys(value).some(key => !allowed.has(key))) throw new SetupRequestError(); +}; + +const bounded = (value: unknown, max: number): value is string => typeof value === 'string' && value.length > 0 && value.length <= max; + +export const parseDesktopSetupRequest = (input: unknown): DesktopSetupRequest => { + const value = record(input); + exact(value, ['sessionId', 'root', 'reinitialize', 'agents', 'github', 'intake', 'whitelist', 'repository']); + if (typeof value.sessionId !== 'string' || !SESSION.test(value.sessionId)) throw new SetupRequestError(); + if (typeof value.reinitialize !== 'boolean') throw new SetupRequestError(); + + const root = record(value.root); + if (root.mode === 'default' || root.mode === 'resume') exact(root, ['mode']); + else throw new SetupRequestError(); + + const agents = value.agents; + if (!Array.isArray(agents) || agents.length > AGENTS.size || !agents.every(item => typeof item === 'string' && AGENTS.has(item)) || new Set(agents).size !== agents.length) { + throw new SetupRequestError('Invalid agent selection'); + } + + const github = record(value.github); + switch (github.mode) { + case 'keep': case 'demo': case 'relay': exact(github, ['mode']); break; + case 'app': + exact(github, ['mode', 'appId', 'privateKeyCapability', 'installationId']); + if (!bounded(github.appId, 20) || !INTEGER.test(github.appId) || !bounded(github.installationId, 20) || !INTEGER.test(github.installationId) + || typeof github.privateKeyCapability !== 'string' || !CAPABILITY.test(github.privateKeyCapability)) throw new SetupRequestError('Invalid GitHub App configuration'); + break; + default: throw new SetupRequestError('Invalid GitHub configuration'); + } + + const intake = record(value.intake); + if (intake.mode === 'keep' || intake.mode === 'routing_websocket' || intake.mode === 'polling') exact(intake, ['mode']); + else if (intake.mode === 'direct_webhook') { + exact(intake, ['mode', 'secretCapability']); + if (typeof intake.secretCapability !== 'string' || !CAPABILITY.test(intake.secretCapability)) throw new SetupRequestError('Invalid webhook secret capability'); + } else throw new SetupRequestError('Invalid GitHub intake configuration'); + if ((github.mode === 'relay' && intake.mode === 'direct_webhook') + || (github.mode === 'app' && intake.mode === 'routing_websocket') + || (github.mode === 'demo' && intake.mode !== 'keep')) throw new SetupRequestError('GitHub intake mode is incompatible with the selected authentication mode'); + + if (value.whitelist !== null && (!Array.isArray(value.whitelist) || value.whitelist.length > 100 + || !value.whitelist.every(item => typeof item === 'string' && USERNAME.test(item)) || new Set(value.whitelist.map(item => item.toLowerCase())).size !== value.whitelist.length)) { + throw new SetupRequestError('Invalid GitHub whitelist'); + } + + if (value.repository !== null) { + const repository = record(value.repository); + exact(repository, ['fullName'], ['alias', 'baseBranch']); + const [owner, name, extra] = typeof repository.fullName === 'string' ? repository.fullName.split('/') : []; + if (!bounded(repository.fullName, 140) || extra !== undefined || !owner || !USERNAME.test(owner) || !name || !REPOSITORY_NAME.test(name) || name === '.' || name === '..' + || (repository.alias !== undefined && (typeof repository.alias !== 'string' || !ALIAS.test(repository.alias))) + || (repository.baseBranch !== undefined && (typeof repository.baseBranch !== 'string' || !BRANCH.test(repository.baseBranch)))) { + throw new SetupRequestError('Invalid repository selection'); + } + } + return structuredClone(value) as unknown as DesktopSetupRequest; +}; diff --git a/apps/desktop/src/setup-security.test.ts b/apps/desktop/src/setup-security.test.ts new file mode 100644 index 000000000..2cb1dea61 --- /dev/null +++ b/apps/desktop/src/setup-security.test.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { chmod, mkdtemp, rename, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { SetupFilesystemCapabilities, SetupSecretCapabilities } from './setup-capabilities'; +import { parseDesktopSetupRequest } from './setup-schema'; + +const sessionId = '00000000-0000-4000-8000-000000000000'; +const baseRequest = () => ({ + sessionId, + root: { mode: 'default' }, + reinitialize: false, + agents: ['codex'], + github: { mode: 'relay' }, + intake: { mode: 'routing_websocket' }, + whitelist: ['octocat'], + repository: { fullName: 'integry/propr', alias: 'propr', baseBranch: 'main' }, +}); + +describe('desktop setup request schema', () => { + it('accepts the complete bounded discriminated shape and rejects unknown or mode-forbidden fields', () => { + assert.equal(parseDesktopSetupRequest(baseRequest()).github.mode, 'relay'); + assert.throws(() => parseDesktopSetupRequest({ ...baseRequest(), relayUrl: 'https://attacker.invalid' })); + assert.throws(() => parseDesktopSetupRequest({ ...baseRequest(), github: { mode: 'relay', relayUrl: 'https://attacker.invalid?token=x' } })); + assert.throws(() => parseDesktopSetupRequest({ ...baseRequest(), agents: ['shell-agent'] })); + assert.throws(() => parseDesktopSetupRequest({ ...baseRequest(), reinitialize: 'yes' })); + assert.throws(() => parseDesktopSetupRequest({ ...baseRequest(), whitelist: ['bad user'] })); + assert.throws(() => parseDesktopSetupRequest({ ...baseRequest(), repository: { fullName: '../escape' } })); + assert.throws(() => parseDesktopSetupRequest({ ...baseRequest(), root: { mode: 'selected', capability: '/forged/path' } })); + assert.throws(() => parseDesktopSetupRequest({ ...baseRequest(), intake: { mode: 'polling', webhookSecret: 'forbidden' } })); + assert.throws(() => parseDesktopSetupRequest({ ...baseRequest(), github: { mode: 'app', appId: '1', installationId: '2', privateKeyCapability: 'A'.repeat(43) }, intake: { mode: 'routing_websocket' } })); + }); +}); + +describe('desktop setup filesystem capabilities', () => { + it('binds an exact canonical private key to one session and rejects replay or path switching', async () => { + const parent = await mkdtemp(join(tmpdir(), 'propr-capability-')); + const selected = join(parent, 'selected.pem'); + await writeFile(selected, 'private key', { mode: 0o600 }); + const capabilities = new SetupFilesystemCapabilities(); + const issued = await capabilities.issue('private-key', sessionId, selected); + await assert.rejects(capabilities.validate(issued.capability, 'private-key', '11111111-1111-4111-8111-111111111111')); + assert.equal(await capabilities.validate(issued.capability, 'private-key', sessionId), selected); + capabilities.consume([issued.capability]); + await assert.rejects(capabilities.validate(issued.capability, 'private-key', sessionId)); + + const switched = await capabilities.issue('private-key', sessionId, selected); + await rename(selected, `${selected}-old`); + await writeFile(selected, 'replacement key', { mode: 0o600 }); + await assert.rejects(capabilities.validate(switched.capability, 'private-key', sessionId)); + }); + + it('expires unused capabilities after a short bounded lifetime', async () => { + const parent = await mkdtemp(join(tmpdir(), 'propr-expired-capability-')); + const selected = join(parent, 'selected.pem'); + await writeFile(selected, 'private key', { mode: 0o600 }); + let now = 1_000; + const capabilities = new SetupFilesystemCapabilities(() => now); + const issued = await capabilities.issue('private-key', sessionId, selected); + now += 5 * 60_000 + 1; + await assert.rejects(capabilities.validate(issued.capability, 'private-key', sessionId)); + }); + + it('rejects symlinks, non-regular key files, and unsafe private-key permissions', async () => { + const parent = await mkdtemp(join(tmpdir(), 'propr-key-capability-')); + const key = join(parent, 'github-app.pem'); + await writeFile(key, 'private material', { mode: 0o644 }); + const capabilities = new SetupFilesystemCapabilities(); + await assert.rejects(capabilities.issue('private-key', sessionId, key), /group or other/); + await chmod(key, 0o600); + const issued = await capabilities.issue('private-key', sessionId, key); + assert.equal(issued.label, 'github-app.pem'); + const link = join(parent, 'linked.pem'); + await symlink(key, link); + await assert.rejects(capabilities.issue('private-key', sessionId, link), /Symbolic-link/); + await assert.rejects(capabilities.issue('private-key', sessionId, parent)); + }); +}); + +describe('desktop setup secret capabilities', () => { + it('is opaque, expiring, session-bound, single-use, and rejects forgery/replay', () => { + const sentinel = 'SENTINEL_SECRET_CAPABILITY_VALUE'; + let now = 1_000; + const secrets = new SetupSecretCapabilities(() => now); + const issued = secrets.issue(sessionId, sentinel); + assert.doesNotMatch(JSON.stringify(issued), new RegExp(sentinel)); + assert.throws(() => secrets.consume('A'.repeat(43), sessionId)); + assert.throws(() => secrets.consume(issued.capability, '11111111-1111-4111-8111-111111111111')); + const fresh = secrets.issue(sessionId, sentinel); + assert.equal(secrets.consume(fresh.capability, sessionId), sentinel); + assert.throws(() => secrets.consume(fresh.capability, sessionId)); + const expired = secrets.issue(sessionId, sentinel); + now += 5 * 60_000 + 1; + assert.throws(() => secrets.consume(expired.capability, sessionId)); + }); +}); diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index f34d23298..d70f5afd1 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -9,13 +9,18 @@ export const IPC_CHANNELS = Object.freeze({ profilesSave: 'desktop:profiles-save', profilesRemove: 'desktop:profiles-remove', profilesSetActive: 'desktop:profiles-set-active', - credentialsRead: 'desktop:credentials-read', - credentialsWrite: 'desktop:credentials-write', - credentialsRemove: 'desktop:credentials-remove', lifecycleStatus: 'desktop:lifecycle-status', lifecycleStart: 'desktop:lifecycle-start', lifecycleStop: 'desktop:lifecycle-stop', lifecycleRestart: 'desktop:lifecycle-restart', + discovery: 'desktop:discovery', + setupStatus: 'desktop:setup-status', + setupStart: 'desktop:setup-start', + setupRetry: 'desktop:setup-retry', + setupCancel: 'desktop:setup-cancel', + setupSelectPrivateKey: 'desktop:setup-select-private-key', + setupAcquireWebhookSecret: 'desktop:setup-acquire-webhook-secret', + setupProgress: 'desktop:setup-progress', deepLink: 'desktop:deep-link', } as const); @@ -58,14 +63,6 @@ export type StorageSecurity = { reason: 'os-encryption-unavailable' | 'insecure-basic-text-backend'; }; -export type CredentialReadResult = - | { available: false; value: null } - | { available: true; value: string | null }; - -export type CredentialWriteResult = - | { stored: true } - | { stored: false; reason: 'encryption-unavailable' }; - export type LocalLifecycleState = 'disconnected' | 'starting' | 'connected' | 'stopping' | 'error'; export interface LocalLifecycleStatus { @@ -97,11 +94,6 @@ export interface DesktopBridge { remove(profileId: string): Promise; setActive(profileId: string | null): Promise; }; - credentials: { - read(profileId: string): Promise; - write(profileId: string, value: string): Promise; - remove(profileId: string): Promise; - }; lifecycle: { status(): Promise; start(): Promise; @@ -109,3 +101,108 @@ export interface DesktopBridge { restart(): Promise; }; } + +export type DesktopPlatformView = 'macos' | 'windows' | 'linux'; + +/** Renderer profile shape used by the shared desktop presentation layer. */ +export interface DesktopProfileView { + id: string; + name: string; + baseUrl: string; + kind: 'local' | 'remote'; + lastConnectedAt?: string; +} + +export type DesktopConnectionResult = + | { status: 'ready'; version?: string } + | { status: 'authentication-required'; message?: string } + | { status: 'incompatible'; message: string; version?: string } + | { status: 'offline'; message: string }; + +export interface DesktopSetupRequest { + sessionId: string; + root: { mode: 'default' | 'resume' }; + reinitialize: boolean; + agents: string[]; + github: + | { mode: 'keep' } + | { mode: 'demo' } + | { mode: 'relay' } + | { mode: 'app'; appId: string; privateKeyCapability: string; installationId: string }; + intake: + | { mode: 'keep' } + | { mode: 'routing_websocket' | 'polling' } + | { mode: 'direct_webhook'; secretCapability: string }; + whitelist: string[] | null; + repository: { fullName: string; alias?: string; baseBranch?: string } | null; +} + +export interface DesktopFilesystemSelection { + capability: string; + label: string; +} + +export interface DesktopSecretSelection { + capability: string; + label: 'Secret entered'; +} + +export interface DesktopSetupResumeView { + agents: string[]; + reinitialize: boolean; + github: { mode: 'keep' | 'demo' | 'relay' } | { mode: 'app'; appId: string; installationId: string; reconfigurationRequired: true }; + intake: { mode: 'keep' | 'routing_websocket' | 'polling' } | { mode: 'direct_webhook'; reconfigurationRequired: true }; + whitelist: string[] | null; + repository: { fullName: string; alias?: string; baseBranch?: string } | null; + reconfigurationStage?: 'github' | 'intake'; +} + +export type DesktopSetupPhase = + | 'idle' + | 'running' + | 'interrupted' + | 'cancelled' + | 'failed' + | 'completed' + | 'unsupported'; + +export interface DesktopSetupSnapshot { + phase: DesktopSetupPhase; + capability: import('@propr/local-setup').LocalSetupCapability; + sessionId: string; + rootDir?: string; + state?: import('@propr/local-setup').SetupState; + logs: string[]; + errors?: import('@propr/local-setup').SetupStructuredError[]; + error?: string; + profile?: DesktopProfileView; + resume?: DesktopSetupResumeView; + resumeAvailable?: boolean; + reconfigurationRequired?: boolean; +} + +/** Narrow bridge consumed by `propr-ui/src/desktop`. */ +export interface DesktopRendererBridge { + isDesktop: true; + platform: DesktopPlatformView; + profiles: { + list(): Promise; + save(profile: DesktopProfileView): Promise; + remove(profileId: string): Promise; + getActiveId(): Promise; + setActiveId(profileId: string | null): Promise; + }; + discovery: { discover(): Promise }; + authentication: { authenticate(profile: DesktopProfileView): Promise }; + externalBrowser: { open(url: string): Promise }; + localSetup: { + status(): Promise; + start(request: DesktopSetupRequest): Promise; + retry(request?: DesktopSetupRequest): Promise; + cancel(): Promise; + selectPrivateKey(): Promise; + acquireWebhookSecret(): Promise; + onProgress(listener: (snapshot: DesktopSetupSnapshot) => void): () => void; + }; + connection: { probe(profile: DesktopProfileView): Promise }; +} diff --git a/docker/launcher/orchestrator.mjs b/docker/launcher/orchestrator.mjs index 311e458d8..9974ca6bc 100644 --- a/docker/launcher/orchestrator.mjs +++ b/docker/launcher/orchestrator.mjs @@ -18,7 +18,7 @@ // The CLI imports this .mjs dynamically and types it via src/orchestrator/types.ts. import { spawn, spawnSync } from 'node:child_process'; -import { createECDH, timingSafeEqual } from 'node:crypto'; +import { createECDH, randomUUID, timingSafeEqual } from 'node:crypto'; import { readFileSync, existsSync, statSync, accessSync, constants as fsConstants } from 'node:fs'; import { homedir } from 'node:os'; import { resolve, dirname, isAbsolute, join } from 'node:path'; @@ -247,14 +247,15 @@ export function resolveConfig(env = process.env, overrides = {}) { const network = overrides.network ?? env.PROPR_NETWORK ?? `${stack}-net`; const envFileLocal = overrides.envFileLocal ?? env.PROPR_LAUNCHER_ENV_FILE ?? '/app/.env'; const envFileHost = overrides.envFileHost ?? env.PROPR_ENV_FILE; + const envFileRead = overrides.envFileRead ?? envFileLocal; // NODE_ENV is special: Docker receives it from the stack's --env-file, not // from the CLI/launcher process environment. Inspect that exact source so a // developer's shell NODE_ENV cannot accidentally describe (or alter) the // packaged container runtime. - const nodeEnv = readEnvFile(envFileLocal).NODE_ENV || undefined; + const nodeEnv = readEnvFile(envFileRead).NODE_ENV || undefined; // value precedence: explicit override → process env → .env file - const get = (name) => env[name] !== undefined ? env[name] : envFileValueFrom(envFileLocal, name) || undefined; + const get = (name) => env[name] !== undefined ? env[name] : envFileValueFrom(envFileRead, name) || undefined; const hostData = overrides.hostData ?? env.PROPR_DATA_DIR; const hostLogs = overrides.hostLogs ?? env.PROPR_LOGS_DIR; @@ -386,10 +387,11 @@ export function resolveConfig(env = process.env, overrides = {}) { * `cliOverrides` lets the CLI pass in persisted config (e.g. docsEnabled from * ConfigManager) that should take precedence over env/defaults. */ -export function resolveHostConfig({ rootDir = process.cwd(), env = process.env, manifestPath, cliOverrides = {} } = {}) { +export function resolveHostConfig({ rootDir = process.cwd(), readRootDir = rootDir, env = process.env, manifestPath, cliOverrides = {} } = {}) { return resolveConfig(env, { envFileLocal: join(rootDir, '.env'), envFileHost: join(rootDir, '.env'), + envFileRead: join(readRootDir, '.env'), hostData: join(rootDir, 'data'), hostLogs: join(rootDir, 'logs'), hostRepos: join(rootDir, 'repos'), @@ -527,29 +529,86 @@ export function docker(args, { capture = false, timeout } = {}) { * On timeout it kills the child and reports an ETIMEDOUT error, matching the * spawnSync timeout contract that `dockerError` inspects. */ -export function dockerAsync(args, { timeout } = {}) { +export function dockerAsync(args, { timeout, signal, maxOutputBytes } = {}) { return new Promise((resolveResult) => { - const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + if (signal?.aborted) { + resolveResult({ status: null, stdout: '', stderr: '', error: Object.assign(new Error('docker command cancelled'), { code: 'ABORT_ERR' }) }); + return; + } + // A separate process group lets cancellation terminate docker and every + // helper it spawned. Windows uses taskkill /T as the equivalent tree kill. + const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' }); let stdout = ''; let stderr = ''; + let stdoutBytes = 0; + let stderrBytes = 0; + let stdoutTruncated = false; + let stderrTruncated = false; let settled = false; let timeoutError = null; const finish = (res) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); - resolveResult(res); + if (killTimer) clearTimeout(killTimer); + signal?.removeEventListener('abort', abort); + resolveResult({ + ...res, + ...(stdoutTruncated ? { stdoutTruncated: true } : {}), + ...(stderrTruncated ? { stderrTruncated: true } : {}), + }); + }; + const appendOutput = (chunk, stream) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 0) { + if (stream === 'stdout') stdout += buffer.toString(); + else stderr += buffer.toString(); + return; + } + const used = stream === 'stdout' ? stdoutBytes : stderrBytes; + const remaining = Math.max(0, maxOutputBytes - used); + const captured = buffer.subarray(0, remaining); + if (stream === 'stdout') { + stdout += captured.toString(); + stdoutBytes += captured.length; + if (captured.length < buffer.length) stdoutTruncated = true; + } else { + stderr += captured.toString(); + stderrBytes += captured.length; + if (captured.length < buffer.length) stderrTruncated = true; + } + }; + const killTree = (force = false) => { + if (!child.pid) return; + if (process.platform === 'win32') { + const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', ...(force ? ['/F'] : [])], { stdio: 'ignore' }); + killer.unref(); + } else { + try { process.kill(-child.pid, force ? 'SIGKILL' : 'SIGTERM'); } catch { child.kill(force ? 'SIGKILL' : 'SIGTERM'); } + } + }; + let cancellationError = null; + let killTimer = null; + const abort = () => { + cancellationError = Object.assign(new Error('docker command cancelled'), { code: 'ABORT_ERR' }); + killTree(false); + killTimer = setTimeout(() => { + killTree(true); + killTimer = setTimeout(() => finish({ status: null, stdout, stderr, error: cancellationError }), 2_000); + }, 2_000); }; const timer = timeout ? setTimeout(() => { timeoutError = Object.assign(new Error('docker command timed out'), { code: 'ETIMEDOUT' }); - child.kill('SIGKILL'); + killTree(true); + killTimer = setTimeout(() => finish({ status: null, stdout, stderr, error: timeoutError }), 2_000); }, timeout) : null; - child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); - child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + child.stdout.on('data', (chunk) => appendOutput(chunk, 'stdout')); + child.stderr.on('data', (chunk) => appendOutput(chunk, 'stderr')); + signal?.addEventListener('abort', abort, { once: true }); child.on('error', (error) => finish({ status: null, stdout, stderr, error })); - child.on('close', (code, signal) => finish({ status: code, stdout, stderr, signal, error: timeoutError || undefined })); + child.on('close', (code, exitSignal) => finish({ status: code, stdout, stderr, signal: exitSignal, error: cancellationError || timeoutError || undefined })); }); } @@ -589,6 +648,14 @@ export function tagAgentLatest(key, imageTag) { } } +export async function tagAgentLatestAsync(key, imageTag, signal) { + if (key !== 'agent') return; + const latestTag = latestTagFor(imageTag); + if (!latestTag || latestTag === imageTag) return; + const res = await dockerAsync(['tag', imageTag, latestTag], { signal }); + if (res.status !== 0) throw new Error(`Failed to tag ${imageTag} as ${latestTag}: ${res.stderr}`); +} + export function containerExists(cfg, name) { const res = docker(['ps', '-a', '--filter', `name=^${name}$`, '--format', '{{.Names}}'], { capture: true }); return res.stdout.trim() === name; @@ -614,6 +681,17 @@ function imagePresentLocally(tag) { return res.stdout.trim().length > 0; } +async function imagePresentLocallyAsync(tag, signal) { + const res = await dockerAsync(['images', '-q', tag], { signal }); + throwIfCancelledResult(res, signal); + return res.stdout.trim().length > 0; +} + +function throwIfCancelledResult(result, signal) { + signal?.throwIfAborted(); + if (result?.error?.code === 'ABORT_ERR' || result?.error?.name === 'AbortError') throw result.error; +} + function firstLine(value) { return (value || '').trim().split('\n')[0] || ''; } @@ -636,6 +714,20 @@ function localRepoDigests(tag) { } } +async function localRepoDigestsAsync(tag, signal) { + const res = await dockerAsync(['image', 'inspect', '--format', '{{json .RepoDigests}}', tag], { signal }); + throwIfCancelledResult(res, signal); + if (res.status !== 0) return null; + try { + const parsed = JSON.parse(res.stdout.trim() || '[]'); + return Array.isArray(parsed) ? parsed.map(normalizeDigest).filter(Boolean) : []; + } catch (error) { + signal?.throwIfAborted(); + if (error?.code === 'ABORT_ERR' || error?.name === 'AbortError') throw error; + return []; + } +} + export function remoteDigestFromManifestInspectOutput(output) { return remoteDigestsFromManifestInspectOutput(output)[0] ?? null; } @@ -764,8 +856,9 @@ export function inspectImageFreshness(tag, { skipRemoteCheck = false } = {}) { } /** Async mirror of remoteManifestDigest using non-blocking docker exec. */ -async function remoteManifestDigestAsync(tag) { - const res = await dockerAsync(['manifest', 'inspect', '--verbose', tag], { timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS }); +async function remoteManifestDigestAsync(tag, signal) { + const res = await dockerAsync(['manifest', 'inspect', '--verbose', tag], { timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS, signal }); + throwIfCancelledResult(res, signal); if (res.status !== 0) { return { ok: false, error: dockerError(res, 'docker manifest inspect failed') }; } @@ -774,13 +867,15 @@ async function remoteManifestDigestAsync(tag) { if (digests.length > 0) { let allDigests = digests; if (res.stdout.trim().startsWith('[')) { - const buildx = await dockerAsync(['buildx', 'imagetools', 'inspect', tag], { timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS }); + const buildx = await dockerAsync(['buildx', 'imagetools', 'inspect', tag], { timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS, signal }); + throwIfCancelledResult(buildx, signal); if (buildx.status === 0) allDigests = appendDigest(allDigests, remoteDigestFromImagetoolsInspectOutput(buildx.stdout)); } return { ok: true, digests: allDigests, digest: allDigests[0] }; } - const buildx = await dockerAsync(['buildx', 'imagetools', 'inspect', tag], { timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS }); + const buildx = await dockerAsync(['buildx', 'imagetools', 'inspect', tag], { timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS, signal }); + throwIfCancelledResult(buildx, signal); if (buildx.status !== 0) { return { ok: false, error: dockerError(buildx, 'docker buildx imagetools inspect failed') }; } @@ -788,7 +883,9 @@ async function remoteManifestDigestAsync(tag) { if (buildxDigest) return { ok: true, digests: [buildxDigest], digest: buildxDigest }; return { ok: false, error: 'remote manifest digest was not available from docker manifest inspect or docker buildx imagetools inspect' }; - } catch { + } catch (error) { + signal?.throwIfAborted(); + if (error?.code === 'ABORT_ERR' || error?.name === 'AbortError') throw error; return { ok: false, error: 'could not parse docker manifest inspect output' }; } } @@ -798,12 +895,12 @@ async function remoteManifestDigestAsync(tag) { * synchronous; only the remote registry probe is awaited, so many tags can be * checked concurrently without blocking the event loop. */ -export async function inspectImageFreshnessAsync(tag, { skipRemoteCheck = false } = {}) { - if (!imagePresentLocally(tag)) { +export async function inspectImageFreshnessAsync(tag, { skipRemoteCheck = false, signal } = {}) { + if (!(await imagePresentLocallyAsync(tag, signal))) { return { status: 'missing', tag }; } - const localDigests = localRepoDigests(tag); + const localDigests = await localRepoDigestsAsync(tag, signal); if (!localDigests) { return { status: 'unknown', tag, error: 'local image metadata could not be inspected' }; } @@ -816,7 +913,7 @@ export async function inspectImageFreshnessAsync(tag, { skipRemoteCheck = false return { status: 'unknown', tag, localDigests, localOnly: true, error: 'local image has no registry digest; pull the tag to verify freshness' }; } - return classifyImageFreshness(tag, localDigests, await remoteManifestDigestAsync(tag)); + return classifyImageFreshness(tag, localDigests, await remoteManifestDigestAsync(tag, signal)); } function cachedImageFreshness(cache, tag, opts) { @@ -1166,13 +1263,14 @@ export function startStack(cfg, { ui = true, docs = cfg.docsEnabled, tunnel = cf return getStackStatus(cfg); } -function migrationDockerArgs(cfg) { +function migrationDockerArgs(cfg, setupRunId) { const spec = migrationSpec(cfg); return [ 'run', '--rm', '--init', '--name', `${cfg.stack}-migrate`, '--network', cfg.network, '--label', `propr.stack=${cfg.stack}`, '--label', 'propr.service=migrate', + ...(setupRunId ? ['--label', `propr.setup-run=${setupRunId}`] : []), ...spec.args, spec.image, ...spec.command, @@ -1272,96 +1370,104 @@ export function runMigrationPhase(cfg, { onLog, freshnessCache } = {}) { // one, change the other. // --------------------------------------------------------------------------- -async function containerExistsAsync(cfg, name) { - const res = await dockerAsync(['ps', '-a', '--filter', `name=^${name}$`, '--format', '{{.Names}}']); +async function containerExistsAsync(cfg, name, signal) { + const res = await dockerAsync(['ps', '-a', '--filter', `name=^${name}$`, '--format', '{{.Names}}'], { signal }); return res.stdout.trim() === name; } -async function removeIfExistsAsync(cfg, name, onLog) { - if (await containerExistsAsync(cfg, name)) { +async function removeIfExistsAsync(cfg, name, onLog, signal) { + if (await containerExistsAsync(cfg, name, signal)) { onLog?.(` · removing stale ${name}`); - await dockerAsync(['rm', '-f', name]); + await dockerAsync(['rm', '-f', name], { signal }); } } -async function containerRunningAsync(cfg, name) { - const res = await dockerAsync(['ps', '--filter', `name=^${name}$`, '--format', '{{.Names}}']); +async function containerRunningAsync(cfg, name, signal) { + const res = await dockerAsync(['ps', '--filter', `name=^${name}$`, '--format', '{{.Names}}'], { signal }); if (res.status !== 0) { throw new Error(`Cannot safely inspect ${name} before database migration: ${firstLine(res.stderr || res.error?.message || 'docker ps failed')}`); } return res.stdout.trim().split('\n').includes(name); } -async function assertNoLiveMigrationOwnerAsync(cfg, service) { +async function assertNoLiveMigrationOwnerAsync(cfg, service, signal) { if (!DATABASE_SERVICES.has(service)) return; const migrationName = `${cfg.stack}-migrate`; - if (await containerRunningAsync(cfg, migrationName)) { + if (await containerRunningAsync(cfg, migrationName, signal)) { throw new Error(`Refusing to start ${cfg.stack}-${service} while database migration owner ${migrationName} is running; the existing migration container was left untouched.`); } } -async function runningDatabaseServiceNamesAsync(cfg) { +async function runningDatabaseServiceNamesAsync(cfg, signal) { const running = []; for (const service of DATABASE_SERVICES) { const name = `${cfg.stack}-${service}`; - if (await containerRunningAsync(cfg, name)) running.push(name); + if (await containerRunningAsync(cfg, name, signal)) running.push(name); } return running; } -async function assertDatabaseServiceCanStartAsync(cfg, service, migrationHandoff) { +async function assertDatabaseServiceCanStartAsync(cfg, service, migrationHandoff, signal) { if (!DATABASE_SERVICES.has(service)) return; - await assertNoLiveMigrationOwnerAsync(cfg, service); + await assertNoLiveMigrationOwnerAsync(cfg, service, signal); if (migrationHandoff === MIGRATIONS_PREAPPLIED_HANDOFF) return; - const running = await runningDatabaseServiceNamesAsync(cfg); + const running = await runningDatabaseServiceNamesAsync(cfg, signal); if (running.length > 0) throw directDatabaseStartError(cfg, service, running); } -async function assertMigrationCanStartAsync(cfg) { - const running = await runningDatabaseServiceNamesAsync(cfg); +async function assertMigrationCanStartAsync(cfg, signal) { + const running = await runningDatabaseServiceNamesAsync(cfg, signal); if (running.length > 0) { throw new Error(`Refusing to run database migrations while database services are running (${running.join(', ')}). Stop the stack first (for the CLI, run \`propr stop\`) and retry; existing containers were left untouched.`); } const migrationName = `${cfg.stack}-migrate`; - if (await containerRunningAsync(cfg, migrationName)) { + if (await containerRunningAsync(cfg, migrationName, signal)) { throw new Error(`Database migration owner ${migrationName} is already running; it was left untouched. Wait for it to finish, inspect its logs, or stop it explicitly before retrying.`); } } -async function prepareMigrationOwnerAsync(cfg, onLog) { - await assertMigrationCanStartAsync(cfg); +async function prepareMigrationOwnerAsync(cfg, onLog, signal) { + await assertMigrationCanStartAsync(cfg, signal); const migrationName = `${cfg.stack}-migrate`; - if (!(await containerExistsAsync(cfg, migrationName))) return; + if (!(await containerExistsAsync(cfg, migrationName, signal))) return; onLog?.(` · removing stopped migration container ${migrationName}`); - const removed = await dockerAsync(['rm', migrationName]); + const removed = await dockerAsync(['rm', migrationName], { signal }); if (removed.status !== 0) { throw new Error(`Could not safely remove stopped migration container ${migrationName}; it may have started and was left untouched: ${firstLine(removed.stderr || removed.error?.message || 'docker rm failed')}`); } } -async function dockerRunDetachedAsync(cfg, name, service, args, networkMode = cfg.network) { +async function dockerRunDetachedAsync(cfg, name, service, args, networkMode = cfg.network, signal, setupRunId) { const full = [ 'run', '-d', '--init', '--name', name, '--network', networkMode, '--restart', 'unless-stopped', '--label', `propr.stack=${cfg.stack}`, '--label', `propr.service=${service}`, + ...(setupRunId ? ['--label', `propr.setup-run=${setupRunId}`] : []), ...args, ]; - const res = await dockerAsync(full); + const res = await dockerAsync(full, { signal }); if (res.status !== 0) { throw new Error(`Failed to start ${name}: ${res.stderr}`); } } /** Async mirror of ensureNetwork. */ -export async function ensureNetworkAsync(cfg, onLog) { - const res = await dockerAsync(['network', 'inspect', cfg.network]); +export async function ensureNetworkAsync(cfg, onLog, { signal, beforeMutation } = {}) { + beforeMutation?.(); + const res = await dockerAsync(['network', 'inspect', cfg.network], { signal }); + throwIfCancelledResult(res, signal); + beforeMutation?.(); if (res.status !== 0) { onLog?.(`creating network ${cfg.network}`); - await dockerAsync(['network', 'create', cfg.network]); + beforeMutation?.(); + const created = await dockerAsync(['network', 'create', cfg.network], { signal }); + throwIfCancelledResult(created, signal); + beforeMutation?.(); + if (created.status !== 0) throw new Error(`Could not create Docker network ${cfg.network}.`); } } @@ -1374,11 +1480,12 @@ async function cachedImageFreshnessAsync(cache, tag, opts) { } /** Async mirror of ensureServiceImage — pulls a missing/stale image, awaited. */ -async function ensureServiceImageAsync(cfg, service, onLog, { freshnessCache } = {}) { +async function ensureServiceImageAsync(cfg, service, onLog, { freshnessCache, signal, beforeMutation } = {}) { const tag = imageTagForService(cfg, service); if (!tag) return; const skipFreshness = skipRemoteImageCheck() || !isProprPublishedImage(cfg, tag); - const freshness = await cachedImageFreshnessAsync(freshnessCache, tag, { skipRemoteCheck: skipFreshness }); + const freshness = await cachedImageFreshnessAsync(freshnessCache, tag, { skipRemoteCheck: skipFreshness, signal }); + beforeMutation?.(); if (freshness.status === 'current') return; if (freshness.status === 'unknown') { if (freshness.skipped) return; @@ -1391,23 +1498,37 @@ async function ensureServiceImageAsync(cfg, service, onLog, { freshnessCache } = } else { onLog?.(` · pulling ${tag}`); } - const res = await dockerAsync(['pull', tag]); + beforeMutation?.(); + const res = await dockerAsync(['pull', tag], { signal }); + beforeMutation?.(); if (res.status !== 0) { throw new Error(`Failed to pull ${tag}: ${(res.stderr || '').trim()}`); } } /** Async mirror of startService. */ -export async function startServiceAsync(cfg, service, { onLog, pull = true, freshnessCache, migrationHandoff } = {}) { +export async function startServiceAsync(cfg, service, { onLog, pull = true, freshnessCache, migrationHandoff, signal, setupRunId, beforeLaunch, returnStatus = true } = {}) { const name = `${cfg.stack}-${service}`; - await assertDatabaseServiceCanStartAsync(cfg, service, migrationHandoff); - if (pull) await ensureServiceImageAsync(cfg, service, onLog, { freshnessCache }); + await assertDatabaseServiceCanStartAsync(cfg, service, migrationHandoff, signal); + beforeLaunch?.(); + if (pull) await ensureServiceImageAsync(cfg, service, onLog, { freshnessCache, signal, beforeMutation: beforeLaunch }); + beforeLaunch?.(); const spec = withMigrationPolicy(buildServiceSpec(cfg, service), service, migrationHandoff); - await removeIfExistsAsync(cfg, name, onLog); + if (setupRunId) { + if (await containerExistsAsync(cfg, name, signal)) { + throw new Error(`Refusing to replace preexisting container ${name} during setup; it was left untouched.`); + } + beforeLaunch?.(); + } else { + await removeIfExistsAsync(cfg, name, onLog, signal); + } const runArgs = [...spec.args, spec.image, ...(spec.command || [])]; - await dockerRunDetachedAsync(cfg, name, service, runArgs, spec.networkMode); + signal?.throwIfAborted(); + beforeLaunch?.(); + await dockerRunDetachedAsync(cfg, name, service, runArgs, spec.networkMode, signal, setupRunId); + beforeLaunch?.(); onLog?.(` [ok] started ${name}`); - return getServiceStateAsync(cfg, service); + return returnStatus ? getServiceStateAsync(cfg, service, signal) : undefined; } /** Async mirror of stopService (used by startStackAsync's rollback). */ @@ -1432,63 +1553,374 @@ async function stopServiceAsync(cfg, service, { remove = true, onLog } = {}) { * without blocking the event loop, rolling back already-started services on a * mid-startup failure (best effort) before rethrowing. */ -export async function startStackAsync(cfg, { ui = true, docs = cfg.docsEnabled, tunnel = cfg.uiTunnelEnabled, onLog } = {}) { +export async function startStackAsync(cfg, { ui = true, docs = cfg.docsEnabled, tunnel = cfg.uiTunnelEnabled, onLog, signal, beforeLaunch } = {}) { const toStart = [...CORE_SERVICES, ...(ui ? ['ui'] : []), ...(docs ? ['docs'] : []), ...(tunnel ? ['tunnel'] : [])]; - const started = []; + const setupRunId = randomUUID(); + const journal = []; const freshnessCache = new Map(); + const recordBeforeLaunch = async (name, service) => { + beforeLaunch?.(); + const preexisting = await containerExistsAsync(cfg, name, signal); + beforeLaunch?.(); + journal.push({ name, service, preexisting }); + if (preexisting) throw new Error(`Refusing to replace preexisting container ${name} during setup; it was left untouched.`); + }; try { - await runMigrationPhaseAsync(cfg, { onLog, freshnessCache }); + signal?.throwIfAborted(); + await recordBeforeLaunch(`${cfg.stack}-migrate`, 'migrate'); + await runMigrationPhaseAsync(cfg, { onLog, freshnessCache, signal, setupRunId, beforeLaunch }); for (const service of toStart) { + await recordBeforeLaunch(`${cfg.stack}-${service}`, service); await startServiceAsync(cfg, service, { onLog, freshnessCache, migrationHandoff: MIGRATIONS_PREAPPLIED_HANDOFF, pull: !DATABASE_SERVICES.has(service), + signal, + setupRunId, + beforeLaunch, + returnStatus: false, }); - started.push(service); } + signal?.throwIfAborted(); + beforeLaunch?.(); + const status = await getStackStatusAsync(cfg, signal); + signal?.throwIfAborted(); + beforeLaunch?.(); + return status; } catch (err) { - onLog?.(` ! startup failed (${err.message}) — rolling back already-started services`); - for (const service of started.reverse()) { - try { - await stopServiceAsync(cfg, service, { onLog }); - } catch (stopErr) { - onLog?.(` ! rollback: ${stopErr.message}`); - } + onLog?.(` ! startup failed (${err.message}) — cleaning up run-owned containers`); + try { + await cleanupSetupRunContainers(cfg, setupRunId, journal, onLog); + } catch (cleanupError) { + const failure = new AggregateError( + [err, cleanupError], + `Stack startup failed and run-owned container cleanup is incomplete: ${cleanupError.message}`, + ); + failure.code = 'PROPR_SETUP_CLEANUP_INCOMPLETE'; + throw failure; } throw err; } - return getStackStatusAsync(cfg); } /** Async mirror of runMigrationPhase for the interactive setup UI. */ -export async function runMigrationPhaseAsync(cfg, { onLog, freshnessCache } = {}) { - await assertMigrationCanStartAsync(cfg); - await ensureServiceImageAsync(cfg, 'daemon', onLog, { freshnessCache }); - await prepareMigrationOwnerAsync(cfg, onLog); +export async function runMigrationPhaseAsync(cfg, { onLog, freshnessCache, signal, setupRunId, beforeLaunch } = {}) { + await assertMigrationCanStartAsync(cfg, signal); + beforeLaunch?.(); + await ensureServiceImageAsync(cfg, 'daemon', onLog, { freshnessCache, signal, beforeMutation: beforeLaunch }); + beforeLaunch?.(); + if (setupRunId) { + const migrationName = `${cfg.stack}-migrate`; + if (await containerExistsAsync(cfg, migrationName, signal)) { + throw new Error(`Refusing to replace preexisting container ${migrationName} during setup; it was left untouched.`); + } + } else { + await prepareMigrationOwnerAsync(cfg, onLog, signal); + } onLog?.(' · running database migrations'); - const res = await dockerAsync(migrationDockerArgs(cfg)); + signal?.throwIfAborted(); + beforeLaunch?.(); + const res = await dockerAsync(migrationDockerArgs(cfg, setupRunId), { signal }); + beforeLaunch?.(); if (res.status !== 0) throw migrationFailure(res); onLog?.(' [ok] database migrations completed'); } +const SETUP_CLEANUP_INSPECT_TIMEOUT_MS = 3_000; +const SETUP_CLEANUP_QUERY_TIMEOUT_MS = 3_000; +// `docker stop -t 2` gets its full grace plus three seconds of daemon overhead. +const SETUP_CLEANUP_STOP_TIMEOUT_MS = 5_000; +const SETUP_CLEANUP_REMOVE_TIMEOUT_MS = 4_000; +const SETUP_CLEANUP_WIDE_TIMEOUT_MS = 20_000; +const SETUP_CLEANUP_OUTPUT_LIMIT_BYTES = 8_192; +const STRICT_DOCKER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; + +function assertSetupCleanupEntry(cfg, entry) { + const validService = entry?.service === 'migrate' || SERVICES.includes(entry?.service); + if (!validService || typeof entry?.name !== 'string' + || !STRICT_DOCKER_NAME_PATTERN.test(entry.name) + || entry.name !== `${cfg.stack}-${entry.service}`) { + throw new Error('setup cleanup journal contains an invalid container identity'); + } +} + +function exactDockerNameFilter(name) { + // Docker's name filter is a regular expression over a leading-slash name. + // Escape every regexp metacharacter that the validated Docker alphabet can + // contain so a stack name with dots still means one literal exact name. + return `name=^/${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`; +} + +function successfulBoundedDockerResult(result) { + return result.status === 0 + && !result.error + && !result.signal + && !result.stdoutTruncated + && !result.stderrTruncated; +} + +function parseExactNameQuery(stdout, expectedName) { + if (stdout === '') return 'absent'; + // One JSON row may have Docker's single line terminator. Whitespace-only + // output, extra blank lines, and every multi-row shape are not empty proof. + const row = stdout.match(/^([^\r\n]+)(?:\r?\n)?$/)?.[1]; + if (!row) return 'ambiguous'; + try { + const name = JSON.parse(row); + return typeof name === 'string' && name === expectedName ? 'present' : 'ambiguous'; + } catch { + return 'ambiguous'; + } +} + +/** + * Cleanup uses a fresh signal because the setup signal is already aborted. + * Journal entries are independent exact names, so clean them concurrently: the + * wide deadline covers one bounded inspect/stop/reinspect/rm/reinspect chain, + * rather than multiplying the two-second stop grace by up to nine services. + */ +async function cleanupSetupRunContainers(cfg, setupRunId, journal, onLog) { + const cleanup = new AbortController(); + const timer = setTimeout(() => cleanup.abort(new Error('setup cleanup deadline exceeded')), SETUP_CLEANUP_WIDE_TIMEOUT_MS); + const entries = [...journal].reverse().filter((entry) => !entry.preexisting); + const command = (args, timeout, capture = false) => dockerAsync(args, { + signal: cleanup.signal, + timeout, + maxOutputBytes: capture ? SETUP_CLEANUP_OUTPUT_LIMIT_BYTES : 0, + }); + const proveExactNameAfterInspectFailure = async (entry) => { + const queried = await command([ + 'ps', '-a', + '--filter', exactDockerNameFilter(entry.name), + '--format', '{{json .Names}}', + ], SETUP_CLEANUP_QUERY_TIMEOUT_MS, true); + if (!successfulBoundedDockerResult(queried)) return { state: 'unresolved' }; + const proof = parseExactNameQuery(queried.stdout, entry.name); + if (proof === 'absent') return { state: 'absent' }; + return proof === 'present' ? { state: 'unresolved-present' } : { state: 'unresolved' }; + }; + const classify = async (entry) => { + assertSetupCleanupEntry(cfg, entry); + const inspected = await command( + ['inspect', '--format', '{{json .Config.Labels}}', entry.name], + SETUP_CLEANUP_INSPECT_TIMEOUT_MS, + true, + ); + if (!successfulBoundedDockerResult(inspected)) { + return proveExactNameAfterInspectFailure(entry); + } + try { + const labels = JSON.parse(inspected.stdout.trim()); + if (!labels || Array.isArray(labels) || typeof labels !== 'object') { + return proveExactNameAfterInspectFailure(entry); + } + return labels['propr.stack'] === cfg.stack + && labels?.['propr.service'] === entry.service + && labels?.['propr.setup-run'] === setupRunId + ? { state: 'owned' } + : { state: 'foreign' }; + } catch { + return proveExactNameAfterInspectFailure(entry); + } + }; + try { + const settled = await Promise.allSettled(entries.map(async (entry) => { + const beforeStop = await classify(entry); + if (beforeStop.state === 'absent' || beforeStop.state === 'foreign') return; + if (beforeStop.state !== 'owned') throw new Error('container absence could not be proved before stop'); + await command(['stop', '-t', '2', entry.name], SETUP_CLEANUP_STOP_TIMEOUT_MS); + // A nonzero stop can mean the owned container exited between + // inspect and stop while its stopped record still exists. The + // second exact-label inspection, not the stop status, decides + // whether it remains safe to force-remove that same record. + const afterStop = await classify(entry); + if (afterStop.state === 'absent' || afterStop.state === 'foreign') return; + if (afterStop.state !== 'owned') throw new Error('container absence could not be proved after stop'); + await command(['rm', '-f', entry.name], SETUP_CLEANUP_REMOVE_TIMEOUT_MS); + const afterRemove = await classify(entry); + if (afterRemove.state === 'absent' || afterRemove.state === 'foreign') { + onLog?.(` [ok] removed run-owned ${entry.name}`); + return; + } + throw new Error(afterRemove.state === 'owned' + ? 'run-owned container remains after remove' + : 'container absence could not be proved after remove'); + })); + const failures = settled.flatMap((result, index) => result.status === 'rejected' + ? [`${entries[index].name}: rollback step could not be proved complete`] + : []); + + // Await every entry, then independently prove no exact same-run record + // remains. Foreign replacements deliberately fail the label match and + // are therefore preserved and not reported as residual run ownership. + const terminal = await Promise.all(entries.map(async (entry) => { + try { return await classify(entry); } catch { return { state: 'unresolved' }; } + })); + failures.push(...terminal.flatMap((result, index) => { + if (result.state === 'absent' || result.state === 'foreign') return []; + return [`${entries[index].name}: ${result.state === 'owned' || result.state === 'unresolved-present' + ? 'run-owned container may remain' + : 'container absence could not be proved'}`]; + })); + if (failures.length) { + for (const failure of failures) onLog?.(` ! rollback: ${failure}`); + throw new Error('run-owned container cleanup could not be proved complete'); + } + } finally { + clearTimeout(timer); + } +} + /** Async mirror of getStackStatus. */ -export async function getStackStatusAsync(cfg) { - const res = await dockerAsync(STACK_STATUS_PS_ARGS); +export async function getStackStatusAsync(cfg, signal) { + signal?.throwIfAborted(); + const res = await dockerAsync(STACK_STATUS_PS_ARGS, { signal }); + signal?.throwIfAborted(); + if (res.error || res.status !== 0) { + const detail = firstLine(res.stderr || res.error?.message || `docker ps exited with status ${res.status}`); + throw new Error(`Failed to inspect stack status: ${detail}`); + } return parseStackStatus(cfg, res.stdout); } /** Async mirror of getServiceState. */ -async function getServiceStateAsync(cfg, service) { - return (await getStackStatusAsync(cfg)).services.find((s) => s.service === service); +async function getServiceStateAsync(cfg, service, signal) { + return (await getStackStatusAsync(cfg, signal)).services.find((s) => s.service === service); } /** Async mirror of isStackRunning. */ -export async function isStackRunningAsync(cfg) { - const status = await getStackStatusAsync(cfg); +export async function isStackRunningAsync(cfg, signal) { + const status = await getStackStatusAsync(cfg, signal); return status.services.some((s) => CORE_SERVICES.includes(s.service) && s.running); } +function expectedServiceBinds(cfg, service) { + const args = buildServiceSpec(cfg, service).args; + const binds = []; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === '-v') { + const bind = args[index + 1]; + const source = bind.split(':', 1)[0]; + if ((source.startsWith('/') && /(?:^|\/)(?:proc\/(?:[0-9]+|self|thread-self)\/fd|dev\/fd)(?:\/|$)/.test(source)) + || (!source.startsWith('/') && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(source))) { + throw new Error(`Lifecycle recovery requires stable Docker bind sources for ${service}.`); + } + binds.push(bind); + } + } + return binds.sort(); +} + +function assertStableLifecycleConfig(cfg) { + for (const path of [cfg.envFileHost, cfg.hostData, cfg.hostLogs, cfg.hostRepos]) { + if (!isAbsolute(path) || /(?:^|\/)(?:proc\/(?:[0-9]+|self|thread-self)\/fd|dev\/fd)(?:\/|$)/.test(path)) { + throw new Error('Lifecycle recovery requires stable fixed-root Docker bind paths.'); + } + } +} + +async function inspectLifecycleContainer(cfg, service, signal, assertRootAuthority) { + const name = `${cfg.stack}-${service}`; + assertRootAuthority?.(); + const inspected = await dockerAsync(['inspect', name], { signal }); + throwIfCancelledResult(inspected, signal); + assertRootAuthority?.(); + if (inspected.status !== 0) { + const detail = firstLine(inspected.stderr || inspected.error?.message); + if (/no such (?:object|container)/i.test(detail)) return { name, service, exists: false, running: false }; + throw new Error(`Could not safely inspect ${name}; no lifecycle mutation was attempted.`); + } + let value; + try { + const parsed = JSON.parse(inspected.stdout); + value = Array.isArray(parsed) ? parsed[0] : parsed; + } catch { + throw new Error(`Refusing lifecycle recovery for ${name}: its Docker inspection was malformed; it was left untouched.`); + } + const labels = value?.Config?.Labels; + const containerId = typeof value?.Id === 'string' && value.Id.length > 0 ? value.Id : null; + const inspectedName = typeof value?.Name === 'string' ? value.Name.replace(/^\//, '') : null; + const actualBinds = Array.isArray(value?.HostConfig?.Binds) ? [...value.HostConfig.Binds].sort() : []; + const expectedBinds = expectedServiceBinds(cfg, service); + if (!containerId || inspectedName !== name + || labels?.['propr.stack'] !== cfg.stack || labels?.['propr.service'] !== service + || JSON.stringify(actualBinds) !== JSON.stringify(expectedBinds)) { + throw new Error(`Refusing lifecycle recovery for ${name}: ownership or fixed-root binds do not match; it was left untouched.`); + } + return { id: containerId, name, service, exists: true, running: value?.State?.Running === true }; +} + +/** + * Resume only an already-created, exactly owned lifecycle stack. Setup-run + * creation remains transactional and uses startStackAsync; this path never + * adopts or replaces a same-name container with mismatched labels or binds. + */ +export async function recoverStackAsync(cfg, { ui = true, docs = cfg.docsEnabled, tunnel = cfg.uiTunnelEnabled, signal, onLog, assertRootAuthority } = {}) { + assertStableLifecycleConfig(cfg); + assertRootAuthority?.(); + const services = [...CORE_SERVICES, ...(ui ? ['ui'] : []), ...(docs ? ['docs'] : []), ...(tunnel ? ['tunnel'] : [])]; + const inspected = []; + for (const service of services) inspected.push(await inspectLifecycleContainer(cfg, service, signal, assertRootAuthority)); + const existing = inspected.filter((entry) => entry.exists); + if (existing.length === 0) return { recovered: false }; + if (existing.length !== inspected.length) { + throw new Error('Refusing partial lifecycle recreation: expected service containers are missing; existing containers were left untouched.'); + } + for (const entry of inspected) { + signal?.throwIfAborted(); + if (entry.running) continue; + const current = await inspectLifecycleContainer(cfg, entry.service, signal, assertRootAuthority); + if (!current.exists || current.running) continue; + assertRootAuthority?.(); + const started = await dockerAsync(['start', current.id], { signal }); + throwIfCancelledResult(started, signal); + assertRootAuthority?.(); + if (started.status !== 0) throw new Error(`Could not restart ${entry.name}; remaining containers were left untouched.`); + const verified = await inspectLifecycleContainer(cfg, entry.service, signal, assertRootAuthority); + if (!verified.exists || !verified.running) throw new Error(`Could not verify ${entry.name} after restart.`); + onLog?.(` [ok] restarted ${entry.name}`); + } + return { recovered: true }; +} + +/** Report desktop lifecycle state only after every same-name service is verified. */ +export async function isLifecycleStackRunningAsync(cfg, { signal, assertRootAuthority } = {}) { + assertStableLifecycleConfig(cfg); + assertRootAuthority?.(); + const inspected = []; + for (const service of SERVICES) inspected.push(await inspectLifecycleContainer(cfg, service, signal, assertRootAuthority)); + return inspected.some((entry) => CORE_SERVICES.includes(entry.service) && entry.running); +} + +/** Stop only exact expected service names after labels and binds are verified. */ +export async function stopLifecycleStackAsync(cfg, { signal, onLog, assertRootAuthority } = {}) { + assertStableLifecycleConfig(cfg); + assertRootAuthority?.(); + const inspected = []; + for (const service of SERVICES) inspected.push(await inspectLifecycleContainer(cfg, service, signal, assertRootAuthority)); + const failed = []; + for (const entry of inspected.filter((value) => value.exists && value.running).reverse()) { + try { + const current = await inspectLifecycleContainer(cfg, entry.service, signal, assertRootAuthority); + if (!current.exists || !current.running) continue; + assertRootAuthority?.(); + const stopped = await dockerAsync(['stop', '-t', '10', current.id], { signal }); + assertRootAuthority?.(); + // Always re-inspect after the stop result. A replacement is never + // removed or retried; exact ownership is required on every pass. + await inspectLifecycleContainer(cfg, entry.service, signal, assertRootAuthority); + if (stopped.status !== 0) throw new Error(`Could not stop ${entry.name}.`); + onLog?.(` [ok] stopped ${entry.name}`); + } catch (error) { + signal?.throwIfAborted(); + failed.push(entry.name); + onLog?.(` ! ${error instanceof Error ? error.message : String(error)}`); + } + } + return { failed }; +} + /** * Stop every container belonging to this stack, discovered by the stack label. * Returns `{ failed }` listing containers that could not be stopped/removed so @@ -1691,7 +2123,7 @@ export function validateEnv(cfg) { // Docker name constraint — the stack name is embedded in container, volume // and network names, so reject it early instead of failing mid-startup. - const dockerNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; + const dockerNamePattern = STRICT_DOCKER_NAME_PATTERN; if (!dockerNamePattern.test(cfg.stack)) { errors.push(`PROPR_STACK ("${cfg.stack}") is not a valid Docker name — use letters, digits, '_', '.' or '-', starting with a letter or digit.`); } diff --git a/package-lock.json b/package-lock.json index 88956cb9d..7689f87c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,6 +75,12 @@ "name": "@propr/desktop", "version": "0.8.15", "license": "Apache-2.0", + "dependencies": { + "@propr/cli": "*", + "@propr/client": "*", + "@propr/local-setup": "*", + "@propr/shared": "*" + }, "devDependencies": { "@electron-forge/cli": "8.0.0-alpha.10", "@electron-forge/maker-deb": "8.0.0-alpha.10", diff --git a/package.json b/package.json index 668efd4f2..0374ad08d 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "deploy:hosted-ui": "npm run build -w propr-ui && npx wrangler deploy --config wrangler.hosted-ui.toml", "desktop": "npm run dev -w @propr/desktop", "desktop:dev": "npm run dev -w @propr/desktop", - "desktop:prepare": "npm run build -w @propr/shared && npm run build -w @propr/client", + "desktop:prepare": "npm run build -w @propr/shared && npm run build -w @propr/client && npm run build -w @propr/local-setup && npm run build -w @propr/cli", "desktop:typecheck": "npm run typecheck -w @propr/desktop && npm run typecheck -w propr-ui", "desktop:test": "npm run test -w @propr/desktop", "desktop:package": "npm run package -w @propr/desktop", diff --git a/packages/cli/src/api/agents.ts b/packages/cli/src/api/agents.ts index 4c010fd27..ba05a4ddd 100644 --- a/packages/cli/src/api/agents.ts +++ b/packages/cli/src/api/agents.ts @@ -158,10 +158,10 @@ export interface SaveAgentsResponse { * console.log(`Found ${result.agents.length} agents`); * ``` */ -export async function listAgents(client?: ApiClient): Promise { +export async function listAgents(client?: ApiClient, signal?: AbortSignal): Promise { const apiClient = client ?? (await createApiClient()); - const response = await apiClient.get("/api/config/agents"); + const response = await apiClient.get("/api/config/agents", { signal }); return response.data; } @@ -188,12 +188,13 @@ export async function listAgents(client?: ApiClient): Promise */ export async function addAgent( options: AddAgentOptions, - client?: ApiClient + client?: ApiClient, + signal?: AbortSignal ): Promise { const apiClient = client ?? (await createApiClient()); // Fetch existing agents - const existingResponse = await apiClient.get("/api/config/agents"); + const existingResponse = await apiClient.get("/api/config/agents", { signal }); const existingAgents = existingResponse.data.agents || []; // Check if alias already exists @@ -224,6 +225,7 @@ export async function addAgent( // Save the updated list const response = await apiClient.post("/api/config/agents", { body: { agents: updatedAgents }, + signal, }); return response.data; diff --git a/packages/cli/src/api/client.ts b/packages/cli/src/api/client.ts index fcbc169ef..57a97d301 100644 --- a/packages/cli/src/api/client.ts +++ b/packages/cli/src/api/client.ts @@ -123,6 +123,7 @@ export class ApiClient { headers: customHeaders = {}, params, timeout = this.defaultTimeout, + signal, } = options; // Build the full URL @@ -156,19 +157,25 @@ export class ApiClient { for (let attempt = 1; attempt <= maxAttempts; attempt++) { // Each retry receives its own timeout window and abort signal. const controller = new AbortController(); - fetchOptions.signal = controller.signal; + signal?.throwIfAborted(); + fetchOptions.signal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal; const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, fetchOptions); clearTimeout(timeoutId); + signal?.throwIfAborted(); // Handle error responses if (!response.ok) { let errorResponse: ApiErrorResponse | undefined; try { errorResponse = await response.json() as ApiErrorResponse; - } catch { + signal?.throwIfAborted(); + } catch (error) { + if (signal?.aborted) throw signal.reason; + if ((error as { name?: unknown; code?: unknown } | null)?.name === "AbortError" + || (error as { code?: unknown } | null)?.code === "ABORT_ERR") throw error; // Response body is not JSON or empty } throw createApiError(response.status, errorResponse); @@ -183,6 +190,7 @@ export class ApiClient { // Handle non-JSON responses data = await response.text() as unknown as T; } + signal?.throwIfAborted(); return { data, @@ -197,6 +205,7 @@ export class ApiClient { throw error; } + if (signal?.aborted) throw signal.reason; const retryableError = error instanceof Error && error.name === "AbortError" ? new TimeoutError("Request timed out.", timeout) : error instanceof TypeError diff --git a/packages/cli/src/api/relay.ts b/packages/cli/src/api/relay.ts index ec0cd65a6..5a6329c06 100644 --- a/packages/cli/src/api/relay.ts +++ b/packages/cli/src/api/relay.ts @@ -10,11 +10,18 @@ const FETCH_TIMEOUT_MS = 15_000; +function rethrowRequestCancellation(error: unknown, signal?: AbortSignal): void { + signal?.throwIfAborted(); + if ((error as { name?: unknown; code?: unknown } | null)?.name === "AbortError" + || (error as { code?: unknown } | null)?.code === "ABORT_ERR") throw error; +} + export interface RelayClientOptions { /** Relay base URL, including the version prefix (e.g. https://relay.example/v1). */ baseUrl: string; /** GitHub user token used to prove identity to the relay. */ githubToken: string; + signal?: AbortSignal; } export interface EnrollRelayTokenResult { @@ -75,9 +82,11 @@ async function relayRequest( method, headers, body: body === undefined ? undefined : JSON.stringify(body), - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]) : AbortSignal.timeout(FETCH_TIMEOUT_MS), }); + options.signal?.throwIfAborted(); } catch (error) { + rethrowRequestCancellation(error, options.signal); throw new Error(`Cannot reach the relay at ${options.baseUrl}: ${(error as Error).message}`); } @@ -85,8 +94,10 @@ async function relayRequest( let code = ""; try { const parsed = (await response.json()) as { error?: { code?: string } }; + options.signal?.throwIfAborted(); code = parsed?.error?.code ?? ""; - } catch { + } catch (error) { + rethrowRequestCancellation(error, options.signal); /* non-JSON error body */ } if (response.status === 401) { @@ -104,8 +115,11 @@ async function relayRequest( } try { - return (await response.json()) as T; - } catch { + const result = (await response.json()) as T; + options.signal?.throwIfAborted(); + return result; + } catch (error) { + rethrowRequestCancellation(error, options.signal); throw new Error("The relay returned a malformed JSON response."); } } diff --git a/packages/cli/src/api/repos.ts b/packages/cli/src/api/repos.ts index d0c98afb8..750456335 100644 --- a/packages/cli/src/api/repos.ts +++ b/packages/cli/src/api/repos.ts @@ -257,10 +257,10 @@ export interface RepoConfigResponse { * } * ``` */ -export async function getRepos(client?: ApiClient): Promise { +export async function getRepos(client?: ApiClient, signal?: AbortSignal): Promise { const apiClient = client ?? (await createApiClient()); - const response = await apiClient.get("/api/config/repos"); + const response = await apiClient.get("/api/config/repos", { signal }); return response.data; } @@ -288,12 +288,13 @@ export async function getRepos(client?: ApiClient): Promise { export async function addRepo( fullName: string, options: AddRepoOptions = {}, - client?: ApiClient + client?: ApiClient, + signal?: AbortSignal ): Promise { const apiClient = client ?? (await createApiClient()); // First, fetch the current list of repos - const currentRepos = await getRepos(apiClient); + const currentRepos = await getRepos(apiClient, signal); // Check if repo already exists const existingRepo = currentRepos.repos_to_monitor.find( @@ -317,6 +318,7 @@ export async function addRepo( const response = await apiClient.post("/api/config/repos", { body: { repos_to_monitor: updatedRepos }, + signal, }); return response.data; diff --git a/packages/cli/src/api/settings.ts b/packages/cli/src/api/settings.ts index 96c6e8918..682e03759 100644 --- a/packages/cli/src/api/settings.ts +++ b/packages/cli/src/api/settings.ts @@ -436,12 +436,14 @@ export async function getSettings(client?: ApiClient): Promise { const apiClient = client ?? (await createApiClient()); const response = await apiClient.post("/api/config/settings", { body: { settings }, + signal, }); return response.data; @@ -467,10 +469,11 @@ export async function updateSettings( export async function updateSetting( key: SettingKey, value: number | string | string[] | boolean, - client?: ApiClient + client?: ApiClient, + signal?: AbortSignal ): Promise { const settings: UpdateSettingsOptions = { [key]: value }; - return updateSettings(settings, client); + return updateSettings(settings, client, signal); } export async function getConfigValue< diff --git a/packages/cli/src/api/system.ts b/packages/cli/src/api/system.ts index bcf3e2dfb..8a9449845 100644 --- a/packages/cli/src/api/system.ts +++ b/packages/cli/src/api/system.ts @@ -138,10 +138,11 @@ export interface QueueStats { * ``` */ export async function getSystemStatus( - client?: ApiClient + client?: ApiClient, + signal?: AbortSignal ): Promise { const apiClient = client ?? (await createApiClient()); - const response = await apiClient.get("/api/status"); + const response = await apiClient.get("/api/status", { signal }); return response.data; } diff --git a/packages/cli/src/api/types.ts b/packages/cli/src/api/types.ts index 5caeaf4d8..579648cdf 100644 --- a/packages/cli/src/api/types.ts +++ b/packages/cli/src/api/types.ts @@ -37,6 +37,7 @@ export interface RequestOptions { * Request timeout in milliseconds. Defaults to 30000 (30 seconds). */ timeout?: number; + signal?: AbortSignal; } /** diff --git a/packages/cli/src/auth/githubLogin.ts b/packages/cli/src/auth/githubLogin.ts index 36d27e3cd..5d9ad44c8 100644 --- a/packages/cli/src/auth/githubLogin.ts +++ b/packages/cli/src/auth/githubLogin.ts @@ -9,6 +9,8 @@ */ import type { ConfigManager } from "../config/index.js"; +import { spawn } from "node:child_process"; +import { rethrowCancellation } from "@propr/local-setup"; /** Scopes requested when launching the interactive `gh auth login`. */ const GH_LOGIN_SCOPES = "repo,read:org"; @@ -23,6 +25,7 @@ export interface GithubLoginOptions { interactive?: boolean; /** Sink for human-facing progress lines. Defaults to no output. */ onLog?: (line: string) => void; + signal?: AbortSignal; } export interface GithubLoginResult { @@ -44,13 +47,16 @@ export async function loginWithGithubCli( configManager: ConfigManager, options: GithubLoginOptions = {} ): Promise { - const { interactive = false, onLog } = options; - const { execSync, spawnSync } = await import("child_process"); + const { interactive = false, onLog, signal } = options; // Require the gh CLI up front — every path below shells out to it. try { - execSync("gh --version", { stdio: "ignore" }); - } catch { + const version = await runGh(["--version"], false, signal); + signal?.throwIfAborted(); + if (version.status !== 0) throw version.error; + } catch (error) { + signal?.throwIfAborted(); + rethrowCancellation(error); return { ok: false, message: @@ -59,9 +65,11 @@ export async function loginWithGithubCli( } // Reuse an existing gh session when one is already authenticated. - const existing = readGhToken(execSync); + const existing = await readGhToken(signal); if (existing) { - await configManager.setGithubToken(existing); + signal?.throwIfAborted(); + await configManager.setGithubToken(existing, signal); + signal?.throwIfAborted(); return { ok: true, token: existing, message: "Authenticated using your existing gh CLI session." }; } @@ -75,25 +83,69 @@ export async function loginWithGithubCli( // Launch the interactive browser/device login. Inherits stdio so the user can // complete the gh prompts directly. onLog?.("No existing gh session found. Starting interactive login…"); - const result = spawnSync("gh", ["auth", "login", "-s", GH_LOGIN_SCOPES], { stdio: "inherit" }); + const result = await runGh(["auth", "login", "-s", GH_LOGIN_SCOPES], false, signal, true); + signal?.throwIfAborted(); if (result.status !== 0) { return { ok: false, message: "GitHub login failed or was cancelled." }; } - const token = readGhToken(execSync); + const token = await readGhToken(signal); if (!token) { return { ok: false, message: "Could not retrieve a token after login." }; } - await configManager.setGithubToken(token); + signal?.throwIfAborted(); + await configManager.setGithubToken(token, signal); + signal?.throwIfAborted(); return { ok: true, token, message: "Authentication successful." }; } /** Read the current `gh` token, or null when no session is authenticated. */ -function readGhToken(execSync: typeof import("child_process").execSync): string | null { +async function readGhToken(signal?: AbortSignal): Promise { try { - const token = execSync("gh auth token", { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim(); + const result = await runGh(["auth", "token"], true, signal); + signal?.throwIfAborted(); + const token = result.status === 0 ? result.stdout.trim() : ""; return token || null; - } catch { + } catch (error) { + signal?.throwIfAborted(); + rethrowCancellation(error); return null; } } + +function runGh(args: string[], capture: boolean, signal?: AbortSignal, interactive = false): Promise<{ status: number | null; stdout: string; error?: Error }> { + return new Promise((resolve, reject) => { + signal?.throwIfAborted(); + const child = spawn("gh", args, { + stdio: interactive ? "inherit" : capture ? ["ignore", "pipe", "ignore"] : "ignore", + detached: process.platform !== "win32", + }); + let stdout = ""; + let forceTimer: NodeJS.Timeout | undefined; + child.stdout?.on("data", chunk => { stdout += chunk.toString(); }); + const terminate = (force = false) => { + if (!child.pid) return; + if (process.platform === "win32") { + const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", ...(force ? ["/F"] : [])], { stdio: "ignore" }); + killer.unref(); + } else { + try { process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM"); } catch { child.kill(force ? "SIGKILL" : "SIGTERM"); } + } + }; + const abort = () => { + terminate(); + forceTimer = setTimeout(() => { + terminate(true); + forceTimer = setTimeout(() => reject(signal?.reason), 2_000); + }, 2_000); + }; + signal?.addEventListener("abort", abort, { once: true }); + child.once("error", error => resolve({ status: null, stdout, error })); + child.once("close", status => { + if (forceTimer) clearTimeout(forceTimer); + signal?.removeEventListener("abort", abort); + if (signal?.aborted) reject(signal.reason); + else resolve({ status, stdout }); + }); + }); +} diff --git a/packages/cli/src/commands/agentValidation.ts b/packages/cli/src/commands/agentValidation.ts index 05fa26cdf..f3cb4adfc 100644 --- a/packages/cli/src/commands/agentValidation.ts +++ b/packages/cli/src/commands/agentValidation.ts @@ -60,10 +60,14 @@ interface ExecResult { function execAsync( cmd: string, args: string[], - opts: { input?: string; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs: number } + opts: { input?: string; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs: number; signal?: AbortSignal } ): Promise { return new Promise((resolve) => { - const child = spawn(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ["pipe", "pipe", "pipe"] }); + if (opts.signal?.aborted) { + resolve({ status: null, stdout: "", stderr: "", error: Object.assign(new Error("cancelled"), { code: "ABORT_ERR" }) }); + return; + } + const child = spawn(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" }); let stdout = ""; let stderr = ""; let settled = false; @@ -71,16 +75,39 @@ function execAsync( if (settled) return; settled = true; clearTimeout(timer); + if (forceTimer) clearTimeout(forceTimer); + opts.signal?.removeEventListener("abort", abort); resolve(res); }; + const terminate = (force = false): void => { + if (!child.pid) return; + if (process.platform === "win32") { + const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", ...(force ? ["/F"] : [])], { stdio: "ignore" }); + killer.unref(); + } else { + try { process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM"); } catch { child.kill(force ? "SIGKILL" : "SIGTERM"); } + } + }; + let terminalError: NodeJS.ErrnoException | undefined; + let forceTimer: NodeJS.Timeout | undefined; + const abort = (): void => { + terminalError = Object.assign(new Error("cancelled"), { code: "ABORT_ERR" }); + terminate(); + forceTimer = setTimeout(() => { + terminate(true); + forceTimer = setTimeout(() => finish({ status: null, stdout, stderr, error: terminalError }), 2_000); + }, 2_000); + }; const timer = setTimeout(() => { - child.kill("SIGKILL"); - finish({ status: null, stdout, stderr, error: Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }) }); + terminalError = Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }); + terminate(true); + forceTimer = setTimeout(() => finish({ status: null, stdout, stderr, error: terminalError }), 2_000); }, opts.timeoutMs); child.stdout.on("data", (d) => { stdout += d.toString(); }); child.stderr.on("data", (d) => { stderr += d.toString(); }); child.on("error", (error) => finish({ status: null, stdout, stderr, error })); - child.on("close", (code) => finish({ status: code, stdout, stderr })); + opts.signal?.addEventListener("abort", abort, { once: true }); + child.on("close", (code) => finish({ status: terminalError ? null : code, stdout, stderr, error: terminalError })); child.stdin.on("error", () => { /* ignore EPIPE if the child never reads stdin */ }); if (opts.input != null) child.stdin.write(opts.input); child.stdin.end(); @@ -353,8 +380,8 @@ const DESCRIPTORS: AgentValidationDescriptor[] = [ }, ]; -function imagePresent(orch: OrchestratorModule, tag: string): boolean { - return orch.docker(["images", "-q", tag], { capture: true }).stdout.trim().length > 0; +async function imagePresent(orch: OrchestratorModule, tag: string, signal?: AbortSignal): Promise { + return (await orch.dockerAsync(["images", "-q", tag], { signal })).stdout.trim().length > 0; } function commandExists(bin: string): boolean { @@ -404,6 +431,7 @@ export interface ValidateAgentsOptions { onUpdate?: (agent: string, update: AgentCellUpdate) => void; /** Skip the billable host invocation; setup uses the worker image as truth. */ skipHost?: boolean; + signal?: AbortSignal; } /** The agent types that would be validated for the given filter (for seeding a live view). */ @@ -463,13 +491,14 @@ export interface AgentValidationRow { async function versionInfo( d: AgentValidationDescriptor, image: string | undefined, - orch: OrchestratorModule + orch: OrchestratorModule, + options: Pick ): Promise<{ host?: string; image?: string; drift?: "older" | "newer" }> { - const hostPromise = d.hostBin && commandExists(d.hostBin) - ? execAsync(d.hostBin, ["--version"], { timeoutMs: VERSION_TIMEOUT_MS }).then((r) => parseVersion(`${r.stdout}\n${r.stderr}`)) + const hostPromise = !options.skipHost && d.hostBin && commandExists(d.hostBin) + ? execAsync(d.hostBin, ["--version"], { timeoutMs: VERSION_TIMEOUT_MS, signal: options.signal }).then((r) => parseVersion(`${r.stdout}\n${r.stderr}`)) : Promise.resolve(undefined); - const imagePromise = image && imagePresent(orch, image) - ? execAsync("docker", ["run", "--rm", "--network=none", "-e", `PROPR_AGENT_TYPE=${d.type}`, image, ...d.versionArgs], { timeoutMs: VERSION_TIMEOUT_MS }).then((r) => parseVersion(`${r.stdout}\n${r.stderr}`)) + const imagePromise = image && await imagePresent(orch, image, options.signal) + ? execAsync("docker", ["run", "--rm", "--network=none", "-e", `PROPR_AGENT_TYPE=${d.type}`, image, ...d.versionArgs], { timeoutMs: VERSION_TIMEOUT_MS, signal: options.signal }).then((r) => parseVersion(`${r.stdout}\n${r.stderr}`)) : Promise.resolve(undefined); const [host, img] = await Promise.all([hostPromise, imagePromise]); const drift = host && img && host !== img ? (compareVersions(img, host) < 0 ? "older" : "newer") : undefined; @@ -526,13 +555,13 @@ export async function validateAgents( return { status: "warn", detail: `${d.hostBin} not installed on host — skipped` }; } const { args, stdin } = d.hostInvocation({ prompt: VALIDATION_PROMPT, promptFileHost }); - const run = await execAsync(d.hostBin, args, { input: stdin, cwd: workspaceDir, timeoutMs: VALIDATION_TIMEOUT_MS }); + const run = await execAsync(d.hostBin, args, { input: stdin, cwd: workspaceDir, timeoutMs: VALIDATION_TIMEOUT_MS, signal: options.signal }); const ev = evaluateRun(run); return { status: ev.ok ? "ok" : "fail", detail: ev.detail, ...(ev.ok ? {} : { fix: `Run \`${hostDebugCommand(d)}\` on the host to debug ${d.type} auth.` }) }; }; const runImage = async (d: AgentValidationDescriptor, image: string | undefined, hostDir: string | undefined): Promise => { - if (!image || !imagePresent(orch, image)) { + if (!image || !(await imagePresent(orch, image, options.signal))) { return { status: "warn", detail: `image ${image ?? d.imageKey} not present — skipped` }; } if (!hostDir) { @@ -561,6 +590,7 @@ export async function validateAgents( input: stdin, env: d.type === "vibe" && cfg.mistralApiKey ? { ...process.env, MISTRAL_API_KEY: cfg.mistralApiKey } : undefined, timeoutMs: VALIDATION_TIMEOUT_MS, + signal: options.signal, }); const ev = evaluateRun(run); const loginHint = d.loginArgs ? ` Re-authenticate with: propr agent login ${d.type}.` : ""; @@ -585,7 +615,8 @@ export async function validateAgents( mkdirSync(hostDir, { recursive: true, mode: 0o700 }); } // Emit each cell as it resolves so a live view can fill the table in. - const versionP = versionInfo(d, image, orch).then((v) => { + options.signal?.throwIfAborted(); + const versionP = versionInfo(d, image, orch, options).then((v) => { options.onUpdate?.(d.type, { field: "version", hostVersion: v.host, imageVersion: v.image, drift: v.drift }); return v; }); diff --git a/packages/cli/src/commands/checkCommands.ts b/packages/cli/src/commands/checkCommands.ts index 5815f8eee..bd4993b97 100644 --- a/packages/cli/src/commands/checkCommands.ts +++ b/packages/cli/src/commands/checkCommands.ts @@ -124,6 +124,7 @@ export interface RunChecksOptions { verify?: boolean; agents?: string[]; skipRemoteImageCheck?: boolean; + signal?: AbortSignal; /** Fired when a slow check begins, so a live UI can show a pending row. */ onPending?: (slot: { name: string; group?: CheckGroup }) => void; /** Fired as each result is finalized, so a live UI can update incrementally. */ @@ -215,13 +216,15 @@ export async function runChecks(options: RunChecksOptions = {}): Promise => { // Presence-only for third-party images and when remote checks are skipped. if (skipRemoteImageCheck || !isProprPublished(tag)) { - if (!imagePresent(orch, tag)) return missingImageResult(key, tag); + if (!(await imagePresent(orch, tag, options.signal))) return missingImageResult(key, tag); const detail = skipRemoteImageCheck ? `${tag} (local; remote check skipped)` : `${tag} (present)`; return { name: `Image ${key}`, status: "ok", detail, group: "Images" }; } let freshnessPromise = freshnessByTag.get(tag); if (!freshnessPromise) { - freshnessPromise = orch.inspectImageFreshnessAsync(tag); + freshnessPromise = orch.inspectImageFreshnessAsync(tag, { signal: options.signal }); freshnessByTag.set(tag, freshnessPromise); } const freshness = await freshnessPromise; @@ -405,7 +407,7 @@ export async function runChecks(options: RunChecksOptions = {}): Promise { + const res = await orch.dockerAsync(["images", "-q", tag], { signal }); return res.stdout.trim().length > 0; } diff --git a/packages/cli/src/commands/initStack.ts b/packages/cli/src/commands/initStack.ts index 71fa7ea37..72f0b01ae 100644 --- a/packages/cli/src/commands/initStack.ts +++ b/packages/cli/src/commands/initStack.ts @@ -53,6 +53,14 @@ export interface DetectedCred { path: string; } +let configuredStackTemplatePath: string | undefined; + +/** Configure an application-packaged stack template before scaffolding. */ +export function configureStackTemplatePath(path: string): void { + if (!isAbsolute(path) || !existsSync(path)) throw new Error("The configured stack template path is invalid"); + configuredStackTemplatePath = path; +} + // Mirrors the launcher's HOST_VIBE_PROMPT_CACHE_DIR default in // docker/launcher/orchestrator.mjs. Keep it per-user and private because prompt // files can contain task/repository context. @@ -84,6 +92,7 @@ export function ensureVibePromptCacheDir(cacheDir: string | undefined): string | /** Resolve the bundled .env.example, falling back to a repo checkout. */ function resolveEnvExample(): string | undefined { + if (configuredStackTemplatePath) return configuredStackTemplatePath; const here = dirname(fileURLToPath(import.meta.url)); // Bundled copy is renamed to avoid npm's .env* exclusion from tarballs. const bundled = join(here, "..", "assets", "env.example.txt"); @@ -120,6 +129,7 @@ function detectCredentials(): DetectedCred[] { export interface InitStackOptions { root?: string; force?: boolean; + signal?: AbortSignal; } export interface InitStackResult { @@ -165,10 +175,12 @@ export async function scaffoldStack( pendingCredentials: [], }; - mkdirSync(rootDir, { recursive: true }); + options.signal?.throwIfAborted(); + mkdirSync(rootDir, { recursive: true, mode: 0o700 }); // 1. data/logs/repos directories for (const sub of ["data", "logs", "repos"]) { + options.signal?.throwIfAborted(); const dir = join(rootDir, sub); const created = !existsSync(dir); ensurePrivateDirectory(dir); @@ -204,7 +216,7 @@ export async function scaffoldStack( if (options.force && envExists) { secureExistingPrivateFile(envPath); const bakPath = `${envPath}.bak`; - writePrivateFileAtomic(bakPath, readFileSync(envPath), { secureParent: false }); + writePrivateFileAtomic(bakPath, readFileSync(envPath), { secureParent: false, signal: options.signal }); result.envBackedUp = true; } shouldWriteEnv = true; @@ -234,7 +246,7 @@ export async function scaffoldStack( result.pendingCredentials = toAppend; if (shouldWriteEnv) { - writePrivateFileAtomic(envPath, envContent, { secureParent: false }); + writePrivateFileAtomic(envPath, envContent, { secureParent: false, signal: options.signal }); result.envCreated = true; } @@ -256,6 +268,7 @@ export async function scaffoldStack( } // 4. Persist the stack root so other commands can find it. + options.signal?.throwIfAborted(); await dependencies.persistStackRoot(rootDir); return result; diff --git a/packages/cli/src/commands/setup/agentHostActions.ts b/packages/cli/src/commands/setup/agentHostActions.ts index 1cf470714..784db16ed 100644 --- a/packages/cli/src/commands/setup/agentHostActions.ts +++ b/packages/cli/src/commands/setup/agentHostActions.ts @@ -1,60 +1,113 @@ import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import type { AgentSetupActions } from "@propr/local-setup"; import type { ConfigManager } from "../../config/index.js"; import { localhostServiceUrl } from "../../utils/dockerPort.js"; /** Bind the portable agent setup engine to the CLI API and Docker launcher. */ export function createDefaultAgentSetupActions(configManager?: ConfigManager): AgentSetupActions { - const localApiClient = async (rootDir: string): Promise => { + const localApiClient = async (rootDir: string, root?: import("@propr/local-setup").RootOperationBoundary): Promise => { + root?.assertRootAuthority?.(); const { getHostConfig } = await import("../../orchestrator/index.js"); - const { cfg } = await getHostConfig({ configManager, root: rootDir }); + root?.assertRootAuthority?.(); + const { cfg } = await getHostConfig({ configManager, root: rootDir, readRoot: root?.rootOperationsDir }); + root?.assertRootAuthority?.(); const { createApiClient } = await import("../../api/client.js"); + root?.assertRootAuthority?.(); return createApiClient({ baseUrl: localhostServiceUrl(cfg.apiPort) }); }; return { - async listAgents(rootDir) { + async listAgents(rootDir, signal, root) { const { listAgents } = await import("../../api/agents.js"); - return (await listAgents(await localApiClient(rootDir))).agents; + root?.assertRootAuthority?.(); + const result = await listAgents(await localApiClient(rootDir, root), signal); + root?.assertRootAuthority?.(); + return result.agents; }, - async addAgent(rootDir, options) { + async addAgent(rootDir, options, signal, root) { const { addAgent } = await import("../../api/agents.js"); - await addAgent(options, await localApiClient(rootDir)); + root?.assertRootAuthority?.(); + await addAgent(options, await localApiClient(rootDir, root), signal); + root?.assertRootAuthority?.(); }, async loginableAgents() { const { loginableAgents } = await import("../agentValidation.js"); return loginableAgents(); }, - async loginAgent(rootDir, type) { + async loginAgent(rootDir, type, signal, root) { + root?.assertRootAuthority?.(); const { getHostConfig } = await import("../../orchestrator/index.js"); const { planAgentLogin } = await import("../agentValidation.js"); - const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); + root?.assertRootAuthority?.(); + const { orch, cfg } = await getHostConfig({ configManager, root: rootDir, readRoot: root?.rootOperationsDir }); + root?.assertRootAuthority?.(); const temporaryRoot = mkdtempSync(join(tmpdir(), "propr-setup-login-")); const workspaceDir = join(temporaryRoot, "workspace"); mkdirSync(workspaceDir, { recursive: true, mode: 0o700 }); try { const { plan, error } = planAgentLogin(type, cfg, workspaceDir, orch.validateDockerBindPath); if (error || !plan) return { available: false, success: false, detail: error }; - if (!orch.docker(["images", "-q", plan.image], { capture: true }).stdout.trim()) { + root?.assertRootAuthority?.(); + const image = await orch.dockerAsync(["images", "-q", plan.image], { signal }); + signal?.throwIfAborted(); + root?.assertRootAuthority?.(); + if (!image.stdout.trim()) { + root?.assertRootAuthority?.(); return { available: true, success: false, detail: `image ${plan.image} not present locally — run \`propr images pull\`` }; } + root?.assertRootAuthority?.(); mkdirSync(plan.hostDir, { recursive: true, mode: 0o700 }); - const result = spawnSync("docker", plan.dockerArgs, { stdio: "inherit" }); - return result.status === 0 + const status = await new Promise((resolve, reject) => { + signal?.throwIfAborted(); + root?.assertRootAuthority?.(); + const child = spawn("docker", plan.dockerArgs, { stdio: "inherit", detached: process.platform !== "win32" }); + let forceTimer: NodeJS.Timeout | undefined; + const terminate = (force = false) => { + if (!child.pid) return; + if (process.platform === "win32") { + const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", ...(force ? ["/F"] : [])], { stdio: "ignore" }); + killer.unref(); + } else { + try { process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM"); } catch { child.kill(force ? "SIGKILL" : "SIGTERM"); } + } + }; + const abort = () => { + terminate(); + forceTimer = setTimeout(() => { + terminate(true); + forceTimer = setTimeout(() => reject(signal?.reason), 2_000); + }, 2_000); + }; + signal?.addEventListener("abort", abort, { once: true }); + child.once("error", reject); + child.once("close", code => { + if (forceTimer) clearTimeout(forceTimer); + signal?.removeEventListener("abort", abort); + if (signal?.aborted) reject(signal.reason); + else resolve(code); + }); + }); + signal?.throwIfAborted(); + root?.assertRootAuthority?.(); + return status === 0 ? { available: true, success: true, detail: `${type} login finished — credentials written to ${plan.hostDir}` } - : { available: true, success: false, detail: `${type} login exited with code ${result.status ?? "?"}` }; + : { available: true, success: false, detail: `${type} login exited with code ${status ?? "?"}` }; } finally { rmSync(temporaryRoot, { recursive: true, force: true }); } }, - async validateAgents(rootDir, types) { + async validateAgents(rootDir, types, signal, root) { + root?.assertRootAuthority?.(); const { getHostConfig } = await import("../../orchestrator/index.js"); const { validateAgents } = await import("../agentValidation.js"); - const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); - const rows = await validateAgents(orch, cfg, { agents: types, skipHost: true }); + root?.assertRootAuthority?.(); + const { orch, cfg } = await getHostConfig({ configManager, root: rootDir, readRoot: root?.rootOperationsDir }); + root?.assertRootAuthority?.(); + const rows = await validateAgents(orch, cfg, { agents: types, skipHost: true, signal }); + root?.assertRootAuthority?.(); return rows.map((row) => ({ type: row.type, status: row.image.status === "ok" ? "ok" as const : row.image.status === "fail" ? "failed" as const : "skipped" as const, diff --git a/packages/cli/src/commands/setup/engine.test.ts b/packages/cli/src/commands/setup/engine.test.ts index 014cc21ae..be5faf54c 100644 --- a/packages/cli/src/commands/setup/engine.test.ts +++ b/packages/cli/src/commands/setup/engine.test.ts @@ -6,7 +6,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { classifyBackendAccessError, runSetup, type SetupActions, type SetupPrompts } from "./engine.js"; +import { classifyBackendAccessError, retrySetup, runSetup, type SetupActions, type SetupPrompts } from "./engine.js"; import type { ChecksOutcome } from "../checkCommands.js"; import type { AuthorizedInstallation } from "../../api/relay.js"; import { DEFAULT_PROPR_GH_RELAY_URL, type GithubAuthModeResult } from "@propr/shared"; @@ -1361,6 +1361,58 @@ test("whitelist falls back to .env when the backend is not running", async () => assert.equal(statusOf(result.state, "whitelist"), "done"); }); +test("whitelist abort is cancellation and never falls back to an env commit", async () => { + const controller = new AbortController(); + let envCommitted = false; + const result = await runSetup({ + root: "/stack", + signal: controller.signal, + prompts: { configureWhitelist: async () => ["erin"] }, + actions: mockActions({ + isStackRunning: async () => true, + saveWhitelistSetting: async (_root, _users, signal) => { + controller.abort(); + signal?.throwIfAborted(); + }, + applyEnvSelection: (_root, vars) => { + if ("GITHUB_USER_WHITELIST" in vars) envCommitted = true; + return { written: Object.keys(vars), skipped: [] }; + }, + }), + }); + + assert.equal(result.cancelled, true); + assert.equal(result.errors[0]?.code, "cancelled"); + assert.equal(envCommitted, false); +}); + +test("relay boundary abort never writes the minted token or continues classification", async () => { + const controller = new AbortController(); + let wroteRelayToken = false; + let started = false; + const result = await runSetup({ + root: "/stack", + signal: controller.signal, + prompts: { configureGithubAuth: async () => ({ mode: "relay", enrollRelay: { relayUrl: DEFAULT_PROPR_GH_RELAY_URL } }) }, + actions: mockActions({ + hasGithubToken: () => true, + fetchRelayInstallations: async () => ({ username: "octocat", installations: [{ installation_id: 42, account_login: "octocat", account_type: "User" }] }), + enrollRelay: async () => { + controller.abort(); + return { relayUrl: DEFAULT_PROPR_GH_RELAY_URL, token: "must-not-be-written" }; + }, + applyEnvSelection: (_root, vars) => { + if (vars.PROPR_GH_RELAY_TOKEN) wroteRelayToken = true; + return { written: Object.keys(vars), skipped: [] }; + }, + startStack: async () => { started = true; }, + }), + }); + assert.equal(result.cancelled, true); + assert.equal(wroteRelayToken, false); + assert.equal(started, false); +}); + test("prompts drive a full unattended run to completion", async () => { const seen: string[] = []; const prompts: SetupPrompts = { @@ -1386,3 +1438,13 @@ test("prompts drive a full unattended run to completion", async () => { ["check", "init-stack", "pull-images", "configure-agents", "github-auth", "intake", "start-stack", "enable-agents", "whitelist", "repo", "launch-ui"] ); }); + +for (const platform of ["darwin", "win32"] as const) { + test(`CLI setup and retry reject ${platform} before host actions`, async () => { + let actions = 0; + const overrides = { runChecks: async () => { actions += 1; throw new Error("not called"); } }; + await assert.rejects(runSetup({ root: "/stack", platform, actions: overrides }), /not supported/); + await assert.rejects(retrySetup({ rootDir: "/stack" } as never, { platform, actions: overrides }), /not supported/); + assert.equal(actions, 0); + }); +} diff --git a/packages/cli/src/commands/setup/engine.ts b/packages/cli/src/commands/setup/engine.ts index 7effef45b..90ae64fb0 100644 --- a/packages/cli/src/commands/setup/engine.ts +++ b/packages/cli/src/commands/setup/engine.ts @@ -1,5 +1,6 @@ import { runSetup as runLocalSetup, + getLocalSetupCapability, retrySetup as retryLocalSetup, resolveSetupRoot, type RunSetupOptions as LocalRunSetupOptions, @@ -21,6 +22,8 @@ export interface RunSetupOptions extends Omit { const { configManager, actions: overrides, root, ...portable } = options; + const capability = getLocalSetupCapability(portable.platform); + if (!capability.supported) throw new Error(capability.reason); const actions = { ...createDefaultActions(configManager), ...overrides } as SetupActions; return runLocalSetup({ ...portable, @@ -31,6 +34,8 @@ export async function runSetup(options: RunSetupOptions = {}): Promise = {}): Promise { const { configManager, actions: overrides, ...portable } = options; + const capability = getLocalSetupCapability(portable.platform); + if (!capability.supported) return Promise.reject(new Error(capability.reason)); const actions = { ...createDefaultActions(configManager), ...overrides } as SetupActions; return retryLocalSetup(previous, { ...portable, actions }); } diff --git a/packages/cli/src/commands/setup/hostActions.ts b/packages/cli/src/commands/setup/hostActions.ts index af1aa4746..35280ec44 100644 --- a/packages/cli/src/commands/setup/hostActions.ts +++ b/packages/cli/src/commands/setup/hostActions.ts @@ -12,6 +12,7 @@ import { readEnvVars, type PullImagesResult, type SetupActions, + rethrowCancellation, } from "@propr/local-setup"; import type { ConfigManager } from "../../config/index.js"; import type { RelayClientOptions } from "../../api/relay.js"; @@ -26,12 +27,24 @@ function assertSafeAgentCredentialDir(path: string, name = "Agent credential pat } } +function assertStableDockerHandoff( + rootDir: string, +): void { + if (!isAbsolute(rootDir) || /(?:^|\/)(?:proc\/[0-9]+\/fd|dev\/fd)(?:\/|$)/.test(rootDir)) { + throw new Error("Desktop Docker lifecycle requires the stable app-owned runtime root"); + } +} + export function createDefaultActions(configManager?: ConfigManager): SetupActions { /** A client pointed at the local stack's API port (not the saved remote URL). */ - const localApiClient = async (rootDir: string): Promise => { + const localApiClient = async (rootDir: string, rootOperationsDir?: string, assertRootAuthority?: () => void): Promise => { + assertRootAuthority?.(); const { getHostConfig } = await import("../../orchestrator/index.js"); - const { cfg } = await getHostConfig({ configManager, root: rootDir }); + assertRootAuthority?.(); + const { cfg } = await getHostConfig({ configManager, root: rootDir, readRoot: rootOperationsDir }); + assertRootAuthority?.(); const { createApiClient, createApiClientWithConfig } = await import("../../api/client.js"); + assertRootAuthority?.(); const options = { baseUrl: localhostServiceUrl(cfg.apiPort) }; // Keep the local client on setup's active profile and, importantly, the // token that an in-progress setup login just stored. Creating an unrelated @@ -53,12 +66,15 @@ export function createDefaultActions(configManager?: ConfigManager): SetupAction inspectDatastoreAdministrators, async scaffoldStack(options) { const { scaffoldStack } = await import("../initStack.js"); - return scaffoldStack(options); + // The setup engine persists the display root after scaffolding. Avoid an + // intermediate descriptor-root path escaping into CLI configuration. + return scaffoldStack(options, { persistStackRoot: async () => {} }); }, - async persistStackRoot(rootDir) { + async persistStackRoot(rootDir, signal) { // Mirror scaffoldStack's `configManager.setStackRoot` so the reuse path // records the root too. Best-effort: without a config there is nowhere to // persist it (tests run this way), so it is simply a no-op. + signal?.throwIfAborted(); await configManager?.setStackRoot(rootDir); }, readEnvVars, @@ -69,9 +85,12 @@ export function createDefaultActions(configManager?: ConfigManager): SetupAction assertSafeAgentCredentialDir(path); mkdirSync(path, { recursive: true, mode: 0o700 }); }, - async pullImages({ rootDir, agentTypes, onLog }) { + async pullImages({ rootDir, rootOperationsDir, assertRootAuthority, agentTypes, onLog, signal }) { + assertRootAuthority?.(); const { getHostConfig } = await import("../../orchestrator/index.js"); - const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); + assertRootAuthority?.(); + const { orch, cfg } = await getHostConfig({ configManager, root: rootDir, readRoot: rootOperationsDir }); + assertRootAuthority?.(); const selected = new Set(agentTypes); const result: PullImagesResult = { pulledCore: [], pulledAgents: [], failedCore: [], failedAgents: [] }; @@ -85,11 +104,18 @@ export function createDefaultActions(configManager?: ConfigManager): SetupAction onLog?.(`pulling ${tag}…`); // Async exec keeps the event loop free so the wizard's Ink spinner keeps // animating while the (often slow) pull runs, instead of freezing. - const pulled = await orch.dockerAsync(["pull", tag]); + signal?.throwIfAborted(); + assertRootAuthority?.(); + const pulled = await orch.dockerAsync(["pull", tag], { signal }); + assertRootAuthority?.(); + signal?.throwIfAborted(); if (pulled.status === 0) { try { - orch.tagAgentLatest(key, tag); - } catch { + await orch.tagAgentLatestAsync(key, tag, signal); + assertRootAuthority?.(); + } catch (error) { + rethrowCancellation(error); + assertRootAuthority?.(); /* best-effort local retag; the pull itself succeeded */ } (isAgent ? result.pulledAgents : result.pulledCore).push(tag); @@ -99,23 +125,43 @@ export function createDefaultActions(configManager?: ConfigManager): SetupAction } return result; }, - async isStackRunning(rootDir) { + async isStackRunning(rootDir, signal, root) { + root?.assertRootAuthority?.(); const { getHostConfig } = await import("../../orchestrator/index.js"); - const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); - return orch.isStackRunningAsync(cfg); + root?.assertRootAuthority?.(); + const { orch, cfg } = await getHostConfig({ configManager, root: rootDir, readRoot: root?.rootOperationsDir }); + root?.assertRootAuthority?.(); + const running = await orch.isStackRunningAsync(cfg, signal); + root?.assertRootAuthority?.(); + return running; }, - async startStack({ rootDir, ui, docs, onLog }) { + async startStack({ rootDir, rootOperationsDir, ui, docs, onLog, signal, assertRootAuthority }) { + signal?.throwIfAborted(); + assertRootAuthority?.(); const { getHostConfig } = await import("../../orchestrator/index.js"); - const { orch, cfg } = await getHostConfig({ configManager, root: rootDir }); + assertRootAuthority?.(); + const { orch, cfg } = await getHostConfig({ configManager, root: rootDir, readRoot: rootOperationsDir }); + assertRootAuthority?.(); + if (assertRootAuthority) { + assertStableDockerHandoff(rootDir); + assertRootAuthority(); + } // Pre-create the host Vibe prompt-cache dir owned by this user so Docker // does not auto-create it as root on first bind-mount — a root-owned dir // would fail the writability check and block future `propr start` runs. try { + assertRootAuthority?.(); const { ensureVibePromptCacheDir } = await import("../initStack.js"); + assertRootAuthority?.(); ensureVibePromptCacheDir(cfg.hostVibePromptCacheDir); - } catch { + assertRootAuthority?.(); + } catch (error) { + signal?.throwIfAborted(); + assertRootAuthority?.(); + rethrowCancellation(error); /* best-effort: startup validation will surface an actionable error */ } + assertRootAuthority?.(); const validation = orch.validateEnv(cfg); for (const warning of validation.warnings) onLog?.(`warning: ${warning}`); if (!validation.ok) { @@ -124,27 +170,39 @@ export function createDefaultActions(configManager?: ConfigManager): SetupAction // Use the async start path: `propr setup` drives this from behind a live // Ink TUI, so the blocking synchronous startStack would freeze the spinner // and swallow keystrokes for the seconds-to-minutes a cold start takes. - await orch.ensureNetworkAsync(cfg, onLog); + assertRootAuthority?.(); + await orch.ensureNetworkAsync(cfg, onLog, { signal, beforeMutation: assertRootAuthority }); + assertRootAuthority?.(); await orch.startStackAsync(cfg, { ui: ui ?? configManager?.getUiEnabled() ?? true, docs: docs ?? cfg.docsEnabled, onLog, + signal, + beforeLaunch: assertRootAuthority, }); }, - async checkBackendHealth({ rootDir, timeoutMs = 60_000 }) { + async checkBackendHealth({ rootDir, rootOperationsDir, assertRootAuthority, timeoutMs = 60_000, signal }) { + assertRootAuthority?.(); const { getSystemStatus } = await import("../../api/system.js"); - const client = await localApiClient(rootDir); + assertRootAuthority?.(); + const client = await localApiClient(rootDir, rootOperationsDir, assertRootAuthority); + assertRootAuthority?.(); const deadline = Date.now() + timeoutMs; let lastError = "no response"; // Containers take a few seconds to report healthy; poll until the deadline. do { + signal?.throwIfAborted(); try { - const status = await getSystemStatus(client); + assertRootAuthority?.(); + const status = await getSystemStatus(client, signal); + assertRootAuthority?.(); if (String(status.api).toLowerCase() === "healthy") { return { healthy: true, detail: `API healthy (daemon ${status.daemon}, worker ${status.worker})` }; } lastError = `API reports "${status.api}"`; } catch (error) { + rethrowCancellation(error); + assertRootAuthority?.(); // A 401/403 is not an unhealthy backend — the API answered but denied // this protected request. Return immediately so setup does not stall // on a running backend, while preserving whether remediation requires @@ -154,22 +212,34 @@ export function createDefaultActions(configManager?: ConfigManager): SetupAction lastError = (error as Error).message; } if (Date.now() >= deadline) break; - await sleep(2_000); + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, 2_000); + signal?.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }, { once: true }); + }); } while (Date.now() < deadline); return { healthy: false, detail: `backend not healthy within ${Math.round(timeoutMs / 1000)}s (${lastError})` }; }, - async addRepository({ fullName, alias, baseBranch }, rootDir) { + async addRepository({ fullName, alias, baseBranch }, rootDir, signal, root) { + root?.assertRootAuthority?.(); const { addRepo } = await import("../../api/repos.js"); // Point the client at this stack's API port rather than the saved remote. - const client = await localApiClient(rootDir); - await addRepo(fullName, { alias, baseBranch }, client); + root?.assertRootAuthority?.(); + const client = await localApiClient(rootDir, root?.rootOperationsDir, root?.assertRootAuthority); + root?.assertRootAuthority?.(); + await addRepo(fullName, { alias, baseBranch }, client, signal); + root?.assertRootAuthority?.(); }, - async resolveUiUrl(rootDir) { + async resolveUiUrl(rootDir, signal, root) { + signal?.throwIfAborted(); + root?.assertRootAuthority?.(); const { getHostConfig } = await import("../../orchestrator/index.js"); - const { cfg } = await getHostConfig({ configManager, root: rootDir }); + root?.assertRootAuthority?.(); + const { cfg } = await getHostConfig({ configManager, root: rootDir, readRoot: root?.rootOperationsDir }); + root?.assertRootAuthority?.(); + signal?.throwIfAborted(); return localhostServiceUrl(cfg.uiPort); }, - async openUrl(url) { + async openUrl(url, signal) { // Open in the host's default browser with the platform launcher. Detached // and unref'd so the wizard isn't held open by the child, with stdio // ignored so the launcher can't scribble over the TUI. @@ -178,40 +248,62 @@ export function createDefaultActions(configManager?: ConfigManager): SetupAction const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; const args = platform === "win32" ? ["/c", "start", "", url] : [url]; await new Promise((resolve, reject) => { - const child = spawn(command, args, { stdio: "ignore", detached: true }); + signal?.throwIfAborted(); + const child = spawn(command, args, { stdio: "ignore", detached: process.platform !== "win32" }); + let forceTimer: NodeJS.Timeout | undefined; + const terminate = (force = false) => { + if (!child.pid) return; + if (process.platform === "win32") { + const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", ...(force ? ["/F"] : [])], { stdio: "ignore" }); + killer.unref(); + } else { + try { process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM"); } catch { child.kill(force ? "SIGKILL" : "SIGTERM"); } + } + }; + const abort = () => { + terminate(); + forceTimer = setTimeout(() => { terminate(true); reject(signal?.reason); }, 2_000); + }; + signal?.addEventListener("abort", abort, { once: true }); child.once("error", reject); - // The launcher returns immediately; once it has spawned we're done. - child.once("spawn", () => { - child.unref(); - resolve(); + child.once("close", code => { + if (forceTimer) clearTimeout(forceTimer); + signal?.removeEventListener("abort", abort); + if (signal?.aborted) reject(signal.reason); + else if (code === 0) resolve(); + else reject(new Error(`browser launcher exited with code ${code ?? "?"}`)); }); }); }, - async saveWhitelistSetting(rootDir, users) { + async saveWhitelistSetting(rootDir, users, signal, root) { + root?.assertRootAuthority?.(); const { updateSetting } = await import("../../api/settings.js"); // Point the client at this stack's API port rather than the saved remote. - const client = await localApiClient(rootDir); - await updateSetting("github_user_whitelist", users, client); + root?.assertRootAuthority?.(); + const client = await localApiClient(rootDir, root?.rootOperationsDir, root?.assertRootAuthority); + root?.assertRootAuthority?.(); + await updateSetting("github_user_whitelist", users, client, signal); + root?.assertRootAuthority?.(); }, hasGithubToken() { return Boolean(configManager?.getGithubToken()); }, - async fetchRelayInstallations({ relayUrl }) { + async fetchRelayInstallations({ relayUrl, signal }) { const { fetchAuthenticatedUser } = await import("../../api/relay.js"); - const me = await fetchAuthenticatedUser(relayClient(relayUrl)); + const me = await fetchAuthenticatedUser(relayClient(relayUrl, signal)); return { username: me.username, installations: me.installations }; }, - async enrollRelay({ relayUrl, installationId, label }) { + async enrollRelay({ relayUrl, installationId, label, signal }) { const { enrollRelayToken } = await import("../../api/relay.js"); - const client = relayClient(relayUrl); + const client = relayClient(relayUrl, signal); // Default the token label to the hostname, mirroring `propr relay enroll`. const result = await enrollRelayToken(client, { installationId, label: label ?? hostname() }); return { relayUrl: client.baseUrl, token: result.token }; }, - async loginWithGithub({ onLog } = {}) { + async loginWithGithub({ onLog, signal } = {}) { if (!configManager) return false; const { loginWithGithubCli } = await import("../../auth/githubLogin.js"); - const result = await loginWithGithubCli(configManager, { interactive: true, onLog }); + const result = await loginWithGithubCli(configManager, { interactive: true, onLog, signal }); if (!result.ok) onLog?.(result.message); return result.ok; }, @@ -224,12 +316,12 @@ export function createDefaultActions(configManager?: ConfigManager): SetupAction * Build a relay client bound to the stored GitHub token. The hosted relay is * the default base URL; an explicit `relayUrl` (self-hosted) overrides it. */ - function relayClient(relayUrl?: string): RelayClientOptions { + function relayClient(relayUrl?: string, signal?: AbortSignal): RelayClientOptions { const githubToken = configManager?.getGithubToken(); if (!githubToken) { throw new Error("Not logged in to GitHub. Run `propr login` first."); } - return { baseUrl: relayUrl ?? DEFAULT_PROPR_GH_RELAY_URL, githubToken }; + return { baseUrl: relayUrl ?? DEFAULT_PROPR_GH_RELAY_URL, githubToken, signal }; } } diff --git a/packages/cli/src/commands/setupCommand.test.ts b/packages/cli/src/commands/setupCommand.test.ts index aa4581fe9..a5bf83eff 100644 --- a/packages/cli/src/commands/setupCommand.test.ts +++ b/packages/cli/src/commands/setupCommand.test.ts @@ -141,11 +141,12 @@ test("--no-skill conflicts with --install-skill", async () => { }); for (const platform of ["darwin", "win32"] as const) { - test(`setup reaches the agent-skill and engine flow on ${platform}`, { concurrency: false }, async () => { + test(`setup rejects ${platform} before agent-skill, config, or engine actions`, { concurrency: false }, async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; Object.defineProperty(process, "platform", { ...originalPlatform, value: platform }); const offeredTargets: Array = []; let sequentialRuns = 0; + let configLoads = 0; const exitCodes: number[] = []; try { @@ -154,7 +155,7 @@ for (const platform of ["darwin", "win32"] as const) { offeredTargets.push(options?.explicitTargets); return []; }, - createConfig: async () => ({} as never), + createConfig: async () => { configLoads += 1; return {} as never; }, runSequential: async () => { sequentialRuns += 1; return { completed: true } as never; @@ -164,9 +165,10 @@ for (const platform of ["darwin", "win32"] as const) { await command.parseAsync(["node", "propr", "--no-tui", "--install-skill", "codex"]); - assert.deepEqual(offeredTargets, ["codex"]); - assert.equal(sequentialRuns, 1); - assert.deepEqual(exitCodes, [0]); + assert.deepEqual(offeredTargets, []); + assert.equal(configLoads, 0); + assert.equal(sequentialRuns, 0); + assert.deepEqual(exitCodes, [1]); } finally { Object.defineProperty(process, "platform", originalPlatform); } diff --git a/packages/cli/src/commands/setupCommand.ts b/packages/cli/src/commands/setupCommand.ts index f0e4f2804..207691459 100644 --- a/packages/cli/src/commands/setupCommand.ts +++ b/packages/cli/src/commands/setupCommand.ts @@ -223,6 +223,9 @@ cannot prompt and exits with guidance — scaffold non-interactively instead wit `) .action(async (options: SetupCommandOptions) => { try { + if (process.platform !== "linux") { + throw new Error(`Local setup is not supported on ${process.platform}; use a remote ProPR deployment.`); + } let skillReadline: ReturnType | undefined; const canPromptForSkill = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY); await (dependencies.offerAgentSkill ?? offerSetupAgentSkill)({ diff --git a/packages/cli/src/config/ConfigManager.ts b/packages/cli/src/config/ConfigManager.ts index c6a79439a..5d34b8dce 100644 --- a/packages/cli/src/config/ConfigManager.ts +++ b/packages/cli/src/config/ConfigManager.ts @@ -257,15 +257,23 @@ export class ConfigManager { return this.getActiveProfile()[key]; } - private async updateActiveProfile(patch: Partial): Promise { + private async updateActiveProfile(patch: Partial, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); const name = this.getActiveProfileName(); - const profiles = { ...(this.config.profiles ?? {}) }; + const previousProfiles = this.config.profiles; + const profiles = { ...(previousProfiles ?? {}) }; profiles[name] = { ...(profiles[name] ?? {}), ...patch, }; this.config.profiles = profiles; - await this.save(); + try { + await this.save(signal); + signal?.throwIfAborted(); + } catch (error) { + this.config.profiles = previousProfiles; + throw error; + } } /** @@ -273,8 +281,10 @@ export class ConfigManager { * * @returns A promise that resolves when the configuration is saved. */ - async save(): Promise { + async save(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); ensurePrivateDirectory(this.configDir); + signal?.throwIfAborted(); // Only write non-undefined values const dataToWrite: Record = {}; @@ -285,7 +295,8 @@ export class ConfigManager { } const content = JSON.stringify(dataToWrite, null, 2); - writePrivateFileAtomic(this.configFilePath, content); + writePrivateFileAtomic(this.configFilePath, content, { signal }); + signal?.throwIfAborted(); } /** @@ -340,8 +351,10 @@ export class ConfigManager { * @param token - The GitHub token to set. * @returns A promise that resolves when the token is saved. */ - async setGithubToken(token: string): Promise { - await this.updateActiveProfile({ githubToken: token }); + async setGithubToken(token: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + await this.updateActiveProfile({ githubToken: token }, signal); + signal?.throwIfAborted(); } /** diff --git a/packages/cli/src/orchestrator/index.ts b/packages/cli/src/orchestrator/index.ts index 5d1a9b86a..4d540e9cb 100644 --- a/packages/cli/src/orchestrator/index.ts +++ b/packages/cli/src/orchestrator/index.ts @@ -9,7 +9,7 @@ import { existsSync } from "node:fs"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import type { OrchestratorConfig, OrchestratorModule } from "./types.js"; import type { ConfigManager } from "../config/index.js"; @@ -24,6 +24,14 @@ export type { let cached: OrchestratorModule | undefined; let cachedPath: string | undefined; +let configuredAssetPath: string | undefined; + +/** Configure an application-packaged launcher asset before the first load. */ +export function configureOrchestratorAssetPath(path: string): void { + if (!isAbsolute(path) || !existsSync(path)) throw new Error("The configured orchestrator asset path is invalid"); + if (cached && cachedPath !== path) throw new Error("The orchestrator is already loaded from another path"); + configuredAssetPath = path; +} /** * Candidate locations for orchestrator.mjs, in priority order: @@ -31,6 +39,7 @@ let cachedPath: string | undefined; * 2. Bundled next to this module in dist. */ function resolveOrchestratorPath(): string { + if (configuredAssetPath) return configuredAssetPath; const here = dirname(fileURLToPath(import.meta.url)); const bundled = join(here, "orchestrator.mjs"); @@ -106,6 +115,7 @@ export function resolveStackRoot( export async function getHostConfig(opts: { configManager?: ConfigManager; root?: string; + readRoot?: string; }): Promise<{ orch: OrchestratorModule; cfg: OrchestratorConfig; rootDir: string }> { const orch = await loadOrchestrator(); const rootDir = resolveStackRoot(opts.configManager, opts.root); @@ -127,6 +137,6 @@ export async function getHostConfig(opts: { cliOverrides.uiTunnelEnabled = tunnelExplicit; } } - const cfg = orch.resolveHostConfig({ rootDir, env: process.env, manifestPath, cliOverrides }); + const cfg = orch.resolveHostConfig({ rootDir, readRootDir: opts.readRoot, env: process.env, manifestPath, cliOverrides }); return { orch, cfg, rootDir }; } diff --git a/packages/cli/src/orchestrator/types.ts b/packages/cli/src/orchestrator/types.ts index 2a1160d7c..36757fca5 100644 --- a/packages/cli/src/orchestrator/types.ts +++ b/packages/cli/src/orchestrator/types.ts @@ -122,12 +122,15 @@ export interface DockerCommandResult { status: number | null; stdout: string; stderr: string; + stdoutTruncated?: boolean; + stderrTruncated?: boolean; error?: Error & { code?: string }; signal?: NodeJS.Signals | null; } export interface ResolveHostConfigOptions { rootDir?: string; + readRootDir?: string; env?: NodeJS.ProcessEnv; manifestPath?: string; cliOverrides?: Record; @@ -149,10 +152,11 @@ export interface OrchestratorModule { dockerAvailable(): boolean; inspectImageFreshness(tag: string, opts?: { skipRemoteCheck?: boolean }): ImageFreshnessResult; - inspectImageFreshnessAsync(tag: string, opts?: { skipRemoteCheck?: boolean }): Promise; + inspectImageFreshnessAsync(tag: string, opts?: { skipRemoteCheck?: boolean; signal?: AbortSignal }): Promise; tagAgentLatest(key: string, imageTag: string): void; + tagAgentLatestAsync(key: string, imageTag: string, signal?: AbortSignal): Promise; ensureNetwork(cfg: OrchestratorConfig, onLog?: (line: string) => void): void; - ensureNetworkAsync(cfg: OrchestratorConfig, onLog?: (line: string) => void): Promise; + ensureNetworkAsync(cfg: OrchestratorConfig, onLog?: (line: string) => void, opts?: { signal?: AbortSignal; beforeMutation?: () => void }): Promise; ensureServiceImage( cfg: OrchestratorConfig, service: string, @@ -169,7 +173,10 @@ export interface OrchestratorModule { readonly TOGGLE_SERVICES: readonly string[]; isStackRunning(cfg: OrchestratorConfig): boolean; - isStackRunningAsync(cfg: OrchestratorConfig): Promise; + isStackRunningAsync(cfg: OrchestratorConfig, signal?: AbortSignal): Promise; + isLifecycleStackRunningAsync(cfg: OrchestratorConfig, opts?: { signal?: AbortSignal; assertRootAuthority?: () => void }): Promise; + recoverStackAsync(cfg: OrchestratorConfig, opts?: { ui?: boolean; docs?: boolean; tunnel?: boolean; signal?: AbortSignal; onLog?: (line: string) => void; assertRootAuthority?: () => void }): Promise<{ recovered: boolean }>; + stopLifecycleStackAsync(cfg: OrchestratorConfig, opts?: { signal?: AbortSignal; onLog?: (line: string) => void; assertRootAuthority?: () => void }): Promise<{ failed: string[] }>; startService(cfg: OrchestratorConfig, service: string, opts?: OnLogOption): ServiceState | undefined; startServiceAsync(cfg: OrchestratorConfig, service: string, opts?: OnLogOption): Promise; @@ -188,7 +195,7 @@ export interface OrchestratorModule { ): StackStatus; startStackAsync( cfg: OrchestratorConfig, - opts?: { ui?: boolean; docs?: boolean; tunnel?: boolean; onLog?: (line: string) => void } + opts?: { ui?: boolean; docs?: boolean; tunnel?: boolean; onLog?: (line: string) => void; signal?: AbortSignal; beforeLaunch?: () => void } ): Promise; stopStack( cfg: OrchestratorConfig, @@ -209,5 +216,5 @@ export interface OrchestratorModule { containerExists(cfg: OrchestratorConfig, name: string): boolean; docker(args: string[], opts?: DockerCommandOptions): DockerCommandResult; - dockerAsync(args: string[], opts?: { timeout?: number }): Promise; + dockerAsync(args: string[], opts?: { timeout?: number; signal?: AbortSignal; maxOutputBytes?: number }): Promise; } diff --git a/packages/cli/src/utils/envFile.ts b/packages/cli/src/utils/envFile.ts index 963504b14..af8650ef7 100644 --- a/packages/cli/src/utils/envFile.ts +++ b/packages/cli/src/utils/envFile.ts @@ -9,7 +9,7 @@ * literally and must fit on one line. */ -import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { readPrivateFile, writePrivateFileAtomic } from "@propr/local-setup"; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -30,7 +30,8 @@ export function upsertEnvVars(envPath: string, vars: Record): vo } } - const raw = existsSync(envPath) ? readFileSync(envPath, "utf-8") : ""; + const previous = readPrivateFile(envPath); + const raw = previous?.toString("utf-8") ?? ""; const lines = raw.split(/\r?\n/); // Drop trailing blank lines so appends stay tidy; we re-add one newline at the end. @@ -50,24 +51,7 @@ export function upsertEnvVars(envPath: string, vars: Record): vo } } - const isNew = !existsSync(envPath); - let tightenedFrom: number | null = null; - if (!isNew) { - try { - const before = statSync(envPath).mode & 0o777; - if (before !== 0o600) { - chmodSync(envPath, 0o600); - tightenedFrom = before; - } - } catch { - // Best-effort — may fail on Windows or non-owned files. - } - } - - writeFileSync(envPath, `${lines.join("\n")}\n`, { encoding: "utf-8", mode: isNew ? 0o600 : undefined }); - if (tightenedFrom !== null) { - console.warn(`Note: tightened ${envPath} permissions from ${tightenedFrom.toString(8)} to 600 (secrets file).`); - } + writePrivateFileAtomic(envPath, `${lines.join("\n")}\n`); } /** @@ -87,31 +71,18 @@ export function upsertEnvVars(envPath: string, vars: Record): vo * the next read or restart. */ export function clearEnvKeys(envPath: string, keys: string[]): void { - if (keys.length === 0 || !existsSync(envPath)) return; + if (keys.length === 0) return; - const lines = readFileSync(envPath, "utf-8").split(/\r?\n/); + const previous = readPrivateFile(envPath); + if (!previous) return; + const lines = previous.toString("utf-8").split(/\r?\n/); const patterns = keys.map((key) => new RegExp(`^\\s*(export\\s+)?${escapeRegExp(key)}\\s*=`)); const kept = lines.filter((line) => !patterns.some((pattern) => pattern.test(line))); // Nothing matched → leave the file (and its mode) untouched. if (kept.length === lines.length) return; - // Tighten permissions like upsertEnvVars does — this is still the secrets file. - let tightenedFrom: number | null = null; - try { - const before = statSync(envPath).mode & 0o777; - if (before !== 0o600) { - chmodSync(envPath, 0o600); - tightenedFrom = before; - } - } catch { - // Best-effort — may fail on Windows or non-owned files. - } - // Drop trailing blank lines, then re-add exactly one terminating newline. while (kept.length > 0 && kept[kept.length - 1] === "") kept.pop(); - writeFileSync(envPath, `${kept.join("\n")}\n`, "utf-8"); - if (tightenedFrom !== null) { - console.warn(`Note: tightened ${envPath} permissions from ${tightenedFrom.toString(8)} to 600 (secrets file).`); - } + writePrivateFileAtomic(envPath, `${kept.join("\n")}\n`); } diff --git a/packages/cli/src/utils/privateFilesystem.ts b/packages/cli/src/utils/privateFilesystem.ts index e1dd10146..fecbf89c0 100644 --- a/packages/cli/src/utils/privateFilesystem.ts +++ b/packages/cli/src/utils/privateFilesystem.ts @@ -1,92 +1,9 @@ -import { - chmodSync, - closeSync, - fsyncSync, - lstatSync, - mkdirSync, - openSync, - renameSync, - unlinkSync, - writeFileSync, -} from "node:fs"; -import type { Stats } from "node:fs"; -import { randomUUID } from "node:crypto"; -import { dirname } from "node:path"; - -export const PRIVATE_DIRECTORY_MODE = 0o700; -export const PRIVATE_FILE_MODE = 0o600; - -function lstatIfPresent(targetPath: string): Stats | undefined { - try { - return lstatSync(targetPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; - throw error; - } -} - -function assertOwned(stat: Stats, targetPath: string): void { - if (process.platform === "win32") return; - const currentUid = process.getuid?.(); - if (currentUid !== undefined && stat.uid !== currentUid) { - throw new Error(`Refusing to use ${targetPath}: it is not owned by the current user`); - } -} - -export function secureExistingPrivateDirectory(directoryPath: string): boolean { - const stat = lstatIfPresent(directoryPath); - if (!stat) return false; - if (stat.isSymbolicLink()) throw new Error(`Refusing to use symbolic-link directory ${directoryPath}`); - if (!stat.isDirectory()) throw new Error(`Expected a directory at ${directoryPath}`); - assertOwned(stat, directoryPath); - if (process.platform !== "win32" && (stat.mode & 0o777) !== PRIVATE_DIRECTORY_MODE) { - chmodSync(directoryPath, PRIVATE_DIRECTORY_MODE); - } - return true; -} - -export function ensurePrivateDirectory(directoryPath: string): void { - if (!lstatIfPresent(directoryPath)) { - mkdirSync(directoryPath, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); - } - secureExistingPrivateDirectory(directoryPath); -} - -export function secureExistingPrivateFile(filePath: string): boolean { - const stat = lstatIfPresent(filePath); - if (!stat) return false; - if (stat.isSymbolicLink()) throw new Error(`Refusing to use symbolic-link file ${filePath}`); - if (!stat.isFile()) throw new Error(`Expected a regular file at ${filePath}`); - assertOwned(stat, filePath); - if (process.platform !== "win32" && (stat.mode & 0o777) !== PRIVATE_FILE_MODE) { - chmodSync(filePath, PRIVATE_FILE_MODE); - } - return true; -} - -export interface PrivateFileWriteOptions { - secureParent?: boolean; -} - -export function writePrivateFileAtomic( - filePath: string, - content: string | Buffer, - options: PrivateFileWriteOptions = {}, -): void { - if (options.secureParent !== false) ensurePrivateDirectory(dirname(filePath)); - secureExistingPrivateFile(filePath); - const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`; - let descriptor: number | undefined; - try { - descriptor = openSync(tempPath, "wx", PRIVATE_FILE_MODE); - writeFileSync(descriptor, content); - fsyncSync(descriptor); - closeSync(descriptor); - descriptor = undefined; - renameSync(tempPath, filePath); - if (process.platform !== "win32") chmodSync(filePath, PRIVATE_FILE_MODE); - } finally { - if (descriptor !== undefined) closeSync(descriptor); - try { unlinkSync(tempPath); } catch { /* Best-effort cleanup after success or failure. */ } - } -} +export { + PRIVATE_DIRECTORY_MODE, + PRIVATE_FILE_MODE, + ensurePrivateDirectory, + secureExistingPrivateDirectory, + secureExistingPrivateFile, + writePrivateFileAtomic, + type PrivateFileWriteOptions, +} from "@propr/local-setup"; diff --git a/packages/local-setup/src/agents.ts b/packages/local-setup/src/agents.ts index 2f58936e2..bb3821ea5 100644 --- a/packages/local-setup/src/agents.ts +++ b/packages/local-setup/src/agents.ts @@ -22,6 +22,12 @@ */ import { AGENT_DEFAULTS, type AgentType } from "@propr/shared"; +import { rethrowCancellation } from "./cancellation.js"; + +export interface RootOperationBoundary { + rootOperationsDir?: string; + assertRootAuthority?(): void; +} /** Minimal backend agent shape needed by the setup engine. */ export interface AgentConfig { @@ -59,15 +65,15 @@ export interface AgentConnectivityResult { */ export interface AgentSetupActions { /** List the agents currently configured in the running backend. */ - listAgents(rootDir: string): Promise; + listAgents(rootDir: string, signal?: AbortSignal, root?: RootOperationBoundary): Promise; /** Add a new agent to the backend configuration. */ - addAgent(rootDir: string, options: AddAgentOptions): Promise; + addAgent(rootDir: string, options: AddAgentOptions, signal?: AbortSignal, root?: RootOperationBoundary): Promise; /** Agent types that support an interactive image login (have a login plan). */ - loginableAgents(): Promise; + loginableAgents(signal?: AbortSignal): Promise; /** Authenticate one agent through its image; interactive (inherits stdio). */ - loginAgent(rootDir: string, type: string): Promise; + loginAgent(rootDir: string, type: string, signal?: AbortSignal, root?: RootOperationBoundary): Promise; /** Run a live, image-only request that mirrors the worker credential mount. */ - validateAgents(rootDir: string, types: string[]): Promise; + validateAgents(rootDir: string, types: string[], signal?: AbortSignal, root?: RootOperationBoundary): Promise; } /** Inputs for {@link runAgentSetup}. */ @@ -82,6 +88,7 @@ export interface AgentSetupParams { */ confirmLogin?(ctx: { candidates: string[]; rootDir: string }): Promise; onLog?(line: string): void; + signal?: AbortSignal; } /** What the agent-setup step did, for the caller to render as a step status. */ @@ -111,7 +118,7 @@ export interface AgentSetupOutcome { * the caller can settle the step as a warning rather than aborting setup. */ export async function runAgentSetup(params: AgentSetupParams): Promise { - const { rootDir, selectedAgents, actions, confirmLogin, onLog } = params; + const { rootDir, selectedAgents, actions, confirmLogin, onLog, signal } = params; const outcome: AgentSetupOutcome = { added: [], alreadyConfigured: [], @@ -129,8 +136,10 @@ export async function runAgentSetup(params: AgentSetupParams): Promise agent.type)); for (const type of selectedAgents) { + signal?.throwIfAborted(); if (configuredTypes.has(type as AgentType)) { outcome.alreadyConfigured.push(type); continue; @@ -156,10 +166,12 @@ export async function runAgentSetup(params: AgentSetupParams): Promise; + signal?.throwIfAborted(); try { - loginable = new Set(await actions.loginableAgents()); + loginable = new Set(await actions.loginableAgents(signal)); + signal?.throwIfAborted(); } catch (error) { + rethrowCancellation(error); outcome.errors.push(`could not determine which agents support image login: ${(error as Error).message}`); loginable = new Set(); } @@ -179,6 +194,7 @@ export async function runAgentSetup(params: AgentSetupParams): Promise { + test(`the setup engine rejects ${platform} before reporter or host actions`, async () => { let checksRun = false; + let reports = 0; const actions = { runChecks: async () => { checksRun = true; @@ -37,12 +38,13 @@ for (const platform of ["darwin", "win32"] as const) { }; }, } as unknown as SetupActions; - const result = await runSetup({ root: "/stack", platform, actions }); + const result = await runSetup({ root: "/stack", platform, actions, reporter: { onState: () => { reports += 1; } } }); - assert.equal(checksRun, true); + assert.equal(checksRun, false); + assert.equal(reports, 0); assert.equal(result.completed, false); assert.equal(result.capability.kind, "remote-only"); - assert.notEqual(result.errors[0]?.code, "local-unsupported"); + assert.equal(result.errors[0]?.code, "local-unsupported"); }); } diff --git a/packages/local-setup/src/engine.ts b/packages/local-setup/src/engine.ts index ac19ddf67..f6c37a2cf 100644 --- a/packages/local-setup/src/engine.ts +++ b/packages/local-setup/src/engine.ts @@ -51,7 +51,9 @@ import { import { runAgentSetup, type AgentSetupActions, + type RootOperationBoundary, } from "./agents.js"; +import { isSetupCancellation } from "./cancellation.js"; import { createSetupState, getStep, @@ -340,6 +342,10 @@ export interface InitStackResult { export interface PullImagesParams { rootDir: string; + /** Descriptor-anchored root used only to read configuration. */ + rootOperationsDir?: string; + /** Revalidate fixed-root identity at every external mutation boundary. */ + assertRootAuthority?(): void; /** Agent types whose images should be pulled (in addition to core images). */ agentTypes: string[]; onLog?: (line: string) => void; @@ -357,14 +363,20 @@ export interface PullImagesResult { export interface StartStackParams { rootDir: string; + /** Main-process-only anchored path used to read setup files, never mounted. */ + rootOperationsDir?: string; ui?: boolean; docs?: boolean; onLog?: (line: string) => void; signal?: AbortSignal; + /** Main-process authority check invoked at each Docker container handoff. */ + assertRootAuthority?(): void; } export interface BackendHealthParams { rootDir: string; + rootOperationsDir?: string; + assertRootAuthority?(): void; timeoutMs?: number; signal?: AbortSignal; } @@ -401,9 +413,9 @@ export function classifyBackendAccessError(error: unknown): BackendHealth | unde */ export interface SetupActions extends AgentSetupActions { runChecks(options: RunChecksOptions): Promise; - inspectStackInit(rootDir: string): StackInitState; + inspectStackInit(rootDir: string, signal?: AbortSignal): StackInitState; /** Inspect the configured datastore's durable administrator state without modifying it. */ - inspectDatastoreAdministrators(rootDir: string): Promise; + inspectDatastoreAdministrators(rootDir: string, signal?: AbortSignal): Promise; scaffoldStack(options: InitStackOptions): Promise; /** * Persist the resolved stack root to the CLI config so later `propr start` / @@ -412,36 +424,37 @@ export interface SetupActions extends AgentSetupActions { * already-initialized root that setup leaves untouched), which would otherwise * leave config pointing at a stale root or the cwd. A no-op without a config. */ - persistStackRoot(rootDir: string): Promise; - readEnvVars(rootDir: string): Record; - applyEnvSelection(rootDir: string, vars: Record, opts?: { overwrite?: boolean }): EnvSelectionResult; + persistStackRoot(rootDir: string, signal?: AbortSignal): Promise; + readEnvVars(rootDir: string, signal?: AbortSignal): Record; + applyEnvSelection(rootDir: string, vars: Record, opts?: { overwrite?: boolean }, signal?: AbortSignal): EnvSelectionResult; /** Remove keys from `.env` entirely (used to clear a value, not blank it). */ - clearEnvKeys(rootDir: string, keys: string[]): void; - detectGithubAuthMode(rootDir: string): GithubAuthModeResult; + clearEnvKeys(rootDir: string, keys: string[], signal?: AbortSignal): void; + detectGithubAuthMode(rootDir: string, signal?: AbortSignal): GithubAuthModeResult; /** Ensure a selected agent's host credential path is a directory, creating it securely when absent. */ - prepareAgentCredentialDir(path: string): void; + prepareAgentCredentialDir(path: string, signal?: AbortSignal): void; pullImages(params: PullImagesParams): Promise; - isStackRunning(rootDir: string): Promise; + isStackRunning(rootDir: string, signal?: AbortSignal, root?: RootOperationBoundary): Promise; startStack(params: StartStackParams): Promise; checkBackendHealth(params: BackendHealthParams): Promise; - addRepository(selection: RepoSelection, rootDir: string): Promise; - resolveUiUrl(rootDir: string): Promise; + addRepository(selection: RepoSelection, rootDir: string, signal?: AbortSignal, root?: RootOperationBoundary): Promise; + resolveUiUrl(rootDir: string, signal?: AbortSignal, root?: RootOperationBoundary): Promise; /** Open `url` in the host's default browser (best-effort; may reject). */ - openUrl(url: string): Promise; + openUrl(url: string, signal?: AbortSignal): Promise; /** * Save the user whitelist through the running backend's settings API. A * partial update — only the whitelist key is sent, so unrelated settings are * left intact. */ - saveWhitelistSetting(rootDir: string, users: string[]): Promise; + saveWhitelistSetting(rootDir: string, users: string[], signal?: AbortSignal, root?: RootOperationBoundary): Promise; /** True when a GitHub user token is stored (relay enrollment and protected local API calls need it). */ - hasGithubToken(): boolean; + hasGithubToken(signal?: AbortSignal): boolean; /** * List the relay installations the stored GitHub identity can access (drives * auto-select / the picker during relay enrollment). Throws if not logged in. */ fetchRelayInstallations(params: { relayUrl?: string; + signal?: AbortSignal; }): Promise<{ username: string; installations: AuthorizedInstallation[] }>; /** * Mint a relay token for `installationId`, returning the token and the relay @@ -451,11 +464,12 @@ export interface SetupActions extends AgentSetupActions { relayUrl?: string; installationId: string; label?: string; + signal?: AbortSignal; }): Promise<{ relayUrl: string; token: string }>; /** Authenticate with GitHub via the interactive `gh` CLI and store the token. */ - loginWithGithub(params?: { onLog?: (line: string) => void }): Promise; + loginWithGithub(params?: { onLog?: (line: string) => void; signal?: AbortSignal }): Promise; /** Host preference used to select managed browser authentication. */ - getTunnelEnabled?(rootDir: string): boolean | undefined; + getTunnelEnabled?(rootDir: string, signal?: AbortSignal): boolean | undefined; } /** Options for {@link runSetup}. */ @@ -555,7 +569,7 @@ async function runSetupAttempt(options: RunSetupOptions): Promise getStep(state, id)!; - const begin = (id: SetupStepId): void => { + const checkCancelled = (): void => { if (options.signal?.aborted) { state = { ...state, @@ -565,6 +579,21 @@ async function runSetupAttempt(options: RunSetupOptions): Promise { + // A cancelled startup with residual run-owned containers is not a clean + // cancellation. Preserve the orchestrator's explicit failure so callers + // can require operator attention instead of reporting cancellation done. + if (error && typeof error === "object" + && (error as { code?: unknown }).code === "PROPR_SETUP_CLEANUP_INCOMPLETE") { + throw error; + } + if (!isSetupCancellation(error)) return; + checkCancelled(); + throw error; + }; + const begin = (id: SetupStepId): void => { + checkCancelled(); state = updateStep(state, id, { status: "active", detail: undefined, nextAction: undefined }); emit(); const step = safeStep(stepOf(id)); @@ -622,12 +651,15 @@ async function runSetupAttempt(options: RunSetupOptions): Promise a.type), detected }) : detected; + checkCancelled(); // Guard the engine boundary: a renderer may hand back unknown or duplicate // agent names. Keep only types we know about, de-duped (first occurrence // wins), so unknown names never reach pullImages() and a duplicate can't @@ -896,6 +948,7 @@ async function runSetupAttempt(options: RunSetupOptions): Promise known.has(type)); const pull = await actions.pullImages({ rootDir, agentTypes: selectedAgents, onLog: log, signal: options.signal }); + checkCancelled(); if (pull.failedCore.length > 0) { settle("pull-images", { status: "failed", @@ -915,6 +968,7 @@ async function runSetupAttempt(options: RunSetupOptions): Promise = {}; - const existingEnv = actions.readEnvVars(rootDir); + const existingEnv = actions.readEnvVars(rootDir, options.signal); for (const type of selectedAgents) { const desc = catalog.find((a) => a.type === type); if (!desc) continue; @@ -947,17 +1001,19 @@ async function runSetupAttempt(options: RunSetupOptions): Promise 0 ? `recorded ${applied.written.length} credential dir(s)` : "no new credentials to record"); if (applied.skipped.length > 0) detailParts.push(`${applied.skipped.length} already set`); settle("configure-agents", { status: "done", detail: detailParts.join("; ") }); } } catch (error) { + rethrowIfCancelled(error); settle("configure-agents", { status: "failed", detail: `could not record agent credentials: ${(error as Error).message}`, @@ -978,20 +1034,25 @@ async function runSetupAttempt(options: RunSetupOptions): Promise 0) { - actions.applyEnvSelection(rootDir, authDecision.vars, { overwrite: true }); + checkCancelled(); + actions.applyEnvSelection(rootDir, authDecision.vars, { overwrite: true }, options.signal); } resolvedAuth = relayDoneDetail ? { mode: "relay", warnings: [] } - : actions.detectGithubAuthMode(rootDir); + : actions.detectGithubAuthMode(rootDir, options.signal); } catch (error) { + rethrowIfCancelled(error); settle("github-auth", { status: "failed", detail: `could not configure GitHub auth: ${(error as Error).message}`, @@ -1017,10 +1078,10 @@ async function runSetupAttempt(options: RunSetupOptions): Promise - (actions.readEnvVars(rootDir).PROPR_ADMIN_USERS ?? "") + (actions.readEnvVars(rootDir, options.signal).PROPR_ADMIN_USERS ?? "") .split(",") .map((value) => value.trim()) .filter(Boolean); @@ -1032,15 +1093,17 @@ async function runSetupAttempt(options: RunSetupOptions): Promise String(installation.installation_id) === installationId @@ -1061,12 +1124,14 @@ async function runSetupAttempt(options: RunSetupOptions): Promise s.trim()).filter(Boolean); const demoMode = resolvedAuth.mode === "demo"; let whitelist: string[] | null = null; - if (prompts.configureWhitelist) whitelist = await prompts.configureWhitelist({ current: currentWhitelist, demoMode }); + if (prompts.configureWhitelist) { + whitelist = await prompts.configureWhitelist({ current: currentWhitelist, demoMode }); + checkCancelled(); + } if (whitelist !== null) { // Trim, drop blanks, and de-dupe (first occurrence wins) so the value // matches saveWhitelist's "cleaned, de-duped usernames" contract — a @@ -1342,11 +1424,12 @@ async function runSetupAttempt(options: RunSetupOptions): Promise actions.saveWhitelistSetting(rootDir, users), + saveViaSettings: (users) => actions.saveWhitelistSetting(rootDir, users, options.signal), saveViaEnv: (users) => { // A non-empty list is written; clearing to "none" must *remove* the key // rather than blank it. applyEnvSelection ignores blank values (so it @@ -1354,12 +1437,14 @@ async function runSetupAttempt(options: RunSetupOptions): Promise 0) { - actions.applyEnvSelection(rootDir, { GITHUB_USER_WHITELIST: users.join(",") }, { overwrite: true }); + actions.applyEnvSelection(rootDir, { GITHUB_USER_WHITELIST: users.join(",") }, { overwrite: true }, options.signal); } else { - actions.clearEnvKeys(rootDir, ["GITHUB_USER_WHITELIST"]); + actions.clearEnvKeys(rootDir, ["GITHUB_USER_WHITELIST"], options.signal); } }, + signal: options.signal, }); + checkCancelled(); const where = saved.target === "settings" ? "via settings API" : "in .env"; const summary = cleaned.length > 0 ? `${cleaned.length} user(s) allowed (${where})` : `whitelist cleared (${where})`; if (saved.error) { @@ -1383,6 +1468,7 @@ async function runSetupAttempt(options: RunSetupOptions): Promise { + const capability = getLocalSetupCapability(options.platform); + if (!capability.supported) { + const rootDir = resolve(options.root ?? process.cwd()); + return { + rootDir, + state: createSetupState(rootDir), + capability, + completed: false, + cancelled: false, + errors: [{ code: "local-unsupported", message: capability.reason, retryable: false }], + }; + } try { return await runSetupAttempt(options); } catch (error) { diff --git a/packages/local-setup/src/envFile.ts b/packages/local-setup/src/envFile.ts index 963504b14..557c2b680 100644 --- a/packages/local-setup/src/envFile.ts +++ b/packages/local-setup/src/envFile.ts @@ -9,13 +9,13 @@ * literally and must fit on one line. */ -import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { readPrivateFile, writePrivateFileAtomic } from "./privateFilesystem.js"; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -export function upsertEnvVars(envPath: string, vars: Record): void { +export function upsertEnvVars(envPath: string, vars: Record, signal?: AbortSignal): void { for (const [key, value] of Object.entries(vars)) { if (/[\r\n]/.test(value)) { throw new Error(`${key} cannot contain newlines; Docker --env-file only supports one KEY=VALUE assignment per line.`); @@ -30,7 +30,8 @@ export function upsertEnvVars(envPath: string, vars: Record): vo } } - const raw = existsSync(envPath) ? readFileSync(envPath, "utf-8") : ""; + const previous = readPrivateFile(envPath); + const raw = previous?.toString("utf-8") ?? ""; const lines = raw.split(/\r?\n/); // Drop trailing blank lines so appends stay tidy; we re-add one newline at the end. @@ -50,24 +51,7 @@ export function upsertEnvVars(envPath: string, vars: Record): vo } } - const isNew = !existsSync(envPath); - let tightenedFrom: number | null = null; - if (!isNew) { - try { - const before = statSync(envPath).mode & 0o777; - if (before !== 0o600) { - chmodSync(envPath, 0o600); - tightenedFrom = before; - } - } catch { - // Best-effort — may fail on Windows or non-owned files. - } - } - - writeFileSync(envPath, `${lines.join("\n")}\n`, { encoding: "utf-8", mode: isNew ? 0o600 : undefined }); - if (tightenedFrom !== null) { - console.warn(`Note: tightened ${envPath} permissions from ${tightenedFrom.toString(8)} to 600 (secrets file).`); - } + writePrivateFileAtomic(envPath, `${lines.join("\n")}\n`, { signal }); } /** @@ -86,32 +70,19 @@ export function upsertEnvVars(envPath: string, vars: Record): vo * switching auth/intake modes) use this so the value does not silently return on * the next read or restart. */ -export function clearEnvKeys(envPath: string, keys: string[]): void { - if (keys.length === 0 || !existsSync(envPath)) return; +export function clearEnvKeys(envPath: string, keys: string[], signal?: AbortSignal): void { + if (keys.length === 0) return; - const lines = readFileSync(envPath, "utf-8").split(/\r?\n/); + const previous = readPrivateFile(envPath); + if (!previous) return; + const lines = previous.toString("utf-8").split(/\r?\n/); const patterns = keys.map((key) => new RegExp(`^\\s*(export\\s+)?${escapeRegExp(key)}\\s*=`)); const kept = lines.filter((line) => !patterns.some((pattern) => pattern.test(line))); // Nothing matched → leave the file (and its mode) untouched. if (kept.length === lines.length) return; - // Tighten permissions like upsertEnvVars does — this is still the secrets file. - let tightenedFrom: number | null = null; - try { - const before = statSync(envPath).mode & 0o777; - if (before !== 0o600) { - chmodSync(envPath, 0o600); - tightenedFrom = before; - } - } catch { - // Best-effort — may fail on Windows or non-owned files. - } - // Drop trailing blank lines, then re-add exactly one terminating newline. while (kept.length > 0 && kept[kept.length - 1] === "") kept.pop(); - writeFileSync(envPath, `${kept.join("\n")}\n`, "utf-8"); - if (tightenedFrom !== null) { - console.warn(`Note: tightened ${envPath} permissions from ${tightenedFrom.toString(8)} to 600 (secrets file).`); - } + writePrivateFileAtomic(envPath, `${kept.join("\n")}\n`, { signal }); } diff --git a/packages/local-setup/src/github.ts b/packages/local-setup/src/github.ts index ede47e447..0bfdb9749 100644 --- a/packages/local-setup/src/github.ts +++ b/packages/local-setup/src/github.ts @@ -34,6 +34,7 @@ */ import type { GithubAuthMode, GithubEventIntakeMode } from "@propr/shared"; +import { rethrowCancellation } from "./cancellation.js"; /** * How the backend ingests GitHub events. Aliased to the shared @@ -238,6 +239,8 @@ export interface SaveWhitelistParams { saveViaSettings(users: string[]): Promise; /** Persist into `.env` (non-destructive, single key). */ saveViaEnv(users: string[]): void; + /** Abort is observed before each persistence commit and never triggers fallback. */ + signal?: AbortSignal; } /** @@ -250,20 +253,25 @@ export interface SaveWhitelistParams { * unrelated settings are never overwritten. */ export async function saveWhitelist(params: SaveWhitelistParams): Promise { - const { users, backendRunning, saveViaSettings, saveViaEnv } = params; + const { users, backendRunning, saveViaSettings, saveViaEnv, signal } = params; + signal?.throwIfAborted(); if (backendRunning) { try { await saveViaSettings(users); + signal?.throwIfAborted(); // Mirror into `.env` so the whitelist persists across `propr start`. saveViaEnv(users); return { target: "settings", count: users.length }; } catch (error) { + rethrowCancellation(error); + signal?.throwIfAborted(); // The backend rejected the update (or was unreachable after all) — keep // the value in `.env` so it is not lost, and surface why. saveViaEnv(users); return { target: "env", count: users.length, error: (error as Error).message }; } } + signal?.throwIfAborted(); saveViaEnv(users); return { target: "env", count: users.length }; } diff --git a/packages/local-setup/src/index.ts b/packages/local-setup/src/index.ts index b0599dc8d..78f8057dd 100644 --- a/packages/local-setup/src/index.ts +++ b/packages/local-setup/src/index.ts @@ -1,5 +1,7 @@ export * from "./agents.js"; +export * from "./cancellation.js"; export * from "./engine.js"; export * from "./github.js"; +export * from "./privateFilesystem.js"; export * from "./state.js"; export * from "./types.js"; diff --git a/packages/local-setup/src/privateFilesystem.ts b/packages/local-setup/src/privateFilesystem.ts new file mode 100644 index 000000000..d63f6399f --- /dev/null +++ b/packages/local-setup/src/privateFilesystem.ts @@ -0,0 +1,195 @@ +import { randomBytes } from "node:crypto"; +import { + chmodSync, + closeSync, + constants, + fstatSync, + fchmodSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + renameSync, + unlinkSync, + writeSync, + type Stats, +} from "node:fs"; +import { dirname, isAbsolute, join, parse, resolve } from "node:path"; + +export const PRIVATE_DIRECTORY_MODE = 0o700; +export const PRIVATE_FILE_MODE = 0o600; +const O_CLOEXEC = (constants as unknown as Record).O_CLOEXEC ?? (process.platform === 'linux' ? 0o2000000 : 0); + +interface DescriptorRoot { + descriptor: number; + root: string; + suffix: string[]; +} + +/** Recognize only this process's explicit Linux descriptor paths. */ +function descriptorRootFor(targetPath: string): DescriptorRoot | undefined { + if (process.platform !== "linux") return undefined; + const absolute = resolve(targetPath); + const prefix = `/proc/${process.pid}/fd/`; + if (!absolute.startsWith(prefix)) return undefined; + const [descriptorText, ...suffix] = absolute.slice(prefix.length).split("/").filter(Boolean); + if (!descriptorText || !/^(?:0|[1-9][0-9]*)$/.test(descriptorText)) return undefined; + const descriptor = Number(descriptorText); + const opened = fstatSync(descriptor); + if (!opened.isDirectory()) throw new Error("Descriptor-root path is not anchored to a directory"); + return { descriptor, root: `${prefix}${descriptorText}`, suffix }; +} + +function lstatIfPresent(targetPath: string): Stats | undefined { + try { + return lstatSync(targetPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +function assertOwned(stat: Stats, targetPath: string): void { + if (process.platform === "win32") return; + const currentUid = process.getuid?.(); + if (currentUid !== undefined && stat.uid !== currentUid) { + throw new Error(`Refusing to use ${targetPath}: it is not owned by the current user`); + } +} + +function assertNoSymlinkComponents(targetPath: string): void { + const absolute = resolve(targetPath); + if (!isAbsolute(absolute) || absolute.includes("\0")) throw new Error("Invalid private filesystem path"); + const anchored = descriptorRootFor(absolute); + const root = anchored?.root ?? parse(absolute).root; + let cursor = root; + const components = anchored?.suffix ?? absolute.slice(root.length).split(/[\\/]+/).filter(Boolean); + for (const component of components) { + cursor = join(cursor, component); + const stat = lstatIfPresent(cursor); + if (!stat) break; + // Let the exact-target validator report whether the link was supplied as a + // file or directory. Components above the target can never be followed. + if (stat.isSymbolicLink() && cursor === absolute) return; + if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symbolic-link directory component ${cursor}`); + } +} + +export function secureExistingPrivateDirectory(directoryPath: string): boolean { + assertNoSymlinkComponents(directoryPath); + const anchored = descriptorRootFor(directoryPath); + if (anchored?.suffix.length === 0) { + const stat = fstatSync(anchored.descriptor); + assertOwned(stat, directoryPath); + if (process.platform !== "win32" && (stat.mode & 0o777) !== PRIVATE_DIRECTORY_MODE) { + fchmodSync(anchored.descriptor, PRIVATE_DIRECTORY_MODE); + } + return true; + } + const stat = lstatIfPresent(directoryPath); + if (!stat) return false; + if (stat.isSymbolicLink()) throw new Error(`Refusing to use symbolic-link directory ${directoryPath}`); + if (!stat.isDirectory()) throw new Error(`Expected a directory at ${directoryPath}`); + assertOwned(stat, directoryPath); + if (!anchored && realpathSync(directoryPath) !== resolve(directoryPath)) throw new Error(`Refusing to use linked directory ${directoryPath}`); + if (process.platform !== "win32" && (stat.mode & 0o777) !== PRIVATE_DIRECTORY_MODE) { + chmodSync(directoryPath, PRIVATE_DIRECTORY_MODE); + } + return true; +} + +export function ensurePrivateDirectory(directoryPath: string): void { + assertNoSymlinkComponents(directoryPath); + if (!lstatIfPresent(directoryPath)) mkdirSync(directoryPath, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); + secureExistingPrivateDirectory(directoryPath); +} + +export function secureExistingPrivateFile(filePath: string): boolean { + assertNoSymlinkComponents(filePath); + const stat = lstatIfPresent(filePath); + if (!stat) return false; + if (stat.isSymbolicLink()) throw new Error(`Refusing to use symbolic-link file ${filePath}`); + if (!stat.isFile()) throw new Error(`Expected a regular file at ${filePath}`); + if (stat.nlink !== 1) throw new Error(`Refusing to use hard-linked file ${filePath}`); + assertOwned(stat, filePath); + if (process.platform !== "win32" && (stat.mode & 0o777) !== PRIVATE_FILE_MODE) chmodSync(filePath, PRIVATE_FILE_MODE); + return true; +} + +export interface PrivateFileWriteOptions { + secureParent?: boolean; + signal?: AbortSignal; + /** Test seam for simulating a commit failure after the durable temp write. */ + beforeRename?(): void; +} + +/** + * Publish a private file without ever modifying the previous inode in place. + * The random same-directory temporary is exclusive, fully written and synced; + * cancellation is observed immediately before the only commit point. + */ +export function writePrivateFileAtomic( + filePath: string, + content: string | Buffer, + options: PrivateFileWriteOptions = {}, +): void { + const target = resolve(filePath); + const parent = dirname(target); + if (options.secureParent !== false) ensurePrivateDirectory(parent); + else secureExistingPrivateDirectory(parent); + secureExistingPrivateFile(target); + const temporary = join(parent, `.${randomBytes(24).toString("hex")}.tmp`); + const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content); + let descriptor: number | undefined; + let directoryDescriptor: number | undefined; + try { + descriptor = openSync( + temporary, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW | O_CLOEXEC, + PRIVATE_FILE_MODE, + ); + const opened = fstatSync(descriptor); + if (!opened.isFile() || opened.nlink !== 1) throw new Error("Atomic write temporary is not a private regular file"); + let offset = 0; + while (offset < bytes.length) offset += writeSync(descriptor, bytes, offset, bytes.length - offset); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = undefined; + options.beforeRename?.(); + options.signal?.throwIfAborted(); + renameSync(temporary, target); + const final = lstatSync(target); + if (!final.isFile() || final.isSymbolicLink() || final.nlink !== 1) throw new Error("Atomic write produced an unsafe target"); + assertOwned(final, target); + if (process.platform !== "win32") chmodSync(target, PRIVATE_FILE_MODE); + directoryDescriptor = openSync(parent, constants.O_RDONLY | constants.O_DIRECTORY | O_CLOEXEC); + fsyncSync(directoryDescriptor); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + if (directoryDescriptor !== undefined) closeSync(directoryDescriptor); + try { unlinkSync(temporary); } catch { /* Removed by rename or best-effort failure cleanup. */ } + } +} + +/** Open a private file without following links and read that exact inode once. */ +export function readPrivateFile(filePath: string, maxBytes = 1024 * 1024): Buffer | undefined { + const target = resolve(filePath); + assertNoSymlinkComponents(target); + let descriptor: number; + try { + descriptor = openSync(target, constants.O_RDONLY | constants.O_NOFOLLOW | O_CLOEXEC); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + try { + const stat = fstatSync(descriptor); + if (!stat.isFile() || stat.nlink !== 1 || stat.size > maxBytes) throw new Error(`Refusing to read unsafe private file ${target}`); + assertOwned(stat, target); + return readFileSync(descriptor); + } finally { + closeSync(descriptor); + } +} diff --git a/packages/local-setup/src/state.test.ts b/packages/local-setup/src/state.test.ts index 36e528def..499eb2885 100644 --- a/packages/local-setup/src/state.test.ts +++ b/packages/local-setup/src/state.test.ts @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { linkSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { applyEnvSelection, clearEnvKeys, inspectStackInit, readEnvVars } from "./state.js"; +import { applyEnvSelection, clearEnvKeys, inspectStackInit, readEnvVars, writePrivateFileAtomic } from "./index.js"; function withStack(run: (rootDir: string) => void): void { const rootDir = mkdtempSync(join(tmpdir(), "propr-local-setup-test-")); @@ -42,3 +42,27 @@ test("stack inspection requires the env file and every launcher directory", () = mkdirSync(join(rootDir, "repos")); assert.equal(inspectStackInit(rootDir).initialized, true); })); + +test("environment commits reject symlink and hardlink targets without changing outside bytes", () => withStack((rootDir) => { + const envPath = join(rootDir, ".env"); + const outside = join(rootDir, "outside"); + writeFileSync(outside, "OUTSIDE=unchanged\n", { mode: 0o600 }); + symlinkSync(outside, envPath); + assert.throws(() => applyEnvSelection(rootDir, { SAFE: "no" }), /symbolic|unsafe/i); + assert.equal(readFileSync(outside, "utf8"), "OUTSIDE=unchanged\n"); + rmSync(envPath); + linkSync(outside, envPath); + assert.throws(() => applyEnvSelection(rootDir, { SAFE: "no" }), /hard-linked|unsafe/i); + assert.equal(readFileSync(outside, "utf8"), "OUTSIDE=unchanged\n"); +})); + +test("an atomic commit failure retains prior bytes, cleans its temp, and successful output is mode 0600", () => withStack((rootDir) => { + const envPath = join(rootDir, ".env"); + writeFileSync(envPath, "OLD=bytes\n", { mode: 0o600 }); + assert.throws(() => writePrivateFileAtomic(envPath, "NEW=bytes\n", { beforeRename: () => { throw new Error("rename fault"); } }), /rename fault/); + assert.equal(readFileSync(envPath, "utf8"), "OLD=bytes\n"); + assert.equal(readdirSync(rootDir).some(name => name.endsWith(".tmp")), false); + writePrivateFileAtomic(envPath, "NEW=bytes\n"); + assert.equal(readFileSync(envPath, "utf8"), "NEW=bytes\n"); + assert.equal(statSync(envPath).mode & 0o777, 0o600); +})); diff --git a/packages/local-setup/src/state.ts b/packages/local-setup/src/state.ts index aa190f4a6..ddff8b064 100644 --- a/packages/local-setup/src/state.ts +++ b/packages/local-setup/src/state.ts @@ -12,10 +12,11 @@ * and unit-tested without Docker, Ink, or readline. */ -import { lstatSync, readFileSync, statSync } from "node:fs"; +import { lstatSync, statSync } from "node:fs"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { resolveGithubAuthMode, type GithubAuthModeResult } from "@propr/shared"; import { clearEnvKeys as clearEnvFileKeys, upsertEnvVars } from "./envFile.js"; +import { readPrivateFile } from "./privateFilesystem.js"; import { SETUP_STEP_DEFINITIONS, type SetupState, @@ -246,14 +247,17 @@ export function isStackInitialized(rootDir: string): boolean { * full dotenv implementation — it does not handle escaped quotes or multiline * values. */ -export function readEnvVars(rootDir: string): Record { +export function readEnvVars(rootDir: string, signal?: AbortSignal): Record { + signal?.throwIfAborted(); const envPath = envPathFor(rootDir); // Treat anything that is not a regular file (absent, a directory, a broken // symlink) as "no vars", matching inspectStackInit's `isFile` guard, so a // malformed stack surfaces as not-initialized instead of crashing the read. if (!isFile(envPath)) return {}; + const contents = readPrivateFile(envPath); + if (!contents) return {}; const vars: Record = {}; - for (const line of readFileSync(envPath, "utf-8").split(/\r?\n/)) { + for (const line of contents.toString("utf-8").split(/\r?\n/)) { const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/); if (!match) continue; const [, key, rawValue] = match; @@ -296,9 +300,11 @@ export interface EnvSelectionResult { export function applyEnvSelection( rootDir: string, vars: Record, - opts: { overwrite?: boolean } = {} + opts: { overwrite?: boolean } = {}, + signal?: AbortSignal, ): EnvSelectionResult { - const existing = readEnvVars(rootDir); + signal?.throwIfAborted(); + const existing = readEnvVars(rootDir, signal); const toWrite: Record = {}; const written: string[] = []; const skipped: string[] = []; @@ -315,7 +321,7 @@ export function applyEnvSelection( } if (written.length > 0) { - upsertEnvVars(envPathFor(rootDir), toWrite); + upsertEnvVars(envPathFor(rootDir), toWrite, signal); } return { written, skipped }; } @@ -330,8 +336,9 @@ export function applyEnvSelection( * user whitelist back to "none", removing a key when switching modes — call this * instead. A missing `.env` or absent keys are no-ops. */ -export function clearEnvKeys(rootDir: string, keys: string[]): void { - clearEnvFileKeys(envPathFor(rootDir), keys); +export function clearEnvKeys(rootDir: string, keys: string[], signal?: AbortSignal): void { + signal?.throwIfAborted(); + clearEnvFileKeys(envPathFor(rootDir), keys, signal); } /** diff --git a/propr-ui/src/desktop.tsx b/propr-ui/src/desktop.tsx index 993447a0b..3a1039165 100644 --- a/propr-ui/src/desktop.tsx +++ b/propr-ui/src/desktop.tsx @@ -1,241 +1,9 @@ -import { StrictMode, type ComponentType, useEffect, useState } from 'react'; +import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import type { - DesktopAppMetadata, - DesktopProfile, - StorageSecurity, -} from '../../apps/desktop/src/shared/contract'; -import { activateDesktopProfile } from './desktop-profile'; +import App from './App'; import './index.css'; -import './desktop.css'; - -const logoUrl = new URL('./media/logo-and-name.png', window.location.href).href; - -export const DesktopTitleBar = ({ - metadata, - profile, - onDisconnect, -}: { - metadata: DesktopAppMetadata | null; - profile: DesktopProfile | null; - onDisconnect?: () => void; -}) => ( -
-
- ProPR - - {profile ? profile.label : 'Desktop'} - -
-
- {metadata && v{metadata.version} · {metadata.platform}} - {onDisconnect && ( - - )} -
-
-); - -export const ConnectionPlaceholder = ({ - metadata, - security, - initialApiUrl, - onConnect, -}: { - metadata: DesktopAppMetadata | null; - security: StorageSecurity | null; - initialApiUrl: string; - onConnect: (label: string, apiBaseUrl: string) => Promise; -}) => { - const [label, setLabel] = useState('Local ProPR'); - const [apiBaseUrl, setApiBaseUrl] = useState(initialApiUrl); - const [error, setError] = useState(null); - const [saving, setSaving] = useState(false); - - useEffect(() => setApiBaseUrl(initialApiUrl), [initialApiUrl]); - - const submit = async (event: React.FormEvent) => { - event.preventDefault(); - setError(null); - setSaving(true); - try { - await onConnect(label, apiBaseUrl); - } catch (caught) { - setError(caught instanceof Error ? caught.message : 'Could not save this connection.'); - } finally { - setSaving(false); - } - }; - - return ( -
-
-
-
-

ProPR Desktop

-

- Connect to your ProPR instance -

-
-
- Not connected -
-
-

- Add an existing instance to open the same dashboard you use on the web. The desktop app will not - install, download, or start runtime components. -

-
- - - {security && !security.available && ( -
- OS-backed encryption is unavailable ({security.backend}). Profiles can still be saved, but this - app will refuse to persist credentials until secure storage is available. -
- )} - {error &&
{error}
} - -
-
- Local lifecycle controls and secure pairing will appear here in a later setup flow. - {metadata && Runtime: Electron on {metadata.platform} ({metadata.arch})} -
-
-
- ); -}; - -export const DesktopRoot = () => { - const bridge = window.proprDesktop; - const [metadata, setMetadata] = useState(null); - const [security, setSecurity] = useState(null); - const [profile, setProfile] = useState(null); - const [DashboardApp, setDashboardApp] = useState(null); - const [initialApiUrl, setInitialApiUrl] = useState('http://localhost:4000'); - const [loading, setLoading] = useState(true); - const [fatalError, setFatalError] = useState(null); - - const loadDashboard = async (activeProfile: DesktopProfile) => { - window.__PROPR_CONFIG__ = { apiBaseUrl: activeProfile.apiBaseUrl }; - const application = await import('./App'); - setProfile(activeProfile); - setDashboardApp(() => application.default); - }; - - useEffect(() => { - if (!bridge) { - setFatalError('The secure desktop bridge did not load. Restart ProPR Desktop.'); - setLoading(false); - return; - } - let cancelled = false; - const unsubscribe = bridge.app.onDeepLink(value => { - try { - const deepLink = new URL(value); - if (deepLink.hostname === 'connect') { - const apiUrl = deepLink.searchParams.get('api'); - if (apiUrl) setInitialApiUrl(apiUrl); - } - } catch { - // Main validates protocol input; ignore malformed values defensively. - } - }); - void Promise.all([bridge.app.getMetadata(), bridge.storage.security(), bridge.profiles.list()]) - .then(async ([appMetadata, storageSecurity, profiles]) => { - if (cancelled) return; - setMetadata(appMetadata); - setSecurity(storageSecurity); - const active = profiles.profiles.find(item => item.id === profiles.activeProfileId); - if (active) await loadDashboard(active); - }) - .catch(error => { - if (!cancelled) setFatalError(error instanceof Error ? error.message : 'Desktop startup failed.'); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - unsubscribe(); - }; - }, [bridge]); - - const connect = async (label: string, apiBaseUrl: string) => { - if (!bridge) return; - const saved = await bridge.profiles.save({ label, apiBaseUrl }); - await activateDesktopProfile(bridge.profiles, saved); - }; - - const disconnect = async () => { - if (!bridge) return; - await bridge.profiles.setActive(null); - setProfile(null); - setDashboardApp(null); - window.__PROPR_CONFIG__ = undefined; - window.location.hash = ''; - }; - - if (loading) { - return ( -
- -
Starting ProPR Desktop…
-
- ); - } - - if (fatalError) { - return ( -
- -
-
- {fatalError} -
-
-
- ); - } - - return ( -
- -
- {profile && DashboardApp - ? - : } -
-
- ); -}; const container = document.getElementById('root'); if (!container) throw new Error('Root container missing in renderer.html'); -createRoot(container).render(); + +createRoot(container).render(); diff --git a/propr-ui/src/desktop/DesktopExperience.test.tsx b/propr-ui/src/desktop/DesktopExperience.test.tsx index d1ae8880e..0aff6bdb1 100644 --- a/propr-ui/src/desktop/DesktopExperience.test.tsx +++ b/propr-ui/src/desktop/DesktopExperience.test.tsx @@ -11,17 +11,13 @@ vi.mock('../api/apiClient', () => ({ setApiBaseUrl: apiMock.setApiBaseUrl })); vi.mock('../config/runtimeConfig', () => ({ setDesktopApiBaseUrl: runtimeMock.setDesktopApiBaseUrl })); const localProfile: DesktopProfile = { - id: 'local', - name: 'This computer', - baseUrl: 'http://127.0.0.1:3000', - kind: 'local', + id: 'local', name: 'This computer', + baseUrl: 'http://127.0.0.1:3000', kind: 'local', }; const remoteProfile: DesktopProfile = { - id: 'remote', - name: 'Team server', - baseUrl: 'https://propr.example.com', - kind: 'remote', + id: 'remote', name: 'Team server', + baseUrl: 'https://propr.example.com', kind: 'remote', }; const adaptersFor = ( @@ -40,7 +36,28 @@ const adaptersFor = ( discovery: { discover: vi.fn(async () => []) }, authentication: { authenticate: vi.fn(async () => undefined) }, externalBrowser: { open: vi.fn(async () => undefined) }, - localSetup: { setup: vi.fn(async () => localProfile) }, + localSetup: { + status: vi.fn(async () => ({ + phase: 'idle' as const, + capability: { supported: true as const, kind: 'local' as const, platform: 'linux' as const }, sessionId: '00000000-0000-4000-8000-000000000000', + rootDir: '/tmp/propr', + logs: [], + })), + start: vi.fn(async () => ({ + phase: 'completed' as const, + capability: { supported: true as const, kind: 'local' as const, platform: 'linux' as const }, sessionId: '00000000-0000-4000-8000-000000000000', + rootDir: '/tmp/propr', + logs: [], + profile: localProfile, + })), + retry: vi.fn(async () => { throw new Error('not used'); }), + cancel: vi.fn(async () => ({ + phase: 'cancelled' as const, + capability: { supported: true as const, kind: 'local' as const, platform: 'linux' as const }, sessionId: '00000000-0000-4000-8000-000000000000', + logs: [], + })), + selectPrivateKey: vi.fn(async () => null), acquireWebhookSecret: vi.fn(async () => null), onProgress: vi.fn(() => () => undefined), + }, connection: { probe: vi.fn(probe) }, }); @@ -69,8 +86,15 @@ describe('DesktopExperience', () => { fireEvent.click(screen.getByRole('button', { name: /Set up this computer/i })); + expect(await screen.findByRole('heading', { name: 'Check the essentials' })).toBeInTheDocument(); + for (let step = 0; step < 5; step += 1) { + fireEvent.click(screen.getByRole('button', { name: /Continue/i })); + } + fireEvent.click(screen.getByRole('button', { name: /Install ProPR/i })); + fireEvent.click(await screen.findByRole('button', { name: /Open dashboard/i })); + expect(await screen.findByText('Shared route tree')).toBeInTheDocument(); - expect(adapters.localSetup.setup).toHaveBeenCalledOnce(); + expect(adapters.localSetup.start).toHaveBeenCalledOnce(); expect(adapters.connection.probe).toHaveBeenCalledWith(localProfile); expect(adapters.profiles.save).toHaveBeenCalledWith(expect.objectContaining({ id: 'local' })); expect(adapters.profiles.setActiveId).toHaveBeenCalledWith('local'); @@ -274,7 +298,10 @@ describe('DesktopExperience', () => { expect(await screen.findByText('Connected app')).toBeInTheDocument(); vi.clearAllMocks(); - fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + await waitFor(() => { + fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + expect(screen.getByRole('dialog', { name: 'Manage instances' })).toBeInTheDocument(); + }); fireEvent.click(await screen.findByRole('button', { name: /Add instance/i })); fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'New server' } }); fireEvent.change(screen.getByLabelText('Instance URL'), { target: { value: 'https://new.example.com/' } }); @@ -299,7 +326,10 @@ describe('DesktopExperience', () => { render(
Connected app
); expect(await screen.findByText('Connected app')).toBeInTheDocument(); - fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + await waitFor(() => { + fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + expect(screen.getByRole('dialog', { name: 'Manage instances' })).toBeInTheDocument(); + }); if (profileKind === 'new') { fireEvent.click(await screen.findByRole('button', { name: /Add instance/i })); fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'New server' } }); diff --git a/propr-ui/src/desktop/DesktopExperience.tsx b/propr-ui/src/desktop/DesktopExperience.tsx index d2c8239d6..754e70975 100644 --- a/propr-ui/src/desktop/DesktopExperience.tsx +++ b/propr-ui/src/desktop/DesktopExperience.tsx @@ -6,11 +6,13 @@ import { DesktopContext } from './DesktopContext'; import { normalizeBaseUrl } from './browserAdapters'; import { useDesktopModal, useSerializedMutationQueue } from './desktopExperienceHooks'; import type { DesktopAdapters, DesktopConnectionResult, DesktopProfile } from './types'; +import { LocalSetupWizard } from './LocalSetupWizard'; import './desktop.css'; type ExperienceState = | { phase: 'loading' } | { phase: 'choose' } + | { phase: 'local-setup' } | { phase: 'connecting'; profile: DesktopProfile } | { phase: 'blocked'; profile: DesktopProfile; result: Exclude } | { phase: 'connected'; profile: DesktopProfile; result: Extract }; @@ -330,16 +332,8 @@ export const DesktopExperience: React.FC = ({ adapters, }; const setupLocal = async () => { - setBusy(true); setOperationError(null); - try { - const profile = await adapters.localSetup.setup(); - await saveProfile(profile); - } catch (error) { - setOperationError(error instanceof Error ? error.message : 'Local setup could not be started.'); - } finally { - setBusy(false); - } + setState({ phase: 'local-setup' }); }; const discover = async () => { @@ -391,6 +385,7 @@ export const DesktopExperience: React.FC = ({ adapters, if (state.phase === 'loading') return
Opening ProPR…
; if (state.phase === 'connecting') return undefined} onHelp={() => undefined} />; if (state.phase === 'blocked') return void runBlockedAction(state.profile, () => adapters.authentication.authenticate(state.profile), 'ProPR Desktop could not open sign in.', () => connect(state.profile))} onHelp={() => void runBlockedAction(state.profile, () => adapters.externalBrowser.open('https://propr.dev'), 'ProPR Desktop could not open connection help.')} />; + if (state.phase === 'local-setup') return setState({ phase: 'choose' })} onComplete={profile => void saveProfile(profile)} />; if (editing) return
setEditing(null)} onSave={profile => void saveProfile(profile)} />
; return void setupLocal()} onConnectNew={() => openEditor('new')} onDiscover={() => void discover()} onConnect={profile => void connect(profile)} onEdit={openEditor} onRemove={profile => void removeProfile(profile)} />; }; diff --git a/propr-ui/src/desktop/LocalSetupWizard.tsx b/propr-ui/src/desktop/LocalSetupWizard.tsx new file mode 100644 index 000000000..0c5805720 --- /dev/null +++ b/propr-ui/src/desktop/LocalSetupWizard.tsx @@ -0,0 +1,203 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { ArrowLeft, Check, ChevronRight, CircleAlert, KeyRound, LoaderCircle, RotateCcw, X } from 'lucide-react'; +import type { DesktopFilesystemSelection, DesktopProfileView, DesktopSecretSelection, DesktopSetupRequest, DesktopSetupSnapshot } from '../../../apps/desktop/src/shared/contract'; +import type { DesktopLocalSetupAdapter } from './types'; + +type FormStage = 'prerequisites' | 'directory' | 'github' | 'intake' | 'agents' | 'summary'; +type GithubMode = DesktopSetupRequest['github']['mode']; +type IntakeMode = DesktopSetupRequest['intake']['mode']; +type RootChoice = { mode: 'default' | 'resume'; label: string }; +const agents = ['codex', 'claude', 'antigravity', 'opencode', 'vibe']; +const stages: FormStage[] = ['prerequisites', 'directory', 'github', 'intake', 'agents', 'summary']; + +interface SetupDraft { + root: RootChoice; + githubMode: GithubMode; + appId: string; + privateKey: DesktopFilesystemSelection | null; + installationId: string; + intakeMode: IntakeMode; + intakeSecretApproval: DesktopSecretSelection | null; + selectedAgents: string[]; + reinitialize: boolean; + whitelist: string[] | null; + repository: DesktopSetupRequest['repository']; +} + +const buildSetupRequest = (sessionId: string, draft: SetupDraft): DesktopSetupRequest => ({ + sessionId, + root: { mode: draft.root.mode }, + reinitialize: draft.reinitialize, + agents: draft.selectedAgents, + github: draft.githubMode === 'app' + ? { mode: 'app', appId: draft.appId, privateKeyCapability: draft.privateKey?.capability ?? '', installationId: draft.installationId } + : { mode: draft.githubMode }, + intake: draft.intakeMode === 'direct_webhook' + ? { mode: 'direct_webhook', secretCapability: draft.intakeSecretApproval?.capability ?? '' } + : { mode: draft.intakeMode }, + whitelist: draft.whitelist, + repository: draft.repository, +}); + +const UnsupportedSetup: React.FC<{ error?: string; onBack(): void }> = ({ error, onBack }) => ( +

Local setup is unavailable

{error}

Local Docker setup is intentionally Linux-only.

+); + +const RunningSetup: React.FC<{ snapshot: DesktopSetupSnapshot; onCancel(): void }> = ({ snapshot, onCancel }) => { + const completed = snapshot.state?.steps.filter(step => ['done', 'skipped', 'warning'].includes(step.status)).length ?? 0; + const total = snapshot.state?.steps.length ?? 1; + return
Installing locally

Setting up ProPR

{snapshot.state?.steps.map(step =>
{step.status === 'active' ? : step.status === 'done' ? : step.status === 'failed' ? : null}
{step.title}{step.detail || step.description}
)}
{snapshot.logs.length > 0 &&
{snapshot.logs.slice(-8).join('\n')}
}
; +}; + +const RecoverySetup: React.FC<{ snapshot: DesktopSetupSnapshot; busy: boolean; onBack(): void; onRetry(): void }> = ({ snapshot, busy, onBack, onRetry }) => { + const failed = snapshot.state?.steps.find(step => step.status === 'failed'); + const nextAction = failed?.nextAction || snapshot.errors?.[0]?.nextAction; + const label = snapshot.reconfigurationRequired ? 'Review saved choices' : 'Retry setup'; + return
Recovery

{snapshot.phase === 'interrupted' ? 'Continue your setup' : 'Setup needs attention'}

{failed?.detail || snapshot.error || snapshot.errors?.[0]?.message || 'Setup stopped safely.'}

{snapshot.resumeAvailable === false &&
Resume after restart is unavailable.
}{nextAction &&
{nextAction}
}
; +}; + +const CompletedSetup: React.FC<{ profile: DesktopProfileView; onConfigureAgain(): void; onComplete(profile: DesktopProfileView): void }> = ({ profile, onConfigureAgain, onComplete }) => ( +
Setup complete

ProPR is ready

Your local stack is healthy and registered as “This computer”.

+); + +const githubModeCopy: Record = { + relay: { title: 'ProPR Connect', description: 'Uses the official ProPR GitHub relay.' }, + app: { title: 'Custom GitHub App', description: 'Use your App ID, installation, and a natively selected private key.' }, + demo: { title: 'Demo mode', description: 'Explore locally without GitHub access.' }, + keep: { title: 'Keep existing configuration', description: 'Best for an already configured stack.' }, +}; + +interface FormProps extends Omit { + stage: FormStage; + busy: boolean; + error: string | null; + setStage(value: FormStage): void; + setGithubMode(value: GithubMode): void; + setAppId(value: string): void; + setInstallationId(value: string): void; + setIntakeMode(value: IntakeMode): void; + setSelectedAgents(value: React.SetStateAction): void; + setWhitelist(value: string): void; + whitelist: string; + onChoosePrivateKey(): void; + onAcquireWebhookSecret(): void; + onBack(): void; + onContinue(): void; +} + +const GithubStage: React.FC = props => <>

Connect GitHub

Credentials remain in the trusted desktop process and are never returned to this page.

{(['relay', 'app', 'demo', 'keep'] as GithubMode[]).map(mode => )}
{props.githubMode === 'relay' &&
The official ProPR relay will be used. Custom renderer URLs are not accepted.
}{props.githubMode === 'app' &&
{props.privateKey?.label ?? 'No key selected'}
}; + +const FormContent: React.FC = props => { + switch (props.stage) { + case 'prerequisites': return <>

Check the essentials

ProPR requires a running Docker Engine on Linux. The installer verifies it before changing the stack.

; + case 'directory': return <>

Private local storage

ProPR keeps its environment, data, logs, repositories, and Docker mounts in one fixed owner-only directory managed by the desktop app.

{props.root.label}
; + case 'github': return ; + case 'intake': { + const allowed: IntakeMode[] = props.githubMode === 'relay' ? ['keep', 'routing_websocket', 'polling'] : props.githubMode === 'app' ? ['keep', 'polling', 'direct_webhook'] : props.githubMode === 'demo' ? ['keep'] : ['keep', 'routing_websocket', 'polling', 'direct_webhook']; + return <>

Choose GitHub event intake

{allowed.map(mode => )}
{props.intakeMode === 'direct_webhook' &&
{props.intakeSecretApproval?.label ?? 'No secret entered'}
}; + } + case 'agents': return <>

Select coding agents

{agents.map(agent => )}
{props.githubMode !== 'demo' && }; + case 'summary': return <>

Ready to install

Directory
{props.root.label}
GitHub
{props.githubMode}
Intake
{props.intakeMode}
Agents
{props.selectedAgents.join(', ') || 'None'}
; + } +}; + +const SetupForm: React.FC = props => { + const index = stages.indexOf(props.stage); + return
Local setup · {index + 1} of {stages.length}{props.error &&
{props.error}
}
; +}; + +export const LocalSetupWizard: React.FC<{ adapter: DesktopLocalSetupAdapter; onBack(): void; onComplete(profile: DesktopProfileView): void }> = ({ adapter, onBack, onComplete }) => { + const [stage, setStage] = useState('prerequisites'); + const [snapshot, setSnapshot] = useState(null); + const [root, setRoot] = useState({ mode: 'default', label: 'Desktop default directory' }); + const [githubMode, setGithubMode] = useState('relay'); + const [appId, setAppId] = useState(''); + const [privateKey, setPrivateKey] = useState(null); + const [installationId, setInstallationId] = useState(''); + const [intakeMode, setIntakeMode] = useState('routing_websocket'); + const [intakeSecretApproval, setIntakeSecretApproval] = useState(null); + const [selectedAgents, setSelectedAgents] = useState(['codex']); + const [reinitialize, setReinitialize] = useState(false); + const [whitelistText, setWhitelistText] = useState(''); + const [whitelist, setWhitelistChoice] = useState(null); + const [repository, setRepository] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [configureAgain, setConfigureAgain] = useState(false); + const [reconfiguring, setReconfiguring] = useState(false); + + useEffect(() => { + let mounted = true; + const unsubscribe = adapter.onProgress(value => { if (mounted) setSnapshot(value); }); + void adapter.status().then(value => { + if (!mounted) return; + setSnapshot(value); + setRoot({ mode: value.resume ? 'resume' : 'default', label: value.rootDir ?? 'Desktop default directory' }); + if (value.resume) { + setSelectedAgents(value.resume.agents); + setReinitialize(value.resume.reinitialize); + setGithubMode(value.resume.github.mode); + if (value.resume.github.mode === 'app') { setAppId(value.resume.github.appId); setInstallationId(value.resume.github.installationId); } + setIntakeMode(value.resume.intake.mode); + setWhitelistChoice(value.resume.whitelist); + setWhitelistText(value.resume.whitelist?.join(', ') ?? ''); + setRepository(value.resume.repository); + } + }).catch(() => { if (mounted) setError('Setup status is unavailable.'); }); + return () => { mounted = false; unsubscribe(); }; + }, [adapter]); + + const draft = useMemo(() => ({ root, githubMode, appId, privateKey, installationId, intakeMode, intakeSecretApproval, selectedAgents, reinitialize, whitelist, repository }), [appId, githubMode, installationId, intakeMode, intakeSecretApproval, privateKey, reinitialize, repository, root, selectedAgents, whitelist]); + const request = snapshot ? buildSetupRequest(snapshot.sessionId, draft) : null; + + const run = async (retry = false) => { + if (retry && snapshot?.reconfigurationRequired && !reconfiguring) { + setStage(snapshot.resume?.reconfigurationStage ?? 'github'); + setReconfiguring(true); + return; + } + if (!request) return; + setError(null); setBusy(true); + try { + const result = retry ? reconfiguring ? await adapter.retry(request) : await adapter.retry() : await adapter.start(request); + setSnapshot(result); + } catch { setError('Local setup could not be started. Check the selected values and try again.'); } + finally { setBusy(false); } + }; + + const choosePrivateKey = async () => { + setError(null); setBusy(true); + try { const selection = await adapter.selectPrivateKey(); if (selection) setPrivateKey(selection); } + catch { setError('Choose a regular, owner-only private-key file.'); } finally { setBusy(false); } + }; + const acquireWebhookSecret = async () => { + setError(null); setBusy(true); + try { const selection = await adapter.acquireWebhookSecret(); if (selection) setIntakeSecretApproval(selection); } + catch { setError('The secure secret prompt could not be opened.'); } finally { setBusy(false); } + }; + + if (!snapshot) return
Loading setup…
; + if (snapshot.phase === 'unsupported') return ; + if (snapshot.phase === 'running') return void adapter.cancel()} />; + if (['failed', 'cancelled', 'interrupted'].includes(snapshot.phase) && !reconfiguring) return void run(true)} />; + if (snapshot.phase === 'completed' && snapshot.profile && !configureAgain) return { setConfigureAgain(true); setGithubMode('keep'); setIntakeMode('keep'); }} onComplete={onComplete} />; + + const continueForm = () => { + setError(null); + if (stage === 'github' && githubMode === 'app' && (!/^\d{1,20}$/.test(appId) || !/^\d{1,20}$/.test(installationId) || !privateKey)) { setError('Enter numeric App and installation IDs, then choose the private key.'); return; } + if (stage === 'intake' && intakeMode === 'direct_webhook' && !intakeSecretApproval) { setError('Enter the webhook secret.'); return; } + const index = stages.indexOf(stage); + if (index === stages.length - 1) void run(reconfiguring); else setStage(stages[index + 1]); + }; + const chooseGithubMode = (mode: GithubMode) => { + setGithubMode(mode); + if (mode === 'relay' && intakeMode === 'direct_webhook') setIntakeMode('routing_websocket'); + if (mode === 'app' && intakeMode === 'routing_websocket') setIntakeMode('polling'); + if (mode === 'demo') setIntakeMode('keep'); + }; + const setWhitelist = (value: string) => { + setWhitelistText(value); + setWhitelistChoice(value.split(',').map(item => item.trim()).filter(Boolean)); + }; + return void choosePrivateKey()} onAcquireWebhookSecret={() => void acquireWebhookSecret()} onBack={onBack} onContinue={continueForm} />; +}; diff --git a/propr-ui/src/desktop/browserAdapters.ts b/propr-ui/src/desktop/browserAdapters.ts index ba47a324c..6ba0120e5 100644 --- a/propr-ui/src/desktop/browserAdapters.ts +++ b/propr-ui/src/desktop/browserAdapters.ts @@ -164,10 +164,15 @@ const createBrowserAdapters = (fixture: DesktopFixture | null): DesktopAdapters authenticate: authenticateBrowserFixture, }, localSetup: { - async setup() { - if (fixture) return fixtureProfile; - throw new Error('Local setup will be available when the desktop host adapter is connected.'); + async status() { + return { phase: 'idle', capability: { supported: true, kind: 'local', platform: 'linux' }, sessionId: '00000000-0000-4000-8000-000000000000', logs: [] }; }, + async start() { throw new Error('Local setup requires the Electron desktop host.'); }, + async retry() { throw new Error('Local setup requires the Electron desktop host.'); }, + async cancel() { return { phase: 'cancelled', capability: { supported: true, kind: 'local', platform: 'linux' }, sessionId: '00000000-0000-4000-8000-000000000000', logs: [] }; }, + async selectPrivateKey() { throw new Error('Private-key selection requires the Electron desktop host.'); }, + async acquireWebhookSecret() { throw new Error('Webhook-secret entry requires the Electron desktop host.'); }, + onProgress() { return () => undefined; }, }, connection: { async probe(profile) { diff --git a/propr-ui/src/desktop/desktop.css b/propr-ui/src/desktop/desktop.css index 8151f8a73..3ea8aa5b6 100644 --- a/propr-ui/src/desktop/desktop.css +++ b/propr-ui/src/desktop/desktop.css @@ -243,6 +243,64 @@ outline-offset: 2px; } +.desktop-setup-wizard { + width: min(100%, 46rem); + border: 1px solid #d7e3e2; + border-radius: 1.2rem; + padding: 2rem; + color: #263938; + background: rgba(255, 255, 255, .97); + box-shadow: 0 24px 70px rgba(25, 48, 48, .12); +} +.desktop-setup-wizard > .desktop-back-button { margin-bottom: 1.4rem; } +.desktop-setup-wizard h1 { margin: .4rem 0 .6rem; color: #132525; font-size: 1.75rem; font-weight: 720; letter-spacing: -.035em; } +.desktop-setup-wizard > p { max-width: 42rem; color: #5e6d6d; font-size: .9rem; line-height: 1.6; } +.desktop-setup-note, +.desktop-setup-recovery { margin-top: 1.25rem; border: 1px solid #cce1df; border-radius: .7rem; padding: .8rem .9rem; color: #365b59; background: #f2f9f8; font-size: .8rem; line-height: 1.5; } +.desktop-setup-recovery { border-color: #f0d5b8; color: #704b28; background: #fff9f1; } +.desktop-setup-field { display: grid; gap: .4rem; margin-top: 1.35rem; color: #435555; font-size: .76rem; font-weight: 650; } +.desktop-setup-field > div { display: flex; align-items: center; gap: .45rem; border: 1px solid #cdd9d9; border-radius: .6rem; padding: 0 .65rem; } +.desktop-setup-field svg { width: 1rem; color: #6a8583; } +.desktop-setup-field input, +.desktop-setup-grid input { width: 100%; border: 0; padding: .72rem .15rem; color: #192c2c; background: transparent; outline: none; font-size: .84rem; } +.desktop-setup-field > div:focus-within { border-color: #16827c; box-shadow: 0 0 0 3px rgba(22, 130, 124, .13); } +.desktop-setup-options { display: grid; gap: .55rem; margin-top: 1.2rem; } +.desktop-setup-options > label { display: flex; align-items: flex-start; gap: .7rem; border: 1px solid #dbe5e4; border-radius: .7rem; padding: .7rem .8rem; cursor: pointer; } +.desktop-setup-options > label:has(input:checked) { border-color: #83bdb9; background: #f3faf9; } +.desktop-setup-options strong, +.desktop-setup-options small { display: block; } +.desktop-setup-options strong { font-size: .82rem; } +.desktop-setup-options small { margin-top: .15rem; color: #6c7d7c; font-size: .72rem; line-height: 1.4; } +.desktop-setup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .65rem; margin-top: 1rem; } +.desktop-setup-grid label { display: grid; gap: .3rem; color: #536665; font-size: .72rem; font-weight: 650; } +.desktop-setup-grid input { border: 1px solid #cdd9d9; border-radius: .55rem; padding: .65rem .7rem; } +.desktop-setup-wide { grid-column: 1 / -1; } +.desktop-agent-options { display: grid; grid-template-columns: repeat(2, 1fr); gap: .55rem; margin-top: 1.2rem; } +.desktop-agent-options > label { display: flex; align-items: center; gap: .5rem; border: 1px solid #dce5e4; border-radius: .65rem; padding: .65rem; font-size: .8rem; text-transform: capitalize; } +.desktop-agent-login { margin-left: auto; color: #71807f; font-size: .65rem; text-transform: none; } +.desktop-setup-summary { margin-top: 1.25rem; border: 1px solid #dce5e4; border-radius: .7rem; overflow: hidden; } +.desktop-setup-summary > div { display: grid; grid-template-columns: 7rem 1fr; gap: .8rem; padding: .7rem .85rem; border-bottom: 1px solid #e6edec; font-size: .78rem; } +.desktop-setup-summary > div:last-child { border-bottom: 0; } +.desktop-setup-summary dt { color: #758382; } +.desktop-setup-summary dd { overflow-wrap: anywhere; color: #273a39; font-weight: 600; } +.desktop-setup-footer { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1.4rem; } +.desktop-setup-progress { height: .45rem; margin: 1.2rem 0; border-radius: 999px; overflow: hidden; background: #e4eceb; } +.desktop-setup-progress > span { display: block; height: 100%; border-radius: inherit; background: #16827c; transition: width .25s ease; } +.desktop-setup-step-list { display: grid; gap: .35rem; max-height: 22rem; overflow-y: auto; } +.desktop-setup-step-list > div { display: grid; grid-template-columns: 1.25rem 1fr; gap: .55rem; padding: .45rem .55rem; border-radius: .5rem; } +.desktop-setup-step-list > div[data-status="active"] { background: #edf8f7; } +.desktop-setup-step-list > div[data-status="failed"] { color: #9f2d20; background: #fff6f4; } +.desktop-setup-step-list svg { width: .95rem; height: .95rem; } +.desktop-setup-step-list strong, +.desktop-setup-step-list small { display: block; } +.desktop-setup-step-list strong { font-size: .78rem; } +.desktop-setup-step-list small { margin-top: .1rem; color: #6d7c7b; font-size: .68rem; line-height: 1.35; } +.desktop-setup-log { max-height: 7rem; margin: .8rem 0; overflow: auto; border-radius: .55rem; padding: .65rem; color: #c7e8e4; background: #18302f; font-size: .65rem; line-height: 1.45; white-space: pre-wrap; } +.desktop-setup-hero-icon { width: 2.5rem; height: 2.5rem; margin-bottom: .8rem; color: #b46a2a; } +.desktop-setup-error-icon { color: #b64334; } +.desktop-setup-success { display: grid; place-items: center; width: 3.5rem; height: 3.5rem; margin-bottom: 1rem; border-radius: 1rem; color: white; background: #21956c; } +.desktop-setup-success svg { width: 1.7rem; height: 1.7rem; } + @media (prefers-reduced-motion: reduce) { .desktop-choice-button { transition: none; } .desktop-choice-button:hover:not(:disabled) { transform: none; } @@ -255,4 +313,8 @@ .desktop-welcome-card, .desktop-connection-card { border-radius: .9rem; padding: 1.25rem; } .desktop-welcome-copy { padding: 1.8rem 0 1.25rem; } + .desktop-setup-wizard { padding: 1.25rem; } + .desktop-agent-options, + .desktop-setup-grid { grid-template-columns: 1fr; } + .desktop-setup-wide { grid-column: auto; } } diff --git a/propr-ui/src/desktop/types.ts b/propr-ui/src/desktop/types.ts index 1bcab4343..5355f96fc 100644 --- a/propr-ui/src/desktop/types.ts +++ b/propr-ui/src/desktop/types.ts @@ -46,7 +46,13 @@ export interface DesktopExternalBrowserAdapter { } export interface DesktopLocalSetupAdapter { - setup(): Promise; + status(): Promise; + start(request: import('../../../apps/desktop/src/shared/contract').DesktopSetupRequest): Promise; + retry(request?: import('../../../apps/desktop/src/shared/contract').DesktopSetupRequest): Promise; + cancel(): Promise; + selectPrivateKey(): Promise; + acquireWebhookSecret(): Promise; + onProgress(listener: (snapshot: import('../../../apps/desktop/src/shared/contract').DesktopSetupSnapshot) => void): () => void; } export interface DesktopConnectionAdapter { @@ -67,12 +73,4 @@ export interface DesktopAdapters { * Small preload-facing contract. Electron can expose this object through * contextBridge without exposing Node or command execution to React. */ -export interface ProprDesktopBridge extends DesktopAdapters { - isDesktop: true; -} - -declare global { - interface Window { - __PROPR_DESKTOP__?: ProprDesktopBridge; - } -} +export type ProprDesktopBridge = import('../../../apps/desktop/src/shared/contract').DesktopRendererBridge; diff --git a/propr-ui/src/vite-env.d.ts b/propr-ui/src/vite-env.d.ts index 6abae6cad..65725a30d 100644 --- a/propr-ui/src/vite-env.d.ts +++ b/propr-ui/src/vite-env.d.ts @@ -7,4 +7,5 @@ declare const __PROPR_DESKTOP__: boolean; interface Window { proprDesktop?: import('../../apps/desktop/src/shared/contract').DesktopBridge; + __PROPR_DESKTOP__?: import('../../apps/desktop/src/shared/contract').DesktopRendererBridge; } diff --git a/test/cliAgentValidation.test.ts b/test/cliAgentValidation.test.ts index 6c6bab3d1..ccf542229 100644 --- a/test/cliAgentValidation.test.ts +++ b/test/cliAgentValidation.test.ts @@ -62,6 +62,7 @@ function fakeConfig(overrides: Partial = {}): OrchestratorCo function fakeOrchestrator(): OrchestratorModule { return { docker: () => ({ status: 0, stdout: "image-id\n", stderr: "" }), + dockerAsync: async () => ({ status: 0, stdout: "image-id\n", stderr: "" }), validateDockerBindPath: (name, value) => (!value || value.startsWith("/") ? null : `${name} must be absolute`), } as unknown as OrchestratorModule; } diff --git a/test/orchestratorCancellation.test.mjs b/test/orchestratorCancellation.test.mjs new file mode 100644 index 000000000..76f2e5d44 --- /dev/null +++ b/test/orchestratorCancellation.test.mjs @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { dockerAsync, resolveConfig, startStackAsync } from '../docker/launcher/orchestrator.mjs'; + +const eventually = async (operation, timeoutMs = 15_000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { return await operation(); } catch { await new Promise(resolve => setTimeout(resolve, 20)); } + } + return operation(); +}; + +test('dockerAsync cancellation terminates the spawned process group before settling', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-docker-cancel-')); + const executable = join(directory, 'docker'); + const descendantPath = join(directory, 'descendant.pid'); + const previousPath = process.env.PATH; + process.env.PATH = `${directory}:${previousPath ?? ''}`; + process.env.PROPR_TEST_DESCENDANT_PATH = descendantPath; + try { + await writeFile(executable, '#!/bin/sh\nsleep 30 &\necho "$!" > "$PROPR_TEST_DESCENDANT_PATH"\nwait\n', { mode: 0o700 }); + await chmod(executable, 0o700); + const controller = new AbortController(); + const operation = dockerAsync(['pull', 'example'], { signal: controller.signal }); + const descendantPid = Number(await eventually(async () => readFile(descendantPath, 'utf8'))); + controller.abort(); + const result = await operation; + assert.equal(result.error?.code, 'ABORT_ERR'); + await eventually(async () => { + try { + const state = (await readFile(`/proc/${descendantPid}/stat`, 'utf8')).split(' ')[2]; + assert.equal(state, 'Z', 'descendant must be terminated (a container PID 1 may leave it as a zombie)'); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + }); + } finally { + process.env.PATH = previousPath; + delete process.env.PROPR_TEST_DESCENDANT_PATH; + await rm(directory, { recursive: true, force: true }); + } +}); + +test('setup abort during launch and final status cleans run-owned containers and leaves preexisting and foreign containers untouched', { concurrency: false, timeout: 180_000 }, async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-docker-daemon-cancel-')); + const executable = join(directory, 'docker'); + const statePath = join(directory, 'containers.json'); + const markerPath = join(directory, 'created.marker'); + const previous = { path: process.env.PATH, state: process.env.PROPR_FAKE_STATE, marker: process.env.PROPR_FAKE_MARKER, target: process.env.PROPR_FAKE_ABORT_TARGET, stopMode: process.env.PROPR_FAKE_STOP_MODE, skip: process.env.PROPR_SKIP_REMOTE_IMAGE_CHECK }; + const initial = { + 'propr-api': { 'propr.stack': 'propr', 'propr.service': 'api', foreign: 'preexisting', __running: false }, + foreign: { foreign: 'true', __running: true }, + }; + await writeFile(statePath, JSON.stringify(initial)); + await writeFile(executable, `#!/bin/sh +exec /usr/local/bin/node - -- "$@" <<'PROPR_FAKE_NODE' +const fs = require('node:fs'); +const args = process.argv.slice(2); if (args[0] === '--') args.shift(); +const statePath = process.env.PROPR_FAKE_STATE; +const load = () => JSON.parse(fs.readFileSync(statePath, 'utf8')); +const save = value => { const temporary = statePath + '.' + process.pid; fs.writeFileSync(temporary, JSON.stringify(value)); fs.renameSync(temporary, statePath); }; +const lockPath = statePath + '.lock'; +const mutate = operation => { + for (;;) { + try { fs.mkdirSync(lockPath); break; } + catch (error) { if (error.code !== 'EEXIST') throw error; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2); } + } + try { const state = load(); const result = operation(state); save(state); return result; } + finally { fs.rmdirSync(lockPath); } +}; +const option = name => { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; }; +if (args[0] === 'images') { console.log('image-id'); process.exit(0); } +if (args[0] === 'image' && args[1] === 'inspect') { console.log('[]'); process.exit(0); } +if (args[0] === 'network') process.exit(0); +if (args[0] === 'ps') { + const match = args.join(' ').match(/name=\\^([^$]+)\\$/); + const name = match && match[1].replace(/^\\//, ''); + const state = load(); + const entry = name && state[name]; + const allCoreLaunched = ['redis', 'daemon', 'worker', 'analysis-worker', 'indexing-worker', 'api'] + .every(service => state['propr-' + service]?.['propr.setup-run'] && state['propr-' + service].__running); + if (!name && state.foreign?.statusError && allCoreLaunched) { + fs.writeSync(2, 'synthetic docker ps failure\\n'); + process.exit(23); + } else if (!name && state.foreign?.abortFinal && allCoreLaunched) { + fs.writeFileSync(process.env.PROPR_FAKE_MARKER, 'final-status'); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 30_000); + process.exit(0); + } else { + if (entry && (args.includes('-a') || entry.__running)) { + fs.writeSync(1, args.includes('{{json .Names}}') ? JSON.stringify(name) + '\\n' : name + '\\n'); + } + process.exit(0); + } +} +if (args[0] === 'inspect') { + const name = args[args.length - 1]; + const labels = load()[name]; + if (!labels) process.exit(1); + const value = args.join(' ').includes('.HostConfig.Binds') ? labels.__hostConfig?.Binds : labels; + fs.writeSync(1, JSON.stringify(value) + '\\n'); + process.exit(0); +} +if (args[0] === 'run') { + const name = option('--name'); + const labels = {}; + for (let i = 0; i < args.length; i += 1) if (args[i] === '--label') { const [key, ...rest] = args[++i].split('='); labels[key] = rest.join('='); } + labels.__hostConfig = { Binds: args.flatMap((value, index) => value === '-v' ? [args[index + 1]] : []) }; + labels.__running = true; + mutate(state => { state[name] = labels; if (args.includes('--rm')) delete state[name]; }); + fs.writeFileSync(process.env.PROPR_FAKE_MARKER, name); + if (name === process.env.PROPR_FAKE_ABORT_TARGET) setTimeout(() => {}, 30_000); + else { console.log(name); process.exit(0); } +} else if (args[0] === 'stop') { + const name = args[args.length - 1]; + if (name === 'propr-redis' && process.env.PROPR_FAKE_STOP_MODE === 'owned-remains') { + mutate(state => { if (state[name]) state[name].__running = false; }); + process.exit(42); + } + if (name === 'propr-redis' && process.env.PROPR_FAKE_STOP_MODE === 'foreign-replacement') { + mutate(state => { state[name] = { foreign: 'replacement', __running: false }; }); + process.exit(42); + } + process.exit(0); +} +else if (args[0] === 'rm') { const name = args[args.length - 1]; mutate(state => { delete state[name]; }); process.exit(0); } +else process.exit(0); +PROPR_FAKE_NODE +`, { mode: 0o700 }); + await chmod(executable, 0o700); + process.env.PATH = `${directory}:${previous.path ?? ''}`; + process.env.PROPR_FAKE_STATE = statePath; + process.env.PROPR_FAKE_MARKER = markerPath; + process.env.PROPR_FAKE_ABORT_TARGET = 'propr-redis'; + process.env.PROPR_FAKE_STOP_MODE = 'owned-remains'; + process.env.PROPR_SKIP_REMOTE_IMAGE_CHECK = '1'; + const manifestPath = fileURLToPath(new URL('../docker/launcher/manifest.json', import.meta.url)); + const stableRoot = join(directory, 'app-data', 'desktop', 'local-stack'); + await mkdir(join(stableRoot, 'data'), { recursive: true, mode: 0o700 }); + await mkdir(join(stableRoot, 'logs'), { mode: 0o700 }); + await mkdir(join(stableRoot, 'repos'), { mode: 0o700 }); + await writeFile(join(stableRoot, '.env'), '', { mode: 0o600 }); + const cfg = resolveConfig({}, { + manifestPath, + envFileLocal: join(stableRoot, '.env'), + envFileHost: join(stableRoot, '.env'), + hostData: join(stableRoot, 'data'), + hostLogs: join(stableRoot, 'logs'), + hostRepos: join(stableRoot, 'repos'), + }); + try { + for (let iteration = 0; iteration < 5; iteration += 1) { + await writeFile(statePath, JSON.stringify(initial)); + await writeFile(markerPath, ''); + process.env.PROPR_FAKE_ABORT_TARGET = 'propr-redis'; + const controller = new AbortController(); + const operation = startStackAsync(cfg, { ui: false, docs: false, tunnel: false, signal: controller.signal }); + const rejected = assert.rejects(operation); + await Promise.race([ + eventually(async () => { assert.equal(await readFile(markerPath, 'utf8'), 'propr-redis'); }), + operation.then(() => { throw new Error('stack unexpectedly completed'); }, error => { throw error; }), + ]); + controller.abort(); + await rejected; + const settled = JSON.parse(readFileSync(statePath, 'utf8')); + assert.deepEqual(Object.keys(settled).sort(), ['foreign', 'propr-api'], `iteration ${iteration + 1}`); + assert.equal(settled['propr-api'].foreign, 'preexisting'); + assert.equal(settled.foreign.foreign, 'true'); + assert.equal(Object.values(settled).some(labels => labels['propr.setup-run']), false); + } + + await writeFile(statePath, JSON.stringify(initial)); + await writeFile(markerPath, ''); + process.env.PROPR_FAKE_STOP_MODE = 'foreign-replacement'; + const replacementController = new AbortController(); + const replacementOperation = startStackAsync(cfg, { ui: false, docs: false, tunnel: false, signal: replacementController.signal }); + await eventually(async () => { assert.equal(await readFile(markerPath, 'utf8'), 'propr-redis'); }); + replacementController.abort(); + await assert.rejects(replacementOperation); + const replacementSettled = JSON.parse(readFileSync(statePath, 'utf8')); + assert.equal(replacementSettled['propr-redis']?.foreign, 'replacement'); + assert.equal(replacementSettled['propr-api'].foreign, 'preexisting'); + assert.equal(replacementSettled.foreign.foreign, 'true'); + process.env.PROPR_FAKE_STOP_MODE = 'owned-remains'; + + const finalInitial = { + 'propr-ui': { 'propr.stack': 'propr', 'propr.service': 'ui', foreign: 'preexisting', __running: false }, + foreign: { foreign: 'true', abortFinal: true, __running: true }, + }; + await writeFile(statePath, JSON.stringify(finalInitial)); + await writeFile(markerPath, ''); + process.env.PROPR_FAKE_ABORT_TARGET = 'final-status'; + const finalController = new AbortController(); + const finalOperation = startStackAsync(cfg, { ui: false, docs: false, tunnel: false, signal: finalController.signal }); + await Promise.race([ + eventually(async () => { assert.equal(await readFile(markerPath, 'utf8'), 'final-status'); }, 5_000), + finalOperation.then(() => { throw new Error('stack unexpectedly completed'); }, error => { throw error; }), + ]); + finalController.abort(); + await assert.rejects(finalOperation); + const finalSettled = JSON.parse(readFileSync(statePath, 'utf8')); + assert.deepEqual(Object.keys(finalSettled).sort(), ['foreign', 'propr-ui']); + assert.equal(finalSettled['propr-ui'].foreign, 'preexisting'); + assert.equal(finalSettled.foreign.foreign, 'true'); + assert.equal(Object.values(finalSettled).some(labels => labels['propr.setup-run']), false); + + const errorInitial = { + 'propr-docs': { 'propr.stack': 'propr', 'propr.service': 'docs', foreign: 'preexisting', __running: false }, + foreign: { foreign: 'true', statusError: true, __running: true }, + }; + await writeFile(statePath, JSON.stringify(errorInitial)); + process.env.PROPR_FAKE_ABORT_TARGET = 'status-error'; + await assert.rejects( + startStackAsync(cfg, { ui: false, docs: false, tunnel: false }), + /Failed to inspect stack status: synthetic docker ps failure/, + ); + const errorSettled = JSON.parse(readFileSync(statePath, 'utf8')); + assert.deepEqual(Object.keys(errorSettled).sort(), ['foreign', 'propr-docs']); + assert.equal(errorSettled['propr-docs'].foreign, 'preexisting'); + assert.equal(errorSettled.foreign.foreign, 'true'); + assert.equal(Object.values(errorSettled).some(labels => labels['propr.setup-run']), false); + + // A successful create persists only the stable app-owned bind sources. + // Toggle the fake daemon's running state to model an automatic Docker + // restart after the creating Electron authority has gone away; HostConfig + // remains byte-for-byte unchanged and contains no PID/fd path. + await writeFile(statePath, JSON.stringify({ foreign: { foreign: 'true', __running: true } })); + process.env.PROPR_FAKE_ABORT_TARGET = 'none'; + await startStackAsync(cfg, { ui: false, docs: false, tunnel: false }); + const created = JSON.parse(readFileSync(statePath, 'utf8')); + const createdNames = Object.keys(created).filter(name => name.startsWith('propr-')); + assert.ok(createdNames.length > 0); + for (const name of createdNames) { + const inspected = await dockerAsync(['inspect', '--format', '{{json .HostConfig.Binds}}', name]); + assert.equal(inspected.status, 0); + const binds = JSON.parse(inspected.stdout); + for (const bind of binds.filter(value => value.startsWith(stableRoot))) { + const source = bind.split(':')[0]; + assert.ok(source === join(stableRoot, '.env') || source.startsWith(`${stableRoot}/`)); + assert.doesNotMatch(source, /(?:^|\/)proc\/[0-9]+\/fd\/|(?:^|\/)dev\/fd\//); + } + created[name].__running = false; + created[name].__running = true; + } + await writeFile(statePath, JSON.stringify(created)); + const restarted = JSON.parse(readFileSync(statePath, 'utf8')); + for (const name of createdNames) assert.deepEqual(restarted[name].__hostConfig, created[name].__hostConfig); + } finally { + process.env.PATH = previous.path; + for (const [name, value] of [['PROPR_FAKE_STATE', previous.state], ['PROPR_FAKE_MARKER', previous.marker], ['PROPR_FAKE_ABORT_TARGET', previous.target], ['PROPR_FAKE_STOP_MODE', previous.stopMode], ['PROPR_SKIP_REMOTE_IMAGE_CHECK', previous.skip]]) { + if (value === undefined) delete process.env[name]; else process.env[name] = value; + } + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/test/orchestratorConcurrentCleanup.test.mjs b/test/orchestratorConcurrentCleanup.test.mjs new file mode 100644 index 000000000..b70fcf175 --- /dev/null +++ b/test/orchestratorConcurrentCleanup.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { chmod, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { resolveConfig, startStackAsync } from '../docker/launcher/orchestrator.mjs'; + +const eventually = async (operation, timeoutMs = 10_000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { return await operation(); } catch { await new Promise(resolve => setTimeout(resolve, 20)); } + } + return operation(); +}; + +test('full nine-container cancellation cleans delayed journal entries concurrently and surfaces residuals', { timeout: 120_000 }, async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-concurrent-cleanup-')); + const executable = join(directory, 'docker'); + const stateDir = join(directory, 'containers'); + const marker = join(directory, 'final-status.marker'); + await mkdir(stateDir); + const previous = { + path: process.env.PATH, + state: process.env.PROPR_FAKE_STATE_DIR, + marker: process.env.PROPR_FAKE_MARKER, + residual: process.env.PROPR_FAKE_RESIDUAL, + skip: process.env.PROPR_SKIP_REMOTE_IMAGE_CHECK, + }; + await writeFile(executable, `#!/bin/sh +exec /usr/local/bin/node - -- "$@" <<'PROPR_FAKE_NODE' +const fs = require('node:fs'); const path = require('node:path'); +const args = process.argv.slice(2); if (args[0] === '--') args.shift(); +const dir = process.env.PROPR_FAKE_STATE_DIR; +const file = name => path.join(dir, encodeURIComponent(name) + '.json'); +const names = () => fs.readdirSync(dir).filter(name => name.endsWith('.json')).map(name => decodeURIComponent(name.slice(0, -5))); +const read = name => { try { return JSON.parse(fs.readFileSync(file(name), 'utf8')); } catch { return null; } }; +const option = key => { const i = args.indexOf(key); return i < 0 ? undefined : args[i + 1]; }; +if (args[0] === 'images') { fs.writeSync(1, 'image-id\\n'); process.exit(0); } +if (args[0] === 'image' && args[1] === 'inspect') { fs.writeSync(1, '[]\\n'); process.exit(0); } +if (args[0] === 'network') process.exit(0); +if (args[0] === 'ps') { + const match = args.join(' ').match(/name=\\^([^$]+)\\$/); + if (match) { + const name = match[1].replace(/^\\//, ''); + if (read(name)) fs.writeSync(1, args.includes('{{json .Names}}') ? JSON.stringify(name) + '\\n' : name + '\\n'); + process.exit(0); + } + const current = names(); + const services = ['redis','daemon','worker','analysis-worker','indexing-worker','api','ui','docs','tunnel']; + if (services.every(service => current.includes('propr-' + service))) { + fs.writeFileSync(process.env.PROPR_FAKE_MARKER, 'ready'); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 30000); + } + for (const name of current) fs.writeSync(1, name + '\\trunning\\tUp\\t\\n'); + process.exit(0); +} +if (args[0] === 'run') { + const name = option('--name'); const labels = {}; + for (let i = 0; i < args.length; i++) if (args[i] === '--label') { const [key, ...value] = args[++i].split('='); labels[key] = value.join('='); } + fs.writeFileSync(file(name), JSON.stringify(labels)); + if (args.includes('--rm')) fs.unlinkSync(file(name)); + fs.writeSync(1, name + '\\n'); process.exit(0); +} +if (args[0] === 'inspect') { + const value = read(args[args.length - 1]); if (!value) process.exit(1); + fs.writeSync(1, JSON.stringify(value) + '\\n'); process.exit(0); +} +if (args[0] === 'stop') { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1800); process.exit(0); } +if (args[0] === 'rm') { + const name = args[args.length - 1]; + if (name !== process.env.PROPR_FAKE_RESIDUAL) { try { fs.unlinkSync(file(name)); } catch {} } + process.exit(0); +} +process.exit(0); +PROPR_FAKE_NODE +`, { mode: 0o700 }); + await chmod(executable, 0o700); + process.env.PATH = `${directory}:${previous.path ?? ''}`; + process.env.PROPR_FAKE_STATE_DIR = stateDir; + process.env.PROPR_FAKE_MARKER = marker; + process.env.PROPR_SKIP_REMOTE_IMAGE_CHECK = '1'; + const root = join(directory, 'app-data', 'desktop', 'local-stack'); + await mkdir(join(root, 'data'), { recursive: true, mode: 0o700 }); + await mkdir(join(root, 'logs'), { mode: 0o700 }); + await mkdir(join(root, 'repos'), { mode: 0o700 }); + await writeFile(join(root, '.env'), '', { mode: 0o600 }); + const manifestPath = fileURLToPath(new URL('../docker/launcher/manifest.json', import.meta.url)); + const cfg = resolveConfig({ PROPR_UI_TUNNEL_TOKEN: 'fake-tunnel-token' }, { + manifestPath, + envFileLocal: join(root, '.env'), envFileHost: join(root, '.env'), + hostData: join(root, 'data'), hostLogs: join(root, 'logs'), hostRepos: join(root, 'repos'), + uiTunnelEnabled: true, + }); + const run = async (residual) => { + await rm(stateDir, { recursive: true, force: true }); await mkdir(stateDir); + await writeFile(marker, ''); + if (residual) process.env.PROPR_FAKE_RESIDUAL = residual; else delete process.env.PROPR_FAKE_RESIDUAL; + const controller = new AbortController(); + const operation = startStackAsync(cfg, { ui: true, docs: true, tunnel: true, signal: controller.signal }); + const observed = operation.then(() => null, failure => failure); + await eventually(async () => assert.equal(await readFile(marker, 'utf8'), 'ready')); + const cancelledAt = Date.now(); + controller.abort(); + const error = await observed; + return { error, elapsed: Date.now() - cancelledAt, names: (await readdir(stateDir)).filter(name => name.endsWith('.json')) }; + }; + try { + const clean = await run(undefined); + assert.ok(clean.error, 'cancellation must reject'); + assert.deepEqual(clean.names, []); + assert.ok(clean.elapsed < 9_000, `concurrent cleanup took ${clean.elapsed}ms`); + + const residual = await run('propr-ui'); + assert.equal(residual.error?.code, 'PROPR_SETUP_CLEANUP_INCOMPLETE'); + assert.match(String(residual.error?.message), /cleanup is incomplete|run-owned containers remain/); + assert.deepEqual(residual.names, ['propr-ui.json']); + } finally { + process.env.PATH = previous.path; + for (const [name, value] of [['PROPR_FAKE_STATE_DIR', previous.state], ['PROPR_FAKE_MARKER', previous.marker], ['PROPR_FAKE_RESIDUAL', previous.residual], ['PROPR_SKIP_REMOTE_IMAGE_CHECK', previous.skip]]) { + if (value === undefined) delete process.env[name]; else process.env[name] = value; + } + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/test/orchestratorConfig.test.mjs b/test/orchestratorConfig.test.mjs index ae2a42526..dd42ab302 100644 --- a/test/orchestratorConfig.test.mjs +++ b/test/orchestratorConfig.test.mjs @@ -108,6 +108,30 @@ test('resolveHostConfig honors stack .env values for ports and docs', () => { ); }); +test('anchored config reads keep every Docker path on the stable runtime root', () => { + const parent = mkdtempSync(join(tmpdir(), 'propr-orch-fixed-root-')); + const stableRoot = join(parent, 'app-data', 'desktop', 'local-stack'); + const readRoot = join(parent, 'descriptor-root'); + mkdirSync(stableRoot, { recursive: true, mode: 0o700 }); + mkdirSync(readRoot, { mode: 0o700 }); + writeFileSync(join(stableRoot, '.env'), 'API_PORT=attacker-value\n', { mode: 0o600 }); + writeFileSync(join(readRoot, '.env'), 'API_PORT=4401\nDOCS_ENABLED=true\n', { mode: 0o600 }); + + const cfg = resolveHostConfig({ rootDir: stableRoot, readRootDir: readRoot, env: {}, manifestPath }); + assert.equal(cfg.apiPort, '4401'); + assert.equal(cfg.docsEnabled, true); + assert.equal(cfg.envFileLocal, join(stableRoot, '.env')); + assert.equal(cfg.envFileHost, join(stableRoot, '.env')); + assert.equal(cfg.hostData, join(stableRoot, 'data')); + assert.equal(cfg.hostLogs, join(stableRoot, 'logs')); + assert.equal(cfg.hostRepos, join(stableRoot, 'repos')); + for (const service of ['daemon', 'worker', 'api']) { + const serialized = JSON.stringify(buildServiceSpec(cfg, service)); + assert.doesNotMatch(serialized, new RegExp(readRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.doesNotMatch(serialized, /\/proc\/[0-9]+\/fd\/|\/dev\/fd\//); + } +}); + test('api service receives the configured stack env file', () => { const rootDir = mkdtempSync(join(tmpdir(), 'propr-orch-')); const envFile = join(rootDir, '.env'); diff --git a/test/orchestratorLifecycleRecovery.test.mjs b/test/orchestratorLifecycleRecovery.test.mjs new file mode 100644 index 000000000..aea71ef42 --- /dev/null +++ b/test/orchestratorLifecycleRecovery.test.mjs @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { + getStackStatusAsync, + isLifecycleStackRunningAsync, + recoverStackAsync, + resolveHostConfig, + startStackAsync, + stopLifecycleStackAsync, +} from '../docker/launcher/orchestrator.mjs'; + +test('fixed-root lifecycle safely survives stop/start/restart and rejects replacements', { timeout: 120_000 }, async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-lifecycle-recovery-')); + const executable = join(directory, 'docker'); + const statePath = join(directory, 'containers.json'); + const oldPath = process.env.PATH; + const oldState = process.env.PROPR_FAKE_STATE; + const oldReplaceOnStop = process.env.PROPR_FAKE_REPLACE_ON_STOP; + const oldSkip = process.env.PROPR_SKIP_REMOTE_IMAGE_CHECK; + await writeFile(statePath, '{}'); + await writeFile(executable, `#!/bin/sh +exec /usr/local/bin/node - -- "$@" <<'PROPR_FAKE_NODE' +const fs = require('node:fs'); +const args = process.argv.slice(2); if (args[0] === '--') args.shift(); +const statePath = process.env.PROPR_FAKE_STATE; +const load = () => JSON.parse(fs.readFileSync(statePath, 'utf8')); +const save = state => fs.writeFileSync(statePath, JSON.stringify(state)); +const byId = (state, id) => Object.entries(state).find(([, entry]) => entry.id === id); +const idFor = name => Buffer.from(name).toString('hex').padEnd(64, '0').slice(0, 64); +const option = key => { const i = args.indexOf(key); return i < 0 ? undefined : args[i + 1]; }; +if (args[0] === 'images') { fs.writeSync(1, 'image-id\\n'); process.exit(0); } +if (args[0] === 'image' && args[1] === 'inspect') { fs.writeSync(1, '[]\\n'); process.exit(0); } +if (args[0] === 'network') process.exit(0); +if (args[0] === 'ps') { + const state = load(); + const match = args.join(' ').match(/name=\\^([^$]+)\\$/); + if (match) { + const entry = state[match[1]]; + if (entry && (args.includes('-a') || entry.running)) fs.writeSync(1, match[1] + '\\n'); + process.exit(0); + } + for (const [name, entry] of Object.entries(state)) { + fs.writeSync(1, name + '\\t' + (entry.running ? 'running' : 'exited') + '\\t' + (entry.running ? 'Up' : 'Exited') + '\\t\\n'); + } + process.exit(0); +} +if (args[0] === 'run') { + const name = option('--name'); const labels = {}; + for (let i = 0; i < args.length; i++) if (args[i] === '--label') { const [key, ...value] = args[++i].split('='); labels[key] = value.join('='); } + const binds = args.flatMap((value, index) => value === '-v' ? [args[index + 1]] : []); + const state = load(); state[name] = { id: idFor(name), labels, binds, running: true }; save(state); + if (args.includes('--rm')) { delete state[name]; save(state); } + fs.writeSync(1, name + '\\n'); process.exit(0); +} +if (args[0] === 'inspect') { + const name = args[args.length - 1]; const entry = load()[name]; + if (!entry) { fs.writeSync(2, 'Error: No such object: ' + name + '\\n'); process.exit(1); } + fs.writeSync(1, JSON.stringify([{ Id: entry.id, Name: '/' + name, Config: { Labels: entry.labels }, HostConfig: { Binds: entry.binds }, State: { Running: entry.running } }]) + '\\n'); + process.exit(0); +} +if (args[0] === 'stop') { + const state = load(); const found = byId(state, args[args.length - 1]); + if (found && process.env.PROPR_FAKE_REPLACE_ON_STOP === found[0]) { + state[found[0]] = { id: 'e'.repeat(64), labels: { 'propr.stack': 'foreign', 'propr.service': found[1].labels['propr.service'] }, binds: [], running: false, sentinel: 'replacement-untouched' }; + } else if (found) found[1].running = false; + save(state); process.exit(found ? 0 : 1); +} +if (args[0] === 'start') { const state = load(); const found = byId(state, args[args.length - 1]); if (!found) process.exit(1); found[1].running = true; save(state); process.exit(0); } +if (args[0] === 'rm') { const state = load(); delete state[args[args.length - 1]]; save(state); process.exit(0); } +process.exit(0); +PROPR_FAKE_NODE +`, { mode: 0o700 }); + await chmod(executable, 0o700); + process.env.PATH = `${directory}:${oldPath ?? ''}`; + process.env.PROPR_FAKE_STATE = statePath; + process.env.PROPR_SKIP_REMOTE_IMAGE_CHECK = '1'; + const rootDir = join(directory, 'app-data', 'desktop', 'local-stack'); + await mkdir(join(rootDir, 'data'), { recursive: true, mode: 0o700 }); + await mkdir(join(rootDir, 'logs'), { mode: 0o700 }); + await mkdir(join(rootDir, 'repos'), { mode: 0o700 }); + await writeFile(join(rootDir, '.env'), 'DOCS_ENABLED=true\n', { mode: 0o600 }); + const manifestPath = fileURLToPath(new URL('../docker/launcher/manifest.json', import.meta.url)); + const cfg = resolveHostConfig({ rootDir, env: {}, manifestPath }); + try { + await startStackAsync(cfg, { ui: true, docs: true, tunnel: false }); + assert.equal((await getStackStatusAsync(cfg)).running, true, 'setup then reopen status'); + assert.equal(await isLifecycleStackRunningAsync(cfg), true); + + assert.deepEqual(await stopLifecycleStackAsync(cfg), { failed: [] }); + assert.equal((await getStackStatusAsync(cfg)).running, false); + assert.equal(await isLifecycleStackRunningAsync(cfg), false); + assert.deepEqual(await recoverStackAsync(cfg, { ui: true, docs: true, tunnel: false }), { recovered: true }); + assert.equal((await getStackStatusAsync(cfg)).running, true); + + const partial = JSON.parse(await readFile(statePath, 'utf8')); + partial['propr-worker'].running = false; + partial['propr-ui'].running = false; + partial['propr-docs'].running = false; + await writeFile(statePath, JSON.stringify(partial)); + await recoverStackAsync(cfg, { ui: true, docs: true, tunnel: false }); + const recovered = JSON.parse(await readFile(statePath, 'utf8')); + assert.equal(recovered['propr-worker'].running, true); + assert.equal(recovered['propr-ui'].running, true); + assert.equal(recovered['propr-docs'].running, true); + + await stopLifecycleStackAsync(cfg); + await recoverStackAsync(cfg, { ui: true, docs: true, tunnel: false }); + assert.equal((await getStackStatusAsync(cfg)).running, true, 'restart sequence'); + + await stopLifecycleStackAsync(cfg); + const foreign = JSON.parse(await readFile(statePath, 'utf8')); + foreign['propr-api'] = { id: 'f'.repeat(64), labels: { 'propr.stack': 'foreign', 'propr.service': 'api' }, binds: [], running: false, sentinel: 'untouched' }; + await writeFile(statePath, JSON.stringify(foreign)); + await assert.rejects(isLifecycleStackRunningAsync(cfg), /left untouched/); + await assert.rejects(recoverStackAsync(cfg, { ui: true, docs: true, tunnel: false }), /left untouched/); + assert.equal(JSON.parse(await readFile(statePath, 'utf8'))['propr-api'].sentinel, 'untouched'); + + const mismatched = JSON.parse(await readFile(statePath, 'utf8')); + mismatched['propr-api'] = { ...recovered['propr-api'], running: false, binds: ['/foreign:/usr/src/app/.env:ro'], sentinel: 'mismatch' }; + await writeFile(statePath, JSON.stringify(mismatched)); + await assert.rejects(recoverStackAsync(cfg, { ui: true, docs: true, tunnel: false }), /fixed-root binds/); + assert.equal(JSON.parse(await readFile(statePath, 'utf8'))['propr-api'].sentinel, 'mismatch'); + + await writeFile(statePath, JSON.stringify(recovered)); + process.env.PROPR_FAKE_REPLACE_ON_STOP = 'propr-worker'; + const replacedStop = await stopLifecycleStackAsync(cfg); + assert.ok(replacedStop.failed.includes('propr-worker')); + assert.equal(JSON.parse(await readFile(statePath, 'utf8'))['propr-worker'].sentinel, 'replacement-untouched'); + } finally { + process.env.PATH = oldPath; + if (oldState === undefined) delete process.env.PROPR_FAKE_STATE; else process.env.PROPR_FAKE_STATE = oldState; + if (oldReplaceOnStop === undefined) delete process.env.PROPR_FAKE_REPLACE_ON_STOP; else process.env.PROPR_FAKE_REPLACE_ON_STOP = oldReplaceOnStop; + if (oldSkip === undefined) delete process.env.PROPR_SKIP_REMOTE_IMAGE_CHECK; else process.env.PROPR_SKIP_REMOTE_IMAGE_CHECK = oldSkip; + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/test/orchestratorRollbackAbsenceProof.test.mjs b/test/orchestratorRollbackAbsenceProof.test.mjs new file mode 100644 index 000000000..61fd3082f --- /dev/null +++ b/test/orchestratorRollbackAbsenceProof.test.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { resolveConfig, startStackAsync } from '../docker/launcher/orchestrator.mjs'; + +const eventually = async (operation, timeoutMs = 10_000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { return await operation(); } catch { await new Promise(resolve => setTimeout(resolve, 20)); } + } + return operation(); +}; + +test('rollback proves exact-name absence and fails closed for unusable Docker proofs', { concurrency: false, timeout: 120_000 }, async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-rollback-proof-')); + const executable = join(directory, 'docker'); + const stateDir = join(directory, 'state'); + const marker = join(directory, 'created.marker'); + const previous = { + path: process.env.PATH, + state: process.env.PROPR_FAKE_STATE_DIR, + marker: process.env.PROPR_FAKE_MARKER, + mode: process.env.PROPR_FAKE_PROOF_MODE, + skip: process.env.PROPR_SKIP_REMOTE_IMAGE_CHECK, + }; + await mkdir(stateDir); + await writeFile(executable, `#!/bin/sh +exec /usr/local/bin/node - -- "$@" <<'PROPR_FAKE_NODE' +const fs = require('node:fs'); const path = require('node:path'); +const args = process.argv.slice(2); if (args[0] === '--') args.shift(); +const dir = process.env.PROPR_FAKE_STATE_DIR; const mode = process.env.PROPR_FAKE_PROOF_MODE; +const marker = process.env.PROPR_FAKE_MARKER; const target = 'propr-redis'; +const file = name => path.join(dir, encodeURIComponent(name) + '.json'); +const exists = name => fs.existsSync(file(name)); +const read = name => JSON.parse(fs.readFileSync(file(name), 'utf8')); +const remove = name => { try { fs.unlinkSync(file(name)); } catch {} }; +const option = key => { const index = args.indexOf(key); return index < 0 ? undefined : args[index + 1]; }; +if (args[0] === 'images') { fs.writeSync(1, 'image-id\\n'); process.exit(0); } +if (args[0] === 'image' && args[1] === 'inspect') { fs.writeSync(1, '[]\\n'); process.exit(0); } +if (args[0] === 'network') process.exit(0); +if (args[0] === 'ps') { + const match = args.join(' ').match(/name=\\^\\/?([^$]+)\\$/); + if (!match) process.exit(0); + const name = match[1].replace(/\\\\\./g, '.'); + const proof = args.includes('{{json .Names}}'); + if (!proof) { if (exists(name)) fs.writeSync(1, name + '\\n'); process.exit(0); } + if (name !== target || !exists(name)) process.exit(0); + if (mode === 'daemon-failure') { fs.writeSync(2, 'RAW_DOCKER_DAEMON_SECRET\\n'); process.exit(42); } + if (mode === 'permission-failure') { fs.writeSync(2, 'RAW_DOCKER_PERMISSION_SECRET\\n'); process.exit(13); } + if (mode === 'query-timeout') { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 30_000); process.exit(0); } + if (mode === 'query-signal') { process.kill(process.pid, 'SIGTERM'); } + if (mode === 'query-malformed') { fs.writeSync(1, 'RAW_DOCKER_MALFORMED_SECRET\\n'); process.exit(0); } + if (mode === 'query-truncated') { fs.writeSync(1, 'x'.repeat(20_000)); process.exit(0); } + if (mode === 'query-ambiguous') { fs.writeSync(1, JSON.stringify('not-' + name) + '\\n'); process.exit(0); } + if (mode === 'query-duplicate') { const row = JSON.stringify(name) + '\\n'; fs.writeSync(1, row + row); process.exit(0); } + fs.writeSync(1, JSON.stringify(name) + '\\n'); process.exit(0); +} +if (args[0] === 'run') { + const name = option('--name'); const labels = {}; + for (let i = 0; i < args.length; i += 1) if (args[i] === '--label') { const [key, ...rest] = args[++i].split('='); labels[key] = rest.join('='); } + if (args.includes('--rm')) process.exit(0); + fs.writeFileSync(file(name), JSON.stringify(labels)); fs.writeFileSync(marker, name); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 30_000); process.exit(0); +} +if (args[0] === 'inspect') { + const name = args[args.length - 1]; + if (!exists(name)) process.exit(1); + if (name === target && mode === 'exact-not-found') { remove(name); process.exit(1); } + if (name === target && mode === 'generic-inspect-present') { fs.writeSync(2, 'RAW_DOCKER_INSPECT_SECRET\\n'); process.exit(23); } + if (name === target && ['daemon-failure','permission-failure','query-timeout','query-signal','query-malformed','query-truncated','query-ambiguous','query-duplicate'].includes(mode)) process.exit(23); + fs.writeSync(1, JSON.stringify(read(name)) + '\\n'); process.exit(0); +} +if (args[0] === 'stop') { + const name = args[args.length - 1]; + if (mode === 'disappears-between-checks') { remove(name); process.exit(44); } + process.exit(0); +} +if (args[0] === 'rm') { remove(args[args.length - 1]); process.exit(0); } +process.exit(0); +PROPR_FAKE_NODE +`, { mode: 0o700 }); + await chmod(executable, 0o700); + process.env.PATH = `${directory}:${previous.path ?? ''}`; + process.env.PROPR_FAKE_STATE_DIR = stateDir; + process.env.PROPR_FAKE_MARKER = marker; + process.env.PROPR_SKIP_REMOTE_IMAGE_CHECK = '1'; + + const root = join(directory, 'app-data', 'desktop', 'local-stack'); + await mkdir(join(root, 'data'), { recursive: true, mode: 0o700 }); + await mkdir(join(root, 'logs'), { mode: 0o700 }); + await mkdir(join(root, 'repos'), { mode: 0o700 }); + await writeFile(join(root, '.env'), '', { mode: 0o600 }); + const cfg = resolveConfig({}, { + manifestPath: fileURLToPath(new URL('../docker/launcher/manifest.json', import.meta.url)), + envFileLocal: join(root, '.env'), envFileHost: join(root, '.env'), + hostData: join(root, 'data'), hostLogs: join(root, 'logs'), hostRepos: join(root, 'repos'), + }); + + const run = async mode => { + await rm(stateDir, { recursive: true, force: true }); await mkdir(stateDir); + await writeFile(marker, ''); process.env.PROPR_FAKE_PROOF_MODE = mode; + const logs = []; const controller = new AbortController(); + const operation = startStackAsync(cfg, { + ui: false, docs: false, tunnel: false, signal: controller.signal, + onLog: value => logs.push(value), + }); + const observed = operation.then(() => null, error => error); + await eventually(async () => assert.equal(await readFile(marker, 'utf8'), 'propr-redis')); + controller.abort(); + return { error: await observed, logs, remains: existsSync(join(stateDir, 'propr-redis.json')) }; + }; + + try { + for (const mode of ['exact-not-found', 'disappears-between-checks']) { + const result = await run(mode); + assert.ok(result.error, `${mode} must preserve the original cancellation`); + assert.notEqual(result.error?.code, 'PROPR_SETUP_CLEANUP_INCOMPLETE', `${mode} conclusively proves absence`); + assert.equal(result.remains, false, `${mode} leaves no run-owned container`); + assert.doesNotMatch(JSON.stringify([result.error, result.logs]), /RAW_DOCKER_/); + } + + for (const mode of [ + 'generic-inspect-present', 'daemon-failure', 'permission-failure', + 'query-timeout', 'query-signal', 'query-malformed', 'query-truncated', + 'query-ambiguous', 'query-duplicate', + ]) { + const result = await run(mode); + assert.equal(result.error?.code, 'PROPR_SETUP_CLEANUP_INCOMPLETE', `${mode} must fail closed`); + assert.equal(result.remains, true, `${mode} must not mutate without proved ownership`); + assert.match(String(result.error?.message), /cleanup is incomplete/); + assert.doesNotMatch(JSON.stringify([result.error, result.logs]), /RAW_DOCKER_/); + } + + const laterRetry = await run('exact-not-found'); + assert.notEqual(laterRetry.error?.code, 'PROPR_SETUP_CLEANUP_INCOMPLETE'); + assert.equal(laterRetry.remains, false, 'a later successful proof retry settles as cancelled'); + } finally { + process.env.PATH = previous.path; + for (const [name, value] of [['PROPR_FAKE_STATE_DIR', previous.state], ['PROPR_FAKE_MARKER', previous.marker], ['PROPR_FAKE_PROOF_MODE', previous.mode], ['PROPR_SKIP_REMOTE_IMAGE_CHECK', previous.skip]]) { + if (value === undefined) delete process.env[name]; else process.env[name] = value; + } + await rm(directory, { recursive: true, force: true }); + } +});