@fungi.computer/oyster 0.6.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 (163) hide show
  1. package/README.md +132 -78
  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 +93 -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.d.ts +1 -24
  24. package/dist/auth.js +9 -39
  25. package/dist/authorization.d.ts +307 -0
  26. package/dist/authorization.js +542 -0
  27. package/dist/boot-host-script.js +1 -1
  28. package/dist/build/build-jobs.d.ts +2 -2
  29. package/dist/build/candidate-builder.d.ts +2 -0
  30. package/dist/build/candidate-builder.js +5 -1
  31. package/dist/build/candidate-cli.js +3 -0
  32. package/dist/build/candidate-cli.mjs +59 -21
  33. package/dist/build/job-log.d.ts +23 -0
  34. package/dist/build/job-log.js +88 -0
  35. package/dist/build/local-build.d.ts +2 -0
  36. package/dist/build/local-build.js +11 -9
  37. package/dist/cloudflare.d.ts +4 -1
  38. package/dist/cloudflare.js +1 -1
  39. package/dist/files.js +9 -8
  40. package/dist/git-admission.d.ts +3 -3
  41. package/dist/git-admission.js +4 -6
  42. package/dist/git-door.d.ts +8 -7
  43. package/dist/git-door.js +18 -17
  44. package/dist/git.d.ts +1 -1
  45. package/dist/host.d.ts +33 -40
  46. package/dist/host.js +153 -272
  47. package/dist/installation-execution.js +10 -5
  48. package/dist/installation-operations.d.ts +4 -180
  49. package/dist/installation-operations.js +1 -5
  50. package/dist/installation-store-generation.d.ts +4 -1
  51. package/dist/installation-store-generation.js +59 -6
  52. package/dist/internal/delivery.js +0 -1
  53. package/dist/internal/host-gate.d.ts +3 -2
  54. package/dist/internal/host-gate.js +5 -18
  55. package/dist/internal/invocation-timing.d.ts +16 -0
  56. package/dist/internal/invocation-timing.js +23 -0
  57. package/dist/internal/registry-installations.js +1 -0
  58. package/dist/invocation.d.ts +15 -14
  59. package/dist/invocation.js +58 -26
  60. package/dist/keys.d.ts +2 -19
  61. package/dist/keys.js +1 -37
  62. package/dist/mcp.d.ts +15 -27
  63. package/dist/mcp.js +105 -155
  64. package/dist/page/agent-browser.d.ts +15 -0
  65. package/dist/page/agent-browser.js +162 -0
  66. package/dist/page/app-permissions-port.d.ts +3 -0
  67. package/dist/page/app-permissions-port.js +43 -0
  68. package/dist/page/app-permissions.d.ts +11 -0
  69. package/dist/page/app-permissions.js +296 -0
  70. package/dist/page/isolated-page-theme.d.ts +2 -0
  71. package/dist/page/isolated-page-theme.js +11 -0
  72. package/dist/page/mount.d.ts +2 -13
  73. package/dist/page/mount.js +7 -93
  74. package/dist/page-script.d.ts +0 -3
  75. package/dist/page-script.js +3 -4
  76. package/dist/standalone/agent-archive-owner.d.ts +42 -0
  77. package/dist/standalone/agent-archive-owner.js +116 -0
  78. package/dist/standalone/agent-archive-schedules.d.ts +6 -0
  79. package/dist/standalone/agent-archive-schedules.js +76 -0
  80. package/dist/standalone/agent-archive.d.ts +31 -0
  81. package/dist/standalone/agent-archive.js +67 -0
  82. package/dist/standalone/agent-computers.d.ts +7 -6
  83. package/dist/standalone/agent-computers.js +4 -7
  84. package/dist/standalone/agent-mail.d.ts +2 -2
  85. package/dist/standalone/agents.d.ts +157 -20
  86. package/dist/standalone/agents.js +234 -30
  87. package/dist/standalone/app-builds-binding.js +1 -1
  88. package/dist/standalone/app-set.js +2 -54
  89. package/dist/standalone/app-source.d.ts +3 -4
  90. package/dist/standalone/app-source.js +12 -9
  91. package/dist/standalone/builds.d.ts +2 -14
  92. package/dist/standalone/builds.js +15 -13
  93. package/dist/standalone/computer-fleet.d.ts +4 -4
  94. package/dist/standalone/computer-fleet.js +13 -42
  95. package/dist/standalone/computer.d.ts +4 -3
  96. package/dist/standalone/computer.js +52 -53
  97. package/dist/standalone/dashboard-approval.d.ts +10 -0
  98. package/dist/standalone/dashboard-approval.js +66 -0
  99. package/dist/standalone/machine-link.d.ts +4 -221
  100. package/dist/standalone/machine-link.js +21 -15
  101. package/dist/standalone/media.d.ts +1 -0
  102. package/dist/standalone/media.js +20 -14
  103. package/dist/standalone/owner-routes.d.ts +16 -0
  104. package/dist/standalone/owner-routes.js +21 -0
  105. package/dist/standalone/permission-resources.d.ts +5 -0
  106. package/dist/standalone/permission-resources.js +31 -0
  107. package/dist/standalone/registry.d.ts +7 -0
  108. package/dist/standalone/registry.js +3 -0
  109. package/dist/standalone/tenancy.d.ts +0 -3
  110. package/dist/standalone/tenancy.js +2 -5
  111. package/dist/standalone/worker-routes.d.ts +19 -0
  112. package/dist/standalone/worker-routes.js +9 -0
  113. package/dist/standalone/worker.d.ts +1 -1
  114. package/dist/standalone/worker.js +27 -35
  115. package/dist/standalone/workflow-binding.d.ts +8 -3
  116. package/dist/standalone/workflow-binding.js +14 -5
  117. package/dist/standalone/workflow-git-capture.js +2 -1
  118. package/dist/standalone/workflow-host.d.ts +8 -103
  119. package/dist/standalone/workflow-host.js +1 -1
  120. package/dist/standalone/workflow-node-capture.js +2 -2
  121. package/dist/standalone/workflow-root-authority.d.ts +1 -1
  122. package/dist/standalone/workflow-root-authority.js +1 -1
  123. package/dist/standalone/workflow-schedules.d.ts +14 -5
  124. package/dist/standalone/workflow-schedules.js +11 -5
  125. package/dist/standalone/workflow-webhooks.d.ts +4 -18
  126. package/dist/standalone/workflow-webhooks.js +5 -5
  127. package/dist/standalone/workflows.d.ts +36 -28
  128. package/dist/standalone/workflows.js +6 -3
  129. package/dist/storage-frame.d.ts +6 -6
  130. package/dist/testing/capabilities.d.ts +7 -6
  131. package/dist/testing/capabilities.js +3 -6
  132. package/dist/testing/installation.d.ts +0 -1
  133. package/dist/testing/installation.js +2 -5
  134. package/dist/workers-git.d.ts +12 -4
  135. package/dist/workers-git.js +38 -35
  136. package/dist/workers.d.ts +9 -1
  137. package/dist/workers.js +44 -8
  138. package/dist/workflows.d.ts +2 -0
  139. package/dist/workflows.js +1 -0
  140. package/docs/api/README.md +7 -0
  141. package/docs/api/agent-schedules.md +175 -0
  142. package/docs/api/agents.md +34 -13
  143. package/docs/api/app-storage.md +1 -1
  144. package/docs/api/apps.md +323 -225
  145. package/docs/api/auth.md +0 -55
  146. package/docs/api/authorization.md +1300 -0
  147. package/docs/api/build.md +40 -37
  148. package/docs/api/cloudflare.md +3 -3
  149. package/docs/api/git.md +20 -11
  150. package/docs/api/host.md +64 -192
  151. package/docs/api/index.md +1 -1
  152. package/docs/api/invocation.md +14 -14
  153. package/docs/api/keys.md +1 -98
  154. package/docs/api/mcp.md +43 -40
  155. package/docs/api/modules.md +2 -0
  156. package/docs/api/page/mount.md +16 -19
  157. package/docs/api/workers-git.md +46 -12
  158. package/docs/api/workers.md +124 -235
  159. package/package.json +39 -20
  160. package/dist/page/computer-target.d.ts +0 -3
  161. package/dist/page/computer-target.js +0 -9
  162. package/dist/standalone/channel-session-door.d.ts +0 -4
  163. package/dist/standalone/channel-session-door.js +0 -72
package/dist/agents.js CHANGED
@@ -1,16 +1,7 @@
1
- /**
2
- * Agents for Apps on a standalone Oyster: one adapter shape and the routes
3
- * the App page calls. Apps reach agents through Agar's `agent.list`,
4
- * `agent.manage`, `agent.read` and `agent.send` contracts, so an App written
5
- * for the Hub (Grove) runs unchanged. Each agent's own transport is
6
- * Shiitake's HTTP routes (`/sessions…`, `/acp`) and Knapsack's Session browser
7
- * transport (`/browser/…`), served by the adapter behind the same App grants.
8
- */
9
- import { Hono } from "hono";
10
- import { keyAgentAccess } from "./keys.js";
1
+ import { agent, agents as agentCollection, owner, routes, } from "./authorization.js";
11
2
  import * as Schema from "effect/Schema";
12
3
  import { z } from "zod";
13
- import { oysterPrincipalIsOwner, } from "./host.js";
4
+ import {} from "./host.js";
14
5
  import { agentAvatarSchema } from "./agent-avatars.js";
15
6
  import { computerTargetParam } from "./computer-target.js";
16
7
  import { agentAvatar, agentList, agentManage, agentRename, agentSessionRead, agentSessionWrite, agentToolPolicy, } from "@fungi.computer/agar/agents";
@@ -46,6 +37,13 @@ const visitSchema = Schema.Struct({
46
37
  sessionId: Schema.String,
47
38
  });
48
39
  const agentIdPattern = /^[a-z0-9_-]{1,64}$/u;
40
+ /** Explicit owner-addressed settings remain reachable after list hides an archive. */
41
+ async function knownAgent(agents, agentId) {
42
+ if (!agentIdPattern.test(agentId))
43
+ return false;
44
+ return ((await agents.list()).some((agent) => agent.agentId === agentId) ||
45
+ (await agents.list({ archived: true })).some((agent) => agent.agentId === agentId));
46
+ }
49
47
  /** How often an open inbox stream re-reads the revision. */
50
48
  const inboxPollMs = 2_000;
51
49
  const maxAgentRequestBytes = 512 * 1024;
@@ -53,9 +51,8 @@ function refusal(status, error) {
53
51
  return Response.json({ error }, { status });
54
52
  }
55
53
  /** Agent adapters admitted against each App's declared capabilities. */
56
- export function agentCapabilities(agents, liveKey) {
57
- const routes = createAgentRoutes(agents, liveKey);
58
- const shared = { base: "/", routes };
54
+ export function agentCapabilities(agents) {
55
+ const shared = { base: "/", routes: createAgentRoutes(agents) };
59
56
  return [
60
57
  {
61
58
  ...shared,
@@ -136,45 +133,22 @@ export function agentCapabilities(agents, liveKey) {
136
133
  },
137
134
  ];
138
135
  }
139
- function createAgentRoutes(agents, liveKey) {
140
- const routes = new Hono();
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
- });
174
- routes.post("/agents", async (context) => {
175
- const { principal } = context.env;
176
- if (!oysterPrincipalIsOwner(principal))
177
- return refusal(403, "Only the owner creates agents");
136
+ function createAgentRoutes(agents) {
137
+ return (routes()
138
+ .get("/agents", agentCollection(), async (context, { reaches }) => {
139
+ const selected = context.req.query("archived");
140
+ if (selected !== undefined &&
141
+ selected !== "true" &&
142
+ selected !== "false")
143
+ return refusal(400, "Choose a valid archive view");
144
+ const listed = await agents.list({ archived: selected === "true" });
145
+ const reached = [];
146
+ for (const item of listed)
147
+ if (await reaches(item.agentId))
148
+ reached.push(item);
149
+ return Response.json(reached);
150
+ })
151
+ .post("/agents", owner("direct-or-app"), async (context, { principal }) => {
178
152
  const body = Schema.decodeUnknownOption(createSchema)(await context.req.json());
179
153
  if (body._tag === "None")
180
154
  return refusal(400, "Name the agent");
@@ -186,11 +160,8 @@ function createAgentRoutes(agents, liveKey) {
186
160
  // The owner's own authenticated request creates this Agent; its App tools
187
161
  // are approved the same way direct owner approval is, with no second path.
188
162
  return Response.json(await agents.create(body.value.name, body.value.requestId, principal, "owner-direct", avatar?.data));
189
- });
190
- routes.put("/agent-names/:agentId", async (context) => {
191
- const { principal } = context.env;
192
- if (!oysterPrincipalIsOwner(principal))
193
- return refusal(403, "Only the owner renames agents");
163
+ })
164
+ .put("/agent-names/:agentId", owner("direct-or-app"), async (context, { principal }) => {
194
165
  const agentId = context.req.param("agentId");
195
166
  if (!agentIdPattern.test(agentId))
196
167
  return refusal(404, "No such agent");
@@ -205,11 +176,8 @@ function createAgentRoutes(agents, liveKey) {
205
176
  ? 409
206
177
  : 403, result.error);
207
178
  return Response.json(result);
208
- });
209
- routes.put("/agent-avatars/:agentId", async (context) => {
210
- const { principal } = context.env;
211
- if (!oysterPrincipalIsOwner(principal))
212
- return refusal(403, "Only the owner changes avatars");
179
+ })
180
+ .put("/agent-avatars/:agentId", owner("direct-or-app"), async (context, { principal }) => {
213
181
  const agentId = context.req.param("agentId");
214
182
  if (!agentIdPattern.test(agentId))
215
183
  return refusal(404, "No such agent");
@@ -220,19 +188,19 @@ function createAgentRoutes(agents, liveKey) {
220
188
  if ("error" in result)
221
189
  return refusal(result.error === "not_found" ? 404 : 403, result.error);
222
190
  return Response.json(result);
223
- });
224
- routes.get("/agent-policies/:agentId", async (context) => {
225
- const agentId = context.req.param("agentId");
191
+ })
192
+ .get("/agent-policies/:agentId", agent(), async (_context, { agentId }) => {
226
193
  if (!agentIdPattern.test(agentId))
227
194
  return refusal(404, "No such agent");
228
195
  const status = await agents.policyStatus(agentId);
229
196
  return status === null
230
197
  ? refusal(404, "No such agent")
231
198
  : Response.json(status);
232
- });
233
- routes.put("/agent-policies/:agentId", async (context) => approvePolicy(agents, context.env.principal, "owner-direct", context.req.param("agentId"), await context.req.text()));
234
- routes.all("/agents/:agentId/*", async (context) => {
235
- const agentId = context.req.param("agentId");
199
+ })
200
+ .put("/agent-policies/:agentId", owner("direct-or-app"), async (context, { principal }) => approvePolicy(agents, principal, "owner-direct", context.req.param("agentId"), await context.req.text()))
201
+ .post("/agents/:agentId/archive", owner("direct-or-app"), (context, { principal }) => changeArchive(agents, context.req.param("agentId"), true, principal))
202
+ .post("/agents/:agentId/unarchive", owner("direct-or-app"), (context, { principal }) => changeArchive(agents, context.req.param("agentId"), false, principal))
203
+ .all("/agents/:agentId/*", agent(), async (context, { agentId }) => {
236
204
  if (!agentIdPattern.test(agentId))
237
205
  return refusal(404, "No such agent");
238
206
  const incoming = context.req.raw;
@@ -252,49 +220,40 @@ function createAgentRoutes(agents, liveKey) {
252
220
  target.searchParams.delete(computerTargetParam);
253
221
  const response = await agents.fetch(agentId, new Request(target, incoming));
254
222
  return response ?? refusal(404, "No such agent");
255
- });
256
- routes.get("/member/inbox", async (context) => {
223
+ })
224
+ .get("/member/inbox", agentCollection(), async (_context, { authority, reaches }) => {
257
225
  const inbox = await agents.inbox();
258
- const principal = context.env.principal;
259
- if (oysterPrincipalIsOwner(principal))
226
+ if (authority.kind === "owner")
260
227
  return Response.json({
261
228
  items: inbox.items,
262
229
  next: null,
263
230
  cursor: inbox.revision,
264
231
  });
265
- if (principal.actor.kind !== "team-key")
266
- return refusal(403, "Agent access denied");
267
- const key = await liveKey(principal.actor.keyId);
232
+ const items = [];
233
+ for (const item of inbox.items)
234
+ if ("agentId" in item && (await reaches(item.agentId)))
235
+ items.push(item);
268
236
  return Response.json({
269
- items: key === null
270
- ? []
271
- : inbox.items.filter((item) => "agentId" in item && keyAgentAccess(key, item.agentId)),
237
+ items,
272
238
  next: null,
273
239
  // The global revision changes for ungranted Agents too. Key feeds have
274
240
  // no scoped event stream, so expose neither that revision nor its events.
275
241
  cursor: 0,
276
242
  });
277
- });
278
- routes.get("/member/inbox/notices", (context) => oysterPrincipalIsOwner(context.env.principal)
279
- ? Response.json({ items: [], next: null })
280
- : refusal(403, "Agent access denied"));
281
- routes.post("/member/inbox/visits", async (context) => {
243
+ })
244
+ .get("/member/inbox/notices", owner("direct-or-app"), () => Response.json({ items: [], next: null }))
245
+ .post("/member/inbox/visits", agent(async (facts) => (await facts.body(visitSchema))?.agentId ?? null), async (context, { agentId }) => {
282
246
  const body = Schema.decodeUnknownOption(visitSchema)(await context.req.json());
283
247
  if (body._tag === "None")
284
248
  return refusal(400, "Invalid visit");
285
- if (!(await access(context.env.principal, body.value.agentId)))
286
- return refusal(403, "Agent access denied");
287
- return (await agents.visit(body.value.agentId, body.value.sessionId))
249
+ return (await agents.visit(agentId, body.value.sessionId))
288
250
  ? new Response(null, { status: 204 })
289
251
  : refusal(404, "No such Session");
290
- });
291
- // The feed is small (one person's agents), so a change is announced as a
292
- // `reset` and the client re-reads the whole inbox.
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");
252
+ })
253
+ // The feed is small (one person's agents), so a change is announced as a
254
+ // `reset` and the client re-reads the whole inbox. The unscoped revision
255
+ // also changes for other Agents, so only the owner reads it.
256
+ .get("/member/inbox/events", owner("direct-or-app"), (context) => {
298
257
  const after = Number(context.req.query("after") ?? "0");
299
258
  const signal = context.req.raw.signal;
300
259
  const encoder = new TextEncoder();
@@ -321,20 +280,33 @@ function createAgentRoutes(agents, liveKey) {
321
280
  "Cache-Control": "no-store",
322
281
  },
323
282
  });
324
- });
325
- return routes;
283
+ }));
284
+ }
285
+ /** Owner-only archive transition; creator Agents use their own control tool. */
286
+ async function changeArchive(agents, agentId, archived, principal) {
287
+ if (!agentIdPattern.test(agentId))
288
+ return refusal(404, "No such agent");
289
+ const result = await agents.setArchived(agentId, archived, principal);
290
+ if ("error" in result)
291
+ return Response.json(result, {
292
+ status: result.error === "not_found"
293
+ ? 404
294
+ : result.error === "not_granted"
295
+ ? 403
296
+ : result.archived !== undefined
297
+ ? 503
298
+ : 409,
299
+ });
300
+ return Response.json(result);
326
301
  }
327
302
  async function approvePolicy(agents, principal, actor, agentId, body) {
328
- if (!oysterPrincipalIsOwner(principal))
329
- return refusal(403, "Only the owner approves Agent tools");
330
303
  const input = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Struct({
331
304
  level: Schema.Literals(["read", "write"]),
332
305
  expectedRevision: Schema.NullOr(Schema.NonEmptyString),
333
306
  })), { onExcessProperty: "error" })(body);
334
307
  if (input._tag === "None")
335
308
  return refusal(400, "Choose read or write tools");
336
- if (!agentIdPattern.test(agentId) ||
337
- !(await agents.list()).some((agent) => agent.agentId === agentId))
309
+ if (!(await knownAgent(agents, agentId)))
338
310
  return refusal(404, "No such agent");
339
311
  const result = await agents.approveAppToolPolicy(agentId, principal, input.value.level, input.value.expectedRevision, actor);
340
312
  const failureStatuses = {
@@ -348,11 +320,8 @@ async function approvePolicy(agents, principal, actor, agentId, body) {
348
320
  }
349
321
  /** Host-owned direct approval under the admitted owner; never an App capability. */
350
322
  export function agentApprovalRoutes(agents) {
351
- return new Hono()
352
- .get("/api/agent-models/:agentId", async (context) => {
353
- if (context.req.raw.headers.has("X-Oyster-Installation") ||
354
- !oysterPrincipalIsOwner(context.get("principal")))
355
- return refusal(403, "Owner only");
323
+ return routes()
324
+ .get("/api/agent-models/:agentId", owner("direct"), async (context) => {
356
325
  const agentId = context.req.param("agentId");
357
326
  if (!agentIdPattern.test(agentId))
358
327
  return refusal(404, "No such agent");
@@ -361,17 +330,14 @@ export function agentApprovalRoutes(agents) {
361
330
  ? refusal(404, "No such agent")
362
331
  : Response.json(choice);
363
332
  })
364
- .put("/api/agent-models/:agentId", async (context) => {
365
- if (context.req.raw.headers.has("X-Oyster-Installation") ||
366
- !oysterPrincipalIsOwner(context.get("principal")))
367
- return refusal(403, "Owner only");
333
+ .put("/api/agent-models/:agentId", owner("direct"), async (context, { principal }) => {
368
334
  const agentId = context.req.param("agentId");
369
335
  if (!agentIdPattern.test(agentId))
370
336
  return refusal(404, "No such agent");
371
337
  const input = agents.decodeModelChoice(await context.req.text());
372
338
  if (input === null)
373
339
  return refusal(400, "Invalid model choice");
374
- const choice = await agents.setModelChoice(agentId, input, context.get("principal"));
340
+ const choice = await agents.setModelChoice(agentId, input, principal);
375
341
  if ("error" in choice && choice.error === "invalid_model_choice")
376
342
  return refusal(400, "Invalid model choice");
377
343
  return "error" in choice
@@ -382,21 +348,15 @@ export function agentApprovalRoutes(agents) {
382
348
  : 409, choice.error)
383
349
  : Response.json(choice);
384
350
  })
385
- .get("/api/agent-policies/:agentId", async (context) => {
386
- if (context.req.raw.headers.has("X-Oyster-Installation"))
387
- return refusal(403, "Owner direct only");
388
- if (!oysterPrincipalIsOwner(context.get("principal")))
389
- return refusal(403, "Owner only");
351
+ .get("/api/agent-policies/:agentId", owner("direct"), async (context) => {
390
352
  const agentId = context.req.param("agentId");
391
- if (!(await agents.list()).some((agent) => agent.agentId === agentId))
353
+ if (!(await knownAgent(agents, agentId)))
392
354
  return refusal(404, "No such agent");
393
355
  return Response.json({
394
356
  expectedRevision: await agents.policyRevision(agentId),
395
357
  });
396
358
  })
397
- .put("/api/agent-policies/:agentId", async (context) => context.req.raw.headers.has("X-Oyster-Installation")
398
- ? refusal(403, "Missing admitted Agent installation")
399
- : approvePolicy(agents, context.get("principal"), "owner-direct", context.req.param("agentId"), await context.req.text()));
359
+ .put("/api/agent-policies/:agentId", owner("direct"), async (context, { principal }) => approvePolicy(agents, principal, "owner-direct", context.req.param("agentId"), await context.req.text()));
400
360
  }
401
361
  const serverIdPattern = /^[a-z0-9_-]{1,64}$/u;
402
362
  const mcpRefusalStatus = { not_found: 404, duplicate_name: 409, limit: 409 };
@@ -405,28 +365,18 @@ const mcpRefusalStatus = { not_found: 404, duplicate_name: 409, limit: 409 };
405
365
  * capability. Bearer tokens go in and never come back out.
406
366
  */
407
367
  export function agentMcpServerRoutes(agents) {
408
- const routes = new Hono();
409
- const ownerOnly = async (context, next) => {
410
- if (context.req.raw.headers.has("X-Oyster-Installation"))
411
- return refusal(403, "Owner direct only");
412
- if (!oysterPrincipalIsOwner(context.get("principal")))
413
- return refusal(403, "Only the owner connects MCP servers");
414
- await next();
415
- return undefined;
416
- };
417
- routes.use("/api/agent-mcp-servers", ownerOnly);
418
- routes.use("/api/agent-mcp-servers/*", ownerOnly);
419
- // One owner read for the settings panel: every Agent with its servers.
420
- routes.get("/api/agent-mcp-servers", async () => {
368
+ return (routes()
369
+ // One owner read for the settings panel: every Agent with its servers.
370
+ .get("/api/agent-mcp-servers", owner("direct"), async () => {
421
371
  const listed = await agents.list();
422
- const rows = await Promise.all(listed.map(async (agent) => ({
423
- agentId: agent.agentId,
424
- name: agent.name,
425
- servers: (await agents.mcpServers(agent.agentId)) ?? [],
372
+ const rows = await Promise.all(listed.map(async (item) => ({
373
+ agentId: item.agentId,
374
+ name: item.name,
375
+ servers: (await agents.mcpServers(item.agentId)) ?? [],
426
376
  })));
427
377
  return Response.json({ agents: rows });
428
- });
429
- routes.post("/api/agent-mcp-servers/:agentId", async (context) => {
378
+ })
379
+ .post("/api/agent-mcp-servers/:agentId", owner("direct"), async (context) => {
430
380
  if (!agentIdPattern.test(context.req.param("agentId")))
431
381
  return refusal(404, "No such agent");
432
382
  const length = Number(context.req.header("content-length") ?? "0");
@@ -439,8 +389,8 @@ export function agentMcpServerRoutes(agents) {
439
389
  return "error" in added
440
390
  ? refusal(mcpRefusalStatus[added.error], added.error)
441
391
  : Response.json(added, { status: 201 });
442
- });
443
- routes.delete("/api/agent-mcp-servers/:agentId/:serverId", async (context) => {
392
+ })
393
+ .delete("/api/agent-mcp-servers/:agentId/:serverId", owner("direct"), async (context) => {
444
394
  const serverId = context.req.param("serverId");
445
395
  if (!agentIdPattern.test(context.req.param("agentId")) ||
446
396
  !serverIdPattern.test(serverId))
@@ -448,6 +398,5 @@ export function agentMcpServerRoutes(agents) {
448
398
  return (await agents.removeMcpServer(context.req.param("agentId"), serverId))
449
399
  ? new Response(null, { status: 204 })
450
400
  : refusal(404, "No such server");
451
- });
452
- return routes;
401
+ }));
453
402
  }
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import * as Schema from "effect/Schema";
20
20
  import type { WatchdogSqliteOwner } from "@fungi.computer/watchdog";
21
+ import { type BuildJobLog } from "./build/job-log.js";
21
22
  import { type Release } from "@fungi.computer/puffball";
22
23
  import { type AppPublisherCoreConfigInput } from "./build/effect.js";
23
24
  import { type TeamAppCandidateManifest } from "./build/submission-contract.js";
@@ -144,10 +145,12 @@ export type OysterBuildComputer = Readonly<{
144
145
  command: string;
145
146
  timeoutMs: number;
146
147
  }): Promise<Readonly<{
147
- output?: Readonly<{
148
+ output: Readonly<{
148
149
  exitCode: number | null;
149
150
  stderr?: string;
150
151
  }> | null;
152
+ /** Raw combined events captured before the Computer reply's stream bounds. */
153
+ log: BuildJobLog;
151
154
  }>>;
152
155
  }>;
153
156
  export type OysterAppBuildOptions = Readonly<{
@@ -208,6 +211,8 @@ export declare function createOysterAppBuilds(options: OysterAppBuildOptions): R
208
211
  run: () => Promise<OysterBuildView>;
209
212
  }>>;
210
213
  read(jobId: string): OysterBuildView | null;
214
+ /** Full raw output; the host applies the same principal guard as `read`. */
215
+ readLog(jobId: string): Promise<BuildJobLog | null>;
211
216
  /** Discover the current source's latest job from the caller's Git owner,
212
217
  * never a job id remembered in a browser or another installation's repo. */
213
218
  latestRepositoryBuild(installationId: string, repository: string): OysterBuildView | null;
@@ -280,4 +285,4 @@ export declare function createOysterAppBuilds(options: OysterAppBuildOptions): R
280
285
  submitCandidate(jobId: string, manifest: TeamAppCandidateManifest, grant: string, credentialId: string): Promise<Response>;
281
286
  }>;
282
287
  export type OysterAppBuilds = ReturnType<typeof createOysterAppBuilds>;
283
- export {};
288
+ export { buildJobLogMaxBytes, type BuildJobLog } from "./build/job-log.js";
@@ -20,6 +20,7 @@ import * as Schema from "effect/Schema";
20
20
  import * as Data from "effect/Data";
21
21
  import * as Option from "effect/Option";
22
22
  import { candidateFailureDiagnostic } from "./build/candidate-diagnostic.js";
23
+ import { buildJobDiagnostic, buildJobLogSchema, } from "./build/job-log.js";
23
24
  import { parseArtifactRef, parseReleaseId, } from "@fungi.computer/puffball";
24
25
  import { appIdSchema, releaseSchema, sha256DigestSchema, sourceRevisionSchema, } from "@fungi.computer/puffball/effect";
25
26
  import { capturedPinnedRecipeSchema, normalizeAppPublisherRecipe, digestPinnedRecipe, fitsPinnedRecipe, pinnedRecipeObjectBound, } from "./build/effect.js";
@@ -235,8 +236,9 @@ export function createOysterAppBuilds(options) {
235
236
  detail: job.detail,
236
237
  });
237
238
  const settle = (jobId, status, detail) => {
239
+ const diagnostic = detail === null ? null : buildJobDiagnostic(jobId, detail);
238
240
  owner.sql.exec(`UPDATE oyster_build_jobs SET installation=CASE WHEN ?='succeeded' THEN installation ELSE ${failedInstallation} END,
239
- status=?, detail=?, expires_at=MIN(expires_at, ?) WHERE job_id=? AND status='running'`, status, detail, status, detail, now(), jobId);
241
+ status=?, detail=?, expires_at=MIN(expires_at, ?) WHERE job_id=? AND status='running'`, status, diagnostic, status, diagnostic, now(), jobId);
240
242
  };
241
243
  const required = (jobId) => {
242
244
  const job = readJob(jobId);
@@ -244,6 +246,10 @@ export function createOysterAppBuilds(options) {
244
246
  throw new Error("Unknown build job");
245
247
  return job;
246
248
  };
249
+ // A job-scoped immutable object in the same artifact store as its build.
250
+ // It is read only through the owner build door, never exposed as a Release.
251
+ const logRef = (jobId) => parseArtifactRef(`artifact:build-log-${jobId}`);
252
+ const logCodec = Schema.fromJsonString(buildJobLogSchema);
247
253
  const publisher = createTeamBuildPublisher({
248
254
  teamId: options.tenantId,
249
255
  now,
@@ -284,7 +290,8 @@ export function createOysterAppBuilds(options) {
284
290
  command: input.command,
285
291
  timeoutMs: input.timeoutMs,
286
292
  });
287
- if (result.output === undefined || result.output === null)
293
+ await options.artifacts.put(logRef(input.jobId), new TextEncoder().encode(Schema.encodeUnknownSync(logCodec)(result.log)));
294
+ if (result.output === null)
288
295
  throw new BuildOutcomeUnknown();
289
296
  return result;
290
297
  },
@@ -458,6 +465,15 @@ export function createOysterAppBuilds(options) {
458
465
  const job = readJob(jobId);
459
466
  return job === null ? null : view(job);
460
467
  },
468
+ /** Full raw output; the host applies the same principal guard as `read`. */
469
+ async readLog(jobId) {
470
+ if (readJob(jobId) === null)
471
+ return null;
472
+ const bytes = await options.artifacts.get(logRef(jobId));
473
+ return bytes === null
474
+ ? null
475
+ : Schema.decodeUnknownSync(logCodec)(new TextDecoder().decode(bytes));
476
+ },
461
477
  /** Discover the current source's latest job from the caller's Git owner,
462
478
  * never a job id remembered in a browser or another installation's repo. */
463
479
  latestRepositoryBuild(installationId, repository) {
@@ -660,3 +676,4 @@ export function createOysterAppBuilds(options) {
660
676
  },
661
677
  });
662
678
  }
679
+ export { buildJobLogMaxBytes } from "./build/job-log.js";
@@ -1,5 +1,5 @@
1
1
  import { type Approvals } from "@fungi.computer/approvals";
2
2
  import type { HostActor } from "@fungi.computer/puffball/app";
3
3
  import type { OysterCapability } from "./host.js";
4
- /** Ordinary capability routes; the outer App gate supplies identity and consent. */
4
+ /** Ordinary capability routes; the outer App gate supplies consent, and only members answer. */
5
5
  export declare function approvalCapability(control: (actor: HostActor) => Approvals): OysterCapability;
@@ -1,19 +1,19 @@
1
- import { Hono } from "hono";
1
+ import { member, routes as routesFor } from "./authorization.js";
2
2
  import { bodyLimit } from "hono/body-limit";
3
3
  import { causeClass } from "./cause-class.js";
4
4
  import * as Schema from "effect/Schema";
5
5
  import { ApprovalListInputSchema, ApprovalGetInputSchema, ApprovalAnswerInputSchema, AutoApproveInputSchema, } from "@fungi.computer/approvals";
6
- /** Ordinary capability routes; the outer App gate supplies identity and consent. */
6
+ /** Ordinary capability routes; the outer App gate supplies consent, and only members answer. */
7
7
  export function approvalCapability(control) {
8
- const routes = new Hono();
9
- routes.use("*", bodyLimit({ maxSize: 4096 }));
10
- routes.use("*", async (context, next) => {
11
- if (context.env.principal.actor.kind !== "member")
12
- return context.json({ kind: "refused", reason: "not-eligible" }, 403);
13
- await next();
14
- return undefined;
15
- });
16
- routes.onError((cause, context) => {
8
+ const strict = { onExcessProperty: "error" };
9
+ const eligible = member(() => Response.json({ kind: "refused", reason: "not-eligible" }, { status: 403 }));
10
+ const options = { middleware: [bodyLimit({ maxSize: 4096 })] };
11
+ const routes = routesFor()
12
+ .post("/list", eligible, async (c, { principal }) => c.json(await control(principal.actor).list(Schema.decodeUnknownSync(ApprovalListInputSchema, strict)(await c.req.json()))), options)
13
+ .post("/get", eligible, async (c, { principal }) => c.json(await control(principal.actor).get(Schema.decodeUnknownSync(ApprovalGetInputSchema, strict)(await c.req.json()))), options)
14
+ .post("/answer", eligible, async (c, { principal }) => c.json(await control(principal.actor).answer(Schema.decodeUnknownSync(ApprovalAnswerInputSchema, strict)(await c.req.json()))), options)
15
+ .post("/set-auto-approve", eligible, async (c, { principal }) => c.json(await control(principal.actor).setAutoApprove(Schema.decodeUnknownSync(AutoApproveInputSchema, strict)(await c.req.json()))), options)
16
+ .onError((cause, context) => {
17
17
  if (cause instanceof SyntaxError || cause instanceof Schema.SchemaError)
18
18
  return context.json({ error: "Invalid approval request" }, 400);
19
19
  console.warn("Native approval control failed", {
@@ -21,11 +21,6 @@ export function approvalCapability(control) {
21
21
  });
22
22
  return context.json({ error: "Approval home unavailable" }, 503);
23
23
  });
24
- const strict = { onExcessProperty: "error" };
25
- routes.post("/list", async (c) => c.json(await control(c.env.principal.actor).list(Schema.decodeUnknownSync(ApprovalListInputSchema, strict)(await c.req.json()))));
26
- routes.post("/get", async (c) => c.json(await control(c.env.principal.actor).get(Schema.decodeUnknownSync(ApprovalGetInputSchema, strict)(await c.req.json()))));
27
- routes.post("/answer", async (c) => c.json(await control(c.env.principal.actor).answer(Schema.decodeUnknownSync(ApprovalAnswerInputSchema, strict)(await c.req.json()))));
28
- routes.post("/set-auto-approve", async (c) => c.json(await control(c.env.principal.actor).setAutoApprove(Schema.decodeUnknownSync(AutoApproveInputSchema, strict)(await c.req.json()))));
29
24
  return {
30
25
  id: "approvals",
31
26
  version: 1,
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { ApprovalLedger, type Approvals, type PendingApproval, type ApprovalApplyResult } from "@fungi.computer/approvals";
3
3
  import type { WatchdogSqliteOwner } from "@fungi.computer/watchdog";
4
- import { type OysterHost } from "./host.js";
4
+ import type { OysterHost } from "./host.js";
5
5
  import type { HostActor } from "@fungi.computer/puffball/app";
6
6
  import type { TenantScope } from "./tenant.js";
7
7
  declare const callSchema: z.ZodObject<{
@@ -32,6 +32,7 @@ export type OysterApprovalCall = z.output<typeof callSchema>;
32
32
  export declare class OysterApprovalHome {
33
33
  private readonly host;
34
34
  readonly ledger: ApprovalLedger;
35
+ private surfaces;
35
36
  constructor(host: Readonly<{
36
37
  owner: WatchdogSqliteOwner;
37
38
  scope: TenantScope;
@@ -3,9 +3,8 @@ import * as Schema from "effect/Schema";
3
3
  import { hostActorSchema } from "@fungi.computer/puffball/app-method-runtime";
4
4
  import { ApprovalLedger, } from "@fungi.computer/approvals";
5
5
  import { invokeModuleOperation } from "@fungi.computer/mycelium/remote";
6
- import { oysterPrincipalIsOwner } from "./host.js";
6
+ import { keyAppAccess, oysterPrincipalIsOwner } from "./authorization.js";
7
7
  import { sha256 } from "./release.js";
8
- import { keyAppAccess } from "./keys.js";
9
8
  const identity = z.string().min(1).max(256);
10
9
  const targetSchema = z.strictObject({
11
10
  actor: hostActorSchema,
@@ -25,6 +24,9 @@ const intentRow = Schema.decodeUnknownSync(Schema.Struct({ target_json: Schema.S
25
24
  export class OysterApprovalHome {
26
25
  host;
27
26
  ledger;
27
+ // The home's artifact store and capability configuration are fixed, unlike
28
+ // the authority read fresh from current() for every recheck and dispatch.
29
+ surfaces;
28
30
  constructor(host) {
29
31
  this.host = host;
30
32
  host.owner.sql.exec("CREATE TABLE IF NOT EXISTS oyster_approval_targets (record_id TEXT PRIMARY KEY, target_json TEXT NOT NULL) STRICT");
@@ -180,7 +182,7 @@ export class OysterApprovalHome {
180
182
  byInstallation: new Map([[target.installationId, policy]]),
181
183
  };
182
184
  }
183
- const snapshot = await oysterAppSnapshot(host, appSurfaceCache(host), principal, [installation], access, target.sessionId ?? undefined);
185
+ const snapshot = await oysterAppSnapshot(host, (this.surfaces ??= appSurfaceCache(host)), principal, [installation], access, target.sessionId, "direct");
184
186
  const module = snapshot.modules[record.module];
185
187
  const fence = snapshot.fences[record.module];
186
188
  const effect = snapshot.effects[record.module]?.[record.operation];
@@ -34,7 +34,7 @@ export const permissionCatalog = {
34
34
  "apps.call": entry("apps", "Call declared App methods", "call the listed methods in other Apps as you. Renamed target methods need approval again.", "run", "install"),
35
35
  "app.storage": entry("data", "Store App data", "save its App data.", "write", "install"),
36
36
  "app.events.emit": entry("workflows", "Send events", "tell your Workflows when something happens.", "sendPrompts", "ask"),
37
- "team.workbenchHost": entry("shell", "Arrange the workbench", "arrange Apps on your workbench.", "list", "ask", null, "The shell runs with full access to your Team and every App inside it. Only pick a shell you trust."),
37
+ "team.workbenchHost": entry("shell", "Shell", "Full access to your Team and every App", "list", "ask", null, "Only pick a shell you trust."),
38
38
  "marketplace.browse": entry("bazaar", "Browse the marketplace", "browse Apps in the marketplace.", "browseBazaar", "install"),
39
39
  "marketplace.purchase": entry("bazaar", "Purchase from the marketplace", "purchase Apps from the marketplace.", "buyApps", "install"),
40
40
  "workflow.run": entry("workflows", "Run Workflows", "start Workflows.", "run", "install"),
@@ -4,9 +4,7 @@ import { type AppToolAccess } from "./tools.js";
4
4
  import { type ResourceRef, type DeclaredOperations, type Installation, type ApprovedGrant } from "./installation.js";
5
5
  /** Attested by the host, never authenticated by decoding request JSON. */
6
6
  export type HostPrincipal = Readonly<{
7
- actor: HostActor | Readonly<{
8
- kind: "visitor";
9
- }>;
7
+ actor: HostActor;
10
8
  role: "owner" | "member" | null;
11
9
  }>;
12
10
  /** Requests select existing SDK methods, opaque backend calls and derived operation identities. */
@@ -54,7 +52,7 @@ export type GateRequest = z.output<typeof gateRequestSchema>;
54
52
  /** Immutable values the host already read; resource/grantor freshness remains host-owned. */
55
53
  export type GateFacts = Readonly<{
56
54
  principal: HostPrincipal;
57
- installation: Readonly<Pick<Installation, "status" | "visibility" | "instanceId" | "authority"> & {
55
+ installation: Readonly<Pick<Installation, "status" | "instanceId" | "authority"> & {
58
56
  artifactDigest: string;
59
57
  }>;
60
58
  declaration: AppDeclaration;
@@ -87,7 +85,6 @@ export declare const gateRefusalSchema: z.ZodLazy<z.ZodObject<{
87
85
  approval_required: "approval_required";
88
86
  requires_not_granted: "requires_not_granted";
89
87
  invalid_input: "invalid_input";
90
- visitor_read_only: "visitor_read_only";
91
88
  }>;
92
89
  }, z.core.$strict>>;
93
90
  /** Either an admitted scoped ceiling or one closed refusal code. */
@@ -100,13 +97,13 @@ export type GateDecision = Readonly<{
100
97
  export declare function grantsFor(installation: Pick<GateFacts["installation"], "status" | "authority" | "artifactDigest">, declaration: AppDeclaration, snapshot: DeclaredOperations, capability: ApprovedGrant["capability"], resource?: ResourceRef | "any"): ApprovedGrant | null;
101
98
  /** Approved binding operations from an already verified declaration. Hosts still pin
102
99
  * the selection/version/artifact and compare running frame grants as a subset. */
103
- export declare function approvedBindingGrants(installation: Pick<GateFacts["installation"], "status" | "authority">, declaration: AppDeclaration): InvocationGrants;
100
+ export declare function approvedBindingGrants(installation: Pick<GateFacts["installation"], "status" | "authority">, declaration: AppDeclaration, resource?: ResourceRef | "any"): InvocationGrants;
104
101
  /** Narrow captured binding operations to the method's verified requirements and effect.
105
102
  * The captured set is the ceiling; this never adds an operation or resource authority. */
106
103
  export declare function scopedBindings(captured: InvocationGrants, method?: AppMethodDeclaration, ceiling?: "read" | "write"): InvocationGrants;
107
104
  /** Principal ceiling for invocation and metadata capture. Captures must pass this
108
105
  * to scopedBindings; its default write ceiling does not encode caller access. */
109
- export declare function appInvocationCeiling(facts: Pick<GateFacts, "principal" | "access">): "read" | "write";
106
+ export declare function appInvocationCeiling(facts: Pick<GateFacts, "access">): "read" | "write";
110
107
  /** Permission metadata only: this is neither an invocation grant nor input admission. */
111
108
  export type AppMethodMetadataDecision = Readonly<{
112
109
  kind: "admitted";