@desplega.ai/agent-swarm 2.5.1 → 2.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{acp-adapter-7g6bpwbk.js → acp-adapter-gr2fcyyj.js} +3 -3
- package/dist/{actions-w8xd014k.js → actions-wv766ghf.js} +18 -18
- package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-speb906t.js} +3 -3
- package/dist/{app-nndcvftq.js → app-ag29n14f.js} +3 -3
- package/dist/{assistant-hjmqyscx.js → assistant-3x8bz3eg.js} +19 -19
- package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-5b3d2eea.js} +1 -1
- package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-jsmfrpzf.js} +1 -1
- package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-s73gerxy.js} +2 -2
- package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-5zmax2wr.js} +4 -4
- package/dist/{claude-adapter-2f8b85df.js → claude-adapter-hswdnm3t.js} +3 -3
- package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-cvbzj1h6.js} +2 -2
- package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3k8m3hhc.js} +2 -2
- package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-fkbyk66x.js} +6905 -6855
- package/dist/{cli-y3zyaee5.js → cli-1fc85mvt.js} +5 -5
- package/dist/{cli-v2g93k4v.js → cli-210xd1f4.js} +4 -4
- package/dist/{cli-sn1vbtg2.js → cli-25j01dpj.js} +3 -3
- package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
- package/dist/{cli-r9fy0ddc.js → cli-3ngdk5te.js} +1 -1
- package/dist/{cli-z63a269p.js → cli-40jsnrjm.js} +2 -2
- package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
- package/dist/{cli-qfm6my86.js → cli-502eqv93.js} +1 -1
- package/dist/{cli-xd4eqtr2.js → cli-6n8axjv1.js} +1 -1
- package/dist/{cli-hadv0725.js → cli-8s5wx3nz.js} +2 -2
- package/dist/{cli-z2dv25mf.js → cli-99xgw26r.js} +116 -5
- package/dist/{cli-jpxsrxcb.js → cli-9s05422w.js} +1 -1
- package/dist/{cli-kcsd1ez8.js → cli-aecdcr77.js} +343 -325
- package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
- package/dist/{cli-e9btqw4y.js → cli-ce0ak9ms.js} +18 -13
- package/dist/{cli-nj12tn2c.js → cli-e2ar4bp1.js} +71 -20
- package/dist/{cli-gwcf52gz.js → cli-e8jyq7d8.js} +11 -7
- package/dist/{cli-rngde2jv.js → cli-egjggehm.js} +1 -1
- package/dist/{cli-qfjm4gvt.js → cli-ewm3pwbq.js} +2 -2
- package/dist/{cli-9mm5ade4.js → cli-fsdprwrg.js} +1 -1
- package/dist/{cli-3ee5z0q0.js → cli-fzcn3d87.js} +1 -1
- package/dist/{cli-eym3jg7w.js → cli-gpsz7g88.js} +6 -6
- package/dist/{cli-cwyafhw1.js → cli-hcpj2xqz.js} +1 -1
- package/dist/{cli-j1eghqfm.js → cli-hhxjnjzs.js} +1 -1
- package/dist/{cli-pyy4c9ch.js → cli-jjznk3f7.js} +881 -232
- package/dist/{cli-q9pzwxvk.js → cli-kxawjrxg.js} +222 -37
- package/dist/{cli-rh5smt8t.js → cli-mz9afxxf.js} +14 -3
- package/dist/{cli-mz2ygeyq.js → cli-n45mr7zh.js} +2 -2
- package/dist/{cli-fes51ya2.js → cli-p0rzjkya.js} +2 -2
- package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
- package/dist/{cli-mq7kg1b1.js → cli-p9rafdrb.js} +41 -6
- package/dist/{cli-cah5detr.js → cli-qgf8emrn.js} +5 -5
- package/dist/{cli-2wba1ac9.js → cli-qzme5949.js} +26 -26
- package/dist/{cli-znvq0k3t.js → cli-s49hpn37.js} +2 -2
- package/dist/{cli-xc65x52q.js → cli-s53vp709.js} +1 -1
- package/dist/{cli-0jy85zz4.js → cli-svrj71pg.js} +18 -16
- package/dist/{cli-1dp1zj04.js → cli-vwn81jbs.js} +3 -3
- package/dist/{cli-ajqhxtnz.js → cli-w1zwctj2.js} +1 -1
- package/dist/{cli-22rmcjkg.js → cli-y561h5a9.js} +7 -6
- package/dist/{cli-2jg61wab.js → cli-z21whrrg.js} +15 -15
- package/dist/cli.js +19 -18
- package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-n8ka66vf.js} +2 -2
- package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-fsebts37.js} +1 -1
- package/dist/{commands-mbvhbrcy.js → commands-t04rt2pc.js} +2 -2
- package/dist/{controller-gnq1k1mf.js → controller-rgna04r2.js} +2 -2
- package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-bqm1rtft.js} +20 -20
- package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-swr2k95y.js} +1 -1
- package/dist/{db-88wcymxt.js → db-24ck09xy.js} +1 -1
- package/dist/{db-af9y8nkf.js → db-a4m7haw0.js} +3 -3
- package/dist/{dispatcher-606ydx5v.js → dispatcher-nwf9ss0q.js} +17 -17
- package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-kpdnhjst.js} +2 -2
- package/dist/{external-channel-y8q5z8z2.js → external-channel-j1e2p34p.js} +2 -2
- package/dist/{grok-adapter-39aha52t.js → grok-adapter-qtjz0whk.js} +4 -4
- package/dist/{handlers-rk9dqech.js → handlers-19xr6dnz.js} +20 -20
- package/dist/{heartbeat-jfe9zfjr.js → heartbeat-vtesda5d.js} +20 -20
- package/dist/{hook-b1mez1m8.js → hook-8wqaszda.js} +1 -1
- package/dist/{hook-ffevzed1.js → hook-b2hhj6rd.js} +1 -1
- package/dist/{http-d5b44f1r.js → http-mxcqhhcs.js} +3020 -2463
- package/dist/{index-5en23s5z.js → index-1r8wfpzs.js} +1 -1
- package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
- package/dist/{index-mj46rk9k.js → index-kw2mmat7.js} +20 -20
- package/dist/{index-nq8hhv28.js → index-n1vbvta9.js} +19 -10
- package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
- package/dist/{index-d2qeem97.js → index-spwxjgay.js} +17 -17
- package/dist/{index-4q3vj1yr.js → index-srg522m6.js} +17 -17
- package/dist/{keepalive-wsby1btq.js → keepalive-dsd8rtgx.js} +3 -3
- package/dist/{lead-paa141j6.js → lead-zyerk1x2.js} +30 -30
- package/dist/{maintenance-vnshcyv9.js → maintenance-jqmqwnet.js} +1 -1
- package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
- package/dist/{onboard-nr4xhpzy.js → onboard-w4yd7s8t.js} +2 -2
- package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
- package/dist/{otel-impl-ytn9wyv9.js → otel-impl-f36byzbd.js} +1 -1
- package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-xvak5p4v.js} +3 -3
- package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-682k2mt5.js} +12 -2
- package/dist/rbac-roles-gfn30fb8.js +26 -0
- package/dist/{render-v2-pyfsg9zk.js → render-v2-hgytz8nn.js} +3 -3
- package/dist/{resume-phj8xhqr.js → resume-2dvq348g.js} +7 -5
- package/dist/{session-access-jzx2z9h5.js → session-access-7f21ekq9.js} +4 -4
- package/dist/{setup-rfg0f0sg.js → setup-epffxtbk.js} +1 -1
- package/dist/{work-objects-44z2yjg8.js → work-objects-xz1a5rqk.js} +20 -20
- package/dist/{worker-zsfqzxhm.js → worker-42t8emjt.js} +30 -30
- package/openapi.json +3163 -2114
- package/package.json +7 -6
- package/src/be/approval-config-question.ts +389 -0
- package/src/be/automation-preflight.ts +37 -3
- package/src/be/db/session-tree.ts +158 -13
- package/src/be/db/session-visibility.ts +31 -0
- package/src/be/db/skill-search.ts +87 -0
- package/src/be/db/tasks/read.ts +47 -7
- package/src/be/db/workflow-runs.ts +54 -7
- package/src/be/db.ts +27 -5
- package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
- package/src/be/migrations/235_chat_relay_defer.sql +86 -0
- package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
- 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/rbac-roles.ts +138 -0
- package/src/be/scripts/run-credential.ts +64 -0
- package/src/be/scripts/run-saved.ts +3 -2
- package/src/be/swarm-config-guard.ts +23 -0
- package/src/be/users.ts +28 -4
- package/src/extensions/catalog.generated.json +4 -4
- package/src/http/all-routes.ts +1 -0
- package/src/http/approval-requests.ts +254 -43
- package/src/http/config.ts +22 -37
- package/src/http/index.ts +2 -0
- package/src/http/schedules.ts +26 -0
- package/src/http/scripts.ts +2 -2
- package/src/http/sessions.ts +118 -2
- package/src/http/user-roles.ts +173 -0
- package/src/http/users.ts +9 -0
- package/src/http/x.ts +2 -2
- package/src/integrations/chat-sdk/commands.ts +14 -1
- package/src/integrations/chat-sdk/inbound.ts +42 -6
- package/src/integrations/chat-sdk/outbound.ts +35 -10
- package/src/integrations/chat-sdk/reactions.ts +107 -0
- package/src/integrations/chat-sdk/relay.ts +75 -7
- package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
- package/src/integrations/reaction-events.ts +20 -0
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/rbac/legacy-policy.ts +3 -0
- package/src/rbac/permissions.ts +8 -0
- package/src/scheduler/schedule-task.ts +44 -3
- package/src/scheduler/scheduler.ts +4 -4
- package/src/scripts-runtime/loader.ts +8 -1
- package/src/slack/ack.ts +2 -1
- package/src/slack/reaction-shortcode.ts +4 -7
- package/src/tasks/worker-follow-up.ts +30 -0
- package/src/tests/approval-config-question.test.ts +702 -0
- package/src/tests/approval-requests-notifications.test.ts +189 -0
- package/src/tests/chat-sdk-commands.test.ts +12 -0
- package/src/tests/chat-sdk-inbound.test.ts +183 -9
- package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
- package/src/tests/chat-sdk-relay.test.ts +361 -10
- package/src/tests/chat-sdk-tools.test.ts +104 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- 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/http-users.test.ts +38 -0
- package/src/tests/list-endpoint-slimming.test.ts +33 -0
- package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
- package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
- package/src/tests/rbac-engine.test.ts +2 -0
- package/src/tests/rbac-roles.test.ts +10 -0
- package/src/tests/schedule-http-triage-tools.test.ts +44 -0
- package/src/tests/script-api-credential.test.ts +173 -0
- package/src/tests/script-connections.test.ts +10 -0
- package/src/tests/script-run-config-scope.test.ts +120 -0
- package/src/tests/script-run-token.test.ts +75 -0
- package/src/tests/scripts-http.test.ts +19 -0
- package/src/tests/sessions-tree.test.ts +410 -1
- package/src/tests/skill-search-long-content.test.ts +193 -0
- package/src/tests/skill-search.test.ts +173 -22
- package/src/tests/user-roles-http.test.ts +204 -0
- package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
- package/src/tests/workflow-sub-workflow.test.ts +538 -1
- package/src/tools/chat-reply.ts +2 -0
- package/src/tools/request-human-input.ts +27 -5
- package/src/tools/schedules/patch-schedule.ts +18 -0
- package/src/tools/schedules/update-schedule.ts +18 -0
- package/src/types.ts +23 -0
- package/src/workflows/approval-notifications.ts +221 -0
- package/src/workflows/checkpoint.ts +69 -35
- package/src/workflows/engine.ts +234 -87
- package/src/workflows/executors/base.ts +6 -0
- package/src/workflows/executors/human-in-the-loop.ts +36 -173
- package/src/workflows/executors/sub-workflow.ts +45 -16
- package/src/workflows/executors/swarm-script.ts +2 -2
- package/src/workflows/recovery.ts +63 -1
- package/src/workflows/resume.ts +60 -21
- package/src/workflows/retry-poller.ts +25 -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/schedules/weekly-harness-upgrade-check/content.md +1 -1
- package/templates/skills/code-quality/content.md +1 -1
- package/templates/skills/turso-interaction/content.md +1 -1
- package/templates/skills/workflow-iterate/content.md +8 -1
- package/dist/rbac-roles-ff1ffmec.js +0 -12
- /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
- /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
|
@@ -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,13 +31,19 @@ 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";
|
|
39
|
+
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
23
40
|
import { cancelApprovalRequest } from "../workflows/approval-cancel";
|
|
24
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
createApprovalFollowUpTask,
|
|
43
|
+
dispatchApprovalNotifications,
|
|
44
|
+
} from "../workflows/approval-notifications";
|
|
25
45
|
import { workflowEventBus } from "../workflows/event-bus";
|
|
46
|
+
import { ensureConfigAdmin, runConfigWriteSideEffects } from "./config";
|
|
26
47
|
import { bodyTask, route } from "./route-def";
|
|
27
48
|
import { jsonError } from "./utils";
|
|
28
49
|
|
|
@@ -30,7 +51,7 @@ import { jsonError } from "./utils";
|
|
|
30
51
|
|
|
31
52
|
const QuestionSchema = z.object({
|
|
32
53
|
id: z.string(),
|
|
33
|
-
type: z.enum(["approval", "text", "single-select", "multi-select", "boolean"]),
|
|
54
|
+
type: z.enum(["approval", "text", "single-select", "multi-select", "boolean", "config"]),
|
|
34
55
|
label: z.string(),
|
|
35
56
|
required: z.boolean().optional(),
|
|
36
57
|
description: z.string().optional(),
|
|
@@ -48,8 +69,14 @@ const QuestionSchema = z.object({
|
|
|
48
69
|
minSelections: z.number().int().min(0).optional(),
|
|
49
70
|
maxSelections: z.number().int().min(1).optional(),
|
|
50
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
|
+
),
|
|
51
75
|
});
|
|
52
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
|
+
|
|
53
80
|
export type ApprovalQuestion = z.infer<typeof QuestionSchema>;
|
|
54
81
|
|
|
55
82
|
// ─── Response Schemas ────────────────────────────────────────────────────────
|
|
@@ -129,6 +156,12 @@ const ApprovalRequestSchema = z.object({
|
|
|
129
156
|
createdBy: z.string().optional(),
|
|
130
157
|
createdAt: z.string(),
|
|
131
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
|
+
),
|
|
132
165
|
});
|
|
133
166
|
|
|
134
167
|
/** Slim list row (`?fields=slim`) — see `ApprovalRequestSummary`. */
|
|
@@ -139,6 +172,7 @@ const ApprovalRequestSummarySchema = ApprovalRequestSchema.omit({
|
|
|
139
172
|
approvals: true,
|
|
140
173
|
resolutionReason: true,
|
|
141
174
|
notificationChannels: true,
|
|
175
|
+
configState: true,
|
|
142
176
|
}).extend({ questionCount: z.number().int() });
|
|
143
177
|
|
|
144
178
|
/**
|
|
@@ -148,11 +182,16 @@ const ApprovalRequestSummarySchema = ApprovalRequestSchema.omit({
|
|
|
148
182
|
*/
|
|
149
183
|
async function toApprovalRequestResponse(
|
|
150
184
|
request: ApprovalRequest,
|
|
185
|
+
options: { withConfigState?: boolean } = {},
|
|
151
186
|
): Promise<z.infer<typeof ApprovalRequestSchema>> {
|
|
152
187
|
const approvers = request.approvers as ApproversShape;
|
|
153
188
|
const approvals = request.approvals ?? null;
|
|
189
|
+
const configState = options.withConfigState
|
|
190
|
+
? await readConfigStates(request.questions as ApprovalQuestion[])
|
|
191
|
+
: undefined;
|
|
154
192
|
return {
|
|
155
193
|
...request,
|
|
194
|
+
...(configState ? { configState } : {}),
|
|
156
195
|
questions: request.questions as ApprovalQuestion[],
|
|
157
196
|
approvers,
|
|
158
197
|
responses: request.responses as Record<string, unknown> | null,
|
|
@@ -184,6 +223,28 @@ async function withApprovalProgress(
|
|
|
184
223
|
);
|
|
185
224
|
}
|
|
186
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
|
+
|
|
187
248
|
export async function getWorkflowApprovalUnavailableReason(
|
|
188
249
|
request: ApprovalRequest,
|
|
189
250
|
): Promise<string | null> {
|
|
@@ -237,6 +298,15 @@ function hasRequiredResponse(question: ApprovalQuestion, response: unknown): boo
|
|
|
237
298
|
}
|
|
238
299
|
case "boolean":
|
|
239
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
|
+
}
|
|
240
310
|
}
|
|
241
311
|
}
|
|
242
312
|
|
|
@@ -260,7 +330,12 @@ const createRoute = route({
|
|
|
260
330
|
tags: ["ApprovalRequests"],
|
|
261
331
|
body: z.object({
|
|
262
332
|
title: z.string().min(1),
|
|
263
|
-
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
|
+
),
|
|
264
339
|
approvers: z.object({
|
|
265
340
|
users: z.array(z.string()).optional(),
|
|
266
341
|
roles: z.array(z.string()).optional(),
|
|
@@ -288,7 +363,10 @@ const createRoute = route({
|
|
|
288
363
|
target: z.string(),
|
|
289
364
|
}),
|
|
290
365
|
)
|
|
291
|
-
.optional()
|
|
366
|
+
.optional()
|
|
367
|
+
.describe(
|
|
368
|
+
"Where to announce the request. A Slack target (channel or user ID) gets a card with the title, the questions and a link to the dashboard page that answers it. Slack targets need a lead agent caller (403 otherwise). Email is not supported yet and is skipped.",
|
|
369
|
+
),
|
|
292
370
|
}),
|
|
293
371
|
responses: {
|
|
294
372
|
201: {
|
|
@@ -296,6 +374,7 @@ const createRoute = route({
|
|
|
296
374
|
schema: z.object({ approvalRequest: ApprovalRequestSchema }),
|
|
297
375
|
},
|
|
298
376
|
400: { description: "Validation error" },
|
|
377
|
+
403: { description: "Slack notifications require a lead agent" },
|
|
299
378
|
},
|
|
300
379
|
auth: { apiKey: true },
|
|
301
380
|
});
|
|
@@ -315,6 +394,8 @@ const getByIdRoute = route({
|
|
|
315
394
|
404: { description: "Not found" },
|
|
316
395
|
},
|
|
317
396
|
auth: { apiKey: true },
|
|
397
|
+
description:
|
|
398
|
+
"Includes `configState` when the request has `config` questions: the live state of each target key, read now.",
|
|
318
399
|
});
|
|
319
400
|
|
|
320
401
|
const respondRoute = route({
|
|
@@ -323,7 +404,7 @@ const respondRoute = route({
|
|
|
323
404
|
pattern: ["api", "approval-requests", null, "respond"],
|
|
324
405
|
summary: "Submit a response to an approval request",
|
|
325
406
|
description:
|
|
326
|
-
|
|
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.',
|
|
327
408
|
tags: ["ApprovalRequests"],
|
|
328
409
|
params: z.object({ id: z.string().uuid() }),
|
|
329
410
|
body: z.object({
|
|
@@ -479,11 +560,47 @@ export async function handleApprovalRequests(
|
|
|
479
560
|
return true;
|
|
480
561
|
}
|
|
481
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
|
+
|
|
482
599
|
// Determine status from responses: if any approval question has approved: false → rejected
|
|
483
600
|
let status: "approved" | "rejected" = "approved";
|
|
484
601
|
for (const q of questions) {
|
|
485
602
|
if (q.type === "approval") {
|
|
486
|
-
const answer =
|
|
603
|
+
const answer = safeResponses[q.id] as { approved?: boolean } | undefined;
|
|
487
604
|
if (answer && answer.approved === false) {
|
|
488
605
|
status = "rejected";
|
|
489
606
|
break;
|
|
@@ -492,38 +609,89 @@ export async function handleApprovalRequests(
|
|
|
492
609
|
}
|
|
493
610
|
|
|
494
611
|
const responderId = responderKey(responder.responder);
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
approved: status === "approved",
|
|
498
|
-
responses: parsed.body.responses,
|
|
499
|
-
...(parsed.body.respondedBy ? { claimedRespondedBy: parsed.body.respondedBy } : {}),
|
|
500
|
-
respondedAt: new Date().toISOString(),
|
|
501
|
-
};
|
|
612
|
+
const respondedAt = new Date().toISOString();
|
|
613
|
+
const configWrites: { result: ConfigResult; value: string }[] = [];
|
|
502
614
|
// One write transaction (BEGIN IMMEDIATE) so concurrent answers to an
|
|
503
615
|
// `all` / `{ min: N }` request append in turn and never drop each other.
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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 }
|
|
518
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
|
+
);
|
|
519
693
|
}
|
|
520
|
-
|
|
521
|
-
? await getApprovalRequestById(parsed.params.id)
|
|
522
|
-
: null;
|
|
523
|
-
return recorded
|
|
524
|
-
? { kind: "recorded" as const, request: recorded }
|
|
525
|
-
: { kind: "unavailable" as const };
|
|
526
|
-
});
|
|
694
|
+
}
|
|
527
695
|
|
|
528
696
|
if (outcome.kind === "duplicate") {
|
|
529
697
|
jsonError(res, "You already answered this approval request", 409);
|
|
@@ -568,7 +736,7 @@ export async function handleApprovalRequests(
|
|
|
568
736
|
await createApprovalFollowUpTask(updated, "hitl.follow_up");
|
|
569
737
|
|
|
570
738
|
respondRoute.respond(res, 200, {
|
|
571
|
-
approvalRequest: await toApprovalRequestResponse(updated),
|
|
739
|
+
approvalRequest: await toApprovalRequestResponse(updated, { withConfigState: true }),
|
|
572
740
|
});
|
|
573
741
|
return true;
|
|
574
742
|
}
|
|
@@ -578,7 +746,7 @@ export async function handleApprovalRequests(
|
|
|
578
746
|
const parsed = await cancelRoute.parse(req, res, pathSegments, queryParams);
|
|
579
747
|
if (!parsed) return true;
|
|
580
748
|
|
|
581
|
-
const { principal, resolvedBy } = await
|
|
749
|
+
const { principal, resolvedBy } = await approvalCallerPrincipal(req);
|
|
582
750
|
const result = await cancelApprovalRequest({
|
|
583
751
|
id: parsed.params.id,
|
|
584
752
|
reason: parsed.body.reason,
|
|
@@ -609,7 +777,7 @@ export async function handleApprovalRequests(
|
|
|
609
777
|
}
|
|
610
778
|
|
|
611
779
|
getByIdRoute.respond(res, 200, {
|
|
612
|
-
approvalRequest: await toApprovalRequestResponse(request),
|
|
780
|
+
approvalRequest: await toApprovalRequestResponse(request, { withConfigState: true }),
|
|
613
781
|
});
|
|
614
782
|
return true;
|
|
615
783
|
}
|
|
@@ -619,6 +787,26 @@ export async function handleApprovalRequests(
|
|
|
619
787
|
const parsed = await createRoute.parse(req, res, pathSegments, queryParams);
|
|
620
788
|
if (!parsed) return true;
|
|
621
789
|
|
|
790
|
+
// Posting to Slack is lead-only everywhere else (slack-post); a caller that
|
|
791
|
+
// may not post must not reach an arbitrary channel through this route.
|
|
792
|
+
if (parsed.body.notifications?.some((notification) => notification.channel === "slack")) {
|
|
793
|
+
const { principal } = await approvalCallerPrincipal(req);
|
|
794
|
+
const decision = can({ principal, verb: "integration.slack.post", source: "http" });
|
|
795
|
+
if (!decision.allow) {
|
|
796
|
+
jsonError(res, SLACK_NOTIFICATION_REFUSED, 403);
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
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
|
+
|
|
622
810
|
const id = crypto.randomUUID();
|
|
623
811
|
// Prefer a trusted authenticated user (never client-controlled); else fall
|
|
624
812
|
// back to the ownership-validated sourceTaskId the request body carries.
|
|
@@ -632,7 +820,7 @@ export async function handleApprovalRequests(
|
|
|
632
820
|
const request = await createApprovalRequest({
|
|
633
821
|
id,
|
|
634
822
|
title: parsed.body.title,
|
|
635
|
-
questions
|
|
823
|
+
questions,
|
|
636
824
|
approvers: parsed.body.approvers,
|
|
637
825
|
workflowRunId: parsed.body.workflowRunId,
|
|
638
826
|
workflowRunStepId: parsed.body.workflowRunStepId,
|
|
@@ -642,6 +830,24 @@ export async function handleApprovalRequests(
|
|
|
642
830
|
createdBy,
|
|
643
831
|
});
|
|
644
832
|
|
|
833
|
+
// Fire-and-forget: Slack must not delay or fail the 201. Workflow HITL
|
|
834
|
+
// requests are created by the executor directly, so they never post here.
|
|
835
|
+
const notifications = parsed.body.notifications;
|
|
836
|
+
if (notifications?.length && request.status === "pending") {
|
|
837
|
+
dispatchApprovalNotifications({
|
|
838
|
+
requestId: request.id,
|
|
839
|
+
title: request.title,
|
|
840
|
+
questions,
|
|
841
|
+
timeoutSeconds: parsed.body.timeoutSeconds,
|
|
842
|
+
notifications,
|
|
843
|
+
}).catch((err) => {
|
|
844
|
+
console.error(
|
|
845
|
+
"[approval-requests] Unexpected error dispatching notifications:",
|
|
846
|
+
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
847
|
+
);
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
645
851
|
createRoute.respond(res, 201, {
|
|
646
852
|
approvalRequest: await toApprovalRequestResponse(request),
|
|
647
853
|
});
|
|
@@ -668,7 +874,9 @@ export async function handleApprovalRequests(
|
|
|
668
874
|
|
|
669
875
|
const requests = await listApprovalRequests(filters);
|
|
670
876
|
listRoute.respond(res, 200, {
|
|
671
|
-
approvalRequests: await Promise.all(
|
|
877
|
+
approvalRequests: await Promise.all(
|
|
878
|
+
requests.map((request) => toApprovalRequestResponse(request)),
|
|
879
|
+
),
|
|
672
880
|
});
|
|
673
881
|
return true;
|
|
674
882
|
}
|
|
@@ -676,11 +884,14 @@ export async function handleApprovalRequests(
|
|
|
676
884
|
return false;
|
|
677
885
|
}
|
|
678
886
|
|
|
887
|
+
const SLACK_NOTIFICATION_REFUSED =
|
|
888
|
+
"Slack notifications require lead privileges (integration.slack.post). Create the request without them, or ask the lead to post the link.";
|
|
889
|
+
|
|
679
890
|
/**
|
|
680
|
-
* The caller of the cancel
|
|
681
|
-
* header identifies that agent
|
|
891
|
+
* The caller of the create and cancel routes. The shared API key with an
|
|
892
|
+
* `X-Agent-ID` header identifies that agent.
|
|
682
893
|
*/
|
|
683
|
-
async function
|
|
894
|
+
async function approvalCallerPrincipal(
|
|
684
895
|
req: IncomingMessage,
|
|
685
896
|
): Promise<{ principal: RbacPrincipal; resolvedBy: string | null }> {
|
|
686
897
|
const auth = getRequestAuth(req);
|
package/src/http/config.ts
CHANGED
|
@@ -21,10 +21,7 @@ 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";
|
|
29
26
|
import { can, isRbacEnabled, type PermissionVerb } from "../rbac";
|
|
30
27
|
import { SwarmConfigSchema } from "../types";
|
|
@@ -145,6 +142,25 @@ export async function ensureConfigAdmin(
|
|
|
145
142
|
);
|
|
146
143
|
}
|
|
147
144
|
|
|
145
|
+
/**
|
|
146
|
+
* What a committed config write triggers beyond the row itself. Shared by
|
|
147
|
+
* `PUT /api/config` and HITL `config` answers.
|
|
148
|
+
*/
|
|
149
|
+
export async function runConfigWriteSideEffects(
|
|
150
|
+
scope: "global" | "agent" | "repo",
|
|
151
|
+
key: string,
|
|
152
|
+
value: string,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
if (scope !== "global") return;
|
|
155
|
+
if (key === "SWARM_ORG_WEBSITE") await fillOrgLogoFromWebsite(value);
|
|
156
|
+
await syncRankingAfterConfigChange(key, value);
|
|
157
|
+
// Auto-reload integrations when a global-scoped config changes so callers
|
|
158
|
+
// (CLI, automation, dashboard) don't have to remember to hit /reload.
|
|
159
|
+
// Debounced to coalesce sequential single-key upserts from the dashboard
|
|
160
|
+
// batch save (no bulk endpoint exists).
|
|
161
|
+
scheduleIntegrationsReload();
|
|
162
|
+
}
|
|
163
|
+
|
|
148
164
|
/**
|
|
149
165
|
* The general form of `ensureConfigAdmin`: operators and users pass, guests
|
|
150
166
|
* get a plain 403, and an agent principal (session identity first, then the
|
|
@@ -484,28 +500,7 @@ export async function handleConfig(
|
|
|
484
500
|
if (!(await ensureConfigAdmin(req, res, "config.write.any"))) return true;
|
|
485
501
|
const { scope, scopeId, key, value, isSecret, envPath, description, exportToEnv } = parsed.body;
|
|
486
502
|
|
|
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);
|
|
503
|
+
const validationError = validateConfigEntry({ scope, scopeId, key, value, exportToEnv });
|
|
509
504
|
if (validationError) {
|
|
510
505
|
jsonError(res, validationError, 400);
|
|
511
506
|
return true;
|
|
@@ -523,17 +518,7 @@ export async function handleConfig(
|
|
|
523
518
|
description: description || null,
|
|
524
519
|
exportToEnv,
|
|
525
520
|
});
|
|
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
|
-
}
|
|
521
|
+
await runConfigWriteSideEffects(scope, key, String(value));
|
|
537
522
|
const result = includeSecrets || !config.isSecret ? config : maskSecrets([config])[0]!;
|
|
538
523
|
upsertConfig.respond(res, 200, result);
|
|
539
524
|
} catch (error) {
|
package/src/http/index.ts
CHANGED
|
@@ -139,6 +139,7 @@ import { handleTasks } from "./tasks";
|
|
|
139
139
|
import { handleTelemetryUi } from "./telemetry-ui";
|
|
140
140
|
import { handleTrackers } from "./trackers";
|
|
141
141
|
import { handleUsedBy } from "./used-by";
|
|
142
|
+
import { handleUserRoles } from "./user-roles";
|
|
142
143
|
import { handleUsers } from "./users";
|
|
143
144
|
import {
|
|
144
145
|
getPathSegments,
|
|
@@ -404,6 +405,7 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
404
405
|
() => handleRuntimePools(req, res, pathSegments, queryParams, myAgentId),
|
|
405
406
|
() => handleExtensions(req, res, pathSegments, queryParams, myAgentId),
|
|
406
407
|
() => handleFavorites(req, res, pathSegments, queryParams, myAgentId),
|
|
408
|
+
() => handleUserRoles(req, res, pathSegments, queryParams),
|
|
407
409
|
() => handleUsers(req, res, pathSegments, queryParams),
|
|
408
410
|
() => handleSessions(req, res, pathSegments, queryParams, myAgentId),
|
|
409
411
|
() => handleInboxState(req, res, pathSegments, queryParams),
|
package/src/http/schedules.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { explicitModelErrorForAgent } from "../be/model-validation";
|
|
20
20
|
import { mergeScheduleTiming, validateRecurringTiming } from "../be/schedules/validate";
|
|
21
21
|
import { getScript } from "../be/scripts/db";
|
|
22
|
+
import { can } from "../rbac";
|
|
22
23
|
import { calculateNextRun, dispatchScheduleTarget } from "../scheduler/scheduler";
|
|
23
24
|
import {
|
|
24
25
|
AgentTaskSchema,
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
splitLegacyModelAlias,
|
|
31
32
|
} from "../types";
|
|
32
33
|
import { resolveHttpFavoriteOwner } from "./favorite-owner";
|
|
34
|
+
import { agentFirstPrincipal } from "./request-principal";
|
|
33
35
|
import { route } from "./route-def";
|
|
34
36
|
import { jsonError } from "./utils";
|
|
35
37
|
|
|
@@ -219,6 +221,7 @@ const updateSchedule = route({
|
|
|
219
221
|
responses: {
|
|
220
222
|
200: { description: "Schedule updated", schema: ScheduledTaskSchema.nullable() },
|
|
221
223
|
400: { description: "Validation error" },
|
|
224
|
+
403: { description: "Deferral owned by another agent" },
|
|
222
225
|
404: { description: "Schedule not found" },
|
|
223
226
|
409: { description: "Duplicate name" },
|
|
224
227
|
},
|
|
@@ -237,6 +240,7 @@ const patchSchedule = route({
|
|
|
237
240
|
responses: {
|
|
238
241
|
200: { description: "Schedule patched", schema: ScheduledTaskSchema.nullable() },
|
|
239
242
|
400: { description: "Validation error" },
|
|
243
|
+
403: { description: "Deferral owned by another agent" },
|
|
240
244
|
404: { description: "Schedule not found" },
|
|
241
245
|
409: { description: "Duplicate name" },
|
|
242
246
|
},
|
|
@@ -559,6 +563,28 @@ export async function handleSchedules(
|
|
|
559
563
|
jsonError(res, "Schedule not found", 404);
|
|
560
564
|
return true;
|
|
561
565
|
}
|
|
566
|
+
// A deferral continues its owner's task, in that task's chat thread when
|
|
567
|
+
// it has one (src/scheduler/schedule-task.ts). An agent may change it only
|
|
568
|
+
// as its owner or a lead, as in the patch-schedule and update-schedule tools.
|
|
569
|
+
if (existing.taskType === "deferred") {
|
|
570
|
+
const principal = await agentFirstPrincipal(req, myAgentId);
|
|
571
|
+
if (
|
|
572
|
+
principal.kind === "agent" &&
|
|
573
|
+
!can({
|
|
574
|
+
principal,
|
|
575
|
+
verb: "schedule.deferral.update.any",
|
|
576
|
+
resource: { kind: "owned", ownerAgentId: existing.createdByAgentId },
|
|
577
|
+
source: "http",
|
|
578
|
+
}).allow
|
|
579
|
+
) {
|
|
580
|
+
jsonError(
|
|
581
|
+
res,
|
|
582
|
+
`Schedule "${existing.name}" defers another agent's task. Only that agent or a lead can change it.`,
|
|
583
|
+
403,
|
|
584
|
+
);
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
562
588
|
// A move to another agent re-judges the stored model against the new harness.
|
|
563
589
|
const agentChanged =
|
|
564
590
|
parsed.body.targetAgentId !== undefined &&
|
package/src/http/scripts.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
} from "../be/scripts/db";
|
|
33
33
|
import { searchScripts } from "../be/scripts/embeddings";
|
|
34
34
|
import { extractArgsJsonSchema } from "../be/scripts/extract-schema";
|
|
35
|
+
import { runScriptWithRunCredential } from "../be/scripts/run-credential";
|
|
35
36
|
import { refuseSourceWithSecrets } from "../be/scripts/source-secrets";
|
|
36
37
|
import {
|
|
37
38
|
scriptSdkTypesWithGeneratedApis,
|
|
@@ -40,7 +41,6 @@ import {
|
|
|
40
41
|
} from "../be/scripts/typecheck";
|
|
41
42
|
import { can } from "../rbac";
|
|
42
43
|
import { extractScriptSignature, type ScriptSignature } from "../scripts-runtime/extract-signature";
|
|
43
|
-
import { runScript } from "../scripts-runtime/loader";
|
|
44
44
|
import { checkQuickJSCompat, quickJSCompatWarnings } from "../scripts-runtime/quickjs-compat";
|
|
45
45
|
import {
|
|
46
46
|
ScriptApiAuthModeSchema,
|
|
@@ -782,7 +782,7 @@ export async function handleScripts(
|
|
|
782
782
|
? await touchScratchScriptLastUsed(namedScript.id)
|
|
783
783
|
: null;
|
|
784
784
|
const credentials = await buildScriptCredentialBindingsWithFailures({ agentId: agent.id });
|
|
785
|
-
const output = await
|
|
785
|
+
const output = await runScriptWithRunCredential({
|
|
786
786
|
source: source as string,
|
|
787
787
|
args: parsed.body.args,
|
|
788
788
|
fsMode,
|