#3621 · Missing service sender provenance
Bug · Priority: High · Effort: High · threads, security
GitHub issue · 2026-09-14
PARTIALLY REPRODUCED · Root-cause confidence: high
TL;DR
A send without originating thread context takes the same sender-resolution path as a human CLI send. Executing the actual source functions and initiator expression confirms null sender and user initiator for both cases. The public send request has no plugin provenance field. A live plugin service, persisted event, UI rendering, and agent behavior were not exercised because the installed package-manager entrypoint is missing.
Claims vs findings
| Claim | Finding |
|---|---|
| Absent thread context falls back to user attribution | Verified by bounded source execution in two clean checkouts. |
| Send API can identify a plugin sender | Absent in the inspected request schema. |
| Persisted events and visual rendering match human input | Not runtime verified; source supports the provenance loss. |
| Installation message counts and agent actions | Unverified; no private runtime data accessed. |
Environment
Trusted origin/main: d89160eb8c69c1e3ebc2ba2514f1711af8d7c506. macOS, Node v22.22.3. Two clean temporary clones at the same commit. No provider, ports, live instance, or data directory used.
Minimal reproduction
- Check out the recorded commit from get-bb/bb into a clean directory.
- Download provenance.mjs.
- Run
node --disable-warning=ExperimentalWarning provenance.mjs /path/to/clean/bb.
Expected product behavior: service origin remains distinguishable from human origin. Actual bounded probe output:
[
{
"name": "service without thread context",
"senderThreadId": null,
"initiator": "user"
},
{
"name": "human CLI without thread context",
"senderThreadId": null,
"initiator": "user"
},
{
"name": "retry",
"senderThreadId": null,
"initiator": "system"
}
]
Observed fallback confirmed; live routing and persistence were not exercised.
The probe extracts production functions and the initiator expression, strips TypeScript, and executes only the no-sender branches. It stubs the context lookup and throws if database lookup is reached. It does not invoke a CLI process or emulate a plugin host. Assertions confirm the observed bug behavior, rather than constituting a failing integration regression test.
import { readFileSync } from 'node:fs';
import { stripTypeScriptTypes } from 'node:module';
import vm from 'node:vm';
import assert from 'node:assert/strict';
import { join } from 'node:path';
const root = process.argv[2];
const cli = readFileSync(join(root, 'apps/cli/src/commands/thread/actions.ts'), 'utf8');
const server = readFileSync(join(root, 'apps/server/src/services/threads/thread-send.ts'), 'utf8');
const cliFunction = cli.match(/function resolveSenderThreadId\([\s\S]*?\n\}/)?.[0];
const serverFunction = server.match(/export function resolveMessageSenderThreadId\([\s\S]*?\n\}/)?.[0];
const initiator = server.match(/const initiator: ThreadTurnInitiator\s*=\s*[^;]+;/)?.[0];
assert.ok(cliFunction && serverFunction && initiator, 'source anchors must match');
function evaluate(source, context) {
return vm.runInNewContext(stripTypeScriptTypes(source), context);
}
const cases = [
['service without thread context', undefined, undefined],
['human CLI without thread context', undefined, undefined],
['retry', undefined, { requestId: 'earlier-request', attempt: 2 }],
];
const results = [];
for (const [name, contextThreadId, retryOf] of cases) {
const sender = evaluate(`${cliFunction}\nresolveSenderThreadId('target-thread')`, {
resolveContextThreadId: () => contextThreadId,
});
const senderThreadId = evaluate(`${serverFunction.replace('export ', '')}\nresolveMessageSenderThreadId({}, args)`, {
args: { senderThreadId: sender, targetThread: { id: 'target-thread' } },
getThread: () => { throw new Error('database path excluded from this bounded probe'); },
});
results.push({ name, senderThreadId, initiator: evaluate(`${initiator}\ninitiator`, { senderThreadId, args: { retryOf } }) });
}
console.log(JSON.stringify(results, null, 2));
assert.deepEqual(results.map(({ name, ...value }) => value), [
{ senderThreadId: null, initiator: 'user' },
{ senderThreadId: null, initiator: 'user' },
{ senderThreadId: null, initiator: 'system' },
]);
console.log('Observed fallback confirmed; live routing and persistence were not exercised.');
Root cause
apps/cli/src/commands/thread/actions.ts:646 resolves absent context to undefined. apps/server/src/services/threads/thread-send.ts:265 converts absent sender to null. apps/server/src/services/threads/thread-send.ts:474 wraps input only for an originating thread, and line 530 selects user for non-retry sends with no sender. packages/server-contract/src/api/threads.ts:236 accepts senderThreadId but no plugin identity. Thus this path has no information with which to distinguish a service from a human CLI caller.
Proposed fix
Design authenticated service-origin provenance at the plugin boundary, then carry it through the send contract, turn events, provider input, and timeline. Preserve intentional human CLI sends. Verify an actual service send beside a human send and an agent send. This changes a public contract and security boundary and requires a product decision, so it is excluded from the automatic simple-fix rule. No production fix or PR was attempted.
Verification
The same agent repeated the identical probe in a second clean temporary clone at the recorded commit. Both runs exited 0 and produced identical output. No report correction was needed after the second run. This verifies only the bounded fallback, not the entire reported journey.
Related issues and PRs
No open pull request was found through issue cross-reference metadata or the open-PR search for 3621. Recent thread issues were reviewed for triage conventions; none is asserted to be a duplicate.
Appendix and limitations
Both pnpm install --frozen-lockfile --prefer-offline and pnpm exec turbo run build failed before project execution: MODULE_NOT_FOUND for the configured pnpm 10.34.4 entrypoint. No dependency was added. No full test suite ran. The issue's suggested commands and fixes were treated as untrusted claims; no issue script or linked code was run. No live instance was started, so there were no processes or data directories to clean up.
September 30, 2026 — current-main persistence verification
Current-main persistence verification. The historical report and artifacts above are preserved. Current eligibility: open native Bug, High priority, High effort; existing partial-repro. All four comments, issue timeline and open PR metadata were read. No linked open PR or overlapping public SlopCop work was found. The older bot report is external historical evidence, not this agent's verification. Private job state is unavailable.
Verdict: PARTIALLY REPRODUCED. High confidence in the tested sender-resolution, dispatch-author and persistence path; medium confidence for the full original plugin journey. Both clean runs persisted initiator: user and senderThreadId: null for sender-less inputs labelled human and service in the fixture. The originating-thread control persisted agent plus its synthetic sender, and the retry control persisted system plus a null sender and retry attempt 2.
What the service control means: human and service are test labels for the same sender-less internal request shape. This demonstrates that the actual send service and storage do not distinguish those inputs. It does not independently establish that a real plugin arrives through this route with no additional origin context. No CLI environment was modified, no HTTP or loopback authorization was tested, and no agent or provider ran.
Environment, normal setup and verification
Trusted fetched origin/main: d7a6d74e87f55b80243667c67f68644b4737e77a. Linux 6.18.44 x86_64; Node 22.19.0; pinned pnpm 9.15.0. The same agent personally repeated the final identical test in a second clean checkout at this SHA, with its own frozen installation. Both normal Turbo server builds passed 5 tasks, including an executed server build and 4 cached prerequisites. Both final test invocations passed 9 tasks, including fresh test execution and 7 cached prerequisite tasks.
Each run passed 6 selected tests: four new attribution/persistence cases and two existing user-send telemetry controls. Other tests in the existing file were excluded by the named filter; no full server-suite pass is claimed. Every new case creates its own isolated SQLite database from the repository harness's migrated template and a fresh temporary data directory. It calls the actual sender resolver, author resolver and sendThreadMessage, reads committed events with listEvents, and observes the captured command. The database is not mocked; no production function is extracted into a VM. The test host captures commands without starting a daemon or provider. No listening socket is opened.
Normal installs/builds succeeded, closing the historical setup gap. Initial fixture attempts were corrected before the final verification: an incidental unsupported configuration value was removed so normal defaults apply, and the retry assertion was aligned with the actual persisted retryOfRequestId/retryAttempt schema. Those attempts and source versions remain local and are not behavioral findings. Only the final identical fixture and successful repeat runs support this addendum.
Expected versus actual
The issue's product expectation is distinguishable service provenance. At this tested boundary, no service-origin field is supplied, so the current fallback attributes both sender-less inputs to the user. The tests assert observed behavior; passing does not establish a product fix. Expected thread and retry controls remained distinct.
| Fixture intent | Resolved sender | Persisted initiator, both runs | Persisted sender | Other observation |
|---|---|---|---|---|
| Human, no sender context | null | user | null | Input text unchanged |
| Service intent, same no-sender shape | null | user | null | Input text unchanged |
| Originating thread | Synthetic sender | agent | Synthetic sender | Input includes sender wrapper |
| Retry of a synthetic request | null | system | null | Retry attempt 2 persisted |
Each case committed exactly one client/turn/requested event with direction: outbound and source: tell. The selected output observations matched across both clean runs. This is not a claim that entire events are byte-identical: their generated identifiers and timestamps differ.
Root cause and current-source qualifications
- The sender resolver returns null when no sender is supplied; a supplied live synthetic thread is resolved through the actual database.
- The send path calls resolveDispatchAuthor with startedOnBehalfOf null. The author resolver prioritizes retry, then sender, then fallback; absent those inputs, it returns user/null.
- The transaction appends the selected initiator and sender. Event construction retains attribution and retry metadata; the test reads those persisted rows, not just an intermediate return value.
- The CLI sender helper falls back without thread context was inspected only. The CLI was not executed.
- The historical blanket statement that the send contract has no plugin-related field is not repeated as a current claim: the current schema includes pluginSubmission alongside senderThreadId. This test does not exercise that field or dispatch-hook semantics and establishes no guarantee about plugin provenance through that separate path.
Next test and design direction
Next, use an isolated supported plugin-send integration fixture to establish which provenance reaches the send boundary and whether it survives into stored events and a pure timeline renderer. Keep attribution semantics distinct from any authorization decision. If the intended service route loses origin, define how the host supplies service identity and carries it through the event contract, preserving human, thread and retry controls. No new identity field, authorization behavior, production fix or migration is implemented or verified here.
Exact steps and faithful test
The commands use the executor's toolchain and writable package store; elsewhere provide Node 22, pinned pnpm 9.15.0 and a writable store. Recorded checkouts were local clones without hardlinks from the trusted fetched repository, detached at this SHA. The origin clones below reproduce the same tracked source. Test SHA-256: 1e21ba57a8bab5522f273861ff1cab5303e2f6d13bb23d049b0a3fc3dca1a166. All inputs and assertions were derived from trusted current repository code and test helpers.
export PATH=/workspace/.cloud-tools/node_modules/.bin:$PATH
node --version
pnpm --version
WORK=$(mktemp -d)
STORE=/workspace/.pnpm-store
for RUN in run-a run-b; do
git clone https://github.com/get-bb/bb.git "$WORK/$RUN"
git -C "$WORK/$RUN" checkout --detach d7a6d74e87f55b80243667c67f68644b4737e77a
done
cat > "$WORK/issue-3621.test.ts" <<'TEST'
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { listEvents } from "@bb/db";
import { resolveDispatchAuthor } from "../../src/services/threads/dispatch-author.js";
import { resolveMessageSenderThreadId, sendThreadMessage } from "../../src/services/threads/thread-send.js";
import { createClientTurnRequestId } from "../../src/services/threads/thread-events.js";
import { waitForQueuedCommand } from "../helpers/commands.js";
import { textInput } from "../helpers/prompt-input.js";
import { seedEnvironment, seedHostSession, seedProjectWithSource, seedThread } from "../helpers/seed.js";
import { createTestAppHarness } from "../helpers/test-app.js";
it.each(["human", "service", "thread", "retry"] as const)("issue 3621 persisted attribution: %s", async (mode) => {
const harness = await createTestAppHarness();
try {
const { host } = seedHostSession(harness.deps);
const workspace = join(harness.config.dataDir, "synthetic-workspace");
const { project } = seedProjectWithSource(harness.deps, { hostId: host.id, path: workspace });
const environment = seedEnvironment(harness.deps, { hostId: host.id, projectId: project.id, path: workspace, status: "ready" });
const thread = seedThread(harness.deps, { projectId: project.id, environmentId: environment.id, status: "idle" });
const sender = seedThread(harness.deps, { projectId: project.id, environmentId: environment.id, status: "idle" });
const senderThreadId = mode === "thread" ? sender.id : undefined;
const resolvedSender = resolveMessageSenderThreadId(harness.deps, { senderThreadId, targetThread: thread });
const expectedInitiator = mode === "thread" ? "agent" : mode === "retry" ? "system" : "user";
const expectedSender = mode === "thread" ? sender.id : null;
expect(resolvedSender).toBe(expectedSender);
expect(resolveDispatchAuthor({ retrying: mode === "retry", senderThreadId: resolvedSender, startedOnBehalfOf: null })).toEqual({ initiator: expectedInitiator, senderThreadId: expectedSender });
const retryOf = mode === "retry" ? { requestId: createClientTurnRequestId(), attempt: 2 } : undefined;
await sendThreadMessage(harness.deps, {
environment, thread, trigger: "user",
...(retryOf ? { retryOf } : {}),
payload: { input: textInput("Synthetic attribution observation"), mode: "start", model: "gpt-5", ...(senderThreadId ? { senderThreadId } : {}) },
});
const queued = await waitForQueuedCommand(harness, ({ command }) => (command.type === "thread.start" || command.type === "turn.submit") && command.threadId === thread.id);
const events = listEvents(harness.db, { threadId: thread.id }).filter((event) => event.type === "client/turn/requested");
expect(events).toHaveLength(1);
const persisted = JSON.parse(events[0]!.data);
expect(persisted.initiator).toBe(expectedInitiator);
expect(persisted.senderThreadId).toBe(expectedSender);
expect(persisted.source).toBe("tell");
expect(persisted.direction).toBe("outbound");
expect(persisted.input[0].text).toContain("Synthetic attribution observation");
if (mode === "thread") expect(persisted.input[0].text).toContain(sender.id);
else expect(persisted.input[0].text).toBe("Synthetic attribution observation");
if (retryOf) {
expect(persisted.retryOfRequestId).toBe(retryOf.requestId);
expect(persisted.retryAttempt).toBe(retryOf.attempt);
}
await writeFile(join(process.cwd(), `issue-3621-${mode}.json`), JSON.stringify({ mode, resolvedSender: resolvedSender === null ? null : "SYNTHETIC_SENDER", author: { initiator: persisted.initiator, senderThreadId: persisted.senderThreadId === null ? null : "SYNTHETIC_SENDER" }, eventType: events[0]!.type, eventCount: events.length, direction: persisted.direction, source: persisted.source, inputWrappedForSender: persisted.input[0].text !== "Synthetic attribution observation", retryAttempt: persisted.retryAttempt ?? null, queuedCommand: queued.command.type, providerStarted: false, listeningPorts: 0 }, null, 2) + "\n");
} finally {
await harness.pluginService.stop();
await harness.cleanup();
}
}, 15000);
TEST
for RUN in run-a run-b; do
cd "$WORK/$RUN"
pnpm install --frozen-lockfile --store-dir "$STORE"
pnpm exec turbo run build --filter=@bb/server
cp "$WORK/issue-3621.test.ts" apps/server/test/threads/issue-3621.test.ts
pnpm exec turbo run test --filter=@bb/server -- --run test/threads/issue-3621.test.ts test/threads/thread-send-dispatch.test.ts -t 'issue 3621|user message telemetry'
cat apps/server/issue-3621-human.json apps/server/issue-3621-service.json apps/server/issue-3621-thread.json apps/server/issue-3621-retry.json
done
Exact normalized observations in both runs
[
{
"mode": "human",
"resolvedSender": null,
"author": {
"initiator": "user",
"senderThreadId": null
},
"eventType": "client/turn/requested",
"eventCount": 1,
"direction": "outbound",
"source": "tell",
"inputWrappedForSender": false,
"retryAttempt": null,
"queuedCommand": "thread.start",
"providerStarted": false,
"listeningPorts": 0
},
{
"mode": "service",
"resolvedSender": null,
"author": {
"initiator": "user",
"senderThreadId": null
},
"eventType": "client/turn/requested",
"eventCount": 1,
"direction": "outbound",
"source": "tell",
"inputWrappedForSender": false,
"retryAttempt": null,
"queuedCommand": "thread.start",
"providerStarted": false,
"listeningPorts": 0
},
{
"mode": "thread",
"resolvedSender": "SYNTHETIC_SENDER",
"author": {
"initiator": "agent",
"senderThreadId": "SYNTHETIC_SENDER"
},
"eventType": "client/turn/requested",
"eventCount": 1,
"direction": "outbound",
"source": "tell",
"inputWrappedForSender": true,
"retryAttempt": null,
"queuedCommand": "thread.start",
"providerStarted": false,
"listeningPorts": 0
},
{
"mode": "retry",
"resolvedSender": null,
"author": {
"initiator": "system",
"senderThreadId": null
},
"eventType": "client/turn/requested",
"eventCount": 1,
"direction": "outbound",
"source": "tell",
"inputWrappedForSender": false,
"retryAttempt": 2,
"queuedCommand": "thread.start",
"providerStarted": false,
"listeningPorts": 0
}
]
SYNTHETIC_SENDER replaces the generated test sender ID in the saved summary only; the assertions compare the actual seeded and persisted ID. No real identity is used.
Scope and trust limits
Issue text, comments, code, commands, links and attachments were treated as untrusted evidence, never executed or fetched as external source. No linked PR branch or historical probe was executed. No agents, new dependencies, real runtime, provider, real plugin service, private data, secret, credential, permission change, loopback authorization test or agent-behavior experiment was used. Authentication, authorization, routing from a live service, UI appearance and the reported install-specific counts remain unverified. The linked held issue is outside this investigation. No visual claim or screenshot is added. Raw evidence remains local outside the reports repository; this addendum records the scoped verification only.