Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.

Commit 4d5a674

Browse files
authored
Merge pull request #278 from closedloop-ai/PLN-821-ungate-local-import
PLN-821: Ungate local dashboard import from sandbox scoping
2 parents 013df19 + a7cb10e commit 4d5a674

7 files changed

Lines changed: 37 additions & 72 deletions

File tree

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "desktop",
3-
"version": "0.15.116",
3+
"version": "0.15.117",
44
"description": "ClosedLoop Desktop",
55
"author": "ClosedLoop AI <support@closedloop.ai>",
66
"private": true,

apps/desktop/src/main/agent-dashboard-design-system-runtime.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ const DESIGN_SYSTEM_DB_IPC_CHANNELS = [
3333
] as const;
3434

3535
export interface AgentDashboardDesignSystemRuntimeOptions {
36-
getSandboxBaseDirectory: () => string;
3736
getWindow: () => BrowserWindow | null;
3837
onTerminalFailure: (reason: string) => void;
3938
userDataPath?: string;
@@ -90,15 +89,13 @@ export function createAgentDashboardDesignSystemRuntime(
9089

9190
const hookListener = new AgentHookListener({
9291
lifecycle,
93-
getSandboxBaseDirectory: options.getSandboxBaseDirectory,
9492
log: (message: string) => log("agent-monitor-listener", message),
9593
onBindError: options.onTerminalFailure,
9694
});
9795

9896
const collectorManager = new CollectorManager({
9997
agentDatabase,
10098
detectBillingMode,
101-
getSandboxBaseDirectory: options.getSandboxBaseDirectory,
10299
stateDir: path.join(options.userDataPath ?? app.getPath("userData"), "agent-monitor"),
103100
emit: (sessionId?: string) => {
104101
options.getWindow()?.webContents.send("desktop:db:changed", { sessionId });

apps/desktop/src/main/agent-monitor-listener.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type { AddressInfo } from "node:net";
33
import type { IncomingMessage, ServerResponse } from "node:http";
44
import { z } from "zod";
55
import { AGENT_MONITOR_PORT } from "../shared/contracts.js";
6-
import { isSessionInSandbox } from "./agent-session-sync-service.js";
76
import type { createLifecycle, HookData } from "./database/lifecycle.js";
87

98
// CLOSEDLOOP-TICKET FEA-1500: remove legacy HTTP hook listener on 4820 after
@@ -30,8 +29,6 @@ const HookEnvelopeSchema = z.object({
3029
export interface AgentHookListenerOptions {
3130
/** The lifecycle processor that owns all DB writes. */
3231
lifecycle: ReturnType<typeof createLifecycle>;
33-
/** FEA-1407 sandbox base directory (empty string ⇒ capture nothing). */
34-
getSandboxBaseDirectory: () => string;
3532
/** Key-free diagnostic sink (gatewayLog). */
3633
log?: (message: string) => void;
3734
/**
@@ -53,9 +50,9 @@ export interface AgentHookListenerOptions {
5350
* - `POST /api/hooks/codex/event` → Codex `{ hook_type, data }`
5451
*
5552
* Every request responds 200 fail-soft so a hook never blocks an agent turn.
56-
* FEA-1407 sandbox gating is enforced BEFORE any DB write — with no sandbox
57-
* configured, nothing is captured (fail-closed; do not ungate — defense in
58-
* depth so out-of-sandbox sessions never enter the local DB). Provider
53+
* Local import is ungated — all hook events are written to the local DB
54+
* regardless of the sandbox directory. Sandbox enforcement is applied
55+
* exclusively on the cloud-sync path in AgentSessionSyncService. Provider
5956
* attribution is route-owned; payload-level provider hints are rejected as
6057
* spoofable data before lifecycle writes or live DB-change emits.
6158
*/
@@ -172,15 +169,6 @@ export class AgentHookListener {
172169
return;
173170
}
174171

175-
// FEA-1407: gate LOCAL capture on the sandbox BEFORE any write. Empty
176-
// sandbox ⇒ isSessionInSandbox returns false ⇒ nothing is captured.
177-
const sandboxBase = this.options.getSandboxBaseDirectory();
178-
const cwd = typeof data.cwd === "string" ? data.cwd : null;
179-
if (!isSessionInSandbox(cwd, sandboxBase)) {
180-
this.json(res, 200, { ok: true, skipped: "out-of-sandbox" });
181-
return;
182-
}
183-
184172
this.options.lifecycle.processEvent(hookType, data, harness);
185173
this.json(res, 200, { ok: true });
186174
} catch (error) {

apps/desktop/src/main/app.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,8 +1515,6 @@ export class DesktopApplication {
15151515
this.agentDashboardDesignSystem =
15161516
createAgentDashboardDesignSystemRuntime({
15171517
userDataPath: app.getPath("userData"),
1518-
getSandboxBaseDirectory: () =>
1519-
this.settingsStore.getSandboxBaseDirectory(),
15201518
getWindow: () => this.desktopWindow.getWindow(),
15211519
onTerminalFailure: (reason) => {
15221520
const notification = new Notification({

apps/desktop/src/main/collectors/collector-manager.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,16 @@
66
* `importSession` into the shared in-process DB. Started/stopped alongside the
77
* hook listener via the `agentMonitorEnabled` toggle.
88
*
9-
* Gating (fail-closed, FEA-1407): every parsed session is checked against the
10-
* sandbox base directory BEFORE any write — an out-of-sandbox `cwd` (or an empty
11-
* sandbox) drops the session, exactly like the hook path. Do not ungate.
9+
* Local import is ungated — all sessions from all five harnesses are imported
10+
* into the local DB regardless of the sandbox directory. Sandbox enforcement
11+
* is applied exclusively on the cloud-sync path in AgentSessionSyncService.
1212
*
1313
* Claude has a live hook path; its live watcher is therefore gated OFF when hooks
1414
* are installed (hooks own live capture — a concurrent file watcher would
1515
* double-count turns). Claude boot historical import still runs and is idempotent
1616
* against any hook-written events.
1717
*/
1818
import type { AgentDatabase } from "../database/index.js";
19-
import { isSessionInSandbox } from "../agent-session-sync-service.js";
2019
import { createImporter, type Importer } from "./import-session.js";
2120
import { createCatchupCache, type CatchupCache } from "./catchup-cache.js";
2221
import { ingestCachePath, ingestOpencodeFingerprintPath } from "./ingest-paths.js";
@@ -32,8 +31,6 @@ export interface CollectorManagerOptions {
3231
agentDatabase: AgentDatabase;
3332
/** Resolve a billing mode for a harness at session creation (FEA-1434). */
3433
detectBillingMode: (harness: string) => string;
35-
/** FEA-1407 sandbox base directory (empty ⇒ capture nothing). Read live. */
36-
getSandboxBaseDirectory: () => string;
3734
/** Durable dir for persisted catchup caches (e.g. userData/agent-monitor). */
3835
stateDir: string;
3936
/** Push a renderer live-update after an import batch wrote rows. */
@@ -124,10 +121,6 @@ export class CollectorManager {
124121
this.started = false;
125122
}
126123

127-
private gate(session: NormalizedSession): boolean {
128-
return isSessionInSandbox(session.cwd, this.options.getSandboxBaseDirectory());
129-
}
130-
131124
private async runImportFor(collector: HarnessCollector): Promise<void> {
132125
if (this.stopped) return;
133126
try {
@@ -167,7 +160,6 @@ export class CollectorManager {
167160

168161
for (const session of sessions) {
169162
if (this.stopped) break;
170-
if (!this.gate(session)) continue; // FEA-1407 fail-closed
171163
const result = this.importer.importSession(session, collector.key);
172164
if (!(result.skipped && !result.reactivated)) imported++;
173165
}

apps/desktop/test/agent-monitor-listener.test.ts

Lines changed: 13 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ interface ListenerDiagnostics {
6464
}
6565

6666
async function withListener(
67-
sandboxBaseRef: { value: string },
6867
run: (
6968
url: string,
7069
db: ReturnType<typeof openAgentDatabase>,
@@ -83,7 +82,6 @@ async function withListener(
8382
});
8483
const listener = new AgentHookListener({
8584
lifecycle,
86-
getSandboxBaseDirectory: () => sandboxBaseRef.value,
8785
log: (message) => diagnostics.logs.push(message),
8886
port: 0,
8987
});
@@ -108,17 +106,15 @@ function assertNoWritesOrEmits(
108106
}
109107

110108
test("listener: GET /api/health returns 200 ok", async () => {
111-
const sandbox = { value: "/work" };
112-
await withListener(sandbox, async (url) => {
109+
await withListener(async (url) => {
113110
const res = await request(`${url}/api/health`, "GET");
114111
assert.equal(res.status, 200);
115112
assert.deepEqual(res.body, { ok: true });
116113
});
117114
});
118115

119-
test("listener: in-sandbox SessionStart writes a session with harness=claude", async () => {
120-
const sandbox = { value: "/work" };
121-
await withListener(sandbox, async (url, db, diagnostics) => {
116+
test("listener: SessionStart writes a session with harness=claude", async () => {
117+
await withListener(async (url, db, diagnostics) => {
122118
const res = await request(`${url}/api/hooks/event`, "POST", {
123119
hook_type: "SessionStart",
124120
data: { session_id: "s1", cwd: "/work/project" },
@@ -132,8 +128,7 @@ test("listener: in-sandbox SessionStart writes a session with harness=claude", a
132128
});
133129

134130
test("listener: Codex route stamps harness=codex without payload provider hint", async () => {
135-
const sandbox = { value: "/work" };
136-
await withListener(sandbox, async (url, db, diagnostics) => {
131+
await withListener(async (url, db, diagnostics) => {
137132
const res = await request(`${url}/api/hooks/codex/event`, "POST", {
138133
hook_type: "SessionStart",
139134
data: { session_id: "cx1", cwd: "/work/project" },
@@ -145,8 +140,7 @@ test("listener: Codex route stamps harness=codex without payload provider hint",
145140
});
146141

147142
test("listener: payload provider hints are rejected before writes on every hook route", async () => {
148-
const sandbox = { value: "/work" };
149-
await withListener(sandbox, async (url, db, diagnostics) => {
143+
await withListener(async (url, db, diagnostics) => {
150144
for (const route of ["/api/hooks/event", "/api/hooks/codex/event"]) {
151145
const res = await request(`${url}${route}`, "POST", {
152146
hook_type: "SessionStart",
@@ -160,8 +154,7 @@ test("listener: payload provider hints are rejected before writes on every hook
160154
});
161155

162156
test("listener: malformed, invalid, and oversized payloads fail soft without writes", async () => {
163-
const sandbox = { value: "/work" };
164-
await withListener(sandbox, async (url, db, diagnostics) => {
157+
await withListener(async (url, db, diagnostics) => {
165158
const malformed = await requestRaw(
166159
`${url}/api/hooks/event`,
167160
"POST",
@@ -198,28 +191,16 @@ test("listener: malformed, invalid, and oversized payloads fail soft without wri
198191
});
199192
});
200193

201-
test("listener: out-of-sandbox event is dropped (no write)", async () => {
202-
const sandbox = { value: "/work" };
203-
await withListener(sandbox, async (url, db, diagnostics) => {
194+
test("listener: sessions from any directory are captured (no sandbox gating)", async () => {
195+
await withListener(async (url, db, diagnostics) => {
204196
const res = await request(`${url}/api/hooks/event`, "POST", {
205197
hook_type: "SessionStart",
206-
data: { session_id: "outside", cwd: "/somewhere/else" },
207-
});
208-
assert.equal(res.status, 200, "still acks so the hook never blocks");
209-
assert.deepEqual(res.body, { ok: true, skipped: "out-of-sandbox" });
210-
assertNoWritesOrEmits(db, diagnostics);
211-
});
212-
});
213-
214-
test("listener: empty sandbox captures nothing (fail-closed)", async () => {
215-
const sandbox = { value: "" };
216-
await withListener(sandbox, async (url, db, diagnostics) => {
217-
const res = await request(`${url}/api/hooks/event`, "POST", {
218-
hook_type: "SessionStart",
219-
data: { session_id: "s1", cwd: "/work/project" },
198+
data: { session_id: "anywhere", cwd: "/somewhere/else" },
220199
});
221200
assert.equal(res.status, 200);
222-
assert.deepEqual(res.body, { ok: true, skipped: "out-of-sandbox" });
223-
assertNoWritesOrEmits(db, diagnostics);
201+
assert.deepEqual(res.body, { ok: true });
202+
const session = db.sessions.getById("anywhere");
203+
assert.ok(session, "session from any directory is imported");
204+
assert.deepEqual(diagnostics.emits, ["anywhere"]);
224205
});
225206
});

apps/desktop/test/collectors-import.test.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
* @file collectors-import.test.ts
33
* @description Tests the first-party importSession write-sink (FEA-1503): it
44
* writes a NormalizedSession into the in-process repository, is idempotent on
5-
* re-import (the headline acceptance criterion), and the CollectorManager applies
6-
* FEA-1407 sandbox gating (fail-closed) before any write.
5+
* re-import (the headline acceptance criterion), and the CollectorManager imports
6+
* all sessions regardless of the sandbox directory.
77
*/
88
import assert from "node:assert/strict";
99
import { mkdtempSync, rmSync } from "node:fs";
@@ -14,6 +14,7 @@ import { test } from "node:test";
1414
import { openAgentDatabase } from "../src/main/database/index.js";
1515
import { createImporter } from "../src/main/collectors/import-session.js";
1616
import { CollectorManager } from "../src/main/collectors/collector-manager.js";
17+
import { isSessionInSandbox } from "../src/main/agent-session-sync-service.js";
1718
import type { HarnessCollector, NormalizedSession } from "../src/main/collectors/types.js";
1819

1920
const FIXED_NOW = "2024-03-09T17:00:00.000Z";
@@ -404,7 +405,7 @@ test("Agent/Task tool use creates an idempotent subagent row", () => {
404405
}
405406
});
406407

407-
test("CollectorManager imports in-sandbox sessions and drops out-of-sandbox ones (FEA-1407)", async () => {
408+
test("CollectorManager imports all sessions regardless of sandbox", async () => {
408409
const { db, dir, cleanup } = openTempDb();
409410
try {
410411
let emitted = 0;
@@ -423,7 +424,6 @@ test("CollectorManager imports in-sandbox sessions and drops out-of-sandbox ones
423424
const manager = new CollectorManager({
424425
agentDatabase: db,
425426
detectBillingMode: () => "api",
426-
getSandboxBaseDirectory: () => "/sandbox",
427427
stateDir: dir,
428428
emit: () => { emitted++; },
429429
shouldWatchClaude: () => false,
@@ -437,14 +437,14 @@ test("CollectorManager imports in-sandbox sessions and drops out-of-sandbox ones
437437
manager.stop();
438438

439439
assert.ok(db.sessions.getById("in"), "in-sandbox session is imported");
440-
assert.equal(db.sessions.getById("out"), undefined, "out-of-sandbox session is dropped");
440+
assert.ok(db.sessions.getById("out"), "out-of-sandbox session is also imported");
441441
assert.ok(emitted > 0, "emits a live-update signal after writing");
442442
} finally {
443443
cleanup();
444444
}
445445
});
446446

447-
test("CollectorManager with an empty sandbox captures nothing (fail-closed)", async () => {
447+
test("CollectorManager imports sessions even with empty sandbox", async () => {
448448
const { db, dir, cleanup } = openTempDb();
449449
try {
450450
const fakeCollector: HarnessCollector = {
@@ -459,7 +459,6 @@ test("CollectorManager with an empty sandbox captures nothing (fail-closed)", as
459459
const manager = new CollectorManager({
460460
agentDatabase: db,
461461
detectBillingMode: () => "api",
462-
getSandboxBaseDirectory: () => "", // empty ⇒ capture nothing
463462
stateDir: dir,
464463
emit: () => {},
465464
shouldWatchClaude: () => false,
@@ -471,8 +470,18 @@ test("CollectorManager with an empty sandbox captures nothing (fail-closed)", as
471470
await new Promise((resolve) => setTimeout(resolve, 50));
472471
manager.stop();
473472

474-
assert.equal(db.sessions.getAll().length, 0, "empty sandbox ⇒ nothing captured");
473+
assert.equal(db.sessions.getAll().length, 1, "sessions captured regardless of sandbox");
475474
} finally {
476475
cleanup();
477476
}
478477
});
478+
479+
test("sync-service isSessionInSandbox still filters out-of-sandbox sessions (regression)", () => {
480+
// The cloud sync path in AgentSessionSyncService uses isSessionInSandbox
481+
// independently of the local import path. Verify it still gates correctly.
482+
assert.equal(isSessionInSandbox("/sandbox/proj", "/sandbox"), true);
483+
assert.equal(isSessionInSandbox("/elsewhere/proj", "/sandbox"), false);
484+
assert.equal(isSessionInSandbox(null, "/sandbox"), false);
485+
assert.equal(isSessionInSandbox("/sandbox/proj", ""), false);
486+
assert.equal(isSessionInSandbox("/sandbox/proj", null), false);
487+
});

0 commit comments

Comments
 (0)