@songsid/agend 2.1.5 → 2.1.6-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/dist/agent-endpoint.d.ts +18 -0
  2. package/dist/agent-endpoint.js +53 -2
  3. package/dist/agent-endpoint.js.map +1 -1
  4. package/dist/apply-job.d.ts +114 -0
  5. package/dist/apply-job.js +214 -0
  6. package/dist/apply-job.js.map +1 -0
  7. package/dist/backend/claude-code.d.ts +25 -0
  8. package/dist/backend/claude-code.js +127 -0
  9. package/dist/backend/claude-code.js.map +1 -1
  10. package/dist/backend/codex.d.ts +57 -0
  11. package/dist/backend/codex.js +183 -0
  12. package/dist/backend/codex.js.map +1 -1
  13. package/dist/backend/credential-profile.d.ts +159 -0
  14. package/dist/backend/credential-profile.js +340 -0
  15. package/dist/backend/credential-profile.js.map +1 -0
  16. package/dist/backend/factory.js +4 -1
  17. package/dist/backend/factory.js.map +1 -1
  18. package/dist/backend/kiro-auth-store.d.ts +25 -0
  19. package/dist/backend/kiro-auth-store.js +61 -0
  20. package/dist/backend/kiro-auth-store.js.map +1 -0
  21. package/dist/backend/kiro.d.ts +8 -0
  22. package/dist/backend/kiro.js +31 -2
  23. package/dist/backend/kiro.js.map +1 -1
  24. package/dist/backend/muse.d.ts +90 -0
  25. package/dist/backend/muse.js +437 -0
  26. package/dist/backend/muse.js.map +1 -0
  27. package/dist/backend/types.d.ts +54 -0
  28. package/dist/backend/types.js.map +1 -1
  29. package/dist/channel/access-manager.d.ts +20 -0
  30. package/dist/channel/access-manager.js +25 -0
  31. package/dist/channel/access-manager.js.map +1 -1
  32. package/dist/channel/adapters/discord.d.ts +17 -0
  33. package/dist/channel/adapters/discord.js +72 -11
  34. package/dist/channel/adapters/discord.js.map +1 -1
  35. package/dist/channel/adapters/telegram.d.ts +6 -0
  36. package/dist/channel/adapters/telegram.js +36 -0
  37. package/dist/channel/adapters/telegram.js.map +1 -1
  38. package/dist/channel/markdown-chunk.d.ts +8 -3
  39. package/dist/channel/markdown-chunk.js +12 -3
  40. package/dist/channel/markdown-chunk.js.map +1 -1
  41. package/dist/channel/mcp-server.js +9 -12
  42. package/dist/channel/mcp-server.js.map +1 -1
  43. package/dist/channel/mcp-tools.d.ts +6 -2
  44. package/dist/channel/mcp-tools.js +6 -36
  45. package/dist/channel/mcp-tools.js.map +1 -1
  46. package/dist/channel/tool-router.js +32 -3
  47. package/dist/channel/tool-router.js.map +1 -1
  48. package/dist/channel/types.d.ts +18 -0
  49. package/dist/cli.js +113 -0
  50. package/dist/cli.js.map +1 -1
  51. package/dist/config-validator.js +45 -5
  52. package/dist/config-validator.js.map +1 -1
  53. package/dist/connection-secrets.d.ts +96 -0
  54. package/dist/connection-secrets.js +25 -0
  55. package/dist/connection-secrets.js.map +1 -0
  56. package/dist/daemon.d.ts +76 -0
  57. package/dist/daemon.js +489 -103
  58. package/dist/daemon.js.map +1 -1
  59. package/dist/event-log.d.ts +9 -0
  60. package/dist/event-log.js +21 -0
  61. package/dist/event-log.js.map +1 -1
  62. package/dist/fleet-level-config.d.ts +42 -0
  63. package/dist/fleet-level-config.js +78 -0
  64. package/dist/fleet-level-config.js.map +1 -0
  65. package/dist/fleet-lock.d.ts +21 -0
  66. package/dist/fleet-lock.js +42 -8
  67. package/dist/fleet-lock.js.map +1 -1
  68. package/dist/fleet-manager.d.ts +425 -2
  69. package/dist/fleet-manager.js +2055 -182
  70. package/dist/fleet-manager.js.map +1 -1
  71. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +145 -0
  72. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +11 -1
  73. package/dist/instance-config-impact.d.ts +55 -0
  74. package/dist/instance-config-impact.js +152 -0
  75. package/dist/instance-config-impact.js.map +1 -0
  76. package/dist/instance-lifecycle.js +8 -4
  77. package/dist/instance-lifecycle.js.map +1 -1
  78. package/dist/locale.js +24 -0
  79. package/dist/locale.js.map +1 -1
  80. package/dist/logger.js +42 -25
  81. package/dist/logger.js.map +1 -1
  82. package/dist/muse-usage-relay.d.ts +72 -0
  83. package/dist/muse-usage-relay.js +419 -0
  84. package/dist/muse-usage-relay.js.map +1 -0
  85. package/dist/outbound-handlers.d.ts +5 -1
  86. package/dist/outbound-handlers.js +199 -1
  87. package/dist/outbound-handlers.js.map +1 -1
  88. package/dist/outbound-schemas.d.ts +7 -5
  89. package/dist/outbound-schemas.js +3 -2
  90. package/dist/outbound-schemas.js.map +1 -1
  91. package/dist/provider-probe.d.ts +48 -0
  92. package/dist/provider-probe.js +121 -0
  93. package/dist/provider-probe.js.map +1 -0
  94. package/dist/provider-secret-registry.d.ts +98 -0
  95. package/dist/provider-secret-registry.js +334 -0
  96. package/dist/provider-secret-registry.js.map +1 -0
  97. package/dist/quickstart-api.d.ts +164 -0
  98. package/dist/quickstart-api.js +351 -0
  99. package/dist/quickstart-api.js.map +1 -0
  100. package/dist/quickstart.js +23 -50
  101. package/dist/quickstart.js.map +1 -1
  102. package/dist/scheduler/db.js +17 -5
  103. package/dist/scheduler/db.js.map +1 -1
  104. package/dist/scheduler/db.test.js +34 -1
  105. package/dist/scheduler/db.test.js.map +1 -1
  106. package/dist/scheduler/types.d.ts +4 -0
  107. package/dist/scheduler/types.js.map +1 -1
  108. package/dist/secret-store.d.ts +25 -0
  109. package/dist/secret-store.js +165 -0
  110. package/dist/secret-store.js.map +1 -0
  111. package/dist/self-restart-limit.d.ts +21 -0
  112. package/dist/self-restart-limit.js +129 -0
  113. package/dist/self-restart-limit.js.map +1 -0
  114. package/dist/settings-api.d.ts +116 -0
  115. package/dist/settings-api.js +530 -5
  116. package/dist/settings-api.js.map +1 -1
  117. package/dist/setup-auth.d.ts +113 -0
  118. package/dist/setup-auth.js +181 -0
  119. package/dist/setup-auth.js.map +1 -0
  120. package/dist/setup-form.d.ts +18 -0
  121. package/dist/setup-form.js +315 -0
  122. package/dist/setup-form.js.map +1 -0
  123. package/dist/setup-host.d.ts +162 -0
  124. package/dist/setup-host.js +496 -0
  125. package/dist/setup-host.js.map +1 -0
  126. package/dist/setup-marker.d.ts +8 -0
  127. package/dist/setup-marker.js +39 -0
  128. package/dist/setup-marker.js.map +1 -0
  129. package/dist/setup-tunnel-consent.d.ts +53 -0
  130. package/dist/setup-tunnel-consent.js +82 -0
  131. package/dist/setup-tunnel-consent.js.map +1 -0
  132. package/dist/setup-wizard.js +4 -0
  133. package/dist/setup-wizard.js.map +1 -1
  134. package/dist/steer-capability.js +4 -1
  135. package/dist/steer-capability.js.map +1 -1
  136. package/dist/tips.js +1 -1
  137. package/dist/tips.js.map +1 -1
  138. package/dist/tmux-manager.js +29 -4
  139. package/dist/tmux-manager.js.map +1 -1
  140. package/dist/tool-permissions-notice.d.ts +38 -0
  141. package/dist/tool-permissions-notice.js +92 -0
  142. package/dist/tool-permissions-notice.js.map +1 -0
  143. package/dist/tool-permissions.d.ts +120 -0
  144. package/dist/tool-permissions.js +277 -0
  145. package/dist/tool-permissions.js.map +1 -0
  146. package/dist/topic-commands.js +1 -0
  147. package/dist/topic-commands.js.map +1 -1
  148. package/dist/transcript-sources.d.ts +12 -1
  149. package/dist/transcript-sources.js +19 -3
  150. package/dist/transcript-sources.js.map +1 -1
  151. package/dist/tunnel/cloudflared.d.ts +71 -0
  152. package/dist/tunnel/cloudflared.js +447 -0
  153. package/dist/tunnel/cloudflared.js.map +1 -0
  154. package/dist/tunnel/lease.d.ts +75 -0
  155. package/dist/tunnel/lease.js +209 -0
  156. package/dist/tunnel/lease.js.map +1 -0
  157. package/dist/tunnel/manager.d.ts +56 -0
  158. package/dist/tunnel/manager.js +167 -0
  159. package/dist/tunnel/manager.js.map +1 -0
  160. package/dist/tunnel/types.d.ts +119 -0
  161. package/dist/tunnel/types.js +33 -0
  162. package/dist/tunnel/types.js.map +1 -0
  163. package/dist/types.d.ts +2 -0
  164. package/dist/ui/dashboard.html +4 -3
  165. package/dist/ui/settings.html +808 -156
  166. package/dist/ui/view.html +2 -2
  167. package/dist/usage/i18n-keys.d.ts +1 -1
  168. package/dist/usage/i18n-keys.js +1 -1
  169. package/dist/usage/i18n-keys.js.map +1 -1
  170. package/dist/usage/providers.d.ts +23 -14
  171. package/dist/usage/providers.js +220 -58
  172. package/dist/usage/providers.js.map +1 -1
  173. package/dist/usage/usage-api.d.ts +13 -0
  174. package/dist/usage/usage-api.js +69 -1
  175. package/dist/usage/usage-api.js.map +1 -1
  176. package/dist/web-api.js +8 -5
  177. package/dist/web-api.js.map +1 -1
  178. package/dist/web-auth.d.ts +75 -0
  179. package/dist/web-auth.js +208 -2
  180. package/dist/web-auth.js.map +1 -1
  181. package/dist/web-terminal.d.ts +30 -1
  182. package/dist/web-terminal.js +86 -3
  183. package/dist/web-terminal.js.map +1 -1
  184. package/package.json +2 -1
@@ -5,9 +5,17 @@ import { fileURLToPath } from "node:url";
5
5
  import yaml from "js-yaml";
6
6
  import { KNOWN_BACKENDS, validateFleetConfig, validateClassicBotConfig } from "./config-validator.js";
7
7
  import { clearPausedMarker } from "./pause-marker.js";
8
+ import { buildSettingsImpactSchema, CLASSIC_HOT_CONFIG_KEYS } from "./instance-config-impact.js";
9
+ import { viewOf } from "./apply-job.js";
10
+ import { handleQuickstartRequest } from "./quickstart-api.js";
11
+ import { requestSessionBinding, } from "./connection-secrets.js";
12
+ import { providerRegistryEnvKeys, isReservedProviderEnvKey } from "./provider-secret-registry.js";
8
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
- function json(res, code, body) {
10
- res.writeHead(code, { "Content-Type": "application/json" });
14
+ function json(res, code, body, noStore = false) {
15
+ res.writeHead(code, {
16
+ "Content-Type": "application/json",
17
+ ...(noStore ? { "Cache-Control": "no-store" } : {}),
18
+ });
11
19
  res.end(JSON.stringify(body));
12
20
  }
13
21
  function readBody(req, maxBytes) {
@@ -27,6 +35,21 @@ function readBody(req, maxBytes) {
27
35
  req.on("error", reject);
28
36
  });
29
37
  }
38
+ /**
39
+ * Channel/group identifiers are opaque provider coordinates. JSON numbers
40
+ * are accepted only while they are still exactly representable, then
41
+ * canonicalized to strings before validation/persistence. An unsafe number
42
+ * is rejected rather than silently rounding a Discord snowflake.
43
+ */
44
+ function normalizeChannelIdValue(value, path) {
45
+ if (value === undefined || value === null || typeof value === "string") {
46
+ return { ok: true, value };
47
+ }
48
+ if (typeof value === "number" && Number.isSafeInteger(value)) {
49
+ return { ok: true, value: String(value) };
50
+ }
51
+ return { ok: false, error: `${path} must be a string (or a safe integer)` };
52
+ }
30
53
  export function isSettingsPath(path) {
31
54
  return path === "/settings" || path.startsWith("/api/settings/");
32
55
  }
@@ -107,6 +130,18 @@ export function handleSettingsRequest(req, res, url, ctx) {
107
130
  return true;
108
131
  }
109
132
  // ── Reads ──
133
+ // What each field costs to change, derived from HOT_INSTANCE_CONFIG_KEYS.
134
+ // The page used to carry its own copy of the hot set plus one hand-written
135
+ // badge per field, which could disagree with what the fleet actually does.
136
+ if (method === "GET" && path === "/api/settings/schema") {
137
+ json(res, 200, {
138
+ ...buildSettingsImpactSchema(),
139
+ // Non-null means a restart cannot clear the fleet row, so the page shows
140
+ // the mismatch instead of offering a button that can never succeed.
141
+ fleet_signature_mismatch: ctx.fleetSignatureMismatchKeys?.() ?? null,
142
+ });
143
+ return true;
144
+ }
110
145
  if (method === "GET" && path === "/api/settings/fleet") {
111
146
  json(res, 200, ctx.fleetConfig ?? {});
112
147
  return true;
@@ -124,6 +159,331 @@ export function handleSettingsRequest(req, res, url, ctx) {
124
159
  }
125
160
  return true;
126
161
  }
162
+ // ── Secure Connections & Bots ──
163
+ // These routes deliberately do not reuse the broad fleet/channel CRUD API:
164
+ // a token is accepted only in a body, verified server-side, and never echoed
165
+ // or written to a log/SSE frame. Adapter replacement is owned by FleetManager
166
+ // so writing .env alone can never be reported as "applied".
167
+ if (method === "GET" && path === "/api/settings/connections") {
168
+ if (!ctx.listSecureConnections) {
169
+ json(res, 501, { error: "connection secrets unavailable" });
170
+ return true;
171
+ }
172
+ json(res, 200, ctx.listSecureConnections().map(connection => ({
173
+ ...connection,
174
+ token_present: !!connection.token_present,
175
+ })), true);
176
+ return true;
177
+ }
178
+ // Generic provider API-key registry. Unlike the legacy connection token
179
+ // routes, this endpoint accepts a code-owned spec id only; env key, origin,
180
+ // auth header and verifier are resolved server-side. The body is consumed
181
+ // once and no secret-bearing value is ever placed in a URL, job, SSE frame,
182
+ // or response.
183
+ const providerSecretsEnabled = ctx.providerSecretsEnabled?.() === true;
184
+ if (method === "GET" && (path === "/api/settings/provider-secrets" || path === "/api/settings/secrets")) {
185
+ if (!providerSecretsEnabled) {
186
+ json(res, 404, { error: "not found" }, true);
187
+ return true;
188
+ }
189
+ if (!ctx.listProviderSecrets) {
190
+ json(res, 501, { error: "provider secret registry unavailable" }, true);
191
+ return true;
192
+ }
193
+ json(res, 200, ctx.listProviderSecrets().map(item => ({
194
+ id: item.id,
195
+ display_name: item.display_name,
196
+ kind: item.kind,
197
+ token_present: item.token_present,
198
+ verifier: item.verifier,
199
+ activation: item.activation,
200
+ stale_consumers: item.stale_consumers,
201
+ })), true);
202
+ return true;
203
+ }
204
+ const providerSecretVerifyMatch = path.match(/^\/api\/settings\/(?:provider-secrets|secrets)\/([^/]+)\/verify$/);
205
+ if (method === "POST" && providerSecretVerifyMatch) {
206
+ if (!providerSecretsEnabled) {
207
+ json(res, 404, { error: "not found" }, true);
208
+ return true;
209
+ }
210
+ if (!ctx.verifyProviderSecret) {
211
+ json(res, 501, { error: "provider secret registry unavailable" }, true);
212
+ return true;
213
+ }
214
+ const specId = decodeURIComponent(providerSecretVerifyMatch[1]);
215
+ readBody(req, 16 * 1024).then(async (buf) => {
216
+ let body;
217
+ try {
218
+ body = JSON.parse(buf.toString("utf-8") || "{}");
219
+ }
220
+ catch {
221
+ return json(res, 400, { error: "invalid JSON" }, true);
222
+ }
223
+ const secret = typeof body.secret === "string" ? body.secret : "";
224
+ const key = typeof req.headers["idempotency-key"] === "string"
225
+ ? req.headers["idempotency-key"] : typeof body.idempotency_key === "string" ? body.idempotency_key : "";
226
+ if (!secret)
227
+ return json(res, 400, { error: "secret required" }, true);
228
+ if (!/^[A-Za-z0-9_.:-]{8,128}$/.test(key))
229
+ return json(res, 400, { error: "Idempotency-Key required" }, true);
230
+ const result = await ctx.verifyProviderSecret({ specId, secret, sessionBinding: requestSessionBinding(req), idempotencyKey: key });
231
+ if (!result.ok) {
232
+ const code = result.status === "unsupported_verifier" ? 422 : result.status === "provider_unavailable" ? 503 : 422;
233
+ return json(res, code, { ok: false, result: result.status, error: result.status === "unsupported_verifier" ? "unsupported verifier" : "provider secret verification failed" }, true);
234
+ }
235
+ json(res, 200, { ok: true, result: "verified", verification_id: result.verification_id, expires_at: result.expires_at, spec_id: result.spec_id, activation: result.activation }, true);
236
+ }).catch(() => json(res, 400, { error: "bad request" }, true));
237
+ return true;
238
+ }
239
+ const providerSecretApplyMatch = path.match(/^\/api\/settings\/(?:provider-secrets|secrets)\/([^/]+)\/apply$/);
240
+ if (method === "POST" && providerSecretApplyMatch) {
241
+ if (!providerSecretsEnabled) {
242
+ json(res, 404, { error: "not found" }, true);
243
+ return true;
244
+ }
245
+ if (!ctx.startProviderSecretApply) {
246
+ json(res, 501, { error: "provider secret registry unavailable" }, true);
247
+ return true;
248
+ }
249
+ const specId = decodeURIComponent(providerSecretApplyMatch[1]);
250
+ readBody(req, 16 * 1024).then(buf => {
251
+ let body;
252
+ try {
253
+ body = JSON.parse(buf.toString("utf-8") || "{}");
254
+ }
255
+ catch {
256
+ return json(res, 400, { error: "invalid JSON" }, true);
257
+ }
258
+ const verificationId = typeof body.verification_id === "string" ? body.verification_id : "";
259
+ const key = typeof req.headers["idempotency-key"] === "string"
260
+ ? req.headers["idempotency-key"] : typeof body.idempotency_key === "string" ? body.idempotency_key : "";
261
+ if (!verificationId)
262
+ return json(res, 400, { error: "verification_id required" }, true);
263
+ if (!/^[A-Za-z0-9_.:-]{8,128}$/.test(key))
264
+ return json(res, 400, { error: "Idempotency-Key required" }, true);
265
+ const result = ctx.startProviderSecretApply({ specId, verificationId, sessionBinding: requestSessionBinding(req), idempotencyKey: key });
266
+ if ("error" in result)
267
+ return json(res, 422, { ok: false, error: "provider secret apply rejected" }, true);
268
+ if ("busy" in result)
269
+ return json(res, 409, { ok: false, result: "applying", job_id: result.busy?.id ?? null }, true);
270
+ json(res, result.reused ? 200 : 202, { ok: true, result: result.job.result, job_id: result.job.id, reused: result.reused, stale_consumers: result.job.stale_consumers ?? [] }, true);
271
+ }).catch(() => json(res, 400, { error: "bad request" }, true));
272
+ return true;
273
+ }
274
+ const providerSecretStatusMatch = path.match(/^\/api\/settings\/(?:provider-secrets|secrets)\/[^/]+\/apply\/([A-Za-z0-9_-]+)$/);
275
+ if (method === "GET" && providerSecretStatusMatch) {
276
+ if (!providerSecretsEnabled) {
277
+ json(res, 404, { error: "not found" }, true);
278
+ return true;
279
+ }
280
+ if (!ctx.getProviderSecretApply) {
281
+ json(res, 501, { error: "provider secret registry unavailable" }, true);
282
+ return true;
283
+ }
284
+ const job = ctx.getProviderSecretApply(providerSecretStatusMatch[1], requestSessionBinding(req));
285
+ if (!job) {
286
+ json(res, 404, { error: "job not found" }, true);
287
+ return true;
288
+ }
289
+ json(res, 200, job, true);
290
+ return true;
291
+ }
292
+ const secretVerifyMatch = path.match(/^\/api\/settings\/connections\/([^/]+)\/secret\/verify$/);
293
+ if (method === "POST" && secretVerifyMatch) {
294
+ const verifyConnectionSecret = ctx.verifyConnectionSecret;
295
+ if (!verifyConnectionSecret) {
296
+ json(res, 501, { error: "connection secret verification unavailable" });
297
+ return true;
298
+ }
299
+ const connectionId = decodeURIComponent(secretVerifyMatch[1]);
300
+ readBody(req, 16 * 1024).then(async (buf) => {
301
+ let body;
302
+ try {
303
+ body = JSON.parse(buf.toString("utf-8") || "{}");
304
+ }
305
+ catch {
306
+ return json(res, 400, { error: "invalid JSON" });
307
+ }
308
+ const secret = typeof body.secret === "string" ? body.secret : "";
309
+ const key = typeof req.headers["idempotency-key"] === "string"
310
+ ? req.headers["idempotency-key"] : typeof body.idempotency_key === "string" ? body.idempotency_key : "";
311
+ if (!secret)
312
+ return json(res, 400, { error: "secret required" }, true);
313
+ if (!/^[A-Za-z0-9_.:-]{8,128}$/.test(key))
314
+ return json(res, 400, { error: "Idempotency-Key required" }, true);
315
+ const result = await verifyConnectionSecret({
316
+ connectionId,
317
+ secret,
318
+ sessionBinding: requestSessionBinding(req),
319
+ idempotencyKey: key,
320
+ });
321
+ // The provider may have echoed request material in an SDK error. Keep
322
+ // the HTTP contract deliberately generic; FleetManager logs only a
323
+ // redacted diagnostic and the browser has no need for the raw reason.
324
+ if (!result.ok)
325
+ return json(res, 422, { ok: false, error: "secret verification failed" }, true);
326
+ json(res, 200, {
327
+ ok: true,
328
+ result: "verified",
329
+ verification_id: result.verification_id,
330
+ expires_at: result.expires_at,
331
+ identity: result.identity,
332
+ }, true);
333
+ }).catch(() => json(res, 400, { error: "bad request" }));
334
+ return true;
335
+ }
336
+ const secretApplyMatch = path.match(/^\/api\/settings\/connections\/([^/]+)\/secret\/apply$/);
337
+ if (method === "POST" && secretApplyMatch) {
338
+ if (!ctx.startConnectionSecretApply) {
339
+ json(res, 501, { error: "connection secret apply unavailable" });
340
+ return true;
341
+ }
342
+ const connectionId = decodeURIComponent(secretApplyMatch[1]);
343
+ readBody(req, 16 * 1024).then(buf => {
344
+ let body;
345
+ try {
346
+ body = JSON.parse(buf.toString("utf-8") || "{}");
347
+ }
348
+ catch {
349
+ return json(res, 400, { error: "invalid JSON" });
350
+ }
351
+ const verificationId = typeof body.verification_id === "string" ? body.verification_id : "";
352
+ const key = typeof req.headers["idempotency-key"] === "string"
353
+ ? req.headers["idempotency-key"] : typeof body.idempotency_key === "string" ? body.idempotency_key : "";
354
+ if (!verificationId)
355
+ return json(res, 400, { error: "verification_id required" }, true);
356
+ if (!/^[A-Za-z0-9_.:-]{8,128}$/.test(key))
357
+ return json(res, 400, { error: "Idempotency-Key required" }, true);
358
+ const result = ctx.startConnectionSecretApply({
359
+ connectionId,
360
+ verificationId,
361
+ sessionBinding: requestSessionBinding(req),
362
+ idempotencyKey: key,
363
+ });
364
+ if ("error" in result)
365
+ return json(res, 422, { ok: false, error: "secret apply rejected" }, true);
366
+ if ("busy" in result)
367
+ return json(res, 409, {
368
+ ok: false,
369
+ result: "applying",
370
+ job_id: result.busy?.id ?? null,
371
+ }, true);
372
+ json(res, result.reused ? 200 : 202, {
373
+ ok: true,
374
+ result: result.job.result,
375
+ job_id: result.job.id,
376
+ reused: result.reused,
377
+ }, true);
378
+ }).catch(() => json(res, 400, { error: "bad request" }));
379
+ return true;
380
+ }
381
+ const secretApplyStatusMatch = path.match(/^\/api\/settings\/connections\/[^/]+\/secret\/apply\/([A-Za-z0-9_-]+)$/);
382
+ if (method === "GET" && secretApplyStatusMatch) {
383
+ if (!ctx.getConnectionSecretApply) {
384
+ json(res, 501, { error: "connection secret apply unavailable" });
385
+ return true;
386
+ }
387
+ const job = ctx.getConnectionSecretApply(secretApplyStatusMatch[1], requestSessionBinding(req));
388
+ if (!job) {
389
+ json(res, 404, { error: "job not found" }, true);
390
+ return true;
391
+ }
392
+ // Job errors are already provider-redacted by FleetManager. Never add raw
393
+ // request data here, and never expose a secret-bearing provider response.
394
+ json(res, 200, job, true);
395
+ return true;
396
+ }
397
+ const bindingVerifyMatch = path.match(/^\/api\/settings\/connections\/([^/]+)\/binding\/verify$/);
398
+ if (method === "POST" && bindingVerifyMatch) {
399
+ if (!ctx.verifyConnectionBinding) {
400
+ json(res, 501, { error: "connection binding verification unavailable" });
401
+ return true;
402
+ }
403
+ const connectionId = decodeURIComponent(bindingVerifyMatch[1]);
404
+ readBody(req, 16 * 1024).then(async (buf) => {
405
+ let body;
406
+ try {
407
+ body = JSON.parse(buf.toString("utf-8") || "{}");
408
+ }
409
+ catch {
410
+ return json(res, 400, { error: "invalid JSON" });
411
+ }
412
+ // Provider IDs must arrive as strings. Accepting a JSON number here can
413
+ // round a Discord snowflake before the verifier ever sees it.
414
+ if (typeof body.group_id !== "string"
415
+ || (body.general_channel_id !== undefined
416
+ && body.general_channel_id !== null
417
+ && typeof body.general_channel_id !== "string")) {
418
+ return json(res, 400, { error: "binding IDs must be strings" }, true);
419
+ }
420
+ const key = typeof req.headers["idempotency-key"] === "string"
421
+ ? req.headers["idempotency-key"] : typeof body.idempotency_key === "string" ? body.idempotency_key : "";
422
+ if (!/^[A-Za-z0-9_.:-]{8,128}$/.test(key))
423
+ return json(res, 400, { error: "Idempotency-Key required" }, true);
424
+ const result = await ctx.verifyConnectionBinding({
425
+ connectionId,
426
+ binding: { group_id: body.group_id, general_channel_id: body.general_channel_id },
427
+ sessionBinding: requestSessionBinding(req),
428
+ idempotencyKey: key,
429
+ });
430
+ if (!result.ok)
431
+ return json(res, 422, { ok: false, error: "binding verification failed" }, true);
432
+ json(res, 200, {
433
+ ok: true, result: "verified",
434
+ verification_id: result.verification_id, expires_at: result.expires_at,
435
+ binding: result.binding, probe: result.probe,
436
+ }, true);
437
+ }).catch(() => json(res, 400, { error: "bad request" }));
438
+ return true;
439
+ }
440
+ const bindingApplyMatch = path.match(/^\/api\/settings\/connections\/([^/]+)\/binding\/apply$/);
441
+ if (method === "POST" && bindingApplyMatch) {
442
+ if (!ctx.startConnectionBindingApply) {
443
+ json(res, 501, { error: "connection binding apply unavailable" });
444
+ return true;
445
+ }
446
+ const connectionId = decodeURIComponent(bindingApplyMatch[1]);
447
+ readBody(req, 16 * 1024).then(buf => {
448
+ let body;
449
+ try {
450
+ body = JSON.parse(buf.toString("utf-8") || "{}");
451
+ }
452
+ catch {
453
+ return json(res, 400, { error: "invalid JSON" });
454
+ }
455
+ const verificationId = typeof body.verification_id === "string" ? body.verification_id : "";
456
+ const key = typeof req.headers["idempotency-key"] === "string"
457
+ ? req.headers["idempotency-key"] : typeof body.idempotency_key === "string" ? body.idempotency_key : "";
458
+ if (!verificationId)
459
+ return json(res, 400, { error: "verification_id required" }, true);
460
+ if (!/^[A-Za-z0-9_.:-]{8,128}$/.test(key))
461
+ return json(res, 400, { error: "Idempotency-Key required" }, true);
462
+ const result = ctx.startConnectionBindingApply({
463
+ connectionId, verificationId, sessionBinding: requestSessionBinding(req), idempotencyKey: key,
464
+ });
465
+ if ("error" in result)
466
+ return json(res, 422, { ok: false, error: "binding apply rejected" }, true);
467
+ if ("busy" in result)
468
+ return json(res, 409, { ok: false, result: "applying", job_id: result.busy?.id ?? null }, true);
469
+ json(res, result.reused ? 200 : 202, { ok: true, result: result.job.result, job_id: result.job.id, reused: result.reused }, true);
470
+ }).catch(() => json(res, 400, { error: "bad request" }));
471
+ return true;
472
+ }
473
+ const bindingApplyStatusMatch = path.match(/^\/api\/settings\/connections\/[^/]+\/binding\/apply\/([A-Za-z0-9_-]+)$/);
474
+ if (method === "GET" && bindingApplyStatusMatch) {
475
+ if (!ctx.getConnectionBindingApply) {
476
+ json(res, 501, { error: "connection binding apply unavailable" });
477
+ return true;
478
+ }
479
+ const job = ctx.getConnectionBindingApply(bindingApplyStatusMatch[1], requestSessionBinding(req));
480
+ if (!job) {
481
+ json(res, 404, { error: "job not found" }, true);
482
+ return true;
483
+ }
484
+ json(res, 200, job, true);
485
+ return true;
486
+ }
127
487
  // Everything below mutates — needs an in-memory fleet config.
128
488
  const cfg = ctx.fleetConfig;
129
489
  // ── Manual pause / wake ──
@@ -187,13 +547,68 @@ export function handleSettingsRequest(req, res, url, ctx) {
187
547
  }
188
548
  if (!Array.isArray(body))
189
549
  return json(res, 400, { error: "expected an array of channels" });
190
- const next = { ...cfg, channels: body };
550
+ const normalizedBody = [];
551
+ // Rebinding an existing provider connection is security-sensitive: a
552
+ // broad channel replace must not bypass the positive provider probe and
553
+ // ready-adapter fence exposed by /connections/:id/binding/*.
554
+ const currentChannels = cfg.channels ?? (cfg.channel ? [cfg.channel] : []);
555
+ const currentById = new Map(currentChannels.map((channel, index) => [channel.id ?? channel.type ?? `channel-${index}`, channel]));
556
+ const currentTokenEnvs = new Set(currentChannels.map(channel => channel.bot_token_env).filter(Boolean));
557
+ for (const [index, raw] of body.entries()) {
558
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
559
+ normalizedBody.push(raw);
560
+ continue;
561
+ }
562
+ const candidate = { ...raw };
563
+ const group = normalizeChannelIdValue(candidate.group_id, `channels[${index}].group_id`);
564
+ if (!group.ok)
565
+ return json(res, 400, { ok: false, error: group.error }, true);
566
+ if (group.value !== undefined)
567
+ candidate.group_id = group.value;
568
+ if (candidate.options && typeof candidate.options === "object" && !Array.isArray(candidate.options)) {
569
+ const options = { ...candidate.options };
570
+ const general = normalizeChannelIdValue(options.general_channel_id, `channels[${index}].options.general_channel_id`);
571
+ if (!general.ok)
572
+ return json(res, 400, { ok: false, error: general.error }, true);
573
+ if (general.value !== undefined)
574
+ options.general_channel_id = general.value;
575
+ candidate.options = options;
576
+ }
577
+ normalizedBody.push(candidate);
578
+ const id = typeof candidate.id === "string" ? candidate.id
579
+ : typeof candidate.type === "string" ? candidate.type : `channel-${index}`;
580
+ const previous = currentById.get(id);
581
+ const tokenEnv = typeof candidate.bot_token_env === "string" ? candidate.bot_token_env : null;
582
+ if (tokenEnv && (providerRegistryEnvKeys().has(tokenEnv) || isReservedProviderEnvKey(tokenEnv))) {
583
+ return json(res, 409, { ok: false, error: "bot token env conflicts with a protected provider secret key" }, true);
584
+ }
585
+ const reusedByAnotherChannel = tokenEnv !== null && currentChannels.some((channel, channelIndex) => {
586
+ const ownerId = channel.id ?? channel.type ?? `channel-${channelIndex}`;
587
+ return ownerId !== id && channel.bot_token_env === tokenEnv;
588
+ });
589
+ if ((!previous && tokenEnv !== null && currentTokenEnvs.has(tokenEnv))
590
+ || (previous && tokenEnv !== previous.bot_token_env && reusedByAnotherChannel)) {
591
+ return json(res, 409, { ok: false, error: "new connections reusing an existing bot token require verified rebind" }, true);
592
+ }
593
+ if (!previous)
594
+ continue;
595
+ const oldGeneral = previous.options?.general_channel_id == null ? null : String(previous.options.general_channel_id);
596
+ const nextOptions = candidate.options && typeof candidate.options === "object" && !Array.isArray(candidate.options)
597
+ ? candidate.options : {};
598
+ const nextGeneral = nextOptions.general_channel_id == null ? null : String(nextOptions.general_channel_id);
599
+ const oldGroup = previous.group_id == null ? null : String(previous.group_id);
600
+ const nextGroup = candidate.group_id == null ? null : String(candidate.group_id);
601
+ if (oldGroup !== nextGroup || oldGeneral !== nextGeneral) {
602
+ return json(res, 409, { ok: false, error: "connection binding changes require verified rebind" }, true);
603
+ }
604
+ }
605
+ const next = { ...cfg, channels: normalizedBody };
191
606
  delete next.channel; // channels[] supersedes the legacy single channel
192
607
  const before = validateFleetConfig(cfg);
193
608
  const after = validateFleetConfig(next);
194
609
  if (rejectIfWorse(res, before, after))
195
610
  return;
196
- cfg.channels = body;
611
+ cfg.channels = normalizedBody;
197
612
  delete cfg.channel;
198
613
  ctx.saveFleetConfig();
199
614
  json(res, 200, { ok: true, warnings: saveWarnings(before, after) });
@@ -362,7 +777,7 @@ export function handleSettingsRequest(req, res, url, ctx) {
362
777
  }
363
778
  ctx.logger.info({ key, instanceName }, "settings: updated classic channel");
364
779
  const hotOnly = Object.keys(body).length > 0
365
- && Object.keys(body).every(field => field === "tool_progress" || field === "reply_completion_guard");
780
+ && Object.keys(body).every(field => CLASSIC_HOT_CONFIG_KEYS.has(field));
366
781
  json(res, 200, {
367
782
  ok: true,
368
783
  warnings: saveWarnings(before, after),
@@ -372,7 +787,117 @@ export function handleSettingsRequest(req, res, url, ctx) {
372
787
  }).catch(() => json(res, 400, { error: "bad request" }));
373
788
  return true;
374
789
  }
790
+ // ── Apply (observable) ──
791
+ //
792
+ // Replaces the fire-and-forget SIGHUP below for anything that wants to watch:
793
+ // the answer is a job, and the job is the authority. `apply_progress` SSE
794
+ // frames are an accelerator carrying no event id, so a client that reconnects
795
+ // cannot replay what it missed — it re-reads the job.
796
+ if (method === "POST" && path === "/api/settings/apply") {
797
+ if (!ctx.startSettingsApply) {
798
+ json(res, 501, { error: "apply jobs unavailable" });
799
+ return true;
800
+ }
801
+ readBody(req, 64 * 1024).then(buf => {
802
+ let body = {};
803
+ try {
804
+ body = JSON.parse(buf.toString("utf-8") || "{}");
805
+ }
806
+ catch { /* key may come from the header */ }
807
+ // The client generates the key before its first attempt. A server-minted
808
+ // id cannot deduplicate a retry whose first response was lost.
809
+ const header = req.headers["idempotency-key"];
810
+ const key = (typeof header === "string" ? header : undefined)
811
+ ?? (typeof body.idempotency_key === "string" ? body.idempotency_key : undefined);
812
+ if (!key || !/^[A-Za-z0-9_.:-]{8,128}$/.test(key)) {
813
+ json(res, 400, { error: "Idempotency-Key required (8-128 chars of [A-Za-z0-9_.:-])" });
814
+ return;
815
+ }
816
+ const result = ctx.startSettingsApply(key);
817
+ if ("busy" in result) {
818
+ // One reconcile at a time: two of them stop and start the same agent in
819
+ // parallel. The running job's id lets the client watch that one instead.
820
+ ctx.logger.info({ runningJobId: result.busy?.id ?? null }, "settings: apply refused — a reconcile is already running");
821
+ json(res, 409, {
822
+ error: "a configuration reload is already running",
823
+ running_job_id: result.busy?.id ?? null,
824
+ });
825
+ return;
826
+ }
827
+ ctx.logger.info({ jobId: result.job.id, reused: result.reused }, result.reused
828
+ ? "settings: apply retry rejoined the existing job"
829
+ : "settings: apply job started");
830
+ json(res, result.reused ? 200 : 202, viewOf(result.job));
831
+ }).catch(() => json(res, 400, { error: "bad request" }));
832
+ return true;
833
+ }
834
+ // ── Setup wizard ──
835
+ // Served by the same handlers the pre-fleet setup host serves, so the wizard
836
+ // asks the same questions whether or not a fleet is running yet.
837
+ if (handleQuickstartRequest(req, res, url, ctx))
838
+ return true;
839
+ // ── Restart AgEnD itself ──
840
+ //
841
+ // Deliberately not part of Apply. The panel is reachable from outside the
842
+ // LAN, so restarting the whole fleet is its own action with its own
843
+ // confirmation, its own idempotency key, and its own rate limit.
844
+ if (method === "POST" && path === "/api/settings/restart-fleet") {
845
+ if (!ctx.requestSettingsSelfRestart) {
846
+ json(res, 501, { error: "self restart unavailable" });
847
+ return true;
848
+ }
849
+ readBody(req, 64 * 1024).then(async (buf) => {
850
+ let body = {};
851
+ try {
852
+ body = JSON.parse(buf.toString("utf-8") || "{}");
853
+ }
854
+ catch { /* reported below */ }
855
+ const header = req.headers["idempotency-key"];
856
+ const key = (typeof header === "string" ? header : undefined)
857
+ ?? (typeof body.idempotency_key === "string" ? body.idempotency_key : undefined);
858
+ if (!key || !/^[A-Za-z0-9_.:-]{8,128}$/.test(key)) {
859
+ json(res, 400, { error: "Idempotency-Key required (8-128 chars of [A-Za-z0-9_.:-])" });
860
+ return;
861
+ }
862
+ // A literal, not a boolean: a stray `true` in a replayed body should not
863
+ // be able to mean "yes, restart the fleet".
864
+ if (body.confirm !== "restart-agend") {
865
+ json(res, 400, { error: 'confirm must be the literal "restart-agend"' });
866
+ return;
867
+ }
868
+ const jobId = typeof body.job_id === "string" ? body.job_id : "";
869
+ if (!jobId) {
870
+ json(res, 400, { error: "job_id required" });
871
+ return;
872
+ }
873
+ const result = await ctx.requestSettingsSelfRestart(jobId, key);
874
+ if (result.ok) {
875
+ ctx.logger.info({ jobId: result.jobId, reused: !!result.reused }, "settings: self restart accepted");
876
+ json(res, result.reused ? 200 : 202, { job_id: result.jobId, restarting: true });
877
+ return;
878
+ }
879
+ ctx.logger.warn({ jobId, status: result.status, reason: result.error }, "settings: self restart refused");
880
+ if (result.retryAfterSeconds)
881
+ res.setHeader("Retry-After", String(result.retryAfterSeconds));
882
+ json(res, result.status, {
883
+ error: result.error,
884
+ ...(result.retryAfterSeconds ? { retry_after_seconds: result.retryAfterSeconds } : {}),
885
+ });
886
+ }).catch(() => json(res, 400, { error: "bad request" }));
887
+ return true;
888
+ }
889
+ const jobMatch = path.match(/^\/api\/settings\/apply\/([A-Za-z0-9-]+)$/);
890
+ if (method === "GET" && jobMatch) {
891
+ const job = ctx.applyJobs?.get(jobMatch[1]) ?? null;
892
+ if (!job) {
893
+ json(res, 404, { error: "job not found" });
894
+ return true;
895
+ }
896
+ json(res, 200, viewOf(job));
897
+ return true;
898
+ }
375
899
  // ── Reload (SIGHUP) ──
900
+ // The unobserved path, kept for scripts. Nothing reports what it did.
376
901
  if (method === "POST" && path === "/api/settings/reload") {
377
902
  ctx.logger.info("settings: reload requested — sending SIGHUP");
378
903
  try {