agent-embassy 1.7.1 → 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 (121) hide show
  1. package/CHANGELOG.md +19 -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.d.ts +1 -1
  30. package/dist/src/gateway/cli-copy.en.d.ts +2 -4
  31. package/dist/src/gateway/cli-copy.en.js +3 -4
  32. package/dist/src/gateway/cli-copy.en.js.map +1 -1
  33. package/dist/src/gateway/cli-copy.js +0 -2
  34. package/dist/src/gateway/cli-copy.js.map +1 -1
  35. package/dist/src/gateway/cli-copy.zh-CN.d.ts +2 -4
  36. package/dist/src/gateway/cli-copy.zh-CN.js +3 -4
  37. package/dist/src/gateway/cli-copy.zh-CN.js.map +1 -1
  38. package/dist/src/gateway/cli.d.ts +7 -2
  39. package/dist/src/gateway/cli.js +273 -617
  40. package/dist/src/gateway/cli.js.map +1 -1
  41. package/dist/src/gateway/codex-app-server.d.ts +5 -227
  42. package/dist/src/gateway/codex-app-server.js +18 -1463
  43. package/dist/src/gateway/codex-app-server.js.map +1 -1
  44. package/dist/src/gateway/codex-doctor.d.ts +6 -3
  45. package/dist/src/gateway/codex-doctor.js +77 -98
  46. package/dist/src/gateway/codex-doctor.js.map +1 -1
  47. package/dist/src/gateway/codex-local-transport.d.ts +1 -6
  48. package/dist/src/gateway/codex-local-transport.js +8 -10
  49. package/dist/src/gateway/codex-local-transport.js.map +1 -1
  50. package/dist/src/gateway/codex-stateless-transport.d.ts +114 -0
  51. package/dist/src/gateway/codex-stateless-transport.js +1108 -0
  52. package/dist/src/gateway/codex-stateless-transport.js.map +1 -0
  53. package/dist/src/gateway/config.d.ts +2 -9
  54. package/dist/src/gateway/config.js +62 -100
  55. package/dist/src/gateway/config.js.map +1 -1
  56. package/dist/src/gateway/control.d.ts +36 -90
  57. package/dist/src/gateway/control.js +464 -1159
  58. package/dist/src/gateway/control.js.map +1 -1
  59. package/dist/src/gateway/dashboard-copy.d.ts +1 -1
  60. package/dist/src/gateway/dashboard-copy.en.d.ts +3 -26
  61. package/dist/src/gateway/dashboard-copy.en.js +14 -37
  62. package/dist/src/gateway/dashboard-copy.en.js.map +1 -1
  63. package/dist/src/gateway/dashboard-copy.js +3 -26
  64. package/dist/src/gateway/dashboard-copy.js.map +1 -1
  65. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +3 -26
  66. package/dist/src/gateway/dashboard-copy.zh-CN.js +14 -37
  67. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +1 -1
  68. package/dist/src/gateway/dashboard-model.d.ts +6 -23
  69. package/dist/src/gateway/dashboard-model.js +37 -131
  70. package/dist/src/gateway/dashboard-model.js.map +1 -1
  71. package/dist/src/gateway/dashboard.d.ts +0 -6
  72. package/dist/src/gateway/dashboard.js +0 -6
  73. package/dist/src/gateway/dashboard.js.map +1 -1
  74. package/dist/src/gateway/live-dashboard-app/app.js +9 -32
  75. package/dist/src/gateway/live-dashboard-command.js +2 -2
  76. package/dist/src/gateway/live-dashboard-command.js.map +1 -1
  77. package/dist/src/gateway/live-dashboard-http.d.ts +1 -1
  78. package/dist/src/gateway/live-dashboard-http.js +2 -2
  79. package/dist/src/gateway/live-dashboard-http.js.map +1 -1
  80. package/dist/src/gateway/progress-watch-machine.d.ts +1 -37
  81. package/dist/src/gateway/progress-watch-machine.js +4 -15
  82. package/dist/src/gateway/progress-watch-machine.js.map +1 -1
  83. package/dist/src/gateway/providers.d.ts +55 -195
  84. package/dist/src/gateway/providers.js +608 -2465
  85. package/dist/src/gateway/providers.js.map +1 -1
  86. package/dist/src/gateway/server.d.ts +16 -25
  87. package/dist/src/gateway/server.js +164 -251
  88. package/dist/src/gateway/server.js.map +1 -1
  89. package/dist/src/gateway/service.d.ts +146 -448
  90. package/dist/src/gateway/service.js +1670 -6142
  91. package/dist/src/gateway/service.js.map +1 -1
  92. package/dist/src/gateway/state-v2-to-v3.d.ts +23 -0
  93. package/dist/src/gateway/state-v2-to-v3.js +994 -0
  94. package/dist/src/gateway/state-v2-to-v3.js.map +1 -0
  95. package/dist/src/gateway/store.d.ts +54 -309
  96. package/dist/src/gateway/store.js +1560 -3524
  97. package/dist/src/gateway/store.js.map +1 -1
  98. package/dist/src/gateway/types.d.ts +175 -228
  99. package/dist/src/gateway/types.js +114 -252
  100. package/dist/src/gateway/types.js.map +1 -1
  101. package/docs/CONFIGURATION.md +22 -9
  102. package/docs/CONFIGURATION.zh-CN.md +21 -9
  103. package/docs/DASHBOARD.md +10 -10
  104. package/docs/DASHBOARD.zh-CN.md +3 -3
  105. package/docs/DELIVERY.md +4 -4
  106. package/docs/DELIVERY.zh-CN.md +4 -4
  107. package/docs/GATEWAY-ARCHITECTURE.md +158 -187
  108. package/package.json +1 -1
  109. package/skills/embassy-peer/SKILL.md +28 -22
  110. package/dist/src/gateway/codex-registration-generation.d.ts +0 -5
  111. package/dist/src/gateway/codex-registration-generation.js +0 -19
  112. package/dist/src/gateway/codex-registration-generation.js.map +0 -1
  113. package/dist/src/gateway/codex-registration-succession.d.ts +0 -209
  114. package/dist/src/gateway/codex-registration-succession.js +0 -588
  115. package/dist/src/gateway/codex-registration-succession.js.map +0 -1
  116. package/dist/src/gateway/compatibility.d.ts +0 -22
  117. package/dist/src/gateway/compatibility.js +0 -23
  118. package/dist/src/gateway/compatibility.js.map +0 -1
  119. package/dist/src/gateway/delivery-machine.d.ts +0 -235
  120. package/dist/src/gateway/delivery-machine.js +0 -540
  121. 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, CONNECTOR_OBSERVATION_STALE_AFTER_MS, 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,1084 +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
- ], [
587
- "lastSeenAt",
588
- "observationAgeMs",
589
- "codexDoctor",
590
- "safeErrorCode",
591
- "registry",
592
- ]) &&
593
- isGatewayProvider(value.provider) &&
594
- isHostId(value.host) &&
595
- isConnectorHealth(value.health) &&
596
- typeof value.protocol === "string" &&
597
- PROTOCOL_PATTERN.test(value.protocol) &&
598
- typeof value.protocolVersion === "string" &&
599
- PROTOCOL_VERSION_PATTERN.test(value.protocolVersion) &&
600
- (value.lastSeenAt === undefined || isIsoTimestamp(value.lastSeenAt)) &&
601
- (value.observationAgeMs === undefined ||
602
- isNonNegativeInteger(value.observationAgeMs)) &&
603
- (value.codexDoctor === undefined ||
604
- (value.provider === "codex" &&
605
- isRecord(value.codexDoctor) &&
606
- hasExactKeys(value.codexDoctor, ["conditions"]) &&
607
- Array.isArray(value.codexDoctor.conditions) &&
608
- value.codexDoctor.conditions.length > 0 &&
609
- value.codexDoctor.conditions.length <= 2 &&
610
- value.codexDoctor.conditions.every((condition) => [
611
- "split_brain",
612
- "orphaned",
613
- "attached",
614
- "observation_stale",
615
- "unknown",
616
- ].includes(condition)))) &&
617
- (value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)) &&
618
- (value.registry === undefined ||
619
- (value.provider === "claude" &&
620
- 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");
621
355
  }
622
356
  function isRouteSnapshot(value) {
623
- return (isRecord(value) &&
624
- hasExactKeys(value, [
625
- "alias",
626
- "provider",
627
- "host",
628
- "enabled",
629
- "state",
630
- "busyPolicy",
631
- "queueDepth",
632
- "counters",
633
- ], ["lastSeenAt", "oldestQueuedAt", "safeErrorCode"]) &&
634
- isAlias(value.alias) &&
635
- isGatewayProvider(value.provider) &&
636
- isHostId(value.host) &&
637
- value.alias.endsWith(`@${value.host}`) &&
638
- typeof value.enabled === "boolean" &&
639
- (value.state === "stale" ||
640
- value.state === "idle" ||
641
- value.state === "busy" ||
642
- value.state === "awaiting_approval" ||
643
- value.state === "offline" ||
644
- value.state === "disabled") &&
645
- value.busyPolicy === "queue" &&
646
- (value.lastSeenAt === undefined || isIsoTimestamp(value.lastSeenAt)) &&
647
- isNonNegativeInteger(value.queueDepth) &&
648
- (value.oldestQueuedAt === undefined
649
- ? value.queueDepth === 0
650
- : value.queueDepth > 0 && isIsoTimestamp(value.oldestQueuedAt)) &&
651
- isRouteCounters(value.counters) &&
652
- (value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)));
653
- }
654
- function isConsentEndpoint(value) {
655
- return (isRecord(value) &&
656
- hasExactKeys(value, ["alias", "provider"]) &&
657
- isAlias(value.alias) &&
658
- 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);
659
364
  }
660
365
  function compareConsentEndpoints(left, right) {
661
- const providerDelta = gatewayProviders.indexOf(left.provider) -
662
- gatewayProviders.indexOf(right.provider);
663
- return providerDelta || left.alias.localeCompare(right.alias);
366
+ return gatewayProviders.indexOf(left.provider) -
367
+ gatewayProviders.indexOf(right.provider) || left.alias.localeCompare(right.alias);
664
368
  }
665
369
  function isConsentEdgeSnapshot(value) {
666
- if (!isRecord(value) ||
667
- !hasExactKeys(value, ["endpoints", "host", "counters"]) ||
668
- !Array.isArray(value.endpoints) ||
669
- value.endpoints.length !== 2 ||
670
- !isConsentEndpoint(value.endpoints[0]) ||
671
- !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 }))
672
373
  return false;
673
- }
674
374
  const [left, right] = value.endpoints;
675
- return (left.provider !== right.provider &&
676
- left.alias !== right.alias &&
677
- compareConsentEndpoints(left, right) < 0 &&
678
- isHostId(value.host) &&
679
- left.alias.endsWith(`@${value.host}`) &&
680
- right.alias.endsWith(`@${value.host}`) &&
681
- 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}`);
682
377
  }
683
378
  function isAvailablePeerSnapshot(value) {
684
- return (isRecord(value) &&
685
- hasExactKeys(value, [
686
- "alias",
687
- "provider",
688
- "host",
689
- "state",
690
- "validated",
691
- "selected",
692
- ], ["lastSeenAt", "safeErrorCode"]) &&
693
- isAlias(value.alias) &&
694
- value.provider === "claude" &&
695
- isHostId(value.host) &&
696
- value.alias.endsWith(`@${value.host}`) &&
697
- (value.state === "idle" ||
698
- value.state === "busy" ||
699
- value.state === "awaiting_approval" ||
700
- value.state === "offline") &&
701
- typeof value.validated === "boolean" &&
702
- typeof value.selected === "boolean" &&
703
- (value.lastSeenAt === undefined || isIsoTimestamp(value.lastSeenAt)) &&
704
- (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)}`);
705
383
  }
706
384
  function isNormalizedMessageEvent(value) {
707
- return (isRecord(value) &&
708
- hasExactKeys(value, [
709
- "sequence",
710
- "timestamp",
711
- "messageIdSuffix",
712
- "direction",
713
- "sourceAlias",
714
- "targetAlias",
715
- "state",
716
- "bytes",
717
- ], ["conversationIdSuffix", "body", "latencyMs", "safeErrorCode", "steer"]) &&
718
- isNonNegativeInteger(value.sequence) &&
719
- isIsoTimestamp(value.timestamp) &&
720
- typeof value.messageIdSuffix === "string" &&
721
- MESSAGE_SUFFIX_PATTERN.test(value.messageIdSuffix) &&
722
- (value.conversationIdSuffix === undefined ||
723
- (typeof value.conversationIdSuffix === "string" &&
724
- CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix))) &&
725
- isMessageDirection(value.direction) &&
726
- isAlias(value.sourceAlias) &&
727
- isAlias(value.targetAlias) &&
728
- (value.state === "queued" ||
729
- value.state === "duplicate" ||
730
- value.state === "dispatching" ||
731
- value.state === "transport_written" ||
732
- value.state === "held" ||
733
- value.state === "delivered" ||
734
- value.state === "unconfirmed" ||
735
- value.state === "failed" ||
736
- value.state === "ambiguous" ||
737
- value.state === "expired" ||
738
- value.state === "cancelled" ||
739
- value.state === "abandoned" ||
740
- value.state === "rejected") &&
741
- isNonNegativeInteger(value.bytes) &&
742
- (value.body === undefined ||
743
- (typeof value.body === "string" &&
744
- value.body.length > 0 &&
745
- !value.body.includes("\u0000") &&
746
- Buffer.byteLength(value.body, "utf8") === value.bytes)) &&
747
- (value.latencyMs === undefined || isNonNegativeInteger(value.latencyMs)) &&
748
- (value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)) &&
749
- (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;
750
394
  }
751
395
  function isAccounting(value) {
752
- if (!isRecord(value) ||
753
- !hasExactKeys(value, [
754
- "accepted",
755
- "duplicates",
756
- "delivered",
757
- "unconfirmed",
758
- "failed",
759
- "ambiguous",
760
- "expired",
761
- "cancelled",
762
- "abandoned",
763
- "rejected",
764
- "bytesAccepted",
765
- "queuedBytes",
766
- ])) {
767
- return false;
768
- }
769
- 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]));
770
399
  }
771
400
  function isSafeAlert(value) {
772
- return (isRecord(value) &&
773
- hasExactKeys(value, ["code", "severity", "timestamp"], [
774
- "provider",
775
- "host",
776
- "alias",
777
- ]) &&
778
- isSafeCode(value.code) &&
779
- (value.severity === "info" ||
780
- value.severity === "warning" ||
781
- value.severity === "error") &&
782
- isIsoTimestamp(value.timestamp) &&
783
- (value.provider === undefined || isGatewayProvider(value.provider)) &&
784
- (value.host === undefined || isHostId(value.host)) &&
785
- (value.alias === undefined || isAlias(value.alias)));
401
+ return shape(value, { code: safeCode, severity: oneOf("info", "warning", "error"), timestamp: iso }, { provider: isGatewayProvider, host, alias });
786
402
  }
787
403
  function isProgressWatchSnapshot(value) {
788
- return (isRecord(value) &&
789
- hasExactKeys(value, [
790
- "conversationIdSuffix",
791
- "ownerAlias",
792
- "workerAlias",
793
- "lastActivityAt",
794
- "nextActionAt",
795
- "nudgeCount",
796
- ]) &&
797
- typeof value.conversationIdSuffix === "string" &&
798
- CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix) &&
799
- isAlias(value.ownerAlias) &&
800
- isAlias(value.workerAlias) &&
801
- value.ownerAlias !== value.workerAlias &&
802
- isIsoTimestamp(value.lastActivityAt) &&
803
- isIsoTimestamp(value.nextActionAt) &&
804
- (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;
805
409
  }
806
410
  function isProgressWatchEventSnapshot(value) {
807
- return (isRecord(value) &&
808
- hasExactKeys(value, [
809
- "sequence",
810
- "timestamp",
811
- "conversationIdSuffix",
812
- "ownerAlias",
813
- "workerAlias",
814
- "kind",
815
- "actor",
816
- ], ["reason"]) &&
817
- isNonNegativeInteger(value.sequence) &&
818
- isIsoTimestamp(value.timestamp) &&
819
- typeof value.conversationIdSuffix === "string" &&
820
- CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix) &&
821
- isAlias(value.ownerAlias) &&
822
- isAlias(value.workerAlias) &&
823
- ((value.kind === "opened" &&
824
- value.actor === "owner" &&
825
- value.reason === undefined) ||
826
- (value.kind === "replaced" &&
827
- (value.actor === "owner" || value.actor === "unknown") &&
828
- value.reason === undefined) ||
829
- (value.kind === "settled" &&
830
- ((value.reason === "done" &&
831
- (value.actor === "owner" || value.actor === "worker")) ||
832
- (value.reason === "untracked" && value.actor === "operator") ||
833
- ((value.reason === "idle_timeout" ||
834
- value.reason === "tracking_disabled") &&
835
- value.actor === "gateway") ||
836
- (value.reason === "endpoint_retired" &&
837
- (value.actor === "gateway" || value.actor === "operator")) ||
838
- (value.reason === "pair_removed" &&
839
- 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");
840
425
  }
841
426
  function isGatewayActivityEvent(value) {
842
- return (isRecord(value) &&
843
- hasExactKeys(value, [
844
- "sequence",
845
- "timestamp",
846
- "kind",
847
- "action",
848
- "outcome",
849
- "aliases",
850
- "operatorAction",
851
- ], ["safeErrorCode"]) &&
852
- isNonNegativeInteger(value.sequence) &&
853
- value.sequence > 0 &&
854
- isIsoTimestamp(value.timestamp) &&
855
- isGatewayActivityIdentity(value.kind, value.action, value.operatorAction) &&
856
- (value.outcome === "accepted" || value.outcome === "rejected") &&
857
- Array.isArray(value.aliases) &&
858
- value.aliases.length <= 2 &&
859
- value.aliases.every(isAlias) &&
860
- new Set(value.aliases).size === value.aliases.length &&
861
- (value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)));
862
- }
863
- function isGatewayActivityIdentity(kind, action, operatorAction) {
864
- 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 }))
865
432
  return false;
866
- switch (kind) {
867
- case "discovery":
868
- return action === "discovery_refreshed" && operatorAction;
869
- case "selection":
870
- return ((action === "claude_selected" || action === "claude_unselected") &&
871
- operatorAction);
872
- case "registration":
873
- return ((action === "codex_registered" ||
874
- action === "codex_succeeded" ||
875
- action === "codex_unregistered") &&
876
- operatorAction);
877
- case "pairing":
878
- return ((action === "routes_paired" || action === "routes_unpaired") &&
879
- operatorAction);
880
- case "watch":
881
- return action === "watch_ended" && operatorAction;
882
- case "endpoint":
883
- return action === "endpoint_refreshed" && !operatorAction;
884
- case "recovery":
885
- return action === "codex_orphan_removed" && operatorAction;
886
- default:
887
- return false;
888
- }
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);
889
440
  }
890
441
  function isDeadlinePressure(value) {
891
- const names = [
892
- "under_1m",
893
- "1m_to_5m",
894
- "5m_to_15m",
895
- "15m_to_60m",
896
- "over_60m",
897
- ];
898
- return (isRecord(value) &&
899
- hasExactKeys(value, ["configuredDeadlineMs", "terminalEvents", "expiredEvents", "buckets"], ["retainedSince"]) &&
900
- isNonNegativeInteger(value.configuredDeadlineMs) &&
901
- value.configuredDeadlineMs > 0 &&
902
- (value.retainedSince === undefined || isIsoTimestamp(value.retainedSince)) &&
903
- isNonNegativeInteger(value.terminalEvents) &&
904
- isNonNegativeInteger(value.expiredEvents) &&
905
- value.expiredEvents <= value.terminalEvents &&
906
- Array.isArray(value.buckets) &&
907
- value.buckets.length === names.length &&
908
- value.buckets.every((bucket, index) => isRecord(bucket) &&
909
- hasExactKeys(bucket, ["bucket", "settled", "expired"]) &&
910
- bucket.bucket === names[index] &&
911
- isNonNegativeInteger(bucket.settled) &&
912
- isNonNegativeInteger(bucket.expired) &&
913
- bucket.expired <= bucket.settled) &&
914
- value.buckets.reduce((sum, bucket) => sum + Number(bucket.settled), 0) ===
915
- value.terminalEvents &&
916
- value.buckets.reduce((sum, bucket) => sum + Number(bucket.expired), 0) ===
917
- 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;
918
452
  }
919
453
  function isSnapshotTruncation(value) {
920
- return (isRecord(value) &&
921
- hasExactKeys(value, [
922
- "connectors",
923
- "availablePeers",
924
- "routes",
925
- "consentEdges",
926
- "messages",
927
- "alerts",
928
- ], [
929
- "progressWatches",
930
- "progressWatchEvents",
931
- "activityEvents",
932
- ]) &&
933
- isNonNegativeInteger(value.connectors) &&
934
- isNonNegativeInteger(value.availablePeers) &&
935
- isNonNegativeInteger(value.routes) &&
936
- isNonNegativeInteger(value.consentEdges) &&
937
- isNonNegativeInteger(value.messages) &&
938
- isNonNegativeInteger(value.alerts) &&
939
- (value.progressWatches === undefined ||
940
- isNonNegativeInteger(value.progressWatches)) &&
941
- (value.progressWatchEvents === undefined ||
942
- isNonNegativeInteger(value.progressWatchEvents)) &&
943
- (value.activityEvents === undefined ||
944
- 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]));
945
458
  }
946
459
  export function isGatewaySnapshot(value) {
947
- if (!isRecord(value) ||
948
- !hasExactKeys(value, [
949
- "schemaVersion",
950
- "generatedAt",
951
- "inboundMode",
952
- "health",
953
- "connectors",
954
- "availablePeers",
955
- "routes",
956
- "consentEdges",
957
- "messages",
958
- "accounting",
959
- "alerts",
960
- "truncation",
961
- ], [
962
- "progressWatches",
963
- "progressWatchEvents",
964
- "activityEvents",
965
- "deadlinePressure",
966
- ]) ||
967
- value.schemaVersion !== 2 ||
968
- !isIsoTimestamp(value.generatedAt) ||
969
- (value.inboundMode !== "paired" && value.inboundMode !== "open") ||
970
- !isConnectorHealth(value.health) ||
971
- !Array.isArray(value.connectors) ||
972
- value.connectors.length > gatewayPublicSnapshotLimits.connectors ||
973
- !value.connectors.every(isConnectorSnapshot) ||
974
- !Array.isArray(value.availablePeers) ||
975
- value.availablePeers.length > gatewayPublicSnapshotLimits.availablePeers ||
976
- !value.availablePeers.every(isAvailablePeerSnapshot) ||
977
- !Array.isArray(value.routes) ||
978
- value.routes.length > gatewayPublicSnapshotLimits.routes ||
979
- !value.routes.every(isRouteSnapshot) ||
980
- !Array.isArray(value.consentEdges) ||
981
- value.consentEdges.length > gatewayPublicSnapshotLimits.consentEdges ||
982
- !value.consentEdges.every(isConsentEdgeSnapshot) ||
983
- (value.progressWatches !== undefined &&
984
- (!Array.isArray(value.progressWatches) ||
985
- value.progressWatches.length >
986
- gatewayPublicSnapshotLimits.progressWatches ||
987
- !value.progressWatches.every(isProgressWatchSnapshot))) ||
988
- (value.progressWatchEvents !== undefined &&
989
- (!Array.isArray(value.progressWatchEvents) ||
990
- value.progressWatchEvents.length >
991
- gatewayPublicSnapshotLimits.progressWatchEvents ||
992
- !value.progressWatchEvents.every(isProgressWatchEventSnapshot))) ||
993
- (value.activityEvents !== undefined &&
994
- (!Array.isArray(value.activityEvents) ||
995
- value.activityEvents.length >
996
- gatewayPublicSnapshotLimits.activityEvents ||
997
- !value.activityEvents.every(isGatewayActivityEvent))) ||
998
- (value.deadlinePressure !== undefined &&
999
- !isDeadlinePressure(value.deadlinePressure)) ||
1000
- !Array.isArray(value.messages) ||
1001
- value.messages.length > gatewayPublicSnapshotLimits.messages ||
1002
- !value.messages.every(isNormalizedMessageEvent) ||
1003
- !isAccounting(value.accounting) ||
1004
- !Array.isArray(value.alerts) ||
1005
- value.alerts.length > gatewayPublicSnapshotLimits.alerts ||
1006
- !value.alerts.every(isSafeAlert) ||
1007
- !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 }))
1008
472
  return false;
1009
- }
1010
- const connectorKeys = value.connectors.map((connector) => `${connector.provider}@${connector.host}`);
1011
- const generatedAtMs = Date.parse(value.generatedAt);
1012
- const connectorObservationsHonest = value.connectors.every((connector) => {
1013
- const observedAtMs = connector.lastSeenAt === undefined
1014
- ? undefined
1015
- : Date.parse(connector.lastSeenAt);
1016
- const derivedAgeMs = observedAtMs === undefined
1017
- ? undefined
1018
- : Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, generatedAtMs - observedAtMs));
1019
- return ((connector.observationAgeMs === undefined ||
1020
- connector.observationAgeMs === derivedAgeMs) &&
1021
- (connector.health !== "healthy" ||
1022
- (derivedAgeMs !== undefined &&
1023
- derivedAgeMs <= CONNECTOR_OBSERVATION_STALE_AFTER_MS)));
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));
1024
486
  });
1025
- const aliases = value.routes.map((route) => route.alias);
1026
- const peerAliases = value.availablePeers.map((peer) => peer.alias);
1027
- const consentEdgeKeys = value.consentEdges.map((edge) => `${edge.endpoints[0].alias}\0${edge.endpoints[1].alias}`);
1028
- const routeByAlias = new Map(value.routes.map((route) => [route.alias, route]));
1029
- return (connectorObservationsHonest &&
1030
- new Set(connectorKeys).size === connectorKeys.length &&
1031
- new Set(aliases).size === aliases.length &&
1032
- new Set(peerAliases).size === peerAliases.length &&
1033
- new Set(consentEdgeKeys).size === consentEdgeKeys.length &&
1034
- value.consentEdges.every((edge) => routeByAlias.get(edge.endpoints[0].alias)?.provider ===
1035
- edge.endpoints[0].provider &&
1036
- routeByAlias.get(edge.endpoints[1].alias)?.provider ===
1037
- edge.endpoints[1].provider) &&
1038
- Buffer.byteLength(JSON.stringify(value), "utf8") <=
1039
- GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET);
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;
1040
490
  }
1041
491
  function isSnapshotObservation(value) {
1042
- return (isRecord(value) &&
1043
- hasExactKeys(value, ["snapshotRevision", "snapshot"]) &&
1044
- isNonNegativeInteger(value.snapshotRevision) &&
1045
- isGatewaySnapshot(value.snapshot));
1046
- }
1047
- function isResultForMethod(method, value) {
1048
- switch (method) {
1049
- case "health":
1050
- return isHealthResult(value);
1051
- case "register_codex":
1052
- case "unregister_codex":
1053
- case "remove_stale_codex_registration":
1054
- case "select_claude":
1055
- case "unselect_claude":
1056
- case "pair":
1057
- case "unpair":
1058
- return isDecision(value);
1059
- case "list_snapshot":
1060
- return isGatewaySnapshot(value);
1061
- case "observe_snapshot":
1062
- return isSnapshotObservation(value);
1063
- case "delivery_status":
1064
- return isDeliveryStatusResult(value);
1065
- case "untrack":
1066
- return isDecision(value);
1067
- case "send_to_claude":
1068
- case "send_to_codex":
1069
- case "reply":
1070
- return isSendResult(value);
1071
- case "refresh_dashboard":
1072
- 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);
1073
520
  }
1074
521
  }
1075
522
  function errorResponse(code) {
1076
- return {
1077
- protocolVersion: GATEWAY_CONTROL_PROTOCOL_VERSION,
1078
- ok: false,
1079
- error: { code, message: WIRE_ERROR_MESSAGES[code] },
1080
- };
523
+ return { protocolVersion: 1, ok: false, error: { code, message: WIRE_ERROR_MESSAGES[code] } };
1081
524
  }
1082
525
  async function dispatch(request, handlers) {
1083
- let result;
526
+ const descriptor = descriptors[request.method];
527
+ const handler = handlers[descriptor.handler];
1084
528
  try {
1085
- switch (request.method) {
1086
- case "health":
1087
- result = await handlers.health();
1088
- break;
1089
- case "register_codex":
1090
- result = await handlers.registerCodex(request.params);
1091
- break;
1092
- case "unregister_codex":
1093
- result = await handlers.unregisterCodex(request.params);
1094
- break;
1095
- case "remove_stale_codex_registration":
1096
- result = await handlers.removeStaleCodexRegistration(request.params);
1097
- break;
1098
- case "select_claude":
1099
- result = await handlers.selectClaude(request.params);
1100
- break;
1101
- case "unselect_claude":
1102
- result = await handlers.unselectClaude(request.params);
1103
- break;
1104
- case "pair":
1105
- result = await handlers.pair(request.params);
1106
- break;
1107
- case "unpair":
1108
- result = await handlers.unpair(request.params);
1109
- break;
1110
- case "list_snapshot":
1111
- result = await handlers.listSnapshot();
1112
- break;
1113
- case "observe_snapshot":
1114
- result = await handlers.observeSnapshot();
1115
- break;
1116
- case "delivery_status":
1117
- result = await handlers.deliveryStatus(request.params);
1118
- break;
1119
- case "untrack":
1120
- result = await handlers.untrack(request.params);
1121
- break;
1122
- case "send_to_claude":
1123
- result = await handlers.sendToClaude(request.params);
1124
- break;
1125
- case "send_to_codex":
1126
- result = await handlers.sendToCodex(request.params);
1127
- break;
1128
- case "reply":
1129
- result = await handlers.reply(request.params);
1130
- break;
1131
- case "refresh_dashboard":
1132
- result = await handlers.refreshDashboard();
1133
- break;
1134
- }
529
+ const result = await (Object.keys(request.params).length === 0 ? handler() : handler(request.params));
530
+ return { protocolVersion: 1, ok: true, result };
1135
531
  }
1136
532
  catch {
1137
533
  return errorResponse("HANDLER_FAILURE");
1138
534
  }
1139
- if (!isResultForMethod(request.method, result)) {
1140
- return errorResponse("INVALID_HANDLER_RESPONSE");
1141
- }
1142
- return {
1143
- protocolVersion: GATEWAY_CONTROL_PROTOCOL_VERSION,
1144
- ok: true,
1145
- result,
1146
- };
1147
535
  }
1148
536
  function serializeResponse(response) {
1149
- const encoded = Buffer.from(`${JSON.stringify(response)}\n`, "utf8");
1150
- if (encoded.length <= GATEWAY_CONTROL_MAX_RESPONSE_BYTES)
1151
- return encoded;
1152
- return Buffer.from(`${JSON.stringify(errorResponse("RESPONSE_TOO_LARGE"))}\n`, "utf8");
1153
- }
1154
- function respond(socket, response) {
1155
- if (socket.destroyed)
1156
- return;
1157
- let encoded;
1158
537
  try {
1159
- 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`);
1160
540
  }
1161
541
  catch {
1162
- encoded = Buffer.from(`${JSON.stringify(errorResponse("INVALID_HANDLER_RESPONSE"))}\n`, "utf8");
542
+ return Buffer.from(`${JSON.stringify(errorResponse("INVALID_HANDLER_RESPONSE"))}\n`);
1163
543
  }
1164
- socket.end(encoded);
1165
544
  }
1166
- function handleConnection(socket, handlers, requestTimeoutMs) {
1167
- let buffered = Buffer.alloc(0);
1168
- let completed = false;
1169
- const complete = (response) => {
1170
- if (completed)
1171
- return;
1172
- completed = true;
1173
- socket.pause();
1174
- socket.setTimeout(0);
1175
- respond(socket, response);
1176
- };
1177
- socket.setTimeout(requestTimeoutMs);
1178
- socket.once("timeout", () => complete(errorResponse("REQUEST_TIMEOUT")));
1179
- socket.on("error", () => {
1180
- 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")));
1181
583
  });
1182
- socket.on("data", (chunk) => {
1183
- if (completed)
1184
- return;
1185
- if (buffered.length + chunk.length > GATEWAY_CONTROL_MAX_FRAME_BYTES) {
1186
- complete(errorResponse("FRAME_TOO_LARGE"));
1187
- return;
1188
- }
1189
- buffered = Buffer.concat([buffered, chunk]);
1190
- const newline = buffered.indexOf(0x0a);
1191
- if (newline < 0)
1192
- return;
1193
- const trailing = buffered.subarray(newline + 1).toString("utf8");
1194
- if (trailing.trim().length > 0) {
1195
- complete(errorResponse("MULTIPLE_FRAMES"));
1196
- return;
1197
- }
1198
- 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);
1199
593
  let request;
1200
594
  try {
1201
- request = parseRequestFrame(frame);
595
+ request = parseRequestObject(decodeJson(frame, "INVALID_JSON"));
1202
596
  }
1203
597
  catch (error) {
1204
- const code = error instanceof ProtocolFault ? error.code : "INVALID_REQUEST";
1205
- complete(errorResponse(code));
598
+ respond(socket, errorResponse(error instanceof ProtocolFault ? error.code : "INVALID_REQUEST"));
1206
599
  return;
1207
600
  }
1208
- completed = true;
1209
- socket.pause();
1210
- socket.setTimeout(0);
1211
- void dispatch(request, handlers).then((response) => respond(socket, response), () => respond(socket, errorResponse("HANDLER_FAILURE")));
1212
- });
1213
- socket.once("end", () => {
1214
- 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
1215
611
  socket.destroy();
1216
612
  });
1217
613
  }
1218
- function controlTransportError(code, ambiguous = false) {
1219
- const messages = {
1220
- UNSUPPORTED_PLATFORM: "Unix-domain gateway control sockets are unavailable.",
1221
- INVALID_STATE_DIR: "The gateway state directory is invalid.",
1222
- INSECURE_STATE_DIR: "The gateway state directory is not private to this user.",
1223
- INVALID_SOCKET_PATH: "The gateway control socket path is invalid.",
1224
- UNSAFE_SOCKET_TARGET: "The gateway control socket target is unsafe.",
1225
- SOCKET_IN_USE: "The gateway control socket is already served by a live process.",
1226
- SOCKET_PROBE_FAILED: "The gateway control socket could not be checked safely.",
1227
- SOCKET_BIND_FAILED: "The gateway control socket could not be bound.",
1228
- SOCKET_PERMISSION_FAILED: "The gateway control socket permissions could not be secured.",
1229
- SOCKET_CLEANUP_CONFLICT: "The gateway control socket path changed during cleanup.",
1230
- CONTROL_CONNECT_FAILED: "The gateway control socket could not be reached.",
1231
- CONTROL_TIMEOUT: "The gateway control request timed out.",
1232
- CONTROL_RESPONSE_TOO_LARGE: "The gateway control response exceeds the client limit.",
1233
- CONTROL_INVALID_RESPONSE: "The gateway returned an invalid control response. Restart the broker, then retry.",
1234
- CONTROL_CONNECTION_CLOSED: "The gateway closed before returning a control response.",
1235
- CONTROL_OUTCOME_AMBIGUOUS: "The gateway may have applied the control mutation before the response was lost; do not retry automatically.",
1236
- };
1237
- return new GatewayControlTransportError(code, messages[code] ?? "The gateway control transport failed.", ambiguous);
1238
- }
1239
- function isNonIdempotentControlMethod(method) {
1240
- return (method === "register_codex" ||
1241
- method === "unregister_codex" ||
1242
- method === "remove_stale_codex_registration" ||
1243
- method === "select_claude" ||
1244
- method === "unselect_claude" ||
1245
- method === "pair" ||
1246
- method === "unpair" ||
1247
- method === "send_to_claude" ||
1248
- method === "send_to_codex" ||
1249
- method === "reply");
1250
- }
1251
614
  async function optionalLstat(target) {
1252
615
  try {
1253
616
  return await lstat(target);
@@ -1258,15 +621,19 @@ async function optionalLstat(target) {
1258
621
  throw error;
1259
622
  }
1260
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
+ }
1261
631
  async function validateSocketLocation(stateDir, socketPath) {
1262
632
  if (process.platform === "win32") {
1263
633
  throw controlTransportError("UNSUPPORTED_PLATFORM");
1264
634
  }
1265
- if (!path.isAbsolute(stateDir) ||
1266
- path.resolve(stateDir) !== stateDir ||
1267
- !path.isAbsolute(socketPath) ||
1268
- path.resolve(socketPath) !== socketPath ||
1269
- Buffer.byteLength(socketPath, "utf8") > MAX_SOCKET_PATH_BYTES) {
635
+ assertSocketPath(socketPath);
636
+ if (!path.isAbsolute(stateDir) || path.resolve(stateDir) !== stateDir) {
1270
637
  throw controlTransportError("INVALID_SOCKET_PATH");
1271
638
  }
1272
639
  let stateInfo;
@@ -1388,8 +755,7 @@ async function removeOwnedSocket(socketPath, identity) {
1388
755
  await unlink(socketPath);
1389
756
  }
1390
757
  catch {
1391
- // The server is already closed. A failed best-effort exact unlink must not
1392
- // broaden cleanup to any replacement path.
758
+ // Never broaden cleanup to a replacement path.
1393
759
  }
1394
760
  }
1395
761
  async function protectSocketReplacement(socketPath, identity) {
@@ -1442,24 +808,17 @@ async function restoreSocketReplacement(socketPath, replacement) {
1442
808
  throw controlTransportError("SOCKET_CLEANUP_CONFLICT");
1443
809
  }
1444
810
  }
1445
- function validServerTimeout(value) {
1446
- return Number.isInteger(value) && value >= 50 && value <= 30_000;
1447
- }
1448
- function validClientTimeout(value) {
1449
- return Number.isInteger(value) && value >= 50 && value <= 90_000;
1450
- }
1451
811
  export async function startGatewayControlServer(options) {
1452
812
  await validateSocketLocation(options.stateDir, options.socketPath);
1453
813
  await prepareSocketTarget(options.socketPath);
1454
814
  const requestTimeoutMs = options.requestTimeoutMs ?? GATEWAY_CONTROL_DEFAULT_TIMEOUT_MS;
1455
- if (!validServerTimeout(requestTimeoutMs)) {
815
+ if (!Number.isInteger(requestTimeoutMs) ||
816
+ requestTimeoutMs < 50 ||
817
+ requestTimeoutMs > 30_000) {
1456
818
  throw controlTransportError("INVALID_SOCKET_PATH");
1457
819
  }
1458
820
  const connections = new Set();
1459
821
  const server = net.createServer({ allowHalfOpen: true }, (socket) => {
1460
- // Directory ownership proves same-user access, not that every caller is a
1461
- // trusted agent. Bound idle and in-flight peers before allocating parser or
1462
- // handler state so a local peer cannot grow this set without limit.
1463
822
  if (connections.size >= GATEWAY_CONTROL_MAX_CONNECTIONS) {
1464
823
  socket.once("error", () => socket.destroy());
1465
824
  socket.end(serializeResponse(errorResponse("SERVER_BUSY")), () => socket.destroy());
@@ -1498,8 +857,8 @@ export async function startGatewayControlServer(options) {
1498
857
  }
1499
858
  }
1500
859
  catch {
1501
- for (const connection of connections)
1502
- connection.destroy();
860
+ for (const socket of connections)
861
+ socket.destroy();
1503
862
  await closeNativeServer(server);
1504
863
  if (identity)
1505
864
  await removeOwnedSocket(options.socketPath, identity);
@@ -1511,8 +870,8 @@ export async function startGatewayControlServer(options) {
1511
870
  const close = () => {
1512
871
  closePromise ??= (async () => {
1513
872
  const replacement = await protectSocketReplacement(options.socketPath, ownedIdentity);
1514
- for (const connection of connections)
1515
- connection.destroy();
873
+ for (const socket of connections)
874
+ socket.destroy();
1516
875
  await closeNativeServer(server);
1517
876
  await removeOwnedSocket(options.socketPath, ownedIdentity);
1518
877
  await restoreSocketReplacement(options.socketPath, replacement);
@@ -1528,117 +887,63 @@ export async function startGatewayControlServer(options) {
1528
887
  close,
1529
888
  };
1530
889
  }
1531
- function validateResponseForMethod(method, value) {
1532
- if (!isRecord(value) || value.protocolVersion !== 1) {
890
+ function decodeResponse(method, value) {
891
+ if (!isRecord(value) || value.protocolVersion !== 1)
1533
892
  throw controlTransportError("CONTROL_INVALID_RESPONSE");
1534
- }
1535
893
  if (value.ok === false) {
1536
- if (!hasExactKeys(value, ["protocolVersion", "ok", "error"]) ||
1537
- !isRecord(value.error) ||
1538
- !hasExactKeys(value.error, ["code", "message"]) ||
1539
- typeof value.error.code !== "string" ||
1540
- !Object.hasOwn(WIRE_ERROR_MESSAGES, value.error.code) ||
1541
- value.error.message !==
1542
- 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])
1543
897
  throw controlTransportError("CONTROL_INVALID_RESPONSE");
1544
- }
1545
898
  return value;
1546
899
  }
1547
- if (value.ok !== true ||
1548
- !hasExactKeys(value, ["protocolVersion", "ok", "result"]) ||
1549
- !isResultForMethod(method, value.result)) {
900
+ if (!shape(value, { protocolVersion: oneOf(1), ok: oneOf(true), result: descriptors[method].result }))
1550
901
  throw controlTransportError("CONTROL_INVALID_RESPONSE");
1551
- }
1552
902
  return value;
1553
903
  }
1554
904
  export async function sendGatewayControlRequest(options) {
1555
- if (!path.isAbsolute(options.socketPath) ||
1556
- path.resolve(options.socketPath) !== options.socketPath ||
1557
- Buffer.byteLength(options.socketPath, "utf8") > MAX_SOCKET_PATH_BYTES) {
1558
- throw controlTransportError("INVALID_SOCKET_PATH");
1559
- }
1560
- const timeoutMs = options.timeoutMs ?? GATEWAY_CONTROL_DEFAULT_TIMEOUT_MS;
1561
- const maxResponseBytes = options.maxResponseBytes ?? GATEWAY_CONTROL_MAX_RESPONSE_BYTES;
1562
- if (!validClientTimeout(timeoutMs) ||
1563
- !Number.isInteger(maxResponseBytes) ||
1564
- maxResponseBytes < 256 ||
1565
- 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)
1566
910
  throw controlTransportError("CONTROL_INVALID_RESPONSE");
1567
- }
1568
- const normalized = parseRequestObject(options.request);
1569
- if (normalized.method !== options.request.method) {
1570
- throw new ProtocolFault("INVALID_REQUEST");
1571
- }
1572
- const requestFrame = Buffer.from(`${JSON.stringify(normalized)}\n`, "utf8");
1573
- 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)
1574
914
  throw new ProtocolFault("FRAME_TOO_LARGE");
1575
- }
1576
915
  return await new Promise((resolve, reject) => {
1577
916
  const socket = net.createConnection(options.socketPath);
1578
917
  let settled = false;
1579
918
  let writeStarted = false;
1580
- let buffered = Buffer.alloc(0);
1581
- const mutation = isNonIdempotentControlMethod(normalized.method);
919
+ const mutation = descriptors[request.method].mutation;
1582
920
  const fail = (error) => {
1583
921
  if (settled)
1584
922
  return;
1585
923
  settled = true;
1586
924
  socket.destroy();
1587
- reject(mutation && writeStarted
1588
- ? controlTransportError("CONTROL_OUTCOME_AMBIGUOUS", true)
1589
- : error);
1590
- };
1591
- const succeed = (response) => {
1592
- if (settled)
1593
- return;
1594
- settled = true;
1595
- socket.destroy();
1596
- resolve(response);
925
+ reject(mutation && writeStarted ? controlTransportError("CONTROL_OUTCOME_AMBIGUOUS", true) : error);
1597
926
  };
1598
- socket.setTimeout(timeoutMs);
1599
- socket.once("connect", () => {
1600
- // From this point onward the peer may receive and execute all or part of
1601
- // the frame. Losing the response to a mutation is therefore ambiguous,
1602
- // never a safe retry signal.
1603
- writeStarted = true;
1604
- socket.write(requestFrame);
1605
- });
1606
- socket.once("timeout", () => fail(controlTransportError("CONTROL_TIMEOUT")));
1607
- socket.once("error", () => fail(controlTransportError("CONTROL_CONNECT_FAILED")));
1608
- socket.on("data", (chunk) => {
1609
- if (settled)
1610
- return;
1611
- if (buffered.length + chunk.length > maxResponseBytes) {
1612
- fail(controlTransportError("CONTROL_RESPONSE_TOO_LARGE"));
1613
- return;
1614
- }
1615
- buffered = Buffer.concat([buffered, chunk]);
1616
- const newline = buffered.indexOf(0x0a);
1617
- if (newline < 0)
1618
- return;
1619
- if (buffered.subarray(newline + 1).toString("utf8").trim().length > 0) {
1620
- fail(controlTransportError("CONTROL_INVALID_RESPONSE"));
1621
- return;
1622
- }
1623
- let parsed;
927
+ socket.setTimeout(timeout);
928
+ socket.once("connect", () => { writeStarted = true; socket.write(frame); });
929
+ void readOneFrame(socket, maximum).then((responseFrame) => {
1624
930
  try {
1625
- parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffered.subarray(0, newline)));
1626
- }
1627
- catch {
1628
- fail(controlTransportError("CONTROL_INVALID_RESPONSE"));
1629
- return;
1630
- }
1631
- try {
1632
- 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
+ }
1633
937
  }
1634
938
  catch {
1635
939
  fail(controlTransportError("CONTROL_INVALID_RESPONSE"));
1636
940
  }
1637
- });
1638
- socket.once("close", () => {
1639
- if (!settled) {
1640
- fail(controlTransportError("CONTROL_CONNECTION_CLOSED"));
1641
- }
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));
1642
947
  });
1643
948
  });
1644
949
  }