@fungi.computer/oyster 0.4.1 → 0.6.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 (86) hide show
  1. package/README.md +46 -8
  2. package/dist/agents.d.ts +2 -1
  3. package/dist/agents.js +67 -8
  4. package/dist/apps/gate.d.ts +4 -0
  5. package/dist/apps/gate.js +3 -0
  6. package/dist/auth.d.ts +24 -1
  7. package/dist/auth.js +38 -0
  8. package/dist/boot-host-script.js +1 -1
  9. package/dist/build/candidate-cli.mjs +27 -5
  10. package/dist/channels/binding.d.ts +70 -0
  11. package/dist/channels/binding.js +97 -0
  12. package/dist/channels/conversations.d.ts +246 -0
  13. package/dist/channels/conversations.js +705 -0
  14. package/dist/channels/ids.d.ts +8 -0
  15. package/dist/channels/ids.js +15 -0
  16. package/dist/channels/visitor-channel.d.ts +53 -0
  17. package/dist/channels/visitor-channel.js +124 -0
  18. package/dist/channels/visitor-edge.d.ts +115 -0
  19. package/dist/channels/visitor-edge.js +582 -0
  20. package/dist/channels/visitor-request.d.ts +7 -0
  21. package/dist/channels/visitor-request.js +17 -0
  22. package/dist/channels/visitor-stream-slots.d.ts +2 -0
  23. package/dist/channels/visitor-stream-slots.js +33 -0
  24. package/dist/channels/visitors.d.ts +47 -0
  25. package/dist/channels/visitors.js +181 -0
  26. package/dist/cloudflare.d.ts +8 -2
  27. package/dist/cloudflare.js +27 -1
  28. package/dist/computer-target.d.ts +5 -0
  29. package/dist/computer-target.js +11 -0
  30. package/dist/host.d.ts +7 -4
  31. package/dist/host.js +96 -17
  32. package/dist/internal/host-gate.d.ts +6 -4
  33. package/dist/internal/host-gate.js +14 -7
  34. package/dist/invocation.d.ts +11 -4
  35. package/dist/invocation.js +134 -91
  36. package/dist/keys.d.ts +4 -1
  37. package/dist/keys.js +15 -4
  38. package/dist/machine-link/authority.d.ts +7 -0
  39. package/dist/machine-link/delivery.d.ts +12 -0
  40. package/dist/machine-link/delivery.js +22 -1
  41. package/dist/machine-link/disposition.d.ts +16 -0
  42. package/dist/machine-link/disposition.js +65 -0
  43. package/dist/machine-link/host.d.ts +11 -2
  44. package/dist/machine-link/host.js +125 -5
  45. package/dist/machine-link/link.d.ts +4 -0
  46. package/dist/machine-link/link.js +47 -1
  47. package/dist/page/capabilities.js +1 -1
  48. package/dist/page/child-selection.d.ts +27 -0
  49. package/dist/page/child-selection.js +38 -0
  50. package/dist/page/mount.js +1 -1
  51. package/dist/page-script.js +4 -4
  52. package/dist/release.d.ts +13 -0
  53. package/dist/release.js +10 -0
  54. package/dist/request-origin.d.ts +4 -0
  55. package/dist/request-origin.js +10 -0
  56. package/dist/standalone/agent-mail.js +4 -1
  57. package/dist/standalone/agents.d.ts +8 -0
  58. package/dist/standalone/agents.js +30 -11
  59. package/dist/standalone/channel-session-door.d.ts +4 -0
  60. package/dist/standalone/channel-session-door.js +72 -0
  61. package/dist/standalone/computer-fleet.js +7 -1
  62. package/dist/standalone/machine-link.d.ts +2 -0
  63. package/dist/standalone/machine-link.js +34 -1
  64. package/dist/standalone/tenancy.d.ts +3 -0
  65. package/dist/standalone/tenancy.js +7 -2
  66. package/dist/testing/capabilities.d.ts +2 -0
  67. package/dist/testing/capabilities.js +3 -1
  68. package/dist/turnstile.d.ts +27 -0
  69. package/dist/turnstile.js +51 -0
  70. package/dist/workers-git.js +21 -2
  71. package/dist/workers.d.ts +3 -0
  72. package/dist/workers.js +15 -12
  73. package/docs/api/agents.md +8 -4
  74. package/docs/api/apps.md +8 -4
  75. package/docs/api/auth.md +55 -0
  76. package/docs/api/browser.md +40 -16
  77. package/docs/api/build-contract.md +2 -2
  78. package/docs/api/cloudflare.md +26 -1
  79. package/docs/api/host.md +22 -13
  80. package/docs/api/invocation.md +7 -3
  81. package/docs/api/keys.md +25 -3
  82. package/docs/api/workers.md +38 -30
  83. package/docs/site/agent-quickstart.mdx +29 -2
  84. package/examples/standalone/wrangler.celld.jsonc +66 -0
  85. package/examples/standalone/wrangler.jsonc +3 -4
  86. package/package.json +53 -17
package/dist/release.d.ts CHANGED
@@ -60,6 +60,19 @@ export type VerifiedRelease = Readonly<{
60
60
  export declare function sha256(bytes: Uint8Array): Promise<string>;
61
61
  /** Read one artifact and refuse it unless its bytes match `digest`. */
62
62
  export declare function readVerified(store: ArtifactStore, artifactRef: ArtifactRef, digest: Sha256Digest): Promise<Uint8Array>;
63
+ /**
64
+ * Copies of verified artifacts near the reader, keyed by content digest. An
65
+ * entry holds only bytes that already matched its digest, so a hit is served
66
+ * without another store read or hash.
67
+ */
68
+ export type VerifiedObjectCache = Readonly<{
69
+ /** The cached bytes for `digest`, or null on a miss. */
70
+ match: (digest: Sha256Digest) => Promise<ReadableStream<Uint8Array> | null>;
71
+ /** Keep verified bytes; an unstored write only costs the next reader a store read. */
72
+ put: (digest: Sha256Digest, bytes: Uint8Array) => Promise<"stored" | "unstored">;
73
+ }>;
74
+ /** {@link readVerified} through an optional digest-keyed cache it fills on a miss. */
75
+ export declare function readCachedVerified(store: ArtifactStore, cache: VerifiedObjectCache | undefined, artifactRef: ArtifactRef, digest: Sha256Digest): Promise<ReadableStream<Uint8Array> | Uint8Array<ArrayBuffer>>;
63
76
  /** Decode an untrusted Release record, as an install request carries it. */
64
77
  export declare function parseRelease(value: unknown): Release;
65
78
  /**
package/dist/release.js CHANGED
@@ -36,6 +36,16 @@ export async function readVerified(store, artifactRef, digest) {
36
36
  throw new ReleaseError("digest_mismatch", `Digest mismatch ${artifactRef}`);
37
37
  return bytes;
38
38
  }
39
+ /** {@link readVerified} through an optional digest-keyed cache it fills on a miss. */
40
+ export async function readCachedVerified(store, cache, artifactRef, digest) {
41
+ const hit = await cache?.match(digest);
42
+ if (hit !== undefined && hit !== null)
43
+ return hit;
44
+ const bytes = await readVerified(store, artifactRef, digest);
45
+ await cache?.put(digest, bytes);
46
+ // A response body owns a plain ArrayBuffer, whatever view the store returned.
47
+ return new Uint8Array(bytes);
48
+ }
39
49
  const decodeText = (bytes) => new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
40
50
  /** Decode an untrusted Release record, as an install request carries it. */
41
51
  export function parseRelease(value) {
@@ -0,0 +1,4 @@
1
+ /** Require the request's own Origin and refuse cross-site provenance. */
2
+ export declare function sameOriginRequest(request: Request): boolean;
3
+ /** Channel reads allow absent Origin but never conflicting or cross-site provenance. */
4
+ export declare function sameOriginReadRequest(request: Request): boolean;
@@ -0,0 +1,10 @@
1
+ /** Require the request's own Origin and refuse cross-site provenance. */
2
+ export function sameOriginRequest(request) {
3
+ return request.headers.has("origin") && sameOriginReadRequest(request);
4
+ }
5
+ /** Channel reads allow absent Origin but never conflicting or cross-site provenance. */
6
+ export function sameOriginReadRequest(request) {
7
+ const origin = request.headers.get("origin");
8
+ return (request.headers.get("sec-fetch-site") !== "cross-site" &&
9
+ (origin === null || origin === new URL(request.url).origin));
10
+ }
@@ -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
  }
@@ -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;
@@ -1,5 +1,4 @@
1
- import { normalizeModel, streamModel } from "@fungi.computer/mule/effect";
2
- import { observeProviderStatus } from "@fungi.computer/mule/effect";
1
+ import { isRetryableProviderRejection, normalizeModel, observeProviderStatus, streamModel, } from "@fungi.computer/mule/effect";
3
2
  import { standaloneTenancy, } from "./tenancy.js";
4
3
  import { AcceptedSchema } from "@fungi.computer/shiitake/client";
5
4
  import { createShiitakeClient } from "@fungi.computer/shiitake/client";
@@ -7,6 +6,8 @@ import { AgentCreateRefused, } from "@fungi.computer/shiitake/agent-control";
7
6
  import { AgentDelegation, AgentDisclosureRefused, machineAgentCodecs, } from "@fungi.computer/shiitake/delegation";
8
7
  import { FungiMessageMetaSchema } from "@fungi.computer/agar/agents";
9
8
  import { agentMail } from "./agent-mail.js";
9
+ import { createChannelConversations, } from "../channels/conversations.js";
10
+ import { channelSessionDoor } from "./channel-session-door.js";
10
11
  import { createStandaloneMachineEnrollment, standaloneMachineAgentHome, standaloneMachineLinkAuthority, } from "./machine-link.js";
11
12
  import { createMachineLinkFences, MachineEnrollmentRefused, MachineLinkRefused, createMachineAgentClient, } from "../machine-link/index.js";
12
13
  import { memberInboxItemSchema } from "@fungi.computer/puffball/inbox";
@@ -51,10 +52,10 @@ function observedModels(model, defaults) {
51
52
  acquire: ({ signal }) => {
52
53
  const retryableMessages = new WeakSet();
53
54
  const safeFailures = new WeakMap();
54
- const failure = (message, code) => {
55
+ const failure = (message, rejection) => {
55
56
  // Pi can include raw response bodies in errorMessage. No provider
56
57
  // prose enters a Session, retry journal, model context or diagnostics.
57
- const safe = `Model request failed (${code}). Retry the request.`;
58
+ const safe = `Model request failed (${rejection?.code ?? "provider_rejected"}). Retry the request.`;
58
59
  const terminal = {
59
60
  role: "assistant",
60
61
  content: [],
@@ -67,7 +68,7 @@ function observedModels(model, defaults) {
67
68
  timestamp: message.timestamp,
68
69
  };
69
70
  safeFailures.set(terminal, safe);
70
- if (code === "provider_unavailable")
71
+ if (isRetryableProviderRejection(rejection))
71
72
  retryableMessages.add(terminal);
72
73
  return terminal;
73
74
  };
@@ -90,7 +91,7 @@ function observedModels(model, defaults) {
90
91
  });
91
92
  for await (const event of response) {
92
93
  if (event.type === "error") {
93
- const terminal = failure(event.error, observed.rejection()?.code ?? "provider_rejected");
94
+ const terminal = failure(event.error, observed.rejection(event.error));
94
95
  events.push({ ...event, error: terminal });
95
96
  events.end(terminal);
96
97
  return;
@@ -98,7 +99,7 @@ function observedModels(model, defaults) {
98
99
  if (event.type === "done" &&
99
100
  (event.message.stopReason === "error" ||
100
101
  event.message.stopReason === "aborted")) {
101
- const terminal = failure(event.message, observed.rejection()?.code ?? "provider_rejected");
102
+ const terminal = failure(event.message, observed.rejection(event.message));
102
103
  events.push({ ...event, message: terminal });
103
104
  events.end(terminal);
104
105
  return;
@@ -107,7 +108,7 @@ function observedModels(model, defaults) {
107
108
  }
108
109
  const result = await response.result();
109
110
  events.end(result.stopReason === "error" || result.stopReason === "aborted"
110
- ? failure(result, observed.rejection()?.code ?? "provider_rejected")
111
+ ? failure(result, observed.rejection(result))
111
112
  : result);
112
113
  }
113
114
  catch {
@@ -134,7 +135,7 @@ function observedModels(model, defaults) {
134
135
  },
135
136
  stopReason: signal.aborted ? "aborted" : "error",
136
137
  timestamp: Date.now(),
137
- }, "provider_unavailable");
138
+ }, { code: "provider_unavailable" });
138
139
  events.push({
139
140
  type: "error",
140
141
  reason: signal.aborted ? "aborted" : "error",
@@ -228,6 +229,23 @@ const instructions = [
228
229
  ].join("\n");
229
230
  /** One agent: Shiitake's Cloudflare host over this Durable Object; its root supplies `hostFor`. */
230
231
  export class StandaloneAgentBase extends ShiitakeDO {
232
+ /**
233
+ * Compose Oyster's channel owner on this Agent's real store and Shiitake.
234
+ * A host must supply its own binding lookup after authenticating its edge;
235
+ * this does not install an inbound route or mint provider credentials.
236
+ */
237
+ channelConversations(binding) {
238
+ const owner = createChannelConversations({
239
+ sql: this.ctx.storage.sql,
240
+ transactionSync: (body) => this.ctx.storage.transactionSync(body),
241
+ binding,
242
+ door: channelSessionDoor(() => this.shiitake(), () => {
243
+ // Standalone Agents have no Hub Team grants to deliver.
244
+ }, () => owner),
245
+ now: () => Date.now(),
246
+ });
247
+ return owner;
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
  }
@@ -0,0 +1,4 @@
1
+ import type { ShiitakeOwnerAgent } from "@fungi.computer/shiitake/cloudflare";
2
+ import type { ChannelConversations, 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, conversations: () => ChannelConversations): SessionDoor;
@@ -0,0 +1,72 @@
1
+ import { authorMeta } from "@fungi.computer/agar/agents";
2
+ import { ShiitakeError } from "@fungi.computer/shiitake";
3
+ import { participantSnapshot, visitorPendingPrompt, visitorSnapshot, } from "@fungi.computer/shiitake/client";
4
+ import { visitorStream } from "@fungi.computer/shiitake/server-visitor-stream";
5
+ /** Translate a standalone Agent's Shiitake owner into Oyster's channel Session port. */
6
+ export function channelSessionDoor(open, deliverGrants, conversations) {
7
+ const policy = () => conversations();
8
+ return {
9
+ createSession: async (requestId) => (await open()).createSession(requestId),
10
+ deliverGrants,
11
+ withdraw: async (sessionId, requestId) => (await open()).session(sessionId).withdraw(requestId),
12
+ latestPending: async (sessionId, visitor) => {
13
+ const prompt = await (await open())
14
+ .withMeta(authorMeta(visitor))
15
+ .session(sessionId)
16
+ .latestPending();
17
+ return prompt === null ? null : visitorPendingPrompt(prompt, visitor.id);
18
+ },
19
+ participantDocument: async (sessionId, visitorId, options) => {
20
+ const snapshot = await (await open()).session(sessionId).read(options);
21
+ return JSON.stringify(visitorSnapshot(participantSnapshot(snapshot, policy()), visitorId));
22
+ },
23
+ participantStream: async (sessionId, visitorId, options) => {
24
+ let watch;
25
+ try {
26
+ watch = await (await open()).session(sessionId).watch();
27
+ }
28
+ catch (cause) {
29
+ options.release();
30
+ throw cause;
31
+ }
32
+ return visitorStream({
33
+ ...options,
34
+ watch,
35
+ visitorId,
36
+ filter: (snapshot) => participantSnapshot(snapshot, policy()),
37
+ read: async () => (await open()).session(sessionId).read(),
38
+ onError: (event, cause) => {
39
+ console.warn(event, cause);
40
+ },
41
+ });
42
+ },
43
+ prompt: async (sessionId, { requestId, text, meta }) => {
44
+ // The conversation row exists now, so its channel admission is known;
45
+ // deliver Agent tools before the first run can call one.
46
+ deliverGrants(sessionId);
47
+ const agent = await open();
48
+ return await agent.withMeta(meta).session(sessionId).command({
49
+ type: "prompt",
50
+ requestId,
51
+ text,
52
+ delivery: "followUp",
53
+ });
54
+ },
55
+ line: async (sessionId, input) => {
56
+ const agent = await open();
57
+ const meta = input.author === "operator" ? authorMeta(input.identity) : input.meta;
58
+ await agent.withMeta(meta).session(sessionId).command({
59
+ type: "operator",
60
+ requestId: input.requestId,
61
+ author: input.author,
62
+ text: input.text,
63
+ });
64
+ },
65
+ errorKind: (error, requestId) => error instanceof ShiitakeError &&
66
+ error.boundary === "contract" &&
67
+ error.cause === "closed" &&
68
+ error.requestId === requestId
69
+ ? "closed_request"
70
+ : null,
71
+ };
72
+ }
@@ -1,8 +1,10 @@
1
1
  import { Hono } from "hono";
2
+ import { endTime, startTime } from "hono/timing";
2
3
  import { HTTPException } from "hono/http-exception";
3
4
  import { z } from "zod";
4
5
  import { computerCreateRequestSchema, computerPageSchema, } from "@fungi.computer/computer/fleet";
5
6
  import { oysterPrincipalIsOwner, } from "../host.js";
7
+ import { computerTargetParam } from "../computer-target.js";
6
8
  /** Fleet controls never accept an actor from the request body. */
7
9
  const fleetComputerId = z.string().min(1).max(256);
8
10
  export const standaloneFleetRequestSchema = z.discriminatedUnion("operation", [
@@ -48,7 +50,7 @@ function fleetResponse(result) {
48
50
  export async function standaloneComputerSelection(registry, incoming) {
49
51
  if (incoming === undefined)
50
52
  return registry.selectedComputer();
51
- const requested = new URL(incoming.request.url).searchParams.getAll("computerId");
53
+ const requested = new URL(incoming.request.url).searchParams.getAll(computerTargetParam);
52
54
  if (requested.length === 0)
53
55
  return registry.selectedComputer();
54
56
  const admission = await incoming.auth.authenticate(incoming.request);
@@ -91,16 +93,20 @@ export function standaloneComputerFleetRoutes(registryFor) {
91
93
  if (!oysterPrincipalIsOwner(principal))
92
94
  return context.json({ error: "Owner only" }, 403);
93
95
  const registry = registryFor();
96
+ startTime(context, "fleet");
94
97
  const admitted = await registry.computerFleet(principal.actor.memberId, {
95
98
  operation: "list",
96
99
  page: { limit: 1, offset: 0 },
97
100
  });
101
+ endTime(context, "fleet");
98
102
  if (admitted === null || "error" in admitted)
99
103
  return fleetResponse(admitted);
104
+ startTime(context, "registry");
100
105
  const [directory, computerId] = await Promise.all([
101
106
  registry.directory(),
102
107
  registry.selectedComputer(),
103
108
  ]);
109
+ endTime(context, "registry");
104
110
  return context.json({
105
111
  ...directory,
106
112
  computer: { computerId, name: computerId },
@@ -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
  }
@@ -1,5 +1,6 @@
1
1
  import { TenancyRegistryDO } from "../tenancy/cloudflare.js";
2
2
  import { type Identity, type Tenant, type ReadonlyTenancy } from "../tenancy/index.js";
3
+ import { type OysterKeySessionResolver } from "../auth.js";
3
4
  import type { OysterGitWorkerEnv } from "../workers-git.js";
4
5
  import { type OysterTenancy, type TenantId } from "../tenant.js";
5
6
  /** Trusted standalone provisioning inputs; credentials never enter the seed. */
@@ -28,6 +29,8 @@ export declare class StandaloneTenants extends TenancyRegistryDO {
28
29
  /** The same trusted bindings are used by HTTP, MCP and cold root readers. */
29
30
  export type StandaloneTenancyEnv = Pick<OysterGitWorkerEnv, "OYSTER_ORIGIN" | "OYSTER_OWNER_NAME" | "OYSTER_OWNER_KEY" | "KEYS"> & Readonly<{
30
31
  TENANTS: DurableObjectNamespace<StandaloneTenants>;
32
+ /** Optional host-owned named-key browser sessions; Oyster mints none. */
33
+ OYSTER_KEY_SESSIONS?: OysterKeySessionResolver;
31
34
  }>;
32
35
  /** A live registry-owned owner; the configured label is never its member ID. */
33
36
  export type StandaloneOwner = Readonly<{
@@ -2,7 +2,7 @@ import { TenancyRegistryDO, admittedRegistry, durableObjectRegistry, } from "../
2
2
  import { createTenancy, } from "../tenancy/index.js";
3
3
  import { ownerKey } from "../tenancy/owner-key.js";
4
4
  import { hosts } from "../tenancy/resolve.js";
5
- import { ownerOrKeyAuth } from "../auth.js";
5
+ import { keySessionAuth, ownerOrKeyAuth, } from "../auth.js";
6
6
  import { HTTPException } from "hono/http-exception";
7
7
  import { defaultTenant, tenantScope, } from "../tenant.js";
8
8
  import * as Schema from "effect/Schema";
@@ -173,7 +173,12 @@ async function composeTenancy(env) {
173
173
  async authenticate(request) {
174
174
  if (owner === null)
175
175
  return { kind: "refused", status: 403 };
176
- const admission = await ownerOrKeyAuth(env.OYSTER_OWNER_KEY, owner.actor.memberId, () => env.KEYS.getByName(scope.durableName("keys"))).authenticate(request);
176
+ const keys = () => env.KEYS.getByName(scope.durableName("keys"));
177
+ const namedKeyAuth = ownerOrKeyAuth(env.OYSTER_OWNER_KEY, owner.actor.memberId, keys);
178
+ const auth = env.OYSTER_KEY_SESSIONS === undefined
179
+ ? namedKeyAuth
180
+ : keySessionAuth(namedKeyAuth, owner.actor.memberId, keys, env.OYSTER_KEY_SESSIONS);
181
+ const admission = await auth.authenticate(request);
177
182
  // This entry admits only named keys; the owner key has its own entry.
178
183
  return admission.kind === "admitted" &&
179
184
  admission.principal.actor.kind === "team-key"
@@ -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) };
@@ -0,0 +1,27 @@
1
+ /** Host-owned configuration inputs, decoded before any challenge is opened. */
2
+ export type TurnstileEnvironment = Readonly<{
3
+ TURNSTILE?: string;
4
+ TURNSTILE_SITE_KEY?: string;
5
+ TURNSTILE_SECRET_KEY?: string;
6
+ }>;
7
+ /** Explicit opt-out, complete verification credentials, or a closed door. */
8
+ export type TurnstileConfiguration = Readonly<{
9
+ kind: "disabled";
10
+ }> | Readonly<{
11
+ kind: "enabled";
12
+ siteKey: string;
13
+ secretKey: string;
14
+ }> | Readonly<{
15
+ kind: "misconfigured";
16
+ }>;
17
+ /** Retain the development default; HTTPS hosts without keys fail closed. */
18
+ export declare function readTurnstileConfiguration(environment: TurnstileEnvironment, origin: string | undefined): TurnstileConfiguration;
19
+ /** Verify with Cloudflare; timeout policy and safe failure reporting belong to the host. */
20
+ export declare function verifyTurnstileToken(input: {
21
+ secretKey: string;
22
+ token: string;
23
+ remoteIp?: string;
24
+ }, options: {
25
+ timeoutMs: number;
26
+ onError(cause: unknown): void;
27
+ }): Promise<"passed" | "failed" | "unavailable">;
@@ -0,0 +1,51 @@
1
+ import { z } from "zod";
2
+ const siteverifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
3
+ const environmentSchema = z.object({
4
+ TURNSTILE: z.literal("off").optional(),
5
+ TURNSTILE_SITE_KEY: z.string().trim().min(1).optional(),
6
+ TURNSTILE_SECRET_KEY: z.string().trim().min(1).optional(),
7
+ });
8
+ const responseSchema = z.object({ success: z.boolean() });
9
+ /** Retain the development default; HTTPS hosts without keys fail closed. */
10
+ export function readTurnstileConfiguration(environment, origin) {
11
+ const parsed = environmentSchema.safeParse(environment);
12
+ if (!parsed.success)
13
+ return { kind: "misconfigured" };
14
+ if (parsed.data.TURNSTILE === "off")
15
+ return { kind: "disabled" };
16
+ const { TURNSTILE_SITE_KEY: siteKey, TURNSTILE_SECRET_KEY: secretKey } = parsed.data;
17
+ if (siteKey === undefined && secretKey === undefined)
18
+ return URL.parse(origin ?? "")?.protocol === "https:"
19
+ ? { kind: "misconfigured" }
20
+ : { kind: "disabled" };
21
+ if (siteKey === undefined || secretKey === undefined)
22
+ return { kind: "misconfigured" };
23
+ return { kind: "enabled", siteKey, secretKey };
24
+ }
25
+ /** Verify with Cloudflare; timeout policy and safe failure reporting belong to the host. */
26
+ export async function verifyTurnstileToken(input, options) {
27
+ try {
28
+ const body = {
29
+ secret: input.secretKey,
30
+ response: input.token,
31
+ };
32
+ if (input.remoteIp !== undefined)
33
+ body.remoteip = input.remoteIp;
34
+ const response = await fetch(siteverifyUrl, {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/json" },
37
+ body: JSON.stringify(body),
38
+ signal: AbortSignal.timeout(options.timeoutMs),
39
+ });
40
+ if (!response.ok)
41
+ return "unavailable";
42
+ const verdict = responseSchema.safeParse(await response.json());
43
+ if (!verdict.success)
44
+ return "unavailable";
45
+ return verdict.data.success ? "passed" : "failed";
46
+ }
47
+ catch (cause) {
48
+ options.onError(cause);
49
+ return "unavailable";
50
+ }
51
+ }
@@ -889,6 +889,7 @@ export function createOysterGitWorker(root) {
889
889
  hosts = new Map();
890
890
  environments.set(env, hosts);
891
891
  }
892
+ const started = Date.now();
892
893
  let resolved;
893
894
  try {
894
895
  resolved = await root.admit(env, request);
@@ -907,6 +908,7 @@ export function createOysterGitWorker(root) {
907
908
  const boot = await serveOysterBootCapability(request, env, scope);
908
909
  if (boot !== null)
909
910
  return boot;
911
+ const admitted = Date.now();
910
912
  let host;
911
913
  try {
912
914
  host = await root.hostFor(env, scope, request);
@@ -916,6 +918,7 @@ export function createOysterGitWorker(root) {
916
918
  return error.getResponse();
917
919
  throw error;
918
920
  }
921
+ const hosted = Date.now();
919
922
  const key = `${scope.tenantId}:${scope.origin}:${host.computerId ?? ""}`;
920
923
  let entry = hosts.get(key);
921
924
  let cache = true;
@@ -938,7 +941,10 @@ export function createOysterGitWorker(root) {
938
941
  }
939
942
  entry.active += 1;
940
943
  try {
941
- return await entry.app.fetch(request, env, ctx);
944
+ return withStageTimings(await entry.app.fetch(request, env, ctx), {
945
+ admit: admitted - started,
946
+ host: hosted - admitted,
947
+ });
942
948
  }
943
949
  finally {
944
950
  entry.active -= 1;
@@ -946,6 +952,19 @@ export function createOysterGitWorker(root) {
946
952
  },
947
953
  };
948
954
  }
955
+ /**
956
+ * Add the root's own stages (tenant admission, host selection) to the App
957
+ * server's Server-Timing, so a slow request names the hop it waited on. An
958
+ * upgraded socket keeps its response untouched.
959
+ */
960
+ function withStageTimings(response, stages) {
961
+ if (response.status === 101)
962
+ return response;
963
+ const timed = new Response(response.body, response);
964
+ for (const [name, duration] of Object.entries(stages))
965
+ timed.headers.append("Server-Timing", `${name};dur=${String(duration)}`);
966
+ return timed;
967
+ }
949
968
  /**
950
969
  * `host.git` for an Oyster Worker: the git door's writer projection and the
951
970
  * admitted, installation-scoped Artifacts path with the App's push policy and
@@ -1117,7 +1136,7 @@ export async function oysterGitWorkerHost(env, options, selected, request) {
1117
1136
  const base = oysterWorkerHost(env, auth, policyOptions);
1118
1137
  const capabilities = [
1119
1138
  ...(base.capabilities ?? []),
1120
- ...(agents === undefined ? [] : agentCapabilities(agents)),
1139
+ ...(agents === undefined ? [] : agentCapabilities(agents, keysLive)),
1121
1140
  ...(options.capabilities?.(env, workspace, keysLive) ?? []),
1122
1141
  ];
1123
1142
  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;