@ravefox/space 0.2.0 → 0.3.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/LICENSE +21 -203
- package/README.md +89 -26
- package/bin/space.js +106 -0
- package/lib/agents.js +119 -0
- package/lib/credentials.js +95 -0
- package/lib/daemon.js +294 -0
- package/lib/harness.js +166 -0
- package/lib/http.js +101 -0
- package/lib/login.js +68 -0
- package/lib/mcp.js +34 -0
- package/lib/runtime.js +241 -0
- package/lib/store.js +96 -0
- package/lib/tools.js +93 -0
- package/package.json +28 -40
- package/NOTICE +0 -44
- package/assets/webhook-sdk/python/bot.py +0 -63
- package/assets/webhook-sdk/python/commonly.py +0 -232
- package/node_modules/@commonlyai/seats/README.md +0 -25
- package/node_modules/@commonlyai/seats/package.json +0 -22
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
- package/node_modules/@commonlyai/seats/src/contract.js +0 -48
- package/node_modules/@commonlyai/seats/src/failures.js +0 -44
- package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
- package/node_modules/@commonlyai/seats/src/index.js +0 -22
- package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
- package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
- package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
- package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
- package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
- package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
- package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
- package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
- package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
- package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
- package/skills/commonly/SKILL.md +0 -233
- package/src/commands/agent.js +0 -6334
- package/src/commands/daemon.js +0 -933
- package/src/commands/dev.js +0 -153
- package/src/commands/doctor.js +0 -141
- package/src/commands/inbox.js +0 -330
- package/src/commands/login.js +0 -194
- package/src/commands/mcp.js +0 -56
- package/src/commands/pod.js +0 -120
- package/src/commands/task.js +0 -279
- package/src/commands/verify.js +0 -48
- package/src/index.js +0 -99
- package/src/lib/adapter-login.js +0 -76
- package/src/lib/adapters/broker-bridge-entry.js +0 -76
- package/src/lib/adapters/claude.js +0 -2207
- package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
- package/src/lib/adapters/codex.js +0 -1898
- package/src/lib/adapters/dsh.js +0 -513
- package/src/lib/adapters/goose.js +0 -510
- package/src/lib/adapters/hermes.js +0 -18
- package/src/lib/adapters/index.js +0 -68
- package/src/lib/adapters/opencode.js +0 -1681
- package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
- package/src/lib/adapters/pi-mcp-client.mjs +0 -617
- package/src/lib/adapters/pi-shell-env.mjs +0 -41
- package/src/lib/adapters/pi.js +0 -1025
- package/src/lib/adapters/qwen.js +0 -523
- package/src/lib/adapters/seat-model.js +0 -8
- package/src/lib/adapters/stub.js +0 -1
- package/src/lib/adapters/tested-version.js +0 -106
- package/src/lib/api.js +0 -205
- package/src/lib/claim-outcome.js +0 -128
- package/src/lib/claimed-work-gate.js +0 -73
- package/src/lib/cli-hint.js +0 -33
- package/src/lib/commit-attribution.js +0 -391
- package/src/lib/config.js +0 -155
- package/src/lib/content-refusal.js +0 -94
- package/src/lib/credential-file.js +0 -208
- package/src/lib/daemon-agent-states.js +0 -95
- package/src/lib/daemon-hint.js +0 -18
- package/src/lib/daemon-logs.js +0 -43
- package/src/lib/daemon-service-windows.js +0 -263
- package/src/lib/daemon-service.js +0 -486
- package/src/lib/daemon-state.js +0 -84
- package/src/lib/daemon-store.js +0 -119
- package/src/lib/daemon-supervisor.js +0 -629
- package/src/lib/daemon-watch.js +0 -115
- package/src/lib/declared-mcp-guard.js +0 -284
- package/src/lib/default-environment.js +0 -207
- package/src/lib/device-login.js +0 -126
- package/src/lib/digest-frame.js +0 -151
- package/src/lib/doctor.js +0 -291
- package/src/lib/egress-proxy.js +0 -160
- package/src/lib/enforcement.js +0 -946
- package/src/lib/environment.js +0 -681
- package/src/lib/first-output-watchdog.js +0 -191
- package/src/lib/git-readonly-server.js +0 -138
- package/src/lib/git-readonly.js +0 -463
- package/src/lib/git-remote-commonly.mjs +0 -100
- package/src/lib/grant-broker-guard.js +0 -345
- package/src/lib/hooks-config.js +0 -238
- package/src/lib/host-gh-broker.js +0 -152
- package/src/lib/host-gh-shim.mjs +0 -98
- package/src/lib/inbox.js +0 -483
- package/src/lib/instance-proxy.js +0 -76
- package/src/lib/machine-host.js +0 -29
- package/src/lib/mcp-credential-delivery.js +0 -121
- package/src/lib/mcp-first-run.js +0 -50
- package/src/lib/mcp-home.js +0 -644
- package/src/lib/mcp-ready.js +0 -68
- package/src/lib/mcp-server-version.js +0 -123
- package/src/lib/mcp-token.js +0 -36
- package/src/lib/mcp-warm-child.mjs +0 -412
- package/src/lib/memory-bridge.js +0 -191
- package/src/lib/memory-import.js +0 -156
- package/src/lib/missing-harness.js +0 -31
- package/src/lib/model-unavailable.js +0 -154
- package/src/lib/native-runtime-inventory.js +0 -40
- package/src/lib/outsider-grant-mcp.js +0 -82
- package/src/lib/own-key-launch.js +0 -42
- package/src/lib/pod-catch-up.js +0 -55
- package/src/lib/pod-focus.js +0 -224
- package/src/lib/pod-tail.js +0 -68
- package/src/lib/poll-liveness.js +0 -103
- package/src/lib/poll-retry.js +0 -81
- package/src/lib/poller.js +0 -200
- package/src/lib/provider-proxy.js +0 -42
- package/src/lib/public-seat-mcp-flag.js +0 -36
- package/src/lib/public-tool-error.js +0 -60
- package/src/lib/public-withheld-tools.js +0 -48
- package/src/lib/reply-receipt.js +0 -55
- package/src/lib/resolve-on-path.js +0 -22
- package/src/lib/runtime-inventory.js +0 -56
- package/src/lib/runtime-observation.js +0 -37
- package/src/lib/sandbox/bwrap.js +0 -275
- package/src/lib/sandbox/codex-perimeter.js +0 -16
- package/src/lib/sandbox/host-check.js +0 -268
- package/src/lib/sandbox/mode.js +0 -49
- package/src/lib/sandbox/opencode-loopback.mjs +0 -68
- package/src/lib/sandbox/seatbelt.js +0 -684
- package/src/lib/sandbox/turn-dir.js +0 -56
- package/src/lib/sandbox/windows-unsandboxed.js +0 -183
- package/src/lib/sandbox/workspace-git-config.js +0 -243
- package/src/lib/sandbox/workspace-pin.js +0 -158
- package/src/lib/seat-runtime-controls.js +0 -61
- package/src/lib/seat-stop.js +0 -97
- package/src/lib/server-skills.js +0 -32
- package/src/lib/session-recycle.js +0 -219
- package/src/lib/session-store.js +0 -215
- package/src/lib/skill-prompt.js +0 -74
- package/src/lib/skills-import.js +0 -113
- package/src/lib/spawn-credential.js +0 -402
- package/src/lib/spawn-retry.js +0 -311
- package/src/lib/stable-install.js +0 -176
- package/src/lib/supervised-turn.js +0 -352
- package/src/lib/task.js +0 -235
- package/src/lib/turn-failure-notice.js +0 -177
- package/src/lib/unconfined-seat-guard.js +0 -94
- package/src/lib/update-check.js +0 -192
- package/src/lib/upstream-refusal.js +0 -1
- package/src/lib/webhook-server.js +0 -109
- package/src/lib/windows-command.js +0 -393
- package/src/lib/workspace-git.js +0 -87
- package/src/lib/workspace-trust.js +0 -162
|
@@ -1,529 +0,0 @@
|
|
|
1
|
-
import { Transform } from 'node:stream';
|
|
2
|
-
import { StringDecoder } from 'node:string_decoder';
|
|
3
|
-
/** The per-spawn loopback proxy that holds a provider key for a CLI (OpenAI chat, Anthropic Messages, OpenAI Responses). */
|
|
4
|
-
import { randomBytes, timingSafeEqual } from 'crypto';
|
|
5
|
-
import { constants as fsConstants, realpathSync, statSync } from 'fs';
|
|
6
|
-
import { createServer, request as httpRequest } from 'http';
|
|
7
|
-
import { request as httpsRequest } from 'https';
|
|
8
|
-
import { chmod, lstat, open, unlink, writeFile } from 'fs/promises';
|
|
9
|
-
import {
|
|
10
|
-
dirname, isAbsolute, relative, sep,
|
|
11
|
-
} from 'path';
|
|
12
|
-
|
|
13
|
-
// Providers sometimes echo request headers in an error response. Redact the
|
|
14
|
-
// material across arbitrary stream chunk boundaries before any model sees it.
|
|
15
|
-
export const redactProviderMaterial = (material) => {
|
|
16
|
-
const secret = Buffer.from(material);
|
|
17
|
-
// An empty key matches everywhere and the scan below would never advance.
|
|
18
|
-
if (secret.length === 0) throw new TypeError('provider material to redact is empty');
|
|
19
|
-
let pending = Buffer.alloc(0);
|
|
20
|
-
return new Transform({
|
|
21
|
-
transform(chunk, _encoding, done) {
|
|
22
|
-
pending = Buffer.concat([pending, Buffer.from(chunk)]);
|
|
23
|
-
while (pending.length >= secret.length) {
|
|
24
|
-
const index = pending.indexOf(secret);
|
|
25
|
-
if (index >= 0) {
|
|
26
|
-
this.push(pending.subarray(0, index)); this.push('[redacted]');
|
|
27
|
-
pending = pending.subarray(index + secret.length);
|
|
28
|
-
} else {
|
|
29
|
-
const count = pending.length - secret.length + 1;
|
|
30
|
-
this.push(pending.subarray(0, count)); pending = pending.subarray(count);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
// Hold back only the longest tail that could still start the key, so a
|
|
34
|
-
// streamed token is not delayed until a key's length of bytes follows it.
|
|
35
|
-
let keep = Math.min(pending.length, secret.length - 1);
|
|
36
|
-
while (keep > 0 && !pending.subarray(pending.length - keep).equals(secret.subarray(0, keep))) keep -= 1;
|
|
37
|
-
if (pending.length > keep) {
|
|
38
|
-
this.push(pending.subarray(0, pending.length - keep)); pending = pending.subarray(pending.length - keep);
|
|
39
|
-
}
|
|
40
|
-
done();
|
|
41
|
-
},
|
|
42
|
-
flush(done) { this.push(pending); pending = Buffer.alloc(0); done(); },
|
|
43
|
-
});
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const MAX_PINNED_REQUEST_BYTES = 8 * 1024 * 1024;
|
|
47
|
-
const MAX_PINNED_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Checks that every completion an upstream serves names the pinned model.
|
|
51
|
-
* SSE is checked line by line, and nothing is released until a line has named
|
|
52
|
-
* the pinned model; after that only checked lines are released, so a chunk
|
|
53
|
-
* naming another model never reaches the CLI. Any other body is held whole and
|
|
54
|
-
* released only after its `model` is checked. A body that names no model is
|
|
55
|
-
* unverified, which counts as a failure: the pin is only as good as the check.
|
|
56
|
-
*/
|
|
57
|
-
const servedModelCheck = ({ pinned, stream, onServed }) => {
|
|
58
|
-
// A chunk boundary can fall inside a multi-byte character; the decoder
|
|
59
|
-
// carries the partial bytes into the next chunk instead of mangling both.
|
|
60
|
-
const decoder = new StringDecoder('utf8');
|
|
61
|
-
let pending = '';
|
|
62
|
-
let held = '';
|
|
63
|
-
let named = false;
|
|
64
|
-
let size = 0;
|
|
65
|
-
const check = (value) => {
|
|
66
|
-
if (typeof value !== 'string') return;
|
|
67
|
-
named = true;
|
|
68
|
-
onServed(value);
|
|
69
|
-
if (value !== pinned) throw new Error(`served model ${JSON.stringify(value)} is not the pinned ${JSON.stringify(pinned)}`);
|
|
70
|
-
};
|
|
71
|
-
const checkLine = (line) => {
|
|
72
|
-
const data = /^data:\s?(.*)$/.exec(line.replace(/\r$/, ''))?.[1];
|
|
73
|
-
if (data === undefined || data.trim() === '[DONE]' || !data.trim()) return;
|
|
74
|
-
let parsed;
|
|
75
|
-
try { parsed = JSON.parse(data); } catch { return; }
|
|
76
|
-
check(parsed?.model);
|
|
77
|
-
};
|
|
78
|
-
return new Transform({
|
|
79
|
-
transform(chunk, _encoding, done) {
|
|
80
|
-
size += chunk.length;
|
|
81
|
-
if (size > MAX_PINNED_RESPONSE_BYTES) { done(new Error('provider response exceeded the pinned-model check limit')); return; }
|
|
82
|
-
pending += decoder.write(chunk);
|
|
83
|
-
if (!stream) { done(); return; }
|
|
84
|
-
const end = pending.lastIndexOf('\n');
|
|
85
|
-
if (end < 0) { done(); return; }
|
|
86
|
-
const complete = pending.slice(0, end + 1);
|
|
87
|
-
pending = pending.slice(end + 1);
|
|
88
|
-
try { complete.split('\n').forEach(checkLine); } catch (error) { done(error); return; }
|
|
89
|
-
held += complete;
|
|
90
|
-
if (!named) { done(); return; }
|
|
91
|
-
const release = held;
|
|
92
|
-
held = '';
|
|
93
|
-
done(null, release);
|
|
94
|
-
},
|
|
95
|
-
flush(done) {
|
|
96
|
-
pending += decoder.end();
|
|
97
|
-
try {
|
|
98
|
-
if (stream) checkLine(pending);
|
|
99
|
-
else {
|
|
100
|
-
let parsed;
|
|
101
|
-
try { parsed = JSON.parse(pending); } catch { parsed = null; }
|
|
102
|
-
check(parsed?.model);
|
|
103
|
-
}
|
|
104
|
-
if (!named) throw new Error(`provider response named no model; the pinned ${JSON.stringify(pinned)} cannot be verified`);
|
|
105
|
-
} catch (error) { done(error); return; }
|
|
106
|
-
done(null, held + pending);
|
|
107
|
-
},
|
|
108
|
-
});
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
const isPathWithin = (parent, candidate) => {
|
|
112
|
-
const fromParent = relative(parent, candidate);
|
|
113
|
-
return fromParent === '' || (fromParent !== '..'
|
|
114
|
-
&& !fromParent.startsWith(`..${sep}`)
|
|
115
|
-
&& !isAbsolute(fromParent));
|
|
116
|
-
};
|
|
117
|
-
// What the file system says a path IS, not how it was spelled: one device and
|
|
118
|
-
// inode, whatever the letter case on a case-insensitive volume, a macOS
|
|
119
|
-
// firmlink (/System/Volumes/Data/opt is /opt) or a symlinked directory.
|
|
120
|
-
// BigInt: inodes on the macOS sealed system volume exceed 2^53, and as numbers
|
|
121
|
-
// /usr and /usr/bin round to the same value.
|
|
122
|
-
const identityOf = (path) => {
|
|
123
|
-
try {
|
|
124
|
-
const details = statSync(path, { bigint: true });
|
|
125
|
-
return `${details.dev}:${details.ino}`;
|
|
126
|
-
} catch {
|
|
127
|
-
return null;
|
|
128
|
-
}
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
const realOrSelf = (path) => {
|
|
132
|
-
try { return realpathSync(path); } catch { return path; }
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* The root a sandbox could read this key file through, or null. A subpath root
|
|
137
|
-
* holds the file when it is the file's directory or one of its ancestors; a
|
|
138
|
-
* literal root only when it is the file itself. Either of two tests refuses:
|
|
139
|
-
* the paths as written and real-pathed (which also covers a root that doesn't
|
|
140
|
-
* exist yet), and file identity, so any spelling the file system resolves to
|
|
141
|
-
* the same place is treated as that place. A second hard link is refused by
|
|
142
|
-
* resolveProviderKeyFile (one link only), not here.
|
|
143
|
-
*/
|
|
144
|
-
export const keyFileReadableRoot = (keyFile, { subpaths = [], literals = [] } = {}) => {
|
|
145
|
-
const ancestors = new Set();
|
|
146
|
-
for (let current = keyFile; ; current = dirname(current)) {
|
|
147
|
-
const id = identityOf(current);
|
|
148
|
-
if (id) ancestors.add(id);
|
|
149
|
-
if (dirname(current) === current) break;
|
|
150
|
-
}
|
|
151
|
-
for (const root of subpaths) {
|
|
152
|
-
if (isPathWithin(root, keyFile) || isPathWithin(realOrSelf(root), keyFile)) return root;
|
|
153
|
-
const id = identityOf(root);
|
|
154
|
-
if (id && ancestors.has(id)) return root;
|
|
155
|
-
}
|
|
156
|
-
const own = identityOf(keyFile);
|
|
157
|
-
for (const root of literals) {
|
|
158
|
-
if (root === keyFile || realOrSelf(root) === keyFile) return root;
|
|
159
|
-
if (own && identityOf(root) === own) return root;
|
|
160
|
-
}
|
|
161
|
-
return null;
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Validate the key path without reading the key. The proxy reads it at spawn.
|
|
166
|
-
* `readableRoots` ({ subpaths, literals }) is what a sandboxed seat can read;
|
|
167
|
-
* the proxy reads the key outside the sandbox, so a key file there is refused.
|
|
168
|
-
*/
|
|
169
|
-
export const resolveProviderKeyFile = async (
|
|
170
|
-
provider,
|
|
171
|
-
workspacePath,
|
|
172
|
-
adapterLabel = 'OpenCode',
|
|
173
|
-
{ readableRoots } = {},
|
|
174
|
-
) => {
|
|
175
|
-
if (!provider) return null;
|
|
176
|
-
const keyFile = provider.keyFile;
|
|
177
|
-
if (typeof keyFile !== 'string' || !isAbsolute(keyFile)) {
|
|
178
|
-
throw new Error(`${adapterLabel} provider.keyFile must be an absolute path`);
|
|
179
|
-
}
|
|
180
|
-
if (/[\r\n}]/.test(keyFile)) {
|
|
181
|
-
throw new Error(`${adapterLabel} provider.keyFile contains characters unsafe for a file reference`);
|
|
182
|
-
}
|
|
183
|
-
let details;
|
|
184
|
-
try {
|
|
185
|
-
details = await lstat(keyFile);
|
|
186
|
-
} catch (err) {
|
|
187
|
-
throw new Error(`${adapterLabel} provider key file is unavailable: ${err.message}`);
|
|
188
|
-
}
|
|
189
|
-
if (details.isSymbolicLink() || !details.isFile()) {
|
|
190
|
-
throw new Error(`${adapterLabel} provider.keyFile must be a regular, non-symlink file`);
|
|
191
|
-
}
|
|
192
|
-
if ((details.mode & 0o777) !== 0o600) {
|
|
193
|
-
throw new Error(`${adapterLabel} provider.keyFile must have mode 0600`);
|
|
194
|
-
}
|
|
195
|
-
// One name only: a second link elsewhere is the same file under a path the
|
|
196
|
-
// checks below never look at.
|
|
197
|
-
if (details.nlink !== 1) {
|
|
198
|
-
throw new Error(`${adapterLabel} provider.keyFile must have exactly one link (no hard links)`);
|
|
199
|
-
}
|
|
200
|
-
if (typeof workspacePath !== 'string' || !isAbsolute(workspacePath)) {
|
|
201
|
-
throw new Error(`${adapterLabel} provider.keyFile requires an absolute workspace path`);
|
|
202
|
-
}
|
|
203
|
-
let realKeyFile;
|
|
204
|
-
let realWorkspace;
|
|
205
|
-
try {
|
|
206
|
-
realKeyFile = realpathSync(keyFile);
|
|
207
|
-
realWorkspace = realpathSync(workspacePath);
|
|
208
|
-
} catch (err) {
|
|
209
|
-
throw new Error(`${adapterLabel} provider key path could not be resolved: ${err.message}`);
|
|
210
|
-
}
|
|
211
|
-
if (isPathWithin(realWorkspace, realKeyFile) || keyFileReadableRoot(realKeyFile, { subpaths: [realWorkspace] })) {
|
|
212
|
-
throw new Error(`${adapterLabel} provider.keyFile must be outside the workspace`);
|
|
213
|
-
}
|
|
214
|
-
const readableRoot = readableRoots ? keyFileReadableRoot(realKeyFile, readableRoots) : null;
|
|
215
|
-
if (readableRoot) {
|
|
216
|
-
throw new Error(
|
|
217
|
-
`${adapterLabel} provider.keyFile ${realKeyFile} is inside the seat's sandbox; move it outside ${readableRoot}`,
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
return realKeyFile;
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
const readProviderKey = async (keyFile, adapterLabel) => {
|
|
224
|
-
const noFollow = fsConstants.O_NOFOLLOW || 0;
|
|
225
|
-
const handle = await open(keyFile, fsConstants.O_RDONLY | noFollow);
|
|
226
|
-
try {
|
|
227
|
-
const details = await handle.stat();
|
|
228
|
-
if (!details.isFile() || (details.mode & 0o777) !== 0o600 || details.nlink !== 1) {
|
|
229
|
-
throw new Error(`${adapterLabel} provider.keyFile changed after validation; expected a regular mode-0600 file with one link`);
|
|
230
|
-
}
|
|
231
|
-
const value = (await handle.readFile()).toString('utf8').trim();
|
|
232
|
-
if (!value) throw new Error(`${adapterLabel} provider.keyFile must not be empty`);
|
|
233
|
-
return value;
|
|
234
|
-
} finally {
|
|
235
|
-
await handle.close();
|
|
236
|
-
}
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
// The routes a CLI on each wire may call, relative to the configured base
|
|
240
|
-
// path, and the header the real key goes out in. Anything else is refused.
|
|
241
|
-
// `search` lists the exact query strings a wire's client sends, forwarded as
|
|
242
|
-
// sent: Claude Code calls `/v1/messages?beta=true` (F96). Any other query is
|
|
243
|
-
// refused.
|
|
244
|
-
const WIRES = Object.freeze({
|
|
245
|
-
'openai-chat': { routes: [['POST', 'chat/completions'], ['GET', 'models']], auth: 'bearer' },
|
|
246
|
-
'anthropic-messages': {
|
|
247
|
-
routes: [['POST', 'v1/messages'], ['POST', 'v1/messages/count_tokens'], ['GET', 'v1/models']],
|
|
248
|
-
search: ['?beta=true'],
|
|
249
|
-
auth: 'x-api-key',
|
|
250
|
-
},
|
|
251
|
-
'openai-responses': { routes: [['POST', 'responses'], ['GET', 'models']], auth: 'bearer' },
|
|
252
|
-
});
|
|
253
|
-
export const PROVIDER_WIRES = Object.freeze(Object.keys(WIRES));
|
|
254
|
-
|
|
255
|
-
const presents = (value, expected) => {
|
|
256
|
-
const supplied = Buffer.from(typeof value === 'string' ? value : '');
|
|
257
|
-
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
export const startProviderProxy = async ({
|
|
261
|
-
provider, keyFile, keyMaterial, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode', wire = 'openai-chat',
|
|
262
|
-
// When set, every completion must request this model and be served by it.
|
|
263
|
-
pinnedModel,
|
|
264
|
-
}) => {
|
|
265
|
-
const wireSpec = WIRES[wire];
|
|
266
|
-
if (!wireSpec) throw new Error(`${adapterLabel} provider wire must be one of ${PROVIDER_WIRES.join(', ')}`);
|
|
267
|
-
if (pinnedModel !== undefined && (typeof pinnedModel !== 'string' || !pinnedModel.trim())) {
|
|
268
|
-
throw new Error(`${adapterLabel} pinnedModel must be a non-empty string`);
|
|
269
|
-
}
|
|
270
|
-
const modelState = { requested: new Set(), served: new Set(), refused: 0, failures: [] };
|
|
271
|
-
const upstream = new URL(provider.baseURL);
|
|
272
|
-
if (socketPath && (!isAbsolute(socketPath)
|
|
273
|
-
|| !Number.isInteger(isolatedPort) || isolatedPort < 1 || isolatedPort > 65535)) {
|
|
274
|
-
throw new Error(`${adapterLabel} isolated proxy requires an absolute socket path and valid port`);
|
|
275
|
-
}
|
|
276
|
-
if (!['http:', 'https:'].includes(upstream.protocol)
|
|
277
|
-
|| upstream.username || upstream.password || upstream.search || upstream.hash) {
|
|
278
|
-
throw new Error(`${adapterLabel} provider.baseURL must be an http(s) URL without embedded credentials or query data`);
|
|
279
|
-
}
|
|
280
|
-
const basePath = upstream.pathname.replace(/\/+$/, '') || '/';
|
|
281
|
-
const upstreamKey = keyMaterial === undefined ? await readProviderKey(keyFile, adapterLabel) : keyMaterial;
|
|
282
|
-
if (typeof upstreamKey !== 'string' || !upstreamKey || /[\r\n]/.test(upstreamKey)) throw new Error('Invalid provider key');
|
|
283
|
-
const proxyToken = randomBytes(32).toString('base64url');
|
|
284
|
-
// A caller that hands the token to its child in memory needs no file.
|
|
285
|
-
if (tokenFile) {
|
|
286
|
-
await writeFile(tokenFile, proxyToken, { encoding: 'utf8', mode: 0o600 });
|
|
287
|
-
await chmod(tokenFile, 0o600);
|
|
288
|
-
}
|
|
289
|
-
const bearerToken = Buffer.from(`Bearer ${proxyToken}`);
|
|
290
|
-
const bareToken = Buffer.from(proxyToken);
|
|
291
|
-
|
|
292
|
-
const logRefusedRequest = (method, requestPath) => {
|
|
293
|
-
let normalizedPath = '/';
|
|
294
|
-
try {
|
|
295
|
-
normalizedPath = new URL(requestPath || '/', 'http://127.0.0.1').pathname || '/';
|
|
296
|
-
} catch {
|
|
297
|
-
// Do not include an unparseable raw request target in logs.
|
|
298
|
-
}
|
|
299
|
-
const safeMethod = typeof method === 'string' && /^[A-Z]+$/.test(method)
|
|
300
|
-
? method : 'UNKNOWN';
|
|
301
|
-
process.stderr.write(`[${adapterLabel.toLowerCase()}] provider proxy refused ${safeMethod} ${normalizedPath}\n`);
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
// A refusal answers before the request body has been read. A keep-alive
|
|
305
|
-
// client that reuses that socket can have its next request reset, so every
|
|
306
|
-
// refusal on every wire also closes the connection.
|
|
307
|
-
const refuse = (incoming, outgoing, status, headers = {}, body = undefined) => {
|
|
308
|
-
incoming.resume();
|
|
309
|
-
outgoing.writeHead(status, { ...headers, connection: 'close' });
|
|
310
|
-
outgoing.end(body);
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
const server = createServer((incoming, outgoing) => {
|
|
314
|
-
// Anthropic clients send their key as x-api-key, the others as a Bearer.
|
|
315
|
-
if (!presents(incoming.headers.authorization, bearerToken)
|
|
316
|
-
&& !presents(incoming.headers['x-api-key'], bareToken)) {
|
|
317
|
-
refuse(incoming, outgoing, 401);
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
if (!['GET', 'POST'].includes(incoming.method || '')) {
|
|
321
|
-
refuse(incoming, outgoing, 405);
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
const rejectRoute = (path) => {
|
|
325
|
-
logRefusedRequest(incoming.method, path);
|
|
326
|
-
refuse(incoming, outgoing, 403, { 'content-type': 'text/plain; charset=utf-8' },
|
|
327
|
-
`Unsupported provider request: ${incoming.method || 'UNKNOWN'} ${path}`);
|
|
328
|
-
};
|
|
329
|
-
const rejectBadRequest = () => {
|
|
330
|
-
logRefusedRequest(incoming.method, incoming.url);
|
|
331
|
-
refuse(incoming, outgoing, 400);
|
|
332
|
-
};
|
|
333
|
-
|
|
334
|
-
let target;
|
|
335
|
-
try {
|
|
336
|
-
const requestPath = incoming.url || '/';
|
|
337
|
-
if (!requestPath.startsWith('/') || requestPath.startsWith('//')) {
|
|
338
|
-
rejectBadRequest();
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
if (/%(?:2f|5c|2e)/i.test(requestPath)) {
|
|
342
|
-
rejectBadRequest();
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
// The CLI owns this local request path, so never use it as an outbound
|
|
346
|
-
// URL. Each wire needs only the fixed routes in WIRES; parse the
|
|
347
|
-
// incoming URL against a loopback sentinel, then select a
|
|
348
|
-
// constant endpoint after matching the configured base path exactly.
|
|
349
|
-
// This keeps a CLI request from turning the proxy into an arbitrary
|
|
350
|
-
// path/URL fetcher (including paths on a local provider host).
|
|
351
|
-
const requested = new URL(requestPath, 'http://127.0.0.1');
|
|
352
|
-
if (requested.origin !== 'http://127.0.0.1' || requested.hash
|
|
353
|
-
|| (requested.search && !(wireSpec.search || []).includes(requested.search))) {
|
|
354
|
-
rejectRoute(requested.pathname);
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
const configuredPath = basePath === '/' ? '' : basePath;
|
|
358
|
-
const route = wireSpec.routes.find(([method, path]) => incoming.method === method
|
|
359
|
-
&& requested.pathname === `${configuredPath}/${path}`);
|
|
360
|
-
if (!route) {
|
|
361
|
-
rejectRoute(requested.pathname);
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
const endpoint = route[1];
|
|
365
|
-
target = {
|
|
366
|
-
path: `${configuredPath}/${endpoint}${requested.search}`,
|
|
367
|
-
protocol: upstream.protocol,
|
|
368
|
-
hostname: upstream.hostname,
|
|
369
|
-
port: upstream.port || undefined,
|
|
370
|
-
host: upstream.host,
|
|
371
|
-
};
|
|
372
|
-
} catch {
|
|
373
|
-
rejectBadRequest();
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const headers = Object.fromEntries(Object.entries(incoming.headers)
|
|
378
|
-
.filter(([name]) => ![
|
|
379
|
-
'authorization', 'connection', 'host', 'keep-alive', 'proxy-authenticate',
|
|
380
|
-
'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
381
|
-
'x-api-key',
|
|
382
|
-
].includes(name.toLowerCase())));
|
|
383
|
-
if (wireSpec.auth === 'x-api-key') headers['x-api-key'] = upstreamKey;
|
|
384
|
-
else headers.authorization = `Bearer ${upstreamKey}`;
|
|
385
|
-
headers.host = target.host;
|
|
386
|
-
headers['accept-encoding'] = 'identity';
|
|
387
|
-
const pinned = pinnedModel !== undefined && target.path.endsWith('/chat/completions');
|
|
388
|
-
const forward = (body) => {
|
|
389
|
-
let streamed = false;
|
|
390
|
-
if (body) {
|
|
391
|
-
headers['content-length'] = String(body.length);
|
|
392
|
-
try { streamed = JSON.parse(body.toString('utf8'))?.stream === true; } catch { /* checked before forward */ }
|
|
393
|
-
}
|
|
394
|
-
const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)({
|
|
395
|
-
protocol: target.protocol,
|
|
396
|
-
hostname: target.hostname,
|
|
397
|
-
port: target.port,
|
|
398
|
-
path: target.path,
|
|
399
|
-
method: incoming.method,
|
|
400
|
-
headers,
|
|
401
|
-
}, (response) => {
|
|
402
|
-
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
403
|
-
response.resume();
|
|
404
|
-
outgoing.writeHead(502).end('Provider redirects are not supported');
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
if (response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity') {
|
|
408
|
-
response.resume(); outgoing.writeHead(502).end('Compressed provider responses are not supported'); return;
|
|
409
|
-
}
|
|
410
|
-
const responseHeaders = Object.fromEntries(Object.entries(response.headers)
|
|
411
|
-
.filter(([name]) => ![
|
|
412
|
-
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
413
|
-
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
414
|
-
].includes(name.toLowerCase())));
|
|
415
|
-
delete responseHeaders['content-length'];
|
|
416
|
-
for (const [name, value] of Object.entries(responseHeaders)) {
|
|
417
|
-
if (typeof value === 'string') responseHeaders[name] = value.split(upstreamKey).join('[redacted]');
|
|
418
|
-
else if (Array.isArray(value)) responseHeaders[name] = value.map((entry) => entry.split(upstreamKey).join('[redacted]'));
|
|
419
|
-
}
|
|
420
|
-
const ok = response.statusCode >= 200 && response.statusCode < 300;
|
|
421
|
-
if (!pinned || !ok) {
|
|
422
|
-
outgoing.writeHead(response.statusCode || 502, responseHeaders);
|
|
423
|
-
response.pipe(redactProviderMaterial(upstreamKey)).pipe(outgoing);
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
const sse = streamed || /text\/event-stream/i.test(String(response.headers['content-type'] || ''));
|
|
427
|
-
const check = servedModelCheck({
|
|
428
|
-
pinned: pinnedModel, stream: sse, onServed: (served) => modelState.served.add(served),
|
|
429
|
-
});
|
|
430
|
-
const fail = (error) => {
|
|
431
|
-
modelState.failures.push(error.message);
|
|
432
|
-
process.stderr.write(`[${adapterLabel.toLowerCase()}] provider proxy: ${error.message}\n`);
|
|
433
|
-
response.destroy();
|
|
434
|
-
if (!outgoing.headersSent) outgoing.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }).end('Served model check failed');
|
|
435
|
-
else outgoing.destroy();
|
|
436
|
-
};
|
|
437
|
-
check.on('error', fail);
|
|
438
|
-
// A stream's headers go out at once; a whole body waits for its check.
|
|
439
|
-
if (sse) outgoing.writeHead(response.statusCode, responseHeaders);
|
|
440
|
-
else check.once('data', () => { if (!outgoing.headersSent) outgoing.writeHead(response.statusCode, responseHeaders); });
|
|
441
|
-
response.pipe(check).pipe(redactProviderMaterial(upstreamKey)).pipe(outgoing);
|
|
442
|
-
});
|
|
443
|
-
request.on('error', () => {
|
|
444
|
-
if (!outgoing.headersSent) outgoing.writeHead(502);
|
|
445
|
-
outgoing.end('Provider request failed');
|
|
446
|
-
});
|
|
447
|
-
outgoing.on('close', () => request.destroy());
|
|
448
|
-
incoming.on('aborted', () => request.destroy());
|
|
449
|
-
if (body) request.end(body);
|
|
450
|
-
else incoming.pipe(request);
|
|
451
|
-
};
|
|
452
|
-
if (!pinned) {
|
|
453
|
-
forward(null);
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
// A pinned proxy reads the request first: a completion for any other model
|
|
457
|
-
// is refused here, before the provider bills it.
|
|
458
|
-
const chunks = [];
|
|
459
|
-
let size = 0;
|
|
460
|
-
incoming.on('data', (chunk) => {
|
|
461
|
-
size += chunk.length;
|
|
462
|
-
if (size <= MAX_PINNED_REQUEST_BYTES) chunks.push(chunk);
|
|
463
|
-
});
|
|
464
|
-
incoming.on('end', () => {
|
|
465
|
-
if (size > MAX_PINNED_REQUEST_BYTES) { outgoing.writeHead(413).end(); return; }
|
|
466
|
-
const body = Buffer.concat(chunks);
|
|
467
|
-
let requested;
|
|
468
|
-
try { requested = JSON.parse(body.toString('utf8'))?.model; } catch { requested = undefined; }
|
|
469
|
-
if (requested !== pinnedModel) {
|
|
470
|
-
modelState.refused += 1;
|
|
471
|
-
process.stderr.write(`[${adapterLabel.toLowerCase()}] provider proxy refused a completion for an unpinned model\n`);
|
|
472
|
-
outgoing.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
473
|
-
outgoing.end(`This seat is pinned to ${pinnedModel}`);
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
modelState.requested.add(requested);
|
|
477
|
-
forward(body);
|
|
478
|
-
});
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
// A WebSocket client (codex on the Responses wire) falls back to HTTP SSE on
|
|
482
|
-
// 426. Nothing is relayed over an upgrade, so no token check is needed first.
|
|
483
|
-
server.on('upgrade', (_incoming, socket) => {
|
|
484
|
-
socket.end('HTTP/1.1 426 Upgrade Required\r\nconnection: close\r\ncontent-length: 0\r\n\r\n');
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
await new Promise((resolve, reject) => {
|
|
488
|
-
server.once('error', reject);
|
|
489
|
-
const started = () => {
|
|
490
|
-
server.removeListener('error', reject);
|
|
491
|
-
resolve();
|
|
492
|
-
};
|
|
493
|
-
if (socketPath) server.listen(socketPath, started);
|
|
494
|
-
else server.listen(0, '127.0.0.1', started);
|
|
495
|
-
});
|
|
496
|
-
if (socketPath) {
|
|
497
|
-
try {
|
|
498
|
-
await chmod(socketPath, 0o600);
|
|
499
|
-
} catch (err) {
|
|
500
|
-
await new Promise((resolve) => server.close(resolve));
|
|
501
|
-
throw err;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
const port = socketPath ? isolatedPort : server.address().port;
|
|
505
|
-
const proxyBaseURL = new URL(upstream.pathname, `http://127.0.0.1:${port}`).toString();
|
|
506
|
-
return {
|
|
507
|
-
baseURL: proxyBaseURL,
|
|
508
|
-
token: proxyToken,
|
|
509
|
-
tokenFile,
|
|
510
|
-
socketPath,
|
|
511
|
-
port,
|
|
512
|
-
/** The pin's verdict so far: what was asked for, what was served, and what failed. */
|
|
513
|
-
modelCheck: () => ({
|
|
514
|
-
pinned: pinnedModel ?? null,
|
|
515
|
-
requested: [...modelState.requested],
|
|
516
|
-
served: [...modelState.served],
|
|
517
|
-
refused: modelState.refused,
|
|
518
|
-
failures: [...modelState.failures],
|
|
519
|
-
}),
|
|
520
|
-
close: () => new Promise((resolve) => {
|
|
521
|
-
server.close(async () => {
|
|
522
|
-
if (socketPath) await unlink(socketPath).catch(() => {});
|
|
523
|
-
resolve();
|
|
524
|
-
});
|
|
525
|
-
server.closeAllConnections?.();
|
|
526
|
-
}),
|
|
527
|
-
};
|
|
528
|
-
};
|
|
529
|
-
|