@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.
Files changed (175) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. 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 — EXCEPT a
23
- * genuine icacls timeout, which soft-fails (warn + ok:false) so a hung/slow
24
- * icacls cannot block OAuth logins or token refresh (field report: Kimi auth
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
- return opts.timeoutMemoKey ?? targetPath;
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
- * Real EPERM/EACCES/EICACLS failures stay fail-closed on required paths; only
373
- * genuine timeouts soft-fail, with an honest state-annotated diagnostic.
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
- return { ok: false, diagnostics: "ACL hardening skipped — previous attempt timed out" };
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
- // Timeout-only soft-fail: a hung icacls must not block OAuth/token writes.
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
- return { ok: false, diagnostics: "ACL hardening skipped — previous attempt timed out" };
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
- env.OPENCODEX_HOME?.trim() ? ` <env name="OPENCODEX_HOME" value="${xmlEscape(getConfigDir())}"/>` : null,
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