@ixo/oracle-runtime-workers 0.22.0 → 0.23.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.
Files changed (39) hide show
  1. package/README.md +15 -13
  2. package/package.json +10 -4
  3. package/src/action-proposals/policy.ts +54 -0
  4. package/src/action-proposals/reconcile.ts +87 -0
  5. package/src/action-proposals/store.ts +863 -0
  6. package/src/action-proposals/topic-budget.ts +84 -0
  7. package/src/action-proposals/topic-chain.ts +272 -0
  8. package/src/action-proposals/topic-effects.ts +44 -0
  9. package/src/action-proposals/topic-errors.ts +79 -0
  10. package/src/action-proposals/topic-history.ts +267 -0
  11. package/src/action-proposals/topic-matrix.ts +292 -0
  12. package/src/action-proposals/topic-prepare.ts +299 -0
  13. package/src/action-proposals/topic-store.ts +999 -0
  14. package/src/action-proposals/topic-verify.ts +279 -0
  15. package/src/action-proposals/verify.ts +223 -0
  16. package/src/channels/auth.ts +6 -1
  17. package/src/channels/contract.ts +4 -0
  18. package/src/channels/turns.ts +1 -0
  19. package/src/core/env.ts +89 -5
  20. package/src/core/main-agent-types.ts +8 -0
  21. package/src/core/main-agent.ts +2 -0
  22. package/src/core/runtime-context.ts +12 -0
  23. package/src/delivery/plan.ts +43 -2
  24. package/src/delivery/schema.ts +25 -1
  25. package/src/delivery/types.ts +13 -2
  26. package/src/do/ambient.ts +6 -0
  27. package/src/do/contracts.ts +39 -0
  28. package/src/do/user-oracle-do.ts +1253 -7
  29. package/src/hub-actions/client.ts +163 -0
  30. package/src/hub-actions/request.ts +110 -0
  31. package/src/hub-actions/store.ts +485 -0
  32. package/src/matrix/gateway-do.ts +176 -0
  33. package/src/matrix/reply-parts.ts +8 -2
  34. package/src/plugin-api/types.ts +54 -0
  35. package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
  36. package/src/plugins/action-proposals/evidence.ts +67 -0
  37. package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
  38. package/src/plugins/index.ts +6 -0
  39. package/src/shell/app.ts +195 -1
@@ -0,0 +1,244 @@
1
+ import {
2
+ operationJsonSchema,
3
+ parseOperation,
4
+ type ActionReferenceV1,
5
+ } from '@ixo/action-protocol';
6
+ import { z } from 'zod';
7
+ import { OraclePlugin } from '../../plugin-api/oracle-plugin';
8
+ import type {
9
+ PluginManifest,
10
+ PluginTool,
11
+ RuntimeContext,
12
+ } from '../../plugin-api/types';
13
+
14
+ const configSchema = z.object({
15
+ HUB_ACTIONS_ENABLED: z.enum(['true', 'false']).default('false'),
16
+ });
17
+
18
+ const operationSchema = z.fromJSONSchema({
19
+ ...operationJsonSchema,
20
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
21
+ });
22
+ const requestSchema = z.strictObject({ operation: operationSchema });
23
+ const statusSchema = z.strictObject({
24
+ actionId: z.string().regex(/^[A-Za-z0-9_-]{32}$/),
25
+ });
26
+ const discoverSchema = z.strictObject({});
27
+
28
+ export type HubActionRequestResult =
29
+ | { status: 'prepared'; actionReference: ActionReferenceV1; summary: string }
30
+ | {
31
+ status: 'unavailable' | 'expired';
32
+ reason: string;
33
+ /** The earlier action that blocks this one (an unconfirmed email). */
34
+ priorActionId?: string;
35
+ };
36
+
37
+ /**
38
+ * The idempotency key the Hub sees: a fixed-length digest of the run and
39
+ * tool call (the Hub allows 120 characters; a run id alone is 72).
40
+ */
41
+ async function hubRequestKey(runId: string, toolCallId: string) {
42
+ const bytes = new Uint8Array(
43
+ await crypto.subtle.digest(
44
+ 'SHA-256',
45
+ new TextEncoder().encode(`${runId}:${toolCallId}`),
46
+ ),
47
+ );
48
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
49
+ }
50
+
51
+ async function requestHubAction(
52
+ input: unknown,
53
+ ctx: RuntimeContext,
54
+ ): Promise<HubActionRequestResult> {
55
+ const parsed = requestSchema.safeParse(input);
56
+ if (!parsed.success)
57
+ return { status: 'unavailable', reason: 'An operation is required.' };
58
+ if (ctx.config.HUB_ACTIONS_ENABLED !== 'true' || !ctx.hubActions)
59
+ return {
60
+ status: 'unavailable',
61
+ reason: 'Hub actions are not enabled for this oracle.',
62
+ };
63
+ const channel = ctx.session.channel;
64
+ if (
65
+ ctx.session.client !== 'channel' ||
66
+ !channel ||
67
+ !ctx.session.runId ||
68
+ !ctx.toolCallId
69
+ )
70
+ return {
71
+ status: 'unavailable',
72
+ reason: 'A verified channel turn is required.',
73
+ };
74
+ let operation;
75
+ try {
76
+ operation = parseOperation(parsed.data.operation);
77
+ } catch (error) {
78
+ // The protocol's own words ("Invalid fromAddress"): the model can fix
79
+ // the field instead of guessing.
80
+ return {
81
+ status: 'unavailable',
82
+ reason: `The requested operation is unsupported or incomplete${error instanceof Error && error.message ? ` (${error.message})` : ''}.`,
83
+ };
84
+ }
85
+ let reference: ActionReferenceV1;
86
+ try {
87
+ reference = await ctx.hubActions.request({
88
+ ownerDid: ctx.user.did,
89
+ sessionId: ctx.session.id,
90
+ runId: ctx.session.runId,
91
+ requestId: ctx.session.requestId,
92
+ provider: channel.provider,
93
+ bindingId: channel.bindingId,
94
+ bindingRevision: channel.bindingRevision,
95
+ requestKey: await hubRequestKey(ctx.session.runId, ctx.toolCallId),
96
+ operation,
97
+ });
98
+ } catch (error) {
99
+ // A cap reached, a request that expired, a run no user message
100
+ // started: the runtime's own words, so the model can tell the user.
101
+ // Anything else stays an error.
102
+ if (
103
+ error instanceof Error &&
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
+ };
114
+ throw error;
115
+ }
116
+ return {
117
+ status: 'prepared',
118
+ actionReference: reference,
119
+ summary: reference.title,
120
+ };
121
+ }
122
+
123
+ async function hubActionStatus(
124
+ input: unknown,
125
+ ctx: RuntimeContext,
126
+ ): Promise<unknown> {
127
+ const parsed = statusSchema.safeParse(input);
128
+ if (
129
+ !parsed.success ||
130
+ !ctx.hubActions ||
131
+ ctx.config.HUB_ACTIONS_ENABLED !== 'true'
132
+ )
133
+ return { status: 'unavailable' };
134
+ return {
135
+ status: 'known',
136
+ outcome: await ctx.hubActions.status(ctx.user.did, parsed.data.actionId),
137
+ };
138
+ }
139
+
140
+ async function hubActionCapabilities(
141
+ _input: unknown,
142
+ ctx: RuntimeContext,
143
+ ): Promise<unknown> {
144
+ if (
145
+ ctx.config.HUB_ACTIONS_ENABLED !== 'true' ||
146
+ ctx.session.client !== 'channel' ||
147
+ !ctx.session.channel ||
148
+ !ctx.hubActions
149
+ )
150
+ return { status: 'unavailable' };
151
+ return ctx.hubActions.capabilities();
152
+ }
153
+
154
+ async function hubActionOptions(
155
+ _input: unknown,
156
+ ctx: RuntimeContext,
157
+ ): Promise<unknown> {
158
+ const channel = ctx.session.channel;
159
+ if (
160
+ ctx.config.HUB_ACTIONS_ENABLED !== 'true' ||
161
+ ctx.session.client !== 'channel' ||
162
+ !channel ||
163
+ !ctx.hubActions
164
+ )
165
+ return { status: 'unavailable' };
166
+ return ctx.hubActions.options({
167
+ ownerDid: ctx.user.did,
168
+ provider: channel.provider,
169
+ bindingId: channel.bindingId,
170
+ bindingRevision: channel.bindingRevision,
171
+ });
172
+ }
173
+
174
+ const manifest: PluginManifest = {
175
+ title: 'Auth Hub action review',
176
+ summary:
177
+ 'Prepare a registered operation for owner review and read its verified status.',
178
+ whenToUse: [
179
+ 'The user requests a registered form submission or a transfer.',
180
+ 'The user asks to review an exact private resource or to sign a statement.',
181
+ 'The user asks to connect Gmail or Calendar, send a reviewed email or add an event.',
182
+ 'The user asks for document access or to save a private note.',
183
+ ],
184
+ whenNotToUse: [
185
+ 'Signing, executing, or treating a resource open as acceptance.',
186
+ ],
187
+ tags: ['actions', 'channels'],
188
+ category: 'integration',
189
+ visibility: 'on-demand',
190
+ stability: 'experimental',
191
+ };
192
+
193
+ export class HubActionsPlugin extends OraclePlugin {
194
+ readonly name = 'hub-actions';
195
+ readonly version = '0.1.0';
196
+ readonly manifest = manifest;
197
+ override readonly configSchema = configSchema;
198
+ /** The flag alone is not enough: the owner object needs the Hub's URL and this oracle's key. */
199
+ override autoDetect(env: Record<string, unknown>): boolean {
200
+ return (
201
+ env.HUB_ACTIONS_ENABLED === 'true' &&
202
+ typeof env.AUTH_HUB_URL === 'string' &&
203
+ env.AUTH_HUB_URL.length > 0 &&
204
+ typeof env.AUTH_HUB_CHANNEL_SERVICE_KEY === 'string' &&
205
+ env.AUTH_HUB_CHANNEL_SERVICE_KEY.length > 0
206
+ );
207
+ }
208
+ override getTools(): PluginTool[] {
209
+ return [
210
+ {
211
+ name: 'hub_action_capabilities',
212
+ description:
213
+ "Read this authenticated oracle account's enabled operation kinds and exact registered form field schema before proposing a Hub action.",
214
+ schema: discoverSchema,
215
+ effect: 'read',
216
+ handler: hubActionCapabilities,
217
+ },
218
+ {
219
+ name: 'hub_action_options',
220
+ description:
221
+ 'Read this authenticated owner’s confirmed Gmail and Calendar connections, requestable documents and action resources. Use each returned service, identifier and label before preparing a related Hub review. A Gmail profile connection does not authorize sending.',
222
+ schema: discoverSchema,
223
+ effect: 'read',
224
+ handler: hubActionOptions,
225
+ },
226
+ {
227
+ name: 'request_hub_action',
228
+ description:
229
+ 'Prepare a supported operation for the authenticated channel owner to review in Auth Hub. Never approves, signs, or executes. For email, propose one plain-text message to one recipient using a confirmed Gmail send connection. Supply only the operation candidate; the runtime supplies identity and turn binding. Never prepare a replacement email after an unknown send without the user checking Gmail’s Sent folder and asking again.',
230
+ schema: requestSchema,
231
+ effect: 'write',
232
+ handler: requestHubAction,
233
+ },
234
+ {
235
+ name: 'hub_action_status',
236
+ description:
237
+ 'Read the verified status of a Hub action already prepared for this owner. Never executes.',
238
+ schema: statusSchema,
239
+ effect: 'read',
240
+ handler: hubActionStatus,
241
+ },
242
+ ];
243
+ }
244
+ }
@@ -1,3 +1,7 @@
1
+ import { ActionProposalsPlugin } from './action-proposals/action-proposals.plugin';
2
+ import { HubActionsPlugin } from './hub-actions/hub-actions.plugin';
3
+ export { ActionProposalsPlugin } from './action-proposals/action-proposals.plugin';
4
+ export { HubActionsPlugin } from './hub-actions/hub-actions.plugin';
1
5
  /**
2
6
  * Bundled Workers-runtime plugins — ports of the Node runtime's MCP / fetch
3
7
  * plugins. Each is gated the same way as on Node: `autoDetect` on its env
@@ -66,6 +70,8 @@ export const tasksPlugin = TasksPlugin;
66
70
  * instances instead of using these singletons.
67
71
  */
68
72
  export const BUNDLED_WORKERS_PLUGINS = [
73
+ new ActionProposalsPlugin(),
74
+ new HubActionsPlugin(),
69
75
  memoryPlugin,
70
76
  sandboxPlugin,
71
77
  firecrawlPlugin,
package/src/shell/app.ts CHANGED
@@ -1,3 +1,15 @@
1
+ import {
2
+ ActionProposalWireSchema,
3
+ TopicReceiptReferencesSchema,
4
+ BeginTopicStepSchema,
5
+ BeginTopicStepResultSchema,
6
+ } from '@ixo/ixo-transaction/topic-proposals';
7
+ import {
8
+ ActionProposalClaimSchema,
9
+ ActionProposalIdSchema,
10
+ ActionProposalReconcileSchema,
11
+ } from '@ixo/ixo-transaction/action-proposals';
12
+ import { ActionProposalConflict } from '../action-proposals/store';
1
13
  import {
2
14
  authenticateChannel,
3
15
  assertActiveChannelBindingCached,
@@ -65,6 +77,23 @@ import {
65
77
  type FeedbackShellOptions,
66
78
  } from '../feedback/submit';
67
79
  import { z } from 'zod';
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
+ }
68
97
 
69
98
  const DebugEventBody = z.object({
70
99
  type: z
@@ -217,6 +246,7 @@ export function rateLimitKey(
217
246
  | 'user'
218
247
  | 'poll'
219
248
  | 'channel'
249
+ | 'action'
220
250
  | 'artifact'
221
251
  | 'socket'
222
252
  | 'status'
@@ -424,6 +454,38 @@ export function createShell(
424
454
  );
425
455
  });
426
456
 
457
+ app.use('/api/internal/actions/outcomes', limitBody(64 * 1024));
458
+ app.post('/api/internal/actions/outcomes', async (c) => {
459
+ if (
460
+ c.env.HUB_ACTIONS_ENABLED !== 'true' ||
461
+ !(await equalOutcomeKeys(
462
+ c.req.header('x-auth-hub-outcome-key') ?? null,
463
+ c.env.AUTH_HUB_ACTION_OUTCOME_KEY,
464
+ ))
465
+ )
466
+ return c.json({ code: 'unauthorized' }, 401);
467
+ const body = await c.req.raw.text();
468
+ if (body.length > 64_000) return c.json({ code: 'request_too_large' }, 413);
469
+ try {
470
+ const outcome = parseOutcome(JSON.parse(body));
471
+ await userStub(c.env, outcome.ownerDid).recordHubActionOutcome(outcome);
472
+ return c.json({
473
+ accepted: true,
474
+ eventId: outcome.eventId,
475
+ accountId: outcome.reference.accountId,
476
+ actionId: outcome.reference.id,
477
+ revision: outcome.authorizedRevision,
478
+ digest: outcome.authorizedDigest,
479
+ sequence: outcome.sequence,
480
+ });
481
+ } catch (error) {
482
+ console.warn(
483
+ `[hub-actions] outcome refused: ${error instanceof Error ? error.message : String(error)}`,
484
+ );
485
+ return c.json({ code: 'outcome_refused' }, 409);
486
+ }
487
+ });
488
+
427
489
  app.post('/channels/turn', async (c) => {
428
490
  try {
429
491
  if (parseChannelServiceDids(c.env.CHANNEL_SERVICE_DID).length === 0)
@@ -478,6 +540,8 @@ export function createShell(
478
540
  outcome.status,
479
541
  );
480
542
  const result = outcome.result;
543
+ if (result.actionOutcomes?.length || result.hubActionOutcomes?.length)
544
+ await assertActiveChannelBindingCached(identity, c.env);
481
545
  return c.json(
482
546
  result,
483
547
  ['queued', 'running', 'recovering'].includes(result.status) ? 202 : 200,
@@ -538,8 +602,15 @@ export function createShell(
538
602
  if (!limiter) return next();
539
603
  const auth = c.get('auth') as AuthResult | undefined;
540
604
  if (!auth) return next();
605
+ // The chat lists' status poll and the card routes (Portal's review
606
+ // page) each have their own bucket: polling a card or the run list
607
+ // while chatting must not push turns into 429.
541
608
  const scope =
542
- c.req.method === 'GET' && c.req.path === '/runs/active' ? 'poll' : 'user';
609
+ c.req.method === 'GET' && c.req.path === '/runs/active'
610
+ ? 'poll'
611
+ : c.req.path.startsWith('/action-proposals/')
612
+ ? 'action'
613
+ : 'user';
543
614
  const { success } = await limiter.limit({
544
615
  key: rateLimitKey(c.env, scope, auth.userDid),
545
616
  });
@@ -555,6 +626,129 @@ export function createShell(
555
626
  return next();
556
627
  });
557
628
 
629
+ // What the owner object answers a proposal command with: the card; a
630
+ // conflict (the card or the authority it was sealed on changed: the Portal
631
+ // re-reads and retries, 409); or an authority read that failed and may
632
+ // succeed later (503, with a generic message).
633
+ const ProposalReply = z.discriminatedUnion('ok', [
634
+ z.object({ ok: z.literal(true), proposal: ActionProposalWireSchema }),
635
+ z.object({
636
+ ok: z.literal(false),
637
+ error: z.string(),
638
+ kind: z.enum(['conflict', 'unavailable']).default('conflict'),
639
+ }),
640
+ ]);
641
+ const refusalStatus = (kind: 'conflict' | 'unavailable') =>
642
+ kind === 'unavailable' ? 503 : 409;
643
+
644
+ app.get('/action-proposals/:proposalId', async (c) => {
645
+ c.header('Cache-Control', 'private, no-store');
646
+ c.header('Vary', 'Authorization');
647
+ const parsed = ActionProposalIdSchema.safeParse(c.req.param('proposalId'));
648
+ if (!parsed.success) return c.json({ error: 'Invalid proposal ID' }, 400);
649
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
650
+ const proposal = await userStub(c.env, identity.userDid).actionProposal(
651
+ identity,
652
+ parsed.data,
653
+ );
654
+ if (!proposal) return c.json({ error: 'Proposal not found' }, 404);
655
+ const result = ProposalReply.parse(JSON.parse(proposal));
656
+ return result.ok
657
+ ? c.json({ proposal: result.proposal })
658
+ : c.json({ error: result.error }, refusalStatus(result.kind));
659
+ });
660
+ for (const operation of ['claim', 'reject', 'revoke', 'reconcile'] as const) {
661
+ app.post(
662
+ `/action-proposals/:proposalId/${operation}`,
663
+ limitBody(4096),
664
+ async (c) => {
665
+ c.header('Cache-Control', 'private, no-store');
666
+ c.header('Vary', 'Authorization');
667
+ const id = ActionProposalIdSchema.safeParse(c.req.param('proposalId'));
668
+ const body = await c.req.json().catch(() => null);
669
+ const parsed = (
670
+ operation === 'reconcile'
671
+ ? z.union([
672
+ ActionProposalReconcileSchema,
673
+ TopicReceiptReferencesSchema,
674
+ ])
675
+ : ActionProposalClaimSchema
676
+ ).safeParse(body);
677
+ if (!id.success || !parsed.success)
678
+ return c.json({ error: 'Invalid proposal command' }, 400);
679
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
680
+ try {
681
+ const command =
682
+ operation === 'reconcile'
683
+ ? 'v' in parsed.data
684
+ ? {
685
+ kind: 'topic-reconcile' as const,
686
+ input: TopicReceiptReferencesSchema.parse(parsed.data),
687
+ }
688
+ : {
689
+ kind: operation,
690
+ ...ActionProposalReconcileSchema.parse(parsed.data),
691
+ }
692
+ : {
693
+ kind: operation,
694
+ ...ActionProposalClaimSchema.parse(parsed.data),
695
+ };
696
+ const proposal = await userStub(
697
+ c.env,
698
+ identity.userDid,
699
+ ).actionProposal(identity, id.data, command);
700
+ if (!proposal) return c.json({ error: 'Proposal not found' }, 404);
701
+ const result = ProposalReply.parse(JSON.parse(proposal));
702
+ return result.ok
703
+ ? c.json({ proposal: result.proposal })
704
+ : c.json({ error: result.error }, refusalStatus(result.kind));
705
+ } catch (error) {
706
+ if (
707
+ error instanceof ActionProposalConflict ||
708
+ (error instanceof Error && error.name === 'ActionProposalConflict')
709
+ )
710
+ return c.json({ error: error.message }, 409);
711
+ throw error;
712
+ }
713
+ },
714
+ );
715
+ }
716
+
717
+ app.post(
718
+ '/action-proposals/:proposalId/begin-step',
719
+ limitBody(4096),
720
+ async (c) => {
721
+ c.header('Cache-Control', 'private, no-store');
722
+ c.header('Vary', 'Authorization');
723
+ const id = ActionProposalIdSchema.safeParse(c.req.param('proposalId'));
724
+ const body = BeginTopicStepSchema.safeParse(
725
+ await c.req.json().catch(() => null),
726
+ );
727
+ if (!id.success || !body.success)
728
+ return c.json({ error: 'Invalid topic step command' }, 400);
729
+ const identity = identityOf(c.get('auth'), c.req.raw.headers);
730
+ const raw = await userStub(c.env, identity.userDid).beginTopicStep(
731
+ identity,
732
+ id.data,
733
+ body.data,
734
+ );
735
+ if (!raw) return c.json({ error: 'Proposal not found' }, 404);
736
+ const response = z
737
+ .discriminatedUnion('ok', [
738
+ z.object({ ok: z.literal(true), result: BeginTopicStepResultSchema }),
739
+ z.object({
740
+ ok: z.literal(false),
741
+ error: z.string(),
742
+ kind: z.enum(['conflict', 'unavailable']).default('conflict'),
743
+ }),
744
+ ])
745
+ .parse(JSON.parse(raw));
746
+ return response.ok
747
+ ? c.json(response.result)
748
+ : c.json({ error: response.error }, refusalStatus(response.kind));
749
+ },
750
+ );
751
+
558
752
  // --- sessions ----------------------------------------------------------------
559
753
  app.post('/sessions', async (c) => {
560
754
  const identity = identityOf(c.get('auth'), c.req.raw.headers);