@fungi.computer/oyster 0.4.0 → 0.5.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 (63) hide show
  1. package/dist/agents.d.ts +2 -1
  2. package/dist/agents.js +63 -8
  3. package/dist/app-builds.d.ts +1 -1
  4. package/dist/app-builds.js +16 -6
  5. package/dist/apps/gate.d.ts +4 -0
  6. package/dist/apps/gate.js +3 -0
  7. package/dist/boot-capability.d.ts +2 -1
  8. package/dist/boot-capability.js +7 -5
  9. package/dist/boot-host-script.js +1 -1
  10. package/dist/build/build-jobs.d.ts +3 -3
  11. package/dist/build/build-jobs.js +10 -5
  12. package/dist/build/candidate-cli.js +5 -3
  13. package/dist/build/candidate-cli.mjs +238 -5
  14. package/dist/build/candidate-diagnostic.d.ts +2 -0
  15. package/dist/build/candidate-diagnostic.js +16 -0
  16. package/dist/channels/binding.d.ts +63 -0
  17. package/dist/channels/binding.js +85 -0
  18. package/dist/channels/conversations.d.ts +235 -0
  19. package/dist/channels/conversations.js +705 -0
  20. package/dist/channels/ids.d.ts +8 -0
  21. package/dist/channels/ids.js +15 -0
  22. package/dist/host.d.ts +1 -1
  23. package/dist/host.js +21 -4
  24. package/dist/keys.d.ts +4 -1
  25. package/dist/keys.js +15 -4
  26. package/dist/machine-link/authority.d.ts +7 -0
  27. package/dist/machine-link/delivery.d.ts +12 -0
  28. package/dist/machine-link/delivery.js +22 -1
  29. package/dist/machine-link/disposition.d.ts +16 -0
  30. package/dist/machine-link/disposition.js +65 -0
  31. package/dist/machine-link/host.d.ts +11 -2
  32. package/dist/machine-link/host.js +125 -5
  33. package/dist/machine-link/link.d.ts +4 -0
  34. package/dist/machine-link/link.js +47 -1
  35. package/dist/page-script.js +4 -4
  36. package/dist/standalone/agent-mail.js +4 -1
  37. package/dist/standalone/agent-model-choice.d.ts +4 -1
  38. package/dist/standalone/agents.d.ts +8 -0
  39. package/dist/standalone/agents.js +21 -2
  40. package/dist/standalone/app-set.d.ts +8 -1
  41. package/dist/standalone/app-set.js +46 -31
  42. package/dist/standalone/channel-session-door.d.ts +4 -0
  43. package/dist/standalone/channel-session-door.js +42 -0
  44. package/dist/standalone/install-apps-bin.d.ts +2 -0
  45. package/dist/standalone/install-apps-bin.js +3 -0
  46. package/dist/standalone/install-apps-cli.d.ts +3 -1
  47. package/dist/standalone/install-apps-cli.js +27 -17
  48. package/dist/standalone/machine-link.d.ts +2 -0
  49. package/dist/standalone/machine-link.js +34 -1
  50. package/dist/standalone/registry.d.ts +1 -1
  51. package/dist/testing/capabilities.d.ts +2 -0
  52. package/dist/testing/capabilities.js +3 -1
  53. package/dist/workers-git.js +1 -1
  54. package/dist/workers.d.ts +3 -0
  55. package/dist/workers.js +9 -1
  56. package/docs/api/agents.md +8 -4
  57. package/docs/api/apps.md +8 -4
  58. package/docs/api/browser.md +40 -16
  59. package/docs/api/keys.md +25 -3
  60. package/docs/api/workers.md +21 -14
  61. package/docs/site/agent-quickstart.mdx +23 -0
  62. package/examples/standalone/wrangler.jsonc +3 -4
  63. package/package.json +29 -17
@@ -23,6 +23,7 @@ export function agentMail(options) {
23
23
  if (tick === undefined)
24
24
  throw new Error("Agent Post lane is not open");
25
25
  await tick();
26
+ await mail.stageUndeliverableReplies();
26
27
  };
27
28
  const mail = new AgentMail({ ...options, post: runtime, drain });
28
29
  const host = {
@@ -57,6 +58,8 @@ export function agentMail(options) {
57
58
  };
58
59
  return {
59
60
  host,
60
- deliver: (value) => mail.send(value),
61
+ deliver: (value) => {
62
+ return mail.send(value);
63
+ },
61
64
  };
62
65
  }
@@ -20,7 +20,10 @@ export declare class AgentModelRefused extends Error {
20
20
  /** Read the maintained Pi catalog; never accept a model from a different egress. */
21
21
  export declare function selectAgentModel(choice: AgentModelChoice): Promise<Model<Api>>;
22
22
  /** The Agent DO is the only durable owner. Absence is a new-format default, not an old-env reader. */
23
- export declare function agentModelChoice(storage: Pick<DurableObjectStorage, "get" | "put">, admit: (choice: AgentModelChoice) => void): {
23
+ export declare function agentModelChoice(storage: Readonly<{
24
+ get<T = unknown>(key: string): Promise<T | undefined>;
25
+ put(key: string, value: AgentModelChoice): Promise<void>;
26
+ }>, admit: (choice: AgentModelChoice) => void): {
24
27
  read(): Promise<AgentModelChoice>;
25
28
  set(input: AgentModelChoice): Promise<AgentModelChoice>;
26
29
  };
@@ -1,5 +1,7 @@
1
1
  import { type StandaloneTenancyEnv } from "./tenancy.js";
2
2
  import type { PostEnvelope } from "@fungi.computer/carrier/post";
3
+ import { type ChannelConversations } from "../channels/conversations.js";
4
+ import type { ConversationBinding } from "../channels/binding.js";
3
5
  import { type StandaloneMachineLinkBindings, type StandaloneMachineJoinResult } from "./machine-link.js";
4
6
  import { type MachineAgentSettlementPrincipal, type MachineAgentHome, type MachineEnrollment, type MachineLinkCaller, type MachineLinkIdentity } from "../machine-link/index.js";
5
7
  /** Workers AI binding surface used by the standalone provider adapter. */
@@ -78,6 +80,12 @@ export declare abstract class StandaloneAgentBase<Env extends StandaloneAgentEnv
78
80
  #private;
79
81
  /** The deployable root's host for this Agent's pinned tenant. */
80
82
  protected abstract hostFor(scope: TenantScope): Promise<OysterHost>;
83
+ /**
84
+ * Compose Oyster's channel owner on this Agent's real store and Shiitake.
85
+ * A host must supply its own binding lookup after authenticating its edge;
86
+ * this does not install an inbound route or mint provider credentials.
87
+ */
88
+ protected channelConversations(binding: (bindingId: string) => ConversationBinding | null): ChannelConversations;
81
89
  private uiOwner;
82
90
  private machineClient;
83
91
  private computerSnapshot;
@@ -7,6 +7,8 @@ import { AgentCreateRefused, } from "@fungi.computer/shiitake/agent-control";
7
7
  import { AgentDelegation, AgentDisclosureRefused, machineAgentCodecs, } from "@fungi.computer/shiitake/delegation";
8
8
  import { FungiMessageMetaSchema } from "@fungi.computer/agar/agents";
9
9
  import { agentMail } from "./agent-mail.js";
10
+ import { createChannelConversations, } from "../channels/conversations.js";
11
+ import { channelSessionDoor } from "./channel-session-door.js";
10
12
  import { createStandaloneMachineEnrollment, standaloneMachineAgentHome, standaloneMachineLinkAuthority, } from "./machine-link.js";
11
13
  import { createMachineLinkFences, MachineEnrollmentRefused, MachineLinkRefused, createMachineAgentClient, } from "../machine-link/index.js";
12
14
  import { memberInboxItemSchema } from "@fungi.computer/puffball/inbox";
@@ -228,6 +230,22 @@ const instructions = [
228
230
  ].join("\n");
229
231
  /** One agent: Shiitake's Cloudflare host over this Durable Object; its root supplies `hostFor`. */
230
232
  export class StandaloneAgentBase extends ShiitakeDO {
233
+ /**
234
+ * Compose Oyster's channel owner on this Agent's real store and Shiitake.
235
+ * A host must supply its own binding lookup after authenticating its edge;
236
+ * this does not install an inbound route or mint provider credentials.
237
+ */
238
+ channelConversations(binding) {
239
+ return createChannelConversations({
240
+ sql: this.ctx.storage.sql,
241
+ transactionSync: (body) => this.ctx.storage.transactionSync(body),
242
+ binding,
243
+ door: channelSessionDoor(() => this.shiitake(), () => {
244
+ // Standalone Agents have no Hub Team grants to deliver.
245
+ }),
246
+ now: () => Date.now(),
247
+ });
248
+ }
231
249
  /** Each installed Release's verified catalog surface, built once per Release for this Agent's lifetime. */
232
250
  #appSurfaces;
233
251
  uiOwner;
@@ -479,10 +497,11 @@ export class StandaloneAgentBase extends ShiitakeDO {
479
497
  throw new Error("Delegated reply identity is unavailable");
480
498
  if (mail.reply.outcome === "undeliverable")
481
499
  return undefined;
482
- if (mail.from.sessionId === undefined)
483
- throw new Error("Delegated reply Session is unavailable");
500
+ if (mail.from.kind !== "agent")
501
+ throw new Error("Delegated reply Agent identity is unavailable");
484
502
  return (await this.machineClient()).settlement(mail.from.agentId, mail.from.sessionId, mail.reply.commandId, mail.to.agentId);
485
503
  },
504
+ onUndeliverable: (original) => this.delegation().localUndeliverable(original),
486
505
  wake: () => this.wakeRuntime(),
487
506
  }));
488
507
  }
@@ -60,6 +60,13 @@ export type InstalledStarterApp = Readonly<{
60
60
  release: Schema.Json;
61
61
  timings: StarterAppTimings;
62
62
  }>;
63
+ export type StarterAppSetResult = Readonly<{
64
+ installed: readonly InstalledStarterApp[];
65
+ failed: readonly Readonly<{
66
+ installationId: string;
67
+ reason: string;
68
+ }>[];
69
+ }>;
63
70
  /**
64
71
  * Builds the installer runs at once by default. A build's compiler may take a
65
72
  * 4 GB heap, so two fit the platform's 12 GiB Computer (standard-4) with room
@@ -91,4 +98,4 @@ export declare function installStandaloneAppSet(options: Readonly<{
91
98
  /** The idle wait between build status reads; the real timer by default. */
92
99
  idle?: (ms: number, signal: AbortSignal) => Promise<void>;
93
100
  waitMs?: number;
94
- }>): Promise<readonly InstalledStarterApp[]>;
101
+ }>): Promise<StarterAppSetResult>;
@@ -72,7 +72,13 @@ const publishedSchema = Schema.Struct({
72
72
  });
73
73
  const buildSchema = Schema.Struct({
74
74
  jobId: Schema.String,
75
- status: Schema.Literals(["running", "succeeded", "failed", "revoked"]),
75
+ status: Schema.Literals([
76
+ "running",
77
+ "succeeded",
78
+ "failed",
79
+ "interrupted",
80
+ "revoked",
81
+ ]),
76
82
  detail: Schema.NullOr(Schema.String),
77
83
  /** Installed exactly as the host encoded it. */
78
84
  release: Schema.NullOr(Schema.Json),
@@ -271,10 +277,15 @@ export async function installStandaloneAppSet(options) {
271
277
  }
272
278
  };
273
279
  const installed = new Map();
274
- /** Set by the first failure, so no further seed or build starts. */
275
- const stop = { requested: false };
276
- /** Every failure, in the order it happened; the first is the one thrown. */
277
- const failures = [];
280
+ const failures = new Map();
281
+ const fail = (app, cause) => {
282
+ const reason = cause instanceof Error ? cause.message : String(cause);
283
+ failures.set(app.installationId, reason);
284
+ options.report(`Failed ${app.installationId}: ${reason}`);
285
+ };
286
+ const reportFailure = ({ app, cause, }) => {
287
+ fail(app, cause);
288
+ };
278
289
  const seededQueue = [];
279
290
  const waiting = [];
280
291
  const seeding = { finished: false };
@@ -318,21 +329,19 @@ export async function installStandaloneAppSet(options) {
318
329
  const seedAll = async () => {
319
330
  try {
320
331
  for (const app of standaloneAppSet) {
321
- if (stop.requested)
322
- break;
323
- const seeded = await seedApp(app);
324
- const waiter = waiting.shift();
325
- if (waiter === undefined)
326
- seededQueue.push(seeded);
327
- else
328
- waiter(seeded);
332
+ try {
333
+ const seeded = await seedApp(app);
334
+ const waiter = waiting.shift();
335
+ if (waiter === undefined)
336
+ seededQueue.push(seeded);
337
+ else
338
+ waiter(seeded);
339
+ }
340
+ catch (cause) {
341
+ reportFailure({ app, cause });
342
+ }
329
343
  }
330
344
  }
331
- catch (cause) {
332
- stop.requested = true;
333
- failures.push(cause);
334
- throw cause;
335
- }
336
345
  finally {
337
346
  seeding.finished = true;
338
347
  for (const waiter of waiting.splice(0))
@@ -341,7 +350,7 @@ export async function installStandaloneAppSet(options) {
341
350
  };
342
351
  // `concurrency` builds at once, each installing as its build succeeds.
343
352
  const slot = async () => {
344
- for (let next = await takeSeeded(); next !== undefined && !stop.requested; next = await takeSeeded()) {
353
+ for (let next = await takeSeeded(); next !== undefined; next = await takeSeeded()) {
345
354
  const name = next.app.installationId;
346
355
  try {
347
356
  const buildStarted = performance.now();
@@ -416,22 +425,28 @@ export async function installStandaloneAppSet(options) {
416
425
  });
417
426
  }
418
427
  catch (cause) {
419
- stop.requested = true;
420
- failures.push(cause);
421
- throw cause;
428
+ reportFailure({ app: next.app, cause });
422
429
  }
423
430
  }
424
431
  };
425
- // Wait for every seed and build to settle before reporting: nothing of this
426
- // call may still run, or install, after it rejects.
427
- await Promise.allSettled([
432
+ // Wait for every seed and build to settle before returning the full outcome.
433
+ const settled = await Promise.allSettled([
428
434
  seedAll(),
429
435
  ...Array.from({ length: concurrency }, slot),
430
436
  ]);
431
- if (failures.length > 0)
432
- throw failures[0];
433
- return standaloneAppSet.flatMap((app) => {
434
- const result = installed.get(app.installationId);
435
- return result === undefined ? [] : [result];
436
- });
437
+ for (const outcome of settled)
438
+ if (outcome.status === "rejected")
439
+ throw outcome.reason;
440
+ return {
441
+ installed: standaloneAppSet.flatMap((app) => {
442
+ const result = installed.get(app.installationId);
443
+ return result === undefined ? [] : [result];
444
+ }),
445
+ failed: standaloneAppSet.flatMap((app) => {
446
+ const reason = failures.get(app.installationId);
447
+ return reason === undefined
448
+ ? []
449
+ : [{ installationId: app.installationId, reason }];
450
+ }),
451
+ };
437
452
  }
@@ -0,0 +1,4 @@
1
+ import type { ShiitakeOwnerAgent } from "@fungi.computer/shiitake/cloudflare";
2
+ import type { SessionDoor } from "../channels/conversations.js";
3
+ /** Translate a standalone Agent's Shiitake owner into Oyster's channel Session port. */
4
+ export declare function channelSessionDoor(open: () => Promise<ShiitakeOwnerAgent>, deliverGrants: (sessionId: string) => void): SessionDoor;
@@ -0,0 +1,42 @@
1
+ import { authorMeta } from "@fungi.computer/agar/agents";
2
+ import { ShiitakeError } from "@fungi.computer/shiitake";
3
+ /** Translate a standalone Agent's Shiitake owner into Oyster's channel Session port. */
4
+ export function channelSessionDoor(open, deliverGrants) {
5
+ return {
6
+ createSession: async (requestId) => (await open()).createSession(requestId),
7
+ deliverGrants,
8
+ withdraw: async (sessionId, requestId) => (await open()).session(sessionId).withdraw(requestId),
9
+ latestPending: async (sessionId, visitor) => (await open())
10
+ .withMeta(authorMeta(visitor))
11
+ .session(sessionId)
12
+ .latestPending(),
13
+ prompt: async (sessionId, { requestId, text, meta }) => {
14
+ // The conversation row exists now, so its channel admission is known;
15
+ // deliver Agent tools before the first run can call one.
16
+ deliverGrants(sessionId);
17
+ const agent = await open();
18
+ return await agent.withMeta(meta).session(sessionId).command({
19
+ type: "prompt",
20
+ requestId,
21
+ text,
22
+ delivery: "followUp",
23
+ });
24
+ },
25
+ line: async (sessionId, input) => {
26
+ const agent = await open();
27
+ const meta = input.author === "operator" ? authorMeta(input.identity) : input.meta;
28
+ await agent.withMeta(meta).session(sessionId).command({
29
+ type: "operator",
30
+ requestId: input.requestId,
31
+ author: input.author,
32
+ text: input.text,
33
+ });
34
+ },
35
+ errorKind: (error, requestId) => error instanceof ShiitakeError &&
36
+ error.boundary === "contract" &&
37
+ error.cause === "closed" &&
38
+ error.requestId === requestId
39
+ ? "closed_request"
40
+ : null,
41
+ };
42
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { runInstallAppsCli } from "./install-apps-cli.js";
3
+ await runInstallAppsCli();
@@ -1,2 +1,4 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import { installStandaloneAppSet } from "./app-set.js";
3
+ export declare function installApps(args: readonly string[], install?: typeof installStandaloneAppSet): Promise<void>;
4
+ export declare function runInstallAppsCli(args?: readonly string[]): Promise<void>;
@@ -76,7 +76,7 @@ function timingTable(results) {
76
76
  ];
77
77
  return [header, ...rows].map((row) => row.join("\t")).join("\n");
78
78
  }
79
- async function installApps(args) {
79
+ export async function installApps(args, install = installStandaloneAppSet) {
80
80
  const { values } = parseArgs({
81
81
  args: [...args],
82
82
  options: {
@@ -121,7 +121,7 @@ async function installApps(args) {
121
121
  source = { kind: "checkout", directory, revision: `git:${revision}` };
122
122
  }
123
123
  const started = performance.now();
124
- const results = await installStandaloneAppSet({
124
+ const { installed, failed } = await install({
125
125
  source,
126
126
  url,
127
127
  key,
@@ -131,33 +131,43 @@ async function installApps(args) {
131
131
  },
132
132
  });
133
133
  const elapsed = performance.now() - started;
134
- process.stdout.write(`\n${timingTable(results)}\n`);
135
- process.stdout.write(`\nInstalled ${String(results.length)} Apps in ${seconds(elapsed)}. Open ${url}/ to use Tourtois and the installed Apps.\n`);
134
+ process.stdout.write(`\n${timingTable(installed)}\n`);
135
+ process.stdout.write("\nApp\tResult\n");
136
+ for (const app of installed)
137
+ process.stdout.write(`${app.installationId}\tinstalled\n`);
138
+ for (const app of failed)
139
+ process.stdout.write(`${app.installationId}\tfailed: ${app.reason}\n`);
140
+ process.stdout.write(`\nInstalled ${String(installed.length)} Apps; failed ${String(failed.length)} in ${seconds(elapsed)}.${installed.length > 0 ? ` Open ${url}/ to use the installed Apps.` : ""}\n`);
136
141
  if (values.timings !== undefined)
137
142
  await writeFile(values.timings, JSON.stringify({
138
143
  concurrency,
139
144
  elapsedMs: elapsed,
140
- apps: results.map(({ installationId, jobId, timings }) => ({
145
+ apps: installed.map(({ installationId, jobId, timings }) => ({
141
146
  installationId,
142
147
  jobId,
143
148
  timings,
144
149
  })),
150
+ failed,
145
151
  }, null, 2) + "\n");
152
+ if (failed.length > 0)
153
+ process.exitCode = 1;
146
154
  }
147
155
  finally {
148
156
  await rm(scratch, { recursive: true, force: true });
149
157
  }
150
158
  }
151
- const [command, ...rest] = process.argv.slice(2);
152
- if (command === "--help" || command === "-h") {
153
- process.stdout.write(usage + "\n" + installReleaseUsage + "\n");
154
- }
155
- else if (command !== "install-apps" && command !== "install") {
156
- process.stderr.write(`${usage}\n`);
157
- process.exitCode = 2;
159
+ export async function runInstallAppsCli(args = process.argv.slice(2)) {
160
+ const [command, ...rest] = args;
161
+ if (command === "--help" || command === "-h") {
162
+ process.stdout.write(usage + "\n" + installReleaseUsage + "\n");
163
+ }
164
+ else if (command !== "install-apps" && command !== "install") {
165
+ process.stderr.write(`${usage}\n`);
166
+ process.exitCode = 2;
167
+ }
168
+ else
169
+ await (command === "install" ? installReleaseCommand(rest) : installApps(rest)).catch((cause) => {
170
+ process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`);
171
+ process.exitCode = 1;
172
+ });
158
173
  }
159
- else
160
- await (command === "install" ? installReleaseCommand(rest) : installApps(rest)).catch((cause) => {
161
- process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`);
162
- process.exitCode = 1;
163
- });
@@ -268,6 +268,8 @@ export declare class MachineLinkDO extends DurableObject<StandaloneMachineLinkEn
268
268
  alarm(): Promise<void>;
269
269
  fetch(request: Request): Promise<Response>;
270
270
  deliver(id: MachineLinkIdentity, caller: MachineLinkCaller, agentId: string, envelope: PostEnvelope): Promise<PostDeliveryResult>;
271
+ deliverRelayDisposition(id: MachineLinkIdentity, relay: MachineLinkIdentity, original: PostEnvelope, reply: PostEnvelope): Promise<PostDeliveryResult>;
272
+ hasStagedDisposition(id: MachineLinkIdentity, original: PostEnvelope, reply: PostEnvelope): Promise<boolean>;
271
273
  operation(id: MachineLinkIdentity, caller: MachineLinkCaller, agentId: string, operation: MachineAgentOperation): Promise<import("effect/Schema").Json>;
272
274
  applyFence(fact: MachineLinkFenceFact): Promise<MachineLinkFenceResult>;
273
275
  preflightErasure(teamId: string, deviceId: string): Promise<void>;
@@ -1,4 +1,4 @@
1
- import { machineAgentReservations } from "@fungi.computer/shiitake/delegation";
1
+ import { machineAgentCodecs, machineAgentReservations, } from "@fungi.computer/shiitake/delegation";
2
2
  import { createMachineEnrollment, MachineEnrollmentRefused, MachineLinkRefused, } from "../machine-link/index.js";
3
3
  import { keyAllowsMachineEnroll, } from "../keys.js";
4
4
  import { agentIds, createAgentDirectory, } from "@fungi.computer/shiitake/fleet";
@@ -8,6 +8,7 @@ import { bodyLimit } from "hono/body-limit";
8
8
  import { validator } from "hono/validator";
9
9
  import { machineEnrollmentSchema, machineLinkName, } from "../machine-link/index.js";
10
10
  import { createMachineLink } from "../machine-link/workers.js";
11
+ import { deliverUndeliverableMail } from "../machine-link/delivery.js";
11
12
  import { defaultTenant, tenantScope } from "../tenant.js";
12
13
  import { presentedGitKey } from "../git-auth.js";
13
14
  import { standaloneTenancy } from "./tenancy.js";
@@ -49,6 +50,7 @@ export function standaloneMachineLinkAuthority(env) {
49
50
  return directory().machineDevice(id);
50
51
  },
51
52
  currentFence: async (id) => id.teamId !== teamId ? null : directory().machineFence(id),
53
+ agentHome: async (id, agentId) => id.teamId !== teamId ? null : directory().agentHome(agentId),
52
54
  directory: async (id) => {
53
55
  if (!(await authority.admitDevice(id)).live)
54
56
  throw new MachineLinkRefused("actor_not_allowed");
@@ -69,6 +71,31 @@ export function standaloneMachineLinkAuthority(env) {
69
71
  throw new MachineLinkRefused("actor_not_allowed");
70
72
  return env.MACHINE_LINK.getByName(machineLinkName(next)).deliver(next, caller, facts.targetAgentId, envelope);
71
73
  },
74
+ deliverUndeliverable: async (id, original, reply) => {
75
+ if ((await authority.currentFence(id)) === null)
76
+ return "refused";
77
+ const mail = original.event;
78
+ if (mail.from.kind !== "agent")
79
+ return "refused";
80
+ const home = await authority.agentHome(id, mail.from.agentId);
81
+ if (home === null)
82
+ throw new MachineLinkRefused("unavailable");
83
+ return await deliverUndeliverableMail({
84
+ identity: id,
85
+ original,
86
+ reply,
87
+ senderHome: home,
88
+ hosted: env.AGENTS,
89
+ accepted: machineAgentCodecs.accepted,
90
+ machine: async (deviceId, envelope) => {
91
+ const destination = { teamId, deviceId };
92
+ return await env.MACHINE_LINK.getByName(machineLinkName(destination)).deliverRelayDisposition(destination, id, original, envelope);
93
+ },
94
+ });
95
+ },
96
+ verifyRelayDisposition: async (relay, original, reply) => {
97
+ return await env.MACHINE_LINK.getByName(machineLinkName(relay)).hasStagedDisposition(relay, original, reply);
98
+ },
72
99
  canErase: async (id) => {
73
100
  if (id.teamId !== teamId)
74
101
  return false;
@@ -185,6 +212,12 @@ export class MachineLinkDO extends DurableObject {
185
212
  deliver(id, caller, agentId, envelope) {
186
213
  return this.link.deliver(id, caller, agentId, envelope);
187
214
  }
215
+ deliverRelayDisposition(id, relay, original, reply) {
216
+ return this.link.deliverRelayDisposition(id, relay, original, reply);
217
+ }
218
+ hasStagedDisposition(id, original, reply) {
219
+ return this.link.hasStagedDisposition(id, original, reply);
220
+ }
188
221
  operation(id, caller, agentId, operation) {
189
222
  return this.link.operation(id, caller, agentId, operation);
190
223
  }
@@ -187,7 +187,7 @@ export declare abstract class StandaloneRegistry extends OysterRegistry implemen
187
187
  appId: string;
188
188
  releaseId: string;
189
189
  sourceRevision: string;
190
- status: "running" | "succeeded" | "failed" | "revoked";
190
+ status: "running" | "succeeded" | "failed" | "interrupted" | "revoked";
191
191
  detail: string | null;
192
192
  release: Release | null;
193
193
  installation: import("../app-builds.js").OysterBuildInstallation | null;
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { type AppCapabilityId } from "@fungi.computer/puffball/app";
3
3
  import { type OysterAgents } from "../agents.js";
4
+ import type { LiveOysterKey } from "../keys.js";
4
5
  import { type FileSource } from "../files.js";
5
6
  import { type OysterAuthResult, type OysterCapability } from "../host.js";
6
7
  /** How one capability fixture host admits its single installation. */
@@ -19,6 +20,7 @@ export type CapabilityFixtureOptions = Readonly<{
19
20
  visitor?: boolean;
20
21
  authenticate?: (request: Request) => Promise<OysterAuthResult>;
21
22
  agents?: OysterAgents;
23
+ liveKey?: (keyId: string) => Promise<LiveOysterKey | null>;
22
24
  }>;
23
25
  /** One Oyster host with a single installation whose capability grants the options shape. */
24
26
  export declare function capabilityFixture(options?: CapabilityFixtureOptions): Promise<{
@@ -84,7 +84,7 @@ export async function capabilityFixture(options = {}) {
84
84
  if (options.visitor)
85
85
  installation = { ...installation, visibility: "public" };
86
86
  const readOnly = () => Promise.reject(new Error("Fixture installations are read-only"));
87
- const base = {
87
+ let base = {
88
88
  bootUi: () => Promise.resolve({ kind: "passed" }),
89
89
  auth: {
90
90
  resolve: () => Promise.reject(new Error("Capability fixture resolves no actor")),
@@ -116,6 +116,8 @@ export async function capabilityFixture(options = {}) {
116
116
  },
117
117
  capabilities,
118
118
  };
119
+ if (options.liveKey !== undefined)
120
+ base = { ...base, keys: { live: options.liveKey } };
119
121
  const host = options.agents === undefined
120
122
  ? base
121
123
  : { ...base, ownerRoutes: agentApprovalRoutes(options.agents) };
@@ -1117,7 +1117,7 @@ export async function oysterGitWorkerHost(env, options, selected, request) {
1117
1117
  const base = oysterWorkerHost(env, auth, policyOptions);
1118
1118
  const capabilities = [
1119
1119
  ...(base.capabilities ?? []),
1120
- ...(agents === undefined ? [] : agentCapabilities(agents)),
1120
+ ...(agents === undefined ? [] : agentCapabilities(agents, keysLive)),
1121
1121
  ...(options.capabilities?.(env, workspace, keysLive) ?? []),
1122
1122
  ];
1123
1123
  let gitOptions = { scope };
package/dist/workers.d.ts CHANGED
@@ -138,6 +138,7 @@ export declare class OysterRegistry<Env extends OysterWorkerEnv = OysterWorkerEn
138
138
  };
139
139
  readonly reversibility: "reversible" | "irreversible-external";
140
140
  readonly requestedAt: number;
141
+ readonly denialNote?: string | undefined;
141
142
  readonly evidence?: readonly {
142
143
  readonly kind: "artifact";
143
144
  readonly ref: string;
@@ -155,6 +156,7 @@ export declare class OysterRegistry<Env extends OysterWorkerEnv = OysterWorkerEn
155
156
  }>, actor: HostActor, input: ApprovalAnswerInput): Promise<Readonly<{
156
157
  kind: "refused";
157
158
  reason: "pending" | "expired" | "not-found" | "not-eligible" | "already-answered" | "stale-revision" | "declined" | "fenced" | "not-granted" | "read-only" | "in-flight" | "outcome-unknown" | "identity-conflict";
159
+ denialNote?: string;
158
160
  }> | Readonly<{
159
161
  kind: "answered";
160
162
  view: import("@fungi.computer/approvals").ApprovalView;
@@ -166,6 +168,7 @@ export declare class OysterRegistry<Env extends OysterWorkerEnv = OysterWorkerEn
166
168
  }>, actor: HostActor, input: AutoApproveInput): Promise<Readonly<{
167
169
  kind: "refused";
168
170
  reason: "pending" | "expired" | "not-found" | "not-eligible" | "already-answered" | "stale-revision" | "declined" | "fenced" | "not-granted" | "read-only" | "in-flight" | "outcome-unknown" | "identity-conflict";
171
+ denialNote?: string;
169
172
  }> | Readonly<{
170
173
  kind: "updated";
171
174
  rule: AutoApproveInput;
package/dist/workers.js CHANGED
@@ -48,6 +48,8 @@ import { createOysterRootData } from "./root-data.js";
48
48
  import { backendCache, resolveBackend } from "./release.js";
49
49
  import { ensureAppSourceTables, createOysterAppSourceReceipts, createOysterAppRegistrations, createOysterArtifactsRepositoryMetadataStore, } from "./app-sources.js";
50
50
  export { r2ArtifactStore, r2FileSource, ownerKeyAuth };
51
+ /** Issuer-ahead clock allowance at issue only; resolution never adds skew. */
52
+ const BOOT_ISSUER_SKEW_MS = 2_000;
51
53
  const recordRow = Schema.Struct({
52
54
  installation_id: Schema.String,
53
55
  record: Schema.String,
@@ -433,8 +435,14 @@ export class OysterRegistry extends DurableObject {
433
435
  const { lease, tokenDigest } = bootCapabilityGrantSchema.parse(input.grant);
434
436
  validateCapabilityAssetSet(lease);
435
437
  const now = Date.now();
438
+ // The issuer can be at most 2 s ahead of this registry. Apply that
439
+ // allowance only on issue: resolution still uses the stored absolute
440
+ // expiry, and revocation still removes the token after the page check.
441
+ // A lease can therefore live at most 14 s on this clock, not be renewed
442
+ // or replayed once expired.
436
443
  if (lease.expiresAt <= now ||
437
- lease.expiresAt > now + BOOT_DEADLINE_MS + BOOT_TRANSPORT_GRACE_MS)
444
+ lease.expiresAt >
445
+ now + BOOT_DEADLINE_MS + BOOT_TRANSPORT_GRACE_MS + BOOT_ISSUER_SKEW_MS)
438
446
  throw new Error("Boot capability lease is outside its deadline");
439
447
  this.ctx.storage.sql.exec("DELETE FROM boot_capabilities WHERE expires_at <= ?", now);
440
448
  const count = this.ctx.storage.sql
@@ -358,16 +358,20 @@ optional bearer.
358
358
  ### agentCapabilities()
359
359
 
360
360
  ```ts
361
- function agentCapabilities(agents): readonly Readonly<object & object>[];
361
+ function agentCapabilities(
362
+ agents,
363
+ liveKey,
364
+ ): readonly Readonly<object & object>[];
362
365
  ```
363
366
 
364
367
  Agent adapters admitted against each App's declared capabilities.
365
368
 
366
369
  #### Parameters
367
370
 
368
- | Parameter | Type |
369
- | --------- | ------------------------------- |
370
- | `agents` | [`OysterAgents`](#oysteragents) |
371
+ | Parameter | Type |
372
+ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
373
+ | `agents` | [`OysterAgents`](#oysteragents) |
374
+ | `liveKey` | (`keyId`) => `Promise`\< \| `Readonly`\<\{ `keyId`: `string`; `owner`: `string`; `grantVersion`: `number`; `grants`: readonly `Readonly`\<\{ `capability`: [`OysterKeyCapability`](keys.md#oysterkeycapability); `targets`: `GrantTargets`; \}\>[]; \}\> \| `null`\> |
371
375
 
372
376
  #### Returns
373
377