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.
- package/dist/agent-helpers.js +1146 -212
- package/dist/agent-helpers.js.map +27 -19
- package/dist/background-bash.js.map +1 -1
- package/dist/browser-runtime.js.map +1 -1
- package/dist/{chunk-kkftbxh2.js → chunk-7he9nzfa.js} +1 -1
- package/dist/{chunk-55dd0xw7.js → chunk-ad6xqvqy.js} +2 -2
- package/dist/{chunk-55dd0xw7.js.map → chunk-ad6xqvqy.js.map} +2 -2
- package/dist/{chunk-exqz8bez.js → chunk-hh3f0yr7.js} +3 -3
- package/dist/{chunk-exqz8bez.js.map → chunk-hh3f0yr7.js.map} +2 -2
- package/dist/{chunk-aqqmzmft.js → chunk-nm59q3em.js} +1 -1
- package/dist/{chunk-cv7vdsrn.js → chunk-w3c6fc0p.js} +286 -9
- package/dist/{chunk-cv7vdsrn.js.map → chunk-w3c6fc0p.js.map} +10 -7
- package/dist/hosted-agent.js +290 -12
- package/dist/hosted-agent.js.map +10 -7
- package/dist/main.js +4599 -2724
- package/dist/main.js.map +49 -38
- package/dist/mcp-factories.js +1166 -162
- package/dist/mcp-factories.js.map +27 -19
- package/dist/media.js.map +1 -1
- package/dist/prompts.js.map +1 -1
- package/dist/query-runtime.js.map +3 -3
- package/dist/registry.js +3 -3
- package/dist/rollout.js +2 -2
- package/dist/runtime/src/agents/explicit-agent-switch.ts +101 -0
- package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +126 -43
- package/dist/runtime/src/agents/public-helpers.ts +5 -0
- package/dist/runtime/src/agents/self-config-core.ts +28 -58
- package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +34 -8
- package/dist/runtime/src/index.ts +75 -0
- package/dist/runtime/src/mcp/peer-bridge.ts +6 -1
- package/dist/runtime/src/mcp/runtime-spec.ts +145 -10
- package/dist/runtime/src/mcp/session-comm/actor-policy.ts +87 -0
- package/dist/runtime/src/mcp/session-comm/context.ts +42 -1
- package/dist/runtime/src/mcp/session-comm/default-host.ts +178 -2
- package/dist/runtime/src/mcp/session-comm/hub-remote-session.ts +553 -0
- package/dist/runtime/src/mcp/session-comm/limits.ts +8 -0
- package/dist/runtime/src/mcp/session-comm/peer-forward.ts +78 -3
- package/dist/runtime/src/mcp/session-comm/server.ts +167 -4
- package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +7 -0
- package/dist/runtime/src/mcp/session-comm/topics.ts +32 -10
- package/dist/runtime/src/mcp-runtime-host.ts +2 -1
- package/dist/runtime/src/node-host.ts +26 -0
- package/dist/runtime/src/platform/mcp-config.ts +56 -2
- package/dist/runtime/src/query/active-rooms.ts +6 -0
- package/dist/runtime/src/runtime/actor-topic-reach.ts +85 -0
- package/dist/runtime/src/runtime/actor-topic-scope.ts +175 -0
- package/dist/runtime/src/runtime/ask-callbacks.ts +73 -20
- package/dist/runtime/src/runtime/gateway-capabilities.ts +22 -0
- package/dist/runtime/src/runtime/remote-session-grant.ts +221 -0
- package/dist/runtime/src/runtime/remote-session-inbox.ts +476 -0
- package/dist/runtime/src/runtime/remote-session-reply-outbox.ts +156 -0
- package/dist/runtime/src/runtime/turn-runner.ts +157 -33
- package/dist/runtime/src/runtime-gateway.ts +20 -0
- package/dist/runtime/src/storage/api-topics.ts +22 -3
- package/dist/runtime/src/storage/remote-session.ts +526 -0
- package/dist/runtime/src/storage/runtime-turn-requests.ts +75 -2
- package/dist/runtime/src/topics/derive.ts +3 -0
- package/dist/runtime/src/types/api.ts +6 -0
- package/dist/runtime/src/types.ts +42 -0
- package/dist/runtime/src/version.ts +1 -1
- package/dist/runtime-gateway.js.map +2 -2
- package/dist/runtime-helpers.js.map +1 -1
- package/dist/storage.js +16 -4
- package/dist/storage.js.map +4 -4
- package/dist/types/packages/core/src/agents/explicit-agent-switch.d.ts +26 -0
- package/dist/types/packages/core/src/agents/mcp-tools/spawn-subagent.d.ts +12 -2
- package/dist/types/packages/core/src/agents/public-helpers.d.ts +1 -0
- package/dist/types/packages/core/src/agents/self-config-core.d.ts +15 -1
- package/dist/types/packages/core/src/mcp/peer-bridge.d.ts +6 -1
- package/dist/types/packages/core/src/mcp/runtime-spec.d.ts +69 -2
- package/dist/types/packages/core/src/mcp/session-comm/context.d.ts +17 -1
- package/dist/types/packages/core/src/mcp/session-comm/hub-remote-session.d.ts +149 -0
- package/dist/types/packages/core/src/mcp/session-comm/limits.d.ts +8 -0
- package/dist/types/packages/core/src/mcp/session-comm/peer-forward.d.ts +23 -2
- package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +6 -0
- package/dist/types/packages/core/src/platform/mcp-config.d.ts +10 -1
- package/dist/types/packages/core/src/query/active-rooms.d.ts +6 -0
- package/dist/types/packages/core/src/runtime/actor-topic-reach.d.ts +49 -0
- package/dist/types/packages/core/src/runtime/actor-topic-scope.d.ts +91 -0
- package/dist/types/packages/core/src/runtime/ask-callbacks.d.ts +8 -2
- package/dist/types/packages/core/src/runtime/remote-session-grant.d.ts +91 -0
- package/dist/types/packages/core/src/runtime/remote-session-inbox.d.ts +83 -0
- package/dist/types/packages/core/src/runtime/remote-session-reply-outbox.d.ts +37 -0
- package/dist/types/packages/core/src/runtime/turn-runner.d.ts +22 -3
- package/dist/types/packages/core/src/runtime-gateway.d.ts +20 -0
- package/dist/types/packages/core/src/storage/api-topics.d.ts +3 -0
- package/dist/types/packages/core/src/storage/remote-session.d.ts +135 -0
- package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +12 -1
- package/dist/types/packages/core/src/topics/derive.d.ts +2 -0
- package/dist/types/packages/core/src/types/api.d.ts +6 -0
- package/dist/types/packages/core/src/types.d.ts +40 -0
- package/dist/types/packages/core/src/version.d.ts +1 -1
- package/dist/vault.js.map +1 -1
- package/package.json +1 -1
- /package/dist/{chunk-kkftbxh2.js.map → chunk-7he9nzfa.js.map} +0 -0
- /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 {
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|