@get-bb/plugin-sdk 0.4.3

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.
@@ -0,0 +1,1625 @@
1
+ // src/testing/fake-plugin-host.ts
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import Database from "better-sqlite3";
6
+ import { CronExpressionParser } from "cron-parser";
7
+ import { Hono } from "hono";
8
+ import { z as z2 } from "zod";
9
+
10
+ // ../domain/src/plugin-interaction-limits.ts
11
+ var PLUGIN_INTERACTION_MAX_TITLE_LENGTH = 160;
12
+
13
+ // src/internal/host-policy.ts
14
+ import { z } from "zod";
15
+
16
+ // ../domain/src/plugin-cli.ts
17
+ var RESERVED_BB_CLI_COMMANDS = [
18
+ "environment",
19
+ "guide",
20
+ "help",
21
+ "manager",
22
+ "plugin",
23
+ "project",
24
+ "provider",
25
+ "skill",
26
+ "status",
27
+ "theme",
28
+ "thread"
29
+ ];
30
+
31
+ // src/backend-contract.ts
32
+ var PLUGIN_CLI_OUTPUT_MAX_BYTES = 1024 * 1024;
33
+
34
+ // src/internal/host-policy.ts
35
+ var RESERVED_AGENT_TOOL_NAMES = [
36
+ "update_environment_directory"
37
+ ];
38
+ var KV_VALUE_MAX_BYTES = 256 * 1024;
39
+ var PLUGIN_HTTP_METHODS = /* @__PURE__ */ new Set([
40
+ "GET",
41
+ "POST",
42
+ "PUT",
43
+ "PATCH",
44
+ "DELETE",
45
+ "HEAD",
46
+ "OPTIONS"
47
+ ]);
48
+ var RPC_METHOD_PATTERN = /^[a-zA-Z0-9_-]+$/;
49
+ var BACKGROUND_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
50
+ var CLI_COMMAND_NAME_PATTERN = /^[a-z0-9-]+$/;
51
+ var AGENT_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
52
+ var PLUGIN_AGENT_STATIC_INSTRUCTIONS_MAX_CHARS = 4096;
53
+ var PLUGIN_AGENT_STATUS_LABEL_MAX_CHARS = 80;
54
+ var PLUGIN_AGENT_SELECTION_MAX_IDS = 256;
55
+ var PLUGIN_AGENT_DYNAMIC_INSTRUCTIONS_MAX_CHARS = 4096;
56
+ var PLUGIN_AGENT_TOOL_PARAMETERS_MAX_BYTES = 128 * 1024;
57
+ var MENTION_PROVIDER_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
58
+ var SETTING_KEY_PATTERN = /^[a-zA-Z0-9_-]+$/;
59
+ var settingsBaseFields = {
60
+ label: z.string().min(1),
61
+ description: z.string().min(1).optional()
62
+ };
63
+ var settingDescriptorSchema = z.discriminatedUnion("type", [
64
+ z.object({
65
+ type: z.literal("string"),
66
+ ...settingsBaseFields,
67
+ secret: z.literal(true).optional(),
68
+ default: z.string().optional()
69
+ }).strict(),
70
+ z.object({
71
+ type: z.literal("boolean"),
72
+ ...settingsBaseFields,
73
+ default: z.boolean().optional()
74
+ }).strict(),
75
+ z.object({
76
+ type: z.literal("select"),
77
+ ...settingsBaseFields,
78
+ options: z.array(z.string().min(1)).min(1),
79
+ default: z.string().optional()
80
+ }).strict(),
81
+ z.object({
82
+ type: z.literal("project"),
83
+ ...settingsBaseFields,
84
+ default: z.string().optional()
85
+ }).strict()
86
+ ]);
87
+ function registerSettingDescriptors(target, added) {
88
+ const validated = {};
89
+ for (const [key, raw] of Object.entries(added)) {
90
+ if (!SETTING_KEY_PATTERN.test(key)) {
91
+ throw new Error(
92
+ `invalid setting key "${key}" \u2014 use letters, digits, "-" and "_"`
93
+ );
94
+ }
95
+ if (key in target) {
96
+ throw new Error(`setting "${key}" is already defined`);
97
+ }
98
+ const parsed = settingDescriptorSchema.safeParse(raw);
99
+ if (!parsed.success) {
100
+ const issue = parsed.error.issues[0];
101
+ const path = issue?.path.join(".") ?? "";
102
+ throw new Error(
103
+ `invalid descriptor for setting "${key}"${path ? ` (${path})` : ""}: ${issue?.message ?? "unknown error"}`
104
+ );
105
+ }
106
+ const descriptor = parsed.data;
107
+ if (descriptor.type === "select" && descriptor.default !== void 0 && !descriptor.options.includes(descriptor.default)) {
108
+ throw new Error(
109
+ `default for setting "${key}" must be one of its options`
110
+ );
111
+ }
112
+ validated[key] = descriptor;
113
+ }
114
+ Object.assign(target, validated);
115
+ return validated;
116
+ }
117
+ function validateSettingsUpdate(descriptors, values) {
118
+ const errors = [];
119
+ for (const [key, value] of Object.entries(values)) {
120
+ const descriptor = descriptors[key];
121
+ if (!descriptor) {
122
+ errors.push(`unknown setting "${key}"`);
123
+ continue;
124
+ }
125
+ if (value === null) continue;
126
+ if (descriptor.type === "boolean") {
127
+ if (typeof value !== "boolean") {
128
+ errors.push(`setting "${key}" expects a boolean`);
129
+ }
130
+ continue;
131
+ }
132
+ if (typeof value !== "string") {
133
+ errors.push(`setting "${key}" expects a string`);
134
+ continue;
135
+ }
136
+ if (descriptor.type === "select" && !descriptor.options.includes(value)) {
137
+ errors.push(
138
+ `setting "${key}" must be one of: ${descriptor.options.join(", ")}`
139
+ );
140
+ }
141
+ }
142
+ return errors;
143
+ }
144
+ var PLUGIN_MENTION_TRIGGER_VALUES = [
145
+ "@",
146
+ "#",
147
+ "$",
148
+ "!",
149
+ "~"
150
+ ];
151
+ var DEFAULT_PLUGIN_MENTION_TRIGGERS = [
152
+ "@"
153
+ ];
154
+ function isPluginMentionTrigger(value) {
155
+ return typeof value === "string" && PLUGIN_MENTION_TRIGGER_VALUES.includes(value);
156
+ }
157
+ function normalizeMentionProviderTriggers(providerId, triggers) {
158
+ if (triggers === void 0) {
159
+ return DEFAULT_PLUGIN_MENTION_TRIGGERS;
160
+ }
161
+ if (!Array.isArray(triggers)) {
162
+ throw new Error(
163
+ `mention provider "${providerId}" triggers must be an array`
164
+ );
165
+ }
166
+ if (triggers.length === 0) {
167
+ throw new Error(
168
+ `mention provider "${providerId}" triggers must include at least one trigger`
169
+ );
170
+ }
171
+ const seen = /* @__PURE__ */ new Set();
172
+ const normalized = [];
173
+ for (const trigger of triggers) {
174
+ if (!isPluginMentionTrigger(trigger)) {
175
+ throw new Error(
176
+ `mention provider "${providerId}" trigger ${JSON.stringify(trigger)} is invalid; use one of ${PLUGIN_MENTION_TRIGGER_VALUES.join(" ")}`
177
+ );
178
+ }
179
+ if (seen.has(trigger)) {
180
+ throw new Error(
181
+ `mention provider "${providerId}" trigger ${JSON.stringify(trigger)} is duplicated`
182
+ );
183
+ }
184
+ seen.add(trigger);
185
+ normalized.push(trigger);
186
+ }
187
+ return normalized;
188
+ }
189
+ function isStandardSchema(value) {
190
+ if (typeof value !== "object" || value === null) return false;
191
+ const standard = Reflect.get(value, "~standard");
192
+ return typeof standard === "object" && standard !== null && Reflect.get(standard, "version") === 1 && typeof Reflect.get(standard, "vendor") === "string" && typeof Reflect.get(standard, "validate") === "function";
193
+ }
194
+ function readRpcMethodContract(method, value) {
195
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
196
+ throw new Error(
197
+ `rpc method "${method}" contract must provide input and output Standard Schemas`
198
+ );
199
+ }
200
+ const input = Reflect.get(value, "input");
201
+ const output = Reflect.get(value, "output");
202
+ if (!isStandardSchema(input)) {
203
+ throw new Error(
204
+ `rpc method "${method}" input must be a Standard Schema v1 validator`
205
+ );
206
+ }
207
+ if (!isStandardSchema(output)) {
208
+ throw new Error(
209
+ `rpc method "${method}" output must be a Standard Schema v1 validator`
210
+ );
211
+ }
212
+ return { input, output };
213
+ }
214
+ function isZodSchemaLike(value) {
215
+ return typeof value === "object" && value !== null && typeof value.safeParse === "function";
216
+ }
217
+ function summarizeParseIssues(error) {
218
+ const issues = error?.issues;
219
+ if (Array.isArray(issues) && issues.length > 0) {
220
+ return issues.map((issue) => {
221
+ const path = Array.isArray(issue.path) && issue.path.length > 0 ? issue.path.join(".") : "(input)";
222
+ return `${path}: ${issue.message ?? "invalid"}`;
223
+ }).join("; ");
224
+ }
225
+ return error instanceof Error ? error.message : String(error);
226
+ }
227
+ function enforcePluginCliOutputLimit(result, jsonOutput) {
228
+ const stdoutBytes = Buffer.byteLength(result.stdout, "utf8");
229
+ const stderrBytes = Buffer.byteLength(result.stderr, "utf8");
230
+ const totalBytes = stdoutBytes + stderrBytes;
231
+ if (totalBytes <= PLUGIN_CLI_OUTPUT_MAX_BYTES) return result;
232
+ const error = {
233
+ code: "plugin_cli_output_too_large",
234
+ message: `Plugin CLI output is ${totalBytes} bytes (${stdoutBytes} stdout + ${stderrBytes} stderr), exceeding the ${PLUGIN_CLI_OUTPUT_MAX_BYTES}-byte limit. Narrow the query, request a smaller page, or use a file/streaming command.`,
235
+ maxBytes: PLUGIN_CLI_OUTPUT_MAX_BYTES,
236
+ stdoutBytes,
237
+ stderrBytes,
238
+ totalBytes
239
+ };
240
+ return jsonOutput ? {
241
+ exitCode: 1,
242
+ stdout: JSON.stringify({ error }),
243
+ stderr: "",
244
+ error
245
+ } : { exitCode: 1, stdout: "", stderr: error.message, error };
246
+ }
247
+
248
+ // src/testing/fake-sdk.ts
249
+ function withSpawnAttribution(pluginId, args) {
250
+ const [first, ...rest] = args;
251
+ if (typeof first !== "object" || first === null) return args;
252
+ const spawnArgs = first;
253
+ const origin = spawnArgs.origin ?? "plugin";
254
+ return [
255
+ {
256
+ ...spawnArgs,
257
+ origin,
258
+ ...origin === "plugin" ? { originPluginId: spawnArgs.originPluginId ?? pluginId } : {}
259
+ },
260
+ ...rest
261
+ ];
262
+ }
263
+ function createFakeSdk(options) {
264
+ const calls = [];
265
+ const stubs = /* @__PURE__ */ new Map();
266
+ function addOverrides(prefix, value) {
267
+ if (typeof value === "function") {
268
+ stubs.set(prefix, value);
269
+ return;
270
+ }
271
+ if (typeof value !== "object" || value === null) return;
272
+ for (const [key, child] of Object.entries(value)) {
273
+ addOverrides(prefix.length === 0 ? key : `${prefix}.${key}`, child);
274
+ }
275
+ }
276
+ addOverrides("", options.overrides ?? {});
277
+ function invoke(path, rawArgs) {
278
+ const args = path === "threads.spawn" ? withSpawnAttribution(options.pluginId, rawArgs) : rawArgs;
279
+ calls.push({ path, args });
280
+ const stub = stubs.get(path);
281
+ if (!stub) {
282
+ throw new Error(
283
+ `bb.sdk.${path} is not stubbed \u2014 pass an implementation via createFakePluginHost({ sdk: { ... } }) or harness.sdk.stub("${path}", fn)`
284
+ );
285
+ }
286
+ return stub(...args);
287
+ }
288
+ const nodes = /* @__PURE__ */ new Map();
289
+ function node(path) {
290
+ const cached = nodes.get(path);
291
+ if (cached) return cached;
292
+ const created = new Proxy(function() {
293
+ }, {
294
+ get(_target, prop) {
295
+ if (typeof prop !== "string" || prop === "then") return void 0;
296
+ return node(path === "" ? prop : `${path}.${prop}`);
297
+ },
298
+ apply(_target, _thisArg, args) {
299
+ return invoke(path, args);
300
+ }
301
+ });
302
+ nodes.set(path, created);
303
+ return created;
304
+ }
305
+ const harness = {
306
+ calls,
307
+ callsTo(path) {
308
+ return calls.filter((call) => call.path === path).map((call) => call.args);
309
+ },
310
+ stub(path, implementation) {
311
+ stubs.set(path, implementation);
312
+ }
313
+ };
314
+ return { sdk: node(""), harness };
315
+ }
316
+
317
+ // src/testing/fake-plugin-host.ts
318
+ var PluginContextStaleError = class extends Error {
319
+ constructor(pluginId) {
320
+ super(
321
+ `plugin "${pluginId}" used a stale API handle \u2014 it was reloaded or disabled; re-entry happens via a fresh factory call`
322
+ );
323
+ this.name = "PluginContextStaleError";
324
+ }
325
+ };
326
+ function readSettingsValues(descriptors, stored) {
327
+ const values = {};
328
+ for (const [key, descriptor] of Object.entries(descriptors)) {
329
+ let value = stored.get(key);
330
+ const expected = descriptor.type === "boolean" ? "boolean" : "string";
331
+ if (typeof value !== expected) value = void 0;
332
+ if (descriptor.type === "select" && typeof value === "string" && !descriptor.options.includes(value)) {
333
+ value = void 0;
334
+ }
335
+ values[key] = value ?? descriptor.default;
336
+ }
337
+ return values;
338
+ }
339
+ function isNeedsConfigurationError(error) {
340
+ return error instanceof Error && error.name === "NeedsConfigurationError";
341
+ }
342
+ function errorMessage(error) {
343
+ return error instanceof Error ? error.message : String(error);
344
+ }
345
+ function jsonRoundTrip(value, what) {
346
+ if (value === void 0) return void 0;
347
+ let json;
348
+ try {
349
+ json = JSON.stringify(value);
350
+ } catch {
351
+ json = void 0;
352
+ }
353
+ if (json === void 0) {
354
+ throw new Error(`${what} is not JSON-serializable`);
355
+ }
356
+ return JSON.parse(json);
357
+ }
358
+ function normalizeRpcIssues(issues) {
359
+ return issues.map((issue) => {
360
+ const rawPath = issue.path;
361
+ const segments = rawPath === void 0 ? [] : Array.isArray(rawPath) ? rawPath : [rawPath];
362
+ const path = segments.map((segment) => {
363
+ const key = typeof segment === "object" && segment !== null ? Reflect.get(segment, "key") : segment;
364
+ return typeof key === "number" ? key : String(key);
365
+ });
366
+ return {
367
+ message: issue.message,
368
+ ...path.length > 0 ? { path } : {}
369
+ };
370
+ });
371
+ }
372
+ function throwRpcError(error) {
373
+ const thrown = new Error(error.message);
374
+ Reflect.set(thrown, "code", error.code);
375
+ if (error.issues !== void 0) Reflect.set(thrown, "issues", error.issues);
376
+ throw thrown;
377
+ }
378
+ async function validateRpcValue(schema, value, phase) {
379
+ let result;
380
+ try {
381
+ result = await schema["~standard"].validate(value);
382
+ } catch (error) {
383
+ const message = errorMessage(error);
384
+ return throwRpcError({
385
+ code: phase === "input" ? "invalid_input" : "invalid_output",
386
+ message: `rpc ${phase} validator failed: ${message}`,
387
+ issues: [{ message }]
388
+ });
389
+ }
390
+ if (result.issues !== void 0) {
391
+ return throwRpcError({
392
+ code: phase === "input" ? "invalid_input" : "invalid_output",
393
+ message: `rpc ${phase} validation failed`,
394
+ issues: normalizeRpcIssues(result.issues)
395
+ });
396
+ }
397
+ return result.value;
398
+ }
399
+ function normalizeRpcJsonResult(value) {
400
+ const ancestors = /* @__PURE__ */ new Set();
401
+ function visit(current, path) {
402
+ if (current === null || typeof current === "string" || typeof current === "boolean") {
403
+ return current;
404
+ }
405
+ if (typeof current === "number") {
406
+ if (!Number.isFinite(current)) {
407
+ return throwRpcError({
408
+ code: "non_json_result",
409
+ message: `rpc result at ${path} contains a non-finite number`
410
+ });
411
+ }
412
+ return current;
413
+ }
414
+ if (typeof current !== "object") {
415
+ return throwRpcError({
416
+ code: "non_json_result",
417
+ message: `rpc result at ${path} is not a JSON value (${typeof current})`
418
+ });
419
+ }
420
+ if (ancestors.has(current)) {
421
+ return throwRpcError({
422
+ code: "non_json_result",
423
+ message: `rpc result at ${path} is cyclic`
424
+ });
425
+ }
426
+ ancestors.add(current);
427
+ try {
428
+ if (Array.isArray(current)) {
429
+ return current.map((item, index) => visit(item, `${path}[${index}]`));
430
+ }
431
+ const prototype = Object.getPrototypeOf(current);
432
+ if (prototype !== Object.prototype && prototype !== null) {
433
+ return throwRpcError({
434
+ code: "non_json_result",
435
+ message: `rpc result at ${path} must be a plain JSON object`
436
+ });
437
+ }
438
+ if (Reflect.ownKeys(current).some((key) => typeof key === "symbol")) {
439
+ return throwRpcError({
440
+ code: "non_json_result",
441
+ message: `rpc result at ${path} contains a symbol key`
442
+ });
443
+ }
444
+ const normalized = {};
445
+ for (const [key, child] of Object.entries(current)) {
446
+ normalized[key] = visit(child, `${path}.${key}`);
447
+ }
448
+ return normalized;
449
+ } finally {
450
+ ancestors.delete(current);
451
+ }
452
+ }
453
+ return visit(value, "$result");
454
+ }
455
+ function normalizeAgentToolSelections(args) {
456
+ if (!Array.isArray(args.value)) {
457
+ throw new Error("configure() output.tools must be an array");
458
+ }
459
+ if (args.value.length > PLUGIN_AGENT_SELECTION_MAX_IDS) {
460
+ throw new Error(
461
+ `configure() output.tools exceeds the ${PLUGIN_AGENT_SELECTION_MAX_IDS}-id limit`
462
+ );
463
+ }
464
+ const toolIds = [];
465
+ const parameterOverrides = /* @__PURE__ */ new Map();
466
+ const seen = /* @__PURE__ */ new Set();
467
+ for (let index = 0; index < args.value.length; index += 1) {
468
+ const entry = args.value[index];
469
+ let name;
470
+ let parameters = null;
471
+ if (typeof entry === "string") {
472
+ name = entry;
473
+ } else if (typeof entry === "object" && entry !== null && !Array.isArray(entry)) {
474
+ const typed = entry;
475
+ const unknownKeys = Object.keys(typed).filter((key) => !["name", "parameters"].includes(key)).sort();
476
+ if (unknownKeys.length > 0) {
477
+ throw new Error(
478
+ `configure() output.tools[${index}] contains unknown field${unknownKeys.length === 1 ? "" : "s"}: ${unknownKeys.join(", ")}`
479
+ );
480
+ }
481
+ name = typed.name;
482
+ parameters = normalizeAgentToolParameters({
483
+ index,
484
+ value: typed.parameters
485
+ });
486
+ } else {
487
+ throw new Error(
488
+ `configure() output.tools[${index}] must be a tool name or { name, parameters }`
489
+ );
490
+ }
491
+ if (typeof name !== "string" || name.length === 0) {
492
+ throw new Error(
493
+ `configure() output.tools[${index}] must ${typeof entry === "string" ? "be" : "name"} a non-empty string`
494
+ );
495
+ }
496
+ if (seen.has(name)) {
497
+ throw new Error(
498
+ `configure() output.tools contains duplicate id ${JSON.stringify(name)}`
499
+ );
500
+ }
501
+ if (!args.knownIds.has(name)) {
502
+ throw new Error(
503
+ `configure() selected unknown tool id ${JSON.stringify(name)} owned by plugin ${JSON.stringify(args.pluginId)}`
504
+ );
505
+ }
506
+ seen.add(name);
507
+ toolIds.push(name);
508
+ if (parameters !== null) parameterOverrides.set(name, parameters);
509
+ }
510
+ return { toolIds, parameterOverrides };
511
+ }
512
+ function normalizeAgentToolParameters(args) {
513
+ const { index, value } = args;
514
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
515
+ throw new Error(
516
+ `configure() output.tools[${index}].parameters must be a JSON-schema object`
517
+ );
518
+ }
519
+ let serialized;
520
+ try {
521
+ serialized = JSON.stringify(value);
522
+ } catch {
523
+ throw new Error(
524
+ `configure() output.tools[${index}].parameters is not JSON-serializable`
525
+ );
526
+ }
527
+ if (serialized === void 0) {
528
+ throw new Error(
529
+ `configure() output.tools[${index}].parameters is not JSON-serializable`
530
+ );
531
+ }
532
+ if (Buffer.byteLength(serialized, "utf8") > PLUGIN_AGENT_TOOL_PARAMETERS_MAX_BYTES) {
533
+ throw new Error(
534
+ `configure() output.tools[${index}].parameters exceeds the ${PLUGIN_AGENT_TOOL_PARAMETERS_MAX_BYTES}-byte limit`
535
+ );
536
+ }
537
+ const parameters = JSON.parse(serialized);
538
+ if (parameters.type !== "object") {
539
+ throw new Error(
540
+ `configure() output.tools[${index}].parameters must have root type "object"`
541
+ );
542
+ }
543
+ return parameters;
544
+ }
545
+ function normalizeAgentConfigurationIds(args) {
546
+ if (!Array.isArray(args.value)) {
547
+ throw new Error(`configure() output.${args.field} must be an array`);
548
+ }
549
+ if (args.value.length > PLUGIN_AGENT_SELECTION_MAX_IDS) {
550
+ throw new Error(
551
+ `configure() output.${args.field} exceeds the ${PLUGIN_AGENT_SELECTION_MAX_IDS}-id limit`
552
+ );
553
+ }
554
+ const selected = [];
555
+ const seen = /* @__PURE__ */ new Set();
556
+ for (let index = 0; index < args.value.length; index += 1) {
557
+ const id = args.value[index];
558
+ if (typeof id !== "string" || id.length === 0) {
559
+ throw new Error(
560
+ `configure() output.${args.field}[${index}] must be a non-empty string`
561
+ );
562
+ }
563
+ if (seen.has(id)) {
564
+ throw new Error(
565
+ `configure() output.${args.field} contains duplicate id ${JSON.stringify(id)}`
566
+ );
567
+ }
568
+ if (!args.knownIds.has(id)) {
569
+ throw new Error(
570
+ `configure() selected unknown skill id ${JSON.stringify(id)} owned by plugin ${JSON.stringify(args.pluginId)}`
571
+ );
572
+ }
573
+ seen.add(id);
574
+ selected.push(id);
575
+ }
576
+ return selected;
577
+ }
578
+ function normalizeAgentConfiguration(args) {
579
+ if (typeof args.value !== "object" || args.value === null || Array.isArray(args.value)) {
580
+ throw new Error(
581
+ "configure() must return { tools: string[], skills: string[], instructions?: string }"
582
+ );
583
+ }
584
+ const output = args.value;
585
+ const unknownKeys = Object.keys(output).filter((key) => !["tools", "skills", "instructions"].includes(key)).sort();
586
+ if (unknownKeys.length > 0) {
587
+ throw new Error(
588
+ `configure() output contains unknown field${unknownKeys.length === 1 ? "" : "s"}: ${unknownKeys.join(", ")}`
589
+ );
590
+ }
591
+ if (output.instructions !== void 0 && typeof output.instructions !== "string") {
592
+ throw new Error("configure() output.instructions must be a string");
593
+ }
594
+ const toolSelections = normalizeAgentToolSelections({
595
+ knownIds: args.knownToolIds,
596
+ pluginId: args.pluginId,
597
+ value: output.tools
598
+ });
599
+ return {
600
+ toolIds: toolSelections.toolIds,
601
+ toolParameterOverrides: toolSelections.parameterOverrides,
602
+ skillIds: normalizeAgentConfigurationIds({
603
+ field: "skills",
604
+ knownIds: args.knownSkillIds,
605
+ pluginId: args.pluginId,
606
+ value: output.skills
607
+ }),
608
+ instructions: typeof output.instructions === "string" && output.instructions.trim().length > 0 ? output.instructions.slice(
609
+ 0,
610
+ PLUGIN_AGENT_DYNAMIC_INSTRUCTIONS_MAX_CHARS
611
+ ) : null
612
+ };
613
+ }
614
+ var fakeHostDisposers = /* @__PURE__ */ new WeakMap();
615
+ function createFakePluginHost(options = {}) {
616
+ return createFakePluginHostInternal(options);
617
+ }
618
+ function createFakePluginHostInternal(options, sharedState) {
619
+ const persistentState = sharedState ?? {
620
+ kvRows: /* @__PURE__ */ new Map(),
621
+ storageRoot: mkdtempSync(join(tmpdir(), "bb-fake-plugin-host-")),
622
+ storedSettings: new Map(
623
+ Object.entries(options.settings ?? {})
624
+ )
625
+ };
626
+ const pluginId = options.pluginId ?? "test-plugin";
627
+ const agentSkillIds = [...options.agentSkillIds ?? []];
628
+ if (new Set(agentSkillIds).size !== agentSkillIds.length) {
629
+ throw new Error("agentSkillIds must not contain duplicates");
630
+ }
631
+ let invalidated = false;
632
+ let disposed = false;
633
+ function assertLive() {
634
+ if (invalidated) throw new PluginContextStaleError(pluginId);
635
+ }
636
+ const logEntries = [];
637
+ function emitLog(level, message) {
638
+ logEntries.push({ level, message });
639
+ }
640
+ const log = {
641
+ debug: (message) => emitLog("debug", message),
642
+ info: (message) => emitLog("info", message),
643
+ warn: (message) => emitLog("warn", message),
644
+ error: (message) => emitLog("error", message)
645
+ };
646
+ const kvRows = persistentState.kvRows;
647
+ const kv = {
648
+ async get(key) {
649
+ assertLive();
650
+ const raw = kvRows.get(key);
651
+ if (raw === void 0) return void 0;
652
+ return JSON.parse(raw);
653
+ },
654
+ async set(key, value) {
655
+ assertLive();
656
+ const json = JSON.stringify(value);
657
+ if (json === void 0) {
658
+ throw new Error(`kv value for "${key}" is not JSON-serializable`);
659
+ }
660
+ const bytes = Buffer.byteLength(json, "utf8");
661
+ if (bytes > KV_VALUE_MAX_BYTES) {
662
+ throw new Error(
663
+ `kv value for "${key}" is ${bytes} bytes; the limit is ${KV_VALUE_MAX_BYTES} (256KB). Store large data in storage.database() instead.`
664
+ );
665
+ }
666
+ kvRows.set(key, json);
667
+ },
668
+ async delete(key) {
669
+ assertLive();
670
+ kvRows.delete(key);
671
+ },
672
+ async list(prefix) {
673
+ assertLive();
674
+ return [...kvRows.keys()].filter((key) => prefix === void 0 || key.startsWith(prefix)).sort();
675
+ }
676
+ };
677
+ const storageRoot = persistentState.storageRoot;
678
+ let databaseHandle;
679
+ const storage = {
680
+ kv,
681
+ database() {
682
+ assertLive();
683
+ if (!databaseHandle) {
684
+ databaseHandle = new Database(join(storageRoot, "data.db"));
685
+ databaseHandle.pragma("busy_timeout = 5000");
686
+ }
687
+ return databaseHandle;
688
+ },
689
+ migrate(database, statements) {
690
+ assertLive();
691
+ database.exec(
692
+ "CREATE TABLE IF NOT EXISTS _bb_migrations (id INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)"
693
+ );
694
+ const applied = new Set(
695
+ database.prepare("SELECT id FROM _bb_migrations").all().map((row) => row.id)
696
+ );
697
+ const record = database.prepare(
698
+ "INSERT INTO _bb_migrations (id, applied_at) VALUES (?, ?)"
699
+ );
700
+ database.transaction(() => {
701
+ statements.forEach((statement, index) => {
702
+ if (applied.has(index)) return;
703
+ database.exec(statement);
704
+ record.run(index, Date.now());
705
+ });
706
+ })();
707
+ }
708
+ };
709
+ const settingsDescriptors = {};
710
+ const settingsListeners = [];
711
+ const storedSettings = persistentState.storedSettings;
712
+ const settings = {
713
+ define(descriptors) {
714
+ assertLive();
715
+ registerSettingDescriptors(
716
+ settingsDescriptors,
717
+ descriptors
718
+ );
719
+ return {
720
+ async get() {
721
+ assertLive();
722
+ return readSettingsValues(
723
+ settingsDescriptors,
724
+ storedSettings
725
+ );
726
+ },
727
+ onChange(listener) {
728
+ assertLive();
729
+ settingsListeners.push(
730
+ listener
731
+ );
732
+ }
733
+ };
734
+ }
735
+ };
736
+ const httpRoutes = [];
737
+ const http = {
738
+ route(method, path, handler, opts) {
739
+ assertLive();
740
+ const normalizedMethod = String(method).toUpperCase();
741
+ if (!PLUGIN_HTTP_METHODS.has(normalizedMethod)) {
742
+ throw new Error(
743
+ `invalid http method "${String(method)}" \u2014 use one of: ${[...PLUGIN_HTTP_METHODS].join(", ")}`
744
+ );
745
+ }
746
+ if (typeof path !== "string" || !path.startsWith("/")) {
747
+ throw new Error(
748
+ `http route path must be a string starting with "/", got ${JSON.stringify(path)}`
749
+ );
750
+ }
751
+ if (typeof handler !== "function") {
752
+ throw new Error(
753
+ `http route handler for ${normalizedMethod} ${path} must be a function`
754
+ );
755
+ }
756
+ const auth = opts?.auth ?? "local";
757
+ if (auth !== "local" && auth !== "token" && auth !== "none") {
758
+ throw new Error(
759
+ `invalid auth mode "${String(auth)}" for ${normalizedMethod} ${path} \u2014 use "local", "token", or "none"`
760
+ );
761
+ }
762
+ if (httpRoutes.some(
763
+ (route) => route.method === normalizedMethod && route.path === path
764
+ )) {
765
+ throw new Error(
766
+ `http route ${normalizedMethod} ${path} is already registered`
767
+ );
768
+ }
769
+ httpRoutes.push({ method: normalizedMethod, path, auth, handler });
770
+ }
771
+ };
772
+ const rpcHandlers = /* @__PURE__ */ new Map();
773
+ const rpc = {
774
+ register(contract, handlers) {
775
+ assertLive();
776
+ if (typeof contract !== "object" || contract === null || Array.isArray(contract)) {
777
+ throw new Error("rpc.register contract must be an object");
778
+ }
779
+ if (typeof handlers !== "object" || handlers === null || Array.isArray(handlers)) {
780
+ throw new Error("rpc.register handlers must be an object");
781
+ }
782
+ const pending = [];
783
+ const contractEntries = Object.entries(contract);
784
+ const contractNames = new Set(contractEntries.map(([name]) => name));
785
+ for (const extraName of Object.keys(handlers)) {
786
+ if (!contractNames.has(extraName)) {
787
+ throw new Error(
788
+ `rpc handler "${extraName}" has no matching contract method`
789
+ );
790
+ }
791
+ }
792
+ for (const [name, contractValue] of contractEntries) {
793
+ if (!RPC_METHOD_PATTERN.test(name)) {
794
+ throw new Error(
795
+ `invalid rpc method name "${name}" \u2014 use letters, digits, "-" and "_"`
796
+ );
797
+ }
798
+ const methodContract = readRpcMethodContract(name, contractValue);
799
+ const handler = Reflect.get(handlers, name);
800
+ if (typeof handler !== "function") {
801
+ throw new Error(
802
+ `rpc method "${name}" must provide a handler function`
803
+ );
804
+ }
805
+ if (rpcHandlers.has(name)) {
806
+ throw new Error(`rpc method "${name}" is already registered`);
807
+ }
808
+ pending.push([
809
+ name,
810
+ {
811
+ inputSchema: methodContract.input,
812
+ outputSchema: methodContract.output,
813
+ handler
814
+ }
815
+ ]);
816
+ }
817
+ for (const [name, record] of pending) {
818
+ rpcHandlers.set(name, record);
819
+ }
820
+ }
821
+ };
822
+ const realtimeSignals = [];
823
+ const realtime = {
824
+ publish(channel, payload) {
825
+ assertLive();
826
+ if (typeof channel !== "string" || channel.length === 0) {
827
+ throw new Error("realtime channel must be a non-empty string");
828
+ }
829
+ const normalized = payload === void 0 ? null : jsonRoundTrip(
830
+ payload,
831
+ `realtime payload for channel "${channel}"`
832
+ ) ?? null;
833
+ realtimeSignals.push({ channel, payload: normalized });
834
+ }
835
+ };
836
+ const services = [];
837
+ const schedules = [];
838
+ const background = {
839
+ service(name, service) {
840
+ assertLive();
841
+ if (typeof name !== "string" || !BACKGROUND_NAME_PATTERN.test(name)) {
842
+ throw new Error(
843
+ `invalid service name ${JSON.stringify(name)} \u2014 use letters, digits, "-" and "_"`
844
+ );
845
+ }
846
+ if (services.some((record) => record.name === name)) {
847
+ throw new Error(`background service "${name}" is already registered`);
848
+ }
849
+ if (typeof service?.start !== "function") {
850
+ throw new Error(
851
+ `background service "${name}" must provide a start(signal) function`
852
+ );
853
+ }
854
+ services.push({ name, start: service.start.bind(service) });
855
+ },
856
+ schedule(name, cron, fn) {
857
+ assertLive();
858
+ if (typeof name !== "string" || !BACKGROUND_NAME_PATTERN.test(name)) {
859
+ throw new Error(
860
+ `invalid schedule name ${JSON.stringify(name)} \u2014 use letters, digits, "-" and "_"`
861
+ );
862
+ }
863
+ if (schedules.some((record) => record.name === name)) {
864
+ throw new Error(`schedule "${name}" is already registered`);
865
+ }
866
+ try {
867
+ CronExpressionParser.parse(String(cron));
868
+ } catch (error) {
869
+ throw new Error(
870
+ `invalid cron ${JSON.stringify(cron)} for schedule "${name}": ${errorMessage(error)}`
871
+ );
872
+ }
873
+ if (typeof fn !== "function") {
874
+ throw new Error(`schedule "${name}" must provide a function`);
875
+ }
876
+ schedules.push({ name, cron: String(cron), fn });
877
+ }
878
+ };
879
+ const cliRecord = {
880
+ registration: null
881
+ };
882
+ const cli = {
883
+ register(registration) {
884
+ assertLive();
885
+ if (cliRecord.registration !== null) {
886
+ throw new Error("cli command is already registered");
887
+ }
888
+ const name = registration?.name;
889
+ if (typeof name !== "string" || !CLI_COMMAND_NAME_PATTERN.test(name)) {
890
+ throw new Error(
891
+ `invalid cli command name ${JSON.stringify(name)} \u2014 use lowercase letters, digits, and "-"`
892
+ );
893
+ }
894
+ if (RESERVED_BB_CLI_COMMANDS.includes(name)) {
895
+ throw new Error(
896
+ `cli command name "${name}" is reserved by the bb CLI \u2014 pick another name`
897
+ );
898
+ }
899
+ if (typeof registration.summary !== "string" || registration.summary.trim().length === 0) {
900
+ throw new Error(`cli command "${name}" must provide a summary`);
901
+ }
902
+ const commands = registration.commands ?? [];
903
+ if (!Array.isArray(commands)) {
904
+ throw new Error(`cli command "${name}" commands must be an array`);
905
+ }
906
+ const validatedCommands = commands.map((command, index) => {
907
+ if (typeof command?.name !== "string" || !CLI_COMMAND_NAME_PATTERN.test(command.name) || typeof command.summary !== "string" || typeof command.usage !== "string") {
908
+ throw new Error(
909
+ `cli command "${name}" commands[${index}] must be { name: [a-z0-9-]+, summary, usage }`
910
+ );
911
+ }
912
+ return {
913
+ name: command.name,
914
+ summary: command.summary,
915
+ usage: command.usage
916
+ };
917
+ });
918
+ if (typeof registration.run !== "function") {
919
+ throw new Error(
920
+ `cli command "${name}" must provide a run(argv, ctx) function`
921
+ );
922
+ }
923
+ cliRecord.registration = {
924
+ name,
925
+ summary: registration.summary,
926
+ commands: validatedCommands,
927
+ run: registration.run.bind(registration)
928
+ };
929
+ }
930
+ };
931
+ const agentTools = [];
932
+ let agentConfigurationProvider = null;
933
+ let instructionProvider = null;
934
+ const agents = {
935
+ configure(provider) {
936
+ assertLive();
937
+ if (agentConfigurationProvider !== null) {
938
+ throw new Error("agent configuration is already registered");
939
+ }
940
+ if (typeof provider !== "function") {
941
+ throw new Error(
942
+ "configure requires a provider function (context) => ({ tools, skills, instructions? })"
943
+ );
944
+ }
945
+ agentConfigurationProvider = provider;
946
+ },
947
+ contributeInstructions(provider) {
948
+ assertLive();
949
+ if (instructionProvider !== null) {
950
+ throw new Error("agent instructions are already registered");
951
+ }
952
+ if (typeof provider !== "function") {
953
+ throw new Error(
954
+ "contributeInstructions requires a provider function (ctx) => string | null"
955
+ );
956
+ }
957
+ instructionProvider = provider;
958
+ },
959
+ registerTool(tool) {
960
+ assertLive();
961
+ const name = tool?.name;
962
+ if (typeof name !== "string" || !AGENT_TOOL_NAME_PATTERN.test(name)) {
963
+ throw new Error(
964
+ `invalid tool name ${JSON.stringify(name)} \u2014 use letters, digits, "-" and "_"`
965
+ );
966
+ }
967
+ if (RESERVED_AGENT_TOOL_NAMES.includes(name)) {
968
+ throw new Error(
969
+ `tool name "${name}" is a built-in bb tool \u2014 pick another name`
970
+ );
971
+ }
972
+ if (typeof tool.description !== "string" || tool.description.trim().length === 0) {
973
+ throw new Error(`tool "${name}" must provide a description`);
974
+ }
975
+ if (tool.instructions !== void 0 && typeof tool.instructions !== "string") {
976
+ throw new Error(`tool "${name}" instructions must be a string`);
977
+ }
978
+ if (typeof tool.instructions === "string" && tool.instructions.length > PLUGIN_AGENT_STATIC_INSTRUCTIONS_MAX_CHARS) {
979
+ throw new Error(
980
+ `tool "${name}" instructions exceed the ${PLUGIN_AGENT_STATIC_INSTRUCTIONS_MAX_CHARS}-character limit`
981
+ );
982
+ }
983
+ const experimentalStatusLabels = tool.experimental_statusLabels;
984
+ if (experimentalStatusLabels !== void 0 && (typeof experimentalStatusLabels !== "object" || experimentalStatusLabels === null || typeof experimentalStatusLabels.pending !== "string" || typeof experimentalStatusLabels.completed !== "string" || experimentalStatusLabels.pending.trim().length === 0 || experimentalStatusLabels.completed.trim().length === 0)) {
985
+ throw new Error(
986
+ `tool "${name}" experimental_statusLabels must provide non-empty pending and completed strings`
987
+ );
988
+ }
989
+ if (experimentalStatusLabels !== void 0 && (experimentalStatusLabels.pending.length > PLUGIN_AGENT_STATUS_LABEL_MAX_CHARS || experimentalStatusLabels.completed.length > PLUGIN_AGENT_STATUS_LABEL_MAX_CHARS)) {
990
+ throw new Error(
991
+ `tool "${name}" experimental_statusLabels exceed the ${PLUGIN_AGENT_STATUS_LABEL_MAX_CHARS}-character limit`
992
+ );
993
+ }
994
+ if (typeof tool.execute !== "function") {
995
+ throw new Error(
996
+ `tool "${name}" must provide an execute(params, ctx) function`
997
+ );
998
+ }
999
+ const parameters = tool.parameters;
1000
+ let inputSchema;
1001
+ let parse;
1002
+ if (isZodSchemaLike(parameters)) {
1003
+ try {
1004
+ inputSchema = z2.toJSONSchema(parameters, {
1005
+ io: "input"
1006
+ });
1007
+ } catch (error) {
1008
+ throw new Error(
1009
+ `tool "${name}" parameters look like a zod schema but could not be converted to JSON Schema (${errorMessage(error)}) \u2014 use zod 4, or pass a plain JSON-schema object`
1010
+ );
1011
+ }
1012
+ parse = (input) => {
1013
+ const result = parameters.safeParse(input);
1014
+ if (result.success) return { ok: true, value: result.data };
1015
+ return { ok: false, error: summarizeParseIssues(result.error) };
1016
+ };
1017
+ } else if (typeof parameters === "object" && parameters !== null && !Array.isArray(parameters)) {
1018
+ try {
1019
+ inputSchema = JSON.parse(JSON.stringify(parameters));
1020
+ } catch {
1021
+ throw new Error(
1022
+ `tool "${name}" parameters JSON schema is not JSON-serializable`
1023
+ );
1024
+ }
1025
+ parse = (input) => ({ ok: true, value: input });
1026
+ } else {
1027
+ throw new Error(
1028
+ `tool "${name}" parameters must be a zod schema or a JSON-schema object`
1029
+ );
1030
+ }
1031
+ const record = {
1032
+ name,
1033
+ description: tool.description,
1034
+ experimentalStatusLabels: experimentalStatusLabels === void 0 ? null : {
1035
+ pending: experimentalStatusLabels.pending,
1036
+ completed: experimentalStatusLabels.completed
1037
+ },
1038
+ instructions: tool.instructions !== void 0 && tool.instructions.trim().length > 0 ? tool.instructions : null,
1039
+ inputSchema,
1040
+ parse,
1041
+ execute: tool.execute.bind(tool)
1042
+ };
1043
+ if (agentTools.some((existing) => existing.name === name)) {
1044
+ throw new Error(`tool "${name}" is already registered`);
1045
+ }
1046
+ agentTools.push(record);
1047
+ }
1048
+ };
1049
+ const mentionProviders = [];
1050
+ const ui = {
1051
+ requestInput,
1052
+ registerMentionProvider(provider) {
1053
+ assertLive();
1054
+ const id = provider?.id;
1055
+ if (typeof id !== "string" || !MENTION_PROVIDER_ID_PATTERN.test(id)) {
1056
+ throw new Error(
1057
+ `invalid mention provider id ${JSON.stringify(id)} \u2014 use letters, digits, "-" and "_"`
1058
+ );
1059
+ }
1060
+ if (mentionProviders.some((record) => record.id === id)) {
1061
+ throw new Error(`mention provider "${id}" is already registered`);
1062
+ }
1063
+ if (typeof provider.label !== "string" || provider.label.trim().length === 0) {
1064
+ throw new Error(`mention provider "${id}" must provide a label`);
1065
+ }
1066
+ if (typeof provider.search !== "function") {
1067
+ throw new Error(
1068
+ `mention provider "${id}" must provide a search({ query, projectId, threadId }) function`
1069
+ );
1070
+ }
1071
+ if (typeof provider.resolve !== "function") {
1072
+ throw new Error(
1073
+ `mention provider "${id}" must provide a resolve(itemId) function`
1074
+ );
1075
+ }
1076
+ mentionProviders.push({
1077
+ id,
1078
+ label: provider.label.trim(),
1079
+ triggers: normalizeMentionProviderTriggers(id, provider.triggers),
1080
+ search: provider.search.bind(provider),
1081
+ resolve: provider.resolve.bind(provider)
1082
+ });
1083
+ }
1084
+ };
1085
+ const needsConfigurationMessages = [];
1086
+ const status = {
1087
+ needsConfiguration(message) {
1088
+ assertLive();
1089
+ needsConfigurationMessages.push(
1090
+ typeof message === "string" && message.length > 0 ? message : "needs configuration"
1091
+ );
1092
+ }
1093
+ };
1094
+ const loopbackBaseUrl = options.loopbackBaseUrl ?? "http://127.0.0.1:38886";
1095
+ const server = {
1096
+ get loopbackBaseUrl() {
1097
+ assertLive();
1098
+ return loopbackBaseUrl;
1099
+ }
1100
+ };
1101
+ const { sdk, harness: sdkHarness } = createFakeSdk({
1102
+ pluginId,
1103
+ overrides: options.sdk
1104
+ });
1105
+ const threadEventHandlers = {
1106
+ "thread.created": [],
1107
+ "thread.active": [],
1108
+ "thread.idle": [],
1109
+ "thread.failed": [],
1110
+ "thread.archived": [],
1111
+ "thread.deleted": []
1112
+ };
1113
+ const disposeHooks = [];
1114
+ const serviceControllers = [];
1115
+ let nextInteractionId = 1;
1116
+ const pendingInteractions = /* @__PURE__ */ new Map();
1117
+ function requestInput(request, requestOptions) {
1118
+ assertLive();
1119
+ if (!request || typeof request !== "object") {
1120
+ throw new Error("ui.requestInput requires an options object");
1121
+ }
1122
+ if (typeof request.threadId !== "string" || request.threadId.length === 0) {
1123
+ throw new Error("ui.requestInput threadId must be a non-empty string");
1124
+ }
1125
+ if (typeof request.rendererId !== "string" || !/^[a-zA-Z0-9_-]+$/.test(request.rendererId)) {
1126
+ throw new Error(
1127
+ "ui.requestInput rendererId must use letters, digits, '-' or '_'"
1128
+ );
1129
+ }
1130
+ if (typeof request.title !== "string" || request.title.trim().length === 0 || request.title.trim().length > PLUGIN_INTERACTION_MAX_TITLE_LENGTH) {
1131
+ throw new Error(
1132
+ `ui.requestInput title must be 1-${PLUGIN_INTERACTION_MAX_TITLE_LENGTH} characters`
1133
+ );
1134
+ }
1135
+ let payload;
1136
+ try {
1137
+ const json = JSON.stringify(request.payload);
1138
+ if (json === void 0) throw new Error();
1139
+ if (Buffer.byteLength(json, "utf8") > 64 * 1024) {
1140
+ throw new Error("ui.requestInput payload exceeds 64 KiB");
1141
+ }
1142
+ payload = JSON.parse(json);
1143
+ } catch (error) {
1144
+ if (error instanceof Error && error.message.includes("64 KiB")) {
1145
+ throw error;
1146
+ }
1147
+ throw new Error("ui.requestInput payload must be JSON-serializable");
1148
+ }
1149
+ const timeoutMs = request.timeoutMs ?? 10 * 60 * 1e3;
1150
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60 * 60 * 1e3) {
1151
+ throw new Error(
1152
+ "ui.requestInput timeoutMs must be between 1 and 3600000"
1153
+ );
1154
+ }
1155
+ const normalizedRequest = {
1156
+ ...request,
1157
+ title: request.title.trim(),
1158
+ payload,
1159
+ timeoutMs
1160
+ };
1161
+ const id = `fake-interaction-${nextInteractionId++}`;
1162
+ return new Promise((resolve) => {
1163
+ const settleAborted = () => {
1164
+ const pending = pendingInteractions.get(id);
1165
+ if (!pending) return;
1166
+ clearTimeout(pending.timer);
1167
+ pendingInteractions.delete(id);
1168
+ resolve({ outcome: "cancelled", reason: "request-aborted" });
1169
+ };
1170
+ requestOptions?.signal?.addEventListener("abort", settleAborted, {
1171
+ once: true
1172
+ });
1173
+ const timer = setTimeout(() => {
1174
+ pendingInteractions.delete(id);
1175
+ resolve({ outcome: "cancelled", reason: "timeout" });
1176
+ }, timeoutMs);
1177
+ pendingInteractions.set(id, {
1178
+ request: normalizedRequest,
1179
+ resolve,
1180
+ timer
1181
+ });
1182
+ });
1183
+ }
1184
+ const sharedPortDeclarations = [];
1185
+ const hosts = {
1186
+ async ensureSharedPortTunnel(hostId) {
1187
+ assertLive();
1188
+ if (hostId.trim().length === 0) {
1189
+ throw new Error("shared-port hostId must be non-empty");
1190
+ }
1191
+ const identity = options.sharedPortTunnelIdentities?.[hostId];
1192
+ if (!identity) {
1193
+ throw new Error(`host ${hostId} has no shared-port tunnel identity`);
1194
+ }
1195
+ return { ...identity };
1196
+ },
1197
+ declareSharedPorts(hostId, ports) {
1198
+ assertLive();
1199
+ if (hostId.trim().length === 0) {
1200
+ throw new Error("shared-port hostId must be non-empty");
1201
+ }
1202
+ const normalizedPorts = [...new Set(ports)].sort((a, b) => a - b);
1203
+ for (const port of normalizedPorts) {
1204
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1205
+ throw new Error(
1206
+ `shared port ${String(port)} must be an integer between 1 and 65535`
1207
+ );
1208
+ }
1209
+ }
1210
+ const replacement = {
1211
+ hostId,
1212
+ ports: normalizedPorts
1213
+ };
1214
+ const existingIndex = sharedPortDeclarations.findIndex(
1215
+ (declaration) => declaration.hostId === hostId
1216
+ );
1217
+ if (existingIndex === -1) {
1218
+ sharedPortDeclarations.push(replacement);
1219
+ } else {
1220
+ sharedPortDeclarations[existingIndex] = replacement;
1221
+ }
1222
+ }
1223
+ };
1224
+ disposeHooks.push(() => {
1225
+ sharedPortDeclarations.length = 0;
1226
+ });
1227
+ const events = {
1228
+ on(event, handler) {
1229
+ assertLive();
1230
+ const handlers = threadEventHandlers[event];
1231
+ if (handlers === void 0) {
1232
+ throw new Error(
1233
+ `unknown event "${String(event)}" \u2014 supported events: ${Object.keys(
1234
+ threadEventHandlers
1235
+ ).join(", ")}`
1236
+ );
1237
+ }
1238
+ handlers.push(handler);
1239
+ }
1240
+ };
1241
+ const bb = {
1242
+ pluginId,
1243
+ log,
1244
+ settings,
1245
+ storage,
1246
+ http,
1247
+ rpc,
1248
+ realtime,
1249
+ background,
1250
+ cli,
1251
+ agents,
1252
+ ui,
1253
+ events,
1254
+ status,
1255
+ server,
1256
+ hosts,
1257
+ get sdk() {
1258
+ assertLive();
1259
+ return sdk;
1260
+ },
1261
+ onDispose(hook) {
1262
+ assertLive();
1263
+ disposeHooks.push(hook);
1264
+ }
1265
+ };
1266
+ async function disposeHost(cleanupStorage) {
1267
+ if (disposed) return;
1268
+ disposed = true;
1269
+ for (const [id, pending] of pendingInteractions) {
1270
+ clearTimeout(pending.timer);
1271
+ pendingInteractions.delete(id);
1272
+ pending.resolve({ outcome: "cancelled", reason: "plugin-disposed" });
1273
+ }
1274
+ for (const controller of serviceControllers) controller.abort();
1275
+ for (const hook of [...disposeHooks].reverse()) {
1276
+ try {
1277
+ await hook();
1278
+ } catch (error) {
1279
+ emitLog("warn", `dispose hook failed: ${errorMessage(error)}`);
1280
+ }
1281
+ }
1282
+ if (databaseHandle) {
1283
+ try {
1284
+ databaseHandle.close();
1285
+ } catch (error) {
1286
+ emitLog("warn", `database close failed: ${errorMessage(error)}`);
1287
+ }
1288
+ }
1289
+ if (cleanupStorage) {
1290
+ rmSync(storageRoot, { recursive: true, force: true });
1291
+ }
1292
+ invalidated = true;
1293
+ }
1294
+ const harness = {
1295
+ get behavior() {
1296
+ return this;
1297
+ },
1298
+ get inspection() {
1299
+ return this;
1300
+ },
1301
+ get lifecycle() {
1302
+ return this;
1303
+ },
1304
+ pluginId,
1305
+ logEntries,
1306
+ realtimeSignals,
1307
+ needsConfigurationMessages,
1308
+ sharedPortDeclarations,
1309
+ sdk: sdkHarness,
1310
+ registrations: {
1311
+ settingsDescriptors,
1312
+ httpRoutes,
1313
+ get rpcMethods() {
1314
+ return [...rpcHandlers.keys()];
1315
+ },
1316
+ services,
1317
+ schedules,
1318
+ get cli() {
1319
+ return cliRecord.registration;
1320
+ },
1321
+ agentTools,
1322
+ get agentConfigurationProvider() {
1323
+ return agentConfigurationProvider;
1324
+ },
1325
+ get instructionProvider() {
1326
+ return instructionProvider;
1327
+ },
1328
+ get threadEventHandlers() {
1329
+ return {
1330
+ "thread.created": threadEventHandlers["thread.created"].length,
1331
+ "thread.active": threadEventHandlers["thread.active"].length,
1332
+ "thread.idle": threadEventHandlers["thread.idle"].length,
1333
+ "thread.failed": threadEventHandlers["thread.failed"].length,
1334
+ "thread.archived": threadEventHandlers["thread.archived"].length,
1335
+ "thread.deleted": threadEventHandlers["thread.deleted"].length
1336
+ };
1337
+ },
1338
+ mentionProviders
1339
+ },
1340
+ get pendingInteractions() {
1341
+ return [...pendingInteractions].map(([id, pending]) => ({
1342
+ id,
1343
+ ...pending.request
1344
+ }));
1345
+ },
1346
+ submitInteraction(id, value) {
1347
+ const pending = pendingInteractions.get(id);
1348
+ if (!pending) throw new Error(`no pending interaction "${id}"`);
1349
+ clearTimeout(pending.timer);
1350
+ pendingInteractions.delete(id);
1351
+ pending.resolve({ outcome: "submitted", value });
1352
+ },
1353
+ cancelInteraction(id) {
1354
+ const pending = pendingInteractions.get(id);
1355
+ if (!pending) throw new Error(`no pending interaction "${id}"`);
1356
+ clearTimeout(pending.timer);
1357
+ pendingInteractions.delete(id);
1358
+ pending.resolve({ outcome: "cancelled", reason: "user" });
1359
+ },
1360
+ async setSettings(values) {
1361
+ const errors = validateSettingsUpdate(settingsDescriptors, values);
1362
+ if (errors.length > 0) {
1363
+ throw new Error(errors.join("; "));
1364
+ }
1365
+ const prev = readSettingsValues(settingsDescriptors, storedSettings);
1366
+ for (const [key, value] of Object.entries(values)) {
1367
+ if (value === null) storedSettings.delete(key);
1368
+ else storedSettings.set(key, value);
1369
+ }
1370
+ const next = readSettingsValues(settingsDescriptors, storedSettings);
1371
+ if (JSON.stringify(next) === JSON.stringify(prev)) return;
1372
+ for (const listener of settingsListeners) {
1373
+ try {
1374
+ listener(next, prev);
1375
+ } catch (error) {
1376
+ emitLog(
1377
+ "warn",
1378
+ `settings onChange listener failed: ${errorMessage(error)}`
1379
+ );
1380
+ }
1381
+ }
1382
+ },
1383
+ async callRpc(method, input) {
1384
+ const record = rpcHandlers.get(method);
1385
+ if (!record) {
1386
+ return throwRpcError({
1387
+ code: "unknown_method",
1388
+ message: `plugin "${pluginId}" has no rpc method "${method}"`
1389
+ });
1390
+ }
1391
+ const parsedInput = input === void 0 ? null : jsonRoundTrip(input, `rpc "${method}" input`);
1392
+ const validatedInput = await validateRpcValue(
1393
+ record.inputSchema,
1394
+ parsedInput,
1395
+ "input"
1396
+ );
1397
+ let result;
1398
+ try {
1399
+ result = await record.handler(validatedInput);
1400
+ } catch (error) {
1401
+ return throwRpcError({
1402
+ code: "handler_error",
1403
+ message: errorMessage(error)
1404
+ });
1405
+ }
1406
+ const validatedOutput = await validateRpcValue(
1407
+ record.outputSchema,
1408
+ result,
1409
+ "output"
1410
+ );
1411
+ return normalizeRpcJsonResult(validatedOutput);
1412
+ },
1413
+ async runCli(argv, ctx = {}) {
1414
+ const registration = cliRecord.registration;
1415
+ if (!registration) {
1416
+ throw new Error(`plugin "${pluginId}" registers no CLI command`);
1417
+ }
1418
+ try {
1419
+ const result = await registration.run(argv, ctx);
1420
+ if (typeof result?.exitCode !== "number") {
1421
+ throw new Error(
1422
+ "cli run() must return { exitCode: number, stdout?, stderr? }"
1423
+ );
1424
+ }
1425
+ return enforcePluginCliOutputLimit(
1426
+ {
1427
+ exitCode: result.exitCode,
1428
+ stdout: typeof result.stdout === "string" ? result.stdout : "",
1429
+ stderr: typeof result.stderr === "string" ? result.stderr : ""
1430
+ },
1431
+ argv.includes("--json")
1432
+ );
1433
+ } catch (error) {
1434
+ return enforcePluginCliOutputLimit(
1435
+ {
1436
+ exitCode: 1,
1437
+ stdout: "",
1438
+ stderr: `bb ${registration.name} failed: ${errorMessage(error)}`
1439
+ },
1440
+ argv.includes("--json")
1441
+ );
1442
+ }
1443
+ },
1444
+ async fetchHttp(method, path, init) {
1445
+ const normalizedMethod = String(method).toUpperCase();
1446
+ const pathname = new URL(path, "http://plugin.test").pathname;
1447
+ const route = httpRoutes.find(
1448
+ (candidate) => candidate.method === normalizedMethod && candidate.path === pathname
1449
+ );
1450
+ if (!route) {
1451
+ throw new Error(
1452
+ `no http route ${normalizedMethod} ${pathname} is registered \u2014 registered: ${httpRoutes.map((r) => `${r.method} ${r.path}`).join(", ") || "(none)"}`
1453
+ );
1454
+ }
1455
+ const app = new Hono();
1456
+ app.on(route.method, route.path, async (context) => {
1457
+ try {
1458
+ const response = await route.handler(context);
1459
+ if (!(response instanceof Response)) {
1460
+ throw new Error("http route handler must return a Response");
1461
+ }
1462
+ return response;
1463
+ } catch (error) {
1464
+ const message = errorMessage(error);
1465
+ emitLog(
1466
+ "warn",
1467
+ `http ${route.method} ${route.path} failed: ${message}`
1468
+ );
1469
+ return context.json(
1470
+ { ok: false, error: `plugin route failed: ${message}` },
1471
+ 500
1472
+ );
1473
+ }
1474
+ });
1475
+ return app.request(path, { ...init, method: normalizedMethod });
1476
+ },
1477
+ runService(name) {
1478
+ const service = services.find((record) => record.name === name);
1479
+ if (!service) {
1480
+ throw new Error(`no background service "${name}" is registered`);
1481
+ }
1482
+ const controller = new AbortController();
1483
+ serviceControllers.push(controller);
1484
+ let started;
1485
+ try {
1486
+ started = Promise.resolve(service.start(controller.signal)).then(
1487
+ () => void 0
1488
+ );
1489
+ } catch (error) {
1490
+ started = Promise.reject(error);
1491
+ }
1492
+ const done = started.catch((error) => {
1493
+ if (isNeedsConfigurationError(error)) {
1494
+ needsConfigurationMessages.push(error.message);
1495
+ return void 0;
1496
+ }
1497
+ throw error;
1498
+ });
1499
+ return { controller, done };
1500
+ },
1501
+ async runSchedule(name) {
1502
+ const schedule = schedules.find((record) => record.name === name);
1503
+ if (!schedule) {
1504
+ throw new Error(`no schedule "${name}" is registered`);
1505
+ }
1506
+ await schedule.fn();
1507
+ },
1508
+ async emitThreadEvent(event, payload) {
1509
+ const errors = [];
1510
+ for (const handler of [...threadEventHandlers[event]]) {
1511
+ try {
1512
+ await handler(payload);
1513
+ } catch (error) {
1514
+ errors.push(error);
1515
+ emitLog("warn", `${event} handler failed: ${errorMessage(error)}`);
1516
+ }
1517
+ }
1518
+ return { errors };
1519
+ },
1520
+ async callAgentTool(name, input, ctx) {
1521
+ const record = agentTools.find((tool) => tool.name === name);
1522
+ if (!record) {
1523
+ throw new Error(`no agent tool "${name}" is registered`);
1524
+ }
1525
+ const parsed = record.parse(input);
1526
+ if (!parsed.ok) {
1527
+ throw new Error(
1528
+ `tool "${name}" arguments are invalid: ${parsed.error}`
1529
+ );
1530
+ }
1531
+ return record.execute(parsed.value, {
1532
+ threadId: ctx?.threadId ?? "thread-test",
1533
+ projectId: ctx?.projectId ?? "project-test",
1534
+ signal: ctx?.signal ?? new AbortController().signal
1535
+ });
1536
+ },
1537
+ async resolveAgentConfiguration(context) {
1538
+ if (agentConfigurationProvider === null) {
1539
+ return {
1540
+ tools: [...agentTools],
1541
+ skills: [...agentSkillIds],
1542
+ instructions: null
1543
+ };
1544
+ }
1545
+ try {
1546
+ const normalized = normalizeAgentConfiguration({
1547
+ knownSkillIds: new Set(agentSkillIds),
1548
+ knownToolIds: new Set(agentTools.map((tool) => tool.name)),
1549
+ pluginId,
1550
+ value: agentConfigurationProvider(context)
1551
+ });
1552
+ const selectedTools = new Set(normalized.toolIds);
1553
+ return {
1554
+ tools: agentTools.filter((tool) => selectedTools.has(tool.name)).map((tool) => {
1555
+ const parameters = normalized.toolParameterOverrides.get(
1556
+ tool.name
1557
+ );
1558
+ return parameters === void 0 ? tool : { ...tool, inputSchema: parameters };
1559
+ }),
1560
+ skills: normalized.skillIds,
1561
+ instructions: normalized.instructions
1562
+ };
1563
+ } catch (error) {
1564
+ emitLog("warn", `agent configure failed: ${errorMessage(error)}`);
1565
+ return { tools: [], skills: [], instructions: null };
1566
+ }
1567
+ },
1568
+ async reload(factory) {
1569
+ assertLive();
1570
+ const replacement = createFakePluginHostInternal(
1571
+ options,
1572
+ persistentState
1573
+ );
1574
+ try {
1575
+ await factory(replacement.bb);
1576
+ } catch (error) {
1577
+ await fakeHostDisposers.get(replacement.harness)?.(false);
1578
+ throw error;
1579
+ }
1580
+ await disposeHost(false);
1581
+ return replacement;
1582
+ },
1583
+ async dispose() {
1584
+ await disposeHost(true);
1585
+ }
1586
+ };
1587
+ fakeHostDisposers.set(harness, disposeHost);
1588
+ return { bb, harness };
1589
+ }
1590
+
1591
+ // src/testing/fixtures.ts
1592
+ function makeThreadResponse(overrides = {}) {
1593
+ return {
1594
+ id: "thread-1",
1595
+ projectId: "project-1",
1596
+ environmentId: null,
1597
+ providerId: "test-provider",
1598
+ title: null,
1599
+ titleFallback: null,
1600
+ sectionId: null,
1601
+ status: "idle",
1602
+ parentThreadId: null,
1603
+ sourceThreadId: null,
1604
+ originKind: null,
1605
+ originPluginId: null,
1606
+ visibility: "visible",
1607
+ archivedAt: null,
1608
+ pinnedAt: null,
1609
+ deletedAt: null,
1610
+ lastReadAt: null,
1611
+ latestAttentionAt: 0,
1612
+ createdAt: 0,
1613
+ updatedAt: 0,
1614
+ runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null },
1615
+ activeBackgroundAgentCount: 0,
1616
+ canSpawnChild: true,
1617
+ ...overrides
1618
+ };
1619
+ }
1620
+ export {
1621
+ PluginContextStaleError,
1622
+ createFakePluginHost,
1623
+ createFakeSdk,
1624
+ makeThreadResponse
1625
+ };