privateer-agent 0.14.2 → 0.14.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,21 +21,50 @@
21
21
  // RECYCLED pid makes a dead owner look alive, so we skip a reclaimable session and
22
22
  // spawn a fresh one — the old behaviour, no harm. The dangerous direction (a live
23
23
  // process reported dead) can't happen: a running pid never reports ESRCH.
24
+ //
25
+ // LAYOUT — one file per owning pid (`<pid>.json` in accountSessionsDir), and an orphan
26
+ // is CLAIMED by renaming its file before anyone rotates it. This used to be a single
27
+ // JSON file that every process read, modified and wrote back whole, and that was a real
28
+ // outage under concurrency (an agent firing several `privateer -p` one-shots at once):
29
+ //
30
+ // A reads {orphan: R} · B reads {orphan: R} · B adopts the orphan (R → R′) and
31
+ // writes it out · A writes ITS stale copy back, orphan entry and all.
32
+ //
33
+ // The resurrected entry names a refresh token that was already rotated. The next launch
34
+ // "reclaims" it, the server sees a spent token replayed past its 30s grace window, calls
35
+ // it theft, and revokes the whole lineage — which is the session B (or whoever adopted
36
+ // down the chain) is using right now, and possibly the one in the shared auth.json that
37
+ // other running terminals read. They all 401 mid-run, and a one-shot run has no next
38
+ // message to recover on, so the user is told to /login while signed in.
39
+ //
40
+ // Per-pid files mean no process ever writes another's entry, and rename is atomic on
41
+ // every platform we ship on, so exactly one claimer wins an orphan and the rest get
42
+ // ENOENT and move on. A claim whose claimant died is deleted rather than released: its
43
+ // token may already have been rotated, and offering it again is the same hazard.
24
44
 
25
- import { existsSync, mkdirSync, readFileSync, rmSync, renameSync, writeFileSync, chmodSync } from "node:fs";
26
- import { dirname } from "node:path";
27
- import { accountSessionsPath, globalDir } from "../config/paths.ts";
45
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, renameSync, writeFileSync, chmodSync } from "node:fs";
46
+ import { join } from "node:path";
47
+ import { accountSessionsDir, accountSessionsPath, globalDir } from "../config/paths.ts";
28
48
 
29
- // One spawned session, keyed in the file by the pid of its owning terminal. `refresh`
30
- // is what lets a later launch adopt or revoke it; `expires` is its access token's exp
31
- // (see jwtExpMs), used only to prune entries that are dead server-side anyway.
49
+ // One spawned session, keyed by the pid of its owning terminal. `refresh` is what lets
50
+ // a later launch adopt or revoke it; `expires` is its access token's exp (see
51
+ // jwtExpMs), used only to prune entries that are dead server-side anyway.
32
52
  export interface OwnedSession {
33
53
  pid: number;
34
54
  refresh: string;
35
55
  expires: number;
36
56
  }
37
57
 
38
- type Registry = Record<string, { refresh?: unknown; expires?: unknown }>;
58
+ const ENTRY = /^(\d+)\.json$/;
59
+ const CLAIM = /^(\d+)\.claim-(\d+)$/;
60
+
61
+ function entryPath(pid: number): string {
62
+ return join(accountSessionsDir(), `${pid}.json`);
63
+ }
64
+
65
+ function claimPath(pid: number, claimant = process.pid): string {
66
+ return join(accountSessionsDir(), `${pid}.claim-${claimant}`);
67
+ }
39
68
 
40
69
  function tryChmod(path: string, mode: number): void {
41
70
  try {
@@ -45,31 +74,21 @@ function tryChmod(path: string, mode: number): void {
45
74
  }
46
75
  }
47
76
 
48
- function readRegistry(): Registry {
49
- const path = accountSessionsPath();
50
- if (!existsSync(path)) return {};
77
+ function tryRm(path: string): void {
51
78
  try {
52
- const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
53
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Registry) : {};
79
+ rmSync(path, { force: true });
54
80
  } catch {
55
- return {}; // corrupt/truncated — start clean rather than wedging every launch
81
+ /* already gone, or unremovable — nothing more to do */
56
82
  }
57
83
  }
58
84
 
59
- // Write via temp + rename so a concurrent reader never sees a half-written file. Two
60
- // terminals racing can still lose one entry (last writer wins); the cost is one
61
- // unreclaimable orphan, not a broken launch, so a lock file isn't worth it here.
62
- function writeRegistry(reg: Registry): void {
63
- const path = accountSessionsPath();
85
+ function readEntry(path: string, pid: number): OwnedSession | null {
64
86
  try {
65
- mkdirSync(dirname(path), { recursive: true });
66
- tryChmod(globalDir(), 0o700);
67
- const tmp = `${path}.${process.pid}.tmp`;
68
- writeFileSync(tmp, JSON.stringify(reg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
69
- tryChmod(tmp, 0o600);
70
- renameSync(tmp, path);
87
+ const raw = JSON.parse(readFileSync(path, "utf8")) as { refresh?: unknown; expires?: unknown };
88
+ if (typeof raw?.refresh !== "string" || !raw.refresh) return null;
89
+ return { pid, refresh: raw.refresh, expires: typeof raw.expires === "number" ? raw.expires : 0 };
71
90
  } catch {
72
- /* best effort — losing the registry costs reclamation, never correctness */
91
+ return null; // corrupt/truncated — treated as malformed and pruned
73
92
  }
74
93
  }
75
94
 
@@ -86,72 +105,109 @@ function isAlive(pid: number): boolean {
86
105
  }
87
106
  }
88
107
 
89
- function parseEntry(pid: string, raw: { refresh?: unknown; expires?: unknown }): OwnedSession | null {
90
- const n = Number(pid);
91
- if (!Number.isInteger(n) || typeof raw?.refresh !== "string" || !raw.refresh) return null;
92
- return { pid: n, refresh: raw.refresh, expires: typeof raw.expires === "number" ? raw.expires : 0 };
93
- }
94
-
95
108
  // Claim (or re-claim) a session for THIS process. Called wherever the account
96
109
  // credential is minted or rotated — spawnAccountCredentials and
97
110
  // refreshAccountCredentials — so the registry always holds the token that would
98
- // actually work, including the rotations Pi drives on its own.
111
+ // actually work, including the rotations Pi drives on its own. Written via temp +
112
+ // rename so a concurrent reader never sees a half-written file.
99
113
  export function recordOwnedSession(cred: { refresh: string; expires: number }): void {
100
- const reg = readRegistry();
101
- reg[String(process.pid)] = { refresh: cred.refresh, expires: cred.expires };
102
- writeRegistry(reg);
114
+ try {
115
+ mkdirSync(accountSessionsDir(), { recursive: true });
116
+ tryChmod(globalDir(), 0o700);
117
+ tryChmod(accountSessionsDir(), 0o700);
118
+ const path = entryPath(process.pid);
119
+ const tmp = `${path}.${process.pid}.tmp`;
120
+ writeFileSync(tmp, JSON.stringify({ refresh: cred.refresh, expires: cred.expires }) + "\n", {
121
+ encoding: "utf8",
122
+ mode: 0o600,
123
+ });
124
+ tryChmod(tmp, 0o600);
125
+ renameSync(tmp, path);
126
+ } catch {
127
+ /* best effort — losing the registry costs reclamation, never correctness */
128
+ }
103
129
  }
104
130
 
105
131
  // Drop this process's entry — the session is being revoked (clean exit, /signout), so
106
132
  // it is about to stop existing server-side. Leaving it behind would advertise a dead
107
133
  // session as a reclaimable orphan to the next launch.
108
134
  export function forgetOwnedSession(): void {
109
- const reg = readRegistry();
110
- if (!(String(process.pid) in reg)) return;
111
- delete reg[String(process.pid)];
112
- writeRegistry(reg);
135
+ tryRm(entryPath(process.pid));
113
136
  }
114
137
 
115
- // Sessions whose owning terminal is gone: candidates to adopt or revoke. Prunes
116
- // entries that are unusable anyway (malformed, or past their expiry) as a side
117
- // effect, so the file can't grow without bound. Our own pid is never a candidate.
138
+ // Sessions whose owning terminal is gone: candidates to adopt or revoke. Listing is NOT
139
+ // claiming — call claimOrphan before rotating one. Prunes entries that are unusable
140
+ // anyway (malformed, or past their expiry) and claims left by a dead claimant, so the
141
+ // directory can't grow without bound. Our own pid is never a candidate.
118
142
  export function orphanedSessions(now = Date.now()): OwnedSession[] {
119
- const reg = readRegistry();
143
+ let names: string[];
144
+ try {
145
+ names = readdirSync(accountSessionsDir());
146
+ } catch {
147
+ return []; // no registry yet
148
+ }
120
149
  const orphans: OwnedSession[] = [];
121
- let pruned = false;
122
-
123
- for (const [pid, raw] of Object.entries(reg)) {
124
- const entry = parseEntry(pid, raw);
150
+ for (const name of names) {
151
+ const path = join(accountSessionsDir(), name);
152
+ const claim = CLAIM.exec(name);
153
+ if (claim) {
154
+ // Mid-adoption by a live process: theirs. Claimant died: its token may already
155
+ // be rotated, so it is junk — never put back up for reclamation.
156
+ if (!isAlive(Number(claim[2]))) tryRm(path);
157
+ continue;
158
+ }
159
+ const m = ENTRY.exec(name);
160
+ if (!m) continue; // a temp file mid-write, or something that isn't ours
161
+ const pid = Number(m[1]);
162
+ if (pid === process.pid || isAlive(pid)) continue; // ours, or a live terminal's
163
+ const entry = readEntry(path, pid);
125
164
  if (!entry || (entry.expires > 0 && entry.expires <= now)) {
126
- delete reg[pid]; // malformed, or dead server-side — nothing to reclaim
127
- pruned = true;
165
+ tryRm(path); // malformed, or dead server-side — nothing to reclaim
128
166
  continue;
129
167
  }
130
- if (entry.pid === process.pid || isAlive(entry.pid)) continue; // ours, or a live terminal's
131
168
  orphans.push(entry);
132
169
  }
133
-
134
- if (pruned) writeRegistry(reg);
135
170
  return orphans;
136
171
  }
137
172
 
173
+ // Take an orphan for THIS process, atomically. True only for the one caller whose rename
174
+ // landed; everyone else racing for the same orphan gets false and must leave it alone.
175
+ // Pair with dropOwnedSession (handled) or releaseOrphan (couldn't reach the server).
176
+ export function claimOrphan(pid: number): boolean {
177
+ try {
178
+ renameSync(entryPath(pid), claimPath(pid));
179
+ return true;
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ // Put back an orphan we claimed but couldn't settle: its refresh never reached the
186
+ // server, so the token is still the live one and the row is still worth reclaiming.
187
+ export function releaseOrphan(pid: number): void {
188
+ try {
189
+ renameSync(claimPath(pid), entryPath(pid));
190
+ } catch {
191
+ tryRm(claimPath(pid));
192
+ }
193
+ }
194
+
138
195
  // Forget one orphan, once it has been definitively handled (adopted, or confirmed dead
139
- // server-side). A entry whose refresh merely FAILED TO REACH the server is deliberately
140
- // kept: dropping it on a network blip would leak that row until its TTL.
196
+ // server-side). An entry whose refresh merely FAILED TO REACH the server goes back via
197
+ // releaseOrphan instead: dropping it on a network blip would leak that row until its TTL.
141
198
  export function dropOwnedSession(pid: number): void {
142
- const reg = readRegistry();
143
- if (!(String(pid) in reg)) return;
144
- delete reg[String(pid)];
145
- writeRegistry(reg);
199
+ tryRm(claimPath(pid));
200
+ if (pid !== process.pid) tryRm(entryPath(pid));
146
201
  }
147
202
 
148
203
  // Wipe the registry. Used by logout(), where the server has just revoked this
149
204
  // machine's whole token family: every entry now names a dead session, and leaving
150
205
  // them behind would offer the next login a list of orphans to "reclaim" that can
151
- // only fail. Also a test seam.
206
+ // only fail. Also removes the legacy single-file registry. Also a test seam.
152
207
  export function clearOwnedSessions(): void {
208
+ tryRm(accountSessionsPath());
153
209
  try {
154
- rmSync(accountSessionsPath(), { force: true });
210
+ if (existsSync(accountSessionsDir())) rmSync(accountSessionsDir(), { recursive: true, force: true });
155
211
  } catch {
156
212
  /* nothing to remove */
157
213
  }
@@ -20,6 +20,8 @@ import {
20
20
  recordOwnedSession,
21
21
  forgetOwnedSession,
22
22
  orphanedSessions,
23
+ claimOrphan,
24
+ releaseOrphan,
23
25
  dropOwnedSession,
24
26
  clearOwnedSessions,
25
27
  } from "./accountSessions.ts";
@@ -811,13 +813,17 @@ export async function acquireAccountCredential(): Promise<AccountCredential> {
811
813
 
812
814
  while (attempted < orphans.length && !adopted) {
813
815
  const orphan = orphans[attempted++];
816
+ // Another launch racing us may already have it. Rotating it anyway would replay a
817
+ // token that launch just spent — reuse detection, and its session revoked mid-run.
818
+ if (!claimOrphan(orphan.pid)) continue;
814
819
  try {
815
820
  adopted = await refreshAccountCredentials(orphan.refresh);
816
821
  dropOwnedSession(orphan.pid); // the rotation above re-recorded it under OUR pid
817
822
  } catch (e) {
818
- // Refused → the session is gone; stop tracking it. Unreachable → keep it, so a
819
- // network blip doesn't strand a live row we could have reclaimed next launch.
823
+ // Refused → the session is gone; stop tracking it. Unreachable → put it back, so
824
+ // a network blip doesn't strand a live row we could have reclaimed next launch.
820
825
  if (isRefreshRejection(e)) dropOwnedSession(orphan.pid);
826
+ else releaseOrphan(orphan.pid);
821
827
  }
822
828
  }
823
829
 
@@ -836,12 +842,14 @@ export async function acquireAccountCredential(): Promise<AccountCredential> {
836
842
  // overwrite the entry for the credential this terminal is actually using.
837
843
  async function revokeOrphanedSessions(orphans: OwnedSession[], timeoutMs = 1500): Promise<void> {
838
844
  for (const orphan of orphans) {
845
+ if (!claimOrphan(orphan.pid)) continue; // another launch got there first
839
846
  try {
840
847
  const cred = await rotateSession(orphan.refresh);
841
848
  await deleteSession(cred.access, timeoutMs);
842
849
  dropOwnedSession(orphan.pid);
843
850
  } catch (e) {
844
851
  if (isRefreshRejection(e)) dropOwnedSession(orphan.pid);
852
+ else releaseOrphan(orphan.pid);
845
853
  }
846
854
  }
847
855
  }
@@ -45,6 +45,18 @@ export function configPath(): string {
45
45
  // session belonging to a STILL-RUNNING terminal from one orphaned by a crash, so it
46
46
  // can reclaim the orphan instead of spawning another and walking into the server's
47
47
  // per-device terminal cap. Holds refresh tokens — written 0600, like credentials.json.
48
+ //
49
+ // One file per owning pid in a directory, not one shared JSON file: concurrent launches
50
+ // (an agent firing several `privateer -p` runs at once) each read-modify-wrote the whole
51
+ // file, so one could write back an orphan another had already adopted (see
52
+ // auth/accountSessions.ts).
53
+ export function accountSessionsDir(): string {
54
+ return join(globalDir(), "account-sessions");
55
+ }
56
+
57
+ // The pre-directory single-file registry. Only ever deleted now (clearOwnedSessions);
58
+ // its entries are never offered for reclamation, because a stale one is exactly the
59
+ // token that trips the server's reuse detection.
48
60
  export function accountSessionsPath(): string {
49
61
  return join(globalDir(), "account-sessions.json");
50
62
  }
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
3
- "reviewedFor": "0.14.2",
3
+ "reviewedFor": "0.14.3",
4
4
  "items": [
5
+ { "text": "Several privateer -p runs at once no longer sign each other out", "since": "0.14.3" },
5
6
  { "text": "A reply cut off at the output limit picks back up on its own", "since": "0.14.2" },
6
7
  { "text": "Chat channels as a login service: privateer channels install", "since": "0.14.1" },
7
- { "text": "Hand off sensitive files: privateer --private", "since": "0.14.0" },
8
- { "text": "Open and switch named terminals —", "cmd": "/term", "since": "0.13.0" }
8
+ { "text": "Hand off sensitive files: privateer --private", "since": "0.14.0" }
9
9
  ]
10
10
  }
@@ -597,7 +597,7 @@ export function describeErrorText(
597
597
  bare ? `${who} ${refused} this request was sent with (${status}).` : compactProviderError(s),
598
598
  ),
599
599
  hint: account
600
- ? "This terminal's Privateer session was refused. It is renewed automatically on your next message; if it keeps failing, run /login (or `privateer auth status` to see whether this machine is signed in)."
600
+ ? "This run's Privateer session was refused. A fresh one is opened automatically — send the message again, or re-run a one-shot (`-p`) command. Only if it keeps failing, check `privateer auth status`."
601
601
  : `Check the API key for ${who} — run /login, or set the provider's API key env var.`,
602
602
  };
603
603
  }
@@ -227,12 +227,22 @@ async function callAccount<T>(
227
227
  if (res.status === 402 || /INSUFFICIENT|QUOTA|TOP_?UP/i.test(code)) {
228
228
  return { ok: false, status: res.status, message: serverMessage || "the account is out of credit for media generation — top up or upgrade to continue" };
229
229
  }
230
- if (res.status === 401 || res.status === 403) {
230
+ // Same split as callRag (tools/web.ts): a 401 survived authedFetch's retry, a 403 is
231
+ // a refusal that signing in again won't change.
232
+ if (res.status === 401) {
231
233
  return {
232
234
  ok: false, status: res.status,
233
235
  message:
234
236
  serverMessage ||
235
- "this agent is not signed in to a Privateer account (or the plan doesn't include this), so it cannot generate media",
237
+ "Privateer refused this agent's session (401) — running the command again opens a fresh one; if it keeps failing, check `privateer auth status`",
238
+ };
239
+ }
240
+ if (res.status === 403) {
241
+ return {
242
+ ok: false, status: res.status,
243
+ message:
244
+ serverMessage ||
245
+ "Privateer refused this media request (403) — the plan may not include it; this is not a sign-in problem",
236
246
  };
237
247
  }
238
248
  if (res.status === 400 || res.status === 413) {
package/src/tools/web.ts CHANGED
@@ -129,8 +129,23 @@ export async function callRag<T>(path: string, body: unknown): Promise<({ ok: tr
129
129
  if (res.status === 402 || /CAP|QUOTA|INSUFFICIENT|TOP_?UP/i.test(code)) {
130
130
  return { ok: false, message: serverMessage || "the account is out of credit for web access — top up or upgrade to continue" };
131
131
  }
132
- if (res.status === 401 || res.status === 403) {
133
- return { ok: false, message: "this agent is not signed in to a Privateer account, so it has no web access" };
132
+ // A 401 here has already survived authedFetch's refresh-and-retry, so the session
133
+ // really couldn't be renewed. A 403 is NOT a sign-in problem — a plan or account
134
+ // refusal, or the edge firewall in front of the API — and calling it "not signed in"
135
+ // had models telling signed-in users to log in. Pass the server's reason through.
136
+ if (res.status === 401) {
137
+ return {
138
+ ok: false,
139
+ message:
140
+ serverMessage ||
141
+ "Privateer refused this agent's session for web access (401) — running the command again opens a fresh one; if it keeps failing, check `privateer auth status`",
142
+ };
143
+ }
144
+ if (res.status === 403) {
145
+ return {
146
+ ok: false,
147
+ message: serverMessage || "Privateer refused this web request (403) — not a sign-in problem, so logging in again won't change it",
148
+ };
134
149
  }
135
150
  if (res.status === 400) {
136
151
  return { ok: false, message: serverMessage || `Privateer rejected the request${code ? ` (${code})` : ""}` };