@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.
Files changed (163) hide show
  1. package/LICENSE +21 -203
  2. package/README.md +89 -26
  3. package/bin/space.js +106 -0
  4. package/lib/agents.js +119 -0
  5. package/lib/credentials.js +95 -0
  6. package/lib/daemon.js +294 -0
  7. package/lib/harness.js +166 -0
  8. package/lib/http.js +101 -0
  9. package/lib/login.js +68 -0
  10. package/lib/mcp.js +34 -0
  11. package/lib/runtime.js +241 -0
  12. package/lib/store.js +96 -0
  13. package/lib/tools.js +93 -0
  14. package/package.json +28 -40
  15. package/NOTICE +0 -44
  16. package/assets/webhook-sdk/python/bot.py +0 -63
  17. package/assets/webhook-sdk/python/commonly.py +0 -232
  18. package/node_modules/@commonlyai/seats/README.md +0 -25
  19. package/node_modules/@commonlyai/seats/package.json +0 -22
  20. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
  21. package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
  22. package/node_modules/@commonlyai/seats/src/contract.js +0 -48
  23. package/node_modules/@commonlyai/seats/src/failures.js +0 -44
  24. package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
  25. package/node_modules/@commonlyai/seats/src/index.js +0 -22
  26. package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
  27. package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
  28. package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
  29. package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
  30. package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
  31. package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
  32. package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
  33. package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
  34. package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
  35. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
  36. package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
  37. package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
  38. package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
  39. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
  40. package/skills/commonly/SKILL.md +0 -233
  41. package/src/commands/agent.js +0 -6334
  42. package/src/commands/daemon.js +0 -933
  43. package/src/commands/dev.js +0 -153
  44. package/src/commands/doctor.js +0 -141
  45. package/src/commands/inbox.js +0 -330
  46. package/src/commands/login.js +0 -194
  47. package/src/commands/mcp.js +0 -56
  48. package/src/commands/pod.js +0 -120
  49. package/src/commands/task.js +0 -279
  50. package/src/commands/verify.js +0 -48
  51. package/src/index.js +0 -99
  52. package/src/lib/adapter-login.js +0 -76
  53. package/src/lib/adapters/broker-bridge-entry.js +0 -76
  54. package/src/lib/adapters/claude.js +0 -2207
  55. package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
  56. package/src/lib/adapters/codex.js +0 -1898
  57. package/src/lib/adapters/dsh.js +0 -513
  58. package/src/lib/adapters/goose.js +0 -510
  59. package/src/lib/adapters/hermes.js +0 -18
  60. package/src/lib/adapters/index.js +0 -68
  61. package/src/lib/adapters/opencode.js +0 -1681
  62. package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
  63. package/src/lib/adapters/pi-mcp-client.mjs +0 -617
  64. package/src/lib/adapters/pi-shell-env.mjs +0 -41
  65. package/src/lib/adapters/pi.js +0 -1025
  66. package/src/lib/adapters/qwen.js +0 -523
  67. package/src/lib/adapters/seat-model.js +0 -8
  68. package/src/lib/adapters/stub.js +0 -1
  69. package/src/lib/adapters/tested-version.js +0 -106
  70. package/src/lib/api.js +0 -205
  71. package/src/lib/claim-outcome.js +0 -128
  72. package/src/lib/claimed-work-gate.js +0 -73
  73. package/src/lib/cli-hint.js +0 -33
  74. package/src/lib/commit-attribution.js +0 -391
  75. package/src/lib/config.js +0 -155
  76. package/src/lib/content-refusal.js +0 -94
  77. package/src/lib/credential-file.js +0 -208
  78. package/src/lib/daemon-agent-states.js +0 -95
  79. package/src/lib/daemon-hint.js +0 -18
  80. package/src/lib/daemon-logs.js +0 -43
  81. package/src/lib/daemon-service-windows.js +0 -263
  82. package/src/lib/daemon-service.js +0 -486
  83. package/src/lib/daemon-state.js +0 -84
  84. package/src/lib/daemon-store.js +0 -119
  85. package/src/lib/daemon-supervisor.js +0 -629
  86. package/src/lib/daemon-watch.js +0 -115
  87. package/src/lib/declared-mcp-guard.js +0 -284
  88. package/src/lib/default-environment.js +0 -207
  89. package/src/lib/device-login.js +0 -126
  90. package/src/lib/digest-frame.js +0 -151
  91. package/src/lib/doctor.js +0 -291
  92. package/src/lib/egress-proxy.js +0 -160
  93. package/src/lib/enforcement.js +0 -946
  94. package/src/lib/environment.js +0 -681
  95. package/src/lib/first-output-watchdog.js +0 -191
  96. package/src/lib/git-readonly-server.js +0 -138
  97. package/src/lib/git-readonly.js +0 -463
  98. package/src/lib/git-remote-commonly.mjs +0 -100
  99. package/src/lib/grant-broker-guard.js +0 -345
  100. package/src/lib/hooks-config.js +0 -238
  101. package/src/lib/host-gh-broker.js +0 -152
  102. package/src/lib/host-gh-shim.mjs +0 -98
  103. package/src/lib/inbox.js +0 -483
  104. package/src/lib/instance-proxy.js +0 -76
  105. package/src/lib/machine-host.js +0 -29
  106. package/src/lib/mcp-credential-delivery.js +0 -121
  107. package/src/lib/mcp-first-run.js +0 -50
  108. package/src/lib/mcp-home.js +0 -644
  109. package/src/lib/mcp-ready.js +0 -68
  110. package/src/lib/mcp-server-version.js +0 -123
  111. package/src/lib/mcp-token.js +0 -36
  112. package/src/lib/mcp-warm-child.mjs +0 -412
  113. package/src/lib/memory-bridge.js +0 -191
  114. package/src/lib/memory-import.js +0 -156
  115. package/src/lib/missing-harness.js +0 -31
  116. package/src/lib/model-unavailable.js +0 -154
  117. package/src/lib/native-runtime-inventory.js +0 -40
  118. package/src/lib/outsider-grant-mcp.js +0 -82
  119. package/src/lib/own-key-launch.js +0 -42
  120. package/src/lib/pod-catch-up.js +0 -55
  121. package/src/lib/pod-focus.js +0 -224
  122. package/src/lib/pod-tail.js +0 -68
  123. package/src/lib/poll-liveness.js +0 -103
  124. package/src/lib/poll-retry.js +0 -81
  125. package/src/lib/poller.js +0 -200
  126. package/src/lib/provider-proxy.js +0 -42
  127. package/src/lib/public-seat-mcp-flag.js +0 -36
  128. package/src/lib/public-tool-error.js +0 -60
  129. package/src/lib/public-withheld-tools.js +0 -48
  130. package/src/lib/reply-receipt.js +0 -55
  131. package/src/lib/resolve-on-path.js +0 -22
  132. package/src/lib/runtime-inventory.js +0 -56
  133. package/src/lib/runtime-observation.js +0 -37
  134. package/src/lib/sandbox/bwrap.js +0 -275
  135. package/src/lib/sandbox/codex-perimeter.js +0 -16
  136. package/src/lib/sandbox/host-check.js +0 -268
  137. package/src/lib/sandbox/mode.js +0 -49
  138. package/src/lib/sandbox/opencode-loopback.mjs +0 -68
  139. package/src/lib/sandbox/seatbelt.js +0 -684
  140. package/src/lib/sandbox/turn-dir.js +0 -56
  141. package/src/lib/sandbox/windows-unsandboxed.js +0 -183
  142. package/src/lib/sandbox/workspace-git-config.js +0 -243
  143. package/src/lib/sandbox/workspace-pin.js +0 -158
  144. package/src/lib/seat-runtime-controls.js +0 -61
  145. package/src/lib/seat-stop.js +0 -97
  146. package/src/lib/server-skills.js +0 -32
  147. package/src/lib/session-recycle.js +0 -219
  148. package/src/lib/session-store.js +0 -215
  149. package/src/lib/skill-prompt.js +0 -74
  150. package/src/lib/skills-import.js +0 -113
  151. package/src/lib/spawn-credential.js +0 -402
  152. package/src/lib/spawn-retry.js +0 -311
  153. package/src/lib/stable-install.js +0 -176
  154. package/src/lib/supervised-turn.js +0 -352
  155. package/src/lib/task.js +0 -235
  156. package/src/lib/turn-failure-notice.js +0 -177
  157. package/src/lib/unconfined-seat-guard.js +0 -94
  158. package/src/lib/update-check.js +0 -192
  159. package/src/lib/upstream-refusal.js +0 -1
  160. package/src/lib/webhook-server.js +0 -109
  161. package/src/lib/windows-command.js +0 -393
  162. package/src/lib/workspace-git.js +0 -87
  163. 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
-