fix: add SSH key selection to workspace tool starter + docker compose policy

tool-starter.tsx was hardcoding ssh_key_ids=[] and only showing a read-only
SSH key status. Users couldn't select keys when starting tools from workspaces.

Changes:
- tool-starter.tsx: add checkboxes for SSH key selection with repo key
  pre-selected, pass selected keys to createInstance/startInstance
- AGENTS.md: add explicit rule forbidding docker compose commands without
  user approval and proper isolation

The web container must be rebuilt to pick up the frontend changes:
  docker compose up -d --build web

Quality gates: tsc clean, pytest (19 passed, 1 skipped)
This commit is contained in:
Alex Blank
2026-06-02 15:40:20 +02:00
parent 37134b8c18
commit fc75eeb76d
3 changed files with 61 additions and 23 deletions
+1
View File
@@ -75,6 +75,7 @@ Do not:
* Introduce new dependencies without clear justification. * Introduce new dependencies without clear justification.
* Treat existing code as more authoritative than OpenSpec for intended behavior. * Treat existing code as more authoritative than OpenSpec for intended behavior.
* Decide product behavior silently when the spec is unclear. * Decide product behavior silently when the spec is unclear.
* Run `docker compose` commands (build, up, down, etc.) without explicit user approval and proper isolation (e.g., feature branches, separate worktrees, or staged rollouts). Docker Compose operations are deployment-level changes that can affect running services, shared volumes, and network state. Always ask first.
If scope must change, propose an OpenSpec update first. If scope must change, propose an OpenSpec update first.
+5 -7
View File
@@ -187,9 +187,7 @@ class TerminalSession:
try: try:
data = os.read(self._master_fd, 4096) data = os.read(self._master_fd, 4096)
except (OSError, IOError) as exc: except (OSError, IOError) as exc:
logger.debug( logger.debug("PTY read error for session %s: %s", self.session_id, exc)
"PTY read error for session %s: %s", self.session_id, exc
)
self._handle_eof() self._handle_eof()
return return
@@ -320,7 +318,9 @@ class TerminalSession:
self._websockets.clear() self._websockets.clear()
for ws in dead_sockets: for ws in dead_sockets:
try: try:
asyncio.create_task(ws.close(code=4001, reason="Session process exited")) asyncio.create_task(
ws.close(code=4001, reason="Session process exited")
)
except Exception: except Exception:
pass pass
logger.info("Session %s EOF handled, websockets closed", self.session_id) logger.info("Session %s EOF handled, websockets closed", self.session_id)
@@ -333,9 +333,7 @@ class TerminalSession:
os.write(self._master_fd, data) os.write(self._master_fd, data)
self.last_activity = time.time() self.last_activity = time.time()
except (OSError, IOError) as exc: except (OSError, IOError) as exc:
logger.debug( logger.debug("PTY write error for session %s: %s", self.session_id, exc)
"PTY write error for session %s: %s", self.session_id, exc
)
self._handle_eof() self._handle_eof()
def _set_terminal_size(self, cols: int, rows: int) -> None: def _set_terminal_size(self, cols: int, rows: int) -> None:
+55 -16
View File
@@ -31,6 +31,7 @@ export function ToolStarter({
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]); const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [sshKeysLoading, setSshKeysLoading] = useState(true); const [sshKeysLoading, setSshKeysLoading] = useState(true);
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -89,14 +90,18 @@ export function ToolStarter({
try { try {
const data = await listSSHKeys(); const data = await listSSHKeys();
setSshKeys(data); setSshKeys(data);
} catch { // Auto-select the repository's SSH key if available
// ignore if (workspace.repo_ssh_key_id) {
setSelectedSshKeyIds([workspace.repo_ssh_key_id]);
}
} catch (err) {
console.error("Failed to load SSH keys:", err);
} finally { } finally {
setSshKeysLoading(false); setSshKeysLoading(false);
} }
}; };
void load(); void load();
}, []); }, [workspace.repo_ssh_key_id]);
const repoHasSshKey = !!workspace.repo_ssh_key_id; const repoHasSshKey = !!workspace.repo_ssh_key_id;
const repoSshKey = sshKeys.find((k) => k.id === workspace.repo_ssh_key_id); const repoSshKey = sshKeys.find((k) => k.id === workspace.repo_ssh_key_id);
@@ -119,7 +124,7 @@ export function ToolStarter({
undefined, undefined,
undefined, undefined,
selectedProfileId || undefined, selectedProfileId || undefined,
[], selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
workspace.id, workspace.id,
); );
await startInstance( await startInstance(
@@ -127,6 +132,7 @@ export function ToolStarter({
workspace.repo_id, workspace.repo_id,
instance.id, instance.id,
selectedProfileId || undefined, selectedProfileId || undefined,
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
); );
onStarted(instance); onStarted(instance);
} catch (err) { } catch (err) {
@@ -208,20 +214,53 @@ export function ToolStarter({
</div> </div>
)} )}
{/* SSH Key Status */} {/* SSH Key Selection */}
<div className="form-group ssh-key-status"> <div className="form-group ssh-key-selection">
<label>SSH Key</label> <label>SSH Keys</label>
{sshKeysLoading ? ( {sshKeysLoading ? (
<span className="muted">Checking...</span> <span className="muted">Loading SSH keys...</span>
) : repoHasSshKey ? ( ) : sshKeys.length === 0 ? (
<span className="success-text"> <span className="muted">No SSH keys configured.</span>
<Icon name="success" size="sm" />{" "}
{repoSshKey?.name || "SSH key assigned"}
</span>
) : ( ) : (
<span className="warning-text"> <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
<Icon name="warning" size="sm" /> No SSH key assigned to repository {sshKeys.map((key) => (
</span> <label
key={key.id}
className="checkbox-label"
style={{
display: "flex",
alignItems: "center",
gap: "0.25rem",
padding: "0.375rem 0.75rem",
background: "var(--panel)",
borderRadius: "0.375rem",
border: "1px solid var(--border)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={selectedSshKeyIds.includes(key.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedSshKeyIds((prev) => [...prev, key.id]);
} else {
setSelectedSshKeyIds((prev) =>
prev.filter((id) => id !== key.id),
);
}
}}
disabled={starting}
/>
{key.name}
</label>
))}
</div>
)}
{!sshKeysLoading && repoHasSshKey && repoSshKey && (
<div className="hint" style={{ marginTop: "0.5rem" }}>
Repository key <strong>{repoSshKey.name}</strong> is pre-selected.
</div>
)} )}
</div> </div>