@hasna/machines 0.0.44 → 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.
- package/README.md +27 -4
- package/dist/agent/index.d.ts +0 -1
- package/dist/agent/index.js +249 -14
- package/dist/agent/runtime.d.ts +0 -1
- package/dist/cli/index.d.ts +0 -1
- package/dist/cli/index.js +1316 -213
- package/dist/cli-utils.d.ts +0 -1
- package/dist/commands/apps.d.ts +7 -5
- package/dist/commands/backup.d.ts +0 -1
- package/dist/commands/cert.d.ts +0 -1
- package/dist/commands/clipboard-daemon.d.ts +0 -1
- package/dist/commands/clipboard-server.d.ts +0 -1
- package/dist/commands/clipboard.d.ts +0 -1
- package/dist/commands/daemon.d.ts +0 -1
- package/dist/commands/diff.d.ts +0 -1
- package/dist/commands/dns.d.ts +0 -1
- package/dist/commands/doctor.d.ts +0 -1
- package/dist/commands/heal-daemon.d.ts +0 -1
- package/dist/commands/heal.d.ts +0 -1
- package/dist/commands/install-claude.d.ts +5 -3
- package/dist/commands/install-tailscale.d.ts +5 -3
- package/dist/commands/manifest.d.ts +0 -1
- package/dist/commands/mutation-approval.d.ts +54 -0
- package/dist/commands/notifications.d.ts +14 -2
- package/dist/commands/ports.d.ts +0 -1
- package/dist/commands/runtime.d.ts +15 -1
- package/dist/commands/screen.d.ts +4 -1
- package/dist/commands/self-test.d.ts +0 -1
- package/dist/commands/serve.d.ts +0 -1
- package/dist/commands/setup.d.ts +5 -3
- package/dist/commands/ssh.d.ts +8 -1
- package/dist/commands/status.d.ts +0 -1
- package/dist/commands/sync.d.ts +5 -3
- package/dist/commands/workspace.d.ts +0 -1
- package/dist/compatibility.d.ts +0 -1
- package/dist/consumer-schema.d.ts +0 -1
- package/dist/consumer.d.ts +0 -1
- package/dist/consumer.js +253 -12
- package/dist/cross-project-types.d.ts +0 -1
- package/dist/db.d.ts +0 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1108 -189
- package/dist/manifests.d.ts +0 -1
- package/dist/mcp/http.d.ts +26 -2
- package/dist/mcp/index.d.ts +0 -1
- package/dist/mcp/index.js +1021 -167
- package/dist/mcp/server.d.ts +5 -3
- package/dist/paths.d.ts +0 -1
- package/dist/pg-migrations.d.ts +0 -1
- package/dist/redaction.d.ts +0 -1
- package/dist/remote-storage.d.ts +0 -1
- package/dist/remote.d.ts +14 -5
- package/dist/storage-sync.d.ts +0 -1
- package/dist/storage.d.ts +0 -1
- package/dist/storage.js +18 -0
- package/dist/topology.d.ts +0 -1
- package/dist/types.d.ts +3 -1
- package/dist/version.d.ts +0 -1
- package/package.json +5 -3
- package/dist/agent/index.d.ts.map +0 -1
- package/dist/agent/runtime.d.ts.map +0 -1
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli-utils.d.ts.map +0 -1
- package/dist/commands/apps.d.ts.map +0 -1
- package/dist/commands/backup.d.ts.map +0 -1
- package/dist/commands/cert.d.ts.map +0 -1
- package/dist/commands/clipboard-daemon.d.ts.map +0 -1
- package/dist/commands/clipboard-server.d.ts.map +0 -1
- package/dist/commands/clipboard.d.ts.map +0 -1
- package/dist/commands/daemon.d.ts.map +0 -1
- package/dist/commands/diff.d.ts.map +0 -1
- package/dist/commands/dns.d.ts.map +0 -1
- package/dist/commands/doctor.d.ts.map +0 -1
- package/dist/commands/heal-daemon.d.ts.map +0 -1
- package/dist/commands/heal.d.ts.map +0 -1
- package/dist/commands/install-claude.d.ts.map +0 -1
- package/dist/commands/install-tailscale.d.ts.map +0 -1
- package/dist/commands/manifest.d.ts.map +0 -1
- package/dist/commands/notifications.d.ts.map +0 -1
- package/dist/commands/ports.d.ts.map +0 -1
- package/dist/commands/runtime.d.ts.map +0 -1
- package/dist/commands/screen.d.ts.map +0 -1
- package/dist/commands/self-test.d.ts.map +0 -1
- package/dist/commands/serve.d.ts.map +0 -1
- package/dist/commands/setup.d.ts.map +0 -1
- package/dist/commands/ssh.d.ts.map +0 -1
- package/dist/commands/status.d.ts.map +0 -1
- package/dist/commands/sync.d.ts.map +0 -1
- package/dist/commands/workspace.d.ts.map +0 -1
- package/dist/compatibility.d.ts.map +0 -1
- package/dist/consumer-schema.d.ts.map +0 -1
- package/dist/consumer.d.ts.map +0 -1
- package/dist/cross-project-types.d.ts.map +0 -1
- package/dist/db.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/manifests.d.ts.map +0 -1
- package/dist/mcp/http.d.ts.map +0 -1
- package/dist/mcp/index.d.ts.map +0 -1
- package/dist/mcp/server.d.ts.map +0 -1
- package/dist/paths.d.ts.map +0 -1
- package/dist/pg-migrations.d.ts.map +0 -1
- package/dist/redaction.d.ts.map +0 -1
- package/dist/remote-storage.d.ts.map +0 -1
- package/dist/remote.d.ts.map +0 -1
- package/dist/storage-sync.d.ts.map +0 -1
- package/dist/storage.d.ts.map +0 -1
- package/dist/topology.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
- 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/
|
|
4463
|
-
import {
|
|
4464
|
-
import {
|
|
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
|
-
|
|
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:
|
|
5617
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
5774
|
-
|
|
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
|
|
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
|
|
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 =
|
|
5808
|
-
const keyPath =
|
|
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
|
|
5873
|
-
import { join as
|
|
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
|
|
6372
|
+
return join6(getDataDir(), "dns.json");
|
|
5876
6373
|
}
|
|
5877
6374
|
function readMappings() {
|
|
5878
6375
|
const path = getDnsPath();
|
|
5879
|
-
if (!
|
|
6376
|
+
if (!existsSync6(path))
|
|
5880
6377
|
return [];
|
|
5881
|
-
return JSON.parse(
|
|
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 ${
|
|
6405
|
+
tls ${join6(getDataDir(), "certs", `${entry.domain}.pem`)} ${join6(getDataDir(), "certs", `${entry.domain}-key.pem`)}
|
|
5909
6406
|
}`,
|
|
5910
|
-
certPath:
|
|
5911
|
-
keyPath:
|
|
6407
|
+
certPath: join6(getDataDir(), "certs", `${entry.domain}.pem`),
|
|
6408
|
+
keyPath: join6(getDataDir(), "certs", `${entry.domain}-key.pem`)
|
|
5912
6409
|
};
|
|
5913
6410
|
}
|
|
5914
6411
|
|
|
@@ -5954,8 +6451,8 @@ function diffMachines(leftMachineId, rightMachineId) {
|
|
|
5954
6451
|
}
|
|
5955
6452
|
|
|
5956
6453
|
// src/commands/daemon.ts
|
|
5957
|
-
import { chmodSync, existsSync as
|
|
5958
|
-
import { dirname as dirname4 } from "path";
|
|
6454
|
+
import { chmodSync, existsSync as existsSync7, readFileSync as readFileSync5, statSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
6455
|
+
import { delimiter, dirname as dirname4 } from "path";
|
|
5959
6456
|
import { platform as osPlatform } from "os";
|
|
5960
6457
|
var DEFAULT_SERVICE_NAME = "machines-agent";
|
|
5961
6458
|
var DEFAULT_EXECUTABLE = "/usr/local/bin/machines-agent";
|
|
@@ -6219,19 +6716,31 @@ WantedBy=${options.mode === "system" ? "multi-user.target" : "default.target"}
|
|
|
6219
6716
|
`;
|
|
6220
6717
|
}
|
|
6221
6718
|
function daemonProgramArguments(options) {
|
|
6222
|
-
const bunRuntime =
|
|
6719
|
+
const bunRuntime = bunRuntimeForExecutable(options.executable);
|
|
6223
6720
|
const base = bunRuntime ? [bunRuntime, options.executable] : [options.executable];
|
|
6224
6721
|
return [...base, "--interval-ms", String(options.intervalMs)];
|
|
6225
6722
|
}
|
|
6226
|
-
function
|
|
6723
|
+
function bunRuntimeForExecutable(executable) {
|
|
6227
6724
|
if (!isBunShebangScript(executable))
|
|
6228
6725
|
return null;
|
|
6229
|
-
const candidate
|
|
6230
|
-
|
|
6726
|
+
for (const candidate of bunRuntimeCandidates(executable)) {
|
|
6727
|
+
if (isExecutableFile(candidate))
|
|
6728
|
+
return candidate;
|
|
6729
|
+
}
|
|
6730
|
+
return null;
|
|
6731
|
+
}
|
|
6732
|
+
function bunRuntimeCandidates(executable) {
|
|
6733
|
+
const candidates = [
|
|
6734
|
+
`${dirname4(executable)}/bun`,
|
|
6735
|
+
process.env["BUN_INSTALL"] ? `${process.env["BUN_INSTALL"]}/bin/bun` : null,
|
|
6736
|
+
process.env["HOME"] ? `${process.env["HOME"]}/.bun/bin/bun` : null,
|
|
6737
|
+
...(process.env["PATH"] ?? "").split(delimiter).filter(Boolean).map((entry) => `${entry}/bun`)
|
|
6738
|
+
].filter((value) => Boolean(value));
|
|
6739
|
+
return [...new Set(candidates)];
|
|
6231
6740
|
}
|
|
6232
6741
|
function isBunShebangScript(executable) {
|
|
6233
6742
|
try {
|
|
6234
|
-
const content =
|
|
6743
|
+
const content = readFileSync5(executable, "utf8").slice(0, 256);
|
|
6235
6744
|
const firstLine = content.split(/\r?\n/, 1)[0] ?? "";
|
|
6236
6745
|
return /^#!.*\bbun\b/.test(firstLine);
|
|
6237
6746
|
} catch {
|
|
@@ -6239,7 +6748,7 @@ function isBunShebangScript(executable) {
|
|
|
6239
6748
|
}
|
|
6240
6749
|
}
|
|
6241
6750
|
function isExecutableFile(path) {
|
|
6242
|
-
if (!
|
|
6751
|
+
if (!existsSync7(path))
|
|
6243
6752
|
return false;
|
|
6244
6753
|
try {
|
|
6245
6754
|
const stats = statSync(path);
|
|
@@ -6529,12 +7038,12 @@ function parseProbe(tool, stdout) {
|
|
|
6529
7038
|
}
|
|
6530
7039
|
function buildClaudeInstallPlan(machineId, tools) {
|
|
6531
7040
|
const machine = resolveMachine2(machineId);
|
|
6532
|
-
return {
|
|
7041
|
+
return attachMutationPlanDigest({
|
|
6533
7042
|
machineId: machine.id,
|
|
6534
7043
|
mode: "plan",
|
|
6535
7044
|
steps: buildInstallSteps(machine, tools),
|
|
6536
7045
|
executed: 0
|
|
6537
|
-
};
|
|
7046
|
+
});
|
|
6538
7047
|
}
|
|
6539
7048
|
function getClaudeCliStatus(machineId, tools, runner = runMachineCommand) {
|
|
6540
7049
|
const machine = resolveMachine2(machineId);
|
|
@@ -6557,10 +7066,10 @@ function diffClaudeCli(machineId, tools, runner = runMachineCommand) {
|
|
|
6557
7066
|
installed: status.tools.filter((tool) => tool.installed).map((tool) => tool.tool)
|
|
6558
7067
|
};
|
|
6559
7068
|
}
|
|
6560
|
-
function
|
|
6561
|
-
|
|
7069
|
+
function runClaudeInstallPlan(plan, options = {}, runner = runMachineCommand) {
|
|
7070
|
+
assertMutationPlanDigest(plan, options.expectedPlanDigest);
|
|
6562
7071
|
if (!options.apply)
|
|
6563
|
-
return plan;
|
|
7072
|
+
return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
|
|
6564
7073
|
if (!options.yes) {
|
|
6565
7074
|
throw new Error("Claude CLI installation requires --yes.");
|
|
6566
7075
|
}
|
|
@@ -6569,12 +7078,12 @@ function runClaudeInstall(machineId, tools, options = {}, runner = runMachineCom
|
|
|
6569
7078
|
requireMachineCommandSuccess(`AI CLI install ${step.id}`, runner(plan.machineId, step.command));
|
|
6570
7079
|
executed += 1;
|
|
6571
7080
|
}
|
|
6572
|
-
return {
|
|
7081
|
+
return attachMutationPlanDigest({
|
|
6573
7082
|
machineId: plan.machineId,
|
|
6574
7083
|
mode: "apply",
|
|
6575
7084
|
steps: plan.steps,
|
|
6576
7085
|
executed
|
|
6577
|
-
};
|
|
7086
|
+
});
|
|
6578
7087
|
}
|
|
6579
7088
|
|
|
6580
7089
|
// src/commands/install-tailscale.ts
|
|
@@ -6614,17 +7123,17 @@ function buildTailscaleInstallPlan(machineId) {
|
|
|
6614
7123
|
if (!machine) {
|
|
6615
7124
|
throw new Error(`Machine not found in manifest: ${machineId}`);
|
|
6616
7125
|
}
|
|
6617
|
-
return {
|
|
7126
|
+
return attachMutationPlanDigest({
|
|
6618
7127
|
machineId: machine.id,
|
|
6619
7128
|
mode: "plan",
|
|
6620
7129
|
steps: buildInstallSteps2(machine),
|
|
6621
7130
|
executed: 0
|
|
6622
|
-
};
|
|
7131
|
+
});
|
|
6623
7132
|
}
|
|
6624
|
-
function
|
|
6625
|
-
|
|
7133
|
+
function runTailscaleInstallPlan(plan, options = {}, runner = runMachineCommand) {
|
|
7134
|
+
assertMutationPlanDigest(plan, options.expectedPlanDigest);
|
|
6626
7135
|
if (!options.apply)
|
|
6627
|
-
return plan;
|
|
7136
|
+
return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
|
|
6628
7137
|
if (!options.yes) {
|
|
6629
7138
|
throw new Error("Tailscale install requires --yes.");
|
|
6630
7139
|
}
|
|
@@ -6633,20 +7142,22 @@ function runTailscaleInstall(machineId, options = {}, runner = runMachineCommand
|
|
|
6633
7142
|
requireMachineCommandSuccess(`Tailscale install ${step.id}`, runner(plan.machineId, step.command));
|
|
6634
7143
|
executed += 1;
|
|
6635
7144
|
}
|
|
6636
|
-
return {
|
|
7145
|
+
return attachMutationPlanDigest({
|
|
6637
7146
|
machineId: plan.machineId,
|
|
6638
7147
|
mode: "apply",
|
|
6639
7148
|
steps: plan.steps,
|
|
6640
7149
|
executed
|
|
6641
|
-
};
|
|
7150
|
+
});
|
|
6642
7151
|
}
|
|
6643
7152
|
|
|
6644
7153
|
// src/commands/notifications.ts
|
|
6645
|
-
import { existsSync as
|
|
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";
|
|
6646
7156
|
var notificationChannelSchema = exports_external.object({
|
|
6647
7157
|
id: exports_external.string(),
|
|
6648
7158
|
type: exports_external.enum(["email", "webhook", "command"]),
|
|
6649
7159
|
target: exports_external.string(),
|
|
7160
|
+
commandArgs: exports_external.array(exports_external.string()).optional(),
|
|
6650
7161
|
events: exports_external.array(exports_external.string()),
|
|
6651
7162
|
enabled: exports_external.boolean()
|
|
6652
7163
|
});
|
|
@@ -6655,19 +7166,31 @@ var notificationConfigSchema = exports_external.object({
|
|
|
6655
7166
|
updatedAt: exports_external.string().optional(),
|
|
6656
7167
|
channels: exports_external.array(notificationChannelSchema)
|
|
6657
7168
|
});
|
|
7169
|
+
var trustedNotificationApproval = Symbol("trustedNotificationApproval");
|
|
7170
|
+
function createTrustedNotificationApproval() {
|
|
7171
|
+
return { [trustedNotificationApproval]: true };
|
|
7172
|
+
}
|
|
7173
|
+
function isTrustedNotificationApproval(approval) {
|
|
7174
|
+
return approval?.[trustedNotificationApproval] === true;
|
|
7175
|
+
}
|
|
6658
7176
|
function sortChannels(channels) {
|
|
6659
7177
|
return [...channels].sort((left, right) => left.id.localeCompare(right.id));
|
|
6660
7178
|
}
|
|
6661
|
-
function shellQuote5(value) {
|
|
6662
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
6663
|
-
}
|
|
6664
7179
|
function hasCommand2(binary) {
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
|
|
6670
|
-
|
|
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;
|
|
6671
7194
|
}
|
|
6672
7195
|
function buildNotificationPreview(channel, event, message) {
|
|
6673
7196
|
if (channel.type === "email") {
|
|
@@ -6676,7 +7199,8 @@ function buildNotificationPreview(channel, event, message) {
|
|
|
6676
7199
|
if (channel.type === "webhook") {
|
|
6677
7200
|
return `POST ${channel.target} with payload {"event":"${event}","message":"${message}"}`;
|
|
6678
7201
|
}
|
|
6679
|
-
|
|
7202
|
+
const args = channel.commandArgs?.length ? ` ${channel.commandArgs.join(" ")}` : "";
|
|
7203
|
+
return `${channel.target}${args} with HASNA_MACHINES_NOTIFICATION_* environment`;
|
|
6680
7204
|
}
|
|
6681
7205
|
async function dispatchEmail(channel, event, message) {
|
|
6682
7206
|
const subject = `[${event}] machines notification`;
|
|
@@ -6687,7 +7211,7 @@ Content-Type: text/plain; charset=utf-8
|
|
|
6687
7211
|
${message}
|
|
6688
7212
|
`;
|
|
6689
7213
|
if (hasCommand2("sendmail")) {
|
|
6690
|
-
const result = Bun.spawnSync(["
|
|
7214
|
+
const result = Bun.spawnSync(["sendmail", "-t"], {
|
|
6691
7215
|
stdin: new TextEncoder().encode(body),
|
|
6692
7216
|
stdout: "pipe",
|
|
6693
7217
|
stderr: "pipe",
|
|
@@ -6705,8 +7229,9 @@ ${message}
|
|
|
6705
7229
|
};
|
|
6706
7230
|
}
|
|
6707
7231
|
if (hasCommand2("mail")) {
|
|
6708
|
-
const
|
|
6709
|
-
|
|
7232
|
+
const result = Bun.spawnSync(["mail", "-s", subject, channel.target], {
|
|
7233
|
+
stdin: new TextEncoder().encode(`${message}
|
|
7234
|
+
`),
|
|
6710
7235
|
stdout: "pipe",
|
|
6711
7236
|
stderr: "pipe",
|
|
6712
7237
|
env: process.env
|
|
@@ -6749,8 +7274,20 @@ async function dispatchWebhook(channel, event, message) {
|
|
|
6749
7274
|
detail: `Webhook accepted with HTTP ${response.status}`
|
|
6750
7275
|
};
|
|
6751
7276
|
}
|
|
6752
|
-
async function dispatchCommand(channel, event, message) {
|
|
6753
|
-
|
|
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 ?? []], {
|
|
6754
7291
|
stdout: "pipe",
|
|
6755
7292
|
stderr: "pipe",
|
|
6756
7293
|
env: {
|
|
@@ -6772,7 +7309,7 @@ async function dispatchCommand(channel, event, message) {
|
|
|
6772
7309
|
detail: stdout || "Command completed successfully"
|
|
6773
7310
|
};
|
|
6774
7311
|
}
|
|
6775
|
-
async function dispatchChannel(channel, event, message) {
|
|
7312
|
+
async function dispatchChannel(channel, event, message, options = {}) {
|
|
6776
7313
|
if (!channel.enabled) {
|
|
6777
7314
|
return {
|
|
6778
7315
|
channelId: channel.id,
|
|
@@ -6788,7 +7325,7 @@ async function dispatchChannel(channel, event, message) {
|
|
|
6788
7325
|
if (channel.type === "webhook") {
|
|
6789
7326
|
return dispatchWebhook(channel, event, message);
|
|
6790
7327
|
}
|
|
6791
|
-
return dispatchCommand(channel, event, message);
|
|
7328
|
+
return dispatchCommand(channel, event, message, options);
|
|
6792
7329
|
}
|
|
6793
7330
|
function getDefaultNotificationConfig() {
|
|
6794
7331
|
return {
|
|
@@ -6798,10 +7335,10 @@ function getDefaultNotificationConfig() {
|
|
|
6798
7335
|
};
|
|
6799
7336
|
}
|
|
6800
7337
|
function readNotificationConfig(path = getNotificationsPath()) {
|
|
6801
|
-
if (!
|
|
7338
|
+
if (!existsSync8(path)) {
|
|
6802
7339
|
return getDefaultNotificationConfig();
|
|
6803
7340
|
}
|
|
6804
|
-
return notificationConfigSchema.parse(JSON.parse(
|
|
7341
|
+
return notificationConfigSchema.parse(JSON.parse(readFileSync6(path, "utf8")));
|
|
6805
7342
|
}
|
|
6806
7343
|
function writeNotificationConfig(config, path = getNotificationsPath()) {
|
|
6807
7344
|
ensureParentDir(path);
|
|
@@ -6817,11 +7354,20 @@ function writeNotificationConfig(config, path = getNotificationsPath()) {
|
|
|
6817
7354
|
function listNotificationChannels() {
|
|
6818
7355
|
return readNotificationConfig();
|
|
6819
7356
|
}
|
|
6820
|
-
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
|
+
}
|
|
6821
7366
|
const config = readNotificationConfig();
|
|
6822
7367
|
const channels = config.channels.filter((entry) => entry.id !== channel.id);
|
|
6823
7368
|
channels.push({
|
|
6824
7369
|
...channel,
|
|
7370
|
+
commandArgs: channel.commandArgs?.map(String),
|
|
6825
7371
|
events: [...new Set(channel.events)]
|
|
6826
7372
|
});
|
|
6827
7373
|
return writeNotificationConfig({ ...config, channels });
|
|
@@ -6843,7 +7389,7 @@ async function dispatchNotificationEvent(event, message, options = {}) {
|
|
|
6843
7389
|
const deliveries = [];
|
|
6844
7390
|
for (const channel of channels) {
|
|
6845
7391
|
try {
|
|
6846
|
-
deliveries.push(await dispatchChannel(channel, event, message));
|
|
7392
|
+
deliveries.push(await dispatchChannel(channel, event, message, { approvalToken: options.approvalToken, trustedApproval: options.trustedApproval }));
|
|
6847
7393
|
} catch (error) {
|
|
6848
7394
|
deliveries.push({
|
|
6849
7395
|
channelId: channel.id,
|
|
@@ -6878,7 +7424,7 @@ async function testNotificationChannel(channelId, event = "manual.test", message
|
|
|
6878
7424
|
if (!options.yes) {
|
|
6879
7425
|
throw new Error("Notification test execution requires --yes.");
|
|
6880
7426
|
}
|
|
6881
|
-
const delivery = await dispatchChannel(channel, event, message);
|
|
7427
|
+
const delivery = await dispatchChannel(channel, event, message, { approvalToken: options.approvalToken, trustedApproval: options.trustedApproval });
|
|
6882
7428
|
return {
|
|
6883
7429
|
channelId,
|
|
6884
7430
|
mode: "apply",
|
|
@@ -6943,7 +7489,7 @@ function listPorts(machineId) {
|
|
|
6943
7489
|
}
|
|
6944
7490
|
|
|
6945
7491
|
// src/commands/serve.ts
|
|
6946
|
-
import { EventsClient, sanitizeChannelsForOutput } from "@hasna/events";
|
|
7492
|
+
import { EventsClient, getEventsDataDir, sanitizeChannelsForOutput } from "@hasna/events";
|
|
6947
7493
|
|
|
6948
7494
|
// src/agent/runtime.ts
|
|
6949
7495
|
import { arch as arch3, hostname as hostname6, platform as platform4, release, uptime, version as osVersion } from "os";
|
|
@@ -7502,7 +8048,7 @@ function escapeHtml(value) {
|
|
|
7502
8048
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
7503
8049
|
}
|
|
7504
8050
|
function getServeInfo(options = {}) {
|
|
7505
|
-
const host = options.host || "
|
|
8051
|
+
const host = options.host || "127.0.0.1";
|
|
7506
8052
|
const port = options.port || 7676;
|
|
7507
8053
|
return {
|
|
7508
8054
|
host,
|
|
@@ -7799,17 +8345,17 @@ function buildSetupPlan(machineId) {
|
|
|
7799
8345
|
workspacePath: `${homedir4()}/workspace`
|
|
7800
8346
|
};
|
|
7801
8347
|
const steps = [...buildBaseSteps(target), ...buildPackageSteps(target)];
|
|
7802
|
-
return {
|
|
8348
|
+
return attachMutationPlanDigest({
|
|
7803
8349
|
machineId: target.id,
|
|
7804
8350
|
mode: "plan",
|
|
7805
8351
|
steps,
|
|
7806
8352
|
executed: 0
|
|
7807
|
-
};
|
|
8353
|
+
});
|
|
7808
8354
|
}
|
|
7809
|
-
function
|
|
7810
|
-
|
|
8355
|
+
function runSetupPlan(plan, options = {}, runner = runMachineCommand) {
|
|
8356
|
+
assertMutationPlanDigest(plan, options.expectedPlanDigest);
|
|
7811
8357
|
if (!options.apply) {
|
|
7812
|
-
return plan;
|
|
8358
|
+
return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
|
|
7813
8359
|
}
|
|
7814
8360
|
if (!options.yes) {
|
|
7815
8361
|
throw new Error("Setup execution requires --yes.");
|
|
@@ -7830,18 +8376,18 @@ function runSetup(machineId, options = {}, runner = runMachineCommand) {
|
|
|
7830
8376
|
}
|
|
7831
8377
|
executed += 1;
|
|
7832
8378
|
}
|
|
7833
|
-
const summary = {
|
|
8379
|
+
const summary = attachMutationPlanDigest({
|
|
7834
8380
|
machineId: plan.machineId,
|
|
7835
8381
|
mode: "apply",
|
|
7836
8382
|
steps: plan.steps,
|
|
7837
8383
|
executed
|
|
7838
|
-
};
|
|
8384
|
+
});
|
|
7839
8385
|
recordSetupRun(plan.machineId, "completed", summary);
|
|
7840
8386
|
return summary;
|
|
7841
8387
|
}
|
|
7842
8388
|
|
|
7843
8389
|
// src/commands/sync.ts
|
|
7844
|
-
import { existsSync as
|
|
8390
|
+
import { existsSync as existsSync9, lstatSync, readFileSync as readFileSync7, symlinkSync, copyFileSync } from "fs";
|
|
7845
8391
|
import { homedir as homedir5 } from "os";
|
|
7846
8392
|
function quote4(value) {
|
|
7847
8393
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
@@ -7894,15 +8440,15 @@ function detectFileActions(machine) {
|
|
|
7894
8440
|
throw new Error(`Remote file sync planning is not supported for ${machine.id}; refusing to inspect or apply local paths as remote state.`);
|
|
7895
8441
|
}
|
|
7896
8442
|
return (machine.files || []).map((file, index) => {
|
|
7897
|
-
const sourceExists =
|
|
7898
|
-
const targetExists =
|
|
8443
|
+
const sourceExists = existsSync9(file.source);
|
|
8444
|
+
const targetExists = existsSync9(file.target);
|
|
7899
8445
|
let status = "missing";
|
|
7900
8446
|
if (sourceExists && targetExists) {
|
|
7901
8447
|
if (file.mode === "symlink") {
|
|
7902
8448
|
status = lstatSync(file.target).isSymbolicLink() ? "ok" : "drifted";
|
|
7903
8449
|
} else {
|
|
7904
|
-
const source =
|
|
7905
|
-
const target =
|
|
8450
|
+
const source = readFileSync7(file.source, "utf8");
|
|
8451
|
+
const target = readFileSync7(file.target, "utf8");
|
|
7906
8452
|
status = source === target ? "ok" : "drifted";
|
|
7907
8453
|
}
|
|
7908
8454
|
}
|
|
@@ -7932,12 +8478,12 @@ function buildSyncPlan(machineId, runner = runMachineCommand) {
|
|
|
7932
8478
|
...detectPackageActions(target, runner),
|
|
7933
8479
|
...detectFileActions(target)
|
|
7934
8480
|
];
|
|
7935
|
-
return {
|
|
8481
|
+
return attachMutationPlanDigest({
|
|
7936
8482
|
machineId: target.id,
|
|
7937
8483
|
mode: "plan",
|
|
7938
8484
|
actions,
|
|
7939
8485
|
executed: 0
|
|
7940
|
-
};
|
|
8486
|
+
});
|
|
7941
8487
|
}
|
|
7942
8488
|
function applyFileAction(command2) {
|
|
7943
8489
|
const [verb, source, target] = command2.split(" ");
|
|
@@ -7959,10 +8505,10 @@ function applyFileAction(command2) {
|
|
|
7959
8505
|
symlinkSync(sourcePath, targetPath);
|
|
7960
8506
|
}
|
|
7961
8507
|
}
|
|
7962
|
-
function
|
|
7963
|
-
|
|
8508
|
+
function runSyncPlan(plan, options = {}, runner = runMachineCommand) {
|
|
8509
|
+
assertMutationPlanDigest(plan, options.expectedPlanDigest);
|
|
7964
8510
|
if (!options.apply) {
|
|
7965
|
-
return plan;
|
|
8511
|
+
return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
|
|
7966
8512
|
}
|
|
7967
8513
|
if (!options.yes) {
|
|
7968
8514
|
throw new Error("Sync execution requires --yes.");
|
|
@@ -7989,12 +8535,12 @@ function runSync(machineId, options = {}, runner = runMachineCommand) {
|
|
|
7989
8535
|
}
|
|
7990
8536
|
executed += 1;
|
|
7991
8537
|
}
|
|
7992
|
-
const summary = {
|
|
8538
|
+
const summary = attachMutationPlanDigest({
|
|
7993
8539
|
machineId: plan.machineId,
|
|
7994
8540
|
mode: "apply",
|
|
7995
8541
|
actions: plan.actions,
|
|
7996
8542
|
executed
|
|
7997
|
-
};
|
|
8543
|
+
});
|
|
7998
8544
|
recordSyncRun(plan.machineId, "completed", summary);
|
|
7999
8545
|
return summary;
|
|
8000
8546
|
}
|
|
@@ -8007,7 +8553,7 @@ var DEFAULT_COMMANDS = [
|
|
|
8007
8553
|
function defaultPackages() {
|
|
8008
8554
|
return [{ name: "@hasna/machines", command: "machines", expectedVersion: getPackageVersion(), required: true }];
|
|
8009
8555
|
}
|
|
8010
|
-
function
|
|
8556
|
+
function shellQuote5(value) {
|
|
8011
8557
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
8012
8558
|
}
|
|
8013
8559
|
function commandId(value) {
|
|
@@ -8058,7 +8604,7 @@ function defaultRunner2(machineId, command2) {
|
|
|
8058
8604
|
return runMachineCommand(machineId, command2);
|
|
8059
8605
|
}
|
|
8060
8606
|
function inspectCommand(machineId, spec, runner) {
|
|
8061
|
-
const command2 =
|
|
8607
|
+
const command2 = shellQuote5(spec.command);
|
|
8062
8608
|
const versionArgs = spec.versionArgs ?? "--version";
|
|
8063
8609
|
const script = [
|
|
8064
8610
|
`cmd=${command2}`,
|
|
@@ -8087,7 +8633,7 @@ function fieldCommand(field) {
|
|
|
8087
8633
|
}
|
|
8088
8634
|
function inspectWorkspace(machineId, spec, runner) {
|
|
8089
8635
|
const script = [
|
|
8090
|
-
`path=${
|
|
8636
|
+
`path=${shellQuote5(spec.path)}`,
|
|
8091
8637
|
'printf "exists=%s\\n" "$(test -d "$path" && printf yes || printf no)"',
|
|
8092
8638
|
'pkg="$path/package.json"',
|
|
8093
8639
|
'printf "package_json=%s\\n" "$(test -f "$pkg" && printf yes || printf no)"',
|
|
@@ -8240,8 +8786,8 @@ function checkMachineCompatibility(options = {}) {
|
|
|
8240
8786
|
};
|
|
8241
8787
|
}
|
|
8242
8788
|
// src/mcp/server.ts
|
|
8243
|
-
function buildServer(version = getPackageVersion()) {
|
|
8244
|
-
return createMcpServer(version);
|
|
8789
|
+
function buildServer(version = getPackageVersion(), options = {}) {
|
|
8790
|
+
return createMcpServer(version, options);
|
|
8245
8791
|
}
|
|
8246
8792
|
function privateMetadataAllowed(requested) {
|
|
8247
8793
|
return requested === true && isPrivateOutputEnabled();
|
|
@@ -8255,9 +8801,50 @@ function appendWarnings(payload, warnings) {
|
|
|
8255
8801
|
const currentWarnings = typeof payload === "object" && payload && "warnings" in payload && Array.isArray(payload.warnings) ? payload.warnings : [];
|
|
8256
8802
|
return { ...payload, warnings: [...currentWarnings, ...warnings] };
|
|
8257
8803
|
}
|
|
8258
|
-
|
|
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 = {}) {
|
|
8259
8841
|
const server = new McpServer({ name: "machines", version });
|
|
8260
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
|
+
}
|
|
8261
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 }) => {
|
|
8262
8849
|
const privateMetadata = privateMetadataAllowed(private_metadata);
|
|
8263
8850
|
const warnings = privateOutputWarnings(private_metadata, privateMetadata);
|
|
@@ -8271,18 +8858,31 @@ function createMcpServer(version) {
|
|
|
8271
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) }] }));
|
|
8272
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) }] }));
|
|
8273
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) }] }));
|
|
8274
|
-
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 }) =>
|
|
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
|
+
});
|
|
8275
8871
|
server.tool("machines_manifest", "Read the current fleet manifest.", {}, async () => ({
|
|
8276
8872
|
content: [{ type: "text", text: JSON.stringify(manifestList(), null, 2) }]
|
|
8277
8873
|
}));
|
|
8278
8874
|
server.tool("machines_manifest_validate", "Validate the current fleet manifest.", {}, async () => ({
|
|
8279
8875
|
content: [{ type: "text", text: JSON.stringify(manifestValidate(), null, 2) }]
|
|
8280
8876
|
}));
|
|
8281
|
-
server.tool("machines_manifest_bootstrap", "Detect and upsert the current machine into the fleet manifest.", {}, async () =>
|
|
8282
|
-
|
|
8283
|
-
|
|
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
|
+
});
|
|
8284
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) }] }));
|
|
8285
|
-
server.tool("machines_manifest_remove", "Remove a single machine from the fleet manifest.", { machine_id: exports_external.string().describe("Machine identifier") }, async ({ machine_id }) =>
|
|
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
|
+
});
|
|
8286
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 }) => {
|
|
8287
8887
|
const privateMetadata = privateMetadataAllowed(private_metadata);
|
|
8288
8888
|
const warnings = privateOutputWarnings(private_metadata, privateMetadata);
|
|
@@ -8334,9 +8934,27 @@ function createMcpServer(version) {
|
|
|
8334
8934
|
}]
|
|
8335
8935
|
}));
|
|
8336
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) }] }));
|
|
8337
|
-
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 }) =>
|
|
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
|
+
});
|
|
8338
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) }] }));
|
|
8339
|
-
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 }) =>
|
|
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
|
+
});
|
|
8340
8958
|
server.tool("machines_topology", "Discover local, manifest, heartbeat, SSH, and Tailscale machine topology.", {
|
|
8341
8959
|
include_tailscale: exports_external.boolean().optional().describe("Whether to probe tailscale status --json"),
|
|
8342
8960
|
private_metadata: exports_external.boolean().optional().describe("Include private host/network route fields")
|
|
@@ -8391,12 +9009,31 @@ function createMcpServer(version) {
|
|
|
8391
9009
|
server.tool("machines_install_claude_apply", "Execute Claude, Codex, and Gemini CLI install steps for a machine.", {
|
|
8392
9010
|
machine_id: exports_external.string().optional().describe("Machine identifier"),
|
|
8393
9011
|
tools: exports_external.array(exports_external.enum(["claude", "codex", "gemini"])).optional().describe("AI CLIs to install"),
|
|
8394
|
-
yes: exports_external.boolean().describe("Confirmation flag for execution")
|
|
8395
|
-
|
|
8396
|
-
|
|
8397
|
-
|
|
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
|
+
});
|
|
8398
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) }] }));
|
|
8399
|
-
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 }) =>
|
|
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
|
+
});
|
|
8400
9037
|
server.tool("machines_route_resolve", "Resolve the best route for a machine using manifest, heartbeat, SSH, LAN, and Tailscale topology.", {
|
|
8401
9038
|
machine_id: exports_external.string().describe("Machine identifier"),
|
|
8402
9039
|
include_tailscale: exports_external.boolean().optional().describe("Whether to probe tailscale status --json"),
|
|
@@ -8464,41 +9101,72 @@ function createMcpServer(version) {
|
|
|
8464
9101
|
content: [{ type: "text", text: JSON.stringify(listPorts(machine_id), null, 2) }]
|
|
8465
9102
|
}));
|
|
8466
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) }] }));
|
|
8467
|
-
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 }) =>
|
|
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
|
+
});
|
|
8468
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) }] }));
|
|
8469
|
-
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 }) =>
|
|
8470
|
-
|
|
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
|
+
});
|
|
8471
9118
|
server.tool("machines_dns_list", "List local domain mappings.", {}, async () => ({ content: [{ type: "text", text: JSON.stringify(listDomainMappings(), null, 2) }] }));
|
|
8472
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) }] }));
|
|
8473
9120
|
server.tool("machines_notifications_add", "Add or replace a notification channel.", {
|
|
8474
9121
|
channel_id: exports_external.string().describe("Channel identifier"),
|
|
8475
9122
|
type: exports_external.enum(["email", "webhook", "command"]).describe("Notification transport"),
|
|
8476
|
-
target: exports_external.string().describe("Email, webhook URL, or
|
|
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"),
|
|
8477
9125
|
events: exports_external.array(exports_external.string()).describe("Events routed to this channel"),
|
|
8478
|
-
enabled: exports_external.boolean().optional().describe("Whether the channel is enabled")
|
|
8479
|
-
|
|
8480
|
-
|
|
8481
|
-
|
|
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
|
+
});
|
|
8482
9137
|
server.tool("machines_notifications_list", "List notification channels.", {}, async () => ({
|
|
8483
9138
|
content: [{ type: "text", text: JSON.stringify(listNotificationChannels(), null, 2) }]
|
|
8484
9139
|
}));
|
|
8485
|
-
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 }) =>
|
|
8486
|
-
|
|
8487
|
-
|
|
8488
|
-
|
|
8489
|
-
|
|
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
|
+
});
|
|
8490
9155
|
server.tool("machines_webhooks_add", "Add or replace a shared event webhook channel.", {
|
|
8491
9156
|
channel_id: exports_external.string().describe("Channel identifier"),
|
|
8492
9157
|
url: exports_external.string().url().describe("Webhook URL"),
|
|
8493
9158
|
event_type: exports_external.string().optional().describe("Optional event type filter, e.g. machines.*"),
|
|
8494
9159
|
source: exports_external.string().optional().describe("Optional source filter"),
|
|
8495
9160
|
secret: exports_external.string().optional().describe("Optional HMAC secret"),
|
|
8496
|
-
enabled: exports_external.boolean().optional().describe("Whether the channel is enabled")
|
|
8497
|
-
|
|
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 } });
|
|
8498
9166
|
const now = new Date().toISOString();
|
|
8499
9167
|
const channel = await events.addChannel({
|
|
8500
9168
|
id: channel_id,
|
|
8501
|
-
enabled:
|
|
9169
|
+
enabled: resolvedEnabled,
|
|
8502
9170
|
transport: "webhook",
|
|
8503
9171
|
filters: event_type || source ? [{ type: event_type, source }] : undefined,
|
|
8504
9172
|
webhook: { url, secret },
|
|
@@ -8510,10 +9178,16 @@ function createMcpServer(version) {
|
|
|
8510
9178
|
server.tool("machines_webhooks_list", "List shared event webhook channels.", {}, async () => ({
|
|
8511
9179
|
content: [{ type: "text", text: JSON.stringify(sanitizeChannelsForOutput2(await events.listChannels()), null, 2) }]
|
|
8512
9180
|
}));
|
|
8513
|
-
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 }) =>
|
|
8514
|
-
|
|
8515
|
-
|
|
8516
|
-
|
|
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
|
+
});
|
|
8517
9191
|
server.tool("machines_events_emit", "Emit a shared event from machines.", {
|
|
8518
9192
|
event_type: exports_external.string().describe("Event type"),
|
|
8519
9193
|
subject: exports_external.string().optional().describe("Event subject"),
|
|
@@ -8522,25 +9196,36 @@ function createMcpServer(version) {
|
|
|
8522
9196
|
data: exports_external.record(exports_external.unknown()).optional().describe("Event data"),
|
|
8523
9197
|
metadata: exports_external.record(exports_external.unknown()).optional().describe("Event metadata"),
|
|
8524
9198
|
dedupe_key: exports_external.string().optional().describe("Dedupe key"),
|
|
8525
|
-
deliver: exports_external.boolean().optional().describe("Deliver to matching channels")
|
|
8526
|
-
|
|
8527
|
-
|
|
8528
|
-
|
|
8529
|
-
|
|
8530
|
-
|
|
8531
|
-
|
|
8532
|
-
|
|
8533
|
-
|
|
8534
|
-
|
|
8535
|
-
|
|
8536
|
-
|
|
8537
|
-
|
|
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
|
+
});
|
|
8538
9219
|
server.tool("machines_events_list", "List shared events.", {}, async () => ({
|
|
8539
9220
|
content: [{ type: "text", text: JSON.stringify(await events.listEvents(), null, 2) }]
|
|
8540
9221
|
}));
|
|
8541
|
-
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 }) =>
|
|
8542
|
-
|
|
8543
|
-
|
|
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
|
+
});
|
|
8544
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) }] }));
|
|
8545
9230
|
server.tool("machines_serve_dashboard", "Render the current dashboard HTML.", {}, async () => ({
|
|
8546
9231
|
content: [{ type: "text", text: renderDashboardHtml() }]
|
|
@@ -8548,15 +9233,28 @@ function createMcpServer(version) {
|
|
|
8548
9233
|
server.tool("storage_status", "Show machines storage sync configuration and local sync history.", {}, async () => ({
|
|
8549
9234
|
content: [{ type: "text", text: JSON.stringify(getStorageStatus(), null, 2) }]
|
|
8550
9235
|
}));
|
|
8551
|
-
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")
|
|
8552
|
-
|
|
8553
|
-
|
|
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
|
+
});
|
|
8554
9251
|
return server;
|
|
8555
9252
|
}
|
|
8556
9253
|
|
|
8557
9254
|
// src/mcp/http.ts
|
|
8558
9255
|
var DEFAULT_HTTP_PORT = 8821;
|
|
8559
9256
|
var HTTP_NAME = "machines";
|
|
9257
|
+
var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
8560
9258
|
function isHttpMode(args = process.argv.slice(2)) {
|
|
8561
9259
|
return args.includes("--http") || process.env.MCP_HTTP === "1";
|
|
8562
9260
|
}
|
|
@@ -8586,28 +9284,156 @@ function parsePort(raw) {
|
|
|
8586
9284
|
function pathnameFromRequest(req) {
|
|
8587
9285
|
return new URL(req.url ?? "/", "http://127.0.0.1").pathname;
|
|
8588
9286
|
}
|
|
8589
|
-
|
|
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) {
|
|
8590
9406
|
if (req.method !== "POST" && req.method !== "DELETE") {
|
|
8591
9407
|
return;
|
|
8592
9408
|
}
|
|
8593
9409
|
const chunks = [];
|
|
9410
|
+
let size = 0;
|
|
8594
9411
|
for await (const chunk of req) {
|
|
8595
|
-
|
|
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);
|
|
8596
9418
|
}
|
|
8597
9419
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
8598
9420
|
if (!text) {
|
|
8599
9421
|
return;
|
|
8600
9422
|
}
|
|
8601
|
-
|
|
9423
|
+
try {
|
|
9424
|
+
return JSON.parse(text);
|
|
9425
|
+
} catch {
|
|
9426
|
+
throw new HttpRequestError(400, "Invalid JSON request body.");
|
|
9427
|
+
}
|
|
8602
9428
|
}
|
|
8603
|
-
async function handleMcpRequest(req, res) {
|
|
8604
|
-
const server = buildServer();
|
|
9429
|
+
async function handleMcpRequest(req, res, maxBodyBytes) {
|
|
9430
|
+
const server = buildServer(undefined, { mutationTransport: "mcp:http" });
|
|
8605
9431
|
const transport = new StreamableHTTPServerTransport({
|
|
8606
9432
|
sessionIdGenerator: undefined
|
|
8607
9433
|
});
|
|
8608
9434
|
await server.connect(transport);
|
|
8609
9435
|
try {
|
|
8610
|
-
const body = await readRequestBody(req);
|
|
9436
|
+
const body = await readRequestBody(req, maxBodyBytes);
|
|
8611
9437
|
await transport.handleRequest(req, res, body);
|
|
8612
9438
|
} finally {
|
|
8613
9439
|
res.on("close", () => {
|
|
@@ -8624,19 +9450,47 @@ function startHttpServer(options = {}) {
|
|
|
8624
9450
|
const host = options.host ?? "127.0.0.1";
|
|
8625
9451
|
const port = options.port ?? resolveHttpPort();
|
|
8626
9452
|
const name = options.name ?? HTTP_NAME;
|
|
9453
|
+
const security = options.security ?? resolveHttpSecurityConfig(process.env, host);
|
|
8627
9454
|
const httpServer = createServer(async (req, res) => {
|
|
8628
9455
|
const path = pathnameFromRequest(req);
|
|
8629
9456
|
if (req.method === "GET" && path === "/health") {
|
|
8630
|
-
res
|
|
8631
|
-
res.end(JSON.stringify({ status: "ok", name }));
|
|
9457
|
+
writeJson(res, 200, { status: "ok", name });
|
|
8632
9458
|
return;
|
|
8633
9459
|
}
|
|
8634
9460
|
if (path === "/mcp") {
|
|
8635
|
-
|
|
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
|
+
}
|
|
8636
9491
|
return;
|
|
8637
9492
|
}
|
|
8638
|
-
res
|
|
8639
|
-
res.end(JSON.stringify({ error: "Not found" }));
|
|
9493
|
+
writeJson(res, 404, { error: "Not found" });
|
|
8640
9494
|
});
|
|
8641
9495
|
httpServer.listen(port, host, () => {
|
|
8642
9496
|
const address = httpServer.address();
|