diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 730b9e1a97f34..ad4faf3fdbc8d 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -13,6 +13,7 @@ /* ─── Typography ────────────────────────────────────────────────────── */ @theme { + --shadow-composer-bash: 0 0 32px 4px rgb(0 0 0 / 25%); /* Keep these native family names aligned with app.config.ts. */ --font-sans: "DMSans-Regular"; --font-medium: "DMSans-Medium"; diff --git a/apps/mobile/src/features/threads/BashMessageDisclosure.tsx b/apps/mobile/src/features/threads/BashMessageDisclosure.tsx new file mode 100644 index 0000000000000..0ab3d8eb3ea81 --- /dev/null +++ b/apps/mobile/src/features/threads/BashMessageDisclosure.tsx @@ -0,0 +1,95 @@ +import { + composerBashStatusLabel, + parseComposerBashOutput, + type ComposerBashCommand, +} from "@t3tools/client-runtime/composer-bash"; +import type { ReactNode } from "react"; +import { Pressable, ScrollView, View, type ColorValue } from "react-native"; +import Animated, { FadeIn, FadeInUp, FadeOut, ReduceMotion } from "react-native-reanimated"; +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { ThreadDisclosureChevron } from "./thread-work-log"; + +export function BashMessageDisclosure({ + command, + expanded, + onToggle, + children, + chevronColor, +}: { + command: ComposerBashCommand | undefined; + expanded: boolean; + onToggle: () => void; + children: ReactNode; + chevronColor: ColorValue; +}) { + if (!command) return children; + + return ( + + + + + {composerBashStatusLabel(command)} + + + + + {children} + {expanded && ( + + + + )} + + ); +} + +function BashOutput({ command }: { command: ComposerBashCommand }) { + if (!command.output) { + return ( + + {command.status === "running" ? "Running command…" : "No command output."} + + ); + } + const { exitCode, stdout, stderr } = parseComposerBashOutput(command.output); + return ( + + + + {stdout} + {stdout && stderr ? "\n" : null} + {stderr ? ( + {stderr} + ) : null} + {!stdout && !stderr ? "No output" : null} + + + {exitCode !== null && ( + + exit {exitCode} + + )} + + ); +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index b8168cf481565..1d64e939f71cf 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1758,6 +1758,7 @@ export function NewTaskDraftScreen(props: { style={promptEnhancementHaloStyle} /> + {props.bashMode && ( + + + + Bash mode + + + + Output goes to the agent + + + )} {props.children} @@ -675,6 +699,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} ) : null} - {message.text.trim().length > 0 ? ( - - - - ) : null} + props.onToggleWorkRow(`bash-collapsed:${message.id}`, entry.id)} + > + {message.text.trim().length > 0 ? ( + + + + ) : null} + diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 5d256f7c6206b..03fcdbe9cf3a4 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -3748,3 +3748,90 @@ it("keeps attachment-only question answers expandable outside mobile work groups expect(running[1]).toBe(group); expect(running[2]?.type).toBe("work-toggle"); }); + +describe("composer Bash message results", () => { + it("refreshes a cached message when its command finishes, preserving ordinary rows", () => { + const createdAt = "2026-09-01T00:00:00.000Z"; + const thread = makeThread({ + id: ThreadId.make("bash-thread"), + projectId: ProjectId.make("project"), + title: "Bash", + messages: [ + { + id: MessageId.make("bash-user"), + role: "user", + text: "!ls", + turnId: null, + createdAt, + updatedAt: createdAt, + streaming: false, + }, + { + id: MessageId.make("ordinary-user"), + role: "user", + text: "Explain this", + turnId: null, + createdAt, + updatedAt: createdAt, + streaming: false, + }, + { + id: MessageId.make("other-bash-user"), + role: "user", + text: "!pwd", + turnId: null, + createdAt, + updatedAt: createdAt, + streaming: false, + }, + ], + }); + const activity = makeActivity({ + id: EventId.make("bash-activity"), + kind: "tool.updated", + summary: "Running Bash command", + tone: "tool", + createdAt, + payload: { + toolCallId: "composer-bash:bash-user", + itemType: "command_execution", + status: "inProgress", + data: { command: "ls" }, + }, + }); + const messageRows = (feed: ThreadFeedEntry[]) => + feed.filter((entry) => entry.type === "message"); + const initial = messageRows(buildThreadFeed(thread)); + expect(initial.every((entry) => entry.bashCommand === undefined)).toBe(true); + const running = messageRows(buildThreadFeed({ ...thread, activities: [activity] })); + expect(running[0]?.bashCommand).toEqual({ status: "running", output: null }); + for (const status of ["completed", "failed"] as const) { + const detail = + status === "completed" ? "Exit code: 0\nfile.txt" : "Exit code: 1\nBuild failed"; + const finishedThread = { + ...thread, + activities: [ + { + ...activity, + kind: "tool.completed" as const, + payload: { + toolCallId: "composer-bash:bash-user", + itemType: "command_execution", + status, + detail, + data: { command: "ls", rawOutput: detail }, + }, + }, + ], + }; + const finished = messageRows(buildThreadFeed(finishedThread)); + expect(finished[0]?.bashCommand).toEqual({ status, output: detail }); + expect(finished[0]).not.toBe(running[0]); + expect(messageRows(buildThreadFeed(finishedThread))[0]).toBe(finished[0]); + for (const index of [1, 2]) { + expect(finished[index]).toBe(initial[index]); + expect(finished[index]?.bashCommand).toBeUndefined(); + } + } + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 8f000b1b55fc5..be625dd51d40d 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,3 +1,8 @@ +import { + indexComposerBashCommands, + isComposerBashEntry, + type ComposerBashCommand, +} from "@t3tools/client-runtime/composer-bash"; import * as Option from "effect/Option"; import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input"; import * as Schema from "effect/Schema"; @@ -139,6 +144,7 @@ type RawThreadFeedEntry = readonly id: string; readonly createdAt: string; readonly message: OrchestrationThread["messages"][number]; + readonly bashCommand?: ComposerBashCommand | undefined; } | { readonly type: "activity"; @@ -574,6 +580,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const message = asTrimmedString(payload?.message); if (message) entry.detail = message; } + // Composer Bash output is shown whole on its message. The client projection + // trims `data.rawOutput` to one line, so read the untrimmed `detail`. + const composerBashOutput = payload?.detail; + if (toolCallId?.startsWith("composer-bash:") && typeof composerBashOutput === "string") { + entry.detail = composerBashOutput; + } if (viewedImagePath) { entry.viewedImagePath = viewedImagePath; } @@ -2420,6 +2432,9 @@ export function buildThreadFeed( (entry) => oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt, ); + const bashCommands = indexComposerBashCommands( + activityEntries.map((entry) => entry.activity.workEntry), + ); const foldedAnswerMessageIds = new Set( activityEntries.flatMap((entry) => entry.activity.workEntry.questionAnswer @@ -2432,14 +2447,28 @@ export function buildThreadFeed( ...messages .filter((message) => message.role !== "user" || !foldedAnswerMessageIds.has(message.id)) .map((message) => { + const bashCommand = + message.role === "user" && message.text.startsWith("!") + ? bashCommands.get(message.id) + : undefined; let entry = messageEntriesCache.get(message); - if (!entry) { - entry = { type: "message", id: message.id, createdAt: message.createdAt, message }; + if ( + !entry || + entry.bashCommand?.status !== bashCommand?.status || + entry.bashCommand?.output !== bashCommand?.output + ) { + entry = { + type: "message", + id: message.id, + createdAt: message.createdAt, + message, + bashCommand, + }; messageEntriesCache.set(message, entry); } return entry; }), - ...activityEntries, + ...activityEntries.filter((entry) => !isComposerBashEntry(entry.activity.workEntry)), ], (s) => new Date(s.createdAt), Order.Date, diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index e6468ff8f789d..ca4ce51fd1889 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -21,6 +21,19 @@ function activity(payload: Record): OrchestrationThreadActivity * assertions are the tripwire. */ describe("projectActivityPayload", () => { + it("keeps composer Bash output whole in detail for the message result", () => { + const output = "Exit code: 0\nstdout:\nAGENTS.md\napps\ndocs"; + const projected = projectActivityPayload( + activity({ + itemType: "command_execution", + toolCallId: "composer-bash:message-1", + detail: output, + data: { command: "ls", rawOutput: output }, + }), + ); + expect((projected.payload as Record).detail).toBe(output); + }); + it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { const projected = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c0db79fa42e3f..521e798183f40 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -171,6 +171,8 @@ describe("ProviderCommandReactor", () => { async function createHarness(input?: { readonly baseDir?: string; + readonly workspaceRoot?: string; + readonly onTurnSent?: () => Effect.Effect; readonly initialTitle?: string; readonly deferReactorStart?: boolean; readonly threadModelSelection?: ModelSelection; @@ -274,7 +276,7 @@ describe("ProviderCommandReactor", () => { Effect.succeed({ threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-1"), - }), + }).pipe(Effect.tap(() => input?.onTurnSent?.() ?? Effect.void)), ); const compactThread = vi.fn((_: ThreadId) => input?.compactThreadEffect?.() ?? Effect.void); const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void); @@ -525,7 +527,7 @@ describe("ProviderCommandReactor", () => { commandId: CommandId.make("cmd-project-create"), projectId: asProjectId("project-1"), title: "Provider Project", - workspaceRoot: "/tmp/provider-project", + workspaceRoot: input?.workspaceRoot ?? "/tmp/provider-project", defaultModelSelection: modelSelection, createdAt: now, }), @@ -1023,6 +1025,126 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect.each(["project", "worktree"])( + "runs a composer command once in the %s and sends failure output as context", + (workspace) => + Effect.gen(function* () { + const sent = yield* Deferred.make(); + const workspaceRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-bash-")); + createdBaseDirs.add(workspaceRoot); + const harness = yield* Effect.promise(() => + createHarness({ + workspaceRoot, + onTurnSent: () => Deferred.succeed(sent, undefined).pipe(Effect.asVoid), + }), + ); + const cwd = + workspace === "worktree" ? NodePath.join(workspaceRoot, "checkout") : workspaceRoot; + if (workspace === "worktree") { + NodeFS.mkdirSync(cwd); + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-bash-worktree"), + threadId: ThreadId.make("thread-1"), + worktreePath: cwd, + }); + } + const command = { + type: "thread.turn.start" as const, + commandId: CommandId.make("cmd-bash"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("bash-message"), + role: "user" as const, + text: "!printf x >> count; printf 'failed build' >&2; exit 2", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required" as const, + createdAt: "2026-01-01T00:00:00.000Z", + }; + yield* harness.engine.dispatch(command); + yield* Deferred.await(sent); + yield* harness.engine.dispatch(command); + yield* Effect.promise(() => harness.drain()); + expect(NodeFS.readFileSync(NodePath.join(cwd, "count"), "utf8")).toBe("x"); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + input: expect.stringContaining("Exit code: 2"), + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + input: expect.stringContaining("failed build"), + }); + const snapshot = yield* Effect.promise(() => harness.readModel()); + const activities = snapshot.threads[0]?.activities ?? []; + expect(activities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "tool.updated", summary: "Running Bash command" }), + expect.objectContaining({ + kind: "tool.completed", + tone: "error", + payload: expect.objectContaining({ detail: expect.stringContaining("failed build") }), + }), + ]), + ); + }), + ); + + effectIt.effect("cancels a composer command without sending it to the provider", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ workspaceRoot: NodeOS.tmpdir() }), + ); + const running = yield* Deferred.make(); + const canceled = yield* Deferred.make(); + const events = yield* harness.engine.subscribeDomainEvents; + yield* Stream.runForEach(events, (event) => { + if (event.type !== "thread.activity-appended") return Effect.void; + const activity = event.payload.activity; + if (activity.summary === "Running Bash command") + return Deferred.succeed(running, undefined); + if (activity.kind === "provider.turn.start.failed") + return Deferred.succeed(canceled, undefined); + return Effect.void; + }).pipe(Effect.forkScoped); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bash-cancel-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("bash-cancel-message"), + role: "user", + text: "!sleep 60", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(running); + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-bash-cancel"), + threadId: ThreadId.make("thread-1"), + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* Deferred.await(canceled); + yield* Effect.promise(() => harness.drain()); + expect(harness.sendTurn).not.toHaveBeenCalled(); + expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([]); + const snapshot = yield* Effect.promise(() => harness.readModel()); + expect(snapshot.threads[0]?.session?.status).toBe("ready"); + expect(snapshot.threads[0]?.activities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "tool.completed", + payload: expect.objectContaining({ detail: "Bash command canceled." }), + }), + ]), + ); + }), + ); + it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -1046,6 +1168,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ input: "hello reactor" }); expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.make("thread-1")); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ cwd: "/tmp/provider-project", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 35353c0662e39..b2fbf39b85ff3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -31,6 +31,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -73,6 +74,8 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; +import * as ProcessRunner from "../../processRunner.ts"; +import { runComposerBashCommand, sanitizeComposerBashText } from "../composerBash.ts"; const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); @@ -269,6 +272,15 @@ const make = Effect.gen(function* () { const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; const terminalManager = yield* TerminalManager.TerminalManager; + const processRunner = yield* ProcessRunner.ProcessRunner; + const composerCommands = new Map }>(); + const interruptComposerCommands = Effect.fnUntraced(function* (threadId: ThreadId) { + const commands = [...composerCommands.values()].filter( + (command) => command.threadId === threadId, + ); + yield* Effect.forEach(commands, ({ fiber }) => Fiber.interrupt(fiber), { discard: true }); + return commands.length > 0; + }); /** Environment settings with the thread's project overrides applied. */ const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) { const settings = yield* serverSettingsService.getSettings; @@ -1610,15 +1622,75 @@ const make = Effect.gen(function* () { return; } - const send = providerService - .sendTurn(sendTurnRequest.value) - .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure)); + const isBashCommand = message.text.startsWith("!"); + const appendBashActivity = Effect.fnUntraced(function* (detail?: string, failed = false) { + const createdAt = DateTime.formatIso(yield* DateTime.now); + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: yield* serverCommandId("composer-bash"), + threadId: thread.id, + activity: { + id: yield* serverEventId(), + tone: failed ? "error" : "tool", + kind: detail === undefined ? "tool.updated" : "tool.completed", + summary: + detail === undefined + ? "Running Bash command" + : failed + ? "Bash command failed" + : "Bash command completed", + payload: { + itemType: "command_execution", + toolCallId: `composer-bash:${message.id}`, + status: detail === undefined ? "inProgress" : failed ? "failed" : "completed", + ...(detail !== undefined ? { detail } : {}), + data: { + command: sanitizeComposerBashText(message.text.slice(1)), + ...(detail !== undefined ? { rawOutput: detail } : {}), + }, + }, + turnId: null, + createdAt, + }, + createdAt, + }); + }); + const send = Effect.gen(function* () { + if (!isBashCommand) return yield* providerService.sendTurn(sendTurnRequest.value); + const result = yield* Effect.gen(function* () { + const project = yield* resolveProject(thread.projectId); + yield* appendBashActivity(); + return yield* runComposerBashCommand({ + text: message.text, + cwd: + resolveThreadWorkspaceCwd({ thread, projects: project ? [project] : [] }) ?? undefined, + }); + }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + Effect.onInterrupt(() => + appendBashActivity("Bash command canceled.", true).pipe( + Effect.andThen( + appendTurnStartFailure( + "Bash command canceled", + "The command was canceled before its result could be sent.", + ), + ), + ), + ), + ); + composerCommands.delete(message.id); + if (result === null) return; + yield* appendBashActivity(result.detail, result.failed); + return yield* providerService.sendTurn({ ...sendTurnRequest.value, input: result.input }); + }).pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure)); // The forked send settles `sent` from here on, so drop the entry the post-processing hook uses. if (resumed && event.commandId !== null) resumedTurnStarts.delete(event.commandId); - yield* send.pipe( + const fiber = yield* send.pipe( Effect.ensuring(resumed ? Deferred.succeed(resumed.sent, undefined) : Effect.void), + Effect.ensuring(Effect.sync(() => composerCommands.delete(message.id))), Effect.forkScoped, ); + if (isBashCommand) composerCommands.set(message.id, { threadId: thread.id, fiber }); }); const resumeNextQueuedTurn = Effect.fn("resumeNextQueuedTurn")(function* (threadId: ThreadId) { @@ -1639,6 +1711,7 @@ const make = Effect.gen(function* () { const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( event: Extract, ) { + const canceledBash = yield* interruptComposerCommands(event.payload.threadId); yield* cancelTurnsAfterCompaction( event.payload.threadId, "Context compaction was interrupted. Send this message again to continue.", @@ -1648,6 +1721,13 @@ const make = Effect.gen(function* () { return; } const session = thread.session; + if (canceledBash && session && session.status !== "stopped" && session.activeTurnId === null) { + return yield* setThreadSession({ + threadId: thread.id, + session: { ...session, status: "ready", updatedAt: event.payload.createdAt }, + createdAt: event.payload.createdAt, + }); + } if (!session || session.status === "stopped") { return yield* appendProviderFailureActivity({ threadId: event.payload.threadId, @@ -1829,6 +1909,7 @@ const make = Effect.gen(function* () { const processSessionStopRequested = Effect.fn("processSessionStopRequested")(function* ( event: Extract, ) { + yield* interruptComposerCommands(event.payload.threadId); const thread = yield* resolveThreadShell(event.payload.threadId); if (!thread) { return; @@ -2079,4 +2160,6 @@ const make = Effect.gen(function* () { } satisfies ProviderCommandReactorShape; }); -export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make); +export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make).pipe( + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/orchestration/composerBash.test.ts b/apps/server/src/orchestration/composerBash.test.ts new file mode 100644 index 0000000000000..3f1cd943a40e6 --- /dev/null +++ b/apps/server/src/orchestration/composerBash.test.ts @@ -0,0 +1,120 @@ +import * as NodeOS from "node:os"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ProcessRunner from "../processRunner.ts"; +import { runComposerBashCommand, sanitizeComposerBashText } from "./composerBash.ts"; + +const live = ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)); +const run = (text: string) => + runComposerBashCommand({ text, cwd: NodeOS.tmpdir() }).pipe(Effect.provide(live)); + +describe("composer Bash commands", () => { + it.effect("leaves ordinary messages alone", () => + Effect.gen(function* () { + expect(yield* run("Explain !false")).toBeNull(); + expect(yield* run("hello")).toBeNull(); + }), + ); + + it.effect("captures stdout and stderr from one Bash execution", () => + Effect.gen(function* () { + const result = yield* run("!printf 'build output'; printf 'build warning' >&2; exit 2"); + expect(result?.failed).toBe(true); + expect(result?.detail).toContain("Exit code: 2"); + expect(result?.detail).toContain("build output"); + expect(result?.detail).toContain("build warning"); + expect(result?.input).toContain("already ran"); + }), + ); + + it.effect.each(["!true", "!printf '\\n'", "!printf '\\033[0m'"])( + "reports empty output explicitly for %s", + (command) => + Effect.gen(function* () { + const result = yield* run(command); + expect(result?.failed).toBe(false); + expect(result?.detail).toBe("Exit code: 0\n(no output)"); + }), + ); + + it("redacts secret environment values", () => { + vi.stubEnv("T3_TEST_SECRET", "example-private-value"); + try { + expect(sanitizeComposerBashText("value=example-private-value")).toBe("value=[redacted]"); + } finally { + vi.unstubAllEnvs(); + } + }); + + it.effect("does not execute without a thread workspace", () => + Effect.gen(function* () { + const result = yield* runComposerBashCommand({ text: "!pwd", cwd: undefined }).pipe( + Effect.provide(live), + ); + expect(result?.failed).toBe(true); + expect(result?.detail).toContain("no workspace"); + }), + ); + + it.effect("rejects an empty command", () => + Effect.gen(function* () { + const result = yield* run("! "); + expect(result?.failed).toBe(true); + expect(result?.detail).toContain("Enter a Bash command after !"); + }), + ); + + it.effect("bounds output and redacts recognizable credentials", () => + Effect.gen(function* () { + const result = yield* run("!printf 'Bearer example-secret\\n'; printf '%20000s' x"); + expect(result?.detail).toContain("Bearer [redacted]"); + expect(result?.detail).not.toContain("example-secret"); + expect(result?.detail).toContain("[output truncated]"); + expect(result!.detail.length).toBeLessThan(17000); + }), + ); + + it.effect("reports a timeout without claiming success", () => + Effect.gen(function* () { + const result = yield* runComposerBashCommand({ text: "!build", cwd: "/workspace" }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => + Effect.succeed({ + stdout: "", + stderr: "", + code: null, + timedOut: true, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + }), + ); + expect(result?.failed).toBe(true); + expect(result?.detail).toContain("timed out after 60 seconds"); + }), + ); + + it.effect("does not expose process failure causes", () => + Effect.gen(function* () { + const result = yield* runComposerBashCommand({ text: "!build", cwd: "/workspace" }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => + Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: "bash", + argumentCount: 2, + cause: "private credential", + }), + ), + }), + ); + expect(result?.detail).toContain("Bash could not be started"); + expect(result?.input).not.toContain("private credential"); + }), + ); +}); diff --git a/apps/server/src/orchestration/composerBash.ts b/apps/server/src/orchestration/composerBash.ts new file mode 100644 index 0000000000000..98ddfa67768fd --- /dev/null +++ b/apps/server/src/orchestration/composerBash.ts @@ -0,0 +1,79 @@ +import * as NodeUtil from "node:util"; +import * as Effect from "effect/Effect"; + +import { ProcessRunner } from "../processRunner.ts"; +import { sanitizeAcpStderrExcerpt } from "../provider/acp/AcpStderr.ts"; + +export function sanitizeComposerBashText(text: string): string { + let sanitized = NodeUtil.stripVTControlCharacters(text); + for (const [name, value] of Object.entries(process.env)) { + if ( + /(?:TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i.test(name) && + value && + value.length >= 4 + ) { + sanitized = sanitized.replaceAll(value, "[redacted]"); + } + } + return sanitizeAcpStderrExcerpt(sanitized); +} + +export const runComposerBashCommand = Effect.fnUntraced(function* (input: { + readonly text: string; + readonly cwd: string | undefined; +}) { + if (!input.text.startsWith("!")) return null; + const command = input.text.slice(1).trim(); + const runner = yield* ProcessRunner; + const result = !command + ? { failed: true, detail: "Enter a Bash command after !." } + : !input.cwd + ? { failed: true, detail: "The thread has no workspace to run this command in." } + : yield* runner + .run({ + command: "bash", + args: ["--noprofile", "--norc", "-c", command], + cwd: input.cwd, + stdin: "", + timeout: "60 seconds", + timeoutBehavior: "timedOutResult", + maxOutputBytes: 16 * 1024, + outputMode: "truncate", + truncatedMarker: "\n[output truncated]", + env: { BASH_ENV: "", ENV: "" }, + }) + .pipe( + Effect.map((output) => { + const stdout = sanitizeComposerBashText(output.stdout); + const stderr = sanitizeComposerBashText(output.stderr); + return { + failed: output.timedOut || output.code !== 0, + detail: output.timedOut + ? "Bash command timed out after 60 seconds. Partial output is unavailable." + : [ + `Exit code: ${output.code ?? "unknown"}`, + ...(stdout ? [`stdout:\n${stdout}`] : []), + ...(stderr ? [`stderr:\n${stderr}`] : []), + ...(!stdout && !stderr ? ["(no output)"] : []), + ].join("\n"), + }; + }), + Effect.catch((error) => + Effect.succeed({ + failed: true, + detail: + error._tag === "ProcessSpawnError" + ? "Bash could not be started. Check that Bash is installed and the workspace exists." + : "Bash command failed while collecting its output.", + }), + ), + ); + const safeCommand = sanitizeComposerBashText(command); + const detail = sanitizeComposerBashText(result.detail); + return { + command: safeCommand, + failed: result.failed, + detail, + input: `The user already ran this one-off Bash command. Use its result as context; do not rerun it automatically.\n\nCommand: ${safeCommand}\n\n${detail}`, + }; +}); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b20458cd200bd..d65c08962c788 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4559,6 +4559,58 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("requires terminal permission for Bash composer submissions", () => + Effect.gen(function* () { + const dispatched: string[] = []; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command.commandId); + return { sequence: 1 }; + }), + }, + }, + }); + const { body: token } = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { + scope: "orchestration:operate", + }); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${token.access_token ?? ""}` }, + }); + const ticket = (yield* ticketResponse.json) as { readonly ticket: string }; + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket.ticket)}`; + yield* withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const command = (text: string) => ({ + type: "thread.turn.start" as const, + commandId: CommandId.make(text === "hello" ? "normal-message" : "bash-message"), + threadId: ThreadId.make("thread-bash-scope"), + message: { + messageId: MessageId.make("scope-message"), + role: "user" as const, + text, + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand](command("hello")); + const error = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]( + command("!printf hello"), + ).pipe(Effect.flip); + assert.equal(error._tag, "EnvironmentAuthorizationError"); + if (error._tag === "EnvironmentAuthorizationError") + assert.equal(error.requiredScope, "terminal:operate"); + }), + ); + assert.deepEqual(dispatched, ["normal-message"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not allow management-only access tokens to operate the environment", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3728fcca69c31..a923ceb1a60ce 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -20,6 +20,7 @@ import * as Stream from "effect/Stream"; import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, AuthAccessStreamError, + AuthTerminalOperateScope, AutomationOperationError, type AuthAccessStreamEvent, type AuthEnvironmentScope, @@ -195,6 +196,7 @@ import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); +const isEnvironmentAuthorizationError = Schema.is(EnvironmentAuthorizationError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const CONFIG_DISCOVERY_TIMEOUT = Duration.seconds(5); @@ -1994,6 +1996,12 @@ const makeWsRpcLayer = ( Effect.gen(function* () { yield* ProjectCloneTracker.rejectCommandsDuringClone(projectCloneTracker, command); const normalizedCommand = yield* normalizeDispatchCommand(command); + if ( + normalizedCommand.type === "thread.turn.start" && + normalizedCommand.message.text.startsWith("!") + ) { + yield* authorizeEffect(AuthTerminalOperateScope, Effect.void); + } if (normalizedCommand.type === "project.delete") { yield* worktreeRuns.stopProject(normalizedCommand.projectId); } @@ -2068,7 +2076,7 @@ const makeWsRpcLayer = ( return result; }).pipe( Effect.mapError((cause) => - isOrchestrationDispatchCommandError(cause) + isEnvironmentAuthorizationError(cause) || isOrchestrationDispatchCommandError(cause) ? cause : new OrchestrationDispatchCommandError({ message: "Failed to dispatch orchestration command", diff --git a/apps/web/src/components/chat/BashMessageDisclosure.test.tsx b/apps/web/src/components/chat/BashMessageDisclosure.test.tsx new file mode 100644 index 0000000000000..8897a8e26af7c --- /dev/null +++ b/apps/web/src/components/chat/BashMessageDisclosure.test.tsx @@ -0,0 +1,73 @@ +import { act } from "react"; +import { create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { BashMessageDisclosure } from "./BashMessageDisclosure"; + +describe("Bash message disclosure", () => { + let renderer: ReactTestRenderer; + beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true)); + afterEach(async () => { + if (renderer) await act(() => renderer.unmount()); + vi.unstubAllGlobals(); + }); + + it("leaves ordinary and unconfirmed messages unchanged", async () => { + await act(() => { + renderer = create( + +

!ls

+
, + ); + }); + expect(renderer.root.findAllByType("button")).toHaveLength(0); + expect(renderer.root.findByType("p").children).toEqual(["!ls"]); + }); + + const textOf = (node: ReactTestInstance | string): string => + typeof node === "string" ? node : node.children.map(textOf).join(""); + const resultText = () => textOf(renderer.root.findByType("pre")); + + it("shows stdout and stderr inline and collapses on demand", async () => { + await act(() => { + renderer = create( + +

!build

+
, + ); + }); + const button = renderer.root.findByType("button"); + expect(button.props["aria-expanded"]).toBe(true); + expect(resultText()).toContain("building"); + expect(resultText()).toContain("boom"); + expect(resultText()).not.toContain("stdout:"); + await act(() => button.props.onClick()); + expect(button.props["aria-expanded"]).toBe(false); + expect(renderer.root.findByProps({ id: button.props["aria-controls"] }).props.inert).toBe(true); + }); + + it("updates a running command with the server result", async () => { + await act(() => { + renderer = create( + + !ls + , + ); + }); + expect(renderer.root.findByType("p").children).toEqual(["Running command…"]); + await act(() => + renderer.update( + + !ls + , + ), + ); + expect(resultText()).toContain("file.txt"); + expect(renderer.root.findByProps({ role: "status" }).props["aria-label"]).toBe( + "Bash · Completed", + ); + }); +}); diff --git a/apps/web/src/components/chat/BashMessageDisclosure.tsx b/apps/web/src/components/chat/BashMessageDisclosure.tsx new file mode 100644 index 0000000000000..6fb194f0fe883 --- /dev/null +++ b/apps/web/src/components/chat/BashMessageDisclosure.tsx @@ -0,0 +1,117 @@ +import { + composerBashStatusLabel, + parseComposerBashOutput, + type ComposerBashCommand, +} from "@t3tools/client-runtime/composer-bash"; +import { CheckIcon, ChevronDownIcon, TerminalIcon, XIcon } from "lucide-react"; +import { cn } from "~/lib/utils"; +import { Spinner } from "../ui/spinner"; +import { useId, useState, type ReactNode } from "react"; + +export function BashMessageDisclosure({ + command, + children, +}: { + command: ComposerBashCommand | undefined; + children: ReactNode; +}) { + const [expanded, setExpanded] = useState(true); + const outputId = useId(); + if (!command) return children; + + return ( +
+ + {children} + {/* Grid rows animate height without measuring; inert keeps the collapsed output out of focus and AT. */} +
+
+ +
+
+
+ ); +} + +function BashOutput({ command }: { command: ComposerBashCommand }) { + if (!command.output) { + return ( +

+ {command.status === "running" ? "Running command…" : "No command output."} +

+ ); + } + const { exitCode, stdout, stderr } = parseComposerBashOutput(command.output); + return ( +
+
+        {stdout}
+        {stdout && stderr ? "\n" : null}
+        {stderr && {stderr}}
+        {!stdout && !stderr && No output}
+      
+ {exitCode !== null && ( +

+ exit {exitCode} +

+ )} +
+ ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 18327d6ad85f1..5fbe396ee9171 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -953,6 +953,7 @@ import { ShieldIcon, WandSparklesIcon, XIcon, + TerminalIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; import { hasProviderSetup } from "./ProviderStatusBanner"; @@ -6322,6 +6323,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ], ); + const isBashMode = prompt.startsWith("!") && !activePendingUserInput && !isComposerApprovalState; + // Render // ------------------------------------------------------------------ return ( @@ -6579,7 +6582,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /> ) : null} -
+
+ {/* One-shot fade/scale on opacity+transform only, so the glow never repaints while idle. */} +