@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.
- package/README.md +132 -78
- package/dist/agent-archive-projection.d.ts +8 -0
- package/dist/agent-archive-projection.js +49 -0
- package/dist/agent-schedules.d.ts +50 -0
- package/dist/agent-schedules.js +17 -0
- package/dist/agents.d.ts +35 -14
- package/dist/agents.js +93 -144
- package/dist/app-builds.d.ts +7 -2
- package/dist/app-builds.js +19 -2
- package/dist/approval-capability.d.ts +1 -1
- package/dist/approval-capability.js +11 -16
- package/dist/approval-home.d.ts +2 -1
- package/dist/approval-home.js +5 -3
- package/dist/apps/catalog.js +1 -1
- package/dist/apps/gate.d.ts +4 -7
- package/dist/apps/gate.js +14 -29
- package/dist/apps/index.d.ts +1 -1
- package/dist/apps/installation.d.ts +4 -42
- package/dist/apps/installation.js +7 -75
- package/dist/apps/operations.js +0 -10
- package/dist/apps/permissions.d.ts +18 -8
- package/dist/apps/permissions.js +7 -1
- package/dist/auth.d.ts +1 -24
- package/dist/auth.js +9 -39
- package/dist/authorization.d.ts +307 -0
- package/dist/authorization.js +542 -0
- package/dist/boot-host-script.js +1 -1
- package/dist/build/build-jobs.d.ts +2 -2
- package/dist/build/candidate-builder.d.ts +2 -0
- package/dist/build/candidate-builder.js +5 -1
- package/dist/build/candidate-cli.js +3 -0
- package/dist/build/candidate-cli.mjs +59 -21
- package/dist/build/job-log.d.ts +23 -0
- package/dist/build/job-log.js +88 -0
- package/dist/build/local-build.d.ts +2 -0
- package/dist/build/local-build.js +11 -9
- package/dist/cloudflare.d.ts +4 -1
- package/dist/cloudflare.js +1 -1
- package/dist/files.js +9 -8
- package/dist/git-admission.d.ts +3 -3
- package/dist/git-admission.js +4 -6
- package/dist/git-door.d.ts +8 -7
- package/dist/git-door.js +18 -17
- package/dist/git.d.ts +1 -1
- package/dist/host.d.ts +33 -40
- package/dist/host.js +153 -272
- package/dist/installation-execution.js +10 -5
- package/dist/installation-operations.d.ts +4 -180
- package/dist/installation-operations.js +1 -5
- package/dist/installation-store-generation.d.ts +4 -1
- package/dist/installation-store-generation.js +59 -6
- package/dist/internal/delivery.js +0 -1
- package/dist/internal/host-gate.d.ts +3 -2
- package/dist/internal/host-gate.js +5 -18
- package/dist/internal/invocation-timing.d.ts +16 -0
- package/dist/internal/invocation-timing.js +23 -0
- package/dist/internal/registry-installations.js +1 -0
- package/dist/invocation.d.ts +15 -14
- package/dist/invocation.js +58 -26
- package/dist/keys.d.ts +2 -19
- package/dist/keys.js +1 -37
- package/dist/mcp.d.ts +15 -27
- package/dist/mcp.js +105 -155
- package/dist/page/agent-browser.d.ts +15 -0
- package/dist/page/agent-browser.js +162 -0
- package/dist/page/app-permissions-port.d.ts +3 -0
- package/dist/page/app-permissions-port.js +43 -0
- package/dist/page/app-permissions.d.ts +11 -0
- package/dist/page/app-permissions.js +296 -0
- package/dist/page/isolated-page-theme.d.ts +2 -0
- package/dist/page/isolated-page-theme.js +11 -0
- package/dist/page/mount.d.ts +2 -13
- package/dist/page/mount.js +7 -93
- package/dist/page-script.d.ts +0 -3
- package/dist/page-script.js +3 -4
- package/dist/standalone/agent-archive-owner.d.ts +42 -0
- package/dist/standalone/agent-archive-owner.js +116 -0
- package/dist/standalone/agent-archive-schedules.d.ts +6 -0
- package/dist/standalone/agent-archive-schedules.js +76 -0
- package/dist/standalone/agent-archive.d.ts +31 -0
- package/dist/standalone/agent-archive.js +67 -0
- package/dist/standalone/agent-computers.d.ts +7 -6
- package/dist/standalone/agent-computers.js +4 -7
- package/dist/standalone/agent-mail.d.ts +2 -2
- package/dist/standalone/agents.d.ts +157 -20
- package/dist/standalone/agents.js +234 -30
- package/dist/standalone/app-builds-binding.js +1 -1
- package/dist/standalone/app-set.js +2 -54
- package/dist/standalone/app-source.d.ts +3 -4
- package/dist/standalone/app-source.js +12 -9
- package/dist/standalone/builds.d.ts +2 -14
- package/dist/standalone/builds.js +15 -13
- package/dist/standalone/computer-fleet.d.ts +4 -4
- package/dist/standalone/computer-fleet.js +13 -42
- package/dist/standalone/computer.d.ts +4 -3
- package/dist/standalone/computer.js +52 -53
- package/dist/standalone/dashboard-approval.d.ts +10 -0
- package/dist/standalone/dashboard-approval.js +66 -0
- package/dist/standalone/machine-link.d.ts +4 -221
- package/dist/standalone/machine-link.js +21 -15
- package/dist/standalone/media.d.ts +1 -0
- package/dist/standalone/media.js +20 -14
- package/dist/standalone/owner-routes.d.ts +16 -0
- package/dist/standalone/owner-routes.js +21 -0
- package/dist/standalone/permission-resources.d.ts +5 -0
- package/dist/standalone/permission-resources.js +31 -0
- package/dist/standalone/registry.d.ts +7 -0
- package/dist/standalone/registry.js +3 -0
- package/dist/standalone/tenancy.d.ts +0 -3
- package/dist/standalone/tenancy.js +2 -5
- package/dist/standalone/worker-routes.d.ts +19 -0
- package/dist/standalone/worker-routes.js +9 -0
- package/dist/standalone/worker.d.ts +1 -1
- package/dist/standalone/worker.js +27 -35
- package/dist/standalone/workflow-binding.d.ts +8 -3
- package/dist/standalone/workflow-binding.js +14 -5
- package/dist/standalone/workflow-git-capture.js +2 -1
- package/dist/standalone/workflow-host.d.ts +8 -103
- package/dist/standalone/workflow-host.js +1 -1
- package/dist/standalone/workflow-node-capture.js +2 -2
- package/dist/standalone/workflow-root-authority.d.ts +1 -1
- package/dist/standalone/workflow-root-authority.js +1 -1
- package/dist/standalone/workflow-schedules.d.ts +14 -5
- package/dist/standalone/workflow-schedules.js +11 -5
- package/dist/standalone/workflow-webhooks.d.ts +4 -18
- package/dist/standalone/workflow-webhooks.js +5 -5
- package/dist/standalone/workflows.d.ts +36 -28
- package/dist/standalone/workflows.js +6 -3
- package/dist/storage-frame.d.ts +6 -6
- package/dist/testing/capabilities.d.ts +7 -6
- package/dist/testing/capabilities.js +3 -6
- package/dist/testing/installation.d.ts +0 -1
- package/dist/testing/installation.js +2 -5
- package/dist/workers-git.d.ts +12 -4
- package/dist/workers-git.js +38 -35
- package/dist/workers.d.ts +9 -1
- package/dist/workers.js +44 -8
- package/dist/workflows.d.ts +2 -0
- package/dist/workflows.js +1 -0
- package/docs/api/README.md +7 -0
- package/docs/api/agent-schedules.md +175 -0
- package/docs/api/agents.md +34 -13
- package/docs/api/app-storage.md +1 -1
- package/docs/api/apps.md +323 -225
- package/docs/api/auth.md +0 -55
- package/docs/api/authorization.md +1300 -0
- package/docs/api/build.md +40 -37
- package/docs/api/cloudflare.md +3 -3
- package/docs/api/git.md +20 -11
- package/docs/api/host.md +64 -192
- package/docs/api/index.md +1 -1
- package/docs/api/invocation.md +14 -14
- package/docs/api/keys.md +1 -98
- package/docs/api/mcp.md +43 -40
- package/docs/api/modules.md +2 -0
- package/docs/api/page/mount.md +16 -19
- package/docs/api/workers-git.md +46 -12
- package/docs/api/workers.md +124 -235
- package/package.json +39 -20
- package/dist/page/computer-target.d.ts +0 -3
- package/dist/page/computer-target.js +0 -9
- package/dist/standalone/channel-session-door.d.ts +0 -4
- package/dist/standalone/channel-session-door.js +0 -72
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 {
|
|
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 {
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
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:
|
|
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
|
|
309
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
//
|
|
417
|
-
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
453
|
+
grants,
|
|
438
454
|
acquire: async ({ signal, session }) => {
|
|
439
455
|
signal.throwIfAborted();
|
|
440
|
-
const
|
|
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:
|
|
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
|
|
518
|
+
const ports = hostAuthorityPorts(host);
|
|
519
|
+
const authority = await authorityOf(principal, ports);
|
|
505
520
|
const byInstallation = new Map();
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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,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
|
+
}
|