#3616 · Automation execution directory

Bug · Priority: Medium · Effort: Medium · plugins · automations
2026-09-13 · base d89160eb8c69c1e3ebc2ba2514f1711af8d7c506 · Issue

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

The stored-script runner overrides the caller’s working directory with the plugin’s scripts root. A script referring to a file in a project directory therefore fails unless it explicitly selects that directory or uses an absolute path. The runner captures the missing-file diagnostic, but the outcome mapper puts only the exit code in the error summary. Two runs against unchanged trusted main reproduce this mechanism; neither uses the reporter’s code or branch.

2. Claims vs findings

ClaimFindingEvidence
Project-relative arguments fail because of cwdVerified at the production runner boundaryBoth probes print the plugin scripts directory and fail to read a project fixture.
Absolute path worksVerifiedControl run reads the same fixture and exits zero.
Summary hides captured causeVerifiedMissing-file text is in output; mapped error contains only code 1.
Reported Python exit code and desktop flowUnverified directlyThis reproduction uses bash and cat, with exit code 1, and calls the runner without a desktop instance.
No execution cwd documentationSupported in inspected runtime referenceThe reference explains upload path resolution and stored copies, but does not specify execution cwd.
A locally existing default-source path is sufficientNot establishedProject source identity includes a host ID. Directory existence on the server cannot establish source-host identity.

3. Environment

Trusted origin/main at the commit above; Darwin arm64; Node v22.22.3. Frozen dependency installation completed using Corepack. Full Turbo build passed in the first checkout (56 tasks). The initial bare pnpm launcher was broken; a temporary wrapper routing pnpm through Corepack resolved it. No provider, database, desktop, live server, user runtime data, or network-facing plugin instance was used. Each probe creates and removes its own temporary fixture directory; the unused server URL points to loopback port 1.

4. Minimal reproduction

  1. Use a clean checkout of the trusted base:
    git clone https://github.com/get-bb/bb.git bb-repro
    cd bb-repro
    git checkout --detach d89160eb8c69c1e3ebc2ba2514f1711af8d7c506
    corepack pnpm install --frozen-lockfile --prefer-offline
    corepack pnpm exec turbo run build
  2. Save the probe as plugins/automations/repro-3616.mts.
  3. Run from the repository root:
    corepack pnpm exec tsx plugins/automations/repro-3616.mts

The probe creates a project fixture, changes the caller cwd to that project, stores its own shell script through the production writer, and invokes the production executor and mapper. It supplies a harmless local CLI stub so no installed BB CLI is invoked. It verifies the observed cwd, captured diagnostic, summary, and successful absolute-path control before asserting the desired relative-path behavior.

Expected: the relative fixture is readable and exits zero. Actual, in both runs:

{
  "relative": {
    "exitCode": 1,
    "output": "<scratch>/plugin/scripts\ncat: fixture.txt: No such file or directory\n",
    "timedOut": false
  },
  "summary": "Script exited with code 1",
  "absolute": {
    "exitCode": 0,
    "output": "fixture found\n",
    "timedOut": false
  }
}
node:internal/modules/run_main:123
    triggerUncaughtException(
    ^

AssertionError [ERR_ASSERTION]: project-relative fixture should be readable

1 !== 0

    at <anonymous> (<checkout>/plugins/automations/repro-3616.mts:33:10)
    at process.processTicksAndRejections (node:internal/process/task_queues:103:5) {
  generatedMessage: false,
  code: 'ERR_ASSERTION',
  actual: 1,
  expected: 0,
  operator: 'strictEqual',
  diff: 'simple'
}

Node.js v22.22.3
Complete reproduction source
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile, rm, realpath } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';

const checkout = process.cwd();
const { executeStoredScript, mapScriptResultToRun } = await import(pathToFileURL(join(checkout, 'plugins/automations/src/script-runner.ts')).href);
const { writeInlineAutomationScript } = await import(pathToFileURL(join(checkout, 'plugins/automations/src/script-files.ts')).href);
const scratch = await realpath(await mkdtemp(join(tmpdir(), 'automation-cwd-probe-')));
const project = join(scratch, 'project');
const pluginDataDir = join(scratch, 'plugin');
try {
  await mkdir(project);
  const shim = join(scratch, 'bb');
  await writeFile(shim, '#!/bin/sh\nexit 0\n', { mode: 0o700 });
  process.env.BB_CLI = shim;
  process.env.PATH = '/usr/bin:/bin';
  await writeFile(join(project, 'fixture.txt'), 'fixture found\n');
  process.chdir(project);
  const scriptFile = await writeInlineAutomationScript({dataDir: pluginDataDir, automationId: 'probe', content: 'pwd\ncat fixture.txt\n'});
  const args = {pluginDataDir, automationId: 'probe', runId: 'probe-run', projectId: 'probe-project', scriptFile, interpreter: 'bash', timeoutMs: 3000, serverUrl: 'http://127.0.0.1:1'};
  const relative = await executeStoredScript(args);
  const relativeRun = mapScriptResultToRun(relative);
  assert.equal(relative.output.split('\n')[0], join(pluginDataDir, 'scripts'));
  assert.match(relative.output, /fixture.txt: No such file or directory/);
  assert.equal(relativeRun.error, 'Script exited with code 1');
  const absoluteFile = await writeInlineAutomationScript({dataDir: pluginDataDir, automationId: 'probe', content: 'cat "$PROBE_PROJECT/fixture.txt"\n'});
  const absolute = await executeStoredScript({...args, scriptFile: absoluteFile, env: {PROBE_PROJECT: project}});
  assert.equal(absolute.exitCode, 0);
  assert.equal(absolute.output, 'fixture found\n');
  console.log(JSON.stringify({relative: {...relative, output: relative.output.replaceAll(scratch, '<scratch>')}, summary: relativeRun.error, absolute}, null, 2));
  assert.equal(relative.exitCode, 0, 'project-relative fixture should be readable');
} finally {
  process.chdir(checkout);
  await rm(scratch, {recursive: true, force: true});
}

5. Root cause

executeScriptRun passes projectId, but no source directory or host identity, into the runner. In executeStoredScript, cwd is unconditionally computed from plugin storage and passed to spawn:

const cwd = scriptsRoot(args.pluginDataDir);
await mkdir(cwd, { recursive: true });

The project ID is injected into the environment but does not change cwd. mapScriptResultToRun constructs the failure summary from exitCode alone. The capture result concatenates stdout and stderr, preserving the diagnostic in output but losing its stream identity.

6. Proposed fix and simple-fix assessment

Define which host and source a project-scoped script should use, then resolve that host’s checkout before executing. Make behavior explicit for missing sources, personal projects, disconnected hosts, and paths that happen to exist on multiple hosts. The source contract binds each local path to a host; checking only server-side directory existence can select the wrong checkout. Separately, retain bounded stderr internally and include a useful failure diagnostic in summaries.

No fix branch or PR was created. Choosing source/host and fallback behavior requires a product decision, failing the rule’s simple-fix condition. The runner probe supplies a failing regression candidate but does not validate a full project resolver. No production changes were made. No linked open PR appeared in issue timeline metadata or the issue-number PR search.

7. Verification

The same agent repeated the probe in a second clean temporary Git worktree at the identical trusted commit, with a fresh dependency installation and freshly created fixture storage. Command: corepack pnpm exec tsx plugins/automations/repro-3616.mts. Exit status: 1 from the final intended assertion. All preceding observed-behavior assertions and the absolute-path control passed. Second run output. No correction was needed. This is a runner-level reproduction; project API selection, actual Python output, and the UI were not exercised.

8. Related issues

A small sample of existing automation issues was reviewed for classification patterns. No related issue is used as proof of this behavior.

9. Appendix

First run log · Second run log · Repeatable probe. Temporary checkout and fixture paths are redacted. Both probe runs clean up their temporary storage in finally blocks. No service was started.

Issue content, including its prototype and commands, was treated as untrusted evidence. No linked external URL, branch, patch, or script was fetched or executed.