@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
|
@@ -14,16 +14,20 @@
|
|
|
14
14
|
* it is not a standalone leak discriminator. `responseState` attributes growth
|
|
15
15
|
* further: it is the proxy's previous_response_id continuation store, so a
|
|
16
16
|
* growing responseState.totalBytes under rising observed memory points at
|
|
17
|
-
* conversation retention rather than the runtime allocator.
|
|
17
|
+
* conversation retention rather than the runtime allocator. Spill counts,
|
|
18
|
+
* payload-byte totals, tombstones, and failure counters remain finite scalars;
|
|
19
|
+
* response ids, filenames, digests, paths, and payload content never leave the owner.
|
|
18
20
|
*
|
|
19
21
|
* `activeTurnCount` / `isDraining` are scalar lifecycle counters for the
|
|
20
22
|
* dashboard drain-and-restart confirm UX — never request bodies or IDs.
|
|
21
23
|
*/
|
|
22
|
-
import {
|
|
24
|
+
import { selectEagerPath } from "../../lib/bun-stream-caps";
|
|
23
25
|
import { getActiveTurnCount, isDraining } from "../lifecycle";
|
|
24
26
|
import { getActiveMemoryWatchdog, observedMemoryCounter } from "../memory-watchdog";
|
|
25
27
|
import { responseStateMetrics } from "../../responses/state";
|
|
28
|
+
import { appOwnedBytesSnapshot } from "../../lib/app-owned-memory";
|
|
26
29
|
import { jsonResponse } from "../auth-cors";
|
|
30
|
+
import { getInspectionCounters } from "../relay";
|
|
27
31
|
import type { ManagementContext } from "./context";
|
|
28
32
|
import { acceptSystemRestart } from "./system-restart";
|
|
29
33
|
|
|
@@ -64,6 +68,12 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
64
68
|
})()
|
|
65
69
|
: null;
|
|
66
70
|
const streamMode = config.streamMode ?? "auto";
|
|
71
|
+
/**
|
|
72
|
+
* No request-specific rewrite context exists on this route, so report the
|
|
73
|
+
* effective no-client-rewrite baseline. Individual rewrite requests still
|
|
74
|
+
* stay on tee even when this baseline says eager.
|
|
75
|
+
*/
|
|
76
|
+
const eagerRelay = selectEagerPath(process.platform, false, streamMode);
|
|
67
77
|
return jsonResponse({
|
|
68
78
|
pid: process.pid,
|
|
69
79
|
bunVersion: Bun.version,
|
|
@@ -79,8 +89,10 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
79
89
|
observedMetric: observed.observedMetric,
|
|
80
90
|
jscHeap,
|
|
81
91
|
responseState: responseStateMetrics(),
|
|
92
|
+
appOwnedBytes: appOwnedBytesSnapshot(),
|
|
93
|
+
inspectionCounters: getInspectionCounters(),
|
|
82
94
|
streamMode,
|
|
83
|
-
eagerRelay
|
|
95
|
+
eagerRelay,
|
|
84
96
|
watchdog,
|
|
85
97
|
activeTurnCount: getActiveTurnCount(),
|
|
86
98
|
isDraining: isDraining(),
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../../lib/app-owned-memory";
|
|
2
|
+
import type { UsageSummary } from "../../usage/summary";
|
|
3
|
+
|
|
4
|
+
export type CachedUsageSummary = UsageSummary & {
|
|
5
|
+
historyTruncated: boolean;
|
|
6
|
+
truncatedPrefixBytes: number;
|
|
7
|
+
entriesTruncated: boolean;
|
|
8
|
+
entriesDropped: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
interface UsageSummaryCacheEntry {
|
|
12
|
+
revisionKey: string;
|
|
13
|
+
expiresAt: number;
|
|
14
|
+
summary: CachedUsageSummary;
|
|
15
|
+
revisionReadAt: number;
|
|
16
|
+
sizeBytes: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const usageSummaryCache = new Map<string, UsageSummaryCacheEntry>();
|
|
20
|
+
let usageSummaryCacheBytes = 0;
|
|
21
|
+
let oldestUsageSummaryKey: string | undefined;
|
|
22
|
+
let oldestUsageSummaryAt: number | null = null;
|
|
23
|
+
|
|
24
|
+
function recomputeOldestUsageSummary(): void {
|
|
25
|
+
oldestUsageSummaryKey = undefined;
|
|
26
|
+
oldestUsageSummaryAt = null;
|
|
27
|
+
for (const [key, entry] of usageSummaryCache) {
|
|
28
|
+
const readAt = entry.revisionReadAt;
|
|
29
|
+
if (oldestUsageSummaryAt !== null && readAt >= oldestUsageSummaryAt) continue;
|
|
30
|
+
oldestUsageSummaryKey = key;
|
|
31
|
+
oldestUsageSummaryAt = readAt;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function deleteUsageSummaryCacheEntry(key: string): number {
|
|
36
|
+
const entry = usageSummaryCache.get(key);
|
|
37
|
+
if (!entry) return 0;
|
|
38
|
+
usageSummaryCache.delete(key);
|
|
39
|
+
usageSummaryCacheBytes = Math.max(0, usageSummaryCacheBytes - entry.sizeBytes);
|
|
40
|
+
if (oldestUsageSummaryKey === key) recomputeOldestUsageSummary();
|
|
41
|
+
return entry.sizeBytes;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getUsageSummaryCacheEntry(key: string): UsageSummaryCacheEntry | undefined {
|
|
45
|
+
return usageSummaryCache.get(key);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function discardUsageSummaryCacheEntry(key: string): number {
|
|
49
|
+
return deleteUsageSummaryCacheEntry(key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function setUsageSummaryCacheEntry(
|
|
53
|
+
key: string,
|
|
54
|
+
entry: Omit<UsageSummaryCacheEntry, "sizeBytes">,
|
|
55
|
+
): void {
|
|
56
|
+
deleteUsageSummaryCacheEntry(key);
|
|
57
|
+
const sizeBytes = Buffer.byteLength(JSON.stringify({ key, ...entry }), "utf8");
|
|
58
|
+
usageSummaryCache.set(key, { ...entry, sizeBytes });
|
|
59
|
+
usageSummaryCacheBytes += sizeBytes;
|
|
60
|
+
if (oldestUsageSummaryAt === null || entry.revisionReadAt < oldestUsageSummaryAt) {
|
|
61
|
+
oldestUsageSummaryKey = key;
|
|
62
|
+
oldestUsageSummaryAt = entry.revisionReadAt;
|
|
63
|
+
}
|
|
64
|
+
enforceAppOwnedMemoryBudget();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function usageSummaryRetainedStoreSnapshot(): RetainedStoreSnapshot {
|
|
68
|
+
return {
|
|
69
|
+
count: usageSummaryCache.size,
|
|
70
|
+
bytes: usageSummaryCacheBytes,
|
|
71
|
+
evictableBytes: usageSummaryCacheBytes,
|
|
72
|
+
pinnedBytes: 0,
|
|
73
|
+
oldestAt: oldestUsageSummaryAt,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function evictOldestUsageSummaryForBudget(): number {
|
|
78
|
+
return oldestUsageSummaryKey === undefined ? 0 : deleteUsageSummaryCacheEntry(oldestUsageSummaryKey);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function resetUsageSummaryCacheForTests(): void {
|
|
82
|
+
usageSummaryCache.clear();
|
|
83
|
+
usageSummaryCacheBytes = 0;
|
|
84
|
+
oldestUsageSummaryKey = undefined;
|
|
85
|
+
oldestUsageSummaryAt = null;
|
|
86
|
+
}
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
submitManualLoginCode,
|
|
22
22
|
upsertOAuthProvider,
|
|
23
23
|
} from "../oauth";
|
|
24
|
-
import { removeCredential } from "../oauth/store";
|
|
24
|
+
import { OAuthMutationBusyError, removeCredential } from "../oauth/store";
|
|
25
25
|
import { providerDestinationResolvedError } from "../lib/destination-policy";
|
|
26
26
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../oauth/key-providers";
|
|
27
27
|
import { deriveProviderPresets } from "../providers/derive";
|
|
@@ -69,6 +69,8 @@ import { handleSidebarRoutes } from "./management/sidebar-routes";
|
|
|
69
69
|
import type { ManagementContext } from "./management/context";
|
|
70
70
|
export type { ManagementApiDeps } from "./management/context";
|
|
71
71
|
import { fetchAllModels } from "./management/shared";
|
|
72
|
+
import { CatalogGatherBusyError } from "../codex/catalog/provider-fetch";
|
|
73
|
+
import { managementBodyTooLargeResponse } from "./management/body";
|
|
72
74
|
|
|
73
75
|
// installed npm version instead of a stale hardcode.
|
|
74
76
|
export const VERSION = (() => {
|
|
@@ -123,8 +125,9 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
123
125
|
} catch { /* best-effort */ }
|
|
124
126
|
}
|
|
125
127
|
const ctx: ManagementContext = { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort };
|
|
126
|
-
|
|
127
|
-
|
|
128
|
+
let routed: Response | null;
|
|
129
|
+
try {
|
|
130
|
+
routed = (await handleConfigRoutes(ctx))
|
|
128
131
|
?? (await handleLogsUsageRoutes(ctx))
|
|
129
132
|
?? (await handleProviderRoutes(ctx))
|
|
130
133
|
?? (await handleModelRoutes(ctx))
|
|
@@ -132,7 +135,22 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
132
135
|
?? (await handleOauthAccountRoutes(ctx))
|
|
133
136
|
?? (await handleComboRoutes(ctx))
|
|
134
137
|
?? (await handleSystemRoutes(ctx))
|
|
135
|
-
|
|
138
|
+
?? (await handleSidebarRoutes(ctx));
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const tooLarge = managementBodyTooLargeResponse(error, req, config);
|
|
141
|
+
if (tooLarge) return tooLarge;
|
|
142
|
+
if (error instanceof OAuthMutationBusyError) {
|
|
143
|
+
return new Response(JSON.stringify({ error: { type: "server_error", code: "oauth_mutation_busy", message: error.message } }), {
|
|
144
|
+
status: 503,
|
|
145
|
+
headers: { "content-type": "application/json", "Retry-After": "1" },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (!(error instanceof CatalogGatherBusyError)) throw error;
|
|
149
|
+
return new Response(JSON.stringify({ error: { type: "server_error", code: "catalog_busy", message: error.message } }), {
|
|
150
|
+
status: 503,
|
|
151
|
+
headers: { "content-type": "application/json", "Retry-After": "1" },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
136
154
|
if (routed) return routed;
|
|
137
155
|
|
|
138
156
|
if (url.pathname === "/api/stop" && req.method === "POST") {
|
|
@@ -167,7 +185,23 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
167
185
|
|
|
168
186
|
if (url.pathname.startsWith("/api/codex-auth/")) {
|
|
169
187
|
const { handleCodexAuthAPI } = await import("../codex/auth-api");
|
|
170
|
-
|
|
188
|
+
const { ConfigMutationLockError } = await import("../config");
|
|
189
|
+
const { CodexCredentialRefreshLockTimeoutError } = await import("../codex/account-store");
|
|
190
|
+
try {
|
|
191
|
+
return await handleCodexAuthAPI(req, url, config);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// Credential writers remap ConfigMutationLockError to CodexCredentialRefreshLockTimeoutError;
|
|
194
|
+
// treat both as the same retryable busy response.
|
|
195
|
+
if (error instanceof ConfigMutationLockError || error instanceof CodexCredentialRefreshLockTimeoutError) {
|
|
196
|
+
return jsonResponse(
|
|
197
|
+
{ error: "Configuration is busy; retry shortly", code: "CONFIG_MUTATION_LOCK_UNAVAILABLE" },
|
|
198
|
+
503,
|
|
199
|
+
req,
|
|
200
|
+
config,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
171
205
|
}
|
|
172
206
|
|
|
173
207
|
return null;
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "node:fs";
|
|
14
14
|
import { dirname, join } from "node:path";
|
|
15
15
|
import { adminApiTokenFilePath } from "../lib/admin-secrets";
|
|
16
|
-
import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
|
|
16
|
+
import { forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
|
|
17
17
|
import type { OcxConfig } from "../types";
|
|
18
18
|
import {
|
|
19
19
|
isAllowedManagementOrigin,
|
|
@@ -55,8 +55,19 @@ function assertSafeDirectory(path: string): void {
|
|
|
55
55
|
const stat = lstatSync(path);
|
|
56
56
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("management token directory is not a regular directory");
|
|
57
57
|
chmodSync(path, 0o700);
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
let hardened: { ok: boolean };
|
|
59
|
+
try {
|
|
60
|
+
hardened = hardenSecretDir(path, { required: true });
|
|
61
|
+
} catch {
|
|
62
|
+
// required:true hardening now fails closed on genuine ACL timeouts too;
|
|
63
|
+
// keep the actionable guidance in the surfaced reason.
|
|
64
|
+
hardened = { ok: false };
|
|
65
|
+
}
|
|
66
|
+
if (!hardened.ok) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
"management token directory ACL hardening did not complete; set OPENCODEX_ADMIN_AUTH_TOKEN to use an environment token instead of a file-backed token",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
function readExistingToken(path: string): string {
|
|
@@ -65,15 +76,33 @@ function readExistingToken(path: string): string {
|
|
|
65
76
|
throw new Error("management token path is not a regular secret file");
|
|
66
77
|
}
|
|
67
78
|
chmodSync(path, 0o600);
|
|
68
|
-
|
|
69
|
-
|
|
79
|
+
let hardened: { ok: boolean };
|
|
80
|
+
try {
|
|
81
|
+
hardened = hardenSecretPath(path, { required: true });
|
|
82
|
+
} catch {
|
|
83
|
+
hardened = { ok: false };
|
|
84
|
+
}
|
|
85
|
+
if (!hardened.ok) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
"management token file ACL hardening did not complete; set OPENCODEX_ADMIN_AUTH_TOKEN to use an environment token instead of a file-backed token",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
70
90
|
const token = readFileSync(path, "utf8").trim();
|
|
71
91
|
if (!/^ocx_admin_[A-Za-z0-9_-]{43}$/.test(token)) throw new Error("management token file is invalid");
|
|
72
92
|
return token;
|
|
73
93
|
}
|
|
74
94
|
|
|
75
|
-
function
|
|
76
|
-
|
|
95
|
+
export function removeManagementTokenPathBestEffort(
|
|
96
|
+
path: string,
|
|
97
|
+
remove: (path: string) => void = unlinkSync,
|
|
98
|
+
): void {
|
|
99
|
+
try {
|
|
100
|
+
remove(path);
|
|
101
|
+
forgetHardenedSecretPath(path);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") forgetHardenedSecretPath(path);
|
|
104
|
+
/* other failures retain fail-closed state for the caller */
|
|
105
|
+
}
|
|
77
106
|
}
|
|
78
107
|
|
|
79
108
|
function createTokenFile(path: string): string {
|
|
@@ -89,8 +118,17 @@ function createTokenFile(path: string): string {
|
|
|
89
118
|
closeSync(fd);
|
|
90
119
|
fd = null;
|
|
91
120
|
chmodSync(temporary, 0o600);
|
|
92
|
-
|
|
93
|
-
|
|
121
|
+
let temporaryHardened: { ok: boolean };
|
|
122
|
+
try {
|
|
123
|
+
temporaryHardened = hardenSecretPath(temporary, { required: true });
|
|
124
|
+
} catch {
|
|
125
|
+
temporaryHardened = { ok: false };
|
|
126
|
+
}
|
|
127
|
+
if (!temporaryHardened.ok) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"management token temporary ACL hardening did not complete; set OPENCODEX_ADMIN_AUTH_TOKEN to use an environment token instead of a file-backed token",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
94
132
|
try {
|
|
95
133
|
linkSync(temporary, path);
|
|
96
134
|
linked = true;
|
|
@@ -98,17 +136,26 @@ function createTokenFile(path: string): string {
|
|
|
98
136
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") return readExistingToken(path);
|
|
99
137
|
throw error;
|
|
100
138
|
}
|
|
101
|
-
|
|
102
|
-
|
|
139
|
+
let finalHardened: { ok: boolean };
|
|
140
|
+
try {
|
|
141
|
+
finalHardened = hardenSecretPath(path, { required: true });
|
|
142
|
+
} catch {
|
|
143
|
+
finalHardened = { ok: false };
|
|
144
|
+
}
|
|
145
|
+
if (!finalHardened.ok) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
"management token file ACL hardening did not complete; set OPENCODEX_ADMIN_AUTH_TOKEN to use an environment token instead of a file-backed token",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
103
150
|
return token;
|
|
104
151
|
} catch (error) {
|
|
105
|
-
if (linked)
|
|
152
|
+
if (linked) removeManagementTokenPathBestEffort(path);
|
|
106
153
|
throw error;
|
|
107
154
|
} finally {
|
|
108
155
|
if (fd !== null) {
|
|
109
156
|
try { closeSync(fd); } catch { /* best effort */ }
|
|
110
157
|
}
|
|
111
|
-
|
|
158
|
+
removeManagementTokenPathBestEffort(temporary);
|
|
112
159
|
}
|
|
113
160
|
}
|
|
114
161
|
|
|
@@ -190,7 +237,11 @@ export function requireManagementAuth(
|
|
|
190
237
|
config?: OcxConfig,
|
|
191
238
|
): Response | null {
|
|
192
239
|
if (!state.available) {
|
|
193
|
-
return Response.json({
|
|
240
|
+
return Response.json({
|
|
241
|
+
error: "management API unavailable",
|
|
242
|
+
reason: state.reason,
|
|
243
|
+
hint: "Set OPENCODEX_ADMIN_AUTH_TOKEN to bypass file-backed admin token ACL hardening",
|
|
244
|
+
}, { status: 503 });
|
|
194
245
|
}
|
|
195
246
|
const actual = req.headers.get("x-opencodex-api-key")?.trim()
|
|
196
247
|
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* Reclaim a listen port after stop/update so restart can stay on the configured
|
|
3
3
|
* port instead of hopping to an ephemeral one (Windows CLOSE_WAIT / leftover ocx).
|
|
4
4
|
*
|
|
5
|
-
* Killing is never the default
|
|
6
|
-
* supplies a non-empty
|
|
5
|
+
* Killing is never the default. A process may be killed only when the caller
|
|
6
|
+
* sets `killOcxHolders` and either supplies a non-empty `onlyKillPids` allowlist
|
|
7
|
+
* (trusted teardown PIDs, including allowlisted holders that fail ocx revalidate)
|
|
8
|
+
* or enables `killAllOcxOnPort` for revalidated ocx listeners. Unknown foreign
|
|
9
|
+
* (non-ocx, non-allowlisted) processes are never killed.
|
|
7
10
|
*/
|
|
8
11
|
import { execFileSync } from "node:child_process";
|
|
9
12
|
import { verifyPidIdentity } from "../config";
|
|
@@ -19,13 +22,24 @@ export type ReclaimListenPortOptions = WaitForPortOptions & {
|
|
|
19
22
|
/**
|
|
20
23
|
* When true AND `onlyKillPids` is a non-empty allowlist, those PIDs may be
|
|
21
24
|
* killed after revalidation. Default false — never kill without an allowlist.
|
|
25
|
+
* When {@link killAllOcxOnPort} is also true, any ocx listener on this port
|
|
26
|
+
* may be killed even if it is not in `onlyKillPids`.
|
|
22
27
|
*/
|
|
23
28
|
killOcxHolders?: boolean;
|
|
24
29
|
/**
|
|
25
30
|
* Explicit PIDs the caller just stopped / hard-killed. An omitted or empty
|
|
26
|
-
* list means no process may be killed.
|
|
31
|
+
* list means no process may be killed — unless {@link killAllOcxOnPort} is set.
|
|
27
32
|
*/
|
|
28
33
|
onlyKillPids?: number[];
|
|
34
|
+
/**
|
|
35
|
+
* When true with `killOcxHolders`, every live ocx listener on this port may be
|
|
36
|
+
* killed (re-checked each scan). Used by post-update restart so a Windows
|
|
37
|
+
* service wrapper that respawns a *new* bun PID mid-reclaim cannot stay
|
|
38
|
+
* protected just because it was absent from the pre-wait allowlist snapshot.
|
|
39
|
+
* Never kills foreign (non-ocx) processes — only allowlisted teardown PIDs
|
|
40
|
+
* and revalidated ocx listeners.
|
|
41
|
+
*/
|
|
42
|
+
killAllOcxOnPort?: boolean;
|
|
29
43
|
/**
|
|
30
44
|
* On Windows, force-delete IPv4 TCP rows for this local port via SetTcpEntry.
|
|
31
45
|
* Default true on win32. Never kills foreign processes, never runs while a
|
|
@@ -157,9 +171,9 @@ export function listListenPids(port: number): number[] {
|
|
|
157
171
|
|
|
158
172
|
/**
|
|
159
173
|
* Wait until `port` can bind.
|
|
160
|
-
* Never kills a process unless `killOcxHolders === true` and
|
|
161
|
-
* non-empty allowlist
|
|
162
|
-
* immediately before each kill.
|
|
174
|
+
* Never kills a process unless `killOcxHolders === true` and either
|
|
175
|
+
* `onlyKillPids` is a non-empty allowlist or `killAllOcxOnPort` is set — then
|
|
176
|
+
* revalidates immediately before each kill.
|
|
163
177
|
* Never kills foreign processes. Never drops TCP rows while a live foreign or
|
|
164
178
|
* protected ocx listener owns the port, or when the listener scan failed.
|
|
165
179
|
*/
|
|
@@ -174,7 +188,9 @@ export async function reclaimListenPort(
|
|
|
174
188
|
const allowedKillPids = new Set(
|
|
175
189
|
(opts.onlyKillPids ?? []).filter(pid => Number.isSafeInteger(pid) && pid > 0),
|
|
176
190
|
);
|
|
177
|
-
const
|
|
191
|
+
const killAllOcx = opts.killAllOcxOnPort === true;
|
|
192
|
+
const mayKill = opts.killOcxHolders === true
|
|
193
|
+
&& (allowedKillPids.size > 0 || killAllOcx);
|
|
178
194
|
const dropTcpRows = opts.dropTcpRows ?? process.platform === "win32";
|
|
179
195
|
const listFn = opts.listListenPidsFn ?? scanListenPids;
|
|
180
196
|
const isAliveFn = opts.isAliveFn ?? isProcessAlive;
|
|
@@ -207,20 +223,44 @@ export async function reclaimListenPort(
|
|
|
207
223
|
|
|
208
224
|
for (const pid of scan.pids) {
|
|
209
225
|
if (pid === process.pid) continue;
|
|
210
|
-
if (!isAliveFn(pid))
|
|
226
|
+
if (!isAliveFn(pid)) {
|
|
227
|
+
// Clear "already tried" so a later respawn that reuses this PID slot
|
|
228
|
+
// is not skipped (Windows service :loop / npm rename respawns).
|
|
229
|
+
killed.delete(pid);
|
|
230
|
+
continue; // Windows may still list a dead owner briefly
|
|
231
|
+
}
|
|
211
232
|
const isOcx = verifyOcxFn(pid) === pid;
|
|
233
|
+
const allowlisted = allowedKillPids.has(pid);
|
|
234
|
+
// Pre-update PIDs can fail verify while still LISTENing (dead owner still
|
|
235
|
+
// listed, or cmdline probe raced). Allowlisted teardown PIDs may be killed;
|
|
236
|
+
// unknown foreign claimants must remain fail-closed.
|
|
212
237
|
if (!isOcx) {
|
|
238
|
+
if (mayKill && allowlisted) {
|
|
239
|
+
if (!killed.has(pid)) {
|
|
240
|
+
try {
|
|
241
|
+
killFn(pid);
|
|
242
|
+
killed.add(pid);
|
|
243
|
+
} catch {
|
|
244
|
+
// Kill failed: never SetTcpEntry while the process may still own the port.
|
|
245
|
+
protectedOcxListener = true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (!isAliveFn(pid)) killed.delete(pid);
|
|
249
|
+
else protectedOcxListener = true;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
213
252
|
foreignLive = true;
|
|
214
253
|
continue;
|
|
215
254
|
}
|
|
216
|
-
|
|
255
|
+
const mayKillThis = allowlisted || killAllOcx;
|
|
256
|
+
if (!mayKill || !mayKillThis) {
|
|
217
257
|
// Healthy / intentional ocx proxy — never steal its port.
|
|
218
258
|
protectedOcxListener = true;
|
|
219
259
|
continue;
|
|
220
260
|
}
|
|
221
261
|
if (!killed.has(pid)) {
|
|
222
262
|
// Revalidate immediately before termination.
|
|
223
|
-
if (isAliveFn(pid) && verifyOcxFn(pid) === pid &&
|
|
263
|
+
if (isAliveFn(pid) && verifyOcxFn(pid) === pid && mayKillThis) {
|
|
224
264
|
try {
|
|
225
265
|
killFn(pid);
|
|
226
266
|
killed.add(pid);
|
|
@@ -233,8 +273,14 @@ export async function reclaimListenPort(
|
|
|
233
273
|
protectedOcxListener = true;
|
|
234
274
|
}
|
|
235
275
|
}
|
|
236
|
-
//
|
|
237
|
-
|
|
276
|
+
// Respawning supervisors (Windows service :loop) mint a new PID after each
|
|
277
|
+
// kill — clear the per-PID "already tried" bit once the process is gone so a
|
|
278
|
+
// later child with a reused slot is not skipped, and keep reclaiming while live.
|
|
279
|
+
if (!isAliveFn(pid)) {
|
|
280
|
+
killed.delete(pid);
|
|
281
|
+
} else {
|
|
282
|
+
protectedOcxListener = true;
|
|
283
|
+
}
|
|
238
284
|
}
|
|
239
285
|
|
|
240
286
|
if (foreignLive || protectedOcxListener) {
|
package/src/server/ports.ts
CHANGED
|
@@ -16,6 +16,8 @@ export function isAddrInUse(err: unknown): boolean {
|
|
|
16
16
|
export async function isPortAvailable(port: number, hostname = "127.0.0.1"): Promise<boolean> {
|
|
17
17
|
return await new Promise(resolve => {
|
|
18
18
|
const server = createServer();
|
|
19
|
+
// Fail closed: EACCES / EADDRNOTAVAIL / EPERM / unknown listen errors mean the
|
|
20
|
+
// requested bind is not available. Only the listening event reports free.
|
|
19
21
|
server.once("error", () => resolve(false));
|
|
20
22
|
server.once("listening", () => {
|
|
21
23
|
server.close(() => resolve(true));
|
|
@@ -30,8 +30,29 @@ export interface LivenessIo {
|
|
|
30
30
|
readRuntimeFn?: (pid?: number) => { pid?: number; port: number; hostname?: string } | null;
|
|
31
31
|
configFn?: () => { port?: number; hostname?: string };
|
|
32
32
|
timeoutMs?: number;
|
|
33
|
+
/**
|
|
34
|
+
* How many times to retry a probe that failed with a transport error (timeout /
|
|
35
|
+
* connection refused). Definitive answers (non-OK HTTP, foreign /healthz body, pid
|
|
36
|
+
* mismatch) do not retry. Default 1 = no retry. Stop paths should pass 2–3 (#764).
|
|
37
|
+
*/
|
|
38
|
+
attempts?: number;
|
|
39
|
+
sleepFn?: (ms: number) => Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Absolute wall-clock deadline for discovery. When set, each probe attempt aborts
|
|
42
|
+
* once the remaining budget cannot cover another fetch — so multi-candidate
|
|
43
|
+
* `findLiveProxy` under `SERVICE_STOP_LIVENESS` cannot overrun the stop-path
|
|
44
|
+
* verification window (#764 / CodeRabbit).
|
|
45
|
+
*/
|
|
46
|
+
deadlineAt?: number;
|
|
47
|
+
nowFn?: () => number;
|
|
33
48
|
}
|
|
34
49
|
|
|
50
|
+
/** Default probe options for service stop / orphan cleanup — a just-bound proxy can miss a single 750ms probe. */
|
|
51
|
+
export const SERVICE_STOP_LIVENESS: Pick<LivenessIo, "timeoutMs" | "attempts"> = {
|
|
52
|
+
timeoutMs: 1500,
|
|
53
|
+
attempts: 3,
|
|
54
|
+
};
|
|
55
|
+
|
|
35
56
|
export interface LiveProxy {
|
|
36
57
|
pid: number | null;
|
|
37
58
|
port: number;
|
|
@@ -72,19 +93,37 @@ export async function proxyIdentityAt(
|
|
|
72
93
|
io: LivenessIo = {},
|
|
73
94
|
): Promise<{ pid: number | null } | null> {
|
|
74
95
|
const fetchFn = io.fetchFn ?? fetch;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
96
|
+
const sleepFn = io.sleepFn ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
|
|
97
|
+
const nowFn = io.nowFn ?? Date.now;
|
|
98
|
+
const baseTimeoutMs = io.timeoutMs ?? 750;
|
|
99
|
+
const requestedAttempts = Math.trunc(io.attempts ?? 1);
|
|
100
|
+
const attempts = Number.isNaN(requestedAttempts)
|
|
101
|
+
? 1
|
|
102
|
+
: Math.max(1, Math.min(requestedAttempts, 5));
|
|
103
|
+
|
|
104
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
105
|
+
const remainingMs = io.deadlineAt === undefined ? baseTimeoutMs : io.deadlineAt - nowFn();
|
|
106
|
+
if (remainingMs <= 0) return null;
|
|
107
|
+
const timeoutMs = Math.min(baseTimeoutMs, remainingMs);
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetchFn(`http://${probeHostname(opts.hostname)}:${port}/healthz`, {
|
|
110
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
111
|
+
});
|
|
112
|
+
if (!res.ok) return null;
|
|
113
|
+
const body = (await res.json().catch(() => null)) as HealthzIdentity | null;
|
|
114
|
+
if (!isOpencodexHealthz(body)) return null;
|
|
115
|
+
const pid = typeof body?.pid === "number" ? body.pid : null;
|
|
116
|
+
if (opts.expectedPid !== undefined && pid !== null && pid !== opts.expectedPid) return null;
|
|
117
|
+
return { pid };
|
|
118
|
+
} catch {
|
|
119
|
+
// Transport failure (timeout / refused) — retry while budget remains; a proxy that
|
|
120
|
+
// has only just begun listening can miss a single short probe (#764).
|
|
121
|
+
if (attempt >= attempts) return null;
|
|
122
|
+
if (io.deadlineAt !== undefined && io.deadlineAt - nowFn() <= 0) return null;
|
|
123
|
+
await sleepFn(100);
|
|
124
|
+
}
|
|
87
125
|
}
|
|
126
|
+
return null;
|
|
88
127
|
}
|
|
89
128
|
|
|
90
129
|
/**
|
|
@@ -109,6 +148,13 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
|
|
|
109
148
|
return verified === candidate ? verified : null;
|
|
110
149
|
};
|
|
111
150
|
|
|
151
|
+
const verifiedReportedPid = (reported: number | null): number | null => {
|
|
152
|
+
if (reported === null) return null;
|
|
153
|
+
if (!Number.isSafeInteger(reported) || reported <= 0) return null;
|
|
154
|
+
const verified = verifyPidFn(reported);
|
|
155
|
+
return verified === reported ? verified : null;
|
|
156
|
+
};
|
|
157
|
+
|
|
112
158
|
const pid = readPidFn();
|
|
113
159
|
let probedPort: number | null = null;
|
|
114
160
|
if (pid) {
|
|
@@ -136,7 +182,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
|
|
|
136
182
|
// (its process dead, the port reused by a pidless legacy proxy) — synthesizing it
|
|
137
183
|
// would hand destructive callers (stopProxy → kill fallback) a reusable pid.
|
|
138
184
|
if (identity) {
|
|
139
|
-
return { pid: identity.pid
|
|
185
|
+
return { pid: verifiedReportedPid(identity.pid), port: record.port, hostname: record.hostname, source: "runtime" };
|
|
140
186
|
}
|
|
141
187
|
}
|
|
142
188
|
|
|
@@ -145,7 +191,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
|
|
|
145
191
|
const identity = await proxyIdentityAt(port, { hostname: config.hostname }, io);
|
|
146
192
|
if (identity) {
|
|
147
193
|
return {
|
|
148
|
-
pid: identity.pid ?? killablePid(pid),
|
|
194
|
+
pid: verifiedReportedPid(identity.pid) ?? killablePid(pid),
|
|
149
195
|
port,
|
|
150
196
|
hostname: config.hostname,
|
|
151
197
|
source: "config",
|
|
@@ -24,11 +24,15 @@
|
|
|
24
24
|
* up to the drain window.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { buildFailedTailPayload } from "./relay";
|
|
28
|
+
|
|
27
29
|
export type EagerRelayHooks = {
|
|
28
30
|
/** Feed one upstream chunk through SSE inspection (createSseInspector.feed). */
|
|
29
31
|
inspectChunk: (chunk: Uint8Array) => void;
|
|
30
32
|
/** Flush inspection at upstream end (createSseInspector.finish). */
|
|
31
33
|
finishInspection: () => void;
|
|
34
|
+
/** Drop inspector-owned frame/item state during producer teardown. */
|
|
35
|
+
disposeInspection?: () => void;
|
|
32
36
|
/** True once inspection has reported a protocol terminal (inspector.reported). */
|
|
33
37
|
sawTerminal: () => boolean;
|
|
34
38
|
/** Record a synthetic terminal (caller decides incomplete vs failed-502). */
|
|
@@ -151,25 +155,37 @@ export function relaySseEagerBounded(
|
|
|
151
155
|
await paused();
|
|
152
156
|
}
|
|
153
157
|
}
|
|
154
|
-
} catch {
|
|
158
|
+
} catch (err) {
|
|
155
159
|
// Upstream read failure. Distinguish genuine mid-stream reset from
|
|
156
160
|
// abort-driven teardown (shutdown/cancel-expiry) — audit M3.
|
|
157
161
|
if (!hooks.sawTerminal() && !cancelled && !upstream.signal.aborted) {
|
|
158
|
-
|
|
159
|
-
|
|
162
|
+
// Serializing `err` can run user-defined accessors (Error.message
|
|
163
|
+
// getters, toString) that re-entrantly cancel the client or abort the
|
|
164
|
+
// upstream. Build the tail FIRST, then re-check eligibility before
|
|
165
|
+
// committing to the synthetic terminal (adversarial review blocker).
|
|
166
|
+
const tail = new TextEncoder().encode(
|
|
167
|
+
`\n\nevent: response.failed\ndata: ${buildFailedTailPayload(err)}\n\ndata: [DONE]\n\n`,
|
|
168
|
+
);
|
|
169
|
+
if (!hooks.sawTerminal() && !cancelled && !upstream.signal.aborted) {
|
|
170
|
+
syntheticKind = "failed";
|
|
171
|
+
queuedBytes += tail.byteLength;
|
|
172
|
+
try { controllerRef?.enqueue(tail); } catch { /* client already torn down */ }
|
|
173
|
+
try { controllerRef?.close(); } catch { /* client already torn down */ }
|
|
174
|
+
}
|
|
160
175
|
}
|
|
161
176
|
} finally {
|
|
162
177
|
if (syntheticKind) hooks.onSynthetic(syntheticKind);
|
|
163
178
|
if (cancelled && !hooks.sawTerminal()) {
|
|
164
179
|
hooks.onClientCancel();
|
|
165
180
|
}
|
|
166
|
-
if (cancelled || upstream.signal.aborted) {
|
|
181
|
+
if (cancelled || upstream.signal.aborted || syntheticKind === "failed") {
|
|
167
182
|
upstream.abort();
|
|
168
183
|
reader.cancel().catch(() => {});
|
|
169
184
|
}
|
|
170
185
|
if (!cancelled) {
|
|
171
186
|
try { controllerRef?.close(); } catch { /* already closed/errored */ }
|
|
172
187
|
}
|
|
188
|
+
try { hooks.disposeInspection?.(); } catch { /* inspection teardown must not block lifecycle cleanup */ }
|
|
173
189
|
fireDone();
|
|
174
190
|
}
|
|
175
191
|
};
|