@bitkyc08/opencodex 2.89.0 → 2.91.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/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/App-nZ34cR5G.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
- package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +28 -2
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google.ts +28 -9
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +7 -2
- package/src/adapters/openai-responses/request-strips.ts +32 -0
- package/src/adapters/openai-responses/transport.ts +19 -1
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +36 -4
- package/src/chat/reasoning-details.ts +137 -0
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/cli/capabilities-agents-routing.ts +50 -0
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -2
- package/src/cli/link-apply-restart.ts +72 -0
- package/src/cli/link-child.ts +6 -2
- package/src/cli/link-cleanup.ts +95 -0
- package/src/cli/link-enrollment.ts +4 -0
- package/src/cli/link.ts +84 -9
- package/src/cli/provider.ts +44 -2
- package/src/cli/registry.ts +13 -1
- package/src/cli/restart-failure.ts +4 -0
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/status-codex-settings.ts +56 -0
- package/src/cli/status-fallback.ts +29 -0
- package/src/cli/status-install.ts +52 -0
- package/src/cli/status.ts +20 -1
- package/src/cli/system-restart-client.ts +32 -1
- package/src/cli/update-restart-child.ts +4 -3
- package/src/cli/update-restart-home.ts +20 -8
- package/src/client/connect.ts +82 -6
- package/src/client/home-unreachable.ts +16 -0
- package/src/client/hub-relay.ts +8 -2
- package/src/client/link-home.ts +119 -6
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-relay-transport.ts +8 -1
- package/src/client/link-relay.ts +27 -3
- package/src/client/machine-listener.ts +83 -1
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +137 -25
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-store.ts +27 -8
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +10 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +58 -12
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/codex/subagent-model-fallback.ts +24 -1
- package/src/combos/failover.ts +8 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/provider-validation.ts +7 -2
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/generated/compatibility-version.json +360 -192
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/link/child-parity.ts +124 -0
- package/src/link/codex-settings.ts +37 -0
- package/src/link/compatibility.ts +119 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +24 -2
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +1 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +2 -1
- package/src/protocols/codecs/chat.ts +3 -3
- package/src/protocols/encoders/adapter-events.ts +26 -2
- package/src/protocols/encoders/chat.ts +55 -3
- package/src/providers/claude-cli-usage.ts +20 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +10 -2
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +240 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota/anthropic-account-quota.ts +18 -6
- package/src/providers/quota/antigravity.ts +8 -10
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/vendor-probes-oauth.ts +12 -3
- package/src/providers/quota-routing-cache.ts +1 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry/entries-extended.ts +6 -1
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/quota-pool/assemble.ts +326 -0
- package/src/quota-pool/contract.ts +58 -0
- package/src/quota-pool/identity.ts +203 -0
- package/src/remote/features.ts +74 -0
- package/src/remote/protocol.ts +2 -0
- package/src/remote/served-by.ts +57 -0
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +11 -2
- package/src/routing/fallback-policy.ts +290 -0
- package/src/routing/fallback-state.ts +179 -0
- package/src/server/audio-client.ts +2 -1
- package/src/server/audio-dictation.ts +2 -1
- package/src/server/audio-upstream.ts +2 -1
- package/src/server/auth-cors.ts +6 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +8 -1
- package/src/server/chat-native.ts +8 -7
- package/src/server/gui-session.ts +2 -1
- package/src/server/index/link-listener.ts +11 -1
- package/src/server/index/link-management.ts +47 -0
- package/src/server/index/link-relay-sessions.ts +11 -2
- package/src/server/index/optional-listeners.ts +7 -2
- package/src/server/index/serve-options.ts +27 -4
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/live.ts +5 -0
- package/src/server/management/context.ts +1 -1
- package/src/server/management/fallback-routes.ts +75 -0
- package/src/server/management/link-enrollment.ts +21 -3
- package/src/server/management/link-route-state.ts +2 -0
- package/src/server/management/link-routes.ts +12 -1
- package/src/server/management/oauth-account-routes.ts +2 -1
- package/src/server/management/quota-pool-routes.ts +24 -0
- package/src/server/management/route-registry.ts +3 -0
- package/src/server/management-api.ts +16 -0
- package/src/server/management-auth.ts +2 -1
- package/src/server/messages-native.ts +11 -0
- package/src/server/request-log.ts +16 -7
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +2 -2
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-stream-preflight.ts +7 -2
- package/src/server/responses/compact.ts +4 -2
- package/src/server/responses/core-auth.ts +31 -1
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +75 -1
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +8 -0
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +85 -8
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +2 -0
- package/src/server/responses/passthrough-dispatch.ts +13 -13
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +19 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/diagnostics.ts +20 -6
- package/src/service/orchestration.ts +17 -0
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +7 -0
- package/src/types/fallback.ts +38 -0
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +16 -1
- package/src/types.ts +1 -0
- package/src/web-search/loop.ts +2 -2
- package/gui/dist/assets/App-BnRSujOq.js +0 -58
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
package/src/claude/desktop-3p.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { canonicalDirectory } from "./desktop-remote-store-io";
|
|
|
6
6
|
import {
|
|
7
7
|
resolveDesktop3pConfigLibraryPath, parseMetadata, SAFE_DESKTOP_PROFILE_ID, isRecord,
|
|
8
8
|
isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, profilePath, readDesktopProfileSnapshot, readDesktopLibraryText, readDesktopLibraryBytes,
|
|
9
|
-
readOptionalDesktopLibraryBytes,
|
|
9
|
+
readOptionalDesktopLibraryBytes, assertDesktopLibraryTextSize, serializeDesktopLibraryJson,
|
|
10
10
|
type Desktop3pConfigLibraryOptions, type Desktop3pMetadata, type Desktop3pMetadataEntry,
|
|
11
11
|
} from "./desktop-3p-library";
|
|
12
12
|
export { resolveDesktop3pConfigLibraryPath, type Desktop3pConfigLibraryOptions } from "./desktop-3p-library";
|
|
@@ -642,13 +642,24 @@ function removeDesktop3pStandardPivotLocal(
|
|
|
642
642
|
if (targetIds.length === 0) return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath };
|
|
643
643
|
|
|
644
644
|
let metadataAfterPivot = metadata;
|
|
645
|
+
let standardPath: string | undefined;
|
|
646
|
+
let pivotMetadataJson: string | undefined;
|
|
645
647
|
if (selectedOwnedGatewayActive) {
|
|
646
648
|
const standardId = randomUUID();
|
|
647
|
-
|
|
648
|
-
atomicWriteFile(standardPath, "{}\n");
|
|
649
|
+
standardPath = profilePath(inspected.libraryPath, standardId);
|
|
649
650
|
const standardEntry: Desktop3pMetadataEntry = { id: standardId, name: "opencodex-standard" };
|
|
650
651
|
metadataAfterPivot = { ...metadata, appliedId: standardId, entries: [...metadata.entries, standardEntry] };
|
|
651
|
-
|
|
652
|
+
pivotMetadataJson = serializeDesktopLibraryJson(metadataAfterPivot);
|
|
653
|
+
}
|
|
654
|
+
// Bound the final metadata before any unlink so a size refusal cannot strand
|
|
655
|
+
// a row pointing at an already-deleted profile.
|
|
656
|
+
const removalMetadataJson = serializeDesktopLibraryJson({
|
|
657
|
+
...metadataAfterPivot,
|
|
658
|
+
entries: metadataAfterPivot.entries.filter(entry => !targetIds.includes(entry.id)),
|
|
659
|
+
});
|
|
660
|
+
if (standardPath !== undefined && pivotMetadataJson !== undefined) {
|
|
661
|
+
atomicWriteFile(standardPath, "{}\n");
|
|
662
|
+
atomicWriteFile(metadataPath, pivotMetadataJson);
|
|
652
663
|
}
|
|
653
664
|
|
|
654
665
|
const residualPaths: string[] = [];
|
|
@@ -674,10 +685,7 @@ function removeDesktop3pStandardPivotLocal(
|
|
|
674
685
|
}
|
|
675
686
|
// Do not leave a metadata row pointing at a deleted profile. For a foreign
|
|
676
687
|
// selection this only removes proven opencodex residues; appliedId is kept.
|
|
677
|
-
atomicWriteFile(
|
|
678
|
-
metadataPath,
|
|
679
|
-
JSON.stringify({ ...metadataAfterPivot, entries: metadataAfterPivot.entries.filter(entry => !targetIds.includes(entry.id)) }, null, 2) + "\n",
|
|
680
|
-
);
|
|
688
|
+
atomicWriteFile(metadataPath, removalMetadataJson);
|
|
681
689
|
return { ok: true, changed: true, kind: "removed", libraryPath: inspected.libraryPath };
|
|
682
690
|
} catch (error) {
|
|
683
691
|
return { ok: false, changed: false, kind: "write_failed", libraryPath: inspected.libraryPath, reason: desktopMutationFailureReason(error) };
|
|
@@ -831,7 +839,7 @@ function writeDesktop3pConfigWithGenerator(
|
|
|
831
839
|
|
|
832
840
|
const generated = generate();
|
|
833
841
|
const previous = readDesktopProfileSnapshot(configPath);
|
|
834
|
-
const configJson =
|
|
842
|
+
const configJson = serializeDesktopLibraryJson({ ...previous.foreign, ...generated });
|
|
835
843
|
const fingerprint = createHash("sha256").update(configJson).digest("hex").slice(0, 16);
|
|
836
844
|
if (refreshOnly) {
|
|
837
845
|
atomicWriteFileNoFollow(configPath, configJson, undefined, {
|
|
@@ -849,9 +857,10 @@ function writeDesktop3pConfigWithGenerator(
|
|
|
849
857
|
});
|
|
850
858
|
return { written: true, path: configPath, fingerprint };
|
|
851
859
|
}
|
|
860
|
+
const metadataJson = serializeDesktopLibraryJson({ ...metadata, appliedId: id, entries });
|
|
852
861
|
atomicReplaceDesktopConfig(configPath, configJson, previous.bytes);
|
|
853
862
|
try {
|
|
854
|
-
atomicWriteFile(metadataPath,
|
|
863
|
+
atomicWriteFile(metadataPath, metadataJson);
|
|
855
864
|
} catch (metaError) {
|
|
856
865
|
// Roll back from the verified bytes in memory: reopening the backup could meet a swapped
|
|
857
866
|
// entry and leave the new profile beside the old metadata. A profile this call created is
|
|
@@ -895,6 +904,7 @@ export function atomicReplaceDesktopConfig(
|
|
|
895
904
|
previous: Buffer | undefined,
|
|
896
905
|
writer: typeof atomicWriteFileNoFollow = atomicWriteFileNoFollow,
|
|
897
906
|
): { backupPath?: string } {
|
|
907
|
+
assertDesktopLibraryTextSize(content);
|
|
898
908
|
const validateBeforeRename = () => assertDesktopProfileUnchanged(path, previous);
|
|
899
909
|
validateBeforeRename();
|
|
900
910
|
const backupPath = `${path}.bak`;
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
readDesktopLibraryText,
|
|
20
20
|
resolveDesktop3pConfigLibraryPath,
|
|
21
21
|
SAFE_DESKTOP_PROFILE_ID,
|
|
22
|
+
serializeDesktopLibraryJson,
|
|
22
23
|
type Desktop3pConfigLibraryOptions,
|
|
23
24
|
type Desktop3pMetadata,
|
|
24
25
|
type Desktop3pMetadataEntry,
|
|
@@ -48,7 +49,7 @@ function metadataPath(libraryPath: string): string {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
function metadataJson(metadata: Desktop3pMetadata): string {
|
|
51
|
-
return
|
|
52
|
+
return serializeDesktopLibraryJson(metadata);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
function isValidMetadata(metadata: Desktop3pMetadata): boolean {
|
|
@@ -190,6 +191,10 @@ function applyDesktopPickerProfileLocked(options: { proxyPort: number } & Deskto
|
|
|
190
191
|
const changed = !alreadySelected || !oldProfile.exists || oldProfile.content !== target;
|
|
191
192
|
if (!changed) return { ok: true, changed: false, path: profile };
|
|
192
193
|
|
|
194
|
+
const entries = existing
|
|
195
|
+
? metadata.entries.map(entry => entry.id === entryId ? { ...entry, name: DESKTOP_PICKER_ENTRY_NAME } : entry)
|
|
196
|
+
: [...metadata.entries, { id: entryId, name: DESKTOP_PICKER_ENTRY_NAME }];
|
|
197
|
+
const nextMetadataJson = metadataJson({ ...metadata, appliedId: entryId, entries });
|
|
193
198
|
mkdirSync(join(configDir, PICKER_DIRECTORY), { recursive: true, mode: 0o700 });
|
|
194
199
|
try {
|
|
195
200
|
// Keep the same backup convention as the existing Desktop writer. It is removed with the
|
|
@@ -199,10 +204,7 @@ function applyDesktopPickerProfileLocked(options: { proxyPort: number } & Deskto
|
|
|
199
204
|
if (!alreadySelected) {
|
|
200
205
|
atomicWriteFile(previousStatePath, JSON.stringify({ entryId, previousAppliedId }) + "\n");
|
|
201
206
|
}
|
|
202
|
-
|
|
203
|
-
? metadata.entries.map(entry => entry.id === entryId ? { ...entry, name: DESKTOP_PICKER_ENTRY_NAME } : entry)
|
|
204
|
-
: [...metadata.entries, { id: entryId, name: DESKTOP_PICKER_ENTRY_NAME }];
|
|
205
|
-
atomicWriteFile(metaPath, metadataJson({ ...metadata, appliedId: entryId, entries }));
|
|
207
|
+
atomicWriteFile(metaPath, nextMetadataJson);
|
|
206
208
|
} catch {
|
|
207
209
|
try {
|
|
208
210
|
restoreFile(metaPath, oldMeta);
|
|
@@ -246,6 +248,8 @@ function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions):
|
|
|
246
248
|
try { state = readPickerState(statePath); } catch { return { ok: false, reason: "profile_state_unreadable" }; }
|
|
247
249
|
|
|
248
250
|
let metadataAfterPivot = metadata;
|
|
251
|
+
let standardPath: string | undefined;
|
|
252
|
+
let pivotMetadataJson: string | undefined;
|
|
249
253
|
if (selected) {
|
|
250
254
|
const previous = state?.entryId === picker.id ? state.previousAppliedId : null;
|
|
251
255
|
const previousExists = previous !== null && previous !== picker.id && metadata.entries.some(entry => entry.id === previous);
|
|
@@ -253,16 +257,22 @@ function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions):
|
|
|
253
257
|
metadataAfterPivot = { ...metadata, appliedId: previous };
|
|
254
258
|
} else {
|
|
255
259
|
const standardId = randomUUID();
|
|
256
|
-
|
|
260
|
+
standardPath = profilePath(libraryPath, standardId);
|
|
257
261
|
metadataAfterPivot = {
|
|
258
262
|
...metadata,
|
|
259
263
|
appliedId: standardId,
|
|
260
264
|
entries: [...metadata.entries, { id: standardId, name: "opencodex-standard" }],
|
|
261
265
|
};
|
|
262
266
|
}
|
|
263
|
-
|
|
267
|
+
pivotMetadataJson = metadataJson(metadataAfterPivot);
|
|
264
268
|
}
|
|
265
269
|
|
|
270
|
+
const entries = metadataAfterPivot.entries.filter(entry => entry.id !== picker.id);
|
|
271
|
+
const finalMetadataJson = metadataJson({ ...metadataAfterPivot, entries });
|
|
272
|
+
|
|
273
|
+
if (standardPath !== undefined) atomicWriteFile(standardPath, "{}\n");
|
|
274
|
+
if (pivotMetadataJson !== undefined) atomicWriteFile(metaPath, pivotMetadataJson);
|
|
275
|
+
|
|
266
276
|
const residualPaths: string[] = [];
|
|
267
277
|
for (const path of [profilePath(libraryPath, picker.id), `${profilePath(libraryPath, picker.id)}.bak`]) {
|
|
268
278
|
try { unlinkIfPresent(path); } catch { /* report the path without exposing file contents */ }
|
|
@@ -272,9 +282,8 @@ function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions):
|
|
|
272
282
|
return { ok: false, reason: "cleanup_incomplete", residualPaths };
|
|
273
283
|
}
|
|
274
284
|
|
|
275
|
-
const entries = metadataAfterPivot.entries.filter(entry => entry.id !== picker.id);
|
|
276
285
|
try {
|
|
277
|
-
atomicWriteFile(metaPath,
|
|
286
|
+
atomicWriteFile(metaPath, finalMetadataJson);
|
|
278
287
|
try { unlinkIfPresent(statePath); } catch { /* residual is reported below */ }
|
|
279
288
|
if (existsSync(statePath)) return { ok: false, reason: "cleanup_incomplete", residualPaths: [statePath] };
|
|
280
289
|
} catch {
|
|
@@ -2,7 +2,7 @@ import { chmodSync, lstatSync, mkdirSync, realpathSync, unlinkSync } from "node:
|
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import { atomicWriteFile, getConfigDir } from "../config";
|
|
4
4
|
import { hardenSecretDir } from "../lib/windows-secret-acl";
|
|
5
|
-
import { isRecord, readDesktopLibraryBytes, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pMetadata } from "./desktop-3p-library";
|
|
5
|
+
import { isRecord, MAX_DESKTOP_PROFILE_FILE_BYTES, readDesktopLibraryBytes, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pMetadata } from "./desktop-3p-library";
|
|
6
6
|
import { DesktopStoreError, digest } from "./desktop-remote-store-state";
|
|
7
7
|
|
|
8
8
|
export const MAX_DESKTOP_METADATA_ENTRIES = 256;
|
|
@@ -45,7 +45,7 @@ export class StoreIO {
|
|
|
45
45
|
try {
|
|
46
46
|
// The shared verified regular-file read keeps a swapped FIFO from parking this
|
|
47
47
|
// open before the post-read identity recheck below can run.
|
|
48
|
-
const bytes = readDesktopLibraryBytes(path);
|
|
48
|
+
const bytes = readDesktopLibraryBytes(path, Math.min(maxBytes, MAX_DESKTOP_PROFILE_FILE_BYTES));
|
|
49
49
|
if (bytes.byteLength > maxBytes || identity(lstatSync(path)) !== identity(stat)) throw new DesktopStoreError("conflict");
|
|
50
50
|
const value: unknown = JSON.parse(bytes.toString("utf8"));
|
|
51
51
|
if (!isRecord(value)) throw new DesktopStoreError("unsafe");
|
|
@@ -203,6 +203,56 @@ export const AGENT_ROUTING_CAPABILITIES: readonly Capability[] = [
|
|
|
203
203
|
json: "payload",
|
|
204
204
|
details: ["Without --list at least one setting is required. Stored settings and Codex application outcomes remain distinct.","Stream-routed-output belongs only to web. Timeout changes preserve siblings; native apply and saved state are distinct."],
|
|
205
205
|
},
|
|
206
|
+
{
|
|
207
|
+
command: ["fallback"],
|
|
208
|
+
usage: "ocx fallback [status] [--json]",
|
|
209
|
+
summary: "Show which model and provider fallbacks are allowed, per-device local fallback, and the last fallback taken or refused.",
|
|
210
|
+
routes: [{"method": "GET", "path": "/api/fallback"}],
|
|
211
|
+
flags: [{"name": "--json", "value": "boolean", "summary": "Emit the ocx.fallback/1 state."}],
|
|
212
|
+
mutates: false,
|
|
213
|
+
json: "payload",
|
|
214
|
+
details: ["Reads the live proxy. On a Remote Link Child it reads the Home's state over the link (aliases omitted). The last event is in memory and resets on restart."],
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
command: ["fallback", "off"],
|
|
218
|
+
usage: "ocx fallback off [--kind model|provider|all] [--provider <name> | --route <selector> | --device <id|alias>] [--json]",
|
|
219
|
+
summary: "Refuse model or provider fallback globally or for one provider, route or device.",
|
|
220
|
+
routes: [{"method": "PUT", "path": "/api/fallback"}],
|
|
221
|
+
flags: [{"name": "--kind", "value": "string", "summary": "model, provider or all (default)."}, {"name": "--provider", "value": "string", "summary": "Scope to the provider a request started on."}, {"name": "--route", "value": "string", "summary": "Scope to an exact selector such as combo/<id> or policy/<id>."}, {"name": "--device", "value": "string", "summary": "Scope to a Remote Link id or alias, or a Hub data key id; device off always refuses."}, {"name": "--json", "value": "boolean", "summary": "Emit the resulting state."}],
|
|
222
|
+
mutates: true,
|
|
223
|
+
json: "payload",
|
|
224
|
+
details: ["A refused hop answers fallback_refused with the triggering failure's status. Rotation among accounts or keys of one provider pool is not a fallback and stays on."],
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
command: ["fallback", "on"],
|
|
228
|
+
usage: "ocx fallback on [--kind model|provider|all] [--provider <name> | --route <selector> | --device <id|alias>] [--json]",
|
|
229
|
+
summary: "Allow model or provider fallback at one scope, overriding a broader off.",
|
|
230
|
+
routes: [{"method": "PUT", "path": "/api/fallback"}],
|
|
231
|
+
flags: [{"name": "--kind", "value": "string", "summary": "model, provider or all (default)."}, {"name": "--provider", "value": "string", "summary": "Scope to a provider."}, {"name": "--route", "value": "string", "summary": "Scope to an exact selector."}, {"name": "--device", "value": "string", "summary": "Scope to a device."}, {"name": "--json", "value": "boolean", "summary": "Emit the resulting state."}],
|
|
232
|
+
mutates: true,
|
|
233
|
+
json: "payload",
|
|
234
|
+
details: ["Route beats provider beats global. A device setting can only refuse: on for a device records the value but never reopens a hop another scope refuses."],
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
command: ["fallback", "clear"],
|
|
238
|
+
usage: "ocx fallback clear [--kind model|provider|all] [--provider <name> | --route <selector> | --device <id|alias>] [--json]",
|
|
239
|
+
summary: "Remove a fallback override so the scope inherits again.",
|
|
240
|
+
routes: [{"method": "PUT", "path": "/api/fallback"}],
|
|
241
|
+
flags: [{"name": "--kind", "value": "string", "summary": "model, provider or all (default)."}, {"name": "--provider", "value": "string", "summary": "Scope to a provider."}, {"name": "--route", "value": "string", "summary": "Scope to an exact selector."}, {"name": "--device", "value": "string", "summary": "Scope to a device."}, {"name": "--json", "value": "boolean", "summary": "Emit the resulting state."}],
|
|
242
|
+
mutates: true,
|
|
243
|
+
json: "payload",
|
|
244
|
+
details: ["Clearing every override restores today's behavior: every fallback allowed."],
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
command: ["fallback", "local"],
|
|
248
|
+
usage: "ocx fallback local <on|off> --device <id|alias> [--json]",
|
|
249
|
+
summary: "Record whether a device may use its own providers while the Home is unreachable.",
|
|
250
|
+
routes: [{"method": "PUT", "path": "/api/fallback"}],
|
|
251
|
+
flags: [{"name": "--device", "value": "string", "summary": "Remote Link id or alias, or Hub data key id."}, {"name": "--json", "value": "boolean", "summary": "Emit the resulting state."}],
|
|
252
|
+
mutates: true,
|
|
253
|
+
json: "payload",
|
|
254
|
+
details: ["Run on the Home. A permission GOAT reads; OpenCodex itself keeps answering home_unreachable on the device and never serves it from local providers."],
|
|
255
|
+
},
|
|
206
256
|
{
|
|
207
257
|
command: ["effort"],
|
|
208
258
|
usage: "ocx effort [status|low|medium|high|xhigh|max|ultra|->] [--json]",
|
|
@@ -86,6 +86,16 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
86
86
|
usage: "ocx link reconnect --link-id <id> [--json]",
|
|
87
87
|
details: ["Requires a valid link ID and loopback admin authority. Stops the current ssh child, clears any backoff or failed state and starts a fresh attempt; read ocx link status for the outcome.","A Child-initiated link is refused with link_not_home_initiated: its own computer owns and retries that tunnel.","Always emits {linkId} JSON."],
|
|
88
88
|
},
|
|
89
|
+
{
|
|
90
|
+
command: ["link","cleanup"],
|
|
91
|
+
summary: "Find Windows Remote Link Children and explain how to move them to Hub mode.",
|
|
92
|
+
routes: [],
|
|
93
|
+
flags: [{"name":"--link-id","value":"string","summary":"Check only this link."},{"name":"--json","value":"boolean","summary":"Emit {links, changed:false} as JSON."}],
|
|
94
|
+
mutates: false,
|
|
95
|
+
json: "payload",
|
|
96
|
+
usage: "ocx link cleanup [--link-id <id>] [--json]",
|
|
97
|
+
details: ["Runs on the Home from its local link store. Asks each Home-initiated Child for uname -s over the link's pinned SSH trust; a Windows answer (Git Bash, MSYS, Cygwin, cmd or PowerShell) is reported with recovery steps.","Never revokes, disconnects or edits a link. A Windows Child needs ocx disconnect on itself, then ocx link revoke --force --yes on the Home, then ocx hub invite and ocx connect."],
|
|
98
|
+
},
|
|
89
99
|
{
|
|
90
100
|
command: ["remote-workspace","pair"],
|
|
91
101
|
summary: "Enroll this executor with one Hub using a one-time code from stdin and locally approved roots.",
|
|
@@ -156,7 +166,7 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
156
166
|
flags: [{ name: "--json", value: "boolean", summary: "Emit the status envelope as JSON." }],
|
|
157
167
|
mutates: false,
|
|
158
168
|
json: "envelope",
|
|
159
|
-
details: ["Reads /healthz plus local config; drives no management API route."],
|
|
169
|
+
details: ["Reads /healthz plus local config; drives no management API route.", "--json includes install: {method: bun|npm|pnpm|mise|source|standalone|unknown, evidence: {basis, mise?, error?}} from the updater's install detector."],
|
|
160
170
|
},
|
|
161
171
|
{
|
|
162
172
|
command: ["resolve"],
|
|
@@ -225,7 +235,10 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
225
235
|
flags: [{"name":"--json","value":"boolean","summary":"Emit the provider list as JSON."},{"name":"--jsonl","value":"boolean","summary":"Emit one configured provider per JSON line."}],
|
|
226
236
|
mutates: false,
|
|
227
237
|
json: "envelope",
|
|
228
|
-
details: [
|
|
238
|
+
details: [
|
|
239
|
+
"Local config and registry read, not GET /api/providers. --json and --jsonl are mutually exclusive.",
|
|
240
|
+
"Each configured row carries active: false while this device is linked to a Home (a Remote Link Child or a Hub-mode client), whose providers serve instead; true when the row serves. Credential values are never printed.",
|
|
241
|
+
],
|
|
229
242
|
usage: "ocx provider list [--json|--jsonl]",
|
|
230
243
|
},
|
|
231
244
|
{
|
|
@@ -621,11 +621,11 @@ export const OBSERVE_SYSTEM_CAPABILITIES: readonly Capability[] = [
|
|
|
621
621
|
},
|
|
622
622
|
{
|
|
623
623
|
command: ["service", "status"], summary: "Inspect local service-manager status.",
|
|
624
|
-
usage: "ocx service status",
|
|
624
|
+
usage: "ocx service status [--json]",
|
|
625
625
|
routes: [],
|
|
626
|
-
flags: [],
|
|
627
|
-
mutates: false, json: "
|
|
628
|
-
details: ["Local OS service manager: macOS launchd, Linux systemd, Windows Task Scheduler or WinSW. No management API
|
|
626
|
+
flags: [{"name": "--json", "value": "boolean", "summary": "Emit the passive service status document as JSON."}],
|
|
627
|
+
mutates: false, json: "payload",
|
|
628
|
+
details: ["Local OS service manager: macOS launchd, Linux systemd, Windows Task Scheduler or WinSW. No management API.", "--json is passive: schemaVersion 1 with installed, running, enabled, viable, stale, conflict, backend (launchd|systemd|schtasks|winsw|none), label, serving {probed, ok, port}, summary, logPath, ownershipLease and desktopLogin. It starts, stops, installs and repairs nothing.", "Backend options apply only to install; other verbs use the installed backend."],
|
|
629
629
|
},
|
|
630
630
|
{
|
|
631
631
|
command: ["service", "uninstall"], summary: "Uninstall the local service.",
|
|
@@ -26,6 +26,11 @@ export function skipsCodexShimAutoRestore(command: string | undefined, args: str
|
|
|
26
26
|
// `resolve` is read-only inspection for embedding shells: a lookup made to populate
|
|
27
27
|
// a consent surface must not trigger a shim repair side effect first.
|
|
28
28
|
if (command === "resolve") return true;
|
|
29
|
+
// `service status --json` is a passive inventory read for peers such as GOAT: a shim repair
|
|
30
|
+
// before dispatch would change the installation it was asked only to describe.
|
|
31
|
+
// Flags may precede the verb (`ocx service --json status`), exactly as parseServiceArgs reads it.
|
|
32
|
+
if (command === "service" && args.includes("--json")
|
|
33
|
+
&& args.slice(1).find(arg => !arg.startsWith("--")) === "status") return true;
|
|
29
34
|
// The desktop's approval-bound stop must validate its snapshot before any unrelated
|
|
30
35
|
// CLI repair changes the installation it was approved against.
|
|
31
36
|
if (command === "stop" && args.some(arg => arg.startsWith("--expect-"))) return true;
|
package/src/cli/dispatch.ts
CHANGED
|
@@ -437,6 +437,32 @@ const commandRunners: Record<string, CommandRunner> = {
|
|
|
437
437
|
}
|
|
438
438
|
}
|
|
439
439
|
await handleConnectedSyncCatalogWrite(result, restartScope);
|
|
440
|
+
if (result.applyRecordFailed) {
|
|
441
|
+
console.error("The Home's Codex settings were applied, but the apply record could not be written; the client runtime was not restarted. Run 'ocx sync' again.");
|
|
442
|
+
process.exitCode = 1;
|
|
443
|
+
}
|
|
444
|
+
// OCX-6: a link Child's runtime must have started after the apply it serves under.
|
|
445
|
+
if (link && result.appliedRecord) {
|
|
446
|
+
const { applyRestartMessage, restartRuntimeAfterApply } = await import("./link-apply-restart");
|
|
447
|
+
const { sameClientConnectionOwner } = await import("../client/state");
|
|
448
|
+
const applied = clientState.value;
|
|
449
|
+
const outcome = await restartRuntimeAfterApply(result.appliedRecord, {
|
|
450
|
+
restart: startWhenStopped => deps.handleProxyRestart(startWhenStopped),
|
|
451
|
+
recoveryStart: recovering => deps.handleRestartStartWhenStopped(recovering),
|
|
452
|
+
connectionUnchanged: () => {
|
|
453
|
+
const current = readClientConnectionState();
|
|
454
|
+
return current.kind === "connected" && sameClientConnectionOwner(current.value, applied)
|
|
455
|
+
&& current.value.link?.linkId === applied.link?.linkId;
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
const message = applyRestartMessage(outcome);
|
|
459
|
+
if (outcome.kind === "failed" || outcome.kind === "connection-changed") {
|
|
460
|
+
console.error(message);
|
|
461
|
+
process.exitCode = 1;
|
|
462
|
+
} else {
|
|
463
|
+
console.log(message);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
440
466
|
// `process.exitCode` rather than a literal 0, for the same reason every other
|
|
441
467
|
// runner does it (tests/cli/cli-transport-honesty.test.ts): the catalog-write helper
|
|
442
468
|
// drives app-server restarts, and one of those recording a failure must not be
|
|
@@ -876,6 +902,10 @@ const commandRunners: Record<string, CommandRunner> = {
|
|
|
876
902
|
const { handleEffortCommand } = await import("./effort");
|
|
877
903
|
return await handleEffortCommand(deps.args.slice(1), { findLiveProxy: deps.findLiveProxy });
|
|
878
904
|
},
|
|
905
|
+
fallback: async deps => {
|
|
906
|
+
const { handleFallbackCommand } = await import("./fallback");
|
|
907
|
+
return await handleFallbackCommand(deps.args.slice(1), { findLiveProxy: deps.findLiveProxy });
|
|
908
|
+
},
|
|
879
909
|
agent: async deps => {
|
|
880
910
|
const { handleAgentCommand } = await import("./agent");
|
|
881
911
|
return await handleAgentCommand(deps.args.slice(1));
|
|
@@ -241,12 +241,12 @@ function consentedDesktopBundle(home: string, installId: string, run: Execute):
|
|
|
241
241
|
const plistPath = realpathSync(join(home, "Library", "LaunchAgents", `${DESKTOP_PLIST_LABEL}.plist`));
|
|
242
242
|
const registration = JSON.parse(
|
|
243
243
|
run("/usr/bin/plutil", ["-convert", "json", "-o", "-", plistPath], 3_000),
|
|
244
|
-
) as { Label?: unknown; ProgramArguments?: unknown; Program?: unknown };
|
|
244
|
+
) as { Label?: unknown; RunAtLoad?: unknown; ProgramArguments?: unknown; Program?: unknown };
|
|
245
245
|
const args = registration.ProgramArguments;
|
|
246
246
|
// The shapes auto-launch writes: [program] or [program, "--autostart"]; see the migration
|
|
247
247
|
// rules in desktop/src-tauri/src/login_registration.rs, which classify anything else Leave.
|
|
248
248
|
// This is a macOS plist path, regardless of the host running an injected launch probe.
|
|
249
|
-
if (registration.Label !== DESKTOP_PLIST_LABEL || !Array.isArray(args)
|
|
249
|
+
if (registration.Label !== DESKTOP_PLIST_LABEL || registration.RunAtLoad !== true || !Array.isArray(args)
|
|
250
250
|
|| (args.length !== 1 && args.length !== 2) || typeof args[0] !== "string" || !posix.isAbsolute(args[0])
|
|
251
251
|
|| (args.length === 2 && args[1] !== "--autostart")
|
|
252
252
|
|| (registration.Program !== undefined && registration.Program !== args[0])) return null;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CliUsageError,
|
|
3
|
+
printData,
|
|
4
|
+
rejectArgs,
|
|
5
|
+
runCliAction,
|
|
6
|
+
runtimeRequest,
|
|
7
|
+
takeFlag,
|
|
8
|
+
takeOption,
|
|
9
|
+
terminalSafeText,
|
|
10
|
+
type RuntimeApiDeps,
|
|
11
|
+
} from "./runtime-api";
|
|
12
|
+
|
|
13
|
+
export const FALLBACK_USAGE = `Usage:
|
|
14
|
+
ocx fallback status [--json]
|
|
15
|
+
ocx fallback off [--kind model|provider|all] [--provider <name> | --route <selector> | --device <id|alias>] [--json]
|
|
16
|
+
ocx fallback on [--kind model|provider|all] [--provider <name> | --route <selector> | --device <id|alias>] [--json]
|
|
17
|
+
ocx fallback clear [--kind model|provider|all] [--provider <name> | --route <selector> | --device <id|alias>] [--json]
|
|
18
|
+
ocx fallback local <on|off> --device <id|alias> [--json]`;
|
|
19
|
+
|
|
20
|
+
type Switches = { model?: boolean; provider?: boolean };
|
|
21
|
+
interface DeviceRow { id?: string; alias?: string; localFallback?: boolean; model?: boolean | null; provider?: boolean | null }
|
|
22
|
+
interface EventRow { at?: string; site?: string; route?: string; kind?: string; from?: string; to?: string; trigger?: string; outcome?: string; scope?: string }
|
|
23
|
+
interface FallbackState {
|
|
24
|
+
global?: { model?: boolean; provider?: boolean };
|
|
25
|
+
providers?: Record<string, Switches>;
|
|
26
|
+
routes?: Record<string, Switches>;
|
|
27
|
+
devices?: DeviceRow[];
|
|
28
|
+
last?: EventRow | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const onOff = (value: boolean | null | undefined): string => value === false ? "off" : value === true ? "on" : "inherit";
|
|
32
|
+
|
|
33
|
+
function switchesLine(label: string, value: Switches): string {
|
|
34
|
+
const parts: string[] = [];
|
|
35
|
+
if (value.model !== undefined) parts.push("model " + onOff(value.model));
|
|
36
|
+
if (value.provider !== undefined) parts.push("provider " + onOff(value.provider));
|
|
37
|
+
return terminalSafeText(label) + ": " + parts.join(", ");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function fallbackStatusLines(state: FallbackState): string[] {
|
|
41
|
+
const lines = [
|
|
42
|
+
"model fallback: " + (state.global?.model === false ? "off" : "on") + " (global)",
|
|
43
|
+
"provider fallback: " + (state.global?.provider === false ? "off" : "on") + " (global)",
|
|
44
|
+
];
|
|
45
|
+
for (const [name, value] of Object.entries(state.providers ?? {})) lines.push(switchesLine("provider " + name, value));
|
|
46
|
+
for (const [name, value] of Object.entries(state.routes ?? {})) lines.push(switchesLine("route " + name, value));
|
|
47
|
+
for (const device of state.devices ?? []) {
|
|
48
|
+
const label = "device " + (device.id ?? "?") + (device.alias ? " (" + device.alias + ")" : "");
|
|
49
|
+
lines.push(terminalSafeText(label) + ": local fallback " + (device.localFallback ? "allowed" : "off")
|
|
50
|
+
+ ", model " + onOff(device.model) + ", provider " + onOff(device.provider));
|
|
51
|
+
}
|
|
52
|
+
const last = state.last;
|
|
53
|
+
lines.push(last
|
|
54
|
+
? terminalSafeText("last: " + last.at + " " + last.outcome + " " + last.kind + " fallback on " + last.route + ": "
|
|
55
|
+
+ last.from + " -> " + last.to + " (" + last.trigger + ", " + last.scope + ")")
|
|
56
|
+
: "last: no fallback since this proxy started");
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function takeScope(args: string[]): { scope: "global" | "provider" | "route" | "device"; key?: string } {
|
|
61
|
+
const provider = takeOption(args, "--provider");
|
|
62
|
+
const route = takeOption(args, "--route");
|
|
63
|
+
const device = takeOption(args, "--device");
|
|
64
|
+
const given = [provider, route, device].filter(value => value !== undefined);
|
|
65
|
+
if (given.length > 1) throw new CliUsageError("use one of --provider, --route or --device", FALLBACK_USAGE);
|
|
66
|
+
if (provider !== undefined) return { scope: "provider", key: provider };
|
|
67
|
+
if (route !== undefined) return { scope: "route", key: route };
|
|
68
|
+
if (device !== undefined) return { scope: "device", key: device };
|
|
69
|
+
return { scope: "global" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function takeKinds(args: string[]): Array<"model" | "provider"> {
|
|
73
|
+
const kind = takeOption(args, "--kind") ?? "all";
|
|
74
|
+
if (kind === "all") return ["model", "provider"];
|
|
75
|
+
if (kind === "model" || kind === "provider") return [kind];
|
|
76
|
+
throw new CliUsageError("--kind must be model, provider or all", FALLBACK_USAGE);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function put(body: Record<string, unknown>, deps: RuntimeApiDeps): Promise<FallbackState> {
|
|
80
|
+
return await runtimeRequest<FallbackState>("/api/fallback", {
|
|
81
|
+
method: "PUT",
|
|
82
|
+
headers: { "content-type": "application/json" },
|
|
83
|
+
body: JSON.stringify(body),
|
|
84
|
+
}, deps);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function status(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
88
|
+
const args = [...argv];
|
|
89
|
+
const wantsJson = takeFlag(args, "--json");
|
|
90
|
+
rejectArgs(args, FALLBACK_USAGE);
|
|
91
|
+
const state = await runtimeRequest<FallbackState>("/api/fallback", {}, deps);
|
|
92
|
+
printData(state, wantsJson, fallbackStatusLines(state));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function setSwitch(argv: string[], value: boolean | null, deps: RuntimeApiDeps): Promise<void> {
|
|
96
|
+
const args = [...argv];
|
|
97
|
+
const wantsJson = takeFlag(args, "--json");
|
|
98
|
+
const kinds = takeKinds(args);
|
|
99
|
+
const scope = takeScope(args);
|
|
100
|
+
rejectArgs(args, FALLBACK_USAGE);
|
|
101
|
+
const state = await put({ writes: kinds.map(setting => ({ ...scope, setting, value })) }, deps);
|
|
102
|
+
printData(state, wantsJson, fallbackStatusLines(state));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function local(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
106
|
+
const args = [...argv];
|
|
107
|
+
const wantsJson = takeFlag(args, "--json");
|
|
108
|
+
const mode = args.shift();
|
|
109
|
+
if (mode !== "on" && mode !== "off") throw new CliUsageError("fallback local needs on or off", FALLBACK_USAGE);
|
|
110
|
+
const device = takeOption(args, "--device");
|
|
111
|
+
if (!device) throw new CliUsageError("fallback local needs --device <id|alias>", FALLBACK_USAGE);
|
|
112
|
+
rejectArgs(args, FALLBACK_USAGE);
|
|
113
|
+
const state = await put({ scope: "device", key: device, setting: "localFallback", value: mode === "on" }, deps);
|
|
114
|
+
printData(state, wantsJson, fallbackStatusLines(state));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function handleFallbackCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
|
|
118
|
+
return runCliAction(async () => {
|
|
119
|
+
const [sub, ...rest] = argv;
|
|
120
|
+
if (sub === undefined || sub === "status") await status(rest, deps);
|
|
121
|
+
else if (sub === "off") await setSwitch(rest, false, deps);
|
|
122
|
+
else if (sub === "on") await setSwitch(rest, true, deps);
|
|
123
|
+
else if (sub === "clear") await setSwitch(rest, null, deps);
|
|
124
|
+
else if (sub === "local") await local(rest, deps);
|
|
125
|
+
else throw new CliUsageError("unknown fallback command: " + sub, FALLBACK_USAGE);
|
|
126
|
+
});
|
|
127
|
+
}
|
package/src/cli/help.ts
CHANGED
|
@@ -81,6 +81,7 @@ Usage:
|
|
|
81
81
|
ocx agent <sub> Subagents, injection, effort caps, and sidecars
|
|
82
82
|
ocx message <sub> Codex queued messages, explicit SSH peers and foreground ownership
|
|
83
83
|
ocx effort [sub] Inspect and configure reasoning effort caps and defaults
|
|
84
|
+
ocx fallback <sub> Turn model or provider fallback off per scope; per-device local fallback
|
|
84
85
|
ocx observe <sub> Logs, usage, storage, memory, and debug data
|
|
85
86
|
ocx inspect <sub> Effective config, catalog, analytics, pacing, client-config
|
|
86
87
|
ocx route <sub> Routing features (combo, policy)
|
package/src/cli/index.ts
CHANGED
|
@@ -167,7 +167,7 @@ import { startClientRuntimeUnderOwnershipLease } from "./client-start-fence";
|
|
|
167
167
|
import { recoverStartStateUnderOwnershipLease } from "./start-owner-fence";
|
|
168
168
|
import { handledSignalExitCode } from "../lib/handled-signal-exit";
|
|
169
169
|
import { createLocalAttestationSecret } from "../lib/local-management-attestation";
|
|
170
|
-
import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../lib/system-restart-contract";
|
|
170
|
+
import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS, RESTART_REOBSERVE_RESERVE_MS } from "../lib/system-restart-contract";
|
|
171
171
|
import { startServiceLogging } from "../service/logging";
|
|
172
172
|
|
|
173
173
|
/**
|
|
@@ -941,7 +941,6 @@ async function handleTrayProxyStart(existingIsSuccess = true): Promise<boolean>
|
|
|
941
941
|
const PROXY_RESTART_OBSERVE_MS = MEMORY_DRAIN_RESTART_MS + REPLACEMENT_READY_TIMEOUT_MS + 15_000;
|
|
942
942
|
|
|
943
943
|
/** Reserve confirmation time within the shared restart deadline. */
|
|
944
|
-
const RESTART_REOBSERVE_RESERVE_MS = 10_000;
|
|
945
944
|
async function handleProxyRestart(
|
|
946
945
|
startWhenStopped: (recoveringLiveRestart: boolean) => Promise<ProxyRestartStartOutcome>,
|
|
947
946
|
): Promise<boolean> {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* After `ocx sync` applies the Home's Codex settings on a Remote Link Child, restart the Child's
|
|
3
|
+
* runtime so it started after the apply (OCX-6, P5).
|
|
4
|
+
*
|
|
5
|
+
* Idempotent: the record keeps its apply time while the settings digest is unchanged, so a runtime
|
|
6
|
+
* that already started after it is left alone, and a sync after a failed restart retries it. The
|
|
7
|
+
* restart is `ocx restart`'s own (`handleProxyRestart`); this module only decides whether to call
|
|
8
|
+
* it and never starts a runtime that was not running, except to recover one an accepted restart
|
|
9
|
+
* left absent.
|
|
10
|
+
*/
|
|
11
|
+
import type { HomeCodexSettingsRecord } from "../client/link-home";
|
|
12
|
+
import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
|
|
13
|
+
import type { ProxyRestartStartOutcome } from "./tray-proxy";
|
|
14
|
+
|
|
15
|
+
export type ApplyRestartOutcome =
|
|
16
|
+
| { kind: "current" }
|
|
17
|
+
| { kind: "not-running" }
|
|
18
|
+
| { kind: "connection-changed" }
|
|
19
|
+
| { kind: "restarted" }
|
|
20
|
+
| { kind: "failed" };
|
|
21
|
+
|
|
22
|
+
export interface ApplyRestartDeps {
|
|
23
|
+
findLive?: () => Promise<LiveProxy | null>;
|
|
24
|
+
/** Epoch ms the live client runtime reports as its start, or null when it reports none. */
|
|
25
|
+
readStartedAt?: (live: LiveProxy) => Promise<number | null>;
|
|
26
|
+
restart: (startWhenStopped: (recoveringLiveRestart: boolean) => Promise<ProxyRestartStartOutcome>) => Promise<boolean>;
|
|
27
|
+
recoveryStart: (recoveringLiveRestart: boolean) => Promise<ProxyRestartStartOutcome>;
|
|
28
|
+
/** Still the connection this sync applied for (rechecked right before the restart). */
|
|
29
|
+
connectionUnchanged: () => boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function readClientRuntimeStartedAt(live: LiveProxy, fetchImpl: typeof fetch = fetch): Promise<number | null> {
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetchImpl(`http://${probeHostname(live.hostname)}:${live.port}/healthz`, {
|
|
35
|
+
headers: { accept: "application/json" },
|
|
36
|
+
redirect: "manual",
|
|
37
|
+
signal: AbortSignal.timeout(2_000),
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok) return null;
|
|
40
|
+
const body = await response.json() as { pid?: unknown; startedAt?: unknown } | null;
|
|
41
|
+
if (body?.pid !== live.pid || typeof body.startedAt !== "string") return null;
|
|
42
|
+
const startedAt = Date.parse(body.startedAt);
|
|
43
|
+
return Number.isFinite(startedAt) ? startedAt : null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function restartRuntimeAfterApply(record: HomeCodexSettingsRecord, deps: ApplyRestartDeps): Promise<ApplyRestartOutcome> {
|
|
50
|
+
const live = await (deps.findLive ?? (() => findLiveProxy()))();
|
|
51
|
+
if (!live || live.role !== "client") return { kind: "not-running" };
|
|
52
|
+
const appliedAt = Date.parse(record.appliedAt);
|
|
53
|
+
// A runtime that reports no start time predates OCX-6 and cannot prove it is current.
|
|
54
|
+
const startedAt = await (deps.readStartedAt ?? (target => readClientRuntimeStartedAt(target)))(live);
|
|
55
|
+
if (startedAt !== null && Number.isFinite(appliedAt) && startedAt >= appliedAt) return { kind: "current" };
|
|
56
|
+
if (!deps.connectionUnchanged()) return { kind: "connection-changed" };
|
|
57
|
+
const ok = await deps.restart(async recovering => recovering
|
|
58
|
+
? deps.recoveryStart(true)
|
|
59
|
+
: { status: "skipped" });
|
|
60
|
+
return ok ? { kind: "restarted" } : { kind: "failed" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One line for each outcome, printed by `ocx sync`. */
|
|
64
|
+
export function applyRestartMessage(outcome: ApplyRestartOutcome): string {
|
|
65
|
+
switch (outcome.kind) {
|
|
66
|
+
case "current": return "Client runtime already started after this apply; no restart needed.";
|
|
67
|
+
case "not-running": return "No client runtime is running; the applied settings take effect when it starts.";
|
|
68
|
+
case "connection-changed": return "The connection changed during sync; the client runtime was not restarted. Run 'ocx sync' again.";
|
|
69
|
+
case "restarted": return "Client runtime restarted after applying the Home's settings.";
|
|
70
|
+
case "failed": return "The client runtime restart did not complete; the previous runtime keeps serving. If a background service or the desktop app owns it, restart it there; otherwise retry with 'ocx restart' or 'ocx sync'.";
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/cli/link-child.ts
CHANGED
|
@@ -74,6 +74,7 @@ export async function readLinkHomeJson(
|
|
|
74
74
|
context: LinkChildContext,
|
|
75
75
|
path: string,
|
|
76
76
|
deps: LinkHomeDeps = {},
|
|
77
|
+
init: { signal?: AbortSignal } = {},
|
|
77
78
|
): Promise<{ status: number; body: unknown }> {
|
|
78
79
|
const token = readServiceApiTokenState();
|
|
79
80
|
if (token.kind !== "present" || token.fingerprint !== context.connection.tokenFingerprint) {
|
|
@@ -81,12 +82,15 @@ export async function readLinkHomeJson(
|
|
|
81
82
|
}
|
|
82
83
|
let response: Response;
|
|
83
84
|
try {
|
|
84
|
-
response = await fetchLinkHomeManagement(context.connection, token.token, path.startsWith("/") ? path : `/${path}`,
|
|
85
|
+
response = await fetchLinkHomeManagement(context.connection, token.token, path.startsWith("/") ? path : `/${path}`,
|
|
86
|
+
init.signal ? { signal: init.signal } : {}, deps);
|
|
85
87
|
} catch (error) {
|
|
86
88
|
const detail = error instanceof Error && error.name === "LinkRelayAuthenticationError" ? error.message : "the link tunnel did not answer";
|
|
87
89
|
throw new Error(`Could not read ${context.homePhrase} over Remote Link (${context.linkId}): ${detail}.`);
|
|
88
90
|
}
|
|
89
|
-
|
|
91
|
+
// A caller's deadline (the status report's 2 s) bounds the body read as well as the request.
|
|
92
|
+
const readSignal = init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000);
|
|
93
|
+
const result = await readBoundedResponseBytes(response, { maxBytes: RESPONSE_MAX_BYTES, signal: readSignal });
|
|
90
94
|
if (result.oversized) throw new Error(`${context.homePhrase} returned a response larger than the CLI accepts.`);
|
|
91
95
|
const text = new TextDecoder().decode(result.bytes);
|
|
92
96
|
let body: unknown = null;
|