@robota-sdk/agent-transport 3.0.0-beta.82 → 3.0.0-beta.84

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 (62) hide show
  1. package/CHANGELOG.md +795 -0
  2. package/README.md +80 -118
  3. package/dist/browser/client.d.ts +2 -2
  4. package/dist/browser/client.js +1 -1
  5. package/dist/browser/index.d.ts +87 -13
  6. package/dist/browser/index.d.ts.map +1 -1
  7. package/dist/browser/index.js +1 -1
  8. package/dist/browser/index.js.map +1 -1
  9. package/dist/browser/observer-messages-CEeisRaF.js +2 -0
  10. package/dist/browser/observer-messages-CEeisRaF.js.map +1 -0
  11. package/dist/browser/observer-messages-Dnff2Tdb.d.ts +471 -0
  12. package/dist/browser/observer-messages-Dnff2Tdb.d.ts.map +1 -0
  13. package/dist/node/index.cjs +1 -1
  14. package/dist/node/index.d.cts +308 -16
  15. package/dist/node/index.d.cts.map +1 -1
  16. package/dist/node/index.d.ts +308 -16
  17. package/dist/node/index.d.ts.map +1 -1
  18. package/dist/node/index.js +1 -1
  19. package/dist/node/index.js.map +1 -1
  20. package/dist/node/node.cjs +1 -1
  21. package/dist/node/node.d.cts +69 -1
  22. package/dist/node/node.d.cts.map +1 -1
  23. package/dist/node/node.d.ts +70 -2
  24. package/dist/node/node.d.ts.map +1 -1
  25. package/dist/node/node.js +1 -1
  26. package/dist/node/node.js.map +1 -1
  27. package/package.json +17 -14
  28. package/src/__tests__/bearer-resource-gate.test.ts +120 -0
  29. package/src/__tests__/history-attached-budget.test.ts +216 -0
  30. package/src/__tests__/message-decoders.test.ts +496 -6
  31. package/src/__tests__/project-read-messages.test.ts +118 -0
  32. package/src/__tests__/session-detail-loop-status.test.ts +169 -0
  33. package/src/__tests__/session-directory-messages.test.ts +322 -0
  34. package/src/__tests__/session-error-classification.test.ts +113 -0
  35. package/src/__tests__/session-history-wire.test.ts +342 -0
  36. package/src/__tests__/session-message-handler-observe.test.ts +217 -0
  37. package/src/__tests__/session-message-handler.test.ts +139 -4
  38. package/src/__tests__/settings-messages.test.ts +193 -0
  39. package/src/__tests__/ws-broadcast-events.test.ts +10 -0
  40. package/src/client.ts +9 -1
  41. package/src/index.ts +18 -2
  42. package/src/loop-control-messages.ts +34 -0
  43. package/src/message-decoders.ts +238 -6
  44. package/src/node/bearer-resource-gate.ts +291 -0
  45. package/src/node/index.ts +13 -0
  46. package/src/observer-messages.ts +47 -0
  47. package/src/open-prompts.ts +76 -0
  48. package/src/outbound-delivery.ts +11 -2
  49. package/src/project-read-messages.ts +74 -0
  50. package/src/protocol-session.ts +23 -1
  51. package/src/session-directory-messages.ts +162 -0
  52. package/src/session-error-classification.ts +91 -0
  53. package/src/session-events.ts +100 -19
  54. package/src/session-message-handler.ts +110 -15
  55. package/src/session-query-messages.ts +123 -5
  56. package/src/session-resume-bridge.ts +8 -0
  57. package/src/settings-messages.ts +103 -0
  58. package/src/wire-messages.ts +238 -11
  59. package/dist/browser/message-decoders-BB8n-H2X.d.ts +0 -252
  60. package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +0 -1
  61. package/dist/browser/message-decoders-CDmDoZPR.js +0 -2
  62. package/dist/browser/message-decoders-CDmDoZPR.js.map +0 -1
@@ -14,6 +14,11 @@
14
14
  * owner and is tracked there, not duplicated in a transport package.
15
15
  */
16
16
 
17
+ import {
18
+ SESSION_CHANGE_REFUSAL_CODES,
19
+ SESSION_DELETE_REFUSAL_CODES,
20
+ } from '@robota-sdk/agent-interface-session';
21
+
17
22
  import type { TBackgroundControlAction, TClientMessage, TServerMessage } from './wire-messages.js';
18
23
 
19
24
  export type TMessageDecodeResult<TMessage> =
@@ -32,6 +37,7 @@ const isString = (v: unknown): v is string => typeof v === 'string';
32
37
  const isNonEmptyString = (v: unknown): v is string => typeof v === 'string' && v.length > 0;
33
38
  const isBoolean = (v: unknown): v is boolean => typeof v === 'boolean';
34
39
  const isFiniteNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
40
+ const isCount = (v: unknown): v is number => Number.isSafeInteger(v) && (v as number) >= 0;
35
41
  const isOptional =
36
42
  (check: (v: unknown) => boolean) =>
37
43
  (v: unknown): boolean =>
@@ -42,6 +48,24 @@ const oneOf =
42
48
  (members: readonly string[]) =>
43
49
  (v: unknown): boolean =>
44
50
  typeof v === 'string' && members.includes(v);
51
+ /** A record, not an array, so a turn source added to the union without one here fails to compile. */
52
+ const TURN_SOURCES: Readonly<
53
+ Record<Extract<TServerMessage, { type: 'turn_source' }>['source'], true>
54
+ > = { user: true, 'agent-wakeup': true, peer: true, external: true };
55
+ /** A history entry's envelope is this package's (`IWireHistoryEntry`); its `data` is the recorder's. */
56
+ const isWireHistoryEntry = (v: unknown): boolean =>
57
+ isRecord(v) &&
58
+ isString(v['id']) &&
59
+ isString(v['timestamp']) &&
60
+ isString(v['category']) &&
61
+ isString(v['type']);
62
+ const isWireHistoryEntries = (v: unknown): boolean =>
63
+ Array.isArray(v) && v.every(isWireHistoryEntry);
64
+ /** #3282 §4: one row of `agent_definitions` — name, one-line description, plain-words location. */
65
+ const isWireAgentDefinition = (v: unknown): boolean =>
66
+ isRecord(v) && isString(v['name']) && isString(v['description']) && isString(v['definedIn']);
67
+ const isWireAgentDefinitions = (v: unknown): boolean =>
68
+ Array.isArray(v) && v.every(isWireAgentDefinition);
45
69
 
46
70
  type TFieldCheck = (value: unknown) => boolean;
47
71
  type TVariantShape = Readonly<Record<string, TFieldCheck>>;
@@ -69,6 +93,7 @@ const isBackgroundTaskListFilter: TFieldCheck = (v) =>
69
93
  const isBackgroundTaskInput: TFieldCheck = (v) =>
70
94
  isRecord(v) && isOptional(isString)(v['prompt']) && isOptional(isString)(v['stdin']);
71
95
  const isBackgroundTaskLogCursor: TFieldCheck = (v) => isRecord(v) && isFiniteNumber(v['offset']);
96
+ const isExecutionDetailCursor: TFieldCheck = (v) => isRecord(v) && isCount(v['offset']);
72
97
  const isPermissionResultValue: TFieldCheck = (v) =>
73
98
  isBoolean(v) || v === 'allow-session' || v === 'allow-project';
74
99
  const isActionResponse: TFieldCheck = (v) =>
@@ -79,14 +104,149 @@ const isActionResponse: TFieldCheck = (v) =>
79
104
  isOptional(isString)(v['text'])) ||
80
105
  (v['type'] === 'cancelled' && v['values'] === undefined && v['text'] === undefined));
81
106
 
107
+ type TSettingsPatch = Extract<TClientMessage, { type: 'update-settings' }>['patch'];
108
+ /** Keyed by `field`, so a patch variant added to the union without a shape here fails to compile. */
109
+ const SETTINGS_PATCH_SHAPES: Readonly<Record<TSettingsPatch['field'], TVariantShape>> = {
110
+ language: { language: isNonEmptyString },
111
+ outputStyle: { styleId: isNonEmptyString },
112
+ preset: { presetId: isNonEmptyString },
113
+ permissionMode: { mode: isNonEmptyString },
114
+ sandbox: { enabled: isBoolean },
115
+ removePermissionRule: {
116
+ scope: isNonEmptyString,
117
+ kind: oneOf(['allow', 'deny', 'ask']),
118
+ pattern: isNonEmptyString,
119
+ },
120
+ mcpServerEnabled: { serverId: isNonEmptyString, enabled: isBoolean },
121
+ reloadMcpServers: {},
122
+ pluginEnabled: { pluginId: isNonEmptyString, enabled: isBoolean },
123
+ reloadPlugins: {},
124
+ installPlugin: { pluginId: isNonEmptyString },
125
+ uninstallPlugin: { pluginId: isNonEmptyString },
126
+ // #3282 §4b: "Providers & Models" — Use, Model, Delete.
127
+ providerProfile: { profileName: isNonEmptyString },
128
+ providerModel: { profileName: isNonEmptyString, modelId: isNonEmptyString },
129
+ deleteProviderProfile: { profileName: isNonEmptyString },
130
+ };
131
+ const isSettingsPatch: TFieldCheck = (v) => {
132
+ if (!isRecord(v) || !isString(v['field'])) return false;
133
+ const field = v['field'];
134
+ if (!Object.prototype.hasOwnProperty.call(SETTINGS_PATCH_SHAPES, field)) return false;
135
+ const shape = SETTINGS_PATCH_SHAPES[field as TSettingsPatch['field']];
136
+ return Object.entries(shape).every(([key, check]) => check(v[key]));
137
+ };
138
+
139
+ type TWaitingLoopStopOutcome = Extract<TServerMessage, { type: 'waiting_loop_stop' }>['outcome'];
140
+ /** Keyed by `kind`, so an outcome added to the union without a shape here fails to compile. */
141
+ const WAITING_LOOP_STOP_OUTCOME_SHAPES: Readonly<
142
+ Record<TWaitingLoopStopOutcome['kind'], TVariantShape>
143
+ > = {
144
+ none: {},
145
+ several: { message: isString },
146
+ stopped: { loopId: isNonEmptyString, message: isOptional(isString) },
147
+ failed: { loopId: isOptional(isNonEmptyString), message: isString },
148
+ };
149
+ const isWaitingLoopStopOutcome: TFieldCheck = (v) => {
150
+ if (!isRecord(v) || !isString(v['kind'])) return false;
151
+ const kind = v['kind'];
152
+ if (!Object.prototype.hasOwnProperty.call(WAITING_LOOP_STOP_OUTCOME_SHAPES, kind)) return false;
153
+ const shape = WAITING_LOOP_STOP_OUTCOME_SHAPES[kind as TWaitingLoopStopOutcome['kind']];
154
+ return Object.entries(shape).every(([field, check]) => check(v[field]));
155
+ };
156
+
157
+ /** Decode a `kind`-discriminated result union (the `TWaitingLoopStopOutcome` convention) from a shape
158
+ * map keyed by `kind`. */
159
+ function isKindDiscriminated(shapes: Readonly<Record<string, TVariantShape>>): TFieldCheck {
160
+ return (v) => {
161
+ if (!isRecord(v) || !isString(v['kind'])) return false;
162
+ const shape = Object.prototype.hasOwnProperty.call(shapes, v['kind']) ? shapes[v['kind']] : undefined;
163
+ if (shape === undefined) return false;
164
+ return Object.entries(shape).every(([field, check]) => check(v[field]));
165
+ };
166
+ }
167
+
168
+ // #3282 §4c: the Project panel's reads. Each result is the session method's own return value, crossing
169
+ // the wire unchanged (the `TWaitingLoopStopOutcome` convention above) — decoded from a `kind`-keyed
170
+ // shape map the same way.
171
+ const PROJECT_FILE_STATUSES = [
172
+ 'Added',
173
+ 'Modified',
174
+ 'Deleted',
175
+ 'Renamed',
176
+ 'Copied',
177
+ 'Untracked',
178
+ 'Conflicted',
179
+ ] as const;
180
+ const isProjectStatusFile: TFieldCheck = (v) =>
181
+ isRecord(v) &&
182
+ isNonEmptyString(v['path']) &&
183
+ oneOf(PROJECT_FILE_STATUSES)(v['status']) &&
184
+ isOptional(isNonEmptyString)(v['renamedFrom']) &&
185
+ isOptional(isFiniteNumber)(v['added']) &&
186
+ isOptional(isFiniteNumber)(v['removed']);
187
+ const isProjectStatusFiles: TFieldCheck = (v) => Array.isArray(v) && v.every(isProjectStatusFile);
188
+
189
+ type TProjectStatusReadResult = Extract<TServerMessage, { type: 'project_status' }>['result'];
190
+ const PROJECT_STATUS_READ_SHAPES: Readonly<Record<TProjectStatusReadResult['kind'], TVariantShape>> = {
191
+ status: {
192
+ branch: isOptional(isString),
193
+ unborn: isBoolean,
194
+ files: isProjectStatusFiles,
195
+ truncated: isBoolean,
196
+ },
197
+ 'not-a-repository': {},
198
+ failed: { message: isString },
199
+ };
200
+ const isProjectStatusRead = isKindDiscriminated(PROJECT_STATUS_READ_SHAPES);
201
+
202
+ const DIFF_LINE_TYPES = ['add', 'remove', 'context', 'hunk'] as const;
203
+ const isDiffLine: TFieldCheck = (v) =>
204
+ isRecord(v) && oneOf(DIFF_LINE_TYPES)(v['type']) && isString(v['text']) && isFiniteNumber(v['lineNumber']);
205
+ const isDiffLines: TFieldCheck = (v) => Array.isArray(v) && v.every(isDiffLine);
206
+
207
+ type TProjectDiffReadResult = Extract<TServerMessage, { type: 'project_diff' }>['result'];
208
+ const PROJECT_DIFF_READ_SHAPES: Readonly<Record<TProjectDiffReadResult['kind'], TVariantShape>> = {
209
+ diff: { diffLines: isDiffLines, truncated: isBoolean },
210
+ 'not-a-repository': {},
211
+ 'outside-workspace': {},
212
+ failed: { message: isString },
213
+ };
214
+ const isProjectDiffRead = isKindDiscriminated(PROJECT_DIFF_READ_SHAPES);
215
+
216
+ type TProjectMemoryReadResult = Extract<TServerMessage, { type: 'project_memory' }>['result'];
217
+ const PROJECT_MEMORY_READ_SHAPES: Readonly<Record<TProjectMemoryReadResult['kind'], TVariantShape>> = {
218
+ memory: { content: isString, path: isNonEmptyString, truncated: isBoolean },
219
+ unavailable: { message: isString },
220
+ };
221
+ const isProjectMemoryRead = isKindDiscriminated(PROJECT_MEMORY_READ_SHAPES);
222
+
82
223
  /** One entry per `TClientMessage` variant — a variant added to the union without one fails the test. */
83
224
  export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVariantShape>> = {
84
225
  submit: { prompt: isNonEmptyString },
85
- command: { name: isNonEmptyString, args: isOptional(isString) },
226
+ command: {
227
+ name: isNonEmptyString,
228
+ args: isOptional(isString),
229
+ requestId: isOptional(isNonEmptyString),
230
+ },
86
231
  abort: {},
87
232
  'cancel-queue': {},
88
233
  'get-messages': {},
234
+ 'get-history': { fromIndex: isOptional(isCount) },
235
+ 'get-prompts': {},
89
236
  'get-context': {},
237
+ 'get-commands': {},
238
+ 'get-status': {},
239
+ 'list-models': { requestId: isNonEmptyString },
240
+ 'get-agent-definitions': { requestId: isNonEmptyString },
241
+ 'list-sessions': { requestId: isNonEmptyString },
242
+ 'new-session': { requestId: isOptional(isNonEmptyString) },
243
+ 'switch-session': { sessionId: isNonEmptyString, requestId: isOptional(isNonEmptyString) },
244
+ 'rename-session': {
245
+ sessionId: isNonEmptyString,
246
+ name: isNonEmptyString,
247
+ requestId: isNonEmptyString,
248
+ },
249
+ 'delete-session': { sessionId: isNonEmptyString, requestId: isNonEmptyString },
90
250
  'get-usage-report': {},
91
251
  'get-personal-usage-report': {
92
252
  requestId: isNonEmptyString,
@@ -100,6 +260,15 @@ export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVar
100
260
  'get-executing': {},
101
261
  'get-pending': {},
102
262
  'get-execution-workspace': {},
263
+ 'read-execution-detail': {
264
+ requestId: isNonEmptyString,
265
+ entryId: isNonEmptyString,
266
+ cursor: isOptional(isExecutionDetailCursor),
267
+ },
268
+ 'stop-waiting-loop': { requestId: isNonEmptyString },
269
+ 'project-status': { requestId: isNonEmptyString },
270
+ 'project-diff': { requestId: isNonEmptyString, path: isNonEmptyString },
271
+ 'project-memory': { requestId: isNonEmptyString },
103
272
  'get-background-tasks': { filter: isOptional(isBackgroundTaskListFilter) },
104
273
  'get-background-task': { taskId: isNonEmptyString },
105
274
  'get-background-job-groups': {},
@@ -116,6 +285,8 @@ export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVar
116
285
  'ask-response': { id: isNonEmptyString, response: isActionResponse },
117
286
  resume: { lastSeq: isFiniteNumber },
118
287
  ack: { seq: isFiniteNumber },
288
+ 'get-settings': { requestId: isNonEmptyString },
289
+ 'update-settings': { requestId: isNonEmptyString, patch: isSettingsPatch },
119
290
  };
120
291
 
121
292
  const authored: TVariantShape = { driverId: isOptional(isString) };
@@ -129,10 +300,58 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
129
300
  thinking: { isThinking: isBoolean, ...authored },
130
301
  complete: { result: isRecord, ...authored },
131
302
  interrupted: { result: isRecord, ...authored },
132
- error: { message: isString, ...authored },
133
- command_result: { name: isString, message: isString, success: isBoolean },
134
- messages: { messages: isRecordArray },
303
+ error: {
304
+ message: isString,
305
+ code: isOptional(oneOf(['auth', 'rate_limit', 'model_unavailable', 'network', 'provider'])),
306
+ provider: isOptional(isString),
307
+ retryAfterSeconds: isOptional(isFiniteNumber),
308
+ model: isOptional(isString),
309
+ ...authored,
310
+ },
311
+ command_result: {
312
+ name: isString,
313
+ message: isString,
314
+ success: isBoolean,
315
+ requestId: isOptional(isString),
316
+ },
317
+ messages: { messages: isRecordArray, display: isOptional(isRecordArray), ...authored },
318
+ history: { startIndex: isCount, total: isCount, entries: isWireHistoryEntries },
135
319
  context: { state: isRecord },
320
+ history_changed: {},
321
+ turn_source: { source: oneOf(Object.keys(TURN_SOURCES)) },
322
+ commands: { commands: isRecordArray, skills: isRecordArray },
323
+ session_status: { status: isRecord },
324
+ model_list: {
325
+ requestId: isNonEmptyString,
326
+ groups: isRecordArray,
327
+ currentProfile: isOptional(isString),
328
+ currentModel: isString,
329
+ },
330
+ agent_definitions: {
331
+ requestId: isNonEmptyString,
332
+ agents: isWireAgentDefinitions,
333
+ current: isNonEmptyString,
334
+ },
335
+ sessions: { requestId: isNonEmptyString, listing: isRecord },
336
+ sessions_error: {
337
+ requestId: isNonEmptyString,
338
+ code: oneOf(['not_available', 'list_failed']),
339
+ message: isString,
340
+ },
341
+ session_switched: { event: isRecord },
342
+ session_change_failed: {
343
+ code: oneOf(SESSION_CHANGE_REFUSAL_CODES),
344
+ message: isString,
345
+ requestId: isOptional(isString),
346
+ },
347
+ session_renamed_in_list: { requestId: isNonEmptyString, sessionId: isString, name: isString },
348
+ session_rename_failed: { requestId: isNonEmptyString, message: isString },
349
+ session_deleted: { requestId: isNonEmptyString, sessionId: isString },
350
+ session_delete_failed: {
351
+ requestId: isNonEmptyString,
352
+ code: oneOf(SESSION_DELETE_REFUSAL_CODES),
353
+ message: isString,
354
+ },
136
355
  usage_report: { report: isRecord },
137
356
  personal_usage_report: { requestId: isNonEmptyString, report: isRecord },
138
357
  personal_usage_report_error: {
@@ -152,8 +371,15 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
152
371
  message: isString,
153
372
  },
154
373
  executing: { executing: isBoolean },
155
- pending: { pending: (v) => v === null || isString(v) },
374
+ // An older host sends no count; a client then counts the one prompt it can see.
375
+ pending: { pending: (v) => v === null || isString(v), pendingCount: isOptional(isCount) },
156
376
  execution_workspace_event: { snapshot: isRecord },
377
+ execution_detail: { requestId: isNonEmptyString, page: isRecord },
378
+ execution_detail_error: { requestId: isNonEmptyString, message: isString },
379
+ waiting_loop_stop: { requestId: isNonEmptyString, outcome: isWaitingLoopStopOutcome },
380
+ project_status: { requestId: isNonEmptyString, result: isProjectStatusRead },
381
+ project_diff: { requestId: isNonEmptyString, result: isProjectDiffRead },
382
+ project_memory: { requestId: isNonEmptyString, result: isProjectMemoryRead },
157
383
  background_task_event: { event: isRecord },
158
384
  background_job_group_event: { event: isRecord },
159
385
  plan_event: { event: isRecord },
@@ -176,8 +402,14 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
176
402
  success: isBoolean,
177
403
  message: isOptional(isString),
178
404
  },
179
- protocol_error: { message: isString },
405
+ protocol_error: { message: isString, requestId: isOptional(isString) },
180
406
  resume_gap: {},
407
+ settings: { requestId: isNonEmptyString, settings: isRecord },
408
+ settings_error: {
409
+ requestId: isNonEmptyString,
410
+ code: oneOf(['not_available', 'invalid', 'refused', 'update_failed']),
411
+ message: isString,
412
+ },
181
413
  };
182
414
 
183
415
  function decodeVariant<TMessage extends { type: string }>(
@@ -0,0 +1,291 @@
1
+ /**
2
+ * The shared admission gate of an OAuth resource server reached over HTTP: RFC 6750 bearer challenges,
3
+ * RFC 9728 protected-resource metadata, the public-URL name checks, and a failure-only throttle per
4
+ * peer address. Every remote HTTP carrier that admits by access token composes these pieces, so the
5
+ * rules are one set of code rather than one per carrier.
6
+ *
7
+ * The host is reached through its PUBLIC URL, usually behind a reverse proxy, so every name the gate
8
+ * checks comes from that URL rather than from the listener: the Host and Origin a request must carry
9
+ * (the proxy must preserve Host) and the paths it serves. The listener's own address is never a name a
10
+ * client uses.
11
+ *
12
+ * Refusals answer with an empty body and, for a token problem, only the standard challenge: an error
13
+ * code from RFC 6750 and where the metadata lives. Only failures are counted, so a peer holding a valid
14
+ * token is never throttled, however many failures share its address. What a carrier reports about a
15
+ * refused peer is a coarse address class, never the address.
16
+ *
17
+ * This module binds no listener; a carrier owns its socket and calls these per request.
18
+ */
19
+
20
+ import { BlockList, isIP } from 'node:net';
21
+
22
+ import type { IncomingMessage, ServerResponse } from 'node:http';
23
+
24
+ const WELL_KNOWN_PREFIX = '/.well-known/oauth-protected-resource';
25
+ /** Window over which one address's failures are counted. */
26
+ const FAILURE_WINDOW_MS = 60_000;
27
+ /** Failures one address may have in a window before its further failures answer 429. */
28
+ const MAX_FAILURES_PER_WINDOW = 20;
29
+ /** Addresses tracked at once; the oldest is forgotten first, so a spray cannot grow memory. */
30
+ const MAX_TRACKED_ADDRESSES = 4096;
31
+ /** RFC 6749 scope-token characters: printable ASCII except space, `"` and `\`. */
32
+ const SCOPE_TOKEN = /^[\x21\x23-\x5b\x5d-\x7e]+$/;
33
+
34
+ /** A coarse class of a peer's address; the address itself is never reported. */
35
+ export type TRemoteAddressClass = 'loopback' | 'private' | 'public' | 'unknown';
36
+
37
+ /** Where a refused request came from, and whether its address is over the failure budget. */
38
+ export interface IBearerFailure {
39
+ readonly remote: TRemoteAddressClass;
40
+ readonly throttled: boolean;
41
+ readonly retryAfterSeconds: number;
42
+ }
43
+
44
+ /** The public-facing identity of one resource server and its per-address failure budget. */
45
+ export interface IBearerResourceServer {
46
+ /** The parsed public URL; its path is the resource's base path. */
47
+ readonly url: URL;
48
+ /**
49
+ * Host and Origin must name the public URL. On a mismatch the 403 is written and false returned;
50
+ * nothing is counted, since no credential was judged.
51
+ */
52
+ checkNames(req: IncomingMessage, res: ServerResponse): boolean;
53
+ /** Count one failure against the request's peer. */
54
+ fail(req: IncomingMessage): IBearerFailure;
55
+ /** The request's peer class without counting anything. */
56
+ remote(req: IncomingMessage): TRemoteAddressClass;
57
+ }
58
+
59
+ /** The challenges and metadata of one protected resource (one audience). */
60
+ export interface IProtectedResource {
61
+ /** The RFC 9728 path clients fetch the metadata from. */
62
+ readonly wellKnownPath: string;
63
+ readonly metadata: string;
64
+ readonly challenges: {
65
+ readonly missing: string;
66
+ readonly invalid: string;
67
+ readonly scope: string;
68
+ };
69
+ }
70
+
71
+ const LOOPBACK_RANGES = new BlockList();
72
+ LOOPBACK_RANGES.addSubnet('127.0.0.0', 8, 'ipv4');
73
+ LOOPBACK_RANGES.addAddress('::1', 'ipv6');
74
+ const PRIVATE_RANGES = new BlockList();
75
+ PRIVATE_RANGES.addSubnet('10.0.0.0', 8, 'ipv4');
76
+ PRIVATE_RANGES.addSubnet('172.16.0.0', 12, 'ipv4');
77
+ PRIVATE_RANGES.addSubnet('192.168.0.0', 16, 'ipv4');
78
+ PRIVATE_RANGES.addSubnet('169.254.0.0', 16, 'ipv4');
79
+ PRIVATE_RANGES.addSubnet('100.64.0.0', 10, 'ipv4');
80
+ PRIVATE_RANGES.addSubnet('fc00::', 7, 'ipv6');
81
+ PRIVATE_RANGES.addSubnet('fe80::', 10, 'ipv6');
82
+
83
+ /** A literal address in one spelling: IPv4-mapped IPv6 folded to IPv4, IPv6 lowercased. */
84
+ function normalizeAddress(value: string | undefined): string | undefined {
85
+ if (value === undefined) return undefined;
86
+ const text = value.trim().toLowerCase();
87
+ const mapped = text.startsWith('::ffff:') ? text.slice('::ffff:'.length) : undefined;
88
+ if (mapped !== undefined && isIP(mapped) === 4) return mapped;
89
+ return isIP(text) === 0 ? undefined : text;
90
+ }
91
+
92
+ function classifyAddress(address: string | undefined): TRemoteAddressClass {
93
+ if (address === undefined) return 'unknown';
94
+ const family = isIP(address) === 4 ? 'ipv4' : 'ipv6';
95
+ if (LOOPBACK_RANGES.check(address, family)) return 'loopback';
96
+ if (PRIVATE_RANGES.check(address, family)) return 'private';
97
+ return 'public';
98
+ }
99
+
100
+ /** Parse a resource server's public URL: plain `https`, no credentials, query or fragment. */
101
+ export function parsePublicHttpsUrl(publicUrl: string, label: string): URL {
102
+ let url: URL;
103
+ try {
104
+ url = new URL(publicUrl);
105
+ } catch {
106
+ throw new Error(`${label} public URL must be an absolute https URL`);
107
+ }
108
+ if (url.protocol !== 'https:') throw new Error(`${label} public URL must use https`);
109
+ if (url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '') {
110
+ throw new Error(`${label} public URL must not carry credentials, a query or a fragment`);
111
+ }
112
+ return url;
113
+ }
114
+
115
+ interface IFailureWindow {
116
+ count: number;
117
+ startedAt: number;
118
+ }
119
+
120
+ /** Counts failures per address in fixed windows. Only failures are ever recorded here. */
121
+ function createFailureCounter(now: () => number) {
122
+ const windows = new Map<string, IFailureWindow>();
123
+ return (address: string): { throttled: boolean; retryAfterSeconds: number } => {
124
+ const at = now();
125
+ let entry = windows.get(address);
126
+ if (entry === undefined || at - entry.startedAt >= FAILURE_WINDOW_MS) {
127
+ windows.delete(address);
128
+ if (windows.size >= MAX_TRACKED_ADDRESSES) {
129
+ for (const [key, value] of windows) {
130
+ if (at - value.startedAt >= FAILURE_WINDOW_MS) windows.delete(key);
131
+ }
132
+ const oldest = windows.keys().next();
133
+ if (windows.size >= MAX_TRACKED_ADDRESSES && oldest.done !== true) {
134
+ windows.delete(oldest.value);
135
+ }
136
+ }
137
+ entry = { count: 0, startedAt: at };
138
+ windows.set(address, entry);
139
+ }
140
+ entry.count += 1;
141
+ const remainingMs = FAILURE_WINDOW_MS - (at - entry.startedAt);
142
+ return {
143
+ throttled: entry.count > MAX_FAILURES_PER_WINDOW,
144
+ retryAfterSeconds: Math.max(1, Math.ceil(remainingMs / 1000)),
145
+ };
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Build the public identity of a resource server. Throws when the public URL is not plain `https`,
151
+ * or a trusted proxy is not a literal address. `label` names the carrier in those messages.
152
+ */
153
+ export function createBearerResourceServer(options: {
154
+ readonly publicUrl: string;
155
+ /** Proxy addresses whose `X-Forwarded-For` is believed. Absent: the header is ignored. */
156
+ readonly trustedProxies?: readonly string[];
157
+ readonly label: string;
158
+ readonly now?: () => number;
159
+ }): IBearerResourceServer {
160
+ const url = parsePublicHttpsUrl(options.publicUrl, options.label);
161
+ const trustedProxies = new Set<string>();
162
+ for (const proxy of options.trustedProxies ?? []) {
163
+ const address = normalizeAddress(proxy);
164
+ if (address === undefined) {
165
+ throw new Error(`${options.label} trusted proxy must be a literal IP address`);
166
+ }
167
+ trustedProxies.add(address);
168
+ }
169
+ const expectedHosts = new Set([url.host]);
170
+ if (url.port === '') expectedHosts.add(`${url.hostname}:443`);
171
+ const failures = createFailureCounter(options.now ?? (() => performance.now()));
172
+
173
+ function remoteAddress(req: IncomingMessage): string | undefined {
174
+ const peer = normalizeAddress(req.socket.remoteAddress);
175
+ if (peer === undefined || !trustedProxies.has(peer)) return peer;
176
+ const header = req.headers['x-forwarded-for'];
177
+ const hops = (Array.isArray(header) ? header.join(',') : (header ?? '')).split(',');
178
+ // Walk from the proxy inward: the nearest hop not itself a trusted proxy is the client, as the
179
+ // trusted proxy saw it. Hops further left were written by the client and are not believed.
180
+ for (let index = hops.length - 1; index >= 0; index -= 1) {
181
+ const hop = normalizeAddress(hops[index]);
182
+ if (hop === undefined) return peer;
183
+ if (!trustedProxies.has(hop)) return hop;
184
+ }
185
+ return peer;
186
+ }
187
+
188
+ return {
189
+ url,
190
+ checkNames(req, res) {
191
+ const host = req.headers.host?.toLowerCase();
192
+ const origin = req.headers.origin;
193
+ if (
194
+ host === undefined ||
195
+ !expectedHosts.has(host) ||
196
+ (origin !== undefined && origin !== url.origin)
197
+ ) {
198
+ res.writeHead(403).end();
199
+ return false;
200
+ }
201
+ return true;
202
+ },
203
+ fail(req) {
204
+ const address = remoteAddress(req);
205
+ return { remote: classifyAddress(address), ...failures(address ?? 'unknown') };
206
+ },
207
+ remote(req) {
208
+ return classifyAddress(remoteAddress(req));
209
+ },
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Describe one protected resource: where its RFC 9728 metadata lives and the challenges that point
215
+ * there. Throws for a non-`https` issuer, or no scope or a scope a challenge cannot quote.
216
+ */
217
+ export function describeProtectedResource(options: {
218
+ /** The resource identifier (`aud`), an `https` URL. */
219
+ readonly resource: string;
220
+ readonly issuer: string;
221
+ readonly scopes: readonly string[];
222
+ readonly label: string;
223
+ }): IProtectedResource {
224
+ const url = parsePublicHttpsUrl(options.resource, options.label);
225
+ let issuer: URL;
226
+ try {
227
+ issuer = new URL(options.issuer);
228
+ } catch {
229
+ throw new Error(`${options.label} authorization issuer must be an absolute https URL`);
230
+ }
231
+ if (issuer.protocol !== 'https:') {
232
+ throw new Error(`${options.label} authorization issuer must use https`);
233
+ }
234
+ if (options.scopes.length === 0 || !options.scopes.every((scope) => SCOPE_TOKEN.test(scope))) {
235
+ throw new Error(`${options.label} authorization needs at least one valid scope`);
236
+ }
237
+ // RFC 9728 §3.1: the well-known segment goes between the host and the resource's path, with a
238
+ // lone terminating slash dropped.
239
+ const wellKnownPath = `${WELL_KNOWN_PREFIX}${url.pathname === '/' ? '' : url.pathname}`;
240
+ const metadataUrl = `${url.origin}${wellKnownPath}`;
241
+ const scopeList = options.scopes.join(' ');
242
+ return {
243
+ wellKnownPath,
244
+ metadata: JSON.stringify({
245
+ resource: options.resource,
246
+ authorization_servers: [options.issuer],
247
+ scopes_supported: [...options.scopes],
248
+ bearer_methods_supported: ['header'],
249
+ }),
250
+ challenges: {
251
+ missing: `Bearer resource_metadata="${metadataUrl}"`,
252
+ invalid: `Bearer error="invalid_token", resource_metadata="${metadataUrl}"`,
253
+ scope: `Bearer error="insufficient_scope", scope="${scopeList}", resource_metadata="${metadataUrl}"`,
254
+ },
255
+ };
256
+ }
257
+
258
+ /** Answer a metadata request: GET only, the JSON document, nothing else. */
259
+ export function serveProtectedResourceMetadata(
260
+ req: IncomingMessage,
261
+ res: ServerResponse,
262
+ resource: IProtectedResource,
263
+ ): void {
264
+ if (req.method !== 'GET') {
265
+ res.writeHead(405, { Allow: 'GET' }).end();
266
+ return;
267
+ }
268
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end(resource.metadata);
269
+ }
270
+
271
+ /**
272
+ * Answer a refused bearer token after its failure was counted: 429 once the peer is over budget,
273
+ * 403 `insufficient_scope` for a missing scope, otherwise 401 with the missing-token or
274
+ * `invalid_token` challenge. Always an empty body.
275
+ */
276
+ export function refuseBearerToken(
277
+ res: ServerResponse,
278
+ resource: IProtectedResource,
279
+ refusal: 'missing-token' | 'missing-scope' | 'invalid-token',
280
+ failure: IBearerFailure,
281
+ ): void {
282
+ if (failure.throttled) {
283
+ res.writeHead(429, { 'Retry-After': String(failure.retryAfterSeconds) }).end();
284
+ } else if (refusal === 'missing-scope') {
285
+ res.writeHead(403, { 'WWW-Authenticate': resource.challenges.scope }).end();
286
+ } else {
287
+ const challenge =
288
+ refusal === 'missing-token' ? resource.challenges.missing : resource.challenges.invalid;
289
+ res.writeHead(401, { 'WWW-Authenticate': challenge }).end();
290
+ }
291
+ }
package/src/node/index.ts CHANGED
@@ -8,6 +8,19 @@ export { sealHandoffRecord, verifyHandoffPayload } from './handoff-manifest.js';
8
8
  export type { IIntegrityVerdict, TIntegrityFailure } from './handoff-manifest.js';
9
9
  export { createAccessTokenVerifier } from './access-token-verifier.js';
10
10
  export type { IAccessTokenVerifierDeps } from './access-token-verifier.js';
11
+ export {
12
+ createBearerResourceServer,
13
+ describeProtectedResource,
14
+ parsePublicHttpsUrl,
15
+ refuseBearerToken,
16
+ serveProtectedResourceMetadata,
17
+ } from './bearer-resource-gate.js';
18
+ export type {
19
+ IBearerFailure,
20
+ IBearerResourceServer,
21
+ IProtectedResource,
22
+ TRemoteAddressClass,
23
+ } from './bearer-resource-gate.js';
11
24
  export {
12
25
  DEFAULT_MAX_FILE_BYTES,
13
26
  FILE_CHUNK_BYTES,
@@ -0,0 +1,47 @@
1
+ import type { TClientMessage } from './wire-messages.js';
2
+
3
+ /**
4
+ * The only inbound messages an observer may send: reads of this session. An allowlist, so a message
5
+ * type added later is refused to observers until someone decides it is a read. `get-prompts` is left
6
+ * out: an observer never receives prompts, open ones included.
7
+ *
8
+ * The host refuses anything else from an observer, and a client in observe mode sends only these, so
9
+ * both sides read this one list.
10
+ */
11
+ export const OBSERVER_MESSAGES: ReadonlySet<TClientMessage['type']> = new Set<
12
+ TClientMessage['type']
13
+ >([
14
+ 'get-messages',
15
+ 'get-history',
16
+ 'get-context',
17
+ 'get-status',
18
+ 'get-commands',
19
+ 'list-models',
20
+ 'get-executing',
21
+ 'get-pending',
22
+ 'get-execution-workspace',
23
+ 'read-execution-detail',
24
+ 'get-agent-definitions',
25
+ 'get-usage-report',
26
+ 'list-sessions',
27
+ 'get-background-tasks',
28
+ 'get-background-task',
29
+ 'get-background-job-groups',
30
+ 'get-background-job-group',
31
+ 'wait-background-job-group',
32
+ 'read-background-task-log',
33
+ // #3282 §4a: reading the Settings snapshot is a pure read of this session's own configuration.
34
+ // `update-settings` stays out — an observer never writes.
35
+ 'get-settings',
36
+ // #3282 §4c: the Project panel's reads — git status, one file's diff, project memory. All three are
37
+ // read-only (there is no "restore"/write counterpart in this feature — see the SPEC.md design
38
+ // decision on why checkpoints/rewind are excluded entirely), so all three are observer-safe.
39
+ 'project-status',
40
+ 'project-diff',
41
+ 'project-memory',
42
+ ]);
43
+
44
+ /** Whether an observer may send a message of this type. */
45
+ export function isObserverMessageType(type: string): boolean {
46
+ return (OBSERVER_MESSAGES as ReadonlySet<string>).has(type);
47
+ }