privateer-agent 0.3.5 → 0.4.0

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.
Files changed (48) hide show
  1. package/README.md +19 -0
  2. package/bin/privateer-daemon.mjs +30 -0
  3. package/bin/privateer-subagent.mjs +68 -0
  4. package/bin/privateer-tui +35 -5
  5. package/extensions/privateer-brand.ts +213 -34
  6. package/extensions/privateer-context.ts +59 -0
  7. package/extensions/privateer-gate.ts +351 -6
  8. package/extensions/privateer-posture.ts +18 -2
  9. package/extensions/privateer-privacy.ts +50 -1
  10. package/package.json +4 -1
  11. package/src/auth/privateer.ts +151 -19
  12. package/src/channels/bridge.ts +293 -0
  13. package/src/channels/discord.ts +210 -0
  14. package/src/channels/run.ts +383 -0
  15. package/src/channels/slack.ts +176 -0
  16. package/src/channels/status.ts +54 -0
  17. package/src/channels/telegram.ts +139 -0
  18. package/src/channels/types.ts +36 -0
  19. package/src/channels/whatsapp.ts +178 -0
  20. package/src/cli/chat.ts +414 -28
  21. package/src/cli/daemonCli.ts +67 -0
  22. package/src/config/version.ts +16 -0
  23. package/src/context.ts +171 -0
  24. package/src/crypto/accountTrust.ts +113 -0
  25. package/src/crypto/accountVerify.ts +138 -0
  26. package/src/crypto/terminalKey.ts +95 -0
  27. package/src/crypto/terminalUnseal.ts +62 -0
  28. package/src/daemon/index.ts +522 -34
  29. package/src/daemon/service.ts +232 -0
  30. package/src/ext/permissionGate.ts +38 -0
  31. package/src/permissions/classify.ts +49 -5
  32. package/src/providers/account.ts +20 -12
  33. package/src/remote/channelsControl.ts +192 -0
  34. package/src/remote/controlAuth.ts +67 -0
  35. package/src/remote/extensionsControl.ts +140 -0
  36. package/src/remote/liveTaskSession.ts +218 -0
  37. package/src/remote/relayClient.ts +524 -0
  38. package/src/remote/remoteBridge.ts +172 -0
  39. package/src/remote/routinesControl.ts +216 -0
  40. package/src/remote/skillsControl.ts +205 -0
  41. package/src/remote/subagentChannel.ts +261 -0
  42. package/src/remote/subagentRelay.ts +126 -0
  43. package/src/remote/workflowsControl.ts +132 -0
  44. package/src/routines/store.ts +5 -1
  45. package/src/workflows/expr.ts +4 -0
  46. package/src/workflows/runner.ts +8 -0
  47. package/src/workflows/schema.ts +5 -0
  48. package/src/workflows/store.ts +108 -0
@@ -0,0 +1,232 @@
1
+ // Install the resident daemon as a per-user OS service so it auto-starts at login
2
+ // and survives the terminal closing — the difference between "the CLI is running"
3
+ // and "the daemon is reachable from the app even when no CLI is". macOS → launchd
4
+ // user agent; Linux → systemd --user unit. No root: everything lives under the
5
+ // user's own home and login session.
6
+ //
7
+ // ORDERING NOTE: this module is import-safe (node builtins + our paths only, no Pi),
8
+ // so the daemon CLI can load it without going through boot.ts.
9
+ import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
10
+ import { spawnSync } from "node:child_process";
11
+ import { homedir } from "node:os";
12
+ import { join, dirname, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { globalDir } from "../config/paths.ts";
15
+ import { daemonIsRunning } from "./ipc.ts";
16
+
17
+ const LABEL = "pro.privateer.daemon"; // launchd label / reverse-dns id
18
+ const UNIT = "privateer-daemon.service"; // systemd --user unit name
19
+
20
+ // Absolute path to the node launcher that boots + runs the daemon (bin/privateer-daemon.mjs).
21
+ // Resolved from THIS module so it's correct for both a dev checkout and a global npm
22
+ // install (…/node_modules/privateer-agent/bin/privateer-daemon.mjs).
23
+ function daemonLauncherPath(): string {
24
+ const here = dirname(fileURLToPath(import.meta.url)); // …/src/daemon
25
+ return resolve(here, "../../bin/privateer-daemon.mjs");
26
+ }
27
+
28
+ // The node binary to bake into the unit. We use the CURRENT interpreter (>=22, the
29
+ // bash launcher already picked a compatible one) by absolute path, so the service
30
+ // never depends on launchd/systemd having a usable PATH.
31
+ function nodeBinaryPath(): string {
32
+ return process.execPath;
33
+ }
34
+
35
+ function daemonLogPath(): string {
36
+ return join(globalDir(), "daemon.log");
37
+ }
38
+
39
+ // Env we forward into the service so a non-default home / server URL survives. Kept
40
+ // tiny and explicit — the daemon reads the rest from ~/.privateer.
41
+ function forwardedEnv(): Record<string, string> {
42
+ const env: Record<string, string> = {};
43
+ if (process.env.PRIVATEER_HOME) env.PRIVATEER_HOME = process.env.PRIVATEER_HOME;
44
+ if (process.env.PRIVATEER_SERVER_URL) env.PRIVATEER_SERVER_URL = process.env.PRIVATEER_SERVER_URL;
45
+ return env;
46
+ }
47
+
48
+ // ── macOS (launchd) ─────────────────────────────────────────────────────────────
49
+
50
+ function launchAgentPath(): string {
51
+ return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
52
+ }
53
+
54
+ function xmlEscape(s: string): string {
55
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
56
+ }
57
+
58
+ function launchdPlist(): string {
59
+ const args = [nodeBinaryPath(), daemonLauncherPath(), "run"];
60
+ const envVars = forwardedEnv();
61
+ const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
62
+ const envXml = Object.entries(envVars)
63
+ .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
64
+ .join("\n");
65
+ const log = xmlEscape(daemonLogPath());
66
+ return `<?xml version="1.0" encoding="UTF-8"?>
67
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
68
+ <plist version="1.0">
69
+ <dict>
70
+ <key>Label</key>
71
+ <string>${LABEL}</string>
72
+ <key>ProgramArguments</key>
73
+ <array>
74
+ ${argXml}
75
+ </array>
76
+ ${envVars.PRIVATEER_HOME || envVars.PRIVATEER_SERVER_URL ? ` <key>EnvironmentVariables</key>\n <dict>\n${envXml}\n </dict>\n` : ""} <key>RunAtLoad</key>
77
+ <true/>
78
+ <key>KeepAlive</key>
79
+ <true/>
80
+ <key>StandardOutPath</key>
81
+ <string>${log}</string>
82
+ <key>StandardErrorPath</key>
83
+ <string>${log}</string>
84
+ </dict>
85
+ </plist>
86
+ `;
87
+ }
88
+
89
+ function installLaunchd(): void {
90
+ const plist = launchAgentPath();
91
+ mkdirSync(dirname(plist), { recursive: true });
92
+ writeFileSync(plist, launchdPlist());
93
+ // Unload a prior copy (ignore failure — it may not be loaded), then load with -w so
94
+ // it's enabled across reboots.
95
+ spawnSync("launchctl", ["unload", plist], { stdio: "ignore" });
96
+ const r = spawnSync("launchctl", ["load", "-w", plist], { encoding: "utf8" });
97
+ if (r.status !== 0) {
98
+ throw new Error(`launchctl load failed: ${(r.stderr || r.stdout || "").trim() || `exit ${r.status}`}`);
99
+ }
100
+ }
101
+
102
+ function uninstallLaunchd(): void {
103
+ const plist = launchAgentPath();
104
+ if (existsSync(plist)) {
105
+ spawnSync("launchctl", ["unload", "-w", plist], { stdio: "ignore" });
106
+ rmSync(plist, { force: true });
107
+ }
108
+ }
109
+
110
+ // ── Linux (systemd --user) ───────────────────────────────────────────────────────
111
+
112
+ function systemdUnitPath(): string {
113
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
114
+ return join(base, "systemd", "user", UNIT);
115
+ }
116
+
117
+ function systemdUnit(): string {
118
+ const exec = [nodeBinaryPath(), daemonLauncherPath(), "run"].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
119
+ const envLines = Object.entries(forwardedEnv())
120
+ .map(([k, v]) => `Environment=${k}=${v}`)
121
+ .join("\n");
122
+ return `[Unit]
123
+ Description=Privateer resident agent daemon (routines + app-driven task spawns)
124
+ After=network-online.target
125
+ Wants=network-online.target
126
+
127
+ [Service]
128
+ Type=simple
129
+ ExecStart=${exec}
130
+ Restart=on-failure
131
+ RestartSec=5
132
+ ${envLines}
133
+
134
+ [Install]
135
+ WantedBy=default.target
136
+ `;
137
+ }
138
+
139
+ function installSystemd(): void {
140
+ const unit = systemdUnitPath();
141
+ mkdirSync(dirname(unit), { recursive: true });
142
+ writeFileSync(unit, systemdUnit());
143
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
144
+ // enable-linger so the user service keeps running with no active login session —
145
+ // the whole point of "reachable even when no shell is open". Best-effort: it needs
146
+ // no root on most distros, but don't fail the install if it's disallowed.
147
+ spawnSync("loginctl", ["enable-linger", process.env.USER || ""], { stdio: "ignore" });
148
+ const r = spawnSync("systemctl", ["--user", "enable", "--now", UNIT], { encoding: "utf8" });
149
+ if (r.status !== 0) {
150
+ throw new Error(`systemctl enable failed: ${(r.stderr || r.stdout || "").trim() || `exit ${r.status}`}`);
151
+ }
152
+ }
153
+
154
+ function uninstallSystemd(): void {
155
+ const unit = systemdUnitPath();
156
+ spawnSync("systemctl", ["--user", "disable", "--now", UNIT], { stdio: "ignore" });
157
+ if (existsSync(unit)) rmSync(unit, { force: true });
158
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
159
+ }
160
+
161
+ // ── Public API ───────────────────────────────────────────────────────────────────
162
+
163
+ export interface ServiceInfo {
164
+ platform: NodeJS.Platform;
165
+ supported: boolean;
166
+ installed: boolean;
167
+ unitPath: string;
168
+ logPath: string;
169
+ }
170
+
171
+ function unitPathFor(platform: NodeJS.Platform): string {
172
+ if (platform === "darwin") return launchAgentPath();
173
+ if (platform === "linux") return systemdUnitPath();
174
+ return "";
175
+ }
176
+
177
+ export function serviceInfo(): ServiceInfo {
178
+ const platform = process.platform;
179
+ const unitPath = unitPathFor(platform);
180
+ return {
181
+ platform,
182
+ supported: platform === "darwin" || platform === "linux",
183
+ installed: !!unitPath && existsSync(unitPath),
184
+ unitPath,
185
+ logPath: daemonLogPath(),
186
+ };
187
+ }
188
+
189
+ // Install the service for the current platform. Idempotent (rewrites + reloads).
190
+ export function installService(): ServiceInfo {
191
+ const platform = process.platform;
192
+ if (platform === "darwin") installLaunchd();
193
+ else if (platform === "linux") installSystemd();
194
+ else throw new Error(`Auto-start isn't supported on ${platform}. Run \`privateer daemon\` yourself, or keep a terminal open.`);
195
+ return serviceInfo();
196
+ }
197
+
198
+ export function uninstallService(): ServiceInfo {
199
+ const platform = process.platform;
200
+ if (platform === "darwin") uninstallLaunchd();
201
+ else if (platform === "linux") uninstallSystemd();
202
+ else throw new Error(`No service to remove on ${platform}.`);
203
+ return serviceInfo();
204
+ }
205
+
206
+ // Human-readable status line for `privateer daemon status`: whether the service is
207
+ // installed AND whether a daemon is actually answering on the IPC socket right now.
208
+ export async function statusReport(): Promise<string> {
209
+ const info = serviceInfo();
210
+ const live = await daemonIsRunning();
211
+ const lines = [
212
+ `platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer daemon` manually)"}`,
213
+ `service: ${info.installed ? `installed (${info.unitPath})` : "not installed"}`,
214
+ `daemon: ${live ? "running (answering IPC)" : "not reachable"}`,
215
+ `logs: ${info.logPath}`,
216
+ ];
217
+ // Surface a stale-unit hint: file present but nothing answering usually means it
218
+ // failed to boot — the log path above is where to look.
219
+ if (info.installed && !live) lines.push("hint: service is installed but not answering — check the log for a boot error.");
220
+ return lines.join("\n");
221
+ }
222
+
223
+ // Best-effort read of the tail of the daemon log (for a `status --log` affordance or
224
+ // error surfacing). Returns "" if absent.
225
+ export function tailDaemonLog(maxBytes = 4_000): string {
226
+ try {
227
+ const buf = readFileSync(daemonLogPath(), "utf8");
228
+ return buf.length > maxBytes ? buf.slice(buf.length - maxBytes) : buf;
229
+ } catch {
230
+ return "";
231
+ }
232
+ }
@@ -44,6 +44,14 @@ export interface GateController {
44
44
  confineToCwd?: boolean;
45
45
  getRemote?(): boolean;
46
46
  getNoQuarter?(): boolean;
47
+ // Block a tool outright while the turn is remote-driven (only consulted when
48
+ // getRemote() is true). For tools whose own prompts render on the host terminal
49
+ // rather than the relay — e.g. pi-subagents — so a driven turn can't wedge on an
50
+ // invisible local prompt. Returns true to block. See isRemoteUnsafeTool.
51
+ blockedWhenRemote?(toolName: string): boolean;
52
+ // Notified when blockedWhenRemote blocked a tool, so the controller can surface a
53
+ // one-line reason in the app feed (a `notice` frame) explaining why nothing ran.
54
+ onRemoteBlocked?(toolName: string): void;
47
55
  // Local interactive approval via the per-call ctx. Default provided below.
48
56
  localAsk(req: PermissionRequest, ctx: ToolCallCtx): Promise<AskOutcome>;
49
57
  // Remote (relay) approval. Optional until Phase 4; when absent, a remote turn
@@ -60,6 +68,23 @@ export interface GateBlock {
60
68
  reason: string;
61
69
  }
62
70
 
71
+ // Tools that CANNOT be driven from the app and so are blocked outright on a
72
+ // remote-driven turn (see chat.ts wiring). pi-subagents runs each subagent as a
73
+ // child session/subprocess whose own permission gate + UI aren't wired to the
74
+ // relay — its approvals and "TUI clarification" prompts surface on the HOST
75
+ // terminal, never in the app. A driven turn that spawned one would wedge on a
76
+ // prompt the phone can't answer. `contact_supervisor`/`intercom` are the child→
77
+ // parent tools; they only exist in child sessions but are listed for safety.
78
+ // Blocking is fail-closed and matches the "remote turns never auto-approve"
79
+ // posture: the feature is simply disabled while driving until its prompts relay.
80
+ export const REMOTE_UNSAFE_TOOLS: ReadonlySet<string> = new Set([
81
+ "subagent",
82
+ "contact_supervisor",
83
+ "intercom",
84
+ ]);
85
+
86
+ export const isRemoteUnsafeTool = (toolName: string): boolean => REMOTE_UNSAFE_TOOLS.has(toolName);
87
+
63
88
  // THE decision path. Classify → policy → decision, fail-closed on any error.
64
89
  // Returns a block directive to deny, or undefined to let the tool run.
65
90
  export async function decideToolCall(
@@ -68,6 +93,19 @@ export async function decideToolCall(
68
93
  input: unknown,
69
94
  ctx: ToolCallCtx,
70
95
  ): Promise<GateBlock | undefined> {
96
+ // Remote-driven turn: a few tools can't be driven from the app because their own
97
+ // interactive prompts render on the host terminal, not the relay (pi-subagents
98
+ // spawns child sessions outside the bridge). Block them fail-closed BEFORE any
99
+ // classification so a driven turn never wedges on a prompt the phone can't answer,
100
+ // and tell the controller so it can post a notice to the app feed.
101
+ if (ctrl.getRemote?.() && ctrl.blockedWhenRemote?.(toolName)) {
102
+ ctrl.onRemoteBlocked?.(toolName);
103
+ return {
104
+ block: true,
105
+ reason: `${toolName} is unavailable while this terminal is driven remotely — its prompts can't reach the app. Complete the task without it, or ask the operator to run it from the terminal directly.`,
106
+ };
107
+ }
108
+
71
109
  const req = classifyToolCall(toolName, input, {
72
110
  cwd: ctrl.cwd,
73
111
  confineToCwd: ctrl.confineToCwd,
@@ -1,4 +1,5 @@
1
- import { resolve, isAbsolute, relative } from "node:path";
1
+ import { resolve, isAbsolute, relative, dirname, join, basename } from "node:path";
2
+ import { realpathSync } from "node:fs";
2
3
  import { isProtectedPath } from "./protected.ts";
3
4
  import type { PermissionRequest } from "./gate.ts";
4
5
 
@@ -18,8 +19,31 @@ export interface ScopeOptions {
18
19
  allowedOutsideRoots?: string[];
19
20
  }
20
21
 
22
+ // Resolve symlinks so an in-cwd symlink can't smuggle a path outside scope past the
23
+ // lexical `resolve()` check (P5-1: `resolve` normalizes `..` but does NOT follow
24
+ // symlinks, so `cwd/link/secret` where `link -> /etc` looks in-cwd lexically). We
25
+ // realpath the DEEPEST EXISTING ancestor — a write target's leaf may not exist yet —
26
+ // and re-append the missing tail; the escape lives in the existing prefix, so
27
+ // canonicalizing that is what matters. Falls back to the lexical path when nothing
28
+ // resolves (e.g. a fully-nonexistent tree, as in unit tests).
29
+ function realBase(abs: string): string {
30
+ let dir = abs;
31
+ const tail: string[] = [];
32
+ for (;;) {
33
+ try {
34
+ const real = realpathSync(dir);
35
+ return tail.length ? join(real, ...tail) : real;
36
+ } catch {
37
+ const parent = dirname(dir);
38
+ if (parent === dir) return abs; // reached the FS root without resolving → lexical
39
+ tail.unshift(basename(dir));
40
+ dir = parent;
41
+ }
42
+ }
43
+ }
44
+
21
45
  function resolveInCwd(cwd: string, p: string): string {
22
- return isAbsolute(p) ? p : resolve(cwd, p);
46
+ return realBase(isAbsolute(p) ? p : resolve(cwd, p));
23
47
  }
24
48
 
25
49
  function isInsideDir(root: string, abs: string): boolean {
@@ -29,11 +53,14 @@ function isInsideDir(root: string, abs: string): boolean {
29
53
  }
30
54
 
31
55
  // Outside the agent's working-directory scope? Only when confinement is on and the
32
- // path is neither inside cwd nor inside a session-approved outside root.
56
+ // path is neither inside cwd nor inside a session-approved outside root. Both sides are
57
+ // symlink-canonicalized (realBase) so a symlinked cwd — or a symlink inside cwd — can't
58
+ // fake containment (P5-1).
33
59
  export function isOutsideScope(scope: ScopeOptions, abs: string): boolean {
34
60
  if (scope.confineToCwd === false) return false;
35
- if (isInsideDir(scope.cwd, abs)) return false;
36
- return !(scope.allowedOutsideRoots ?? []).some((root) => isInsideDir(root, abs));
61
+ const target = realBase(abs);
62
+ if (isInsideDir(realBase(scope.cwd), target)) return false;
63
+ return !(scope.allowedOutsideRoots ?? []).some((root) => isInsideDir(realBase(root), target));
37
64
  }
38
65
 
39
66
  function str(v: unknown): string {
@@ -44,6 +71,21 @@ function firstPath(input: Record<string, unknown>): string {
44
71
  return str(input.path ?? input.file_path ?? input.file ?? input.filename ?? input.dir ?? input.directory);
45
72
  }
46
73
 
74
+ // A write/edit tool call whose target path we can't statically extract — an aliased
75
+ // param name, or a patch tool whose target paths live in the DIFF BODY rather than a
76
+ // param (P5-4). Fail safe: mark it outside-scope so it prompts (in default/acceptEdits)
77
+ // instead of defaulting to a silent in-cwd auto-write. Precise patch-body path parsing
78
+ // needs Pi's apply_patch schema — TODO(verify) against the full builtin tool catalog.
79
+ function unknownTarget(toolName: string, kind: "write" | "edit"): PermissionRequest {
80
+ return {
81
+ tool: toolName,
82
+ kind,
83
+ title: kind === "write" ? "Write to an unverified path" : "Edit an unverified path",
84
+ detail: "(target path not statically known — approve to allow)",
85
+ outside: true,
86
+ };
87
+ }
88
+
47
89
  // Known-safe read-only / meta builtins that never mutate and never leave the
48
90
  // machine: no gate regardless of arguments. Tunable — the conservative default for
49
91
  // anything NOT listed here is to ask (see below). TODO(verify) against Pi's full
@@ -96,6 +138,7 @@ export function classifyToolCall(
96
138
  // Write — create/overwrite a file.
97
139
  if (WRITE_TOOLS.has(name)) {
98
140
  const p = firstPath(obj);
141
+ if (!p) return unknownTarget(toolName, "write"); // P5-4: no extractable path → fail safe
99
142
  const abs = resolveInCwd(scope.cwd, p);
100
143
  const outside = isOutsideScope(scope, abs);
101
144
  return {
@@ -112,6 +155,7 @@ export function classifyToolCall(
112
155
  // Edit — modify an existing file.
113
156
  if (EDIT_TOOLS.has(name)) {
114
157
  const p = firstPath(obj);
158
+ if (!p) return unknownTarget(toolName, "edit"); // P5-4: no extractable path → fail safe
115
159
  const abs = resolveInCwd(scope.cwd, p);
116
160
  const outside = isOutsideScope(scope, abs);
117
161
  return {
@@ -17,8 +17,9 @@ import {
17
17
  authedFetch,
18
18
  spawnAccountCredentials,
19
19
  refreshAccountCredentials,
20
+ notifySignedIn,
20
21
  } from "../auth/privateer.ts";
21
- import { interpretReport, teePosture, type PrivacyTier } from "pi-privacy";
22
+ import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
22
23
 
23
24
  // Seed/fallback catalog: registered synchronously so the account provider has real
24
25
  // models the instant it loads (before the live /api/models fetch resolves) — in
@@ -73,7 +74,12 @@ export const privateerOAuthProvider = {
73
74
  // authentication…" screen hangs with no way out. See auth/privateer.ts
74
75
  // pollForToken, which checks the signal and rejects with "Login cancelled.".
75
76
  async login(cb: { onDeviceCode?: (info: unknown) => void; signal?: AbortSignal }) {
76
- if (!hasCredentials()) {
77
+ // Fresh machine? The device-code flow below fires notifySignedIn itself (via
78
+ // pollForToken). Already linked? No device code runs — so we announce the
79
+ // completed subscription login ourselves at the end, or the header/badge would
80
+ // keep showing "not signed in" until the next launch.
81
+ const wasLinked = hasCredentials();
82
+ if (!wasLinked) {
77
83
  try {
78
84
  await runDeviceLogin({
79
85
  signal: cb.signal,
@@ -94,7 +100,11 @@ export const privateerOAuthProvider = {
94
100
  }
95
101
  }
96
102
  if (cb.signal?.aborted) throw new Error("Login cancelled");
97
- return spawnAccountCredentials();
103
+ const creds = await spawnAccountCredentials();
104
+ // The fresh path already fired notifySignedIn (pollForToken); fire here for the
105
+ // already-linked path so the header re-renders to "connected" on this terminal too.
106
+ if (wasLinked) notifySignedIn();
107
+ return creds;
98
108
  },
99
109
  async refreshToken(creds: { refresh: string }) {
100
110
  try {
@@ -124,13 +134,11 @@ export interface AccountPosture {
124
134
 
125
135
  // Posture for an account-channel model. For NEAR models the attestation is fetched
126
136
  // through the SERVER proxy (the account's NEAR key stays server-side): the server
127
- // mints the nonce AND returns the report, so the CLIENT contributes no freshness and
128
- // can't bind the report to the live TLS key. That is a trust-the-server posture, not
129
- // client-verified materially weaker than the direct nearai path (which supplies its
130
- // own randomNonce). So we must NOT promote it to `tee-verified`: that tier reads as
131
- // cryptographically proven and, critically, disables pi-privacy's PII gate. We cap a
132
- // green server posture at `tee-unverified` (honest yellow — the PII gate stays on) and
133
- // still surface the raw teePosture for display. ZDR-channel models route to
137
+ // mints the nonce and returns the report. A green attestation is trusted as a genuine
138
+ // TEE promoted to `tee-verified` (green shield "Trusted Execution" in the badge)
139
+ // so an attested confidential-compute model reads as verified-private. A yellow report
140
+ // stays `tee-unverified` (unconfirmed) and red falls back to `standard`; the raw
141
+ // teePosture is still surfaced for display. ZDR-channel models route to
134
142
  // zero-retention endpoints server-side, which we can't observe here — a policy claim.
135
143
  export async function accountPosture(modelId: string): Promise<AccountPosture> {
136
144
  if (privateerChannel(modelId) === "zdr") {
@@ -144,8 +152,8 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
144
152
  const data = (await res.json()) as { nonce?: string; report?: unknown };
145
153
  const att = interpretReport(modelId, data.nonce ?? "", data.report ?? {});
146
154
  const tp = teePosture(att);
147
- // Cap at client-unverifiable: server-proxied green never earns `tee-verified`.
148
- const tier: PrivacyTier = tp === "red" ? "standard" : "tee-unverified";
155
+ // green tee-verified, yellow → tee-unverified, red standard.
156
+ const tier: PrivacyTier = tierFromTeePosture(tp);
149
157
  return { tier, teePosture: tp };
150
158
  } catch (e) {
151
159
  return { tier: "tee-unverified", error: (e as Error).message };
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Channel management for the app — the sibling of routinesControl.ts, but for the
3
+ * messaging-channel config (Telegram/Slack/Discord/WhatsApp) rather than scheduled
4
+ * routines.
5
+ *
6
+ * Like routines, this is owned by the ALWAYS-ON daemon relay (daemon/index.ts),
7
+ * NOT the channels daemon (channels/run.ts) — which may be down, and which the app
8
+ * must be able to configure before it has ever run. So this control edits the
9
+ * `channels` block of ~/.privateer/config.json; the channels daemon adopts changes
10
+ * on its next RESTART, matching run.ts's deliberate "no in-chat toggle, restart is
11
+ * the fail-safe reset" posture. `running` is a best-effort read of the channels
12
+ * daemon's heartbeat (channels/status.ts), never a dependency.
13
+ *
14
+ * SECRETS: bot tokens are WRITE-ONLY from the app's perspective. list() NEVER
15
+ * returns a token value — it reports `configured`, `running`, and `secretsSet`
16
+ * (which secret fields are present, by name only). save() persists whatever secret
17
+ * VALUES it is handed in `draft.secrets`; the seal/open of those values in transit
18
+ * is the caller's job (Phase 3), so this module only ever deals in the plaintext
19
+ * config.json it already owns.
20
+ *
21
+ * Framework-agnostic: nothing here imports React or the relay. The caller owns the
22
+ * frame plumbing and the running-presence read.
23
+ */
24
+ import { readFileSync, writeFileSync } from "node:fs";
25
+ import { configPath } from "../config/paths.ts";
26
+
27
+ // The platforms channels/run.ts knows how to start. Order is the app's display
28
+ // order. Keep in sync with the `startChannel` calls in run.ts.
29
+ export const CHANNEL_PLATFORMS = ["telegram", "slack", "discord", "whatsapp"] as const;
30
+ export type ChannelPlatform = (typeof CHANNEL_PLATFORMS)[number];
31
+
32
+ const POSTURES = ["readonly", "approve", "auto"] as const;
33
+ export type ChannelPosture = (typeof POSTURES)[number];
34
+
35
+ // The secret (never-echoed) fields per platform — the union of the token blocks
36
+ // run.ts requires to START each platform. `secretsSet` reports presence of these.
37
+ const SECRET_FIELDS: Record<ChannelPlatform, string[]> = {
38
+ telegram: ["botToken"],
39
+ slack: ["appToken", "botToken"],
40
+ discord: ["botToken"],
41
+ whatsapp: ["phoneNumberId", "accessToken", "verifyToken", "appSecret"],
42
+ };
43
+
44
+ // Non-secret projection of one platform's config, sent to the app. No token
45
+ // values, ever — only which secret fields are already present (`secretsSet`).
46
+ export interface RemoteChannel {
47
+ platform: ChannelPlatform;
48
+ configured: boolean; // a config block exists for this platform
49
+ running: boolean; // the channels daemon is currently serving it
50
+ adminCount: number;
51
+ memberCount: number;
52
+ posture: ChannelPosture;
53
+ tools: string[];
54
+ model?: string;
55
+ secretsSet: string[]; // e.g. ["botToken"] — names only, never values
56
+ }
57
+
58
+ // An app-submitted edit. Non-secret fields REPLACE when present; `secrets` maps a
59
+ // secret field name → its (already-opened) value, and only present, non-empty
60
+ // values overwrite — omitted means "keep the existing value".
61
+ export interface ChannelDraft {
62
+ platform: ChannelPlatform;
63
+ admins?: string[];
64
+ members?: string[];
65
+ posture?: ChannelPosture;
66
+ tools?: string[];
67
+ model?: string;
68
+ secrets?: Record<string, string>;
69
+ }
70
+
71
+ export interface ChannelsControl {
72
+ // All four platforms, always — configured or not — so the app can show empty
73
+ // slots to set up. Order follows CHANNEL_PLATFORMS.
74
+ list(): RemoteChannel[];
75
+ // Create or edit a platform's config. Validates posture + fail-closes on a block
76
+ // with no admins/members (mirrors run.ts). Returns a one-line result.
77
+ save(draft: ChannelDraft): { ok: boolean; message?: string };
78
+ // Delete a platform's config entirely. ok:false when nothing was configured.
79
+ remove(platform: ChannelPlatform): { ok: boolean; message?: string };
80
+ }
81
+
82
+ function isPlatform(v: unknown): v is ChannelPlatform {
83
+ return typeof v === "string" && (CHANNEL_PLATFORMS as readonly string[]).includes(v);
84
+ }
85
+
86
+ function normalizePosture(v: unknown): ChannelPosture | undefined {
87
+ return typeof v === "string" && (POSTURES as readonly string[]).includes(v) ? (v as ChannelPosture) : undefined;
88
+ }
89
+
90
+ function cleanStrList(v: unknown): string[] | undefined {
91
+ if (!Array.isArray(v)) return undefined;
92
+ const out = v.map((x) => String(x ?? "").trim()).filter(Boolean);
93
+ return out.length > 0 ? out : [];
94
+ }
95
+
96
+ export function makeChannelsControl(opts: {
97
+ // Which platforms the channels daemon is serving right now (heartbeat read).
98
+ // Absent → everything reports not-running.
99
+ runningPlatforms?: () => Set<string>;
100
+ }): ChannelsControl {
101
+ const running = opts.runningPlatforms ?? (() => new Set<string>());
102
+
103
+ function readCfg(): any {
104
+ try {
105
+ return JSON.parse(readFileSync(configPath(), "utf8"));
106
+ } catch {
107
+ return {};
108
+ }
109
+ }
110
+
111
+ function toRemote(platform: ChannelPlatform, block: any, live: Set<string>): RemoteChannel {
112
+ const admins = block?.admins ?? block?.allowFrom ?? []; // legacy allowFrom == admins
113
+ return {
114
+ platform,
115
+ configured: !!block,
116
+ running: live.has(platform),
117
+ adminCount: Array.isArray(admins) ? admins.length : 0,
118
+ memberCount: Array.isArray(block?.members) ? block.members.length : 0,
119
+ posture: normalizePosture(block?.posture) ?? "approve",
120
+ tools: Array.isArray(block?.tools) ? block.tools.map(String) : [],
121
+ model: typeof block?.model === "string" ? block.model : undefined,
122
+ secretsSet: SECRET_FIELDS[platform].filter((f) => block?.[f]),
123
+ };
124
+ }
125
+
126
+ return {
127
+ list(): RemoteChannel[] {
128
+ const ch = readCfg().channels ?? {};
129
+ const live = running();
130
+ return CHANNEL_PLATFORMS.map((p) => toRemote(p, ch[p], live));
131
+ },
132
+
133
+ save(draft: ChannelDraft): { ok: boolean; message?: string } {
134
+ if (!isPlatform(draft?.platform)) return { ok: false, message: "Unknown platform." };
135
+ if (draft.posture !== undefined && !normalizePosture(draft.posture))
136
+ return { ok: false, message: "Invalid posture." };
137
+
138
+ const cfg = readCfg();
139
+ cfg.channels ??= {};
140
+ const block: any = { ...(cfg.channels[draft.platform] ?? {}) };
141
+
142
+ // Non-secret fields replace when provided. An explicit empty array clears.
143
+ const admins = cleanStrList(draft.admins);
144
+ if (admins !== undefined) block.admins = admins;
145
+ const members = cleanStrList(draft.members);
146
+ if (members !== undefined) block.members = members;
147
+ if (draft.posture !== undefined) block.posture = draft.posture;
148
+ const tools = cleanStrList(draft.tools);
149
+ if (tools !== undefined) block.tools = tools;
150
+ if (draft.model !== undefined) {
151
+ const m = String(draft.model).trim();
152
+ if (m) block.model = m;
153
+ else delete block.model;
154
+ }
155
+
156
+ // Secrets: a present, non-empty value overwrites; omitted keeps the existing.
157
+ for (const [k, v] of Object.entries(draft.secrets ?? {})) {
158
+ if (!SECRET_FIELDS[draft.platform].includes(k)) continue;
159
+ const val = String(v ?? "").trim();
160
+ if (val) block[k] = val;
161
+ }
162
+
163
+ // Fail-closed: never persist a block that can't authorize anyone (mirrors
164
+ // run.ts:261 — a channel with no admins/members is skipped anyway).
165
+ const hasAdmins = Array.isArray(block.admins) && block.admins.length > 0;
166
+ const hasMembers = Array.isArray(block.members) && block.members.length > 0;
167
+ if (!hasAdmins && !hasMembers)
168
+ return { ok: false, message: "Add at least one admin before saving." };
169
+
170
+ cfg.channels[draft.platform] = block;
171
+ try {
172
+ writeFileSync(configPath(), JSON.stringify(cfg, null, 2));
173
+ } catch (e) {
174
+ return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
175
+ }
176
+ return { ok: true, message: `Saved ${draft.platform}. Restart the channels daemon to apply.` };
177
+ },
178
+
179
+ remove(platform: ChannelPlatform): { ok: boolean; message?: string } {
180
+ if (!isPlatform(platform)) return { ok: false, message: "Unknown platform." };
181
+ const cfg = readCfg();
182
+ if (!cfg.channels?.[platform]) return { ok: false, message: "Not configured." };
183
+ delete cfg.channels[platform];
184
+ try {
185
+ writeFileSync(configPath(), JSON.stringify(cfg, null, 2));
186
+ } catch (e) {
187
+ return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
188
+ }
189
+ return { ok: true, message: `Removed ${platform}. Restart the channels daemon to apply.` };
190
+ },
191
+ };
192
+ }