← reports

#1649 · automation --script-file copies the script at register, so edits silently do nothing

Bug Medium Effort: Small automations open on GitHub 2026-08-18 base 16ceb3a540f81c1189efaffb27a39b1d9443abf5 (main)

Verdict: REPRODUCED · root-cause confidence: high · linked open PRs: none

TL;DR

Plain-language framing. An automation is a scheduled job bb runs for a project; a script automation runs a shell/node/python file instead of an AI agent. bb automation create … --script-file <path> lets you register such a job from a file on disk. The automations feature is a builtin plugin whose CLI code runs inside the bb server (the bb binary just forwards argv over HTTP).

The reporter is right, and the behaviour is deliberate rather than accidental: --script-file is read once, at registration, and the bytes are written to a private copy under <data dir>/plugins/automations/scripts/<automation id>/<basename>. Only that basename is persisted in the automation row; the original path is thrown away. Every run executes the private copy, so editing the source file has no effect until bb automation update <id> --script-file <path> re-uploads it. Nothing in the CLI output, bb automation show, the skill docs, or the guide says any of this. I reproduced it end to end on a dev instance (run #1 prints VERSION 1, edit the source to VERSION 2, run #2 still prints VERSION 1) and with a unit test at the exact code path.

Two adjacent findings surfaced while tracing the path: (a) since the automations rewrite in #516 moved this CLI server-side, the file is read on the server's filesystem, so a relative path such as the documented --script-file ./watch.sh fails with ENOENT because the plugin ignores the forwarded ctx.cwd; and (b) because the read already happens on the server, the "read the file at run time" fix the reporter asks for has exactly the same reachability constraints as today's copy — it is feasible.

Claims vs findings

ClaimStatusEvidence
--script-file copies the script at registration into ~/.bb/plugins/automations/scripts/<automation-id>/Verifiedplugins/automations/src/cli.ts#L365-L373 reads the file into script; plugins/automations/src/service.ts#L150-L172 + plugins/automations/src/script-files.ts#L76-L104 write it to <pluginDataDir>/scripts/<id>/<basename>. Repro shows the copy at …/plugins/automations/scripts/auto_wh1ksyr12u4/hello.sh.
Editing the original afterwards changes nothingVerifiedRun #2 after editing the source still prints VERSION 1; the copy is byte-identical to before. Runs execute the copy via plugins/automations/src/script-runner.ts#L308-L324. Unit test below.
Until bb automation update is run againVerifiedupdate … --script-file /tmp/1649-src/hello.sh then run → VERSION 2 (1649-after-update.out). Note: only an update that passes --script-file/--script re-reads; the source path is not stored, so a bare update cannot.
There is no warning, and the copy path is never shownVerifiedprintAutomation (plugins/automations/src/cli.ts#L479-L495) prints no script info; show --json returns the copy's content and strips scriptFile (plugins/automations/src/service.ts#L196-L220). Skill doc says only "Read script content from a local file" (plugins/automations/skills/automations/SKILL.md#L77); guide and bb-cli skill say nothing about copying.
All 13 automations on the reporter's machine were rewritten as exec loadersUnverifiableReporter's environment; consistent with the mechanism (a loader is the only way to get run-time reads today).
Fix option 1: watch the source file and re-copy on changeFeasible but not the right shapePath is not persisted today, and the file must be visible from the server; see Proposed fix.
Fix option 2: update re-reads the source path by defaultNot possible without a data-model changePersisted execution is {"mode":"script","scriptFile":"hello.sh",…} (basename only, sqlite dump below); the absolute path is gone.
Fix option 3: print the copy path loudly on registerTrivially doableThe plugin already knows writtenScriptFile and pluginDataDir at create/update time (service.ts L512/L587).

Environment

Minimal reproduction

  1. Build and start a dev instance: pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build && scripts/bb-dev-app current. Note the printed Data dir.
  2. Create a project (see Appendix for the curl). Export BB_REPO=<your worktree> and PROJECT=<project id>.
  3. Run 1649/repro/1649-repro.sh. It writes /tmp/1649-src/hello.sh printing VERSION 1, registers it with --script-file, runs it, edits the source to VERSION 2, runs again, and dumps the stored copy and show output.

Expected (reporter, and what the flag name implies): run #2 prints VERSION 2, or at least the tool tells you where the copy lives. Actual (1649-repro.out): run #2 prints VERSION 1; the copy is unchanged; neither create nor show mentions a path:

$ BB_REPO=/home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-15 PROJECT=proj_tvria3vnj2 /tmp/bb-reports/issues/1649/repro/1649-repro.sh
--- source script (/tmp/1649-src/hello.sh):
#!/bin/sh
echo "VERSION 1"
--- register with --script-file (note: output never mentions where the script is stored)
Automation created: auto_wh1ksyr12u4

  ID:        auto_wh1ksyr12u4
  Name:      issue-1649
  Enabled:   yes
  Mode:      script
  Schedule:  0 0 1 1 * (UTC)
  Next run:  1/1/2027, 12:00:00 AM
  Last run:  -
  Runs:      0
  Origin:    human
--- copy stored under the plugin data dir:
/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-15-fadb3f38adb4/plugins/automations/scripts/auto_wh1ksyr12u4/hello.sh:
#!/bin/sh
echo "VERSION 1"
--- run #1
run1 output:
VERSION 1

--- edit the SOURCE file (what the reporter did)
#!/bin/sh
echo "VERSION 2"
--- run #2 (expected: VERSION 2 if edits were honoured)
run2 output:
VERSION 1

--- stored copy is unchanged:
/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-15-fadb3f38adb4/plugins/automations/scripts/auto_wh1ksyr12u4/hello.sh:
#!/bin/sh
echo "VERSION 1"
--- 'show' does not reveal a source path or the stored copy path either:

  ID:        auto_wh1ksyr12u4
  Name:      issue-1649
  Enabled:   yes
  Mode:      script
  Schedule:  0 0 1 1 * (UTC)
  Next run:  1/1/2027, 12:00:00 AM
  Last run:  -
  Runs:      0
  Origin:    human

--- show --json execution:
{
  "mode": "script",
  "interpreter": "bash",
  "timeoutMs": 120000,
  "script": "#!/bin/sh\necho \"VERSION 1\"\n"
}

Persisted row (plugin SQLite, <Data dir>/plugins/automations/data.db): the source path is not stored, only the basename.

$ sqlite3 <Data dir>/plugins/automations/data.db "select id, execution from automations where id='auto_wh1ksyr12u4'"
auto_wh1ksyr12u4|{"mode":"script","scriptFile":"hello.sh","interpreter":"bash","timeoutMs":120000}

Confirming the reporter's remedy: bb automation update auto_wh1ksyr12u4 --project proj_tvria3vnj2 --script-file /tmp/1649-src/hello.sh, then run, then runs --output <latest> (1649-after-update.out):

VERSION 2

Adjacent: the documented relative form fails

The bb-cli skill tells agents to use --script-file ./watch.sh (apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md#L565). Because the plugin CLI runs inside the server and does not resolve the path against the forwarded ctx.cwd, that fails (1649-relative-path.sh, output):

$ cd /tmp/1649-src && bb automation create --project proj_tvria3vnj2 --name rel-test --cron "0 0 1 1 *" --timezone UTC --script-file ./rel.sh
ENOENT: no such file or directory, open './rel.sh'
exit=1

Unit-level repro at the exact code path

File: 1649/repro/issue-1649-script-file.test.ts (copy to plugins/automations/src/, run pnpm exec vitest run src/issue-1649-script-file.test.ts from plugins/automations). It calls service.create with exactly what cli.ts buildExecution sends (content + path), then edits the source and runs executeStoredScript. It passes on main, i.e. it characterises the current behaviour: the persisted execution is {mode:"script", scriptFile:"hello.sh"}, the run prints VERSION 1 after the source says VERSION 2, and get returns content without any path. Output: 1649-vitest.out.

// Repro for get-bb/bb#1649: `--script-file` is copied at registration; edits to
// the source path afterwards do not reach the automation.
//
// The CLI (cli.ts buildExecution) reads the file into `script` and forwards the
// path as `scriptFile`; the service (service.ts resolveStoredExecution ->
// script-files.ts writeInlineAutomationScript) writes the content under
// <pluginDataDir>/scripts/<automationId>/<basename> and persists ONLY that
// basename. The absolute source path is never stored, and runs execute the copy.
//
// The last `expect` in each test is the one that documents the reported
// behaviour: it PASSES on main (the behaviour is deliberate), so this file is a
// characterisation test, not a red test.
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import Database from "better-sqlite3";
import { describe, expect, it } from "vitest";
import { getAutomation, migrations, type Db } from "./data.js";
import { createAutomationService } from "./service.js";
import { automationScriptDir } from "./script-files.js";
import { executeStoredScript } from "./script-runner.js";

function createTestDb(): Db {
  const db = new Database(":memory:");
  for (const migration of migrations) db.exec(migration);
  return db;
}

function bb() {
  return {
    sdk: {
      projects: {
        get: async ({ projectId }: { projectId: string }) => ({
          id: projectId,
          kind: "standard" as const,
          name: "Test Project",
          gitRemoteUrl: null,
          createdAt: 1,
          updatedAt: 1,
          sources: [],
        }),
        list: async () => [],
      },
      providers: { list: async () => [] as never },
      threads: {
        get: async () => {
          throw new Error("not expected");
        },
        send: async () => {
          throw new Error("not expected");
        },
        spawn: async () => {
          throw new Error("not expected");
        },
      },
    },
    realtime: { publish: () => undefined },
    log: {
      debug: () => undefined,
      error: () => undefined,
      info: () => undefined,
      warn: () => undefined,
    },
  };
}

describe("issue #1649: --script-file copy semantics", () => {
  it("stores a copy keyed by basename, forgets the source path, and runs the copy after the source changes", async () => {
    const db = createTestDb();
    const pluginDataDir = await mkdtemp(join(tmpdir(), "bb-1649-data-"));
    const srcDir = await mkdtemp(join(tmpdir(), "bb-1649-src-"));
    const sourcePath = join(srcDir, "hello.sh");
    await writeFile(sourcePath, '#!/bin/sh\necho "VERSION 1"\n');
    const service = createAutomationService({
      bb: bb(),
      db,
      pluginDataDir,
      serverUrl: "http://127.0.0.1:1",
    });
    try {
      // Exactly what `bb automation create --script-file <sourcePath>` sends
      // (cli.ts buildExecution): the file CONTENT plus the path.
      const created = await service.create({
        projectId: "proj_test",
        name: "issue-1649",
        enabled: true,
        trigger: { triggerType: "once", runAt: Date.now() + 60_000 },
        execution: {
          mode: "script",
          script: await readFile(sourcePath, "utf8"),
          scriptFile: sourcePath,
          timeoutMs: 120_000,
        },
        origin: "human",
      });

      // 1. Persisted execution keeps only the basename; the source path is gone.
      const row = getAutomation(db, created.id);
      expect(JSON.parse(String(row?.execution))).toEqual({
        mode: "script",
        scriptFile: "hello.sh",
        timeoutMs: 120_000,
      });
      const copyPath = join(
        automationScriptDir(pluginDataDir, created.id),
        "hello.sh",
      );
      await expect(readFile(copyPath, "utf8")).resolves.toContain("VERSION 1");

      // 2. Reporter's step: edit the source file, then run.
      await writeFile(sourcePath, '#!/bin/sh\necho "VERSION 2"\n');
      const result = await executeStoredScript({
        pluginDataDir,
        automationId: created.id,
        runId: "arun_test",
        projectId: "proj_test",
        scriptFile: "hello.sh",
        timeoutMs: 30_000,
        serverUrl: "http://127.0.0.1:1",
      });

      // Expected by the reporter: VERSION 2. Actual on main: VERSION 1, because
      // the run executes <pluginDataDir>/scripts/<id>/hello.sh, never sourcePath.
      expect(result.output).toContain("VERSION 1");
      expect(result.output).not.toContain("VERSION 2");

      // 3. `show` returns the copy's content, without either path.
      const shown = await service.get({
        projectId: "proj_test",
        automationId: created.id,
      });
      expect(shown.execution).toEqual({
        mode: "script",
        script: '#!/bin/sh\necho "VERSION 1"\n',
        timeoutMs: 120_000,
      });
    } finally {
      await rm(pluginDataDir, { recursive: true, force: true });
      await rm(srcDir, { recursive: true, force: true });
    }
  });
});

Root cause

Mechanism (deliberate copy semantics, undocumented and unsurfaced).

  1. plugins/automations/src/cli.ts#L359-L376: buildExecution does const content = scriptFile ? await readFile(scriptFile, "utf8") : script; and returns { mode:"script", script: content, scriptFile }. The path is only used for two things: inferring the interpreter from the extension and naming the copy.
  2. plugins/automations/src/service.ts#L150-L172: resolveStoredExecution sees script !== undefined and calls writeInlineAutomationScript, then drops script and replaces scriptFile with the stored basename ({ ...rest, scriptFile }).
  3. plugins/automations/src/script-files.ts#L42-L45 sanitizeScriptFileName takes basename(name); plugins/automations/src/script-files.ts#L76-L104 writes the content atomically to <pluginDataDir>/scripts/<automationId>/<basename>. From here on the automation has no memory of where the bytes came from — the request schema (plugins/automations/src/rpc-types.ts#L134-L151) has no source-path field at all.
  4. plugins/automations/src/script-runner.ts#L308-L324: executeStoredScript resolves scriptFile inside the automation's script dir (with containment checks) and spawns it. The source path is never consulted.
  5. Nothing surfaces the indirection: plugins/automations/src/cli.ts#L479-L495 printAutomation prints ID/name/mode/schedule only; plugins/automations/src/service.ts#L196-L220 toEditableAutomationResponse replaces scriptFile with the copy's content for show/UI; the docs describe the flag as "Read script content from a local file".

Why it exists. In #190 the automation CLI ran in the user's bb process, possibly on a different machine from the server, so uploading content inline was the only option (the code comment at the time: "The CLI uploads --script-file content inline, so the original filename … is lost server-side"). #516 moved the CLI into the plugin, which runs in the server (apps/cli/src/plugin-cli-proxy.ts#L8-L10: "plugin code only ever runs server-side"), and kept the copy model — which is also what lets the app UI display and the service round-trip a script as content. So the copy is intentional design that was never made visible; it is not a regression of some earlier read-at-run-time behaviour.

Adjacent defect. The proxy forwards cwd: process.cwd() (apps/cli/src/plugin-cli-proxy.ts#L399-L410) and the plugin contract exposes it as PluginCliContext.cwd (packages/plugin-sdk/src/backend-contract.ts#L253-L260), but plugins/automations/src/cli.ts never references ctx.cwd, so readFile("./rel.sh") resolves against the server process cwd → ENOENT. That was not the case in #190 (client-side readFile). It also means --script-file can only ever read files the server can see, which is relevant to the fix below.

Minor inconsistency noticed, not exercised. The zod request schema requires exactly one of script | scriptFile (plugins/automations/src/rpc-types.ts#L157-L177) yet the plugin CLI calls service.create directly with both set (the RPC path used by the app would reject that shape). Harmless today because the service tolerates it, but it shows scriptFile is overloaded as "stored basename" in one direction and "name hint" in the other.

Proposed fix (first principles)

Confident about the cause. Two tiers; the first is the minimum that would have prevented the confusion (reporter's option 3), the second gives the semantics the flag name implies (closer to #1767's first suggestion).

  1. Make the snapshot visible (small, safe).
    • plugins/automations/src/cli.ts create/update: when --script-file was used, print after the summary something like Script: copied <abs source> → <pluginDataDir>/scripts/<id>/<basename> (a snapshot; re-run 'bb automation update <id> --script-file <path>' after editing the source). The plugin has writtenScriptFile and pluginDataDir at hand (plugins/automations/src/service.ts#L505-L515); the simplest wiring is to add storedScriptPath to the script execution in AutomationResponse for script automations (populated in toEditableAutomationResponse) so show, show --json and the detail view can display it too, and printAutomation gains a Script: line.
    • Resolve --script-file against ctx.cwd (resolve(ctx.cwd ?? process.cwd(), scriptFile)) so the documented ./watch.sh form works, and print the absolute path that was read.
    • Docs: plugins/automations/skills/automations/SKILL.md L77, the guide template packages/templates/src/templates/bb-guide-automations.md, and the bb-cli skill L565: state that --script-file is read on the server at create/update time and stored as a copy.
  2. Optionally give --script-file live semantics. Add a persisted sourcePath (absolute, server-visible) to the script execution alongside the stored copy. At run time (run.tsexecuteStoredScript), if sourcePath is set: re-read it and refresh the copy (or execute it directly), and if it is missing/unreadable fail the run with a clear error rather than silently running the stale copy — mirroring what the reporter's hand-written loaders do. Keep --script as the inline/snapshot form. Because readFile already runs server-side, this adds no new filesystem reach the plugin does not have today; what changes is when the read happens (TOCTOU is the feature). Risks: an agent that authored an automation from a temp file would now break when the temp file disappears (hence: fail loudly, and default to snapshot when the source is under a temp dir, or make live mode opt-in via --watch-script-file). Also update the zod schema (rpc-types.ts) and the app detail view to display sourcePath. This is plugin-internal storage (its own SQLite + JSON column); no HOST_DAEMON_PROTOCOL_VERSION bump is involved.

Related issues

Appendix

Commands run

git checkout 16ceb3a54
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
scripts/bb-dev-app current              # app :11568, server :19568, daemon :27568
mkdir -p /tmp/1649-qa && git -C /tmp/1649-qa init
BB_SERVER_URL=http://localhost:19568 pnpm bb:dev machine list          # host_5tkuhhtgpc
curl -s -X POST http://localhost:19568/api/v1/projects -H 'content-type: application/json' \
  -d '{"name":"qa","source":{"type":"local_path","path":"/tmp/1649-qa","hostId":"host_5tkuhhtgpc"}}'   # proj_tvria3vnj2
BB_REPO=$PWD PROJECT=proj_tvria3vnj2 /tmp/bb-reports/issues/1649/repro/1649-repro.sh
sqlite3 <Data dir>/plugins/automations/data.db "select id, execution from automations"
bb automation update auto_wh1ksyr12u4 --project proj_tvria3vnj2 --script-file /tmp/1649-src/hello.sh; bb automation run …; bb automation runs … --output …
BB_REPO=$PWD PROJECT=proj_tvria3vnj2 /tmp/bb-reports/issues/1649/repro/1649-relative-path.sh
cd plugins/automations && pnpm exec vitest run src/issue-1649-script-file.test.ts
pnpm dev:stop

vitest output

 RUN  v4.1.1 /home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-15/plugins/automations


 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  07:33:21
   Duration  588ms (transform 90ms, setup 0ms, import 170ms, tests 309ms, environment 0ms)

Runs table (plugin SQLite)

$ sqlite3 <Data dir>/plugins/automations/data.db "select id,status,exit_code,started_at from automation_runs where automation_id='auto_wh1ksyr12u4' order by started_at"
arun_e239pg8eltm|succeeded|0|1787038242902
arun_m0xkk0gayj8|succeeded|0|1787038250124

Artifacts