@hasna/machines 0.0.45 → 0.0.47
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 +53 -4
- package/dist/agent/index.d.ts +0 -1
- package/dist/agent/index.js +250 -15
- package/dist/agent/runtime.d.ts +0 -1
- package/dist/cli/index.d.ts +0 -1
- package/dist/cli/index.js +1659 -233
- 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/hosts.d.ts +81 -0
- 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 +1092 -185
- 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 +1004 -162
- 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,7 +6451,7 @@ function diffMachines(leftMachineId, rightMachineId) {
|
|
|
5954
6451
|
}
|
|
5955
6452
|
|
|
5956
6453
|
// src/commands/daemon.ts
|
|
5957
|
-
import { chmodSync, existsSync as
|
|
6454
|
+
import { chmodSync, existsSync as existsSync7, readFileSync as readFileSync5, statSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
5958
6455
|
import { delimiter, dirname as dirname4 } from "path";
|
|
5959
6456
|
import { platform as osPlatform } from "os";
|
|
5960
6457
|
var DEFAULT_SERVICE_NAME = "machines-agent";
|
|
@@ -6243,7 +6740,7 @@ function bunRuntimeCandidates(executable) {
|
|
|
6243
6740
|
}
|
|
6244
6741
|
function isBunShebangScript(executable) {
|
|
6245
6742
|
try {
|
|
6246
|
-
const content =
|
|
6743
|
+
const content = readFileSync5(executable, "utf8").slice(0, 256);
|
|
6247
6744
|
const firstLine = content.split(/\r?\n/, 1)[0] ?? "";
|
|
6248
6745
|
return /^#!.*\bbun\b/.test(firstLine);
|
|
6249
6746
|
} catch {
|
|
@@ -6251,7 +6748,7 @@ function isBunShebangScript(executable) {
|
|
|
6251
6748
|
}
|
|
6252
6749
|
}
|
|
6253
6750
|
function isExecutableFile(path) {
|
|
6254
|
-
if (!
|
|
6751
|
+
if (!existsSync7(path))
|
|
6255
6752
|
return false;
|
|
6256
6753
|
try {
|
|
6257
6754
|
const stats = statSync(path);
|
|
@@ -6541,12 +7038,12 @@ function parseProbe(tool, stdout) {
|
|
|
6541
7038
|
}
|
|
6542
7039
|
function buildClaudeInstallPlan(machineId, tools) {
|
|
6543
7040
|
const machine = resolveMachine2(machineId);
|
|
6544
|
-
return {
|
|
7041
|
+
return attachMutationPlanDigest({
|
|
6545
7042
|
machineId: machine.id,
|
|
6546
7043
|
mode: "plan",
|
|
6547
7044
|
steps: buildInstallSteps(machine, tools),
|
|
6548
7045
|
executed: 0
|
|
6549
|
-
};
|
|
7046
|
+
});
|
|
6550
7047
|
}
|
|
6551
7048
|
function getClaudeCliStatus(machineId, tools, runner = runMachineCommand) {
|
|
6552
7049
|
const machine = resolveMachine2(machineId);
|
|
@@ -6569,10 +7066,10 @@ function diffClaudeCli(machineId, tools, runner = runMachineCommand) {
|
|
|
6569
7066
|
installed: status.tools.filter((tool) => tool.installed).map((tool) => tool.tool)
|
|
6570
7067
|
};
|
|
6571
7068
|
}
|
|
6572
|
-
function
|
|
6573
|
-
|
|
7069
|
+
function runClaudeInstallPlan(plan, options = {}, runner = runMachineCommand) {
|
|
7070
|
+
assertMutationPlanDigest(plan, options.expectedPlanDigest);
|
|
6574
7071
|
if (!options.apply)
|
|
6575
|
-
return plan;
|
|
7072
|
+
return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
|
|
6576
7073
|
if (!options.yes) {
|
|
6577
7074
|
throw new Error("Claude CLI installation requires --yes.");
|
|
6578
7075
|
}
|
|
@@ -6581,12 +7078,12 @@ function runClaudeInstall(machineId, tools, options = {}, runner = runMachineCom
|
|
|
6581
7078
|
requireMachineCommandSuccess(`AI CLI install ${step.id}`, runner(plan.machineId, step.command));
|
|
6582
7079
|
executed += 1;
|
|
6583
7080
|
}
|
|
6584
|
-
return {
|
|
7081
|
+
return attachMutationPlanDigest({
|
|
6585
7082
|
machineId: plan.machineId,
|
|
6586
7083
|
mode: "apply",
|
|
6587
7084
|
steps: plan.steps,
|
|
6588
7085
|
executed
|
|
6589
|
-
};
|
|
7086
|
+
});
|
|
6590
7087
|
}
|
|
6591
7088
|
|
|
6592
7089
|
// src/commands/install-tailscale.ts
|
|
@@ -6626,17 +7123,17 @@ function buildTailscaleInstallPlan(machineId) {
|
|
|
6626
7123
|
if (!machine) {
|
|
6627
7124
|
throw new Error(`Machine not found in manifest: ${machineId}`);
|
|
6628
7125
|
}
|
|
6629
|
-
return {
|
|
7126
|
+
return attachMutationPlanDigest({
|
|
6630
7127
|
machineId: machine.id,
|
|
6631
7128
|
mode: "plan",
|
|
6632
7129
|
steps: buildInstallSteps2(machine),
|
|
6633
7130
|
executed: 0
|
|
6634
|
-
};
|
|
7131
|
+
});
|
|
6635
7132
|
}
|
|
6636
|
-
function
|
|
6637
|
-
|
|
7133
|
+
function runTailscaleInstallPlan(plan, options = {}, runner = runMachineCommand) {
|
|
7134
|
+
assertMutationPlanDigest(plan, options.expectedPlanDigest);
|
|
6638
7135
|
if (!options.apply)
|
|
6639
|
-
return plan;
|
|
7136
|
+
return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
|
|
6640
7137
|
if (!options.yes) {
|
|
6641
7138
|
throw new Error("Tailscale install requires --yes.");
|
|
6642
7139
|
}
|
|
@@ -6645,20 +7142,22 @@ function runTailscaleInstall(machineId, options = {}, runner = runMachineCommand
|
|
|
6645
7142
|
requireMachineCommandSuccess(`Tailscale install ${step.id}`, runner(plan.machineId, step.command));
|
|
6646
7143
|
executed += 1;
|
|
6647
7144
|
}
|
|
6648
|
-
return {
|
|
7145
|
+
return attachMutationPlanDigest({
|
|
6649
7146
|
machineId: plan.machineId,
|
|
6650
7147
|
mode: "apply",
|
|
6651
7148
|
steps: plan.steps,
|
|
6652
7149
|
executed
|
|
6653
|
-
};
|
|
7150
|
+
});
|
|
6654
7151
|
}
|
|
6655
7152
|
|
|
6656
7153
|
// src/commands/notifications.ts
|
|
6657
|
-
import { existsSync as
|
|
7154
|
+
import { accessSync, constants, existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
|
|
7155
|
+
import { delimiter as delimiter2, isAbsolute, join as join7 } from "path";
|
|
6658
7156
|
var notificationChannelSchema = exports_external.object({
|
|
6659
7157
|
id: exports_external.string(),
|
|
6660
7158
|
type: exports_external.enum(["email", "webhook", "command"]),
|
|
6661
7159
|
target: exports_external.string(),
|
|
7160
|
+
commandArgs: exports_external.array(exports_external.string()).optional(),
|
|
6662
7161
|
events: exports_external.array(exports_external.string()),
|
|
6663
7162
|
enabled: exports_external.boolean()
|
|
6664
7163
|
});
|
|
@@ -6667,19 +7166,31 @@ var notificationConfigSchema = exports_external.object({
|
|
|
6667
7166
|
updatedAt: exports_external.string().optional(),
|
|
6668
7167
|
channels: exports_external.array(notificationChannelSchema)
|
|
6669
7168
|
});
|
|
7169
|
+
var trustedNotificationApproval = Symbol("trustedNotificationApproval");
|
|
7170
|
+
function createTrustedNotificationApproval() {
|
|
7171
|
+
return { [trustedNotificationApproval]: true };
|
|
7172
|
+
}
|
|
7173
|
+
function isTrustedNotificationApproval(approval) {
|
|
7174
|
+
return approval?.[trustedNotificationApproval] === true;
|
|
7175
|
+
}
|
|
6670
7176
|
function sortChannels(channels) {
|
|
6671
7177
|
return [...channels].sort((left, right) => left.id.localeCompare(right.id));
|
|
6672
7178
|
}
|
|
6673
|
-
function shellQuote5(value) {
|
|
6674
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
6675
|
-
}
|
|
6676
7179
|
function hasCommand2(binary) {
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
|
|
6682
|
-
|
|
7180
|
+
return Boolean(resolveExecutable(binary));
|
|
7181
|
+
}
|
|
7182
|
+
function resolveExecutable(binary) {
|
|
7183
|
+
const trimmed = binary.trim();
|
|
7184
|
+
if (!trimmed)
|
|
7185
|
+
return null;
|
|
7186
|
+
const candidates = isAbsolute(trimmed) ? [trimmed] : (process.env.PATH ?? "").split(delimiter2).filter(Boolean).map((dir) => join7(dir, trimmed));
|
|
7187
|
+
for (const candidate of candidates) {
|
|
7188
|
+
try {
|
|
7189
|
+
accessSync(candidate, constants.X_OK);
|
|
7190
|
+
return candidate;
|
|
7191
|
+
} catch {}
|
|
7192
|
+
}
|
|
7193
|
+
return null;
|
|
6683
7194
|
}
|
|
6684
7195
|
function buildNotificationPreview(channel, event, message) {
|
|
6685
7196
|
if (channel.type === "email") {
|
|
@@ -6688,7 +7199,8 @@ function buildNotificationPreview(channel, event, message) {
|
|
|
6688
7199
|
if (channel.type === "webhook") {
|
|
6689
7200
|
return `POST ${channel.target} with payload {"event":"${event}","message":"${message}"}`;
|
|
6690
7201
|
}
|
|
6691
|
-
|
|
7202
|
+
const args = channel.commandArgs?.length ? ` ${channel.commandArgs.join(" ")}` : "";
|
|
7203
|
+
return `${channel.target}${args} with HASNA_MACHINES_NOTIFICATION_* environment`;
|
|
6692
7204
|
}
|
|
6693
7205
|
async function dispatchEmail(channel, event, message) {
|
|
6694
7206
|
const subject = `[${event}] machines notification`;
|
|
@@ -6699,7 +7211,7 @@ Content-Type: text/plain; charset=utf-8
|
|
|
6699
7211
|
${message}
|
|
6700
7212
|
`;
|
|
6701
7213
|
if (hasCommand2("sendmail")) {
|
|
6702
|
-
const result = Bun.spawnSync(["
|
|
7214
|
+
const result = Bun.spawnSync(["sendmail", "-t"], {
|
|
6703
7215
|
stdin: new TextEncoder().encode(body),
|
|
6704
7216
|
stdout: "pipe",
|
|
6705
7217
|
stderr: "pipe",
|
|
@@ -6717,8 +7229,9 @@ ${message}
|
|
|
6717
7229
|
};
|
|
6718
7230
|
}
|
|
6719
7231
|
if (hasCommand2("mail")) {
|
|
6720
|
-
const
|
|
6721
|
-
|
|
7232
|
+
const result = Bun.spawnSync(["mail", "-s", subject, channel.target], {
|
|
7233
|
+
stdin: new TextEncoder().encode(`${message}
|
|
7234
|
+
`),
|
|
6722
7235
|
stdout: "pipe",
|
|
6723
7236
|
stderr: "pipe",
|
|
6724
7237
|
env: process.env
|
|
@@ -6761,8 +7274,20 @@ async function dispatchWebhook(channel, event, message) {
|
|
|
6761
7274
|
detail: `Webhook accepted with HTTP ${response.status}`
|
|
6762
7275
|
};
|
|
6763
7276
|
}
|
|
6764
|
-
async function dispatchCommand(channel, event, message) {
|
|
6765
|
-
|
|
7277
|
+
async function dispatchCommand(channel, event, message, options = {}) {
|
|
7278
|
+
if (!isTrustedNotificationApproval(options.trustedApproval)) {
|
|
7279
|
+
assertMutationApproved({
|
|
7280
|
+
surface: "notifications",
|
|
7281
|
+
operation: "dispatch_command",
|
|
7282
|
+
resourceId: channel.id,
|
|
7283
|
+
approvalToken: options.approvalToken
|
|
7284
|
+
});
|
|
7285
|
+
}
|
|
7286
|
+
const executable = resolveExecutable(channel.target);
|
|
7287
|
+
if (!executable) {
|
|
7288
|
+
throw new Error(`Command executable not found or not executable: ${channel.target}`);
|
|
7289
|
+
}
|
|
7290
|
+
const result = Bun.spawnSync([executable, ...channel.commandArgs ?? []], {
|
|
6766
7291
|
stdout: "pipe",
|
|
6767
7292
|
stderr: "pipe",
|
|
6768
7293
|
env: {
|
|
@@ -6784,7 +7309,7 @@ async function dispatchCommand(channel, event, message) {
|
|
|
6784
7309
|
detail: stdout || "Command completed successfully"
|
|
6785
7310
|
};
|
|
6786
7311
|
}
|
|
6787
|
-
async function dispatchChannel(channel, event, message) {
|
|
7312
|
+
async function dispatchChannel(channel, event, message, options = {}) {
|
|
6788
7313
|
if (!channel.enabled) {
|
|
6789
7314
|
return {
|
|
6790
7315
|
channelId: channel.id,
|
|
@@ -6800,7 +7325,7 @@ async function dispatchChannel(channel, event, message) {
|
|
|
6800
7325
|
if (channel.type === "webhook") {
|
|
6801
7326
|
return dispatchWebhook(channel, event, message);
|
|
6802
7327
|
}
|
|
6803
|
-
return dispatchCommand(channel, event, message);
|
|
7328
|
+
return dispatchCommand(channel, event, message, options);
|
|
6804
7329
|
}
|
|
6805
7330
|
function getDefaultNotificationConfig() {
|
|
6806
7331
|
return {
|
|
@@ -6810,10 +7335,10 @@ function getDefaultNotificationConfig() {
|
|
|
6810
7335
|
};
|
|
6811
7336
|
}
|
|
6812
7337
|
function readNotificationConfig(path = getNotificationsPath()) {
|
|
6813
|
-
if (!
|
|
7338
|
+
if (!existsSync8(path)) {
|
|
6814
7339
|
return getDefaultNotificationConfig();
|
|
6815
7340
|
}
|
|
6816
|
-
return notificationConfigSchema.parse(JSON.parse(
|
|
7341
|
+
return notificationConfigSchema.parse(JSON.parse(readFileSync6(path, "utf8")));
|
|
6817
7342
|
}
|
|
6818
7343
|
function writeNotificationConfig(config, path = getNotificationsPath()) {
|
|
6819
7344
|
ensureParentDir(path);
|
|
@@ -6829,11 +7354,20 @@ function writeNotificationConfig(config, path = getNotificationsPath()) {
|
|
|
6829
7354
|
function listNotificationChannels() {
|
|
6830
7355
|
return readNotificationConfig();
|
|
6831
7356
|
}
|
|
6832
|
-
function addNotificationChannel(channel) {
|
|
7357
|
+
function addNotificationChannel(channel, options = {}) {
|
|
7358
|
+
if (channel.type === "command" && !isTrustedNotificationApproval(options.trustedApproval)) {
|
|
7359
|
+
assertMutationApproved({
|
|
7360
|
+
surface: "notifications",
|
|
7361
|
+
operation: "add_command_channel",
|
|
7362
|
+
resourceId: channel.id,
|
|
7363
|
+
approvalToken: options.approvalToken
|
|
7364
|
+
});
|
|
7365
|
+
}
|
|
6833
7366
|
const config = readNotificationConfig();
|
|
6834
7367
|
const channels = config.channels.filter((entry) => entry.id !== channel.id);
|
|
6835
7368
|
channels.push({
|
|
6836
7369
|
...channel,
|
|
7370
|
+
commandArgs: channel.commandArgs?.map(String),
|
|
6837
7371
|
events: [...new Set(channel.events)]
|
|
6838
7372
|
});
|
|
6839
7373
|
return writeNotificationConfig({ ...config, channels });
|
|
@@ -6855,7 +7389,7 @@ async function dispatchNotificationEvent(event, message, options = {}) {
|
|
|
6855
7389
|
const deliveries = [];
|
|
6856
7390
|
for (const channel of channels) {
|
|
6857
7391
|
try {
|
|
6858
|
-
deliveries.push(await dispatchChannel(channel, event, message));
|
|
7392
|
+
deliveries.push(await dispatchChannel(channel, event, message, { approvalToken: options.approvalToken, trustedApproval: options.trustedApproval }));
|
|
6859
7393
|
} catch (error) {
|
|
6860
7394
|
deliveries.push({
|
|
6861
7395
|
channelId: channel.id,
|
|
@@ -6890,7 +7424,7 @@ async function testNotificationChannel(channelId, event = "manual.test", message
|
|
|
6890
7424
|
if (!options.yes) {
|
|
6891
7425
|
throw new Error("Notification test execution requires --yes.");
|
|
6892
7426
|
}
|
|
6893
|
-
const delivery = await dispatchChannel(channel, event, message);
|
|
7427
|
+
const delivery = await dispatchChannel(channel, event, message, { approvalToken: options.approvalToken, trustedApproval: options.trustedApproval });
|
|
6894
7428
|
return {
|
|
6895
7429
|
channelId,
|
|
6896
7430
|
mode: "apply",
|
|
@@ -6955,7 +7489,7 @@ function listPorts(machineId) {
|
|
|
6955
7489
|
}
|
|
6956
7490
|
|
|
6957
7491
|
// src/commands/serve.ts
|
|
6958
|
-
import { EventsClient, sanitizeChannelsForOutput } from "@hasna/events";
|
|
7492
|
+
import { EventsClient, getEventsDataDir, sanitizeChannelsForOutput } from "@hasna/events";
|
|
6959
7493
|
|
|
6960
7494
|
// src/agent/runtime.ts
|
|
6961
7495
|
import { arch as arch3, hostname as hostname6, platform as platform4, release, uptime, version as osVersion } from "os";
|
|
@@ -7514,7 +8048,7 @@ function escapeHtml(value) {
|
|
|
7514
8048
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
7515
8049
|
}
|
|
7516
8050
|
function getServeInfo(options = {}) {
|
|
7517
|
-
const host = options.host || "
|
|
8051
|
+
const host = options.host || "127.0.0.1";
|
|
7518
8052
|
const port = options.port || 7676;
|
|
7519
8053
|
return {
|
|
7520
8054
|
host,
|
|
@@ -7811,17 +8345,17 @@ function buildSetupPlan(machineId) {
|
|
|
7811
8345
|
workspacePath: `${homedir4()}/workspace`
|
|
7812
8346
|
};
|
|
7813
8347
|
const steps = [...buildBaseSteps(target), ...buildPackageSteps(target)];
|
|
7814
|
-
return {
|
|
8348
|
+
return attachMutationPlanDigest({
|
|
7815
8349
|
machineId: target.id,
|
|
7816
8350
|
mode: "plan",
|
|
7817
8351
|
steps,
|
|
7818
8352
|
executed: 0
|
|
7819
|
-
};
|
|
8353
|
+
});
|
|
7820
8354
|
}
|
|
7821
|
-
function
|
|
7822
|
-
|
|
8355
|
+
function runSetupPlan(plan, options = {}, runner = runMachineCommand) {
|
|
8356
|
+
assertMutationPlanDigest(plan, options.expectedPlanDigest);
|
|
7823
8357
|
if (!options.apply) {
|
|
7824
|
-
return plan;
|
|
8358
|
+
return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
|
|
7825
8359
|
}
|
|
7826
8360
|
if (!options.yes) {
|
|
7827
8361
|
throw new Error("Setup execution requires --yes.");
|
|
@@ -7842,18 +8376,18 @@ function runSetup(machineId, options = {}, runner = runMachineCommand) {
|
|
|
7842
8376
|
}
|
|
7843
8377
|
executed += 1;
|
|
7844
8378
|
}
|
|
7845
|
-
const summary = {
|
|
8379
|
+
const summary = attachMutationPlanDigest({
|
|
7846
8380
|
machineId: plan.machineId,
|
|
7847
8381
|
mode: "apply",
|
|
7848
8382
|
steps: plan.steps,
|
|
7849
8383
|
executed
|
|
7850
|
-
};
|
|
8384
|
+
});
|
|
7851
8385
|
recordSetupRun(plan.machineId, "completed", summary);
|
|
7852
8386
|
return summary;
|
|
7853
8387
|
}
|
|
7854
8388
|
|
|
7855
8389
|
// src/commands/sync.ts
|
|
7856
|
-
import { existsSync as
|
|
8390
|
+
import { existsSync as existsSync9, lstatSync, readFileSync as readFileSync7, symlinkSync, copyFileSync } from "fs";
|
|
7857
8391
|
import { homedir as homedir5 } from "os";
|
|
7858
8392
|
function quote4(value) {
|
|
7859
8393
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
@@ -7906,15 +8440,15 @@ function detectFileActions(machine) {
|
|
|
7906
8440
|
throw new Error(`Remote file sync planning is not supported for ${machine.id}; refusing to inspect or apply local paths as remote state.`);
|
|
7907
8441
|
}
|
|
7908
8442
|
return (machine.files || []).map((file, index) => {
|
|
7909
|
-
const sourceExists =
|
|
7910
|
-
const targetExists =
|
|
8443
|
+
const sourceExists = existsSync9(file.source);
|
|
8444
|
+
const targetExists = existsSync9(file.target);
|
|
7911
8445
|
let status = "missing";
|
|
7912
8446
|
if (sourceExists && targetExists) {
|
|
7913
8447
|
if (file.mode === "symlink") {
|
|
7914
8448
|
status = lstatSync(file.target).isSymbolicLink() ? "ok" : "drifted";
|
|
7915
8449
|
} else {
|
|
7916
|
-
const source =
|
|
7917
|
-
const target =
|
|
8450
|
+
const source = readFileSync7(file.source, "utf8");
|
|
8451
|
+
const target = readFileSync7(file.target, "utf8");
|
|
7918
8452
|
status = source === target ? "ok" : "drifted";
|
|
7919
8453
|
}
|
|
7920
8454
|
}
|
|
@@ -7944,12 +8478,12 @@ function buildSyncPlan(machineId, runner = runMachineCommand) {
|
|
|
7944
8478
|
...detectPackageActions(target, runner),
|
|
7945
8479
|
...detectFileActions(target)
|
|
7946
8480
|
];
|
|
7947
|
-
return {
|
|
8481
|
+
return attachMutationPlanDigest({
|
|
7948
8482
|
machineId: target.id,
|
|
7949
8483
|
mode: "plan",
|
|
7950
8484
|
actions,
|
|
7951
8485
|
executed: 0
|
|
7952
|
-
};
|
|
8486
|
+
});
|
|
7953
8487
|
}
|
|
7954
8488
|
function applyFileAction(command2) {
|
|
7955
8489
|
const [verb, source, target] = command2.split(" ");
|
|
@@ -7971,10 +8505,10 @@ function applyFileAction(command2) {
|
|
|
7971
8505
|
symlinkSync(sourcePath, targetPath);
|
|
7972
8506
|
}
|
|
7973
8507
|
}
|
|
7974
|
-
function
|
|
7975
|
-
|
|
8508
|
+
function runSyncPlan(plan, options = {}, runner = runMachineCommand) {
|
|
8509
|
+
assertMutationPlanDigest(plan, options.expectedPlanDigest);
|
|
7976
8510
|
if (!options.apply) {
|
|
7977
|
-
return plan;
|
|
8511
|
+
return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
|
|
7978
8512
|
}
|
|
7979
8513
|
if (!options.yes) {
|
|
7980
8514
|
throw new Error("Sync execution requires --yes.");
|
|
@@ -8001,12 +8535,12 @@ function runSync(machineId, options = {}, runner = runMachineCommand) {
|
|
|
8001
8535
|
}
|
|
8002
8536
|
executed += 1;
|
|
8003
8537
|
}
|
|
8004
|
-
const summary = {
|
|
8538
|
+
const summary = attachMutationPlanDigest({
|
|
8005
8539
|
machineId: plan.machineId,
|
|
8006
8540
|
mode: "apply",
|
|
8007
8541
|
actions: plan.actions,
|
|
8008
8542
|
executed
|
|
8009
|
-
};
|
|
8543
|
+
});
|
|
8010
8544
|
recordSyncRun(plan.machineId, "completed", summary);
|
|
8011
8545
|
return summary;
|
|
8012
8546
|
}
|
|
@@ -8019,7 +8553,7 @@ var DEFAULT_COMMANDS = [
|
|
|
8019
8553
|
function defaultPackages() {
|
|
8020
8554
|
return [{ name: "@hasna/machines", command: "machines", expectedVersion: getPackageVersion(), required: true }];
|
|
8021
8555
|
}
|
|
8022
|
-
function
|
|
8556
|
+
function shellQuote5(value) {
|
|
8023
8557
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
8024
8558
|
}
|
|
8025
8559
|
function commandId(value) {
|
|
@@ -8070,7 +8604,7 @@ function defaultRunner2(machineId, command2) {
|
|
|
8070
8604
|
return runMachineCommand(machineId, command2);
|
|
8071
8605
|
}
|
|
8072
8606
|
function inspectCommand(machineId, spec, runner) {
|
|
8073
|
-
const command2 =
|
|
8607
|
+
const command2 = shellQuote5(spec.command);
|
|
8074
8608
|
const versionArgs = spec.versionArgs ?? "--version";
|
|
8075
8609
|
const script = [
|
|
8076
8610
|
`cmd=${command2}`,
|
|
@@ -8099,7 +8633,7 @@ function fieldCommand(field) {
|
|
|
8099
8633
|
}
|
|
8100
8634
|
function inspectWorkspace(machineId, spec, runner) {
|
|
8101
8635
|
const script = [
|
|
8102
|
-
`path=${
|
|
8636
|
+
`path=${shellQuote5(spec.path)}`,
|
|
8103
8637
|
'printf "exists=%s\\n" "$(test -d "$path" && printf yes || printf no)"',
|
|
8104
8638
|
'pkg="$path/package.json"',
|
|
8105
8639
|
'printf "package_json=%s\\n" "$(test -f "$pkg" && printf yes || printf no)"',
|
|
@@ -8252,8 +8786,8 @@ function checkMachineCompatibility(options = {}) {
|
|
|
8252
8786
|
};
|
|
8253
8787
|
}
|
|
8254
8788
|
// src/mcp/server.ts
|
|
8255
|
-
function buildServer(version = getPackageVersion()) {
|
|
8256
|
-
return createMcpServer(version);
|
|
8789
|
+
function buildServer(version = getPackageVersion(), options = {}) {
|
|
8790
|
+
return createMcpServer(version, options);
|
|
8257
8791
|
}
|
|
8258
8792
|
function privateMetadataAllowed(requested) {
|
|
8259
8793
|
return requested === true && isPrivateOutputEnabled();
|
|
@@ -8267,9 +8801,50 @@ function appendWarnings(payload, warnings) {
|
|
|
8267
8801
|
const currentWarnings = typeof payload === "object" && payload && "warnings" in payload && Array.isArray(payload.warnings) ? payload.warnings : [];
|
|
8268
8802
|
return { ...payload, warnings: [...currentWarnings, ...warnings] };
|
|
8269
8803
|
}
|
|
8270
|
-
|
|
8804
|
+
var approvalTokenSchema = exports_external.string().optional().describe("Operator mutation approval token");
|
|
8805
|
+
function mutationMachineId(machineId) {
|
|
8806
|
+
return machineId?.trim() || "local";
|
|
8807
|
+
}
|
|
8808
|
+
function mutationResourceId(kind, ...parts) {
|
|
8809
|
+
const values = parts.map((part) => String(part ?? "*").trim()).filter(Boolean).join(":");
|
|
8810
|
+
return values ? `${kind}:${values}` : kind;
|
|
8811
|
+
}
|
|
8812
|
+
function mutationCallerId() {
|
|
8813
|
+
return process.env["HASNA_MACHINES_MUTATION_CALLER_ID"]?.trim() || "mcp";
|
|
8814
|
+
}
|
|
8815
|
+
function mutationRunId() {
|
|
8816
|
+
return process.env["HASNA_MACHINES_MUTATION_RUN_ID"]?.trim() || "mcp";
|
|
8817
|
+
}
|
|
8818
|
+
function assertScopedMcpMutation(operation, approvalToken, scope = {}, transport) {
|
|
8819
|
+
assertMutationApproved({
|
|
8820
|
+
surface: "mcp",
|
|
8821
|
+
operation,
|
|
8822
|
+
transport,
|
|
8823
|
+
callerId: mutationCallerId(),
|
|
8824
|
+
runId: mutationRunId(),
|
|
8825
|
+
machineId: scope.machineId === undefined ? undefined : mutationMachineId(scope.machineId),
|
|
8826
|
+
resourceId: scope.resourceId === undefined || scope.resourceId === null ? undefined : scope.resourceId,
|
|
8827
|
+
args: scope.args,
|
|
8828
|
+
approvalToken
|
|
8829
|
+
});
|
|
8830
|
+
}
|
|
8831
|
+
function mcpPlanApprovalArgs(args, plan) {
|
|
8832
|
+
return {
|
|
8833
|
+
...args,
|
|
8834
|
+
plan_digest: mutationPlanDigest(plan)
|
|
8835
|
+
};
|
|
8836
|
+
}
|
|
8837
|
+
function mcpPlanResourceId(operation, machineId, plan) {
|
|
8838
|
+
return mutationResourceId("plan", operation, machineId, mutationPlanDigest(plan));
|
|
8839
|
+
}
|
|
8840
|
+
function createMcpServer(version, options = {}) {
|
|
8271
8841
|
const server = new McpServer({ name: "machines", version });
|
|
8272
8842
|
const events = new EventsClient2;
|
|
8843
|
+
const trustedNotificationApproval2 = createTrustedNotificationApproval();
|
|
8844
|
+
const mutationTransport = options.mutationTransport ?? "mcp:stdio";
|
|
8845
|
+
function requireMcpMutation(operation, approvalToken, scope = {}) {
|
|
8846
|
+
assertScopedMcpMutation(operation, approvalToken, scope, mutationTransport);
|
|
8847
|
+
}
|
|
8273
8848
|
server.tool("machines_status", "Return local machine fleet status paths and machine identity.", { private_metadata: exports_external.boolean().optional().describe("Include private local paths and machine identifiers") }, async ({ private_metadata }) => {
|
|
8274
8849
|
const privateMetadata = privateMetadataAllowed(private_metadata);
|
|
8275
8850
|
const warnings = privateOutputWarnings(private_metadata, privateMetadata);
|
|
@@ -8283,18 +8858,31 @@ function createMcpServer(version) {
|
|
|
8283
8858
|
server.tool("machines_apps_status", "Check installed state for manifest-managed apps.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(getAppsStatus(machine_id), null, 2) }] }));
|
|
8284
8859
|
server.tool("machines_apps_diff", "Show missing and installed manifest-managed apps.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(diffApps(machine_id), null, 2) }] }));
|
|
8285
8860
|
server.tool("machines_apps_plan", "Preview app install steps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(buildAppsPlan(machine_id), null, 2) }] }));
|
|
8286
|
-
server.tool("machines_apps_apply", "Install manifest-managed apps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ machine_id, yes }) =>
|
|
8861
|
+
server.tool("machines_apps_apply", "Install manifest-managed apps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution"), approval_token: approvalTokenSchema }, async ({ machine_id, yes, approval_token }) => {
|
|
8862
|
+
const resolvedMachineId = mutationMachineId(machine_id);
|
|
8863
|
+
const plan = buildAppsPlan(machine_id);
|
|
8864
|
+
requireMcpMutation("machines_apps_apply", approval_token, {
|
|
8865
|
+
machineId: resolvedMachineId,
|
|
8866
|
+
resourceId: mcpPlanResourceId("machines_apps_apply", resolvedMachineId, plan),
|
|
8867
|
+
args: mcpPlanApprovalArgs({ machine_id: resolvedMachineId, yes }, plan)
|
|
8868
|
+
});
|
|
8869
|
+
return { content: [{ type: "text", text: JSON.stringify(runAppsPlan(plan, { apply: true, yes }), null, 2) }] };
|
|
8870
|
+
});
|
|
8287
8871
|
server.tool("machines_manifest", "Read the current fleet manifest.", {}, async () => ({
|
|
8288
8872
|
content: [{ type: "text", text: JSON.stringify(manifestList(), null, 2) }]
|
|
8289
8873
|
}));
|
|
8290
8874
|
server.tool("machines_manifest_validate", "Validate the current fleet manifest.", {}, async () => ({
|
|
8291
8875
|
content: [{ type: "text", text: JSON.stringify(manifestValidate(), null, 2) }]
|
|
8292
8876
|
}));
|
|
8293
|
-
server.tool("machines_manifest_bootstrap", "Detect and upsert the current machine into the fleet manifest.", {}, async () =>
|
|
8294
|
-
|
|
8295
|
-
|
|
8877
|
+
server.tool("machines_manifest_bootstrap", "Detect and upsert the current machine into the fleet manifest.", { approval_token: approvalTokenSchema }, async ({ approval_token }) => {
|
|
8878
|
+
requireMcpMutation("machines_manifest_bootstrap", approval_token, { resourceId: "manifest:bootstrap", args: {} });
|
|
8879
|
+
return { content: [{ type: "text", text: JSON.stringify(manifestBootstrapCurrentMachine(), null, 2) }] };
|
|
8880
|
+
});
|
|
8296
8881
|
server.tool("machines_manifest_get", "Read a single machine from the fleet manifest.", { machine_id: exports_external.string().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(manifestGet(machine_id), null, 2) }] }));
|
|
8297
|
-
server.tool("machines_manifest_remove", "Remove a single machine from the fleet manifest.", { machine_id: exports_external.string().describe("Machine identifier") }, async ({ machine_id }) =>
|
|
8882
|
+
server.tool("machines_manifest_remove", "Remove a single machine from the fleet manifest.", { machine_id: exports_external.string().describe("Machine identifier"), approval_token: approvalTokenSchema }, async ({ machine_id, approval_token }) => {
|
|
8883
|
+
requireMcpMutation("machines_manifest_remove", approval_token, { machineId: machine_id, args: { machine_id } });
|
|
8884
|
+
return { content: [{ type: "text", text: JSON.stringify(manifestRemove(machine_id), null, 2) }] };
|
|
8885
|
+
});
|
|
8298
8886
|
server.tool("machines_agent_status", "List current machine agent heartbeats.", { private_metadata: exports_external.boolean().optional().describe("Include private heartbeat metadata") }, async ({ private_metadata }) => {
|
|
8299
8887
|
const privateMetadata = privateMetadataAllowed(private_metadata);
|
|
8300
8888
|
const warnings = privateOutputWarnings(private_metadata, privateMetadata);
|
|
@@ -8346,9 +8934,27 @@ function createMcpServer(version) {
|
|
|
8346
8934
|
}]
|
|
8347
8935
|
}));
|
|
8348
8936
|
server.tool("machines_setup_preview", "Preview setup actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(buildSetupPlan(machine_id), null, 2) }] }));
|
|
8349
|
-
server.tool("machines_setup_apply", "Execute setup actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ machine_id, yes }) =>
|
|
8937
|
+
server.tool("machines_setup_apply", "Execute setup actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution"), approval_token: approvalTokenSchema }, async ({ machine_id, yes, approval_token }) => {
|
|
8938
|
+
const resolvedMachineId = mutationMachineId(machine_id);
|
|
8939
|
+
const plan = buildSetupPlan(machine_id);
|
|
8940
|
+
requireMcpMutation("machines_setup_apply", approval_token, {
|
|
8941
|
+
machineId: resolvedMachineId,
|
|
8942
|
+
resourceId: mcpPlanResourceId("machines_setup_apply", resolvedMachineId, plan),
|
|
8943
|
+
args: mcpPlanApprovalArgs({ machine_id: resolvedMachineId, yes }, plan)
|
|
8944
|
+
});
|
|
8945
|
+
return { content: [{ type: "text", text: JSON.stringify(runSetupPlan(plan, { apply: true, yes }), null, 2) }] };
|
|
8946
|
+
});
|
|
8350
8947
|
server.tool("machines_sync_preview", "Preview sync actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(buildSyncPlan(machine_id), null, 2) }] }));
|
|
8351
|
-
server.tool("machines_sync_apply", "Execute sync actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ machine_id, yes }) =>
|
|
8948
|
+
server.tool("machines_sync_apply", "Execute sync actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution"), approval_token: approvalTokenSchema }, async ({ machine_id, yes, approval_token }) => {
|
|
8949
|
+
const resolvedMachineId = mutationMachineId(machine_id);
|
|
8950
|
+
const plan = buildSyncPlan(machine_id);
|
|
8951
|
+
requireMcpMutation("machines_sync_apply", approval_token, {
|
|
8952
|
+
machineId: resolvedMachineId,
|
|
8953
|
+
resourceId: mcpPlanResourceId("machines_sync_apply", resolvedMachineId, plan),
|
|
8954
|
+
args: mcpPlanApprovalArgs({ machine_id: resolvedMachineId, yes }, plan)
|
|
8955
|
+
});
|
|
8956
|
+
return { content: [{ type: "text", text: JSON.stringify(runSyncPlan(plan, { apply: true, yes }), null, 2) }] };
|
|
8957
|
+
});
|
|
8352
8958
|
server.tool("machines_topology", "Discover local, manifest, heartbeat, SSH, and Tailscale machine topology.", {
|
|
8353
8959
|
include_tailscale: exports_external.boolean().optional().describe("Whether to probe tailscale status --json"),
|
|
8354
8960
|
private_metadata: exports_external.boolean().optional().describe("Include private host/network route fields")
|
|
@@ -8403,12 +9009,31 @@ function createMcpServer(version) {
|
|
|
8403
9009
|
server.tool("machines_install_claude_apply", "Execute Claude, Codex, and Gemini CLI install steps for a machine.", {
|
|
8404
9010
|
machine_id: exports_external.string().optional().describe("Machine identifier"),
|
|
8405
9011
|
tools: exports_external.array(exports_external.enum(["claude", "codex", "gemini"])).optional().describe("AI CLIs to install"),
|
|
8406
|
-
yes: exports_external.boolean().describe("Confirmation flag for execution")
|
|
8407
|
-
|
|
8408
|
-
|
|
8409
|
-
|
|
9012
|
+
yes: exports_external.boolean().describe("Confirmation flag for execution"),
|
|
9013
|
+
approval_token: approvalTokenSchema
|
|
9014
|
+
}, async ({ machine_id, tools, yes, approval_token }) => {
|
|
9015
|
+
const resolvedMachineId = mutationMachineId(machine_id);
|
|
9016
|
+
const plan = buildClaudeInstallPlan(machine_id, tools);
|
|
9017
|
+
requireMcpMutation("machines_install_claude_apply", approval_token, {
|
|
9018
|
+
machineId: resolvedMachineId,
|
|
9019
|
+
resourceId: mcpPlanResourceId("machines_install_claude_apply", resolvedMachineId, plan),
|
|
9020
|
+
args: mcpPlanApprovalArgs({ machine_id: resolvedMachineId, tools, yes }, plan)
|
|
9021
|
+
});
|
|
9022
|
+
return {
|
|
9023
|
+
content: [{ type: "text", text: JSON.stringify(runClaudeInstallPlan(plan, { apply: true, yes }), null, 2) }]
|
|
9024
|
+
};
|
|
9025
|
+
});
|
|
8410
9026
|
server.tool("machines_install_tailscale_preview", "Preview Tailscale install steps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(buildTailscaleInstallPlan(machine_id), null, 2) }] }));
|
|
8411
|
-
server.tool("machines_install_tailscale_apply", "Execute Tailscale install steps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ machine_id, yes }) =>
|
|
9027
|
+
server.tool("machines_install_tailscale_apply", "Execute Tailscale install steps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution"), approval_token: approvalTokenSchema }, async ({ machine_id, yes, approval_token }) => {
|
|
9028
|
+
const resolvedMachineId = mutationMachineId(machine_id);
|
|
9029
|
+
const plan = buildTailscaleInstallPlan(machine_id);
|
|
9030
|
+
requireMcpMutation("machines_install_tailscale_apply", approval_token, {
|
|
9031
|
+
machineId: resolvedMachineId,
|
|
9032
|
+
resourceId: mcpPlanResourceId("machines_install_tailscale_apply", resolvedMachineId, plan),
|
|
9033
|
+
args: mcpPlanApprovalArgs({ machine_id: resolvedMachineId, yes }, plan)
|
|
9034
|
+
});
|
|
9035
|
+
return { content: [{ type: "text", text: JSON.stringify(runTailscaleInstallPlan(plan, { apply: true, yes }), null, 2) }] };
|
|
9036
|
+
});
|
|
8412
9037
|
server.tool("machines_route_resolve", "Resolve the best route for a machine using manifest, heartbeat, SSH, LAN, and Tailscale topology.", {
|
|
8413
9038
|
machine_id: exports_external.string().describe("Machine identifier"),
|
|
8414
9039
|
include_tailscale: exports_external.boolean().optional().describe("Whether to probe tailscale status --json"),
|
|
@@ -8476,41 +9101,72 @@ function createMcpServer(version) {
|
|
|
8476
9101
|
content: [{ type: "text", text: JSON.stringify(listPorts(machine_id), null, 2) }]
|
|
8477
9102
|
}));
|
|
8478
9103
|
server.tool("machines_backup_preview", "Preview backup steps for the current machine.", { bucket: exports_external.string().optional().describe("S3 bucket name; defaults to HASNA_MACHINES_S3_BUCKET or MACHINES_S3_BUCKET"), prefix: exports_external.string().optional().describe("S3 key prefix; defaults to HASNA_MACHINES_S3_PREFIX, MACHINES_S3_PREFIX, or machines") }, async ({ bucket, prefix }) => ({ content: [{ type: "text", text: JSON.stringify(buildBackupPlan(bucket, prefix), null, 2) }] }));
|
|
8479
|
-
server.tool("machines_backup_apply", "Execute backup steps for the current machine.", { bucket: exports_external.string().optional().describe("S3 bucket name; defaults to HASNA_MACHINES_S3_BUCKET or MACHINES_S3_BUCKET"), prefix: exports_external.string().optional().describe("S3 key prefix; defaults to HASNA_MACHINES_S3_PREFIX, MACHINES_S3_PREFIX, or machines"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ bucket, prefix, yes }) =>
|
|
9104
|
+
server.tool("machines_backup_apply", "Execute backup steps for the current machine.", { bucket: exports_external.string().optional().describe("S3 bucket name; defaults to HASNA_MACHINES_S3_BUCKET or MACHINES_S3_BUCKET"), prefix: exports_external.string().optional().describe("S3 key prefix; defaults to HASNA_MACHINES_S3_PREFIX, MACHINES_S3_PREFIX, or machines"), yes: exports_external.boolean().describe("Confirmation flag for execution"), approval_token: approvalTokenSchema }, async ({ bucket, prefix, yes, approval_token }) => {
|
|
9105
|
+
requireMcpMutation("machines_backup_apply", approval_token, { resourceId: mutationResourceId("backup", bucket, prefix), args: { bucket, prefix, yes } });
|
|
9106
|
+
return { content: [{ type: "text", text: JSON.stringify(runBackup(bucket, prefix, { apply: true, yes }), null, 2) }] };
|
|
9107
|
+
});
|
|
8480
9108
|
server.tool("machines_cert_preview", "Preview mkcert steps for one or more domains.", { domains: exports_external.array(exports_external.string()).describe("Domains to issue certificates for") }, async ({ domains }) => ({ content: [{ type: "text", text: JSON.stringify(buildCertPlan(domains), null, 2) }] }));
|
|
8481
|
-
server.tool("machines_cert_apply", "Execute mkcert steps for one or more domains.", { domains: exports_external.array(exports_external.string()).describe("Domains to issue certificates for"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ domains, yes }) =>
|
|
8482
|
-
|
|
9109
|
+
server.tool("machines_cert_apply", "Execute mkcert steps for one or more domains.", { domains: exports_external.array(exports_external.string()).describe("Domains to issue certificates for"), yes: exports_external.boolean().describe("Confirmation flag for execution"), approval_token: approvalTokenSchema }, async ({ domains, yes, approval_token }) => {
|
|
9110
|
+
requireMcpMutation("machines_cert_apply", approval_token, { resourceId: mutationResourceId("cert", domains.join(",")), args: { domains, yes } });
|
|
9111
|
+
return { content: [{ type: "text", text: JSON.stringify(runCertPlan(domains, { apply: true, yes }), null, 2) }] };
|
|
9112
|
+
});
|
|
9113
|
+
server.tool("machines_dns_add", "Add or replace a local domain mapping.", { domain: exports_external.string().describe("Domain name"), port: exports_external.number().describe("Target port"), target_host: exports_external.string().optional().describe("Target host"), approval_token: approvalTokenSchema }, async ({ domain, port, target_host, approval_token }) => {
|
|
9114
|
+
const resolvedTargetHost = target_host ?? "127.0.0.1";
|
|
9115
|
+
requireMcpMutation("machines_dns_add", approval_token, { resourceId: mutationResourceId("dns", domain), args: { domain, port, target_host: resolvedTargetHost } });
|
|
9116
|
+
return { content: [{ type: "text", text: JSON.stringify(addDomainMapping(domain, port, resolvedTargetHost), null, 2) }] };
|
|
9117
|
+
});
|
|
8483
9118
|
server.tool("machines_dns_list", "List local domain mappings.", {}, async () => ({ content: [{ type: "text", text: JSON.stringify(listDomainMappings(), null, 2) }] }));
|
|
8484
9119
|
server.tool("machines_dns_render", "Render hosts/proxy configuration for a domain.", { domain: exports_external.string().describe("Domain name") }, async ({ domain }) => ({ content: [{ type: "text", text: JSON.stringify(renderDomainMapping(domain), null, 2) }] }));
|
|
8485
9120
|
server.tool("machines_notifications_add", "Add or replace a notification channel.", {
|
|
8486
9121
|
channel_id: exports_external.string().describe("Channel identifier"),
|
|
8487
9122
|
type: exports_external.enum(["email", "webhook", "command"]).describe("Notification transport"),
|
|
8488
|
-
target: exports_external.string().describe("Email, webhook URL, or
|
|
9123
|
+
target: exports_external.string().describe("Email, webhook URL, or command executable"),
|
|
9124
|
+
command_args: exports_external.array(exports_external.string()).optional().describe("Arguments for command transports"),
|
|
8489
9125
|
events: exports_external.array(exports_external.string()).describe("Events routed to this channel"),
|
|
8490
|
-
enabled: exports_external.boolean().optional().describe("Whether the channel is enabled")
|
|
8491
|
-
|
|
8492
|
-
|
|
8493
|
-
|
|
9126
|
+
enabled: exports_external.boolean().optional().describe("Whether the channel is enabled"),
|
|
9127
|
+
approval_token: approvalTokenSchema
|
|
9128
|
+
}, async ({ channel_id, type, target, command_args, events: events2, enabled, approval_token }) => {
|
|
9129
|
+
const resolvedEnabled = enabled ?? true;
|
|
9130
|
+
const resolvedEvents = [...new Set(events2)];
|
|
9131
|
+
const commandArgs = command_args ?? [];
|
|
9132
|
+
requireMcpMutation("machines_notifications_add", approval_token, { resourceId: mutationResourceId("notification", channel_id), args: { channel_id, type, target, command_args: commandArgs, events: resolvedEvents, enabled: resolvedEnabled } });
|
|
9133
|
+
return {
|
|
9134
|
+
content: [{ type: "text", text: JSON.stringify(addNotificationChannel({ id: channel_id, type, target, commandArgs: type === "command" && commandArgs.length > 0 ? commandArgs : undefined, events: resolvedEvents, enabled: resolvedEnabled }, { trustedApproval: trustedNotificationApproval2 }), null, 2) }]
|
|
9135
|
+
};
|
|
9136
|
+
});
|
|
8494
9137
|
server.tool("machines_notifications_list", "List notification channels.", {}, async () => ({
|
|
8495
9138
|
content: [{ type: "text", text: JSON.stringify(listNotificationChannels(), null, 2) }]
|
|
8496
9139
|
}));
|
|
8497
|
-
server.tool("machines_notifications_test", "Preview or execute a notification test.", { channel_id: exports_external.string().describe("Channel identifier"), event: exports_external.string().optional().describe("Event name"), message: exports_external.string().optional().describe("Message body"), yes: exports_external.boolean().optional().describe("Execute the test when true") }, async ({ channel_id, event, message, yes }) =>
|
|
8498
|
-
|
|
8499
|
-
|
|
8500
|
-
|
|
8501
|
-
|
|
9140
|
+
server.tool("machines_notifications_test", "Preview or execute a notification test.", { channel_id: exports_external.string().describe("Channel identifier"), event: exports_external.string().optional().describe("Event name"), message: exports_external.string().optional().describe("Message body"), yes: exports_external.boolean().optional().describe("Execute the test when true"), approval_token: approvalTokenSchema }, async ({ channel_id, event, message, yes, approval_token }) => {
|
|
9141
|
+
if (yes === true)
|
|
9142
|
+
requireMcpMutation("machines_notifications_test", approval_token, { resourceId: mutationResourceId("notification-test", channel_id, event), args: { channel_id, event, message, yes: true } });
|
|
9143
|
+
return {
|
|
9144
|
+
content: [{ type: "text", text: JSON.stringify(await testNotificationChannel(channel_id, event, message, { apply: Boolean(yes), yes, trustedApproval: yes === true ? trustedNotificationApproval2 : undefined }), null, 2) }]
|
|
9145
|
+
};
|
|
9146
|
+
});
|
|
9147
|
+
server.tool("machines_notifications_dispatch", "Dispatch an event to matching notification channels.", { event: exports_external.string().describe("Event name"), message: exports_external.string().describe("Message body"), channel_id: exports_external.string().optional().describe("Limit delivery to one channel"), approval_token: approvalTokenSchema }, async ({ event, message, channel_id, approval_token }) => {
|
|
9148
|
+
requireMcpMutation("machines_notifications_dispatch", approval_token, { resourceId: mutationResourceId("notification-dispatch", channel_id, event), args: { event, message, channel_id } });
|
|
9149
|
+
return { content: [{ type: "text", text: JSON.stringify(await dispatchNotificationEvent(event, message, { channelId: channel_id, trustedApproval: trustedNotificationApproval2 }), null, 2) }] };
|
|
9150
|
+
});
|
|
9151
|
+
server.tool("machines_notifications_remove", "Remove a notification channel.", { channel_id: exports_external.string().describe("Channel identifier"), approval_token: approvalTokenSchema }, async ({ channel_id, approval_token }) => {
|
|
9152
|
+
requireMcpMutation("machines_notifications_remove", approval_token, { resourceId: mutationResourceId("notification", channel_id), args: { channel_id } });
|
|
9153
|
+
return { content: [{ type: "text", text: JSON.stringify(removeNotificationChannel(channel_id), null, 2) }] };
|
|
9154
|
+
});
|
|
8502
9155
|
server.tool("machines_webhooks_add", "Add or replace a shared event webhook channel.", {
|
|
8503
9156
|
channel_id: exports_external.string().describe("Channel identifier"),
|
|
8504
9157
|
url: exports_external.string().url().describe("Webhook URL"),
|
|
8505
9158
|
event_type: exports_external.string().optional().describe("Optional event type filter, e.g. machines.*"),
|
|
8506
9159
|
source: exports_external.string().optional().describe("Optional source filter"),
|
|
8507
9160
|
secret: exports_external.string().optional().describe("Optional HMAC secret"),
|
|
8508
|
-
enabled: exports_external.boolean().optional().describe("Whether the channel is enabled")
|
|
8509
|
-
|
|
9161
|
+
enabled: exports_external.boolean().optional().describe("Whether the channel is enabled"),
|
|
9162
|
+
approval_token: approvalTokenSchema
|
|
9163
|
+
}, async ({ channel_id, url, event_type, source, secret, enabled, approval_token }) => {
|
|
9164
|
+
const resolvedEnabled = enabled ?? true;
|
|
9165
|
+
requireMcpMutation("machines_webhooks_add", approval_token, { resourceId: mutationResourceId("webhook", channel_id), args: { channel_id, url, event_type, source, secret, enabled: resolvedEnabled } });
|
|
8510
9166
|
const now = new Date().toISOString();
|
|
8511
9167
|
const channel = await events.addChannel({
|
|
8512
9168
|
id: channel_id,
|
|
8513
|
-
enabled:
|
|
9169
|
+
enabled: resolvedEnabled,
|
|
8514
9170
|
transport: "webhook",
|
|
8515
9171
|
filters: event_type || source ? [{ type: event_type, source }] : undefined,
|
|
8516
9172
|
webhook: { url, secret },
|
|
@@ -8522,10 +9178,16 @@ function createMcpServer(version) {
|
|
|
8522
9178
|
server.tool("machines_webhooks_list", "List shared event webhook channels.", {}, async () => ({
|
|
8523
9179
|
content: [{ type: "text", text: JSON.stringify(sanitizeChannelsForOutput2(await events.listChannels()), null, 2) }]
|
|
8524
9180
|
}));
|
|
8525
|
-
server.tool("machines_webhooks_test", "Send a test event to one shared event channel.", { channel_id: exports_external.string().describe("Channel identifier"), event_type: exports_external.string().optional().describe("Event type"), message: exports_external.string().optional().describe("Message body") }, async ({ channel_id, event_type, message }) =>
|
|
8526
|
-
|
|
8527
|
-
|
|
8528
|
-
|
|
9181
|
+
server.tool("machines_webhooks_test", "Send a test event to one shared event channel.", { channel_id: exports_external.string().describe("Channel identifier"), event_type: exports_external.string().optional().describe("Event type"), message: exports_external.string().optional().describe("Message body"), approval_token: approvalTokenSchema }, async ({ channel_id, event_type, message, approval_token }) => {
|
|
9182
|
+
requireMcpMutation("machines_webhooks_test", approval_token, { resourceId: mutationResourceId("webhook-test", channel_id, event_type), args: { channel_id, event_type, message } });
|
|
9183
|
+
return {
|
|
9184
|
+
content: [{ type: "text", text: JSON.stringify(await events.testChannel(channel_id, { source: "machines", type: event_type ?? "events.test", message }), null, 2) }]
|
|
9185
|
+
};
|
|
9186
|
+
});
|
|
9187
|
+
server.tool("machines_webhooks_remove", "Remove a shared event channel.", { channel_id: exports_external.string().describe("Channel identifier"), approval_token: approvalTokenSchema }, async ({ channel_id, approval_token }) => {
|
|
9188
|
+
requireMcpMutation("machines_webhooks_remove", approval_token, { resourceId: mutationResourceId("webhook", channel_id), args: { channel_id } });
|
|
9189
|
+
return { content: [{ type: "text", text: JSON.stringify({ removed: await events.removeChannel(channel_id) }, null, 2) }] };
|
|
9190
|
+
});
|
|
8529
9191
|
server.tool("machines_events_emit", "Emit a shared event from machines.", {
|
|
8530
9192
|
event_type: exports_external.string().describe("Event type"),
|
|
8531
9193
|
subject: exports_external.string().optional().describe("Event subject"),
|
|
@@ -8534,25 +9196,36 @@ function createMcpServer(version) {
|
|
|
8534
9196
|
data: exports_external.record(exports_external.unknown()).optional().describe("Event data"),
|
|
8535
9197
|
metadata: exports_external.record(exports_external.unknown()).optional().describe("Event metadata"),
|
|
8536
9198
|
dedupe_key: exports_external.string().optional().describe("Dedupe key"),
|
|
8537
|
-
deliver: exports_external.boolean().optional().describe("Deliver to matching channels")
|
|
8538
|
-
|
|
8539
|
-
|
|
8540
|
-
|
|
8541
|
-
|
|
8542
|
-
|
|
8543
|
-
|
|
8544
|
-
|
|
8545
|
-
|
|
8546
|
-
|
|
8547
|
-
|
|
8548
|
-
|
|
8549
|
-
|
|
9199
|
+
deliver: exports_external.boolean().optional().describe("Deliver to matching channels"),
|
|
9200
|
+
approval_token: approvalTokenSchema
|
|
9201
|
+
}, async ({ event_type, subject, severity, message, data, metadata, dedupe_key, deliver, approval_token }) => {
|
|
9202
|
+
const resolvedData = data ?? {};
|
|
9203
|
+
const resolvedMetadata = metadata ?? {};
|
|
9204
|
+
const resolvedDeliver = deliver !== false;
|
|
9205
|
+
requireMcpMutation("machines_events_emit", approval_token, { resourceId: mutationResourceId("event", event_type, subject, dedupe_key), args: { event_type, subject, severity, message, data: resolvedData, metadata: resolvedMetadata, dedupe_key, deliver: resolvedDeliver } });
|
|
9206
|
+
return {
|
|
9207
|
+
content: [{ type: "text", text: JSON.stringify(await events.emit({
|
|
9208
|
+
source: "machines",
|
|
9209
|
+
type: event_type,
|
|
9210
|
+
subject,
|
|
9211
|
+
severity,
|
|
9212
|
+
message,
|
|
9213
|
+
data: resolvedData,
|
|
9214
|
+
metadata: resolvedMetadata,
|
|
9215
|
+
dedupeKey: dedupe_key
|
|
9216
|
+
}, { deliver: resolvedDeliver }), null, 2) }]
|
|
9217
|
+
};
|
|
9218
|
+
});
|
|
8550
9219
|
server.tool("machines_events_list", "List shared events.", {}, async () => ({
|
|
8551
9220
|
content: [{ type: "text", text: JSON.stringify(await events.listEvents(), null, 2) }]
|
|
8552
9221
|
}));
|
|
8553
|
-
server.tool("machines_events_replay", "Replay shared events.", { event_id: exports_external.string().optional().describe("Event id"), source: exports_external.string().optional().describe("Source filter"), event_type: exports_external.string().optional().describe("Event type filter"), dry_run: exports_external.boolean().optional().describe("Preview without delivery") }, async ({ event_id, source, event_type, dry_run }) =>
|
|
8554
|
-
|
|
8555
|
-
|
|
9222
|
+
server.tool("machines_events_replay", "Replay shared events.", { event_id: exports_external.string().optional().describe("Event id"), source: exports_external.string().optional().describe("Source filter"), event_type: exports_external.string().optional().describe("Event type filter"), dry_run: exports_external.boolean().optional().describe("Preview without delivery"), approval_token: approvalTokenSchema }, async ({ event_id, source, event_type, dry_run, approval_token }) => {
|
|
9223
|
+
if (dry_run !== true)
|
|
9224
|
+
requireMcpMutation("machines_events_replay", approval_token, { resourceId: mutationResourceId("event-replay", event_id, source, event_type), args: { event_id, source, event_type, dry_run: false } });
|
|
9225
|
+
return {
|
|
9226
|
+
content: [{ type: "text", text: JSON.stringify(await events.replay({ eventId: event_id, source, type: event_type, dryRun: dry_run }), null, 2) }]
|
|
9227
|
+
};
|
|
9228
|
+
});
|
|
8556
9229
|
server.tool("machines_serve_info", "Preview the dashboard server bind address and routes.", { host: exports_external.string().optional().describe("Host interface"), port: exports_external.number().optional().describe("Port number") }, async ({ host, port }) => ({ content: [{ type: "text", text: JSON.stringify(getServeInfo({ host, port }), null, 2) }] }));
|
|
8557
9230
|
server.tool("machines_serve_dashboard", "Render the current dashboard HTML.", {}, async () => ({
|
|
8558
9231
|
content: [{ type: "text", text: renderDashboardHtml() }]
|
|
@@ -8560,15 +9233,28 @@ function createMcpServer(version) {
|
|
|
8560
9233
|
server.tool("storage_status", "Show machines storage sync configuration and local sync history.", {}, async () => ({
|
|
8561
9234
|
content: [{ type: "text", text: JSON.stringify(getStorageStatus(), null, 2) }]
|
|
8562
9235
|
}));
|
|
8563
|
-
server.tool("storage_push", "Push local machine runtime data to storage PostgreSQL.", { tables: exports_external.array(exports_external.string()).optional().describe("Optional table list to push")
|
|
8564
|
-
|
|
8565
|
-
|
|
9236
|
+
server.tool("storage_push", "Push local machine runtime data to storage PostgreSQL.", { tables: exports_external.array(exports_external.string()).optional().describe("Optional table list to push"), approval_token: approvalTokenSchema }, async ({ tables, approval_token }) => {
|
|
9237
|
+
const resolvedTables = resolveTables(tables);
|
|
9238
|
+
requireMcpMutation("storage_push", approval_token, { resourceId: mutationResourceId("storage-push", resolvedTables.join(",")), args: { tables: resolvedTables } });
|
|
9239
|
+
return { content: [{ type: "text", text: JSON.stringify(await storagePush({ tables: resolvedTables }), null, 2) }] };
|
|
9240
|
+
});
|
|
9241
|
+
server.tool("storage_pull", "Pull machine runtime data from storage PostgreSQL to local SQLite.", { tables: exports_external.array(exports_external.string()).optional().describe("Optional table list to pull"), approval_token: approvalTokenSchema }, async ({ tables, approval_token }) => {
|
|
9242
|
+
const resolvedTables = resolveTables(tables);
|
|
9243
|
+
requireMcpMutation("storage_pull", approval_token, { resourceId: mutationResourceId("storage-pull", resolvedTables.join(",")), args: { tables: resolvedTables } });
|
|
9244
|
+
return { content: [{ type: "text", text: JSON.stringify(await storagePull({ tables: resolvedTables }), null, 2) }] };
|
|
9245
|
+
});
|
|
9246
|
+
server.tool("storage_sync", "Bidirectional machines storage sync: pull then push.", { tables: exports_external.array(exports_external.string()).optional().describe("Optional table list to sync"), approval_token: approvalTokenSchema }, async ({ tables, approval_token }) => {
|
|
9247
|
+
const resolvedTables = resolveTables(tables);
|
|
9248
|
+
requireMcpMutation("storage_sync", approval_token, { resourceId: mutationResourceId("storage-sync", resolvedTables.join(",")), args: { tables: resolvedTables } });
|
|
9249
|
+
return { content: [{ type: "text", text: JSON.stringify(await storageSync({ tables: resolvedTables }), null, 2) }] };
|
|
9250
|
+
});
|
|
8566
9251
|
return server;
|
|
8567
9252
|
}
|
|
8568
9253
|
|
|
8569
9254
|
// src/mcp/http.ts
|
|
8570
9255
|
var DEFAULT_HTTP_PORT = 8821;
|
|
8571
9256
|
var HTTP_NAME = "machines";
|
|
9257
|
+
var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
8572
9258
|
function isHttpMode(args = process.argv.slice(2)) {
|
|
8573
9259
|
return args.includes("--http") || process.env.MCP_HTTP === "1";
|
|
8574
9260
|
}
|
|
@@ -8598,28 +9284,156 @@ function parsePort(raw) {
|
|
|
8598
9284
|
function pathnameFromRequest(req) {
|
|
8599
9285
|
return new URL(req.url ?? "/", "http://127.0.0.1").pathname;
|
|
8600
9286
|
}
|
|
8601
|
-
|
|
9287
|
+
function isLoopbackHost(host) {
|
|
9288
|
+
const normalized = host.toLowerCase().replace(/^\[|\]$/g, "");
|
|
9289
|
+
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
|
9290
|
+
}
|
|
9291
|
+
function resolveHttpSecurityConfig(env = process.env, host = "127.0.0.1") {
|
|
9292
|
+
const allowUnauthenticated = env.MACHINES_ALLOW_UNAUTHENTICATED === "1" && isLoopbackHost(host);
|
|
9293
|
+
const allowedOrigins = (env.MACHINES_HTTP_ALLOWED_ORIGINS ?? "").split(",").map((origin) => origin.trim()).filter(Boolean);
|
|
9294
|
+
const maxBodyBytes = parsePositiveInteger(env.MACHINES_HTTP_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
|
|
9295
|
+
return {
|
|
9296
|
+
apiKey: env.MACHINES_API_KEY,
|
|
9297
|
+
allowUnauthenticated,
|
|
9298
|
+
allowedOrigins,
|
|
9299
|
+
maxBodyBytes
|
|
9300
|
+
};
|
|
9301
|
+
}
|
|
9302
|
+
function parsePositiveInteger(raw, fallback) {
|
|
9303
|
+
if (!raw)
|
|
9304
|
+
return fallback;
|
|
9305
|
+
const parsed = Number.parseInt(raw, 10);
|
|
9306
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
9307
|
+
}
|
|
9308
|
+
function safeTokenEquals(left, right) {
|
|
9309
|
+
const leftBuffer = Buffer.from(left);
|
|
9310
|
+
const rightBuffer = Buffer.from(right);
|
|
9311
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual2(leftBuffer, rightBuffer);
|
|
9312
|
+
}
|
|
9313
|
+
function originValue(req) {
|
|
9314
|
+
const origin = req.headers.origin;
|
|
9315
|
+
if (typeof origin === "string")
|
|
9316
|
+
return origin.trim();
|
|
9317
|
+
return;
|
|
9318
|
+
}
|
|
9319
|
+
function isTrustedHttpOrigin(origin, host, allowedOrigins = []) {
|
|
9320
|
+
if (!origin)
|
|
9321
|
+
return true;
|
|
9322
|
+
if (allowedOrigins.includes(origin) || allowedOrigins.includes("*"))
|
|
9323
|
+
return true;
|
|
9324
|
+
let parsed;
|
|
9325
|
+
try {
|
|
9326
|
+
parsed = new URL(origin);
|
|
9327
|
+
} catch {
|
|
9328
|
+
return false;
|
|
9329
|
+
}
|
|
9330
|
+
return isLoopbackHost(host) && isLoopbackHost(parsed.hostname);
|
|
9331
|
+
}
|
|
9332
|
+
function authorizeHttpOrigin(req, host, security) {
|
|
9333
|
+
const origin = originValue(req);
|
|
9334
|
+
if (isTrustedHttpOrigin(origin, host, security.allowedOrigins))
|
|
9335
|
+
return { ok: true };
|
|
9336
|
+
return {
|
|
9337
|
+
ok: false,
|
|
9338
|
+
status: 403,
|
|
9339
|
+
reason: "Untrusted Origin header for machines MCP HTTP request."
|
|
9340
|
+
};
|
|
9341
|
+
}
|
|
9342
|
+
function requestBearerToken(req) {
|
|
9343
|
+
const authorization = req.headers.authorization;
|
|
9344
|
+
if (typeof authorization === "string") {
|
|
9345
|
+
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
|
9346
|
+
if (match?.[1])
|
|
9347
|
+
return match[1].trim();
|
|
9348
|
+
}
|
|
9349
|
+
const apiKey = req.headers["x-machines-api-key"];
|
|
9350
|
+
if (typeof apiKey === "string")
|
|
9351
|
+
return apiKey.trim();
|
|
9352
|
+
if (Array.isArray(apiKey))
|
|
9353
|
+
return apiKey[0]?.trim();
|
|
9354
|
+
return;
|
|
9355
|
+
}
|
|
9356
|
+
function authorizeHttpRequest(req, security) {
|
|
9357
|
+
if (security.allowUnauthenticated)
|
|
9358
|
+
return { ok: true };
|
|
9359
|
+
const expected = security.apiKey?.trim();
|
|
9360
|
+
if (!expected) {
|
|
9361
|
+
return { ok: false, status: 401, reason: "machines MCP HTTP requires MACHINES_API_KEY or loopback-only MACHINES_ALLOW_UNAUTHENTICATED=1." };
|
|
9362
|
+
}
|
|
9363
|
+
const received = requestBearerToken(req);
|
|
9364
|
+
if (received && safeTokenEquals(received, expected))
|
|
9365
|
+
return { ok: true };
|
|
9366
|
+
return { ok: false, status: 401, reason: "Invalid or missing machines MCP HTTP API key." };
|
|
9367
|
+
}
|
|
9368
|
+
function corsHeaders(req, host, security) {
|
|
9369
|
+
const origin = originValue(req);
|
|
9370
|
+
if (!origin || !isTrustedHttpOrigin(origin, host, security.allowedOrigins))
|
|
9371
|
+
return {};
|
|
9372
|
+
return {
|
|
9373
|
+
"access-control-allow-origin": origin,
|
|
9374
|
+
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
|
|
9375
|
+
"access-control-allow-headers": "authorization, content-type, x-machines-api-key, mcp-session-id",
|
|
9376
|
+
"access-control-expose-headers": "mcp-session-id",
|
|
9377
|
+
vary: "Origin"
|
|
9378
|
+
};
|
|
9379
|
+
}
|
|
9380
|
+
function applyCorsHeaders(req, res, host, security) {
|
|
9381
|
+
for (const [key, value] of Object.entries(corsHeaders(req, host, security))) {
|
|
9382
|
+
res.setHeader(key, value);
|
|
9383
|
+
}
|
|
9384
|
+
}
|
|
9385
|
+
function writeJson(res, status, payload, headers = {}) {
|
|
9386
|
+
res.writeHead(status, { "content-type": "application/json", ...headers });
|
|
9387
|
+
res.end(JSON.stringify(payload));
|
|
9388
|
+
}
|
|
9389
|
+
function requestContentLength(req) {
|
|
9390
|
+
const raw = req.headers["content-length"];
|
|
9391
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
9392
|
+
if (!value)
|
|
9393
|
+
return null;
|
|
9394
|
+
const parsed = Number.parseInt(value, 10);
|
|
9395
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
9396
|
+
}
|
|
9397
|
+
|
|
9398
|
+
class HttpRequestError extends Error {
|
|
9399
|
+
status;
|
|
9400
|
+
constructor(status, message) {
|
|
9401
|
+
super(message);
|
|
9402
|
+
this.status = status;
|
|
9403
|
+
}
|
|
9404
|
+
}
|
|
9405
|
+
async function readRequestBody(req, maxBodyBytes) {
|
|
8602
9406
|
if (req.method !== "POST" && req.method !== "DELETE") {
|
|
8603
9407
|
return;
|
|
8604
9408
|
}
|
|
8605
9409
|
const chunks = [];
|
|
9410
|
+
let size = 0;
|
|
8606
9411
|
for await (const chunk of req) {
|
|
8607
|
-
|
|
9412
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
9413
|
+
size += buffer.length;
|
|
9414
|
+
if (size > maxBodyBytes) {
|
|
9415
|
+
throw new HttpRequestError(413, `Request body exceeds ${maxBodyBytes} bytes.`);
|
|
9416
|
+
}
|
|
9417
|
+
chunks.push(buffer);
|
|
8608
9418
|
}
|
|
8609
9419
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
8610
9420
|
if (!text) {
|
|
8611
9421
|
return;
|
|
8612
9422
|
}
|
|
8613
|
-
|
|
9423
|
+
try {
|
|
9424
|
+
return JSON.parse(text);
|
|
9425
|
+
} catch {
|
|
9426
|
+
throw new HttpRequestError(400, "Invalid JSON request body.");
|
|
9427
|
+
}
|
|
8614
9428
|
}
|
|
8615
|
-
async function handleMcpRequest(req, res) {
|
|
8616
|
-
const server = buildServer();
|
|
9429
|
+
async function handleMcpRequest(req, res, maxBodyBytes) {
|
|
9430
|
+
const server = buildServer(undefined, { mutationTransport: "mcp:http" });
|
|
8617
9431
|
const transport = new StreamableHTTPServerTransport({
|
|
8618
9432
|
sessionIdGenerator: undefined
|
|
8619
9433
|
});
|
|
8620
9434
|
await server.connect(transport);
|
|
8621
9435
|
try {
|
|
8622
|
-
const body = await readRequestBody(req);
|
|
9436
|
+
const body = await readRequestBody(req, maxBodyBytes);
|
|
8623
9437
|
await transport.handleRequest(req, res, body);
|
|
8624
9438
|
} finally {
|
|
8625
9439
|
res.on("close", () => {
|
|
@@ -8636,19 +9450,47 @@ function startHttpServer(options = {}) {
|
|
|
8636
9450
|
const host = options.host ?? "127.0.0.1";
|
|
8637
9451
|
const port = options.port ?? resolveHttpPort();
|
|
8638
9452
|
const name = options.name ?? HTTP_NAME;
|
|
9453
|
+
const security = options.security ?? resolveHttpSecurityConfig(process.env, host);
|
|
8639
9454
|
const httpServer = createServer(async (req, res) => {
|
|
8640
9455
|
const path = pathnameFromRequest(req);
|
|
8641
9456
|
if (req.method === "GET" && path === "/health") {
|
|
8642
|
-
res
|
|
8643
|
-
res.end(JSON.stringify({ status: "ok", name }));
|
|
9457
|
+
writeJson(res, 200, { status: "ok", name });
|
|
8644
9458
|
return;
|
|
8645
9459
|
}
|
|
8646
9460
|
if (path === "/mcp") {
|
|
8647
|
-
|
|
9461
|
+
const origin = authorizeHttpOrigin(req, host, security);
|
|
9462
|
+
if (!origin.ok) {
|
|
9463
|
+
writeJson(res, origin.status, { error: "Forbidden", reason: origin.reason });
|
|
9464
|
+
return;
|
|
9465
|
+
}
|
|
9466
|
+
if (req.method === "OPTIONS") {
|
|
9467
|
+
res.writeHead(204, corsHeaders(req, host, security));
|
|
9468
|
+
res.end();
|
|
9469
|
+
return;
|
|
9470
|
+
}
|
|
9471
|
+
const authorization = authorizeHttpRequest(req, security);
|
|
9472
|
+
if (!authorization.ok) {
|
|
9473
|
+
writeJson(res, authorization.status, { error: "Unauthorized", reason: authorization.reason }, corsHeaders(req, host, security));
|
|
9474
|
+
return;
|
|
9475
|
+
}
|
|
9476
|
+
const contentLength = requestContentLength(req);
|
|
9477
|
+
if (contentLength !== null && contentLength > security.maxBodyBytes) {
|
|
9478
|
+
writeJson(res, 413, { error: "Payload Too Large", reason: `Request body exceeds ${security.maxBodyBytes} bytes.` }, corsHeaders(req, host, security));
|
|
9479
|
+
return;
|
|
9480
|
+
}
|
|
9481
|
+
applyCorsHeaders(req, res, host, security);
|
|
9482
|
+
try {
|
|
9483
|
+
await handleMcpRequest(req, res, security.maxBodyBytes);
|
|
9484
|
+
} catch (error) {
|
|
9485
|
+
if (error instanceof HttpRequestError) {
|
|
9486
|
+
writeJson(res, error.status, { error: error.status === 413 ? "Payload Too Large" : "Bad Request", reason: error.message }, corsHeaders(req, host, security));
|
|
9487
|
+
return;
|
|
9488
|
+
}
|
|
9489
|
+
throw error;
|
|
9490
|
+
}
|
|
8648
9491
|
return;
|
|
8649
9492
|
}
|
|
8650
|
-
res
|
|
8651
|
-
res.end(JSON.stringify({ error: "Not found" }));
|
|
9493
|
+
writeJson(res, 404, { error: "Not found" });
|
|
8652
9494
|
});
|
|
8653
9495
|
httpServer.listen(port, host, () => {
|
|
8654
9496
|
const address = httpServer.address();
|