@ixo/oracle-runtime-workers 0.24.0 → 0.25.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/package.json +5 -4
- package/src/action-proposals/store.ts +0 -40
- package/src/action-proposals/topic-store.ts +0 -42
- package/src/delivery/plan.ts +4 -1
- package/src/do/contracts.ts +0 -1
- package/src/do/user-oracle-do.ts +256 -70
- package/src/hub-actions/client.ts +58 -5
- package/src/hub-actions/outcome-signature.ts +227 -0
- package/src/hub-actions/request.ts +16 -7
- package/src/hub-actions/store.ts +61 -37
- package/src/hub-actions/transfer.ts +182 -0
- package/src/plugin-api/types.ts +25 -2
- package/src/plugins/hub-actions/hub-actions.plugin.ts +154 -25
- package/src/shell/app.ts +36 -20
package/src/plugin-api/types.ts
CHANGED
|
@@ -762,7 +762,26 @@ export interface RuntimeContext<TConfig = MergedConfig> {
|
|
|
762
762
|
provider: string;
|
|
763
763
|
bindingId: string;
|
|
764
764
|
bindingRevision: number;
|
|
765
|
-
}): Promise<import('@ixo/action-protocol').
|
|
765
|
+
}): Promise<import('@ixo/action-protocol').HubActionOptionsV3>;
|
|
766
|
+
/**
|
|
767
|
+
* A bank send of a token from the owner's options list: `amount` is a
|
|
768
|
+
* decimal in the token's display units; the host converts it to base
|
|
769
|
+
* units from the listed decimals and builds the operation (no fee).
|
|
770
|
+
*/
|
|
771
|
+
transfer(input: {
|
|
772
|
+
ownerDid: string;
|
|
773
|
+
sessionId: string;
|
|
774
|
+
runId: string;
|
|
775
|
+
requestId: string;
|
|
776
|
+
provider: string;
|
|
777
|
+
bindingId: string;
|
|
778
|
+
bindingRevision: number;
|
|
779
|
+
requestKey: string;
|
|
780
|
+
toAddress: string;
|
|
781
|
+
denom: string;
|
|
782
|
+
amount: string;
|
|
783
|
+
memo: string;
|
|
784
|
+
}): Promise<ActionReferenceV1>;
|
|
766
785
|
request(input: {
|
|
767
786
|
ownerDid: string;
|
|
768
787
|
sessionId: string;
|
|
@@ -774,7 +793,11 @@ export interface RuntimeContext<TConfig = MergedConfig> {
|
|
|
774
793
|
requestKey: string;
|
|
775
794
|
operation: ActionOperationV1;
|
|
776
795
|
}): Promise<ActionReferenceV1>;
|
|
777
|
-
|
|
796
|
+
/** The latest authenticated outcome and, for a transfer, its owner-facing line. */
|
|
797
|
+
status(
|
|
798
|
+
ownerDid: string,
|
|
799
|
+
actionId: string,
|
|
800
|
+
): Promise<{ outcome: ActionOutcomeV1 | null; summary: string | null }>;
|
|
778
801
|
};
|
|
779
802
|
|
|
780
803
|
decisions: {
|
|
@@ -24,9 +24,37 @@ const statusSchema = z.strictObject({
|
|
|
24
24
|
actionId: z.string().regex(/^[A-Za-z0-9_-]{32}$/),
|
|
25
25
|
});
|
|
26
26
|
const discoverSchema = z.strictObject({});
|
|
27
|
+
const transferSchema = z.strictObject({
|
|
28
|
+
toAddress: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1)
|
|
31
|
+
.max(100)
|
|
32
|
+
.describe('The recipient address the user gave, exactly.'),
|
|
33
|
+
denom: z
|
|
34
|
+
.string()
|
|
35
|
+
.min(1)
|
|
36
|
+
.max(128)
|
|
37
|
+
.describe(
|
|
38
|
+
'The token, copied exactly from a `denom` in hub_action_options `transfer.tokens` (never guessed).',
|
|
39
|
+
),
|
|
40
|
+
amount: z
|
|
41
|
+
.string()
|
|
42
|
+
.regex(/^(0|[1-9][0-9]*)(\.[0-9]+)?$/)
|
|
43
|
+
.max(80)
|
|
44
|
+
.describe(
|
|
45
|
+
"A decimal amount in the token's listed units (its `decimals`); for an unverified token (decimals 0) a whole number of base units.",
|
|
46
|
+
),
|
|
47
|
+
memo: z.string().max(256).optional(),
|
|
48
|
+
});
|
|
27
49
|
|
|
28
50
|
export type HubActionRequestResult =
|
|
29
|
-
| {
|
|
51
|
+
| {
|
|
52
|
+
status: 'prepared';
|
|
53
|
+
actionReference: ActionReferenceV1;
|
|
54
|
+
summary: string;
|
|
55
|
+
/** How the review reaches the user; set on a chat surface only. */
|
|
56
|
+
note?: string;
|
|
57
|
+
}
|
|
30
58
|
| {
|
|
31
59
|
status: 'unavailable' | 'expired';
|
|
32
60
|
reason: string;
|
|
@@ -34,6 +62,34 @@ export type HubActionRequestResult =
|
|
|
34
62
|
priorActionId?: string;
|
|
35
63
|
};
|
|
36
64
|
|
|
65
|
+
/**
|
|
66
|
+
* What a prepared result tells the model on a chat surface. There the
|
|
67
|
+
* finished run's Reply Plan carries the review as its own `hub-action` part
|
|
68
|
+
* (a card with the title and a review button; its title linked to the review
|
|
69
|
+
* in a plain-text rendering), so text that repeats it reaches the user twice.
|
|
70
|
+
*/
|
|
71
|
+
export const HUB_ACTION_CARD_NOTE =
|
|
72
|
+
'The user is shown this action automatically as a card with its title and a Review action button that opens it in Auth Hub. Do not repeat the link or the title, and do not say that you have not executed or signed anything. Reply with one short line, or nothing.';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A prepared result. Only a chat turn's reply is a Reply Plan, so only a
|
|
76
|
+
* chat surface gets the note: on the Portal stream no card is added and the
|
|
77
|
+
* model's own text is the whole reply.
|
|
78
|
+
*/
|
|
79
|
+
function preparedResult(
|
|
80
|
+
reference: ActionReferenceV1,
|
|
81
|
+
ctx: RuntimeContext,
|
|
82
|
+
): HubActionRequestResult {
|
|
83
|
+
return {
|
|
84
|
+
status: 'prepared',
|
|
85
|
+
actionReference: reference,
|
|
86
|
+
summary: reference.title,
|
|
87
|
+
...(ctx.session.surface?.kind === 'chat'
|
|
88
|
+
? { note: HUB_ACTION_CARD_NOTE }
|
|
89
|
+
: {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
37
93
|
/**
|
|
38
94
|
* The idempotency key the Hub sees: a fixed-length digest of the run and
|
|
39
95
|
* tool call (the Hub allows 120 characters; a run id alone is 72).
|
|
@@ -74,6 +130,15 @@ async function requestHubAction(
|
|
|
74
130
|
let operation;
|
|
75
131
|
try {
|
|
76
132
|
operation = parseOperation(parsed.data.operation);
|
|
133
|
+
if (operation.kind === 'ixo.bank.send.v1')
|
|
134
|
+
// A transfer is built by the runtime from a listed token and a
|
|
135
|
+
// display amount (request_hub_transfer): the model never supplies
|
|
136
|
+
// base units, a denom it chose or a fee.
|
|
137
|
+
return {
|
|
138
|
+
status: 'unavailable',
|
|
139
|
+
reason:
|
|
140
|
+
'Use request_hub_transfer with a token from hub_action_options for a transfer.',
|
|
141
|
+
};
|
|
77
142
|
} catch (error) {
|
|
78
143
|
// The protocol's own words ("Invalid fromAddress"): the model can fix
|
|
79
144
|
// the field instead of guessing.
|
|
@@ -96,28 +161,83 @@ async function requestHubAction(
|
|
|
96
161
|
operation,
|
|
97
162
|
});
|
|
98
163
|
} catch (error) {
|
|
99
|
-
// A
|
|
100
|
-
//
|
|
164
|
+
// A request that expired, a run no user message started, a Hub
|
|
165
|
+
// refusal: the runtime's own words, so the model can tell the user.
|
|
101
166
|
// Anything else stays an error.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
error.name === 'HubActionRefused' &&
|
|
105
|
-
'kind' in error
|
|
106
|
-
)
|
|
107
|
-
return {
|
|
108
|
-
status: error.kind === 'expired' ? 'expired' : 'unavailable',
|
|
109
|
-
reason: error.message,
|
|
110
|
-
...('priorActionId' in error && typeof error.priorActionId === 'string'
|
|
111
|
-
? { priorActionId: error.priorActionId }
|
|
112
|
-
: {}),
|
|
113
|
-
};
|
|
167
|
+
const refused = refusalOf(error);
|
|
168
|
+
if (refused) return refused;
|
|
114
169
|
throw error;
|
|
115
170
|
}
|
|
116
|
-
return
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
171
|
+
return preparedResult(reference, ctx);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function requestHubTransfer(
|
|
175
|
+
input: unknown,
|
|
176
|
+
ctx: RuntimeContext,
|
|
177
|
+
): Promise<HubActionRequestResult> {
|
|
178
|
+
const parsed = transferSchema.safeParse(input);
|
|
179
|
+
if (!parsed.success)
|
|
180
|
+
return {
|
|
181
|
+
status: 'unavailable',
|
|
182
|
+
reason:
|
|
183
|
+
'A recipient, a listed token denom and a plain decimal amount are required.',
|
|
184
|
+
};
|
|
185
|
+
if (ctx.config.HUB_ACTIONS_ENABLED !== 'true' || !ctx.hubActions)
|
|
186
|
+
return {
|
|
187
|
+
status: 'unavailable',
|
|
188
|
+
reason: 'Hub actions are not enabled for this oracle.',
|
|
189
|
+
};
|
|
190
|
+
const channel = ctx.session.channel;
|
|
191
|
+
if (
|
|
192
|
+
ctx.session.client !== 'channel' ||
|
|
193
|
+
!channel ||
|
|
194
|
+
!ctx.session.runId ||
|
|
195
|
+
!ctx.toolCallId
|
|
196
|
+
)
|
|
197
|
+
return {
|
|
198
|
+
status: 'unavailable',
|
|
199
|
+
reason: 'A verified channel turn is required.',
|
|
200
|
+
};
|
|
201
|
+
try {
|
|
202
|
+
const reference = await ctx.hubActions.transfer({
|
|
203
|
+
ownerDid: ctx.user.did,
|
|
204
|
+
sessionId: ctx.session.id,
|
|
205
|
+
runId: ctx.session.runId,
|
|
206
|
+
requestId: ctx.session.requestId,
|
|
207
|
+
provider: channel.provider,
|
|
208
|
+
bindingId: channel.bindingId,
|
|
209
|
+
bindingRevision: channel.bindingRevision,
|
|
210
|
+
requestKey: await hubRequestKey(ctx.session.runId, ctx.toolCallId),
|
|
211
|
+
toAddress: parsed.data.toAddress,
|
|
212
|
+
denom: parsed.data.denom,
|
|
213
|
+
amount: parsed.data.amount,
|
|
214
|
+
memo: parsed.data.memo ?? '',
|
|
215
|
+
});
|
|
216
|
+
return preparedResult(reference, ctx);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
// The owner object turns the protocol refusing the operation built
|
|
219
|
+
// from the model's fields into a refusal; anything else is an error.
|
|
220
|
+
const refused = refusalOf(error);
|
|
221
|
+
if (refused) return refused;
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A typed runtime refusal as a result the model can relay, else null. */
|
|
227
|
+
function refusalOf(error: unknown): HubActionRequestResult | null {
|
|
228
|
+
if (
|
|
229
|
+
error instanceof Error &&
|
|
230
|
+
error.name === 'HubActionRefused' &&
|
|
231
|
+
'kind' in error
|
|
232
|
+
)
|
|
233
|
+
return {
|
|
234
|
+
status: error.kind === 'expired' ? 'expired' : 'unavailable',
|
|
235
|
+
reason: error.message,
|
|
236
|
+
...('priorActionId' in error && typeof error.priorActionId === 'string'
|
|
237
|
+
? { priorActionId: error.priorActionId }
|
|
238
|
+
: {}),
|
|
239
|
+
};
|
|
240
|
+
return null;
|
|
121
241
|
}
|
|
122
242
|
|
|
123
243
|
async function hubActionStatus(
|
|
@@ -131,10 +251,11 @@ async function hubActionStatus(
|
|
|
131
251
|
ctx.config.HUB_ACTIONS_ENABLED !== 'true'
|
|
132
252
|
)
|
|
133
253
|
return { status: 'unavailable' };
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
254
|
+
const { outcome, summary } = await ctx.hubActions.status(
|
|
255
|
+
ctx.user.did,
|
|
256
|
+
parsed.data.actionId,
|
|
257
|
+
);
|
|
258
|
+
return { status: 'known', outcome, ...(summary ? { summary } : {}) };
|
|
138
259
|
}
|
|
139
260
|
|
|
140
261
|
async function hubActionCapabilities(
|
|
@@ -218,7 +339,7 @@ export class HubActionsPlugin extends OraclePlugin {
|
|
|
218
339
|
{
|
|
219
340
|
name: 'hub_action_options',
|
|
220
341
|
description:
|
|
221
|
-
'Read this authenticated owner’s confirmed Gmail and Calendar connections, requestable documents and
|
|
342
|
+
'Read this authenticated owner’s confirmed Gmail and Calendar connections, requestable documents, action resources and bank tokens (`transfer.tokens`: denom, symbol, decimals, verified, balance in base units). Use each returned service, identifier and label before preparing a related Hub review. A Gmail profile connection does not authorize sending. For a token that is not verified, name its full denom and say it is unverified; its amounts are base units.',
|
|
222
343
|
schema: discoverSchema,
|
|
223
344
|
effect: 'read',
|
|
224
345
|
handler: hubActionOptions,
|
|
@@ -231,6 +352,14 @@ export class HubActionsPlugin extends OraclePlugin {
|
|
|
231
352
|
effect: 'write',
|
|
232
353
|
handler: requestHubAction,
|
|
233
354
|
},
|
|
355
|
+
{
|
|
356
|
+
name: 'request_hub_transfer',
|
|
357
|
+
description:
|
|
358
|
+
'Prepare a bank transfer of one of the owner’s listed tokens for review in Auth Hub. Never signs or executes. Read hub_action_options first and copy the token `denom` exactly; give the amount as the user said it in that token’s units (no base-unit math, no fee: Auth Hub sets the fee). Name the full denom for anything but IXO, and say when a token is unverified.',
|
|
359
|
+
schema: transferSchema,
|
|
360
|
+
effect: 'write',
|
|
361
|
+
handler: requestHubTransfer,
|
|
362
|
+
},
|
|
234
363
|
{
|
|
235
364
|
name: 'hub_action_status',
|
|
236
365
|
description:
|
package/src/shell/app.ts
CHANGED
|
@@ -78,22 +78,7 @@ import {
|
|
|
78
78
|
} from '../feedback/submit';
|
|
79
79
|
import { z } from 'zod';
|
|
80
80
|
import { parseOutcome } from '@ixo/action-protocol';
|
|
81
|
-
|
|
82
|
-
async function equalOutcomeKeys(
|
|
83
|
-
provided: string | null,
|
|
84
|
-
expected: string | undefined,
|
|
85
|
-
): Promise<boolean> {
|
|
86
|
-
if (!provided || !expected || expected.length < 32 || provided.length > 512)
|
|
87
|
-
return false;
|
|
88
|
-
const digest = async (value: string) =>
|
|
89
|
-
new Uint8Array(
|
|
90
|
-
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)),
|
|
91
|
-
);
|
|
92
|
-
const [a, b] = await Promise.all([digest(provided), digest(expected)]);
|
|
93
|
-
let difference = 0;
|
|
94
|
-
for (let i = 0; i < a.length; i++) difference |= (a[i] ?? 0) ^ (b[i] ?? 0);
|
|
95
|
-
return difference === 0;
|
|
96
|
-
}
|
|
81
|
+
import { verifyHubOutcomeCallback } from '../hub-actions/outcome-signature';
|
|
97
82
|
|
|
98
83
|
const DebugEventBody = z.object({
|
|
99
84
|
type: z
|
|
@@ -217,6 +202,22 @@ export const BYO_LLM_BODY_BYTES = 64 * 1024;
|
|
|
217
202
|
export const DEBUG_BODY_BYTES = 64 * 1024;
|
|
218
203
|
|
|
219
204
|
/** A body cap enforced before the body is buffered: over it, 413 and the handler never runs. */
|
|
205
|
+
/**
|
|
206
|
+
* The audience a signed Hub outcome callback must be signed for: the origin
|
|
207
|
+
* of `ORACLE_PUBLIC_URL` when it is set, else the origin of the request URL.
|
|
208
|
+
* An unparsable public URL yields '' (which never verifies).
|
|
209
|
+
*/
|
|
210
|
+
export function callbackAudience(
|
|
211
|
+
publicUrl: string | undefined,
|
|
212
|
+
requestUrl: string,
|
|
213
|
+
): string {
|
|
214
|
+
try {
|
|
215
|
+
return new URL(publicUrl || requestUrl).origin;
|
|
216
|
+
} catch {
|
|
217
|
+
return '';
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
220
221
|
function limitBody(maxSize: number) {
|
|
221
222
|
return bodyLimit({
|
|
222
223
|
maxSize,
|
|
@@ -455,17 +456,32 @@ export function createShell(
|
|
|
455
456
|
});
|
|
456
457
|
|
|
457
458
|
app.use('/api/internal/actions/outcomes', limitBody(64 * 1024));
|
|
459
|
+
// Auth Hub's signed outcome callbacks: on whenever Hub actions are
|
|
460
|
+
// configured. The signature is checked against the Hub's own did:web key
|
|
461
|
+
// (from AUTH_HUB_URL) for this oracle's origin, then the owner object
|
|
462
|
+
// confirms the outcome through Auth Hub's authenticated status API.
|
|
458
463
|
app.post('/api/internal/actions/outcomes', async (c) => {
|
|
459
464
|
if (
|
|
460
465
|
c.env.HUB_ACTIONS_ENABLED !== 'true' ||
|
|
461
|
-
!
|
|
462
|
-
|
|
463
|
-
c.env.AUTH_HUB_ACTION_OUTCOME_KEY,
|
|
464
|
-
))
|
|
466
|
+
!c.env.AUTH_HUB_URL ||
|
|
467
|
+
!c.env.AUTH_HUB_CHANNEL_SERVICE_KEY
|
|
465
468
|
)
|
|
466
469
|
return c.json({ code: 'unauthorized' }, 401);
|
|
467
470
|
const body = await c.req.raw.text();
|
|
468
471
|
if (body.length > 64_000) return c.json({ code: 'request_too_large' }, 413);
|
|
472
|
+
if (
|
|
473
|
+
!(await verifyHubOutcomeCallback({
|
|
474
|
+
hubUrl: c.env.AUTH_HUB_URL,
|
|
475
|
+
rawBody: body,
|
|
476
|
+
headers: c.req.raw.headers,
|
|
477
|
+
// The origin the Hub signs for: the configured public URL when
|
|
478
|
+
// set (a Worker reachable under several hostnames), else the
|
|
479
|
+
// origin this request came in on.
|
|
480
|
+
audience: callbackAudience(c.env.ORACLE_PUBLIC_URL, c.req.url),
|
|
481
|
+
...(c.env.AUTH_HUB ? { fetcher: c.env.AUTH_HUB } : {}),
|
|
482
|
+
}))
|
|
483
|
+
)
|
|
484
|
+
return c.json({ code: 'unauthorized' }, 401);
|
|
469
485
|
try {
|
|
470
486
|
const outcome = parseOutcome(JSON.parse(body));
|
|
471
487
|
await userStub(c.env, outcome.ownerDid).recordHubActionOutcome(outcome);
|