@bitkyc08/opencodex 2.84.0 → 2.86.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 +0 -4
- package/bin/ocx.mjs +61 -3
- package/gui/dist/assets/App-BjlttVLy.js +58 -0
- package/gui/dist/assets/{Tray-C9TGtwad.js → Tray-DyR9yBee.js} +1 -1
- package/gui/dist/assets/index-BMssP4k3.js +94 -0
- package/gui/dist/assets/quota-summary-D8M1bc_R.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -2
- package/src/adapters/anthropic/beta-allowlist.ts +87 -8
- package/src/adapters/anthropic/client-identity.ts +1 -1
- package/src/adapters/anthropic/passthrough.ts +33 -4
- package/src/adapters/azure.ts +19 -1
- package/src/adapters/client-fingerprint.ts +24 -0
- package/src/adapters/coding-agent/turn.ts +22 -9
- package/src/adapters/cursor/arg-codec.ts +17 -5
- package/src/adapters/cursor/catalog.ts +8 -0
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/cursor/freeform-args.ts +75 -0
- package/src/adapters/cursor/protobuf-events.ts +36 -23
- package/src/adapters/google-antigravity-wire.ts +22 -1
- package/src/adapters/google.ts +2 -2
- package/src/adapters/openai-chat/response-events.ts +59 -1
- package/src/adapters/openai-chat.ts +8 -5
- package/src/adapters/qoder/adapter.ts +40 -1
- package/src/advisor/consult.ts +220 -0
- package/src/advisor/context.ts +226 -0
- package/src/advisor/disclosure.ts +20 -0
- package/src/advisor/runtime.ts +305 -0
- package/src/advisor/settings.ts +135 -0
- package/src/advisor/state.ts +369 -0
- package/src/advisor/synthetic-tool.ts +33 -0
- package/src/bridge/errors.ts +5 -0
- package/src/chat/inbound.ts +30 -6
- package/src/chat/outbound.ts +5 -0
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +22 -8
- package/src/claude/desktop-picker-profile.ts +11 -4
- package/src/claude/desktop-profile.ts +10 -1
- package/src/claude/desktop-remote-store-io.ts +5 -3
- package/src/claude/inbound.ts +121 -50
- package/src/claude/intercept/local-ca.ts +8 -1
- package/src/claude/model-default-settings.ts +154 -0
- package/src/claude/model-info.ts +7 -7
- package/src/claude/outbound.ts +13 -6
- package/src/cli/advisor.ts +151 -0
- package/src/cli/auxiliary-listener-preflight.ts +37 -0
- package/src/cli/capabilities-agents-routing.ts +18 -0
- package/src/cli/capabilities-base.ts +1 -1
- package/src/cli/chatgpt-command.ts +35 -14
- package/src/cli/claude.ts +46 -6
- package/src/cli/dispatch.ts +5 -1
- package/src/cli/doctor.ts +9 -0
- package/src/cli/ensure-desktop-cold-start.ts +329 -0
- package/src/cli/export-command.ts +1 -1
- package/src/cli/help.ts +2 -1
- package/src/cli/index.ts +13 -4
- package/src/cli/integration-plan-dto.ts +2 -0
- package/src/cli/registry.ts +15 -3
- package/src/cli/restart-scope.ts +3 -1
- package/src/clients/config-export/contracts.ts +1 -0
- package/src/clients/config-export/model-metadata.ts +3 -2
- package/src/clients/config-export/qoder.ts +93 -0
- package/src/clients/config-export.ts +17 -4
- package/src/codex/auth-api/pool-mode-gate.ts +5 -2
- package/src/codex/auth-context.ts +79 -5
- package/src/codex/autostart-health.ts +4 -0
- package/src/codex/catalog/build-entries.ts +11 -2
- package/src/codex/catalog/configured-tiers.ts +50 -0
- package/src/codex/catalog/effort.ts +4 -1
- package/src/codex/catalog/model-hints.ts +21 -3
- package/src/codex/catalog/native-ultrafast.ts +106 -0
- package/src/codex/catalog/parsing.ts +5 -0
- package/src/codex/catalog/provider-models.ts +4 -0
- package/src/codex/catalog/retained-sync.ts +2 -0
- package/src/codex/catalog/routed-gather.ts +22 -4
- package/src/codex/catalog/vendor-context.ts +82 -0
- package/src/codex/config-write-lock.ts +40 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desktop-app/types.ts +2 -0
- package/src/codex/desktop-app/windows.ts +29 -16
- package/src/codex/desktop-app-restart.ts +4 -2
- package/src/codex/desktop-compatibility/certificate-service.ts +99 -0
- package/src/codex/desktop-compatibility/certificate-store.ts +193 -0
- package/src/codex/desktop-compatibility/connection-store.ts +87 -0
- package/src/codex/desktop-compatibility/installed-build.ts +55 -0
- package/src/codex/desktop-compatibility/json-body.ts +29 -0
- package/src/codex/desktop-compatibility/native-identity.ts +62 -0
- package/src/codex/desktop-compatibility/relay-listener.ts +93 -0
- package/src/codex/desktop-compatibility/routing-binding.ts +10 -0
- package/src/codex/desktop-compatibility/routing-preflight.ts +69 -0
- package/src/codex/desktop-compatibility/runtime-ownership.ts +28 -0
- package/src/codex/desktop-compatibility/runtime.ts +173 -0
- package/src/codex/desktop-compatibility/service.ts +30 -0
- package/src/codex/desktop-compatibility/startup-settings.ts +50 -0
- package/src/codex/desktop-compatibility/usage-activation.ts +86 -0
- package/src/codex/desktop-compatibility/usage-controlled-fetch.ts +59 -0
- package/src/codex/desktop-compatibility/usage-controller.ts +107 -0
- package/src/codex/desktop-compatibility/usage-policy.ts +59 -0
- package/src/codex/desktop-compatibility/usage-refresh.ts +33 -0
- package/src/codex/desktop-compatibility/usage-sse-controller.ts +86 -0
- package/src/codex/desktop-compatibility/windows-activation-source.ts +44 -0
- package/src/codex/desktop-compatibility/windows-certificate-trust.ts +81 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +59 -0
- package/src/codex/desktop-compatibility/windows-package-command.ts +86 -0
- package/src/codex/desktop-compatibility/windows-package-launch.ts +28 -0
- package/src/codex/history-provider.ts +1 -1
- package/src/codex/inject/config-write-section.ts +4 -1
- package/src/codex/inject/remove.ts +18 -6
- package/src/codex/journal.ts +16 -1
- package/src/codex/main-account.ts +99 -15
- package/src/codex/native-main-refresh-mode.ts +217 -0
- package/src/codex/routing/health-store.ts +1 -1
- package/src/codex/routing/pool-recovery.ts +79 -0
- package/src/codex/shim-diagnostics.ts +6 -0
- package/src/codex/shim-overlay.ts +7 -2
- package/src/config/diagnostics.ts +2 -1
- package/src/config/live-reconcile.ts +13 -0
- package/src/config/load-degrade.ts +6 -1
- package/src/config/paths.ts +10 -3
- package/src/config/schema/config-schema.ts +17 -0
- package/src/config/schema/desktop-compatibility.ts +9 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +451 -163
- package/src/generated/model-metadata.ts +1 -1
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/config-io.ts +20 -2
- package/src/integrations/journal.ts +15 -1
- package/src/integrations/mutation-plan.ts +1 -0
- package/src/integrations/registry.ts +7 -0
- package/src/integrations/store.ts +3 -0
- package/src/integrations/writer.ts +10 -1
- package/src/lib/advisor-activation.ts +16 -0
- package/src/lib/background-work.ts +65 -0
- package/src/lib/bounded-file-read.ts +58 -0
- package/src/lib/bun-path-runtime.d.mts +10 -1
- package/src/lib/bun-path-runtime.mjs +62 -3
- package/src/lib/desktop-proxy-route.ts +30 -0
- package/src/lib/desktop-upstream-tunnel.ts +107 -0
- package/src/lib/errors.ts +7 -0
- package/src/lib/local-internal-call-capability.ts +55 -0
- package/src/lib/package-tree-integrity.ts +2 -2
- package/src/lib/socks5-fetch.ts +48 -71
- package/src/lib/socks5-handshake.ts +112 -0
- package/src/lib/translator-budget.ts +7 -1
- package/src/oauth/generic-account-failover.ts +29 -5
- package/src/oauth/index.ts +4 -2
- package/src/oauth/kiro-device-login.ts +2 -1
- package/src/oauth/kiro.ts +7 -1
- package/src/protocols/codecs/chat-ir.ts +467 -0
- package/src/protocols/codecs/chat.ts +10 -1
- package/src/protocols/codecs/messages-ir.ts +500 -0
- package/src/protocols/features.ts +10 -2
- package/src/protocols/path.ts +11 -1
- package/src/protocols/plan-snapshot.ts +39 -16
- package/src/protocols/plan.ts +9 -1
- package/src/protocols/settings.ts +8 -0
- package/src/providers/antigravity-account-roster.ts +102 -0
- package/src/providers/antigravity-models.ts +85 -7
- package/src/providers/model-rename-fields.ts +2 -0
- package/src/providers/model-rename-migration.ts +5 -3
- package/src/providers/registry/entries-core.ts +7 -6
- package/src/providers/registry/entries-extended.ts +19 -0
- package/src/responses/parser.ts +30 -19
- package/src/responses/state.ts +32 -29
- package/src/routing/history/build-file.ts +92 -0
- package/src/routing/history/indexer.ts +248 -216
- package/src/routing/history/ingest.ts +238 -0
- package/src/routing/history/rebuild-job.ts +111 -0
- package/src/routing/history/rebuild-protocol.ts +15 -0
- package/src/routing/history/rebuild-worker.ts +34 -0
- package/src/server/admission-model-scope.ts +4 -1
- package/src/server/antigravity-direct-eligibility.ts +208 -0
- package/src/server/auth-cors.ts +4 -0
- package/src/server/chat-completions.ts +75 -9
- package/src/server/claude-messages.ts +110 -20
- package/src/server/index/desktop-compatibility-startup.ts +56 -0
- package/src/server/index/serve-options.ts +2 -2
- package/src/server/index/startup-warnings.ts +9 -0
- package/src/server/index/websocket-handler.ts +9 -0
- package/src/server/index.ts +13 -10
- package/src/server/inference/client-encoder-delivery.ts +6 -5
- package/src/server/login-shell-path.ts +146 -0
- package/src/server/management/advisor-routes.ts +191 -0
- package/src/server/management/agent-settings-routes.ts +22 -4
- package/src/server/management/companion-routes.ts +4 -0
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/desktop-compatibility-routes.ts +46 -0
- package/src/server/management/desktop-compatibility-runtime-routes.ts +46 -0
- package/src/server/management/desktop-compatibility-settings-routes.ts +27 -0
- package/src/server/management/model-routes.ts +2 -2
- package/src/server/management/protocol-settings-patch.ts +1 -0
- package/src/server/management/route-registry.ts +11 -0
- package/src/server/management/sibling-guard.ts +1 -0
- package/src/server/management-api.ts +6 -0
- package/src/server/messages-antigravity-direct.ts +195 -0
- package/src/server/messages-native.ts +5 -1
- package/src/server/request-log.ts +7 -0
- package/src/server/responses/adapter-continuation.ts +35 -18
- package/src/server/responses/adapter-delivery.ts +24 -2
- package/src/server/responses/adapter-dispatch.ts +29 -2
- package/src/server/responses/advisor-plan-slot.ts +27 -0
- package/src/server/responses/advisor-slot.ts +336 -0
- package/src/server/responses/code-mode-collab-surface.ts +116 -8
- package/src/server/responses/core-combo-failure.ts +10 -1
- package/src/server/responses/core-combo.ts +21 -0
- package/src/server/responses/core-lifetime.ts +2 -0
- package/src/server/responses/core-normalize.ts +11 -0
- package/src/server/responses/core-options.ts +46 -0
- package/src/server/responses/core-replay.ts +7 -4
- package/src/server/responses/core.ts +1 -1
- package/src/server/responses/passthrough-delivery.ts +16 -10
- package/src/server/responses/passthrough-dispatch.ts +14 -3
- package/src/server/responses/request-prepare.ts +68 -5
- package/src/server/responses/request-transport.ts +16 -0
- package/src/server/responses/run-turn-execution.ts +70 -8
- package/src/server/responses/sidecar-execution.ts +78 -3
- package/src/server/responses/terminal-guard.ts +2 -1
- package/src/server/responses-undeclared-tool-guard.ts +30 -9
- package/src/server/startup-action-control.ts +16 -2
- package/src/server/startup-health-cache.ts +1 -0
- package/src/service/desktop-startup.ts +14 -0
- package/src/service/health-sentinel-main.ts +23 -4
- package/src/service/health-sentinel-probe.ts +24 -7
- package/src/service/logging.ts +72 -0
- package/src/tray/windows-tray.ps1 +43 -24
- package/src/types/config.ts +63 -6
- package/src/types/provider.ts +7 -0
- package/src/types/request.ts +15 -0
- package/src/types/tools.ts +2 -0
- package/src/update/async-check.ts +1 -1
- package/src/update/badge.ts +2 -2
- package/src/update/index.ts +88 -15
- package/src/update/job-supervised-restart.ts +107 -0
- package/src/update/job.ts +32 -32
- package/src/update/notify.ts +1 -1
- package/src/update/refresh-scheduler.ts +2 -2
- package/src/update/supervised-recovery.d.mts +27 -0
- package/src/update/supervised-recovery.mjs +157 -0
- package/src/usage/cache-diagnostic.ts +2 -0
- package/src/usage/expected-prices.ts +12 -0
- package/src/web-search/gemini-executor.ts +2 -2
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/App-CZtqiGcw.js +0 -56
- package/gui/dist/assets/index-CTm4l-HQ.js +0 -94
- package/gui/dist/assets/quota-summary-BbEeRfPY.js +0 -1
|
@@ -7,6 +7,9 @@ import type { ResponsesEffects } from "./response-effects";
|
|
|
7
7
|
import type { ResponsesSendBudget } from "./request-send-budget";
|
|
8
8
|
import { formatErrorResponse } from "../../bridge";
|
|
9
9
|
import { planWebSearch, buildWebSearchTool, runWithWebSearch } from "../../web-search";
|
|
10
|
+
import { createRegisteredAdvisorPlan } from "./advisor-plan-slot";
|
|
11
|
+
import { ADVISOR_TOOL_NAME } from "./advisor-slot";
|
|
12
|
+
import { buildToolBridgeMaps } from "./collaboration";
|
|
10
13
|
import {
|
|
11
14
|
planImageBridge,
|
|
12
15
|
planVideoBridge,
|
|
@@ -30,6 +33,8 @@ import {
|
|
|
30
33
|
} from "../../oauth/generic-account-failover";
|
|
31
34
|
import { classifyKiroRefusal } from "../../adapters/kiro-refusal";
|
|
32
35
|
import { isNonReplayableResponse } from "../../lib/upstream-retry";
|
|
36
|
+
import { unwrapUpstreamRetryEvidenceError } from "../../codex/auth-context";
|
|
37
|
+
import { AdmissionModelDeniedError, admissionModelDeniedResponse } from "../admission-model-scope";
|
|
33
38
|
import { noteKiroMonthlyRefusal, noteKiroServedSuccess } from "../../providers/kiro-usage";
|
|
34
39
|
import { persistKiroAccountState } from "../../providers/kiro-account-state-disk";
|
|
35
40
|
import { anthropicPoolCooldownResponse, readDisplaySafeErrorText } from "./core-errors";
|
|
@@ -60,6 +65,7 @@ export async function executeResponsesSidecars(
|
|
|
60
65
|
| "inboundWire"
|
|
61
66
|
| "selectedForwardHeaders"
|
|
62
67
|
| "translatorBudget"
|
|
68
|
+
| "toolBridgeMaps"
|
|
63
69
|
| "rememberKiroDeliveredFinalAnswer"
|
|
64
70
|
| "responseStateOptions"
|
|
65
71
|
>,
|
|
@@ -169,6 +175,25 @@ export async function executeResponsesSidecars(
|
|
|
169
175
|
}
|
|
170
176
|
}
|
|
171
177
|
|
|
178
|
+
// Advisor sidecar plan (optional subsystem; null when disabled, unconfigured, or when this
|
|
179
|
+
// request IS an advisor loopback consultation — the recursion fence). The plan carries
|
|
180
|
+
// request-scoped state only; preflight keeps fixed policy separate from user-role advice
|
|
181
|
+
// before the worker is dispatched. Registration seam: the core path sees only
|
|
182
|
+
// `parsed._advisorGuard`; only an activated factory can load the optional implementation.
|
|
183
|
+
const advisorPlan = options.advisorInternal === true
|
|
184
|
+
? null
|
|
185
|
+
: await createRegisteredAdvisorPlan({
|
|
186
|
+
config,
|
|
187
|
+
workerIdentity: `${route.modelId} (provider ${route.providerName})`,
|
|
188
|
+
workerModelId: route.modelId,
|
|
189
|
+
abortSignal: options.abortSignal,
|
|
190
|
+
});
|
|
191
|
+
// Preflight only on real worker turns: a routed-compaction turn is a summarization request,
|
|
192
|
+
// and injecting advice into it would pollute the summary Codex replaces its history with.
|
|
193
|
+
if (advisorPlan && !routedCompaction) {
|
|
194
|
+
await advisorPlan.preflightInject(parsed);
|
|
195
|
+
}
|
|
196
|
+
|
|
172
197
|
// Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority.
|
|
173
198
|
// Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but
|
|
174
199
|
// leaves _imageGeneration, so planImageBridge would activate and return a normal Responses
|
|
@@ -198,6 +223,20 @@ export async function executeResponsesSidecars(
|
|
|
198
223
|
// A single request may rotate once for a verification refusal. Keep this separate from the
|
|
199
224
|
// broader OAuth failover count: a sidecar turn can also spend that count on rate-limit recovery.
|
|
200
225
|
let antigravityValidationRotationAttempted = false;
|
|
226
|
+
// A replacement credential whose Copilot Auto negotiation leaves the key's scope ends the turn.
|
|
227
|
+
// The loops only see "no rotation" and fail with the original, replayable 429, so the refusal
|
|
228
|
+
// is held here and answers in place of that failure.
|
|
229
|
+
let sidecarScopeDenial: AdmissionModelDeniedError | undefined;
|
|
230
|
+
const noteSidecarScopeDenial = (error: unknown): void => {
|
|
231
|
+
const refusal = unwrapUpstreamRetryEvidenceError(error);
|
|
232
|
+
if (refusal instanceof AdmissionModelDeniedError) sidecarScopeDenial ??= refusal;
|
|
233
|
+
};
|
|
234
|
+
const sidecarScopeRefusal = (response: Response): Response | undefined => {
|
|
235
|
+
if (!sidecarScopeDenial || response.ok) return undefined;
|
|
236
|
+
void response.body?.cancel().catch(() => {});
|
|
237
|
+
releaseSearchProbeLease();
|
|
238
|
+
return admissionModelDeniedResponse(sidecarScopeDenial);
|
|
239
|
+
};
|
|
201
240
|
/** Admit a replacement credential, or leave the loop's original refusal authoritative. */
|
|
202
241
|
const rotateSidecarProviderOn429 = async (
|
|
203
242
|
retryAfter: string | null,
|
|
@@ -264,7 +303,8 @@ export async function executeResponsesSidecars(
|
|
|
264
303
|
return null;
|
|
265
304
|
}
|
|
266
305
|
transportState.genericFailovers += 1;
|
|
267
|
-
} catch {
|
|
306
|
+
} catch (error) {
|
|
307
|
+
noteSidecarScopeDenial(error);
|
|
268
308
|
hop.permit?.release();
|
|
269
309
|
return null;
|
|
270
310
|
}
|
|
@@ -284,7 +324,10 @@ export async function executeResponsesSidecars(
|
|
|
284
324
|
} else if (rotated) {
|
|
285
325
|
route.provider = rotated;
|
|
286
326
|
try { await resolveCopilotSelection(retryParsed ?? parsed); }
|
|
287
|
-
catch {
|
|
327
|
+
catch (error) { // Keep the sidecar's original refusal when replacement negotiation fails.
|
|
328
|
+
noteSidecarScopeDenial(error);
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
288
331
|
} else if (
|
|
289
332
|
// A POSITIVE gate, not an early return. An early `return null` here made every later arm
|
|
290
333
|
// unreachable: Anthropic never has a genericFailoverAccountId (isGenericFailoverProvider
|
|
@@ -326,7 +369,8 @@ export async function executeResponsesSidecars(
|
|
|
326
369
|
return null;
|
|
327
370
|
}
|
|
328
371
|
if (route.providerName === "kiro") transportState.genericFailovers += 1;
|
|
329
|
-
} catch {
|
|
372
|
+
} catch (error) {
|
|
373
|
+
noteSidecarScopeDenial(error);
|
|
330
374
|
hop.permit?.release();
|
|
331
375
|
return null;
|
|
332
376
|
}
|
|
@@ -527,6 +571,8 @@ export async function executeResponsesSidecars(
|
|
|
527
571
|
notifyResponseComplete(response);
|
|
528
572
|
},
|
|
529
573
|
});
|
|
574
|
+
const imgScopeRefusal = sidecarScopeRefusal(imgResponse);
|
|
575
|
+
if (imgScopeRefusal) return imgScopeRefusal;
|
|
530
576
|
if (imgResponse.body) {
|
|
531
577
|
const imgTurnAc = new AbortController();
|
|
532
578
|
imgTurnAc.signal.addEventListener("abort", cancelResponseCompletion, { once: true });
|
|
@@ -622,6 +668,8 @@ export async function executeResponsesSidecars(
|
|
|
622
668
|
notifyResponseComplete(response);
|
|
623
669
|
},
|
|
624
670
|
});
|
|
671
|
+
const wsScopeRefusal = sidecarScopeRefusal(loopResponse);
|
|
672
|
+
if (wsScopeRefusal) return wsScopeRefusal;
|
|
625
673
|
// A routed Anthropic model whose last pool account refused on spent quota fails the loop's
|
|
626
674
|
// first iteration with a generic 429 before any search runs. Answer the usage limit instead,
|
|
627
675
|
// as the main path does (#6991), so Codex stops rather than retrying the whole turn.
|
|
@@ -645,5 +693,32 @@ export async function executeResponsesSidecars(
|
|
|
645
693
|
return wsResponse;
|
|
646
694
|
}
|
|
647
695
|
|
|
696
|
+
// Advisor synthetic tool + stream guard: attach ONLY when this function is about to hand the
|
|
697
|
+
// request back to the normal translated exchange (no sidecar loop claimed the turn, not a
|
|
698
|
+
// run-turn adapter, not compaction). The web-search/image loops own their own event handling;
|
|
699
|
+
// run-turn adapters execute their own loop; both would leak the synthetic tool call they
|
|
700
|
+
// cannot intercept. Preflight (above) still applies to those paths — only the tool does not.
|
|
701
|
+
if (advisorPlan && !routedCompaction && !transportState.adapter.runTurn && !wsPlan && !imgPlan && !vidPlan) {
|
|
702
|
+
// A client-declared tool named "advisor" would collide with the synthetic injection (one
|
|
703
|
+
// wire name, two schemas); the synthetic runtime owns the name for this turn.
|
|
704
|
+
parsed.context.tools = [
|
|
705
|
+
...(parsed.context.tools ?? []).filter(t => !t.advisor && t.name !== ADVISOR_TOOL_NAME),
|
|
706
|
+
advisorPlan.tool,
|
|
707
|
+
];
|
|
708
|
+
// The advisor tool joined AFTER prepare computed the bridge maps; recompute so the tool is
|
|
709
|
+
// declared (undeclared-tool guard, tool_choice mapping, schema repair) on this turn.
|
|
710
|
+
//
|
|
711
|
+
// Rebuild WITHOUT the budget: prepare already charged every tool in the catalog, and
|
|
712
|
+
// buildToolBridgeMaps charges each name it registers, so re-passing the budget would bill the
|
|
713
|
+
// whole catalog a second time and can trip `translation_buffer_limit` for a large MCP catalog.
|
|
714
|
+
// Charge only the one entry that is genuinely new — the synthetic advisor tool is a bare
|
|
715
|
+
// function tool, so it retains its wire name and its bare name (collaboration.ts:187, :233).
|
|
716
|
+
requestState.toolBridgeMaps = buildToolBridgeMaps(parsed);
|
|
717
|
+
translatorBudget.chargeRetained(new TextEncoder().encode(ADVISOR_TOOL_NAME).byteLength * 2, {
|
|
718
|
+
kind: "retained_collectors",
|
|
719
|
+
});
|
|
720
|
+
advisorPlan.attachGuard(parsed);
|
|
721
|
+
}
|
|
722
|
+
|
|
648
723
|
return undefined;
|
|
649
724
|
}
|
|
@@ -170,7 +170,8 @@ export function isTerminalGuardPassthroughOnly(event: AdapterEvent): boolean {
|
|
|
170
170
|
return event.type === "heartbeat" || event.type === "tool_call_delta";
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
|
|
173
|
+
/** Merge two reported usages (canonical Responses convention). Shared with the advisor guard. */
|
|
174
|
+
export function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined): OcxUsage | undefined {
|
|
174
175
|
if (!first) return second;
|
|
175
176
|
if (!second) return first;
|
|
176
177
|
const sumOptional = (key: keyof OcxUsage): number | undefined => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors";
|
|
1
2
|
import {
|
|
2
3
|
collectAmbiguousDottedAliases,
|
|
3
4
|
dottedAliasIsUnambiguous,
|
|
@@ -78,7 +79,7 @@ const NAMELESS_TOOL_SPEC_TYPES = new Set([
|
|
|
78
79
|
/** An upstream-supplied name reaches the error message; keep it bounded. */
|
|
79
80
|
const MAX_REPORTED_NAME_CHARS = 100;
|
|
80
81
|
|
|
81
|
-
export
|
|
82
|
+
export { UNDECLARED_TOOL_CALL_ERROR_CODE };
|
|
82
83
|
|
|
83
84
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
84
85
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
@@ -370,6 +371,24 @@ export function hasExplicitWireToolCatalog(body: unknown): boolean {
|
|
|
370
371
|
);
|
|
371
372
|
}
|
|
372
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Whether the caller declared an explicitly EMPTY catalog — `tools: []` or an
|
|
376
|
+
* `additional_tools` item with `tools: []`. Unlike an absent or partial catalog this is an
|
|
377
|
+
* unambiguous deny-all: no deferred registry can exist under it, so even deferred wires
|
|
378
|
+
* enforce it. A hosted-only catalog is NOT empty — it still defers like an absent one.
|
|
379
|
+
*/
|
|
380
|
+
export function hasExplicitEmptyToolCatalog(body: unknown): boolean {
|
|
381
|
+
if (!isPlainObject(body)) return false;
|
|
382
|
+
if (Array.isArray(body.tools) && body.tools.length === 0) return true;
|
|
383
|
+
if (!Array.isArray(body.input)) return false;
|
|
384
|
+
return body.input.some(item =>
|
|
385
|
+
isPlainObject(item)
|
|
386
|
+
&& item.type === "additional_tools"
|
|
387
|
+
&& Array.isArray(item.tools)
|
|
388
|
+
&& item.tools.length === 0
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
373
392
|
/**
|
|
374
393
|
* Evaluates whether an individual output item represents an undeclared tool call.
|
|
375
394
|
*
|
|
@@ -703,7 +722,6 @@ function failedBlocks(name: string, newline: string): readonly string[] {
|
|
|
703
722
|
* @param providerExecutedCallTypes - Call types executed by the provider.
|
|
704
723
|
* @param declaredBare - Explicitly declared bare tool names without namespace provenance.
|
|
705
724
|
* @param declaredCustom - Current bare custom declarations eligible for code-mode recovery.
|
|
706
|
-
* @param enforceDeclaredToolNames - Refuse undeclared calls; normalization remains active when false.
|
|
707
725
|
* @returns An SSE block rewrite function.
|
|
708
726
|
*/
|
|
709
727
|
export function createUndeclaredToolCallGuardBlockRewrite(
|
|
@@ -712,8 +730,11 @@ export function createUndeclaredToolCallGuardBlockRewrite(
|
|
|
712
730
|
providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES,
|
|
713
731
|
declaredBare?: ReadonlySet<string>,
|
|
714
732
|
declaredCustom?: ReadonlySet<string>,
|
|
715
|
-
|
|
733
|
+
options?: { refuseUndeclared?: boolean },
|
|
716
734
|
): SseBlockRewrite {
|
|
735
|
+
// Deferred-catalog wires (Chat/Anthropic) keep the default-namespace normalization — the
|
|
736
|
+
// client can only resolve the bare name it declared — while the refusal itself stays off.
|
|
737
|
+
const refuse = options?.refuseUndeclared !== false;
|
|
717
738
|
let tripped = false;
|
|
718
739
|
return (block: string) => {
|
|
719
740
|
if (tripped) return [];
|
|
@@ -725,12 +746,12 @@ export function createUndeclaredToolCallGuardBlockRewrite(
|
|
|
725
746
|
} catch {
|
|
726
747
|
return [block];
|
|
727
748
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
749
|
+
if (refuse) {
|
|
750
|
+
const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare, declaredCustom);
|
|
751
|
+
if (name !== undefined) {
|
|
752
|
+
tripped = true;
|
|
753
|
+
return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n");
|
|
754
|
+
}
|
|
734
755
|
}
|
|
735
756
|
const normalized = normalizeDefaultNamespaceInPayload(parsed, declared, declaredBare);
|
|
736
757
|
if (normalized.changed) {
|
|
@@ -3,6 +3,8 @@ import { execFile } from "node:child_process";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { durableBunPath } from "../lib/bun-runtime";
|
|
5
5
|
import { selfLaunchArgv } from "../lib/self-launch-argv";
|
|
6
|
+
import { isDesktopSupervised } from "../lib/system-restart-contract";
|
|
7
|
+
import { withLoginShellPath } from "./login-shell-path";
|
|
6
8
|
import {
|
|
7
9
|
WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER,
|
|
8
10
|
isWindowsSchtasksCreateAccessDenied,
|
|
@@ -66,6 +68,8 @@ interface StartupInstallLaunch {
|
|
|
66
68
|
execFile?: typeof execFile;
|
|
67
69
|
/** Overrides Bun's own standalone detection, which a source-mode test cannot flip. */
|
|
68
70
|
isStandaloneExecutable?: boolean;
|
|
71
|
+
isDesktopSupervised?: () => boolean;
|
|
72
|
+
withLoginShellPath?: (env: NodeJS.ProcessEnv) => Promise<NodeJS.ProcessEnv>;
|
|
69
73
|
}
|
|
70
74
|
let installLaunch: StartupInstallLaunch = {};
|
|
71
75
|
|
|
@@ -169,7 +173,16 @@ export function installFailureDetail(stdout: string, stderr: string, error: Erro
|
|
|
169
173
|
return classifyCliInstallFailure(stdout, stderr, error).detail;
|
|
170
174
|
}
|
|
171
175
|
|
|
172
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Under OpenCodex Desktop the proxy has the GUI launch PATH, where no user-installed Codex is
|
|
178
|
+
* visible. The shim installer gets the user's login-shell PATH instead (#6971).
|
|
179
|
+
*/
|
|
180
|
+
async function installChildEnv(action: StartupInstallAction): Promise<NodeJS.ProcessEnv> {
|
|
181
|
+
if (action !== "install-shim" || !(installLaunch.isDesktopSupervised ?? isDesktopSupervised)()) return process.env;
|
|
182
|
+
return (installLaunch.withLoginShellPath ?? withLoginShellPath)(process.env);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function runCliInstall(
|
|
173
186
|
action: StartupInstallAction,
|
|
174
187
|
options?: { repair?: boolean },
|
|
175
188
|
): Promise<{ stdout: string; stderr: string }> {
|
|
@@ -183,13 +196,14 @@ function runCliInstall(
|
|
|
183
196
|
isStandaloneExecutable: installLaunch.isStandaloneExecutable,
|
|
184
197
|
});
|
|
185
198
|
const run = installLaunch.execFile ?? execFile;
|
|
199
|
+
const env = await installChildEnv(action);
|
|
186
200
|
return new Promise((resolve, reject) => {
|
|
187
201
|
const timeout = process.platform === "win32" && action === "install-service"
|
|
188
202
|
? 0
|
|
189
203
|
: 60_000;
|
|
190
204
|
run(bun, argv, {
|
|
191
205
|
encoding: "utf8",
|
|
192
|
-
env
|
|
206
|
+
env,
|
|
193
207
|
// A fresh Windows scheduler install now owns its UAC prompt inside this CLI
|
|
194
208
|
// transaction. Killing only the CLI at 60s can orphan its elevated schtasks child,
|
|
195
209
|
// which may register the task after the Dashboard has reported failure. Keep the
|
|
@@ -117,6 +117,7 @@ function conservativeFallback(config: Pick<OcxConfig, "codexAutoStart">): Startu
|
|
|
117
117
|
serviceSupported: process.platform === "win32" || process.platform === "darwin" || process.platform === "linux",
|
|
118
118
|
shimInstalled: shim.installed,
|
|
119
119
|
shimHealthy: shim.healthy,
|
|
120
|
+
...(shim.activationPending ? { shimActivationPending: true } : {}),
|
|
120
121
|
platform: process.platform,
|
|
121
122
|
diagnosticStale: true,
|
|
122
123
|
});
|
|
@@ -122,11 +122,25 @@ function macLoginRegistration(deps: DesktopStartupDeps) {
|
|
|
122
122
|
}
|
|
123
123
|
const program = /^\s*program = (.+)$/m.exec(loaded)?.[1];
|
|
124
124
|
const loadedPath = /^\s*path = (.+)$/m.exec(loaded)?.[1];
|
|
125
|
+
// macOS 13+ Background Task Management can take over the user LaunchAgent and resubmit it
|
|
126
|
+
// through smd; launchctl then names the submitter instead of the plist (#7019). The loaded
|
|
127
|
+
// job no longer points at the registration, so credit it like an unloaded job: same program,
|
|
128
|
+
// and the entire registration re-read unchanged.
|
|
129
|
+
if (program !== undefined && loadedPath !== undefined && SMD_SUBMITTED.test(loadedPath)
|
|
130
|
+
&& /^\s*managed_by = (.+)$/m.exec(loaded)?.[1] === SERVICE_MANAGEMENT && realpathSync(program) === app) {
|
|
131
|
+
const final = capture();
|
|
132
|
+
if (!final || JSON.stringify(first) !== JSON.stringify(final)) return { app, proxy, loginEnabled: false };
|
|
133
|
+
return { app, proxy, loginEnabled: true, loginRegistration: "loaded" as const };
|
|
134
|
+
}
|
|
125
135
|
const loginEnabled = program !== undefined && realpathSync(program) === app
|
|
126
136
|
&& loadedPath !== undefined && realpathSync(loadedPath) === first.registrationPath;
|
|
127
137
|
return { app, proxy, loginEnabled, ...(loginEnabled ? { loginRegistration: "loaded" as const } : {}) };
|
|
128
138
|
}
|
|
129
139
|
|
|
140
|
+
/** launchctl's `path` for a job Background Task Management resubmitted through smd. */
|
|
141
|
+
const SMD_SUBMITTED = /^\(submitted by smd\.\d+\)$/;
|
|
142
|
+
const SERVICE_MANAGEMENT = "com.apple.xpc.ServiceManagement";
|
|
143
|
+
|
|
130
144
|
type DesktopLoginFacts = Pick<DesktopStartupDiagnostic, "loginEnabled" | "loginRegistration">;
|
|
131
145
|
|
|
132
146
|
/**
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
type HealthProbeOutcome,
|
|
39
39
|
type HealthSentinelTiming,
|
|
40
40
|
} from "./health-sentinel-policy";
|
|
41
|
-
import { probeHealth, type HealthProbeTarget } from "./health-sentinel-probe";
|
|
41
|
+
import { probeHealth, probeUrlHost, type HealthProbeTarget } from "./health-sentinel-probe";
|
|
42
42
|
|
|
43
43
|
export const HEALTH_SENTINEL_ENV = {
|
|
44
44
|
targetPid: "OCX_HEALTH_SENTINEL_TARGET_PID",
|
|
@@ -143,6 +143,11 @@ type Attempt =
|
|
|
143
143
|
| { kind: "gone" }
|
|
144
144
|
| { kind: "intent"; entry: RecoveryEntry };
|
|
145
145
|
|
|
146
|
+
function matchesProbeTarget(runtime: SentinelRuntimeRecord | null, pid: number, target: HealthProbeTarget): boolean {
|
|
147
|
+
return runtime !== null && runtime.pid === pid && !runtime.sibling
|
|
148
|
+
&& runtime.port === target.port && probeUrlHost(runtime.hostname) === target.hostname;
|
|
149
|
+
}
|
|
150
|
+
|
|
146
151
|
export async function runHealthSentinel(deps: HealthSentinelDeps): Promise<HealthSentinelResult> {
|
|
147
152
|
const timing: HealthSentinelTiming = { ...DEFAULT_HEALTH_SENTINEL_TIMING, ...deps.timing };
|
|
148
153
|
const standDown = (reason: string): HealthSentinelResult => {
|
|
@@ -162,12 +167,18 @@ export async function runHealthSentinel(deps: HealthSentinelDeps): Promise<Healt
|
|
|
162
167
|
recoveryHistory: history,
|
|
163
168
|
timing,
|
|
164
169
|
});
|
|
170
|
+
let pinnedTarget: HealthProbeTarget | null = null;
|
|
165
171
|
for (;;) {
|
|
166
172
|
if (deps.isTargetGone()) return { outcome: "target_exited" };
|
|
167
173
|
const runtime = deps.readRuntime();
|
|
168
174
|
if (!runtime || runtime.pid !== deps.targetPid) return standDown("the runtime record no longer names the watched process");
|
|
169
175
|
if (runtime.sibling) return standDown("the watched process is a sibling proxy");
|
|
170
|
-
const
|
|
176
|
+
const hostname = probeUrlHost(runtime.hostname);
|
|
177
|
+
if (hostname === null) return standDown("the recorded bind address is not numeric; DNS names cannot authorize recovery");
|
|
178
|
+
const target: HealthProbeTarget = pinnedTarget ?? { hostname, port: runtime.port };
|
|
179
|
+
if (!matchesProbeTarget(runtime, deps.targetPid, target)) return standDown("the recorded probe endpoint changed");
|
|
180
|
+
pinnedTarget = target;
|
|
181
|
+
const outcome = await raceGone(deps.probe(target, timing.probeTimeoutMs), exitSignal);
|
|
171
182
|
if (outcome === GONE) return { outcome: "target_exited" };
|
|
172
183
|
const wasArmed = policy.state().armed;
|
|
173
184
|
const decision = policy.observe(outcome);
|
|
@@ -178,7 +189,7 @@ export async function runHealthSentinel(deps: HealthSentinelDeps): Promise<Healt
|
|
|
178
189
|
// Everything that could have changed during the probe is re-read under the lease.
|
|
179
190
|
if (deps.isTargetGone()) return { kind: "gone" };
|
|
180
191
|
const current = deps.readRuntime();
|
|
181
|
-
if (!current
|
|
192
|
+
if (!matchesProbeTarget(current, deps.targetPid, target)) {
|
|
182
193
|
return { kind: "stand_down", reason: "the runtime record changed while the target was unresponsive" };
|
|
183
194
|
}
|
|
184
195
|
// Persist first: an attempted recovery must count toward the cap, and on Windows this
|
|
@@ -202,7 +213,10 @@ export async function runHealthSentinel(deps: HealthSentinelDeps): Promise<Healt
|
|
|
202
213
|
const { entry } = attempt;
|
|
203
214
|
// The lease, history and log work above takes time. Re-check that the evidence is still
|
|
204
215
|
// fresh right before the irreversible step: a proxy that recovered meanwhile must not be
|
|
205
|
-
// killed on old observations. A clock that ran backwards makes the evidence stale too.
|
|
216
|
+
// killed on old observations. A clock that ran backwards makes the evidence stale too. The
|
|
217
|
+
// final runtime-record read is included before the freshness check so time spent on it
|
|
218
|
+
// cannot authorize termination on stale observations.
|
|
219
|
+
const finalRuntime = deps.readRuntime();
|
|
206
220
|
const evidenceAge = deps.now() - outcome.endedAt;
|
|
207
221
|
if (evidenceAge < 0 || evidenceAge > timing.maxEvidenceAgeMs || deps.isTargetGone()) {
|
|
208
222
|
settle({ ...entry, state: "skipped" });
|
|
@@ -211,6 +225,10 @@ export async function runHealthSentinel(deps: HealthSentinelDeps): Promise<Healt
|
|
|
211
225
|
if (await raceGone(deps.sleep(policy.nextDelayMs()), exitSignal) === GONE) return { outcome: "target_exited" };
|
|
212
226
|
continue;
|
|
213
227
|
}
|
|
228
|
+
if (!matchesProbeTarget(finalRuntime, deps.targetPid, target)) {
|
|
229
|
+
settle({ ...entry, state: "skipped" });
|
|
230
|
+
return standDown("the recorded probe endpoint changed before termination");
|
|
231
|
+
}
|
|
214
232
|
const result = deps.terminateTarget();
|
|
215
233
|
if (result === "not_target") {
|
|
216
234
|
settle({ ...entry, state: "skipped" });
|
|
@@ -329,6 +347,7 @@ export function readSentinelRuntime(path: string): SentinelRuntimeRecord | null
|
|
|
329
347
|
try {
|
|
330
348
|
const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
331
349
|
const { pid, port, hostname } = parsed;
|
|
350
|
+
if (hostname !== undefined && typeof hostname !== "string") return null;
|
|
332
351
|
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
333
352
|
if (typeof port !== "number" || !Number.isSafeInteger(port) || port <= 0 || port > 65_535) return null;
|
|
334
353
|
return { pid, port, ...(typeof hostname === "string" ? { hostname } : {}), sibling: parsed.siblingOfPort !== undefined };
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* response framing and an absolute deadline. Only the timing decides liveness; the body's `pid`
|
|
8
8
|
* lets the policy tell the target from another process that took the port.
|
|
9
9
|
*/
|
|
10
|
+
import { isIP } from "node:net";
|
|
11
|
+
|
|
12
|
+
import { isWildcardHostname } from "../codex/loopback-target";
|
|
10
13
|
import { directLocalHttpFetch } from "../server/direct-local-http";
|
|
11
14
|
import type { HealthProbeOutcome } from "./health-sentinel-policy";
|
|
12
15
|
|
|
@@ -17,12 +20,24 @@ export type HealthProbeIo = {
|
|
|
17
20
|
fetch?: (url: string, timeoutMs: number) => Promise<Response>;
|
|
18
21
|
};
|
|
19
22
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Numeric probe endpoint only; unknown DNS destinations cannot authorize recovery. Wildcard
|
|
25
|
+
* spellings use the server's own predicate (`*`, `0`, `00.0.0.000`, `::0`, ...), so every bind
|
|
26
|
+
* the server accepts as a wildcard is probed on loopback instead of standing the sentinel down.
|
|
27
|
+
*/
|
|
28
|
+
export function probeUrlHost(hostname: string | undefined): string | null {
|
|
29
|
+
let value = (hostname ?? "").trim();
|
|
30
|
+
if (isWildcardHostname(value)) return value.includes(":") ? "[::1]" : "127.0.0.1";
|
|
31
|
+
if (value.startsWith("[") || value.endsWith("]")) {
|
|
32
|
+
if (!value.startsWith("[") || !value.endsWith("]")) return null;
|
|
33
|
+
value = value.slice(1, -1);
|
|
34
|
+
if (isIP(value) !== 6) return null;
|
|
35
|
+
}
|
|
36
|
+
if (!value || value === "0.0.0.0" || /^localhost\.?$/i.test(value)) return "127.0.0.1";
|
|
37
|
+
if (isIP(value) === 4) return value;
|
|
38
|
+
if (isIP(value) !== 6 || value.includes("%")) return null;
|
|
39
|
+
const host = new URL(`http://[${value}]/`).hostname;
|
|
40
|
+
return host === "[::]" ? "[::1]" : host;
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
/** `pid` from a `/healthz` JSON body, or null when the body does not carry one. */
|
|
@@ -39,8 +54,10 @@ export async function probeHealth(target: HealthProbeTarget, timeoutMs: number,
|
|
|
39
54
|
const now = io.now ?? Date.now;
|
|
40
55
|
const fetchFn = io.fetch ?? ((url: string, deadlineMs: number) => directLocalHttpFetch(url, { headers: { accept: "application/json" } }, { timeoutMs: deadlineMs }));
|
|
41
56
|
const startedAt = now();
|
|
57
|
+
const hostname = probeUrlHost(target.hostname);
|
|
58
|
+
if (hostname === null) return { kind: "error", startedAt, endedAt: now() };
|
|
42
59
|
try {
|
|
43
|
-
const response = await fetchFn(`http://${
|
|
60
|
+
const response = await fetchFn(`http://${hostname}:${target.port}/healthz`, timeoutMs);
|
|
44
61
|
// The framed body is already in memory; reading it cannot wait on the target.
|
|
45
62
|
const body = await response.text().catch(() => "");
|
|
46
63
|
return { kind: "answered", pid: healthzPid(body), startedAt, endedAt: now() };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { closeSync, fstatSync, ftruncateSync, openSync, readSync, renameSync, writeSync } from "node:fs";
|
|
2
|
+
import { formatWithOptions } from "node:util";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
5
|
+
|
|
6
|
+
export const SERVICE_LOG_MAX_BYTES = 20 * 1024 * 1024;
|
|
7
|
+
export const SERVICE_LOG_CHECK_INTERVAL_MS = 60_000;
|
|
8
|
+
|
|
9
|
+
/** Preserve the manager's open append descriptor; retain at most two bounded archives. */
|
|
10
|
+
export function rotateServiceLog(path: string, maxBytes = SERVICE_LOG_MAX_BYTES): boolean {
|
|
11
|
+
let source: number | undefined;
|
|
12
|
+
let archive: number | undefined;
|
|
13
|
+
try {
|
|
14
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) return false;
|
|
15
|
+
source = openSync(path, "r+");
|
|
16
|
+
const size = fstatSync(source).size;
|
|
17
|
+
if (size <= maxBytes) return false;
|
|
18
|
+
try { renameSync(`${path}.1`, `${path}.2`); } catch (error) {
|
|
19
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false;
|
|
20
|
+
}
|
|
21
|
+
archive = openSync(`${path}.1`, "w", 0o600);
|
|
22
|
+
// A legacy unbounded file may be huge. Copy only its newest cap-sized tail,
|
|
23
|
+
// using fixed memory, before truncating the original inode.
|
|
24
|
+
const buffer = Buffer.alloc(Math.min(64 * 1024, maxBytes));
|
|
25
|
+
let position = size - maxBytes;
|
|
26
|
+
while (position < size) {
|
|
27
|
+
const read = readSync(source, buffer, 0, Math.min(buffer.length, size - position), position);
|
|
28
|
+
if (read === 0) return false;
|
|
29
|
+
let written = 0;
|
|
30
|
+
while (written < read) {
|
|
31
|
+
const count = writeSync(archive, buffer, written, read - written);
|
|
32
|
+
if (count === 0) return false;
|
|
33
|
+
written += count;
|
|
34
|
+
}
|
|
35
|
+
position += read;
|
|
36
|
+
}
|
|
37
|
+
ftruncateSync(source, 0);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false; // Missing files, permissions and disk failures must never stop the service.
|
|
41
|
+
} finally {
|
|
42
|
+
for (const fd of [archive, source]) {
|
|
43
|
+
if (fd !== undefined) try { closeSync(fd); } catch { /* best-effort */ }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One sink wrapper covers startup, provider diagnostics and multiline errors. */
|
|
49
|
+
export function startServiceLogging(
|
|
50
|
+
path: string,
|
|
51
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
52
|
+
intervalMs = SERVICE_LOG_CHECK_INTERVAL_MS,
|
|
53
|
+
): () => void {
|
|
54
|
+
if (!env.OCX_SERVICE) return () => {};
|
|
55
|
+
const restore: (() => void)[] = [];
|
|
56
|
+
for (const method of ["log", "info", "debug", "warn", "error"] as const) {
|
|
57
|
+
const original = console[method];
|
|
58
|
+
console[method] = (...args: unknown[]) => {
|
|
59
|
+
const at = new Date().toISOString();
|
|
60
|
+
const line = formatWithOptions({ colors: false }, ...args);
|
|
61
|
+
original.call(console, line.split("\n").map(part => `${at} ${part}`).join("\n"));
|
|
62
|
+
};
|
|
63
|
+
restore.push(() => { console[method] = original; });
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
for (const suffix of [".1", ".2"]) recordOwnedConfigPath(dirname(path), `${path}${suffix}`);
|
|
67
|
+
} catch { /* ownership bookkeeping must not break logging */ }
|
|
68
|
+
rotateServiceLog(path);
|
|
69
|
+
const timer = setInterval(() => { rotateServiceLog(path); }, intervalMs);
|
|
70
|
+
timer.unref();
|
|
71
|
+
return () => { clearInterval(timer); for (const reset of restore) reset(); };
|
|
72
|
+
}
|
|
@@ -201,14 +201,10 @@ function Start-StartupHealthProbe {
|
|
|
201
201
|
if ($null -ne $script:startupProbeProcess) {
|
|
202
202
|
# Process.Start succeeded but the async pipe setup failed: Dispose alone
|
|
203
203
|
# would leave the Bun child running, so terminate it before cleanup.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
} catch {
|
|
208
|
-
Write-ActionLog "startup-health probe launch cleanup failed: $($_.Exception.GetType().Name)"
|
|
209
|
-
}
|
|
204
|
+
Stop-StartupHealthProbe
|
|
205
|
+
} else {
|
|
206
|
+
Complete-StartupHealthProbe
|
|
210
207
|
}
|
|
211
|
-
Complete-StartupHealthProbe
|
|
212
208
|
}
|
|
213
209
|
}
|
|
214
210
|
|
|
@@ -217,8 +213,11 @@ function Complete-StartupHealthProbe {
|
|
|
217
213
|
$script:startupProbeProcess = $null
|
|
218
214
|
$script:startupProbeOutputTask = $null
|
|
219
215
|
$script:startupProbeErrorTask = $null
|
|
216
|
+
$script:startupProbeTerminating = $false
|
|
220
217
|
if ($null -ne $process) {
|
|
221
218
|
try {
|
|
219
|
+
$process.StandardOutput.Dispose()
|
|
220
|
+
$process.StandardError.Dispose()
|
|
222
221
|
$process.Dispose()
|
|
223
222
|
} catch {
|
|
224
223
|
Write-ActionLog "startup-health probe dispose failed: $($_.Exception.GetType().Name)"
|
|
@@ -226,6 +225,31 @@ function Complete-StartupHealthProbe {
|
|
|
226
225
|
}
|
|
227
226
|
}
|
|
228
227
|
|
|
228
|
+
function Stop-StartupHealthProbe([switch]$Shutdown) {
|
|
229
|
+
if ($null -eq $script:startupProbeProcess) { return }
|
|
230
|
+
$script:startupProbeTerminating = $true
|
|
231
|
+
try {
|
|
232
|
+
if (-not $script:startupProbeProcess.HasExited) { $script:startupProbeProcess.Kill() }
|
|
233
|
+
} catch { Write-ActionLog "startup-health probe kill failed: $($_.Exception.GetType().Name)" }
|
|
234
|
+
if ($Shutdown) {
|
|
235
|
+
# Shutdown alone may wait. Timeout ticks keep the child tracked until actual exit.
|
|
236
|
+
try { [void]$script:startupProbeProcess.WaitForExit(500) } catch { $null = $_ }
|
|
237
|
+
$exited = $false
|
|
238
|
+
try { $exited = $script:startupProbeProcess.HasExited } catch { $null = $_ }
|
|
239
|
+
if ($exited) {
|
|
240
|
+
# Exit does not mean both pipe readers finished; give them the same bounded wait.
|
|
241
|
+
$readers = @($script:startupProbeOutputTask, $script:startupProbeErrorTask) | Where-Object { $null -ne $_ }
|
|
242
|
+
if ($readers.Count -gt 0) {
|
|
243
|
+
try { [void][System.Threading.Tasks.Task]::WaitAll([System.Threading.Tasks.Task[]]$readers, 500) } catch { $null = $_ }
|
|
244
|
+
}
|
|
245
|
+
Complete-StartupHealthProbe
|
|
246
|
+
} else {
|
|
247
|
+
# Releasing the only reference would orphan a live child; keep it tracked.
|
|
248
|
+
Write-ActionLog "startup-health probe still running at shutdown; leaving it tracked"
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
229
253
|
function Initialize-UpdateBadgeReader {
|
|
230
254
|
if (([System.Management.Automation.PSTypeName]"TrayUpdateBadgeReader").Type) { return }
|
|
231
255
|
Add-Type -TypeDefinition @'
|
|
@@ -433,6 +457,7 @@ $script:startupRefreshMs = 20000L
|
|
|
433
457
|
$script:startupProbeProcess = $null
|
|
434
458
|
$script:startupProbeOutputTask = $null
|
|
435
459
|
$script:startupProbeErrorTask = $null
|
|
460
|
+
$script:startupProbeTerminating = $false
|
|
436
461
|
$script:startupProbeStarted = 0L
|
|
437
462
|
$script:startupProbeTimeoutMs = 30000L
|
|
438
463
|
$script:updateAvailable = $false
|
|
@@ -520,10 +545,16 @@ function Update-TrayState {
|
|
|
520
545
|
try {
|
|
521
546
|
$probeExited = $script:startupProbeProcess.HasExited
|
|
522
547
|
} catch {
|
|
523
|
-
|
|
548
|
+
# Failed inspection is not proof of exit; keep ownership and request termination.
|
|
549
|
+
Stop-StartupHealthProbe
|
|
524
550
|
}
|
|
525
551
|
$probeTimedOut = ($now - $script:startupProbeStarted) -gt $script:startupProbeTimeoutMs
|
|
526
|
-
|
|
552
|
+
$probeDrained = ($null -eq $script:startupProbeOutputTask -or $script:startupProbeOutputTask.IsCompleted) -and
|
|
553
|
+
($null -eq $script:startupProbeErrorTask -or $script:startupProbeErrorTask.IsCompleted)
|
|
554
|
+
if ($script:startupProbeTerminating) {
|
|
555
|
+
if ($probeExited -and $probeDrained) { Complete-StartupHealthProbe }
|
|
556
|
+
elseif (-not $probeExited) { Stop-StartupHealthProbe }
|
|
557
|
+
} elseif ($probeExited -and $probeDrained -and $null -ne $script:startupProbeOutputTask) {
|
|
527
558
|
$probeStartup = $null
|
|
528
559
|
try {
|
|
529
560
|
$probeStartup = Parse-StartupHealthText $script:startupProbeOutputTask.Result
|
|
@@ -537,14 +568,8 @@ function Update-TrayState {
|
|
|
537
568
|
# A hung diagnostic must not survive its refresh slot: terminate it so the
|
|
538
569
|
# next refresh cannot stack another orphaned Bun process behind it.
|
|
539
570
|
Write-ActionLog "startup-health probe timed out; terminating it"
|
|
540
|
-
|
|
541
|
-
$script:startupProbeProcess.Kill()
|
|
542
|
-
[void]$script:startupProbeProcess.WaitForExit(3000)
|
|
543
|
-
} catch {
|
|
544
|
-
Write-ActionLog "startup-health probe kill failed: $($_.Exception.GetType().Name)"
|
|
545
|
-
}
|
|
571
|
+
Stop-StartupHealthProbe
|
|
546
572
|
$script:startupHealth = $null
|
|
547
|
-
Complete-StartupHealthProbe
|
|
548
573
|
$script:startupHealthCheckedAt = $now
|
|
549
574
|
}
|
|
550
575
|
}
|
|
@@ -710,14 +735,8 @@ try {
|
|
|
710
735
|
# A probe still running when the tray shuts down would outlive it: Dispose()
|
|
711
736
|
# releases the handle but does not terminate the child, so kill the active
|
|
712
737
|
# probe, wait briefly for exit, then release the probe state.
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
$script:startupProbeProcess.Kill()
|
|
716
|
-
[void]$script:startupProbeProcess.WaitForExit(3000)
|
|
717
|
-
} catch {
|
|
718
|
-
Write-ActionLog "startup-health probe shutdown kill failed: $($_.Exception.GetType().Name)"
|
|
719
|
-
}
|
|
720
|
-
Complete-StartupHealthProbe
|
|
738
|
+
Write-ActionLog "terminating active startup-health probe on tray shutdown"
|
|
739
|
+
Stop-StartupHealthProbe -Shutdown
|
|
721
740
|
}
|
|
722
741
|
Stop-UpdateBadgeProbe -Shutdown
|
|
723
742
|
$notify.Dispose()
|