Skip to content

Commit c2be3ab

Browse files
committed
feat(search): use hosted search for signed-in sessions
1 parent 5ce32a9 commit c2be3ab

10 files changed

Lines changed: 151 additions & 15 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ codebase project build --wait "build a launch waitlist page"
8686

8787
OAuth uses Codebase Auto by default (`codebase/d4f`, in-house DeepSeek V4 Flash). Swap models live with `/model`. Set reasoning depth with `/effort`. `project build` hands a prompt to the web builder and prints the session, status, event stream, and preview URL when you pass `--wait`.
8888

89+
Signed-in sessions also get hosted web search with no extra key. BYOK users can
90+
set `TAVILY_API_KEY`, `BRAVE_API_KEY`, or `SEARXNG_URL`; local search providers
91+
take priority over the hosted service. Run `codebase doctor` to verify which
92+
path is active.
93+
8994
## Use Codebase from an ACP client
9095

9196
Codebase speaks the [Agent Client Protocol](https://agentclientprotocol.com/) over stdio:

docs/MIGRATION_v1_to_v2.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ and project memory all keep working.
2929
| `~/.codebase/config.json` | User config (theme, MCP servers, hooks) | Compatible. New fields are added; existing ones are read as before. |
3030
| `CLAUDE.md`, `AGENTS.md`, `CODEX.md`, `.cursorrules` | Project instructions | Identical pickup logic. |
3131
| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL` | Provider env vars | Identical. |
32-
| `GLUE_*`, `TAVILY_API_KEY`, `BRAVE_API_KEY`, `SEARXNG_URL` | Sidecar + search keys | Identical. |
32+
| `GLUE_*`, `TAVILY_API_KEY`, `BRAVE_API_KEY`, `SEARXNG_URL` | Sidecar + search keys | Still supported. Signed-in Codebase sessions can use hosted search without a separate search key. |
3333
| `CODEBASE_NOBOOT`, `CODEBASE_NOSOUND` | Behavior toggles | Identical. |
3434

3535
If you sign in via codebase.foundation in v1, you stay signed in

src/agent/agent.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,12 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
324324

325325
const toolContext: ToolContext = {
326326
cwd,
327+
...(tokenManager && {
328+
platform: {
329+
baseUrl: (process.env.CODEBASE_AUTH_BASE_URL ?? "https://codebase.design").replace(/\/+$/, ""),
330+
getAccessToken: () => tokenManager.getAccessToken(),
331+
},
332+
}),
327333
fileStateCache: new FileStateCache(),
328334
tasks: new TaskStore({ cwd, taskListId: opts.taskListId ?? sessions.id }),
329335
userQueries,

src/agent/system-prompt.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ describe("buildSystemPrompt", () => {
2828
expect(out).toMatch(/Issue independent tool calls together/);
2929
});
3030

31+
it("forbids presenting invented results after a failed web search", () => {
32+
const out = buildSystemPrompt();
33+
expect(out).toMatch(/When web_search fails, no search occurred/);
34+
expect(out).toMatch(/never invent.*links as search results/i);
35+
});
36+
3137
it("teaches subagent dispatch for fan-out work", () => {
3238
const out = buildSystemPrompt();
3339
expect(out).toMatch(/dispatch_agent/);

src/agent/system-prompt.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ export function buildSystemPrompt(opts: BuildSystemPromptOptions = {}): string {
6060
lines.push(
6161
"When a task fans out cleanly — multi-file audits, security reviews, broad codebase exploration — prefer dispatching subagents via dispatch_agent so each stream runs in parallel and their context stays out of your main loop. Don't also do the same searches yourself; that wastes turns and doubles the noise.",
6262
);
63+
lines.push(
64+
"When web_search fails, no search occurred. Say that it failed and why; never invent, reconstruct from memory, or present plausible-looking links as search results.",
65+
);
6366
lines.push(
6467
'Subagents come in types: "explore" (read-only, the default) for investigation, "general" for work that edits files or runs commands, plus any project-defined types. For parallel write work, give each general subagent isolation: "worktree" so their edits can\'t collide.',
6568
);

src/diagnostics/doctor.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ describe("buildDoctorReport", () => {
5252
const out = buildDoctorReport({
5353
cwd,
5454
dataRoot,
55-
env: { TAVILY_API_KEY: "configured" } as NodeJS.ProcessEnv,
55+
env: {},
5656
model: { provider: "codebase", id: "d4f", name: "Codebase Auto" },
5757
source: "proxy",
5858
mcpStatuses: [{ name: "db", connected: false, toolCount: 0, error: "spawn failed" }],
@@ -66,7 +66,7 @@ describe("buildDoctorReport", () => {
6666
expect(out).toContain("config ");
6767
expect(out).toContain("is not valid JSON");
6868
expect(out).toContain("mcp db: spawn failed");
69-
expect(out).toContain("✓ web_search configured");
69+
expect(out).toContain("✓ web_search configured (Codebase hosted)");
7070
expect(out).toContain("sessions for this directory: 3");
7171
expect(out).toContain("subagent types: general");
7272
});

src/diagnostics/doctor.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,16 @@ export function buildDoctorReport(options: DoctorReportOptions): string[] {
8787
lines.push(check(s.connected, `mcp ${s.name}: ${s.toolCount} tools`, `mcp ${s.name}: ${s.error ?? "failed"}`));
8888
}
8989

90-
const hasSearch = Boolean(env.TAVILY_API_KEY || env.BRAVE_API_KEY || env.SEARXNG_URL);
90+
const localSearch = Boolean(env.TAVILY_API_KEY || env.BRAVE_API_KEY || env.SEARXNG_URL);
91+
const hostedSearch = Boolean(creds && creds.source !== "byok");
9192
lines.push(
92-
hasSearch
93-
? check(true, "web_search configured", "")
94-
: info("web_search unconfigured — set TAVILY_API_KEY, BRAVE_API_KEY, or SEARXNG_URL to enable"),
93+
localSearch
94+
? check(true, "web_search configured (local provider)", "")
95+
: hostedSearch
96+
? check(true, "web_search configured (Codebase hosted)", "")
97+
: info(
98+
"web_search unconfigured — sign in with `codebase auth login` or set TAVILY_API_KEY, BRAVE_API_KEY, or SEARXNG_URL",
99+
),
95100
);
96101

97102
lines.push(

src/tools/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ import type { TaskStore } from "./task-store.js";
1818
*/
1919
export interface ToolContext {
2020
cwd: string;
21+
/**
22+
* Authenticated Codebase platform access. Present for OAuth/manual proxy
23+
* sessions so tools can use hosted services without exposing service keys.
24+
*/
25+
platform?: {
26+
baseUrl: string;
27+
getAccessToken: () => Promise<string>;
28+
};
2129
fileStateCache: FileStateCache;
2230
tasks: TaskStore;
2331
userQueries: UserQueryStore;

src/tools/web-search.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,14 @@ describe("web_search provider selection", () => {
6161
expect(pickProvider({ TAVILY_API_KEY: "x", BRAVE_API_KEY: "y" }).name).toBe("tavily");
6262
});
6363

64+
it("uses Codebase hosted search only when no local provider is configured", () => {
65+
const hosted = { baseUrl: "https://codebase.design", getAccessToken: async () => "token" };
66+
expect(pickProvider({}, hosted).name).toBe("codebase");
67+
expect(pickProvider({ BRAVE_API_KEY: "x" }, hosted).name).toBe("brave");
68+
});
69+
6470
it("errors with onboarding when nothing is configured", () => {
65-
expect(() => pickProvider({})).toThrow(/TAVILY_API_KEY/);
71+
expect(() => pickProvider({})).toThrow(/SEARCH_FAILED_NO_RESULTS/);
6672
});
6773
});
6874

@@ -140,6 +146,56 @@ describe("web_search end-to-end via mock servers", () => {
140146
expect(result.details.results[0].snippet).toBe("s1");
141147
});
142148

149+
it("uses authenticated Codebase hosted search when signed in", async () => {
150+
routes["/api/v1/search"] = (req, res) => {
151+
expect(req.method).toBe("POST");
152+
expect(req.headers.authorization).toBe("Bearer oauth-token");
153+
const chunks: Buffer[] = [];
154+
req.on("data", (chunk) => chunks.push(chunk));
155+
req.on("end", () => {
156+
expect(JSON.parse(Buffer.concat(chunks).toString("utf8"))).toEqual({
157+
query: "AIPG",
158+
max_results: 4,
159+
});
160+
res.setHeader("Content-Type", "application/json");
161+
res.end(
162+
JSON.stringify({
163+
query: "AIPG",
164+
provider: "tavily",
165+
results: [
166+
{
167+
title: "AI Power Grid",
168+
url: "https://example.com/aipg",
169+
snippet: "A sourced result.",
170+
},
171+
],
172+
duration_ms: 12,
173+
}),
174+
);
175+
});
176+
};
177+
178+
const original = { ...process.env };
179+
for (const key of ["TAVILY_API_KEY", "BRAVE_API_KEY", "SEARXNG_URL"]) delete process.env[key];
180+
try {
181+
const result = await createWebSearch({
182+
...makeCtx(),
183+
platform: {
184+
baseUrl,
185+
getAccessToken: async () => "oauth-token",
186+
},
187+
}).execute("call", { query: "AIPG", max_results: 4 });
188+
189+
expect(result.details.provider).toBe("codebase");
190+
expect(result.details.results[0]).toMatchObject({
191+
title: "AI Power Grid",
192+
url: "https://example.com/aipg",
193+
});
194+
} finally {
195+
Object.assign(process.env, original);
196+
}
197+
});
198+
143199
it("surfaces provider HTTP errors with the status code", async () => {
144200
routes["/search"] = (_req, res) => {
145201
res.statusCode = 401;

src/tools/web-search.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,11 @@ Provider auto-detected from env (highest-priority match wins):
4848
- TAVILY_API_KEY → Tavily (recommended; free tier available)
4949
- BRAVE_API_KEY → Brave Search
5050
- SEARXNG_URL → self-hosted SearXNG instance
51+
- Codebase OAuth → hosted search (when signed in)
5152
52-
If none are set, this tool errors with onboarding instructions instead of attempting an unauthenticated fallback. Default 5 results, max 20.`;
53+
Local providers take priority over hosted search. If none are available, this tool errors with onboarding instructions instead of attempting an unauthenticated fallback. Default 5 results, max 20.`;
5354

54-
export type ProviderName = "tavily" | "brave" | "searxng";
55+
export type ProviderName = "tavily" | "brave" | "searxng" | "codebase";
5556

5657
interface ProviderConfig {
5758
name: ProviderName;
@@ -66,15 +67,20 @@ export interface ProviderEnv {
6667
SEARXNG_URL?: string;
6768
}
6869

69-
export function createWebSearch(_ctx: ToolContext): AgentTool<typeof Params, WebSearchDetails> {
70+
export interface HostedSearchConfig {
71+
baseUrl: string;
72+
getAccessToken: () => Promise<string>;
73+
}
74+
75+
export function createWebSearch(ctx: ToolContext): AgentTool<typeof Params, WebSearchDetails> {
7076
return {
7177
name: "web_search",
7278
label: "Search",
7379
description: DESCRIPTION,
7480
parameters: Params,
7581
executionMode: "parallel",
7682
execute: async (_id, params, signal) => {
77-
const provider = pickProvider(process.env);
83+
const provider = pickProvider(process.env, ctx.platform);
7884
const max = params.max_results ?? DEFAULT_RESULTS;
7985
const timeout = params.timeout_ms ?? DEFAULT_TIMEOUT_MS;
8086

@@ -107,7 +113,7 @@ export function createWebSearch(_ctx: ToolContext): AgentTool<typeof Params, Web
107113
};
108114
}
109115

110-
export function pickProvider(env: ProviderEnv): ProviderConfig {
116+
export function pickProvider(env: ProviderEnv, hosted?: HostedSearchConfig): ProviderConfig {
111117
if (env.TAVILY_API_KEY) {
112118
return tavilyProvider(env.TAVILY_API_KEY, env.TAVILY_BASE_URL);
113119
}
@@ -117,12 +123,53 @@ export function pickProvider(env: ProviderEnv): ProviderConfig {
117123
if (env.SEARXNG_URL) {
118124
return searxngProvider(env.SEARXNG_URL);
119125
}
126+
if (hosted) {
127+
return codebaseProvider(hosted);
128+
}
120129
throw new Error(
121-
"web_search has no provider configured. Set one of TAVILY_API_KEY (recommended), BRAVE_API_KEY, " +
122-
"or SEARXNG_URL. Tavily offers a free tier at https://tavily.com.",
130+
"SEARCH_FAILED_NO_RESULTS: web_search has no provider configured. Sign in with `codebase auth login`, " +
131+
"or set TAVILY_API_KEY, BRAVE_API_KEY, or SEARXNG_URL. No web results were obtained; do not invent " +
132+
"or present search results.",
123133
);
124134
}
125135

136+
function codebaseProvider(config: HostedSearchConfig): ProviderConfig {
137+
const url = `${config.baseUrl.replace(/\/+$/, "")}/api/v1/search`;
138+
return {
139+
name: "codebase",
140+
search: async (query, max, signal) => {
141+
const token = await config.getAccessToken();
142+
const res = await fetch(url, {
143+
method: "POST",
144+
headers: {
145+
Authorization: `Bearer ${token}`,
146+
"Content-Type": "application/json",
147+
Accept: "application/json",
148+
},
149+
body: JSON.stringify({ query, max_results: max }),
150+
signal,
151+
});
152+
const json = (await res.json().catch(() => ({}))) as {
153+
error?: string;
154+
error_description?: string;
155+
results?: SearchResult[];
156+
};
157+
if (!res.ok) {
158+
const reason = json.error_description ?? json.error ?? res.statusText;
159+
throw new Error(`codebase ${res.status} ${reason}`);
160+
}
161+
return Array.isArray(json.results)
162+
? json.results.filter(
163+
(result) =>
164+
typeof result?.title === "string" &&
165+
typeof result?.url === "string" &&
166+
typeof result?.snippet === "string",
167+
)
168+
: [];
169+
},
170+
};
171+
}
172+
126173
function tavilyProvider(apiKey: string, baseUrl?: string): ProviderConfig {
127174
const url = `${baseUrl ?? "https://api.tavily.com"}/search`;
128175
return {

0 commit comments

Comments
 (0)