@bitkyc08/opencodex 2.68.0 → 2.69.0-preview.20260928
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 +25 -17
- package/gui/dist/assets/App-cDPyCb_V.js +51 -0
- package/gui/dist/assets/{Tray-Bn8WhKBH.js → Tray-H1IAB8mN.js} +1 -1
- package/gui/dist/assets/index-BF9QnKO_.css +1 -0
- package/gui/dist/assets/index-CI6YTp8z.js +86 -0
- package/gui/dist/assets/{quota-summary-DL-bNuNL.js → quota-summary-CutOlPu2.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/factory-droid.svg +8 -0
- package/package.json +1 -1
- package/src/adapters/anthropic-output-schema.ts +109 -11
- package/src/adapters/coding-agent/protocol.ts +106 -11
- package/src/adapters/coding-agent/turn.ts +23 -18
- package/src/adapters/google-errors.ts +16 -1
- package/src/adapters/google-http.ts +14 -0
- package/src/adapters/openai-chat/passthrough.ts +2 -0
- package/src/adapters/openai-chat/summary-budget.ts +72 -0
- package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
- package/src/adapters/openai-chat.ts +6 -9
- package/src/bridge/response-json.ts +3 -1
- package/src/bridge/sse.ts +3 -1
- package/src/claude/desktop-picker.ts +6 -2
- package/src/claude/desktop-system-proxy.ts +155 -0
- package/src/claude/inbound-model-options.ts +10 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
- package/src/claude/intercept/picker-ca.ts +283 -83
- package/src/claude/intercept/picker-models.ts +4 -1
- package/src/claude/intercept/picker-trust.ts +15 -2
- package/src/claude/intercept/runtime.ts +99 -32
- package/src/cli/account-api.ts +5 -2
- package/src/cli/account-extended.ts +47 -11
- package/src/cli/account-target.ts +12 -7
- package/src/cli/account.ts +47 -3
- package/src/cli/capabilities.ts +33 -7
- package/src/cli/claude-agent-startup-sync.ts +2 -0
- package/src/cli/claude-desktop.ts +37 -13
- package/src/cli/cross-home-owner.ts +138 -0
- package/src/cli/dispatch.ts +1 -1
- package/src/cli/doctor.ts +18 -8
- package/src/cli/ensure-desired-integrations.ts +5 -0
- package/src/cli/export-command.ts +6 -6
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +15 -18
- package/src/cli/integrations.ts +8 -1
- package/src/cli/models-runtime-subcommands.ts +1 -0
- package/src/cli/models-runtime.ts +106 -4
- package/src/cli/models.ts +6 -1
- package/src/cli/opencode.ts +2 -83
- package/src/cli/registry.ts +3 -2
- package/src/cli/status.ts +86 -7
- package/src/client/connect.ts +24 -10
- package/src/client/link-ingress.ts +3 -1
- package/src/client/link-join.ts +76 -9
- package/src/client/link-relay-transport.ts +184 -0
- package/src/client/link-relay.ts +16 -4
- package/src/client/machine-listener.ts +2 -0
- package/src/clients/config-export/constants.ts +8 -0
- package/src/clients/config-export/contracts.ts +13 -1
- package/src/clients/config-export/droid.ts +71 -0
- package/src/clients/config-export/kilo.ts +116 -0
- package/src/clients/config-export.ts +33 -2
- package/src/codex/auth-api/routes.ts +1 -1
- package/src/codex/catalog/access-programs.ts +14 -2
- package/src/codex/catalog/provider-models.ts +1 -1
- package/src/codex/catalog-auto-refresh.ts +122 -0
- package/src/codex/config-drift-heal.ts +80 -0
- package/src/codex/desktop-switches.ts +7 -5
- package/src/codex/inject/bounded-config-reader.ts +69 -0
- package/src/codex/inject/config-toml.ts +31 -3
- package/src/codex/inject/routing-target.ts +6 -1
- package/src/codex/inject.ts +4 -0
- package/src/codex/model-entitlements.ts +32 -5
- package/src/codex/native-profile-startup.ts +17 -5
- package/src/codex/project-config-warnings.ts +72 -21
- package/src/codex/quota-auto-refresh-state.ts +8 -2
- package/src/codex/quota-auto-refresh.ts +80 -9
- package/src/codex/sync.ts +10 -0
- package/src/combos/failover.ts +4 -1
- package/src/combos/jev.ts +15 -4
- package/src/combos/types.ts +16 -0
- package/src/config/diagnostics.ts +19 -1
- package/src/config/load-degrade.ts +29 -0
- package/src/config/macos-system-proxy.ts +114 -0
- package/src/config/proxy-env.ts +67 -20
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +23 -0
- package/src/config.ts +2 -0
- package/src/generated/compatibility-version.json +220 -144
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/config-io.ts +12 -3
- package/src/integrations/droid-settings.ts +65 -0
- package/src/integrations/kilo-candidates.ts +30 -0
- package/src/integrations/mutation-plan.ts +62 -7
- package/src/integrations/native/ownership-preflight.ts +46 -12
- package/src/integrations/registry.ts +136 -7
- package/src/integrations/state.ts +52 -4
- package/src/integrations/target.ts +3 -0
- package/src/integrations/writer.ts +55 -14
- package/src/lib/account-selection-events.ts +14 -0
- package/src/lib/jsonc.ts +110 -0
- package/src/lib/standalone.ts +9 -1
- package/src/lib/windows-system-proxy.ts +101 -1
- package/src/link/relay-auth.ts +29 -0
- package/src/link/ssh-argv.ts +6 -3
- package/src/link/ssh-runner.ts +5 -5
- package/src/oauth/generic-account-failover.ts +52 -8
- package/src/oauth/index.ts +37 -14
- package/src/oauth/store.ts +96 -11
- package/src/oauth/token-guardian.ts +7 -3
- package/src/oauth/types.ts +2 -0
- package/src/plugins/loader.ts +5 -3
- package/src/providers/fastwire.ts +3 -0
- package/src/providers/key-failover.ts +13 -5
- package/src/providers/kiro-model-catalog.ts +4 -3
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-tiers-destination.ts +9 -0
- package/src/providers/quota/account-cache.ts +17 -3
- package/src/providers/quota/vendor-probes-oauth.ts +2 -0
- package/src/providers/quota.ts +2 -2
- package/src/responses/code-mode-helper-compat.ts +14 -1
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/server/auth-cors.ts +1 -0
- package/src/server/images.ts +70 -20
- package/src/server/index/link-listener.ts +20 -3
- package/src/server/index/link-relay-sessions.ts +138 -0
- package/src/server/index/optional-listeners.ts +11 -1
- package/src/server/index/serve-options.ts +12 -0
- package/src/server/inference/client-encoder-delivery.ts +1 -0
- package/src/server/local-management-read-client.ts +21 -2
- package/src/server/management/agent-settings-routes.ts +19 -5
- package/src/server/management/combo-routes.ts +5 -3
- package/src/server/management/config-routes.ts +32 -6
- package/src/server/management/model-routes.ts +232 -7
- package/src/server/management/model-rows.ts +199 -1
- package/src/server/management/oauth-account-routes.ts +63 -3
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/port-reclaim.ts +170 -32
- package/src/server/responses/adapter-delivery.ts +6 -4
- package/src/server/responses/adapter-dispatch.ts +72 -2
- package/src/server/responses/core-combo.ts +6 -1
- package/src/server/responses/core-normalize.ts +19 -4
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/memory-models.ts +169 -0
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/passthrough-dispatch.ts +10 -0
- package/src/server/responses/request-prepare.ts +86 -4
- package/src/server/responses/request-transport.ts +4 -0
- package/src/server/responses/run-turn-execution.ts +5 -2
- package/src/server/responses/shadow-target-availability.ts +17 -4
- package/src/server/responses/sidecar-execution.ts +18 -2
- package/src/server/responses/skills-snapshot.ts +239 -0
- package/src/server/responses-undeclared-tool-guard.ts +43 -6
- package/src/server/search.ts +1 -1
- package/src/service/guards.ts +4 -5
- package/src/service/state.ts +39 -2
- package/src/service.ts +2 -2
- package/src/types/config.ts +46 -4
- package/src/types/provider.ts +9 -0
- package/src/types/request.ts +6 -0
- package/src/types/tools.ts +38 -8
- package/src/types.ts +3 -0
- package/src/update/async-check.ts +5 -1
- package/src/update/index.ts +30 -12
- package/src/update/job.ts +3 -1
- package/src/update/pnpm-read-policy.d.mts +4 -0
- package/src/update/pnpm-read-policy.mjs +44 -0
- package/src/update/worker-launch.ts +152 -11
- package/src/usage/cost.ts +6 -3
- package/src/usage/log.ts +4 -0
- package/src/web-search/backends.ts +2 -2
- package/src/web-search/devin-executor.ts +1 -1
- package/src/web-search/sidecar-providers.ts +5 -5
- package/gui/dist/assets/App-mpsibsFj.js +0 -51
- package/gui/dist/assets/index-BDUBS8PW.css +0 -1
- package/gui/dist/assets/index-kUE8tuAK.js +0 -86
package/src/cli/opencode.ts
CHANGED
|
@@ -51,6 +51,8 @@ import type { OcxConfig } from "../types";
|
|
|
51
51
|
import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
|
|
52
52
|
import { selfLaunchArgv } from "../lib/self-launch-argv";
|
|
53
53
|
import { withoutSiblingMarker } from "../codex/sibling-start";
|
|
54
|
+
import { parseJsonc } from "../lib/jsonc";
|
|
55
|
+
export { parseJsonc };
|
|
54
56
|
|
|
55
57
|
/**
|
|
56
58
|
* The provider-block serializer, its constants, and the config-path helpers now live in
|
|
@@ -124,89 +126,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
124
126
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
125
127
|
}
|
|
126
128
|
|
|
127
|
-
/**
|
|
128
|
-
* Strip `//` and block comments outside string literals. Escape-aware so a quote inside
|
|
129
|
-
* an escaped sequence cannot flip string state and expose config text to the stripper.
|
|
130
|
-
*/
|
|
131
|
-
function stripJsonComments(text: string): string {
|
|
132
|
-
let out = "";
|
|
133
|
-
let inString = false;
|
|
134
|
-
let inLine = false;
|
|
135
|
-
let inBlock = false;
|
|
136
|
-
for (let i = 0; i < text.length; i++) {
|
|
137
|
-
const ch = text[i]!;
|
|
138
|
-
const next = text[i + 1];
|
|
139
|
-
if (inLine) {
|
|
140
|
-
if (ch === "\n") {
|
|
141
|
-
inLine = false;
|
|
142
|
-
out += ch;
|
|
143
|
-
}
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
if (inBlock) {
|
|
147
|
-
// Newlines are preserved so JSON.parse error positions stay meaningful.
|
|
148
|
-
if (ch === "\n") out += ch;
|
|
149
|
-
else if (ch === "*" && next === "/") { inBlock = false; i++; }
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
if (inString) {
|
|
153
|
-
out += ch;
|
|
154
|
-
if (ch === "\\") {
|
|
155
|
-
const escaped = text[i + 1];
|
|
156
|
-
if (escaped !== undefined) { out += escaped; i++; }
|
|
157
|
-
continue;
|
|
158
|
-
}
|
|
159
|
-
if (ch === "\"") inString = false;
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
if (ch === "\"") { inString = true; out += ch; continue; }
|
|
163
|
-
if (ch === "/" && next === "/") { inLine = true; i++; continue; }
|
|
164
|
-
if (ch === "/" && next === "*") { inBlock = true; i++; continue; }
|
|
165
|
-
out += ch;
|
|
166
|
-
}
|
|
167
|
-
return out;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Drop commas that sit directly before `}` or `]`, ignoring string contents. */
|
|
171
|
-
function stripTrailingCommas(text: string): string {
|
|
172
|
-
let out = "";
|
|
173
|
-
let inString = false;
|
|
174
|
-
for (let i = 0; i < text.length; i++) {
|
|
175
|
-
const ch = text[i]!;
|
|
176
|
-
if (inString) {
|
|
177
|
-
out += ch;
|
|
178
|
-
if (ch === "\\") {
|
|
179
|
-
const escaped = text[i + 1];
|
|
180
|
-
if (escaped !== undefined) { out += escaped; i++; }
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
if (ch === "\"") inString = false;
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
if (ch === "\"") { inString = true; out += ch; continue; }
|
|
187
|
-
if (ch === ",") {
|
|
188
|
-
let j = i + 1;
|
|
189
|
-
while (j < text.length && /\s/.test(text[j]!)) j++;
|
|
190
|
-
if (text[j] === "}" || text[j] === "]") continue;
|
|
191
|
-
}
|
|
192
|
-
out += ch;
|
|
193
|
-
}
|
|
194
|
-
return out;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* opencode documents opencode.json as JSONC, so a valid user config may carry comments
|
|
199
|
-
* or trailing commas. Strict JSON.parse runs first and untouched — the tolerant path is
|
|
200
|
-
* only attempted when that throws, keeping well-formed configs away from the stripper.
|
|
201
|
-
*/
|
|
202
|
-
export function parseJsonc(text: string): unknown {
|
|
203
|
-
try {
|
|
204
|
-
return JSON.parse(text);
|
|
205
|
-
} catch {
|
|
206
|
-
return JSON.parse(stripTrailingCommas(stripJsonComments(text)));
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
129
|
/** Model key as the proxy routes it: `provider/id` for routed models, bare slug for native OpenAI entries. */
|
|
211
130
|
export function opencodeModelKey(provider: string, id: string): string {
|
|
212
131
|
return provider === "native" ? id : `${provider}/${id}`;
|
package/src/cli/registry.ts
CHANGED
|
@@ -416,12 +416,13 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
|
|
|
416
416
|
},
|
|
417
417
|
{
|
|
418
418
|
name: "export",
|
|
419
|
-
usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode|zcode|prime|aside|raycast|omo|cline> [--json] [--out <path>] [--force]",
|
|
420
|
-
summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline) wired to the running proxy.",
|
|
419
|
+
usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode|zcode|prime|aside|raycast|omo|cline|kilo|droid> [--json] [--out <path>] [--force]",
|
|
420
|
+
summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline, Kilo, Factory Droid) wired to the running proxy.",
|
|
421
421
|
details: [
|
|
422
422
|
"--json prints the generated document as JSON on stdout; use --out for the client's native format.",
|
|
423
423
|
"--out <path> writes the native config there and refuses to replace an existing file without --force.",
|
|
424
424
|
"Cline exports a two-document bundle: settings for providers.json and catalog for sibling models.json. Use integration client enable --client cline for a journaled write.",
|
|
425
|
+
"Droid exports documented customModels for settings.json; use integration client enable --client droid for a journaled write.",
|
|
425
426
|
"The config never contains a real key; it carries a documented env reference or a non-secret loopback placeholder.",
|
|
426
427
|
"The destination path is printed for merging by hand — ocx never writes your real client config.",
|
|
427
428
|
],
|
package/src/cli/status.ts
CHANGED
|
@@ -31,6 +31,8 @@ import { tokenCollidesWithAdmin } from "../lib/admin-secrets";
|
|
|
31
31
|
export { proxyHealthFailureReason, isConnectionRefused, isUncleanExitEvidence, probeUncleanExitState } from "./status-probes";
|
|
32
32
|
export type { ListenTarget } from "./status-probes";
|
|
33
33
|
import { checkProxyHealth, probeUncleanExitState, type ListenTarget } from "./status-probes";
|
|
34
|
+
import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability";
|
|
35
|
+
import { fetchBoundLocalManagementRead } from "../server/local-management-read-client";
|
|
34
36
|
|
|
35
37
|
/**
|
|
36
38
|
* The state of the data-plane admission secret the SERVICE will use. State only -- never the value.
|
|
@@ -235,6 +237,84 @@ function statusDashboardUrl(config: StatusListenConfig, hostname: string | undef
|
|
|
235
237
|
return `http://${dashboardHostname}:${port}/`;
|
|
236
238
|
}
|
|
237
239
|
|
|
240
|
+
const STARTUP_HEALTH_BOOLEAN_FIELDS = [
|
|
241
|
+
"routingInjected", "localRoutingDependency", "autostartEnabled", "rebootSafe",
|
|
242
|
+
"serviceInstalled", "serviceViable", "serviceEnabled", "serviceRunning",
|
|
243
|
+
"serviceStale", "serviceConflict", "shimInstalled", "shimHealthy",
|
|
244
|
+
"serviceSupported", "diagnosticStale",
|
|
245
|
+
] as const;
|
|
246
|
+
|
|
247
|
+
export async function fetchLiveStartupHealth(
|
|
248
|
+
live: NonNullable<Awaited<ReturnType<typeof findLiveProxy>>>,
|
|
249
|
+
deps: Parameters<typeof fetchBoundLocalManagementRead>[2] = {},
|
|
250
|
+
): Promise<StartupHealth | null> {
|
|
251
|
+
const result = await fetchBoundLocalManagementRead(
|
|
252
|
+
live, LOCAL_MANAGEMENT_READ_PATHS.startupHealth, { timeoutMs: 1_500, ...deps, requireResponseProof: true },
|
|
253
|
+
);
|
|
254
|
+
if (result.kind !== "response" || !result.response.ok) return null;
|
|
255
|
+
let payload: unknown;
|
|
256
|
+
try { payload = await result.response.json(); } catch { return null; }
|
|
257
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
258
|
+
const row = payload as Record<string, unknown>;
|
|
259
|
+
if (row.status !== "native" && row.status !== "protected" && row.status !== "at-risk") return null;
|
|
260
|
+
if (row.protection !== "service" && row.protection !== "shim" && row.protection !== "none") return null;
|
|
261
|
+
if (row.routingKind !== "native" && row.routingKind !== "opencodex-local"
|
|
262
|
+
&& row.routingKind !== "custom-local" && row.routingKind !== "custom-remote" && row.routingKind !== "unknown") return null;
|
|
263
|
+
if (row.shimCoverage !== "full" && row.shimCoverage !== "cli-only" && row.shimCoverage !== "none") return null;
|
|
264
|
+
if (typeof row.platform !== "string") return null;
|
|
265
|
+
if (row.recommendedCommand !== null && typeof row.recommendedCommand !== "string") return null;
|
|
266
|
+
if (!row.commands || typeof row.commands !== "object" || Array.isArray(row.commands)) return null;
|
|
267
|
+
for (const key of ["installService", "repairService", "installShim", "restoreNative"] as const) {
|
|
268
|
+
if (typeof (row.commands as Record<string, unknown>)[key] !== "string") return null;
|
|
269
|
+
}
|
|
270
|
+
if (row.routingAdoption !== undefined) {
|
|
271
|
+
if (!row.routingAdoption || typeof row.routingAdoption !== "object" || Array.isArray(row.routingAdoption)) return null;
|
|
272
|
+
const adoption = row.routingAdoption as Record<string, unknown>;
|
|
273
|
+
if (adoption.adoption !== "not-applicable" && adoption.adoption !== "adopted"
|
|
274
|
+
&& adoption.adoption !== "pending-client-restart" && adoption.adoption !== "unknown") return null;
|
|
275
|
+
if (adoption.injectedAtMs !== null && typeof adoption.injectedAtMs !== "number") return null;
|
|
276
|
+
if (typeof adoption.observedClients !== "number" || !Array.isArray(adoption.staleClients)) return null;
|
|
277
|
+
for (const client of adoption.staleClients) {
|
|
278
|
+
if (!client || typeof client !== "object" || Array.isArray(client)) return null;
|
|
279
|
+
const row = client as Record<string, unknown>;
|
|
280
|
+
if (typeof row.pid !== "number" || typeof row.startedAtMs !== "number") return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
for (const key of STARTUP_HEALTH_BOOLEAN_FIELDS) if (typeof row[key] !== "boolean") return null;
|
|
284
|
+
return payload as StartupHealth;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Prefer an attested live verdict and evaluate the local fallback only when live state is absent. */
|
|
288
|
+
export function selectStatusStartupHealth(
|
|
289
|
+
liveStartup: StartupHealth | null,
|
|
290
|
+
fallback: () => StartupHealth,
|
|
291
|
+
): StartupHealth {
|
|
292
|
+
return liveStartup ?? fallback();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Build the service summary from the same startup source that `ocx status` selected. */
|
|
296
|
+
export function statusServiceSummary(
|
|
297
|
+
liveStartup: StartupHealth | null,
|
|
298
|
+
service: Pick<ReturnType<typeof diagnoseService>, "installed" | "summary">,
|
|
299
|
+
live: boolean,
|
|
300
|
+
): string {
|
|
301
|
+
if (liveStartup) {
|
|
302
|
+
if (liveStartup.protection === "service" && liveStartup.serviceViable) {
|
|
303
|
+
return `running under the live managed service (logs: ${serviceLogPath()})`;
|
|
304
|
+
}
|
|
305
|
+
const state = [
|
|
306
|
+
liveStartup.serviceInstalled ? "installed" : "absent",
|
|
307
|
+
liveStartup.serviceRunning ? "running" : "not running",
|
|
308
|
+
liveStartup.serviceViable ? "viable" : "not viable",
|
|
309
|
+
].join(", ");
|
|
310
|
+
const action = liveStartup.recommendedCommand ? `; run '${liveStartup.recommendedCommand}'` : "";
|
|
311
|
+
return `live startup reports service ${state}${action} (logs: ${serviceLogPath()})`;
|
|
312
|
+
}
|
|
313
|
+
return service.installed && !live
|
|
314
|
+
? `${service.summary} — registered but NOT serving; see ${serviceLogPath()} and re-run 'ocx service repair'`
|
|
315
|
+
: service.summary;
|
|
316
|
+
}
|
|
317
|
+
|
|
238
318
|
/**
|
|
239
319
|
* The hub block, or null when this machine is not a hub.
|
|
240
320
|
*
|
|
@@ -666,20 +746,19 @@ export async function collectStatus(): Promise<CliStatusView> {
|
|
|
666
746
|
hostname: config.hostname,
|
|
667
747
|
});
|
|
668
748
|
const bunRuntime = durableBunRuntime();
|
|
749
|
+
const liveStartup = live ? await fetchLiveStartupHealth(live) : null;
|
|
669
750
|
const service = diagnoseService();
|
|
670
751
|
// A service can be registered and still not serve: the manager reports the job
|
|
671
|
-
// either way.
|
|
672
|
-
//
|
|
673
|
-
const serviceSummary = service
|
|
674
|
-
? `${service.summary} — registered but NOT serving; see ${serviceLogPath()} and re-run 'ocx service repair'`
|
|
675
|
-
: service.summary;
|
|
752
|
+
// either way. When the identity-probed live proxy provides an attested startup verdict,
|
|
753
|
+
// prefer it over a shell-local service-manager probe that lacks the service environment.
|
|
754
|
+
const serviceSummary = statusServiceSummary(liveStartup, service, Boolean(live));
|
|
676
755
|
const codexShim = diagnoseCodexShim();
|
|
677
756
|
const codexShimSummary = codexShim.summary;
|
|
678
|
-
const startup = collectStartupHealth(config, {
|
|
757
|
+
const startup = selectStatusStartupHealth(liveStartup, () => collectStartupHealth(config, {
|
|
679
758
|
service,
|
|
680
759
|
shim: codexShim,
|
|
681
760
|
routingKind: getCodexRoutingKind(),
|
|
682
|
-
});
|
|
761
|
+
}));
|
|
683
762
|
const codexPlugins = diagnoseCodexBundledPlugins();
|
|
684
763
|
const lastClamp = loadLastEffortClamp();
|
|
685
764
|
const clampActive = effortClampAppliesToRuntime(lastClamp, resolvedRuntime.runtime);
|
package/src/client/connect.ts
CHANGED
|
@@ -104,6 +104,8 @@ export interface LinkClientCredential {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
export interface ClientConnectDeps {
|
|
107
|
+
/** Abort enrollment network work and refuse subsequent writes; rollback still drains. */
|
|
108
|
+
signal?: AbortSignal;
|
|
107
109
|
fetchImpl?: typeof fetch;
|
|
108
110
|
now?: () => Date;
|
|
109
111
|
lifecycleLockDeps?: ClientLifecycleLockDeps;
|
|
@@ -539,6 +541,18 @@ export async function connectClient(
|
|
|
539
541
|
options: ConnectOptions,
|
|
540
542
|
deps: ClientConnectDeps = {},
|
|
541
543
|
): Promise<OcxClientConnectionConfig> {
|
|
544
|
+
deps.signal?.throwIfAborted();
|
|
545
|
+
const rawFetch = deps.fetchImpl ?? fetch;
|
|
546
|
+
const fetchImpl: typeof fetch = deps.signal ? Object.assign(async (...[input, init = {}]: Parameters<typeof fetch>) => {
|
|
547
|
+
deps.signal!.throwIfAborted();
|
|
548
|
+
const signals = [deps.signal, init.signal, input instanceof Request ? input.signal : undefined]
|
|
549
|
+
.filter((signal): signal is AbortSignal => signal != null);
|
|
550
|
+
return rawFetch(input, { ...init, signal: AbortSignal.any(signals), redirect: "manual" });
|
|
551
|
+
}, { preconnect: rawFetch.preconnect }) : rawFetch;
|
|
552
|
+
const assertActiveConnectingState = (fingerprint?: string) => {
|
|
553
|
+
deps.signal?.throwIfAborted();
|
|
554
|
+
assertConnectingState(fingerprint);
|
|
555
|
+
};
|
|
542
556
|
let serverUrl = "";
|
|
543
557
|
let managementUrl = "";
|
|
544
558
|
let linkAdmissionToken: string | null = null;
|
|
@@ -586,7 +600,7 @@ export async function connectClient(
|
|
|
586
600
|
throw new Error("link mode requires a valid local config port");
|
|
587
601
|
}
|
|
588
602
|
withClientLifecycleSync(() => withConfigMutationLockSync(() => {
|
|
589
|
-
|
|
603
|
+
assertActiveConnectingState();
|
|
590
604
|
const externalProvider = currentExternalCodexModelProvider();
|
|
591
605
|
if (externalProvider) throw new Error("connect refused: an external Codex provider owns config.toml");
|
|
592
606
|
if (linkMode) {
|
|
@@ -603,7 +617,7 @@ export async function connectClient(
|
|
|
603
617
|
}
|
|
604
618
|
}), deps.lifecycleLockDeps);
|
|
605
619
|
|
|
606
|
-
const upstreamFetch =
|
|
620
|
+
const upstreamFetch = fetchImpl;
|
|
607
621
|
const readinessFetch = linkMode
|
|
608
622
|
? (async (input, init = {}) => {
|
|
609
623
|
const url = input instanceof Request ? input.url : String(input);
|
|
@@ -622,18 +636,18 @@ export async function connectClient(
|
|
|
622
636
|
managementUrl,
|
|
623
637
|
localGuiOrigin(),
|
|
624
638
|
options.credential.value,
|
|
625
|
-
{ fetchImpl
|
|
639
|
+
{ fetchImpl },
|
|
626
640
|
);
|
|
627
641
|
cleanupCredential = { kind: "gui-session", value: session };
|
|
628
642
|
} else if (!linkMode && options.credential.kind === "admin") {
|
|
629
643
|
cleanupCredential = { kind: "admin", value: options.credential.value };
|
|
630
644
|
}
|
|
631
645
|
if (!linkMode) {
|
|
632
|
-
issued = await issueClientKey(managementUrl, cleanupCredential!, clientKeyName(), { fetchImpl
|
|
646
|
+
issued = await issueClientKey(managementUrl, cleanupCredential!, clientKeyName(), { fetchImpl });
|
|
633
647
|
}
|
|
634
648
|
|
|
635
649
|
const initialFiles = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
|
|
636
|
-
|
|
650
|
+
assertActiveConnectingState(linkMode ? pendingConnectFingerprint! : undefined);
|
|
637
651
|
const persisted = linkMode
|
|
638
652
|
? (() => {
|
|
639
653
|
const current = readServiceApiTokenState();
|
|
@@ -656,7 +670,7 @@ export async function connectClient(
|
|
|
656
670
|
if (!admissionToken) throw new Error("client admission credential unavailable");
|
|
657
671
|
const apiKeyId = linkMode ? (options.credential as LinkClientCredential).apiKeyId : issued!.id;
|
|
658
672
|
const catalog = await downloadClientCatalog(serverUrl, admissionToken, {
|
|
659
|
-
fetchImpl
|
|
673
|
+
fetchImpl,
|
|
660
674
|
timeoutMs: options.catalogTimeoutMs,
|
|
661
675
|
});
|
|
662
676
|
// Fail closed BEFORE the write (#4207). The hub being reachable and the credential working
|
|
@@ -666,7 +680,7 @@ export async function connectClient(
|
|
|
666
680
|
// than writing one and restoring it afterwards.
|
|
667
681
|
assertClientCatalogCompatible(catalog.body, deps.catalogCompatibility);
|
|
668
682
|
writtenCatalogFingerprint = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
|
|
669
|
-
|
|
683
|
+
assertActiveConnectingState(persisted.fingerprint);
|
|
670
684
|
atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body);
|
|
671
685
|
return sha256(catalog.body);
|
|
672
686
|
}), deps.lifecycleLockDeps);
|
|
@@ -679,7 +693,7 @@ export async function connectClient(
|
|
|
679
693
|
routingTarget: target,
|
|
680
694
|
catalogPath: DEFAULT_CATALOG_PATH,
|
|
681
695
|
journalOwner: { kind: "client", apiKeyId },
|
|
682
|
-
beforeClientWrite: () =>
|
|
696
|
+
beforeClientWrite: () => assertActiveConnectingState(persisted.fingerprint),
|
|
683
697
|
});
|
|
684
698
|
if (!preflight.success) throw new Error(preflight.message);
|
|
685
699
|
|
|
@@ -688,7 +702,7 @@ export async function connectClient(
|
|
|
688
702
|
routingTarget: target,
|
|
689
703
|
catalogPath: DEFAULT_CATALOG_PATH,
|
|
690
704
|
journalOwner: { kind: "client", apiKeyId },
|
|
691
|
-
beforeClientWrite: () =>
|
|
705
|
+
beforeClientWrite: () => assertActiveConnectingState(persisted.fingerprint),
|
|
692
706
|
});
|
|
693
707
|
if (!injected.success || injected.status === "skipped") throw new Error(injected.message);
|
|
694
708
|
injectionCommitted = true;
|
|
@@ -715,7 +729,7 @@ export async function connectClient(
|
|
|
715
729
|
...(linkMode ? { transport: "link" as const, link: linkMetadata } : {}),
|
|
716
730
|
};
|
|
717
731
|
withClientLifecycleSync(() => withConfigMutationLockSync(() => {
|
|
718
|
-
|
|
732
|
+
assertActiveConnectingState(persisted.fingerprint);
|
|
719
733
|
clearClientConnectPending(persisted.fingerprint);
|
|
720
734
|
commitClientConnection(connection);
|
|
721
735
|
committed = true;
|
|
@@ -43,6 +43,8 @@ export function createLinkKeySource(expectedFingerprint: string, deps: LinkKeySo
|
|
|
43
43
|
|
|
44
44
|
export interface LinkIngress {
|
|
45
45
|
tunnelPort: number;
|
|
46
|
+
linkId?: string;
|
|
47
|
+
apiKeyId?: string;
|
|
46
48
|
/** The listener's loopback policy: the standalone Host and Origin anti-rebinding gate. */
|
|
47
49
|
policy: RequestPolicyView;
|
|
48
50
|
linkKey: () => string | null;
|
|
@@ -98,5 +100,5 @@ export function handleLinkIngress(
|
|
|
98
100
|
const admissionKey = ingress.linkKey();
|
|
99
101
|
if (!admissionKey) return Response.json({ error: "link_credential_unavailable" }, { status: 503 });
|
|
100
102
|
liftRequestIdleTimer(req, server);
|
|
101
|
-
return relayLinkDataRequest(req, { tunnelPort: ingress.tunnelPort, admissionKey }, ingress.relay);
|
|
103
|
+
return relayLinkDataRequest(req, { tunnelPort: ingress.tunnelPort, admissionKey, linkId: ingress.linkId, apiKeyId: ingress.apiKeyId }, ingress.relay);
|
|
102
104
|
}
|
package/src/client/link-join.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
3
|
import { isPortAvailable } from "../server/ports";
|
|
4
|
+
import { scanListenPidsForAddress, type ListenPidScan } from "../server/port-reclaim";
|
|
4
5
|
import { isLinkPort, JOIN_TUNNEL_PORT_MAX, JOIN_TUNNEL_PORT_MIN } from "../link/ports";
|
|
5
6
|
import { buildExecArgv, REMOTE_COMMAND_NOT_FOUND, remoteOcxArgv } from "../link/ssh-argv";
|
|
6
7
|
import { sshFailureHint, sshRunnerErrorHint, type SshRunner, type SshRunResult } from "../link/ssh-runner";
|
|
@@ -22,6 +23,7 @@ import type { OcxConnectedClientId } from "../types";
|
|
|
22
23
|
|
|
23
24
|
const JOIN_TUNNEL_READY_TIMEOUT_MS = 15_000;
|
|
24
25
|
const JOIN_TUNNEL_POLL_MS = 100;
|
|
26
|
+
const JOIN_TUNNEL_SPAWN_GRACE_MS = 100;
|
|
25
27
|
const JOIN_REVOKE_TIMEOUT_MS = 30_000;
|
|
26
28
|
const JOIN_CONFIRM_TTL_MS = 5 * 60_000;
|
|
27
29
|
const JOIN_PORT_ATTEMPTS = 32;
|
|
@@ -74,6 +76,12 @@ export interface ClientLinkJoinDeps {
|
|
|
74
76
|
hostname?: () => string;
|
|
75
77
|
randomBytes?: (size: number) => Uint8Array;
|
|
76
78
|
fetchImpl?: typeof fetch;
|
|
79
|
+
/**
|
|
80
|
+
* LISTEN-owner probe for the tunnel port; defaults to the netstat/lsof/ss scan.
|
|
81
|
+
* Receives the loopback address the tunnel binds so listeners on unrelated
|
|
82
|
+
* addresses do not confuse the readiness check.
|
|
83
|
+
*/
|
|
84
|
+
scanListenPids?: (port: number, address?: string) => ListenPidScan;
|
|
77
85
|
spawnTunnel?: (spec: {
|
|
78
86
|
linkId: string;
|
|
79
87
|
alias: string;
|
|
@@ -222,8 +230,10 @@ async function compensateStaleSidecar(deps: ClientLinkJoinDeps): Promise<void> {
|
|
|
222
230
|
}
|
|
223
231
|
}
|
|
224
232
|
|
|
233
|
+
/** Wait for the live tunnel's authenticated readiness, retaining the deadline after failed ownership rechecks. */
|
|
225
234
|
async function waitForReady(
|
|
226
235
|
deps: ClientLinkJoinDeps,
|
|
236
|
+
tunnel: ClientLinkTunnelHandle,
|
|
227
237
|
port: number,
|
|
228
238
|
key: string,
|
|
229
239
|
): Promise<void> {
|
|
@@ -231,19 +241,53 @@ async function waitForReady(
|
|
|
231
241
|
const now = deps.now ?? Date.now;
|
|
232
242
|
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
|
|
233
243
|
const deadline = now() + JOIN_TUNNEL_READY_TIMEOUT_MS;
|
|
244
|
+
const tunnelExited = tunnel.exited.then(() => { throw new ClientLinkJoinError("join_tunnel_failed"); });
|
|
245
|
+
await Promise.race([
|
|
246
|
+
tunnelExited,
|
|
247
|
+
new Promise<void>(resolve => setTimeout(resolve, JOIN_TUNNEL_SPAWN_GRACE_MS)),
|
|
248
|
+
]);
|
|
249
|
+
const listenPids = deps.scanListenPids ?? scanListenPidsForAddress;
|
|
250
|
+
// The tunnel binds 127.0.0.1; a listener on a different loopback or interface address
|
|
251
|
+
// never receives our requests, so ownership is only judged among sockets that serve it.
|
|
252
|
+
const tunnelAddress = "127.0.0.1";
|
|
234
253
|
for (;;) {
|
|
235
254
|
try {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
255
|
+
// A squatter answering the 401 challenge would otherwise collect the issued key:
|
|
256
|
+
// the only listener allowed a keyed request is the ssh process we spawned — it owns
|
|
257
|
+
// the port only after a successful bind, and ExitOnForwardFailure makes it exit when
|
|
258
|
+
// it cannot take the port. An unverifiable scan stays "not ready", never a pass.
|
|
259
|
+
const ownership = listenPids(port, tunnelAddress);
|
|
260
|
+
if (ownership.ok && ownership.pids.length === 1 && ownership.pids[0] === tunnel.pid) {
|
|
261
|
+
// Never follow redirects: a port occupant must not reroute the challenge, and a
|
|
262
|
+
// redirected keyed request would carry the issued key to an unrelated listener.
|
|
263
|
+
const probe = await Promise.race([
|
|
264
|
+
tunnelExited,
|
|
265
|
+
fetchImpl(`http://127.0.0.1:${port}/readyz`, { redirect: "manual" }),
|
|
266
|
+
]);
|
|
267
|
+
if (probe.status === 401) {
|
|
268
|
+
// Ownership can flip between the probe and the keyed request (a squatter
|
|
269
|
+
// takes the port after the tunnel dies). Re-scan in the same iteration and
|
|
270
|
+
// skip only the keyed request on failure, not the deadline check and sleep.
|
|
271
|
+
const recheck = listenPids(port, tunnelAddress);
|
|
272
|
+
if (recheck.ok && recheck.pids.length === 1 && recheck.pids[0] === tunnel.pid) {
|
|
273
|
+
const response = await Promise.race([
|
|
274
|
+
tunnelExited,
|
|
275
|
+
fetchImpl(`http://127.0.0.1:${port}/readyz`, {
|
|
276
|
+
headers: { "x-opencodex-api-key": key },
|
|
277
|
+
redirect: "manual",
|
|
278
|
+
}),
|
|
279
|
+
]);
|
|
280
|
+
if (response.status === 200) return;
|
|
281
|
+
if (response.status === 401) throw new ClientLinkJoinError("admission_failed");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
241
285
|
} catch (error) {
|
|
242
286
|
if (error instanceof ClientLinkJoinError) throw error;
|
|
243
287
|
}
|
|
244
288
|
const remaining = deadline - now();
|
|
245
289
|
if (remaining <= 0) throw new ClientLinkJoinError("join_tunnel_failed");
|
|
246
|
-
await sleep(Math.min(JOIN_TUNNEL_POLL_MS, remaining));
|
|
290
|
+
await Promise.race([tunnelExited, sleep(Math.min(JOIN_TUNNEL_POLL_MS, remaining))]);
|
|
247
291
|
}
|
|
248
292
|
}
|
|
249
293
|
|
|
@@ -256,6 +300,7 @@ function requireConfirmedHost(deps: ClientLinkJoinDeps, alias: string): JoinConf
|
|
|
256
300
|
return confirmed;
|
|
257
301
|
}
|
|
258
302
|
|
|
303
|
+
/** Issue and enroll a confirmed Home link, compensating failures before committing the connection. */
|
|
259
304
|
export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string }): Promise<{ linkId: string; apiKeyId: string }> {
|
|
260
305
|
const confirmed = requireConfirmedHost(deps, input.alias);
|
|
261
306
|
await compensateStaleSidecar(deps);
|
|
@@ -308,15 +353,29 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string
|
|
|
308
353
|
configDir: deps.configDir,
|
|
309
354
|
knownHostsFile: deps.knownHostsFile,
|
|
310
355
|
});
|
|
311
|
-
await waitForReady(deps, tunnelPort, issued.key);
|
|
356
|
+
await waitForReady(deps, tunnel, tunnelPort, issued.key);
|
|
312
357
|
} catch (error) {
|
|
313
358
|
const code = error instanceof ClientLinkJoinError ? error.code : "join_tunnel_failed";
|
|
314
359
|
await rollback(deps, issued.linkId, tunnel);
|
|
315
360
|
throw new ClientLinkJoinError(code);
|
|
316
361
|
}
|
|
317
362
|
|
|
363
|
+
const enrollmentAbort = new AbortController();
|
|
364
|
+
let enrollmentFinished = false;
|
|
318
365
|
try {
|
|
366
|
+
if (!tunnel) throw new ClientLinkJoinError("join_tunnel_failed");
|
|
319
367
|
const connect = deps.connect ?? connectClient;
|
|
368
|
+
// A tunnel exit cancels work; it is NOT a competing terminal result. The connect
|
|
369
|
+
// transaction alone decides commit versus rollback, so an exit queued immediately
|
|
370
|
+
// after commit cannot revoke a key that a connected client has already retained.
|
|
371
|
+
void tunnel.exited.then(() => {
|
|
372
|
+
if (!enrollmentFinished) enrollmentAbort.abort(new ClientLinkJoinError("join_tunnel_failed"));
|
|
373
|
+
});
|
|
374
|
+
const signal = deps.connectDeps?.signal
|
|
375
|
+
? AbortSignal.any([enrollmentAbort.signal, deps.connectDeps.signal]) : enrollmentAbort.signal;
|
|
376
|
+
// Observe a tunnel that exited after readiness before starting any enrollment write.
|
|
377
|
+
await Promise.resolve();
|
|
378
|
+
signal.throwIfAborted();
|
|
320
379
|
await connect({
|
|
321
380
|
serverUrl: `http://127.0.0.1:${tunnelPort}`,
|
|
322
381
|
managementUrl: `http://127.0.0.1:${tunnelPort}`,
|
|
@@ -328,10 +387,18 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string
|
|
|
328
387
|
}, {
|
|
329
388
|
fetchImpl: deps.fetchImpl,
|
|
330
389
|
...deps.connectDeps,
|
|
390
|
+
signal,
|
|
331
391
|
});
|
|
332
|
-
} catch {
|
|
392
|
+
} catch (error) {
|
|
393
|
+
// connectClient has drained its local rollback before rejecting. Only then can
|
|
394
|
+
// the tunnel and the remote key be compensated without racing a late writer.
|
|
395
|
+
const tunnelAborted = enrollmentAbort.signal.aborted;
|
|
396
|
+
enrollmentFinished = true;
|
|
333
397
|
await rollback(deps, issued.linkId, tunnel);
|
|
334
|
-
throw new ClientLinkJoinError("
|
|
398
|
+
throw new ClientLinkJoinError(tunnelAborted ? "join_tunnel_failed"
|
|
399
|
+
: error instanceof ClientLinkJoinError ? error.code : "join_connect_failed");
|
|
400
|
+
} finally {
|
|
401
|
+
enrollmentFinished = true;
|
|
335
402
|
}
|
|
336
403
|
|
|
337
404
|
await stopTunnel(tunnel);
|