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.
@@ -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, writeFileSync } from "node:fs";
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
- writeFileSync(configPath(), JSON.stringify(cfg, null, 2));
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
- writeFileSync(configPath(), JSON.stringify(cfg, null, 2));
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 (spawnedAccount) {
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);
@@ -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, writeFileSync, existsSync, mkdirSync } from "node:fs";
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
- writeFileSync(sourcePath(), JSON.stringify(src, null, 2) + "\n");
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
- writeFileSync(
224
+ writeFileAtomic(
224
225
  projectionPath(),
225
226
  JSON.stringify({ mcpServers, settings: { toolPrefix: "server" } }, null, 2) + "\n",
226
227
  );
@@ -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
- writeFileSync(routinesFilePath(), JSON.stringify(payload, null, 2) + "\n", {
68
- encoding: "utf8",
69
- mode: 0o600,
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
- // Write a run's result to the routine's output dir: a dated file plus latest.md.
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
- writeFileSync(latest, content, "utf8");
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
- writeFileSync(noticesPath(), JSON.stringify(trimmed, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
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
- writeFileSync(pendingRelayPath(), JSON.stringify(trimmed, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
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
- writeFileSync(pendingCloudPath(), JSON.stringify(entries, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
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
+ }