@bitkyc08/opencodex 2.83.0 → 2.84.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-CZtqiGcw.js +56 -0
- package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
- package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
- package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
- package/gui/dist/assets/{quota-summary-DiJO6sYY.js → quota-summary-BbEeRfPY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/coding-agent/protocol.ts +15 -8
- package/src/adapters/ollama-native.ts +29 -1
- package/src/adapters/openai-chat/messages.ts +8 -4
- package/src/adapters/openai-responses/empty-assistant.ts +32 -0
- package/src/adapters/openai-responses/passthrough.ts +3 -0
- package/src/adapters/openai-responses/request-strips.ts +10 -2
- package/src/adapters/qoder/adapter.ts +16 -14
- package/src/bridge/errors.ts +19 -0
- package/src/bridge.ts +1 -1
- package/src/claude/context-windows.ts +54 -1
- package/src/claude/intercept/connect-proxy.ts +10 -3
- package/src/claude/intercept/picker-ca-persistence.ts +3 -1
- package/src/claude/intercept/picker-listener.ts +49 -1
- package/src/claude/intercept/settings-no-proxy.ts +146 -0
- package/src/claude/intercept/settings.ts +101 -9
- package/src/cli/account-api.ts +65 -0
- package/src/cli/account-auth.ts +26 -2
- package/src/cli/account.ts +41 -5
- package/src/cli/capabilities-access-remote.ts +8 -0
- package/src/cli/capabilities-base.ts +13 -1
- package/src/cli/catalog-command-result.ts +2 -1
- package/src/cli/claude.ts +15 -6
- package/src/cli/config-command.ts +8 -2
- package/src/cli/debug.ts +8 -0
- package/src/cli/dispatch.ts +16 -3
- package/src/cli/export-command.ts +14 -1
- package/src/cli/hub-disable.ts +99 -0
- package/src/cli/hub.ts +4 -2
- package/src/cli/index.ts +13 -4
- package/src/cli/link-child.ts +104 -0
- package/src/cli/link-enrollment.ts +96 -0
- package/src/cli/link.ts +39 -24
- package/src/cli/observe-stream.ts +6 -1
- package/src/cli/observe.ts +10 -2
- package/src/cli/provider.ts +12 -0
- package/src/cli/registry.ts +5 -4
- package/src/cli/runtime-api.ts +27 -2
- package/src/cli/status.ts +104 -20
- package/src/client/connect.ts +39 -4
- package/src/client/hub-client.ts +14 -7
- package/src/client/hub-state.ts +25 -14
- package/src/client/link-home.ts +248 -0
- package/src/client/link-management-relay.ts +61 -0
- package/src/client/link-relay.ts +6 -1
- package/src/client/link-status.ts +14 -0
- package/src/client/machine-api.ts +14 -7
- package/src/client/machine-listener.ts +29 -4
- package/src/clients/config-export.ts +46 -16
- package/src/clients/omo-role-models.ts +6 -3
- package/src/codex/agent-role-models.ts +26 -8
- package/src/codex/auth-api/account-list.ts +17 -2
- package/src/codex/auth-api/account-selection.ts +17 -7
- package/src/codex/auth-api/routes.ts +5 -1
- package/src/codex/autostart-health.ts +3 -1
- package/src/codex/catalog/bundled.ts +5 -1
- package/src/codex/catalog/effort.ts +2 -2
- package/src/codex/catalog/metadata.ts +4 -2
- package/src/codex/catalog/native-models.ts +16 -2
- package/src/codex/native-profile-startup.ts +4 -1
- package/src/codex/prompt-journal.ts +20 -2
- package/src/codex/prompt-layers/encoding.ts +49 -0
- package/src/codex/prompt-layers/import-source.ts +30 -0
- package/src/codex/prompt-layers/toml-edit.ts +269 -37
- package/src/codex/prompt-layers/toml-read.ts +52 -4
- package/src/codex/prompt-layers.ts +383 -20
- package/src/codex/prompt-lock-claim.ts +13 -3
- package/src/codex/prompt-lock-evidence.ts +3 -3
- package/src/codex/prompt-lock-owner.ts +14 -6
- package/src/codex/prompt-text-probe.ts +34 -5
- package/src/codex/quota.ts +13 -6
- package/src/codex/role-auto-assign.ts +2 -5
- package/src/codex/routing/active-account.ts +21 -0
- package/src/codex/routing.ts +1 -1
- package/src/codex/runtime.ts +196 -8
- package/src/codex/shim-overlay.ts +8 -4
- package/src/codex/shim.ts +1 -1
- package/src/combos/failover.ts +3 -0
- package/src/config/api-key-reconcile.ts +34 -0
- package/src/config/diagnostics.ts +16 -0
- package/src/config/feature-flags.ts +5 -0
- package/src/config/live-reconcile.ts +43 -29
- package/src/config/live-topology.ts +29 -0
- package/src/config/load-degrade.ts +2 -1
- package/src/config/paths.ts +3 -0
- package/src/config/salvage.ts +3 -1
- package/src/config/schema/config-schema.ts +2 -0
- package/src/generated/compatibility-version.json +311 -171
- package/src/grok/grpc-web.ts +23 -1
- package/src/grok/reset-coupon-ledger.ts +75 -5
- package/src/grok/reset-coupons.ts +4 -2
- package/src/integrations/mutation-plan.ts +13 -7
- package/src/integrations/registry.ts +45 -11
- package/src/integrations/state.ts +19 -7
- package/src/integrations/target.ts +42 -5
- package/src/integrations/writer.ts +14 -6
- package/src/lib/app-owned-memory-stores.ts +4 -0
- package/src/lib/app-owned-memory.ts +11 -0
- package/src/lib/json-byte-size.ts +31 -0
- package/src/lib/optional-shutdown-hooks.ts +35 -0
- package/src/lib/outbound-credential-registry.ts +210 -0
- package/src/lib/passive-read.ts +35 -0
- package/src/lib/shadow-call.ts +44 -5
- package/src/link/codex-settings.ts +155 -0
- package/src/link/management-routes.ts +89 -0
- package/src/link/remote-probe.ts +38 -0
- package/src/link/ssh-argv.ts +26 -5
- package/src/link/ssh-runner.ts +58 -27
- package/src/link/status-projection.ts +22 -4
- package/src/link/supervisor.ts +144 -49
- package/src/link/tunnel-state.ts +11 -4
- package/src/oauth/anthropic-continuity.ts +4 -3
- package/src/oauth/anthropic-identity.ts +13 -2
- package/src/oauth/anthropic-model-quota.ts +13 -1
- package/src/oauth/anthropic-routing.ts +230 -23
- package/src/oauth/anthropic.ts +18 -4
- package/src/oauth/google-antigravity.ts +24 -7
- package/src/oauth/index.ts +24 -15
- package/src/oauth/kiro-account-identity.ts +106 -0
- package/src/oauth/kiro.ts +48 -5
- package/src/oauth/plan.ts +29 -0
- package/src/oauth/store.ts +40 -3
- package/src/oauth/types.ts +2 -0
- package/src/plugins/loader.ts +11 -0
- package/src/providers/anthropic-billing-ownership.ts +74 -0
- package/src/providers/anthropic-billing-recovery.ts +31 -0
- package/src/providers/anthropic-billing-refusal.ts +55 -0
- package/src/providers/anthropic-reset-admission.ts +62 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
- package/src/providers/api-key-resolve.ts +17 -2
- package/src/providers/key-failover.ts +52 -5
- package/src/providers/model-rename-startup.ts +5 -3
- package/src/providers/quota/account-cache.ts +11 -0
- package/src/providers/quota/anthropic-account-quota.ts +4 -2
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota.ts +4 -1
- package/src/providers/registry/entries-core.ts +14 -14
- package/src/providers/registry/entries-extended.ts +0 -1
- package/src/providers/registry/model-seeds.ts +17 -0
- package/src/providers/request-pacing.ts +9 -0
- package/src/providers/stale-no-vision-seed-migration.ts +72 -0
- package/src/responses/compaction.ts +14 -0
- package/src/responses/parser.ts +7 -1
- package/src/server/auth-cors.ts +42 -0
- package/src/server/configured-credentials.ts +194 -0
- package/src/server/index/link-management.ts +58 -0
- package/src/server/index/optional-listeners.ts +5 -1
- package/src/server/index/serve-options.ts +13 -0
- package/src/server/lifecycle.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +28 -10
- package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
- package/src/server/management/codex-agent-role-routes.ts +19 -6
- package/src/server/management/codex-prompt-routes.ts +136 -5
- package/src/server/management/codex-role-auto-assign.ts +1 -1
- package/src/server/management/grok-coupon-routes.ts +78 -27
- package/src/server/management/link-enrollment.ts +192 -0
- package/src/server/management/link-route-state.ts +185 -0
- package/src/server/management/link-routes.ts +82 -402
- package/src/server/management/model-rows.ts +3 -1
- package/src/server/management/provider-overwrite-carry.ts +10 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/management/route-registry.ts +5 -3
- package/src/server/management/shared.ts +3 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native.ts +68 -42
- package/src/server/relay-eager.ts +2 -0
- package/src/server/request-log-filter.ts +6 -1
- package/src/server/request-log-terminal-status.ts +63 -6
- package/src/server/responses/adapter-dispatch.ts +232 -79
- package/src/server/responses/code-mode-collab-surface.ts +205 -0
- package/src/server/responses/codex-ws-exchange.ts +147 -6
- package/src/server/responses/codex-ws-wire.ts +203 -0
- package/src/server/responses/collaboration.ts +36 -7
- package/src/server/responses/compact.ts +126 -48
- package/src/server/responses/context-overflow.ts +13 -1
- package/src/server/responses/core-codex-account.ts +105 -1
- package/src/server/responses/core-combo-failure.ts +14 -0
- package/src/server/responses/core-combo.ts +7 -2
- package/src/server/responses/core-errors.ts +29 -1
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/deferred-collab-surface.ts +158 -0
- package/src/server/responses/deferred-send-reporter.ts +25 -0
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/passthrough-delivery.ts +4 -1
- package/src/server/responses/passthrough-dispatch.ts +15 -4
- package/src/server/responses/request-prepare.ts +6 -4
- package/src/server/responses/request-transport.ts +6 -8
- package/src/server/responses/sidecar-execution.ts +11 -2
- package/src/server/responses/ws-upstream.ts +9 -1
- package/src/server/startup-action-control.ts +22 -2
- package/src/server/startup-health-cache.ts +4 -0
- package/src/server/system-env-shell.ts +7 -2
- package/src/server/system-env.ts +11 -1
- package/src/service/cli.ts +5 -1
- package/src/service/desktop-startup.ts +147 -34
- package/src/service/desktop-supervision.d.mts +27 -1
- package/src/service/desktop-supervision.mjs +111 -5
- package/src/service/health-sentinel-launch.ts +176 -0
- package/src/service/health-sentinel-main.ts +447 -0
- package/src/service/health-sentinel-policy.ts +241 -0
- package/src/service/health-sentinel-probe.ts +54 -0
- package/src/service/health-sentinel-windows.ts +87 -0
- package/src/service/linux-linger.ts +20 -0
- package/src/types/config.ts +5 -0
- package/src/usage/expected-prices.ts +4 -0
- package/src/usage/log.ts +19 -1
- package/gui/dist/assets/App-Ba8uG696.js +0 -54
- package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
- package/gui/dist/assets/index-BHRZdJuv.js +0 -94
|
@@ -30,11 +30,14 @@ import {
|
|
|
30
30
|
composeProjection,
|
|
31
31
|
computePromptProbeStateFingerprint,
|
|
32
32
|
findInvalidCharacter,
|
|
33
|
+
importBaseVariant,
|
|
33
34
|
inspectOwnership,
|
|
34
35
|
normalizeBody,
|
|
35
36
|
previewAdopt,
|
|
37
|
+
previewBaseImport,
|
|
36
38
|
previewSalvage,
|
|
37
39
|
readPromptLayers,
|
|
40
|
+
recoverPromptJournal,
|
|
38
41
|
salvageProjection,
|
|
39
42
|
selectBaseVariant,
|
|
40
43
|
setToggle,
|
|
@@ -76,6 +79,7 @@ const WRITE_ERROR_STATUS: Record<WriteError, number> = {
|
|
|
76
79
|
unknown_layer: 400,
|
|
77
80
|
store_unreadable: 409,
|
|
78
81
|
invalid_characters: 400,
|
|
82
|
+
body_too_large: 400,
|
|
79
83
|
write_superseded: 409,
|
|
80
84
|
// Not the caller's fault and not a race: the filesystem refused the write and the
|
|
81
85
|
// transaction rolled itself back. 500 rather than 409 — retrying the same request
|
|
@@ -84,6 +88,8 @@ const WRITE_ERROR_STATUS: Record<WriteError, number> = {
|
|
|
84
88
|
recovery_required: 409,
|
|
85
89
|
locked: 409,
|
|
86
90
|
unsafe: 422,
|
|
91
|
+
import_body_changed: 409,
|
|
92
|
+
unsupported_form: 409,
|
|
87
93
|
};
|
|
88
94
|
|
|
89
95
|
/** Read-only view for the route test that asserts every mapping is a client error. */
|
|
@@ -335,7 +341,12 @@ export async function handleCodexPromptRoutes(ctx: ManagementContext): Promise<R
|
|
|
335
341
|
if (!body) return fail(ctx, "invalid_body", 400, "expected a JSON object");
|
|
336
342
|
const id = body.id;
|
|
337
343
|
if (typeof id !== "string") return fail(ctx, "invalid_body", 400, "id must be a string");
|
|
338
|
-
|
|
344
|
+
// `null` is the restore-the-default verb: it deletes the line instead of
|
|
345
|
+
// writing the documented default back as a literal, so a later upstream
|
|
346
|
+
// change to the default still reaches this file.
|
|
347
|
+
if (typeof body.enabled !== "boolean" && body.enabled !== null) {
|
|
348
|
+
return fail(ctx, "invalid_body", 400, "enabled must be a boolean or null");
|
|
349
|
+
}
|
|
339
350
|
const revision = revisionOf(body);
|
|
340
351
|
if (!revision) return fail(ctx, "stale_revision", 409, "revision required");
|
|
341
352
|
|
|
@@ -388,6 +399,109 @@ export async function handleCodexPromptRoutes(ctx: ManagementContext): Promise<R
|
|
|
388
399
|
return settle(ctx, selectBaseVariant(selection, revision, paths(ctx)));
|
|
389
400
|
}
|
|
390
401
|
|
|
402
|
+
if (url.pathname === "/api/codex-prompt/base/import" && req.method === "POST") {
|
|
403
|
+
// The adopt flow for `model_instructions_file`: preview first, write only on
|
|
404
|
+
// confirmation. The verb exists because /base/select refuses the `external`
|
|
405
|
+
// state by design — that refusal is about SILENT retargeting, and an
|
|
406
|
+
// explicit, previewed import is precisely the opt-in it protects.
|
|
407
|
+
//
|
|
408
|
+
// The preview carries the SERIALIZED variant file — `# {title}\n{body}` —
|
|
409
|
+
// not just the body: the heading is part of what Codex will read, so the
|
|
410
|
+
// thing confirmed must be the whole stored text, title included. Both byte
|
|
411
|
+
// counts are reported and separately named: `bodyBytes` is the budget the
|
|
412
|
+
// 64 KiB cap applies to, `serializedBytes` is the complete file.
|
|
413
|
+
const body = await readBody(ctx);
|
|
414
|
+
if (!body) return fail(ctx, "invalid_body", 400, "expected a JSON object");
|
|
415
|
+
const readState = readPromptLayers(paths(ctx));
|
|
416
|
+
if (!readState.readable) {
|
|
417
|
+
return fail(ctx, "config_unreadable", 409, "the configuration file exists but could not be read", { path: readState.configPath });
|
|
418
|
+
}
|
|
419
|
+
// The title is validated before anything is previewed or written: a caller
|
|
420
|
+
// that supplies one gets the same contract as a custom-layer title —
|
|
421
|
+
// 1-80 characters on a single line.
|
|
422
|
+
if (body.title !== undefined) {
|
|
423
|
+
const t = body.title;
|
|
424
|
+
if (typeof t !== "string" || t.trim().length === 0
|
|
425
|
+
|| t.length > MAX_TITLE_CHARS || /[\r\n]/.test(t)) {
|
|
426
|
+
return fail(ctx, "invalid_title", 400, "title must be 1-80 characters on a single line");
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const preview = previewBaseImport(paths(ctx), typeof body.title === "string" ? body.title : undefined);
|
|
430
|
+
if (preview.reason === "nothing_to_import") {
|
|
431
|
+
return fail(ctx, "nothing_to_import", 409,
|
|
432
|
+
"model_instructions_file is absent or already points at a managed variant");
|
|
433
|
+
}
|
|
434
|
+
if (preview.reason === "unsupported_form") {
|
|
435
|
+
return fail(ctx, "import_unsupported_form", 409, preview.detail, {
|
|
436
|
+
path: preview.rawPath,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
if (preview.reason === "file_unreadable") {
|
|
440
|
+
return fail(ctx, "import_file_unreadable", 409, preview.detail, {
|
|
441
|
+
path: preview.resolvedPath ?? preview.rawPath,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
if (preview.reason === "invalid_characters") {
|
|
445
|
+
return fail(ctx, "invalid_characters", 400, preview.detail, { path: preview.resolvedPath });
|
|
446
|
+
}
|
|
447
|
+
// Same boundary as every other write to this key: the 64 KiB cap applies to
|
|
448
|
+
// the BODY alone — the title heading lives outside that budget, unchanged
|
|
449
|
+
// from the managed-variant contract.
|
|
450
|
+
if (preview.reason === "body_too_large" || utf8Bytes(preview.body!) > MAX_BODY_BYTES) {
|
|
451
|
+
return fail(ctx, "body_too_large", 400, `the file body exceeds ${MAX_BODY_BYTES} bytes`, {
|
|
452
|
+
path: preview.resolvedPath,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
if (preview.reason === "slots_full") {
|
|
456
|
+
return fail(ctx, "variant_slots_full", 409,
|
|
457
|
+
`at most ${MAX_BASE_VARIANTS} base variants; delete one first`, {
|
|
458
|
+
maxBaseVariants: MAX_BASE_VARIANTS,
|
|
459
|
+
serialized: preview.serialized,
|
|
460
|
+
resolvedPath: preview.resolvedPath,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
if (body.confirm !== true) {
|
|
464
|
+
// Preview writes nothing, by construction: previewBaseImport is a pure read.
|
|
465
|
+
return jsonResponse({
|
|
466
|
+
ok: true,
|
|
467
|
+
changed: false,
|
|
468
|
+
preview: {
|
|
469
|
+
rawPath: preview.rawPath,
|
|
470
|
+
resolvedPath: preview.resolvedPath,
|
|
471
|
+
serialized: preview.serialized,
|
|
472
|
+
serializedBytes: utf8Bytes(preview.serialized!),
|
|
473
|
+
bodyBytes: utf8Bytes(preview.body!),
|
|
474
|
+
suggestedTitle: preview.suggestedTitle,
|
|
475
|
+
effectiveTitle: preview.effectiveTitle,
|
|
476
|
+
previewSha256: preview.previewSha256,
|
|
477
|
+
},
|
|
478
|
+
}, 200, req, ctx.config);
|
|
479
|
+
}
|
|
480
|
+
const revision = revisionOf(body);
|
|
481
|
+
if (!revision) return fail(ctx, "stale_revision", 409, "revision required");
|
|
482
|
+
// The preview hash is REQUIRED — a confirm that does not name exactly what
|
|
483
|
+
// was previewed (serialized text, title included) cannot be checked against
|
|
484
|
+
// it and is refused without writing.
|
|
485
|
+
const previewSha256 = body.previewSha256;
|
|
486
|
+
if (typeof previewSha256 !== "string" || !/^[0-9a-f]{64}$/.test(previewSha256)) {
|
|
487
|
+
return fail(ctx, "import_preview_required", 400,
|
|
488
|
+
"confirm requires the previewSha256 returned by a preview of the same title");
|
|
489
|
+
}
|
|
490
|
+
if (previewSha256 !== preview.previewSha256) {
|
|
491
|
+
// The file or the selected title moved between preview and confirm. Refuse
|
|
492
|
+
// rather than import a serialization nobody previewed.
|
|
493
|
+
return fail(ctx, "import_body_changed", 409,
|
|
494
|
+
"the preview no longer matches; preview it again", {
|
|
495
|
+
resolvedPath: preview.resolvedPath,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
return settle(ctx, importBaseVariant(
|
|
499
|
+
{ title: typeof body.title === "string" ? body.title : undefined, previewSha256 },
|
|
500
|
+
revision,
|
|
501
|
+
paths(ctx),
|
|
502
|
+
));
|
|
503
|
+
}
|
|
504
|
+
|
|
391
505
|
if (url.pathname === "/api/codex-prompt/base" && req.method === "PUT") {
|
|
392
506
|
const body = await readBody(ctx);
|
|
393
507
|
if (!body) return fail(ctx, "invalid_body", 400, "expected a JSON object");
|
|
@@ -568,11 +682,28 @@ export async function handleCodexPromptRoutes(ctx: ManagementContext): Promise<R
|
|
|
568
682
|
return settle(ctx, adoptDeveloperInstructions(revision, paths(ctx)));
|
|
569
683
|
}
|
|
570
684
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
685
|
+
if (drift === "journal-present") {
|
|
686
|
+
// Recovery as its own locked operation — never a disguised layers write.
|
|
687
|
+
// The GUI used to replay recovery through a byte-identical custom PUT,
|
|
688
|
+
// which projected custom=[] over a config still carrying instructions
|
|
689
|
+
// whenever the store file was missing.
|
|
690
|
+
if (!confirm) {
|
|
691
|
+
return jsonResponse({
|
|
692
|
+
ok: true,
|
|
693
|
+
changed: false,
|
|
694
|
+
preview: { drift, storePath: snapshot.storePath },
|
|
695
|
+
}, 200, req, ctx.config);
|
|
696
|
+
}
|
|
697
|
+
if (!revision) return fail(ctx, "stale_revision", 409, "revision required");
|
|
698
|
+
if (revision !== snapshot.revision) {
|
|
699
|
+
return fail(ctx, "stale_revision", 409, "the configuration moved since it was read");
|
|
700
|
+
}
|
|
701
|
+
return settle(ctx, recoverPromptJournal(paths(ctx)));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// A drift kind this build does not repair: name it rather than guess.
|
|
574
705
|
return fail(ctx, "repair_unsupported", 409,
|
|
575
|
-
"
|
|
706
|
+
"this drift has no repair on this route", {
|
|
576
707
|
drift, storePath: snapshot.storePath,
|
|
577
708
|
});
|
|
578
709
|
}
|
|
@@ -209,7 +209,7 @@ export async function proposeDelegationModel(options: {
|
|
|
209
209
|
role: DELEGATED_WORK_ROLE,
|
|
210
210
|
model: options.config.injectionModel ?? null,
|
|
211
211
|
effort: options.config.injectionEffort ?? null,
|
|
212
|
-
}], outcomes, classified
|
|
212
|
+
}], outcomes, classified);
|
|
213
213
|
return {
|
|
214
214
|
sizingModel,
|
|
215
215
|
sizingError,
|
|
@@ -22,10 +22,12 @@ import {
|
|
|
22
22
|
} from "../../grok/reset-coupons";
|
|
23
23
|
import {
|
|
24
24
|
getGrokResetCouponOperationAccountId,
|
|
25
|
+
isTerminalGrokCouponReplay,
|
|
25
26
|
markGrokResetCouponAttempt,
|
|
26
27
|
openGrokResetCouponOperation,
|
|
27
28
|
recordGrokResetCouponSettlement,
|
|
28
29
|
readGrokResetCouponTerminalReplay,
|
|
30
|
+
refuseGrokResetCouponBeforeOpen,
|
|
29
31
|
settleGrokResetCouponPreflightRefusal,
|
|
30
32
|
type GrokResetCouponOperationRecord,
|
|
31
33
|
} from "../../grok/reset-coupon-ledger";
|
|
@@ -140,10 +142,6 @@ export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise<Re
|
|
|
140
142
|
|
|
141
143
|
const { accountId: rawAccountId, tokenId: requestedTokenId, operationId } = body;
|
|
142
144
|
|
|
143
|
-
if (requestedTokenId !== undefined && (typeof requestedTokenId !== "string" || requestedTokenId.trim() === "")) {
|
|
144
|
-
return jsonResponse({ error: { code: "invalid_token_id", message: "tokenId must be a non-empty string" } }, 400, req, config);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
145
|
if (operationId !== undefined && !isCodexResetCreditOperationId(operationId)) {
|
|
148
146
|
return jsonResponse(
|
|
149
147
|
{ error: { code: "invalid_operation_id", message: "operationId must be a valid UUIDv4" } },
|
|
@@ -153,47 +151,87 @@ export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise<Re
|
|
|
153
151
|
);
|
|
154
152
|
}
|
|
155
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Once a caller supplies an operationId, that id may already name an operation that
|
|
156
|
+
* another request is spending. A refusal from this request must not read as
|
|
157
|
+
* definitive then, or the caller drops its hold and mints a replacement.
|
|
158
|
+
*/
|
|
159
|
+
const uncertain = (id: string): Response => jsonResponse({ operationId: id, error: {
|
|
160
|
+
code: "attempt_in_progress",
|
|
161
|
+
message: "This request did not attempt a redemption, but another request may hold this operation; retry the same operationId to read its durable state",
|
|
162
|
+
} }, 409, req, config);
|
|
163
|
+
/** Refuse before open: definitive only when the supplied id is durably reserved as failed. */
|
|
164
|
+
const preOpenRefusal = (definitive: Response, code: string, knownAccountId?: string): Response => {
|
|
165
|
+
if (operationId === undefined) return definitive;
|
|
166
|
+
if (knownAccountId === undefined) return uncertain(operationId);
|
|
167
|
+
try {
|
|
168
|
+
const result = refuseGrokResetCouponBeforeOpen({
|
|
169
|
+
operationId, accountId: knownAccountId, code,
|
|
170
|
+
tokenId: typeof requestedTokenId === "string" ? requestedTokenId : undefined,
|
|
171
|
+
});
|
|
172
|
+
if (result.kind === "recorded") return definitive;
|
|
173
|
+
if (result.kind === "replay") return jsonResponse({
|
|
174
|
+
code: result.code, replayed: true, tokenId: result.tokenId, settledAt: result.settledAt,
|
|
175
|
+
}, 200, req, config);
|
|
176
|
+
if (result.kind === "identity-mismatch") return jsonResponse({ error: {
|
|
177
|
+
code: "operation_id_owned_by_another_account",
|
|
178
|
+
message: "Operation ID was previously registered with a different account or token",
|
|
179
|
+
} }, 409, req, config);
|
|
180
|
+
return uncertain(operationId);
|
|
181
|
+
} catch {
|
|
182
|
+
return uncertain(operationId);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (requestedTokenId !== undefined && (typeof requestedTokenId !== "string" || requestedTokenId.trim() === "")) {
|
|
187
|
+
return preOpenRefusal(jsonResponse({ error: { code: "invalid_token_id", message: "tokenId must be a non-empty string" } }, 400, req, config), "invalid_token_id");
|
|
188
|
+
}
|
|
189
|
+
|
|
156
190
|
let recordedAccountId: string | undefined;
|
|
157
191
|
if (rawAccountId === undefined && operationId !== undefined) {
|
|
158
192
|
try { recordedAccountId = getGrokResetCouponOperationAccountId(operationId); }
|
|
159
193
|
catch {
|
|
160
|
-
return
|
|
194
|
+
return uncertain(operationId);
|
|
161
195
|
}
|
|
162
196
|
}
|
|
163
197
|
let accountId: string;
|
|
164
198
|
try {
|
|
165
199
|
accountId = resolveTargetAccountId(rawAccountId ?? recordedAccountId);
|
|
166
200
|
} catch (err) {
|
|
167
|
-
return jsonResponse(
|
|
201
|
+
return preOpenRefusal(jsonResponse(
|
|
168
202
|
{ error: { code: "no_account", message: err instanceof Error ? err.message : String(err) } },
|
|
169
203
|
400,
|
|
170
204
|
req,
|
|
171
205
|
config,
|
|
172
|
-
);
|
|
206
|
+
), "no_account");
|
|
173
207
|
}
|
|
174
208
|
|
|
175
209
|
let tokenSnapshot;
|
|
176
210
|
try {
|
|
177
211
|
tokenSnapshot = await getValidAccessSnapshotForAccount("xai", accountId, { requireUsableAccount: true });
|
|
178
212
|
} catch (err) {
|
|
179
|
-
return jsonResponse(
|
|
213
|
+
return preOpenRefusal(jsonResponse(
|
|
180
214
|
{ error: { code: "auth_failed", message: "Failed to resolve valid xAI credentials for account" } },
|
|
181
215
|
401,
|
|
182
216
|
req,
|
|
183
217
|
config,
|
|
184
|
-
);
|
|
218
|
+
), "auth_failed", accountId);
|
|
185
219
|
}
|
|
186
220
|
|
|
187
221
|
// Journaling and Idempotency settlement check
|
|
188
222
|
const effectiveOpId = operationId ?? crypto.randomUUID();
|
|
189
|
-
if (grokCouponInFlightAttempts.has(effectiveOpId)) return jsonResponse({ error: {
|
|
223
|
+
if (grokCouponInFlightAttempts.has(effectiveOpId)) return jsonResponse({ operationId: effectiveOpId, error: {
|
|
190
224
|
code: "attempt_in_progress", message: "This operation still has an active request; retry later",
|
|
191
225
|
} }, 409, req, config);
|
|
192
226
|
let opRecord: GrokResetCouponOperationRecord;
|
|
193
227
|
try {
|
|
194
228
|
opRecord = openGrokResetCouponOperation({ accountId, tokenId: requestedTokenId, operationId: effectiveOpId });
|
|
195
229
|
} catch {
|
|
196
|
-
|
|
230
|
+
// A failed open cannot prove that no earlier request already opened this id.
|
|
231
|
+
return jsonResponse({ operationId: effectiveOpId, error: {
|
|
232
|
+
code: "ledger_unavailable",
|
|
233
|
+
message: "Coupon ledger could not be read or locked; this request did not attempt a redemption. Retry the same operationId",
|
|
234
|
+
} }, 503, req, config);
|
|
197
235
|
}
|
|
198
236
|
|
|
199
237
|
let resolvedTokenId = requestedTokenId ?? opRecord.tokenId;
|
|
@@ -201,6 +239,14 @@ export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise<Re
|
|
|
201
239
|
|
|
202
240
|
if (opRecord.kind === "replay") {
|
|
203
241
|
if (opRecord.code !== undefined) {
|
|
242
|
+
// Replay only a definitive outcome; a recorded code that means "unconfirmed"
|
|
243
|
+
// must keep the caller on this operation.
|
|
244
|
+
if (!isTerminalGrokCouponReplay(opRecord, accountId, requestedTokenId)) {
|
|
245
|
+
return jsonResponse({ operationId: effectiveOpId, error: {
|
|
246
|
+
code: "attempt_unresolved",
|
|
247
|
+
message: "This operation's recorded outcome is unconfirmed; preserve this operationId and do not create a replacement attempt",
|
|
248
|
+
} }, 409, req, config);
|
|
249
|
+
}
|
|
204
250
|
return jsonResponse(
|
|
205
251
|
{
|
|
206
252
|
code: opRecord.code,
|
|
@@ -230,6 +276,7 @@ export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise<Re
|
|
|
230
276
|
// retry with the recorded token and preserve the operationId.
|
|
231
277
|
return jsonResponse(
|
|
232
278
|
{
|
|
279
|
+
operationId: effectiveOpId,
|
|
233
280
|
error: {
|
|
234
281
|
code: "operation_token_mismatch",
|
|
235
282
|
message: "Operation was attempted with a different coupon; preserve the operationId and retry with the recorded tokenId",
|
|
@@ -282,7 +329,7 @@ export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise<Re
|
|
|
282
329
|
} }, 409, req, config);
|
|
283
330
|
}
|
|
284
331
|
|
|
285
|
-
if (opRecord.kind === "token-mismatch") return jsonResponse({ error: {
|
|
332
|
+
if (opRecord.kind === "token-mismatch") return jsonResponse({ operationId: effectiveOpId, error: {
|
|
286
333
|
code: "operation_token_mismatch", message: "Operation ID was previously registered with a different coupon",
|
|
287
334
|
} }, 409, req, config);
|
|
288
335
|
|
|
@@ -301,11 +348,14 @@ export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise<Re
|
|
|
301
348
|
}
|
|
302
349
|
|
|
303
350
|
if (opRecord.kind !== "execute") {
|
|
351
|
+
// A capacity refusal cannot reserve the id, so an earlier same-id request still in
|
|
352
|
+
// credential resolution may open it once records expire: keep the caller on the id.
|
|
304
353
|
return jsonResponse(
|
|
305
354
|
{
|
|
355
|
+
...(operationId !== undefined ? { operationId: effectiveOpId } : {}),
|
|
306
356
|
error: {
|
|
307
357
|
code: opRecord.kind,
|
|
308
|
-
message: "Coupon ledger capacity
|
|
358
|
+
message: "Coupon ledger is at capacity; this request did not attempt a redemption. Retry the same operationId later",
|
|
309
359
|
},
|
|
310
360
|
},
|
|
311
361
|
503,
|
|
@@ -324,14 +374,13 @@ export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise<Re
|
|
|
324
374
|
if (result.kind === "replay") return jsonResponse({
|
|
325
375
|
code: result.code, replayed: true, tokenId: result.tokenId, settledAt: result.settledAt,
|
|
326
376
|
}, 200, req, config);
|
|
327
|
-
return jsonResponse({ error: {
|
|
377
|
+
return jsonResponse({ operationId: effectiveOpId, error: {
|
|
328
378
|
code: "operation_state_changed",
|
|
329
379
|
message: "Another request changed this operation during inspection; retry the same operationId to read its durable state",
|
|
330
380
|
} }, 409, req, config);
|
|
331
381
|
} catch {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
} }, 503, req, config);
|
|
382
|
+
// The refusal could not be recorded, so the operation may still be claimed elsewhere.
|
|
383
|
+
return uncertain(effectiveOpId);
|
|
335
384
|
}
|
|
336
385
|
};
|
|
337
386
|
|
|
@@ -345,6 +394,10 @@ export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise<Re
|
|
|
345
394
|
const remaining = await getGrokRemainingResets({ accessToken: tokenSnapshot.accessToken });
|
|
346
395
|
tokens = remaining.tokens ?? [];
|
|
347
396
|
} catch (err) {
|
|
397
|
+
// Close the still-open operation before reporting a definitive failure: once it is
|
|
398
|
+
// recorded as failed it can never be claimed, so the caller may safely start over.
|
|
399
|
+
const changed = settlePreflightRefusal("fetch_resets_failed", resolvedTokenId);
|
|
400
|
+
if (changed) return changed;
|
|
348
401
|
return jsonResponse(
|
|
349
402
|
{ error: { code: "fetch_resets_failed", message: err instanceof Error ? err.message : String(err) } },
|
|
350
403
|
502,
|
|
@@ -389,6 +442,8 @@ export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise<Re
|
|
|
389
442
|
|
|
390
443
|
if (resolvedTokenId === undefined) {
|
|
391
444
|
// Unreachable: every path above either resolves a token or returns.
|
|
445
|
+
const changed = settlePreflightRefusal("token_unresolved");
|
|
446
|
+
if (changed) return changed;
|
|
392
447
|
return jsonResponse(
|
|
393
448
|
{ error: { code: "token_unresolved", message: "No reset coupon token could be resolved" } },
|
|
394
449
|
500,
|
|
@@ -403,6 +458,7 @@ export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise<Re
|
|
|
403
458
|
// on this operationId again regardless of how stale the mark looks.
|
|
404
459
|
return jsonResponse(
|
|
405
460
|
{
|
|
461
|
+
operationId: effectiveOpId,
|
|
406
462
|
error: {
|
|
407
463
|
code: "attempt_in_progress",
|
|
408
464
|
message: "A redemption attempt for this operation is still running; retry later",
|
|
@@ -433,17 +489,12 @@ export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise<Re
|
|
|
433
489
|
if (winner) return jsonResponse({
|
|
434
490
|
code: winner.code, replayed: true, tokenId: winner.tokenId, settledAt: winner.settledAt,
|
|
435
491
|
}, 200, req, config);
|
|
436
|
-
return
|
|
437
|
-
code: "attempt_in_progress", message: "Another request already claimed this operation; no redemption was attempted",
|
|
438
|
-
} }, 409, req, config);
|
|
492
|
+
return uncertain(effectiveOpId);
|
|
439
493
|
}
|
|
440
|
-
} catch
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
req,
|
|
445
|
-
config,
|
|
446
|
-
);
|
|
494
|
+
} catch {
|
|
495
|
+
// The claim write failed or was contended: this request dispatched nothing, but a
|
|
496
|
+
// concurrent holder of this operation may be spending, so keep the caller on it.
|
|
497
|
+
return uncertain(effectiveOpId);
|
|
447
498
|
}
|
|
448
499
|
|
|
449
500
|
try {
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv, REMOTE_COMMAND_NOT_FOUND, REMOTE_PLATFORM_UNSUPPORTED, remoteOcxArgv } from "../../link/ssh-argv";
|
|
5
|
+
import { parseFingerprintLine } from "../../link/fingerprint";
|
|
6
|
+
import { LINK_CATALOG_MAX_BYTES, LINK_RELAY_VERIFY_MS, isChildHealth, linkCommandBudget, linkRestartBudget, parseChildHttp, remoteChildHttpArgv, REMOTE_CURL_PREFLIGHT } from "../../link/remote-probe";
|
|
7
|
+
import { newLinkId, type LinkStore } from "../../link/store";
|
|
8
|
+
import { SshRunnerError, boundHint, sshFailureHint, sshRunnerErrorHint, type SshRunResult } from "../../link/ssh-runner";
|
|
9
|
+
import { withConfigMutationLockSync } from "../../config/mutation-lock";
|
|
10
|
+
import type { ManagementContext } from "./context";
|
|
11
|
+
import { readManagementJsonBodyOr } from "./body";
|
|
12
|
+
import { fail, exactBody, port, isRecord, runnerFor, readStoreFor, writeStoreFor, knownHostsFile, putKnownHost, issueKey, compensateNewLink, type LinkRouteState } from "./link-route-state";
|
|
13
|
+
|
|
14
|
+
const PROBE_TTL_MS = 5 * 60_000;
|
|
15
|
+
const MIN_REMOTE_OCX = [2, 66, 0] as const;
|
|
16
|
+
const REMOTE_OCX_VERSION = /^opencodex (\d{1,9})\.(\d{1,9})\.(\d{1,9})(-[0-9A-Za-z.-]{1,64})?(\+[0-9A-Za-z.-]{1,64})?(?=\s|$)/;
|
|
17
|
+
|
|
18
|
+
/** Same-alias operations refuse overlap rather than overwrite a confirmed generation. */
|
|
19
|
+
async function serialized(state: LinkRouteState, alias: string, action: () => Promise<Response>): Promise<Response> {
|
|
20
|
+
const operations = state.operations ??= new Set();
|
|
21
|
+
if (operations.has(alias)) return fail("link_operation_in_progress", "A link operation for this alias is already running.", 409);
|
|
22
|
+
operations.add(alias);
|
|
23
|
+
try { return await action(); } finally { operations.delete(alias); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function remoteFailure(result: SshRunResult, code: string, message: string): Response | null {
|
|
27
|
+
if (result.code === REMOTE_PLATFORM_UNSUPPORTED) return fail("remote_platform_unsupported", "Remote Link Children require macOS or Linux; Windows is unsupported.", 409);
|
|
28
|
+
if (result.code === REMOTE_COMMAND_NOT_FOUND) return fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, sshFailureHint(result.stderr));
|
|
29
|
+
return result.code === 0 ? null : fail(code, message, 502, sshFailureHint(result.stderr));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function probe(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
|
|
33
|
+
const value = await readManagementJsonBodyOr(ctx.req, null);
|
|
34
|
+
const body = exactBody(value, ["alias"]) ?? exactBody(value, ["alias", "includeVersion"]);
|
|
35
|
+
if (!body || typeof body.alias !== "string" || (body.includeVersion !== undefined && body.includeVersion !== true)) return fail("invalid_body", "alias is required.", 400);
|
|
36
|
+
const alias = body.alias;
|
|
37
|
+
try { assertSshAlias(alias); } catch { return fail("invalid_alias", "alias must be a valid SSH host alias.", 400); }
|
|
38
|
+
return serialized(state, alias, async () => {
|
|
39
|
+
const dir = mkdtempSync(join(tmpdir(), "ocx-link-probe-"));
|
|
40
|
+
const path = join(dir, "known_hosts");
|
|
41
|
+
writeFileSync(path, "", { mode: 0o600 });
|
|
42
|
+
const runner = runnerFor(ctx), now = ctx.deps.now ?? Date.now;
|
|
43
|
+
try {
|
|
44
|
+
const started = now();
|
|
45
|
+
const result = await runner.run(buildProbeArgv({ alias, tempKnownHostsFile: path }), { timeoutMs: 30_000 });
|
|
46
|
+
const latency = Math.max(0, now() - started);
|
|
47
|
+
if (result.code !== 0) return fail("probe_failed", "SSH host probing failed.", 502, sshFailureHint(result.stderr));
|
|
48
|
+
const fp = await runner.run(buildFingerprintArgv(path), { timeoutMs: 10_000 });
|
|
49
|
+
if (fp.code !== 0) return fail("fingerprint_failed", "The SSH host fingerprint could not be read.", 502, sshFailureHint(fp.stderr));
|
|
50
|
+
const parsed = parseFingerprintLine(fp.stdout.trim());
|
|
51
|
+
const line = readFileSync(path, "utf8").trim().split(/\r?\n/).filter(Boolean).at(-1);
|
|
52
|
+
if (!line) return fail("fingerprint_failed", "The SSH probe did not record a host key.", 502);
|
|
53
|
+
let ocxVersion: string | undefined;
|
|
54
|
+
if (body.includeVersion === true) {
|
|
55
|
+
const versionResult = await runner.run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
|
|
56
|
+
const denied = remoteFailure(versionResult, "version_probe_failed", "The remote ocx version could not be read.");
|
|
57
|
+
if (denied) return denied;
|
|
58
|
+
const parsedVersion = parseVersion(versionResult.stdout);
|
|
59
|
+
if (!parsedVersion) return fail("remote_ocx_unrecognized", "The remote ocx did not report an OpenCodex version.", 502);
|
|
60
|
+
ocxVersion = parsedVersion.version;
|
|
61
|
+
}
|
|
62
|
+
state.pendingHosts.set(alias, { alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType, knownHostLine: line, probedAt: now(), sshLatencyMs: latency });
|
|
63
|
+
state.confirmedHosts?.delete(alias);
|
|
64
|
+
return Response.json({ alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType, ...(ocxVersion === undefined ? {} : { ocxVersion }) });
|
|
65
|
+
} catch (error) { return fail("probe_failed", "SSH host probing failed.", 502, sshRunnerErrorHint(error)); }
|
|
66
|
+
finally { rmSync(dir, { recursive: true, force: true }); }
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseVersion(stdout: string): { version: string; parts: number[] } | null {
|
|
71
|
+
const match = REMOTE_OCX_VERSION.exec(stdout.trim().split(/\r?\n/)[0] ?? "");
|
|
72
|
+
return match ? { version: match.slice(1, 4).join(".") + (match[4] ?? "") + (match[5] ?? ""), parts: match.slice(1, 4).map(Number) } : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function confirmHost(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
|
|
76
|
+
const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias", "fingerprint"]);
|
|
77
|
+
if (!body || typeof body.alias !== "string" || typeof body.fingerprint !== "string") return fail("invalid_body", "alias and fingerprint are required.", 400);
|
|
78
|
+
const alias = body.alias;
|
|
79
|
+
return serialized(state, alias, async () => {
|
|
80
|
+
const pending = state.pendingHosts.get(alias), now = ctx.deps.now ?? Date.now;
|
|
81
|
+
if (!pending || now() - pending.probedAt > PROBE_TTL_MS) return fail("host_confirmation_expired", "The SSH host probe has expired.", 409);
|
|
82
|
+
if (pending.fingerprint !== body.fingerprint) return fail("host_fingerprint_mismatch", "The confirmed fingerprint does not match the probe.", 409);
|
|
83
|
+
const dir = mkdtempSync(join(tmpdir(), "ocx-link-confirm-")), path = join(dir, "known_hosts");
|
|
84
|
+
writeFileSync(path, pending.knownHostLine + "\n", { mode: 0o600 });
|
|
85
|
+
try {
|
|
86
|
+
const started = now();
|
|
87
|
+
const result = await runnerFor(ctx).run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
|
|
88
|
+
const denied = remoteFailure(result, "version_probe_failed", "The remote ocx version could not be confirmed.");
|
|
89
|
+
if (denied) return denied;
|
|
90
|
+
const version = parseVersion(result.stdout);
|
|
91
|
+
if (!version) return fail("remote_ocx_unrecognized", "The remote ocx did not report an OpenCodex version.", 502);
|
|
92
|
+
const cmp = version.parts.findIndex((part, i) => part !== MIN_REMOTE_OCX[i]);
|
|
93
|
+
if (cmp >= 0 && version.parts[cmp]! < MIN_REMOTE_OCX[cmp]!) return fail("remote_ocx_outdated", "The remote OpenCodex is older than 2.66.0.", 409, boundHint("opencodex " + version.version));
|
|
94
|
+
if (state.pendingHosts.get(alias) !== pending || now() - pending.probedAt > PROBE_TTL_MS) return fail("host_confirmation_expired", "The SSH host probe changed or expired.", 409);
|
|
95
|
+
withConfigMutationLockSync(() => putKnownHost(knownHostsFile(ctx), alias, pending.knownHostLine));
|
|
96
|
+
const confirmed = { ...pending, sshLatencyMs: Math.max(pending.sshLatencyMs ?? 0, now() - started), ocxVersion: version.version };
|
|
97
|
+
(state.confirmedHosts ??= new Map()).set(alias, confirmed);
|
|
98
|
+
state.pendingHosts.delete(alias);
|
|
99
|
+
return Response.json({ alias, fingerprint: pending.fingerprint, ocxVersion: version.version });
|
|
100
|
+
} catch (error) { return fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502, sshRunnerErrorHint(error)); }
|
|
101
|
+
finally { rmSync(dir, { recursive: true, force: true }); }
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parsePort(stdout: string): number | null {
|
|
106
|
+
try { const value: unknown = JSON.parse(stdout.trim()); return isRecord(value) && Object.keys(value).length === 1 && port(value.port) ? value.port : null; }
|
|
107
|
+
catch { return null; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function apply(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
|
|
111
|
+
const value = await readManagementJsonBodyOr(ctx.req, null);
|
|
112
|
+
const body = exactBody(value, ["alias"]) ?? exactBody(value, ["alias", "fingerprint"]);
|
|
113
|
+
if (!body || typeof body.alias !== "string" || (body.fingerprint !== undefined && typeof body.fingerprint !== "string")) return fail("invalid_body", "alias and optional fingerprint are required.", 400);
|
|
114
|
+
const alias = body.alias;
|
|
115
|
+
return serialized(state, alias, async () => {
|
|
116
|
+
const confirmed = state.confirmedHosts?.get(alias), now = ctx.deps.now ?? Date.now;
|
|
117
|
+
if (!confirmed) return fail("host_not_confirmed", "Confirm the SSH host before applying the link.", 409);
|
|
118
|
+
if (now() - confirmed.probedAt > PROBE_TTL_MS) return fail("host_confirmation_expired", "The SSH host confirmation has expired.", 409);
|
|
119
|
+
if (body.fingerprint !== undefined && body.fingerprint !== confirmed.fingerprint) return fail("host_fingerprint_mismatch", "The apply fingerprint does not match the confirmed host.", 409);
|
|
120
|
+
if (readStoreFor(ctx).links.some(link => link.alias === alias && link.direction === "hub-initiated")) return fail("link_exists", "A link for this alias already exists.", 409);
|
|
121
|
+
const dir = mkdtempSync(join(tmpdir(), "ocx-link-apply-")), knownHosts = join(dir, "known_hosts");
|
|
122
|
+
writeFileSync(knownHosts, confirmed.knownHostLine + "\n", { mode: 0o600 });
|
|
123
|
+
const runner = runnerFor(ctx), budget = linkCommandBudget(confirmed.sshLatencyMs);
|
|
124
|
+
const run = (argv: readonly string[], timeoutMs = budget, maxOutputBytes?: number) => runner.run(buildExecArgv({ alias, argv, knownHostsFile: knownHosts }), { timeoutMs, ...(maxOutputBytes === undefined ? {} : { maxOutputBytes }) });
|
|
125
|
+
let record: LinkStore["links"][number] | undefined, input: Uint8Array | undefined;
|
|
126
|
+
let remoteMayBeConnected = false, phase = "remote_port";
|
|
127
|
+
const failed = async (response: Response): Promise<Response> => {
|
|
128
|
+
const compensation = record ? await compensateNewLink(ctx, state, record) : null;
|
|
129
|
+
const outcome = compensation ?? response;
|
|
130
|
+
if (!remoteMayBeConnected) return outcome;
|
|
131
|
+
const payload = await outcome.json() as Record<string, unknown>;
|
|
132
|
+
return Response.json({ ...payload, residual: true, recovery: "Run ocx disconnect and ocx restart on the Child before retrying." }, { status: outcome.status });
|
|
133
|
+
};
|
|
134
|
+
try {
|
|
135
|
+
const chosen = await run(remoteOcxArgv(["link", "port"]));
|
|
136
|
+
const denied = remoteFailure(chosen, "remote_port_failed", "The remote link port could not be determined.");
|
|
137
|
+
if (denied) return denied;
|
|
138
|
+
const remotePort = parsePort(chosen.stdout);
|
|
139
|
+
if (!remotePort) return fail("remote_port_failed", "The remote link port could not be determined.", 502);
|
|
140
|
+
phase = "child_preflight";
|
|
141
|
+
const configured = await run(remoteOcxArgv(["config", "show", "--json"]));
|
|
142
|
+
let childConfig: unknown = null;
|
|
143
|
+
try { if (configured.code === 0) childConfig = JSON.parse(configured.stdout); } catch { /* fail closed */ }
|
|
144
|
+
if (!isRecord(childConfig) || !port(childConfig.port)) return fail("child_preflight_failed", "The Child effective configuration could not be read.", 502);
|
|
145
|
+
if ((childConfig.runtimeRole ?? "standalone") !== "standalone") return fail("child_restart_unsupported", "The Child must run standalone OpenCodex; an existing Hub or Child cannot be converted by Add Child.", 409);
|
|
146
|
+
const childPort = childConfig.port;
|
|
147
|
+
if ((await run(REMOTE_CURL_PREFLIGHT)).code !== 0) return fail("child_curl_missing", "Install curl on the Child before connecting a Remote Link.", 409);
|
|
148
|
+
const before = await run(remoteChildHttpArgv(childPort, "healthz"));
|
|
149
|
+
const beforeHealth = before.code === 0 ? parseChildHttp(before.stdout) : null;
|
|
150
|
+
if (!isChildHealth(beforeHealth, "standalone", childPort)) return fail("child_restart_unsupported", "The Child must run a standalone OpenCodex with bound restart support; update or disconnect it before linking.", 409);
|
|
151
|
+
state.confirmedHosts?.delete(alias);
|
|
152
|
+
phase = "home_setup";
|
|
153
|
+
const id = newLinkId(), issued = issueKey(ctx, "link:" + alias);
|
|
154
|
+
record = { id, alias, direction: "hub-initiated", hostKeyFingerprint: confirmed.fingerprint, tunnelPort: remotePort, apiKeyId: issued.id, createdAt: new Date(now()).toISOString() };
|
|
155
|
+
withConfigMutationLockSync(() => {
|
|
156
|
+
const current = readStoreFor(ctx);
|
|
157
|
+
if (current.links.some(link => link.alias === alias && link.direction === "hub-initiated")) throw new Error("link exists");
|
|
158
|
+
writeStoreFor(ctx, { ...current, links: [...current.links, record!] });
|
|
159
|
+
});
|
|
160
|
+
await state.listener.ensureStarted();
|
|
161
|
+
if (state.listener.status().state !== "listening") return await failed(fail("listener_unavailable", "The link listener is unavailable.", 503));
|
|
162
|
+
await state.supervisor.ensureStarted();
|
|
163
|
+
await state.supervisor.reload();
|
|
164
|
+
phase = "remote_connect";
|
|
165
|
+
input = new TextEncoder().encode(JSON.stringify({ apiKeyId: issued.id, key: issued.key }));
|
|
166
|
+
remoteMayBeConnected = true;
|
|
167
|
+
const connected = await runner.run(buildExecArgv({ alias, argv: remoteOcxArgv(["connect", "--link", "--key-stdin", "--tunnel-port", String(remotePort), "--link-id", id]), knownHostsFile: knownHosts }), { stdin: input, timeoutMs: budget });
|
|
168
|
+
const connectDenied = remoteFailure(connected, "remote_connect_failed", "The Child enrollment command failed.");
|
|
169
|
+
if (connectDenied) return await failed(connectDenied);
|
|
170
|
+
input.fill(0);
|
|
171
|
+
phase = "child_restart";
|
|
172
|
+
const restarted = await run(remoteOcxArgv(["restart"]), linkRestartBudget(confirmed.sshLatencyMs));
|
|
173
|
+
if (restarted.code !== 0) return await failed(fail("child_restart_failed", "The Child restart could not be verified.", 502, sshFailureHint(restarted.stderr)));
|
|
174
|
+
phase = "child_relay";
|
|
175
|
+
const deadline = now() + LINK_RELAY_VERIFY_MS, remaining = () => Math.max(1, deadline - now());
|
|
176
|
+
const health = await run(remoteChildHttpArgv(childPort, "healthz", remaining()), remaining());
|
|
177
|
+
if (!isChildHealth(health.code === 0 ? parseChildHttp(health.stdout) : null, "client", childPort)) return await failed(fail("child_runtime_not_client", "The Child is not serving the client runtime after restart.", 502));
|
|
178
|
+
let admitted = false;
|
|
179
|
+
const unsubscribe = state.listener.onAuthenticatedCatalog(keyId => { if (keyId === issued.id) admitted = true; });
|
|
180
|
+
try {
|
|
181
|
+
if (now() >= deadline) return await failed(fail("child_relay_timeout", "The Child relay verification deadline expired.", 502));
|
|
182
|
+
const catalog = await run(remoteChildHttpArgv(childPort, "v1/catalog", remaining()), remaining(), LINK_CATALOG_MAX_BYTES);
|
|
183
|
+
let data: unknown = null;
|
|
184
|
+
try { if (catalog.code === 0) data = parseChildHttp(catalog.stdout); } catch { /* malformed is not a completed catalog */ }
|
|
185
|
+
if (!isRecord(data) || !Array.isArray(data.models) || !admitted || now() > deadline) return await failed(fail("child_relay_failed", "The Child did not relay a complete authenticated catalog through this Home.", 502));
|
|
186
|
+
return Response.json({ linkId: id }, { status: 202 });
|
|
187
|
+
} finally { unsubscribe(); }
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return await failed(fail(phase + (error instanceof SshRunnerError && error.code === "timeout" ? "_timeout" : "_failed"), "Remote Link failed during " + phase.replaceAll("_", " ") + ".", 502, sshRunnerErrorHint(error)));
|
|
190
|
+
} finally { input?.fill(0); rmSync(dir, { recursive: true, force: true }); }
|
|
191
|
+
});
|
|
192
|
+
}
|