@rynx-ai/server 0.1.11-beta.1 → 0.1.11-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,32 +1,21 @@
1
1
  /**
2
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
3
+ * managing plugins, declarative agents, skills, and sessions. Mounted by
5
4
  * {@link import("./server.js").createApp} only when control deps are injected.
6
5
  *
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}.
6
+ * Channel configuration and instances are deliberately absent: each plugin
7
+ * owns that state and exposes its own management application.
18
8
  */
19
9
  import { timingSafeEqual } from "node:crypto";
20
10
  import Router from "@koa/router";
21
- import { agentSpecSchema, newSessionId, skillInstallRecipeSchema, } from "@rynx-ai/core";
22
- import { DAEMON_BROWSER_ARTIFACT_CLEAN_PATH, DAEMON_BROWSER_ARTIFACT_INSTALL_PATH, DAEMON_BROWSER_ARTIFACT_UPDATE_PATH, DAEMON_BROWSER_ARTIFACT_VERSION_PATH, DAEMON_CHROME_INSPECTION_PATH, DAEMON_CLEANUP_SESSIONS_PATH, DAEMON_SHUTDOWN_IF_IDLE_PATH, parseDaemonBrowserArtifactInstallInput, parseDaemonBrowserArtifactUpdateInput, parseDaemonChromeInspectionConfigureInput, parseDaemonCleanupSessionsInput, } from "@rynx-ai/protocol/control";
11
+ import { skillInstallRecipeSchema, } from "@rynx-ai/core";
12
+ import { DAEMON_BROWSER_ARTIFACT_CLEAN_PATH, DAEMON_BROWSER_ARTIFACT_INSTALL_PATH, DAEMON_BROWSER_ARTIFACT_UPDATE_PATH, DAEMON_BROWSER_ARTIFACT_VERSION_PATH, DAEMON_CHROME_INSPECTION_PATH, DAEMON_CLEANUP_SESSIONS_PATH, DAEMON_MAINTENANCE_LEASE_HEADER, DAEMON_MAINTENANCE_LEASE_RELEASE_PATH, DAEMON_SHUTDOWN_IF_IDLE_PATH, parseDaemonBrowserArtifactInstallInput, parseDaemonBrowserArtifactUpdateInput, parseDaemonChromeInspectionConfigureInput, parseDaemonCleanupSessionsInput, } from "@rynx-ai/protocol/control";
23
13
  import { DIRECT_RUNTIME_CONTROL_PATH, encodePairingCode, } from "@rynx-ai/protocol/direct-runtime";
24
- import { PLUGIN_INSTALL_PREPARATIONS_PATH, parsePluginInstallCommitInput, parsePluginInstallPrepareInput, } from "@rynx-ai/protocol/plugin-management";
14
+ import { PLUGIN_CONSOLE_INSTALL_PREPARATIONS_PATH, PLUGIN_CONSOLE_MARKETPLACES_PATH, PLUGIN_INSTALL_PREPARATIONS_PATH, PLUGIN_MARKETPLACES_PATH, parsePluginInstallCommitInput, parsePluginInstallPrepareInput, parsePluginMarketplaceAddInput, } from "@rynx-ai/protocol/plugin-management";
25
15
  import { parseDaemonStatus } from "@rynx-ai/protocol/remote-runtime";
26
16
  import { materializeRemoteRuntimeSessionEvent, materializeRemoteRuntimeSessionRuntimeSnapshot, parseRemoteRuntimeRpcRequest, REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES, SESSION_IMAGE_MEDIA_TYPES, SESSION_RESOURCE_MAX_IMAGE_BYTES, SESSION_RESOURCE_MAX_FILENAME_CHARS, SESSION_RESOURCE_TRANSFER_CHUNK_BYTES, REMOTE_RUNTIME_SESSION_MAX_PAGE_SIZE, } from "@rynx-ai/protocol/remote-runtime-rpc";
27
17
  import { RUNTIME_BROWSER_BOOTSTRAP_PATH, RUNTIME_BROWSER_CAPABILITY_HEADER, RUNTIME_BROWSER_MANAGEMENT_PATH_PREFIX, RUNTIME_BROWSER_RPC_PATH, RUNTIME_BROWSER_SESSION_ID_HEADER, parseRuntimeBrowserBootstrapCredential, } from "@rynx-ai/protocol/runtime-browser-bootstrap";
28
18
  import { parseRuntimeBrowserStateGetParams } from "@rynx-ai/protocol/runtime-browser";
29
- import { ensureCodexResumeRollout } from "@rynx-ai/runtime";
30
19
  import { encodeDaemonStatus } from "./remote-runtime.js";
31
20
  import { SessionRuntimeIndex } from "./session-runtime-index.js";
32
21
  import { authorizeRuntimeWebRequest, RUNTIME_WEB_CAPABILITY, RUNTIME_WEB_CAPABILITY_HEADER, } from "./runtime-web-auth.js";
@@ -40,6 +29,8 @@ const PLUGIN_CLI_TIMEOUT_MS = 120_000;
40
29
  const PLUGIN_INSTALL_BODY_MAX_BYTES = 16 * 1024;
41
30
  const PLUGIN_INSTALL_OPERATION_TIMEOUT_MS = 15 * 60_000;
42
31
  const PLUGIN_INSTALL_ERROR_MAX_BYTES = 16 * 1024;
32
+ const PLUGIN_SETTINGS_UI_BODY_MAX_BYTES = 1024 * 1024;
33
+ const CANONICAL_PLUGIN_ID_PATTERN = /^[a-z][a-z0-9-]{0,62}@[a-z0-9][a-z0-9-]{0,62}(?:\/[a-z0-9][a-z0-9._-]{0,99})?$/;
43
34
  const BROWSER_ARTIFACT_BODY_MAX_BYTES = 4 * 1024;
44
35
  const BROWSER_ARTIFACT_OPERATION_TIMEOUT_MS = 15 * 60_000;
45
36
  const BROWSER_ARTIFACT_ERROR_MAX_BYTES = 16 * 1024;
@@ -74,7 +65,7 @@ const RUNTIME_BROWSER_RPC_METHODS = new Set([
74
65
  "browser.page.reload",
75
66
  ]);
76
67
  export function createControlRouter(opts) {
77
- const { manager, deps, runtime, sessionBus, sessionLog, runnerManager, sessionStore, pluginRuntimeStatus, pluginRuntimeReload, pluginRuntimeEnsure, daemonManagement, remoteRuntime, remoteRuntimeAdmin, runtimeTargetControl, runtimeWebAccess, runtimeConnectionResolver, localSessionResources, sessionEmulators, shutdownSignal, sessionRuntimeIndex = new SessionRuntimeIndex(), sessionLifecycle, runtimeLocalBrowser, providerClis, } = opts;
68
+ const { deps, runtime, sessionBus, sessionLog, runnerManager, pluginRuntimeStatus, daemonManagement, remoteRuntime, remoteRuntimeAdmin, runtimeTargetControl, runtimeWebAccess, runtimeConnectionResolver, localSessionResources, localMachineSessions, sessionEmulators, shutdownSignal, sessionRuntimeIndex = new SessionRuntimeIndex(), sessionLifecycle, runtimeLocalBrowser, providerClis, } = opts;
78
69
  const router = new Router();
79
70
  const mutateBrowserArtifact = async (ctx, operation) => {
80
71
  const host = daemonManagement?.browserArtifacts;
@@ -137,8 +128,6 @@ export function createControlRouter(opts) {
137
128
  invocation.dispose();
138
129
  }
139
130
  };
140
- /** The instance (if any) bound to a channel — used to reload after a config change. */
141
- const instanceForChannel = (channelId) => deps.listInstancesConfig().find((i) => i.channelId === channelId);
142
131
  const listRuntimeTargets = async (ctx) => {
143
132
  if (!runtimeTargetControl)
144
133
  return notFound(ctx, "Runtime target control is not configured");
@@ -689,6 +678,37 @@ export function createControlRouter(opts) {
689
678
  lease.release();
690
679
  }
691
680
  };
681
+ const runtimeSessionSnapshotPage = async (ctx) => {
682
+ const lease = await acquireRuntimeLease(ctx);
683
+ if (!lease)
684
+ return;
685
+ const sessionId = ctx.params.id;
686
+ const afterId = optionalRuntimeString(ctx.query.afterId);
687
+ if (ctx.query.afterId !== undefined && afterId === undefined) {
688
+ lease.release();
689
+ ctx.status = 400;
690
+ ctx.body = { error: "invalid_request" };
691
+ return;
692
+ }
693
+ try {
694
+ const page = await lease.call("session.snapshot.get", {
695
+ sessionId,
696
+ ...(afterId === undefined ? {} : { afterId }),
697
+ limit: REMOTE_RUNTIME_SESSION_MAX_PAGE_SIZE,
698
+ }, { timeoutMs: REMOTE_RUNTIME_SESSION_TIMEOUT_MS });
699
+ if (page.sessionId !== sessionId) {
700
+ throw runtimeProtocolError("Runtime Session snapshot identity changed");
701
+ }
702
+ ctx.set("Cache-Control", "no-store");
703
+ ctx.body = page;
704
+ }
705
+ catch (error) {
706
+ runtimeTargetHostError(ctx, error);
707
+ }
708
+ finally {
709
+ lease.release();
710
+ }
711
+ };
692
712
  const runtimeSessionInterrupt = async (ctx) => {
693
713
  const outcome = await runtimeCall(ctx, "session.interrupt", { sessionId: ctx.params.id });
694
714
  if (outcome.ok)
@@ -712,6 +732,68 @@ export function createControlRouter(opts) {
712
732
  if (outcome.ok)
713
733
  ctx.body = outcome.result;
714
734
  };
735
+ const runtimeSessionFork = async (ctx) => {
736
+ const body = await readRuntimeBody(ctx);
737
+ if (!body)
738
+ return;
739
+ if (!acceptRuntimeBodyKeys(ctx, body, ["operationId", "title"]))
740
+ return;
741
+ const outcome = await runtimeCall(ctx, "session.fork", {
742
+ sourceSessionId: ctx.params.id,
743
+ operationId: body.operationId,
744
+ ...(typeof body.title === "string" ? { title: body.title } : {}),
745
+ });
746
+ if (outcome.ok)
747
+ ctx.body = outcome.result;
748
+ };
749
+ const runtimeProjects = async (ctx) => {
750
+ const outcome = await runtimeCall(ctx, "project.list", {});
751
+ if (outcome.ok)
752
+ ctx.body = outcome.result;
753
+ };
754
+ const runtimeProject = async (ctx) => {
755
+ const outcome = await runtimeCall(ctx, "project.get", {
756
+ projectId: ctx.params.projectId,
757
+ });
758
+ if (outcome.ok)
759
+ ctx.body = outcome.result;
760
+ };
761
+ const runtimeProjectCreate = async (ctx) => {
762
+ const body = await readRuntimeBody(ctx);
763
+ if (!body)
764
+ return;
765
+ if (!acceptRuntimeBodyKeys(ctx, body, ["name", "directories", "primary"]))
766
+ return;
767
+ const outcome = await runtimeCall(ctx, "project.create", {
768
+ name: body.name,
769
+ directories: body.directories,
770
+ primary: body.primary,
771
+ });
772
+ if (outcome.ok)
773
+ ctx.body = outcome.result;
774
+ };
775
+ const runtimeProjectUpdate = async (ctx) => {
776
+ const body = await readRuntimeBody(ctx);
777
+ if (!body)
778
+ return;
779
+ if (!acceptRuntimeBodyKeys(ctx, body, ["name", "directories", "primary"]))
780
+ return;
781
+ const outcome = await runtimeCall(ctx, "project.update", {
782
+ projectId: ctx.params.projectId,
783
+ name: body.name,
784
+ directories: body.directories,
785
+ primary: body.primary,
786
+ });
787
+ if (outcome.ok)
788
+ ctx.body = outcome.result;
789
+ };
790
+ const runtimeProjectDelete = async (ctx) => {
791
+ const outcome = await runtimeCall(ctx, "project.delete", {
792
+ projectId: ctx.params.projectId,
793
+ });
794
+ if (outcome.ok)
795
+ ctx.body = outcome.result;
796
+ };
715
797
  const runtimeSessionMessage = async (ctx) => {
716
798
  const body = await readRuntimeBody(ctx);
717
799
  if (!body)
@@ -1623,10 +1705,17 @@ export function createControlRouter(opts) {
1623
1705
  // The browser talks only to this loopback BFF; Local and Direct both enter the
1624
1706
  // target daemon through the same typed application service.
1625
1707
  router.get("/api/runtimes/:selector/sessions", publicRuntimeRead(runtimeSessions));
1708
+ router.get("/api/runtimes/:selector/projects", publicRuntimeRead(runtimeProjects));
1709
+ router.get("/api/runtimes/:selector/projects/:projectId", publicRuntimeRead(runtimeProject));
1710
+ router.post("/api/runtimes/:selector/projects", publicRuntimeMutation(runtimeProjectCreate));
1711
+ router.put("/api/runtimes/:selector/projects/:projectId", publicRuntimeMutation(runtimeProjectUpdate));
1712
+ router.delete("/api/runtimes/:selector/projects/:projectId", publicRuntimeMutation(runtimeProjectDelete));
1626
1713
  router.get("/api/runtimes/:selector/sessions/agent-options", publicRuntimeRead(runtimeSessionAgentOptions));
1627
1714
  router.get("/api/runtimes/:selector/sessions/launch-options", publicRuntimeRead(runtimeSessionLaunchOptions));
1628
1715
  router.post("/api/runtimes/:selector/sessions", publicRuntimeMutation(runtimeSessionCreate));
1716
+ router.post("/api/runtimes/:selector/sessions/:id/fork", publicRuntimeMutation(runtimeSessionFork));
1629
1717
  router.get("/api/runtimes/:selector/sessions/:id/snapshot", publicRuntimeRead(runtimeSessionSnapshot));
1718
+ router.get("/api/runtimes/:selector/sessions/:id/snapshot-page", publicRuntimeRead(runtimeSessionSnapshotPage));
1630
1719
  router.get("/api/runtimes/:selector/sessions/:id/stream", publicRuntimeRead(runtimeSessionStream));
1631
1720
  router.post("/api/runtimes/:selector/sessions/:id/interrupt", publicRuntimeMutation(runtimeSessionInterrupt));
1632
1721
  router.post("/api/runtimes/:selector/sessions/:id/messages", publicRuntimeMutation(runtimeSessionMessage));
@@ -1801,7 +1890,6 @@ export function createControlRouter(opts) {
1801
1890
  });
1802
1891
  router.get("/api/meta", (ctx) => {
1803
1892
  const meta = {
1804
- channelTypes: deps.listChannelTypes().map((t) => t.type),
1805
1893
  ...(daemonManagement?.buildStatus
1806
1894
  ? { daemonBuild: daemonManagement.buildStatus() }
1807
1895
  : {}),
@@ -1830,18 +1918,42 @@ export function createControlRouter(opts) {
1830
1918
  return;
1831
1919
  ctx.body = encodeDaemonStatus(remoteRuntime.status());
1832
1920
  });
1833
- router.post(DAEMON_SHUTDOWN_IF_IDLE_PATH, (ctx) => {
1921
+ router.post(DAEMON_SHUTDOWN_IF_IDLE_PATH, async (ctx) => {
1834
1922
  if (!isConfiguredDaemonManagement(daemonManagement) ||
1835
1923
  !daemonManagement.shutdownIfIdle) {
1836
1924
  return notFound(ctx, "daemon shutdown management is not configured");
1837
1925
  }
1838
1926
  if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1839
1927
  return;
1840
- const result = daemonManagement.shutdownIfIdle();
1928
+ const maintenanceLeaseToken = ctx.get(DAEMON_MAINTENANCE_LEASE_HEADER).trim() || undefined;
1929
+ let result;
1930
+ try {
1931
+ result = await daemonManagement.shutdownIfIdle(maintenanceLeaseToken);
1932
+ }
1933
+ catch (error) {
1934
+ ctx.set("Cache-Control", "no-store");
1935
+ ctx.status = 409;
1936
+ ctx.body = {
1937
+ error: error instanceof Error ? error.message : String(error),
1938
+ };
1939
+ return;
1940
+ }
1841
1941
  ctx.set("Cache-Control", "no-store");
1842
1942
  ctx.status = result.outcome === "accepted" ? 202 : 409;
1843
1943
  ctx.body = result;
1844
1944
  });
1945
+ router.post(DAEMON_MAINTENANCE_LEASE_RELEASE_PATH, (ctx) => {
1946
+ if (!isConfiguredDaemonManagement(daemonManagement)
1947
+ || !daemonManagement.releaseMaintenanceLease) {
1948
+ return notFound(ctx, "daemon maintenance lease management is not configured");
1949
+ }
1950
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1951
+ return;
1952
+ const result = daemonManagement.releaseMaintenanceLease(ctx.get(DAEMON_MAINTENANCE_LEASE_HEADER).trim());
1953
+ ctx.set("Cache-Control", "no-store");
1954
+ ctx.status = result.outcome === "denied" ? 403 : 200;
1955
+ ctx.body = result;
1956
+ });
1845
1957
  router.post(DAEMON_BROWSER_ARTIFACT_INSTALL_PATH, async (ctx) => {
1846
1958
  await mutateBrowserArtifact(ctx, "install");
1847
1959
  });
@@ -2014,6 +2126,360 @@ export function createControlRouter(opts) {
2014
2126
  ctx.set("Cache-Control", "no-store");
2015
2127
  ctx.body = await daemonManagement.pluginManagement.listPlugins();
2016
2128
  });
2129
+ // Public same-origin console view. It contains metadata only; package paths,
2130
+ // source URLs, package-private metadata, and credentials are not exposed.
2131
+ router.get("/api/plugins", async (ctx) => {
2132
+ if (!daemonManagement?.pluginManagement) {
2133
+ return notFound(ctx, "plugin management is not configured");
2134
+ }
2135
+ if (!requireConsoleReadRequest(ctx, runtimeWebAccess))
2136
+ return;
2137
+ ctx.set("Cache-Control", "no-store");
2138
+ ctx.body = await daemonManagement.pluginManagement.listPlugins();
2139
+ });
2140
+ router.get(PLUGIN_CONSOLE_MARKETPLACES_PATH, async (ctx) => {
2141
+ if (!daemonManagement?.pluginMarketplaces) {
2142
+ return notFound(ctx, "plugin marketplace management is not configured");
2143
+ }
2144
+ if (!requireConsoleReadRequest(ctx, runtimeWebAccess))
2145
+ return;
2146
+ ctx.set("Cache-Control", "no-store");
2147
+ ctx.body = { marketplaces: await daemonManagement.pluginMarketplaces.list() };
2148
+ });
2149
+ router.post(PLUGIN_CONSOLE_MARKETPLACES_PATH, async (ctx) => {
2150
+ if (!daemonManagement?.pluginMarketplaces) {
2151
+ return notFound(ctx, "plugin marketplace management is not configured");
2152
+ }
2153
+ if (!requireConsoleJsonRequest(ctx, "Market registration", runtimeWebAccess))
2154
+ return;
2155
+ let input;
2156
+ try {
2157
+ input = parsePluginMarketplaceAddInput(await readBoundedJson(ctx, PLUGIN_INSTALL_BODY_MAX_BYTES));
2158
+ }
2159
+ catch (error) {
2160
+ return bad(ctx, error instanceof Error ? error.message : String(error));
2161
+ }
2162
+ const invocation = pluginInstallInvocationAbortScope(ctx, shutdownSignal);
2163
+ try {
2164
+ ctx.status = 201;
2165
+ ctx.set("Cache-Control", "no-store");
2166
+ ctx.body = await daemonManagement.pluginMarketplaces.add(input, {
2167
+ signal: invocation.signal,
2168
+ });
2169
+ }
2170
+ catch (error) {
2171
+ ctx.status = invocation.signal.aborted ? 504 : 409;
2172
+ ctx.body = { error: boundedUtf8(error instanceof Error ? error.message : String(error), PLUGIN_INSTALL_ERROR_MAX_BYTES) };
2173
+ }
2174
+ finally {
2175
+ invocation.dispose();
2176
+ }
2177
+ });
2178
+ router.post(`${PLUGIN_CONSOLE_MARKETPLACES_PATH}/:id/refresh`, async (ctx) => {
2179
+ if (!daemonManagement?.pluginMarketplaces) {
2180
+ return notFound(ctx, "plugin marketplace management is not configured");
2181
+ }
2182
+ if (!requireConsoleJsonRequest(ctx, "Market refresh", runtimeWebAccess))
2183
+ return;
2184
+ try {
2185
+ await readBoundedJson(ctx, 1024);
2186
+ }
2187
+ catch (error) {
2188
+ return bad(ctx, error instanceof Error ? error.message : String(error));
2189
+ }
2190
+ const invocation = pluginInstallInvocationAbortScope(ctx, shutdownSignal);
2191
+ try {
2192
+ ctx.set("Cache-Control", "no-store");
2193
+ ctx.body = await daemonManagement.pluginMarketplaces.refresh(ctx.params.id, { signal: invocation.signal });
2194
+ }
2195
+ catch (error) {
2196
+ ctx.status = invocation.signal.aborted ? 504 : 409;
2197
+ ctx.body = { error: boundedUtf8(error instanceof Error ? error.message : String(error), PLUGIN_INSTALL_ERROR_MAX_BYTES) };
2198
+ }
2199
+ finally {
2200
+ invocation.dispose();
2201
+ }
2202
+ });
2203
+ router.delete(`${PLUGIN_CONSOLE_MARKETPLACES_PATH}/:id`, async (ctx) => {
2204
+ if (!daemonManagement?.pluginMarketplaces) {
2205
+ return notFound(ctx, "plugin marketplace management is not configured");
2206
+ }
2207
+ if (!requireConsoleRequest(ctx, "Market removal", runtimeWebAccess))
2208
+ return;
2209
+ try {
2210
+ const removed = await daemonManagement.pluginMarketplaces.remove(ctx.params.id);
2211
+ if (!removed)
2212
+ return notFound(ctx, `marketplace "${ctx.params.id}" not found`);
2213
+ ctx.set("Cache-Control", "no-store");
2214
+ ctx.body = { removed: true };
2215
+ }
2216
+ catch (error) {
2217
+ ctx.status = 409;
2218
+ ctx.body = { error: boundedUtf8(error instanceof Error ? error.message : String(error), PLUGIN_INSTALL_ERROR_MAX_BYTES) };
2219
+ }
2220
+ });
2221
+ router.post(PLUGIN_CONSOLE_INSTALL_PREPARATIONS_PATH, async (ctx) => {
2222
+ if (!daemonManagement?.pluginManagement) {
2223
+ return notFound(ctx, "plugin management is not configured");
2224
+ }
2225
+ if (!requireConsoleJsonRequest(ctx, "Plugin installation preparation", runtimeWebAccess))
2226
+ return;
2227
+ let input;
2228
+ try {
2229
+ input = parsePluginInstallPrepareInput(await readBoundedJson(ctx, PLUGIN_INSTALL_BODY_MAX_BYTES));
2230
+ }
2231
+ catch (error) {
2232
+ return bad(ctx, error instanceof Error ? error.message : String(error));
2233
+ }
2234
+ const invocation = pluginInstallInvocationAbortScope(ctx, shutdownSignal);
2235
+ try {
2236
+ ctx.status = 201;
2237
+ ctx.set("Cache-Control", "no-store");
2238
+ ctx.body = await daemonManagement.pluginManagement.prepareInstallation(input, { signal: invocation.signal });
2239
+ }
2240
+ catch (error) {
2241
+ ctx.status = invocation.signal.aborted ? 504 : 409;
2242
+ ctx.body = { error: boundedUtf8(error instanceof Error ? error.message : String(error), PLUGIN_INSTALL_ERROR_MAX_BYTES) };
2243
+ }
2244
+ finally {
2245
+ invocation.dispose();
2246
+ }
2247
+ });
2248
+ router.post(`${PLUGIN_CONSOLE_INSTALL_PREPARATIONS_PATH}/:token/commit`, async (ctx) => {
2249
+ if (!daemonManagement?.pluginManagement) {
2250
+ return notFound(ctx, "plugin management is not configured");
2251
+ }
2252
+ if (!requireConsoleJsonRequest(ctx, "Plugin installation commit", runtimeWebAccess))
2253
+ return;
2254
+ const token = ctx.params.token;
2255
+ if (!/^[A-Za-z0-9_-]{32,128}$/.test(token)) {
2256
+ return bad(ctx, "invalid plugin preparation token");
2257
+ }
2258
+ let input;
2259
+ try {
2260
+ input = parsePluginInstallCommitInput(await readBoundedJson(ctx, PLUGIN_INSTALL_BODY_MAX_BYTES));
2261
+ }
2262
+ catch (error) {
2263
+ return bad(ctx, error instanceof Error ? error.message : String(error));
2264
+ }
2265
+ const invocation = pluginInstallInvocationAbortScope(ctx, shutdownSignal);
2266
+ try {
2267
+ ctx.set("Cache-Control", "no-store");
2268
+ ctx.body = await daemonManagement.pluginManagement.commitInstallation(token, input, { signal: invocation.signal });
2269
+ }
2270
+ catch (error) {
2271
+ ctx.status = invocation.signal.aborted ? 504 : 409;
2272
+ ctx.body = { error: boundedUtf8(error instanceof Error ? error.message : String(error), PLUGIN_INSTALL_ERROR_MAX_BYTES) };
2273
+ }
2274
+ finally {
2275
+ invocation.dispose();
2276
+ }
2277
+ });
2278
+ router.delete(`${PLUGIN_CONSOLE_INSTALL_PREPARATIONS_PATH}/:token`, async (ctx) => {
2279
+ if (!daemonManagement?.pluginManagement) {
2280
+ return notFound(ctx, "plugin management is not configured");
2281
+ }
2282
+ if (!requireConsoleRequest(ctx, "Plugin installation cancellation", runtimeWebAccess))
2283
+ return;
2284
+ const token = ctx.params.token;
2285
+ if (!/^[A-Za-z0-9_-]{32,128}$/.test(token)) {
2286
+ return bad(ctx, "invalid plugin preparation token");
2287
+ }
2288
+ ctx.set("Cache-Control", "no-store");
2289
+ ctx.body = {
2290
+ cancelled: await daemonManagement.pluginManagement.cancelPreparation(token),
2291
+ };
2292
+ });
2293
+ for (const [action, enabled] of [["enable", true], ["disable", false]]) {
2294
+ router.post(`/api/plugins/:pluginId/${action}`, async (ctx) => {
2295
+ if (!daemonManagement?.pluginManagement) {
2296
+ return notFound(ctx, "plugin management is not configured");
2297
+ }
2298
+ const pluginId = ctx.params.pluginId;
2299
+ if (!CANONICAL_PLUGIN_ID_PATTERN.test(pluginId))
2300
+ return bad(ctx, "invalid plugin identity");
2301
+ if (!requireConsoleJsonRequest(ctx, `Plugin ${action}`, runtimeWebAccess))
2302
+ return;
2303
+ try {
2304
+ await readBoundedJson(ctx, 1024);
2305
+ const plugin = await daemonManagement.pluginManagement.setPluginEnabled(pluginId, enabled);
2306
+ if (!plugin)
2307
+ return notFound(ctx, `plugin "${pluginId}" not found`);
2308
+ ctx.set("Cache-Control", "no-store");
2309
+ ctx.body = { plugin };
2310
+ }
2311
+ catch (error) {
2312
+ ctx.status = 409;
2313
+ ctx.body = {
2314
+ error: boundedUtf8(error instanceof Error ? error.message : String(error), PLUGIN_INSTALL_ERROR_MAX_BYTES),
2315
+ };
2316
+ }
2317
+ });
2318
+ }
2319
+ router.get("/api/plugins/:pluginId/settings-ui", (ctx) => {
2320
+ if (!daemonManagement?.pluginManagement) {
2321
+ return notFound(ctx, "plugin management is not configured");
2322
+ }
2323
+ if (!requireConsoleReadRequest(ctx, runtimeWebAccess))
2324
+ return;
2325
+ const pluginId = ctx.params.pluginId;
2326
+ if (!CANONICAL_PLUGIN_ID_PATTERN.test(pluginId))
2327
+ return bad(ctx, "invalid plugin identity");
2328
+ try {
2329
+ const ui = daemonManagement.pluginManagement.settingsUi(pluginId);
2330
+ if (!ui)
2331
+ return notFound(ctx, `plugin "${pluginId}" has no settings UI`);
2332
+ const assetBasePath = `/api/plugins/${encodeURIComponent(pluginId)}/settings-ui-assets/` +
2333
+ `${ui.assetGeneration}/`;
2334
+ const assetBaseUrl = new URL(assetBasePath, ctx.href).href;
2335
+ ctx.set("Cache-Control", "no-store");
2336
+ ctx.body = {
2337
+ generation: ui.generation,
2338
+ html: withSettingsUiDocumentSecurity(ui.html, assetBaseUrl),
2339
+ };
2340
+ }
2341
+ catch (error) {
2342
+ ctx.status = 409;
2343
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
2344
+ }
2345
+ });
2346
+ router.get("/api/plugins/:pluginId/settings-ui-assets/:assetGeneration/*assetPath", (ctx) => {
2347
+ if (!daemonManagement?.pluginManagement) {
2348
+ return notFound(ctx, "plugin management is not configured");
2349
+ }
2350
+ if (!requireConsoleReadRequest(ctx, runtimeWebAccess))
2351
+ return;
2352
+ const pluginId = ctx.params.pluginId;
2353
+ const assetGeneration = ctx.params.assetGeneration;
2354
+ const assetPath = ctx.params.assetPath;
2355
+ if (!CANONICAL_PLUGIN_ID_PATTERN.test(pluginId)) {
2356
+ return bad(ctx, "invalid plugin identity");
2357
+ }
2358
+ if (!/^[A-Za-z0-9_-]{43}$/.test(assetGeneration) || typeof assetPath !== "string") {
2359
+ return bad(ctx, "invalid plugin settings UI asset");
2360
+ }
2361
+ try {
2362
+ const asset = daemonManagement.pluginManagement.settingsUiAsset(pluginId, assetGeneration, assetPath);
2363
+ if (!asset)
2364
+ return notFound(ctx, "plugin settings UI asset not found");
2365
+ ctx.set("Access-Control-Allow-Origin", "*");
2366
+ ctx.set("Cache-Control", "private, max-age=31536000, immutable");
2367
+ ctx.set("Cross-Origin-Resource-Policy", "cross-origin");
2368
+ ctx.set("X-Content-Type-Options", "nosniff");
2369
+ ctx.set("Content-Type", asset.contentType);
2370
+ ctx.body = asset.data;
2371
+ }
2372
+ catch (error) {
2373
+ ctx.status = 409;
2374
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
2375
+ }
2376
+ });
2377
+ router.post("/api/plugins/:pluginId/settings", async (ctx) => {
2378
+ if (!daemonManagement?.pluginManagement) {
2379
+ return notFound(ctx, "plugin management is not configured");
2380
+ }
2381
+ if (!requireConsoleJsonRequest(ctx, "Plugin settings call", runtimeWebAccess))
2382
+ return;
2383
+ const pluginId = ctx.params.pluginId;
2384
+ if (!CANONICAL_PLUGIN_ID_PATTERN.test(pluginId))
2385
+ return bad(ctx, "invalid plugin identity");
2386
+ let body;
2387
+ try {
2388
+ body = await readBoundedJson(ctx, PLUGIN_SETTINGS_UI_BODY_MAX_BYTES);
2389
+ }
2390
+ catch (error) {
2391
+ return bad(ctx, error instanceof Error ? error.message : String(error));
2392
+ }
2393
+ const generation = asString(body.generation);
2394
+ const method = asString(body.method);
2395
+ if (!generation || !method || !/^[A-Za-z][A-Za-z0-9._:-]{0,127}$/.test(method)) {
2396
+ return bad(ctx, "plugin settings generation and method are required");
2397
+ }
2398
+ const invocation = pluginInstallInvocationAbortScope(ctx, shutdownSignal);
2399
+ try {
2400
+ const result = await daemonManagement.pluginManagement.callSettings(pluginId, generation, method, body.params, { signal: invocation.signal });
2401
+ ctx.set("Cache-Control", "no-store");
2402
+ ctx.body = { result };
2403
+ }
2404
+ catch (error) {
2405
+ ctx.status = invocation.signal.aborted ? 504 : 409;
2406
+ ctx.body = { error: boundedUtf8(error instanceof Error ? error.message : String(error), PLUGIN_INSTALL_ERROR_MAX_BYTES) };
2407
+ }
2408
+ finally {
2409
+ invocation.dispose();
2410
+ }
2411
+ });
2412
+ router.get(PLUGIN_MARKETPLACES_PATH, async (ctx) => {
2413
+ if (!daemonManagement?.pluginMarketplaces ||
2414
+ !isConfiguredDaemonManagement(daemonManagement)) {
2415
+ return notFound(ctx, "plugin marketplace management is not configured");
2416
+ }
2417
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2418
+ return;
2419
+ ctx.body = { marketplaces: await daemonManagement.pluginMarketplaces.list() };
2420
+ });
2421
+ router.post(PLUGIN_MARKETPLACES_PATH, async (ctx) => {
2422
+ if (!daemonManagement?.pluginMarketplaces ||
2423
+ !isConfiguredDaemonManagement(daemonManagement)) {
2424
+ return notFound(ctx, "plugin marketplace management is not configured");
2425
+ }
2426
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2427
+ return;
2428
+ const body = await readBoundedJson(ctx, PLUGIN_INSTALL_BODY_MAX_BYTES);
2429
+ const source = asString(body.source);
2430
+ const alias = asString(body.alias);
2431
+ if (!source)
2432
+ return bad(ctx, "marketplace source is required");
2433
+ const invocation = pluginInstallInvocationAbortScope(ctx, shutdownSignal);
2434
+ try {
2435
+ ctx.status = 201;
2436
+ ctx.body = await daemonManagement.pluginMarketplaces.add({ source, ...(alias ? { alias } : {}) }, { signal: invocation.signal });
2437
+ }
2438
+ catch (error) {
2439
+ ctx.status = invocation.signal.aborted ? 504 : 409;
2440
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
2441
+ }
2442
+ finally {
2443
+ invocation.dispose();
2444
+ }
2445
+ });
2446
+ router.post(`${PLUGIN_MARKETPLACES_PATH}/:id/refresh`, async (ctx) => {
2447
+ if (!daemonManagement?.pluginMarketplaces ||
2448
+ !isConfiguredDaemonManagement(daemonManagement)) {
2449
+ return notFound(ctx, "plugin marketplace management is not configured");
2450
+ }
2451
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2452
+ return;
2453
+ const invocation = pluginInstallInvocationAbortScope(ctx, shutdownSignal);
2454
+ try {
2455
+ ctx.body = await daemonManagement.pluginMarketplaces.refresh(ctx.params.id, { signal: invocation.signal });
2456
+ }
2457
+ catch (error) {
2458
+ ctx.status = invocation.signal.aborted ? 504 : 409;
2459
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
2460
+ }
2461
+ finally {
2462
+ invocation.dispose();
2463
+ }
2464
+ });
2465
+ router.delete(`${PLUGIN_MARKETPLACES_PATH}/:id`, async (ctx) => {
2466
+ if (!daemonManagement?.pluginMarketplaces ||
2467
+ !isConfiguredDaemonManagement(daemonManagement)) {
2468
+ return notFound(ctx, "plugin marketplace management is not configured");
2469
+ }
2470
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2471
+ return;
2472
+ try {
2473
+ const removed = await daemonManagement.pluginMarketplaces.remove(ctx.params.id);
2474
+ if (!removed)
2475
+ return notFound(ctx, `marketplace "${ctx.params.id}" not found`);
2476
+ ctx.body = { removed: true };
2477
+ }
2478
+ catch (error) {
2479
+ ctx.status = 409;
2480
+ ctx.body = { error: boundedUtf8(error instanceof Error ? error.message : String(error), PLUGIN_INSTALL_ERROR_MAX_BYTES) };
2481
+ }
2482
+ });
2017
2483
  router.post(PLUGIN_INSTALL_PREPARATIONS_PATH, async (ctx) => {
2018
2484
  if (!isConfiguredDaemonManagement(daemonManagement) ||
2019
2485
  !daemonManagement.pluginManagement) {
@@ -2140,8 +2606,8 @@ export function createControlRouter(opts) {
2140
2606
  if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2141
2607
  return;
2142
2608
  const pluginId = ctx.params.pluginId;
2143
- if (!/^[a-z][a-z0-9-]{0,62}$/.test(pluginId)) {
2144
- return bad(ctx, "invalid plugin id");
2609
+ if (!CANONICAL_PLUGIN_ID_PATTERN.test(pluginId)) {
2610
+ return bad(ctx, "invalid plugin identity");
2145
2611
  }
2146
2612
  try {
2147
2613
  const uninstalled = await daemonManagement.pluginManagement.uninstallPlugin(pluginId);
@@ -2162,8 +2628,8 @@ export function createControlRouter(opts) {
2162
2628
  if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2163
2629
  return;
2164
2630
  const pluginId = ctx.params.pluginId;
2165
- if (!/^[a-z][a-z0-9-]{0,62}$/.test(pluginId)) {
2166
- return bad(ctx, "invalid plugin id");
2631
+ if (!CANONICAL_PLUGIN_ID_PATTERN.test(pluginId)) {
2632
+ return bad(ctx, "invalid plugin identity");
2167
2633
  }
2168
2634
  if (!ctx.is("application/json")) {
2169
2635
  ctx.status = 415;
@@ -2214,8 +2680,8 @@ export function createControlRouter(opts) {
2214
2680
  if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2215
2681
  return;
2216
2682
  const pluginId = ctx.params.pluginId;
2217
- if (!/^[a-z][a-z0-9-]{0,62}$/.test(pluginId)) {
2218
- bad(ctx, "invalid plugin id");
2683
+ if (!CANONICAL_PLUGIN_ID_PATTERN.test(pluginId)) {
2684
+ bad(ctx, "invalid plugin identity");
2219
2685
  return;
2220
2686
  }
2221
2687
  try {
@@ -2232,42 +2698,6 @@ export function createControlRouter(opts) {
2232
2698
  ctx.body = { error: error instanceof Error ? error.message : String(error) };
2233
2699
  }
2234
2700
  }
2235
- router.post("/api/internal/plugin-runtimes/:pluginId/reload", async (ctx) => {
2236
- if (!pluginRuntimeReload || !isConfiguredDaemonManagement(daemonManagement)) {
2237
- return notFound(ctx, "plugin runtime management is not configured");
2238
- }
2239
- if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2240
- return;
2241
- const pluginId = ctx.params.pluginId;
2242
- if (!/^[a-z][a-z0-9-]{0,62}$/.test(pluginId)) {
2243
- return bad(ctx, "invalid plugin id");
2244
- }
2245
- try {
2246
- ctx.body = await pluginRuntimeReload(pluginId);
2247
- }
2248
- catch (error) {
2249
- ctx.status = 409;
2250
- ctx.body = { error: error instanceof Error ? error.message : String(error) };
2251
- }
2252
- });
2253
- router.post("/api/internal/plugin-runtimes/:pluginId/ensure", async (ctx) => {
2254
- if (!pluginRuntimeEnsure || !isConfiguredDaemonManagement(daemonManagement)) {
2255
- return notFound(ctx, "plugin runtime management is not configured");
2256
- }
2257
- if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
2258
- return;
2259
- const pluginId = ctx.params.pluginId;
2260
- if (!/^[a-z][a-z0-9-]{0,62}$/.test(pluginId)) {
2261
- return bad(ctx, "invalid plugin id");
2262
- }
2263
- try {
2264
- ctx.body = await pluginRuntimeEnsure(pluginId);
2265
- }
2266
- catch (error) {
2267
- ctx.status = 409;
2268
- ctx.body = { error: error instanceof Error ? error.message : String(error) };
2269
- }
2270
- });
2271
2701
  router.get("/control/link", (ctx) => {
2272
2702
  const token = asString(ctx.query.token);
2273
2703
  if (!token || !deps.consumeControlLink)
@@ -2277,149 +2707,6 @@ export function createControlRouter(opts) {
2277
2707
  return notFound(ctx, "control link expired or already used");
2278
2708
  ctx.redirect(`/sessions/${encodeURIComponent(link.sessionId)}`);
2279
2709
  });
2280
- router.get("/api/channel-types", (ctx) => {
2281
- ctx.body = { channelTypes: deps.listChannelTypes() };
2282
- });
2283
- // ── channels ───────────────────────────────────────────────────────────
2284
- router.get("/api/channels", (ctx) => {
2285
- ctx.body = {
2286
- channels: deps.listChannels().map((c) => ({
2287
- id: c.id,
2288
- name: c.name,
2289
- type: c.type,
2290
- options: redact(deps, c.type, c.options),
2291
- })),
2292
- };
2293
- });
2294
- router.post("/api/channels", async (ctx) => {
2295
- const body = await readJson(ctx);
2296
- const name = asString(body.name);
2297
- const type = asString(body.type);
2298
- if (!name)
2299
- return bad(ctx, "missing channel name");
2300
- if (!type)
2301
- return bad(ctx, "missing channel type");
2302
- let id;
2303
- try {
2304
- ({ id } = deps.createChannel({ name, type, options: asRecord(body.options) }));
2305
- }
2306
- catch (error) {
2307
- return bad(ctx, error instanceof Error ? error.message : String(error));
2308
- }
2309
- ctx.body = { ok: true, id };
2310
- });
2311
- router.put("/api/channels/:id", async (ctx) => {
2312
- const id = ctx.params.id;
2313
- const body = await readJson(ctx);
2314
- try {
2315
- deps.setChannel(id, {
2316
- ...(body.name !== undefined ? { name: asString(body.name) } : {}),
2317
- ...(body.options !== undefined ? { options: asRecord(body.options) } : {}),
2318
- });
2319
- }
2320
- catch (error) {
2321
- return bad(ctx, error instanceof Error ? error.message : String(error));
2322
- }
2323
- const bound = instanceForChannel(id);
2324
- if (bound)
2325
- await manager.reloadInstance(bound.id);
2326
- ctx.body = { ok: true };
2327
- });
2328
- router.delete("/api/channels/:id", async (ctx) => {
2329
- const id = ctx.params.id;
2330
- const bound = instanceForChannel(id);
2331
- if (bound)
2332
- await manager.stopInstance(bound.id); // its row cascades on channel delete
2333
- deps.removeChannel(id);
2334
- ctx.body = { ok: true };
2335
- });
2336
- // Authorize (config mode 2): run the channel type's flow over SSE, persist the
2337
- // resolved options on the channel, reload the bound instance (if any).
2338
- router.post("/api/channels/:id/authorize", async (ctx) => {
2339
- const id = ctx.params.id;
2340
- const channel = deps.listChannels().find((c) => c.id === id);
2341
- if (!channel)
2342
- return notFound(ctx, "channel not found");
2343
- await streamEvents(ctx, async (send, signal) => {
2344
- send("status", { text: `authorizing ${channel.type}…` });
2345
- const bound = instanceForChannel(id);
2346
- const options = await deps.authorizeChannel(channel.type, {
2347
- ...(bound ? { instanceId: bound.id } : {}),
2348
- config: channel.options ?? {},
2349
- }, (m) => send(m.kind, m), signal);
2350
- deps.setChannel(id, { options });
2351
- if (bound)
2352
- await manager.reloadInstance(bound.id);
2353
- send("done", { id });
2354
- }, shutdownSignal);
2355
- });
2356
- // ── instances ──────────────────────────────────────────────────────────
2357
- router.get("/api/instances", (ctx) => {
2358
- const statuses = new Map(manager.list().map((status) => [status.instanceId, status]));
2359
- const instances = deps.listInstancesConfig().map((inst) => {
2360
- const status = statuses.get(inst.id);
2361
- return {
2362
- id: inst.id,
2363
- channelId: inst.channelId,
2364
- channelName: inst.channelName,
2365
- type: inst.type,
2366
- agent: inst.agent,
2367
- enabled: inst.enabled,
2368
- running: status?.running ?? false,
2369
- connected: status?.connected ?? null,
2370
- lastError: status?.lastError ?? null,
2371
- lastEventAt: status?.lastEventAt ?? null,
2372
- };
2373
- });
2374
- ctx.body = { instances };
2375
- });
2376
- router.post("/api/instances", async (ctx) => {
2377
- const body = await readJson(ctx);
2378
- const channelId = asString(body.channelId);
2379
- if (!channelId)
2380
- return bad(ctx, "missing channelId");
2381
- let id;
2382
- try {
2383
- ({ id } = deps.createInstance({ channelId, agent: asString(body.agent) }));
2384
- }
2385
- catch (error) {
2386
- return bad(ctx, error instanceof Error ? error.message : String(error));
2387
- }
2388
- await manager.reloadInstance(id);
2389
- ctx.body = { ok: true, id };
2390
- });
2391
- router.put("/api/instances/:id", async (ctx) => {
2392
- const id = ctx.params.id;
2393
- const body = await readJson(ctx);
2394
- deps.setInstance(id, {
2395
- ...(body.agent !== undefined ? { agent: asString(body.agent) } : {}),
2396
- ...(body.enabled !== undefined ? { enabled: Boolean(body.enabled) } : {}),
2397
- });
2398
- await manager.reloadInstance(id);
2399
- ctx.body = { ok: true };
2400
- });
2401
- router.post("/api/instances/:id/enable", async (ctx) => {
2402
- const id = ctx.params.id;
2403
- deps.setInstance(id, { enabled: true });
2404
- await manager.startInstance(id);
2405
- ctx.body = { ok: true };
2406
- });
2407
- router.post("/api/instances/:id/disable", async (ctx) => {
2408
- const id = ctx.params.id;
2409
- deps.setInstance(id, { enabled: false });
2410
- await manager.stopInstance(id);
2411
- ctx.body = { ok: true };
2412
- });
2413
- router.post("/api/instances/:id/restart", async (ctx) => {
2414
- await manager.reloadInstance(ctx.params.id);
2415
- ctx.body = { ok: true };
2416
- });
2417
- router.delete("/api/instances/:id", async (ctx) => {
2418
- const id = ctx.params.id;
2419
- await manager.stopInstance(id);
2420
- deps.removeInstance(id);
2421
- ctx.body = { ok: true };
2422
- });
2423
2710
  // ── agents ─────────────────────────────────────────────────────────────
2424
2711
  router.get("/api/agents", async (ctx) => {
2425
2712
  ctx.body = { agents: await deps.listAgents() };
@@ -2802,8 +3089,7 @@ export function createControlRouter(opts) {
2802
3089
  const updatedAt = log ? new Date(log.updatedAt).toISOString() : (meta?.updatedAt ?? createdAt);
2803
3090
  return {
2804
3091
  id,
2805
- provider: meta?.provider,
2806
- agent: meta?.agent,
3092
+ provider: meta?.execution?.provider,
2807
3093
  title: meta?.title,
2808
3094
  status: sessionRuntimeIndex.snapshot(id).status,
2809
3095
  createdAt,
@@ -2815,134 +3101,85 @@ export function createControlRouter(opts) {
2815
3101
  ctx.body = { sessions };
2816
3102
  });
2817
3103
  router.post("/api/sessions", async (ctx) => {
2818
- if (!runtime)
2819
- return bad(ctx, "session runs are not available on this server");
3104
+ if (!localMachineSessions) {
3105
+ return bad(ctx, "session creation is unavailable on this server");
3106
+ }
2820
3107
  const body = await readJson(ctx);
2821
3108
  const agent = asString(body.agent);
2822
- let config;
3109
+ const provider = asString(body.provider);
2823
3110
  if (body.config != null) {
2824
- const parsed = agentSpecSchema.safeParse(body.config);
2825
- if (!parsed.success) {
2826
- return bad(ctx, `invalid config: ${parsed.error.issues.map((i) => i.message).join("; ")}`);
2827
- }
2828
- config = parsed.data;
2829
- }
2830
- if (!agent && !config)
2831
- return bad(ctx, "provide an agent id or an inline config");
2832
- const id = newSessionId();
2833
- deps.createSessionMeta({
2834
- id,
2835
- source: "console",
2836
- agent,
2837
- config,
2838
- model: asString(body.model),
2839
- reasoningEffort: asString(body.reasoningEffort),
2840
- title: asString(body.title),
2841
- createdAt: new Date().toISOString(),
2842
- });
2843
- ctx.body = { sessionId: id };
3111
+ return bad(ctx, "inline Agent configs are no longer supported; save an Agent first");
3112
+ }
3113
+ if (Boolean(agent) === Boolean(provider)) {
3114
+ return bad(ctx, "provide exactly one Agent or Provider");
3115
+ }
3116
+ try {
3117
+ ctx.body = await localMachineSessions.create({
3118
+ ...(agent ? { agent } : { provider: provider }),
3119
+ ...(asString(body.model) ? { model: asString(body.model) } : {}),
3120
+ ...(asString(body.reasoningEffort)
3121
+ ? { reasoningEffort: asString(body.reasoningEffort) }
3122
+ : {}),
3123
+ ...(asString(body.title) ? { title: asString(body.title) } : {}),
3124
+ ...(asString(body.projectId) ? { projectId: asString(body.projectId) } : {}),
3125
+ });
3126
+ }
3127
+ catch (error) {
3128
+ return bad(ctx, error instanceof Error ? error.message : String(error));
3129
+ }
2844
3130
  });
2845
3131
  router.post("/api/sessions/:id/messages", async (ctx) => {
2846
3132
  const id = ctx.params.id;
2847
- if (!runtime)
2848
- return bad(ctx, "session runs are not available on this server");
2849
3133
  const body = await readJson(ctx);
2850
3134
  const message = asString(body.message);
2851
3135
  if (!message)
2852
3136
  return bad(ctx, "missing message");
2853
- const meta = deps.getSessionMeta(id);
2854
- // A console session carries its agent/config inline. Any other session — a
2855
- // channel session, or one only in the canonical log — resumes on the runtime
2856
- // binding recorded in the shared store (its original runtime/model/cwd). The
2857
- // turn lands in the same canonical log + LLM session, so the channel sees it
2858
- // in history — but no card is pushed (channels don't subscribe to the bus).
2859
- const consoleMeta = meta?.source === "console" ? meta : undefined;
2860
- const bound = consoleMeta ? null : ((await sessionStore?.get(id)) ?? null);
2861
- if (!meta && !bound)
2862
- return notFound(ctx, "session not found");
2863
- // First message names a console session (reference implementation-style: the message,
2864
- // truncated). Channel sessions keep their own title elsewhere — skip.
2865
- if (consoleMeta && !consoleMeta.title)
2866
- deps.setSessionTitle(id, synthesizeSessionTitle(message));
2867
- // codex-native live mode (single writer): bring up the session's persistent
2868
- // forwarder + TUI, then INJECT this turn into the shared app-server thread
2869
- // (turn/start | turn/steer) and return immediately. The forwarder mirrors the
2870
- // output onto the bus; the web renders it from the persistent `/stream`. No
2871
- // per-`/messages` normalize/persist here — that would double-write the turn.
2872
- if (runnerManager?.ensureLiveSession && runnerManager.injectMessage) {
2873
- // Resolve the session's runtime from its agent spec (as the run path does),
2874
- // so live co-drive picks the right backend PER AGENT — the live path
2875
- // bypasses ConversationRuntime's own resolution, so without this it would
2876
- // fall back to the global default and mis-route (e.g. a claude agent onto codex).
2877
- const liveRuntime = await runtime.resolveRuntime({
2878
- agentName: consoleMeta?.agent,
2879
- agentSpec: consoleMeta?.config,
2880
- provider: meta?.provider ?? bound?.runtime,
2881
- });
2882
- // codex RESUME with a missing local rollout (fork / cross-machine): synthesize
2883
- // one from the session log so the app-server's thread/resume finds it. No-op
2884
- // when the rollout already exists (the normal single-machine case). The bound
2885
- // thread record is fetched directly — `bound` above is null for console
2886
- // sessions, but a console session still resumes a stored codex thread.
2887
- if ((liveRuntime === "codex" || liveRuntime === "traex") && sessionLog) {
2888
- const codexRecord = await sessionStore?.get(id);
2889
- if (codexRecord?.codexSessionId) {
2890
- ensureCodexResumeRollout({
2891
- // `id` (the rynx localThreadId) resolves the per-session CODEX_HOME —
2892
- // the SAME id the runner child gets via `RYNX_RUNNER_SESSION`, so the
2893
- // synthesized rollout lands where that session's app-server reads.
2894
- sessionId: id,
2895
- runtime: liveRuntime,
2896
- threadId: codexRecord.codexSessionId,
2897
- cwd: codexRecord.cwd ?? process.cwd(),
2898
- items: await sessionLog.snapshot(id),
2899
- });
2900
- }
3137
+ if (localMachineSessions) {
3138
+ try {
3139
+ ctx.body = await localMachineSessions.sendMessage(id, message);
2901
3140
  }
2902
- const live = await runnerManager.ensureLiveSession(id, {
2903
- ...(bound?.cwd
2904
- ? { cwd: bound.cwd }
2905
- : consoleMeta?.config?.osEnv?.cwd
2906
- ? { cwd: consoleMeta.config.osEnv.cwd }
2907
- : {}),
2908
- runtime: liveRuntime,
2909
- ...(consoleMeta?.reasoningEffort ? { reasoningEffort: consoleMeta.reasoningEffort } : {}),
2910
- // Carry the agent identity so the live launch applies the agent spec's
2911
- // model / skills / instructions (not just the runtime). A preset agent
2912
- // rides its id; an inline-config console session rides its spec.
2913
- ...(consoleMeta?.agent ? { agentName: consoleMeta.agent } : {}),
2914
- ...(consoleMeta?.config ? { agentSpec: consoleMeta.config } : {}),
2915
- });
2916
- if (!live) {
2917
- // A live-runtime (codex/claude-native) session whose forwarder couldn't come
2918
- // up. reference implementation reports this as a failure and NEVER falls through to a second
2919
- // output path — falling through would run+persist the turn while the forwarder
2920
- // also mirrors it (the double-write). Report + stop.
2921
- const detail = runnerManager.lastLiveSessionError?.(id);
3141
+ catch (error) {
2922
3142
  ctx.status = 503;
2923
- ctx.body = {
2924
- error: `live session unavailable (${liveRuntime})`,
2925
- ...(detail ? { detail } : {}),
2926
- };
2927
- return;
2928
- }
2929
- const outcome = await runnerManager.injectMessage(id, message);
2930
- if (outcome === "injected") {
2931
- ctx.body = { ok: true, injected: true };
2932
- return;
3143
+ ctx.body = { error: error instanceof Error ? error.message : String(error) };
2933
3144
  }
2934
- // notLive / notReady / failed → hard error. NEVER fall through to run+stream:
2935
- // the second path would double-write the turn alongside the forwarder
2936
- // (reference implementation: inject failure ⇒ response.failed, no local re-run).
3145
+ return;
3146
+ }
3147
+ const meta = deps.getSessionMeta(id);
3148
+ if (!meta)
3149
+ return notFound(ctx, "session not found");
3150
+ if (!meta.title)
3151
+ deps.setSessionTitle(id, synthesizeSessionTitle(message));
3152
+ if (!runnerManager?.ensureLiveSession || !runnerManager.injectMessage) {
3153
+ ctx.status = 503;
3154
+ ctx.body = { error: "session runs require a live runner" };
3155
+ return;
3156
+ }
3157
+ const live = await runnerManager.ensureLiveSession(id, {
3158
+ workspace: structuredClone(meta.workspace),
3159
+ execution: structuredClone(meta.execution),
3160
+ });
3161
+ if (!live) {
2937
3162
  ctx.status = 503;
2938
- ctx.body = { error: `live injection ${outcome}` };
3163
+ ctx.body = {
3164
+ error: `live session unavailable (${meta.execution.provider})`,
3165
+ ...(runnerManager.lastLiveSessionError?.(id)
3166
+ ? { detail: runnerManager.lastLiveSessionError(id) }
3167
+ : {}),
3168
+ };
3169
+ return;
3170
+ }
3171
+ const outcome = await runnerManager.injectMessage(id, message);
3172
+ if (outcome === "injected") {
3173
+ ctx.body = { ok: true, injected: true };
2939
3174
  return;
2940
3175
  }
2941
- // Native-only: a turn can only run on a live runner (codex app-server / claude
2942
- // TUI). With no runner there is nothing to inject into, and there is no non-live
2943
- // programmatic fallback (that path was removed) — report + stop.
2944
3176
  ctx.status = 503;
2945
- ctx.body = { error: "session runs require a live runner" };
3177
+ ctx.body = {
3178
+ error: `live injection ${outcome}`,
3179
+ ...(runnerManager.lastLiveSessionError?.(id)
3180
+ ? { detail: runnerManager.lastLiveSessionError(id) }
3181
+ : {}),
3182
+ };
2946
3183
  });
2947
3184
  // Stop the session's active turn (the web Stop button). For a live native
2948
3185
  // session this interrupts the running turn at its source — codex app-server
@@ -3179,23 +3416,6 @@ async function readJson(ctx) {
3179
3416
  return {};
3180
3417
  }
3181
3418
  }
3182
- /**
3183
- * Mask secret-ish option values so the page never echoes credentials back. A
3184
- * value is masked when its key is a `type: "secret"` field in the channel type's
3185
- * declared schema, or matches the secret-name heuristic as a fallback.
3186
- */
3187
- function redact(deps, type, options) {
3188
- if (!options)
3189
- return undefined;
3190
- const schema = deps.listChannelTypes().find((t) => t.type === type)?.configSchema;
3191
- const secretKeys = new Set((schema ?? []).filter((f) => f.type === "secret").map((f) => f.key));
3192
- const out = {};
3193
- for (const [key, value] of Object.entries(options)) {
3194
- const secret = secretKeys.has(key) || /secret|token|key/i.test(key);
3195
- out[key] = secret && value ? "***" : value;
3196
- }
3197
- return out;
3198
- }
3199
3419
  function asString(value) {
3200
3420
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
3201
3421
  }
@@ -3359,6 +3579,63 @@ function namedError(message, name) {
3359
3579
  error.name = name;
3360
3580
  return error;
3361
3581
  }
3582
+ function withSettingsUiDocumentSecurity(html, assetBaseUrl) {
3583
+ const source = assetBaseUrl.replace(/["&<>]/g, encodeURIComponent);
3584
+ const csp = [
3585
+ "default-src 'none'",
3586
+ `script-src 'unsafe-inline' ${source}`,
3587
+ `style-src 'unsafe-inline' ${source}`,
3588
+ `img-src data: ${source}`,
3589
+ `font-src data: ${source}`,
3590
+ `media-src ${source}`,
3591
+ "connect-src 'none'",
3592
+ "worker-src 'none'",
3593
+ "object-src 'none'",
3594
+ "frame-src 'none'",
3595
+ "form-action 'none'",
3596
+ "navigate-to 'none'",
3597
+ `base-uri ${source}`,
3598
+ ].join("; ");
3599
+ const prefix = `<meta http-equiv="Content-Security-Policy" content="${csp}">` +
3600
+ `<base href="${source}">`;
3601
+ return /<head(?:\s[^>]*)?>/i.test(html)
3602
+ ? html.replace(/<head(?:\s[^>]*)?>/i, (head) => `${head}${prefix}`)
3603
+ : `${prefix}${html}`;
3604
+ }
3605
+ function requireConsoleReadRequest(ctx, access) {
3606
+ const failure = authorizeRuntimeWebRequest(ctx.req, { access });
3607
+ if (!failure)
3608
+ return true;
3609
+ ctx.status = failure.status;
3610
+ ctx.body = { error: failure.error };
3611
+ return false;
3612
+ }
3613
+ function requireConsoleRequest(ctx, operation, access) {
3614
+ const failure = authorizeRuntimeWebRequest(ctx.req, {
3615
+ mutation: true,
3616
+ capability: ctx.get(RUNTIME_WEB_CAPABILITY_HEADER),
3617
+ access,
3618
+ });
3619
+ if (failure) {
3620
+ ctx.status = failure.status;
3621
+ ctx.body = { error: failure.error };
3622
+ return false;
3623
+ }
3624
+ if (ctx.get("x-rynx-console") === "1")
3625
+ return true;
3626
+ ctx.status = 403;
3627
+ ctx.body = { error: `${operation} requires the Rynx console` };
3628
+ return false;
3629
+ }
3630
+ function requireConsoleJsonRequest(ctx, operation, access) {
3631
+ if (!requireConsoleRequest(ctx, operation, access))
3632
+ return false;
3633
+ if (ctx.is("application/json"))
3634
+ return true;
3635
+ ctx.status = 415;
3636
+ ctx.body = { error: `${operation} requires application/json` };
3637
+ return false;
3638
+ }
3362
3639
  function authorizeLoopbackManagementRequest(ctx, expectedToken) {
3363
3640
  if (!isLoopbackRequest(ctx)) {
3364
3641
  ctx.status = 403;