privateer-agent 0.6.7 → 0.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SECURITY.md CHANGED
@@ -30,7 +30,11 @@ npm audit signatures # verifies registry signatures + p
30
30
  ```
31
31
 
32
32
  If a version lacks provenance, it did not come from this workflow. Treat that as
33
- suspicious and report it.
33
+ suspicious and report it — with one documented exception: **0.6.7 is the first release
34
+ published this way.** Trusted publishing was misconfigured until then, so every earlier
35
+ version (through 0.6.6) was published by hand from a maintainer's machine and carries no
36
+ attestation. Those are not forgeries, but they are not independently verifiable either.
37
+ If that distinction matters to you, use 0.6.7 or later.
34
38
 
35
39
  ## The permission gate
36
40
 
@@ -146,6 +146,7 @@ else {
146
146
  const MANAGED = [
147
147
  "privateer-brand", "privateer-context", "privateer-gate", "privateer-account",
148
148
  "privateer-models", "privateer-posture", "privateer-tools", "privateer-privacy",
149
+ "privateer-connect",
149
150
  "pi-privacy", "pi-web-access", "rpiv-web-tools", "pi-mcp-adapter", "pi-hypa", "pi-subagents",
150
151
  ];
151
152
  for (const name of MANAGED) fs.rmSync(path.join(EXT_DIR, `${name}.ts`), { force: true });
@@ -171,6 +172,7 @@ else {
171
172
  shim("privateer-posture", ext("privateer-posture.ts"));
172
173
  shim("privateer-tools", ext("privateer-tools.ts"));
173
174
  shim("privateer-privacy", ext("privateer-privacy.ts")); // pi-privacy + account tier resolver
175
+ shim("privateer-connect", ext("privateer-connect.ts")); // /connect — MCP connector manager
174
176
  shim("rpiv-web-tools", dep("@juicesharp/rpiv-web-tools", "index.ts")); // private web tools
175
177
  shim("pi-mcp-adapter", dep("pi-mcp-adapter", "index.ts"));
176
178
  shim("pi-hypa", dep("@hypabolic/pi-hypa", "extensions", "index.ts"));
@@ -214,28 +216,39 @@ else {
214
216
  // auto-install. Fire-and-forget: the event loop stays alive while the TUI child runs.
215
217
  refreshUpdateCache();
216
218
 
217
- // Default model. Explicit PRIVATEER_MODEL wins; else Tinfoil GLM 5.2 (client-attested
218
- // TEE, strongest tier) when a Tinfoil key is present; else the signed-in account's
219
- // NEAR channel; else a cheap OpenRouter fallback.
219
+ // Default model. Mirrors src/providers/defaultModel.ts resolveDefaultModel() keep
220
+ // the two in step. Tinfoil's GLM 5.2 is the default either way: direct when the user
221
+ // has a Tinfoil key (pi-privacy can client-attest the enclave), over the Privateer
222
+ // subscription otherwise.
223
+ //
224
+ // The last branch is the important one. A signed-out, keyless terminal used to launch
225
+ // on `openrouter/openai/gpt-4o-mini`, which it had no key for — so the first prompt
226
+ // died on "No API key found for openrouter", /login couldn't fix it (nothing switched
227
+ // the live model), and the error named a provider the user had never heard of. It now
228
+ // launches on the SAME account model it will use once signed in: nothing to switch,
229
+ // the status bar shows what they're about to get, and the error until then names
230
+ // Privateer and points at /login.
220
231
  const CRED = path.join(PRIVATEER_HOME, "credentials.json");
221
232
  const signedIn = fs.existsSync(CRED);
233
+ const ACCOUNT_MODEL = "privateer/tinfoil/glm-5-2";
222
234
  const MODEL = process.env.PRIVATEER_MODEL
223
235
  ? process.env.PRIVATEER_MODEL
224
236
  : haveTinfoilKey()
225
237
  ? "tinfoil/glm-5-2"
226
238
  : signedIn
227
- ? "privateer/near/zai-org/GLM-5.1-FP8"
228
- : "openrouter/openai/gpt-4o-mini";
229
-
230
- // Guard the keyless dead-end. We land on the OpenRouter fallback ONLY when the user
231
- // named no model, has no Tinfoil key, AND isn't signed in (no credentials.json). If
232
- // they also have no OpenRouter/other BYO key, the very first prompt errors with a bare
233
- // "No API key found for openrouter" and nothing explains why. Worse, if this machine
234
- // was signed in before (other ~/.privateer state exists but the login file is gone),
235
- // that bare error hides a vanished session. Surface a clear, branded notice BEFORE the
236
- // TUI loads but still boot it, so `/login` inside works (and activateSignedInModel
237
- // switches the live session onto the account channel the moment they sign back in).
238
- if (MODEL === "openrouter/openai/gpt-4o-mini" && !haveByoKey()) {
239
+ ? ACCOUNT_MODEL
240
+ : haveKey("ANTHROPIC_API_KEY")
241
+ ? "anthropic/claude-opus-4-8"
242
+ : haveKey("OPENAI_API_KEY")
243
+ ? "openai/gpt-5.5"
244
+ : haveKey("OPENROUTER_API_KEY")
245
+ ? "openrouter/openai/gpt-4o-mini"
246
+ : ACCOUNT_MODEL;
247
+
248
+ // Nothing to run with: no model named, no BYO key, not signed in. The TUI still boots
249
+ // (that's where /login lives), but say why up front a returning user whose login
250
+ // file vanished otherwise has no way to tell a cleared session from a first run.
251
+ if (!signedIn && !process.env.PRIVATEER_MODEL && !haveByoKey()) {
239
252
  warnKeylessLaunch();
240
253
  }
241
254
 
@@ -367,20 +380,17 @@ function warnKeylessLaunch() {
367
380
  ? [
368
381
  "",
369
382
  " ⚓ Your Privateer login is missing — this terminal isn't signed in.",
370
- ` (no ${path.join(PRIVATEER_HOME, "credentials.json")})`,
371
383
  "",
372
- " If you were signed in before, your session was cleared. Run /login to sign",
373
- " back in you'll return to your subscription models right away. Until then,",
374
- " prompting fails with \"No API key found\" because no model key is set.",
384
+ " Run /login and approve the code in the Privateer app. You'll be back on your",
385
+ " subscription models straight away no API key needed.",
375
386
  "",
376
387
  ]
377
388
  : [
378
389
  "",
379
- " ⚓ You're not signed in to Privateer and no provider API key is set.",
390
+ " ⚓ Welcome aboard. Run /login to connect your Privateer account.",
380
391
  "",
381
- " Run /login to use your subscription, or set a provider key (e.g.",
382
- " ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY). Until then,",
383
- " prompting fails with \"No API key found\".",
392
+ " One approval in the Privateer app and you're running Tinfoil GLM 5.2 in a",
393
+ " trusted enclave no API key needed. Prefer your own key? /login keys.",
384
394
  "",
385
395
  ];
386
396
  process.stderr.write(lines.join("\n") + "\n");
@@ -5,25 +5,30 @@
5
5
  // 1. A branded startup header — the anchor mark + "✻ PRIVATEER" wordmark + the
6
6
  // "Chart your own course privately." tagline (ported from tree-cli's Banner).
7
7
  // 2. A live status-bar badge (⚓ account) showing the sign-in state at a glance.
8
- // 3. Sign-in UX — /signin, /signout, and a /privateer hub — which drive the
8
+ // 3. Auth UX — /login, /logout, and a /privateer hub — which drive the
9
9
  // device-code flow the account channel needs.
10
10
  //
11
- // Pi already owns /login and /logout for PROVIDER auth (and /whoami), so we do NOT
12
- // shadow them. The account provider now registers unconditionally (see
13
- // makeAccountProvider), so Privateer appears under Pi's /login "Use a subscription"
14
- // list and a first-time user CAN sign in through provider auth. /signin remains as a
15
- // friendlier, dedicated shortcut that drives the same account device-code flow
16
- // directly one obvious command instead of /login pick a provider.
11
+ // ONE vocabulary, deliberately: log in / log out. This file used to avoid shadowing
12
+ // Pi's built-in /login and /logout and shipped /signin and /signout alongside them,
13
+ // which left the user with two auth vocabularies and — worse — a /logout that did
14
+ // not log you out. Pi's /logout only clears Pi's own authStorage; the Privateer
15
+ // machine login lives in ~/.privateer/credentials.json and survived it, so /logout
16
+ // on a signed-in machine reported "No stored credentials to remove" and changed
17
+ // nothing. We now own both verbs: /logout here is canonical and Pi's built-in is
18
+ // redirected to it (patches/, same mechanism as the /model → /models redirect).
19
+ // /signin and /signout stay as undocumented aliases for muscle memory.
17
20
  //
18
- // On a successful /signin we hot-register the account provider so privateer/* models
19
- // appear immediately (the account catalog refreshes to the live listing without a
20
- // restart).
21
+ // The account provider also registers unconditionally (see makeAccountProvider), so
22
+ // Privateer appears under Pi's "Use a subscription" list and a first-time user can
23
+ // log in that way too. On a successful login we hot-register the account provider so
24
+ // privateer/* models appear immediately (the account catalog refreshes to the live
25
+ // listing without a restart).
21
26
 
22
27
  import { readFileSync, appendFileSync } from "node:fs";
23
28
  import { homedir } from "node:os";
24
29
  import { join } from "node:path";
25
30
  import * as priv from "../src/auth/privateer.ts";
26
- import { makeAccountProvider } from "../src/providers/account.ts";
31
+ import { armAccountCredential, makeAccountProvider } from "../src/providers/account.ts";
27
32
  import { resolveSignedInModel } from "../src/providers/defaultModel.ts";
28
33
  import { discoverContextFiles, onContextChanged } from "../src/context.ts";
29
34
  import { type Palette, paletteFor } from "../src/ui/palette.ts";
@@ -345,11 +350,18 @@ export default function privateerBrand(pi: any): void {
345
350
  }
346
351
  }
347
352
 
353
+ // /login. Signing in must leave the terminal ABLE TO PROMPT, not merely "connected" —
354
+ // so this runs the device flow, hot-registers the account provider, then hands off to
355
+ // activateSignedInModel to arm the channel and select the model. Every step reports.
348
356
  async function doSignIn(ctx: any): Promise<void> {
349
357
  if (priv.hasCredentials()) {
350
358
  const u = priv.currentUser();
359
+ // Already linked: still make sure THIS session can use the account (a terminal
360
+ // launched before the login, or one whose channel never armed, otherwise sits
361
+ // "signed in" and unusable), then point at the two things they might have meant.
362
+ await activateSignedInModel(ctx, { switchModel: false });
351
363
  return ctx?.ui?.notify?.(
352
- `Already signed in as ${u?.email ?? u?.id}. Run /signout to switch accounts.`,
364
+ `Signed in as ${u?.email ?? u?.id}. Run /logout to switch accounts, or /login keys to add a provider API key.`,
353
365
  "info",
354
366
  );
355
367
  }
@@ -367,7 +379,7 @@ export default function privateerBrand(pi: any): void {
367
379
  `${p.DIM}Approve this terminal in the Privateer app:${p.RESET}`,
368
380
  ` code ${p.BOLD}${p.ACCENT}${userCode}${p.RESET}`,
369
381
  uri ? `${p.DIM} or open ${p.RESET}${p.INK}${uri}${p.RESET}` : "",
370
- `${p.DIM} waiting for approval…${p.RESET}`,
382
+ `${p.DIM} waiting for approval… ${p.RESET}${p.DIM}(esc to cancel · ${p.RESET}${p.INK}/login keys${p.DIM} to use your own API key instead)${p.RESET}`,
371
383
  ].filter(Boolean),
372
384
  { placement: "aboveEditor" },
373
385
  );
@@ -381,20 +393,31 @@ export default function privateerBrand(pi: any): void {
381
393
  /* provider list fetch failed — models appear on next launch */
382
394
  }
383
395
  refresh(ctx);
384
- ctx?.ui?.notify?.(`Signed in as ${user.email ?? user.id}. Your Privateer models are ready.`, "info");
396
+ ctx?.ui?.notify?.(`Signed in as ${user.email ?? user.id}.`, "info");
397
+ // Arm the account channel and select the model, IN THIS SESSION. runDeviceLogin
398
+ // fires onSignedIn too, which does the same work — this await is what makes the
399
+ // outcome (and any failure) land before we hand the prompt back to the user.
400
+ await activateSignedInModel(ctx);
385
401
  } catch (e) {
386
402
  ctx?.ui?.setWidget?.("privateer-signin", undefined);
387
403
  ctx?.ui?.notify?.((e as Error).message || "Sign-in failed.", "error");
388
404
  }
389
405
  }
390
406
 
407
+ // Log out of this MACHINE — the login and every terminal spawned from it (see
408
+ // priv.logout). Unlike the old terminal-scoped sign-out this makes a blocking
409
+ // network call, so say what's happening before the round trip rather than after.
391
410
  async function doSignOut(ctx: any): Promise<void> {
392
411
  if (!priv.hasCredentials()) return ctx?.ui?.notify?.("Not signed in.", "info");
393
412
  const u = priv.currentUser();
394
- await priv.logout();
413
+ ctx?.ui?.notify?.("Signing out of Privateer…", "info");
414
+ await priv.logout(); // never throws: local state is wiped whatever the network did
395
415
  dropPersistedAccount(ctx);
396
416
  refresh(ctx);
397
- ctx?.ui?.notify?.(`Signed out${u?.email ? ` (${u.email})` : ""}. Drop anchor for now.`, "info");
417
+ ctx?.ui?.notify?.(
418
+ `Signed out${u?.email ? ` (${u.email})` : ""} — this machine and its terminals. Drop anchor for now.`,
419
+ "info",
420
+ );
398
421
  }
399
422
 
400
423
  function showStatus(ctx: any): void {
@@ -402,50 +425,103 @@ export default function privateerBrand(pi: any): void {
402
425
  ctx?.ui?.notify?.(
403
426
  u
404
427
  ? `Signed in to Privateer as ${u.email ?? u.id}.`
405
- : "Not signed in. Run /signin to connect your Privateer account.",
428
+ : "Not logged in. Run /login to connect your Privateer account.",
406
429
  "info",
407
430
  );
408
431
  }
409
432
 
410
- // Move the LIVE session onto a confidential model the instant the user signs in. A
411
- // terminal launched with no credentials is pinned by `--model` to the keyless
412
- // OpenRouter fallback; without this switch it stays there and the first prompt after
413
- // sign-in dead-ends on "No API key found for openrouter". resolveSignedInModel picks
414
- // Tinfoil GLM 5.2 (client-attested TEE) when a key is present, else the account's NEAR
415
- // channel private inference that works out of the box. We only override an auto-picked
416
- // launch model, never a deliberate PRIVATEER_MODEL, and never re-switch if we're already
417
- // on the target. The account (NEAR) credential is spawned moments AFTER sign-in fires,
418
- // so setModel can briefly return false ("no key yet"); retry a few times so the switch
419
- // lands as soon as the credential is ready (Tinfoil, key already in env, succeeds first
420
- // try). Best-effort throughout a failure just leaves the launch model in place.
421
- async function activateSignedInModel(ctx: any): Promise<void> {
422
- if (process.env.PRIVATEER_MODEL?.trim()) return; // deliberate overriderespect it
423
- const reg = ctx?.modelRegistry;
424
- if (!reg?.find || typeof pi.setModel !== "function") return;
425
- const spec = resolveSignedInModel();
426
- const slash = spec.indexOf("/");
427
- if (slash <= 0) return;
428
- const provider = spec.slice(0, slash), id = spec.slice(slash + 1);
429
- const currentSpec = ctx?.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
430
- if (currentSpec === spec) return; // already there nothing to do
431
- const model = reg.find(provider, id);
432
- if (!model) { dbg(`activateSignedInModel: ${spec} not in registry`); return; }
433
- for (let attempt = 0; attempt < 4; attempt++) {
434
- try {
435
- const ok = await pi.setModel(model);
436
- if (ok !== false) {
437
- currentModelProvider = provider;
438
- refresh(ctx);
439
- ctx?.ui?.notify?.(`Now using ${spec} for private inference.`, "info");
440
- dbg(`activateSignedInModel: switched to ${spec}`);
433
+ // Make the terminal USABLE the instant the user signs in — the whole point of
434
+ // logging in, and the step that used to be missing. Two halves, in this order:
435
+ //
436
+ // 1. ARM the account channel. Pi stores an OAuth credential only for a login it
437
+ // drove itself, so after our own device-code /login the `privateer` provider has
438
+ // no key at all. Arming first also matters for the now-common case where the
439
+ // launch model ALREADY is the account model (a signed-out terminal boots on it,
440
+ // see providers/defaultModel.ts): there's no switch to make, only a key to fetch,
441
+ // and the old early-return skipped it and left the next prompt to fail.
442
+ // 2. SWITCH the live model, if we aren't already on it. A terminal that launched on
443
+ // a BYO key stays on that key otherwise, so signing in appears to do nothing.
444
+ //
445
+ // resolveSignedInModel picks Tinfoil GLM 5.2direct (client-attested) when a Tinfoil
446
+ // key is present, over the subscription otherwise. A deliberate PRIVATEER_MODEL is
447
+ // never overridden. Idempotent: sign-in fires this twice by design (once when
448
+ // credentials land, once when the channel is ready), and a second run is a no-op.
449
+ // Best-effort but a failure is now REPORTED, because silently leaving the user on an
450
+ // unusable model is exactly the bug this replaces.
451
+ let activating = false;
452
+ async function activateSignedInModel(ctx: any, opts: { switchModel?: boolean } = {}): Promise<void> {
453
+ if (activating || process.env.PRIVATEER_MODEL?.trim()) return; // in-flight, or a deliberate override
454
+ activating = true;
455
+ try {
456
+ const spec = resolveSignedInModel();
457
+ const slash = spec.indexOf("/");
458
+ if (slash <= 0) return;
459
+ const provider = spec.slice(0, slash), id = spec.slice(slash + 1);
460
+
461
+ // The account channel needs a live session token before any privateer/* model can
462
+ // run. Retry briefly: this can be racing the credential the login itself minted.
463
+ if (provider === "privateer") {
464
+ let armed = false;
465
+ for (let attempt = 0; attempt < 3 && !armed; attempt++) {
466
+ armed = await armAccountCredential(ctx, { notify: false });
467
+ if (!armed) await new Promise((r) => setTimeout(r, 500));
468
+ }
469
+ if (!armed) {
470
+ dbg("activateSignedInModel: account channel not armed");
471
+ ctx?.ui?.notify?.(
472
+ "Signed in, but this terminal couldn't open an account session. Check your connection and run /login again, or sign a terminal out in the app if you're at the device limit.",
473
+ "error",
474
+ );
441
475
  return;
442
476
  }
443
- } catch (e) {
444
- dbg(`activateSignedInModel: setModel threw ${(e as Error).message}`);
445
477
  }
446
- await new Promise((r) => setTimeout(r, 400)); // credential still spawning — retry
478
+
479
+ // Re-running /login while already signed in arms the channel but must NOT move the
480
+ // user off a model they picked with /models. Only a genuine sign-in switches.
481
+ if (opts.switchModel === false) return;
482
+
483
+ // Already on the target — the common case now that a signed-out terminal launches
484
+ // on the account model. Nothing to switch, but SAY so: the channel just went live
485
+ // under them, and silence after a login is what made the old flow feel broken.
486
+ const currentSpec = ctx?.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
487
+ if (currentSpec === spec) {
488
+ dbg(`activateSignedInModel: already on ${spec}`);
489
+ refresh(ctx);
490
+ ctx?.ui?.notify?.(`${spec} is ready — private inference on your Privateer account.`, "info");
491
+ return;
492
+ }
493
+
494
+ const reg = ctx?.modelRegistry;
495
+ if (!reg?.find || typeof pi.setModel !== "function") return;
496
+ const model = reg.find(provider, id);
497
+ if (!model) {
498
+ // The provider's live catalog may still be loading (or this is the first sign-in
499
+ // of the run, before the account provider was registered). Say something useful
500
+ // rather than stranding them on a model their account can't bill.
501
+ dbg(`activateSignedInModel: ${spec} not in registry`);
502
+ ctx?.ui?.notify?.(`Signed in. Run /models to pick a model — ${spec} isn't loaded yet.`, "warning");
503
+ return;
504
+ }
505
+ for (let attempt = 0; attempt < 4; attempt++) {
506
+ try {
507
+ const ok = await pi.setModel(model);
508
+ if (ok !== false) {
509
+ currentModelProvider = provider;
510
+ refresh(ctx);
511
+ ctx?.ui?.notify?.(`Now using ${spec} — private inference on your Privateer account.`, "info");
512
+ dbg(`activateSignedInModel: switched to ${spec}`);
513
+ return;
514
+ }
515
+ } catch (e) {
516
+ dbg(`activateSignedInModel: setModel threw ${(e as Error).message}`);
517
+ }
518
+ await new Promise((r) => setTimeout(r, 400)); // registry still settling — retry
519
+ }
520
+ dbg(`activateSignedInModel: gave up switching to ${spec}`);
521
+ ctx?.ui?.notify?.(`Signed in, but couldn't switch to ${spec}. Run /models to pick one.`, "warning");
522
+ } finally {
523
+ activating = false;
447
524
  }
448
- dbg(`activateSignedInModel: gave up switching to ${spec}`);
449
525
  }
450
526
 
451
527
  dbg("extension loaded, onSignedIn listener registering");
@@ -529,27 +605,43 @@ export default function privateerBrand(pi: any): void {
529
605
  // that's now dead server-side (see dropPersistedAccount).
530
606
  dropPersistedAccount(ctxRef);
531
607
  refresh(ctxRef);
532
- ctxRef?.ui?.notify?.("Your Privateer session expired. Run /signin to sign back in.", "warning");
608
+ ctxRef?.ui?.notify?.("Your Privateer session expired. Run /login to sign back in.", "warning");
533
609
  });
534
610
 
535
611
  pi.registerCommand?.("update", {
536
612
  description: "Update Privateer to the latest release (npm i -g privateer-agent@latest)",
537
613
  handler: (_args: string, ctx: any) => doUpdate(ctx),
538
614
  });
539
- pi.registerCommand?.("signin", {
540
- description: "Sign in to your Privateer account (device-code flow)",
615
+ // ONE vocabulary: log in / log out. Pi's own built-ins are /login and /logout, so
616
+ // matching them is what makes the pair feel like a single concept instead of two
617
+ // half-overlapping ones (Pi's /logout used to clear only Pi's authStorage while the
618
+ // machine login lived on, which read as "logout doesn't work"). doSignIn/doSignOut
619
+ // are canonical; patches/ redirects Pi's built-ins to `/privateer login|logout`
620
+ // rather than to these registrations, so the redirect can't re-enter the branch it
621
+ // was dispatched from — the same mechanism as the /model → /models redirect.
622
+ //
623
+ // In interactive mode that redirect means the built-in wins and these two never
624
+ // dispatch (patches/ also silences the resulting conflict warning). They stay
625
+ // registered for rpc/print mode, which has no such redirect and reaches them by name.
626
+ //
627
+ // signin/signout stay registered as undocumented aliases — they were the shipped
628
+ // names, they're in muscle memory and in older docs, and an alias costs nothing.
629
+ pi.registerCommand?.("login", {
630
+ description: "Log in to your Privateer account · /login keys for a provider API key",
541
631
  handler: (_args: string, ctx: any) => doSignIn(ctx),
542
632
  });
543
- pi.registerCommand?.("signout", {
544
- description: "Sign out of your Privateer account on this terminal",
633
+ pi.registerCommand?.("logout", {
634
+ description: "Log out of Privateer on this machine (revokes all its terminals)",
545
635
  handler: (_args: string, ctx: any) => doSignOut(ctx),
546
636
  });
637
+ pi.registerCommand?.("signin", { description: "", handler: (_a: string, ctx: any) => doSignIn(ctx) });
638
+ pi.registerCommand?.("signout", { description: "", handler: (_a: string, ctx: any) => doSignOut(ctx) });
547
639
  pi.registerCommand?.("privateer", {
548
- description: "Privateer account: /privateer [status | signin | signout]",
640
+ description: "Privateer account: /privateer [status | login | logout]",
549
641
  handler: (args: string, ctx: any) => {
550
642
  const sub = String(args ?? "").trim().toLowerCase().split(/\s+/)[0];
551
- if (sub === "signin" || sub === "login") return doSignIn(ctx);
552
- if (sub === "signout" || sub === "logout") return doSignOut(ctx);
643
+ if (sub === "login" || sub === "signin") return doSignIn(ctx);
644
+ if (sub === "logout" || sub === "signout") return doSignOut(ctx);
553
645
  return showStatus(ctx);
554
646
  },
555
647
  });