@bitkyc08/opencodex 2.77.0 → 2.78.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.
Files changed (222) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-BtfG5id-.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
  5. package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  15. package/src/adapters/devin/trajectory.ts +49 -0
  16. package/src/adapters/devin.ts +13 -0
  17. package/src/adapters/ollama-native.ts +35 -19
  18. package/src/claude/intercept/client-hello.ts +103 -0
  19. package/src/claude/intercept/model-bindings.ts +3 -2
  20. package/src/claude/intercept/picker-listener.ts +279 -66
  21. package/src/cli/access-audio-input.ts +84 -0
  22. package/src/cli/access-audio-live.ts +99 -0
  23. package/src/cli/access-audio.ts +123 -0
  24. package/src/cli/access-data-client.ts +127 -0
  25. package/src/cli/access-data-plane.ts +104 -0
  26. package/src/cli/access-data-response.ts +76 -0
  27. package/src/cli/access.ts +65 -2
  28. package/src/cli/account-api.ts +30 -6
  29. package/src/cli/account-auth.ts +111 -42
  30. package/src/cli/account-key-quota.ts +118 -0
  31. package/src/cli/account-main.ts +11 -4
  32. package/src/cli/account-next-actions.ts +63 -0
  33. package/src/cli/account-policy-dto.ts +68 -0
  34. package/src/cli/account-policy.ts +167 -0
  35. package/src/cli/account-target.ts +8 -0
  36. package/src/cli/account.ts +34 -8
  37. package/src/cli/agent-runtime-settings.ts +130 -0
  38. package/src/cli/agent-settings.ts +101 -0
  39. package/src/cli/agent.ts +19 -2
  40. package/src/cli/alias.ts +7 -2
  41. package/src/cli/aside-profiles.ts +2 -2
  42. package/src/cli/capabilities-access-remote.ts +147 -0
  43. package/src/cli/capabilities-accounts.ts +195 -0
  44. package/src/cli/capabilities-agents-routing.ts +414 -0
  45. package/src/cli/capabilities-base.ts +783 -0
  46. package/src/cli/capabilities-command.ts +1 -0
  47. package/src/cli/capabilities-integrations.ts +264 -0
  48. package/src/cli/capabilities-lab.ts +214 -0
  49. package/src/cli/capabilities-observe-system.ts +662 -0
  50. package/src/cli/capabilities-provider-models.ts +302 -0
  51. package/src/cli/capabilities.ts +30 -1199
  52. package/src/cli/capability-types.ts +54 -0
  53. package/src/cli/catalog-command-result.ts +57 -0
  54. package/src/cli/chatgpt-command.ts +25 -2
  55. package/src/cli/claude-agent-startup-sync.ts +17 -0
  56. package/src/cli/claude-desktop-profile.ts +100 -0
  57. package/src/cli/claude-desktop.ts +39 -6
  58. package/src/cli/codex-shim-autorestore.ts +2 -1
  59. package/src/cli/codex-shim-readiness.ts +23 -3
  60. package/src/cli/combo-input.ts +88 -0
  61. package/src/cli/combo-result.ts +52 -0
  62. package/src/cli/combo-stats.ts +123 -0
  63. package/src/cli/combo.ts +37 -14
  64. package/src/cli/companion-timeline.ts +142 -0
  65. package/src/cli/companion-usage.ts +146 -0
  66. package/src/cli/companion.ts +12 -3
  67. package/src/cli/config-command.ts +24 -8
  68. package/src/cli/dispatch.ts +52 -79
  69. package/src/cli/doctor.ts +22 -4
  70. package/src/cli/help-catalog.ts +31 -14
  71. package/src/cli/help-recovery.ts +2 -2
  72. package/src/cli/help.ts +25 -11
  73. package/src/cli/index.ts +38 -27
  74. package/src/cli/injection-follow.ts +35 -0
  75. package/src/cli/inspect.ts +5 -0
  76. package/src/cli/integration-aside-sync.ts +69 -0
  77. package/src/cli/integration-cursor.ts +106 -0
  78. package/src/cli/integration-input.ts +60 -0
  79. package/src/cli/integration-journal.ts +49 -0
  80. package/src/cli/integration-plan-dto.ts +156 -0
  81. package/src/cli/integration-preview.ts +215 -0
  82. package/src/cli/integrations.ts +24 -18
  83. package/src/cli/json-input.ts +134 -0
  84. package/src/cli/link.ts +32 -4
  85. package/src/cli/local-sync-result.ts +41 -0
  86. package/src/cli/log-follow.ts +84 -0
  87. package/src/cli/log-view-filter.ts +146 -0
  88. package/src/cli/logout-command.ts +44 -0
  89. package/src/cli/model-picker-ordering.ts +137 -0
  90. package/src/cli/models-custom-input.ts +107 -0
  91. package/src/cli/models-custom-runtime.ts +112 -0
  92. package/src/cli/models-order.ts +194 -0
  93. package/src/cli/models-runtime-subcommands.ts +2 -0
  94. package/src/cli/models-runtime.ts +7 -0
  95. package/src/cli/models.ts +66 -81
  96. package/src/cli/observe-stream.ts +142 -0
  97. package/src/cli/observe.ts +75 -34
  98. package/src/cli/provider-batch.ts +77 -0
  99. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  100. package/src/cli/provider-result.ts +124 -0
  101. package/src/cli/provider-runtime.ts +15 -12
  102. package/src/cli/provider-settings.ts +143 -0
  103. package/src/cli/provider.ts +112 -34
  104. package/src/cli/registry.ts +62 -20
  105. package/src/cli/remote-workspace-hub.ts +83 -0
  106. package/src/cli/remote-workspace.ts +6 -1
  107. package/src/cli/restart-failure.ts +27 -0
  108. package/src/cli/root.ts +12 -0
  109. package/src/cli/route-policy-write.ts +137 -0
  110. package/src/cli/route-policy.ts +8 -0
  111. package/src/cli/runtime-api.ts +56 -79
  112. package/src/cli/secret-args.ts +149 -0
  113. package/src/cli/settings-result.ts +30 -0
  114. package/src/cli/storage.ts +77 -7
  115. package/src/cli/system-command.ts +60 -2
  116. package/src/cli/system-restart-client.ts +20 -3
  117. package/src/cli/system-settings-parity.ts +83 -0
  118. package/src/cli/update-restart-candidate.ts +21 -0
  119. package/src/cli/update-restart-child.ts +81 -0
  120. package/src/cli/update-restart-home.ts +38 -0
  121. package/src/cli/update-restart-transport.ts +193 -0
  122. package/src/cli/update-restart.ts +260 -0
  123. package/src/cli/usage-model-search.ts +58 -0
  124. package/src/cli/usage-report.ts +13 -5
  125. package/src/cli/v2-input.ts +66 -0
  126. package/src/cli/v2-local-output.ts +37 -0
  127. package/src/cli/v2-runtime.ts +90 -0
  128. package/src/cli/v2.ts +102 -144
  129. package/src/clients/config-export/dsh.ts +21 -0
  130. package/src/clients/config-export.ts +7 -1
  131. package/src/codex/catalog/build-entries.ts +1 -1
  132. package/src/codex/catalog/bundled.ts +9 -2
  133. package/src/codex/catalog/heal-observation.ts +66 -0
  134. package/src/codex/catalog/publication-observer.ts +19 -0
  135. package/src/codex/catalog/remote.ts +13 -15
  136. package/src/codex/catalog/restore.ts +19 -6
  137. package/src/codex/catalog/retained-sync.ts +58 -10
  138. package/src/codex/catalog/routed-removal.ts +116 -0
  139. package/src/codex/catalog/write-audit-contract.ts +21 -0
  140. package/src/codex/catalog/write-audit.ts +171 -0
  141. package/src/codex/catalog-self-heal.ts +284 -0
  142. package/src/codex/catalog-write-serialization.ts +71 -7
  143. package/src/codex/codex-home-owner.ts +124 -0
  144. package/src/codex/convergence.ts +55 -15
  145. package/src/codex/features.ts +8 -1
  146. package/src/codex/inject/restore.ts +46 -4
  147. package/src/codex/inject.ts +11 -1
  148. package/src/codex/internal/catalog-writer.ts +120 -13
  149. package/src/codex/journal.ts +49 -13
  150. package/src/codex/management-convergence.ts +3 -0
  151. package/src/codex/quota-types.ts +3 -0
  152. package/src/codex/quota.ts +13 -3
  153. package/src/codex/refresh.ts +20 -9
  154. package/src/codex/runtime.ts +7 -1
  155. package/src/codex/shim-diagnostics.ts +80 -0
  156. package/src/codex/shim-fingerprint.ts +7 -2
  157. package/src/codex/shim-migration.ts +153 -0
  158. package/src/codex/shim-overlay.ts +509 -0
  159. package/src/codex/shim-path-resolution.ts +14 -2
  160. package/src/codex/shim-probe.ts +4 -3
  161. package/src/codex/shim-state-file.ts +93 -7
  162. package/src/codex/shim.ts +15 -68
  163. package/src/codex/sync.ts +24 -4
  164. package/src/combos/failover.ts +19 -0
  165. package/src/combos/index.ts +1 -0
  166. package/src/combos/resolve.ts +12 -0
  167. package/src/config/atomic-write.ts +13 -8
  168. package/src/config/diagnostics.ts +19 -1
  169. package/src/config/load-degrade.ts +3 -0
  170. package/src/config/schema/chatgpt-desktop.ts +22 -0
  171. package/src/config/schema/config-schema.ts +32 -14
  172. package/src/config/schema/leaf-validators.ts +3 -2
  173. package/src/generated/compatibility-version.json +445 -129
  174. package/src/integrations/merge.ts +13 -5
  175. package/src/integrations/mutation-plan.ts +46 -3
  176. package/src/integrations/omp-yaml-source.ts +181 -1
  177. package/src/integrations/ownership-policy.ts +66 -0
  178. package/src/integrations/registry.ts +50 -1
  179. package/src/integrations/state.ts +46 -21
  180. package/src/integrations/target.ts +25 -2
  181. package/src/integrations/writer.ts +89 -31
  182. package/src/lib/package-tree-integrity.ts +42 -1
  183. package/src/lib/proxy-env.ts +3 -0
  184. package/src/lib/test-home-guard.ts +2 -1
  185. package/src/oauth/anthropic-account-refusal.ts +19 -8
  186. package/src/oauth/anthropic-routing.ts +17 -12
  187. package/src/oauth/anthropic.ts +27 -2
  188. package/src/oauth/index.ts +6 -8
  189. package/src/oauth/pool-settings-capability.ts +9 -2
  190. package/src/protocols/plan-snapshot.ts +1 -1
  191. package/src/protocols/settings.ts +28 -4
  192. package/src/server/caller-session-identity.ts +32 -0
  193. package/src/server/claude-messages.ts +11 -4
  194. package/src/server/index/serve-options.ts +12 -7
  195. package/src/server/index.ts +1 -1
  196. package/src/server/management/agent-settings-routes.ts +6 -5
  197. package/src/server/management/context.ts +8 -1
  198. package/src/server/management/cursor-integration-routes.ts +3 -3
  199. package/src/server/management/integration-routes.ts +5 -5
  200. package/src/server/management/model-routes.ts +3 -3
  201. package/src/server/management/native-integration-routes.ts +2 -2
  202. package/src/server/management/oauth-account-routes.ts +96 -30
  203. package/src/server/management/route-registry.ts +25 -24
  204. package/src/server/messages-native-eligibility.ts +3 -6
  205. package/src/server/messages-native-oauth.ts +82 -35
  206. package/src/server/messages-native.ts +78 -16
  207. package/src/server/responses/adapter-dispatch.ts +53 -28
  208. package/src/server/responses/core-auth.ts +10 -4
  209. package/src/server/responses/core-combo.ts +46 -6
  210. package/src/service/cli.ts +6 -1
  211. package/src/service/ownership-mutation-lease.d.mts +26 -0
  212. package/src/service/ownership-mutation-lease.mjs +76 -1
  213. package/src/types/config.ts +2 -0
  214. package/src/types/provider.ts +10 -10
  215. package/src/update/index.ts +2 -1
  216. package/src/update/update-failure-guidance.d.mts +6 -0
  217. package/src/update/update-failure-guidance.mjs +30 -5
  218. package/gui/dist/assets/App-COdEeqSj.css +0 -1
  219. package/gui/dist/assets/App-DEpCJEP_.js +0 -54
  220. package/gui/dist/assets/index-Cd7ittMv.css +0 -1
  221. package/gui/dist/assets/index-DwJKFJm5.js +0 -94
  222. package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
@@ -0,0 +1,156 @@
1
+ /** Value-free wire boundary. Canonical DTO imports erase at runtime; no GUI or writer dependency. */
2
+ import type {
3
+ IntegrationMutationPlan, IntegrationPlanOperation, IntegrationPlanChangeKind,
4
+ IntegrationPlanForeignEdit, IntegrationPlanChange, RefusalReason as IntegrationRefusalReason,
5
+ } from "../integrations/mutation-plan";
6
+ import type { IntegrationClientId as FileIntegrationClientId } from "../integrations/registry";
7
+ import type { IntegrationState } from "../integrations/state";
8
+ import type { IneffectiveWriteReason } from "../integrations/target";
9
+
10
+ export const FILE_INTEGRATION_CLIENTS = [
11
+ "opencode",
12
+ "pi",
13
+ "omp",
14
+ "hermes",
15
+ "openclaw",
16
+ "kimi",
17
+ "gajae",
18
+ "dsh",
19
+ "mcode",
20
+ "zcode",
21
+ "prime",
22
+ "aside",
23
+ "raycast",
24
+ "omo",
25
+ "cline",
26
+ "kilo",
27
+ "droid",
28
+ ] as const;
29
+
30
+ export const INTEGRATION_REFUSAL_REASONS: ReadonlySet<string> = new Set<IntegrationRefusalReason>([
31
+ "not_installed",
32
+ "conflict",
33
+ "unsafe",
34
+ "non_loopback",
35
+ "superseded_store",
36
+ "drift_requires_confirm",
37
+ "snapshot_expired",
38
+ "write_failed",
39
+ ]);
40
+ export const INTEGRATION_STATES: ReadonlySet<string> = new Set<IntegrationState>([
41
+ "absent",
42
+ "current",
43
+ "stale",
44
+ "conflict",
45
+ "unsafe",
46
+ ]);
47
+ const PLAN_OPERATIONS: readonly IntegrationPlanOperation[] = ["apply", "overwrite", "disable", "restore"];
48
+ const PLAN_CHANGE_KINDS: readonly IntegrationPlanChangeKind[] = ["add", "replace", "remove", "snapshot", "ownership", "journal"];
49
+ const PLAN_FOREIGN_EDITS: readonly IntegrationPlanForeignEdit[] = ["none", "unowned", "foreign-edit", "drift"];
50
+ const PLAN_KEYS = new Set(["version", "clientId", "operation", "state", "foreignEdit", "changes", "fingerprint", "canApply", "willChange", "refusalReason", "supersededReason", "missingStoreDocument", "profileId"]);
51
+ const SUPERSEDED_REASONS: ReadonlySet<string> = new Set<IneffectiveWriteReason>(["owned-config-file", "unestablished-schema", "missing-store"]);
52
+ /** What to create a missing store with: echoed to a terminal, so one short printable-ASCII line. */
53
+ const MISSING_STORE_DOCUMENT = /^[\x20-\x7e]{1,64}$/;
54
+ const PLAN_CHANGE_KEYS = new Set(["kind", "path"]);
55
+ const PLAN_PSEUDO_PATHS = new Set(["$snapshot", "$ownership", "$journal"]);
56
+ const PLAN_SCHEMA_PATHS = new Set([
57
+ "provider.opencodex",
58
+ "providers.opencodex",
59
+ "models.providers.opencodex",
60
+ "models.*",
61
+ "llm-pi-ai.providers.opencodex",
62
+ "custom_provider.opencodex",
63
+ "providers.[id=opencodex]",
64
+ "settings.providers.opencodex",
65
+ "catalog.providers.opencodex",
66
+ "customModels.*",
67
+ // ZCode reads its providers from a second file; a plan for it publishes that
68
+ // file's templates, and a path missing here is rejected as an invalid preview.
69
+ "config.providerConfigRules.providerRules.[providerId=opencodex]",
70
+ "config.modelConfigRules.providerModelRules.*",
71
+ // DSH 0.1.7+ reads routes from the `llm-pi-ai` row of its Desktop profile patch.
72
+ "[id=llm-pi-ai].config.providers.opencodex",
73
+ ]);
74
+ const PLAN_CHANGE_LIMIT = 256;
75
+
76
+ function isRecord(value: unknown): value is Record<string, unknown> {
77
+ return typeof value === "object" && value !== null && !Array.isArray(value);
78
+ }
79
+
80
+ function hasOnlyKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>): boolean {
81
+ return Object.keys(value).every(key => allowed.has(key));
82
+ }
83
+
84
+ function isSafePlanPath(path: string): boolean {
85
+ return PLAN_PSEUDO_PATHS.has(path) || PLAN_SCHEMA_PATHS.has(path);
86
+ }
87
+
88
+ function invalidPreviewResponse(): Error {
89
+ return new Error("Invalid integration preview response");
90
+ }
91
+
92
+ export function decodeIntegrationPlan(value: unknown): IntegrationMutationPlan {
93
+ if (!isRecord(value) || !hasOnlyKeys(value, PLAN_KEYS)
94
+ || value.version !== 1
95
+ || !FILE_INTEGRATION_CLIENTS.includes(value.clientId as FileIntegrationClientId)
96
+ || !PLAN_OPERATIONS.includes(value.operation as IntegrationPlanOperation)
97
+ || (typeof value.state !== "string" || !INTEGRATION_STATES.has(value.state))
98
+ || !PLAN_FOREIGN_EDITS.includes(value.foreignEdit as IntegrationPlanForeignEdit)
99
+ /*
100
+ * The version is matched as a version, not as `p1`. The server calls this
101
+ * token opaque and bumps its prefix whenever the inputs it binds change; a
102
+ * literal here made that bump a silent client-side rejection of every
103
+ * preview, which is a worse failure than the drift it was meant to catch.
104
+ */
105
+ || typeof value.fingerprint !== "string" || !/^p[0-9]+:(?:[0-9a-f]{32}|unbound)$/.test(value.fingerprint)
106
+ || typeof value.canApply !== "boolean" || typeof value.willChange !== "boolean"
107
+ || !Array.isArray(value.changes) || value.changes.length > PLAN_CHANGE_LIMIT
108
+ || (value.profileId !== undefined && (typeof value.profileId !== "number" || !Number.isSafeInteger(value.profileId) || value.profileId < 0))
109
+ || (value.profileId !== undefined && value.clientId !== "aside")
110
+ || (value.refusalReason !== undefined && (typeof value.refusalReason !== "string" || !INTEGRATION_REFUSAL_REASONS.has(value.refusalReason)))
111
+ // Descriptive fields of a superseded-store refusal; anywhere else they are a malformed plan.
112
+ || (value.supersededReason !== undefined && (value.refusalReason !== "superseded_store"
113
+ || typeof value.supersededReason !== "string" || !SUPERSEDED_REASONS.has(value.supersededReason)))
114
+ || (value.missingStoreDocument !== undefined && (value.supersededReason !== "missing-store"
115
+ || typeof value.missingStoreDocument !== "string" || !MISSING_STORE_DOCUMENT.test(value.missingStoreDocument)))) {
116
+ throw invalidPreviewResponse();
117
+ }
118
+ const changes: IntegrationPlanChange[] = [];
119
+ let previousOrder = -1;
120
+ let previousPath = "";
121
+ const seenByKind = new Map<IntegrationPlanChangeKind, Set<string>>();
122
+ for (const item of value.changes) {
123
+ if (!isRecord(item) || !hasOnlyKeys(item, PLAN_CHANGE_KEYS)
124
+ || !PLAN_CHANGE_KINDS.includes(item.kind as IntegrationPlanChangeKind)
125
+ || typeof item.path !== "string" || !isSafePlanPath(item.path)) throw invalidPreviewResponse();
126
+ const order = PLAN_CHANGE_KINDS.indexOf(item.kind as IntegrationPlanChangeKind);
127
+ if (order < previousOrder || (order === previousOrder && item.path <= previousPath)) throw invalidPreviewResponse();
128
+ const kind = item.kind as IntegrationPlanChangeKind;
129
+ const paths = seenByKind.get(kind) ?? new Set<string>();
130
+ if (paths.has(item.path)) throw invalidPreviewResponse();
131
+ paths.add(item.path);
132
+ seenByKind.set(kind, paths);
133
+ previousOrder = order;
134
+ previousPath = item.path;
135
+ changes.push({ kind, path: item.path });
136
+ }
137
+ if ((value.willChange && (!value.canApply || changes.length === 0))
138
+ || (!value.willChange && changes.length !== 0)
139
+ || ((value.fingerprint as string).endsWith(":unbound") && value.canApply)
140
+ || (value.canApply === (value.refusalReason !== undefined))) throw invalidPreviewResponse();
141
+ return {
142
+ version: 1,
143
+ clientId: value.clientId as FileIntegrationClientId,
144
+ operation: value.operation as IntegrationPlanOperation,
145
+ state: value.state as IntegrationState,
146
+ foreignEdit: value.foreignEdit as IntegrationPlanForeignEdit,
147
+ changes,
148
+ fingerprint: value.fingerprint,
149
+ canApply: value.canApply,
150
+ willChange: value.willChange,
151
+ ...(value.refusalReason === undefined ? {} : { refusalReason: value.refusalReason as IntegrationRefusalReason }),
152
+ ...(value.supersededReason === undefined ? {} : { supersededReason: value.supersededReason as IneffectiveWriteReason }),
153
+ ...(value.missingStoreDocument === undefined ? {} : { missingStoreDocument: value.missingStoreDocument as string }),
154
+ ...(value.profileId === undefined ? {} : { profileId: Number(value.profileId) }),
155
+ };
156
+ }
@@ -0,0 +1,215 @@
1
+ /** Preview and explicitly bound mutations use the existing management authority. */
2
+ import type { IntegrationMutationPlan, IntegrationPlanOperation } from "../integrations/mutation-plan";
3
+ import { redactSecretString, redactUserPath } from "../lib/redact";
4
+ import {
5
+ CliUsageError, RuntimeApiError, printData, runtimeRequest, terminalSafeText,
6
+ type RuntimeApiDeps,
7
+ } from "./runtime-api";
8
+ import {
9
+ clientIntegrationPath, validateAsideProfile, integrationOption, integrationFlag,
10
+ integrationFingerprint, integrationDroidDefaults,
11
+ } from "./integration-input";
12
+ import { decodeIntegrationPlan, FILE_INTEGRATION_CLIENTS, INTEGRATION_STATES, INTEGRATION_REFUSAL_REASONS } from "./integration-plan-dto";
13
+
14
+ const USAGE = `Usage:
15
+ ocx integration client preview --client ID --operation <apply|overwrite|disable> [--profile N] [--json]
16
+ ocx integration client restore --op ID --preview [--client aside --profile N] [--confirm-drift] [--json]
17
+ ocx integration client <enable|disable> --client ID [--profile N] [--overwrite-conflict] [--plan-fingerprint TOKEN] [--json]
18
+ ocx integration client restore --op ID [--client aside --profile N] [--confirm-drift] [--plan-fingerprint TOKEN] [--json]
19
+ Droid apply/overwrite: [--reasoning-default MODEL=EFFORT ... | --clear-reasoning-defaults]`;
20
+
21
+ type Intent = {
22
+ preview: boolean; json: boolean; client?: string; profile?: string;
23
+ operation: IntegrationPlanOperation; path: string; body: Record<string, unknown>;
24
+ };
25
+
26
+ function parseIntent(argv: string[]): Intent {
27
+ const args = [...argv];
28
+ const action = args.shift();
29
+ if (!["preview", "enable", "disable", "restore"].includes(action ?? "")) throw new CliUsageError("Expected preview, enable, disable or restore", USAGE);
30
+ const json = integrationFlag(args, "--json");
31
+ const client = integrationOption(args, "--client");
32
+ const profile = integrationOption(args, "--profile");
33
+ validateAsideProfile(profile, client, USAGE);
34
+ if (client !== undefined && !FILE_INTEGRATION_CLIENTS.some(id => id === client)) throw new CliUsageError("Unknown integration client", USAGE);
35
+ const previewFlag = integrationFlag(args, "--preview");
36
+ const token = integrationFingerprint(args);
37
+ const defaults = integrationDroidDefaults(args);
38
+ const overwrite = integrationFlag(args, "--overwrite-conflict");
39
+ const confirmDrift = integrationFlag(args, "--confirm-drift");
40
+ const op = integrationOption(args, "--op");
41
+ const opAlias = integrationOption(args, "--op-id");
42
+ const selectedOperation = integrationOption(args, "--operation");
43
+ if (args.length) throw new CliUsageError("Unexpected integration argument(s)", USAGE);
44
+ if (op !== undefined && opAlias !== undefined) throw new CliUsageError("Use only one of --op and --op-id", USAGE);
45
+ const opId = op ?? opAlias;
46
+ if (previewFlag && action !== "restore") throw new CliUsageError("--preview applies only to restore; use the preview command", USAGE);
47
+ const preview = action === "preview" || previewFlag;
48
+ if (preview && token !== undefined) throw new CliUsageError("Preview cannot be combined with --plan-fingerprint", USAGE);
49
+ if (selectedOperation !== undefined && action !== "preview") throw new CliUsageError("--operation applies only to preview", USAGE);
50
+ if (overwrite && action !== "enable") throw new CliUsageError("--overwrite-conflict applies only to enable", USAGE);
51
+ if ((opId !== undefined || confirmDrift) && action !== "restore") throw new CliUsageError("--op and --confirm-drift apply only to restore", USAGE);
52
+ let operation: IntegrationPlanOperation;
53
+ if (action === "preview") {
54
+ if (selectedOperation !== "apply" && selectedOperation !== "overwrite" && selectedOperation !== "disable") throw new CliUsageError("--operation must be apply, overwrite or disable", USAGE);
55
+ operation = selectedOperation;
56
+ } else operation = action === "restore" ? "restore" : action === "disable" ? "disable" : overwrite ? "overwrite" : "apply";
57
+ if (action !== "restore" && !client) throw new CliUsageError("--client is required", USAGE);
58
+ if (action === "restore" && (!opId?.trim() || (client !== undefined && (client !== "aside" || profile === undefined)))) {
59
+ throw new CliUsageError("restore requires --op; its optional target is --client aside --profile N", USAGE);
60
+ }
61
+ if (client === "aside" && profile === undefined) throw new CliUsageError("A preview or bound operation requires one Aside --profile", USAGE);
62
+ if (defaults !== undefined && (client !== "droid" || !["apply", "overwrite"].includes(operation))) throw new CliUsageError("Reasoning defaults apply only to Droid apply or overwrite", USAGE);
63
+ let path: string;
64
+ let body: Record<string, unknown>;
65
+ if (action === "restore") {
66
+ path = profile === undefined ? `/api/client-integrations/restore${preview ? "/preview" : ""}`
67
+ : `${clientIntegrationPath("aside", profile)}/${preview ? "preview" : "restore"}`;
68
+ body = { opId, confirmDrift, ...(preview && profile !== undefined ? { operation } : {}) };
69
+ } else if (preview) {
70
+ path = client === "aside" ? `${clientIntegrationPath(client, profile)}/preview` : "/api/client-integrations/preview";
71
+ body = { ...(client === "aside" ? {} : { clientId: client }), operation };
72
+ } else {
73
+ path = clientIntegrationPath(client!, profile);
74
+ body = { enabled: action === "enable", ...(overwrite ? { overwriteConflict: true } : {}) };
75
+ }
76
+ if (defaults !== undefined) body.droidReasoningDefaults = defaults;
77
+ if (token !== undefined) { body.operation = operation; body.planFingerprint = token; }
78
+ return { preview, json, client, profile, operation, path, body };
79
+ }
80
+
81
+ function record(value: unknown): value is Record<string, unknown> {
82
+ return value !== null && typeof value === "object" && !Array.isArray(value);
83
+ }
84
+
85
+ function checkIdentity(value: { clientId: string; profileId?: number }, intent: Intent): void {
86
+ if ((intent.client !== undefined && value.clientId !== intent.client)
87
+ || (intent.client === undefined && value.clientId === "aside")
88
+ || (intent.profile === undefined ? value.profileId !== undefined : value.profileId !== Number(intent.profile))) {
89
+ throw new Error("Invalid integration target response");
90
+ }
91
+ }
92
+
93
+ interface Receipt {
94
+ ok: boolean; clientId: string; state: string; operation: IntegrationPlanOperation;
95
+ profileId?: number; changed?: boolean; opId?: string; reason?: string; residual?: boolean; snapshotPath?: string;
96
+ }
97
+
98
+ function recoveryFields(value: Record<string, unknown>): { residual?: boolean; snapshotPath?: string } {
99
+ if ((value.residual !== undefined && typeof value.residual !== "boolean")
100
+ || (value.snapshotPath !== undefined && (typeof value.snapshotPath !== "string" || value.snapshotPath.length > 32768))) {
101
+ throw new Error("Invalid integration recovery response");
102
+ }
103
+ return {
104
+ ...(typeof value.residual === "boolean" ? { residual: value.residual } : {}),
105
+ ...(typeof value.snapshotPath === "string" ? { snapshotPath: redactSecretString(redactUserPath(value.snapshotPath)) } : {}),
106
+ };
107
+ }
108
+
109
+ function mutationReceipt(value: unknown, intent: Intent): Receipt {
110
+ if (!record(value) || typeof value.ok !== "boolean" || !FILE_INTEGRATION_CLIENTS.some(id => id === value.clientId)
111
+ || typeof value.state !== "string" || !INTEGRATION_STATES.has(value.state)
112
+ || (value.ok ? typeof value.changed !== "boolean" : typeof value.reason !== "string" || !INTEGRATION_REFUSAL_REASONS.has(value.reason))
113
+ || (value.profileId !== undefined && (typeof value.profileId !== "number" || !Number.isSafeInteger(value.profileId) || value.profileId < 0))
114
+ || (value.opId !== undefined && (typeof value.opId !== "string" || !/^[A-Za-z0-9_-]{1,256}$/.test(value.opId)))) {
115
+ throw new Error("Invalid integration mutation response");
116
+ }
117
+ const result: Receipt = {
118
+ ok: value.ok, clientId: value.clientId as string, state: value.state, operation: intent.operation,
119
+ ...(typeof value.profileId === "number" ? { profileId: value.profileId } : {}),
120
+ ...(typeof value.changed === "boolean" ? { changed: value.changed } : {}),
121
+ ...(typeof value.opId === "string" ? { opId: value.opId } : {}),
122
+ ...(typeof value.reason === "string" && INTEGRATION_REFUSAL_REASONS.has(value.reason) ? { reason: value.reason } : {}),
123
+ ...recoveryFields(value),
124
+ };
125
+ checkIdentity(result, intent);
126
+ return result;
127
+ }
128
+
129
+ function recoveryLines(value: { residual?: boolean; snapshotPath?: string }): string[] {
130
+ return [
131
+ ...(value.residual === true ? ["Automatic recovery did not finish; inspect the client configuration before retrying."] : []),
132
+ ...(value.snapshotPath ? [`Backup (redacted path): ${value.snapshotPath}`] : []),
133
+ ];
134
+ }
135
+
136
+ const REFUSAL_GUIDANCE: Readonly<Record<string, string>> = {
137
+ not_installed: "Install or locate the client before applying its integration.",
138
+ conflict: "Inspect the conflicting client configuration; review an overwrite preview before choosing --overwrite-conflict.",
139
+ unsafe: "Inspect the client configuration and resolve its unsafe path or contents before retrying.",
140
+ non_loopback: "This integration requires a loopback proxy listener.",
141
+ superseded_store: "Inspect integration status for the configuration store the client now reads.",
142
+ drift_requires_confirm: "The client file changed. Review restore --preview --confirm-drift before explicitly confirming drift.",
143
+ snapshot_expired: "The operation backup is no longer available; inspect integration history for another recovery point.",
144
+ write_failed: "Inspect the client configuration and any recovery backup before retrying.",
145
+ };
146
+
147
+ /** A missing store is fixed by hand, so its refusal says with what; the path is on the status row. */
148
+ function refusalGuidance(plan: IntegrationMutationPlan): string {
149
+ if (plan.supersededReason === "missing-store" && plan.missingStoreDocument !== undefined) {
150
+ return `The provider store this client reads is missing. Create it containing \`${plan.missingStoreDocument}\` `
151
+ + `(its path is supersededBy in \`ocx integration client status --client ${plan.clientId}\`), then apply again.`;
152
+ }
153
+ return REFUSAL_GUIDANCE[plan.refusalReason!]!;
154
+ }
155
+
156
+ function reportError(error: unknown): number {
157
+ if (error instanceof CliUsageError) {
158
+ console.error(`Error: ${error.message}`);
159
+ console.error(USAGE);
160
+ return 2;
161
+ }
162
+ if (error instanceof RuntimeApiError) {
163
+ const body = record(error.body) ? error.body : {};
164
+ const stale = body.code === "integration_preview_stale";
165
+ const unavailable = body.code === "integration_preview_unavailable";
166
+ // Local discovery found no proxy: nothing was sent, so name the start command instead of
167
+ // the generic management-host guidance. Server 503s always carry a body; this one has none.
168
+ const stopped = error.status === 503 && error.body === null && error.message.startsWith("Proxy is not running");
169
+ console.error(stale ? "Error: Integration preview is stale. Run the explicit preview again and review its changes before retrying."
170
+ : unavailable ? "Error: Integration preview is unavailable. Load the model catalog, then run the preview again."
171
+ : stopped ? "Error: Proxy is not running. Start the intended proxy with: ocx start. No request was sent."
172
+ : error.status === 404 ? "Error: Integration operation or profile was not found. Inspect integration history and the selected profile."
173
+ : error.status === 409 ? "Error: Integration change was refused. Inspect integration status and preview before retrying."
174
+ : error.status === 503 ? "Error: Management API is unavailable. Check the proxy and run this command on its management host."
175
+ : "Error: Integration request failed. Inspect integration status before retrying.");
176
+ if (!stale) {
177
+ if (typeof body.reason === "string" && INTEGRATION_REFUSAL_REASONS.has(body.reason)) {
178
+ console.error(`Refused (${body.reason}): ${REFUSAL_GUIDANCE[body.reason]}`);
179
+ }
180
+ // Recovery is separate from arbitrary server messages and nested replacement plans.
181
+ try { for (const line of recoveryLines(recoveryFields(body))) console.error(terminalSafeText(line)); } catch { /* Invalid recovery data is not printable. */ }
182
+ }
183
+ return error.status === 404 ? 4 : error.status === 409 ? 5 : 1;
184
+ }
185
+ console.error("Error: Invalid integration response. Check the proxy version and inspect integration status.");
186
+ return 1;
187
+ }
188
+
189
+ export async function handleIntegrationPreviewCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
190
+ try {
191
+ const intent = parseIntent(argv);
192
+ const result = await runtimeRequest(intent.path, {
193
+ method: intent.preview || intent.operation === "restore" ? "POST" : "PUT",
194
+ redirect: "error", body: JSON.stringify(intent.body),
195
+ }, deps);
196
+ if (intent.preview) {
197
+ const plan = decodeIntegrationPlan(result);
198
+ checkIdentity(plan, intent);
199
+ if (plan.operation !== intent.operation) throw new Error("Invalid integration operation response");
200
+ printData(plan, intent.json, [
201
+ `${plan.clientId}${plan.profileId === undefined ? "" : `:${plan.profileId}`} ${plan.operation}: ${!plan.canApply ? `refused (${plan.refusalReason})` : !plan.willChange ? "no changes needed" : "ready for review"}`,
202
+ ...plan.changes.map(change => ` ${change.kind} ${change.path}`),
203
+ ...(plan.canApply ? [`Fingerprint: ${plan.fingerprint}`] : [refusalGuidance(plan)]),
204
+ ]);
205
+ return 0;
206
+ }
207
+ const receipt = mutationReceipt(result, intent);
208
+ printData(receipt, intent.json, [
209
+ `${receipt.clientId}${receipt.profileId === undefined ? "" : `:${receipt.profileId}`} ${receipt.operation}: ${!receipt.ok ? `refused (${receipt.reason})` : receipt.changed ? "updated" : "no changes needed"}.`,
210
+ ...(receipt.opId ? [`Operation: ${receipt.opId}`] : []),
211
+ ...(receipt.reason ? [REFUSAL_GUIDANCE[receipt.reason]!] : []), ...recoveryLines(receipt),
212
+ ]);
213
+ return receipt.ok ? 0 : 1;
214
+ } catch (error) { return reportError(error); }
215
+ }
@@ -12,6 +12,7 @@ import {
12
12
  takeOption,
13
13
  type RuntimeApiDeps,
14
14
  } from "./runtime-api";
15
+ import { clientIntegrationPath, validateAsideProfile } from "./integration-input";
15
16
 
16
17
  const CLAUDE_USAGE = `Usage:
17
18
  ocx claude config [status] [--json]
@@ -34,22 +35,14 @@ const CLIENT_USAGE = `Usage:
34
35
  ocx integration client <enable|disable> --client <id> [--profile <id>] [--overwrite-conflict] [--json]
35
36
  ocx integration client history [--client <id>] [--profile <id>] [--json]
36
37
  ocx integration client restore --op <opId> [--client aside --profile <id>] [--confirm-drift] [--json]
38
+ ocx integration client preview --client <id> --operation <apply|overwrite|disable> [--profile <id>] [--json]
39
+ ocx integration client restore --op <opId> --preview [--client aside --profile <id>] [--confirm-drift] [--json]
40
+ ocx integration client history remove --op <opId> --yes [--client aside] [--profile <id>] [--json]
41
+ ocx integration client sync --client aside [--json]
42
+ enable/disable/restore accept --plan-fingerprint <token>; bound Aside operations require one profile.
43
+ Droid enable/preview accept repeatable --reasoning-default <model=effort> or --clear-reasoning-defaults.
37
44
  --profile selects one Aside account-backed profile; omitted Aside toggles affect all profiles.`;
38
45
 
39
- function validateAsideProfile(profile: string | undefined, client: string | undefined): void {
40
- if (profile === undefined) return;
41
- if (client !== "aside") throw new CliUsageError("--profile requires --client aside", CLIENT_USAGE);
42
- if (!/^(0|[1-9][0-9]*)$/.test(profile) || !Number.isSafeInteger(Number(profile))) {
43
- throw new CliUsageError("--profile must be a nonnegative integer account ID", CLIENT_USAGE);
44
- }
45
-
46
- }
47
-
48
- function clientIntegrationPath(client: string, profile?: string): string {
49
- const base = `/api/client-integrations/${encodeURIComponent(client)}`;
50
- return client === "aside" ? `${base}/profiles${profile === undefined ? "" : `/${encodeURIComponent(profile)}`}` : base;
51
- }
52
-
53
46
  function parseMap(raw: string): Record<string, string> {
54
47
  if (raw === "-") return {};
55
48
  const map: Record<string, string> = {};
@@ -223,6 +216,19 @@ export async function handleClientIntegrationCommand(
223
216
  argv: string[],
224
217
  deps: RuntimeApiDeps = {},
225
218
  ): Promise<number> {
219
+ if ((argv[0] === "history" || argv[0] === "journal") && argv[1] === "remove") {
220
+ const { handleIntegrationJournalRemove } = await import("./integration-journal");
221
+ return handleIntegrationJournalRemove(argv.slice(2), deps);
222
+ }
223
+ if (argv[0] === "sync") {
224
+ const { handleIntegrationAsideSync } = await import("./integration-aside-sync");
225
+ return handleIntegrationAsideSync(argv.slice(1), deps);
226
+ }
227
+ if (argv[0] === "preview" || argv.some(arg => ["--preview", "--plan-fingerprint", "--reasoning-default", "--clear-reasoning-defaults"]
228
+ .some(flag => arg === flag || arg.startsWith(`${flag}=`)))) {
229
+ const { handleIntegrationPreviewCommand } = await import("./integration-preview");
230
+ return handleIntegrationPreviewCommand(argv, deps);
231
+ }
226
232
  return runCliAction(async () => {
227
233
  const args = [...argv];
228
234
  const action = (args.shift() ?? "status").toLowerCase();
@@ -231,7 +237,7 @@ export async function handleClientIntegrationCommand(
231
237
 
232
238
  if (action === "status" || action === "show" || action === "list") {
233
239
  const client = takeOption(args, "--client");
234
- validateAsideProfile(profile, client);
240
+ validateAsideProfile(profile, client, CLIENT_USAGE);
235
241
  rejectArgs(args, CLIENT_USAGE);
236
242
  const path = client
237
243
  ? clientIntegrationPath(client, profile)
@@ -257,7 +263,7 @@ export async function handleClientIntegrationCommand(
257
263
 
258
264
  if (action === "history" || action === "journal") {
259
265
  const client = takeOption(args, "--client");
260
- validateAsideProfile(profile, client);
266
+ validateAsideProfile(profile, client, CLIENT_USAGE);
261
267
  rejectArgs(args, CLIENT_USAGE);
262
268
  const path = client === "aside" ? `${clientIntegrationPath(client, profile)}/journal`
263
269
  : `/api/client-integrations/journal${client ? `?client=${encodeURIComponent(client)}` : ""}`;
@@ -279,7 +285,7 @@ export async function handleClientIntegrationCommand(
279
285
  const opId = takeOption(args, "--op") ?? takeOption(args, "--op-id");
280
286
  const confirmDrift = takeFlag(args, "--confirm-drift");
281
287
  const client = takeOption(args, "--client");
282
- validateAsideProfile(profile, client);
288
+ validateAsideProfile(profile, client, CLIENT_USAGE);
283
289
  if (client !== undefined && profile === undefined) throw new CliUsageError("restore --client requires --profile", CLIENT_USAGE);
284
290
  rejectArgs(args, CLIENT_USAGE);
285
291
  if (!opId) throw new CliUsageError("--op <opId> is required", CLIENT_USAGE);
@@ -295,7 +301,7 @@ export async function handleClientIntegrationCommand(
295
301
  throw new CliUsageError(`unknown client integration command ${action}`, CLIENT_USAGE);
296
302
  }
297
303
  const client = takeOption(args, "--client");
298
- validateAsideProfile(profile, client);
304
+ validateAsideProfile(profile, client, CLIENT_USAGE);
299
305
  /*
300
306
  * The conflict escape hatch, spelled the way `restore --confirm-drift` is: the
301
307
  * refusal is the default and the waiver has to be typed.
@@ -0,0 +1,134 @@
1
+ /** Bounded JSON ingress for management commands; domain validation stays with the caller. */
2
+ import { constants } from "node:fs";
3
+ import { open, stat, type FileHandle } from "node:fs/promises";
4
+ import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../server/management/body";
5
+ import { CliUsageError, readSecretBytes, type CliStdin, type RuntimeApiDeps } from "./runtime-api";
6
+
7
+ const INPUT_TIMEOUT_MS = 30_000;
8
+ const INPUT_LABEL = "JSON input";
9
+ const TOO_LARGE = "JSON input exceeds the 4 MiB management body limit";
10
+ const TIMED_OUT = "Timed out reading JSON input";
11
+
12
+ // Only errors created here may cross the boundary. Files, streams and parsers can
13
+ // throw arbitrary objects, including CliUsageError instances containing secrets.
14
+ class JsonInputFailure extends CliUsageError {}
15
+
16
+ function inputTimeout(deps: RuntimeApiDeps): number {
17
+ const value = deps.stdinTimeoutMs ?? INPUT_TIMEOUT_MS;
18
+ if (!Number.isFinite(value) || value <= 0 || value > INPUT_TIMEOUT_MS) {
19
+ throw new JsonInputFailure("JSON input deadline must be within 30 seconds");
20
+ }
21
+ return value;
22
+ }
23
+
24
+ async function boundedStdin(input: CliStdin, timeoutMs: number): Promise<Uint8Array> {
25
+ if (input.isTTY) throw new JsonInputFailure("JSON input requires a file or piped stdin");
26
+ if ("readableEncoding" in input && input.readableEncoding != null) {
27
+ throw new JsonInputFailure("JSON input requires byte-oriented stdin");
28
+ }
29
+ try {
30
+ return await readSecretBytes({ stdinImpl: input, stdinTimeoutMs: timeoutMs }, INPUT_LABEL, MANAGEMENT_JSON_BODY_MAX_BYTES);
31
+ } catch (error) {
32
+ if (error instanceof CliUsageError) {
33
+ if (error.message === `${INPUT_LABEL} exceeds ${MANAGEMENT_JSON_BODY_MAX_BYTES} bytes`) throw new JsonInputFailure(TOO_LARGE);
34
+ if (error.message === `timed out waiting for ${INPUT_LABEL} on stdin`) throw new JsonInputFailure(TIMED_OUT);
35
+ if (error.message === `${INPUT_LABEL} input was empty`) throw new JsonInputFailure("JSON input was empty");
36
+ }
37
+ throw new JsonInputFailure("Unable to read JSON input");
38
+ } finally {
39
+ // readSecretBytes owns its listeners/timer; it does not stop a still-flowing
40
+ // stream after size, error or timeout refusal. We never close caller stdin.
41
+ input.pause();
42
+ }
43
+ }
44
+
45
+ async function beforeDeadline<T>(operation: Promise<T>, deadline: number, late?: (value: T) => Promise<void>): Promise<T> {
46
+ let expired = false;
47
+ let timer: ReturnType<typeof setTimeout> | undefined;
48
+ const timeout = new Promise<never>((_, reject) => {
49
+ timer = setTimeout(() => { expired = true; reject(new JsonInputFailure(TIMED_OUT)); }, Math.max(0, deadline - performance.now()));
50
+ });
51
+ try {
52
+ return await Promise.race([operation.then(async value => {
53
+ // An open can finish after the deadline: its descriptor is still ours.
54
+ if (expired) {
55
+ await late?.(value);
56
+ throw new JsonInputFailure(TIMED_OUT);
57
+ }
58
+ return value;
59
+ }), timeout]);
60
+ } finally {
61
+ clearTimeout(timer);
62
+ }
63
+ }
64
+
65
+ async function fileBytes(path: string, timeoutMs: number): Promise<Uint8Array> {
66
+ const deadline = performance.now() + timeoutMs;
67
+ let handle: FileHandle | undefined;
68
+ let stream: ReturnType<FileHandle["createReadStream"]> | undefined;
69
+ let bytes: Uint8Array | undefined;
70
+ try {
71
+ // Preflight avoids opening known devices/FIFOs. O_NONBLOCK plus fstat also
72
+ // refuses a FIFO substituted between the path check and the actual open.
73
+ const pathStat = await beforeDeadline(stat(path), deadline);
74
+ if (!pathStat.isFile()) throw new JsonInputFailure("JSON input must be a regular file");
75
+ if (pathStat.size > MANAGEMENT_JSON_BODY_MAX_BYTES) throw new JsonInputFailure(TOO_LARGE);
76
+ handle = await beforeDeadline(open(path, constants.O_RDONLY | constants.O_NONBLOCK), deadline, file => file.close());
77
+ const openedStat = await beforeDeadline(handle.stat(), deadline);
78
+ if (!openedStat.isFile()) throw new JsonInputFailure("JSON input must be a regular file");
79
+ if (openedStat.size > MANAGEMENT_JSON_BODY_MAX_BYTES) throw new JsonInputFailure(TOO_LARGE);
80
+ const remaining = deadline - performance.now();
81
+ if (remaining <= 0) throw new JsonInputFailure(TIMED_OUT);
82
+ stream = handle.createReadStream({ autoClose: false });
83
+ // The stream can report a pending I/O error while teardown destroys it.
84
+ // readSecretBytes removes only its own error listener; retain this one
85
+ // through close to avoid an unhandled error after timeout/limit refusal.
86
+ const ignoreTeardownError = () => {};
87
+ stream.on("error", ignoreTeardownError);
88
+ stream.once("close", () => stream?.removeListener("error", ignoreTeardownError));
89
+ bytes = await boundedStdin(stream, remaining);
90
+ return bytes;
91
+ } finally {
92
+ stream?.destroy();
93
+ try { await handle?.close(); }
94
+ catch {
95
+ bytes?.fill(0);
96
+ throw new JsonInputFailure("Unable to close JSON input file");
97
+ }
98
+ }
99
+ }
100
+
101
+ /** Explicit '-' is the only stdin selector. The label is never treated as safe output. */
102
+ export async function readJsonInput(pathOrDash: string, deps: RuntimeApiDeps = {}, _label = INPUT_LABEL): Promise<unknown> {
103
+ let bytes: Uint8Array | undefined;
104
+ try {
105
+ if (!pathOrDash) throw new JsonInputFailure("JSON input requires an explicit file or '-'");
106
+ const timeoutMs = inputTimeout(deps);
107
+ bytes = pathOrDash === "-"
108
+ ? await boundedStdin(deps.stdinImpl ?? process.stdin, timeoutMs)
109
+ : await fileBytes(pathOrDash, timeoutMs);
110
+ let text: string;
111
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
112
+ catch { throw new JsonInputFailure("JSON input must be valid UTF-8"); }
113
+ if (!text.trim()) throw new JsonInputFailure("JSON input was empty");
114
+ try { return JSON.parse(text) as unknown; }
115
+ catch { throw new JsonInputFailure("JSON input must contain valid JSON"); }
116
+ } catch (error) {
117
+ if (error instanceof JsonInputFailure) throw error;
118
+ throw new JsonInputFailure("Unable to read JSON input");
119
+ } finally {
120
+ // Decoded strings and parsed values are immutable/consumer-owned; only
121
+ // mutable byte buffers can be erased, without promising string erasure.
122
+ bytes?.fill(0);
123
+ }
124
+ }
125
+
126
+ /** Serialize once, bounding the complete UTF-8 wire body, including envelope keys. */
127
+ export function serializeManagementJson(value: unknown): string {
128
+ let serialized: string | undefined;
129
+ try { serialized = JSON.stringify(value); }
130
+ catch { throw new JsonInputFailure("Management body must be JSON serializable"); }
131
+ if (serialized === undefined) throw new JsonInputFailure("Management body must be JSON serializable");
132
+ if (Buffer.byteLength(serialized, "utf8") > MANAGEMENT_JSON_BODY_MAX_BYTES) throw new JsonInputFailure(TOO_LARGE);
133
+ return serialized;
134
+ }