@bitkyc08/opencodex 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/bin/ocx.mjs +32 -4
- package/gui/dist/assets/index-CHwf3tTD.css +1 -0
- package/gui/dist/assets/index-u5eFOv2y.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-normalize.ts +114 -20
- package/src/adapters/anthropic.ts +126 -10
- package/src/adapters/azure.ts +3 -3
- package/src/adapters/base.ts +7 -3
- package/src/adapters/cursor/discovery.ts +14 -4
- package/src/adapters/cursor/effort-map.ts +18 -6
- package/src/adapters/cursor/framing.ts +102 -27
- package/src/adapters/cursor/kv-store.ts +30 -3
- package/src/adapters/cursor/live-models.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +245 -49
- package/src/adapters/cursor/mcp-manager.ts +105 -8
- package/src/adapters/cursor/native-exec-mcp.ts +5 -3
- package/src/adapters/cursor/native-exec-shell.ts +296 -14
- package/src/adapters/cursor/native-exec.ts +381 -33
- package/src/adapters/cursor/protobuf-events.ts +28 -1
- package/src/adapters/cursor/protobuf-request.ts +71 -39
- package/src/adapters/cursor/request-builder.ts +2 -2
- package/src/adapters/cursor/transport.ts +2 -0
- package/src/adapters/cursor.ts +13 -2
- package/src/adapters/google-antigravity-replay.ts +184 -17
- package/src/adapters/google.ts +58 -8
- package/src/adapters/kiro-thinking.ts +23 -9
- package/src/adapters/kiro-tools.ts +49 -18
- package/src/adapters/kiro.ts +377 -133
- package/src/adapters/mimo-free.ts +36 -4
- package/src/adapters/openai-chat.ts +143 -17
- package/src/adapters/openai-responses.ts +130 -14
- package/src/adapters/run-turn-queue.ts +7 -1
- package/src/bridge.ts +466 -69
- package/src/chat/outbound.ts +144 -38
- package/src/claude/inbound-debug.ts +53 -8
- package/src/claude/outbound.ts +224 -38
- package/src/cli/agent-driven.ts +34 -1
- package/src/cli/catalog-prewarm.ts +5 -2
- package/src/cli/claude-desktop.ts +2 -2
- package/src/cli/doctor.ts +12 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +11 -0
- package/src/cli/index.ts +13 -3
- package/src/cli/init.ts +129 -102
- package/src/cli/opencode.ts +36 -151
- package/src/cli/star-prompt.ts +13 -4
- package/src/cli/status-oauth.ts +12 -2
- package/src/clients/config-export.ts +377 -0
- package/src/codex/account-runtime-state.ts +19 -1
- package/src/codex/account-store.ts +162 -82
- package/src/codex/auth-api.ts +467 -159
- package/src/codex/auth-context.ts +15 -2
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/effort.ts +16 -6
- package/src/codex/catalog/metadata.ts +6 -0
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +29 -0
- package/src/codex/catalog/sync.ts +64 -7
- package/src/codex/catalog.ts +2 -2
- package/src/codex/inject.ts +5 -5
- package/src/codex/main-account-cache.ts +8 -1
- package/src/codex/model-cache.ts +81 -2
- package/src/codex/pool-rotation.ts +39 -0
- package/src/codex/project-config-warnings.ts +12 -1
- package/src/codex/quota.ts +35 -3
- package/src/codex/routing.ts +46 -1
- package/src/codex/shim.ts +10 -4
- package/src/codex/subagent-model-fallback.ts +12 -0
- package/src/codex/websocket-registry.ts +27 -0
- package/src/combos/failover.ts +31 -1
- package/src/combos/request.ts +9 -0
- package/src/combos/resolve.ts +60 -4
- package/src/combos/types.ts +12 -0
- package/src/config.ts +510 -55
- package/src/github/star-state.ts +13 -1
- package/src/images/fulfill.ts +39 -1
- package/src/images/loop.ts +52 -12
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bun-stream-caps.ts +31 -7
- package/src/lib/config-ownership.ts +33 -0
- package/src/lib/crash-guard.ts +65 -5
- package/src/lib/debug-log-buffer.ts +47 -6
- package/src/lib/destination-policy.ts +12 -1
- package/src/lib/errors.ts +3 -0
- package/src/lib/gcp-adc.ts +40 -2
- package/src/lib/injection-debug-log.ts +26 -2
- package/src/lib/provider-outbound.ts +3 -0
- package/src/lib/sidecar-tracker.ts +5 -2
- package/src/lib/sse-decoder.ts +257 -37
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/windows-secret-acl.ts +33 -12
- package/src/lib/winsw.ts +14 -1
- package/src/oauth/anthropic-routing.ts +31 -7
- package/src/oauth/google-antigravity.ts +2 -1
- package/src/oauth/health.ts +30 -12
- package/src/oauth/index.ts +127 -23
- package/src/oauth/kiro-credentials.ts +72 -1
- package/src/oauth/kiro.ts +23 -4
- package/src/oauth/store.ts +165 -18
- package/src/oauth/token-guardian.ts +43 -4
- package/src/oauth/types.ts +2 -1
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/derive.ts +12 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/key-failover.ts +12 -0
- package/src/providers/openai-sidecar.ts +4 -1
- package/src/providers/quota.ts +68 -7
- package/src/providers/registry.ts +279 -3
- package/src/responses/parser.ts +5 -1
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +520 -102
- package/src/router.ts +18 -1
- package/src/server/adapter-resolve.ts +20 -3
- package/src/server/auth-cors.ts +121 -28
- package/src/server/chat-completions.ts +57 -12
- package/src/server/claude-messages.ts +85 -13
- package/src/server/index.ts +242 -100
- package/src/server/lifecycle.ts +155 -25
- package/src/server/management/agent-settings-routes.ts +79 -36
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +5 -1
- package/src/server/management/config-routes.ts +42 -12
- package/src/server/management/logs-usage-routes.ts +41 -21
- package/src/server/management/model-routes.ts +188 -54
- package/src/server/management/oauth-account-routes.ts +115 -26
- package/src/server/management/provider-routes.ts +56 -6
- package/src/server/management/shared.ts +16 -3
- package/src/server/management/sidebar-routes.ts +50 -1
- package/src/server/management/system-restart.ts +13 -6
- package/src/server/management/system-routes.ts +15 -3
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +39 -5
- package/src/server/management-auth.ts +65 -14
- package/src/server/port-reclaim.ts +58 -12
- package/src/server/ports.ts +2 -0
- package/src/server/proxy-liveness.ts +60 -14
- package/src/server/relay-eager.ts +20 -4
- package/src/server/relay.ts +548 -154
- package/src/server/request-decompress.ts +51 -4
- package/src/server/request-log.ts +134 -15
- package/src/server/responses/collaboration.ts +15 -4
- package/src/server/responses/compact.ts +3 -0
- package/src/server/responses/core.ts +241 -66
- package/src/server/responses-image-gen-repair.ts +19 -5
- package/src/server/responses-item-id-repair.ts +23 -5
- package/src/server/sse-payload-rewrite.ts +71 -12
- package/src/server/startup-health-cache.ts +14 -1
- package/src/server/system-env.ts +8 -1
- package/src/server/windows-tcp-drop.ts +15 -5
- package/src/server/ws-bridge.ts +25 -0
- package/src/service.ts +179 -13
- package/src/storage/policy-job.ts +93 -23
- package/src/storage/policy-worker.ts +6 -0
- package/src/storage/restore-job.ts +62 -16
- package/src/storage/restore-worker.ts +6 -0
- package/src/storage/storage-mutation-coordinator.ts +36 -6
- package/src/storage/worker-lifecycle.ts +181 -47
- package/src/tray/windows.ts +97 -25
- package/src/types.ts +39 -6
- package/src/update/index.ts +24 -5
- package/src/update/job.ts +598 -73
- package/src/usage/log.ts +115 -17
- package/src/usage/summary.ts +67 -2
- package/src/vision/index.ts +112 -22
- package/src/web-search/loop.ts +38 -6
- package/src/web-search/progress-stream.ts +14 -3
- package/gui/dist/assets/index-BDjpkcRN.js +0 -67
- package/gui/dist/assets/index-BHsKRFh9.css +0 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
export const STATE_SWEEP_INTERVAL_MS = 60_000;
|
|
2
|
+
|
|
3
|
+
export interface GenerationContext {
|
|
4
|
+
generation: number;
|
|
5
|
+
providerNames: ReadonlySet<string>;
|
|
6
|
+
comboIds: ReadonlySet<string>;
|
|
7
|
+
comboTargets: ReadonlySet<string>;
|
|
8
|
+
codexAccountIds: ReadonlySet<string>;
|
|
9
|
+
oauthAccountKeys: ReadonlySet<string>;
|
|
10
|
+
configRoots: ReadonlySet<string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface StateStoreRegistration {
|
|
14
|
+
name: string;
|
|
15
|
+
sweepExpired?: (now: number) => number;
|
|
16
|
+
sweepLiveness?: () => number;
|
|
17
|
+
reconcileGeneration?: (context: GenerationContext) => number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StateSweepAfterTickRegistration {
|
|
21
|
+
name: string;
|
|
22
|
+
afterTick(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface StateSweepResult {
|
|
26
|
+
storesVisited: number;
|
|
27
|
+
rowsRemoved: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StateStoreSweeperOptions {
|
|
31
|
+
/** Override the 60 s interval (tests only; production uses the default). */
|
|
32
|
+
intervalMs?: number;
|
|
33
|
+
/** Injectable clock for tests. */
|
|
34
|
+
now?: () => number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const registrations = new Map<string, StateStoreRegistration>();
|
|
38
|
+
const afterTickRegistrations = new Map<string, StateSweepAfterTickRegistration>();
|
|
39
|
+
let configGeneration = 0;
|
|
40
|
+
let attemptSequence = 0;
|
|
41
|
+
let generationContextBuilder: (() => GenerationContext) | null = null;
|
|
42
|
+
let reconciliationRetryPending = false;
|
|
43
|
+
let interval: ReturnType<typeof setInterval> | null = null;
|
|
44
|
+
|
|
45
|
+
function logCallbackFailure(name: string): void {
|
|
46
|
+
console.warn(`[state-store-sweeper] ${name} failed`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function registerStateStore(registration: StateStoreRegistration): () => void {
|
|
50
|
+
registrations.set(registration.name, registration);
|
|
51
|
+
return () => {
|
|
52
|
+
if (registrations.get(registration.name) === registration) {
|
|
53
|
+
registrations.delete(registration.name);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function registerStateSweepAfterTick(registration: StateSweepAfterTickRegistration): () => void {
|
|
59
|
+
afterTickRegistrations.set(registration.name, registration);
|
|
60
|
+
return () => {
|
|
61
|
+
if (afterTickRegistrations.get(registration.name) === registration) {
|
|
62
|
+
afterTickRegistrations.delete(registration.name);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function runCallbacks(
|
|
68
|
+
callback: (registration: StateStoreRegistration) => (() => number) | undefined,
|
|
69
|
+
): StateSweepResult {
|
|
70
|
+
let storesVisited = 0;
|
|
71
|
+
let rowsRemoved = 0;
|
|
72
|
+
for (const registration of registrations.values()) {
|
|
73
|
+
const invoke = callback(registration);
|
|
74
|
+
if (!invoke) continue;
|
|
75
|
+
storesVisited += 1;
|
|
76
|
+
try {
|
|
77
|
+
rowsRemoved += invoke();
|
|
78
|
+
} catch {
|
|
79
|
+
logCallbackFailure(registration.name);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { storesVisited, rowsRemoved };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function sweepExpired(now = Date.now()): StateSweepResult {
|
|
86
|
+
return runCallbacks(registration => registration.sweepExpired
|
|
87
|
+
? () => registration.sweepExpired!(now)
|
|
88
|
+
: undefined);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function sweepExpiredOnWrite(now = Date.now()): StateSweepResult {
|
|
92
|
+
const result = sweepExpired(now);
|
|
93
|
+
if (reconciliationRetryPending && generationContextBuilder) {
|
|
94
|
+
try {
|
|
95
|
+
reconcileStateGeneration(generationContextBuilder());
|
|
96
|
+
} catch {
|
|
97
|
+
// The builder reads authoritative live owners. Keep the single retry pending
|
|
98
|
+
// until a later successful owner write can obtain a complete context.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function sweepLiveness(): StateSweepResult {
|
|
105
|
+
return runCallbacks(registration => registration.sweepLiveness);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function runAfterTickCallbacks(): void {
|
|
109
|
+
for (const registration of afterTickRegistrations.values()) {
|
|
110
|
+
try {
|
|
111
|
+
registration.afterTick();
|
|
112
|
+
} catch {
|
|
113
|
+
logCallbackFailure(registration.name);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function captureConfigGeneration(): number {
|
|
119
|
+
return configGeneration;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function setGenerationContextBuilder(build: () => GenerationContext): void {
|
|
123
|
+
generationContextBuilder = build;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function reconcileStateGeneration(context: GenerationContext): StateSweepResult {
|
|
127
|
+
const candidateGeneration = ++attemptSequence;
|
|
128
|
+
const candidateContext: GenerationContext = {
|
|
129
|
+
...context,
|
|
130
|
+
generation: candidateGeneration,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
let storesVisited = 0;
|
|
134
|
+
let rowsRemoved = 0;
|
|
135
|
+
let failed = false;
|
|
136
|
+
for (const registration of registrations.values()) {
|
|
137
|
+
if (!registration.reconcileGeneration) continue;
|
|
138
|
+
storesVisited += 1;
|
|
139
|
+
try {
|
|
140
|
+
rowsRemoved += registration.reconcileGeneration(candidateContext);
|
|
141
|
+
} catch {
|
|
142
|
+
failed = true;
|
|
143
|
+
logCallbackFailure(registration.name);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (failed) {
|
|
147
|
+
reconciliationRetryPending = true;
|
|
148
|
+
} else {
|
|
149
|
+
configGeneration = candidateGeneration;
|
|
150
|
+
reconciliationRetryPending = false;
|
|
151
|
+
}
|
|
152
|
+
return { storesVisited, rowsRemoved };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function startStateStoreSweeper(
|
|
156
|
+
options: StateStoreSweeperOptions = {},
|
|
157
|
+
): { stop(): void } {
|
|
158
|
+
stopStateStoreSweeper();
|
|
159
|
+
const now = options.now ?? Date.now;
|
|
160
|
+
interval = setInterval(() => {
|
|
161
|
+
sweepExpired(now());
|
|
162
|
+
sweepLiveness();
|
|
163
|
+
runAfterTickCallbacks();
|
|
164
|
+
}, options.intervalMs ?? STATE_SWEEP_INTERVAL_MS);
|
|
165
|
+
interval.unref?.();
|
|
166
|
+
return { stop: stopStateStoreSweeper };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function stopStateStoreSweeper(): void {
|
|
170
|
+
if (!interval) return;
|
|
171
|
+
clearInterval(interval);
|
|
172
|
+
interval = null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Test-only reset for module-global registrations, generation, and lifecycle state. */
|
|
176
|
+
export function resetStateStoreSweeperForTests(): void {
|
|
177
|
+
stopStateStoreSweeper();
|
|
178
|
+
registrations.clear();
|
|
179
|
+
afterTickRegistrations.clear();
|
|
180
|
+
configGeneration = 0;
|
|
181
|
+
attemptSequence = 0;
|
|
182
|
+
generationContextBuilder = null;
|
|
183
|
+
reconciliationRetryPending = false;
|
|
184
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
export const TRANSLATOR_MAX_CALL_ARGUMENT_BYTES = 2 * 1024 * 1024;
|
|
2
|
+
export const TRANSLATOR_MAX_TURN_BYTES = 32 * 1024 * 1024;
|
|
3
|
+
export const TRANSLATOR_MAX_SSE_EVENT_BYTES = 32 * 1024 * 1024;
|
|
4
|
+
export const CURSOR_MAX_CONNECT_FRAME_BYTES = 32 * 1024 * 1024;
|
|
5
|
+
export const CURSOR_MAX_EFFECTIVE_CONNECT_PAYLOAD_BYTES = 16 * 1024 * 1024;
|
|
6
|
+
export const CURSOR_TRANSPORT_MAX_BUFFERED_BYTES = 32 * 1024 * 1024;
|
|
7
|
+
export const CURSOR_TRANSPORT_RESUME_BYTES = 16 * 1024 * 1024;
|
|
8
|
+
export const CURSOR_MAX_PENDING_FRAMES = 1024;
|
|
9
|
+
export const CURSOR_PENDING_FRAMES_RESUME = 512;
|
|
10
|
+
export const MAX_PENDING_IMAGE_FULFILLMENTS = 64;
|
|
11
|
+
export const MAX_PENDING_OAUTH_MUTATIONS = 128;
|
|
12
|
+
|
|
13
|
+
export type TranslatorBufferKind =
|
|
14
|
+
| "tool_args"
|
|
15
|
+
| "retained_collectors"
|
|
16
|
+
| "live_transient"
|
|
17
|
+
| "reasoning"
|
|
18
|
+
| "item_ids"
|
|
19
|
+
| "tool_search_sources"
|
|
20
|
+
| "cursor_transport"
|
|
21
|
+
| "cursor_kv"
|
|
22
|
+
| "request_copies";
|
|
23
|
+
|
|
24
|
+
export type ExternallyCappedKind = "passthrough_serialization" | "mcp_payload";
|
|
25
|
+
|
|
26
|
+
export class TranslatorBudgetExceededError extends Error {
|
|
27
|
+
readonly code = "translation_buffer_limit";
|
|
28
|
+
|
|
29
|
+
constructor(readonly kind: TranslatorBufferKind, readonly limitBytes: number) {
|
|
30
|
+
super(`translator ${kind} buffer exceeded ${limitBytes} bytes`);
|
|
31
|
+
this.name = "TranslatorBudgetExceededError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isTranslatorBudgetExceededError(error: unknown): error is TranslatorBudgetExceededError {
|
|
36
|
+
return error instanceof TranslatorBudgetExceededError
|
|
37
|
+
|| (error instanceof Error && (error as { code?: unknown }).code === "translation_buffer_limit");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TranslatorBudgetSnapshot {
|
|
41
|
+
currentBytes: number;
|
|
42
|
+
highWaterBytes: number;
|
|
43
|
+
activeCalls: number;
|
|
44
|
+
overflows: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TranslatorBudgetOptions {
|
|
48
|
+
maxCallArgumentBytes?: number;
|
|
49
|
+
maxTurnBytes?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TranslatorTransientReservation {
|
|
53
|
+
commitRetained(): void;
|
|
54
|
+
release(): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface TranslatorBudget {
|
|
58
|
+
openCall(id: string): void;
|
|
59
|
+
closeCall(id: string): void;
|
|
60
|
+
reserveTransient(
|
|
61
|
+
bytes: number,
|
|
62
|
+
scope: { kind: TranslatorBufferKind; callId?: string },
|
|
63
|
+
): TranslatorTransientReservation;
|
|
64
|
+
chargeRetained(delta: number, scope: { kind: TranslatorBufferKind; callId?: string }): void;
|
|
65
|
+
releaseRetained(bytes: number, scope: { kind: TranslatorBufferKind; callId?: string }): void;
|
|
66
|
+
observeAcceptedRequestCopy(bytes: number): () => void;
|
|
67
|
+
observeExternallyCapped(kind: ExternallyCappedKind, bytes: number): () => void;
|
|
68
|
+
snapshot(): TranslatorBudgetSnapshot;
|
|
69
|
+
dispose(): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const retainedEventOwnership = new WeakMap<object, { budget: TranslatorBudget; bytes: number }>();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Charge a materialized adapter-event batch and attach its lease to the events themselves.
|
|
76
|
+
* A copied event array (for example terminal-guard collection) preserves the event objects, so
|
|
77
|
+
* the response builder can consume each source lease immediately after its replacement lands.
|
|
78
|
+
*/
|
|
79
|
+
export function retainTranslatedEventBatch<T extends object>(events: T[], budget: TranslatorBudget): void {
|
|
80
|
+
if (events.length === 0) return;
|
|
81
|
+
const serialized = events.map(event => JSON.stringify(event));
|
|
82
|
+
const totalBytes = Buffer.byteLength(`[${serialized.join(",")}]`);
|
|
83
|
+
budget.chargeRetained(totalBytes, { kind: "retained_collectors" });
|
|
84
|
+
for (let index = 0; index < events.length; index++) {
|
|
85
|
+
const delimiterBytes = index === events.length - 1 ? 2 : 1;
|
|
86
|
+
retainedEventOwnership.set(events[index]!, {
|
|
87
|
+
budget,
|
|
88
|
+
bytes: Buffer.byteLength(serialized[index]!) + delimiterBytes,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Transfer one charged adapter event to the response builder's newly materialized owner. */
|
|
94
|
+
export function releaseTranslatedEvent(event: object, budget: TranslatorBudget): void {
|
|
95
|
+
const ownership = retainedEventOwnership.get(event);
|
|
96
|
+
if (!ownership || ownership.budget !== budget) return;
|
|
97
|
+
retainedEventOwnership.delete(event);
|
|
98
|
+
budget.releaseRetained(ownership.bytes, { kind: "retained_collectors" });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const liveBudgets = new Set<Budget>();
|
|
102
|
+
let aggregateCurrentBytes = 0;
|
|
103
|
+
let aggregateActiveCalls = 0;
|
|
104
|
+
let aggregateHighWaterBytes = 0;
|
|
105
|
+
let aggregateOverflows = 0;
|
|
106
|
+
|
|
107
|
+
function normalizeBytes(bytes: number): number {
|
|
108
|
+
return Number.isFinite(bytes) && bytes > 0 ? Math.floor(bytes) : 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function observeAggregate(): void {
|
|
112
|
+
aggregateHighWaterBytes = Math.max(aggregateHighWaterBytes, aggregateCurrentBytes);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class Budget implements TranslatorBudget {
|
|
116
|
+
private readonly calls = new Map<string, { logicalBytes: number; physicalBytes: number }>();
|
|
117
|
+
private readonly charged = new Map<TranslatorBufferKind, number>();
|
|
118
|
+
private hardChargedBytes = 0;
|
|
119
|
+
private observedBytes = 0;
|
|
120
|
+
private currentBytes = 0;
|
|
121
|
+
private highWaterBytes = 0;
|
|
122
|
+
private overflowCount = 0;
|
|
123
|
+
private disposed = false;
|
|
124
|
+
|
|
125
|
+
constructor(
|
|
126
|
+
private readonly maxCallArgumentBytes: number,
|
|
127
|
+
private readonly maxTurnBytes: number,
|
|
128
|
+
) {}
|
|
129
|
+
|
|
130
|
+
private chargedBytes(): number {
|
|
131
|
+
return this.hardChargedBytes;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
observedCurrentBytes(): number {
|
|
135
|
+
return this.currentBytes;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private changed(): void {
|
|
139
|
+
const nextBytes = this.hardChargedBytes + this.observedBytes;
|
|
140
|
+
aggregateCurrentBytes += nextBytes - this.currentBytes;
|
|
141
|
+
this.currentBytes = nextBytes;
|
|
142
|
+
this.highWaterBytes = Math.max(this.highWaterBytes, nextBytes);
|
|
143
|
+
observeAggregate();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private reject(kind: TranslatorBufferKind, limitBytes: number): never {
|
|
147
|
+
this.overflowCount += 1;
|
|
148
|
+
aggregateOverflows += 1;
|
|
149
|
+
throw new TranslatorBudgetExceededError(kind, limitBytes);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private admit(kind: TranslatorBufferKind, bytes: number): number {
|
|
153
|
+
const admitted = normalizeBytes(bytes);
|
|
154
|
+
if (this.chargedBytes() + admitted > this.maxTurnBytes) this.reject(kind, this.maxTurnBytes);
|
|
155
|
+
return admitted;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
openCall(id: string): void {
|
|
159
|
+
if (!this.calls.has(id)) {
|
|
160
|
+
this.calls.set(id, { logicalBytes: 0, physicalBytes: 0 });
|
|
161
|
+
aggregateActiveCalls += 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
closeCall(id: string): void {
|
|
166
|
+
const call = this.calls.get(id);
|
|
167
|
+
if (!call) return;
|
|
168
|
+
this.hardChargedBytes = Math.max(0, this.hardChargedBytes - call.physicalBytes);
|
|
169
|
+
this.calls.delete(id);
|
|
170
|
+
aggregateActiveCalls = Math.max(0, aggregateActiveCalls - 1);
|
|
171
|
+
this.changed();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
reserveTransient(
|
|
175
|
+
bytes: number,
|
|
176
|
+
scope: { kind: TranslatorBufferKind; callId?: string },
|
|
177
|
+
): TranslatorTransientReservation {
|
|
178
|
+
const admitted = normalizeBytes(bytes);
|
|
179
|
+
if (scope.callId && admitted > this.maxCallArgumentBytes) {
|
|
180
|
+
this.reject("tool_args", this.maxCallArgumentBytes);
|
|
181
|
+
}
|
|
182
|
+
this.admit(scope.kind, admitted);
|
|
183
|
+
this.charged.set(scope.kind, (this.charged.get(scope.kind) ?? 0) + admitted);
|
|
184
|
+
this.hardChargedBytes += admitted;
|
|
185
|
+
this.changed();
|
|
186
|
+
let state: "reserved" | "committed" | "released" = "reserved";
|
|
187
|
+
return {
|
|
188
|
+
commitRetained: () => {
|
|
189
|
+
if (state !== "reserved") return;
|
|
190
|
+
state = "committed";
|
|
191
|
+
if (!scope.callId) return;
|
|
192
|
+
const call = this.calls.get(scope.callId) ?? { logicalBytes: 0, physicalBytes: 0 };
|
|
193
|
+
this.charged.set(scope.kind, Math.max(0, (this.charged.get(scope.kind) ?? 0) - admitted));
|
|
194
|
+
call.logicalBytes = admitted;
|
|
195
|
+
call.physicalBytes += admitted;
|
|
196
|
+
this.calls.set(scope.callId, call);
|
|
197
|
+
this.changed();
|
|
198
|
+
},
|
|
199
|
+
release: () => {
|
|
200
|
+
if (state !== "reserved") return;
|
|
201
|
+
state = "released";
|
|
202
|
+
this.charged.set(scope.kind, Math.max(0, (this.charged.get(scope.kind) ?? 0) - admitted));
|
|
203
|
+
this.hardChargedBytes = Math.max(0, this.hardChargedBytes - admitted);
|
|
204
|
+
this.changed();
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
chargeRetained(delta: number, scope: { kind: TranslatorBufferKind; callId?: string }): void {
|
|
210
|
+
const admitted = normalizeBytes(delta);
|
|
211
|
+
if (scope.callId) {
|
|
212
|
+
const call = this.calls.get(scope.callId) ?? { logicalBytes: 0, physicalBytes: 0 };
|
|
213
|
+
if (call.logicalBytes + admitted > this.maxCallArgumentBytes) {
|
|
214
|
+
this.reject("tool_args", this.maxCallArgumentBytes);
|
|
215
|
+
}
|
|
216
|
+
this.admit(scope.kind, admitted);
|
|
217
|
+
call.logicalBytes += admitted;
|
|
218
|
+
call.physicalBytes += admitted;
|
|
219
|
+
this.calls.set(scope.callId, call);
|
|
220
|
+
} else {
|
|
221
|
+
this.admit(scope.kind, admitted);
|
|
222
|
+
this.charged.set(scope.kind, (this.charged.get(scope.kind) ?? 0) + admitted);
|
|
223
|
+
}
|
|
224
|
+
this.hardChargedBytes += admitted;
|
|
225
|
+
this.changed();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
releaseRetained(bytes: number, scope: { kind: TranslatorBufferKind; callId?: string }): void {
|
|
229
|
+
const released = normalizeBytes(bytes);
|
|
230
|
+
if (scope.callId) {
|
|
231
|
+
const call = this.calls.get(scope.callId);
|
|
232
|
+
if (call) {
|
|
233
|
+
const actual = Math.min(released, call.physicalBytes);
|
|
234
|
+
call.physicalBytes -= actual;
|
|
235
|
+
this.hardChargedBytes = Math.max(0, this.hardChargedBytes - actual);
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
const current = this.charged.get(scope.kind) ?? 0;
|
|
239
|
+
const actual = Math.min(released, current);
|
|
240
|
+
this.charged.set(scope.kind, current - actual);
|
|
241
|
+
this.hardChargedBytes = Math.max(0, this.hardChargedBytes - actual);
|
|
242
|
+
}
|
|
243
|
+
this.changed();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private observe(bytes: number): () => void {
|
|
247
|
+
const observed = normalizeBytes(bytes);
|
|
248
|
+
this.observedBytes += observed;
|
|
249
|
+
this.changed();
|
|
250
|
+
let released = false;
|
|
251
|
+
return () => {
|
|
252
|
+
if (released) return;
|
|
253
|
+
released = true;
|
|
254
|
+
this.observedBytes = Math.max(0, this.observedBytes - observed);
|
|
255
|
+
this.changed();
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
observeAcceptedRequestCopy(bytes: number): () => void {
|
|
260
|
+
return this.observe(bytes);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
observeExternallyCapped(_kind: ExternallyCappedKind, bytes: number): () => void {
|
|
264
|
+
return this.observe(bytes);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
snapshot(): TranslatorBudgetSnapshot {
|
|
268
|
+
return {
|
|
269
|
+
currentBytes: this.observedCurrentBytes(),
|
|
270
|
+
highWaterBytes: this.highWaterBytes,
|
|
271
|
+
activeCalls: this.calls.size,
|
|
272
|
+
overflows: this.overflowCount,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
dispose(): void {
|
|
277
|
+
if (this.disposed) return;
|
|
278
|
+
this.disposed = true;
|
|
279
|
+
aggregateActiveCalls = Math.max(0, aggregateActiveCalls - this.calls.size);
|
|
280
|
+
aggregateCurrentBytes = Math.max(0, aggregateCurrentBytes - this.currentBytes);
|
|
281
|
+
this.calls.clear();
|
|
282
|
+
this.charged.clear();
|
|
283
|
+
this.hardChargedBytes = 0;
|
|
284
|
+
this.observedBytes = 0;
|
|
285
|
+
this.currentBytes = 0;
|
|
286
|
+
liveBudgets.delete(this);
|
|
287
|
+
observeAggregate();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function createTranslatorBudget(options: TranslatorBudgetOptions = {}): TranslatorBudget {
|
|
292
|
+
const budget = new Budget(
|
|
293
|
+
options.maxCallArgumentBytes ?? TRANSLATOR_MAX_CALL_ARGUMENT_BYTES,
|
|
294
|
+
options.maxTurnBytes ?? TRANSLATOR_MAX_TURN_BYTES,
|
|
295
|
+
);
|
|
296
|
+
liveBudgets.add(budget);
|
|
297
|
+
observeAggregate();
|
|
298
|
+
return budget;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function translatorObservedBufferSnapshot(): {
|
|
302
|
+
currentBytes: number;
|
|
303
|
+
highWaterBytes: number;
|
|
304
|
+
active: number;
|
|
305
|
+
} {
|
|
306
|
+
return {
|
|
307
|
+
currentBytes: aggregateCurrentBytes,
|
|
308
|
+
highWaterBytes: aggregateHighWaterBytes,
|
|
309
|
+
active: aggregateActiveCalls,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Internal diagnostics used by focused tests; never registered with app-owned memory. */
|
|
314
|
+
export function translatorObservedOverflowCount(): number {
|
|
315
|
+
return aggregateOverflows;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Clears process-wide translator diagnostics and disposes leaked test budgets. */
|
|
319
|
+
export function resetTranslatorAggregateForTests(): void {
|
|
320
|
+
for (const budget of liveBudgets) budget.dispose();
|
|
321
|
+
aggregateCurrentBytes = 0;
|
|
322
|
+
aggregateActiveCalls = 0;
|
|
323
|
+
aggregateHighWaterBytes = 0;
|
|
324
|
+
aggregateOverflows = 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function finalizeTranslatorBudgetResponse(response: Response, budget: TranslatorBudget): Response {
|
|
328
|
+
if (!response.body) {
|
|
329
|
+
budget.dispose();
|
|
330
|
+
return response;
|
|
331
|
+
}
|
|
332
|
+
const reader = response.body.getReader();
|
|
333
|
+
let finalized = false;
|
|
334
|
+
const finalize = () => {
|
|
335
|
+
if (finalized) return;
|
|
336
|
+
finalized = true;
|
|
337
|
+
budget.dispose();
|
|
338
|
+
};
|
|
339
|
+
return new Response(new ReadableStream<Uint8Array>({
|
|
340
|
+
async pull(controller) {
|
|
341
|
+
try {
|
|
342
|
+
const result = await reader.read();
|
|
343
|
+
if (result.done) {
|
|
344
|
+
finalize();
|
|
345
|
+
controller.close();
|
|
346
|
+
} else controller.enqueue(result.value);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
finalize();
|
|
349
|
+
controller.error(error);
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
async cancel(reason) {
|
|
353
|
+
try { await reader.cancel(reason); } finally { finalize(); }
|
|
354
|
+
},
|
|
355
|
+
}), { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
356
|
+
}
|
|
@@ -19,11 +19,9 @@
|
|
|
19
19
|
* hardenSecretPath(path, { required: false }) — non-fatal read-path mode.
|
|
20
20
|
* Never throws. Returns { ok, diagnostics? }.
|
|
21
21
|
* hardenSecretPath(path, { required: true }) — write-path mode.
|
|
22
|
-
* Throws a sanitized error (no raw path) on Windows ACL failure
|
|
23
|
-
* genuine icacls
|
|
24
|
-
*
|
|
25
|
-
* stuck behind ETIMEDOUT). Real EPERM/EACCES/exit-code failures still throw:
|
|
26
|
-
* availability never silently overrides confidentiality for those.
|
|
22
|
+
* Throws a sanitized error (no raw path) on every Windows ACL failure,
|
|
23
|
+
* including genuine icacls timeouts. Required secret publication therefore
|
|
24
|
+
* fails closed; only required:false read-path probes may soft-fail.
|
|
27
25
|
* hardenSecretPathAsync / hardenSecretDirAsync — same policy, async icacls
|
|
28
26
|
* runner so the event loop is not held for the child lifetime (#612).
|
|
29
27
|
* HardenOptions.timeoutMemoKey — optional destination-path key for the
|
|
@@ -170,10 +168,25 @@ export function resetHardenedStateForTests(): void {
|
|
|
170
168
|
timedOutPaths.clear();
|
|
171
169
|
}
|
|
172
170
|
|
|
171
|
+
/** Forget a successful harden only after this exact ephemeral path is gone. */
|
|
172
|
+
export function forgetHardenedSecretPath(targetPath: string): void {
|
|
173
|
+
hardenedPaths.delete(targetPath);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Test seam for proving ephemeral success memos do not grow across replacements. */
|
|
177
|
+
export function hardenedSecretPathCountForTests(): number {
|
|
178
|
+
return hardenedPaths.size;
|
|
179
|
+
}
|
|
180
|
+
|
|
173
181
|
function effectivePlatform(): string {
|
|
174
182
|
return platformOverride ?? platform;
|
|
175
183
|
}
|
|
176
184
|
|
|
185
|
+
/** Test-aware platform predicate for callers that must avoid even a no-op ACL call. */
|
|
186
|
+
export function windowsSecretAclApplies(): boolean {
|
|
187
|
+
return (platformOverride ?? process.platform) === "win32";
|
|
188
|
+
}
|
|
189
|
+
|
|
177
190
|
/** Error carrying an honest code: ETIMEDOUT only for real timeouts, EICACLS otherwise. */
|
|
178
191
|
function icaclsError(step: string, result: IcaclsResult): NodeJS.ErrnoException {
|
|
179
192
|
const err = new Error(
|
|
@@ -363,14 +376,18 @@ async function describeAclStateAfterTimeoutAsync(targetPath: string, deadline: n
|
|
|
363
376
|
function timeoutMemoKey(targetPath: string, opts: HardenOptions): string {
|
|
364
377
|
// Destination-path memo only (issue #612). Never a parent directory — directory ACLs
|
|
365
378
|
// are not authoritative for newly created temps.
|
|
366
|
-
|
|
379
|
+
//
|
|
380
|
+
// Namespace by required-ness (#766): a soft `required:false` timeout during loadConfig
|
|
381
|
+
// must not poison a later `required:true` management-token harden of the same path.
|
|
382
|
+
const base = opts.timeoutMemoKey ?? targetPath;
|
|
383
|
+
return `${opts.required ? "required" : "optional"}:${base}`;
|
|
367
384
|
}
|
|
368
385
|
|
|
369
386
|
/**
|
|
370
387
|
* Shared harden flow for files and directories: one total budget (env-configurable)
|
|
371
388
|
* covering the initial attempt, ONE timeout retry, and the diagnostic verification.
|
|
372
|
-
*
|
|
373
|
-
*
|
|
389
|
+
* Required paths fail closed for every hardening failure, including genuine
|
|
390
|
+
* timeouts. Optional read paths soft-fail with an honest diagnostic.
|
|
374
391
|
*/
|
|
375
392
|
function hardenEntry(
|
|
376
393
|
targetPath: string,
|
|
@@ -383,7 +400,9 @@ function hardenEntry(
|
|
|
383
400
|
if (cache.has(targetPath)) return { ok: true };
|
|
384
401
|
const memoKey = timeoutMemoKey(targetPath, opts);
|
|
385
402
|
if (timedOutPaths.has(memoKey)) {
|
|
386
|
-
|
|
403
|
+
const diagnostics = "ACL hardening skipped — previous attempt timed out";
|
|
404
|
+
if (opts.required) throw new Error(diagnostics);
|
|
405
|
+
return { ok: false, diagnostics };
|
|
387
406
|
}
|
|
388
407
|
|
|
389
408
|
const deadline = nowFn() + resolveHardenDeadlineMs();
|
|
@@ -405,8 +424,7 @@ function hardenEntry(
|
|
|
405
424
|
timedOutPaths.add(memoKey);
|
|
406
425
|
const state = describeAclStateAfterTimeout(targetPath, deadline);
|
|
407
426
|
const annotated = `${diagnostics}; ${state}`;
|
|
408
|
-
|
|
409
|
-
// chmod is still applied by the caller.
|
|
427
|
+
if (opts.required) throw new Error(annotated);
|
|
410
428
|
console.warn(`[opencodex] ${annotated} — continuing without NTFS ACL harden`);
|
|
411
429
|
return { ok: false, diagnostics: annotated };
|
|
412
430
|
}
|
|
@@ -426,7 +444,9 @@ async function hardenEntryAsync(
|
|
|
426
444
|
if (cache.has(targetPath)) return { ok: true };
|
|
427
445
|
const memoKey = timeoutMemoKey(targetPath, opts);
|
|
428
446
|
if (timedOutPaths.has(memoKey)) {
|
|
429
|
-
|
|
447
|
+
const diagnostics = "ACL hardening skipped — previous attempt timed out";
|
|
448
|
+
if (opts.required) throw new Error(diagnostics);
|
|
449
|
+
return { ok: false, diagnostics };
|
|
430
450
|
}
|
|
431
451
|
|
|
432
452
|
const deadline = nowFn() + resolveHardenDeadlineMs();
|
|
@@ -448,6 +468,7 @@ async function hardenEntryAsync(
|
|
|
448
468
|
timedOutPaths.add(memoKey);
|
|
449
469
|
const state = await describeAclStateAfterTimeoutAsync(targetPath, deadline);
|
|
450
470
|
const annotated = `${diagnostics}; ${state}`;
|
|
471
|
+
if (opts.required) throw new Error(annotated);
|
|
451
472
|
console.warn(`[opencodex] ${annotated} — continuing without NTFS ACL harden`);
|
|
452
473
|
return { ok: false, diagnostics: annotated };
|
|
453
474
|
}
|
package/src/lib/winsw.ts
CHANGED
|
@@ -87,12 +87,19 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces
|
|
|
87
87
|
})();
|
|
88
88
|
// Services never bake `--port 0` (parsePortOption rejects it); treat as default.
|
|
89
89
|
const safeListenPort = listenPort > 0 && listenPort <= 65535 ? listenPort : 10100;
|
|
90
|
+
// SCM services do not inherit the interactive user environment (#764). Bake:
|
|
91
|
+
// - OPENCODEX_HOME so file-backed admin auth (`admin-api-token`) resolves
|
|
92
|
+
// - OPENCODEX_ACL_TIMEOUT_MS when set (not a secret)
|
|
93
|
+
// Never embed OPENCODEX_ADMIN_AUTH_TOKEN or OPENCODEX_API_AUTH_TOKEN values in XML —
|
|
94
|
+
// those stay file-pointer / generated-file only (uninstall retains the XML).
|
|
95
|
+
const aclTimeout = env.OPENCODEX_ACL_TIMEOUT_MS?.trim();
|
|
90
96
|
const envLines = [
|
|
91
97
|
` <env name="OCX_SERVICE" value="1"/>`,
|
|
92
98
|
` <env name="OCX_API_TOKEN_FILE" value="${xmlEscape(serviceApiTokenFilePath())}"/>`,
|
|
93
99
|
` <env name="PATH" value="${xmlEscape(env.PATH ?? "")}"/>`,
|
|
94
100
|
env.CODEX_HOME?.trim() ? ` <env name="CODEX_HOME" value="${xmlEscape(currentCodexHomeAbsolute())}"/>` : null,
|
|
95
|
-
|
|
101
|
+
` <env name="OPENCODEX_HOME" value="${xmlEscape(getConfigDir())}"/>`,
|
|
102
|
+
aclTimeout ? ` <env name="OPENCODEX_ACL_TIMEOUT_MS" value="${xmlEscape(aclTimeout)}"/>` : null,
|
|
96
103
|
].filter((line): line is string => Boolean(line));
|
|
97
104
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
98
105
|
<service>
|
|
@@ -165,6 +172,12 @@ function runWinsw(args: string[]): string {
|
|
|
165
172
|
|
|
166
173
|
/** `install /p` prompts for the service-account password on the console — stdin must be inherited. */
|
|
167
174
|
function runWinswInteractive(args: string[]): void {
|
|
175
|
+
if (!process.stdin.isTTY) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
"WinSW install requires an interactive console to prompt for the service account password. "
|
|
178
|
+
+ "Run `ocx service install --native` from an elevated Command Prompt or PowerShell window, not a hidden or piped session.",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
168
181
|
execFileSync(winswExePath(), args, { stdio: "inherit" });
|
|
169
182
|
}
|
|
170
183
|
|