@pix3/agent-bridge 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -0
- package/package.json +38 -0
- package/src/cli.ts +195 -0
- package/src/config.ts +179 -0
- package/src/index.ts +350 -0
- package/src/proxy.ts +68 -0
- package/src/sessions.ts +597 -0
- package/src/wire.ts +161 -0
- package/tsconfig.json +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Pix3AgentBridge
|
|
2
|
+
|
|
3
|
+
A small **local** service that connects the Pix3 editor's in-editor AI agent to LLM providers a
|
|
4
|
+
browser can't reach on its own — and keeps your API keys on your machine, never in the browser.
|
|
5
|
+
|
|
6
|
+
It runs on `127.0.0.1` and does two things:
|
|
7
|
+
|
|
8
|
+
1. **Claude Code (MAX) lane** — serves the agent from a real Claude Agent SDK session using your
|
|
9
|
+
Claude Code Pro/MAX subscription (`claude login`). No API key, no per-token cost.
|
|
10
|
+
2. **Provider proxy lane** — a credential-injecting reverse proxy for **OpenAI**, the **Anthropic
|
|
11
|
+
API**, **OpenCode Zen**, and any **custom OpenAI-compatible** endpoint. The editor authenticates
|
|
12
|
+
to the bridge with a pairing token; the bridge adds the real provider key and forwards the request
|
|
13
|
+
to the provider. Your keys live only in `~/.pix3/agent-bridge.json`.
|
|
14
|
+
|
|
15
|
+
Google Gemini is **not** proxied here — the editor calls it directly (it sends CORS headers), so a
|
|
16
|
+
basic user only needs a Gemini key and no bridge at all. The bridge is the "advanced" path that
|
|
17
|
+
unlocks the other providers.
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- Node.js **24+**
|
|
22
|
+
- For the Claude Code lane: a logged-in Claude Code (`claude login`, Pro/MAX)
|
|
23
|
+
|
|
24
|
+
## Run
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx @pix3/agent-bridge
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
On start it prints a **pairing token**. In the editor: **Settings → AI Agent**, paste the token.
|
|
31
|
+
Providers you've enabled below then appear in the model picker.
|
|
32
|
+
|
|
33
|
+
Options: `--port <n>` (default 8484), `--origin <url>` (repeatable — extra allowed browser origins).
|
|
34
|
+
|
|
35
|
+
## Manage providers
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Built-in presets — just supply a key:
|
|
39
|
+
npx @pix3/agent-bridge provider add openai --key sk-...
|
|
40
|
+
npx @pix3/agent-bridge provider add anthropic --key sk-ant-...
|
|
41
|
+
npx @pix3/agent-bridge provider add opencode-zen --key ...
|
|
42
|
+
|
|
43
|
+
# A custom OpenAI-compatible endpoint (arbitrary id + explicit base URL):
|
|
44
|
+
npx @pix3/agent-bridge provider add my-router \
|
|
45
|
+
--base-url https://openrouter.ai/api/v1 --key sk-or-... --kind openai --label OpenRouter
|
|
46
|
+
|
|
47
|
+
npx @pix3/agent-bridge provider list
|
|
48
|
+
npx @pix3/agent-bridge provider disable openai
|
|
49
|
+
npx @pix3/agent-bridge provider enable openai
|
|
50
|
+
npx @pix3/agent-bridge provider set-key openai sk-...
|
|
51
|
+
npx @pix3/agent-bridge provider remove my-router
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`--kind openai` forwards `Authorization: Bearer <key>` (OpenAI Chat Completions, gateways, local
|
|
55
|
+
Ollama/LM Studio). `--kind anthropic` forwards `x-api-key` + `anthropic-version` (native Anthropic
|
|
56
|
+
Messages API). Presets set the right kind for you.
|
|
57
|
+
|
|
58
|
+
Changes take effect on the editor's next availability probe — no server restart needed for key/enable
|
|
59
|
+
changes (a base-URL/kind change to a provider you're actively using is picked up on reconnect).
|
|
60
|
+
|
|
61
|
+
## Security
|
|
62
|
+
|
|
63
|
+
- Binds to `127.0.0.1` only; `Host` must be localhost (blocks DNS-rebinding).
|
|
64
|
+
- Every API call requires the pairing token; browser `Origin` is allowlisted.
|
|
65
|
+
- The proxy's upstream host is fixed per provider (never taken from the request) → no open relay / SSRF.
|
|
66
|
+
- Outbound requests carry only `content-type` + the injected key — the pairing token, cookies and
|
|
67
|
+
other inbound headers are stripped, so nothing leaks upstream.
|
|
68
|
+
- The Claude Code session runs with zero built-in tools — the model can only call pix3 editor tools,
|
|
69
|
+
never this machine's shell or filesystem.
|
|
70
|
+
|
|
71
|
+
## Config file
|
|
72
|
+
|
|
73
|
+
`~/.pix3/agent-bridge.json` holds the pairing token and the provider table (kind, base URL, key,
|
|
74
|
+
enabled). It is migrated automatically from the old `claude-bridge.json` (the pairing token carries
|
|
75
|
+
over) on first run.
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pix3/agent-bridge",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Local bridge for the Pix3 editor's in-editor agent: a Claude Agent SDK (Pro/MAX subscription) lane plus a credential-injecting proxy for OpenAI / Anthropic / OpenCode Zen / custom OpenAI-compatible providers. Keys stay on your machine.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pix3-agent-bridge": "src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"tsconfig.json",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=24"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node src/index.ts",
|
|
19
|
+
"type-check": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"pix3",
|
|
23
|
+
"llm",
|
|
24
|
+
"proxy",
|
|
25
|
+
"claude",
|
|
26
|
+
"openai",
|
|
27
|
+
"agent"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.211",
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^24.5.2",
|
|
36
|
+
"typescript": "~5.8.3"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI for Pix3AgentBridge. Running the bridge with no subcommand starts the server (see index.ts);
|
|
3
|
+
* the `provider` subcommands below manage the provider table in `~/.pix3/agent-bridge.json` — adding
|
|
4
|
+
* a key, enabling/disabling, or removing a provider — without touching a running server (the editor
|
|
5
|
+
* re-reads availability from `GET /v1/providers` on its next probe).
|
|
6
|
+
*
|
|
7
|
+
* pix3-agent-bridge provider list
|
|
8
|
+
* pix3-agent-bridge provider add openai --key sk-...
|
|
9
|
+
* pix3-agent-bridge provider add anthropic --key sk-ant-...
|
|
10
|
+
* pix3-agent-bridge provider add opencode-zen --key ...
|
|
11
|
+
* pix3-agent-bridge provider add my-router --base-url https://openrouter.ai/api/v1 --key ... --kind openai
|
|
12
|
+
* pix3-agent-bridge provider enable|disable|remove <id>
|
|
13
|
+
* pix3-agent-bridge provider set-key <id> <key>
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
PROVIDER_PRESETS,
|
|
18
|
+
RESERVED_PROVIDER_IDS,
|
|
19
|
+
configPath,
|
|
20
|
+
loadConfig,
|
|
21
|
+
saveConfig,
|
|
22
|
+
type ProviderConfig,
|
|
23
|
+
} from './config.ts';
|
|
24
|
+
|
|
25
|
+
/** Parse `--flag value` / `--flag=value` pairs, returning [positional[], flags]. */
|
|
26
|
+
const parseArgs = (args: string[]): { positional: string[]; flags: Record<string, string> } => {
|
|
27
|
+
const positional: string[] = [];
|
|
28
|
+
const flags: Record<string, string> = {};
|
|
29
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
30
|
+
const arg = args[i];
|
|
31
|
+
if (arg.startsWith('--')) {
|
|
32
|
+
const eq = arg.indexOf('=');
|
|
33
|
+
if (eq >= 0) {
|
|
34
|
+
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
35
|
+
} else {
|
|
36
|
+
flags[arg.slice(2)] = args[i + 1] ?? '';
|
|
37
|
+
i += 1;
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
positional.push(arg);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { positional, flags };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const maskKey = (key: string): string =>
|
|
47
|
+
key.length <= 8 ? (key ? '••••' : '(none)') : `${key.slice(0, 4)}…${key.slice(-4)}`;
|
|
48
|
+
|
|
49
|
+
const printList = (providers: Record<string, ProviderConfig>): void => {
|
|
50
|
+
const ids = Object.keys(providers);
|
|
51
|
+
if (ids.length === 0) {
|
|
52
|
+
console.log('No providers configured. Add one, e.g.: pix3-agent-bridge provider add openai --key sk-...');
|
|
53
|
+
console.log(`\nBuilt-in presets: ${Object.keys(PROVIDER_PRESETS).join(', ')}`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
console.log(`Providers (from ${configPath()}):\n`);
|
|
57
|
+
for (const id of ids) {
|
|
58
|
+
const p = providers[id];
|
|
59
|
+
const state = p.enabled ? 'enabled' : 'disabled';
|
|
60
|
+
const key = p.apiKey ? maskKey(p.apiKey) : '(no key — add one with `provider set-key`)';
|
|
61
|
+
console.log(` ${id}`);
|
|
62
|
+
console.log(` kind: ${p.kind}`);
|
|
63
|
+
console.log(` label: ${p.label}`);
|
|
64
|
+
console.log(` baseUrl: ${p.baseUrl}`);
|
|
65
|
+
console.log(` key: ${key}`);
|
|
66
|
+
console.log(` status: ${state}`);
|
|
67
|
+
console.log('');
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const usage = (): void => {
|
|
72
|
+
console.log(
|
|
73
|
+
[
|
|
74
|
+
'Usage:',
|
|
75
|
+
' pix3-agent-bridge start the bridge server',
|
|
76
|
+
' pix3-agent-bridge provider list',
|
|
77
|
+
' pix3-agent-bridge provider add <id> [--key <k>] [--base-url <url>] [--kind openai|anthropic] [--label <l>]',
|
|
78
|
+
' pix3-agent-bridge provider set-key <id> <key>',
|
|
79
|
+
' pix3-agent-bridge provider enable <id>',
|
|
80
|
+
' pix3-agent-bridge provider disable <id>',
|
|
81
|
+
' pix3-agent-bridge provider remove <id>',
|
|
82
|
+
'',
|
|
83
|
+
`Built-in presets (add with just --key): ${Object.keys(PROVIDER_PRESETS).join(', ')}`,
|
|
84
|
+
].join('\n')
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const addProvider = (
|
|
89
|
+
config: ReturnType<typeof loadConfig>,
|
|
90
|
+
id: string,
|
|
91
|
+
flags: Record<string, string>
|
|
92
|
+
): void => {
|
|
93
|
+
if ((RESERVED_PROVIDER_IDS as readonly string[]).includes(id)) {
|
|
94
|
+
console.error(
|
|
95
|
+
`Provider id "${id}" is reserved by the bridge and cannot be added. Pick a different id.`
|
|
96
|
+
);
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const preset = PROVIDER_PRESETS[id];
|
|
101
|
+
const baseUrl = (flags['base-url'] ?? preset?.baseUrl ?? '').replace(/\/$/, '');
|
|
102
|
+
if (!baseUrl) {
|
|
103
|
+
console.error(
|
|
104
|
+
`Provider "${id}" is not a built-in preset, so it needs an explicit --base-url ` +
|
|
105
|
+
`(e.g. --base-url https://openrouter.ai/api/v1).`
|
|
106
|
+
);
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const kindFlag = flags.kind === 'anthropic' ? 'anthropic' : flags.kind === 'openai' ? 'openai' : undefined;
|
|
111
|
+
const existing = config.providers[id];
|
|
112
|
+
config.providers[id] = {
|
|
113
|
+
kind: kindFlag ?? preset?.kind ?? existing?.kind ?? 'openai',
|
|
114
|
+
label: flags.label ?? preset?.label ?? existing?.label ?? id,
|
|
115
|
+
baseUrl,
|
|
116
|
+
apiKey: flags.key ?? existing?.apiKey ?? '',
|
|
117
|
+
enabled: true,
|
|
118
|
+
builtin: preset?.builtin ?? existing?.builtin ?? false,
|
|
119
|
+
};
|
|
120
|
+
saveConfig(config);
|
|
121
|
+
console.log(`Provider "${id}" added/updated and enabled.`);
|
|
122
|
+
if (!config.providers[id].apiKey) {
|
|
123
|
+
console.log(' Note: no API key set yet — add one with `provider set-key ' + id + ' <key>`.');
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Run a `provider …` subcommand. Returns true if it was handled (so the server should not start). */
|
|
128
|
+
export const runProviderCommand = (args: string[]): void => {
|
|
129
|
+
const { positional, flags } = parseArgs(args);
|
|
130
|
+
const [sub, id] = positional;
|
|
131
|
+
const config = loadConfig();
|
|
132
|
+
|
|
133
|
+
switch (sub) {
|
|
134
|
+
case undefined:
|
|
135
|
+
case 'list':
|
|
136
|
+
printList(config.providers);
|
|
137
|
+
return;
|
|
138
|
+
case 'add':
|
|
139
|
+
if (!id) {
|
|
140
|
+
console.error('Usage: provider add <id> [--key <k>] [--base-url <url>] [--kind openai|anthropic]');
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
addProvider(config, id, flags);
|
|
145
|
+
return;
|
|
146
|
+
case 'set-key': {
|
|
147
|
+
const key = positional[2];
|
|
148
|
+
if (!id || !key) {
|
|
149
|
+
console.error('Usage: provider set-key <id> <key>');
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const provider = config.providers[id];
|
|
154
|
+
if (!provider) {
|
|
155
|
+
console.error(`No such provider "${id}". Add it first with \`provider add ${id}\`.`);
|
|
156
|
+
process.exitCode = 1;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
provider.apiKey = key;
|
|
160
|
+
saveConfig(config);
|
|
161
|
+
console.log(`Key updated for "${id}".`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
case 'enable':
|
|
165
|
+
case 'disable': {
|
|
166
|
+
const provider = id ? config.providers[id] : undefined;
|
|
167
|
+
if (!provider) {
|
|
168
|
+
console.error(`No such provider "${id ?? ''}".`);
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
provider.enabled = sub === 'enable';
|
|
173
|
+
saveConfig(config);
|
|
174
|
+
console.log(`Provider "${id}" ${provider.enabled ? 'enabled' : 'disabled'}.`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
case 'remove': {
|
|
178
|
+
if (!id || !config.providers[id]) {
|
|
179
|
+
console.error(`No such provider "${id ?? ''}".`);
|
|
180
|
+
process.exitCode = 1;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
delete config.providers[id];
|
|
184
|
+
saveConfig(config);
|
|
185
|
+
console.log(`Provider "${id}" removed.`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
default:
|
|
189
|
+
console.error(`Unknown provider subcommand: ${sub}\n`);
|
|
190
|
+
usage();
|
|
191
|
+
process.exitCode = 1;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export { usage };
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent configuration for Pix3AgentBridge, stored in `~/.pix3/agent-bridge.json`.
|
|
3
|
+
*
|
|
4
|
+
* Beyond the pairing token / port / origins the bridge always had, this now holds the **provider
|
|
5
|
+
* table**: for each metered LLM provider (OpenAI, Anthropic API, OpenCode Zen, or a user-defined
|
|
6
|
+
* OpenAI-compatible endpoint) the upstream base URL, the API key, and an enabled flag. Keys live
|
|
7
|
+
* ONLY here on the user's machine — the editor never receives them; it authenticates to the bridge
|
|
8
|
+
* with the pairing token and the bridge injects the real key when forwarding upstream.
|
|
9
|
+
*
|
|
10
|
+
* The provider table is managed through the CLI (`pix3-agent-bridge provider add|remove|enable|…`),
|
|
11
|
+
* never by hand-editing (though the file is plain JSON if you must).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Provider ids the bridge reserves for its own intrinsic lanes (e.g. the Agent-SDK lane exposed as
|
|
21
|
+
* `claude-bridge` in discovery). The CLI rejects adding a provider under one of these so `GET
|
|
22
|
+
* /v1/providers` can never emit duplicate ids.
|
|
23
|
+
*/
|
|
24
|
+
export const RESERVED_PROVIDER_IDS = ['claude-bridge'] as const;
|
|
25
|
+
|
|
26
|
+
/** Upstream auth scheme: how the bridge presents the stored key to the provider. */
|
|
27
|
+
export type ProviderKind = 'openai' | 'anthropic';
|
|
28
|
+
|
|
29
|
+
export interface ProviderConfig {
|
|
30
|
+
/**
|
|
31
|
+
* `openai` → forward `Authorization: Bearer <key>` (OpenAI Chat Completions, OpenCode Zen, most
|
|
32
|
+
* gateways, local Ollama/LM Studio). `anthropic` → forward `x-api-key: <key>` + `anthropic-version`
|
|
33
|
+
* (native Anthropic Messages API).
|
|
34
|
+
*/
|
|
35
|
+
kind: ProviderKind;
|
|
36
|
+
/** Human label shown in the editor picker and `provider list`. */
|
|
37
|
+
label: string;
|
|
38
|
+
/** Upstream base URL INCLUDING the version segment, e.g. `https://api.openai.com/v1`. */
|
|
39
|
+
baseUrl: string;
|
|
40
|
+
/** Provider API key. Empty string = not yet configured (provider stays effectively unusable). */
|
|
41
|
+
apiKey: string;
|
|
42
|
+
/** When false the provider is hidden from discovery and its proxy route 404s. */
|
|
43
|
+
enabled: boolean;
|
|
44
|
+
/** True for the shipped presets (openai/anthropic/opencode-zen/cerebras); false for user-added. */
|
|
45
|
+
builtin?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface BridgeConfig {
|
|
49
|
+
token: string;
|
|
50
|
+
port: number;
|
|
51
|
+
origins: string[];
|
|
52
|
+
providers: Record<string, ProviderConfig>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Shipped presets: `provider add <id> --key <k>` for one of these fills in kind/label/baseUrl so the
|
|
57
|
+
* common providers are one command away. A custom OpenAI-compatible endpoint is added with an
|
|
58
|
+
* arbitrary id plus an explicit `--base-url`.
|
|
59
|
+
*/
|
|
60
|
+
export const PROVIDER_PRESETS: Record<string, Omit<ProviderConfig, 'apiKey' | 'enabled'>> = {
|
|
61
|
+
openai: {
|
|
62
|
+
kind: 'openai',
|
|
63
|
+
label: 'OpenAI',
|
|
64
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
65
|
+
builtin: true,
|
|
66
|
+
},
|
|
67
|
+
anthropic: {
|
|
68
|
+
kind: 'anthropic',
|
|
69
|
+
label: 'Anthropic (Claude)',
|
|
70
|
+
baseUrl: 'https://api.anthropic.com/v1',
|
|
71
|
+
builtin: true,
|
|
72
|
+
},
|
|
73
|
+
'opencode-zen': {
|
|
74
|
+
kind: 'openai',
|
|
75
|
+
label: 'OpenCode Zen',
|
|
76
|
+
baseUrl: 'https://opencode.ai/zen/v1',
|
|
77
|
+
builtin: true,
|
|
78
|
+
},
|
|
79
|
+
cerebras: {
|
|
80
|
+
kind: 'openai',
|
|
81
|
+
label: 'Cerebras',
|
|
82
|
+
baseUrl: 'https://api.cerebras.ai/v1',
|
|
83
|
+
builtin: true,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const DEFAULT_PORT = 8484;
|
|
88
|
+
|
|
89
|
+
export const DEFAULT_ORIGINS = [
|
|
90
|
+
'https://editor.pix3.dev',
|
|
91
|
+
'https://cloud.pix3.dev',
|
|
92
|
+
'http://localhost:8123',
|
|
93
|
+
'http://127.0.0.1:8123',
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const CONFIG_DIR = path.join(os.homedir(), '.pix3');
|
|
97
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'agent-bridge.json');
|
|
98
|
+
/** Pre-rename config file. Read once to carry the existing pairing token forward on first run. */
|
|
99
|
+
const LEGACY_CONFIG_PATH = path.join(CONFIG_DIR, 'claude-bridge.json');
|
|
100
|
+
|
|
101
|
+
export const configPath = (): string => CONFIG_PATH;
|
|
102
|
+
|
|
103
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
104
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
105
|
+
|
|
106
|
+
const parseProviders = (raw: unknown): Record<string, ProviderConfig> => {
|
|
107
|
+
if (!isRecord(raw)) return {};
|
|
108
|
+
const providers: Record<string, ProviderConfig> = {};
|
|
109
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
110
|
+
if (!isRecord(value)) continue;
|
|
111
|
+
const kind = value.kind === 'anthropic' ? 'anthropic' : 'openai';
|
|
112
|
+
if (typeof value.baseUrl !== 'string' || !value.baseUrl) continue;
|
|
113
|
+
providers[id] = {
|
|
114
|
+
kind,
|
|
115
|
+
label: typeof value.label === 'string' && value.label ? value.label : id,
|
|
116
|
+
baseUrl: value.baseUrl.replace(/\/$/, ''),
|
|
117
|
+
apiKey: typeof value.apiKey === 'string' ? value.apiKey : '',
|
|
118
|
+
enabled: value.enabled !== false,
|
|
119
|
+
builtin: value.builtin === true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return providers;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const readLegacyToken = (): string | null => {
|
|
126
|
+
try {
|
|
127
|
+
const legacy = JSON.parse(fs.readFileSync(LEGACY_CONFIG_PATH, 'utf8')) as unknown;
|
|
128
|
+
if (isRecord(legacy) && typeof legacy.token === 'string' && legacy.token.length >= 16) {
|
|
129
|
+
return legacy.token;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
/* no legacy file */
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Load config, creating the file (and a fresh pairing token) on first run. */
|
|
138
|
+
export const loadConfig = (): BridgeConfig => {
|
|
139
|
+
let stored: Partial<BridgeConfig> = {};
|
|
140
|
+
let existed = false;
|
|
141
|
+
try {
|
|
142
|
+
stored = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) as Partial<BridgeConfig>;
|
|
143
|
+
existed = true;
|
|
144
|
+
} catch {
|
|
145
|
+
/* first run */
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const config: BridgeConfig = {
|
|
149
|
+
token:
|
|
150
|
+
typeof stored.token === 'string' && stored.token.length >= 16
|
|
151
|
+
? stored.token
|
|
152
|
+
: (readLegacyToken() ?? randomBytes(24).toString('base64url')),
|
|
153
|
+
port: typeof stored.port === 'number' ? stored.port : DEFAULT_PORT,
|
|
154
|
+
origins: [
|
|
155
|
+
...DEFAULT_ORIGINS,
|
|
156
|
+
...(Array.isArray(stored.origins) ? stored.origins.filter(o => typeof o === 'string') : []),
|
|
157
|
+
],
|
|
158
|
+
providers: parseProviders(stored.providers),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Persist on first run (or after a legacy-token migration) so the token is stable across restarts.
|
|
162
|
+
if (!existed || !stored.token) {
|
|
163
|
+
saveConfig(config);
|
|
164
|
+
}
|
|
165
|
+
return config;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** Persist only the durable fields (token, port, custom origins, providers) — never the merged defaults. */
|
|
169
|
+
export const saveConfig = (config: BridgeConfig): void => {
|
|
170
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
171
|
+
const customOrigins = config.origins.filter(o => !DEFAULT_ORIGINS.includes(o));
|
|
172
|
+
const persisted = {
|
|
173
|
+
token: config.token,
|
|
174
|
+
...(config.port !== DEFAULT_PORT ? { port: config.port } : {}),
|
|
175
|
+
...(customOrigins.length > 0 ? { origins: customOrigins } : {}),
|
|
176
|
+
providers: config.providers,
|
|
177
|
+
};
|
|
178
|
+
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(persisted, null, 2)}\n`, 'utf8');
|
|
179
|
+
};
|