diff --git a/README.md b/README.md index 2884e9cf..6536cf99 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ modex/ explicit; connection tests identify the saved configuration, and failed saves retain your draft. CLI-only routing never falls back to HTTPS, existing sessions stay on their backend, and Auto stops if it cannot honor your reasoning-effort ceiling. +- **Local model connections (experimental).** Settings → Local models manages loopback connections and reads bounded model metadata without loading or downloading models. Local coding execution remains disabled while native qualification and product admission are open. See [setup and current limits](docs/local-models.md#local-models-v1-workflow-candidate). - **Launch from anywhere.** Opened from Finder or the Dock, Modex reads your login shell's PATH once at startup (interactive `zsh -ilc`, falling back to `-lc`), so `claude`, `codex`, and `jev` installed under nvm, Homebrew, or `~/.local/bin` are found exactly as in a terminal. diff --git a/apps/desktop/e2e/app.spec.ts b/apps/desktop/e2e/app.spec.ts index c9369868..7f3f01e1 100644 --- a/apps/desktop/e2e/app.spec.ts +++ b/apps/desktop/e2e/app.spec.ts @@ -150,6 +150,7 @@ test("⌘N → type → ⌘⏎ → approve: the agent edits the repo and the UI await expect(followUp).toHaveAttribute("data-source", "heuristic"); const suggested = (await tid(followUp, "followup-text").innerText()).trim(); expect(suggested).toMatch(/test|verif|review/i); + await expect(followUp).toHaveAccessibleName(`Use suggested follow-up: ${suggested}`); const input = tid(page, "composer-input"); await input.focus(); await input.press("Tab"); @@ -310,12 +311,18 @@ test("a key typed into Settings is kept encrypted outside state.json, reported m const status = tid(page, "routing-status"); await expect(status).toContainText("Jev transport configured"); await expect(status).toContainText("key ****4321 from Modex keychain"); - await expect(keyBox.locator("label > span").first()).toContainText("saved in test cipher (not secure)"); + const storageBackend = process.env.MODEX_PACKAGED_APP + ? process.platform === "darwin" ? "macOS Keychain" : process.platform === "win32" ? "Windows DPAPI" : "OS keyring" + : "test cipher (not secure)"; + await expect(keyBox.locator("label > span").first()).toContainText(`saved in ${storageBackend}`); await expect(keyBox.locator("input[type=password]")).toHaveValue(""); - // On disk: never in state.json, never in plaintext, and the secrets file is owner-only. + // The unpackaged fixture is reversible; a packaged artifact must use OS encryption. + // Neither stores credentials in state.json, and the secrets file remains owner-only. expect(fs.readFileSync(path.join(home, "app", "state.json"), "utf8")).not.toContain("4321"); const secretsFile = path.join(home, "app", "secrets.json"); expect(fs.readFileSync(secretsFile, "utf8")).not.toContain("sk-e2e-typed-key-4321"); + const savedKey = JSON.parse(fs.readFileSync(secretsFile, "utf8")).entries.typesafe_api_key.enc; + expect(Buffer.from(savedKey, "base64").includes(Buffer.from("sk-e2e-typed-key-4321"))).toBe(!process.env.MODEX_PACKAGED_APP); if (process.platform !== "win32") expect(fs.statSync(secretsFile).mode & 0o777).toBe(0o600); await tid(page, "settings").getByRole("button", { name: "Cancel" }).click(); await expect(tid(page, "settings")).toHaveCount(0); diff --git a/apps/desktop/e2e/code-review-support.ts b/apps/desktop/e2e/code-review-support.ts index 77688c53..5fe1f1c6 100644 --- a/apps/desktop/e2e/code-review-support.ts +++ b/apps/desktop/e2e/code-review-support.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { appDir, resizeWindow, seedHome, tid } from "./support.js"; import { WORLD_GH, type GhWorld } from "../test/fixtures/code-review-gh-world.js"; +import { closeWithDiagnostics } from "../test/support/electron-close.js"; export { baseWorld, FIELD_GUIDE, FIELD_GUIDE_73, FIELD_GUIDE_91, LENDING, LENDING_73 } from "../test/fixtures/code-review-gh-world.js"; @@ -93,17 +94,17 @@ export async function launchWithGh(initial: GhWorld, options: { showCodeReview?: // SIGKILL: no quit handlers run, as when the Mac loses power or Modex crashes mid-send. const pid = app.process().pid; if (pid) process.kill(pid, "SIGKILL"); - await app.close().catch(() => {}); + await closeWithDiagnostics(app, test.info().outputDir).catch(() => {}); ({ app, page } = await start()); harness.app = app; harness.page = page; }, restart: async () => { - await app.close().catch(() => {}); + await closeWithDiagnostics(app, test.info().outputDir).catch(() => {}); ({ app, page } = await start()); harness.app = app; harness.page = page; }, close: async () => { - await app.close().catch(() => {}); + await closeWithDiagnostics(app, test.info().outputDir).catch(() => {}); for (const dir of [home, repo]) fs.rmSync(dir, { recursive: true, force: true }); }, }; diff --git a/apps/desktop/e2e/fixtures/local-model-native.mjs b/apps/desktop/e2e/fixtures/local-model-native.mjs new file mode 100644 index 00000000..2fd0342e --- /dev/null +++ b/apps/desktop/e2e/fixtures/local-model-native.mjs @@ -0,0 +1,75 @@ +// Real transport for the existing opt-in disposable-home Electron bootstrap. Not packaged. +import fs from "node:fs"; +import path from "node:path"; +import { app } from "electron"; +import { LocalExecution } from "../../dist/src/main/engine/local-models/execution.js"; +import { cliSpawn } from "../../dist/src/main/engine/backends/health.js"; +import { resolveCli } from "../../dist/src/main/engine/cli-path.js"; +import { ALLOWED, MODEL, CONTEXT } from "./local-native-spec.mjs"; +import { nativeQualification } from "./native-qualification-guard.mjs"; + +export function createNativeExecution(home, connections, runner) { + const { cwd, origin } = nativeQualification({ home, enabled: process.env.MODEX_LM_NATIVE_E2E, e2e: process.env.MODEX_E2E, packaged: app.isPackaged }); + const bin = resolveCli("codex", "codex"); + const children = new Map(); + const events = []; + let cloudCalls = 0; + const record = event => { + const value = { at: Date.now(), ...event }; events.push(value); + fs.appendFileSync(path.join(home, "app/local-native-events.jsonl"), JSON.stringify(value) + "\n", { mode: 0o600 }); + }; + const observeLines = (direction, child) => { + let buffer = ""; + return bytes => { + buffer += bytes.toString(); + if (buffer.length > 1024 * 1024) throw new Error("Native qualification observer exceeded its bound."); + let end; + while ((end = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + try { + const frame = JSON.parse(line); + if (frame.method) record({ type: "rpc", direction, pid: child.pid, method: frame.method, ...(frame.params?.threadId ? { threadId: frame.params.threadId } : {}), ...(frame.params?.turn?.status ? { turnStatus: frame.params.turn.status } : {}), ...(frame.method === "error" ? { willRetry: frame.params?.willRetry, error: frame.params?.error } : {}) }); + } catch { record({ type: "observer-error", direction, pid: child.pid }); } + } + }; + }; + const spawn = (executable, args, options) => { + if (executable !== bin || args[0] !== "app-server" || !options.env?.HOME?.startsWith(path.join(home, "app/local-codex") + path.sep)) throw new Error("Refusing a non-owned native child."); + const child = cliSpawn(executable, args, options); + const row = { child, home: options.env.HOME, running: true }; children.set(child.pid, row); + record({ type: "spawn", pid: child.pid, executable, home: row.home }); + const input = observeLines("out", child), write = child.stdin.write; + child.stdin.write = function (bytes, ...rest) { input(bytes); return write.call(this, bytes, ...rest); }; + child.stdout.on("data", observeLines("in", child)); + child.once("exit", (code, signal) => { row.running = false; record({ type: "exit", pid: child.pid, code, signal }); }); + return child; + }; + const execution = new LocalExecution(home, connections, bin, spawn); + const validate = execution.validate.bind(execution); + execution.validate = thread => { + const target = validate(thread), connection = connections.resolve(target.model.connection); + if (target.model.id !== MODEL || connection?.endpoint.origin !== origin || thread.localContextTokens !== CONTEXT || fs.realpathSync(thread.cwd) !== cwd) throw new Error("Native qualification refused an unapproved target."); + return target; + }; + const pending = () => [...runner.live].flatMap(([threadId, live]) => Object.values(live.requests.requests).filter(r => r.state === "pending" && r.request.kind === "approval" && live.pending.has(r.request.id)).map(r => r.request).map(r => ({ threadId, itemId: r.id, + command: r.action?.tool === "command" ? String(r.action.input?.command ?? "") : "", cwd: r.action?.cwd, + allowed: r.action?.tool === "command" && ALLOWED.includes(String(r.action.input?.command ?? "")) && r.action.cwd && fs.realpathSync(r.action.cwd) === cwd }))); + globalThis.__modexLocalNative = { + snapshot: () => ({ kind: "real Codex and Ollama; test-only admission", executable: bin, cloudCalls, events: [...events], children: [...children].map(([pid, row]) => ({ pid, home: row.home, running: row.running })), pending: pending(), + provenance: Object.fromEntries(runner.o.store.snapshot().threads.filter(t => t.localTarget).map(t => [t.id, execution.provenance(t.id)])) }), + killCodex: pid => { + const row = children.get(pid); + if (!row || !row.running || row.child.exitCode !== null || row.child.signalCode) throw new Error("Native PID is no longer owned and running."); + record({ type: "injected-codex-loss", pid }); return row.child.kill("SIGKILL"); + }, + cloudCalled: () => { cloudCalls++; record({ type: "forbidden-cloud-call" }); }, + }; + const answer = runner.answer.bind(runner); + runner.answer = (threadId, itemId, decision, by) => { + const request = pending().find(r => r.threadId === threadId && r.itemId === itemId); + if (decision !== "no" && !request?.allowed) throw new Error("Native qualification refuses approval outside its exact command allowlist."); + record({ type: "approval", threadId, itemId, decision, command: request?.command ?? "" }); + return answer(threadId, itemId, decision, by); + }; + return execution; +} diff --git a/apps/desktop/e2e/fixtures/local-model-workflow.mjs b/apps/desktop/e2e/fixtures/local-model-workflow.mjs new file mode 100644 index 00000000..abde0b3a --- /dev/null +++ b/apps/desktop/e2e/fixtures/local-model-workflow.mjs @@ -0,0 +1,36 @@ +// Inject a scripted app-server only in a disposable Electron acceptance home. +// The production index, preload, IPC, runner, admission policy and renderer stay in use. +// This fixture is never packaged and does not establish real-model qualification. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { ThreadRunner } from "../../dist/src/main/engine/runner.js"; +import { LocalExecution } from "../../dist/src/main/engine/local-models/execution.js"; +import { LocalConnectionStore } from "../../dist/src/main/engine/local-models/connections.js"; +import { SecretStore, testCipher } from "../../dist/src/main/engine/secrets.js"; +import { localWorkflowSpawn } from "../../dist/test/local-workflow-fixture.js"; + +const home = fs.realpathSync(process.env.MODEX_HOME ?? ""); +if (process.env.MODEX_E2E !== "1" || !home.startsWith(fs.realpathSync(os.tmpdir()) + path.sep) + || !path.basename(home).startsWith("modex-e2e-home-") + || JSON.parse(fs.readFileSync(path.join(home, "app/state.json"), "utf8")).settings.default_backend !== "mock") { + throw new Error("Local workflow acceptance requires a disposable mock home."); +} +const installed = new WeakSet(); +// Only this unpackaged, disposable-home entry point can select the real test transport. +const native = process.env.MODEX_LM_NATIVE_E2E === "1" ? await import("./local-model-native.mjs") : null; +function install(runner) { + if (installed.has(runner)) return; + const connections = new LocalConnectionStore(home, new SecretStore(home, testCipher)); + + runner.o.localExecution = native ? native.createNativeExecution(home, connections, runner) : new LocalExecution(home, connections, "fixture-codex", localWorkflowSpawn); + runner.backends.codex = { id: "codex", listModels: async () => [{ id: "fixture-cloud", label: "Fixture Cloud", isDefault: true }], + health: async () => ({ executable: "available", authentication: "authenticated", access: "available", detail: "Offline fixture" }), + dispose: async () => {}, runTurn: async () => { globalThis.__modexLocalNative?.cloudCalled(); throw new Error("A local fixture reached the cloud backend"); } }; + installed.add(runner); +} +for (const method of ["updateThread", "send", "sendDurably", "retry", "listModels", "backend"]) { + const original = ThreadRunner.prototype[method]; + ThreadRunner.prototype[method] = function (...args) { install(this); return original.apply(this, args); }; +} +await import("../../dist/src/main/index.js"); diff --git a/apps/desktop/e2e/fixtures/local-native-spec.mjs b/apps/desktop/e2e/fixtures/local-native-spec.mjs new file mode 100644 index 00000000..fff95a62 --- /dev/null +++ b/apps/desktop/e2e/fixtures/local-native-spec.mjs @@ -0,0 +1,22 @@ +// Fixed inputs for the opt-in native lifecycle qualification. Never imported by product main. +export const MODEL = "qwen3-coder:30b"; +export const DIGEST = "06c1097efce0431c2045fe7b2e5108366e43bee1b4603a7aded8f21689e90bca"; +export const CONTEXT = 65536; +export const SENTINEL = "LM3-FIXTURE-SENTINEL-7f3a"; +export const EDITED = "LM3-APPROVED-EDIT-8c42\n"; +export const WRITE = "echo LM3-APPROVED-EDIT-8c42 > result.txt"; +export const CHECK_COMMAND = "node check.cjs"; +export const DELAY_MS = 10000; +export const GRACE_MS = 1500; +export const DELAYED_CASES = Object.freeze(["stop", "codex-loss", "server-loss"]); +export const FRESH_CASES = Object.freeze(["after-stop", "after-codex-loss", "after-server-loss"]); +export const commandFor = id => `node delayed-write.cjs ${id}`; +export const ALLOWED = Object.freeze(["cat fixture.txt", WRITE, CHECK_COMMAND, ...DELAYED_CASES.map(commandFor), ...FRESH_CASES.map(commandFor)]); +export const CHECK_SOURCE = `const fs=require('node:fs');const ok=fs.readFileSync('result.txt','utf8')===${JSON.stringify(EDITED)};fs.appendFileSync('checks.jsonl',JSON.stringify({ok,exitCode:ok?0:1})+'\\n');console.log(ok?'independent check passed':'independent check failed');process.exit(ok?0:1);\n`; +export const DELAY_SOURCE = `const fs=require('node:fs');const id=process.argv[2];const delayed=${JSON.stringify(DELAYED_CASES)}.includes(id);if(![...${JSON.stringify(DELAYED_CASES)},...${JSON.stringify(FRESH_CASES)}].includes(id))throw Error('Unknown qualification case');const startedAt=Date.now(),deadline=startedAt+(delayed?${DELAY_MS}:0);fs.appendFileSync('lifecycle-runs.jsonl',JSON.stringify({id,pid:process.pid,startedAt,deadline})+'\\n');fs.writeFileSync(id+'-started.json',JSON.stringify({id,pid:process.pid,startedAt,deadline}),{flag:'wx'});const finish=()=>{fs.appendFileSync('lifecycle-effects.jsonl',JSON.stringify({id,pid:process.pid,at:Date.now()})+'\\n');fs.writeFileSync(id+'-effect.txt',id+':one\\n',{flag:'wx'});console.log(id+' effect completed');};if(delayed)setTimeout(finish,${DELAY_MS});else finish();\n`; +export const SCENARIOS = Object.freeze({ + golden: { barrier: "approved exact edit and separately seeded check", result: EDITED, checks: 1 }, + stop: { barrier: "stop-started.json exists before deadline", dispatches: 1, effectsAfterDeadline: 0, freshAction: "after-stop", freshEffects: 1 }, + "codex-loss": { barrier: "codex-loss-started.json exists before deadline; exact owned native PID", dispatches: 1, effectsAfterDeadline: "zero or one from original command; never replayed", freshAction: "after-codex-loss", freshEffects: 1 }, + "server-loss": { barrier: "server-loss-started.json exists before deadline; exact owned inference-server PID", dispatches: 1, effectsAfterDeadline: "zero or one from original command; never replayed", freshAction: "after-server-loss", freshEffects: 1 }, +}); diff --git a/apps/desktop/e2e/fixtures/native-qualification-guard.mjs b/apps/desktop/e2e/fixtures/native-qualification-guard.mjs new file mode 100644 index 00000000..ef88e3bc --- /dev/null +++ b/apps/desktop/e2e/fixtures/native-qualification-guard.mjs @@ -0,0 +1,21 @@ +// Validation for the opt-in, unpackaged native fixture. This module starts no process or service. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { MODEL, DIGEST, CONTEXT } from "./local-native-spec.mjs"; + +export function nativeQualification({ home, enabled, e2e, packaged }) { + if (enabled !== "1" || e2e !== "1" || packaged !== false) throw new Error("Native qualification requires an opt-in unpackaged test app."); + const temporary = fs.realpathSync(os.tmpdir()), root = fs.realpathSync(home); + if (!root.startsWith(temporary + path.sep) || !path.basename(root).startsWith("modex-e2e-home-") + || JSON.parse(fs.readFileSync(path.join(root, "app/state.json"), "utf8")).settings.default_backend !== "mock") throw new Error("Native qualification requires a disposable mock home."); + const file = path.join(root, "local-native-qualification.json"), stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192) throw new Error("Invalid native qualification manifest."); + const manifest = JSON.parse(fs.readFileSync(file, "utf8")); + const cwd = fs.realpathSync(manifest.cwd), origin = new URL(manifest.origin); + if (manifest.version !== 1 || manifest.authorization !== "local-native-lifecycle" || manifest.model !== MODEL || manifest.digest !== DIGEST || manifest.contextTokens !== CONTEXT + || !cwd.startsWith(temporary + path.sep) || !path.basename(cwd).startsWith("modex-e2e-repo-") + || origin.protocol !== "http:" || origin.hostname !== "127.0.0.1" || !origin.port || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash + || Object.keys(manifest).some(key => !["version", "authorization", "model", "digest", "contextTokens", "cwd", "origin"].includes(key))) throw new Error("Native qualification is limited to the approved disposable project and model."); + return { cwd, origin: origin.origin }; +} diff --git a/apps/desktop/e2e/images-plugins.spec.ts b/apps/desktop/e2e/images-plugins.spec.ts index e8f760fe..2bc9c9ee 100644 --- a/apps/desktop/e2e/images-plugins.spec.ts +++ b/apps/desktop/e2e/images-plugins.spec.ts @@ -9,6 +9,8 @@ const png = Buffer.from( test("Images imports, displays, inserts into Space, attaches to chat and survives relaunch", async () => { const { home } = seedHome(); let { app, page } = await launch(home); + // Electron owns beforeunload; CDP dismissal races its native close decision. + page.on("dialog", dialog => { if (dialog.type() !== "beforeunload") void dialog.dismiss(); }); try { await tid(page, "rail-images").click(); await expect( @@ -80,6 +82,7 @@ test("Images imports, displays, inserts into Space, attaches to chat and survive await page.getByRole("button", { name: "Restore", exact: true }).click(); await app.close(); ({ app, page } = await launch(home)); + page.on("dialog", dialog => { if (dialog.type() !== "beforeunload") void dialog.dismiss(); }); await tid(page, "rail-images").click(); await expect( page.getByRole("button", { name: "Open Kept image", exact: true }), @@ -309,3 +312,52 @@ for (const mode of ["delayed", "rejected"] as const) test(`ordinary close preser await app.close(); } }); + +test("opening an already saved image draft neither rewrites it nor vetoes ordinary close", async () => { + const { home } = seedHome(); + const { app, page } = await launch(home); + const nativeProcess = app.process(); + const file = path.join(home, "images/jobs.json"); + let closed = false; app.on("close", () => { closed = true; }); + const exited = new Promise(resolve => app.once("close", () => resolve())); + page.on("dialog", dialog => { if (dialog.type() !== "beforeunload") void dialog.dismiss(); }); + try { + const capability = await page.evaluate(() => window.modex.invoke("images:bind", { runtime: "claude", account: "cli", pluginId: "modex-fixture", pluginVersion: "1", entrypoint: "modex-fixture", label: "Offline fixture", edit: true })); + await page.evaluate(capabilityId => window.modex.invoke("images:saveDraft", { prompt: "Already saved image idea", capabilityId, referenceIds: [] }), capability.id); + const before = fs.readFileSync(file); + await app.evaluate(({ ipcMain, BrowserWindow }) => { + const controls = globalThis as typeof globalThis & { unchangedImageWrites?: number; unchangedImageVetoes?: number; releaseUnchangedImageWrite?: () => void }; + controls.unchangedImageWrites = 0; controls.unchangedImageVetoes = 0; + ipcMain.removeHandler("images:saveDraft"); + ipcMain.handle("images:saveDraft", async () => { + controls.unchangedImageWrites!++; + await new Promise(resolve => { controls.releaseUnchangedImageWrite = resolve; }); + }); + BrowserWindow.getAllWindows()[0]!.webContents.on("will-prevent-unload", () => { controls.unchangedImageVetoes!++; }); + }); + await tid(page, "rail-images").click(); + await page.getByRole("button", { name: "Generate", exact: true }).click(); + await expect(page.getByLabel("Image prompt", { exact: true })).toHaveValue("Already saved image idea"); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + const writes = await app.evaluate(() => (globalThis as { unchangedImageWrites: number }).unchangedImageWrites); + await app.evaluate(({ app }) => { app.quit(); }); + await expect.poll(async () => { + if (closed) return "closed"; + return app.evaluate(() => (globalThis as { unchangedImageVetoes: number }).unchangedImageVetoes ? "vetoed" : "waiting").catch(() => closed ? "closed" : "waiting"); + }).not.toBe("waiting"); + expect(closed, "opening an acknowledged draft must not create a new close veto").toBe(true); + await exited; + await expect.poll(() => nativeProcess.exitCode).toBe(0); + expect(nativeProcess.signalCode).toBeNull(); + expect(writes).toBe(0); + expect(fs.readFileSync(file)).toEqual(before); + } finally { + if (!closed) { + await app.evaluate(({ BrowserWindow }) => { + (globalThis as { releaseUnchangedImageWrite?: () => void }).releaseUnchangedImageWrite?.(); + for (const window of BrowserWindow.getAllWindows()) window.destroy(); + }).catch(() => {}); + await app.close(); + } + } +}); diff --git a/apps/desktop/e2e/local-model-acceptance.spec.ts b/apps/desktop/e2e/local-model-acceptance.spec.ts new file mode 100644 index 00000000..fa1e365d --- /dev/null +++ b/apps/desktop/e2e/local-model-acceptance.spec.ts @@ -0,0 +1,145 @@ +import { test, expect, _electron as electron, type Page } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { appDir, seedHome, tid } from "./support.js"; + +// Full app composition with a scripted native transport injected by a temporary-home-only +// bootstrap. This exercises fixture integration, not product admission or a real model. +async function launchFixture(home: string) { + const app = await electron.launch({ args: [path.join(appDir, "e2e/fixtures/local-model-workflow.mjs")], cwd: appDir, + env: { ...process.env, MODEX_HOME: home, MODEX_E2E: "1", MODEX_E2E_NODE: process.execPath, MODEX_NO_LOGIN_PATH: "1", TYPESAFE_API_KEY: "", JEV_API_KEY: "" } }); + const page = await app.firstWindow(); + await page.waitForLoadState("domcontentloaded"); + await expect(tid(page, "composer-input")).toBeVisible(); + return { app, page }; +} + +async function runtime() { + const hits: string[] = []; + const server = http.createServer((req, res) => { + hits.push(`${req.method} ${req.url}`); + res.setHeader("content-type", "application/json"); + if (req.url === "/api/tags") res.end(JSON.stringify({ models: [{ name: "fixture-coder", details: { family: "qwen3moe" } }] })); + else if (req.url === "/api/ps") res.end(JSON.stringify({ models: [] })); + else if (req.url === "/api/show") res.end(JSON.stringify({ capabilities: ["completion", "tools"], model_info: { "qwen3moe.context_length": 65536 } })); + else { res.writeHead(404); res.end(); } + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + return { hits, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + stop: () => { server.closeAllConnections(); server.close(); } }; +} + +async function connect(page: Page, url: string) { + await tid(page, "open-settings").click(); + await page.getByRole("button", { name: "Local models", exact: true }).click(); + await tid(page, "local-runtime").selectOption("ollama"); + await tid(page, "local-label").fill("Ollama fixture"); + await tid(page, "local-url").fill(url); + await tid(page, "local-add").click(); + await expect(tid(page, "local-connection")).toHaveAttribute("data-state", "ready"); + await page.keyboard.press("Escape"); + await choose(page); +} + +async function choose(page: Page) { + await tid(page, "model-picker").click(); + await tid(page, "model-local-option").click(); + await tid(page, "model-local-context").getByRole("menuitemradio", { name: /65,536/ }).click(); + await expect(tid(page, "model-local-badge")).toHaveText("Ollama"); +} + +async function send(page: Page, text: string) { + await tid(page, "composer-input").fill(text); + await tid(page, "send").click(); +} + +test("local golden fixture: approved edit, independent check, diff, Stop, continuation, restart and server failure", async ({}, testInfo) => { + const r = await runtime(); + const { home, repo } = seedHome(); + fs.writeFileSync(path.join(repo, "greeting.txt"), "hello\n"); + fs.writeFileSync(path.join(repo, "fixture-check.cjs"), `const fs=require('node:fs'); const ok=fs.readFileSync('greeting.txt','utf8')===${JSON.stringify("hello, local\n")}; fs.appendFileSync('checks.txt',String(ok)+${JSON.stringify("\n")}); console.log(ok?'check passed':'check failed'); process.exit(ok?0:1);\n`); + + try { + let run = await launchFixture(home); + try { + await connect(run.page, r.url); + await expect(tid(run.page, "local-inference-host")).toContainText(r.url); + await send(run.page, "Edit the greeting and run its check."); + const approve = run.page.getByRole("button", { name: "Approve", exact: true }); + await expect(approve).toBeVisible(); + expect(fs.readFileSync(path.join(repo, "greeting.txt"), "utf8")).toBe("hello\n"); + await approve.click(); + await expect.poll(() => fs.existsSync(path.join(repo, "checks.txt")) && fs.readFileSync(path.join(repo, "checks.txt"), "utf8")).toBe("true\n"); + await expect(tid(run.page, "stop")).toHaveCount(0); + expect(fs.readFileSync(path.join(repo, "greeting.txt"), "utf8")).toBe("hello, local\n"); + const state = await run.page.evaluate(() => window.modex!.invoke("state:get", undefined)); + const threadId = state.threads.find(t => t.localTarget)?.id; + expect(threadId).toBeTruthy(); + const diff = await run.page.evaluate(id => window.modex!.invoke("changes:diff", { threadId: id!, path: "greeting.txt" }), threadId); + expect(JSON.stringify(diff)).toContain("hello, local"); + await run.page.keyboard.press("Control+Shift+G"); + await tid(run.page, "changes-file").filter({ hasText: "greeting.txt" }).getByRole("button").first().click(); + await expect(tid(run.page, "changes-diff")).toContainText("hello, local"); + + await send(run.page, "STOP_FIXTURE"); + await expect.poll(() => fs.existsSync(path.join(repo, "stop-started.txt"))).toBe(true); + await tid(run.page, "stop").click(); + await expect(tid(run.page, "stop")).toHaveCount(0); + expect(fs.existsSync(path.join(repo, "late-effect.txt"))).toBe(false); + await send(run.page, "Continue explicitly."); + await run.page.getByRole("button", { name: "Approve", exact: true }).click(); + await expect.poll(() => fs.readFileSync(path.join(repo, "edits.txt"), "utf8")).toBe("edit\nedit\n"); + await expect(tid(run.page, "stop")).toHaveCount(0); + + await run.app.close(); run = await launchFixture(home); + expect(fs.readFileSync(path.join(repo, "edits.txt"), "utf8")).toBe("edit\nedit\n"); + await send(run.page, "An old handle must refuse after restart."); + await expect(run.page.getByText(/native session is gone/)).toBeVisible(); + expect(fs.readFileSync(path.join(repo, "edits.txt"), "utf8")).toBe("edit\nedit\n"); + await choose(run.page); + await send(run.page, "SERVER_FAILURE"); + const failure = run.page.getByRole("alert").filter({ hasText: "The local server returned an internal error (HTTP 500)." }); + await expect(failure).toBeVisible(); + await expect(failure).toContainText("Modex didn't retry the turn or switch models."); + expect(fs.readFileSync(path.join(repo, "edits.txt"), "utf8")).toBe("edit\nedit\n"); + expect(r.hits.every(hit => /^(GET \/api\/(tags|ps)|POST \/api\/show)$/.test(hit))).toBe(true); + await run.page.screenshot({ path: testInfo.outputPath("local-workflow-failure.png"), fullPage: true }); + } finally { await run.app.close(); } + } finally { r.stop(); } +}); + +test("editing a connection invalidates its selected revision and cloud return confirms the exact model", async () => { + const r = await runtime(); const { home } = seedHome(); + try { + const run = await launchFixture(home); + try { + await connect(run.page, r.url); + await tid(run.page, "open-settings").click(); + await run.page.getByRole("button", { name: "Local models", exact: true }).click(); + await tid(run.page, "local-edit").click(); + await tid(run.page, "local-edit-form").getByLabel("Name", { exact: true }).fill("Renamed fixture"); + await tid(run.page, "local-edit-form").getByLabel("Replacement token (optional)").fill("fixture-token-revision-2"); + await tid(run.page, "local-edit-form").getByRole("button", { name: "Save connection" }).click(); + await expect(tid(run.page, "local-connection")).toContainText("Renamed fixture"); + await pageReady(run.page); + await send(run.page, "Stale configuration must send nothing."); + await expect(run.page.getByText(/changed or was deleted|destination changed/).first()).toBeVisible(); + await choose(run.page); + await tid(run.page, "model-picker").click(); + const dismissed = run.page.waitForEvent("dialog").then(async dialog => { + expect(dialog.message()).toContain("fixture-cloud"); await dialog.dismiss(); + }); + await tid(run.page, "model-local-clear").click(); await dismissed; + expect((await run.page.evaluate(() => window.modex!.invoke("state:get", undefined))).threads.some(t => t.localTarget)).toBe(true); + await tid(run.page, "model-picker").click(); + run.page.once("dialog", dialog => void dialog.accept()); + await tid(run.page, "model-local-clear").click(); + await expect.poll(async () => (await run.page.evaluate(() => window.modex!.invoke("state:get", undefined))).threads.some(t => t.localTarget)).toBe(false); + expect((await run.page.evaluate(() => window.modex!.invoke("state:get", undefined))).threads[0]?.model).toBe("fixture-cloud"); + } finally { await run.app.close(); } + } finally { r.stop(); } +}); + +async function pageReady(page: Page) { await page.keyboard.press("Escape"); await expect(tid(page, "settings")).toHaveCount(0); } diff --git a/apps/desktop/e2e/local-model-native.spec.ts b/apps/desktop/e2e/local-model-native.spec.ts new file mode 100644 index 00000000..5ce4b60f --- /dev/null +++ b/apps/desktop/e2e/local-model-native.spec.ts @@ -0,0 +1,260 @@ +import { test, expect, _electron as electron, type ElectronApplication, type Page } from "@playwright/test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import net from "node:net"; +import { createHash } from "node:crypto"; +import { spawn, execFileSync, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { appDir, seedHome, tid, items, waitForWindowReady } from "./support.js"; +import { realCodexConfig } from "../dist/test/local-global-config-guard.js"; +import { MODEL, DIGEST, CONTEXT, SENTINEL, EDITED, WRITE, CHECK_COMMAND, CHECK_SOURCE, DELAY_SOURCE, DELAY_MS, GRACE_MS, ALLOWED, SCENARIOS, commandFor } from "./fixtures/local-native-spec.mjs"; + +// The default PR suite skips this file before any server or CLI starts. All inference stays in +// the existing production LocalExecution/Codex path; the disposable bootstrap only admits it. +test.skip(process.env.MODEX_LM_NATIVE_E2E !== "1", "real local-model lifecycle is explicitly opt-in"); +test.setTimeout(240_000); +// One owned service and immutable receipt directory per qualification run; preserve the first failure. +test.describe.configure({ mode: "serial" }); + +const report = process.env.MODEX_LM_NATIVE_REPORT ?? ""; +const sha256 = (file: string) => createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +const jsonLines = (file: string): any[] => fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line)) : []; +type Opened = { app: ElectronApplication; page: Page }; +type Service = { child: ChildProcess; port: number; url: string; home: string; generation: number }; +let service: Service; +let generation = 0; +let personalConfig: ReturnType; +const results: Record[] = []; +const runtimeEnv = () => Object.fromEntries(["PATH", "LANG", "TMPDIR", "USER", "LOGNAME"].filter(key => process.env[key]).map(key => [key, process.env[key]!])) as NodeJS.ProcessEnv; +const listing = async (s: Service) => { + const response = await fetch(s.url + "/api/tags", { redirect: "error", signal: AbortSignal.timeout(1500) }); + const models = (await response.json()).models; + const model = models.find((m: any) => m.name === MODEL); + expect(model?.digest, "owned service must list the exact approved digest").toBe(DIGEST); + return model; +}; + +async function startService(port?: number): Promise { + if (!port) { + const reserved = net.createServer(); reserved.listen(0, "127.0.0.1"); await once(reserved, "listening"); + port = (reserved.address() as net.AddressInfo).port; await new Promise(resolve => reserved.close(() => resolve())); + } + const home = fs.mkdtempSync(path.join(os.tmpdir(), "modex-native-ollama-")); + const n = ++generation, log = fs.openSync(path.join(report, `ollama-${n}.log`), "wx", 0o600); + const child = spawn("ollama", ["serve"], { env: { ...runtimeEnv(), HOME: home, NO_PROXY: "*", OLLAMA_HOST: `127.0.0.1:${port}`, OLLAMA_MODELS: path.join(os.homedir(), ".ollama/models"), OLLAMA_CONTEXT_LENGTH: String(CONTEXT), OLLAMA_NO_CLOUD: "true", OLLAMA_NOPRUNE: "true" }, stdio: ["ignore", log, log] }); + fs.closeSync(log); + const s = { child, port, url: `http://127.0.0.1:${port}`, home, generation: n }; + try { + const until = Date.now() + 20000; + while (true) { + if (child.exitCode !== null || child.signalCode) throw new Error("Owned Ollama exited before metadata readiness"); + try { await listing(s); return s; } + catch (error) { if (Date.now() >= until) throw error; await new Promise(resolve => setTimeout(resolve, 100)); } + } + } catch (error) { await stopService(s); throw error; } +} + +async function stopService(s: Service) { + if (s.child.exitCode === null && !s.child.signalCode) { + const closed = once(s.child, "close"); s.child.kill("SIGTERM"); + const force = setTimeout(() => s.child.kill("SIGKILL"), 5000); await closed; clearTimeout(force); + } + fs.rmSync(s.home, { recursive: true, force: true }); +} + +test.beforeAll(async () => { + expect(process.platform).toBe("darwin"); + expect(path.isAbsolute(report), "choose a new absolute report directory outside the worktree").toBe(true); + const relative = path.relative(appDir, report); expect(relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)).toBe(true); + fs.mkdirSync(report, { recursive: true, mode: 0o700 }); + expect(fs.existsSync(path.join(report, "lifecycle.json")), "never overwrite a prior run").toBe(false); + personalConfig = realCodexConfig(); + const git = (args: string[]) => execFileSync("git", args, { cwd: appDir, encoding: "utf8" }).trim(); + fs.writeFileSync(path.join(report, "protocol.json"), JSON.stringify({ source: { head: git(["rev-parse", "HEAD"]), indexTree: git(["write-tree"]), status: git(["status", "--porcelain=v1"]), patchSha256: createHash("sha256").update(git(["diff", "HEAD", "--"])).digest("hex") }, model: MODEL, digest: DIGEST, contextTokens: CONTEXT, + host: { cpu: os.cpus()[0]?.model, architecture: os.arch(), release: os.release(), memoryBytes: os.totalmem() }, versions: { codex: execFileSync("codex", ["--version"], { encoding: "utf8" }).trim(), ollama: execFileSync("ollama", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(), node: process.version }, + allowedCommands: ALLOWED, delayMs: DELAY_MS, observationGraceMs: GRACE_MS, scenarios: SCENARIOS, kind: "real Electron/main/Codex/Ollama; ordinary native sandbox; not offline/human acceptance" }, null, 2) + "\n", { flag: "wx", mode: 0o600 }); + service = await startService(); + const model = await listing(service); + const metadata = await (await fetch(service.url + "/api/show", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: MODEL }), redirect: "error" })).json(); + fs.writeFileSync(path.join(report, "runtime.json"), JSON.stringify({ pid: service.child.pid, origin: service.url, model, details: metadata.details, capabilities: metadata.capabilities, modelInfo: metadata.model_info, templateSha256: createHash("sha256").update(metadata.template ?? "").digest("hex"), parameters: metadata.parameters }, null, 2) + "\n", { flag: "wx", mode: 0o600 }); +}); + +test.afterAll(async () => { + if (service) await stopService(service); + if (personalConfig) { + const configuration = personalConfig(); + fs.writeFileSync(path.join(report, "lifecycle.json"), JSON.stringify({ cases: results, realConfiguration: configuration, serviceStopped: service ? service.child.exitCode !== null || Boolean(service.child.signalCode) : true, humanAcceptance: "unrun", offline: "not claimed by this Electron run" }, null, 2) + "\n", { mode: 0o600 }); + expect(configuration.unchanged, "personal Codex configuration must remain unchanged").toBe(true); + } +}); + +function seed() { + const seeded = seedHome(); + fs.writeFileSync(path.join(seeded.home, "local-native-qualification.json"), JSON.stringify({ version: 1, authorization: "local-native-lifecycle", model: MODEL, digest: DIGEST, contextTokens: CONTEXT, cwd: seeded.repo, origin: service.url }) + "\n", { mode: 0o600 }); + fs.writeFileSync(path.join(seeded.repo, "fixture.txt"), SENTINEL + "\n"); + fs.writeFileSync(path.join(seeded.repo, "result.txt"), "before approval\n"); + fs.writeFileSync(path.join(seeded.repo, "check.cjs"), CHECK_SOURCE); + fs.writeFileSync(path.join(seeded.repo, "delayed-write.cjs"), DELAY_SOURCE); + fs.appendFileSync(path.join(report, "seeds.jsonl"), JSON.stringify(seeded) + "\n", { mode: 0o600 }); + return { ...seeded, checkHash: sha256(path.join(seeded.repo, "check.cjs")), delayHash: sha256(path.join(seeded.repo, "delayed-write.cjs")) }; +} + +async function launchNative(home: string): Promise { + const app = await electron.launch({ args: [path.join(appDir, "e2e/fixtures/local-model-workflow.mjs")], cwd: appDir, + env: { ...runtimeEnv(), HOME: os.homedir(), MODEX_HOME: home, MODEX_E2E: "1", MODEX_LM_NATIVE_E2E: "1", MODEX_NO_LOGIN_PATH: "1", ELECTRON_OVERRIDE_DIST_PATH: process.env.ELECTRON_OVERRIDE_DIST_PATH ?? "", TYPESAFE_API_KEY: "", JEV_API_KEY: "" } }); + try { + const page = await app.firstWindow(); await page.waitForLoadState("domcontentloaded"); await waitForWindowReady(app, page); + await page.evaluate(() => window.modex!.invoke("models:list", { backend: "codex" })); + return { app, page }; + } catch (error) { await app.close().catch(() => {}); throw error; } +} +const snapshot = (run: Opened): Promise => run.app.evaluate(() => (globalThis as any).__modexLocalNative?.snapshot()); +const state = (run: Opened) => run.page.evaluate(() => window.modex!.invoke("state:get", undefined)); +const thread = async (run: Opened) => (await state(run)).threads.find(t => t.localTarget)!; +const transcript = async (run: Opened): Promise => { const t = await thread(run); return run.page.evaluate(id => window.modex!.invoke("thread:items", { threadId: id }), t.id); }; +async function choose(run: Opened) { + await tid(run.page, "model-picker").click(); + await tid(run.page, "model-local-option").filter({ hasText: MODEL }).click(); + await tid(run.page, "model-local-context").getByRole("menuitemradio", { name: /65,536/ }).click(); + await expect(tid(run.page, "model-local-badge")).toHaveText("Ollama"); +} +async function connect(run: Opened) { + await tid(run.page, "open-settings").click(); + await run.page.getByRole("button", { name: "Local models", exact: true }).click(); + await tid(run.page, "local-runtime").selectOption("ollama"); + await tid(run.page, "local-label").fill("Ollama native lifecycle"); + await tid(run.page, "local-url").fill(service.url); await tid(run.page, "local-add").click(); + await expect(tid(run.page, "local-connection")).toHaveAttribute("data-state", "ready"); + await run.page.keyboard.press("Escape"); await choose(run); + await expect(tid(run.page, "local-inference-host")).toContainText(service.url); +} +async function send(run: Opened, prompt: string) { + await tid(run.page, "composer-input").fill(prompt); await tid(run.page, "send").click(); + await expect.poll(async () => (await transcript(run)).some(item => item.kind === "user" && item.text === prompt)).toBe(true); +} +async function approve(run: Opened, expectedCommand: string) { + await expect.poll(async () => Boolean((await snapshot(run))?.pending.length) || ["idle", "error"].includes((await thread(run)).status), { timeout: 90000, intervals: [100, 200, 500] }).toBe(true); + const pending = (await snapshot(run)).pending; + expect(pending).toHaveLength(1); expect(pending[0].allowed).toBe(true); expect(pending[0].command).toBe(expectedCommand); + await items(run.page, "approval").getByRole("button", { name: "Approve", exact: true }).click(); +} +async function idle(run: Opened) { + await expect.poll(async () => (await thread(run)).status, { timeout: 90000 }).toMatch(/^(idle|error)$/); + await expect(tid(run.page, "stop")).toHaveCount(0, { timeout: 90000 }); +} +const commandPrompt = (command: string) => `The current disposable repository is already prepared. The fixture scripts exist and require no inspection. Invoke exec_command to execute exactly this shell command once:\n\n\`\`\`sh\n${command}\n\`\`\`\n\nThe client handles native command approval. Do not ask a question or use request_user_input. Do not print tool-call syntax as text. Do not run other commands, including ls, cat, pwd or preliminary checks. Do not use apply_patch, edit scripts, retry or change the command.`; +const dispatches = (repo: string, id: string) => jsonLines(path.join(repo, "lifecycle-runs.jsonl")).filter(row => row.id === id); +const effects = (repo: string, id: string) => jsonLines(path.join(repo, "lifecycle-effects.jsonl")).filter(row => row.id === id); +async function marker(repo: string, id: string) { + const file = path.join(repo, id + "-started.json"); + await expect.poll(() => fs.existsSync(file), { timeout: 90000, intervals: [50, 100, 200] }).toBe(true); + const started = JSON.parse(fs.readFileSync(file, "utf8")); + expect(Date.now(), "fault injection must happen before the command's deadline").toBeLessThan(started.deadline - 2000); + expect(dispatches(repo, id)).toHaveLength(1); return started; +} +async function pastDeadline(started: any) { const remaining = started.deadline + GRACE_MS - Date.now(); if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining)); } +function scriptsIntact(seeded: ReturnType) { expect(sha256(path.join(seeded.repo, "check.cjs"))).toBe(seeded.checkHash); expect(sha256(path.join(seeded.repo, "delayed-write.cjs"))).toBe(seeded.delayHash); expect(fs.readFileSync(path.join(seeded.repo, "fixture.txt"), "utf8")).toBe(SENTINEL + "\n"); } +async function save(name: string, run: Opened | undefined, extra: Record) { + const native = run ? await snapshot(run).catch(() => undefined) : undefined; + if (native) expect(native.cloudCalls).toBe(0); + const view = run ? { state: await state(run).catch(() => undefined), transcript: await transcript(run).catch(() => undefined) } : undefined; + const loaded = service?.child.exitCode === null && !service.child.signalCode ? await fetch(service.url + "/api/ps", { redirect: "error", signal: AbortSignal.timeout(1500) }).then(r => r.json()).catch(() => undefined) : undefined; + const value = { name, ...extra, native, view, loaded, at: new Date().toISOString() }; results.push(value); + fs.writeFileSync(path.join(report, name + ".json"), JSON.stringify(value, null, 2) + "\n", { flag: "wx", mode: 0o600 }); +} + +test("native Electron golden: real read, exact approved edit, independent check and rendered diff", async () => { + const seeded = seed(); let run: Opened | undefined; let completed = false; + try { + run = await launchNative(seeded.home); await connect(run); + await send(run, "Run `cat fixture.txt` in the current directory and reply with exactly the word it contains."); + await approve(run, "cat fixture.txt"); await idle(run); + const read = await transcript(run); + expect(read.some(item => item.kind === "tool" && item.outcome === "succeeded" && /cat fixture.txt/.test(item.exec?.command ?? item.title ?? ""))).toBe(true); + expect(read.some(item => item.kind === "assistant" && item.text?.includes(SENTINEL))).toBe(true); + await send(run, commandPrompt(WRITE)); + await expect.poll(async () => (await snapshot(run)).pending.some((p: any) => p.command === WRITE), { timeout: 90000 }).toBe(true); + expect(fs.readFileSync(path.join(seeded.repo, "result.txt"), "utf8")).toBe("before approval\n"); + await approve(run, WRITE); await idle(run); + expect(fs.readFileSync(path.join(seeded.repo, "result.txt"), "utf8")).toBe(EDITED); + await send(run, commandPrompt(CHECK_COMMAND)); await approve(run, CHECK_COMMAND); await idle(run); + expect(jsonLines(path.join(seeded.repo, "checks.jsonl"))).toEqual([{ ok: true, exitCode: 0 }]); + const final = await transcript(run), checked = final.filter(item => item.kind === "tool" && /node check.cjs/.test(item.exec?.command ?? item.title ?? "")); + expect(checked).toHaveLength(1); expect(checked[0].outcome).toBe("succeeded"); expect(checked[0].exitCode).toBe(0); + const t = await thread(run); + const diff = await run.page.evaluate(id => window.modex!.invoke("changes:diff", { threadId: id, path: "result.txt" }), t.id); + expect(JSON.stringify(diff)).toContain("LM3-APPROVED-EDIT-8c42"); + await run.page.keyboard.press("Control+Shift+G"); + await tid(run.page, "changes-file").filter({ hasText: "result.txt" }).getByRole("button").first().click(); + await expect(tid(run.page, "changes-diff")).toContainText("LM3-APPROVED-EDIT-8c42"); + await run.page.screenshot({ path: path.join(report, "golden.png"), fullPage: true }); + scriptsIntact(seeded); await listing(service); completed = true; + } finally { try { await save("golden", run, { completed, repo: seeded.repo, modelDigest: DIGEST }); } finally { await run?.app.close().catch(() => {}); } } +}); + +async function recover(run: Opened, seeded: ReturnType, original: string, before: any, fresh: string) { + const old = await thread(run), identity = structuredClone(old.localTarget), history = await transcript(run); + await run.app.close(); Object.assign(run, await launchNative(seeded.home)); + await expect.poll(async () => Boolean(await snapshot(run))).toBe(true); + expect((await thread(run)).id).toBe(old.id); expect((await thread(run)).localTarget).toEqual(identity); + expect((await transcript(run)).map(item => item.id)).toEqual(history.map(item => item.id)); + expect((await snapshot(run)).children).toHaveLength(0); + await send(run, "An old native handle must refuse after restart."); await idle(run); + await expect(run.page.getByRole("alert").filter({ hasText: /native session.*gone|another Codex process/ })).toBeVisible(); + expect((await snapshot(run)).children).toHaveLength(0); expect(dispatches(seeded.repo, original)).toHaveLength(1); + expect(effects(seeded.repo, original)).toHaveLength(before.effectCount); + await choose(run); await send(run, commandPrompt(commandFor(fresh))); await approve(run, commandFor(fresh)); await idle(run); + expect(dispatches(seeded.repo, fresh)).toHaveLength(1); expect(effects(seeded.repo, fresh)).toHaveLength(1); + expect(fs.readFileSync(path.join(seeded.repo, fresh + "-effect.txt"), "utf8")).toBe(fresh + ":one\n"); + expect(dispatches(seeded.repo, original)).toHaveLength(1); expect(effects(seeded.repo, original)).toHaveLength(before.effectCount); + scriptsIntact(seeded); return run; +} + +test("native Electron Stop: started command has no late effect, restart never replays, explicit fresh send runs once", async () => { + const seeded = seed(); let run: Opened | undefined; let completed = false; let before: any; + try { + run = await launchNative(seeded.home); await connect(run); await send(run, commandPrompt(commandFor("stop"))); await approve(run, commandFor("stop")); + const started = await marker(seeded.repo, "stop"), clickedAt = Date.now(); await tid(run.page, "stop").click(); await idle(run); await pastDeadline(started); + expect(effects(seeded.repo, "stop")).toHaveLength(0); expect(fs.existsSync(path.join(seeded.repo, "stop-effect.txt"))).toBe(false); + const list = await transcript(run); + expect(list.some(item => item.kind === "tool" && /delayed-write.cjs stop/.test(item.exec?.command ?? item.title ?? "") && item.outcome === "interrupted")).toBe(true); + before = { started, clickedAt, effectCount: 0, native: await snapshot(run), transcript: list }; + run = await recover(run, seeded, "stop", before, "after-stop"); await listing(service); completed = true; + } finally { try { await save("stop", run, { completed, before, dispatches: dispatches(seeded.repo, "stop"), effects: effects(seeded.repo, "stop") }); } finally { await run?.app.close().catch(() => {}); } } +}); + +test("native Electron Codex loss: truthful outcome, no duplicate effect or stale-handle replay", async () => { + const seeded = seed(); let run: Opened | undefined; let completed = false; let before: any; + try { + run = await launchNative(seeded.home); await connect(run); await send(run, commandPrompt(commandFor("codex-loss"))); await approve(run, commandFor("codex-loss")); + const started = await marker(seeded.repo, "codex-loss"), owned = (await snapshot(run)).children.filter((p: any) => p.running); + expect(owned).toHaveLength(1); const injectedAt = Date.now(); + expect(await run.app.evaluate((_, pid) => (globalThis as any).__modexLocalNative.killCodex(pid), owned[0].pid)).toBe(true); + await idle(run); await pastDeadline(started); + const effectCount = effects(seeded.repo, "codex-loss").length; expect(effectCount).toBeLessThanOrEqual(1); expect(dispatches(seeded.repo, "codex-loss")).toHaveLength(1); + const list = await transcript(run); expect((await thread(run)).status).toBe("error"); + expect(list.some(item => item.kind === "notice" && item.failure)).toBe(true); + expect(list.some(item => item.kind === "tool" && /delayed-write.cjs codex-loss/.test(item.exec?.command ?? item.title ?? "") && item.outcome === "unknown")).toBe(true); + before = { started, injectedAt, killedPid: owned[0].pid, effectCount, native: await snapshot(run), transcript: list }; + run = await recover(run, seeded, "codex-loss", before, "after-codex-loss"); await listing(service); completed = true; + } finally { try { await save("codex-loss", run, { completed, before, dispatches: dispatches(seeded.repo, "codex-loss"), effects: effects(seeded.repo, "codex-loss") }); } finally { await run?.app.close().catch(() => {}); } } +}); + +test("native Electron inference loss: owned server failure, restart and explicit recovery preserve action counts", async () => { + const seeded = seed(); let run: Opened | undefined; let completed = false; let before: any; + try { + run = await launchNative(seeded.home); await connect(run); await send(run, commandPrompt(commandFor("server-loss"))); await approve(run, commandFor("server-loss")); + const started = await marker(seeded.repo, "server-loss"), killedPid = service.child.pid, port = service.port, injectedAt = Date.now(); + await stopService(service); await idle(run); await pastDeadline(started); + const effectCount = effects(seeded.repo, "server-loss").length; expect(effectCount).toBeLessThanOrEqual(1); expect(dispatches(seeded.repo, "server-loss")).toHaveLength(1); + const list = await transcript(run); expect((await thread(run)).status).toBe("error"); expect(list.some(item => item.kind === "notice" && item.failure)).toBe(true); + const native = await snapshot(run); + expect(native.events.filter((e: any) => e.type === "rpc" && e.direction === "out" && e.method === "turn/start")).toHaveLength(1); + expect(native.events.filter((e: any) => e.type === "rpc" && e.direction === "in" && e.method === "error" && e.willRetry === true)).toHaveLength(1); + expect(native.events.filter((e: any) => e.type === "rpc" && e.direction === "out" && e.method === "turn/interrupt")).toHaveLength(1); + before = { started, injectedAt, killedPid, effectCount, native, transcript: list }; + service = await startService(port); + run = await recover(run, seeded, "server-loss", before, "after-server-loss"); await listing(service); completed = true; + } finally { try { await save("server-loss", run, { completed, before, dispatches: dispatches(seeded.repo, "server-loss"), effects: effects(seeded.repo, "server-loss") }); } finally { await run?.app.close().catch(() => {}); } } +}); diff --git a/apps/desktop/e2e/local-models.spec.ts b/apps/desktop/e2e/local-models.spec.ts new file mode 100644 index 00000000..0a9c75b7 --- /dev/null +++ b/apps/desktop/e2e/local-models.spec.ts @@ -0,0 +1,139 @@ +import { test, expect, type Page } from "@playwright/test"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { launch, seedHome, tid } from "./support.js"; + +// LM-4 (#381), LM Studio first: Settings → Local models and the model menu's local section, through the +// production preload, IPC, store and HTTP transport. Only the runtime is synthetic: an LM Studio-shaped +// server on loopback that records every request, so the spec can show that listing models never loads +// one, downloads one or asks it anything. +function lmStudio() { + const hits: string[] = []; + const server = http.createServer((req, res) => { + hits.push(`${req.method} ${req.url}`); + if (req.method !== "GET" || req.url !== "/api/v0/models") { res.writeHead(404); res.end(); return; } + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ data: [ + { id: "qwen/qwen3.8-27b", type: "llm", state: "not-loaded", max_context_length: 40960, capabilities: ["tool_use"] }, + { id: "text-embedding-nomic-embed-text-v1.5", type: "embeddings", state: "not-loaded" }, + ] })); + }); + return { + hits, + start: () => new Promise(resolve => server.listen(0, "127.0.0.1", () => resolve(`http://127.0.0.1:${(server.address() as AddressInfo).port}`))), + stop: () => { server.closeAllConnections(); server.close(); }, + }; +} + +/** A loopback port nothing listens on. */ +async function closedPort(): Promise { + const probe = http.createServer(); + await new Promise(resolve => probe.listen(0, "127.0.0.1", resolve)); + const port = (probe.address() as AddressInfo).port; + await new Promise(resolve => probe.close(() => resolve())); + return `http://127.0.0.1:${port}`; +} + +async function openLocalSettings(page: Page) { + await tid(page, "open-settings").click(); + await page.getByRole("button", { name: "Local models" }).click(); + await expect(tid(page, "settings-local-section")).toBeVisible(); +} + +test("Settings adds an LM Studio server, lists its models without loading one, and removes it", async () => { + const runtime = lmStudio(); + const url = await runtime.start(); + const { home } = seedHome(); + const { app, page } = await launch(home); + try { + await openLocalSettings(page); + // No server yet: the form opens with LM Studio's defaults. + await expect(tid(page, "local-runtime")).toHaveValue("lmstudio"); + await expect(tid(page, "local-label")).toHaveValue("LM Studio"); + await expect(tid(page, "local-url")).toHaveValue("http://127.0.0.1:1234"); + await expect(tid(page, "settings-local-section")).toContainText("Checking models reads metadata only"); + await tid(page, "local-url").fill(url); + await tid(page, "local-add").click(); + + const card = tid(page, "local-connection"); + await expect(card).toHaveCount(1); + await expect(card).toHaveAttribute("data-state", "ready"); + await expect(tid(card, "local-connection-status")).toHaveText("Answered · 1 coding candidate"); + await expect(tid(card, "local-model")).toHaveText(["qwen/qwen3.8-27b · not loaded · server advertises 40K context · server advertises tool use · coding capability unverified"]); + await expect(tid(card, "local-unusable")).toContainText("text-embedding-nomic-embed-text-v1.5 (embedding model)"); + // Listing is one metadata request: no load, chat, completion or download endpoint was touched. + expect(runtime.hits).toEqual(["GET /api/v0/models"]); + + // A server that is not running is shown as such, with what to do. + await tid(page, "local-add-open").click(); + await tid(page, "local-label").fill("LM Studio (stopped)"); + await tid(page, "local-url").fill(await closedPort()); + await tid(page, "local-add").click(); + const stopped = tid(page, "local-connection").filter({ hasText: "LM Studio (stopped)" }); + await expect(stopped).toHaveAttribute("data-state", "unavailable"); + await expect(tid(stopped, "local-connection-reason")).toContainText(/Start the server|did not answer/); + + // Refresh lists again; Remove asks first and then deletes the profile in main. + await tid(card.first(), "local-refresh").click(); + await expect.poll(() => runtime.hits.length).toBe(2); + page.once("dialog", dialog => void dialog.accept()); + await tid(stopped, "local-remove").click(); + await expect(tid(page, "local-connection")).toHaveCount(1); + const listed = await page.evaluate(() => window.modex!.invoke("local-models:command", { op: "list" })); + expect(listed.op === "list" && listed.connections.map(c => c.label)).toEqual(["LM Studio"]); + expect(runtime.hits.every(hit => hit === "GET /api/v0/models")).toBe(true); + } finally { + await app.close(); + runtime.stop(); + } +}); + +test("the model menu lists LM Studio's models by server and offers context windows within the advertised maximum", async () => { + const runtime = lmStudio(); + const url = await runtime.start(); + const { home } = seedHome(); + const { app, page } = await launch(home); + try { + await page.evaluate(u => window.modex!.invoke("local-models:command", { op: "create", input: { label: "LM Studio", runtime: "lmstudio", url: u } }), url); + const threadsBefore = (await page.evaluate(() => window.modex!.invoke("state:get", undefined))).threads.length; + await tid(page, "model-picker").click(); + const section = tid(page, "model-local-section"); + await expect(section).toBeVisible(); + const group = tid(section, "model-local-group"); + await expect(group).toContainText("LM Studio"); + await expect(group).toContainText("reachable"); + // Only the generative model is offered; the embedding model is not. + await expect(tid(group, "model-local-title")).toHaveText(["qwen/qwen3.8-27b"]); + await tid(group, "model-local-option").click(); + const context = tid(page, "model-local-context"); + // 40,960 advertised: 8K, 16K, 32K and the maximum itself, nothing larger. + await expect(context.getByRole("menuitemradio")).toHaveText(["8K", "16K", "32K", "40K"]); + + // A draft with text cannot become a local thread: nothing typed may have been meant for the cloud. + await page.keyboard.press("Escape"); + await tid(page, "composer-input").fill("hello"); + await tid(page, "model-picker").click(); + await expect(tid(page, "model-local-blocked")).toContainText("Choose a local model before typing"); + await expect(tid(page, "model-local-option")).toBeDisabled(); + await page.keyboard.press("Escape"); + await tid(page, "composer-input").fill(""); + + // This product candidate keeps execution unadmitted; selection must refuse and remove its empty thread. + await tid(page, "model-picker").click(); + await tid(page, "model-local-option").click(); + await tid(page, "model-local-context").getByRole("menuitemradio", { name: /32,768/ }).click(); + await expect(page.getByRole("alert").filter({ hasText: "Local execution is unavailable." })).toBeVisible(); + await expect.poll(async () => (await page.evaluate(() => window.modex!.invoke("state:get", undefined))).threads.length).toBe(threadsBefore); + expect((await page.evaluate(() => window.modex!.invoke("state:get", undefined))).threads.some(t => t.localTarget !== undefined)).toBe(false); + expect(runtime.hits.every(hit => hit === "GET /api/v0/models")).toBe(true); + + // Manage opens Settings on Local models. + await page.keyboard.press("Escape"); + await tid(page, "model-picker").click(); + await tid(page, "model-local-manage").click(); + await expect(tid(page, "settings-local-section")).toBeVisible(); + } finally { + await app.close(); + runtime.stop(); + } +}); diff --git a/apps/desktop/e2e/native-drafts.spec.ts b/apps/desktop/e2e/native-drafts.spec.ts index d80e293a..c040b343 100644 --- a/apps/desktop/e2e/native-drafts.spec.ts +++ b/apps/desktop/e2e/native-drafts.spec.ts @@ -173,3 +173,34 @@ test("retiring a project draft rejects a late native picker without copying its expect(fs.readdirSync(path.join(home, "attachments", "staging"))).toEqual([]); expect(fs.readFileSync(selected, "utf8")).toBe("selected source remains unchanged"); }); + +test("a refused legacy original can be copied to a new chat without sending or replacing newer typing", async () => { + await app.close(); + const stateFile = path.join(home, "app", "state.json"), state = JSON.parse(fs.readFileSync(stateFile, "utf8")); + const at = new Date().toISOString(), sourceId = "legacy-original-thread", id = "11111111-1111-4111-8111-111111111111"; + state.threads = [{ id: sourceId, projectId: "p1", title: "Legacy original", cwd: repo, backend: "mock", model: "mock", mode: "chat", plan: false, auto: false, status: "idle", createdAt: at, updatedAt: at }]; + fs.writeFileSync(stateFile, JSON.stringify(state)); + fs.writeFileSync(path.join(home, "app", "update-recovery.json"), JSON.stringify({ schema: 2, revision: 1, attempt: null, drafts: { threads: [{ threadId: sourceId, text: "newer typing stays here", attachments: [] }], projects: [], selectedThreadId: sourceId, draft: null } })); + const directory = path.join(home, "app", "pending-sends"); fs.mkdirSync(directory, { recursive: true }); + const rawText = " private legacy pending original "; + const pendingFile = path.join(directory, `${id}.json`); + fs.writeFileSync(pendingFile, JSON.stringify({ schema: 1, id, createdAt: at, source: { kind: "thread", threadId: sourceId }, owner: { kind: "thread", threadId: sourceId }, rawText, + configuration: { worktree: false, settings: { backend: "mock", model: "mock", mode: "chat", plan: false, auto: false } }, files: [], creation: null, target: null })); + const bytes = fs.readFileSync(pendingFile); + ({ app, page } = await launch(home)); + await expect(tid(page, "composer-input")).toHaveValue("newer typing stays here"); + await tid(page, "pending-message-retry").click(); + await expect(page.getByText(/destination changed or is unverified/)).toBeVisible(); + await tid(page, "pending-message-copy").click(); + await expect(tid(page, "composer-input")).toHaveValue(rawText); + const saved = await page.evaluate(() => window.modex!.invoke("state:get", undefined)); + const fresh = saved.threads.find(thread => thread.id !== "legacy-original-thread")!; + expect(fresh).toBeTruthy(); + expect(await page.evaluate(id => window.modex!.invoke("thread:items", { threadId: id }), fresh.id)).toHaveLength(0); + expect(fs.readFileSync(pendingFile)).toEqual(bytes); + await tid(page, "send").click(); + await expect.poll(async () => (await page.evaluate(id => window.modex!.invoke("thread:items", { threadId: id }), fresh.id)).filter(item => item.kind === "user").length).toBe(1); + expect(fs.readFileSync(pendingFile)).toEqual(bytes); + await page.locator(`[data-testid="thread-row"][data-thread-id="${sourceId}"] .thread-main`).click(); + await expect(tid(page, "composer-input")).toHaveValue("newer typing stays here"); +}); diff --git a/apps/desktop/e2e/settings-layout.spec.ts b/apps/desktop/e2e/settings-layout.spec.ts index 246fdaaf..ef7f174a 100644 --- a/apps/desktop/e2e/settings-layout.spec.ts +++ b/apps/desktop/e2e/settings-layout.spec.ts @@ -61,7 +61,7 @@ test("Settings sections and persistent actions stay reachable in a narrow, short // horizontally when needed, then Enter selects the section. const general = nav.getByRole("button", { name: "General" }); await general.focus(); - for (const label of ["Coding CLIs", "Auto routing", "Approval rules", "Advanced / demo"]) { + for (const label of ["Coding CLIs", "Local models", "Auto routing", "Approval rules", "Advanced / demo"]) { await page.keyboard.press("Tab"); const next = nav.getByRole("button", { name: label }); await expect(next).toBeFocused(); diff --git a/apps/desktop/e2e/store-desktop.spec.ts b/apps/desktop/e2e/store-desktop.spec.ts index 67b5d718..4e2da95c 100644 --- a/apps/desktop/e2e/store-desktop.spec.ts +++ b/apps/desktop/e2e/store-desktop.spec.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import https from "node:https"; import { appDir, createThread, seedHome, storeClientDir, tid, items } from "./support"; +import { closeWithDiagnostics } from "../test/support/electron-close.js"; async function startHost(home: string): Promise { const host = await electron.launch({ args: [appDir, "--desktop-host"], cwd: appDir, @@ -57,7 +58,7 @@ test("Store client uses real host commands, approvals and terminal; reconnect pr await expect.poll(() => page.evaluate(async (threadId) => (await (window as any).modex.invoke('terminal:open', { threadId, cols: 80, rows: 24 })).output, threadId)).toContain('store_transport_ok'); await page.evaluate(async ({ threadId, sessionId }) => (window as any).modex.invoke('terminal:close', { threadId, sessionId }), { threadId, sessionId: terminal.sessionId }); await tid(page, "composer-input").fill("Keep this unsent follow-up"); - await host.close(); + await closeWithDiagnostics(host, test.info().outputDir); await expect(page.getByText("Reconnecting automatically", { exact: true })).toBeVisible(); host = await startHost(home); await expect(page.getByText("Reconnecting automatically", { exact: true })).toBeHidden(); @@ -69,7 +70,7 @@ test("Store client uses real host commands, approvals and terminal; reconnect pr await expect(page.getByText(/Your Mac removed this desktop's access/)).toBeVisible(); expect(fs.existsSync(path.join(clientHome, "host-access.enc"))).toBe(false); } finally { - await client.close(); await host.close(); + await closeWithDiagnostics(client, test.info().outputDir); await closeWithDiagnostics(host, test.info().outputDir); fs.rmSync(home, { recursive: true, force: true }); fs.rmSync(repo, { recursive: true, force: true }); fs.rmSync(clientHome, { recursive: true, force: true }); } }); @@ -102,12 +103,12 @@ test("Store preview discovers this Mac, confirms access once and restores saved expect(fs.existsSync(path.join(clientHome, "host-access.enc"))).toBe(false); await host.evaluate(() => (globalThis as typeof globalThis & { approveHost: () => void }).approveHost()); await expect(tid(page, "draft-view")).toBeVisible(); - await client.close(); + await closeWithDiagnostics(client, test.info().outputDir); client = await startClient(); await expect(tid(await client.firstWindow(), "draft-view")).toBeVisible(); expect(await host.evaluate(() => (globalThis as typeof globalThis & { hostApprovals: number }).hostApprovals)).toBe(1); } finally { - await client.close(); await host.close(); + await closeWithDiagnostics(client, test.info().outputDir); await closeWithDiagnostics(host, test.info().outputDir); fs.rmSync(home, { recursive: true, force: true }); fs.rmSync(repo, { recursive: true, force: true }); fs.rmSync(clientHome, { recursive: true, force: true }); } }); @@ -144,7 +145,7 @@ test("Store connected account commands stay local and explain unavailable sign-i await expect(account).toContainText("Manage your Modex account in the full desktop app."); await expect(page.getByText("Unknown desktop command.", { exact: false })).toHaveCount(0); } finally { - await client.close(); await host.close(); + await closeWithDiagnostics(client, test.info().outputDir); await closeWithDiagnostics(host, test.info().outputDir); for (const dir of [home, repo, clientHome]) fs.rmSync(dir, { recursive: true, force: true }); } }); @@ -181,7 +182,7 @@ for (const overlay of ["settings", "companion", "menu", "browser-tools"] as cons await expect(preservedOverlay).toBeVisible(); const access = fs.readFileSync(path.join(clientHome, "host-access.enc"), "utf8"); const { port } = JSON.parse(access); - await host.close(); host = undefined; + await closeWithDiagnostics(host, test.info().outputDir); host = undefined; incompatible = https.createServer({ key: fs.readFileSync(path.join(home, "desktop-host/key.pem")), cert: fs.readFileSync(path.join(home, "desktop-host/cert.pem")) }, (_req, res) => { res.writeHead(409); res.end(); }); await new Promise(resolve => incompatible!.listen(port, "127.0.0.1", resolve)); const retry = page.getByRole("button", { name: "Retry saved connection", exact: true }); @@ -224,7 +225,7 @@ for (const overlay of ["settings", "companion", "menu", "browser-tools"] as cons await expect(tid(page, overlay === "browser-tools" ? "thread-view" : "draft-view")).toBeVisible(); expect(fs.readFileSync(path.join(clientHome, "host-access.enc"), "utf8")).toBe(access); } finally { - await client.close(); await host?.close(); + await closeWithDiagnostics(client, test.info().outputDir); if (host) await closeWithDiagnostics(host, test.info().outputDir); if (incompatible) { incompatible.closeAllConnections(); await new Promise(resolve => incompatible!.close(() => resolve())); } for (const dir of [home, repo, clientHome]) fs.rmSync(dir, { recursive: true, force: true }); } diff --git a/apps/desktop/e2e/support.ts b/apps/desktop/e2e/support.ts index b6e78866..b2f1d59f 100644 --- a/apps/desktop/e2e/support.ts +++ b/apps/desktop/e2e/support.ts @@ -64,7 +64,7 @@ export function seedHome(settings: Record = {}): { home: string return { home, repo }; } -/** Launches the built app against `home`. MODEX_E2E keeps the secret store on the test cipher: CI runners have no unlocked keychain. */ +/** Launches against `home`. Only unpackaged E2E uses synthetic storage; packaged artifacts require OS encryption. */ export async function launch(home: string): Promise<{ app: ElectronApplication; page: Page }> { // Release verification runs the same flows against the installed bundle, including its ASAR and native modules. const executablePath = process.env.MODEX_PACKAGED_APP; diff --git a/apps/desktop/e2e/transcript-measure.spec.ts b/apps/desktop/e2e/transcript-measure.spec.ts index 81d5bf46..63b86827 100644 --- a/apps/desktop/e2e/transcript-measure.spec.ts +++ b/apps/desktop/e2e/transcript-measure.spec.ts @@ -131,6 +131,26 @@ async function longTasks(page: Page): Promise<{ count: number; totalMs: number; }); } +/** Per-process observations, never summed: shared pages make a sum misleading. */ +async function processMemory(app: ElectronApplication) { + const observed = await app.evaluate(async ({ app, BrowserWindow }) => ({ + at: new Date().toISOString(), + units: { nativeMemory: "KiB", nodeMemory: "bytes" }, + main: { pid: process.pid, native: await process.getProcessMemoryInfo(), node: process.memoryUsage() }, + windowRendererPids: BrowserWindow.getAllWindows().map(w => w.webContents.getOSProcessId()), + processes: app.getAppMetrics().map(p => ({ pid: p.pid, creationTime: p.creationTime, type: p.type, name: p.name, memory: p.memory })), + })); + expect(observed.main.native.private, "native main private memory must be observed in KiB").toBeGreaterThan(0); + expect(observed.main.node.rss, "Node process counters must be observed in bytes").toBeGreaterThan(0); + expect(observed.windowRendererPids.length).toBeGreaterThan(0); + for (const pid of observed.windowRendererPids) { + const renderer = observed.processes.find(p => p.pid === pid); + expect(renderer, "every window renderer must have its own native process receipt").toBeDefined(); + expect(renderer!.memory.workingSetSize).toBeGreaterThan(0); + } + return observed; +} + let app: ElectronApplication | undefined; let home = "", repo = ""; test.afterEach(async () => { @@ -166,6 +186,8 @@ async function measureTranscript(testInfo: TestInfo, launchNumber: number): Prom return send(channel, ...args); }; }); + const memoryBefore = await processMemory(app); + const openMemory: Awaited>[] = []; const opens: number[] = []; const tasks: { count: number; totalMs: number; maxMs: number }[] = []; for (let run = 0; run < 5; run++) { @@ -195,11 +217,13 @@ async function measureTranscript(testInfo: TestInfo, launchNumber: number): Prom } }, { id: threadId, marker: `Answer ${TURNS - 1}.` })); tasks.push(await longTasks(page)); + openMemory.push(await processMemory(app)); } // The share of an open that is main reading the file and IPC: thread:items, called from the renderer. const loads: number[] = []; for (let run = 0; run < 3; run++) loads.push(await page.evaluate(async (id) => { const start = performance.now(); await window.modex!.invoke("thread:items", { threadId: id }); return performance.now() - start; }, threadId)); const measurement = { + processMemory: { before: memoryBefore, after: await processMemory(app), afterEachOpen: openMemory }, turns: TURNS, items: saved.length, savedBytes: Buffer.byteLength(JSON.stringify(saved)), viewport: await page.evaluate(() => ({ width: innerWidth, height: innerHeight, scale: devicePixelRatio })), firstOpenMs: Math.round(opens[0]!), repeatOpenMs: opens.slice(1).map(Math.round), repeatMedianMs: Math.round(median(opens.slice(1))), @@ -276,6 +300,7 @@ test("a 3 MiB command log streams to the window bounded, and the turn settles", await expect(tid(page, "composer-input")).toBeFocused(); await tid(page, "composer-input").fill("Print a long log"); await watchLongTasks(page); + const memoryBefore = await processMemory(app); const sentAt = Date.now(); await page.keyboard.press("Meta+Enter"); await expect(page.getByText("Streamed the log.")).toBeVisible({ timeout: 60_000 }); @@ -286,6 +311,7 @@ test("a 3 MiB command log streams to the window bounded, and the turn settles", return { bytes: m.bytes, events: m.events, largestEvent: m.largestEvent, largestOutput: m.largestOutput, streamMs: Math.round(m.idle - m.first), cpuMs: m.cpuMs }; }); results.logStream = { + processMemory: { before: memoryBefore, after: await processMemory(app) }, loggedBytes: CHUNK.length * CHUNKS, deltas: CHUNKS, deltaSpacingMs: 10, sentToWindow: { bytes: main.bytes, events: main.events, largestEventBytes: main.largestEvent, largestOutputBytes: main.largestOutput }, mainStreamMs: main.streamMs, mainCpuMs: main.cpuMs, sendToAnswerShownMs: shownMs, diff --git a/apps/desktop/scripts/local-model-network-policy.mjs b/apps/desktop/scripts/local-model-network-policy.mjs new file mode 100644 index 00000000..f9a00c5f --- /dev/null +++ b/apps/desktop/scripts/local-model-network-policy.mjs @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import path from "node:path"; +import net from "node:net"; +import { createHash, randomUUID } from "node:crypto"; +import { execFileSync } from "node:child_process"; + +// The complete canary tree inherits network denial; Codex uses its documented externalSandbox mode. +export const LOOPBACK_ONLY_POLICY = '(version 1)\n(allow default)\n(deny network*)\n(allow network-inbound (local ip "localhost:*"))\n(allow network-outbound (remote ip "localhost:*"))\n'; + +export function offlineSandboxPolicy(root, cache) { + for (const directory of [root, cache]) { + if (!path.isAbsolute(directory) || directory === path.parse(directory).root) throw new Error("A sandbox write root must be a bounded absolute directory."); + } + return LOOPBACK_ONLY_POLICY + `(deny file-write*)\n(allow file-write* (subpath ${JSON.stringify(root)}) (subpath ${JSON.stringify(cache)}) (literal "/dev/null"))\n`; +} + +/** Run once outside the sandbox, before any native/model work. No model is loaded or contacted. */ +export function prepareOfflineSandbox(report) { + if (fs.lstatSync(report).isSymbolicLink()) throw new Error("Sandbox evidence must be a real directory."); + const root = fs.realpathSync(report); + const cache = fs.realpathSync(execFileSync("/usr/bin/getconf", ["DARWIN_USER_CACHE_DIR"], { encoding: "utf8" }).trim()); + const policy = offlineSandboxPolicy(root, cache), profile = path.join(root, "loopback-only.sb"); + const temporary = path.join(root, "tmp"); fs.mkdirSync(temporary, { mode: 0o700 }); + const outsideProbe = path.join(path.dirname(root), `.${path.basename(root)}-outside-${randomUUID()}`); + // Positive control: this same account can write here before the host profile is installed. + fs.writeFileSync(outsideProbe, "ordinary write control\n", { flag: "wx", mode: 0o600 }); fs.unlinkSync(outsideProbe); + const boundaryId = "sha256:" + createHash("sha256").update(policy).digest("hex"); + fs.writeFileSync(profile, policy, { flag: "wx", mode: 0o600 }); + fs.writeFileSync(path.join(root, "sandbox.json"), JSON.stringify({ root, cache, outsideProbe, outsideWriteControl: "succeeded", boundaryId }, null, 2) + "\n", { flag: "wx", mode: 0o600 }); + return { profile, env: { TMPDIR: temporary }, boundaryId }; +} + +/** Verify the installed kernel boundary before telling Codex that sandboxing is already provided. */ +export async function verifyOfflineSandbox(report) { + const root = fs.realpathSync(report), metadata = JSON.parse(fs.readFileSync(path.join(root, "sandbox.json"), "utf8")); + const profile = fs.readFileSync(path.join(root, "loopback-only.sb"), "utf8"); + const boundaryId = "sha256:" + createHash("sha256").update(profile).digest("hex"); + if (metadata.root !== root || metadata.boundaryId !== boundaryId || profile !== offlineSandboxPolicy(root, metadata.cache) + || metadata.outsideWriteControl !== "succeeded" || path.dirname(metadata.outsideProbe) !== path.dirname(root) + || !path.basename(metadata.outsideProbe).startsWith(`.${path.basename(root)}-outside-`)) throw new Error("The installed sandbox boundary receipt is missing or changed."); + let writeProbe; + try { + fs.writeFileSync(metadata.outsideProbe, "must remain denied\n", { flag: "wx", mode: 0o600 }); + fs.unlinkSync(metadata.outsideProbe); writeProbe = "allowed"; + } catch (error) { writeProbe = error.code ?? error.message; } + if (!["EPERM", "EACCES"].includes(writeProbe)) throw new Error(`The host sandbox did not deny an outside write: ${writeProbe}`); + const externalProbe = await new Promise(resolve => { + const socket = net.connect({ host: "192.0.2.1", port: 443 }); + const settle = value => { socket.destroy(); resolve(value); }; + socket.once("connect", () => settle("connected")); socket.once("error", error => settle(error.code ?? error.message)); socket.setTimeout(2000, () => settle("timed-out")); + }); + if (!["EPERM", "EACCES"].includes(externalProbe)) throw new Error(`The host sandbox did not deny external TCP: ${externalProbe}`); + return { hostSandbox: { type: "externalSandbox", networkAccess: "restricted", boundaryId }, externalProbe, writeProbe }; +} diff --git a/apps/desktop/scripts/local-model-offline.mjs b/apps/desktop/scripts/local-model-offline.mjs new file mode 100644 index 00000000..3719c514 --- /dev/null +++ b/apps/desktop/scripts/local-model-offline.mjs @@ -0,0 +1,101 @@ +// Opt-in macOS qualification only. Starts its own Ollama on a fresh loopback port; +// never changes the person's server, downloads weights or edits global Codex settings. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import net from "node:net"; +import { spawn, spawnSync, execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { once } from "node:events"; +import { LOOPBACK_ONLY_POLICY, prepareOfflineSandbox, verifyOfflineSandbox } from "./local-model-network-policy.mjs"; + +const script = fileURLToPath(import.meta.url); +const appDir = path.dirname(path.dirname(script)); +const inside = process.argv[2] === "--inside"; +const model = process.argv[inside ? 3 : 2]; +const destination = process.argv[inside ? 4 : 3]; +if (process.platform !== "darwin" || !model || !destination || !path.isAbsolute(destination)) { + throw new Error("Usage on macOS: node scripts/local-model-offline.mjs "); +} +const report = path.resolve(destination); +fs.mkdirSync(report, { recursive: true, mode: 0o700 }); +const policy = LOOPBACK_ONLY_POLICY; +if (!inside) { + const evidence = fs.lstatSync(report); + if (!evidence.isDirectory() || evidence.isSymbolicLink() || fs.readdirSync(report).length !== 0) { + throw new Error("Choose a new empty evidence directory. Earlier receipts and linked destinations were preserved; no runtime was contacted."); + } + const listing = await fetch("http://127.0.0.1:11434/api/tags", { redirect: "error", signal: AbortSignal.timeout(5000) }).then(r => r.json()); + const installed = listing.models?.find(m => m.name === model); + if (typeof installed?.digest !== "string" || !installed.digest) throw new Error("The exact approved model must already be installed with a recorded digest. This canary never downloads one."); + fs.writeFileSync(path.join(report, "model.json"), JSON.stringify(installed, null, 2) + "\n"); + const sandbox = prepareOfflineSandbox(report); + const result = spawnSync("/usr/bin/sandbox-exec", ["-f", sandbox.profile, process.execPath, script, "--inside", model, report], { + cwd: appDir, env: { ...process.env, ...sandbox.env }, encoding: "utf8", timeout: 1_300_000, maxBuffer: 8 * 1024 * 1024, + }); + fs.writeFileSync(path.join(report, "launcher.log"), `${result.stdout ?? ""}${result.stderr ?? ""}`); + if (result.status !== 0) { + if (!fs.existsSync(path.join(report, "offline.json"))) fs.writeFileSync(path.join(report, "offline.json"), JSON.stringify({ status: "failed-before-canary", exitCode: result.status, signal: result.signal, error: result.error?.message }, null, 2) + "\n"); + process.exitCode = 1; + } +} else { + const receipt = { status: "running", model, policy, platform: `${os.release()} ${os.arch()}`, hardware: execFileSync("sysctl", ["-n", "machdep.cpu.brand_string"], { encoding: "utf8" }).trim(), + sha: execFileSync("git", ["rev-parse", "HEAD"], { cwd: appDir, encoding: "utf8" }).trim(), contextTokens: 65536 }; + let server, serverHome; + try { + Object.assign(receipt, await verifyOfflineSandbox(report)); + receipt.policy = fs.readFileSync(path.join(report, "loopback-only.sb"), "utf8"); + const approved = JSON.parse(fs.readFileSync(path.join(report, "model.json"), "utf8")); + if (approved.name !== model || typeof approved.digest !== "string" || !approved.digest) throw new Error("The approved model name and digest receipt is missing or invalid."); + receipt.approvedModelDigest = approved.digest; + receipt.observedModelDigests = {}; + const requireApprovedDigest = (listed, phase) => { + const digest = typeof listed?.digest === "string" ? listed.digest : null; + receipt.observedModelDigests[phase] = digest; + if (digest !== approved.digest) throw new Error(`Owned model digest ${phase} the native task does not match the approved digest.`); + }; + const reservation = net.createServer(); reservation.listen(0, "127.0.0.1"); await once(reservation, "listening"); + const port = reservation.address().port; await new Promise(resolve => reservation.close(resolve)); + receipt.url = `http://127.0.0.1:${port}`; + serverHome = fs.mkdtempSync(path.join(report, "tmp", "ollama-home-")); + const log = fs.openSync(path.join(report, "ollama.log"), "w", 0o600); + server = spawn("ollama", ["serve"], { env: { ...process.env, HOME: serverHome, OLLAMA_HOST: `127.0.0.1:${port}`, OLLAMA_MODELS: path.join(os.homedir(), ".ollama", "models"), OLLAMA_CONTEXT_LENGTH: "65536" }, stdio: ["ignore", log, log] }); + fs.closeSync(log); + const deadline = Date.now() + 20_000; + while (true) { + if (server.exitCode !== null) throw new Error(`Owned Ollama exited: ${server.exitCode}`); + let listed; + try { + const listing = await fetch(`${receipt.url}/api/tags`, { redirect: "error", signal: AbortSignal.timeout(1000) }).then(r => r.json()); + listed = listing.models?.find(m => m.name === model); + if (!listed) throw new Error("Owned server cannot list the approved model"); + } catch (error) { + if (Date.now() >= deadline) throw error; + await new Promise(resolve => setTimeout(resolve, 100)); + continue; + } + requireApprovedDigest(listed, "before"); + break; + } + receipt.ownedServerPid = server.pid; + const checked = spawnSync(process.execPath, ["--test", "--test-force-exit", "--test-name-pattern=AC1: a person", "dist/test/local-codex-native.test.js"], { + cwd: appDir, env: { ...process.env, MODEX_LM3_LIVE: "1", MODEX_LM3_EXTERNAL_SANDBOX: "1", MODEX_LM3_URL: `${receipt.url}/v1`, MODEX_LM3_MODEL: model, MODEX_LM3_CONTEXT: "65536", MODEX_LM3_REPORT: path.join(report, "native") }, + encoding: "utf8", timeout: 1_220_000, maxBuffer: 8 * 1024 * 1024, + }); + fs.writeFileSync(path.join(report, "native.log"), `${checked.stdout ?? ""}${checked.stderr ?? ""}`); + receipt.exitCode = checked.status; + const listing = await fetch(`${receipt.url}/api/tags`, { redirect: "error", signal: AbortSignal.timeout(1000) }).then(r => r.json()); + requireApprovedDigest(listing.models?.find(m => m.name === model), "after"); + receipt.status = checked.status === 0 ? "passed" : "failed"; + if (checked.status !== 0) process.exitCode = 1; + } catch (error) { receipt.status = "failed"; receipt.error = error.message; process.exitCode = 1; } + finally { + if (server && server.exitCode === null) { + const exited = once(server, "exit"); server.kill("SIGTERM"); + const force = setTimeout(() => server.kill("SIGKILL"), 5000); + await exited; clearTimeout(force); + } + if (serverHome) fs.rmSync(serverHome, { recursive: true, force: true }); + fs.writeFileSync(path.join(report, "offline.json"), JSON.stringify(receipt, null, 2) + "\n"); + } +} diff --git a/apps/desktop/scripts/local-model-qualify.mjs b/apps/desktop/scripts/local-model-qualify.mjs new file mode 100644 index 00000000..82765b67 --- /dev/null +++ b/apps/desktop/scripts/local-model-qualify.mjs @@ -0,0 +1,214 @@ +// Fixed, opt-in qualification from an interactive Terminal. No daemon management, +// model downloads, product admission, arbitrary commands or inference SDKs. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn, execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { once } from "node:events"; +import { prepareOfflineSandbox } from "./local-model-network-policy.mjs"; + +const script = fileURLToPath(import.meta.url); +const repository = path.resolve(path.dirname(script), "../../.."); + +function snapshot(repo) { + const git = (...args) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); + const dirty = git("status", "--porcelain", "--untracked-files=no"); + if (dirty) throw new Error("Commit or preserve tracked source changes before qualification."); + return { head: git("rev-parse", "HEAD"), tree: git("rev-parse", "HEAD^{tree}") }; +} + +function electronBinary(repo, dist) { + if (dist && !path.isAbsolute(dist)) throw new Error("Choose an absolute path to the already installed Electron distribution."); + // Resolve metadata without importing Electron: its loader can download missing binaries. + const require = createRequire(path.join(repo, "package.json")); + const moduleDir = path.dirname(require.resolve("electron")); + const relative = fs.readFileSync(path.join(moduleDir, "path.txt"), "utf8").trim(); + const binary = path.resolve(dist ?? path.join(moduleDir, "dist"), relative); + if (!relative || !fs.existsSync(binary)) throw new Error("The selected Electron binary must already be installed."); + return binary; +} + +function ordinaryEnvironment(electronDist) { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (/^(MODEX_|CODEX_|CLAUDE_|OPENAI_|ANTHROPIC_|OLLAMA_|ELECTRON_|PLAYWRIGHT_|NODE_OPTIONS$|NODE_V8_COVERAGE$)/.test(key)) delete env[key]; + } + if (electronDist) env.ELECTRON_OVERRIDE_DIST_PATH = electronDist; + return env; +} + +function validateReceipt(name, report, source, options) { + const read = relative => JSON.parse(fs.readFileSync(path.join(report, relative), "utf8")); + if (name === "electron-local") { + const { stats } = read("electron-local.json"); + if (!stats || stats.expected !== 4 || stats.skipped !== 0 || stats.unexpected !== 0 || stats.flaky !== 0) throw new Error("All four local Electron journeys must execute and pass without retries or skips."); + } + if (name === "native-offline") { + const offline = read("native-offline/offline.json"), workflow = read("native-offline/native/live-workflow.json"); + if (offline.status !== "passed" || offline.sha !== source.head || !["EPERM", "EACCES"].includes(offline.externalProbe) || !["EPERM", "EACCES"].includes(offline.writeProbe) || offline.approvedModelDigest !== options.digest || offline.observedModelDigests?.before !== options.digest || offline.observedModelDigests?.after !== options.digest) throw new Error("The native receipt must prove the enforced host boundary and the approved owned-server model digest before and after the task on this revision."); + if (offline.hostSandbox?.type !== "externalSandbox" || offline.hostSandbox.networkAccess !== "restricted" || !/^sha256:[a-f0-9]{64}$/.test(offline.hostSandbox.boundaryId) || workflow.hostSandbox?.type !== offline.hostSandbox.type || workflow.hostSandbox?.networkAccess !== offline.hostSandbox.networkAccess || workflow.hostSandbox?.boundaryId !== offline.hostSandbox.boundaryId || !["EPERM", "EACCES"].includes(workflow.boundaryChecks?.externalProbe) || !["EPERM", "EACCES"].includes(workflow.boundaryChecks?.writeProbe)) throw new Error("The native workflow must use the same verified external sandbox boundary."); + if (workflow.model !== options.model || workflow.contextTokens !== 65536 || !["read", "approved edit and test"].every(name => workflow.stages?.some(s => s.name === name && s.status === "completed")) || !workflow.approvals?.some(a => a.decision === "yes") || !["bytes", "mtimeMs", "tree"].every(k => workflow.configurationUnchanged?.[k] === true) || workflow.realConfiguration?.unchanged !== true) throw new Error("The native workflow must complete read, approved edit and test while preserving configuration."); + } +} + +async function runCommand(command, args, options) { + const stdout = fs.openSync(options.stdout, "wx", 0o600), stderr = fs.openSync(options.stderr, "wx", 0o600); + let child; + try { + child = spawn(command, args, { cwd: options.cwd, env: options.env, detached: process.platform !== "win32", stdio: ["ignore", stdout, stderr] }); + } finally { fs.closeSync(stdout); fs.closeSync(stderr); } + let timedOut = false, spawnError, force; + const stop = () => { + if (!child.pid || child.exitCode !== null || child.signalCode) return; + const kill = signal => { try { process.kill(process.platform === "win32" ? child.pid : -child.pid, signal); } catch { /* Already exited. */ } }; + kill("SIGTERM"); force = setTimeout(() => kill("SIGKILL"), 5000); + }; + const timeout = setTimeout(() => { timedOut = true; stop(); }, options.timeout); + const abort = () => stop(); options.signal?.addEventListener("abort", abort, { once: true }); + if (options.signal?.aborted) stop(); + child.once("error", error => { spawnError = error.message; }); + const [exitCode, signal] = await new Promise(resolve => child.once("close", (...args) => resolve(args))); + clearTimeout(timeout); clearTimeout(force); options.signal?.removeEventListener("abort", abort); + return { exitCode, signal, timedOut, ...(spawnError ? { error: spawnError } : {}) }; +} + +async function nativeConfiguration() { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "modex-local-preflight-")); + const codexHome = path.join(home, ".codex"); fs.mkdirSync(codexHome, { mode: 0o700 }); + const env = Object.fromEntries(["PATH", "TMPDIR", "LANG", "USER", "LOGNAME"].filter(k => process.env[k] !== undefined).map(k => [k, process.env[k]])); + Object.assign(env, { HOME: home, CODEX_HOME: codexHome, NO_PROXY: "*" }); + const child = spawn("codex", ["app-server"], { cwd: home, env, stdio: ["pipe", "pipe", "ignore"] }); + let buffer = ""; + const result = await new Promise(resolve => { + const responses = {}; + const finish = value => { clearTimeout(timer); resolve(value); }; + const timer = setTimeout(() => finish({ okay: false, error: "Codex configuration preflight timed out." }), 15000); + child.once("error", error => finish({ okay: false, error: error.message })); + child.once("exit", () => finish({ okay: false, error: "Codex exited before its configuration was verified." })); + child.stdout.on("data", bytes => { + buffer += bytes; + if (buffer.length > 4 * 1024 * 1024) return finish({ okay: false, error: "Codex preflight output exceeded its bound." }); + let end; + while ((end = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + let message; try { message = JSON.parse(line); } catch { continue; } + if (message.id === 1) { + if (message.error) return finish({ okay: false, error: message.error }); + child.stdin.write(JSON.stringify({ method: "initialized" }) + "\n"); + for (const [id, method, params] of [[2, "config/read", { includeLayers: true }], [3, "configRequirements/read", {}]]) child.stdin.write(JSON.stringify({ id, method, params }) + "\n"); + } + if (message.id === 2 || message.id === 3) { + responses[message.id] = { okay: !message.error && (message.id === 2 ? !!message.result?.config : !!message.result), ...(message.error ? { error: message.error } : {}) }; + if (responses[2] && responses[3]) finish({ okay: responses[2].okay && responses[3].okay, responses }); + } + } + }); + child.stdin.on("error", () => {}); + child.stdin.write(JSON.stringify({ id: 1, method: "initialize", params: { clientInfo: { name: "modex-terminal-qualification", version: "1" }, capabilities: { experimentalApi: true } } }) + "\n"); + }); + if (child.exitCode === null && !child.signalCode) { + const closed = once(child, "close"); child.stdin.end(); child.kill("SIGTERM"); + const force = setTimeout(() => child.kill("SIGKILL"), 3000); + await closed; clearTimeout(force); + } + fs.rmSync(home, { recursive: true, force: true }); + return result; +} + +export async function qualify(options, dependencies = {}) { + if (!options.evidence || !path.isAbsolute(options.evidence)) throw new Error("Choose an absolute evidence directory outside the worktree."); + const repo = fs.realpathSync(options.repo ?? repository), report = path.resolve(options.evidence); + let ancestor = path.dirname(report); + while (!fs.existsSync(ancestor)) ancestor = path.dirname(ancestor); + const physical = path.resolve(fs.realpathSync(ancestor), path.relative(ancestor, report)); + const relative = path.relative(repo, physical); + if (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)) throw new Error("Choose an absolute evidence directory outside the worktree."); + if (!options.live || !options.model || !/^[a-f0-9]{64}$/.test(options.digest ?? "")) throw new Error("Pass --live and the exact approved --model and --digest. No model is downloaded."); + let existing; try { existing = fs.lstatSync(report); } catch (error) { if (error.code !== "ENOENT") throw error; } + if (existing && (!existing.isDirectory() || existing.isSymbolicLink() || fs.readdirSync(report).length)) throw new Error("Choose a new empty evidence directory; earlier receipts are preserved."); + fs.mkdirSync(report, { recursive: true, mode: 0o700 }); + const result = { schema: 1, kind: Object.keys(dependencies).length ? "scripted-runner-test" : "foreground-qualification", status: "running", pid: process.pid, startedAt: new Date().toISOString(), model: options.model, approvedDigest: options.digest, stages: [], productAdmission: "unchanged; separate admission required", humanAcceptance: "unrun" }; + const write = () => fs.writeFileSync(path.join(report, "qualification.json"), JSON.stringify(result, null, 2) + "\n", { mode: 0o600 }); + const capture = dependencies.snapshot ?? (() => snapshot(repo)); + const run = dependencies.run ?? runCommand; + const assertSource = () => { const now = capture(); if (now.head !== result.source.head || now.tree !== result.source.tree) throw new Error("Source changed during qualification; remaining stages were not started."); }; + write(); + try { + result.source = capture(); + if (options.expectHead && options.expectHead !== result.source.head) throw new Error("This launcher names another source revision. Generate a fresh launcher before qualification."); + if ((dependencies.platform ?? process.platform) !== "darwin") throw new Error("Native qualification requires macOS."); + try { (dependencies.session ?? (() => os.userInfo()))(); } + catch (error) { throw new Error(`macOS user lookup failed: ${error.message}. Launch this runner from your normal macOS Terminal.`); } + const binary = (dependencies.electron ?? (() => electronBinary(repo, options.electronDist)))(); + const env = ordinaryEnvironment(options.electronDist); + result.host = { platform: process.platform, release: os.release(), architecture: os.arch(), cpu: os.cpus()[0]?.model, memoryBytes: os.totalmem(), node: process.version, umask: process.umask().toString(8).padStart(3, "0"), requestedContextTokens: 65536, loadedContextTokens: "unverified" }; + result.electron = { binary, sha256: dependencies.binaryHash ?? (await import("node:crypto")).createHash("sha256").update(fs.readFileSync(binary)).digest("hex") }; + const stage = async (name, command, args, cwd, timeout, extraEnv = {}) => { + assertSource(); + if (options.signal?.aborted) throw new Error("Qualification was interrupted; no further stages were started."); + const record = { name, command: [command, ...args], startedAt: new Date().toISOString(), stdout: path.join(report, name + ".stdout.log"), stderr: path.join(report, name + ".stderr.log") }; + result.stages.push(record); write(); + console.log(`Running ${name}…`); + const outcome = await run(command, args, { cwd, env: { ...env, ...extraEnv }, timeout, signal: options.signal, stdout: record.stdout, stderr: record.stderr }); + if (outcome.stdout !== undefined) fs.writeFileSync(record.stdout, outcome.stdout, { mode: 0o600 }); + if (outcome.stderr !== undefined) fs.writeFileSync(record.stderr, outcome.stderr, { mode: 0o600 }); + const { stdout, stderr, ...facts } = outcome; + Object.assign(record, facts, { finishedAt: new Date().toISOString() }); + record.passed = outcome.exitCode === 0 && !outcome.signal && !outcome.error && !outcome.timedOut; + if (record.passed) { + try { validateReceipt(name, report, result.source, options); } + catch (error) { record.passed = false; record.receiptError = error.message; } + } + write(); assertSource(); return record.passed; + }; + if (!await stage("electron-preflight", binary, ["--version"], repo, 15000)) throw new Error("Electron cannot start in this session. Launch this runner from your normal macOS Terminal."); + result.nativeConfiguration = await (dependencies.configuration ?? nativeConfiguration)(); write(); + if (!result.nativeConfiguration.okay) throw new Error("Codex cannot read managed configuration in this session. Launch this runner from your normal macOS Terminal."); + for (const runtime of ["codex", "ollama", "pnpm"]) { + if (!await stage(runtime + "-version", runtime, ["--version"], repo, 15000)) throw new Error(`The installed ${runtime} version could not be recorded.`); + } + if (!await stage("build", "pnpm", ["build"], repo, 900000)) throw new Error("The build failed; dependent acceptance stages were not started."); + await stage("typecheck", "pnpm", ["typecheck"], repo, 900000); + await stage("unit", "pnpm", ["test"], repo, 1800000); + const app = path.join(repo, "apps/desktop"); + await stage("electron-local", "pnpm", ["exec", "playwright", "test", "e2e/local-model-acceptance.spec.ts", "e2e/local-models.spec.ts", "--workers=1", "--max-failures=1", "--output", path.join(report, "electron-local"), "--reporter=list,json"], app, 900000, { PLAYWRIGHT_JSON_OUTPUT_NAME: path.join(report, "electron-local.json") }); + // Retain earlier artifacts before Playwright's canonical command clears its output. + for (const name of ["desktop", "site"]) { + const outputs = path.join(repo, "apps", name, "test-results"); + if (fs.existsSync(outputs)) fs.renameSync(outputs, path.join(report, name + "-prior-test-results")); + } + await stage("full-e2e", "pnpm", ["test:e2e"], repo, 5400000); + for (const name of ["desktop", "site"]) { + const outputs = path.join(repo, "apps", name, "test-results"); + if (fs.existsSync(outputs)) fs.cpSync(outputs, path.join(report, name + "-test-results"), { recursive: true, errorOnExist: true }); + } + const native = path.join(report, "native-offline"); fs.mkdirSync(native, { mode: 0o700 }); + fs.writeFileSync(path.join(native, "model.json"), JSON.stringify({ name: options.model, digest: options.digest, evidenceKind: "approved expected identity; owned-server observations recorded separately" }, null, 2) + "\n", { mode: 0o600 }); + const sandbox = (dependencies.prepareSandbox ?? prepareOfflineSandbox)(native); + await stage("native-offline", "/usr/bin/sandbox-exec", ["-f", sandbox.profile, process.execPath, path.join(app, "scripts/local-model-offline.mjs"), "--inside", options.model, native], app, 1300000, sandbox.env); + assertSource(); result.status = result.stages.every(s => s.passed) ? "passed" : "failed"; + } catch (error) { result.status = "failed"; result.error = error.message; } + finally { result.finishedAt = new Date().toISOString(); write(); } + console.log(`${result.status}: ${path.join(report, "qualification.json")}`); + return result; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === script) { + const usage = "Usage: node scripts/local-model-qualify.mjs --live --model --digest --evidence [--electron-dist ] [--expect-head ]"; + try { + const values = {}, args = process.argv.slice(2); + if (args.length === 1 && args[0] === "--help") { console.log(usage); process.exit(0); } + for (let i = 0; i < args.length; i++) { + if (args[i] === "--live") { values.live = true; continue; } + const key = { "--evidence": "evidence", "--model": "model", "--digest": "digest", "--electron-dist": "electronDist", "--expect-head": "expectHead" }[args[i]]; + if (!key || !args[i + 1] || args[i + 1].startsWith("--")) throw new Error(usage); + values[key] = args[++i]; + } + const controller = new AbortController(); + for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => controller.abort()); + const result = await qualify({ ...values, signal: controller.signal }); process.exitCode = result.status === "passed" ? 0 : 1; + } + catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/apps/desktop/src/main/commands.ts b/apps/desktop/src/main/commands.ts index fc189683..bbc5c9c9 100644 --- a/apps/desktop/src/main/commands.ts +++ b/apps/desktop/src/main/commands.ts @@ -101,9 +101,9 @@ export function registerCommands(handle: RegisterCommand, { handle("task:save-note", ({ threadId, turnId }) => runner.saveTaskNote(threadId, turnId)); handle("task:note", ({ pageId }) => runner.taskNote(pageId)); handle("thread:followup", ({ threadId }) => runner.followUp(threadId)); - handle("thread:send", ({ threadId, text, attachments }) => startTurn(threadId, runner.send(threadId, text, attachments))); + handle("thread:send", ({ threadId, text, attachments }) => startTurn(threadId, runner.send(threadId, text, attachments, "browser"))); registerAttachmentCommands(handle, runner.attachments, { bytesOnly: true, reveal: revealAttachment ?? (() => { throw new Error("Attachment reveal is unavailable."); }) }); - handle("thread:retry", ({ threadId }) => startTurn(threadId, runner.retry(threadId))); + handle("thread:retry", ({ threadId }) => startTurn(threadId, runner.retry(threadId, "browser"))); handle("thread:stop", ({ threadId }) => runner.stop(threadId)); handle("thread:answer", ({ threadId, itemId, answer }) => runner.answer(threadId, itemId, answer)); handle("thread:answer-question", ({ threadId, itemId, answers }) => runner.answerQuestion(threadId, itemId, answers)); diff --git a/apps/desktop/src/main/engine/backends/codex.ts b/apps/desktop/src/main/engine/backends/codex.ts index c8234855..ba3d7c02 100644 --- a/apps/desktop/src/main/engine/backends/codex.ts +++ b/apps/desktop/src/main/engine/backends/codex.ts @@ -70,7 +70,7 @@ export class CodexBackend implements Backend { constructor(private readonly executable: CliExecutable = process.env.MODEX_CODEX_BIN ?? "codex", private readonly spawnImpl = cliSpawn, private readonly configuration?: { args: string[]; env: NodeJS.ProcessEnv; redact: (text: string) => string; /** `admit` sees the child's effective configuration and requirements before every thread open, and throws to refuse it (#380). */ - local?: { model: string; provider: string; admit?: (reads: { config: unknown; requirements: unknown }) => void } }) {} + local?: { model: string; provider: string; externalSandbox?: true; admit?: (reads: { config: unknown; requirements: unknown }) => void } }) {} private ensure(): Promise { if (this.ready && this.child && this.child.exitCode === null) return this.ready; @@ -90,7 +90,11 @@ export class CodexBackend implements Backend { this.threadModels.clear(); const lines = new LineBuffer(); let stderr = ""; - child.stderr?.on("data", (d: Buffer) => (stderr = (stderr + d.toString()).slice(-16_384))); + child.stderr?.on("data", (d: Buffer) => { + const output = stderr + d.toString(); + // Redact across chunks before the bound can discard a token's beginning. + stderr = (this.configuration?.redact(output) ?? output).slice(-16_384); + }); child.stdout?.on("data", (d: Buffer) => { if (this.child === child) lines.push(d, (line) => this.dispatch(line)); }); child.on("error", (err) => { const error = new CodexError(`${bin}: ${err.message}. Is the Codex CLI installed and on PATH?`, { bin, spawnError: err.message, errno: (err as NodeJS.ErrnoException).code, pid: child.pid }); @@ -307,6 +311,9 @@ export class CodexBackend implements Backend { return failed(err, { stage: "start app-server" }); } const pol = CodexBackend.policy(opts); + // The trusted host already confines this process tree. Preserve native approval policy, + // but avoid a second sandbox_apply inside the parent's macOS sandbox. + if (this.configuration?.local?.externalSandbox) pol.sandboxPolicy = { type: "externalSandbox", networkAccess: "restricted" }; // Only these scoped adapter tools are preapproved. Main enforces project/turn permissions. const config = { ...(this.configuration?.local ? {} : THREAD_CONFIG), ...(opts.images ? { "mcp_servers.modex_images": { url: opts.images.url, enabled_tools: ["register"], tools: { register: { approval_mode: "approve" } } } } : {}), ...(opts.knowledge ? { "mcp_servers.modex_knowledge": { url: opts.knowledge.url, enabled_tools: [...KNOWLEDGE_TOOLS], @@ -322,7 +329,13 @@ export class CodexBackend implements Backend { if (local?.admit) { try { const reads = await duringSetup(Promise.all([this.request("config/read", { includeLayers: true, cwd: opts.cwd }), this.request("configRequirements/read", {})]) - .catch((err: unknown) => { throw new CodexError(`Codex did not report its effective configuration (${(err as Error).message}). Local execution needs codex-cli 0.164.0 or later.`, detailOf(err)); }), signal); + .catch((err: unknown) => { + const detail = detailOf(err), reason = (err as Error).message; + const recovery = /failed to synchronize managed preferences/i.test(reason) + ? " Start Modex from a signed-in macOS desktop session and retry." + : detail.rpcCode === -32601 ? " Local execution needs codex-cli 0.164.0 or later." : ""; + throw new CodexError(`Codex did not report its effective configuration (${reason}).${recovery}`, detail); + }), signal); local.admit({ config: reads[0], requirements: reads[1] }); } catch (err) { return failed(err, { stage: "verify configuration" }); @@ -396,6 +409,9 @@ export class CodexBackend implements Backend { let stopping = false; // Set when Codex reroutes a pinned local model: the turn is interrupted and reported failed, never continued (#380). let rerouted: { message: string; from: string; to: string } | null = null; + // Some native reconnect paths still retry with provider retry counts set to zero. + // Local coding must stop on that notification rather than continue after possible effects. + let retryRefused: { message: string; nativeError: Record } | null = null; const child = this.child; const detail = (extra: Record): Record => ({ codexThreadId: tid, turnId, userAgent: this.userAgent, pid: child?.pid, ...(retried.length ? { retriedErrors: retried } : {}), ...(lastError ? { errorNotification: lastError } : {}), ...extra, @@ -480,12 +496,12 @@ export class CodexBackend implements Backend { if (item.type === "agentMessage") answered = item.phase === "final_answer" && "answer"; else if (item.type === "plan") answered = "plan"; } - if (answered && !signal.aborted && !rerouted && msg.method !== "turn/completed") { + if (answered && !signal.aborted && !rerouted && !retryRefused && msg.method !== "turn/completed") { quiet = setTimeout(() => { // Codex can go quiet after its final answer or proposed plan without ending the turn (live in // codex-cli 0.163.0, default and plan mode). Nothing else is open, so // Modex ends it and says so; the interrupt lets Codex close its side. - if (done || signal.aborted || rerouted || commands.size || [...this.openRequests.values()].some((r) => r.thread === tid)) return; + if (done || signal.aborted || rerouted || retryRefused || commands.size || [...this.openRequests.values()].some((r) => r.thread === tid)) return; sink.notice("warn", `Codex ${answered === "plan" ? "proposed its plan" : "gave its final answer"} but did not report the turn finished. Modex ended the turn after ${Math.round(this.quietAfterFinalMs / 60_000) || 1} minute${this.quietAfterFinalMs > 60_000 ? "s" : ""} without activity.`); interrupt(); finish({ status: "completed", detail: detail({ endedBy: "modex", quietAfterFinalMs: this.quietAfterFinalMs }) }); @@ -565,7 +581,9 @@ export class CodexBackend implements Backend { const delta = String(p.delta ?? ""); sink.output?.(native(p.itemId), delta); t.bytes += byteLength(delta); - t.output = boundOutput(t.output + delta).text; + const output = t.output + delta; + // A token may span deltas. Redact it before the preview cuts off its beginning. + t.output = boundOutput(this.configuration?.redact(output) ?? output).text; sink.observe({ type: "tool.update", native: native(p.itemId), progress: { output: t.output, ...(t.bytes > byteLength(t.output) ? { outputBytes: t.bytes, outputTruncated: true } : {}) } }); } break; @@ -630,15 +648,23 @@ export class CodexBackend implements Backend { const e = p.error as Record | undefined; const message = typeof e?.message === "string" ? e.message : null; if (!message) break; - if (p.willRetry) retried.push(message); + if (p.willRetry === true && this.configuration?.local) { + if (!retryRefused) { + retryRefused = { message: "The local inference connection failed. Automatic retry is disabled; earlier commands may already have run. Select the local target again before continuing.", nativeError: { ...(e ?? {}), at: new Date().toISOString() } }; + sink.notice("error", retryRefused.message); + } + interrupt(); + } else if (p.willRetry) retried.push(message); else lastError = { ...(e ?? {}), at: new Date().toISOString() }; break; } case "turn/completed": { const turn = p.turn as { status: string; error?: { message?: string } | null }; if (turn.status === "failed") finish({ status: "failed", error: turn.error?.message ?? (lastError?.message as string | undefined) ?? "turn failed", detail: detail({ turnError: turn.error ?? undefined }) }); - else if (rerouted) { - const failure: TurnResult = { status: "failed", error: rerouted.message, detail: detail({ rerouted: { from: rerouted.from, to: rerouted.to } }) }; + else if (rerouted || retryRefused) { + const failure: TurnResult = rerouted + ? { status: "failed", error: rerouted.message, detail: detail({ rerouted: { from: rerouted.from, to: rerouted.to } }) } + : { status: "failed", error: retryRefused!.message, detail: detail({ retryRefused: retryRefused!.nativeError }) }; if (commands.size) { stopping = true; clearTimeout(abortTimer); diff --git a/apps/desktop/src/main/engine/execution-binding.ts b/apps/desktop/src/main/engine/execution-binding.ts new file mode 100644 index 00000000..d90f857e --- /dev/null +++ b/apps/desktop/src/main/engine/execution-binding.ts @@ -0,0 +1,11 @@ +import type { Thread } from "../../shared/types.js"; +import type { PendingExecutionBinding } from "../../shared/native-drafts.js"; +import { threadTarget } from "../../shared/local-models.js"; + +/** Canonical main-owned destination snapshot; no credentials or resume handles. */ +export function executionBinding(thread: Pick, defaultAccount: string | null): PendingExecutionBinding { + const local = thread.localTarget !== undefined ? threadTarget(thread) : null; + return { schema: 1, backend: thread.backend, model: thread.model, + account: local || thread.backend !== "codex" ? null : thread.codexAccount ?? (thread.sessionHandle ? "cli" : defaultAccount), + localTarget: local?.kind === "local" ? local.target : null, localContextTokens: local ? thread.localContextTokens ?? null : null }; +} diff --git a/apps/desktop/src/main/engine/local-models/execution.ts b/apps/desktop/src/main/engine/local-models/execution.ts index f0df54f9..f82fb054 100644 --- a/apps/desktop/src/main/engine/local-models/execution.ts +++ b/apps/desktop/src/main/engine/local-models/execution.ts @@ -6,8 +6,9 @@ import type { Backend, TurnOptions, TurnResult, TurnSink } from "../backends/typ import type { CliExecutable } from "../cli-path.js"; import { modelKey, threadTarget, type LocalModelRef, type LocalThreadTarget } from "../../../shared/local-models.js"; import type { LocalConnectionStore } from "./connections.js"; -import { admitLocalConfiguration, type LocalConfigurationProvenance } from "./run-policy.js"; +import { admitLocalConfiguration, type LocalConfigurationProvenance, type LocalHostSandbox } from "./run-policy.js"; import { OLLAMA_CLOUD_NAME, type AdvertisedContext } from "./discovery.js"; +import { redactingSink, TOKEN_MASK } from "./redacting-sink.js"; /** * Runs a thread's turns on a local model through the Codex CLI (#379), the same `codex app-server` @@ -54,16 +55,37 @@ function refuseProjectConfiguration(cwd: string): void { } } +/** + * Codex words every HTTP 500 as a cloud service under load ("We're currently experiencing high demand"). From a + * loopback server that is not what happened: it is the person's own server failing, often out of memory. Codex's + * text stays in the detail; the turn's error says what is known (#380 AC4). + */ +function truthful(result: TurnResult): TurnResult { + if (result.status !== "failed") return result; + const detail = result.detail as { turnError?: { codexErrorInfo?: unknown }; errorNotification?: { codexErrorInfo?: unknown } } | undefined; + // A turn that completes with no error of its own reports the `error` notification's text instead (backends/codex.ts). + if ((detail?.turnError?.codexErrorInfo ?? detail?.errorNotification?.codexErrorInfo) !== "internalServerError") return result; + return { ...result, error: "The local server returned an internal error (HTTP 500). Check its log: it may have run out of memory or crashed while loading the model. Modex didn't retry the turn or switch models." }; +} + export class LocalExecution { /** The child owning each thread's native conversation, with the key it was started for. */ private readonly children = new Map(); private readonly services = new Map void> }>(); private disposed = false; + private readonly hostSandbox?: LocalHostSandbox; /** `advertisedContext` reads main's record of the runtime's own listing (LocalModelDiscovery); without it, nothing was checked. */ constructor(private readonly home: string, private readonly connections: LocalConnectionStore, private readonly executable: CliExecutable = "codex", private readonly spawnImpl = cliSpawn, - private readonly advertisedContext: (model: LocalModelRef) => AdvertisedContext | undefined = () => undefined) {} + private readonly advertisedContext: (model: LocalModelRef) => AdvertisedContext | undefined = () => undefined, + hostSandbox?: LocalHostSandbox) { + // Only the trusted host can provide this. Normal product wiring has no external-boundary option. + if (hostSandbox && (hostSandbox.type !== "externalSandbox" || hostSandbox.networkAccess !== "restricted" || !/^sha256:[a-f0-9]{64}$/.test(hostSandbox.boundaryId))) { + throw new Error("A host sandbox requires a restricted, identified external boundary."); + } + this.hostSandbox = hostSandbox ? Object.freeze({ ...hostSandbox }) : undefined; + } /** The thread's target, checked against the live profile: exactly its revision, with its token readable. */ validate(thread: LocalExecutionThread): LocalThreadTarget { @@ -125,12 +147,13 @@ export class LocalExecution { if (opts.model !== target.model.id || opts.cwd !== thread.cwd || opts.account) throw new Error("The local target changed. Select it again to start a fresh conversation."); if (opts.plan || opts.images || opts.pages || opts.knowledge || opts.attachments?.some(file => file.kind === "image")) throw new Error("Plan, images and Modex's note and knowledge tools are not qualified for local models yet."); const connection = this.connections.resolve(target.model.connection)!; + sink = redactingSink(sink, connection.token); // Every loopback alias of a port is the same server; separate servers sharing a port also wait on each other. release = await this.acquire(`loopback:${connection.endpoint.port}`, signal); this.validate(thread); // The profile may have changed while this turn waited. if (this.disposed) throw new Error("Local execution has stopped. Select the local target again after Modex restarts."); refuseProjectConfiguration(opts.cwd); - const key = JSON.stringify([modelKey(target.model), thread.localContextTokens, path.resolve(opts.cwd)]); + const key = JSON.stringify([modelKey(target.model), thread.localContextTokens, path.resolve(opts.cwd), this.hostSandbox?.boundaryId ?? null]); let owned = this.children.get(thread.id); if (opts.resume && (!owned || owned.key !== key)) throw new Error("This conversation's native session is gone: the target changed or Modex restarted. Select the local target again to start a fresh conversation."); if (owned && owned.key !== key) { await this.forget(thread.id); owned = undefined; } @@ -139,14 +162,14 @@ export class LocalExecution { const child: { key: string; backend: CodexBackend; home: string; provenance?: LocalConfigurationProvenance } = { key, home, backend: undefined as unknown as CodexBackend }; const advertised = this.advertisedContext(target.model); child.backend = this.start(home, connection.endpoint.origin, connection.token, target.model.id, thread.localContextTokens!, provenance => { - child.provenance = { ...provenance, context: { configured: thread.localContextTokens!, source: "person", effective: thread.localContextTokens!, + child.provenance = { ...provenance, ...(this.hostSandbox ? { hostSandbox: this.hostSandbox } : {}), context: { configured: thread.localContextTokens!, source: "person", effective: thread.localContextTokens!, advertised: advertised ? { ...advertised } : "not-checked", loaded: "unknown" } }; }); owned = child; this.children.set(thread.id, owned); } // Effort, fast mode and service tiers are cloud-model settings with no local qualification: none is sent. - return await owned.backend.runTurn(text, { ...opts, effort: undefined, fast: false }, sink, signal); + return truthful(await owned.backend.runTurn(text, { ...opts, effort: undefined, fast: false }, sink, signal)); } catch (error) { return signal.aborted ? { status: "interrupted" } : { status: "failed", error: (error as Error).message }; } finally { release?.(); } @@ -190,8 +213,8 @@ export class LocalExecution { ]; return new CodexBackend(this.executable, this.spawnImpl, { args: settings.flatMap(setting => ["-c", setting]), env, - redact: text => token ? text.split(token).join("[local token]") : text, - local: { model, provider: PROVIDER_ID, + redact: text => token ? text.split(token).join(TOKEN_MASK) : text, + local: { model, provider: PROVIDER_ID, ...(this.hostSandbox ? { externalSandbox: true } : {}), // Before each thread open the child's effective configuration must be exactly this, from these flags alone (#380). admit: reads => admitted(admitLocalConfiguration(reads.config as Parameters[0], reads.requirements as Parameters[1], { provider: PROVIDER_ID, origin, contextTokens, tokenEnv: token ? TOKEN_ENV : null })) }, }); diff --git a/apps/desktop/src/main/engine/local-models/redacting-sink.ts b/apps/desktop/src/main/engine/local-models/redacting-sink.ts new file mode 100644 index 00000000..26fd2392 --- /dev/null +++ b/apps/desktop/src/main/engine/local-models/redacting-sink.ts @@ -0,0 +1,80 @@ +import type { TurnSink } from "../backends/types.js"; + +export const TOKEN_MASK = "[local token]"; + +/** How much of the end of `text` could still grow into `token`. */ +function heldBack(text: string, token: string): number { + for (let length = Math.min(token.length - 1, text.length); length > 0; length--) { + if (text.endsWith(token.slice(0, length))) return length; + } + return 0; +} + +/** + * CodexBackend redacts each line it reads, but a streamed token can cross two lines. This holds back the end of each + * stream while it could still become the token, so no renderer event or tool log ever holds it or part of its start. + * Whatever is held when an item ends unexpectedly is dropped: at most a piece of text that looked like the token's start. + */ +export function redactingSink(sink: TurnSink, token: string | null): TurnSink { + if (!token) return sink; + const streams = new Map(); + const push = (key: string, delta: string): string => { + const text = ((streams.get(key) ?? "") + delta).split(token).join(TOKEN_MASK); + const held = heldBack(text, token); + streams.set(key, text.slice(text.length - held)); + return text.slice(0, text.length - held); + }; + const end = (key: string): string => { const rest = streams.get(key) ?? ""; streams.delete(key); return rest; }; + return { + ...sink, + ...(sink.output ? { output: (native, chunk) => { const text = push(`output:${native.item}`, chunk); if (text) sink.output!(native, text); } } : {}), + observe(observation) { + switch (observation.type) { + case "message.delta": { + const text = push(`message:${observation.native.item}`, observation.text); + if (text) sink.observe({ ...observation, text }); + return; + } + case "reasoning.delta": { + const text = push(`reasoning:${observation.native.item}:${observation.representation}:${observation.index}`, observation.text); + if (text) sink.observe({ ...observation, text }); + return; + } + case "message.done": { + const rest = end(`message:${observation.native.item}`); + if (rest) sink.observe({ type: "message.delta", native: observation.native, text: rest }); + break; + } + case "reasoning.done": + for (const key of [...streams.keys()]) { + if (!key.startsWith(`reasoning:${observation.native.item}:`)) continue; + const [, , representation, index] = key.split(":") as [string, string, "summary" | "provider-text", string]; + const rest = end(key); + if (rest) sink.observe({ type: "reasoning.delta", native: observation.native, representation, index: Number(index), text: rest }); + } + break; + case "tool.update": { + // The whole output so far, rebuilt from deltas after CodexBackend's per-line redaction. + const output = observation.progress.output; + if (output !== undefined) { + const text = output.split(token).join(TOKEN_MASK); + sink.observe({ ...observation, progress: { ...observation.progress, output: text.slice(0, text.length - heldBack(text, token)) } }); + return; + } + break; + } + case "tool.done": { + const rest = end(`output:${observation.native.item}`); + if (rest) sink.output?.(observation.native, rest); + const output = observation.progress?.output; + if (output !== undefined) { + sink.observe({ ...observation, progress: { ...observation.progress, output: output.split(token).join(TOKEN_MASK) } }); + return; + } + break; + } + } + sink.observe(observation); + }, + }; +} diff --git a/apps/desktop/src/main/engine/local-models/run-policy.ts b/apps/desktop/src/main/engine/local-models/run-policy.ts index 0a774be5..a4ce3b2b 100644 --- a/apps/desktop/src/main/engine/local-models/run-policy.ts +++ b/apps/desktop/src/main/engine/local-models/run-policy.ts @@ -33,12 +33,21 @@ export interface RequirementsRead { requirements: unknown } /** What Modex passed as session flags for this child (execution.ts), to be found again in the effective configuration. */ export interface ExpectedLocalConfiguration { provider: string; origin: string; contextTokens: number; tokenEnv: string | null } +/** Main-owned assertion of an already enforced host boundary, never a thread or renderer setting. */ +export interface LocalHostSandbox { + readonly type: "externalSandbox"; + readonly networkAccess: "restricted"; + readonly boundaryId: string; +} + /** The configuration a child was admitted on, kept with the child for diagnostics; no secrets. */ export interface LocalConfigurationProvenance { /** Codex's hash of the session-flags layer: the configuration Modex passed, as Codex read it. */ sessionFlags: string; layers: { source: string; version: string; file?: string }[]; managedRequirements: false; + /** The caller owns enforcing this boundary before spawning Codex; its hash binds process identity. */ + hostSandbox?: LocalHostSandbox; /** Set by the caller that knows where the window came from (execution.ts). */ context?: LocalContextEvidence; } @@ -62,6 +71,8 @@ export interface LocalContextEvidence { /** Keys a layer other than Modex's session flags may not set: the inference destination, the model, its context and the tools reaching it. */ const GOVERNED = ["model", "model_provider", "model_providers", "openai_base_url", "oss_provider", "model_catalog_json", "model_context_window", "model_auto_compact_token_limit", + // Native compaction: what it sends the provider and when it runs (#380 AC1; key names from codex-cli 0.166.0). + "compact_prompt", "model_auto_compact_token_limit_scope", "model_post_turn_compact_threshold_percent", "model_async_compact_threshold_percent", "model_reasoning_summary", "model_reasoning_effort", "service_tier", "web_search", "features", "mcp_servers", "shell_environment_policy", "plugins", "hooks", "marketplaces", "profile", "profiles", "projects", "notify", "otel", "tools", "developer_instructions", "model_instructions_file", "agents", "cloud"]; const MANAGED = new Set(["mdm", "system", "enterpriseManaged", "legacyManagedConfigTomlFromFile", "legacyManagedConfigTomlFromMdm"]); @@ -131,6 +142,8 @@ export function admitLocalConfiguration(read: ConfigRead, requirements: Requirem want(`model_providers.${expected.provider}.requires_openai_auth`, provider.requires_openai_auth, false); want(`model_providers.${expected.provider}.env_key`, provider.env_key ?? null, expected.tokenEnv); want(`model_providers.${expected.provider}.request_max_retries`, provider.request_max_retries, 0); + // A dropped stream is otherwise re-sent whole: the turn reports the drop instead (#380 AC4). + want(`model_providers.${expected.provider}.stream_max_retries`, provider.stream_max_retries, 0); for (const name of ["experimental_bearer_token", "auth", "http_headers", "env_http_headers", "query_params"]) want(`model_providers.${expected.provider}.${name}`, provider[name] ?? null, null); want("model_context_window", config.model_context_window, expected.contextTokens); want("web_search", config.web_search, "disabled"); diff --git a/apps/desktop/src/main/engine/local-models/service.ts b/apps/desktop/src/main/engine/local-models/service.ts index cc6a45e3..5812fefa 100644 --- a/apps/desktop/src/main/engine/local-models/service.ts +++ b/apps/desktop/src/main/engine/local-models/service.ts @@ -12,7 +12,7 @@ export class LocalModelsService { readonly connections: LocalConnectionStore; readonly discovery: LocalModelDiscovery; - constructor(home: string, secrets: SecretStore, options: DiscoveryOptions = {}) { + constructor(home: string, secrets: SecretStore, private readonly options: DiscoveryOptions & { connectionInUse?: (id: string) => boolean } = {}) { this.connections = new LocalConnectionStore(home, secrets); this.discovery = new LocalModelDiscovery(this.connections, options); } @@ -24,11 +24,13 @@ export class LocalModelsService { case "list": return { op: "list", connections: this.connections.list() }; case "create": return { op: "create", connection: this.connections.create(request.input) }; case "update": { + this.requireIdle(request.id); const connection = this.connections.update(request.id, request.revision, request.patch); this.discovery.invalidate(request.id); return { op: "update", connection }; } case "delete": + this.requireIdle(request.id); this.connections.delete(request.id, request.revision); this.discovery.invalidate(request.id); return { op: "delete" }; @@ -38,4 +40,8 @@ export class LocalModelsService { return { op: "cancel" }; } } + + private requireIdle(id: string): void { + if (this.options.connectionInUse?.(id)) throw new Error("Stop the local turn before editing or removing its connection."); + } } diff --git a/apps/desktop/src/main/engine/native-drafts.ts b/apps/desktop/src/main/engine/native-drafts.ts index 8ad52f3e..9b4d7cc9 100644 --- a/apps/desktop/src/main/engine/native-drafts.ts +++ b/apps/desktop/src/main/engine/native-drafts.ts @@ -1,3 +1,5 @@ +import { executionBinding } from "./execution-binding.js"; +import type { PendingExecutionBinding } from "../../shared/native-drafts.js"; import type { Attachment, AttachmentDraftOwner } from "../../shared/attachments.js"; import type { ComposerRecovery, DraftSaveReceipt } from "../../shared/update-recovery.js"; import type { Thread } from "../../shared/types.js"; @@ -94,7 +96,7 @@ export class NativeDrafts { const matches = this.options.store.items(record.target.threadId).filter(item => item.id === record.id); const item = matches[0]; return matches.length === 1 && item?.kind === "user" && item.text === record.rawText && item.at === record.createdAt - && equal(item.attachments ?? [], record.files); + && equal(item.attachments ?? [], record.files) && (!record.execution || equal(item.execution, record.execution)); } private current(session: string): AttachmentDocument { if (!this.document || session !== this.session) throw fail(); @@ -229,15 +231,28 @@ export class NativeDrafts { const document = this.current(session); this.documents.retire(this.documents.register(document, owner)); } + private execution(thread: Pick): PendingExecutionBinding { + return executionBinding(thread, thread.localTarget === undefined && thread.backend === "codex" + ? this.options.runner.backend("codex")?.identity?.() ?? null : null); + } + private assertExecution(record: PendingSendRecord, thread: Thread): void { + // Legacy composer settings may describe the selection before local was + // chosen. They cannot prove the original's destination, on any backend. + const same = record.execution && equal(record.execution, this.execution(thread)); + if (!same) throw new Error("Saved message destination changed or is unverified. The original is preserved; use Copy text to new chat, review its model, then send explicitly."); + } begin(session: string, input: PendingSendInput): { id: string } { this.assertEditing(); const document = this.current(session); + const existing = input?.source?.kind === "thread" ? this.options.store.thread(input.source.threadId) : undefined; + const destination = existing ?? input?.configuration?.settings; + if (!destination) throw fail(); const pending = this.pending.begin(input, record => { if (this.current(session) !== document || !this.ownerExists(record.source)) return false; for (const other of this.records()) if (key(other.owner) === key(record.source) || other.files.some(file => record.files.some(selected => selected.id === file.id))) return false; return record.files.every(file => this.documents.validate(document, record.source, file)); - }); + }, this.execution(destination)); if (!this.documents.refreshRetention()) throw fail(); return { id: pending.value!.id }; } @@ -253,6 +268,7 @@ export class NativeDrafts { thread = await this.options.runner.createThreadForPending(record.source.projectId, record.creation, record.configuration); } else thread = this.options.store.thread(record.source.threadId); if (this.current(session) !== document || !thread) throw fail(); + this.assertExecution(record, thread); const target = { threadId: thread.id, projectId: thread.projectId, createdAt: thread.createdAt }; if (!this.proveTarget(target)) throw fail(); this.pending.bind(intentId, () => target); @@ -302,6 +318,9 @@ export class NativeDrafts { const document = this.current(session), record = this.requirePending(intentId); if (!this.transferred.has(intentId) || !record.target || record.owner.kind !== "thread" || !this.proveTarget(record.target) || record.files.some(file => !this.documents.validate(document, record.owner, file))) throw fail(); + const thread = this.options.store.thread(record.target.threadId); + if (!thread) throw fail(); + this.assertExecution(record, thread); await this.options.runner.sendDurably(record.target.threadId, record.rawText, record.files, { id: record.id, at: record.createdAt, committed: () => { if (this.current(session) !== document) throw fail(); diff --git a/apps/desktop/src/main/engine/pending-sends.ts b/apps/desktop/src/main/engine/pending-sends.ts index 1ea023d9..d8329fd3 100644 --- a/apps/desktop/src/main/engine/pending-sends.ts +++ b/apps/desktop/src/main/engine/pending-sends.ts @@ -1,3 +1,6 @@ +import { threadTarget } from "../../shared/local-models.js"; +import { isBackendId } from "../../shared/types.js"; +import type { PendingExecutionBinding } from "../../shared/native-drafts.js"; import fs from "node:fs"; import path from "node:path"; import { randomUUID } from "node:crypto"; @@ -38,7 +41,18 @@ function validateTarget(value: unknown): asserts value is PendingTarget { if (!id(value.threadId) || !id(value.projectId)) throw fail(); date(value.createdAt); } function validateRecord(value: unknown): asserts value is PendingSendRecord { - shape(value, ["schema", "id", "createdAt", "source", "owner", "rawText", "configuration", "files", "creation", "target"]); + const keys = ["schema", "id", "createdAt", "source", "owner", "rawText", "configuration", "files", "creation", "target"]; + if (value && typeof value === "object" && Object.hasOwn(value, "execution")) keys.push("execution"); + shape(value, keys); + if (Object.hasOwn(value, "execution")) { + const binding = value.execution; + shape(binding, ["schema", "backend", "model", "account", "localTarget", "localContextTokens"]); + if (binding.schema !== 1 || !isBackendId(binding.backend) || typeof binding.model !== "string" || binding.model.length > 512 || (binding.account !== null && (typeof binding.account !== "string" || binding.account.length > 256))) throw fail(); + if (binding.localTarget !== null) { + const parsed = threadTarget({ backend: binding.backend as PendingExecutionBinding["backend"], localTarget: binding.localTarget }); + if (parsed.kind !== "local" || parsed.target.model.id !== binding.model || binding.account !== null || !Number.isSafeInteger(binding.localContextTokens) || (binding.localContextTokens as number) < 4096 || (binding.localContextTokens as number) > 262144) throw fail(); + } else if (binding.localContextTokens !== null) throw fail(); + } if (value.schema !== 1 || !uuid(value.id) || typeof value.rawText !== "string") throw fail(); date(value.createdAt); owner(value.source); owner(value.owner); if (value.rawText.length > PENDING_TEXT_CHARACTERS) throw fail(); @@ -136,12 +150,12 @@ export class PendingSends { this.file(id).checkpoint(expected); }); } - begin(input: unknown, authorize: (record: NonNullable) => boolean): PendingSnapshot { + begin(input: unknown, authorize: (record: NonNullable) => boolean, execution?: PendingExecutionBinding): PendingSnapshot { return this.operation(() => { const copied: unknown = parseRecoveryJSON(recoveryJSON(input, PENDING_RECORD_BYTES), PENDING_RECORD_BYTES); shape(copied, ["source", "rawText", "configuration", "files"]); owner(copied.source); const id = randomUUID(), createdAt = new Date().toISOString(); - const record = copyRecord({ schema: 1, id, createdAt, ...copied, owner: copied.source, + const record = copyRecord({ schema: 1, id, createdAt, ...copied, ...(execution ? { execution } : {}), owner: copied.source, creation: copied.source.kind === "project" ? { threadId: randomUUID(), createdAt } : null, target: null }); const admitted = JSON.parse(recoveryJSON(record, PENDING_RECORD_BYTES)) as PendingSendRecord; const freeze = (value: unknown): void => { if (value && typeof value === "object") { for (const child of Object.values(value)) freeze(child); Object.freeze(value); } }; diff --git a/apps/desktop/src/main/engine/runner.ts b/apps/desktop/src/main/engine/runner.ts index c6d6e0c9..517face3 100644 --- a/apps/desktop/src/main/engine/runner.ts +++ b/apps/desktop/src/main/engine/runner.ts @@ -1,3 +1,4 @@ +import { executionBinding } from "./execution-binding.js"; import { resolveCli } from "./cli-path.js"; import type { LocalExecution } from "./local-models/execution.js"; import { localOnly } from "./local-models/run-policy.js"; @@ -183,6 +184,11 @@ export class ThreadRunner { return [...this.live].some(([threadId, l]) => Boolean(l.abort) && l.backend === backend && threadAccount(this.o.store.thread(threadId)) === account); } + localConnectionInUse(connectionId: string): boolean { + return [...this.live].some(([threadId, live]) => Boolean(live.run || live.abort) + && this.o.store.thread(threadId)?.localTarget?.model.connection.id === connectionId); + } + async listModels(id: BackendId): Promise<{ models: ModelInfo[]; error?: string }> { if (this.disposing) return { models: [], error: "Modex is shutting down." }; if (this.restartHold) return { models: [], error: "Modex is preparing to restart." }; @@ -648,10 +654,17 @@ export class ThreadRunner { } /** Runs one user turn. Resolves when the thread is idle again (or errored). */ - async send(threadId: string, text: string, attachments: Attachment[] = []): Promise { + async send(threadId: string, text: string, attachments: Attachment[] = [], by = "desktop"): Promise { + this.assertLocalSender(threadId, by); return this.start(threadId, text, undefined, attachments); } + private assertLocalSender(threadId: string, by: string): void { + if (this.o.store.thread(threadId)?.localTarget !== undefined && by !== "desktop") { + throw new Error("Send local-model messages from this Mac. Phone and paired-desktop sends are unavailable for local threads."); + } + } + /** Native durable adapter only. Files have already crossed its journaled * ownership transaction. Existing generic send and explicit Retry stay separate. */ async sendDurably(threadId: string, rawText: string, attachments: readonly Readonly[], admission: DurableSendAdmission): Promise { @@ -668,10 +681,19 @@ export class ThreadRunner { * Runs the thread's last message again, in place: the transcript keeps its one user bubble and the * new attempt follows the failure card. Auto routing is not re-asked; the pick already applied. */ - async retry(threadId: string): Promise { + async retry(threadId: string, by = "desktop"): Promise { + this.assertLocalSender(threadId, by); this.assertThreadAvailable(threadId); const user = [...this.slot(threadId).items].reverse().find((item) => item.kind === "user"); if (!user || user.kind !== "user") throw new Error("Nothing to retry: this thread has no message yet."); + const current = this.o.store.thread(threadId)!; + // Old transcript items carry no destination proof. The current thread may + // already have switched away from local, so its current target is not proof. + if (!user.execution) throw new Error("Saved message destination is unverified. Copy the original into a new message to authorize this destination."); + if (current.localTarget !== undefined || user.execution.localTarget) { + const binding = executionBinding(current, null); + if (JSON.stringify(user.execution) !== JSON.stringify(binding)) throw new Error("Saved message destination changed. Copy the original into a new message to authorize this destination."); + } return this.start(threadId, user.text, user.id, user.attachments); } @@ -722,6 +744,11 @@ export class ThreadRunner { }; const retry = reuse !== undefined; + // Fix the account before durable admission and before any asynchronous + // auxiliary setup can observe a different active account. + const prospectiveCodexAccount = thread.localTarget === undefined && (thread.backend === "codex" || thread.auto) + ? this.backends.codex.identity?.() ?? null : null; + const admittedExecution = executionBinding(thread, prospectiveCodexAccount); // Staged files move into the thread's folder now, so a Retry (or a restart) still finds them. let draft: AttachmentDraft | undefined; if ((prepared || retry) && Array.isArray(attachments) && attachments.length) { @@ -747,7 +774,7 @@ export class ThreadRunner { // The Modex turn is the user message it answers: Retry runs the same turn again as a new attempt. const turnId = reuse ?? prepared?.id ?? newId(); if (!retry) { - const item: UserItem = { id: turnId, kind: "user", text: prepared?.rawText ?? text, + const item: UserItem = { id: turnId, kind: "user", text: prepared?.rawText ?? text, execution: admittedExecution, at: prepared?.at ?? new Date().toISOString(), ...(files.length ? { attachments: files } : {}) }; this.addItem(threadId, item, prepared ? () => { const receipt = Object.freeze({ ...item, ...(item.attachments ? { attachments: Object.freeze(item.attachments.map(file => Object.freeze({ ...file }))) } : {}) }); @@ -848,13 +875,14 @@ export class ThreadRunner { if (knowledge.warning) sink.notice("warn", knowledge.warning); if (abort.signal.aborted) { this.setStatus(threadId, "idle"); return; } } - // Codex and Claude Code bind a thread to the account its first turn ran on; older handles belong to the CLI's own login. + // Preserve Codex's admitted identity across asynchronous setup; Claude keeps its account-pool binding. const bound = threadAccount(thread); const account = !local && (thread.backend === "codex" || thread.backend === "claude") && backend.identity - ? bound ?? (thread.sessionHandle ? "cli" : backend.identity()) : undefined; + ? thread.backend === "codex" + ? admittedExecution.backend === "codex" ? admittedExecution.account ?? undefined : prospectiveCodexAccount ?? undefined + : bound ?? (thread.sessionHandle ? "cli" : backend.identity()) : undefined; if (account && !bound) this.o.store.updateThread(threadId, thread.backend === "codex" ? { codexAccount: account } : { claudeAccount: account }); const accountFor = (ran: Thread) => threadAccount(ran) ?? account; - // Read before the turn: the store's thread object takes the session id the moment the CLI reports one. const freshConversation = !thread.sessionHandle; const turnFiles: TurnAttachment[] = files.map((f) => { const file = this.attachments.resolve(f.rel); @@ -1209,8 +1237,9 @@ export class ThreadRunner { // External edits remain fenced until the renderer adopts the canonical state. if (this.restartHold?.kind !== "owner-removal" || (!opts.fromRouter && !opts.automaticTitle)) this.assertNotRestarting(); // Resume handles and account bindings are main-owned, even if an IPC caller sends extra keys. - // A local target is chosen on this Mac: a paired desktop's patch never carries one. - const localKeys = opts.by === "paired-desktop" ? [] : ["localTarget", "localContextTokens"]; + // A local target is chosen, changed and cleared on this Mac's own window only (#380 AC5): a paired desktop's, + // the phone's or an unnamed caller's patch never carries one. + const localKeys = opts.by === "desktop" ? ["localTarget", "localContextTokens"] : []; patch = Object.fromEntries(Object.entries(patch).filter(([key]) => ["mode", "model", "title", "backend", "plan", "effort", "auto", "approvals", ...localKeys].includes(key))) as ThreadPatch; if (patch.approvals !== undefined && !APPROVAL_POLICIES.includes(patch.approvals)) delete patch.approvals; @@ -1237,7 +1266,7 @@ export class ThreadRunner { const extra: Partial = current && patch.backend && patch.backend !== current.backend ? { sessionHandle: undefined, codexAccount: undefined, claudeAccount: undefined, lastTurn: undefined, model: this.o.store.settings.default_model[patch.backend] ?? "", effort: undefined } : {}; // So does any local target change: the old child's handle is dropped, and clearing the target returns to the default cloud model. const localReset: Partial = switchingLocal ? { sessionHandle: undefined, codexAccount: undefined, claudeAccount: undefined, lastTurn: undefined, - ...(patch.localTarget === null ? { localTarget: undefined, localContextTokens: undefined, model: this.o.store.settings.default_model.codex ?? "", effort: undefined } : {}) } : {}; + ...(patch.localTarget === null ? { localTarget: undefined, localContextTokens: undefined, model: patch.model ?? this.o.store.settings.default_model.codex ?? "", effort: patch.effort } : {}) } : {}; if (switchingLocal) { this.naming.get(threadId)?.abort(); this.naming.delete(threadId); const slot = this.slot(threadId); slot.followUp?.abort.abort(); slot.followUp = undefined; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index ee7d4163..9cefbb0b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -220,14 +220,14 @@ const emit = (event: ThreadEvent): void => { if (win && !win.isDestroyed() && !win.webContents.isDestroyed()) win.webContents.send("thread:event", event); if (!journal) desktopHost?.broadcast("thread", event); }; -// The e2e harness has no keychain to unlock; everything else goes through the OS keychain. -const secrets = new SecretStore(home, process.env.MODEX_E2E ? testCipher : electronCipher(safeStorage)); -const localModels = new LocalModelsService(home, secrets); +// Only unpackaged E2E fixtures may substitute storage. A packaged app always uses +// OS encryption (or refuses credentials), even when it inherits test flags. +const osCipher = !app.isPackaged && process.env.MODEX_E2E ? testCipher : electronCipher(safeStorage); +const secrets = new SecretStore(home, osCipher); +const localModels = new LocalModelsService(home, secrets, { connectionInUse: id => runner.localConnectionInUse(id) }); // Local execution (LM-3, engine/local-models/execution.ts) is not wired into the product runner: Codex's redirect // handling and managed-policy precedence are unqualified (#379 AC6, LM-5 #380), so without the seam the runner // refuses every local selection and persisted target instead of starting a child or routing to a cloud backend. -// Settings reads ChatGPT status in packaged e2e too; keep its test home off the OS keychain. -const osCipher = process.env.MODEX_E2E ? testCipher : electronCipher(safeStorage); const chatgptCipher = { ...osCipher, available: () => osCipher.available() && (process.platform !== "linux" || safeStorage.getSelectedStorageBackend() !== "basic_text") }; const modexAccount = new WorkOSAuth({ home, cipher: chatgptCipher, config: workosConfig(process.env.MODEX_WORKOS_ENV), openBrowser: (url) => shell.openExternal(url) }); const chatgpt = new ChatGPTAuth({ home, cipher: chatgptCipher, openBrowser: (url) => shell.openExternal(url) }); @@ -383,7 +383,7 @@ const companion = new CompanionServer(home, { if (currentDraftRecovery()) throw new Error("Draft recovery is required."); await pathReady; if (currentDraftRecovery()) throw new Error("Draft recovery is required."); - return startTurn(id, runner.send(id, text)); + return startTurn(id, runner.send(id, text, [], "phone")); }), answer: (id, itemId, answer, by) => { restartAdmission.assertOpen(); if (currentDraftRecovery()) throw new Error("Draft recovery is required."); return runner.answer(id, itemId, answer, by); }, answerQuestion: (id, itemId, answers, by) => { restartAdmission.assertOpen(); if (currentDraftRecovery()) throw new Error("Draft recovery is required."); return runner.answerQuestion(id, itemId, answers, by); }, @@ -833,7 +833,7 @@ async function startTurn(threadId: string, run: Promise): Promise<{ ok: bo accepting = false; return earlyError !== undefined ? { ok: false, error: earlyError } : { ok: true }; } -handle("thread:send", ({ threadId, text, attachments: files }) => startTurn(threadId, runner.send(threadId, text, files))); +handle("thread:send", ({ threadId, text, attachments: files }, event) => startTurn(threadId, runner.send(threadId, text, files, event ? "desktop" : "paired-desktop"))); registerAttachmentCommands(handle, attachments, { bytesOnly: false, reveal: (abs) => shell.showItemInFolder(abs) }); handle("attachments:pick", async (req) => { // Explicit renderer paths keep the strict whole-request guard. Only the native dialog's @@ -849,7 +849,7 @@ handle("attachments:pick", async (req) => { if (r.filePaths.length > remaining) result.errors.push(`Up to ${LIMITS.perTurn} attachments per message; ${r.filePaths.length - remaining} left out.`); return result; }, { localOnly: true }); -handle("thread:retry", ({ threadId }) => startTurn(threadId, runner.retry(threadId))); +handle("thread:retry", ({ threadId }, event) => startTurn(threadId, runner.retry(threadId, event ? "desktop" : "paired-desktop"))); handle("thread:stop", ({ threadId }) => runner.stop(threadId)); // Only this Mac's window calls with an IPC event; a paired desktop's command comes through the host without one. const answeredFrom = (event?: NativeIPCEvent) => event ? "desktop" : "paired-desktop"; diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 02138e94..65d18600 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -6,6 +6,7 @@ import { useComposerRecovery } from "./useComposerRecovery"; import { ComposerRestartPreparation } from "../shared/composer-restart-preparation"; import { ComposerOwnerRemoval } from "../shared/composer-owner-removal"; import { restartDrafts } from "./restart-drafts"; +import { copyPendingTextToNewThread } from "./pending-message-copy"; import { StreamerMode, useStreamerRedaction } from "./streamer"; import { redactSecrets } from "../shared/streamer-secrets"; import { RepositoryOverview } from "./components/RepositoryOverview"; @@ -23,6 +24,9 @@ import { Sidebar } from "./components/Sidebar"; import { ThreadView } from "./components/ThreadView"; import { Workspace } from "./components/Workspace"; import { SettingsDialog } from "./components/SettingsDialog"; +import type { LocalMenu } from "./components/ModelMenu"; +import { discoverAll, useLocalModels } from "./local-models"; +import type { LocalModelRef } from "../shared/local-models"; import { CompanionDialog } from "./components/CompanionDialog"; import { EmptyState } from "./components/EmptyState"; import { DraftView, type Draft } from "./components/DraftView"; @@ -189,6 +193,9 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE const [terminalCommands, setTerminalCommands] = useState>({}); const commandNonce = useRef(0); const [showSettings, setShowSettings] = useState(false); + /** Settings opened from the model menu lands on Local models. */ + const [settingsSection, setSettingsSection] = useState<"local" | undefined>(undefined); + const localModels = useLocalModels(); const [showCompanion, setShowCompanion] = useState(false); // Panel toggles survive a relaunch (localStorage, see shared/layout.ts). const [layout, setLayout] = useLayout(persistPreferences); @@ -516,6 +523,20 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE setDraftRevision((revision) => revision + 1); setDraft({ projectId, worktree, settings: draftDefaults(state) }); }; + const pendingProjectId = (record: PendingSendView) => { + const source = record.source; + return source.kind === "project" ? source.projectId : record.target?.projectId ?? state?.threads.find(row => row.id === source.threadId)?.projectId; + }; + const copySavedMessage = (record: PendingSendView) => act(async () => { + const projectId = pendingProjectId(record); + if (!projectId || !state || nativeHoldRef.current || nativeLoading || nativeRecoveryError || pendingRuns.current.has(record.id)) return; + const { backend, model, mode } = draftDefaults(state); + const copy = await copyPendingTextToNewThread(record, { projectId, backend, model, mode }, request => bridge.invoke("thread:create", request)); + await refresh(); + setUnsentFor(copy.thread.id, copy.text); + selectedRef.current = copy.thread.id; setSelected(copy.thread.id); + setDraft(null); setSurface("chat"); + }); // Nothing selected and no draft, but there are projects (first launch, last thread deleted): open a draft. useEffect(() => { if (!state || selected || creating || nativeLoading || nativeHold || nativeRecoveryError) return; @@ -543,7 +564,8 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE // Like the Codex App, a thread always has a concrete model the CLI knows: pick the CLI's // default (or the settings default) when the thread has none or names one the CLI no longer lists. useEffect(() => { - if (!thread) return; + // A local thread is pinned to its exact local model, which the CLI's own list never contains. + if (!thread || thread.localTarget !== undefined) return; const list = models[thread.backend]?.models; if (!list?.length) return; const current = list.find((m) => m.id === thread.model); @@ -755,6 +777,53 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE await bridge.invoke("thread:update", { threadId: thread.id, patch }); await refresh(); }); + const openLocalSettings = () => { setSettingsSection("local"); setShowSettings(true); }; + const localTarget = (model: LocalModelRef) => ({ schema: 1 as const, harness: "codex" as const, model }); + /** + * A draft has no thread to pin, so choosing a local model creates the thread now, pinned before its + * first message: nothing typed in it ever goes to a cloud model. Only an empty draft can do this, so no + * text or file has to move. If main refuses the target, the empty thread is removed again. + */ + const chooseLocalForDraft = (model: LocalModelRef, localContextTokens: number) => draft && act(async () => { + const d = draft; + const t = await bridge.invoke("thread:create", { projectId: d.projectId, worktree: d.worktree, backend: "codex", mode: d.settings.mode, auto: false }); + try { + await bridge.invoke("thread:update", { threadId: t.id, patch: { localTarget: localTarget(model), localContextTokens } }); + } catch (error) { + await bridge.invoke("thread:delete", { threadId: t.id, removeWorktree: true }).catch(() => undefined); + await refresh(); + throw error; + } + await refresh(); + if (activeDraftKeyRef.current === draftKey) { setDraft(null); setSelected(t.id); } + }); + const threadBusy = thread ? thread.status === "running" || thread.status === "waiting" : false; + const threadLocal: LocalMenu | undefined = thread ? { + entries: localModels.entries, + current: thread.localTarget, + currentContext: thread.localContextTokens, + blocked: threadBusy ? "Wait for the turn to finish before changing the model." : undefined, + onOpen: () => void discoverAll(15_000), + onChoose: (model, localContextTokens) => void updateThread({ localTarget: localTarget(model), localContextTokens }), + onClear: (requested) => { + const cloud = requested ?? models.codex?.models.find(m => m.id === state?.settings.default_model.codex) + ?? models.codex?.models.find(m => m.isDefault) ?? models.codex?.models[0]; + if (!cloud) { setError({ message: "Load the Codex cloud model list before returning to cloud models." }); return; } + if (window.confirm(`Use cloud model ${cloud.label} (${cloud.id})? Future messages will go to that model in a fresh conversation. Existing messages will stay in the transcript.`)) { + void updateThread({ localTarget: null, model: cloud.id, effort: cloud.defaultEffort }); + } + }, + onManage: openLocalSettings, + } : undefined; + const draftEmpty = draft ? !(unsent[draftKey] ?? "").trim() && attachmentDrafts.get(draftKey).attachments.length === 0 : false; + const draftLocal: LocalMenu | undefined = draft ? { + entries: localModels.entries, + blocked: creating ? "Wait for the thread to be created." : draftEmpty ? undefined : "Choose a local model before typing: clear this draft, or send it and start a new thread.", + onOpen: () => void discoverAll(15_000), + onChoose: (model, localContextTokens) => void chooseLocalForDraft(model, localContextTokens), + onClear: () => undefined, + onManage: openLocalSettings, + } : undefined; /** Archived threads leave the working list on every device; the selection stays so the row remains visible under Archived. */ const archiveThread = (t: Thread, archived: boolean) => act(async () => { await bridge.invoke("thread:archive", { threadId: t.id, archived }); @@ -970,6 +1039,8 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE
{privateLabel(record.rawText)}
{record.files.length > 0 &&

{record.files.map(file => privateLabel(file.name)).join(", ")}

} + +

Review the new chat's model before sending. Attachments stay with the saved original; attach them again if needed.

)} {surface === "chat" && thread && project && !showChanges && setLayout({ changes: true })} onOpenFolder={() => openPath(thread.cwd)} />} {thread && project ? ( @@ -995,6 +1066,7 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE modelsError={models[thread.backend]?.error} onRetryModels={() => retryModels(thread.backend)} openModelPickerRequest={modelPickerRequest} + local={threadLocal} inputRef={inputRef} branch={changes?.branch ?? undefined} /> @@ -1008,6 +1080,7 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE models={models[draft.settings.backend]?.models ?? []} modelsError={models[draft.settings.backend]?.error} onRetryModels={() => retryModels(draft.settings.backend)} + local={draftLocal} branch={draftBranch?.projectId === draft.projectId ? draftBranch.branch ?? undefined : undefined} onChange={value => { if (!nativeHoldRef.current && !nativeRecoveryError) setDraft(value); }} onSend={sendDraft} @@ -1044,7 +1117,7 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE )} - {showSettings && setShowSettings(false)} {...(analyticsEnabled ? { analyticsInRail: layout.analytics, onAnalyticsInRail: (shown: boolean) => setLayout({ analytics: shown }) } : {})} {...(codeReviewEnabled ? { codeReviewInRail: layout.codeReview, onCodeReviewInRail: (codeReview: boolean) => setLayout({ codeReview }) } : {})} />} + {showSettings && { setShowSettings(false); setSettingsSection(undefined); }} initialSection={settingsSection} {...(analyticsEnabled ? { analyticsInRail: layout.analytics, onAnalyticsInRail: (shown: boolean) => setLayout({ analytics: shown }) } : {})} {...(codeReviewEnabled ? { codeReviewInRail: layout.codeReview, onCodeReviewInRail: (codeReview: boolean) => setLayout({ codeReview }) } : {})} />} {showCompanion && setShowCompanion(false)} />} ) : {error?.message ?? "Loading…"}; diff --git a/apps/desktop/src/renderer/components/Composer.tsx b/apps/desktop/src/renderer/components/Composer.tsx index f49e05ff..e31f1077 100644 --- a/apps/desktop/src/renderer/components/Composer.tsx +++ b/apps/desktop/src/renderer/components/Composer.tsx @@ -6,7 +6,7 @@ import type { AttachmentActions } from "../composer-attachments"; import type { Attachment } from "../../shared/attachments"; import { hasFiles } from "../attachments"; import { AttachmentChips } from "./Attachments"; -import { ModelMenu } from "./ModelMenu"; +import { ModelMenu, type LocalMenu } from "./ModelMenu"; import { Icon } from "./ui/Icon"; import { IconButton } from "./ui/IconButton"; import { ErrorNotice } from "./ui/ErrorNotice"; @@ -48,6 +48,8 @@ interface Props { /** Asks the CLI for its models again after `modelsError`. */ onRetryModels?: () => void; openModelPickerRequest?: number; + /** Local models beside the CLI's own (LM-4). A thread with a local target is pinned: no Plan, Auto or backend change. */ + local?: LocalMenu; /** A suggested next message for an idle thread; shown only while the box is empty. */ suggestion?: FollowUp | null; onBackend: (b: BackendId) => void; @@ -71,7 +73,7 @@ const MAX_INPUT = 180; * input and one control row — `+` (plan, auto, backend), the access pill (mode), any active chips, * the model picker, and a round send/stop button. */ -export function Composer({ text, onText: setText, attachments, attachmentActions, busy, context, backend, mode, approvals = "ask", onApprovals, plan, model, effort, auto, models, modelsError, onRetryModels, openModelPickerRequest, suggestion, onBackend, onMode, onPlan, onModel, onEffort, onAuto, onSend, onStop, inputRef }: Props) { +export function Composer({ text, onText: setText, attachments, attachmentActions, busy, context, backend, mode, approvals = "ask", onApprovals, plan, model, effort, auto, models, modelsError, onRetryModels, openModelPickerRequest, local, suggestion, onBackend, onMode, onPlan, onModel, onEffort, onAuto, onSend, onStop, inputRef }: Props) { const fallbackRef = useRef(null); const ta = inputRef ?? fallbackRef; const { copying, sending, warning: attachWarning, onFiles: attach, onPick: pick, onRemove, onDismissWarning } = attachmentActions; @@ -92,7 +94,8 @@ export function Composer({ text, onText: setText, attachments, attachmentActions ta.current?.focus({ preventScroll: true }); }, [busy]); - const canSend = Boolean(text.trim() || attachments.length); + const unsupportedImages = Boolean(local?.current && attachments.some(file => file.kind === "image")); + const canSend = Boolean(text.trim() || attachments.length) && !unsupportedImages; const submit = () => { if (!canSend || busy || copying || sending) return; void onSend(text); @@ -144,9 +147,11 @@ export function Composer({ text, onText: setText, attachments, attachmentActions
+ {local?.current &&

Runs through Codex at {local.entries.find(e => e.connection.id === local.current!.model.connection.id && e.connection.revision === local.current!.model.connection.revision)?.connection.origin ?? "an unavailable local server"} · connection revision {local.current.model.connection.revision}. A local address does not verify offline operation.

} + {unsupportedImages &&

Images are not qualified for local models. Remove the image or choose a cloud model before sending; the attachment is still here.

}
{canSuggest && ( - @@ -181,7 +186,7 @@ export function Composer({ text, onText: setText, attachments, attachmentActions />
- void pick()} busy={busy} backend={backend} plan={plan} auto={auto} onBackend={onBackend} onPlan={onPlan} onAuto={onAuto} /> + void pick()} busy={busy} pinned={Boolean(local?.current)} backend={backend} plan={plan} auto={auto} onBackend={onBackend} onPlan={onPlan} onAuto={onAuto} /> {/* After the access pill: `+` and the pill keep their 22 px / 40 px insets (e2e/layout.spec.ts). */} void pick()} /> @@ -201,7 +206,7 @@ export function Composer({ text, onText: setText, attachments, attachmentActions )} - + {busy ? ( { stopFocused.current = document.activeElement === e.currentTarget; onStop(); }} /> ) : ( @@ -245,7 +250,7 @@ function ContextStrip({ context }: { context: ComposerContext }) { } /** `+`: plan mode, Auto routing, and which CLI runs the thread. Everything is disabled while a turn runs. */ -function PlusMenu({ busy, backend, plan, auto, onBackend, onPlan, onAuto, onAttach }: { onAttach: () => void; busy: boolean; backend: BackendId; plan: boolean; auto: boolean; onBackend: (b: BackendId) => void; onPlan: (p: boolean) => void; onAuto: (a: boolean) => void }) { +function PlusMenu({ busy, pinned, backend, plan, auto, onBackend, onPlan, onAuto, onAttach }: { onAttach: () => void; busy: boolean; pinned: boolean; backend: BackendId; plan: boolean; auto: boolean; onBackend: (b: BackendId) => void; onPlan: (p: boolean) => void; onAuto: (a: boolean) => void }) { const [open, setOpen] = useState(false); const trigger = useRef(null); const pick = (fn: () => void) => () => { fn(); setOpen(false); }; @@ -255,23 +260,24 @@ function PlusMenu({ busy, backend, plan, auto, onBackend, onPlan, onAuto, onAtta setOpen(false)} anchorRef={trigger} label="Composer options" placement="top-start" testId="composer-plus-menu" className="composer-options"> Attach files - onPlan(!plan))}> + {pinned &&
This thread uses a local model, so Plan, Auto and the backend are fixed. Choose cloud models in the model menu to change them.
} + onPlan(!plan))}> Plan mode ⇧⌘P - Investigate read-only and return a plan instead of editing. + {pinned ? "Plan mode is not qualified for local models." : "Investigate read-only and return a plan instead of editing."} - onAuto(!auto))}> + onAuto(!auto))}> Auto routing - A fast judge picks the model, effort and speed for each turn, within your limits. + {pinned ? "Local threads keep the model you explicitly selected." : "A fast judge picks the model, effort and speed for each turn, within your limits."} Backend
{BACKENDS.map((b) => ( - onBackend(b.id))}> + onBackend(b.id))}> {b.label} ))} diff --git a/apps/desktop/src/renderer/components/DraftView.tsx b/apps/desktop/src/renderer/components/DraftView.tsx index 1dc2b7ae..da223fb2 100644 --- a/apps/desktop/src/renderer/components/DraftView.tsx +++ b/apps/desktop/src/renderer/components/DraftView.tsx @@ -4,6 +4,7 @@ import type { BackendId, Mode, ModelInfo, Project } from "../../shared/types"; import type { AttachmentActions } from "../composer-attachments"; import type { Attachment } from "../../shared/attachments"; import { Composer } from "./Composer"; +import type { LocalMenu } from "./ModelMenu"; import { Icon } from "./ui/Icon"; import { Menu, MenuItem } from "./ui/Menu"; @@ -32,6 +33,8 @@ interface Props { models: ModelInfo[]; modelsError?: string; onRetryModels?: () => void; + /** Local models beside the CLI's own (LM-4). */ + local?: LocalMenu; /** The project checkout's current branch; shown for a Local draft (a worktree's branch does not exist yet). */ branch?: string; onChange: (draft: Draft) => void; @@ -47,7 +50,7 @@ interface Props { * The "What should we build in ?" screen: a live composer before any thread exists. * The project name is a picker; the context strip's Local/Worktree item is a toggle. */ -export function DraftView({ draft, projects, creating, models, modelsError, onRetryModels, branch, onChange, onSend, inputRef, text, onText, attachments, attachmentActions }: Props) { +export function DraftView({ draft, projects, creating, models, modelsError, onRetryModels, local, branch, onChange, onSend, inputRef, text, onText, attachments, attachmentActions }: Props) { const project = projects.find((p) => p.id === draft.projectId); const set = (patch: Partial) => onChange({ ...draft, settings: { ...draft.settings, ...patch } }); if (!project) return null; @@ -75,6 +78,7 @@ export function DraftView({ draft, projects, creating, models, modelsError, onRe auto={draft.settings.auto} models={models} modelsError={modelsError} + local={local} onRetryModels={onRetryModels} onBackend={(backend) => set({ backend, model: "", effort: undefined })} onMode={(mode) => set({ mode })} diff --git a/apps/desktop/src/renderer/components/LocalModelsSettings.tsx b/apps/desktop/src/renderer/components/LocalModelsSettings.tsx new file mode 100644 index 00000000..0f9a77db --- /dev/null +++ b/apps/desktop/src/renderer/components/LocalModelsSettings.tsx @@ -0,0 +1,159 @@ +import { useEffect, useState } from "react"; +import type { LocalConnectionView, LocalModelDescriptor, LocalRuntime } from "../../shared/local-connections"; +import { FAILURE_WORDS, RUNTIME_DEFAULTS, RUNTIME_NAMES, addConnection, cancelDiscovery, discover, discoverAll, entryModels, removeConnection, tokensLabel, updateConnection, useLocalModels, type LocalEntry } from "../local-models"; +import { Icon } from "./ui/Icon"; + +/** + * Settings → Local models (LM-4, #381): connect a model server running on this Mac, list its models, + * and remove it. Every change applies at once through main, like an account sign-in, not on Save. + * Listing reads metadata only. Nothing here loads a model, downloads one or runs inference; a model is + * used only when a person picks it for a thread and sends a message. + */ +export function LocalModelsSettings() { + const local = useLocalModels(); + const [runtime, setRuntime] = useState("lmstudio"); + const [label, setLabel] = useState(RUNTIME_DEFAULTS.lmstudio.label); + const [url, setUrl] = useState(RUNTIME_DEFAULTS.lmstudio.url); + const [token, setToken] = useState(""); + const [manualModels, setManualModels] = useState(""); + const [busy, setBusy] = useState(false); + const [feedback, setFeedback] = useState(""); + const [adding, setAdding] = useState(false); + + useEffect(() => { void discoverAll(10_000); }, [local.loaded]); + useEffect(() => { if (local.loaded && !local.entries.length) setAdding(true); }, [local.loaded, local.entries.length]); + + const chooseRuntime = (next: LocalRuntime) => { + // Replace the defaults only while the person has not typed their own. + if (label === RUNTIME_DEFAULTS[runtime].label) setLabel(RUNTIME_DEFAULTS[next].label); + if (url === RUNTIME_DEFAULTS[runtime].url) setUrl(RUNTIME_DEFAULTS[next].url); + setRuntime(next); + }; + const run = async (fn: () => Promise) => { + setBusy(true); setFeedback(""); + try { setFeedback(await fn()); } catch (error) { setFeedback((error as Error).message); } finally { setBusy(false); } + }; + const add = () => void run(async () => { + await addConnection({ label: label.trim(), runtime, url: url.trim(), manualModels: manualModels.split("\n").filter(Boolean), ...(token ? { token } : {}) }); + setToken(""); setAdding(false); + return `${label.trim()} added. Checking its models…`; + }); + + return ( +
+
+
+

Local models

+

Connect Ollama, LM Studio or another compatible model server. Coding support is qualified separately for each configuration.

+
+ +
+

Changes here apply immediately. Checking models reads metadata only and does not load a model. Modex never downloads models. Your first message may make the server load one, which can take time and use a lot of memory. Local coding execution is unavailable until its qualification and admission gates pass.

+ {local.error &&

Local models are unavailable: {local.error}

} +
+ {local.entries.map(entry => )} +
+ {adding ? ( +
{ event.preventDefault(); add(); }}> +

Add a model server

+ + + + +