@cef-ai/widget-runtime 1.1.1 → 2.1.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.
@@ -1,11 +1,46 @@
1
- import { type WidgetManifest, type QueryResult } from './types.js';
1
+ import { type QueryResult, type WidgetManifest } from './types.js';
2
2
  export interface VaultSdkLike {
3
3
  vault: {
4
4
  ensure: (opts?: {
5
5
  onboard?: boolean;
6
+ delegationToken?: string;
6
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>;
7
19
  };
8
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
+ }
9
44
  interface VaultLike {
10
45
  id: string;
11
46
  agents: {
@@ -25,6 +60,31 @@ interface VaultLike {
25
60
  eventId: string;
26
61
  }>;
27
62
  };
63
+ /**
64
+ * The agent-SERVICE cubbies: one database per service, global within the
65
+ * vault, so a widget reads what its agent's siblings wrote wherever the run
66
+ * came from. Optional on the structural type because a host running an older
67
+ * vault-sdk does not have `vault.service`.
68
+ */
69
+ service?: (asPubKey: string) => {
70
+ cubby: (alias: string) => CubbyHandleLike;
71
+ };
72
+ /**
73
+ * The vault's memory bank — the read half. Same `{columns, rows}` shape the
74
+ * cubby returns, which is why one normaliser serves both.
75
+ */
76
+ memory?: MemoryLike;
77
+ }
78
+ /** The bank's read surface, as much of it as a widget can name. */
79
+ interface MemoryLike {
80
+ search: (match: string, opts?: {
81
+ limit?: number;
82
+ }) => Promise<unknown>;
83
+ get: (id: string) => Promise<unknown>;
84
+ neighbours: (id: string, opts?: {
85
+ limit?: number;
86
+ }) => Promise<unknown>;
87
+ countByType: () => Promise<unknown>;
28
88
  }
29
89
  interface CubbyHandleLike {
30
90
  query: (sql: string, params?: unknown[]) => Promise<unknown>;
@@ -41,5 +101,39 @@ export interface VaultClient {
41
101
  connectAgent(): Promise<void>;
42
102
  agentStatus(): Promise<'connected' | 'disconnected'>;
43
103
  }
44
- export declare function createVaultClient(manifest: WidgetManifest, makeSdk: () => VaultSdkLike): VaultClient;
104
+ export declare function createVaultClient(manifest: WidgetManifest, makeSdk: () => VaultSdkLike,
105
+ /**
106
+ * The host's delegation, when it gave one — handed to `ensure()` as well as
107
+ * used for auth.
108
+ *
109
+ * WHY BOTH. With a token the SDK is built with `auth` and NO `signer`, on
110
+ * purpose: the handle structurally cannot sign, which is what keeps a keyless
111
+ * passkey session from stepping up on every request. But `vault.ensure()`
112
+ * does not look at `auth` — it needs a `delegationToken` or a `signer`, and
113
+ * with neither it throws before the first query runs:
114
+ *
115
+ * vault.ensure() needs either a `delegationToken` or a `signer` configured
116
+ * on VaultSDKConfig (the signer drives the on-chain vault-delegate grant).
117
+ *
118
+ * So a framed widget with a host delegation — the configuration this whole
119
+ * tier is built around — could authenticate every request and never reach
120
+ * one. The token IS the authorization, which is exactly the case `ensure`
121
+ * documents `delegationToken` for.
122
+ */
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;
45
139
  export {};
@@ -1,5 +1,34 @@
1
1
  // packages/widget-runtime/src/vault-client.ts
2
- import { AgentNotConnectedError } from './types.js';
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).
@@ -23,78 +52,163 @@ function normaliseQueryResult(raw) {
23
52
  console.warn('[widget-runtime] unrecognized cubby query result shape; treating as empty', raw);
24
53
  return { columns: [], rows: [], meta: { rowCount: 0 } };
25
54
  }
55
+ export function createVaultClient(manifest, makeSdk,
26
56
  /**
27
- * Map a cubbies.query rejection to AgentNotConnectedError when the error
28
- * indicates a 404 / agent-not-connected condition.
57
+ * The host's delegation, when it gave one — handed to `ensure()` as well as
58
+ * used for auth.
59
+ *
60
+ * WHY BOTH. With a token the SDK is built with `auth` and NO `signer`, on
61
+ * purpose: the handle structurally cannot sign, which is what keeps a keyless
62
+ * passkey session from stepping up on every request. But `vault.ensure()`
63
+ * does not look at `auth` — it needs a `delegationToken` or a `signer`, and
64
+ * with neither it throws before the first query runs:
65
+ *
66
+ * vault.ensure() needs either a `delegationToken` or a `signer` configured
67
+ * on VaultSDKConfig (the signer drives the on-chain vault-delegate grant).
68
+ *
69
+ * So a framed widget with a host delegation — the configuration this whole
70
+ * tier is built around — could authenticate every request and never reach
71
+ * one. The token IS the authorization, which is exactly the case `ensure`
72
+ * documents `delegationToken` for.
29
73
  */
30
- function mapQueryError(err, agentId) {
31
- if (err && typeof err === 'object') {
32
- const status = err.status;
33
- const message = err.message;
34
- if (status === 404 ||
35
- (typeof message === 'string' && /not.?connected|404/i.test(message))) {
36
- throw new AgentNotConnectedError(agentId);
37
- }
38
- }
39
- throw err;
40
- }
41
- export function createVaultClient(manifest, makeSdk) {
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) {
42
90
  let sdk;
43
91
  let vaultP;
44
- let agentHandleP;
45
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
+ };
46
124
  const getVault = () => {
47
- // `onboard: false` preserves the old lightweight ensure(): claim the vault
48
- // with a wallet-built delegation token, skipping the gateway onboarding
49
- // status/bootstrap round-trip (widgets assume a provisioned wallet).
50
- vaultP ?? (vaultP = getSdk().vault.ensure({ onboard: false }).catch((err) => {
125
+ vaultP ?? (vaultP = openVault().catch((err) => {
51
126
  vaultP = undefined;
52
127
  throw err;
53
128
  }));
54
129
  return vaultP;
55
130
  };
56
- // Memoised agent-connection handle for cubby reads. Cached so polling
57
- // queries don't re-fetch the connection record on every call (matching the
58
- // single-request-per-query cost of the prior path). Reset on (re)connect
59
- // and on read failure so a stale handle can't pin a disconnected state.
60
- const getAgentHandle = () => {
61
- agentHandleP ?? (agentHandleP = getVault()
62
- .then((vault) => vault.agents.get(manifest.agentId))
63
- .catch((err) => {
64
- agentHandleP = undefined;
65
- throw err;
66
- }));
67
- return agentHandleP;
131
+ const declFor = (ref) => manifest.queries.find((q) => q.id === ref);
132
+ /**
133
+ * The agent service a widget belongs to — the half of its agent id before the
134
+ * colon.
135
+ *
136
+ * DERIVED, never declared. A widget that could name its own service could
137
+ * name somebody else's, and then a query's reach would be a field in a file
138
+ * uploaded to a content gateway rather than a property of who published it.
139
+ * This way the only service a widget can read is the one that shipped it.
140
+ */
141
+ const ownService = () => {
142
+ const at = manifest.agentId.indexOf(':');
143
+ return at > 0 ? manifest.agentId.slice(0, at) : '';
144
+ };
145
+ /** Read the service's cubby — what this agent's siblings wrote. The default. */
146
+ const queryService = async (decl, params) => {
147
+ const vault = await getVault();
148
+ if (!vault.service) {
149
+ throw new Error('[widget-runtime] this host\'s vault-sdk has no agent-service cubby surface, '
150
+ + `so the query "${decl.id}" cannot be answered here`);
151
+ }
152
+ const asPubKey = ownService();
153
+ if (!asPubKey) {
154
+ throw new Error(`[widget-runtime] "${decl.id}" reads the agent service's cubby, but `
155
+ + `"${manifest.agentId}" carries no service key to read it from`);
156
+ }
157
+ // WHICH of the service's cubbies. `cubbyAlias` is the default, and it is
158
+ // legitimately EMPTY for a widget that declares no cubby of its own —
159
+ // `buildWidgetManifestForDecl` writes "" in that case — so a `service`
160
+ // query that also names none has no alias at all. Left unguarded that
161
+ // reaches vault-api as `/cubbies//query` and comes back as an alias-shape
162
+ // 400, which sends the author to look at their SQL.
163
+ const alias = decl.cubby ?? manifest.cubbyAlias;
164
+ if (!alias) {
165
+ throw new Error(`[widget-runtime] "${decl.id}" reads the agent service's cubby but names `
166
+ + 'none, and this widget has no cubbyAlias to fall back on — set `cubby` on the query');
167
+ }
168
+ return vault.service(asPubKey).cubby(alias).query(decl.sql ?? '', params);
169
+ };
170
+ /**
171
+ * Read the vault's memory bank.
172
+ *
173
+ * Named tools rather than SQL, because the bank is a gated graph API and not
174
+ * a table: the gate decides which records the caller may see, and a SQL
175
+ * string would imply a reach the surface does not offer.
176
+ */
177
+ const queryMemory = async (decl, params) => {
178
+ const vault = await getVault();
179
+ if (!vault.memory) {
180
+ throw new Error('[widget-runtime] this host\'s vault-sdk has no memory surface, '
181
+ + `so the query "${decl.id}" cannot be answered here`);
182
+ }
183
+ const first = params[0];
184
+ const limit = decl.limit;
185
+ switch (decl.tool ?? 'search') {
186
+ case 'search':
187
+ return vault.memory.search(String(first ?? ''), limit ? { limit } : undefined);
188
+ case 'get':
189
+ return vault.memory.get(String(first ?? ''));
190
+ case 'neighbours':
191
+ return vault.memory.neighbours(String(first ?? ''), limit ? { limit } : undefined);
192
+ case 'countByType':
193
+ return vault.memory.countByType();
194
+ default:
195
+ throw new Error(`[widget-runtime] "${decl.id}" names no memory tool this runtime knows`);
196
+ }
68
197
  };
69
- const resolveSql = (ref) => manifest.queries.find((q) => q.id === ref)?.sql ?? ref;
70
198
  return {
71
199
  async query(ref, params) {
72
- // Cubby reads go through vault-sdk's agentId-keyed endpoint. This was
73
- // briefly routed via @cef-ai/client-sdk because vault-sdk@0.5.0
74
- // percent-encoded the agentId ':' → '%3A' → 404; fixed in 0.5.1.
75
- let raw;
76
- try {
77
- const handle = await getAgentHandle();
78
- // vault-sdk 2.x throws synchronously from `.cubby(alias)` when the
79
- // alias isn't in the connection's declared aliases (e.g. the agent
80
- // isn't connected yet, so the handle carries no aliases). Map that to
81
- // AgentNotConnectedError instead of letting an opaque throw escape.
82
- let cubbyHandle;
83
- try {
84
- cubbyHandle = handle.cubby(manifest.cubbyAlias);
85
- }
86
- catch {
87
- throw new AgentNotConnectedError(manifest.agentId);
88
- }
89
- raw = await cubbyHandle.query(resolveSql(ref), params ?? []);
90
- }
91
- catch (err) {
92
- agentHandleP = undefined; // force a fresh handle on the next read
93
- if (err instanceof AgentNotConnectedError)
94
- throw err;
95
- mapQueryError(err, manifest.agentId);
200
+ const decl = declFor(ref);
201
+ // TWO PLACES, AND THE DECLARATION SAYS WHICH. `tool` is the memory bank;
202
+ // anything else is SQL against one of the agent service's cubbies. A ref
203
+ // matching no declaration is raw SQL, which is a cubby read.
204
+ //
205
+ // Neither is agent-connection shaped — a cubby read addresses the
206
+ // SERVICE, the bank addresses the vault — so neither can fail as "agent
207
+ // not connected".
208
+ if (decl?.tool) {
209
+ return normaliseQueryResult(await queryMemory(decl, params ?? []));
96
210
  }
97
- return normaliseQueryResult(raw);
211
+ return normaliseQueryResult(await queryService(decl ?? { id: ref, label: ref, sql: ref }, params ?? []));
98
212
  },
99
213
  async publish(type, payload, context) {
100
214
  const vault = await getVault();
@@ -106,7 +220,6 @@ export function createVaultClient(manifest, makeSdk) {
106
220
  // The vault-api resolves the manifest from the DDC registry and the SDK
107
221
  // signs the GAR agreement internally — no marketplace.getAgent() fetch.
108
222
  await vault.agents.connect({ agentId: manifest.agentId, scope: manifest.scope });
109
- agentHandleP = undefined; // drop any cached pre-connect handle
110
223
  },
111
224
  async agentStatus() {
112
225
  try {
@@ -0,0 +1,26 @@
1
+ import type { VaultSDKConfig, Signer } from '@cef-ai/vault-sdk';
2
+ import type { WidgetManifest } from './types.js';
3
+ /**
4
+ * Build the VaultSDK config the runtime constructs everywhere it talks to a
5
+ * user's vault — `buildVaultClient`'s SDK (queries/publish/connect) and the
6
+ * ADR-002 audio uploader's SDK. One builder so the two cannot drift.
7
+ *
8
+ * A TOKEN BEATS A SIGNER, when the host supplied one. `auth` overrides
9
+ * `signer` in `VaultSDKConfig`, so a config built with a delegation provider
10
+ * structurally CANNOT sign — which is the point: on the passkey wallet every
11
+ * signature from a keyless session raises a step-up, so a widget that signs per
12
+ * request raises a ceremony per request. The delegation was minted once, by a
13
+ * host that already had a session, and authenticates the same calls.
14
+ *
15
+ * `chainUrl` (the manifest's `endpoints.rpc`) is required by
16
+ * `vault.ensure()`'s wallet path: the SDK grants vault-api durable write
17
+ * delegation by submitting an on-chain `NonTransfer` proxy before claiming
18
+ * the vault, and needs a Cere node WS URL to do it. Both runtime call sites
19
+ * use `ensure({ onboard: false })` — that skips the gateway onboarding
20
+ * round-trip but NOT the grant step, so without `chainUrl` every widget boot
21
+ * would die on an actionable-but-user-visible config error. The manifest's
22
+ * `rpc` is baked per environment by the CLI's env table (devnet / testnet /
23
+ * mainnet), so the runtime inherits the deployment's chain instead of
24
+ * hard-coding one.
25
+ */
26
+ export declare function toVaultSdkConfig(endpoints: WidgetManifest['endpoints'], signer: Signer, token?: string): VaultSDKConfig;
@@ -0,0 +1,37 @@
1
+ // packages/widget-runtime/src/vault-sdk-config.ts
2
+ import { DelegationAuthProvider } from '@cef-ai/vault-sdk';
3
+ /**
4
+ * Build the VaultSDK config the runtime constructs everywhere it talks to a
5
+ * user's vault — `buildVaultClient`'s SDK (queries/publish/connect) and the
6
+ * ADR-002 audio uploader's SDK. One builder so the two cannot drift.
7
+ *
8
+ * A TOKEN BEATS A SIGNER, when the host supplied one. `auth` overrides
9
+ * `signer` in `VaultSDKConfig`, so a config built with a delegation provider
10
+ * structurally CANNOT sign — which is the point: on the passkey wallet every
11
+ * signature from a keyless session raises a step-up, so a widget that signs per
12
+ * request raises a ceremony per request. The delegation was minted once, by a
13
+ * host that already had a session, and authenticates the same calls.
14
+ *
15
+ * `chainUrl` (the manifest's `endpoints.rpc`) is required by
16
+ * `vault.ensure()`'s wallet path: the SDK grants vault-api durable write
17
+ * delegation by submitting an on-chain `NonTransfer` proxy before claiming
18
+ * the vault, and needs a Cere node WS URL to do it. Both runtime call sites
19
+ * use `ensure({ onboard: false })` — that skips the gateway onboarding
20
+ * round-trip but NOT the grant step, so without `chainUrl` every widget boot
21
+ * would die on an actionable-but-user-visible config error. The manifest's
22
+ * `rpc` is baked per environment by the CLI's env table (devnet / testnet /
23
+ * mainnet), so the runtime inherits the deployment's chain instead of
24
+ * hard-coding one.
25
+ */
26
+ export function toVaultSdkConfig(endpoints, signer, token) {
27
+ const base = {
28
+ endpoint: endpoints.vault,
29
+ garEndpoint: endpoints.gar,
30
+ marketplaceEndpoint: endpoints.marketplace,
31
+ s3GatewayAuthInfoUrl: endpoints.s3GatewayAuthInfo,
32
+ chainUrl: endpoints.rpc,
33
+ };
34
+ if (token)
35
+ return { ...base, auth: new DelegationAuthProvider(token) };
36
+ return { ...base, signer };
37
+ }