@rynx-ai/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channel-manager.d.ts +60 -0
- package/dist/channel-manager.js +106 -0
- package/dist/control-api.d.ts +188 -0
- package/dist/control-api.js +834 -0
- package/dist/control-web-dist.d.ts +6 -0
- package/dist/control-web-dist.js +63 -0
- package/dist/emulator-touch-ws.d.ts +8 -0
- package/dist/emulator-touch-ws.js +87 -0
- package/dist/server.d.ts +66 -0
- package/dist/server.js +224 -0
- package/dist/terminal-ws.d.ts +25 -0
- package/dist/terminal-ws.js +123 -0
- package/package.json +38 -0
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control-plane HTTP surface: a JSON + SSE API (and the static console) for
|
|
3
|
+
* managing **channels** (a plugin type + its credentials) and **instances** (a
|
|
4
|
+
* channel bound to an agent), plus declarative agents. Mounted by
|
|
5
|
+
* {@link import("./server.js").createApp} only when control deps are injected.
|
|
6
|
+
*
|
|
7
|
+
* Channel and instance are separate objects:
|
|
8
|
+
* - a **channel** is configured/authorized on its own (`/api/channels` +
|
|
9
|
+
* `/api/channels/:id/authorize`); credentials live in its `options`.
|
|
10
|
+
* - an **instance** binds a channel to an agent (`/api/instances`); it's what
|
|
11
|
+
* the daemon mounts and runs.
|
|
12
|
+
*
|
|
13
|
+
* The server is channel-agnostic: available channel *types* (with their declared
|
|
14
|
+
* config schema / authorize capability) come from {@link ControlPlaneDeps.listChannelTypes},
|
|
15
|
+
* which the daemon derives from loaded plugins. Plugins themselves are managed
|
|
16
|
+
* via the CLI, not here. The daemon owns the db-backed mutators (injected as
|
|
17
|
+
* {@link ControlPlaneDeps}); the server owns the {@link ChannelManager}.
|
|
18
|
+
*/
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import Router from "@koa/router";
|
|
21
|
+
import { agentSpecSchema, newSessionId, persistSessionEvent, skillInstallRecipeSchema, } from "@rynx-ai/core";
|
|
22
|
+
import { ensureCodexResumeRollout } from "@rynx-ai/runtime";
|
|
23
|
+
export function createControlRouter(opts) {
|
|
24
|
+
const { manager, deps, runtime, sessionBus, sessionLog, runnerManager, sessionStore } = opts;
|
|
25
|
+
const router = new Router();
|
|
26
|
+
// claude-native PermissionRequest long-polls: approvalId → resolve the held
|
|
27
|
+
// hook response with claude's verdict. The hook POST blocks until the web user
|
|
28
|
+
// answers via `/approvals/:approvalId`, or the hook client disconnects.
|
|
29
|
+
const claudeApprovals = new Map();
|
|
30
|
+
/** The instance (if any) bound to a channel — used to reload after a config change. */
|
|
31
|
+
const instanceForChannel = (channelId) => deps.listInstancesConfig().find((i) => i.channelId === channelId);
|
|
32
|
+
router.get("/api/meta", (ctx) => {
|
|
33
|
+
const meta = {
|
|
34
|
+
channelTypes: deps.listChannelTypes().map((t) => t.type),
|
|
35
|
+
};
|
|
36
|
+
ctx.body = meta;
|
|
37
|
+
});
|
|
38
|
+
router.get("/api/channel-types", (ctx) => {
|
|
39
|
+
ctx.body = { channelTypes: deps.listChannelTypes() };
|
|
40
|
+
});
|
|
41
|
+
// ── channels ───────────────────────────────────────────────────────────
|
|
42
|
+
router.get("/api/channels", (ctx) => {
|
|
43
|
+
ctx.body = {
|
|
44
|
+
channels: deps.listChannels().map((c) => ({
|
|
45
|
+
id: c.id,
|
|
46
|
+
name: c.name,
|
|
47
|
+
type: c.type,
|
|
48
|
+
options: redact(deps, c.type, c.options),
|
|
49
|
+
})),
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
router.post("/api/channels", async (ctx) => {
|
|
53
|
+
const body = await readJson(ctx);
|
|
54
|
+
const name = asString(body.name);
|
|
55
|
+
const type = asString(body.type);
|
|
56
|
+
if (!name)
|
|
57
|
+
return bad(ctx, "missing channel name");
|
|
58
|
+
if (!type)
|
|
59
|
+
return bad(ctx, "missing channel type");
|
|
60
|
+
const { id } = deps.createChannel({ name, type, options: asRecord(body.options) });
|
|
61
|
+
ctx.body = { ok: true, id };
|
|
62
|
+
});
|
|
63
|
+
router.put("/api/channels/:id", async (ctx) => {
|
|
64
|
+
const id = ctx.params.id;
|
|
65
|
+
const body = await readJson(ctx);
|
|
66
|
+
deps.setChannel(id, {
|
|
67
|
+
...(body.name !== undefined ? { name: asString(body.name) } : {}),
|
|
68
|
+
...(body.options !== undefined ? { options: asRecord(body.options) } : {}),
|
|
69
|
+
});
|
|
70
|
+
const bound = instanceForChannel(id);
|
|
71
|
+
if (bound)
|
|
72
|
+
await manager.reloadInstance(bound.id);
|
|
73
|
+
ctx.body = { ok: true };
|
|
74
|
+
});
|
|
75
|
+
router.delete("/api/channels/:id", async (ctx) => {
|
|
76
|
+
const id = ctx.params.id;
|
|
77
|
+
const bound = instanceForChannel(id);
|
|
78
|
+
if (bound)
|
|
79
|
+
await manager.stopInstance(bound.id); // its row cascades on channel delete
|
|
80
|
+
deps.removeChannel(id);
|
|
81
|
+
ctx.body = { ok: true };
|
|
82
|
+
});
|
|
83
|
+
// Authorize (config mode 2): run the channel type's flow over SSE, persist the
|
|
84
|
+
// resolved options on the channel, reload the bound instance (if any).
|
|
85
|
+
router.post("/api/channels/:id/authorize", async (ctx) => {
|
|
86
|
+
const id = ctx.params.id;
|
|
87
|
+
const channel = deps.listChannels().find((c) => c.id === id);
|
|
88
|
+
if (!channel)
|
|
89
|
+
return notFound(ctx, "channel not found");
|
|
90
|
+
await streamEvents(ctx, async (send, signal) => {
|
|
91
|
+
send("status", { text: `authorizing ${channel.type}…` });
|
|
92
|
+
const options = await deps.authorizeChannel(channel.type, (m) => send(m.kind, m), signal);
|
|
93
|
+
deps.setChannel(id, { options });
|
|
94
|
+
const bound = instanceForChannel(id);
|
|
95
|
+
if (bound)
|
|
96
|
+
await manager.reloadInstance(bound.id);
|
|
97
|
+
send("done", { id });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
// ── instances ──────────────────────────────────────────────────────────
|
|
101
|
+
router.get("/api/instances", (ctx) => {
|
|
102
|
+
const statuses = new Map(manager.list().map((status) => [status.instanceId, status]));
|
|
103
|
+
const instances = deps.listInstancesConfig().map((inst) => {
|
|
104
|
+
const status = statuses.get(inst.id);
|
|
105
|
+
return {
|
|
106
|
+
id: inst.id,
|
|
107
|
+
channelId: inst.channelId,
|
|
108
|
+
channelName: inst.channelName,
|
|
109
|
+
type: inst.type,
|
|
110
|
+
agent: inst.agent,
|
|
111
|
+
enabled: inst.enabled,
|
|
112
|
+
running: status?.running ?? false,
|
|
113
|
+
connected: status?.connected ?? null,
|
|
114
|
+
lastError: status?.lastError ?? null,
|
|
115
|
+
lastEventAt: status?.lastEventAt ?? null,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
ctx.body = { instances };
|
|
119
|
+
});
|
|
120
|
+
router.post("/api/instances", async (ctx) => {
|
|
121
|
+
const body = await readJson(ctx);
|
|
122
|
+
const channelId = asString(body.channelId);
|
|
123
|
+
if (!channelId)
|
|
124
|
+
return bad(ctx, "missing channelId");
|
|
125
|
+
let id;
|
|
126
|
+
try {
|
|
127
|
+
({ id } = deps.createInstance({ channelId, agent: asString(body.agent) }));
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
return bad(ctx, error instanceof Error ? error.message : String(error));
|
|
131
|
+
}
|
|
132
|
+
await manager.reloadInstance(id);
|
|
133
|
+
ctx.body = { ok: true, id };
|
|
134
|
+
});
|
|
135
|
+
router.put("/api/instances/:id", async (ctx) => {
|
|
136
|
+
const id = ctx.params.id;
|
|
137
|
+
const body = await readJson(ctx);
|
|
138
|
+
deps.setInstance(id, {
|
|
139
|
+
...(body.agent !== undefined ? { agent: asString(body.agent) } : {}),
|
|
140
|
+
...(body.enabled !== undefined ? { enabled: Boolean(body.enabled) } : {}),
|
|
141
|
+
});
|
|
142
|
+
await manager.reloadInstance(id);
|
|
143
|
+
ctx.body = { ok: true };
|
|
144
|
+
});
|
|
145
|
+
router.post("/api/instances/:id/enable", async (ctx) => {
|
|
146
|
+
const id = ctx.params.id;
|
|
147
|
+
deps.setInstance(id, { enabled: true });
|
|
148
|
+
await manager.startInstance(id);
|
|
149
|
+
ctx.body = { ok: true };
|
|
150
|
+
});
|
|
151
|
+
router.post("/api/instances/:id/disable", async (ctx) => {
|
|
152
|
+
const id = ctx.params.id;
|
|
153
|
+
deps.setInstance(id, { enabled: false });
|
|
154
|
+
await manager.stopInstance(id);
|
|
155
|
+
ctx.body = { ok: true };
|
|
156
|
+
});
|
|
157
|
+
router.post("/api/instances/:id/restart", async (ctx) => {
|
|
158
|
+
await manager.reloadInstance(ctx.params.id);
|
|
159
|
+
ctx.body = { ok: true };
|
|
160
|
+
});
|
|
161
|
+
router.delete("/api/instances/:id", async (ctx) => {
|
|
162
|
+
const id = ctx.params.id;
|
|
163
|
+
await manager.stopInstance(id);
|
|
164
|
+
deps.removeInstance(id);
|
|
165
|
+
ctx.body = { ok: true };
|
|
166
|
+
});
|
|
167
|
+
// ── agents ─────────────────────────────────────────────────────────────
|
|
168
|
+
router.get("/api/agents", async (ctx) => {
|
|
169
|
+
ctx.body = { agents: await deps.listAgents() };
|
|
170
|
+
});
|
|
171
|
+
router.get("/api/agents/:id", async (ctx) => {
|
|
172
|
+
const spec = await deps.getAgent(ctx.params.id);
|
|
173
|
+
if (!spec)
|
|
174
|
+
return notFound(ctx, "agent not found");
|
|
175
|
+
ctx.body = spec;
|
|
176
|
+
});
|
|
177
|
+
router.post("/api/agents", async (ctx) => {
|
|
178
|
+
const body = await readJson(ctx);
|
|
179
|
+
const id = asString(body.id);
|
|
180
|
+
if (!id)
|
|
181
|
+
return bad(ctx, "missing agent id");
|
|
182
|
+
try {
|
|
183
|
+
deps.writeAgent(id, body);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
return bad(ctx, error instanceof Error ? error.message : String(error));
|
|
187
|
+
}
|
|
188
|
+
ctx.body = { ok: true };
|
|
189
|
+
});
|
|
190
|
+
router.delete("/api/agents/:id", (ctx) => {
|
|
191
|
+
ctx.body = { ok: deps.removeAgent(ctx.params.id) };
|
|
192
|
+
});
|
|
193
|
+
// ── skills catalog (global, ~/.rynx/skills) ──────────────────────────────
|
|
194
|
+
router.get("/api/skills", async (ctx) => {
|
|
195
|
+
ctx.body = { skills: await deps.listSkills() };
|
|
196
|
+
});
|
|
197
|
+
router.get("/api/skills/:name/file", async (ctx) => {
|
|
198
|
+
const file = asString(ctx.query.path);
|
|
199
|
+
if (!file)
|
|
200
|
+
return bad(ctx, "missing path");
|
|
201
|
+
try {
|
|
202
|
+
ctx.body = await deps.readSkillFile(ctx.params.name, file);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
return bad(ctx, error instanceof Error ? error.message : String(error));
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
router.get("/api/skills/:name", async (ctx) => {
|
|
209
|
+
const detail = await deps.getSkill(ctx.params.name);
|
|
210
|
+
if (!detail)
|
|
211
|
+
return notFound(ctx, "skill not found");
|
|
212
|
+
ctx.body = detail;
|
|
213
|
+
});
|
|
214
|
+
router.post("/api/skills", async (ctx) => {
|
|
215
|
+
// The body is a full SkillInstallRecipe — the same object a receipt records
|
|
216
|
+
// and a SkillRef declares. Adding a new install method never changes this API.
|
|
217
|
+
const body = await readJson(ctx);
|
|
218
|
+
const recipe = skillInstallRecipeSchema.safeParse(body);
|
|
219
|
+
if (!recipe.success) {
|
|
220
|
+
return bad(ctx, `invalid install recipe: ${recipe.error.issues.map((i) => i.message).join("; ")}`);
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
ctx.body = { skills: await deps.installSkill(recipe.data) };
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
return bad(ctx, error instanceof Error ? error.message : String(error));
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
router.delete("/api/skills/:name", async (ctx) => {
|
|
230
|
+
try {
|
|
231
|
+
ctx.body = { ok: await deps.removeSkill(ctx.params.name) };
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
return bad(ctx, error instanceof Error ? error.message : String(error));
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
router.get("/api/logs", async (ctx) => {
|
|
238
|
+
const instance = asString(ctx.query.instance);
|
|
239
|
+
const lines = Number(ctx.query.lines) || 200;
|
|
240
|
+
ctx.body = { logs: await deps.tailLogs(instance, lines) };
|
|
241
|
+
});
|
|
242
|
+
// ── emulator proxy ───────────────────────────────────────────────────────
|
|
243
|
+
// These endpoints deliberately live in the daemon/control process. Sandboxed
|
|
244
|
+
// agent children can call localhost while daemon owns macOS CoreSimulator/adb.
|
|
245
|
+
const emulatorOr404 = (ctx) => {
|
|
246
|
+
if (!deps.emulator) {
|
|
247
|
+
notFound(ctx, "emulator API is not available on this server");
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
return deps.emulator;
|
|
251
|
+
};
|
|
252
|
+
const emulatorResult = async (ctx, run) => {
|
|
253
|
+
const emulator = emulatorOr404(ctx);
|
|
254
|
+
if (!emulator)
|
|
255
|
+
return;
|
|
256
|
+
try {
|
|
257
|
+
ctx.body = await run(emulator);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
bad(ctx, error instanceof Error ? error.message : String(error));
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
// Per-session device scoping (orca's per-worktree active emulator, keyed by
|
|
264
|
+
// rynx session id riding the `x-rynx-session` header): an attach from a
|
|
265
|
+
// session binds it to the attached device; later session commands without an
|
|
266
|
+
// explicit `device` resolve against the binding. Sharing is allowed (N
|
|
267
|
+
// sessions → one device); explicit `device` always wins; sessions without a
|
|
268
|
+
// binding fall back to the global active. In-memory by design: on a daemon
|
|
269
|
+
// restart agents just re-attach (the skill mandates attach-before-use).
|
|
270
|
+
const emulatorSessionDevices = new Map();
|
|
271
|
+
const emulatorSessionOf = (ctx) => ctx.get("x-rynx-session")?.trim() || undefined;
|
|
272
|
+
const emulatorScopedDevice = (ctx, body) => {
|
|
273
|
+
const explicit = asString(body.device);
|
|
274
|
+
if (explicit)
|
|
275
|
+
return explicit;
|
|
276
|
+
const sessionId = emulatorSessionOf(ctx);
|
|
277
|
+
return sessionId ? emulatorSessionDevices.get(sessionId) : undefined;
|
|
278
|
+
};
|
|
279
|
+
const emulatorUnbindDevice = (device) => {
|
|
280
|
+
for (const [sessionId, bound] of emulatorSessionDevices) {
|
|
281
|
+
if (bound === device)
|
|
282
|
+
emulatorSessionDevices.delete(sessionId);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
router.get("/api/emulator/doctor", async (ctx) => {
|
|
286
|
+
await emulatorResult(ctx, (emulator) => emulator.doctor());
|
|
287
|
+
});
|
|
288
|
+
router.get("/api/emulator/devices", async (ctx) => {
|
|
289
|
+
await emulatorResult(ctx, async (emulator) => ({ devices: await emulator.devices() }));
|
|
290
|
+
});
|
|
291
|
+
router.get("/api/emulator/active", async (ctx) => {
|
|
292
|
+
await emulatorResult(ctx, async (emulator) => {
|
|
293
|
+
const device = emulatorScopedDevice(ctx, {});
|
|
294
|
+
if (device && emulator.deviceSession) {
|
|
295
|
+
return { active: await emulator.deviceSession({ device }) };
|
|
296
|
+
}
|
|
297
|
+
return { active: await emulator.active() };
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
router.post("/api/emulator/attach", async (ctx) => {
|
|
301
|
+
const body = await readJson(ctx);
|
|
302
|
+
const sessionId = emulatorSessionOf(ctx);
|
|
303
|
+
await emulatorResult(ctx, async (emulator) => {
|
|
304
|
+
const session = (await emulator.attach(asString(body.device)));
|
|
305
|
+
if (sessionId && typeof session?.deviceUdid === "string") {
|
|
306
|
+
emulatorSessionDevices.set(sessionId, session.deviceUdid);
|
|
307
|
+
}
|
|
308
|
+
return session;
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
router.post("/api/emulator/tap", async (ctx) => {
|
|
312
|
+
const body = await readJson(ctx);
|
|
313
|
+
const x = asNumber(body.x);
|
|
314
|
+
const y = asNumber(body.y);
|
|
315
|
+
if (x == null || y == null)
|
|
316
|
+
return bad(ctx, "tap requires numeric x and y");
|
|
317
|
+
await emulatorResult(ctx, (emulator) => emulator.tap({ x, y, device: emulatorScopedDevice(ctx, body) }));
|
|
318
|
+
});
|
|
319
|
+
router.post("/api/emulator/type", async (ctx) => {
|
|
320
|
+
const body = await readJson(ctx);
|
|
321
|
+
const text = asString(body.text);
|
|
322
|
+
if (!text)
|
|
323
|
+
return bad(ctx, "type requires text");
|
|
324
|
+
await emulatorResult(ctx, (emulator) => emulator.type({ text, device: emulatorScopedDevice(ctx, body), mode: asString(body.mode) }));
|
|
325
|
+
});
|
|
326
|
+
router.post("/api/emulator/button", async (ctx) => {
|
|
327
|
+
const body = await readJson(ctx);
|
|
328
|
+
await emulatorResult(ctx, (emulator) => emulator.button({ name: asString(body.name), device: emulatorScopedDevice(ctx, body) }));
|
|
329
|
+
});
|
|
330
|
+
router.post("/api/emulator/rotate", async (ctx) => {
|
|
331
|
+
const body = await readJson(ctx);
|
|
332
|
+
const orientation = asString(body.orientation);
|
|
333
|
+
if (!orientation)
|
|
334
|
+
return bad(ctx, "rotate requires orientation");
|
|
335
|
+
await emulatorResult(ctx, (emulator) => emulator.rotate({ orientation, device: emulatorScopedDevice(ctx, body) }));
|
|
336
|
+
});
|
|
337
|
+
router.post("/api/emulator/launch", async (ctx) => {
|
|
338
|
+
const body = await readJson(ctx);
|
|
339
|
+
const appId = asString(body.appId) ?? asString(body.bundleId) ?? asString(body.packageName);
|
|
340
|
+
if (!appId)
|
|
341
|
+
return bad(ctx, "launch requires appId");
|
|
342
|
+
await emulatorResult(ctx, (emulator) => emulator.launch({ appId, device: emulatorScopedDevice(ctx, body) }));
|
|
343
|
+
});
|
|
344
|
+
router.post("/api/emulator/gesture", async (ctx) => {
|
|
345
|
+
const body = await readJson(ctx);
|
|
346
|
+
const points = body.points;
|
|
347
|
+
if (!Array.isArray(points))
|
|
348
|
+
return bad(ctx, "gesture requires points");
|
|
349
|
+
await emulatorResult(ctx, (emulator) => emulator.gesture({ points, device: emulatorScopedDevice(ctx, body) }));
|
|
350
|
+
});
|
|
351
|
+
router.post("/api/emulator/exec", async (ctx) => {
|
|
352
|
+
const body = await readJson(ctx);
|
|
353
|
+
const command = asString(body.command);
|
|
354
|
+
if (!command)
|
|
355
|
+
return bad(ctx, "exec requires command");
|
|
356
|
+
await emulatorResult(ctx, (emulator) => emulator.exec({ command, device: emulatorScopedDevice(ctx, body) }));
|
|
357
|
+
});
|
|
358
|
+
router.post("/api/emulator/screenshot", async (ctx) => {
|
|
359
|
+
const body = await readJson(ctx);
|
|
360
|
+
await emulatorResult(ctx, (emulator) => emulator.screenshot({ device: emulatorScopedDevice(ctx, body), out: asString(body.out) }));
|
|
361
|
+
});
|
|
362
|
+
router.get("/api/emulator/frame", async (ctx) => {
|
|
363
|
+
const emulator = emulatorOr404(ctx);
|
|
364
|
+
if (!emulator)
|
|
365
|
+
return;
|
|
366
|
+
if (!emulator.frame) {
|
|
367
|
+
return notFound(ctx, "emulator frame API is not available on this server");
|
|
368
|
+
}
|
|
369
|
+
const body = { device: asString(ctx.query.device) };
|
|
370
|
+
try {
|
|
371
|
+
const frame = await emulator.frame({ device: emulatorScopedDevice(ctx, body) });
|
|
372
|
+
ctx.type = frame.contentType;
|
|
373
|
+
ctx.set("Cache-Control", "no-store");
|
|
374
|
+
ctx.body = frame.data;
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
bad(ctx, error instanceof Error ? error.message : String(error));
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
router.get("/api/emulator/video", async (ctx) => {
|
|
381
|
+
const emulator = emulatorOr404(ctx);
|
|
382
|
+
if (!emulator)
|
|
383
|
+
return;
|
|
384
|
+
if (!emulator.video) {
|
|
385
|
+
return notFound(ctx, "emulator video API is not available on this server");
|
|
386
|
+
}
|
|
387
|
+
const body = { device: asString(ctx.query.device) };
|
|
388
|
+
try {
|
|
389
|
+
const video = await emulator.video({ device: emulatorScopedDevice(ctx, body) });
|
|
390
|
+
let stopped = false;
|
|
391
|
+
const stop = () => {
|
|
392
|
+
if (stopped)
|
|
393
|
+
return;
|
|
394
|
+
stopped = true;
|
|
395
|
+
video.stop();
|
|
396
|
+
};
|
|
397
|
+
ctx.respond = false;
|
|
398
|
+
const res = ctx.res;
|
|
399
|
+
res.statusCode = 200;
|
|
400
|
+
res.setHeader("Content-Type", video.contentType);
|
|
401
|
+
res.setHeader("Cache-Control", "no-store");
|
|
402
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
403
|
+
res.on("close", stop);
|
|
404
|
+
video.stream.on("end", stop);
|
|
405
|
+
video.stream.on("error", (error) => {
|
|
406
|
+
stop();
|
|
407
|
+
if (!res.destroyed)
|
|
408
|
+
res.destroy(error);
|
|
409
|
+
});
|
|
410
|
+
video.stream.pipe(res);
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
bad(ctx, error instanceof Error ? error.message : String(error));
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
router.get("/api/emulator/screenshot-file", async (ctx) => {
|
|
417
|
+
const emulator = emulatorOr404(ctx);
|
|
418
|
+
if (!emulator)
|
|
419
|
+
return;
|
|
420
|
+
if (!emulator.readScreenshotFile) {
|
|
421
|
+
return notFound(ctx, "screenshot file API is not available on this server");
|
|
422
|
+
}
|
|
423
|
+
const file = asString(ctx.query.file);
|
|
424
|
+
if (!file)
|
|
425
|
+
return bad(ctx, "missing file");
|
|
426
|
+
try {
|
|
427
|
+
const image = await emulator.readScreenshotFile({ file });
|
|
428
|
+
if (!image)
|
|
429
|
+
return notFound(ctx, "screenshot not found");
|
|
430
|
+
ctx.type = image.contentType;
|
|
431
|
+
ctx.set("Cache-Control", "private, max-age=300");
|
|
432
|
+
ctx.body = image.data;
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
bad(ctx, error instanceof Error ? error.message : String(error));
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
router.post("/api/emulator/kill", async (ctx) => {
|
|
439
|
+
const body = await readJson(ctx);
|
|
440
|
+
await emulatorResult(ctx, async (emulator) => {
|
|
441
|
+
const result = (await emulator.kill({ device: emulatorScopedDevice(ctx, body) }));
|
|
442
|
+
if (typeof result?.device === "string")
|
|
443
|
+
emulatorUnbindDevice(result.device);
|
|
444
|
+
return result;
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
router.post("/api/emulator/shutdown", async (ctx) => {
|
|
448
|
+
const body = await readJson(ctx);
|
|
449
|
+
await emulatorResult(ctx, async (emulator) => {
|
|
450
|
+
const result = (await emulator.shutdown({ device: emulatorScopedDevice(ctx, body) }));
|
|
451
|
+
if (typeof result?.device === "string")
|
|
452
|
+
emulatorUnbindDevice(result.device);
|
|
453
|
+
return result;
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
// ── sessions (channel-agnostic agent runs) ───────────────────────────────
|
|
457
|
+
// Create a session bound to a preset agent (`agent`) or an inline spec
|
|
458
|
+
// (`config`, the config-driven / remote-ready path), then drive it with
|
|
459
|
+
// messages over SSE. omnigent-style: create carries no message.
|
|
460
|
+
// One unified list: every session is a machine-session record. The registry
|
|
461
|
+
// (console + every channel) supplies identity — `source` is a plain field, not
|
|
462
|
+
// parsed from the id — and the canonical log supplies recency. A legacy session
|
|
463
|
+
// present only in the log (pre-migration) lists with source "unknown". Every
|
|
464
|
+
// session is observable, replayable, and continuable the same way.
|
|
465
|
+
router.get("/api/sessions", async (ctx) => {
|
|
466
|
+
const metaById = new Map(deps.listSessionMetas().map((m) => [m.id, m]));
|
|
467
|
+
const logged = sessionLog ? await sessionLog.listSessions() : [];
|
|
468
|
+
const logById = new Map(logged.map((s) => [s.sessionId, s]));
|
|
469
|
+
const ids = new Set([...metaById.keys(), ...logById.keys()]);
|
|
470
|
+
const sessions = [...ids].map((id) => {
|
|
471
|
+
const meta = metaById.get(id);
|
|
472
|
+
const log = logById.get(id);
|
|
473
|
+
const createdAt = meta?.createdAt ?? new Date(log?.createdAt ?? Date.now()).toISOString();
|
|
474
|
+
const updatedAt = log ? new Date(log.updatedAt).toISOString() : (meta?.updatedAt ?? createdAt);
|
|
475
|
+
return {
|
|
476
|
+
id,
|
|
477
|
+
agent: meta?.agent,
|
|
478
|
+
title: meta?.title,
|
|
479
|
+
status: "idle",
|
|
480
|
+
createdAt,
|
|
481
|
+
updatedAt,
|
|
482
|
+
source: meta?.source ?? "unknown",
|
|
483
|
+
};
|
|
484
|
+
});
|
|
485
|
+
sessions.sort((a, b) => (b.updatedAt ?? b.createdAt).localeCompare(a.updatedAt ?? a.createdAt));
|
|
486
|
+
ctx.body = { sessions };
|
|
487
|
+
});
|
|
488
|
+
router.post("/api/sessions", async (ctx) => {
|
|
489
|
+
if (!runtime)
|
|
490
|
+
return bad(ctx, "session runs are not available on this server");
|
|
491
|
+
const body = await readJson(ctx);
|
|
492
|
+
const agent = asString(body.agent);
|
|
493
|
+
let config;
|
|
494
|
+
if (body.config != null) {
|
|
495
|
+
const parsed = agentSpecSchema.safeParse(body.config);
|
|
496
|
+
if (!parsed.success) {
|
|
497
|
+
return bad(ctx, `invalid config: ${parsed.error.issues.map((i) => i.message).join("; ")}`);
|
|
498
|
+
}
|
|
499
|
+
config = parsed.data;
|
|
500
|
+
}
|
|
501
|
+
if (!agent && !config)
|
|
502
|
+
return bad(ctx, "provide an agent id or an inline config");
|
|
503
|
+
const id = newSessionId();
|
|
504
|
+
deps.createSessionMeta({
|
|
505
|
+
id,
|
|
506
|
+
source: "console",
|
|
507
|
+
agent,
|
|
508
|
+
config,
|
|
509
|
+
model: asString(body.model),
|
|
510
|
+
reasoningEffort: asString(body.reasoningEffort),
|
|
511
|
+
title: asString(body.title),
|
|
512
|
+
createdAt: new Date().toISOString(),
|
|
513
|
+
});
|
|
514
|
+
ctx.body = { sessionId: id };
|
|
515
|
+
});
|
|
516
|
+
router.post("/api/sessions/:id/messages", async (ctx) => {
|
|
517
|
+
const id = ctx.params.id;
|
|
518
|
+
if (!runtime)
|
|
519
|
+
return bad(ctx, "session runs are not available on this server");
|
|
520
|
+
const body = await readJson(ctx);
|
|
521
|
+
const message = asString(body.message);
|
|
522
|
+
if (!message)
|
|
523
|
+
return bad(ctx, "missing message");
|
|
524
|
+
const meta = deps.getSessionMeta(id);
|
|
525
|
+
// A console session carries its agent/config inline. Any other session — a
|
|
526
|
+
// channel session, or one only in the canonical log — resumes on the runtime
|
|
527
|
+
// binding recorded in the shared store (its original runtime/model/cwd). The
|
|
528
|
+
// turn lands in the same canonical log + LLM session, so the channel sees it
|
|
529
|
+
// in history — but no card is pushed (channels don't subscribe to the bus).
|
|
530
|
+
const consoleMeta = meta?.source === "console" ? meta : undefined;
|
|
531
|
+
const bound = consoleMeta ? null : ((await sessionStore?.get(id)) ?? null);
|
|
532
|
+
if (!meta && !bound)
|
|
533
|
+
return notFound(ctx, "session not found");
|
|
534
|
+
// First message names a console session (omnigent-style: the message,
|
|
535
|
+
// truncated). Channel sessions keep their own title elsewhere — skip.
|
|
536
|
+
if (consoleMeta && !consoleMeta.title)
|
|
537
|
+
deps.setSessionTitle(id, synthesizeSessionTitle(message));
|
|
538
|
+
// codex-native live mode (single writer): bring up the session's persistent
|
|
539
|
+
// forwarder + TUI, then INJECT this turn into the shared app-server thread
|
|
540
|
+
// (turn/start | turn/steer) and return immediately. The forwarder mirrors the
|
|
541
|
+
// output onto the bus; the web renders it from the persistent `/stream`. No
|
|
542
|
+
// per-`/messages` normalize/persist here — that would double-write the turn.
|
|
543
|
+
if (runnerManager?.ensureLiveSession && runnerManager.injectMessage) {
|
|
544
|
+
// Resolve the session's runtime from its agent spec (as the run path does),
|
|
545
|
+
// so live co-drive picks the right backend PER AGENT — the live path
|
|
546
|
+
// bypasses ConversationRuntime's own resolution, so without this it would
|
|
547
|
+
// fall back to the global default and mis-route (e.g. a claude agent onto codex).
|
|
548
|
+
const liveRuntime = await runtime.resolveRuntime({
|
|
549
|
+
agentName: consoleMeta?.agent,
|
|
550
|
+
agentSpec: consoleMeta?.config,
|
|
551
|
+
provider: bound?.runtime,
|
|
552
|
+
});
|
|
553
|
+
// codex RESUME with a missing local rollout (fork / cross-machine): synthesize
|
|
554
|
+
// one from the session log so the app-server's thread/resume finds it. No-op
|
|
555
|
+
// when the rollout already exists (the normal single-machine case). The bound
|
|
556
|
+
// thread record is fetched directly — `bound` above is null for console
|
|
557
|
+
// sessions, but a console session still resumes a stored codex thread.
|
|
558
|
+
if (liveRuntime === "codex" && sessionLog) {
|
|
559
|
+
const codexRecord = await sessionStore?.get(id);
|
|
560
|
+
if (codexRecord?.codexSessionId) {
|
|
561
|
+
ensureCodexResumeRollout({
|
|
562
|
+
// `id` (the rynx localThreadId) resolves the per-session CODEX_HOME —
|
|
563
|
+
// the SAME id the runner child gets via `RYNX_RUNNER_SESSION`, so the
|
|
564
|
+
// synthesized rollout lands where that session's app-server reads.
|
|
565
|
+
sessionId: id,
|
|
566
|
+
threadId: codexRecord.codexSessionId,
|
|
567
|
+
cwd: codexRecord.cwd ?? process.cwd(),
|
|
568
|
+
items: await sessionLog.snapshot(id),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const live = await runnerManager.ensureLiveSession(id, {
|
|
573
|
+
...(bound?.cwd ? { cwd: bound.cwd } : {}),
|
|
574
|
+
runtime: liveRuntime,
|
|
575
|
+
// Carry the agent identity so the live launch applies the agent spec's
|
|
576
|
+
// model / skills / instructions (not just the runtime). A preset agent
|
|
577
|
+
// rides its id; an inline-config console session rides its spec.
|
|
578
|
+
...(consoleMeta?.agent ? { agentName: consoleMeta.agent } : {}),
|
|
579
|
+
...(consoleMeta?.config ? { agentSpec: consoleMeta.config } : {}),
|
|
580
|
+
});
|
|
581
|
+
if (!live) {
|
|
582
|
+
// A live-runtime (codex/claude-native) session whose forwarder couldn't come
|
|
583
|
+
// up. omnigent reports this as a failure and NEVER falls through to a second
|
|
584
|
+
// output path — falling through would run+persist the turn while the forwarder
|
|
585
|
+
// also mirrors it (the double-write). Report + stop.
|
|
586
|
+
ctx.status = 503;
|
|
587
|
+
ctx.body = { error: `live session unavailable (${liveRuntime})` };
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const outcome = await runnerManager.injectMessage(id, message);
|
|
591
|
+
if (outcome === "injected") {
|
|
592
|
+
ctx.body = { ok: true, injected: true };
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
// notLive / notReady / failed → hard error. NEVER fall through to run+stream:
|
|
596
|
+
// the second path would double-write the turn alongside the forwarder
|
|
597
|
+
// (omnigent: inject failure ⇒ response.failed, no local re-run).
|
|
598
|
+
ctx.status = 503;
|
|
599
|
+
ctx.body = { error: `live injection ${outcome}` };
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
// Native-only: a turn can only run on a live runner (codex app-server / claude
|
|
603
|
+
// TUI). With no runner there is nothing to inject into, and there is no non-live
|
|
604
|
+
// programmatic fallback (that path was removed) — report + stop.
|
|
605
|
+
ctx.status = 503;
|
|
606
|
+
ctx.body = { error: "session runs require a live runner" };
|
|
607
|
+
});
|
|
608
|
+
// Stop the session's active turn (the web Stop button). For a live native
|
|
609
|
+
// session this interrupts the running turn at its source — codex app-server
|
|
610
|
+
// `turn/interrupt`, or an Escape into the claude pane — which the non-live path
|
|
611
|
+
// (aborting the streaming POST) cannot do once the turn is injected. Best-effort:
|
|
612
|
+
// `interrupted:false` when there was no active live turn.
|
|
613
|
+
router.post("/api/sessions/:id/interrupt", async (ctx) => {
|
|
614
|
+
const id = ctx.params.id;
|
|
615
|
+
const interrupted = runnerManager?.interruptLiveSession
|
|
616
|
+
? await runnerManager.interruptLiveSession(id)
|
|
617
|
+
: false;
|
|
618
|
+
ctx.body = { ok: true, interrupted };
|
|
619
|
+
});
|
|
620
|
+
// No meta gate: a channel session (Lark, …) has canonical-log history regardless
|
|
621
|
+
// of its registry row — it must be replayable too. Unknown ids snapshot to [].
|
|
622
|
+
router.get("/api/sessions/:id/history", async (ctx) => {
|
|
623
|
+
const id = ctx.params.id;
|
|
624
|
+
ctx.body = { sessionId: id, items: sessionLog ? await sessionLog.snapshot(id) : [] };
|
|
625
|
+
});
|
|
626
|
+
// Persistent live event stream (codex-native): one SSE per session, held for
|
|
627
|
+
// its whole lifetime, tailing the canonical bus. Sees EVERY turn — web-injected
|
|
628
|
+
// AND co-driving-TUI-initiated — since the forwarder is the single writer. The
|
|
629
|
+
// bus has no replay; the client reconciles pre-subscribe state via `/history`
|
|
630
|
+
// + item-id dedupe. Ends when the client disconnects.
|
|
631
|
+
router.get("/api/sessions/:id/stream", async (ctx) => {
|
|
632
|
+
const id = ctx.params.id;
|
|
633
|
+
if (!sessionBus)
|
|
634
|
+
return bad(ctx, "session streaming is not available on this server");
|
|
635
|
+
await streamEvents(ctx, async (send, signal) => {
|
|
636
|
+
const iterator = sessionBus.subscribe(id)[Symbol.asyncIterator]();
|
|
637
|
+
const disconnected = new Promise((resolve) => {
|
|
638
|
+
if (signal.aborted)
|
|
639
|
+
resolve(null);
|
|
640
|
+
else
|
|
641
|
+
signal.addEventListener("abort", () => resolve(null), { once: true });
|
|
642
|
+
});
|
|
643
|
+
try {
|
|
644
|
+
for (;;) {
|
|
645
|
+
const next = await Promise.race([iterator.next(), disconnected]);
|
|
646
|
+
if (next === null)
|
|
647
|
+
break; // client went away
|
|
648
|
+
if (next.done)
|
|
649
|
+
break; // bus closed for this session
|
|
650
|
+
send("event", next.value);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
finally {
|
|
654
|
+
await iterator.return?.();
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
// claude-native approvals: the PermissionRequest hook POSTs here and BLOCKS.
|
|
659
|
+
// Surface a `response.approval.requested` (the same event + ApprovalCard the
|
|
660
|
+
// codex path uses), hold the response until the web user answers, then relay
|
|
661
|
+
// claude's `{hookSpecificOutput:{decision:{behavior}}}` verdict to the hook.
|
|
662
|
+
router.post("/api/sessions/:id/hooks/permission-request", async (ctx) => {
|
|
663
|
+
const id = ctx.params.id;
|
|
664
|
+
if (!sessionBus)
|
|
665
|
+
return bad(ctx, "session streaming is not available on this server");
|
|
666
|
+
const payload = await readJson(ctx);
|
|
667
|
+
const toolName = asString(payload.tool_name) ?? "tool";
|
|
668
|
+
const toolInput = payload.tool_input && typeof payload.tool_input === "object"
|
|
669
|
+
? payload.tool_input
|
|
670
|
+
: {};
|
|
671
|
+
const cwd = asString(payload.cwd);
|
|
672
|
+
const approvalId = `claude_${randomUUID()}`;
|
|
673
|
+
const kind = /edit|write|patch|apply|notebook/i.test(toolName)
|
|
674
|
+
? "patch"
|
|
675
|
+
: "exec";
|
|
676
|
+
const command = asString(toolInput.command) ?? asString(toolInput.file_path) ?? toolName;
|
|
677
|
+
const event = {
|
|
678
|
+
type: "response.approval.requested",
|
|
679
|
+
responseId: `resp_claude_permission_${approvalId}`,
|
|
680
|
+
approvalId,
|
|
681
|
+
kind,
|
|
682
|
+
...(command ? { command } : {}),
|
|
683
|
+
...(cwd ? { cwd } : {}),
|
|
684
|
+
};
|
|
685
|
+
await persistSessionEvent(id, event, { sessionLog, sessionBus });
|
|
686
|
+
const verdict = await new Promise((resolve) => {
|
|
687
|
+
claudeApprovals.set(approvalId, resolve);
|
|
688
|
+
ctx.req.on("close", () => {
|
|
689
|
+
if (claudeApprovals.delete(approvalId))
|
|
690
|
+
resolve({ behavior: "deny" });
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
ctx.body = { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: verdict } };
|
|
694
|
+
});
|
|
695
|
+
// Resolve an interactive approval (Phase D): the user's decision for a
|
|
696
|
+
// `response.approval.requested` event, routed to the session's runner (codex)
|
|
697
|
+
// or the held claude PermissionRequest long-poll.
|
|
698
|
+
router.post("/api/sessions/:id/approvals/:approvalId", async (ctx) => {
|
|
699
|
+
const id = ctx.params.id;
|
|
700
|
+
const approvalId = ctx.params.approvalId;
|
|
701
|
+
const body = await readJson(ctx);
|
|
702
|
+
const decision = asString(body.decision);
|
|
703
|
+
if (decision !== "acceptForSession" &&
|
|
704
|
+
decision !== "accept" &&
|
|
705
|
+
decision !== "decline" &&
|
|
706
|
+
decision !== "cancel") {
|
|
707
|
+
ctx.status = 400;
|
|
708
|
+
ctx.body = { error: "decision must be acceptForSession | accept | decline | cancel" };
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const claudeResolve = claudeApprovals.get(approvalId);
|
|
712
|
+
if (claudeResolve) {
|
|
713
|
+
claudeApprovals.delete(approvalId);
|
|
714
|
+
claudeResolve(decision === "decline" || decision === "cancel" ? { behavior: "deny" } : { behavior: "allow" });
|
|
715
|
+
ctx.body = { ok: true };
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
runnerManager?.resolveApproval?.(id, approvalId, decision);
|
|
719
|
+
ctx.body = { ok: true };
|
|
720
|
+
});
|
|
721
|
+
router.delete("/api/sessions/:id", async (ctx) => {
|
|
722
|
+
const id = ctx.params.id;
|
|
723
|
+
runnerManager?.stopRunner(id);
|
|
724
|
+
sessionBus?.close(id);
|
|
725
|
+
deps.removeSessionMeta(id);
|
|
726
|
+
// Hard-delete the transcript too — the session list is meta ∪ log, so
|
|
727
|
+
// dropping only the meta would leave the session showing as an UNKNOWN
|
|
728
|
+
// ghost. Both together make it fully disappear.
|
|
729
|
+
await sessionLog?.deleteSession(id);
|
|
730
|
+
// Unbind only the scope mapping — the device (and its helper) stays attached
|
|
731
|
+
// for the web panel / other sessions sharing it (orca's unregisterWorktree).
|
|
732
|
+
emulatorSessionDevices.delete(id);
|
|
733
|
+
ctx.body = { ok: true };
|
|
734
|
+
});
|
|
735
|
+
return router;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Stream Server-Sent Events for `run`. `send(event, data)` writes one frame;
|
|
739
|
+
* `signal` aborts when the client disconnects. Takes over the raw response so
|
|
740
|
+
* frames flush immediately; a thrown error is reported as an `error` event.
|
|
741
|
+
*/
|
|
742
|
+
async function streamEvents(ctx, run) {
|
|
743
|
+
ctx.status = 200;
|
|
744
|
+
ctx.set("Content-Type", "text/event-stream");
|
|
745
|
+
ctx.set("Cache-Control", "no-cache, no-transform");
|
|
746
|
+
ctx.set("Connection", "keep-alive");
|
|
747
|
+
ctx.respond = false;
|
|
748
|
+
const res = ctx.res;
|
|
749
|
+
res.flushHeaders?.();
|
|
750
|
+
const controller = new AbortController();
|
|
751
|
+
ctx.req.on("close", () => controller.abort());
|
|
752
|
+
const send = (event, data) => {
|
|
753
|
+
if (!res.writableEnded)
|
|
754
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
755
|
+
};
|
|
756
|
+
try {
|
|
757
|
+
await run(send, controller.signal);
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
send("error", { message: error instanceof Error ? error.message : String(error) });
|
|
761
|
+
}
|
|
762
|
+
finally {
|
|
763
|
+
if (!res.writableEnded)
|
|
764
|
+
res.end();
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
/** Derive a one-line session title from the first user message: collapse
|
|
768
|
+
* whitespace, trim, and truncate to `limit` chars + an ellipsis (omnigent's
|
|
769
|
+
* rule — no LLM call). */
|
|
770
|
+
export function synthesizeSessionTitle(message, limit = 60) {
|
|
771
|
+
const collapsed = message.replace(/\s+/g, " ").trim();
|
|
772
|
+
if (collapsed.length <= limit)
|
|
773
|
+
return collapsed;
|
|
774
|
+
return `${collapsed.slice(0, limit - 1).trimEnd()}…`;
|
|
775
|
+
}
|
|
776
|
+
async function readJson(ctx) {
|
|
777
|
+
const raw = await new Promise((resolve, reject) => {
|
|
778
|
+
let data = "";
|
|
779
|
+
ctx.req.on("data", (chunk) => (data += chunk));
|
|
780
|
+
ctx.req.on("end", () => resolve(data));
|
|
781
|
+
ctx.req.on("error", reject);
|
|
782
|
+
});
|
|
783
|
+
if (!raw.trim())
|
|
784
|
+
return {};
|
|
785
|
+
try {
|
|
786
|
+
const parsed = JSON.parse(raw);
|
|
787
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
return {};
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Mask secret-ish option values so the page never echoes credentials back. A
|
|
795
|
+
* value is masked when its key is a `type: "secret"` field in the channel type's
|
|
796
|
+
* declared schema, or matches the secret-name heuristic as a fallback.
|
|
797
|
+
*/
|
|
798
|
+
function redact(deps, type, options) {
|
|
799
|
+
if (!options)
|
|
800
|
+
return undefined;
|
|
801
|
+
const schema = deps.listChannelTypes().find((t) => t.type === type)?.configSchema;
|
|
802
|
+
const secretKeys = new Set((schema ?? []).filter((f) => f.type === "secret").map((f) => f.key));
|
|
803
|
+
const out = {};
|
|
804
|
+
for (const [key, value] of Object.entries(options)) {
|
|
805
|
+
const secret = secretKeys.has(key) || /secret|token|key/i.test(key);
|
|
806
|
+
out[key] = secret && value ? "***" : value;
|
|
807
|
+
}
|
|
808
|
+
return out;
|
|
809
|
+
}
|
|
810
|
+
function asString(value) {
|
|
811
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
812
|
+
}
|
|
813
|
+
function asNumber(value) {
|
|
814
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
815
|
+
return value;
|
|
816
|
+
if (typeof value === "string" && value.trim()) {
|
|
817
|
+
const parsed = Number(value);
|
|
818
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
819
|
+
}
|
|
820
|
+
return undefined;
|
|
821
|
+
}
|
|
822
|
+
function asRecord(value) {
|
|
823
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
824
|
+
? value
|
|
825
|
+
: undefined;
|
|
826
|
+
}
|
|
827
|
+
function bad(ctx, message) {
|
|
828
|
+
ctx.status = 400;
|
|
829
|
+
ctx.body = { error: message };
|
|
830
|
+
}
|
|
831
|
+
function notFound(ctx, message) {
|
|
832
|
+
ctx.status = 404;
|
|
833
|
+
ctx.body = { error: message };
|
|
834
|
+
}
|