@fungi.computer/oyster 0.5.0 → 0.7.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 (185) hide show
  1. package/README.md +155 -63
  2. package/dist/agent-archive-projection.d.ts +8 -0
  3. package/dist/agent-archive-projection.js +49 -0
  4. package/dist/agent-schedules.d.ts +50 -0
  5. package/dist/agent-schedules.js +17 -0
  6. package/dist/agents.d.ts +35 -14
  7. package/dist/agents.js +97 -144
  8. package/dist/app-builds.d.ts +7 -2
  9. package/dist/app-builds.js +19 -2
  10. package/dist/approval-capability.d.ts +1 -1
  11. package/dist/approval-capability.js +11 -16
  12. package/dist/approval-home.d.ts +2 -1
  13. package/dist/approval-home.js +5 -3
  14. package/dist/apps/catalog.js +1 -1
  15. package/dist/apps/gate.d.ts +4 -7
  16. package/dist/apps/gate.js +14 -29
  17. package/dist/apps/index.d.ts +1 -1
  18. package/dist/apps/installation.d.ts +4 -42
  19. package/dist/apps/installation.js +7 -75
  20. package/dist/apps/operations.js +0 -10
  21. package/dist/apps/permissions.d.ts +18 -8
  22. package/dist/apps/permissions.js +7 -1
  23. package/dist/auth.js +9 -1
  24. package/dist/authorization.d.ts +307 -0
  25. package/dist/authorization.js +542 -0
  26. package/dist/boot-host-script.js +1 -1
  27. package/dist/build/build-jobs.d.ts +2 -2
  28. package/dist/build/candidate-builder.d.ts +2 -0
  29. package/dist/build/candidate-builder.js +5 -1
  30. package/dist/build/candidate-cli.js +3 -0
  31. package/dist/build/candidate-cli.mjs +67 -22
  32. package/dist/build/job-log.d.ts +23 -0
  33. package/dist/build/job-log.js +88 -0
  34. package/dist/build/local-build.d.ts +2 -0
  35. package/dist/build/local-build.js +11 -9
  36. package/dist/channels/binding.d.ts +8 -1
  37. package/dist/channels/binding.js +12 -0
  38. package/dist/channels/conversations.d.ts +16 -5
  39. package/dist/channels/visitor-channel.d.ts +53 -0
  40. package/dist/channels/visitor-channel.js +124 -0
  41. package/dist/channels/visitor-edge.d.ts +115 -0
  42. package/dist/channels/visitor-edge.js +582 -0
  43. package/dist/channels/visitor-request.d.ts +7 -0
  44. package/dist/channels/visitor-request.js +17 -0
  45. package/dist/channels/visitor-stream-slots.d.ts +2 -0
  46. package/dist/channels/visitor-stream-slots.js +33 -0
  47. package/dist/channels/visitors.d.ts +47 -0
  48. package/dist/channels/visitors.js +181 -0
  49. package/dist/cloudflare.d.ts +11 -2
  50. package/dist/cloudflare.js +27 -1
  51. package/dist/{page/computer-target.d.ts → computer-target.d.ts} +2 -0
  52. package/dist/{page/computer-target.js → computer-target.js} +3 -1
  53. package/dist/files.js +9 -8
  54. package/dist/git-admission.d.ts +3 -3
  55. package/dist/git-admission.js +4 -6
  56. package/dist/git-door.d.ts +8 -7
  57. package/dist/git-door.js +18 -17
  58. package/dist/git.d.ts +1 -1
  59. package/dist/host.d.ts +38 -42
  60. package/dist/host.js +182 -239
  61. package/dist/installation-execution.js +10 -5
  62. package/dist/installation-operations.d.ts +4 -180
  63. package/dist/installation-operations.js +1 -5
  64. package/dist/installation-store-generation.d.ts +4 -1
  65. package/dist/installation-store-generation.js +59 -6
  66. package/dist/internal/delivery.js +0 -1
  67. package/dist/internal/host-gate.d.ts +9 -6
  68. package/dist/internal/host-gate.js +19 -25
  69. package/dist/internal/invocation-timing.d.ts +16 -0
  70. package/dist/internal/invocation-timing.js +23 -0
  71. package/dist/internal/registry-installations.js +1 -0
  72. package/dist/invocation.d.ts +19 -11
  73. package/dist/invocation.js +171 -96
  74. package/dist/keys.d.ts +2 -19
  75. package/dist/keys.js +1 -37
  76. package/dist/mcp.d.ts +15 -27
  77. package/dist/mcp.js +105 -155
  78. package/dist/page/agent-browser.d.ts +15 -0
  79. package/dist/page/agent-browser.js +162 -0
  80. package/dist/page/app-permissions-port.d.ts +3 -0
  81. package/dist/page/app-permissions-port.js +43 -0
  82. package/dist/page/app-permissions.d.ts +11 -0
  83. package/dist/page/app-permissions.js +296 -0
  84. package/dist/page/capabilities.js +1 -1
  85. package/dist/page/child-selection.d.ts +27 -0
  86. package/dist/page/child-selection.js +38 -0
  87. package/dist/page/isolated-page-theme.d.ts +2 -0
  88. package/dist/page/isolated-page-theme.js +11 -0
  89. package/dist/page/mount.d.ts +2 -13
  90. package/dist/page/mount.js +8 -94
  91. package/dist/page-script.d.ts +0 -3
  92. package/dist/page-script.js +3 -4
  93. package/dist/release.d.ts +13 -0
  94. package/dist/release.js +10 -0
  95. package/dist/request-origin.d.ts +4 -0
  96. package/dist/request-origin.js +10 -0
  97. package/dist/standalone/agent-archive-owner.d.ts +42 -0
  98. package/dist/standalone/agent-archive-owner.js +116 -0
  99. package/dist/standalone/agent-archive-schedules.d.ts +6 -0
  100. package/dist/standalone/agent-archive-schedules.js +76 -0
  101. package/dist/standalone/agent-archive.d.ts +31 -0
  102. package/dist/standalone/agent-archive.js +67 -0
  103. package/dist/standalone/agent-computers.d.ts +7 -6
  104. package/dist/standalone/agent-computers.js +4 -7
  105. package/dist/standalone/agent-mail.d.ts +2 -2
  106. package/dist/standalone/agents.d.ts +157 -20
  107. package/dist/standalone/agents.js +242 -38
  108. package/dist/standalone/app-builds-binding.js +1 -1
  109. package/dist/standalone/app-set.js +2 -54
  110. package/dist/standalone/app-source.d.ts +3 -4
  111. package/dist/standalone/app-source.js +12 -9
  112. package/dist/standalone/builds.d.ts +2 -14
  113. package/dist/standalone/builds.js +15 -13
  114. package/dist/standalone/computer-fleet.d.ts +4 -4
  115. package/dist/standalone/computer-fleet.js +20 -43
  116. package/dist/standalone/computer.d.ts +4 -3
  117. package/dist/standalone/computer.js +52 -53
  118. package/dist/standalone/dashboard-approval.d.ts +10 -0
  119. package/dist/standalone/dashboard-approval.js +66 -0
  120. package/dist/standalone/machine-link.d.ts +4 -221
  121. package/dist/standalone/machine-link.js +21 -15
  122. package/dist/standalone/media.d.ts +1 -0
  123. package/dist/standalone/media.js +20 -14
  124. package/dist/standalone/owner-routes.d.ts +16 -0
  125. package/dist/standalone/owner-routes.js +21 -0
  126. package/dist/standalone/permission-resources.d.ts +5 -0
  127. package/dist/standalone/permission-resources.js +31 -0
  128. package/dist/standalone/registry.d.ts +7 -0
  129. package/dist/standalone/registry.js +3 -0
  130. package/dist/standalone/tenancy.js +3 -1
  131. package/dist/standalone/worker-routes.d.ts +19 -0
  132. package/dist/standalone/worker-routes.js +9 -0
  133. package/dist/standalone/worker.d.ts +1 -1
  134. package/dist/standalone/worker.js +27 -35
  135. package/dist/standalone/workflow-binding.d.ts +8 -3
  136. package/dist/standalone/workflow-binding.js +14 -5
  137. package/dist/standalone/workflow-git-capture.js +2 -1
  138. package/dist/standalone/workflow-host.d.ts +8 -103
  139. package/dist/standalone/workflow-host.js +1 -1
  140. package/dist/standalone/workflow-node-capture.js +2 -2
  141. package/dist/standalone/workflow-root-authority.d.ts +1 -1
  142. package/dist/standalone/workflow-root-authority.js +1 -1
  143. package/dist/standalone/workflow-schedules.d.ts +14 -5
  144. package/dist/standalone/workflow-schedules.js +11 -5
  145. package/dist/standalone/workflow-webhooks.d.ts +4 -18
  146. package/dist/standalone/workflow-webhooks.js +5 -5
  147. package/dist/standalone/workflows.d.ts +36 -28
  148. package/dist/standalone/workflows.js +6 -3
  149. package/dist/storage-frame.d.ts +6 -6
  150. package/dist/testing/capabilities.d.ts +7 -6
  151. package/dist/testing/capabilities.js +3 -6
  152. package/dist/testing/installation.d.ts +0 -1
  153. package/dist/testing/installation.js +2 -5
  154. package/dist/turnstile.d.ts +27 -0
  155. package/dist/turnstile.js +51 -0
  156. package/dist/workers-git.d.ts +12 -4
  157. package/dist/workers-git.js +58 -36
  158. package/dist/workers.d.ts +9 -1
  159. package/dist/workers.js +59 -20
  160. package/dist/workflows.d.ts +2 -0
  161. package/dist/workflows.js +1 -0
  162. package/docs/api/README.md +7 -0
  163. package/docs/api/agent-schedules.md +175 -0
  164. package/docs/api/agents.md +34 -13
  165. package/docs/api/app-storage.md +1 -1
  166. package/docs/api/apps.md +323 -225
  167. package/docs/api/authorization.md +1300 -0
  168. package/docs/api/build-contract.md +2 -2
  169. package/docs/api/build.md +40 -37
  170. package/docs/api/cloudflare.md +27 -2
  171. package/docs/api/git.md +20 -11
  172. package/docs/api/host.md +80 -199
  173. package/docs/api/index.md +1 -1
  174. package/docs/api/invocation.md +15 -11
  175. package/docs/api/keys.md +1 -98
  176. package/docs/api/mcp.md +43 -40
  177. package/docs/api/modules.md +2 -0
  178. package/docs/api/page/mount.md +16 -19
  179. package/docs/api/workers-git.md +46 -12
  180. package/docs/api/workers.md +125 -235
  181. package/docs/site/agent-quickstart.mdx +6 -2
  182. package/examples/standalone/wrangler.celld.jsonc +66 -0
  183. package/package.json +64 -21
  184. package/dist/standalone/channel-session-door.d.ts +0 -4
  185. package/dist/standalone/channel-session-door.js +0 -42
package/dist/mcp.js CHANGED
@@ -1,7 +1,5 @@
1
1
  import { z } from "zod";
2
- import { describeModule, invokeModuleOperation, } from "@fungi.computer/mycelium/remote";
3
2
  import { PendingApprovalSchema, ApprovalRefusalSchema, } from "@fungi.computer/approvals";
4
- import { jsonSchemaToZod } from "@fungi.computer/forage";
5
3
  import { admits } from "./apps/gate.js";
6
4
  import { createMcpWorkflowControl, WORKFLOW_CONTROL_MODULE_ID, } from "./workflows.js";
7
5
  import { causeClass } from "./cause-class.js";
@@ -11,15 +9,24 @@ import { emptyAppDeclaration } from "@fungi.computer/puffball/app";
11
9
  import { admittedMethodCallerFacts, invocationCallerContext, } from "./invocation.js";
12
10
  import { guestLimits } from "./guest-policy.js";
13
11
  import { Mycelium, } from "@fungi.computer/mycelium";
14
- import { appToolSchemas, appToolFamilySchemas } from "@fungi.computer/forage";
12
+ import { appJsonSchema, appToolFamilySchemas } from "@fungi.computer/forage";
13
+ import { forageDiscoveryInstructions } from "@fungi.computer/mycelium/effect";
15
14
  import { Knapsack } from "@fungi.computer/knapsack";
16
15
  import * as Schema from "effect/Schema";
17
16
  import { defineIdFamily } from "@fungi.computer/cairn/id";
18
17
  import { computerCreateRequestSchema, computerPageSchema, } from "@fungi.computer/computer/fleet/inventory-wire";
19
18
  import { computerStateSchema } from "@fungi.computer/computer/fleet/wire";
20
19
  import { teamComputerDirectorySchema } from "@fungi.computer/nacre/workbench";
21
- import { keyAppAccess } from "./keys.js";
20
+ import { appToolAccess, authorityOf, hostAuthorityPorts, } from "./authorization.js";
22
21
  import { defaultAssetPolicy, sha256, verifyRelease, } from "./release.js";
22
+ /** Shared host result guidance for Agent and MCP instructions, not per method. */
23
+ export const appApprovalInstructions = [
24
+ "A write (including Effect: external) may instead return AppApprovalResult:",
25
+ "type AppApprovalResult =",
26
+ ' | { kind: "pending_approval"; recordId: string; description: { title: string; inputBytes: number; inputSha256: string } }',
27
+ ' | { kind: "refused"; reason: string; denialNote?: string };',
28
+ "On pending_approval, tell the person it is waiting for their approval; do not retry.",
29
+ ].join("\n");
23
30
  const fleetIds = defineIdFamily({
24
31
  family: "mcp-computer-control",
25
32
  version: 1,
@@ -128,10 +135,8 @@ function capturedAccessFor(access, installationId) {
128
135
  }
129
136
  /** Re-read one actor's current App-tool access for one installation, fresh. */
130
137
  async function freshAccessFor(host, actor, installationId) {
131
- if (actor.kind === "agent")
132
- return (await host.appToolPolicy?.(actor)) ?? null;
133
- const key = (await host.keys?.live(actor.keyId)) ?? null;
134
- return key === null ? null : keyAppAccess(key, installationId);
138
+ const ports = hostAuthorityPorts(host);
139
+ return appToolAccess(await authorityOf({ actor }, ports), installationId, ports);
135
140
  }
136
141
  /** Make one {@link AppSurfaceCache} for a host whose artifact store and capabilities stay fixed. */
137
142
  export function appSurfaceCache(host) {
@@ -144,7 +149,7 @@ export function appSurfaceCache(host) {
144
149
  release,
145
150
  api: release.api,
146
151
  declaredOperations: await deriveDeclaredOperations(installed, release.declaration ?? emptyAppDeclaration, host.capabilities ?? []),
147
- toolSchemas: appToolSchemas(release.api),
152
+ methods: Object.fromEntries(Object.entries(release.api.methods).filter(([, method]) => method.hidden !== true)),
148
153
  };
149
154
  };
150
155
  return (installed) => {
@@ -161,17 +166,11 @@ export function appSurfaceCache(host) {
161
166
  };
162
167
  }
163
168
  /** Derive the current principal's admitted App modules and grants. */
164
- export async function oysterAppSnapshot(host, surfaces, principal, installations, access, sessionId,
165
- /** Shares one tool-family listing per Instance across the two
166
- * `appModules` passes one `oysterAppMethods` call can make (the eager
167
- * grants/version pass, and a session-scoped re-derivation); never shared
168
- * across separate calls, so it never serves another request's listing. */
169
- familyListingCache = new Map()) {
169
+ export async function oysterAppSnapshot(host, surfaces, principal, installations, access, sessionId, mode) {
170
170
  const limits = guestLimits(host.guestLimits);
171
171
  const generation = await host.installations.generation();
172
172
  const caller = invocationCallerContext(principal, host.computerId ?? null);
173
173
  const { actor } = principal;
174
- const visitor = actor.kind === "visitor";
175
174
  const modules = {};
176
175
  const levels = {};
177
176
  const fences = {};
@@ -179,8 +178,6 @@ familyListingCache = new Map()) {
179
178
  for (const installation of installations) {
180
179
  if (installation.status !== "enabled" || installation.instanceId === null)
181
180
  continue;
182
- if (visitor && installation.visibility !== "public")
183
- continue;
184
181
  const captured = capturedAccessFor(access, installation.installationId);
185
182
  // A key that holds no grant for this installation never sees it listed
186
183
  // at all. An Agent's one blanket policy either covers every
@@ -191,14 +188,14 @@ familyListingCache = new Map()) {
191
188
  const surface = await surfaces(installation.release);
192
189
  if (surface === null)
193
190
  continue;
194
- const { release, api, declaredOperations, toolSchemas } = surface;
191
+ const { release, api, declaredOperations, methods } = surface;
195
192
  const instanceId = installation.instanceId;
196
193
  const declaration = release.declaration ?? emptyAppDeclaration;
197
194
  const id = `oyster-app:${installation.installationId}`;
198
- // A member/owner/visitor, or an Agent/key with no captured access,
199
- // defaults to "write" here exactly as every module always did before:
200
- // the deeper gate (`admits`), not this coarse Mycelium-level grant, is
201
- // what actually enforces visitor/role ceilings. Only a captured
195
+ // A member/owner, or an Agent/key with no captured access, defaults to
196
+ // "write" here exactly as every module always did before: the deeper gate
197
+ // (`admits`), not this coarse Mycelium-level grant, is what actually
198
+ // enforces role ceilings. Only a captured
202
199
  // Agent/key access with its own level narrows this.
203
200
  levels[id] = captured?.level ?? "write";
204
201
  // Shared by every operation this installation exposes (ordinary methods
@@ -245,7 +242,6 @@ familyListingCache = new Map()) {
245
242
  const decision = admits({
246
243
  installation: {
247
244
  status: installation.status,
248
- visibility: installation.visibility,
249
245
  instanceId: installation.instanceId,
250
246
  authority: installation.authority,
251
247
  artifactDigest: installation.release.artifact.artifactDigest,
@@ -268,7 +264,7 @@ familyListingCache = new Map()) {
268
264
  }, { kind: "method", name, input: json, via: "mcp" });
269
265
  if (decision.kind === "refused")
270
266
  throw new AppMethodRefused(decision.code);
271
- const result = await host.instances.get(instanceId, limits).call(await admittedMethodCallerFacts(host, installation, release, caller, decision.grants, { name, effect }, [], sessionId, actor.kind === "agent" && captured !== null
267
+ const result = await host.instances.get(instanceId, limits).call(await admittedMethodCallerFacts(host, installation, release, caller, decision.grants, { name, effect }, [], sessionId ?? undefined, actor.kind === "agent" && captured !== null
272
268
  ? {
273
269
  policyRevision: captured.revision,
274
270
  grantorMemberId: captured.grantedBy,
@@ -283,20 +279,42 @@ familyListingCache = new Map()) {
283
279
  const operationEffects = {};
284
280
  effects[id] = operationEffects;
285
281
  let familyFacts = null;
286
- const toolOperations = Object.entries(toolSchemas)
287
- .filter(([, method]) => !visitor || method.effect === "read")
288
- .map(([name, method]) => {
282
+ const outputFor = (declared, effect) => mode === "approval" && effect !== "read"
283
+ ? approvalOutput(declared)
284
+ : declared;
285
+ const runOperation = async (name, effect, input, context, direct) => {
286
+ if (mode === "direct" || effect === "read")
287
+ return direct();
288
+ if (host.approvals === undefined)
289
+ return { kind: "refused", reason: "not-granted" };
290
+ const fence = fences[id];
291
+ if (fence === undefined)
292
+ throw new Error("App snapshot has no operation authority fence");
293
+ const result = await host.approvals.submit({
294
+ actor,
295
+ installationId: installation.installationId,
296
+ sessionId,
297
+ operation: name,
298
+ access: effect,
299
+ authorityFence: fence,
300
+ idempotencyKey: await sha256(new TextEncoder().encode(JSON.stringify([
301
+ context.executionId,
302
+ context.toolCallId,
303
+ context.operationOrdinal ?? 0,
304
+ ]))),
305
+ input,
306
+ });
307
+ return result.kind === "applied" ? result.output : result;
308
+ };
309
+ const toolOperations = Object.entries(methods).map(([name, method]) => {
289
310
  operationEffects[name] = method.effect;
290
311
  const appMethod = Mycelium.operation({
291
312
  replay: "unsafe",
292
313
  access: method.effect === "read" ? "read" : "write",
293
314
  description: `${method.description}\nEffect: ${method.effect}.`,
294
- input: method.input,
295
- output: method.output,
296
- execute: async (input, context) => {
297
- const value = await callAppMethod(name, method.effect, input, context.signal);
298
- return method.output.parse(value);
299
- },
315
+ input: appJsonSchema(method.input),
316
+ output: outputFor(appJsonSchema(method.output), method.effect),
317
+ execute: (input, context) => runOperation(name, method.effect, input, context, () => callAppMethod(name, method.effect, input, context.signal)),
300
318
  });
301
319
  return [name, appMethod];
302
320
  });
@@ -305,20 +323,14 @@ familyListingCache = new Map()) {
305
323
  // that listing and compiles its schemas; it supplies no admission or
306
324
  // invocation arguments by design, so the host supplies both here, reusing
307
325
  // the exact same `callAppMethod` gate as every other method on this App.
308
- // A visitor who cannot see non-read ordinary methods cannot see these
309
- // either; a caller the App itself refuses to list for (stale authority,
310
- // an unapproved App-tool policy, a missing capability grant) sees none.
326
+ // A caller the App itself refuses to list for (stale authority, an
327
+ // unapproved App-tool policy, a missing capability grant) sees none.
311
328
  const family = api.toolFamily;
312
- const familyOperations = family === undefined ||
313
- (visitor && api.methods[family.call]?.effect !== "read")
329
+ const familyOperations = family === undefined
314
330
  ? []
315
331
  : await (async () => {
316
332
  try {
317
- let listing = familyListingCache.get(instanceId);
318
- if (listing === undefined) {
319
- listing = await callAppMethod(family.list, "read", {});
320
- familyListingCache.set(instanceId, listing);
321
- }
333
+ const listing = await callAppMethod(family.list, "read", {});
322
334
  const compiled = appToolFamilySchemas(api, listing);
323
335
  if (compiled === null)
324
336
  return [];
@@ -331,21 +343,20 @@ familyListingCache = new Map()) {
331
343
  access: compiled.effect === "read" ? "read" : "write",
332
344
  description: `${tool.description}\nEffect: ${compiled.effect}.`,
333
345
  input: tool.input,
334
- output: tool.output,
335
- execute: async (input, context) => {
346
+ output: outputFor(tool.output, compiled.effect),
347
+ execute: (input, context) => runOperation(`${compiled.name}.${tool.row.tool}`, compiled.effect, input, context, () => {
336
348
  // The declaring App owns every field below except
337
349
  // `requestId` (this call's own identity) and `input`
338
350
  // (the caller's tool-shaped payload): `tool.row.call` is
339
351
  // opaque data this host never reads into or defaults.
340
352
  // `requestId` is last: a row whose own `call` data named
341
353
  // that key would otherwise replay the same run forever.
342
- const value = await callAppMethod(compiled.call, compiled.effect, {
354
+ return callAppMethod(compiled.call, compiled.effect, {
343
355
  ...tool.row.call,
344
356
  input,
345
357
  requestId: toolFamilyCallIds.call.generate(),
346
358
  }, context.signal);
347
- return tool.output.parse(value);
348
- },
359
+ }),
349
360
  });
350
361
  return [
351
362
  `${compiled.name}.${tool.row.tool}`,
@@ -404,8 +415,7 @@ function serializableAccess(access) {
404
415
  /**
405
416
  * Capture installed App methods for MCP or agents as one principal-scoped source.
406
417
  * Enabled installations become `env.<APP>` modules using the Instance call path; a dotted method such as `repos.list` is `env.<APP>.repos.list()`.
407
- * Visitors see only public read methods; owners see every discoverable
408
- * method. A key sees only the installations its own grants admit, each at
418
+ * Owners see every discoverable method. A key sees only the installations its own grants admit, each at
409
419
  * the level (`read`/`write`) those grants give it; Mycelium itself refuses a
410
420
  * write call up front when the module's own grant is `read`
411
421
  * (`mycelium/src/access.ts`). Call again to capture changes to Releases,
@@ -413,11 +423,18 @@ function serializableAccess(access) {
413
423
  */
414
424
  export async function oysterAppMethods(host, principal, access, surfaces, preloadedInstallations) {
415
425
  const installations = preloadedInstallations ?? (await host.installations.list());
416
- // Shared so a session-scoped re-derivation below reuses this call's tool-
417
- // family listings instead of asking each Instance for them twice.
418
- const familyListingCache = new Map();
419
- const initial = await oysterAppSnapshot(host, surfaces, principal, installations, access, undefined, familyListingCache);
420
- const modules = admittedAppModules(host, principal, initial, null);
426
+ // Grants need only admitted Release data, not modules, fences or listings.
427
+ const grants = {};
428
+ for (const installation of installations) {
429
+ if (installation.status !== "enabled" || installation.instanceId === null)
430
+ continue;
431
+ const captured = capturedAccessFor(access, installation.installationId);
432
+ if (access?.kind === "key" && captured === null)
433
+ continue;
434
+ if ((await surfaces(installation.release)) !== null)
435
+ grants[`oyster-app:${installation.installationId}`] =
436
+ captured?.level ?? "write";
437
+ }
421
438
  const version = await sha256(new TextEncoder().encode(JSON.stringify({
422
439
  access: serializableAccess(access),
423
440
  installations: [...installations]
@@ -429,20 +446,17 @@ export async function oysterAppMethods(host, principal, access, surfaces, preloa
429
446
  installation.release.artifact.artifactDigest,
430
447
  installation.authority.authorityVersion,
431
448
  installation.status,
432
- installation.visibility,
433
449
  ]),
434
450
  })));
435
451
  return {
436
452
  id: "oyster-apps",
437
- grants: initial.levels,
453
+ grants,
438
454
  acquire: async ({ signal, session }) => {
439
455
  signal.throwIfAborted();
440
- const acquiredModules = session === undefined
441
- ? modules
442
- : admittedAppModules(host, principal, await oysterAppSnapshot(host, surfaces, principal, installations, access, session.sessionId, familyListingCache), session.sessionId);
456
+ const snapshot = await oysterAppSnapshot(host, surfaces, principal, installations, access, session?.sessionId ?? null, "approval");
443
457
  signal.throwIfAborted();
444
458
  return {
445
- snapshot: { version, modules: acquiredModules },
459
+ snapshot: { version, modules: Object.values(snapshot.modules) },
446
460
  release: () => Promise.resolve(),
447
461
  };
448
462
  },
@@ -501,14 +515,14 @@ export function oysterMcpCatalogLoad(host) {
501
515
  // expired or suspended key: that keeps every installation invisible
502
516
  // (via appModules' own `access?.kind === "key"` check) rather than
503
517
  // falling through to the member/owner default of "write" below.
504
- const key = (await host.keys?.live(actor.keyId)) ?? null;
518
+ const ports = hostAuthorityPorts(host);
519
+ const authority = await authorityOf(principal, ports);
505
520
  const byInstallation = new Map();
506
- if (key !== null)
507
- for (const installation of installations) {
508
- const level = keyAppAccess(key, installation.installationId);
509
- if (level !== null)
510
- byInstallation.set(installation.installationId, level);
511
- }
521
+ for (const installation of installations) {
522
+ const level = await appToolAccess(authority, installation.installationId, ports);
523
+ if (level !== null)
524
+ byInstallation.set(installation.installationId, level);
525
+ }
512
526
  access = { kind: "key", keyId: actor.keyId, byInstallation };
513
527
  }
514
528
  const apps = await oysterAppMethods(host, principal, access, surfaces, installations);
@@ -565,12 +579,20 @@ export function oysterMcpCatalogLoad(host) {
565
579
  const workflowGrants = control == null ? {} : { [WORKFLOW_CONTROL_MODULE_ID]: "write" };
566
580
  if (computer === null)
567
581
  return {
568
- instructions: "Installed Apps on this Oyster. Each App is env.<APP>; dotted method names are nested properties, e.g. env.BONSAI.repos.list({}). Use forage.search / forage.describe to find methods.",
582
+ instructions: [
583
+ "Installed Apps on this Oyster. Each App is env.<APP>; dotted method names are nested properties, e.g. env.BONSAI.repos.list({}).",
584
+ forageDiscoveryInstructions,
585
+ appApprovalInstructions,
586
+ ].join("\n"),
569
587
  grants: { ...apps.grants, ...workflowGrants, ...fleetGrants },
570
588
  sources: [apps, ...workflowSources, ...fleetSource],
571
589
  };
572
590
  return {
573
- instructions: 'Installed Apps and this Oyster\'s Computer. Each App is env.<APP>; dotted method names are nested properties, e.g. env.BONSAI.repos.list({}). The Computer is env.computers.workspace, e.g. env.computers.workspace.exec({command: "ls"}). Use forage.search / forage.describe to find methods.',
591
+ instructions: [
592
+ 'Installed Apps and this Oyster\'s Computer. Each App is env.<APP>; dotted method names are nested properties, e.g. env.BONSAI.repos.list({}). The Computer is env.computers.workspace, e.g. env.computers.workspace.exec({command: "ls"}).',
593
+ forageDiscoveryInstructions,
594
+ appApprovalInstructions,
595
+ ].join("\n"),
574
596
  grants: {
575
597
  ...apps.grants,
576
598
  ...computer.grants,
@@ -581,88 +603,16 @@ export function oysterMcpCatalogLoad(host) {
581
603
  };
582
604
  };
583
605
  }
584
- function admittedAppModules(host, principal, snapshot, sessionId) {
585
- return Object.entries(snapshot.modules).map(([id, raw]) => {
586
- const { description: moduleDescription, ...descriptor } = describeModule(raw);
587
- const installationId = id.slice("oyster-app:".length);
588
- const moduleMetadata = {};
589
- if (moduleDescription !== undefined)
590
- moduleMetadata.description = moduleDescription;
591
- return Mycelium.module({
592
- ...descriptor,
593
- ...moduleMetadata,
594
- id,
595
- name: appBindingName(installationId),
596
- operations: Object.fromEntries(Object.entries(descriptor.operations).map(([path, rawOperation]) => {
597
- const { description: operationDescription, ...operation } = rawOperation;
598
- const effect = snapshot.effects[id]?.[path];
599
- const fence = snapshot.fences[id];
600
- if (effect === undefined || fence === undefined)
601
- throw new Error("App snapshot has no operation authority facts");
602
- const declaredOutput = operation.output ?? true;
603
- // Decode each document at its own root before composing: a nested
604
- // JSON Schema document's root-relative $refs would otherwise escape.
605
- const declared = jsonSchemaToZod(declaredOutput);
606
- const output = effect === "read"
607
- ? declared
608
- : z.union([
609
- declared,
610
- jsonSchemaToZod(Schema.toStandardJSONSchemaV1(PendingApprovalSchema)["~standard"].jsonSchema.output({ target: "draft-2020-12" })),
611
- jsonSchemaToZod(Schema.toStandardJSONSchemaV1(ApprovalRefusalSchema)["~standard"].jsonSchema.output({ target: "draft-2020-12" })),
612
- ]);
613
- const outputValidator = z.intersection(output, z.json());
614
- const operationMetadata = {};
615
- if (operationDescription !== undefined)
616
- operationMetadata.description = operationDescription;
617
- // App methods are never replayed by the host: a write goes through
618
- // its approval record, and even a read may have App-side effects.
619
- const appOperation = Mycelium.operation({
620
- ...operation,
621
- ...operationMetadata,
622
- replay: "unsafe",
623
- access: effect === "read" ? "read" : "write",
624
- input: z.intersection(jsonSchemaToZod(operation.input), z.json()),
625
- output: {
626
- "~standard": {
627
- ...outputValidator["~standard"],
628
- // As appMethodSchemas does, describe the JSON contract
629
- // without nesting another redundant recursive JSON guard.
630
- jsonSchema: output["~standard"].jsonSchema,
631
- },
632
- },
633
- execute: async (input, context) => {
634
- if (effect === "read")
635
- return z
636
- .json()
637
- .parse(await invokeModuleOperation(raw, path, input, context));
638
- if (principal.actor.kind === "visitor")
639
- return { kind: "refused", reason: "not-granted" };
640
- if (host.approvals === undefined)
641
- return { kind: "refused", reason: "not-granted" };
642
- // Mycelium numbers each operation call within one execute and
643
- // replays the same sequence; a direct call is its own execution.
644
- const ordinal = context.operationOrdinal ?? 0;
645
- const result = await host.approvals.submit({
646
- actor: principal.actor,
647
- installationId,
648
- sessionId,
649
- operation: path,
650
- access: effect,
651
- authorityFence: fence,
652
- idempotencyKey: await sha256(new TextEncoder().encode(JSON.stringify([
653
- context.executionId,
654
- context.toolCallId,
655
- ordinal,
656
- ]))),
657
- input,
658
- });
659
- return z
660
- .json()
661
- .parse(result.kind === "applied" ? result.output : result);
662
- },
663
- });
664
- return [path, appOperation];
665
- })),
666
- });
667
- });
606
+ const isPendingApproval = Schema.is(PendingApprovalSchema);
607
+ const isApprovalRefusal = Schema.is(ApprovalRefusalSchema);
608
+ /** Approval results are a host contract, not part of each App's declaration. */
609
+ function approvalOutput(declared) {
610
+ return {
611
+ "~standard": {
612
+ ...declared["~standard"],
613
+ validate: (value) => isPendingApproval(value) || isApprovalRefusal(value)
614
+ ? { value }
615
+ : declared["~standard"].validate(value),
616
+ },
617
+ };
668
618
  }
@@ -0,0 +1,15 @@
1
+ import type { AgentSessionSelection, CapabilityDirectory } from "@fungi.computer/agar";
2
+ import { type WhistleRuntime } from "@fungi.computer/whistle";
3
+ /** One admitted page's live windows and selected Agent Session, never a global catalog. */
4
+ export declare class OysterAgentBrowserHost {
5
+ #private;
6
+ constructor(signal: AbortSignal);
7
+ /** Borrow a Release-validated window view only for this exact frame lifetime. */
8
+ mount(frame: HTMLIFrameElement, commands: Pick<WhistleRuntime, "snapshot" | "execute">): () => void;
9
+ /** A departing or deselecting window cannot close a different selection. */
10
+ release(frame: HTMLIFrameElement): void;
11
+ /** Release this page's window registrations and selected Agent connection. */
12
+ dispose(): void;
13
+ /** Admit the selected Agent through the reporting App's existing write door. */
14
+ report(frame: HTMLIFrameElement, selection: AgentSessionSelection | null, capabilities: CapabilityDirectory): Promise<void>;
15
+ }
@@ -0,0 +1,162 @@
1
+ import { agentManage, agentSessionWrite, openAppAgent, } from "@fungi.computer/agar/agents";
2
+ import { correlationKey } from "@fungi.computer/cairn";
3
+ import { connectBrowser, UiBrowserError, } from "@fungi.computer/knapsack/ui/browser";
4
+ import { sameWhistleCommand, } from "@fungi.computer/whistle";
5
+ import { EventSource } from "eventsource";
6
+ const detachedOutcome = {
7
+ status: "failed",
8
+ error: {
9
+ code: "window_command_detached",
10
+ message: "The window or selected Session closed after dispatch; completion is unknown",
11
+ },
12
+ };
13
+ function windowCommand(window, command) {
14
+ return { namespace: `${command.namespace}@${window.id}`, id: command.id };
15
+ }
16
+ /** One admitted page's live windows and selected Agent Session, never a global catalog. */
17
+ export class OysterAgentBrowserHost {
18
+ #windows = new Map();
19
+ #active;
20
+ #disposed = false;
21
+ constructor(signal) {
22
+ if (signal.aborted)
23
+ this.dispose();
24
+ else
25
+ signal.addEventListener("abort", () => {
26
+ this.dispose();
27
+ }, { once: true });
28
+ }
29
+ /** Borrow a Release-validated window view only for this exact frame lifetime. */
30
+ mount(frame, commands) {
31
+ if (this.#disposed)
32
+ return () => undefined;
33
+ if (this.#windows.has(frame))
34
+ throw new Error("Agent command window is already mounted");
35
+ const window = { id: correlationKey(), commands };
36
+ this.#windows.set(frame, window);
37
+ return () => {
38
+ if (this.#windows.get(frame) !== window)
39
+ return;
40
+ this.#windows.delete(frame);
41
+ this.release(frame);
42
+ };
43
+ }
44
+ /** A departing or deselecting window cannot close a different selection. */
45
+ release(frame) {
46
+ if (this.#active?.frame !== frame)
47
+ return;
48
+ const previous = this.#active;
49
+ this.#active = undefined;
50
+ previous.close();
51
+ }
52
+ /** Release this page's window registrations and selected Agent connection. */
53
+ dispose() {
54
+ this.#disposed = true;
55
+ this.#windows.clear();
56
+ if (this.#active !== undefined)
57
+ this.release(this.#active.frame);
58
+ }
59
+ #commands(selection) {
60
+ const live = () => this.#active === selection && !this.#disposed;
61
+ return {
62
+ snapshot: () => ({
63
+ bindings: [],
64
+ palette: [],
65
+ slash: [],
66
+ help: [],
67
+ menu: [],
68
+ agent: live()
69
+ ? [...this.#windows.values()].flatMap((window) => window.commands.snapshot().agent.map((row) => ({
70
+ ...row,
71
+ command: windowCommand(window, row.command),
72
+ })))
73
+ : [],
74
+ }),
75
+ execute: async (command, input) => {
76
+ if (!live() || input.origin !== "agent")
77
+ return { status: "missing" };
78
+ for (const [frame, window] of this.#windows) {
79
+ const row = window.commands
80
+ .snapshot()
81
+ .agent.find((row) => sameWhistleCommand(windowCommand(window, row.command), command));
82
+ if (row === undefined)
83
+ continue;
84
+ // The mounted view remains the execution owner: it checks the live
85
+ // Agent projection and invokes only this frame's Agar connection.
86
+ const attached = () => live() && this.#windows.get(frame) === window;
87
+ try {
88
+ const outcome = await window.commands.execute(row.command, input);
89
+ // Closing does not promise to undo an already dispatched effect,
90
+ // but its old result must never settle as a replacement window's.
91
+ return attached() ? outcome : detachedOutcome;
92
+ }
93
+ catch (cause) {
94
+ if (!attached())
95
+ return detachedOutcome;
96
+ throw cause;
97
+ }
98
+ }
99
+ return { status: "missing" };
100
+ },
101
+ };
102
+ }
103
+ /** Admit the selected Agent through the reporting App's existing write door. */
104
+ async report(frame, selection, capabilities) {
105
+ if (this.#disposed || !this.#windows.has(frame))
106
+ return;
107
+ // A read-only Session handle cannot open or displace this connection.
108
+ // The server re-admits the principal on every transport request.
109
+ if (!capabilities.has(agentSessionWrite) && !capabilities.has(agentManage))
110
+ return;
111
+ if (selection === null) {
112
+ this.release(frame);
113
+ return;
114
+ }
115
+ if (this.#active !== undefined)
116
+ this.release(this.#active.frame);
117
+ let transport;
118
+ let connection;
119
+ const failure = () => {
120
+ console.warn("Oyster Agent browser connection failed");
121
+ };
122
+ const active = {
123
+ frame,
124
+ close: () => {
125
+ connection?.close();
126
+ if (transport !== undefined)
127
+ void transport.close().catch(failure);
128
+ },
129
+ };
130
+ this.#active = active;
131
+ try {
132
+ transport = await openAppAgent(capabilities, {
133
+ agentId: selection.agentId,
134
+ });
135
+ if (this.#active !== active) {
136
+ await transport.close();
137
+ return;
138
+ }
139
+ const admitted = transport;
140
+ connection = connectBrowser({
141
+ baseUrl: `${admitted.baseUrl}/browser/sessions/${encodeURIComponent(selection.sessionId)}`,
142
+ whistle: this.#commands(active),
143
+ transport: {
144
+ fetch: admitted.fetch,
145
+ openEvents: (url) => new EventSource(url, { fetch: admitted.fetch }),
146
+ },
147
+ });
148
+ void connection.closed.catch((cause) => {
149
+ if (this.#active === active)
150
+ this.release(frame);
151
+ console.warn("Oyster Agent browser connection failed", cause instanceof UiBrowserError
152
+ ? cause.message
153
+ : "Transport unavailable");
154
+ });
155
+ }
156
+ catch (cause) {
157
+ if (this.#active === active)
158
+ this.release(frame);
159
+ throw cause;
160
+ }
161
+ }
162
+ }
@@ -0,0 +1,3 @@
1
+ import { type AppPermissionsPort } from "../apps/permissions.js";
2
+ /** Owner-direct transport over Oyster's existing generation/version/digest-fenced grant routes. */
3
+ export declare function createOysterAppPermissionsPort(): AppPermissionsPort;
@@ -0,0 +1,43 @@
1
+ import { appPermissionPageSchema, permissionSaveSchema, } from "../apps/permissions.js";
2
+ /** Owner-direct transport over Oyster's existing generation/version/digest-fenced grant routes. */
3
+ export function createOysterAppPermissionsPort() {
4
+ const path = (appKey) => `/api/installations/${encodeURIComponent(appKey)}/grants`;
5
+ const request = (url, init) => fetch(url, {
6
+ ...init,
7
+ headers: {
8
+ Accept: "application/json",
9
+ "Content-Type": "application/json",
10
+ },
11
+ credentials: "same-origin",
12
+ cache: "no-store",
13
+ redirect: "error",
14
+ });
15
+ return {
16
+ read: async (appKey) => {
17
+ const response = await request(path(appKey));
18
+ if (response.status === 404)
19
+ return "unavailable";
20
+ if (!response.ok)
21
+ throw new Error("Permissions could not be loaded");
22
+ return appPermissionPageSchema.parse(await response.json());
23
+ },
24
+ submit: async (appKey, pins, changes) => {
25
+ const response = await request(path(appKey), {
26
+ method: "PUT",
27
+ body: JSON.stringify(permissionSaveSchema.parse({
28
+ ...pins,
29
+ command: { kind: "change-grants", changes },
30
+ })),
31
+ });
32
+ if (response.ok)
33
+ return "accepted";
34
+ if (response.status === 409)
35
+ return "stale";
36
+ if (response.status === 404)
37
+ return "unavailable";
38
+ if (response.status >= 500)
39
+ return "unconfirmed";
40
+ return "refused";
41
+ },
42
+ };
43
+ }