diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a6462fd1..969b3809 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.15.116", + "version": "0.15.117", "description": "ClosedLoop Desktop", "author": "ClosedLoop AI ", "private": true, diff --git a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts index 2a6bf1d1..05804028 100644 --- a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts +++ b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts @@ -33,7 +33,6 @@ const DESIGN_SYSTEM_DB_IPC_CHANNELS = [ ] as const; export interface AgentDashboardDesignSystemRuntimeOptions { - getSandboxBaseDirectory: () => string; getWindow: () => BrowserWindow | null; onTerminalFailure: (reason: string) => void; userDataPath?: string; @@ -90,7 +89,6 @@ export function createAgentDashboardDesignSystemRuntime( const hookListener = new AgentHookListener({ lifecycle, - getSandboxBaseDirectory: options.getSandboxBaseDirectory, log: (message: string) => log("agent-monitor-listener", message), onBindError: options.onTerminalFailure, }); @@ -98,7 +96,6 @@ export function createAgentDashboardDesignSystemRuntime( const collectorManager = new CollectorManager({ agentDatabase, detectBillingMode, - getSandboxBaseDirectory: options.getSandboxBaseDirectory, stateDir: path.join(options.userDataPath ?? app.getPath("userData"), "agent-monitor"), emit: (sessionId?: string) => { options.getWindow()?.webContents.send("desktop:db:changed", { sessionId }); diff --git a/apps/desktop/src/main/agent-monitor-listener.ts b/apps/desktop/src/main/agent-monitor-listener.ts index f40f1b52..40ac87b8 100644 --- a/apps/desktop/src/main/agent-monitor-listener.ts +++ b/apps/desktop/src/main/agent-monitor-listener.ts @@ -3,7 +3,6 @@ import type { AddressInfo } from "node:net"; import type { IncomingMessage, ServerResponse } from "node:http"; import { z } from "zod"; import { AGENT_MONITOR_PORT } from "../shared/contracts.js"; -import { isSessionInSandbox } from "./agent-session-sync-service.js"; import type { createLifecycle, HookData } from "./database/lifecycle.js"; // CLOSEDLOOP-TICKET FEA-1500: remove legacy HTTP hook listener on 4820 after @@ -30,8 +29,6 @@ const HookEnvelopeSchema = z.object({ export interface AgentHookListenerOptions { /** The lifecycle processor that owns all DB writes. */ lifecycle: ReturnType; - /** FEA-1407 sandbox base directory (empty string ⇒ capture nothing). */ - getSandboxBaseDirectory: () => string; /** Key-free diagnostic sink (gatewayLog). */ log?: (message: string) => void; /** @@ -53,9 +50,9 @@ export interface AgentHookListenerOptions { * - `POST /api/hooks/codex/event` → Codex `{ hook_type, data }` * * Every request responds 200 fail-soft so a hook never blocks an agent turn. - * FEA-1407 sandbox gating is enforced BEFORE any DB write — with no sandbox - * configured, nothing is captured (fail-closed; do not ungate — defense in - * depth so out-of-sandbox sessions never enter the local DB). Provider + * Local import is ungated — all hook events are written to the local DB + * regardless of the sandbox directory. Sandbox enforcement is applied + * exclusively on the cloud-sync path in AgentSessionSyncService. Provider * attribution is route-owned; payload-level provider hints are rejected as * spoofable data before lifecycle writes or live DB-change emits. */ @@ -172,15 +169,6 @@ export class AgentHookListener { return; } - // FEA-1407: gate LOCAL capture on the sandbox BEFORE any write. Empty - // sandbox ⇒ isSessionInSandbox returns false ⇒ nothing is captured. - const sandboxBase = this.options.getSandboxBaseDirectory(); - const cwd = typeof data.cwd === "string" ? data.cwd : null; - if (!isSessionInSandbox(cwd, sandboxBase)) { - this.json(res, 200, { ok: true, skipped: "out-of-sandbox" }); - return; - } - this.options.lifecycle.processEvent(hookType, data, harness); this.json(res, 200, { ok: true }); } catch (error) { diff --git a/apps/desktop/src/main/app.ts b/apps/desktop/src/main/app.ts index a9f268ea..893c2492 100644 --- a/apps/desktop/src/main/app.ts +++ b/apps/desktop/src/main/app.ts @@ -1515,8 +1515,6 @@ export class DesktopApplication { this.agentDashboardDesignSystem = createAgentDashboardDesignSystemRuntime({ userDataPath: app.getPath("userData"), - getSandboxBaseDirectory: () => - this.settingsStore.getSandboxBaseDirectory(), getWindow: () => this.desktopWindow.getWindow(), onTerminalFailure: (reason) => { const notification = new Notification({ diff --git a/apps/desktop/src/main/collectors/collector-manager.ts b/apps/desktop/src/main/collectors/collector-manager.ts index ecbec10d..17ebfa2d 100644 --- a/apps/desktop/src/main/collectors/collector-manager.ts +++ b/apps/desktop/src/main/collectors/collector-manager.ts @@ -6,9 +6,9 @@ * `importSession` into the shared in-process DB. Started/stopped alongside the * hook listener via the `agentMonitorEnabled` toggle. * - * Gating (fail-closed, FEA-1407): every parsed session is checked against the - * sandbox base directory BEFORE any write — an out-of-sandbox `cwd` (or an empty - * sandbox) drops the session, exactly like the hook path. Do not ungate. + * Local import is ungated — all sessions from all five harnesses are imported + * into the local DB regardless of the sandbox directory. Sandbox enforcement + * is applied exclusively on the cloud-sync path in AgentSessionSyncService. * * Claude has a live hook path; its live watcher is therefore gated OFF when hooks * are installed (hooks own live capture — a concurrent file watcher would @@ -16,7 +16,6 @@ * against any hook-written events. */ import type { AgentDatabase } from "../database/index.js"; -import { isSessionInSandbox } from "../agent-session-sync-service.js"; import { createImporter, type Importer } from "./import-session.js"; import { createCatchupCache, type CatchupCache } from "./catchup-cache.js"; import { ingestCachePath, ingestOpencodeFingerprintPath } from "./ingest-paths.js"; @@ -32,8 +31,6 @@ export interface CollectorManagerOptions { agentDatabase: AgentDatabase; /** Resolve a billing mode for a harness at session creation (FEA-1434). */ detectBillingMode: (harness: string) => string; - /** FEA-1407 sandbox base directory (empty ⇒ capture nothing). Read live. */ - getSandboxBaseDirectory: () => string; /** Durable dir for persisted catchup caches (e.g. userData/agent-monitor). */ stateDir: string; /** Push a renderer live-update after an import batch wrote rows. */ @@ -124,10 +121,6 @@ export class CollectorManager { this.started = false; } - private gate(session: NormalizedSession): boolean { - return isSessionInSandbox(session.cwd, this.options.getSandboxBaseDirectory()); - } - private async runImportFor(collector: HarnessCollector): Promise { if (this.stopped) return; try { @@ -167,7 +160,6 @@ export class CollectorManager { for (const session of sessions) { if (this.stopped) break; - if (!this.gate(session)) continue; // FEA-1407 fail-closed const result = this.importer.importSession(session, collector.key); if (!(result.skipped && !result.reactivated)) imported++; } diff --git a/apps/desktop/test/agent-monitor-listener.test.ts b/apps/desktop/test/agent-monitor-listener.test.ts index cc7c78ea..6f011a60 100644 --- a/apps/desktop/test/agent-monitor-listener.test.ts +++ b/apps/desktop/test/agent-monitor-listener.test.ts @@ -64,7 +64,6 @@ interface ListenerDiagnostics { } async function withListener( - sandboxBaseRef: { value: string }, run: ( url: string, db: ReturnType, @@ -83,7 +82,6 @@ async function withListener( }); const listener = new AgentHookListener({ lifecycle, - getSandboxBaseDirectory: () => sandboxBaseRef.value, log: (message) => diagnostics.logs.push(message), port: 0, }); @@ -108,17 +106,15 @@ function assertNoWritesOrEmits( } test("listener: GET /api/health returns 200 ok", async () => { - const sandbox = { value: "/work" }; - await withListener(sandbox, async (url) => { + await withListener(async (url) => { const res = await request(`${url}/api/health`, "GET"); assert.equal(res.status, 200); assert.deepEqual(res.body, { ok: true }); }); }); -test("listener: in-sandbox SessionStart writes a session with harness=claude", async () => { - const sandbox = { value: "/work" }; - await withListener(sandbox, async (url, db, diagnostics) => { +test("listener: SessionStart writes a session with harness=claude", async () => { + await withListener(async (url, db, diagnostics) => { const res = await request(`${url}/api/hooks/event`, "POST", { hook_type: "SessionStart", data: { session_id: "s1", cwd: "/work/project" }, @@ -132,8 +128,7 @@ test("listener: in-sandbox SessionStart writes a session with harness=claude", a }); test("listener: Codex route stamps harness=codex without payload provider hint", async () => { - const sandbox = { value: "/work" }; - await withListener(sandbox, async (url, db, diagnostics) => { + await withListener(async (url, db, diagnostics) => { const res = await request(`${url}/api/hooks/codex/event`, "POST", { hook_type: "SessionStart", data: { session_id: "cx1", cwd: "/work/project" }, @@ -145,8 +140,7 @@ test("listener: Codex route stamps harness=codex without payload provider hint", }); test("listener: payload provider hints are rejected before writes on every hook route", async () => { - const sandbox = { value: "/work" }; - await withListener(sandbox, async (url, db, diagnostics) => { + await withListener(async (url, db, diagnostics) => { for (const route of ["/api/hooks/event", "/api/hooks/codex/event"]) { const res = await request(`${url}${route}`, "POST", { hook_type: "SessionStart", @@ -160,8 +154,7 @@ test("listener: payload provider hints are rejected before writes on every hook }); test("listener: malformed, invalid, and oversized payloads fail soft without writes", async () => { - const sandbox = { value: "/work" }; - await withListener(sandbox, async (url, db, diagnostics) => { + await withListener(async (url, db, diagnostics) => { const malformed = await requestRaw( `${url}/api/hooks/event`, "POST", @@ -198,28 +191,16 @@ test("listener: malformed, invalid, and oversized payloads fail soft without wri }); }); -test("listener: out-of-sandbox event is dropped (no write)", async () => { - const sandbox = { value: "/work" }; - await withListener(sandbox, async (url, db, diagnostics) => { +test("listener: sessions from any directory are captured (no sandbox gating)", async () => { + await withListener(async (url, db, diagnostics) => { const res = await request(`${url}/api/hooks/event`, "POST", { hook_type: "SessionStart", - data: { session_id: "outside", cwd: "/somewhere/else" }, - }); - assert.equal(res.status, 200, "still acks so the hook never blocks"); - assert.deepEqual(res.body, { ok: true, skipped: "out-of-sandbox" }); - assertNoWritesOrEmits(db, diagnostics); - }); -}); - -test("listener: empty sandbox captures nothing (fail-closed)", async () => { - const sandbox = { value: "" }; - await withListener(sandbox, async (url, db, diagnostics) => { - const res = await request(`${url}/api/hooks/event`, "POST", { - hook_type: "SessionStart", - data: { session_id: "s1", cwd: "/work/project" }, + data: { session_id: "anywhere", cwd: "/somewhere/else" }, }); assert.equal(res.status, 200); - assert.deepEqual(res.body, { ok: true, skipped: "out-of-sandbox" }); - assertNoWritesOrEmits(db, diagnostics); + assert.deepEqual(res.body, { ok: true }); + const session = db.sessions.getById("anywhere"); + assert.ok(session, "session from any directory is imported"); + assert.deepEqual(diagnostics.emits, ["anywhere"]); }); }); diff --git a/apps/desktop/test/collectors-import.test.ts b/apps/desktop/test/collectors-import.test.ts index ae50f3c2..fb25da07 100644 --- a/apps/desktop/test/collectors-import.test.ts +++ b/apps/desktop/test/collectors-import.test.ts @@ -2,8 +2,8 @@ * @file collectors-import.test.ts * @description Tests the first-party importSession write-sink (FEA-1503): it * writes a NormalizedSession into the in-process repository, is idempotent on - * re-import (the headline acceptance criterion), and the CollectorManager applies - * FEA-1407 sandbox gating (fail-closed) before any write. + * re-import (the headline acceptance criterion), and the CollectorManager imports + * all sessions regardless of the sandbox directory. */ import assert from "node:assert/strict"; import { mkdtempSync, rmSync } from "node:fs"; @@ -14,6 +14,7 @@ import { test } from "node:test"; import { openAgentDatabase } from "../src/main/database/index.js"; import { createImporter } from "../src/main/collectors/import-session.js"; import { CollectorManager } from "../src/main/collectors/collector-manager.js"; +import { isSessionInSandbox } from "../src/main/agent-session-sync-service.js"; import type { HarnessCollector, NormalizedSession } from "../src/main/collectors/types.js"; const FIXED_NOW = "2024-03-09T17:00:00.000Z"; @@ -404,7 +405,7 @@ test("Agent/Task tool use creates an idempotent subagent row", () => { } }); -test("CollectorManager imports in-sandbox sessions and drops out-of-sandbox ones (FEA-1407)", async () => { +test("CollectorManager imports all sessions regardless of sandbox", async () => { const { db, dir, cleanup } = openTempDb(); try { let emitted = 0; @@ -423,7 +424,6 @@ test("CollectorManager imports in-sandbox sessions and drops out-of-sandbox ones const manager = new CollectorManager({ agentDatabase: db, detectBillingMode: () => "api", - getSandboxBaseDirectory: () => "/sandbox", stateDir: dir, emit: () => { emitted++; }, shouldWatchClaude: () => false, @@ -437,14 +437,14 @@ test("CollectorManager imports in-sandbox sessions and drops out-of-sandbox ones manager.stop(); assert.ok(db.sessions.getById("in"), "in-sandbox session is imported"); - assert.equal(db.sessions.getById("out"), undefined, "out-of-sandbox session is dropped"); + assert.ok(db.sessions.getById("out"), "out-of-sandbox session is also imported"); assert.ok(emitted > 0, "emits a live-update signal after writing"); } finally { cleanup(); } }); -test("CollectorManager with an empty sandbox captures nothing (fail-closed)", async () => { +test("CollectorManager imports sessions even with empty sandbox", async () => { const { db, dir, cleanup } = openTempDb(); try { const fakeCollector: HarnessCollector = { @@ -459,7 +459,6 @@ test("CollectorManager with an empty sandbox captures nothing (fail-closed)", as const manager = new CollectorManager({ agentDatabase: db, detectBillingMode: () => "api", - getSandboxBaseDirectory: () => "", // empty ⇒ capture nothing stateDir: dir, emit: () => {}, shouldWatchClaude: () => false, @@ -471,8 +470,18 @@ test("CollectorManager with an empty sandbox captures nothing (fail-closed)", as await new Promise((resolve) => setTimeout(resolve, 50)); manager.stop(); - assert.equal(db.sessions.getAll().length, 0, "empty sandbox ⇒ nothing captured"); + assert.equal(db.sessions.getAll().length, 1, "sessions captured regardless of sandbox"); } finally { cleanup(); } }); + +test("sync-service isSessionInSandbox still filters out-of-sandbox sessions (regression)", () => { + // The cloud sync path in AgentSessionSyncService uses isSessionInSandbox + // independently of the local import path. Verify it still gates correctly. + assert.equal(isSessionInSandbox("/sandbox/proj", "/sandbox"), true); + assert.equal(isSessionInSandbox("/elsewhere/proj", "/sandbox"), false); + assert.equal(isSessionInSandbox(null, "/sandbox"), false); + assert.equal(isSessionInSandbox("/sandbox/proj", ""), false); + assert.equal(isSessionInSandbox("/sandbox/proj", null), false); +});