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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user