diff --git a/db/migrations/20260525120000_watcher_execution_config.sql b/db/migrations/20260525120000_watcher_execution_config.sql new file mode 100644 index 000000000..f9163747b --- /dev/null +++ b/db/migrations/20260525120000_watcher_execution_config.sql @@ -0,0 +1,18 @@ +-- migrate:up + +-- Per-watcher local-CLI execution settings for device-worker runs (the Mac +-- app's WatcherDispatcher spawns `claude -p`/`codex` — see +-- packages/owletto/apps/mac/Owletto/WatcherDispatcher.swift). NULL = all +-- defaults (timeout falls back to the dispatcher's 600s cap; every other +-- field is omitted from the spawn args, leaving the CLI default). +-- +-- Shape: { timeout_seconds, max_budget_usd, model, permission_mode, effort }. +-- A single jsonb (mirroring watchers.model_config) so future knobs don't need +-- a migration. Validated on write by the manage_watchers TypeBox schema. +ALTER TABLE public.watchers ADD COLUMN IF NOT EXISTS execution_config jsonb; + +COMMENT ON COLUMN public.watchers.execution_config IS 'Per-watcher device-worker CLI execution settings: { timeout_seconds, max_budget_usd, model, permission_mode, effort }. NULL = defaults.'; + +-- migrate:down + +ALTER TABLE public.watchers DROP COLUMN IF EXISTS execution_config; diff --git a/packages/owletto b/packages/owletto index 00a05008a..b3d3a647d 160000 --- a/packages/owletto +++ b/packages/owletto @@ -1 +1 @@ -Subproject commit 00a05008ab1f29f298ff7304e4fdb61f0dadeeba +Subproject commit b3d3a647d676df28a2e50e7ab19b76dbf76981e0 diff --git a/packages/server/src/__tests__/integration/watchers/manual-trigger.test.ts b/packages/server/src/__tests__/integration/watchers/manual-trigger.test.ts index 44181cee9..24e101ffc 100644 --- a/packages/server/src/__tests__/integration/watchers/manual-trigger.test.ts +++ b/packages/server/src/__tests__/integration/watchers/manual-trigger.test.ts @@ -307,4 +307,35 @@ describe('POST /api/workers/me/watchers/:watcher_id/trigger', () => { }); expect(response.status).toBe(404); }); + + it('poll payload carries the watcher execution_config', async () => { + const ctx = await setupDevicePinnedWatcher({ workerId: 'mac-poll-exec' }); + const execCfg = { timeout_seconds: 1800, model: 'opus', permission_mode: 'plan' }; + await ctx.sql` + UPDATE watchers SET execution_config = ${ctx.sql.json(execCfg)} WHERE id = ${ctx.watcherId} + `; + const { token } = await createWorkerBoundPat( + ctx.workspace.users.owner.id, + ctx.workspace.org.id, + 'mac-poll-exec' + ); + + // Trigger queues a pending run pinned to this device. + const trig = await post(`/api/workers/me/watchers/${ctx.watcherId}/trigger`, { token }); + expect(trig.status).toBe(200); + + // The device poll claims it and gets the payload envelope the dispatcher + // builds its CLI invocation from — execution_config must round-trip. + const pollRes = await post('/api/workers/poll', { + token, + body: { worker_id: 'mac-poll-exec', capabilities: {} }, + }); + expect(pollRes.status).toBe(200); + const job = (await pollRes.json()) as { + run_type?: string; + payload?: { watcher?: { execution_config?: Record } }; + }; + expect(job.run_type).toBe('watcher'); + expect(job.payload?.watcher?.execution_config).toEqual(execCfg); + }); }); diff --git a/packages/server/src/__tests__/integration/watchers/watchers-crud.test.ts b/packages/server/src/__tests__/integration/watchers/watchers-crud.test.ts index e0fd1ae38..3e83ed4d0 100644 --- a/packages/server/src/__tests__/integration/watchers/watchers-crud.test.ts +++ b/packages/server/src/__tests__/integration/watchers/watchers-crud.test.ts @@ -86,6 +86,123 @@ describe('watcher CRUD', () => { expect(list.watchers?.some((w) => w.watcher_id === watcherId)).toBe(false); }); + it('round-trips execution_config through create → list → update', async () => { + const created = (await owner.watchers.create({ + entity_id: entityId, + slug: 'exec-config-watcher', + name: 'Exec Config Watcher', + prompt: 'Track things.', + extraction_schema: { type: 'object', properties: {} }, + agent_id: agentId, + execution_config: { + timeout_seconds: 1800, + max_budget_usd: 2.5, + model: 'opus', + permission_mode: 'acceptEdits', + effort: 'high', + }, + })) as { watcher_id: string }; + const watcherId = created.watcher_id; + + const findRow = ( + res: { + watchers?: Array<{ watcher_id: string; execution_config?: Record | null }>; + }, + id: string + ) => res.watchers?.find((w) => String(w.watcher_id) === String(id)); + + const list = (await owner.watchers.list({ entity_id: entityId })) as { + watchers?: Array<{ watcher_id: string; execution_config?: Record }>; + }; + expect(findRow(list, watcherId)?.execution_config).toEqual({ + timeout_seconds: 1800, + max_budget_usd: 2.5, + model: 'opus', + permission_mode: 'acceptEdits', + effort: 'high', + }); + + // Update replaces the whole jsonb; a partial object is stored verbatim. + await owner.watchers.update({ + watcher_id: watcherId, + execution_config: { timeout_seconds: 300 }, + }); + const after = (await owner.watchers.list({ entity_id: entityId })) as { + watchers?: Array<{ watcher_id: string; execution_config?: Record }>; + }; + expect(findRow(after, watcherId)?.execution_config).toEqual({ timeout_seconds: 300 }); + + // Passing null clears the saved config back to NULL/defaults. + await owner.watchers.update({ watcher_id: watcherId, execution_config: null }); + const cleared = (await owner.watchers.list({ entity_id: entityId })) as { + watchers?: Array<{ watcher_id: string; execution_config?: Record | null }>; + }; + expect(findRow(cleared, watcherId)?.execution_config ?? null).toBeNull(); + + await owner.watchers.delete([watcherId]); + }); + + it('leaves execution_config null when unset', async () => { + const created = (await owner.watchers.create({ + entity_id: entityId, + slug: 'no-exec-config-watcher', + name: 'No Exec Config', + prompt: 'Track things.', + extraction_schema: { type: 'object', properties: {} }, + agent_id: agentId, + })) as { watcher_id: string }; + + const list = (await owner.watchers.list({ entity_id: entityId })) as { + watchers?: Array<{ watcher_id: string; execution_config?: Record | null }>; + }; + const row = list.watchers?.find( + (w) => String(w.watcher_id) === String(created.watcher_id) + ); + expect(row).toBeDefined(); + expect(row?.execution_config ?? null).toBeNull(); + + await owner.watchers.delete([created.watcher_id]); + }); + + it('rejects an invalid execution_config (type/range/unknown-key)', async () => { + const base = { + entity_id: entityId, + name: 'Bad Exec', + prompt: 'x', + extraction_schema: { type: 'object', properties: {} }, + agent_id: agentId, + }; + // timeout_seconds below minimum + await expect( + owner.watchers.create({ ...base, slug: 'bad-1', execution_config: { timeout_seconds: 0 } }) + ).rejects.toThrow(/execution_config/i); + // wrong type (string where integer expected) — would otherwise brick the + // Swift payload decode at run time. + await expect( + owner.watchers.create({ + ...base, + slug: 'bad-2', + execution_config: { timeout_seconds: '600' }, + } as never) + ).rejects.toThrow(/execution_config/i); + // unknown key (additionalProperties: false) + await expect( + owner.watchers.create({ + ...base, + slug: 'bad-3', + execution_config: { bogus: true }, + } as never) + ).rejects.toThrow(/execution_config/i); + // above maximum + await expect( + owner.watchers.create({ + ...base, + slug: 'bad-4', + execution_config: { timeout_seconds: 999_999 }, + }) + ).rejects.toThrow(/execution_config/i); + }); + it('creates an org-scoped watcher with no entity_id', async () => { const created = (await owner.watchers.create({ slug: 'org-scoped-watcher', diff --git a/packages/server/src/__tests__/unit/watcher-execution-config.test.ts b/packages/server/src/__tests__/unit/watcher-execution-config.test.ts new file mode 100644 index 000000000..12d402a43 --- /dev/null +++ b/packages/server/src/__tests__/unit/watcher-execution-config.test.ts @@ -0,0 +1,99 @@ +/** + * Unit coverage for execution_config validation + the owner/admin gate on + * elevated permission modes. The end-to-end persistence/round-trip is covered + * in __tests__/integration/watchers/watchers-crud.test.ts; this pins the + * validation rules and the privilege gate without the integration harness. + */ + +import { describe, expect, it } from 'bun:test'; +import { + assertValidExecutionConfig, + type ExecutionConfigCaller, +} from '../../tools/admin/watcher-execution-config'; + +const owner: ExecutionConfigCaller = { memberRole: 'owner', userId: 'u1', isAuthenticated: true }; +const admin: ExecutionConfigCaller = { memberRole: 'admin', userId: 'u2', isAuthenticated: true }; +const member: ExecutionConfigCaller = { memberRole: 'member', userId: 'u3', isAuthenticated: true }; +// apply / automation / default-provisioning: authenticated, no user/role. +const system: ExecutionConfigCaller = { memberRole: null, userId: null, isAuthenticated: true }; + +describe('assertValidExecutionConfig — passthrough', () => { + it('accepts undefined (unchanged) and null (clear)', () => { + expect(() => assertValidExecutionConfig(undefined, member)).not.toThrow(); + expect(() => assertValidExecutionConfig(null, member)).not.toThrow(); + }); + + it('accepts a valid full config', () => { + expect(() => + assertValidExecutionConfig( + { + timeout_seconds: 1800, + max_budget_usd: 2.5, + model: 'opus', + permission_mode: 'plan', + effort: 'high', + }, + owner + ) + ).not.toThrow(); + }); +}); + +describe('assertValidExecutionConfig — schema validation', () => { + it('rejects a non-object', () => { + expect(() => assertValidExecutionConfig('nope', owner)).toThrow(/must be a JSON object/i); + expect(() => assertValidExecutionConfig([1, 2], owner)).toThrow(/must be a JSON object/i); + }); + + it('rejects out-of-range timeout_seconds', () => { + expect(() => assertValidExecutionConfig({ timeout_seconds: 0 }, owner)).toThrow( + /execution_config/i + ); + expect(() => assertValidExecutionConfig({ timeout_seconds: 999_999 }, owner)).toThrow( + /execution_config/i + ); + }); + + it('rejects a wrong-typed field (string where integer expected)', () => { + // This is the silent-brick case: an unvalidated string would fail the + // device-worker's strict payload decode and disable every run. + expect(() => assertValidExecutionConfig({ timeout_seconds: '600' }, owner)).toThrow( + /execution_config/i + ); + }); + + it('rejects unknown keys (additionalProperties: false)', () => { + expect(() => assertValidExecutionConfig({ bogus: true }, owner)).toThrow(/execution_config/i); + }); + + it('rejects an invalid permission_mode enum value', () => { + expect(() => assertValidExecutionConfig({ permission_mode: 'yolo' }, owner)).toThrow( + /execution_config/i + ); + }); +}); + +describe('assertValidExecutionConfig — elevated permission_mode gate', () => { + for (const mode of ['bypassPermissions', 'dontAsk']) { + it(`blocks a member from setting ${mode}`, () => { + expect(() => assertValidExecutionConfig({ permission_mode: mode }, member)).toThrow( + /owner or admin/i + ); + }); + it(`allows an owner to set ${mode}`, () => { + expect(() => assertValidExecutionConfig({ permission_mode: mode }, owner)).not.toThrow(); + }); + it(`allows an admin to set ${mode}`, () => { + expect(() => assertValidExecutionConfig({ permission_mode: mode }, admin)).not.toThrow(); + }); + it(`allows a system/internal caller to set ${mode}`, () => { + expect(() => assertValidExecutionConfig({ permission_mode: mode }, system)).not.toThrow(); + }); + } + + it('allows a member to set non-elevated modes', () => { + for (const mode of ['default', 'plan', 'auto', 'acceptEdits']) { + expect(() => assertValidExecutionConfig({ permission_mode: mode }, member)).not.toThrow(); + } + }); +}); diff --git a/packages/server/src/sandbox/namespaces/watchers.ts b/packages/server/src/sandbox/namespaces/watchers.ts index 46cfb3e0f..563154dae 100644 --- a/packages/server/src/sandbox/namespaces/watchers.ts +++ b/packages/server/src/sandbox/namespaces/watchers.ts @@ -23,6 +23,30 @@ type WatcherActionInput = Omit & { watcher_id?: WatcherId; }; +/** + * Per-watcher device-worker CLI execution settings (mirrors the + * `watchers.execution_config` jsonb and the manage_watchers TypeBox schema). + * Every field is optional; omitted fields fall back to dispatcher/CLI defaults. + */ +export interface WatcherExecutionConfig { + /** Wall-clock cap in seconds for the device-worker CLI run (default 600). */ + timeout_seconds?: number; + /** Per-run dollar ceiling (claude: --max-budget-usd). */ + max_budget_usd?: number; + /** Model alias/id passed to the CLI (--model). */ + model?: string; + /** Tool permission mode (claude: --permission-mode). */ + permission_mode?: + | "acceptEdits" + | "auto" + | "bypassPermissions" + | "default" + | "dontAsk" + | "plan"; + /** Reasoning effort (claude: --effort). */ + effort?: "low" | "medium" | "high"; +} + export interface WatcherListFilter { entity_id?: number; status?: "active" | "paused" | "draft"; @@ -49,6 +73,7 @@ export interface WatcherCreateInput { agent_id?: string; scheduler_client_id?: string; model_config?: Record; + execution_config?: WatcherExecutionConfig; tags?: string[]; } @@ -58,6 +83,8 @@ export interface WatcherUpdateInput { agent_id?: string; scheduler_client_id?: string; model_config?: Record; + /** `null` clears a previously-saved config back to NULL/defaults. */ + execution_config?: WatcherExecutionConfig | null; sources?: Source[]; } diff --git a/packages/server/src/tools/admin/manage_watchers.ts b/packages/server/src/tools/admin/manage_watchers.ts index ba518ee86..9d1b389e7 100644 --- a/packages/server/src/tools/admin/manage_watchers.ts +++ b/packages/server/src/tools/admin/manage_watchers.ts @@ -61,6 +61,10 @@ import { validateTemplate } from '../../watchers/renderer'; import { validateClassifierSourcePaths, validateExtractionSchema } from '../../watchers/validator'; import type { ToolContext } from '../registry'; import { routeAction } from './action-router'; +import { + assertValidExecutionConfig, + WatcherExecutionConfigSchema, +} from './watcher-execution-config'; import { getNextNumericId, requireExists } from './helpers/db-helpers'; // Initialize AJV for JSON Schema validation @@ -406,6 +410,11 @@ export const ManageWatchersSchema = Type.Object({ }) ), model_config: Type.Optional(Type.Any({ description: '[create/update] AI model configuration' })), + // Union with Null so `update` can clear a previously-saved config back to + // NULL/defaults — omitted = unchanged, null = clear, object = replace. The + // object shape lives in WatcherExecutionConfigSchema (below) so it can also + // be compiled into a runtime validator (assertValidExecutionConfig). + execution_config: Type.Optional(Type.Union([Type.Null(), WatcherExecutionConfigSchema])), tags: Type.Optional(Type.Array(Type.String(), { description: '[create] Tags for filtering' })), // Version management @@ -719,7 +728,7 @@ export async function manageWatchers( return routeAction('manage_watchers', args.action, ctx, { create: () => handleCreate(args, env, ctx), - update: () => handleUpdate(args, env), + update: () => handleUpdate(args, env, ctx), create_version: () => handleCreateVersion(args, env, ctx), upgrade: () => handleUpgrade(args, env), complete_window: () => handleCompleteWindow(args, env, ctx), @@ -943,6 +952,7 @@ async function handleCreate( if (!args.extraction_schema) { throw new ToolUserError('extraction_schema is required for create action'); } + assertValidExecutionConfig(args.execution_config, ctx); // entity_id is optional: omit it for an org-scoped/global watcher. const entityId = args.entity_id; @@ -1056,7 +1066,8 @@ async function handleCreate( current_version_id, tags, status, created_by, created_at, updated_at, watcher_group_id, device_worker_id, agent_kind, - notification_channel, notification_priority, min_cooldown_seconds + notification_channel, notification_priority, min_cooldown_seconds, + execution_config ) VALUES ( ${watcherId}, ${args.name ?? args.slug}, ${args.slug}, ${organizationId}, ${`{${entityIdsArray.join(',')}}`}::bigint[], @@ -1069,7 +1080,8 @@ async function handleCreate( ${args.device_worker_id ?? null}, ${args.agent_kind ?? null}, ${args.notification_channel ?? 'canvas'}, ${args.notification_priority ?? 'normal'}, - ${args.min_cooldown_seconds ?? 0} + ${args.min_cooldown_seconds ?? 0}, + ${toJsonParam(tx, args.execution_config)} ) `; @@ -1173,7 +1185,7 @@ async function handleCreateFromVersion( // entity. Without this copy the new assignment would have no reactions. const versionRows = await sql` SELECT wv.*, w.organization_id, w.schedule, w.sources, w.agent_id, w.scheduler_client_id, - w.model_config, w.tags, w.watcher_group_id, + w.model_config, w.execution_config, w.tags, w.watcher_group_id, w.reaction_script, w.reaction_script_compiled FROM watcher_versions wv JOIN watchers w ON w.id = wv.watcher_id @@ -1230,7 +1242,7 @@ async function handleCreateFromVersion( await sql` INSERT INTO watchers ( id, name, slug, organization_id, entity_ids, - schedule, next_run_at, agent_id, scheduler_client_id, model_config, sources, version, + schedule, next_run_at, agent_id, scheduler_client_id, model_config, execution_config, sources, version, current_version_id, tags, status, created_by, created_at, updated_at, watcher_group_id, source_watcher_id, reaction_script, reaction_script_compiled @@ -1239,7 +1251,7 @@ async function handleCreateFromVersion( ${`{${entityId}}`}::bigint[], ${version.schedule ?? null}, ${version.schedule ? nextRunAt(version.schedule as string) : null}, ${version.agent_id ?? null}, ${version.scheduler_client_id ?? null}, - ${toJsonParam(sql, version.model_config)}, ${toJsonParam(sql, sources)}, + ${toJsonParam(sql, version.model_config)}, ${toJsonParam(sql, version.execution_config)}, ${toJsonParam(sql, sources)}, ${(version.version as number) ?? 1}, ${sharedVersionId}, ${toTextArrayParam((version.tags as string[]) || [])}::text[], 'active', ${createdBy}, NOW(), NOW(), ${groupId}, ${version.watcher_id}, @@ -1265,13 +1277,15 @@ async function handleCreateFromVersion( async function handleUpdate( args: ManageWatchersArgs, - _env: Env + _env: Env, + ctx: ToolContext ): Promise<{ action: 'update'; watcher_id: string; updated_fields: string[] }> { const sql = getDb(); if (!args.watcher_id) { throw new Error('watcher_id is required for update action'); } + assertValidExecutionConfig(args.execution_config, ctx); await requireExists(sql, 'watchers', args.watcher_id, 'Watcher'); @@ -1306,6 +1320,7 @@ async function handleUpdate( const updatedFields: string[] = []; if (args.model_config !== undefined) updatedFields.push('model_config'); + if (args.execution_config !== undefined) updatedFields.push('execution_config'); if (args.schedule !== undefined) updatedFields.push('schedule'); if (args.agent_id !== undefined) updatedFields.push('agent_id'); if (args.scheduler_client_id !== undefined) updatedFields.push('scheduler_client_id'); @@ -1331,6 +1346,7 @@ async function handleUpdate( UPDATE watchers SET updated_at = NOW(), model_config = CASE WHEN ${args.model_config !== undefined} THEN ${sql.json(args.model_config ?? {})} ELSE model_config END, + execution_config = CASE WHEN ${args.execution_config !== undefined} THEN ${toJsonParam(sql, args.execution_config)} ELSE execution_config END, schedule = CASE WHEN ${args.schedule !== undefined} THEN ${scheduleValue} ELSE schedule END, next_run_at = CASE WHEN ${args.schedule !== undefined} THEN ${nextRunAtVal}::timestamptz ELSE next_run_at END, agent_id = CASE WHEN ${args.agent_id !== undefined} THEN ${args.agent_id ?? null} ELSE agent_id END, @@ -2296,6 +2312,7 @@ async function handleList( i.last_fired_at, i.scheduler_client_id, i.model_config, + i.execution_config, i.sources, -- With fetch_types:false (see db/client.ts) postgres.js does not parse -- arrays, so text[] arrives as the literal "{a,b}"; wrap in to_jsonb so diff --git a/packages/server/src/tools/admin/watcher-execution-config.ts b/packages/server/src/tools/admin/watcher-execution-config.ts new file mode 100644 index 000000000..1541940a7 --- /dev/null +++ b/packages/server/src/tools/admin/watcher-execution-config.ts @@ -0,0 +1,101 @@ +import { Type } from '@sinclair/typebox'; +import { TypeCompiler } from '@sinclair/typebox/compiler'; +import { ToolUserError } from '../../utils/errors'; + +/** + * Per-watcher device-worker CLI execution settings (stored as the + * `watchers.execution_config` jsonb). Standalone so the same shape both feeds + * ManageWatchersSchema and compiles into a runtime validator — manage_watchers + * args are otherwise NOT schema-validated at the call boundary, and an + * unvalidated, type-wrong value would silently fail the device-worker's strict + * payload decode (bricking every run of that watcher). + */ +export const WatcherExecutionConfigSchema = Type.Object( + { + timeout_seconds: Type.Optional( + Type.Integer({ + minimum: 1, + // Bounded: the device dispatcher runs one CLI at a time, so an + // unbounded value could wedge a device's watcher queue. 24h ceiling. + maximum: 86_400, + description: 'Wall-clock cap in seconds for the device-worker CLI run (default 600).', + }) + ), + max_budget_usd: Type.Optional( + Type.Number({ + minimum: 0, + description: 'Per-run dollar ceiling (claude only: --max-budget-usd). No-op on other CLIs.', + }) + ), + model: Type.Optional(Type.String({ description: 'Model alias/id passed to the CLI (--model).' })), + permission_mode: Type.Optional( + Type.Union( + [ + Type.Literal('acceptEdits'), + Type.Literal('auto'), + Type.Literal('bypassPermissions'), + Type.Literal('default'), + Type.Literal('dontAsk'), + Type.Literal('plan'), + ], + { description: 'Tool permission mode (claude only: --permission-mode).' } + ) + ), + effort: Type.Optional( + Type.Union([Type.Literal('low'), Type.Literal('medium'), Type.Literal('high')], { + description: 'Reasoning effort (claude only: --effort).', + }) + ), + }, + { + additionalProperties: false, + description: + '[create/update] Per-watcher device-worker CLI execution settings. Omitted fields fall back to dispatcher/CLI defaults; pass null to clear.', + } +); + +const executionConfigValidator = TypeCompiler.Compile(WatcherExecutionConfigSchema); + +// Permission modes that let the spawned agent act unattended without prompting. +// Restricted to org owner/admin: a member-write actor can pin a watcher to +// another user's device, so allowing them to set these would be a privilege +// escalation (unattended privileged execution on the device owner's machine). +const ELEVATED_PERMISSION_MODES = new Set(['bypassPermissions', 'dontAsk']); + +/** Minimal caller identity needed to authorize elevated permission modes. */ +export interface ExecutionConfigCaller { + memberRole: string | null; + userId: string | null; + isAuthenticated: boolean; +} + +/** + * Validate an incoming `execution_config`. `undefined` = unchanged, `null` = + * clear — both pass. An object is validated against the schema + * (types/enums/range); elevated permission modes require owner/admin. Throws + * ToolUserError on rejection. + */ +export function assertValidExecutionConfig(value: unknown, caller: ExecutionConfigCaller): void { + if (value === undefined || value === null) return; + if (typeof value !== 'object' || Array.isArray(value)) { + throw new ToolUserError('execution_config must be a JSON object or null.'); + } + if (!executionConfigValidator.Check(value)) { + const errs = [...executionConfigValidator.Errors(value)] + .slice(0, 5) + .map((e) => `${e.path || '/'}: ${e.message}`) + .join('; '); + throw new ToolUserError(`Invalid execution_config — ${errs}`); + } + const mode = (value as { permission_mode?: string }).permission_mode; + // System/internal callers (apply, automation, default-provisioning) carry no + // memberRole and already bypass action-access enforcement; don't block them. + const isSystem = + caller.isAuthenticated && caller.userId === null && caller.memberRole === null; + const isOwnerOrAdmin = caller.memberRole === 'owner' || caller.memberRole === 'admin'; + if (mode && ELEVATED_PERMISSION_MODES.has(mode) && !isSystem && !isOwnerOrAdmin) { + throw new ToolUserError( + `execution_config.permission_mode '${mode}' requires an owner or admin role; members may use: default, plan, auto, acceptEdits.` + ); + } +} diff --git a/packages/server/src/utils/table-schema.ts b/packages/server/src/utils/table-schema.ts index 39f54d364..dded74068 100644 --- a/packages/server/src/utils/table-schema.ts +++ b/packages/server/src/utils/table-schema.ts @@ -129,6 +129,7 @@ export const QUERYABLE_SCHEMA = { 'agent_id', 'scheduler_client_id', 'model_config', + 'execution_config', 'status', 'created_at', 'updated_at', diff --git a/packages/server/src/worker-api.ts b/packages/server/src/worker-api.ts index 39e31512f..744afa7e9 100644 --- a/packages/server/src/worker-api.ts +++ b/packages/server/src/worker-api.ts @@ -527,7 +527,8 @@ export async function pollWorkerJob(c: Context<{ Bindings: Env }>) { w.slug AS watcher_slug, w.agent_kind AS watcher_agent_kind, w.notification_channel AS watcher_notification_channel, - w.notification_priority AS watcher_notification_priority + w.notification_priority AS watcher_notification_priority, + w.execution_config AS watcher_execution_config FROM runs r LEFT JOIN feeds f ON f.id = r.feed_id LEFT JOIN connections conn ON conn.id = r.connection_id @@ -602,6 +603,7 @@ export async function pollWorkerJob(c: Context<{ Bindings: Env }>) { watcher_agent_kind: string | null; watcher_notification_channel: string | null; watcher_notification_priority: string | null; + watcher_execution_config: Record | null; // Auth run fields run_auth_profile_id: number | null; auth_profile_auth_data: Record | null; @@ -639,6 +641,7 @@ export async function pollWorkerJob(c: Context<{ Bindings: Env }>) { agent_kind: agentKindFromPayload ?? row.watcher_agent_kind ?? null, notification_channel: row.watcher_notification_channel ?? 'canvas', notification_priority: row.watcher_notification_priority ?? 'normal', + execution_config: row.watcher_execution_config ?? null, }, event: { trigger_event_id: null,