@indigoai-us/hq-cli 5.244.0 → 5.246.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.
package/CHANGELOG.md CHANGED
@@ -2,8 +2,49 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.246.0] — 2026-09-27
6
+
7
+ ### Fixed
8
+
9
+ - Every file the agent kit writes now works on storage that has no file
10
+ permissions. A bot whose agent home is a CIFS/SMB share (Azure Files) gets
11
+ EPERM from `chmod`, and the writes were hitting that one at a time: the wake
12
+ config could not be saved, so the bot could never be told mail had arrived,
13
+ and any message carrying an attachment failed on download. All of them now
14
+ go through one writer that asks for owner-only permissions, reports whether
15
+ they actually took, and carries on either way. The same writer makes those
16
+ writes atomic, so the inbox mirror, the seen/handled id lists, the kit
17
+ config, the heartbeat record and saved attachments can no longer be read
18
+ half-written by a concurrent poller. A chmod refused for any reason other
19
+ than a missing capability is still a real error.
20
+ - Picks up hq-cloud 6.18.10, which publishes the token refresh lock without
21
+ hard links so a bot on a network share can mint a token at all.
22
+
23
+ ## [5.245.0] — 2026-09-27
24
+
25
+ ### Fixed
26
+
27
+ - Work Mesh sessions registered without a bound project (unresolved,
28
+ needs_project, needs_task) no longer store the register receipt as their
29
+ binding episode, and `hq mesh context bind-project` clears it. The server
30
+ dropped every session-event batch carrying that id, so turn and session ends
31
+ never landed and no work report posted. Existing state files are repaired on
32
+ the next flush.
33
+
5
34
  ## [5.244.0] — 2026-09-27
6
35
 
36
+ ### Fixed
37
+
38
+ - A bot whose agent home is on a network share can now mint a token. The
39
+ refresh lock was a POSIX hard-link lock, and CIFS/SMB shares — Azure Files
40
+ again — do not implement `link()` at all, so every mint failed with
41
+ ENOTSUP and took the whole probe down with it: no heartbeat ever posted
42
+ and the inbox poller retried and failed every 15 seconds. The lock now
43
+ falls back to an exclusive create, which gives the same guarantee and
44
+ works there. Contention still surfaces as EEXIST on both paths, so
45
+ stale-owner reclamation is unchanged, and a link that fails for any other
46
+ reason is still a real error.
47
+
7
48
  ### Changed
8
49
 
9
50
  - The setup bot no longer posts "This one takes a minute. I'll reply here as
@@ -1643,16 +1684,6 @@
1643
1684
 
1644
1685
  ### Fixed
1645
1686
 
1646
- - A bot whose agent home is on a network share can now mint a token. The
1647
- refresh lock was a POSIX hard-link lock, and CIFS/SMB shares — Azure Files
1648
- again — do not implement `link()` at all, so every mint failed with
1649
- ENOTSUP and took the whole probe down with it: no heartbeat ever posted
1650
- and the inbox poller retried and failed every 15 seconds. The lock now
1651
- falls back to an exclusive create, which gives the same guarantee and
1652
- works there. Contention still surfaces as EEXIST on both paths, so
1653
- stale-owner reclamation is unchanged, and a link that fails for any other
1654
- reason is still a real error.
1655
-
1656
1687
  - `hq mesh daemon install` and `hq mesh daemon run` unload and remove the
1657
1688
  legacy `ai.getindigo.hq-work-mesh-listen` unit (LaunchAgent, systemd user
1658
1689
  unit, or Scheduled Task) so only the daemon subscribes. The daemon writes
@@ -4669,7 +4669,7 @@ export declare const COMMAND_CATALOG: readonly [{
4669
4669
  readonly subcommands: readonly [];
4670
4670
  }, {
4671
4671
  readonly name: "exec";
4672
- readonly description: "Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command). Default is synchronous (API Gateway ~20s cap). Use --async for long jobs, or --detach to print the commandId and return immediately.";
4672
+ readonly description: "Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command). The command waits for the submitted job to finish. Use --wait-timeout-seconds to bound local waiting, or --detach to print the commandId and return immediately.";
4673
4673
  readonly aliases: readonly [];
4674
4674
  readonly hidden: false;
4675
4675
  readonly usage: "[options] <command...>";
@@ -4692,7 +4692,10 @@ export declare const COMMAND_CATALOG: readonly [{
4692
4692
  readonly description: "Submit via the async transport, print commandId, and return immediately (pair with `hq outposts exec-result --wait`)";
4693
4693
  }, {
4694
4694
  readonly flags: "--timeout-seconds <n>";
4695
- readonly description: "Async shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800). Implies --async unless --detach is set.";
4695
+ readonly description: "Remote shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800)";
4696
+ }, {
4697
+ readonly flags: "--wait-timeout-seconds <n>";
4698
+ readonly description: "Maximum time this client waits for the command result (1..172800; default: 1800)";
4696
4699
  }, {
4697
4700
  readonly flags: "--json";
4698
4701
  readonly description: "Emit raw JSON";
@@ -6036,7 +6036,7 @@ export const COMMAND_CATALOG = [
6036
6036
  },
6037
6037
  {
6038
6038
  "name": "exec",
6039
- "description": "Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command). Default is synchronous (API Gateway ~20s cap). Use --async for long jobs, or --detach to print the commandId and return immediately.",
6039
+ "description": "Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command). The command waits for the submitted job to finish. Use --wait-timeout-seconds to bound local waiting, or --detach to print the commandId and return immediately.",
6040
6040
  "aliases": [],
6041
6041
  "hidden": false,
6042
6042
  "usage": "[options] <command...>",
@@ -6066,7 +6066,11 @@ export const COMMAND_CATALOG = [
6066
6066
  },
6067
6067
  {
6068
6068
  "flags": "--timeout-seconds <n>",
6069
- "description": "Async shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800). Implies --async unless --detach is set."
6069
+ "description": "Remote shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800)"
6070
+ },
6071
+ {
6072
+ "flags": "--wait-timeout-seconds <n>",
6073
+ "description": "Maximum time this client waits for the command result (1..172800; default: 1800)"
6070
6074
  },
6071
6075
  {
6072
6076
  "flags": "--json",
@@ -42,13 +42,12 @@ import { defaultRunCommand } from "../lib/service-manager/launchd.js";
42
42
  import { workContextRoot } from "../lib/work-context/paths.js";
43
43
  import { formatMigrateConfirmation, submitSessionMigration, } from "../lib/work-context/migrate.js";
44
44
  import { formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, submitOrganizeDecision, } from "../lib/work-context/organize.js";
45
- import { readSessionState, updateSessionState } from "../lib/work-context/state.js";
45
+ import { applyOperatorProjectBinding, readSessionState, updateSessionState, } from "../lib/work-context/state.js";
46
46
  import { findLocalPrd, listNeedsProjectSessions } from "../lib/work-context/bind-best.js";
47
47
  import { bindCallingSession, formatSessionBoundLine } from "../lib/work-context/bind-calling-session.js";
48
48
  import { rebindNeedsProjectOnPrompt } from "../lib/work-context/prompt-rebind.js";
49
49
  import { planPrdSync, rebindSessionToProject } from "../lib/work-context/prd-sync.js";
50
50
  import { reserveSessionSeq } from "../lib/mesh/live/session-seq.js";
51
- import { WORK_CONTEXT_CONTRACT_VERSION } from "../lib/work-context/contract.js";
52
51
  import { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "../lib/work-context/reconcile.js";
53
52
  import { resolveCompany } from "../lib/work-context/company.js";
54
53
  import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../lib/work-context/repo-remote.js";
@@ -835,20 +834,7 @@ async function runBindProject(target, opts) {
835
834
  fail(`No candidate ${projectId} on session ${sessionId}.`);
836
835
  projectId = chosen.projectId;
837
836
  }
838
- const updated = updateSessionState(sessionId, root, (state) => {
839
- if (!state)
840
- return null;
841
- return {
842
- ...state,
843
- contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
844
- contextStatus: state.taskId ? "bound" : "needs_task",
845
- projectId,
846
- projectGate: undefined,
847
- projectCandidates: undefined,
848
- projectBindReason: `operator selected ${projectId}`,
849
- updatedAt: new Date().toISOString(),
850
- };
851
- });
837
+ const updated = updateSessionState(sessionId, root, (state) => state ? applyOperatorProjectBinding(state, projectId) : null);
852
838
  if (!updated)
853
839
  fail(`No work-context state for session ${sessionId}.`);
854
840
  if (opts.json) {
@@ -25,6 +25,7 @@
25
25
  * would only have moved it somewhere it silently disappears.
26
26
  */
27
27
  import type { AgentKitPaths, KitComponent } from "./paths.js";
28
+ import { type PrivateFileOutcome } from "./private-file.js";
28
29
  /** Exact `machine-creds.json` shape for an external agent (api-contract v1). */
29
30
  export interface ExternalMachineCreds {
30
31
  username: string;
@@ -49,18 +50,7 @@ export interface HostKeyPair {
49
50
  /** Contract fingerprint: sha256(DER SPKI public key) hex, first 16 chars. */
50
51
  export declare function hostFingerprint(publicPem: string): string;
51
52
  export declare function generateHostKeyPair(): HostKeyPair;
52
- /**
53
- * What a write could actually guarantee about who can read the file.
54
- * `enforced: false` means the bytes are on disk but readable beyond the
55
- * owner — true of a share mounted with a fixed `file_mode`.
56
- */
57
- export interface PrivateFileOutcome {
58
- enforced: boolean;
59
- /** Mode observed after the write, when it could be read back. */
60
- observedMode?: number;
61
- /** errno of a refused chmod (EPERM on Azure Files), when there was one. */
62
- chmodError?: string;
63
- }
53
+ export type { PrivateFileOutcome } from "./private-file.js";
64
54
  export declare function writeHostKeyPair(paths: Pick<AgentKitPaths, "hostKeyPath" | "hostKeyPubPath">, pair: HostKeyPair): PrivateFileOutcome;
65
55
  export declare function writeMachineCreds(paths: Pick<AgentKitPaths, "machineCredsPath">, creds: ExternalMachineCreds): PrivateFileOutcome;
66
56
  /**
@@ -26,8 +26,8 @@
26
26
  */
27
27
  import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto";
28
28
  import * as fs from "node:fs";
29
- import * as path from "node:path";
30
29
  import { componentStatePath } from "./paths.js";
30
+ import { writePrivateFile, } from "./private-file.js";
31
31
  /** Contract fingerprint: sha256(DER SPKI public key) hex, first 16 chars. */
32
32
  export function hostFingerprint(publicPem) {
33
33
  const der = createPublicKey(publicPem).export({ type: "spki", format: "der" });
@@ -39,65 +39,6 @@ export function generateHostKeyPair() {
39
39
  const publicPem = publicKey.export({ type: "spki", format: "pem" }).toString();
40
40
  return { privatePem, publicPem, fingerprint: hostFingerprint(publicPem) };
41
41
  }
42
- /**
43
- * chmod failures that mean "this filesystem does not do modes" rather than
44
- * "something is wrong". Anything else still throws: a refused chmod on a
45
- * local disk is a real fault and must not be swallowed.
46
- */
47
- const MODE_UNSUPPORTED_CODES = new Set([
48
- "EPERM",
49
- "EACCES",
50
- "EINVAL",
51
- "ENOTSUP",
52
- "EOPNOTSUPP",
53
- ]);
54
- /** Write a file atomically with the given mode, replacing any existing one. */
55
- function writePrivateFile(dest, contents, mode) {
56
- fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
57
- // Date.now() as well as the pid: two writes from one process must not
58
- // collide on a temp name, and a leftover from a crashed run must not be
59
- // adopted by the next one.
60
- const tmp = `${dest}.tmp.${process.pid}.${Date.now()}`;
61
- let chmodError;
62
- try {
63
- fs.writeFileSync(tmp, contents, { mode });
64
- try {
65
- fs.chmodSync(tmp, mode);
66
- }
67
- catch (err) {
68
- const code = err.code ?? "";
69
- if (!MODE_UNSUPPORTED_CODES.has(code))
70
- throw err;
71
- chmodError = code;
72
- }
73
- // Read the truth back rather than trusting either call: a mount can
74
- // accept chmod and ignore it just as easily as it can refuse.
75
- let observedMode;
76
- try {
77
- observedMode = fs.statSync(tmp).mode & 0o777;
78
- }
79
- catch {
80
- /* stat is not worth failing a write over */
81
- }
82
- fs.renameSync(tmp, dest);
83
- return {
84
- enforced: observedMode === undefined ? !chmodError : (observedMode & 0o077) === 0,
85
- ...(observedMode === undefined ? {} : { observedMode }),
86
- ...(chmodError ? { chmodError } : {}),
87
- };
88
- }
89
- catch (err) {
90
- // Never leave the temp file behind — it holds the same secret as the
91
- // file that failed to land.
92
- try {
93
- fs.unlinkSync(tmp);
94
- }
95
- catch {
96
- /* already gone */
97
- }
98
- throw err;
99
- }
100
- }
101
42
  export function writeHostKeyPair(paths, pair) {
102
43
  const key = writePrivateFile(paths.hostKeyPath, pair.privatePem, 0o600);
103
44
  // The public half is public; its mode is not a promise worth reporting.
@@ -11,6 +11,7 @@ import { spawn, spawnSync } from "node:child_process";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { kitServiceEnv } from "./services.js";
14
+ import { writePrivateFile } from "./private-file.js";
14
15
  export const KIT_PID_NAME = "kit.pid";
15
16
  export const KIT_FALLBACK_LOG_NAME = "kit.log";
16
17
  export function kitPidPath(paths) {
@@ -119,7 +120,7 @@ export function startFallback(paths, host, deps = {}) {
119
120
  if (!child.pid)
120
121
  throw new Error(`could not start ${cmd} ${args.join(" ")}`);
121
122
  child.unref();
122
- fs.writeFileSync(kitPidPath(paths), `${child.pid}\n`, { mode: 0o600 });
123
+ writePrivateFile(kitPidPath(paths), `${child.pid}\n`, 0o600);
123
124
  return { pid: child.pid, logPath, pidFile: kitPidPath(paths) };
124
125
  }
125
126
  finally {
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
+ import { writePrivateFile } from "./private-file.js";
13
14
  export const DONE_IDS_NAME = "done-ids.json";
14
15
  export const MAX_DONE_IDS = 5000;
15
16
  const INBOX_JSONL = "inbox.jsonl";
@@ -31,8 +32,7 @@ export function markInboxDone(paths, ids) {
31
32
  added.push(id);
32
33
  }
33
34
  }
34
- fs.mkdirSync(paths.inboxDir, { recursive: true, mode: 0o700 });
35
- fs.writeFileSync(path.join(paths.inboxDir, DONE_IDS_NAME), JSON.stringify([...done].slice(-MAX_DONE_IDS)), { mode: 0o600 });
35
+ writePrivateFile(path.join(paths.inboxDir, DONE_IDS_NAME), JSON.stringify([...done].slice(-MAX_DONE_IDS)), 0o600);
36
36
  return added;
37
37
  }
38
38
  /** Every mirrored entry, oldest first, de-duplicated by id (last write wins). */
@@ -7,6 +7,7 @@
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { KIT_COMPONENTS } from "./paths.js";
10
+ import { writePrivateFile } from "./private-file.js";
10
11
  export const DEFAULT_SYNC_INTERVAL_MS = 5 * 60_000;
11
12
  export const DEFAULT_INBOX_POLL_MS = 15_000;
12
13
  export const DEFAULT_HEARTBEAT_INTERVAL_MS = 60_000;
@@ -36,8 +37,7 @@ export function defaultKitConfig(paths, cliVersion, now = () => new Date()) {
36
37
  };
37
38
  }
38
39
  export function writeKitConfig(paths, config) {
39
- fs.mkdirSync(path.dirname(paths.kitConfigPath), { recursive: true, mode: 0o700 });
40
- fs.writeFileSync(paths.kitConfigPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
40
+ writePrivateFile(paths.kitConfigPath, `${JSON.stringify(config, null, 2)}\n`, 0o600);
41
41
  }
42
42
  /** Read kit.json, falling back to defaults for any missing/invalid field. */
43
43
  export function readKitConfig(paths, cliVersion) {
@@ -32,9 +32,9 @@
32
32
  * with a sanitized name, so nothing from the message can pick the location.
33
33
  */
34
34
  import { createHash } from "node:crypto";
35
- import * as fs from "node:fs";
36
35
  import * as path from "node:path";
37
36
  import { McpToolInputError } from "./jsonrpc.js";
37
+ import { mkdirPrivate, writePrivateFile } from "../private-file.js";
38
38
  /**
39
39
  * Refuse anything larger than this. Chat uploads are capped at 25 MB
40
40
  * (MAX_CHAT_ATTACHMENT_BYTES in hq-desktop-app); 26 MiB leaves a little
@@ -147,27 +147,15 @@ export async function saveAttachment(input) {
147
147
  }
148
148
  const name = sanitizeAttachmentFileName(input.name || key.split("/").pop() || "file");
149
149
  const dir = path.join(attachmentsDir, sanitizeAttachmentFileName(scopeUid));
150
- for (const d of [attachmentsDir, dir]) {
151
- fs.mkdirSync(d, { recursive: true, mode: 0o700 });
152
- fs.chmodSync(d, 0o700);
153
- }
150
+ for (const d of [attachmentsDir, dir])
151
+ mkdirPrivate(d);
154
152
  // A short digest of scope+key keeps two same-named files apart and makes a
155
153
  // re-fetch of the same attachment overwrite rather than pile up.
156
154
  const digest = createHash("sha256").update(`${scopeUid}\0${key}`).digest("hex").slice(0, 12);
157
155
  const localPath = path.join(dir, `${digest}-${name}`);
158
156
  if (path.dirname(localPath) !== dir)
159
157
  throw new Error("refusing to write outside the attachments directory");
160
- const tmp = `${localPath}.${process.pid}.tmp`;
161
- try {
162
- fs.writeFileSync(tmp, bytes, { mode: 0o600 });
163
- fs.chmodSync(tmp, 0o600);
164
- fs.renameSync(tmp, localPath);
165
- }
166
- catch (err) {
167
- // Never leave a partial copy of the attachment behind.
168
- fs.rmSync(tmp, { force: true });
169
- throw err;
170
- }
158
+ writePrivateFile(localPath, bytes, 0o600);
171
159
  const ext = name.split(".").pop()?.toLowerCase() ?? "";
172
160
  const text = renderText(bytes, name, input.contentType);
173
161
  const note = text !== undefined
@@ -0,0 +1,29 @@
1
+ /**
2
+ * What a write could actually guarantee about who can read the file.
3
+ * `enforced: false` means the bytes are on disk but readable beyond the
4
+ * owner — true of a share mounted with a fixed `file_mode`.
5
+ */
6
+ export interface PrivateFileOutcome {
7
+ enforced: boolean;
8
+ /** Mode observed after the write, when it could be read back. */
9
+ observedMode?: number;
10
+ /** errno of a refused chmod (EPERM on Azure Files), when there was one. */
11
+ chmodError?: string;
12
+ }
13
+ /**
14
+ * chmod failures that mean "this filesystem does not do modes" rather than
15
+ * "something is wrong". Anything else still throws: a refused chmod on a
16
+ * local disk is a real fault and must not be swallowed.
17
+ */
18
+ export declare const MODE_UNSUPPORTED_CODES: Set<string>;
19
+ /** Apply a mode, tolerating filesystems that do not have modes. Returns the errno if refused. */
20
+ export declare function chmodIfSupported(target: string, mode: number): string | undefined;
21
+ /**
22
+ * Make a directory and try to make it owner-only. The `mode` argument to
23
+ * mkdir(2) is silently ignored on a share, and the follow-up chmod is refused
24
+ * there, so neither is allowed to fail the call.
25
+ */
26
+ export declare function mkdirPrivate(dir: string, mode?: number): void;
27
+ /** Write a file atomically with the given mode, replacing any existing one. */
28
+ export declare function writePrivateFile(dest: string, contents: string | Uint8Array, mode: number): PrivateFileOutcome;
29
+ //# sourceMappingURL=private-file.d.ts.map
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Writing owner-only files on filesystems that may not have owner-only files.
3
+ *
4
+ * The agent kit keeps credentials, wake config, and downloaded attachments in
5
+ * the agent's home directory, and writes all of it 0600/0700. Not every
6
+ * filesystem can honour that. A CIFS/SMB mount (Azure Files is the one that
7
+ * found this) is mounted with a fixed `file_mode` and fails `chmod` outright
8
+ * with EPERM; an NFS export or a Docker volume may accept the call and ignore
9
+ * it. A bot whose only persistent storage is such a share must still be able
10
+ * to run — so these helpers write the bytes, ask for the mode, and report
11
+ * whether the mode actually took instead of dying on the request.
12
+ *
13
+ * Every helper here is atomic: bytes land in a temp file in the destination
14
+ * directory and are renamed into place, so a reader never sees a half-written
15
+ * file and a failed write never leaves a partial one behind.
16
+ */
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ /**
20
+ * chmod failures that mean "this filesystem does not do modes" rather than
21
+ * "something is wrong". Anything else still throws: a refused chmod on a
22
+ * local disk is a real fault and must not be swallowed.
23
+ */
24
+ export const MODE_UNSUPPORTED_CODES = new Set([
25
+ "EPERM",
26
+ "EACCES",
27
+ "EINVAL",
28
+ "ENOTSUP",
29
+ "EOPNOTSUPP",
30
+ ]);
31
+ /** Apply a mode, tolerating filesystems that do not have modes. Returns the errno if refused. */
32
+ export function chmodIfSupported(target, mode) {
33
+ try {
34
+ fs.chmodSync(target, mode);
35
+ return undefined;
36
+ }
37
+ catch (err) {
38
+ const code = err.code ?? "";
39
+ if (!MODE_UNSUPPORTED_CODES.has(code))
40
+ throw err;
41
+ return code;
42
+ }
43
+ }
44
+ /**
45
+ * Make a directory and try to make it owner-only. The `mode` argument to
46
+ * mkdir(2) is silently ignored on a share, and the follow-up chmod is refused
47
+ * there, so neither is allowed to fail the call.
48
+ */
49
+ export function mkdirPrivate(dir, mode = 0o700) {
50
+ fs.mkdirSync(dir, { recursive: true, mode });
51
+ chmodIfSupported(dir, mode);
52
+ }
53
+ /** Write a file atomically with the given mode, replacing any existing one. */
54
+ export function writePrivateFile(dest, contents, mode) {
55
+ mkdirPrivate(path.dirname(dest));
56
+ // Date.now() as well as the pid: two writes from one process must not
57
+ // collide on a temp name, and a leftover from a crashed run must not be
58
+ // adopted by the next one.
59
+ const tmp = `${dest}.tmp.${process.pid}.${Date.now()}`;
60
+ try {
61
+ fs.writeFileSync(tmp, contents, { mode });
62
+ const chmodError = chmodIfSupported(tmp, mode);
63
+ // Read the truth back rather than trusting either call: a mount can
64
+ // accept chmod and ignore it just as easily as it can refuse.
65
+ let observedMode;
66
+ try {
67
+ observedMode = fs.statSync(tmp).mode & 0o777;
68
+ }
69
+ catch {
70
+ /* stat is not worth failing a write over */
71
+ }
72
+ fs.renameSync(tmp, dest);
73
+ return {
74
+ enforced: observedMode === undefined ? !chmodError : (observedMode & 0o077) === 0,
75
+ ...(observedMode === undefined ? {} : { observedMode }),
76
+ ...(chmodError ? { chmodError } : {}),
77
+ };
78
+ }
79
+ catch (err) {
80
+ // Never leave the temp file behind — it may hold the same secret as the
81
+ // file that failed to land.
82
+ try {
83
+ fs.unlinkSync(tmp);
84
+ }
85
+ catch {
86
+ /* already gone */
87
+ }
88
+ throw err;
89
+ }
90
+ }
91
+ //# sourceMappingURL=private-file.js.map
@@ -18,6 +18,7 @@ import { captureHeartbeatIdentityMismatch, identityMismatchMessage, isHeartbeatI
18
18
  import { vaultApiFetch } from "../../../utils/vault-api.js";
19
19
  import { readComponentStatus } from "../creds.js";
20
20
  import { KIT_COMPONENTS } from "../paths.js";
21
+ import { writePrivateFile } from "./../private-file.js";
21
22
  /** A component stamp older than this reads as error. */
22
23
  export const COMPONENT_STALE_AFTER_MS = {
23
24
  sync: 3 * 60 * 60_000, // sync passes every 5 min; three hours of silence is real trouble
@@ -40,8 +41,7 @@ export function buildHeartbeatBody(paths, now = () => new Date(), hostInfo, comp
40
41
  };
41
42
  }
42
43
  export function writeLastHeartbeat(paths, record) {
43
- fs.mkdirSync(paths.stateDir, { recursive: true, mode: 0o700 });
44
- fs.writeFileSync(paths.lastHeartbeatPath, `${JSON.stringify(record)}\n`, { mode: 0o600 });
44
+ writePrivateFile(paths.lastHeartbeatPath, `${JSON.stringify(record)}\n`, 0o600);
45
45
  }
46
46
  export function readLastHeartbeat(paths) {
47
47
  try {
@@ -20,6 +20,7 @@ import * as path from "node:path";
20
20
  import { vaultApiFetch } from "../../../utils/vault-api.js";
21
21
  import { writeComponentStatus } from "../creds.js";
22
22
  import { pendingInbox } from "../inbox-state.js";
23
+ import { writePrivateFile } from "./../private-file.js";
23
24
  import { buildWakeNotice, currentWakeState, fireWake, readWakeConfig, shouldWake, } from "../wake.js";
24
25
  export const SEEN_IDS_NAME = "seen-ids.json";
25
26
  export const INBOX_JSONL_NAME = "inbox.jsonl";
@@ -50,14 +51,12 @@ export function readSeenIds(paths) {
50
51
  }
51
52
  }
52
53
  export function writeSeenIds(paths, seen) {
53
- fs.mkdirSync(paths.inboxDir, { recursive: true, mode: 0o700 });
54
54
  const list = [...seen].slice(-MAX_SEEN_IDS);
55
- fs.writeFileSync(path.join(paths.inboxDir, SEEN_IDS_NAME), JSON.stringify(list), { mode: 0o600 });
55
+ writePrivateFile(path.join(paths.inboxDir, SEEN_IDS_NAME), JSON.stringify(list), 0o600);
56
56
  }
57
57
  export function mirrorInboxItem(paths, item) {
58
- fs.mkdirSync(paths.inboxDir, { recursive: true, mode: 0o700 });
59
58
  const dest = path.join(paths.inboxDir, `${item.id}.json`);
60
- fs.writeFileSync(dest, `${JSON.stringify(item.raw, null, 2)}\n`, { mode: 0o600 });
59
+ writePrivateFile(dest, `${JSON.stringify(item.raw, null, 2)}\n`, 0o600);
61
60
  fs.appendFileSync(path.join(paths.inboxDir, INBOX_JSONL_NAME), `${JSON.stringify({ id: item.id, mirroredAt: new Date().toISOString(), ...item.raw })}\n`, { mode: 0o600 });
62
61
  return dest;
63
62
  }
@@ -21,6 +21,7 @@
21
21
  import { spawn } from "node:child_process";
22
22
  import * as fs from "node:fs";
23
23
  import * as path from "node:path";
24
+ import { writePrivateFile } from "./private-file.js";
24
25
  export const WAKE_FILE_NAME = "wake.json";
25
26
  export const WAKE_STATE_NAME = "wake-state.json";
26
27
  export const DEFAULT_WAKE_RETRY_MS = 10 * 60_000;
@@ -92,10 +93,10 @@ export function readWakeConfig(paths) {
92
93
  return null;
93
94
  }
94
95
  export function writeWakeConfig(paths, config) {
95
- fs.mkdirSync(paths.agentDir, { recursive: true, mode: 0o700 });
96
- const file = wakePath(paths);
97
- fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
98
- fs.chmodSync(file, 0o600);
96
+ // Owner-only if the filesystem allows it. A bot whose only persistent
97
+ // storage is a CIFS share cannot have owner-only files at all, and refusing
98
+ // to write there would mean it could never be woken.
99
+ writePrivateFile(wakePath(paths), `${JSON.stringify(config, null, 2)}\n`, 0o600);
99
100
  }
100
101
  export function clearWakeConfig(paths) {
101
102
  try {