@fungi.computer/oyster 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +46 -8
  2. package/dist/agents.d.ts +2 -1
  3. package/dist/agents.js +67 -8
  4. package/dist/apps/gate.d.ts +4 -0
  5. package/dist/apps/gate.js +3 -0
  6. package/dist/auth.d.ts +24 -1
  7. package/dist/auth.js +38 -0
  8. package/dist/boot-host-script.js +1 -1
  9. package/dist/build/candidate-cli.mjs +27 -5
  10. package/dist/channels/binding.d.ts +70 -0
  11. package/dist/channels/binding.js +97 -0
  12. package/dist/channels/conversations.d.ts +246 -0
  13. package/dist/channels/conversations.js +705 -0
  14. package/dist/channels/ids.d.ts +8 -0
  15. package/dist/channels/ids.js +15 -0
  16. package/dist/channels/visitor-channel.d.ts +53 -0
  17. package/dist/channels/visitor-channel.js +124 -0
  18. package/dist/channels/visitor-edge.d.ts +115 -0
  19. package/dist/channels/visitor-edge.js +582 -0
  20. package/dist/channels/visitor-request.d.ts +7 -0
  21. package/dist/channels/visitor-request.js +17 -0
  22. package/dist/channels/visitor-stream-slots.d.ts +2 -0
  23. package/dist/channels/visitor-stream-slots.js +33 -0
  24. package/dist/channels/visitors.d.ts +47 -0
  25. package/dist/channels/visitors.js +181 -0
  26. package/dist/cloudflare.d.ts +8 -2
  27. package/dist/cloudflare.js +27 -1
  28. package/dist/computer-target.d.ts +5 -0
  29. package/dist/computer-target.js +11 -0
  30. package/dist/host.d.ts +7 -4
  31. package/dist/host.js +96 -17
  32. package/dist/internal/host-gate.d.ts +6 -4
  33. package/dist/internal/host-gate.js +14 -7
  34. package/dist/invocation.d.ts +11 -4
  35. package/dist/invocation.js +134 -91
  36. package/dist/keys.d.ts +4 -1
  37. package/dist/keys.js +15 -4
  38. package/dist/machine-link/authority.d.ts +7 -0
  39. package/dist/machine-link/delivery.d.ts +12 -0
  40. package/dist/machine-link/delivery.js +22 -1
  41. package/dist/machine-link/disposition.d.ts +16 -0
  42. package/dist/machine-link/disposition.js +65 -0
  43. package/dist/machine-link/host.d.ts +11 -2
  44. package/dist/machine-link/host.js +125 -5
  45. package/dist/machine-link/link.d.ts +4 -0
  46. package/dist/machine-link/link.js +47 -1
  47. package/dist/page/capabilities.js +1 -1
  48. package/dist/page/child-selection.d.ts +27 -0
  49. package/dist/page/child-selection.js +38 -0
  50. package/dist/page/mount.js +1 -1
  51. package/dist/page-script.js +4 -4
  52. package/dist/release.d.ts +13 -0
  53. package/dist/release.js +10 -0
  54. package/dist/request-origin.d.ts +4 -0
  55. package/dist/request-origin.js +10 -0
  56. package/dist/standalone/agent-mail.js +4 -1
  57. package/dist/standalone/agents.d.ts +8 -0
  58. package/dist/standalone/agents.js +30 -11
  59. package/dist/standalone/channel-session-door.d.ts +4 -0
  60. package/dist/standalone/channel-session-door.js +72 -0
  61. package/dist/standalone/computer-fleet.js +7 -1
  62. package/dist/standalone/machine-link.d.ts +2 -0
  63. package/dist/standalone/machine-link.js +34 -1
  64. package/dist/standalone/tenancy.d.ts +3 -0
  65. package/dist/standalone/tenancy.js +7 -2
  66. package/dist/testing/capabilities.d.ts +2 -0
  67. package/dist/testing/capabilities.js +3 -1
  68. package/dist/turnstile.d.ts +27 -0
  69. package/dist/turnstile.js +51 -0
  70. package/dist/workers-git.js +21 -2
  71. package/dist/workers.d.ts +3 -0
  72. package/dist/workers.js +15 -12
  73. package/docs/api/agents.md +8 -4
  74. package/docs/api/apps.md +8 -4
  75. package/docs/api/auth.md +55 -0
  76. package/docs/api/browser.md +40 -16
  77. package/docs/api/build-contract.md +2 -2
  78. package/docs/api/cloudflare.md +26 -1
  79. package/docs/api/host.md +22 -13
  80. package/docs/api/invocation.md +7 -3
  81. package/docs/api/keys.md +25 -3
  82. package/docs/api/workers.md +38 -30
  83. package/docs/site/agent-quickstart.mdx +29 -2
  84. package/examples/standalone/wrangler.celld.jsonc +66 -0
  85. package/examples/standalone/wrangler.jsonc +3 -4
  86. package/package.json +53 -17
package/README.md CHANGED
@@ -62,6 +62,10 @@ Workers AI, Workflows and media.
62
62
 
63
63
  ## Quick start: deploy your own Oyster
64
64
 
65
+ Deciding what to run?
66
+ [Is Oyster for you?](https://docs.fungi.computer/is-oyster-for-you/) compares
67
+ agent Computers and App builds with Cloudflare Workers Builds.
68
+
65
69
  You need:
66
70
 
67
71
  - a Cloudflare account on Workers Paid, with Containers, Artifacts, Workers AI,
@@ -69,11 +73,12 @@ You need:
69
73
  - Docker, which Wrangler uses to build the Computer's container image;
70
74
  - Node 24, pnpm 11 or newer and Git.
71
75
 
72
- The first install takes about 15 to 20 minutes, most of it the seven App builds
73
- on the Computer, one after another. Start a project, add Oyster, its Computer
74
- and Wrangler, and copy the example Worker out of the package. The local pnpm
75
- policy allows esbuild and workerd to install their native binaries and disables
76
- unneeded optional dependency scripts, so a fresh install exits successfully:
76
+ The first install takes about 15 to 20 minutes, mostly building the seven Apps
77
+ on the Computer. By default, Oyster builds two Apps at a time. Start a project,
78
+ add Oyster, its Computer and Wrangler, and copy the example Worker out of the
79
+ package. The local pnpm policy allows esbuild and workerd to install their
80
+ native binaries and disables unneeded optional dependency scripts, so a fresh
81
+ install exits successfully:
77
82
 
78
83
  ```sh
79
84
  mkdir my-oyster && cd my-oyster
@@ -462,10 +467,43 @@ the Computer. `examples/standalone/wrangler.jsonc` declares every binding it
462
467
  uses. Set the owner key as a secret (`OYSTER_OWNER_KEY`, 24+ characters) and
463
468
  deploy with `wrangler deploy`. Sign in at `/login`.
464
469
 
470
+ Hosts may supply the optional `OYSTER_KEY_SESSIONS` binding, implementing
471
+ `OysterKeySessionResolver` from `@fungi.computer/oyster/auth`. Its
472
+ `resolve(value)` returns `{ keyId }` for a live host-owned session, or `null`.
473
+ The host mints and expires opaque sessions and sets the `keySessionCookieName`
474
+ cookie; it is not a named key's bearer secret. Hosts should use `Path=/`,
475
+ `HttpOnly`, `Secure` and `SameSite=Lax` (or stricter) as defense in depth.
476
+ Oyster independently checks request provenance. Cookie-only GET/HEAD navigation
477
+ is allowed. Other methods require `Origin` to be present and equal the request
478
+ URL's origin, and refuse `Sec-Fetch-Site: cross-site`. Missing `Origin` is
479
+ refused, using the same strict predicate as the channel's POST routes. Oyster
480
+ stores no browser sessions. `keySessionAuth` wraps ordinary `ownerOrKeyAuth` at
481
+ the standalone tenancy composition root. Each cookie admission reopens the live
482
+ key and checks its canonical owner; every authenticated door still uses that
483
+ key's current grants. App pages refuse keys without App access, even when the
484
+ App is public to visitors. Explicit header authentication is unchanged, and a
485
+ failed Authorization header cannot fall back to a named-key cookie. The owner
486
+ cookie and owner-only management API are unchanged; without the binding
487
+ named-key cookies admit nobody.
488
+
489
+ The celld example is a limited trial, not the full Cloudflare setup above. It
490
+ requires access to Fungi's private celld fork and Botanical source repository.
491
+ Use `examples/standalone/wrangler.celld.jsonc` beside `worker.ts`, not the
492
+ Cloudflare-only config. The published `0.5.0` tarball does not yet contain this
493
+ celld example; copy it from your authorized Botanical source checkout until a
494
+ package including it is released.
495
+
465
496
  The layers it is built from stay exported for hosts that compose their own:
466
- `@fungi.computer/oyster/workers` (Releases, Instances, MCP and App storage) and
467
- `@fungi.computer/oyster/workers-git` (the git door), with `oysterAppMethods`
468
- supplying the installed App catalog to an Agent runtime.
497
+ `@fungi.computer/oyster/turnstile` owns Cloudflare challenge configuration and
498
+ token verification. Hosts supply their origin, verification timeout and safe
499
+ error reporter; they retain their own challenged routes and HTTP responses.
500
+ Configuration keeps the explicit `TURNSTILE=off` opt-out and fails closed on
501
+ HTTPS hosts without both keys. Hub retains its five-second verification policy.
502
+
503
+ Other host building blocks: `@fungi.computer/oyster/workers` (Releases,
504
+ Instances, MCP and App storage) and `@fungi.computer/oyster/workers-git` (the
505
+ git door), with `oysterAppMethods` supplying the installed App catalog to an
506
+ Agent runtime.
469
507
 
470
508
  Pack and install an App with `@fungi.computer/oyster/pack`: `packRelease` turns
471
509
  an exact Git commit and a canonical build recipe into a Release with immutable
package/dist/agents.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * transport (`/browser/…`), served by the adapter behind the same App grants.
8
8
  */
9
9
  import { Hono } from "hono";
10
+ import { type LiveOysterKey } from "./keys.js";
10
11
  import type { AppToolPolicy } from "./apps/tools.js";
11
12
  import * as Schema from "effect/Schema";
12
13
  import { type OysterCapability, type OysterEnv, type OysterPrincipal } from "./host.js";
@@ -125,7 +126,7 @@ export type OysterAgents = Readonly<{
125
126
  visit(agentId: string, sessionId: string): Promise<boolean>;
126
127
  }>;
127
128
  /** Agent adapters admitted against each App's declared capabilities. */
128
- export declare function agentCapabilities(agents: OysterAgents): readonly OysterCapability[];
129
+ export declare function agentCapabilities(agents: OysterAgents, liveKey: (keyId: string) => Promise<LiveOysterKey | null>): readonly OysterCapability[];
129
130
  /** Host-owned direct approval under the admitted owner; never an App capability. */
130
131
  export declare function agentApprovalRoutes(agents: OysterAgents): Hono<OysterEnv>;
131
132
  /**
package/dist/agents.js CHANGED
@@ -7,10 +7,12 @@
7
7
  * transport (`/browser/…`), served by the adapter behind the same App grants.
8
8
  */
9
9
  import { Hono } from "hono";
10
+ import { keyAgentAccess } from "./keys.js";
10
11
  import * as Schema from "effect/Schema";
11
12
  import { z } from "zod";
12
13
  import { oysterPrincipalIsOwner, } from "./host.js";
13
14
  import { agentAvatarSchema } from "./agent-avatars.js";
15
+ import { computerTargetParam } from "./computer-target.js";
14
16
  import { agentAvatar, agentList, agentManage, agentRename, agentSessionRead, agentSessionWrite, agentToolPolicy, } from "@fungi.computer/agar/agents";
15
17
  /** At most this many MCP servers per Agent. */
16
18
  export const maxAgentMcpServers = 16;
@@ -51,8 +53,8 @@ function refusal(status, error) {
51
53
  return Response.json({ error }, { status });
52
54
  }
53
55
  /** Agent adapters admitted against each App's declared capabilities. */
54
- export function agentCapabilities(agents) {
55
- const routes = createAgentRoutes(agents);
56
+ export function agentCapabilities(agents, liveKey) {
57
+ const routes = createAgentRoutes(agents, liveKey);
56
58
  const shared = { base: "/", routes };
57
59
  return [
58
60
  {
@@ -134,9 +136,41 @@ export function agentCapabilities(agents) {
134
136
  },
135
137
  ];
136
138
  }
137
- function createAgentRoutes(agents) {
139
+ function createAgentRoutes(agents, liveKey) {
138
140
  const routes = new Hono();
139
- routes.get("/agents", async () => Response.json(await agents.list()));
141
+ // Every Agent and Session lookup shares this live principal-scoped door.
142
+ const access = async (principal, agentId) => {
143
+ if (oysterPrincipalIsOwner(principal))
144
+ return true;
145
+ if (principal.actor.kind !== "team-key")
146
+ return false;
147
+ const key = await liveKey(principal.actor.keyId);
148
+ return key !== null && keyAgentAccess(key, agentId);
149
+ };
150
+ routes.use("/agents/:agentId/*", async (context, next) => {
151
+ if (!(await access(context.env.principal, context.req.param("agentId"))))
152
+ return refusal(403, "Agent access denied");
153
+ await next();
154
+ return undefined;
155
+ });
156
+ routes.use("/agent-policies/:agentId", async (context, next) => {
157
+ if (!(await access(context.env.principal, context.req.param("agentId"))))
158
+ return refusal(403, "Agent access denied");
159
+ await next();
160
+ return undefined;
161
+ });
162
+ routes.get("/agents", async (context) => {
163
+ const listed = await agents.list();
164
+ const actor = context.env.principal.actor;
165
+ if (oysterPrincipalIsOwner(context.env.principal))
166
+ return Response.json(listed);
167
+ if (actor.kind !== "team-key")
168
+ return Response.json([]);
169
+ const key = await liveKey(actor.keyId);
170
+ return Response.json(key === null
171
+ ? []
172
+ : listed.filter((agent) => keyAgentAccess(key, agent.agentId)));
173
+ });
140
174
  routes.post("/agents", async (context) => {
141
175
  const { principal } = context.env;
142
176
  if (!oysterPrincipalIsOwner(principal))
@@ -213,22 +247,43 @@ function createAgentRoutes(agents) {
213
247
  if (length > maxAgentRequestBytes)
214
248
  return refusal(413, "Request too large");
215
249
  const target = new URL(`https://agent.internal${path}${url.search}`);
250
+ // A desk window's Computer routing hint is the host's to admit; the
251
+ // Agent's Session routes decode a closed query that refuses it.
252
+ target.searchParams.delete(computerTargetParam);
216
253
  const response = await agents.fetch(agentId, new Request(target, incoming));
217
254
  return response ?? refusal(404, "No such agent");
218
255
  });
219
- routes.get("/member/inbox", async () => {
256
+ routes.get("/member/inbox", async (context) => {
220
257
  const inbox = await agents.inbox();
258
+ const principal = context.env.principal;
259
+ if (oysterPrincipalIsOwner(principal))
260
+ return Response.json({
261
+ items: inbox.items,
262
+ next: null,
263
+ cursor: inbox.revision,
264
+ });
265
+ if (principal.actor.kind !== "team-key")
266
+ return refusal(403, "Agent access denied");
267
+ const key = await liveKey(principal.actor.keyId);
221
268
  return Response.json({
222
- items: inbox.items,
269
+ items: key === null
270
+ ? []
271
+ : inbox.items.filter((item) => "agentId" in item && keyAgentAccess(key, item.agentId)),
223
272
  next: null,
224
- cursor: inbox.revision,
273
+ // The global revision changes for ungranted Agents too. Key feeds have
274
+ // no scoped event stream, so expose neither that revision nor its events.
275
+ cursor: 0,
225
276
  });
226
277
  });
227
- routes.get("/member/inbox/notices", () => Response.json({ items: [], next: null }));
278
+ routes.get("/member/inbox/notices", (context) => oysterPrincipalIsOwner(context.env.principal)
279
+ ? Response.json({ items: [], next: null })
280
+ : refusal(403, "Agent access denied"));
228
281
  routes.post("/member/inbox/visits", async (context) => {
229
282
  const body = Schema.decodeUnknownOption(visitSchema)(await context.req.json());
230
283
  if (body._tag === "None")
231
284
  return refusal(400, "Invalid visit");
285
+ if (!(await access(context.env.principal, body.value.agentId)))
286
+ return refusal(403, "Agent access denied");
232
287
  return (await agents.visit(body.value.agentId, body.value.sessionId))
233
288
  ? new Response(null, { status: 204 })
234
289
  : refusal(404, "No such Session");
@@ -236,6 +291,10 @@ function createAgentRoutes(agents) {
236
291
  // The feed is small (one person's agents), so a change is announced as a
237
292
  // `reset` and the client re-reads the whole inbox.
238
293
  routes.get("/member/inbox/events", (context) => {
294
+ // The unscoped revision also changes for other Agents. Do not expose it
295
+ // until a principal-scoped revision exists.
296
+ if (!oysterPrincipalIsOwner(context.env.principal))
297
+ return refusal(403, "Agent access denied");
239
298
  const after = Number(context.req.query("after") ?? "0");
240
299
  const signal = context.req.raw.signal;
241
300
  const encoder = new TextEncoder();
@@ -36,6 +36,10 @@ export declare const gateRequestSchema: z.ZodLazy<z.ZodDiscriminatedUnion<[z.Zod
36
36
  kind: z.ZodLiteral<"capability-route">;
37
37
  capability: z.ZodPipe<z.ZodUnknown, z.ZodTransform<"approvals" | "agent.list" | "agent.manage" | "agent.rename" | "agent.avatar" | "agent.toolPolicy" | "agent.read" | "agent.send" | "computer.list" | "computer.readFile" | "computer.writeFile" | "computer.execute" | "llm.generation" | "media.generation" | "games:servers" | "git.repository" | "apps.open" | "apps.call" | "app.storage" | "app.events.emit" | "team.workbenchHost" | "marketplace.browse" | "marketplace.purchase" | "workflow.run" | "workflow.publish" | "workflow.arm" | "world" | "channels.read" | "channels.personal.manage", unknown>>;
38
38
  operation: z.ZodString;
39
+ effect: z.ZodEnum<{
40
+ read: "read";
41
+ write: "write";
42
+ }>;
39
43
  }, z.core.$strict>, z.ZodObject<{
40
44
  kind: z.ZodLiteral<"binding">;
41
45
  binding: z.ZodPipe<z.ZodUnknown, z.ZodTransform<"BACKGROUND" | "GIT" | "EVENTS" | "APPS" | "WORKFLOWS" | "APP_STORAGE" | "MEDIA" | "COMPUTER" | "INBOX" | "AGENTS", unknown>>;
package/dist/apps/gate.js CHANGED
@@ -26,6 +26,7 @@ export const gateRequestSchema = z.lazy(() => z.discriminatedUnion("kind", [
26
26
  kind: z.literal("capability-route"),
27
27
  capability: ownerCodec(appCapabilityIdSchema),
28
28
  operation: z.string().min(1),
29
+ effect: z.enum(["read", "write"]),
29
30
  }),
30
31
  z.strictObject({
31
32
  kind: z.literal("binding"),
@@ -259,6 +260,8 @@ export function admits(facts, rawRequest) {
259
260
  case "capability-route": {
260
261
  if (visitor)
261
262
  return refusal("visitor_read_only");
263
+ if (request.effect === "write" && facts.access?.level === "read")
264
+ return refusal("level_too_low");
262
265
  if (!request.operation.startsWith("route:") ||
263
266
  !facts.declaration.capabilities.includes(request.capability) ||
264
267
  facts.declaredOperations.capabilities
package/dist/auth.d.ts CHANGED
@@ -1,7 +1,24 @@
1
- import type { OysterAuth } from "./host.js";
1
+ import type { OysterAuth, OysterHost } from "./host.js";
2
2
  import type { OysterKeyStore } from "./keys.js";
3
3
  /** Cookie emitted by ownerKeyAuth, for composing credential-specific adapters. */
4
4
  export declare const ownerKeyCookieName = "oyster_session";
5
+ /** Host-minted opaque sessions for named keys, separate from the owner cookie. */
6
+ export declare const keySessionCookieName = "oyster_key_session";
7
+ /**
8
+ * The host owns session minting, hashing/storage and expiry.
9
+ * A session resolves only to a key id, never to owner or grant authority.
10
+ * Hosts should use Path=/, HttpOnly, Secure and SameSite=Lax (or stricter)
11
+ * as defense in depth; Oyster independently checks request provenance.
12
+ * Cookie-only admission allows GET/HEAD navigation. Every other method requires
13
+ * Origin to be present and equal the request URL's origin, and Sec-Fetch-Site
14
+ * not to be cross-site. Missing Origin is refused.
15
+ */
16
+ export type OysterKeySessionResolver = Readonly<{
17
+ /** Resolve an opaque host session to its named key, or null when unavailable. */
18
+ resolve: (value: string) => Promise<Readonly<{
19
+ keyId: string;
20
+ }> | null>;
21
+ }>;
5
22
  /**
6
23
  * Single-owner sign-in: a bearer token or a session cookie holding the
7
24
  * owner key's digest. The key itself is never stored in the browser.
@@ -9,3 +26,9 @@ export declare const ownerKeyCookieName = "oyster_session";
9
26
  export declare function ownerKeyAuth(ownerKey: string, ownerId?: string): OysterAuth;
10
27
  /** The owner key, or a named key admitted as its canonical Team-key actor. */
11
28
  export declare function ownerOrKeyAuth(ownerKey: string, ownerId: string, keys: () => Pick<OysterKeyStore, "verify">): OysterAuth;
29
+ /**
30
+ * Add host-owned browser sessions to ownerOrKeyAuth. Explicit credentials and
31
+ * non-401 refusals keep their ordinary result; ambient cookies cannot undo them.
32
+ * Every cookie admission rechecks the key's liveness and canonical owner.
33
+ */
34
+ export declare function keySessionAuth(auth: OysterAuth, ownerId: string, keys: () => NonNullable<OysterHost["keys"]>, sessions: OysterKeySessionResolver): OysterAuth;
package/dist/auth.js CHANGED
@@ -1,9 +1,13 @@
1
1
  import * as Option from "effect/Option";
2
2
  import * as Schema from "effect/Schema";
3
+ import { parse } from "hono/utils/cookie";
3
4
  import { presentedGitKey } from "./git-auth.js";
4
5
  import { sha256 } from "./release.js";
6
+ import { sameOriginRequest } from "./request-origin.js";
5
7
  /** Cookie emitted by ownerKeyAuth, for composing credential-specific adapters. */
6
8
  export const ownerKeyCookieName = "oyster_session";
9
+ /** Host-minted opaque sessions for named keys, separate from the owner cookie. */
10
+ export const keySessionCookieName = "oyster_key_session";
7
11
  const loginFormSchema = Schema.Struct({ key: Schema.String });
8
12
  /** Compare two secrets through their fixed-length digests, in constant time. */
9
13
  async function constantTimeEqual(a, b) {
@@ -91,3 +95,37 @@ export function ownerOrKeyAuth(ownerKey, ownerId, keys) {
91
95
  Promise.resolve(new Response("Sign-in is not available", { status: 404 })),
92
96
  };
93
97
  }
98
+ /**
99
+ * Add host-owned browser sessions to ownerOrKeyAuth. Explicit credentials and
100
+ * non-401 refusals keep their ordinary result; ambient cookies cannot undo them.
101
+ * Every cookie admission rechecks the key's liveness and canonical owner.
102
+ */
103
+ export function keySessionAuth(auth, ownerId, keys, sessions) {
104
+ return {
105
+ ...auth,
106
+ async authenticate(request) {
107
+ const result = await auth.authenticate(request);
108
+ if (result.kind === "admitted" ||
109
+ result.status !== 401 ||
110
+ request.headers.has("Authorization"))
111
+ return result;
112
+ if (request.method !== "GET" &&
113
+ request.method !== "HEAD" &&
114
+ !sameOriginRequest(request))
115
+ return result;
116
+ const value = parse(request.headers.get("Cookie") ?? "", keySessionCookieName)[keySessionCookieName];
117
+ if (value === undefined || value.length === 0)
118
+ return result;
119
+ const session = await sessions.resolve(value);
120
+ if (session === null)
121
+ return result;
122
+ const key = await keys().live(session.keyId);
123
+ return key !== null && key.owner === ownerId
124
+ ? {
125
+ kind: "admitted",
126
+ principal: { actor: { kind: "team-key", keyId: key.keyId } },
127
+ }
128
+ : result;
129
+ },
130
+ };
131
+ }