@fungi.computer/oyster 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -8
- package/dist/agents.d.ts +2 -1
- package/dist/agents.js +67 -8
- package/dist/apps/gate.d.ts +4 -0
- package/dist/apps/gate.js +3 -0
- package/dist/auth.d.ts +24 -1
- package/dist/auth.js +38 -0
- package/dist/boot-host-script.js +1 -1
- package/dist/build/candidate-cli.mjs +27 -5
- package/dist/channels/binding.d.ts +70 -0
- package/dist/channels/binding.js +97 -0
- package/dist/channels/conversations.d.ts +246 -0
- package/dist/channels/conversations.js +705 -0
- package/dist/channels/ids.d.ts +8 -0
- package/dist/channels/ids.js +15 -0
- package/dist/channels/visitor-channel.d.ts +53 -0
- package/dist/channels/visitor-channel.js +124 -0
- package/dist/channels/visitor-edge.d.ts +115 -0
- package/dist/channels/visitor-edge.js +582 -0
- package/dist/channels/visitor-request.d.ts +7 -0
- package/dist/channels/visitor-request.js +17 -0
- package/dist/channels/visitor-stream-slots.d.ts +2 -0
- package/dist/channels/visitor-stream-slots.js +33 -0
- package/dist/channels/visitors.d.ts +47 -0
- package/dist/channels/visitors.js +181 -0
- package/dist/cloudflare.d.ts +8 -2
- package/dist/cloudflare.js +27 -1
- package/dist/computer-target.d.ts +5 -0
- package/dist/computer-target.js +11 -0
- package/dist/host.d.ts +7 -4
- package/dist/host.js +96 -17
- package/dist/internal/host-gate.d.ts +6 -4
- package/dist/internal/host-gate.js +14 -7
- package/dist/invocation.d.ts +11 -4
- package/dist/invocation.js +134 -91
- package/dist/keys.d.ts +4 -1
- package/dist/keys.js +15 -4
- package/dist/machine-link/authority.d.ts +7 -0
- package/dist/machine-link/delivery.d.ts +12 -0
- package/dist/machine-link/delivery.js +22 -1
- package/dist/machine-link/disposition.d.ts +16 -0
- package/dist/machine-link/disposition.js +65 -0
- package/dist/machine-link/host.d.ts +11 -2
- package/dist/machine-link/host.js +125 -5
- package/dist/machine-link/link.d.ts +4 -0
- package/dist/machine-link/link.js +47 -1
- package/dist/page/capabilities.js +1 -1
- package/dist/page/child-selection.d.ts +27 -0
- package/dist/page/child-selection.js +38 -0
- package/dist/page/mount.js +1 -1
- package/dist/page-script.js +4 -4
- package/dist/release.d.ts +13 -0
- package/dist/release.js +10 -0
- package/dist/request-origin.d.ts +4 -0
- package/dist/request-origin.js +10 -0
- package/dist/standalone/agent-mail.js +4 -1
- package/dist/standalone/agents.d.ts +8 -0
- package/dist/standalone/agents.js +30 -11
- package/dist/standalone/channel-session-door.d.ts +4 -0
- package/dist/standalone/channel-session-door.js +72 -0
- package/dist/standalone/computer-fleet.js +7 -1
- package/dist/standalone/machine-link.d.ts +2 -0
- package/dist/standalone/machine-link.js +34 -1
- package/dist/standalone/tenancy.d.ts +3 -0
- package/dist/standalone/tenancy.js +7 -2
- package/dist/testing/capabilities.d.ts +2 -0
- package/dist/testing/capabilities.js +3 -1
- package/dist/turnstile.d.ts +27 -0
- package/dist/turnstile.js +51 -0
- package/dist/workers-git.js +21 -2
- package/dist/workers.d.ts +3 -0
- package/dist/workers.js +15 -12
- package/docs/api/agents.md +8 -4
- package/docs/api/apps.md +8 -4
- package/docs/api/auth.md +55 -0
- package/docs/api/browser.md +40 -16
- package/docs/api/build-contract.md +2 -2
- package/docs/api/cloudflare.md +26 -1
- package/docs/api/host.md +22 -13
- package/docs/api/invocation.md +7 -3
- package/docs/api/keys.md +25 -3
- package/docs/api/workers.md +38 -30
- package/docs/site/agent-quickstart.mdx +29 -2
- package/examples/standalone/wrangler.celld.jsonc +66 -0
- package/examples/standalone/wrangler.jsonc +3 -4
- package/package.json +53 -17
|
@@ -2,24 +2,31 @@ import { emptyAppDeclaration } from "@fungi.computer/puffball/app";
|
|
|
2
2
|
import { deriveDeclaredOperations } from "../apps/operations.js";
|
|
3
3
|
import { keyAppAccess } from "../keys.js";
|
|
4
4
|
import { defaultAssetPolicy, verifyRelease, } from "../release.js";
|
|
5
|
+
/** Verified Releases one cache keeps; the least recently added leaves first. */
|
|
6
|
+
const releaseCacheLimit = 256;
|
|
5
7
|
/**
|
|
6
|
-
* Verify each Release once per
|
|
7
|
-
* next request reads it again.
|
|
8
|
-
* verifies each on read.
|
|
8
|
+
* Verify each Release once per owner (an isolate's HTTP host, or the
|
|
9
|
+
* Registry): a failed read is not cached, so the next request reads it again.
|
|
10
|
+
* Installation verified every asset; serving verifies each on read. A Release
|
|
11
|
+
* is content-addressed and immutable, so the key is its ID and root digest and
|
|
12
|
+
* a hit never goes stale; the bound only forgets the oldest-verified Releases.
|
|
9
13
|
*/
|
|
10
|
-
export function releaseCache(artifacts) {
|
|
14
|
+
export function releaseCache(artifacts, limit = releaseCacheLimit) {
|
|
11
15
|
const verified = new Map();
|
|
12
16
|
return (release) => {
|
|
13
17
|
const key = `${release.releaseId}:${release.artifact.artifactDigest}`;
|
|
14
18
|
let entry = verified.get(key);
|
|
15
19
|
if (entry === undefined) {
|
|
16
|
-
|
|
17
|
-
// Installation verified every asset; serving verifies each on read.
|
|
18
|
-
entry = verifyRelease(artifacts, release, defaultAssetPolicy, false).then((value) => value, (cause) => {
|
|
20
|
+
entry = verifyRelease(artifacts, release, defaultAssetPolicy, false).catch((cause) => {
|
|
19
21
|
verified.delete(key);
|
|
20
22
|
throw cause;
|
|
21
23
|
});
|
|
22
24
|
verified.set(key, entry);
|
|
25
|
+
for (const oldest of verified.keys()) {
|
|
26
|
+
if (verified.size <= limit)
|
|
27
|
+
break;
|
|
28
|
+
verified.delete(oldest);
|
|
29
|
+
}
|
|
23
30
|
}
|
|
24
31
|
return entry;
|
|
25
32
|
};
|
package/dist/invocation.d.ts
CHANGED
|
@@ -193,7 +193,7 @@ export declare function assertBackgroundBinding(frame: InvocationFrame<Backgroun
|
|
|
193
193
|
export declare function admitBackgroundFrame(key: CryptoKey, frame: InvocationFrame<BackgroundFrameState>, raw: unknown): Promise<ClaimedJobAdmission>;
|
|
194
194
|
/** The host's native guest-abort and authority-close hooks. */
|
|
195
195
|
export type InvocationGatePorts<Host> = Readonly<{
|
|
196
|
-
/** Stop the guest when
|
|
196
|
+
/** Stop the guest when live authority closes early (revocation, expiry). */
|
|
197
197
|
abortGuest: () => Promise<void>;
|
|
198
198
|
/** Release the host resources belonging to this completed or revoked frame. */
|
|
199
199
|
close: (frame: InvocationFrame<Host>) => Promise<void>;
|
|
@@ -204,8 +204,8 @@ export type InvocationGatePorts<Host> = Readonly<{
|
|
|
204
204
|
export type InvocationGate<Host> = Readonly<{
|
|
205
205
|
/** Run work within one immutable frame and close its authority afterward. */
|
|
206
206
|
run: <A>(facts: InvocationFacts, host: Host, work: (frame: InvocationFrame<Host>) => Promise<A>) => Promise<A>;
|
|
207
|
-
/** Resolve
|
|
208
|
-
active: () => InvocationFrame<Host>;
|
|
207
|
+
/** Resolve one live frame by its id, refusing a closed or unknown frame. */
|
|
208
|
+
active: (frameId: string) => InvocationFrame<Host>;
|
|
209
209
|
/** Close live authority, optionally restricted to frames of a selected run. */
|
|
210
210
|
revoke: (reason?: Error, target?: Readonly<{
|
|
211
211
|
runId: string;
|
|
@@ -215,7 +215,14 @@ export type InvocationGate<Host> = Readonly<{
|
|
|
215
215
|
}>;
|
|
216
216
|
/** Decode only identities minted from the invocation-frame ID family. */
|
|
217
217
|
export declare const invocationFrameIdSchema: Schema.String;
|
|
218
|
-
/**
|
|
218
|
+
/**
|
|
219
|
+
* Admit frames against one guest. Verified foreground reads share it, up to
|
|
220
|
+
* eight at once; writes, raw backend requests, Gills and replay run alone. Every guest binding packet names its frame, so a finished
|
|
221
|
+
* frame's leftover timers cannot borrow a later frame's authority and a
|
|
222
|
+
* normal finish keeps the guest warm. Closing authority early (revocation,
|
|
223
|
+
* expiry, a write preempting replay) aborts the guest and so every frame
|
|
224
|
+
* sharing it; replay only yields to a verified write.
|
|
225
|
+
*/
|
|
219
226
|
export declare function createInvocationGate<Host>(ports: InvocationGatePorts<Host>): InvocationGate<Host>;
|
|
220
227
|
/** Verified selection, artifact and binding grants before caller attribution. */
|
|
221
228
|
export type InvocationAuthority = Omit<InvocationFacts, "subject" | "deadlineAt">;
|
package/dist/invocation.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { approvedBindingGrants } from "./apps/gate.js";
|
|
2
2
|
import { appToolPolicySchema } from "./apps/tools.js";
|
|
3
|
-
import * as Effect from "effect/Effect";
|
|
4
3
|
import * as Schema from "effect/Schema";
|
|
5
|
-
import * as Semaphore from "effect/Semaphore";
|
|
6
4
|
import { defineIdFamily } from "@fungi.computer/cairn/id";
|
|
7
5
|
import { sha256DigestSchema } from "@fungi.computer/puffball/effect";
|
|
8
6
|
import { appBackendBindingKinds, appBackendBindingsSchema, appMethodCallContextSchema, } from "@fungi.computer/puffball/app";
|
|
@@ -116,6 +114,8 @@ export async function admitBackgroundFrame(key, frame, raw) {
|
|
|
116
114
|
frame.host.origin = decision.kind;
|
|
117
115
|
return decision;
|
|
118
116
|
}
|
|
117
|
+
/** Foreground reads one Instance runs at once; every other frame runs alone. */
|
|
118
|
+
const concurrentReadFrames = 8;
|
|
119
119
|
const frameIds = defineIdFamily({
|
|
120
120
|
family: "app-invocation-frame",
|
|
121
121
|
version: 1,
|
|
@@ -152,28 +152,79 @@ function immutableFacts(facts) {
|
|
|
152
152
|
Object.freeze(facts.ancestry);
|
|
153
153
|
return Object.freeze(facts);
|
|
154
154
|
}
|
|
155
|
-
/**
|
|
155
|
+
/**
|
|
156
|
+
* First-come permits over one guest: a waiting exclusive frame holds back
|
|
157
|
+
* every frame queued after it, so a stream of reads never starves a write.
|
|
158
|
+
*/
|
|
159
|
+
function orderedPermits(total) {
|
|
160
|
+
let available = total;
|
|
161
|
+
const waiting = [];
|
|
162
|
+
const admit = () => {
|
|
163
|
+
for (let next = waiting[0]; next !== undefined && next.permits <= available; next = waiting[0]) {
|
|
164
|
+
waiting.shift();
|
|
165
|
+
available -= next.permits;
|
|
166
|
+
next.enter();
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
return async (permits, work) => {
|
|
170
|
+
await new Promise((enter) => {
|
|
171
|
+
waiting.push({ permits, enter });
|
|
172
|
+
admit();
|
|
173
|
+
});
|
|
174
|
+
try {
|
|
175
|
+
return await work();
|
|
176
|
+
}
|
|
177
|
+
catch (cause) {
|
|
178
|
+
throw cause instanceof Error
|
|
179
|
+
? cause
|
|
180
|
+
: new Error("App invocation failed", { cause });
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
available += permits;
|
|
184
|
+
admit();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** A foreground call to a verified read method; only these share the guest. */
|
|
189
|
+
function concurrentRead(facts) {
|
|
190
|
+
return (facts.subject.kind === "call" &&
|
|
191
|
+
facts.subject.replay === undefined &&
|
|
192
|
+
facts.subject.method?.effect === "read");
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Admit frames against one guest. Verified foreground reads share it, up to
|
|
196
|
+
* eight at once; writes, raw backend requests, Gills and replay run alone. Every guest binding packet names its frame, so a finished
|
|
197
|
+
* frame's leftover timers cannot borrow a later frame's authority and a
|
|
198
|
+
* normal finish keeps the guest warm. Closing authority early (revocation,
|
|
199
|
+
* expiry, a write preempting replay) aborts the guest and so every frame
|
|
200
|
+
* sharing it; replay only yields to a verified write.
|
|
201
|
+
*/
|
|
156
202
|
export function createInvocationGate(ports) {
|
|
157
|
-
const
|
|
203
|
+
const withPermits = orderedPermits(concurrentReadFrames);
|
|
158
204
|
const now = ports.now ?? Date.now;
|
|
159
205
|
let foregroundWaiting = 0;
|
|
160
|
-
|
|
161
|
-
const
|
|
206
|
+
const live = new Map();
|
|
207
|
+
const finish = (state, reason) => {
|
|
162
208
|
if (state.closing)
|
|
163
209
|
return state.closing;
|
|
164
|
-
|
|
165
|
-
active = undefined;
|
|
210
|
+
live.delete(state.frame.frameId);
|
|
166
211
|
state.controller.abort(reason);
|
|
167
|
-
state.closing = (
|
|
168
|
-
try {
|
|
169
|
-
await ports.abortGuest();
|
|
170
|
-
}
|
|
171
|
-
finally {
|
|
172
|
-
await ports.close(state.frame);
|
|
173
|
-
}
|
|
174
|
-
})();
|
|
212
|
+
state.closing = ports.close(state.frame);
|
|
175
213
|
return state.closing;
|
|
176
214
|
};
|
|
215
|
+
// The guest is shared: aborting it ends every live frame's guest work, and
|
|
216
|
+
// each of those frames closes only after the guest abort has completed.
|
|
217
|
+
const abortAll = (reason) => {
|
|
218
|
+
const states = [...live.values()];
|
|
219
|
+
for (const state of states) {
|
|
220
|
+
live.delete(state.frame.frameId);
|
|
221
|
+
state.controller.abort(reason);
|
|
222
|
+
}
|
|
223
|
+
const guest = ports.abortGuest();
|
|
224
|
+
for (const state of states)
|
|
225
|
+
state.closing ??= guest.finally(() => ports.close(state.frame));
|
|
226
|
+
return Promise.all([guest, ...states.map((state) => state.closing)]).then(() => undefined);
|
|
227
|
+
};
|
|
177
228
|
const run = async (input, host, work) => {
|
|
178
229
|
let facts;
|
|
179
230
|
try {
|
|
@@ -196,64 +247,63 @@ export function createInvocationGate(ports) {
|
|
|
196
247
|
if (queuedForeground &&
|
|
197
248
|
facts.subject.kind === "call" &&
|
|
198
249
|
facts.subject.method?.effect === "write" &&
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
await
|
|
202
|
-
return await
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
250
|
+
[...live.values()].some(({ frame }) => frame.facts.subject.kind === "call" &&
|
|
251
|
+
frame.facts.subject.replay !== undefined))
|
|
252
|
+
await abortAll(new AppInstanceError("revoked"));
|
|
253
|
+
return await withPermits(concurrentRead(facts) ? 1 : concurrentReadFrames, async () => {
|
|
254
|
+
if (facts.subject.kind === "call" &&
|
|
255
|
+
facts.subject.replay !== undefined &&
|
|
256
|
+
foregroundWaiting > 0)
|
|
257
|
+
throw new AppInstanceError("revoked");
|
|
258
|
+
if (queuedForeground) {
|
|
259
|
+
foregroundWaiting -= 1;
|
|
260
|
+
queuedForeground = false;
|
|
261
|
+
}
|
|
262
|
+
const { deadlineAt } = facts;
|
|
263
|
+
if (deadlineAt <= now())
|
|
264
|
+
throw new AppInstanceError("expired");
|
|
265
|
+
const frameId = frameIds.frame.generate();
|
|
266
|
+
const controller = new AbortController();
|
|
267
|
+
const frame = Object.freeze({
|
|
268
|
+
frameId,
|
|
269
|
+
facts,
|
|
270
|
+
host,
|
|
271
|
+
signal: controller.signal,
|
|
272
|
+
});
|
|
273
|
+
const state = { frame, controller };
|
|
274
|
+
live.set(frameId, state);
|
|
275
|
+
let timer;
|
|
276
|
+
let stop = () => undefined;
|
|
277
|
+
const revoked = new Promise((_resolve, reject) => {
|
|
278
|
+
const onAbort = () => {
|
|
279
|
+
const reason = controller.signal.reason;
|
|
280
|
+
reject(reason instanceof Error
|
|
281
|
+
? reason
|
|
282
|
+
: new AppInstanceError("revoked"));
|
|
283
|
+
};
|
|
284
|
+
controller.signal.addEventListener("abort", onAbort, {
|
|
285
|
+
once: true,
|
|
222
286
|
});
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
});
|
|
244
|
-
try {
|
|
245
|
-
return await Promise.race([work(frame), revoked]);
|
|
246
|
-
}
|
|
247
|
-
finally {
|
|
248
|
-
clearTimeout(timer);
|
|
249
|
-
stop();
|
|
250
|
-
await close(state, new AppInstanceError("revoked"));
|
|
251
|
-
}
|
|
252
|
-
},
|
|
253
|
-
catch: (cause) => cause instanceof Error
|
|
254
|
-
? cause
|
|
255
|
-
: new Error("App invocation failed", { cause }),
|
|
256
|
-
})));
|
|
287
|
+
stop = () => {
|
|
288
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
289
|
+
};
|
|
290
|
+
timer = setTimeout(() => {
|
|
291
|
+
// An expired frame's guest may still be running. Each frame's own
|
|
292
|
+
// run awaits its close, so a failure reaches that caller.
|
|
293
|
+
void Promise.allSettled([
|
|
294
|
+
abortAll(new AppInstanceError("expired")),
|
|
295
|
+
]);
|
|
296
|
+
}, Math.max(0, deadlineAt - now()));
|
|
297
|
+
});
|
|
298
|
+
try {
|
|
299
|
+
return await Promise.race([work(frame), revoked]);
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
stop();
|
|
304
|
+
await finish(state, new AppInstanceError("revoked"));
|
|
305
|
+
}
|
|
306
|
+
});
|
|
257
307
|
}
|
|
258
308
|
finally {
|
|
259
309
|
if (queuedForeground)
|
|
@@ -262,27 +312,20 @@ export function createInvocationGate(ports) {
|
|
|
262
312
|
};
|
|
263
313
|
return {
|
|
264
314
|
run,
|
|
265
|
-
exclusive: (work) =>
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
active: () => {
|
|
272
|
-
if (active === undefined ||
|
|
273
|
-
active.controller.signal.aborted ||
|
|
274
|
-
active.frame.facts.deadlineAt <= now())
|
|
315
|
+
exclusive: (work) => withPermits(concurrentReadFrames, work),
|
|
316
|
+
active: (frameId) => {
|
|
317
|
+
const state = live.get(frameId);
|
|
318
|
+
if (state === undefined ||
|
|
319
|
+
state.controller.signal.aborted ||
|
|
320
|
+
state.frame.facts.deadlineAt <= now())
|
|
275
321
|
throw new AppInstanceError("revoked");
|
|
276
|
-
return
|
|
322
|
+
return state.frame;
|
|
277
323
|
},
|
|
278
324
|
revoke: (reason = new AppInstanceError("revoked"), target) => {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
active.frame.facts.subject.context.workflow?.runId !== target.runId))
|
|
284
|
-
return Promise.resolve();
|
|
285
|
-
return close(active, reason);
|
|
325
|
+
const matched = [...live.values()].some(({ frame }) => target === undefined ||
|
|
326
|
+
(frame.facts.subject.kind === "call" &&
|
|
327
|
+
frame.facts.subject.context.workflow?.runId === target.runId));
|
|
328
|
+
return matched ? abortAll(reason) : Promise.resolve();
|
|
286
329
|
},
|
|
287
330
|
};
|
|
288
331
|
}
|
package/dist/keys.d.ts
CHANGED
|
@@ -2,12 +2,13 @@ import type { Grant, GrantVocabulary, Scope } from "@fungi.computer/ear-tag/gran
|
|
|
2
2
|
import type { KeyPrincipal, KeyRecord, KeyStore } from "@fungi.computer/ear-tag/keys";
|
|
3
3
|
import type { AppToolPolicy } from "./apps/tools.js";
|
|
4
4
|
/** Every capability an Oyster key may carry. */
|
|
5
|
-
export type OysterKeyCapability = "apps.read" | "apps.use" | "computer.use" | "machine.enroll";
|
|
5
|
+
export type OysterKeyCapability = "apps.read" | "apps.use" | "agents.use" | "computer.use" | "machine.enroll";
|
|
6
6
|
/**
|
|
7
7
|
* `apps.use` also reaches `apps.read` (one level, per `defineGrants`'s own
|
|
8
8
|
* `implies`): a key that may change an App's data may also read it.
|
|
9
9
|
* `computer.use` has no narrower capability under it; the Computer is on or
|
|
10
10
|
* off, host-wide.
|
|
11
|
+
* `agents.use` selects Agent ids independently of App grants.
|
|
11
12
|
* `machine.enroll` admits enrollment only; it implies no App or Computer access.
|
|
12
13
|
* The enrollment owner, not this vocabulary, owns one-use consumption.
|
|
13
14
|
*/
|
|
@@ -46,6 +47,8 @@ export type LiveOysterKey = Readonly<{
|
|
|
46
47
|
* binding name for MCP) to reach the full `AppToolAccess`.
|
|
47
48
|
*/
|
|
48
49
|
export declare function keyAppAccess(key: LiveOysterKey, installationId: string): AppToolPolicy | null;
|
|
50
|
+
/** Agent access is scoped to the granted Agent ids, never inferred from App grants. */
|
|
51
|
+
export declare function keyAgentAccess(key: LiveOysterKey, agentId: string): boolean;
|
|
49
52
|
/** Whether a live key's own grants turn its Computer access on. Off by default. */
|
|
50
53
|
export declare function keyAllowsComputerUse(key: LiveOysterKey): boolean;
|
|
51
54
|
/** Whether a live key's grants permit a one-use device enrollment. */
|
package/dist/keys.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Oyster's own Ear Tag vocabulary: named keys an owner issues for
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Oyster's own Ear Tag vocabulary: named keys an owner issues for selected
|
|
3
|
+
* Apps and Agents, its Computer or machine enrollment. App grants never imply
|
|
4
|
+
* Agent access. A key's id is its name, so name-derived identities stay stable.
|
|
5
5
|
*/
|
|
6
6
|
import { defineGrants } from "@fungi.computer/ear-tag/grants";
|
|
7
7
|
/**
|
|
@@ -9,11 +9,18 @@ import { defineGrants } from "@fungi.computer/ear-tag/grants";
|
|
|
9
9
|
* `implies`): a key that may change an App's data may also read it.
|
|
10
10
|
* `computer.use` has no narrower capability under it; the Computer is on or
|
|
11
11
|
* off, host-wide.
|
|
12
|
+
* `agents.use` selects Agent ids independently of App grants.
|
|
12
13
|
* `machine.enroll` admits enrollment only; it implies no App or Computer access.
|
|
13
14
|
* The enrollment owner, not this vocabulary, owns one-use consumption.
|
|
14
15
|
*/
|
|
15
16
|
export const oysterKeyVocabulary = defineGrants({
|
|
16
|
-
capabilities: [
|
|
17
|
+
capabilities: [
|
|
18
|
+
"apps.read",
|
|
19
|
+
"apps.use",
|
|
20
|
+
"agents.use",
|
|
21
|
+
"computer.use",
|
|
22
|
+
"machine.enroll",
|
|
23
|
+
],
|
|
17
24
|
implies: { "apps.use": ["apps.read"] },
|
|
18
25
|
});
|
|
19
26
|
/** A grant reaching every current and future installation for `capability`. */
|
|
@@ -48,6 +55,10 @@ export function keyAppAccess(key, installationId) {
|
|
|
48
55
|
grantedBy: key.owner,
|
|
49
56
|
};
|
|
50
57
|
}
|
|
58
|
+
/** Agent access is scoped to the granted Agent ids, never inferred from App grants. */
|
|
59
|
+
export function keyAgentAccess(key, agentId) {
|
|
60
|
+
return oysterKeyVocabulary.grantsAllow(key.grants, "agents.use", agentId);
|
|
61
|
+
}
|
|
51
62
|
/** Whether a live key's own grants turn its Computer access on. Off by default. */
|
|
52
63
|
export function keyAllowsComputerUse(key) {
|
|
53
64
|
return oysterKeyVocabulary.grantsAllow(key.grants, "computer.use");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { PostDeliveryResult, PostEnvelope } from "@fungi.computer/carrier/post";
|
|
3
3
|
import type { MachineAgentHome } from "./home.js";
|
|
4
|
+
import type { MachineLinkDispositionMail } from "./disposition.js";
|
|
4
5
|
export declare const machineLinkIdentitySchema: z.ZodObject<{
|
|
5
6
|
teamId: z.ZodString;
|
|
6
7
|
deviceId: z.ZodString;
|
|
@@ -73,10 +74,16 @@ export type MachineLinkAuthority = Readonly<{
|
|
|
73
74
|
agentId: string;
|
|
74
75
|
name: string;
|
|
75
76
|
}[]>;
|
|
77
|
+
/** Current home of one Team Agent, including machine-hosted homes. */
|
|
78
|
+
agentHome(id: MachineLinkIdentity, agentId: string): Promise<MachineAgentHome | null>;
|
|
76
79
|
/** Sender, actor and the target's actual home, rechecked for this act. */
|
|
77
80
|
resolveTarget(id: MachineLinkIdentity, caller: MachineLinkCaller, agentId: string, requesterAgentId: string | null): Promise<MachineLinkTargetFacts>;
|
|
78
81
|
/** Carry mail received from this device to its target's actual home. */
|
|
79
82
|
deliverHome(id: MachineLinkIdentity, caller: MachineLinkCaller, target: MachineLinkTargetFacts, envelope: PostEnvelope): Promise<PostDeliveryResult>;
|
|
83
|
+
/** Deliver a typed expiry reply authenticated by this device's held row. */
|
|
84
|
+
deliverUndeliverable(id: MachineLinkIdentity, original: MachineLinkDispositionMail, reply: MachineLinkDispositionMail): Promise<PostDeliveryResult>;
|
|
85
|
+
/** Ask the sending link to attest that it durably staged this disposition. */
|
|
86
|
+
verifyRelayDisposition(relay: MachineLinkIdentity, original: PostEnvelope, reply: PostEnvelope): Promise<boolean>;
|
|
80
87
|
/** Whether this link's replay ledger may be erased now. */
|
|
81
88
|
canErase(id: MachineLinkIdentity): Promise<boolean>;
|
|
82
89
|
/** Push one committed fence fact to the device's link (C2 promptness). */
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as Schema from "effect/Schema";
|
|
2
2
|
import { type PostEnvelope, type PostDeliveryResult, type PostRoute } from "@fungi.computer/carrier/post";
|
|
3
3
|
import { type MachineAgentHome } from "./home.js";
|
|
4
|
+
import type { MachineLinkIdentity } from "./authority.js";
|
|
5
|
+
import type { MachineLinkDispositionMail } from "./disposition.js";
|
|
4
6
|
/**
|
|
5
7
|
* Home selection only, as the namespace under the host's disclosure-owning
|
|
6
8
|
* Agent mail route. Carrier retains retries; the receiving Agent admits work.
|
|
@@ -16,3 +18,13 @@ export declare function createHomeAwareAgentMailNamespace(options: {
|
|
|
16
18
|
/** Host's Agent grammar: validates Accepted or Held without conflating them. */
|
|
17
19
|
accepted: (receipt: Schema.Json) => void;
|
|
18
20
|
}): PostRoute<never, never>["namespace"];
|
|
21
|
+
/** Route one checked expiry reply through the same home-aware mail owner. */
|
|
22
|
+
export declare function deliverUndeliverableMail(options: {
|
|
23
|
+
identity: MachineLinkIdentity;
|
|
24
|
+
original: MachineLinkDispositionMail;
|
|
25
|
+
reply: MachineLinkDispositionMail;
|
|
26
|
+
senderHome: MachineAgentHome;
|
|
27
|
+
hosted: PostRoute<never, never>["namespace"];
|
|
28
|
+
machine: (deviceId: string, envelope: PostEnvelope) => Promise<PostDeliveryResult>;
|
|
29
|
+
accepted: (receipt: Schema.Json) => void;
|
|
30
|
+
}): Promise<PostDeliveryResult>;
|
|
@@ -23,12 +23,17 @@ export function createHomeAwareAgentMailNamespace(options) {
|
|
|
23
23
|
const mail = Schema.decodeUnknownSync(BirdDogMailSchema, {
|
|
24
24
|
onExcessProperty: "error",
|
|
25
25
|
})(envelope.event);
|
|
26
|
+
const senderMatches = mail.from.kind === "agent" &&
|
|
27
|
+
mail.from.agentId === options.sender.agentId;
|
|
28
|
+
const systemOfflineDisposition = mail.from.kind === "system" &&
|
|
29
|
+
mail.reply?.outcome === "undeliverable" &&
|
|
30
|
+
mail.reply.reason === "recipient_offline";
|
|
26
31
|
if (envelope.kind !== "agentMail" ||
|
|
27
32
|
envelope.key !== mail.key ||
|
|
28
33
|
address.scopeId !== options.sender.teamId ||
|
|
29
34
|
address.agentId !== agentId ||
|
|
30
35
|
mail.to.agentId !== agentId ||
|
|
31
|
-
|
|
36
|
+
(!senderMatches && !systemOfflineDisposition))
|
|
32
37
|
return "refused";
|
|
33
38
|
const projected = await options.home(agentId);
|
|
34
39
|
if (projected === null)
|
|
@@ -52,3 +57,19 @@ export function createHomeAwareAgentMailNamespace(options) {
|
|
|
52
57
|
}),
|
|
53
58
|
};
|
|
54
59
|
}
|
|
60
|
+
/** Route one checked expiry reply through the same home-aware mail owner. */
|
|
61
|
+
export function deliverUndeliverableMail(options) {
|
|
62
|
+
const sent = options.original.event;
|
|
63
|
+
if (sent.from.kind !== "agent")
|
|
64
|
+
return Promise.resolve("refused");
|
|
65
|
+
const senderAgentId = sent.from.agentId;
|
|
66
|
+
return createHomeAwareAgentMailNamespace({
|
|
67
|
+
sender: { teamId: options.identity.teamId, agentId: sent.to.agentId },
|
|
68
|
+
home: (agentId) => Promise.resolve(agentId === senderAgentId ? options.senderHome : null),
|
|
69
|
+
hosted: options.hosted,
|
|
70
|
+
machine: options.machine,
|
|
71
|
+
accepted: options.accepted,
|
|
72
|
+
})
|
|
73
|
+
.getByName(senderAgentId)
|
|
74
|
+
.deliver(options.reply);
|
|
75
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import type { BirdDogMail } from "@fungi.computer/bird-dog";
|
|
3
|
+
import { agentMailAddressSchema } from "@fungi.computer/seam/machine-link-protocol";
|
|
4
|
+
import type { PostEnvelope } from "@fungi.computer/carrier/post";
|
|
5
|
+
import type { MachineLinkIdentity } from "./authority.js";
|
|
6
|
+
export type MachineLinkDispositionMail = Readonly<{
|
|
7
|
+
key: string;
|
|
8
|
+
kind: "agentMail";
|
|
9
|
+
address: Schema.Schema.Type<typeof agentMailAddressSchema>;
|
|
10
|
+
event: BirdDogMail;
|
|
11
|
+
}>;
|
|
12
|
+
/** Parse and authenticate the only terminal reply a device relay may author. */
|
|
13
|
+
export declare function machineLinkDisposition(identity: MachineLinkIdentity, rawOriginal: PostEnvelope, rawReply: PostEnvelope): Readonly<{
|
|
14
|
+
original: MachineLinkDispositionMail;
|
|
15
|
+
reply: MachineLinkDispositionMail;
|
|
16
|
+
}> | undefined;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import { BirdDogMailSchema } from "@fungi.computer/bird-dog/effect";
|
|
3
|
+
import { agentMailAddressSchema } from "@fungi.computer/seam/machine-link-protocol";
|
|
4
|
+
const machineMailEnvelope = Schema.Struct({
|
|
5
|
+
key: Schema.String,
|
|
6
|
+
kind: Schema.Literal("agentMail"),
|
|
7
|
+
address: agentMailAddressSchema,
|
|
8
|
+
event: BirdDogMailSchema,
|
|
9
|
+
});
|
|
10
|
+
function originalMatchesLink(identity, original) {
|
|
11
|
+
const sentFrom = original.event.from;
|
|
12
|
+
if (sentFrom.kind !== "agent")
|
|
13
|
+
return false;
|
|
14
|
+
return (original.key === original.event.key &&
|
|
15
|
+
original.address.scopeId === identity.teamId &&
|
|
16
|
+
original.address.agentId === original.event.to.agentId &&
|
|
17
|
+
original.event.reply === undefined);
|
|
18
|
+
}
|
|
19
|
+
function reverseIdentityMatches(original, reply) {
|
|
20
|
+
const sentFrom = original.event.from;
|
|
21
|
+
if (sentFrom.kind !== "agent")
|
|
22
|
+
return false;
|
|
23
|
+
return (reply.event.from.kind === "system" &&
|
|
24
|
+
reply.event.to.agentId === sentFrom.agentId &&
|
|
25
|
+
reply.event.to.sessionId === sentFrom.sessionId);
|
|
26
|
+
}
|
|
27
|
+
function replyMatchesDisposition(identity, original, reply) {
|
|
28
|
+
const sent = original.event;
|
|
29
|
+
const returned = reply.event;
|
|
30
|
+
if (sent.from.kind !== "agent")
|
|
31
|
+
return false;
|
|
32
|
+
const senderAgentId = sent.from.agentId;
|
|
33
|
+
const expectedKey = `${original.key}:reply:0`;
|
|
34
|
+
return [
|
|
35
|
+
reply.key === expectedKey,
|
|
36
|
+
returned.key === reply.key,
|
|
37
|
+
reply.address.scopeId === identity.teamId,
|
|
38
|
+
reply.address.agentId === senderAgentId,
|
|
39
|
+
returned.replyTo.length === 0,
|
|
40
|
+
returned.reply?.outcome === "undeliverable",
|
|
41
|
+
returned.reply?.outcome === "undeliverable" &&
|
|
42
|
+
returned.reply.reason === "recipient_offline",
|
|
43
|
+
reverseIdentityMatches(original, reply),
|
|
44
|
+
].every((valid) => valid);
|
|
45
|
+
}
|
|
46
|
+
/** Parse and authenticate the only terminal reply a device relay may author. */
|
|
47
|
+
export function machineLinkDisposition(identity, rawOriginal, rawReply) {
|
|
48
|
+
let original;
|
|
49
|
+
let reply;
|
|
50
|
+
try {
|
|
51
|
+
original = Schema.decodeUnknownSync(machineMailEnvelope, {
|
|
52
|
+
onExcessProperty: "error",
|
|
53
|
+
})(rawOriginal);
|
|
54
|
+
reply = Schema.decodeUnknownSync(machineMailEnvelope, {
|
|
55
|
+
onExcessProperty: "error",
|
|
56
|
+
})(rawReply);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
if (!originalMatchesLink(identity, original) ||
|
|
62
|
+
!replyMatchesDisposition(identity, original, reply))
|
|
63
|
+
return undefined;
|
|
64
|
+
return { original, reply };
|
|
65
|
+
}
|
|
@@ -33,6 +33,8 @@ export declare class MachineLinkHost {
|
|
|
33
33
|
authorize: (deviceId: string) => Promise<MachineLinkDevice>;
|
|
34
34
|
replay: () => Promise<SeamReplayStore>;
|
|
35
35
|
receive: (device: MachineLinkDevice, envelope: PostEnvelope) => Promise<PostDeliveryResult>;
|
|
36
|
+
/** Deliver one relay-authored reply to its original sender. */
|
|
37
|
+
disposition: (original: PostEnvelope, reply: PostEnvelope) => Promise<PostDeliveryResult>;
|
|
36
38
|
/** Arm the Durable Object alarm for Carrier/Watchdog progress. */
|
|
37
39
|
scheduleWake: (at: number) => Promise<void>;
|
|
38
40
|
/** The host's failure log for refused dials and closed links. */
|
|
@@ -44,12 +46,17 @@ export declare class MachineLinkHost {
|
|
|
44
46
|
connected(socket: WebSocket, deviceId: string): Promise<void>;
|
|
45
47
|
private publishDirectory;
|
|
46
48
|
private channel;
|
|
47
|
-
deliver(deviceId: string, envelope: PostEnvelope
|
|
49
|
+
deliver(deviceId: string, envelope: PostEnvelope, options?: Readonly<{
|
|
50
|
+
refuseDuplicate?: boolean;
|
|
51
|
+
}>): Promise<PostDeliveryResult>;
|
|
48
52
|
private deliverLiveOperation;
|
|
49
53
|
private deliverCustodyMail;
|
|
50
54
|
/** Run due Carrier work; expired rows remain visible as Carrier dead letters. */
|
|
51
55
|
alarm(): Promise<void>;
|
|
52
|
-
/**
|
|
56
|
+
/** Turn each terminal incoming-mail row into a durable reply outbox row. */
|
|
57
|
+
private stageUndeliverableReplies;
|
|
58
|
+
private stageUndeliverableReply;
|
|
59
|
+
/** Expose terminal custody rows for the device-owned diagnostic surface. */
|
|
53
60
|
deadLetters(afterKey?: string): Promise<Readonly<{
|
|
54
61
|
letters: readonly Readonly<{
|
|
55
62
|
key: string;
|
|
@@ -59,6 +66,8 @@ export declare class MachineLinkHost {
|
|
|
59
66
|
}>[];
|
|
60
67
|
nextCursor: string | undefined;
|
|
61
68
|
}>>;
|
|
69
|
+
/** Prove a disposition is still present in this link's durable Post outbox. */
|
|
70
|
+
hasStagedDisposition(deviceId: string, original: PostEnvelope, reply: PostEnvelope): Promise<boolean>;
|
|
62
71
|
private deliverToSocket;
|
|
63
72
|
private custody;
|
|
64
73
|
private createCustody;
|