diff --git a/docs/decisions.md b/docs/decisions.md index 13af33fb..914ca82a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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.** diff --git a/docs/environment-tools.md b/docs/environment-tools.md index 7ff58a63..18adb838 100644 --- a/docs/environment-tools.md +++ b/docs/environment-tools.md @@ -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 @@ -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. @@ -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 diff --git a/docs/launchpad-development.md b/docs/launchpad-development.md index 0d65f490..663ae8e0 100644 --- a/docs/launchpad-development.md +++ b/docs/launchpad-development.md @@ -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 diff --git a/src/launchpad/messages.ts b/src/launchpad/messages.ts index 4c17ac1b..a874d633 100644 --- a/src/launchpad/messages.ts +++ b/src/launchpad/messages.ts @@ -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", @@ -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: @@ -778,6 +791,8 @@ const cs: Record = { 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", @@ -802,16 +817,27 @@ const cs: Record = { "Č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: diff --git a/src/launchpad/server.ts b/src/launchpad/server.ts index 461ee725..d92de1c8 100644 --- a/src/launchpad/server.ts +++ b/src/launchpad/server.ts @@ -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), diff --git a/src/launchpad/tools-panel.ts b/src/launchpad/tools-panel.ts index 31a0f789..0131d663 100644 --- a/src/launchpad/tools-panel.ts +++ b/src/launchpad/tools-panel.ts @@ -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, @@ -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() { @@ -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); } @@ -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(); @@ -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)] : []), ); diff --git a/src/launchpad/tools-view.ts b/src/launchpad/tools-view.ts index 3044cd92..841755b1 100644 --- a/src/launchpad/tools-view.ts +++ b/src/launchpad/tools-view.ts @@ -553,6 +553,7 @@ const loginFailures = [ "spawn-failed", "not-signed-in", "environment-unreadable", + "no-challenge", ] as const; function parseKeyFacts(input: unknown): SshKeyFacts | null { @@ -680,6 +681,7 @@ export function parseLoginState(input: unknown): LoginView | null { case "signed-in": { if (!optionalText(value.account) || !optionalText(value.organization)) return null; + if (value.already !== undefined && value.already !== true) return null; const ssh = value.ssh === undefined ? undefined : parseSshLink(value.ssh); if (ssh === null) return null; return { @@ -690,6 +692,7 @@ export function parseLoginState(input: unknown): LoginView | null { ? {} : { organization: value.organization }), ...(ssh === undefined ? {} : { ssh }), + ...(value.already === true ? { already: true as const } : {}), }; } case "pending": { @@ -966,6 +969,7 @@ export function loginEndMessage( "spawn-failed": "toolsLoginFailureSpawn", "not-signed-in": "toolsLoginFailureNotSignedIn", "environment-unreadable": "toolsLoginFailureEnvironment", + "no-challenge": "toolsLoginFailureNoChallenge", }; return { message: copy[reasons[state.reason]], @@ -983,16 +987,71 @@ export function loginEndMessage( return { message: copy.toolsLoginEnded, agent: false, retry: true }; } +/** What the dialog's status line and detail say while a sign-in runs: before + * the first answer and while the tool has shown nothing to act on, the status + * names the step and the detail says what happens and for how long at most, + * never the same sentence twice; with a challenge the status says it waits + * for you and the challenge is the detail. */ +export function loginProgress( + state: LoginView | null, + copy: Copy, +): Readonly<{ status: string; detail: string | null }> { + if (state?.kind === "pending" && state.step === "ssh-key") + return { status: copy.toolsLoginLinking, detail: null }; + if (state?.kind === "pending" && state.challenge !== undefined) + return { status: copy.toolsLoginWaiting, detail: null }; + return { + status: copy.toolsLoginStarting, + detail: copy.toolsLoginStartingDetail, + }; +} + +/** How long the dialog waits for one answer of the Launchpad: longer than + * the start's own wait for the first challenge (20 s). */ +export const loginAnswerMs = 45_000; + +/** One request of the dialog, awaited at most `ms`: a request that never + * settles ends as not answered instead of leaving the dialog waiting. A + * failed request answers `null`, which no parser accepts. */ +export async function answerWithin( + request: Promise>, + ms: number, +): Promise> { + let timer: ReturnType | undefined; + const late = new Promise<{ answered: false }>((resolve) => { + timer = setTimeout(() => resolve({ answered: false }), ms); + }); + try { + return await Promise.race([ + request.then( + ({ value }) => ({ answered: true as const, value }), + () => ({ answered: true as const, value: null }), + ), + late, + ]); + } finally { + clearTimeout(timer); + } +} + /** "You are signed in to gh as octocat (Org)." */ export function signedInMessage(state: LoginView, copy: Copy): string { if (state.kind !== "signed-in") return ""; + // Signed in before this sign-in started (#98): nothing was paired now. + const already = state.already === true; if (state.account === undefined) - return fill(copy.toolsLoginSignedIn, { name: state.tool }); + return fill( + already ? copy.toolsLoginAlreadySignedIn : copy.toolsLoginSignedIn, + { name: state.tool }, + ); const account = state.organization === undefined ? state.account : `${state.account} (${state.organization})`; - return fill(copy.toolsLoginSignedInAs, { name: state.tool, account }); + return fill( + already ? copy.toolsLoginAlreadySignedInAs : copy.toolsLoginSignedInAs, + { name: state.tool, account }, + ); } export type OrganizationChoice = Readonly<{ diff --git a/src/tools/catalog.ts b/src/tools/catalog.ts index bc4cd402..1782b7df 100644 --- a/src/tools/catalog.ts +++ b/src/tools/catalog.ts @@ -240,10 +240,16 @@ export const toolCatalog: readonly ToolEntry[] = Object.freeze([ setup: "launchpad", signInProbe: { argv: ["auth", "status", "--json", "--read-only"], + // wacli wraps every `--json` answer in its envelope (`internal/out` + // `WriteJSON`): `{"success":true,"data":{"authenticated":…, + // "linked_jid":…,"phone":…},"error":null}` (#98). account: { kind: "json", - paths: [["phone"], ["linked_jid"]], - flag: ["authenticated"], + paths: [ + ["data", "phone"], + ["data", "linked_jid"], + ], + flag: ["data", "authenticated"], }, }, installation: installation( diff --git a/src/tools/curated-cli.ts b/src/tools/curated-cli.ts index 20cddd4d..e8bbcfd7 100644 --- a/src/tools/curated-cli.ts +++ b/src/tools/curated-cli.ts @@ -202,7 +202,8 @@ const failureText: Record = { "the tool offered an address that is not its official sign-in page, so it was not shown", "unexpected-output": "the tool answered in a form Lazurio does not know", "tool-exit": "the tool ended without completing the sign-in", - "not-confirmed": "the tool ended, but its status does not say signed in", + "not-confirmed": + "the tool did not complete it: its status does not say signed in, so it was stopped", "invalid-phone": "the phone number is not an international number (+ country code and number)", "spawn-failed": "the tool could not be started", @@ -210,6 +211,8 @@ const failureText: Record = { "gh is not signed in on this Machine; sign in first: lazurio tools login gh", "environment-unreadable": "the kind of this Environment could not be read, so the sign-in stopped before changing anything further", + "no-challenge": + "the tool showed no link, code or QR code within a minute, so it was stopped; check that this Machine reaches the internet and try again", }; const sshFailureText: Record = { @@ -314,11 +317,15 @@ function loginLines(state: LoginState): string[] { } case "signed-in": return [ - state.account === undefined - ? `${state.tool}: signed in.` - : `${state.tool}: signed in as ${state.account}${ - state.organization === undefined ? "" : ` (${state.organization})` - }.`, + `${state.tool}: ${state.already === true ? "already signed in on this Machine" : "signed in"}${ + state.account === undefined + ? "" + : ` as ${state.account}${ + state.organization === undefined + ? "" + : ` (${state.organization})` + }` + }${state.already === true ? "; nothing was paired or changed" : ""}.`, ...(state.tool === "gh" && state.ssh !== undefined ? sshLines(state.account, state.ssh) : []), diff --git a/src/tools/login.ts b/src/tools/login.ts index a781a985..dd5e8571 100644 --- a/src/tools/login.ts +++ b/src/tools/login.ts @@ -66,6 +66,9 @@ export type LoginState = /** gh only: whether `git clone git@github.com:…` works as `account` * (decision F19, addendum 2026-09-28). A gh sign-in always says it. */ ssh?: SshLink; + /** The tool was signed in before this session: it connected without + * showing a challenge, and its probe confirmed it (#98). */ + already?: true; }> | Readonly<{ kind: "failed"; tool: string; reason: LoginFailure }> /** gh on a Team Environment (Principal 2026-09-28): the Environment became @@ -91,7 +94,21 @@ export type LoginFailure = | "not-signed-in" /** The kind of Environment could not be read before a step that changes * the account or the Machine, so the session stopped (fail closed). */ - | "environment-unreadable"; + | "environment-unreadable" + /** The tool ran but showed no challenge (link, code, QR or pairing code) + * within the bound, so the session stopped and its process was killed. */ + | "no-challenge"; + +/** One line of the owner's journal about a curated sign-in: the tool and the + * outcome, never the tool's output, a challenge or an account. */ +export type LoginJournalEntry = + | Readonly<{ tool: string; event: "start" }> + | Readonly<{ + tool: string; + event: "end"; + outcome: "signed-in" | "failed" | "blocked" | "expired" | "cancelled"; + reason?: string; + }>; export type LogoutResult = | Readonly<{ @@ -149,6 +166,15 @@ export type LoginEnvironment = Readonly<{ lifetimes?: Partial>; /** How long `start` waits for the first challenge. */ firstChallengeMs?: number; + /** How long a sign-in may run without showing its first challenge before + * it fails as `no-challenge` (default one minute). */ + challengeMs?: number; + /** How long after `connected` without a challenge the probe may take to + * confirm before the sign-in fails as `not-confirmed` (default 20 s). */ + connectedMs?: number; + /** Where the owner journals the start and the end of each sign-in (the + * Launchpad: its unit's journal). Absent: nothing is journaled. */ + journal?: (entry: LoginJournalEntry) => void; probeIntervalMs?: number; /** How long wacli's bootstrap sync may run on after pairing. */ backgroundMs?: number; @@ -170,6 +196,13 @@ const defaultLifetimes = { composio: 10 * 60_000, wacli: 5 * 60_000, }; +// A tool that shows nothing to act on for a minute is not starting its +// sign-in: gh prints its code and composio its link at once, and wacli's +// first QR code follows its connection to WhatsApp within seconds. +const defaultChallengeMs = 60_000; +// After wacli's `connected` without a code, its probe has this long to +// confirm the sign-in (retried every 2 s), within the minute above. +const defaultConnectedMs = 20_000; const probeTimeoutMs = 10_000; // Linking the SSH key after the sign-in: every step is bounded by 30 s. const sshLinkMs = 5 * 60_000; @@ -324,6 +357,10 @@ type Session = { action: "login" | "ssh-key"; expiresAt: number; challenge?: LoginChallenge; + /** A challenge was shown at least once. */ + challenged: boolean; + /** wacli reported `connected`: the probe decides from here, bounded. */ + connected?: boolean; step?: "ssh-key"; processes: Set; timers: Set>; @@ -389,6 +426,23 @@ export function createLoginSessions(environment: LoginEnvironment) { } } + // The journal names the tool and the outcome only: a challenge, an account + // or anything the tool printed never reaches it. + const journal = (entry: LoginJournalEntry) => { + try { + environment.journal?.(entry); + } catch {} + }; + const ended = (tool: string, outcome: Terminal) => + journal({ + tool, + event: "end", + outcome: outcome.kind, + ...(outcome.kind === "failed" || outcome.kind === "blocked" + ? { reason: outcome.reason } + : {}), + }); + const notify = (session: Session) => { session.sequence++; for (const waiter of session.waiters) waiter(); @@ -410,6 +464,7 @@ export function createLoginSessions(environment: LoginEnvironment) { session.processes.clear(); if (sessions.get(session.tool) === session) sessions.delete(session.tool); if (outcome.kind !== "cancelled") finished.set(session.tool, session); + ended(session.tool, outcome); notify(session); } const fail = (session: Session, reason: LoginFailure) => @@ -444,6 +499,7 @@ export function createLoginSessions(environment: LoginEnvironment) { const setChallenge = (session: Session, challenge: LoginChallenge) => { if (!current(session)) return; session.challenge = challenge; + session.challenged = true; notify(session); }; const later = (session: Session, ms: number, action: () => void) => { @@ -485,6 +541,7 @@ export function createLoginSessions(environment: LoginEnvironment) { ...(signIn.organization === undefined ? {} : { organization: signIn.organization }), + ...(session.challenged ? {} : { already: true as const }), }, keep, ); @@ -806,12 +863,28 @@ export function createLoginSessions(environment: LoginEnvironment) { code: code.toUpperCase(), sequence, }); - } else if (name === "connected") void check(); + } else if (name === "connected") { + // Connected without a code first: wacli was signed in already + // (#98), which only the probe confirms; after a code: paired now. + // Unconfirmed and still without a code, the session ends as + // not-confirmed a bounded time after the first `connected`. + if (session.connected !== true && !session.challenged) + later( + session, + environment.connectedMs ?? defaultConnectedMs, + () => { + if (current(session) && !session.challenged) + fail(session, "not-confirmed"); + }, + ); + session.connected = true; + void check(); + } }), ); track(session, child); // Paired means: the read-only status probe says authenticated. It runs - // while a challenge is shown, one at a time. + // while a challenge is shown or after `connected`, one at a time. const check = async () => { if (session.probing || !current(session) || signedIn) return; session.probing = true; @@ -824,7 +897,8 @@ export function createLoginSessions(environment: LoginEnvironment) { }; const tick = () => { if (!current(session)) return; - if (session.challenge !== undefined) void check(); + if (session.challenge !== undefined || session.connected === true) + void check(); later(session, environment.probeIntervalMs ?? 2_000, tick); }; later(session, environment.probeIntervalMs ?? 2_000, tick); @@ -884,20 +958,27 @@ export function createLoginSessions(environment: LoginEnvironment) { throw new Error("Only wacli pairs with a phone number"); if (options.sshKey === true && name !== "gh") throw new Error("Only gh links an SSH key"); + journal({ tool: name, event: "start" }); + // Ended before a session exists: journaled like every other end. + const refused = (reason: LoginFailure): LoginState => { + const outcome = { kind: "failed", tool: name, reason } as const; + ended(name, outcome); + return outcome; + }; const phone = options.phone === undefined ? undefined : normalizePhone(options.phone); if (options.phone !== undefined && phone === undefined) - return { kind: "failed", tool: name, reason: "invalid-phone" }; + return refused("invalid-phone"); cancelRunning(name); const path = await locate(entry); - if (path === undefined) - return { kind: "failed", tool: name, reason: "not-installed" }; + if (path === undefined) return refused("not-installed"); const lifetime = lifetimes[name as keyof typeof lifetimes]; const session: Session = { id: randomBytes(16).toString("hex"), tool: name, action: options.sshKey === true ? "ssh-key" : "login", expiresAt: now() + lifetime, + challenged: false, processes: new Set(), timers: new Set(), waiters: new Set(), @@ -913,6 +994,21 @@ export function createLoginSessions(environment: LoginEnvironment) { else finish(session, { kind: "expired", tool: name }); }; later(session, lifetime, expire); + // A sign-in that shows nothing to act on within the bound ends with a + // reason instead of waiting out its lifetime. The linking of gh's SSH + // key has bounded steps of its own and shows no challenge. + if (session.action === "login") + later(session, environment.challengeMs ?? defaultChallengeMs, () => { + if ( + current(session) && + !session.challenged && + session.step === undefined + ) + fail( + session, + session.connected === true ? "not-confirmed" : "no-challenge", + ); + }); const flow = name === "gh" ? options.sshKey === true diff --git a/tests/fixtures/fake-login-tools.ts b/tests/fixtures/fake-login-tools.ts index 82982923..fafd46a0 100644 --- a/tests/fixtures/fake-login-tools.ts +++ b/tests/fixtures/fake-login-tools.ts @@ -216,8 +216,8 @@ case "$1" in auth) case "$2" in status) - if [ -f "$HOME/.wacli/paired" ]; then echo '{"authenticated":true,"linked_jid":"420123456789@s.whatsapp.net","phone":"420123456789"}' - else echo '{"authenticated":false}'; fi + if [ -f "$HOME/.wacli/paired" ]; then echo '{"success":true,"data":{"authenticated":true,"linked_jid":"420123456789@s.whatsapp.net","phone":"420123456789"},"error":null}' + else echo '{"success":true,"data":{"authenticated":false},"error":null}'; fi exit 0;; logout) rm -f "$HOME/.wacli/paired"; exit 0;; esac diff --git a/tests/launchpad-tools.test.ts b/tests/launchpad-tools.test.ts index a6d41f07..27b4219e 100644 --- a/tests/launchpad-tools.test.ts +++ b/tests/launchpad-tools.test.ts @@ -580,7 +580,7 @@ test.skipIf(process.platform === "win32")( await signInTool( standard, "wacli", - '{"authenticated":false,"phone":"+420000000000"}', + '{"success":true,"data":{"authenticated":false},"error":null}', ); await signInTool(standard, "neon", "not json at all"); return [standard]; diff --git a/tests/launchpad-wacli-sign-in.test.ts b/tests/launchpad-wacli-sign-in.test.ts new file mode 100644 index 00000000..650de97c --- /dev/null +++ b/tests/launchpad-wacli-sign-in.test.ts @@ -0,0 +1,430 @@ +import { expect, test } from "bun:test"; +import { + chmod, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { initializeFolder } from "../src/folder/initialize-folder"; +import { executionOs } from "../src/folder/platform"; +import { startLaunchpad } from "../src/launchpad/server"; +import type { LoginJournalEntry } from "../src/tools/login"; +import { runTool } from "../src/tools/status"; +import { fakeLoginTools } from "./fixtures/fake-login-tools"; + +// The curated wacli sign-in (#98) driven through the routes the Settings → +// Tools dialog uses, with stand-in executables for `wacli` that behave as the +// real one may on a Machine whose Launchpad runs as a service: no terminal, +// the QR code only as `--events` NDJSON on stderr, late, never, or an exit at +// once. HOME and XDG are temporary; nothing reaches the network. + +const posix = process.platform !== "win32"; +type Json = Record; + +// A realistic pairing payload shape (a link with the four keys and the client +// type); every value here is made up. +const payload = + "https://wa.me/settings/linked_devices#2@standinref,AAAAnoisekey=,BBBBidentity=,CCCCadv=,1"; +const qrEvent = `{"event":"qr_code","data":{"code":"${payload}"},"ts":2}`; + +// `wacli auth status --json` of 0.18.2 as its source prints it +// (`internal/out` WriteJSON wraps every answer in success/data/error), with +// fictional values. +const notSignedIn = + '{"success":true,"data":{"authenticated":false},"error":null}'; +const fictionalPhone = "420000000000"; +const fictionalJid = `${fictionalPhone}@s.whatsapp.net`; +const signedIn = `{"success":true,"data":{"authenticated":true,"linked_jid":"${fictionalJid}","phone":"${fictionalPhone}"},"error":null}`; + +// `auth status` answers the given status; `auth` records whether any +// standard stream is a terminal and its pid, then runs the variant's body. +const standIn = (body: string, status: string) => `#!/bin/sh +case "$1 $2" in +"--version ") echo "wacli 0.18.2"; exit 0;; +"auth status") echo '${status}'; exit 0;; +esac +tty=none +[ -t 0 ] && tty=stdin +[ -t 1 ] && tty=stdout +[ -t 2 ] && tty=stderr +echo "$tty" > "$HOME/wacli.tty" +echo $$ > "$HOME/wacli.pid" +echo '{"event":"auth_starting","ts":1}' >&2 +${body} +`; + +async function launchpad( + body: string | null, + login: Readonly<{ + firstChallengeMs: number; + challengeMs: number; + connectedMs?: number; + }>, + status = notSignedIn, +) { + const parent = await realpath( + await mkdtemp(join(tmpdir(), "launchpad-wacli-")), + ); + const home = join(parent, "home"); + const path = await fakeLoginTools(home, body === null ? [] : ["wacli"]); + if (body !== null) { + const script = join(home, ".local", "bin", "wacli"); + await writeFile(script, standIn(body, status)); + await chmod(script, 0o755); + } + const folder = join(parent, "Lazurio"); + await initializeFolder(folder, { + os: executionOs(process.platform), + access: "local", + purpose: "human", + locale: "en", + detail: "concise", + coordination: "direct", + }); + const journal: LoginJournalEntry[] = []; + const app = await startLaunchpad( + folder, + undefined, + undefined, + undefined, + {}, + { + path, + home, + xdg: { + XDG_STATE_HOME: join(home, ".local", "state"), + XDG_CONFIG_HOME: join(home, ".config"), + }, + platform: process.platform, + run: runTool, + }, + { + login: { + ...login, + probeIntervalMs: 50, + journal: (entry) => journal.push(entry), + }, + }, + ); + const url = new URL(app.url); + const post = async (route: string, value: unknown) => { + const response = await fetch(new URL(route, url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: url.origin, + Authorization: `Bearer ${url.hash.slice(1)}`, + }, + body: JSON.stringify(value), + }); + return (await response.json()) as Json; + }; + const pid = async () => + Number((await readFile(join(home, "wacli.pid"), "utf8")).trim()); + let stopped: Promise | null = null; + const stop = () => { + stopped ??= app.close(); + return stopped; + }; + return { + home, + journal, + post, + pid, + stop, + async close() { + await stop(); + await rm(parent, { recursive: true, force: true }); + }, + }; +} + +async function gone(pid: number): Promise { + const deadline = Date.now() + 5_000; + for (;;) { + try { + process.kill(pid, 0); + } catch { + return true; + } + if (Date.now() > deadline) return false; + await Bun.sleep(25); + } +} + +// The journal names the tool and the outcome only: no payload, no output. +function journaled(journal: readonly LoginJournalEntry[], end: Json): void { + expect(journal).toEqual([ + { tool: "wacli", event: "start" }, + { tool: "wacli", event: "end", ...end } as LoginJournalEntry, + ]); + expect(JSON.stringify(journal)).not.toContain("standinref"); +} + +test.skipIf(!posix)( + "wacli without a terminal: a QR code printed late on stderr reaches the dialog through start and poll", + async () => { + const opened = await launchpad( + `sleep 1\necho '${qrEvent}' >&2\nexec sleep 300`, + { + firstChallengeMs: 200, + challengeMs: 10_000, + }, + ); + try { + const started = await opened.post("/api/tools/login/start", { + tool: "wacli", + }); + // The start answered before the code: pending, nothing to show yet. + expect(started).toMatchObject({ kind: "pending", tool: "wacli" }); + expect(started.challenge).toBeUndefined(); + const session = started.session as string; + const deadline = Date.now() + 10_000; + let polled: Json = started; + while (polled.challenge === undefined && Date.now() < deadline) { + await Bun.sleep(50); + polled = await opened.post("/api/tools/login/poll", { + tool: "wacli", + session, + }); + } + expect(polled).toMatchObject({ + kind: "pending", + challenge: { kind: "qr", payload, sequence: 1 }, + }); + expect(polled.qrSvg as string).toStartWith(" { + const opened = await launchpad( + `exec 1>&-\necho '${qrEvent}' >&2\nexec sleep 300`, + { + firstChallengeMs: 5_000, + challengeMs: 10_000, + }, + ); + try { + const started = await opened.post("/api/tools/login/start", { + tool: "wacli", + }); + expect(started).toMatchObject({ + kind: "pending", + challenge: { kind: "qr", payload, sequence: 1 }, + }); + expect(started.qrSvg as string).toStartWith(" { + const opened = await launchpad( + `echo '{"event":"error","data":{"message":"stand-in failure"},"ts":2}' >&2\nexit 1`, + { firstChallengeMs: 5_000, challengeMs: 10_000 }, + ); + try { + const started = await opened.post("/api/tools/login/start", { + tool: "wacli", + }); + expect(started).toEqual({ + kind: "failed", + tool: "wacli", + reason: "tool-exit", + }); + journaled(opened.journal, { outcome: "failed", reason: "tool-exit" }); + expect(JSON.stringify(opened.journal)).not.toContain("stand-in failure"); + } finally { + await opened.close(); + } + }, + 20_000, +); + +test.skipIf(!posix)( + "wacli that prints nothing and keeps running ends as no-challenge within the bound and is killed", + async () => { + const opened = await launchpad("exec sleep 300", { + firstChallengeMs: 200, + challengeMs: 600, + }); + try { + const began = Date.now(); + const started = await opened.post("/api/tools/login/start", { + tool: "wacli", + }); + expect(started).toMatchObject({ kind: "pending", tool: "wacli" }); + const session = started.session as string; + let polled: Json = started; + while (polled.kind === "pending" && Date.now() - began < 5_000) { + await Bun.sleep(50); + polled = await opened.post("/api/tools/login/poll", { + tool: "wacli", + session, + }); + } + expect(polled).toEqual({ + kind: "failed", + tool: "wacli", + reason: "no-challenge", + }); + expect(Date.now() - began).toBeLessThan(5_000); + expect(await gone(await opened.pid())).toBe(true); + journaled(opened.journal, { outcome: "failed", reason: "no-challenge" }); + } finally { + await opened.close(); + } + }, + 20_000, +); + +test.skipIf(!posix)( + "wacli missing from PATH ends the start as not-installed", + async () => { + const opened = await launchpad(null, { + firstChallengeMs: 5_000, + challengeMs: 10_000, + }); + try { + expect( + await opened.post("/api/tools/login/start", { tool: "wacli" }), + ).toEqual({ kind: "failed", tool: "wacli", reason: "not-installed" }); + journaled(opened.journal, { outcome: "failed", reason: "not-installed" }); + } finally { + await opened.close(); + } + }, + 20_000, +); + +test.skipIf(!posix)( + "wacli signed in already: the observed event stream without a QR code ends the start as already signed in, and the state reads signed in", + async () => { + // The kinds and order observed on a Machine whose wacli was paired + // before; the 33 s idle exit is shortened to 1.5 s. No value is real. + const opened = await launchpad( + [ + `echo '{"event":"warning","data":{"code":"sync_limits_unset","message":"stand-in"},"ts":1}' >&2`, + "sleep 0.8", + `echo '{"event":"connected","ts":2}' >&2`, + "sleep 0.2", + `echo '{"event":"offline_sync_completed","data":{"count":0},"ts":3}' >&2`, + `echo '{"event":"warning","data":{"code":"app_state_sync_failed","message":"stand-in"},"ts":4}' >&2`, + `echo '{"event":"warning","data":{"code":"app_state_sync_failed","message":"stand-in"},"ts":4}' >&2`, + `echo '{"event":"app_state_recovery_requested","data":{"name":"regular","id":"stand-in"},"ts":5}' >&2`, + "sleep 1.5", + `echo '{"event":"idle_exit","ts":6}' >&2`, + `echo "Authenticated. Messages stored: 0"`, + "exit 0", + ].join("\n"), + { firstChallengeMs: 10_000, challengeMs: 60_000 }, + signedIn, + ); + try { + const began = Date.now(); + const started = await opened.post("/api/tools/login/start", { + tool: "wacli", + }); + // Ended at `connected`, well before the idle exit, as signed in + // before this session; not as no-challenge and not as not-confirmed. + expect(started).toEqual({ + kind: "signed-in", + tool: "wacli", + account: fictionalPhone, + already: true, + }); + expect(Date.now() - began).toBeLessThan(5_000); + // The panel's refresh reads the same probe: signed in. + const status = await opened.post("/api/tools/status", { signIn: true }); + const wacli = (status.tools as Json[]).find( + (tool) => tool.name === "wacli", + ); + expect(wacli?.signIn).toMatchObject({ + state: "signed-in", + account: fictionalPhone, + }); + // The tool finishes its sync and exits on its own idle. + expect(await gone(await opened.pid())).toBe(true); + journaled(opened.journal, { outcome: "signed-in" }); + expect(JSON.stringify(opened.journal)).not.toContain(fictionalPhone); + } finally { + await opened.close(); + } + }, + 20_000, +); + +test.skipIf(!posix)( + "wacli that reports connected while its status check keeps answering not signed in ends as not-confirmed within the bound and is killed", + async () => { + // The reviewer's case: `connected`, no QR or pairing code, and the + // status check never confirms. Only the check confirms a sign-in, so + // the session ends as not-confirmed 20 s (here 500 ms) after + // `connected`, not at the 5-minute lifetime. + const opened = await launchpad( + [`echo '{"event":"connected","ts":2}' >&2`, "exec sleep 300"].join("\n"), + { firstChallengeMs: 200, challengeMs: 60_000, connectedMs: 500 }, + notSignedIn, + ); + try { + const began = Date.now(); + const started = await opened.post("/api/tools/login/start", { + tool: "wacli", + }); + expect(started).toMatchObject({ kind: "pending", tool: "wacli" }); + expect(started.challenge).toBeUndefined(); + const session = started.session as string; + let polled: Json = started; + while (polled.kind === "pending" && Date.now() - began < 5_000) { + await Bun.sleep(50); + polled = await opened.post("/api/tools/login/poll", { + tool: "wacli", + session, + }); + } + expect(polled).toEqual({ + kind: "failed", + tool: "wacli", + reason: "not-confirmed", + }); + expect(Date.now() - began).toBeLessThan(5_000); + expect(await gone(await opened.pid())).toBe(true); + journaled(opened.journal, { outcome: "failed", reason: "not-confirmed" }); + } finally { + await opened.close(); + } + }, + 20_000, +); diff --git a/tests/tools-view.test.ts b/tests/tools-view.test.ts index 95092c22..b13cca1f 100644 --- a/tests/tools-view.test.ts +++ b/tests/tools-view.test.ts @@ -1,11 +1,13 @@ import { expect, test } from "bun:test"; import { messages } from "../src/launchpad/messages"; import { + answerWithin, curatedActions, currentNotes, installOutcome, loginEndMessage, loginLink, + loginProgress, loginStepOrder, loginSteps, logoutOutcome, @@ -983,3 +985,101 @@ test("the sign-in of gh may carry its SSH state, only in its exact form", () => ]) expect(gh(bad)).toBeNull(); }); + +test("a starting sign-in never shows the same sentence as status and detail; no challenge in time ends with what to do", () => { + const pending = { + kind: "pending", + tool: "wacli", + session: "0".repeat(32), + expiresAt: "2026-09-29T12:00:00.000Z", + } as const; + for (const locale of ["en", "cs"] as const) { + const copy = messages(locale); + // Before the first answer and while the tool shows nothing to act on. + for (const state of [null, pending]) { + const progress = loginProgress(state, copy); + expect(progress.status).toBe(copy.toolsLoginStarting); + expect(progress.detail).toBe(copy.toolsLoginStartingDetail); + expect(progress.detail).not.toBe(progress.status); + } + expect( + loginProgress( + { ...pending, challenge: { kind: "qr", payload: "x", sequence: 1 } }, + copy, + ), + ).toEqual({ status: copy.toolsLoginWaiting, detail: null }); + const failed = parseLoginState({ + kind: "failed", + tool: "wacli", + reason: "no-challenge", + }); + expect(failed).toEqual({ + kind: "failed", + tool: "wacli", + reason: "no-challenge", + }); + expect(loginEndMessage(failed as NonNullable, copy)).toEqual( + { message: copy.toolsLoginFailureNoChallenge, agent: true, retry: true }, + ); + // Every end of a sign-in that did not start, ended early or showed + // nothing says what to do next, distinct from the starting sentence. + for (const key of [ + "toolsLoginFailureNotInstalled", + "toolsLoginFailureSpawn", + "toolsLoginFailureExit", + "toolsLoginFailureNoChallenge", + "toolsLoginNoAnswer", + ] as const) { + expect(copy[key].split(". ").length).toBeGreaterThan(1); + expect(copy[key]).not.toBe(copy.toolsLoginStarting); + } + } +}); + +test("a dialog request that never settles is not answered within its bound", async () => { + expect( + await answerWithin(Promise.resolve({ value: { kind: "none" } }), 1_000), + ).toEqual({ answered: true, value: { kind: "none" } }); + expect(await answerWithin(Promise.reject(new Error("x")), 1_000)).toEqual({ + answered: true, + value: null, + }); + const began = Date.now(); + expect(await answerWithin(new Promise(() => undefined), 50)).toEqual({ + answered: false, + }); + expect(Date.now() - began).toBeLessThan(1_000); +}); + +test("a tool signed in before the sign-in says so, in both languages", () => { + const already = parseLoginState({ + kind: "signed-in", + tool: "wacli", + account: "420000000000", + already: true, + }); + expect(already).toEqual({ + kind: "signed-in", + tool: "wacli", + account: "420000000000", + already: true, + }); + expect( + parseLoginState({ kind: "signed-in", tool: "wacli", already: "yes" }), + ).toBeNull(); + for (const locale of ["en", "cs"] as const) { + const copy = messages(locale); + expect(signedInMessage(already as NonNullable, copy)).toBe( + copy.toolsLoginAlreadySignedInAs + .replace("{name}", "wacli") + .replace("{account}", "420000000000"), + ); + expect( + signedInMessage( + { kind: "signed-in", tool: "wacli", already: true }, + copy, + ), + ).toBe(copy.toolsLoginAlreadySignedIn.replace("{name}", "wacli")); + expect(copy.toolsLoginAlreadySignedIn).not.toBe(copy.toolsLoginSignedIn); + } +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index ae25d279..f5e72a1f 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -806,22 +806,47 @@ test("each catalog probe reads signed in, as whom, signed out or unknown from it state: "unknown", }); + // wacli 0.18.2 wraps every --json answer (internal/out WriteJSON) in + // {"success","data","error"}; the fields of `auth status` are in `data` + // (#98). The values are fictional. const wacli = probeOf("wacli"); + const envelope = (data: unknown) => + answer(JSON.stringify({ success: true, data, error: null })); expect( - readSignIn(wacli, answer('{"authenticated":true,"phone":"+420123"}')), - ).toEqual({ state: "signed-in", account: "+420123" }); + readSignIn( + wacli, + envelope({ + authenticated: true, + linked_jid: "420123@s.whatsapp.net", + phone: "420123", + }), + ), + ).toEqual({ state: "signed-in", account: "420123" }); expect( readSignIn( wacli, - answer('{"authenticated":true,"linked_jid":"420123@s.whatsapp.net"}'), + envelope({ authenticated: true, linked_jid: "420123@s.whatsapp.net" }), ), ).toEqual({ state: "signed-in", account: "420123@s.whatsapp.net" }); - expect( - readSignIn(wacli, answer('{"authenticated":false,"phone":"+420123"}')), - ).toEqual({ state: "signed-out" }); - expect(readSignIn(wacli, answer('{"authenticated":"yes"}'))).toEqual({ + expect(readSignIn(wacli, envelope({ authenticated: false }))).toEqual({ + state: "signed-out", + }); + expect(readSignIn(wacli, envelope({ authenticated: "yes" }))).toEqual({ state: "signed-out", }); + // The shape the probe read before #98, top-level fields, is not wacli's: + // it never counted as signed in and still does not. + expect( + readSignIn(wacli, answer('{"authenticated":true,"phone":"420123"}')), + ).toEqual({ state: "signed-out" }); + // An error answer of the read-only status (exit 1, envelope on stderr). + expect( + readSignIn(wacli, { + exitCode: 1, + stdout: "", + stderr: '{"success":false,"data":null,"error":"read auth status: x"}\n', + }), + ).toEqual({ state: "signed-out" }); const gog = probeOf("gogcli"); expect(