privateer-agent 0.6.4 → 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 +183 -22
- package/SECURITY.md +54 -0
- package/bin/apply-patches.d.mts +18 -0
- package/bin/apply-patches.mjs +143 -0
- package/bin/privateer-launch.mjs +49 -6
- package/package.json +7 -3
- package/src/auth/accountSessions.ts +154 -0
- package/src/auth/privateer.ts +131 -19
- package/src/cli/chat.ts +1 -1
- package/src/config/hosted.ts +36 -0
- package/src/config/paths.ts +9 -0
- package/src/daemon/index.ts +77 -9
- package/src/providers/account.ts +59 -4
- package/src/remote/mcpControl.ts +268 -0
- package/src/remote/relayClient.ts +78 -0
- package/src/remote/remoteBridge.ts +7 -0
- package/src/routines/schema.ts +9 -1
|
@@ -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/hosted.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { globalDir } from "./paths.ts";
|
|
4
|
+
import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
|
|
5
|
+
|
|
1
6
|
// Harbor hosted mode.
|
|
2
7
|
//
|
|
3
8
|
// When true, this daemon is running inside Privateer's confidential-VM fleet
|
|
@@ -11,3 +16,34 @@
|
|
|
11
16
|
export function isHosted(): boolean {
|
|
12
17
|
return process.env.HARBOR_HOSTED === "1";
|
|
13
18
|
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Publish this daemon's relay identity key so the Harbor host can attest it.
|
|
22
|
+
*
|
|
23
|
+
* ATTESTATION CONTRACT (host side: treeview `server/services/harborOrchestrator/`):
|
|
24
|
+
* the orchestrator mints the SEV-SNP report on the CVM host — configfs-tsm is a
|
|
25
|
+
* privileged kernel interface a rootless tenant deliberately cannot reach — and binds
|
|
26
|
+
* `report_data[0:32] = sha256(DER-SPKI(terminalPub))`. To do that it needs OUR public
|
|
27
|
+
* key, so we drop it in `$PRIVATEER_HOME` (bind-mounted from host tmpfs) as the mirror
|
|
28
|
+
* of the `routines/relay-id` file the host seeds for us.
|
|
29
|
+
*
|
|
30
|
+
* It must be the key the app ACTUALLY drives over the relay — the same value we send
|
|
31
|
+
* in sendContext({ terminalPub }) — otherwise the app's fail-closed check reports a
|
|
32
|
+
* key mismatch. Base64 of the raw 32 X25519 bytes; the host wraps it in the SPKI DER
|
|
33
|
+
* prefix itself. Minting happens on first call, which is fine: this runs at boot,
|
|
34
|
+
* before the relay registers.
|
|
35
|
+
*
|
|
36
|
+
* Hosted-only and best-effort: on a user's own machine this is a no-op, and a write
|
|
37
|
+
* failure must never take the daemon down — attestation simply fail-closes host-side
|
|
38
|
+
* with HARBOR_ATTEST_NO_KEY rather than reporting a false "attested".
|
|
39
|
+
*/
|
|
40
|
+
export function publishRelayPub(): void {
|
|
41
|
+
if (!isHosted()) return;
|
|
42
|
+
try {
|
|
43
|
+
writeFileSync(join(globalDir(), "relay-pub"), terminalPublicKeyBase64(), { mode: 0o600 });
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(
|
|
46
|
+
`[harbor] could not publish relay-pub — enclave attestation will fail closed: ${String(err)}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
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
|
@@ -20,6 +20,7 @@ import { RelayClient, type TaskSpec } from "../remote/relayClient.ts";
|
|
|
20
20
|
import { createLiveTaskSession, type LiveTaskHandle } from "../remote/liveTaskSession.ts";
|
|
21
21
|
import { makeRoutinesControl } from "../remote/routinesControl.ts";
|
|
22
22
|
import { makeChannelsControl } from "../remote/channelsControl.ts";
|
|
23
|
+
import { makeMcpControl } from "../remote/mcpControl.ts";
|
|
23
24
|
import { makeWorkflowsControl } from "../remote/workflowsControl.ts";
|
|
24
25
|
import { runWorkflow as executeWorkflow, type RunnerDeps, type AgentRunSpec, type AgentRunResult, type ScriptRunResult } from "../workflows/runner.ts";
|
|
25
26
|
import type { Workflow, Step } from "../workflows/schema.ts";
|
|
@@ -29,7 +30,7 @@ import { openJsonFromApp } from "../crypto/terminalUnseal.ts";
|
|
|
29
30
|
import { verifyChannelSave, verifyOutboxKey } from "../crypto/accountVerify.ts";
|
|
30
31
|
import { loadAccountSignKey, loadLastControlTs, saveLastControlTs } from "../crypto/accountTrust.ts";
|
|
31
32
|
import { authorizeControl } from "../remote/controlAuth.ts";
|
|
32
|
-
import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest,
|
|
33
|
+
import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke } from "../auth/privateer.ts";
|
|
33
34
|
import {
|
|
34
35
|
loadRoutines,
|
|
35
36
|
upsertRoutine,
|
|
@@ -50,7 +51,7 @@ import { deliver, type RelayPusher, type CloudPusher } from "../routines/deliver
|
|
|
50
51
|
import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
|
|
51
52
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
52
53
|
import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
|
|
53
|
-
import { isHosted } from "../config/hosted.ts";
|
|
54
|
+
import { isHosted, publishRelayPub } from "../config/hosted.ts";
|
|
54
55
|
|
|
55
56
|
// The safe, read-only toolset for unattended runs — Pi builtins with no
|
|
56
57
|
// write/edit/bash, so a routine firing with nobody watching can't mutate the
|
|
@@ -190,6 +191,13 @@ export class Daemon {
|
|
|
190
191
|
runningPlatforms: () => readRunningPlatforms(),
|
|
191
192
|
});
|
|
192
193
|
|
|
194
|
+
// App-facing MCP connector management (list/save/set_enabled/remove) over the
|
|
195
|
+
// daemon's relay — the daemon is the Node HOST that actually runs the adapter (a
|
|
196
|
+
// phone/web client can't). Edits the SHARED agent/mcp-desktop.json + mcp.json, so a
|
|
197
|
+
// machine has one MCP config whether it was set from the desktop (IPC) or the phone
|
|
198
|
+
// (relay). Tokens ride in a SEALED box (applyMcpSave) — the relay never sees them.
|
|
199
|
+
private readonly mcp = makeMcpControl();
|
|
200
|
+
|
|
193
201
|
// App-facing workflow management (list/get/save/remove/run) over the daemon's relay.
|
|
194
202
|
// Run-now is injected here since only the daemon owns the runner + its seams. A
|
|
195
203
|
// workflow can carry a `script` step (RCE if forged), so every mutation is
|
|
@@ -219,6 +227,9 @@ export class Daemon {
|
|
|
219
227
|
};
|
|
220
228
|
|
|
221
229
|
start(): void {
|
|
230
|
+
// Hosted only: publish our relay pubkey for the host to bind into the SEV-SNP
|
|
231
|
+
// report. Before syncRelay() so the key exists by the time we're reachable.
|
|
232
|
+
publishRelayPub();
|
|
222
233
|
this.primeSchedule();
|
|
223
234
|
this.timer = setInterval(() => void this.tick(), TICK_MS);
|
|
224
235
|
this.server = startIpcServer((req) => this.handleIpc(req));
|
|
@@ -287,6 +298,14 @@ export class Daemon {
|
|
|
287
298
|
onChannelsList: () => this.pushChannels(),
|
|
288
299
|
onChannelsSave: (draft, sealedSecrets, sig, ts) => this.pushChannels(this.applyChannelSave(draft, sealedSecrets, sig, ts)),
|
|
289
300
|
onChannelsRemove: (platform, sig, ts) => this.pushChannels(this.guardControl("channels_remove", { platform }, sig, ts, () => this.channels.remove(platform as any).message)),
|
|
301
|
+
// MCP connector management from the app. `save` has its own signed verify (it
|
|
302
|
+
// carries a sealed env box — applyMcpSave); `set_enabled`/`remove` are
|
|
303
|
+
// account-signed here (H2 — a forged toggle arms/disarms a tool surface; a
|
|
304
|
+
// forged removal is a DoS). Then mcpControl writes the shared config + re-pushes.
|
|
305
|
+
onMcpList: () => this.pushMcp(),
|
|
306
|
+
onMcpSave: (draft, sealedSecrets, sig, ts) => this.pushMcp(this.applyMcpSave(draft, sealedSecrets, sig, ts)),
|
|
307
|
+
onMcpSetEnabled: (name, enabled, sig, ts) => this.pushMcp(this.guardControl("mcp_set_enabled", { name, enabled }, sig, ts, () => this.mcp.setEnabled(name, enabled).message)),
|
|
308
|
+
onMcpRemove: (name, sig, ts) => this.pushMcp(this.guardControl("mcp_remove", { name }, sig, ts, () => this.mcp.remove(name).message)),
|
|
290
309
|
// Workflow management from the app. Each MUTATION is account-signed (H2) — a forged
|
|
291
310
|
// workflows_save plants a `script` step that bypasses the permission gate (RCE),
|
|
292
311
|
// and workflows_run executes the graph — so all three are verified (guardControl,
|
|
@@ -340,6 +359,42 @@ export class Daemon {
|
|
|
340
359
|
this.relay?.sendChannels({ items: this.channels.list(), message });
|
|
341
360
|
}
|
|
342
361
|
|
|
362
|
+
private pushMcp(message?: string): void {
|
|
363
|
+
this.relay?.sendMcp({ items: this.mcp.list(), message });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Verify an account-signed MCP save (H2) that also carries a SEALED env box, then
|
|
367
|
+
// apply it. Same shape as applyChannelSave but routed through the generic signed
|
|
368
|
+
// envelope (action "mcp_save", args {draft, sealedSecrets}) — the action tag stops a
|
|
369
|
+
// signature made for any other frame from being replayed as an MCP save. Fail-closed:
|
|
370
|
+
// an unsigned/forged/stale frame returns the refusal message and NOTHING is written.
|
|
371
|
+
// The sealed box opens to { termId, env } — a token the relay never sees in the clear.
|
|
372
|
+
private applyMcpSave(draft: Record<string, unknown>, sealedSecrets?: string, sig?: string, ts?: number): string | undefined {
|
|
373
|
+
const auth = authorizeControl(
|
|
374
|
+
routineRelayId(),
|
|
375
|
+
"mcp_save",
|
|
376
|
+
{ draft, sealedSecrets: sealedSecrets ?? null },
|
|
377
|
+
sig,
|
|
378
|
+
ts,
|
|
379
|
+
);
|
|
380
|
+
if (!auth.ok) return auth.message;
|
|
381
|
+
|
|
382
|
+
let withEnv = draft;
|
|
383
|
+
if (sealedSecrets) {
|
|
384
|
+
let opened: { termId?: string; env?: Record<string, string> };
|
|
385
|
+
try {
|
|
386
|
+
opened = openJsonFromApp(sealedSecrets);
|
|
387
|
+
} catch {
|
|
388
|
+
return "Couldn't decrypt the connector credentials — they may have been sealed to a different terminal.";
|
|
389
|
+
}
|
|
390
|
+
if (opened.termId !== routineRelayId()) {
|
|
391
|
+
return "These credentials were addressed to a different terminal.";
|
|
392
|
+
}
|
|
393
|
+
withEnv = { ...draft, env: opened.env ?? {} };
|
|
394
|
+
}
|
|
395
|
+
return this.mcp.save(withEnv as any).message;
|
|
396
|
+
}
|
|
397
|
+
|
|
343
398
|
// Push the current workflow summaries to an attached controller (its workflows
|
|
344
399
|
// manager). `message` is a one-line result from the last mutation, if any.
|
|
345
400
|
private pushWorkflows(message?: string): void {
|
|
@@ -600,9 +655,14 @@ export class Daemon {
|
|
|
600
655
|
const config = loadDaemonConfig();
|
|
601
656
|
const modelSpec = routine.model ?? config.defaultModel;
|
|
602
657
|
const split = splitRoutineTools(routine.tools);
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
658
|
+
// MCP tools (server__tool) join the allow-list: the mcpAdapter loaded in runSession
|
|
659
|
+
// registers them from the shared mcp.json, and the routine's SIGNED tool list is the
|
|
660
|
+
// authorization boundary under the bypass gate (same as builtin tools). An http/OAuth
|
|
661
|
+
// connector that never completed its browser flow simply errors at call time.
|
|
662
|
+
const builtinAllow = split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
|
|
663
|
+
const allowedTools = [...builtinAllow, ...split.mcp];
|
|
664
|
+
if (routine.delivery.includes("email")) {
|
|
665
|
+
log(" note: email delivery is not wired yet (Phase 5) — skipping it");
|
|
606
666
|
}
|
|
607
667
|
|
|
608
668
|
const { out, status, error } = await this.runSession({
|
|
@@ -655,11 +715,19 @@ export class Daemon {
|
|
|
655
715
|
return "deny";
|
|
656
716
|
},
|
|
657
717
|
};
|
|
718
|
+
// MCP adapter (Phase 5): registers the tools from the shared agent/mcp.json — the
|
|
719
|
+
// same projection the app's MCP manager (mcpControl) writes over the relay. No
|
|
720
|
+
// servers configured → a no-op. Dynamically imported so it loads only when a
|
|
721
|
+
// session actually runs (Pi is already booted by here). The specifier is a
|
|
722
|
+
// variable so tsc treats it as Promise<any> and doesn't pull the third-party
|
|
723
|
+
// adapter's own .ts into our typecheck — same intent as the desktop's agentImport.
|
|
724
|
+
const mcpAdapterSpec = "pi-mcp-adapter";
|
|
725
|
+
const { default: mcpAdapter } = await import(mcpAdapterSpec);
|
|
658
726
|
const services = await createAgentSessionServices({
|
|
659
727
|
cwd: spec.cwd,
|
|
660
728
|
agentDir: agentDir(),
|
|
661
729
|
resourceLoaderOptions: {
|
|
662
|
-
extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
|
|
730
|
+
extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider(), mcpAdapter] as any,
|
|
663
731
|
},
|
|
664
732
|
});
|
|
665
733
|
servicesRef = services as any;
|
|
@@ -667,7 +735,7 @@ export class Daemon {
|
|
|
667
735
|
const { provider, modelId } = parseSpec(spec.model);
|
|
668
736
|
if (provider === "privateer") {
|
|
669
737
|
try {
|
|
670
|
-
const creds = await
|
|
738
|
+
const creds = await acquireAccountCredential();
|
|
671
739
|
(services.authStorage as any).set("privateer", { type: "oauth", ...creds });
|
|
672
740
|
spawnedAccount = true;
|
|
673
741
|
} catch (e) {
|
|
@@ -724,7 +792,7 @@ export class Daemon {
|
|
|
724
792
|
const cwd = spec.cwd && spec.cwd.trim() ? spec.cwd : process.cwd();
|
|
725
793
|
const modelSpec = spec.model && spec.model.trim() ? spec.model : config.defaultModel;
|
|
726
794
|
const split = spec.tools && spec.tools.length ? splitRoutineTools(spec.tools) : undefined;
|
|
727
|
-
const allowedTools = split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
|
|
795
|
+
const allowedTools = [...(split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS), ...(split?.mcp ?? [])];
|
|
728
796
|
const title = deriveTaskTitle(spec);
|
|
729
797
|
const key = `task:${title}`;
|
|
730
798
|
if (this.running.has(key)) {
|
|
@@ -904,7 +972,7 @@ export class Daemon {
|
|
|
904
972
|
const config = loadDaemonConfig();
|
|
905
973
|
const model = spec.model && spec.model.trim() ? spec.model : config.defaultModel;
|
|
906
974
|
const split = spec.tools && spec.tools.length ? splitRoutineTools(spec.tools) : undefined;
|
|
907
|
-
const tools = split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
|
|
975
|
+
const tools = [...(split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS), ...(split?.mcp ?? [])];
|
|
908
976
|
const { out, status, error } = await this.runSession({ prompt: spec.prompt, cwd: spec.cwd, model, tools });
|
|
909
977
|
let output: Record<string, unknown> = {};
|
|
910
978
|
try { const p = JSON.parse(out.trim()); if (p && typeof p === "object" && !Array.isArray(p)) output = p as Record<string, unknown>; } catch { /* non-JSON → raw text only */ }
|