From 5cb8a9917e8bb2d32af759dd98ebd7092deda244 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 29 Oct 2026 00:15:11 -0700 Subject: [PATCH] feat(core): add Code Mode checks with static tool scanning to schedules --- .../client/src/promise/generated/types.ts | 38 ++- packages/codemode/src/codemode.ts | 17 +- packages/codemode/src/interpreter/execute.ts | 2 +- packages/codemode/src/scan.ts | 249 +++++++++++++++++ packages/codemode/test/scan.test.ts | 59 ++++ packages/core/src/codemode/tool.ts | 64 +++-- packages/core/src/plugin/command/schedule.txt | 2 +- packages/core/src/schedule.ts | 30 +- packages/core/src/schedule/check.ts | 239 +++++++++++++++- packages/core/src/tool.ts | 53 +++- packages/core/src/tool/plugin/schedule.ts | 9 +- packages/core/test/schedule.test.ts | 263 +++++++++++++++++- packages/core/test/tool-schedule.test.ts | 12 + packages/protocol/openapi.json | 82 ++++-- packages/schema/src/schedule.ts | 60 +++- packages/server/test/schedule.test.ts | 19 ++ 16 files changed, 1128 insertions(+), 70 deletions(-) create mode 100644 packages/codemode/src/scan.ts create mode 100644 packages/codemode/test/scan.test.ts diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 64ba3f90b124..882dc6c4522e 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -329,6 +329,8 @@ export type ScheduleWhenCron = { type: "cron"; cron: string; tz?: string } export type ScheduleConditionShell = { type: "shell"; run: string; is?: string; matches?: string; changes?: boolean } +export type ScheduleConditionCode = { type: "code"; code: string } + export type ScheduleActionPrompt = { type: "prompt"; prompt: string } export type ScheduleStatus = "active" | "paused" | "done" @@ -907,7 +909,7 @@ export type ScheduleWhenIn = { type: "in"; in: ScheduleDuration; tz?: string } export type ScheduleWhenEvery = { type: "every"; every: ScheduleDuration; tz?: string } -export type ScheduleCondition = ScheduleConditionShell +export type ScheduleCondition = ScheduleConditionShell | ScheduleConditionCode export type ScheduleAction = ScheduleActionPrompt @@ -4445,10 +4447,40 @@ export type ExperimentalScheduleCreateInput = { ) | undefined readonly if?: - | { readonly run: string; readonly is?: string; readonly matches?: string; readonly changes?: boolean } + | ( + | { + readonly run: string + readonly is?: string + readonly matches?: string + readonly changes?: boolean + readonly code?: never + } + | { + readonly code: string + readonly run?: never + readonly is?: never + readonly matches?: never + readonly changes?: never + } + ) | undefined readonly until?: - | { readonly run: string; readonly is?: string; readonly matches?: string; readonly changes?: boolean } + | ( + | { + readonly run: string + readonly is?: string + readonly matches?: string + readonly changes?: boolean + readonly code?: never + } + | { + readonly code: string + readonly run?: never + readonly is?: never + readonly matches?: never + readonly changes?: never + } + ) | undefined readonly ends?: { readonly after?: string; readonly at?: string; readonly times?: number } | undefined readonly prompt: string diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 50bf562a278d..c6637b82573e 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -1,11 +1,15 @@ import { Effect, Schema } from "effect" +import { fromJson } from "./data.js" import type { Extension } from "./extension.js" import { executeProgram } from "./interpreter/execute.js" import { extensionGlobals } from "./interpreter/extensions.js" import { globalNames } from "./interpreter/globals.js" +import { scanTools } from "./scan.js" import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js" import type { Tools } from "./tools.js" +export { scanTools } + /** A tool call admitted during an execution. */ export type { CallResult, @@ -64,6 +68,8 @@ export type ExecuteOptions< > = Options & { /** Source for one program in the supported JavaScript subset. */ code: string + /** Read-only JSON values bound in the program's outer global scope (shadowable by top-level declarations). */ + bindings?: Readonly> } /** A JSON value that can cross the confined interpreter boundary. */ @@ -128,7 +134,7 @@ export type Result = typeof Result.Type /** Reusable confined runtime over explicit tools. */ export type Runtime = { readonly catalog: ReadonlyArray - readonly execute: (code: string) => Effect.Effect + readonly execute: (code: string, bindings?: Readonly>) => Effect.Effect } const validateLimit = (name: keyof ExecutionLimits, value: number | undefined, minimum: number): number | undefined => { @@ -150,7 +156,7 @@ export const execute = < const Extensions extends ReadonlyArray = [], >( options: ExecuteOptions, -): Effect.Effect> => make(options).execute(options.code) +): Effect.Effect> => make(options).execute(options.code, options.bindings) /** Creates an Effect-native runtime over explicit, schema-described tools and extensions. */ export const make = < @@ -175,7 +181,10 @@ export const make = < get catalog() { return prepared.catalog }, - execute: (code) => - executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => extensionGlobals(ctx, extensions)), + execute: (code, bindings) => + executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => [ + ...Object.entries(bindings ?? {}).map(([name, value]) => [name, fromJson(ctx, value)] as const), + ...extensionGlobals(ctx, extensions), + ]), } } diff --git a/packages/codemode/src/interpreter/execute.ts b/packages/codemode/src/interpreter/execute.ts index 19e899f3e447..dbad1daa2184 100644 --- a/packages/codemode/src/interpreter/execute.ts +++ b/packages/codemode/src/interpreter/execute.ts @@ -110,7 +110,7 @@ export const executeProgram = ( }) } -const parseProgram = (code: string): { ok: true; program: Program } | { ok: false; error: Diagnostic } => { +export const parseProgram = (code: string): { ok: true; program: Program } | { ok: false; error: Diagnostic } => { try { const program = parse(code, { ecmaVersion: "latest", diff --git a/packages/codemode/src/scan.ts b/packages/codemode/src/scan.ts new file mode 100644 index 000000000000..50694134372d --- /dev/null +++ b/packages/codemode/src/scan.ts @@ -0,0 +1,249 @@ +import type { AnyNode, BlockStatement, ModuleDeclaration, Pattern, Program, Statement } from "acorn" +import { Result } from "effect" +import type { Diagnostic } from "./codemode.js" +import { parseProgram } from "./interpreter/execute.js" +import { formatLocation } from "./interpreter/model.js" + +/** + * Extracts every `tools.` referenced by a Code Mode program, in first-seen order. Fails on syntax errors, + * dynamic bracket access (`tools[name]`), or aliasing/passing `tools` rather than calling `tools.(...)` directly. + */ +export function scanTools(code: string): Result.Result, Diagnostic> { + if (code.trim().length === 0) { + return Result.fail({ kind: "ParseError", message: "Code cannot be empty." }) + } + const parsed = parseProgram(code) + if (!parsed.ok) return Result.fail(parsed.error) + + const found = new Set() + const scopes: Array> = [collectScope(parsed.program, true)] + const ancestors: Array = [] + + const isShadowed = () => scopes.some((scope) => scope.has("tools")) + + const fail = (node: AnyNode, message: string): Diagnostic => { + const loc = node.loc?.start ? { line: node.loc.start.line, column: node.loc.start.column + 1 } : undefined + return { + kind: "UnsupportedSyntax", + message: `${message}${loc ? formatLocation(loc) : ""}`, + ...(loc ? { location: loc } : {}), + } + } + + const visit = (node: AnyNode | null | undefined): Diagnostic | undefined => { + if (!node) return undefined + + if (node.type === "Identifier" && node.name === "tools" && !isShadowed()) { + const parent = ancestors.at(-1) + if (!isReferencePosition(node, parent)) return undefined + const chain = readStaticToolCall(node, ancestors) + if (!chain.ok) return fail(chain.node, chain.reason) + found.add(chain.path) + return undefined + } + + const pushedScope = enterNodeScope(node) + if (pushedScope) scopes.push(pushedScope) + ancestors.push(node) + + const error = visitChildren(node, visit) + + ancestors.pop() + if (pushedScope) scopes.pop() + return error + } + + const diagnostic = visit(parsed.program) + if (diagnostic) return Result.fail(diagnostic) + return Result.succeed(Array.from(found)) +} + +function isReferencePosition(node: AnyNode, parent: AnyNode | undefined): boolean { + if (!parent) return true + if (parent.type === "MemberExpression" && parent.property === node && !parent.computed) return false + if (parent.type === "Property" && parent.key === node && !parent.computed && !parent.shorthand) return false + if (parent.type === "MethodDefinition" && parent.key === node && !parent.computed) return false + if ( + (parent.type === "LabeledStatement" || parent.type === "BreakStatement" || parent.type === "ContinueStatement") && + parent.label === node + ) { + return false + } + if ( + (parent.type === "FunctionDeclaration" || parent.type === "FunctionExpression" || parent.type === "ClassDeclaration" || parent.type === "ClassExpression") && + parent.id === node + ) { + return false + } + if (parent.type === "VariableDeclarator" && parent.id === node) return false + return true +} + +function readStaticToolCall( + root: AnyNode, + ancestors: ReadonlyArray, +): { readonly ok: true; readonly path: string } | { readonly ok: false; readonly node: AnyNode; readonly reason: string } { + const segments: Array = [] + let current = root + let index = ancestors.length - 1 + + while (index >= 0) { + const parent = ancestors[index]! + if (parent.type === "ChainExpression" && parent.expression === current) { + current = parent + index -= 1 + continue + } + if (parent.type !== "MemberExpression" || parent.object !== current) break + if (!parent.computed && parent.property.type === "Identifier") { + segments.push(parent.property.name) + current = parent + index -= 1 + continue + } + if (parent.computed && parent.property.type === "Literal" && typeof parent.property.value === "string") { + segments.push(parent.property.value) + current = parent + index -= 1 + continue + } + return { + ok: false, + node: parent.property, + reason: "Schedule checks must reference tools statically (e.g. tools.slack.read_thread), not with dynamic bracket expressions.", + } + } + + const callParent = index >= 0 ? ancestors[index] : undefined + if (segments.length === 0 || callParent?.type !== "CallExpression" || callParent.callee !== current) { + return { + ok: false, + node: current, + reason: "Schedule checks must call tools directly as tools.(...), not alias or pass the tools object.", + } + } + return { ok: true, path: segments.join(".") } +} + +function enterNodeScope(node: AnyNode): Set | undefined { + if ( + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression" + ) { + const names = new Set() + if (node.id) names.add(node.id.name) + for (const param of node.params) collectPattern(param, names) + if (node.body.type === "BlockStatement") { + for (const name of collectScope(node.body, true)) names.add(name) + } + return names + } + if (node.type === "BlockStatement") return collectScope(node, false) + if (node.type === "CatchClause") { + const names = new Set() + if (node.param) collectPattern(node.param, names) + return names + } + if (node.type === "ForStatement" && node.init?.type === "VariableDeclaration" && node.init.kind !== "var") { + const names = new Set() + for (const decl of node.init.declarations) collectPattern(decl.id, names) + return names + } + if ( + (node.type === "ForInStatement" || node.type === "ForOfStatement") && + node.left.type === "VariableDeclaration" && + node.left.kind !== "var" + ) { + const names = new Set() + for (const decl of node.left.declarations) collectPattern(decl.id, names) + return names + } + return undefined +} + +function collectScope(container: Program | BlockStatement, includeVar: boolean): Set { + const names = new Set() + for (const statement of container.body) { + if (statement.type === "FunctionDeclaration" && statement.id) names.add(statement.id.name) + if (statement.type === "ClassDeclaration" && statement.id) names.add(statement.id.name) + if (statement.type === "VariableDeclaration" && (includeVar || statement.kind !== "var")) { + for (const decl of statement.declarations) collectPattern(decl.id, names) + } + } + if (includeVar) collectNestedVars(container.body, names) + return names +} + +function collectNestedVars(statements: ReadonlyArray, names: Set) { + const walkStmt = (node: AnyNode | null | undefined) => { + if (!node) return + if ( + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression" + ) { + return + } + if (node.type === "VariableDeclaration" && node.kind === "var") { + for (const decl of node.declarations) collectPattern(decl.id, names) + } + visitChildren(node, (child) => { + walkStmt(child) + return undefined + }) + } + for (const stmt of statements) walkStmt(stmt) +} + +function collectPattern(pattern: Pattern, names: Set) { + switch (pattern.type) { + case "Identifier": + names.add(pattern.name) + return + case "AssignmentPattern": + collectPattern(pattern.left, names) + return + case "RestElement": + collectPattern(pattern.argument, names) + return + case "ArrayPattern": + for (const element of pattern.elements) if (element) collectPattern(element, names) + return + case "ObjectPattern": + for (const prop of pattern.properties) { + if (prop.type === "RestElement") collectPattern(prop.argument, names) + if (prop.type === "Property") collectPattern(prop.value, names) + } + return + case "MemberExpression": + return + } +} + +function visitChildren( + node: AnyNode, + visit: (child: AnyNode | null | undefined) => Diagnostic | undefined, +): Diagnostic | undefined { + for (const [key, value] of Object.entries(node as unknown as Record)) { + if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue + if (Array.isArray(value)) { + for (const item of value) { + if (isAstNode(item)) { + const error = visit(item) + if (error) return error + } + } + continue + } + if (isAstNode(value)) { + const error = visit(value) + if (error) return error + } + } + return undefined +} + +function isAstNode(value: unknown): value is AnyNode { + return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string" +} diff --git a/packages/codemode/test/scan.test.ts b/packages/codemode/test/scan.test.ts new file mode 100644 index 000000000000..f6a876901b11 --- /dev/null +++ b/packages/codemode/test/scan.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Result } from "effect" +import { CodeMode } from "../src/index.js" + +describe("CodeMode.scanTools", () => { + test("extracts dotted, bracketed and nested static tool calls in first-seen order", () => { + const scanned = CodeMode.scanTools(` + const chapters = await tools["hex-meetings"].get_chapters({ meeting_id: "m1" }) + const thread = await tools.slack.read_thread({ ts: await tools.slack.latest_ts({}) }) + await tools.slack.read_thread({ ts: "again" }) + const ignored = { tools: 1 }.tools + return { wake: true, state: chapters } + `) + expect(Result.isSuccess(scanned)).toBe(true) + if (Result.isSuccess(scanned)) { + expect(scanned.success).toEqual(["hex-meetings.get_chapters", "slack.read_thread", "slack.latest_ts"]) + } + }) + + test("ignores shadowed local tools bindings", () => { + const scanned = CodeMode.scanTools(` + function helper(tools) { + return tools[dynamicKey]() + } + { + const tools = { any: () => 1 } + tools.any() + } + return helper({ [dynamicKey]: () => ({ wake: false }) }) + `) + expect(scanned).toEqual(Result.succeed([])) + }) + + test("rejects empty code, syntax errors, dynamic bracket access, and aliasing tools", () => { + expect(Result.isFailure(CodeMode.scanTools(" "))).toBe(true) + expect(Result.isFailure(CodeMode.scanTools("const x = {"))).toBe(true) + expect(Result.isFailure(CodeMode.scanTools("return await tools.slack[name]({})"))).toBe(true) + expect(Result.isFailure(CodeMode.scanTools("const s = tools.slack; return await s.read_thread({})"))).toBe(true) + expect(Result.isFailure(CodeMode.scanTools("return Object.keys(tools)"))).toBe(true) + }) + + test("binds outer globals that programs can read or shadow while keeping line numbers", async () => { + const read = await Effect.runPromise( + CodeMode.execute({ + code: "return { prev: state.count, runs, same: input.state.count }", + bindings: { state: { count: 4 }, runs: 2, input: { state: { count: 4 } } }, + }), + ) + expect(read).toMatchObject({ ok: true, value: { prev: 4, runs: 2, same: 4 } }) + + const shadowed = await Effect.runPromise( + CodeMode.execute({ + code: "const state = 9\nreturn { wake: true, state }", + bindings: { state: { count: 4 } }, + }), + ) + expect(shadowed).toMatchObject({ ok: true, value: { wake: true, state: 9 } }) + }) +}) diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index 977099911844..31eee5d16f8b 100644 --- a/packages/core/src/codemode/tool.ts +++ b/packages/core/src/codemode/tool.ts @@ -104,16 +104,11 @@ export const create = ( Effect.gen(function* () { const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1) const executed = yield* executeTool(name, tool, input, context) - const content = - typeof executed.content === "string" - ? [{ type: "text" as const, text: executed.content }] - : (executed.content ?? []) + const content = resultContent(executed) const outputFileParts = outputFiles(content) if (outputFileParts.length > 0) yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }]) - if (executed.output !== undefined) return executed.output - const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n") - return text === "" ? null : text + return resultValue(executed) }), hooks: progressHooks(record), }), @@ -237,6 +232,36 @@ const runtimes = new WeakMap>() function runtime(inventory: Inventory) { const cached = runtimes.get(inventory) if (cached) return cached + const tools = inventoryTools(inventory) + const created = CodeMode.make({ + tools, + extensions: [CodeModeWeb.extension], + hooks: runHooks, + }) + runtimes.set(inventory, created) + return created +} + +/** + * Runs one program over `inventory` alone with no model step and no `fetch`, binding `bindings` as outer globals. + */ +export const check = ( + inventory: Inventory, + code: string, + bindings: Readonly>, + limits: CodeMode.ExecutionLimits, + executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect, +) => + CodeMode.make({ tools: inventoryTools(inventory), hooks: runHooks, limits }) + .execute(code, bindings) + .pipe( + Effect.provideService(Run, { + executeTool: (name, tool, input) => executeTool(name, tool, input).pipe(Effect.map(resultValue)), + hooks: {}, + }), + ) + +function inventoryTools(inventory: Inventory) { // A path may carry namespace metadata, a callable tool, child tools, or all three. const root: ToolNode = { children: new Map() } for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace @@ -249,14 +274,21 @@ function runtime(inventory: Inventory) { execute: (input) => Run.use((run) => run.executeTool(name, registration, input)), }) } - const tools = renderTools(root) - const created = CodeMode.make({ - tools, - extensions: [CodeModeWeb.extension], - hooks: runHooks, - }) - runtimes.set(inventory, created) - return created + return renderTools(root) +} + +function resultContent(executed: Result) { + return typeof executed.content === "string" + ? [{ type: "text" as const, text: executed.content }] + : (executed.content ?? []) +} + +function resultValue(executed: Result) { + if (executed.output !== undefined) return executed.output + const text = resultContent(executed) + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join("\n") + return text === "" ? null : text } const runHooks = { @@ -320,7 +352,7 @@ function flattenTools(node: ToolNode, path: ReadonlyArray, tools: Tools) for (const [name, child] of node.children) flattenTools(child, [...path, name], tools) } -function qualifiedName(registration: Info) { +export function qualifiedName(registration: Info) { const normalized = normalizedName(registration) if (registration.options?.namespace === undefined) return normalized return `${registration.options.namespace}.${normalized}` diff --git a/packages/core/src/plugin/command/schedule.txt b/packages/core/src/plugin/command/schedule.txt index 42ac4847d084..c8a05153606d 100644 --- a/packages/core/src/plugin/command/schedule.txt +++ b/packages/core/src/plugin/command/schedule.txt @@ -4,7 +4,7 @@ Request: $ARGUMENTS Use the schedule tools in the `opencode` namespace. They only see this session's schedules. -- To schedule something, call `schedule_create`. Choose `when` (`{ in: "20 minutes" }`, `{ at: "2026-10-27T09:00" }`, `{ every: "1 hour" }` or `{ cron: "0 9 * * 1-5" }`, optional with `until` which defaults to every 30s). Add `until: { run: "..." }` to wait once for a shell condition (exit 0, `is`, `matches`, or `changes: true`, defaulting to a 24h timeout), or `if: { run: "..." }` to gate each recurring slot without a model call. To stop a repeating schedule, add `ends`: `{ after: "8 hours" }`, `{ at: "..." }` or `{ times: 3 }`. Write `prompt` as the instruction you will receive when it wakes, with enough context to act on it without this conversation. +- To schedule something, call `schedule_create`. Choose `when` (`{ in: "20 minutes" }`, `{ at: "2026-10-27T09:00" }`, `{ every: "1 hour" }` or `{ cron: "0 9 * * 1-5" }`, optional with `until` which defaults to every 30s). Add `until` (wake once then stop, 24h default timeout) or `if` (gate each recurring slot with no model call) using either `{ run: "..." }` for a shell check or `{ code: "..." }` for a Code Mode JavaScript check calling `tools.(...)` statically and returning a boolean or `{ wake, state?, message? }`. To stop a repeating schedule, add `ends`: `{ after: "8 hours" }`, `{ at: "..." }` or `{ times: 3 }`. Write `prompt` as the instruction you will receive when it wakes, with enough context to act on it without this conversation. - To pause, resume or delete one, find its id with `schedule_list`, then call `schedule_pause`, `schedule_resume` or `schedule_delete`. - If the request is empty, call `schedule_list` and show this session's schedules: name, when, status and next run. diff --git a/packages/core/src/schedule.ts b/packages/core/src/schedule.ts index 96e096caf80b..6900f334ee32 100644 --- a/packages/core/src/schedule.ts +++ b/packages/core/src/schedule.ts @@ -285,9 +285,12 @@ const layer = Layer.effect( if (row.check !== null) { const check = row.check - const outcome = yield* ScheduleCheck.evaluate(session, check, row.state ?? null).pipe( - instances.provide(session), - ) + const checkMessageID = SessionMessage.ID.make(`msg_${row.id}_check_${row.checks + 1}`) + const outcome = yield* ScheduleCheck.evaluate(session, check, row.state ?? null, { + messageID: checkMessageID, + now, + checks: row.checks, + }).pipe(instances.provide(session)) if (!outcome.ok) { const strikes = row.failures + 1 @@ -628,6 +631,7 @@ const layer = Layer.effect( if (ends !== null && due > ends) return yield* new InvalidError({ reason: "The schedule ends before it first fires" }) + const scheduleID = ID.create() const seeded = check === undefined ? { matched: false as const, state: null as Schema.Json, checks: 0 } @@ -642,9 +646,15 @@ const layer = Layer.effect( ), ) const shouldSeed = - check.mode === "until" || (check.condition.type === "shell" && check.condition.changes === true) + check.mode === "until" || + (check.condition.type === "code" && when.type === "every") || + (check.condition.type === "shell" && check.condition.changes === true) if (!shouldSeed) return { matched: false as const, state: null as Schema.Json, checks: 0 } - const init = yield* ScheduleCheck.evaluate(session, check, null).pipe( + const init = yield* ScheduleCheck.evaluate(session, check, null, { + messageID: SessionMessage.ID.make(`msg_${scheduleID}_check_0`), + now, + checks: 0, + }).pipe( instances.provide(session), Effect.catchTag("FileSystem.DirectoryNotFoundError", () => Effect.fail( @@ -652,8 +662,10 @@ const layer = Layer.effect( ), ), ) - // A target that is not up yet (e.g. curl exiting 7 while a server boots) is "not yet", not an invalid schedule. - if (!init.ok) return { matched: false as const, state: null as Schema.Json, checks: 0 } + if (!init.ok) { + if (init.invalid) return yield* new InvalidError({ reason: init.error }) + return { matched: false as const, state: null as Schema.Json, checks: 0 } + } if (check.mode === "until" && init.wake) return { matched: true as const, state: init.state, message: init.message } return { matched: false as const, state: init.state, checks: 1 } @@ -663,7 +675,7 @@ const layer = Layer.effect( const row = yield* db .insert(ScheduleTable) .values({ - id: ID.create(), + id: scheduleID, session_id: session.id, name: input.name ?? null, check: check ?? null, @@ -692,7 +704,7 @@ const layer = Layer.effect( const row = yield* db .insert(ScheduleTable) .values({ - id: ID.create(), + id: scheduleID, session_id: session.id, name: input.name ?? null, check: check ?? null, diff --git a/packages/core/src/schedule/check.ts b/packages/core/src/schedule/check.ts index 0ebf78a3b103..16f0633a382f 100644 --- a/packages/core/src/schedule/check.ts +++ b/packages/core/src/schedule/check.ts @@ -1,22 +1,57 @@ export * as ScheduleCheck from "./check.js" +import { CodeMode } from "@opencode/codemode" import { isRecord } from "@opencode/util/record" import { collectStream } from "@opencode/util/process" import type { Schedule as ScheduleSchema } from "@opencode/schema/schedule" -import { Array, Duration, Effect, Option, Result, type Schema } from "effect" +import { Array, Duration, Effect, Option, Result, Schema } from "effect" import { ChildProcess } from "effect/process" import { Agent } from "../agent.js" +import { CodeModeTool } from "../codemode/tool.js" import { Environment } from "../environment/index.js" import { FileAccess } from "../file-access.js" import { Location } from "../location.js" import { Permission } from "../permission.js" import { Plugin } from "../plugin.js" +import type { SessionMessage } from "../session/message.js" import type { SessionSchema } from "../session/schema.js" import { ShellParse } from "../shell/parse.js" import { ShellSelect } from "../shell/select.js" +import { Tool } from "../tool.js" +import { McpTool } from "../tool/mcp.js" export const TIMEOUT_MS = 30_000 const MAX_OUTPUT_BYTES = 32 * 1024 +const CODE_LIMITS: CodeMode.ExecutionLimits = { + timeoutMs: TIMEOUT_MS, + maxToolCalls: 20, + maxOutputBytes: MAX_OUTPUT_BYTES, +} + +const FORBIDDEN_CODE_TOOLS = new Set([ + "opencode.schedule_create", + "opencode.schedule_pause", + "opencode.schedule_resume", + "opencode.schedule_delete", + "opencode.session_move", + "opencode.session_rename", + "opencode.worktree_create", + "opencode.worktree_remove", +]) + +const decodeCodeObjectResult = Schema.decodeUnknownResult( + Schema.Struct({ + wake: Schema.Boolean, + state: Schema.optionalKey(Schema.Json), + message: Schema.optionalKey(Schema.String), + }), +) + +export interface RunContext { + readonly messageID: SessionMessage.ID + readonly now: number + readonly checks: number +} export type Outcome = | { @@ -28,6 +63,7 @@ export type Outcome = | { readonly ok: false readonly error: string + readonly invalid?: boolean } /** Validates condition syntax synchronously before asking for permissions or spawning anything. */ @@ -50,6 +86,16 @@ export function validate(condition: ScheduleSchema.Condition): Result.Result item.trim()) + .find((item) => item.length > 0) + return line ? `code (${line.slice(0, 60)})` : "code" + } } } @@ -71,6 +124,8 @@ export const authorize = Effect.fnUntraced(function* ( switch (check.condition.type) { case "shell": return yield* authorizeShell(session, name, check.mode, check.condition) + case "code": + return yield* authorizeCode(session, name, check.mode, check.condition) } }) @@ -79,6 +134,7 @@ export const evaluate = Effect.fnUntraced(function* ( session: SessionSchema.Info, check: ScheduleSchema.StoredCheck, prevState: Schema.Json, + context: RunContext, ) { const run = Effect.gen(function* () { const plugins = yield* Plugin.Service @@ -86,6 +142,8 @@ export const evaluate = Effect.fnUntraced(function* ( switch (check.condition.type) { case "shell": return yield* runShell(session, check.condition, prevState) + case "code": + return yield* runCode(session, check.condition, prevState, context) } }) return yield* run.pipe( @@ -99,6 +157,183 @@ export const evaluate = Effect.fnUntraced(function* ( ) }) +const resolveCodeTools = Effect.fnUntraced(function* (paths: ReadonlyArray) { + const mcp = yield* McpTool.Service + const tools = yield* Tool.Service + if (paths.length > 0) yield* mcp.flush + const registered = yield* tools.list() + const resolved: Array<(typeof registered)[number]> = [] + for (const path of paths) { + if (FORBIDDEN_CODE_TOOLS.has(path)) { + return yield* Effect.fail(`Tool ${path} cannot be used inside a schedule check`) + } + const found = registered.find( + (item) => item.options?.codemode !== false && CodeModeTool.qualifiedName(item) === path, + ) + if (!found) return yield* Effect.fail(`Unknown Code Mode tool: ${path}`) + if (found.options?.permission === "edit") { + return yield* Effect.fail(`Tool ${path} cannot be used inside a schedule check`) + } + resolved.push(found) + } + return { tools, resolved } +}) + +const authorizeCode = Effect.fnUntraced(function* ( + session: SessionSchema.Info, + name: string | undefined, + mode: ScheduleSchema.CheckMode, + condition: ScheduleSchema.CodeCondition, +) { + const permission = yield* Permission.Service + const agents = yield* Agent.Service + const agent = yield* agents.select(session.agent) + const scanned = CodeMode.scanTools(condition.code) + if (Result.isFailure(scanned)) return yield* Effect.fail(scanned.failure.message) + const paths = scanned.success + + yield* permission.assertNotDenied({ + action: "execute", + resources: ["*"], + sessionID: session.id, + agent: agent.id, + }) + + const { resolved } = yield* resolveCodeTools(paths) + const actions = Array.dedupe(resolved.map((tool) => tool.options?.permission ?? tool.id)) + + if (actions.length === 0) { + yield* permission.assert({ + action: "execute", + resources: ["*"], + metadata: { schedule: name, mode, code: condition.code, tools: [] }, + sessionID: session.id, + agent: agent.id, + }) + return + } + + // Fail fast on any explicit deny before asking the user to approve earlier tools. + yield* Effect.forEach( + actions, + (action) => + permission.assertNotDenied({ + action, + resources: ["*"], + sessionID: session.id, + agent: agent.id, + }), + { discard: true }, + ) + + yield* Effect.forEach( + actions, + (action) => + permission.assert({ + action, + resources: ["*"], + // No `save`: "always" would save a project-wide allow, while scheduled ticks run via Unattended. + metadata: { schedule: name, mode, code: condition.code, tools: [...paths] }, + sessionID: session.id, + agent: agent.id, + }), + { discard: true }, + ) +}) + +const runCode = Effect.fnUntraced(function* ( + session: SessionSchema.Info, + condition: ScheduleSchema.CodeCondition, + prevState: Schema.Json, + context: RunContext, +) { + const permission = yield* Permission.Service + const agents = yield* Agent.Service + const agent = yield* agents.select(session.agent) + if (!agent.info) return { ok: false as const, error: `Agent not found: ${agent.id}` } + + const scanned = CodeMode.scanTools(condition.code) + if (Result.isFailure(scanned)) return { ok: false as const, error: scanned.failure.message } + const paths = scanned.success + + const resolvedExit = yield* Effect.result(resolveCodeTools(paths)) + if (Result.isFailure(resolvedExit)) return { ok: false as const, error: resolvedExit.failure } + + const actions = Array.dedupe(resolvedExit.success.resolved.map((tool) => tool.options?.permission ?? tool.id)) + const unattended: Permission.Ruleset = ["execute", ...actions].map((action) => ({ + action, + resource: "*", + effect: "allow" as const, + })) + + const notDenied = yield* Effect.forEach( + ["execute", ...actions], + (action) => + permission.assertNotDenied({ + action, + resources: ["*"], + sessionID: session.id, + agent: agent.id, + }), + { discard: true }, + ).pipe(Effect.provideService(Permission.Unattended, unattended), Effect.result) + + if (Result.isFailure(notDenied)) return { ok: false as const, error: notDenied.failure.message } + + const bindings: Record = { + state: prevState, + now: context.now, + runs: context.checks, + input: { state: prevState, now: context.now, runs: context.checks }, + } + + const result = yield* resolvedExit.success.tools + .check({ + code: condition.code, + tools: paths, + bindings, + permissions: Permission.merge(agent.info.permissions, session.permissions ?? []), + limits: CODE_LIMITS, + context: { + sessionID: session.id, + agent: agent.id, + messageID: context.messageID, + progress: () => Effect.void, + }, + }) + .pipe(Effect.provideService(Permission.Unattended, unattended)) + + if (!result.ok) { + const invalid = + result.error.kind === "ParseError" || + result.error.kind === "UnsupportedSyntax" || + result.error.kind === "UnknownTool" || + result.error.kind === "InvalidToolInput" || + result.error.message.includes("needs approval") + return { ok: false as const, error: result.error.message, ...(invalid ? { invalid: true } : {}) } + } + if (typeof result.value === "boolean") { + return { ok: true as const, wake: result.value, state: prevState } + } + const decoded = decodeCodeObjectResult(result.value) + if (Result.isFailure(decoded)) { + return { + ok: false as const, + error: `A code check must return a boolean or { wake, state?, message? }: ${decoded.failure}`, + invalid: true, + } + } + const state = decoded.success.state === undefined ? prevState : decoded.success.state + const trimmed = decoded.success.message?.trim() + const message = trimmed ? trimmed : undefined + return { + ok: true as const, + wake: decoded.success.wake, + state, + ...(decoded.success.wake && message !== undefined ? { message } : {}), + } +}) + const scanShell = Effect.fnUntraced(function* (command: string) { const location = yield* Location.Service const access = yield* FileAccess.Service @@ -171,7 +406,7 @@ const runShell = Effect.fnUntraced(function* ( ) { const environment = yield* Environment.Service const scannedExit = yield* Effect.result(scanShell(condition.run)) - if (Result.isFailure(scannedExit)) return { ok: false, error: scannedExit.failure } satisfies Outcome + if (Result.isFailure(scannedExit)) return { ok: false as const, error: scannedExit.failure } satisfies Outcome const scanned = scannedExit.success const unattended: Permission.Ruleset = [ ...scanned.external.map((resource) => ({ action: "external_directory", resource, effect: "allow" as const })), diff --git a/packages/core/src/tool.ts b/packages/core/src/tool.ts index 659cee2643d1..201c19f5e459 100644 --- a/packages/core/src/tool.ts +++ b/packages/core/src/tool.ts @@ -3,8 +3,9 @@ export { CallID, Content, Error, FileContent, TextContent } from "@opencode/sche export type { Context, Metadata, Namespace, Options, Result } from "@opencode/schema/tool" import { ToolDefinition, type ToolCall } from "@opencode/ai/effect" +import type { CodeMode } from "@opencode/codemode" import { Tool } from "@opencode/schema/tool" -import { Context, Effect, Layer, Result, Schema, SchemaIssue, Types } from "effect" +import { Context, Effect, Layer, Ref, Result, Schema, SchemaIssue, Types } from "effect" import { makeLocationNode } from "@opencode/util/effect/app-node" import type { Agent } from "./agent.js" import { CodeModeCatalog } from "./codemode/catalog.js" @@ -43,6 +44,20 @@ type Data = { export interface Interface extends State.Transformable { readonly list: () => Effect.Effect> readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect + /** + * Runs a Code Mode program with no model step over only the Code Mode tools named in `tools` (still subject to + * whole-tool permission filtering and each tool's own executor policy), with no `fetch`. + */ + readonly check: (input: CheckInput) => Effect.Effect +} + +export interface CheckInput { + readonly code: string + readonly tools: ReadonlyArray + readonly bindings: Readonly> + readonly permissions: Permission.Ruleset + readonly limits: CodeMode.ExecutionLimits + readonly context: Omit } /** A local execution result after hooks and content normalization. */ @@ -319,6 +334,42 @@ const layer = Layer.effect( } }), ), + check: Effect.fn("Tool.check")(function* (input) { + if (whollyDisabled("execute", input.permissions)) { + return { + ok: false, + error: { kind: "ExecutionFailure", message: "Code Mode is disabled for this agent" }, + toolCalls: [], + } satisfies CodeMode.Result + } + const data = state.get() + const allowed = new Set(input.tools) + const tools = new Map( + Array.from(data.tools).filter( + ([name, tool]) => + tool.options?.codemode !== false && + allowed.has(CodeModeTool.qualifiedName(tool)) && + !whollyDisabled(tool.options?.permission ?? name, input.permissions), + ), + ) + const callSeq = yield* Ref.make(0) + return yield* CodeModeTool.check( + { tools, namespaces: data.namespaces }, + input.code, + input.bindings, + input.limits, + (name, tool, toolInput) => + Effect.gen(function* () { + const index = yield* Ref.getAndUpdate(callSeq, (value) => value + 1) + const context: Tool.Context = { + ...input.context, + id: Tool.CallID.make(`call_${input.context.messageID}_${index}`), + } + const event = yield* beforeExecute(name, toolInput, context) + return yield* executeTool(tool, name, event.input, context) + }), + ) + }), }) }), ) diff --git a/packages/core/src/tool/plugin/schedule.ts b/packages/core/src/tool/plugin/schedule.ts index e8ef41a373b9..781ef69a5bd0 100644 --- a/packages/core/src/tool/plugin/schedule.ts +++ b/packages/core/src/tool/plugin/schedule.ts @@ -76,7 +76,7 @@ export const Plugin = { editor.add({ name: "schedule_create", description: - "Schedule a prompt that wakes this session later: once after a delay, at a time, every interval, on a cron, or gated by an `if` / `until` shell check. Returns the schedule with its next run (or `done` immediately if `until` already matches).", + "Schedule a prompt that wakes this session later: once after a delay, at a time, every interval, on a cron, or gated by an `if` / `until` shell (`{ run }`) or Code Mode (`{ code }`) check. Returns the schedule with its next run (or `done` immediately if `until` already matches).", input: Schedule.Request, output: View, options: { namespace: "opencode", codemode: true }, @@ -174,6 +174,13 @@ function formatCondition(condition: Schedule.Condition): string { switch (condition.type) { case "shell": return JSON.stringify(condition.run) + case "code": { + const first = condition.code + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) + return `code ${JSON.stringify(first ? first.slice(0, 60) : "")}` + } } } diff --git a/packages/core/test/schedule.test.ts b/packages/core/test/schedule.test.ts index 7314928036b9..a4454f98ef2f 100644 --- a/packages/core/test/schedule.test.ts +++ b/packages/core/test/schedule.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { FileSystem } from "@opencode/core/filesystem" -import { DateTime, Effect, Fiber, Latch, Layer, LayerMap, Queue, Sink, Stream } from "effect" +import { DateTime, Effect, Fiber, Latch, Layer, LayerMap, Queue, Schema, Sink, Stream } from "effect" import { ChildProcessSpawner } from "effect/process" import { ExitCode, makeHandle, ProcessId } from "effect/process/ChildProcessSpawner" import { TestClock } from "effect/testing" @@ -10,6 +10,7 @@ import { Database } from "@opencode/core/database/database" import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" import { Environment } from "@opencode/core/environment/index" import { FileAccess } from "@opencode/core/file-access" +import { Image } from "@opencode/core/image" import { Instance } from "@opencode/core/instance/service" import { LayerNode } from "@opencode/util/effect/layer-node" import { makeGlobalNode } from "@opencode/util/effect/app-node" @@ -30,6 +31,8 @@ import { SessionInbox } from "@opencode/core/session/inbox" import { SessionInboxTable, SessionTable } from "@opencode/core/session/sql" import { SessionStore } from "@opencode/core/session/store" import { ShellSelect } from "@opencode/core/shell/select" +import { Tool } from "@opencode/core/tool" +import { McpTool } from "@opencode/core/tool/mcp" import { LocationServiceMap } from "@opencode/core/location-service-map" import type { LocationServices } from "@opencode/core/location-services" import { Plugin } from "@opencode/core/plugin" @@ -41,6 +44,7 @@ import { Document, Info } from "@opencode/schema/config" import { ConfigExperimental } from "@opencode/schema/config/experimental" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" +import { imagePassthrough } from "./lib/image" const enabledDirectory = "/enabled" const disabledDirectory = "/disabled" @@ -99,6 +103,7 @@ const shellState = { runs: [] as string[], missingNow: false, } +const slack = { replies: 0, reads: 0, exports: 0, failing: false } let agentRules: Permission.Ruleset = [{ action: "*", resource: "*", effect: "allow" }] let activationGate: Effect.Effect = Effect.void @@ -138,11 +143,61 @@ const testEnvironment = Layer.effect( const configureAgent = Layer.effectDiscard( Effect.gen(function* () { const agents = yield* Agent.Service + const tools = yield* Tool.Service + const permission = yield* Permission.Service yield* agents.transform((editor) => editor.update(Agent.defaultID, (agent) => { agent.permissions = [...agentRules] }), ) + yield* tools.transform((editor) => { + editor.add({ + name: "read_thread", + description: "Read the thread", + input: Schema.Struct({}), + output: Schema.Struct({ replies: Schema.Number }), + options: { namespace: "slack" }, + execute: (_input, context) => + permission + .assert({ + action: "slack_read_thread", + resources: ["*"], + sessionID: context.sessionID, + agent: context.agent, + }) + .pipe( + Effect.orDie, + Effect.flatMap(() => { + if (slack.failing) return Effect.fail(new Tool.Error({ message: "Slack is down" })) + slack.reads++ + return Effect.succeed({ output: { replies: slack.replies } }) + }), + ), + }) + editor.add({ + name: "export_thread", + description: "Export the thread to a file", + input: Schema.Struct({}), + options: { namespace: "slack" }, + execute: (_input, context) => + permission + .assert({ action: "file_write", resources: ["*"], sessionID: context.sessionID, agent: context.agent }) + .pipe( + Effect.orDie, + Effect.map(() => { + slack.exports++ + return { content: "exported" } + }), + ), + }) + editor.add({ + name: "dangerous_edit", + description: "Mutates files", + input: Schema.Struct({}), + options: { namespace: "slack", permission: "edit" }, + execute: () => Effect.succeed({ content: "edited" }), + }) + }) }), ) @@ -178,6 +233,7 @@ const locations = makeGlobalNode({ Environment.node, PluginHooks.node, Skill.node, + Tool.node, Agent.node, Permission.node, FileAccess.node, @@ -189,11 +245,13 @@ const locations = makeGlobalNode({ PermissionSaved.node.replace(Layer.succeed(PermissionSaved.Service, saved)), Location.node.replace(Layer.succeed(Location.Service, location(ref))), Environment.node.replace(testEnvironment), + Image.node.replace(imagePassthrough), ], }, ), ), ), + Layer.mock(McpTool.Service, { flush: Effect.void }), Layer.succeed( ShellSelect.Service, ShellSelect.Service.of({ @@ -259,6 +317,10 @@ const setup = (directory = enabledDirectory) => shellState.hangMs = 0 shellState.runs = [] shellState.missingNow = false + slack.replies = 0 + slack.reads = 0 + slack.exports = 0 + slack.failing = false agentRules = [{ action: "*", resource: "*", effect: "allow" }] activationGate = Effect.void return yield* Schedule.Service @@ -1159,4 +1221,203 @@ describe("Schedule", () => { }), ) }) + + describe("code checks", () => { + const watchThread = ` + const thread = await tools.slack.read_thread({}) + if (state === null || thread.replies <= state.replies) { + return { wake: false, state: { replies: thread.replies, runs } } + } + return { + wake: true, + state: { replies: thread.replies, runs }, + message: thread.replies + " replies", + } + ` + + it.effect("seeds initial state at t=0 so replies before the first tick are caught, and counts ends.times by wakes", () => + Effect.gen(function* () { + const schedules = yield* setup() + slack.replies = 3 + const schedule = yield* schedules.create({ + sessionID, + name: "thread", + when: { type: "every", every: "2 minutes" }, + if: { type: "code", code: watchThread }, + ends: { times: 1 }, + action: { type: "prompt", prompt: "New thread activity." }, + }) + // Seeded at creation (t=0): 1 read, baseline state = { replies: 3, runs: 0 }. + expect(slack.reads).toBe(1) + + // Quiet tick at +2m (still 3 replies): remainingRuns stays 1! + yield* TestClock.adjust("2 minutes") + yield* schedules.tick + expect(yield* pending).toEqual([]) + expect(yield* schedules.get(schedule.id)).toMatchObject({ + status: "active", + runCount: 0, + remainingRuns: 1, + lastOutcome: "checked", + }) + + // Reply arrives -> wakes once and stops because ends.times = 1. + slack.replies = 4 + yield* TestClock.adjust("2 minutes") + yield* schedules.tick + expect(yield* pending).toEqual([messageID(schedule, 1)]) + const { db } = yield* Database.Service + expect(yield* SessionInbox.find(db, messageID(schedule, 1))).toMatchObject({ + payload: { + text: "New thread activity.\n\n4 replies", + metadata: { schedule: { id: schedule.id, run: 1, check: { message: "4 replies" } } }, + }, + }) + expect(yield* schedules.get(schedule.id)).toMatchObject({ + status: "done", + runCount: 1, + remainingRuns: 0, + lastOutcome: "fired", + }) + }), + ) + + it.effect("supports boolean and state-free returns for until checks, including immediate t=0 match", () => + Effect.gen(function* () { + const schedules = yield* setup() + slack.replies = 5 + const immediate = yield* schedules.create({ + sessionID, + until: { + type: "code", + code: `const t = await tools.slack.read_thread({}); return { wake: t.replies >= 5, message: "already 5" }`, + }, + action: { type: "prompt", prompt: "Done." }, + }) + expect(immediate).toMatchObject({ + status: "done", + lastOutcome: "fired", + lastOutput: "already 5", + }) + expect(yield* pending).toEqual([]) + + slack.replies = 1 + const waiting = yield* schedules.create({ + sessionID, + until: { + type: "code", + code: `return (await tools.slack.read_thread({})).replies >= 2 && typeof fetch === "undefined"`, + }, + action: { type: "prompt", prompt: "Reached 2." }, + }) + expect(waiting.status).toBe("active") + + slack.replies = 2 + yield* TestClock.adjust("30 seconds") + yield* schedules.tick + expect(yield* pending).toEqual([messageID(waiting, 1)]) + expect(yield* schedules.get(waiting.id)).toMatchObject({ status: "done", lastOutcome: "fired" }) + }), + ) + + it.effect("extracts tools statically, asks once without save, and denies unapproved side permissions", () => + Effect.gen(function* () { + const schedules = yield* setup() + agentRules = [{ action: "*", resource: "*", effect: "ask" }] + const bus = yield* Bus.Service + const instances = yield* Instance.Service + const sessions = yield* Session.Service + const session = yield* sessions.get(sessionID) + const asks = yield* Queue.unbounded() + const unsubscribe = yield* bus.listen((event) => + event.type === Permission.Event.Asked.type + ? Queue.offer(asks, event.data as Permission.Request).pipe(Effect.asVoid) + : Effect.void, + ) + yield* Effect.addFinalizer(() => unsubscribe) + const asked: Permission.Request[] = [] + const answerLoop = yield* Queue.take(asks).pipe( + Effect.tap((request) => Effect.sync(() => asked.push(request))), + Effect.flatMap((request) => + Permission.Service.use((permission) => permission.reply({ requestID: request.id, reply: "once" })).pipe( + instances.provide(session), + ), + ), + Effect.forever, + Effect.forkChild, + ) + + const schedule = yield* schedules.create({ + sessionID, + name: "thread", + when: { type: "every", every: "1 minute" }, + if: { type: "code", code: watchThread }, + action: { type: "prompt", prompt: "Changed." }, + }) + yield* Fiber.interrupt(answerLoop) + expect(asked).toHaveLength(1) + expect(asked[0]).toMatchObject({ + action: "slack_read_thread", + metadata: { schedule: "thread", mode: "if", tools: ["slack.read_thread"] }, + }) + expect(asked[0]?.save).toBeUndefined() + + // Subsequent unattended ticks run without asking. + yield* TestClock.adjust("1 minute") + yield* schedules.tick + expect(asked).toHaveLength(1) + expect(yield* schedules.get(schedule.id)).toMatchObject({ lastOutcome: "checked" }) + + // A tool requesting a second permission (`file_write`) that was not authorized fails at create/seed. + const answerExport = yield* Queue.take(asks).pipe( + Effect.flatMap((request) => + Permission.Service.use((permission) => permission.reply({ requestID: request.id, reply: "once" })).pipe( + instances.provide(session), + ), + ), + Effect.forever, + Effect.forkChild, + ) + const exportFailure = yield* Effect.flip( + schedules.create({ + sessionID, + until: { type: "code", code: `await tools.slack.export_thread({}); return true` }, + action: { type: "prompt", prompt: "Exported." }, + }), + ) + yield* Fiber.interrupt(answerExport) + expect(slack.exports).toBe(0) + expect(exportFailure).toMatchObject({ _tag: "Schedule.InvalidError" }) + }), + ) + + it.effect("rejects dynamic tool access, unknown tools, edit tools, and recursive schedule tools", () => + Effect.gen(function* () { + const schedules = yield* setup() + const bad = (code: string) => + Effect.flip( + schedules.create({ + sessionID, + until: { type: "code", code }, + action: { type: "prompt", prompt: "x" }, + }), + ) + + expect(yield* bad("const x = {")).toMatchObject({ _tag: "Schedule.InvalidError" }) + expect(yield* bad("return await tools.slack[name]({})")).toMatchObject({ _tag: "Schedule.InvalidError" }) + expect(yield* bad("return await tools.slack.missing({})")).toMatchObject({ + reason: "Unknown Code Mode tool: slack.missing", + }) + expect(yield* bad("return await tools.slack.dangerous_edit({})")).toMatchObject({ + reason: "Tool slack.dangerous_edit cannot be used inside a schedule check", + }) + expect(yield* bad("return await tools.opencode.schedule_create({})")).toMatchObject({ + reason: "Tool opencode.schedule_create cannot be used inside a schedule check", + }) + + agentRules = [{ action: "execute", resource: "*", effect: "deny" }] + expect(yield* bad("return true")).toMatchObject({ _tag: "Permission.BlockedError" }) + }), + ) + }) }) diff --git a/packages/core/test/tool-schedule.test.ts b/packages/core/test/tool-schedule.test.ts index d76825761c22..cf62a2584890 100644 --- a/packages/core/test/tool-schedule.test.ts +++ b/packages/core/test/tool-schedule.test.ts @@ -287,6 +287,18 @@ describe("ScheduleTools", () => { prompt: "working tree changed", }), ).toContain('if "git status -s", active') + expect( + yield* direct(registry, "schedule_create", { + until: { code: "return (await tools.slack.read_thread({})).ok" }, + prompt: "thread ok", + }), + ).toContain('until code "return (await tools.slack.read_thread({})).ok", active') + expect( + yield* call(registry, "schedule_create", { + until: { run: "true", code: "return true" }, + prompt: "both", + }), + ).toMatchObject({ metadata: { error: true } }) }), ) }) diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 7b32cff5f78d..cc02d4f456e1 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -21190,9 +21190,26 @@ "anyOf": [ { "$ref": "#/components/schemas/Schedule.Condition.Shell" + }, + { + "$ref": "#/components/schemas/Schedule.Condition.Code" } ] }, + "Schedule.Condition.Code": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["code"] + }, + "code": { + "type": "string" + } + }, + "required": ["type", "code"], + "additionalProperties": false + }, "Schedule.Condition.Shell": { "type": "object", "properties": { @@ -21217,25 +21234,56 @@ "additionalProperties": false }, "Schedule.ConditionInput": { - "type": "object", - "properties": { - "run": { - "type": "string", - "minLength": 1, - "description": "Shell command to run in the Session's directory. Alone, exit 0 wakes and stdout is appended." - }, - "is": { - "type": "string" - }, - "matches": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "run": { + "type": "string", + "minLength": 1, + "description": "Shell command to run in the Session's directory. Alone, exit 0 wakes and stdout is appended." + }, + "is": { + "type": "string" + }, + "matches": { + "type": "string" + }, + "changes": { + "type": "boolean" + }, + "code": { + "not": {} + } + }, + "required": ["run"], + "additionalProperties": false }, - "changes": { - "type": "boolean" + { + "type": "object", + "properties": { + "code": { + "type": "string", + "minLength": 1, + "description": "Code Mode JavaScript program. Static tools.(...) calls are authorized at creation; gets state, now and runs, and returns a boolean or { wake, state?, message? }." + }, + "run": { + "not": {} + }, + "is": { + "not": {} + }, + "matches": { + "not": {} + }, + "changes": { + "not": {} + } + }, + "required": ["code"], + "additionalProperties": false } - }, - "required": ["run"], - "additionalProperties": false + ] }, "Schedule.CreatePayload": { "type": "object", diff --git a/packages/schema/src/schedule.ts b/packages/schema/src/schedule.ts index dfbd27b3d110..4525862b0092 100644 --- a/packages/schema/src/schedule.ts +++ b/packages/schema/src/schedule.ts @@ -84,8 +84,19 @@ export const ShellCondition = Schema.Struct({ }).annotate({ identifier: "Schedule.Condition.Shell" }) export type ShellCondition = typeof ShellCondition.Type +/** + * Runs `code` in a confined Code Mode runtime before firing, with no model call and no `fetch`. Tools called statically + * as `tools.(...)` are extracted from the AST and authorized at creation; the program receives `state` (initially + * `null`), `now` (epoch ms) and `runs` (completed checks), and returns a boolean or `{ wake, state?, message? }`. + */ +export const CodeCondition = Schema.Struct({ + type: Schema.tag("code"), + code: Schema.String, +}).annotate({ identifier: "Schedule.Condition.Code" }) +export type CodeCondition = typeof CodeCondition.Type + /** Checked before firing; the schedule wakes the Session only when the condition passes. */ -export const Condition = Schema.Union([ShellCondition]).pipe( +export const Condition = Schema.Union([ShellCondition, CodeCondition]).pipe( Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Schedule.Condition" }), ) @@ -148,7 +159,7 @@ export const CreateInput = Schema.Struct({ }).annotate({ identifier: "Schedule.CreateInput" }) export type CreateInput = typeof CreateInput.Type -/** Declares a trigger absent, so `{ in, every }` fails instead of decoding as a one-shot. */ +/** Declares a variant field absent, so `{ in, every }` or `{ run, code }` fails instead of silently dropping one. */ const absent = Schema.optionalKey(Schema.Never) /** The `when` callers write: exactly one of `in`, `at`, `every` or `cron`, with an optional `tz`. */ @@ -173,9 +184,26 @@ export const ShellConditionInput = Schema.Struct({ changes: Schema.Boolean.pipe(optional).annotate({ description: "Wake when exit is 0 and stdout differs from the previous check.", }), -}).annotate({ identifier: "Schedule.ConditionInput" }) -export type ConditionInput = typeof ShellConditionInput.Type -export const ConditionInput = ShellConditionInput +}).annotate({ identifier: "Schedule.ShellConditionInput" }) + +export const CodeConditionInput = Schema.Struct({ + code: Schema.String.check(Schema.isMinLength(1)).annotate({ + description: + "Code Mode JavaScript program. Static tools.(...) calls are authorized at creation; gets state, now and runs, and returns a boolean or { wake, state?, message? }.", + }), +}).annotate({ identifier: "Schedule.CodeConditionInput" }) + +export const ConditionInput = Schema.Union([ + Schema.Struct({ ...ShellConditionInput.fields, code: absent }), + Schema.Struct({ + ...CodeConditionInput.fields, + run: absent, + is: absent, + matches: absent, + changes: absent, + }), +]).annotate({ identifier: "Schedule.ConditionInput" }) +export type ConditionInput = typeof ConditionInput.Type /** A schedule as callers describe it: when it fires, optional condition, when it stops, and the prompt it sends. */ export const Request = Schema.Struct({ @@ -185,11 +213,12 @@ export const Request = Schema.Struct({ 'When it fires: { in: "20 minutes" }, { at: "2026-10-27T09:00" }, { every: "1 hour" } or { cron: "0 9 * * 1-5" }. Optional with `until` (defaults to { every: "30 seconds" }).', }), if: ConditionInput.pipe(optional).annotate({ - description: "Recurring gate: check on each slot with no model call, and wake on every slot where it passes.", + description: + "Recurring gate ({ run } shell command or { code } Code Mode script): check on each slot with no model call, and wake on every slot where it passes.", }), until: ConditionInput.pipe(optional).annotate({ description: - 'One-shot gate: poll with no model call, wake once when it passes, and stop. Defaults ends.after to "24 hours".', + 'One-shot gate ({ run } shell command or { code } Code Mode script): poll with no model call, wake once when it passes, and stop. Defaults ends.after to "24 hours".', }), ends: Ends.pipe(optional).annotate({ description: 'Stops a repeating schedule at the first limit reached: { after: "8 hours" }, { at }, { times: 3 }.', @@ -210,13 +239,16 @@ export const toCreateInput = (sessionID: SessionID, request: Request): CreateInp action: { type: "prompt", prompt: request.prompt }, }) -const toCondition = (condition: ConditionInput): Condition => ({ - type: "shell", - run: condition.run, - is: condition.is, - matches: condition.matches, - changes: condition.changes, -}) +const toCondition = (condition: ConditionInput): Condition => { + if (condition.code !== undefined) return { type: "code", code: condition.code } + return { + type: "shell", + run: condition.run, + is: condition.is, + matches: condition.matches, + changes: condition.changes, + } +} const toWhen = (when: WhenInput): When => { if (when.in !== undefined) return { type: "in", in: when.in, tz: when.tz } diff --git a/packages/server/test/schedule.test.ts b/packages/server/test/schedule.test.ts index fa1b97db5ded..afc7b697473b 100644 --- a/packages/server/test/schedule.test.ts +++ b/packages/server/test/schedule.test.ts @@ -120,6 +120,25 @@ it.live("manages schedules through the experimental API where the session's conf prompt: "never", }), ).rejects.toMatchObject({ _tag: "ForbiddenError" }) + + // Code Mode until check works end-to-end with no allow field, and { run, code } together is 400. + const codeMatched = await api.experimental.schedule.create({ + sessionID: session.id, + until: { code: 'return { wake: true, message: "models=" + (await tools.opencode.models({})).total }' }, + prompt: "models checked", + }) + expect(codeMatched).toMatchObject({ + status: "done", + lastOutcome: "fired", + lastOutput: expect.stringContaining("models="), + until: { type: "code" }, + }) + const mixed = await handlerRequest("/api/experimental/schedule", { + sessionID: session.id, + until: { run: "true", code: "return true" }, + prompt: "ping", + }) + expect(mixed.status).toBe(400) }) }).pipe(Effect.scoped), )