@neurosquad/card-sdk 1.0.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/LICENSE +21 -0
- package/README.md +431 -0
- package/dist/card-sdk.js +5266 -0
- package/dist/cli.js +2838 -0
- package/dist/react.js +257 -0
- package/dist/testing.js +1150 -0
- package/dist/types/client/base64.d.ts +7 -0
- package/dist/types/client/card.d.ts +653 -0
- package/dist/types/client/channel.d.ts +49 -0
- package/dist/types/client/coalesce.d.ts +14 -0
- package/dist/types/client/connect.d.ts +39 -0
- package/dist/types/client/errors.d.ts +46 -0
- package/dist/types/client/helpers.d.ts +25 -0
- package/dist/types/client/net.d.ts +111 -0
- package/dist/types/client/theme.d.ts +31 -0
- package/dist/types/client/toolResult.d.ts +16 -0
- package/dist/types/contract/api.d.ts +812 -0
- package/dist/types/contract/index.d.ts +11 -0
- package/dist/types/contract/jsonSchema.d.ts +88 -0
- package/dist/types/contract/localized.d.ts +10 -0
- package/dist/types/contract/manifest.d.ts +179 -0
- package/dist/types/contract/network.d.ts +48 -0
- package/dist/types/contract/permissions.d.ts +180 -0
- package/dist/types/contract/ports.d.ts +237 -0
- package/dist/types/contract/protocol.d.ts +74 -0
- package/dist/types/contract/source.d.ts +111 -0
- package/dist/types/contract/theme.d.ts +21 -0
- package/dist/types/contract/version.d.ts +126 -0
- package/dist/types/i18n.d.ts +50 -0
- package/dist/types/index.d.ts +28 -0
- package/dist/types/react/index.d.ts +132 -0
- package/dist/types/testing/index.d.ts +7 -0
- package/dist/types/testing/mockHost.d.ts +315 -0
- package/dist/types/version.d.ts +2 -0
- package/dist/ui.css +581 -0
- package/package.json +77 -0
- package/schema/neurosquad-card.v1.json +434 -0
- package/templates/react/README.md +34 -0
- package/templates/react/_gitignore +10 -0
- package/templates/react/icon.png +0 -0
- package/templates/react/index.html +12 -0
- package/templates/react/neurosquad-card.json +94 -0
- package/templates/react/package.json +25 -0
- package/templates/react/src/App.tsx +131 -0
- package/templates/react/src/i18n.ts +61 -0
- package/templates/react/src/main.tsx +77 -0
- package/templates/react/src/styles.css +42 -0
- package/templates/react/tsconfig.json +18 -0
- package/templates/react/vite.config.ts +19 -0
- package/templates/vanilla/README.md +37 -0
- package/templates/vanilla/_gitignore +6 -0
- package/templates/vanilla/icon.png +0 -0
- package/templates/vanilla/index.html +41 -0
- package/templates/vanilla/main.js +256 -0
- package/templates/vanilla/neurosquad-card.json +94 -0
- package/templates/vanilla/style.css +41 -0
package/dist/testing.js
ADDED
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
/*! @neurosquad/card-sdk 1.0.0 | MIT */
|
|
2
|
+
|
|
3
|
+
// src/testing/mockHost.ts
|
|
4
|
+
import {
|
|
5
|
+
CARD_EVENT_NAMES,
|
|
6
|
+
CARD_METHOD_NAMES,
|
|
7
|
+
CARD_PROTOCOL_VERSION,
|
|
8
|
+
checkRequestHeader,
|
|
9
|
+
checkSettingValues,
|
|
10
|
+
coercePortValue,
|
|
11
|
+
defaultSettings,
|
|
12
|
+
errorResponse,
|
|
13
|
+
eventMessage,
|
|
14
|
+
expandGranted,
|
|
15
|
+
hostAllowed,
|
|
16
|
+
isLocalHostname,
|
|
17
|
+
LIMITS,
|
|
18
|
+
METHOD_SPECS,
|
|
19
|
+
messageSize,
|
|
20
|
+
methodAllowed,
|
|
21
|
+
normalizeWorkspacePath,
|
|
22
|
+
okResponse,
|
|
23
|
+
parseCardMessage,
|
|
24
|
+
permissionStates,
|
|
25
|
+
pickInputFor,
|
|
26
|
+
protectedWriteReason,
|
|
27
|
+
portSchemas,
|
|
28
|
+
resolveLocalized,
|
|
29
|
+
SECRET_PLACEHOLDER_RE,
|
|
30
|
+
secretPlaceholders,
|
|
31
|
+
TOPIC_PERMISSION,
|
|
32
|
+
validateJson,
|
|
33
|
+
validateManifest,
|
|
34
|
+
base64ToBytes,
|
|
35
|
+
bytesToBase64,
|
|
36
|
+
connect,
|
|
37
|
+
DEFAULT_DARK_THEME,
|
|
38
|
+
LARGE_REQUEST_METHODS
|
|
39
|
+
} from "./card-sdk.js";
|
|
40
|
+
var HostError = class extends Error {
|
|
41
|
+
constructor(code, message, data) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.data = data;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
var fail = (code, message, data) => {
|
|
48
|
+
throw new HostError(code, message, data);
|
|
49
|
+
};
|
|
50
|
+
var MINIMAL_MANIFEST = {
|
|
51
|
+
manifestVersion: 1,
|
|
52
|
+
name: "mock-card",
|
|
53
|
+
displayName: "Mock card",
|
|
54
|
+
version: "0.0.0",
|
|
55
|
+
protocol: CARD_PROTOCOL_VERSION
|
|
56
|
+
};
|
|
57
|
+
function portInfo(port, language) {
|
|
58
|
+
return {
|
|
59
|
+
id: port.id,
|
|
60
|
+
label: resolveLocalized(port.label, language),
|
|
61
|
+
...port.description ? { description: resolveLocalized(port.description, language) } : {},
|
|
62
|
+
type: port.type,
|
|
63
|
+
...port.schema ? { schema: port.schema } : {},
|
|
64
|
+
mode: port.mode ?? "stream",
|
|
65
|
+
...port.response ? { response: port.response } : {},
|
|
66
|
+
retain: port.retain === true,
|
|
67
|
+
default: port.default === true
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
var MockHost = class {
|
|
71
|
+
constructor(options = {}) {
|
|
72
|
+
this.options = options;
|
|
73
|
+
const result = validateManifest(options.manifest ?? MINIMAL_MANIFEST);
|
|
74
|
+
if (!result.ok) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`mock host: invalid manifest \u2014 ${result.issues.map((i) => `${i.path || "/"}: ${i.message}`).join("; ")}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
this.manifest = result.manifest;
|
|
80
|
+
const declared = this.manifest.permissions;
|
|
81
|
+
this.granted = new Set(
|
|
82
|
+
options.grant === "all" ? declared.map((p) => p.id) : options.grant ?? declared.filter((p) => !p.optional).map((p) => p.id)
|
|
83
|
+
);
|
|
84
|
+
this.settingsValues = { ...defaultSettings(this.manifest.settings), ...options.settings };
|
|
85
|
+
for (const field of this.manifest.settings) {
|
|
86
|
+
if (field.type !== "secret") continue;
|
|
87
|
+
const value = options.secrets?.[field.key];
|
|
88
|
+
if (value !== void 0) this.secretValues.set(field.key, value);
|
|
89
|
+
this.secretsSet[field.key] = value !== void 0;
|
|
90
|
+
}
|
|
91
|
+
for (const [path, data] of Object.entries(options.files ?? {})) this.putFile(path, data);
|
|
92
|
+
const language = options.context?.i18n?.language ?? "en";
|
|
93
|
+
const base = {
|
|
94
|
+
protocol: options.hostProtocol ?? CARD_PROTOCOL_VERSION,
|
|
95
|
+
appVersion: "0.0.0-mock",
|
|
96
|
+
instance: {
|
|
97
|
+
instanceId: "card-1",
|
|
98
|
+
packageId: `dev:/mock/${this.manifest.name}`,
|
|
99
|
+
name: this.manifest.name,
|
|
100
|
+
displayName: resolveLocalized(this.manifest.displayName, language),
|
|
101
|
+
version: this.manifest.version,
|
|
102
|
+
commit: null,
|
|
103
|
+
title: null,
|
|
104
|
+
size: this.manifest.card.defaultSize,
|
|
105
|
+
dev: true,
|
|
106
|
+
...options.context?.instance
|
|
107
|
+
},
|
|
108
|
+
workspace: { id: "workspace-1", name: "Mock workspace" },
|
|
109
|
+
visibility: "visible",
|
|
110
|
+
expanded: false,
|
|
111
|
+
theme: DEFAULT_DARK_THEME,
|
|
112
|
+
i18n: { language, locale: { en: "en-US", ru: "ru-RU", zh: "zh-CN" }[language] },
|
|
113
|
+
settings: { values: this.settingsValues, secrets: this.secretsSet },
|
|
114
|
+
permissions: [],
|
|
115
|
+
ports: {
|
|
116
|
+
inputs: this.manifest.ports.inputs.map((p) => portInfo(p, language)),
|
|
117
|
+
outputs: this.manifest.ports.outputs.map((p) => portInfo(p, language))
|
|
118
|
+
},
|
|
119
|
+
peers: [],
|
|
120
|
+
launch: "created",
|
|
121
|
+
limits: LIMITS
|
|
122
|
+
};
|
|
123
|
+
const { instance: _instance, ...rest } = options.context ?? {};
|
|
124
|
+
void _instance;
|
|
125
|
+
this.context = { ...base, ...rest, instance: base.instance };
|
|
126
|
+
this.context.permissions = this.permissionList();
|
|
127
|
+
this.context.peers = this.peerInfos();
|
|
128
|
+
if (this.granted.has("fs.read") || this.granted.has("fs.write")) {
|
|
129
|
+
this.context.workspace = { ...this.context.workspace, path: "/mock/workspace" };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** The validated, normalized manifest. */
|
|
133
|
+
manifest;
|
|
134
|
+
/** Every request the card made, in order (after the envelope check). */
|
|
135
|
+
calls = [];
|
|
136
|
+
/** Every event sent to the card. */
|
|
137
|
+
events = [];
|
|
138
|
+
/** Storage per scope. */
|
|
139
|
+
storage = {
|
|
140
|
+
instance: /* @__PURE__ */ new Map(),
|
|
141
|
+
package: /* @__PURE__ */ new Map()
|
|
142
|
+
};
|
|
143
|
+
/** `ports.emit` / `ports.send` deliveries to peers. */
|
|
144
|
+
deliveries = [];
|
|
145
|
+
/** Retained values of the card's own outputs. */
|
|
146
|
+
retained = /* @__PURE__ */ new Map();
|
|
147
|
+
/** Prompts the card sent to agents. */
|
|
148
|
+
prompts = [];
|
|
149
|
+
/** Terminal writes and runs. */
|
|
150
|
+
terminal = [];
|
|
151
|
+
/** `host.log` lines. */
|
|
152
|
+
logs = [];
|
|
153
|
+
/** `ui.toast` messages. */
|
|
154
|
+
toasts = [];
|
|
155
|
+
/** Clipboard writes. */
|
|
156
|
+
clipboard = [];
|
|
157
|
+
/** Cards spawned with `card.spawn`. */
|
|
158
|
+
spawned = [];
|
|
159
|
+
/** `net.abort` request ids. */
|
|
160
|
+
aborted = [];
|
|
161
|
+
/** `host.pong` sequence numbers received. */
|
|
162
|
+
pongs = [];
|
|
163
|
+
/** Topics the card is subscribed to (and agent ids for agents.output). */
|
|
164
|
+
subscriptions = /* @__PURE__ */ new Set();
|
|
165
|
+
outputAgents = [];
|
|
166
|
+
/** Header/tile state the card set. */
|
|
167
|
+
chrome = {
|
|
168
|
+
title: null,
|
|
169
|
+
status: null,
|
|
170
|
+
badge: null,
|
|
171
|
+
overview: null,
|
|
172
|
+
attention: null,
|
|
173
|
+
menu: [],
|
|
174
|
+
enabledTools: {}
|
|
175
|
+
};
|
|
176
|
+
/** Workspace files. */
|
|
177
|
+
files = /* @__PURE__ */ new Map();
|
|
178
|
+
context;
|
|
179
|
+
granted;
|
|
180
|
+
port = null;
|
|
181
|
+
ready = false;
|
|
182
|
+
overrides = /* @__PURE__ */ new Map();
|
|
183
|
+
lastCall = /* @__PURE__ */ new Map();
|
|
184
|
+
minuteCalls = /* @__PURE__ */ new Map();
|
|
185
|
+
toolCalls = /* @__PURE__ */ new Map();
|
|
186
|
+
portRequests = /* @__PURE__ */ new Map();
|
|
187
|
+
watchers = /* @__PURE__ */ new Map();
|
|
188
|
+
seq = 0;
|
|
189
|
+
counter = 0;
|
|
190
|
+
settingsValues;
|
|
191
|
+
secretsSet = {};
|
|
192
|
+
secretValues = /* @__PURE__ */ new Map();
|
|
193
|
+
/** Agents whose last status was `working` (the turn rule main applies). */
|
|
194
|
+
lastStatus = /* @__PURE__ */ new Map();
|
|
195
|
+
// --- wiring ------------------------------------------------------------------
|
|
196
|
+
/** A new channel for a card; the returned port goes to `connect({ port })`. Replaces a previous one. */
|
|
197
|
+
createPort() {
|
|
198
|
+
this.port?.close();
|
|
199
|
+
const channel = new MessageChannel();
|
|
200
|
+
this.port = channel.port1;
|
|
201
|
+
this.ready = false;
|
|
202
|
+
this.port.onmessage = (event) => void this.receive(event.data);
|
|
203
|
+
return channel.port2;
|
|
204
|
+
}
|
|
205
|
+
/** Connects a real SDK {@link Card} to this host. */
|
|
206
|
+
connect(options = {}) {
|
|
207
|
+
return connect({ ...options, port: this.createPort() });
|
|
208
|
+
}
|
|
209
|
+
/** Replaces or adds the handler of a method (runs after the host's schema/permission checks). */
|
|
210
|
+
handle(method, handler) {
|
|
211
|
+
this.overrides.set(method, handler);
|
|
212
|
+
return this;
|
|
213
|
+
}
|
|
214
|
+
/** Closes the channel (the card sees UNAVAILABLE on later calls). */
|
|
215
|
+
close() {
|
|
216
|
+
this.port?.close();
|
|
217
|
+
this.port = null;
|
|
218
|
+
}
|
|
219
|
+
/** Current HostContext (as it would be sent on `host.ready` now). */
|
|
220
|
+
get hostContext() {
|
|
221
|
+
return this.context;
|
|
222
|
+
}
|
|
223
|
+
/** Currently granted permission ids (with implications). */
|
|
224
|
+
get grants() {
|
|
225
|
+
return expandGranted(this.granted);
|
|
226
|
+
}
|
|
227
|
+
// --- driving the card ---------------------------------------------------------
|
|
228
|
+
/** Sends any event to the card. */
|
|
229
|
+
emit(event, data) {
|
|
230
|
+
this.events.push({ event, data });
|
|
231
|
+
if (this.options.verbose) console.log("[mock host] evt", event, data);
|
|
232
|
+
this.port?.postMessage(eventMessage(event, data));
|
|
233
|
+
}
|
|
234
|
+
/** Sends `host.ping` and resolves when the card's `host.pong` arrives. */
|
|
235
|
+
ping(timeoutMs = 2e3) {
|
|
236
|
+
const seq = ++this.seq;
|
|
237
|
+
this.emit("host.ping", { seq });
|
|
238
|
+
return this.until(() => this.pongs.includes(seq), timeoutMs, "host.pong").then(() => seq);
|
|
239
|
+
}
|
|
240
|
+
setVisibility(state) {
|
|
241
|
+
this.context = { ...this.context, visibility: state };
|
|
242
|
+
this.emit("lifecycle.visibility", { state });
|
|
243
|
+
}
|
|
244
|
+
setExpanded(expanded) {
|
|
245
|
+
this.context = { ...this.context, expanded };
|
|
246
|
+
this.emit("lifecycle.expanded", { expanded });
|
|
247
|
+
}
|
|
248
|
+
resize(size) {
|
|
249
|
+
this.context = { ...this.context, instance: { ...this.context.instance, size } };
|
|
250
|
+
this.emit("lifecycle.resized", size);
|
|
251
|
+
}
|
|
252
|
+
/** Sends `lifecycle.suspend`. */
|
|
253
|
+
suspend(graceMs = LIMITS.suspendGraceMs) {
|
|
254
|
+
this.emit("lifecycle.suspend", { graceMs });
|
|
255
|
+
}
|
|
256
|
+
setTheme(theme) {
|
|
257
|
+
this.context = { ...this.context, theme };
|
|
258
|
+
this.emit("theme.changed", theme);
|
|
259
|
+
}
|
|
260
|
+
setLanguage(language) {
|
|
261
|
+
const i18n = { language, locale: { en: "en-US", ru: "ru-RU", zh: "zh-CN" }[language] };
|
|
262
|
+
this.context = { ...this.context, i18n };
|
|
263
|
+
this.emit("i18n.changed", i18n);
|
|
264
|
+
}
|
|
265
|
+
/** The user changed settings in the host form (secrets: pass `true`/`false` in `secrets`). */
|
|
266
|
+
setSettings(values, secrets = {}) {
|
|
267
|
+
this.settingsValues = { ...this.settingsValues, ...values };
|
|
268
|
+
this.secretsSet = { ...this.secretsSet, ...secrets };
|
|
269
|
+
const snapshot = { values: this.settingsValues, secrets: this.secretsSet };
|
|
270
|
+
this.context = { ...this.context, settings: snapshot };
|
|
271
|
+
this.emit("settings.changed", snapshot);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The user entered (or cleared, with `null`) a secret setting in the host
|
|
275
|
+
* form. The card only sees `settings.secrets[key]` flip; the value is used
|
|
276
|
+
* for `{{secret:key}}` in `net.fetch` headers.
|
|
277
|
+
*/
|
|
278
|
+
setSecret(key, value) {
|
|
279
|
+
const field = this.manifest.settings.find((f) => f.key === key && f.type === "secret");
|
|
280
|
+
if (!field) throw new Error(`mock host: "${key}" is not a secret setting of the manifest`);
|
|
281
|
+
if (value === null) this.secretValues.delete(key);
|
|
282
|
+
else this.secretValues.set(key, value);
|
|
283
|
+
this.setSettings({}, { [key]: value !== null });
|
|
284
|
+
}
|
|
285
|
+
/** Grants or revokes permissions (the card gets `permissions.changed`). */
|
|
286
|
+
setGrants(ids) {
|
|
287
|
+
this.granted = new Set(ids);
|
|
288
|
+
const permissions = this.permissionList();
|
|
289
|
+
this.context = { ...this.context, permissions };
|
|
290
|
+
this.emit("permissions.changed", permissions);
|
|
291
|
+
}
|
|
292
|
+
/** Replaces the connected cards (the card gets `ports.peersChanged`). */
|
|
293
|
+
setPeers(peers) {
|
|
294
|
+
this.options.peers = peers;
|
|
295
|
+
this.context = { ...this.context, peers: this.peerInfos() };
|
|
296
|
+
this.emit("ports.peersChanged", this.context.peers);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Delivers a value to one of the card's inputs, as if a peer emitted it —
|
|
300
|
+
* with the app's checks: `from.cardId`, when given, must be a peer whose
|
|
301
|
+
* arrow points at this card (`upstream` or `both`); the value is converted
|
|
302
|
+
* from the sender's output type (`from.output` of that peer, or
|
|
303
|
+
* `from.type`) to the input's type and must fit the input's schema.
|
|
304
|
+
* Without `from.cardId` the first upstream peer is the sender, or an
|
|
305
|
+
* anonymous custom card when there is none. Throws when the app would not
|
|
306
|
+
* deliver the value.
|
|
307
|
+
*/
|
|
308
|
+
sendPortMessage(input, data, from = {}) {
|
|
309
|
+
const port = this.context.ports.inputs.find((p) => p.id === input);
|
|
310
|
+
if (!port) throw new Error(`mock host: the card has no input "${input}"`);
|
|
311
|
+
const peers = this.options.peers ?? [];
|
|
312
|
+
let peer;
|
|
313
|
+
if (from.cardId !== void 0) {
|
|
314
|
+
peer = peers.find((p) => p.cardId === from.cardId);
|
|
315
|
+
if (!peer) throw new Error(`mock host: ${from.cardId} is not connected to this card`);
|
|
316
|
+
if (peer.direction === "downstream") {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`mock host: the arrow goes from this card to ${peer.name}; it cannot send here`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
} else peer = peers.find((p) => p.direction !== "downstream");
|
|
322
|
+
const output = peer?.outputs.find((o) => o.id === from.output) ?? peer?.outputs[0];
|
|
323
|
+
const sourceType = from.type ?? output?.type ?? port.type;
|
|
324
|
+
let value;
|
|
325
|
+
try {
|
|
326
|
+
value = coercePortValue(sourceType, port.type, data);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`mock host: ${sourceType} does not convert to ${port.type}: ${error.message}`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
for (const schema of portSchemas(port)) {
|
|
333
|
+
const issues = validateJson(schema, value);
|
|
334
|
+
if (issues.length > 0) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`mock host: the value does not fit input "${input}" \u2014 ${issues[0].path || "value"} ${issues[0].message}`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
this.emit("ports.message", {
|
|
341
|
+
from: peer?.cardId ?? from.cardId ?? "peer-1",
|
|
342
|
+
fromKind: from.kind ?? peer?.kind ?? "custom",
|
|
343
|
+
output: output?.id ?? from.output ?? "out",
|
|
344
|
+
input,
|
|
345
|
+
type: port.type,
|
|
346
|
+
sourceType,
|
|
347
|
+
data: value,
|
|
348
|
+
at: Date.now()
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
/** Asks one of the card's `mode: "request"` inputs and resolves with its reply. */
|
|
352
|
+
requestPort(input, data, options = {}) {
|
|
353
|
+
const requestId = `req-${++this.counter}`;
|
|
354
|
+
const timeoutMs = options.timeoutMs ?? LIMITS.portRequestTimeoutMs;
|
|
355
|
+
return new Promise((resolve, reject) => {
|
|
356
|
+
const timer = setTimeout(() => {
|
|
357
|
+
this.portRequests.delete(requestId);
|
|
358
|
+
reject(new HostError("TIMEOUT", `no reply to ${requestId}`));
|
|
359
|
+
}, timeoutMs);
|
|
360
|
+
this.portRequests.set(requestId, {
|
|
361
|
+
resolve: (value) => {
|
|
362
|
+
clearTimeout(timer);
|
|
363
|
+
resolve(value);
|
|
364
|
+
},
|
|
365
|
+
reject: (error) => {
|
|
366
|
+
clearTimeout(timer);
|
|
367
|
+
reject(error);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
this.emit("ports.request", {
|
|
371
|
+
requestId,
|
|
372
|
+
from: options.from ?? "peer-1",
|
|
373
|
+
input,
|
|
374
|
+
data,
|
|
375
|
+
deadline: Date.now() + timeoutMs
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Calls one of the card's tools as a connected agent would, and resolves
|
|
381
|
+
* with the result (rejects with the card's error message).
|
|
382
|
+
*/
|
|
383
|
+
callTool(tool, args = {}, options = {}) {
|
|
384
|
+
const rejected = (message) => Object.assign(Promise.reject(new HostError("INVALID_PARAMS", message)), { callId: "" });
|
|
385
|
+
const declared = this.manifest.tools.find((t) => t.name === tool);
|
|
386
|
+
if (!declared) return rejected(`mock host: the manifest declares no tool "${tool}"`);
|
|
387
|
+
const issues = validateJson(declared.inputSchema, args);
|
|
388
|
+
if (issues.length > 0)
|
|
389
|
+
return rejected(`mock host: invalid tool args \u2014 ${issues[0].path}: ${issues[0].message}`);
|
|
390
|
+
const callId = `call-${++this.counter}`;
|
|
391
|
+
const timeoutMs = options.timeoutMs ?? declared.timeoutMs ?? LIMITS.toolTimeoutMs;
|
|
392
|
+
const promise = new Promise((resolve, reject) => {
|
|
393
|
+
const timer = setTimeout(() => {
|
|
394
|
+
this.toolCalls.delete(callId);
|
|
395
|
+
this.emit("tools.cancel", { callId });
|
|
396
|
+
reject(new HostError("TIMEOUT", `tool ${tool} timed out`));
|
|
397
|
+
}, timeoutMs);
|
|
398
|
+
this.toolCalls.set(callId, {
|
|
399
|
+
resolve: (result) => {
|
|
400
|
+
clearTimeout(timer);
|
|
401
|
+
resolve(result);
|
|
402
|
+
},
|
|
403
|
+
reject: (error) => {
|
|
404
|
+
clearTimeout(timer);
|
|
405
|
+
reject(error);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
this.emit("tools.call", {
|
|
409
|
+
callId,
|
|
410
|
+
tool,
|
|
411
|
+
args,
|
|
412
|
+
agent: options.agent ?? { id: "agent-1", name: "Claude Code" },
|
|
413
|
+
deadline: Date.now() + timeoutMs
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
return Object.assign(promise, { callId });
|
|
417
|
+
}
|
|
418
|
+
/** Cancels a running tool call (`tools.cancel`); the promise from callTool rejects. */
|
|
419
|
+
cancelTool(callId) {
|
|
420
|
+
const pending = this.toolCalls.get(callId);
|
|
421
|
+
this.toolCalls.delete(callId);
|
|
422
|
+
this.emit("tools.cancel", { callId });
|
|
423
|
+
pending?.reject(new HostError("USER_CANCELLED", "cancelled"));
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Changes an agent's status; the card gets `agents.status` if subscribed,
|
|
427
|
+
* and `agents.turn` by the app's rule: `start` on entering `working`,
|
|
428
|
+
* `end` on leaving it for `finished` or `needs-input`.
|
|
429
|
+
*/
|
|
430
|
+
setAgentStatus(agentId, status) {
|
|
431
|
+
const agent = this.agentList().find((a) => a.id === agentId);
|
|
432
|
+
if (!agent) throw new Error(`mock host: no agent "${agentId}"`);
|
|
433
|
+
const previous = this.lastStatus.get(agentId) ?? agent.status;
|
|
434
|
+
const at = Date.now();
|
|
435
|
+
agent.status = status;
|
|
436
|
+
this.lastStatus.set(agentId, status);
|
|
437
|
+
if (previous === "working" && (status === "finished" || status === "needs-input")) {
|
|
438
|
+
agent.lastTurn = { startedAt: agent.turnStartedAt ?? null, endedAt: at, endedAs: status };
|
|
439
|
+
}
|
|
440
|
+
if (status === "working" && previous !== "working") agent.turnStartedAt = at;
|
|
441
|
+
else if (status !== "working") delete agent.turnStartedAt;
|
|
442
|
+
if (this.subscriptions.has("agents.status")) this.emit("agents.status", { agentId, status, at });
|
|
443
|
+
const phase = status === "working" && previous !== "working" ? "start" : previous === "working" && (status === "finished" || status === "needs-input") ? "end" : null;
|
|
444
|
+
if (phase && this.subscriptions.has("agents.turn"))
|
|
445
|
+
this.emit("agents.turn", { agentId, phase, at });
|
|
446
|
+
}
|
|
447
|
+
/** Output of an agent; the card gets `agents.output` if subscribed for that agent. */
|
|
448
|
+
agentOutput(agentId, text) {
|
|
449
|
+
if (this.subscriptions.has("agents.output") && this.outputAgents.includes(agentId)) {
|
|
450
|
+
this.emit("agents.output", { agentId, text, at: Date.now() });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/** Changes a file (or creates it) and notifies matching watchers. */
|
|
454
|
+
touchFile(path, data) {
|
|
455
|
+
if (data !== void 0) this.putFile(path, data);
|
|
456
|
+
for (const [watchId, watch] of this.watchers) {
|
|
457
|
+
const inside = watch.path === "" || path === watch.path || path.startsWith(`${watch.path}/`);
|
|
458
|
+
const direct = watch.recursive || !path.slice(watch.path.length + 1).includes("/");
|
|
459
|
+
if (inside && direct) this.emit("fs.changed", { watchId, path, type: "change" });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/** Text of a file in the mock workspace, or undefined. */
|
|
463
|
+
readFile(path) {
|
|
464
|
+
const file = this.files.get(path);
|
|
465
|
+
return file ? new TextDecoder().decode(file.data) : void 0;
|
|
466
|
+
}
|
|
467
|
+
// --- internals -----------------------------------------------------------------
|
|
468
|
+
putFile(path, data) {
|
|
469
|
+
this.files.set(path, {
|
|
470
|
+
data: typeof data === "string" ? new TextEncoder().encode(data) : data,
|
|
471
|
+
mtimeMs: Date.now()
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
agentList() {
|
|
475
|
+
return this.options.agents ??= [];
|
|
476
|
+
}
|
|
477
|
+
permissionList() {
|
|
478
|
+
return permissionStates(
|
|
479
|
+
this.manifest.permissions,
|
|
480
|
+
expandGranted(this.granted),
|
|
481
|
+
(text) => resolveLocalized(text, this.context?.i18n.language ?? "en")
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
peerInfos() {
|
|
485
|
+
return (this.options.peers ?? []).map(({ retained: _r, respond: _s, ...peer }) => peer);
|
|
486
|
+
}
|
|
487
|
+
peer(cardId) {
|
|
488
|
+
return (this.options.peers ?? []).find((p) => p.cardId === cardId) ?? fail("NOT_CONNECTED", `card ${cardId} is not connected to this card by an arrow`);
|
|
489
|
+
}
|
|
490
|
+
connectedAgent(agentId) {
|
|
491
|
+
const agent = this.agentList().find((a) => a.id === agentId) ?? fail("NOT_FOUND", `no agent ${agentId}`);
|
|
492
|
+
if (!agent.connected)
|
|
493
|
+
fail("NOT_CONNECTED", `agent ${agentId} is not connected to this card by an arrow`);
|
|
494
|
+
return agent;
|
|
495
|
+
}
|
|
496
|
+
until(check, timeoutMs, what) {
|
|
497
|
+
return new Promise((resolve, reject) => {
|
|
498
|
+
const started = Date.now();
|
|
499
|
+
const tick = () => {
|
|
500
|
+
if (check()) resolve();
|
|
501
|
+
else if (Date.now() - started > timeoutMs)
|
|
502
|
+
reject(new Error(`mock host: no ${what} in ${timeoutMs} ms`));
|
|
503
|
+
else setTimeout(tick, 5);
|
|
504
|
+
};
|
|
505
|
+
tick();
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
reply(message) {
|
|
509
|
+
this.port?.postMessage(message);
|
|
510
|
+
}
|
|
511
|
+
async receive(data) {
|
|
512
|
+
const requested = data?.method;
|
|
513
|
+
const parsed = parseCardMessage(
|
|
514
|
+
data,
|
|
515
|
+
typeof requested === "string" && LARGE_REQUEST_METHODS.has(requested) ? LIMITS.largeMessageBytes : LIMITS.messageBytes
|
|
516
|
+
);
|
|
517
|
+
if (!parsed.ok) {
|
|
518
|
+
if (parsed.id !== null)
|
|
519
|
+
this.reply(
|
|
520
|
+
errorResponse(parsed.id, parsed.error.code, parsed.error.message, parsed.error.data)
|
|
521
|
+
);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const { id, method } = parsed.message;
|
|
525
|
+
const params = parsed.message.params ?? {};
|
|
526
|
+
this.calls.push({ method, params });
|
|
527
|
+
if (this.options.verbose) console.log("[mock host] req", method, params);
|
|
528
|
+
try {
|
|
529
|
+
const result = await this.dispatch(method, params);
|
|
530
|
+
const response = okResponse(id, result);
|
|
531
|
+
const spec = METHOD_SPECS[method];
|
|
532
|
+
const max = spec?.largeResponse ? LIMITS.largeMessageBytes : LIMITS.messageBytes;
|
|
533
|
+
if (messageSize(response) > max)
|
|
534
|
+
this.reply(errorResponse(id, "TOO_LARGE", "result larger than the limit"));
|
|
535
|
+
else this.reply(response);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (error instanceof HostError)
|
|
538
|
+
this.reply(errorResponse(id, error.code, error.message, error.data));
|
|
539
|
+
else
|
|
540
|
+
this.reply(
|
|
541
|
+
errorResponse(id, "INTERNAL", error instanceof Error ? error.message : String(error))
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
async dispatch(method, params) {
|
|
546
|
+
if (!this.ready && method !== "host.ready") fail("NOT_READY", "call host.ready first");
|
|
547
|
+
const spec = METHOD_SPECS[method];
|
|
548
|
+
if (!spec) fail("METHOD_NOT_FOUND", `no method ${method}`);
|
|
549
|
+
const issues = validateJson(spec.params, params);
|
|
550
|
+
if (issues.length > 0)
|
|
551
|
+
fail("INVALID_PARAMS", `${issues[0].path || "params"}: ${issues[0].message}`, issues);
|
|
552
|
+
const name = method;
|
|
553
|
+
if (!methodAllowed(name, this.grants)) {
|
|
554
|
+
const need = spec.permission;
|
|
555
|
+
fail("PERMISSION_DENIED", `${method} needs ${String(need)}`, {
|
|
556
|
+
permission: typeof need === "string" ? need : need?.[0]
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
if (spec.visible && this.context.visibility !== "visible")
|
|
560
|
+
fail("NOT_VISIBLE", `${method} needs the card on screen`);
|
|
561
|
+
if (this.options.enforceRateLimits) this.throttle(name);
|
|
562
|
+
const override = this.overrides.get(method);
|
|
563
|
+
if (override) return override(params, this);
|
|
564
|
+
return this.builtin(name, params);
|
|
565
|
+
}
|
|
566
|
+
throttle(method) {
|
|
567
|
+
const spec = METHOD_SPECS[method];
|
|
568
|
+
const now = Date.now();
|
|
569
|
+
if (spec.minIntervalMs !== void 0) {
|
|
570
|
+
const last = this.lastCall.get(method);
|
|
571
|
+
if (last !== void 0 && now - last < spec.minIntervalMs) {
|
|
572
|
+
fail("RATE_LIMITED", `${method}: once per ${spec.minIntervalMs} ms`, {
|
|
573
|
+
retryAfterMs: spec.minIntervalMs - (now - last)
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
this.lastCall.set(method, now);
|
|
577
|
+
}
|
|
578
|
+
if (spec.perMinute !== void 0) {
|
|
579
|
+
const recent = (this.minuteCalls.get(method) ?? []).filter((t) => now - t < 6e4);
|
|
580
|
+
if (recent.length >= spec.perMinute) {
|
|
581
|
+
fail("RATE_LIMITED", `${method}: ${spec.perMinute} per minute`, {
|
|
582
|
+
retryAfterMs: 6e4 - (now - recent[0])
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
recent.push(now);
|
|
586
|
+
this.minuteCalls.set(method, recent);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
async builtin(method, p) {
|
|
590
|
+
const str = (key) => p[key];
|
|
591
|
+
switch (method) {
|
|
592
|
+
case "host.ready": {
|
|
593
|
+
if (p["protocol"] > this.context.protocol) {
|
|
594
|
+
fail("PROTOCOL_MISMATCH", `the host speaks protocol ${this.context.protocol}`);
|
|
595
|
+
}
|
|
596
|
+
this.ready = true;
|
|
597
|
+
return { ...this.context, chrome: this.chromeState() };
|
|
598
|
+
}
|
|
599
|
+
case "host.capabilities":
|
|
600
|
+
return {
|
|
601
|
+
protocol: this.context.protocol,
|
|
602
|
+
methods: CARD_METHOD_NAMES,
|
|
603
|
+
events: CARD_EVENT_NAMES
|
|
604
|
+
};
|
|
605
|
+
case "host.log":
|
|
606
|
+
this.logs.push({ level: str("level"), message: str("message") });
|
|
607
|
+
return void 0;
|
|
608
|
+
case "host.pong":
|
|
609
|
+
this.pongs.push(p["seq"]);
|
|
610
|
+
return void 0;
|
|
611
|
+
case "events.subscribe": {
|
|
612
|
+
const topics = p["topics"];
|
|
613
|
+
const agentIds = p["agentIds"] ?? [];
|
|
614
|
+
const granted = topics.filter((topic) => {
|
|
615
|
+
const need = TOPIC_PERMISSION[topic];
|
|
616
|
+
if (need && !this.grants.has(need)) return false;
|
|
617
|
+
if (topic === "agents.output") {
|
|
618
|
+
if (agentIds.length === 0) return false;
|
|
619
|
+
for (const id of agentIds) this.connectedAgent(id);
|
|
620
|
+
this.outputAgents = agentIds;
|
|
621
|
+
}
|
|
622
|
+
return true;
|
|
623
|
+
});
|
|
624
|
+
granted.forEach((topic) => this.subscriptions.add(topic));
|
|
625
|
+
return { topics: granted };
|
|
626
|
+
}
|
|
627
|
+
case "events.unsubscribe":
|
|
628
|
+
for (const topic of p["topics"]) this.subscriptions.delete(topic);
|
|
629
|
+
return void 0;
|
|
630
|
+
case "card.getInfo":
|
|
631
|
+
return this.context.instance;
|
|
632
|
+
case "card.setTitle":
|
|
633
|
+
this.chrome.title = p["title"];
|
|
634
|
+
return void 0;
|
|
635
|
+
case "card.setStatus":
|
|
636
|
+
this.chrome.status = p;
|
|
637
|
+
return void 0;
|
|
638
|
+
case "card.setBadge":
|
|
639
|
+
this.chrome.badge = p;
|
|
640
|
+
return void 0;
|
|
641
|
+
case "card.setOverview":
|
|
642
|
+
this.chrome.overview = p;
|
|
643
|
+
return void 0;
|
|
644
|
+
case "card.attention":
|
|
645
|
+
this.chrome.attention = p;
|
|
646
|
+
return void 0;
|
|
647
|
+
case "card.requestResize": {
|
|
648
|
+
const { minSize, maxSize } = this.manifest.card;
|
|
649
|
+
const size = {
|
|
650
|
+
w: Math.min(maxSize.w, Math.max(minSize.w, p["w"])),
|
|
651
|
+
h: Math.min(maxSize.h, Math.max(minSize.h, p["h"]))
|
|
652
|
+
};
|
|
653
|
+
this.resize(size);
|
|
654
|
+
return size;
|
|
655
|
+
}
|
|
656
|
+
case "card.openLink": {
|
|
657
|
+
const url = str("url");
|
|
658
|
+
if (!url.startsWith("https://")) fail("INVALID_PARAMS", "only https links");
|
|
659
|
+
return { opened: await this.confirmed({ type: "open-link", url }) };
|
|
660
|
+
}
|
|
661
|
+
case "card.focus":
|
|
662
|
+
return void 0;
|
|
663
|
+
case "card.spawn": {
|
|
664
|
+
const cardId = `spawned-${++this.counter}`;
|
|
665
|
+
if (this.spawned.length >= LIMITS.spawnsPerInstance)
|
|
666
|
+
fail("QUOTA_EXCEEDED", "spawn quota reached");
|
|
667
|
+
this.spawned.push({
|
|
668
|
+
cardId,
|
|
669
|
+
kind: str("kind"),
|
|
670
|
+
...p["title"] !== void 0 ? { title: str("title") } : {},
|
|
671
|
+
...p["init"] !== void 0 ? { init: p["init"] } : {}
|
|
672
|
+
});
|
|
673
|
+
return { cardId };
|
|
674
|
+
}
|
|
675
|
+
case "ui.toast":
|
|
676
|
+
this.toasts.push({ message: str("message"), ...p["tone"] ? { tone: str("tone") } : {} });
|
|
677
|
+
return void 0;
|
|
678
|
+
case "ui.confirm":
|
|
679
|
+
return {
|
|
680
|
+
confirmed: await this.confirmed({
|
|
681
|
+
type: "confirm",
|
|
682
|
+
title: str("title"),
|
|
683
|
+
message: str("message")
|
|
684
|
+
})
|
|
685
|
+
};
|
|
686
|
+
case "ui.setMenu":
|
|
687
|
+
this.chrome.menu = p["items"];
|
|
688
|
+
return void 0;
|
|
689
|
+
case "settings.get":
|
|
690
|
+
return { values: this.settingsValues, secrets: this.secretsSet };
|
|
691
|
+
case "settings.set": {
|
|
692
|
+
const values = p["values"];
|
|
693
|
+
const problems = checkSettingValues(this.manifest.settings, values);
|
|
694
|
+
if (problems.length > 0)
|
|
695
|
+
fail("INVALID_PARAMS", `${problems[0].key}: ${problems[0].message}`, problems);
|
|
696
|
+
this.setSettings(values);
|
|
697
|
+
return { values: this.settingsValues, secrets: this.secretsSet };
|
|
698
|
+
}
|
|
699
|
+
case "settings.open":
|
|
700
|
+
return void 0;
|
|
701
|
+
case "storage.get": {
|
|
702
|
+
const map = this.storage[str("scope")];
|
|
703
|
+
return map.has(str("key")) ? { exists: true, value: map.get(str("key")) } : { exists: false, value: null };
|
|
704
|
+
}
|
|
705
|
+
case "storage.set": {
|
|
706
|
+
const scope = str("scope");
|
|
707
|
+
const map = this.storage[scope];
|
|
708
|
+
const size = JSON.stringify(p["value"]).length;
|
|
709
|
+
if (size > LIMITS.storageValueBytes) fail("TOO_LARGE", "value larger than 1 MB");
|
|
710
|
+
if (!map.has(str("key")) && map.size >= LIMITS.storageKeys)
|
|
711
|
+
fail("QUOTA_EXCEEDED", "too many keys");
|
|
712
|
+
const quota = scope === "instance" ? LIMITS.instanceStorageBytes : LIMITS.packageStorageBytes;
|
|
713
|
+
const used = this.storageBytes(map) - (map.has(str("key")) ? JSON.stringify(map.get(str("key"))).length : 0);
|
|
714
|
+
if (used + size + str("key").length > quota)
|
|
715
|
+
fail("QUOTA_EXCEEDED", "storage quota exceeded");
|
|
716
|
+
map.set(str("key"), p["value"]);
|
|
717
|
+
return void 0;
|
|
718
|
+
}
|
|
719
|
+
case "storage.delete":
|
|
720
|
+
this.storage[str("scope")].delete(str("key"));
|
|
721
|
+
return void 0;
|
|
722
|
+
case "storage.keys": {
|
|
723
|
+
const prefix = p["prefix"] ?? "";
|
|
724
|
+
return {
|
|
725
|
+
keys: [...this.storage[str("scope")].keys()].filter((k) => k.startsWith(prefix)).sort()
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
case "storage.clear":
|
|
729
|
+
this.storage[str("scope")].clear();
|
|
730
|
+
return void 0;
|
|
731
|
+
case "storage.usage": {
|
|
732
|
+
const scope = str("scope");
|
|
733
|
+
const map = this.storage[scope];
|
|
734
|
+
return {
|
|
735
|
+
bytes: this.storageBytes(map),
|
|
736
|
+
quota: scope === "instance" ? LIMITS.instanceStorageBytes : LIMITS.packageStorageBytes,
|
|
737
|
+
keys: map.size
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
case "theme.get":
|
|
741
|
+
return this.context.theme;
|
|
742
|
+
case "i18n.get":
|
|
743
|
+
return this.context.i18n;
|
|
744
|
+
case "workspace.get":
|
|
745
|
+
return this.context.workspace;
|
|
746
|
+
case "permissions.list":
|
|
747
|
+
return this.permissionList();
|
|
748
|
+
case "permissions.request": {
|
|
749
|
+
const ids = p["ids"];
|
|
750
|
+
for (const id of ids) {
|
|
751
|
+
const declared = this.manifest.permissions.find((perm) => perm.id === id);
|
|
752
|
+
if (!declared?.optional)
|
|
753
|
+
fail("PERMISSION_DENIED", `${id} is not a declared optional permission`, {
|
|
754
|
+
permission: id
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
const granted = this.options.requestPermissions ? await this.options.requestPermissions(ids) : ids;
|
|
758
|
+
const next = [...this.granted, ...granted];
|
|
759
|
+
this.setGrants(next);
|
|
760
|
+
return { granted };
|
|
761
|
+
}
|
|
762
|
+
case "agents.list":
|
|
763
|
+
return this.agentList();
|
|
764
|
+
case "agents.get":
|
|
765
|
+
return this.agentList().find((a) => a.id === str("agentId")) ?? fail("NOT_FOUND", `no agent ${str("agentId")}`);
|
|
766
|
+
case "agents.readScreen": {
|
|
767
|
+
this.connectedAgent(str("agentId"));
|
|
768
|
+
const text = this.options.screens?.[str("agentId")] ?? "";
|
|
769
|
+
const lines = p["lines"] ?? LIMITS.screenLines;
|
|
770
|
+
return { text: text.split("\n").slice(-lines).join("\n") };
|
|
771
|
+
}
|
|
772
|
+
case "agents.lastReply": {
|
|
773
|
+
this.connectedAgent(str("agentId"));
|
|
774
|
+
const text = this.options.replies?.[str("agentId")] ?? null;
|
|
775
|
+
return { text, at: text === null ? null : Date.now() };
|
|
776
|
+
}
|
|
777
|
+
case "agents.prompt": {
|
|
778
|
+
const agent = this.connectedAgent(str("agentId"));
|
|
779
|
+
if (agent.kind !== "ai") fail("NOT_FOUND", `${agent.name} is not an AI agent`);
|
|
780
|
+
let delivered = "sent";
|
|
781
|
+
if (p["submit"] === false) delivered = "inserted";
|
|
782
|
+
else if (agent.status === "working") {
|
|
783
|
+
const whenBusy = p["whenBusy"] ?? "queue";
|
|
784
|
+
if (whenBusy === "fail") fail("BUSY", `${agent.name} is working`);
|
|
785
|
+
delivered = whenBusy === "queue" ? "queued" : "sent";
|
|
786
|
+
}
|
|
787
|
+
this.prompts.push({ agentId: agent.id, text: str("text"), delivered });
|
|
788
|
+
return { delivered };
|
|
789
|
+
}
|
|
790
|
+
case "terminals.run":
|
|
791
|
+
case "terminals.write": {
|
|
792
|
+
const agent = this.connectedAgent(str("agentId"));
|
|
793
|
+
if (agent.kind !== "shell") fail("NOT_FOUND", `${agent.name} is not a terminal`);
|
|
794
|
+
const run = method === "terminals.run";
|
|
795
|
+
this.terminal.push({
|
|
796
|
+
agentId: agent.id,
|
|
797
|
+
text: run ? str("command") : str("text"),
|
|
798
|
+
kind: run ? "run" : "write"
|
|
799
|
+
});
|
|
800
|
+
return run ? { exitCode: 0, output: "", truncated: false, timedOut: false } : void 0;
|
|
801
|
+
}
|
|
802
|
+
case "ports.describe":
|
|
803
|
+
return this.context.ports;
|
|
804
|
+
case "ports.peers":
|
|
805
|
+
return this.peerInfos();
|
|
806
|
+
case "ports.emit":
|
|
807
|
+
case "ports.send":
|
|
808
|
+
return {
|
|
809
|
+
delivered: this.deliver(
|
|
810
|
+
method === "ports.send" ? str("to") : null,
|
|
811
|
+
str("port"),
|
|
812
|
+
p["data"],
|
|
813
|
+
p["input"]
|
|
814
|
+
)
|
|
815
|
+
};
|
|
816
|
+
case "ports.request": {
|
|
817
|
+
const peer = this.peer(str("to"));
|
|
818
|
+
const input = peer.inputs.find((i) => i.id === str("input")) ?? fail("NOT_FOUND", `${peer.name} has no input ${str("input")}`);
|
|
819
|
+
if (input.mode !== "request") fail("INVALID_PARAMS", `${input.id} is not a request input`);
|
|
820
|
+
const respond = peer.respond ?? fail("UNAVAILABLE", `${peer.name} does not answer requests in this mock`);
|
|
821
|
+
return { data: await respond(input.id, p["data"]) };
|
|
822
|
+
}
|
|
823
|
+
case "ports.respond": {
|
|
824
|
+
const pending = this.portRequests.get(str("requestId")) ?? fail("NOT_FOUND", `no request ${str("requestId")}`);
|
|
825
|
+
this.portRequests.delete(str("requestId"));
|
|
826
|
+
if (p["error"] !== void 0) pending.reject(new HostError("INTERNAL", str("error")));
|
|
827
|
+
else pending.resolve(p["data"] ?? null);
|
|
828
|
+
return void 0;
|
|
829
|
+
}
|
|
830
|
+
case "ports.read": {
|
|
831
|
+
const peer = this.peer(str("from"));
|
|
832
|
+
const value = peer.retained?.[str("port")];
|
|
833
|
+
return value === void 0 ? null : { data: value, at: Date.now() };
|
|
834
|
+
}
|
|
835
|
+
case "tools.respond": {
|
|
836
|
+
const pending = this.toolCalls.get(str("callId")) ?? fail("NOT_FOUND", `no tool call ${str("callId")}`);
|
|
837
|
+
this.toolCalls.delete(str("callId"));
|
|
838
|
+
if (p["error"] !== void 0) pending.reject(new HostError("INTERNAL", str("error")));
|
|
839
|
+
else pending.resolve(p["result"]);
|
|
840
|
+
return void 0;
|
|
841
|
+
}
|
|
842
|
+
case "tools.progress":
|
|
843
|
+
return void 0;
|
|
844
|
+
case "tools.setEnabled":
|
|
845
|
+
if (!this.manifest.tools.some((t) => t.name === str("tool")))
|
|
846
|
+
fail("NOT_FOUND", `no tool ${str("tool")}`);
|
|
847
|
+
this.chrome.enabledTools[str("tool")] = p["enabled"];
|
|
848
|
+
return void 0;
|
|
849
|
+
case "net.fetch":
|
|
850
|
+
return this.fetch(p);
|
|
851
|
+
case "net.abort":
|
|
852
|
+
this.aborted.push(str("requestId"));
|
|
853
|
+
return void 0;
|
|
854
|
+
case "clipboard.writeText":
|
|
855
|
+
this.clipboard.push(str("text"));
|
|
856
|
+
return void 0;
|
|
857
|
+
case "usage.summary":
|
|
858
|
+
return this.options.usage ?? {
|
|
859
|
+
period: str("period"),
|
|
860
|
+
from: (/* @__PURE__ */ new Date()).toISOString(),
|
|
861
|
+
to: (/* @__PURE__ */ new Date()).toISOString(),
|
|
862
|
+
rows: [],
|
|
863
|
+
totalCostMicroUsd: 0,
|
|
864
|
+
partial: false
|
|
865
|
+
};
|
|
866
|
+
default:
|
|
867
|
+
return this.fs(method, p);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/** HostContext.chrome as the app would send it. */
|
|
871
|
+
chromeState() {
|
|
872
|
+
const { title, status, badge, overview, attention } = this.chrome;
|
|
873
|
+
return {
|
|
874
|
+
title,
|
|
875
|
+
status: status && status.text ? { text: status.text, tone: status.tone ?? "default", busy: status.busy === true } : null,
|
|
876
|
+
badge: badge && (badge.count != null || badge.text) ? { count: badge.count ?? null, text: badge.text ?? null, tone: badge.tone ?? "default" } : null,
|
|
877
|
+
overview: overview ?? null,
|
|
878
|
+
attention: attention && attention.level !== "none" ? { level: attention.level, message: attention.message ?? null } : null
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
storageBytes(map) {
|
|
882
|
+
let bytes = 0;
|
|
883
|
+
for (const [key, value] of map) bytes += key.length + JSON.stringify(value).length;
|
|
884
|
+
return bytes;
|
|
885
|
+
}
|
|
886
|
+
async confirmed(request) {
|
|
887
|
+
return this.options.confirm ? await this.options.confirm(request) : true;
|
|
888
|
+
}
|
|
889
|
+
deliver(to, portId, data, explicitInput) {
|
|
890
|
+
const output = this.context.ports.outputs.find((o) => o.id === portId) ?? fail("NOT_FOUND", `no output port "${portId}" in the manifest`);
|
|
891
|
+
for (const schema of portSchemas(output)) {
|
|
892
|
+
const issues = validateJson(schema, data);
|
|
893
|
+
if (issues.length > 0)
|
|
894
|
+
fail(
|
|
895
|
+
"INVALID_PARAMS",
|
|
896
|
+
`data for ${portId}: ${issues[0].path || "value"} ${issues[0].message}`,
|
|
897
|
+
issues
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
if (output.retain) this.retained.set(portId, { data, at: Date.now() });
|
|
901
|
+
const peers = to ? [this.peer(to)] : this.options.peers ?? [];
|
|
902
|
+
let delivered = 0;
|
|
903
|
+
for (const peer of peers) {
|
|
904
|
+
if (peer.direction === "upstream") {
|
|
905
|
+
if (to)
|
|
906
|
+
fail("NOT_CONNECTED", `the arrow goes from ${peer.name} to this card, not the other way`);
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
const input = explicitInput ? peer.inputs.find((i) => i.id === explicitInput) ?? fail("NOT_FOUND", `${peer.name} has no input ${explicitInput}`) : pickInputFor(output.type, peer.inputs);
|
|
910
|
+
if (!input) continue;
|
|
911
|
+
if (input.permission && !this.grants.has(input.permission)) {
|
|
912
|
+
if (to)
|
|
913
|
+
fail("PERMISSION_DENIED", `${input.id} needs ${input.permission}`, {
|
|
914
|
+
permission: input.permission
|
|
915
|
+
});
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
let value;
|
|
919
|
+
try {
|
|
920
|
+
value = coercePortValue(output.type, input.type, data);
|
|
921
|
+
} catch {
|
|
922
|
+
if (to) fail("INVALID_PARAMS", `${output.type} does not fit ${input.type}`);
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
const bad = portSchemas(input).some((schema) => validateJson(schema, value).length > 0);
|
|
926
|
+
if (bad) {
|
|
927
|
+
if (to) fail("INVALID_PARAMS", `the value does not fit ${peer.name}'s input ${input.id}`);
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
this.deliveries.push({
|
|
931
|
+
to: peer.cardId,
|
|
932
|
+
output: portId,
|
|
933
|
+
input: input.id,
|
|
934
|
+
data: value
|
|
935
|
+
});
|
|
936
|
+
delivered++;
|
|
937
|
+
}
|
|
938
|
+
return delivered;
|
|
939
|
+
}
|
|
940
|
+
async fetch(p) {
|
|
941
|
+
let url;
|
|
942
|
+
try {
|
|
943
|
+
url = new URL(p["url"]);
|
|
944
|
+
} catch {
|
|
945
|
+
return fail("INVALID_PARAMS", "not a URL");
|
|
946
|
+
}
|
|
947
|
+
const local = isLocalHostname(url.hostname);
|
|
948
|
+
if (url.username || url.password)
|
|
949
|
+
fail("INVALID_PARAMS", "credentials in the URL are not allowed");
|
|
950
|
+
if (local) {
|
|
951
|
+
if (!this.grants.has("network.local"))
|
|
952
|
+
fail("PERMISSION_DENIED", "localhost needs network.local", { permission: "network.local" });
|
|
953
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
954
|
+
fail("HOST_NOT_ALLOWED", "http(s) only");
|
|
955
|
+
} else {
|
|
956
|
+
if (url.protocol !== "https:") fail("HOST_NOT_ALLOWED", "https only");
|
|
957
|
+
const hosts = this.manifest.permissions.find((perm) => perm.id === "network")?.hosts ?? [];
|
|
958
|
+
if (!this.grants.has("network") || !hostAllowed(hosts, url.hostname, Number(url.port || 443))) {
|
|
959
|
+
fail("HOST_NOT_ALLOWED", `${url.host} is not in the network permission`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (!this.options.fetch)
|
|
963
|
+
return fail("NETWORK_ERROR", "the mock host has no fetch handler (MockHostOptions.fetch)");
|
|
964
|
+
const headers = {};
|
|
965
|
+
for (const [name, value] of Object.entries(
|
|
966
|
+
p["headers"] ?? {}
|
|
967
|
+
)) {
|
|
968
|
+
const problem = checkRequestHeader(name, value);
|
|
969
|
+
if (problem) fail("INVALID_PARAMS", problem);
|
|
970
|
+
const keys = secretPlaceholders(value);
|
|
971
|
+
for (const key of keys) {
|
|
972
|
+
if (!this.manifest.settings.some((f) => f.key === key && f.type === "secret"))
|
|
973
|
+
fail("INVALID_PARAMS", `"${key}" is not a secret setting of this card`);
|
|
974
|
+
if (!this.secretValues.has(key))
|
|
975
|
+
fail("INVALID_PARAMS", `the secret setting "${key}" is not set`);
|
|
976
|
+
}
|
|
977
|
+
headers[name.toLowerCase()] = keys.length ? value.replace(
|
|
978
|
+
SECRET_PLACEHOLDER_RE,
|
|
979
|
+
(_all, key) => this.secretValues.get(key) ?? ""
|
|
980
|
+
) : value;
|
|
981
|
+
}
|
|
982
|
+
const bodyText = p["body"];
|
|
983
|
+
const response = await this.options.fetch({
|
|
984
|
+
url: url.toString(),
|
|
985
|
+
method: p["method"] ?? "GET",
|
|
986
|
+
headers,
|
|
987
|
+
...bodyText !== void 0 ? { body: p["bodyEncoding"] === "base64" ? base64ToBytes(bodyText) : bodyText } : {}
|
|
988
|
+
});
|
|
989
|
+
if (response.error) fail("NETWORK_ERROR", response.error);
|
|
990
|
+
const requestId = p["requestId"];
|
|
991
|
+
const type = p["responseType"] ?? "text";
|
|
992
|
+
const base = {
|
|
993
|
+
requestId,
|
|
994
|
+
status: response.status ?? 200,
|
|
995
|
+
statusText: response.statusText ?? "OK",
|
|
996
|
+
headers: Object.fromEntries(
|
|
997
|
+
Object.entries(response.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])
|
|
998
|
+
),
|
|
999
|
+
url: url.toString()
|
|
1000
|
+
};
|
|
1001
|
+
const body = response.body;
|
|
1002
|
+
const asText = () => body === void 0 ? "" : typeof body === "string" ? body : body instanceof Uint8Array ? new TextDecoder().decode(body) : JSON.stringify(body);
|
|
1003
|
+
switch (type) {
|
|
1004
|
+
case "json": {
|
|
1005
|
+
if (body !== void 0 && typeof body !== "string" && !(body instanceof Uint8Array))
|
|
1006
|
+
return { ...base, json: body };
|
|
1007
|
+
if (asText().trim() === "") return { ...base, json: null };
|
|
1008
|
+
try {
|
|
1009
|
+
return { ...base, json: JSON.parse(asText()) };
|
|
1010
|
+
} catch {
|
|
1011
|
+
return fail("NETWORK_ERROR", "response is not JSON");
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
case "base64":
|
|
1015
|
+
return {
|
|
1016
|
+
...base,
|
|
1017
|
+
body: bytesToBase64(
|
|
1018
|
+
body instanceof Uint8Array ? body : new TextEncoder().encode(asText())
|
|
1019
|
+
)
|
|
1020
|
+
};
|
|
1021
|
+
case "stream": {
|
|
1022
|
+
const chunks = response.stream ?? [asText()];
|
|
1023
|
+
setTimeout(() => {
|
|
1024
|
+
chunks.forEach(
|
|
1025
|
+
(chunk) => this.emit("net.chunk", {
|
|
1026
|
+
requestId,
|
|
1027
|
+
chunk: typeof chunk === "string" ? chunk : bytesToBase64(chunk),
|
|
1028
|
+
encoding: typeof chunk === "string" ? "text" : "base64",
|
|
1029
|
+
done: false
|
|
1030
|
+
})
|
|
1031
|
+
);
|
|
1032
|
+
this.emit("net.chunk", { requestId, chunk: "", encoding: "text", done: true });
|
|
1033
|
+
}, 0);
|
|
1034
|
+
return base;
|
|
1035
|
+
}
|
|
1036
|
+
default:
|
|
1037
|
+
return { ...base, body: asText() };
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
/** The app's write rules: any `.git` segment, agent config (`.claude/`, `CLAUDE.md`, `.mcp.json`…). */
|
|
1041
|
+
assertWritable(path) {
|
|
1042
|
+
const reason = protectedWriteReason(path);
|
|
1043
|
+
if (reason) fail("FS_DENIED", reason);
|
|
1044
|
+
}
|
|
1045
|
+
fs(method, p) {
|
|
1046
|
+
const pathOf = (key = "path") => {
|
|
1047
|
+
const checked = normalizeWorkspacePath(p[key]);
|
|
1048
|
+
return checked.ok ? checked.value : fail("FS_DENIED", checked.error);
|
|
1049
|
+
};
|
|
1050
|
+
const isDir = (path) => path === "" || [...this.files.keys()].some((file) => file.startsWith(`${path}/`));
|
|
1051
|
+
const stat = (path) => {
|
|
1052
|
+
const file = this.files.get(path);
|
|
1053
|
+
if (file) return { path, type: "file", size: file.data.length, mtimeMs: file.mtimeMs };
|
|
1054
|
+
if (isDir(path)) return { path, type: "directory", size: 0, mtimeMs: 0 };
|
|
1055
|
+
return fail("NOT_FOUND", `${path} does not exist`);
|
|
1056
|
+
};
|
|
1057
|
+
switch (method) {
|
|
1058
|
+
case "fs.stat":
|
|
1059
|
+
return stat(pathOf());
|
|
1060
|
+
case "fs.list": {
|
|
1061
|
+
const root = pathOf();
|
|
1062
|
+
if (!isDir(root)) fail("NOT_FOUND", `${root} is not a folder`);
|
|
1063
|
+
const prefix = root ? `${root}/` : "";
|
|
1064
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1065
|
+
for (const [path, file] of this.files) {
|
|
1066
|
+
if (!path.startsWith(prefix)) continue;
|
|
1067
|
+
const rest = path.slice(prefix.length).split("/");
|
|
1068
|
+
if (p["recursive"] === true) {
|
|
1069
|
+
for (let i = 1; i < rest.length; i++) {
|
|
1070
|
+
const dir = prefix + rest.slice(0, i).join("/");
|
|
1071
|
+
entries.set(dir, { path: dir, type: "directory" });
|
|
1072
|
+
}
|
|
1073
|
+
entries.set(path, { path, type: "file", size: file.data.length });
|
|
1074
|
+
} else if (rest.length === 1)
|
|
1075
|
+
entries.set(path, { path, type: "file", size: file.data.length });
|
|
1076
|
+
else entries.set(prefix + rest[0], { path: prefix + rest[0], type: "directory" });
|
|
1077
|
+
}
|
|
1078
|
+
const max = p["maxEntries"] ?? LIMITS.fsListEntries;
|
|
1079
|
+
const all = [...entries.values()].sort((a, b) => a.path < b.path ? -1 : 1);
|
|
1080
|
+
return { entries: all.slice(0, max), truncated: all.length > max };
|
|
1081
|
+
}
|
|
1082
|
+
case "fs.read": {
|
|
1083
|
+
const path = pathOf();
|
|
1084
|
+
const file = this.files.get(path) ?? fail("NOT_FOUND", `${path} does not exist`);
|
|
1085
|
+
const max = p["maxBytes"] ?? LIMITS.fsReadBytes;
|
|
1086
|
+
const bytes = file.data.subarray(0, max);
|
|
1087
|
+
const encoding = p["encoding"] ?? "utf8";
|
|
1088
|
+
return {
|
|
1089
|
+
data: encoding === "base64" ? bytesToBase64(bytes) : new TextDecoder().decode(bytes),
|
|
1090
|
+
encoding,
|
|
1091
|
+
size: file.data.length,
|
|
1092
|
+
truncated: file.data.length > max
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
case "fs.write": {
|
|
1096
|
+
const path = pathOf();
|
|
1097
|
+
if (path === "") fail("FS_DENIED", "the workspace folder itself cannot be changed");
|
|
1098
|
+
this.assertWritable(path);
|
|
1099
|
+
const existing = this.files.get(path);
|
|
1100
|
+
if (p["ifMtimeMs"] !== void 0 && existing && existing.mtimeMs !== p["ifMtimeMs"]) {
|
|
1101
|
+
fail("FS_ERROR", "the file changed since it was read", {
|
|
1102
|
+
conflict: true,
|
|
1103
|
+
mtimeMs: existing.mtimeMs
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
const parent = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
|
1107
|
+
if (parent && !isDir(parent) && p["createDirs"] !== true)
|
|
1108
|
+
fail("FS_ERROR", `folder ${parent} does not exist`);
|
|
1109
|
+
const data = p["encoding"] === "base64" ? base64ToBytes(p["data"]) : p["data"];
|
|
1110
|
+
this.putFile(path, data);
|
|
1111
|
+
const file = this.files.get(path);
|
|
1112
|
+
if (existing && file.mtimeMs <= existing.mtimeMs) file.mtimeMs = existing.mtimeMs + 1;
|
|
1113
|
+
this.touchFile(path);
|
|
1114
|
+
return stat(path);
|
|
1115
|
+
}
|
|
1116
|
+
case "fs.mkdir": {
|
|
1117
|
+
const path = pathOf();
|
|
1118
|
+
this.assertWritable(path);
|
|
1119
|
+
this.files.set(`${path}/.keep`, { data: new Uint8Array(), mtimeMs: Date.now() });
|
|
1120
|
+
return { path, type: "directory", size: 0, mtimeMs: Date.now() };
|
|
1121
|
+
}
|
|
1122
|
+
case "fs.trash": {
|
|
1123
|
+
const path = pathOf();
|
|
1124
|
+
this.assertWritable(path);
|
|
1125
|
+
stat(path);
|
|
1126
|
+
for (const file of [...this.files.keys()])
|
|
1127
|
+
if (file === path || file.startsWith(`${path}/`)) this.files.delete(file);
|
|
1128
|
+
return void 0;
|
|
1129
|
+
}
|
|
1130
|
+
case "fs.watch": {
|
|
1131
|
+
if (this.watchers.size >= LIMITS.fsWatchers) fail("QUOTA_EXCEEDED", "too many watchers");
|
|
1132
|
+
const watchId = `watch-${++this.counter}`;
|
|
1133
|
+
this.watchers.set(watchId, { path: pathOf(), recursive: p["recursive"] === true });
|
|
1134
|
+
return { watchId };
|
|
1135
|
+
}
|
|
1136
|
+
case "fs.unwatch":
|
|
1137
|
+
this.watchers.delete(p["watchId"]);
|
|
1138
|
+
return void 0;
|
|
1139
|
+
default:
|
|
1140
|
+
return fail("METHOD_NOT_FOUND", `the mock host does not implement ${method}`);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
function createMockHost(options = {}) {
|
|
1145
|
+
return new MockHost(options);
|
|
1146
|
+
}
|
|
1147
|
+
export {
|
|
1148
|
+
MockHost,
|
|
1149
|
+
createMockHost
|
|
1150
|
+
};
|