@fungi.computer/oyster 0.4.1 → 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 (45) hide show
  1. package/dist/agents.d.ts +2 -1
  2. package/dist/agents.js +63 -8
  3. package/dist/apps/gate.d.ts +4 -0
  4. package/dist/apps/gate.js +3 -0
  5. package/dist/boot-host-script.js +1 -1
  6. package/dist/build/candidate-cli.mjs +19 -4
  7. package/dist/channels/binding.d.ts +63 -0
  8. package/dist/channels/binding.js +85 -0
  9. package/dist/channels/conversations.d.ts +235 -0
  10. package/dist/channels/conversations.js +705 -0
  11. package/dist/channels/ids.d.ts +8 -0
  12. package/dist/channels/ids.js +15 -0
  13. package/dist/host.d.ts +1 -1
  14. package/dist/host.js +21 -4
  15. package/dist/keys.d.ts +4 -1
  16. package/dist/keys.js +15 -4
  17. package/dist/machine-link/authority.d.ts +7 -0
  18. package/dist/machine-link/delivery.d.ts +12 -0
  19. package/dist/machine-link/delivery.js +22 -1
  20. package/dist/machine-link/disposition.d.ts +16 -0
  21. package/dist/machine-link/disposition.js +65 -0
  22. package/dist/machine-link/host.d.ts +11 -2
  23. package/dist/machine-link/host.js +125 -5
  24. package/dist/machine-link/link.d.ts +4 -0
  25. package/dist/machine-link/link.js +47 -1
  26. package/dist/page-script.js +4 -4
  27. package/dist/standalone/agent-mail.js +4 -1
  28. package/dist/standalone/agents.d.ts +8 -0
  29. package/dist/standalone/agents.js +21 -2
  30. package/dist/standalone/channel-session-door.d.ts +4 -0
  31. package/dist/standalone/channel-session-door.js +42 -0
  32. package/dist/standalone/machine-link.d.ts +2 -0
  33. package/dist/standalone/machine-link.js +34 -1
  34. package/dist/testing/capabilities.d.ts +2 -0
  35. package/dist/testing/capabilities.js +3 -1
  36. package/dist/workers-git.js +1 -1
  37. package/dist/workers.d.ts +3 -0
  38. package/docs/api/agents.md +8 -4
  39. package/docs/api/apps.md +8 -4
  40. package/docs/api/browser.md +40 -16
  41. package/docs/api/keys.md +25 -3
  42. package/docs/api/workers.md +21 -14
  43. package/docs/site/agent-quickstart.mdx +23 -0
  44. package/examples/standalone/wrangler.jsonc +3 -4
  45. package/package.json +28 -16
@@ -0,0 +1,8 @@
1
+ /** Server- and widget-minted channel identities, shared by both Oyster hosts. */
2
+ export declare const channelIds: Readonly<{
3
+ readonly binding: import("@fungi.computer/cairn").IdCodec<"channel", "binding", 1>;
4
+ readonly site: import("@fungi.computer/cairn").IdCodec<"channel", "site", 1>;
5
+ readonly endpoint: import("@fungi.computer/cairn").IdCodec<"channel", "endpoint", 1>;
6
+ readonly visitor: import("@fungi.computer/cairn").IdCodec<"channel", "visitor", 1>;
7
+ readonly message: import("@fungi.computer/cairn").IdCodec<"channel", "message", 1>;
8
+ }>;
@@ -0,0 +1,15 @@
1
+ import { defineIdFamily } from "@fungi.computer/cairn/id";
2
+ /** Server- and widget-minted channel identities, shared by both Oyster hosts. */
3
+ export const channelIds = defineIdFamily({
4
+ family: "channel",
5
+ version: 1,
6
+ kinds: {
7
+ binding: { prefix: "chb" },
8
+ site: { prefix: "site_pk" },
9
+ /** The public, unguessable address of one incoming-events binding. */
10
+ endpoint: { prefix: "evt_ep" },
11
+ visitor: { prefix: "v" },
12
+ /** One visitor message; the widget mints it so a resend replays. */
13
+ message: { prefix: "msg" },
14
+ },
15
+ });
package/dist/host.d.ts CHANGED
@@ -18,7 +18,7 @@ import * as Schema from "effect/Schema";
18
18
  import { type BackgroundSyncRefusal } from "@fungi.computer/puffball/background-replay";
19
19
  import { type Installation, type InstallationReader } from "./apps/installation.js";
20
20
  import type { AppToolPolicy } from "./apps/tools.js";
21
- import type { LiveOysterKey } from "./keys.js";
21
+ import { type LiveOysterKey } from "./keys.js";
22
22
  import { type AppMethodReply } from "@fungi.computer/agar/backend";
23
23
  import type { AppCapabilityId, HostActor } from "@fungi.computer/puffball/app";
24
24
  import { type CapabilityDescriptor } from "./capability-descriptor.js";
package/dist/host.js CHANGED
@@ -20,6 +20,7 @@ import { emptyAppDeclaration } from "@fungi.computer/puffball/app";
20
20
  import { applyInstallationCommand, installationIdSchema, } from "./apps/installation.js";
21
21
  import { admits, scopedBindings, grantsFor } from "./apps/gate.js";
22
22
  import { appPermissionPageSchema, permissionSaveSchema, } from "./apps/permissions.js";
23
+ import { keyAppAccess } from "./keys.js";
23
24
  import { deriveDeclaredOperations, appCallOperation, } from "./apps/operations.js";
24
25
  import { appBackendActorHeader, appBackendComputerHeader, appMethodRefusalStatus, } from "@fungi.computer/agar/backend";
25
26
  import { appCallsDescriptor, } from "./capability-descriptor.js";
@@ -487,6 +488,10 @@ export function createOyster(host) {
487
488
  kind: "capability-route",
488
489
  capability: capability.permission,
489
490
  operation,
491
+ effect: (context.req.method === "GET" || context.req.method === "HEAD") &&
492
+ context.req.header("Upgrade")?.toLowerCase() !== "websocket"
493
+ ? "read"
494
+ : "write",
490
495
  }).kind === "refused")
491
496
  continue;
492
497
  url.pathname = `/${url.pathname.slice(capability.base.length)}`;
@@ -498,14 +503,23 @@ export function createOyster(host) {
498
503
  return refusal(403, "Capability permission denied");
499
504
  });
500
505
  app.get("/", async (context) => {
501
- const publicHome = context.get("principal").actor.kind === "visitor";
506
+ const actor = context.get("principal").actor;
507
+ const publicHome = actor.kind === "visitor";
508
+ const key = actor.kind === "team-key"
509
+ ? ((await host.keys?.live(actor.keyId)) ?? null)
510
+ : null;
502
511
  if (!publicHome && host.homeInstallationId !== undefined) {
503
512
  const home = await host.installations.get(host.homeInstallationId);
504
- if (home?.status === "enabled")
513
+ if (home?.status === "enabled" &&
514
+ (actor.kind !== "team-key" ||
515
+ (key !== null && keyAppAccess(key, home.installationId) !== null)))
505
516
  return context.redirect(`/apps/${encodeURIComponent(home.installationId)}/`);
506
517
  }
507
518
  const installations = (await host.installations.list()).filter((installation) => installation.status === "enabled" &&
508
- (!publicHome || installation.visibility === "public"));
519
+ (!publicHome || installation.visibility === "public") &&
520
+ (actor.kind !== "team-key" ||
521
+ (key !== null &&
522
+ keyAppAccess(key, installation.installationId) !== null)));
509
523
  if (publicHome) {
510
524
  const apps = [];
511
525
  for (const installation of installations) {
@@ -846,10 +860,11 @@ export function createOyster(host) {
846
860
  const facts = await gateFacts(installation, release, principal);
847
861
  const capabilities = [];
848
862
  for (const capability of host.capabilities ?? []) {
849
- const methods = Object.fromEntries(Object.entries(capability.methods).filter(([name]) => admits(facts, {
863
+ const methods = Object.fromEntries(Object.entries(capability.methods).filter(([name, method]) => admits(facts, {
850
864
  kind: "capability-route",
851
865
  capability: capability.permission,
852
866
  operation: `route:${capability.id}:${name}`,
867
+ effect: method.method === "GET" ? "read" : "write",
853
868
  }).kind === "admitted"));
854
869
  if (Object.keys(methods).length > 0) {
855
870
  const { id, version, base } = capability;
@@ -862,6 +877,7 @@ export function createOyster(host) {
862
877
  kind: "capability-route",
863
878
  capability: "apps.call",
864
879
  operation: appCallOperation(call.appId, method),
880
+ effect: "write",
865
881
  }).kind === "admitted"))) {
866
882
  capabilities.push({
867
883
  ...appCallsDescriptor,
@@ -1119,6 +1135,7 @@ export function createOyster(host) {
1119
1135
  kind: "capability-route",
1120
1136
  capability: "apps.call",
1121
1137
  operation: appCallOperation(appId, method),
1138
+ effect: "write",
1122
1139
  }).kind === "admitted");
1123
1140
  };
1124
1141
  if (!(await allows(caller, principal)))
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;
@@ -5,10 +5,12 @@ import { correlationKey } from "@fungi.computer/cairn";
5
5
  import { createMachineLinkPostChannel, MachineLinkPostUnavailable, machineLinkAudience, machineLinkProtocolVersion, } from "@fungi.computer/seam/machine-link-protocol";
6
6
  import { importSeamVerifiers, seamHeader, verifySeamRequest, } from "@fungi.computer/seam";
7
7
  import { createPost, definePostRoute, PostDeliveryResultSchema, } from "@fungi.computer/carrier/post";
8
+ import { undeliverableReply } from "@fungi.computer/bird-dog";
8
9
  import { BirdDogMailSchema } from "@fungi.computer/bird-dog/effect";
9
10
  import { machineAgentOperationSchema } from "@fungi.computer/seam/machine-link-protocol";
10
11
  import { Watchdog, } from "@fungi.computer/watchdog";
11
12
  import { createMachineLinkSockets } from "./presence.js";
13
+ import { machineLinkDisposition } from "./disposition.js";
12
14
  const attachmentSchema = z.strictObject({
13
15
  deviceId: z.string().min(1).max(256),
14
16
  fenceEpoch: z.int().nonnegative(),
@@ -48,6 +50,15 @@ const machineMailEnvelope = Schema.Struct({
48
50
  address: custodyAddress,
49
51
  event: BirdDogMailSchema,
50
52
  });
53
+ const machineDispositionEnvelope = Schema.Struct({
54
+ key: Schema.String,
55
+ kind: Schema.Literal("machineLinkDisposition"),
56
+ address: custodyAddress,
57
+ event: Schema.Struct({
58
+ original: machineMailEnvelope,
59
+ reply: BirdDogMailSchema,
60
+ }),
61
+ });
51
62
  const machineAgentEnvelope = Schema.Struct({
52
63
  key: Schema.String,
53
64
  kind: Schema.Literal("machineAgent"),
@@ -87,6 +98,25 @@ export class MachineLinkHost {
87
98
  }),
88
99
  },
89
100
  }),
101
+ machineLinkDisposition: definePostRoute({
102
+ addressKind: "machineLinkAgent",
103
+ address: custodyAddress,
104
+ event: machineDispositionEnvelope.fields.event,
105
+ name: ({ agentId }) => agentId,
106
+ namespace: {
107
+ getByName: () => ({
108
+ deliver: async (envelope) => {
109
+ const typed = Schema.decodeUnknownSync(machineDispositionEnvelope, { onExcessProperty: "error" })(envelope);
110
+ return await this.options.disposition(typed.event.original, {
111
+ key: typed.key,
112
+ kind: "agentMail",
113
+ address: typed.address,
114
+ event: typed.event.reply,
115
+ });
116
+ },
117
+ }),
118
+ },
119
+ }),
90
120
  };
91
121
  context.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
92
122
  this.router = new Hono().get("/api/teams/:teamId/local/mesh/:deviceId/connect", async (context) => {
@@ -141,6 +171,8 @@ export class MachineLinkHost {
141
171
  await this.publishDirectory(socket, deviceId);
142
172
  await this.releaseCustody();
143
173
  await this.drainCustody();
174
+ await this.stageUndeliverableReplies();
175
+ await this.drainCustody();
144
176
  }
145
177
  async publishDirectory(socket, deviceId) {
146
178
  this.deviceId = deviceId;
@@ -195,7 +227,7 @@ export class MachineLinkHost {
195
227
  }
196
228
  return channel;
197
229
  }
198
- async deliver(deviceId, envelope) {
230
+ async deliver(deviceId, envelope, options = {}) {
199
231
  this.deviceId = deviceId;
200
232
  await this.options.authorize(deviceId);
201
233
  const links = createMachineLinkSockets(this.context, deviceId);
@@ -205,7 +237,7 @@ export class MachineLinkHost {
205
237
  const typedEnvelope = Schema.decodeUnknownSync(Schema.Union([machineMailEnvelope, machineAgentEnvelope]))(envelope);
206
238
  if (typedEnvelope.kind === "machineAgent")
207
239
  return await this.deliverLiveOperation(deviceId, socket, typedEnvelope);
208
- return await this.deliverCustodyMail(deviceId, socket, typedEnvelope);
240
+ return await this.deliverCustodyMail(deviceId, socket, typedEnvelope, options.refuseDuplicate === true);
209
241
  }
210
242
  async deliverLiveOperation(deviceId, socket, envelope) {
211
243
  if (socket === undefined)
@@ -213,18 +245,31 @@ export class MachineLinkHost {
213
245
  await this.publishDirectory(socket, deviceId);
214
246
  return await this.channel(socket, deviceId).deliver(envelope);
215
247
  }
216
- async deliverCustodyMail(deviceId, socket, envelope) {
248
+ async deliverCustodyMail(deviceId, socket, envelope, refuseDuplicate = false) {
217
249
  const { post } = await this.custody();
218
250
  const existing = post.read(envelope.key);
219
251
  if (existing !== undefined) {
252
+ if (refuseDuplicate)
253
+ return "refused";
220
254
  this.owner.transactionSync(() => {
221
255
  post.send(envelope);
222
256
  });
223
257
  return previousCustodyReceipt(existing);
224
258
  }
225
- this.owner.transactionSync(() => {
259
+ const duplicate = this.owner.transactionSync(() => {
260
+ if (post.read(envelope.key) !== undefined)
261
+ return true;
226
262
  post.send(envelope);
263
+ return false;
227
264
  });
265
+ if (duplicate) {
266
+ if (refuseDuplicate)
267
+ return "refused";
268
+ const raced = post.read(envelope.key);
269
+ return raced === undefined
270
+ ? recipientOffline
271
+ : previousCustodyReceipt(raced);
272
+ }
228
273
  await this.options.scheduleWake(post.nextWake() ?? Date.now());
229
274
  if (socket === undefined)
230
275
  return recipientOffline;
@@ -237,12 +282,87 @@ export class MachineLinkHost {
237
282
  /** Run due Carrier work; expired rows remain visible as Carrier dead letters. */
238
283
  async alarm() {
239
284
  await this.drainCustody();
285
+ await this.stageUndeliverableReplies();
286
+ await this.drainCustody();
287
+ }
288
+ /** Turn each terminal incoming-mail row into a durable reply outbox row. */
289
+ async stageUndeliverableReplies() {
290
+ const { post } = await this.custody();
291
+ let cursor;
292
+ do {
293
+ const page = post.deadLetters(64, cursor);
294
+ for (const letter of page.letters)
295
+ this.stageUndeliverableReply(post, letter);
296
+ cursor = page.nextCursor;
297
+ } while (cursor !== undefined);
298
+ }
299
+ stageUndeliverableReply(post, letter) {
300
+ if ((letter.reason !== "exhausted" && letter.reason !== "expired") ||
301
+ letter.envelope.kind !== "agentMail")
302
+ return;
303
+ const original = Schema.decodeUnknownSync(machineMailEnvelope, {
304
+ onExcessProperty: "error",
305
+ })(letter.envelope);
306
+ if (original.event.reply !== undefined ||
307
+ original.event.from.kind !== "agent")
308
+ return;
309
+ const replyKey = `${original.key}:reply:0`;
310
+ if (post.read(replyKey) !== undefined)
311
+ return;
312
+ const reply = {
313
+ key: replyKey,
314
+ kind: "agentMail",
315
+ address: {
316
+ scopeId: original.address.scopeId,
317
+ agentId: original.event.from.agentId,
318
+ },
319
+ event: undeliverableReply(original.event, Date.now()),
320
+ };
321
+ const disposition = {
322
+ key: replyKey,
323
+ kind: "machineLinkDisposition",
324
+ address: reply.address,
325
+ event: { original, reply: reply.event },
326
+ };
327
+ this.owner.transactionSync(() => {
328
+ if (post.read(replyKey) === undefined)
329
+ post.send(disposition);
330
+ });
240
331
  }
241
- /** Expose terminal custody rows so S9 can stage its own disposition reply. */
332
+ /** Expose terminal custody rows for the device-owned diagnostic surface. */
242
333
  async deadLetters(afterKey) {
243
334
  const { post } = await this.custody();
244
335
  return post.deadLetters(64, afterKey);
245
336
  }
337
+ /** Prove a disposition is still present in this link's durable Post outbox. */
338
+ async hasStagedDisposition(deviceId, original, reply) {
339
+ this.deviceId = deviceId;
340
+ await this.options.authorize(deviceId);
341
+ const identity = { teamId: this.options.teamId, deviceId };
342
+ const requested = machineLinkDisposition(identity, original, reply);
343
+ if (requested === undefined)
344
+ return false;
345
+ const { post } = await this.custody();
346
+ const row = post.read(requested.reply.key);
347
+ if (row?.state !== "pending")
348
+ return false;
349
+ try {
350
+ const staged = Schema.decodeUnknownSync(machineDispositionEnvelope, {
351
+ onExcessProperty: "error",
352
+ })(row.envelope);
353
+ const stagedDisposition = machineLinkDisposition(identity, staged.event.original, {
354
+ key: staged.key,
355
+ kind: "agentMail",
356
+ address: staged.address,
357
+ event: staged.event.reply,
358
+ });
359
+ return (stagedDisposition !== undefined &&
360
+ JSON.stringify(stagedDisposition) === JSON.stringify(requested));
361
+ }
362
+ catch {
363
+ return false;
364
+ }
365
+ }
246
366
  async deliverToSocket(envelope) {
247
367
  const deviceId = this.deviceId;
248
368
  if (deviceId === undefined)
@@ -29,6 +29,10 @@ export declare function createMachineLink<Readback>(state: MachineLinkState, opt
29
29
  fetch: (request: Request) => Promise<Response>;
30
30
  /** Mail addressed to this device's Agent, rechecked against its home. */
31
31
  deliver(input: MachineLinkIdentity, caller: MachineLinkCaller, agentId: string, envelope: PostEnvelope): Promise<PostDeliveryResult>;
32
+ /** A relay's typed terminal reply to an Agent homed on this device. */
33
+ deliverRelayDisposition(input: MachineLinkIdentity, relayInput: MachineLinkIdentity, original: PostEnvelope, reply: PostEnvelope): Promise<PostDeliveryResult>;
34
+ /** Attest a still-pending disposition in this device's own Post outbox. */
35
+ hasStagedDisposition(input: MachineLinkIdentity, original: PostEnvelope, reply: PostEnvelope): Promise<boolean>;
32
36
  /** One finite Agent operation on this device, returning its receipt. */
33
37
  operation(input: MachineLinkIdentity, caller: MachineLinkCaller, agentId: string, raw: MachineAgentOperation): Promise<Schema.Json>;
34
38
  /**