@desplega.ai/agent-swarm 2.5.2 → 2.5.4
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/{acp-adapter-csh0zz3b.js → acp-adapter-ek38cwsv.js} +5 -5
- package/dist/{actions-6xx079d1.js → actions-92mepq03.js} +19 -19
- package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-9gaxg015.js} +4 -4
- package/dist/{app-ft6r6v23.js → app-ctn8gsgx.js} +3 -3
- package/dist/{assistant-vt2cyfzd.js → assistant-zyr5kz47.js} +21 -21
- package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-dwzfttdr.js} +1 -1
- package/dist/{boot-reembed-r3e808ky.js → boot-reembed-w75maga2.js} +1 -1
- package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
- package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-x28v3fhb.js} +4 -4
- package/dist/{claude-adapter-s7j47k06.js → claude-adapter-f0afqnne.js} +5 -5
- package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-x81a7r65.js} +3 -3
- package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-bqzwjd9x.js} +2 -2
- package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
- package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
- package/dist/{cli-4qkg9968.js → cli-19202kew.js} +4 -4
- package/dist/{cli-cyfgxnvb.js → cli-1r5ad7j5.js} +19 -17
- package/dist/{cli-jmcjbjkt.js → cli-25n3mv5d.js} +9 -7
- package/dist/cli-2655k16q.js +140 -0
- package/dist/{cli-wadzapwb.js → cli-277c31dw.js} +1 -1
- package/dist/{cli-n6kmqngw.js → cli-4b2z6n0m.js} +29 -7
- package/dist/{cli-7ccf7akh.js → cli-5bnsc0e1.js} +9 -7
- package/dist/{cli-qrjv7t18.js → cli-621ce7kw.js} +1 -1
- package/dist/{cli-xzge46sj.js → cli-64mh9mf3.js} +20 -6
- package/dist/{cli-b6tnjdvx.js → cli-6a1xg1p7.js} +6 -2
- package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
- package/dist/{cli-e91vshb1.js → cli-7p4jxgtt.js} +5 -5
- package/dist/{cli-f3pfn4x3.js → cli-9j129brq.js} +9 -3
- package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
- package/dist/{cli-15xd0j7w.js → cli-abs8ndtr.js} +12 -12
- package/dist/{cli-h5d22pfs.js → cli-asq5ydm4.js} +219 -309
- package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
- package/dist/{cli-0vgy5wfw.js → cli-bvpgr9ny.js} +2 -2
- package/dist/{cli-3e9vs93g.js → cli-c61mbdds.js} +4 -4
- package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
- package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
- package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
- package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
- package/dist/{cli-b63d08rs.js → cli-eftzyh9y.js} +1 -1
- package/dist/{cli-fgn7yg5r.js → cli-ethgy7ha.js} +1 -1
- package/dist/{cli-86zpfje5.js → cli-ff1tm9n9.js} +1 -1
- package/dist/{cli-cf63dnxj.js → cli-g9vd9ece.js} +37 -12
- package/dist/{cli-j3dwd4kz.js → cli-gbfxp48k.js} +770 -126
- package/dist/{cli-n2918sed.js → cli-gn5af0x5.js} +21 -21
- package/dist/{cli-d172svcw.js → cli-gwhjmh8g.js} +4 -4
- package/dist/{cli-stxdrqs2.js → cli-hh5we352.js} +24 -5
- package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
- package/dist/{cli-csh8fnbg.js → cli-jb30dzvk.js} +473 -58
- package/dist/{cli-vaxw3bg0.js → cli-jbnn4vxb.js} +3 -3
- package/dist/{cli-ha22h0eg.js → cli-k4tjsdx9.js} +45 -40
- package/dist/{cli-f55b6ky0.js → cli-m4ecdxvz.js} +40 -13
- package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
- package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
- package/dist/{cli-3qv8naq8.js → cli-rr7zx19j.js} +1591 -353
- package/dist/{cli-b1tv22wr.js → cli-s682zgqz.js} +1 -1
- package/dist/{cli-bpbcpd8b.js → cli-sbwsxvtd.js} +1 -1
- package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
- package/dist/{cli-6y43v79h.js → cli-tsma7p8v.js} +1 -1
- package/dist/{cli-edtm741g.js → cli-wghjkr0e.js} +5 -5
- package/dist/{cli-aaqnqpyw.js → cli-wx2nj19m.js} +30 -11
- package/dist/{cli-y3dzwyy3.js → cli-x0f2v69c.js} +180 -60
- package/dist/{cli-kcnwn85g.js → cli-xtrz6bzs.js} +7 -7
- package/dist/{cli-14v1sxxy.js → cli-xzfq4at3.js} +7 -7
- package/dist/cli.js +20 -20
- package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-wch12q99.js} +3 -3
- package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
- package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-41pfcr95.js} +2 -2
- package/dist/{commands-1cnvseqx.js → commands-9849dkj6.js} +2 -2
- package/dist/{controller-kc7s9xhn.js → controller-ja0sd95p.js} +29 -3
- package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-xp05dj6x.js} +20 -20
- package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-623qxqew.js} +1 -2
- package/dist/{db-4vq88jkc.js → db-47vj40dy.js} +3 -3
- package/dist/{db-v5ytg5pw.js → db-w759qmx0.js} +2 -2
- package/dist/{dispatcher-trtcrqae.js → dispatcher-7b8e4z2j.js} +17 -17
- package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
- package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-2ezw3xea.js} +2 -3
- package/dist/{external-channel-n9bymc2n.js → external-channel-e2s028da.js} +2 -2
- package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-ys7gaqee.js} +6 -6
- package/dist/{handlers-ptsraqa8.js → handlers-wy1dr6ja.js} +20 -20
- package/dist/{heartbeat-60qtxtjf.js → heartbeat-w3mntghj.js} +20 -20
- package/dist/{hook-rbktcstg.js → hook-3jjywnpb.js} +9 -3
- package/dist/{hook-ngtvh729.js → hook-g7kvv6x9.js} +3 -2
- package/dist/{http-z8xy72gp.js → http-tkp7d4kb.js} +2984 -2483
- package/dist/{index-r70q0xjx.js → index-1ef1myq2.js} +17 -17
- package/dist/{index-a9nsavhx.js → index-3n8m9ea2.js} +1 -1
- package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
- package/dist/{index-s193j5fh.js → index-nmm79qgr.js} +20 -20
- package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
- package/dist/{index-06qkmcx6.js → index-q7m26kvs.js} +33 -23
- package/dist/{index-1cscynkb.js → index-xdaahthb.js} +17 -17
- package/dist/{keepalive-a9srcphj.js → keepalive-v4ta8wgg.js} +3 -3
- package/dist/{lead-3devneqy.js → lead-sawnyqxk.js} +35 -35
- package/dist/{maintenance-0er16h2y.js → maintenance-84n1ex21.js} +1 -1
- package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
- package/dist/{onboard-wqywz5py.js → onboard-sfmpe4np.js} +2 -2
- package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-jmpst4q4.js} +4 -4
- package/dist/{otel-impl-vk32s884.js → otel-impl-v6cc0zve.js} +1 -1
- package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
- package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-fsk1e1zc.js} +3 -3
- package/dist/{rbac-roles-452665y0.js → rbac-roles-prmkdnkw.js} +2 -2
- package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-zxmxqa39.js} +1 -1
- package/dist/{render-v2-0ttsk2j4.js → render-v2-pjwxvnkw.js} +3 -3
- package/dist/{resume-a26kvr41.js → resume-frdtgfa0.js} +9 -5
- package/dist/{session-access-dgh9nkwk.js → session-access-fpez4y5q.js} +4 -4
- package/dist/{setup-9vc9qahm.js → setup-mq6m3m8q.js} +1 -1
- package/dist/{work-objects-vn8nkxwn.js → work-objects-kfahhjsq.js} +20 -20
- package/dist/{worker-1x1h8vnm.js → worker-v5e5rxj3.js} +35 -35
- package/openapi.json +3078 -2424
- package/package.json +7 -7
- package/plugin/commands/create-pr.md +2 -2
- package/plugin/pi-skills/create-pr/SKILL.md +2 -2
- package/src/be/approval-config-question.ts +389 -0
- package/src/be/automation-preflight.ts +37 -3
- package/src/be/db/approvals.ts +48 -0
- package/src/be/db/session-tree.ts +73 -15
- package/src/be/db/skill-search.ts +87 -0
- package/src/be/db/tasks/read.ts +29 -2
- package/src/be/db/tasks/write.ts +15 -5
- package/src/be/db/workflow-idempotency.ts +183 -0
- package/src/be/db/workflow-runs.ts +366 -8
- package/src/be/db.ts +92 -22
- package/src/be/migrations/239_skills_fts.sql +40 -0
- package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
- package/src/be/migrations/241_skills_meta_fts.sql +39 -0
- package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
- package/src/be/sealed-json.ts +1 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
- package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
- package/src/be/seed-skills/bundled-files.generated.json +1 -1
- package/src/be/sql-connections.ts +7 -1
- package/src/be/swarm-config-guard.ts +25 -0
- package/src/be/users.ts +9 -0
- package/src/commands/controller.ts +33 -0
- package/src/commands/runner.ts +6 -2
- package/src/extensions/apply-task-create.ts +57 -11
- package/src/extensions/catalog.generated.json +4 -4
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/extensions/contract.ts +25 -0
- package/src/extensions/dispatcher.ts +4 -0
- package/src/extensions/post-bridge.ts +8 -0
- package/src/hooks/context-bound-note.ts +196 -0
- package/src/hooks/hook.ts +12 -0
- package/src/http/approval-requests.ts +209 -38
- package/src/http/config.ts +70 -53
- package/src/http/idempotency-key.ts +117 -0
- package/src/http/sessions.ts +27 -2
- package/src/http/workflows.ts +129 -24
- package/src/integrations/chat-sdk/inbound.ts +5 -4
- package/src/prompts/context-bound-note.ts +48 -0
- package/src/prompts/session-templates.ts +25 -1
- package/src/providers/amp-adapter.ts +30 -8
- package/src/providers/claude-adapter.ts +29 -4
- package/src/providers/claude-managed-adapter.ts +26 -0
- package/src/providers/claude-sdk-session.ts +10 -0
- package/src/providers/claude-session-events.ts +13 -0
- package/src/providers/codex-adapter.ts +12 -0
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/opencode-adapter.ts +18 -0
- package/src/providers/pi-mono-adapter.ts +35 -0
- package/src/runtime-providers/docker.ts +6 -2
- package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
- package/src/tests/amp-adapter.test.ts +97 -0
- package/src/tests/approval-config-question.test.ts +702 -0
- package/src/tests/approval-requests.test.ts +10 -7
- package/src/tests/approval-sweeps.test.ts +12 -6
- package/src/tests/base-prompt.test.ts +1 -1
- package/src/tests/chat-sdk-inbound.test.ts +20 -0
- package/src/tests/claude-hook-output.test.ts +79 -1
- package/src/tests/claude-managed-adapter.test.ts +275 -0
- package/src/tests/codex-adapter.test.ts +163 -0
- package/src/tests/context-bound-note.test.ts +269 -0
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
- package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
- package/src/tests/extensions-pre-task.test.ts +273 -0
- package/src/tests/fetch-resolved-env.test.ts +17 -0
- package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
- package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
- package/src/tests/fixtures/fake-amp.ts +22 -8
- package/src/tests/fixtures/held-template-render.ts +42 -0
- package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
- package/src/tests/list-endpoint-slimming.test.ts +33 -0
- package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
- package/src/tests/openapi-route-headers.test.ts +2 -0
- package/src/tests/opencode-adapter.test.ts +134 -0
- package/src/tests/pi-mono-adapter.test.ts +105 -0
- package/src/tests/prompt-template-session.test.ts +4 -3
- package/src/tests/runner-push-delivery.test.ts +25 -0
- package/src/tests/runtime-controller-liveness.test.ts +133 -0
- package/src/tests/runtime-providers-docker.test.ts +2 -0
- package/src/tests/script-connections.test.ts +9 -0
- package/src/tests/script-run-config-scope.test.ts +91 -3
- package/src/tests/session-logs.test.ts +70 -0
- package/src/tests/sessions-tree.test.ts +205 -1
- package/src/tests/skill-search-long-content.test.ts +193 -0
- package/src/tests/skill-search.test.ts +173 -22
- package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
- package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
- package/src/tests/workflow-idempotency.test.ts +915 -0
- package/src/tests/workflow-sub-workflow.test.ts +538 -1
- package/src/tools/request-human-input.ts +27 -5
- package/src/tools/workflows/cancel-workflow-run.ts +1 -1
- package/src/types.ts +7 -0
- package/src/utils/context-window.ts +8 -6
- package/src/workflows/approval-cancel.ts +15 -13
- package/src/workflows/approval-notifications.ts +15 -4
- package/src/workflows/checkpoint.ts +72 -35
- package/src/workflows/engine.ts +262 -93
- package/src/workflows/executors/base.ts +6 -0
- package/src/workflows/executors/foreach.ts +2 -2
- package/src/workflows/executors/human-in-the-loop.ts +25 -1
- package/src/workflows/executors/sub-workflow.ts +45 -16
- package/src/workflows/foreach-driver.ts +10 -3
- package/src/workflows/foreach-join.ts +29 -6
- package/src/workflows/recovery.ts +68 -1
- package/src/workflows/resume.ts +228 -77
- package/src/workflows/retry-poller.ts +74 -7
- package/src/workflows/task-step-routing.ts +29 -7
- package/templates/ai-toolbox.manifest.json +8 -8
- package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
- package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
- package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
- package/templates/extensions/comms-adherence-gate/README.md +38 -13
- package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
- package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
- package/templates/skills/agent-browser/SKILL.md +4 -4
- package/templates/skills/agent-browser/content.md +4 -4
- package/templates/skills/code-quality/config.json +1 -1
- package/templates/skills/code-quality/content.md +2 -2
- package/templates/skills/delegate-work/content.md +29 -25
- package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
- package/templates/skills/qa/content.md +2 -2
- package/templates/skills/researching/content.md +2 -0
- package/templates/skills/turso-interaction/content.md +1 -1
- package/templates/skills/work-on-task/SKILL.md +1 -1
- package/templates/skills/work-on-task/content.md +1 -1
- package/templates/skills/workflow-iterate/content.md +8 -1
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
applyConfigAnswer,
|
|
5
|
+
type ConfigAnswer,
|
|
6
|
+
ConfigQuestionSettingsSchema,
|
|
7
|
+
type ConfigResult,
|
|
8
|
+
type ConfigState,
|
|
9
|
+
ConfigStateSchema,
|
|
10
|
+
isConfigQuestion,
|
|
11
|
+
keptConfigResult,
|
|
12
|
+
normalizeApprovalQuestions,
|
|
13
|
+
parseConfigAnswer,
|
|
14
|
+
readConfigState,
|
|
15
|
+
type StoredConfigQuestion,
|
|
16
|
+
skippedConfigResult,
|
|
17
|
+
} from "../be/approval-config-question";
|
|
3
18
|
import { resolveTaskAuditUserId } from "../be/audit-user";
|
|
4
19
|
import {
|
|
5
20
|
type ApprovalRequest,
|
|
@@ -16,8 +31,9 @@ import {
|
|
|
16
31
|
recordApprovalVotes,
|
|
17
32
|
resolveApprovalRequest,
|
|
18
33
|
} from "../be/db";
|
|
34
|
+
import { getUserGrant } from "../be/rbac-roles";
|
|
19
35
|
import { findUserById } from "../be/users";
|
|
20
|
-
import { can, type RbacPrincipal } from "../rbac";
|
|
36
|
+
import { can, isRbacEnabled, type RbacPrincipal } from "../rbac";
|
|
21
37
|
import type { User } from "../types";
|
|
22
38
|
import { getRequestAuth } from "../utils/request-auth-context";
|
|
23
39
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
@@ -27,6 +43,7 @@ import {
|
|
|
27
43
|
dispatchApprovalNotifications,
|
|
28
44
|
} from "../workflows/approval-notifications";
|
|
29
45
|
import { workflowEventBus } from "../workflows/event-bus";
|
|
46
|
+
import { ensureConfigAdmin, runConfigWriteSideEffects } from "./config";
|
|
30
47
|
import { bodyTask, route } from "./route-def";
|
|
31
48
|
import { jsonError } from "./utils";
|
|
32
49
|
|
|
@@ -34,7 +51,7 @@ import { jsonError } from "./utils";
|
|
|
34
51
|
|
|
35
52
|
const QuestionSchema = z.object({
|
|
36
53
|
id: z.string(),
|
|
37
|
-
type: z.enum(["approval", "text", "single-select", "multi-select", "boolean"]),
|
|
54
|
+
type: z.enum(["approval", "text", "single-select", "multi-select", "boolean", "config"]),
|
|
38
55
|
label: z.string(),
|
|
39
56
|
required: z.boolean().optional(),
|
|
40
57
|
description: z.string().optional(),
|
|
@@ -52,8 +69,14 @@ const QuestionSchema = z.object({
|
|
|
52
69
|
minSelections: z.number().int().min(0).optional(),
|
|
53
70
|
maxSelections: z.number().int().min(1).optional(),
|
|
54
71
|
defaultValue: z.boolean().optional(),
|
|
72
|
+
config: ConfigQuestionSettingsSchema.optional().describe(
|
|
73
|
+
"Config question settings. Answers are written to swarm_config and replaced by a result that never holds a secret value.",
|
|
74
|
+
),
|
|
55
75
|
});
|
|
56
76
|
|
|
77
|
+
/** Create input: `label` may be omitted on a config question (it defaults to the key). */
|
|
78
|
+
const QuestionInputSchema = QuestionSchema.extend({ label: z.string().optional() });
|
|
79
|
+
|
|
57
80
|
export type ApprovalQuestion = z.infer<typeof QuestionSchema>;
|
|
58
81
|
|
|
59
82
|
// ─── Response Schemas ────────────────────────────────────────────────────────
|
|
@@ -133,6 +156,12 @@ const ApprovalRequestSchema = z.object({
|
|
|
133
156
|
createdBy: z.string().optional(),
|
|
134
157
|
createdAt: z.string(),
|
|
135
158
|
updatedAt: z.string(),
|
|
159
|
+
configState: z
|
|
160
|
+
.record(z.string(), ConfigStateSchema)
|
|
161
|
+
.optional()
|
|
162
|
+
.describe(
|
|
163
|
+
"Live state of each config question's key, by question id. Read at request time and never stored. `value` appears only for non-secret keys.",
|
|
164
|
+
),
|
|
136
165
|
});
|
|
137
166
|
|
|
138
167
|
/** Slim list row (`?fields=slim`) — see `ApprovalRequestSummary`. */
|
|
@@ -143,6 +172,7 @@ const ApprovalRequestSummarySchema = ApprovalRequestSchema.omit({
|
|
|
143
172
|
approvals: true,
|
|
144
173
|
resolutionReason: true,
|
|
145
174
|
notificationChannels: true,
|
|
175
|
+
configState: true,
|
|
146
176
|
}).extend({ questionCount: z.number().int() });
|
|
147
177
|
|
|
148
178
|
/**
|
|
@@ -152,11 +182,16 @@ const ApprovalRequestSummarySchema = ApprovalRequestSchema.omit({
|
|
|
152
182
|
*/
|
|
153
183
|
async function toApprovalRequestResponse(
|
|
154
184
|
request: ApprovalRequest,
|
|
185
|
+
options: { withConfigState?: boolean } = {},
|
|
155
186
|
): Promise<z.infer<typeof ApprovalRequestSchema>> {
|
|
156
187
|
const approvers = request.approvers as ApproversShape;
|
|
157
188
|
const approvals = request.approvals ?? null;
|
|
189
|
+
const configState = options.withConfigState
|
|
190
|
+
? await readConfigStates(request.questions as ApprovalQuestion[])
|
|
191
|
+
: undefined;
|
|
158
192
|
return {
|
|
159
193
|
...request,
|
|
194
|
+
...(configState ? { configState } : {}),
|
|
160
195
|
questions: request.questions as ApprovalQuestion[],
|
|
161
196
|
approvers,
|
|
162
197
|
responses: request.responses as Record<string, unknown> | null,
|
|
@@ -188,6 +223,28 @@ async function withApprovalProgress(
|
|
|
188
223
|
);
|
|
189
224
|
}
|
|
190
225
|
|
|
226
|
+
/** Live state per config question id, or undefined when there are none. */
|
|
227
|
+
async function readConfigStates(
|
|
228
|
+
questions: ApprovalQuestion[],
|
|
229
|
+
): Promise<Record<string, ConfigState> | undefined> {
|
|
230
|
+
const configQuestions = questions.filter(isConfigQuestion);
|
|
231
|
+
if (configQuestions.length === 0) return undefined;
|
|
232
|
+
const entries = await Promise.all(
|
|
233
|
+
configQuestions.map(async (q) => [q.id, await readConfigState(q.config)] as const),
|
|
234
|
+
);
|
|
235
|
+
return Object.fromEntries(entries);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Thrown inside the respond transaction to roll back config writes. */
|
|
239
|
+
class ConfigRespondAbort extends Error {
|
|
240
|
+
constructor(
|
|
241
|
+
readonly kind: "unavailable" | "conflict",
|
|
242
|
+
message: string = kind,
|
|
243
|
+
) {
|
|
244
|
+
super(message);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
191
248
|
export async function getWorkflowApprovalUnavailableReason(
|
|
192
249
|
request: ApprovalRequest,
|
|
193
250
|
): Promise<string | null> {
|
|
@@ -241,6 +298,15 @@ function hasRequiredResponse(question: ApprovalQuestion, response: unknown): boo
|
|
|
241
298
|
}
|
|
242
299
|
case "boolean":
|
|
243
300
|
return typeof response === "boolean";
|
|
301
|
+
case "config": {
|
|
302
|
+
// Shape only. The respond handler checks `keep` against the live row.
|
|
303
|
+
if (typeof response !== "object" || response === null) return false;
|
|
304
|
+
const answer = response as { action?: unknown; value?: unknown };
|
|
305
|
+
if (answer.action === "keep") return true;
|
|
306
|
+
return (
|
|
307
|
+
answer.action === "set" && typeof answer.value === "string" && answer.value.trim() !== ""
|
|
308
|
+
);
|
|
309
|
+
}
|
|
244
310
|
}
|
|
245
311
|
}
|
|
246
312
|
|
|
@@ -264,7 +330,12 @@ const createRoute = route({
|
|
|
264
330
|
tags: ["ApprovalRequests"],
|
|
265
331
|
body: z.object({
|
|
266
332
|
title: z.string().min(1),
|
|
267
|
-
questions: z
|
|
333
|
+
questions: z
|
|
334
|
+
.array(QuestionInputSchema)
|
|
335
|
+
.min(1)
|
|
336
|
+
.describe(
|
|
337
|
+
'A request with a `config` question needs `approvers.policy: "any"`. The answer is written to swarm_config; the request stores only `{ key, scope, scopeId, isSecret, status, updatedAt }` (plus `value` for non-secret keys).',
|
|
338
|
+
),
|
|
268
339
|
approvers: z.object({
|
|
269
340
|
users: z.array(z.string()).optional(),
|
|
270
341
|
roles: z.array(z.string()).optional(),
|
|
@@ -323,6 +394,8 @@ const getByIdRoute = route({
|
|
|
323
394
|
404: { description: "Not found" },
|
|
324
395
|
},
|
|
325
396
|
auth: { apiKey: true },
|
|
397
|
+
description:
|
|
398
|
+
"Includes `configState` when the request has `config` questions: the live state of each target key, read now.",
|
|
326
399
|
});
|
|
327
400
|
|
|
328
401
|
const respondRoute = route({
|
|
@@ -331,7 +404,7 @@ const respondRoute = route({
|
|
|
331
404
|
pattern: ["api", "approval-requests", null, "respond"],
|
|
332
405
|
summary: "Submit a response to an approval request",
|
|
333
406
|
description:
|
|
334
|
-
|
|
407
|
+
'Only a person may answer: a user token, a page session signed for a user, or the shared key with no agent identity (recorded as `operator`). The responder is taken from the credential. When the request lists `approvers.users` or `approvers.roles`, a user must match one of them. A rejection resolves the request at once; approvals resolve it when the `any`, `all` or `{ min: N }` policy is met, and until then it stays pending with the answer recorded in `approvals`. A `config` question takes `{ action: "set", value }`, `{ action: "keep" }` (key must exist) or `{ action: "skip" }` (optional questions only); `set` also needs the config write permission. The value is written only when the request is approved, and the stored answer is the result, never a secret value.',
|
|
335
408
|
tags: ["ApprovalRequests"],
|
|
336
409
|
params: z.object({ id: z.string().uuid() }),
|
|
337
410
|
body: z.object({
|
|
@@ -487,11 +560,47 @@ export async function handleApprovalRequests(
|
|
|
487
560
|
return true;
|
|
488
561
|
}
|
|
489
562
|
|
|
563
|
+
// Config answers (D6) are checked here and replaced by their results (D7)
|
|
564
|
+
// before the vote exists, so no plaintext reaches any stored or sent copy.
|
|
565
|
+
const configQuestions = questions.filter(isConfigQuestion);
|
|
566
|
+
const configAnswers = new Map<
|
|
567
|
+
string,
|
|
568
|
+
{ question: StoredConfigQuestion; answer: ConfigAnswer }
|
|
569
|
+
>();
|
|
570
|
+
for (const question of configQuestions) {
|
|
571
|
+
const state = await readConfigState(question.config);
|
|
572
|
+
const answer = parseConfigAnswer(question, parsed.body.responses[question.id], state);
|
|
573
|
+
if (!answer.ok) {
|
|
574
|
+
jsonError(res, answer.error, 400);
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
configAnswers.set(question.id, { question, answer: answer.answer });
|
|
578
|
+
}
|
|
579
|
+
if (configAnswers.size > 0 && approvers.policy !== "any") {
|
|
580
|
+
jsonError(res, 'Config questions need approvers.policy "any"', 409);
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
if ([...configAnswers.values()].some(({ answer }) => answer.action === "set")) {
|
|
584
|
+
if (!(await ensureConfigAdmin(req, res, "config.write.any"))) return true;
|
|
585
|
+
// Admission only checked `approval.respond`; `ensureConfigAdmin` passes
|
|
586
|
+
// every user, so check the config write grant here as PUT /api/config would.
|
|
587
|
+
const auth = getRequestAuth(req);
|
|
588
|
+
if (auth?.kind === "user" && isRbacEnabled()) {
|
|
589
|
+
const grant = await getUserGrant(auth.userId);
|
|
590
|
+
if (!grant.grantsAll && !grant.verbs.has("config.write.any")) {
|
|
591
|
+
jsonError(res, "Forbidden: setting a config value needs 'config.write.any'", 403);
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const safeResponses: Record<string, unknown> = { ...parsed.body.responses };
|
|
597
|
+
for (const id of configAnswers.keys()) delete safeResponses[id];
|
|
598
|
+
|
|
490
599
|
// Determine status from responses: if any approval question has approved: false → rejected
|
|
491
600
|
let status: "approved" | "rejected" = "approved";
|
|
492
601
|
for (const q of questions) {
|
|
493
602
|
if (q.type === "approval") {
|
|
494
|
-
const answer =
|
|
603
|
+
const answer = safeResponses[q.id] as { approved?: boolean } | undefined;
|
|
495
604
|
if (answer && answer.approved === false) {
|
|
496
605
|
status = "rejected";
|
|
497
606
|
break;
|
|
@@ -500,38 +609,89 @@ export async function handleApprovalRequests(
|
|
|
500
609
|
}
|
|
501
610
|
|
|
502
611
|
const responderId = responderKey(responder.responder);
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
approved: status === "approved",
|
|
506
|
-
responses: parsed.body.responses,
|
|
507
|
-
...(parsed.body.respondedBy ? { claimedRespondedBy: parsed.body.respondedBy } : {}),
|
|
508
|
-
respondedAt: new Date().toISOString(),
|
|
509
|
-
};
|
|
612
|
+
const respondedAt = new Date().toISOString();
|
|
613
|
+
const configWrites: { result: ConfigResult; value: string }[] = [];
|
|
510
614
|
// One write transaction (BEGIN IMMEDIATE) so concurrent answers to an
|
|
511
615
|
// `all` / `{ min: N }` request append in turn and never drop each other.
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
616
|
+
// Config writes share it, so a lost race writes nothing.
|
|
617
|
+
const outcome = await getDbClient()
|
|
618
|
+
.transaction(async () => {
|
|
619
|
+
const current = await getApprovalRequestById(parsed.params.id);
|
|
620
|
+
if (!current || current.status !== "pending") return { kind: "unavailable" as const };
|
|
621
|
+
const prior = current.approvals ?? [];
|
|
622
|
+
if (prior.some((v) => v.responder === responderId)) return { kind: "duplicate" as const };
|
|
623
|
+
|
|
624
|
+
// Config values are written only when the request resolves approved (D8).
|
|
625
|
+
const responses = { ...safeResponses };
|
|
626
|
+
for (const [id, { question, answer }] of configAnswers) {
|
|
627
|
+
if (status === "rejected" || answer.action === "skip") {
|
|
628
|
+
responses[id] = skippedConfigResult(question);
|
|
629
|
+
} else if (answer.action === "keep") {
|
|
630
|
+
const kept = await keptConfigResult(question);
|
|
631
|
+
if (!kept) {
|
|
632
|
+
throw new ConfigRespondAbort(
|
|
633
|
+
"conflict",
|
|
634
|
+
`config question "${id}": the key was removed before the answer was saved`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
responses[id] = kept;
|
|
638
|
+
} else {
|
|
639
|
+
const written = await applyConfigAnswer(question, answer.value);
|
|
640
|
+
configWrites.push({ result: written, value: answer.value });
|
|
641
|
+
responses[id] = written;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const vote: ApprovalVote = {
|
|
646
|
+
responder: responderId,
|
|
647
|
+
approved: status === "approved",
|
|
648
|
+
responses,
|
|
649
|
+
...(parsed.body.respondedBy ? { claimedRespondedBy: parsed.body.respondedBy } : {}),
|
|
650
|
+
respondedAt,
|
|
651
|
+
};
|
|
652
|
+
const votes = [...prior, vote];
|
|
653
|
+
if (status === "rejected" || (await approvalQuorumMet(approvers, votes))) {
|
|
654
|
+
const resolved = await resolveApprovalRequest(
|
|
655
|
+
parsed.params.id,
|
|
656
|
+
{ status, responses, approvals: votes, resolvedBy: responderId },
|
|
657
|
+
{ requireActionableWorkflow: true },
|
|
658
|
+
);
|
|
659
|
+
if (!resolved && configWrites.length > 0) throw new ConfigRespondAbort("unavailable");
|
|
660
|
+
return resolved
|
|
661
|
+
? { kind: "resolved" as const, request: resolved }
|
|
662
|
+
: { kind: "unavailable" as const };
|
|
663
|
+
}
|
|
664
|
+
const recorded = (await recordApprovalVotes(parsed.params.id, votes))
|
|
665
|
+
? await getApprovalRequestById(parsed.params.id)
|
|
666
|
+
: null;
|
|
667
|
+
return recorded
|
|
668
|
+
? { kind: "recorded" as const, request: recorded }
|
|
526
669
|
: { kind: "unavailable" as const };
|
|
670
|
+
})
|
|
671
|
+
.catch((error: unknown) => {
|
|
672
|
+
if (!(error instanceof ConfigRespondAbort)) throw error;
|
|
673
|
+
// Rolled back: nothing was written.
|
|
674
|
+
configWrites.length = 0;
|
|
675
|
+
return error.kind === "conflict"
|
|
676
|
+
? { kind: "conflict" as const, message: error.message }
|
|
677
|
+
: { kind: "unavailable" as const };
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
if (outcome.kind === "conflict") {
|
|
681
|
+
jsonError(res, outcome.message, 409);
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
// The transaction has committed: run what PUT /api/config runs after a write.
|
|
685
|
+
for (const { result, value } of configWrites) {
|
|
686
|
+
try {
|
|
687
|
+
await runConfigWriteSideEffects(result.scope, result.key, value);
|
|
688
|
+
} catch (err) {
|
|
689
|
+
console.error(
|
|
690
|
+
"[approval-requests] Config side effects failed:",
|
|
691
|
+
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
692
|
+
);
|
|
527
693
|
}
|
|
528
|
-
|
|
529
|
-
? await getApprovalRequestById(parsed.params.id)
|
|
530
|
-
: null;
|
|
531
|
-
return recorded
|
|
532
|
-
? { kind: "recorded" as const, request: recorded }
|
|
533
|
-
: { kind: "unavailable" as const };
|
|
534
|
-
});
|
|
694
|
+
}
|
|
535
695
|
|
|
536
696
|
if (outcome.kind === "duplicate") {
|
|
537
697
|
jsonError(res, "You already answered this approval request", 409);
|
|
@@ -576,7 +736,7 @@ export async function handleApprovalRequests(
|
|
|
576
736
|
await createApprovalFollowUpTask(updated, "hitl.follow_up");
|
|
577
737
|
|
|
578
738
|
respondRoute.respond(res, 200, {
|
|
579
|
-
approvalRequest: await toApprovalRequestResponse(updated),
|
|
739
|
+
approvalRequest: await toApprovalRequestResponse(updated, { withConfigState: true }),
|
|
580
740
|
});
|
|
581
741
|
return true;
|
|
582
742
|
}
|
|
@@ -617,7 +777,7 @@ export async function handleApprovalRequests(
|
|
|
617
777
|
}
|
|
618
778
|
|
|
619
779
|
getByIdRoute.respond(res, 200, {
|
|
620
|
-
approvalRequest: await toApprovalRequestResponse(request),
|
|
780
|
+
approvalRequest: await toApprovalRequestResponse(request, { withConfigState: true }),
|
|
621
781
|
});
|
|
622
782
|
return true;
|
|
623
783
|
}
|
|
@@ -638,6 +798,15 @@ export async function handleApprovalRequests(
|
|
|
638
798
|
}
|
|
639
799
|
}
|
|
640
800
|
|
|
801
|
+
const normalized = await normalizeApprovalQuestions(parsed.body.questions, {
|
|
802
|
+
approvers: parsed.body.approvers,
|
|
803
|
+
});
|
|
804
|
+
if (!normalized.ok) {
|
|
805
|
+
jsonError(res, normalized.error, 400);
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
808
|
+
const questions = normalized.questions;
|
|
809
|
+
|
|
641
810
|
const id = crypto.randomUUID();
|
|
642
811
|
// Prefer a trusted authenticated user (never client-controlled); else fall
|
|
643
812
|
// back to the ownership-validated sourceTaskId the request body carries.
|
|
@@ -651,7 +820,7 @@ export async function handleApprovalRequests(
|
|
|
651
820
|
const request = await createApprovalRequest({
|
|
652
821
|
id,
|
|
653
822
|
title: parsed.body.title,
|
|
654
|
-
questions
|
|
823
|
+
questions,
|
|
655
824
|
approvers: parsed.body.approvers,
|
|
656
825
|
workflowRunId: parsed.body.workflowRunId,
|
|
657
826
|
workflowRunStepId: parsed.body.workflowRunStepId,
|
|
@@ -668,7 +837,7 @@ export async function handleApprovalRequests(
|
|
|
668
837
|
dispatchApprovalNotifications({
|
|
669
838
|
requestId: request.id,
|
|
670
839
|
title: request.title,
|
|
671
|
-
questions
|
|
840
|
+
questions,
|
|
672
841
|
timeoutSeconds: parsed.body.timeoutSeconds,
|
|
673
842
|
notifications,
|
|
674
843
|
}).catch((err) => {
|
|
@@ -705,7 +874,9 @@ export async function handleApprovalRequests(
|
|
|
705
874
|
|
|
706
875
|
const requests = await listApprovalRequests(filters);
|
|
707
876
|
listRoute.respond(res, 200, {
|
|
708
|
-
approvalRequests: await Promise.all(
|
|
877
|
+
approvalRequests: await Promise.all(
|
|
878
|
+
requests.map((request) => toApprovalRequestResponse(request)),
|
|
879
|
+
),
|
|
709
880
|
});
|
|
710
881
|
return true;
|
|
711
882
|
}
|
package/src/http/config.ts
CHANGED
|
@@ -21,13 +21,11 @@ import {
|
|
|
21
21
|
internalConfigKeyError,
|
|
22
22
|
isApiOnlyConfigKey,
|
|
23
23
|
isInternalConfigKey,
|
|
24
|
-
|
|
25
|
-
reservedKeyError,
|
|
26
|
-
validateConfigValue,
|
|
27
|
-
validateExportToEnv,
|
|
24
|
+
validateConfigEntry,
|
|
28
25
|
} from "../be/swarm-config-guard";
|
|
26
|
+
import { isScriptRunToken } from "../be/users";
|
|
29
27
|
import { can, isRbacEnabled, type PermissionVerb } from "../rbac";
|
|
30
|
-
import { SwarmConfigSchema } from "../types";
|
|
28
|
+
import { type SwarmConfig, SwarmConfigSchema } from "../types";
|
|
31
29
|
import { getRequestAuth, type HttpRequestAuth } from "../utils/request-auth-context";
|
|
32
30
|
import { registerVolatileSecret } from "../utils/secret-scrubber";
|
|
33
31
|
import { reloadGlobalConfigsAndIntegrations, scheduleIntegrationsReload } from "./core";
|
|
@@ -114,6 +112,49 @@ async function resolveSecretsRead(
|
|
|
114
112
|
};
|
|
115
113
|
}
|
|
116
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Ids of the rows a script run's token reads unmasked on its own
|
|
117
|
+
* `/api/config/resolved` (its agent over global, no repo secrets). The run
|
|
118
|
+
* already holds these values in `ctx.swarm.config`. `undefined` for any other
|
|
119
|
+
* caller.
|
|
120
|
+
*/
|
|
121
|
+
async function scriptRunHeldConfigIds(req: IncomingMessage): Promise<Set<string> | undefined> {
|
|
122
|
+
const auth = getRequestAuth(req);
|
|
123
|
+
if (auth?.kind !== "agent" || auth.tokenKind !== "agent") return undefined;
|
|
124
|
+
if (!(await isScriptRunToken(auth.tokenId))) return undefined;
|
|
125
|
+
const held = stripApiOnlyKeys(
|
|
126
|
+
await getResolvedConfig(auth.agentId, undefined, { includeRepoSecrets: false }),
|
|
127
|
+
);
|
|
128
|
+
return new Set(held.map((config) => config.id));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Mask a raw list or by-id read for this caller. A script run's token gets
|
|
133
|
+
* the rows it holds (see {@link scriptRunHeldConfigIds}) unmasked, so
|
|
134
|
+
* `config_list` and `ctx.swarm.config` agree; every other secret row stays
|
|
135
|
+
* masked. Unmasked secret values are registered with the scrubber.
|
|
136
|
+
*/
|
|
137
|
+
async function maskConfigRead(
|
|
138
|
+
req: IncomingMessage,
|
|
139
|
+
configs: SwarmConfig[],
|
|
140
|
+
includeSecrets: boolean,
|
|
141
|
+
): Promise<{ result: SwarmConfig[]; secretsNote: string }> {
|
|
142
|
+
const { effectiveIncludeSecrets, secretsNote } = await resolveSecretsRead(req, includeSecrets);
|
|
143
|
+
const held =
|
|
144
|
+
includeSecrets && !effectiveIncludeSecrets ? await scriptRunHeldConfigIds(req) : undefined;
|
|
145
|
+
const reveal = (config: SwarmConfig) => effectiveIncludeSecrets || !!held?.has(config.id);
|
|
146
|
+
const result = configs.map((config) =>
|
|
147
|
+
config.isSecret && !reveal(config) ? maskSecrets([config])[0]! : config,
|
|
148
|
+
);
|
|
149
|
+
for (const config of result) {
|
|
150
|
+
if (config.isSecret && config.value && reveal(config)) {
|
|
151
|
+
registerVolatileSecret(config.value, `config:${config.key}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const anyMasked = result.some((config) => config.isSecret && !reveal(config));
|
|
155
|
+
return { result, secretsNote: held && !anyMasked ? "" : secretsNote };
|
|
156
|
+
}
|
|
157
|
+
|
|
117
158
|
/**
|
|
118
159
|
* Gate a config write/delete over HTTP (DES-445 follow-up).
|
|
119
160
|
*
|
|
@@ -145,6 +186,25 @@ export async function ensureConfigAdmin(
|
|
|
145
186
|
);
|
|
146
187
|
}
|
|
147
188
|
|
|
189
|
+
/**
|
|
190
|
+
* What a committed config write triggers beyond the row itself. Shared by
|
|
191
|
+
* `PUT /api/config` and HITL `config` answers.
|
|
192
|
+
*/
|
|
193
|
+
export async function runConfigWriteSideEffects(
|
|
194
|
+
scope: "global" | "agent" | "repo",
|
|
195
|
+
key: string,
|
|
196
|
+
value: string,
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
if (scope !== "global") return;
|
|
199
|
+
if (key === "SWARM_ORG_WEBSITE") await fillOrgLogoFromWebsite(value);
|
|
200
|
+
await syncRankingAfterConfigChange(key, value);
|
|
201
|
+
// Auto-reload integrations when a global-scoped config changes so callers
|
|
202
|
+
// (CLI, automation, dashboard) don't have to remember to hit /reload.
|
|
203
|
+
// Debounced to coalesce sequential single-key upserts from the dashboard
|
|
204
|
+
// batch save (no bulk endpoint exists).
|
|
205
|
+
scheduleIntegrationsReload();
|
|
206
|
+
}
|
|
207
|
+
|
|
148
208
|
/**
|
|
149
209
|
* The general form of `ensureConfigAdmin`: operators and users pass, guests
|
|
150
210
|
* get a plain 403, and an agent principal (session identity first, then the
|
|
@@ -439,13 +499,9 @@ export async function handleConfig(
|
|
|
439
499
|
jsonError(res, "Config not found", 404);
|
|
440
500
|
return true;
|
|
441
501
|
}
|
|
442
|
-
const {
|
|
443
|
-
const singleResult = effectiveIncludeSecrets ? config : maskSecrets([config])[0]!;
|
|
444
|
-
if (effectiveIncludeSecrets && singleResult.isSecret && singleResult.value) {
|
|
445
|
-
registerVolatileSecret(singleResult.value, `config:${singleResult.key}`);
|
|
446
|
-
}
|
|
502
|
+
const { result, secretsNote } = await maskConfigRead(req, [config], includeSecrets);
|
|
447
503
|
getConfigById.respond(res, 200, {
|
|
448
|
-
...
|
|
504
|
+
...result[0]!,
|
|
449
505
|
...(secretsNote ? { message: `Found 1 config(s).${secretsNote}` } : {}),
|
|
450
506
|
});
|
|
451
507
|
return true;
|
|
@@ -455,7 +511,6 @@ export async function handleConfig(
|
|
|
455
511
|
const parsed = await listConfig.parse(req, res, pathSegments, queryParams);
|
|
456
512
|
if (!parsed) return true;
|
|
457
513
|
const includeSecrets = parsed.query.includeSecrets === "true";
|
|
458
|
-
const { effectiveIncludeSecrets, secretsNote } = await resolveSecretsRead(req, includeSecrets);
|
|
459
514
|
const configs = stripApiOnlyKeys(
|
|
460
515
|
await getSwarmConfigs({
|
|
461
516
|
scope: parsed.query.scope || undefined,
|
|
@@ -463,14 +518,7 @@ export async function handleConfig(
|
|
|
463
518
|
key: parsed.query.key,
|
|
464
519
|
}),
|
|
465
520
|
);
|
|
466
|
-
const listResult =
|
|
467
|
-
if (effectiveIncludeSecrets) {
|
|
468
|
-
for (const c of listResult) {
|
|
469
|
-
if (c.isSecret && c.value) {
|
|
470
|
-
registerVolatileSecret(c.value, `config:${c.key}`);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
521
|
+
const { result: listResult, secretsNote } = await maskConfigRead(req, configs, includeSecrets);
|
|
474
522
|
listConfig.respond(res, 200, {
|
|
475
523
|
configs: listResult,
|
|
476
524
|
...(secretsNote ? { message: `Found ${listResult.length} config(s).${secretsNote}` } : {}),
|
|
@@ -484,28 +532,7 @@ export async function handleConfig(
|
|
|
484
532
|
if (!(await ensureConfigAdmin(req, res, "config.write.any"))) return true;
|
|
485
533
|
const { scope, scopeId, key, value, isSecret, envPath, description, exportToEnv } = parsed.body;
|
|
486
534
|
|
|
487
|
-
|
|
488
|
-
jsonError(res, "Global scope must not have scopeId", 400);
|
|
489
|
-
return true;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
if ((scope === "agent" || scope === "repo") && !scopeId) {
|
|
493
|
-
jsonError(res, "Agent/repo scope requires scopeId", 400);
|
|
494
|
-
return true;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
if (isReservedConfigKey(key)) {
|
|
498
|
-
jsonError(res, reservedKeyError(key).message, 400);
|
|
499
|
-
return true;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
if (isInternalConfigKey(key)) {
|
|
503
|
-
jsonError(res, internalConfigKeyError(key).message, 400);
|
|
504
|
-
return true;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
const validationError =
|
|
508
|
-
validateConfigValue(key, value) ?? validateExportToEnv(key, exportToEnv);
|
|
535
|
+
const validationError = validateConfigEntry({ scope, scopeId, key, value, exportToEnv });
|
|
509
536
|
if (validationError) {
|
|
510
537
|
jsonError(res, validationError, 400);
|
|
511
538
|
return true;
|
|
@@ -523,17 +550,7 @@ export async function handleConfig(
|
|
|
523
550
|
description: description || null,
|
|
524
551
|
exportToEnv,
|
|
525
552
|
});
|
|
526
|
-
|
|
527
|
-
// (CLI, automation, dashboard) don't have to remember to hit /reload.
|
|
528
|
-
// Debounced to coalesce sequential single-key upserts from the dashboard
|
|
529
|
-
// batch save (no bulk endpoint exists).
|
|
530
|
-
if (scope === "global" && key === "SWARM_ORG_WEBSITE") {
|
|
531
|
-
await fillOrgLogoFromWebsite(String(value));
|
|
532
|
-
}
|
|
533
|
-
if (scope === "global") await syncRankingAfterConfigChange(key, String(value));
|
|
534
|
-
if (scope === "global") {
|
|
535
|
-
scheduleIntegrationsReload();
|
|
536
|
-
}
|
|
553
|
+
await runConfigWriteSideEffects(scope, key, String(value));
|
|
537
554
|
const result = includeSecrets || !config.isSecret ? config : maskSecrets([config])[0]!;
|
|
538
555
|
upsertConfig.respond(res, 200, result);
|
|
539
556
|
} catch (error) {
|