privateer-agent 0.13.0 → 0.14.1
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 +62 -3
- package/bin/headless-flags.d.mts +7 -0
- package/bin/headless-flags.mjs +40 -0
- package/bin/model-args.d.mts +9 -0
- package/bin/model-args.mjs +43 -0
- package/bin/privateer-channels.mjs +30 -0
- package/bin/privateer-launch.mjs +31 -6
- package/extensions/privateer-brand.ts +15 -10
- package/extensions/privateer-gate.ts +58 -2
- package/extensions/privateer-hints.ts +3 -2
- package/package.json +2 -1
- package/src/acp/run.ts +29 -4
- package/src/acp/server.ts +0 -0
- package/src/channels/run.ts +30 -6
- package/src/cli/channelsCli.ts +67 -0
- package/src/config/mediaModels.ts +3 -2
- package/src/config/piiAllow.ts +3 -2
- package/src/config/whatsNew.json +10 -0
- package/src/engine/stallWatchdog.ts +61 -0
- package/src/ext/permissionGate.ts +21 -0
- package/src/harbor/accountLease.ts +76 -0
- package/src/harbor/closeSession.ts +20 -0
- package/src/harbor/index.ts +147 -58
- package/src/harbor/logRotate.ts +31 -0
- package/src/harbor/service.ts +202 -75
- package/src/permissions/classify.ts +12 -4
- package/src/permissions/danger.ts +9 -0
- package/src/permissions/modeGate.ts +8 -0
- package/src/permissions/privateMode.ts +136 -0
- package/src/permissions/protected.ts +36 -1
- package/src/remote/channelsControl.ts +4 -3
- package/src/remote/liveTaskSession.ts +18 -2
- package/src/remote/mcpControl.ts +4 -3
- package/src/routines/store.ts +41 -12
- package/src/util/atomicWrite.ts +46 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Private mode — the contract another agent relies on when it hands Privateer data it
|
|
2
|
+
// must not see itself.
|
|
3
|
+
//
|
|
4
|
+
// The handoff: an agent running on a model that is NOT private (a hosted frontier model,
|
|
5
|
+
// say) has a task that touches sensitive files. It keeps them out of its own context by
|
|
6
|
+
// asking Privateer to do the reading: `privateer -p --private "…"`, or an ACP session
|
|
7
|
+
// with `acp.private`. That only works if Privateer refuses to put those files in front of
|
|
8
|
+
// a model that isn't private either. A config that quietly fell back to a BYO-key model,
|
|
9
|
+
// a sealed shim that never came up, an attestation that came back yellow: each would
|
|
10
|
+
// send the very data the caller was keeping out of its own provider to someone else's.
|
|
11
|
+
//
|
|
12
|
+
// So private mode is enforced where the data enters the model: at the TOOL CALL. Before
|
|
13
|
+
// any tool runs, the session's current model must resolve to a verified-private tier —
|
|
14
|
+
// `tee-verified` (an enclave quote we checked) or `local` (on this machine). Anything
|
|
15
|
+
// else, including a check that throws, blocks the tool. The caller's own prompt still
|
|
16
|
+
// reaches the model, by design: it is the caller's text, and the caller already holds it.
|
|
17
|
+
// What never reaches an unverified model is anything Privateer READ on the caller's
|
|
18
|
+
// behalf.
|
|
19
|
+
//
|
|
20
|
+
// The second half is egress. A private model does not make a tool call private: a
|
|
21
|
+
// web_fetch carrying a file's contents leaves the machine no matter where inference ran.
|
|
22
|
+
// Private mode therefore runs on an ALLOWLIST of tools that stay on this machine, and
|
|
23
|
+
// refuses shell commands that reach the network, before any mode or approval is
|
|
24
|
+
// consulted — so no-quarter, `acp.posture: "auto"` and an approval cannot lift it.
|
|
25
|
+
//
|
|
26
|
+
// Turned on by `--private` (the launcher sets PRIVATEER_PRIVATE=1) or `acp.private`.
|
|
27
|
+
// The env var is honoured from the ambient environment on purpose: unlike the spend
|
|
28
|
+
// grants it can only ever make a run STRICTER, so an orchestrator may export it once
|
|
29
|
+
// for every Privateer it spawns. Subagent children inherit it with the rest of the env.
|
|
30
|
+
//
|
|
31
|
+
// IMPORT-SAFETY: the gate imports this module statically, and the gate must stay
|
|
32
|
+
// import-order-safe, so the Pi-touching posture code is imported dynamically below.
|
|
33
|
+
|
|
34
|
+
import type { PrivacyTier } from "pi-privacy";
|
|
35
|
+
import { reachesNetwork } from "./danger.ts";
|
|
36
|
+
|
|
37
|
+
export const PRIVATE_ENV = "PRIVATEER_PRIVATE";
|
|
38
|
+
|
|
39
|
+
/** True when the environment asks for private mode. */
|
|
40
|
+
export function privateModeFromEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
41
|
+
const v = (env[PRIVATE_ENV] ?? "").trim().toLowerCase();
|
|
42
|
+
return v === "1" || v === "true" || v === "yes";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The tiers a sensitive file may be shown to. Nothing weaker, and nothing asserted. */
|
|
46
|
+
export const PRIVATE_TIERS: ReadonlySet<PrivacyTier> = new Set<PrivacyTier>(["tee-verified", "local"]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Tools that keep their effects on this machine. Everything else (web, crypto lookups,
|
|
50
|
+
* media generation, MCP, app/library uploads, routines, screen control) is refused in
|
|
51
|
+
* private mode, because each one can carry what was read to a third party. `bash` and
|
|
52
|
+
* `terminal` are here but also pass through reachesNetwork().
|
|
53
|
+
*/
|
|
54
|
+
export const PRIVATE_TOOLS: ReadonlySet<string> = new Set([
|
|
55
|
+
"read",
|
|
56
|
+
"grep",
|
|
57
|
+
"find",
|
|
58
|
+
"ls",
|
|
59
|
+
"write",
|
|
60
|
+
"edit",
|
|
61
|
+
"bash",
|
|
62
|
+
"terminal",
|
|
63
|
+
// Children inherit PRIVATEER_PRIVATE and run this same check against their own model.
|
|
64
|
+
"subagent",
|
|
65
|
+
"contact_supervisor",
|
|
66
|
+
"intercom",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
export interface ModelRef {
|
|
70
|
+
provider?: string;
|
|
71
|
+
id?: string;
|
|
72
|
+
baseUrl?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const TTL_MS = 60_000;
|
|
76
|
+
const tierCache = new Map<string, { tier: PrivacyTier; at: number }>();
|
|
77
|
+
|
|
78
|
+
/** Test seam: swap how a model's tier is resolved. */
|
|
79
|
+
let resolveTierImpl: (m: Required<Pick<ModelRef, "provider" | "id">> & ModelRef) => Promise<PrivacyTier> = defaultResolveTier;
|
|
80
|
+
export function setTierResolverForTests(fn: typeof resolveTierImpl | undefined): void {
|
|
81
|
+
resolveTierImpl = fn ?? defaultResolveTier;
|
|
82
|
+
tierCache.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function defaultResolveTier(m: Required<Pick<ModelRef, "provider" | "id">> & ModelRef): Promise<PrivacyTier> {
|
|
86
|
+
// The account channel's verdict is ours to give (see config/privacyPolicy.ts
|
|
87
|
+
// resolveTier); every other provider is pi-privacy's, which attests TEE providers live
|
|
88
|
+
// and never returns tee-verified from a static claim.
|
|
89
|
+
if (m.provider === "privateer") {
|
|
90
|
+
const { accountPosture } = await import("../providers/account.ts");
|
|
91
|
+
return (await accountPosture(m.id)).tier;
|
|
92
|
+
}
|
|
93
|
+
const { verifyModelPosture } = await import("pi-privacy");
|
|
94
|
+
const apiKey = m.provider === "nearai" ? process.env.NEARAI_API_KEY : undefined;
|
|
95
|
+
return (await verifyModelPosture(m.provider, m.id, { baseUrl: m.baseUrl, apiKey })).tier;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The model's privacy tier, from a short cache. A failure is not a tier — it throws. */
|
|
99
|
+
export async function modelTier(model: ModelRef): Promise<PrivacyTier> {
|
|
100
|
+
if (!model.provider || !model.id) throw new Error("no model selected");
|
|
101
|
+
const key = `${model.provider}/${model.id}@${model.baseUrl ?? ""}`;
|
|
102
|
+
const hit = tierCache.get(key);
|
|
103
|
+
if (hit && Date.now() - hit.at < TTL_MS) return hit.tier;
|
|
104
|
+
const tier = await resolveTierImpl({ ...model, provider: model.provider, id: model.id });
|
|
105
|
+
tierCache.set(key, { tier, at: Date.now() });
|
|
106
|
+
return tier;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const label = (m: ModelRef | undefined) => (m?.provider && m?.id ? `${m.provider}/${m.id}` : "no model");
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Why this tool call may not run in private mode, or undefined when it may. Never
|
|
113
|
+
* throws: any failure to establish privacy is itself the refusal.
|
|
114
|
+
*/
|
|
115
|
+
export async function privateRefusal(toolName: string, input: unknown, model: ModelRef | undefined): Promise<string | undefined> {
|
|
116
|
+
if (!PRIVATE_TOOLS.has(toolName)) {
|
|
117
|
+
return `${toolName} is unavailable in private mode: it can carry what this session has read off this machine. Work with local files only.`;
|
|
118
|
+
}
|
|
119
|
+
if (toolName === "bash" || toolName === "terminal") {
|
|
120
|
+
const obj = (input ?? {}) as Record<string, unknown>;
|
|
121
|
+
const command = String(obj.command ?? obj.cmd ?? obj.script ?? "");
|
|
122
|
+
if (reachesNetwork(command)) {
|
|
123
|
+
return `This command reaches the network, which private mode refuses: it could carry what this session has read off this machine. Use local commands only.`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
let tier: PrivacyTier;
|
|
127
|
+
try {
|
|
128
|
+
tier = await modelTier(model ?? {});
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return `Private mode could not verify ${label(model)} (${e instanceof Error ? e.message : String(e)}), so no tool may run: its output would reach a model that isn't proven private. Stop and report this; do not retry.`;
|
|
131
|
+
}
|
|
132
|
+
if (!PRIVATE_TIERS.has(tier)) {
|
|
133
|
+
return `Private mode: ${label(model)} is "${tier}", not a verified-private model, so no tool may run: its output would reach a model that isn't proven private. Stop and report this; do not retry.`;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { basename } from "node:path";
|
|
1
|
+
import { basename, dirname, join, sep } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { agentDir, globalDir } from "../config/paths.ts";
|
|
2
5
|
|
|
3
6
|
// Files we never auto-edit, even under acceptEdits/allowlist: shell rc / git /
|
|
4
7
|
// package-manager / secrets that a coding task should not silently rewrite. A
|
|
@@ -27,3 +30,35 @@ export function isProtectedPath(p: string): boolean {
|
|
|
27
30
|
const name = basename(p);
|
|
28
31
|
return PROTECTED_BASENAMES.has(name) || looksSensitive(name);
|
|
29
32
|
}
|
|
33
|
+
|
|
34
|
+
// Where this machine keeps credentials that are never a coding task's business: the
|
|
35
|
+
// standard per-user secret directories, and the files at the top of our OWN state
|
|
36
|
+
// dirs — credentials.json, agent/auth.json, terminal-key.json, config.json (bot
|
|
37
|
+
// tokens, provider keys), mcp.json, account-trust.json and the rest. Only the TOP
|
|
38
|
+
// level of ~/.privateer and its agent dir: their subdirectories (routines output,
|
|
39
|
+
// projects, sessions) are ordinary data a run may legitimately be pointed at.
|
|
40
|
+
//
|
|
41
|
+
// Consulted only by surfaces that opt in (ScopeOptions.guardSecretReads — the
|
|
42
|
+
// harbor's unattended runs), where a READ of one of these is refused even inside the
|
|
43
|
+
// working directory: an unattended run that fetches a web page is one prompt
|
|
44
|
+
// injection away from reading a token and putting it in the next URL it fetches.
|
|
45
|
+
const SECRET_DIRS = [".ssh", ".aws", ".gnupg", ".kube", ".docker", join(".config", "gh"), join(".config", "gcloud")];
|
|
46
|
+
|
|
47
|
+
function canonical(p: string): string {
|
|
48
|
+
try {
|
|
49
|
+
return realpathSync(p);
|
|
50
|
+
} catch {
|
|
51
|
+
return p;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function within(root: string, abs: string): boolean {
|
|
56
|
+
return abs === root || abs.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isCredentialStorePath(abs: string): boolean {
|
|
60
|
+
const home = homedir();
|
|
61
|
+
if (SECRET_DIRS.some((d) => within(canonical(join(home, d)), abs))) return true;
|
|
62
|
+
const parent = dirname(abs);
|
|
63
|
+
return parent === canonical(globalDir()) || parent === canonical(agentDir());
|
|
64
|
+
}
|
|
@@ -21,13 +21,14 @@
|
|
|
21
21
|
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
22
22
|
* frame plumbing and the running-presence read.
|
|
23
23
|
*/
|
|
24
|
-
import { readFileSync
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
25
|
import { configPath } from "../config/paths.ts";
|
|
26
26
|
// The platform roster, per-platform secret field names, and the "is this block
|
|
27
27
|
// startable" predicate all live in ONE place, shared with the channels runtime —
|
|
28
28
|
// see channels/platforms.ts. Re-exported here so existing importers of
|
|
29
29
|
// CHANNEL_PLATFORMS/ChannelPlatform from this module keep working unchanged.
|
|
30
30
|
import { CHANNEL_PLATFORMS, SECRET_FIELDS, isChannelPlatform, type ChannelPlatform } from "../channels/platforms.ts";
|
|
31
|
+
import { writeFileAtomic } from "../util/atomicWrite.ts";
|
|
31
32
|
|
|
32
33
|
export { CHANNEL_PLATFORMS };
|
|
33
34
|
export type { ChannelPlatform };
|
|
@@ -161,7 +162,7 @@ export function makeChannelsControl(opts: {
|
|
|
161
162
|
|
|
162
163
|
cfg.channels[draft.platform] = block;
|
|
163
164
|
try {
|
|
164
|
-
|
|
165
|
+
writeFileAtomic(configPath(), JSON.stringify(cfg, null, 2) + "\n");
|
|
165
166
|
} catch (e) {
|
|
166
167
|
return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
|
|
167
168
|
}
|
|
@@ -174,7 +175,7 @@ export function makeChannelsControl(opts: {
|
|
|
174
175
|
if (!cfg.channels?.[platform]) return { ok: false, message: "Not configured." };
|
|
175
176
|
delete cfg.channels[platform];
|
|
176
177
|
try {
|
|
177
|
-
|
|
178
|
+
writeFileAtomic(configPath(), JSON.stringify(cfg, null, 2) + "\n");
|
|
178
179
|
} catch (e) {
|
|
179
180
|
return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
|
|
180
181
|
}
|
|
@@ -29,6 +29,8 @@ import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.
|
|
|
29
29
|
import { spawnAccountCredentials, revokeAccountSession } from "../auth/privateer.ts";
|
|
30
30
|
import { createUIContext } from "../ext/headlessUi.ts";
|
|
31
31
|
import { noQuarterActive } from "../permissions/noQuarter.ts";
|
|
32
|
+
import type { AccountLease } from "../harbor/accountLease.ts";
|
|
33
|
+
import { closeSession } from "../harbor/closeSession.ts";
|
|
32
34
|
|
|
33
35
|
export interface LiveTaskHandle {
|
|
34
36
|
termId: string;
|
|
@@ -41,6 +43,10 @@ export interface LiveTaskDeps {
|
|
|
41
43
|
parseSpec: (spec: string) => { provider: string; modelId: string };
|
|
42
44
|
log: (msg: string) => void;
|
|
43
45
|
onClosed: (termId: string) => void;
|
|
46
|
+
// The harbor's shared account session (harbor/accountLease.ts). The credential slot is
|
|
47
|
+
// one per process, so a spawn minting and revoking its own killed whatever routine was
|
|
48
|
+
// mid-turn beside it. Absent → this session mints and revokes its own (standalone use).
|
|
49
|
+
account?: AccountLease;
|
|
44
50
|
// Deliver the session's closing answer durably (the harbor seals it to the account
|
|
45
51
|
// outbox, so it lands in the app's inbox). A live spawn's feed is otherwise purely
|
|
46
52
|
// ephemeral: close the screen, reap the session, and everything it said is gone —
|
|
@@ -72,6 +78,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
72
78
|
let initialPromptSent = false;
|
|
73
79
|
let stopped = false;
|
|
74
80
|
let spawnedAccount = false;
|
|
81
|
+
let leasedAccount = false;
|
|
75
82
|
|
|
76
83
|
let attachTimer: ReturnType<typeof setTimeout> | undefined;
|
|
77
84
|
let lifeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -111,10 +118,16 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
111
118
|
if (attachTimer) clearTimeout(attachTimer);
|
|
112
119
|
if (lifeTimer) clearTimeout(lifeTimer);
|
|
113
120
|
try { relay?.stop(); } catch { /* already stopped */ }
|
|
121
|
+
if (session) {
|
|
122
|
+
try { await session.abort?.(); } catch { /* nothing in flight */ }
|
|
123
|
+
await closeSession(session); // MCP connections, listeners — see closeSession.ts
|
|
124
|
+
}
|
|
114
125
|
attachments.cleanup(); // drop the scratch dir holding inbound file bytes
|
|
115
126
|
// Revoke ONLY this session's account inference session so it doesn't linger in the
|
|
116
127
|
// app's Linked Devices; the harbor's own child session stays alive. Best-effort.
|
|
117
|
-
if (
|
|
128
|
+
if (leasedAccount) {
|
|
129
|
+
await deps.account!.release();
|
|
130
|
+
} else if (spawnedAccount) {
|
|
118
131
|
try { await revokeAccountSession(); } catch { /* server TTL is the fallback */ }
|
|
119
132
|
// Ownership-checked: auth.json is shared machine-wide, so a live task's teardown
|
|
120
133
|
// must not delete an interactive terminal's entry (see providers/account.ts).
|
|
@@ -228,7 +241,10 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
228
241
|
});
|
|
229
242
|
|
|
230
243
|
const { provider, modelId } = deps.parseSpec(modelSpec);
|
|
231
|
-
if (provider === "privateer") {
|
|
244
|
+
if (provider === "privateer" && deps.account) {
|
|
245
|
+
leasedAccount = true; // set before the await, so stop() always releases what we took
|
|
246
|
+
await deps.account.acquire();
|
|
247
|
+
} else if (provider === "privateer") {
|
|
232
248
|
try {
|
|
233
249
|
const creds = await spawnAccountCredentials();
|
|
234
250
|
await persistAccountCredential(creds);
|
package/src/remote/mcpControl.ts
CHANGED
|
@@ -35,9 +35,10 @@
|
|
|
35
35
|
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
36
36
|
* frame plumbing and the sealed-secret open.
|
|
37
37
|
*/
|
|
38
|
-
import { readFileSync,
|
|
38
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
39
39
|
import { join, dirname } from "node:path";
|
|
40
40
|
import { agentDir } from "../config/paths.ts";
|
|
41
|
+
import { writeFileAtomic } from "../util/atomicWrite.ts";
|
|
41
42
|
|
|
42
43
|
export type McpTransport = "stdio" | "http";
|
|
43
44
|
|
|
@@ -200,7 +201,7 @@ export function makeMcpControl(opts?: {
|
|
|
200
201
|
|
|
201
202
|
function writeSource(src: SourceFile): void {
|
|
202
203
|
mkdirSync(dirname(sourcePath()), { recursive: true });
|
|
203
|
-
|
|
204
|
+
writeFileAtomic(sourcePath(), JSON.stringify(src, null, 2) + "\n");
|
|
204
205
|
project(src);
|
|
205
206
|
}
|
|
206
207
|
|
|
@@ -220,7 +221,7 @@ export function makeMcpControl(opts?: {
|
|
|
220
221
|
mcpServers[name] = toStandard(e);
|
|
221
222
|
}
|
|
222
223
|
mkdirSync(dirname(projectionPath()), { recursive: true });
|
|
223
|
-
|
|
224
|
+
writeFileAtomic(
|
|
224
225
|
projectionPath(),
|
|
225
226
|
JSON.stringify({ mcpServers, settings: { toolPrefix: "server" } }, null, 2) + "\n",
|
|
226
227
|
);
|
package/src/routines/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, readdirSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { writeFileAtomic } from "../util/atomicWrite.ts";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { globalDir } from "../config/paths.ts";
|
|
@@ -64,11 +65,9 @@ export function saveRoutines(routines: Routine[]): void {
|
|
|
64
65
|
mkdirSync(dir, { recursive: true });
|
|
65
66
|
tryChmod(dir, 0o700);
|
|
66
67
|
const payload: RoutineFile = { routines };
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
});
|
|
71
|
-
tryChmod(routinesFilePath(), 0o600);
|
|
68
|
+
// Atomic: a half-written routines.json reads as "no routines" (loadRoutines), and the
|
|
69
|
+
// next save would then persist only the one routine it was given.
|
|
70
|
+
writeFileAtomic(routinesFilePath(), JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
|
|
72
71
|
}
|
|
73
72
|
|
|
74
73
|
// Look up by id first, then by (case-insensitive) name for CLI convenience.
|
|
@@ -99,18 +98,48 @@ export function removeRoutine(idOrName: string): Routine | null {
|
|
|
99
98
|
return target;
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
//
|
|
101
|
+
// Dated result files kept per routine. An hourly routine on an always-on box wrote one
|
|
102
|
+
// every hour, forever; latest.md is what anything reads, and this many is history enough.
|
|
103
|
+
export const ROUTINE_OUTPUT_KEEP = Number(process.env.PRIVATEER_ROUTINE_OUTPUT_KEEP) || 30;
|
|
104
|
+
|
|
105
|
+
// Write a run's result to the routine's output dir: a dated file plus latest.md, then
|
|
106
|
+
// prune the dated files to the newest ROUTINE_OUTPUT_KEEP. Owner-only, like the rest of
|
|
107
|
+
// the global dir: a result can quote whatever the routine read.
|
|
103
108
|
// Returns the absolute path of latest.md.
|
|
104
109
|
export function writeRoutineOutput(name: string, content: string): string {
|
|
105
110
|
const dir = routineOutputDir(name);
|
|
106
111
|
mkdirSync(dir, { recursive: true });
|
|
107
112
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
108
|
-
writeFileSync(join(dir, `${stamp}.md`), content, "utf8");
|
|
113
|
+
writeFileSync(join(dir, `${stamp}.md`), content, { encoding: "utf8", mode: 0o600 });
|
|
109
114
|
const latest = join(dir, "latest.md");
|
|
110
|
-
|
|
115
|
+
writeFileAtomic(latest, content, { mode: 0o600 });
|
|
116
|
+
pruneRoutineOutput(dir, ROUTINE_OUTPUT_KEEP);
|
|
111
117
|
return latest;
|
|
112
118
|
}
|
|
113
119
|
|
|
120
|
+
// Our dated names (ISO with ':' and '.' → '-') sort chronologically as strings, so the
|
|
121
|
+
// oldest are first. Only files of that shape are touched — anything else a user put in
|
|
122
|
+
// the folder is theirs. Best-effort: a prune failure never fails a delivery.
|
|
123
|
+
const DATED_OUTPUT = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.md$/;
|
|
124
|
+
|
|
125
|
+
export function pruneRoutineOutput(dir: string, keep: number): number {
|
|
126
|
+
let removed = 0;
|
|
127
|
+
try {
|
|
128
|
+
const dated = readdirSync(dir).filter((f) => DATED_OUTPUT.test(f)).sort();
|
|
129
|
+
for (const f of dated.slice(0, Math.max(0, dated.length - keep))) {
|
|
130
|
+
try {
|
|
131
|
+
unlinkSync(join(dir, f));
|
|
132
|
+
removed++;
|
|
133
|
+
} catch {
|
|
134
|
+
/* already gone */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
/* unreadable dir */
|
|
139
|
+
}
|
|
140
|
+
return removed;
|
|
141
|
+
}
|
|
142
|
+
|
|
114
143
|
// A pending routine result queued for the next interactive session ("notice"
|
|
115
144
|
// delivery). The TUI drains these on startup so results surface even when no
|
|
116
145
|
// terminal was attached at fire time.
|
|
@@ -144,7 +173,7 @@ export function addNotice(notice: RoutineNotice): void {
|
|
|
144
173
|
notices.push(notice);
|
|
145
174
|
// Keep the queue bounded so an offline stretch can't grow it without limit.
|
|
146
175
|
const trimmed = notices.slice(-50);
|
|
147
|
-
|
|
176
|
+
writeFileAtomic(noticesPath(), JSON.stringify(trimmed, null, 2) + "\n", { mode: 0o600 });
|
|
148
177
|
tryChmod(noticesPath(), 0o600);
|
|
149
178
|
}
|
|
150
179
|
|
|
@@ -189,7 +218,7 @@ export function addPendingRelay(entry: PendingRelay): void {
|
|
|
189
218
|
const queue = loadPendingRelay();
|
|
190
219
|
queue.push(entry);
|
|
191
220
|
const trimmed = queue.slice(-50); // bound the backlog
|
|
192
|
-
|
|
221
|
+
writeFileAtomic(pendingRelayPath(), JSON.stringify(trimmed, null, 2) + "\n", { mode: 0o600 });
|
|
193
222
|
tryChmod(pendingRelayPath(), 0o600);
|
|
194
223
|
}
|
|
195
224
|
|
|
@@ -271,6 +300,6 @@ export function addPendingCloud(entry: PendingCloud): void {
|
|
|
271
300
|
export function savePendingCloud(entries: PendingCloud[]): void {
|
|
272
301
|
const dir = join(globalDir(), "routines");
|
|
273
302
|
mkdirSync(dir, { recursive: true });
|
|
274
|
-
|
|
303
|
+
writeFileAtomic(pendingCloudPath(), JSON.stringify(entries, null, 2) + "\n", { mode: 0o600 });
|
|
275
304
|
tryChmod(pendingCloudPath(), 0o600);
|
|
276
305
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Replace a file all at once: write a sibling temp file, then rename it over the target.
|
|
2
|
+
//
|
|
3
|
+
// A plain writeFileSync truncates first and writes second, so a crash, a full disk or a
|
|
4
|
+
// kill in between leaves a short or empty file. For the files this is used on that is
|
|
5
|
+
// worse than losing the write: loadRoutines() reads a half-written routines.json as "no
|
|
6
|
+
// routines", and the next save from the app then writes just the one routine it holds —
|
|
7
|
+
// every other routine gone. rename() is atomic on one filesystem, so a reader sees the
|
|
8
|
+
// old file or the new one and never anything between.
|
|
9
|
+
//
|
|
10
|
+
// Permissions carry over from the file being replaced (a config.json someone chmod'ed
|
|
11
|
+
// 0600 stays 0600). A new file gets `mode`, default 0600: everything written through
|
|
12
|
+
// here is config that can hold tokens.
|
|
13
|
+
//
|
|
14
|
+
// IMPORT-SAFETY: node builtins only.
|
|
15
|
+
import { chmodSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
export function writeFileAtomic(path: string, data: string, opts: { mode?: number } = {}): void {
|
|
19
|
+
// Follow a symlinked config to the file it names, so we replace the file rather than
|
|
20
|
+
// the link (which would quietly detach a dotfiles-managed config).
|
|
21
|
+
let target = path;
|
|
22
|
+
let mode = opts.mode ?? 0o600;
|
|
23
|
+
try {
|
|
24
|
+
target = realpathSync(path);
|
|
25
|
+
if (opts.mode === undefined) mode = statSync(target).mode & 0o777;
|
|
26
|
+
} catch {
|
|
27
|
+
/* new file */
|
|
28
|
+
}
|
|
29
|
+
const tmp = `${target}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
30
|
+
try {
|
|
31
|
+
writeFileSync(tmp, data, { encoding: "utf8", mode });
|
|
32
|
+
try {
|
|
33
|
+
chmodSync(tmp, mode); // writeFileSync's mode is filtered by the umask
|
|
34
|
+
} catch {
|
|
35
|
+
/* non-POSIX filesystem */
|
|
36
|
+
}
|
|
37
|
+
renameSync(tmp, target);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
try {
|
|
40
|
+
unlinkSync(tmp);
|
|
41
|
+
} catch {
|
|
42
|
+
/* never created */
|
|
43
|
+
}
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
}
|