Skip to content
Merged
2 changes: 2 additions & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export type AdeRuntimeSyncOptions = {
phonePairingStateDir?: string;
projectCatalogProvider?: Parameters<typeof createSyncService>[0]["projectCatalogProvider"];
rosterProvider?: Parameters<typeof createSyncService>[0]["rosterProvider"];
foreignChatProvider?: Parameters<typeof createSyncService>[0]["foreignChatProvider"];
remoteCommandExecutor?: Parameters<typeof createSyncService>[0]["remoteCommandExecutor"];
/**
* Brain-level websocket listener shared by every project scope's sync host
Expand Down Expand Up @@ -1239,6 +1240,7 @@ export async function createAdeRuntime(args: {
forceHostRole: resolvedArgs.syncRuntime.forceHostRole ?? false,
projectCatalogProvider: resolvedArgs.syncRuntime.projectCatalogProvider,
rosterProvider: resolvedArgs.syncRuntime.rosterProvider,
foreignChatProvider: resolvedArgs.syncRuntime.foreignChatProvider,
remoteCommandExecutor: resolvedArgs.syncRuntime.remoteCommandExecutor,
getModelPickerStore: () => getSharedModelPickerStore(db),
onStatusChanged: (snapshot) => pushEvent("runtime", { type: "sync-status", snapshot }),
Expand Down
7 changes: 6 additions & 1 deletion apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13205,7 +13205,7 @@ async function runServe(
{ createSharedSyncListener },
{ resolveMobileProjectIconDataUrl },
{ createBrainProjectActionsSyncHandler },
{ buildRosterSnapshot },
{ buildRosterSnapshot, createForeignChatTranscriptResolver },
] = await Promise.all([
import("./services/projects/machineLayout"),
import("./services/projects/projectRegistry"),
Expand Down Expand Up @@ -13585,6 +13585,11 @@ async function runServe(
logger: headlessProjectLogger,
}),
},
// Cross-project chat "quick look": lets the phone stream a foreign
// project's chat transcript read-only without a project switch. Reads
// straight off that project's `.ade` transcripts dir (registry-validated,
// no runtime boot) — the counterpart to the roster feed above.
foreignChatProvider: createForeignChatTranscriptResolver({ projectRegistry }),
},
});
const previousRole = process.env.ADE_DEFAULT_ROLE;
Expand Down
22 changes: 22 additions & 0 deletions apps/ade-cli/src/services/sync/rosterBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createRequire } from "node:module";
import type { DatabaseSync as DatabaseSyncType } from "node:sqlite";
import {
buildRosterSnapshot,
createForeignChatTranscriptResolver,
type RosterBootedScope,
type RosterLiveSession,
type RosterScopeRegistry,
Expand Down Expand Up @@ -188,6 +189,27 @@ describe("buildRosterSnapshot", () => {
expect(project.runningCount).toBe(1);
});

it("resolves a registered foreign chat transcript path and rejects unsafe input", () => {
const resolver = createForeignChatTranscriptResolver({ projectRegistry });
const expectedDir = path.join(projectRoot, ".ade", "transcripts", "chat");

// Registered project + safe session id → path inside the transcripts dir.
expect(resolver.resolveTranscriptPath({ projectId: PROJECT_ID, sessionId: "chat-run" }))
.toBe(path.join(expectedDir, "chat-run.jsonl"));
// Resolvable by rootPath too.
expect(resolver.resolveTranscriptPath({ projectRootPath: projectRoot, sessionId: "chat-run" }))
.toBe(path.join(expectedDir, "chat-run.jsonl"));

// Unknown project → null (not registered).
expect(resolver.resolveTranscriptPath({ projectId: "project_unknown", sessionId: "chat-run" })).toBeNull();
// No project reference → null.
expect(resolver.resolveTranscriptPath({ sessionId: "chat-run" })).toBeNull();
// Path-traversal / unsafe session ids → null (never touches the filesystem).
expect(resolver.resolveTranscriptPath({ projectId: PROJECT_ID, sessionId: "../../etc/passwd" })).toBeNull();
expect(resolver.resolveTranscriptPath({ projectId: PROJECT_ID, sessionId: "a/b" })).toBeNull();
expect(resolver.resolveTranscriptPath({ projectId: PROJECT_ID, sessionId: "" })).toBeNull();
});

it("tolerates a project with no ADE database (empty lanes/chats)", async () => {
const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-roster-empty-"));
try {
Expand Down
57 changes: 57 additions & 0 deletions apps/ade-cli/src/services/sync/rosterBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
SyncRosterProject,
} from "../../../../desktop/src/shared/types";
import type { Logger } from "../../../../desktop/src/main/services/logging/logger";
import type { SyncForeignChatTranscriptResolver } from "./syncHostService";

// Anchor builtin resolution to the active runtime, mirroring kvDb / the cheap
// cross-project read in recentProjectSummary.ts. The roster opens each
Expand Down Expand Up @@ -395,6 +396,62 @@ async function buildRosterProject(
};
}

// --- Cross-project "quick look" transcript resolver --------------------------

export type ForeignChatProjectRegistry = {
list(): { projectId: string; rootPath: string }[];
};

// A chat session id is a filename segment: reject anything that could escape
// the transcripts dir (path separators, `..`, nul) before it ever touches the
// filesystem. Real ids are UUID/nanoid-shaped.
const SAFE_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;

/**
* Builds a resolver that maps (registered foreign project, sessionId) to that
* session's on-disk chat transcript JSONL path — the security boundary for
* cross-project chat streaming. It ONLY resolves projects present in the
* registry and ONLY returns paths confined to that project's `.ade`
* transcripts dir; everything else yields null. Never boots a runtime.
*/
export function createForeignChatTranscriptResolver(args: {
projectRegistry: ForeignChatProjectRegistry;
}): SyncForeignChatTranscriptResolver {
return {
resolveTranscriptPath: ({ projectId, projectRootPath, sessionId }) => {
const safeSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
if (!SAFE_SESSION_ID.test(safeSessionId)) return null;

const requestedProjectId = typeof projectId === "string" ? projectId.trim() : "";
const requestedRootPath = normalizePath(projectRootPath);
const records = args.projectRegistry.list();
const record = records.find((entry) => {
if (requestedProjectId && entry.projectId === requestedProjectId) return true;
if (requestedRootPath && normalizePath(entry.rootPath) === requestedRootPath) return true;
return false;
});
if (!record) return null;

const layout = resolveAdeLayout(record.rootPath);
const chatTranscriptsDir = path.resolve(layout.chatTranscriptsDir);
const legacyTranscriptsDir = path.resolve(layout.transcriptsDir);
// Mirror the chat service's candidate order: the dedicated chat dir
// first, then the legacy `<transcripts>/<id>.chat.jsonl` location that
// older/restored sessions may still be writing. Defense in depth: a
// validated session id already can't traverse, but confirm each
// resolved path stays inside its transcripts dir.
const dedicatedPath = path.resolve(path.join(chatTranscriptsDir, `${safeSessionId}.jsonl`));
if (!dedicatedPath.startsWith(chatTranscriptsDir + path.sep)) return null;
const legacyPath = path.resolve(path.join(legacyTranscriptsDir, `${safeSessionId}.chat.jsonl`));
if (!legacyPath.startsWith(legacyTranscriptsDir + path.sep)) return null;
if (!fs.existsSync(dedicatedPath) && fs.existsSync(legacyPath)) {
return legacyPath;
}
return dedicatedPath;
},
};
}

/**
* Build the all-projects chat roster: every registered project's lanes + chat
* sessions, sourced cheaply from disk, with live status overlaid for any
Expand Down
2 changes: 2 additions & 0 deletions apps/ade-cli/src/services/sync/syncHostService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ describe("buildSyncHostHelloOkPayload", () => {
projectCatalog: { projects: [project] },
projectCatalogEnabled: true,
projectActionsEnabled: false,
crossProjectChatEnabled: true,
remoteCommandSupportedActions: [remoteCommand.action],
remoteCommandDescriptors: [remoteCommand],
localCommandDescriptors: [localPresenceCommand],
Expand All @@ -211,6 +212,7 @@ describe("buildSyncHostHelloOkPayload", () => {
expect(payload.projects).toEqual([project]);
expect(payload.features.projectCatalog).toEqual({ enabled: true });
expect(payload.features.projectActions).toEqual({ enabled: false });
expect(payload.features.crossProjectChat).toEqual({ enabled: true });
expect(payload.features.fileAccess).toBe(true);
expect(payload.features.terminalStreaming).toBe(true);
expect(payload.features.chatStreaming).toEqual({ enabled: true });
Expand Down
Loading
Loading