Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Utilities (run when relevant, not part of the core loop): **/audit** (targeted b
- Keep IPC contracts, preload types, shared types, and renderer usage in sync whenever an interface changes.
- For ADE CLI changes, verify both headless mode and the desktop socket-backed ADE RPC path.
- For computer-use changes, treat policy enforcement and artifact ownership as hard requirements, not prompt guidance.
- `ade search "<query>" --text` searches everything in ADE (chats, terminal scrollback, PRs, commits, branches, lanes, files, Linear) instead of grepping `.ade/` internals; see the ade-search skill.

## Validation

Expand Down
3 changes: 3 additions & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,9 @@ ade history show --id operation-id --text
ade history commits --lane lane-id --text
ade history export --lane lane-id --out history.json
ade diff patch --lane lane-id --path src/file.ts --text
ade search "login redirect" --text # full-text search across chats, terminals, PRs, commits, lanes, files, Linear
ade search "flaky test" --kind chat,terminal --lane fix-login --text # exit 1 when nothing matches
ade search --status --text # index doc counts, backfill state, index path
ade prs create --lane lane-id --base main --title "Fix checkout flow" --text # prints GitHub + ADE PR URLs
ade prs create --lane lane-id --base main --close-linear-issue-on-merge
ade prs list-open --text
Expand Down
87 changes: 87 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3861,3 +3861,90 @@ describe("adeRpcServer", () => {
});
});
});

describe("run_ade_action search scoping", () => {
const searchServiceMock = () => ({
query: vi.fn(async (args: unknown) => ({ results: [], totalByKind: {}, nextCursor: null, receivedArgs: args })),
indexStatus: vi.fn(() => ({ ready: true })),
rebuildIndex: vi.fn(() => ({ started: true })),
});

it("injects the caller's own session scope for a session-bound agent", async () => {
const fixture = createRuntime();
const search = searchServiceMock();
(fixture.runtime as Record<string, unknown>).searchService = search;
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
await initialize(handler, { callerId: "agent-1", role: "agent", chatSessionId: "session-1" });

const response = await callTool(handler, "run_ade_action", {
domain: "search",
action: "query",
args: { query: "kind:chat secrets", callerScope: { chatSessionId: "someone-else" } },
});
expect(response?.isError).toBeUndefined();
expect(search.query).toHaveBeenCalledTimes(1);
const args = search.query.mock.calls[0]![0] as { query: string; callerScope?: Record<string, unknown> };
expect(args.query).toBe("kind:chat secrets");
// The gate overwrites any caller-supplied scope with the bound session.
expect(args.callerScope).toEqual({ chatSessionId: "session-1" });
});

it("excludes session content for an unbound agent-role caller", async () => {
const fixture = createRuntime();
const search = searchServiceMock();
(fixture.runtime as Record<string, unknown>).searchService = search;
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
await initialize(handler, { callerId: "agent-2", role: "agent" });

const response = await callTool(handler, "run_ade_action", {
domain: "search",
action: "query",
args: { query: "aws secret" },
});
expect(response?.isError).toBeUndefined();
const args = search.query.mock.calls[0]![0] as { callerScope?: Record<string, unknown> };
expect(args.callerScope).toEqual({ excludeSessionContent: true });
});

it("leaves an unbound external caller unscoped", async () => {
const fixture = createRuntime();
const search = searchServiceMock();
(fixture.runtime as Record<string, unknown>).searchService = search;
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
await initialize(handler, { callerId: "human-cli", role: "external" });

const response = await callTool(handler, "run_ade_action", {
domain: "search",
action: "query",
args: { query: "whole project" },
});
expect(response?.isError).toBeUndefined();
const args = search.query.mock.calls[0]![0] as { query: string; callerScope?: Record<string, unknown> };
expect(args.query).toBe("whole project");
expect(args.callerScope).toBeUndefined();
});

it("gates rebuildIndex to CTO role while allowing indexStatus for agents", async () => {
const fixture = createRuntime();
const search = searchServiceMock();
(fixture.runtime as Record<string, unknown>).searchService = search;
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
await initialize(handler, { callerId: "agent-3", role: "agent" });

const denied = await callTool(handler, "run_ade_action", {
domain: "search",
action: "rebuildIndex",
args: {},
});
expect(denied?.isError).toBe(true);
expect(search.rebuildIndex).not.toHaveBeenCalled();

const status = await callTool(handler, "run_ade_action", {
domain: "search",
action: "indexStatus",
args: {},
});
expect(status?.isError).toBeUndefined();
expect(search.indexStatus).toHaveBeenCalledTimes(1);
});
});
29 changes: 29 additions & 0 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2334,6 +2334,30 @@ function scopeChatAdeActionArgs(
return scopedArgs;
}

/**
* Universal search scoping for session-bound non-CTO callers: chat and
* terminal results are limited to the caller's own session, mirroring
* scopeChatAdeActionArgs / scopeTerminalAdeActionArgs on the direct read
* paths. Unbound callers (user CLI, desktop, CTO) keep whole-project search;
* pr/commit/branch/lane/file kinds are unaffected because those surfaces are
* already readable unscoped through their own actions.
*/
function scopeSearchAdeActionArgs(
session: SessionState,
searchArgs: Record<string, unknown>,
): Record<string, unknown> {
const callerChatSessionId = asOptionalTrimmedString(session.identity.chatSessionId);
if (!callerChatSessionId) {
// Mirror scopeChatAdeActionArgs' unbound-caller policy: an unbound
// external caller (user CLI / desktop) keeps whole-project search, while
// an unbound agent/orchestrator/evaluator — which chat.readTranscript
// would deny outright — gets no session content at all.
if (session.identity.role === "external") return searchArgs;
return { ...searchArgs, callerScope: { excludeSessionContent: true } };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat unbound CLI search as whole-project

For a normal ade search launched from a shell, the CLI initializes as role agent by default and usually has no ADE_CHAT_SESSION_ID, so this branch injects excludeSessionContent and strips all chat and terminal hits. That makes the documented user CLI search miss the primary indexed content unless users know to pass --role external; distinguish unbound human CLI callers from unbound agents or have the search command initialize as an external/user caller.

Useful? React with 👍 / 👎.

}
return { ...searchArgs, callerScope: { chatSessionId: callerChatSessionId } };
}

async function runCtoOperatorBridgeTool(
runtime: AdeRuntime,
session: SessionState,
Expand Down Expand Up @@ -3174,6 +3198,11 @@ async function runTool(args: {
action,
requireObjectArgsForScopedAdeAction(domain, action, argsList, hasScalarArg, rawObjectArgs),
);
} else if (!callerIsCto && domain === "search" && action === "query") {
scopedObjectArgs = scopeSearchAdeActionArgs(
session,
requireObjectArgsForScopedAdeAction(domain, action, argsList, hasScalarArg, rawObjectArgs),
);
}
if (domain === "lane" && action === "create" && !argsList && !hasScalarArg) {
// Same remote-first default as the `create_lane` tool and the sync
Expand Down
34 changes: 33 additions & 1 deletion apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { createConflictService } from "../../desktop/src/main/services/conflicts
import { createGitOperationsService } from "../../desktop/src/main/services/git/gitOperationsService";
import { createDiffService } from "../../desktop/src/main/services/diffs/diffService";
import { createPtyService } from "../../desktop/src/main/services/pty/ptyService";
import { createProjectSearchService } from "../../desktop/src/main/services/search/searchServiceWiring";
import type { SearchService } from "../../desktop/src/main/services/search/searchService";
import { createSupervisedPtyLoader } from "../../desktop/src/main/services/pty/supervisedPtyHost";
import { createTestService } from "../../desktop/src/main/services/tests/testService";
import { createKeybindingsService } from "../../desktop/src/main/services/keybindings/keybindingsService";
Expand Down Expand Up @@ -224,6 +226,7 @@ export type AdeRuntime = {
budgetCapService?: ReturnType<typeof createBudgetCapService> | null;
sessionDeltaService?: ReturnType<typeof createSessionDeltaService> | null;
reviewService?: ReturnType<typeof createReviewService> | null;
searchService?: SearchService | null;
autoUpdateService?: ReturnType<typeof createAutoUpdateService> | null;
appNavigationService?: {
navigate(args: AppNavigationRequest): Promise<AppNavigationResult>;
Expand Down Expand Up @@ -456,6 +459,7 @@ export async function createAdeRuntime(args: {
let conflictServiceRef: ReturnType<typeof createConflictService> | null = null;
let rebaseSuggestionServiceRef: ReturnType<typeof createRebaseSuggestionService> | null = null;
let autoRebaseServiceRef: ReturnType<typeof createAutoRebaseService> | null = null;
const searchServiceHolder: { current: SearchService | null } = { current: null };
let linearIssueTrackerRef: ReturnType<typeof createLinearIssueTracker> | null = null;
let githubServiceRef: ReturnType<typeof createGithubService> | null = null;
const publishLinearChatLink = createLinearChatLinkPublisher({
Expand Down Expand Up @@ -484,7 +488,10 @@ export async function createAdeRuntime(args: {
}
},
onDeleteEvent: (event) => pushEvent("runtime", { type: "lane_delete_event", event }),
onLifecycleEvent: (event) => pushEvent("runtime", { type: "lane_lifecycle_event", event }),
onLifecycleEvent: (event) => {
pushEvent("runtime", { type: "lane_lifecycle_event", event });
if (event.laneId) searchServiceHolder.current?.notifyLaneActivity(event.laneId);
},
onLinearIssueLinked: ({ lane, issue, linkedAt }) => {
const tracker = linearIssueTrackerRef;
if (!tracker) return;
Expand Down Expand Up @@ -731,6 +738,7 @@ export async function createAdeRuntime(args: {
logger,
broadcastData: (event) => {
pushEvent("pty", { type: "pty_data", event });
searchServiceHolder.current?.notifyTerminalData(event.sessionId);
const { projectRoot: _projectRoot, ...syncEvent } = event;
syncServiceForPtyEvents?.handlePtyData(syncEvent);
},
Expand Down Expand Up @@ -1324,6 +1332,28 @@ export async function createAdeRuntime(args: {
}
}

const searchService = createProjectSearchService({
cacheDir: paths.cacheDir,
transcriptsDir: paths.transcriptsDir,
chatTranscriptsDir: paths.chatTranscriptsDir,
logger,
sessionService,
laneService,
agentChatService,
prService: headlessLinearServices.prService ?? null,
gitService,
fileService: headlessLinearServices.fileService ?? null,
artifactBroker: computerUseArtifactBrokerService,
linearIssueTracker: headlessLinearServices.linearIssueTracker ?? null,
backfillDelayMs: 5_000,
});
searchServiceHolder.current = searchService;
headlessLinearServices.prService?.setEventEmitter((event) => {
if (event.type === "prs-updated") {
for (const pr of event.prs) searchService.notifyPrChanged(pr.id);
}
});

const runtime: AdeRuntime = {
projectRoot,
workspaceRoot,
Expand Down Expand Up @@ -1358,6 +1388,7 @@ export async function createAdeRuntime(args: {
ptyService,
testService,
reviewService,
searchService,
aiIntegrationService,
agentChatService,
orchestrationService,
Expand Down Expand Up @@ -1413,6 +1444,7 @@ export async function createAdeRuntime(args: {
swallow(() => agentChatService?.forceDisposeAll?.());
swallow(() => testService.disposeAll());
swallow(() => ptyService.disposeAll());
swallow(() => searchService.dispose());
swallow(() => processRegistry.stop());
swallow(() => db.flushNow());
swallow(() => db.close());
Expand Down
65 changes: 65 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,71 @@ describe("ADE CLI", () => {
});
});

it("builds typed ADE search commands", () => {
const query = expectExecutePlan(
buildCliPlan([
"search",
"login redirect",
"--kind",
"chat,terminal",
"--lane",
"fix-login",
"--limit",
"5",
"--cursor",
"abc",
]),
);
expect(query.formatter).toBe("search-results");
expect(query.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: {
domain: "search",
action: "query",
args: {
query: "login redirect",
kinds: ["chat", "terminal"],
laneId: "fix-login",
limit: 5,
cursor: "abc",
},
},
});
// Query invocations exit nonzero when nothing matches, zero otherwise.
expect(query.exitCodeFromResult?.({ results: [] })).toBe(1);
expect(query.exitCodeFromResult?.({ results: [{ id: "chat:1" }] })).toBe(0);

// Bare query omits optional args entirely.
const bare = expectExecutePlan(buildCliPlan(["search", "just words"]));
expect(bare.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: {
domain: "search",
action: "query",
args: { query: "just words" },
},
});

// Unknown kinds are a usage error (exit 2), not a silent pass-through.
expect(() => buildCliPlan(["search", "q", "--kind", "chat,bogus"])).toThrow(
/Unknown search kind/,
);

// A missing query is a usage error, but --status / --rebuild are not queries.
expect(() => buildCliPlan(["search"])).toThrow(/requires a query/);
const status = expectExecutePlan(buildCliPlan(["search", "--status"]));
expect(status.formatter).toBe("search-status");
expect(status.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: { domain: "search", action: "indexStatus", args: {} },
});
const rebuild = expectExecutePlan(buildCliPlan(["search", "--rebuild"]));
expect(rebuild.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: { domain: "search", action: "rebuildIndex", args: {} },
});
});

it("builds PR transcript gist settings commands", () => {
const enable = buildCliPlan(["settings", "pr-transcript-gists", "enable"]);
expect(enable.kind).toBe("execute");
Expand Down
Loading