diff --git a/apps/desktop/src/main/host.ts b/apps/desktop/src/main/host.ts index a1e8814..d1d0d5d 100644 --- a/apps/desktop/src/main/host.ts +++ b/apps/desktop/src/main/host.ts @@ -35,6 +35,7 @@ import { app, ipcMain } from "electron"; import WebSocket from "ws"; import { join } from "node:path"; import { + type BoxReadiness, type CreateBoxSpec, type CreateProgressEvent, HOST_CH, @@ -71,6 +72,28 @@ const INSTALL_URL = // Default WebSocket port baked into a provisioned box's Tailscale listener (matches // the picker's placeholder). The listener only exists if the box is on Tailscale. const WS_DEFAULT_PORT = 8787; + +// Probe a box's task-readiness (base64'd over SSH to dodge quoting) and self-heal the +// git identity: once the box is signed into GitHub, derive name+email from the account +// — `gh auth login` authenticates but does NOT set the commit identity. `\\(` becomes +// `\(` in the string so jq gets its interpolation syntax. +const READINESS_PROBE = `GH=$(command -v gh || true) +SIGNED=0; LOGIN="" +if [ -n "$GH" ] && "$GH" auth status >/dev/null 2>&1; then + SIGNED=1 + LOGIN=$("$GH" api user -q .login 2>/dev/null || true) + if [ -z "$(git config --global user.name || true)" ] || [ -z "$(git config --global user.email || true)" ]; then + N=$("$GH" api user -q '.name // .login' 2>/dev/null || true) + E=$("$GH" api user -q '"\\(.id)+\\(.login)@users.noreply.github.com"' 2>/dev/null || true) + [ -n "$N" ] && git config --global user.name "$N" + [ -n "$E" ] && git config --global user.email "$E" + fi +fi +echo "gh_installed=$([ -n "$GH" ] && echo 1 || echo 0)" +echo "gh_signed_in=$SIGNED" +echo "gh_login=$LOGIN" +echo "git_name=$(git config --global user.name || true)" +echo "git_email=$(git config --global user.email || true)"`; // Cap a connect: ssh can hang on an auth prompt or an unreachable host with no // error, and the UI must not wait forever. A live daemon replies in well under this. const CONNECT_TIMEOUT_MS = 20_000; @@ -112,6 +135,8 @@ export interface Host { createBox(spec: CreateBoxSpec): Promise; /** Install an agent's CLI on a connected box, streaming the log; returns the login step. */ installAgent(alias: string, agentId: string): Promise; + /** Probe a connected box's task-readiness (and self-heal git identity once signed in). */ + boxReadiness(alias: string): Promise; /** Which provisioning secrets are saved (booleans, never the values). */ secretsStatus(): SecretsStatus; /** Persist provider credentials (encrypted). Returns the new saved-status. */ @@ -442,6 +467,30 @@ export function createHost({ localEngine, broadcast }: HostDeps): Host { return { agentId, loginCommand: agent.loginCommand }; } + async function boxReadiness(alias: string): Promise { + if (!backends.has(alias)) throw new Error(`Not connected to "${alias}".`); + const b64 = Buffer.from(READINESS_PROBE).toString("base64"); + let out = ""; + const r = await sshExec(alias, `echo ${b64} | base64 -d | bash -ls`, { + onData: (chunk) => { + out += chunk; + }, + }); + if (r.code !== 0) { + throw new Error(`Couldn't read "${alias}" readiness (ssh exited ${r.code ?? "on a signal"}).`); + } + const val = (k: string) => out.match(new RegExp(`^${k}=(.*)$`, "m"))?.[1]?.trim() ?? ""; + return { + gh: { + installed: val("gh_installed") === "1", + signedIn: val("gh_signed_in") === "1", + login: val("gh_login") || undefined, + }, + gitName: val("git_name") || undefined, + gitEmail: val("git_email") || undefined, + }; + } + async function provision(alias: string, input: { cloneUrl: string }): Promise { // Provisioning targets a SPECIFIC engine — the aggregate routes by learned id, // but there's no id on the box yet, so call that backend's clone directly. @@ -467,6 +516,7 @@ export function createHost({ localEngine, broadcast }: HostDeps): Host { install, createBox, installAgent, + boxReadiness, secretsStatus, saveSecrets, providerOptions, @@ -490,6 +540,7 @@ export function registerHostIpc(host: Host): void { ipcMain.handle(HOST_CH.installAgent, (_e, alias: string, agentId: string) => host.installAgent(alias, agentId), ); + ipcMain.handle(HOST_CH.boxReadiness, (_e, alias: string) => host.boxReadiness(alias)); ipcMain.handle(HOST_CH.secretsStatus, () => host.secretsStatus()); ipcMain.handle( HOST_CH.saveSecrets, diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 6f567d5..bdc8072 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -126,6 +126,7 @@ const host: AteamHost = { install: (dest, opts) => ipcRenderer.invoke(HOST_CH.install, dest, opts), createBox: (spec) => ipcRenderer.invoke(HOST_CH.createBox, spec), installAgent: (alias, agentId) => ipcRenderer.invoke(HOST_CH.installAgent, alias, agentId), + boxReadiness: (alias) => ipcRenderer.invoke(HOST_CH.boxReadiness, alias), secretsStatus: () => ipcRenderer.invoke(HOST_CH.secretsStatus), saveSecrets: (patch) => ipcRenderer.invoke(HOST_CH.saveSecrets, patch), providerOptions: (token) => ipcRenderer.invoke(HOST_CH.providerOptions, token), diff --git a/apps/desktop/src/renderer/src/components/AgentPicker.tsx b/apps/desktop/src/renderer/src/components/AgentPicker.tsx index a1583fe..86bca69 100644 --- a/apps/desktop/src/renderer/src/components/AgentPicker.tsx +++ b/apps/desktop/src/renderer/src/components/AgentPicker.tsx @@ -121,39 +121,41 @@ export function AgentPicker({ const canInstall = !avail && alias !== null && !!onInstallAgent; return (
- - {canInstall ? ( +
- ) : null} + {canInstall ? ( + + ) : null} +
{installing === a.id && log ? (
 											{log}
diff --git a/apps/desktop/src/renderer/src/components/CreateBoxDialog.tsx b/apps/desktop/src/renderer/src/components/CreateBoxDialog.tsx
index 5bccd67..06ae142 100644
--- a/apps/desktop/src/renderer/src/components/CreateBoxDialog.tsx
+++ b/apps/desktop/src/renderer/src/components/CreateBoxDialog.tsx
@@ -1,7 +1,14 @@
 import { X } from "lucide-react";
 import { useEffect, useRef, useState } from "react";
 import type { AgentDTO } from "@ateam/protocol";
-import type { ProviderOptions } from "../../../shared/host";
+import type { BoxReadiness, ProviderOptions } from "../../../shared/host";
+
+// The one-time OAuth login per agent (mirrors the registry; the renderer can't import it).
+const AGENT_LOGIN: Record = {
+	claude: "claude login",
+	codex: "codex login",
+	opencode: "opencode auth login",
+};
 import { HetznerLogo } from "./HetznerLogo";
 
 // "Create a box" — Ateam stands up a fresh VPS at a provider, generates the SSH key,
@@ -32,8 +39,24 @@ export function CreateBoxDialog({
 	const [error, setError] = useState(null);
 	const [agents, setAgents] = useState([]);
 	const [preinstall, setPreinstall] = useState([]);
+	// After a box is created + connected: its readiness (gh/identity) + installed agents.
+	const [readyAlias, setReadyAlias] = useState(null);
+	const [readyBox, setReadyBox] = useState(null);
+	const [readyAgents, setReadyAgents] = useState([]);
+	const [checking, setChecking] = useState(false);
 	const logRef = useRef(null);
 
+	const checkReadiness = async (alias: string) => {
+		setChecking(true);
+		try {
+			setReadyBox(await window.ateamHost.boxReadiness(alias));
+		} catch {
+			// A probe failure just leaves the checklist partial — not worth blocking on.
+		} finally {
+			setChecking(false);
+		}
+	};
+
 	const loadOptions = async () => {
 		setLoadingOpts(true);
 		setOptsError(null);
@@ -98,8 +121,13 @@ export function CreateBoxDialog({
 				tailscaleAuthKey: tsKey.trim() || undefined,
 				agents: preinstall.length ? preinstall : undefined,
 			});
-			// A created box is always a remote engine (never the local null alias).
-			if (status.alias) onDone(status.alias);
+			// Created + connected (always a remote engine). Show what's left to make it
+			// task-ready (GitHub sign-in, agent logins) instead of closing blind.
+			if (status.alias) {
+				setReadyAlias(status.alias);
+				setReadyAgents(status.info.agents);
+				void checkReadiness(status.alias);
+			}
 		} catch (e) {
 			setError(e instanceof Error ? e.message : String(e));
 		} finally {
@@ -139,7 +167,55 @@ export function CreateBoxDialog({
 							
)} {error &&
{error}
} - {!busy && ( + {!busy && readyAlias ? ( +
+
+ Box created — finish these in the box’s terminal: +
+
    +
  • Engine + Tailscale
  • +
  • + {readyBox?.gh.signedIn + ? `GitHub signed in${readyBox.gh.login ? ` as ${readyBox.gh.login}` : ""}` + : "GitHub — sign in: "} + {!readyBox?.gh.signedIn ? gh auth login : null} +
  • +
  • + {readyBox?.gitName + ? `git identity (${readyBox.gitName})` + : "git identity — sets automatically after GitHub sign-in"} +
  • + {readyAgents.length === 0 ? ( +
  • + no coding agent yet — install one from the agent picker +
  • + ) : ( + readyAgents.map((a) => ( +
  • + {a} — sign in: {AGENT_LOGIN[a] ?? `${a} login`} +
  • + )) + )} +
+
+ + +
+
+ ) : !busy ? (
- )} + ) : null}
) : (
diff --git a/apps/desktop/src/renderer/src/index.css b/apps/desktop/src/renderer/src/index.css index 6fa700b..85817a3 100644 --- a/apps/desktop/src/renderer/src/index.css +++ b/apps/desktop/src/renderer/src/index.css @@ -1903,12 +1903,22 @@ input { display: flex; flex-direction: column; } +.agent-row { + display: flex; + align-items: center; + gap: 6px; + padding-right: 8px; +} +.agent-row > .conn-row { + flex: 1; + min-width: 0; +} .agent-install-btn { display: inline-flex; align-items: center; gap: 5px; - align-self: flex-start; - margin: 0 10px 6px 34px; + flex: none; + margin: 0; background: var(--bg-elev-2); border: 1px solid var(--border); border-radius: 6px; @@ -1957,3 +1967,50 @@ input { color: var(--text); cursor: pointer; } + +.cb-ready { + display: flex; + flex-direction: column; + gap: 10px; +} +.cb-ready-title { + font-size: 13px; + color: var(--text); +} +.cb-ready-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.cb-ready-list li { + font-size: 13px; + color: var(--text-dim); + padding-left: 18px; + position: relative; +} +.cb-ready-list li.done { + color: var(--text); +} +.cb-ready-list li.done::before { + content: "\2713"; + position: absolute; + left: 0; + color: var(--text); +} +.cb-ready-list li.todo::before { + content: "\2192"; + position: absolute; + left: 0; + color: var(--text-dim); +} +.cb-ready-list code { + font-family: ui-monospace, Menlo, monospace; + font-size: 12px; + color: var(--text); + background: var(--bg-elev-2); + padding: 1px 5px; + border-radius: 4px; +} diff --git a/apps/desktop/src/shared/host.ts b/apps/desktop/src/shared/host.ts index 9d3a618..0220d04 100644 --- a/apps/desktop/src/shared/host.ts +++ b/apps/desktop/src/shared/host.ts @@ -22,6 +22,8 @@ export const HOST_CH = { createBox: "host:createBox", /** Install an agent's CLI on a connected box (streamed via evtInstallLog). */ installAgent: "host:installAgent", + /** Probe a connected box's task-readiness (gh installed/signed-in, git identity). */ + boxReadiness: "host:boxReadiness", /** Read/write the encrypted provider credentials (token + Tailscale auth key). */ secretsStatus: "host:secretsStatus", saveSecrets: "host:saveSecrets", @@ -57,6 +59,15 @@ export interface CreateBoxSpec { agents?: string[]; } +/** A connected box's task-readiness — what a task needs beyond the engine. */ +export interface BoxReadiness { + /** GitHub CLI: installed on the box, and signed in (so it can clone private repos). */ + gh: { installed: boolean; signedIn: boolean; login?: string }; + /** The git commit identity (derived from the GitHub login once signed in). */ + gitName?: string; + gitEmail?: string; +} + /** Result of installing an agent's CLI on a box — the login the user runs next. */ export interface InstallAgentResult { agentId: string; @@ -122,6 +133,9 @@ export interface AteamHost { /** Install an agent's CLI on a connected box (streamed via onInstallLog), then * return the one-time OAuth login to run on the box. */ installAgent(alias: string, agentId: string): Promise; + /** Probe a connected box's task-readiness (also derives the git identity once the + * box is signed into GitHub). */ + boxReadiness(alias: string): Promise; /** Which provisioning secrets are already saved (booleans, never the values). */ secretsStatus(): Promise; /** Persist provider credentials (encrypted at rest). Empty string clears one. */