@bitkyc08/opencodex 2.90.0 → 2.92.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/bin/ocx.mjs +37 -3
- package/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
- package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
- package/gui/dist/assets/index-BlFE_VB_.js +94 -0
- package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
- package/gui/dist/index.html +1 -1
- package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
- package/package.json +4 -3
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/claude-cli/usage-admission.ts +14 -0
- package/src/adapters/google-vertex-endpoint.ts +9 -1
- package/src/adapters/google-vertex-key-project.ts +223 -0
- package/src/adapters/google.ts +13 -3
- package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/passthrough.ts +2 -1
- package/src/adapters/openai-responses/request-strips.ts +92 -0
- package/src/adapters/openai-responses/transport.ts +6 -0
- package/src/adapters/registry.ts +5 -0
- package/src/adapters/workbuddy-schema.ts +61 -0
- package/src/adapters/workbuddy.ts +68 -0
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +1 -1
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +47 -8
- package/src/chat/reasoning-details.ts +137 -0
- package/src/cli/capabilities-agents-routing.ts +53 -3
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-cli-update.ts +8 -0
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/combo-result.ts +6 -0
- package/src/cli/combo-stats.ts +3 -0
- package/src/cli/combo.ts +66 -42
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -2
- package/src/cli/launcher-context.ts +49 -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 +123 -35
- package/src/cli/provider.ts +46 -3
- 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/cli/usage-compare.ts +68 -12
- 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-ingress.ts +1 -1
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-readiness.ts +97 -0
- package/src/client/link-relay-transport.ts +84 -9
- package/src/client/link-relay.ts +98 -15
- package/src/client/link-tunnel.ts +45 -18
- package/src/client/machine-listener.ts +103 -3
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +155 -25
- package/src/codex/account-store.ts +17 -3
- package/src/codex/app-server-restart-service.ts +6 -5
- package/src/codex/auth-context.ts +10 -6
- package/src/codex/catalog/effort.ts +9 -2
- package/src/codex/catalog/gather-capture.ts +4 -2
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/catalog/provider-models.ts +14 -8
- package/src/codex/chatgpt-refresh-failure.ts +6 -2
- package/src/codex/cli-install-provenance.ts +418 -0
- package/src/codex/cli-installation-targets.ts +5 -1
- package/src/codex/cli-update-plan.ts +43 -16
- package/src/codex/desktop-app/picker-freshness.ts +10 -8
- package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
- package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/pool-refresh-backoff.ts +3 -1
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/subagent-model-fallback.ts +24 -1
- package/src/codex/user-identity.ts +35 -6
- package/src/codex/windows-current-sid.ts +162 -0
- package/src/combos/index.ts +1 -0
- package/src/combos/jev-quota-activation.ts +5 -1
- package/src/combos/jev-quota-route.ts +1 -1
- package/src/combos/jev-quota.ts +9 -1
- package/src/combos/jev-session-pin-config.ts +51 -0
- package/src/combos/jev.ts +6 -0
- package/src/combos/resolve.ts +12 -0
- package/src/combos/types.ts +19 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/provider-validation.ts +7 -0
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +375 -195
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/decision-quota-sink.ts +10 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -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 +4 -4
- package/src/lib/upstream-retry.ts +4 -1
- 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/diagnostics.ts +193 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/relay-auth.ts +6 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +63 -2
- package/src/link/supervisor.ts +45 -12
- package/src/link/tunnel-history.ts +109 -0
- package/src/link/tunnel-state.ts +80 -17
- package/src/messaging/remote-files.ts +24 -1
- package/src/oauth/generic-account-failover.ts +91 -7
- package/src/oauth/index.ts +16 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/model-discovery-auth.ts +5 -0
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +6 -2
- package/src/oauth/token-guardian.ts +7 -0
- package/src/oauth/types.ts +9 -0
- package/src/oauth/workbuddy.ts +139 -0
- 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/antigravity-quota-identity.ts +37 -0
- package/src/providers/base-url-choices.ts +8 -5
- package/src/providers/derive.ts +23 -3
- package/src/providers/mistral-transport.ts +37 -4
- package/src/providers/model-discovery.ts +2 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/quota/account-cache.ts +3 -2
- package/src/providers/quota/antigravity.ts +27 -22
- package/src/providers/quota/report-cache.ts +13 -4
- package/src/providers/quota/vendor-probes-key.ts +60 -0
- package/src/providers/quota/workbuddy.ts +83 -0
- package/src/providers/quota-decision-antigravity.ts +64 -0
- package/src/providers/quota-decision-publication.ts +11 -0
- package/src/providers/quota-decision-snapshot.ts +21 -4
- package/src/providers/quota-types.ts +2 -0
- package/src/providers/quota.ts +3 -1
- package/src/providers/registry/entries-core.ts +9 -0
- package/src/providers/registry/entries-extended.ts +20 -7
- package/src/providers/registry/model-ids.ts +3 -0
- package/src/providers/registry/model-seeds.ts +27 -28
- package/src/providers/registry/types.ts +19 -1
- package/src/providers/resolved-model-policy.ts +4 -4
- package/src/providers/routed-config.ts +15 -0
- package/src/providers/stale-model-roster-migration.ts +83 -15
- package/src/providers/workbuddy-credit-balance.ts +81 -0
- package/src/providers/workbuddy-models.ts +37 -0
- package/src/providers/workbuddy.ts +60 -0
- 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/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/responses/reasoning-replay-cache.ts +18 -14
- package/src/router.ts +2 -0
- package/src/routing/analytics.ts +2 -3
- 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 +7 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +22 -2
- package/src/server/chat-native-reasoning.ts +10 -3
- package/src/server/chat-native.ts +13 -2
- package/src/server/claude-messages.ts +3 -0
- 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 +20 -7
- package/src/server/index/optional-listeners.ts +11 -2
- package/src/server/index/serve-options.ts +19 -2
- package/src/server/live.ts +5 -0
- package/src/server/local-management-read-client.ts +3 -1
- package/src/server/management/combo-routes.ts +10 -4
- 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 +16 -2
- package/src/server/management/provider-routes.ts +1 -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/port-reclaim.ts +51 -9
- package/src/server/request-log-conversation.ts +49 -0
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
- package/src/server/responses/compact.ts +12 -4
- package/src/server/responses/core-auth.ts +11 -1
- package/src/server/responses/core-combo.ts +125 -32
- package/src/server/responses/core-lifetime.ts +2 -2
- package/src/server/responses/core-normalize.ts +6 -3
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +2 -1
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/jev-combo-choice.ts +39 -0
- package/src/server/responses/jev-session-pin.ts +203 -0
- package/src/server/responses/main-hard-lock-routing.ts +54 -2
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/request-prepare.ts +19 -6
- package/src/server/responses/request-transport.ts +25 -6
- package/src/server/responses/run-turn-execution.ts +2 -4
- package/src/server/responses/sidecar-execution.ts +2 -4
- package/src/server/responses-phase-inference.ts +267 -0
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/sse-payload-rewrite.ts +14 -1
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/definition.ts +20 -8
- package/src/service/diagnostics.ts +20 -6
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +13 -0
- package/src/types/fallback.ts +38 -0
- package/src/types/provider.ts +9 -1
- package/src/types/request.ts +1 -0
- package/src/types/wire.ts +1 -0
- package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/usage/jev-stats.ts +54 -14
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/index-TmaHa54u.js +0 -94
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
|
@@ -24,7 +24,11 @@ export type TrustedNodeLaunchContext = {
|
|
|
24
24
|
codexCliPath: string | null;
|
|
25
25
|
path: string | null;
|
|
26
26
|
pathExt: string | null;
|
|
27
|
+
home: string | null;
|
|
28
|
+
nodePath: string | null;
|
|
27
29
|
managerRoots: CodexCliVersionManagerRoots | null;
|
|
30
|
+
transportEnv: Readonly<Record<string, string>>;
|
|
31
|
+
arch: NodeJS.Architecture | null;
|
|
28
32
|
configDir: string;
|
|
29
33
|
}> | null;
|
|
30
34
|
};
|
|
@@ -50,6 +54,27 @@ function parseVersionManagerRoots(value: unknown): CodexCliVersionManagerRoots |
|
|
|
50
54
|
return Object.freeze(Object.fromEntries(entries)) as CodexCliVersionManagerRoots;
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
function parseTransportEnv(value: unknown): Readonly<Record<string, string>> | undefined {
|
|
58
|
+
// Older snapshots convey no transport settings. Never fill them from Bun's ambient env.
|
|
59
|
+
if (value === undefined) return Object.freeze({});
|
|
60
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
61
|
+
const entries = Object.entries(value);
|
|
62
|
+
if (entries.some(([name, setting]) => !CODEX_CLI_TRANSPORT_ENV_KEYS.includes(name.toLowerCase())
|
|
63
|
+
|| typeof setting !== "string" || setting.length > 32 * 1024)) return undefined;
|
|
64
|
+
return Object.freeze(Object.fromEntries(entries));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const NODE_ARCHITECTURES: ReadonlySet<string> = new Set<NodeJS.Architecture>([
|
|
68
|
+
"arm", "arm64", "ia32", "loong64", "mips", "mipsel", "ppc64", "riscv64", "s390x", "x64",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
function parseLauncherArch(value: unknown): NodeJS.Architecture | null | undefined {
|
|
72
|
+
// Older snapshots predate the field; absent stays null (Bun's own arch is the
|
|
73
|
+
// pre-existing fallback). Any other non-architecture value is untrusted.
|
|
74
|
+
if (value === undefined || value === null) return null;
|
|
75
|
+
return typeof value === "string" && NODE_ARCHITECTURES.has(value) ? value as NodeJS.Architecture : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
/** Consume the internal proof before normal CLI argument parsing. */
|
|
54
79
|
export function initializeNodeLauncherContext(
|
|
55
80
|
argv: string[] = process.argv,
|
|
@@ -70,7 +95,7 @@ export function initializeNodeLauncherContext(
|
|
|
70
95
|
delete env.OCX_PRE_BUN_ANTHROPIC_ENV;
|
|
71
96
|
trustedContext = null;
|
|
72
97
|
|
|
73
|
-
if (proofArgs.length !== 1 || !raw || raw.length >
|
|
98
|
+
if (proofArgs.length !== 1 || !raw || raw.length > NODE_LAUNCH_CONTEXT_MAX_CHARS) return null;
|
|
74
99
|
const proof = proofArgs[0]!;
|
|
75
100
|
if (!isLaunchProof(proof)) return null;
|
|
76
101
|
|
|
@@ -95,6 +120,12 @@ export function initializeNodeLauncherContext(
|
|
|
95
120
|
const managerRoots = inspection && typeof inspection === "object" && !Array.isArray(inspection)
|
|
96
121
|
? parseVersionManagerRoots((inspection as Record<string, unknown>).managerRoots)
|
|
97
122
|
: null;
|
|
123
|
+
const transportEnv = inspection && typeof inspection === "object" && !Array.isArray(inspection)
|
|
124
|
+
? parseTransportEnv((inspection as Record<string, unknown>).transportEnv)
|
|
125
|
+
: undefined;
|
|
126
|
+
const arch = inspection && typeof inspection === "object" && !Array.isArray(inspection)
|
|
127
|
+
? parseLauncherArch((inspection as Record<string, unknown>).arch)
|
|
128
|
+
: undefined;
|
|
98
129
|
const codexCliInspectionEnv = inspection === null || inspection === undefined
|
|
99
130
|
? null
|
|
100
131
|
: inspection && typeof inspection === "object" && !Array.isArray(inspection)
|
|
@@ -102,15 +133,27 @@ export function initializeNodeLauncherContext(
|
|
|
102
133
|
const value = (inspection as Record<string, unknown>)[key];
|
|
103
134
|
return value === null || typeof value === "string";
|
|
104
135
|
})
|
|
136
|
+
// Older launchers predate the lookup-seed slots; absent stays null so
|
|
137
|
+
// the fallback lane simply remains unverifiable rather than untrusted.
|
|
138
|
+
&& ["home", "nodePath"].every(key => {
|
|
139
|
+
const value = (inspection as Record<string, unknown>)[key];
|
|
140
|
+
return value === undefined || value === null || typeof value === "string";
|
|
141
|
+
})
|
|
105
142
|
&& typeof (inspection as Record<string, unknown>).configDir === "string"
|
|
106
143
|
&& (inspection as Record<string, string>).configDir.length > 0
|
|
107
144
|
&& (inspection as Record<string, string>).configDir.length <= 32 * 1024
|
|
108
145
|
&& managerRoots !== undefined
|
|
146
|
+
&& transportEnv !== undefined
|
|
147
|
+
&& arch !== undefined
|
|
109
148
|
? Object.freeze({
|
|
110
149
|
codexCliPath: (inspection as Record<string, string | null>).codexCliPath ?? null,
|
|
111
150
|
path: (inspection as Record<string, string | null>).path ?? null,
|
|
112
151
|
pathExt: (inspection as Record<string, string | null>).pathExt ?? null,
|
|
152
|
+
home: (inspection as Record<string, string | null>).home ?? null,
|
|
153
|
+
nodePath: (inspection as Record<string, string | null>).nodePath ?? null,
|
|
113
154
|
managerRoots,
|
|
155
|
+
transportEnv,
|
|
156
|
+
arch,
|
|
114
157
|
configDir: (inspection as Record<string, string>).configDir,
|
|
115
158
|
})
|
|
116
159
|
: undefined;
|
|
@@ -125,4 +168,8 @@ export function initializeNodeLauncherContext(
|
|
|
125
168
|
export function trustedNodeLauncherContext(): TrustedNodeLaunchContext | null {
|
|
126
169
|
return trustedContext;
|
|
127
170
|
}
|
|
128
|
-
import {
|
|
171
|
+
import {
|
|
172
|
+
CODEX_CLI_TRANSPORT_ENV_KEYS,
|
|
173
|
+
CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS,
|
|
174
|
+
NODE_LAUNCH_CONTEXT_MAX_CHARS,
|
|
175
|
+
} from "../update/codex-cli-update-launch-policy.mjs";
|
|
@@ -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;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ocx link cleanup`: find Home-initiated Remote Link Children that run Windows and explain how to
|
|
3
|
+
* move each one to Hub mode. It never revokes or changes anything.
|
|
4
|
+
*
|
|
5
|
+
* Windows Children are refused at enrollment, but a link enrolled before that refusal existed keeps
|
|
6
|
+
* its record, key and reverse tunnel. Its record carries no platform, so this command asks the
|
|
7
|
+
* Child over the same pinned SSH trust the tunnel uses (`uname -s` through `sh`) and classifies the
|
|
8
|
+
* answer with `detectRemotePlatform` (a localized cmd or PowerShell refusal gets the fixed %OS% probe).
|
|
9
|
+
*
|
|
10
|
+
* The recovery order matters. An ordinary `ocx link revoke` runs `ocx disconnect` on the Child
|
|
11
|
+
* through the POSIX ocx prelude, which exits 125 on Windows, so for these Children the revoke must
|
|
12
|
+
* be forced after the Child disconnected itself.
|
|
13
|
+
*/
|
|
14
|
+
import { linkKnownHostsPath, linkStorePath } from "../link/paths";
|
|
15
|
+
import { detectRemotePlatform, REMOTE_UNAME_ARGV, type RemotePlatform } from "../link/remote-platform";
|
|
16
|
+
import { buildExecArgv } from "../link/ssh-argv";
|
|
17
|
+
import { createSshRunner, sshFailureHint, sshRunnerErrorHint, type SshRunner } from "../link/ssh-runner";
|
|
18
|
+
import { readLinkStore, type LinkStore } from "../link/store";
|
|
19
|
+
import { CliUsageError, printData, rejectArgs, takeJsonFlag, takeOption } from "./runtime-api";
|
|
20
|
+
|
|
21
|
+
const LINK_ID = /^lnk_[0-9a-f]{16}$/;
|
|
22
|
+
const CHECK_TIMEOUT_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
export interface LinkCleanupDeps {
|
|
25
|
+
readStore?: () => LinkStore;
|
|
26
|
+
sshRunner?: SshRunner;
|
|
27
|
+
knownHostsFile?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LinkCleanupEntry {
|
|
31
|
+
linkId: string;
|
|
32
|
+
alias: string;
|
|
33
|
+
direction: LinkStore["links"][number]["direction"];
|
|
34
|
+
platform: RemotePlatform | "not_checked";
|
|
35
|
+
reason: string | null;
|
|
36
|
+
recovery: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function windowsChildRecovery(linkId: string, alias: string): string[] {
|
|
40
|
+
return [
|
|
41
|
+
`On ${alias}, run 'ocx disconnect' and then 'ocx restart' so it stops using this link.`,
|
|
42
|
+
`On this Home, run 'ocx link revoke --link-id ${linkId} --force --yes'. A plain revoke cannot disconnect a Windows Child remotely.`,
|
|
43
|
+
`Then connect ${alias} in Hub mode: run 'ocx hub invite' on a Hub and the printed 'ocx connect' command on ${alias}.`,
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function checkPlatform(runner: SshRunner, alias: string, knownHostsFile: string): Promise<{ platform: RemotePlatform; reason: string | null }> {
|
|
48
|
+
try {
|
|
49
|
+
const result = await runner.run(buildExecArgv({ alias, argv: REMOTE_UNAME_ARGV, knownHostsFile }), { timeoutMs: CHECK_TIMEOUT_MS });
|
|
50
|
+
const platform = await detectRemotePlatform(runner, alias, knownHostsFile, result);
|
|
51
|
+
return { platform, reason: platform === "unknown" ? (sshFailureHint(result.stderr) ?? `ssh exited ${result.code}`) : null };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return { platform: "unknown", reason: sshRunnerErrorHint(error) ?? "ssh could not run" };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function humanLines(entries: LinkCleanupEntry[]): string[] {
|
|
58
|
+
if (entries.length === 0) return ["No links are recorded on this Home.", "Nothing was revoked or changed."];
|
|
59
|
+
const lines: string[] = [];
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const label = `${entry.linkId} (${entry.alias})`;
|
|
62
|
+
if (entry.platform === "windows") {
|
|
63
|
+
lines.push(`${label}: Windows Child. Remote Link does not support Windows Children; this link predates that refusal.`);
|
|
64
|
+
for (const [index, step] of entry.recovery.entries()) lines.push(` ${index + 1}. ${step}`);
|
|
65
|
+
} else if (entry.platform === "posix") lines.push(`${label}: macOS or Linux Child; nothing to clean up.`);
|
|
66
|
+
else if (entry.platform === "not_checked") lines.push(`${label}: Child-initiated; this Home has no SSH route to check it.`);
|
|
67
|
+
else lines.push(`${label}: platform could not be checked (${entry.reason ?? "unknown"}); retry when it is reachable.`);
|
|
68
|
+
}
|
|
69
|
+
lines.push("Nothing was revoked or changed.");
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function runLinkCleanup(args: string[], deps: LinkCleanupDeps, usage: string): Promise<void> {
|
|
74
|
+
const wantsJson = takeJsonFlag(args);
|
|
75
|
+
const linkId = takeOption(args, "--link-id");
|
|
76
|
+
if (linkId !== undefined && !LINK_ID.test(linkId)) throw new CliUsageError("cleanup --link-id must be a valid link id", usage);
|
|
77
|
+
rejectArgs(args, usage);
|
|
78
|
+
const store = (deps.readStore ?? (() => readLinkStore(linkStorePath())))();
|
|
79
|
+
const records = linkId === undefined ? store.links : store.links.filter(link => link.id === linkId);
|
|
80
|
+
if (linkId !== undefined && records.length === 0) throw new CliUsageError(`No link ${linkId} is recorded on this Home.`, usage);
|
|
81
|
+
const runner = deps.sshRunner ?? createSshRunner();
|
|
82
|
+
const knownHostsFile = deps.knownHostsFile ?? linkKnownHostsPath();
|
|
83
|
+
const entries: LinkCleanupEntry[] = [];
|
|
84
|
+
// One at a time: each check is a bounded SSH command, and order keeps the report stable.
|
|
85
|
+
for (const record of records) {
|
|
86
|
+
const checked = record.direction === "hub-initiated"
|
|
87
|
+
? await checkPlatform(runner, record.alias, knownHostsFile)
|
|
88
|
+
: { platform: "not_checked" as const, reason: null };
|
|
89
|
+
entries.push({
|
|
90
|
+
linkId: record.id, alias: record.alias, direction: record.direction, ...checked,
|
|
91
|
+
recovery: checked.platform === "windows" ? windowsChildRecovery(record.id, record.alias) : [],
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
printData({ links: entries, changed: false }, wantsJson, humanLines(entries));
|
|
95
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { assertSshAlias } from "../link/ssh-argv";
|
|
3
|
+
import { windowsChildRefusal } from "../link/remote-platform";
|
|
3
4
|
import { CliUsageError, RuntimeApiError, runtimeBaseUrl, rejectArgs, takeJsonFlag, takeOption, printData } from "./runtime-api";
|
|
4
5
|
import type { LinkCliDeps } from "./link";
|
|
5
6
|
|
|
@@ -83,6 +84,9 @@ export async function runLinkEnrollment(command: "probe" | "add", args: string[]
|
|
|
83
84
|
catch (error) {
|
|
84
85
|
if (error instanceof RuntimeApiError && record(error.body)) {
|
|
85
86
|
const code = record(error.body.error) ? error.body.error.code : undefined;
|
|
87
|
+
// Refused at probe or confirm, before any key or tunnel: point at Hub mode, not at a phase.
|
|
88
|
+
// A residual response comes after key issuance; it keeps its Child recovery below.
|
|
89
|
+
if (code === "remote_platform_unsupported" && error.body.residual !== true) throw new Error(windowsChildRefusal(alias));
|
|
86
90
|
const phase = typeof code === "string" && Object.hasOwn(PHASES, code) ? PHASES[code] : undefined;
|
|
87
91
|
const message = phase ? "Remote Link failed during " + phase : error.message;
|
|
88
92
|
if (phase || error.body.residual === true) throw new Error(message + (error.body.residual === true ? ". " + RECOVERY : ""));
|
package/src/cli/link.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { clientLinkStatusIdentity } from "../client/link-status";
|
|
2
2
|
import { clientLinkTunnelStatus } from "../client/link-tunnel";
|
|
3
3
|
import { loadConfig } from "../config";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
projectLinkStatus, type ClientLinkIdentity, type LinkStatusConfig, type LinkStatusDto, type LinkTunnelDiagnostics, type LinkWireState,
|
|
6
|
+
} from "../link/status-projection";
|
|
7
|
+
import { TUNNEL_CAUSES } from "../link/tunnel-history";
|
|
8
|
+
import { isEpochMs, type LinkChildParity } from "../link/child-parity";
|
|
9
|
+
import { CODEX_SETTINGS_DIGEST_PATTERN } from "../link/codex-settings";
|
|
10
|
+
import type { LinkLocalPeer, LinkPeerStatus } from "../link/compatibility";
|
|
5
11
|
import { runLinkEnrollment } from "./link-enrollment";
|
|
12
|
+
import { runLinkCleanup, type LinkCleanupDeps } from "./link-cleanup";
|
|
6
13
|
import { assertSshAlias } from "../link/ssh-argv";
|
|
7
14
|
import { linkStorePath } from "../link/paths";
|
|
8
15
|
import { isLinkPort } from "../link/ports";
|
|
@@ -31,31 +38,16 @@ export const LINK_USAGE = `Usage:
|
|
|
31
38
|
ocx link status [--json]
|
|
32
39
|
ocx link revoke --link-id <id> [--force --yes] [--json]
|
|
33
40
|
ocx link reconnect --link-id <id> [--json]
|
|
41
|
+
ocx link cleanup [--link-id <id>] [--json]
|
|
34
42
|
|
|
35
|
-
Forced revoke skips remote cleanup. Run ocx disconnect on the remote client
|
|
36
|
-
|
|
37
|
-
type LinkState = "connecting" | "connected" | "reconnecting" | "failed" | "idle";
|
|
38
|
-
type ListenerState = "off" | "listening" | "failed";
|
|
43
|
+
Forced revoke skips remote cleanup. Run ocx disconnect on the remote client.
|
|
44
|
+
Cleanup finds Windows Children and explains how to move them to Hub mode; it changes nothing.`;
|
|
39
45
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
alias: string;
|
|
46
|
-
direction: LinkDirection;
|
|
47
|
-
state: LinkState;
|
|
48
|
-
since: string;
|
|
49
|
-
reason: string | null;
|
|
50
|
-
tunnelPort: number;
|
|
51
|
-
}>;
|
|
52
|
-
child: null | {
|
|
53
|
-
alias: string;
|
|
54
|
-
state: LinkState;
|
|
55
|
-
since: string;
|
|
56
|
-
reason: string | null;
|
|
57
|
-
};
|
|
58
|
-
}
|
|
46
|
+
/**
|
|
47
|
+
* What `ocx link status` prints: the runtime's status DTO itself, so a field the server adds (like
|
|
48
|
+
* `peer` or the diagnostics `tunnel`) is part of this type the moment it is part of the wire.
|
|
49
|
+
*/
|
|
50
|
+
export type LinkStatusPayload = LinkStatusDto;
|
|
59
51
|
|
|
60
52
|
export interface LinkIssuePayload {
|
|
61
53
|
linkId: string;
|
|
@@ -64,7 +56,7 @@ export interface LinkIssuePayload {
|
|
|
64
56
|
listenerPort: number;
|
|
65
57
|
}
|
|
66
58
|
|
|
67
|
-
export interface LinkCliDeps extends RuntimeApiDeps {
|
|
59
|
+
export interface LinkCliDeps extends RuntimeApiDeps, Omit<LinkCleanupDeps, "readStore"> {
|
|
68
60
|
/** Test seam can shorten, never extend, the whole enrollment deadline. */
|
|
69
61
|
enrollmentTimeoutMs?: number;
|
|
70
62
|
choosePort?: () => Promise<number>;
|
|
@@ -85,9 +77,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
85
77
|
}
|
|
86
78
|
|
|
87
79
|
function assertExactKeys(value: Record<string, unknown>, keys: readonly string[], label: string): void {
|
|
88
|
-
|
|
80
|
+
assertKeys(value, keys, [], label);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Exact keys plus fields a newer proxy adds; an older proxy omits them. */
|
|
84
|
+
function assertKeys(value: Record<string, unknown>, keys: readonly string[], optional: readonly string[], label: string): void {
|
|
85
|
+
const allowed = new Set([...keys, ...optional]);
|
|
89
86
|
const actual = Object.keys(value);
|
|
90
|
-
if (
|
|
87
|
+
if (keys.some(key => !(key in value)) || actual.some(key => !allowed.has(key))) {
|
|
91
88
|
throw new Error(`invalid link API response: ${label} fields`);
|
|
92
89
|
}
|
|
93
90
|
}
|
|
@@ -104,9 +101,42 @@ function validNullableString(value: unknown): value is string | null {
|
|
|
104
101
|
return value === null || typeof value === "string";
|
|
105
102
|
}
|
|
106
103
|
|
|
104
|
+
function validStringList(value: unknown): value is string[] {
|
|
105
|
+
return Array.isArray(value) && value.every(validString);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validateLocal(value: unknown): LinkLocalPeer {
|
|
109
|
+
if (!isRecord(value)) throw new Error("invalid link API response: local");
|
|
110
|
+
assertExactKeys(value, ["version", "relayAuth", "features"], "local");
|
|
111
|
+
if (!validString(value.version) || !validString(value.relayAuth) || !validStringList(value.features)) {
|
|
112
|
+
throw new Error("invalid link API response: local fields");
|
|
113
|
+
}
|
|
114
|
+
return { version: value.version, relayAuth: value.relayAuth, features: [...value.features] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function validatePeer(value: unknown, index: number): LinkPeerStatus {
|
|
118
|
+
const fail = (): never => { throw new Error(`invalid link API response: link ${index} peer`); };
|
|
119
|
+
if (!isRecord(value)) return fail();
|
|
120
|
+
assertExactKeys(value, ["observedAt", "version", "versionRange", "relayAuth", "features", "compatibility", "reasons"], `link ${index} peer`);
|
|
121
|
+
const range = value.versionRange;
|
|
122
|
+
if (!validNullableString(value.observedAt) || !validNullableString(value.version) || !validNullableString(value.relayAuth)
|
|
123
|
+
|| (value.features !== null && !validStringList(value.features)) || !validStringList(value.reasons)
|
|
124
|
+
|| !["compatible", "degraded", "incompatible", "unknown"].includes(value.compatibility as string)
|
|
125
|
+
|| (range !== null && (!isRecord(range) || !validNullableString(range.min) || !validString(range.below)))) fail();
|
|
126
|
+
return {
|
|
127
|
+
observedAt: value.observedAt as string | null,
|
|
128
|
+
version: value.version as string | null,
|
|
129
|
+
versionRange: range === null ? null : { min: (range as Record<string, unknown>).min as string | null, below: (range as Record<string, unknown>).below as string },
|
|
130
|
+
relayAuth: value.relayAuth as string | null,
|
|
131
|
+
features: value.features === null ? null : [...value.features as string[]],
|
|
132
|
+
compatibility: value.compatibility as LinkPeerStatus["compatibility"],
|
|
133
|
+
reasons: [...value.reasons as string[]],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
107
137
|
function validateStatus(value: unknown): LinkStatusPayload {
|
|
108
138
|
if (!isRecord(value)) throw new Error("invalid link API response: status object");
|
|
109
|
-
|
|
139
|
+
assertKeys(value, ["role", "listener", "links", "child"], ["local"], "status");
|
|
110
140
|
if (value.role !== "standalone" && value.role !== "home" && value.role !== "child") {
|
|
111
141
|
throw new Error("invalid link API response: status role");
|
|
112
142
|
}
|
|
@@ -119,9 +149,12 @@ function validateStatus(value: unknown): LinkStatusPayload {
|
|
|
119
149
|
throw new Error("invalid link API response: listener port");
|
|
120
150
|
}
|
|
121
151
|
if (!Array.isArray(value.links)) throw new Error("invalid link API response: links");
|
|
122
|
-
const links = value.links.map((candidate, index) => {
|
|
152
|
+
const links = value.links.map((candidate, index): LinkStatusPayload["links"][number] => {
|
|
123
153
|
if (!isRecord(candidate)) throw new Error(`invalid link API response: link ${index}`);
|
|
124
|
-
|
|
154
|
+
// The OCX-6 parity fields are optional on the wire (an older runtime omits them) and always
|
|
155
|
+
// present in this output, null when absent.
|
|
156
|
+
assertKeys(candidate, ["id", "alias", "direction", "state", "since", "reason", "tunnelPort"], [...PARITY_KEYS, "peer", "tunnel"], `link ${index}`);
|
|
157
|
+
const parity = validateParity(candidate, index);
|
|
125
158
|
if (typeof candidate.id !== "string" || !LINK_ID.test(candidate.id)
|
|
126
159
|
|| !validString(candidate.alias) || (candidate.direction !== "hub-initiated" && candidate.direction !== "client-initiated")
|
|
127
160
|
|| (candidate.state !== "connecting" && candidate.state !== "connected" && candidate.state !== "reconnecting" && candidate.state !== "failed" && candidate.state !== "idle")
|
|
@@ -131,12 +164,15 @@ function validateStatus(value: unknown): LinkStatusPayload {
|
|
|
131
164
|
return {
|
|
132
165
|
id: candidate.id,
|
|
133
166
|
alias: candidate.alias,
|
|
134
|
-
direction: candidate.direction,
|
|
135
|
-
state: candidate.state,
|
|
167
|
+
direction: candidate.direction as LinkDirection,
|
|
168
|
+
state: candidate.state as LinkWireState,
|
|
136
169
|
since: candidate.since,
|
|
137
170
|
reason: candidate.reason,
|
|
138
171
|
tunnelPort: candidate.tunnelPort,
|
|
139
|
-
|
|
172
|
+
...parity,
|
|
173
|
+
...(candidate.peer !== undefined ? { peer: validatePeer(candidate.peer, index) } : {}),
|
|
174
|
+
...(candidate.tunnel !== undefined ? { tunnel: validateTunnel(candidate.tunnel, index) } : {}),
|
|
175
|
+
};
|
|
140
176
|
});
|
|
141
177
|
let child: LinkStatusPayload["child"] = null;
|
|
142
178
|
if (value.child !== null) {
|
|
@@ -159,6 +195,7 @@ function validateStatus(value: unknown): LinkStatusPayload {
|
|
|
159
195
|
listener: { state: value.listener.state, port: value.listener.port },
|
|
160
196
|
links,
|
|
161
197
|
child,
|
|
198
|
+
...(value.local !== undefined ? { local: validateLocal(value.local) } : {}),
|
|
162
199
|
};
|
|
163
200
|
}
|
|
164
201
|
|
|
@@ -258,7 +295,9 @@ async function runStatus(args: string[], deps: LinkCliDeps): Promise<void> {
|
|
|
258
295
|
}
|
|
259
296
|
throw error;
|
|
260
297
|
}
|
|
261
|
-
|
|
298
|
+
// A proxy older than the compatibility report or the tunnel diagnostics ignores the query and
|
|
299
|
+
// answers without those fields.
|
|
300
|
+
const result = validateStatus(await linkRequest<unknown>("/api/link/status?compatibility=1&diagnostics=1", {}, { ...deps, baseUrl }));
|
|
262
301
|
console.log(JSON.stringify(result));
|
|
263
302
|
}
|
|
264
303
|
|
|
@@ -326,6 +365,55 @@ export async function runLinkCommand(rawArgs: string[], deps: LinkCliDeps = {}):
|
|
|
326
365
|
else if (command === "status") await runStatus(args, deps);
|
|
327
366
|
else if (command === "revoke") await runRevoke(args, deps);
|
|
328
367
|
else if (command === "reconnect") await runReconnect(args, deps);
|
|
329
|
-
else
|
|
368
|
+
else if (command === "cleanup") await runLinkCleanup(args, deps, LINK_USAGE);
|
|
369
|
+
else throw new CliUsageError("link requires one of: probe, add, port, issue, status, revoke, reconnect, cleanup", LINK_USAGE);
|
|
330
370
|
});
|
|
331
371
|
}
|
|
372
|
+
|
|
373
|
+
const PARITY_KEYS = ["lastApplyAt", "runtimeStartedAt", "appliedCodexSettingsDigest", "reportedAt"] as const;
|
|
374
|
+
const WIRE_STATES = new Set(["connecting", "connected", "reconnecting", "failed", "idle"]);
|
|
375
|
+
const CAUSES = new Set<string>(TUNNEL_CAUSES);
|
|
376
|
+
|
|
377
|
+
function validIso(value: unknown): value is string {
|
|
378
|
+
return typeof value === "string" && value.length <= 64 && Number.isFinite(Date.parse(value));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function validateTunnel(value: unknown, index: number): LinkTunnelDiagnostics {
|
|
382
|
+
const fail = (): never => { throw new Error(`invalid link API response: link ${index} tunnel`); };
|
|
383
|
+
if (!isRecord(value)) return fail();
|
|
384
|
+
assertExactKeys(value, ["attempt", "nextRetryAt", "lastTransition", "lastFailure"], `link ${index} tunnel`);
|
|
385
|
+
const { attempt, nextRetryAt, lastTransition, lastFailure } = value;
|
|
386
|
+
if (typeof attempt !== "number" || !Number.isSafeInteger(attempt) || attempt < 0) fail();
|
|
387
|
+
if (nextRetryAt !== null && !validIso(nextRetryAt)) fail();
|
|
388
|
+
if (lastTransition !== null) {
|
|
389
|
+
if (!isRecord(lastTransition)) return fail();
|
|
390
|
+
assertExactKeys(lastTransition, ["from", "to", "at", "cause"], `link ${index} tunnel transition`);
|
|
391
|
+
if (!WIRE_STATES.has(lastTransition.from as string) || !WIRE_STATES.has(lastTransition.to as string)
|
|
392
|
+
|| !validIso(lastTransition.at) || (lastTransition.cause !== null && !CAUSES.has(lastTransition.cause as string))) fail();
|
|
393
|
+
}
|
|
394
|
+
if (lastFailure !== null) {
|
|
395
|
+
if (!isRecord(lastFailure)) return fail();
|
|
396
|
+
assertExactKeys(lastFailure, ["cause", "at"], `link ${index} tunnel failure`);
|
|
397
|
+
if (!CAUSES.has(lastFailure.cause as string) || !validIso(lastFailure.at)) fail();
|
|
398
|
+
}
|
|
399
|
+
return value as unknown as LinkTunnelDiagnostics;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function validateParity(candidate: Record<string, unknown>, index: number): LinkChildParity {
|
|
403
|
+
const time = (key: "lastApplyAt" | "runtimeStartedAt" | "reportedAt"): number | null => {
|
|
404
|
+
const value = candidate[key];
|
|
405
|
+
if (value === undefined || value === null) return null;
|
|
406
|
+
if (!isEpochMs(value)) throw new Error(`invalid link API response: link ${index} ${key}`);
|
|
407
|
+
return value;
|
|
408
|
+
};
|
|
409
|
+
const digest = candidate.appliedCodexSettingsDigest;
|
|
410
|
+
if (digest !== undefined && digest !== null && (typeof digest !== "string" || !CODEX_SETTINGS_DIGEST_PATTERN.test(digest))) {
|
|
411
|
+
throw new Error(`invalid link API response: link ${index} appliedCodexSettingsDigest`);
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
lastApplyAt: time("lastApplyAt"),
|
|
415
|
+
runtimeStartedAt: time("runtimeStartedAt"),
|
|
416
|
+
appliedCodexSettingsDigest: typeof digest === "string" ? digest : null,
|
|
417
|
+
reportedAt: time("reportedAt"),
|
|
418
|
+
};
|
|
419
|
+
}
|
package/src/cli/provider.ts
CHANGED
|
@@ -28,6 +28,8 @@ import type { RuntimeApiDeps } from "./runtime-api";
|
|
|
28
28
|
import { redactSecretArgs } from "./secret-args";
|
|
29
29
|
import { projectLocalSyncResult, type LocalSyncResult } from "./local-sync-result";
|
|
30
30
|
import { providerManagementConfigError } from "../server/auth-cors";
|
|
31
|
+
import { readClientConnectionState, type ClientConnectionState } from "../client/state";
|
|
32
|
+
import { SENSITIVE_KEY_PATTERN } from "../lib/redact";
|
|
31
33
|
|
|
32
34
|
export interface ProviderCommandDeps extends RuntimeApiDeps {
|
|
33
35
|
syncModels?: typeof syncModelsToCodex;
|
|
@@ -102,7 +104,46 @@ function validateAndSave(config: ReturnType<typeof loadConfig>): void {
|
|
|
102
104
|
// provider list
|
|
103
105
|
// ---------------------------------------------------------------------------
|
|
104
106
|
|
|
105
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Does this device's own provider config serve requests? A device linked to a Home — a Remote
|
|
109
|
+
* Link Child or a Hub-mode client — routes through the Home, so its local rows are shadowed.
|
|
110
|
+
* A standalone device and a Hub serve their own rows. An unreadable or inconsistent client
|
|
111
|
+
* state is not a link, the same reading `readLinkChildContext` takes.
|
|
112
|
+
*/
|
|
113
|
+
export function localProvidersActive(read: () => ClientConnectionState = readClientConnectionState): boolean {
|
|
114
|
+
try {
|
|
115
|
+
return read().kind !== "connected";
|
|
116
|
+
} catch {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const SENSITIVE_QUERY_KEY = /key|token|secret|password|passwd|signature|^sig$|auth|credential/i;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The baseUrl as `provider list` prints it. Validation already rejects embedded credentials;
|
|
125
|
+
* this also covers a hand-edited config, dropping userinfo and masking credential-shaped query
|
|
126
|
+
* values while keeping ordinary parameters such as `api-version`.
|
|
127
|
+
*/
|
|
128
|
+
export function providerListBaseUrl(baseUrl: string): string {
|
|
129
|
+
let parsed: URL;
|
|
130
|
+
try { parsed = new URL(baseUrl); } catch { return baseUrl; }
|
|
131
|
+
let changed = false;
|
|
132
|
+
if (parsed.username || parsed.password) {
|
|
133
|
+
parsed.username = "";
|
|
134
|
+
parsed.password = "";
|
|
135
|
+
changed = true;
|
|
136
|
+
}
|
|
137
|
+
for (const key of [...new Set(parsed.searchParams.keys())]) {
|
|
138
|
+
if (SENSITIVE_KEY_PATTERN.test(key) || SENSITIVE_QUERY_KEY.test(key)) {
|
|
139
|
+
parsed.searchParams.set(key, "redacted");
|
|
140
|
+
changed = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return changed ? parsed.toString() : baseUrl;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function handleList(args: string[], active: boolean = localProvidersActive()): void {
|
|
106
147
|
const wantsJson = consumeFlag(args, "--json");
|
|
107
148
|
const wantsJsonl = consumeFlag(args, "--jsonl");
|
|
108
149
|
rejectUnknownArgs(args, "Usage: ocx provider list [--json|--jsonl]");
|
|
@@ -120,12 +161,13 @@ function handleList(args: string[]): void {
|
|
|
120
161
|
return {
|
|
121
162
|
name,
|
|
122
163
|
adapter: prov.adapter,
|
|
123
|
-
baseUrl: prov.baseUrl,
|
|
164
|
+
baseUrl: providerListBaseUrl(prov.baseUrl),
|
|
124
165
|
authMode: prov.authMode ?? "key",
|
|
125
166
|
defaultModel: prov.defaultModel ?? null,
|
|
126
167
|
isDefault: name === config.defaultProvider,
|
|
127
168
|
source: registryEntry ? "registry" : "custom",
|
|
128
169
|
models: prov.models ?? [],
|
|
170
|
+
active,
|
|
129
171
|
};
|
|
130
172
|
});
|
|
131
173
|
|
|
@@ -149,7 +191,7 @@ function handleList(args: string[]): void {
|
|
|
149
191
|
console.log(` ${name}${isDefault}${source} adapter=${prov.adapter}${model}`);
|
|
150
192
|
}
|
|
151
193
|
|
|
152
|
-
const available = PROVIDER_REGISTRY.filter(e => !configured.includes(e.id));
|
|
194
|
+
const available = PROVIDER_REGISTRY.filter(e => !e.deprecated && !configured.includes(e.id));
|
|
153
195
|
if (available.length > 0) {
|
|
154
196
|
console.log(`\nAvailable from registry (${available.length}):\n`);
|
|
155
197
|
for (const entry of available) {
|
|
@@ -223,6 +265,7 @@ async function handleAdd(args: string[], deps: ProviderCommandDeps): Promise<voi
|
|
|
223
265
|
const registryEntry = getProviderRegistryEntry(name);
|
|
224
266
|
|
|
225
267
|
if (registryEntry) {
|
|
268
|
+
if (registryEntry.deprecated) console.warn(`Warning: provider "${name}" is deprecated: ${registryEntry.deprecated}`);
|
|
226
269
|
provConfig = providerConfigSeed(registryEntry);
|
|
227
270
|
if (apiKey) {
|
|
228
271
|
const candidateError = providerCredentialInputError({ apiKey });
|