negotium 0.19.2 → 0.20.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 (96) hide show
  1. package/dist/agent-helpers.js +1146 -212
  2. package/dist/agent-helpers.js.map +27 -19
  3. package/dist/background-bash.js.map +1 -1
  4. package/dist/browser-runtime.js.map +1 -1
  5. package/dist/{chunk-kkftbxh2.js → chunk-7he9nzfa.js} +1 -1
  6. package/dist/{chunk-55dd0xw7.js → chunk-ad6xqvqy.js} +2 -2
  7. package/dist/{chunk-55dd0xw7.js.map → chunk-ad6xqvqy.js.map} +2 -2
  8. package/dist/{chunk-exqz8bez.js → chunk-hh3f0yr7.js} +3 -3
  9. package/dist/{chunk-exqz8bez.js.map → chunk-hh3f0yr7.js.map} +2 -2
  10. package/dist/{chunk-aqqmzmft.js → chunk-nm59q3em.js} +1 -1
  11. package/dist/{chunk-cv7vdsrn.js → chunk-w3c6fc0p.js} +286 -9
  12. package/dist/{chunk-cv7vdsrn.js.map → chunk-w3c6fc0p.js.map} +10 -7
  13. package/dist/hosted-agent.js +290 -12
  14. package/dist/hosted-agent.js.map +10 -7
  15. package/dist/main.js +4599 -2724
  16. package/dist/main.js.map +49 -38
  17. package/dist/mcp-factories.js +1166 -162
  18. package/dist/mcp-factories.js.map +27 -19
  19. package/dist/media.js.map +1 -1
  20. package/dist/prompts.js.map +1 -1
  21. package/dist/query-runtime.js.map +3 -3
  22. package/dist/registry.js +3 -3
  23. package/dist/rollout.js +2 -2
  24. package/dist/runtime/src/agents/explicit-agent-switch.ts +101 -0
  25. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +126 -43
  26. package/dist/runtime/src/agents/public-helpers.ts +5 -0
  27. package/dist/runtime/src/agents/self-config-core.ts +28 -58
  28. package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +34 -8
  29. package/dist/runtime/src/index.ts +75 -0
  30. package/dist/runtime/src/mcp/peer-bridge.ts +6 -1
  31. package/dist/runtime/src/mcp/runtime-spec.ts +145 -10
  32. package/dist/runtime/src/mcp/session-comm/actor-policy.ts +87 -0
  33. package/dist/runtime/src/mcp/session-comm/context.ts +42 -1
  34. package/dist/runtime/src/mcp/session-comm/default-host.ts +178 -2
  35. package/dist/runtime/src/mcp/session-comm/hub-remote-session.ts +553 -0
  36. package/dist/runtime/src/mcp/session-comm/limits.ts +8 -0
  37. package/dist/runtime/src/mcp/session-comm/peer-forward.ts +78 -3
  38. package/dist/runtime/src/mcp/session-comm/server.ts +167 -4
  39. package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +7 -0
  40. package/dist/runtime/src/mcp/session-comm/topics.ts +32 -10
  41. package/dist/runtime/src/mcp-runtime-host.ts +2 -1
  42. package/dist/runtime/src/node-host.ts +26 -0
  43. package/dist/runtime/src/platform/mcp-config.ts +56 -2
  44. package/dist/runtime/src/query/active-rooms.ts +6 -0
  45. package/dist/runtime/src/runtime/actor-topic-reach.ts +85 -0
  46. package/dist/runtime/src/runtime/actor-topic-scope.ts +175 -0
  47. package/dist/runtime/src/runtime/ask-callbacks.ts +73 -20
  48. package/dist/runtime/src/runtime/gateway-capabilities.ts +22 -0
  49. package/dist/runtime/src/runtime/remote-session-grant.ts +221 -0
  50. package/dist/runtime/src/runtime/remote-session-inbox.ts +476 -0
  51. package/dist/runtime/src/runtime/remote-session-reply-outbox.ts +156 -0
  52. package/dist/runtime/src/runtime/turn-runner.ts +157 -33
  53. package/dist/runtime/src/runtime-gateway.ts +20 -0
  54. package/dist/runtime/src/storage/api-topics.ts +22 -3
  55. package/dist/runtime/src/storage/remote-session.ts +526 -0
  56. package/dist/runtime/src/storage/runtime-turn-requests.ts +75 -2
  57. package/dist/runtime/src/topics/derive.ts +3 -0
  58. package/dist/runtime/src/types/api.ts +6 -0
  59. package/dist/runtime/src/types.ts +42 -0
  60. package/dist/runtime/src/version.ts +1 -1
  61. package/dist/runtime-gateway.js.map +2 -2
  62. package/dist/runtime-helpers.js.map +1 -1
  63. package/dist/storage.js +16 -4
  64. package/dist/storage.js.map +4 -4
  65. package/dist/types/packages/core/src/agents/explicit-agent-switch.d.ts +26 -0
  66. package/dist/types/packages/core/src/agents/mcp-tools/spawn-subagent.d.ts +12 -2
  67. package/dist/types/packages/core/src/agents/public-helpers.d.ts +1 -0
  68. package/dist/types/packages/core/src/agents/self-config-core.d.ts +15 -1
  69. package/dist/types/packages/core/src/mcp/peer-bridge.d.ts +6 -1
  70. package/dist/types/packages/core/src/mcp/runtime-spec.d.ts +69 -2
  71. package/dist/types/packages/core/src/mcp/session-comm/context.d.ts +17 -1
  72. package/dist/types/packages/core/src/mcp/session-comm/hub-remote-session.d.ts +149 -0
  73. package/dist/types/packages/core/src/mcp/session-comm/limits.d.ts +8 -0
  74. package/dist/types/packages/core/src/mcp/session-comm/peer-forward.d.ts +23 -2
  75. package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +6 -0
  76. package/dist/types/packages/core/src/platform/mcp-config.d.ts +10 -1
  77. package/dist/types/packages/core/src/query/active-rooms.d.ts +6 -0
  78. package/dist/types/packages/core/src/runtime/actor-topic-reach.d.ts +49 -0
  79. package/dist/types/packages/core/src/runtime/actor-topic-scope.d.ts +91 -0
  80. package/dist/types/packages/core/src/runtime/ask-callbacks.d.ts +8 -2
  81. package/dist/types/packages/core/src/runtime/remote-session-grant.d.ts +91 -0
  82. package/dist/types/packages/core/src/runtime/remote-session-inbox.d.ts +83 -0
  83. package/dist/types/packages/core/src/runtime/remote-session-reply-outbox.d.ts +37 -0
  84. package/dist/types/packages/core/src/runtime/turn-runner.d.ts +22 -3
  85. package/dist/types/packages/core/src/runtime-gateway.d.ts +20 -0
  86. package/dist/types/packages/core/src/storage/api-topics.d.ts +3 -0
  87. package/dist/types/packages/core/src/storage/remote-session.d.ts +135 -0
  88. package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +12 -1
  89. package/dist/types/packages/core/src/topics/derive.d.ts +2 -0
  90. package/dist/types/packages/core/src/types/api.d.ts +6 -0
  91. package/dist/types/packages/core/src/types.d.ts +40 -0
  92. package/dist/types/packages/core/src/version.d.ts +1 -1
  93. package/dist/vault.js.map +1 -1
  94. package/package.json +1 -1
  95. /package/dist/{chunk-kkftbxh2.js.map → chunk-7he9nzfa.js.map} +0 -0
  96. /package/dist/{chunk-aqqmzmft.js.map → chunk-nm59q3em.js.map} +0 -0
@@ -0,0 +1,85 @@
1
+ import {
2
+ getTopic,
3
+ listSubagentChildTopicIds,
4
+ listSubagentTellTargetIds,
5
+ } from "#storage/api-topics";
6
+ import type { ActorTopicScope } from "#types";
7
+
8
+ /**
9
+ * Rooms a turn may reach because of lineage this node itself recorded, not
10
+ * because a person is in them: a subagent's direct parent and the targets an
11
+ * ancestor granted it, and a room's own subagent workers.
12
+ *
13
+ * Lineage stands in for an assertion only when there is none — the turns that
14
+ * manage subagents (the spawn, the report back, the follow-up) are turns no
15
+ * person started, they carry no `actorTopicScope`, and cutting them off would
16
+ * break delegation the moment it began. A turn that *does* carry an assertion
17
+ * never gets lineage on top of it: the hub mirrors a subagent room with its
18
+ * parent's roster, so an owner of the parent is asserted as owner of the child
19
+ * and needs no exception, while a member who is not the owner — or a person
20
+ * speaking from a subagent room an ancestor granted rooms outside that
21
+ * person's own — must not inherit authority the hub did not assert.
22
+ * `canSubagentTellTarget` still applies its own rules on top (status-only,
23
+ * grants).
24
+ */
25
+ export function subagentLineageTopicIds(currentTopicId: string | undefined): {
26
+ reachable: Set<string>;
27
+ children: Set<string>;
28
+ } {
29
+ const reachable = new Set<string>();
30
+ const children = new Set<string>();
31
+ if (!currentTopicId) return { reachable, children };
32
+ const current = getTopic(currentTopicId);
33
+ if (current?.isSubagent && current.parentTopicId) {
34
+ reachable.add(current.parentTopicId);
35
+ for (const id of listSubagentTellTargetIds(currentTopicId)) reachable.add(id);
36
+ }
37
+ for (const id of listSubagentChildTopicIds(currentTopicId)) {
38
+ reachable.add(id);
39
+ children.add(id);
40
+ }
41
+ return { reachable, children };
42
+ }
43
+
44
+ /**
45
+ * The rooms cross-room tools may name for this turn.
46
+ *
47
+ * Off the `otium` surface the node owns membership, so nothing narrows the
48
+ * caller's own rooms. On `otium` the hub does, and its assertion is
49
+ * authoritative: with one, exactly the current room plus the asserted visible
50
+ * rooms are reachable — no lineage. Without one (an older hub, or a turn no
51
+ * person started) only the current room and its subagent lineage are
52
+ * reachable (fail-closed).
53
+ */
54
+ export function actorReachableTopicIds(input: {
55
+ surface: string | undefined;
56
+ currentTopicId: string | undefined;
57
+ actorTopicScope: ActorTopicScope | undefined;
58
+ }): ReadonlySet<string> | null {
59
+ if (input.surface !== "otium") return null;
60
+ const ids = new Set<string>();
61
+ if (input.currentTopicId) ids.add(input.currentTopicId);
62
+ if (input.actorTopicScope) {
63
+ for (const id of input.actorTopicScope.visibleNodeTopicIds) ids.add(id);
64
+ return ids;
65
+ }
66
+ for (const id of subagentLineageTopicIds(input.currentTopicId).reachable) ids.add(id);
67
+ return ids;
68
+ }
69
+
70
+ /**
71
+ * Rooms the actor may abort/restart/delete. With an assertion: exactly the
72
+ * rooms the hub says the actor owns (a parent's subagent workers are among
73
+ * them only if the hub asserted so). Without one: the current room's own
74
+ * subagent workers (a room controls what it delegated), nothing else. Same
75
+ * surface rule as {@link actorReachableTopicIds}.
76
+ */
77
+ export function actorOwnedTopicIds(input: {
78
+ surface: string | undefined;
79
+ currentTopicId?: string | undefined;
80
+ actorTopicScope: ActorTopicScope | undefined;
81
+ }): ReadonlySet<string> | null {
82
+ if (input.surface !== "otium") return null;
83
+ if (input.actorTopicScope) return new Set(input.actorTopicScope.ownedNodeTopicIds);
84
+ return subagentLineageTopicIds(input.currentTopicId).children;
85
+ }
@@ -0,0 +1,175 @@
1
+ import type { ActorTopicScope } from "#types";
2
+
3
+ /**
4
+ * Hard caps on a hub-supplied room assertion.
5
+ *
6
+ * The assertion is not stored and forgotten: it rides the signed per-turn MCP
7
+ * token, which every hosted built-in MCP server receives in the URL query
8
+ * (`?token=`) of an `http://127.0.0.1` request, and — with
9
+ * `NEGOTIUM_BUILTIN_MCP_TRANSPORT=stdio` — one base64url argv entry of the
10
+ * session-comm child. The transport, not the database, is the tight bound:
11
+ *
12
+ * - `Bun.serve` answers 431 once the request line is too long (Bun 1.3.14:
13
+ * a 16,205-character URL is served, 16,305 is refused). These caps alone
14
+ * do not bound the URL — the token carries other fields — so the whole URL
15
+ * is budgeted at `MCP_URL_BUDGET_CHARS` (14 KiB) in `runtime-spec.ts`,
16
+ * where the numbers live. The 8 KiB assertion below becomes 10,924
17
+ * base64url characters at most; with every other runtime-token field at
18
+ * its largest (the prompt itself is not in the token — only the derived
19
+ * `explicitAgentSwitchTargets`, at most three agent names) the URL is
20
+ * ≤ ~13.4 KB.
21
+ * - argv: `encodeActorTopicScopeArg` is the assertion alone, ≤ 10,924
22
+ * characters, far below Linux `MAX_ARG_STRLEN` (128 KiB) for one argument
23
+ * and macOS `ARG_MAX` (1 MiB) overall. The encoder re-validates before it
24
+ * encodes, so an in-process caller that bypassed the gateway parser cannot
25
+ * put an unbounded object into argv either.
26
+ *
27
+ * With UUID room ids (39 bytes each serialized) the byte cap admits roughly
28
+ * 208 ids across both lists; the per-list count cap bounds the work done on
29
+ * an assertion before its size is known, and the id length cap keeps a single
30
+ * id from being the whole budget. Raising these means moving the assertion
31
+ * out of the token (server-side per-turn context), not editing the numbers.
32
+ */
33
+ export const ACTOR_TOPIC_SCOPE_LIMITS = Object.freeze({
34
+ /** Ids per list, counted before de-duplication. */
35
+ maxIdsPerList: 200,
36
+ /** Characters per id, after trimming. */
37
+ maxIdLength: 128,
38
+ /** UTF-8 bytes of the normalized assertion as JSON. */
39
+ maxSerializedBytes: 8 * 1024,
40
+ });
41
+
42
+ const SCOPE_KEYS = ["visibleNodeTopicIds", "ownedNodeTopicIds"] as const;
43
+
44
+ export type ActorTopicScopeValidation =
45
+ | { ok: true; scope: ActorTopicScope | undefined }
46
+ | { ok: false; error: string };
47
+
48
+ function idList(value: unknown, name: string): string[] | string {
49
+ if (!Array.isArray(value)) return `${name} must be an array of topic ids`;
50
+ if (value.length > ACTOR_TOPIC_SCOPE_LIMITS.maxIdsPerList) {
51
+ return `${name} has ${value.length} ids; at most ${ACTOR_TOPIC_SCOPE_LIMITS.maxIdsPerList} are allowed`;
52
+ }
53
+ const ids = new Set<string>();
54
+ for (const item of value) {
55
+ if (typeof item !== "string") return `${name} must contain only strings`;
56
+ const id = item.trim();
57
+ if (!id) return `${name} must not contain empty ids`;
58
+ if (id.length > ACTOR_TOPIC_SCOPE_LIMITS.maxIdLength) {
59
+ return `${name} contains an id longer than ${ACTOR_TOPIC_SCOPE_LIMITS.maxIdLength} characters`;
60
+ }
61
+ ids.add(id);
62
+ }
63
+ return [...ids];
64
+ }
65
+
66
+ /**
67
+ * Validate a hub-supplied room assertion and say why it failed.
68
+ *
69
+ * `undefined` in means `undefined` out (no assertion, fail-closed downstream);
70
+ * anything else must be exactly `{ visibleNodeTopicIds, ownedNodeTopicIds }`
71
+ * — a plain object with those two own keys and nothing else (so a stray
72
+ * `__proto__` or `constructor` key is a rejection, not a property), each a
73
+ * list of non-empty strings within {@link ACTOR_TOPIC_SCOPE_LIMITS} — or it is
74
+ * rejected, so a malformed assertion can never be read as "everything".
75
+ */
76
+ export function validateActorTopicScope(value: unknown): ActorTopicScopeValidation {
77
+ if (value === undefined) return { ok: true, scope: undefined };
78
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
79
+ return {
80
+ ok: false,
81
+ error:
82
+ "actorTopicScope must be { visibleNodeTopicIds: string[], ownedNodeTopicIds: string[] }",
83
+ };
84
+ }
85
+ const proto = Object.getPrototypeOf(value);
86
+ if (proto !== Object.prototype && proto !== null) {
87
+ return { ok: false, error: "actorTopicScope must be a plain object" };
88
+ }
89
+ const keys = Object.keys(value);
90
+ const unknown = keys.filter((key) => !(SCOPE_KEYS as readonly string[]).includes(key));
91
+ if (unknown.length || keys.length !== SCOPE_KEYS.length) {
92
+ return {
93
+ ok: false,
94
+ error: unknown.length
95
+ ? `actorTopicScope has unexpected key(s): ${unknown.join(", ")}`
96
+ : "actorTopicScope must have both visibleNodeTopicIds and ownedNodeTopicIds",
97
+ };
98
+ }
99
+ const record = value as Record<string, unknown>;
100
+ const visible = idList(record.visibleNodeTopicIds, "actorTopicScope.visibleNodeTopicIds");
101
+ if (typeof visible === "string") return { ok: false, error: visible };
102
+ const owned = idList(record.ownedNodeTopicIds, "actorTopicScope.ownedNodeTopicIds");
103
+ if (typeof owned === "string") return { ok: false, error: owned };
104
+ const scope: ActorTopicScope = { visibleNodeTopicIds: visible, ownedNodeTopicIds: owned };
105
+ const bytes = Buffer.byteLength(JSON.stringify(scope), "utf-8");
106
+ if (bytes > ACTOR_TOPIC_SCOPE_LIMITS.maxSerializedBytes) {
107
+ return {
108
+ ok: false,
109
+ error: `actorTopicScope is ${bytes} bytes serialized; at most ${ACTOR_TOPIC_SCOPE_LIMITS.maxSerializedBytes} are allowed`,
110
+ };
111
+ }
112
+ return { ok: true, scope };
113
+ }
114
+
115
+ /**
116
+ * Validate a hub-supplied room assertion.
117
+ *
118
+ * `undefined` in means `undefined` out (no assertion, fail-closed downstream);
119
+ * anything else must pass {@link validateActorTopicScope} or it is rejected
120
+ * with `null`. Every path an assertion can enter by — the gateway body, the
121
+ * signed MCP tokens, the stdio argv — goes through this one parser, so the
122
+ * limits cannot drift between them.
123
+ */
124
+ export function parseActorTopicScope(value: unknown): ActorTopicScope | undefined | null {
125
+ const validated = validateActorTopicScope(value);
126
+ return validated.ok ? validated.scope : null;
127
+ }
128
+
129
+ export function isActorTopicScope(value: unknown): value is ActorTopicScope {
130
+ return parseActorTopicScope(value) != null;
131
+ }
132
+
133
+ /**
134
+ * Read a validated assertion out of a persisted or signed context. Anything
135
+ * that does not validate collapses to "no assertion" rather than throwing, so
136
+ * a stale row cannot break a turn — it only narrows it to what a turn without
137
+ * an assertion reaches: the current room plus its own subagent lineage.
138
+ */
139
+ export function actorTopicScopeFrom(value: unknown): ActorTopicScope | undefined {
140
+ return parseActorTopicScope(value) ?? undefined;
141
+ }
142
+
143
+ /**
144
+ * The strictest assertion two requests agree on: an id is kept only when both
145
+ * assert it, and a request without an assertion leaves the result without one
146
+ * (fail-closed). Used when several pending requests of one actor fold into a
147
+ * single turn, so the merged turn never reaches a room one of them could not.
148
+ */
149
+ export function intersectActorTopicScopes(
150
+ scopes: ReadonlyArray<ActorTopicScope | undefined>,
151
+ ): ActorTopicScope | undefined {
152
+ if (scopes.length === 0 || scopes.some((scope) => scope === undefined)) return undefined;
153
+ const [first, ...rest] = scopes as ActorTopicScope[];
154
+ const keep = (key: keyof ActorTopicScope) =>
155
+ first[key].filter((id) => rest.every((scope) => scope[key].includes(id)));
156
+ return {
157
+ visibleNodeTopicIds: keep("visibleNodeTopicIds"),
158
+ ownedNodeTopicIds: keep("ownedNodeTopicIds"),
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Argv-safe encoding for the stdio session-comm server (`--actor-topic-scope=`).
164
+ * Bounded by {@link ACTOR_TOPIC_SCOPE_LIMITS}: 8,192 bytes of JSON become at
165
+ * most 10,924 base64url characters, see there. The input is put through
166
+ * {@link validateActorTopicScope} first and an invalid or oversized assertion
167
+ * throws — it is never truncated (which would narrow the turn silently) nor
168
+ * passed on (which would let a typed in-process caller overflow argv).
169
+ */
170
+ export function encodeActorTopicScopeArg(scope: ActorTopicScope): string {
171
+ const validated = validateActorTopicScope(scope);
172
+ if (!validated.ok) throw new Error(validated.error);
173
+ if (!validated.scope) throw new Error("actorTopicScope is required");
174
+ return Buffer.from(JSON.stringify(validated.scope), "utf-8").toString("base64url");
175
+ }
@@ -9,7 +9,12 @@
9
9
  * (the ask/tell REST routes stayed in the host).
10
10
  */
11
11
 
12
- import { deliverPeerReply, type RemoteReplyRoute } from "#mcp/session-comm/peer-forward";
12
+ import {
13
+ deliverPeerReply,
14
+ isHubRemoteReplyRoute,
15
+ parseHubRemoteReplyRoute,
16
+ type RemoteReplyRoute,
17
+ } from "#mcp/session-comm/peer-forward";
13
18
  import { db } from "#storage/forum-db";
14
19
  import { PENDING_ASK_TTL_MS, type PendingAskUserId } from "#storage/session-asks";
15
20
  import { registerStorageSchemaInitializer } from "#storage/storage-host";
@@ -31,6 +36,13 @@ registerStorageSchemaInitializer((database) => {
31
36
  created_at INTEGER NOT NULL
32
37
  )
33
38
  `);
39
+ // Hub-routed asks keep their whole route here; the legacy columns are
40
+ // blank for them. Added after the table existed, so guarded.
41
+ try {
42
+ database.exec("ALTER TABLE remote_ask_callbacks ADD COLUMN route_json TEXT");
43
+ } catch {
44
+ // Column already present.
45
+ }
34
46
  });
35
47
 
36
48
  interface PendingAskIdentity {
@@ -65,25 +77,29 @@ const MAX_ASK_AGE_MS = PENDING_ASK_TTL_MS;
65
77
  export function registerAskCallback(entry: AskPending): void {
66
78
  pendingAsks.set(entry.targetQueryId, entry);
67
79
  if (entry.remoteReply) {
80
+ const route = entry.remoteReply;
81
+ const hub = isHubRemoteReplyRoute(route);
68
82
  db.query(
69
83
  `INSERT INTO remote_ask_callbacks
70
- (target_query_id, request_id, node_name, node_cell_id, topic_id, user_id, created_at)
71
- VALUES (?, ?, ?, ?, ?, ?, ?)
84
+ (target_query_id, request_id, node_name, node_cell_id, topic_id, user_id, created_at, route_json)
85
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
72
86
  ON CONFLICT(target_query_id) DO UPDATE SET
73
87
  request_id = excluded.request_id,
74
88
  node_name = excluded.node_name,
75
89
  node_cell_id = excluded.node_cell_id,
76
90
  topic_id = excluded.topic_id,
77
91
  user_id = excluded.user_id,
78
- created_at = excluded.created_at`,
92
+ created_at = excluded.created_at,
93
+ route_json = excluded.route_json`,
79
94
  ).run(
80
95
  entry.targetQueryId,
81
- entry.remoteReply.requestId,
82
- entry.remoteReply.nodeName,
83
- entry.remoteReply.nodeCellId,
84
- entry.remoteReply.topicId,
85
- entry.remoteReply.userId,
96
+ route.requestId,
97
+ route.nodeName,
98
+ hub ? "" : route.nodeCellId,
99
+ route.topicId,
100
+ hub ? "" : route.userId,
86
101
  entry.createdAt,
102
+ hub ? JSON.stringify(route) : null,
87
103
  );
88
104
  }
89
105
  }
@@ -99,10 +115,23 @@ export function resolveAskCallback(queryId: string): AskPending | null {
99
115
  entry.timedOut = true;
100
116
  }
101
117
  pendingAsks.delete(queryId);
102
- db.query("DELETE FROM remote_ask_callbacks WHERE target_query_id = ?").run(queryId);
118
+ // A hub-routed callback keeps its durable row until the answer is in the
119
+ // reply outbox: `deliverHubRemoteReply` deletes it in the same transaction
120
+ // as the outbox insert, so a crash between resolve and enqueue still leaves
121
+ // the row for the startup pass to fail explicitly instead of losing both.
122
+ if (!entry.remoteReply || !isHubRemoteReplyRoute(entry.remoteReply)) {
123
+ db.query("DELETE FROM remote_ask_callbacks WHERE target_query_id = ?").run(queryId);
124
+ }
103
125
  return entry;
104
126
  }
105
127
 
128
+ /** The hub-routed answer is durably queued: the callback row has served its purpose. */
129
+ export function deleteHubRemoteAskCallbackRow(requestId: string): void {
130
+ db.query("DELETE FROM remote_ask_callbacks WHERE request_id = ? AND route_json IS NOT NULL").run(
131
+ requestId,
132
+ );
133
+ }
134
+
106
135
  /** Clean up stale asks that outlived their TTL (periodic, lightweight). */
107
136
  export function purgeStaleAsks(now: number = Date.now()): void {
108
137
  for (const [id, entry] of pendingAsks) {
@@ -130,28 +159,52 @@ interface DurableRemoteAskCallbackRow {
130
159
  node_cell_id: string;
131
160
  topic_id: string;
132
161
  user_id: string;
162
+ route_json: string | null;
163
+ }
164
+
165
+ function durableRoute(row: DurableRemoteAskCallbackRow): RemoteReplyRoute | null {
166
+ if (row.route_json) {
167
+ try {
168
+ return parseHubRemoteReplyRoute(JSON.parse(row.route_json));
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+ return {
174
+ nodeName: row.node_name,
175
+ nodeCellId: row.node_cell_id,
176
+ topicId: row.topic_id,
177
+ userId: row.user_id,
178
+ requestId: row.request_id,
179
+ };
133
180
  }
134
181
 
135
182
  /** Fail remote asks whose target turn was interrupted by the previous process.
136
- * Call once during startup after the peer session bridge is registered. */
137
- export async function failInterruptedRemoteAskCallbacks(): Promise<number> {
183
+ * Call once during startup after the peer session bridge is registered.
184
+ * `routes: "hub"` limits the pass to hub-routed asks, which need no adapter
185
+ * bridge and are therefore failed by the node itself at startup. */
186
+ export async function failInterruptedRemoteAskCallbacks(
187
+ options: { routes?: "all" | "hub" } = {},
188
+ ): Promise<number> {
138
189
  const rows = db
139
190
  .query(
140
- `SELECT target_query_id, request_id, node_name, node_cell_id, topic_id, user_id
191
+ `SELECT target_query_id, request_id, node_name, node_cell_id, topic_id, user_id, route_json
141
192
  FROM remote_ask_callbacks
142
193
  ORDER BY created_at ASC`,
143
194
  )
144
195
  .all() as DurableRemoteAskCallbackRow[];
145
196
  let failed = 0;
146
197
  for (const row of rows) {
198
+ const route = durableRoute(row);
199
+ if (!route) {
200
+ db.query("DELETE FROM remote_ask_callbacks WHERE target_query_id = ?").run(
201
+ row.target_query_id,
202
+ );
203
+ continue;
204
+ }
205
+ if (options.routes === "hub" && !isHubRemoteReplyRoute(route)) continue;
147
206
  const delivered = await deliverPeerReply(
148
- {
149
- nodeName: row.node_name,
150
- nodeCellId: row.node_cell_id,
151
- topicId: row.topic_id,
152
- userId: row.user_id,
153
- requestId: row.request_id,
154
- },
207
+ route,
155
208
  "peer",
156
209
  "The remote worker restarted before this ask completed. Please retry the request.",
157
210
  "error",
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Extra `GET /health` capability strings contributed by whatever is loaded
3
+ * alongside the node — an adapter that forwards a new gateway route over its
4
+ * relay, for instance. The core route list is fixed in `control.ts`; this
5
+ * registry lets a host advertise a capability only when the piece that makes
6
+ * it true is actually present, so a hub can tell "the node supports it" from
7
+ * "the node *and* its relay support it".
8
+ */
9
+ const registered = new Set<string>();
10
+
11
+ export function registerRuntimeGatewayCapability(name: string): () => void {
12
+ const capability = name.trim();
13
+ if (!capability) throw new Error("capability name is required");
14
+ registered.add(capability);
15
+ return () => {
16
+ registered.delete(capability);
17
+ };
18
+ }
19
+
20
+ export function listRuntimeGatewayCapabilities(): string[] {
21
+ return [...registered].sort();
22
+ }
@@ -0,0 +1,221 @@
1
+ import type { RemoteSessionGrant } from "#types";
2
+
3
+ /**
4
+ * Hard caps on a hub-supplied remote-session grant.
5
+ *
6
+ * Like the actor room assertion (`actor-topic-scope.ts`), the grant rides the
7
+ * signed per-turn `session-comm` MCP token in a URL query and — with
8
+ * `NEGOTIUM_BUILTIN_MCP_TRANSPORT=stdio` — one base64url argv entry, so the
9
+ * transport bounds it, not the database. A capability is ≈330–440 characters
10
+ * (`rsc1.<base64url JSON>.<base64url HMAC>`) and adds ≈680 URL characters
11
+ * beside the maximal 8 KiB assertion (12,800 of the 14,336 budget, measured
12
+ * in `hosted-runtime-spec.test.ts`). The caps below leave a hub room to grow
13
+ * the payload, but a grant at both caps (≈3,475 URL characters) does not fit
14
+ * beside the *maximal* assertion — `MCP_URL_BUDGET_CHARS` is the backstop and
15
+ * refuses that mint loudly rather than dropping a field. A capability up to
16
+ * ≈1,590 characters always fits.
17
+ */
18
+ export const REMOTE_SESSION_GRANT_LIMITS = Object.freeze({
19
+ /** Characters of `hubUrl`, after trimming. */
20
+ maxHubUrlLength: 512,
21
+ /** Characters of `capability`, after trimming. */
22
+ maxCapabilityLength: 2 * 1024,
23
+ /** The only capability format this node forwards. */
24
+ capabilityPrefix: "rsc1.",
25
+ });
26
+
27
+ const GRANT_KEYS = ["hubUrl", "capability"] as const;
28
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]);
29
+
30
+ export type RemoteSessionGrantValidation =
31
+ | { ok: true; grant: RemoteSessionGrant | undefined }
32
+ | { ok: false; error: string };
33
+
34
+ /**
35
+ * Normalize a hub URL the node may present a bearer to: loopback `http://`
36
+ * or any `https://` origin, ≤ 512 characters, no credentials, no query or
37
+ * fragment. Returns the canonical string (trailing slash dropped) or the
38
+ * reason it is refused. The one rule for every hub URL the node stores — the
39
+ * turn grant's `hubUrl` and an ask's `remoteReply.hubUrl` alike — so a
40
+ * bearer can never be steered to an attacker-chosen host.
41
+ */
42
+ export function normalizeHubUrl(
43
+ raw: string,
44
+ name = "remoteSession.hubUrl",
45
+ ): { url: string } | { error: string } {
46
+ if (raw.length > REMOTE_SESSION_GRANT_LIMITS.maxHubUrlLength) {
47
+ return {
48
+ error: `${name} is longer than ${REMOTE_SESSION_GRANT_LIMITS.maxHubUrlLength} characters`,
49
+ };
50
+ }
51
+ let url: URL;
52
+ try {
53
+ url = new URL(raw);
54
+ } catch {
55
+ return { error: `${name} must be an absolute URL` };
56
+ }
57
+ if (url.username || url.password) {
58
+ return { error: `${name} must not carry credentials` };
59
+ }
60
+ if (url.search || url.hash || raw.includes("?") || raw.includes("#")) {
61
+ return { error: `${name} must not carry a query or fragment` };
62
+ }
63
+ if (url.protocol === "http:") {
64
+ if (!LOOPBACK_HOSTS.has(url.hostname)) {
65
+ return { error: `${name} must be https:// or a loopback http:// origin` };
66
+ }
67
+ } else if (url.protocol !== "https:") {
68
+ return { error: `${name} must be https:// or a loopback http:// origin` };
69
+ }
70
+ return { url: url.toString().replace(/\/+$/, "") };
71
+ }
72
+
73
+ /** Shape of the hub's one-shot `rsr1` reply token; the node never verifies it. */
74
+ export const REMOTE_SESSION_REPLY_TOKEN_PATTERN = /^rsr1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
75
+ /** Same cap as the capability: the hub's tokens are ≈200 characters. */
76
+ export const MAX_REMOTE_SESSION_REPLY_TOKEN_LENGTH =
77
+ REMOTE_SESSION_GRANT_LIMITS.maxCapabilityLength;
78
+
79
+ /**
80
+ * Validate a hub-supplied remote-session grant and say why it failed.
81
+ *
82
+ * `undefined` in means `undefined` out (no grant: remote session-comm stays
83
+ * fail-closed on `otium`); anything else must be exactly
84
+ * `{ hubUrl, capability }` — a plain object with those two own keys — with a
85
+ * callable hub URL and an opaque `rsc1.` capability within
86
+ * {@link REMOTE_SESSION_GRANT_LIMITS}. The node never decodes or verifies the
87
+ * capability: it is a bearer the hub issued and only the hub can check.
88
+ */
89
+ export function validateRemoteSessionGrant(value: unknown): RemoteSessionGrantValidation {
90
+ if (value === undefined) return { ok: true, grant: undefined };
91
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
92
+ return { ok: false, error: "remoteSession must be { hubUrl: string, capability: string }" };
93
+ }
94
+ const proto = Object.getPrototypeOf(value);
95
+ if (proto !== Object.prototype && proto !== null) {
96
+ return { ok: false, error: "remoteSession must be a plain object" };
97
+ }
98
+ const keys = Object.keys(value);
99
+ const unknown = keys.filter((key) => !(GRANT_KEYS as readonly string[]).includes(key));
100
+ if (unknown.length || keys.length !== GRANT_KEYS.length) {
101
+ return {
102
+ ok: false,
103
+ error: unknown.length
104
+ ? `remoteSession has unexpected key(s): ${unknown.join(", ")}`
105
+ : "remoteSession must have both hubUrl and capability",
106
+ };
107
+ }
108
+ const record = value as Record<string, unknown>;
109
+ if (typeof record.hubUrl !== "string" || !record.hubUrl.trim()) {
110
+ return { ok: false, error: "remoteSession.hubUrl must be a non-empty string" };
111
+ }
112
+ const hubUrl = normalizeHubUrl(record.hubUrl.trim());
113
+ if ("error" in hubUrl) return { ok: false, error: hubUrl.error };
114
+ if (typeof record.capability !== "string" || !record.capability.trim()) {
115
+ return { ok: false, error: "remoteSession.capability must be a non-empty string" };
116
+ }
117
+ const capability = record.capability.trim();
118
+ if (capability.length > REMOTE_SESSION_GRANT_LIMITS.maxCapabilityLength) {
119
+ return {
120
+ ok: false,
121
+ error: `remoteSession.capability is ${capability.length} characters; at most ${REMOTE_SESSION_GRANT_LIMITS.maxCapabilityLength} are allowed`,
122
+ };
123
+ }
124
+ if (!capability.startsWith(REMOTE_SESSION_GRANT_LIMITS.capabilityPrefix)) {
125
+ return {
126
+ ok: false,
127
+ error: `remoteSession.capability must start with "${REMOTE_SESSION_GRANT_LIMITS.capabilityPrefix}"`,
128
+ };
129
+ }
130
+ // Three dot-separated base64url parts and nothing else, so a value that
131
+ // is not even shaped like a capability never reaches the hub.
132
+ if (!/^rsc1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(capability)) {
133
+ return { ok: false, error: "remoteSession.capability is malformed" };
134
+ }
135
+ return { ok: true, grant: { hubUrl: hubUrl.url, capability } };
136
+ }
137
+
138
+ /** {@link validateRemoteSessionGrant} collapsed to `null` on failure. */
139
+ export function parseRemoteSessionGrant(value: unknown): RemoteSessionGrant | undefined | null {
140
+ const validated = validateRemoteSessionGrant(value);
141
+ return validated.ok ? validated.grant : null;
142
+ }
143
+
144
+ export function isRemoteSessionGrant(value: unknown): value is RemoteSessionGrant {
145
+ return parseRemoteSessionGrant(value) != null;
146
+ }
147
+
148
+ /**
149
+ * Read a validated grant out of a persisted or signed context. Anything that
150
+ * does not validate collapses to "no grant" rather than throwing: a stale row
151
+ * only loses remote reach, it never breaks the turn.
152
+ */
153
+ export function remoteSessionGrantFrom(value: unknown): RemoteSessionGrant | undefined {
154
+ return parseRemoteSessionGrant(value) ?? undefined;
155
+ }
156
+
157
+ /**
158
+ * Best-effort peek at the capability's expiry (`e`, epoch seconds), used only
159
+ * to order grants when several requests fold into one turn. Untrusted: the
160
+ * node never decides validity from it — the hub does.
161
+ */
162
+ export function remoteSessionGrantExpiry(grant: RemoteSessionGrant): number | null {
163
+ const payload = grant.capability.split(".")[1];
164
+ if (!payload) return null;
165
+ try {
166
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as unknown;
167
+ const exp = (decoded as { e?: unknown } | null)?.e;
168
+ return typeof exp === "number" && Number.isFinite(exp) ? exp : null;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * The grant a merged turn runs with: symmetric to `intersectActorTopicScopes`
176
+ * — a request without a grant leaves the batch without one (fail-closed), and
177
+ * a batch of grants for one hub keeps the one that expires last so the merged
178
+ * turn is not cut short by the oldest message's capability. Grants naming
179
+ * different hubs never merge (the batch runs with none). The caller has
180
+ * already ensured the folded requests speak for the same actor.
181
+ */
182
+ export function mergeRemoteSessionGrants(
183
+ grants: ReadonlyArray<RemoteSessionGrant | undefined>,
184
+ ): RemoteSessionGrant | undefined {
185
+ if (grants.length === 0 || grants.some((grant) => grant === undefined)) return undefined;
186
+ const present = grants as RemoteSessionGrant[];
187
+ const hubUrl = present[0]!.hubUrl;
188
+ if (present.some((grant) => grant.hubUrl !== hubUrl)) return undefined;
189
+ let chosen = present[present.length - 1]!;
190
+ let chosenExpiry = remoteSessionGrantExpiry(chosen);
191
+ for (const grant of present) {
192
+ const expiry = remoteSessionGrantExpiry(grant);
193
+ if (expiry !== null && (chosenExpiry === null || expiry > chosenExpiry)) {
194
+ chosen = grant;
195
+ chosenExpiry = expiry;
196
+ }
197
+ }
198
+ return chosen;
199
+ }
200
+
201
+ /**
202
+ * Argv-safe encoding for the stdio session-comm server
203
+ * (`--remote-session-grant=`). Re-validated before encoding, so an in-process
204
+ * caller cannot put an unbounded or malformed grant into argv.
205
+ */
206
+ export function encodeRemoteSessionGrantArg(grant: RemoteSessionGrant): string {
207
+ const validated = validateRemoteSessionGrant(grant);
208
+ if (!validated.ok) throw new Error(validated.error);
209
+ if (!validated.grant) throw new Error("remoteSession is required");
210
+ return Buffer.from(JSON.stringify(validated.grant), "utf-8").toString("base64url");
211
+ }
212
+
213
+ /** Inverse of {@link encodeRemoteSessionGrantArg}; `null` when malformed. */
214
+ export function decodeRemoteSessionGrantArg(value: string): RemoteSessionGrant | null {
215
+ try {
216
+ const decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf-8")) as unknown;
217
+ return parseRemoteSessionGrant(decoded) ?? null;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }