Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1713,6 +1713,25 @@ Folder for a workstation, ended with #83.)
here provisions the App, its token broker or a brokered `gh`. The product refuses the
personal sign-in and relies on what the Machine delivers.

**Addendum 2026-09-29 (#98): a sign-in that shows nothing ends with a reason, and the
journal names it.** A sign-in that shows no challenge within one minute is killed and
ends as `failed` with the fixed code `no-challenge`, instead of waiting out its
lifetime; the linking of gh's SSH key, which has bounded steps of its own, is not
affected. The Launchpad writes the start and the end of each sign-in to its journal:
the tool, the outcome and the fixed reason code, never output, a challenge or an
account. The dialog never waits for an answer of the Launchpad for longer than 45
seconds, never shows the same sentence as its status and its detail, and every end of
a sign-in that did not start, ended early or showed nothing says what to do next.
A wacli that reports `connected` without a challenge was paired before this session:
confirmed by its probe, the session ends as `signed-in` with `already: true` and the
dialog says "already signed in"; this is a sign-in that changed nothing, not a new
pairing. Only the probe confirms it: unconfirmed 20 s after `connected` (and always
within the minute above) with no challenge shown, the process is killed and the
session ends as `not-confirmed`. A session that has shown a challenge keeps its
pairing lifetime. The wacli probe reads `wacli auth status --json`'s own envelope
(`data.authenticated`, `data.phone`, `data.linked_jid`); it read the top level before
and reported a paired wacli as not signed in.

## F20 — One-command first installation; the downloaded executable verifies its own release

**Principal's decision 2026-09-28, implemented in this revision for Linux and macOS.**
Expand Down
30 changes: 27 additions & 3 deletions docs/environment-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,11 @@ request. They run in parallel, 10 s each, with only `PATH`, `HOME` and `XDG_*` i
environment. `signIn` is `{ state: "signed-in" | "signed-out" | "unknown", account?,
organization? }`: signed in when the probe exits 0 and its rules hold (composio exits
0 also when not logged in and counts as signed in only with its JSON line and a
non-empty email), `unknown` for a tool not installed, a timeout or unreadable output.
non-empty email; wacli wraps its answer as `{"success":true,"data":{"authenticated":…,
"linked_jid":…,"phone":…},"error":null}` and counts as signed in only when
`data.authenticated` is `true`, with `data.phone`, else `data.linked_jid`, as the
account (#98: the probe read these fields at the top level before and so reported every
paired wacli as not signed in)), `unknown` for a tool not installed, a timeout or unreadable output.
`account` and `organization` (composio's current organization) are the only things
taken from the output, as plain text of at most 120 characters; the output itself is
never returned or logged. A signed-in gh also carries `ssh` ([F19 addendum
Expand Down Expand Up @@ -214,10 +218,24 @@ arm64. CLI first; the Launchpad serves the same core. The tools set up by an age
for composio the dashboard link (`composio login --no-wait --no-skill-install`, then
`composio login --poll --no-skill-install`); for wacli the WhatsApp QR code drawn in
the terminal on a white background, redrawn when it rotates (`wacli auth --events
--idle-exit 30s`), or with `--phone` the pairing code. The code or link is opened on
--idle-exit 30s`), or with `--phone` the pairing code. wacli prints these as
`--events` NDJSON on stderr whether or not it has a terminal, so the sign-in works
from a Launchpad that runs as a service; the tool gets no terminal (stdin is
`/dev/null`, stdout and stderr are read). The code or link is opened on
any device; nothing is typed into Lazurio and no key is copied. It waits until signed
in (confirmed by the tool's sign-in probe), failed or expired (gh 15 minutes,
composio 10, WhatsApp pairing 5); Ctrl-C cancels and kills the tool's process group.
A wacli that reports `connected` without a QR or pairing code first was paired
before: once its probe confirms it the sign-in ends at once as `signed-in` with
`already: true` ("already signed in", nothing paired or changed) and the process
finishes its sync to its own idle exit (#98). Only the probe confirms: when it has not
confirmed 20 s after `connected` (retried every 2 s) and no code was shown, the
process is killed and the sign-in fails as `not-confirmed`. A tool that shows no
link, code or QR code within a minute is killed and the sign-in fails as
`no-challenge` (or `not-confirmed` after `connected`), instead of waiting out its
lifetime; a session that has shown a code keeps its lifetime; a tool that is
not found fails as `not-installed`, one that cannot be started as `spawn-failed`,
and one that exits before it is signed in as `tool-exit`.
After WhatsApp pairing it waits for the first sync of messages. `--json` prints one
JSON object per state change, the challenge included, because the running command
holds the session. Exit 0 signed in, 1 not, 2 usage or refused.
Expand Down Expand Up @@ -270,7 +288,13 @@ true` for gh only ("Link SSH key"; anything else with it is `400`), and a pendin
login says `step: "ssh-key"` while the key is being linked. No answer carries private
key content or a public key's blob; only the key's path and SHA-256 fingerprint.

A challenge is never written to a log, a file, the Folder or an error. The tools keep
A challenge is never written to a log, a file, the Folder or an error. The Launchpad
writes one JSON line to its journal (the unit's journal on a Machine) when a sign-in
starts and one when it ends: `{"scope":"tools-login","tool":"wacli","event":"start"}`
and `{"scope":"tools-login","tool":"wacli","event":"end","outcome":"failed","reason":"no-challenge"}`,
with the tool, the outcome (`signed-in`, `failed`, `blocked`, `expired`, `cancelled`)
and the fixed reason code only; never the tool's output, a challenge or an account.
The tools keep
their own pending state in their own stores (composio's pending login in
`~/.composio`), which is the tool's custody and is not copied anywhere. On a shared
Environment (the Team preset) a composio or wacli login belongs to the whole
Expand Down
9 changes: 8 additions & 1 deletion docs/launchpad-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,14 @@ warning and "Continue" come first. The dialog then installs (`/api/tools/install
needed and starts the login (`/api/tools/login/start`), and polls
`/api/tools/login/poll` every 2 seconds with the session handle while it is open;
closing it sends `/api/tools/login/cancel`, clears the code, link or QR from the page
and returns focus to the card. What it shows:
and returns focus to the card. Until the tool shows something to act on, the status
line says "Starting the sign-in…" and the body, never the same sentence, says that
Lazurio waits for the tool's first step and for at most a minute (#98). A request that
gets no answer within 45 seconds ends the dialog with "The Launchpad did not answer in
time", and a sign-in that ends as `not-installed`, `spawn-failed`, `tool-exit`,
`not-confirmed` or `no-challenge` shows a sentence that says what to do. A tool that was signed in
before (`signed-in` with `already: true`) is reported as "already signed in" and the
card's state is read again. What it shows:

- gh: the sentence that the code is entered on any device, the code in large
selectable characters (with a spelled-out accessible name) and a link to
Expand Down
42 changes: 34 additions & 8 deletions src/launchpad/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ const en = {
toolsAgentFallback:
"An agent can finish the setup by the written target state of this tool.",
toolsLoginStarting: "Starting the sign-in…",
toolsLoginStartingDetail:
"Lazurio has started the tool's own sign-in and waits for its first step: a link, a code or a QR code. It usually takes a few seconds; after a minute without one the sign-in stops and says why.",
toolsLoginGhText:
"Open the GitHub device page on any device (this computer, another one or your phone) and enter this code:",
toolsLoginGhLink: "Open github.com/login/device in a new tab",
Expand All @@ -243,16 +245,27 @@ const en = {
"Waiting for you to finish. This window checks every 2 seconds.",
toolsLoginSignedIn: "You are signed in to {name}.",
toolsLoginSignedInAs: "You are signed in to {name} as {account}.",
toolsLoginAlreadySignedIn:
"{name} was already signed in on this Machine; nothing was paired or changed.",
toolsLoginAlreadySignedInAs:
"{name} was already signed in on this Machine as {account}; nothing was paired or changed.",
toolsLoginWacliSync:
"WhatsApp now copies your recent messages to this Environment in the background. You can close this window.",
toolsLoginFailureNotInstalled: "The tool is not installed on this Machine.",
toolsLoginFailureNotInstalled:
"The tool is not installed on this Machine. Close this window and choose Install and sign in on its card.",
toolsLoginFailureUrl:
"The tool offered an address that is not its official sign-in page, so it was not shown.",
toolsLoginFailureOutput: "The tool answered in a form Lazurio does not know.",
toolsLoginFailureExit: "The tool ended without completing the sign-in.",
toolsLoginFailureExit:
"The tool ended without completing the sign-in. Try again; if it ends again, finish it with an agent.",
toolsLoginFailureNotConfirmed:
"The tool ended, but its status does not say signed in.",
toolsLoginFailureSpawn: "The tool could not be started.",
"The tool did not complete the sign-in: its status does not say signed in, so it was stopped. Try again; if it happens again, finish it with an agent.",
toolsLoginFailureSpawn:
"The tool could not be started. Try again; if it does not start, finish it with an agent.",
toolsLoginFailureNoChallenge:
"The tool showed no link, code or QR code within a minute, so its sign-in was stopped. Check that this Machine reaches the internet and try again; if it happens again, finish it with an agent.",
toolsLoginNoAnswer:
"The Launchpad did not answer in time. Try again; if it does not answer again, reload the page.",
toolsLoginExpired:
"The sign-in expired before it was finished. Start it again when you are ready.",
toolsLoginEnded:
Expand Down Expand Up @@ -778,6 +791,8 @@ const cs: Record<MessageKey, string> = {
toolsAgentFallback:
"Nastavení může dokončit agent podle sepsaného cílového stavu tohoto nástroje.",
toolsLoginStarting: "Spouští se přihlášení…",
toolsLoginStartingDetail:
"Lazurio spustilo přihlášení nástroje a čeká na jeho první krok: odkaz, kód nebo QR kód. Obvykle to trvá pár sekund; když se do minuty neobjeví, přihlášení skončí a řekne proč.",
toolsLoginGhText:
"Na libovolném zařízení (tomhle počítači, jiném nebo telefonu) otevřete stránku zařízení GitHubu a zadejte tento kód:",
toolsLoginGhLink: "Otevřít github.com/login/device v nové záložce",
Expand All @@ -802,16 +817,27 @@ const cs: Record<MessageKey, string> = {
"Čeká se, až to dokončíte. Okno to ověřuje každé 2 sekundy.",
toolsLoginSignedIn: "Jste přihlášeni do {name}.",
toolsLoginSignedInAs: "Jste přihlášeni do {name} jako {account}.",
toolsLoginAlreadySignedIn:
"{name} už byl na téhle Mašině přihlášený; nic se nepárovalo ani neměnilo.",
toolsLoginAlreadySignedInAs:
"{name} už byl na téhle Mašině přihlášený jako {account}; nic se nepárovalo ani neměnilo.",
toolsLoginWacliSync:
"WhatsApp teď na pozadí kopíruje vaše nedávné zprávy do tohoto Environmentu. Okno můžete zavřít.",
toolsLoginFailureNotInstalled: "Nástroj na téhle Mašině není nainstalovaný.",
toolsLoginFailureNotInstalled:
"Nástroj na téhle Mašině není nainstalovaný. Zavřete okno a na jeho kartě zvolte Nainstalovat a přihlásit.",
toolsLoginFailureUrl:
"Nástroj nabídl adresu, která není jeho oficiální přihlašovací stránkou, a proto se nezobrazila.",
toolsLoginFailureOutput: "Nástroj odpověděl v podobě, kterou Lazurio nezná.",
toolsLoginFailureExit: "Nástroj skončil, aniž by přihlášení dokončil.",
toolsLoginFailureExit:
"Nástroj skončil, aniž by přihlášení dokončil. Zkuste to znovu; když skončí znovu, dokončete to s agentem.",
toolsLoginFailureNotConfirmed:
"Nástroj skončil, ale jeho stav neříká, že je přihlášený.",
toolsLoginFailureSpawn: "Nástroj se nepodařilo spustit.",
"Nástroj přihlášení nedokončil: jeho stav neříká, že je přihlášený, a proto se zastavil. Zkuste to znovu; když se to zopakuje, dokončete to s agentem.",
toolsLoginFailureSpawn:
"Nástroj se nepodařilo spustit. Zkuste to znovu; když se nespustí, dokončete to s agentem.",
toolsLoginFailureNoChallenge:
"Nástroj do minuty neukázal odkaz, kód ani QR kód, a proto se jeho přihlášení zastavilo. Ověřte, že se tahle Mašina dostane na internet, a zkuste to znovu; když se to zopakuje, dokončete to s agentem.",
toolsLoginNoAnswer:
"Launchpad včas neodpověděl. Zkuste to znovu; když zase neodpoví, obnovte stránku.",
toolsLoginExpired:
"Přihlášení vypršelo dřív, než bylo dokončeno. Spusťte ho znovu, až budete připraveni.",
toolsLoginEnded:
Expand Down
4 changes: 4 additions & 0 deletions src/launchpad/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,12 @@ export async function startLaunchpad(
// A running gh session asks the Team rule again, on this Folder's current
// preset, before every step that changes the account or the Machine
// (Principal 2026-09-28).
// The start and the end of each sign-in go to this process's journal (the
// unit's journal on a Machine): the tool and the outcome, nothing else.
const logins = createLoginSessions({
...toolsEnvironment,
journal: (entry) =>
console.log(JSON.stringify({ scope: "tools-login", ...entry })),
...curatedOptions.login,
refused: async (tool, action) =>
githubLoginRefused(await folderPreset(folder), tool, action),
Expand Down
58 changes: 41 additions & 17 deletions src/launchpad/tools-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import type { ToolOverview, ToolsOverview } from "../tools/overview";
import type { ToolSignIn } from "../tools/status";
import type { MessageKey } from "./messages";
import {
answerWithin,
curatedActions,
currentNotes,
installOutcome,
type LoginPhase,
type LoginView,
loginAnswerMs,
loginEndMessage,
loginLink,
loginProgress,
loginStepOrder,
loginSteps,
logoutOutcome,
Expand Down Expand Up @@ -343,19 +346,27 @@ export function createToolsPanel(
current.qr = null;
current.refresh = false;
const turn = ++current.turn;
phase(ssh ? "linking" : "waiting", copy.toolsLoginStarting);
if (loginBody.childElementCount === 0 || phone === undefined)
loginBody.append(element("p", "tools-muted", copy.toolsLoginStarting));
let value: unknown = null;
try {
({ value } = await options.post("/api/tools/login/start", {
// The status names the step; the detail, never the same sentence, says
// what happens and how long at most.
const progress = loginProgress(null, copy);
phase(ssh ? "linking" : "waiting", progress.status);
if (
!ssh &&
progress.detail !== null &&
(loginBody.childElementCount === 0 || phone === undefined)
)
loginBody.append(element("p", "tools-muted", progress.detail));
const answer = await answerWithin(
options.post("/api/tools/login/start", {
tool: current.tool.name,
...(phone === undefined ? {} : { phone }),
...(ssh ? { sshKey: true } : {}),
}));
} catch {}
}),
loginAnswerMs,
);
if (flow !== current || current.turn !== turn) return;
accept(value);
if (!answer.answered) return loginFailed(copy.toolsLoginNoAnswer, false);
accept(answer.value);
}

function schedulePoll() {
Expand All @@ -364,15 +375,25 @@ export function createToolsPanel(
const turn = current.turn;
const handle = current.handle;
current.timer = setTimeout(async () => {
let value: unknown = null;
try {
({ value } = await options.post("/api/tools/login/poll", {
const answer = await answerWithin(
options.post("/api/tools/login/poll", {
tool: current.tool.name,
session: handle,
}));
} catch {}
}),
loginAnswerMs,
);
if (flow !== current || current.turn !== turn) return;
accept(value);
if (!answer.answered) {
// The session is not left running without anyone to watch it.
void options
.post("/api/tools/login/cancel", {
tool: current.tool.name,
session: handle,
})
.catch(() => undefined);
return loginFailed(options.copy().toolsLoginNoAnswer, false);
}
accept(answer.value);
}, 2_000);
}

Expand All @@ -399,7 +420,9 @@ export function createToolsPanel(
if (current.mode === "ssh" && state.challenge !== undefined)
current.refresh = true;
current.failedAt = "waiting";
phase("waiting", copy.toolsLoginWaiting);
// Nothing to act on yet: the status still says it is starting and
// the detail below stays.
phase("waiting", loginProgress(state, copy).status);
showChallenge(state);
}
schedulePoll();
Expand Down Expand Up @@ -562,7 +585,8 @@ export function createToolsPanel(
phase("signed-in", message);
// The status line above already says it; the body adds what follows.
loginBody.replaceChildren(
...(current.tool.name === "wacli"
...(current.tool.name === "wacli" &&
!(state.kind === "signed-in" && state.already === true)
? [element("p", "tools-muted", copy.toolsLoginWacliSync)]
: []),
);
Expand Down
Loading
Loading