privateer-agent 0.6.6 → 0.6.7
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/README.md +163 -22
- package/package.json +1 -1
- package/src/auth/accountSessions.ts +154 -0
- package/src/auth/privateer.ts +131 -19
- package/src/cli/chat.ts +1 -1
- package/src/config/paths.ts +9 -0
- package/src/daemon/index.ts +2 -2
- package/src/providers/account.ts +59 -4
package/README.md
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
</p>
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
curl -fsSL https://privateer.pro/install.sh | sh # installs the `privateer` command
|
|
26
|
+
curl -fsSL https://privateer.pro/install.sh | sh # macOS / Linux — installs the `privateer` command
|
|
27
|
+
irm https://privateer.pro/install.ps1 | iex # Windows (PowerShell)
|
|
27
28
|
npx privateer-agent # or run it instantly, nothing installed
|
|
28
29
|
```
|
|
29
30
|
|
|
@@ -33,9 +34,13 @@ Point it at a frontier model today and a local Ollama model tomorrow — **OpenR
|
|
|
33
34
|
inference), **Venice** / **Fireworks** (no-retention inference), and any **custom
|
|
34
35
|
OpenAI-compatible endpoint** (LM Studio, vLLM, llama.cpp…) are interchangeable at
|
|
35
36
|
`/model` time, including mid-session. No model lock-in, no separate code paths. MCP
|
|
36
|
-
servers, sub-agents, scheduled routines,
|
|
37
|
-
included — and every one of the agent's actions runs
|
|
38
|
-
permission gate**.
|
|
37
|
+
servers, sub-agents, scheduled routines, multi-step workflows, chat-app bridges, and
|
|
38
|
+
one-tap approval from your phone are included — and every one of the agent's actions runs
|
|
39
|
+
through a **safe-by-default permission gate**.
|
|
40
|
+
|
|
41
|
+
Privateer runs in three places, over one account and one config: the **terminal**, a
|
|
42
|
+
**background daemon** for unattended work, and the **Privateer app** on
|
|
43
|
+
[phone, web](https://privateer.pro), and [desktop](#the-privateer-app).
|
|
39
44
|
|
|
40
45
|
## Why Privateer?
|
|
41
46
|
|
|
@@ -95,12 +100,21 @@ silently. The moat is swappable; the floor under it holds.
|
|
|
95
100
|
- **Honest privacy posture, graded.** A verified TEE and a "we promise not to retain"
|
|
96
101
|
policy are **never rendered the same** — the badge tells you exactly how strong the
|
|
97
102
|
guarantee is (cryptographically verified → observable → policy → none).
|
|
98
|
-
- **
|
|
99
|
-
|
|
100
|
-
stays on your machine
|
|
103
|
+
- **Drive it from your phone.** Link the terminal with `/remote-access` (off by default) and
|
|
104
|
+
the Privateer app can send prompts, stream output, and Allow/Deny every action — while
|
|
105
|
+
execution stays on your machine. Sub-agent actions surface for approval the same way.
|
|
106
|
+
- **Manage it from the app.** Extensions, skills, routines, workflows, MCP connectors, and
|
|
107
|
+
chat-app channels are all configurable from your phone or the web app, against any linked
|
|
108
|
+
terminal. See [The Privateer app](#the-privateer-app).
|
|
109
|
+
- **A desktop app.** The same agent hosted inside a local Electron shell — no relay hop, works
|
|
110
|
+
offline, multi-window with per-window MCP connectors. Shares your CLI login and config.
|
|
101
111
|
- **Scheduled routines.** A background daemon runs approved tasks unattended — cron or
|
|
102
112
|
one-off — and the agent can schedule its own follow-up work. Results deliver to a file,
|
|
103
113
|
the next session, your phone, email, or a webhook.
|
|
114
|
+
- **Declarative workflows.** Multi-step agent pipelines as YAML — typed steps, conditional
|
|
115
|
+
routing between them, and `human_gate` steps that pause for your approval and resume.
|
|
116
|
+
- **Chat-app channels.** Bridge the agent into Telegram, Slack, Discord, or WhatsApp with
|
|
117
|
+
role-based approval — admins can approve actions, members are read-only.
|
|
104
118
|
- **MCP servers, sub-agents & skills.** Connect Model Context Protocol servers (local stdio
|
|
105
119
|
or remote HTTP with OAuth), delegate work to bounded parallel sub-agents, and drop in
|
|
106
120
|
skills — all gated like everything else.
|
|
@@ -124,14 +138,27 @@ No install at all: `npx privateer-agent`.
|
|
|
124
138
|
## Install
|
|
125
139
|
|
|
126
140
|
```bash
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
141
|
+
# the one-liner installer — downloads a self-contained bundle, no Node needed:
|
|
142
|
+
curl -fsSL https://privateer.pro/install.sh | sh # macOS / Linux
|
|
143
|
+
irm https://privateer.pro/install.ps1 | iex # Windows (PowerShell)
|
|
144
|
+
|
|
145
|
+
# or via npm, if you'd rather manage it yourself (needs Node ≥ 22.19):
|
|
146
|
+
npm install -g privateer-agent
|
|
147
|
+
npx privateer-agent # run without installing
|
|
132
148
|
```
|
|
133
149
|
|
|
134
|
-
**Requirements:** macOS
|
|
150
|
+
**Requirements:** macOS (arm64/x64), Linux (x64), or Windows (x64). The installers ship a
|
|
151
|
+
**pinned Node runtime inside the bundle**, so you don't need Node or npm on your machine at
|
|
152
|
+
all — Node ≥ 22.19.0 is only required for the `npm` / `npx` path.
|
|
153
|
+
|
|
154
|
+
Update in place with **`privateer update`** (bundle-aware: it re-runs the right installer
|
|
155
|
+
for how you installed) or check your version with `privateer --version`.
|
|
156
|
+
|
|
157
|
+
> **Windows:** the agent's command tool needs a bash, which Windows doesn't ship. Install
|
|
158
|
+
> Git for Windows (or WSL) and Privateer will find it; the launcher checks at startup and
|
|
159
|
+
> tells you how to fix it if not. Override the choice with `shellPath` in
|
|
160
|
+
> `~/.privateer/agent/settings.json`. Linux arm64 and Windows arm64 bundles aren't built
|
|
161
|
+
> yet — arm64 Windows runs the x64 bundle under emulation.
|
|
135
162
|
|
|
136
163
|
### Verifying what you're about to run
|
|
137
164
|
|
|
@@ -248,13 +275,106 @@ with `/signout`; manage linked terminals from the app.
|
|
|
248
275
|
> to spend on your account. If someone sends you a code and asks you to approve it, don't —
|
|
249
276
|
> that hands *them* a billed session on *your* account.
|
|
250
277
|
|
|
251
|
-
##
|
|
278
|
+
## The Privateer app
|
|
279
|
+
|
|
280
|
+
The same account drives Privateer from **iOS, Android, [the web app](https://privateer.pro),
|
|
281
|
+
and a desktop app**. The terminal stays where the work happens — the app is a remote control
|
|
282
|
+
and a management surface for it.
|
|
283
|
+
|
|
284
|
+
### Linking a terminal
|
|
285
|
+
|
|
286
|
+
1. Run **`privateer`** and **`/signin`**. It prints a short device code.
|
|
287
|
+
2. Open the app → **Link a terminal**, enter the code (or tap the deep link). No password or
|
|
288
|
+
wallet key ever touches the terminal, and the app pins the terminal's public key on first
|
|
289
|
+
link.
|
|
290
|
+
3. In the terminal, turn on **`/remote-access`** (off by default). The terminal now shows
|
|
291
|
+
**Online** in the app.
|
|
292
|
+
|
|
293
|
+
> **Only approve a code you generated yourself.** Approving someone else's code hands *them*
|
|
294
|
+
> a billed session on *your* account.
|
|
295
|
+
|
|
296
|
+
### What you can do from the app
|
|
297
|
+
|
|
298
|
+
| | |
|
|
299
|
+
|---|---|
|
|
300
|
+
| **Drive a session** | Send prompts, watch streamed output, and **Allow/Deny** each proposed action — including actions from sub-agents the session spawned |
|
|
301
|
+
| **Spawn an agent** | Start a one-shot task on the daemon: *background* (headless, read-only toolset, result sealed to your outbox) or *live* (a fresh drivable session) |
|
|
302
|
+
| **Routines** | Create, edit, pause, run, and delete scheduled unattended tasks |
|
|
303
|
+
| **Workflows** | List, run, and monitor multi-step workflows; answer `human_gate` steps to resume a paused run |
|
|
304
|
+
| **MCP connectors** | Add, edit, and enable MCP servers; credentials are sealed to the terminal and write-only |
|
|
305
|
+
| **Channels** | Configure the Telegram / Slack / Discord / WhatsApp bridges — admins, members, posture, tool ceiling, model |
|
|
306
|
+
| **Extensions & skills** | Install Pi extensions from the catalog; create, edit, and run `SKILL.md` skills |
|
|
307
|
+
|
|
308
|
+
Config changes that carry secrets or executable content (MCP credentials, channel bot
|
|
309
|
+
tokens, workflows with `script` steps) are **sealed to the terminal's pinned key and signed
|
|
310
|
+
by your account** — the relay forwards them blind and can neither read nor forge them.
|
|
311
|
+
|
|
312
|
+
The relay itself is live-only (nothing is archived), carries no API keys, and output is
|
|
313
|
+
size-truncated and run through a best-effort secret redactor before it leaves your machine.
|
|
314
|
+
|
|
315
|
+
### Desktop app
|
|
316
|
+
|
|
317
|
+
The desktop app hosts the agent **in-process** and talks to it over loopback IPC — no relay,
|
|
318
|
+
no network hop, and it works offline. It reads the same `~/.privateer` home, so it shares
|
|
319
|
+
your CLI login, model config, and MCP catalog. Multi-window, with a per-window subset of
|
|
320
|
+
your MCP connectors and a native folder picker.
|
|
321
|
+
|
|
322
|
+
Download for [macOS](https://privateer.pro/download/mac) (Apple silicon),
|
|
323
|
+
[macOS Intel](https://privateer.pro/download/mac-intel), or
|
|
324
|
+
[Windows](https://privateer.pro/download/windows).
|
|
325
|
+
|
|
326
|
+
It's an early release and **not yet code-signed or notarized** — macOS will warn on first
|
|
327
|
+
open. Routines and channels deliberately aren't hosted here: those belong to the always-on
|
|
328
|
+
daemon, so background work still wants `privateer daemon install`.
|
|
252
329
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
330
|
+
## Run it unattended — the daemon
|
|
331
|
+
|
|
332
|
+
A resident background daemon runs scheduled routines, executes workflows, and accepts task
|
|
333
|
+
spawns from the app — with no terminal open.
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
privateer daemon install # install as a login service (auto-starts, reachable from the app)
|
|
337
|
+
privateer daemon status # service installed? daemon answering?
|
|
338
|
+
privateer daemon run # or just run it in the foreground
|
|
339
|
+
privateer daemon uninstall
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Installs as a **launchd user agent** on macOS or a **`systemd --user` unit** on Linux — no
|
|
343
|
+
root, no sudo. (There's no Windows service path yet; use `privateer daemon run`.)
|
|
344
|
+
|
|
345
|
+
Everything the daemon does still runs through the permission gate. Actions needing approval
|
|
346
|
+
surface in the app; routines you approved once run on their own schedule.
|
|
347
|
+
|
|
348
|
+
## Workflows
|
|
349
|
+
|
|
350
|
+
A workflow is a **YAML file describing a multi-step agent pipeline** — a flat graph of typed
|
|
351
|
+
steps (`agent`, `script`, `human_gate`) with conditional routes between them and `{{ }}`
|
|
352
|
+
templating to pass values along. A `human_gate` step pauses the run for your approval and
|
|
353
|
+
resumes when you answer it, including from your phone.
|
|
354
|
+
|
|
355
|
+
The engine ships in the standalone
|
|
356
|
+
[`privateer-workflow`](https://www.npmjs.com/package/privateer-workflow) package. Today the
|
|
357
|
+
user-facing surface is the **app** (save, run, monitor, share) and the **daemon** that
|
|
358
|
+
executes them — there's no `/workflow` command in the terminal yet. Schedule one by pointing
|
|
359
|
+
a routine at it.
|
|
360
|
+
|
|
361
|
+
Because a workflow can carry `script` steps, saving one from the app requires your **account
|
|
362
|
+
signature** — the server can't inject a workflow onto your daemon.
|
|
363
|
+
|
|
364
|
+
## Chat-app channels
|
|
365
|
+
|
|
366
|
+
Bridge the agent into **Telegram, Slack, Discord, or WhatsApp** so you can hand it work from
|
|
367
|
+
a group chat. Each channel has:
|
|
368
|
+
|
|
369
|
+
- **Roles** — `admins` can approve actions; `members` are always read-only, no exceptions.
|
|
370
|
+
- **A posture** — `readonly`, `approve` (default), or `auto`.
|
|
371
|
+
- **A hard tool ceiling** — a per-channel allowlist the agent can't exceed even in `auto`.
|
|
372
|
+
|
|
373
|
+
Configure a channel from the app, or by hand in the `channels` block of
|
|
374
|
+
`~/.privateer/config.json`. Changes take effect on restart, by design. Bot tokens set from
|
|
375
|
+
the app are write-only — the app can name them but never read them back. Note that tokens
|
|
376
|
+
live in plaintext in `config.json` on your machine, and every channel action is appended to
|
|
377
|
+
`~/.privateer/channels-audit.log`.
|
|
258
378
|
|
|
259
379
|
## Permission modes
|
|
260
380
|
|
|
@@ -289,14 +409,35 @@ Everything below is a **Pi extension** loaded by discovery (see [Built on Pi](#b
|
|
|
289
409
|
drop your own into `~/.privateer/agent/extensions/` and it loads the same way, gated like the rest.
|
|
290
410
|
|
|
291
411
|
- **MCP servers** (`pi-mcp-adapter`) — declare them and their tools become first-class, gated
|
|
292
|
-
like the rest (local stdio, or remote HTTP with interactive OAuth).
|
|
293
|
-
-
|
|
294
|
-
|
|
412
|
+
like the rest (local stdio, or remote HTTP with interactive OAuth). One catalog at
|
|
413
|
+
`~/.privateer/agent/mcp-desktop.json` is shared by the CLI, the daemon, and the desktop
|
|
414
|
+
app, so a machine has one coherent connector config.
|
|
415
|
+
- **Sub-agents** (`pi-subagents`) — delegate investigations to bounded parallel agents. Children
|
|
416
|
+
run as headless child processes that **inherit the moat**, so their actions hit the same
|
|
417
|
+
permission gate and their approvals surface on your phone.
|
|
295
418
|
- **Routines** — saved tasks the daemon runs unattended; ask the agent to schedule work and
|
|
296
419
|
approve it once.
|
|
420
|
+
- **Workflows** — declarative multi-step pipelines the daemon executes; see
|
|
421
|
+
[Workflows](#workflows).
|
|
297
422
|
- **Web tools** (`rpiv-web-tools`) — private-by-default web search/fetch with pluggable backends
|
|
298
423
|
(self-hosted SearXNG for fully private search).
|
|
299
424
|
|
|
425
|
+
## Command reference
|
|
426
|
+
|
|
427
|
+
| Command | What it does |
|
|
428
|
+
|---|---|
|
|
429
|
+
| `/model` · `/models` | switch model; `/models` is a searchable picker with TEE/ZDR privacy shields |
|
|
430
|
+
| `/mode` | switch permission mode |
|
|
431
|
+
| `/verify` | fetch and check the TEE attestation for the current model |
|
|
432
|
+
| `/signin` · `/signout` | sign in to a Privateer account (device flow) / sign out |
|
|
433
|
+
| `/remote-access` | link this terminal to the app and allow it to drive (off by default) |
|
|
434
|
+
| `/extensions` | list loaded Pi extensions |
|
|
435
|
+
| `/init` | scaffold a starter `PRIVATEER.md` in this directory |
|
|
436
|
+
| `/update` · `/privateer` | update to the latest release / Privateer status and posture |
|
|
437
|
+
|
|
438
|
+
Shell subcommands: `privateer` (interactive), `privateer update`, `privateer daemon …`,
|
|
439
|
+
`privateer --no-quarter`, `privateer --version`.
|
|
440
|
+
|
|
300
441
|
## Develop
|
|
301
442
|
|
|
302
443
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.7",
|
|
4
4
|
"description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Which account-provider inference sessions this machine has spawned, and which
|
|
2
|
+
// terminal owns each one.
|
|
3
|
+
//
|
|
4
|
+
// The problem this solves: every launch used to spawn a NEW server-side session, and
|
|
5
|
+
// only a CLEAN exit revoked it (session_shutdown → revokeLocalSessions). A terminal
|
|
6
|
+
// that dies without running its shutdown hook — SIGKILL, a closed window, a crash,
|
|
7
|
+
// `kill` — leaves its session row alive server-side for the rest of its ~24h TTL. Do
|
|
8
|
+
// that a few times and the next spawn is refused with
|
|
9
|
+
// `429 CHILD_SESSION_CAP: Too many active terminals for this device`, which takes the
|
|
10
|
+
// whole account channel down until the rows age out.
|
|
11
|
+
//
|
|
12
|
+
// The fix is to reclaim an orphan instead of stacking another row on top of it. That
|
|
13
|
+
// needs one bit the credential itself can't tell us: is the terminal that owns it
|
|
14
|
+
// still RUNNING? A live terminal's session must never be touched — adopting it rotates
|
|
15
|
+
// its refresh token out from under it and kills a working session (they rotate in
|
|
16
|
+
// isolation, one per terminal, by design). So each entry records the owning pid, and
|
|
17
|
+
// a session counts as orphaned only once that pid is gone.
|
|
18
|
+
//
|
|
19
|
+
// pid liveness is signal-0. The failure mode is asymmetric and we lean on that: a
|
|
20
|
+
// RECYCLED pid makes a dead owner look alive, so we skip a reclaimable session and
|
|
21
|
+
// spawn a fresh one — the old behaviour, no harm. The dangerous direction (a live
|
|
22
|
+
// process reported dead) can't happen: a running pid never reports ESRCH.
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, renameSync, writeFileSync, chmodSync } from "node:fs";
|
|
25
|
+
import { dirname } from "node:path";
|
|
26
|
+
import { accountSessionsPath, globalDir } from "../config/paths.ts";
|
|
27
|
+
|
|
28
|
+
// One spawned session, keyed in the file by the pid of its owning terminal. `refresh`
|
|
29
|
+
// is what lets a later launch adopt or revoke it; `expires` is its access token's exp
|
|
30
|
+
// (see jwtExpMs), used only to prune entries that are dead server-side anyway.
|
|
31
|
+
export interface OwnedSession {
|
|
32
|
+
pid: number;
|
|
33
|
+
refresh: string;
|
|
34
|
+
expires: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Registry = Record<string, { refresh?: unknown; expires?: unknown }>;
|
|
38
|
+
|
|
39
|
+
function tryChmod(path: string, mode: number): void {
|
|
40
|
+
try {
|
|
41
|
+
chmodSync(path, mode);
|
|
42
|
+
} catch {
|
|
43
|
+
/* best effort — a restrictive umask or an odd filesystem */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readRegistry(): Registry {
|
|
48
|
+
const path = accountSessionsPath();
|
|
49
|
+
if (!existsSync(path)) return {};
|
|
50
|
+
try {
|
|
51
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
52
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Registry) : {};
|
|
53
|
+
} catch {
|
|
54
|
+
return {}; // corrupt/truncated — start clean rather than wedging every launch
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Write via temp + rename so a concurrent reader never sees a half-written file. Two
|
|
59
|
+
// terminals racing can still lose one entry (last writer wins); the cost is one
|
|
60
|
+
// unreclaimable orphan, not a broken launch, so a lock file isn't worth it here.
|
|
61
|
+
function writeRegistry(reg: Registry): void {
|
|
62
|
+
const path = accountSessionsPath();
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
65
|
+
tryChmod(globalDir(), 0o700);
|
|
66
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
67
|
+
writeFileSync(tmp, JSON.stringify(reg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
68
|
+
tryChmod(tmp, 0o600);
|
|
69
|
+
renameSync(tmp, path);
|
|
70
|
+
} catch {
|
|
71
|
+
/* best effort — losing the registry costs reclamation, never correctness */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Is a pid still running? EPERM means it exists but belongs to another user, which is
|
|
76
|
+
// still "alive" — and alive is the safe answer (we skip reclamation rather than risk
|
|
77
|
+
// hijacking a live terminal's session).
|
|
78
|
+
function isAlive(pid: number): boolean {
|
|
79
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pid, 0);
|
|
82
|
+
return true;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return (e as NodeJS.ErrnoException).code === "EPERM";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseEntry(pid: string, raw: { refresh?: unknown; expires?: unknown }): OwnedSession | null {
|
|
89
|
+
const n = Number(pid);
|
|
90
|
+
if (!Number.isInteger(n) || typeof raw?.refresh !== "string" || !raw.refresh) return null;
|
|
91
|
+
return { pid: n, refresh: raw.refresh, expires: typeof raw.expires === "number" ? raw.expires : 0 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Claim (or re-claim) a session for THIS process. Called wherever the account
|
|
95
|
+
// credential is minted or rotated — spawnAccountCredentials and
|
|
96
|
+
// refreshAccountCredentials — so the registry always holds the token that would
|
|
97
|
+
// actually work, including the rotations Pi drives on its own.
|
|
98
|
+
export function recordOwnedSession(cred: { refresh: string; expires: number }): void {
|
|
99
|
+
const reg = readRegistry();
|
|
100
|
+
reg[String(process.pid)] = { refresh: cred.refresh, expires: cred.expires };
|
|
101
|
+
writeRegistry(reg);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Drop this process's entry — the session is being revoked (clean exit, /signout), so
|
|
105
|
+
// it is about to stop existing server-side. Leaving it behind would advertise a dead
|
|
106
|
+
// session as a reclaimable orphan to the next launch.
|
|
107
|
+
export function forgetOwnedSession(): void {
|
|
108
|
+
const reg = readRegistry();
|
|
109
|
+
if (!(String(process.pid) in reg)) return;
|
|
110
|
+
delete reg[String(process.pid)];
|
|
111
|
+
writeRegistry(reg);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Sessions whose owning terminal is gone: candidates to adopt or revoke. Prunes
|
|
115
|
+
// entries that are unusable anyway (malformed, or past their expiry) as a side
|
|
116
|
+
// effect, so the file can't grow without bound. Our own pid is never a candidate.
|
|
117
|
+
export function orphanedSessions(now = Date.now()): OwnedSession[] {
|
|
118
|
+
const reg = readRegistry();
|
|
119
|
+
const orphans: OwnedSession[] = [];
|
|
120
|
+
let pruned = false;
|
|
121
|
+
|
|
122
|
+
for (const [pid, raw] of Object.entries(reg)) {
|
|
123
|
+
const entry = parseEntry(pid, raw);
|
|
124
|
+
if (!entry || (entry.expires > 0 && entry.expires <= now)) {
|
|
125
|
+
delete reg[pid]; // malformed, or dead server-side — nothing to reclaim
|
|
126
|
+
pruned = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (entry.pid === process.pid || isAlive(entry.pid)) continue; // ours, or a live terminal's
|
|
130
|
+
orphans.push(entry);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (pruned) writeRegistry(reg);
|
|
134
|
+
return orphans;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Forget one orphan, once it has been definitively handled (adopted, or confirmed dead
|
|
138
|
+
// server-side). A entry whose refresh merely FAILED TO REACH the server is deliberately
|
|
139
|
+
// kept: dropping it on a network blip would leak that row until its TTL.
|
|
140
|
+
export function dropOwnedSession(pid: number): void {
|
|
141
|
+
const reg = readRegistry();
|
|
142
|
+
if (!(String(pid) in reg)) return;
|
|
143
|
+
delete reg[String(pid)];
|
|
144
|
+
writeRegistry(reg);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Test seam: wipe the registry file.
|
|
148
|
+
export function clearOwnedSessions(): void {
|
|
149
|
+
try {
|
|
150
|
+
rmSync(accountSessionsPath(), { force: true });
|
|
151
|
+
} catch {
|
|
152
|
+
/* nothing to remove */
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/auth/privateer.ts
CHANGED
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs";
|
|
16
16
|
import { hostname, userInfo } from "node:os";
|
|
17
17
|
import { globalDir, credentialsPath } from "../config/paths.ts";
|
|
18
|
+
import {
|
|
19
|
+
type OwnedSession,
|
|
20
|
+
recordOwnedSession,
|
|
21
|
+
forgetOwnedSession,
|
|
22
|
+
orphanedSessions,
|
|
23
|
+
dropOwnedSession,
|
|
24
|
+
} from "./accountSessions.ts";
|
|
18
25
|
import { isAccountCapCode } from "../engine/errors.ts";
|
|
19
26
|
import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
|
|
20
27
|
import { pinAccountSignKey, clearAccountSignKey } from "../crypto/accountTrust.ts";
|
|
@@ -117,6 +124,15 @@ let _refreshInFlight: Promise<ChildSession> | null = null;
|
|
|
117
124
|
// right after revokeLocalSessions() so the next launch spawns a fresh session instead
|
|
118
125
|
// of reusing the revoked one. Doing both is safe; doing only one is not. See
|
|
119
126
|
// revokeLocalSessions and its callers (cli/chat.ts, daemon/index.ts).
|
|
127
|
+
//
|
|
128
|
+
// That pairing only covers a CLEAN exit, though. A terminal killed without running its
|
|
129
|
+
// shutdown hook leaves its row alive server-side for the full TTL, and the next launch
|
|
130
|
+
// used to spawn another on top of it — enough repeats and the spawn is refused with
|
|
131
|
+
// `429 CHILD_SESSION_CAP`. So every session is also recorded in a pid-keyed registry
|
|
132
|
+
// (auth/accountSessions.ts) and acquireAccountCredential reclaims one whose owning
|
|
133
|
+
// terminal is gone instead of spawning. Keep the registry in step with reality:
|
|
134
|
+
// recordOwnedSession wherever a credential is minted or rotated, forgetOwnedSession
|
|
135
|
+
// wherever one is revoked.
|
|
120
136
|
let _account: { accessToken: string } | null = null;
|
|
121
137
|
|
|
122
138
|
export function loadCredentials(): Credentials | null {
|
|
@@ -375,6 +391,29 @@ export async function runDeviceLogin(opts: {
|
|
|
375
391
|
* isolation, so two terminals never fight over one rotating token (which would
|
|
376
392
|
* trip the server's reuse-detection and revoke every session).
|
|
377
393
|
*/
|
|
394
|
+
// Turn a failed /auth/session/spawn into an accurate error.
|
|
395
|
+
//
|
|
396
|
+
// A 401 means the parent refresh token is gone — the machine login itself is dead, so
|
|
397
|
+
// clear it and announce (the UI flips to signed-out). EVERY OTHER status used to be
|
|
398
|
+
// reported as an expiry too, which actively misled: the common one is 429
|
|
399
|
+
// `CHILD_SESSION_CAP` ("Too many active terminals for this device. Sign one out and
|
|
400
|
+
// try again"), where /login is not the fix and the credentials are perfectly valid.
|
|
401
|
+
// Pass the server's own message through so the user learns what to actually do.
|
|
402
|
+
async function spawnFailure(res: Response): Promise<Error> {
|
|
403
|
+
if (res.status === 401) {
|
|
404
|
+
clearCredentials();
|
|
405
|
+
notifySessionExpired();
|
|
406
|
+
return new Error("Your Privateer session expired. Run /login to sign in again.");
|
|
407
|
+
}
|
|
408
|
+
let message: string | undefined;
|
|
409
|
+
try {
|
|
410
|
+
message = ((await res.json()) as { message?: string }).message;
|
|
411
|
+
} catch {
|
|
412
|
+
/* non-JSON body — fall back to the status line below */
|
|
413
|
+
}
|
|
414
|
+
return new Error(message?.trim() || `Couldn't start a Privateer session (HTTP ${res.status}).`);
|
|
415
|
+
}
|
|
416
|
+
|
|
378
417
|
async function spawnChildSession(): Promise<ChildSession> {
|
|
379
418
|
const parent = loadCredentials();
|
|
380
419
|
if (!parent) throw new Error("Not logged in to Privateer. Run /login.");
|
|
@@ -389,14 +428,7 @@ async function spawnChildSession(): Promise<ChildSession> {
|
|
|
389
428
|
}, {
|
|
390
429
|
headers: { Authorization: `Bearer ${parent.accessToken}` },
|
|
391
430
|
});
|
|
392
|
-
if (!res.ok)
|
|
393
|
-
// Parent refresh token invalid/expired → the machine login is gone.
|
|
394
|
-
if (res.status === 401) {
|
|
395
|
-
clearCredentials();
|
|
396
|
-
notifySessionExpired();
|
|
397
|
-
}
|
|
398
|
-
throw new Error("Your Privateer session expired. Run /login to sign in again.");
|
|
399
|
-
}
|
|
431
|
+
if (!res.ok) throw await spawnFailure(res);
|
|
400
432
|
const { accessToken, refreshToken } = (await res.json()) as ChildSession;
|
|
401
433
|
_child = { accessToken, refreshToken };
|
|
402
434
|
return _child;
|
|
@@ -543,6 +575,10 @@ export async function revokeAccountSession(timeoutMs = 1500): Promise<void> {
|
|
|
543
575
|
const account = _account;
|
|
544
576
|
if (!account) return;
|
|
545
577
|
_account = null;
|
|
578
|
+
// Stop advertising this session as reclaimable BEFORE killing it: an entry left
|
|
579
|
+
// behind would offer the next launch a dead row to adopt (it would fail over to a
|
|
580
|
+
// spawn, but only after a wasted round trip).
|
|
581
|
+
forgetOwnedSession();
|
|
546
582
|
await deleteSession(account.accessToken, timeoutMs);
|
|
547
583
|
}
|
|
548
584
|
|
|
@@ -615,26 +651,102 @@ export async function spawnAccountCredentials(): Promise<AccountCredential> {
|
|
|
615
651
|
{ refreshToken: parent.refreshToken, deviceLabel: defaultDeviceLabel() },
|
|
616
652
|
{ headers: { Authorization: `Bearer ${parent.accessToken}` } },
|
|
617
653
|
);
|
|
654
|
+
if (!res.ok) throw await spawnFailure(res);
|
|
655
|
+
const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
|
|
656
|
+
_account = { accessToken }; // track for explicit sign-out revoke (revokeAccountSession)
|
|
657
|
+
const cred = { access: accessToken, refresh: refreshToken, expires: jwtExpMs(accessToken) };
|
|
658
|
+
recordOwnedSession(cred); // claim the row, so a crash leaves it reclaimable
|
|
659
|
+
return cred;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// An /auth/refresh the server actively REFUSED, as opposed to one that never got an
|
|
663
|
+
// answer. Only the former proves the session is gone; a network failure says nothing,
|
|
664
|
+
// and treating it as death would leak the row (see dropOwnedSession).
|
|
665
|
+
export interface RefreshRejection extends Error {
|
|
666
|
+
status: number;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export function isRefreshRejection(e: unknown): e is RefreshRejection {
|
|
670
|
+
return e instanceof Error && typeof (e as RefreshRejection).status === "number";
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Rotate a session's refresh token, with NO ownership side effects. Split out from
|
|
674
|
+
// refreshAccountCredentials so orphan cleanup can rotate a session purely to obtain a
|
|
675
|
+
// token it can revoke with, without claiming that session as this terminal's own.
|
|
676
|
+
async function rotateSession(refresh: string): Promise<AccountCredential> {
|
|
677
|
+
const res = await postJson(serverBaseUrl(), "/auth/refresh", { refreshToken: refresh });
|
|
618
678
|
if (!res.ok) {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
}
|
|
623
|
-
throw new Error("Your Privateer session expired. Run /login to sign in again.");
|
|
679
|
+
const err = new Error(`account refresh failed (${res.status})`) as RefreshRejection;
|
|
680
|
+
err.status = res.status;
|
|
681
|
+
throw err;
|
|
624
682
|
}
|
|
625
683
|
const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
|
|
626
|
-
_account = { accessToken }; // track for explicit sign-out revoke (revokeAccountSession)
|
|
627
684
|
return { access: accessToken, refresh: refreshToken, expires: jwtExpMs(accessToken) };
|
|
628
685
|
}
|
|
629
686
|
|
|
630
687
|
// Rotate this account credential's own refresh token; caller falls back to a fresh
|
|
631
688
|
// spawn if this throws (expired/reused child token).
|
|
632
689
|
export async function refreshAccountCredentials(refresh: string): Promise<AccountCredential> {
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
690
|
+
const cred = await rotateSession(refresh);
|
|
691
|
+
_account = { accessToken: cred.access }; // the rotated session is the one an explicit sign-out revokes
|
|
692
|
+
// Re-claim on every rotation — including the ones Pi drives on expiry — so the
|
|
693
|
+
// registry always holds a token that would actually work if we crashed right now.
|
|
694
|
+
recordOwnedSession(cred);
|
|
695
|
+
return cred;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Get an account credential for THIS terminal, reusing a session orphaned by a
|
|
699
|
+
// terminal that died without revoking rather than stacking another row on top of it.
|
|
700
|
+
//
|
|
701
|
+
// Reclaiming is what keeps a crash from costing a permanent session slot: each orphan
|
|
702
|
+
// otherwise sits on the server for its full TTL, and enough of them earn a
|
|
703
|
+
// `429 CHILD_SESSION_CAP` on the next spawn. A successful /auth/refresh doubles as the
|
|
704
|
+
// liveness probe — it proves the row is real and hands back a usable access token —
|
|
705
|
+
// so an orphan that turns out to be dead just falls through to the next candidate.
|
|
706
|
+
//
|
|
707
|
+
// Orphans we don't adopt are revoked in the background: their terminal is gone, so the
|
|
708
|
+
// row is pure waste, and freeing it is what actually unwinds an account already at the
|
|
709
|
+
// cap. Never touches a session whose owner is still running (see accountSessions.ts).
|
|
710
|
+
export async function acquireAccountCredential(): Promise<AccountCredential> {
|
|
711
|
+
const orphans = orphanedSessions();
|
|
712
|
+
let adopted: AccountCredential | null = null;
|
|
713
|
+
let attempted = 0;
|
|
714
|
+
|
|
715
|
+
while (attempted < orphans.length && !adopted) {
|
|
716
|
+
const orphan = orphans[attempted++];
|
|
717
|
+
try {
|
|
718
|
+
adopted = await refreshAccountCredentials(orphan.refresh);
|
|
719
|
+
dropOwnedSession(orphan.pid); // the rotation above re-recorded it under OUR pid
|
|
720
|
+
} catch (e) {
|
|
721
|
+
// Refused → the session is gone; stop tracking it. Unreachable → keep it, so a
|
|
722
|
+
// network blip doesn't strand a live row we could have reclaimed next launch.
|
|
723
|
+
if (isRefreshRejection(e)) dropOwnedSession(orphan.pid);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Best-effort cleanup of the ones we didn't need. Detached: freeing slots must never
|
|
728
|
+
// delay startup, and a failure here costs nothing the next launch can't retry.
|
|
729
|
+
const leftovers = orphans.slice(attempted);
|
|
730
|
+
if (leftovers.length) void revokeOrphanedSessions(leftovers);
|
|
731
|
+
|
|
732
|
+
return adopted ?? (await spawnAccountCredentials());
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Revoke sessions whose terminal is gone. Revoking needs a LIVE access token
|
|
736
|
+
// (DELETE /auth/session/current is Bearer-authenticated) and an orphan's stored one is
|
|
737
|
+
// usually stale, so rotate first — via rotateSession, which deliberately does NOT claim
|
|
738
|
+
// ownership: these sessions are being destroyed, not adopted, and recording them would
|
|
739
|
+
// overwrite the entry for the credential this terminal is actually using.
|
|
740
|
+
async function revokeOrphanedSessions(orphans: OwnedSession[], timeoutMs = 1500): Promise<void> {
|
|
741
|
+
for (const orphan of orphans) {
|
|
742
|
+
try {
|
|
743
|
+
const cred = await rotateSession(orphan.refresh);
|
|
744
|
+
await deleteSession(cred.access, timeoutMs);
|
|
745
|
+
dropOwnedSession(orphan.pid);
|
|
746
|
+
} catch (e) {
|
|
747
|
+
if (isRefreshRejection(e)) dropOwnedSession(orphan.pid);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
638
750
|
}
|
|
639
751
|
|
|
640
752
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
package/src/cli/chat.ts
CHANGED
|
@@ -341,7 +341,7 @@ async function main() {
|
|
|
341
341
|
// resolves it; Pi then manages refresh on expiry via the registered oauth provider.
|
|
342
342
|
if (provider === "privateer") {
|
|
343
343
|
try {
|
|
344
|
-
const creds = await priv.
|
|
344
|
+
const creds = await priv.acquireAccountCredential();
|
|
345
345
|
(services.authStorage as any).set("privateer", { type: "oauth", ...creds });
|
|
346
346
|
} catch (e) {
|
|
347
347
|
console.log(`${RED}Account channel unavailable: ${(e as Error).message}${RESET}`);
|
package/src/config/paths.ts
CHANGED
|
@@ -39,3 +39,12 @@ export function credentialsPath(): string {
|
|
|
39
39
|
export function configPath(): string {
|
|
40
40
|
return join(globalDir(), "config.json");
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
// Account-provider inference sessions this MACHINE has spawned, keyed by the pid of
|
|
44
|
+
// the terminal that owns each one (see auth/accountSessions.ts). Lets a launch tell a
|
|
45
|
+
// session belonging to a STILL-RUNNING terminal from one orphaned by a crash, so it
|
|
46
|
+
// can reclaim the orphan instead of spawning another and walking into the server's
|
|
47
|
+
// per-device terminal cap. Holds refresh tokens — written 0600, like credentials.json.
|
|
48
|
+
export function accountSessionsPath(): string {
|
|
49
|
+
return join(globalDir(), "account-sessions.json");
|
|
50
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { openJsonFromApp } from "../crypto/terminalUnseal.ts";
|
|
|
30
30
|
import { verifyChannelSave, verifyOutboxKey } from "../crypto/accountVerify.ts";
|
|
31
31
|
import { loadAccountSignKey, loadLastControlTs, saveLastControlTs } from "../crypto/accountTrust.ts";
|
|
32
32
|
import { authorizeControl } from "../remote/controlAuth.ts";
|
|
33
|
-
import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest,
|
|
33
|
+
import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke } from "../auth/privateer.ts";
|
|
34
34
|
import {
|
|
35
35
|
loadRoutines,
|
|
36
36
|
upsertRoutine,
|
|
@@ -735,7 +735,7 @@ export class Daemon {
|
|
|
735
735
|
const { provider, modelId } = parseSpec(spec.model);
|
|
736
736
|
if (provider === "privateer") {
|
|
737
737
|
try {
|
|
738
|
-
const creds = await
|
|
738
|
+
const creds = await acquireAccountCredential();
|
|
739
739
|
(services.authStorage as any).set("privateer", { type: "oauth", ...creds });
|
|
740
740
|
spawnedAccount = true;
|
|
741
741
|
} catch (e) {
|
package/src/providers/account.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
hasCredentials,
|
|
16
16
|
runDeviceLogin,
|
|
17
17
|
authedFetch,
|
|
18
|
-
|
|
18
|
+
acquireAccountCredential,
|
|
19
19
|
refreshAccountCredentials,
|
|
20
20
|
notifySignedIn,
|
|
21
21
|
} from "../auth/privateer.ts";
|
|
@@ -169,7 +169,7 @@ export const privateerOAuthProvider = {
|
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
if (cb.signal?.aborted) throw new Error("Login cancelled");
|
|
172
|
-
const creds = await
|
|
172
|
+
const creds = await acquireAccountCredential();
|
|
173
173
|
// Seed Pi's saved model default to the account channel, so the next launch resolves
|
|
174
174
|
// to a billable subscription model instead of falling through to a keyless built-in
|
|
175
175
|
// (the "No API key found for openrouter" trap). No-op if the user already has a
|
|
@@ -184,8 +184,10 @@ export const privateerOAuthProvider = {
|
|
|
184
184
|
try {
|
|
185
185
|
return await refreshAccountCredentials(creds.refresh);
|
|
186
186
|
} catch {
|
|
187
|
-
//
|
|
188
|
-
|
|
187
|
+
// Child token expired/reused → get another. acquire (not spawn) so a terminal
|
|
188
|
+
// that already holds the device's last session slot can reclaim an orphan
|
|
189
|
+
// instead of being refused a fresh one mid-session.
|
|
190
|
+
return acquireAccountCredential();
|
|
189
191
|
}
|
|
190
192
|
},
|
|
191
193
|
getApiKey(creds: { access: string }): string {
|
|
@@ -256,6 +258,7 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
|
|
|
256
258
|
export function makeAccountProvider() {
|
|
257
259
|
return (pi: {
|
|
258
260
|
registerProvider?: (name: string, config: unknown) => void;
|
|
261
|
+
on?: (event: string, handler: (e: unknown, ctx: unknown) => void) => void;
|
|
259
262
|
}): void => {
|
|
260
263
|
if (typeof pi.registerProvider !== "function") return;
|
|
261
264
|
const register = (ids: string[]): void =>
|
|
@@ -274,5 +277,57 @@ export function makeAccountProvider() {
|
|
|
274
277
|
.catch(() => {
|
|
275
278
|
/* keep the fallback model */
|
|
276
279
|
});
|
|
280
|
+
|
|
281
|
+
// Seed the account channel's credential at launch. Nothing else does this in the
|
|
282
|
+
// TUI: Pi only obtains an OAuth credential by running /login, and our shutdown
|
|
283
|
+
// hook deliberately REVOKES the account session and deletes its persisted
|
|
284
|
+
// auth.json entry (see the LIFECYCLE HAZARD note in src/auth/privateer.ts). So a
|
|
285
|
+
// signed-in user who quits and relaunches lands on privateer/* with no key at
|
|
286
|
+
// all, and the first prompt dead-ends on "No API key found for privateer." — even
|
|
287
|
+
// though the banner says "connected". The REPL (cli/chat.ts) and the daemon
|
|
288
|
+
// already spawn one at startup; this gives the TUI the same seed.
|
|
289
|
+
pi.on?.("session_start", (_e, ctx) => void ensureAccountCredential(ctx));
|
|
277
290
|
};
|
|
278
291
|
}
|
|
292
|
+
|
|
293
|
+
// One spawn per PROCESS. session_start also fires for new/resume/fork/reload — all of
|
|
294
|
+
// which keep this process (and its account session) alive — so re-spawning there would
|
|
295
|
+
// leak a device row per event. A fresh process always spawns: a run that crashed
|
|
296
|
+
// without its shutdown hook can leave a REVOKED credential persisted in auth.json with
|
|
297
|
+
// a still-valid-looking `expires`, which Pi would happily reuse and 401 on.
|
|
298
|
+
//
|
|
299
|
+
// The flag lives on globalThis, not in module scope, because jiti gives each extension
|
|
300
|
+
// that imports this file its OWN module instance (see the note in auth/privateer.ts):
|
|
301
|
+
// privateer-account and privateer-brand — which hot-registers the provider on /signin —
|
|
302
|
+
// would otherwise hold separate flags and each spawn a session.
|
|
303
|
+
const SEEDED = Symbol.for("privateer.accountCredentialSeeded");
|
|
304
|
+
type SeedFlag = { [SEEDED]?: boolean };
|
|
305
|
+
|
|
306
|
+
// `ctx` is Pi's ExtensionContext; the auth store hangs off its model registry (the same
|
|
307
|
+
// path privateer-brand uses to DROP the credential on sign-out).
|
|
308
|
+
type SeedContext = {
|
|
309
|
+
modelRegistry?: { authStorage?: { set?: (provider: string, cred: unknown) => void } };
|
|
310
|
+
hasUI?: boolean;
|
|
311
|
+
ui?: { notify?: (message: string, level: string) => void };
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
async function ensureAccountCredential(ctx: unknown): Promise<void> {
|
|
315
|
+
const flag = globalThis as SeedFlag;
|
|
316
|
+
if (flag[SEEDED] || !hasCredentials()) return;
|
|
317
|
+
flag[SEEDED] = true;
|
|
318
|
+
const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
|
|
319
|
+
if (typeof store?.set !== "function") return;
|
|
320
|
+
try {
|
|
321
|
+
const creds = await acquireAccountCredential();
|
|
322
|
+
store.set("privateer", { type: "oauth", ...creds });
|
|
323
|
+
} catch (e) {
|
|
324
|
+
// The account channel is NOT armed: a dead machine login (401 → credentials cleared
|
|
325
|
+
// + onSessionExpired), the terminal cap (429), or a network blip. Say so now — the
|
|
326
|
+
// banner still reads "connected" (it only knows about the local credentials file),
|
|
327
|
+
// so staying silent leaves the user to discover it as a bare "No API key found for
|
|
328
|
+
// privateer" on their first prompt. Cleared so a later attempt can retry.
|
|
329
|
+
flag[SEEDED] = false;
|
|
330
|
+
const c = ctx as SeedContext;
|
|
331
|
+
if (c?.hasUI) c.ui?.notify?.(`Privateer account channel unavailable — ${(e as Error).message}`, "error");
|
|
332
|
+
}
|
|
333
|
+
}
|