@fungi.computer/inkcap 0.1.0 → 0.2.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.
@@ -26,16 +26,16 @@ import {
26
26
  listOutputSchema,
27
27
  readBytesInputSchema,
28
28
  writeOutputSchema,
29
- } from "@fungi.computer/computer/operations";
29
+ } from "@fungi.computer/computer-contract";
30
30
  export type InkcapBackendContext = {
31
31
  activity: DocumentActivityStore;
32
32
  documentsChanged(computerId: string, sequence: number): Promise<void>;
33
33
  env: {
34
34
  APP_STORAGE: Pick<AppStorageWire, "read" | "write">;
35
- COMPUTER?: {
36
- list?(input: z.output<typeof listInput>): Promise<WhistleJsonValue>;
37
- read?(input: z.output<typeof readInput>): Promise<WhistleJsonValue>;
38
- write?(input: z.output<typeof writeInput>): Promise<WhistleJsonValue>;
35
+ COMPUTER: {
36
+ list(input: z.output<typeof listInput>): Promise<WhistleJsonValue>;
37
+ read(input: z.output<typeof readInput>): Promise<WhistleJsonValue>;
38
+ write(input: z.output<typeof writeInput>): Promise<WhistleJsonValue>;
39
39
  };
40
40
  };
41
41
  };
@@ -103,6 +103,19 @@ const refusal = z.union([
103
103
  const listReply = z.union([listOutputSchema, refusal]);
104
104
  const readReply = z.union([readOutput, refusal]);
105
105
  const writeReply = z.union([writeOutputSchema, refusal]);
106
+ // The host always supplies callable bindings; admission is checked on invocation.
107
+ const bindingNotGranted = z.object({ code: z.literal("binding_not_granted") });
108
+ async function computerCall(
109
+ call: () => Promise<WhistleJsonValue>,
110
+ ): Promise<WhistleJsonValue> {
111
+ try {
112
+ return await call();
113
+ } catch (cause) {
114
+ if (bindingNotGranted.safeParse(cause).success)
115
+ return { error: "not_granted" };
116
+ throw cause;
117
+ }
118
+ }
106
119
  async function recordDocument(
107
120
  input: z.output<typeof documentIdentitySchema>,
108
121
  context: InkcapBackendContext,
@@ -164,9 +177,11 @@ export const appBackend = defineAppBackend({
164
177
  input: listInput,
165
178
  output: listReply,
166
179
  handler: async (input, context: InkcapBackendContext, caller) =>
167
- caller.computerId === null || !context.env.COMPUTER?.list
180
+ caller.computerId === null
168
181
  ? refusal.parse({ error: "not_granted" })
169
- : listReply.parse(await context.env.COMPUTER.list(input)),
182
+ : listReply.parse(
183
+ await computerCall(() => context.env.COMPUTER.list(input)),
184
+ ),
170
185
  }),
171
186
  readFile: defineAppMethod({
172
187
  description:
@@ -176,9 +191,11 @@ export const appBackend = defineAppBackend({
176
191
  input: readInput,
177
192
  output: readReply,
178
193
  handler: async (input, context: InkcapBackendContext, caller) =>
179
- caller.computerId === null || !context.env.COMPUTER?.read
194
+ caller.computerId === null
180
195
  ? refusal.parse({ error: "not_granted" })
181
- : readReply.parse(await context.env.COMPUTER.read(input)),
196
+ : readReply.parse(
197
+ await computerCall(() => context.env.COMPUTER.read(input)),
198
+ ),
182
199
  }),
183
200
  writeFile: defineAppMethod({
184
201
  description:
@@ -208,9 +225,11 @@ export const appBackend = defineAppBackend({
208
225
  },
209
226
  },
210
227
  handler: async (input, context: InkcapBackendContext, caller) =>
211
- caller.computerId === null || !context.env.COMPUTER?.write
228
+ caller.computerId === null
212
229
  ? refusal.parse({ error: "not_granted" })
213
- : writeReply.parse(await context.env.COMPUTER.write(input)),
230
+ : writeReply.parse(
231
+ await computerCall(() => context.env.COMPUTER.write(input)),
232
+ ),
214
233
  }),
215
234
  },
216
235
  });
@@ -27,7 +27,7 @@ it("projects the saved Recent Files source with stable identities and its own cu
27
27
  documentsChanged: () => Promise.resolve(),
28
28
  env: {
29
29
  APP_STORAGE: { read: unavailable, write: unavailable },
30
- COMPUTER: { read: unavailable },
30
+ COMPUTER: { list: unavailable, read: unavailable, write: unavailable },
31
31
  },
32
32
  };
33
33
  const caller = {
@@ -37,6 +37,7 @@ const unavailable = (): Promise<never> =>
37
37
  Promise.reject(new Error("Computer calls must not access repositories"));
38
38
  const unrelated = {
39
39
  APP_STORAGE: { read: unavailable, write: unavailable },
40
+ COMPUTER: { list: unavailable, read: unavailable, write: unavailable },
40
41
  };
41
42
  const emptyMarker = () =>
42
43
  Promise.resolve({
@@ -49,7 +50,7 @@ const emptyMarker = () =>
49
50
  canWriteTeam: false,
50
51
  },
51
52
  });
52
- type ComputerBinding = NonNullable<InkcapBackendContext["env"]["COMPUTER"]>;
53
+ type ComputerBinding = InkcapBackendContext["env"]["COMPUTER"];
53
54
 
54
55
  const caller: Parameters<typeof appBackend.call>[3] = {
55
56
  actor: { kind: "agent", agentId: "agent-a" },
@@ -69,9 +70,8 @@ it("lists files only through the admitted pinned Computer binding", async () =>
69
70
  env: {
70
71
  ...unrelated,
71
72
  COMPUTER: {
72
- list: (
73
- input: Parameters<NonNullable<ComputerBinding["list"]>>[0],
74
- ) => {
73
+ ...unrelated.COMPUTER,
74
+ list: (input: Parameters<ComputerBinding["list"]>[0]) => {
75
75
  calls.push(input);
76
76
  return Promise.resolve(reply);
77
77
  },
@@ -95,6 +95,7 @@ it("does not dispatch when the caller has no pinned Computer", async () => {
95
95
  env: {
96
96
  ...unrelated,
97
97
  COMPUTER: {
98
+ ...unrelated.COMPUTER,
98
99
  read: () => {
99
100
  calls++;
100
101
  return Promise.resolve({});
@@ -116,7 +117,8 @@ it("forwards bounded byte reads and compare-and-swap writes", async () => {
116
117
  env: {
117
118
  ...unrelated,
118
119
  COMPUTER: {
119
- read: (input: Parameters<NonNullable<ComputerBinding["read"]>>[0]) => {
120
+ ...unrelated.COMPUTER,
121
+ read: (input: Parameters<ComputerBinding["read"]>[0]) => {
120
122
  calls.push(input);
121
123
  return Promise.resolve({
122
124
  path: "/notes",
@@ -126,9 +128,7 @@ it("forwards bounded byte reads and compare-and-swap writes", async () => {
126
128
  truncated: false,
127
129
  });
128
130
  },
129
- write: (
130
- input: Parameters<NonNullable<ComputerBinding["write"]>>[0],
131
- ) => {
131
+ write: (input: Parameters<ComputerBinding["write"]>[0]) => {
132
132
  calls.push(input);
133
133
  return Promise.resolve({ path: "/notes", bytes: 2 });
134
134
  },
@@ -230,6 +230,7 @@ it("retains typed conflict and attachment budget refusals", async () => {
230
230
  env: {
231
231
  ...unrelated,
232
232
  COMPUTER: {
233
+ ...unrelated.COMPUTER,
233
234
  write: () => Promise.resolve(z.json().parse(response)),
234
235
  },
235
236
  },
@@ -239,16 +240,77 @@ it("retains typed conflict and attachment budget refusals", async () => {
239
240
  ).toEqual(response);
240
241
  }
241
242
  });
242
- it("refuses a missing Computer binding", async () => {
243
- expect(
244
- await appBackend.call(
245
- "readFile",
246
- { path: "/notes" },
247
- { activity, documentsChanged, env: unrelated },
248
- caller,
249
- ),
250
- ).toEqual({ error: "not_granted" });
251
- });
243
+ it.each([
244
+ ["listFiles", {}],
245
+ ["readFile", { path: "/notes" }],
246
+ ["writeFile", { path: "/notes", content: "hi" }],
247
+ ] as const)(
248
+ "maps the host's missing-grant refusal for %s",
249
+ async (method, input) => {
250
+ const denied = (): Promise<never> =>
251
+ Promise.reject(
252
+ Object.assign(new Error("App binding refused: binding_not_granted"), {
253
+ name: "AppInstanceError",
254
+ code: "binding_not_granted",
255
+ }),
256
+ );
257
+ await expect(
258
+ appBackend.call(
259
+ method,
260
+ input,
261
+ {
262
+ activity,
263
+ documentsChanged,
264
+ env: {
265
+ ...unrelated,
266
+ COMPUTER: { list: denied, read: denied, write: denied },
267
+ },
268
+ },
269
+ caller,
270
+ ),
271
+ ).resolves.toEqual({ error: "not_granted" });
272
+ },
273
+ );
274
+ it.each([
275
+ ["listFiles", {}],
276
+ ["readFile", { path: "/notes" }],
277
+ ["writeFile", { path: "/notes", content: "hi" }],
278
+ ] as const)(
279
+ "preserves other binding failures for %s",
280
+ async (method, input) => {
281
+ for (const failure of [
282
+ Object.assign(new Error("App binding refused: binding_unavailable"), {
283
+ name: "AppInstanceError",
284
+ code: "binding_unavailable",
285
+ }),
286
+ Object.assign(
287
+ new Error("App binding refused: invalid_invocation_facts"),
288
+ {
289
+ name: "AppInstanceError",
290
+ code: "invalid_invocation_facts",
291
+ },
292
+ ),
293
+ new Error("binding_not_granted"),
294
+ ]) {
295
+ const failed = (): Promise<never> => Promise.reject(failure);
296
+ await expect(
297
+ appBackend.call(
298
+ method,
299
+ input,
300
+ {
301
+ activity,
302
+ documentsChanged,
303
+ env: {
304
+ ...unrelated,
305
+ COMPUTER: { list: failed, read: failed, write: failed },
306
+ },
307
+ },
308
+ caller,
309
+ ),
310
+ ).rejects.toBe(failure);
311
+ }
312
+ },
313
+ );
252
314
  it("does not expose Computer methods through unauthenticated repository HTTP", async () => {
253
315
  expect(
254
316
  (await Inkcap.prototype.fetch(new Request("https://inkcap.test/"))).status,
@@ -274,6 +336,7 @@ it("persists admitted document activity and pages saved rows without provider ca
274
336
  activity,
275
337
  documentsChanged,
276
338
  env: {
339
+ ...unrelated,
277
340
  APP_STORAGE: {
278
341
  read: emptyMarker,
279
342
  write: (input) => {
@@ -311,6 +374,7 @@ it("keeps cursor pages and activity private to the actor and pinned Computer", a
311
374
  activity: createDocumentActivityStore(sql),
312
375
  documentsChanged,
313
376
  env: {
377
+ ...unrelated,
314
378
  APP_STORAGE: {
315
379
  read: emptyMarker,
316
380
  write: () =>
@@ -386,6 +450,7 @@ it("bounds retained documents and returns source continuation without trimming a
386
450
  activity,
387
451
  documentsChanged,
388
452
  env: {
453
+ ...unrelated,
389
454
  APP_STORAGE: {
390
455
  read: emptyMarker,
391
456
  write: () =>
@@ -440,6 +505,7 @@ it("preserves the original marker failure without any provider or retry", async
440
505
  activity,
441
506
  documentsChanged,
442
507
  env: {
508
+ ...unrelated,
443
509
  APP_STORAGE: {
444
510
  read: emptyMarker,
445
511
  write: () => {