@indigoai-us/hq-cli 5.245.0 → 5.247.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,6 +2,41 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.247.0] — 2026-09-27
6
+ ### Changed
7
+
8
+ - The default-off `lanes.supersession-lineage-aware` flag lets a worker's lane
9
+ keep-alive and message delivery continue when its newer lane senior or a
10
+ same-profile sibling under that senior would otherwise look like a replacement.
11
+ - Keep-alive setup reads lineage settings before provider locks, and message
12
+ eligibility rechecks lane and retirement state after that read.
13
+
14
+ ### Changed
15
+
16
+ - `hq agent kit install` no longer implies the `@reboot` crontab entry is
17
+ enough on its own. The entry is installed and does restart the kit on an
18
+ ordinary host, but on a container whose root filesystem is rebuilt each boot
19
+ both cron and the CLI are erased along with it. The line now says so and
20
+ says what to do instead.
21
+
22
+ ## [5.246.0] — 2026-09-27
23
+
24
+ ### Fixed
25
+
26
+ - Every file the agent kit writes now works on storage that has no file
27
+ permissions. A bot whose agent home is a CIFS/SMB share (Azure Files) gets
28
+ EPERM from `chmod`, and the writes were hitting that one at a time: the wake
29
+ config could not be saved, so the bot could never be told mail had arrived,
30
+ and any message carrying an attachment failed on download. All of them now
31
+ go through one writer that asks for owner-only permissions, reports whether
32
+ they actually took, and carries on either way. The same writer makes those
33
+ writes atomic, so the inbox mirror, the seen/handled id lists, the kit
34
+ config, the heartbeat record and saved attachments can no longer be read
35
+ half-written by a concurrent poller. A chmod refused for any reason other
36
+ than a missing capability is still a real error.
37
+ - Picks up hq-cloud 6.18.10, which publishes the token refresh lock without
38
+ hard links so a bot on a network share can mint a token at all.
39
+
5
40
  ## [5.245.0] — 2026-09-27
6
41
 
7
42
  ### Fixed
@@ -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",
@@ -279,6 +279,10 @@ export function registerAgentKitCommand(agent) {
279
279
  console.log(` kit log: ${result.fallback.logPath}`);
280
280
  if (result.fallback.reboot.installed) {
281
281
  console.log(chalk.green(" reboot: a @reboot crontab entry was installed, so the kit restarts with the host"));
282
+ console.log(chalk.dim(" That holds only if cron and this CLI survive the restart. On a container " +
283
+ "whose root filesystem is rebuilt each boot, both are erased and the entry goes with " +
284
+ "them — reinstall the kit from your persistent storage after a restart, or have your " +
285
+ "platform start `hq agent kit run all` for you."));
282
286
  }
283
287
  else {
284
288
  console.log(chalk.red(` reboot: NOT protected — ${result.fallback.reboot.reason}. Until this is fixed the kit ` +
@@ -46,7 +46,7 @@ import { effectiveAccountHome } from "../lib/lanes/account-home.js";
46
46
  import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, LANE_PROVIDERS, LANE_STATES, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, hireGrantCapacityError, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listLaneIds, listQuestionIds, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseQuestionRecord, parseSeniorKind, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, loadWorkerProfileWithRegistry, registryWorkerRecord, runReconcilePass, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, appendDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, providerSessionPortable, sessionNotPortableMessage, withLaneAdmissionLock, withAllLaneAdmissionLocks, withLaneHireLocks, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, } from "../lib/lanes/index.js";
47
47
  import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
48
48
  import { resolveLaneKeepAlive } from "../lib/lanes/profile.js";
49
- import { keepAliveExclusion, LEAD_LANE_KEEP_ALIVE_FLAG, loadRetiredLaneIds, } from "../lib/lanes/keep-alive.js";
49
+ import { keepAliveExclusion, LEAD_LANE_KEEP_ALIVE_FLAG, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds, } from "../lib/lanes/keep-alive.js";
50
50
  import { questionBodyAbsPath, resolveQuestionCompanyWithFallback, resolveProjectQuestionCompany, } from "../lib/lanes/questions.js";
51
51
  import { assertQuestionBodySecretSafe } from "../lib/lanes/questions.js";
52
52
  import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
@@ -4891,21 +4891,22 @@ async function runKeepAlive(ctx, laneId, opts) {
4891
4891
  throw new LanesError("keep_alive_mode_invalid", "Pass exactly one of --on or --off", { lane_id: laneId });
4892
4892
  }
4893
4893
  const hqRoot = ctx.hqRoot();
4894
+ let lineageAware = false;
4895
+ if (opts.on) {
4896
+ const initialLane = readLane(hqRoot, laneId);
4897
+ assertKeepAliveChangeAllowed(ctx, initialLane, laneId);
4898
+ lineageAware = await readLanesRegistryFlag(LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, {
4899
+ company: initialLane.company,
4900
+ env: ctx.env,
4901
+ });
4902
+ }
4894
4903
  const updated = await withAllLaneAdmissionLocks(hqRoot, async () => {
4895
4904
  const lane = readLane(hqRoot, laneId);
4896
- if (!callerMatchesSenior(ctx, lane.senior)) {
4897
- throw new LanesError("keep_alive_unauthorized", `Only senior ${seniorKey(lane.senior)} may change lane ${laneId}'s keep-alive setting`, { lane_id: laneId, senior: lane.senior });
4898
- }
4899
- if (!laneRunsHere(lane, reconcileHostFromCtx(ctx))) {
4900
- throw new LanesError("keep_alive_owner_host_required", `Run hq lanes keep-alive ${laneId} on its owner host ${lane.host.kind}:${lane.host.id}`, { lane_id: laneId, host: lane.host });
4901
- }
4902
- if (lane.state === "killed" || lane.state === "cancelled" || lane.state === "failed") {
4903
- throw new LanesError("keep_alive_terminal_lane", `Cannot change keep-alive for lane ${laneId} in terminal state ${lane.state}`, { lane_id: laneId, state: lane.state });
4904
- }
4905
+ assertKeepAliveChangeAllowed(ctx, lane, laneId);
4905
4906
  let optedInAt;
4906
4907
  if (opts.on) {
4907
4908
  const retiredLaneIds = loadRetiredLaneIds(hqRoot);
4908
- const exclusion = keepAliveExclusion(lane, loadAllLanes(hqRoot), retiredLaneIds);
4909
+ const exclusion = keepAliveExclusion(lane, loadAllLanes(hqRoot), retiredLaneIds, lineageAware);
4909
4910
  if (exclusion !== undefined) {
4910
4911
  throw new LanesError("keep_alive_lane_excluded", `Cannot opt in lane ${laneId}: it is ${exclusion}`, { lane_id: laneId, blocked_reason: exclusion });
4911
4912
  }
@@ -4926,6 +4927,17 @@ async function runKeepAlive(ctx, laneId, opts) {
4926
4927
  lane: updated,
4927
4928
  });
4928
4929
  }
4930
+ function assertKeepAliveChangeAllowed(ctx, lane, laneId) {
4931
+ if (!callerMatchesSenior(ctx, lane.senior)) {
4932
+ throw new LanesError("keep_alive_unauthorized", "Only senior " + seniorKey(lane.senior) + " may change lane " + laneId + "'s keep-alive setting", { lane_id: laneId, senior: lane.senior });
4933
+ }
4934
+ if (!laneRunsHere(lane, reconcileHostFromCtx(ctx))) {
4935
+ throw new LanesError("keep_alive_owner_host_required", "Run hq lanes keep-alive " + laneId + " on its owner host " + lane.host.kind + ":" + lane.host.id, { lane_id: laneId, host: lane.host });
4936
+ }
4937
+ if (lane.state === "killed" || lane.state === "cancelled" || lane.state === "failed") {
4938
+ throw new LanesError("keep_alive_terminal_lane", "Cannot change keep-alive for lane " + laneId + " in terminal state " + lane.state, { lane_id: laneId, state: lane.state });
4939
+ }
4940
+ }
4929
4941
  async function runGrant(ctx, laneId, opts) {
4930
4942
  if (opts.hire === undefined) {
4931
4943
  throw new LanesError("missing_hire_grant", "--hire is required", {});
@@ -5808,7 +5820,9 @@ async function runMessage(ctx, laneId, opts) {
5808
5820
  }
5809
5821
  }
5810
5822
  try {
5811
- const exclusion = messageLaneExclusion(ctx, lane);
5823
+ const eligibility = await messageLaneExclusion(ctx, lane);
5824
+ lane = eligibility.lane;
5825
+ const exclusion = eligibility.exclusion;
5812
5826
  if (exclusion !== undefined) {
5813
5827
  let messageCount;
5814
5828
  if (dropboxInboxHasMessages(hqRoot, laneId)) {
@@ -6183,10 +6197,24 @@ async function runMessage(ctx, laneId, opts) {
6183
6197
  throw err;
6184
6198
  }
6185
6199
  }
6186
- function messageLaneExclusion(ctx, lane) {
6187
- if (lane.state === "killed" || lane.state === "cancelled")
6188
- return lane.state;
6189
- return keepAliveExclusion(lane, loadAllLanes(ctx.hqRoot()), loadRetiredLaneIds(ctx.hqRoot()));
6200
+ async function messageLaneExclusion(ctx, lane) {
6201
+ if (lane.state === "killed" || lane.state === "cancelled") {
6202
+ return { lane, exclusion: lane.state };
6203
+ }
6204
+ const lineageAware = await readLanesRegistryFlag(LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, {
6205
+ company: lane.company,
6206
+ env: ctx.env,
6207
+ });
6208
+ const currentLane = readLane(ctx.hqRoot(), lane.lane_id);
6209
+ if (currentLane.state === "killed" || currentLane.state === "cancelled") {
6210
+ return { lane: currentLane, exclusion: currentLane.state };
6211
+ }
6212
+ const lanes = loadAllLanes(ctx.hqRoot());
6213
+ const retiredLaneIds = loadRetiredLaneIds(ctx.hqRoot());
6214
+ return {
6215
+ lane: currentLane,
6216
+ exclusion: keepAliveExclusion(currentLane, lanes, retiredLaneIds, lineageAware),
6217
+ };
6190
6218
  }
6191
6219
  function recordMessageUndeliverable(ctx, lane, reason, messageCount) {
6192
6220
  writeWatcherDecision(watcherOutLogPath(ctx.hqRoot(), lane.lane_id), {
@@ -6206,10 +6234,12 @@ function recordMessageUndeliverable(ctx, lane, reason, messageCount) {
6206
6234
  */
6207
6235
  async function recoverQueuedMessages(ctx, laneId) {
6208
6236
  const hqRoot = ctx.hqRoot();
6209
- const lane = readLane(hqRoot, laneId);
6237
+ let lane = readLane(hqRoot, laneId);
6210
6238
  if (!dropboxInboxHasMessages(hqRoot, laneId))
6211
6239
  return { action: "none", message_count: 0 };
6212
- const exclusion = messageLaneExclusion(ctx, lane);
6240
+ const eligibility = await messageLaneExclusion(ctx, lane);
6241
+ lane = eligibility.lane;
6242
+ const exclusion = eligibility.exclusion;
6213
6243
  const refusal = exclusion ?? (lane.state === "done"
6214
6244
  ? "done_before_envelope"
6215
6245
  : lane.state === "awaiting_input"
@@ -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 {