@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,118 @@
|
|
|
1
|
+
import { collectResponsesToolGroups } from "../responses/tool-groups";
|
|
2
|
+
import { relaySseWithPayloadRewrite, type SsePayloadRewrite } from "./sse-payload-rewrite";
|
|
3
|
+
|
|
4
|
+
interface NamespacedTool {
|
|
5
|
+
namespace: string;
|
|
6
|
+
name: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const IMAGE_GEN_NAMESPACE = "image_gen";
|
|
10
|
+
const IMAGE_GEN_DOTTED_PREFIX = `${IMAGE_GEN_NAMESPACE}.`;
|
|
11
|
+
|
|
12
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Restrict the generic bridge map to Codex's standalone image namespace and accept the dotted
|
|
18
|
+
* alias emitted by earlier compatibility attempts. Legacy flat dotted declarations are recovered
|
|
19
|
+
* from the raw request because the generic parser does not assign them a namespace. Exact declared
|
|
20
|
+
* names avoid splitting arbitrary double-underscore function names that belong to unrelated tools.
|
|
21
|
+
*/
|
|
22
|
+
export function imageGenToolCallAliases(
|
|
23
|
+
toolNsMap: ReadonlyMap<string, NamespacedTool>,
|
|
24
|
+
requestBody?: unknown,
|
|
25
|
+
): Map<string, NamespacedTool> {
|
|
26
|
+
const aliases = new Map<string, NamespacedTool>();
|
|
27
|
+
for (const [wireName, tool] of toolNsMap) {
|
|
28
|
+
if (tool.namespace !== IMAGE_GEN_NAMESPACE) continue;
|
|
29
|
+
aliases.set(wireName, tool);
|
|
30
|
+
aliases.set(`${tool.namespace}.${tool.name}`, tool);
|
|
31
|
+
}
|
|
32
|
+
for (const group of collectResponsesToolGroups(requestBody)) {
|
|
33
|
+
for (const tool of group) {
|
|
34
|
+
if (
|
|
35
|
+
!isPlainObject(tool)
|
|
36
|
+
|| tool.type !== "function"
|
|
37
|
+
|| typeof tool.name !== "string"
|
|
38
|
+
|| !tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX)
|
|
39
|
+
|| tool.name.length === IMAGE_GEN_DOTTED_PREFIX.length
|
|
40
|
+
) continue;
|
|
41
|
+
const localName = tool.name.slice(IMAGE_GEN_DOTTED_PREFIX.length);
|
|
42
|
+
const target = { namespace: IMAGE_GEN_NAMESPACE, name: localName };
|
|
43
|
+
aliases.set(`${IMAGE_GEN_NAMESPACE}__${localName}`, target);
|
|
44
|
+
aliases.set(tool.name, target);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return aliases;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Recursively restore only exact image-gen function-call aliases in a parsed Responses payload. */
|
|
51
|
+
function restoreImageGenCalls(
|
|
52
|
+
value: unknown,
|
|
53
|
+
aliases: ReadonlyMap<string, NamespacedTool>,
|
|
54
|
+
): { value: unknown; changed: boolean } {
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
let changed = false;
|
|
57
|
+
const restored = value.map(entry => {
|
|
58
|
+
const result = restoreImageGenCalls(entry, aliases);
|
|
59
|
+
changed ||= result.changed;
|
|
60
|
+
return result.value;
|
|
61
|
+
});
|
|
62
|
+
return changed ? { value: restored, changed: true } : { value, changed: false };
|
|
63
|
+
}
|
|
64
|
+
if (!isPlainObject(value)) return { value, changed: false };
|
|
65
|
+
|
|
66
|
+
let changed = false;
|
|
67
|
+
const restored: Record<string, unknown> = {};
|
|
68
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
69
|
+
const result = restoreImageGenCalls(entry, aliases);
|
|
70
|
+
restored[key] = result.value;
|
|
71
|
+
changed ||= result.changed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const name = typeof value.name === "string" ? value.name : undefined;
|
|
75
|
+
const target = value.type === "function_call" && name ? aliases.get(name) : undefined;
|
|
76
|
+
if (target) {
|
|
77
|
+
restored.name = target.name;
|
|
78
|
+
restored.namespace = target.namespace;
|
|
79
|
+
changed = true;
|
|
80
|
+
}
|
|
81
|
+
return changed ? { value: restored, changed: true } : { value, changed: false };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Restore image-gen function calls in a non-streaming Responses JSON document. */
|
|
85
|
+
export function restoreImageGenCallsInJson(
|
|
86
|
+
text: string,
|
|
87
|
+
aliases: ReadonlyMap<string, NamespacedTool>,
|
|
88
|
+
): string {
|
|
89
|
+
if (aliases.size === 0) return text;
|
|
90
|
+
let payload: unknown;
|
|
91
|
+
try {
|
|
92
|
+
payload = JSON.parse(text);
|
|
93
|
+
} catch {
|
|
94
|
+
return text;
|
|
95
|
+
}
|
|
96
|
+
const restored = restoreImageGenCalls(payload, aliases);
|
|
97
|
+
return restored.changed ? JSON.stringify(restored.value) : text;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Payload rewrite for composition with other client-facing SSE transforms. */
|
|
101
|
+
export function createImageGenCallRestoreRewrite(
|
|
102
|
+
aliases: ReadonlyMap<string, NamespacedTool>,
|
|
103
|
+
): SsePayloadRewrite | undefined {
|
|
104
|
+
if (aliases.size === 0) return undefined;
|
|
105
|
+
return (payload) => restoreImageGenCallsInJson(payload, aliases);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Restore upstream-safe image-gen aliases only on the client-facing SSE branch. The inspection and
|
|
110
|
+
* continuation-cache branch must retain raw upstream names so a replay can be sent back unchanged.
|
|
111
|
+
*/
|
|
112
|
+
export function relaySseWithImageGenCallRestore(
|
|
113
|
+
body: ReadableStream<Uint8Array>,
|
|
114
|
+
aliases: ReadonlyMap<string, NamespacedTool>,
|
|
115
|
+
): ReadableStream<Uint8Array> {
|
|
116
|
+
const rewrite = createImageGenCallRestoreRewrite(aliases);
|
|
117
|
+
return rewrite ? relaySseWithPayloadRewrite(body, rewrite) : body;
|
|
118
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { ResponsesItemIdRepairConfig } from "../types";
|
|
3
|
+
import { relaySseWithPayloadRewrite, type SsePayloadRewrite } from "./sse-payload-rewrite";
|
|
3
4
|
|
|
4
5
|
type RepairableItemType = "message" | "reasoning";
|
|
5
6
|
|
|
@@ -35,44 +36,6 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
35
36
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
function nextSseBlock(buffer: string): { block: string; delimiter: string; rest: string } | null {
|
|
39
|
-
const match = buffer.match(/\r?\n\r?\n/);
|
|
40
|
-
if (!match || match.index === undefined) return null;
|
|
41
|
-
return {
|
|
42
|
-
block: buffer.slice(0, match.index),
|
|
43
|
-
delimiter: match[0],
|
|
44
|
-
rest: buffer.slice(match.index + match[0].length),
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function sseDataPayload(block: string): string | null {
|
|
49
|
-
const data: string[] = [];
|
|
50
|
-
for (const line of block.split(/\r?\n/)) {
|
|
51
|
-
if (!line.startsWith("data:")) continue;
|
|
52
|
-
const value = line.slice(5);
|
|
53
|
-
data.push(value.startsWith(" ") ? value.slice(1) : value);
|
|
54
|
-
}
|
|
55
|
-
return data.length > 0 ? data.join("\n") : null;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function replaceSseDataPayload(block: string, payload: string): string {
|
|
59
|
-
const newline = block.includes("\r\n") ? "\r\n" : "\n";
|
|
60
|
-
const lines = block.split(/\r?\n/);
|
|
61
|
-
const rewritten: string[] = [];
|
|
62
|
-
let replaced = false;
|
|
63
|
-
for (const line of lines) {
|
|
64
|
-
if (!line.startsWith("data:")) {
|
|
65
|
-
rewritten.push(line);
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
if (!replaced) {
|
|
69
|
-
rewritten.push(`data: ${payload}`);
|
|
70
|
-
replaced = true;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return replaced ? rewritten.join(newline) : block;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
39
|
function asOutputIndex(value: unknown): number | null {
|
|
77
40
|
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
|
|
78
41
|
}
|
|
@@ -221,57 +184,19 @@ function repairEventPayload(
|
|
|
221
184
|
* type에 재사용해도 function_call id/call_id는 보존된다. opt-in 게이트웨이는 sequential streams에서도
|
|
222
185
|
* 고유한 canonical id를 얻지만, 보정이 필요한 경우에만 JS stream 재작성 비용을 지불한다.
|
|
223
186
|
*/
|
|
187
|
+
/** Stateful payload rewrite for composition with other client-facing SSE transforms. */
|
|
188
|
+
export function createResponsesItemIdPayloadRewrite(
|
|
189
|
+
config: ResponsesItemIdRepairConfig,
|
|
190
|
+
): SsePayloadRewrite {
|
|
191
|
+
const state = createRepairState(config);
|
|
192
|
+
return (payload) => repairEventPayload(payload, state);
|
|
193
|
+
}
|
|
194
|
+
|
|
224
195
|
export function relaySseWithResponsesItemIdRepair(
|
|
225
196
|
body: ReadableStream<Uint8Array>,
|
|
226
197
|
config: ResponsesItemIdRepairConfig,
|
|
227
198
|
): ReadableStream<Uint8Array> {
|
|
228
|
-
|
|
229
|
-
const decoder = new TextDecoder();
|
|
230
|
-
const encoder = new TextEncoder();
|
|
231
|
-
const state = createRepairState(config);
|
|
232
|
-
let buffer = "";
|
|
233
|
-
|
|
234
|
-
const emitProcessedBlocks = (
|
|
235
|
-
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
236
|
-
flushFinal = false,
|
|
237
|
-
): void => {
|
|
238
|
-
let next: { block: string; delimiter: string; rest: string } | null;
|
|
239
|
-
while ((next = nextSseBlock(buffer))) {
|
|
240
|
-
buffer = next.rest;
|
|
241
|
-
const payload = sseDataPayload(next.block);
|
|
242
|
-
const repairedPayload = payload ? repairEventPayload(payload, state) : undefined;
|
|
243
|
-
const block = payload && repairedPayload !== undefined && repairedPayload !== payload
|
|
244
|
-
? replaceSseDataPayload(next.block, repairedPayload)
|
|
245
|
-
: next.block;
|
|
246
|
-
controller.enqueue(encoder.encode(block + next.delimiter));
|
|
247
|
-
}
|
|
248
|
-
if (flushFinal && buffer.length > 0) {
|
|
249
|
-
const payload = sseDataPayload(buffer);
|
|
250
|
-
const repairedPayload = payload ? repairEventPayload(payload, state) : undefined;
|
|
251
|
-
const block = payload && repairedPayload !== undefined && repairedPayload !== payload
|
|
252
|
-
? replaceSseDataPayload(buffer, repairedPayload)
|
|
253
|
-
: buffer;
|
|
254
|
-
controller.enqueue(encoder.encode(block));
|
|
255
|
-
buffer = "";
|
|
256
|
-
}
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
return new ReadableStream<Uint8Array>({
|
|
260
|
-
async pull(controller) {
|
|
261
|
-
const { done, value } = await reader.read();
|
|
262
|
-
if (done) {
|
|
263
|
-
buffer += decoder.decode();
|
|
264
|
-
emitProcessedBlocks(controller, true);
|
|
265
|
-
controller.close();
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
buffer += decoder.decode(value, { stream: true });
|
|
269
|
-
emitProcessedBlocks(controller);
|
|
270
|
-
},
|
|
271
|
-
cancel(reason) {
|
|
272
|
-
reader.cancel(reason).catch(() => {});
|
|
273
|
-
},
|
|
274
|
-
});
|
|
199
|
+
return relaySseWithPayloadRewrite(body, createResponsesItemIdPayloadRewrite(config));
|
|
275
200
|
}
|
|
276
201
|
|
|
277
202
|
export function hasResponsesItemIdRepair(config: ResponsesItemIdRepairConfig | undefined): boolean {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared client-facing SSE payload rewrite shell.
|
|
3
|
+
*
|
|
4
|
+
* Multiple opt-in transforms (image-gen namespace restore, item-id repair, …) compose into one
|
|
5
|
+
* parse/stringify pass so a tee'd stream is not re-framed twice per event.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type SsePayloadRewrite = (payload: string) => string;
|
|
9
|
+
|
|
10
|
+
/** Split one complete SSE event block while retaining its original blank-line delimiter. */
|
|
11
|
+
export function nextSseBlock(buffer: string): { block: string; delimiter: string; rest: string } | null {
|
|
12
|
+
const match = buffer.match(/\r?\n\r?\n/);
|
|
13
|
+
if (!match || match.index === undefined) return null;
|
|
14
|
+
return {
|
|
15
|
+
block: buffer.slice(0, match.index),
|
|
16
|
+
delimiter: match[0],
|
|
17
|
+
rest: buffer.slice(match.index + match[0].length),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Join all data lines from one SSE event according to the event-stream field rules. */
|
|
22
|
+
export function sseDataPayload(block: string): string | null {
|
|
23
|
+
const data: string[] = [];
|
|
24
|
+
for (const line of block.split(/\r?\n/)) {
|
|
25
|
+
if (!line.startsWith("data:")) continue;
|
|
26
|
+
const value = line.slice(5);
|
|
27
|
+
data.push(value.startsWith(" ") ? value.slice(1) : value);
|
|
28
|
+
}
|
|
29
|
+
return data.length > 0 ? data.join("\n") : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Replace an SSE event's data field while preserving non-data fields and newline style. */
|
|
33
|
+
export function replaceSseDataPayload(block: string, payload: string): string {
|
|
34
|
+
const newline = block.includes("\r\n") ? "\r\n" : "\n";
|
|
35
|
+
const lines = block.split(/\r?\n/);
|
|
36
|
+
const rewritten: string[] = [];
|
|
37
|
+
let replaced = false;
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
if (!line.startsWith("data:")) {
|
|
40
|
+
rewritten.push(line);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (!replaced) {
|
|
44
|
+
rewritten.push(`data: ${payload}`);
|
|
45
|
+
replaced = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return replaced ? rewritten.join(newline) : block;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Apply rewrites left-to-right; empty list is identity. */
|
|
52
|
+
export function composeSsePayloadRewrites(...rewrites: SsePayloadRewrite[]): SsePayloadRewrite {
|
|
53
|
+
if (rewrites.length === 0) return (payload) => payload;
|
|
54
|
+
if (rewrites.length === 1) return rewrites[0]!;
|
|
55
|
+
return (payload) => {
|
|
56
|
+
let next = payload;
|
|
57
|
+
for (const rewrite of rewrites) next = rewrite(next);
|
|
58
|
+
return next;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Relay an SSE body through a single JS pull wrapper, rewriting each event's data payload in place.
|
|
64
|
+
* Non-data fields and framing are preserved; invalid JSON payloads are left to the rewrite callback.
|
|
65
|
+
*/
|
|
66
|
+
export function relaySseWithPayloadRewrite(
|
|
67
|
+
body: ReadableStream<Uint8Array>,
|
|
68
|
+
rewrite: SsePayloadRewrite,
|
|
69
|
+
): ReadableStream<Uint8Array> {
|
|
70
|
+
const reader = body.getReader();
|
|
71
|
+
const decoder = new TextDecoder();
|
|
72
|
+
const encoder = new TextEncoder();
|
|
73
|
+
let buffer = "";
|
|
74
|
+
|
|
75
|
+
const emitProcessedBlocks = (
|
|
76
|
+
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
77
|
+
flushFinal = false,
|
|
78
|
+
): void => {
|
|
79
|
+
let next: { block: string; delimiter: string; rest: string } | null;
|
|
80
|
+
while ((next = nextSseBlock(buffer))) {
|
|
81
|
+
buffer = next.rest;
|
|
82
|
+
const payload = sseDataPayload(next.block);
|
|
83
|
+
const rewrittenPayload = payload ? rewrite(payload) : undefined;
|
|
84
|
+
const block = payload && rewrittenPayload !== undefined && rewrittenPayload !== payload
|
|
85
|
+
? replaceSseDataPayload(next.block, rewrittenPayload)
|
|
86
|
+
: next.block;
|
|
87
|
+
controller.enqueue(encoder.encode(block + next.delimiter));
|
|
88
|
+
}
|
|
89
|
+
if (flushFinal && buffer.length > 0) {
|
|
90
|
+
const payload = sseDataPayload(buffer);
|
|
91
|
+
const rewrittenPayload = payload ? rewrite(payload) : undefined;
|
|
92
|
+
const block = payload && rewrittenPayload !== undefined && rewrittenPayload !== payload
|
|
93
|
+
? replaceSseDataPayload(buffer, rewrittenPayload)
|
|
94
|
+
: buffer;
|
|
95
|
+
controller.enqueue(encoder.encode(block));
|
|
96
|
+
buffer = "";
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return new ReadableStream<Uint8Array>({
|
|
101
|
+
async pull(controller) {
|
|
102
|
+
const { done, value } = await reader.read();
|
|
103
|
+
if (done) {
|
|
104
|
+
buffer += decoder.decode();
|
|
105
|
+
emitProcessedBlocks(controller, true);
|
|
106
|
+
controller.close();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
buffer += decoder.decode(value, { stream: true });
|
|
110
|
+
emitProcessedBlocks(controller);
|
|
111
|
+
},
|
|
112
|
+
cancel(reason) {
|
|
113
|
+
reader.cancel(reason).catch(() => {});
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
package/src/server/system-env.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { getConfigDir } from "../config";
|
|
@@ -6,6 +6,7 @@ import { resolveAutoContext, type AutoContextMode } from "../claude/context-wind
|
|
|
6
6
|
import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens } from "../claude/auth-detect";
|
|
7
7
|
import { resolveClaudeAuthMode } from "../claude/auth-mode";
|
|
8
8
|
import type { OcxConfig } from "../types";
|
|
9
|
+
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Does the opencodex dummy marker belong in the system environment?
|
|
@@ -72,8 +73,10 @@ function writeShellEnvFile(port: number, config: OcxConfig, modelEnv: Record<str
|
|
|
72
73
|
if (config.claudeCode?.alwaysEnableEffort === true) {
|
|
73
74
|
lines.push(conditional("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1"));
|
|
74
75
|
}
|
|
76
|
+
const shellEnvPath = getShellEnvFilePath();
|
|
77
|
+
recordOwnedConfigPath(getConfigDir(), shellEnvPath);
|
|
75
78
|
mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
|
|
76
|
-
writeFileSync(
|
|
79
|
+
writeFileSync(shellEnvPath, lines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
function removeShellEnvFile(): void {
|
|
@@ -128,6 +131,19 @@ const SYSTEM_ENV_NAMES = [
|
|
|
128
131
|
"ANTHROPIC_AUTH_TOKEN",
|
|
129
132
|
] as const;
|
|
130
133
|
|
|
134
|
+
const MANAGED_SYSTEM_ENV_NAMES = new Set<string>([
|
|
135
|
+
...SYSTEM_ENV_NAMES,
|
|
136
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
137
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
138
|
+
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
139
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
140
|
+
"ANTHROPIC_SMALL_FAST_MODEL",
|
|
141
|
+
"CLAUDE_CODE_MAX_CONTEXT_TOKENS",
|
|
142
|
+
"DISABLE_COMPACT",
|
|
143
|
+
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
|
|
144
|
+
"CLAUDE_CODE_ALWAYS_ENABLE_EFFORT",
|
|
145
|
+
]);
|
|
146
|
+
|
|
131
147
|
interface SystemEnvTracking {
|
|
132
148
|
pid: number;
|
|
133
149
|
port: number;
|
|
@@ -147,7 +163,7 @@ export function getSystemEnvTrackingPath(): string {
|
|
|
147
163
|
export function launchctlGetenv(name: string): string | undefined {
|
|
148
164
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return undefined;
|
|
149
165
|
try {
|
|
150
|
-
const value =
|
|
166
|
+
const value = execFileSync("/bin/launchctl", ["getenv", name], { encoding: "utf8" }).trim();
|
|
151
167
|
return value || undefined;
|
|
152
168
|
} catch {
|
|
153
169
|
return undefined;
|
|
@@ -160,22 +176,23 @@ function readTracking(): SystemEnvTracking | undefined {
|
|
|
160
176
|
if (!Number.isInteger(tracking.port) || typeof tracking.pid !== "number" || typeof tracking.injectedAt !== "string") {
|
|
161
177
|
return undefined;
|
|
162
178
|
}
|
|
163
|
-
|
|
179
|
+
const injectedKeys = Array.isArray(tracking.injectedKeys)
|
|
180
|
+
? [...new Set(tracking.injectedKeys.filter(
|
|
181
|
+
(name): name is string => typeof name === "string" && MANAGED_SYSTEM_ENV_NAMES.has(name),
|
|
182
|
+
))]
|
|
183
|
+
: undefined;
|
|
184
|
+
return { ...tracking, injectedKeys } as SystemEnvTracking;
|
|
164
185
|
} catch {
|
|
165
186
|
return undefined;
|
|
166
187
|
}
|
|
167
188
|
}
|
|
168
189
|
|
|
169
|
-
function shellArg(value: string): string {
|
|
170
|
-
return /^[A-Za-z0-9_./:-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
190
|
function setLaunchctlEnv(name: string, value: string): void {
|
|
174
|
-
|
|
191
|
+
execFileSync("/bin/launchctl", ["setenv", name, value]);
|
|
175
192
|
}
|
|
176
193
|
|
|
177
194
|
function unsetLaunchctlEnv(name: string): void {
|
|
178
|
-
|
|
195
|
+
execFileSync("/bin/launchctl", ["unsetenv", name]);
|
|
179
196
|
}
|
|
180
197
|
|
|
181
198
|
function ownedBaseUrl(port: number): string {
|
|
@@ -183,6 +200,7 @@ function ownedBaseUrl(port: number): string {
|
|
|
183
200
|
}
|
|
184
201
|
|
|
185
202
|
function writeTracking(port: number, injectedKeys: string[]): void {
|
|
203
|
+
recordOwnedConfigPath(getConfigDir(), getSystemEnvTrackingPath());
|
|
186
204
|
mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
|
|
187
205
|
writeFileSync(getSystemEnvTrackingPath(), JSON.stringify({
|
|
188
206
|
pid: process.pid,
|
package/src/service.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION } from "./lib/winsw";
|
|
35
35
|
import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
|
|
36
36
|
import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
|
|
37
|
+
import { recordOwnedConfigPath } from "./lib/config-ownership";
|
|
37
38
|
|
|
38
39
|
const LABEL = "com.opencodex.proxy";
|
|
39
40
|
const TASK = "opencodex-proxy";
|
|
@@ -146,6 +147,7 @@ function writeServiceInstallState(backend: ServiceBackend = "scheduler"): void {
|
|
|
146
147
|
};
|
|
147
148
|
for (const path of serviceStatePaths()) {
|
|
148
149
|
const dir = dirname(path);
|
|
150
|
+
recordOwnedConfigPath(getConfigDir(), path);
|
|
149
151
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
150
152
|
writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
151
153
|
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
@@ -254,6 +256,7 @@ function writeServiceApiTokenFile(): string | null {
|
|
|
254
256
|
if (!token) return null;
|
|
255
257
|
const path = serviceApiTokenFilePath();
|
|
256
258
|
const dir = getConfigDir();
|
|
259
|
+
recordOwnedConfigPath(dir, path);
|
|
257
260
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
258
261
|
if (process.platform === "win32") hardenSecretDir(dir, { required: true });
|
|
259
262
|
writeFileSync(path, `${token}\n`, { encoding: "utf8", mode: 0o600 });
|
|
@@ -1039,6 +1042,43 @@ function taskXmlHasPrefixedTag(xml: string, tag: string): boolean {
|
|
|
1039
1042
|
* Absence means the documented schema default (#432); a present element must still
|
|
1040
1043
|
* match exactly, so a malformed or explicitly unsafe value never reads as healthy.
|
|
1041
1044
|
*/
|
|
1045
|
+
/**
|
|
1046
|
+
* Decode XML's five predefined entities, exactly once.
|
|
1047
|
+
*
|
|
1048
|
+
* Task Scheduler re-encodes element text when it exports a task, so a needle we
|
|
1049
|
+
* escaped ourselves can never match its output (#608). Compare decoded values
|
|
1050
|
+
* instead of encoded ones.
|
|
1051
|
+
*
|
|
1052
|
+
* The single pass is the point: decoding twice would turn `&quot;` into `"`,
|
|
1053
|
+
* letting a doubly-encoded value impersonate the expected launcher path.
|
|
1054
|
+
*/
|
|
1055
|
+
function taskXmlDecodeEntities(value: string): string {
|
|
1056
|
+
return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, name: string) => (
|
|
1057
|
+
name === "amp" ? "&"
|
|
1058
|
+
: name === "lt" ? "<"
|
|
1059
|
+
: name === "gt" ? ">"
|
|
1060
|
+
: name === "quot" ? "\""
|
|
1061
|
+
: "'"
|
|
1062
|
+
));
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Exactly one unprefixed `<tag>` whose DECODED text equals `expected`.
|
|
1067
|
+
*
|
|
1068
|
+
* Unlike taskXmlOptionalValueEquals(), an absent element is NOT a pass: these
|
|
1069
|
+
* elements name what actually gets executed, so a missing <Command>/<Arguments>
|
|
1070
|
+
* must fail the health check rather than inherit a schema default.
|
|
1071
|
+
*/
|
|
1072
|
+
function taskXmlDecodedValueEquals(xml: string, tag: string, expected: string): boolean {
|
|
1073
|
+
// Same reasoning as the optional helper: `<t:Arguments>` must not read as absent.
|
|
1074
|
+
if (taskXmlHasPrefixedTag(xml, tag)) return false;
|
|
1075
|
+
if (taskXmlElementCount(xml, tag) !== 1) return false;
|
|
1076
|
+
// `[^<]*` refuses nested markup, so a decoy inside a child element cannot match.
|
|
1077
|
+
const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>([^<]*)<\\/${tag}>`, "i").exec(xml)?.[1];
|
|
1078
|
+
if (value === undefined) return false;
|
|
1079
|
+
return taskXmlDecodeEntities(value).trim() === expected.trim();
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1042
1082
|
function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string): boolean {
|
|
1043
1083
|
// Check the prefixed form first: treating `<t:Enabled>false</t:Enabled>` as an
|
|
1044
1084
|
// omission would turn an explicitly disabled task into a healthy one.
|
|
@@ -1076,8 +1116,11 @@ export function windowsTaskRegistrationHealthy(
|
|
|
1076
1116
|
&& taskXmlOptionalValueEquals(settings, "Enabled", "true")
|
|
1077
1117
|
&& /<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/i.test(settings)
|
|
1078
1118
|
&& /<ExecutionTimeLimit>\s*PT0S\s*<\/ExecutionTimeLimit>/i.test(settings)
|
|
1079
|
-
|
|
1080
|
-
|
|
1119
|
+
// Compare decoded VALUES, not encodings: Task Scheduler canonicalizes the
|
|
1120
|
+
// quotes we wrote as `"` back to literal `"` on export, so an escaped
|
|
1121
|
+
// needle never matched and a healthy task read as permanently stale (#608).
|
|
1122
|
+
&& taskXmlDecodedValueEquals(action, "Command", wscript)
|
|
1123
|
+
&& taskXmlDecodedValueEquals(action, "Arguments", `/b /nologo "${launcher}"`);
|
|
1081
1124
|
}
|
|
1082
1125
|
|
|
1083
1126
|
export interface WindowsSchedulerXmlState {
|
|
@@ -1112,6 +1155,7 @@ export function readWindowsSchedulerXmlState(
|
|
|
1112
1155
|
function installLaunchd(): void {
|
|
1113
1156
|
const dir = join(homedir(), "Library", "LaunchAgents");
|
|
1114
1157
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1158
|
+
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
|
|
1115
1159
|
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
1116
1160
|
writeServiceApiTokenFile();
|
|
1117
1161
|
const p = plistPath();
|
|
@@ -1148,6 +1192,7 @@ function writeServiceAssetWithRetry(path: string, content: string, encoding: "ut
|
|
|
1148
1192
|
}
|
|
1149
1193
|
|
|
1150
1194
|
function installWindows(): void {
|
|
1195
|
+
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
|
|
1151
1196
|
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
1152
1197
|
writeServiceApiTokenFile();
|
|
1153
1198
|
// Transactional backend switch: installing the scheduler backend removes a native
|
|
@@ -1183,6 +1228,7 @@ function installWindows(): void {
|
|
|
1183
1228
|
* reported) — never a silent fallback to the scheduler.
|
|
1184
1229
|
*/
|
|
1185
1230
|
async function installWindowsNative(): Promise<void> {
|
|
1231
|
+
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
|
|
1186
1232
|
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
1187
1233
|
writeServiceApiTokenFile();
|
|
1188
1234
|
let hadScheduler = false;
|
|
@@ -1321,6 +1367,7 @@ function installSystemd(): void {
|
|
|
1321
1367
|
ensureUserBusEnv(); // reach the user bus over a bare SSH session (F9)
|
|
1322
1368
|
const dir = unitDir();
|
|
1323
1369
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1370
|
+
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
|
|
1324
1371
|
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
1325
1372
|
writeServiceApiTokenFile();
|
|
1326
1373
|
writeFileSync(unitPath(), buildUnit(), "utf8");
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-flight wrapper for manual archived cleanup (management API).
|
|
3
|
+
*
|
|
4
|
+
* Shares the CODEX_HOME storage-mutation coordinator with trash restore and
|
|
5
|
+
* (Phase 3) policy-driven cleanup.
|
|
6
|
+
*/
|
|
7
|
+
import { resolveCodexHomeDir } from "../codex/home";
|
|
8
|
+
import {
|
|
9
|
+
executeArchivedCleanup,
|
|
10
|
+
type CleanupResult,
|
|
11
|
+
type ExecuteCleanupOptions,
|
|
12
|
+
} from "./cleanup";
|
|
13
|
+
import {
|
|
14
|
+
resetStorageMutationCoordinatorForTests,
|
|
15
|
+
setStorageMutationCoordinatorTestHooks,
|
|
16
|
+
withStorageMutationSlot,
|
|
17
|
+
type StorageMutationCoordinatorTestHooks,
|
|
18
|
+
} from "./storage-mutation-coordinator";
|
|
19
|
+
|
|
20
|
+
export type CleanupJobTestHooks = StorageMutationCoordinatorTestHooks;
|
|
21
|
+
|
|
22
|
+
export function setArchivedCleanupJobTestHooks(hooks: CleanupJobTestHooks | null): void {
|
|
23
|
+
setStorageMutationCoordinatorTestHooks(hooks);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resetArchivedCleanupJobForTests(): void {
|
|
27
|
+
resetStorageMutationCoordinatorForTests();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run archived cleanup under the shared storage-mutation gate.
|
|
32
|
+
* Returns immediately with `storage_mutation_busy` when restore or another
|
|
33
|
+
* cleanup is in flight for the same CODEX_HOME.
|
|
34
|
+
*/
|
|
35
|
+
export async function runArchivedCleanupJob(
|
|
36
|
+
options: ExecuteCleanupOptions,
|
|
37
|
+
): Promise<CleanupResult> {
|
|
38
|
+
const codexHome = options.codexHome ?? resolveCodexHomeDir();
|
|
39
|
+
const mode = options.mode;
|
|
40
|
+
const percent = options.percent;
|
|
41
|
+
const result = await withStorageMutationSlot("cleanup", codexHome, () =>
|
|
42
|
+
executeArchivedCleanup(options),
|
|
43
|
+
);
|
|
44
|
+
if (result && typeof result === "object" && "ok" in result && result.ok === false
|
|
45
|
+
&& "error" in result && result.error === "storage_mutation_busy") {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
mode,
|
|
49
|
+
percent,
|
|
50
|
+
count: 0,
|
|
51
|
+
bytes: 0,
|
|
52
|
+
removedPaths: [],
|
|
53
|
+
error: "storage_mutation_busy",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return result as CleanupResult;
|
|
57
|
+
}
|