| 8542 | } |
| 8543 | |
| 8544 | async executeBash( |
| 8545 | workspaceId: string, |
| 8546 | script: string, |
| 8547 | options?: ExecuteBashOptions, |
| 8548 | command?: string, |
| 8549 | args?: string[] |
| 8550 | ): Promise<Result<BashToolResult>> { |
| 8551 | // Block bash execution while workspace is being removed to prevent races with directory deletion. |
| 8552 | // A common case: subagent calls agent_report → frontend's GitStatusStore triggers a git status |
| 8553 | // refresh → executeBash arrives while remove() is deleting the directory → spawn fails with ENOENT. |
| 8554 | // removingWorkspaces is set for the entire duration of remove(), covering the window between |
| 8555 | // disk deletion and metadata invalidation. |
| 8556 | if (this.removingWorkspaces.has(workspaceId)) { |
| 8557 | return Err(`Workspace ${workspaceId} is being removed`); |
| 8558 | } |
| 8559 | |
| 8560 | // NOTE: This guard must run before any init/runtime operations that could wake a stopped SSH |
| 8561 | // runtime (e.g., Coder workspaces started via `coder ssh --wait=yes`). |
| 8562 | if (this.archivingWorkspaces.has(workspaceId)) { |
| 8563 | return Err(`Workspace ${workspaceId} is being archived; cannot execute bash`); |
| 8564 | } |
| 8565 | |
| 8566 | const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId); |
| 8567 | if (!metadataResult.success) { |
| 8568 | return Err(`Failed to get workspace metadata: ${metadataResult.error}`); |
| 8569 | } |
| 8570 | |
| 8571 | const metadata = metadataResult.data; |
| 8572 | if (isWorkspaceArchived(metadata.archivedAt, metadata.unarchivedAt)) { |
| 8573 | return Err(`Workspace ${workspaceId} is archived; cannot execute bash`); |
| 8574 | } |
| 8575 | |
| 8576 | // Wait for workspace initialization (container creation, code sync, etc.) |
| 8577 | // Same behavior as AI tools - 5 min timeout, then proceeds anyway |
| 8578 | await this.initStateManager.waitForInit(workspaceId); |
| 8579 | |
| 8580 | try { |
| 8581 | // Get the persisted workspace entry from config. Multi-project git command mode needs the |
| 8582 | // workspace checkout path rather than metadata.projectPath, and other path-addressable |
| 8583 | // runtimes also reuse the persisted workspace root shown in the Explorer. |
| 8584 | const workspace = this.config.findWorkspace(workspaceId); |
| 8585 | if (!workspace) { |
| 8586 | return Err(`Workspace ${workspaceId} not found in config`); |
| 8587 | } |
| 8588 | |
| 8589 | const multiProjectRuntimes = isMultiProject(metadata) |
| 8590 | ? getProjects(metadata).map((project) => ({ |
| 8591 | projectPath: project.projectPath, |
| 8592 | projectName: project.projectName, |
| 8593 | runtime: createRuntime(metadata.runtimeConfig, { |
| 8594 | projectPath: project.projectPath, |
| 8595 | workspaceName: metadata.name, |
| 8596 | workspacePath: isSSHRuntime(metadata.runtimeConfig) |
| 8597 | ? getWorkspacePathHintForProject( |
| 8598 | { |
| 8599 | workspaceId, |
| 8600 | workspaceName: metadata.name, |
| 8601 | workspacePath: workspace.workspacePath, |