Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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