@ryuhq/sdk 0.1.14 → 0.2.2

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 (105) hide show
  1. package/README.md +69 -7
  2. package/dist/action.cjs +839 -0
  3. package/dist/action.d.cts +88 -0
  4. package/dist/action.d.ts +88 -0
  5. package/dist/action.js +8 -0
  6. package/dist/agent-plugin.cjs +46 -0
  7. package/dist/agent-plugin.d.cts +43 -33
  8. package/dist/agent-plugin.d.ts +43 -33
  9. package/dist/agent-plugin.js +1 -1
  10. package/dist/agent.cjs +16 -0
  11. package/dist/agent.d.cts +377 -1
  12. package/dist/agent.d.ts +377 -1
  13. package/dist/agent.js +4 -2
  14. package/dist/app-B0Z9Ew_R.d.cts +141 -0
  15. package/dist/app-C-BDJwfG.d.ts +141 -0
  16. package/dist/builder.cjs +1194 -0
  17. package/dist/builder.d.cts +217 -0
  18. package/dist/builder.d.ts +217 -0
  19. package/dist/builder.js +29 -0
  20. package/dist/chunk-4TPUZDTI.js +94 -0
  21. package/dist/chunk-BC3A7HMO.js +164 -0
  22. package/dist/chunk-FZSFZOIN.js +200 -0
  23. package/dist/{chunk-ODFEUVPW.js → chunk-HACAGK65.js} +1 -18
  24. package/dist/chunk-HLKJZAFK.js +361 -0
  25. package/dist/chunk-IKEDLLFY.js +19 -0
  26. package/dist/{chunk-G6FLVEC4.js → chunk-JX6DDRXV.js} +46 -0
  27. package/dist/{chunk-AO2KJRDD.js → chunk-NZKVOSC2.js} +192 -3
  28. package/dist/chunk-QYFUNJOH.js +83 -0
  29. package/dist/chunk-SN2QBJUF.js +148 -0
  30. package/dist/chunk-TLDPEGC7.js +21 -0
  31. package/dist/chunk-TXSHHZF2.js +0 -0
  32. package/dist/chunk-VLIRNNAE.js +154 -0
  33. package/dist/{chunk-MTUBUPIV.js → chunk-Z57QDDJR.js} +8 -227
  34. package/dist/chunk-ZTJWBRUL.js +282 -0
  35. package/dist/cli.cjs +347 -18
  36. package/dist/cli.js +128 -23
  37. package/dist/client-D5U6ssPc.d.cts +84 -0
  38. package/dist/client-D5U6ssPc.d.ts +84 -0
  39. package/dist/index.cjs +810 -86
  40. package/dist/index.d.cts +19 -634
  41. package/dist/index.d.ts +19 -634
  42. package/dist/index.js +82 -699
  43. package/dist/manifest.cjs +200 -4
  44. package/dist/manifest.d.cts +260 -5
  45. package/dist/manifest.d.ts +260 -5
  46. package/dist/manifest.js +15 -1
  47. package/dist/mcp/client.cjs +180 -0
  48. package/dist/mcp/client.d.cts +49 -0
  49. package/dist/mcp/client.d.ts +49 -0
  50. package/dist/mcp/client.js +10 -0
  51. package/dist/mcp/server.cjs +370 -0
  52. package/dist/mcp/server.d.cts +127 -0
  53. package/dist/mcp/server.d.ts +127 -0
  54. package/dist/mcp/server.js +9 -0
  55. package/dist/mcp.cjs +376 -0
  56. package/dist/mcp.d.cts +2 -0
  57. package/dist/mcp.d.ts +2 -0
  58. package/dist/mcp.js +17 -0
  59. package/dist/model.cjs +141 -0
  60. package/dist/model.d.cts +33 -0
  61. package/dist/model.d.ts +33 -0
  62. package/dist/model.js +18 -0
  63. package/dist/plugin.cjs +46 -0
  64. package/dist/plugin.d.cts +215 -0
  65. package/dist/plugin.d.ts +215 -0
  66. package/dist/plugin.js +8 -0
  67. package/dist/runnable.cjs +1438 -0
  68. package/dist/runnable.d.cts +274 -0
  69. package/dist/runnable.d.ts +274 -0
  70. package/dist/runnable.js +34 -0
  71. package/dist/{index-B6SkaAjJ.d.ts → tool-AjkdFvhE.d.ts} +88 -461
  72. package/dist/{index-BvAB5eMk.d.cts → tool-CgzW92O_.d.cts} +88 -461
  73. package/package.json +48 -3
  74. package/src/agent/loop.test.ts +4 -4
  75. package/src/agent/tools.ts +3 -3
  76. package/src/agent-plugin.test.ts +58 -0
  77. package/src/agent-plugin.ts +109 -36
  78. package/src/builder.ts +9 -0
  79. package/src/cli/dev.test.ts +26 -47
  80. package/src/cli/dev.ts +10 -2
  81. package/src/cli-security.test.ts +109 -0
  82. package/src/cli.ts +141 -25
  83. package/src/contracts-lockstep.test.ts +16 -2
  84. package/src/exports-lockstep.test.ts +93 -0
  85. package/src/generated/plugin-manifest.ts +322 -29
  86. package/src/index.ts +53 -0
  87. package/src/manifest-schema.test.ts +30 -1
  88. package/src/manifest.fixtures.test.ts +22 -6
  89. package/src/manifest.test.ts +165 -10
  90. package/src/manifest.ts +454 -188
  91. package/src/mcp/index.ts +18 -0
  92. package/src/mcp/server.ts +2 -1
  93. package/src/model/index.ts +22 -0
  94. package/src/plugin/ryu-plugin.ts +82 -0
  95. package/src/runnable/action.test.ts +128 -0
  96. package/src/runnable/action.ts +202 -0
  97. package/src/runnable/app.test.ts +2 -0
  98. package/src/runnable/app.ts +50 -14
  99. package/src/runnable/index.ts +20 -3
  100. package/src/runnable/primitives.test.ts +34 -0
  101. package/src/runnable/primitives.ts +116 -0
  102. package/src/runnable/runnable-types.ts +3 -0
  103. package/src/runnable/tool.ts +36 -5
  104. package/src/runnable/turn-hook.ts +8 -3
  105. package/src/slash-command.test.ts +69 -0
@@ -163,7 +163,7 @@ describe("runAgentLoop", () => {
163
163
  agentId: "agent-expense",
164
164
  userId: "user-1",
165
165
  tools: {
166
- gmailSearch: ryuTool("composio__GMAIL_SEARCH_EMAILS", {
166
+ gmailSearch: ryuTool("composio.GMAIL_SEARCH_EMAILS", {
167
167
  description: "Search Gmail",
168
168
  parameters: {
169
169
  type: "object",
@@ -176,7 +176,7 @@ describe("runAgentLoop", () => {
176
176
 
177
177
  const events = await collect(agent.stream("find expenses"));
178
178
  expect(captured.body).not.toBeNull();
179
- expect(captured.body?.tool).toBe("composio__GMAIL_SEARCH_EMAILS");
179
+ expect(captured.body?.tool).toBe("composio.GMAIL_SEARCH_EMAILS");
180
180
  expect(captured.body?.agent_id).toBe("agent-expense");
181
181
  expect(captured.body?.user_id).toBe("user-1");
182
182
  const result = events.find((e) => e.type === "result");
@@ -208,7 +208,7 @@ describe("runAgentLoop", () => {
208
208
  node: { baseUrl: NODE },
209
209
  agentId: "agent-expense",
210
210
  tools: {
211
- gmailSearch: ryuTool("composio__GMAIL_SEARCH_EMAILS", {
211
+ gmailSearch: ryuTool("composio.GMAIL_SEARCH_EMAILS", {
212
212
  parameters: { type: "object", properties: {} },
213
213
  }),
214
214
  },
@@ -242,7 +242,7 @@ describe("runAgentLoop", () => {
242
242
  describe("executeTool", () => {
243
243
  it("throws when a remote tool is used without an agentId", async () => {
244
244
  const tools = {
245
- gmailSearch: ryuTool("composio__GMAIL_SEARCH_EMAILS"),
245
+ gmailSearch: ryuTool("composio.GMAIL_SEARCH_EMAILS"),
246
246
  };
247
247
  await expect(
248
248
  executeTool("gmailSearch", "{}", tools, {
@@ -6,7 +6,7 @@
6
6
  * - **Local** tools are `ToolRunnable`s from `defineTool` — they run in-process
7
7
  * via their `run(input, ctx)` implementation.
8
8
  * - **Remote** tools are references to existing Ryu tools (e.g.
9
- * `composio__GMAIL_SEARCH_EMAILS`) created with `ryuTool(id)`. Their schema is
9
+ * `composio.GMAIL_SEARCH_EMAILS`) created with `ryuTool(id)`. Their schema is
10
10
  * lazily fetched from Core `GET /api/tools/describe` and they execute through
11
11
  * Core `POST /api/mcp/tools/call`, which enforces the agent's allowlist and
12
12
  * selects the Composio connected-account entity via `user_id`.
@@ -26,7 +26,7 @@ import type { ToolFunctionDef } from "./model-call.ts";
26
26
  export interface RemoteToolRef {
27
27
  /** One-line description shown to the model (overrides Core's describe). */
28
28
  description?: string;
29
- /** Fully-qualified Ryu tool id, e.g. `composio__GMAIL_SEARCH_EMAILS`. */
29
+ /** Fully-qualified Ryu tool id, e.g. `composio.GMAIL_SEARCH_EMAILS`. */
30
30
  id: string;
31
31
  readonly kind: "remote";
32
32
  /**
@@ -48,7 +48,7 @@ export interface RyuToolOptions {
48
48
  *
49
49
  * @example
50
50
  * ```ts
51
- * ryuTool("composio__GMAIL_SEARCH_EMAILS", {
51
+ * ryuTool("composio.GMAIL_SEARCH_EMAILS", {
52
52
  * description: "Search the user's Gmail",
53
53
  * parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
54
54
  * });
@@ -183,6 +183,64 @@ describe("toAgentPlugin", () => {
183
183
  });
184
184
  });
185
185
 
186
+ test("exports Streamable HTTP, legacy SSE, and inferred remote servers", () => {
187
+ const { mcp, notes } = toAgentPlugin({
188
+ id: "@ryu/remote",
189
+ name: "Remote",
190
+ mcp_servers: {
191
+ hosted: {
192
+ type: "streamable-http",
193
+ url: "https://mcp.example.com/mcp",
194
+ headers: { Authorization: "Bearer static" },
195
+ },
196
+ legacy: {
197
+ type: "sse",
198
+ url: "https://legacy.example.com/sse",
199
+ },
200
+ inferred: { url: "https://inferred.example.com/mcp" },
201
+ },
202
+ });
203
+ expect(mcp?.mcpServers.hosted).toEqual({
204
+ type: "streamable-http",
205
+ url: "https://mcp.example.com/mcp",
206
+ headers: { Authorization: "Bearer static" },
207
+ });
208
+ expect(mcp?.mcpServers.legacy).toEqual({
209
+ type: "sse",
210
+ url: "https://legacy.example.com/sse",
211
+ });
212
+ expect(mcp?.mcpServers.inferred).toEqual({
213
+ type: "streamable-http",
214
+ url: "https://inferred.example.com/mcp",
215
+ });
216
+ expect(notes).toEqual([]);
217
+ });
218
+
219
+ test("omits malformed remote URLs while preserving valid HTTP endpoints", () => {
220
+ const { mcp, notes } = toAgentPlugin({
221
+ id: "@ryu/remote",
222
+ name: "Remote",
223
+ mcp_servers: {
224
+ insecure: {
225
+ type: "streamable-http",
226
+ url: "http://remote.example.com/mcp",
227
+ },
228
+ bad: { type: "streamable-http", url: "file:///tmp/mcp" },
229
+ local: { type: "sse", url: "http://127.0.0.1:8787/sse" },
230
+ },
231
+ });
232
+ expect(mcp?.mcpServers.insecure).toEqual({
233
+ type: "streamable-http",
234
+ url: "http://remote.example.com/mcp",
235
+ });
236
+ expect(mcp?.mcpServers.local).toEqual({
237
+ type: "sse",
238
+ url: "http://127.0.0.1:8787/sse",
239
+ });
240
+ expect(notes).toHaveLength(1);
241
+ expect(notes[0]).toContain("http or https");
242
+ });
243
+
186
244
  test("omits a disabled server but records it", () => {
187
245
  const { plugin, mcp, notes } = toAgentPlugin({
188
246
  id: "@ryu/a",
@@ -4,7 +4,9 @@
4
4
  * The Agent Plugins Specification (https://agent-plugins.org/, TSC: Amazon,
5
5
  * Cursor, Microsoft, OpenAI, Vercel) defines a small portable floor: a plugin is
6
6
  * a directory with `plugin.json`, Agent Skills under `skills/<slug>/SKILL.md`,
7
- * and MCP servers in `mcp.json`. Nothing else is portable.
7
+ * and MCP servers in `mcp.json`. Native stdio, Streamable HTTP, and legacy SSE
8
+ * servers all have a direct portable representation; richer Ryu-only fields stay
9
+ * in the extension namespace or are reported as notes.
8
10
  *
9
11
  * ## Why this is a SECOND file, not a migration
10
12
  *
@@ -27,7 +29,7 @@
27
29
  * is deliberately NOT a copy of the whole native manifest — that would be a second
28
30
  * source of truth with a stale-copy failure mode.
29
31
  *
30
- * Both `plugins-store/*` and `apps-store/*` use this one `PluginManifest` shape, so
32
+ * Both `plugins-store/{plugins,lsp,external_plugins}/*` and `apps-store/*` use this one `PluginManifest` shape, so
31
33
  * one converter covers both stores.
32
34
  */
33
35
 
@@ -88,24 +90,24 @@ export function isAgentPluginManifest(value: unknown): boolean {
88
90
  }
89
91
 
90
92
  /** Author object — the only three fields the spec permits (§5.4). */
91
- export type AgentPluginAuthor = {
92
- name?: string;
93
+ export interface AgentPluginAuthor {
93
94
  email?: string;
95
+ name?: string;
94
96
  url?: string;
95
- };
97
+ }
96
98
 
97
99
  /** Ryu data carried under {@link AGENT_PLUGIN_EXTENSION_NS}. */
98
- export type RyuExtensionData = {
99
- /** The real scoped plugin id (`@ryu/advisor`) — unrecoverable from spec `name`. */
100
- id: string;
100
+ export interface RyuExtensionData {
101
101
  /** Human display name; spec `name` is a slug, not a display string. */
102
102
  displayName: string;
103
+ /** The real scoped plugin id (`@ryu/advisor`) — unrecoverable from spec `name`. */
104
+ id: string;
103
105
  /** Per-MCP-server fields the spec's closed server variants do not allow. */
104
106
  mcp?: Record<string, RyuMcpServerExtras>;
105
- };
107
+ }
106
108
 
107
109
  /** Native MCP fields stripped out of the exported `mcp.json`. */
108
- export type RyuMcpServerExtras = {
110
+ export interface RyuMcpServerExtras {
109
111
  /** Env var that overrides `command` with an absolute path at spawn. */
110
112
  command_env?: string;
111
113
  /** Human description for our MCP listing endpoint. */
@@ -117,41 +119,51 @@ export type RyuMcpServerExtras = {
117
119
  * deliberately do not.
118
120
  */
119
121
  enabled?: false;
120
- };
122
+ }
121
123
 
122
124
  /** A conformant `plugin.json` (§5.2). */
123
- export type AgentPluginJson = {
125
+ export interface AgentPluginJson {
124
126
  $schema: string;
125
- name: string;
126
- version?: string;
127
- description?: string;
128
127
  author?: AgentPluginAuthor;
128
+ description?: string;
129
+ extensions: Record<string, unknown>;
129
130
  homepage?: string;
130
- repository?: string;
131
- license?: string;
132
131
  keywords?: string[];
133
- extensions: Record<string, unknown>;
134
- };
132
+ license?: string;
133
+ name: string;
134
+ repository?: string;
135
+ version?: string;
136
+ }
135
137
 
136
- /** A stdio server entry (§7.2.1) — the only variant we export. */
137
- export type AgentPluginStdioServer = {
138
- type: "stdio";
139
- command: string;
138
+ /** A stdio server entry (§7.2.1). */
139
+ export interface AgentPluginStdioServer {
140
140
  args?: string[];
141
- env?: Record<string, string>;
141
+ command: string;
142
142
  cwd?: string;
143
- };
143
+ env?: Record<string, string>;
144
+ type: "stdio";
145
+ }
146
+
147
+ /** A remote server entry (§7.2.1). */
148
+ export interface AgentPluginRemoteServer {
149
+ headers?: Record<string, string>;
150
+ type: "streamable-http" | "sse";
151
+ url: string;
152
+ }
153
+
154
+ /** Any server variant a conformant Agent Plugins client can consume. */
155
+ export type AgentPluginServer =
156
+ | AgentPluginStdioServer
157
+ | AgentPluginRemoteServer;
144
158
 
145
159
  /** A conformant `mcp.json` (§7.2.1). */
146
- export type AgentPluginMcpJson = {
160
+ export interface AgentPluginMcpJson {
147
161
  $schema: string;
148
- mcpServers: Record<string, AgentPluginStdioServer>;
149
- };
162
+ mcpServers: Record<string, AgentPluginServer>;
163
+ }
150
164
 
151
165
  /** What {@link toAgentPlugin} produces, plus what it had to leave behind. */
152
- export type AgentPluginExport = {
153
- /** The `plugin.json` contents. */
154
- plugin: AgentPluginJson;
166
+ export interface AgentPluginExport {
155
167
  /** The `mcp.json` contents, or null when the plugin exports no server. */
156
168
  mcp: AgentPluginMcpJson | null;
157
169
  /**
@@ -159,7 +171,9 @@ export type AgentPluginExport = {
159
171
  * is visible at the call site instead of silent.
160
172
  */
161
173
  notes: string[];
162
- };
174
+ /** The `plugin.json` contents. */
175
+ plugin: AgentPluginJson;
176
+ }
163
177
 
164
178
  const SPEC_NAME_MAX = 64;
165
179
  const ILLEGAL_NAME_CHARS = /[^a-z0-9.-]+/g;
@@ -258,8 +272,24 @@ function toSpecEnv(value: unknown): Record<string, string> | undefined {
258
272
  return Object.keys(env).length > 0 ? env : undefined;
259
273
  }
260
274
 
275
+ function validateRemoteUrl(name: string, raw: string): string | undefined {
276
+ let parsed: URL;
277
+ try {
278
+ parsed = new URL(raw);
279
+ } catch {
280
+ return `mcp server '${name}' remote url ${JSON.stringify(raw)} is invalid and was omitted`;
281
+ }
282
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
283
+ return `mcp server '${name}' remote url must use http or https and was omitted`;
284
+ }
285
+ if (parsed.username || parsed.password || parsed.hash) {
286
+ return `mcp server '${name}' remote url must not contain credentials or a fragment and was omitted`;
287
+ }
288
+ return undefined;
289
+ }
290
+
261
291
  /**
262
- * Convert one native MCP server declaration to a spec stdio entry, or return the
292
+ * Convert one native MCP server declaration to a spec entry, or return the
263
293
  * reason it cannot be exported.
264
294
  *
265
295
  * Strictness is not optional here and differs from the manifest: an unknown field
@@ -270,7 +300,7 @@ function toSpecEnv(value: unknown): Record<string, string> | undefined {
270
300
  function toSpecServer(
271
301
  name: string,
272
302
  decl: Record<string, unknown>
273
- ): { server?: AgentPluginStdioServer; extras: RyuMcpServerExtras; note?: string } {
303
+ ): { server?: AgentPluginServer; extras: RyuMcpServerExtras; note?: string } {
274
304
  const extras: RyuMcpServerExtras = {};
275
305
  const commandEnv = asString(decl.command_env);
276
306
  const description = asString(decl.description);
@@ -289,6 +319,48 @@ function toSpecServer(
289
319
  };
290
320
  }
291
321
 
322
+ const declaredType = asString(decl.type);
323
+ const remoteUrl = asString(decl.url);
324
+ const remoteType =
325
+ declaredType === "sse"
326
+ ? "sse"
327
+ : declaredType === "streamable-http" ||
328
+ declaredType === "streamable_http" ||
329
+ declaredType === "http" ||
330
+ (!declaredType && remoteUrl)
331
+ ? "streamable-http"
332
+ : undefined;
333
+ if (remoteType) {
334
+ if (!remoteUrl) {
335
+ return {
336
+ extras,
337
+ note: `mcp server '${name}' has remote type '${remoteType}' but no url and was omitted`,
338
+ };
339
+ }
340
+ const urlNote = validateRemoteUrl(name, remoteUrl);
341
+ if (urlNote) {
342
+ return { extras, note: urlNote };
343
+ }
344
+ const server: AgentPluginRemoteServer = {
345
+ type: remoteType,
346
+ url: remoteUrl,
347
+ };
348
+ const headers = toSpecEnv(decl.headers);
349
+ if (headers) {
350
+ server.headers = headers;
351
+ }
352
+ const note = decl.auth
353
+ ? `mcp server '${name}' uses Ryu OAuth metadata that is not portable; static headers were exported`
354
+ : undefined;
355
+ return { server, extras, note };
356
+ }
357
+ if (declaredType && declaredType !== "stdio") {
358
+ return {
359
+ extras,
360
+ note: `mcp server '${name}' has unsupported transport '${declaredType}' and was omitted`,
361
+ };
362
+ }
363
+
292
364
  const command = asString(decl.command);
293
365
  if (!command) {
294
366
  return {
@@ -359,7 +431,8 @@ export function toAgentPlugin(
359
431
  }
360
432
  // `tagline` is our one-line pitch; it is the better `description` when no long
361
433
  // description exists, and the spec has no second summary field to put it in.
362
- const description = asString(manifest.description) ?? asString(manifest.tagline);
434
+ const description =
435
+ asString(manifest.description) ?? asString(manifest.tagline);
363
436
  if (description) {
364
437
  plugin.description = description;
365
438
  }
@@ -385,7 +458,7 @@ export function toAgentPlugin(
385
458
  }
386
459
 
387
460
  const declared = manifest.mcp_servers;
388
- const servers: Record<string, AgentPluginStdioServer> = {};
461
+ const servers: Record<string, AgentPluginServer> = {};
389
462
  const mcpExtras: Record<string, RyuMcpServerExtras> = {};
390
463
  if (declared && typeof declared === "object" && !Array.isArray(declared)) {
391
464
  for (const [name, raw] of Object.entries(
package/src/builder.ts CHANGED
@@ -16,6 +16,7 @@ import type {
16
16
  CompanionSurface,
17
17
  PluginManifest,
18
18
  RunnableMeta,
19
+ SlashCommandContribution,
19
20
  Surface,
20
21
  } from "./manifest.ts";
21
22
  import { PluginManifestSchema, RunnableMetaSchema } from "./manifest.ts";
@@ -325,6 +326,7 @@ export class AppBuilder {
325
326
  private readonly _dependencies: AppDependency[] = [];
326
327
  private readonly _requiredCapabilities: CapabilityReq[] = [];
327
328
  private readonly _requiredGrants: string[] = [];
329
+ private readonly _slashCommands: SlashCommandContribution[] = [];
328
330
  private readonly _targets: Surface[] = [];
329
331
 
330
332
  /** Set the reverse-domain app id (e.g. `"com.example.checklist"`). */
@@ -393,6 +395,12 @@ export class AppBuilder {
393
395
  return this;
394
396
  }
395
397
 
398
+ /** Add a slash command and its optional sequential argument choices. */
399
+ slashCommand(command: SlashCommandContribution): this {
400
+ this._slashCommands.push(command);
401
+ return this;
402
+ }
403
+
396
404
  /**
397
405
  * Declare a **plugin-to-plugin dependency** (auto-enabled, in dependency order,
398
406
  * before this app). `minVersion` is a MINIMUM (`"1.2.0"` = `">=1.2.0"`).
@@ -448,6 +456,7 @@ export class AppBuilder {
448
456
  uiEntry: this._uiEntry,
449
457
  tools: this._tools,
450
458
  grants: this._grants,
459
+ slashCommands: this._slashCommands,
451
460
  ...(this._server ? { server: this._server } : {}),
452
461
  ...(this._displayMode ? { displayMode: this._displayMode } : {}),
453
462
  ...(this._mime ? { mime: this._mime } : {}),
@@ -3,57 +3,27 @@
3
3
  * sample Runnable, asserting that text, tool-call, and tool-result events
4
4
  * stream to stdout correctly.
5
5
  *
6
- * The mock gateway is an in-process HTTP server (Bun.serve) that returns a
7
- * pre-canned SSE response so the test never needs a real gateway running.
6
+ * The mock gateway is an in-process request handler so the test never needs a
7
+ * real gateway or a listening socket.
8
8
  */
9
9
 
10
- import { afterAll, beforeAll, describe, expect, it } from "bun:test";
10
+ import { describe, expect, it } from "bun:test";
11
11
  import { unlinkSync, writeFileSync } from "node:fs";
12
- import { serve } from "bun";
13
- import type { ChatMessage } from "../model/client.ts";
14
- import { ModelClient } from "../model/client.ts";
15
- import type { DevEvent, Runnable } from "./dev.ts";
12
+ import type { ChatMessage, ModelClient } from "../model/client.ts";
13
+ import type { DevEvent, GatewayFetch, Runnable } from "./dev.ts";
16
14
  import { loadRunnable, probeGateway, runTurn } from "./dev.ts";
17
15
 
18
16
  // ── Mock gateway ──────────────────────────────────────────────────────────────
19
17
 
20
- /** Pre-canned SSE body the mock gateway returns for any chat completions POST. */
21
- const MOCK_SSE_BODY = [
22
- 'data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}',
23
- 'data: {"choices":[{"delta":{"content":", world"},"finish_reason":null}]}',
24
- 'data: {"choices":[{"delta":{"content":"!"},"finish_reason":"stop"}]}',
25
- "data: [DONE]",
26
- ].join("\n");
27
-
28
- let mockServer: ReturnType<typeof serve>;
29
- let mockBaseUrl: string;
30
-
31
- beforeAll(() => {
32
- mockServer = serve({
33
- port: 0, // OS-assigned port
34
- fetch(req) {
35
- const url = new URL(req.url);
36
-
37
- if (url.pathname === "/health") {
38
- return new Response("ok", { status: 200 });
39
- }
40
-
41
- if (url.pathname === "/v1/chat/completions") {
42
- return new Response(MOCK_SSE_BODY, {
43
- status: 200,
44
- headers: { "Content-Type": "text/event-stream" },
45
- });
46
- }
47
-
48
- return new Response("not found", { status: 404 });
49
- },
50
- });
51
- mockBaseUrl = `http://127.0.0.1:${mockServer.port}`;
52
- });
53
-
54
- afterAll(() => {
55
- mockServer.stop();
56
- });
18
+ /** Stable gateway URL used by the injected health request handler. */
19
+ const MOCK_GATEWAY_URL = "http://mock-gateway.test";
20
+ const mockFetch: GatewayFetch = async (input) => {
21
+ const url = new URL(String(input));
22
+ if (url.pathname === "/health") {
23
+ return new Response("ok", { status: 200 });
24
+ }
25
+ return new Response("not found", { status: 404 });
26
+ };
57
27
 
58
28
  // ── Sample Runnable ───────────────────────────────────────────────────────────
59
29
 
@@ -98,7 +68,7 @@ const sampleRunnable: Runnable = {
98
68
 
99
69
  describe("probeGateway", () => {
100
70
  it("returns true when the gateway /health responds", async () => {
101
- const reachable = await probeGateway(mockBaseUrl);
71
+ const reachable = await probeGateway(MOCK_GATEWAY_URL, mockFetch);
102
72
  expect(reachable).toBe(true);
103
73
  });
104
74
 
@@ -130,7 +100,16 @@ describe("loadRunnable", () => {
130
100
 
131
101
  describe("runTurn — full turn streams to stdout", () => {
132
102
  it("collects text + tool events from a full turn", async () => {
133
- const model = new ModelClient("test-model", { baseUrl: mockBaseUrl });
103
+ const model = {
104
+ async *stream(): AsyncGenerator<{
105
+ content: string;
106
+ finishReason: string | null;
107
+ }> {
108
+ for (const content of ["Hello", ", world", "!"]) {
109
+ yield { content, finishReason: null };
110
+ }
111
+ },
112
+ } as unknown as ModelClient;
134
113
 
135
114
  const events: DevEvent[] = [];
136
115
 
@@ -158,7 +137,7 @@ describe("runTurn — full turn streams to stdout", () => {
158
137
  const toolCallEvents = events.filter((e) => e.type === "tool_call");
159
138
  const toolResultEvents = events.filter((e) => e.type === "tool_result");
160
139
 
161
- // Three text chunks from the mock SSE body.
140
+ // Three text chunks from the mock model stream.
162
141
  expect(textEvents).toHaveLength(3);
163
142
  const fullText = textEvents
164
143
  .map((e) => (e as { content: string }).content)
package/src/cli/dev.ts CHANGED
@@ -93,15 +93,23 @@ export interface Runnable {
93
93
 
94
94
  // ── Gateway reachability check ────────────────────────────────────────────────
95
95
 
96
+ export type GatewayFetch = (
97
+ input: string | URL | Request,
98
+ init?: RequestInit
99
+ ) => Promise<Response>;
100
+
96
101
  /**
97
102
  * Probe the gateway at `baseUrl` with a HEAD request.
98
103
  *
99
104
  * Returns `true` when any HTTP response is received (even 4xx — the gateway
100
105
  * is reachable), `false` when the request fails with a network error.
101
106
  */
102
- export async function probeGateway(baseUrl: string): Promise<boolean> {
107
+ export async function probeGateway(
108
+ baseUrl: string,
109
+ fetchImpl: GatewayFetch = fetch
110
+ ): Promise<boolean> {
103
111
  try {
104
- await fetch(`${baseUrl}/health`, {
112
+ await fetchImpl(`${baseUrl}/health`, {
105
113
  method: "HEAD",
106
114
  signal: AbortSignal.timeout(3000),
107
115
  });
@@ -0,0 +1,109 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import {
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ symlinkSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+
15
+ const REPO_ROOT = join(import.meta.dir, "../../..");
16
+ const CLI = join(import.meta.dir, "cli.ts");
17
+ const temporaryDirectories: string[] = [];
18
+
19
+ afterEach(() => {
20
+ for (const directory of temporaryDirectories.splice(0)) {
21
+ rmSync(directory, { force: true, recursive: true });
22
+ }
23
+ });
24
+
25
+ function temporaryPackage(): string {
26
+ const directory = mkdtempSync(join(tmpdir(), "ryu-sdk-pack-"));
27
+ temporaryDirectories.push(directory);
28
+ return directory;
29
+ }
30
+
31
+ function runPack(directory: string) {
32
+ return spawnSync(process.execPath, [CLI, "pack", directory], {
33
+ encoding: "utf8",
34
+ });
35
+ }
36
+
37
+ describe.skipIf(process.platform === "win32")(
38
+ "ryu pack package containment",
39
+ () => {
40
+ it("rejects external symlinks for hooks, adapters, and output styles", () => {
41
+ const scenarios = [
42
+ {
43
+ manifest: "plugins-store/plugins/tool-firewall/manifest.json",
44
+ linkedFile: "hooks/pre.js",
45
+ copiedFiles: ["hooks/post.js"],
46
+ },
47
+ {
48
+ manifest:
49
+ "plugins-store/external_plugins/cloudflare-browser-run/manifest.json",
50
+ linkedFile: "adapters/browser.navigate.js",
51
+ copiedFiles: [
52
+ "adapters/browser.screenshot.js",
53
+ "adapters/browser.snapshot.js",
54
+ ],
55
+ },
56
+ {
57
+ manifest: "plugins-store/plugins/output-styles/manifest.json",
58
+ linkedFile: "output-styles/i-have-adhd.md",
59
+ copiedFiles: [
60
+ "output-styles/bro.md",
61
+ "output-styles/eli5.md",
62
+ "output-styles/explanatory.md",
63
+ "output-styles/gen-z.md",
64
+ "output-styles/learning.md",
65
+ "output-styles/no-ai-slop.md",
66
+ "output-styles/no-hype.md",
67
+ "output-styles/plain-technical.md",
68
+ "output-styles/plain-text.md",
69
+ "output-styles/proactive.md",
70
+ ],
71
+ },
72
+ ];
73
+
74
+ for (const scenario of scenarios) {
75
+ const directory = temporaryPackage();
76
+ const sourceRoot = dirname(join(REPO_ROOT, scenario.manifest));
77
+ writeFileSync(
78
+ join(directory, "manifest.json"),
79
+ readFileSync(join(REPO_ROOT, scenario.manifest))
80
+ );
81
+ for (const file of scenario.copiedFiles) {
82
+ mkdirSync(dirname(join(directory, file)), { recursive: true });
83
+ writeFileSync(
84
+ join(directory, file),
85
+ readFileSync(join(sourceRoot, file))
86
+ );
87
+ }
88
+ mkdirSync(dirname(join(directory, scenario.linkedFile)), {
89
+ recursive: true,
90
+ });
91
+ writeFileSync(
92
+ join(directory, scenario.linkedFile),
93
+ readFileSync(join(sourceRoot, scenario.linkedFile))
94
+ );
95
+ const workingResult = runPack(directory);
96
+ expect(workingResult.status).toBe(0);
97
+ unlinkSync(join(directory, scenario.linkedFile));
98
+
99
+ const external = join(dirname(directory), `${Date.now()}-outside.txt`);
100
+ writeFileSync(external, "private host data");
101
+ symlinkSync(external, join(directory, scenario.linkedFile));
102
+
103
+ const result = runPack(directory);
104
+ expect(result.status).toBe(1);
105
+ rmSync(external, { force: true });
106
+ }
107
+ });
108
+ }
109
+ );