Public: anyone can read this; the crew can change it.

The real-cell stranger test walks claim link → first ask on a new app → first pull green and live, starting from the kept branch #12243

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/12077-the-real-cell-stranger-test-walks-claim → main 15 commits fork 2 threads · 2 unresolved

  • Merged. scout/12077-the-real-cell-stranger-test-walks-claim was merged into main as a5359e9.

Review

Sign in to review this change.
5 files changed +513 −0
scripts/gate.ts +5
@@ -181,6 +181,11 @@ export const SLOW: readonly Exclusion[] = [
181181 reason: "the cold home takes 20 timed requests at ~65 ms, each after a write, until its per-app reads are grouped",
182182 issue: 12310,
183183 },
184 {
185 file: "test/spawn-stranger-cell.test.ts",
186 reason: "one test walks a whole forge in a real cell, from claim link to the first pull green; it is not a unit",
187 issue: 12077,

The SLOW entry is acceptable for this walk, but the check must still run it unskipped. Prove that in the description.

188 },
184189 ];
185190
186191 /** Everything wrong with one list of named files, `list` its name in the lines. */
test/helpers/real-stranger-e2e.ts added +325
@@ -0,0 +1,325 @@
1 /**
2 * The stranger's path in a real cell (#12077), as `test/spawn-stranger-cell.test.ts`
3 * drives it. Its own process, for the reason `real-spawn-e2e.ts` is: a check
4 * sets `FORGE_CELLS=off` for the forges its tests boot.
5 *
6 * A parent with the real server is asked for a forge by name, as the home's
7 * form does, with no account. The child comes up in a real cell at the plan's
8 * size; the walk claims it through its claim link with the forge's name as
9 * username and no email, asks "a page that says hello and shows today's date"
10 * through the form on its first screen, and follows the ask to a new app, its
11 * first pull landing on green, and the app's own address serving the page to
12 * its owner as a browser reaches it. The model is `stranger-claude.sh`, a
13 * fixed edit; the cell, the crew's clone and push, the pull, its check, the
14 * merge and the deploy are the forge's own. The second ask is not walked.
15 *
16 * Each phase has its own budget and a message that names it. Printed: one JSON
17 * line of what was seen, with `error` set to the phase that did not finish.
18 */
19 import { mkdtempSync, rmSync } from "node:fs";
20 import { tmpdir } from "node:os";
21 import { join } from "node:path";
22 import { createApp } from "../../src/app.ts";
23 import { cellsAvailable } from "../../src/apps/contain.ts";
24 import { serve } from "../../src/http/server.ts";
25 import { useProbe } from "../../src/spawn/door.ts";
26 import { cellChildren, stopCellChildren } from "../../src/spawn/cell.ts";
27 import { lastStop } from "../../src/spawn/stopped.ts";
28 import "./roomy-disk.ts";
29
30 // One budget per phase, each counted from when it began: a slow phase never
31 // shortens the next, and a run that ran out says which one did. The test's
32 // timeout is their sum plus slack (see test/spawn-stranger-cell.test.ts).
33 const BUDGET_MS = { up: 120_000, ask: 60_000, pull: 120_000, serves: 60_000 } as const;
34 const NAME = "kid";
35 const ASK = "a page that says hello and shows today's date";
36 const PARENT = "forge.test";
37 const CHILD = `${NAME}--${PARENT}`;
38
39 process.env["FORGE_CLAUDE"] = join(import.meta.dir, "stranger-claude.sh");
40
41 let cleanup: () => Promise<void> = async () => {};
42 process.on("SIGTERM", () => void cleanup().finally(() => process.exit(143)));
43
44 const cells = await cellsAvailable();
45 if (!cells.ok) {
46 console.log(JSON.stringify({ skipped: cells.reason }));
47 process.exit(0);
48 }
49
50 const dataDir = mkdtempSync(join(tmpdir(), "real-stranger-"));
51 const parent = createApp({ dataDir, url: `https://${PARENT}`, port: 0 });
52 const servers = await serve(parent);
53 // The child's public address (https://kid--forge.test) is not a real host here: ask it
54 // the way every other request in this walk does, through the parent's own server with
55 // the child's Host, on /claim (the parent answers /healthz itself, whatever the host).
56 useProbe(parent, () => async () => {
57 try {
58 const res = await fetch(`${servers.url}/claim`, {
59 headers: { host: CHILD },
60 redirect: "manual",
61 signal: AbortSignal.timeout(700),
62 });
63 await res.body?.cancel();
64 return res.status === 200;
65 } catch {
66 return false;
67 }
68 });
69 cleanup = async () => {
70 servers.stop();
71 await stopCellChildren(parent);
72 };
73
74 const seen: Record<string, unknown> = {};
75 const statuses = new Set<number>();
76 /** Cookies a browser would hold, by host. */
77 const jar = new Map<string, Map<string, string>>();
78
79 /** One request to `host` through the parent's real server; every status is noted, a 429 too. */
80 async function call(
81 host: string,
82 path: string,
83 init: { method?: string; body?: URLSearchParams; accept?: string } = {},
84 ): Promise<Response> {
85 const cookies = [...(jar.get(host)?.entries() ?? [])].map(([k, v]) => `${k}=${v}`).join("; ");
86 const res = await fetch(`${servers.url}${path}`, {
87 method: init.method ?? "GET",
88 redirect: "manual",
89 signal: AbortSignal.timeout(20_000),
90 ...(init.body === undefined ? {} : { body: init.body }),
91 headers: {
92 host,
93 ...(init.method === "POST" ? { origin: `https://${host}` } : {}),
94 ...(init.accept === undefined ? {} : { accept: init.accept }),
95 ...(cookies === "" ? {} : { cookie: cookies }),
96 },
97 });
98 statuses.add(res.status);
99 for (const line of res.headers.getSetCookie()) {
100 const [pair = ""] = line.split(";");
101 const at = pair.indexOf("=");
102 if (at < 1) continue;
103 const held = jar.get(host) ?? new Map<string, string>();
104 const value = pair.slice(at + 1);
105 if (value === "" || /max-age=0/i.test(line)) held.delete(pair.slice(0, at));
106 else held.set(pair.slice(0, at), value);
107 jar.set(host, held);
108 }
109 return res;
110 }
111
112 /** Retries `read` each second until it answers, or says which phase ran out and what it saw. */
113 async function until<T>(
114 phase: string,
115 ms: number,
116 read: () => Promise<T | undefined>,
117 last: () => unknown = () => undefined,
118 ): Promise<T> {
119 const end = Date.now() + ms;
120 for (;;) {
121 const value = await read().catch((e: unknown) => {
122 seen["lastError"] = String(e);
123 return undefined;
124 });
125 if (value !== undefined) return value;
126 if (Date.now() >= end) {
127 const saw = JSON.stringify(last() ?? null);
128 throw new Error(`the ${phase} phase did not finish in ${ms / 1000}s: ${saw}`);
129 }
130 await Bun.sleep(1000);
131 }
132 }
133
134 function fail(phase: string, what: string): never {
135 throw new Error(`the ${phase} phase failed: ${what}`);
136 }
137
138 /** The options of the ask form's target select, as a person sees them. */
139 const targets = (html: string): string[] =>
140 [...html.matchAll(/<option[^>]*value="([^"]*)"/g)].map((m) => m[1]!);
141
142 /** Follows redirects as a browser does, from `start`, to the first answer that is not one. */
143 async function browse(
144 start: string,
145 ): Promise<{ status: number; body: string; hops: string[] }> {
146 let url = new URL(start);
147 const hops: string[] = [];
148 for (let i = 0; i < 8; i++) {
149 const res = await call(url.host, url.pathname + url.search, { accept: "text/html" });
150 hops.push(`${res.status} ${url.host}${url.pathname}`);
151 const where = res.headers.get("location");
152 if (res.status >= 300 && res.status < 400 && where !== null) {
153 await res.body?.cancel();
154 url = new URL(where, url);
155 continue;
156 }
157 return { status: res.status, body: await res.text(), hops };
158 }
159 return { status: 0, body: "", hops };
160 }
161
162 try {
163 // Phase 1: a forge by name, from the home's own form; the child comes up in a cell.
164 const home = await call(PARENT, "/");
165 if (home.status !== 200 || !(await home.text()).includes('id="start-forge"')) {
166 fail("up", `the home did not carry the form to start a forge (${home.status})`);
167 }
168 const started = await call(PARENT, "/forges/start", {
169 method: "POST",
170 body: new URLSearchParams({ name: NAME }),
171 });
172 const waiting = await started.text();
173 const token = /href="[^"]*\/welcome#([A-Za-z0-9_-]{43})"/.exec(waiting)?.[1] ?? "";
174 if (started.status !== 200 || token === "") {
175 fail("up", `starting the forge answered ${started.status} with no claim link`);
176 }
177 let upSeen: unknown = null;
178 await until(
179 "cell up",
180 BUDGET_MS.up,
181 async () => {
182 const res = await call(PARENT, `/forges/${NAME}/ready`, { accept: "application/json" });
183 const body = (await res.json()) as { ready?: unknown };
184 upSeen = { status: res.status, ...body, lastError: seen["lastError"] };
185 return res.status === 200 && body.ready === true ? true : undefined;
186 },
187 () => upSeen,
188 );
189 const held = cellChildren(parent)
190 .list()
191 .find((c) => c.name === NAME);
192 seen["placement"] = held === undefined ? "none" : "cell";
193 seen["limits"] = held?.limits ?? null;
194 if (held === undefined) fail("up", "the child is not in a cell");
195
196 // Phase 2: the claim link, with the forge's own name as username, a password and no email.
197 const form = await call(CHILD, "/claim");
198 if (form.status !== 200) fail("claim", `the account form answered ${form.status}`);
199 await form.body?.cancel();
200 const claimed = await call(CHILD, "/claim", {
201 method: "POST",
202 body: new URLSearchParams({ token, username: NAME, password: "a-long-stranger-passphrase" }),
203 });
204 seen["claim"] = claimed.status;
205 if (claimed.status !== 303 || jar.get(CHILD) === undefined) {
206 fail("claim", `claiming answered ${claimed.status}`);
207 }
208 await claimed.body?.cancel();
209
210 // Phase 3: the ask, through the form on the forge's first screen, and where it was filed.
211 const landing = await call(CHILD, claimed.headers.get("location") ?? "/");
212 const first = await landing.text();
213 const tell = /<form[^>]*action="\/tell"[^>]*>([\s\S]*?)<\/form>/.exec(first)?.[1];
214 if (landing.status !== 200 || tell === undefined) {
215 fail("ask", `the forge's first screen answered ${landing.status} with no form to ask through`);
216 }
217 const fields = new URLSearchParams();
218 for (const m of tell!.matchAll(/<input[^>]*type="hidden"[^>]*>/g)) {
219 const name = /name="([^"]+)"/.exec(m[0])?.[1];
220 if (name) fields.set(name, /value="([^"]*)"/.exec(m[0])?.[1] ?? "");
221 }
222 const select = /<select[^>]*name="([^"]+)"[^>]*>([\s\S]*?)<\/select>/.exec(tell!) ?? undefined;
223 const chosen =
224 select === undefined
225 ? undefined
226 : (/<option[^>]*value="([^"]*)"[^>]*\sselected/.exec(select[2]!)?.[1] ??
227 targets(select[2]!)[0]);
228 seen["firstTarget"] = chosen ?? fields.get("app") ?? null;
229 if (select !== undefined && chosen !== undefined) fields.set(select[1]!, chosen);
230 const textName = /<textarea[^>]*name="([^"]+)"/.exec(tell!)?.[1] ?? "";
231 if (textName === "") fail("ask", "the form has no place to write the ask");
232 fields.set(textName, ASK);
233 const before = new Set(targets(first));
234 const asked = await call(CHILD, "/tell", { method: "POST", body: fields });
235 seen["tell"] = asked.status;
236 await asked.body?.cancel();
237 if (asked.status >= 400) fail("ask", `the ask answered ${asked.status}`);
238 const filed = await until("ask filed on a new app", BUDGET_MS.ask, async () => {
239 const res = await call(CHILD, "/");
240 const now = targets(await res.text());
241 // The forge's own copy may show among the targets once there is an app; never the
242 // ask's home.
243 const app = /^[a-z0-9][a-z0-9._-]*\/(?!forge$)[a-z0-9][a-z0-9._-]*$/;
244 return now.find((t) => app.test(t) && !before.has(t));
245 });
246 seen["firstFiledOn"] = filed;
247 const slug = filed.split("/")[1]!;
248 const onForge = await call(CHILD, `/api/v1/repos/${NAME}/forge/issues?state=all`, {
249 accept: "application/json",
250 });
251 const forgeBody = onForge.status === 200 ? ((await onForge.json()) as { items?: unknown[] }) : {};
252 const forgeItems = forgeBody.items?.length ?? 0;
253 await onForge.body?.cancel();
254 seen["forgeIssues"] = onForge.status === 200 ? forgeItems : null;
255 const onApp = await call(CHILD, `/api/v1/repos/${filed}/issues?state=all`, {
256 accept: "application/json",
257 });
258 seen["appIssues"] =
259 onApp.status === 200
260 ? (((await onApp.json()) as { items?: unknown[] }).items?.length ?? 0)
261 : onApp.status;
262
263 // Phase 4: the first pull lands by itself, on green.
264 let pulls: unknown = null;
265 const landed = await until(
266 "first pull",
267 BUDGET_MS.pull,
268 async () => {
269 const res = await call(CHILD, `/api/v1/repos/${filed}/pulls?state=all`, {
270 accept: "application/json",
271 });
272 if (res.status !== 200) return undefined;
273 type Pull = { number: number; state: string; mergedAt?: unknown; check?: string };
274 const items = ((await res.json()) as { items?: Pull[] }).items ?? [];
275 pulls = items.map((p) => ({
276 number: p.number,
277 state: p.state,
278 merged: p.mergedAt != null,
279 check: p.check,
280 }));
281 return items.find((p) => p.mergedAt != null);
282 },
283 () => pulls,
284 );
285 seen["firstPull"] = {
286 number: landed.number,
287 state: landed.state,
288 merged: true,
289 check: landed.check,
290 };
291
292 // Phase 5: the app's own address serves the page to its owner, as a browser reaches it.
293 const host = `${slug}--${CHILD}`;
294 const day = () => new Date().toISOString().slice(0, 10);
295 const from = day();
296 let page: { status: number; body: string; hops: string[] } | undefined;
297 await until(
298 "app serving",
299 BUDGET_MS.serves,
300 async () => {
301 page = await browse(`https://${host}/hello`);
302 const hello = /^Hello! Today is \d{4}-\d{2}-\d{2}\.$/;
303 return page.status === 200 && hello.test(page.body) ? page : undefined;
304 },
305 () => ({ host, page }),
306 );
307 seen["firstServes"] = {
308 host,
309 status: page!.status,
310 body: page!.body.slice(0, 80),
311 hops: page!.hops,
312 };
313 const shown = /\d{4}-\d{2}-\d{2}/.exec(page!.body)?.[0] ?? "";
314 seen["dateShown"] = shown === from || shown === day();
315
316 seen["stop"] = lastStop(parent, held!.bodyId)?.why ?? null;
317 } catch (e) {
318 seen["error"] = e instanceof Error ? e.message : String(e);
319 } finally {
320 seen["statuses"] = [...statuses].sort((a, b) => a - b);
321 console.log(JSON.stringify(seen));
322 await cleanup();
323 parent.close();
324 rmSync(dataDir, { recursive: true, force: true });
325 }
test/helpers/stranger-builder.ts added +78
@@ -0,0 +1,78 @@
1 /**
2 * What the stranger's walk (`real-stranger-e2e.ts`) puts in the model's place
3 * (#12077), run by `bun` in the clone the crew gives it: one fixed edit, the
4 * app's own tests, and only then a commit and a push. Given a prompt that asks
5 * for a page that says hello and shows today's date, it adds a `/hello` route
6 * to the app the template made, and a test of it. Any other prompt, or an app
7 * that already has the route (a reviewer's clone, a second run), changes
8 * nothing and says so. Argument: none; the prompt is read from stdin.
9 */
10 import { readFileSync, writeFileSync } from "node:fs";
11 import { ANCHOR } from "./build-standin.ts";
12
13 /** What `/hello` says, before the date. */
14 export const HELLO = "Hello! Today is ";
15
16 const today = 'new Date().toISOString().slice(0, 10)';
17 const body = `${JSON.stringify(HELLO)} + ${today} + "."`;
18 const route = ` { method: "GET", path: "/hello", handle: () => text(${body}) },\n\n`;
19
20 const test = `import { afterAll, expect, test } from "bun:test";
21 import { mkdtempSync, rmSync } from "node:fs";
22 import { tmpdir } from "node:os";
23 import { join } from "node:path";
24 import { serve } from "./server.ts";
25
26 const dataDir = mkdtempSync(join(tmpdir(), "hello-"));
27 const server = serve({ port: 0, dataDir, identityKey: "a-key-only-the-forge-holds" });
28 afterAll(() => {
29 server.stop(true);
30 rmSync(dataDir, { recursive: true, force: true });
31 });
32
33 test("/hello says hello and today's date", async () => {
34 const res = await fetch(\`http://\${server.hostname}:\${server.port}/hello\`);
35 expect(res.status).toBe(200);
36 expect(await res.text()).toMatch(/^Hello! Today is \\d{4}-\\d{2}-\\d{2}\\.$/);
37 });
38 `;
39
40 const finish = (ok: boolean, result: string): void =>
41 console.log(
42 JSON.stringify({
43 type: "result",
44 subtype: ok ? "success" : "error_during_execution",
45 is_error: !ok,
46 result,
47 total_cost_usd: 0.01,
48 num_turns: 1,
49 session_id: "stranger-1",
50 terminal_reason: ok ? "completed" : "tool_error",
51 }),
52 );
53 const git = (...args: string[]): void => {
54 const r = Bun.spawnSync(["git", ...args], { stdout: "pipe", stderr: "pipe" });
55 if (r.exitCode !== 0) throw new Error(`${args.join(" ")}: ${r.stderr.toString()}`);
56 };
57
58 const prompt = (await Bun.stdin.text()).toLowerCase();
59 const routes = readFileSync("src/routes.ts", "utf8");
60 if (!prompt.includes("hello") || !prompt.includes("date") || routes.includes('path: "/hello"')) {
61 finish(true, "DECLINED: nothing here I am asked to build.");
62 process.exit(0);
63 }
64 if (!routes.includes(ANCHOR)) {
65 finish(false, "the template's routes have changed shape");
66 process.exit(0);
67 }
68 writeFileSync("src/routes.ts", routes.replace(ANCHOR, route + ANCHOR));
69 writeFileSync("src/hello.test.ts", test);
70 const tested = Bun.spawnSync(["bun", "test"], { stdout: "pipe", stderr: "pipe" });
71 if (tested.exitCode !== 0) {
72 finish(false, `its tests failed: ${tested.stderr.toString().slice(-300)}`);
73 process.exit(0);
74 }
75 git("add", "-A");
76 git("commit", "-qm", "Add a page that says hello and today's date");
77 git("push", "-q", "-u", "origin", "HEAD");
78 finish(true, "Added /hello, tests pass.");
test/helpers/stranger-claude.sh added +9
@@ -0,0 +1,9 @@
1 #!/bin/sh
2 # The model's place in test/spawn-stranger-cell.test.ts (#12077): Claude Code's
3 # stream-json, with a fixed edit made by stranger-builder.ts. It lives in the
4 # repository because a cell hides the temp directory a stand-in would be
5 # written to; the parent hands its path to the child as FORGE_CLAUDE.
6 if [ "$1" = "--version" ]; then echo "2.1.0 (stranger stand-in)"; exit 0; fi
7 IFS= read -r first
8 echo '{"type":"system","subtype":"init","session_id":"stranger-1","model":"stand-in"}'
9 printf '%s\n' "$first" | bun "$(dirname "$0")/stranger-builder.ts"
test/spawn-stranger-cell.test.ts added +96
@@ -0,0 +1,96 @@
1 /**
2 * The stranger's path in a real cell (#12077, v1 item 1 of #11541): a forge
3 * named from the home's form comes up at size small, is claimed through its
4 * claim link with the forge's name as username and no email, and is asked for
5 * "a page that says hello and shows today's date" through the form on its
6 * first screen. The model's call is a fixed edit (`helpers/stranger-claude.sh`);
7 * everything else is the forge's own. The ask is filed on a new app and never
8 * `<name>/forge`, the first pull lands on green by itself, the app's address
9 * serves the page to its owner, and nothing was answered 429 or stopped for
10 * memory. The second ask is not walked here.
11 *
12 * It runs where bwrap and socat are, and says it skipped where they are not
13 * (under FORGE_TEST_SOCKET, as `spawn-cell-e2e.test.ts` does). Its budget, as
14 * that file's: each phase has its own (cell up 120 s, ask filed 60 s, first
15 * pull 120 s, app serving 60 s, set in the helper and named in its failure),
16 * and the helper's timeout and this test's are their sum plus slack: 380 s and
17 * 400 s. The first real run took 47 s whole. It is a whole forge made and
18 * built on, not a unit.
19 */
20 import { test } from "bun:test";
21 import { join } from "node:path";
22 import { portInsteadOfSocket } from "../src/git/hook-address.ts";
23 import { missing } from "./helpers/tools.ts";
24 import { failedWith } from "./helpers/machine-note.ts";
25 import { resultLine } from "./helpers/result-line.ts";
26
27 const cellTools =
28 process.platform === "linux" && !portInsteadOfSocket() && !missing("bwrap", "socat");
29
30 const name =
31 "a stranger claims a small child, asks for a page, and the first change lands live, " +
32 "with no 429 and no memory stop";
33
34 test.skipIf(!cellTools)(name, async () => {
35 const env: Record<string, string> = {};
36 for (const [k, v] of Object.entries(process.env)) {
37 if (v !== undefined && k !== "FORGE_CELLS") env[k] = v;
38 }
39 const walk = join(import.meta.dir, "helpers", "real-stranger-e2e.ts");
40 const proc = Bun.spawn([process.execPath, walk], {
41 env,
42 stdout: "pipe",
43 stderr: "pipe",
44 timeout: 380_000,
45 });
46 const [out, errText] = await Promise.all([
47 new Response(proc.stdout).text(),
48 new Response(proc.stderr).text(),
49 proc.exited,
50 ]);
51 let seen: Record<string, unknown>;
52 try {
53 seen = JSON.parse(resultLine(out)) as Record<string, unknown>;
54 } catch {
55 throw new Error(failedWith("the stranger's walk printed no result", out.slice(-800), errText));
56 }
57 if (typeof seen["skipped"] === "string") {
58 const why = seen["skipped"];
59 console.log(`SKIPPING: this host refuses a cell inside the test (${why}); no forge started`);
60 return;
61 }
outdated

700 s of timeout for a test that has never been seen to pass. Land the first real run in the description before trusting these budgets.

62 // The walk's own line, in the check's log, whether it passed or not.
63 console.log(`stranger walk: ${JSON.stringify(seen)}`);
64 const bad = (what: string) => new Error(failedWith(what, seen, errText));
65 if (typeof seen["error"] === "string") throw bad(`the stranger's path broke: ${seen["error"]}`);
66 const limits = seen["limits"] as { memoryMiB?: number; cpus?: number } | null;
67 if (seen["placement"] !== "cell" || limits?.memoryMiB !== 2048 || limits.cpus !== 1) {
68 throw bad("the forge did not come up in a small cell");
69 }
70 if (seen["claim"] !== 303) throw bad("the claim link did not sign the stranger in");
71 if (seen["firstTarget"] !== "new-app") {
72 throw bad("the first screen's form did not default to a new app");
73 }
74 const filed = String(seen["firstFiledOn"]);
75 if (!/^kid\/(?!forge$)[a-z0-9][a-z0-9._-]*$/.test(filed)) {
76 throw bad("the ask was not filed on a new app");
77 }
78 if (seen["forgeIssues"] !== null && seen["forgeIssues"] !== 0) {
79 throw bad("the ask was filed on the forge's own copy");
80 }
81 if (typeof seen["appIssues"] !== "number" || seen["appIssues"] < 1) {
82 throw bad("the new app holds no ask");
83 }
84 const pull = seen["firstPull"] as { merged?: boolean; check?: string } | undefined;
85 if (pull?.merged !== true || pull.check !== "green") {
86 throw bad("the first pull did not land on green");
87 }
88 const serves = seen["firstServes"] as { status?: number } | undefined;
89 if (serves?.status !== 200 || seen["dateShown"] !== true) {
90 throw bad("the app's address did not serve the page with today's date");
91 }
92 if ((seen["statuses"] as number[]).includes(429)) {
93 throw bad("a request was answered 429, the sentence for a full host");
94 }
95 if (seen["stop"] !== null) throw bad("the child was stopped for memory");
96 }, 400_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs