@fungi.computer/oyster 0.0.0-stage → 0.0.1
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/LICENSE +21 -0
- package/README.md +1082 -2
- package/dist/agents.d.ts +114 -0
- package/dist/agents.js +322 -0
- package/dist/app-builds.d.ts +151 -0
- package/dist/app-builds.js +355 -0
- package/dist/app-sandbox.d.ts +2 -0
- package/dist/app-sandbox.js +2 -0
- package/dist/app-sources.d.ts +46 -0
- package/dist/app-sources.js +335 -0
- package/dist/app-storage.d.ts +141 -0
- package/dist/app-storage.js +281 -0
- package/dist/apps/gate.d.ts +105 -0
- package/dist/apps/gate.js +273 -0
- package/dist/apps/index.d.ts +5 -0
- package/dist/apps/index.js +5 -0
- package/dist/apps/installation.d.ts +248 -0
- package/dist/apps/installation.js +307 -0
- package/dist/apps/operations.d.ts +8 -0
- package/dist/apps/operations.js +98 -0
- package/dist/apps/tools.d.ts +33 -0
- package/dist/apps/tools.js +17 -0
- package/dist/auth.d.ts +11 -0
- package/dist/auth.js +93 -0
- package/dist/backend-request.d.ts +3 -0
- package/dist/backend-request.js +53 -0
- package/dist/build/build-jobs.d.ts +128 -0
- package/dist/build/build-jobs.js +197 -0
- package/dist/build/build-step.d.ts +26 -0
- package/dist/build/build-step.js +20 -0
- package/dist/build/bundled-html.d.ts +7 -0
- package/dist/build/bundled-html.js +64 -0
- package/dist/build/definition-emission.d.ts +34 -0
- package/dist/build/definition-emission.js +201 -0
- package/dist/build/effect.d.ts +231 -0
- package/dist/build/effect.js +396 -0
- package/dist/build/git-environment.d.ts +6 -0
- package/dist/build/git-environment.js +48 -0
- package/dist/build/html-recipe.d.ts +20 -0
- package/dist/build/html-recipe.js +51 -0
- package/dist/build/local-build.d.ts +57 -0
- package/dist/build/local-build.js +1078 -0
- package/dist/build/r2-artifact-writer.d.ts +31 -0
- package/dist/build/r2-artifact-writer.js +179 -0
- package/dist/build/snapshot-package-store.d.ts +2 -0
- package/dist/build/snapshot-package-store.js +21 -0
- package/dist/build/snapshot-program-scripts.d.ts +6 -0
- package/dist/build/snapshot-program-scripts.js +2 -0
- package/dist/build/submission-contract.d.ts +384 -0
- package/dist/build/submission-contract.js +70 -0
- package/dist/build/workspace-build.d.ts +50 -0
- package/dist/build/workspace-build.js +156 -0
- package/dist/build-contract.d.ts +4 -0
- package/dist/build-contract.js +4 -0
- package/dist/build-jobs.d.ts +2 -0
- package/dist/build-jobs.js +2 -0
- package/dist/build.d.ts +11 -0
- package/dist/build.js +13 -0
- package/dist/capability-descriptor.d.ts +46 -0
- package/dist/capability-descriptor.js +51 -0
- package/dist/catalog-frame.d.ts +39 -0
- package/dist/catalog-frame.js +72 -0
- package/dist/cause-class.d.ts +19 -0
- package/dist/cause-class.js +54 -0
- package/dist/cloudflare.d.ts +123 -0
- package/dist/cloudflare.js +391 -0
- package/dist/computer-terminal.d.ts +38 -0
- package/dist/computer-terminal.js +410 -0
- package/dist/computer.d.ts +60 -0
- package/dist/computer.js +33 -0
- package/dist/event-recipients.d.ts +57 -0
- package/dist/event-recipients.js +144 -0
- package/dist/facet-invocation.d.ts +4 -0
- package/dist/facet-invocation.js +45 -0
- package/dist/files.d.ts +42 -0
- package/dist/files.js +123 -0
- package/dist/frame-binding-wire-script.d.ts +2 -0
- package/dist/frame-binding-wire-script.js +1 -0
- package/dist/frame-binding-wire.d.ts +28 -0
- package/dist/frame-binding-wire.js +54 -0
- package/dist/git-admission.d.ts +52 -0
- package/dist/git-admission.js +188 -0
- package/dist/git-auth.d.ts +11 -0
- package/dist/git-auth.js +27 -0
- package/dist/git-door.d.ts +35 -0
- package/dist/git-door.js +108 -0
- package/dist/git-journal.d.ts +31 -0
- package/dist/git-journal.js +188 -0
- package/dist/git.d.ts +31 -0
- package/dist/git.js +64 -0
- package/dist/guest-policy.d.ts +9 -0
- package/dist/guest-policy.js +13 -0
- package/dist/host.d.ts +244 -0
- package/dist/host.js +1043 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +320 -0
- package/dist/installation-consent.d.ts +79 -0
- package/dist/installation-consent.js +82 -0
- package/dist/internal/background-origins.d.ts +3 -0
- package/dist/internal/background-origins.js +8 -0
- package/dist/internal/delivery.d.ts +12 -0
- package/dist/internal/delivery.js +167 -0
- package/dist/internal/host-gate.d.ts +14 -0
- package/dist/internal/host-gate.js +62 -0
- package/dist/internal/registry-installations.d.ts +6 -0
- package/dist/internal/registry-installations.js +12 -0
- package/dist/invocation.d.ts +251 -0
- package/dist/invocation.js +377 -0
- package/dist/keys.d.ts +48 -0
- package/dist/keys.js +52 -0
- package/dist/mcp-owner.d.ts +27 -0
- package/dist/mcp-owner.js +143 -0
- package/dist/mcp.d.ts +89 -0
- package/dist/mcp.js +429 -0
- package/dist/media.d.ts +23 -0
- package/dist/media.js +12 -0
- package/dist/pack.d.ts +76 -0
- package/dist/pack.js +134 -0
- package/dist/page/capabilities.d.ts +23 -0
- package/dist/page/capabilities.js +256 -0
- package/dist/page/computer-handles.d.ts +79 -0
- package/dist/page/computer-handles.js +44 -0
- package/dist/page/lifetime.d.ts +6 -0
- package/dist/page/lifetime.js +19 -0
- package/dist/page/mount.d.ts +163 -0
- package/dist/page/mount.js +390 -0
- package/dist/page/storage.d.ts +3 -0
- package/dist/page/storage.js +82 -0
- package/dist/page/terminal.d.ts +5 -0
- package/dist/page/terminal.js +7 -0
- package/dist/page-script.d.ts +14 -0
- package/dist/page-script.js +5 -0
- package/dist/r2-files.d.ts +3 -0
- package/dist/r2-files.js +79 -0
- package/dist/release.d.ts +72 -0
- package/dist/release.js +164 -0
- package/dist/root-data.d.ts +86 -0
- package/dist/root-data.js +69 -0
- package/dist/shell/app-directory.d.ts +537 -0
- package/dist/shell/app-directory.js +195 -0
- package/dist/shell/browser-actions.d.ts +7 -0
- package/dist/shell/browser-actions.js +56 -0
- package/dist/shell/command-host.d.ts +112 -0
- package/dist/shell/command-host.js +502 -0
- package/dist/shell/commands.d.ts +25 -0
- package/dist/shell/commands.js +78 -0
- package/dist/shell/index.d.ts +8 -0
- package/dist/shell/index.js +8 -0
- package/dist/shell/keys.d.ts +41 -0
- package/dist/shell/keys.js +134 -0
- package/dist/shell/shell-module.d.ts +31 -0
- package/dist/shell/shell-module.js +37 -0
- package/dist/shell/window-manager.d.ts +124 -0
- package/dist/shell/window-manager.js +711 -0
- package/dist/shell/workbench-contract.d.ts +68 -0
- package/dist/shell/workbench-contract.js +1 -0
- package/dist/shell/workbench-host-types.d.ts +96 -0
- package/dist/shell/workbench-host-types.js +1 -0
- package/dist/shell/workbench-host.d.ts +34 -0
- package/dist/shell/workbench-host.js +347 -0
- package/dist/shell/workbench-model.d.ts +192 -0
- package/dist/shell/workbench-model.js +53 -0
- package/dist/shell/workbench.d.ts +4 -0
- package/dist/shell/workbench.js +2 -0
- package/dist/source/fork.d.ts +27 -0
- package/dist/source/fork.js +46 -0
- package/dist/source/index.d.ts +11 -0
- package/dist/source/index.js +11 -0
- package/dist/source/seed-receipt.d.ts +53 -0
- package/dist/source/seed-receipt.js +18 -0
- package/dist/source/team-source.d.ts +32 -0
- package/dist/source/team-source.js +42 -0
- package/dist/source/template-repository.d.ts +2 -0
- package/dist/source/template-repository.js +8 -0
- package/dist/source/template-write.d.ts +69 -0
- package/dist/source/template-write.js +344 -0
- package/dist/storage-frame.d.ts +64 -0
- package/dist/storage-frame.js +28 -0
- package/dist/tenant.d.ts +81 -0
- package/dist/tenant.js +71 -0
- package/dist/testing/capabilities.d.ts +29 -0
- package/dist/testing/capabilities.js +130 -0
- package/dist/testing/catalog.d.ts +3 -0
- package/dist/testing/catalog.js +53 -0
- package/dist/testing/index.d.ts +10 -0
- package/dist/testing/index.js +10 -0
- package/dist/testing/installation.d.ts +11 -0
- package/dist/testing/installation.js +25 -0
- package/dist/testing/native-worker.d.ts +21 -0
- package/dist/testing/native-worker.js +106 -0
- package/dist/testing/worker.d.ts +18 -0
- package/dist/testing/worker.js +67 -0
- package/dist/testing/workers-runtime.d.ts +24 -0
- package/dist/testing/workers-runtime.js +33 -0
- package/dist/webhook-delivery.d.ts +14 -0
- package/dist/webhook-delivery.js +17 -0
- package/dist/workers-git.d.ts +285 -0
- package/dist/workers-git.js +1174 -0
- package/dist/workers.d.ts +352 -0
- package/dist/workers.js +866 -0
- package/docs/api/README.md +82 -0
- package/docs/api/agents.md +332 -0
- package/docs/api/app-storage.md +209 -0
- package/docs/api/apps.md +1375 -0
- package/docs/api/auth.md +63 -0
- package/docs/api/build-contract.md +507 -0
- package/docs/api/build.md +1476 -0
- package/docs/api/cloudflare.md +421 -0
- package/docs/api/computer.md +203 -0
- package/docs/api/files.md +117 -0
- package/docs/api/git.md +470 -0
- package/docs/api/host.md +1328 -0
- package/docs/api/index.md +430 -0
- package/docs/api/invocation.md +738 -0
- package/docs/api/keys.md +175 -0
- package/docs/api/mcp.md +301 -0
- package/docs/api/media.md +40 -0
- package/docs/api/modules.md +42 -0
- package/docs/api/pack.md +138 -0
- package/docs/api/page/mount.md +298 -0
- package/docs/api/root-data.md +111 -0
- package/docs/api/shell/app-directory.md +332 -0
- package/docs/api/shell/command-host.md +224 -0
- package/docs/api/shell/commands.md +197 -0
- package/docs/api/shell/keys.md +183 -0
- package/docs/api/shell/shell-module.md +143 -0
- package/docs/api/shell/window-manager.md +267 -0
- package/docs/api/shell/workbench.md +186 -0
- package/docs/api/shell.md +1788 -0
- package/docs/api/tenant.md +194 -0
- package/docs/api/workers-git.md +1527 -0
- package/docs/api/workers.md +1678 -0
- package/docs/media/architecture.mmd +32 -0
- package/docs/media/architecture.png +0 -0
- package/docs/media/bonsai-network.png +0 -0
- package/docs/media/bonsai-repository.png +0 -0
- package/docs/site/agent-quickstart.mdx +349 -0
- package/examples/git/worker.ts +39 -0
- package/examples/git/wrangler.jsonc +32 -0
- package/examples/standalone/worker.ts +14 -0
- package/examples/standalone/wrangler.jsonc +21 -0
- package/package.json +249 -3
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { approvedBindingGrants } from "./apps/gate.js";
|
|
2
|
+
import { appToolPolicySchema } from "./apps/tools.js";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
import * as Semaphore from "effect/Semaphore";
|
|
6
|
+
import { defineIdFamily } from "@fungi.computer/cairn/id";
|
|
7
|
+
import { sha256DigestSchema } from "@fungi.computer/puffball/effect";
|
|
8
|
+
import { appBackendBindingKinds, appBackendBindingsSchema, appMethodCallContextSchema, } from "@fungi.computer/puffball/app";
|
|
9
|
+
import { actorLane, mayDefer, backgroundContext, sealBackgroundOrigin, backgroundRefusalCodeSchema, admitClaimedOrigin, backgroundOriginSchema, } from "@fungi.computer/puffball/background-replay";
|
|
10
|
+
import { appApiMethodNameSchema, emptyAppDeclaration, } from "@fungi.computer/puffball/app";
|
|
11
|
+
import { AppInstanceError, instanceIdSchema, selectionOf, selectionSchema, } from "./index.js";
|
|
12
|
+
/** One App invocation may hold authority for at most the Hub grant's lifetime. */
|
|
13
|
+
export const APP_INVOCATION_CEILING_MS = 60_000;
|
|
14
|
+
const bindingName = Schema.String.pipe(Schema.refine((value) => Object.hasOwn(appBackendBindingKinds, value)));
|
|
15
|
+
const grantsSchema = Schema.Array(Schema.Struct({
|
|
16
|
+
binding: bindingName,
|
|
17
|
+
operations: Schema.Array(Schema.NonEmptyString),
|
|
18
|
+
})).pipe(Schema.check(Schema.makeFilter((grants) => Schema.is(appBackendBindingsSchema)(grants.map((grant) => ({
|
|
19
|
+
name: grant.binding,
|
|
20
|
+
operations: grant.operations,
|
|
21
|
+
}))))));
|
|
22
|
+
/** Host-admitted facts; shape decoding does not establish host authority. */
|
|
23
|
+
export const invocationFactsSchema = Schema.Struct({
|
|
24
|
+
instanceId: instanceIdSchema,
|
|
25
|
+
selection: selectionSchema,
|
|
26
|
+
artifactDigest: sha256DigestSchema,
|
|
27
|
+
subject: Schema.Union([
|
|
28
|
+
Schema.Struct({
|
|
29
|
+
kind: Schema.Literal("call"),
|
|
30
|
+
context: appMethodCallContextSchema,
|
|
31
|
+
/** Originating Session supplied by capability acquisition, never App arguments. */
|
|
32
|
+
sessionId: Schema.optionalKey(Schema.String.pipe(Schema.check(Schema.isMinLength(1)), Schema.check(Schema.isMaxLength(256)))),
|
|
33
|
+
/** Captured Agent approval, supplied by the host after current policy admission. */
|
|
34
|
+
agentPolicy: Schema.optionalKey(Schema.Struct({
|
|
35
|
+
policyRevision: appToolPolicySchema.fields.revision,
|
|
36
|
+
grantorMemberId: appToolPolicySchema.fields.grantedBy,
|
|
37
|
+
})),
|
|
38
|
+
/** Verified method metadata; raw fetch frames have no declared method. */
|
|
39
|
+
method: Schema.optionalKey(Schema.Struct({
|
|
40
|
+
name: appApiMethodNameSchema,
|
|
41
|
+
effect: Schema.Literals(["read", "write", "external"]),
|
|
42
|
+
})),
|
|
43
|
+
/** Host-marked, freshly admitted replay; never read from guest input. */
|
|
44
|
+
replay: Schema.optionalKey(Schema.Struct({
|
|
45
|
+
lane: Schema.NonEmptyString,
|
|
46
|
+
epoch: Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER)),
|
|
47
|
+
admitted: Schema.Array(appApiMethodNameSchema).check(Schema.isMaxLength(256), Schema.makeFilter((names) => new Set(names).size === names.length)),
|
|
48
|
+
})),
|
|
49
|
+
}),
|
|
50
|
+
Schema.Struct({
|
|
51
|
+
kind: Schema.Literal("lifecycle"),
|
|
52
|
+
name: Schema.Literal("onGitPush"),
|
|
53
|
+
eventId: Schema.NonEmptyString,
|
|
54
|
+
}),
|
|
55
|
+
Schema.Struct({
|
|
56
|
+
kind: Schema.Literal("lifecycle"),
|
|
57
|
+
name: Schema.Literal("syncRefused"),
|
|
58
|
+
lane: Schema.String.check(Schema.makeFilter((lane) => lane.startsWith("member:") && lane.length > 7 && lane.length <= 263)),
|
|
59
|
+
code: Schema.Literals(backgroundRefusalCodeSchema.options),
|
|
60
|
+
}),
|
|
61
|
+
]),
|
|
62
|
+
grants: grantsSchema,
|
|
63
|
+
deadlineAt: Schema.Number.pipe(Schema.check(Schema.isInt()), Schema.check(Schema.isGreaterThan(0))),
|
|
64
|
+
ancestry: Schema.Array(instanceIdSchema),
|
|
65
|
+
}).pipe(Schema.check(Schema.makeFilter((facts) => facts.instanceId === facts.selection.instanceId &&
|
|
66
|
+
(facts.subject.kind !== "lifecycle" ||
|
|
67
|
+
facts.subject.name !== "syncRefused" ||
|
|
68
|
+
facts.grants.length === 0) &&
|
|
69
|
+
(facts.subject.kind !== "call" ||
|
|
70
|
+
facts.subject.agentPolicy === undefined ||
|
|
71
|
+
facts.subject.context.actor.kind === "agent") &&
|
|
72
|
+
(facts.subject.kind !== "call" ||
|
|
73
|
+
facts.subject.context.backgroundLane === undefined ||
|
|
74
|
+
(facts.subject.method?.effect === "write" &&
|
|
75
|
+
facts.subject.context.backgroundOrigin?.method ===
|
|
76
|
+
facts.subject.method.name &&
|
|
77
|
+
mayDefer(facts.subject.context, facts.ancestry))) &&
|
|
78
|
+
facts.grants.some((grant) => grant.binding === "BACKGROUND") ===
|
|
79
|
+
(facts.subject.kind === "call" &&
|
|
80
|
+
facts.subject.replay !== undefined) &&
|
|
81
|
+
(facts.subject.kind !== "call" ||
|
|
82
|
+
facts.subject.replay === undefined ||
|
|
83
|
+
(facts.subject.method?.effect === "write" &&
|
|
84
|
+
facts.subject.context.backgroundLane === undefined &&
|
|
85
|
+
mayDefer(facts.subject.context, facts.ancestry) &&
|
|
86
|
+
facts.subject.replay.lane ===
|
|
87
|
+
actorLane(facts.subject.context.actor))))));
|
|
88
|
+
/** Block replay capability effects before job-origin admission; storage permits claiming/settling. */
|
|
89
|
+
export function assertBackgroundBinding(frame, binding) {
|
|
90
|
+
if (frame.signal.aborted ||
|
|
91
|
+
(frame.facts.subject.kind === "call" &&
|
|
92
|
+
frame.facts.subject.replay !== undefined &&
|
|
93
|
+
binding !== "BACKGROUND" &&
|
|
94
|
+
binding !== "APP_STORAGE" &&
|
|
95
|
+
frame.host.origin !== "admitted"))
|
|
96
|
+
throw new AppInstanceError("revoked");
|
|
97
|
+
}
|
|
98
|
+
/** Consume this frame's one origin check before awaiting verification, then retain only its decision. */
|
|
99
|
+
export async function admitBackgroundFrame(key, frame, raw) {
|
|
100
|
+
assertBackgroundBinding(frame, "BACKGROUND");
|
|
101
|
+
const subject = frame.facts.subject;
|
|
102
|
+
if (subject.kind !== "call" ||
|
|
103
|
+
subject.replay === undefined ||
|
|
104
|
+
frame.host.origin !== "unchecked")
|
|
105
|
+
return { kind: "refused", code: "access_changed" };
|
|
106
|
+
frame.host.origin = "refused";
|
|
107
|
+
const origin = backgroundOriginSchema.safeParse(raw);
|
|
108
|
+
if (!origin.success)
|
|
109
|
+
return { kind: "refused", code: "access_changed" };
|
|
110
|
+
const decision = await admitClaimedOrigin(key, {
|
|
111
|
+
instanceId: frame.facts.instanceId,
|
|
112
|
+
lane: subject.replay.lane,
|
|
113
|
+
admitted: subject.replay.admitted,
|
|
114
|
+
}, origin.data);
|
|
115
|
+
assertBackgroundBinding(frame, "BACKGROUND");
|
|
116
|
+
frame.host.origin = decision.kind;
|
|
117
|
+
return decision;
|
|
118
|
+
}
|
|
119
|
+
const frameIds = defineIdFamily({
|
|
120
|
+
family: "app-invocation-frame",
|
|
121
|
+
version: 1,
|
|
122
|
+
kinds: { frame: { prefix: "app_frame" } },
|
|
123
|
+
});
|
|
124
|
+
/** Decode only identities minted from the invocation-frame ID family. */
|
|
125
|
+
export const invocationFrameIdSchema = Schema.String.check(Schema.makeFilter((value) => frameIds.frame.is(value)));
|
|
126
|
+
const parseFacts = Schema.decodeUnknownSync(invocationFactsSchema, {
|
|
127
|
+
onExcessProperty: "error",
|
|
128
|
+
});
|
|
129
|
+
function immutableFacts(facts) {
|
|
130
|
+
Object.freeze(facts.selection.owner);
|
|
131
|
+
Object.freeze(facts.selection);
|
|
132
|
+
if (facts.subject.kind === "call") {
|
|
133
|
+
Object.freeze(facts.subject.context.actor);
|
|
134
|
+
if (facts.subject.context.workflow) {
|
|
135
|
+
Object.freeze(facts.subject.context.workflow.armedBy);
|
|
136
|
+
Object.freeze(facts.subject.context.workflow);
|
|
137
|
+
}
|
|
138
|
+
Object.freeze(facts.subject.context);
|
|
139
|
+
if (facts.subject.agentPolicy)
|
|
140
|
+
Object.freeze(facts.subject.agentPolicy);
|
|
141
|
+
if (facts.subject.method)
|
|
142
|
+
Object.freeze(facts.subject.method);
|
|
143
|
+
if (facts.subject.replay)
|
|
144
|
+
Object.freeze(facts.subject.replay);
|
|
145
|
+
}
|
|
146
|
+
Object.freeze(facts.subject);
|
|
147
|
+
for (const grant of facts.grants) {
|
|
148
|
+
Object.freeze(grant.operations);
|
|
149
|
+
Object.freeze(grant);
|
|
150
|
+
}
|
|
151
|
+
Object.freeze(facts.grants);
|
|
152
|
+
Object.freeze(facts.ancestry);
|
|
153
|
+
return Object.freeze(facts);
|
|
154
|
+
}
|
|
155
|
+
/** Serialize authority, preempt replay only for verified writes, and close each guest before admission. */
|
|
156
|
+
export function createInvocationGate(ports) {
|
|
157
|
+
const permit = Semaphore.makeUnsafe(1);
|
|
158
|
+
const now = ports.now ?? Date.now;
|
|
159
|
+
let foregroundWaiting = 0;
|
|
160
|
+
let active;
|
|
161
|
+
const close = (state, reason) => {
|
|
162
|
+
if (state.closing)
|
|
163
|
+
return state.closing;
|
|
164
|
+
if (active === state)
|
|
165
|
+
active = undefined;
|
|
166
|
+
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
|
+
})();
|
|
175
|
+
return state.closing;
|
|
176
|
+
};
|
|
177
|
+
const run = async (input, host, work) => {
|
|
178
|
+
let facts;
|
|
179
|
+
try {
|
|
180
|
+
facts = immutableFacts(structuredClone(parseFacts(input)));
|
|
181
|
+
}
|
|
182
|
+
catch (cause) {
|
|
183
|
+
throw new AppInstanceError("invalid_invocation_facts", { cause });
|
|
184
|
+
}
|
|
185
|
+
// This must happen before waiting: self-invocation already holds the permit.
|
|
186
|
+
if (facts.ancestry.includes(facts.instanceId))
|
|
187
|
+
throw new AppInstanceError("reentrant");
|
|
188
|
+
let queuedForeground = facts.subject.kind === "call" && facts.subject.replay === undefined;
|
|
189
|
+
if (queuedForeground)
|
|
190
|
+
foregroundWaiting += 1;
|
|
191
|
+
try {
|
|
192
|
+
if (facts.deadlineAt <= now())
|
|
193
|
+
throw new AppInstanceError("expired");
|
|
194
|
+
// Reads wait for the current replay step under the same permit. Only a
|
|
195
|
+
// verified write closes replay; Watchdog recovers its retained claim.
|
|
196
|
+
if (queuedForeground &&
|
|
197
|
+
facts.subject.kind === "call" &&
|
|
198
|
+
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,
|
|
222
|
+
});
|
|
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
|
+
})));
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
if (queuedForeground)
|
|
260
|
+
foregroundWaiting -= 1;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
return {
|
|
264
|
+
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())
|
|
275
|
+
throw new AppInstanceError("revoked");
|
|
276
|
+
return active.frame;
|
|
277
|
+
},
|
|
278
|
+
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);
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Project an admitted principal into App attribution; only the host chooses
|
|
291
|
+
* the fixed Computer context. The App caller grammar has no visitor actor, so
|
|
292
|
+
* the anonymous public reader is attributed as the reserved `visitor` key
|
|
293
|
+
* without a Computer. Host admission reads the principal, never this projection.
|
|
294
|
+
*/
|
|
295
|
+
export function invocationCallerContext(principal, computerId) {
|
|
296
|
+
const { actor } = principal;
|
|
297
|
+
if (actor.kind === "visitor")
|
|
298
|
+
return { computerId: null, actor: { kind: "team-key", keyId: "visitor" } };
|
|
299
|
+
if (!("role" in principal))
|
|
300
|
+
return { computerId, actor };
|
|
301
|
+
return principal.soleMember === true
|
|
302
|
+
? { computerId, actor, role: principal.role, soleMember: true }
|
|
303
|
+
: { computerId, actor, role: principal.role };
|
|
304
|
+
}
|
|
305
|
+
/** Shared host-held caller/Workflow fact builders, never guest argument decoders. */
|
|
306
|
+
export const invocationFacts = {
|
|
307
|
+
/** Combine host-admitted caller facts with the invocation and grant deadlines. */
|
|
308
|
+
fromCaller: (authority, context, deadline, method, sessionId, agentPolicy) => {
|
|
309
|
+
let subject = {
|
|
310
|
+
kind: "call",
|
|
311
|
+
context,
|
|
312
|
+
};
|
|
313
|
+
if (method !== undefined)
|
|
314
|
+
subject = { ...subject, method };
|
|
315
|
+
if (sessionId !== undefined)
|
|
316
|
+
subject = { ...subject, sessionId };
|
|
317
|
+
if (agentPolicy !== undefined)
|
|
318
|
+
subject = { ...subject, agentPolicy };
|
|
319
|
+
return parseFacts({
|
|
320
|
+
...authority,
|
|
321
|
+
subject,
|
|
322
|
+
deadlineAt: Math.min(deadline.deadlineAt, deadline.grantExpiresAt),
|
|
323
|
+
});
|
|
324
|
+
},
|
|
325
|
+
/** Attribute an already host-admitted published node to its retained arming actor. */
|
|
326
|
+
fromWorkflowNode: (authority, input, deadline) => {
|
|
327
|
+
const context = {
|
|
328
|
+
actor: input.workflow.armedBy,
|
|
329
|
+
computerId: input.computerId,
|
|
330
|
+
idempotencyKey: input.idempotencyKey,
|
|
331
|
+
workflow: input.workflow,
|
|
332
|
+
};
|
|
333
|
+
if (input.role !== undefined)
|
|
334
|
+
context.role = input.role;
|
|
335
|
+
return invocationFacts.fromCaller(authority, context, deadline);
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
/** Authority comes from the admitted installation and its verified backend bytes. */
|
|
339
|
+
export function invocationAuthorityOf(installation, release, ancestry = []) {
|
|
340
|
+
if (release.backend === null)
|
|
341
|
+
throw new Error("Release has no backend");
|
|
342
|
+
if (installation.status !== "enabled" ||
|
|
343
|
+
installation.release.releaseId !== release.release.releaseId ||
|
|
344
|
+
installation.release.artifact.artifactDigest !==
|
|
345
|
+
release.release.artifact.artifactDigest)
|
|
346
|
+
throw new Error("Invocation installation changed");
|
|
347
|
+
return {
|
|
348
|
+
instanceId: selectionOf(installation).instanceId,
|
|
349
|
+
selection: selectionOf(installation),
|
|
350
|
+
artifactDigest: release.backend.digest,
|
|
351
|
+
grants: approvedBindingGrants(installation, release.declaration ?? emptyAppDeclaration),
|
|
352
|
+
ancestry,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/** Mint standalone facts only after host caller and Release admission. */
|
|
356
|
+
export function admittedCallerFacts(installation, release, context, grants, ancestry = [], sessionId, agentPolicy, method, origin) {
|
|
357
|
+
const deadlineAt = Date.now() + APP_INVOCATION_CEILING_MS;
|
|
358
|
+
return invocationFacts.fromCaller({ ...invocationAuthorityOf(installation, release, ancestry), grants }, method === undefined
|
|
359
|
+
? context
|
|
360
|
+
: backgroundContext(context, ancestry, method, origin), { deadlineAt, grantExpiresAt: deadlineAt }, method, sessionId, agentPolicy);
|
|
361
|
+
}
|
|
362
|
+
/** Seal the host-verified named method after its current admission checks. */
|
|
363
|
+
export async function admittedMethodCallerFacts(host, installation, release, context, grants, method, ancestry = [], sessionId, agentPolicy) {
|
|
364
|
+
if (release.api?.methods[method.name]?.effect !== method.effect)
|
|
365
|
+
throw new Error("Invocation method is not verified by the Release");
|
|
366
|
+
let origin;
|
|
367
|
+
if (method.effect === "write" && mayDefer(context, ancestry)) {
|
|
368
|
+
if (host.backgroundOriginKey === undefined)
|
|
369
|
+
throw new Error("Background origin key is not configured");
|
|
370
|
+
origin = await sealBackgroundOrigin(await host.backgroundOriginKey(), {
|
|
371
|
+
instanceId: selectionOf(installation).instanceId,
|
|
372
|
+
lane: actorLane(context.actor),
|
|
373
|
+
method: method.name,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
return admittedCallerFacts(installation, release, context, grants, ancestry, sessionId, agentPolicy, method, origin);
|
|
377
|
+
}
|
package/dist/keys.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Grant, GrantVocabulary, Scope } from "@fungi.computer/ear-tag/grants";
|
|
2
|
+
import type { KeyPrincipal, KeyRecord, KeyStore } from "@fungi.computer/ear-tag/keys";
|
|
3
|
+
import type { AppToolPolicy } from "./apps/tools.js";
|
|
4
|
+
/** Every capability an Oyster key may carry. */
|
|
5
|
+
export type OysterKeyCapability = "apps.read" | "apps.use" | "computer.use";
|
|
6
|
+
/**
|
|
7
|
+
* `apps.use` also reaches `apps.read` (one level, per `defineGrants`'s own
|
|
8
|
+
* `implies`): a key that may change an App's data may also read it.
|
|
9
|
+
* `computer.use` has no narrower capability under it; the Computer is on or
|
|
10
|
+
* off, host-wide.
|
|
11
|
+
*/
|
|
12
|
+
export declare const oysterKeyVocabulary: GrantVocabulary<OysterKeyCapability>;
|
|
13
|
+
/** A grant reaching every current and future installation for `capability`. */
|
|
14
|
+
export declare function allInstallations(capability: "apps.read" | "apps.use"): Grant<OysterKeyCapability>;
|
|
15
|
+
/** The grant that turns on the Computer for a key, host-wide; there is no narrower target. */
|
|
16
|
+
export declare const computerUseGrant: Grant<OysterKeyCapability>;
|
|
17
|
+
/** One key's durable record, in the shape a host's own App reads. */
|
|
18
|
+
export type OysterKeyRecord = KeyRecord<OysterKeyCapability>;
|
|
19
|
+
/** One key's live, authenticated principal. */
|
|
20
|
+
export type OysterKeyPrincipal = KeyPrincipal<OysterKeyCapability>;
|
|
21
|
+
/** One key's authority, as `defineGrants` canonicalizes it. */
|
|
22
|
+
export type OysterKeyScope = Scope<OysterKeyCapability>;
|
|
23
|
+
/** The Ear Tag store's own interface, closed over Oyster's capability vocabulary. */
|
|
24
|
+
export type OysterKeyStore = KeyStore<OysterKeyCapability>;
|
|
25
|
+
/**
|
|
26
|
+
* The minimal live facts `OysterHost`'s `keys.live` port returns: enough to
|
|
27
|
+
* gate one call, never the key's name or timestamps. `null` for an unknown,
|
|
28
|
+
* revoked, expired or suspended key -- the same closed set `keyAppAccess`
|
|
29
|
+
* and `keyAllowsComputerUse` already treat as no authority.
|
|
30
|
+
*/
|
|
31
|
+
export type LiveOysterKey = Readonly<{
|
|
32
|
+
keyId: string;
|
|
33
|
+
owner: string;
|
|
34
|
+
grantVersion: number;
|
|
35
|
+
grants: readonly Grant<OysterKeyCapability>[];
|
|
36
|
+
}>;
|
|
37
|
+
/**
|
|
38
|
+
* One live key's access to one installation: `write` when its grants admit
|
|
39
|
+
* `apps.use` there, `read` when they admit `apps.read`, otherwise `null` --
|
|
40
|
+
* including for an installation added after the key was issued, unless the
|
|
41
|
+
* key holds "All Apps" (`targets: {kind: "all"}`). The same shape as an
|
|
42
|
+
* Agent's stored {@link AppToolPolicy}; its caller adds `bindingName` (which
|
|
43
|
+
* varies by caller: the plain installation id for the gate, the uppercased
|
|
44
|
+
* binding name for MCP) to reach the full `AppToolAccess`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function keyAppAccess(key: LiveOysterKey, installationId: string): AppToolPolicy | null;
|
|
47
|
+
/** Whether a live key's own grants turn its Computer access on. Off by default. */
|
|
48
|
+
export declare function keyAllowsComputerUse(key: LiveOysterKey): boolean;
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oyster's own Ear Tag vocabulary: named keys an owner issues for its
|
|
3
|
+
* installed Apps and, optionally, its Computer. A key's id is its name, so
|
|
4
|
+
* forks and other name-derived identities stay stable across the cutover.
|
|
5
|
+
*/
|
|
6
|
+
import { defineGrants } from "@fungi.computer/ear-tag/grants";
|
|
7
|
+
/**
|
|
8
|
+
* `apps.use` also reaches `apps.read` (one level, per `defineGrants`'s own
|
|
9
|
+
* `implies`): a key that may change an App's data may also read it.
|
|
10
|
+
* `computer.use` has no narrower capability under it; the Computer is on or
|
|
11
|
+
* off, host-wide.
|
|
12
|
+
*/
|
|
13
|
+
export const oysterKeyVocabulary = defineGrants({
|
|
14
|
+
capabilities: ["apps.read", "apps.use", "computer.use"],
|
|
15
|
+
implies: { "apps.use": ["apps.read"] },
|
|
16
|
+
});
|
|
17
|
+
/** A grant reaching every current and future installation for `capability`. */
|
|
18
|
+
export function allInstallations(capability) {
|
|
19
|
+
return { capability, targets: { kind: "all" } };
|
|
20
|
+
}
|
|
21
|
+
/** The grant that turns on the Computer for a key, host-wide; there is no narrower target. */
|
|
22
|
+
export const computerUseGrant = {
|
|
23
|
+
capability: "computer.use",
|
|
24
|
+
targets: { kind: "all" },
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* One live key's access to one installation: `write` when its grants admit
|
|
28
|
+
* `apps.use` there, `read` when they admit `apps.read`, otherwise `null` --
|
|
29
|
+
* including for an installation added after the key was issued, unless the
|
|
30
|
+
* key holds "All Apps" (`targets: {kind: "all"}`). The same shape as an
|
|
31
|
+
* Agent's stored {@link AppToolPolicy}; its caller adds `bindingName` (which
|
|
32
|
+
* varies by caller: the plain installation id for the gate, the uppercased
|
|
33
|
+
* binding name for MCP) to reach the full `AppToolAccess`.
|
|
34
|
+
*/
|
|
35
|
+
export function keyAppAccess(key, installationId) {
|
|
36
|
+
const level = oysterKeyVocabulary.grantsAllow(key.grants, "apps.use", installationId)
|
|
37
|
+
? "write"
|
|
38
|
+
: oysterKeyVocabulary.grantsAllow(key.grants, "apps.read", installationId)
|
|
39
|
+
? "read"
|
|
40
|
+
: null;
|
|
41
|
+
if (level === null)
|
|
42
|
+
return null;
|
|
43
|
+
return {
|
|
44
|
+
level,
|
|
45
|
+
revision: `key:${key.keyId}:${String(key.grantVersion)}`,
|
|
46
|
+
grantedBy: key.owner,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Whether a live key's own grants turn its Computer access on. Off by default. */
|
|
50
|
+
export function keyAllowsComputerUse(key) {
|
|
51
|
+
return oysterKeyVocabulary.grantsAllow(key.grants, "computer.use");
|
|
52
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
import type { OysterHost, OysterMcp } from "./host.js";
|
|
3
|
+
import { type TenantScope } from "./tenant.js";
|
|
4
|
+
/** Worker Loader used by the existing Mycelium sandbox. */
|
|
5
|
+
export type OysterMcpOwnerEnv = Readonly<{
|
|
6
|
+
LOADER: WorkerLoader;
|
|
7
|
+
}>;
|
|
8
|
+
/** Serialized trusted tenant pin supplied by the selected host. */
|
|
9
|
+
export type OysterMcpTenantPin = Readonly<{
|
|
10
|
+
tenantId: string;
|
|
11
|
+
origin: string;
|
|
12
|
+
}>;
|
|
13
|
+
/**
|
|
14
|
+
* One tenant's live SDK transports; cold owners refuse old IDs without execution replay.
|
|
15
|
+
* The persisted origin is a lifetime pin. Changing the configured origin requires
|
|
16
|
+
* a fresh MCP namespace; an existing owner never retargets its retained pin.
|
|
17
|
+
*/
|
|
18
|
+
export declare class OysterMcpOwner<Env extends OysterMcpOwnerEnv = OysterMcpOwnerEnv> extends DurableObject<Env> {
|
|
19
|
+
#private;
|
|
20
|
+
constructor(ctx: DurableObjectState, env: Env, createHost: (env: Env, scope: TenantScope) => OysterHost);
|
|
21
|
+
/** Trusted host RPC: the namespace and pin are never selected by guest headers. */
|
|
22
|
+
serve(request: Request, tenant: OysterMcpTenantPin): Promise<Response>;
|
|
23
|
+
}
|
|
24
|
+
/** Bind the owner to the deployment's existing host factory and options. */
|
|
25
|
+
export declare function createOysterMcpOwner<Env extends OysterMcpOwnerEnv>(host: (env: Env, scope: TenantScope) => OysterHost): new (ctx: DurableObjectState, env: Env) => OysterMcpOwner<Env>;
|
|
26
|
+
/** Frontends own no SDK state and cannot close another isolate's sessions. */
|
|
27
|
+
export declare function remoteOysterMcp(binding: DurableObjectNamespace<OysterMcpOwner> | undefined, scope: TenantScope): OysterMcp;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
import { nanoid } from "@fungi.computer/cairn";
|
|
3
|
+
import { createMcp, createMcpCatalog, } from "@fungi.computer/mycelium/mcp";
|
|
4
|
+
import { createCodemodeSandbox } from "@fungi.computer/mycelium/workers";
|
|
5
|
+
import { parseJSONRPCMessage, serializeMessage, } from "@modelcontextprotocol/server";
|
|
6
|
+
import * as Schema from "effect/Schema";
|
|
7
|
+
import { oysterMcpCatalogLoad } from "./mcp.js";
|
|
8
|
+
import { tenantIdSchema, tenantScope } from "./tenant.js";
|
|
9
|
+
const pinSchema = Schema.Struct({
|
|
10
|
+
tenant_id: tenantIdSchema,
|
|
11
|
+
origin: Schema.String,
|
|
12
|
+
});
|
|
13
|
+
const eventSchema = Schema.Struct({
|
|
14
|
+
event_id: Schema.String,
|
|
15
|
+
stream_id: Schema.String,
|
|
16
|
+
message: Schema.String,
|
|
17
|
+
sequence: Schema.Number,
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* One tenant's live SDK transports; cold owners refuse old IDs without execution replay.
|
|
21
|
+
* The persisted origin is a lifetime pin. Changing the configured origin requires
|
|
22
|
+
* a fresh MCP namespace; an existing owner never retargets its retained pin.
|
|
23
|
+
*/
|
|
24
|
+
export class OysterMcpOwner extends DurableObject {
|
|
25
|
+
#mcp;
|
|
26
|
+
#createHost;
|
|
27
|
+
constructor(ctx, env, createHost) {
|
|
28
|
+
super(ctx, env);
|
|
29
|
+
this.#createHost = createHost;
|
|
30
|
+
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS oyster_mcp_tenant (id INTEGER PRIMARY KEY CHECK(id=1), tenant_id TEXT NOT NULL, origin TEXT NOT NULL) STRICT");
|
|
31
|
+
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS oyster_mcp_sessions (protocol_id TEXT PRIMARY KEY, catalog_id TEXT NOT NULL, principal TEXT NOT NULL) STRICT");
|
|
32
|
+
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS oyster_mcp_events (sequence INTEGER PRIMARY KEY AUTOINCREMENT, catalog_id TEXT NOT NULL, event_id TEXT NOT NULL UNIQUE, stream_id TEXT NOT NULL, message TEXT NOT NULL) STRICT");
|
|
33
|
+
// The SDK has no transport snapshot restore port. Expire SDK-only state,
|
|
34
|
+
// retaining the trusted tenant pin and all host-owned business history.
|
|
35
|
+
ctx.storage.transactionSync(() => {
|
|
36
|
+
ctx.storage.sql.exec("DELETE FROM oyster_mcp_events");
|
|
37
|
+
ctx.storage.sql.exec("DELETE FROM oyster_mcp_sessions");
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
#eventStore(catalogId) {
|
|
41
|
+
const sql = this.ctx.storage.sql;
|
|
42
|
+
const find = (eventId) => {
|
|
43
|
+
const row = sql
|
|
44
|
+
.exec("SELECT sequence, event_id, stream_id, message FROM oyster_mcp_events WHERE catalog_id=? AND event_id=?", catalogId, eventId)
|
|
45
|
+
.toArray()[0];
|
|
46
|
+
return row === undefined
|
|
47
|
+
? undefined
|
|
48
|
+
: Schema.decodeUnknownSync(eventSchema)(row);
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
storeEvent: (streamId, message) => {
|
|
52
|
+
const id = nanoid();
|
|
53
|
+
sql.exec("INSERT INTO oyster_mcp_events (catalog_id, event_id, stream_id, message) VALUES (?,?,?,?)", catalogId, id, streamId, serializeMessage(message));
|
|
54
|
+
return Promise.resolve(id);
|
|
55
|
+
},
|
|
56
|
+
getStreamIdForEventId: (id) => Promise.resolve(find(id)?.stream_id),
|
|
57
|
+
replayEventsAfter: async (id, { send }) => {
|
|
58
|
+
const after = find(id);
|
|
59
|
+
if (after === undefined)
|
|
60
|
+
throw new Error("MCP event unavailable");
|
|
61
|
+
const rows = sql
|
|
62
|
+
.exec("SELECT sequence, event_id, stream_id, message FROM oyster_mcp_events WHERE catalog_id=? AND stream_id=? AND sequence>? ORDER BY sequence", catalogId, after.stream_id, after.sequence)
|
|
63
|
+
.toArray();
|
|
64
|
+
for (const raw of rows) {
|
|
65
|
+
const row = Schema.decodeUnknownSync(eventSchema)(raw);
|
|
66
|
+
await send(row.event_id, parseJSONRPCMessage(JSON.parse(row.message)));
|
|
67
|
+
}
|
|
68
|
+
return after.stream_id;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Trusted host RPC: the namespace and pin are never selected by guest headers. */
|
|
73
|
+
async serve(request, tenant) {
|
|
74
|
+
const scope = tenantScope(Schema.decodeUnknownSync(tenantIdSchema)(tenant.tenantId), tenant.origin);
|
|
75
|
+
if (this.ctx.id.name !== scope.durableName("mcp") ||
|
|
76
|
+
new URL(request.url).origin !== scope.origin)
|
|
77
|
+
return new Response("Not found", { status: 404 });
|
|
78
|
+
const sql = this.ctx.storage.sql;
|
|
79
|
+
const raw = sql
|
|
80
|
+
.exec("SELECT tenant_id, origin FROM oyster_mcp_tenant WHERE id=1")
|
|
81
|
+
.toArray()[0];
|
|
82
|
+
if (raw !== undefined) {
|
|
83
|
+
const pin = Schema.decodeUnknownSync(pinSchema)(raw);
|
|
84
|
+
if (pin.tenant_id !== scope.tenantId || pin.origin !== scope.origin)
|
|
85
|
+
return new Response("Not found", { status: 404 });
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
sql.exec("INSERT INTO oyster_mcp_tenant (id, tenant_id, origin) VALUES (1,?,?)", scope.tenantId, scope.origin);
|
|
89
|
+
}
|
|
90
|
+
if (this.#mcp === undefined) {
|
|
91
|
+
const host = this.#createHost(this.env, scope);
|
|
92
|
+
const persistence = {
|
|
93
|
+
eventStore: (id) => this.#eventStore(id),
|
|
94
|
+
initialized: (id, session) => {
|
|
95
|
+
sql.exec("INSERT INTO oyster_mcp_sessions (protocol_id, catalog_id, principal) VALUES (?,?,?)", id, session.id, JSON.stringify(session.principal));
|
|
96
|
+
return Promise.resolve();
|
|
97
|
+
},
|
|
98
|
+
closed: (id, session) => {
|
|
99
|
+
this.ctx.storage.transactionSync(() => {
|
|
100
|
+
sql.exec("DELETE FROM oyster_mcp_events WHERE catalog_id=?", session.id);
|
|
101
|
+
sql.exec("DELETE FROM oyster_mcp_sessions WHERE protocol_id=?", id);
|
|
102
|
+
});
|
|
103
|
+
return Promise.resolve();
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
this.#mcp = createMcp({
|
|
107
|
+
persistence,
|
|
108
|
+
catalog: createMcpCatalog({
|
|
109
|
+
load: oysterMcpCatalogLoad(host),
|
|
110
|
+
sandbox: createCodemodeSandbox(this.env.LOADER),
|
|
111
|
+
}),
|
|
112
|
+
authenticate: async (incoming) => {
|
|
113
|
+
const admission = await host.auth.authenticate(incoming);
|
|
114
|
+
return admission.kind === "refused"
|
|
115
|
+
? admission
|
|
116
|
+
: { kind: "admitted", principal: admission.principal };
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return this.#mcp.handleRequest(request);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Bind the owner to the deployment's existing host factory and options. */
|
|
124
|
+
export function createOysterMcpOwner(host) {
|
|
125
|
+
return class extends OysterMcpOwner {
|
|
126
|
+
constructor(ctx, env) {
|
|
127
|
+
super(ctx, env, host);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Frontends own no SDK state and cannot close another isolate's sessions. */
|
|
132
|
+
export function remoteOysterMcp(binding, scope) {
|
|
133
|
+
return {
|
|
134
|
+
async handleRequest(request) {
|
|
135
|
+
if (binding === undefined)
|
|
136
|
+
return new Response("MCP owner binding unavailable", { status: 503 });
|
|
137
|
+
const response = await binding
|
|
138
|
+
.getByName(scope.durableName("mcp"))
|
|
139
|
+
.serve(request, { tenantId: scope.tenantId, origin: scope.origin });
|
|
140
|
+
return response;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|