create-ryu-app 0.0.5 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,9 +5,17 @@
5
5
  [![License](https://shieldcn.dev/badge/License-Apache--2.0-73DC8C.svg?logo=apache&logoColor=white)](./LICENSE)
6
6
  [![Stack](https://shieldcn.dev/badge/TypeScript-CLI-3178C6.svg?logo=typescript&logoColor=white)](../../README.md)
7
7
 
8
- `create-ryu-app` is the project scaffolder for the Ryu SDK. Running it generates a starter project with a Runnable, a gateway-pointed model config, and a `plugin.json` (legacy `ryu.json`) manifest validated against the PluginManifest schema, so a new plugin compiles and packs out of the box. It depends on `@ryuhq/sdk` by semver.
8
+ `create-ryu-app` is the project scaffolder for Ryu extensions. Running it generates a starter project with a `manifest.json` validated against the PluginManifest schema, so it installs out of the box.
9
9
 
10
- Pick a starter with `--template` (default `agent`):
10
+ Ryu extensions come in two shapes, and `--template` picks which one you get.
11
+
12
+ **Apps** are self-contained `apps-store/<app>` satellites: a manifest plus an out-of-process `sidecar/`, driven through the generic ext-proxy (`/api/ext/<plugin_id>/*`). Shipping one never requires a change to Ryu Core or the Gateway.
13
+
14
+ | Template | Emits | Runtime |
15
+ |---|---|---|
16
+ | `app` | A manifest declaring a lazy local sidecar + a grant-gated capability, and the loopback HTTP sidecar that serves it | Bun/Node, dependency-free |
17
+
18
+ **Plugins** are manifest contributions Ryu renders in-process — runnables, turn hooks, widgets, composer controls, a companion panel. No sidecar, no port. They are authored against `@ryuhq/sdk` and shipped with `ryu pack`.
11
19
 
12
20
  | Template | Emits | Factory |
13
21
  |---|---|---|
@@ -27,6 +35,9 @@ bunx create-ryu-app <name>
27
35
  # scaffold a specific template
28
36
  bunx create-ryu-app <name> --template ryu-app
29
37
 
38
+ # scaffold an apps-store satellite (manifest + sidecar)
39
+ bunx create-ryu-app <name> --template app
40
+
30
41
  # build from source
31
42
  bun run build # tsup → dist/
32
43
  bun test
@@ -35,8 +46,9 @@ bun test
35
46
  ## What it provides
36
47
 
37
48
  - A one-command scaffolder (`create-ryu-app <name> [--template <t>]`) bundled with a `template/<name>/` tree per starter.
38
- - A generated starter Runnable plus a gateway-pointed model config.
39
- - A `plugin.json` / `ryu.json` manifest validated against the PluginManifest schema, ready for `ryu pack`.
49
+ - For plugins: a starter Runnable plus a gateway-pointed model config, and a manifest ready for `ryu pack`.
50
+ - For apps: a satellite tree — `manifest.json` (sidecar + capability + grant) and a fail-closed, bearer-gated loopback sidecar that owns no dependency on this repo.
51
+ - A `manifest.json` validated against the PluginManifest schema at scaffold time.
40
52
 
41
53
  ## License
42
54
 
package/dist/index.js CHANGED
@@ -16,17 +16,38 @@ var RE_WORD_SEPARATOR = /[-_\s]+/;
16
16
  var RE_VALID_NAME = /^[a-z0-9][a-z0-9-_]*$/i;
17
17
  var RE_LABEL_IMPERSONATES = /ryu|system/i;
18
18
  var SAFE_COMPANION_LABEL = "App Panel";
19
- var SDK_DEPENDENCY_RANGE = "^0.0.5";
19
+ var SDK_DEPENDENCY_RANGE = "^0.0.17";
20
20
  var TEMPLATES = {
21
- agent: { devEntry: "src/agent.ts" },
22
- "hook-plugin": { devEntry: "src/plugin.ts" },
21
+ agent: {
22
+ kind: "plugin",
23
+ summary: "a loop-owning Runnable agent (Agent + ryuTool)",
24
+ devEntry: "src/agent.ts"
25
+ },
26
+ "hook-plugin": {
27
+ kind: "plugin",
28
+ summary: "a post-assistant-turn hook (definePlugin + defineTurnHook)",
29
+ devEntry: "src/plugin.ts"
30
+ },
23
31
  "ryu-app": {
32
+ kind: "plugin",
33
+ summary: "an interactive in-chat widget (defineApp + a sandboxed widget)",
24
34
  devEntry: "src/app.ts",
25
35
  extraDependencies: { react: "^19.2.0", "react-dom": "^19.2.0" }
26
36
  },
27
37
  "companion-plugin": {
38
+ kind: "plugin",
39
+ summary: "a widget that calls a companion tool, plus a panel surface",
28
40
  devEntry: "src/app.ts",
29
41
  extraDependencies: { react: "^19.2.0", "react-dom": "^19.2.0" }
42
+ },
43
+ app: {
44
+ kind: "app",
45
+ summary: "an apps-store satellite: manifest + a loopback HTTP sidecar, driven through the ext-proxy",
46
+ devEntry: "sidecar/src/main/index.ts",
47
+ extraScripts: {
48
+ build: "bun run --cwd sidecar build",
49
+ "check-types": "bun run --cwd sidecar check-types"
50
+ }
30
51
  }
31
52
  };
32
53
  var DEFAULT_TEMPLATE = "agent";
@@ -36,11 +57,16 @@ function exitError(message) {
36
57
  `);
37
58
  process.exit(1);
38
59
  }
60
+ function templateLines(kind) {
61
+ const width = Math.max(...Object.keys(TEMPLATES).map((n) => n.length));
62
+ return Object.entries(TEMPLATES).filter(([, spec]) => spec.kind === kind).map(
63
+ ([name, spec]) => ` ${name.padEnd(width)} ${spec.summary}${name === DEFAULT_TEMPLATE ? " (default)" : ""}`
64
+ );
65
+ }
39
66
  function printUsage() {
40
- const templates = Object.keys(TEMPLATES).join(" | ");
41
67
  process.stderr.write(
42
68
  [
43
- "create-ryu-app \u2014 scaffold a starter Ryu SDK project",
69
+ "create-ryu-app \u2014 scaffold a starter Ryu app or plugin",
44
70
  "",
45
71
  "Usage:",
46
72
  " bunx create-ryu-app <name> [--template <template>]",
@@ -49,7 +75,17 @@ function printUsage() {
49
75
  " <name> Project directory name (also used as the app id slug)",
50
76
  "",
51
77
  "Options:",
52
- ` --template One of: ${templates} (default: ${DEFAULT_TEMPLATE})`,
78
+ " --template Which starter to emit. Two shapes:",
79
+ "",
80
+ " APP \u2014 a self-contained satellite: manifest.json + an out-of-process",
81
+ " sidecar/, reached through the generic ext-proxy (/api/ext/<id>/*).",
82
+ " Ships without any change to Ryu Core or the Gateway.",
83
+ ...templateLines("app"),
84
+ "",
85
+ " PLUGIN \u2014 manifest contributions Ryu renders in-process: runnables,",
86
+ " turn hooks, widgets, composer controls, a companion panel. No sidecar,",
87
+ " no port.",
88
+ ...templateLines("plugin"),
53
89
  ""
54
90
  ].join("\n")
55
91
  );
@@ -60,6 +96,9 @@ function toDisplayName(slug) {
60
96
  function toCompanionLabel(displayName) {
61
97
  return RE_LABEL_IMPERSONATES.test(displayName) ? SAFE_COMPANION_LABEL : displayName;
62
98
  }
99
+ function toEnvPrefix(slug) {
100
+ return `RYU_${slug.toUpperCase().replaceAll("-", "_")}`;
101
+ }
63
102
  function stampTemplate(filePath, replacements) {
64
103
  let content = readFileSync(filePath, "utf8");
65
104
  for (const [placeholder, value] of Object.entries(replacements)) {
@@ -115,33 +154,41 @@ function scaffold(name, outDir, template = DEFAULT_TEMPLATE) {
115
154
  }
116
155
  const templateDir = resolveTemplateDir(template);
117
156
  const displayName = toDisplayName(slug);
157
+ const envPrefix = toEnvPrefix(slug);
118
158
  mkdirSync(projectDir, { recursive: true });
119
159
  cpSync(templateDir, projectDir, { recursive: true });
120
160
  stampTree(projectDir, {
121
161
  __APP_NAME__: slug,
122
162
  __APP_DISPLAY_NAME__: displayName,
123
- __COMPANION_LABEL__: toCompanionLabel(displayName)
163
+ __COMPANION_LABEL__: toCompanionLabel(displayName),
164
+ __APP_BIN_ENV__: `${envPrefix}_BIN`,
165
+ __APP_PORT_ENV__: `${envPrefix}_PORT`,
166
+ __APP_TOKEN_ENV__: `${envPrefix}_TOKEN`
124
167
  });
125
- const manifestPath = join(projectDir, "plugin.json");
168
+ const manifestPath = join(projectDir, "manifest.json");
126
169
  const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
127
170
  const validation = PluginManifestSchema.safeParse(parsed);
128
171
  if (!validation.success) {
129
172
  const first = validation.error.issues[0];
130
173
  const field = first?.path.join(".") ?? "unknown";
131
174
  const msg = first?.message ?? "validation failed";
132
- exitError(`generated plugin.json is invalid at '${field}': ${msg}`);
175
+ exitError(`generated manifest.json is invalid at '${field}': ${msg}`);
133
176
  }
177
+ const isApp = spec.kind === "app";
134
178
  const pkgJson = {
135
179
  name: slug,
136
180
  version: "0.1.0",
137
181
  type: "module",
138
182
  scripts: {
139
183
  dev: `bun run ${spec.devEntry}`,
140
- pack: "bunx ryu pack ."
184
+ ...isApp ? {} : { pack: "bunx ryu pack ." },
185
+ ...spec.extraScripts
141
186
  },
142
- dependencies: {
143
- "@ryuhq/sdk": SDK_DEPENDENCY_RANGE,
144
- ...spec.extraDependencies
187
+ ...isApp ? {} : {
188
+ dependencies: {
189
+ "@ryuhq/sdk": SDK_DEPENDENCY_RANGE,
190
+ ...spec.extraDependencies
191
+ }
145
192
  }
146
193
  };
147
194
  writeFileSync(
@@ -191,6 +238,7 @@ if (import.meta.main) {
191
238
  exitError("name argument is required");
192
239
  }
193
240
  const created = scaffold(parsed.name, process.cwd(), parsed.template);
241
+ const isApp = TEMPLATES[parsed.template]?.kind === "app";
194
242
  process.stdout.write(
195
243
  [
196
244
  "",
@@ -199,8 +247,8 @@ if (import.meta.main) {
199
247
  " next steps:",
200
248
  ` cd ${parsed.name}`,
201
249
  " bun install",
202
- " bun dev # runs the template entry",
203
- " bun run pack # validate and bundle plugin.json",
250
+ " bun dev # runs the template entry",
251
+ isApp ? " bun run build # compile the sidecar binary the manifest names" : " bun run pack # validate and bundle manifest.json",
204
252
  ""
205
253
  ].join("\n")
206
254
  );
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "create-ryu-app",
3
- "version": "0.0.5",
3
+ "version": "0.0.17",
4
4
  "type": "module",
5
- "description": "Scaffold a starter Ryu SDK project with a Runnable, gateway-pointed model config, and plugin.json manifest",
5
+ "description": "Scaffold a starter Ryu SDK project with a Runnable, gateway-pointed model config, and manifest.json manifest",
6
6
  "bin": {
7
7
  "create-ryu-app": "./dist/index.js"
8
8
  },
@@ -20,7 +20,7 @@
20
20
  "clean": "rm -rf dist"
21
21
  },
22
22
  "dependencies": {
23
- "@ryuhq/sdk": "^0.0.5"
23
+ "@ryuhq/sdk": "^0.0.17"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "^1.3.4",
@@ -0,0 +1,58 @@
1
+ # __APP_DISPLAY_NAME__
2
+
3
+ A Ryu **app**: a self-contained satellite that ships a manifest and an
4
+ out-of-process sidecar. It is not a plugin — there is no widget, no turn hook and
5
+ no bundled UI code. Everything a Ryu node needs to run it is in this directory.
6
+
7
+ ```
8
+ manifest.json the whole integration surface (sidecar + capability + grant)
9
+ sidecar/ the backend process, dependency-free (node:http only)
10
+ src/main/control.ts the loopback HTTP control server (pure router + auth)
11
+ src/main/index.ts entrypoint
12
+ ```
13
+
14
+ ## The contract
15
+
16
+ An app owns **only** its `manifest.json` and its `sidecar/`. It must never require
17
+ a change to Ryu Core or the Gateway — no route module, no reserved MCP server, no
18
+ hardcoded id or port anywhere but this manifest. Control flows through the generic
19
+ ext-proxy:
20
+
21
+ ```
22
+ client → GET /api/ext/com.example.__APP_NAME__/items → Core → 127.0.0.1:<port>/items
23
+ ```
24
+
25
+ Core forwards **only** the sub-paths declared in `sidecars[].http.routes[]` and
26
+ 404s everything else, so that list is a security boundary, not documentation. Keep
27
+ it in sync with the router in `sidecar/src/main/control.ts`.
28
+
29
+ `provides[]` publishes the sidecar as the `__APP_NAME__.control` capability behind
30
+ the `__APP_NAME__:control` grant, so another app can depend on the *capability*
31
+ rather than on this app's id.
32
+
33
+ ## Run it
34
+
35
+ ```bash
36
+ bun install --cwd sidecar
37
+ __APP_TOKEN_ENV__=dev-token bun run dev
38
+ curl -s localhost:7899/health
39
+ curl -s -H 'Authorization: Bearer dev-token' localhost:7899/items
40
+ ```
41
+
42
+ Every route except `GET /health` is bearer-gated and **fails closed**: with no
43
+ `RYU_EXT_TOKEN` (injected by Core at spawn) and no `__APP_TOKEN_ENV__` override,
44
+ every protected route 401s. Do not relax that — loopback is not a trust boundary.
45
+
46
+ ## Before you ship
47
+
48
+ - **Pick a free port.** There is no port registry. `7899` is the scaffold default;
49
+ avoid Core (`7980`), the Gateway (`7981`), the built-in sidecar band
50
+ (`7990`–`8003`) and the local engines (`8080`–`8087`). Core injects the
51
+ profile-shifted port through `__APP_PORT_ENV__`, so read it — never hardcode.
52
+ - **Build the binary.** `sidecars[].process.command` names `ryu-__APP_NAME__` on
53
+ `PATH`; `bun run --cwd sidecar build` compiles it. `__APP_BIN_ENV__` points a
54
+ node at a local build instead.
55
+ - **Rename the demo domain.** `/items` is a placeholder for whatever this app
56
+ actually does. Change it in `control.ts` *and* in the manifest's `routes[]`.
57
+ - **Keep `sidecar/` self-contained.** No workspace imports, no Ryu SDK — this tree
58
+ is published on its own.
@@ -0,0 +1,45 @@
1
+ {
2
+ "id": "com.example.__APP_NAME__",
3
+ "name": "__APP_DISPLAY_NAME__",
4
+ "version": "0.1.0",
5
+ "description": "A self-contained Ryu app: a loopback HTTP sidecar Ryu spawns lazily and exposes as the grant-gated `__APP_NAME__.control` capability. Clients reach it through the generic ext-proxy at /api/ext/com.example.__APP_NAME__/*; no Core or Gateway code knows this app exists.",
6
+ "tagline": "A sidecar-backed Ryu app",
7
+ "category": "Automation",
8
+ "engines": {
9
+ "ryu": ">=0.0.1"
10
+ },
11
+ "runnables": [],
12
+ "sidecars": [
13
+ {
14
+ "name": "__APP_NAME__",
15
+ "process": {
16
+ "kind": "local",
17
+ "command": "ryu-__APP_NAME__",
18
+ "command_env": "__APP_BIN_ENV__",
19
+ "port_env": "__APP_PORT_ENV__"
20
+ },
21
+ "port": 7899,
22
+ "health_path": "/health",
23
+ "lazy": true,
24
+ "idle_stop_secs": 300,
25
+ "http": {
26
+ "routes": [
27
+ { "path": "/" },
28
+ { "path": "/health" },
29
+ { "path": "/items" },
30
+ { "path": "/items/:id" }
31
+ ]
32
+ }
33
+ }
34
+ ],
35
+ "provides": [
36
+ {
37
+ "capability": "__APP_NAME__.control",
38
+ "version": "1.0.0",
39
+ "sidecar": "__APP_NAME__",
40
+ "route": "/",
41
+ "grant": "__APP_NAME__:control"
42
+ }
43
+ ],
44
+ "permission_grants": ["__APP_NAME__:control"]
45
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@example/__APP_NAME__-sidecar",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "bin": {
7
+ "ryu-__APP_NAME__": "./src/main/index.ts"
8
+ },
9
+ "main": "src/main/index.ts",
10
+ "scripts": {
11
+ "start": "bun run src/main/index.ts",
12
+ "build": "bun build src/main/index.ts --compile --outfile dist/ryu-__APP_NAME__",
13
+ "check-types": "tsc --noEmit"
14
+ },
15
+ "devDependencies": {
16
+ "@types/bun": "^1.3.14",
17
+ "@types/node": "^22",
18
+ "typescript": "^5"
19
+ }
20
+ }
@@ -0,0 +1,291 @@
1
+ // Loopback control server for the __APP_DISPLAY_NAME__ sidecar.
2
+ //
3
+ // Ryu spawns this as a `local` manifest sidecar (see `../../../../manifest.json`,
4
+ // `SidecarProcess::Local`). It exposes a small HTTP control surface bound to
5
+ // loopback so Core — and, through Core's generic ext-proxy
6
+ // (`/api/ext/com.example.__APP_NAME__/*`), every Ryu client — can drive the app.
7
+ // Nothing in Core or the Gateway knows this app exists: the manifest's
8
+ // `sidecars[]` + `provides[]` are the entire integration.
9
+ //
10
+ // SECURITY
11
+ // --------
12
+ // * Bound to 127.0.0.1 only. Never bind 0.0.0.0 — the ext-proxy is the only
13
+ // intended caller and it dials loopback.
14
+ // * Every route except `GET /health` requires `Authorization: Bearer <token>` —
15
+ // the per-plugin secret Core mints and injects at spawn (`RYU_EXT_TOKEN`);
16
+ // `__APP_TOKEN_ENV__` overrides it for standalone/dev runs. Neither set ⇒
17
+ // FAIL-CLOSED (every protected route 401s). A sidecar that skips this check is
18
+ // an unauthenticated RCE surface for anything else running on the machine —
19
+ // loopback is not a trust boundary on a multi-user or agent-laden host.
20
+ //
21
+ // The router (`handleRequest`) is a pure function over an injected `ItemStore`,
22
+ // so it unit-tests with a fake — no sockets, no process.
23
+
24
+ import { randomUUID, timingSafeEqual } from "node:crypto";
25
+ import { createServer, type Server } from "node:http";
26
+
27
+ /** Default loopback port. There is NO port registry: picking a free one is the
28
+ * app author's job. Stay clear of Core (:7980), the Gateway (:7981), the
29
+ * built-in sidecar band (:7990–:8003), and the local engines (:8080–:8087). */
30
+ const CONTROL_BASE_PORT = 7899;
31
+
32
+ /** A Core running under `RYU_PROFILE=dev` shifts every port by this much so a dev
33
+ * node and a release node coexist. Core injects the shifted value through
34
+ * `__APP_PORT_ENV__`; this fallback only matters when the sidecar is run by hand. */
35
+ const DEV_PORT_OFFSET = 1000;
36
+
37
+ const PACKAGE_VERSION = "0.1.0";
38
+
39
+ /** The capability this sidecar serves — must equal `provides[].capability` in the
40
+ * manifest, which is what a consuming app names in its `requires.capabilities`. */
41
+ export const CAPABILITY = "__APP_NAME__.control";
42
+
43
+ /** One `/items` row. Replace this with whatever your app actually owns. */
44
+ export interface Item {
45
+ createdAt: string;
46
+ id: string;
47
+ text: string;
48
+ }
49
+
50
+ /** The domain the router drives. Injected so the router can be tested against a
51
+ * fake without touching the real backing store. */
52
+ export interface ItemStore {
53
+ create(text: string): Item;
54
+ get(id: string): Item | null;
55
+ list(): Item[];
56
+ remove(id: string): boolean;
57
+ }
58
+
59
+ /** The default store — in-memory, so the scaffold runs with zero setup. Swap it
60
+ * for a real one (SQLite, a file, a vendor SDK) without touching the router. */
61
+ export class MemoryItemStore implements ItemStore {
62
+ private readonly items = new Map<string, Item>();
63
+
64
+ list(): Item[] {
65
+ return [...this.items.values()];
66
+ }
67
+
68
+ get(id: string): Item | null {
69
+ return this.items.get(id) ?? null;
70
+ }
71
+
72
+ create(text: string): Item {
73
+ const item: Item = {
74
+ id: randomUUID(),
75
+ text,
76
+ createdAt: new Date().toISOString(),
77
+ };
78
+ this.items.set(item.id, item);
79
+ return item;
80
+ }
81
+
82
+ remove(id: string): boolean {
83
+ return this.items.delete(id);
84
+ }
85
+ }
86
+
87
+ /** The bind port: whatever Core injected, else the profile-aware default. */
88
+ export function resolveControlPort(
89
+ env: NodeJS.ProcessEnv = process.env
90
+ ): number {
91
+ const explicit = Number.parseInt(env.__APP_PORT_ENV__ ?? "", 10);
92
+ if (Number.isInteger(explicit) && explicit > 0) {
93
+ return explicit;
94
+ }
95
+ const isDev = (env.RYU_PROFILE ?? "").trim().toLowerCase() === "dev";
96
+ return isDev ? CONTROL_BASE_PORT + DEV_PORT_OFFSET : CONTROL_BASE_PORT;
97
+ }
98
+
99
+ /** The expected bearer, or `null` when unset — which fails every protected route
100
+ * closed rather than serving the surface unauthenticated. */
101
+ export function resolveControlToken(
102
+ env: NodeJS.ProcessEnv = process.env
103
+ ): string | null {
104
+ const raw = env.RYU_EXT_TOKEN ?? env.__APP_TOKEN_ENV__ ?? "";
105
+ const trimmed = raw.trim();
106
+ return trimmed.length > 0 ? trimmed : null;
107
+ }
108
+
109
+ /** Constant-time bearer check. `null`/empty `expected` ⇒ fail-closed (reject all).
110
+ * Compared with `timingSafeEqual`, not `===`: a byte-at-a-time comparison leaks
111
+ * the token one character per request to a caller that can time the loopback. */
112
+ export function bearerOk(
113
+ authHeader: string | undefined,
114
+ expected: string | null
115
+ ): boolean {
116
+ if (!expected) {
117
+ return false;
118
+ }
119
+ const presented = authHeader?.startsWith("Bearer ")
120
+ ? authHeader.slice("Bearer ".length)
121
+ : null;
122
+ if (!presented) {
123
+ return false;
124
+ }
125
+ const a = Buffer.from(presented, "utf8");
126
+ const b = Buffer.from(expected, "utf8");
127
+ if (a.length !== b.length) {
128
+ return false;
129
+ }
130
+ return timingSafeEqual(a, b);
131
+ }
132
+
133
+ export interface ControlResponse {
134
+ json?: unknown;
135
+ status: number;
136
+ }
137
+
138
+ export interface RequestDeps {
139
+ store: ItemStore;
140
+ token: string | null;
141
+ }
142
+
143
+ /** `/items/<id>` — a top-level literal (lint/performance/useTopLevelRegex). */
144
+ const RE_ITEM_ID = /^\/items\/([^/]+)$/;
145
+
146
+ function notFound(): ControlResponse {
147
+ return { status: 404, json: { ok: false, error: "not found" } };
148
+ }
149
+
150
+ function badRequest(error: string): ControlResponse {
151
+ return { status: 400, json: { ok: false, error } };
152
+ }
153
+
154
+ /** Parse a JSON body. `""` (no body) is an empty object; malformed is `null`. */
155
+ function parseJsonBody(raw: string): Record<string, unknown> | null {
156
+ if (!raw) {
157
+ return {};
158
+ }
159
+ try {
160
+ const parsed: unknown = JSON.parse(raw);
161
+ return typeof parsed === "object" && parsed !== null
162
+ ? (parsed as Record<string, unknown>)
163
+ : null;
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+
169
+ function handleItemsCollection(
170
+ method: string,
171
+ body: string,
172
+ store: ItemStore
173
+ ): ControlResponse {
174
+ if (method === "GET") {
175
+ return { status: 200, json: { ok: true, items: store.list() } };
176
+ }
177
+ if (method !== "POST") {
178
+ return notFound();
179
+ }
180
+ const payload = parseJsonBody(body);
181
+ if (!payload) {
182
+ return badRequest("body must be a JSON object");
183
+ }
184
+ const text = typeof payload.text === "string" ? payload.text.trim() : "";
185
+ if (!text) {
186
+ return badRequest("missing text");
187
+ }
188
+ return { status: 201, json: { ok: true, item: store.create(text) } };
189
+ }
190
+
191
+ function handleItem(
192
+ method: string,
193
+ id: string,
194
+ store: ItemStore
195
+ ): ControlResponse {
196
+ if (method === "GET") {
197
+ const item = store.get(id);
198
+ return item
199
+ ? { status: 200, json: { ok: true, item } }
200
+ : { status: 404, json: { ok: false, error: "unknown item" } };
201
+ }
202
+ if (method === "DELETE") {
203
+ return store.remove(id)
204
+ ? { status: 200, json: { ok: true } }
205
+ : { status: 404, json: { ok: false, error: "unknown item" } };
206
+ }
207
+ return notFound();
208
+ }
209
+
210
+ /**
211
+ * Pure request router. `path` carries no query string; `body` is the raw request
212
+ * body. Every route except `GET /health` is bearer-gated.
213
+ *
214
+ * The paths handled here MUST stay in sync with `sidecars[].http.routes[]` in the
215
+ * manifest: Core 404s any sub-path the manifest does not declare (undeclared paths
216
+ * are never forwarded), so a route added here but not there is simply unreachable
217
+ * through the ext-proxy — and one declared there but missing here 404s from the
218
+ * sidecar instead.
219
+ */
220
+ export function handleRequest(
221
+ method: string,
222
+ path: string,
223
+ authHeader: string | undefined,
224
+ body: string,
225
+ deps: RequestDeps
226
+ ): ControlResponse {
227
+ // Unauthenticated on purpose: Core's health monitor probes this before the
228
+ // plugin's token is in play, and it reveals nothing but liveness.
229
+ if (path === "/health") {
230
+ return { status: 200, json: { ok: true, version: PACKAGE_VERSION } };
231
+ }
232
+
233
+ if (!bearerOk(authHeader, deps.token)) {
234
+ return { status: 401, json: { ok: false, error: "unauthorized" } };
235
+ }
236
+
237
+ // The capability root — `provides[].route` points here, so the broker hits it
238
+ // to describe the capability.
239
+ if (path === "/" && method === "GET") {
240
+ return {
241
+ status: 200,
242
+ json: {
243
+ ok: true,
244
+ capability: CAPABILITY,
245
+ version: PACKAGE_VERSION,
246
+ routes: ["/health", "/items", "/items/:id"],
247
+ },
248
+ };
249
+ }
250
+
251
+ if (path === "/items") {
252
+ return handleItemsCollection(method, body, deps.store);
253
+ }
254
+
255
+ const match = RE_ITEM_ID.exec(path);
256
+ if (match?.[1]) {
257
+ return handleItem(method, decodeURIComponent(match[1]), deps.store);
258
+ }
259
+
260
+ return notFound();
261
+ }
262
+
263
+ /** Start the loopback control server. A bind failure logs and leaves the process
264
+ * up so Core's health probe reports it unhealthy instead of racing a respawn. */
265
+ export function startControlServer(deps: RequestDeps, port: number): Server {
266
+ const server = createServer((req, res) => {
267
+ const chunks: Buffer[] = [];
268
+ req.on("data", (c) => chunks.push(c as Buffer));
269
+ req.on("end", () => {
270
+ const body = Buffer.concat(chunks).toString("utf8");
271
+ const path = (req.url ?? "/").split("?")[0] ?? "/";
272
+ const resp = handleRequest(
273
+ req.method ?? "GET",
274
+ path,
275
+ req.headers.authorization,
276
+ body,
277
+ deps
278
+ );
279
+ res.writeHead(resp.status, { "Content-Type": "application/json" });
280
+ res.end(JSON.stringify(resp.json ?? {}));
281
+ });
282
+ });
283
+ server.on("error", (err) => {
284
+ // biome-ignore lint/suspicious/noConsole: main-process diagnostic, no renderer.
285
+ console.warn(
286
+ `[ryu-__APP_NAME__] control server unavailable: ${err.message}`
287
+ );
288
+ });
289
+ server.listen(port, "127.0.0.1");
290
+ return server;
291
+ }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bun
2
+ // __APP_DISPLAY_NAME__ sidecar — entrypoint.
3
+ //
4
+ // A dependency-free Node/Bun process Core spawns as a `local` manifest sidecar.
5
+ // It owns ONE loopback HTTP control server (`control.ts`) and nothing else: no
6
+ // import from `apps/core`, no Ryu SDK, no shared workspace package. That is the
7
+ // satellite contract — this directory must build and ship from its own tree.
8
+ //
9
+ // bun run src/main/index.ts # standalone (set __APP_TOKEN_ENV__ first)
10
+ // bun run build # → dist/ryu-__APP_NAME__, the `command` the
11
+ // # manifest's sidecars[].process names
12
+
13
+ import {
14
+ MemoryItemStore,
15
+ resolveControlPort,
16
+ resolveControlToken,
17
+ startControlServer,
18
+ } from "./control.ts";
19
+
20
+ function main(): void {
21
+ const port = resolveControlPort();
22
+ const token = resolveControlToken();
23
+ if (!token) {
24
+ // Fail-closed is enforced per-request; warn once so a misconfigured spawn is
25
+ // diagnosable rather than silently 401ing every call.
26
+ // biome-ignore lint/suspicious/noConsole: main-process diagnostic, no renderer.
27
+ console.warn(
28
+ "[ryu-__APP_NAME__] no RYU_EXT_TOKEN/__APP_TOKEN_ENV__ set — all control routes will 401"
29
+ );
30
+ }
31
+ startControlServer({ store: new MemoryItemStore(), token }, port);
32
+ // biome-ignore lint/suspicious/noConsole: main-process diagnostic, no renderer.
33
+ console.log(`[ryu-__APP_NAME__] control server on 127.0.0.1:${port}`);
34
+ }
35
+
36
+ main();
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022"],
5
+ "module": "ESNext",
6
+ "skipLibCheck": true,
7
+ "moduleResolution": "bundler",
8
+ "allowImportingTsExtensions": true,
9
+ "resolveJsonModule": true,
10
+ "isolatedModules": true,
11
+ "noEmit": true,
12
+ "strict": true,
13
+ "noUnusedLocals": true,
14
+ "noUnusedParameters": true,
15
+ "noFallthroughCasesInSwitch": true,
16
+ "baseUrl": ".",
17
+ "types": ["node", "bun"]
18
+ },
19
+ "include": ["src"]
20
+ }
@@ -37,7 +37,7 @@
37
37
  }
38
38
  }
39
39
  ],
40
- "permission_grants": [],
40
+ "permission_grants": ["widget:render"],
41
41
  "activation_events": ["*"],
42
42
  "contributes": {
43
43
  "turn_hooks": [],
@@ -13,7 +13,7 @@
13
13
  * can never impersonate first-party Ryu/system chrome.
14
14
  *
15
15
  * bun run src/app.ts # prints the assembled manifest
16
- * bunx ryu pack . # bundles src/widget.tsx into ui_code + writes plugin.json
16
+ * bunx ryu pack . # bundles src/widget.tsx into ui_code + writes manifest.json
17
17
  *
18
18
  * v1 boundary: DECLARATIVE PASS-THROUGH only — there is no `run` handler for the
19
19
  * tools. The render widget draws from `window.openai.toolInput` / `toolOutput`;
@@ -15,7 +15,7 @@
15
15
  * variables — reference only `ctx`, `host`, and language built-ins.
16
16
  *
17
17
  * bun run src/plugin.ts # prints the assembled manifest
18
- * bunx ryu pack . # serializes the hook + writes plugin.json
18
+ * bunx ryu pack . # serializes the hook + writes manifest.json
19
19
  */
20
20
 
21
21
  import { definePlugin, defineTurnHook } from "@ryuhq/sdk";
@@ -34,7 +34,7 @@
34
34
  }
35
35
  }
36
36
  ],
37
- "permission_grants": [],
37
+ "permission_grants": ["widget:render"],
38
38
  "activation_events": ["*"],
39
39
  "contributes": {
40
40
  "turn_hooks": [],
@@ -3,10 +3,10 @@
3
3
  *
4
4
  * A Ryu App bundles a "render" tool whose result mounts a self-contained widget
5
5
  * inline in the chat reply. This module is the AUTHORING source of truth: it uses
6
- * `defineApp` to assemble the `plugin.json` manifest that ships alongside it.
6
+ * `defineApp` to assemble the `manifest.json` manifest that ships alongside it.
7
7
  *
8
8
  * bun run src/app.ts # prints the assembled manifest
9
- * bunx ryu pack . # bundles src/widget.tsx into ui_code + writes plugin.json
9
+ * bunx ryu pack . # bundles src/widget.tsx into ui_code + writes manifest.json
10
10
  *
11
11
  * v1 boundary: this is DECLARATIVE PASS-THROUGH only. There is no `run` handler —
12
12
  * the widget renders from `window.openai.toolInput` / `toolOutput` (the arguments
File without changes