@capybara-hq/testing 0.0.0-stage → 0.1.0-rc.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.
Files changed (89) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +34 -2
  4. package/dist/containers.d.ts +102 -0
  5. package/dist/containers.js +314 -0
  6. package/dist/dual-replica.d.ts +121 -0
  7. package/dist/dual-replica.js +247 -0
  8. package/dist/extension-config.d.ts +9 -0
  9. package/dist/extension-config.js +16 -0
  10. package/dist/fake-channel.d.ts +162 -0
  11. package/dist/fake-channel.js +325 -0
  12. package/dist/fake-e2b.d.ts +104 -0
  13. package/dist/fake-e2b.js +500 -0
  14. package/dist/fake-git-server.d.ts +57 -0
  15. package/dist/fake-git-server.js +323 -0
  16. package/dist/fake-lark.d.ts +234 -0
  17. package/dist/fake-lark.js +837 -0
  18. package/dist/fake-marketplace.d.ts +25 -0
  19. package/dist/fake-marketplace.js +58 -0
  20. package/dist/fake-mcp-server.d.ts +134 -0
  21. package/dist/fake-mcp-server.js +308 -0
  22. package/dist/fake-model-server.d.ts +28 -0
  23. package/dist/fake-model-server.js +67 -0
  24. package/dist/fake-npm-registry.d.ts +59 -0
  25. package/dist/fake-npm-registry.js +174 -0
  26. package/dist/fake-oauth-server.d.ts +63 -0
  27. package/dist/fake-oauth-server.js +284 -0
  28. package/dist/fake-openapi-server.d.ts +50 -0
  29. package/dist/fake-openapi-server.js +278 -0
  30. package/dist/fake-slack.d.ts +258 -0
  31. package/dist/fake-slack.js +654 -0
  32. package/dist/fixtures.d.ts +47 -0
  33. package/dist/fixtures.js +90 -0
  34. package/dist/global-setup.d.ts +15 -0
  35. package/dist/global-setup.js +58 -0
  36. package/dist/index.d.ts +25 -0
  37. package/dist/index.js +25 -0
  38. package/dist/kernel-helpers.d.ts +125 -0
  39. package/dist/kernel-helpers.js +336 -0
  40. package/dist/media-samples.d.ts +10 -0
  41. package/dist/media-samples.js +76 -0
  42. package/dist/model-conformance.d.ts +61 -0
  43. package/dist/model-conformance.js +287 -0
  44. package/dist/replica-runner.d.ts +1 -0
  45. package/dist/replica-runner.js +59 -0
  46. package/dist/sse.d.ts +20 -0
  47. package/dist/sse.js +87 -0
  48. package/fixtures/extensions/fake-bootstrap-a/.capybara-plugin/plugin.json +7 -0
  49. package/fixtures/extensions/fake-bootstrap-a/package.json +12 -0
  50. package/fixtures/extensions/fake-bootstrap-a/src/index.ts +62 -0
  51. package/fixtures/extensions/fake-bootstrap-b/.capybara-plugin/plugin.json +7 -0
  52. package/fixtures/extensions/fake-bootstrap-b/migrations/0001_init.sql +10 -0
  53. package/fixtures/extensions/fake-bootstrap-b/package.json +12 -0
  54. package/fixtures/extensions/fake-bootstrap-b/src/index.ts +123 -0
  55. package/fixtures/extensions/fake-connector/.capybara-plugin/plugin.json +7 -0
  56. package/fixtures/extensions/fake-connector/connectors/fake-demo.json +12 -0
  57. package/fixtures/extensions/fake-connector/package.json +13 -0
  58. package/fixtures/extensions/fake-connector/src/index.ts +477 -0
  59. package/fixtures/extensions/fake-dep-provider/.capybara-plugin/plugin.json +7 -0
  60. package/fixtures/extensions/fake-dep-provider/package.json +12 -0
  61. package/fixtures/extensions/fake-dep-provider/src/index.ts +19 -0
  62. package/fixtures/extensions/fake-dep-requirer/.capybara-plugin/plugin.json +8 -0
  63. package/fixtures/extensions/fake-dep-requirer/package.json +12 -0
  64. package/fixtures/extensions/fake-dep-requirer/src/index.ts +21 -0
  65. package/fixtures/extensions/fake-embed/.capybara-plugin/plugin.json +7 -0
  66. package/fixtures/extensions/fake-embed/package.json +12 -0
  67. package/fixtures/extensions/fake-embed/src/index.ts +207 -0
  68. package/fixtures/extensions/fake-im/.capybara-plugin/plugin.json +7 -0
  69. package/fixtures/extensions/fake-im/package.json +12 -0
  70. package/fixtures/extensions/fake-im/src/channel.ts +224 -0
  71. package/fixtures/extensions/fake-im/src/directory.ts +25 -0
  72. package/fixtures/extensions/fake-im/src/identity.ts +66 -0
  73. package/fixtures/extensions/fake-im/src/index.ts +91 -0
  74. package/fixtures/extensions/fake-object-store/.capybara-plugin/plugin.json +7 -0
  75. package/fixtures/extensions/fake-object-store/package.json +15 -0
  76. package/fixtures/extensions/fake-object-store/src/index.ts +210 -0
  77. package/fixtures/extensions/fake-openapi/.capybara-plugin/plugin.json +7 -0
  78. package/fixtures/extensions/fake-openapi/package.json +12 -0
  79. package/fixtures/extensions/fake-openapi/src/index.ts +258 -0
  80. package/fixtures/extensions/fake-storage-user/.capybara-plugin/plugin.json +7 -0
  81. package/fixtures/extensions/fake-storage-user/package.json +12 -0
  82. package/fixtures/extensions/fake-storage-user/src/index.ts +58 -0
  83. package/fixtures/extensions/fake-tools-ui/.capybara-plugin/plugin.json +7 -0
  84. package/fixtures/extensions/fake-tools-ui/connectors/fake-notes.json +11 -0
  85. package/fixtures/extensions/fake-tools-ui/migrations/0001_init.sql +7 -0
  86. package/fixtures/extensions/fake-tools-ui/package.json +12 -0
  87. package/fixtures/extensions/fake-tools-ui/skills/fake-notes/SKILL.md +7 -0
  88. package/fixtures/extensions/fake-tools-ui/src/index.ts +140 -0
  89. package/package.json +66 -3
@@ -0,0 +1,91 @@
1
+ import {
2
+ defineExtension,
3
+ secret,
4
+ ToolError,
5
+ type ToolHandler,
6
+ type ToolSpec,
7
+ } from "@capybara-hq/plugin-sdk";
8
+ import { z } from "zod";
9
+ import { makeFakeImChannel } from "./channel.js";
10
+ import { FakeImOrgConfig, fakeImDirectory } from "./directory.js";
11
+ import { fakeImIdentitySource } from "./identity.js";
12
+
13
+ export { FakeImConnection, fakeImWebhook, makeFakeImChannel } from "./channel.js";
14
+ export { FakeImOrgConfig, fakeImDirectory } from "./directory.js";
15
+ export { fakeImIdentitySource } from "./identity.js";
16
+
17
+ /** 部署级配置:平台地址与登录用的 OAuth 客户端(clientSecret 走密钥存储)。 */
18
+ const Deployment = z.object({
19
+ platformUrl: z.string().url(),
20
+ tenant: z.string().min(1).default("t1"),
21
+ clientId: z.string().min(1),
22
+ clientSecret: secret(z.string().min(1)),
23
+ });
24
+ export type FakeImDeployment = z.infer<typeof Deployment>;
25
+
26
+ const PostInput = z.object({ text: z.string().min(1).max(500) });
27
+
28
+ /** 写工具:在平台上发一条公告。默认策略 ask(每次调用都要发起人批准)。 */
29
+ const POST_SPEC: ToolSpec = {
30
+ name: "post_announcement",
31
+ description:
32
+ "Post an announcement to the company-wide channel on the IM platform. Everyone in the " +
33
+ "company sees it, so only use it when the user explicitly asks to announce something.",
34
+ inputSchema: {
35
+ type: "object",
36
+ properties: {
37
+ text: { type: "string", minLength: 1, maxLength: 500, description: "Announcement text." },
38
+ },
39
+ required: ["text"],
40
+ additionalProperties: false,
41
+ },
42
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
43
+ defaultPolicy: "ask",
44
+ };
45
+
46
+ const postHandler =
47
+ (config: () => FakeImDeployment): ToolHandler =>
48
+ async (input, ctx) => {
49
+ const parsed = PostInput.safeParse(input);
50
+ if (!parsed.success) {
51
+ throw new ToolError("Invalid input: pass a non-empty text of at most 500 characters.");
52
+ }
53
+ const res = await fetch(`${config().platformUrl}/posts`, {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json", "x-pid": String(process.pid) },
56
+ body: JSON.stringify({ text: parsed.data.text, conversationId: ctx.conversationId }),
57
+ signal: ctx.signal,
58
+ });
59
+ if (res.status !== 200) {
60
+ await res.body?.cancel();
61
+ throw new ToolError("The IM platform rejected the announcement; retry later.", {
62
+ kind: "unavailable",
63
+ });
64
+ }
65
+ const { id } = (await res.json()) as { id: string };
66
+ return { content: [{ type: "text", text: `Announcement posted (${id}).` }] };
67
+ };
68
+
69
+ /**
70
+ * 假 IM 平台的完整测试插件(连 `@capybara-hq/testing` 的 startFakeChannelServer):
71
+ * `channel@1`(机器人)+ `identity-source@1`(登录)+ `directory@1`(通讯录),另带一个默认需要批准的写工具。
72
+ * 三处的身份源键都是 `fake-im:<租户>`,同一个人只对应一行身份。T15–T18 的真实渠道插件照这个形状写。
73
+ */
74
+ export default defineExtension({
75
+ name: "fake-im",
76
+ version: "0.1.0",
77
+ sdk: "^0.1.0-rc.0",
78
+ provides: ["channel@1", "identity-source@1", "directory@1"],
79
+ permissions: { tools: ["post_announcement"] },
80
+ config: { deployment: Deployment, org: FakeImOrgConfig },
81
+ setup(ctx) {
82
+ const config = () => Deployment.parse(ctx.config);
83
+ ctx.provide(
84
+ "channel@1",
85
+ makeFakeImChannel({ cards: true, edit: true, threads: true }, { webhook: true }),
86
+ );
87
+ ctx.provide("identity-source@1", fakeImIdentitySource);
88
+ ctx.provide("directory@1", fakeImDirectory);
89
+ ctx.tools.register(POST_SPEC, postHandler(config));
90
+ },
91
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "fake-object-store",
4
+ "version": "0.1.0",
5
+ "publisher": "Capybara",
6
+ "description": "Test fake: object-store@1 backed by the startTestS3 container"
7
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@capybara-hq/test-plugin-fake-object-store",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Test fake: object-store@1 backed by the startTestS3 container",
6
+ "license": "Apache-2.0",
7
+ "type": "module",
8
+ "dependencies": {
9
+ "@capybara-hq/plugin-sdk": "^0.1.0-rc.0",
10
+ "@aws-sdk/client-s3": "^3.1154.0",
11
+ "@aws-sdk/s3-presigned-post": "^3.1154.0",
12
+ "@aws-sdk/s3-request-presigner": "^3.1154.0",
13
+ "zod": "^4.6.7"
14
+ }
15
+ }
@@ -0,0 +1,210 @@
1
+ import {
2
+ DeleteObjectCommand,
3
+ GetObjectCommand,
4
+ HeadObjectCommand,
5
+ ListObjectsV2Command,
6
+ PutObjectCommand,
7
+ S3Client,
8
+ S3ServiceException,
9
+ } from "@aws-sdk/client-s3";
10
+ import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
11
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
12
+ import {
13
+ defineExtension,
14
+ OBJECT_STORE_EXTENSION_POINT,
15
+ type ObjectHead,
16
+ type ObjectStore,
17
+ secret,
18
+ } from "@capybara-hq/plugin-sdk";
19
+ import { z } from "zod";
20
+
21
+ /**
22
+ * Test fake of an object store extension (`object-store@1`) backed by the S3 container of
23
+ * `startTestS3()` (SeaweedFS). It exists so core tests do not load the official object store; it
24
+ * implements what the kernel relies on and no more: the deployment config has the same shape as
25
+ * `startTestS3()` returns (`endpoint`, `region`, `bucket`, secret `accessKeyId` /
26
+ * `secretAccessKey`, `forcePathStyle`), streams are buffered in memory (test objects are small),
27
+ * `presignPut` is a POST policy whose size and type the store enforces, keys containing `${` are
28
+ * refused, `presignGet` sets the disposition and response type.
29
+ */
30
+
31
+ const DeploymentConfig = z.object({
32
+ endpoint: z.string().url().optional(),
33
+ region: z.string().min(1),
34
+ bucket: z.string().min(1),
35
+ accessKeyId: secret(z.string().min(1)),
36
+ secretAccessKey: secret(z.string().min(1)),
37
+ forcePathStyle: z.boolean().default(true),
38
+ });
39
+
40
+ /** The deployment config of the fake (as `startTestS3()` gives it). */
41
+ export type FakeObjectStoreConfig = z.input<typeof DeploymentConfig>;
42
+
43
+ const PAGE_MAX = 1000;
44
+
45
+ const isNotFound = (e: unknown): boolean =>
46
+ e instanceof S3ServiceException &&
47
+ (e.name === "NotFound" || e.name === "NoSuchKey" || e.$metadata.httpStatusCode === 404);
48
+
49
+ const etag = (v: string | undefined) => (v ? { etag: v.replaceAll('"', "") } : {});
50
+
51
+ /** `Content-Range: bytes 0-4/11` -> 11. */
52
+ const totalFromRange = (range: string | undefined): number | undefined => {
53
+ const m = range?.match(/\/(\d+)$/);
54
+ return m ? Number(m[1]) : undefined;
55
+ };
56
+
57
+ /** RFC 6266 disposition with an ASCII fallback and the UTF-8 `filename*`. */
58
+ const contentDisposition = (disposition: string, name: string | undefined): string =>
59
+ name === undefined
60
+ ? disposition
61
+ : `${disposition}; filename="${name.replace(/[^\x20-\x7e]|["\\%]/g, "_")}"; filename*=UTF-8''${encodeURIComponent(name)}`;
62
+
63
+ const emptyStream = () =>
64
+ new ReadableStream<Uint8Array>({
65
+ start(c) {
66
+ c.close();
67
+ },
68
+ });
69
+
70
+ /** Creates the S3-backed store (exported for tests that use it without a kernel). */
71
+ export function createFakeObjectStore(input: FakeObjectStoreConfig): ObjectStore {
72
+ const cfg = DeploymentConfig.parse(input);
73
+ const client = new S3Client({
74
+ region: cfg.region,
75
+ credentials: { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey },
76
+ forcePathStyle: cfg.forcePathStyle,
77
+ ...(cfg.endpoint !== undefined && { endpoint: cfg.endpoint }),
78
+ requestChecksumCalculation: "WHEN_REQUIRED",
79
+ responseChecksumValidation: "WHEN_REQUIRED",
80
+ });
81
+ const Bucket = cfg.bucket;
82
+ return {
83
+ async put(key, body, opts) {
84
+ const data =
85
+ body instanceof Uint8Array ? body : new Uint8Array(await new Response(body).arrayBuffer());
86
+ const r = await client.send(
87
+ new PutObjectCommand({ Bucket, Key: key, Body: data, ContentType: opts.contentType }),
88
+ opts.signal ? { abortSignal: opts.signal } : {},
89
+ );
90
+ return { key, size: data.byteLength, contentType: opts.contentType, ...etag(r.ETag) };
91
+ },
92
+ async get(key, opts) {
93
+ const range = opts?.range;
94
+ try {
95
+ const r = await client.send(
96
+ new GetObjectCommand({
97
+ Bucket,
98
+ Key: key,
99
+ ...(range !== undefined && { Range: `bytes=${range.start}-${range.end ?? ""}` }),
100
+ }),
101
+ opts?.signal ? { abortSignal: opts.signal } : {},
102
+ );
103
+ const head: ObjectHead = {
104
+ key,
105
+ size: totalFromRange(r.ContentRange) ?? r.ContentLength ?? 0,
106
+ contentType: r.ContentType ?? "application/octet-stream",
107
+ ...etag(r.ETag),
108
+ };
109
+ const stream = r.Body?.transformToWebStream() as ReadableStream<Uint8Array> | undefined;
110
+ return { head, body: stream ?? emptyStream() };
111
+ } catch (e) {
112
+ if (isNotFound(e)) return null;
113
+ throw e;
114
+ }
115
+ },
116
+ async head(key) {
117
+ try {
118
+ const r = await client.send(new HeadObjectCommand({ Bucket, Key: key }));
119
+ return {
120
+ key,
121
+ size: r.ContentLength ?? 0,
122
+ contentType: r.ContentType ?? "application/octet-stream",
123
+ ...etag(r.ETag),
124
+ };
125
+ } catch (e) {
126
+ if (isNotFound(e)) return null;
127
+ throw e;
128
+ }
129
+ },
130
+ async delete(key) {
131
+ await client.send(new DeleteObjectCommand({ Bucket, Key: key }));
132
+ },
133
+ async deletePrefix(prefix) {
134
+ let deleted = 0;
135
+ for (;;) {
136
+ const page = await client.send(
137
+ new ListObjectsV2Command({ Bucket, Prefix: prefix, MaxKeys: PAGE_MAX }),
138
+ );
139
+ const keys = (page.Contents ?? []).flatMap((o) => (o.Key ? [o.Key] : []));
140
+ if (keys.length === 0) return deleted;
141
+ for (const Key of keys) await client.send(new DeleteObjectCommand({ Bucket, Key }));
142
+ deleted += keys.length;
143
+ }
144
+ },
145
+ async list(prefix, opts) {
146
+ const r = await client.send(
147
+ new ListObjectsV2Command({
148
+ Bucket,
149
+ Prefix: prefix,
150
+ MaxKeys: Math.min(Math.max(1, opts?.limit ?? PAGE_MAX), PAGE_MAX),
151
+ ...(opts?.cursor !== undefined && { ContinuationToken: opts.cursor }),
152
+ }),
153
+ );
154
+ const items: ObjectHead[] = (r.Contents ?? []).flatMap((o) =>
155
+ o.Key === undefined ? [] : [{ key: o.Key, size: o.Size ?? 0, contentType: "" }],
156
+ );
157
+ return r.IsTruncated && r.NextContinuationToken
158
+ ? { items, cursor: r.NextContinuationToken }
159
+ : { items };
160
+ },
161
+ async presignGet(key, opts) {
162
+ return getSignedUrl(
163
+ client,
164
+ new GetObjectCommand({
165
+ Bucket,
166
+ Key: key,
167
+ ResponseContentDisposition: contentDisposition(
168
+ opts.disposition ?? "attachment",
169
+ opts.downloadName,
170
+ ),
171
+ ...(opts.responseContentType !== undefined && {
172
+ ResponseContentType: opts.responseContentType,
173
+ }),
174
+ }),
175
+ { expiresIn: opts.ttlSeconds },
176
+ );
177
+ },
178
+ async presignPut(key, opts) {
179
+ if (key.includes("${")) {
180
+ throw new Error("fake-object-store: presignPut key must not contain '${'");
181
+ }
182
+ const { url, fields } = await createPresignedPost(client, {
183
+ Bucket,
184
+ Key: key,
185
+ Conditions: [
186
+ { key },
187
+ ["content-length-range", 0, opts.maxSize],
188
+ ["eq", "$Content-Type", opts.contentType],
189
+ ],
190
+ Fields: { "Content-Type": opts.contentType },
191
+ Expires: opts.ttlSeconds,
192
+ });
193
+ return { method: "POST", url, headers: {}, fields };
194
+ },
195
+ };
196
+ }
197
+
198
+ export default defineExtension({
199
+ name: "fake-object-store",
200
+ version: "0.1.0",
201
+ sdk: "^0.1.0-rc.0",
202
+ provides: [OBJECT_STORE_EXTENSION_POINT],
203
+ config: { deployment: DeploymentConfig },
204
+ setup(ctx) {
205
+ ctx.provide(
206
+ OBJECT_STORE_EXTENSION_POINT,
207
+ createFakeObjectStore(ctx.config as FakeObjectStoreConfig),
208
+ );
209
+ },
210
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "fake-openapi",
4
+ "version": "0.1.0",
5
+ "publisher": "Capybara",
6
+ "description": "Test fake: connector@1 of kind openapi, reading a small JSON operations document"
7
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@capybara-hq/test-plugin-fake-openapi",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Test fake: connector@1 of kind openapi, reading a small JSON operations document",
6
+ "license": "Apache-2.0",
7
+ "type": "module",
8
+ "dependencies": {
9
+ "@capybara-hq/plugin-sdk": "^0.1.0-rc.0",
10
+ "zod": "^4.6.7"
11
+ }
12
+ }
@@ -0,0 +1,258 @@
1
+ import {
2
+ type AuthHint,
3
+ CONNECTOR_EXTENSION_POINT,
4
+ type Connector,
5
+ type ConnectorInspection,
6
+ type ConnectorOpenArgs,
7
+ type ConnectorSession,
8
+ defineExtension,
9
+ ToolError,
10
+ type ToolResult,
11
+ type ToolSpec,
12
+ } from "@capybara-hq/plugin-sdk";
13
+ import { z } from "zod";
14
+
15
+ /**
16
+ * Test fake of an OpenAPI-style connector extension (`connector@1`, kind `openapi`): the kind the
17
+ * web install wizard drives with a spec URL, an auth step prefilled from the document's security
18
+ * schemes and an operations table. It stands in for the official `connector-openapi` in core's
19
+ * tests (the web e2e loads it next to `fake-connector`, which is kind `mcp` there; one extension
20
+ * provides an extension point once, so the two kinds need two fakes). It reads a small JSON
21
+ * document instead of OpenAPI:
22
+ *
23
+ * ```json
24
+ * { "securitySchemes": { "<scheme>": { "type": "api_key", "in": "header", "name": "X-API-Key" } },
25
+ * "baseUrl": "https://…",
26
+ * "operations": [{ "name": "createPet", "method": "POST", "path": "/pets", "summary": "…",
27
+ * "annotations": { "readOnlyHint": false } }] }
28
+ * ```
29
+ *
30
+ * - `endpointSchema`: `{ specUrl, baseUrl?, operations? }` (the wizard's OpenAPI endpoint without
31
+ * `specText`), https or (with `allowInsecureLoopback`, default on) loopback http;
32
+ * - `inspect`: the document's security schemes as auth hints (`{ scheme, ...value }`) and its
33
+ * operations (`id` = name);
34
+ * - a session lists one tool per operation (all, or the endpoint's `operations`), named by the
35
+ * operation, with input `{ body? }` and `connectorData` `{ method, path }`; a call sends the
36
+ * method to `baseUrl` (endpoint, else document, else the spec URL's origin) + path with `auth()`
37
+ * injected (one `onUnauthorized()` + retry on 401), the JSON `body`, and no redirects. A 4xx is a
38
+ * tool error result (`HTTP <status>: <body>`); a 5xx or an unreachable server is a ToolError of
39
+ * kind `unavailable`.
40
+ */
41
+
42
+ const DeploymentConfig = z.object({
43
+ /** Accept http://127.0.0.1 / localhost / [::1] URLs (on by default: this is a test fake). */
44
+ allowInsecureLoopback: z.boolean().default(true),
45
+ });
46
+
47
+ const isLoopbackHttp = (u: URL): boolean =>
48
+ u.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname);
49
+
50
+ const urlOk = (raw: string, loopback: boolean): boolean => {
51
+ try {
52
+ const u = new URL(raw);
53
+ return u.protocol === "https:" || (loopback && isLoopbackHttp(u));
54
+ } catch {
55
+ return true; // z.url() reports the format error
56
+ }
57
+ };
58
+
59
+ /** The endpoint: the document URL, an optional API base URL and the selected operations. */
60
+ export function fakeOpenApiEndpointSchema(allowInsecureLoopback: boolean) {
61
+ const url = z.url().refine((v) => urlOk(v, allowInsecureLoopback), {
62
+ message: "URL must use https (plain http only for loopback hosts)",
63
+ });
64
+ return z
65
+ .object({
66
+ specUrl: url,
67
+ baseUrl: url.optional(),
68
+ operations: z.array(z.string().min(1)).optional(),
69
+ })
70
+ .strict();
71
+ }
72
+
73
+ type Endpoint = z.infer<ReturnType<typeof fakeOpenApiEndpointSchema>>;
74
+
75
+ const Scheme = z.union([
76
+ z.object({ type: z.literal("api_key"), in: z.enum(["header", "query"]), name: z.string() }),
77
+ z.object({ type: z.literal("bearer") }),
78
+ z.object({ type: z.literal("basic") }),
79
+ ]);
80
+
81
+ const Operation = z.object({
82
+ name: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/),
83
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
84
+ path: z.string().startsWith("/"),
85
+ summary: z.string().optional(),
86
+ annotations: z
87
+ .object({
88
+ readOnlyHint: z.boolean().optional(),
89
+ destructiveHint: z.boolean().optional(),
90
+ idempotentHint: z.boolean().optional(),
91
+ })
92
+ .optional(),
93
+ });
94
+
95
+ const Doc = z.object({
96
+ securitySchemes: z.record(z.string(), Scheme).default({}),
97
+ baseUrl: z.url().optional(),
98
+ operations: z.array(Operation),
99
+ });
100
+
101
+ type Doc = z.infer<typeof Doc>;
102
+
103
+ const UNREACHABLE_TEXT = "Could not reach the connector's server; retry later.";
104
+ const AUTH_FAILED_TEXT = "Authentication failed for this connector; ask the user to reconnect it.";
105
+
106
+ /** Reads the operations document (no credentials: like an OpenAPI document, it is public). */
107
+ async function loadDoc(endpoint: Endpoint, signal: AbortSignal): Promise<Doc> {
108
+ let res: Response;
109
+ try {
110
+ res = await fetch(endpoint.specUrl, { signal, redirect: "manual" });
111
+ } catch {
112
+ throw new ToolError(UNREACHABLE_TEXT, { kind: "unavailable" });
113
+ }
114
+ if (!res.ok) {
115
+ await res.body?.cancel();
116
+ throw new ToolError(`The spec URL answered HTTP ${res.status}; check the endpoint.`);
117
+ }
118
+ const parsed = Doc.safeParse(await res.json().catch(() => undefined));
119
+ if (!parsed.success) {
120
+ throw new ToolError(
121
+ "The spec URL did not return a valid operations document; check the endpoint's specUrl.",
122
+ );
123
+ }
124
+ return parsed.data;
125
+ }
126
+
127
+ const hintsOf = (doc: Doc): AuthHint[] =>
128
+ Object.entries(doc.securitySchemes).map(([scheme, s]) => ({ scheme, ...s }));
129
+
130
+ const selected = (doc: Doc, endpoint: Endpoint) =>
131
+ endpoint.operations === undefined
132
+ ? doc.operations
133
+ : doc.operations.filter((o) => endpoint.operations?.includes(o.name));
134
+
135
+ const INPUT_SCHEMA = {
136
+ type: "object",
137
+ properties: {
138
+ body: { type: "object", description: "JSON request body, for operations that take one." },
139
+ },
140
+ additionalProperties: false,
141
+ };
142
+
143
+ const isObj = (v: unknown): v is Record<string, unknown> =>
144
+ typeof v === "object" && v !== null && !Array.isArray(v);
145
+
146
+ async function openSession(endpoint: Endpoint, args: ConnectorOpenArgs): Promise<ConnectorSession> {
147
+ const doc = await loadDoc(endpoint, args.signal);
148
+ const base = endpoint.baseUrl ?? doc.baseUrl ?? new URL(endpoint.specUrl).origin;
149
+ const ops = selected(doc, endpoint);
150
+ return {
151
+ listTools: async () =>
152
+ ops.map(
153
+ (o): ToolSpec => ({
154
+ name: o.name,
155
+ description: o.summary ?? `${o.method} ${o.path}`,
156
+ inputSchema: INPUT_SCHEMA,
157
+ ...(o.annotations !== undefined && {
158
+ annotations: Object.fromEntries(
159
+ Object.entries(o.annotations).filter(([, v]) => v !== undefined),
160
+ ) as NonNullable<ToolSpec["annotations"]>,
161
+ }),
162
+ connectorData: { method: o.method, path: o.path },
163
+ }),
164
+ ),
165
+ async callTool(name, input, signal, ctx) {
166
+ const data = ctx?.spec.connectorData;
167
+ const op = ops.find((o) => o.name === name);
168
+ const method = typeof data?.method === "string" ? data.method : op?.method;
169
+ const path = typeof data?.path === "string" ? data.path : op?.path;
170
+ if (method === undefined || path === undefined) {
171
+ throw new ToolError(`Unknown operation ${name}; check the tool name and retry.`);
172
+ }
173
+ const body = isObj(input) && isObj(input.body) ? JSON.stringify(input.body) : undefined;
174
+ const send = async (): Promise<Response> => {
175
+ const inj = await args.auth();
176
+ const url = new URL(path.replace(/^\//, ""), base.endsWith("/") ? base : `${base}/`);
177
+ for (const [k, v] of Object.entries(inj.query ?? {})) url.searchParams.set(k, v);
178
+ const headers = new Headers(inj.headers);
179
+ if (body !== undefined) headers.set("content-type", "application/json");
180
+ try {
181
+ return await fetch(url, {
182
+ method,
183
+ headers,
184
+ ...(body !== undefined && { body }),
185
+ signal: AbortSignal.any([signal, args.signal]),
186
+ redirect: "manual",
187
+ });
188
+ } catch (e) {
189
+ if (signal.aborted || args.signal.aborted) throw e;
190
+ throw new ToolError(UNREACHABLE_TEXT, { kind: "unavailable" });
191
+ }
192
+ };
193
+ let res = await send();
194
+ if (res.status === 401) {
195
+ await res.body?.cancel();
196
+ await args.onUnauthorized();
197
+ res = await send();
198
+ if (res.status === 401) {
199
+ await res.body?.cancel();
200
+ throw new ToolError(AUTH_FAILED_TEXT);
201
+ }
202
+ }
203
+ const text = await res.text();
204
+ if (res.status >= 500) {
205
+ throw new ToolError(
206
+ `The connector's server responded with HTTP ${res.status}; retry later.`,
207
+ {
208
+ kind: "unavailable",
209
+ },
210
+ );
211
+ }
212
+ if (res.status >= 300) {
213
+ return {
214
+ content: [{ type: "text", text: `HTTP ${res.status}: ${text}` }],
215
+ isError: true,
216
+ } satisfies ToolResult;
217
+ }
218
+ return { content: [{ type: "text", text }] } satisfies ToolResult;
219
+ },
220
+ async close() {},
221
+ };
222
+ }
223
+
224
+ async function inspect(endpoint: Endpoint, signal: AbortSignal): Promise<ConnectorInspection> {
225
+ const doc = await loadDoc(endpoint, signal);
226
+ return {
227
+ authHints: hintsOf(doc),
228
+ operations: doc.operations.map((o) => ({
229
+ id: o.name,
230
+ method: o.method,
231
+ path: o.path,
232
+ ...(o.summary !== undefined && { summary: o.summary }),
233
+ })),
234
+ };
235
+ }
236
+
237
+ /** Creates the fake OpenAPI connector (exported for unit tests that call it without a kernel). */
238
+ export function createFakeOpenApiConnector(allowInsecureLoopback: boolean): Connector {
239
+ const schema = fakeOpenApiEndpointSchema(allowInsecureLoopback);
240
+ return {
241
+ kind: "openapi",
242
+ endpointSchema: schema,
243
+ open: (args) => openSession(schema.parse(args.endpoint), args),
244
+ inspect: ({ endpoint, signal }) => inspect(schema.parse(endpoint), signal),
245
+ };
246
+ }
247
+
248
+ export default defineExtension({
249
+ name: "fake-openapi",
250
+ version: "0.1.0",
251
+ sdk: "^0.1.0-rc.0",
252
+ provides: [CONNECTOR_EXTENSION_POINT],
253
+ config: { deployment: DeploymentConfig },
254
+ setup(ctx) {
255
+ const { allowInsecureLoopback } = DeploymentConfig.parse(ctx.config ?? {});
256
+ ctx.provide(CONNECTOR_EXTENSION_POINT, createFakeOpenApiConnector(allowInsecureLoopback));
257
+ },
258
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "fake-storage-user",
4
+ "version": "0.1.0",
5
+ "publisher": "Capybara",
6
+ "description": "Test fake: an extension tool that writes through ctx.storage (needs an object-store@1 provider)"
7
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@capybara-hq/test-plugin-fake-storage-user",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Test fake: an extension tool that writes through ctx.storage (needs an object-store@1 provider)",
6
+ "license": "Apache-2.0",
7
+ "type": "module",
8
+ "dependencies": {
9
+ "@capybara-hq/plugin-sdk": "^0.1.0-rc.0",
10
+ "zod": "^4.6.7"
11
+ }
12
+ }
@@ -0,0 +1,58 @@
1
+ import { defineExtension, ToolError, type ToolSpec } from "@capybara-hq/plugin-sdk";
2
+ import { z } from "zod";
3
+
4
+ /**
5
+ * Test fake of an extension that uses `ctx.storage` (`permissions.storage`): tool `note`
6
+ * (model-visible `fake-storage-user__note`) writes the text to `notes/<conversationId>.txt`, which
7
+ * the kernel prefixes with `plugins/fake-storage-user/<orgId>/`, and returns the stored size; tool
8
+ * `read_note` returns it (or says there is none). Needs an `object-store@1` provider.
9
+ */
10
+
11
+ const NoteInput = z.object({ text: z.string().max(10_000) });
12
+
13
+ const NOTE: ToolSpec = {
14
+ name: "note",
15
+ description:
16
+ "Store a text note for the current conversation in object storage. Test fixture only.",
17
+ inputSchema: {
18
+ type: "object",
19
+ properties: { text: { type: "string", maxLength: 10_000, description: "The note text." } },
20
+ required: ["text"],
21
+ additionalProperties: false,
22
+ },
23
+ };
24
+
25
+ const READ_NOTE: ToolSpec = {
26
+ name: "read_note",
27
+ description: "Read the text note stored for the current conversation. Test fixture only.",
28
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
29
+ annotations: { readOnlyHint: true },
30
+ };
31
+
32
+ const keyOf = (conversationId: string) => `notes/${conversationId}.txt`;
33
+
34
+ export default defineExtension({
35
+ name: "fake-storage-user",
36
+ version: "0.1.0",
37
+ sdk: "^0.1.0-rc.0",
38
+ provides: [],
39
+ permissions: { tools: ["note", "read_note"], storage: true },
40
+ setup(ctx) {
41
+ ctx.tools.register(NOTE, async (input, tctx) => {
42
+ const parsed = NoteInput.safeParse(input);
43
+ if (!parsed.success) throw new ToolError("Invalid note: pass { text } with a string text.");
44
+ const head = await ctx.storage.put(
45
+ tctx.orgId,
46
+ keyOf(tctx.conversationId),
47
+ new TextEncoder().encode(parsed.data.text),
48
+ { contentType: "text/plain" },
49
+ );
50
+ return { content: [{ type: "text", text: `Stored the note (${head.size} bytes).` }] };
51
+ });
52
+ ctx.tools.register(READ_NOTE, async (_input, tctx) => {
53
+ const got = await ctx.storage.get(tctx.orgId, keyOf(tctx.conversationId));
54
+ const text = got === null ? "There is no note yet." : await new Response(got.body).text();
55
+ return { content: [{ type: "text", text }] };
56
+ });
57
+ },
58
+ });