| 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,6 +20,7 @@ export interface PtyProcess { |
| 20 | 20 | ||
| 21 | export interface PtySpawnInput { | 21 | export interface PtySpawnInput { |
| 22 | shell: string; | 22 | shell: string; |
| 23 | args?: string[]; | ||
| 23 | cwd: string; | 24 | cwd: string; |
| 24 | cols: number; | 25 | cols: number; |
| 25 | rows: number; | 26 | rows: number; |
| ... | @@ -118,7 +119,7 @@ class NodePtyProcess implements PtyProcess { | ... | @@ -118,7 +119,7 @@ class NodePtyProcess implements PtyProcess { |
| 118 | export class NodePtyAdapter implements PtyAdapter { | 119 | export class NodePtyAdapter implements PtyAdapter { |
| 119 | spawn(input: PtySpawnInput): PtyProcess { | 120 | spawn(input: PtySpawnInput): PtyProcess { |
| 120 | ensureNodePtySpawnHelperExecutable(); | 121 | ensureNodePtySpawnHelperExecutable(); |
| 121 | const ptyProcess = nodePty.spawn(input.shell, [], { | 122 | const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { |
| 122 | cwd: input.cwd, | 123 | cwd: input.cwd, |
| 123 | cols: input.cols, | 124 | cols: input.cols, |
| 124 | rows: input.rows, | 125 | rows: input.rows, |
apps/server/src/terminalManager.test.ts+38| ... | @@ -466,4 +466,42 @@ describe("TerminalManager", () => { | ... | @@ -466,4 +466,42 @@ describe("TerminalManager", () => { |
| 466 | restoreEnv(); | 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,6 +59,12 @@ interface TerminalSessionState { |
| 59 | unsubscribeData: (() => void) | null; | 59 | unsubscribeData: (() => void) | null; |
| 60 | unsubscribeExit: (() => void) | null; | 60 | unsubscribeExit: (() => void) | null; |
| 61 | hasRunningSubprocess: boolean; | 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 | function defaultShellResolver(): string { | 70 | function defaultShellResolver(): string { |
| ... | @@ -82,34 +88,54 @@ function normalizeShellCommand(value: string | undefined): string | null { | ... | @@ -82,34 +88,54 @@ function normalizeShellCommand(value: string | undefined): string | null { |
| 82 | return firstToken.replace(/^['"]|['"]$/g, ""); | 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 | const seen = new Set<string>(); | 106 | const seen = new Set<string>(); |
| 87 | const ordered: string[] = []; | 107 | const ordered: ShellCandidate[] = []; |
| 88 | for (const shell of shells) { | 108 | for (const candidate of candidates) { |
| 89 | if (!shell || shell.length === 0) continue; | 109 | if (!candidate) continue; |
| 90 | if (seen.has(shell)) continue; | 110 | const key = formatShellCandidate(candidate); |
| 91 | seen.add(shell); | 111 | if (seen.has(key)) continue; |
| 92 | ordered.push(shell); | 112 | seen.add(key); |
| 113 | ordered.push(candidate); | ||
| 93 | } | 114 | } |
| 94 | return ordered; | 115 | return ordered; |
| 95 | } | 116 | } |
| 96 | 117 | ||
| 97 | function resolveShellCandidates(shellResolver: () => string): string[] { | 118 | function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { |
| 98 | const requested = normalizeShellCommand(shellResolver()); | 119 | const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); |
| 99 | 120 | ||
| 100 | if (process.platform === "win32") { | 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 | requested, | 131 | requested, |
| 106 | normalizeShellCommand(process.env.SHELL), | 132 | shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), |
| 107 | "/bin/zsh", | 133 | shellCandidateFromCommand("/bin/zsh"), |
| 108 | "/bin/bash", | 134 | shellCandidateFromCommand("/bin/bash"), |
| 109 | "/bin/sh", | 135 | shellCandidateFromCommand("/bin/sh"), |
| 110 | "zsh", | 136 | shellCandidateFromCommand("zsh"), |
| 111 | "bash", | 137 | shellCandidateFromCommand("bash"), |
| 112 | "sh", | 138 | shellCandidateFromCommand("sh"), |
| 113 | ]); | 139 | ]); |
| 114 | } | 140 | } |
| 115 | 141 | ||
| ... | @@ -241,16 +267,33 @@ function shouldExcludeTerminalEnvKey(key: string): boolean { | ... | @@ -241,16 +267,33 @@ function shouldExcludeTerminalEnvKey(key: string): boolean { |
| 241 | return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); | 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 | const spawnEnv: NodeJS.ProcessEnv = {}; | 274 | const spawnEnv: NodeJS.ProcessEnv = {}; |
| 246 | for (const [key, value] of Object.entries(baseEnv)) { | 275 | for (const [key, value] of Object.entries(baseEnv)) { |
| 247 | if (value === undefined) continue; | 276 | if (value === undefined) continue; |
| 248 | if (shouldExcludeTerminalEnvKey(key)) continue; | 277 | if (shouldExcludeTerminalEnvKey(key)) continue; |
| 249 | spawnEnv[key] = value; | 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 | return spawnEnv; | 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 | export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | 297 | export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 255 | private readonly sessions = new Map<string, TerminalSessionState>(); | 298 | private readonly sessions = new Map<string, TerminalSessionState>(); |
| 256 | private readonly logsDir: string; | 299 | private readonly logsDir: string; |
| ... | @@ -307,20 +350,30 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -307,20 +350,30 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 307 | unsubscribeData: null, | 350 | unsubscribeData: null, |
| 308 | unsubscribeExit: null, | 351 | unsubscribeExit: null, |
| 309 | hasRunningSubprocess: false, | 352 | hasRunningSubprocess: false, |
| 353 | runtimeEnv: normalizedRuntimeEnv(input.env), | ||
| 310 | }; | 354 | }; |
| 311 | this.sessions.set(sessionKey, session); | 355 | this.sessions.set(sessionKey, session); |
| 312 | this.startSession(session, input, "started"); | 356 | this.startSession(session, input, "started"); |
| 313 | return this.snapshot(session); | 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 | this.stopProcess(existing); | 366 | this.stopProcess(existing); |
| 318 | existing.cwd = input.cwd; | 367 | existing.cwd = input.cwd; |
| 368 | existing.runtimeEnv = nextRuntimeEnv; | ||
| 319 | existing.history = ""; | 369 | existing.history = ""; |
| 320 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); | 370 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); |
| 321 | } else if (existing.status === "exited" || existing.status === "error") { | 371 | } else if (existing.status === "exited" || existing.status === "error") { |
| 372 | existing.runtimeEnv = nextRuntimeEnv; | ||
| 322 | existing.history = ""; | 373 | existing.history = ""; |
| 323 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); | 374 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); |
| 375 | } else if (currentRuntimeEnv !== nextRuntimeEnv) { | ||
| 376 | existing.runtimeEnv = nextRuntimeEnv; | ||
| 324 | } | 377 | } |
| 325 | 378 | ||
| 326 | if (!existing.process) { | 379 | if (!existing.process) { |
| ... | @@ -404,11 +457,13 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -404,11 +457,13 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 404 | unsubscribeData: null, | 457 | unsubscribeData: null, |
| 405 | unsubscribeExit: null, | 458 | unsubscribeExit: null, |
| 406 | hasRunningSubprocess: false, | 459 | hasRunningSubprocess: false, |
| 460 | runtimeEnv: normalizedRuntimeEnv(input.env), | ||
| 407 | }; | 461 | }; |
| 408 | this.sessions.set(sessionKey, session); | 462 | this.sessions.set(sessionKey, session); |
| 409 | } else { | 463 | } else { |
| 410 | this.stopProcess(session); | 464 | this.stopProcess(session); |
| 411 | session.cwd = input.cwd; | 465 | session.cwd = input.cwd; |
| 466 | session.runtimeEnv = normalizedRuntimeEnv(input.env); | ||
| 412 | } | 467 | } |
| 413 | 468 | ||
| 414 | session.history = ""; | 469 | session.history = ""; |
| ... | @@ -480,19 +535,20 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -480,19 +535,20 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 480 | let startedShell: string | null = null; | 535 | let startedShell: string | null = null; |
| 481 | try { | 536 | try { |
| 482 | const shellCandidates = resolveShellCandidates(this.shellResolver); | 537 | const shellCandidates = resolveShellCandidates(this.shellResolver); |
| 483 | const terminalEnv = createTerminalSpawnEnv(process.env); | 538 | const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); |
| 484 | let lastSpawnError: unknown = null; | 539 | let lastSpawnError: unknown = null; |
| 485 | 540 | ||
| 486 | for (const shell of shellCandidates) { | 541 | for (const candidate of shellCandidates) { |
| 487 | try { | 542 | try { |
| 488 | ptyProcess = this.ptyAdapter.spawn({ | 543 | ptyProcess = this.ptyAdapter.spawn({ |
| 489 | shell, | 544 | shell: candidate.shell, |
| 545 | ...(candidate.args ? { args: candidate.args } : {}), | ||
| 490 | cwd: session.cwd, | 546 | cwd: session.cwd, |
| 491 | cols: session.cols, | 547 | cols: session.cols, |
| 492 | rows: session.rows, | 548 | rows: session.rows, |
| 493 | env: terminalEnv, | 549 | env: terminalEnv, |
| 494 | }); | 550 | }); |
| 495 | startedShell = shell; | 551 | startedShell = formatShellCandidate(candidate); |
| 496 | break; | 552 | break; |
| 497 | } catch (error) { | 553 | } catch (error) { |
| 498 | lastSpawnError = error; | 554 | lastSpawnError = error; |
| ... | @@ -506,7 +562,9 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -506,7 +562,9 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 506 | const detail = | 562 | const detail = |
| 507 | lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed"; | 563 | lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed"; |
| 508 | const tried = | 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 | throw new Error(`${detail}.${tried}`.trim()); | 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,6 +323,8 @@ describe("WebSocket Server", () => { |
| 323 | }); | 323 | }); |
| 324 | 324 | ||
| 325 | it("responds to server.getConfig", async () => { | 325 | it("responds to server.getConfig", async () => { |
| 326 | const fakeHome = makeTempDir("t3code-home-"); | ||
| 327 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | ||
| 326 | server = createTestServer({ cwd: "/my/workspace" }); | 328 | server = createTestServer({ cwd: "/my/workspace" }); |
| 327 | await server.start(); | 329 | await server.start(); |
| 328 | const addr = server.httpServer.address(); | 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,7 +102,6 @@ import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; |
| 102 | import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; | 102 | import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| 103 | import { | 103 | import { |
| 104 | commandForProjectScript, | 104 | commandForProjectScript, |
| 105 | injectEnvIntoShellCommand, | ||
| 106 | nextProjectScriptId, | 105 | nextProjectScriptId, |
| 107 | projectScriptRuntimeEnv, | 106 | projectScriptRuntimeEnv, |
| 108 | projectScriptIdFromCommand, | 107 | projectScriptIdFromCommand, |
| ... | @@ -545,6 +544,15 @@ export default function ChatView() { | ... | @@ -545,6 +544,15 @@ export default function ChatView() { |
| 545 | [composerHighlightedItemId, composerMenuItems], | 544 | [composerHighlightedItemId, composerMenuItems], |
| 546 | ); | 545 | ); |
| 547 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; | 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 | // Default true while loading to avoid toolbar flicker. | 556 | // Default true while loading to avoid toolbar flicker. |
| 549 | const isGitRepo = branchesQuery.data?.isRepo ?? true; | 557 | const isGitRepo = branchesQuery.data?.isRepo ?? true; |
| 550 | const splitTerminalShortcutLabel = useMemo( | 558 | const splitTerminalShortcutLabel = useMemo( |
| ... | @@ -661,13 +669,16 @@ export default function ChatView() { | ... | @@ -661,13 +669,16 @@ export default function ChatView() { |
| 661 | env?: Record<string, string>; | 669 | env?: Record<string, string>; |
| 662 | worktreePath?: string | null; | 670 | worktreePath?: string | null; |
| 663 | preferNewTerminal?: boolean; | 671 | preferNewTerminal?: boolean; |
| 672 | rememberAsLastInvoked?: boolean; | ||
| 664 | }, | 673 | }, |
| 665 | ) => { | 674 | ) => { |
| 666 | if (!api || !activeThreadId || !activeProject || !activeThread) return; | 675 | if (!api || !activeThreadId || !activeProject || !activeThread) return; |
| 667 | setLastInvokedScriptByProjectId((current) => { | 676 | if (options?.rememberAsLastInvoked !== false) { |
| 668 | if (current[activeProject.id] === script.id) return current; | 677 | setLastInvokedScriptByProjectId((current) => { |
| 669 | return { ...current, [activeProject.id]: script.id }; | 678 | if (current[activeProject.id] === script.id) return current; |
| 670 | }); | 679 | return { ...current, [activeProject.id]: script.id }; |
| 680 | }); | ||
| 681 | } | ||
| 671 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; | 682 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; |
| 672 | const baseTerminalId = | 683 | const baseTerminalId = |
| 673 | activeThread.activeTerminalId || | 684 | activeThread.activeTerminalId || |
| ... | @@ -701,20 +712,13 @@ export default function ChatView() { | ... | @@ -701,20 +712,13 @@ export default function ChatView() { |
| 701 | } | 712 | } |
| 702 | setTerminalFocusRequestId((value) => value + 1); | 713 | setTerminalFocusRequestId((value) => value + 1); |
| 703 | 714 | ||
| 704 | const resolvedCommand = injectEnvIntoShellCommand( | 715 | const runtimeEnv = projectScriptRuntimeEnv({ |
| 705 | script.command, | 716 | project: { |
| 706 | projectScriptRuntimeEnv({ | 717 | cwd: activeProject.cwd, |
| 707 | project: { | 718 | }, |
| 708 | id: activeProject.id, | 719 | worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, |
| 709 | name: activeProject.name, | 720 | ...(options?.env ? { extraEnv: options.env } : {}), |
| 710 | cwd: activeProject.cwd, | 721 | }); |
| 711 | }, | ||
| 712 | script, | ||
| 713 | threadId: activeThreadId, | ||
| 714 | worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, | ||
| 715 | ...(options?.env ? { extraEnv: options.env } : {}), | ||
| 716 | }), | ||
| 717 | ); | ||
| 718 | 722 | ||
| 719 | try { | 723 | try { |
| 720 | await api.terminal.open({ | 724 | await api.terminal.open({ |
| ... | @@ -723,11 +727,12 @@ export default function ChatView() { | ... | @@ -723,11 +727,12 @@ export default function ChatView() { |
| 723 | cwd: targetCwd, | 727 | cwd: targetCwd, |
| 724 | cols: SCRIPT_TERMINAL_COLS, | 728 | cols: SCRIPT_TERMINAL_COLS, |
| 725 | rows: SCRIPT_TERMINAL_ROWS, | 729 | rows: SCRIPT_TERMINAL_ROWS, |
| 730 | env: runtimeEnv, | ||
| 726 | }); | 731 | }); |
| 727 | await api.terminal.write({ | 732 | await api.terminal.write({ |
| 728 | threadId: activeThreadId, | 733 | threadId: activeThreadId, |
| 729 | terminalId: targetTerminalId, | 734 | terminalId: targetTerminalId, |
| 730 | data: `${resolvedCommand}\n`, | 735 | data: `${script.command}\r`, |
| 731 | }); | 736 | }); |
| 732 | } catch (error) { | 737 | } catch (error) { |
| 733 | dispatch({ | 738 | dispatch({ |
| ... | @@ -1337,6 +1342,7 @@ export default function ChatView() { | ... | @@ -1337,6 +1342,7 @@ export default function ChatView() { |
| 1337 | void runProjectScript(setupScript, { | 1342 | void runProjectScript(setupScript, { |
| 1338 | cwd: result.worktree.path, | 1343 | cwd: result.worktree.path, |
| 1339 | worktreePath: result.worktree.path, | 1344 | worktreePath: result.worktree.path, |
| 1345 | rememberAsLastInvoked: false, | ||
| 1340 | }); | 1346 | }); |
| 1341 | } | 1347 | } |
| 1342 | } catch (err) { | 1348 | } catch (err) { |
| ... | @@ -1889,6 +1895,7 @@ export default function ChatView() { | ... | @@ -1889,6 +1895,7 @@ export default function ChatView() { |
| 1889 | api={api} | 1895 | api={api} |
| 1890 | threadId={activeThread.id} | 1896 | threadId={activeThread.id} |
| 1891 | cwd={gitCwd ?? activeProject.cwd} | 1897 | cwd={gitCwd ?? activeProject.cwd} |
| 1898 | runtimeEnv={threadTerminalRuntimeEnv} | ||
| 1892 | height={activeThread.terminalHeight} | 1899 | height={activeThread.terminalHeight} |
| 1893 | terminalIds={activeThread.terminalIds} | 1900 | terminalIds={activeThread.terminalIds} |
| 1894 | activeTerminalId={activeThread.activeTerminalId} | 1901 | activeTerminalId={activeThread.activeTerminalId} |
apps/web/src/components/ProjectScriptsControl.tsx+2-2| ... | @@ -6,12 +6,12 @@ import type { | ... | @@ -6,12 +6,12 @@ import type { |
| 6 | import { | 6 | import { |
| 7 | BugIcon, | 7 | BugIcon, |
| 8 | ChevronDownIcon, | 8 | ChevronDownIcon, |
| 9 | CogIcon, | ||
| 10 | FlaskConicalIcon, | 9 | FlaskConicalIcon, |
| 11 | HammerIcon, | 10 | HammerIcon, |
| 12 | ListChecksIcon, | 11 | ListChecksIcon, |
| 13 | PlayIcon, | 12 | PlayIcon, |
| 14 | PlusIcon, | 13 | PlusIcon, |
| 14 | SettingsIcon, | ||
| 15 | WrenchIcon, | 15 | WrenchIcon, |
| 16 | } from "lucide-react"; | 16 | } from "lucide-react"; |
| 17 | import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; | 17 | import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; |
| ... | @@ -319,7 +319,7 @@ export default function ProjectScriptsControl({ | ... | @@ -319,7 +319,7 @@ export default function ProjectScriptsControl({ |
| 319 | openEditDialog(script); | 319 | openEditDialog(script); |
| 320 | }} | 320 | }} |
| 321 | > | 321 | > |
| 322 | <CogIcon className="size-3.5" /> | 322 | <SettingsIcon className="size-3.5" /> |
| 323 | </Button> | 323 | </Button> |
| 324 | </span> | 324 | </span> |
| 325 | </MenuItem> | 325 | </MenuItem> |
apps/web/src/components/ThreadTerminalDrawer.tsx+8-1| ... | @@ -111,6 +111,7 @@ interface TerminalViewportProps { | ... | @@ -111,6 +111,7 @@ interface TerminalViewportProps { |
| 111 | threadId: string; | 111 | threadId: string; |
| 112 | terminalId: string; | 112 | terminalId: string; |
| 113 | cwd: string; | 113 | cwd: string; |
| 114 | runtimeEnv?: Record<string, string>; | ||
| 114 | focusRequestId: number; | 115 | focusRequestId: number; |
| 115 | autoFocus: boolean; | 116 | autoFocus: boolean; |
| 116 | resizeEpoch: number; | 117 | resizeEpoch: number; |
| ... | @@ -122,6 +123,7 @@ function TerminalViewport({ | ... | @@ -122,6 +123,7 @@ function TerminalViewport({ |
| 122 | threadId, | 123 | threadId, |
| 123 | terminalId, | 124 | terminalId, |
| 124 | cwd, | 125 | cwd, |
| 126 | runtimeEnv, | ||
| 125 | focusRequestId, | 127 | focusRequestId, |
| 126 | autoFocus, | 128 | autoFocus, |
| 127 | resizeEpoch, | 129 | resizeEpoch, |
| ... | @@ -274,6 +276,7 @@ function TerminalViewport({ | ... | @@ -274,6 +276,7 @@ function TerminalViewport({ |
| 274 | cwd, | 276 | cwd, |
| 275 | cols: activeTerminal.cols, | 277 | cols: activeTerminal.cols, |
| 276 | rows: activeTerminal.rows, | 278 | rows: activeTerminal.rows, |
| 279 | ...(runtimeEnv ? { env: runtimeEnv } : {}), | ||
| 277 | }); | 280 | }); |
| 278 | if (disposed) return; | 281 | if (disposed) return; |
| 279 | activeTerminal.write("\u001bc"); | 282 | activeTerminal.write("\u001bc"); |
| ... | @@ -369,7 +372,7 @@ function TerminalViewport({ | ... | @@ -369,7 +372,7 @@ function TerminalViewport({ |
| 369 | fitAddonRef.current = null; | 372 | fitAddonRef.current = null; |
| 370 | terminal.dispose(); | 373 | terminal.dispose(); |
| 371 | }; | 374 | }; |
| 372 | }, [api, cwd, terminalId, threadId]); | 375 | }, [api, autoFocus, cwd, runtimeEnv, terminalId, threadId]); |
| 373 | 376 | ||
| 374 | useEffect(() => { | 377 | useEffect(() => { |
| 375 | if (!autoFocus) return; | 378 | if (!autoFocus) return; |
| ... | @@ -414,6 +417,7 @@ interface ThreadTerminalDrawerProps { | ... | @@ -414,6 +417,7 @@ interface ThreadTerminalDrawerProps { |
| 414 | api: NativeApi; | 417 | api: NativeApi; |
| 415 | threadId: string; | 418 | threadId: string; |
| 416 | cwd: string; | 419 | cwd: string; |
| 420 | runtimeEnv?: Record<string, string>; | ||
| 417 | height: number; | 421 | height: number; |
| 418 | terminalIds: string[]; | 422 | terminalIds: string[]; |
| 419 | activeTerminalId: string; | 423 | activeTerminalId: string; |
| ... | @@ -463,6 +467,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -463,6 +467,7 @@ export default function ThreadTerminalDrawer({ |
| 463 | api, | 467 | api, |
| 464 | threadId, | 468 | threadId, |
| 465 | cwd, | 469 | cwd, |
| 470 | runtimeEnv, | ||
| 466 | height, | 471 | height, |
| 467 | terminalIds, | 472 | terminalIds, |
| 468 | activeTerminalId, | 473 | activeTerminalId, |
| ... | @@ -778,6 +783,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -778,6 +783,7 @@ export default function ThreadTerminalDrawer({ |
| 778 | threadId={threadId} | 783 | threadId={threadId} |
| 779 | terminalId={terminalId} | 784 | terminalId={terminalId} |
| 780 | cwd={cwd} | 785 | cwd={cwd} |
| 786 | {...(runtimeEnv ? { runtimeEnv } : {})} | ||
| 781 | focusRequestId={focusRequestId} | 787 | focusRequestId={focusRequestId} |
| 782 | autoFocus={terminalId === resolvedActiveTerminalId} | 788 | autoFocus={terminalId === resolvedActiveTerminalId} |
| 783 | resizeEpoch={resizeEpoch} | 789 | resizeEpoch={resizeEpoch} |
| ... | @@ -795,6 +801,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -795,6 +801,7 @@ export default function ThreadTerminalDrawer({ |
| 795 | threadId={threadId} | 801 | threadId={threadId} |
| 796 | terminalId={resolvedActiveTerminalId} | 802 | terminalId={resolvedActiveTerminalId} |
| 797 | cwd={cwd} | 803 | cwd={cwd} |
| 804 | {...(runtimeEnv ? { runtimeEnv } : {})} | ||
| 798 | focusRequestId={focusRequestId} | 805 | focusRequestId={focusRequestId} |
| 799 | autoFocus | 806 | autoFocus |
| 800 | resizeEpoch={resizeEpoch} | 807 | resizeEpoch={resizeEpoch} |
apps/web/src/projectScripts.test.ts+2-24| ... | @@ -67,40 +67,19 @@ describe("projectScripts helpers", () => { | ... | @@ -67,40 +67,19 @@ describe("projectScripts helpers", () => { |
| 67 | 67 | ||
| 68 | it("builds default runtime env for scripts", () => { | 68 | it("builds default runtime env for scripts", () => { |
| 69 | const env = projectScriptRuntimeEnv({ | 69 | const env = projectScriptRuntimeEnv({ |
| 70 | project: { id: "project-1", name: "acme-web", cwd: "/repo" }, | 70 | project: { cwd: "/repo" }, |
| 71 | script: { | ||
| 72 | id: "test", | ||
| 73 | name: "Test", | ||
| 74 | icon: "test", | ||
| 75 | runOnWorktreeCreate: false, | ||
| 76 | }, | ||
| 77 | threadId: "thread-1", | ||
| 78 | worktreePath: "/repo/worktree-a", | 71 | worktreePath: "/repo/worktree-a", |
| 79 | }); | 72 | }); |
| 80 | 73 | ||
| 81 | expect(env).toMatchObject({ | 74 | expect(env).toMatchObject({ |
| 82 | T3CODE_PROJECT_ROOT: "/repo", | 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 | T3CODE_WORKTREE_PATH: "/repo/worktree-a", | 76 | T3CODE_WORKTREE_PATH: "/repo/worktree-a", |
| 91 | }); | 77 | }); |
| 92 | }); | 78 | }); |
| 93 | 79 | ||
| 94 | it("allows overriding runtime env values", () => { | 80 | it("allows overriding runtime env values", () => { |
| 95 | const env = projectScriptRuntimeEnv({ | 81 | const env = projectScriptRuntimeEnv({ |
| 96 | project: { id: "project-1", name: "acme-web", cwd: "/repo" }, | 82 | project: { cwd: "/repo" }, |
| 97 | script: { | ||
| 98 | id: "setup", | ||
| 99 | name: "Setup", | ||
| 100 | icon: "configure", | ||
| 101 | runOnWorktreeCreate: true, | ||
| 102 | }, | ||
| 103 | threadId: "thread-1", | ||
| 104 | extraEnv: { | 83 | extraEnv: { |
| 105 | T3CODE_PROJECT_ROOT: "/custom-root", | 84 | T3CODE_PROJECT_ROOT: "/custom-root", |
| 106 | CUSTOM_FLAG: "1", | 85 | CUSTOM_FLAG: "1", |
| ... | @@ -108,7 +87,6 @@ describe("projectScripts helpers", () => { | ... | @@ -108,7 +87,6 @@ describe("projectScripts helpers", () => { |
| 108 | }); | 87 | }); |
| 109 | 88 | ||
| 110 | expect(env.T3CODE_PROJECT_ROOT).toBe("/custom-root"); | 89 | expect(env.T3CODE_PROJECT_ROOT).toBe("/custom-root"); |
| 111 | expect(env.T3CODE_SCRIPT_IS_SETUP).toBe("1"); | ||
| 112 | expect(env.CUSTOM_FLAG).toBe("1"); | 90 | expect(env.CUSTOM_FLAG).toBe("1"); |
| 113 | expect(env.T3CODE_WORKTREE_PATH).toBeUndefined(); | 91 | expect(env.T3CODE_WORKTREE_PATH).toBeUndefined(); |
| 114 | }); | 92 | }); |
apps/web/src/projectScripts.ts-11| ... | @@ -85,12 +85,8 @@ export function injectEnvIntoShellCommand( | ... | @@ -85,12 +85,8 @@ export function injectEnvIntoShellCommand( |
| 85 | 85 | ||
| 86 | interface ProjectScriptRuntimeEnvInput { | 86 | interface ProjectScriptRuntimeEnvInput { |
| 87 | project: { | 87 | project: { |
| 88 | id: string; | ||
| 89 | name: string; | ||
| 90 | cwd: string; | 88 | cwd: string; |
| 91 | }; | 89 | }; |
| 92 | script: Pick<ProjectScript, "id" | "name" | "icon" | "runOnWorktreeCreate">; | ||
| 93 | threadId: string; | ||
| 94 | worktreePath?: string | null; | 90 | worktreePath?: string | null; |
| 95 | extraEnv?: Record<string, string>; | 91 | extraEnv?: Record<string, string>; |
| 96 | } | 92 | } |
| ... | @@ -98,13 +94,6 @@ interface ProjectScriptRuntimeEnvInput { | ... | @@ -98,13 +94,6 @@ interface ProjectScriptRuntimeEnvInput { |
| 98 | export function projectScriptRuntimeEnv(input: ProjectScriptRuntimeEnvInput): Record<string, string> { | 94 | export function projectScriptRuntimeEnv(input: ProjectScriptRuntimeEnvInput): Record<string, string> { |
| 99 | const env: Record<string, string> = { | 95 | const env: Record<string, string> = { |
| 100 | T3CODE_PROJECT_ROOT: input.project.cwd, | 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 | if (input.worktreePath) { | 98 | if (input.worktreePath) { |
| 110 | env.T3CODE_WORKTREE_PATH = input.worktreePath; | 99 | env.T3CODE_WORKTREE_PATH = input.worktreePath; |
packages/contracts/src/terminal.test.ts+30| ... | @@ -42,6 +42,36 @@ describe("terminalOpenInputSchema", () => { | ... | @@ -42,6 +42,36 @@ describe("terminalOpenInputSchema", () => { |
| 42 | }); | 42 | }); |
| 43 | expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); | 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 | describe("terminalWriteInputSchema", () => { | 77 | describe("terminalWriteInputSchema", () => { |
packages/contracts/src/terminal.ts+6| ... | @@ -5,6 +5,11 @@ export const DEFAULT_TERMINAL_ID = "default"; | ... | @@ -5,6 +5,11 @@ export const DEFAULT_TERMINAL_ID = "default"; |
| 5 | const terminalColsSchema = z.number().int().min(20).max(400); | 5 | const terminalColsSchema = z.number().int().min(20).max(400); |
| 6 | const terminalRowsSchema = z.number().int().min(5).max(200); | 6 | const terminalRowsSchema = z.number().int().min(5).max(200); |
| 7 | const terminalIdSchema = z.string().trim().min(1).max(128); | 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 | export const terminalThreadInputSchema = z.object({ | 14 | export const terminalThreadInputSchema = z.object({ |
| 10 | threadId: z.string().trim().min(1), | 15 | threadId: z.string().trim().min(1), |
| ... | @@ -18,6 +23,7 @@ export const terminalOpenInputSchema = terminalSessionInputSchema.extend({ | ... | @@ -18,6 +23,7 @@ export const terminalOpenInputSchema = terminalSessionInputSchema.extend({ |
| 18 | cwd: z.string().trim().min(1), | 23 | cwd: z.string().trim().min(1), |
| 19 | cols: terminalColsSchema, | 24 | cols: terminalColsSchema, |
| 20 | rows: terminalRowsSchema, | 25 | rows: terminalRowsSchema, |
| 26 | env: terminalEnvSchema.optional(), | ||
| 21 | }); | 27 | }); |
| 22 | 28 | ||
| 23 | export const terminalWriteInputSchema = terminalSessionInputSchema.extend({ | 29 | export const terminalWriteInputSchema = terminalSessionInputSchema.extend({ |