@bitkyc08/opencodex 2.10.0 → 2.10.1
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/AGENTS_INSTALL.md +77 -0
- package/README.md +4 -10
- package/bin/ocx.mjs +71 -18
- package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
- package/gui/dist/assets/index-ChZQsmBY.js +70 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +8 -4
- package/src/adapters/anthropic.ts +208 -14
- package/src/adapters/base.ts +16 -5
- package/src/adapters/cursor/effort-map.ts +3 -2
- package/src/adapters/cursor/framing.ts +39 -0
- package/src/adapters/cursor/live-transport.ts +105 -95
- package/src/adapters/cursor/native-exec.ts +32 -6
- package/src/adapters/cursor/protobuf-request.ts +20 -15
- package/src/adapters/cursor/request-builder.ts +21 -7
- package/src/adapters/cursor/types.ts +7 -0
- package/src/adapters/google-antigravity-replay.ts +237 -21
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +50 -9
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +10 -2
- package/src/adapters/kiro-tools.ts +10 -1
- package/src/adapters/kiro.ts +37 -11
- package/src/adapters/openai-chat.ts +284 -83
- package/src/adapters/openai-responses.ts +182 -24
- package/src/bridge.ts +177 -7
- package/src/chat/outbound.ts +78 -23
- package/src/claude/agents-inject.ts +27 -5
- package/src/claude/inbound.ts +11 -1
- package/src/claude/model-info.ts +13 -10
- package/src/claude/outbound.ts +17 -0
- package/src/cli/account-api.ts +24 -0
- package/src/cli/account-auth.ts +31 -6
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +5 -0
- package/src/cli/claude.ts +2 -1
- package/src/cli/doctor.ts +93 -22
- package/src/cli/export-command.ts +26 -12
- package/src/cli/help.ts +8 -6
- package/src/cli/index.ts +56 -22
- package/src/cli/integrations.ts +84 -1
- package/src/cli/observe.ts +54 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +18 -1
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +6 -3
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +1 -1
- package/src/cli/v2.ts +36 -9
- package/src/clients/config-export.ts +687 -10
- package/src/codex/account-lifecycle.ts +30 -5
- package/src/codex/account-usability.ts +22 -2
- package/src/codex/admission.ts +255 -0
- package/src/codex/auth-api.ts +427 -140
- package/src/codex/auth-context.ts +155 -30
- package/src/codex/autostart-health.ts +8 -1
- package/src/codex/catalog/account-models.ts +62 -0
- package/src/codex/catalog/aggregation.ts +14 -1
- package/src/codex/catalog/bundled.ts +282 -32
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/metadata.ts +51 -6
- package/src/codex/catalog/parsing.ts +6 -3
- package/src/codex/catalog/provider-fetch.ts +576 -41
- package/src/codex/catalog/sync.ts +505 -66
- package/src/codex/catalog-admission.ts +197 -0
- package/src/codex/catalog-write-serialization.ts +241 -0
- package/src/codex/catalog.ts +2 -1
- package/src/codex/codex-write-lock.ts +372 -0
- package/src/codex/convergence-types.ts +593 -0
- package/src/codex/convergence.ts +441 -0
- package/src/codex/desired-state.ts +177 -0
- package/src/codex/features.ts +52 -8
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +257 -0
- package/src/codex/history-lock.ts +241 -0
- package/src/codex/history-migration-guardian.ts +18 -5
- package/src/codex/history-provider.ts +9 -2
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +176 -0
- package/src/codex/inject-coordination.ts +245 -0
- package/src/codex/inject.ts +605 -124
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +80 -0
- package/src/codex/journal.ts +10 -1
- package/src/codex/main-account-cache.ts +24 -0
- package/src/codex/management-convergence.ts +114 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +167 -0
- package/src/codex/native-main-lock-file.ts +158 -0
- package/src/codex/native-main-owner.ts +315 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1512 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +340 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +557 -0
- package/src/codex/project-config-warnings.ts +18 -4
- package/src/codex/prompt-journal.ts +311 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +224 -0
- package/src/codex/quota.ts +86 -3
- package/src/codex/routing.ts +299 -62
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +39 -13
- package/src/codex/subagent-model-fallback.ts +73 -12
- package/src/codex/transition-state.ts +604 -0
- package/src/codex/upstream-host-health.ts +70 -0
- package/src/codex/user-identity.ts +266 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/config.ts +562 -26
- package/src/generated/jawcode-model-metadata.ts +2 -2
- package/src/grok/inject.ts +15 -4
- package/src/grok/inspect.ts +45 -0
- package/src/images/loop.ts +113 -20
- package/src/integrations/config-io.ts +151 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/native/ownership-preflight.ts +165 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +101 -0
- package/src/integrations/serialize.ts +235 -0
- package/src/integrations/state.ts +290 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer.ts +492 -0
- package/src/lib/bounded-body.ts +46 -8
- package/src/lib/bun-runtime.ts +110 -1
- package/src/lib/bun-stream-caps.ts +2 -1
- package/src/lib/redact.ts +407 -2
- package/src/lib/shadow-call.ts +24 -0
- package/src/lib/translator-budget.ts +10 -0
- package/src/lib/upstream-reachability.ts +91 -0
- package/src/lib/upstream-retry.ts +154 -2
- package/src/lib/windows-secret-acl.ts +212 -11
- package/src/lib/winsw.ts +9 -3
- package/src/oauth/index.ts +61 -3
- package/src/oauth/key-providers.ts +4 -0
- package/src/oauth/kiro.ts +50 -6
- package/src/oauth/store.ts +31 -6
- package/src/oauth/token-guardian.ts +9 -3
- package/src/providers/codex-capacity.ts +288 -0
- package/src/providers/derive.ts +33 -1
- package/src/providers/free-directory.ts +3 -1
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/openai-sidecar.ts +64 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/quota.ts +334 -26
- package/src/providers/registry.ts +284 -16
- package/src/providers/xai-transport.ts +11 -4
- package/src/responses/compaction.ts +8 -1
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/parser.ts +44 -2
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +105 -0
- package/src/responses/spill-store.ts +45 -8
- package/src/responses/state.ts +161 -17
- package/src/router.ts +302 -16
- package/src/routing/analytics.ts +377 -0
- package/src/routing/capability.ts +204 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +444 -0
- package/src/routing/health.ts +401 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +590 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile.ts +423 -0
- package/src/routing/quota.ts +124 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +686 -0
- package/src/server/auth-cors.ts +46 -6
- package/src/server/chat-completions.ts +28 -13
- package/src/server/claude-messages.ts +23 -15
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +10 -1
- package/src/server/index.ts +238 -52
- package/src/server/lifecycle.ts +228 -9
- package/src/server/live.ts +14 -3
- package/src/server/management/agent-settings-routes.ts +64 -14
- package/src/server/management/combo-routes.ts +5 -5
- package/src/server/management/config-routes.ts +1 -1
- package/src/server/management/context.ts +42 -2
- package/src/server/management/integration-routes.ts +538 -0
- package/src/server/management/logs-usage-routes.ts +1 -1
- package/src/server/management/model-routes.ts +32 -113
- package/src/server/management/model-rows.ts +117 -0
- package/src/server/management/native-integration-routes.ts +587 -0
- package/src/server/management/oauth-account-routes.ts +1 -1
- package/src/server/management/provider-routes.ts +218 -117
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +177 -0
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +47 -31
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +276 -24
- package/src/server/management/system-routes.ts +4 -0
- package/src/server/management-api.ts +84 -9
- package/src/server/management-auth.ts +43 -5
- package/src/server/relay-eager.ts +82 -42
- package/src/server/relay.ts +120 -6
- package/src/server/request-log.ts +26 -6
- package/src/server/responses/collaboration.ts +63 -8
- package/src/server/responses/compact.ts +272 -41
- package/src/server/responses/core.ts +730 -132
- package/src/server/responses/fetch-helpers.ts +15 -1
- package/src/server/responses-item-id-repair.ts +32 -3
- package/src/server/responses-json-events.ts +52 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/search.ts +51 -6
- package/src/server/sse-payload-rewrite.ts +89 -12
- package/src/server/startup-health-cache.ts +7 -1
- package/src/server/ws-bridge.ts +11 -17
- package/src/service-manager-probe.ts +297 -0
- package/src/service.ts +222 -32
- package/src/tray/windows-tray.ps1 +9 -0
- package/src/tray/windows.ts +15 -7
- package/src/types.ts +194 -14
- package/src/update/index.ts +13 -13
- package/src/update/job.ts +24 -21
- package/src/update/notify.ts +7 -3
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +129 -10
- package/src/usage/log.ts +50 -15
- package/src/usage/summary.ts +4 -4
- package/src/vision/index.ts +6 -1
- package/src/web-search/loop.ts +161 -34
- package/gui/dist/assets/index-OY43ubAq.css +0 -1
- package/gui/dist/assets/index-YwNnKZcL.js +0 -67
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classification of upstream fetch rejections that occur before any request
|
|
3
|
+
* bytes can reach the origin: DNS resolution failure and TCP connect refusal.
|
|
4
|
+
*
|
|
5
|
+
* Issue #914: these failures are machine/network-wide, not account-specific —
|
|
6
|
+
* every Codex pool account shares the provider host, so rotating accounts
|
|
7
|
+
* cannot repair them. The transport layer previously mapped every non-timeout
|
|
8
|
+
* rejection to `connect_error`, and at `upstreamFailoverThreshold` that streak
|
|
9
|
+
* soft-avoided a healthy account and cleared thread affinity.
|
|
10
|
+
*
|
|
11
|
+
* Bun collapses DNS failure and TCP refusal into one label class
|
|
12
|
+
* (`ConnectionRefused` / `FailedToOpenSocket`, errno 0); Node's undici emits
|
|
13
|
+
* the classic `ECONNREFUSED` / `ENOTFOUND` / `EAI_AGAIN` / `ENETUNREACH` /
|
|
14
|
+
* `ENETDOWN` / `EHOSTUNREACH` shapes. Both shapes are matched here, on `code`
|
|
15
|
+
* values only, through a bounded cause chain. Message substrings are never
|
|
16
|
+
* trusted (a message-only match is a negative case), and labels that can also
|
|
17
|
+
* appear after the origin saw the credential — `ECONNRESET`, `EPIPE`, TLS
|
|
18
|
+
* errors, unknown shapes — deliberately stay outside the set.
|
|
19
|
+
*
|
|
20
|
+
* Classifier semantics extracted from PR #966 (Yuxin-Qiao) with attribution;
|
|
21
|
+
* sidecar blast radius intentionally not inherited.
|
|
22
|
+
*
|
|
23
|
+
* MUST stay a leaf module: imports nothing from server.ts or adapters.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { UpstreamRetryEvidenceError } from "./upstream-retry";
|
|
27
|
+
|
|
28
|
+
export const PRE_CONNECT_REACHABILITY_CODES = new Set([
|
|
29
|
+
// Bun: DNS failure and TCP refusal share this class.
|
|
30
|
+
"ConnectionRefused",
|
|
31
|
+
"FailedToOpenSocket",
|
|
32
|
+
// Node undici / classic Node shapes.
|
|
33
|
+
"ECONNREFUSED",
|
|
34
|
+
"ENOTFOUND",
|
|
35
|
+
"EAI_AGAIN",
|
|
36
|
+
"ENETUNREACH",
|
|
37
|
+
"ENETDOWN",
|
|
38
|
+
"EHOSTUNREACH",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Upper bound on how far `cause` chains are inspected. */
|
|
42
|
+
export const MAX_REACHABILITY_CAUSE_DEPTH = 3;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True when the rejection (or a bounded `cause` of it) carries a proven
|
|
46
|
+
* pre-connection reachability code. Never matches message text.
|
|
47
|
+
*/
|
|
48
|
+
export function isPreConnectReachabilityError(err: unknown): boolean {
|
|
49
|
+
let current: unknown = err;
|
|
50
|
+
const seen = new Set<unknown>();
|
|
51
|
+
for (let depth = 0; depth < MAX_REACHABILITY_CAUSE_DEPTH; depth++) {
|
|
52
|
+
if (!(current instanceof Error) || seen.has(current)) return false;
|
|
53
|
+
seen.add(current);
|
|
54
|
+
const code = (current as { code?: unknown }).code;
|
|
55
|
+
if (typeof code === "string" && PRE_CONNECT_REACHABILITY_CODES.has(code)) return true;
|
|
56
|
+
current = current.cause;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type TransportFailureKind = "timeout" | "connect_neutral" | "connect_error";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Shared transport rejection classification for Codex pool upstream sends.
|
|
65
|
+
* Timeouts keep their existing identity (account-transient); proven pre-connect
|
|
66
|
+
* reachability failures become account-neutral; everything else (ECONNRESET,
|
|
67
|
+
* EPIPE, TLS, unknown shapes) keeps the existing `connect_error`
|
|
68
|
+
* account-attributed behavior.
|
|
69
|
+
*/
|
|
70
|
+
export function classifyTransportFailureKind(err: unknown): TransportFailureKind {
|
|
71
|
+
const evidence = err instanceof UpstreamRetryEvidenceError ? err : undefined;
|
|
72
|
+
const rejection = evidence ? evidence.cause : err;
|
|
73
|
+
if (rejection instanceof Error && rejection.name === "TimeoutError") return "timeout";
|
|
74
|
+
if (isPreConnectReachabilityError(rejection)) {
|
|
75
|
+
// A transient upstream response (5xx) or a credential-visible connection
|
|
76
|
+
// reset before the rejection proves the host and credential path were
|
|
77
|
+
// reached: the failure is account-attributable, never the pre-connection
|
|
78
|
+
// neutral class (issue #914 review).
|
|
79
|
+
if (evidence && (evidence.transientStatuses.length > 0 || evidence.resetSeen)) return "connect_error";
|
|
80
|
+
return "connect_neutral";
|
|
81
|
+
}
|
|
82
|
+
return "connect_error";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Stable `code` carried by a transport rejection, when there is one. */
|
|
86
|
+
export function transportErrorCode(err: unknown): string | undefined {
|
|
87
|
+
const rejection = err instanceof UpstreamRetryEvidenceError ? err.cause : err;
|
|
88
|
+
if (!(rejection instanceof Error)) return undefined;
|
|
89
|
+
const code = (rejection as { code?: unknown }).code;
|
|
90
|
+
return typeof code === "string" && code !== "" ? code : undefined;
|
|
91
|
+
}
|
|
@@ -71,6 +71,107 @@ export async function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<
|
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Best-effort, bounded cancellation of a response body before a retry backoff.
|
|
76
|
+
*
|
|
77
|
+
* The 429 paths release the unread body before waiting so sockets do not accumulate under a
|
|
78
|
+
* rate-limit storm, but a never-settling `cancel()` promise must not be able to block the
|
|
79
|
+
* abort-aware backoff (client cancel, `maxIntervalMs`, or the cumulative header deadline).
|
|
80
|
+
* Cancellation is started and its rejection observed; the await is bounded by `timeoutMs`
|
|
81
|
+
* and the abort signal. This mirrors the rotation-path guarantee (release is initiated, not
|
|
82
|
+
* awaited forever) while preserving the resource-release intent of the same-target paths.
|
|
83
|
+
*/
|
|
84
|
+
export async function releaseResponseBodyBestEffort(
|
|
85
|
+
body: ReadableStream<Uint8Array> | null,
|
|
86
|
+
signal: AbortSignal | undefined,
|
|
87
|
+
timeoutMs = 1_000,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
if (!body) return;
|
|
90
|
+
if (signal?.aborted) {
|
|
91
|
+
void body.cancel().catch(() => {});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const cancel = body.cancel().catch(() => {});
|
|
95
|
+
if (!signal) {
|
|
96
|
+
await Promise.race([cancel, new Promise<void>(resolve => setTimeout(resolve, timeoutMs))]);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
await new Promise<void>(resolve => {
|
|
100
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
101
|
+
/**
|
|
102
|
+
* Abort hook: clear the bounded-body release timer and settle the promise so a
|
|
103
|
+
* never-settling cancel() can never block the abort-aware backoff.
|
|
104
|
+
*/
|
|
105
|
+
const onAbort = () => {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
resolve();
|
|
108
|
+
};
|
|
109
|
+
timer = setTimeout(() => {
|
|
110
|
+
signal.removeEventListener("abort", onAbort);
|
|
111
|
+
resolve();
|
|
112
|
+
}, timeoutMs);
|
|
113
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
114
|
+
void cancel.then(() => {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
signal.removeEventListener("abort", onAbort);
|
|
117
|
+
resolve();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Abort-aware sleep that yields an adapter `heartbeat` at least every `heartbeatIntervalMs`.
|
|
124
|
+
* The Responses bridge treats a returned iterator event as upstream liveness and aborts turns
|
|
125
|
+
* that stay silent past the stall budget (default 300s), while a retryOn429 wait may legally
|
|
126
|
+
* reach 600s — so deliberate waits must keep the watchdog fed or a long backoff is killed
|
|
127
|
+
* mid-turn. The final chunk always yields once, which doubles as the post-wait liveness beat.
|
|
128
|
+
*/
|
|
129
|
+
export async function* sleepWithHeartbeats(
|
|
130
|
+
ms: number,
|
|
131
|
+
signal?: AbortSignal,
|
|
132
|
+
heartbeatIntervalMs = 10_000,
|
|
133
|
+
): AsyncGenerator<{ type: "heartbeat" }> {
|
|
134
|
+
if (ms <= 0) return;
|
|
135
|
+
// Guard against a non-positive interval: a zero/negative step would spin the loop forever
|
|
136
|
+
// while sleepWithAbort early-returns without ever observing the abort signal. NaN must be
|
|
137
|
+
// normalized too: Math.max(1, NaN) is NaN, which would abort the wait after one beat.
|
|
138
|
+
const stepMs = Number.isNaN(heartbeatIntervalMs) ? 1 : Math.max(1, heartbeatIntervalMs);
|
|
139
|
+
let remaining = ms;
|
|
140
|
+
while (remaining > 0) {
|
|
141
|
+
const chunk = Math.min(remaining, stepMs);
|
|
142
|
+
await sleepWithAbort(chunk, signal);
|
|
143
|
+
remaining -= chunk;
|
|
144
|
+
yield { type: "heartbeat" };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface SameTarget429WaitOptions {
|
|
149
|
+
body: ReadableStream<Uint8Array> | null;
|
|
150
|
+
signal?: AbortSignal;
|
|
151
|
+
delayMs: number;
|
|
152
|
+
/**
|
|
153
|
+
* When set, the wait yields adapter heartbeats so bridge stall watchdogs stay fed.
|
|
154
|
+
* Omit for pre-stream recovery paths that have no stall watchdog.
|
|
155
|
+
*/
|
|
156
|
+
heartbeatIntervalMs?: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Shared pre-replay prep for opt-in same-target 429 waits:
|
|
161
|
+
* release the unread 429 body, then sleep (optionally with heartbeats).
|
|
162
|
+
* Callers still own attempt budgeting, abort re-checks, and the replay itself.
|
|
163
|
+
*/
|
|
164
|
+
export async function* prepareSameTarget429Wait(
|
|
165
|
+
options: SameTarget429WaitOptions,
|
|
166
|
+
): AsyncGenerator<{ type: "heartbeat" }> {
|
|
167
|
+
await releaseResponseBodyBestEffort(options.body, options.signal);
|
|
168
|
+
if (options.heartbeatIntervalMs === undefined) {
|
|
169
|
+
await sleepWithAbort(options.delayMs, options.signal);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
yield* sleepWithHeartbeats(options.delayMs, options.signal, options.heartbeatIntervalMs);
|
|
173
|
+
}
|
|
174
|
+
|
|
74
175
|
export function isConnectionResetError(err: unknown): boolean {
|
|
75
176
|
if (!(err instanceof Error)) return false;
|
|
76
177
|
// Aborts and timeouts are caller decisions / honest failures — never retryable.
|
|
@@ -148,6 +249,39 @@ export interface TransientRetryOptions extends ResetRetryOptions {
|
|
|
148
249
|
export type UpstreamSendRecovery = "connection-reset" | "transient-5xx";
|
|
149
250
|
type ReplayableFetch = (recovery?: UpstreamSendRecovery) => Promise<Response>;
|
|
150
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Rejection thrown by the upstream retry helpers when the terminal attempt
|
|
254
|
+
* rejects after earlier attempts already produced credential-visible evidence:
|
|
255
|
+
* transient 5xx responses, or a connection reset after the request was read.
|
|
256
|
+
*
|
|
257
|
+
* That evidence proves the host and credential path were reached, so the
|
|
258
|
+
* failure must stay account-attributed even though the terminal promise looks
|
|
259
|
+
* like a transport rejection (issue #914 review: mixed 5xx/reset -> rejection
|
|
260
|
+
* must not be downgraded to the account-neutral pre-connection class). The
|
|
261
|
+
* original rejection is preserved as `cause` so its code and message stay
|
|
262
|
+
* inspectable. Extracted from PR #966 (Yuxin-Qiao) with attribution.
|
|
263
|
+
*/
|
|
264
|
+
export class UpstreamRetryEvidenceError extends Error {
|
|
265
|
+
constructor(
|
|
266
|
+
public readonly transientStatuses: readonly number[],
|
|
267
|
+
cause: unknown,
|
|
268
|
+
/** True when a connection-reset retry already reached the origin. */
|
|
269
|
+
public readonly resetSeen = false,
|
|
270
|
+
) {
|
|
271
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
272
|
+
const kinds: string[] = [];
|
|
273
|
+
if (transientStatuses.length > 0) kinds.push("transient 5xx response(s)");
|
|
274
|
+
if (resetSeen) kinds.push("a credential-visible connection reset");
|
|
275
|
+
super(
|
|
276
|
+
kinds.length > 0
|
|
277
|
+
? `upstream fetch failed after ${kinds.join(" and ")}: ${detail}`
|
|
278
|
+
: `upstream fetch failed: ${detail}`,
|
|
279
|
+
{ cause },
|
|
280
|
+
);
|
|
281
|
+
this.name = "UpstreamRetryEvidenceError";
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
151
285
|
/**
|
|
152
286
|
* Opt out of Bun's keep-alive pool after a connection-reset retry.
|
|
153
287
|
*
|
|
@@ -181,12 +315,22 @@ export async function fetchWithResetRetry(
|
|
|
181
315
|
): Promise<Response> {
|
|
182
316
|
const attempts = Math.max(1, opts.attempts ?? RESET_RETRY_MAX_ATTEMPTS);
|
|
183
317
|
let lastError: unknown;
|
|
318
|
+
let sawReset = false;
|
|
184
319
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
185
320
|
if (opts.abortSignal?.aborted) throw abortError(opts.abortSignal);
|
|
186
321
|
try {
|
|
187
322
|
return await doFetch(attempt === 0 ? firstRecovery : "connection-reset");
|
|
188
323
|
} catch (err) {
|
|
189
|
-
if (opts.abortSignal?.aborted
|
|
324
|
+
if (opts.abortSignal?.aborted) throw err;
|
|
325
|
+
if (!isConnectionResetError(err)) {
|
|
326
|
+
// A reset that already reached the origin is credential-visible
|
|
327
|
+
// evidence: keep it attached so the terminal rejection cannot be
|
|
328
|
+
// downgraded to the pre-connection neutral class (#914 review).
|
|
329
|
+
if (sawReset) throw new UpstreamRetryEvidenceError([], err, true);
|
|
330
|
+
throw err;
|
|
331
|
+
}
|
|
332
|
+
if (attempt === attempts - 1) throw err;
|
|
333
|
+
sawReset = true;
|
|
190
334
|
lastError = err;
|
|
191
335
|
console.warn(
|
|
192
336
|
`[upstream-retry] connection reset${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`,
|
|
@@ -216,6 +360,7 @@ export async function fetchWithTransientRetry(
|
|
|
216
360
|
): Promise<Response> {
|
|
217
361
|
const attempts = Math.max(1, opts.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS);
|
|
218
362
|
const slowAttemptMs = opts.slowAttemptMs ?? TRANSIENT_RETRY_SLOW_ATTEMPT_MS;
|
|
363
|
+
const transientStatuses: number[] = [];
|
|
219
364
|
let attemptStart = Date.now();
|
|
220
365
|
let res = await fetchWithResetRetry(doFetch, opts);
|
|
221
366
|
for (let attempt = 0; attempt < attempts - 1; attempt++) {
|
|
@@ -233,7 +378,14 @@ export async function fetchWithTransientRetry(
|
|
|
233
378
|
cancelResponseBodyBestEffort(res);
|
|
234
379
|
await sleepWithAbort(delay, opts.abortSignal);
|
|
235
380
|
attemptStart = Date.now();
|
|
236
|
-
res
|
|
381
|
+
transientStatuses.push(res.status);
|
|
382
|
+
try {
|
|
383
|
+
res = await fetchWithResetRetry(doFetch, opts, "transient-5xx");
|
|
384
|
+
} catch (err) {
|
|
385
|
+
// Keep the prior 5xx evidence attached: the origin already responded, so
|
|
386
|
+
// this rejection is not pre-connection and must not classify as neutral.
|
|
387
|
+
throw new UpstreamRetryEvidenceError(transientStatuses, err);
|
|
388
|
+
}
|
|
237
389
|
}
|
|
238
390
|
return res;
|
|
239
391
|
}
|
|
@@ -29,14 +29,175 @@
|
|
|
29
29
|
* hardenSecretDir — same contract for directories.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import { existsSync } from "node:fs";
|
|
32
|
+
import { existsSync, statSync } from "node:fs";
|
|
33
33
|
import { env, platform } from "node:process";
|
|
34
34
|
|
|
35
|
-
const hardenedDirectories = new
|
|
36
|
-
const hardenedPaths = new
|
|
35
|
+
const hardenedDirectories = new Map<string, HardenedIdentity>();
|
|
36
|
+
const hardenedPaths = new Map<string, HardenedIdentity>();
|
|
37
37
|
/** Paths whose harden TIMED OUT this process: do not re-stall every loadConfig on them. */
|
|
38
38
|
const timedOutPaths = new Set<string>();
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The memo value: `object:freshness` for a file a harden was actually attributed
|
|
42
|
+
* to.
|
|
43
|
+
*
|
|
44
|
+
* There is deliberately no null member. An observation that cannot be read is
|
|
45
|
+
* not stored at all — the entry is deleted — because a "recorded as unverifiable"
|
|
46
|
+
* value was dead code the moment attribution became a before/after comparison,
|
|
47
|
+
* and a branch nothing can reach is a branch no test can defend.
|
|
48
|
+
*/
|
|
49
|
+
type HardenedIdentity = string;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What a stat can tell us about WHICH OBJECT is at a path.
|
|
53
|
+
*
|
|
54
|
+
* Two fields, deliberately separated, because conflating them shipped a bug:
|
|
55
|
+
*
|
|
56
|
+
* - `object` — `dev:ino`. Answers "is this the same file". Survives an ACL or
|
|
57
|
+
* permission change, which is exactly what we need across an icacls call.
|
|
58
|
+
* - `freshness` — `ctimeNs`. Answers "has this file's metadata moved since". It
|
|
59
|
+
* distinguishes an unlink/recreate that ext4 gave the same inode back for, and
|
|
60
|
+
* it MOVES when permissions change.
|
|
61
|
+
*
|
|
62
|
+
* The first version used `dev:ino:ctimeNs` for both jobs. Since chmod bumps ctime
|
|
63
|
+
* — probed, `{ctimeChangedByChmod: true}` — and icacls is a permission change, the
|
|
64
|
+
* before/after comparison would have rejected its own successful harden and failed
|
|
65
|
+
* closed on every first harden on Windows. Requiring the identity to be unchanged
|
|
66
|
+
* across an operation whose entire purpose is to change it is not a strict check;
|
|
67
|
+
* it is a broken one.
|
|
68
|
+
*/
|
|
69
|
+
interface PathObservation {
|
|
70
|
+
readonly object: string;
|
|
71
|
+
readonly freshness: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Observe which object is at a path, and how fresh it is.
|
|
76
|
+
*
|
|
77
|
+
* `dev:ino` alone is not enough to detect a replacement, which a Linux CI run
|
|
78
|
+
* proved: ext4 reuses the inode of an unlinked file immediately — 100 of 100
|
|
79
|
+
* unlink/recreate cycles produced the SAME `ino`, while macOS reused none in 200
|
|
80
|
+
* and happily reported the earlier fix working. That is why `freshness` exists;
|
|
81
|
+
* `ctimeNs` differed in 100 of 100 of those same cycles.
|
|
82
|
+
*
|
|
83
|
+
* `bigint: true` is used because `ctimeNs` exists only in that variant.
|
|
84
|
+
*
|
|
85
|
+
* Test seam: `setStatForTests` replaces this reader so a test can vary `dev`,
|
|
86
|
+
* `ino`, and `ctimeNs` independently. Mirroring the implementation's string
|
|
87
|
+
* format in test setup proves nothing about which components production uses —
|
|
88
|
+
* an audit removed `dev` and all forty tests still passed.
|
|
89
|
+
*
|
|
90
|
+
* UNVERIFIED: the plain `ino` is reported to be 0 on NTFS while the bigint form
|
|
91
|
+
* carries the file index, and the zero-ino guard exists for that case. Neither
|
|
92
|
+
* Darwin nor Linux CI can confirm it and no pinned-Bun Windows probe has run.
|
|
93
|
+
* It is defensive code, not a demonstrated platform fact.
|
|
94
|
+
*/
|
|
95
|
+
type StatReader = (path: string) => { dev: bigint; ino: bigint; ctimeNs: bigint };
|
|
96
|
+
|
|
97
|
+
const defaultStatReader: StatReader = path => {
|
|
98
|
+
const s = statSync(path, { bigint: true });
|
|
99
|
+
return { dev: s.dev, ino: s.ino, ctimeNs: s.ctimeNs };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
let statReader: StatReader = defaultStatReader;
|
|
103
|
+
|
|
104
|
+
/** Test seam: drive dev / ino / ctime independently. */
|
|
105
|
+
export function setStatForTests(reader: StatReader | null): void {
|
|
106
|
+
statReader = reader ?? defaultStatReader;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function observe(targetPath: string): PathObservation | null {
|
|
110
|
+
try {
|
|
111
|
+
const s = statReader(targetPath);
|
|
112
|
+
if (s.ino === 0n) return null;
|
|
113
|
+
return { object: `${s.dev}:${s.ino}`, freshness: `${s.ctimeNs}` };
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function memoValue(seen: PathObservation): HardenedIdentity {
|
|
120
|
+
return `${seen.object}:${seen.freshness}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* True only when this exact FILE was hardened, not merely this pathname.
|
|
125
|
+
*
|
|
126
|
+
* The memo used to be a `Set<string>` of paths. A stable destination — such as
|
|
127
|
+
* the coordinator database `hardenStableLockFile` hardens — can be unlinked and
|
|
128
|
+
* recreated at the same name, and the replacement inherited the previous file's
|
|
129
|
+
* hardening while never having been through icacls. Ephemeral temps escaped this
|
|
130
|
+
* only because atomic writers call `forgetEphemeralSecretPath` once the temp is
|
|
131
|
+
* gone; nothing does that for a stable path.
|
|
132
|
+
*/
|
|
133
|
+
function memoSatisfied(cache: Map<string, HardenedIdentity>, targetPath: string): boolean {
|
|
134
|
+
const remembered = cache.get(targetPath);
|
|
135
|
+
if (remembered === undefined) return false;
|
|
136
|
+
const current = observe(targetPath);
|
|
137
|
+
// Unreadable now is not "unchanged": re-harden rather than trust a value we
|
|
138
|
+
// cannot confirm still describes what is there.
|
|
139
|
+
//
|
|
140
|
+
// A miss RETIRES the entry rather than leaving it. Keeping it left the cache in
|
|
141
|
+
// a state nothing could justify: after a mismatch and a failed re-harden, the
|
|
142
|
+
// stale value survived, so restoring the old identity would satisfy it again
|
|
143
|
+
// without any ACL work. That needs exact-identity ABA to bite — outside the
|
|
144
|
+
// proof bound this unit claims — but "the consequence is out of scope" is not a
|
|
145
|
+
// reason to keep an entry we have just proven does not describe what is there.
|
|
146
|
+
if (current === null || memoValue(current) !== remembered) {
|
|
147
|
+
cache.delete(targetPath);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Record a harden ONLY if the file we hardened is still the file at that path.
|
|
155
|
+
*
|
|
156
|
+
* Reading identity after the ACL sequence returns answers "what is there now",
|
|
157
|
+
* which is not the same question as "what did icacls operate on". A replacement
|
|
158
|
+
* landing mid-sequence — probed by swapping the file during the final
|
|
159
|
+
* `/remove:g` — made the memo remember the REPLACEMENT as hardened, so the next
|
|
160
|
+
* acquisition skipped ACL work on a file that had never seen it:
|
|
161
|
+
*
|
|
162
|
+
* {identityChangedDuringHarden: true, callsForOriginal: 3, totalCalls: 3,
|
|
163
|
+
* replacementWasHardened: false}
|
|
164
|
+
*
|
|
165
|
+
* So the OBJECT is captured before the sequence and compared after it. Only the
|
|
166
|
+
* object — `dev:ino` — because icacls changes permissions, and `ctimeNs` moves
|
|
167
|
+
* when permissions change (probed: `{ctimeChangedByChmod: true}`). Comparing the
|
|
168
|
+
* full identity across the call would have rejected every successful harden and
|
|
169
|
+
* failed closed on the first harden on Windows: the check would have been
|
|
170
|
+
* demanding that an operation not do the thing it exists to do.
|
|
171
|
+
*
|
|
172
|
+
* The memo then stores the object plus the freshness read AFTER hardening, which
|
|
173
|
+
* is the state a later lookup should match.
|
|
174
|
+
*
|
|
175
|
+
* A changed object, or an unreadable observation at either end, means we cannot
|
|
176
|
+
* say what was hardened: the memo is cleared rather than written, and required
|
|
177
|
+
* callers fail closed. An optional caller soft-fails, as it does for every other
|
|
178
|
+
* unproven ACL.
|
|
179
|
+
*
|
|
180
|
+
* Returns true when the harden may be reported successful.
|
|
181
|
+
*/
|
|
182
|
+
function recordHarden(
|
|
183
|
+
cache: Map<string, HardenedIdentity>,
|
|
184
|
+
targetPath: string,
|
|
185
|
+
before: PathObservation | null,
|
|
186
|
+
): boolean {
|
|
187
|
+
const after = observe(targetPath);
|
|
188
|
+
if (before === null || after === null || before.object !== after.object) {
|
|
189
|
+
// Never leave a memo behind for a file we cannot vouch for, including one
|
|
190
|
+
// written by an earlier successful harden of a now-replaced file.
|
|
191
|
+
cache.delete(targetPath);
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
cache.set(targetPath, memoValue(after));
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const SUBSTITUTED_DIAGNOSTIC =
|
|
199
|
+
"ACL hardening could not be attributed — the file at this path changed during hardening";
|
|
200
|
+
|
|
40
201
|
export interface HardenResult {
|
|
41
202
|
ok: boolean;
|
|
42
203
|
diagnostics?: string;
|
|
@@ -173,11 +334,37 @@ export function forgetHardenedSecretPath(targetPath: string): void {
|
|
|
173
334
|
hardenedPaths.delete(targetPath);
|
|
174
335
|
}
|
|
175
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Ephemeral-path lifecycle release: clears the success memo AND any timeout
|
|
339
|
+
* memo keyed by THIS TEMP path in both namespaces. Call only after the temp is
|
|
340
|
+
* proven absent (successful rename, successful unlink, ENOENT, or an explicit
|
|
341
|
+
* non-existence check). Never pass a stable destination: destination-keyed
|
|
342
|
+
* timeout memos are intentional anti-restall state and are not touched here.
|
|
343
|
+
*/
|
|
344
|
+
export function forgetEphemeralSecretPath(tempPath: string): void {
|
|
345
|
+
hardenedPaths.delete(tempPath);
|
|
346
|
+
timedOutPaths.delete(`required:${tempPath}`);
|
|
347
|
+
timedOutPaths.delete(`optional:${tempPath}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Test seam: timeout memo sets return to baseline after ephemeral cleanup. */
|
|
351
|
+
export function timedOutSecretPathCountForTests(): number {
|
|
352
|
+
return timedOutPaths.size;
|
|
353
|
+
}
|
|
354
|
+
|
|
176
355
|
/** Test seam for proving ephemeral success memos do not grow across replacements. */
|
|
177
356
|
export function hardenedSecretPathCountForTests(): number {
|
|
178
357
|
return hardenedPaths.size;
|
|
179
358
|
}
|
|
180
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Directory counterpart. It had no seam, and that absence hid a real gap: a
|
|
362
|
+
* directory-only pathname memo passed every file-based test in this suite.
|
|
363
|
+
*/
|
|
364
|
+
export function hardenedSecretDirCountForTests(): number {
|
|
365
|
+
return hardenedDirectories.size;
|
|
366
|
+
}
|
|
367
|
+
|
|
181
368
|
function effectivePlatform(): string {
|
|
182
369
|
return platformOverride ?? platform;
|
|
183
370
|
}
|
|
@@ -393,11 +580,13 @@ function hardenEntry(
|
|
|
393
580
|
targetPath: string,
|
|
394
581
|
directory: boolean,
|
|
395
582
|
opts: HardenOptions,
|
|
396
|
-
cache:
|
|
583
|
+
cache: Map<string, HardenedIdentity>,
|
|
397
584
|
): HardenResult {
|
|
398
|
-
|
|
585
|
+
// Observed absence retires the memo. Leaving it would let a later file at this
|
|
586
|
+
// path satisfy the cache if the filesystem ever hands back a matching identity.
|
|
587
|
+
if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
|
|
399
588
|
if (effectivePlatform() !== "win32") return { ok: true };
|
|
400
|
-
if (cache
|
|
589
|
+
if (memoSatisfied(cache, targetPath)) return { ok: true };
|
|
401
590
|
const memoKey = timeoutMemoKey(targetPath, opts);
|
|
402
591
|
if (timedOutPaths.has(memoKey)) {
|
|
403
592
|
const diagnostics = "ACL hardening skipped — previous attempt timed out";
|
|
@@ -410,10 +599,17 @@ function hardenEntry(
|
|
|
410
599
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
411
600
|
if (attempt > 0 && deadline - nowFn() <= 0) break; // retry only while budget remains
|
|
412
601
|
try {
|
|
602
|
+
// Captured BEFORE the sequence: this is the file we are about to harden.
|
|
603
|
+
const before = observe(targetPath);
|
|
413
604
|
runIcacls(targetPath, directory, deadline);
|
|
414
|
-
cache
|
|
605
|
+
if (!recordHarden(cache, targetPath, before)) {
|
|
606
|
+
if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC);
|
|
607
|
+
return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC };
|
|
608
|
+
}
|
|
415
609
|
return { ok: true };
|
|
416
610
|
} catch (err) {
|
|
611
|
+
// A substitution is not a transient icacls stall; do not spend the retry on it.
|
|
612
|
+
if (err instanceof Error && err.message === SUBSTITUTED_DIAGNOSTIC) throw err;
|
|
417
613
|
lastErr = err;
|
|
418
614
|
if (!isTimeoutError(err)) break; // real failures do not retry
|
|
419
615
|
}
|
|
@@ -437,11 +633,11 @@ async function hardenEntryAsync(
|
|
|
437
633
|
targetPath: string,
|
|
438
634
|
directory: boolean,
|
|
439
635
|
opts: HardenOptions,
|
|
440
|
-
cache:
|
|
636
|
+
cache: Map<string, HardenedIdentity>,
|
|
441
637
|
): Promise<HardenResult> {
|
|
442
|
-
if (!existsSync(targetPath)) return { ok: true };
|
|
638
|
+
if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
|
|
443
639
|
if (effectivePlatform() !== "win32") return { ok: true };
|
|
444
|
-
if (cache
|
|
640
|
+
if (memoSatisfied(cache, targetPath)) return { ok: true };
|
|
445
641
|
const memoKey = timeoutMemoKey(targetPath, opts);
|
|
446
642
|
if (timedOutPaths.has(memoKey)) {
|
|
447
643
|
const diagnostics = "ACL hardening skipped — previous attempt timed out";
|
|
@@ -454,10 +650,15 @@ async function hardenEntryAsync(
|
|
|
454
650
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
455
651
|
if (attempt > 0 && deadline - nowFn() <= 0) break;
|
|
456
652
|
try {
|
|
653
|
+
const before = observe(targetPath);
|
|
457
654
|
await runIcaclsAsync(targetPath, directory, deadline);
|
|
458
|
-
cache
|
|
655
|
+
if (!recordHarden(cache, targetPath, before)) {
|
|
656
|
+
if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC);
|
|
657
|
+
return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC };
|
|
658
|
+
}
|
|
459
659
|
return { ok: true };
|
|
460
660
|
} catch (err) {
|
|
661
|
+
if (err instanceof Error && err.message === SUBSTITUTED_DIAGNOSTIC) throw err;
|
|
461
662
|
lastErr = err;
|
|
462
663
|
if (!isTimeoutError(err)) break;
|
|
463
664
|
}
|
package/src/lib/winsw.ts
CHANGED
|
@@ -20,7 +20,8 @@ import { homedir } from "node:os";
|
|
|
20
20
|
import { join, resolve } from "node:path";
|
|
21
21
|
import { expandUserPath, getConfigDir, loadConfig } from "../config";
|
|
22
22
|
import { recordOwnedConfigPath } from "./config-ownership";
|
|
23
|
-
import {
|
|
23
|
+
import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./bun-runtime";
|
|
24
|
+
import type { BunRuntimeSource } from "./bun-runtime";
|
|
24
25
|
import { serviceApiTokenFilePath } from "./service-secrets";
|
|
25
26
|
|
|
26
27
|
export const WINSW_VERSION = "2.12.0";
|
|
@@ -64,6 +65,8 @@ function currentCodexHomeAbsolute(): string {
|
|
|
64
65
|
|
|
65
66
|
export interface WinswEntry {
|
|
66
67
|
bun: string;
|
|
68
|
+
/** Provenance of `bun`, resolved together with it so the two can never disagree. */
|
|
69
|
+
bunRuntimeSource: BunRuntimeSource;
|
|
67
70
|
cli: string;
|
|
68
71
|
}
|
|
69
72
|
|
|
@@ -95,6 +98,8 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces
|
|
|
95
98
|
const aclTimeout = env.OPENCODEX_ACL_TIMEOUT_MS?.trim();
|
|
96
99
|
const envLines = [
|
|
97
100
|
` <env name="OCX_SERVICE" value="1"/>`,
|
|
101
|
+
` <env name="${BUN_RUNTIME_SOURCE_ENV}" value="${xmlEscape(entry.bunRuntimeSource)}"/>`,
|
|
102
|
+
` <env name="${BUN_RUNTIME_PATH_ENV}" value="${xmlEscape(entry.bun)}"/>`,
|
|
98
103
|
` <env name="OCX_API_TOKEN_FILE" value="${xmlEscape(serviceApiTokenFilePath())}"/>`,
|
|
99
104
|
` <env name="PATH" value="${xmlEscape(env.PATH ?? "")}"/>`,
|
|
100
105
|
env.CODEX_HOME?.trim() ? ` <env name="CODEX_HOME" value="${xmlEscape(currentCodexHomeAbsolute())}"/>` : null,
|
|
@@ -363,7 +368,7 @@ export function winswStatusSummary(): string {
|
|
|
363
368
|
if (status === "nonexistent") {
|
|
364
369
|
// A stale SCM service can outlive a deleted exe; surface the repair path.
|
|
365
370
|
return existsSync(winswXmlPath()) && !existsSync(winswExePath())
|
|
366
|
-
? "native assets present but WinSW binary missing — run 'ocx service
|
|
371
|
+
? "native assets present but WinSW binary missing — run 'ocx service repair'"
|
|
367
372
|
: "";
|
|
368
373
|
}
|
|
369
374
|
return `native (WinSW ${WINSW_VERSION}): ${status}`;
|
|
@@ -371,5 +376,6 @@ export function winswStatusSummary(): string {
|
|
|
371
376
|
|
|
372
377
|
/** Default entry mirrors the Task Scheduler baking: durable Bun + cli.ts. */
|
|
373
378
|
export function defaultWinswEntry(cliDir: string): WinswEntry {
|
|
374
|
-
|
|
379
|
+
const runtime = durableBunRuntime();
|
|
380
|
+
return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(cliDir, "cli", "index.ts") };
|
|
375
381
|
}
|