Skip to content

Commit 5791121

Browse files
committed
feat(proxy): implement proxy socket auth flow
1 parent 75bc06a commit 5791121

File tree

21 files changed

+959
-60
lines changed

21 files changed

+959
-60
lines changed

README.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ for await (const event of stream.events) {
7575

7676
By default, data commands return markdown. Use `--json` to print raw JSON.
7777

78-
- `login` - Log in interactively, or with `--token <token>` / `--token-stdin`.
78+
- `login` - Log in interactively, with `--token <token>` / `--token-stdin`, or via proxy with `--proxy <url|socket>`.
7979
- `status` - Show current authentication status.
8080
- `logout` - Log out and clear stored credentials.
8181

@@ -120,12 +120,42 @@ By default, data commands return markdown. Use `--json` to print raw JSON.
120120

121121
- `sync` - Export your Bee data to markdown files for AI agents. Options: `--output <dir>`, `--recent-days N`, `--only <facts|todos|daily|conversations>`.
122122

123-
- `proxy` - Start a local HTTP proxy for the Bee API. Options: `--port N`.
123+
- `proxy` - Start a local Bee API proxy. Options: `--port N`, `--socket [path]`.
124124

125125
- `ping` - Run a quick connectivity check. Use `--count N` to repeat.
126126

127127
- `version` - Print the CLI version. Use `--json` for JSON output.
128128

129+
## Proxy Authentication
130+
131+
Use proxy auth when another trusted local process handles Bee API authentication and this CLI should send requests through it.
132+
133+
### Configure Proxy Mode
134+
135+
```bash
136+
# HTTP proxy
137+
bee login --proxy http://127.0.0.1:8787
138+
139+
# Unix socket proxy
140+
bee login --proxy ~/.bee/proxy.sock
141+
```
142+
143+
This saves proxy config to `~/.bee/proxy-{env}.json`. When proxy config exists, it takes precedence over stored token auth.
144+
145+
### Start Local Proxy Server
146+
147+
```bash
148+
# TCP listener (default auto-picks from 8787)
149+
bee proxy
150+
bee proxy --port 8787
151+
152+
# Unix socket listener (default: ~/.bee/proxy.sock)
153+
bee proxy --socket
154+
bee proxy --socket /tmp/bee-proxy.sock
155+
```
156+
157+
In socket mode, the CLI removes stale socket files before listening.
158+
129159
## Stream Events
130160

131161
Use `bee stream` to receive server-sent events (SSE). You can filter events with

sources/client.proxy.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { afterEach, describe, expect, it } from "bun:test";
2+
import { mkdtempSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { createProxyClient } from "@/client";
6+
7+
type BunServer = ReturnType<typeof Bun.serve>;
8+
9+
const activeServers: BunServer[] = [];
10+
const cleanupPaths: string[] = [];
11+
12+
afterEach(() => {
13+
for (const server of activeServers.splice(0, activeServers.length)) {
14+
server.stop(true);
15+
}
16+
for (const path of cleanupPaths.splice(0, cleanupPaths.length)) {
17+
rmSync(path, { recursive: true, force: true });
18+
}
19+
});
20+
21+
describe("proxy client", () => {
22+
it("routes HTTP proxy requests through configured base URL", async () => {
23+
let seenPath = "";
24+
let seenAuthorization: string | null = null;
25+
26+
const upstream = Bun.serve({
27+
hostname: "127.0.0.1",
28+
port: 0,
29+
fetch: (request) => {
30+
const url = new URL(request.url);
31+
seenPath = `${url.pathname}${url.search}`;
32+
seenAuthorization = request.headers.get("authorization");
33+
return Response.json({ ok: true });
34+
},
35+
});
36+
activeServers.push(upstream);
37+
38+
const client = createProxyClient("prod", {
39+
address: `http://127.0.0.1:${upstream.port}`,
40+
});
41+
42+
const response = await client.fetch("/v1/me?x=1", { method: "GET" });
43+
expect(response.ok).toBe(true);
44+
expect(seenPath).toBe("/v1/me?x=1");
45+
expect(seenAuthorization).toBeNull();
46+
});
47+
48+
it("routes unix socket proxy requests with unix fetch option", async () => {
49+
let seenPath = "";
50+
const dir = mkdtempSync(join(tmpdir(), "bee-cli-proxy-client-"));
51+
cleanupPaths.push(dir);
52+
const socketPath = join(dir, "proxy.sock");
53+
54+
const upstream = Bun.serve({
55+
unix: socketPath,
56+
fetch: (request) => {
57+
const url = new URL(request.url);
58+
seenPath = url.pathname;
59+
return Response.json({ ok: true });
60+
},
61+
});
62+
activeServers.push(upstream);
63+
64+
const client = createProxyClient("prod", { address: socketPath });
65+
const response = await client.fetch("/v1/me", { method: "GET" });
66+
67+
expect(response.ok).toBe(true);
68+
expect(seenPath).toBe("/v1/me");
69+
expect(client.isProxy).toBe(true);
70+
});
71+
});

sources/client.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
import { getEnvironmentConfig, type Environment } from "@/environment";
2+
import type { ProxyConfig } from "@/secureStore";
3+
import {
4+
expandHomePath,
5+
isSocketPath,
6+
normalizeProxyAddress,
7+
} from "@/utils/proxyAddress";
28

39
type TlsOptions = {
410
ca?: string | string[];
511
};
612

713
type FetchInit = RequestInit & {
814
tls?: TlsOptions;
15+
unix?: string;
916
};
1017

1118
export type DeveloperClient = {
1219
env: Environment;
1320
baseUrl: string;
21+
isProxy: boolean;
22+
proxyAddress?: string;
1423
fetch: (path: string, init?: FetchInit) => Promise<Response>;
1524
};
1625

@@ -21,6 +30,7 @@ export function createDeveloperClient(env: Environment): DeveloperClient {
2130
return {
2231
env,
2332
baseUrl: config.apiUrl,
33+
isProxy: false,
2434
fetch: (path, init) => {
2535
const url = new URL(path, config.apiUrl);
2636
const requestInit: FetchInit = init
@@ -30,3 +40,54 @@ export function createDeveloperClient(env: Environment): DeveloperClient {
3040
},
3141
};
3242
}
43+
44+
export function createProxyClient(
45+
env: Environment,
46+
proxyConfig: ProxyConfig
47+
): DeveloperClient {
48+
const address = normalizeProxyAddress(proxyConfig.address);
49+
if (!address) {
50+
throw new Error("Proxy address cannot be empty.");
51+
}
52+
53+
if (isSocketPath(address)) {
54+
const socketPath = expandHomePath(address);
55+
const baseUrl = "http://localhost/";
56+
return {
57+
env,
58+
baseUrl,
59+
isProxy: true,
60+
proxyAddress: address,
61+
fetch: (path, init) => {
62+
const url = new URL(path, baseUrl);
63+
const requestInit: FetchInit = init ? { ...init, unix: socketPath } : { unix: socketPath };
64+
return fetch(url, requestInit);
65+
},
66+
};
67+
}
68+
69+
let baseUrl: string;
70+
try {
71+
const parsed = new URL(address);
72+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
73+
throw new Error("Proxy URL must start with http:// or https://.");
74+
}
75+
baseUrl = parsed.toString();
76+
} catch (error) {
77+
if (error instanceof Error) {
78+
throw new Error(`Invalid proxy address: ${error.message}`);
79+
}
80+
throw new Error("Invalid proxy address.");
81+
}
82+
83+
return {
84+
env,
85+
baseUrl,
86+
isProxy: true,
87+
proxyAddress: address,
88+
fetch: (path, init) => {
89+
const url = new URL(path, baseUrl);
90+
return fetch(url, init);
91+
},
92+
};
93+
}

sources/client/clientApi.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { afterEach, describe, expect, it } from "bun:test";
2+
import type { CommandContext } from "@/commands/types";
3+
import { createProxyClient } from "@/client";
4+
import { requireClientToken, requestClientJson } from "./clientApi";
5+
6+
type BunServer = ReturnType<typeof Bun.serve>;
7+
8+
const activeServers: BunServer[] = [];
9+
10+
afterEach(() => {
11+
for (const server of activeServers.splice(0, activeServers.length)) {
12+
server.stop(true);
13+
}
14+
});
15+
16+
describe("client API auth", () => {
17+
it("skips token requirement in proxy mode", async () => {
18+
const context: CommandContext = {
19+
env: "prod",
20+
client: createProxyClient("prod", { address: "http://127.0.0.1:8787" }),
21+
};
22+
23+
await expect(requireClientToken(context)).resolves.toBeNull();
24+
});
25+
26+
it("does not inject Authorization header in proxy mode requests", async () => {
27+
let seenAuthorization: string | null = null;
28+
const proxy = Bun.serve({
29+
hostname: "127.0.0.1",
30+
port: 0,
31+
fetch: (request) => {
32+
seenAuthorization = request.headers.get("authorization");
33+
return Response.json({ ok: true });
34+
},
35+
});
36+
activeServers.push(proxy);
37+
38+
const context: CommandContext = {
39+
env: "prod",
40+
client: createProxyClient("prod", {
41+
address: `http://127.0.0.1:${proxy.port}`,
42+
}),
43+
};
44+
45+
const data = await requestClientJson(context, "/v1/me", { method: "GET" });
46+
expect(data).toEqual({ ok: true });
47+
expect(seenAuthorization).toBeNull();
48+
});
49+
});

sources/client/clientApi.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ type JsonRequestInit = RequestInit & {
1313
json?: JsonValue;
1414
};
1515

16-
export async function requireClientToken(context: CommandContext): Promise<string> {
16+
export async function requireClientToken(context: CommandContext): Promise<string | null> {
17+
if (context.client.isProxy) {
18+
return null;
19+
}
20+
1721
const token = await loadToken(context.env);
1822
if (!token) {
1923
throw new Error('Not logged in. Run "bee login" first.');
@@ -28,7 +32,9 @@ export async function requestClientJson(
2832
): Promise<unknown> {
2933
const token = await requireClientToken(context);
3034
const headers = new Headers(init.headers);
31-
headers.set("Authorization", `Bearer ${token}`);
35+
if (token) {
36+
headers.set("Authorization", `Bearer ${token}`);
37+
}
3238

3339
let body = init.body;
3440
if (init.json !== undefined) {

sources/client/clientMe.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const MAX_BACKOFF_MS = 30000;
1515

1616
export async function fetchClientMe(
1717
context: CommandContext,
18-
token: string
18+
token?: string
1919
): Promise<ClientUser> {
2020
const response = await fetchWithRetry(context, token);
2121

@@ -44,18 +44,21 @@ export async function fetchClientMe(
4444

4545
async function fetchWithRetry(
4646
context: CommandContext,
47-
token: string
47+
token?: string
4848
): Promise<Response> {
4949
let lastError: Error | null = null;
5050
let lastErrorType: "network" | "server" | null = null;
5151

5252
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
5353
try {
54+
const headers = new Headers();
55+
if (token) {
56+
headers.set("Authorization", `Bearer ${token}`);
57+
}
58+
5459
const response = await context.client.fetch("/v1/me", {
5560
method: "GET",
56-
headers: {
57-
Authorization: `Bearer ${token}`,
58-
},
61+
headers,
5962
});
6063

6164
if (response.status >= 500 && response.status < 600) {

0 commit comments

Comments
 (0)