@hasna/machines 0.0.45 → 0.0.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +27 -4
  2. package/dist/agent/index.d.ts +0 -1
  3. package/dist/agent/index.js +249 -14
  4. package/dist/agent/runtime.d.ts +0 -1
  5. package/dist/cli/index.d.ts +0 -1
  6. package/dist/cli/index.js +1301 -210
  7. package/dist/cli-utils.d.ts +0 -1
  8. package/dist/commands/apps.d.ts +7 -5
  9. package/dist/commands/backup.d.ts +0 -1
  10. package/dist/commands/cert.d.ts +0 -1
  11. package/dist/commands/clipboard-daemon.d.ts +0 -1
  12. package/dist/commands/clipboard-server.d.ts +0 -1
  13. package/dist/commands/clipboard.d.ts +0 -1
  14. package/dist/commands/daemon.d.ts +0 -1
  15. package/dist/commands/diff.d.ts +0 -1
  16. package/dist/commands/dns.d.ts +0 -1
  17. package/dist/commands/doctor.d.ts +0 -1
  18. package/dist/commands/heal-daemon.d.ts +0 -1
  19. package/dist/commands/heal.d.ts +0 -1
  20. package/dist/commands/install-claude.d.ts +5 -3
  21. package/dist/commands/install-tailscale.d.ts +5 -3
  22. package/dist/commands/manifest.d.ts +0 -1
  23. package/dist/commands/mutation-approval.d.ts +54 -0
  24. package/dist/commands/notifications.d.ts +14 -2
  25. package/dist/commands/ports.d.ts +0 -1
  26. package/dist/commands/runtime.d.ts +15 -1
  27. package/dist/commands/screen.d.ts +4 -1
  28. package/dist/commands/self-test.d.ts +0 -1
  29. package/dist/commands/serve.d.ts +0 -1
  30. package/dist/commands/setup.d.ts +5 -3
  31. package/dist/commands/ssh.d.ts +8 -1
  32. package/dist/commands/status.d.ts +0 -1
  33. package/dist/commands/sync.d.ts +5 -3
  34. package/dist/commands/workspace.d.ts +0 -1
  35. package/dist/compatibility.d.ts +0 -1
  36. package/dist/consumer-schema.d.ts +0 -1
  37. package/dist/consumer.d.ts +0 -1
  38. package/dist/consumer.js +253 -12
  39. package/dist/cross-project-types.d.ts +0 -1
  40. package/dist/db.d.ts +0 -1
  41. package/dist/index.d.ts +2 -2
  42. package/dist/index.js +1091 -184
  43. package/dist/manifests.d.ts +0 -1
  44. package/dist/mcp/http.d.ts +26 -2
  45. package/dist/mcp/index.d.ts +0 -1
  46. package/dist/mcp/index.js +1004 -162
  47. package/dist/mcp/server.d.ts +5 -3
  48. package/dist/paths.d.ts +0 -1
  49. package/dist/pg-migrations.d.ts +0 -1
  50. package/dist/redaction.d.ts +0 -1
  51. package/dist/remote-storage.d.ts +0 -1
  52. package/dist/remote.d.ts +14 -5
  53. package/dist/storage-sync.d.ts +0 -1
  54. package/dist/storage.d.ts +0 -1
  55. package/dist/storage.js +18 -0
  56. package/dist/topology.d.ts +0 -1
  57. package/dist/types.d.ts +3 -1
  58. package/dist/version.d.ts +0 -1
  59. package/package.json +5 -3
  60. package/dist/agent/index.d.ts.map +0 -1
  61. package/dist/agent/runtime.d.ts.map +0 -1
  62. package/dist/cli/index.d.ts.map +0 -1
  63. package/dist/cli-utils.d.ts.map +0 -1
  64. package/dist/commands/apps.d.ts.map +0 -1
  65. package/dist/commands/backup.d.ts.map +0 -1
  66. package/dist/commands/cert.d.ts.map +0 -1
  67. package/dist/commands/clipboard-daemon.d.ts.map +0 -1
  68. package/dist/commands/clipboard-server.d.ts.map +0 -1
  69. package/dist/commands/clipboard.d.ts.map +0 -1
  70. package/dist/commands/daemon.d.ts.map +0 -1
  71. package/dist/commands/diff.d.ts.map +0 -1
  72. package/dist/commands/dns.d.ts.map +0 -1
  73. package/dist/commands/doctor.d.ts.map +0 -1
  74. package/dist/commands/heal-daemon.d.ts.map +0 -1
  75. package/dist/commands/heal.d.ts.map +0 -1
  76. package/dist/commands/install-claude.d.ts.map +0 -1
  77. package/dist/commands/install-tailscale.d.ts.map +0 -1
  78. package/dist/commands/manifest.d.ts.map +0 -1
  79. package/dist/commands/notifications.d.ts.map +0 -1
  80. package/dist/commands/ports.d.ts.map +0 -1
  81. package/dist/commands/runtime.d.ts.map +0 -1
  82. package/dist/commands/screen.d.ts.map +0 -1
  83. package/dist/commands/self-test.d.ts.map +0 -1
  84. package/dist/commands/serve.d.ts.map +0 -1
  85. package/dist/commands/setup.d.ts.map +0 -1
  86. package/dist/commands/ssh.d.ts.map +0 -1
  87. package/dist/commands/status.d.ts.map +0 -1
  88. package/dist/commands/sync.d.ts.map +0 -1
  89. package/dist/commands/workspace.d.ts.map +0 -1
  90. package/dist/compatibility.d.ts.map +0 -1
  91. package/dist/consumer-schema.d.ts.map +0 -1
  92. package/dist/consumer.d.ts.map +0 -1
  93. package/dist/cross-project-types.d.ts.map +0 -1
  94. package/dist/db.d.ts.map +0 -1
  95. package/dist/index.d.ts.map +0 -1
  96. package/dist/manifests.d.ts.map +0 -1
  97. package/dist/mcp/http.d.ts.map +0 -1
  98. package/dist/mcp/index.d.ts.map +0 -1
  99. package/dist/mcp/server.d.ts.map +0 -1
  100. package/dist/paths.d.ts.map +0 -1
  101. package/dist/pg-migrations.d.ts.map +0 -1
  102. package/dist/redaction.d.ts.map +0 -1
  103. package/dist/remote-storage.d.ts.map +0 -1
  104. package/dist/remote.d.ts.map +0 -1
  105. package/dist/storage-sync.d.ts.map +0 -1
  106. package/dist/storage.d.ts.map +0 -1
  107. package/dist/topology.d.ts.map +0 -1
  108. package/dist/types.d.ts.map +0 -1
  109. package/dist/version.d.ts.map +0 -1
package/dist/mcp/index.js CHANGED
@@ -37,6 +37,7 @@ function getPackageVersion() {
37
37
  }
38
38
 
39
39
  // src/mcp/http.ts
40
+ import { timingSafeEqual as timingSafeEqual2 } from "crypto";
40
41
  import { createServer } from "http";
41
42
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
42
43
 
@@ -4459,9 +4460,9 @@ function detectCurrentMachineManifest() {
4459
4460
  };
4460
4461
  }
4461
4462
 
4462
- // src/remote.ts
4463
- import { spawnSync as spawnSync2 } from "child_process";
4464
- import { hostname as hostname5 } from "os";
4463
+ // src/commands/mutation-approval.ts
4464
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from "crypto";
4465
+ import { resolve as resolve2 } from "path";
4465
4466
 
4466
4467
  // src/db.ts
4467
4468
  import { Database } from "bun:sqlite";
@@ -4470,6 +4471,7 @@ class SqliteAdapter {
4470
4471
  raw;
4471
4472
  constructor(path) {
4472
4473
  this.raw = new Database(path);
4474
+ this.raw.exec("PRAGMA busy_timeout = 5000");
4473
4475
  }
4474
4476
  close() {
4475
4477
  this.raw.close();
@@ -4535,6 +4537,23 @@ function createTables(db) {
4535
4537
  updated_at TEXT NOT NULL
4536
4538
  )
4537
4539
  `);
4540
+ db.exec(`
4541
+ CREATE TABLE IF NOT EXISTS mutation_approval_nonces (
4542
+ nonce_sha256 TEXT PRIMARY KEY,
4543
+ token_sha256 TEXT NOT NULL,
4544
+ surface TEXT NOT NULL,
4545
+ operation TEXT NOT NULL,
4546
+ caller_id TEXT NOT NULL,
4547
+ run_id TEXT NOT NULL,
4548
+ transport TEXT NOT NULL,
4549
+ expires_at INTEGER NOT NULL,
4550
+ used_at INTEGER NOT NULL
4551
+ )
4552
+ `);
4553
+ db.exec(`
4554
+ CREATE INDEX IF NOT EXISTS mutation_approval_nonces_expires_at_idx
4555
+ ON mutation_approval_nonces (expires_at)
4556
+ `);
4538
4557
  }
4539
4558
  function migrateAgentHeartbeats(db) {
4540
4559
  const columns = db.query("PRAGMA table_info(agent_heartbeats)").all();
@@ -4609,6 +4628,266 @@ function recordSyncRun(machineId, status, actions) {
4609
4628
  VALUES (?, ?, ?, ?, ?, ?)`).run(crypto.randomUUID(), machineId, status, JSON.stringify(actions), now, now);
4610
4629
  }
4611
4630
 
4631
+ // src/commands/mutation-approval.ts
4632
+ var MUTATION_APPROVAL_FLAG_ENV = "HASNA_MACHINES_ALLOW_MUTATIONS";
4633
+ var LEGACY_MUTATION_APPROVAL_FLAG_ENV = "HASNA_MACHINES_MUTATION_APPROVAL";
4634
+ var MUTATION_APPROVAL_TOKEN_ENV = "HASNA_MACHINES_MUTATION_TOKEN";
4635
+ var MUTATION_APPROVAL_REPLAY_PATH_ENV = "HASNA_MACHINES_MUTATION_REPLAY_PATH";
4636
+ var TOKEN_PREFIX = "machines-mut-v1";
4637
+ var DEFAULT_TOKEN_TTL_MS = 5 * 60 * 1000;
4638
+ var MAX_TOKEN_TTL_MS = 5 * 60 * 1000;
4639
+ var MAX_CLOCK_SKEW_MS = 30000;
4640
+ function isTruthy(value) {
4641
+ return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes";
4642
+ }
4643
+ function nowMs(now) {
4644
+ if (typeof now === "number")
4645
+ return now;
4646
+ if (now instanceof Date)
4647
+ return now.getTime();
4648
+ return Date.now();
4649
+ }
4650
+ function signingSecret(env, explicitSecret) {
4651
+ return explicitSecret?.trim() || env[MUTATION_APPROVAL_TOKEN_ENV]?.trim();
4652
+ }
4653
+ function hmac(payload, secret) {
4654
+ return createHmac("sha256", secret).update(payload).digest("base64url");
4655
+ }
4656
+ function sha256Hex(payload) {
4657
+ return createHash("sha256").update(payload).digest("hex");
4658
+ }
4659
+ function replayDbPath(env) {
4660
+ const configured = env[MUTATION_APPROVAL_REPLAY_PATH_ENV]?.trim();
4661
+ return configured ? resolve2(configured) : undefined;
4662
+ }
4663
+ function replayNonceKey(claims) {
4664
+ return sha256Hex(JSON.stringify({ nonce: claims.nonce }));
4665
+ }
4666
+ function recordReplayNonce(env, claims, tokenPayload, now) {
4667
+ const dbPath = replayDbPath(env);
4668
+ if (!dbPath)
4669
+ return;
4670
+ if (!claims.nonce) {
4671
+ return { approved: false, reason: "approval_token nonce claim is required for replay protection." };
4672
+ }
4673
+ try {
4674
+ const db = getDb(dbPath);
4675
+ db.query("DELETE FROM mutation_approval_nonces WHERE expires_at <= ?").run(now);
4676
+ const result = db.query(`
4677
+ INSERT OR IGNORE INTO mutation_approval_nonces (
4678
+ nonce_sha256,
4679
+ token_sha256,
4680
+ surface,
4681
+ operation,
4682
+ caller_id,
4683
+ run_id,
4684
+ transport,
4685
+ expires_at,
4686
+ used_at
4687
+ )
4688
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
4689
+ `).run(replayNonceKey(claims), sha256Hex(tokenPayload), claims.surface, claims.operation, claims.callerId ?? "", claims.runId ?? "", claims.transport ?? "", claims.expiresAt, now);
4690
+ if (result.changes !== 1) {
4691
+ return { approved: false, reason: "approval_token nonce has already been used." };
4692
+ }
4693
+ return;
4694
+ } catch (error) {
4695
+ const message = error instanceof Error ? error.message : String(error);
4696
+ return { approved: false, reason: `approval_token replay store is unavailable: ${message}` };
4697
+ }
4698
+ }
4699
+ function safeEqual(left, right) {
4700
+ const leftBuffer = Buffer.from(left);
4701
+ const rightBuffer = Buffer.from(right);
4702
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
4703
+ }
4704
+ function canonicalizeMutationArg(value, inArray = false) {
4705
+ if (value === undefined)
4706
+ return inArray ? null : undefined;
4707
+ if (value === null || typeof value === "boolean" || typeof value === "string")
4708
+ return value;
4709
+ if (typeof value === "number")
4710
+ return Number.isFinite(value) ? value : null;
4711
+ if (Array.isArray(value)) {
4712
+ return value.map((entry) => canonicalizeMutationArg(entry, true) ?? null);
4713
+ }
4714
+ if (value instanceof Date)
4715
+ return value.toISOString();
4716
+ if (typeof value === "object") {
4717
+ const result = {};
4718
+ for (const key of Object.keys(value).sort()) {
4719
+ if (key === "approval_token" || key === "approvalToken")
4720
+ continue;
4721
+ const canonicalValue = canonicalizeMutationArg(value[key]);
4722
+ if (canonicalValue !== undefined)
4723
+ result[key] = canonicalValue;
4724
+ }
4725
+ return result;
4726
+ }
4727
+ return inArray ? null : undefined;
4728
+ }
4729
+ function canonicalMutationArgs(value) {
4730
+ return JSON.stringify(canonicalizeMutationArg(value) ?? {});
4731
+ }
4732
+ function mutationArgsSha256(value) {
4733
+ return sha256Hex(canonicalMutationArgs(value));
4734
+ }
4735
+ function stripPlanRuntimeFields(value) {
4736
+ if (Array.isArray(value))
4737
+ return value.map(stripPlanRuntimeFields);
4738
+ if (value instanceof Date)
4739
+ return value;
4740
+ if (value && typeof value === "object") {
4741
+ const result = {};
4742
+ for (const [key, entry] of Object.entries(value)) {
4743
+ if (key === "planDigest" || key === "plan_digest" || key === "mode" || key === "executed")
4744
+ continue;
4745
+ result[key] = stripPlanRuntimeFields(entry);
4746
+ }
4747
+ return result;
4748
+ }
4749
+ return value;
4750
+ }
4751
+ function mutationPlanDigest(plan) {
4752
+ return mutationArgsSha256(stripPlanRuntimeFields(plan));
4753
+ }
4754
+ function attachMutationPlanDigest(plan) {
4755
+ return {
4756
+ ...plan,
4757
+ planDigest: mutationPlanDigest(plan)
4758
+ };
4759
+ }
4760
+ function assertMutationPlanDigest(plan, expectedPlanDigest) {
4761
+ if (expectedPlanDigest && mutationPlanDigest(plan) !== expectedPlanDigest) {
4762
+ throw new Error("Approved plan digest does not match the current execution plan.");
4763
+ }
4764
+ }
4765
+ function parseToken(token) {
4766
+ if (!token)
4767
+ return null;
4768
+ const parts = token.split(".");
4769
+ if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX)
4770
+ return null;
4771
+ try {
4772
+ const claims = JSON.parse(Buffer.from(parts[1] ?? "", "base64url").toString("utf8"));
4773
+ return { payload: parts[1] ?? "", signature: parts[2] ?? "", claims };
4774
+ } catch {
4775
+ return null;
4776
+ }
4777
+ }
4778
+ function claimMatches(expected, actual) {
4779
+ if (expected === undefined)
4780
+ return actual === undefined;
4781
+ return actual === expected;
4782
+ }
4783
+ function verifyMutationApprovalToken(options) {
4784
+ const env = options.env ?? process.env;
4785
+ const secret = signingSecret(env);
4786
+ if (!secret)
4787
+ return { approved: false, reason: `${MUTATION_APPROVAL_TOKEN_ENV} is not configured.` };
4788
+ const parsed = parseToken(options.approvalToken);
4789
+ if (!parsed)
4790
+ return { approved: false, reason: "approval_token is not a scoped mutation token." };
4791
+ if (!safeEqual(hmac(parsed.payload, secret), parsed.signature)) {
4792
+ return { approved: false, reason: "approval_token signature is invalid." };
4793
+ }
4794
+ const claims = parsed.claims;
4795
+ if (claims.version !== 1)
4796
+ return { approved: false, reason: "approval_token version is unsupported." };
4797
+ if (!claims.callerId || !claims.runId) {
4798
+ return { approved: false, reason: "approval_token must include caller and run claims." };
4799
+ }
4800
+ if (!claims.transport) {
4801
+ return { approved: false, reason: "approval_token must include a transport claim." };
4802
+ }
4803
+ if (!Number.isFinite(claims.expiresAt) || claims.expiresAt <= nowMs(options.now)) {
4804
+ return { approved: false, reason: "approval_token is expired." };
4805
+ }
4806
+ const now = nowMs(options.now);
4807
+ if (!Number.isFinite(claims.issuedAt) || claims.issuedAt > now + MAX_CLOCK_SKEW_MS) {
4808
+ return { approved: false, reason: "approval_token issue time is invalid." };
4809
+ }
4810
+ if (claims.expiresAt - claims.issuedAt > MAX_TOKEN_TTL_MS) {
4811
+ return { approved: false, reason: "approval_token TTL is too long." };
4812
+ }
4813
+ for (const key of ["surface", "operation", "machineId", "resourceId", "transport"]) {
4814
+ if (!claimMatches(options[key], claims[key])) {
4815
+ return { approved: false, reason: `approval_token ${key} claim does not match this mutation.` };
4816
+ }
4817
+ }
4818
+ for (const key of ["callerId", "runId"]) {
4819
+ if (options[key] !== undefined && options[key] !== claims[key]) {
4820
+ return { approved: false, reason: `approval_token ${key} claim does not match this mutation.` };
4821
+ }
4822
+ }
4823
+ const expectedArgsSha256 = options.argsSha256 || (options.args === undefined ? undefined : mutationArgsSha256(options.args));
4824
+ if (expectedArgsSha256 !== undefined && claims.args_sha256 !== expectedArgsSha256) {
4825
+ return { approved: false, reason: "approval_token args_sha256 claim does not match this mutation." };
4826
+ }
4827
+ const replayDecision = recordReplayNonce(env, claims, parsed.payload, now);
4828
+ if (replayDecision)
4829
+ return replayDecision;
4830
+ return { approved: true, claims };
4831
+ }
4832
+ function isMutationApproved(options = {}) {
4833
+ const env = options.env ?? process.env;
4834
+ const surface = options.surface ?? "cli";
4835
+ if (surface === "mcp") {
4836
+ if (!options.operation)
4837
+ return false;
4838
+ return verifyMutationApprovalToken({
4839
+ surface,
4840
+ operation: options.operation,
4841
+ machineId: options.machineId,
4842
+ resourceId: options.resourceId,
4843
+ callerId: options.callerId,
4844
+ runId: options.runId,
4845
+ transport: options.transport ?? "mcp",
4846
+ args: options.args,
4847
+ argsSha256: options.argsSha256,
4848
+ approvalToken: options.approvalToken,
4849
+ env,
4850
+ now: options.now
4851
+ }).approved;
4852
+ }
4853
+ if (options.approvalToken) {
4854
+ const decision = options.operation ? verifyMutationApprovalToken({
4855
+ surface,
4856
+ operation: options.operation,
4857
+ machineId: options.machineId,
4858
+ resourceId: options.resourceId,
4859
+ callerId: options.callerId,
4860
+ runId: options.runId,
4861
+ transport: options.transport ?? surface,
4862
+ args: options.args,
4863
+ argsSha256: options.argsSha256,
4864
+ approvalToken: options.approvalToken,
4865
+ env,
4866
+ now: options.now
4867
+ }) : { approved: false };
4868
+ if (decision.approved)
4869
+ return true;
4870
+ if (env[MUTATION_APPROVAL_TOKEN_ENV]?.trim())
4871
+ return false;
4872
+ }
4873
+ return isTruthy(env[MUTATION_APPROVAL_FLAG_ENV]) || isTruthy(env[LEGACY_MUTATION_APPROVAL_FLAG_ENV]);
4874
+ }
4875
+ function assertMutationApproved(options) {
4876
+ if (isMutationApproved(options)) {
4877
+ return;
4878
+ }
4879
+ const env = options.env ?? process.env;
4880
+ const tokenConfigured = Boolean(env[MUTATION_APPROVAL_TOKEN_ENV]?.trim());
4881
+ const approvalHint = options.surface === "mcp" ? `pass a scoped approval_token signed with ${MUTATION_APPROVAL_TOKEN_ENV}` : tokenConfigured ? `pass a scoped approval_token signed with ${MUTATION_APPROVAL_TOKEN_ENV} or set ${MUTATION_APPROVAL_FLAG_ENV}=1 for a trusted local session` : `set ${MUTATION_APPROVAL_FLAG_ENV}=1 for a trusted local session or configure ${MUTATION_APPROVAL_TOKEN_ENV}`;
4882
+ throw new Error(`Fleet mutation blocked: ${options.surface}.${options.operation} requires operator approval; ${approvalHint}.`);
4883
+ }
4884
+
4885
+ // src/remote.ts
4886
+ import { spawnSync as spawnSync2 } from "child_process";
4887
+ import { existsSync as existsSync5, mkdtempSync, readFileSync as readFileSync3, rmSync } from "fs";
4888
+ import { hostname as hostname5, tmpdir } from "os";
4889
+ import { join as join4 } from "path";
4890
+
4612
4891
  // src/topology.ts
4613
4892
  import { existsSync as existsSync4 } from "fs";
4614
4893
  import { arch as arch2, hostname as hostname4, platform as platform2, userInfo as userInfo2 } from "os";
@@ -5579,6 +5858,16 @@ function resolveMachineWorkspace(options) {
5579
5858
  function shellQuote2(value) {
5580
5859
  return `'${value.replace(/'/g, "'\\''")}'`;
5581
5860
  }
5861
+ function validateSshTarget(target) {
5862
+ const trimmed = target.trim();
5863
+ if (!trimmed || trimmed.startsWith("-") || /[\s"'`$\\;&|<>()[\]{}]/.test(trimmed)) {
5864
+ throw new Error(`Unsafe SSH target: ${target}`);
5865
+ }
5866
+ if (!/^(?:[A-Za-z0-9._%+-]+@)?[A-Za-z0-9._:-]+$/.test(trimmed)) {
5867
+ throw new Error(`Unsafe SSH target: ${target}`);
5868
+ }
5869
+ return trimmed;
5870
+ }
5582
5871
  function resolveSshTarget(machineId, options = {}) {
5583
5872
  const resolved = resolveMachineRoute(machineId, options);
5584
5873
  if (!resolved.ok || !resolved.target) {
@@ -5589,15 +5878,25 @@ function resolveSshTarget(machineId, options = {}) {
5589
5878
  }
5590
5879
  return {
5591
5880
  machineId: resolved.machine_id ?? machineId,
5592
- target: resolved.command_target ?? resolved.target,
5881
+ target: validateSshTarget(resolved.command_target ?? resolved.target),
5593
5882
  route: resolved.route,
5594
5883
  confidence: resolved.confidence,
5595
5884
  warnings: resolved.warnings
5596
5885
  };
5597
5886
  }
5598
5887
  function buildSshCommand(machineId, remoteCommand, options = {}) {
5888
+ return buildSshCommandPlan(machineId, remoteCommand, options).shellCommand;
5889
+ }
5890
+ function buildSshCommandPlan(machineId, remoteCommand, options = {}) {
5599
5891
  const resolved = resolveSshTarget(machineId, options);
5600
- return remoteCommand ? `ssh ${resolved.target} ${shellQuote2(remoteCommand)}` : `ssh ${resolved.target}`;
5892
+ const args = remoteCommand ? [resolved.target, remoteCommand] : [resolved.target];
5893
+ const shellCommand2 = `ssh ${args.map(shellQuote2).join(" ")}`;
5894
+ return {
5895
+ ...resolved,
5896
+ command: "ssh",
5897
+ args,
5898
+ shellCommand: shellCommand2
5899
+ };
5601
5900
  }
5602
5901
 
5603
5902
  // src/remote.ts
@@ -5609,35 +5908,233 @@ function machineIsLocal(machineId, localMachineId) {
5609
5908
  }
5610
5909
  function resolveMachineCommand(machineId, command, localMachineId = getLocalMachineId()) {
5611
5910
  if (machineIsLocal(machineId, localMachineId)) {
5612
- return { source: "local", shellCommand: command };
5911
+ return { source: "local", command: "bash", args: ["-c", command], shellCommand: command, usesShell: true };
5613
5912
  }
5614
5913
  try {
5914
+ const plan = buildSshCommandPlan(machineId, command);
5615
5915
  return {
5616
- source: resolveSshTarget(machineId).route,
5617
- shellCommand: buildSshCommand(machineId, command)
5916
+ source: plan.route,
5917
+ command: plan.command,
5918
+ args: plan.args,
5919
+ shellCommand: plan.shellCommand,
5920
+ usesShell: false
5618
5921
  };
5619
5922
  } catch (error) {
5620
5923
  const message = String(error.message ?? error);
5621
5924
  if (message.includes("Machine route not found") || message.includes("Machine not found in manifest")) {
5622
- return { source: "ssh", shellCommand: `ssh ${shellQuote3(machineId)} ${shellQuote3(command)}` };
5925
+ const target = validateSshTarget(machineId);
5926
+ return {
5927
+ source: "ssh",
5928
+ command: "ssh",
5929
+ args: [target, command],
5930
+ shellCommand: `ssh ${shellQuote3(target)} ${shellQuote3(command)}`,
5931
+ usesShell: false
5932
+ };
5623
5933
  }
5624
5934
  throw error;
5625
5935
  }
5626
5936
  }
5627
- function runMachineCommand(machineId, command) {
5937
+ function runMachineCommand(machineId, command, options = {}) {
5628
5938
  const resolved = resolveMachineCommand(machineId, command);
5629
- const result = spawnSync2("bash", ["-c", resolved.shellCommand], {
5939
+ if (options.timeoutMs && options.timeoutMs > 0 && process.platform !== "win32") {
5940
+ return runMachineCommandWithProcessGroupTimeout(machineId, resolved, options);
5941
+ }
5942
+ const result = spawnSync2(resolved.command, resolved.args, {
5630
5943
  encoding: "utf8",
5631
- env: process.env
5944
+ env: process.env,
5945
+ timeout: options.timeoutMs,
5946
+ killSignal: "SIGTERM"
5632
5947
  });
5948
+ const timedOut = Boolean(result.error && "code" in result.error && result.error.code === "ETIMEDOUT");
5949
+ const timeoutMessage = timedOut ? `Command timed out after ${options.timeoutMs}ms.` : "";
5950
+ const stderr = [result.stderr || "", timeoutMessage].filter(Boolean).join(result.stderr ? `
5951
+ ` : "");
5633
5952
  return {
5634
5953
  machineId,
5635
5954
  source: resolved.source,
5636
5955
  stdout: result.stdout || "",
5637
- stderr: result.stderr || "",
5638
- exitCode: result.status ?? 1
5956
+ stderr,
5957
+ exitCode: timedOut ? 124 : result.status ?? 1,
5958
+ timedOut,
5959
+ signal: result.signal
5639
5960
  };
5640
5961
  }
5962
+ function runMachineCommandWithProcessGroupTimeout(machineId, resolved, options) {
5963
+ const timeoutMs = Math.max(1, options.timeoutMs ?? 1);
5964
+ const killGraceMs = Math.max(1, options.killGraceMs ?? 1000);
5965
+ const helperDir = mkdtempSync(join4(tmpdir(), "machines-timeout-helper-"));
5966
+ const pgidFile = join4(helperDir, "pgid");
5967
+ const helper = spawnSync2(process.execPath, ["--eval", PROCESS_GROUP_TIMEOUT_HELPER], {
5968
+ input: JSON.stringify({ command: resolved.command, args: resolved.args }),
5969
+ encoding: "utf8",
5970
+ env: {
5971
+ ...process.env,
5972
+ HASNA_MACHINES_COMMAND_TIMEOUT_MS: String(timeoutMs),
5973
+ HASNA_MACHINES_COMMAND_KILL_GRACE_MS: String(killGraceMs),
5974
+ HASNA_MACHINES_COMMAND_PGID_FILE: pgidFile
5975
+ },
5976
+ timeout: timeoutMs + killGraceMs + 2000,
5977
+ killSignal: "SIGKILL",
5978
+ maxBuffer: 64 * 1024 * 1024
5979
+ });
5980
+ try {
5981
+ const parsed = parseHelperResult(helper.stdout);
5982
+ if (parsed) {
5983
+ return {
5984
+ machineId,
5985
+ source: resolved.source,
5986
+ stdout: parsed.stdout,
5987
+ stderr: parsed.stderr,
5988
+ exitCode: parsed.exitCode,
5989
+ timedOut: parsed.timedOut,
5990
+ signal: parsed.signal
5991
+ };
5992
+ }
5993
+ const helperTimedOut = Boolean(helper.error && "code" in helper.error && helper.error.code === "ETIMEDOUT");
5994
+ if (helperTimedOut)
5995
+ killPublishedProcessGroup(pgidFile);
5996
+ const timeoutMessage = helperTimedOut ? `Command timed out after ${timeoutMs}ms; timeout helper exceeded cleanup grace ${killGraceMs}ms.` : "";
5997
+ const stderr = [helper.stderr || "", timeoutMessage].filter(Boolean).join(helper.stderr ? `
5998
+ ` : "");
5999
+ return {
6000
+ machineId,
6001
+ source: resolved.source,
6002
+ stdout: "",
6003
+ stderr,
6004
+ exitCode: helperTimedOut ? 124 : helper.status ?? 1,
6005
+ timedOut: helperTimedOut,
6006
+ signal: helper.signal
6007
+ };
6008
+ } finally {
6009
+ rmSync(helperDir, { recursive: true, force: true });
6010
+ }
6011
+ }
6012
+ function killPublishedProcessGroup(pgidFile) {
6013
+ if (!existsSync5(pgidFile))
6014
+ return;
6015
+ try {
6016
+ const pid = Number.parseInt(readFileSync3(pgidFile, "utf8").trim(), 10);
6017
+ if (!Number.isInteger(pid) || pid <= 1)
6018
+ return;
6019
+ process.kill(-pid, "SIGKILL");
6020
+ } catch {}
6021
+ }
6022
+ function parseHelperResult(stdout) {
6023
+ if (!stdout)
6024
+ return null;
6025
+ try {
6026
+ const parsed = JSON.parse(stdout);
6027
+ if (typeof parsed.stdout !== "string" || typeof parsed.stderr !== "string" || typeof parsed.exitCode !== "number")
6028
+ return null;
6029
+ return {
6030
+ machineId: "",
6031
+ source: "local",
6032
+ stdout: parsed.stdout,
6033
+ stderr: parsed.stderr,
6034
+ exitCode: parsed.exitCode,
6035
+ timedOut: parsed.timedOut === true,
6036
+ signal: typeof parsed.signal === "string" ? parsed.signal : null
6037
+ };
6038
+ } catch {
6039
+ return null;
6040
+ }
6041
+ }
6042
+ var PROCESS_GROUP_TIMEOUT_HELPER = `
6043
+ const { spawn } = require("node:child_process");
6044
+ const { readFileSync, writeFileSync } = require("node:fs");
6045
+
6046
+ const plan = JSON.parse(readFileSync(0, "utf8"));
6047
+ const command = String(plan.command || "");
6048
+ const args = Array.isArray(plan.args) ? plan.args.map(String) : [];
6049
+ const timeoutMs = Math.max(1, Number.parseInt(process.env.HASNA_MACHINES_COMMAND_TIMEOUT_MS || "1", 10));
6050
+ const killGraceMs = Math.max(1, Number.parseInt(process.env.HASNA_MACHINES_COMMAND_KILL_GRACE_MS || "1000", 10));
6051
+ const pgidFile = process.env.HASNA_MACHINES_COMMAND_PGID_FILE || "";
6052
+ let stdout = "";
6053
+ let stderr = "";
6054
+ let timedOut = false;
6055
+ let finished = false;
6056
+ let timeoutTimer;
6057
+ let killTimer;
6058
+ let sigkillSent = false;
6059
+ let pendingExit = null;
6060
+
6061
+ const child = spawn(command, args, {
6062
+ detached: true,
6063
+ stdio: ["ignore", "pipe", "pipe"],
6064
+ env: process.env,
6065
+ });
6066
+
6067
+ if (pgidFile && child.pid) {
6068
+ try {
6069
+ writeFileSync(pgidFile, String(child.pid), { mode: 0o600 });
6070
+ } catch {}
6071
+ }
6072
+
6073
+ function appendText(target, chunk) {
6074
+ return target + String(chunk);
6075
+ }
6076
+
6077
+ function killTarget(signal) {
6078
+ if (!child.pid) return;
6079
+ if (process.platform === "win32") {
6080
+ try {
6081
+ process.kill(child.pid, signal);
6082
+ } catch {}
6083
+ return;
6084
+ }
6085
+ try {
6086
+ process.kill(-child.pid, signal);
6087
+ } catch {}
6088
+ }
6089
+
6090
+ function finish(code, signal) {
6091
+ if (finished) return;
6092
+ if (timedOut && !sigkillSent) {
6093
+ pendingExit = { code, signal };
6094
+ return;
6095
+ }
6096
+ finished = true;
6097
+ if (timeoutTimer) clearTimeout(timeoutTimer);
6098
+ if (killTimer) clearTimeout(killTimer);
6099
+ if (timedOut) {
6100
+ stderr = [stderr, "Command timed out after " + timeoutMs + "ms."].filter(Boolean).join(stderr ? "\\n" : "");
6101
+ }
6102
+ const exitCode = timedOut ? 124 : code ?? 1;
6103
+ process.stdout.write(JSON.stringify({
6104
+ stdout,
6105
+ stderr,
6106
+ exitCode,
6107
+ timedOut,
6108
+ signal: signal ?? null,
6109
+ }), () => process.exit(exitCode));
6110
+ }
6111
+
6112
+ child.stdout.setEncoding("utf8");
6113
+ child.stderr.setEncoding("utf8");
6114
+ child.stdout.on("data", (chunk) => { stdout = appendText(stdout, chunk); });
6115
+ child.stderr.on("data", (chunk) => { stderr = appendText(stderr, chunk); });
6116
+ let childExit = { code: null, signal: null };
6117
+ child.on("error", (error) => {
6118
+ stderr = [stderr, error instanceof Error ? error.message : String(error)].filter(Boolean).join(stderr ? "\\n" : "");
6119
+ finish(1, null);
6120
+ });
6121
+ child.on("exit", (code, signal) => {
6122
+ childExit = { code, signal };
6123
+ });
6124
+ child.on("close", (code, signal) => {
6125
+ finish(code ?? childExit.code, signal ?? childExit.signal);
6126
+ });
6127
+
6128
+ timeoutTimer = setTimeout(() => {
6129
+ timedOut = true;
6130
+ killTarget("SIGTERM");
6131
+ killTimer = setTimeout(() => {
6132
+ sigkillSent = true;
6133
+ killTarget("SIGKILL");
6134
+ if (pendingExit) finish(pendingExit.code, pendingExit.signal);
6135
+ }, killGraceMs);
6136
+ }, timeoutMs);
6137
+ `;
5641
6138
  function describeMachineCommandFailure(operation, result) {
5642
6139
  const detail = (result.stderr || result.stdout || "").trim();
5643
6140
  const suffix = detail ? `: ${detail}` : "";
@@ -5742,12 +6239,12 @@ function listApps(machineId) {
5742
6239
  }
5743
6240
  function buildAppsPlan(machineId) {
5744
6241
  const machine = resolveMachine(machineId);
5745
- return {
6242
+ return attachMutationPlanDigest({
5746
6243
  machineId: machine.id,
5747
6244
  mode: "plan",
5748
6245
  steps: buildAppSteps(machine),
5749
6246
  executed: 0
5750
- };
6247
+ });
5751
6248
  }
5752
6249
  function getAppsStatus(machineId, runner = runMachineCommand) {
5753
6250
  const machine = resolveMachine(machineId);
@@ -5770,10 +6267,10 @@ function diffApps(machineId, runner = runMachineCommand) {
5770
6267
  installed: status.apps.filter((app) => app.installed).map((app) => app.name)
5771
6268
  };
5772
6269
  }
5773
- function runAppsInstall(machineId, options = {}, runner = runMachineCommand) {
5774
- const plan = buildAppsPlan(machineId);
6270
+ function runAppsPlan(plan, options = {}, runner = runMachineCommand) {
6271
+ assertMutationPlanDigest(plan, options.expectedPlanDigest);
5775
6272
  if (!options.apply)
5776
- return plan;
6273
+ return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
5777
6274
  if (!options.yes) {
5778
6275
  throw new Error("App installation requires --yes.");
5779
6276
  }
@@ -5782,30 +6279,30 @@ function runAppsInstall(machineId, options = {}, runner = runMachineCommand) {
5782
6279
  requireMachineCommandSuccess(`App install ${step.id}`, runner(plan.machineId, step.command));
5783
6280
  executed += 1;
5784
6281
  }
5785
- return {
6282
+ return attachMutationPlanDigest({
5786
6283
  machineId: plan.machineId,
5787
6284
  mode: "apply",
5788
6285
  steps: plan.steps,
5789
6286
  executed
5790
- };
6287
+ });
5791
6288
  }
5792
6289
 
5793
6290
  // src/commands/cert.ts
5794
6291
  import { homedir as homedir3, platform as platform3 } from "os";
5795
- import { join as join4 } from "path";
6292
+ import { join as join5 } from "path";
5796
6293
  function quote2(value) {
5797
6294
  return `'${value.replace(/'/g, `'\\''`)}'`;
5798
6295
  }
5799
6296
  function certDir() {
5800
- return join4(homedir3(), ".hasna", "machines", "certs");
6297
+ return join5(homedir3(), ".hasna", "machines", "certs");
5801
6298
  }
5802
6299
  function buildCertPlan(domains) {
5803
6300
  if (domains.length === 0) {
5804
6301
  throw new Error("At least one domain is required.");
5805
6302
  }
5806
6303
  const primary = domains[0];
5807
- const certPath = join4(certDir(), `${primary}.pem`);
5808
- const keyPath = join4(certDir(), `${primary}-key.pem`);
6304
+ const certPath = join5(certDir(), `${primary}.pem`);
6305
+ const keyPath = join5(certDir(), `${primary}-key.pem`);
5809
6306
  const steps = [];
5810
6307
  if (platform3() === "darwin") {
5811
6308
  steps.push({
@@ -5869,16 +6366,16 @@ function runCertPlan(domains, options = {}) {
5869
6366
  }
5870
6367
 
5871
6368
  // src/commands/dns.ts
5872
- import { existsSync as existsSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
5873
- import { join as join5 } from "path";
6369
+ import { existsSync as existsSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
6370
+ import { join as join6 } from "path";
5874
6371
  function getDnsPath() {
5875
- return join5(getDataDir(), "dns.json");
6372
+ return join6(getDataDir(), "dns.json");
5876
6373
  }
5877
6374
  function readMappings() {
5878
6375
  const path = getDnsPath();
5879
- if (!existsSync5(path))
6376
+ if (!existsSync6(path))
5880
6377
  return [];
5881
- return JSON.parse(readFileSync3(path, "utf8"));
6378
+ return JSON.parse(readFileSync4(path, "utf8"));
5882
6379
  }
5883
6380
  function writeMappings(mappings) {
5884
6381
  const path = getDnsPath();
@@ -5905,10 +6402,10 @@ function renderDomainMapping(domain) {
5905
6402
  hostsEntry: `${entry.targetHost} ${entry.domain}`,
5906
6403
  caddySnippet: `${entry.domain} {
5907
6404
  reverse_proxy 127.0.0.1:${entry.port}
5908
- tls ${join5(getDataDir(), "certs", `${entry.domain}.pem`)} ${join5(getDataDir(), "certs", `${entry.domain}-key.pem`)}
6405
+ tls ${join6(getDataDir(), "certs", `${entry.domain}.pem`)} ${join6(getDataDir(), "certs", `${entry.domain}-key.pem`)}
5909
6406
  }`,
5910
- certPath: join5(getDataDir(), "certs", `${entry.domain}.pem`),
5911
- keyPath: join5(getDataDir(), "certs", `${entry.domain}-key.pem`)
6407
+ certPath: join6(getDataDir(), "certs", `${entry.domain}.pem`),
6408
+ keyPath: join6(getDataDir(), "certs", `${entry.domain}-key.pem`)
5912
6409
  };
5913
6410
  }
5914
6411
 
@@ -5954,7 +6451,7 @@ function diffMachines(leftMachineId, rightMachineId) {
5954
6451
  }
5955
6452
 
5956
6453
  // src/commands/daemon.ts
5957
- import { chmodSync, existsSync as existsSync6, readFileSync as readFileSync4, statSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
6454
+ import { chmodSync, existsSync as existsSync7, readFileSync as readFileSync5, statSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
5958
6455
  import { delimiter, dirname as dirname4 } from "path";
5959
6456
  import { platform as osPlatform } from "os";
5960
6457
  var DEFAULT_SERVICE_NAME = "machines-agent";
@@ -6243,7 +6740,7 @@ function bunRuntimeCandidates(executable) {
6243
6740
  }
6244
6741
  function isBunShebangScript(executable) {
6245
6742
  try {
6246
- const content = readFileSync4(executable, "utf8").slice(0, 256);
6743
+ const content = readFileSync5(executable, "utf8").slice(0, 256);
6247
6744
  const firstLine = content.split(/\r?\n/, 1)[0] ?? "";
6248
6745
  return /^#!.*\bbun\b/.test(firstLine);
6249
6746
  } catch {
@@ -6251,7 +6748,7 @@ function isBunShebangScript(executable) {
6251
6748
  }
6252
6749
  }
6253
6750
  function isExecutableFile(path) {
6254
- if (!existsSync6(path))
6751
+ if (!existsSync7(path))
6255
6752
  return false;
6256
6753
  try {
6257
6754
  const stats = statSync(path);
@@ -6541,12 +7038,12 @@ function parseProbe(tool, stdout) {
6541
7038
  }
6542
7039
  function buildClaudeInstallPlan(machineId, tools) {
6543
7040
  const machine = resolveMachine2(machineId);
6544
- return {
7041
+ return attachMutationPlanDigest({
6545
7042
  machineId: machine.id,
6546
7043
  mode: "plan",
6547
7044
  steps: buildInstallSteps(machine, tools),
6548
7045
  executed: 0
6549
- };
7046
+ });
6550
7047
  }
6551
7048
  function getClaudeCliStatus(machineId, tools, runner = runMachineCommand) {
6552
7049
  const machine = resolveMachine2(machineId);
@@ -6569,10 +7066,10 @@ function diffClaudeCli(machineId, tools, runner = runMachineCommand) {
6569
7066
  installed: status.tools.filter((tool) => tool.installed).map((tool) => tool.tool)
6570
7067
  };
6571
7068
  }
6572
- function runClaudeInstall(machineId, tools, options = {}, runner = runMachineCommand) {
6573
- const plan = buildClaudeInstallPlan(machineId, tools);
7069
+ function runClaudeInstallPlan(plan, options = {}, runner = runMachineCommand) {
7070
+ assertMutationPlanDigest(plan, options.expectedPlanDigest);
6574
7071
  if (!options.apply)
6575
- return plan;
7072
+ return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
6576
7073
  if (!options.yes) {
6577
7074
  throw new Error("Claude CLI installation requires --yes.");
6578
7075
  }
@@ -6581,12 +7078,12 @@ function runClaudeInstall(machineId, tools, options = {}, runner = runMachineCom
6581
7078
  requireMachineCommandSuccess(`AI CLI install ${step.id}`, runner(plan.machineId, step.command));
6582
7079
  executed += 1;
6583
7080
  }
6584
- return {
7081
+ return attachMutationPlanDigest({
6585
7082
  machineId: plan.machineId,
6586
7083
  mode: "apply",
6587
7084
  steps: plan.steps,
6588
7085
  executed
6589
- };
7086
+ });
6590
7087
  }
6591
7088
 
6592
7089
  // src/commands/install-tailscale.ts
@@ -6626,17 +7123,17 @@ function buildTailscaleInstallPlan(machineId) {
6626
7123
  if (!machine) {
6627
7124
  throw new Error(`Machine not found in manifest: ${machineId}`);
6628
7125
  }
6629
- return {
7126
+ return attachMutationPlanDigest({
6630
7127
  machineId: machine.id,
6631
7128
  mode: "plan",
6632
7129
  steps: buildInstallSteps2(machine),
6633
7130
  executed: 0
6634
- };
7131
+ });
6635
7132
  }
6636
- function runTailscaleInstall(machineId, options = {}, runner = runMachineCommand) {
6637
- const plan = buildTailscaleInstallPlan(machineId);
7133
+ function runTailscaleInstallPlan(plan, options = {}, runner = runMachineCommand) {
7134
+ assertMutationPlanDigest(plan, options.expectedPlanDigest);
6638
7135
  if (!options.apply)
6639
- return plan;
7136
+ return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
6640
7137
  if (!options.yes) {
6641
7138
  throw new Error("Tailscale install requires --yes.");
6642
7139
  }
@@ -6645,20 +7142,22 @@ function runTailscaleInstall(machineId, options = {}, runner = runMachineCommand
6645
7142
  requireMachineCommandSuccess(`Tailscale install ${step.id}`, runner(plan.machineId, step.command));
6646
7143
  executed += 1;
6647
7144
  }
6648
- return {
7145
+ return attachMutationPlanDigest({
6649
7146
  machineId: plan.machineId,
6650
7147
  mode: "apply",
6651
7148
  steps: plan.steps,
6652
7149
  executed
6653
- };
7150
+ });
6654
7151
  }
6655
7152
 
6656
7153
  // src/commands/notifications.ts
6657
- import { existsSync as existsSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
7154
+ import { accessSync, constants, existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
7155
+ import { delimiter as delimiter2, isAbsolute, join as join7 } from "path";
6658
7156
  var notificationChannelSchema = exports_external.object({
6659
7157
  id: exports_external.string(),
6660
7158
  type: exports_external.enum(["email", "webhook", "command"]),
6661
7159
  target: exports_external.string(),
7160
+ commandArgs: exports_external.array(exports_external.string()).optional(),
6662
7161
  events: exports_external.array(exports_external.string()),
6663
7162
  enabled: exports_external.boolean()
6664
7163
  });
@@ -6667,19 +7166,31 @@ var notificationConfigSchema = exports_external.object({
6667
7166
  updatedAt: exports_external.string().optional(),
6668
7167
  channels: exports_external.array(notificationChannelSchema)
6669
7168
  });
7169
+ var trustedNotificationApproval = Symbol("trustedNotificationApproval");
7170
+ function createTrustedNotificationApproval() {
7171
+ return { [trustedNotificationApproval]: true };
7172
+ }
7173
+ function isTrustedNotificationApproval(approval) {
7174
+ return approval?.[trustedNotificationApproval] === true;
7175
+ }
6670
7176
  function sortChannels(channels) {
6671
7177
  return [...channels].sort((left, right) => left.id.localeCompare(right.id));
6672
7178
  }
6673
- function shellQuote5(value) {
6674
- return `'${value.replace(/'/g, `'\\''`)}'`;
6675
- }
6676
7179
  function hasCommand2(binary) {
6677
- const result = Bun.spawnSync(["bash", "-lc", `command -v ${binary} >/dev/null 2>&1`], {
6678
- stdout: "ignore",
6679
- stderr: "ignore",
6680
- env: process.env
6681
- });
6682
- return result.exitCode === 0;
7180
+ return Boolean(resolveExecutable(binary));
7181
+ }
7182
+ function resolveExecutable(binary) {
7183
+ const trimmed = binary.trim();
7184
+ if (!trimmed)
7185
+ return null;
7186
+ const candidates = isAbsolute(trimmed) ? [trimmed] : (process.env.PATH ?? "").split(delimiter2).filter(Boolean).map((dir) => join7(dir, trimmed));
7187
+ for (const candidate of candidates) {
7188
+ try {
7189
+ accessSync(candidate, constants.X_OK);
7190
+ return candidate;
7191
+ } catch {}
7192
+ }
7193
+ return null;
6683
7194
  }
6684
7195
  function buildNotificationPreview(channel, event, message) {
6685
7196
  if (channel.type === "email") {
@@ -6688,7 +7199,8 @@ function buildNotificationPreview(channel, event, message) {
6688
7199
  if (channel.type === "webhook") {
6689
7200
  return `POST ${channel.target} with payload {"event":"${event}","message":"${message}"}`;
6690
7201
  }
6691
- return `${channel.target} --event ${event} --message ${JSON.stringify(message)}`;
7202
+ const args = channel.commandArgs?.length ? ` ${channel.commandArgs.join(" ")}` : "";
7203
+ return `${channel.target}${args} with HASNA_MACHINES_NOTIFICATION_* environment`;
6692
7204
  }
6693
7205
  async function dispatchEmail(channel, event, message) {
6694
7206
  const subject = `[${event}] machines notification`;
@@ -6699,7 +7211,7 @@ Content-Type: text/plain; charset=utf-8
6699
7211
  ${message}
6700
7212
  `;
6701
7213
  if (hasCommand2("sendmail")) {
6702
- const result = Bun.spawnSync(["bash", "-lc", "sendmail -t"], {
7214
+ const result = Bun.spawnSync(["sendmail", "-t"], {
6703
7215
  stdin: new TextEncoder().encode(body),
6704
7216
  stdout: "pipe",
6705
7217
  stderr: "pipe",
@@ -6717,8 +7229,9 @@ ${message}
6717
7229
  };
6718
7230
  }
6719
7231
  if (hasCommand2("mail")) {
6720
- const command2 = `printf %s ${shellQuote5(message)} | mail -s ${shellQuote5(subject)} ${shellQuote5(channel.target)}`;
6721
- const result = Bun.spawnSync(["bash", "-lc", command2], {
7232
+ const result = Bun.spawnSync(["mail", "-s", subject, channel.target], {
7233
+ stdin: new TextEncoder().encode(`${message}
7234
+ `),
6722
7235
  stdout: "pipe",
6723
7236
  stderr: "pipe",
6724
7237
  env: process.env
@@ -6761,8 +7274,20 @@ async function dispatchWebhook(channel, event, message) {
6761
7274
  detail: `Webhook accepted with HTTP ${response.status}`
6762
7275
  };
6763
7276
  }
6764
- async function dispatchCommand(channel, event, message) {
6765
- const result = Bun.spawnSync(["bash", "-lc", channel.target], {
7277
+ async function dispatchCommand(channel, event, message, options = {}) {
7278
+ if (!isTrustedNotificationApproval(options.trustedApproval)) {
7279
+ assertMutationApproved({
7280
+ surface: "notifications",
7281
+ operation: "dispatch_command",
7282
+ resourceId: channel.id,
7283
+ approvalToken: options.approvalToken
7284
+ });
7285
+ }
7286
+ const executable = resolveExecutable(channel.target);
7287
+ if (!executable) {
7288
+ throw new Error(`Command executable not found or not executable: ${channel.target}`);
7289
+ }
7290
+ const result = Bun.spawnSync([executable, ...channel.commandArgs ?? []], {
6766
7291
  stdout: "pipe",
6767
7292
  stderr: "pipe",
6768
7293
  env: {
@@ -6784,7 +7309,7 @@ async function dispatchCommand(channel, event, message) {
6784
7309
  detail: stdout || "Command completed successfully"
6785
7310
  };
6786
7311
  }
6787
- async function dispatchChannel(channel, event, message) {
7312
+ async function dispatchChannel(channel, event, message, options = {}) {
6788
7313
  if (!channel.enabled) {
6789
7314
  return {
6790
7315
  channelId: channel.id,
@@ -6800,7 +7325,7 @@ async function dispatchChannel(channel, event, message) {
6800
7325
  if (channel.type === "webhook") {
6801
7326
  return dispatchWebhook(channel, event, message);
6802
7327
  }
6803
- return dispatchCommand(channel, event, message);
7328
+ return dispatchCommand(channel, event, message, options);
6804
7329
  }
6805
7330
  function getDefaultNotificationConfig() {
6806
7331
  return {
@@ -6810,10 +7335,10 @@ function getDefaultNotificationConfig() {
6810
7335
  };
6811
7336
  }
6812
7337
  function readNotificationConfig(path = getNotificationsPath()) {
6813
- if (!existsSync7(path)) {
7338
+ if (!existsSync8(path)) {
6814
7339
  return getDefaultNotificationConfig();
6815
7340
  }
6816
- return notificationConfigSchema.parse(JSON.parse(readFileSync5(path, "utf8")));
7341
+ return notificationConfigSchema.parse(JSON.parse(readFileSync6(path, "utf8")));
6817
7342
  }
6818
7343
  function writeNotificationConfig(config, path = getNotificationsPath()) {
6819
7344
  ensureParentDir(path);
@@ -6829,11 +7354,20 @@ function writeNotificationConfig(config, path = getNotificationsPath()) {
6829
7354
  function listNotificationChannels() {
6830
7355
  return readNotificationConfig();
6831
7356
  }
6832
- function addNotificationChannel(channel) {
7357
+ function addNotificationChannel(channel, options = {}) {
7358
+ if (channel.type === "command" && !isTrustedNotificationApproval(options.trustedApproval)) {
7359
+ assertMutationApproved({
7360
+ surface: "notifications",
7361
+ operation: "add_command_channel",
7362
+ resourceId: channel.id,
7363
+ approvalToken: options.approvalToken
7364
+ });
7365
+ }
6833
7366
  const config = readNotificationConfig();
6834
7367
  const channels = config.channels.filter((entry) => entry.id !== channel.id);
6835
7368
  channels.push({
6836
7369
  ...channel,
7370
+ commandArgs: channel.commandArgs?.map(String),
6837
7371
  events: [...new Set(channel.events)]
6838
7372
  });
6839
7373
  return writeNotificationConfig({ ...config, channels });
@@ -6855,7 +7389,7 @@ async function dispatchNotificationEvent(event, message, options = {}) {
6855
7389
  const deliveries = [];
6856
7390
  for (const channel of channels) {
6857
7391
  try {
6858
- deliveries.push(await dispatchChannel(channel, event, message));
7392
+ deliveries.push(await dispatchChannel(channel, event, message, { approvalToken: options.approvalToken, trustedApproval: options.trustedApproval }));
6859
7393
  } catch (error) {
6860
7394
  deliveries.push({
6861
7395
  channelId: channel.id,
@@ -6890,7 +7424,7 @@ async function testNotificationChannel(channelId, event = "manual.test", message
6890
7424
  if (!options.yes) {
6891
7425
  throw new Error("Notification test execution requires --yes.");
6892
7426
  }
6893
- const delivery = await dispatchChannel(channel, event, message);
7427
+ const delivery = await dispatchChannel(channel, event, message, { approvalToken: options.approvalToken, trustedApproval: options.trustedApproval });
6894
7428
  return {
6895
7429
  channelId,
6896
7430
  mode: "apply",
@@ -6955,7 +7489,7 @@ function listPorts(machineId) {
6955
7489
  }
6956
7490
 
6957
7491
  // src/commands/serve.ts
6958
- import { EventsClient, sanitizeChannelsForOutput } from "@hasna/events";
7492
+ import { EventsClient, getEventsDataDir, sanitizeChannelsForOutput } from "@hasna/events";
6959
7493
 
6960
7494
  // src/agent/runtime.ts
6961
7495
  import { arch as arch3, hostname as hostname6, platform as platform4, release, uptime, version as osVersion } from "os";
@@ -7514,7 +8048,7 @@ function escapeHtml(value) {
7514
8048
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
7515
8049
  }
7516
8050
  function getServeInfo(options = {}) {
7517
- const host = options.host || "0.0.0.0";
8051
+ const host = options.host || "127.0.0.1";
7518
8052
  const port = options.port || 7676;
7519
8053
  return {
7520
8054
  host,
@@ -7811,17 +8345,17 @@ function buildSetupPlan(machineId) {
7811
8345
  workspacePath: `${homedir4()}/workspace`
7812
8346
  };
7813
8347
  const steps = [...buildBaseSteps(target), ...buildPackageSteps(target)];
7814
- return {
8348
+ return attachMutationPlanDigest({
7815
8349
  machineId: target.id,
7816
8350
  mode: "plan",
7817
8351
  steps,
7818
8352
  executed: 0
7819
- };
8353
+ });
7820
8354
  }
7821
- function runSetup(machineId, options = {}, runner = runMachineCommand) {
7822
- const plan = buildSetupPlan(machineId);
8355
+ function runSetupPlan(plan, options = {}, runner = runMachineCommand) {
8356
+ assertMutationPlanDigest(plan, options.expectedPlanDigest);
7823
8357
  if (!options.apply) {
7824
- return plan;
8358
+ return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
7825
8359
  }
7826
8360
  if (!options.yes) {
7827
8361
  throw new Error("Setup execution requires --yes.");
@@ -7842,18 +8376,18 @@ function runSetup(machineId, options = {}, runner = runMachineCommand) {
7842
8376
  }
7843
8377
  executed += 1;
7844
8378
  }
7845
- const summary = {
8379
+ const summary = attachMutationPlanDigest({
7846
8380
  machineId: plan.machineId,
7847
8381
  mode: "apply",
7848
8382
  steps: plan.steps,
7849
8383
  executed
7850
- };
8384
+ });
7851
8385
  recordSetupRun(plan.machineId, "completed", summary);
7852
8386
  return summary;
7853
8387
  }
7854
8388
 
7855
8389
  // src/commands/sync.ts
7856
- import { existsSync as existsSync8, lstatSync, readFileSync as readFileSync6, symlinkSync, copyFileSync } from "fs";
8390
+ import { existsSync as existsSync9, lstatSync, readFileSync as readFileSync7, symlinkSync, copyFileSync } from "fs";
7857
8391
  import { homedir as homedir5 } from "os";
7858
8392
  function quote4(value) {
7859
8393
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -7906,15 +8440,15 @@ function detectFileActions(machine) {
7906
8440
  throw new Error(`Remote file sync planning is not supported for ${machine.id}; refusing to inspect or apply local paths as remote state.`);
7907
8441
  }
7908
8442
  return (machine.files || []).map((file, index) => {
7909
- const sourceExists = existsSync8(file.source);
7910
- const targetExists = existsSync8(file.target);
8443
+ const sourceExists = existsSync9(file.source);
8444
+ const targetExists = existsSync9(file.target);
7911
8445
  let status = "missing";
7912
8446
  if (sourceExists && targetExists) {
7913
8447
  if (file.mode === "symlink") {
7914
8448
  status = lstatSync(file.target).isSymbolicLink() ? "ok" : "drifted";
7915
8449
  } else {
7916
- const source = readFileSync6(file.source, "utf8");
7917
- const target = readFileSync6(file.target, "utf8");
8450
+ const source = readFileSync7(file.source, "utf8");
8451
+ const target = readFileSync7(file.target, "utf8");
7918
8452
  status = source === target ? "ok" : "drifted";
7919
8453
  }
7920
8454
  }
@@ -7944,12 +8478,12 @@ function buildSyncPlan(machineId, runner = runMachineCommand) {
7944
8478
  ...detectPackageActions(target, runner),
7945
8479
  ...detectFileActions(target)
7946
8480
  ];
7947
- return {
8481
+ return attachMutationPlanDigest({
7948
8482
  machineId: target.id,
7949
8483
  mode: "plan",
7950
8484
  actions,
7951
8485
  executed: 0
7952
- };
8486
+ });
7953
8487
  }
7954
8488
  function applyFileAction(command2) {
7955
8489
  const [verb, source, target] = command2.split(" ");
@@ -7971,10 +8505,10 @@ function applyFileAction(command2) {
7971
8505
  symlinkSync(sourcePath, targetPath);
7972
8506
  }
7973
8507
  }
7974
- function runSync(machineId, options = {}, runner = runMachineCommand) {
7975
- const plan = buildSyncPlan(machineId, runner);
8508
+ function runSyncPlan(plan, options = {}, runner = runMachineCommand) {
8509
+ assertMutationPlanDigest(plan, options.expectedPlanDigest);
7976
8510
  if (!options.apply) {
7977
- return plan;
8511
+ return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
7978
8512
  }
7979
8513
  if (!options.yes) {
7980
8514
  throw new Error("Sync execution requires --yes.");
@@ -8001,12 +8535,12 @@ function runSync(machineId, options = {}, runner = runMachineCommand) {
8001
8535
  }
8002
8536
  executed += 1;
8003
8537
  }
8004
- const summary = {
8538
+ const summary = attachMutationPlanDigest({
8005
8539
  machineId: plan.machineId,
8006
8540
  mode: "apply",
8007
8541
  actions: plan.actions,
8008
8542
  executed
8009
- };
8543
+ });
8010
8544
  recordSyncRun(plan.machineId, "completed", summary);
8011
8545
  return summary;
8012
8546
  }
@@ -8019,7 +8553,7 @@ var DEFAULT_COMMANDS = [
8019
8553
  function defaultPackages() {
8020
8554
  return [{ name: "@hasna/machines", command: "machines", expectedVersion: getPackageVersion(), required: true }];
8021
8555
  }
8022
- function shellQuote6(value) {
8556
+ function shellQuote5(value) {
8023
8557
  return `'${value.replace(/'/g, "'\\''")}'`;
8024
8558
  }
8025
8559
  function commandId(value) {
@@ -8070,7 +8604,7 @@ function defaultRunner2(machineId, command2) {
8070
8604
  return runMachineCommand(machineId, command2);
8071
8605
  }
8072
8606
  function inspectCommand(machineId, spec, runner) {
8073
- const command2 = shellQuote6(spec.command);
8607
+ const command2 = shellQuote5(spec.command);
8074
8608
  const versionArgs = spec.versionArgs ?? "--version";
8075
8609
  const script = [
8076
8610
  `cmd=${command2}`,
@@ -8099,7 +8633,7 @@ function fieldCommand(field) {
8099
8633
  }
8100
8634
  function inspectWorkspace(machineId, spec, runner) {
8101
8635
  const script = [
8102
- `path=${shellQuote6(spec.path)}`,
8636
+ `path=${shellQuote5(spec.path)}`,
8103
8637
  'printf "exists=%s\\n" "$(test -d "$path" && printf yes || printf no)"',
8104
8638
  'pkg="$path/package.json"',
8105
8639
  'printf "package_json=%s\\n" "$(test -f "$pkg" && printf yes || printf no)"',
@@ -8252,8 +8786,8 @@ function checkMachineCompatibility(options = {}) {
8252
8786
  };
8253
8787
  }
8254
8788
  // src/mcp/server.ts
8255
- function buildServer(version = getPackageVersion()) {
8256
- return createMcpServer(version);
8789
+ function buildServer(version = getPackageVersion(), options = {}) {
8790
+ return createMcpServer(version, options);
8257
8791
  }
8258
8792
  function privateMetadataAllowed(requested) {
8259
8793
  return requested === true && isPrivateOutputEnabled();
@@ -8267,9 +8801,50 @@ function appendWarnings(payload, warnings) {
8267
8801
  const currentWarnings = typeof payload === "object" && payload && "warnings" in payload && Array.isArray(payload.warnings) ? payload.warnings : [];
8268
8802
  return { ...payload, warnings: [...currentWarnings, ...warnings] };
8269
8803
  }
8270
- function createMcpServer(version) {
8804
+ var approvalTokenSchema = exports_external.string().optional().describe("Operator mutation approval token");
8805
+ function mutationMachineId(machineId) {
8806
+ return machineId?.trim() || "local";
8807
+ }
8808
+ function mutationResourceId(kind, ...parts) {
8809
+ const values = parts.map((part) => String(part ?? "*").trim()).filter(Boolean).join(":");
8810
+ return values ? `${kind}:${values}` : kind;
8811
+ }
8812
+ function mutationCallerId() {
8813
+ return process.env["HASNA_MACHINES_MUTATION_CALLER_ID"]?.trim() || "mcp";
8814
+ }
8815
+ function mutationRunId() {
8816
+ return process.env["HASNA_MACHINES_MUTATION_RUN_ID"]?.trim() || "mcp";
8817
+ }
8818
+ function assertScopedMcpMutation(operation, approvalToken, scope = {}, transport) {
8819
+ assertMutationApproved({
8820
+ surface: "mcp",
8821
+ operation,
8822
+ transport,
8823
+ callerId: mutationCallerId(),
8824
+ runId: mutationRunId(),
8825
+ machineId: scope.machineId === undefined ? undefined : mutationMachineId(scope.machineId),
8826
+ resourceId: scope.resourceId === undefined || scope.resourceId === null ? undefined : scope.resourceId,
8827
+ args: scope.args,
8828
+ approvalToken
8829
+ });
8830
+ }
8831
+ function mcpPlanApprovalArgs(args, plan) {
8832
+ return {
8833
+ ...args,
8834
+ plan_digest: mutationPlanDigest(plan)
8835
+ };
8836
+ }
8837
+ function mcpPlanResourceId(operation, machineId, plan) {
8838
+ return mutationResourceId("plan", operation, machineId, mutationPlanDigest(plan));
8839
+ }
8840
+ function createMcpServer(version, options = {}) {
8271
8841
  const server = new McpServer({ name: "machines", version });
8272
8842
  const events = new EventsClient2;
8843
+ const trustedNotificationApproval2 = createTrustedNotificationApproval();
8844
+ const mutationTransport = options.mutationTransport ?? "mcp:stdio";
8845
+ function requireMcpMutation(operation, approvalToken, scope = {}) {
8846
+ assertScopedMcpMutation(operation, approvalToken, scope, mutationTransport);
8847
+ }
8273
8848
  server.tool("machines_status", "Return local machine fleet status paths and machine identity.", { private_metadata: exports_external.boolean().optional().describe("Include private local paths and machine identifiers") }, async ({ private_metadata }) => {
8274
8849
  const privateMetadata = privateMetadataAllowed(private_metadata);
8275
8850
  const warnings = privateOutputWarnings(private_metadata, privateMetadata);
@@ -8283,18 +8858,31 @@ function createMcpServer(version) {
8283
8858
  server.tool("machines_apps_status", "Check installed state for manifest-managed apps.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(getAppsStatus(machine_id), null, 2) }] }));
8284
8859
  server.tool("machines_apps_diff", "Show missing and installed manifest-managed apps.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(diffApps(machine_id), null, 2) }] }));
8285
8860
  server.tool("machines_apps_plan", "Preview app install steps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(buildAppsPlan(machine_id), null, 2) }] }));
8286
- server.tool("machines_apps_apply", "Install manifest-managed apps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ machine_id, yes }) => ({ content: [{ type: "text", text: JSON.stringify(runAppsInstall(machine_id, { apply: true, yes }), null, 2) }] }));
8861
+ server.tool("machines_apps_apply", "Install manifest-managed apps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution"), approval_token: approvalTokenSchema }, async ({ machine_id, yes, approval_token }) => {
8862
+ const resolvedMachineId = mutationMachineId(machine_id);
8863
+ const plan = buildAppsPlan(machine_id);
8864
+ requireMcpMutation("machines_apps_apply", approval_token, {
8865
+ machineId: resolvedMachineId,
8866
+ resourceId: mcpPlanResourceId("machines_apps_apply", resolvedMachineId, plan),
8867
+ args: mcpPlanApprovalArgs({ machine_id: resolvedMachineId, yes }, plan)
8868
+ });
8869
+ return { content: [{ type: "text", text: JSON.stringify(runAppsPlan(plan, { apply: true, yes }), null, 2) }] };
8870
+ });
8287
8871
  server.tool("machines_manifest", "Read the current fleet manifest.", {}, async () => ({
8288
8872
  content: [{ type: "text", text: JSON.stringify(manifestList(), null, 2) }]
8289
8873
  }));
8290
8874
  server.tool("machines_manifest_validate", "Validate the current fleet manifest.", {}, async () => ({
8291
8875
  content: [{ type: "text", text: JSON.stringify(manifestValidate(), null, 2) }]
8292
8876
  }));
8293
- server.tool("machines_manifest_bootstrap", "Detect and upsert the current machine into the fleet manifest.", {}, async () => ({
8294
- content: [{ type: "text", text: JSON.stringify(manifestBootstrapCurrentMachine(), null, 2) }]
8295
- }));
8877
+ server.tool("machines_manifest_bootstrap", "Detect and upsert the current machine into the fleet manifest.", { approval_token: approvalTokenSchema }, async ({ approval_token }) => {
8878
+ requireMcpMutation("machines_manifest_bootstrap", approval_token, { resourceId: "manifest:bootstrap", args: {} });
8879
+ return { content: [{ type: "text", text: JSON.stringify(manifestBootstrapCurrentMachine(), null, 2) }] };
8880
+ });
8296
8881
  server.tool("machines_manifest_get", "Read a single machine from the fleet manifest.", { machine_id: exports_external.string().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(manifestGet(machine_id), null, 2) }] }));
8297
- server.tool("machines_manifest_remove", "Remove a single machine from the fleet manifest.", { machine_id: exports_external.string().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(manifestRemove(machine_id), null, 2) }] }));
8882
+ server.tool("machines_manifest_remove", "Remove a single machine from the fleet manifest.", { machine_id: exports_external.string().describe("Machine identifier"), approval_token: approvalTokenSchema }, async ({ machine_id, approval_token }) => {
8883
+ requireMcpMutation("machines_manifest_remove", approval_token, { machineId: machine_id, args: { machine_id } });
8884
+ return { content: [{ type: "text", text: JSON.stringify(manifestRemove(machine_id), null, 2) }] };
8885
+ });
8298
8886
  server.tool("machines_agent_status", "List current machine agent heartbeats.", { private_metadata: exports_external.boolean().optional().describe("Include private heartbeat metadata") }, async ({ private_metadata }) => {
8299
8887
  const privateMetadata = privateMetadataAllowed(private_metadata);
8300
8888
  const warnings = privateOutputWarnings(private_metadata, privateMetadata);
@@ -8346,9 +8934,27 @@ function createMcpServer(version) {
8346
8934
  }]
8347
8935
  }));
8348
8936
  server.tool("machines_setup_preview", "Preview setup actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(buildSetupPlan(machine_id), null, 2) }] }));
8349
- server.tool("machines_setup_apply", "Execute setup actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ machine_id, yes }) => ({ content: [{ type: "text", text: JSON.stringify(runSetup(machine_id, { apply: true, yes }), null, 2) }] }));
8937
+ server.tool("machines_setup_apply", "Execute setup actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution"), approval_token: approvalTokenSchema }, async ({ machine_id, yes, approval_token }) => {
8938
+ const resolvedMachineId = mutationMachineId(machine_id);
8939
+ const plan = buildSetupPlan(machine_id);
8940
+ requireMcpMutation("machines_setup_apply", approval_token, {
8941
+ machineId: resolvedMachineId,
8942
+ resourceId: mcpPlanResourceId("machines_setup_apply", resolvedMachineId, plan),
8943
+ args: mcpPlanApprovalArgs({ machine_id: resolvedMachineId, yes }, plan)
8944
+ });
8945
+ return { content: [{ type: "text", text: JSON.stringify(runSetupPlan(plan, { apply: true, yes }), null, 2) }] };
8946
+ });
8350
8947
  server.tool("machines_sync_preview", "Preview sync actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(buildSyncPlan(machine_id), null, 2) }] }));
8351
- server.tool("machines_sync_apply", "Execute sync actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ machine_id, yes }) => ({ content: [{ type: "text", text: JSON.stringify(runSync(machine_id, { apply: true, yes }), null, 2) }] }));
8948
+ server.tool("machines_sync_apply", "Execute sync actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution"), approval_token: approvalTokenSchema }, async ({ machine_id, yes, approval_token }) => {
8949
+ const resolvedMachineId = mutationMachineId(machine_id);
8950
+ const plan = buildSyncPlan(machine_id);
8951
+ requireMcpMutation("machines_sync_apply", approval_token, {
8952
+ machineId: resolvedMachineId,
8953
+ resourceId: mcpPlanResourceId("machines_sync_apply", resolvedMachineId, plan),
8954
+ args: mcpPlanApprovalArgs({ machine_id: resolvedMachineId, yes }, plan)
8955
+ });
8956
+ return { content: [{ type: "text", text: JSON.stringify(runSyncPlan(plan, { apply: true, yes }), null, 2) }] };
8957
+ });
8352
8958
  server.tool("machines_topology", "Discover local, manifest, heartbeat, SSH, and Tailscale machine topology.", {
8353
8959
  include_tailscale: exports_external.boolean().optional().describe("Whether to probe tailscale status --json"),
8354
8960
  private_metadata: exports_external.boolean().optional().describe("Include private host/network route fields")
@@ -8403,12 +9009,31 @@ function createMcpServer(version) {
8403
9009
  server.tool("machines_install_claude_apply", "Execute Claude, Codex, and Gemini CLI install steps for a machine.", {
8404
9010
  machine_id: exports_external.string().optional().describe("Machine identifier"),
8405
9011
  tools: exports_external.array(exports_external.enum(["claude", "codex", "gemini"])).optional().describe("AI CLIs to install"),
8406
- yes: exports_external.boolean().describe("Confirmation flag for execution")
8407
- }, async ({ machine_id, tools, yes }) => ({
8408
- content: [{ type: "text", text: JSON.stringify(runClaudeInstall(machine_id, tools, { apply: true, yes }), null, 2) }]
8409
- }));
9012
+ yes: exports_external.boolean().describe("Confirmation flag for execution"),
9013
+ approval_token: approvalTokenSchema
9014
+ }, async ({ machine_id, tools, yes, approval_token }) => {
9015
+ const resolvedMachineId = mutationMachineId(machine_id);
9016
+ const plan = buildClaudeInstallPlan(machine_id, tools);
9017
+ requireMcpMutation("machines_install_claude_apply", approval_token, {
9018
+ machineId: resolvedMachineId,
9019
+ resourceId: mcpPlanResourceId("machines_install_claude_apply", resolvedMachineId, plan),
9020
+ args: mcpPlanApprovalArgs({ machine_id: resolvedMachineId, tools, yes }, plan)
9021
+ });
9022
+ return {
9023
+ content: [{ type: "text", text: JSON.stringify(runClaudeInstallPlan(plan, { apply: true, yes }), null, 2) }]
9024
+ };
9025
+ });
8410
9026
  server.tool("machines_install_tailscale_preview", "Preview Tailscale install steps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(buildTailscaleInstallPlan(machine_id), null, 2) }] }));
8411
- server.tool("machines_install_tailscale_apply", "Execute Tailscale install steps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ machine_id, yes }) => ({ content: [{ type: "text", text: JSON.stringify(runTailscaleInstall(machine_id, { apply: true, yes }), null, 2) }] }));
9027
+ server.tool("machines_install_tailscale_apply", "Execute Tailscale install steps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution"), approval_token: approvalTokenSchema }, async ({ machine_id, yes, approval_token }) => {
9028
+ const resolvedMachineId = mutationMachineId(machine_id);
9029
+ const plan = buildTailscaleInstallPlan(machine_id);
9030
+ requireMcpMutation("machines_install_tailscale_apply", approval_token, {
9031
+ machineId: resolvedMachineId,
9032
+ resourceId: mcpPlanResourceId("machines_install_tailscale_apply", resolvedMachineId, plan),
9033
+ args: mcpPlanApprovalArgs({ machine_id: resolvedMachineId, yes }, plan)
9034
+ });
9035
+ return { content: [{ type: "text", text: JSON.stringify(runTailscaleInstallPlan(plan, { apply: true, yes }), null, 2) }] };
9036
+ });
8412
9037
  server.tool("machines_route_resolve", "Resolve the best route for a machine using manifest, heartbeat, SSH, LAN, and Tailscale topology.", {
8413
9038
  machine_id: exports_external.string().describe("Machine identifier"),
8414
9039
  include_tailscale: exports_external.boolean().optional().describe("Whether to probe tailscale status --json"),
@@ -8476,41 +9101,72 @@ function createMcpServer(version) {
8476
9101
  content: [{ type: "text", text: JSON.stringify(listPorts(machine_id), null, 2) }]
8477
9102
  }));
8478
9103
  server.tool("machines_backup_preview", "Preview backup steps for the current machine.", { bucket: exports_external.string().optional().describe("S3 bucket name; defaults to HASNA_MACHINES_S3_BUCKET or MACHINES_S3_BUCKET"), prefix: exports_external.string().optional().describe("S3 key prefix; defaults to HASNA_MACHINES_S3_PREFIX, MACHINES_S3_PREFIX, or machines") }, async ({ bucket, prefix }) => ({ content: [{ type: "text", text: JSON.stringify(buildBackupPlan(bucket, prefix), null, 2) }] }));
8479
- server.tool("machines_backup_apply", "Execute backup steps for the current machine.", { bucket: exports_external.string().optional().describe("S3 bucket name; defaults to HASNA_MACHINES_S3_BUCKET or MACHINES_S3_BUCKET"), prefix: exports_external.string().optional().describe("S3 key prefix; defaults to HASNA_MACHINES_S3_PREFIX, MACHINES_S3_PREFIX, or machines"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ bucket, prefix, yes }) => ({ content: [{ type: "text", text: JSON.stringify(runBackup(bucket, prefix, { apply: true, yes }), null, 2) }] }));
9104
+ server.tool("machines_backup_apply", "Execute backup steps for the current machine.", { bucket: exports_external.string().optional().describe("S3 bucket name; defaults to HASNA_MACHINES_S3_BUCKET or MACHINES_S3_BUCKET"), prefix: exports_external.string().optional().describe("S3 key prefix; defaults to HASNA_MACHINES_S3_PREFIX, MACHINES_S3_PREFIX, or machines"), yes: exports_external.boolean().describe("Confirmation flag for execution"), approval_token: approvalTokenSchema }, async ({ bucket, prefix, yes, approval_token }) => {
9105
+ requireMcpMutation("machines_backup_apply", approval_token, { resourceId: mutationResourceId("backup", bucket, prefix), args: { bucket, prefix, yes } });
9106
+ return { content: [{ type: "text", text: JSON.stringify(runBackup(bucket, prefix, { apply: true, yes }), null, 2) }] };
9107
+ });
8480
9108
  server.tool("machines_cert_preview", "Preview mkcert steps for one or more domains.", { domains: exports_external.array(exports_external.string()).describe("Domains to issue certificates for") }, async ({ domains }) => ({ content: [{ type: "text", text: JSON.stringify(buildCertPlan(domains), null, 2) }] }));
8481
- server.tool("machines_cert_apply", "Execute mkcert steps for one or more domains.", { domains: exports_external.array(exports_external.string()).describe("Domains to issue certificates for"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ domains, yes }) => ({ content: [{ type: "text", text: JSON.stringify(runCertPlan(domains, { apply: true, yes }), null, 2) }] }));
8482
- server.tool("machines_dns_add", "Add or replace a local domain mapping.", { domain: exports_external.string().describe("Domain name"), port: exports_external.number().describe("Target port"), target_host: exports_external.string().optional().describe("Target host") }, async ({ domain, port, target_host }) => ({ content: [{ type: "text", text: JSON.stringify(addDomainMapping(domain, port, target_host), null, 2) }] }));
9109
+ server.tool("machines_cert_apply", "Execute mkcert steps for one or more domains.", { domains: exports_external.array(exports_external.string()).describe("Domains to issue certificates for"), yes: exports_external.boolean().describe("Confirmation flag for execution"), approval_token: approvalTokenSchema }, async ({ domains, yes, approval_token }) => {
9110
+ requireMcpMutation("machines_cert_apply", approval_token, { resourceId: mutationResourceId("cert", domains.join(",")), args: { domains, yes } });
9111
+ return { content: [{ type: "text", text: JSON.stringify(runCertPlan(domains, { apply: true, yes }), null, 2) }] };
9112
+ });
9113
+ server.tool("machines_dns_add", "Add or replace a local domain mapping.", { domain: exports_external.string().describe("Domain name"), port: exports_external.number().describe("Target port"), target_host: exports_external.string().optional().describe("Target host"), approval_token: approvalTokenSchema }, async ({ domain, port, target_host, approval_token }) => {
9114
+ const resolvedTargetHost = target_host ?? "127.0.0.1";
9115
+ requireMcpMutation("machines_dns_add", approval_token, { resourceId: mutationResourceId("dns", domain), args: { domain, port, target_host: resolvedTargetHost } });
9116
+ return { content: [{ type: "text", text: JSON.stringify(addDomainMapping(domain, port, resolvedTargetHost), null, 2) }] };
9117
+ });
8483
9118
  server.tool("machines_dns_list", "List local domain mappings.", {}, async () => ({ content: [{ type: "text", text: JSON.stringify(listDomainMappings(), null, 2) }] }));
8484
9119
  server.tool("machines_dns_render", "Render hosts/proxy configuration for a domain.", { domain: exports_external.string().describe("Domain name") }, async ({ domain }) => ({ content: [{ type: "text", text: JSON.stringify(renderDomainMapping(domain), null, 2) }] }));
8485
9120
  server.tool("machines_notifications_add", "Add or replace a notification channel.", {
8486
9121
  channel_id: exports_external.string().describe("Channel identifier"),
8487
9122
  type: exports_external.enum(["email", "webhook", "command"]).describe("Notification transport"),
8488
- target: exports_external.string().describe("Email, webhook URL, or shell command"),
9123
+ target: exports_external.string().describe("Email, webhook URL, or command executable"),
9124
+ command_args: exports_external.array(exports_external.string()).optional().describe("Arguments for command transports"),
8489
9125
  events: exports_external.array(exports_external.string()).describe("Events routed to this channel"),
8490
- enabled: exports_external.boolean().optional().describe("Whether the channel is enabled")
8491
- }, async ({ channel_id, type, target, events: events2, enabled }) => ({
8492
- content: [{ type: "text", text: JSON.stringify(addNotificationChannel({ id: channel_id, type, target, events: events2, enabled: enabled ?? true }), null, 2) }]
8493
- }));
9126
+ enabled: exports_external.boolean().optional().describe("Whether the channel is enabled"),
9127
+ approval_token: approvalTokenSchema
9128
+ }, async ({ channel_id, type, target, command_args, events: events2, enabled, approval_token }) => {
9129
+ const resolvedEnabled = enabled ?? true;
9130
+ const resolvedEvents = [...new Set(events2)];
9131
+ const commandArgs = command_args ?? [];
9132
+ requireMcpMutation("machines_notifications_add", approval_token, { resourceId: mutationResourceId("notification", channel_id), args: { channel_id, type, target, command_args: commandArgs, events: resolvedEvents, enabled: resolvedEnabled } });
9133
+ return {
9134
+ content: [{ type: "text", text: JSON.stringify(addNotificationChannel({ id: channel_id, type, target, commandArgs: type === "command" && commandArgs.length > 0 ? commandArgs : undefined, events: resolvedEvents, enabled: resolvedEnabled }, { trustedApproval: trustedNotificationApproval2 }), null, 2) }]
9135
+ };
9136
+ });
8494
9137
  server.tool("machines_notifications_list", "List notification channels.", {}, async () => ({
8495
9138
  content: [{ type: "text", text: JSON.stringify(listNotificationChannels(), null, 2) }]
8496
9139
  }));
8497
- server.tool("machines_notifications_test", "Preview or execute a notification test.", { channel_id: exports_external.string().describe("Channel identifier"), event: exports_external.string().optional().describe("Event name"), message: exports_external.string().optional().describe("Message body"), yes: exports_external.boolean().optional().describe("Execute the test when true") }, async ({ channel_id, event, message, yes }) => ({
8498
- content: [{ type: "text", text: JSON.stringify(await testNotificationChannel(channel_id, event, message, { apply: Boolean(yes), yes }), null, 2) }]
8499
- }));
8500
- server.tool("machines_notifications_dispatch", "Dispatch an event to matching notification channels.", { event: exports_external.string().describe("Event name"), message: exports_external.string().describe("Message body"), channel_id: exports_external.string().optional().describe("Limit delivery to one channel") }, async ({ event, message, channel_id }) => ({ content: [{ type: "text", text: JSON.stringify(await dispatchNotificationEvent(event, message, { channelId: channel_id }), null, 2) }] }));
8501
- server.tool("machines_notifications_remove", "Remove a notification channel.", { channel_id: exports_external.string().describe("Channel identifier") }, async ({ channel_id }) => ({ content: [{ type: "text", text: JSON.stringify(removeNotificationChannel(channel_id), null, 2) }] }));
9140
+ server.tool("machines_notifications_test", "Preview or execute a notification test.", { channel_id: exports_external.string().describe("Channel identifier"), event: exports_external.string().optional().describe("Event name"), message: exports_external.string().optional().describe("Message body"), yes: exports_external.boolean().optional().describe("Execute the test when true"), approval_token: approvalTokenSchema }, async ({ channel_id, event, message, yes, approval_token }) => {
9141
+ if (yes === true)
9142
+ requireMcpMutation("machines_notifications_test", approval_token, { resourceId: mutationResourceId("notification-test", channel_id, event), args: { channel_id, event, message, yes: true } });
9143
+ return {
9144
+ content: [{ type: "text", text: JSON.stringify(await testNotificationChannel(channel_id, event, message, { apply: Boolean(yes), yes, trustedApproval: yes === true ? trustedNotificationApproval2 : undefined }), null, 2) }]
9145
+ };
9146
+ });
9147
+ server.tool("machines_notifications_dispatch", "Dispatch an event to matching notification channels.", { event: exports_external.string().describe("Event name"), message: exports_external.string().describe("Message body"), channel_id: exports_external.string().optional().describe("Limit delivery to one channel"), approval_token: approvalTokenSchema }, async ({ event, message, channel_id, approval_token }) => {
9148
+ requireMcpMutation("machines_notifications_dispatch", approval_token, { resourceId: mutationResourceId("notification-dispatch", channel_id, event), args: { event, message, channel_id } });
9149
+ return { content: [{ type: "text", text: JSON.stringify(await dispatchNotificationEvent(event, message, { channelId: channel_id, trustedApproval: trustedNotificationApproval2 }), null, 2) }] };
9150
+ });
9151
+ server.tool("machines_notifications_remove", "Remove a notification channel.", { channel_id: exports_external.string().describe("Channel identifier"), approval_token: approvalTokenSchema }, async ({ channel_id, approval_token }) => {
9152
+ requireMcpMutation("machines_notifications_remove", approval_token, { resourceId: mutationResourceId("notification", channel_id), args: { channel_id } });
9153
+ return { content: [{ type: "text", text: JSON.stringify(removeNotificationChannel(channel_id), null, 2) }] };
9154
+ });
8502
9155
  server.tool("machines_webhooks_add", "Add or replace a shared event webhook channel.", {
8503
9156
  channel_id: exports_external.string().describe("Channel identifier"),
8504
9157
  url: exports_external.string().url().describe("Webhook URL"),
8505
9158
  event_type: exports_external.string().optional().describe("Optional event type filter, e.g. machines.*"),
8506
9159
  source: exports_external.string().optional().describe("Optional source filter"),
8507
9160
  secret: exports_external.string().optional().describe("Optional HMAC secret"),
8508
- enabled: exports_external.boolean().optional().describe("Whether the channel is enabled")
8509
- }, async ({ channel_id, url, event_type, source, secret, enabled }) => {
9161
+ enabled: exports_external.boolean().optional().describe("Whether the channel is enabled"),
9162
+ approval_token: approvalTokenSchema
9163
+ }, async ({ channel_id, url, event_type, source, secret, enabled, approval_token }) => {
9164
+ const resolvedEnabled = enabled ?? true;
9165
+ requireMcpMutation("machines_webhooks_add", approval_token, { resourceId: mutationResourceId("webhook", channel_id), args: { channel_id, url, event_type, source, secret, enabled: resolvedEnabled } });
8510
9166
  const now = new Date().toISOString();
8511
9167
  const channel = await events.addChannel({
8512
9168
  id: channel_id,
8513
- enabled: enabled ?? true,
9169
+ enabled: resolvedEnabled,
8514
9170
  transport: "webhook",
8515
9171
  filters: event_type || source ? [{ type: event_type, source }] : undefined,
8516
9172
  webhook: { url, secret },
@@ -8522,10 +9178,16 @@ function createMcpServer(version) {
8522
9178
  server.tool("machines_webhooks_list", "List shared event webhook channels.", {}, async () => ({
8523
9179
  content: [{ type: "text", text: JSON.stringify(sanitizeChannelsForOutput2(await events.listChannels()), null, 2) }]
8524
9180
  }));
8525
- server.tool("machines_webhooks_test", "Send a test event to one shared event channel.", { channel_id: exports_external.string().describe("Channel identifier"), event_type: exports_external.string().optional().describe("Event type"), message: exports_external.string().optional().describe("Message body") }, async ({ channel_id, event_type, message }) => ({
8526
- content: [{ type: "text", text: JSON.stringify(await events.testChannel(channel_id, { source: "machines", type: event_type ?? "events.test", message }), null, 2) }]
8527
- }));
8528
- server.tool("machines_webhooks_remove", "Remove a shared event channel.", { channel_id: exports_external.string().describe("Channel identifier") }, async ({ channel_id }) => ({ content: [{ type: "text", text: JSON.stringify({ removed: await events.removeChannel(channel_id) }, null, 2) }] }));
9181
+ server.tool("machines_webhooks_test", "Send a test event to one shared event channel.", { channel_id: exports_external.string().describe("Channel identifier"), event_type: exports_external.string().optional().describe("Event type"), message: exports_external.string().optional().describe("Message body"), approval_token: approvalTokenSchema }, async ({ channel_id, event_type, message, approval_token }) => {
9182
+ requireMcpMutation("machines_webhooks_test", approval_token, { resourceId: mutationResourceId("webhook-test", channel_id, event_type), args: { channel_id, event_type, message } });
9183
+ return {
9184
+ content: [{ type: "text", text: JSON.stringify(await events.testChannel(channel_id, { source: "machines", type: event_type ?? "events.test", message }), null, 2) }]
9185
+ };
9186
+ });
9187
+ server.tool("machines_webhooks_remove", "Remove a shared event channel.", { channel_id: exports_external.string().describe("Channel identifier"), approval_token: approvalTokenSchema }, async ({ channel_id, approval_token }) => {
9188
+ requireMcpMutation("machines_webhooks_remove", approval_token, { resourceId: mutationResourceId("webhook", channel_id), args: { channel_id } });
9189
+ return { content: [{ type: "text", text: JSON.stringify({ removed: await events.removeChannel(channel_id) }, null, 2) }] };
9190
+ });
8529
9191
  server.tool("machines_events_emit", "Emit a shared event from machines.", {
8530
9192
  event_type: exports_external.string().describe("Event type"),
8531
9193
  subject: exports_external.string().optional().describe("Event subject"),
@@ -8534,25 +9196,36 @@ function createMcpServer(version) {
8534
9196
  data: exports_external.record(exports_external.unknown()).optional().describe("Event data"),
8535
9197
  metadata: exports_external.record(exports_external.unknown()).optional().describe("Event metadata"),
8536
9198
  dedupe_key: exports_external.string().optional().describe("Dedupe key"),
8537
- deliver: exports_external.boolean().optional().describe("Deliver to matching channels")
8538
- }, async ({ event_type, subject, severity, message, data, metadata, dedupe_key, deliver }) => ({
8539
- content: [{ type: "text", text: JSON.stringify(await events.emit({
8540
- source: "machines",
8541
- type: event_type,
8542
- subject,
8543
- severity,
8544
- message,
8545
- data: data ?? {},
8546
- metadata: metadata ?? {},
8547
- dedupeKey: dedupe_key
8548
- }, { deliver: deliver !== false }), null, 2) }]
8549
- }));
9199
+ deliver: exports_external.boolean().optional().describe("Deliver to matching channels"),
9200
+ approval_token: approvalTokenSchema
9201
+ }, async ({ event_type, subject, severity, message, data, metadata, dedupe_key, deliver, approval_token }) => {
9202
+ const resolvedData = data ?? {};
9203
+ const resolvedMetadata = metadata ?? {};
9204
+ const resolvedDeliver = deliver !== false;
9205
+ requireMcpMutation("machines_events_emit", approval_token, { resourceId: mutationResourceId("event", event_type, subject, dedupe_key), args: { event_type, subject, severity, message, data: resolvedData, metadata: resolvedMetadata, dedupe_key, deliver: resolvedDeliver } });
9206
+ return {
9207
+ content: [{ type: "text", text: JSON.stringify(await events.emit({
9208
+ source: "machines",
9209
+ type: event_type,
9210
+ subject,
9211
+ severity,
9212
+ message,
9213
+ data: resolvedData,
9214
+ metadata: resolvedMetadata,
9215
+ dedupeKey: dedupe_key
9216
+ }, { deliver: resolvedDeliver }), null, 2) }]
9217
+ };
9218
+ });
8550
9219
  server.tool("machines_events_list", "List shared events.", {}, async () => ({
8551
9220
  content: [{ type: "text", text: JSON.stringify(await events.listEvents(), null, 2) }]
8552
9221
  }));
8553
- server.tool("machines_events_replay", "Replay shared events.", { event_id: exports_external.string().optional().describe("Event id"), source: exports_external.string().optional().describe("Source filter"), event_type: exports_external.string().optional().describe("Event type filter"), dry_run: exports_external.boolean().optional().describe("Preview without delivery") }, async ({ event_id, source, event_type, dry_run }) => ({
8554
- content: [{ type: "text", text: JSON.stringify(await events.replay({ eventId: event_id, source, type: event_type, dryRun: dry_run }), null, 2) }]
8555
- }));
9222
+ server.tool("machines_events_replay", "Replay shared events.", { event_id: exports_external.string().optional().describe("Event id"), source: exports_external.string().optional().describe("Source filter"), event_type: exports_external.string().optional().describe("Event type filter"), dry_run: exports_external.boolean().optional().describe("Preview without delivery"), approval_token: approvalTokenSchema }, async ({ event_id, source, event_type, dry_run, approval_token }) => {
9223
+ if (dry_run !== true)
9224
+ requireMcpMutation("machines_events_replay", approval_token, { resourceId: mutationResourceId("event-replay", event_id, source, event_type), args: { event_id, source, event_type, dry_run: false } });
9225
+ return {
9226
+ content: [{ type: "text", text: JSON.stringify(await events.replay({ eventId: event_id, source, type: event_type, dryRun: dry_run }), null, 2) }]
9227
+ };
9228
+ });
8556
9229
  server.tool("machines_serve_info", "Preview the dashboard server bind address and routes.", { host: exports_external.string().optional().describe("Host interface"), port: exports_external.number().optional().describe("Port number") }, async ({ host, port }) => ({ content: [{ type: "text", text: JSON.stringify(getServeInfo({ host, port }), null, 2) }] }));
8557
9230
  server.tool("machines_serve_dashboard", "Render the current dashboard HTML.", {}, async () => ({
8558
9231
  content: [{ type: "text", text: renderDashboardHtml() }]
@@ -8560,15 +9233,28 @@ function createMcpServer(version) {
8560
9233
  server.tool("storage_status", "Show machines storage sync configuration and local sync history.", {}, async () => ({
8561
9234
  content: [{ type: "text", text: JSON.stringify(getStorageStatus(), null, 2) }]
8562
9235
  }));
8563
- server.tool("storage_push", "Push local machine runtime data to storage PostgreSQL.", { tables: exports_external.array(exports_external.string()).optional().describe("Optional table list to push") }, async ({ tables }) => ({ content: [{ type: "text", text: JSON.stringify(await storagePush(tables ? { tables } : undefined), null, 2) }] }));
8564
- server.tool("storage_pull", "Pull machine runtime data from storage PostgreSQL to local SQLite.", { tables: exports_external.array(exports_external.string()).optional().describe("Optional table list to pull") }, async ({ tables }) => ({ content: [{ type: "text", text: JSON.stringify(await storagePull(tables ? { tables } : undefined), null, 2) }] }));
8565
- server.tool("storage_sync", "Bidirectional machines storage sync: pull then push.", { tables: exports_external.array(exports_external.string()).optional().describe("Optional table list to sync") }, async ({ tables }) => ({ content: [{ type: "text", text: JSON.stringify(await storageSync(tables ? { tables } : undefined), null, 2) }] }));
9236
+ server.tool("storage_push", "Push local machine runtime data to storage PostgreSQL.", { tables: exports_external.array(exports_external.string()).optional().describe("Optional table list to push"), approval_token: approvalTokenSchema }, async ({ tables, approval_token }) => {
9237
+ const resolvedTables = resolveTables(tables);
9238
+ requireMcpMutation("storage_push", approval_token, { resourceId: mutationResourceId("storage-push", resolvedTables.join(",")), args: { tables: resolvedTables } });
9239
+ return { content: [{ type: "text", text: JSON.stringify(await storagePush({ tables: resolvedTables }), null, 2) }] };
9240
+ });
9241
+ server.tool("storage_pull", "Pull machine runtime data from storage PostgreSQL to local SQLite.", { tables: exports_external.array(exports_external.string()).optional().describe("Optional table list to pull"), approval_token: approvalTokenSchema }, async ({ tables, approval_token }) => {
9242
+ const resolvedTables = resolveTables(tables);
9243
+ requireMcpMutation("storage_pull", approval_token, { resourceId: mutationResourceId("storage-pull", resolvedTables.join(",")), args: { tables: resolvedTables } });
9244
+ return { content: [{ type: "text", text: JSON.stringify(await storagePull({ tables: resolvedTables }), null, 2) }] };
9245
+ });
9246
+ server.tool("storage_sync", "Bidirectional machines storage sync: pull then push.", { tables: exports_external.array(exports_external.string()).optional().describe("Optional table list to sync"), approval_token: approvalTokenSchema }, async ({ tables, approval_token }) => {
9247
+ const resolvedTables = resolveTables(tables);
9248
+ requireMcpMutation("storage_sync", approval_token, { resourceId: mutationResourceId("storage-sync", resolvedTables.join(",")), args: { tables: resolvedTables } });
9249
+ return { content: [{ type: "text", text: JSON.stringify(await storageSync({ tables: resolvedTables }), null, 2) }] };
9250
+ });
8566
9251
  return server;
8567
9252
  }
8568
9253
 
8569
9254
  // src/mcp/http.ts
8570
9255
  var DEFAULT_HTTP_PORT = 8821;
8571
9256
  var HTTP_NAME = "machines";
9257
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
8572
9258
  function isHttpMode(args = process.argv.slice(2)) {
8573
9259
  return args.includes("--http") || process.env.MCP_HTTP === "1";
8574
9260
  }
@@ -8598,28 +9284,156 @@ function parsePort(raw) {
8598
9284
  function pathnameFromRequest(req) {
8599
9285
  return new URL(req.url ?? "/", "http://127.0.0.1").pathname;
8600
9286
  }
8601
- async function readRequestBody(req) {
9287
+ function isLoopbackHost(host) {
9288
+ const normalized = host.toLowerCase().replace(/^\[|\]$/g, "");
9289
+ return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
9290
+ }
9291
+ function resolveHttpSecurityConfig(env = process.env, host = "127.0.0.1") {
9292
+ const allowUnauthenticated = env.MACHINES_ALLOW_UNAUTHENTICATED === "1" && isLoopbackHost(host);
9293
+ const allowedOrigins = (env.MACHINES_HTTP_ALLOWED_ORIGINS ?? "").split(",").map((origin) => origin.trim()).filter(Boolean);
9294
+ const maxBodyBytes = parsePositiveInteger(env.MACHINES_HTTP_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
9295
+ return {
9296
+ apiKey: env.MACHINES_API_KEY,
9297
+ allowUnauthenticated,
9298
+ allowedOrigins,
9299
+ maxBodyBytes
9300
+ };
9301
+ }
9302
+ function parsePositiveInteger(raw, fallback) {
9303
+ if (!raw)
9304
+ return fallback;
9305
+ const parsed = Number.parseInt(raw, 10);
9306
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
9307
+ }
9308
+ function safeTokenEquals(left, right) {
9309
+ const leftBuffer = Buffer.from(left);
9310
+ const rightBuffer = Buffer.from(right);
9311
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual2(leftBuffer, rightBuffer);
9312
+ }
9313
+ function originValue(req) {
9314
+ const origin = req.headers.origin;
9315
+ if (typeof origin === "string")
9316
+ return origin.trim();
9317
+ return;
9318
+ }
9319
+ function isTrustedHttpOrigin(origin, host, allowedOrigins = []) {
9320
+ if (!origin)
9321
+ return true;
9322
+ if (allowedOrigins.includes(origin) || allowedOrigins.includes("*"))
9323
+ return true;
9324
+ let parsed;
9325
+ try {
9326
+ parsed = new URL(origin);
9327
+ } catch {
9328
+ return false;
9329
+ }
9330
+ return isLoopbackHost(host) && isLoopbackHost(parsed.hostname);
9331
+ }
9332
+ function authorizeHttpOrigin(req, host, security) {
9333
+ const origin = originValue(req);
9334
+ if (isTrustedHttpOrigin(origin, host, security.allowedOrigins))
9335
+ return { ok: true };
9336
+ return {
9337
+ ok: false,
9338
+ status: 403,
9339
+ reason: "Untrusted Origin header for machines MCP HTTP request."
9340
+ };
9341
+ }
9342
+ function requestBearerToken(req) {
9343
+ const authorization = req.headers.authorization;
9344
+ if (typeof authorization === "string") {
9345
+ const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
9346
+ if (match?.[1])
9347
+ return match[1].trim();
9348
+ }
9349
+ const apiKey = req.headers["x-machines-api-key"];
9350
+ if (typeof apiKey === "string")
9351
+ return apiKey.trim();
9352
+ if (Array.isArray(apiKey))
9353
+ return apiKey[0]?.trim();
9354
+ return;
9355
+ }
9356
+ function authorizeHttpRequest(req, security) {
9357
+ if (security.allowUnauthenticated)
9358
+ return { ok: true };
9359
+ const expected = security.apiKey?.trim();
9360
+ if (!expected) {
9361
+ return { ok: false, status: 401, reason: "machines MCP HTTP requires MACHINES_API_KEY or loopback-only MACHINES_ALLOW_UNAUTHENTICATED=1." };
9362
+ }
9363
+ const received = requestBearerToken(req);
9364
+ if (received && safeTokenEquals(received, expected))
9365
+ return { ok: true };
9366
+ return { ok: false, status: 401, reason: "Invalid or missing machines MCP HTTP API key." };
9367
+ }
9368
+ function corsHeaders(req, host, security) {
9369
+ const origin = originValue(req);
9370
+ if (!origin || !isTrustedHttpOrigin(origin, host, security.allowedOrigins))
9371
+ return {};
9372
+ return {
9373
+ "access-control-allow-origin": origin,
9374
+ "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
9375
+ "access-control-allow-headers": "authorization, content-type, x-machines-api-key, mcp-session-id",
9376
+ "access-control-expose-headers": "mcp-session-id",
9377
+ vary: "Origin"
9378
+ };
9379
+ }
9380
+ function applyCorsHeaders(req, res, host, security) {
9381
+ for (const [key, value] of Object.entries(corsHeaders(req, host, security))) {
9382
+ res.setHeader(key, value);
9383
+ }
9384
+ }
9385
+ function writeJson(res, status, payload, headers = {}) {
9386
+ res.writeHead(status, { "content-type": "application/json", ...headers });
9387
+ res.end(JSON.stringify(payload));
9388
+ }
9389
+ function requestContentLength(req) {
9390
+ const raw = req.headers["content-length"];
9391
+ const value = Array.isArray(raw) ? raw[0] : raw;
9392
+ if (!value)
9393
+ return null;
9394
+ const parsed = Number.parseInt(value, 10);
9395
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
9396
+ }
9397
+
9398
+ class HttpRequestError extends Error {
9399
+ status;
9400
+ constructor(status, message) {
9401
+ super(message);
9402
+ this.status = status;
9403
+ }
9404
+ }
9405
+ async function readRequestBody(req, maxBodyBytes) {
8602
9406
  if (req.method !== "POST" && req.method !== "DELETE") {
8603
9407
  return;
8604
9408
  }
8605
9409
  const chunks = [];
9410
+ let size = 0;
8606
9411
  for await (const chunk of req) {
8607
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
9412
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
9413
+ size += buffer.length;
9414
+ if (size > maxBodyBytes) {
9415
+ throw new HttpRequestError(413, `Request body exceeds ${maxBodyBytes} bytes.`);
9416
+ }
9417
+ chunks.push(buffer);
8608
9418
  }
8609
9419
  const text = Buffer.concat(chunks).toString("utf8");
8610
9420
  if (!text) {
8611
9421
  return;
8612
9422
  }
8613
- return JSON.parse(text);
9423
+ try {
9424
+ return JSON.parse(text);
9425
+ } catch {
9426
+ throw new HttpRequestError(400, "Invalid JSON request body.");
9427
+ }
8614
9428
  }
8615
- async function handleMcpRequest(req, res) {
8616
- const server = buildServer();
9429
+ async function handleMcpRequest(req, res, maxBodyBytes) {
9430
+ const server = buildServer(undefined, { mutationTransport: "mcp:http" });
8617
9431
  const transport = new StreamableHTTPServerTransport({
8618
9432
  sessionIdGenerator: undefined
8619
9433
  });
8620
9434
  await server.connect(transport);
8621
9435
  try {
8622
- const body = await readRequestBody(req);
9436
+ const body = await readRequestBody(req, maxBodyBytes);
8623
9437
  await transport.handleRequest(req, res, body);
8624
9438
  } finally {
8625
9439
  res.on("close", () => {
@@ -8636,19 +9450,47 @@ function startHttpServer(options = {}) {
8636
9450
  const host = options.host ?? "127.0.0.1";
8637
9451
  const port = options.port ?? resolveHttpPort();
8638
9452
  const name = options.name ?? HTTP_NAME;
9453
+ const security = options.security ?? resolveHttpSecurityConfig(process.env, host);
8639
9454
  const httpServer = createServer(async (req, res) => {
8640
9455
  const path = pathnameFromRequest(req);
8641
9456
  if (req.method === "GET" && path === "/health") {
8642
- res.writeHead(200, { "content-type": "application/json" });
8643
- res.end(JSON.stringify({ status: "ok", name }));
9457
+ writeJson(res, 200, { status: "ok", name });
8644
9458
  return;
8645
9459
  }
8646
9460
  if (path === "/mcp") {
8647
- await handleMcpRequest(req, res);
9461
+ const origin = authorizeHttpOrigin(req, host, security);
9462
+ if (!origin.ok) {
9463
+ writeJson(res, origin.status, { error: "Forbidden", reason: origin.reason });
9464
+ return;
9465
+ }
9466
+ if (req.method === "OPTIONS") {
9467
+ res.writeHead(204, corsHeaders(req, host, security));
9468
+ res.end();
9469
+ return;
9470
+ }
9471
+ const authorization = authorizeHttpRequest(req, security);
9472
+ if (!authorization.ok) {
9473
+ writeJson(res, authorization.status, { error: "Unauthorized", reason: authorization.reason }, corsHeaders(req, host, security));
9474
+ return;
9475
+ }
9476
+ const contentLength = requestContentLength(req);
9477
+ if (contentLength !== null && contentLength > security.maxBodyBytes) {
9478
+ writeJson(res, 413, { error: "Payload Too Large", reason: `Request body exceeds ${security.maxBodyBytes} bytes.` }, corsHeaders(req, host, security));
9479
+ return;
9480
+ }
9481
+ applyCorsHeaders(req, res, host, security);
9482
+ try {
9483
+ await handleMcpRequest(req, res, security.maxBodyBytes);
9484
+ } catch (error) {
9485
+ if (error instanceof HttpRequestError) {
9486
+ writeJson(res, error.status, { error: error.status === 413 ? "Payload Too Large" : "Bad Request", reason: error.message }, corsHeaders(req, host, security));
9487
+ return;
9488
+ }
9489
+ throw error;
9490
+ }
8648
9491
  return;
8649
9492
  }
8650
- res.writeHead(404, { "content-type": "application/json" });
8651
- res.end(JSON.stringify({ error: "Not found" }));
9493
+ writeJson(res, 404, { error: "Not found" });
8652
9494
  });
8653
9495
  httpServer.listen(port, host, () => {
8654
9496
  const address = httpServer.address();