@cef-ai/widget-runtime 2.0.0 → 2.1.1

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 CHANGED
@@ -91,7 +91,7 @@ something the widget never sent.
91
91
  | Message | Direction | Fields |
92
92
  | --- | --- | --- |
93
93
  | `cef-widget:identity-request` | widget → host | `requestId: string`, `v: number` |
94
- | `cef-widget:identity-response` | host → widget | `requestId: string`, `v?: number`, `pubkey?: string`, `sigType?: 'ed25519' \| 'sr25519'` (default `ed25519`), `error?: string`, `errorCode?: 'unsupported-version'` |
94
+ | `cef-widget:identity-response` | host → widget | `requestId: string`, `v?: number`, `pubkey?: string`, `sigType?: 'ed25519' \| 'sr25519'` (default `ed25519`), `token?: string` (a bearer delegation the widget uses instead of asking you to sign), `context?: { runId?, recordId?, vaultId? }`, `error?: string`, `errorCode?: 'unsupported-version'` |
95
95
  | `cef-widget:sign-request` | widget → host | `requestId: string`, `v: number`, `bytes: number[]` |
96
96
  | `cef-widget:sign-response` | host → widget | `requestId: string`, `v?: number`, `signature: number[] \| null` (`null` = user declined), `error?: string`, `errorCode?: 'unsupported-version'` |
97
97
 
@@ -105,6 +105,15 @@ Rules:
105
105
  answers with `errorCode: 'unsupported-version'` — its own reply is still a v1
106
106
  message, so `v` itself cannot carry the rejection, and without the code the
107
107
  receiver would just see an opaque error string and keep retrying.
108
+ - **Which vault (`context.vaultId`).** By default a widget opens the
109
+ **caller's own** vault (`vault.ensure()`), and a `token` does not change that
110
+ — a delegation says *who*, not *which vault*. A workflow, meanwhile, runs in
111
+ the vault of the org that owns its agent service, so a host showing somebody
112
+ else's run must name that vault or the widget reads a different database and
113
+ reports it as empty. Optional: a host that sends none gets exactly today's
114
+ behaviour. A host that **does** send one and the widget cannot open it gets
115
+ `WidgetVaultUnreachableError` — the runtime refuses rather than falling back,
116
+ because a fallback is indistinguishable from a run that produced nothing.
108
117
  - **Sign verbatim.** The host must sign the exact bytes it receives with
109
118
  **`ed25519_signRaw`**. Do **not** use the high-level `signMessage` /
110
119
  `wallet_signMessage`, which wrap the payload in Substrate's
package/dist/entry.js CHANGED
@@ -47,7 +47,23 @@ function boot() {
47
47
  // Remembered for buildVaultClient, which is synchronous and runs after
48
48
  // this. The resolver is memoised, so this is the same identity.
49
49
  hostDelegation = id.token;
50
- return { source: id.source, publicKey: id.publicKey, sigType: id.sigType };
50
+ /*
51
+ * THE CONTEXT TRAVELS THE LAST STEP TOO.
52
+ *
53
+ * It was dropped here: the resolver put the host's `context` on the
54
+ * resolved identity, `createWidgetRuntime` reads `id.context` and hands
55
+ * it to `identity()` — and this literal, the only production
56
+ * implementation of `resolveIdentity`, rebuilt the object without it. So
57
+ * the handshake carried the run all the way to the runtime's front door
58
+ * and no widget could ever read it. Spread rather than re-listed, so the
59
+ * next key on the bag does not have to remember to come through here.
60
+ */
61
+ return {
62
+ source: id.source,
63
+ publicKey: id.publicKey,
64
+ sigType: id.sigType,
65
+ ...(id.context ? { context: id.context } : {}),
66
+ };
51
67
  },
52
68
  buildVaultClient: (m, id) => {
53
69
  const sigType = id.sigType ?? 'ed25519';
@@ -69,7 +85,12 @@ function boot() {
69
85
  // asked for a passkey forever. `sign` stays wired for hosts that supply
70
86
  // no token.
71
87
  const makeSdk = () => new VaultSDK(toVaultSdkConfig(m.endpoints, vaultSdkSigner, hostToken));
72
- return createVaultClient(m, makeSdk, hostToken);
88
+ // WHICH VAULT, when the host named one. It rides `context` rather than
89
+ // a field of its own because it is the same kind of fact as the run id —
90
+ // why this widget was opened — and because an optional key on an
91
+ // already-optional bag is the only shape that leaves a host which sends
92
+ // nothing behaving exactly as it does today.
93
+ return createVaultClient(m, makeSdk, hostToken, id.context?.vaultId);
73
94
  },
74
95
  };
75
96
  // Build the mount deps (audio uploader) for config-driven audio kinds.
@@ -101,7 +122,13 @@ function boot() {
101
122
  // the same `chainUrl`, since this `ensure({ onboard: false })` runs
102
123
  // the vault-delegate grant step too.
103
124
  const sdk = new VaultSDK(toVaultSdkConfig(manifest.endpoints, vaultSdkSigner, id.token));
104
- const ensuredVault = await sdk.vault.ensure({ onboard: false });
125
+ // With a token that SDK has no signer, and `ensure()` does not read
126
+ // `auth`: it needs the token handed to it, or it throws before the
127
+ // first chunk. Without one the key stays absent so the signer path runs.
128
+ const ensuredVault = await sdk.vault.ensure({
129
+ onboard: false,
130
+ ...(id.token ? { delegationToken: id.token } : {}),
131
+ });
105
132
  return createVaultObjectUploader({ objects: ensuredVault.scope(manifest.scope).objects });
106
133
  })().catch((err) => { uploaderP = null; throw err; });
107
134
  return uploaderP;
@@ -33,6 +33,31 @@ export interface WidgetHostContext {
33
33
  runId?: string;
34
34
  /** One record, when the widget was opened from a row rather than a run. */
35
35
  recordId?: string;
36
+ /**
37
+ * WHICH VAULT THE RUN HAPPENED IN — and therefore which one to read.
38
+ *
39
+ * A widget authenticates as the viewer and then opened the VIEWER'S OWN
40
+ * vault, because `vault.ensure()` claims the caller's vault and a delegation
41
+ * says WHO, not WHICH. A workflow, meanwhile, runs in the vault of the org
42
+ * that owns its agent SERVICE. Those are the same database only when the
43
+ * viewer happens to own that org — so for a member of somebody else's
44
+ * service the widget read a different database, found nothing, and said so:
45
+ * "No candidate has been reviewed yet" about a run whose own INSERT had
46
+ * reported `rowsAffected: 1`. An empty answer and a wrong-database answer
47
+ * were indistinguishable.
48
+ *
49
+ * OPTIONAL, and that is the compatibility story — the same one `token` and
50
+ * `context` itself have. A host that sends none gets exactly today's
51
+ * `ensure()`, the viewer's own vault, so every already-deployed widget and
52
+ * every host that has not been taught this key is untouched.
53
+ *
54
+ * CONTEXT, NOT A COMMAND still holds, in the only sense it can here: a widget
55
+ * that reads nothing from `context` never notices this key. But the runtime
56
+ * acts on it before any widget code runs, and when it cannot open the named
57
+ * vault it REFUSES — see `WidgetVaultUnreachableError`. Falling back to the
58
+ * viewer's own vault is what produced the confident lie above.
59
+ */
60
+ vaultId?: string;
36
61
  }
37
62
  export interface IdentityRequest {
38
63
  type: typeof IDENTITY_REQUEST_TYPE;
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export { renderSubmit } from './kinds/submit.js';
12
12
  export { renderConversation } from './kinds/conversation.js';
13
13
  export { renderComposite, registerCompositePanel, getCompositePanel, type CompositePanelRenderer } from './kinds/composite.js';
14
14
  export * from './render-helpers.js';
15
+ export { WidgetVaultUnreachableError } from './vault-client.js';
15
16
  export type { WidgetKind, WidgetConfig, RecordConfig, ListConfig, DashboardConfig, SubmitConfig, ConversationConfig, CustomPanelConfig, CompositePanel, CompositeConfig } from './types.js';
16
17
  export { captureAndUploadAudio, createVaultObjectUploader, createVaultObjectStore } from './audio/capture-upload.js';
17
18
  export type { CaptureUploadInput, CaptureUploadDeps, VaultObjectUploaderConfig, VaultObjectsLike, VaultStoreConfig, VaultObjectStore } from './audio/capture-upload.js';
@@ -21,6 +22,7 @@ export { encodeWav } from './audio/wav.js';
21
22
  export { segmentPcm, decodeToMono16k } from './audio/segment.js';
22
23
  export { requestMicStream } from './audio/mic.js';
23
24
  export { createHostBridge, type HostBridge } from './host-bridge.js';
25
+ export type { WidgetHostContext } from './host-bridge.js';
24
26
  export { IDENTITY_REQUEST_TYPE, IDENTITY_RESPONSE_TYPE, SIGN_REQUEST_TYPE, SIGN_RESPONSE_TYPE, PROTOCOL_VERSION, UNSUPPORTED_VERSION_CODE, MAX_SIGN_BYTES, isByteArray, WidgetProtocolVersionError, WidgetMalformedMessageError, } from './host-bridge.js';
25
27
  export type { SigType, IdentityRequest, IdentityResponse, SignRequest, SignResponse, } from './host-bridge.js';
26
28
  export { createWidgetHost, type WidgetHostOptions, type WidgetHostIdentity } from './widget-host.js';
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ export { renderSubmit } from './kinds/submit.js';
11
11
  export { renderConversation } from './kinds/conversation.js';
12
12
  export { renderComposite, registerCompositePanel, getCompositePanel } from './kinds/composite.js';
13
13
  export * from './render-helpers.js';
14
+ export { WidgetVaultUnreachableError } from './vault-client.js';
14
15
  export { captureAndUploadAudio, createVaultObjectUploader, createVaultObjectStore } from './audio/capture-upload.js';
15
16
  export { mintDelegationToken, Operation } from './audio/ddc-delegation.js';
16
17
  export { encodeWav } from './audio/wav.js';
package/dist/mount.js CHANGED
@@ -27,6 +27,32 @@ export async function mountKind(root, config, runtime, deps) {
27
27
  await renderer(root, config, runtime, deps);
28
28
  }
29
29
  catch (err) {
30
+ /*
31
+ * THE RUN IS SOMEWHERE THIS WIDGET CANNOT LOOK — said here, once, rather
32
+ * than in each widget.
33
+ *
34
+ * This case used to arrive as zero rows, so every widget answered it with
35
+ * its own `empty` copy: "No candidate has been reviewed yet", about a run
36
+ * whose own INSERT had reported `rowsAffected: 1`. No widget can tell an
37
+ * empty result from a wrong-database result, so no widget should have to
38
+ * — the runtime is what knows which vault it could not open, and it is
39
+ * the only layer that can name it.
40
+ *
41
+ * Matched on `name`, like AgentNotConnectedError below, so an error that
42
+ * crossed a bundle boundary still lands here — widget-runtime ships both
43
+ * as this package and as a vendored IIFE.
44
+ */
45
+ if (err && err.name === 'WidgetVaultUnreachableError') {
46
+ const vaultId = err.vaultId;
47
+ root.innerHTML =
48
+ '<p class="error">This run executed in a vault this widget cannot read'
49
+ + (vaultId ? ` (<code>${esc(vaultId)}</code>)` : '')
50
+ + '.</p>'
51
+ + '<p class="muted">Nothing is shown, on purpose: your own vault is a different '
52
+ + 'database, and reading that one here would look exactly like a run that produced '
53
+ + 'nothing. Ask an admin of that organization for access.</p>';
54
+ return;
55
+ }
30
56
  if (err && err.name === 'AgentNotConnectedError') {
31
57
  root.innerHTML = `<p class="muted">Connect your account to view this.</p><button id="cta">Connect</button>`;
32
58
  root.querySelector('#cta').addEventListener('click', async () => {
@@ -5,8 +5,42 @@ export interface VaultSdkLike {
5
5
  onboard?: boolean;
6
6
  delegationToken?: string;
7
7
  }) => Promise<VaultLike>;
8
+ /**
9
+ * SOMEBODY ELSE'S VAULT, by id — what `ensure()` structurally cannot give
10
+ * you. `ensure` claims the CALLER'S OWN vault, and the host's delegation
11
+ * authenticates the caller rather than naming a vault, so with only those
12
+ * two a widget opened on an org's run read the viewer's own database.
13
+ *
14
+ * OPTIONAL on this structural type because a host pinned to an older
15
+ * vault-sdk does not have it — and a widget the host named a vault for must
16
+ * then refuse rather than quietly read the wrong one.
17
+ */
18
+ byId?: (vaultId: string) => Promise<VaultLike>;
8
19
  };
9
20
  }
21
+ /**
22
+ * The host named a vault this widget cannot open.
23
+ *
24
+ * WHY THIS IS AN ERROR AND NOT AN EMPTY RESULT, which is the whole point of the
25
+ * type. The failure it replaces was silent: the widget opened the wrong vault,
26
+ * queried it successfully, got zero rows, and rendered "No candidate has been
27
+ * reviewed yet" — about a run whose own `_cubbyQueries` log showed the INSERT
28
+ * landing with `rowsAffected: 1`. A confident lie, indistinguishable from the
29
+ * honest empty case, and it cost hours. So when the id is supplied and unusable
30
+ * the runtime says so instead of answering; there is no fallback path, because
31
+ * the fallback IS the bug.
32
+ *
33
+ * Shaped after `WidgetSignedOutError`: a named class whose message names itself
34
+ * first, so it reads the same in a console, in a rejected promise, and in the
35
+ * frame `mountKind` renders it into.
36
+ */
37
+ export declare class WidgetVaultUnreachableError extends Error {
38
+ /** The vault the host named. Carried so a renderer can name it too. */
39
+ readonly vaultId: string;
40
+ /** What `vault.byId` said, when it said anything. */
41
+ readonly reason: string;
42
+ constructor(vaultId: string, cause?: unknown);
43
+ }
10
44
  interface VaultLike {
11
45
  id: string;
12
46
  agents: {
@@ -86,5 +120,20 @@ export declare function createVaultClient(manifest: WidgetManifest, makeSdk: ()
86
120
  * one. The token IS the authorization, which is exactly the case `ensure`
87
121
  * documents `delegationToken` for.
88
122
  */
89
- hostToken?: string): VaultClient;
123
+ hostToken?: string,
124
+ /**
125
+ * WHICH VAULT, when the host named one (`WidgetHostContext.vaultId`).
126
+ *
127
+ * WHY IT IS SEPARATE FROM THE TOKEN, which is the mistake this fixes. The
128
+ * host already passed a credential, so the widget authenticated fine and
129
+ * raised no passkey — but a delegation says WHO, not WHICH VAULT, and
130
+ * `ensure()` claims the caller's OWN. A workflow runs in the vault of the org
131
+ * that owns its agent service, so for a member of somebody else's service the
132
+ * two are different databases and the widget read the empty one.
133
+ *
134
+ * ABSENT IS THE DEFAULT AND STAYS EXACTLY AS IT WAS: `ensure({onboard:false})`
135
+ * with the host's delegation. Present means open THAT vault and, if it cannot
136
+ * be opened, refuse — see {@link WidgetVaultUnreachableError}.
137
+ */
138
+ hostVaultId?: string): VaultClient;
90
139
  export {};
@@ -1,5 +1,34 @@
1
1
  // packages/widget-runtime/src/vault-client.ts
2
2
  import {} from './types.js';
3
+ /**
4
+ * The host named a vault this widget cannot open.
5
+ *
6
+ * WHY THIS IS AN ERROR AND NOT AN EMPTY RESULT, which is the whole point of the
7
+ * type. The failure it replaces was silent: the widget opened the wrong vault,
8
+ * queried it successfully, got zero rows, and rendered "No candidate has been
9
+ * reviewed yet" — about a run whose own `_cubbyQueries` log showed the INSERT
10
+ * landing with `rowsAffected: 1`. A confident lie, indistinguishable from the
11
+ * honest empty case, and it cost hours. So when the id is supplied and unusable
12
+ * the runtime says so instead of answering; there is no fallback path, because
13
+ * the fallback IS the bug.
14
+ *
15
+ * Shaped after `WidgetSignedOutError`: a named class whose message names itself
16
+ * first, so it reads the same in a console, in a rejected promise, and in the
17
+ * frame `mountKind` renders it into.
18
+ */
19
+ export class WidgetVaultUnreachableError extends Error {
20
+ constructor(vaultId, cause) {
21
+ const reason = cause === undefined
22
+ ? 'this build of the runtime has no vault.byId to open it with'
23
+ : (cause instanceof Error ? cause.message : String(cause));
24
+ super(`WidgetVaultUnreachableError: the host opened this widget on vault ${vaultId}, `
25
+ + `and it cannot be read from here (${reason}). Falling back to your own vault would `
26
+ + 'answer from a different database — which looks exactly like a run that produced nothing.');
27
+ this.name = 'WidgetVaultUnreachableError';
28
+ this.vaultId = vaultId;
29
+ this.reason = reason;
30
+ }
31
+ }
3
32
  /**
4
33
  * Normalise the raw result from cubbies.query into a uniform QueryResult.
5
34
  * The platform returns `{columns: string[], rows: unknown[][]}` (positional).
@@ -42,18 +71,58 @@ export function createVaultClient(manifest, makeSdk,
42
71
  * one. The token IS the authorization, which is exactly the case `ensure`
43
72
  * documents `delegationToken` for.
44
73
  */
45
- hostToken) {
74
+ hostToken,
75
+ /**
76
+ * WHICH VAULT, when the host named one (`WidgetHostContext.vaultId`).
77
+ *
78
+ * WHY IT IS SEPARATE FROM THE TOKEN, which is the mistake this fixes. The
79
+ * host already passed a credential, so the widget authenticated fine and
80
+ * raised no passkey — but a delegation says WHO, not WHICH VAULT, and
81
+ * `ensure()` claims the caller's OWN. A workflow runs in the vault of the org
82
+ * that owns its agent service, so for a member of somebody else's service the
83
+ * two are different databases and the widget read the empty one.
84
+ *
85
+ * ABSENT IS THE DEFAULT AND STAYS EXACTLY AS IT WAS: `ensure({onboard:false})`
86
+ * with the host's delegation. Present means open THAT vault and, if it cannot
87
+ * be opened, refuse — see {@link WidgetVaultUnreachableError}.
88
+ */
89
+ hostVaultId) {
46
90
  let sdk;
47
91
  let vaultP;
48
92
  const getSdk = () => (sdk ?? (sdk = makeSdk()));
93
+ /**
94
+ * THE VAULT THIS WIDGET READS — the one the host named, else the caller's.
95
+ *
96
+ * There is deliberately no third branch. A named vault that will not open is
97
+ * an error, never a quiet fall-through to `ensure()`: the fall-through is
98
+ * indistinguishable from an empty result, and being indistinguishable from an
99
+ * empty result is the bug.
100
+ */
101
+ const openVault = async () => {
102
+ if (!hostVaultId) {
103
+ // `onboard: false` preserves the old lightweight ensure(): claim the vault
104
+ // with a wallet-built delegation token, skipping the gateway onboarding
105
+ // status/bootstrap round-trip (widgets assume a provisioned wallet).
106
+ return getSdk().vault.ensure({
107
+ onboard: false,
108
+ ...(hostToken ? { delegationToken: hostToken } : {}),
109
+ });
110
+ }
111
+ const vaultNs = getSdk().vault;
112
+ if (typeof vaultNs.byId !== 'function') {
113
+ throw new WidgetVaultUnreachableError(hostVaultId);
114
+ }
115
+ try {
116
+ return await vaultNs.byId(hostVaultId);
117
+ }
118
+ catch (err) {
119
+ // Not found, no access, network — all one answer to the reader: this is
120
+ // not your vault and nothing here can show you what is in it.
121
+ throw new WidgetVaultUnreachableError(hostVaultId, err);
122
+ }
123
+ };
49
124
  const getVault = () => {
50
- // `onboard: false` preserves the old lightweight ensure(): claim the vault
51
- // with a wallet-built delegation token, skipping the gateway onboarding
52
- // status/bootstrap round-trip (widgets assume a provisioned wallet).
53
- vaultP ?? (vaultP = getSdk().vault.ensure({
54
- onboard: false,
55
- ...(hostToken ? { delegationToken: hostToken } : {}),
56
- }).catch((err) => {
125
+ vaultP ?? (vaultP = openVault().catch((err) => {
57
126
  vaultP = undefined;
58
127
  throw err;
59
128
  }));