@bitkyc08/opencodex 2.7.42 → 2.7.43
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 +8 -1
- package/bin/ocx.mjs +13 -14
- package/gui/dist/assets/index-Czw-jpTU.css +1 -0
- package/gui/dist/assets/index-cmds12BG.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic.ts +15 -6
- package/src/adapters/cursor/native-exec-shell.ts +18 -6
- package/src/adapters/cursor/protobuf-events.ts +24 -2
- package/src/adapters/cursor/protobuf-request.ts +1 -2
- package/src/adapters/cursor/tool-definitions.ts +68 -29
- package/src/adapters/google-wire-compiler.ts +4 -0
- package/src/adapters/google.ts +128 -2
- package/src/adapters/identity.ts +12 -2
- package/src/adapters/kiro.ts +49 -6
- package/src/adapters/mimo-free.ts +2 -0
- package/src/adapters/openai-responses.ts +246 -59
- package/src/claude/agents-inject.ts +5 -0
- package/src/claude/inbound.ts +26 -9
- package/src/cli/account-auth.ts +1 -1
- package/src/cli/agent-driven.ts +37 -0
- package/src/cli/claude.ts +3 -3
- package/src/cli/doctor.ts +23 -18
- package/src/cli/help.ts +42 -6
- package/src/cli/index.ts +88 -19
- package/src/cli/interactive-confirm.ts +129 -0
- package/src/cli/opencode.ts +701 -0
- package/src/cli/provider-runtime.ts +3 -0
- package/src/cli/provider.ts +31 -10
- package/src/cli/star-prompt.ts +55 -16
- package/src/cli/status.ts +47 -13
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-lifecycle.ts +4 -1
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +149 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-usability.ts +6 -1
- package/src/codex/app-server-processes.ts +511 -0
- package/src/codex/auth-api.ts +293 -34
- package/src/codex/auth-collision.ts +2 -1
- package/src/codex/auth-context.ts +60 -17
- package/src/codex/catalog/parsing.ts +16 -1
- package/src/codex/catalog/provider-fetch.ts +48 -12
- package/src/codex/catalog/sync.ts +18 -3
- package/src/codex/catalog.ts +1 -1
- package/src/codex/history-provider.ts +145 -1
- package/src/codex/inject.ts +114 -14
- package/src/codex/main-account.ts +2 -8
- package/src/codex/pool-rotation.ts +186 -0
- package/src/codex/quota.ts +15 -0
- package/src/codex/routing.ts +695 -106
- package/src/codex/shim.ts +4 -1
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +2 -0
- package/src/codex/sync.ts +3 -0
- package/src/config.ts +566 -25
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +111 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +789 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/config-ownership.ts +327 -0
- package/src/lib/crash-guard.ts +2 -0
- package/src/lib/destination-policy.ts +122 -4
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/process-control.ts +2 -2
- package/src/lib/provider-outbound.ts +164 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/windows-elevation.ts +81 -3
- package/src/lib/windows-secret-acl.ts +189 -12
- package/src/lib/winsw.ts +2 -0
- package/src/oauth/anthropic-routing.ts +570 -0
- package/src/oauth/health.ts +6 -0
- package/src/oauth/index.ts +283 -72
- package/src/oauth/key-providers.ts +17 -6
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro-credentials.ts +322 -10
- package/src/oauth/kiro.ts +423 -43
- package/src/oauth/login-cli.ts +32 -5
- package/src/oauth/store.ts +54 -4
- package/src/oauth/types.ts +11 -0
- package/src/providers/alibaba-region-migration.ts +16 -3
- package/src/providers/antigravity-models.ts +3 -0
- package/src/providers/api-keys.ts +13 -6
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +24 -4
- package/src/providers/quota.ts +233 -29
- package/src/providers/registry.ts +11 -3
- package/src/responses/parser.ts +11 -0
- package/src/responses/state.ts +22 -8
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +14 -4
- package/src/server/auth-cors.ts +110 -22
- package/src/server/claude-messages.ts +8 -1
- package/src/server/gui-static.ts +30 -6
- package/src/server/images.ts +303 -9
- package/src/server/index.ts +77 -9
- package/src/server/lifecycle.ts +25 -1
- package/src/server/management/agent-settings-routes.ts +28 -4
- package/src/server/management/combo-routes.ts +7 -0
- package/src/server/management/config-routes.ts +5 -2
- package/src/server/management/logs-usage-routes.ts +167 -3
- package/src/server/management/oauth-account-routes.ts +163 -17
- package/src/server/management/provider-routes.ts +35 -7
- package/src/server/management/system-restart.ts +172 -0
- package/src/server/management/system-routes.ts +33 -10
- package/src/server/management-api.ts +2 -2
- package/src/server/management-auth.ts +216 -0
- package/src/server/proxy-liveness.ts +14 -3
- package/src/server/responses/compact.ts +21 -13
- package/src/server/responses/core.ts +583 -152
- package/src/server/responses-image-gen-repair.ts +118 -0
- package/src/server/responses-item-id-repair.ts +10 -85
- package/src/server/sse-payload-rewrite.ts +116 -0
- package/src/server/system-env.ts +28 -10
- package/src/service.ts +49 -2
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +1504 -28
- package/src/storage/policy-job.ts +366 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +53 -0
- package/src/storage/policy.ts +522 -0
- package/src/storage/restore-job.ts +242 -0
- package/src/storage/restore-worker.ts +52 -0
- package/src/storage/storage-mutation-coordinator.ts +109 -0
- package/src/tray/windows.ts +2 -0
- package/src/types.ts +96 -1
- package/src/update/index.ts +36 -18
- package/src/update/job.ts +103 -12
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/usage/debug.ts +2 -0
- package/src/usage/log.ts +4 -0
- package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
- package/gui/dist/assets/index-DfVGuN88.css +0 -1
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmdirSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
15
|
+
|
|
16
|
+
export const CONFIG_OWNER_FILE = ".opencodex-owner.json";
|
|
17
|
+
export const CONFIG_UNINSTALL_MANIFEST = ".opencodex-uninstall.json";
|
|
18
|
+
|
|
19
|
+
export type ConfigRemovalResult = {
|
|
20
|
+
status: "absent" | "removed" | "partial" | "refused";
|
|
21
|
+
reason?: string;
|
|
22
|
+
residualPaths: string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type ConfigOwner = {
|
|
26
|
+
version: 1;
|
|
27
|
+
ownerId: string;
|
|
28
|
+
root: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type ConfigUninstallManifest = ConfigOwner & {
|
|
32
|
+
paths: string[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const METADATA_MAX_BYTES = 64 * 1024;
|
|
36
|
+
const MANIFEST_MAX_PATHS = 1024;
|
|
37
|
+
const INITIAL_OWNED_PATHS = [
|
|
38
|
+
".star-prompted",
|
|
39
|
+
"artifacts",
|
|
40
|
+
"auth.json",
|
|
41
|
+
"auth.store.lock",
|
|
42
|
+
"catalog-backup.json",
|
|
43
|
+
"claude-env.sh",
|
|
44
|
+
"codex-accounts.json",
|
|
45
|
+
"codex-runtime-clamp.json",
|
|
46
|
+
"codex-runtime.json",
|
|
47
|
+
"codex-shim.autorestore.lock",
|
|
48
|
+
"codex-shim.json",
|
|
49
|
+
"config.json",
|
|
50
|
+
"crash.log",
|
|
51
|
+
"kimi-device-id",
|
|
52
|
+
"mimo-client-id",
|
|
53
|
+
"ocx.pid",
|
|
54
|
+
"opencodex-service-launcher.vbs",
|
|
55
|
+
"opencodex-service-task.xml",
|
|
56
|
+
"opencodex-service.cmd",
|
|
57
|
+
"opencodex-tray-offline.ico",
|
|
58
|
+
"opencodex-tray-online.ico",
|
|
59
|
+
"opencodex-tray-warning.ico",
|
|
60
|
+
"opencodex-tray.ps1",
|
|
61
|
+
"responses-state.json",
|
|
62
|
+
"runtime-port.json",
|
|
63
|
+
"service-api-token",
|
|
64
|
+
"service-state.json",
|
|
65
|
+
"service.log",
|
|
66
|
+
"system-env-port",
|
|
67
|
+
"tray-heartbeat.json",
|
|
68
|
+
"tray-state.json",
|
|
69
|
+
"update-job.json",
|
|
70
|
+
"usage-debug.jsonl",
|
|
71
|
+
"usage.jsonl",
|
|
72
|
+
"version.json",
|
|
73
|
+
"winsw",
|
|
74
|
+
] as const;
|
|
75
|
+
const ownershipCache = new Map<string, {
|
|
76
|
+
owner: ConfigOwner;
|
|
77
|
+
manifest: ConfigUninstallManifest;
|
|
78
|
+
} | null>();
|
|
79
|
+
|
|
80
|
+
function ownershipCacheKey(configDir: string): string {
|
|
81
|
+
const key = resolve(configDir);
|
|
82
|
+
return process.platform === "win32" ? key.toLowerCase() : key;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function samePath(left: string, right: string): boolean {
|
|
86
|
+
return process.platform === "win32"
|
|
87
|
+
? left.toLowerCase() === right.toLowerCase()
|
|
88
|
+
: left === right;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readBoundedJson(path: string): unknown {
|
|
92
|
+
const metadata = lstatSync(path);
|
|
93
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
94
|
+
throw new Error("ownership metadata is not a regular file");
|
|
95
|
+
}
|
|
96
|
+
if (metadata.size > METADATA_MAX_BYTES) throw new Error("ownership metadata is too large");
|
|
97
|
+
return JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isOwner(value: unknown): value is ConfigOwner {
|
|
101
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
102
|
+
const owner = value as Record<string, unknown>;
|
|
103
|
+
return owner.version === 1
|
|
104
|
+
&& typeof owner.ownerId === "string"
|
|
105
|
+
&& /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(owner.ownerId)
|
|
106
|
+
&& typeof owner.root === "string";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isManifest(value: unknown): value is ConfigUninstallManifest {
|
|
110
|
+
if (!isOwner(value)) return false;
|
|
111
|
+
const paths = (value as Record<string, unknown>).paths;
|
|
112
|
+
return Array.isArray(paths)
|
|
113
|
+
&& paths.length <= MANIFEST_MAX_PATHS
|
|
114
|
+
&& paths.every(path => typeof path === "string");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function canonicalRoot(configDir: string): string {
|
|
118
|
+
return realpathSync.native(resolve(configDir));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isWithinRoot(root: string, candidate: string): boolean {
|
|
122
|
+
const rel = relative(root, candidate);
|
|
123
|
+
return rel === "" || (
|
|
124
|
+
rel !== ".."
|
|
125
|
+
&& !rel.startsWith(`..${sep}`)
|
|
126
|
+
&& !isAbsolute(rel)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function manifestRelativePath(configDir: string, candidatePath: string): string | null {
|
|
131
|
+
const root = resolve(configDir);
|
|
132
|
+
const candidate = resolve(candidatePath);
|
|
133
|
+
const rel = relative(root, candidate);
|
|
134
|
+
if (
|
|
135
|
+
!rel
|
|
136
|
+
|| rel === ".."
|
|
137
|
+
|| rel.startsWith(`..${sep}`)
|
|
138
|
+
|| isAbsolute(rel)
|
|
139
|
+
) return null;
|
|
140
|
+
const normalized = rel.split(sep).join("/");
|
|
141
|
+
if (normalized.split("/").some(part => !part || part === "." || part === ".." || part.includes("\\"))) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (normalized === CONFIG_OWNER_FILE || normalized === CONFIG_UNINSTALL_MANIFEST) return null;
|
|
145
|
+
return normalized;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function loadOwnership(configDir: string): { owner: ConfigOwner; manifest: ConfigUninstallManifest } | null {
|
|
149
|
+
const ownerPath = join(configDir, CONFIG_OWNER_FILE);
|
|
150
|
+
const manifestPath = join(configDir, CONFIG_UNINSTALL_MANIFEST);
|
|
151
|
+
if (!existsSync(ownerPath) || !existsSync(manifestPath)) return null;
|
|
152
|
+
try {
|
|
153
|
+
const owner = readBoundedJson(ownerPath);
|
|
154
|
+
const manifest = readBoundedJson(manifestPath);
|
|
155
|
+
const root = canonicalRoot(configDir);
|
|
156
|
+
if (
|
|
157
|
+
!isOwner(owner)
|
|
158
|
+
|| !isManifest(manifest)
|
|
159
|
+
|| owner.ownerId !== manifest.ownerId
|
|
160
|
+
|| !samePath(owner.root, root)
|
|
161
|
+
|| !samePath(manifest.root, root)
|
|
162
|
+
) return null;
|
|
163
|
+
return { owner, manifest };
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function createOwnership(configDir: string): { owner: ConfigOwner; manifest: ConfigUninstallManifest } | null {
|
|
170
|
+
const rootStat = lstatSync(configDir);
|
|
171
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || readdirSync(configDir).length !== 0) return null;
|
|
172
|
+
const owner: ConfigOwner = {
|
|
173
|
+
version: 1,
|
|
174
|
+
ownerId: randomUUID(),
|
|
175
|
+
root: canonicalRoot(configDir),
|
|
176
|
+
};
|
|
177
|
+
const manifest: ConfigUninstallManifest = { ...owner, paths: [...INITIAL_OWNED_PATHS] };
|
|
178
|
+
writeFileSync(join(configDir, CONFIG_OWNER_FILE), `${JSON.stringify(owner, null, 2)}\n`, {
|
|
179
|
+
encoding: "utf8",
|
|
180
|
+
flag: "wx",
|
|
181
|
+
mode: 0o600,
|
|
182
|
+
});
|
|
183
|
+
try {
|
|
184
|
+
writeFileSync(join(configDir, CONFIG_UNINSTALL_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, {
|
|
185
|
+
encoding: "utf8",
|
|
186
|
+
flag: "wx",
|
|
187
|
+
mode: 0o600,
|
|
188
|
+
});
|
|
189
|
+
} catch (error) {
|
|
190
|
+
try { unlinkSync(join(configDir, CONFIG_OWNER_FILE)); } catch { /* incomplete metadata fails closed */ }
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
return { owner, manifest };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function writeManifest(configDir: string, manifest: ConfigUninstallManifest): void {
|
|
197
|
+
const path = join(configDir, CONFIG_UNINSTALL_MANIFEST);
|
|
198
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
199
|
+
writeFileSync(temp, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
200
|
+
try {
|
|
201
|
+
renameSync(temp, path);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
try { unlinkSync(temp); } catch { /* best effort */ }
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function removeOwnedEntry(root: string, path: string): void {
|
|
209
|
+
const entry = lstatSync(path);
|
|
210
|
+
if (entry.isSymbolicLink()) {
|
|
211
|
+
unlinkSync(path);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!entry.isDirectory()) {
|
|
215
|
+
unlinkSync(path);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const realDirectory = realpathSync.native(path);
|
|
220
|
+
if (!isWithinRoot(root, realDirectory)) {
|
|
221
|
+
throw new Error(`owned directory resolves outside the config root: ${path}`);
|
|
222
|
+
}
|
|
223
|
+
for (const name of readdirSync(path)) {
|
|
224
|
+
removeOwnedEntry(root, join(path, name));
|
|
225
|
+
}
|
|
226
|
+
rmdirSync(path);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function recordOwnedConfigPath(configDir: string, candidatePath: string): boolean {
|
|
230
|
+
const rel = manifestRelativePath(configDir, candidatePath);
|
|
231
|
+
if (!rel) return false;
|
|
232
|
+
const cacheKey = ownershipCacheKey(configDir);
|
|
233
|
+
if (!existsSync(configDir)) {
|
|
234
|
+
ownershipCache.delete(cacheKey);
|
|
235
|
+
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
236
|
+
}
|
|
237
|
+
let ownership = ownershipCache.get(cacheKey);
|
|
238
|
+
if (ownership === undefined) {
|
|
239
|
+
ownership = loadOwnership(configDir) ?? createOwnership(configDir);
|
|
240
|
+
ownershipCache.set(cacheKey, ownership);
|
|
241
|
+
}
|
|
242
|
+
if (!ownership) return false;
|
|
243
|
+
if (ownership.manifest.paths.includes(rel)) return true;
|
|
244
|
+
const manifest = {
|
|
245
|
+
...ownership.manifest,
|
|
246
|
+
paths: [...ownership.manifest.paths, rel].sort(),
|
|
247
|
+
};
|
|
248
|
+
writeManifest(configDir, manifest);
|
|
249
|
+
ownershipCache.set(cacheKey, { owner: ownership.owner, manifest });
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function removeOwnedConfigState(configDir: string): ConfigRemovalResult {
|
|
254
|
+
ownershipCache.delete(ownershipCacheKey(configDir));
|
|
255
|
+
if (!existsSync(configDir)) return { status: "absent", residualPaths: [] };
|
|
256
|
+
const root = lstatSync(configDir);
|
|
257
|
+
if (!root.isDirectory() || root.isSymbolicLink()) {
|
|
258
|
+
return {
|
|
259
|
+
status: "refused",
|
|
260
|
+
reason: "config ownership root is not a real directory",
|
|
261
|
+
residualPaths: [configDir],
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const ownership = loadOwnership(configDir);
|
|
265
|
+
if (!ownership) {
|
|
266
|
+
return {
|
|
267
|
+
status: "refused",
|
|
268
|
+
reason: "config ownership metadata is missing or invalid",
|
|
269
|
+
residualPaths: [configDir],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (const rel of ownership.manifest.paths) {
|
|
274
|
+
const path = manifestRelativePath(configDir, join(configDir, ...rel.split("/")));
|
|
275
|
+
if (path !== rel) {
|
|
276
|
+
return {
|
|
277
|
+
status: "refused",
|
|
278
|
+
reason: "config ownership manifest contains an unsafe path",
|
|
279
|
+
residualPaths: [configDir],
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const rootPath = canonicalRoot(configDir);
|
|
285
|
+
for (const rel of ownership.manifest.paths) {
|
|
286
|
+
const path = join(configDir, ...rel.split("/"));
|
|
287
|
+
if (!existsSync(path)) continue;
|
|
288
|
+
try {
|
|
289
|
+
removeOwnedEntry(rootPath, path);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return {
|
|
292
|
+
status: "partial",
|
|
293
|
+
reason: `could not remove owned path ${rel}: ${error instanceof Error ? error.message : String(error)}`,
|
|
294
|
+
residualPaths: [path],
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
unlinkSync(join(configDir, CONFIG_UNINSTALL_MANIFEST));
|
|
301
|
+
unlinkSync(join(configDir, CONFIG_OWNER_FILE));
|
|
302
|
+
} catch (error) {
|
|
303
|
+
return {
|
|
304
|
+
status: "partial",
|
|
305
|
+
reason: `could not remove ownership metadata: ${error instanceof Error ? error.message : String(error)}`,
|
|
306
|
+
residualPaths: readdirSync(configDir).map(name => join(configDir, name)),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const residualPaths = readdirSync(configDir).map(name => join(configDir, name));
|
|
310
|
+
if (residualPaths.length > 0) {
|
|
311
|
+
return {
|
|
312
|
+
status: "partial",
|
|
313
|
+
reason: "unowned files remain in the config directory",
|
|
314
|
+
residualPaths,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
rmdirSync(configDir);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
return {
|
|
321
|
+
status: "partial",
|
|
322
|
+
reason: `could not remove the empty config directory: ${error instanceof Error ? error.message : String(error)}`,
|
|
323
|
+
residualPaths: [configDir],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
return { status: "removed", residualPaths: [] };
|
|
327
|
+
}
|
package/src/lib/crash-guard.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getConfigDir } from "../config";
|
|
4
|
+
import { recordOwnedConfigPath } from "./config-ownership";
|
|
4
5
|
import { redactSecretString, redactUrlForLog } from "./redact";
|
|
5
6
|
import { sidecarBreadcrumb, activityBreadcrumb } from "./sidecar-tracker";
|
|
6
7
|
|
|
@@ -27,6 +28,7 @@ let installed = false;
|
|
|
27
28
|
function crashLogPath(): string {
|
|
28
29
|
const dir = getConfigDir();
|
|
29
30
|
try {
|
|
31
|
+
recordOwnedConfigPath(dir, join(dir, "crash.log"));
|
|
30
32
|
mkdirSync(dir, { recursive: true });
|
|
31
33
|
} catch {
|
|
32
34
|
/* best-effort: directory usually already exists */
|
|
@@ -19,7 +19,7 @@ const BLOCKED_METADATA_IPV6 = new Set([
|
|
|
19
19
|
"fd00:ec2::254",
|
|
20
20
|
]);
|
|
21
21
|
|
|
22
|
-
type DestinationKind =
|
|
22
|
+
export type DestinationKind =
|
|
23
23
|
| "public"
|
|
24
24
|
| "hostname"
|
|
25
25
|
| "localhost"
|
|
@@ -79,13 +79,33 @@ function classifyIpv6(hostname: string): DestinationAssessment {
|
|
|
79
79
|
if (BLOCKED_METADATA_IPV6.has(hostname)) return { kind: "metadata", detail: "blocked metadata endpoint" };
|
|
80
80
|
const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)?.[1];
|
|
81
81
|
if (mappedIpv4) return classifyIpv4(mappedIpv4);
|
|
82
|
+
// Decode hex IPv4-mapped IPv6: ::ffff:7f00:1 → 127.0.0.1
|
|
83
|
+
// The dotted-decimal regex above only matches ::ffff:127.0.0.1; without this,
|
|
84
|
+
// hex form bypasses all private/loopback checks (hextet is 0 → classified "public").
|
|
85
|
+
const hexMapped = hostname.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
|
|
86
|
+
if (hexMapped) {
|
|
87
|
+
const hi = Number.parseInt(hexMapped[1], 16);
|
|
88
|
+
const lo = Number.parseInt(hexMapped[2], 16);
|
|
89
|
+
const ipv4 = `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`;
|
|
90
|
+
return classifyIpv4(ipv4);
|
|
91
|
+
}
|
|
82
92
|
if (hostname === "::1") return { kind: "loopback", detail: "loopback address" };
|
|
83
93
|
if (hostname === "::") return { kind: "unspecified", detail: "unspecified address" };
|
|
84
94
|
const hextet = firstIpv6Hextet(hostname);
|
|
85
|
-
if (hextet === null) return { kind: "
|
|
86
|
-
|
|
95
|
+
if (hextet === null) return { kind: "private", detail: "non-global address" };
|
|
96
|
+
// Multicast ff00::/8, deprecated site-local fec0::/10, ULA fc00::/7, link-local fe80::/10.
|
|
97
|
+
if (hextet >= 0xff00) return { kind: "private", detail: "multicast address" };
|
|
87
98
|
if (hextet >= 0xfe80 && hextet <= 0xfebf) return { kind: "link-local", detail: "link-local address" };
|
|
88
|
-
return { kind: "
|
|
99
|
+
if (hextet >= 0xfec0 && hextet <= 0xfeff) return { kind: "private", detail: "site-local address" };
|
|
100
|
+
if (hextet >= 0xfc00 && hextet <= 0xfdff) return { kind: "private", detail: "private-network address" };
|
|
101
|
+
// Documentation 2001:db8::/32 (inside global-unicast 2000::/3).
|
|
102
|
+
if (hextet === 0x2001) {
|
|
103
|
+
const second = Number.parseInt(hostname.split(":")[1] || "0", 16);
|
|
104
|
+
if (second === 0xdb8) return { kind: "private", detail: "documentation address" };
|
|
105
|
+
}
|
|
106
|
+
// Only global unicast 2000::/3 is treated as a public image/CDN peer.
|
|
107
|
+
if (hextet >= 0x2000 && hextet <= 0x3fff) return { kind: "public", detail: "public IP" };
|
|
108
|
+
return { kind: "private", detail: "non-global address" };
|
|
89
109
|
}
|
|
90
110
|
|
|
91
111
|
function assessDestination(baseUrl: string): DestinationAssessment | null {
|
|
@@ -178,3 +198,101 @@ export async function providerDestinationResolvedError(
|
|
|
178
198
|
}
|
|
179
199
|
return null;
|
|
180
200
|
}
|
|
201
|
+
|
|
202
|
+
export interface UrlDestinationAssessment {
|
|
203
|
+
kind: DestinationKind;
|
|
204
|
+
detail: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export class DestinationDnsResolutionError extends Error {
|
|
208
|
+
override readonly name = "DestinationDnsResolutionError";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Synchronous literal URL destination assessment — classifies the hostname
|
|
213
|
+
* without DNS resolution. Returns null for unparseable URLs.
|
|
214
|
+
*/
|
|
215
|
+
export function assessUrlDestination(url: string): UrlDestinationAssessment | null {
|
|
216
|
+
return assessDestination(url);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Async DNS-resolved URL safety check. By default, rejects every non-public
|
|
221
|
+
* address. Provider diagnostics may explicitly admit loopback/private answers;
|
|
222
|
+
* metadata, link-local, and unspecified addresses remain unconditionally denied.
|
|
223
|
+
* Returns the validated addresses so direct callers can pin the connect peer and
|
|
224
|
+
* avoid a second, rebindable resolution. DNS failures remain fail-closed here;
|
|
225
|
+
* the provider proxy wrapper alone may recognize that typed failure and degrade.
|
|
226
|
+
*/
|
|
227
|
+
export async function resolvePublicAddresses(
|
|
228
|
+
url: string,
|
|
229
|
+
options?: string | { context?: string; allowPrivateNetwork?: boolean },
|
|
230
|
+
): Promise<{
|
|
231
|
+
hostname: string;
|
|
232
|
+
addresses: { address: string; family: number }[];
|
|
233
|
+
privateNetwork: boolean;
|
|
234
|
+
}> {
|
|
235
|
+
const context = typeof options === "string"
|
|
236
|
+
? `${options.trim() || "image"} URL`
|
|
237
|
+
: options?.context?.trim() || "image URL";
|
|
238
|
+
const privateNetworkAllowed = typeof options === "object" && options?.allowPrivateNetwork === true;
|
|
239
|
+
let hostname: string;
|
|
240
|
+
try {
|
|
241
|
+
hostname = normalizeHostname(new URL(url.trim()).hostname);
|
|
242
|
+
} catch {
|
|
243
|
+
throw new Error(`${context} is not a valid URL`);
|
|
244
|
+
}
|
|
245
|
+
if (!hostname) throw new Error(`${context} has no hostname`);
|
|
246
|
+
const literalAssessment = assessDestination(url);
|
|
247
|
+
let privateNetwork = false;
|
|
248
|
+
if (literalAssessment && literalAssessment.kind !== "public" && literalAssessment.kind !== "hostname") {
|
|
249
|
+
const allowedPrivateLiteral = privateNetworkAllowed
|
|
250
|
+
&& (literalAssessment.kind === "localhost"
|
|
251
|
+
|| literalAssessment.kind === "loopback"
|
|
252
|
+
|| literalAssessment.kind === "private");
|
|
253
|
+
if (!allowedPrivateLiteral) throw new Error(`${context} targets ${literalAssessment.detail}`);
|
|
254
|
+
privateNetwork = true;
|
|
255
|
+
}
|
|
256
|
+
// Literal public IPs: no DNS round-trip; pin the literal itself.
|
|
257
|
+
const literalKind = isIP(hostname);
|
|
258
|
+
if (literalKind !== 0) {
|
|
259
|
+
return { hostname, addresses: [{ address: hostname, family: literalKind }], privateNetwork };
|
|
260
|
+
}
|
|
261
|
+
let addresses: { address: string; family: number }[];
|
|
262
|
+
try {
|
|
263
|
+
addresses = await lookup(hostname, { all: true, verbatim: true });
|
|
264
|
+
} catch {
|
|
265
|
+
// If DNS fails, we can't verify — fail-closed (unlike provider config-time validation,
|
|
266
|
+
// this is a runtime fetch to an untrusted URL, so be conservative).
|
|
267
|
+
throw new DestinationDnsResolutionError(`${context} hostname ${hostname} could not be resolved`);
|
|
268
|
+
}
|
|
269
|
+
if (addresses.length === 0) {
|
|
270
|
+
throw new DestinationDnsResolutionError(`${context} hostname ${hostname} could not be resolved`);
|
|
271
|
+
}
|
|
272
|
+
const validatedAddresses: { address: string; family: number }[] = [];
|
|
273
|
+
for (const { address, family } of addresses) {
|
|
274
|
+
// Prefer classifying from the address string itself — do not trust a mislabeled
|
|
275
|
+
// resolver `family` that could skip IPv4/IPv6 private checks.
|
|
276
|
+
const ipKind = isIP(address) || (family === 4 || family === 6 ? family : 0);
|
|
277
|
+
const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null;
|
|
278
|
+
if (!assessment || assessment.kind !== "public") {
|
|
279
|
+
const allowedPrivateAddress = privateNetworkAllowed
|
|
280
|
+
&& assessment
|
|
281
|
+
&& (assessment.kind === "loopback" || assessment.kind === "private");
|
|
282
|
+
if (!allowedPrivateAddress) {
|
|
283
|
+
throw new Error(`${context} hostname ${hostname} resolves to ${assessment?.detail ?? "an unsafe address"} (${address})`);
|
|
284
|
+
}
|
|
285
|
+
privateNetwork = true;
|
|
286
|
+
}
|
|
287
|
+
validatedAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) });
|
|
288
|
+
}
|
|
289
|
+
return { hostname, addresses: validatedAddresses, privateNetwork };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Void assertion wrapper around {@link resolvePublicAddresses} for call sites
|
|
294
|
+
* that only need the safety check.
|
|
295
|
+
*/
|
|
296
|
+
export async function assertUrlResolvesPublic(url: string): Promise<void> {
|
|
297
|
+
await resolvePublicAddresses(url);
|
|
298
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import http, { type IncomingMessage, type RequestOptions } from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
|
|
4
|
+
export type PinnedAddress = { address: string; family: number };
|
|
5
|
+
|
|
6
|
+
export interface PinnedHttpGetOptions {
|
|
7
|
+
headers?: HeadersInit;
|
|
8
|
+
maxBytes?: number;
|
|
9
|
+
idleTimeoutMs?: number;
|
|
10
|
+
rejectUnauthorized?: boolean;
|
|
11
|
+
context?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* GET a URL through one previously validated address. The original hostname
|
|
16
|
+
* remains authoritative for Host, SNI, and certificate verification.
|
|
17
|
+
*/
|
|
18
|
+
export function pinnedHttpGet(
|
|
19
|
+
url: string,
|
|
20
|
+
pinned: PinnedAddress,
|
|
21
|
+
signal?: AbortSignal,
|
|
22
|
+
options?: PinnedHttpGetOptions,
|
|
23
|
+
): Promise<Response> {
|
|
24
|
+
const parsed = new URL(url);
|
|
25
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
26
|
+
throw new Error(`${options?.context ?? "request"} must use HTTP or HTTPS, got ${parsed.protocol}`);
|
|
27
|
+
}
|
|
28
|
+
const context = options?.context ?? "request";
|
|
29
|
+
const idleTimeoutMs = options?.idleTimeoutMs ?? 60_000;
|
|
30
|
+
const maxBytes = options?.maxBytes;
|
|
31
|
+
const headers = new Headers(options?.headers);
|
|
32
|
+
headers.set("host", parsed.host);
|
|
33
|
+
const requestHeaders: Record<string, string> = {};
|
|
34
|
+
headers.forEach((value, key) => { requestHeaders[key] = value; });
|
|
35
|
+
|
|
36
|
+
return new Promise<Response>((resolve, reject) => {
|
|
37
|
+
if (signal?.aborted) {
|
|
38
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let settled = false;
|
|
43
|
+
const fail = (error: unknown) => {
|
|
44
|
+
try { req.destroy(); } catch { /* ignore */ }
|
|
45
|
+
if (settled) return;
|
|
46
|
+
settled = true;
|
|
47
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
48
|
+
};
|
|
49
|
+
const requestOptions: RequestOptions & { servername?: string } = {
|
|
50
|
+
protocol: parsed.protocol,
|
|
51
|
+
hostname: parsed.hostname,
|
|
52
|
+
port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
|
|
53
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
54
|
+
method: "GET",
|
|
55
|
+
headers: requestHeaders,
|
|
56
|
+
...(parsed.protocol === "https:"
|
|
57
|
+
? {
|
|
58
|
+
servername: parsed.hostname,
|
|
59
|
+
rejectUnauthorized: options?.rejectUnauthorized ?? true,
|
|
60
|
+
}
|
|
61
|
+
: {}),
|
|
62
|
+
lookup(_hostname, lookupOptions, callback) {
|
|
63
|
+
const opts = typeof lookupOptions === "function" ? undefined : lookupOptions;
|
|
64
|
+
const cb = typeof lookupOptions === "function" ? lookupOptions : callback;
|
|
65
|
+
if (!cb) return;
|
|
66
|
+
if (opts && typeof opts === "object" && "all" in opts && opts.all) {
|
|
67
|
+
(cb as (error: NodeJS.ErrnoException | null, addresses: PinnedAddress[]) => void)(
|
|
68
|
+
null,
|
|
69
|
+
[{ address: pinned.address, family: pinned.family }],
|
|
70
|
+
);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
(cb as (error: NodeJS.ErrnoException | null, address: string, family: 4 | 6) => void)(
|
|
74
|
+
null,
|
|
75
|
+
pinned.address,
|
|
76
|
+
pinned.family as 4 | 6,
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const onResponse = (response: IncomingMessage) => {
|
|
82
|
+
const status = response.statusCode ?? 0;
|
|
83
|
+
const responseHeaders = new Headers();
|
|
84
|
+
for (const [key, value] of Object.entries(response.headers)) {
|
|
85
|
+
if (value === undefined || value === null) continue;
|
|
86
|
+
if (Array.isArray(value)) {
|
|
87
|
+
for (const item of value) responseHeaders.append(key, String(item));
|
|
88
|
+
} else {
|
|
89
|
+
responseHeaders.set(key, String(value));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (status < 200 || status >= 300) {
|
|
94
|
+
try { response.destroy(); } catch { /* ignore */ }
|
|
95
|
+
try { req.destroy(); } catch { /* ignore */ }
|
|
96
|
+
if (settled) return;
|
|
97
|
+
settled = true;
|
|
98
|
+
resolve(new Response(null, { status, headers: responseHeaders }));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let received = 0;
|
|
103
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
104
|
+
start(controller) {
|
|
105
|
+
response.setTimeout(idleTimeoutMs, () => {
|
|
106
|
+
const error = new Error(`${context} stalled`);
|
|
107
|
+
fail(error);
|
|
108
|
+
try { controller.error(error); } catch { /* closed */ }
|
|
109
|
+
});
|
|
110
|
+
response.on("data", (chunk: Buffer | string) => {
|
|
111
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
112
|
+
received += buffer.byteLength;
|
|
113
|
+
if (maxBytes !== undefined && received > maxBytes) {
|
|
114
|
+
const error = new Error(`${context} exceeds ${maxBytes} byte cap`);
|
|
115
|
+
fail(error);
|
|
116
|
+
try { controller.error(error); } catch { /* closed */ }
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try { controller.enqueue(buffer); } catch { /* closed */ }
|
|
120
|
+
});
|
|
121
|
+
response.on("end", () => {
|
|
122
|
+
try { controller.close(); } catch { /* closed */ }
|
|
123
|
+
});
|
|
124
|
+
response.on("error", (error: Error) => {
|
|
125
|
+
fail(error);
|
|
126
|
+
try { controller.error(error); } catch { /* closed */ }
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
cancel() {
|
|
130
|
+
req.destroy();
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (settled) return;
|
|
135
|
+
settled = true;
|
|
136
|
+
resolve(new Response(stream, { status, headers: responseHeaders }));
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const requestFn = parsed.protocol === "https:" ? https.request : http.request;
|
|
140
|
+
const req = requestFn(requestOptions, onResponse);
|
|
141
|
+
const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
142
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
143
|
+
req.setTimeout(idleTimeoutMs, () => fail(new Error(`${context} timed out`)));
|
|
144
|
+
req.on("error", error => {
|
|
145
|
+
signal?.removeEventListener("abort", onAbort);
|
|
146
|
+
fail(error);
|
|
147
|
+
});
|
|
148
|
+
req.on("close", () => signal?.removeEventListener("abort", onAbort));
|
|
149
|
+
req.end();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { loadConfig, readRuntimePort } from "../config";
|
|
3
|
+
import { configuredAdminToken } from "./admin-secrets";
|
|
3
4
|
|
|
4
5
|
export function isProcessAlive(pid: number): boolean {
|
|
5
6
|
try {
|
|
@@ -66,8 +67,7 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
|
|
|
66
67
|
if (!runtime?.port) return false;
|
|
67
68
|
const env = io.env ?? process.env;
|
|
68
69
|
const headers: Record<string, string> = {};
|
|
69
|
-
|
|
70
|
-
const token = env.OPENCODEX_API_AUTH_TOKEN?.trim();
|
|
70
|
+
const token = configuredAdminToken(env.OPENCODEX_HOME?.trim() || undefined, env as NodeJS.ProcessEnv);
|
|
71
71
|
if (token) headers["x-opencodex-api-key"] = token;
|
|
72
72
|
const fetchFn = io.fetchFn ?? fetch;
|
|
73
73
|
try {
|