@intentic/extension-api 1.326.0 → 1.327.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/src/testing.ts ADDED
@@ -0,0 +1,237 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { type ExtensionReach, extensionRouteReach } from "@intentic/extension-manifest/permissions";
5
+ import { type ExtensionEvent, extensionEventsUrl, extensionSettingsUrl } from "@intentic/sandbox-contract/extension-protocol";
6
+ import { EXTENSION_TOKEN_HEADER } from "@intentic/sandbox-contract/headers";
7
+ import { activateServerModule, createServerApi, deactivateWithin, healthWithin } from "./runtime.js";
8
+ import type {
9
+ ExtensionHealth,
10
+ ExtensionServerApi,
11
+ ExtensionServerModule,
12
+ ExtensionSettingValues,
13
+ ServerActivation,
14
+ ToolCard,
15
+ ToolCallContext,
16
+ ToolDefinition,
17
+ } from "./server.js";
18
+
19
+ // A backend under test, on the real backend api (runtime.ts) with only the daemon stood in for: every call the extension
20
+ // makes is judged by the same reach rule the daemon's grant applies (extension-manifest's extensionRouteReach), so a test
21
+ // that passes here does not lean on a route the manifest never declared. Its settings and event stream are the daemon's
22
+ // own routes, answered here; everything else the extension is admitted to goes to the test's `daemon` handler.
23
+ // Touches the disk (fresh temp directories for whatever the test leaves unnamed), so a suite using it is an
24
+ // `*.integration.test.ts`.
25
+
26
+ const DAEMON = "http://daemon.fake";
27
+ const TOKEN = "fake-extension-token";
28
+
29
+ // The part of a manifest the fake reads: who the extension is and how far it reaches.
30
+ export interface FakeManifest {
31
+ readonly publisher: string;
32
+ readonly name: string;
33
+ readonly permissions?: { readonly daemon?: readonly string[] };
34
+ readonly contributes?: { readonly listener?: { readonly provider: string } };
35
+ }
36
+
37
+ export interface FakeExtensionOptions {
38
+ readonly manifest: FakeManifest;
39
+ // Answers a daemon call the extension's reach admits. Absent, or answering undefined: 404, as for a route that is
40
+ // not there.
41
+ readonly daemon?: (request: Request) => Response | undefined | Promise<Response | undefined>;
42
+ readonly settings?: ExtensionSettingValues;
43
+ // Each a fresh temp directory when absent, removed again by `dispose`.
44
+ readonly workspaceRoot?: string;
45
+ readonly extensionDir?: string;
46
+ readonly stateDir?: string;
47
+ readonly cacheDir?: string;
48
+ }
49
+
50
+ // What a tool is called with, as ToolDefinition declares it.
51
+ type ToolArguments = Parameters<ToolDefinition["call"]>[0];
52
+
53
+ // One request the extension sent the daemon, and whether its reach admitted it.
54
+ export interface FakeDaemonCall {
55
+ readonly line: string;
56
+ readonly admitted: boolean;
57
+ }
58
+
59
+ export interface FakeExtension {
60
+ readonly api: ExtensionServerApi;
61
+ readonly calls: readonly FakeDaemonCall[];
62
+ readonly logs: readonly string[];
63
+ // Activates a server module as the backend host does, keeping what it hands back for `deactivate` and `health`.
64
+ activate(module: Partial<ExtensionServerModule> & { default?: ExtensionServerModule }): Promise<void>;
65
+ // A request into the extension's own /x namespace, `path` relative to it, as the daemon's proxy delivers it.
66
+ route(path: string, init?: RequestInit): Promise<Response>;
67
+ // The tools it serves a card (undefined: the extension-level server), and one call to one of them.
68
+ tools(card?: ToolCard): Promise<readonly ToolDefinition[]>;
69
+ // Answers whatever the tool's own `call` answers, as ToolDefinition declares it; the host turns that into what the
70
+ // model reads.
71
+ callTool(name: string, args?: ToolArguments, card?: ToolCard, context?: Partial<ToolCallContext>): ReturnType<ToolDefinition["call"]>;
72
+ // The owner changing its settings: stored, and told to the extension over its event stream as the daemon would.
73
+ setSettings(values: ExtensionSettingValues): void;
74
+ // A frame on its event stream, as the daemon sends it (`files`, `refs`, `repos`, `settings`).
75
+ emit(event: ExtensionEvent): void;
76
+ // Its own account of itself, as the host reads it under the host's deadline and vocabulary.
77
+ health(): Promise<ExtensionHealth>;
78
+ // Its `deactivate`, under the host's deadline.
79
+ deactivate(): Promise<void>;
80
+ // Ends the event stream and removes every temp directory the fake made.
81
+ dispose(): Promise<void>;
82
+ }
83
+
84
+ const json = <T>(body: T, status = 200): Response => Response.json(body, { status });
85
+
86
+ // What changed between two settings maps: every key whose value differs, either way.
87
+ const changedKeys = (before: ExtensionSettingValues, after: ExtensionSettingValues): string[] =>
88
+ [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((key) => before[key] !== after[key]).toSorted();
89
+
90
+ export const fakeExtensionApi = async (options: FakeExtensionOptions): Promise<FakeExtension> => {
91
+ const made: string[] = [];
92
+ const dirOf = async (given: string | undefined, label: string): Promise<string> => {
93
+ if (given !== undefined) {
94
+ return given;
95
+ }
96
+ const dir = await mkdtemp(join(tmpdir(), `fake-extension-${label}-`));
97
+ made.push(dir);
98
+ return dir;
99
+ };
100
+ const workspaceRoot = await dirOf(options.workspaceRoot, "workspace");
101
+ const id = `${options.manifest.publisher}.${options.manifest.name}`;
102
+ const reach: ExtensionReach = { permissions: options.manifest.permissions?.daemon ?? [], listener: options.manifest.contributes?.listener?.provider };
103
+ const calls: FakeDaemonCall[] = [];
104
+ const logs: string[] = [];
105
+ let settings: ExtensionSettingValues = { ...options.settings };
106
+ // Every event stream the extension holds open, each written to by `emit`. A frame sent while none is open waits for
107
+ // the next one: the runtime opens its stream a moment after the first listener registers, and a test that listens
108
+ // and then emits must not race that. The daemon itself keeps nothing for a stream nobody holds.
109
+ const streams = new Set<ReadableStreamDefaultController<Uint8Array>>();
110
+ const pending: ExtensionEvent[] = [];
111
+ const encoder = new TextEncoder();
112
+ const write = (stream: ReadableStreamDefaultController<Uint8Array>, event: ExtensionEvent): void => {
113
+ try {
114
+ stream.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
115
+ } catch {
116
+ // allow(silent-catch): a stream the extension already let go of is no longer one to write to
117
+ streams.delete(stream);
118
+ }
119
+ };
120
+ const send = (event: ExtensionEvent): void => {
121
+ if (streams.size === 0) {
122
+ pending.push(event);
123
+ return;
124
+ }
125
+ for (const stream of streams) {
126
+ write(stream, event);
127
+ }
128
+ };
129
+
130
+ const eventStream = (signal: AbortSignal | null): Response => {
131
+ let held: ReadableStreamDefaultController<Uint8Array> | undefined;
132
+ const body = new ReadableStream<Uint8Array>({
133
+ start: (controller) => {
134
+ held = controller;
135
+ streams.add(controller);
136
+ write(controller, { kind: "heartbeat" });
137
+ for (const event of pending.splice(0)) {
138
+ write(controller, event);
139
+ }
140
+ },
141
+ cancel: () => {
142
+ if (held !== undefined) {
143
+ streams.delete(held);
144
+ }
145
+ },
146
+ });
147
+ signal?.addEventListener("abort", () => {
148
+ if (held !== undefined) {
149
+ streams.delete(held);
150
+ }
151
+ });
152
+ return new Response(body, { headers: { "content-type": "application/x-ndjson" } });
153
+ };
154
+
155
+ // The daemon's side of the wire: the token checked, the reach judged, then the extension's own routes or the test's.
156
+ const daemon = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
157
+ const request = new Request(input, init);
158
+ const url = new URL(request.url);
159
+ const line = `${request.method} ${url.pathname}`;
160
+ if (request.headers.get(EXTENSION_TOKEN_HEADER) !== TOKEN) {
161
+ calls.push({ line, admitted: false });
162
+ return json({ error: "unauthorized" }, 401);
163
+ }
164
+ const admitted = extensionRouteReach(reach, request.method, url.pathname);
165
+ calls.push({ line, admitted });
166
+ if (!admitted) {
167
+ return json({ error: "extension token not valid for this route" }, 403);
168
+ }
169
+ if (request.method === "GET" && url.pathname === extensionSettingsUrl()) {
170
+ return json({ settings });
171
+ }
172
+ if (request.method === "GET" && url.pathname === extensionEventsUrl()) {
173
+ return eventStream(init?.signal ?? null);
174
+ }
175
+ return (await options.daemon?.(request)) ?? json({ error: "not found" }, 404);
176
+ };
177
+
178
+ const slots = createServerApi({
179
+ id,
180
+ daemonUrl: DAEMON,
181
+ token: TOKEN,
182
+ permissions: reach.permissions,
183
+ workspaceRoot,
184
+ extensionDir: await dirOf(options.extensionDir, "checkout"),
185
+ stateDir: await dirOf(options.stateDir, "state"),
186
+ cacheDir: await dirOf(options.cacheDir, "cache"),
187
+ log: (message) => logs.push(message),
188
+ fetch: daemon,
189
+ });
190
+ let activation: ServerActivation = {};
191
+
192
+ return {
193
+ api: slots.api,
194
+ calls,
195
+ logs,
196
+ activate: async (module) => {
197
+ activation = await activateServerModule(module, slots.api, { extensionId: id });
198
+ },
199
+ route: async (path, init) => {
200
+ const handler = slots.handler();
201
+ if (handler === undefined) {
202
+ return json({ error: `the "${id}" backend serves no routes` }, 404);
203
+ }
204
+ return (await handler(new Request(new URL(path, "http://extension.internal"), init))) ?? json({ error: "not found" }, 404);
205
+ },
206
+ tools: async (card) => (await slots.tools()?.(card)) ?? [],
207
+ callTool: async (name, args = {}, card, context = {}) => {
208
+ const tool = ((await slots.tools()?.(card)) ?? []).find((each) => each.name === name);
209
+ if (tool === undefined) {
210
+ throw new Error(`"${id}" serves no tool named ${name}${card === undefined ? "" : ` for card ${card.id}`}`);
211
+ }
212
+ return tool.call(args, { signal: context.signal ?? new AbortController().signal, conversationId: context.conversationId });
213
+ },
214
+ setSettings: (values) => {
215
+ const keys = changedKeys(settings, values);
216
+ settings = { ...values };
217
+ if (keys.length > 0) {
218
+ send({ kind: "settings", keys });
219
+ }
220
+ },
221
+ emit: send,
222
+ health: () => healthWithin(activation),
223
+ deactivate: () => deactivateWithin(activation, (message) => logs.push(message)),
224
+ dispose: async () => {
225
+ slots.close();
226
+ for (const stream of streams) {
227
+ try {
228
+ stream.close();
229
+ } catch {
230
+ // allow(silent-catch): already closed by the extension's side, which is the state being reached
231
+ }
232
+ }
233
+ streams.clear();
234
+ await Promise.all(made.map((dir) => rm(dir, { recursive: true, force: true })));
235
+ },
236
+ };
237
+ };
package/src/version.ts CHANGED
@@ -144,4 +144,26 @@
144
144
  // `badge: true`, since the asks are the badge's count spelled out. Additive: a view that declares none is unchanged,
145
145
  // and an older host ignores the field. The recorded surface grows a grain for it, ViewRegistration's own fields
146
146
  // (`viewRegistration`).
147
- export const extensionApiVersion = "2.23.0";
147
+ // 2.24.0 adds the `phone` capability kind: a phone family the user connects their own phone of, through an app installed
148
+ // on it (`install`, where that app comes from), with a SKILL.md teaching the agent to work on that phone, the way a
149
+ // `webext` card stands for a browser family. Additive, but a manifest declaring one needs this version: an older host
150
+ // knows no such kind and refuses the whole manifest at parse. No member or top-level key was added, so the manifest
151
+ // digest is the only grain that records it.
152
+ // 2.25.0 lets a capability card keep its credential out of the agent's hands: `broker`, the routes (an upstream, the
153
+ // variable that carries the gateway's address, how the credential is attached) and the method and path rules by which
154
+ // the sandbox's credential gateway forwards requests for it. It shipped inside 2.24.0 without a version: an older host's
155
+ // parse drops the block, so there the credential reaches the agent's environment as it did before. No member or
156
+ // top-level key was added, so the manifest digest is the only grain that records it.
157
+ // 2.26.0 gives the backend half what the browser half already had, and one shape wherever an extension's Node code runs.
158
+ // `ExtensionServerApi` gains `stateDir` and `cacheDir` (directories of the extension's own, keyed by its identity, made
159
+ // before activation and deleted with it), `document` (a file there, evolved by the same `conversions` as
160
+ // `sandboxDocument`), `settings` (its own values, secrets included, with `onDidChange`) and `workspace` (file, ref and
161
+ // repository changes), the last two over two routes every extension token reaches without declaring them.
162
+ // `activateServer` may hand back `{ deactivate, health }`: with a `deactivate`, a change to that extension alone reloads
163
+ // it in the running host instead of restarting every backend, and on shutdown it is let go of before the host exits;
164
+ // `health` answers ok, starting, degraded or failed, shown on its row. A declared process gets the same api, less the
165
+ // two slots only the host serves (`ExtensionProcessApi`), from `connectExtensionProcess` in the new `./runtime` subpath,
166
+ // and `./testing` holds `fakeExtensionApi`, the backend api on a fake daemon that refuses what the daemon would. Additive:
167
+ // a bundle that returns nothing from activation and never reads the new members is unchanged. The recorded surface grows
168
+ // two grains, the backend api's `settings` and `workspace` members, where those blocks arrived and will grow.
169
+ export const extensionApiVersion = "2.26.0";