@bitkyc08/opencodex 2.7.9-preview.20260712 → 2.7.9-preview.20260712.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/gui/dist/assets/index-SnN_1Qr9.js +40 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/cursor/transport-retry.ts +5 -3
- package/src/adapters/google-errors.ts +9 -19
- package/src/adapters/google-http.ts +29 -66
- package/src/adapters/kiro-errors.ts +10 -23
- package/src/adapters/kiro-retry.ts +26 -58
- package/src/adapters/upstream-http-error.ts +48 -0
- package/src/claude/gateway-cache.ts +3 -3
- package/src/claude/outbound.ts +40 -35
- package/src/cli/claude.ts +36 -4
- package/src/config.ts +54 -3
- package/src/lib/destination-policy.ts +167 -0
- package/src/lib/injection-debug-log.ts +34 -0
- package/src/lib/upstream-retry.ts +53 -3
- package/src/lib/windows-secret-acl.ts +173 -0
- package/src/oauth/store.ts +1 -0
- package/src/providers/registry.ts +5 -3
- package/src/router.ts +6 -1
- package/src/server/auth-cors.ts +4 -0
- package/src/server/claude-messages.ts +7 -1
- package/src/server/management-api.ts +85 -31
- package/src/server/request-decompress.ts +45 -12
- package/src/server/responses.ts +5 -4
- package/src/server/system-env.ts +110 -68
- package/src/service.ts +4 -0
- package/src/types.ts +8 -3
- package/gui/dist/assets/index-BcaDQD3i.js +0 -40
package/src/config.ts
CHANGED
|
@@ -3,9 +3,41 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSy
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import * as z from "zod/v4";
|
|
6
|
+
import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
|
|
7
|
+
import { providerDestinationConfigError } from "./lib/destination-policy";
|
|
6
8
|
import type { OcxConfig } from "./types";
|
|
7
9
|
|
|
8
10
|
let _atomicSeq = 0;
|
|
11
|
+
|
|
12
|
+
interface AtomicRenameIO {
|
|
13
|
+
platform: NodeJS.Platform;
|
|
14
|
+
rename: (source: string, destination: string) => void;
|
|
15
|
+
sleep: (milliseconds: number) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renameAtomicFile(
|
|
19
|
+
source: string,
|
|
20
|
+
destination: string,
|
|
21
|
+
io: AtomicRenameIO = {
|
|
22
|
+
platform: process.platform,
|
|
23
|
+
rename: renameSync,
|
|
24
|
+
sleep: Bun.sleepSync,
|
|
25
|
+
},
|
|
26
|
+
): void {
|
|
27
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
28
|
+
try {
|
|
29
|
+
io.rename(source, destination);
|
|
30
|
+
return;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
33
|
+
const transientWindowsError = io.platform === "win32"
|
|
34
|
+
&& (code === "EBUSY" || code === "EPERM" || code === "EACCES");
|
|
35
|
+
if (!transientWindowsError || attempt >= 2) throw error;
|
|
36
|
+
io.sleep(25 * (attempt + 1));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
9
41
|
/**
|
|
10
42
|
* Write a file atomically (temp + rename) so concurrent writers — e.g. `ocx stop` and the
|
|
11
43
|
* proxy's own shutdown handler both restoring Codex — can never leave a half-written file.
|
|
@@ -13,7 +45,7 @@ let _atomicSeq = 0;
|
|
|
13
45
|
export function atomicWriteFile(path: string, content: string): void {
|
|
14
46
|
const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
|
|
15
47
|
writeFileSync(tmp, content, { encoding: "utf-8", mode: 0o600 });
|
|
16
|
-
|
|
48
|
+
renameAtomicFile(tmp, path);
|
|
17
49
|
}
|
|
18
50
|
|
|
19
51
|
/**
|
|
@@ -54,6 +86,7 @@ const warnedConfigFallbacks = new Set<string>();
|
|
|
54
86
|
const providerConfigSchema = z.object({
|
|
55
87
|
adapter: z.string().min(1),
|
|
56
88
|
baseUrl: z.string().min(1),
|
|
89
|
+
allowPrivateNetwork: z.boolean().optional(),
|
|
57
90
|
}).passthrough();
|
|
58
91
|
|
|
59
92
|
const RESERVED_PROVIDER_NAMES = new Set(["__proto__", "prototype", "constructor"]);
|
|
@@ -128,6 +161,15 @@ const configSchema = z.object({
|
|
|
128
161
|
path: ["providers", name, "baseUrl"],
|
|
129
162
|
message: baseUrlError,
|
|
130
163
|
});
|
|
164
|
+
} else {
|
|
165
|
+
const destinationError = providerDestinationConfigError(name, provider);
|
|
166
|
+
if (destinationError) {
|
|
167
|
+
ctx.addIssue({
|
|
168
|
+
code: "custom",
|
|
169
|
+
path: ["providers", name, "baseUrl"],
|
|
170
|
+
message: destinationError,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
131
173
|
}
|
|
132
174
|
const headersError = providerHeadersConfigError((provider as { headers?: unknown }).headers);
|
|
133
175
|
if (headersError) {
|
|
@@ -178,15 +220,20 @@ export function hardenConfigDir(): void {
|
|
|
178
220
|
const dir = getConfigDir();
|
|
179
221
|
if (existsSync(dir)) {
|
|
180
222
|
try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
|
|
223
|
+
if (process.platform === "win32") {
|
|
224
|
+
hardenSecretDir(dir, { required: false });
|
|
225
|
+
}
|
|
181
226
|
}
|
|
182
227
|
}
|
|
183
228
|
|
|
184
229
|
export function hardenExistingSecret(path: string): void {
|
|
185
230
|
if (existsSync(path)) {
|
|
186
231
|
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
232
|
+
if (process.platform === "win32") {
|
|
233
|
+
hardenSecretPath(path, { required: false });
|
|
234
|
+
}
|
|
187
235
|
}
|
|
188
236
|
}
|
|
189
|
-
|
|
190
237
|
export function loadConfig(): OcxConfig {
|
|
191
238
|
const dir = getConfigDir();
|
|
192
239
|
const configPath = getConfigPath();
|
|
@@ -305,7 +352,11 @@ export function saveConfig(config: OcxConfig): void {
|
|
|
305
352
|
} else {
|
|
306
353
|
try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ }
|
|
307
354
|
}
|
|
308
|
-
|
|
355
|
+
if (process.platform === "win32") {
|
|
356
|
+
hardenSecretDir(dir, { required: true });
|
|
357
|
+
}
|
|
358
|
+
const configPath = getConfigPath();
|
|
359
|
+
atomicWriteFile(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
309
360
|
}
|
|
310
361
|
|
|
311
362
|
export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolean {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
import { getProviderRegistryEntry } from "../providers/registry";
|
|
4
|
+
import type { OcxProviderConfig } from "../types";
|
|
5
|
+
|
|
6
|
+
const BLOCKED_METADATA_HOSTS = new Set([
|
|
7
|
+
"instance-data.ec2.internal",
|
|
8
|
+
"metadata.azure.internal",
|
|
9
|
+
"metadata.google.internal",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const BLOCKED_METADATA_IPV4 = new Set([
|
|
13
|
+
"100.100.100.200",
|
|
14
|
+
"169.254.169.254",
|
|
15
|
+
"169.254.170.2",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const BLOCKED_METADATA_IPV6 = new Set([
|
|
19
|
+
"fd00:ec2::254",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
type DestinationKind =
|
|
23
|
+
| "public"
|
|
24
|
+
| "hostname"
|
|
25
|
+
| "localhost"
|
|
26
|
+
| "loopback"
|
|
27
|
+
| "private"
|
|
28
|
+
| "link-local"
|
|
29
|
+
| "unspecified"
|
|
30
|
+
| "metadata";
|
|
31
|
+
|
|
32
|
+
interface DestinationAssessment {
|
|
33
|
+
kind: DestinationKind;
|
|
34
|
+
detail: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeHostname(hostname: string): string {
|
|
38
|
+
const trimmed = hostname.trim().toLowerCase().replace(/\.+$/, "");
|
|
39
|
+
return trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseIpv4(hostname: string): number[] | null {
|
|
43
|
+
const parts = hostname.split(".");
|
|
44
|
+
if (parts.length !== 4) return null;
|
|
45
|
+
const octets = parts.map(part => Number(part));
|
|
46
|
+
return octets.every(octet => Number.isInteger(octet) && octet >= 0 && octet <= 255) ? octets : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function classifyIpv4(hostname: string): DestinationAssessment {
|
|
50
|
+
if (BLOCKED_METADATA_IPV4.has(hostname)) return { kind: "metadata", detail: "blocked metadata endpoint" };
|
|
51
|
+
const octets = parseIpv4(hostname);
|
|
52
|
+
if (!octets) return { kind: "public", detail: "public IP" };
|
|
53
|
+
const [a, b, c] = octets;
|
|
54
|
+
if (a === 127) return { kind: "loopback", detail: "loopback address" };
|
|
55
|
+
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127)) {
|
|
56
|
+
return { kind: "private", detail: "private-network address" };
|
|
57
|
+
}
|
|
58
|
+
if (a === 169 && b === 254) return { kind: "link-local", detail: "link-local address" };
|
|
59
|
+
if (a === 0) return { kind: "unspecified", detail: "unspecified address" };
|
|
60
|
+
// Reserved / non-public ranges (review finding, PR #96): protocol-assignment,
|
|
61
|
+
// documentation, benchmark, multicast, and reserved-future space never name a
|
|
62
|
+
// legitimate provider endpoint.
|
|
63
|
+
if (a === 192 && b === 0 && (c === 0 || c === 2)) return { kind: "private", detail: "reserved address" };
|
|
64
|
+
if (a === 198 && (b === 18 || b === 19)) return { kind: "private", detail: "benchmark address" };
|
|
65
|
+
if (a === 198 && b === 51 && c === 100) return { kind: "private", detail: "documentation address" };
|
|
66
|
+
if (a === 203 && b === 0 && c === 113) return { kind: "private", detail: "documentation address" };
|
|
67
|
+
if (a >= 224) return { kind: "private", detail: "multicast/reserved address" };
|
|
68
|
+
return { kind: "public", detail: "public IP" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function firstIpv6Hextet(hostname: string): number | null {
|
|
72
|
+
const head = hostname.split(":")[0];
|
|
73
|
+
if (!head) return 0;
|
|
74
|
+
const parsed = Number.parseInt(head, 16);
|
|
75
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function classifyIpv6(hostname: string): DestinationAssessment {
|
|
79
|
+
if (BLOCKED_METADATA_IPV6.has(hostname)) return { kind: "metadata", detail: "blocked metadata endpoint" };
|
|
80
|
+
const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)?.[1];
|
|
81
|
+
if (mappedIpv4) return classifyIpv4(mappedIpv4);
|
|
82
|
+
if (hostname === "::1") return { kind: "loopback", detail: "loopback address" };
|
|
83
|
+
if (hostname === "::") return { kind: "unspecified", detail: "unspecified address" };
|
|
84
|
+
const hextet = firstIpv6Hextet(hostname);
|
|
85
|
+
if (hextet === null) return { kind: "public", detail: "public IP" };
|
|
86
|
+
if (hextet >= 0xfc00 && hextet <= 0xfdff) return { kind: "private", detail: "private-network address" };
|
|
87
|
+
if (hextet >= 0xfe80 && hextet <= 0xfebf) return { kind: "link-local", detail: "link-local address" };
|
|
88
|
+
return { kind: "public", detail: "public IP" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function assessDestination(baseUrl: string): DestinationAssessment | null {
|
|
92
|
+
try {
|
|
93
|
+
const parsed = new URL(baseUrl.trim());
|
|
94
|
+
const hostname = normalizeHostname(parsed.hostname);
|
|
95
|
+
if (!hostname) return null;
|
|
96
|
+
if (BLOCKED_METADATA_HOSTS.has(hostname)) return { kind: "metadata", detail: "blocked metadata endpoint" };
|
|
97
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
98
|
+
return { kind: "localhost", detail: "localhost destination" };
|
|
99
|
+
}
|
|
100
|
+
const ipKind = isIP(hostname);
|
|
101
|
+
if (ipKind === 4) return classifyIpv4(hostname);
|
|
102
|
+
if (ipKind === 6) return classifyIpv6(hostname);
|
|
103
|
+
return { kind: "hostname", detail: "hostname destination" };
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function registryAllowsPrivateNetwork(name: string): boolean {
|
|
110
|
+
return getProviderRegistryEntry(name)?.allowPrivateNetworkByDefault === true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function providerDestinationConfigError(name: string, provider: Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork">): string | null {
|
|
114
|
+
const assessment = assessDestination(provider.baseUrl);
|
|
115
|
+
if (!assessment) return null;
|
|
116
|
+
if (assessment.kind === "public" || assessment.kind === "hostname") return null;
|
|
117
|
+
if (assessment.kind === "metadata") return "baseUrl targets a blocked metadata endpoint";
|
|
118
|
+
if (registryAllowsPrivateNetwork(name)) return null;
|
|
119
|
+
if (provider.allowPrivateNetwork === true) return null;
|
|
120
|
+
return `baseUrl points to a ${assessment.detail}; set allowPrivateNetwork:true only for intentionally local/self-hosted providers`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function assertProviderDestinationAllowed(name: string, provider: Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork">): void {
|
|
124
|
+
const error = providerDestinationConfigError(name, provider);
|
|
125
|
+
if (error) throw new Error(`provider ${name} ${error}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Async companion to {@link providerDestinationConfigError} for hostname destinations:
|
|
130
|
+
* resolves A/AAAA records and classifies every address, so a hostname that points at
|
|
131
|
+
* loopback/private/metadata space is caught at provider write time (review finding,
|
|
132
|
+
* PR #96 — the sync path must stay literal-only because the router hot path and
|
|
133
|
+
* config load are synchronous). DNS failures return null: config-time validation is
|
|
134
|
+
* advisory and must not hard-fail offline startups. DNS rebinding after validation is
|
|
135
|
+
* a recorded residual for this loopback proxy (devlog 260712_pr_batch_landing 000).
|
|
136
|
+
*/
|
|
137
|
+
export async function providerDestinationResolvedError(
|
|
138
|
+
name: string,
|
|
139
|
+
provider: Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork">,
|
|
140
|
+
): Promise<string | null> {
|
|
141
|
+
const syncError = providerDestinationConfigError(name, provider);
|
|
142
|
+
if (syncError) return syncError;
|
|
143
|
+
let hostname: string;
|
|
144
|
+
try {
|
|
145
|
+
hostname = normalizeHostname(new URL(provider.baseUrl.trim()).hostname);
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
if (!hostname || isIP(hostname) !== 0 || hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
150
|
+
return null; // literals and localhost are fully handled by the sync path
|
|
151
|
+
}
|
|
152
|
+
if (registryAllowsPrivateNetwork(name) || provider.allowPrivateNetwork === true) return null;
|
|
153
|
+
let addresses: { address: string }[];
|
|
154
|
+
try {
|
|
155
|
+
addresses = await lookup(hostname, { all: true, verbatim: true });
|
|
156
|
+
} catch {
|
|
157
|
+
return null; // unresolvable now ≠ malicious; the provider simply won't connect
|
|
158
|
+
}
|
|
159
|
+
for (const { address } of addresses) {
|
|
160
|
+
const ipKind = isIP(address);
|
|
161
|
+
const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null;
|
|
162
|
+
if (!assessment || assessment.kind === "public") continue;
|
|
163
|
+
if (assessment.kind === "metadata") return `baseUrl hostname ${hostname} resolves to a blocked metadata endpoint (${address})`;
|
|
164
|
+
return `baseUrl hostname ${hostname} resolves to a ${assessment.detail} (${address}); set allowPrivateNetwork:true only for intentionally local/self-hosted providers`;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** In-memory ring buffer of multi-agent guidance-injection / effort-cap log lines.
|
|
2
|
+
*
|
|
3
|
+
* Injection debug lines were previously console-only, so the GUI had an "Injection log"
|
|
4
|
+
* toggle with nothing to display. This buffer mirrors the provider debug buffer so the
|
|
5
|
+
* management API and GUI can tail injection lines the same way. Callers keep their own
|
|
6
|
+
* `isInjectionDebugEnabled()` guard; this module only stores what it is given. */
|
|
7
|
+
|
|
8
|
+
import type { DebugLogEntry } from "./debug-log-buffer";
|
|
9
|
+
|
|
10
|
+
const MAX_LINES = 2_000;
|
|
11
|
+
const buffer: DebugLogEntry[] = [];
|
|
12
|
+
let nextSeq = 1;
|
|
13
|
+
|
|
14
|
+
/** Append a line to the injection buffer and echo it to the server console. */
|
|
15
|
+
export function injectionDebugLog(line: string): void {
|
|
16
|
+
const entry: DebugLogEntry = { seq: nextSeq++, at: Date.now(), line };
|
|
17
|
+
buffer.push(entry);
|
|
18
|
+
if (buffer.length > MAX_LINES) buffer.splice(0, buffer.length - MAX_LINES);
|
|
19
|
+
console.log(line);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getInjectionDebugLogEntries(options?: { after?: number; limit?: number }): DebugLogEntry[] {
|
|
23
|
+
const after = options?.after ?? 0;
|
|
24
|
+
const limit = options?.limit ?? 500;
|
|
25
|
+
const filtered = after > 0 ? buffer.filter(entry => entry.seq > after) : buffer;
|
|
26
|
+
if (filtered.length <= limit) return filtered;
|
|
27
|
+
return filtered.slice(-limit);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Test isolation. */
|
|
31
|
+
export function resetInjectionDebugLogBufferForTests(): void {
|
|
32
|
+
buffer.length = 0;
|
|
33
|
+
nextSeq = 1;
|
|
34
|
+
}
|
|
@@ -14,12 +14,19 @@
|
|
|
14
14
|
* MUST stay a leaf module: imports nothing from server.ts or adapters (kiro-retry imports
|
|
15
15
|
* the shared abort helpers from here).
|
|
16
16
|
*/
|
|
17
|
+
import { clearableDeadline } from "./abort";
|
|
17
18
|
|
|
18
19
|
// 1 initial + 2 retries: the pool may hold more than one stale socket.
|
|
19
20
|
const RESET_RETRY_MAX_ATTEMPTS = 3;
|
|
20
21
|
const RESET_RETRY_BASE_DELAY_MS = 150;
|
|
21
22
|
const RESET_RETRY_MAX_DELAY_MS = 1_000;
|
|
22
23
|
|
|
24
|
+
export interface RetryBackoffOptions {
|
|
25
|
+
baseDelayMs: number;
|
|
26
|
+
maxDelayMs: number;
|
|
27
|
+
headers?: Headers;
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
export function abortError(signal?: AbortSignal): unknown {
|
|
24
31
|
return signal?.reason ?? new DOMException("The operation was aborted", "AbortError");
|
|
25
32
|
}
|
|
@@ -56,11 +63,51 @@ export function isConnectionResetError(err: unknown): boolean {
|
|
|
56
63
|
|| msg.includes("connection reset by peer");
|
|
57
64
|
}
|
|
58
65
|
|
|
59
|
-
function
|
|
60
|
-
const
|
|
66
|
+
function retryAfterDelayMs(headers: Headers): number | undefined {
|
|
67
|
+
const raw = headers.get("retry-after")?.trim();
|
|
68
|
+
if (!raw) return undefined;
|
|
69
|
+
const seconds = Number(raw);
|
|
70
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
71
|
+
const dateMs = Date.parse(raw);
|
|
72
|
+
if (!Number.isFinite(dateMs)) return undefined;
|
|
73
|
+
return Math.max(0, dateMs - Date.now());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function retryBackoffDelayMs(attempt: number, opts: RetryBackoffOptions): number {
|
|
77
|
+
const retryAfter = opts.headers ? retryAfterDelayMs(opts.headers) : undefined;
|
|
78
|
+
if (retryAfter !== undefined) return Math.min(retryAfter, opts.maxDelayMs);
|
|
79
|
+
const exp = Math.min(opts.baseDelayMs * (2 ** attempt), opts.maxDelayMs);
|
|
61
80
|
return Math.floor(exp * (0.8 + Math.random() * 0.4));
|
|
62
81
|
}
|
|
63
82
|
|
|
83
|
+
export function cancelResponseBodyBestEffort(res: Response): void {
|
|
84
|
+
try {
|
|
85
|
+
const cancellation = res.body?.cancel();
|
|
86
|
+
if (cancellation) void cancellation.catch(() => {});
|
|
87
|
+
} catch {
|
|
88
|
+
// Cancellation is cleanup only; retries must not wait for or fail because of it.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function fetchWithAttemptDeadline(
|
|
93
|
+
url: string,
|
|
94
|
+
init: RequestInit,
|
|
95
|
+
timeoutMs: number,
|
|
96
|
+
abortSignal?: AbortSignal,
|
|
97
|
+
): Promise<Response> {
|
|
98
|
+
const attemptTimeout = clearableDeadline(timeoutMs, abortSignal);
|
|
99
|
+
try {
|
|
100
|
+
return await fetch(url, {
|
|
101
|
+
...init,
|
|
102
|
+
signal: attemptTimeout.signal,
|
|
103
|
+
});
|
|
104
|
+
} finally {
|
|
105
|
+
// Only the header timer is cleared. The composed signal still contains the parent, so a
|
|
106
|
+
// caller abort after headers continue to cancel consumption of the returned response body.
|
|
107
|
+
attemptTimeout.clear();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
64
111
|
export interface ResetRetryOptions {
|
|
65
112
|
abortSignal?: AbortSignal;
|
|
66
113
|
/** Short host/path label for the retry warn log (no secrets/query strings). */
|
|
@@ -89,7 +136,10 @@ export async function fetchWithResetRetry(
|
|
|
89
136
|
console.warn(
|
|
90
137
|
`[upstream-retry] connection reset${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`,
|
|
91
138
|
);
|
|
92
|
-
await sleepWithAbort(
|
|
139
|
+
await sleepWithAbort(retryBackoffDelayMs(attempt, {
|
|
140
|
+
baseDelayMs: RESET_RETRY_BASE_DELAY_MS,
|
|
141
|
+
maxDelayMs: RESET_RETRY_MAX_DELAY_MS,
|
|
142
|
+
}), opts.abortSignal);
|
|
93
143
|
}
|
|
94
144
|
}
|
|
95
145
|
throw lastError ?? new Error("upstream fetch failed");
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows per-user NTFS ACL hardening for secret files and directories.
|
|
3
|
+
*
|
|
4
|
+
* On Windows, `chmod` only controls POSIX-style bits in the ACE list and does NOT remove
|
|
5
|
+
* inherited permissions from other users. Real per-user isolation requires icacls to:
|
|
6
|
+
* 1. Disable inheritance (icacls path /inheritance:r)
|
|
7
|
+
* 2. Strip broad explicit grants by SID (Everyone, Users, Authenticated Users)
|
|
8
|
+
* 3. Grant the current user full control (icacls path /grant:r "CURRENTUSER:(F)")
|
|
9
|
+
*
|
|
10
|
+
* On non-Windows platforms the helpers fall through to the caller's existing chmod-based
|
|
11
|
+
* behaviour: they return ok:true without invoking any external process.
|
|
12
|
+
*
|
|
13
|
+
* Design:
|
|
14
|
+
* hardenSecretPath(path, { required: false }) — non-fatal read-path mode.
|
|
15
|
+
* Never throws. Returns { ok, diagnostics? }.
|
|
16
|
+
* hardenSecretPath(path, { required: true }) — write-path mode.
|
|
17
|
+
* Throws a sanitized error (no raw path) on Windows ACL failure.
|
|
18
|
+
* hardenSecretDir — same contract for directories.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
23
|
+
import { env, platform } from "node:process";
|
|
24
|
+
|
|
25
|
+
const hardenedDirectories = new Set<string>();
|
|
26
|
+
const hardenedPaths = new Set<string>();
|
|
27
|
+
|
|
28
|
+
export interface HardenResult {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
diagnostics?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface HardenOptions {
|
|
34
|
+
required: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Return the current Windows username from the environment.
|
|
39
|
+
* Falls back to USERDOMAIN\USERNAME if USERNAME alone is ambiguous.
|
|
40
|
+
* The value is used directly in icacls arguments, so it must be present.
|
|
41
|
+
*/
|
|
42
|
+
function currentWindowsUser(): string | undefined {
|
|
43
|
+
const username = env["USERNAME"];
|
|
44
|
+
const domain = env["USERDOMAIN"];
|
|
45
|
+
if (!username) return undefined;
|
|
46
|
+
// USERDOMAIN is the machine/domain name; USERNAME is the account name.
|
|
47
|
+
// icacls accepts "DOMAIN\User" or just "User" for local accounts.
|
|
48
|
+
return domain ? `${domain}\\${username}` : username;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run icacls to harden a single file system entry.
|
|
53
|
+
* - Disables inheritance (keeps nothing: /inheritance:r)
|
|
54
|
+
* - Grants the current user Full Control
|
|
55
|
+
*
|
|
56
|
+
* We do NOT use a shell string; all arguments are passed as an array so no
|
|
57
|
+
* shell injection is possible even for paths with unusual characters.
|
|
58
|
+
*
|
|
59
|
+
* Throws the raw child_process error on failure (caller sanitizes).
|
|
60
|
+
*/
|
|
61
|
+
function runIcacls(targetPath: string, directory: boolean): void {
|
|
62
|
+
const user = currentWindowsUser();
|
|
63
|
+
if (!user) {
|
|
64
|
+
throw new Error("Cannot determine current Windows user for ACL hardening");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Step 1: disable inheritance and remove inherited ACEs
|
|
68
|
+
execFileSync("icacls.exe", [targetPath, "/inheritance:r"], {
|
|
69
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
70
|
+
timeout: 5000,
|
|
71
|
+
shell: false,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Step 2: remove broad explicit grants using stable SIDs (not localized names).
|
|
75
|
+
execFileSync("icacls.exe", [
|
|
76
|
+
targetPath,
|
|
77
|
+
"/remove:g",
|
|
78
|
+
"*S-1-1-0",
|
|
79
|
+
"*S-1-5-11",
|
|
80
|
+
"*S-1-5-32-545",
|
|
81
|
+
], {
|
|
82
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
83
|
+
timeout: 5000,
|
|
84
|
+
shell: false,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Step 3: grant current user full control.
|
|
88
|
+
const grant = directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`;
|
|
89
|
+
execFileSync("icacls.exe", [targetPath, "/grant:r", grant], {
|
|
90
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
91
|
+
timeout: 5000,
|
|
92
|
+
shell: false,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Sanitize an error from a failed ACL operation into a safe diagnostic string.
|
|
98
|
+
* The raw path must not appear in the returned string (it may contain
|
|
99
|
+
* sensitive username components or PII from the home directory path).
|
|
100
|
+
*/
|
|
101
|
+
function sanitizeDiagnostics(error: unknown): string {
|
|
102
|
+
// We do not expose the raw error message or any path-like fragments.
|
|
103
|
+
// Just describe what failed generically.
|
|
104
|
+
const code = error instanceof Error && "code" in error ? String((error as NodeJS.ErrnoException).code) : "";
|
|
105
|
+
const codePart = code ? ` (${code})` : "";
|
|
106
|
+
return `ACL hardening failed${codePart} — filesystem may not support per-user NTFS ACLs`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Harden a single file path with per-user NTFS ACLs on Windows.
|
|
111
|
+
* On non-Windows platforms, returns ok:true immediately (caller owns chmod).
|
|
112
|
+
*
|
|
113
|
+
* @param targetPath Absolute path to the file to harden.
|
|
114
|
+
* @param opts { required: boolean } — required:true throws on failure.
|
|
115
|
+
*/
|
|
116
|
+
export function hardenSecretPath(targetPath: string, opts: HardenOptions): HardenResult {
|
|
117
|
+
// Skip for missing files — we cannot harden what does not exist yet.
|
|
118
|
+
if (!existsSync(targetPath)) {
|
|
119
|
+
return { ok: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Non-Windows: no NTFS ACLs; caller handles chmod.
|
|
123
|
+
if (platform !== "win32") {
|
|
124
|
+
return { ok: true };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (hardenedPaths.has(targetPath)) return { ok: true };
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
runIcacls(targetPath, false);
|
|
131
|
+
hardenedPaths.add(targetPath);
|
|
132
|
+
return { ok: true };
|
|
133
|
+
} catch (err) {
|
|
134
|
+
const diagnostics = sanitizeDiagnostics(err);
|
|
135
|
+
if (opts.required) {
|
|
136
|
+
throw new Error(diagnostics);
|
|
137
|
+
}
|
|
138
|
+
return { ok: false, diagnostics };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Harden a directory path with per-user NTFS ACLs on Windows.
|
|
144
|
+
* On non-Windows platforms, returns ok:true immediately (caller owns chmod).
|
|
145
|
+
*
|
|
146
|
+
* @param targetPath Absolute path to the directory to harden.
|
|
147
|
+
* @param opts { required: boolean } — required:true throws on failure.
|
|
148
|
+
*/
|
|
149
|
+
export function hardenSecretDir(targetPath: string, opts: HardenOptions): HardenResult {
|
|
150
|
+
// Skip for missing directories — we cannot harden what does not exist yet.
|
|
151
|
+
if (!existsSync(targetPath)) {
|
|
152
|
+
return { ok: true };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Non-Windows: no NTFS ACLs; caller handles chmod.
|
|
156
|
+
if (platform !== "win32") {
|
|
157
|
+
return { ok: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (hardenedDirectories.has(targetPath)) return { ok: true };
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
runIcacls(targetPath, true);
|
|
164
|
+
hardenedDirectories.add(targetPath);
|
|
165
|
+
return { ok: true };
|
|
166
|
+
} catch (err) {
|
|
167
|
+
const diagnostics = sanitizeDiagnostics(err);
|
|
168
|
+
if (opts.required) {
|
|
169
|
+
throw new Error(diagnostics);
|
|
170
|
+
}
|
|
171
|
+
return { ok: false, diagnostics };
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/oauth/store.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface ProviderRegistryEntry {
|
|
|
18
18
|
adapter: string;
|
|
19
19
|
baseUrl: string;
|
|
20
20
|
authKind: ProviderAuthKind;
|
|
21
|
+
allowPrivateNetworkByDefault?: boolean;
|
|
21
22
|
keyOptional?: boolean;
|
|
22
23
|
allowBaseUrlOverride?: boolean;
|
|
23
24
|
modelSuffixBracketStrip?: boolean;
|
|
@@ -436,9 +437,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
436
437
|
{ id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] },
|
|
437
438
|
{ id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, defaultModel: "gemini-3.5-flash-low", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
|
|
438
439
|
{ id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
|
|
439
|
-
{ id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
|
|
440
|
-
{ id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
|
|
441
|
-
{ id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" },
|
|
440
|
+
{ id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
|
|
441
|
+
{ id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
|
|
442
|
+
{ id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" },
|
|
442
443
|
{
|
|
443
444
|
id: "deepseek",
|
|
444
445
|
label: "DeepSeek",
|
|
@@ -521,6 +522,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
521
522
|
{
|
|
522
523
|
id: "litellm", label: "LiteLLM (self-hosted)", baseUrl: "http://localhost:4000/v1", adapter: "openai-chat", authKind: "key",
|
|
523
524
|
dashboardUrl: "https://docs.litellm.ai/docs/proxy/quick_start",
|
|
525
|
+
allowPrivateNetworkByDefault: true,
|
|
524
526
|
allowBaseUrlOverride: true,
|
|
525
527
|
// A self-hosted proxy may legitimately run without a master key.
|
|
526
528
|
keyOptional: true,
|
package/src/router.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OcxConfig, OcxProviderConfig } from "./types";
|
|
2
2
|
import { hasOwnProvider, resolveEnvValue } from "./config";
|
|
3
|
+
import { assertProviderDestinationAllowed } from "./lib/destination-policy";
|
|
3
4
|
import { PROVIDER_REGISTRY } from "./providers/registry";
|
|
4
5
|
|
|
5
6
|
interface RouteResult {
|
|
@@ -79,7 +80,10 @@ function mergeStringArrayRecord(
|
|
|
79
80
|
|
|
80
81
|
function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig {
|
|
81
82
|
const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName);
|
|
82
|
-
if (!registryEntry)
|
|
83
|
+
if (!registryEntry) {
|
|
84
|
+
assertProviderDestinationAllowed(providerName, provider);
|
|
85
|
+
return { ...provider, apiKey: resolveEnvValue(provider.apiKey) };
|
|
86
|
+
}
|
|
83
87
|
const canonicalAuthMode = registryEntry.authKind === "forward" || registryEntry.authKind === "oauth"
|
|
84
88
|
? registryEntry.authKind
|
|
85
89
|
: provider.authMode === "forward" ? undefined : provider.authMode;
|
|
@@ -107,6 +111,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
|
|
|
107
111
|
const baseUrl = (registryBaseUrlIsTemplate || registryEntry.allowBaseUrlOverride) && userBaseUrlIsResolved
|
|
108
112
|
? userBaseUrl
|
|
109
113
|
: registryEntry.baseUrl;
|
|
114
|
+
assertProviderDestinationAllowed(providerName, { baseUrl, allowPrivateNetwork: provider.allowPrivateNetwork });
|
|
110
115
|
|
|
111
116
|
return {
|
|
112
117
|
...provider,
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
providerBaseUrlConfigError,
|
|
6
6
|
providerHeadersConfigError,
|
|
7
7
|
} from "../config";
|
|
8
|
+
import { providerDestinationConfigError } from "../lib/destination-policy";
|
|
8
9
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
9
10
|
|
|
10
11
|
let _corsOrigin = "http://localhost:10100";
|
|
@@ -156,6 +157,8 @@ export function requireApiAuth(req: Request, config: OcxConfig, kind: "managemen
|
|
|
156
157
|
export function providerManagementConfigError(name: string, provider: OcxProviderConfig): string | null {
|
|
157
158
|
const baseUrlError = providerBaseUrlConfigError(provider.baseUrl);
|
|
158
159
|
if (baseUrlError) return `provider ${name} ${baseUrlError}`;
|
|
160
|
+
const destinationError = providerDestinationConfigError(name, provider);
|
|
161
|
+
if (destinationError) return `provider ${name} ${destinationError}`;
|
|
159
162
|
const headersError = providerHeadersConfigError(provider.headers);
|
|
160
163
|
if (headersError) return `provider ${name} ${headersError}`;
|
|
161
164
|
if (provider.authMode === "forward") {
|
|
@@ -205,6 +208,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
|
|
|
205
208
|
for (const key of [
|
|
206
209
|
"defaultModel",
|
|
207
210
|
"disabled",
|
|
211
|
+
"allowPrivateNetwork",
|
|
208
212
|
"authMode",
|
|
209
213
|
"liveModels",
|
|
210
214
|
"models",
|