@ggui-ai/iframe-runtime 0.24.0 → 0.26.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/README.md +2 -2
- package/dist/channels/data.d.ts +8 -0
- package/dist/channels/data.js +2 -0
- package/dist/credential-controller.d.ts +71 -0
- package/dist/credential-controller.js +135 -0
- package/dist/iframe-runtime.js +97 -93
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/ladders.d.ts +77 -0
- package/dist/ladders.js +383 -0
- package/dist/meta-parse.js +38 -8
- package/dist/observability.d.ts +38 -11
- package/dist/react-renderer.d.ts +22 -0
- package/dist/react-renderer.js +118 -3
- package/dist/registry-subscribe.d.ts +53 -2
- package/dist/registry-subscribe.js +63 -8
- package/dist/render-failure-report.d.ts +15 -0
- package/dist/render-failure-report.js +61 -0
- package/dist/render-item.d.ts +6 -1
- package/dist/render-item.js +7 -2
- package/dist/runtime-telemetry.d.ts +28 -18
- package/dist/runtime-telemetry.js +68 -29
- package/dist/runtime.d.ts +157 -39
- package/dist/runtime.js +724 -368
- package/dist/shims/wire.js +1 -1
- package/dist/stream-seq.d.ts +56 -0
- package/dist/stream-seq.js +46 -0
- package/dist/system-cards/ProtocolProbeCard.d.ts +16 -0
- package/dist/system-cards/ProtocolProbeCard.js +17 -14
- package/dist/tool-result-error.d.ts +6 -0
- package/dist/tool-result-error.js +32 -0
- package/dist/types.d.ts +49 -1
- package/dist/view-origin.d.ts +29 -0
- package/dist/view-origin.js +33 -0
- package/dist/view-proof-signer.d.ts +31 -0
- package/dist/view-proof-signer.js +99 -0
- package/dist/view-root.d.ts +51 -0
- package/dist/view-root.js +56 -0
- package/dist/wire-config.d.ts +15 -1
- package/dist/wire-config.js +42 -1
- package/package.json +8 -7
package/dist/shims/wire.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const w = globalThis.__ggui__.wire; export const useAction = w.useAction; export const useStream = w.useStream; export const useAuth = w.useAuth; export const useApp = w.useApp; export const useRender = w.useRender; export const useGguiContext = w.useGguiContext; export const useContract = w.useContract; export const useWireContext = w.useWireContext; export const useActionSpent = w.useActionSpent; export const GguiWireProvider = w.GguiWireProvider; export default w;
|
|
1
|
+
const w = globalThis.__ggui__.wire; export const useAction = w.useAction; export const useStream = w.useStream; export const useAuth = w.useAuth; export const useApp = w.useApp; export const useRender = w.useRender; export const useGguiContext = w.useGguiContext; export const useContract = w.useContract; export const useWireContext = w.useWireContext; export const useActionSpent = w.useActionSpent; export const useActionPending = w.useActionPending; export const GguiWireProvider = w.GguiWireProvider; export default w;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view's stream cursor (ggui#1496, rev 2.4).
|
|
3
|
+
*
|
|
4
|
+
* `StreamEnvelope.seq` is session-scoped and gap-free, and stream delivery
|
|
5
|
+
* is at-least-once, so the view applies a stamped envelope only when its
|
|
6
|
+
* `seq` is above the highest one it has applied. That highest `seq` is
|
|
7
|
+
* also where a new subscribe resumes the stream from
|
|
8
|
+
* (`SubscribePayload.fromSeq`). An unstamped envelope (a deployment with no
|
|
9
|
+
* stream buffer) is single-shot and always passes, and it moves nothing.
|
|
10
|
+
*
|
|
11
|
+
* A server's counter can restart (a process restart, an in-memory buffer, a
|
|
12
|
+
* stored counter that expired), after which its `seq` starts over below the
|
|
13
|
+
* view's cursor and every new envelope would be dropped. The ack reveals
|
|
14
|
+
* it: its `streamSeq` is the server's current counter, and a monotonic
|
|
15
|
+
* counter can never be below a `seq` this view applied. So an ack whose
|
|
16
|
+
* `streamSeq` is lower means the counter restarted, and the cursor is
|
|
17
|
+
* forgotten.
|
|
18
|
+
*
|
|
19
|
+
* That numeric test misses a counter that restarted and has already
|
|
20
|
+
* climbed past the cursor before the next ack. The stream EPOCH closes it
|
|
21
|
+
* (ggui#1531): the server names each counter generation, on every stamped
|
|
22
|
+
* envelope and on the ack, and a different epoch means a new generation
|
|
23
|
+
* whatever the numbers say, so the cursor resets to nothing applied. A
|
|
24
|
+
* missing epoch (a server, or an envelope, from before epochs) is
|
|
25
|
+
* unknown, never a mismatch: the `seq` rule alone decides, and a first
|
|
26
|
+
* epoch after unknown history is adopted without a reset.
|
|
27
|
+
*
|
|
28
|
+
* The cursor never moves back to an epoch it has left: it remembers the
|
|
29
|
+
* last {@link LEFT_EPOCHS_KEPT}, and drops their frames and ignores their
|
|
30
|
+
* acks. Otherwise a late frame of the old generation (a cross-replica frame
|
|
31
|
+
* published just before the restart) would move it back with nothing
|
|
32
|
+
* applied, and the next frame of the new one would move it forward again
|
|
33
|
+
* with an empty floor, letting a duplicate through. The server keeps the
|
|
34
|
+
* same rule (SPEC §12.2.1 invariant 4).
|
|
35
|
+
*/
|
|
36
|
+
export interface StreamSeqTracker {
|
|
37
|
+
/**
|
|
38
|
+
* Whether to apply an envelope with this `seq` and `streamEpoch`; records
|
|
39
|
+
* it when so. An envelope from a different epoch first resets the cursor.
|
|
40
|
+
*/
|
|
41
|
+
admit(seq: number | undefined, epoch?: string): boolean;
|
|
42
|
+
/** The highest `seq` applied so far; `undefined` before the first stamped envelope. */
|
|
43
|
+
last(): number | undefined;
|
|
44
|
+
/** The counter generation the cursor belongs to; `undefined` until one is seen. */
|
|
45
|
+
epoch(): string | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* An ack's stream position (`AckPayload.streamSeq`, `AckPayload.streamEpoch`).
|
|
48
|
+
* A different epoch, or a `streamSeq` below the highest applied `seq`,
|
|
49
|
+
* means the counter restarted: the cursor is forgotten, and `true` says
|
|
50
|
+
* so.
|
|
51
|
+
*/
|
|
52
|
+
observeAck(streamSeq: number | undefined, streamEpoch: string | undefined): boolean;
|
|
53
|
+
}
|
|
54
|
+
/** How many epochs the cursor remembers having left; the server keeps as many. */
|
|
55
|
+
export declare const LEFT_EPOCHS_KEPT = 4;
|
|
56
|
+
export declare function createStreamSeqTracker(): StreamSeqTracker;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** How many epochs the cursor remembers having left; the server keeps as many. */
|
|
2
|
+
export const LEFT_EPOCHS_KEPT = 4;
|
|
3
|
+
export function createStreamSeqTracker() {
|
|
4
|
+
let highest;
|
|
5
|
+
let current;
|
|
6
|
+
let left = [];
|
|
7
|
+
const hasLeft = (epoch) => epoch !== undefined && left.includes(epoch);
|
|
8
|
+
/** Adopt `epoch`; a different known epoch resets the cursor. Returns whether it reset. */
|
|
9
|
+
const meetEpoch = (epoch) => {
|
|
10
|
+
if (epoch === undefined)
|
|
11
|
+
return false;
|
|
12
|
+
const from = current;
|
|
13
|
+
const changed = from !== undefined && epoch !== from;
|
|
14
|
+
current = epoch;
|
|
15
|
+
if (changed) {
|
|
16
|
+
left = [...left, from].slice(-LEFT_EPOCHS_KEPT);
|
|
17
|
+
highest = undefined;
|
|
18
|
+
}
|
|
19
|
+
return changed;
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
admit(seq, epoch) {
|
|
23
|
+
if (seq === undefined)
|
|
24
|
+
return true;
|
|
25
|
+
if (hasLeft(epoch))
|
|
26
|
+
return false;
|
|
27
|
+
meetEpoch(epoch);
|
|
28
|
+
if (highest !== undefined && seq <= highest)
|
|
29
|
+
return false;
|
|
30
|
+
highest = seq;
|
|
31
|
+
return true;
|
|
32
|
+
},
|
|
33
|
+
last: () => highest,
|
|
34
|
+
epoch: () => current,
|
|
35
|
+
observeAck(streamSeq, streamEpoch) {
|
|
36
|
+
if (hasLeft(streamEpoch))
|
|
37
|
+
return false;
|
|
38
|
+
if (meetEpoch(streamEpoch))
|
|
39
|
+
return true;
|
|
40
|
+
if (streamSeq === undefined || highest === undefined || streamSeq >= highest)
|
|
41
|
+
return false;
|
|
42
|
+
highest = undefined;
|
|
43
|
+
return true;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -49,6 +49,21 @@
|
|
|
49
49
|
* the submit-action bridge + native idioms in response.
|
|
50
50
|
*/
|
|
51
51
|
import * as React from 'react';
|
|
52
|
+
/**
|
|
53
|
+
* One canonical View→Host method the spec defines (apps.mdx:963-1102).
|
|
54
|
+
* Keep this list aligned with the §"Requests (View → Host)" section.
|
|
55
|
+
* Adding a new probe button means adding one entry here + one
|
|
56
|
+
* matching renderer in the JSX below.
|
|
57
|
+
*/
|
|
58
|
+
type ProbeMethod = 'ui/message' | 'ui/update-model-context' | 'ui/open-link' | 'ui/request-display-mode' | 'tools/call';
|
|
59
|
+
/**
|
|
60
|
+
* Build a default param payload for each canonical method. The shapes
|
|
61
|
+
* mirror the spec examples (apps.mdx:963-1102) exactly so any host
|
|
62
|
+
* with a strict validator accepts them.
|
|
63
|
+
*
|
|
64
|
+
* @internal — exported for unit tests.
|
|
65
|
+
*/
|
|
66
|
+
export declare function defaultParams(method: ProbeMethod): unknown;
|
|
52
67
|
export interface ProtocolProbeCardProps {
|
|
53
68
|
/**
|
|
54
69
|
* Optional: the original prompt that triggered the probe render.
|
|
@@ -58,3 +73,4 @@ export interface ProtocolProbeCardProps {
|
|
|
58
73
|
readonly intent?: string;
|
|
59
74
|
}
|
|
60
75
|
export declare function ProtocolProbeCard({ intent, }: ProtocolProbeCardProps): React.JSX.Element;
|
|
76
|
+
export {};
|
|
@@ -51,6 +51,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
51
51
|
*/
|
|
52
52
|
import * as React from 'react';
|
|
53
53
|
import { Card, Stack, Heading, Text, Button, Badge, } from '@ggui-ai/design/primitives';
|
|
54
|
+
import { withViewProof } from '../view-origin.js';
|
|
54
55
|
const PROBE_TIMEOUT_MS = 5_000;
|
|
55
56
|
/**
|
|
56
57
|
* Lifetime-scoped JSON-RPC caller. One instance per card mount —
|
|
@@ -158,8 +159,10 @@ function useNotificationLog() {
|
|
|
158
159
|
* Build a default param payload for each canonical method. The shapes
|
|
159
160
|
* mirror the spec examples (apps.mdx:963-1102) exactly so any host
|
|
160
161
|
* with a strict validator accepts them.
|
|
162
|
+
*
|
|
163
|
+
* @internal — exported for unit tests.
|
|
161
164
|
*/
|
|
162
|
-
function defaultParams(method) {
|
|
165
|
+
export function defaultParams(method) {
|
|
163
166
|
switch (method) {
|
|
164
167
|
case 'ui/message':
|
|
165
168
|
// Empirical 2026-05-03: claude.ai's validator wants `content`
|
|
@@ -199,20 +202,20 @@ function defaultParams(method) {
|
|
|
199
202
|
// The probe uses `kind: 'dispatch'` so the chained 3-message
|
|
200
203
|
// follow-up (`ui/update-model-context` + `ui/message`)
|
|
201
204
|
// semantically matches a real submit-action click.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
},
|
|
210
|
-
sessionId: 'probe-render',
|
|
211
|
-
appId: 'probe-app',
|
|
212
|
-
actionId: '00000000', // overwritten in onClick with real FNV-1a
|
|
213
|
-
firedAt: new Date().toISOString(),
|
|
205
|
+
// Through the one wrapper, like every call of the three runtime tools
|
|
206
|
+
// (ggui#1415). The probe's fixed session is keyed by no door, so it
|
|
207
|
+
// carries a proof only if a root for it were ever held.
|
|
208
|
+
return withViewProof('ggui_runtime_submit_action', {
|
|
209
|
+
kind: 'dispatch',
|
|
210
|
+
payload: {
|
|
211
|
+
intent: 'probe-click',
|
|
212
|
+
data: { note: 'hello from the protocol probe' },
|
|
214
213
|
},
|
|
215
|
-
|
|
214
|
+
sessionId: 'probe-render',
|
|
215
|
+
appId: 'probe-app',
|
|
216
|
+
actionId: '00000000',
|
|
217
|
+
firedAt: new Date().toISOString(),
|
|
218
|
+
});
|
|
216
219
|
}
|
|
217
220
|
}
|
|
218
221
|
/**
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Whether a tool result is an error result. */
|
|
2
|
+
export declare function isErrorToolResult(result: unknown): boolean;
|
|
3
|
+
/** The text of a tool result's first text block, if it has one. */
|
|
4
|
+
export declare function toolResultText(result: unknown): string | undefined;
|
|
5
|
+
/** The domain-error code an error result carries; `undefined` for any other result. */
|
|
6
|
+
export declare function domainErrorCodeOf(result: unknown): string | undefined;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a tool result the server answered with an error (ggui#1496).
|
|
3
|
+
*
|
|
4
|
+
* A server tool that throws reaches the view, through the host's
|
|
5
|
+
* `tools/call` relay, as a result with `isError: true` whose first text
|
|
6
|
+
* block is the error's text. A domain error's text is `<code>: <detail>`,
|
|
7
|
+
* read by the protocol's own parser.
|
|
8
|
+
*/
|
|
9
|
+
import { parseDomainErrorText } from '@ggui-ai/protocol/wire';
|
|
10
|
+
/** Whether a tool result is an error result. */
|
|
11
|
+
export function isErrorToolResult(result) {
|
|
12
|
+
return typeof result === 'object' && result !== null && Reflect.get(result, 'isError') === true;
|
|
13
|
+
}
|
|
14
|
+
/** The text of a tool result's first text block, if it has one. */
|
|
15
|
+
export function toolResultText(result) {
|
|
16
|
+
if (typeof result !== 'object' || result === null)
|
|
17
|
+
return undefined;
|
|
18
|
+
const content = Reflect.get(result, 'content');
|
|
19
|
+
if (!Array.isArray(content) || content.length === 0)
|
|
20
|
+
return undefined;
|
|
21
|
+
const first = content[0];
|
|
22
|
+
if (typeof first !== 'object' || first === null)
|
|
23
|
+
return undefined;
|
|
24
|
+
const text = Reflect.get(first, 'text');
|
|
25
|
+
return typeof text === 'string' ? text : undefined;
|
|
26
|
+
}
|
|
27
|
+
/** The domain-error code an error result carries; `undefined` for any other result. */
|
|
28
|
+
export function domainErrorCodeOf(result) {
|
|
29
|
+
if (!isErrorToolResult(result))
|
|
30
|
+
return undefined;
|
|
31
|
+
return parseDomainErrorText(toolResultText(result) ?? '')?.code;
|
|
32
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -97,8 +97,56 @@ export type McpAppAiGguiMetaParseResult = {
|
|
|
97
97
|
readonly ok: true;
|
|
98
98
|
readonly meta: McpAppAiGguiRenderMeta;
|
|
99
99
|
readonly hostContext?: HostContextProjection;
|
|
100
|
+
/**
|
|
101
|
+
* The live credential the parse dropped because it had expired
|
|
102
|
+
* (ggui#1496). The meta carries no live trio then; the view may
|
|
103
|
+
* refresh this through its host.
|
|
104
|
+
*/
|
|
105
|
+
readonly held?: HeldCredential;
|
|
106
|
+
/** The slice's view key root, when it carries one (ggui#1415). */
|
|
107
|
+
readonly viewRoot?: ViewRoot;
|
|
100
108
|
} | {
|
|
101
109
|
readonly ok: false;
|
|
102
|
-
readonly reason: McpAppAiGguiMetaParseFailureReason
|
|
110
|
+
readonly reason: Exclude<McpAppAiGguiMetaParseFailureReason, 'EXPIRED_BOOTSTRAP'>;
|
|
111
|
+
} | {
|
|
112
|
+
readonly ok: false;
|
|
113
|
+
/**
|
|
114
|
+
* A live-only slice whose credential expired. It still carries the
|
|
115
|
+
* projected meta (with no live trio) and the held credential, so a
|
|
116
|
+
* view with a host relay can refresh before giving up (ggui#1496).
|
|
117
|
+
*/
|
|
118
|
+
readonly reason: 'EXPIRED_BOOTSTRAP';
|
|
119
|
+
readonly meta: McpAppAiGguiRenderMeta;
|
|
120
|
+
readonly held: HeldCredential;
|
|
121
|
+
/** The slice's view key root, captured before its credential was dropped (ggui#1415). */
|
|
122
|
+
readonly viewRoot?: ViewRoot;
|
|
103
123
|
};
|
|
124
|
+
/**
|
|
125
|
+
* A view's live credential, with the URLs that carry it (ggui#1496).
|
|
126
|
+
* Runtime-local, never a protocol member: the parse returns an expired one
|
|
127
|
+
* as `held`, and the credential controller refreshes it.
|
|
128
|
+
*/
|
|
129
|
+
export interface HeldCredential {
|
|
130
|
+
readonly wsToken: string;
|
|
131
|
+
readonly wsUrl: string;
|
|
132
|
+
/** ISO-8601, as the slice or the refresh stated it. */
|
|
133
|
+
readonly expiresAt?: string;
|
|
134
|
+
readonly sseUrl?: string;
|
|
135
|
+
readonly pollingUrl?: string;
|
|
136
|
+
/** `root`: minted with the render. `refreshed`: came back from a refresh. */
|
|
137
|
+
readonly origin: 'root' | 'refreshed';
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The key root a view proves its app-only runtime calls with (ggui#1415):
|
|
141
|
+
* `P`, the payload segment of the slice's `wsToken`, and `K`, the slice's
|
|
142
|
+
* `viewKey`. Runtime-local, never a protocol member: the parse captures it
|
|
143
|
+
* from the slice before an expired credential is dropped, and the runtime
|
|
144
|
+
* adopts it only once `P` decodes to the slice's own session.
|
|
145
|
+
*/
|
|
146
|
+
export interface ViewRoot {
|
|
147
|
+
/** `P`: the `wsToken`'s payload segment, base64url. */
|
|
148
|
+
readonly root: string;
|
|
149
|
+
/** `K`: the slice's `viewKey`, base64url. */
|
|
150
|
+
readonly key: string;
|
|
151
|
+
}
|
|
104
152
|
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document's view origin (ggui#1415): its one key-root holder, the
|
|
3
|
+
* prover over it, and the user-activation reading a dispatch signs with.
|
|
4
|
+
*
|
|
5
|
+
* One per document, like the mount it proves for. The boot and every later
|
|
6
|
+
* tool result offer their slice's root to {@link documentViewRoots}; every
|
|
7
|
+
* `tools/call` of the three app-only runtime tools goes through
|
|
8
|
+
* {@link withViewProof}. Everything here is held in this module's closure
|
|
9
|
+
* and captured when the runtime bundle evaluates, before card code runs.
|
|
10
|
+
*/
|
|
11
|
+
import type { JsonObject } from '@ggui-ai/protocol/wire';
|
|
12
|
+
import { type ViewRootHolder } from './view-root.js';
|
|
13
|
+
import { type ViewProofOptions, type ViewToolCall } from './view-proof-signer.js';
|
|
14
|
+
/** The document's view key root. */
|
|
15
|
+
export declare const documentViewRoots: ViewRootHolder;
|
|
16
|
+
/**
|
|
17
|
+
* The one wrapper for a view's `tools/call`: the normalized arguments, and a
|
|
18
|
+
* proof at `_meta["ai.ggui/view"]` for the three app-only runtime tools when
|
|
19
|
+
* the document holds a root for the call's session. Any other tool passes
|
|
20
|
+
* through untouched. Never throws.
|
|
21
|
+
*/
|
|
22
|
+
export declare function withViewProof<A extends object>(name: string, args: A, opts?: ViewProofOptions): ViewToolCall<A | JsonObject>;
|
|
23
|
+
/**
|
|
24
|
+
* Whether the current task carries transient user activation, read through
|
|
25
|
+
* the accessor captured at load. `false` where the browser does not expose
|
|
26
|
+
* it. A dispatch reads it at entry and signs it as flag bit 0: measured,
|
|
27
|
+
* never enforced.
|
|
28
|
+
*/
|
|
29
|
+
export declare function userActivationIsActive(): boolean;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createViewRootHolder } from './view-root.js';
|
|
2
|
+
import { createViewProver } from './view-proof-signer.js';
|
|
3
|
+
/** The document's view key root. */
|
|
4
|
+
export const documentViewRoots = createViewRootHolder();
|
|
5
|
+
const documentProver = createViewProver({ roots: documentViewRoots });
|
|
6
|
+
/**
|
|
7
|
+
* The one wrapper for a view's `tools/call`: the normalized arguments, and a
|
|
8
|
+
* proof at `_meta["ai.ggui/view"]` for the three app-only runtime tools when
|
|
9
|
+
* the document holds a root for the call's session. Any other tool passes
|
|
10
|
+
* through untouched. Never throws.
|
|
11
|
+
*/
|
|
12
|
+
export function withViewProof(name, args, opts) {
|
|
13
|
+
return documentProver.withViewProof(name, args, opts);
|
|
14
|
+
}
|
|
15
|
+
// `navigator.userActivation` and its `isActive` getter, captured before card
|
|
16
|
+
// code can replace either.
|
|
17
|
+
const capturedActivation = typeof navigator !== 'undefined' && navigator.userActivation !== undefined ? navigator.userActivation : undefined;
|
|
18
|
+
const capturedIsActive = capturedActivation !== undefined
|
|
19
|
+
? Object.getOwnPropertyDescriptor(Object.getPrototypeOf(capturedActivation), 'isActive')?.get
|
|
20
|
+
: undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the current task carries transient user activation, read through
|
|
23
|
+
* the accessor captured at load. `false` where the browser does not expose
|
|
24
|
+
* it. A dispatch reads it at entry and signs it as flag bit 0: measured,
|
|
25
|
+
* never enforced.
|
|
26
|
+
*/
|
|
27
|
+
export function userActivationIsActive() {
|
|
28
|
+
if (capturedActivation === undefined)
|
|
29
|
+
return false;
|
|
30
|
+
if (capturedIsActive !== undefined)
|
|
31
|
+
return capturedIsActive.call(capturedActivation) === true;
|
|
32
|
+
return capturedActivation.isActive;
|
|
33
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { MCP_APP_AI_GGUI_VIEW_META_KEY } from '@ggui-ai/protocol/integrations/mcp-apps';
|
|
2
|
+
import type { JsonObject } from '@ggui-ai/protocol/wire';
|
|
3
|
+
import type { ViewRootHolder } from './view-root.js';
|
|
4
|
+
/** A `tools/call` request's params, as a view sends them. */
|
|
5
|
+
export interface ViewToolCall<A> {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly arguments: A;
|
|
8
|
+
readonly _meta?: {
|
|
9
|
+
readonly [MCP_APP_AI_GGUI_VIEW_META_KEY]: string;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export interface ViewProofOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Whether the gesture carried transient user activation, read at dispatch
|
|
15
|
+
* entry (`navigator.userActivation.isActive`). Sets flag bit 0. Measured,
|
|
16
|
+
* never enforced: any holder of the key can set it.
|
|
17
|
+
*/
|
|
18
|
+
readonly userActivation?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface ViewProver {
|
|
21
|
+
withViewProof<A extends object>(name: string, args: A, opts?: ViewProofOptions): ViewToolCall<A | JsonObject>;
|
|
22
|
+
}
|
|
23
|
+
export interface ViewProverDeps {
|
|
24
|
+
readonly roots: ViewRootHolder;
|
|
25
|
+
/** Epoch milliseconds. Defaults to the clock captured when this module loaded. */
|
|
26
|
+
readonly now?: () => number;
|
|
27
|
+
/** Fills `bytes` with random values. Defaults to the source captured when this module loaded. */
|
|
28
|
+
readonly randomBytes?: (bytes: Uint8Array) => void;
|
|
29
|
+
}
|
|
30
|
+
/** A prover over the view's key root holder. */
|
|
31
|
+
export declare function createViewProver(deps: ViewProverDeps): ViewProver;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view's prover (ggui#1415): the one wrapper every `tools/call` of the
|
|
3
|
+
* three app-only runtime tools goes through.
|
|
4
|
+
*
|
|
5
|
+
* {@link ViewProver.withViewProof} sends the JSON-normalized arguments and,
|
|
6
|
+
* when the view holds a key root for the call's session, a v1 proof at
|
|
7
|
+
* `_meta["ai.ggui/view"]`, the only `_meta` key it sets. Any other tool
|
|
8
|
+
* passes through untouched. It never throws and never changes whether or
|
|
9
|
+
* where a call is sent: a failure means the call goes without a proof.
|
|
10
|
+
*
|
|
11
|
+
* The HMAC is synchronous (`@noble/hashes`), so wrapping a raw send keeps
|
|
12
|
+
* its place in the send order, and it works where `crypto.subtle` is absent
|
|
13
|
+
* (a plain-http self-host).
|
|
14
|
+
*/
|
|
15
|
+
import { hmac } from '@noble/hashes/hmac';
|
|
16
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
17
|
+
import { MCP_APP_AI_GGUI_VIEW_META_KEY, VIEW_PROOF_FLAG_USER_ACTIVATION, VIEW_PROOF_V1_PATTERN, formatViewProofV1, isViewProofTool, viewProofArgsBytes, viewProofCallBytes, } from '@ggui-ai/protocol/integrations/mcp-apps';
|
|
18
|
+
// Captured when the runtime bundle evaluates, before any card code runs in
|
|
19
|
+
// this realm, so a card that replaces these globals later does not reach
|
|
20
|
+
// the prover.
|
|
21
|
+
const capturedNow = Date.now.bind(Date);
|
|
22
|
+
const capturedCrypto = typeof crypto !== 'undefined' ? crypto : undefined;
|
|
23
|
+
const capturedGetRandomValues = capturedCrypto?.getRandomValues.bind(capturedCrypto);
|
|
24
|
+
function capturedRandomBytes(bytes) {
|
|
25
|
+
if (capturedGetRandomValues === undefined)
|
|
26
|
+
throw new Error('no random source');
|
|
27
|
+
capturedGetRandomValues(bytes);
|
|
28
|
+
}
|
|
29
|
+
const NONCE_BYTES = 16;
|
|
30
|
+
function toBase64Url(bytes) {
|
|
31
|
+
let binary = '';
|
|
32
|
+
for (const byte of bytes)
|
|
33
|
+
binary += String.fromCharCode(byte);
|
|
34
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
35
|
+
}
|
|
36
|
+
const BASE64URL = /^[A-Za-z0-9_-]*$/;
|
|
37
|
+
function fromBase64Url(text) {
|
|
38
|
+
if (!BASE64URL.test(text))
|
|
39
|
+
throw new Error('not base64url');
|
|
40
|
+
const padded = text.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((text.length + 3) % 4);
|
|
41
|
+
return Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0));
|
|
42
|
+
}
|
|
43
|
+
function isJsonObject(value) {
|
|
44
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
/** The arguments as JSON carries them, or `undefined` when JSON cannot carry them. */
|
|
47
|
+
function normalize(args) {
|
|
48
|
+
try {
|
|
49
|
+
const text = JSON.stringify(args);
|
|
50
|
+
if (text === undefined)
|
|
51
|
+
return undefined;
|
|
52
|
+
const value = JSON.parse(text);
|
|
53
|
+
return isJsonObject(value) ? value : undefined;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// A BigInt or a cycle: JSON cannot carry these arguments, so there is
|
|
57
|
+
// nothing to sign, and the call goes as the caller built it.
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** A prover over the view's key root holder. */
|
|
62
|
+
export function createViewProver(deps) {
|
|
63
|
+
const now = deps.now ?? capturedNow;
|
|
64
|
+
const randomBytes = deps.randomBytes ?? capturedRandomBytes;
|
|
65
|
+
return {
|
|
66
|
+
withViewProof(name, args, opts) {
|
|
67
|
+
if (!isViewProofTool(name))
|
|
68
|
+
return { name, arguments: args };
|
|
69
|
+
const normalized = normalize(args);
|
|
70
|
+
if (normalized === undefined)
|
|
71
|
+
return { name, arguments: args };
|
|
72
|
+
const sessionId = normalized['sessionId'];
|
|
73
|
+
const root = typeof sessionId === 'string' ? deps.roots.current(sessionId) : undefined;
|
|
74
|
+
if (root === undefined)
|
|
75
|
+
return { name, arguments: normalized };
|
|
76
|
+
try {
|
|
77
|
+
const key = fromBase64Url(root.key);
|
|
78
|
+
const argmac = toBase64Url(hmac(sha256, key, viewProofArgsBytes(name, normalized)));
|
|
79
|
+
const nonceBytes = new Uint8Array(NONCE_BYTES);
|
|
80
|
+
randomBytes(nonceBytes);
|
|
81
|
+
const nonce = toBase64Url(nonceBytes);
|
|
82
|
+
const vtime = String(Math.trunc(now()));
|
|
83
|
+
const flags = (opts?.userActivation === true ? VIEW_PROOF_FLAG_USER_ACTIVATION : 0).toString(16);
|
|
84
|
+
const callmac = toBase64Url(hmac(sha256, key, viewProofCallBytes({ toolName: name, nonce, vtime, flags, argmac })));
|
|
85
|
+
const proof = formatViewProofV1({ root: root.root, nonce, vtime, flags, argmac, callmac });
|
|
86
|
+
// A clock or a key outside the grammar makes a string no verifier
|
|
87
|
+
// would parse: send none rather than one that can only fail.
|
|
88
|
+
if (!VIEW_PROOF_V1_PATTERN.test(proof))
|
|
89
|
+
return { name, arguments: normalized };
|
|
90
|
+
return { name, arguments: normalized, _meta: { [MCP_APP_AI_GGUI_VIEW_META_KEY]: proof } };
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Total by contract: a failed mint sends the call without a proof,
|
|
94
|
+
// which the server counts as `missing`, never a failed call.
|
|
95
|
+
return { name, arguments: normalized };
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view's key root (ggui#1415): what the parse captures from a render
|
|
3
|
+
* slice, and which root the view proves its app-only runtime calls with.
|
|
4
|
+
*
|
|
5
|
+
* A slice that carries a `viewKey` roots it in its `wsToken`: `K` is
|
|
6
|
+
* derived by the server from `P`, the token's payload segment. The parse
|
|
7
|
+
* takes the pair before an expired credential is dropped from the meta
|
|
8
|
+
* ({@link viewRootOf}), and {@link createViewRootHolder} decides whether a
|
|
9
|
+
* later slice's root replaces the one the view holds.
|
|
10
|
+
*/
|
|
11
|
+
import { type McpAppAiGguiRenderMeta, type ViewRootClaims } from '@ggui-ai/protocol/integrations/mcp-apps';
|
|
12
|
+
import type { ViewRoot } from './types.js';
|
|
13
|
+
/**
|
|
14
|
+
* The slice's key root: `P` from its `wsToken`, `K` from its `viewKey`.
|
|
15
|
+
* `undefined` when either is absent, since a key means nothing without the
|
|
16
|
+
* token it is rooted in. The protocol parser already keeps a `viewKey` only
|
|
17
|
+
* beside its ws envelope and only in its shape.
|
|
18
|
+
*/
|
|
19
|
+
export declare function viewRootOf(meta: Pick<McpAppAiGguiRenderMeta, 'wsToken' | 'viewKey'>): ViewRoot | undefined;
|
|
20
|
+
/** A held root, with the claims its `P` decoded to. */
|
|
21
|
+
export interface HeldViewRoot extends ViewRoot {
|
|
22
|
+
readonly claims: ViewRootClaims;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Why an offered root was not adopted. `no_key`: the slice carried none (a
|
|
26
|
+
* `/state` poll, a keyless door). `oversize`: `P` is longer than a proof can
|
|
27
|
+
* carry. `malformed`: `P` does not decode to ws claims. `session_mismatch`:
|
|
28
|
+
* `P` names another session than the slice. `not_newer`: the view already
|
|
29
|
+
* holds a root for this session at the same or a later `iat`.
|
|
30
|
+
*/
|
|
31
|
+
export type ViewRootRefusal = 'no_key' | 'oversize' | 'malformed' | 'session_mismatch' | 'not_newer';
|
|
32
|
+
export type ViewRootOffer = {
|
|
33
|
+
readonly adopted: true;
|
|
34
|
+
} | {
|
|
35
|
+
readonly adopted: false;
|
|
36
|
+
readonly reason: ViewRootRefusal;
|
|
37
|
+
};
|
|
38
|
+
export interface ViewRootHolder {
|
|
39
|
+
/** Offer the root a slice for `sessionId` carried. */
|
|
40
|
+
offer(sessionId: string, root: ViewRoot | undefined): ViewRootOffer;
|
|
41
|
+
/** The held root, only when it was adopted for `sessionId`. */
|
|
42
|
+
current(sessionId: string): HeldViewRoot | undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* One view's key root. It adopts a root only when `P` fits a proof and
|
|
46
|
+
* decodes to the offering slice's own session. For the session it already
|
|
47
|
+
* holds, it adopts only a greater `iat`, so a replayed or reordered slice
|
|
48
|
+
* never winds the root back. A slice for another session replaces it: a
|
|
49
|
+
* view proves calls only for the session they name.
|
|
50
|
+
*/
|
|
51
|
+
export declare function createViewRootHolder(): ViewRootHolder;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view's key root (ggui#1415): what the parse captures from a render
|
|
3
|
+
* slice, and which root the view proves its app-only runtime calls with.
|
|
4
|
+
*
|
|
5
|
+
* A slice that carries a `viewKey` roots it in its `wsToken`: `K` is
|
|
6
|
+
* derived by the server from `P`, the token's payload segment. The parse
|
|
7
|
+
* takes the pair before an expired credential is dropped from the meta
|
|
8
|
+
* ({@link viewRootOf}), and {@link createViewRootHolder} decides whether a
|
|
9
|
+
* later slice's root replaces the one the view holds.
|
|
10
|
+
*/
|
|
11
|
+
import { VIEW_PROOF_ROOT_MAX_CHARS, decodeViewRootClaims, } from '@ggui-ai/protocol/integrations/mcp-apps';
|
|
12
|
+
/**
|
|
13
|
+
* The slice's key root: `P` from its `wsToken`, `K` from its `viewKey`.
|
|
14
|
+
* `undefined` when either is absent, since a key means nothing without the
|
|
15
|
+
* token it is rooted in. The protocol parser already keeps a `viewKey` only
|
|
16
|
+
* beside its ws envelope and only in its shape.
|
|
17
|
+
*/
|
|
18
|
+
export function viewRootOf(meta) {
|
|
19
|
+
const { wsToken, viewKey } = meta;
|
|
20
|
+
if (typeof viewKey !== 'string' || viewKey.length === 0)
|
|
21
|
+
return undefined;
|
|
22
|
+
if (typeof wsToken !== 'string' || wsToken.length === 0)
|
|
23
|
+
return undefined;
|
|
24
|
+
return { root: wsToken.split('.')[0] ?? '', key: viewKey };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* One view's key root. It adopts a root only when `P` fits a proof and
|
|
28
|
+
* decodes to the offering slice's own session. For the session it already
|
|
29
|
+
* holds, it adopts only a greater `iat`, so a replayed or reordered slice
|
|
30
|
+
* never winds the root back. A slice for another session replaces it: a
|
|
31
|
+
* view proves calls only for the session they name.
|
|
32
|
+
*/
|
|
33
|
+
export function createViewRootHolder() {
|
|
34
|
+
let held;
|
|
35
|
+
return {
|
|
36
|
+
offer(sessionId, root) {
|
|
37
|
+
if (root === undefined)
|
|
38
|
+
return { adopted: false, reason: 'no_key' };
|
|
39
|
+
if (root.root.length > VIEW_PROOF_ROOT_MAX_CHARS)
|
|
40
|
+
return { adopted: false, reason: 'oversize' };
|
|
41
|
+
const claims = decodeViewRootClaims(root.root);
|
|
42
|
+
if (typeof claims === 'string')
|
|
43
|
+
return { adopted: false, reason: 'malformed' };
|
|
44
|
+
if (claims.sessionId !== sessionId)
|
|
45
|
+
return { adopted: false, reason: 'session_mismatch' };
|
|
46
|
+
if (held !== undefined && held.claims.sessionId === sessionId && claims.iat <= held.claims.iat) {
|
|
47
|
+
return { adopted: false, reason: 'not_newer' };
|
|
48
|
+
}
|
|
49
|
+
held = { root: root.root, key: root.key, claims };
|
|
50
|
+
return { adopted: true };
|
|
51
|
+
},
|
|
52
|
+
current(sessionId) {
|
|
53
|
+
return held !== undefined && held.claims.sessionId === sessionId ? held : undefined;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/wire-config.d.ts
CHANGED
|
@@ -51,9 +51,17 @@ export interface BuildRootWireConfigOptions {
|
|
|
51
51
|
/**
|
|
52
52
|
* The runtime's notice that the current render was replaced (a props
|
|
53
53
|
* update, a later live frame), so the card's spent state is re-read at
|
|
54
|
-
* once (ggui#1223)
|
|
54
|
+
* once (ggui#1223), and the card's in-flight dispatches clear (ggui#1398).
|
|
55
|
+
* Passed through as wire's `spentInputsChanged` and `pendingInputsChanged`.
|
|
55
56
|
*/
|
|
56
57
|
readonly renderChanges?: (listener: () => void) => () => void;
|
|
58
|
+
/**
|
|
59
|
+
* ggui#1424 — the `oneShot` names the SERVER refused for this card as
|
|
60
|
+
* already spent (a `CONTRACT_VIOLATION` at `actionSpec.<name>.oneShot`),
|
|
61
|
+
* which the runtime marks so the guard reads them beside the card's
|
|
62
|
+
* persisted record. Read on every dispatch; `undefined` means none.
|
|
63
|
+
*/
|
|
64
|
+
readonly refusedSpentOneShots?: () => readonly string[] | undefined;
|
|
57
65
|
/** Handle to the renderer's WS manager; used for outbound `action` frames. */
|
|
58
66
|
readonly manager: RendererSendSurface;
|
|
59
67
|
/** Shared bus for inbound stream deliveries. */
|
|
@@ -115,6 +123,12 @@ export interface BuildRootWireConfigOptions {
|
|
|
115
123
|
* relay (see iframe-runtime's `routeDispatch`).
|
|
116
124
|
*/
|
|
117
125
|
readonly onDispatchEnvelope?: (envelope: ActionEnvelope) => void;
|
|
126
|
+
/**
|
|
127
|
+
* Called with the action's name when the outbound check refuses a
|
|
128
|
+
* dispatch (ggui#1536), after the refusal is posted to the embedding
|
|
129
|
+
* host. The renderer tells the visitor the tap did nothing.
|
|
130
|
+
*/
|
|
131
|
+
readonly onDispatchRefused?: (actionName: string) => void;
|
|
118
132
|
}
|
|
119
133
|
/**
|
|
120
134
|
* Build the per-render `WireConfig` for the iframe runtime.
|