@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.
- package/dist/agent-endpoint.d.ts +18 -0
- package/dist/agent-endpoint.js +53 -2
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/apply-job.d.ts +114 -0
- package/dist/apply-job.js +214 -0
- package/dist/apply-job.js.map +1 -0
- package/dist/backend/claude-code.d.ts +25 -0
- package/dist/backend/claude-code.js +127 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +57 -0
- package/dist/backend/codex.js +183 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +159 -0
- package/dist/backend/credential-profile.js +340 -0
- package/dist/backend/credential-profile.js.map +1 -0
- package/dist/backend/factory.js +4 -1
- package/dist/backend/factory.js.map +1 -1
- package/dist/backend/kiro-auth-store.d.ts +25 -0
- package/dist/backend/kiro-auth-store.js +61 -0
- package/dist/backend/kiro-auth-store.js.map +1 -0
- package/dist/backend/kiro.d.ts +8 -0
- package/dist/backend/kiro.js +31 -2
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/muse.d.ts +90 -0
- package/dist/backend/muse.js +437 -0
- package/dist/backend/muse.js.map +1 -0
- package/dist/backend/types.d.ts +54 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/access-manager.d.ts +20 -0
- package/dist/channel/access-manager.js +25 -0
- package/dist/channel/access-manager.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +17 -0
- package/dist/channel/adapters/discord.js +72 -11
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +6 -0
- package/dist/channel/adapters/telegram.js +36 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/markdown-chunk.d.ts +8 -3
- package/dist/channel/markdown-chunk.js +12 -3
- package/dist/channel/markdown-chunk.js.map +1 -1
- package/dist/channel/mcp-server.js +9 -12
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.d.ts +6 -2
- package/dist/channel/mcp-tools.js +6 -36
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/tool-router.js +32 -3
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +18 -0
- package/dist/cli.js +113 -0
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +45 -5
- package/dist/config-validator.js.map +1 -1
- package/dist/connection-secrets.d.ts +96 -0
- package/dist/connection-secrets.js +25 -0
- package/dist/connection-secrets.js.map +1 -0
- package/dist/daemon.d.ts +76 -0
- package/dist/daemon.js +489 -103
- package/dist/daemon.js.map +1 -1
- package/dist/event-log.d.ts +9 -0
- package/dist/event-log.js +21 -0
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-level-config.d.ts +42 -0
- package/dist/fleet-level-config.js +78 -0
- package/dist/fleet-level-config.js.map +1 -0
- package/dist/fleet-lock.d.ts +21 -0
- package/dist/fleet-lock.js +42 -8
- package/dist/fleet-lock.js.map +1 -1
- package/dist/fleet-manager.d.ts +425 -2
- package/dist/fleet-manager.js +2055 -182
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +145 -0
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +11 -1
- package/dist/instance-config-impact.d.ts +55 -0
- package/dist/instance-config-impact.js +152 -0
- package/dist/instance-config-impact.js.map +1 -0
- package/dist/instance-lifecycle.js +8 -4
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +24 -0
- package/dist/locale.js.map +1 -1
- package/dist/logger.js +42 -25
- package/dist/logger.js.map +1 -1
- package/dist/muse-usage-relay.d.ts +72 -0
- package/dist/muse-usage-relay.js +419 -0
- package/dist/muse-usage-relay.js.map +1 -0
- package/dist/outbound-handlers.d.ts +5 -1
- package/dist/outbound-handlers.js +199 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +7 -5
- package/dist/outbound-schemas.js +3 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/provider-probe.d.ts +48 -0
- package/dist/provider-probe.js +121 -0
- package/dist/provider-probe.js.map +1 -0
- package/dist/provider-secret-registry.d.ts +98 -0
- package/dist/provider-secret-registry.js +334 -0
- package/dist/provider-secret-registry.js.map +1 -0
- package/dist/quickstart-api.d.ts +164 -0
- package/dist/quickstart-api.js +351 -0
- package/dist/quickstart-api.js.map +1 -0
- package/dist/quickstart.js +23 -50
- package/dist/quickstart.js.map +1 -1
- package/dist/scheduler/db.js +17 -5
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/db.test.js +34 -1
- package/dist/scheduler/db.test.js.map +1 -1
- package/dist/scheduler/types.d.ts +4 -0
- package/dist/scheduler/types.js.map +1 -1
- package/dist/secret-store.d.ts +25 -0
- package/dist/secret-store.js +165 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/self-restart-limit.d.ts +21 -0
- package/dist/self-restart-limit.js +129 -0
- package/dist/self-restart-limit.js.map +1 -0
- package/dist/settings-api.d.ts +116 -0
- package/dist/settings-api.js +530 -5
- package/dist/settings-api.js.map +1 -1
- package/dist/setup-auth.d.ts +113 -0
- package/dist/setup-auth.js +181 -0
- package/dist/setup-auth.js.map +1 -0
- package/dist/setup-form.d.ts +18 -0
- package/dist/setup-form.js +315 -0
- package/dist/setup-form.js.map +1 -0
- package/dist/setup-host.d.ts +162 -0
- package/dist/setup-host.js +496 -0
- package/dist/setup-host.js.map +1 -0
- package/dist/setup-marker.d.ts +8 -0
- package/dist/setup-marker.js +39 -0
- package/dist/setup-marker.js.map +1 -0
- package/dist/setup-tunnel-consent.d.ts +53 -0
- package/dist/setup-tunnel-consent.js +82 -0
- package/dist/setup-tunnel-consent.js.map +1 -0
- package/dist/setup-wizard.js +4 -0
- package/dist/setup-wizard.js.map +1 -1
- package/dist/steer-capability.js +4 -1
- package/dist/steer-capability.js.map +1 -1
- package/dist/tips.js +1 -1
- package/dist/tips.js.map +1 -1
- package/dist/tmux-manager.js +29 -4
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions-notice.d.ts +38 -0
- package/dist/tool-permissions-notice.js +92 -0
- package/dist/tool-permissions-notice.js.map +1 -0
- package/dist/tool-permissions.d.ts +120 -0
- package/dist/tool-permissions.js +277 -0
- package/dist/tool-permissions.js.map +1 -0
- package/dist/topic-commands.js +1 -0
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-sources.d.ts +12 -1
- package/dist/transcript-sources.js +19 -3
- package/dist/transcript-sources.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +71 -0
- package/dist/tunnel/cloudflared.js +447 -0
- package/dist/tunnel/cloudflared.js.map +1 -0
- package/dist/tunnel/lease.d.ts +75 -0
- package/dist/tunnel/lease.js +209 -0
- package/dist/tunnel/lease.js.map +1 -0
- package/dist/tunnel/manager.d.ts +56 -0
- package/dist/tunnel/manager.js +167 -0
- package/dist/tunnel/manager.js.map +1 -0
- package/dist/tunnel/types.d.ts +119 -0
- package/dist/tunnel/types.js +33 -0
- package/dist/tunnel/types.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/ui/dashboard.html +4 -3
- package/dist/ui/settings.html +808 -156
- package/dist/ui/view.html +2 -2
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +1 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/providers.d.ts +23 -14
- package/dist/usage/providers.js +220 -58
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +13 -0
- package/dist/usage/usage-api.js +69 -1
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/web-api.js +8 -5
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +75 -0
- package/dist/web-auth.js +208 -2
- package/dist/web-auth.js.map +1 -1
- package/dist/web-terminal.d.ts +30 -1
- package/dist/web-terminal.js +86 -3
- package/dist/web-terminal.js.map +1 -1
- package/package.json +2 -1
package/dist/settings-api.js
CHANGED
|
@@ -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, {
|
|
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
|
|
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 =
|
|
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
|
|
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 {
|