Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "desktop",
"version": "0.15.116",
"version": "0.15.117",
"description": "ClosedLoop Desktop",
"author": "ClosedLoop AI <support@closedloop.ai>",
"private": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -90,15 +89,13 @@ export function createAgentDashboardDesignSystemRuntime(

const hookListener = new AgentHookListener({
lifecycle,
getSandboxBaseDirectory: options.getSandboxBaseDirectory,
log: (message: string) => log("agent-monitor-listener", message),
onBindError: options.onTerminalFailure,
});

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 });
Expand Down
18 changes: 3 additions & 15 deletions apps/desktop/src/main/agent-monitor-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,8 +29,6 @@ const HookEnvelopeSchema = z.object({
export interface AgentHookListenerOptions {
/** The lifecycle processor that owns all DB writes. */
lifecycle: ReturnType<typeof createLifecycle>;
/** FEA-1407 sandbox base directory (empty string ⇒ capture nothing). */
getSandboxBaseDirectory: () => string;
/** Key-free diagnostic sink (gatewayLog). */
log?: (message: string) => void;
/**
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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);
Comment thread
mikeangstadt marked this conversation as resolved.
this.json(res, 200, { ok: true });
} catch (error) {
Expand Down
2 changes: 0 additions & 2 deletions apps/desktop/src/main/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
14 changes: 3 additions & 11 deletions apps/desktop/src/main/collectors/collector-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,16 @@
* `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
* double-count turns). Claude boot historical import still runs and is idempotent
* 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";
Expand All @@ -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. */
Expand Down Expand Up @@ -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<void> {
if (this.stopped) return;
try {
Expand Down Expand Up @@ -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++;
}
Expand Down
45 changes: 13 additions & 32 deletions apps/desktop/test/agent-monitor-listener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ interface ListenerDiagnostics {
}

async function withListener(
sandboxBaseRef: { value: string },
run: (
url: string,
db: ReturnType<typeof openAgentDatabase>,
Expand All @@ -83,7 +82,6 @@ async function withListener(
});
const listener = new AgentHookListener({
lifecycle,
getSandboxBaseDirectory: () => sandboxBaseRef.value,
log: (message) => diagnostics.logs.push(message),
port: 0,
});
Expand All @@ -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" },
Expand All @@ -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" },
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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"]);
});
});
25 changes: 17 additions & 8 deletions apps/desktop/test/collectors-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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 = {
Expand All @@ -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,
Expand All @@ -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);
});
Loading