@fungi.computer/oyster 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -8
- package/dist/agents.d.ts +2 -1
- package/dist/agents.js +67 -8
- package/dist/apps/gate.d.ts +4 -0
- package/dist/apps/gate.js +3 -0
- package/dist/auth.d.ts +24 -1
- package/dist/auth.js +38 -0
- package/dist/boot-host-script.js +1 -1
- package/dist/build/candidate-cli.mjs +27 -5
- package/dist/channels/binding.d.ts +70 -0
- package/dist/channels/binding.js +97 -0
- package/dist/channels/conversations.d.ts +246 -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/channels/visitor-channel.d.ts +53 -0
- package/dist/channels/visitor-channel.js +124 -0
- package/dist/channels/visitor-edge.d.ts +115 -0
- package/dist/channels/visitor-edge.js +582 -0
- package/dist/channels/visitor-request.d.ts +7 -0
- package/dist/channels/visitor-request.js +17 -0
- package/dist/channels/visitor-stream-slots.d.ts +2 -0
- package/dist/channels/visitor-stream-slots.js +33 -0
- package/dist/channels/visitors.d.ts +47 -0
- package/dist/channels/visitors.js +181 -0
- package/dist/cloudflare.d.ts +8 -2
- package/dist/cloudflare.js +27 -1
- package/dist/computer-target.d.ts +5 -0
- package/dist/computer-target.js +11 -0
- package/dist/host.d.ts +7 -4
- package/dist/host.js +96 -17
- package/dist/internal/host-gate.d.ts +6 -4
- package/dist/internal/host-gate.js +14 -7
- package/dist/invocation.d.ts +11 -4
- package/dist/invocation.js +134 -91
- 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/capabilities.js +1 -1
- package/dist/page/child-selection.d.ts +27 -0
- package/dist/page/child-selection.js +38 -0
- package/dist/page/mount.js +1 -1
- package/dist/page-script.js +4 -4
- package/dist/release.d.ts +13 -0
- package/dist/release.js +10 -0
- package/dist/request-origin.d.ts +4 -0
- package/dist/request-origin.js +10 -0
- package/dist/standalone/agent-mail.js +4 -1
- package/dist/standalone/agents.d.ts +8 -0
- package/dist/standalone/agents.js +30 -11
- package/dist/standalone/channel-session-door.d.ts +4 -0
- package/dist/standalone/channel-session-door.js +72 -0
- package/dist/standalone/computer-fleet.js +7 -1
- package/dist/standalone/machine-link.d.ts +2 -0
- package/dist/standalone/machine-link.js +34 -1
- package/dist/standalone/tenancy.d.ts +3 -0
- package/dist/standalone/tenancy.js +7 -2
- package/dist/testing/capabilities.d.ts +2 -0
- package/dist/testing/capabilities.js +3 -1
- package/dist/turnstile.d.ts +27 -0
- package/dist/turnstile.js +51 -0
- package/dist/workers-git.js +21 -2
- package/dist/workers.d.ts +3 -0
- package/dist/workers.js +15 -12
- package/docs/api/agents.md +8 -4
- package/docs/api/apps.md +8 -4
- package/docs/api/auth.md +55 -0
- package/docs/api/browser.md +40 -16
- package/docs/api/build-contract.md +2 -2
- package/docs/api/cloudflare.md +26 -1
- package/docs/api/host.md +22 -13
- package/docs/api/invocation.md +7 -3
- package/docs/api/keys.md +25 -3
- package/docs/api/workers.md +38 -30
- package/docs/site/agent-quickstart.mdx +29 -2
- package/examples/standalone/wrangler.celld.jsonc +66 -0
- package/examples/standalone/wrangler.jsonc +3 -4
- package/package.json +53 -17
package/dist/release.d.ts
CHANGED
|
@@ -60,6 +60,19 @@ export type VerifiedRelease = Readonly<{
|
|
|
60
60
|
export declare function sha256(bytes: Uint8Array): Promise<string>;
|
|
61
61
|
/** Read one artifact and refuse it unless its bytes match `digest`. */
|
|
62
62
|
export declare function readVerified(store: ArtifactStore, artifactRef: ArtifactRef, digest: Sha256Digest): Promise<Uint8Array>;
|
|
63
|
+
/**
|
|
64
|
+
* Copies of verified artifacts near the reader, keyed by content digest. An
|
|
65
|
+
* entry holds only bytes that already matched its digest, so a hit is served
|
|
66
|
+
* without another store read or hash.
|
|
67
|
+
*/
|
|
68
|
+
export type VerifiedObjectCache = Readonly<{
|
|
69
|
+
/** The cached bytes for `digest`, or null on a miss. */
|
|
70
|
+
match: (digest: Sha256Digest) => Promise<ReadableStream<Uint8Array> | null>;
|
|
71
|
+
/** Keep verified bytes; an unstored write only costs the next reader a store read. */
|
|
72
|
+
put: (digest: Sha256Digest, bytes: Uint8Array) => Promise<"stored" | "unstored">;
|
|
73
|
+
}>;
|
|
74
|
+
/** {@link readVerified} through an optional digest-keyed cache it fills on a miss. */
|
|
75
|
+
export declare function readCachedVerified(store: ArtifactStore, cache: VerifiedObjectCache | undefined, artifactRef: ArtifactRef, digest: Sha256Digest): Promise<ReadableStream<Uint8Array> | Uint8Array<ArrayBuffer>>;
|
|
63
76
|
/** Decode an untrusted Release record, as an install request carries it. */
|
|
64
77
|
export declare function parseRelease(value: unknown): Release;
|
|
65
78
|
/**
|
package/dist/release.js
CHANGED
|
@@ -36,6 +36,16 @@ export async function readVerified(store, artifactRef, digest) {
|
|
|
36
36
|
throw new ReleaseError("digest_mismatch", `Digest mismatch ${artifactRef}`);
|
|
37
37
|
return bytes;
|
|
38
38
|
}
|
|
39
|
+
/** {@link readVerified} through an optional digest-keyed cache it fills on a miss. */
|
|
40
|
+
export async function readCachedVerified(store, cache, artifactRef, digest) {
|
|
41
|
+
const hit = await cache?.match(digest);
|
|
42
|
+
if (hit !== undefined && hit !== null)
|
|
43
|
+
return hit;
|
|
44
|
+
const bytes = await readVerified(store, artifactRef, digest);
|
|
45
|
+
await cache?.put(digest, bytes);
|
|
46
|
+
// A response body owns a plain ArrayBuffer, whatever view the store returned.
|
|
47
|
+
return new Uint8Array(bytes);
|
|
48
|
+
}
|
|
39
49
|
const decodeText = (bytes) => new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
|
|
40
50
|
/** Decode an untrusted Release record, as an install request carries it. */
|
|
41
51
|
export function parseRelease(value) {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Require the request's own Origin and refuse cross-site provenance. */
|
|
2
|
+
export declare function sameOriginRequest(request: Request): boolean;
|
|
3
|
+
/** Channel reads allow absent Origin but never conflicting or cross-site provenance. */
|
|
4
|
+
export declare function sameOriginReadRequest(request: Request): boolean;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Require the request's own Origin and refuse cross-site provenance. */
|
|
2
|
+
export function sameOriginRequest(request) {
|
|
3
|
+
return request.headers.has("origin") && sameOriginReadRequest(request);
|
|
4
|
+
}
|
|
5
|
+
/** Channel reads allow absent Origin but never conflicting or cross-site provenance. */
|
|
6
|
+
export function sameOriginReadRequest(request) {
|
|
7
|
+
const origin = request.headers.get("origin");
|
|
8
|
+
return (request.headers.get("sec-fetch-site") !== "cross-site" &&
|
|
9
|
+
(origin === null || origin === new URL(request.url).origin));
|
|
10
|
+
}
|
|
@@ -23,6 +23,7 @@ export function agentMail(options) {
|
|
|
23
23
|
if (tick === undefined)
|
|
24
24
|
throw new Error("Agent Post lane is not open");
|
|
25
25
|
await tick();
|
|
26
|
+
await mail.stageUndeliverableReplies();
|
|
26
27
|
};
|
|
27
28
|
const mail = new AgentMail({ ...options, post: runtime, drain });
|
|
28
29
|
const host = {
|
|
@@ -57,6 +58,8 @@ export function agentMail(options) {
|
|
|
57
58
|
};
|
|
58
59
|
return {
|
|
59
60
|
host,
|
|
60
|
-
deliver: (value) =>
|
|
61
|
+
deliver: (value) => {
|
|
62
|
+
return mail.send(value);
|
|
63
|
+
},
|
|
61
64
|
};
|
|
62
65
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type StandaloneTenancyEnv } from "./tenancy.js";
|
|
2
2
|
import type { PostEnvelope } from "@fungi.computer/carrier/post";
|
|
3
|
+
import { type ChannelConversations } from "../channels/conversations.js";
|
|
4
|
+
import type { ConversationBinding } from "../channels/binding.js";
|
|
3
5
|
import { type StandaloneMachineLinkBindings, type StandaloneMachineJoinResult } from "./machine-link.js";
|
|
4
6
|
import { type MachineAgentSettlementPrincipal, type MachineAgentHome, type MachineEnrollment, type MachineLinkCaller, type MachineLinkIdentity } from "../machine-link/index.js";
|
|
5
7
|
/** Workers AI binding surface used by the standalone provider adapter. */
|
|
@@ -78,6 +80,12 @@ export declare abstract class StandaloneAgentBase<Env extends StandaloneAgentEnv
|
|
|
78
80
|
#private;
|
|
79
81
|
/** The deployable root's host for this Agent's pinned tenant. */
|
|
80
82
|
protected abstract hostFor(scope: TenantScope): Promise<OysterHost>;
|
|
83
|
+
/**
|
|
84
|
+
* Compose Oyster's channel owner on this Agent's real store and Shiitake.
|
|
85
|
+
* A host must supply its own binding lookup after authenticating its edge;
|
|
86
|
+
* this does not install an inbound route or mint provider credentials.
|
|
87
|
+
*/
|
|
88
|
+
protected channelConversations(binding: (bindingId: string) => ConversationBinding | null): ChannelConversations;
|
|
81
89
|
private uiOwner;
|
|
82
90
|
private machineClient;
|
|
83
91
|
private computerSnapshot;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { normalizeModel, streamModel } from "@fungi.computer/mule/effect";
|
|
2
|
-
import { observeProviderStatus } from "@fungi.computer/mule/effect";
|
|
1
|
+
import { isRetryableProviderRejection, normalizeModel, observeProviderStatus, streamModel, } from "@fungi.computer/mule/effect";
|
|
3
2
|
import { standaloneTenancy, } from "./tenancy.js";
|
|
4
3
|
import { AcceptedSchema } from "@fungi.computer/shiitake/client";
|
|
5
4
|
import { createShiitakeClient } from "@fungi.computer/shiitake/client";
|
|
@@ -7,6 +6,8 @@ import { AgentCreateRefused, } from "@fungi.computer/shiitake/agent-control";
|
|
|
7
6
|
import { AgentDelegation, AgentDisclosureRefused, machineAgentCodecs, } from "@fungi.computer/shiitake/delegation";
|
|
8
7
|
import { FungiMessageMetaSchema } from "@fungi.computer/agar/agents";
|
|
9
8
|
import { agentMail } from "./agent-mail.js";
|
|
9
|
+
import { createChannelConversations, } from "../channels/conversations.js";
|
|
10
|
+
import { channelSessionDoor } from "./channel-session-door.js";
|
|
10
11
|
import { createStandaloneMachineEnrollment, standaloneMachineAgentHome, standaloneMachineLinkAuthority, } from "./machine-link.js";
|
|
11
12
|
import { createMachineLinkFences, MachineEnrollmentRefused, MachineLinkRefused, createMachineAgentClient, } from "../machine-link/index.js";
|
|
12
13
|
import { memberInboxItemSchema } from "@fungi.computer/puffball/inbox";
|
|
@@ -51,10 +52,10 @@ function observedModels(model, defaults) {
|
|
|
51
52
|
acquire: ({ signal }) => {
|
|
52
53
|
const retryableMessages = new WeakSet();
|
|
53
54
|
const safeFailures = new WeakMap();
|
|
54
|
-
const failure = (message,
|
|
55
|
+
const failure = (message, rejection) => {
|
|
55
56
|
// Pi can include raw response bodies in errorMessage. No provider
|
|
56
57
|
// prose enters a Session, retry journal, model context or diagnostics.
|
|
57
|
-
const safe = `Model request failed (${code}). Retry the request.`;
|
|
58
|
+
const safe = `Model request failed (${rejection?.code ?? "provider_rejected"}). Retry the request.`;
|
|
58
59
|
const terminal = {
|
|
59
60
|
role: "assistant",
|
|
60
61
|
content: [],
|
|
@@ -67,7 +68,7 @@ function observedModels(model, defaults) {
|
|
|
67
68
|
timestamp: message.timestamp,
|
|
68
69
|
};
|
|
69
70
|
safeFailures.set(terminal, safe);
|
|
70
|
-
if (
|
|
71
|
+
if (isRetryableProviderRejection(rejection))
|
|
71
72
|
retryableMessages.add(terminal);
|
|
72
73
|
return terminal;
|
|
73
74
|
};
|
|
@@ -90,7 +91,7 @@ function observedModels(model, defaults) {
|
|
|
90
91
|
});
|
|
91
92
|
for await (const event of response) {
|
|
92
93
|
if (event.type === "error") {
|
|
93
|
-
const terminal = failure(event.error, observed.rejection()
|
|
94
|
+
const terminal = failure(event.error, observed.rejection(event.error));
|
|
94
95
|
events.push({ ...event, error: terminal });
|
|
95
96
|
events.end(terminal);
|
|
96
97
|
return;
|
|
@@ -98,7 +99,7 @@ function observedModels(model, defaults) {
|
|
|
98
99
|
if (event.type === "done" &&
|
|
99
100
|
(event.message.stopReason === "error" ||
|
|
100
101
|
event.message.stopReason === "aborted")) {
|
|
101
|
-
const terminal = failure(event.message, observed.rejection()
|
|
102
|
+
const terminal = failure(event.message, observed.rejection(event.message));
|
|
102
103
|
events.push({ ...event, message: terminal });
|
|
103
104
|
events.end(terminal);
|
|
104
105
|
return;
|
|
@@ -107,7 +108,7 @@ function observedModels(model, defaults) {
|
|
|
107
108
|
}
|
|
108
109
|
const result = await response.result();
|
|
109
110
|
events.end(result.stopReason === "error" || result.stopReason === "aborted"
|
|
110
|
-
? failure(result, observed.rejection()
|
|
111
|
+
? failure(result, observed.rejection(result))
|
|
111
112
|
: result);
|
|
112
113
|
}
|
|
113
114
|
catch {
|
|
@@ -134,7 +135,7 @@ function observedModels(model, defaults) {
|
|
|
134
135
|
},
|
|
135
136
|
stopReason: signal.aborted ? "aborted" : "error",
|
|
136
137
|
timestamp: Date.now(),
|
|
137
|
-
}, "provider_unavailable");
|
|
138
|
+
}, { code: "provider_unavailable" });
|
|
138
139
|
events.push({
|
|
139
140
|
type: "error",
|
|
140
141
|
reason: signal.aborted ? "aborted" : "error",
|
|
@@ -228,6 +229,23 @@ const instructions = [
|
|
|
228
229
|
].join("\n");
|
|
229
230
|
/** One agent: Shiitake's Cloudflare host over this Durable Object; its root supplies `hostFor`. */
|
|
230
231
|
export class StandaloneAgentBase extends ShiitakeDO {
|
|
232
|
+
/**
|
|
233
|
+
* Compose Oyster's channel owner on this Agent's real store and Shiitake.
|
|
234
|
+
* A host must supply its own binding lookup after authenticating its edge;
|
|
235
|
+
* this does not install an inbound route or mint provider credentials.
|
|
236
|
+
*/
|
|
237
|
+
channelConversations(binding) {
|
|
238
|
+
const owner = createChannelConversations({
|
|
239
|
+
sql: this.ctx.storage.sql,
|
|
240
|
+
transactionSync: (body) => this.ctx.storage.transactionSync(body),
|
|
241
|
+
binding,
|
|
242
|
+
door: channelSessionDoor(() => this.shiitake(), () => {
|
|
243
|
+
// Standalone Agents have no Hub Team grants to deliver.
|
|
244
|
+
}, () => owner),
|
|
245
|
+
now: () => Date.now(),
|
|
246
|
+
});
|
|
247
|
+
return owner;
|
|
248
|
+
}
|
|
231
249
|
/** Each installed Release's verified catalog surface, built once per Release for this Agent's lifetime. */
|
|
232
250
|
#appSurfaces;
|
|
233
251
|
uiOwner;
|
|
@@ -479,10 +497,11 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
479
497
|
throw new Error("Delegated reply identity is unavailable");
|
|
480
498
|
if (mail.reply.outcome === "undeliverable")
|
|
481
499
|
return undefined;
|
|
482
|
-
if (mail.from.
|
|
483
|
-
throw new Error("Delegated reply
|
|
500
|
+
if (mail.from.kind !== "agent")
|
|
501
|
+
throw new Error("Delegated reply Agent identity is unavailable");
|
|
484
502
|
return (await this.machineClient()).settlement(mail.from.agentId, mail.from.sessionId, mail.reply.commandId, mail.to.agentId);
|
|
485
503
|
},
|
|
504
|
+
onUndeliverable: (original) => this.delegation().localUndeliverable(original),
|
|
486
505
|
wake: () => this.wakeRuntime(),
|
|
487
506
|
}));
|
|
488
507
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ShiitakeOwnerAgent } from "@fungi.computer/shiitake/cloudflare";
|
|
2
|
+
import type { ChannelConversations, SessionDoor } from "../channels/conversations.js";
|
|
3
|
+
/** Translate a standalone Agent's Shiitake owner into Oyster's channel Session port. */
|
|
4
|
+
export declare function channelSessionDoor(open: () => Promise<ShiitakeOwnerAgent>, deliverGrants: (sessionId: string) => void, conversations: () => ChannelConversations): SessionDoor;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { authorMeta } from "@fungi.computer/agar/agents";
|
|
2
|
+
import { ShiitakeError } from "@fungi.computer/shiitake";
|
|
3
|
+
import { participantSnapshot, visitorPendingPrompt, visitorSnapshot, } from "@fungi.computer/shiitake/client";
|
|
4
|
+
import { visitorStream } from "@fungi.computer/shiitake/server-visitor-stream";
|
|
5
|
+
/** Translate a standalone Agent's Shiitake owner into Oyster's channel Session port. */
|
|
6
|
+
export function channelSessionDoor(open, deliverGrants, conversations) {
|
|
7
|
+
const policy = () => conversations();
|
|
8
|
+
return {
|
|
9
|
+
createSession: async (requestId) => (await open()).createSession(requestId),
|
|
10
|
+
deliverGrants,
|
|
11
|
+
withdraw: async (sessionId, requestId) => (await open()).session(sessionId).withdraw(requestId),
|
|
12
|
+
latestPending: async (sessionId, visitor) => {
|
|
13
|
+
const prompt = await (await open())
|
|
14
|
+
.withMeta(authorMeta(visitor))
|
|
15
|
+
.session(sessionId)
|
|
16
|
+
.latestPending();
|
|
17
|
+
return prompt === null ? null : visitorPendingPrompt(prompt, visitor.id);
|
|
18
|
+
},
|
|
19
|
+
participantDocument: async (sessionId, visitorId, options) => {
|
|
20
|
+
const snapshot = await (await open()).session(sessionId).read(options);
|
|
21
|
+
return JSON.stringify(visitorSnapshot(participantSnapshot(snapshot, policy()), visitorId));
|
|
22
|
+
},
|
|
23
|
+
participantStream: async (sessionId, visitorId, options) => {
|
|
24
|
+
let watch;
|
|
25
|
+
try {
|
|
26
|
+
watch = await (await open()).session(sessionId).watch();
|
|
27
|
+
}
|
|
28
|
+
catch (cause) {
|
|
29
|
+
options.release();
|
|
30
|
+
throw cause;
|
|
31
|
+
}
|
|
32
|
+
return visitorStream({
|
|
33
|
+
...options,
|
|
34
|
+
watch,
|
|
35
|
+
visitorId,
|
|
36
|
+
filter: (snapshot) => participantSnapshot(snapshot, policy()),
|
|
37
|
+
read: async () => (await open()).session(sessionId).read(),
|
|
38
|
+
onError: (event, cause) => {
|
|
39
|
+
console.warn(event, cause);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
prompt: async (sessionId, { requestId, text, meta }) => {
|
|
44
|
+
// The conversation row exists now, so its channel admission is known;
|
|
45
|
+
// deliver Agent tools before the first run can call one.
|
|
46
|
+
deliverGrants(sessionId);
|
|
47
|
+
const agent = await open();
|
|
48
|
+
return await agent.withMeta(meta).session(sessionId).command({
|
|
49
|
+
type: "prompt",
|
|
50
|
+
requestId,
|
|
51
|
+
text,
|
|
52
|
+
delivery: "followUp",
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
line: async (sessionId, input) => {
|
|
56
|
+
const agent = await open();
|
|
57
|
+
const meta = input.author === "operator" ? authorMeta(input.identity) : input.meta;
|
|
58
|
+
await agent.withMeta(meta).session(sessionId).command({
|
|
59
|
+
type: "operator",
|
|
60
|
+
requestId: input.requestId,
|
|
61
|
+
author: input.author,
|
|
62
|
+
text: input.text,
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
errorKind: (error, requestId) => error instanceof ShiitakeError &&
|
|
66
|
+
error.boundary === "contract" &&
|
|
67
|
+
error.cause === "closed" &&
|
|
68
|
+
error.requestId === requestId
|
|
69
|
+
? "closed_request"
|
|
70
|
+
: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
+
import { endTime, startTime } from "hono/timing";
|
|
2
3
|
import { HTTPException } from "hono/http-exception";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { computerCreateRequestSchema, computerPageSchema, } from "@fungi.computer/computer/fleet";
|
|
5
6
|
import { oysterPrincipalIsOwner, } from "../host.js";
|
|
7
|
+
import { computerTargetParam } from "../computer-target.js";
|
|
6
8
|
/** Fleet controls never accept an actor from the request body. */
|
|
7
9
|
const fleetComputerId = z.string().min(1).max(256);
|
|
8
10
|
export const standaloneFleetRequestSchema = z.discriminatedUnion("operation", [
|
|
@@ -48,7 +50,7 @@ function fleetResponse(result) {
|
|
|
48
50
|
export async function standaloneComputerSelection(registry, incoming) {
|
|
49
51
|
if (incoming === undefined)
|
|
50
52
|
return registry.selectedComputer();
|
|
51
|
-
const requested = new URL(incoming.request.url).searchParams.getAll(
|
|
53
|
+
const requested = new URL(incoming.request.url).searchParams.getAll(computerTargetParam);
|
|
52
54
|
if (requested.length === 0)
|
|
53
55
|
return registry.selectedComputer();
|
|
54
56
|
const admission = await incoming.auth.authenticate(incoming.request);
|
|
@@ -91,16 +93,20 @@ export function standaloneComputerFleetRoutes(registryFor) {
|
|
|
91
93
|
if (!oysterPrincipalIsOwner(principal))
|
|
92
94
|
return context.json({ error: "Owner only" }, 403);
|
|
93
95
|
const registry = registryFor();
|
|
96
|
+
startTime(context, "fleet");
|
|
94
97
|
const admitted = await registry.computerFleet(principal.actor.memberId, {
|
|
95
98
|
operation: "list",
|
|
96
99
|
page: { limit: 1, offset: 0 },
|
|
97
100
|
});
|
|
101
|
+
endTime(context, "fleet");
|
|
98
102
|
if (admitted === null || "error" in admitted)
|
|
99
103
|
return fleetResponse(admitted);
|
|
104
|
+
startTime(context, "registry");
|
|
100
105
|
const [directory, computerId] = await Promise.all([
|
|
101
106
|
registry.directory(),
|
|
102
107
|
registry.selectedComputer(),
|
|
103
108
|
]);
|
|
109
|
+
endTime(context, "registry");
|
|
104
110
|
return context.json({
|
|
105
111
|
...directory,
|
|
106
112
|
computer: { computerId, name: computerId },
|
|
@@ -268,6 +268,8 @@ export declare class MachineLinkDO extends DurableObject<StandaloneMachineLinkEn
|
|
|
268
268
|
alarm(): Promise<void>;
|
|
269
269
|
fetch(request: Request): Promise<Response>;
|
|
270
270
|
deliver(id: MachineLinkIdentity, caller: MachineLinkCaller, agentId: string, envelope: PostEnvelope): Promise<PostDeliveryResult>;
|
|
271
|
+
deliverRelayDisposition(id: MachineLinkIdentity, relay: MachineLinkIdentity, original: PostEnvelope, reply: PostEnvelope): Promise<PostDeliveryResult>;
|
|
272
|
+
hasStagedDisposition(id: MachineLinkIdentity, original: PostEnvelope, reply: PostEnvelope): Promise<boolean>;
|
|
271
273
|
operation(id: MachineLinkIdentity, caller: MachineLinkCaller, agentId: string, operation: MachineAgentOperation): Promise<import("effect/Schema").Json>;
|
|
272
274
|
applyFence(fact: MachineLinkFenceFact): Promise<MachineLinkFenceResult>;
|
|
273
275
|
preflightErasure(teamId: string, deviceId: string): Promise<void>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { machineAgentReservations } from "@fungi.computer/shiitake/delegation";
|
|
1
|
+
import { machineAgentCodecs, machineAgentReservations, } from "@fungi.computer/shiitake/delegation";
|
|
2
2
|
import { createMachineEnrollment, MachineEnrollmentRefused, MachineLinkRefused, } from "../machine-link/index.js";
|
|
3
3
|
import { keyAllowsMachineEnroll, } from "../keys.js";
|
|
4
4
|
import { agentIds, createAgentDirectory, } from "@fungi.computer/shiitake/fleet";
|
|
@@ -8,6 +8,7 @@ import { bodyLimit } from "hono/body-limit";
|
|
|
8
8
|
import { validator } from "hono/validator";
|
|
9
9
|
import { machineEnrollmentSchema, machineLinkName, } from "../machine-link/index.js";
|
|
10
10
|
import { createMachineLink } from "../machine-link/workers.js";
|
|
11
|
+
import { deliverUndeliverableMail } from "../machine-link/delivery.js";
|
|
11
12
|
import { defaultTenant, tenantScope } from "../tenant.js";
|
|
12
13
|
import { presentedGitKey } from "../git-auth.js";
|
|
13
14
|
import { standaloneTenancy } from "./tenancy.js";
|
|
@@ -49,6 +50,7 @@ export function standaloneMachineLinkAuthority(env) {
|
|
|
49
50
|
return directory().machineDevice(id);
|
|
50
51
|
},
|
|
51
52
|
currentFence: async (id) => id.teamId !== teamId ? null : directory().machineFence(id),
|
|
53
|
+
agentHome: async (id, agentId) => id.teamId !== teamId ? null : directory().agentHome(agentId),
|
|
52
54
|
directory: async (id) => {
|
|
53
55
|
if (!(await authority.admitDevice(id)).live)
|
|
54
56
|
throw new MachineLinkRefused("actor_not_allowed");
|
|
@@ -69,6 +71,31 @@ export function standaloneMachineLinkAuthority(env) {
|
|
|
69
71
|
throw new MachineLinkRefused("actor_not_allowed");
|
|
70
72
|
return env.MACHINE_LINK.getByName(machineLinkName(next)).deliver(next, caller, facts.targetAgentId, envelope);
|
|
71
73
|
},
|
|
74
|
+
deliverUndeliverable: async (id, original, reply) => {
|
|
75
|
+
if ((await authority.currentFence(id)) === null)
|
|
76
|
+
return "refused";
|
|
77
|
+
const mail = original.event;
|
|
78
|
+
if (mail.from.kind !== "agent")
|
|
79
|
+
return "refused";
|
|
80
|
+
const home = await authority.agentHome(id, mail.from.agentId);
|
|
81
|
+
if (home === null)
|
|
82
|
+
throw new MachineLinkRefused("unavailable");
|
|
83
|
+
return await deliverUndeliverableMail({
|
|
84
|
+
identity: id,
|
|
85
|
+
original,
|
|
86
|
+
reply,
|
|
87
|
+
senderHome: home,
|
|
88
|
+
hosted: env.AGENTS,
|
|
89
|
+
accepted: machineAgentCodecs.accepted,
|
|
90
|
+
machine: async (deviceId, envelope) => {
|
|
91
|
+
const destination = { teamId, deviceId };
|
|
92
|
+
return await env.MACHINE_LINK.getByName(machineLinkName(destination)).deliverRelayDisposition(destination, id, original, envelope);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
verifyRelayDisposition: async (relay, original, reply) => {
|
|
97
|
+
return await env.MACHINE_LINK.getByName(machineLinkName(relay)).hasStagedDisposition(relay, original, reply);
|
|
98
|
+
},
|
|
72
99
|
canErase: async (id) => {
|
|
73
100
|
if (id.teamId !== teamId)
|
|
74
101
|
return false;
|
|
@@ -185,6 +212,12 @@ export class MachineLinkDO extends DurableObject {
|
|
|
185
212
|
deliver(id, caller, agentId, envelope) {
|
|
186
213
|
return this.link.deliver(id, caller, agentId, envelope);
|
|
187
214
|
}
|
|
215
|
+
deliverRelayDisposition(id, relay, original, reply) {
|
|
216
|
+
return this.link.deliverRelayDisposition(id, relay, original, reply);
|
|
217
|
+
}
|
|
218
|
+
hasStagedDisposition(id, original, reply) {
|
|
219
|
+
return this.link.hasStagedDisposition(id, original, reply);
|
|
220
|
+
}
|
|
188
221
|
operation(id, caller, agentId, operation) {
|
|
189
222
|
return this.link.operation(id, caller, agentId, operation);
|
|
190
223
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TenancyRegistryDO } from "../tenancy/cloudflare.js";
|
|
2
2
|
import { type Identity, type Tenant, type ReadonlyTenancy } from "../tenancy/index.js";
|
|
3
|
+
import { type OysterKeySessionResolver } from "../auth.js";
|
|
3
4
|
import type { OysterGitWorkerEnv } from "../workers-git.js";
|
|
4
5
|
import { type OysterTenancy, type TenantId } from "../tenant.js";
|
|
5
6
|
/** Trusted standalone provisioning inputs; credentials never enter the seed. */
|
|
@@ -28,6 +29,8 @@ export declare class StandaloneTenants extends TenancyRegistryDO {
|
|
|
28
29
|
/** The same trusted bindings are used by HTTP, MCP and cold root readers. */
|
|
29
30
|
export type StandaloneTenancyEnv = Pick<OysterGitWorkerEnv, "OYSTER_ORIGIN" | "OYSTER_OWNER_NAME" | "OYSTER_OWNER_KEY" | "KEYS"> & Readonly<{
|
|
30
31
|
TENANTS: DurableObjectNamespace<StandaloneTenants>;
|
|
32
|
+
/** Optional host-owned named-key browser sessions; Oyster mints none. */
|
|
33
|
+
OYSTER_KEY_SESSIONS?: OysterKeySessionResolver;
|
|
31
34
|
}>;
|
|
32
35
|
/** A live registry-owned owner; the configured label is never its member ID. */
|
|
33
36
|
export type StandaloneOwner = Readonly<{
|
|
@@ -2,7 +2,7 @@ import { TenancyRegistryDO, admittedRegistry, durableObjectRegistry, } from "../
|
|
|
2
2
|
import { createTenancy, } from "../tenancy/index.js";
|
|
3
3
|
import { ownerKey } from "../tenancy/owner-key.js";
|
|
4
4
|
import { hosts } from "../tenancy/resolve.js";
|
|
5
|
-
import { ownerOrKeyAuth } from "../auth.js";
|
|
5
|
+
import { keySessionAuth, ownerOrKeyAuth, } from "../auth.js";
|
|
6
6
|
import { HTTPException } from "hono/http-exception";
|
|
7
7
|
import { defaultTenant, tenantScope, } from "../tenant.js";
|
|
8
8
|
import * as Schema from "effect/Schema";
|
|
@@ -173,7 +173,12 @@ async function composeTenancy(env) {
|
|
|
173
173
|
async authenticate(request) {
|
|
174
174
|
if (owner === null)
|
|
175
175
|
return { kind: "refused", status: 403 };
|
|
176
|
-
const
|
|
176
|
+
const keys = () => env.KEYS.getByName(scope.durableName("keys"));
|
|
177
|
+
const namedKeyAuth = ownerOrKeyAuth(env.OYSTER_OWNER_KEY, owner.actor.memberId, keys);
|
|
178
|
+
const auth = env.OYSTER_KEY_SESSIONS === undefined
|
|
179
|
+
? namedKeyAuth
|
|
180
|
+
: keySessionAuth(namedKeyAuth, owner.actor.memberId, keys, env.OYSTER_KEY_SESSIONS);
|
|
181
|
+
const admission = await auth.authenticate(request);
|
|
177
182
|
// This entry admits only named keys; the owner key has its own entry.
|
|
178
183
|
return admission.kind === "admitted" &&
|
|
179
184
|
admission.principal.actor.kind === "team-key"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { type AppCapabilityId } from "@fungi.computer/puffball/app";
|
|
3
3
|
import { type OysterAgents } from "../agents.js";
|
|
4
|
+
import type { LiveOysterKey } from "../keys.js";
|
|
4
5
|
import { type FileSource } from "../files.js";
|
|
5
6
|
import { type OysterAuthResult, type OysterCapability } from "../host.js";
|
|
6
7
|
/** How one capability fixture host admits its single installation. */
|
|
@@ -19,6 +20,7 @@ export type CapabilityFixtureOptions = Readonly<{
|
|
|
19
20
|
visitor?: boolean;
|
|
20
21
|
authenticate?: (request: Request) => Promise<OysterAuthResult>;
|
|
21
22
|
agents?: OysterAgents;
|
|
23
|
+
liveKey?: (keyId: string) => Promise<LiveOysterKey | null>;
|
|
22
24
|
}>;
|
|
23
25
|
/** One Oyster host with a single installation whose capability grants the options shape. */
|
|
24
26
|
export declare function capabilityFixture(options?: CapabilityFixtureOptions): Promise<{
|
|
@@ -84,7 +84,7 @@ export async function capabilityFixture(options = {}) {
|
|
|
84
84
|
if (options.visitor)
|
|
85
85
|
installation = { ...installation, visibility: "public" };
|
|
86
86
|
const readOnly = () => Promise.reject(new Error("Fixture installations are read-only"));
|
|
87
|
-
|
|
87
|
+
let base = {
|
|
88
88
|
bootUi: () => Promise.resolve({ kind: "passed" }),
|
|
89
89
|
auth: {
|
|
90
90
|
resolve: () => Promise.reject(new Error("Capability fixture resolves no actor")),
|
|
@@ -116,6 +116,8 @@ export async function capabilityFixture(options = {}) {
|
|
|
116
116
|
},
|
|
117
117
|
capabilities,
|
|
118
118
|
};
|
|
119
|
+
if (options.liveKey !== undefined)
|
|
120
|
+
base = { ...base, keys: { live: options.liveKey } };
|
|
119
121
|
const host = options.agents === undefined
|
|
120
122
|
? base
|
|
121
123
|
: { ...base, ownerRoutes: agentApprovalRoutes(options.agents) };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Host-owned configuration inputs, decoded before any challenge is opened. */
|
|
2
|
+
export type TurnstileEnvironment = Readonly<{
|
|
3
|
+
TURNSTILE?: string;
|
|
4
|
+
TURNSTILE_SITE_KEY?: string;
|
|
5
|
+
TURNSTILE_SECRET_KEY?: string;
|
|
6
|
+
}>;
|
|
7
|
+
/** Explicit opt-out, complete verification credentials, or a closed door. */
|
|
8
|
+
export type TurnstileConfiguration = Readonly<{
|
|
9
|
+
kind: "disabled";
|
|
10
|
+
}> | Readonly<{
|
|
11
|
+
kind: "enabled";
|
|
12
|
+
siteKey: string;
|
|
13
|
+
secretKey: string;
|
|
14
|
+
}> | Readonly<{
|
|
15
|
+
kind: "misconfigured";
|
|
16
|
+
}>;
|
|
17
|
+
/** Retain the development default; HTTPS hosts without keys fail closed. */
|
|
18
|
+
export declare function readTurnstileConfiguration(environment: TurnstileEnvironment, origin: string | undefined): TurnstileConfiguration;
|
|
19
|
+
/** Verify with Cloudflare; timeout policy and safe failure reporting belong to the host. */
|
|
20
|
+
export declare function verifyTurnstileToken(input: {
|
|
21
|
+
secretKey: string;
|
|
22
|
+
token: string;
|
|
23
|
+
remoteIp?: string;
|
|
24
|
+
}, options: {
|
|
25
|
+
timeoutMs: number;
|
|
26
|
+
onError(cause: unknown): void;
|
|
27
|
+
}): Promise<"passed" | "failed" | "unavailable">;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const siteverifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
|
3
|
+
const environmentSchema = z.object({
|
|
4
|
+
TURNSTILE: z.literal("off").optional(),
|
|
5
|
+
TURNSTILE_SITE_KEY: z.string().trim().min(1).optional(),
|
|
6
|
+
TURNSTILE_SECRET_KEY: z.string().trim().min(1).optional(),
|
|
7
|
+
});
|
|
8
|
+
const responseSchema = z.object({ success: z.boolean() });
|
|
9
|
+
/** Retain the development default; HTTPS hosts without keys fail closed. */
|
|
10
|
+
export function readTurnstileConfiguration(environment, origin) {
|
|
11
|
+
const parsed = environmentSchema.safeParse(environment);
|
|
12
|
+
if (!parsed.success)
|
|
13
|
+
return { kind: "misconfigured" };
|
|
14
|
+
if (parsed.data.TURNSTILE === "off")
|
|
15
|
+
return { kind: "disabled" };
|
|
16
|
+
const { TURNSTILE_SITE_KEY: siteKey, TURNSTILE_SECRET_KEY: secretKey } = parsed.data;
|
|
17
|
+
if (siteKey === undefined && secretKey === undefined)
|
|
18
|
+
return URL.parse(origin ?? "")?.protocol === "https:"
|
|
19
|
+
? { kind: "misconfigured" }
|
|
20
|
+
: { kind: "disabled" };
|
|
21
|
+
if (siteKey === undefined || secretKey === undefined)
|
|
22
|
+
return { kind: "misconfigured" };
|
|
23
|
+
return { kind: "enabled", siteKey, secretKey };
|
|
24
|
+
}
|
|
25
|
+
/** Verify with Cloudflare; timeout policy and safe failure reporting belong to the host. */
|
|
26
|
+
export async function verifyTurnstileToken(input, options) {
|
|
27
|
+
try {
|
|
28
|
+
const body = {
|
|
29
|
+
secret: input.secretKey,
|
|
30
|
+
response: input.token,
|
|
31
|
+
};
|
|
32
|
+
if (input.remoteIp !== undefined)
|
|
33
|
+
body.remoteip = input.remoteIp;
|
|
34
|
+
const response = await fetch(siteverifyUrl, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: { "Content-Type": "application/json" },
|
|
37
|
+
body: JSON.stringify(body),
|
|
38
|
+
signal: AbortSignal.timeout(options.timeoutMs),
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok)
|
|
41
|
+
return "unavailable";
|
|
42
|
+
const verdict = responseSchema.safeParse(await response.json());
|
|
43
|
+
if (!verdict.success)
|
|
44
|
+
return "unavailable";
|
|
45
|
+
return verdict.data.success ? "passed" : "failed";
|
|
46
|
+
}
|
|
47
|
+
catch (cause) {
|
|
48
|
+
options.onError(cause);
|
|
49
|
+
return "unavailable";
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/workers-git.js
CHANGED
|
@@ -889,6 +889,7 @@ export function createOysterGitWorker(root) {
|
|
|
889
889
|
hosts = new Map();
|
|
890
890
|
environments.set(env, hosts);
|
|
891
891
|
}
|
|
892
|
+
const started = Date.now();
|
|
892
893
|
let resolved;
|
|
893
894
|
try {
|
|
894
895
|
resolved = await root.admit(env, request);
|
|
@@ -907,6 +908,7 @@ export function createOysterGitWorker(root) {
|
|
|
907
908
|
const boot = await serveOysterBootCapability(request, env, scope);
|
|
908
909
|
if (boot !== null)
|
|
909
910
|
return boot;
|
|
911
|
+
const admitted = Date.now();
|
|
910
912
|
let host;
|
|
911
913
|
try {
|
|
912
914
|
host = await root.hostFor(env, scope, request);
|
|
@@ -916,6 +918,7 @@ export function createOysterGitWorker(root) {
|
|
|
916
918
|
return error.getResponse();
|
|
917
919
|
throw error;
|
|
918
920
|
}
|
|
921
|
+
const hosted = Date.now();
|
|
919
922
|
const key = `${scope.tenantId}:${scope.origin}:${host.computerId ?? ""}`;
|
|
920
923
|
let entry = hosts.get(key);
|
|
921
924
|
let cache = true;
|
|
@@ -938,7 +941,10 @@ export function createOysterGitWorker(root) {
|
|
|
938
941
|
}
|
|
939
942
|
entry.active += 1;
|
|
940
943
|
try {
|
|
941
|
-
return await entry.app.fetch(request, env, ctx)
|
|
944
|
+
return withStageTimings(await entry.app.fetch(request, env, ctx), {
|
|
945
|
+
admit: admitted - started,
|
|
946
|
+
host: hosted - admitted,
|
|
947
|
+
});
|
|
942
948
|
}
|
|
943
949
|
finally {
|
|
944
950
|
entry.active -= 1;
|
|
@@ -946,6 +952,19 @@ export function createOysterGitWorker(root) {
|
|
|
946
952
|
},
|
|
947
953
|
};
|
|
948
954
|
}
|
|
955
|
+
/**
|
|
956
|
+
* Add the root's own stages (tenant admission, host selection) to the App
|
|
957
|
+
* server's Server-Timing, so a slow request names the hop it waited on. An
|
|
958
|
+
* upgraded socket keeps its response untouched.
|
|
959
|
+
*/
|
|
960
|
+
function withStageTimings(response, stages) {
|
|
961
|
+
if (response.status === 101)
|
|
962
|
+
return response;
|
|
963
|
+
const timed = new Response(response.body, response);
|
|
964
|
+
for (const [name, duration] of Object.entries(stages))
|
|
965
|
+
timed.headers.append("Server-Timing", `${name};dur=${String(duration)}`);
|
|
966
|
+
return timed;
|
|
967
|
+
}
|
|
949
968
|
/**
|
|
950
969
|
* `host.git` for an Oyster Worker: the git door's writer projection and the
|
|
951
970
|
* admitted, installation-scoped Artifacts path with the App's push policy and
|
|
@@ -1117,7 +1136,7 @@ export async function oysterGitWorkerHost(env, options, selected, request) {
|
|
|
1117
1136
|
const base = oysterWorkerHost(env, auth, policyOptions);
|
|
1118
1137
|
const capabilities = [
|
|
1119
1138
|
...(base.capabilities ?? []),
|
|
1120
|
-
...(agents === undefined ? [] : agentCapabilities(agents)),
|
|
1139
|
+
...(agents === undefined ? [] : agentCapabilities(agents, keysLive)),
|
|
1121
1140
|
...(options.capabilities?.(env, workspace, keysLive) ?? []),
|
|
1122
1141
|
];
|
|
1123
1142
|
let gitOptions = { scope };
|
package/dist/workers.d.ts
CHANGED
|
@@ -138,6 +138,7 @@ export declare class OysterRegistry<Env extends OysterWorkerEnv = OysterWorkerEn
|
|
|
138
138
|
};
|
|
139
139
|
readonly reversibility: "reversible" | "irreversible-external";
|
|
140
140
|
readonly requestedAt: number;
|
|
141
|
+
readonly denialNote?: string | undefined;
|
|
141
142
|
readonly evidence?: readonly {
|
|
142
143
|
readonly kind: "artifact";
|
|
143
144
|
readonly ref: string;
|
|
@@ -155,6 +156,7 @@ export declare class OysterRegistry<Env extends OysterWorkerEnv = OysterWorkerEn
|
|
|
155
156
|
}>, actor: HostActor, input: ApprovalAnswerInput): Promise<Readonly<{
|
|
156
157
|
kind: "refused";
|
|
157
158
|
reason: "pending" | "expired" | "not-found" | "not-eligible" | "already-answered" | "stale-revision" | "declined" | "fenced" | "not-granted" | "read-only" | "in-flight" | "outcome-unknown" | "identity-conflict";
|
|
159
|
+
denialNote?: string;
|
|
158
160
|
}> | Readonly<{
|
|
159
161
|
kind: "answered";
|
|
160
162
|
view: import("@fungi.computer/approvals").ApprovalView;
|
|
@@ -166,6 +168,7 @@ export declare class OysterRegistry<Env extends OysterWorkerEnv = OysterWorkerEn
|
|
|
166
168
|
}>, actor: HostActor, input: AutoApproveInput): Promise<Readonly<{
|
|
167
169
|
kind: "refused";
|
|
168
170
|
reason: "pending" | "expired" | "not-found" | "not-eligible" | "already-answered" | "stale-revision" | "declined" | "fenced" | "not-granted" | "read-only" | "in-flight" | "outcome-unknown" | "identity-conflict";
|
|
171
|
+
denialNote?: string;
|
|
169
172
|
}> | Readonly<{
|
|
170
173
|
kind: "updated";
|
|
171
174
|
rule: AutoApproveInput;
|