@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
@@ -2,24 +2,31 @@ import { emptyAppDeclaration } from "@fungi.computer/puffball/app";
2
2
  import { deriveDeclaredOperations } from "../apps/operations.js";
3
3
  import { keyAppAccess } from "../keys.js";
4
4
  import { defaultAssetPolicy, verifyRelease, } from "../release.js";
5
+ /** Verified Releases one cache keeps; the least recently added leaves first. */
6
+ const releaseCacheLimit = 256;
5
7
  /**
6
- * Verify each Release once per process: a failed read is not cached, so the
7
- * next request reads it again. Installation verified every asset; serving
8
- * verifies each on read.
8
+ * Verify each Release once per owner (an isolate's HTTP host, or the
9
+ * Registry): a failed read is not cached, so the next request reads it again.
10
+ * Installation verified every asset; serving verifies each on read. A Release
11
+ * is content-addressed and immutable, so the key is its ID and root digest and
12
+ * a hit never goes stale; the bound only forgets the oldest-verified Releases.
9
13
  */
10
- export function releaseCache(artifacts) {
14
+ export function releaseCache(artifacts, limit = releaseCacheLimit) {
11
15
  const verified = new Map();
12
16
  return (release) => {
13
17
  const key = `${release.releaseId}:${release.artifact.artifactDigest}`;
14
18
  let entry = verified.get(key);
15
19
  if (entry === undefined) {
16
- // A failed read is not cached: the next request reads the Release again.
17
- // Installation verified every asset; serving verifies each on read.
18
- entry = verifyRelease(artifacts, release, defaultAssetPolicy, false).then((value) => value, (cause) => {
20
+ entry = verifyRelease(artifacts, release, defaultAssetPolicy, false).catch((cause) => {
19
21
  verified.delete(key);
20
22
  throw cause;
21
23
  });
22
24
  verified.set(key, entry);
25
+ for (const oldest of verified.keys()) {
26
+ if (verified.size <= limit)
27
+ break;
28
+ verified.delete(oldest);
29
+ }
23
30
  }
24
31
  return entry;
25
32
  };
@@ -193,7 +193,7 @@ export declare function assertBackgroundBinding(frame: InvocationFrame<Backgroun
193
193
  export declare function admitBackgroundFrame(key: CryptoKey, frame: InvocationFrame<BackgroundFrameState>, raw: unknown): Promise<ClaimedJobAdmission>;
194
194
  /** The host's native guest-abort and authority-close hooks. */
195
195
  export type InvocationGatePorts<Host> = Readonly<{
196
- /** Stop the guest when the captured invocation's authority closes. */
196
+ /** Stop the guest when live authority closes early (revocation, expiry). */
197
197
  abortGuest: () => Promise<void>;
198
198
  /** Release the host resources belonging to this completed or revoked frame. */
199
199
  close: (frame: InvocationFrame<Host>) => Promise<void>;
@@ -204,8 +204,8 @@ export type InvocationGatePorts<Host> = Readonly<{
204
204
  export type InvocationGate<Host> = Readonly<{
205
205
  /** Run work within one immutable frame and close its authority afterward. */
206
206
  run: <A>(facts: InvocationFacts, host: Host, work: (frame: InvocationFrame<Host>) => Promise<A>) => Promise<A>;
207
- /** Resolve the sole admitted frame, refusing outside an active invocation. */
208
- active: () => InvocationFrame<Host>;
207
+ /** Resolve one live frame by its id, refusing a closed or unknown frame. */
208
+ active: (frameId: string) => InvocationFrame<Host>;
209
209
  /** Close live authority, optionally restricted to frames of a selected run. */
210
210
  revoke: (reason?: Error, target?: Readonly<{
211
211
  runId: string;
@@ -215,7 +215,14 @@ export type InvocationGate<Host> = Readonly<{
215
215
  }>;
216
216
  /** Decode only identities minted from the invocation-frame ID family. */
217
217
  export declare const invocationFrameIdSchema: Schema.String;
218
- /** Serialize authority, preempt replay only for verified writes, and close each guest before admission. */
218
+ /**
219
+ * Admit frames against one guest. Verified foreground reads share it, up to
220
+ * eight at once; writes, raw backend requests, Gills and replay run alone. Every guest binding packet names its frame, so a finished
221
+ * frame's leftover timers cannot borrow a later frame's authority and a
222
+ * normal finish keeps the guest warm. Closing authority early (revocation,
223
+ * expiry, a write preempting replay) aborts the guest and so every frame
224
+ * sharing it; replay only yields to a verified write.
225
+ */
219
226
  export declare function createInvocationGate<Host>(ports: InvocationGatePorts<Host>): InvocationGate<Host>;
220
227
  /** Verified selection, artifact and binding grants before caller attribution. */
221
228
  export type InvocationAuthority = Omit<InvocationFacts, "subject" | "deadlineAt">;
@@ -1,8 +1,6 @@
1
1
  import { approvedBindingGrants } from "./apps/gate.js";
2
2
  import { appToolPolicySchema } from "./apps/tools.js";
3
- import * as Effect from "effect/Effect";
4
3
  import * as Schema from "effect/Schema";
5
- import * as Semaphore from "effect/Semaphore";
6
4
  import { defineIdFamily } from "@fungi.computer/cairn/id";
7
5
  import { sha256DigestSchema } from "@fungi.computer/puffball/effect";
8
6
  import { appBackendBindingKinds, appBackendBindingsSchema, appMethodCallContextSchema, } from "@fungi.computer/puffball/app";
@@ -116,6 +114,8 @@ export async function admitBackgroundFrame(key, frame, raw) {
116
114
  frame.host.origin = decision.kind;
117
115
  return decision;
118
116
  }
117
+ /** Foreground reads one Instance runs at once; every other frame runs alone. */
118
+ const concurrentReadFrames = 8;
119
119
  const frameIds = defineIdFamily({
120
120
  family: "app-invocation-frame",
121
121
  version: 1,
@@ -152,28 +152,79 @@ function immutableFacts(facts) {
152
152
  Object.freeze(facts.ancestry);
153
153
  return Object.freeze(facts);
154
154
  }
155
- /** Serialize authority, preempt replay only for verified writes, and close each guest before admission. */
155
+ /**
156
+ * First-come permits over one guest: a waiting exclusive frame holds back
157
+ * every frame queued after it, so a stream of reads never starves a write.
158
+ */
159
+ function orderedPermits(total) {
160
+ let available = total;
161
+ const waiting = [];
162
+ const admit = () => {
163
+ for (let next = waiting[0]; next !== undefined && next.permits <= available; next = waiting[0]) {
164
+ waiting.shift();
165
+ available -= next.permits;
166
+ next.enter();
167
+ }
168
+ };
169
+ return async (permits, work) => {
170
+ await new Promise((enter) => {
171
+ waiting.push({ permits, enter });
172
+ admit();
173
+ });
174
+ try {
175
+ return await work();
176
+ }
177
+ catch (cause) {
178
+ throw cause instanceof Error
179
+ ? cause
180
+ : new Error("App invocation failed", { cause });
181
+ }
182
+ finally {
183
+ available += permits;
184
+ admit();
185
+ }
186
+ };
187
+ }
188
+ /** A foreground call to a verified read method; only these share the guest. */
189
+ function concurrentRead(facts) {
190
+ return (facts.subject.kind === "call" &&
191
+ facts.subject.replay === undefined &&
192
+ facts.subject.method?.effect === "read");
193
+ }
194
+ /**
195
+ * Admit frames against one guest. Verified foreground reads share it, up to
196
+ * eight at once; writes, raw backend requests, Gills and replay run alone. Every guest binding packet names its frame, so a finished
197
+ * frame's leftover timers cannot borrow a later frame's authority and a
198
+ * normal finish keeps the guest warm. Closing authority early (revocation,
199
+ * expiry, a write preempting replay) aborts the guest and so every frame
200
+ * sharing it; replay only yields to a verified write.
201
+ */
156
202
  export function createInvocationGate(ports) {
157
- const permit = Semaphore.makeUnsafe(1);
203
+ const withPermits = orderedPermits(concurrentReadFrames);
158
204
  const now = ports.now ?? Date.now;
159
205
  let foregroundWaiting = 0;
160
- let active;
161
- const close = (state, reason) => {
206
+ const live = new Map();
207
+ const finish = (state, reason) => {
162
208
  if (state.closing)
163
209
  return state.closing;
164
- if (active === state)
165
- active = undefined;
210
+ live.delete(state.frame.frameId);
166
211
  state.controller.abort(reason);
167
- state.closing = (async () => {
168
- try {
169
- await ports.abortGuest();
170
- }
171
- finally {
172
- await ports.close(state.frame);
173
- }
174
- })();
212
+ state.closing = ports.close(state.frame);
175
213
  return state.closing;
176
214
  };
215
+ // The guest is shared: aborting it ends every live frame's guest work, and
216
+ // each of those frames closes only after the guest abort has completed.
217
+ const abortAll = (reason) => {
218
+ const states = [...live.values()];
219
+ for (const state of states) {
220
+ live.delete(state.frame.frameId);
221
+ state.controller.abort(reason);
222
+ }
223
+ const guest = ports.abortGuest();
224
+ for (const state of states)
225
+ state.closing ??= guest.finally(() => ports.close(state.frame));
226
+ return Promise.all([guest, ...states.map((state) => state.closing)]).then(() => undefined);
227
+ };
177
228
  const run = async (input, host, work) => {
178
229
  let facts;
179
230
  try {
@@ -196,64 +247,63 @@ export function createInvocationGate(ports) {
196
247
  if (queuedForeground &&
197
248
  facts.subject.kind === "call" &&
198
249
  facts.subject.method?.effect === "write" &&
199
- active?.frame.facts.subject.kind === "call" &&
200
- active.frame.facts.subject.replay !== undefined)
201
- await close(active, new AppInstanceError("revoked"));
202
- return await Effect.runPromise(permit.withPermits(1)(Effect.tryPromise({
203
- try: async () => {
204
- if (facts.subject.kind === "call" &&
205
- facts.subject.replay !== undefined &&
206
- foregroundWaiting > 0)
207
- throw new AppInstanceError("revoked");
208
- if (queuedForeground) {
209
- foregroundWaiting -= 1;
210
- queuedForeground = false;
211
- }
212
- const { deadlineAt } = facts;
213
- if (deadlineAt <= now())
214
- throw new AppInstanceError("expired");
215
- const frameId = frameIds.frame.generate();
216
- const controller = new AbortController();
217
- const frame = Object.freeze({
218
- frameId,
219
- facts,
220
- host,
221
- signal: controller.signal,
250
+ [...live.values()].some(({ frame }) => frame.facts.subject.kind === "call" &&
251
+ frame.facts.subject.replay !== undefined))
252
+ await abortAll(new AppInstanceError("revoked"));
253
+ return await withPermits(concurrentRead(facts) ? 1 : concurrentReadFrames, async () => {
254
+ if (facts.subject.kind === "call" &&
255
+ facts.subject.replay !== undefined &&
256
+ foregroundWaiting > 0)
257
+ throw new AppInstanceError("revoked");
258
+ if (queuedForeground) {
259
+ foregroundWaiting -= 1;
260
+ queuedForeground = false;
261
+ }
262
+ const { deadlineAt } = facts;
263
+ if (deadlineAt <= now())
264
+ throw new AppInstanceError("expired");
265
+ const frameId = frameIds.frame.generate();
266
+ const controller = new AbortController();
267
+ const frame = Object.freeze({
268
+ frameId,
269
+ facts,
270
+ host,
271
+ signal: controller.signal,
272
+ });
273
+ const state = { frame, controller };
274
+ live.set(frameId, state);
275
+ let timer;
276
+ let stop = () => undefined;
277
+ const revoked = new Promise((_resolve, reject) => {
278
+ const onAbort = () => {
279
+ const reason = controller.signal.reason;
280
+ reject(reason instanceof Error
281
+ ? reason
282
+ : new AppInstanceError("revoked"));
283
+ };
284
+ controller.signal.addEventListener("abort", onAbort, {
285
+ once: true,
222
286
  });
223
- const state = { frame, controller };
224
- active = state;
225
- let timer;
226
- let stop = () => undefined;
227
- const revoked = new Promise((_resolve, reject) => {
228
- const onAbort = () => {
229
- const reason = controller.signal.reason;
230
- reject(reason instanceof Error
231
- ? reason
232
- : new AppInstanceError("revoked"));
233
- };
234
- controller.signal.addEventListener("abort", onAbort, {
235
- once: true,
236
- });
237
- stop = () => {
238
- controller.signal.removeEventListener("abort", onAbort);
239
- };
240
- timer = setTimeout(() => {
241
- controller.abort(new AppInstanceError("expired"));
242
- }, Math.max(0, deadlineAt - now()));
243
- });
244
- try {
245
- return await Promise.race([work(frame), revoked]);
246
- }
247
- finally {
248
- clearTimeout(timer);
249
- stop();
250
- await close(state, new AppInstanceError("revoked"));
251
- }
252
- },
253
- catch: (cause) => cause instanceof Error
254
- ? cause
255
- : new Error("App invocation failed", { cause }),
256
- })));
287
+ stop = () => {
288
+ controller.signal.removeEventListener("abort", onAbort);
289
+ };
290
+ timer = setTimeout(() => {
291
+ // An expired frame's guest may still be running. Each frame's own
292
+ // run awaits its close, so a failure reaches that caller.
293
+ void Promise.allSettled([
294
+ abortAll(new AppInstanceError("expired")),
295
+ ]);
296
+ }, Math.max(0, deadlineAt - now()));
297
+ });
298
+ try {
299
+ return await Promise.race([work(frame), revoked]);
300
+ }
301
+ finally {
302
+ clearTimeout(timer);
303
+ stop();
304
+ await finish(state, new AppInstanceError("revoked"));
305
+ }
306
+ });
257
307
  }
258
308
  finally {
259
309
  if (queuedForeground)
@@ -262,27 +312,20 @@ export function createInvocationGate(ports) {
262
312
  };
263
313
  return {
264
314
  run,
265
- exclusive: (work) => Effect.runPromise(permit.withPermits(1)(Effect.tryPromise({
266
- try: work,
267
- catch: (cause) => cause instanceof Error
268
- ? cause
269
- : new Error("Instance supervisor operation failed", { cause }),
270
- }))),
271
- active: () => {
272
- if (active === undefined ||
273
- active.controller.signal.aborted ||
274
- active.frame.facts.deadlineAt <= now())
315
+ exclusive: (work) => withPermits(concurrentReadFrames, work),
316
+ active: (frameId) => {
317
+ const state = live.get(frameId);
318
+ if (state === undefined ||
319
+ state.controller.signal.aborted ||
320
+ state.frame.facts.deadlineAt <= now())
275
321
  throw new AppInstanceError("revoked");
276
- return active.frame;
322
+ return state.frame;
277
323
  },
278
324
  revoke: (reason = new AppInstanceError("revoked"), target) => {
279
- if (active === undefined)
280
- return Promise.resolve();
281
- if (target !== undefined &&
282
- (active.frame.facts.subject.kind !== "call" ||
283
- active.frame.facts.subject.context.workflow?.runId !== target.runId))
284
- return Promise.resolve();
285
- return close(active, reason);
325
+ const matched = [...live.values()].some(({ frame }) => target === undefined ||
326
+ (frame.facts.subject.kind === "call" &&
327
+ frame.facts.subject.context.workflow?.runId === target.runId));
328
+ return matched ? abortAll(reason) : Promise.resolve();
286
329
  },
287
330
  };
288
331
  }
package/dist/keys.d.ts CHANGED
@@ -2,12 +2,13 @@ import type { Grant, GrantVocabulary, Scope } from "@fungi.computer/ear-tag/gran
2
2
  import type { KeyPrincipal, KeyRecord, KeyStore } from "@fungi.computer/ear-tag/keys";
3
3
  import type { AppToolPolicy } from "./apps/tools.js";
4
4
  /** Every capability an Oyster key may carry. */
5
- export type OysterKeyCapability = "apps.read" | "apps.use" | "computer.use" | "machine.enroll";
5
+ export type OysterKeyCapability = "apps.read" | "apps.use" | "agents.use" | "computer.use" | "machine.enroll";
6
6
  /**
7
7
  * `apps.use` also reaches `apps.read` (one level, per `defineGrants`'s own
8
8
  * `implies`): a key that may change an App's data may also read it.
9
9
  * `computer.use` has no narrower capability under it; the Computer is on or
10
10
  * off, host-wide.
11
+ * `agents.use` selects Agent ids independently of App grants.
11
12
  * `machine.enroll` admits enrollment only; it implies no App or Computer access.
12
13
  * The enrollment owner, not this vocabulary, owns one-use consumption.
13
14
  */
@@ -46,6 +47,8 @@ export type LiveOysterKey = Readonly<{
46
47
  * binding name for MCP) to reach the full `AppToolAccess`.
47
48
  */
48
49
  export declare function keyAppAccess(key: LiveOysterKey, installationId: string): AppToolPolicy | null;
50
+ /** Agent access is scoped to the granted Agent ids, never inferred from App grants. */
51
+ export declare function keyAgentAccess(key: LiveOysterKey, agentId: string): boolean;
49
52
  /** Whether a live key's own grants turn its Computer access on. Off by default. */
50
53
  export declare function keyAllowsComputerUse(key: LiveOysterKey): boolean;
51
54
  /** Whether a live key's grants permit a one-use device enrollment. */
package/dist/keys.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Oyster's own Ear Tag vocabulary: named keys an owner issues for its
3
- * installed Apps, its Computer or machine enrollment. A key's id is its name, so
4
- * forks and other name-derived identities stay stable across the cutover.
2
+ * Oyster's own Ear Tag vocabulary: named keys an owner issues for selected
3
+ * Apps and Agents, its Computer or machine enrollment. App grants never imply
4
+ * Agent access. A key's id is its name, so name-derived identities stay stable.
5
5
  */
6
6
  import { defineGrants } from "@fungi.computer/ear-tag/grants";
7
7
  /**
@@ -9,11 +9,18 @@ import { defineGrants } from "@fungi.computer/ear-tag/grants";
9
9
  * `implies`): a key that may change an App's data may also read it.
10
10
  * `computer.use` has no narrower capability under it; the Computer is on or
11
11
  * off, host-wide.
12
+ * `agents.use` selects Agent ids independently of App grants.
12
13
  * `machine.enroll` admits enrollment only; it implies no App or Computer access.
13
14
  * The enrollment owner, not this vocabulary, owns one-use consumption.
14
15
  */
15
16
  export const oysterKeyVocabulary = defineGrants({
16
- capabilities: ["apps.read", "apps.use", "computer.use", "machine.enroll"],
17
+ capabilities: [
18
+ "apps.read",
19
+ "apps.use",
20
+ "agents.use",
21
+ "computer.use",
22
+ "machine.enroll",
23
+ ],
17
24
  implies: { "apps.use": ["apps.read"] },
18
25
  });
19
26
  /** A grant reaching every current and future installation for `capability`. */
@@ -48,6 +55,10 @@ export function keyAppAccess(key, installationId) {
48
55
  grantedBy: key.owner,
49
56
  };
50
57
  }
58
+ /** Agent access is scoped to the granted Agent ids, never inferred from App grants. */
59
+ export function keyAgentAccess(key, agentId) {
60
+ return oysterKeyVocabulary.grantsAllow(key.grants, "agents.use", agentId);
61
+ }
51
62
  /** Whether a live key's own grants turn its Computer access on. Off by default. */
52
63
  export function keyAllowsComputerUse(key) {
53
64
  return oysterKeyVocabulary.grantsAllow(key.grants, "computer.use");
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import type { PostDeliveryResult, PostEnvelope } from "@fungi.computer/carrier/post";
3
3
  import type { MachineAgentHome } from "./home.js";
4
+ import type { MachineLinkDispositionMail } from "./disposition.js";
4
5
  export declare const machineLinkIdentitySchema: z.ZodObject<{
5
6
  teamId: z.ZodString;
6
7
  deviceId: z.ZodString;
@@ -73,10 +74,16 @@ export type MachineLinkAuthority = Readonly<{
73
74
  agentId: string;
74
75
  name: string;
75
76
  }[]>;
77
+ /** Current home of one Team Agent, including machine-hosted homes. */
78
+ agentHome(id: MachineLinkIdentity, agentId: string): Promise<MachineAgentHome | null>;
76
79
  /** Sender, actor and the target's actual home, rechecked for this act. */
77
80
  resolveTarget(id: MachineLinkIdentity, caller: MachineLinkCaller, agentId: string, requesterAgentId: string | null): Promise<MachineLinkTargetFacts>;
78
81
  /** Carry mail received from this device to its target's actual home. */
79
82
  deliverHome(id: MachineLinkIdentity, caller: MachineLinkCaller, target: MachineLinkTargetFacts, envelope: PostEnvelope): Promise<PostDeliveryResult>;
83
+ /** Deliver a typed expiry reply authenticated by this device's held row. */
84
+ deliverUndeliverable(id: MachineLinkIdentity, original: MachineLinkDispositionMail, reply: MachineLinkDispositionMail): Promise<PostDeliveryResult>;
85
+ /** Ask the sending link to attest that it durably staged this disposition. */
86
+ verifyRelayDisposition(relay: MachineLinkIdentity, original: PostEnvelope, reply: PostEnvelope): Promise<boolean>;
80
87
  /** Whether this link's replay ledger may be erased now. */
81
88
  canErase(id: MachineLinkIdentity): Promise<boolean>;
82
89
  /** Push one committed fence fact to the device's link (C2 promptness). */
@@ -1,6 +1,8 @@
1
1
  import * as Schema from "effect/Schema";
2
2
  import { type PostEnvelope, type PostDeliveryResult, type PostRoute } from "@fungi.computer/carrier/post";
3
3
  import { type MachineAgentHome } from "./home.js";
4
+ import type { MachineLinkIdentity } from "./authority.js";
5
+ import type { MachineLinkDispositionMail } from "./disposition.js";
4
6
  /**
5
7
  * Home selection only, as the namespace under the host's disclosure-owning
6
8
  * Agent mail route. Carrier retains retries; the receiving Agent admits work.
@@ -16,3 +18,13 @@ export declare function createHomeAwareAgentMailNamespace(options: {
16
18
  /** Host's Agent grammar: validates Accepted or Held without conflating them. */
17
19
  accepted: (receipt: Schema.Json) => void;
18
20
  }): PostRoute<never, never>["namespace"];
21
+ /** Route one checked expiry reply through the same home-aware mail owner. */
22
+ export declare function deliverUndeliverableMail(options: {
23
+ identity: MachineLinkIdentity;
24
+ original: MachineLinkDispositionMail;
25
+ reply: MachineLinkDispositionMail;
26
+ senderHome: MachineAgentHome;
27
+ hosted: PostRoute<never, never>["namespace"];
28
+ machine: (deviceId: string, envelope: PostEnvelope) => Promise<PostDeliveryResult>;
29
+ accepted: (receipt: Schema.Json) => void;
30
+ }): Promise<PostDeliveryResult>;
@@ -23,12 +23,17 @@ export function createHomeAwareAgentMailNamespace(options) {
23
23
  const mail = Schema.decodeUnknownSync(BirdDogMailSchema, {
24
24
  onExcessProperty: "error",
25
25
  })(envelope.event);
26
+ const senderMatches = mail.from.kind === "agent" &&
27
+ mail.from.agentId === options.sender.agentId;
28
+ const systemOfflineDisposition = mail.from.kind === "system" &&
29
+ mail.reply?.outcome === "undeliverable" &&
30
+ mail.reply.reason === "recipient_offline";
26
31
  if (envelope.kind !== "agentMail" ||
27
32
  envelope.key !== mail.key ||
28
33
  address.scopeId !== options.sender.teamId ||
29
34
  address.agentId !== agentId ||
30
35
  mail.to.agentId !== agentId ||
31
- mail.from.agentId !== options.sender.agentId)
36
+ (!senderMatches && !systemOfflineDisposition))
32
37
  return "refused";
33
38
  const projected = await options.home(agentId);
34
39
  if (projected === null)
@@ -52,3 +57,19 @@ export function createHomeAwareAgentMailNamespace(options) {
52
57
  }),
53
58
  };
54
59
  }
60
+ /** Route one checked expiry reply through the same home-aware mail owner. */
61
+ export function deliverUndeliverableMail(options) {
62
+ const sent = options.original.event;
63
+ if (sent.from.kind !== "agent")
64
+ return Promise.resolve("refused");
65
+ const senderAgentId = sent.from.agentId;
66
+ return createHomeAwareAgentMailNamespace({
67
+ sender: { teamId: options.identity.teamId, agentId: sent.to.agentId },
68
+ home: (agentId) => Promise.resolve(agentId === senderAgentId ? options.senderHome : null),
69
+ hosted: options.hosted,
70
+ machine: options.machine,
71
+ accepted: options.accepted,
72
+ })
73
+ .getByName(senderAgentId)
74
+ .deliver(options.reply);
75
+ }
@@ -0,0 +1,16 @@
1
+ import * as Schema from "effect/Schema";
2
+ import type { BirdDogMail } from "@fungi.computer/bird-dog";
3
+ import { agentMailAddressSchema } from "@fungi.computer/seam/machine-link-protocol";
4
+ import type { PostEnvelope } from "@fungi.computer/carrier/post";
5
+ import type { MachineLinkIdentity } from "./authority.js";
6
+ export type MachineLinkDispositionMail = Readonly<{
7
+ key: string;
8
+ kind: "agentMail";
9
+ address: Schema.Schema.Type<typeof agentMailAddressSchema>;
10
+ event: BirdDogMail;
11
+ }>;
12
+ /** Parse and authenticate the only terminal reply a device relay may author. */
13
+ export declare function machineLinkDisposition(identity: MachineLinkIdentity, rawOriginal: PostEnvelope, rawReply: PostEnvelope): Readonly<{
14
+ original: MachineLinkDispositionMail;
15
+ reply: MachineLinkDispositionMail;
16
+ }> | undefined;
@@ -0,0 +1,65 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { BirdDogMailSchema } from "@fungi.computer/bird-dog/effect";
3
+ import { agentMailAddressSchema } from "@fungi.computer/seam/machine-link-protocol";
4
+ const machineMailEnvelope = Schema.Struct({
5
+ key: Schema.String,
6
+ kind: Schema.Literal("agentMail"),
7
+ address: agentMailAddressSchema,
8
+ event: BirdDogMailSchema,
9
+ });
10
+ function originalMatchesLink(identity, original) {
11
+ const sentFrom = original.event.from;
12
+ if (sentFrom.kind !== "agent")
13
+ return false;
14
+ return (original.key === original.event.key &&
15
+ original.address.scopeId === identity.teamId &&
16
+ original.address.agentId === original.event.to.agentId &&
17
+ original.event.reply === undefined);
18
+ }
19
+ function reverseIdentityMatches(original, reply) {
20
+ const sentFrom = original.event.from;
21
+ if (sentFrom.kind !== "agent")
22
+ return false;
23
+ return (reply.event.from.kind === "system" &&
24
+ reply.event.to.agentId === sentFrom.agentId &&
25
+ reply.event.to.sessionId === sentFrom.sessionId);
26
+ }
27
+ function replyMatchesDisposition(identity, original, reply) {
28
+ const sent = original.event;
29
+ const returned = reply.event;
30
+ if (sent.from.kind !== "agent")
31
+ return false;
32
+ const senderAgentId = sent.from.agentId;
33
+ const expectedKey = `${original.key}:reply:0`;
34
+ return [
35
+ reply.key === expectedKey,
36
+ returned.key === reply.key,
37
+ reply.address.scopeId === identity.teamId,
38
+ reply.address.agentId === senderAgentId,
39
+ returned.replyTo.length === 0,
40
+ returned.reply?.outcome === "undeliverable",
41
+ returned.reply?.outcome === "undeliverable" &&
42
+ returned.reply.reason === "recipient_offline",
43
+ reverseIdentityMatches(original, reply),
44
+ ].every((valid) => valid);
45
+ }
46
+ /** Parse and authenticate the only terminal reply a device relay may author. */
47
+ export function machineLinkDisposition(identity, rawOriginal, rawReply) {
48
+ let original;
49
+ let reply;
50
+ try {
51
+ original = Schema.decodeUnknownSync(machineMailEnvelope, {
52
+ onExcessProperty: "error",
53
+ })(rawOriginal);
54
+ reply = Schema.decodeUnknownSync(machineMailEnvelope, {
55
+ onExcessProperty: "error",
56
+ })(rawReply);
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ if (!originalMatchesLink(identity, original) ||
62
+ !replyMatchesDisposition(identity, original, reply))
63
+ return undefined;
64
+ return { original, reply };
65
+ }
@@ -33,6 +33,8 @@ export declare class MachineLinkHost {
33
33
  authorize: (deviceId: string) => Promise<MachineLinkDevice>;
34
34
  replay: () => Promise<SeamReplayStore>;
35
35
  receive: (device: MachineLinkDevice, envelope: PostEnvelope) => Promise<PostDeliveryResult>;
36
+ /** Deliver one relay-authored reply to its original sender. */
37
+ disposition: (original: PostEnvelope, reply: PostEnvelope) => Promise<PostDeliveryResult>;
36
38
  /** Arm the Durable Object alarm for Carrier/Watchdog progress. */
37
39
  scheduleWake: (at: number) => Promise<void>;
38
40
  /** The host's failure log for refused dials and closed links. */
@@ -44,12 +46,17 @@ export declare class MachineLinkHost {
44
46
  connected(socket: WebSocket, deviceId: string): Promise<void>;
45
47
  private publishDirectory;
46
48
  private channel;
47
- deliver(deviceId: string, envelope: PostEnvelope): Promise<PostDeliveryResult>;
49
+ deliver(deviceId: string, envelope: PostEnvelope, options?: Readonly<{
50
+ refuseDuplicate?: boolean;
51
+ }>): Promise<PostDeliveryResult>;
48
52
  private deliverLiveOperation;
49
53
  private deliverCustodyMail;
50
54
  /** Run due Carrier work; expired rows remain visible as Carrier dead letters. */
51
55
  alarm(): Promise<void>;
52
- /** Expose terminal custody rows so S9 can stage its own disposition reply. */
56
+ /** Turn each terminal incoming-mail row into a durable reply outbox row. */
57
+ private stageUndeliverableReplies;
58
+ private stageUndeliverableReply;
59
+ /** Expose terminal custody rows for the device-owned diagnostic surface. */
53
60
  deadLetters(afterKey?: string): Promise<Readonly<{
54
61
  letters: readonly Readonly<{
55
62
  key: string;
@@ -59,6 +66,8 @@ export declare class MachineLinkHost {
59
66
  }>[];
60
67
  nextCursor: string | undefined;
61
68
  }>>;
69
+ /** Prove a disposition is still present in this link's durable Post outbox. */
70
+ hasStagedDisposition(deviceId: string, original: PostEnvelope, reply: PostEnvelope): Promise<boolean>;
62
71
  private deliverToSocket;
63
72
  private custody;
64
73
  private createCustody;