diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 19276c41e7b6..43c6dd15ae82 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -75,12 +75,19 @@ export const STATIC_KEYBINDING_COMMANDS = [ ...THREAD_KEYBINDING_COMMANDS, ] as const; +/** + * Identifier portion of a script run command (`script..run`). The web + * client rebuilds that command during keybinding wiring, so any id persisted + * by the server must satisfy this constraint or thread views throw. + */ +export const ProjectScriptId = Schema.NonEmptyString.check( + Schema.isMaxLength(MAX_SCRIPT_ID_LENGTH), + Schema.isPattern(/^[a-z0-9][a-z0-9-]*$/), +); + export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([ Schema.Literal("script."), - Schema.NonEmptyString.check( - Schema.isMaxLength(MAX_SCRIPT_ID_LENGTH), - Schema.isPattern(/^[a-z0-9][a-z0-9-]*$/), - ), + ProjectScriptId, Schema.Literal(".run"), ]); diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 3e1b9be0bba5..db66185dc265 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -221,6 +221,49 @@ it.effect("rejects command fields that become empty after trim", () => }), ); +it.effect("rejects project scripts whose ids cannot form keybinding commands", () => + Effect.gen(function* () { + // The web client rebuilds `script..run` keybinding commands while + // rendering; an id outside the pattern's constraint crashes thread + // views, so the server must refuse to persist it. + const result = yield* Effect.exit( + decodeProjectMetaUpdatedPayload({ + projectId: "project-1", + updatedAt: "2026-01-01T00:00:00.000Z", + scripts: [ + { + id: "123e4567-e89b-42d3-a456-426614174000", + name: "Setup", + command: "echo hi", + icon: "configure", + runOnWorktreeCreate: false, + }, + ], + }), + ); + assert.strictEqual(result._tag, "Failure"); + }), +); + +it.effect("accepts project scripts with conforming ids", () => + Effect.gen(function* () { + const parsed = yield* decodeProjectMetaUpdatedPayload({ + projectId: "project-1", + updatedAt: "2026-01-01T00:00:00.000Z", + scripts: [ + { + id: "run-setup", + name: "Setup", + command: "echo hi", + icon: "configure", + runOnWorktreeCreate: false, + }, + ], + }); + assert.strictEqual(parsed.scripts?.[0]?.id, "run-setup"); + }), +); + it.effect("decodes thread.turn.start defaults for provider and runtime mode", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index adb17879ff2f..eee8cb0babb1 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -23,6 +23,7 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { ProjectScriptId } from "./keybindings.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -207,7 +208,12 @@ export const ProjectScriptIcon = Schema.Literals([ export type ProjectScriptIcon = typeof ProjectScriptIcon.Type; export const ProjectScript = Schema.Struct({ - id: TrimmedNonEmptyString, + /** + * Must satisfy the script-id constraint from the keybinding command + * pattern (`script..run`); the web client rebuilds that command while + * rendering, so a non-conforming id persisted here crashes thread views. + */ + id: ProjectScriptId, name: TrimmedNonEmptyString, command: TrimmedNonEmptyString, icon: ProjectScriptIcon,