agent-embassy 1.7.0 → 1.8.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 (118) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/CONTRIBUTING.md +51 -47
  3. package/README.md +26 -9
  4. package/README.zh-CN.md +22 -9
  5. package/SECURITY.md +48 -35
  6. package/dist/src/gateway/acp-client.d.ts +12 -19
  7. package/dist/src/gateway/acp-client.js +90 -65
  8. package/dist/src/gateway/acp-client.js.map +1 -1
  9. package/dist/src/gateway/acp-provider.d.ts +11 -10
  10. package/dist/src/gateway/acp-provider.js +165 -53
  11. package/dist/src/gateway/acp-provider.js.map +1 -1
  12. package/dist/src/gateway/claude-helper-client.d.ts +2 -43
  13. package/dist/src/gateway/claude-helper-client.js +1 -211
  14. package/dist/src/gateway/claude-helper-client.js.map +1 -1
  15. package/dist/src/gateway/claude-helper-protocol.d.ts +16 -74
  16. package/dist/src/gateway/claude-helper-protocol.js +99 -378
  17. package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
  18. package/dist/src/gateway/claude-helper-supervisor.d.ts +56 -44
  19. package/dist/src/gateway/claude-helper-supervisor.js +265 -456
  20. package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
  21. package/dist/src/gateway/claude-helper.js +184 -210
  22. package/dist/src/gateway/claude-helper.js.map +1 -1
  23. package/dist/src/gateway/claude-peer.d.ts +20 -87
  24. package/dist/src/gateway/claude-peer.js +310 -1009
  25. package/dist/src/gateway/claude-peer.js.map +1 -1
  26. package/dist/src/gateway/claude-runtime.d.ts +13 -15
  27. package/dist/src/gateway/claude-runtime.js +14 -168
  28. package/dist/src/gateway/claude-runtime.js.map +1 -1
  29. package/dist/src/gateway/cli-copy.en.d.ts +2 -2
  30. package/dist/src/gateway/cli-copy.en.js +4 -2
  31. package/dist/src/gateway/cli-copy.en.js.map +1 -1
  32. package/dist/src/gateway/cli-copy.zh-CN.d.ts +2 -2
  33. package/dist/src/gateway/cli-copy.zh-CN.js +4 -2
  34. package/dist/src/gateway/cli-copy.zh-CN.js.map +1 -1
  35. package/dist/src/gateway/cli.d.ts +7 -2
  36. package/dist/src/gateway/cli.js +277 -575
  37. package/dist/src/gateway/cli.js.map +1 -1
  38. package/dist/src/gateway/codex-app-server.d.ts +5 -227
  39. package/dist/src/gateway/codex-app-server.js +18 -1463
  40. package/dist/src/gateway/codex-app-server.js.map +1 -1
  41. package/dist/src/gateway/codex-doctor.d.ts +36 -0
  42. package/dist/src/gateway/codex-doctor.js +127 -0
  43. package/dist/src/gateway/codex-doctor.js.map +1 -0
  44. package/dist/src/gateway/codex-local-transport.d.ts +6 -6
  45. package/dist/src/gateway/codex-local-transport.js +13 -10
  46. package/dist/src/gateway/codex-local-transport.js.map +1 -1
  47. package/dist/src/gateway/codex-stateless-transport.d.ts +114 -0
  48. package/dist/src/gateway/codex-stateless-transport.js +1108 -0
  49. package/dist/src/gateway/codex-stateless-transport.js.map +1 -0
  50. package/dist/src/gateway/config.d.ts +2 -9
  51. package/dist/src/gateway/config.js +62 -100
  52. package/dist/src/gateway/config.js.map +1 -1
  53. package/dist/src/gateway/control.d.ts +36 -90
  54. package/dist/src/gateway/control.js +465 -1123
  55. package/dist/src/gateway/control.js.map +1 -1
  56. package/dist/src/gateway/dashboard-copy.d.ts +1 -1
  57. package/dist/src/gateway/dashboard-copy.en.d.ts +8 -26
  58. package/dist/src/gateway/dashboard-copy.en.js +19 -37
  59. package/dist/src/gateway/dashboard-copy.en.js.map +1 -1
  60. package/dist/src/gateway/dashboard-copy.js +8 -26
  61. package/dist/src/gateway/dashboard-copy.js.map +1 -1
  62. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +8 -26
  63. package/dist/src/gateway/dashboard-copy.zh-CN.js +19 -37
  64. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +1 -1
  65. package/dist/src/gateway/dashboard-model.d.ts +8 -22
  66. package/dist/src/gateway/dashboard-model.js +44 -130
  67. package/dist/src/gateway/dashboard-model.js.map +1 -1
  68. package/dist/src/gateway/dashboard.d.ts +0 -6
  69. package/dist/src/gateway/dashboard.js +3 -7
  70. package/dist/src/gateway/dashboard.js.map +1 -1
  71. package/dist/src/gateway/live-dashboard-app/app.js +20 -34
  72. package/dist/src/gateway/live-dashboard-command.js +2 -2
  73. package/dist/src/gateway/live-dashboard-command.js.map +1 -1
  74. package/dist/src/gateway/live-dashboard-http.d.ts +1 -1
  75. package/dist/src/gateway/live-dashboard-http.js +2 -2
  76. package/dist/src/gateway/live-dashboard-http.js.map +1 -1
  77. package/dist/src/gateway/progress-watch-machine.d.ts +1 -37
  78. package/dist/src/gateway/progress-watch-machine.js +4 -15
  79. package/dist/src/gateway/progress-watch-machine.js.map +1 -1
  80. package/dist/src/gateway/providers.d.ts +58 -191
  81. package/dist/src/gateway/providers.js +637 -2434
  82. package/dist/src/gateway/providers.js.map +1 -1
  83. package/dist/src/gateway/server.d.ts +16 -25
  84. package/dist/src/gateway/server.js +165 -240
  85. package/dist/src/gateway/server.js.map +1 -1
  86. package/dist/src/gateway/service.d.ts +149 -427
  87. package/dist/src/gateway/service.js +1639 -5662
  88. package/dist/src/gateway/service.js.map +1 -1
  89. package/dist/src/gateway/state-v2-to-v3.d.ts +23 -0
  90. package/dist/src/gateway/state-v2-to-v3.js +994 -0
  91. package/dist/src/gateway/state-v2-to-v3.js.map +1 -0
  92. package/dist/src/gateway/store.d.ts +54 -309
  93. package/dist/src/gateway/store.js +1560 -3510
  94. package/dist/src/gateway/store.js.map +1 -1
  95. package/dist/src/gateway/types.d.ts +181 -225
  96. package/dist/src/gateway/types.js +115 -251
  97. package/dist/src/gateway/types.js.map +1 -1
  98. package/docs/CONFIGURATION.md +22 -9
  99. package/docs/CONFIGURATION.zh-CN.md +21 -9
  100. package/docs/DASHBOARD.md +10 -10
  101. package/docs/DASHBOARD.zh-CN.md +3 -3
  102. package/docs/DELIVERY.md +4 -4
  103. package/docs/DELIVERY.zh-CN.md +4 -4
  104. package/docs/GATEWAY-ARCHITECTURE.md +158 -187
  105. package/package.json +1 -1
  106. package/skills/embassy-peer/SKILL.md +28 -22
  107. package/dist/src/gateway/codex-registration-generation.d.ts +0 -5
  108. package/dist/src/gateway/codex-registration-generation.js +0 -19
  109. package/dist/src/gateway/codex-registration-generation.js.map +0 -1
  110. package/dist/src/gateway/codex-registration-succession.d.ts +0 -209
  111. package/dist/src/gateway/codex-registration-succession.js +0 -588
  112. package/dist/src/gateway/codex-registration-succession.js.map +0 -1
  113. package/dist/src/gateway/compatibility.d.ts +0 -22
  114. package/dist/src/gateway/compatibility.js +0 -23
  115. package/dist/src/gateway/compatibility.js.map +0 -1
  116. package/dist/src/gateway/delivery-machine.d.ts +0 -235
  117. package/dist/src/gateway/delivery-machine.js +0 -540
  118. package/dist/src/gateway/delivery-machine.js.map +0 -1
@@ -1,18 +1,10 @@
1
- /**
2
- * Private, local gateway control transport.
3
- *
4
- * Every byte received here is untrusted even though the socket lives inside a
5
- * same-UID state directory. The directory permissions are the authentication
6
- * boundary; this module still validates every frame and field before calling a
7
- * handler. It deliberately never logs, stores, or reflects task IDs or message
8
- * text.
9
- */
1
+ /** Private, bounded JSONL control transport over one same-user Unix socket. */
10
2
  import { randomBytes } from "node:crypto";
11
- import { chmod, lstat, mkdtemp, realpath, rename, rmdir, unlink, } from "node:fs/promises";
3
+ import { chmod, lstat, mkdtemp, realpath, rename, rmdir, unlink } from "node:fs/promises";
12
4
  import net from "node:net";
13
5
  import path from "node:path";
14
6
  import { TextDecoder } from "node:util";
15
- import { GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET, gatewayProviders, gatewayPublicSnapshotLimits, isGatewayProvider, isMessageDirection, isPublicRegistryObservationSnapshot, } from "./types.js";
7
+ import { CONNECTOR_OBSERVATION_STALE_AFTER_MS, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET, gatewayProviders, gatewayPublicSnapshotLimits, isGatewayProvider, isMessageDirection, isPublicRegistryObservationSnapshot, } from "./types.js";
16
8
  export const GATEWAY_CONTROL_PROTOCOL_VERSION = 1;
17
9
  export const GATEWAY_CONTROL_MAX_FRAME_BYTES = 32 * 1024;
18
10
  export const GATEWAY_CONTROL_MAX_RESPONSE_BYTES = 256 * 1024;
@@ -23,7 +15,6 @@ const MAX_SOCKET_PATH_BYTES = 100;
23
15
  const MAX_REPLY_ADDRESS_BYTES = 256;
24
16
  const MAX_REVISION = Number.MAX_SAFE_INTEGER;
25
17
  const DEFAULT_HOST_ID = "this-mac";
26
- const DEFAULT_BUSY_POLICY = "queue";
27
18
  const ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,31}@[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/;
28
19
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
29
20
  const CONVERSATION_ID_PATTERN = /^conv_[A-Za-z0-9_-]{16,64}$/;
@@ -36,48 +27,46 @@ const MESSAGE_SUFFIX_PATTERN = /^[0-9a-f]{8}$/;
36
27
  const CONVERSATION_SUFFIX_PATTERN = /^[A-Za-z0-9_-]{8}$/;
37
28
  const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
38
29
  export const gatewayControlMethods = [
39
- "health",
40
- "register_codex",
41
- "unregister_codex",
42
- "remove_stale_codex_registration",
43
- "select_claude",
44
- "unselect_claude",
45
- "pair",
46
- "unpair",
47
- "list_snapshot",
48
- "observe_snapshot",
49
- "delivery_status",
50
- "untrack",
51
- "send_to_claude",
52
- "send_to_codex",
53
- "reply",
54
- "refresh_dashboard",
30
+ "health", "register_codex", "unregister_codex", "remove_codex_registration",
31
+ "select_claude", "unselect_claude", "pair", "unpair", "list_snapshot",
32
+ "observe_snapshot", "delivery_status", "untrack", "send_to_claude",
33
+ "send_to_codex", "reply", "refresh_dashboard",
55
34
  ];
56
35
  export function pairParamAliases(params) {
57
- return "aliases" in params
58
- ? params.aliases
59
- : [params.claudeAlias, params.codexAlias];
36
+ return "aliases" in params ? params.aliases : [params.claudeAlias, params.codexAlias];
60
37
  }
61
38
  export function pairParamThreadAttestation(params) {
62
39
  if ("aliases" in params)
63
40
  return params.threadAttestation;
64
41
  return params.codexThreadId === undefined
65
- ? undefined
66
- : { alias: params.codexAlias, threadId: params.codexThreadId };
42
+ ? undefined : { alias: params.codexAlias, threadId: params.codexThreadId };
67
43
  }
68
44
  const WIRE_ERROR_MESSAGES = {
69
- INVALID_JSON: "The control frame is not valid JSON.",
70
- FRAME_TOO_LARGE: "The control frame exceeds the size limit.",
71
- INVALID_REQUEST: "The control request is invalid.",
72
- UNSUPPORTED_VERSION: "The control protocol version is unsupported.",
73
- UNKNOWN_METHOD: "The control method is unsupported.",
74
- MULTIPLE_FRAMES: "Only one control request is allowed per connection.",
75
- SERVER_BUSY: "The gateway control server is at its connection limit.",
76
- REQUEST_TIMEOUT: "The control request timed out.",
77
- HANDLER_FAILURE: "The gateway could not complete the control request.",
78
- INVALID_HANDLER_RESPONSE: "The gateway produced an invalid control response.",
45
+ INVALID_JSON: "The control frame is not valid JSON.", FRAME_TOO_LARGE: "The control frame exceeds the size limit.",
46
+ INVALID_REQUEST: "The control request is invalid.", UNSUPPORTED_VERSION: "The control protocol version is unsupported.",
47
+ UNKNOWN_METHOD: "The control method is unsupported.", MULTIPLE_FRAMES: "Only one control request is allowed per connection.",
48
+ SERVER_BUSY: "The gateway control server is at its connection limit.", REQUEST_TIMEOUT: "The control request timed out.",
49
+ HANDLER_FAILURE: "The gateway could not complete the control request.", INVALID_HANDLER_RESPONSE: "The gateway produced an invalid control response.",
79
50
  RESPONSE_TOO_LARGE: "The control response exceeds the size limit.",
80
51
  };
52
+ const TRANSPORT_MESSAGES = {
53
+ UNSUPPORTED_PLATFORM: "Unix-domain gateway control sockets are unavailable.",
54
+ INVALID_STATE_DIR: "The gateway state directory is invalid.",
55
+ INSECURE_STATE_DIR: "The gateway state directory is not private to this user.",
56
+ INVALID_SOCKET_PATH: "The gateway control socket path is invalid.",
57
+ UNSAFE_SOCKET_TARGET: "The gateway control socket target is unsafe.",
58
+ SOCKET_IN_USE: "The gateway control socket is already served by a live process.",
59
+ SOCKET_PROBE_FAILED: "The gateway control socket could not be checked safely.",
60
+ SOCKET_BIND_FAILED: "The gateway control socket could not be bound.",
61
+ SOCKET_PERMISSION_FAILED: "The gateway control socket permissions could not be secured.",
62
+ SOCKET_CLEANUP_CONFLICT: "The gateway control socket path changed during cleanup.",
63
+ CONTROL_CONNECT_FAILED: "The gateway control socket could not be reached.",
64
+ CONTROL_TIMEOUT: "The gateway control request timed out.",
65
+ CONTROL_RESPONSE_TOO_LARGE: "The gateway control response exceeds the client limit.",
66
+ CONTROL_INVALID_RESPONSE: "The gateway returned an invalid control response. Restart the broker, then retry.",
67
+ CONTROL_CONNECTION_CLOSED: "The gateway closed before returning a control response.",
68
+ CONTROL_OUTCOME_AMBIGUOUS: "The gateway may have applied the control mutation before the response was lost; do not retry automatically.",
69
+ };
81
70
  export class GatewayControlTransportError extends Error {
82
71
  code;
83
72
  ambiguous;
@@ -98,34 +87,27 @@ class ProtocolFault extends Error {
98
87
  this.code = code;
99
88
  }
100
89
  }
90
+ function controlTransportError(code, ambiguous = false) {
91
+ return new GatewayControlTransportError(code, TRANSPORT_MESSAGES[code] ?? "The gateway control transport failed.", ambiguous);
92
+ }
93
+ function invalid() { throw new ProtocolFault("INVALID_REQUEST"); }
101
94
  export function createGatewayConversationId() {
102
95
  return `conv_${randomBytes(18).toString("base64url")}`;
103
96
  }
104
- export function isGatewayAlias(value) {
105
- return ALIAS_PATTERN.test(value);
106
- }
97
+ export function isGatewayAlias(value) { return ALIAS_PATTERN.test(value); }
107
98
  export function isClaudeSessionSelector(value) {
108
99
  return isGatewayAlias(value) || UUID_PATTERN.test(value);
109
100
  }
110
- export function isGatewayHostId(value) {
111
- return HOST_PATTERN.test(value);
112
- }
113
- export function isGatewayConversationId(value) {
114
- return CONVERSATION_ID_PATTERN.test(value);
115
- }
116
- export function isGatewayDeliveryToken(value) {
117
- return DELIVERY_TOKEN_PATTERN.test(value);
118
- }
101
+ export function isGatewayHostId(value) { return HOST_PATTERN.test(value); }
102
+ export function isGatewayConversationId(value) { return CONVERSATION_ID_PATTERN.test(value); }
103
+ export function isGatewayDeliveryToken(value) { return DELIVERY_TOKEN_PATTERN.test(value); }
119
104
  export function isGatewayReplyAddress(value) {
120
- if (value.includes("\0") ||
121
- !value.startsWith("uds:") ||
122
- Buffer.byteLength(value, "utf8") > MAX_REPLY_ADDRESS_BYTES) {
105
+ if (value.includes("\0") || !value.startsWith("uds:") ||
106
+ Buffer.byteLength(value, "utf8") > MAX_REPLY_ADDRESS_BYTES)
123
107
  return false;
124
- }
125
108
  const socketPath = value.slice(4);
126
- return (path.isAbsolute(socketPath) &&
127
- path.resolve(socketPath) === socketPath &&
128
- Buffer.byteLength(socketPath, "utf8") <= MAX_SOCKET_PATH_BYTES);
109
+ return path.isAbsolute(socketPath) && path.resolve(socketPath) === socketPath &&
110
+ Buffer.byteLength(socketPath, "utf8") <= MAX_SOCKET_PATH_BYTES;
129
111
  }
130
112
  function isRecord(value) {
131
113
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
@@ -170,1047 +152,465 @@ function isAlias(value) {
170
152
  function isHostId(value) {
171
153
  return typeof value === "string" && isGatewayHostId(value);
172
154
  }
173
- function isConversationId(value) {
174
- return typeof value === "string" && isGatewayConversationId(value);
175
- }
176
155
  function isMessageText(value) {
177
156
  return (typeof value === "string" &&
178
157
  value.trim().length > 0 &&
179
158
  !value.includes("\0") &&
180
159
  Buffer.byteLength(value, "utf8") <= GATEWAY_CONTROL_MAX_MESSAGE_BYTES);
181
160
  }
182
- function normalizeReplyCaller(value) {
183
- if (!isRecord(value) || typeof value.kind !== "string") {
184
- throw new ProtocolFault("INVALID_REQUEST");
185
- }
186
- if (value.kind === "codex") {
187
- if (!hasExactKeys(value, ["kind", "alias", "threadId"]) ||
188
- !isAlias(value.alias) ||
189
- !isUuid(value.threadId)) {
190
- throw new ProtocolFault("INVALID_REQUEST");
191
- }
192
- return {
193
- kind: "codex",
194
- alias: value.alias,
195
- threadId: value.threadId.toLowerCase(),
196
- };
197
- }
198
- if (value.kind === "claude") {
199
- if (!hasExactKeys(value, ["kind", "alias"], ["replyAddress"]) ||
200
- !isAlias(value.alias) ||
201
- (value.replyAddress !== undefined &&
202
- (typeof value.replyAddress !== "string" ||
203
- !isGatewayReplyAddress(value.replyAddress)))) {
204
- throw new ProtocolFault("INVALID_REQUEST");
205
- }
206
- return {
207
- kind: "claude",
208
- alias: value.alias,
209
- ...(value.replyAddress === undefined
210
- ? {}
211
- : { replyAddress: value.replyAddress }),
212
- };
213
- }
214
- throw new ProtocolFault("INVALID_REQUEST");
215
- }
216
- function normalizeParams(method, value) {
161
+ const exact = hasExactKeys;
162
+ const nonNegative = isNonNegativeInteger;
163
+ const trackMinutes = isTrackIdleMinutes;
164
+ const iso = isIsoTimestamp;
165
+ const uuid = isUuid;
166
+ const alias = isAlias;
167
+ const host = isHostId;
168
+ function shape(value, required, optional = {}) {
169
+ if (!isRecord(value) || !exact(value, Object.keys(required), Object.keys(optional)))
170
+ return false;
171
+ return Object.entries(required).every(([key, check]) => check(value[key])) &&
172
+ Object.entries(optional).every(([key, check]) => value[key] === undefined || check(value[key]));
173
+ }
174
+ function oneOf(...values) { return (value) => values.includes(value); }
175
+ function arrayOf(value, maximum, check) {
176
+ return Array.isArray(value) && value.length <= maximum && value.every(check);
177
+ }
178
+ function positive(value) { return nonNegative(value) && value > 0; }
179
+ function safeCode(value) { return typeof value === "string" && SAFE_CODE_PATTERN.test(value); }
180
+ const messageText = isMessageText;
181
+ function emptyParams(value) {
182
+ if (!isRecord(value) || !exact(value, []))
183
+ invalid();
184
+ return {};
185
+ }
186
+ function decodeRegister(value) {
187
+ if (!isRecord(value) || !exact(value, ["alias", "threadId"], ["hostId", "busyPolicy", "succeedsAlias"]) ||
188
+ !alias(value.alias) || !value.alias.startsWith("codex-") || !uuid(value.threadId) ||
189
+ (value.hostId !== undefined && !host(value.hostId)) ||
190
+ (value.busyPolicy !== undefined && value.busyPolicy !== "queue") ||
191
+ (value.succeedsAlias !== undefined && (!alias(value.succeedsAlias) || !value.succeedsAlias.startsWith("codex-"))))
192
+ invalid();
193
+ const hostId = value.hostId ?? DEFAULT_HOST_ID;
194
+ if (!value.alias.endsWith(`@${hostId}`) ||
195
+ (value.succeedsAlias !== undefined &&
196
+ (value.succeedsAlias === value.alias || !value.succeedsAlias.endsWith(`@${hostId}`))))
197
+ invalid();
198
+ return { alias: value.alias, threadId: value.threadId.toLowerCase(), hostId,
199
+ busyPolicy: "queue", ...(value.succeedsAlias === undefined ? {} : { succeedsAlias: value.succeedsAlias }) };
200
+ }
201
+ function decodeUnregister(value) {
202
+ if (!shape(value, { alias, threadId: uuid }) || !value.alias.startsWith("codex-"))
203
+ invalid();
204
+ return { alias: value.alias, threadId: value.threadId.toLowerCase() };
205
+ }
206
+ function decodeRemoval(value) {
207
+ if (!shape(value, { alias }) || !value.alias.startsWith("codex-"))
208
+ invalid();
209
+ return { alias: value.alias };
210
+ }
211
+ function decodeSelection(value) {
212
+ if (!isRecord(value) || !exact(value, ["alias"], ["codexThreadId"]) ||
213
+ typeof value.alias !== "string" || !isClaudeSessionSelector(value.alias) ||
214
+ (value.codexThreadId !== undefined && !uuid(value.codexThreadId)))
215
+ invalid();
216
+ return { alias: UUID_PATTERN.test(value.alias) ? value.alias.toLowerCase() : value.alias,
217
+ ...(value.codexThreadId === undefined ? {} : { codexThreadId: value.codexThreadId.toLowerCase() }) };
218
+ }
219
+ function decodePair(value) {
217
220
  if (!isRecord(value))
218
- throw new ProtocolFault("INVALID_REQUEST");
219
- switch (method) {
220
- case "health":
221
- case "list_snapshot":
222
- case "observe_snapshot":
223
- case "refresh_dashboard":
224
- if (!hasExactKeys(value, [])) {
225
- throw new ProtocolFault("INVALID_REQUEST");
226
- }
227
- return {};
228
- case "delivery_status": {
229
- if (!hasExactKeys(value, ["token"]) ||
230
- typeof value.token !== "string" ||
231
- !isGatewayDeliveryToken(value.token)) {
232
- throw new ProtocolFault("INVALID_REQUEST");
233
- }
234
- return { token: value.token };
235
- }
236
- case "untrack": {
237
- if (!hasExactKeys(value, ["conversationId"]) ||
238
- !isConversationId(value.conversationId)) {
239
- throw new ProtocolFault("INVALID_REQUEST");
240
- }
241
- return { conversationId: value.conversationId };
221
+ invalid();
222
+ if (Object.hasOwn(value, "aliases")) {
223
+ if (!exact(value, ["aliases"], ["threadAttestation"]) || !Array.isArray(value.aliases) ||
224
+ value.aliases.length !== 2 || !alias(value.aliases[0]) || !alias(value.aliases[1]) ||
225
+ value.aliases[0] === value.aliases[1] ||
226
+ value.aliases[0].slice(value.aliases[0].lastIndexOf("@") + 1) !==
227
+ value.aliases[1].slice(value.aliases[1].lastIndexOf("@") + 1))
228
+ invalid();
229
+ let threadAttestation;
230
+ if (value.threadAttestation !== undefined) {
231
+ if (!shape(value.threadAttestation, { alias, threadId: uuid }) ||
232
+ !value.aliases.includes(value.threadAttestation.alias))
233
+ invalid();
234
+ threadAttestation = { alias: value.threadAttestation.alias,
235
+ threadId: value.threadAttestation.threadId.toLowerCase() };
242
236
  }
243
- case "register_codex": {
244
- if (!hasExactKeys(value, ["alias", "threadId"], ["hostId", "busyPolicy", "succeedsAlias"]) ||
245
- !isAlias(value.alias) ||
246
- !value.alias.startsWith("codex-") ||
247
- !isUuid(value.threadId) ||
248
- (value.hostId !== undefined && !isHostId(value.hostId)) ||
249
- (value.busyPolicy !== undefined &&
250
- value.busyPolicy !== "queue") ||
251
- (value.succeedsAlias !== undefined &&
252
- (!isAlias(value.succeedsAlias) ||
253
- !value.succeedsAlias.startsWith("codex-")))) {
254
- throw new ProtocolFault("INVALID_REQUEST");
255
- }
256
- const hostId = value.hostId ?? DEFAULT_HOST_ID;
257
- if (!value.alias.endsWith(`@${hostId}`) ||
258
- (value.succeedsAlias !== undefined &&
259
- (value.succeedsAlias === value.alias ||
260
- !value.succeedsAlias.endsWith(`@${hostId}`)))) {
261
- throw new ProtocolFault("INVALID_REQUEST");
262
- }
263
- return {
264
- alias: value.alias,
265
- threadId: value.threadId.toLowerCase(),
266
- hostId,
267
- busyPolicy: value.busyPolicy ?? DEFAULT_BUSY_POLICY,
268
- ...(value.succeedsAlias === undefined
269
- ? {}
270
- : { succeedsAlias: value.succeedsAlias }),
271
- };
272
- }
273
- case "unregister_codex": {
274
- if (!hasExactKeys(value, ["alias", "threadId"]) ||
275
- !isAlias(value.alias) ||
276
- !value.alias.startsWith("codex-") ||
277
- !isUuid(value.threadId)) {
278
- throw new ProtocolFault("INVALID_REQUEST");
279
- }
280
- return {
281
- alias: value.alias,
282
- threadId: value.threadId.toLowerCase(),
283
- };
284
- }
285
- case "remove_stale_codex_registration": {
286
- if (!hasExactKeys(value, ["alias"]) ||
287
- !isAlias(value.alias) ||
288
- !value.alias.startsWith("codex-")) {
289
- throw new ProtocolFault("INVALID_REQUEST");
290
- }
291
- return { alias: value.alias };
292
- }
293
- case "select_claude":
294
- case "unselect_claude": {
295
- if (!hasExactKeys(value, ["alias"], ["codexThreadId"]) ||
296
- typeof value.alias !== "string" ||
297
- !isClaudeSessionSelector(value.alias) ||
298
- (value.codexThreadId !== undefined && !isUuid(value.codexThreadId))) {
299
- throw new ProtocolFault("INVALID_REQUEST");
300
- }
301
- return {
302
- alias: UUID_PATTERN.test(value.alias)
303
- ? value.alias.toLowerCase()
304
- : value.alias,
305
- ...(value.codexThreadId === undefined
306
- ? {}
307
- : { codexThreadId: value.codexThreadId.toLowerCase() }),
308
- };
309
- }
310
- case "pair":
311
- case "unpair": {
312
- if (Object.hasOwn(value, "aliases")) {
313
- if (!hasExactKeys(value, ["aliases"], ["threadAttestation"]) ||
314
- !Array.isArray(value.aliases) ||
315
- value.aliases.length !== 2 ||
316
- !isAlias(value.aliases[0]) ||
317
- !isAlias(value.aliases[1]) ||
318
- value.aliases[0] === value.aliases[1] ||
319
- value.aliases[0].slice(value.aliases[0].lastIndexOf("@") + 1) !==
320
- value.aliases[1].slice(value.aliases[1].lastIndexOf("@") + 1)) {
321
- throw new ProtocolFault("INVALID_REQUEST");
322
- }
323
- let threadAttestation;
324
- if (value.threadAttestation !== undefined) {
325
- if (!isRecord(value.threadAttestation) ||
326
- !hasExactKeys(value.threadAttestation, ["alias", "threadId"]) ||
327
- !isAlias(value.threadAttestation.alias) ||
328
- !value.aliases.includes(value.threadAttestation.alias) ||
329
- !isUuid(value.threadAttestation.threadId)) {
330
- throw new ProtocolFault("INVALID_REQUEST");
331
- }
332
- threadAttestation = {
333
- alias: value.threadAttestation.alias,
334
- threadId: value.threadAttestation.threadId.toLowerCase(),
335
- };
336
- }
337
- return {
338
- aliases: [value.aliases[0], value.aliases[1]],
339
- ...(threadAttestation === undefined ? {} : { threadAttestation }),
340
- };
341
- }
342
- if (!hasExactKeys(value, ["claudeAlias", "codexAlias"], ["codexThreadId"]) ||
343
- typeof value.claudeAlias !== "string" ||
344
- !isClaudeSessionSelector(value.claudeAlias) ||
345
- !isAlias(value.codexAlias) ||
346
- !value.codexAlias.startsWith("codex-") ||
347
- (value.codexThreadId !== undefined && !isUuid(value.codexThreadId)) ||
348
- value.claudeAlias === value.codexAlias ||
349
- (isAlias(value.claudeAlias) &&
350
- value.claudeAlias.slice(value.claudeAlias.lastIndexOf("@") + 1) !==
351
- value.codexAlias.slice(value.codexAlias.lastIndexOf("@") + 1))) {
352
- throw new ProtocolFault("INVALID_REQUEST");
353
- }
354
- return {
355
- claudeAlias: UUID_PATTERN.test(value.claudeAlias)
356
- ? value.claudeAlias.toLowerCase()
357
- : value.claudeAlias,
358
- codexAlias: value.codexAlias,
359
- ...(value.codexThreadId === undefined
360
- ? {}
361
- : { codexThreadId: value.codexThreadId.toLowerCase() }),
362
- };
363
- }
364
- case "send_to_claude": {
365
- if (!hasExactKeys(value, ["fromAlias", "threadId", "toAlias", "text"], ["expectsReply", "trackIdleMinutes"]) ||
366
- !isAlias(value.fromAlias) ||
367
- !isUuid(value.threadId) ||
368
- (typeof value.toAlias !== "string" ||
369
- !isClaudeSessionSelector(value.toAlias)) ||
370
- !isMessageText(value.text) ||
371
- (value.expectsReply !== undefined &&
372
- typeof value.expectsReply !== "boolean") ||
373
- (value.trackIdleMinutes !== undefined &&
374
- !isTrackIdleMinutes(value.trackIdleMinutes))) {
375
- throw new ProtocolFault("INVALID_REQUEST");
376
- }
377
- return {
378
- fromAlias: value.fromAlias,
379
- threadId: value.threadId.toLowerCase(),
380
- toAlias: UUID_PATTERN.test(value.toAlias)
381
- ? value.toAlias.toLowerCase()
382
- : value.toAlias,
383
- text: value.text,
384
- expectsReply: value.expectsReply ?? false,
385
- ...(value.trackIdleMinutes === undefined
386
- ? {}
387
- : { trackIdleMinutes: value.trackIdleMinutes }),
388
- };
389
- }
390
- case "send_to_codex": {
391
- if (!hasExactKeys(value, ["fromAlias", "toAlias", "text"], ["replyAddress", "expectsReply", "trackIdleMinutes"]) ||
392
- !isAlias(value.fromAlias) ||
393
- !isAlias(value.toAlias) ||
394
- !isMessageText(value.text) ||
395
- (value.replyAddress !== undefined &&
396
- (typeof value.replyAddress !== "string" ||
397
- !isGatewayReplyAddress(value.replyAddress))) ||
398
- (value.expectsReply !== undefined &&
399
- typeof value.expectsReply !== "boolean") ||
400
- (value.trackIdleMinutes !== undefined &&
401
- !isTrackIdleMinutes(value.trackIdleMinutes))) {
402
- throw new ProtocolFault("INVALID_REQUEST");
403
- }
404
- return {
405
- fromAlias: value.fromAlias,
406
- toAlias: value.toAlias,
407
- text: value.text,
408
- ...(value.replyAddress === undefined
409
- ? {}
410
- : { replyAddress: value.replyAddress }),
411
- expectsReply: value.expectsReply ?? false,
412
- ...(value.trackIdleMinutes === undefined
413
- ? {}
414
- : { trackIdleMinutes: value.trackIdleMinutes }),
415
- };
416
- }
417
- case "reply": {
418
- if (!hasExactKeys(value, ["conversationId", "text", "caller"], ["trackIdleMinutes"]) ||
419
- !isConversationId(value.conversationId) ||
420
- !isMessageText(value.text) ||
421
- (value.trackIdleMinutes !== undefined &&
422
- !isTrackIdleMinutes(value.trackIdleMinutes))) {
423
- throw new ProtocolFault("INVALID_REQUEST");
424
- }
425
- return {
426
- conversationId: value.conversationId,
427
- text: value.text,
428
- caller: normalizeReplyCaller(value.caller),
429
- ...(value.trackIdleMinutes === undefined
430
- ? {}
431
- : { trackIdleMinutes: value.trackIdleMinutes }),
432
- };
433
- }
434
- }
237
+ return { aliases: [value.aliases[0], value.aliases[1]],
238
+ ...(threadAttestation === undefined ? {} : { threadAttestation }) };
239
+ }
240
+ if (!exact(value, ["claudeAlias", "codexAlias"], ["codexThreadId"]) ||
241
+ typeof value.claudeAlias !== "string" || !isClaudeSessionSelector(value.claudeAlias) ||
242
+ !alias(value.codexAlias) || !value.codexAlias.startsWith("codex-") ||
243
+ (value.codexThreadId !== undefined && !uuid(value.codexThreadId)) ||
244
+ value.claudeAlias === value.codexAlias ||
245
+ (alias(value.claudeAlias) && value.claudeAlias.slice(value.claudeAlias.lastIndexOf("@") + 1) !==
246
+ value.codexAlias.slice(value.codexAlias.lastIndexOf("@") + 1)))
247
+ invalid();
248
+ return { claudeAlias: UUID_PATTERN.test(value.claudeAlias) ? value.claudeAlias.toLowerCase() : value.claudeAlias,
249
+ codexAlias: value.codexAlias,
250
+ ...(value.codexThreadId === undefined ? {} : { codexThreadId: value.codexThreadId.toLowerCase() }) };
251
+ }
252
+ function commonSend(value, required, optional) {
253
+ if (!isRecord(value) || !exact(value, required, optional) || !alias(value.fromAlias) ||
254
+ !alias(value.toAlias) || !messageText(value.text) ||
255
+ (value.expectsReply !== undefined && typeof value.expectsReply !== "boolean") ||
256
+ (value.trackIdleMinutes !== undefined && !trackMinutes(value.trackIdleMinutes)))
257
+ invalid();
258
+ return value;
435
259
  }
436
- function parseRequestObject(value) {
437
- if (!isRecord(value) ||
438
- !hasExactKeys(value, ["protocolVersion", "method", "params"])) {
439
- throw new ProtocolFault("INVALID_REQUEST");
440
- }
441
- if (value.protocolVersion !== GATEWAY_CONTROL_PROTOCOL_VERSION) {
442
- throw new ProtocolFault("UNSUPPORTED_VERSION");
443
- }
444
- if (typeof value.method !== "string" ||
445
- !gatewayControlMethods.includes(value.method)) {
446
- throw new ProtocolFault("UNKNOWN_METHOD");
447
- }
448
- const method = value.method;
449
- const params = normalizeParams(method, value.params);
450
- return {
451
- protocolVersion: GATEWAY_CONTROL_PROTOCOL_VERSION,
452
- method,
453
- params,
454
- };
260
+ function decodeSendClaude(value) {
261
+ if (!isRecord(value) || !exact(value, ["fromAlias", "threadId", "toAlias", "text"], ["expectsReply", "trackIdleMinutes"]) ||
262
+ !alias(value.fromAlias) || !uuid(value.threadId) || typeof value.toAlias !== "string" ||
263
+ !isClaudeSessionSelector(value.toAlias) || !messageText(value.text) ||
264
+ (value.expectsReply !== undefined && typeof value.expectsReply !== "boolean") ||
265
+ (value.trackIdleMinutes !== undefined && !trackMinutes(value.trackIdleMinutes)))
266
+ invalid();
267
+ return { fromAlias: value.fromAlias, threadId: value.threadId.toLowerCase(),
268
+ toAlias: UUID_PATTERN.test(value.toAlias) ? value.toAlias.toLowerCase() : value.toAlias,
269
+ text: value.text, expectsReply: value.expectsReply ?? false,
270
+ ...(value.trackIdleMinutes === undefined ? {} : { trackIdleMinutes: value.trackIdleMinutes }) };
271
+ }
272
+ function decodeSendCodex(value) {
273
+ const row = commonSend(value, ["fromAlias", "toAlias", "text"], ["replyAddress", "expectsReply", "trackIdleMinutes"]);
274
+ if (row.replyAddress !== undefined &&
275
+ (typeof row.replyAddress !== "string" || !isGatewayReplyAddress(row.replyAddress)))
276
+ invalid();
277
+ return { fromAlias: row.fromAlias, toAlias: row.toAlias, text: row.text,
278
+ ...(row.replyAddress === undefined ? {} : { replyAddress: row.replyAddress }),
279
+ expectsReply: row.expectsReply === true,
280
+ ...(row.trackIdleMinutes === undefined ? {} : { trackIdleMinutes: row.trackIdleMinutes }) };
455
281
  }
456
- function parseRequestFrame(frame) {
457
- let parsed;
458
- try {
459
- parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(frame));
460
- }
461
- catch {
462
- throw new ProtocolFault("INVALID_JSON");
463
- }
464
- return parseRequestObject(parsed);
282
+ function normalizeReplyCaller(value) {
283
+ if (!isRecord(value))
284
+ invalid();
285
+ if (value.kind === "codex" && shape(value, { kind: oneOf("codex"), alias, threadId: uuid }))
286
+ return { kind: "codex", alias: value.alias,
287
+ threadId: value.threadId.toLowerCase() };
288
+ if (value.kind === "claude" && isRecord(value) && exact(value, ["kind", "alias"], ["replyAddress"]) &&
289
+ alias(value.alias) && (value.replyAddress === undefined ||
290
+ (typeof value.replyAddress === "string" && isGatewayReplyAddress(value.replyAddress))))
291
+ return { kind: "claude", alias: value.alias,
292
+ ...(value.replyAddress === undefined ? {} : { replyAddress: value.replyAddress }) };
293
+ return invalid();
294
+ }
295
+ function decodeReply(value) {
296
+ if (!isRecord(value) || !exact(value, ["conversationId", "text", "caller"], ["trackIdleMinutes"]) ||
297
+ typeof value.conversationId !== "string" || !isGatewayConversationId(value.conversationId) ||
298
+ !messageText(value.text) ||
299
+ (value.trackIdleMinutes !== undefined && !trackMinutes(value.trackIdleMinutes)))
300
+ invalid();
301
+ return { conversationId: value.conversationId, text: value.text, caller: normalizeReplyCaller(value.caller),
302
+ ...(value.trackIdleMinutes === undefined ? {} : { trackIdleMinutes: value.trackIdleMinutes }) };
303
+ }
304
+ function decodeDeliveryStatus(value) {
305
+ if (!shape(value, { token: (item) => typeof item === "string" && DELIVERY_TOKEN_PATTERN.test(item) }))
306
+ invalid();
307
+ return { token: value.token };
308
+ }
309
+ function decodeUntrack(value) {
310
+ if (!shape(value, { conversationId: (item) => typeof item === "string" && CONVERSATION_ID_PATTERN.test(item) }))
311
+ invalid();
312
+ return { conversationId: value.conversationId };
465
313
  }
466
314
  function isDecision(value) {
467
- if (!isRecord(value) ||
468
- !hasExactKeys(value, ["accepted", "code"]) ||
469
- typeof value.accepted !== "boolean" ||
470
- typeof value.code !== "string") {
471
- return false;
472
- }
473
- if (value.accepted)
474
- return value.code === "ok";
475
- return (value.code === "not_found" ||
476
- value.code === "conflict" ||
477
- value.code === "watch_owner_conflict" ||
478
- value.code === "route_mismatch" ||
479
- value.code === "busy" ||
480
- value.code === "unavailable" ||
481
- value.code === "rejected");
315
+ return shape(value, { accepted: (item) => typeof item === "boolean", code: oneOf("ok", "not_found", "conflict", "watch_owner_conflict", "route_mismatch", "busy", "unavailable", "rejected") }) && (value.accepted === true ? value.code === "ok" : value.code !== "ok");
482
316
  }
483
317
  function isHealthResult(value) {
484
- return (isRecord(value) &&
485
- hasExactKeys(value, ["status", "revision"]) &&
486
- (value.status === "ok" || value.status === "degraded") &&
487
- isNonNegativeInteger(value.revision));
318
+ return shape(value, { status: oneOf("ok", "degraded"), revision: nonNegative });
488
319
  }
489
320
  function isSendResult(value) {
490
- if (!isRecord(value))
491
- return false;
492
- if (value.accepted === true) {
493
- return (hasExactKeys(value, [
494
- "accepted",
495
- "code",
496
- "conversationId",
497
- "deliveryToken",
498
- ]) &&
499
- value.code === "ok" &&
500
- isConversationId(value.conversationId) &&
501
- typeof value.deliveryToken === "string" &&
502
- isGatewayDeliveryToken(value.deliveryToken));
503
- }
504
- return isDecision(value);
321
+ if (!isRecord(value) || value.accepted !== true)
322
+ return isDecision(value);
323
+ return shape(value, { accepted: oneOf(true), code: oneOf("ok"),
324
+ conversationId: (item) => typeof item === "string" && CONVERSATION_ID_PATTERN.test(item),
325
+ deliveryToken: (item) => typeof item === "string" && DELIVERY_TOKEN_PATTERN.test(item) });
505
326
  }
506
327
  function isRefreshResult(value) {
507
- if (!isRecord(value) ||
508
- !hasExactKeys(value, ["accepted", "code", "revision"]) ||
509
- !isNonNegativeInteger(value.revision)) {
510
- return false;
511
- }
512
- const decision = { accepted: value.accepted, code: value.code };
513
- return isDecision(decision);
514
- }
515
- function isConnectorHealth(value) {
516
- return (value === "offline" ||
517
- value === "connecting" ||
518
- value === "healthy" ||
519
- value === "degraded");
520
- }
521
- function isSafeCode(value) {
522
- return typeof value === "string" && SAFE_CODE_PATTERN.test(value);
523
- }
524
- const TERMINAL_DELIVERY_STATUS_STATES = new Set([
525
- "delivered",
526
- "unconfirmed",
527
- "expired",
528
- "failed",
529
- "ambiguous",
530
- "cancelled",
531
- ]);
532
- function isDeliveryStatusState(value) {
533
- return (value === "queued" ||
534
- value === "stalled" ||
535
- value === "delivered" ||
536
- value === "unconfirmed" ||
537
- value === "expired" ||
538
- value === "failed" ||
539
- value === "ambiguous" ||
540
- value === "cancelled");
328
+ return shape(value, { accepted: (item) => typeof item === "boolean", code: oneOf("ok", "not_found", "conflict", "watch_owner_conflict", "route_mismatch", "busy", "unavailable", "rejected"), revision: nonNegative }) && isDecision({ accepted: value.accepted, code: value.code });
541
329
  }
330
+ const TERMINAL = new Set(["delivered", "unconfirmed", "expired", "failed", "ambiguous", "cancelled"]);
542
331
  function isDeliveryStatusResult(value) {
543
332
  if (!isRecord(value) || typeof value.found !== "boolean")
544
333
  return false;
545
334
  if (!value.found)
546
- return hasExactKeys(value, ["found"]);
547
- if (!hasExactKeys(value, ["found", "state", "terminal", "updatedAt", "deadlineAt"], ["pendingForMs", "safeErrorCode"]) ||
548
- !isDeliveryStatusState(value.state) ||
549
- typeof value.terminal !== "boolean" ||
550
- value.terminal !== TERMINAL_DELIVERY_STATUS_STATES.has(value.state) ||
551
- !isIsoTimestamp(value.updatedAt) ||
552
- !isIsoTimestamp(value.deadlineAt) ||
553
- (value.pendingForMs !== undefined &&
554
- !isNonNegativeInteger(value.pendingForMs)) ||
555
- (value.safeErrorCode !== undefined && !isSafeCode(value.safeErrorCode))) {
556
- return false;
557
- }
558
- return true;
335
+ return exact(value, ["found"]);
336
+ return shape(value, { found: oneOf(true), state: oneOf("queued", "stalled", "delivered", "unconfirmed", "expired", "failed", "ambiguous", "cancelled"), terminal: (item) => typeof item === "boolean", updatedAt: iso, deadlineAt: iso }, { pendingForMs: nonNegative, safeErrorCode: safeCode }) && value.terminal === TERMINAL.has(value.state);
559
337
  }
560
338
  function isRouteCounters(value) {
561
- if (!isRecord(value) ||
562
- !hasExactKeys(value, [
563
- "accepted",
564
- "delivered",
565
- "unconfirmed",
566
- "failed",
567
- "ambiguous",
568
- "expired",
569
- "cancelled",
570
- "abandoned",
571
- "rejected",
572
- "bytesAccepted",
573
- ])) {
574
- return false;
575
- }
576
- return Object.values(value).every(isNonNegativeInteger);
339
+ const keys = ["accepted", "delivered", "unconfirmed", "failed", "ambiguous", "expired",
340
+ "cancelled", "abandoned", "rejected", "bytesAccepted"];
341
+ return isRecord(value) && exact(value, keys) && keys.every((key) => nonNegative(value[key]));
577
342
  }
343
+ const isConnectorHealth = oneOf("offline", "connecting", "healthy", "degraded");
578
344
  function isConnectorSnapshot(value) {
579
- return (isRecord(value) &&
580
- hasExactKeys(value, [
581
- "provider",
582
- "host",
583
- "health",
584
- "protocol",
585
- "protocolVersion",
586
- ], ["lastSeenAt", "safeErrorCode", "registry"]) &&
587
- isGatewayProvider(value.provider) &&
588
- isHostId(value.host) &&
589
- isConnectorHealth(value.health) &&
590
- typeof value.protocol === "string" &&
591
- PROTOCOL_PATTERN.test(value.protocol) &&
592
- typeof value.protocolVersion === "string" &&
593
- PROTOCOL_VERSION_PATTERN.test(value.protocolVersion) &&
594
- (value.lastSeenAt === undefined || isIsoTimestamp(value.lastSeenAt)) &&
595
- (value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)) &&
596
- (value.registry === undefined ||
597
- (value.provider === "claude" &&
598
- isPublicRegistryObservationSnapshot(value.registry))));
345
+ if (!shape(value, { provider: isGatewayProvider, host, health: isConnectorHealth,
346
+ protocol: (item) => typeof item === "string" && PROTOCOL_PATTERN.test(item),
347
+ protocolVersion: (item) => typeof item === "string" && PROTOCOL_VERSION_PATTERN.test(item) }, { lastSeenAt: iso, observationAgeMs: nonNegative, safeErrorCode: safeCode,
348
+ registry: isPublicRegistryObservationSnapshot, codexDoctor: (item) => shape(item, {
349
+ conditions: (rows) => Array.isArray(rows) && rows.length > 0 && rows.length <= 2 &&
350
+ rows.every(oneOf("split_brain", "orphaned", "attached", "observation_stale", "managed_layout_missing", "unknown")),
351
+ }) }))
352
+ return false;
353
+ return (value.codexDoctor === undefined || value.provider === "codex") &&
354
+ (value.registry === undefined || value.provider === "claude");
599
355
  }
600
356
  function isRouteSnapshot(value) {
601
- return (isRecord(value) &&
602
- hasExactKeys(value, [
603
- "alias",
604
- "provider",
605
- "host",
606
- "enabled",
607
- "state",
608
- "busyPolicy",
609
- "queueDepth",
610
- "counters",
611
- ], ["lastSeenAt", "oldestQueuedAt", "safeErrorCode"]) &&
612
- isAlias(value.alias) &&
613
- isGatewayProvider(value.provider) &&
614
- isHostId(value.host) &&
615
- value.alias.endsWith(`@${value.host}`) &&
616
- typeof value.enabled === "boolean" &&
617
- (value.state === "stale" ||
618
- value.state === "idle" ||
619
- value.state === "busy" ||
620
- value.state === "awaiting_approval" ||
621
- value.state === "offline" ||
622
- value.state === "disabled") &&
623
- value.busyPolicy === "queue" &&
624
- (value.lastSeenAt === undefined || isIsoTimestamp(value.lastSeenAt)) &&
625
- isNonNegativeInteger(value.queueDepth) &&
626
- (value.oldestQueuedAt === undefined
627
- ? value.queueDepth === 0
628
- : value.queueDepth > 0 && isIsoTimestamp(value.oldestQueuedAt)) &&
629
- isRouteCounters(value.counters) &&
630
- (value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)));
631
- }
632
- function isConsentEndpoint(value) {
633
- return (isRecord(value) &&
634
- hasExactKeys(value, ["alias", "provider"]) &&
635
- isAlias(value.alias) &&
636
- isGatewayProvider(value.provider));
357
+ if (!shape(value, { alias, provider: isGatewayProvider, host, enabled: (item) => typeof item === "boolean",
358
+ state: oneOf("stale", "idle", "busy", "awaiting_approval", "offline", "disabled"),
359
+ busyPolicy: oneOf("queue"), queueDepth: nonNegative, counters: isRouteCounters }, { lastSeenAt: iso, oldestQueuedAt: iso, safeErrorCode: safeCode }))
360
+ return false;
361
+ const row = value;
362
+ return row.alias.endsWith(`@${row.host}`) &&
363
+ (row.oldestQueuedAt === undefined ? row.queueDepth === 0 : row.queueDepth > 0);
637
364
  }
638
365
  function compareConsentEndpoints(left, right) {
639
- const providerDelta = gatewayProviders.indexOf(left.provider) -
640
- gatewayProviders.indexOf(right.provider);
641
- return providerDelta || left.alias.localeCompare(right.alias);
366
+ return gatewayProviders.indexOf(left.provider) -
367
+ gatewayProviders.indexOf(right.provider) || left.alias.localeCompare(right.alias);
642
368
  }
643
369
  function isConsentEdgeSnapshot(value) {
644
- if (!isRecord(value) ||
645
- !hasExactKeys(value, ["endpoints", "host", "counters"]) ||
646
- !Array.isArray(value.endpoints) ||
647
- value.endpoints.length !== 2 ||
648
- !isConsentEndpoint(value.endpoints[0]) ||
649
- !isConsentEndpoint(value.endpoints[1])) {
370
+ const endpoint = (item) => shape(item, { alias, provider: isGatewayProvider });
371
+ if (!shape(value, { endpoints: (rows) => Array.isArray(rows) && rows.length === 2 && rows.every(endpoint),
372
+ host, counters: isRouteCounters }))
650
373
  return false;
651
- }
652
374
  const [left, right] = value.endpoints;
653
- return (left.provider !== right.provider &&
654
- left.alias !== right.alias &&
655
- compareConsentEndpoints(left, right) < 0 &&
656
- isHostId(value.host) &&
657
- left.alias.endsWith(`@${value.host}`) &&
658
- right.alias.endsWith(`@${value.host}`) &&
659
- isRouteCounters(value.counters));
375
+ return left.provider !== right.provider && left.alias !== right.alias && compareConsentEndpoints(left, right) < 0 &&
376
+ left.alias.endsWith(`@${value.host}`) && right.alias.endsWith(`@${value.host}`);
660
377
  }
661
378
  function isAvailablePeerSnapshot(value) {
662
- return (isRecord(value) &&
663
- hasExactKeys(value, [
664
- "alias",
665
- "provider",
666
- "host",
667
- "state",
668
- "validated",
669
- "selected",
670
- ], ["lastSeenAt", "safeErrorCode"]) &&
671
- isAlias(value.alias) &&
672
- value.provider === "claude" &&
673
- isHostId(value.host) &&
674
- value.alias.endsWith(`@${value.host}`) &&
675
- (value.state === "idle" ||
676
- value.state === "busy" ||
677
- value.state === "awaiting_approval" ||
678
- value.state === "offline") &&
679
- typeof value.validated === "boolean" &&
680
- typeof value.selected === "boolean" &&
681
- (value.lastSeenAt === undefined || isIsoTimestamp(value.lastSeenAt)) &&
682
- (value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)));
379
+ return shape(value, { alias, provider: oneOf("claude"), host,
380
+ state: oneOf("idle", "busy", "awaiting_approval", "offline"),
381
+ validated: (item) => typeof item === "boolean", selected: (item) => typeof item === "boolean" }, { lastSeenAt: iso, safeErrorCode: safeCode }) &&
382
+ value.alias.endsWith(`@${String(value.host)}`);
683
383
  }
684
384
  function isNormalizedMessageEvent(value) {
685
- return (isRecord(value) &&
686
- hasExactKeys(value, [
687
- "sequence",
688
- "timestamp",
689
- "messageIdSuffix",
690
- "direction",
691
- "sourceAlias",
692
- "targetAlias",
693
- "state",
694
- "bytes",
695
- ], ["conversationIdSuffix", "body", "latencyMs", "safeErrorCode", "steer"]) &&
696
- isNonNegativeInteger(value.sequence) &&
697
- isIsoTimestamp(value.timestamp) &&
698
- typeof value.messageIdSuffix === "string" &&
699
- MESSAGE_SUFFIX_PATTERN.test(value.messageIdSuffix) &&
700
- (value.conversationIdSuffix === undefined ||
701
- (typeof value.conversationIdSuffix === "string" &&
702
- CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix))) &&
703
- isMessageDirection(value.direction) &&
704
- isAlias(value.sourceAlias) &&
705
- isAlias(value.targetAlias) &&
706
- (value.state === "queued" ||
707
- value.state === "duplicate" ||
708
- value.state === "dispatching" ||
709
- value.state === "transport_written" ||
710
- value.state === "held" ||
711
- value.state === "delivered" ||
712
- value.state === "unconfirmed" ||
713
- value.state === "failed" ||
714
- value.state === "ambiguous" ||
715
- value.state === "expired" ||
716
- value.state === "cancelled" ||
717
- value.state === "abandoned" ||
718
- value.state === "rejected") &&
719
- isNonNegativeInteger(value.bytes) &&
720
- (value.body === undefined ||
721
- (typeof value.body === "string" &&
722
- value.body.length > 0 &&
723
- !value.body.includes("\u0000") &&
724
- Buffer.byteLength(value.body, "utf8") === value.bytes)) &&
725
- (value.latencyMs === undefined || isNonNegativeInteger(value.latencyMs)) &&
726
- (value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)) &&
727
- (value.steer === undefined || value.steer === true));
385
+ if (!shape(value, { sequence: nonNegative, timestamp: iso,
386
+ messageIdSuffix: (item) => typeof item === "string" && MESSAGE_SUFFIX_PATTERN.test(item),
387
+ direction: isMessageDirection, sourceAlias: alias, targetAlias: alias,
388
+ state: oneOf("queued", "duplicate", "dispatching", "transport_written", "held", "delivered", "unconfirmed", "failed", "ambiguous", "expired", "cancelled", "abandoned", "rejected"),
389
+ bytes: nonNegative }, { conversationIdSuffix: (item) => typeof item === "string" && CONVERSATION_SUFFIX_PATTERN.test(item),
390
+ body: (item) => typeof item === "string" && item.length > 0 && !item.includes("\0"),
391
+ latencyMs: nonNegative, safeErrorCode: safeCode, steer: oneOf(true) }))
392
+ return false;
393
+ return value.body === undefined || Buffer.byteLength(value.body, "utf8") === value.bytes;
728
394
  }
729
395
  function isAccounting(value) {
730
- if (!isRecord(value) ||
731
- !hasExactKeys(value, [
732
- "accepted",
733
- "duplicates",
734
- "delivered",
735
- "unconfirmed",
736
- "failed",
737
- "ambiguous",
738
- "expired",
739
- "cancelled",
740
- "abandoned",
741
- "rejected",
742
- "bytesAccepted",
743
- "queuedBytes",
744
- ])) {
745
- return false;
746
- }
747
- return Object.values(value).every(isNonNegativeInteger);
396
+ const keys = ["accepted", "duplicates", "delivered", "unconfirmed", "failed", "ambiguous",
397
+ "expired", "cancelled", "abandoned", "rejected", "bytesAccepted", "queuedBytes"];
398
+ return isRecord(value) && exact(value, keys) && keys.every((key) => nonNegative(value[key]));
748
399
  }
749
400
  function isSafeAlert(value) {
750
- return (isRecord(value) &&
751
- hasExactKeys(value, ["code", "severity", "timestamp"], [
752
- "provider",
753
- "host",
754
- "alias",
755
- ]) &&
756
- isSafeCode(value.code) &&
757
- (value.severity === "info" ||
758
- value.severity === "warning" ||
759
- value.severity === "error") &&
760
- isIsoTimestamp(value.timestamp) &&
761
- (value.provider === undefined || isGatewayProvider(value.provider)) &&
762
- (value.host === undefined || isHostId(value.host)) &&
763
- (value.alias === undefined || isAlias(value.alias)));
401
+ return shape(value, { code: safeCode, severity: oneOf("info", "warning", "error"), timestamp: iso }, { provider: isGatewayProvider, host, alias });
764
402
  }
765
403
  function isProgressWatchSnapshot(value) {
766
- return (isRecord(value) &&
767
- hasExactKeys(value, [
768
- "conversationIdSuffix",
769
- "ownerAlias",
770
- "workerAlias",
771
- "lastActivityAt",
772
- "nextActionAt",
773
- "nudgeCount",
774
- ]) &&
775
- typeof value.conversationIdSuffix === "string" &&
776
- CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix) &&
777
- isAlias(value.ownerAlias) &&
778
- isAlias(value.workerAlias) &&
779
- value.ownerAlias !== value.workerAlias &&
780
- isIsoTimestamp(value.lastActivityAt) &&
781
- isIsoTimestamp(value.nextActionAt) &&
782
- (value.nudgeCount === 0 || value.nudgeCount === 1 || value.nudgeCount === 2));
404
+ return shape(value, {
405
+ conversationIdSuffix: (item) => typeof item === "string" && CONVERSATION_SUFFIX_PATTERN.test(item),
406
+ ownerAlias: alias, workerAlias: alias, lastActivityAt: iso, nextActionAt: iso,
407
+ nudgeCount: oneOf(0, 1, 2),
408
+ }) && value.ownerAlias !== value.workerAlias;
783
409
  }
784
410
  function isProgressWatchEventSnapshot(value) {
785
- return (isRecord(value) &&
786
- hasExactKeys(value, [
787
- "sequence",
788
- "timestamp",
789
- "conversationIdSuffix",
790
- "ownerAlias",
791
- "workerAlias",
792
- "kind",
793
- "actor",
794
- ], ["reason"]) &&
795
- isNonNegativeInteger(value.sequence) &&
796
- isIsoTimestamp(value.timestamp) &&
797
- typeof value.conversationIdSuffix === "string" &&
798
- CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix) &&
799
- isAlias(value.ownerAlias) &&
800
- isAlias(value.workerAlias) &&
801
- ((value.kind === "opened" &&
802
- value.actor === "owner" &&
803
- value.reason === undefined) ||
804
- (value.kind === "replaced" &&
805
- (value.actor === "owner" || value.actor === "unknown") &&
806
- value.reason === undefined) ||
807
- (value.kind === "settled" &&
808
- ((value.reason === "done" &&
809
- (value.actor === "owner" || value.actor === "worker")) ||
810
- (value.reason === "untracked" && value.actor === "operator") ||
811
- ((value.reason === "idle_timeout" ||
812
- value.reason === "tracking_disabled") &&
813
- value.actor === "gateway") ||
814
- (value.reason === "endpoint_retired" &&
815
- (value.actor === "gateway" || value.actor === "operator")) ||
816
- (value.reason === "pair_removed" &&
817
- value.actor === "operator")))));
411
+ if (!shape(value, { sequence: nonNegative, timestamp: iso,
412
+ conversationIdSuffix: (item) => typeof item === "string" && CONVERSATION_SUFFIX_PATTERN.test(item),
413
+ ownerAlias: alias, workerAlias: alias, kind: oneOf("opened", "replaced", "settled"),
414
+ actor: oneOf("owner", "worker", "operator", "gateway", "unknown") }, { reason: oneOf("done", "untracked", "idle_timeout", "tracking_disabled", "endpoint_retired", "pair_removed") }))
415
+ return false;
416
+ if (value.kind === "opened")
417
+ return value.actor === "owner" && value.reason === undefined;
418
+ if (value.kind === "replaced")
419
+ return (value.actor === "owner" || value.actor === "unknown") && value.reason === undefined;
420
+ return (value.reason === "done" && (value.actor === "owner" || value.actor === "worker")) ||
421
+ (value.reason === "untracked" && value.actor === "operator") ||
422
+ ((value.reason === "idle_timeout" || value.reason === "tracking_disabled") && value.actor === "gateway") ||
423
+ (value.reason === "endpoint_retired" && (value.actor === "gateway" || value.actor === "operator")) ||
424
+ (value.reason === "pair_removed" && value.actor === "operator");
818
425
  }
819
426
  function isGatewayActivityEvent(value) {
820
- return (isRecord(value) &&
821
- hasExactKeys(value, [
822
- "sequence",
823
- "timestamp",
824
- "kind",
825
- "action",
826
- "outcome",
827
- "aliases",
828
- "operatorAction",
829
- ], ["safeErrorCode"]) &&
830
- isNonNegativeInteger(value.sequence) &&
831
- value.sequence > 0 &&
832
- isIsoTimestamp(value.timestamp) &&
833
- isGatewayActivityIdentity(value.kind, value.action, value.operatorAction) &&
834
- (value.outcome === "accepted" || value.outcome === "rejected") &&
835
- Array.isArray(value.aliases) &&
836
- value.aliases.length <= 2 &&
837
- value.aliases.every(isAlias) &&
838
- new Set(value.aliases).size === value.aliases.length &&
839
- (value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)));
840
- }
841
- function isGatewayActivityIdentity(kind, action, operatorAction) {
842
- if (typeof operatorAction !== "boolean")
427
+ if (!shape(value, { sequence: positive, timestamp: iso,
428
+ kind: oneOf("discovery", "selection", "registration", "pairing", "watch"),
429
+ action: oneOf("discovery_refreshed", "claude_selected", "claude_unselected", "codex_registered", "codex_succeeded", "codex_unregistered", "routes_paired", "routes_unpaired", "watch_ended"),
430
+ outcome: oneOf("accepted", "rejected"), aliases: (rows) => arrayOf(rows, 2, alias),
431
+ operatorAction: oneOf(true) }, { safeErrorCode: safeCode }))
843
432
  return false;
844
- switch (kind) {
845
- case "discovery":
846
- return action === "discovery_refreshed" && operatorAction;
847
- case "selection":
848
- return ((action === "claude_selected" || action === "claude_unselected") &&
849
- operatorAction);
850
- case "registration":
851
- return ((action === "codex_registered" ||
852
- action === "codex_succeeded" ||
853
- action === "codex_unregistered") &&
854
- operatorAction);
855
- case "pairing":
856
- return ((action === "routes_paired" || action === "routes_unpaired") &&
857
- operatorAction);
858
- case "watch":
859
- return action === "watch_ended" && operatorAction;
860
- case "endpoint":
861
- return action === "endpoint_refreshed" && !operatorAction;
862
- case "recovery":
863
- return action === "codex_orphan_removed" && operatorAction;
864
- default:
865
- return false;
866
- }
433
+ const allowed = {
434
+ discovery: ["discovery_refreshed"], selection: ["claude_selected", "claude_unselected"],
435
+ registration: ["codex_registered", "codex_succeeded", "codex_unregistered"],
436
+ pairing: ["routes_paired", "routes_unpaired"], watch: ["watch_ended"],
437
+ };
438
+ return new Set(value.aliases).size === value.aliases.length &&
439
+ (allowed[value.kind]?.includes(value.action) ?? false);
867
440
  }
868
441
  function isDeadlinePressure(value) {
869
- const names = [
870
- "under_1m",
871
- "1m_to_5m",
872
- "5m_to_15m",
873
- "15m_to_60m",
874
- "over_60m",
875
- ];
876
- return (isRecord(value) &&
877
- hasExactKeys(value, ["configuredDeadlineMs", "terminalEvents", "expiredEvents", "buckets"], ["retainedSince"]) &&
878
- isNonNegativeInteger(value.configuredDeadlineMs) &&
879
- value.configuredDeadlineMs > 0 &&
880
- (value.retainedSince === undefined || isIsoTimestamp(value.retainedSince)) &&
881
- isNonNegativeInteger(value.terminalEvents) &&
882
- isNonNegativeInteger(value.expiredEvents) &&
883
- value.expiredEvents <= value.terminalEvents &&
884
- Array.isArray(value.buckets) &&
885
- value.buckets.length === names.length &&
886
- value.buckets.every((bucket, index) => isRecord(bucket) &&
887
- hasExactKeys(bucket, ["bucket", "settled", "expired"]) &&
888
- bucket.bucket === names[index] &&
889
- isNonNegativeInteger(bucket.settled) &&
890
- isNonNegativeInteger(bucket.expired) &&
891
- bucket.expired <= bucket.settled) &&
892
- value.buckets.reduce((sum, bucket) => sum + Number(bucket.settled), 0) ===
893
- value.terminalEvents &&
894
- value.buckets.reduce((sum, bucket) => sum + Number(bucket.expired), 0) ===
895
- value.expiredEvents);
442
+ const names = ["under_1m", "1m_to_5m", "5m_to_15m", "15m_to_60m", "over_60m"];
443
+ if (!shape(value, { configuredDeadlineMs: positive, terminalEvents: nonNegative,
444
+ expiredEvents: nonNegative, buckets: (rows) => Array.isArray(rows) && rows.length === names.length }, { retainedSince: iso }) || Number(value.expiredEvents) > Number(value.terminalEvents))
445
+ return false;
446
+ const rows = value.buckets;
447
+ if (!rows.every((row, index) => shape(row, { bucket: oneOf(names[index]), settled: nonNegative, expired: nonNegative }) &&
448
+ row.expired <= row.settled))
449
+ return false;
450
+ return rows.reduce((sum, row) => sum + Number(row.settled), 0) === value.terminalEvents &&
451
+ rows.reduce((sum, row) => sum + Number(row.expired), 0) === value.expiredEvents;
896
452
  }
897
453
  function isSnapshotTruncation(value) {
898
- return (isRecord(value) &&
899
- hasExactKeys(value, [
900
- "connectors",
901
- "availablePeers",
902
- "routes",
903
- "consentEdges",
904
- "messages",
905
- "alerts",
906
- ], [
907
- "progressWatches",
908
- "progressWatchEvents",
909
- "activityEvents",
910
- ]) &&
911
- isNonNegativeInteger(value.connectors) &&
912
- isNonNegativeInteger(value.availablePeers) &&
913
- isNonNegativeInteger(value.routes) &&
914
- isNonNegativeInteger(value.consentEdges) &&
915
- isNonNegativeInteger(value.messages) &&
916
- isNonNegativeInteger(value.alerts) &&
917
- (value.progressWatches === undefined ||
918
- isNonNegativeInteger(value.progressWatches)) &&
919
- (value.progressWatchEvents === undefined ||
920
- isNonNegativeInteger(value.progressWatchEvents)) &&
921
- (value.activityEvents === undefined ||
922
- isNonNegativeInteger(value.activityEvents)));
454
+ const required = ["connectors", "availablePeers", "routes", "consentEdges", "messages", "alerts"];
455
+ const optional = ["progressWatches", "progressWatchEvents", "activityEvents"];
456
+ return isRecord(value) && exact(value, required, optional) &&
457
+ [...required, ...optional].every((key) => value[key] === undefined || nonNegative(value[key]));
923
458
  }
924
459
  export function isGatewaySnapshot(value) {
925
- if (!isRecord(value) ||
926
- !hasExactKeys(value, [
927
- "schemaVersion",
928
- "generatedAt",
929
- "inboundMode",
930
- "health",
931
- "connectors",
932
- "availablePeers",
933
- "routes",
934
- "consentEdges",
935
- "messages",
936
- "accounting",
937
- "alerts",
938
- "truncation",
939
- ], [
940
- "progressWatches",
941
- "progressWatchEvents",
942
- "activityEvents",
943
- "deadlinePressure",
944
- ]) ||
945
- value.schemaVersion !== 2 ||
946
- !isIsoTimestamp(value.generatedAt) ||
947
- (value.inboundMode !== "paired" && value.inboundMode !== "open") ||
948
- !isConnectorHealth(value.health) ||
949
- !Array.isArray(value.connectors) ||
950
- value.connectors.length > gatewayPublicSnapshotLimits.connectors ||
951
- !value.connectors.every(isConnectorSnapshot) ||
952
- !Array.isArray(value.availablePeers) ||
953
- value.availablePeers.length > gatewayPublicSnapshotLimits.availablePeers ||
954
- !value.availablePeers.every(isAvailablePeerSnapshot) ||
955
- !Array.isArray(value.routes) ||
956
- value.routes.length > gatewayPublicSnapshotLimits.routes ||
957
- !value.routes.every(isRouteSnapshot) ||
958
- !Array.isArray(value.consentEdges) ||
959
- value.consentEdges.length > gatewayPublicSnapshotLimits.consentEdges ||
960
- !value.consentEdges.every(isConsentEdgeSnapshot) ||
961
- (value.progressWatches !== undefined &&
962
- (!Array.isArray(value.progressWatches) ||
963
- value.progressWatches.length >
964
- gatewayPublicSnapshotLimits.progressWatches ||
965
- !value.progressWatches.every(isProgressWatchSnapshot))) ||
966
- (value.progressWatchEvents !== undefined &&
967
- (!Array.isArray(value.progressWatchEvents) ||
968
- value.progressWatchEvents.length >
969
- gatewayPublicSnapshotLimits.progressWatchEvents ||
970
- !value.progressWatchEvents.every(isProgressWatchEventSnapshot))) ||
971
- (value.activityEvents !== undefined &&
972
- (!Array.isArray(value.activityEvents) ||
973
- value.activityEvents.length >
974
- gatewayPublicSnapshotLimits.activityEvents ||
975
- !value.activityEvents.every(isGatewayActivityEvent))) ||
976
- (value.deadlinePressure !== undefined &&
977
- !isDeadlinePressure(value.deadlinePressure)) ||
978
- !Array.isArray(value.messages) ||
979
- value.messages.length > gatewayPublicSnapshotLimits.messages ||
980
- !value.messages.every(isNormalizedMessageEvent) ||
981
- !isAccounting(value.accounting) ||
982
- !Array.isArray(value.alerts) ||
983
- value.alerts.length > gatewayPublicSnapshotLimits.alerts ||
984
- !value.alerts.every(isSafeAlert) ||
985
- !isSnapshotTruncation(value.truncation)) {
460
+ if (!shape(value, { schemaVersion: oneOf(2), generatedAt: iso, inboundMode: oneOf("paired", "open"),
461
+ health: isConnectorHealth,
462
+ connectors: (rows) => arrayOf(rows, gatewayPublicSnapshotLimits.connectors, isConnectorSnapshot),
463
+ availablePeers: (rows) => arrayOf(rows, gatewayPublicSnapshotLimits.availablePeers, isAvailablePeerSnapshot),
464
+ routes: (rows) => arrayOf(rows, gatewayPublicSnapshotLimits.routes, isRouteSnapshot),
465
+ consentEdges: (rows) => arrayOf(rows, gatewayPublicSnapshotLimits.consentEdges, isConsentEdgeSnapshot),
466
+ messages: (rows) => arrayOf(rows, gatewayPublicSnapshotLimits.messages, isNormalizedMessageEvent),
467
+ accounting: isAccounting, alerts: (rows) => arrayOf(rows, gatewayPublicSnapshotLimits.alerts, isSafeAlert),
468
+ truncation: isSnapshotTruncation }, { progressWatches: (rows) => arrayOf(rows, gatewayPublicSnapshotLimits.progressWatches, isProgressWatchSnapshot),
469
+ progressWatchEvents: (rows) => arrayOf(rows, gatewayPublicSnapshotLimits.progressWatchEvents, isProgressWatchEventSnapshot),
470
+ activityEvents: (rows) => arrayOf(rows, gatewayPublicSnapshotLimits.activityEvents, isGatewayActivityEvent),
471
+ deadlinePressure: isDeadlinePressure }))
986
472
  return false;
987
- }
988
- const connectorKeys = value.connectors.map((connector) => `${connector.provider}@${connector.host}`);
989
- const aliases = value.routes.map((route) => route.alias);
990
- const peerAliases = value.availablePeers.map((peer) => peer.alias);
991
- const consentEdgeKeys = value.consentEdges.map((edge) => `${edge.endpoints[0].alias}\0${edge.endpoints[1].alias}`);
992
- const routeByAlias = new Map(value.routes.map((route) => [route.alias, route]));
993
- return (new Set(connectorKeys).size === connectorKeys.length &&
994
- new Set(aliases).size === aliases.length &&
995
- new Set(peerAliases).size === peerAliases.length &&
996
- new Set(consentEdgeKeys).size === consentEdgeKeys.length &&
997
- value.consentEdges.every((edge) => routeByAlias.get(edge.endpoints[0].alias)?.provider ===
998
- edge.endpoints[0].provider &&
999
- routeByAlias.get(edge.endpoints[1].alias)?.provider ===
1000
- edge.endpoints[1].provider) &&
1001
- Buffer.byteLength(JSON.stringify(value), "utf8") <=
1002
- GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET);
473
+ const snapshot = value;
474
+ const unique = (rows) => new Set(rows).size === rows.length;
475
+ const generated = Date.parse(snapshot.generatedAt);
476
+ const connectorKeys = snapshot.connectors.map((row) => `${row.provider}@${row.host}`);
477
+ const aliases = snapshot.routes.map((row) => row.alias);
478
+ const peers = snapshot.availablePeers.map((row) => row.alias);
479
+ const edgeKeys = snapshot.consentEdges.map((row) => `${row.endpoints[0].alias}\0${row.endpoints[1].alias}`);
480
+ const routeByAlias = new Map(snapshot.routes.map((row) => [row.alias, row]));
481
+ const honest = snapshot.connectors.every((row) => {
482
+ const seen = row.lastSeenAt === undefined ? undefined : Date.parse(row.lastSeenAt);
483
+ const age = seen === undefined ? undefined : Math.min(MAX_REVISION, Math.max(0, generated - seen));
484
+ return (row.observationAgeMs === undefined || row.observationAgeMs === age) &&
485
+ (row.health !== "healthy" || (age !== undefined && age <= CONNECTOR_OBSERVATION_STALE_AFTER_MS));
486
+ });
487
+ return honest && unique(connectorKeys) && unique(aliases) && unique(peers) && unique(edgeKeys) &&
488
+ snapshot.consentEdges.every((edge) => edge.endpoints.every((endpoint) => routeByAlias.get(endpoint.alias)?.provider === endpoint.provider)) &&
489
+ Buffer.byteLength(JSON.stringify(snapshot), "utf8") <= GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET;
1003
490
  }
1004
491
  function isSnapshotObservation(value) {
1005
- return (isRecord(value) &&
1006
- hasExactKeys(value, ["snapshotRevision", "snapshot"]) &&
1007
- isNonNegativeInteger(value.snapshotRevision) &&
1008
- isGatewaySnapshot(value.snapshot));
1009
- }
1010
- function isResultForMethod(method, value) {
1011
- switch (method) {
1012
- case "health":
1013
- return isHealthResult(value);
1014
- case "register_codex":
1015
- case "unregister_codex":
1016
- case "remove_stale_codex_registration":
1017
- case "select_claude":
1018
- case "unselect_claude":
1019
- case "pair":
1020
- case "unpair":
1021
- return isDecision(value);
1022
- case "list_snapshot":
1023
- return isGatewaySnapshot(value);
1024
- case "observe_snapshot":
1025
- return isSnapshotObservation(value);
1026
- case "delivery_status":
1027
- return isDeliveryStatusResult(value);
1028
- case "untrack":
1029
- return isDecision(value);
1030
- case "send_to_claude":
1031
- case "send_to_codex":
1032
- case "reply":
1033
- return isSendResult(value);
1034
- case "refresh_dashboard":
1035
- return isRefreshResult(value);
492
+ return shape(value, { snapshotRevision: nonNegative, snapshot: isGatewaySnapshot });
493
+ }
494
+ const descriptors = {
495
+ health: { handler: "health", decode: emptyParams, result: isHealthResult, mutation: false }, register_codex: { handler: "registerCodex", decode: decodeRegister, result: isDecision, mutation: true },
496
+ unregister_codex: { handler: "unregisterCodex", decode: decodeUnregister, result: isDecision, mutation: true }, remove_codex_registration: { handler: "removeCodexRegistration", decode: decodeRemoval, result: isDecision, mutation: true },
497
+ select_claude: { handler: "selectClaude", decode: decodeSelection, result: isDecision, mutation: true }, unselect_claude: { handler: "unselectClaude", decode: decodeSelection, result: isDecision, mutation: true },
498
+ pair: { handler: "pair", decode: decodePair, result: isDecision, mutation: true }, unpair: { handler: "unpair", decode: decodePair, result: isDecision, mutation: true },
499
+ list_snapshot: { handler: "listSnapshot", decode: emptyParams, result: isGatewaySnapshot, mutation: false }, observe_snapshot: { handler: "observeSnapshot", decode: emptyParams, result: isSnapshotObservation, mutation: false },
500
+ delivery_status: { handler: "deliveryStatus", decode: decodeDeliveryStatus, result: isDeliveryStatusResult, mutation: false }, untrack: { handler: "untrack", decode: decodeUntrack, result: isDecision, mutation: false },
501
+ send_to_claude: { handler: "sendToClaude", decode: decodeSendClaude, result: isSendResult, mutation: true }, send_to_codex: { handler: "sendToCodex", decode: decodeSendCodex, result: isSendResult, mutation: true },
502
+ reply: { handler: "reply", decode: decodeReply, result: isSendResult, mutation: true }, refresh_dashboard: { handler: "refreshDashboard", decode: emptyParams, result: isRefreshResult, mutation: false },
503
+ };
504
+ function parseRequestObject(value) {
505
+ if (!isRecord(value) || !exact(value, ["protocolVersion", "method", "params"]))
506
+ invalid();
507
+ if (value.protocolVersion !== 1)
508
+ throw new ProtocolFault("UNSUPPORTED_VERSION");
509
+ if (typeof value.method !== "string" || !Object.hasOwn(descriptors, value.method))
510
+ throw new ProtocolFault("UNKNOWN_METHOD");
511
+ const method = value.method;
512
+ return { protocolVersion: 1, method, params: descriptors[method].decode(value.params) };
513
+ }
514
+ function decodeJson(frame, invalidCode) {
515
+ try {
516
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(frame));
517
+ }
518
+ catch {
519
+ throw new ProtocolFault(invalidCode);
1036
520
  }
1037
521
  }
1038
522
  function errorResponse(code) {
1039
- return {
1040
- protocolVersion: GATEWAY_CONTROL_PROTOCOL_VERSION,
1041
- ok: false,
1042
- error: { code, message: WIRE_ERROR_MESSAGES[code] },
1043
- };
523
+ return { protocolVersion: 1, ok: false, error: { code, message: WIRE_ERROR_MESSAGES[code] } };
1044
524
  }
1045
525
  async function dispatch(request, handlers) {
1046
- let result;
526
+ const descriptor = descriptors[request.method];
527
+ const handler = handlers[descriptor.handler];
1047
528
  try {
1048
- switch (request.method) {
1049
- case "health":
1050
- result = await handlers.health();
1051
- break;
1052
- case "register_codex":
1053
- result = await handlers.registerCodex(request.params);
1054
- break;
1055
- case "unregister_codex":
1056
- result = await handlers.unregisterCodex(request.params);
1057
- break;
1058
- case "remove_stale_codex_registration":
1059
- result = await handlers.removeStaleCodexRegistration(request.params);
1060
- break;
1061
- case "select_claude":
1062
- result = await handlers.selectClaude(request.params);
1063
- break;
1064
- case "unselect_claude":
1065
- result = await handlers.unselectClaude(request.params);
1066
- break;
1067
- case "pair":
1068
- result = await handlers.pair(request.params);
1069
- break;
1070
- case "unpair":
1071
- result = await handlers.unpair(request.params);
1072
- break;
1073
- case "list_snapshot":
1074
- result = await handlers.listSnapshot();
1075
- break;
1076
- case "observe_snapshot":
1077
- result = await handlers.observeSnapshot();
1078
- break;
1079
- case "delivery_status":
1080
- result = await handlers.deliveryStatus(request.params);
1081
- break;
1082
- case "untrack":
1083
- result = await handlers.untrack(request.params);
1084
- break;
1085
- case "send_to_claude":
1086
- result = await handlers.sendToClaude(request.params);
1087
- break;
1088
- case "send_to_codex":
1089
- result = await handlers.sendToCodex(request.params);
1090
- break;
1091
- case "reply":
1092
- result = await handlers.reply(request.params);
1093
- break;
1094
- case "refresh_dashboard":
1095
- result = await handlers.refreshDashboard();
1096
- break;
1097
- }
529
+ const result = await (Object.keys(request.params).length === 0 ? handler() : handler(request.params));
530
+ return { protocolVersion: 1, ok: true, result };
1098
531
  }
1099
532
  catch {
1100
533
  return errorResponse("HANDLER_FAILURE");
1101
534
  }
1102
- if (!isResultForMethod(request.method, result)) {
1103
- return errorResponse("INVALID_HANDLER_RESPONSE");
1104
- }
1105
- return {
1106
- protocolVersion: GATEWAY_CONTROL_PROTOCOL_VERSION,
1107
- ok: true,
1108
- result,
1109
- };
1110
535
  }
1111
536
  function serializeResponse(response) {
1112
- const encoded = Buffer.from(`${JSON.stringify(response)}\n`, "utf8");
1113
- if (encoded.length <= GATEWAY_CONTROL_MAX_RESPONSE_BYTES)
1114
- return encoded;
1115
- return Buffer.from(`${JSON.stringify(errorResponse("RESPONSE_TOO_LARGE"))}\n`, "utf8");
1116
- }
1117
- function respond(socket, response) {
1118
- if (socket.destroyed)
1119
- return;
1120
- let encoded;
1121
537
  try {
1122
- encoded = serializeResponse(response);
538
+ const encoded = Buffer.from(`${JSON.stringify(response)}\n`, "utf8");
539
+ return encoded.length <= GATEWAY_CONTROL_MAX_RESPONSE_BYTES ? encoded : Buffer.from(`${JSON.stringify(errorResponse("RESPONSE_TOO_LARGE"))}\n`);
1123
540
  }
1124
541
  catch {
1125
- encoded = Buffer.from(`${JSON.stringify(errorResponse("INVALID_HANDLER_RESPONSE"))}\n`, "utf8");
542
+ return Buffer.from(`${JSON.stringify(errorResponse("INVALID_HANDLER_RESPONSE"))}\n`);
1126
543
  }
1127
- socket.end(encoded);
1128
544
  }
1129
- function handleConnection(socket, handlers, requestTimeoutMs) {
1130
- let buffered = Buffer.alloc(0);
1131
- let completed = false;
1132
- const complete = (response) => {
1133
- if (completed)
1134
- return;
1135
- completed = true;
1136
- socket.pause();
1137
- socket.setTimeout(0);
1138
- respond(socket, response);
1139
- };
1140
- socket.setTimeout(requestTimeoutMs);
1141
- socket.once("timeout", () => complete(errorResponse("REQUEST_TIMEOUT")));
1142
- socket.on("error", () => {
1143
- completed = true;
545
+ class FrameFault extends Error {
546
+ kind;
547
+ constructor(kind) {
548
+ super(kind);
549
+ this.kind = kind;
550
+ }
551
+ }
552
+ function readOneFrame(socket, maximum) {
553
+ return new Promise((resolve, reject) => {
554
+ let buffered = Buffer.alloc(0);
555
+ let settled = false;
556
+ const finish = (value) => {
557
+ if (settled)
558
+ return;
559
+ settled = true;
560
+ socket.pause();
561
+ socket.off("data", onData);
562
+ socket.off("end", onEnd);
563
+ socket.off("timeout", onTimeout);
564
+ value instanceof FrameFault ? reject(value) : resolve(value);
565
+ };
566
+ const onData = (chunk) => {
567
+ if (buffered.length + chunk.length > maximum)
568
+ return finish(new FrameFault("too_large"));
569
+ buffered = Buffer.concat([buffered, chunk]);
570
+ const newline = buffered.indexOf(0x0a);
571
+ if (newline < 0)
572
+ return;
573
+ if (buffered.subarray(newline + 1).toString("utf8").trim().length > 0)
574
+ return finish(new FrameFault("multiple"));
575
+ finish(buffered.subarray(0, newline));
576
+ };
577
+ const onEnd = () => finish(new FrameFault("closed"));
578
+ const onTimeout = () => finish(new FrameFault("timeout"));
579
+ socket.on("data", onData);
580
+ socket.once("end", onEnd);
581
+ socket.once("timeout", onTimeout);
582
+ socket.once("error", () => finish(new FrameFault("error")));
1144
583
  });
1145
- socket.on("data", (chunk) => {
1146
- if (completed)
1147
- return;
1148
- if (buffered.length + chunk.length > GATEWAY_CONTROL_MAX_FRAME_BYTES) {
1149
- complete(errorResponse("FRAME_TOO_LARGE"));
1150
- return;
1151
- }
1152
- buffered = Buffer.concat([buffered, chunk]);
1153
- const newline = buffered.indexOf(0x0a);
1154
- if (newline < 0)
1155
- return;
1156
- const trailing = buffered.subarray(newline + 1).toString("utf8");
1157
- if (trailing.trim().length > 0) {
1158
- complete(errorResponse("MULTIPLE_FRAMES"));
1159
- return;
1160
- }
1161
- const frame = buffered.subarray(0, newline);
584
+ }
585
+ function respond(socket, response) {
586
+ if (!socket.destroyed)
587
+ socket.end(serializeResponse(response));
588
+ }
589
+ function handleConnection(socket, handlers, timeoutMs) {
590
+ socket.setTimeout(timeoutMs);
591
+ void readOneFrame(socket, GATEWAY_CONTROL_MAX_FRAME_BYTES).then(async (frame) => {
592
+ socket.setTimeout(0);
1162
593
  let request;
1163
594
  try {
1164
- request = parseRequestFrame(frame);
595
+ request = parseRequestObject(decodeJson(frame, "INVALID_JSON"));
1165
596
  }
1166
597
  catch (error) {
1167
- const code = error instanceof ProtocolFault ? error.code : "INVALID_REQUEST";
1168
- complete(errorResponse(code));
598
+ respond(socket, errorResponse(error instanceof ProtocolFault ? error.code : "INVALID_REQUEST"));
1169
599
  return;
1170
600
  }
1171
- completed = true;
1172
- socket.pause();
1173
- socket.setTimeout(0);
1174
- void dispatch(request, handlers).then((response) => respond(socket, response), () => respond(socket, errorResponse("HANDLER_FAILURE")));
1175
- });
1176
- socket.once("end", () => {
1177
- if (!completed)
601
+ respond(socket, await dispatch(request, handlers));
602
+ }, (error) => {
603
+ const kind = error instanceof FrameFault ? error.kind : "error";
604
+ if (kind === "too_large")
605
+ respond(socket, errorResponse("FRAME_TOO_LARGE"));
606
+ else if (kind === "multiple")
607
+ respond(socket, errorResponse("MULTIPLE_FRAMES"));
608
+ else if (kind === "timeout")
609
+ respond(socket, errorResponse("REQUEST_TIMEOUT"));
610
+ else
1178
611
  socket.destroy();
1179
612
  });
1180
613
  }
1181
- function controlTransportError(code, ambiguous = false) {
1182
- const messages = {
1183
- UNSUPPORTED_PLATFORM: "Unix-domain gateway control sockets are unavailable.",
1184
- INVALID_STATE_DIR: "The gateway state directory is invalid.",
1185
- INSECURE_STATE_DIR: "The gateway state directory is not private to this user.",
1186
- INVALID_SOCKET_PATH: "The gateway control socket path is invalid.",
1187
- UNSAFE_SOCKET_TARGET: "The gateway control socket target is unsafe.",
1188
- SOCKET_IN_USE: "The gateway control socket is already served by a live process.",
1189
- SOCKET_PROBE_FAILED: "The gateway control socket could not be checked safely.",
1190
- SOCKET_BIND_FAILED: "The gateway control socket could not be bound.",
1191
- SOCKET_PERMISSION_FAILED: "The gateway control socket permissions could not be secured.",
1192
- SOCKET_CLEANUP_CONFLICT: "The gateway control socket path changed during cleanup.",
1193
- CONTROL_CONNECT_FAILED: "The gateway control socket could not be reached.",
1194
- CONTROL_TIMEOUT: "The gateway control request timed out.",
1195
- CONTROL_RESPONSE_TOO_LARGE: "The gateway control response exceeds the client limit.",
1196
- CONTROL_INVALID_RESPONSE: "The gateway returned an invalid control response. Restart the broker, then retry.",
1197
- CONTROL_CONNECTION_CLOSED: "The gateway closed before returning a control response.",
1198
- CONTROL_OUTCOME_AMBIGUOUS: "The gateway may have applied the control mutation before the response was lost; do not retry automatically.",
1199
- };
1200
- return new GatewayControlTransportError(code, messages[code] ?? "The gateway control transport failed.", ambiguous);
1201
- }
1202
- function isNonIdempotentControlMethod(method) {
1203
- return (method === "register_codex" ||
1204
- method === "unregister_codex" ||
1205
- method === "remove_stale_codex_registration" ||
1206
- method === "select_claude" ||
1207
- method === "unselect_claude" ||
1208
- method === "pair" ||
1209
- method === "unpair" ||
1210
- method === "send_to_claude" ||
1211
- method === "send_to_codex" ||
1212
- method === "reply");
1213
- }
1214
614
  async function optionalLstat(target) {
1215
615
  try {
1216
616
  return await lstat(target);
@@ -1221,15 +621,19 @@ async function optionalLstat(target) {
1221
621
  throw error;
1222
622
  }
1223
623
  }
624
+ function assertSocketPath(socketPath) {
625
+ if (!path.isAbsolute(socketPath) ||
626
+ path.resolve(socketPath) !== socketPath ||
627
+ Buffer.byteLength(socketPath, "utf8") > MAX_SOCKET_PATH_BYTES) {
628
+ throw controlTransportError("INVALID_SOCKET_PATH");
629
+ }
630
+ }
1224
631
  async function validateSocketLocation(stateDir, socketPath) {
1225
632
  if (process.platform === "win32") {
1226
633
  throw controlTransportError("UNSUPPORTED_PLATFORM");
1227
634
  }
1228
- if (!path.isAbsolute(stateDir) ||
1229
- path.resolve(stateDir) !== stateDir ||
1230
- !path.isAbsolute(socketPath) ||
1231
- path.resolve(socketPath) !== socketPath ||
1232
- Buffer.byteLength(socketPath, "utf8") > MAX_SOCKET_PATH_BYTES) {
635
+ assertSocketPath(socketPath);
636
+ if (!path.isAbsolute(stateDir) || path.resolve(stateDir) !== stateDir) {
1233
637
  throw controlTransportError("INVALID_SOCKET_PATH");
1234
638
  }
1235
639
  let stateInfo;
@@ -1351,8 +755,7 @@ async function removeOwnedSocket(socketPath, identity) {
1351
755
  await unlink(socketPath);
1352
756
  }
1353
757
  catch {
1354
- // The server is already closed. A failed best-effort exact unlink must not
1355
- // broaden cleanup to any replacement path.
758
+ // Never broaden cleanup to a replacement path.
1356
759
  }
1357
760
  }
1358
761
  async function protectSocketReplacement(socketPath, identity) {
@@ -1405,24 +808,17 @@ async function restoreSocketReplacement(socketPath, replacement) {
1405
808
  throw controlTransportError("SOCKET_CLEANUP_CONFLICT");
1406
809
  }
1407
810
  }
1408
- function validServerTimeout(value) {
1409
- return Number.isInteger(value) && value >= 50 && value <= 30_000;
1410
- }
1411
- function validClientTimeout(value) {
1412
- return Number.isInteger(value) && value >= 50 && value <= 90_000;
1413
- }
1414
811
  export async function startGatewayControlServer(options) {
1415
812
  await validateSocketLocation(options.stateDir, options.socketPath);
1416
813
  await prepareSocketTarget(options.socketPath);
1417
814
  const requestTimeoutMs = options.requestTimeoutMs ?? GATEWAY_CONTROL_DEFAULT_TIMEOUT_MS;
1418
- if (!validServerTimeout(requestTimeoutMs)) {
815
+ if (!Number.isInteger(requestTimeoutMs) ||
816
+ requestTimeoutMs < 50 ||
817
+ requestTimeoutMs > 30_000) {
1419
818
  throw controlTransportError("INVALID_SOCKET_PATH");
1420
819
  }
1421
820
  const connections = new Set();
1422
821
  const server = net.createServer({ allowHalfOpen: true }, (socket) => {
1423
- // Directory ownership proves same-user access, not that every caller is a
1424
- // trusted agent. Bound idle and in-flight peers before allocating parser or
1425
- // handler state so a local peer cannot grow this set without limit.
1426
822
  if (connections.size >= GATEWAY_CONTROL_MAX_CONNECTIONS) {
1427
823
  socket.once("error", () => socket.destroy());
1428
824
  socket.end(serializeResponse(errorResponse("SERVER_BUSY")), () => socket.destroy());
@@ -1461,8 +857,8 @@ export async function startGatewayControlServer(options) {
1461
857
  }
1462
858
  }
1463
859
  catch {
1464
- for (const connection of connections)
1465
- connection.destroy();
860
+ for (const socket of connections)
861
+ socket.destroy();
1466
862
  await closeNativeServer(server);
1467
863
  if (identity)
1468
864
  await removeOwnedSocket(options.socketPath, identity);
@@ -1474,8 +870,8 @@ export async function startGatewayControlServer(options) {
1474
870
  const close = () => {
1475
871
  closePromise ??= (async () => {
1476
872
  const replacement = await protectSocketReplacement(options.socketPath, ownedIdentity);
1477
- for (const connection of connections)
1478
- connection.destroy();
873
+ for (const socket of connections)
874
+ socket.destroy();
1479
875
  await closeNativeServer(server);
1480
876
  await removeOwnedSocket(options.socketPath, ownedIdentity);
1481
877
  await restoreSocketReplacement(options.socketPath, replacement);
@@ -1491,117 +887,63 @@ export async function startGatewayControlServer(options) {
1491
887
  close,
1492
888
  };
1493
889
  }
1494
- function validateResponseForMethod(method, value) {
1495
- if (!isRecord(value) || value.protocolVersion !== 1) {
890
+ function decodeResponse(method, value) {
891
+ if (!isRecord(value) || value.protocolVersion !== 1)
1496
892
  throw controlTransportError("CONTROL_INVALID_RESPONSE");
1497
- }
1498
893
  if (value.ok === false) {
1499
- if (!hasExactKeys(value, ["protocolVersion", "ok", "error"]) ||
1500
- !isRecord(value.error) ||
1501
- !hasExactKeys(value.error, ["code", "message"]) ||
1502
- typeof value.error.code !== "string" ||
1503
- !Object.hasOwn(WIRE_ERROR_MESSAGES, value.error.code) ||
1504
- value.error.message !==
1505
- WIRE_ERROR_MESSAGES[value.error.code]) {
894
+ if (!shape(value, { protocolVersion: oneOf(1), ok: oneOf(false), error: (item) => shape(item, { code: (code) => typeof code === "string" && Object.hasOwn(WIRE_ERROR_MESSAGES, code),
895
+ message: (message) => typeof message === "string" }) }) ||
896
+ !isRecord(value.error) || value.error.message !== WIRE_ERROR_MESSAGES[value.error.code])
1506
897
  throw controlTransportError("CONTROL_INVALID_RESPONSE");
1507
- }
1508
898
  return value;
1509
899
  }
1510
- if (value.ok !== true ||
1511
- !hasExactKeys(value, ["protocolVersion", "ok", "result"]) ||
1512
- !isResultForMethod(method, value.result)) {
900
+ if (!shape(value, { protocolVersion: oneOf(1), ok: oneOf(true), result: descriptors[method].result }))
1513
901
  throw controlTransportError("CONTROL_INVALID_RESPONSE");
1514
- }
1515
902
  return value;
1516
903
  }
1517
904
  export async function sendGatewayControlRequest(options) {
1518
- if (!path.isAbsolute(options.socketPath) ||
1519
- path.resolve(options.socketPath) !== options.socketPath ||
1520
- Buffer.byteLength(options.socketPath, "utf8") > MAX_SOCKET_PATH_BYTES) {
1521
- throw controlTransportError("INVALID_SOCKET_PATH");
1522
- }
1523
- const timeoutMs = options.timeoutMs ?? GATEWAY_CONTROL_DEFAULT_TIMEOUT_MS;
1524
- const maxResponseBytes = options.maxResponseBytes ?? GATEWAY_CONTROL_MAX_RESPONSE_BYTES;
1525
- if (!validClientTimeout(timeoutMs) ||
1526
- !Number.isInteger(maxResponseBytes) ||
1527
- maxResponseBytes < 256 ||
1528
- maxResponseBytes > GATEWAY_CONTROL_MAX_RESPONSE_BYTES) {
905
+ assertSocketPath(options.socketPath);
906
+ const timeout = options.timeoutMs ?? GATEWAY_CONTROL_DEFAULT_TIMEOUT_MS;
907
+ const maximum = options.maxResponseBytes ?? GATEWAY_CONTROL_MAX_RESPONSE_BYTES;
908
+ if (!Number.isInteger(timeout) || timeout < 50 || timeout > 90_000 ||
909
+ !Number.isInteger(maximum) || maximum < 256 || maximum > GATEWAY_CONTROL_MAX_RESPONSE_BYTES)
1529
910
  throw controlTransportError("CONTROL_INVALID_RESPONSE");
1530
- }
1531
- const normalized = parseRequestObject(options.request);
1532
- if (normalized.method !== options.request.method) {
1533
- throw new ProtocolFault("INVALID_REQUEST");
1534
- }
1535
- const requestFrame = Buffer.from(`${JSON.stringify(normalized)}\n`, "utf8");
1536
- if (requestFrame.length > GATEWAY_CONTROL_MAX_FRAME_BYTES) {
911
+ const request = parseRequestObject(options.request);
912
+ const frame = Buffer.from(`${JSON.stringify(request)}\n`, "utf8");
913
+ if (frame.length > GATEWAY_CONTROL_MAX_FRAME_BYTES)
1537
914
  throw new ProtocolFault("FRAME_TOO_LARGE");
1538
- }
1539
915
  return await new Promise((resolve, reject) => {
1540
916
  const socket = net.createConnection(options.socketPath);
1541
917
  let settled = false;
1542
918
  let writeStarted = false;
1543
- let buffered = Buffer.alloc(0);
1544
- const mutation = isNonIdempotentControlMethod(normalized.method);
919
+ const mutation = descriptors[request.method].mutation;
1545
920
  const fail = (error) => {
1546
921
  if (settled)
1547
922
  return;
1548
923
  settled = true;
1549
924
  socket.destroy();
1550
- reject(mutation && writeStarted
1551
- ? controlTransportError("CONTROL_OUTCOME_AMBIGUOUS", true)
1552
- : error);
1553
- };
1554
- const succeed = (response) => {
1555
- if (settled)
1556
- return;
1557
- settled = true;
1558
- socket.destroy();
1559
- resolve(response);
925
+ reject(mutation && writeStarted ? controlTransportError("CONTROL_OUTCOME_AMBIGUOUS", true) : error);
1560
926
  };
1561
- socket.setTimeout(timeoutMs);
1562
- socket.once("connect", () => {
1563
- // From this point onward the peer may receive and execute all or part of
1564
- // the frame. Losing the response to a mutation is therefore ambiguous,
1565
- // never a safe retry signal.
1566
- writeStarted = true;
1567
- socket.write(requestFrame);
1568
- });
1569
- socket.once("timeout", () => fail(controlTransportError("CONTROL_TIMEOUT")));
1570
- socket.once("error", () => fail(controlTransportError("CONTROL_CONNECT_FAILED")));
1571
- socket.on("data", (chunk) => {
1572
- if (settled)
1573
- return;
1574
- if (buffered.length + chunk.length > maxResponseBytes) {
1575
- fail(controlTransportError("CONTROL_RESPONSE_TOO_LARGE"));
1576
- return;
1577
- }
1578
- buffered = Buffer.concat([buffered, chunk]);
1579
- const newline = buffered.indexOf(0x0a);
1580
- if (newline < 0)
1581
- return;
1582
- if (buffered.subarray(newline + 1).toString("utf8").trim().length > 0) {
1583
- fail(controlTransportError("CONTROL_INVALID_RESPONSE"));
1584
- return;
1585
- }
1586
- let parsed;
927
+ socket.setTimeout(timeout);
928
+ socket.once("connect", () => { writeStarted = true; socket.write(frame); });
929
+ void readOneFrame(socket, maximum).then((responseFrame) => {
1587
930
  try {
1588
- parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffered.subarray(0, newline)));
1589
- }
1590
- catch {
1591
- fail(controlTransportError("CONTROL_INVALID_RESPONSE"));
1592
- return;
1593
- }
1594
- try {
1595
- succeed(validateResponseForMethod(options.request.method, parsed));
931
+ const response = decodeResponse(options.request.method, decodeJson(responseFrame, "INVALID_JSON"));
932
+ if (!settled) {
933
+ settled = true;
934
+ socket.destroy();
935
+ resolve(response);
936
+ }
1596
937
  }
1597
938
  catch {
1598
939
  fail(controlTransportError("CONTROL_INVALID_RESPONSE"));
1599
940
  }
1600
- });
1601
- socket.once("close", () => {
1602
- if (!settled) {
1603
- fail(controlTransportError("CONTROL_CONNECTION_CLOSED"));
1604
- }
941
+ }, (error) => {
942
+ const kind = error instanceof FrameFault ? error.kind : "error";
943
+ const code = kind === "timeout" ? "CONTROL_TIMEOUT" :
944
+ kind === "too_large" ? "CONTROL_RESPONSE_TOO_LARGE" :
945
+ kind === "closed" ? "CONTROL_CONNECTION_CLOSED" : "CONTROL_CONNECT_FAILED";
946
+ fail(controlTransportError(code));
1605
947
  });
1606
948
  });
1607
949
  }