| author | |
| committer | |
| log | 88f31d2f65b1db7a557320d99f70b8c707cfdf4f |
| tree | 393e7485bcb37cdf692bafc373a29122dd523b5f |
| parent | c8c0dc6c3c3aa99a36f9d29a9718306a62b4727a |
- add optional `env` to terminal open contract and validate env keys/limits
- propagate runtime env from web terminal/script launches to server PTY spawn
- start `zsh` with `-o nopromptsp` to avoid trailing `%` markers and add coverage11 files changed, 200 insertions(+), 84 deletions(-)
apps/server/src/ptyAdapter.ts+2-1| ... | ... | @@ -20,6 +20,7 @@ export interface PtyProcess { |
| 20 | 20 | |
| 21 | 21 | export interface PtySpawnInput { |
| 22 | 22 | shell: string; |
| 23 | args?: string[]; | |
| 23 | 24 | cwd: string; |
| 24 | 25 | cols: number; |
| 25 | 26 | rows: number; |
| ... | ... | @@ -118,7 +119,7 @@ class NodePtyProcess implements PtyProcess { |
| 118 | 119 | export class NodePtyAdapter implements PtyAdapter { |
| 119 | 120 | spawn(input: PtySpawnInput): PtyProcess { |
| 120 | 121 | ensureNodePtySpawnHelperExecutable(); |
| 121 | const ptyProcess = nodePty.spawn(input.shell, [], { | |
| 122 | const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { | |
| 122 | 123 | cwd: input.cwd, |
| 123 | 124 | cols: input.cols, |
| 124 | 125 | rows: input.rows, |
apps/server/src/terminalManager.test.ts+38| ... | ... | @@ -466,4 +466,42 @@ describe("TerminalManager", () => { |
| 466 | 466 | restoreEnv(); |
| 467 | 467 | } |
| 468 | 468 | }); |
| 469 | ||
| 470 | it("injects runtime env overrides into spawned terminals", async () => { | |
| 471 | const { manager, ptyAdapter } = makeManager(); | |
| 472 | await manager.open( | |
| 473 | openInput({ | |
| 474 | env: { | |
| 475 | T3CODE_PROJECT_ROOT: "/repo", | |
| 476 | T3CODE_WORKTREE_PATH: "/repo/worktree-a", | |
| 477 | CUSTOM_FLAG: "1", | |
| 478 | }, | |
| 479 | }), | |
| 480 | ); | |
| 481 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 482 | expect(spawnInput).toBeDefined(); | |
| 483 | if (!spawnInput) return; | |
| 484 | ||
| 485 | expect(spawnInput.env.T3CODE_PROJECT_ROOT).toBe("/repo"); | |
| 486 | expect(spawnInput.env.T3CODE_WORKTREE_PATH).toBe("/repo/worktree-a"); | |
| 487 | expect(spawnInput.env.CUSTOM_FLAG).toBe("1"); | |
| 488 | ||
| 489 | manager.dispose(); | |
| 490 | }); | |
| 491 | ||
| 492 | it("starts zsh with prompt spacer disabled to avoid `%` end markers", async () => { | |
| 493 | if (process.platform === "win32") return; | |
| 494 | const { manager, ptyAdapter } = makeManager(5, { | |
| 495 | shellResolver: () => "/bin/zsh", | |
| 496 | }); | |
| 497 | await manager.open(openInput()); | |
| 498 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 499 | expect(spawnInput).toBeDefined(); | |
| 500 | if (!spawnInput) return; | |
| 501 | ||
| 502 | expect(spawnInput.shell).toBe("/bin/zsh"); | |
| 503 | expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); | |
| 504 | ||
| 505 | manager.dispose(); | |
| 506 | }); | |
| 469 | 507 | }); |
apps/server/src/terminalManager.ts+83-25| ... | ... | @@ -59,6 +59,12 @@ interface TerminalSessionState { |
| 59 | 59 | unsubscribeData: (() => void) | null; |
| 60 | 60 | unsubscribeExit: (() => void) | null; |
| 61 | 61 | hasRunningSubprocess: boolean; |
| 62 | runtimeEnv: Record<string, string> | null; | |
| 63 | } | |
| 64 | ||
| 65 | interface ShellCandidate { | |
| 66 | shell: string; | |
| 67 | args?: string[]; | |
| 62 | 68 | } |
| 63 | 69 | |
| 64 | 70 | function defaultShellResolver(): string { |
| ... | ... | @@ -82,34 +88,54 @@ function normalizeShellCommand(value: string | undefined): string | null { |
| 82 | 88 | return firstToken.replace(/^['"]|['"]$/g, ""); |
| 83 | 89 | } |
| 84 | 90 | |
| 85 | function uniqueShells(shells: Array<string | null>): string[] { | |
| 91 | function shellCandidateFromCommand(command: string | null): ShellCandidate | null { | |
| 92 | if (!command || command.length === 0) return null; | |
| 93 | const shellName = path.basename(command).toLowerCase(); | |
| 94 | if (process.platform !== "win32" && shellName === "zsh") { | |
| 95 | return { shell: command, args: ["-o", "nopromptsp"] }; | |
| 96 | } | |
| 97 | return { shell: command }; | |
| 98 | } | |
| 99 | ||
| 100 | function formatShellCandidate(candidate: ShellCandidate): string { | |
| 101 | if (!candidate.args || candidate.args.length === 0) return candidate.shell; | |
| 102 | return `${candidate.shell} ${candidate.args.join(" ")}`; | |
| 103 | } | |
| 104 | ||
| 105 | function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] { | |
| 86 | 106 | const seen = new Set<string>(); |
| 87 | const ordered: string[] = []; | |
| 88 | for (const shell of shells) { | |
| 89 | if (!shell || shell.length === 0) continue; | |
| 90 | if (seen.has(shell)) continue; | |
| 91 | seen.add(shell); | |
| 92 | ordered.push(shell); | |
| 107 | const ordered: ShellCandidate[] = []; | |
| 108 | for (const candidate of candidates) { | |
| 109 | if (!candidate) continue; | |
| 110 | const key = formatShellCandidate(candidate); | |
| 111 | if (seen.has(key)) continue; | |
| 112 | seen.add(key); | |
| 113 | ordered.push(candidate); | |
| 93 | 114 | } |
| 94 | 115 | return ordered; |
| 95 | 116 | } |
| 96 | 117 | |
| 97 | function resolveShellCandidates(shellResolver: () => string): string[] { | |
| 98 | const requested = normalizeShellCommand(shellResolver()); | |
| 118 | function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { | |
| 119 | const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); | |
| 99 | 120 | |
| 100 | 121 | if (process.platform === "win32") { |
| 101 | return uniqueShells([requested, process.env.ComSpec ?? null, "powershell.exe", "cmd.exe"]); | |
| 122 | return uniqueShellCandidates([ | |
| 123 | requested, | |
| 124 | shellCandidateFromCommand(process.env.ComSpec ?? null), | |
| 125 | shellCandidateFromCommand("powershell.exe"), | |
| 126 | shellCandidateFromCommand("cmd.exe"), | |
| 127 | ]); | |
| 102 | 128 | } |
| 103 | 129 | |
| 104 | return uniqueShells([ | |
| 130 | return uniqueShellCandidates([ | |
| 105 | 131 | requested, |
| 106 | normalizeShellCommand(process.env.SHELL), | |
| 107 | "/bin/zsh", | |
| 108 | "/bin/bash", | |
| 109 | "/bin/sh", | |
| 110 | "zsh", | |
| 111 | "bash", | |
| 112 | "sh", | |
| 132 | shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), | |
| 133 | shellCandidateFromCommand("/bin/zsh"), | |
| 134 | shellCandidateFromCommand("/bin/bash"), | |
| 135 | shellCandidateFromCommand("/bin/sh"), | |
| 136 | shellCandidateFromCommand("zsh"), | |
| 137 | shellCandidateFromCommand("bash"), | |
| 138 | shellCandidateFromCommand("sh"), | |
| 113 | 139 | ]); |
| 114 | 140 | } |
| 115 | 141 | |
| ... | ... | @@ -241,16 +267,33 @@ function shouldExcludeTerminalEnvKey(key: string): boolean { |
| 241 | 267 | return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); |
| 242 | 268 | } |
| 243 | 269 | |
| 244 | function createTerminalSpawnEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { | |
| 270 | function createTerminalSpawnEnv( | |
| 271 | baseEnv: NodeJS.ProcessEnv, | |
| 272 | runtimeEnv?: Record<string, string> | null, | |
| 273 | ): NodeJS.ProcessEnv { | |
| 245 | 274 | const spawnEnv: NodeJS.ProcessEnv = {}; |
| 246 | 275 | for (const [key, value] of Object.entries(baseEnv)) { |
| 247 | 276 | if (value === undefined) continue; |
| 248 | 277 | if (shouldExcludeTerminalEnvKey(key)) continue; |
| 249 | 278 | spawnEnv[key] = value; |
| 250 | 279 | } |
| 280 | if (runtimeEnv) { | |
| 281 | for (const [key, value] of Object.entries(runtimeEnv)) { | |
| 282 | spawnEnv[key] = value; | |
| 283 | } | |
| 284 | } | |
| 251 | 285 | return spawnEnv; |
| 252 | 286 | } |
| 253 | 287 | |
| 288 | function normalizedRuntimeEnv( | |
| 289 | env: Record<string, string> | undefined, | |
| 290 | ): Record<string, string> | null { | |
| 291 | if (!env) return null; | |
| 292 | const entries = Object.entries(env); | |
| 293 | if (entries.length === 0) return null; | |
| 294 | return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right))); | |
| 295 | } | |
| 296 | ||
| 254 | 297 | export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 255 | 298 | private readonly sessions = new Map<string, TerminalSessionState>(); |
| 256 | 299 | private readonly logsDir: string; |
| ... | ... | @@ -307,20 +350,30 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 307 | 350 | unsubscribeData: null, |
| 308 | 351 | unsubscribeExit: null, |
| 309 | 352 | hasRunningSubprocess: false, |
| 353 | runtimeEnv: normalizedRuntimeEnv(input.env), | |
| 310 | 354 | }; |
| 311 | 355 | this.sessions.set(sessionKey, session); |
| 312 | 356 | this.startSession(session, input, "started"); |
| 313 | 357 | return this.snapshot(session); |
| 314 | 358 | } |
| 315 | 359 | |
| 316 | if (existing.cwd !== input.cwd) { | |
| 360 | const nextRuntimeEnv = normalizedRuntimeEnv(input.env); | |
| 361 | const currentRuntimeEnv = existing.runtimeEnv; | |
| 362 | const runtimeEnvChanged = | |
| 363 | JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv); | |
| 364 | ||
| 365 | if (existing.cwd !== input.cwd || runtimeEnvChanged) { | |
| 317 | 366 | this.stopProcess(existing); |
| 318 | 367 | existing.cwd = input.cwd; |
| 368 | existing.runtimeEnv = nextRuntimeEnv; | |
| 319 | 369 | existing.history = ""; |
| 320 | 370 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); |
| 321 | 371 | } else if (existing.status === "exited" || existing.status === "error") { |
| 372 | existing.runtimeEnv = nextRuntimeEnv; | |
| 322 | 373 | existing.history = ""; |
| 323 | 374 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); |
| 375 | } else if (currentRuntimeEnv !== nextRuntimeEnv) { | |
| 376 | existing.runtimeEnv = nextRuntimeEnv; | |
| 324 | 377 | } |
| 325 | 378 | |
| 326 | 379 | if (!existing.process) { |
| ... | ... | @@ -404,11 +457,13 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 404 | 457 | unsubscribeData: null, |
| 405 | 458 | unsubscribeExit: null, |
| 406 | 459 | hasRunningSubprocess: false, |
| 460 | runtimeEnv: normalizedRuntimeEnv(input.env), | |
| 407 | 461 | }; |
| 408 | 462 | this.sessions.set(sessionKey, session); |
| 409 | 463 | } else { |
| 410 | 464 | this.stopProcess(session); |
| 411 | 465 | session.cwd = input.cwd; |
| 466 | session.runtimeEnv = normalizedRuntimeEnv(input.env); | |
| 412 | 467 | } |
| 413 | 468 | |
| 414 | 469 | session.history = ""; |
| ... | ... | @@ -480,19 +535,20 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 480 | 535 | let startedShell: string | null = null; |
| 481 | 536 | try { |
| 482 | 537 | const shellCandidates = resolveShellCandidates(this.shellResolver); |
| 483 | const terminalEnv = createTerminalSpawnEnv(process.env); | |
| 538 | const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); | |
| 484 | 539 | let lastSpawnError: unknown = null; |
| 485 | 540 | |
| 486 | for (const shell of shellCandidates) { | |
| 541 | for (const candidate of shellCandidates) { | |
| 487 | 542 | try { |
| 488 | 543 | ptyProcess = this.ptyAdapter.spawn({ |
| 489 | shell, | |
| 544 | shell: candidate.shell, | |
| 545 | ...(candidate.args ? { args: candidate.args } : {}), | |
| 490 | 546 | cwd: session.cwd, |
| 491 | 547 | cols: session.cols, |
| 492 | 548 | rows: session.rows, |
| 493 | 549 | env: terminalEnv, |
| 494 | 550 | }); |
| 495 | startedShell = shell; | |
| 551 | startedShell = formatShellCandidate(candidate); | |
| 496 | 552 | break; |
| 497 | 553 | } catch (error) { |
| 498 | 554 | lastSpawnError = error; |
| ... | ... | @@ -506,7 +562,9 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 506 | 562 | const detail = |
| 507 | 563 | lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed"; |
| 508 | 564 | const tried = |
| 509 | shellCandidates.length > 0 ? ` Tried shells: ${shellCandidates.join(", ")}.` : ""; | |
| 565 | shellCandidates.length > 0 | |
| 566 | ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.` | |
| 567 | : ""; | |
| 510 | 568 | throw new Error(`${detail}.${tried}`.trim()); |
| 511 | 569 | } |
| 512 | 570 |
apps/server/src/wsServer.test.ts+2| ... | ... | @@ -323,6 +323,8 @@ describe("WebSocket Server", () => { |
| 323 | 323 | }); |
| 324 | 324 | |
| 325 | 325 | it("responds to server.getConfig", async () => { |
| 326 | const fakeHome = makeTempDir("t3code-home-"); | |
| 327 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 326 | 328 | server = createTestServer({ cwd: "/my/workspace" }); |
| 327 | 329 | await server.start(); |
| 328 | 330 | const addr = server.httpServer.address(); |
apps/web/src/components/ChatView.tsx+27-20| ... | ... | @@ -102,7 +102,6 @@ import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; |
| 102 | 102 | import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| 103 | 103 | import { |
| 104 | 104 | commandForProjectScript, |
| 105 | injectEnvIntoShellCommand, | |
| 106 | 105 | nextProjectScriptId, |
| 107 | 106 | projectScriptRuntimeEnv, |
| 108 | 107 | projectScriptIdFromCommand, |
| ... | ... | @@ -545,6 +544,15 @@ export default function ChatView() { |
| 545 | 544 | [composerHighlightedItemId, composerMenuItems], |
| 546 | 545 | ); |
| 547 | 546 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; |
| 547 | const threadTerminalRuntimeEnv = useMemo(() => { | |
| 548 | if (!activeProject) return {}; | |
| 549 | return projectScriptRuntimeEnv({ | |
| 550 | project: { | |
| 551 | cwd: activeProject.cwd, | |
| 552 | }, | |
| 553 | worktreePath: activeThread?.worktreePath ?? null, | |
| 554 | }); | |
| 555 | }, [activeProject, activeThread?.worktreePath]); | |
| 548 | 556 | // Default true while loading to avoid toolbar flicker. |
| 549 | 557 | const isGitRepo = branchesQuery.data?.isRepo ?? true; |
| 550 | 558 | const splitTerminalShortcutLabel = useMemo( |
| ... | ... | @@ -661,13 +669,16 @@ export default function ChatView() { |
| 661 | 669 | env?: Record<string, string>; |
| 662 | 670 | worktreePath?: string | null; |
| 663 | 671 | preferNewTerminal?: boolean; |
| 672 | rememberAsLastInvoked?: boolean; | |
| 664 | 673 | }, |
| 665 | 674 | ) => { |
| 666 | 675 | if (!api || !activeThreadId || !activeProject || !activeThread) return; |
| 667 | setLastInvokedScriptByProjectId((current) => { | |
| 668 | if (current[activeProject.id] === script.id) return current; | |
| 669 | return { ...current, [activeProject.id]: script.id }; | |
| 670 | }); | |
| 676 | if (options?.rememberAsLastInvoked !== false) { | |
| 677 | setLastInvokedScriptByProjectId((current) => { | |
| 678 | if (current[activeProject.id] === script.id) return current; | |
| 679 | return { ...current, [activeProject.id]: script.id }; | |
| 680 | }); | |
| 681 | } | |
| 671 | 682 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; |
| 672 | 683 | const baseTerminalId = |
| 673 | 684 | activeThread.activeTerminalId || |
| ... | ... | @@ -701,20 +712,13 @@ export default function ChatView() { |
| 701 | 712 | } |
| 702 | 713 | setTerminalFocusRequestId((value) => value + 1); |
| 703 | 714 | |
| 704 | const resolvedCommand = injectEnvIntoShellCommand( | |
| 705 | script.command, | |
| 706 | projectScriptRuntimeEnv({ | |
| 707 | project: { | |
| 708 | id: activeProject.id, | |
| 709 | name: activeProject.name, | |
| 710 | cwd: activeProject.cwd, | |
| 711 | }, | |
| 712 | script, | |
| 713 | threadId: activeThreadId, | |
| 714 | worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, | |
| 715 | ...(options?.env ? { extraEnv: options.env } : {}), | |
| 716 | }), | |
| 717 | ); | |
| 715 | const runtimeEnv = projectScriptRuntimeEnv({ | |
| 716 | project: { | |
| 717 | cwd: activeProject.cwd, | |
| 718 | }, | |
| 719 | worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, | |
| 720 | ...(options?.env ? { extraEnv: options.env } : {}), | |
| 721 | }); | |
| 718 | 722 | |
| 719 | 723 | try { |
| 720 | 724 | await api.terminal.open({ |
| ... | ... | @@ -723,11 +727,12 @@ export default function ChatView() { |
| 723 | 727 | cwd: targetCwd, |
| 724 | 728 | cols: SCRIPT_TERMINAL_COLS, |
| 725 | 729 | rows: SCRIPT_TERMINAL_ROWS, |
| 730 | env: runtimeEnv, | |
| 726 | 731 | }); |
| 727 | 732 | await api.terminal.write({ |
| 728 | 733 | threadId: activeThreadId, |
| 729 | 734 | terminalId: targetTerminalId, |
| 730 | data: `${resolvedCommand}\n`, | |
| 735 | data: `${script.command}\r`, | |
| 731 | 736 | }); |
| 732 | 737 | } catch (error) { |
| 733 | 738 | dispatch({ |
| ... | ... | @@ -1337,6 +1342,7 @@ export default function ChatView() { |
| 1337 | 1342 | void runProjectScript(setupScript, { |
| 1338 | 1343 | cwd: result.worktree.path, |
| 1339 | 1344 | worktreePath: result.worktree.path, |
| 1345 | rememberAsLastInvoked: false, | |
| 1340 | 1346 | }); |
| 1341 | 1347 | } |
| 1342 | 1348 | } catch (err) { |
| ... | ... | @@ -1889,6 +1895,7 @@ export default function ChatView() { |
| 1889 | 1895 | api={api} |
| 1890 | 1896 | threadId={activeThread.id} |
| 1891 | 1897 | cwd={gitCwd ?? activeProject.cwd} |
| 1898 | runtimeEnv={threadTerminalRuntimeEnv} | |
| 1892 | 1899 | height={activeThread.terminalHeight} |
| 1893 | 1900 | terminalIds={activeThread.terminalIds} |
| 1894 | 1901 | activeTerminalId={activeThread.activeTerminalId} |
apps/web/src/components/ProjectScriptsControl.tsx+2-2| ... | ... | @@ -6,12 +6,12 @@ import type { |
| 6 | 6 | import { |
| 7 | 7 | BugIcon, |
| 8 | 8 | ChevronDownIcon, |
| 9 | CogIcon, | |
| 10 | 9 | FlaskConicalIcon, |
| 11 | 10 | HammerIcon, |
| 12 | 11 | ListChecksIcon, |
| 13 | 12 | PlayIcon, |
| 14 | 13 | PlusIcon, |
| 14 | SettingsIcon, | |
| 15 | 15 | WrenchIcon, |
| 16 | 16 | } from "lucide-react"; |
| 17 | 17 | import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; |
| ... | ... | @@ -319,7 +319,7 @@ export default function ProjectScriptsControl({ |
| 319 | 319 | openEditDialog(script); |
| 320 | 320 | }} |
| 321 | 321 | > |
| 322 | <CogIcon className="size-3.5" /> | |
| 322 | <SettingsIcon className="size-3.5" /> | |
| 323 | 323 | </Button> |
| 324 | 324 | </span> |
| 325 | 325 | </MenuItem> |
apps/web/src/components/ThreadTerminalDrawer.tsx+8-1| ... | ... | @@ -111,6 +111,7 @@ interface TerminalViewportProps { |
| 111 | 111 | threadId: string; |
| 112 | 112 | terminalId: string; |
| 113 | 113 | cwd: string; |
| 114 | runtimeEnv?: Record<string, string>; | |
| 114 | 115 | focusRequestId: number; |
| 115 | 116 | autoFocus: boolean; |
| 116 | 117 | resizeEpoch: number; |
| ... | ... | @@ -122,6 +123,7 @@ function TerminalViewport({ |
| 122 | 123 | threadId, |
| 123 | 124 | terminalId, |
| 124 | 125 | cwd, |
| 126 | runtimeEnv, | |
| 125 | 127 | focusRequestId, |
| 126 | 128 | autoFocus, |
| 127 | 129 | resizeEpoch, |
| ... | ... | @@ -274,6 +276,7 @@ function TerminalViewport({ |
| 274 | 276 | cwd, |
| 275 | 277 | cols: activeTerminal.cols, |
| 276 | 278 | rows: activeTerminal.rows, |
| 279 | ...(runtimeEnv ? { env: runtimeEnv } : {}), | |
| 277 | 280 | }); |
| 278 | 281 | if (disposed) return; |
| 279 | 282 | activeTerminal.write("\u001bc"); |
| ... | ... | @@ -369,7 +372,7 @@ function TerminalViewport({ |
| 369 | 372 | fitAddonRef.current = null; |
| 370 | 373 | terminal.dispose(); |
| 371 | 374 | }; |
| 372 | }, [api, cwd, terminalId, threadId]); | |
| 375 | }, [api, autoFocus, cwd, runtimeEnv, terminalId, threadId]); | |
| 373 | 376 | |
| 374 | 377 | useEffect(() => { |
| 375 | 378 | if (!autoFocus) return; |
| ... | ... | @@ -414,6 +417,7 @@ interface ThreadTerminalDrawerProps { |
| 414 | 417 | api: NativeApi; |
| 415 | 418 | threadId: string; |
| 416 | 419 | cwd: string; |
| 420 | runtimeEnv?: Record<string, string>; | |
| 417 | 421 | height: number; |
| 418 | 422 | terminalIds: string[]; |
| 419 | 423 | activeTerminalId: string; |
| ... | ... | @@ -463,6 +467,7 @@ export default function ThreadTerminalDrawer({ |
| 463 | 467 | api, |
| 464 | 468 | threadId, |
| 465 | 469 | cwd, |
| 470 | runtimeEnv, | |
| 466 | 471 | height, |
| 467 | 472 | terminalIds, |
| 468 | 473 | activeTerminalId, |
| ... | ... | @@ -778,6 +783,7 @@ export default function ThreadTerminalDrawer({ |
| 778 | 783 | threadId={threadId} |
| 779 | 784 | terminalId={terminalId} |
| 780 | 785 | cwd={cwd} |
| 786 | {...(runtimeEnv ? { runtimeEnv } : {})} | |
| 781 | 787 | focusRequestId={focusRequestId} |
| 782 | 788 | autoFocus={terminalId === resolvedActiveTerminalId} |
| 783 | 789 | resizeEpoch={resizeEpoch} |
| ... | ... | @@ -795,6 +801,7 @@ export default function ThreadTerminalDrawer({ |
| 795 | 801 | threadId={threadId} |
| 796 | 802 | terminalId={resolvedActiveTerminalId} |
| 797 | 803 | cwd={cwd} |
| 804 | {...(runtimeEnv ? { runtimeEnv } : {})} | |
| 798 | 805 | focusRequestId={focusRequestId} |
| 799 | 806 | autoFocus |
| 800 | 807 | resizeEpoch={resizeEpoch} |
apps/web/src/projectScripts.test.ts+2-24| ... | ... | @@ -67,40 +67,19 @@ describe("projectScripts helpers", () => { |
| 67 | 67 | |
| 68 | 68 | it("builds default runtime env for scripts", () => { |
| 69 | 69 | const env = projectScriptRuntimeEnv({ |
| 70 | project: { id: "project-1", name: "acme-web", cwd: "/repo" }, | |
| 71 | script: { | |
| 72 | id: "test", | |
| 73 | name: "Test", | |
| 74 | icon: "test", | |
| 75 | runOnWorktreeCreate: false, | |
| 76 | }, | |
| 77 | threadId: "thread-1", | |
| 70 | project: { cwd: "/repo" }, | |
| 78 | 71 | worktreePath: "/repo/worktree-a", |
| 79 | 72 | }); |
| 80 | 73 | |
| 81 | 74 | expect(env).toMatchObject({ |
| 82 | 75 | T3CODE_PROJECT_ROOT: "/repo", |
| 83 | T3CODE_PROJECT_ID: "project-1", | |
| 84 | T3CODE_PROJECT_NAME: "acme-web", | |
| 85 | T3CODE_THREAD_ID: "thread-1", | |
| 86 | T3CODE_SCRIPT_ID: "test", | |
| 87 | T3CODE_SCRIPT_NAME: "Test", | |
| 88 | T3CODE_SCRIPT_ICON: "test", | |
| 89 | T3CODE_SCRIPT_IS_SETUP: "0", | |
| 90 | 76 | T3CODE_WORKTREE_PATH: "/repo/worktree-a", |
| 91 | 77 | }); |
| 92 | 78 | }); |
| 93 | 79 | |
| 94 | 80 | it("allows overriding runtime env values", () => { |
| 95 | 81 | const env = projectScriptRuntimeEnv({ |
| 96 | project: { id: "project-1", name: "acme-web", cwd: "/repo" }, | |
| 97 | script: { | |
| 98 | id: "setup", | |
| 99 | name: "Setup", | |
| 100 | icon: "configure", | |
| 101 | runOnWorktreeCreate: true, | |
| 102 | }, | |
| 103 | threadId: "thread-1", | |
| 82 | project: { cwd: "/repo" }, | |
| 104 | 83 | extraEnv: { |
| 105 | 84 | T3CODE_PROJECT_ROOT: "/custom-root", |
| 106 | 85 | CUSTOM_FLAG: "1", |
| ... | ... | @@ -108,7 +87,6 @@ describe("projectScripts helpers", () => { |
| 108 | 87 | }); |
| 109 | 88 | |
| 110 | 89 | expect(env.T3CODE_PROJECT_ROOT).toBe("/custom-root"); |
| 111 | expect(env.T3CODE_SCRIPT_IS_SETUP).toBe("1"); | |
| 112 | 90 | expect(env.CUSTOM_FLAG).toBe("1"); |
| 113 | 91 | expect(env.T3CODE_WORKTREE_PATH).toBeUndefined(); |
| 114 | 92 | }); |
apps/web/src/projectScripts.ts-11| ... | ... | @@ -85,12 +85,8 @@ export function injectEnvIntoShellCommand( |
| 85 | 85 | |
| 86 | 86 | interface ProjectScriptRuntimeEnvInput { |
| 87 | 87 | project: { |
| 88 | id: string; | |
| 89 | name: string; | |
| 90 | 88 | cwd: string; |
| 91 | 89 | }; |
| 92 | script: Pick<ProjectScript, "id" | "name" | "icon" | "runOnWorktreeCreate">; | |
| 93 | threadId: string; | |
| 94 | 90 | worktreePath?: string | null; |
| 95 | 91 | extraEnv?: Record<string, string>; |
| 96 | 92 | } |
| ... | ... | @@ -98,13 +94,6 @@ interface ProjectScriptRuntimeEnvInput { |
| 98 | 94 | export function projectScriptRuntimeEnv(input: ProjectScriptRuntimeEnvInput): Record<string, string> { |
| 99 | 95 | const env: Record<string, string> = { |
| 100 | 96 | T3CODE_PROJECT_ROOT: input.project.cwd, |
| 101 | T3CODE_PROJECT_ID: input.project.id, | |
| 102 | T3CODE_PROJECT_NAME: input.project.name, | |
| 103 | T3CODE_THREAD_ID: input.threadId, | |
| 104 | T3CODE_SCRIPT_ID: input.script.id, | |
| 105 | T3CODE_SCRIPT_NAME: input.script.name, | |
| 106 | T3CODE_SCRIPT_ICON: input.script.icon, | |
| 107 | T3CODE_SCRIPT_IS_SETUP: input.script.runOnWorktreeCreate ? "1" : "0", | |
| 108 | 97 | }; |
| 109 | 98 | if (input.worktreePath) { |
| 110 | 99 | env.T3CODE_WORKTREE_PATH = input.worktreePath; |
packages/contracts/src/terminal.test.ts+30| ... | ... | @@ -42,6 +42,36 @@ describe("terminalOpenInputSchema", () => { |
| 42 | 42 | }); |
| 43 | 43 | expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); |
| 44 | 44 | }); |
| 45 | ||
| 46 | it("accepts optional env overrides", () => { | |
| 47 | const parsed = terminalOpenInputSchema.parse({ | |
| 48 | threadId: "thread-1", | |
| 49 | cwd: "/tmp/project", | |
| 50 | cols: 100, | |
| 51 | rows: 24, | |
| 52 | env: { | |
| 53 | T3CODE_PROJECT_ROOT: "/tmp/project", | |
| 54 | CUSTOM_FLAG: "1", | |
| 55 | }, | |
| 56 | }); | |
| 57 | expect(parsed.env).toMatchObject({ | |
| 58 | T3CODE_PROJECT_ROOT: "/tmp/project", | |
| 59 | CUSTOM_FLAG: "1", | |
| 60 | }); | |
| 61 | }); | |
| 62 | ||
| 63 | it("rejects invalid env keys", () => { | |
| 64 | const result = terminalOpenInputSchema.safeParse({ | |
| 65 | threadId: "thread-1", | |
| 66 | cwd: "/tmp/project", | |
| 67 | cols: 100, | |
| 68 | rows: 24, | |
| 69 | env: { | |
| 70 | "bad-key": "1", | |
| 71 | }, | |
| 72 | }); | |
| 73 | expect(result.success).toBe(false); | |
| 74 | }); | |
| 45 | 75 | }); |
| 46 | 76 | |
| 47 | 77 | describe("terminalWriteInputSchema", () => { |
packages/contracts/src/terminal.ts+6| ... | ... | @@ -5,6 +5,11 @@ export const DEFAULT_TERMINAL_ID = "default"; |
| 5 | 5 | const terminalColsSchema = z.number().int().min(20).max(400); |
| 6 | 6 | const terminalRowsSchema = z.number().int().min(5).max(200); |
| 7 | 7 | const terminalIdSchema = z.string().trim().min(1).max(128); |
| 8 | const terminalEnvKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(128); | |
| 9 | const terminalEnvValueSchema = z.string().max(8_192); | |
| 10 | const terminalEnvSchema = z | |
| 11 | .record(terminalEnvKeySchema, terminalEnvValueSchema) | |
| 12 | .refine((env) => Object.keys(env).length <= 128, "Too many terminal env vars"); | |
| 8 | 13 | |
| 9 | 14 | export const terminalThreadInputSchema = z.object({ |
| 10 | 15 | threadId: z.string().trim().min(1), |
| ... | ... | @@ -18,6 +23,7 @@ export const terminalOpenInputSchema = terminalSessionInputSchema.extend({ |
| 18 | 23 | cwd: z.string().trim().min(1), |
| 19 | 24 | cols: terminalColsSchema, |
| 20 | 25 | rows: terminalRowsSchema, |
| 26 | env: terminalEnvSchema.optional(), | |
| 21 | 27 | }); |
| 22 | 28 | |
| 23 | 29 | export const terminalWriteInputSchema = terminalSessionInputSchema.extend({ |