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