@fungi.computer/oyster 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents.d.ts +2 -1
- package/dist/agents.js +63 -8
- package/dist/app-builds.d.ts +1 -1
- package/dist/app-builds.js +16 -6
- package/dist/apps/gate.d.ts +4 -0
- package/dist/apps/gate.js +3 -0
- package/dist/boot-capability.d.ts +2 -1
- package/dist/boot-capability.js +7 -5
- package/dist/boot-host-script.js +1 -1
- package/dist/build/build-jobs.d.ts +3 -3
- package/dist/build/build-jobs.js +10 -5
- package/dist/build/candidate-cli.js +5 -3
- package/dist/build/candidate-cli.mjs +238 -5
- package/dist/build/candidate-diagnostic.d.ts +2 -0
- package/dist/build/candidate-diagnostic.js +16 -0
- package/dist/channels/binding.d.ts +63 -0
- package/dist/channels/binding.js +85 -0
- package/dist/channels/conversations.d.ts +235 -0
- package/dist/channels/conversations.js +705 -0
- package/dist/channels/ids.d.ts +8 -0
- package/dist/channels/ids.js +15 -0
- package/dist/host.d.ts +1 -1
- package/dist/host.js +21 -4
- package/dist/keys.d.ts +4 -1
- package/dist/keys.js +15 -4
- package/dist/machine-link/authority.d.ts +7 -0
- package/dist/machine-link/delivery.d.ts +12 -0
- package/dist/machine-link/delivery.js +22 -1
- package/dist/machine-link/disposition.d.ts +16 -0
- package/dist/machine-link/disposition.js +65 -0
- package/dist/machine-link/host.d.ts +11 -2
- package/dist/machine-link/host.js +125 -5
- package/dist/machine-link/link.d.ts +4 -0
- package/dist/machine-link/link.js +47 -1
- package/dist/page-script.js +4 -4
- package/dist/standalone/agent-mail.js +4 -1
- package/dist/standalone/agent-model-choice.d.ts +4 -1
- package/dist/standalone/agents.d.ts +8 -0
- package/dist/standalone/agents.js +21 -2
- package/dist/standalone/app-set.d.ts +8 -1
- package/dist/standalone/app-set.js +46 -31
- package/dist/standalone/channel-session-door.d.ts +4 -0
- package/dist/standalone/channel-session-door.js +42 -0
- package/dist/standalone/install-apps-bin.d.ts +2 -0
- package/dist/standalone/install-apps-bin.js +3 -0
- package/dist/standalone/install-apps-cli.d.ts +3 -1
- package/dist/standalone/install-apps-cli.js +27 -17
- package/dist/standalone/machine-link.d.ts +2 -0
- package/dist/standalone/machine-link.js +34 -1
- package/dist/standalone/registry.d.ts +1 -1
- package/dist/testing/capabilities.d.ts +2 -0
- package/dist/testing/capabilities.js +3 -1
- package/dist/workers-git.js +1 -1
- package/dist/workers.d.ts +3 -0
- package/dist/workers.js +9 -1
- package/docs/api/agents.md +8 -4
- package/docs/api/apps.md +8 -4
- package/docs/api/browser.md +40 -16
- package/docs/api/keys.md +25 -3
- package/docs/api/workers.md +21 -14
- package/docs/site/agent-quickstart.mdx +23 -0
- package/examples/standalone/wrangler.jsonc +3 -4
- package/package.json +29 -17
|
@@ -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
|
|
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
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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: [
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
/**
|