@synapsor/runner 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/runner.mjs +338 -106
- package/docs/capability-authoring.md +29 -1
- package/examples/app-owned-writeback/command-handler.mjs +0 -0
- package/examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh +0 -0
- package/examples/reference-support-billing-app/scripts/run-demo.sh +0 -0
- package/examples/support-billing-agent/scripts/run-demo.sh +0 -0
- package/package.json +6 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@
|
|
|
4
4
|
|
|
5
5
|
No unreleased changes yet.
|
|
6
6
|
|
|
7
|
+
## 0.1.7
|
|
8
|
+
|
|
9
|
+
### Contract Writeback Resolution
|
|
10
|
+
|
|
11
|
+
- Fixes contract-authored proposal capabilities loaded through `contracts: []`
|
|
12
|
+
so `apply` resolves the same reviewed capability catalog used by serve,
|
|
13
|
+
tools, propose, and doctor.
|
|
14
|
+
- Rejects duplicate capability names across embedded runner config and
|
|
15
|
+
referenced contracts instead of silently shadowing a safety contract.
|
|
16
|
+
- Preserves canonical contract writeback modes, including direct SQL,
|
|
17
|
+
app-owned handler, cloud-worker, and proposal-only/no-local-writeback
|
|
18
|
+
semantics.
|
|
19
|
+
- Fails broken applyable writeback definitions at propose/doctor time before a
|
|
20
|
+
human approves a proposal.
|
|
21
|
+
- Creates local store parent directories automatically and trims env-derived
|
|
22
|
+
URLs, tokens, and trusted context values before use.
|
|
23
|
+
|
|
7
24
|
## 0.1.5
|
|
8
25
|
|
|
9
26
|
### Contract Authoring Front Door
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAYA,OAAO,
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAYA,OAAO,EAAoN,KAAK,WAAW,EAA2F,MAAM,6BAA6B,CAAC;AAoB1W,OAAO,EAA6G,KAAK,YAAY,EAAwB,MAAM,2BAA2B,CAAC;AAE/L,OAAO,EAOL,KAAK,gBAAgB,EAEtB,MAAM,mCAAmC,CAAC;AA6U3C,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAqD1D;AA+DD,KAAK,SAAS,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAE9E,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,GAAE;IACP,GAAG,CAAC,EAAE,SAAS,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,MAAM,CAAC,EAAE,IAAI,CAAC,MAAM,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;CACvC,GACL,OAAO,CAAC,MAAM,CAAC,CA2TjB;AAyrED,wBAAsB,0BAA0B,CAAC,GAAG,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAS/H"}
|
package/dist/runner.mjs
CHANGED
|
@@ -440,7 +440,7 @@ function normalizeRecord(value) {
|
|
|
440
440
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
441
441
|
}
|
|
442
442
|
function sleep(ms) {
|
|
443
|
-
return new Promise((
|
|
443
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
444
444
|
}
|
|
445
445
|
|
|
446
446
|
// packages/config/src/index.ts
|
|
@@ -479,6 +479,7 @@ var CAPABILITY_KEYS = /* @__PURE__ */ new Set([
|
|
|
479
479
|
"transition_guards",
|
|
480
480
|
"conflict_guard",
|
|
481
481
|
"approval",
|
|
482
|
+
"writeback",
|
|
482
483
|
"single_tenant_dev_ack"
|
|
483
484
|
]);
|
|
484
485
|
var TARGET_KEYS = /* @__PURE__ */ new Set(["schema", "table", "primary_key", "tenant_key", "single_tenant_dev"]);
|
|
@@ -489,6 +490,8 @@ var NUMERIC_BOUND_KEYS = /* @__PURE__ */ new Set(["minimum", "maximum"]);
|
|
|
489
490
|
var TRANSITION_GUARD_KEYS = /* @__PURE__ */ new Set(["from_column", "allowed"]);
|
|
490
491
|
var CONFLICT_GUARD_KEYS = /* @__PURE__ */ new Set(["column", "weak_guard_ack"]);
|
|
491
492
|
var APPROVAL_KEYS = /* @__PURE__ */ new Set(["mode", "required_role"]);
|
|
493
|
+
var WRITEBACK_KEYS = /* @__PURE__ */ new Set(["mode", "executor"]);
|
|
494
|
+
var WRITEBACK_MODES = /* @__PURE__ */ new Set(["direct_sql", "app_handler", "cloud_worker", "none"]);
|
|
492
495
|
var MODEL_CONTROLLED_TRUST_FIELDS = /* @__PURE__ */ new Set([
|
|
493
496
|
"tenant_id",
|
|
494
497
|
"tenantId",
|
|
@@ -653,9 +656,7 @@ function validateWritebackReadiness(sources, capabilities, mode, errors, warning
|
|
|
653
656
|
const sourceName = isNonEmptyString(capability.source) ? capability.source : void 0;
|
|
654
657
|
const source = sourceName ? sources[sourceName] : void 0;
|
|
655
658
|
if (!sourceName || !isRecord(source)) return;
|
|
656
|
-
|
|
657
|
-
const directSql = executor === "sql_update";
|
|
658
|
-
if (!directSql) return;
|
|
659
|
+
if (capabilityWritebackMode(capability) !== "direct_sql") return;
|
|
659
660
|
if (source.read_only === true) {
|
|
660
661
|
errors.push({
|
|
661
662
|
path: `$.capabilities[${index}].executor`,
|
|
@@ -673,6 +674,13 @@ function validateWritebackReadiness(sources, capabilities, mode, errors, warning
|
|
|
673
674
|
}
|
|
674
675
|
});
|
|
675
676
|
}
|
|
677
|
+
function capabilityWritebackMode(capability) {
|
|
678
|
+
if (isRecord(capability.writeback) && typeof capability.writeback.mode === "string" && WRITEBACK_MODES.has(capability.writeback.mode)) {
|
|
679
|
+
return capability.writeback.mode;
|
|
680
|
+
}
|
|
681
|
+
if (isNonEmptyString(capability.executor) && capability.executor !== "sql_update") return "app_handler";
|
|
682
|
+
return "direct_sql";
|
|
683
|
+
}
|
|
676
684
|
function validateCloud(value, mode, strict, errors) {
|
|
677
685
|
if (mode !== "cloud") {
|
|
678
686
|
if (value !== void 0) {
|
|
@@ -889,7 +897,21 @@ function validateCapabilities(value, sources, contexts, executors, mode, strict,
|
|
|
889
897
|
const sourceNames = isRecord(sources) ? new Set(Object.keys(sources)) : /* @__PURE__ */ new Set();
|
|
890
898
|
const contextNames = isRecord(contexts) ? new Set(Object.keys(contexts)) : /* @__PURE__ */ new Set();
|
|
891
899
|
const executorNames = isRecord(executors) ? new Set(Object.keys(executors)) : /* @__PURE__ */ new Set();
|
|
900
|
+
const capabilityNames = /* @__PURE__ */ new Map();
|
|
892
901
|
value.forEach((capability, index) => validateCapability(capability, index, sourceNames, contextNames, executorNames, strict, errors, warnings));
|
|
902
|
+
value.forEach((capability, index) => {
|
|
903
|
+
if (!isRecord(capability) || !isQualifiedName(capability.name)) return;
|
|
904
|
+
const previous = capabilityNames.get(capability.name);
|
|
905
|
+
if (previous !== void 0) {
|
|
906
|
+
errors.push({
|
|
907
|
+
path: `$.capabilities[${index}].name`,
|
|
908
|
+
code: "DUPLICATE_CAPABILITY_NAME",
|
|
909
|
+
message: `Capability ${capability.name} is already defined at $.capabilities[${previous}]. Capability names must be unique.`
|
|
910
|
+
});
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
capabilityNames.set(capability.name, index);
|
|
914
|
+
});
|
|
893
915
|
}
|
|
894
916
|
function validateCapability(value, index, sourceNames, contextNames, executorNames, strict, errors, warnings) {
|
|
895
917
|
const path4 = `$.capabilities[${index}]`;
|
|
@@ -923,6 +945,7 @@ function validateCapability(value, index, sourceNames, contextNames, executorNam
|
|
|
923
945
|
errors.push({ path: `${path4}.executor`, code: "UNKNOWN_EXECUTOR", message: "Capability executor must be sql_update or reference a configured executor." });
|
|
924
946
|
}
|
|
925
947
|
}
|
|
948
|
+
validateCapabilityWriteback(value, path4, executorNames, strict, errors);
|
|
926
949
|
validateTarget(value.target, `${path4}.target`, strict, errors, warnings);
|
|
927
950
|
validateArgs(value.args, `${path4}.args`, strict, errors);
|
|
928
951
|
validateLookup(value.lookup, `${path4}.lookup`, strict, errors);
|
|
@@ -932,6 +955,40 @@ function validateCapability(value, index, sourceNames, contextNames, executorNam
|
|
|
932
955
|
}
|
|
933
956
|
if (value.kind === "proposal") {
|
|
934
957
|
validateProposalCapability(value, path4, strict, errors);
|
|
958
|
+
} else if (value.writeback !== void 0) {
|
|
959
|
+
errors.push({ path: `${path4}.writeback`, code: "WRITEBACK_ONLY_FOR_PROPOSAL", message: "writeback is only valid on proposal capabilities." });
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
function validateCapabilityWriteback(capability, path4, executorNames, strict, errors) {
|
|
963
|
+
if (capability.writeback === void 0) return;
|
|
964
|
+
if (!isRecord(capability.writeback)) {
|
|
965
|
+
errors.push({ path: `${path4}.writeback`, code: "WRITEBACK_NOT_OBJECT", message: "writeback must be an object." });
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (strict) checkUnknownKeys(capability.writeback, WRITEBACK_KEYS, `${path4}.writeback`, errors);
|
|
969
|
+
if (!WRITEBACK_MODES.has(String(capability.writeback.mode))) {
|
|
970
|
+
errors.push({ path: `${path4}.writeback.mode`, code: "INVALID_WRITEBACK_MODE", message: "writeback.mode must be direct_sql, app_handler, cloud_worker, or none." });
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
const mode = String(capability.writeback.mode);
|
|
974
|
+
const executor = isNonEmptyString(capability.writeback.executor) ? capability.writeback.executor : isNonEmptyString(capability.executor) ? capability.executor : void 0;
|
|
975
|
+
if (mode === "direct_sql") {
|
|
976
|
+
if (executor !== void 0 && executor !== "sql_update") {
|
|
977
|
+
errors.push({ path: `${path4}.writeback.executor`, code: "WRITEBACK_EXECUTOR_MISMATCH", message: "direct_sql writeback cannot name an app-owned executor." });
|
|
978
|
+
}
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (mode === "none") {
|
|
982
|
+
if (executor !== void 0) {
|
|
983
|
+
errors.push({ path: `${path4}.writeback.executor`, code: "WRITEBACK_EXECUTOR_MISMATCH", message: "WRITEBACK NONE must not name an executor." });
|
|
984
|
+
}
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
if (mode === "cloud_worker") return;
|
|
988
|
+
if (!executor || executor === "sql_update") {
|
|
989
|
+
errors.push({ path: `${path4}.writeback.executor`, code: "WRITEBACK_EXECUTOR_REQUIRED", message: "app_handler writeback must name a configured executor." });
|
|
990
|
+
} else if (!executorNames.has(executor)) {
|
|
991
|
+
errors.push({ path: `${path4}.writeback.executor`, code: "UNKNOWN_EXECUTOR", message: "app_handler writeback executor must reference a configured executor." });
|
|
935
992
|
}
|
|
936
993
|
}
|
|
937
994
|
function validateTarget(value, path4, strict, errors, warnings) {
|
|
@@ -1250,6 +1307,8 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
|
1250
1307
|
|
|
1251
1308
|
// packages/proposal-store/src/index.ts
|
|
1252
1309
|
import { DatabaseSync } from "node:sqlite";
|
|
1310
|
+
import { mkdirSync } from "node:fs";
|
|
1311
|
+
import { dirname, resolve } from "node:path";
|
|
1253
1312
|
var ProposalStoreError = class extends Error {
|
|
1254
1313
|
constructor(code, message) {
|
|
1255
1314
|
super(message);
|
|
@@ -1263,6 +1322,9 @@ var ProposalStore = class {
|
|
|
1263
1322
|
path;
|
|
1264
1323
|
constructor(path4 = ":memory:") {
|
|
1265
1324
|
this.path = path4;
|
|
1325
|
+
if (path4 !== ":memory:") {
|
|
1326
|
+
mkdirSync(dirname(resolve(path4)), { recursive: true });
|
|
1327
|
+
}
|
|
1266
1328
|
this.db = new DatabaseSync(path4);
|
|
1267
1329
|
this.migrate();
|
|
1268
1330
|
}
|
|
@@ -1952,6 +2014,10 @@ var ProposalStore = class {
|
|
|
1952
2014
|
if (changeSet.writeback.mode !== "trusted_worker_required") {
|
|
1953
2015
|
throw new ProposalStoreError("WRITEBACK_NOT_REQUIRED", `proposal ${proposalId} uses ${changeSet.writeback.mode}`);
|
|
1954
2016
|
}
|
|
2017
|
+
const writebackExecutor = changeSet.writeback.executor;
|
|
2018
|
+
if (typeof writebackExecutor === "string" && writebackExecutor !== "sql_update" && writebackExecutor !== "trusted_worker_required") {
|
|
2019
|
+
throw new ProposalStoreError("WRITEBACK_NOT_DIRECT_SQL", `proposal ${proposalId} uses app-owned or non-local writeback executor ${writebackExecutor}`);
|
|
2020
|
+
}
|
|
1955
2021
|
if (changeSet.source.kind !== "external_postgres" && changeSet.source.kind !== "external_mysql") {
|
|
1956
2022
|
throw new ProposalStoreError("WRITEBACK_TARGET_NOT_EXTERNAL", `proposal ${proposalId} targets ${changeSet.source.kind}`);
|
|
1957
2023
|
}
|
|
@@ -2755,7 +2821,7 @@ var PROPOSAL_KEYS = /* @__PURE__ */ new Set(["action", "allowed_fields", "patch"
|
|
|
2755
2821
|
var PATCH_KEYS = /* @__PURE__ */ new Set(["fixed", "from_arg"]);
|
|
2756
2822
|
var CONFLICT_GUARD_KEYS2 = /* @__PURE__ */ new Set(["column", "weak_guard_ack"]);
|
|
2757
2823
|
var APPROVAL_KEYS2 = /* @__PURE__ */ new Set(["mode", "required_role"]);
|
|
2758
|
-
var
|
|
2824
|
+
var WRITEBACK_KEYS2 = /* @__PURE__ */ new Set(["mode", "executor", "idempotency_key"]);
|
|
2759
2825
|
var WORKFLOW_KEYS = /* @__PURE__ */ new Set(["name", "description", "context", "allowed_capabilities", "required_evidence", "approval", "settlement", "replay"]);
|
|
2760
2826
|
var POLICY_KEYS = /* @__PURE__ */ new Set(["name", "kind", "mode", "rules"]);
|
|
2761
2827
|
var EVIDENCE_RECORD_KEYS = /* @__PURE__ */ new Set(["handle", "capability", "query_fingerprint", "items"]);
|
|
@@ -3057,7 +3123,7 @@ function validateProposalAction(value, path4, errors) {
|
|
|
3057
3123
|
if (value.approval !== void 0)
|
|
3058
3124
|
validateNestedObject(value.approval, APPROVAL_KEYS2, `${path4}.approval`, errors);
|
|
3059
3125
|
if (value.writeback !== void 0) {
|
|
3060
|
-
validateNestedObject(value.writeback,
|
|
3126
|
+
validateNestedObject(value.writeback, WRITEBACK_KEYS2, `${path4}.writeback`, errors);
|
|
3061
3127
|
if (isRecord3(value.writeback) && !["direct_sql", "app_handler", "cloud_worker", "none"].includes(String(value.writeback.mode)))
|
|
3062
3128
|
errors.push({ path: `${path4}.writeback.mode`, code: "INVALID_WRITEBACK_MODE", message: "writeback.mode must be direct_sql, app_handler, cloud_worker, or none." });
|
|
3063
3129
|
}
|
|
@@ -3296,20 +3362,31 @@ function loadRuntimeConfigFromFile(configPath = process.env.SYNAPSOR_MCP_CONFIG
|
|
|
3296
3362
|
}
|
|
3297
3363
|
function resolveRuntimeConfig(config, baseDir = process.cwd()) {
|
|
3298
3364
|
if (!Array.isArray(config.contracts) || config.contracts.length === 0) return config;
|
|
3365
|
+
const seenCapabilities = /* @__PURE__ */ new Map();
|
|
3366
|
+
for (const [index, capability] of (config.capabilities ?? []).entries()) {
|
|
3367
|
+
rememberCapabilityName(seenCapabilities, capability.name, `embedded capabilities[${index}]`);
|
|
3368
|
+
}
|
|
3299
3369
|
const resolved = {
|
|
3300
3370
|
...config,
|
|
3301
3371
|
contexts: { ...config.contexts ?? {} },
|
|
3302
3372
|
capabilities: [...config.capabilities ?? []]
|
|
3303
3373
|
};
|
|
3304
|
-
for (const contractPath of config.contracts) {
|
|
3374
|
+
for (const [contractIndex, contractPath] of config.contracts.entries()) {
|
|
3305
3375
|
const fullPath = path.resolve(baseDir, contractPath);
|
|
3306
3376
|
const contract = normalizeContract(JSON.parse(fs.readFileSync(fullPath, "utf8")));
|
|
3307
|
-
mergeContractIntoRuntimeConfig(resolved, contract);
|
|
3377
|
+
mergeContractIntoRuntimeConfig(resolved, contract, `contracts[${contractIndex}] ${contractPath}`, seenCapabilities);
|
|
3308
3378
|
}
|
|
3309
3379
|
delete resolved.contracts;
|
|
3310
3380
|
return resolved;
|
|
3311
3381
|
}
|
|
3312
|
-
function
|
|
3382
|
+
function rememberCapabilityName(seen, name, origin) {
|
|
3383
|
+
const previous = seen.get(name);
|
|
3384
|
+
if (previous) {
|
|
3385
|
+
throw new Error(`Duplicate capability ${name}: ${origin} conflicts with ${previous}. Capability names must be unique across embedded runner config and referenced contracts.`);
|
|
3386
|
+
}
|
|
3387
|
+
seen.set(name, origin);
|
|
3388
|
+
}
|
|
3389
|
+
function mergeContractIntoRuntimeConfig(config, contract, origin, seenCapabilities) {
|
|
3313
3390
|
const resources = new Map((contract.resources ?? []).map((resource) => [resource.name, resource]));
|
|
3314
3391
|
for (const context of contract.contexts) {
|
|
3315
3392
|
if (!config.contexts) config.contexts = {};
|
|
@@ -3320,7 +3397,8 @@ function mergeContractIntoRuntimeConfig(config, contract) {
|
|
|
3320
3397
|
if (context) config.trusted_context = runtimeContextFromSpec(context);
|
|
3321
3398
|
}
|
|
3322
3399
|
if (!config.capabilities) config.capabilities = [];
|
|
3323
|
-
for (const capability of contract.capabilities) {
|
|
3400
|
+
for (const [capabilityIndex, capability] of contract.capabilities.entries()) {
|
|
3401
|
+
rememberCapabilityName(seenCapabilities, capability.name, `${origin} capabilities[${capabilityIndex}]`);
|
|
3324
3402
|
config.capabilities.push(runtimeCapabilityFromSpec(capability, resources, config));
|
|
3325
3403
|
}
|
|
3326
3404
|
}
|
|
@@ -3364,7 +3442,11 @@ function runtimeCapabilityFromSpec(capability, resources, config) {
|
|
|
3364
3442
|
runtime.allowed_columns = capability.proposal.allowed_fields;
|
|
3365
3443
|
runtime.conflict_guard = capability.proposal.conflict_guard;
|
|
3366
3444
|
runtime.approval = capability.proposal.approval;
|
|
3367
|
-
|
|
3445
|
+
runtime.writeback = {
|
|
3446
|
+
mode: capability.proposal.writeback?.mode ?? "direct_sql",
|
|
3447
|
+
...capability.proposal.writeback?.executor ? { executor: capability.proposal.writeback.executor } : {}
|
|
3448
|
+
};
|
|
3449
|
+
if (capability.proposal.writeback?.executor) {
|
|
3368
3450
|
runtime.executor = capability.proposal.writeback.executor;
|
|
3369
3451
|
}
|
|
3370
3452
|
}
|
|
@@ -3522,11 +3604,11 @@ async function serveStdio(options = {}) {
|
|
|
3522
3604
|
const transport = new StdioServerTransport();
|
|
3523
3605
|
await server.connect(transport);
|
|
3524
3606
|
process.stderr.write("synapsor-runner MCP stdio server ready. Waiting for an MCP client on stdio; logs stay on stderr.\n");
|
|
3525
|
-
await new Promise((
|
|
3607
|
+
await new Promise((resolve2) => {
|
|
3526
3608
|
const previousOnClose = transport.onclose;
|
|
3527
3609
|
const close = () => {
|
|
3528
3610
|
runtime.close();
|
|
3529
|
-
|
|
3611
|
+
resolve2();
|
|
3530
3612
|
};
|
|
3531
3613
|
transport.onclose = () => {
|
|
3532
3614
|
previousOnClose?.();
|
|
@@ -3545,7 +3627,7 @@ async function startHttpMcpServer(options = {}) {
|
|
|
3545
3627
|
if (devNoAuth && !isLoopbackHost(host)) {
|
|
3546
3628
|
throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
|
|
3547
3629
|
}
|
|
3548
|
-
const authToken = devNoAuth ? void 0 : env
|
|
3630
|
+
const authToken = devNoAuth ? void 0 : envValue(env, authTokenEnv);
|
|
3549
3631
|
if (!devNoAuth && !authToken) {
|
|
3550
3632
|
throw new McpRuntimeError("HTTP_AUTH_TOKEN_MISSING", `${authTokenEnv} is not set. HTTP MCP requires bearer auth by default.`);
|
|
3551
3633
|
}
|
|
@@ -3569,11 +3651,11 @@ async function startHttpMcpServer(options = {}) {
|
|
|
3569
3651
|
});
|
|
3570
3652
|
});
|
|
3571
3653
|
try {
|
|
3572
|
-
await new Promise((
|
|
3654
|
+
await new Promise((resolve2, reject) => {
|
|
3573
3655
|
server.once("error", reject);
|
|
3574
3656
|
server.listen(port, host, () => {
|
|
3575
3657
|
server.off("error", reject);
|
|
3576
|
-
|
|
3658
|
+
resolve2();
|
|
3577
3659
|
});
|
|
3578
3660
|
});
|
|
3579
3661
|
} catch (error) {
|
|
@@ -3611,7 +3693,7 @@ async function startStreamableHttpMcpServer(options = {}) {
|
|
|
3611
3693
|
if (devNoAuth && !isLoopbackHost(host)) {
|
|
3612
3694
|
throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
|
|
3613
3695
|
}
|
|
3614
|
-
const authToken = devNoAuth ? void 0 : env
|
|
3696
|
+
const authToken = devNoAuth ? void 0 : envValue(env, authTokenEnv);
|
|
3615
3697
|
if (!devNoAuth && !authToken) {
|
|
3616
3698
|
throw new McpRuntimeError("HTTP_AUTH_TOKEN_MISSING", `${authTokenEnv} is not set. Streamable HTTP MCP requires bearer auth by default.`);
|
|
3617
3699
|
}
|
|
@@ -3638,11 +3720,11 @@ async function startStreamableHttpMcpServer(options = {}) {
|
|
|
3638
3720
|
});
|
|
3639
3721
|
});
|
|
3640
3722
|
try {
|
|
3641
|
-
await new Promise((
|
|
3723
|
+
await new Promise((resolve2, reject) => {
|
|
3642
3724
|
server.once("error", reject);
|
|
3643
3725
|
server.listen(port, host, () => {
|
|
3644
3726
|
server.off("error", reject);
|
|
3645
|
-
|
|
3727
|
+
resolve2();
|
|
3646
3728
|
});
|
|
3647
3729
|
});
|
|
3648
3730
|
} catch (error) {
|
|
@@ -3966,20 +4048,20 @@ function isLoopbackHost(host) {
|
|
|
3966
4048
|
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
3967
4049
|
}
|
|
3968
4050
|
async function closeHttpServer(server, runtime) {
|
|
3969
|
-
await new Promise((
|
|
4051
|
+
await new Promise((resolve2, reject) => {
|
|
3970
4052
|
server.close((error) => {
|
|
3971
4053
|
if (error) reject(error);
|
|
3972
|
-
else
|
|
4054
|
+
else resolve2();
|
|
3973
4055
|
});
|
|
3974
4056
|
}).finally(() => {
|
|
3975
4057
|
runtime.close();
|
|
3976
4058
|
});
|
|
3977
4059
|
}
|
|
3978
4060
|
async function closeStreamableHttpServer(server, sessions) {
|
|
3979
|
-
await new Promise((
|
|
4061
|
+
await new Promise((resolve2, reject) => {
|
|
3980
4062
|
server.close((error) => {
|
|
3981
4063
|
if (error) reject(error);
|
|
3982
|
-
else
|
|
4064
|
+
else resolve2();
|
|
3983
4065
|
});
|
|
3984
4066
|
}).finally(() => closeStreamableSessions(sessions));
|
|
3985
4067
|
}
|
|
@@ -4023,8 +4105,8 @@ function listedLocalCapabilities(config) {
|
|
|
4023
4105
|
}
|
|
4024
4106
|
function createCloudClient(config, env) {
|
|
4025
4107
|
const cloud2 = requireCloudConfig(config);
|
|
4026
|
-
const baseUrl = env
|
|
4027
|
-
const runnerToken = env
|
|
4108
|
+
const baseUrl = envValue(env, cloud2.base_url_env);
|
|
4109
|
+
const runnerToken = envValue(env, cloud2.runner_token_env);
|
|
4028
4110
|
if (!baseUrl) throw new McpRuntimeError("CLOUD_BASE_URL_MISSING", `${cloud2.base_url_env} is not set.`);
|
|
4029
4111
|
if (!runnerToken) throw new McpRuntimeError("CLOUD_RUNNER_TOKEN_MISSING", `${cloud2.runner_token_env} is not set.`);
|
|
4030
4112
|
return new ControlPlaneClient({
|
|
@@ -4106,6 +4188,9 @@ async function callConfiguredTool(input) {
|
|
|
4106
4188
|
if (capability.kind === "proposal" && input.config.mode === "read_only") {
|
|
4107
4189
|
throw new McpRuntimeError("PROPOSALS_DISABLED", "This runner is in read_only mode; proposal tools are disabled.");
|
|
4108
4190
|
}
|
|
4191
|
+
if (capability.kind === "proposal" && input.config.mode === "review") {
|
|
4192
|
+
assertProposalWritebackResolvable(input.config, capability);
|
|
4193
|
+
}
|
|
4109
4194
|
const source = input.config.sources?.[capability.source];
|
|
4110
4195
|
if (!source) throw new McpRuntimeError("SOURCE_NOT_FOUND", `Unknown source: ${capability.source}`);
|
|
4111
4196
|
const context = resolveTrustedContext(input.config, input.env, capability);
|
|
@@ -4275,7 +4360,7 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
|
|
|
4275
4360
|
const targetType = typeof target2?.type === "string" ? target2.type : capability?.target.table ?? "object";
|
|
4276
4361
|
const targetId = target2?.id !== void 0 ? String(target2.id) : "unknown";
|
|
4277
4362
|
const executor = writebackExecutorName(legacy.writeback);
|
|
4278
|
-
const writebackMode = executor && executor !== "sql_update" && executor !== "trusted_worker_required" ? "app_handler" : "direct_update";
|
|
4363
|
+
const writebackMode = executor === "read_only" || executor === "none" ? "proposal_only" : executor && executor !== "sql_update" && executor !== "trusted_worker_required" ? "app_handler" : "direct_update";
|
|
4279
4364
|
return {
|
|
4280
4365
|
ok: true,
|
|
4281
4366
|
summary: `Created proposal ${proposalId} for ${targetType} ${targetId}. Source database changed: no.`,
|
|
@@ -4330,6 +4415,44 @@ function writebackExecutorName(value) {
|
|
|
4330
4415
|
if (!isRecord4(value)) return void 0;
|
|
4331
4416
|
return typeof value.executor === "string" ? value.executor : typeof value.mode === "string" ? value.mode : void 0;
|
|
4332
4417
|
}
|
|
4418
|
+
function capabilityWritebackMode2(capability) {
|
|
4419
|
+
const mode = capability.writeback?.mode;
|
|
4420
|
+
if (mode === "direct_sql" || mode === "app_handler" || mode === "cloud_worker" || mode === "none") return mode;
|
|
4421
|
+
if (capability.executor && capability.executor !== "sql_update") return "app_handler";
|
|
4422
|
+
return "direct_sql";
|
|
4423
|
+
}
|
|
4424
|
+
function capabilityWritebackExecutor(capability) {
|
|
4425
|
+
return capability.writeback?.executor ?? capability.executor;
|
|
4426
|
+
}
|
|
4427
|
+
function assertProposalWritebackResolvable(config, capability) {
|
|
4428
|
+
if (capability.kind !== "proposal") return;
|
|
4429
|
+
const mode = capabilityWritebackMode2(capability);
|
|
4430
|
+
if (mode === "none" || mode === "cloud_worker") return;
|
|
4431
|
+
if (mode === "direct_sql") {
|
|
4432
|
+
const source = config.sources?.[capability.source];
|
|
4433
|
+
if (!source) {
|
|
4434
|
+
throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares DIRECT SQL writeback but source ${capability.source} is not configured.`);
|
|
4435
|
+
}
|
|
4436
|
+
if (source.read_only === true) {
|
|
4437
|
+
throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares DIRECT SQL writeback but source ${capability.source} is read-only.`);
|
|
4438
|
+
}
|
|
4439
|
+
if (!source.write_url_env) {
|
|
4440
|
+
throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares DIRECT SQL writeback but source ${capability.source} has no write_url_env.`);
|
|
4441
|
+
}
|
|
4442
|
+
return;
|
|
4443
|
+
}
|
|
4444
|
+
const executorName = capabilityWritebackExecutor(capability);
|
|
4445
|
+
if (!executorName || executorName === "sql_update") {
|
|
4446
|
+
throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares HANDLER writeback but has no executor name.`);
|
|
4447
|
+
}
|
|
4448
|
+
const executor = config.executors?.[executorName];
|
|
4449
|
+
if (!isRecord4(executor)) {
|
|
4450
|
+
throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares HANDLER writeback but executor ${executorName} is not configured.`);
|
|
4451
|
+
}
|
|
4452
|
+
if (executor.type !== "http_handler" && executor.type !== "command_handler") {
|
|
4453
|
+
throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares HANDLER writeback but executor ${executorName} is not an app-owned handler.`);
|
|
4454
|
+
}
|
|
4455
|
+
}
|
|
4333
4456
|
function evidenceHandle(bundleId) {
|
|
4334
4457
|
return {
|
|
4335
4458
|
bundle_id: bundleId,
|
|
@@ -4389,6 +4512,9 @@ function buildChangeSet(input) {
|
|
|
4389
4512
|
enforcePatchGuards(input.capability, before, patch);
|
|
4390
4513
|
const after = { ...before, ...patch };
|
|
4391
4514
|
const guard = expectedVersionGuard(input.capability, before);
|
|
4515
|
+
const writebackMode = capabilityWritebackMode2(input.capability);
|
|
4516
|
+
const changeSetWritebackMode = writebackMode === "none" ? "read_only" : "trusted_worker_required";
|
|
4517
|
+
const writebackExecutor = writebackMode === "none" ? "none" : writebackMode === "cloud_worker" ? "cloud_worker" : writebackMode === "direct_sql" ? "sql_update" : capabilityWritebackExecutor(input.capability);
|
|
4392
4518
|
const proposalCore = {
|
|
4393
4519
|
schema_version: protocolVersions.changeSet,
|
|
4394
4520
|
proposal_id: stableId("wrp", {
|
|
@@ -4436,8 +4562,8 @@ function buildChangeSet(input) {
|
|
|
4436
4562
|
},
|
|
4437
4563
|
writeback: {
|
|
4438
4564
|
status: "not_applied",
|
|
4439
|
-
mode:
|
|
4440
|
-
executor:
|
|
4565
|
+
mode: changeSetWritebackMode,
|
|
4566
|
+
executor: writebackExecutor
|
|
4441
4567
|
},
|
|
4442
4568
|
source_database_mutated: false,
|
|
4443
4569
|
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -4460,7 +4586,7 @@ async function readCurrentRow(input) {
|
|
|
4460
4586
|
return readMysqlRow(input);
|
|
4461
4587
|
}
|
|
4462
4588
|
async function readPostgresRow(input) {
|
|
4463
|
-
const connectionString = input.env
|
|
4589
|
+
const connectionString = envValue(input.env, input.source.read_url_env);
|
|
4464
4590
|
if (!connectionString) throw new McpRuntimeError("SOURCE_CREDENTIAL_MISSING", `${input.source.read_url_env} is not set.`);
|
|
4465
4591
|
const pool = new Pool({ connectionString });
|
|
4466
4592
|
const client = await pool.connect();
|
|
@@ -4482,7 +4608,7 @@ async function readPostgresRow(input) {
|
|
|
4482
4608
|
}
|
|
4483
4609
|
}
|
|
4484
4610
|
async function readMysqlRow(input) {
|
|
4485
|
-
const uri = input.env
|
|
4611
|
+
const uri = envValue(input.env, input.source.read_url_env);
|
|
4486
4612
|
if (!uri) throw new McpRuntimeError("SOURCE_CREDENTIAL_MISSING", `${input.source.read_url_env} is not set.`);
|
|
4487
4613
|
const connection = await mysql.createConnection({ uri, dateStrings: true });
|
|
4488
4614
|
try {
|
|
@@ -4537,8 +4663,8 @@ function resolveTrustedContext(config, env, capability) {
|
|
|
4537
4663
|
if (provider === "environment") {
|
|
4538
4664
|
const tenantEnv = String(values.tenant_id_env ?? "SYNAPSOR_TENANT_ID");
|
|
4539
4665
|
const principalEnv = String(values.principal_env ?? "SYNAPSOR_PRINCIPAL");
|
|
4540
|
-
const tenant = env
|
|
4541
|
-
const principal = env
|
|
4666
|
+
const tenant = envValue(env, tenantEnv);
|
|
4667
|
+
const principal = envValue(env, principalEnv);
|
|
4542
4668
|
if (!tenant || !principal) throw new McpRuntimeError("TRUSTED_BINDING_MISSING", `${tenantEnv} and ${principalEnv} must be set.`);
|
|
4543
4669
|
return { tenant_id: tenant, principal, provenance: "environment" };
|
|
4544
4670
|
}
|
|
@@ -4551,8 +4677,19 @@ function resolveTrustedContext(config, env, capability) {
|
|
|
4551
4677
|
throw new McpRuntimeError("TRUSTED_CONTEXT_UNSUPPORTED", `${provider} trusted context is not available in local stdio mode.`);
|
|
4552
4678
|
}
|
|
4553
4679
|
function valueFromEnvOrLiteral(envName, literal, env) {
|
|
4554
|
-
if (typeof envName === "string"
|
|
4555
|
-
|
|
4680
|
+
if (typeof envName === "string") {
|
|
4681
|
+
const value2 = envValue(env, envName);
|
|
4682
|
+
if (value2) return value2;
|
|
4683
|
+
}
|
|
4684
|
+
if (typeof literal !== "string") return void 0;
|
|
4685
|
+
const value = literal.trim();
|
|
4686
|
+
return value.length > 0 ? value : void 0;
|
|
4687
|
+
}
|
|
4688
|
+
function envValue(env, name) {
|
|
4689
|
+
const value = env[name];
|
|
4690
|
+
if (value === void 0) return void 0;
|
|
4691
|
+
const trimmed = value.trim();
|
|
4692
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
4556
4693
|
}
|
|
4557
4694
|
function validateToolArgs(capability, args) {
|
|
4558
4695
|
for (const [name, spec] of Object.entries(capability.args)) {
|
|
@@ -6421,7 +6558,7 @@ async function startPolling(config, adapters2, signal) {
|
|
|
6421
6558
|
} catch (error) {
|
|
6422
6559
|
logger.error("runner loop failed", { error: error instanceof Error ? error.message : String(error) });
|
|
6423
6560
|
}
|
|
6424
|
-
await new Promise((
|
|
6561
|
+
await new Promise((resolve2) => setTimeout(resolve2, config.pollIntervalMs));
|
|
6425
6562
|
}
|
|
6426
6563
|
}
|
|
6427
6564
|
|
|
@@ -6857,11 +6994,11 @@ async function startLocalUiServer(options = {}) {
|
|
|
6857
6994
|
});
|
|
6858
6995
|
}
|
|
6859
6996
|
});
|
|
6860
|
-
await new Promise((
|
|
6997
|
+
await new Promise((resolve2, reject) => {
|
|
6861
6998
|
server.once("error", reject);
|
|
6862
6999
|
server.listen(options.port ?? 0, host, () => {
|
|
6863
7000
|
server.off("error", reject);
|
|
6864
|
-
|
|
7001
|
+
resolve2();
|
|
6865
7002
|
});
|
|
6866
7003
|
});
|
|
6867
7004
|
const address = server.address();
|
|
@@ -6874,10 +7011,10 @@ async function startLocalUiServer(options = {}) {
|
|
|
6874
7011
|
port,
|
|
6875
7012
|
token,
|
|
6876
7013
|
csrfToken,
|
|
6877
|
-
close: () => new Promise((
|
|
7014
|
+
close: () => new Promise((resolve2, reject) => {
|
|
6878
7015
|
server.close((error) => {
|
|
6879
7016
|
if (error) reject(error);
|
|
6880
|
-
else
|
|
7017
|
+
else resolve2();
|
|
6881
7018
|
});
|
|
6882
7019
|
})
|
|
6883
7020
|
};
|
|
@@ -8720,7 +8857,7 @@ async function writeGeneratedSmokeInputFile(lookupArg, objectId, force) {
|
|
|
8720
8857
|
`, force);
|
|
8721
8858
|
}
|
|
8722
8859
|
async function maybeRunGeneratedSmokeCall(input) {
|
|
8723
|
-
const required = uniqueStrings([input.readUrlEnv, input.tenantEnv, input.principalEnv]).filter((envName) => !input.env
|
|
8860
|
+
const required = uniqueStrings([input.readUrlEnv, input.tenantEnv, input.principalEnv]).filter((envName) => !envValue2(input.env, envName));
|
|
8724
8861
|
if (required.length > 0) {
|
|
8725
8862
|
return [
|
|
8726
8863
|
"Smoke call not run yet.",
|
|
@@ -9353,8 +9490,7 @@ function bundleReadme(contract) {
|
|
|
9353
9490
|
}
|
|
9354
9491
|
async function configValidate(args) {
|
|
9355
9492
|
const configPath = optionalArg(args, "--config") ?? "synapsor.runner.json";
|
|
9356
|
-
const
|
|
9357
|
-
const result = validateRunnerCapabilityConfig(parsed);
|
|
9493
|
+
const result = await validateConfigFile(configPath);
|
|
9358
9494
|
if (args.includes("--json")) {
|
|
9359
9495
|
process2.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
9360
9496
|
`);
|
|
@@ -9375,6 +9511,30 @@ async function configValidate(args) {
|
|
|
9375
9511
|
}
|
|
9376
9512
|
return result.ok ? 0 : 1;
|
|
9377
9513
|
}
|
|
9514
|
+
async function validateConfigFile(configPath) {
|
|
9515
|
+
const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
|
|
9516
|
+
const raw = validateRunnerCapabilityConfig(parsed);
|
|
9517
|
+
if (!raw.ok) return raw;
|
|
9518
|
+
try {
|
|
9519
|
+
const resolved = resolveRuntimeConfig(parsed, path3.dirname(path3.resolve(configPath)));
|
|
9520
|
+
const resolvedValidation = validateRunnerCapabilityConfig(resolved);
|
|
9521
|
+
return {
|
|
9522
|
+
ok: resolvedValidation.ok,
|
|
9523
|
+
errors: resolvedValidation.errors,
|
|
9524
|
+
warnings: [...raw.warnings, ...resolvedValidation.warnings]
|
|
9525
|
+
};
|
|
9526
|
+
} catch (error) {
|
|
9527
|
+
return {
|
|
9528
|
+
ok: false,
|
|
9529
|
+
errors: [{
|
|
9530
|
+
path: "$.contracts",
|
|
9531
|
+
code: "CONTRACT_RESOLUTION_FAILED",
|
|
9532
|
+
message: error instanceof Error ? error.message : String(error)
|
|
9533
|
+
}],
|
|
9534
|
+
warnings: raw.warnings
|
|
9535
|
+
};
|
|
9536
|
+
}
|
|
9537
|
+
}
|
|
9378
9538
|
async function configShow(args) {
|
|
9379
9539
|
const configPath = optionalArg(args, "--config") ?? "synapsor.runner.json";
|
|
9380
9540
|
const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
|
|
@@ -9438,13 +9598,49 @@ function trustedContextsForDoctor(config) {
|
|
|
9438
9598
|
return contexts;
|
|
9439
9599
|
}
|
|
9440
9600
|
function envPresenceCheck(envName, message) {
|
|
9601
|
+
const value = envValue2(process2.env, envName);
|
|
9441
9602
|
return {
|
|
9442
9603
|
name: `env:${envName}`,
|
|
9443
|
-
ok: Boolean(
|
|
9444
|
-
level:
|
|
9445
|
-
message:
|
|
9604
|
+
ok: Boolean(value),
|
|
9605
|
+
level: value ? "pass" : "fail",
|
|
9606
|
+
message: value ? `${envName} is set.` : message
|
|
9446
9607
|
};
|
|
9447
9608
|
}
|
|
9609
|
+
function proposalWritebackResolutionDoctorCheck(config, capability) {
|
|
9610
|
+
const mode = capabilityWritebackMode2(capability);
|
|
9611
|
+
if (mode === "none") {
|
|
9612
|
+
return {
|
|
9613
|
+
name: `capability:${capability.name}:writeback-resolution`,
|
|
9614
|
+
ok: true,
|
|
9615
|
+
level: "pass",
|
|
9616
|
+
message: "Capability explicitly declares no local writeback; proposals are review records only."
|
|
9617
|
+
};
|
|
9618
|
+
}
|
|
9619
|
+
if (mode === "cloud_worker") {
|
|
9620
|
+
return {
|
|
9621
|
+
name: `capability:${capability.name}:writeback-resolution`,
|
|
9622
|
+
ok: true,
|
|
9623
|
+
level: "pass",
|
|
9624
|
+
message: "Capability declares cloud-worker writeback; local apply is intentionally unavailable."
|
|
9625
|
+
};
|
|
9626
|
+
}
|
|
9627
|
+
try {
|
|
9628
|
+
assertProposalWritebackResolvable(config, capability);
|
|
9629
|
+
return {
|
|
9630
|
+
name: `capability:${capability.name}:writeback-resolution`,
|
|
9631
|
+
ok: true,
|
|
9632
|
+
level: "pass",
|
|
9633
|
+
message: mode === "direct_sql" ? "Direct SQL writeback definition resolves to a source and writer env var name." : `App-owned handler writeback resolves to executor ${capabilityWritebackExecutor(capability)}.`
|
|
9634
|
+
};
|
|
9635
|
+
} catch (error) {
|
|
9636
|
+
return {
|
|
9637
|
+
name: `capability:${capability.name}:writeback-resolution`,
|
|
9638
|
+
ok: false,
|
|
9639
|
+
level: "fail",
|
|
9640
|
+
message: error instanceof Error ? error.message : String(error)
|
|
9641
|
+
};
|
|
9642
|
+
}
|
|
9643
|
+
}
|
|
9448
9644
|
async function httpHandlerReachabilityCheck(executorName, rawUrl, timeoutMs) {
|
|
9449
9645
|
try {
|
|
9450
9646
|
const url = new URL(rawUrl);
|
|
@@ -9494,7 +9690,7 @@ function safeReachabilityError(error) {
|
|
|
9494
9690
|
return "connection failed";
|
|
9495
9691
|
}
|
|
9496
9692
|
async function inspectConfiguredSource(input) {
|
|
9497
|
-
if (!process2.env
|
|
9693
|
+
if (!envValue2(process2.env, input.source.read_url_env)) return;
|
|
9498
9694
|
const capabilities = (input.config.capabilities ?? []).filter((capability) => capability.source === input.sourceName);
|
|
9499
9695
|
const schemas = Array.from(new Set(capabilities.map((capability) => capability.target.schema)));
|
|
9500
9696
|
for (const schema of schemas.length ? schemas : [void 0]) {
|
|
@@ -9837,15 +10033,15 @@ function firstRunConfigEnvChecks(config) {
|
|
|
9837
10033
|
String(values.tenant_id_env ?? "SYNAPSOR_TENANT_ID"),
|
|
9838
10034
|
String(values.principal_env ?? "SYNAPSOR_PRINCIPAL")
|
|
9839
10035
|
]) {
|
|
9840
|
-
checks.push(process2.env
|
|
10036
|
+
checks.push(envValue2(process2.env, envName) ? pass(`env-${envName}`, `${envName} is set for ${contextName}.`, "Trusted tenant/principal values must come from the launcher, not the model.", "No action needed.") : warn(`env-${envName}`, `${envName} is not set for ${contextName}.`, "Trusted tenant/principal values must come from the launcher, not the model.", `Set ${envName}, or use the generated .env.example as a template.`));
|
|
9841
10037
|
}
|
|
9842
10038
|
}
|
|
9843
10039
|
for (const [sourceName, source] of Object.entries(config.sources ?? {})) {
|
|
9844
|
-
checks.push(process2.env
|
|
10040
|
+
checks.push(envValue2(process2.env, source.read_url_env) ? pass(`env-${source.read_url_env}`, `${source.read_url_env} is set for ${sourceName}.`, "Configured capabilities need a read credential env var to inspect/propose against your DB.", "No action needed.") : warn(`env-${source.read_url_env}`, `${source.read_url_env} is not set for ${sourceName}.`, "Configured capabilities need a read credential env var to inspect/propose against your DB.", `Set ${source.read_url_env} before running doctor, tools preview, or mcp serve against your own database.`));
|
|
9845
10041
|
if (source.write_url_env) {
|
|
9846
|
-
checks.push(process2.env
|
|
9847
|
-
const readValue = process2.env
|
|
9848
|
-
const writeValue = process2.env
|
|
10042
|
+
checks.push(envValue2(process2.env, source.write_url_env) ? pass(`env-${source.write_url_env}`, `${source.write_url_env} is set for ${sourceName}.`, "Trusted writeback needs a separate writer credential outside the MCP client.", "No action needed.") : warn(`env-${source.write_url_env}`, `${source.write_url_env} is not set for ${sourceName}.`, "Trusted writeback needs a separate writer credential outside the MCP client.", `Set ${source.write_url_env} only when you are ready to apply an approved writeback job.`));
|
|
10043
|
+
const readValue = envValue2(process2.env, source.read_url_env);
|
|
10044
|
+
const writeValue = envValue2(process2.env, source.write_url_env);
|
|
9849
10045
|
if (readValue && writeValue && readValue === writeValue) {
|
|
9850
10046
|
checks.push(fail(`credential-split-${sourceName}`, `Read and write env vars resolve to the same credential for ${sourceName}.`, "Read/proposal authority and writeback authority must be separated.", "Use a read-only credential for MCP reads and a separate writer credential only for trusted apply."));
|
|
9851
10047
|
}
|
|
@@ -9900,11 +10096,11 @@ function fail(name, checked, why, fix) {
|
|
|
9900
10096
|
return { name, status: "fail", checked, why, fix };
|
|
9901
10097
|
}
|
|
9902
10098
|
async function isPortAvailable(port) {
|
|
9903
|
-
return new Promise((
|
|
10099
|
+
return new Promise((resolve2) => {
|
|
9904
10100
|
const server = net.createServer();
|
|
9905
|
-
server.once("error", () =>
|
|
10101
|
+
server.once("error", () => resolve2(false));
|
|
9906
10102
|
server.listen(port, "127.0.0.1", () => {
|
|
9907
|
-
server.close(() =>
|
|
10103
|
+
server.close(() => resolve2(true));
|
|
9908
10104
|
});
|
|
9909
10105
|
});
|
|
9910
10106
|
}
|
|
@@ -9924,9 +10120,10 @@ async function localDoctor(args) {
|
|
|
9924
10120
|
const allowSharedCredential = args.includes("--allow-shared-credential");
|
|
9925
10121
|
const checkHandlers = args.includes("--check-handlers");
|
|
9926
10122
|
const checkWriteback = args.includes("--check-writeback") || args.includes("--check-db");
|
|
9927
|
-
const
|
|
10123
|
+
const rawConfig = JSON.parse(await fs3.readFile(configPath, "utf8"));
|
|
10124
|
+
let parsed = rawConfig;
|
|
9928
10125
|
const checks = [];
|
|
9929
|
-
const validation =
|
|
10126
|
+
const validation = await validateConfigFile(configPath);
|
|
9930
10127
|
checks.push({
|
|
9931
10128
|
name: "config-valid",
|
|
9932
10129
|
ok: validation.ok,
|
|
@@ -9936,6 +10133,9 @@ async function localDoctor(args) {
|
|
|
9936
10133
|
for (const warning of validation.warnings) {
|
|
9937
10134
|
checks.push({ name: `config-warning:${warning.code}`, ok: true, level: "warn", message: warning.message });
|
|
9938
10135
|
}
|
|
10136
|
+
if (validation.ok) {
|
|
10137
|
+
parsed = await readRuntimeConfig(configPath);
|
|
10138
|
+
}
|
|
9939
10139
|
const contextsToCheck = trustedContextsForDoctor(parsed);
|
|
9940
10140
|
for (const [contextName, contextValues] of contextsToCheck) {
|
|
9941
10141
|
const tenantEnv = String(contextValues.tenant_id_env ?? "SYNAPSOR_TENANT_ID");
|
|
@@ -9945,14 +10145,19 @@ async function localDoctor(args) {
|
|
|
9945
10145
|
}
|
|
9946
10146
|
}
|
|
9947
10147
|
const sources = parsed.sources ?? {};
|
|
10148
|
+
if (parsed.mode === "review") {
|
|
10149
|
+
for (const capability of (parsed.capabilities ?? []).filter((item) => item.kind === "proposal")) {
|
|
10150
|
+
checks.push(proposalWritebackResolutionDoctorCheck(parsed, capability));
|
|
10151
|
+
}
|
|
10152
|
+
}
|
|
9948
10153
|
for (const [sourceName, source] of Object.entries(sources)) {
|
|
9949
10154
|
checks.push(envPresenceCheck(source.read_url_env, `${source.read_url_env} is required for ${sourceName} reads.`));
|
|
9950
10155
|
if (parsed.mode === "review") {
|
|
9951
10156
|
if (sourceNeedsSqlWriteback(parsed, sourceName)) {
|
|
9952
10157
|
if (source.write_url_env) {
|
|
9953
10158
|
checks.push(envPresenceCheck(source.write_url_env, `${source.write_url_env} is required for trusted writeback in review mode.`));
|
|
9954
|
-
const readValue = process2.env
|
|
9955
|
-
const writeValue = process2.env
|
|
10159
|
+
const readValue = envValue2(process2.env, source.read_url_env);
|
|
10160
|
+
const writeValue = envValue2(process2.env, source.write_url_env);
|
|
9956
10161
|
if (readValue && writeValue && readValue === writeValue) {
|
|
9957
10162
|
checks.push({
|
|
9958
10163
|
name: `source:${sourceName}:credential-separation`,
|
|
@@ -9966,7 +10171,7 @@ async function localDoctor(args) {
|
|
|
9966
10171
|
} else {
|
|
9967
10172
|
checks.push({ name: `source:${sourceName}:write-url-env`, ok: false, level: "fail", message: "SQL writeback proposal capabilities require write_url_env for trusted writeback." });
|
|
9968
10173
|
}
|
|
9969
|
-
const writeUrl = source.write_url_env ? process2.env
|
|
10174
|
+
const writeUrl = source.write_url_env ? envValue2(process2.env, source.write_url_env) : void 0;
|
|
9970
10175
|
if (checkWriteback && writeUrl) {
|
|
9971
10176
|
checks.push(...await directSqlWritebackDoctorChecks(parsed, sourceName, source, writeUrl));
|
|
9972
10177
|
} else if (checkWriteback) {
|
|
@@ -9994,7 +10199,7 @@ async function localDoctor(args) {
|
|
|
9994
10199
|
const urlEnv = String(executor.url_env ?? "");
|
|
9995
10200
|
if (urlEnv) {
|
|
9996
10201
|
checks.push(envPresenceCheck(urlEnv, `${urlEnv} is required for http_handler executor ${executorName}.`));
|
|
9997
|
-
const handlerUrl = process2.env
|
|
10202
|
+
const handlerUrl = envValue2(process2.env, urlEnv);
|
|
9998
10203
|
if (checkHandlers && handlerUrl) {
|
|
9999
10204
|
checks.push(await httpHandlerReachabilityCheck(executorName, handlerUrl, Number(executor.timeout_ms ?? 3e3)));
|
|
10000
10205
|
} else if (!checkHandlers) {
|
|
@@ -10108,7 +10313,7 @@ async function directSqlWritebackDoctorChecks(config, sourceName, source, writeU
|
|
|
10108
10313
|
function directSqlProposalCapabilities(config, sourceName) {
|
|
10109
10314
|
return (config.capabilities ?? []).filter((capability) => {
|
|
10110
10315
|
if (capability.kind !== "proposal" || capability.source !== sourceName) return false;
|
|
10111
|
-
return (capability
|
|
10316
|
+
return capabilityWritebackMode2(capability) === "direct_sql";
|
|
10112
10317
|
});
|
|
10113
10318
|
}
|
|
10114
10319
|
async function rollbackOnlyTargetProbe(engine, databaseUrl, capability) {
|
|
@@ -10299,7 +10504,7 @@ async function applyProposal(args, proposalId) {
|
|
|
10299
10504
|
const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE ?? "./.synapsor/local.db";
|
|
10300
10505
|
const dryRun = args.includes("--dry-run") || process2.env.SYNAPSOR_DRY_RUN === "true";
|
|
10301
10506
|
const runnerId = optionalArg(args, "--runner") ?? process2.env.SYNAPSOR_RUNNER_ID ?? "local_runner";
|
|
10302
|
-
const config =
|
|
10507
|
+
const config = await readRuntimeConfig(configPath);
|
|
10303
10508
|
const resolvedProposalId = await resolveProposalId(proposalId, storePath);
|
|
10304
10509
|
const validation = validateRunnerCapabilityConfig(config);
|
|
10305
10510
|
if (!validation.ok) {
|
|
@@ -10316,6 +10521,9 @@ async function applyProposal(args, proposalId) {
|
|
|
10316
10521
|
const proposal = requireLocalProposal(store, resolvedProposalId);
|
|
10317
10522
|
const capability = findProposalCapability(config, proposal);
|
|
10318
10523
|
const executorName = proposalExecutorName(proposal, capability);
|
|
10524
|
+
if (executorName === "none" || executorName === "cloud_worker") {
|
|
10525
|
+
throw new Error(`proposal ${resolvedProposalId} is not locally applyable; capability ${capability.name} declares ${executorName === "none" ? "no local writeback" : "cloud-worker writeback"}.`);
|
|
10526
|
+
}
|
|
10319
10527
|
if (executorName === "sql_update") {
|
|
10320
10528
|
const job = store.createWritebackJobFromProposal(resolvedProposalId, {
|
|
10321
10529
|
project_id: optionalArg(args, "--project") ?? "local",
|
|
@@ -10373,16 +10581,19 @@ async function applySqlJob(job, configPath, storePath, dryRun, env = process2.en
|
|
|
10373
10581
|
return result;
|
|
10374
10582
|
}
|
|
10375
10583
|
async function resolveSqlWriteDatabaseUrl(job, configPath, env) {
|
|
10376
|
-
const config =
|
|
10584
|
+
const config = await readRuntimeConfig(configPath);
|
|
10377
10585
|
const source = config.sources?.[job.source_id];
|
|
10378
10586
|
const writeUrlEnv = source?.write_url_env;
|
|
10379
|
-
if (writeUrlEnv
|
|
10380
|
-
|
|
10587
|
+
if (writeUrlEnv) {
|
|
10588
|
+
const value = envValue2(env, writeUrlEnv);
|
|
10589
|
+
if (value) return value;
|
|
10590
|
+
}
|
|
10591
|
+
return envValue2(env, "SYNAPSOR_DATABASE_URL") || "";
|
|
10381
10592
|
}
|
|
10382
10593
|
function sourceNeedsSqlWriteback(config, sourceName) {
|
|
10383
10594
|
return (config.capabilities ?? []).some((capability) => {
|
|
10384
10595
|
if (capability.kind !== "proposal" || capability.source !== sourceName) return false;
|
|
10385
|
-
return (capability
|
|
10596
|
+
return capabilityWritebackMode2(capability) === "direct_sql";
|
|
10386
10597
|
});
|
|
10387
10598
|
}
|
|
10388
10599
|
function findProposalCapability(config, proposal) {
|
|
@@ -10401,8 +10612,12 @@ function findProposalCapability(config, proposal) {
|
|
|
10401
10612
|
return capability;
|
|
10402
10613
|
}
|
|
10403
10614
|
function proposalExecutorName(proposal, capability) {
|
|
10615
|
+
const mode = capabilityWritebackMode2(capability);
|
|
10616
|
+
if (mode === "none") return "none";
|
|
10617
|
+
if (mode === "cloud_worker") return "cloud_worker";
|
|
10618
|
+
if (mode === "direct_sql") return "sql_update";
|
|
10404
10619
|
const writeback2 = proposal.change_set.writeback;
|
|
10405
|
-
return capability
|
|
10620
|
+
return capabilityWritebackExecutor(capability) ?? (typeof writeback2.executor === "string" ? writeback2.executor : void 0) ?? "sql_update";
|
|
10406
10621
|
}
|
|
10407
10622
|
function executorConfig(config, executorName) {
|
|
10408
10623
|
const raw = config.executors?.[executorName];
|
|
@@ -10427,7 +10642,7 @@ async function applyHttpHandlerProposal(input) {
|
|
|
10427
10642
|
executor: input.executorName,
|
|
10428
10643
|
request: prepared.request
|
|
10429
10644
|
});
|
|
10430
|
-
const url = input.env
|
|
10645
|
+
const url = envValue2(input.env, input.executor.url_env);
|
|
10431
10646
|
if (!url) throw new Error(`${input.executor.url_env} is not set`);
|
|
10432
10647
|
const headers = {
|
|
10433
10648
|
accept: "application/json",
|
|
@@ -10435,7 +10650,7 @@ async function applyHttpHandlerProposal(input) {
|
|
|
10435
10650
|
"idempotency-key": prepared.request.idempotency_key
|
|
10436
10651
|
};
|
|
10437
10652
|
if (input.executor.auth) {
|
|
10438
|
-
const token = input.env
|
|
10653
|
+
const token = envValue2(input.env, input.executor.auth.token_env);
|
|
10439
10654
|
if (!token) throw new Error(`${input.executor.auth.token_env} is not set`);
|
|
10440
10655
|
headers.authorization = `Bearer ${token}`;
|
|
10441
10656
|
}
|
|
@@ -10450,7 +10665,7 @@ async function applyHttpHandlerProposal(input) {
|
|
|
10450
10665
|
headers["x-synapsor-issued-at"] = issuedAt;
|
|
10451
10666
|
headers["x-synapsor-proposal-id"] = prepared.proposal.proposal_id;
|
|
10452
10667
|
if (input.executor.signing_secret_env) {
|
|
10453
|
-
const signingSecret = input.env
|
|
10668
|
+
const signingSecret = envValue2(input.env, input.executor.signing_secret_env);
|
|
10454
10669
|
if (!signingSecret) throw new Error(`${input.executor.signing_secret_env} is not set`);
|
|
10455
10670
|
headers["x-synapsor-signature"] = signHandlerRequestBody(requestBody, signingSecret);
|
|
10456
10671
|
}
|
|
@@ -10488,7 +10703,7 @@ async function applyCommandHandlerProposal(input) {
|
|
|
10488
10703
|
executor: input.executorName,
|
|
10489
10704
|
request: prepared.request
|
|
10490
10705
|
});
|
|
10491
|
-
const commandText = input.env
|
|
10706
|
+
const commandText = envValue2(input.env, input.executor.command_env);
|
|
10492
10707
|
if (!commandText) throw new Error(`${input.executor.command_env} is not set`);
|
|
10493
10708
|
const [command, ...commandArgs] = splitCommand(commandText);
|
|
10494
10709
|
if (!command) throw new Error(`${input.executor.command_env} did not contain a command`);
|
|
@@ -10738,7 +10953,7 @@ function safeHandlerDetails(value) {
|
|
|
10738
10953
|
return redactConfig(value);
|
|
10739
10954
|
}
|
|
10740
10955
|
async function runCommandHandler(command, args, request, timeoutMs) {
|
|
10741
|
-
return new Promise((
|
|
10956
|
+
return new Promise((resolve2) => {
|
|
10742
10957
|
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: process2.env });
|
|
10743
10958
|
let stdout = "";
|
|
10744
10959
|
let stderr = "";
|
|
@@ -10747,7 +10962,7 @@ async function runCommandHandler(command, args, request, timeoutMs) {
|
|
|
10747
10962
|
if (settled) return;
|
|
10748
10963
|
settled = true;
|
|
10749
10964
|
clearTimeout(timeout);
|
|
10750
|
-
|
|
10965
|
+
resolve2(body);
|
|
10751
10966
|
};
|
|
10752
10967
|
const timeout = setTimeout(() => {
|
|
10753
10968
|
child.kill("SIGTERM");
|
|
@@ -10772,7 +10987,7 @@ async function runCommandHandler(command, args, request, timeoutMs) {
|
|
|
10772
10987
|
});
|
|
10773
10988
|
}
|
|
10774
10989
|
async function verifyLocalWritebackAuthority(job, configPath, storePath) {
|
|
10775
|
-
const config =
|
|
10990
|
+
const config = await readRuntimeConfig(configPath);
|
|
10776
10991
|
const validation = validateRunnerCapabilityConfig(config);
|
|
10777
10992
|
if (!validation.ok) {
|
|
10778
10993
|
throw new Error(`cannot apply writeback with invalid local config: ${validation.errors.map((error) => error.code).join(", ")}`);
|
|
@@ -10790,7 +11005,7 @@ async function verifyLocalWritebackAuthority(job, configPath, storePath) {
|
|
|
10790
11005
|
if (Date.parse(String(job.lease_expires_at)) < Date.now()) {
|
|
10791
11006
|
throw new Error("writeback job lease has expired");
|
|
10792
11007
|
}
|
|
10793
|
-
const proposalCapabilities = (config.capabilities ?? []).filter((capability) => capability.kind === "proposal" && capability.source === job.source_id);
|
|
11008
|
+
const proposalCapabilities = (config.capabilities ?? []).filter((capability) => capability.kind === "proposal" && capability.source === job.source_id && capabilityWritebackMode2(capability) === "direct_sql");
|
|
10794
11009
|
const matching = proposalCapabilities.find((capability) => capabilityMatchesJob(capability, job));
|
|
10795
11010
|
if (!matching) {
|
|
10796
11011
|
throw new Error("writeback job does not match any reviewed proposal capability in local config");
|
|
@@ -10979,7 +11194,7 @@ function formatReviewModeUp(input) {
|
|
|
10979
11194
|
} else {
|
|
10980
11195
|
lines.push(
|
|
10981
11196
|
` Streamable HTTP endpoint: http://${input.host}:${input.port}/mcp`,
|
|
10982
|
-
` Auth token env: ${input.authTokenEnv} (${process2.env
|
|
11197
|
+
` Auth token env: ${input.authTokenEnv} (${envValue2(process2.env, input.authTokenEnv) ? "set" : "missing"})`,
|
|
10983
11198
|
input.serveRequested ? input.dryRun ? " Status: dry run only; server not started." : " Status: starting after this checklist." : ` Start command: ${cliCommandName()} up --serve --config ${input.configPath} --store ${input.storePath} --port ${input.port} --auth-token-env ${input.authTokenEnv} --alias-mode ${input.aliasMode}`
|
|
10984
11199
|
);
|
|
10985
11200
|
}
|
|
@@ -10998,12 +11213,19 @@ function formatUpWritebackLines(config) {
|
|
|
10998
11213
|
const proposals2 = (config.capabilities ?? []).filter((capability) => capability.kind === "proposal");
|
|
10999
11214
|
if (proposals2.length === 0) return [" - no proposal capabilities; this config is read-only from Runner's perspective"];
|
|
11000
11215
|
return proposals2.map((capability) => {
|
|
11001
|
-
const
|
|
11002
|
-
if (
|
|
11216
|
+
const mode = capabilityWritebackMode2(capability);
|
|
11217
|
+
if (mode === "none") {
|
|
11218
|
+
return ` - ${capability.name}: proposal-only; no local writeback`;
|
|
11219
|
+
}
|
|
11220
|
+
if (mode === "cloud_worker") {
|
|
11221
|
+
return ` - ${capability.name}: cloud-worker writeback; local apply disabled`;
|
|
11222
|
+
}
|
|
11223
|
+
if (mode === "direct_sql") {
|
|
11003
11224
|
const source = config.sources?.[capability.source];
|
|
11004
11225
|
const envName = source?.write_url_env ?? "SYNAPSOR_DATABASE_URL";
|
|
11005
|
-
return ` - ${capability.name}: direct guarded one-row UPDATE via ${envName} (${process2.env
|
|
11226
|
+
return ` - ${capability.name}: direct guarded one-row UPDATE via ${envName} (${envValue2(process2.env, envName) ? "set" : "missing"})`;
|
|
11006
11227
|
}
|
|
11228
|
+
const executorName = capabilityWritebackExecutor(capability) ?? "missing_executor";
|
|
11007
11229
|
const executor = config.executors?.[executorName];
|
|
11008
11230
|
return ` - ${capability.name}: app-owned ${String(executor?.type ?? "executor")} ${executorName}`;
|
|
11009
11231
|
});
|
|
@@ -11018,14 +11240,14 @@ function formatUpHandlerLines(config) {
|
|
|
11018
11240
|
const tokenEnv = typeof auth?.token_env === "string" ? auth.token_env : void 0;
|
|
11019
11241
|
const signingSecretEnv = typeof executor.signing_secret_env === "string" ? executor.signing_secret_env : void 0;
|
|
11020
11242
|
lines.push(` - ${name}: http_handler`);
|
|
11021
|
-
if (urlEnv) lines.push(` url env: ${urlEnv} (${process2.env
|
|
11022
|
-
if (tokenEnv) lines.push(` bearer token env: ${tokenEnv} (${process2.env
|
|
11023
|
-
if (signingSecretEnv) lines.push(` signing secret env: ${signingSecretEnv} (${process2.env
|
|
11243
|
+
if (urlEnv) lines.push(` url env: ${urlEnv} (${envValue2(process2.env, urlEnv) ? "set" : "missing"})`);
|
|
11244
|
+
if (tokenEnv) lines.push(` bearer token env: ${tokenEnv} (${envValue2(process2.env, tokenEnv) ? "set" : "missing"})`);
|
|
11245
|
+
if (signingSecretEnv) lines.push(` signing secret env: ${signingSecretEnv} (${envValue2(process2.env, signingSecretEnv) ? "set" : "missing"})`);
|
|
11024
11246
|
if (!signingSecretEnv) lines.push(" signing secret env: not configured (recommended unless loopback-only)");
|
|
11025
11247
|
} else if (executor.type === "command_handler") {
|
|
11026
11248
|
const commandEnv = typeof executor.command_env === "string" ? executor.command_env : "";
|
|
11027
11249
|
lines.push(` - ${name}: command_handler`);
|
|
11028
|
-
if (commandEnv) lines.push(` command env: ${commandEnv} (${process2.env
|
|
11250
|
+
if (commandEnv) lines.push(` command env: ${commandEnv} (${envValue2(process2.env, commandEnv) ? "set" : "missing"})`);
|
|
11029
11251
|
}
|
|
11030
11252
|
}
|
|
11031
11253
|
return lines;
|
|
@@ -11126,8 +11348,8 @@ async function cloudConnect(args) {
|
|
|
11126
11348
|
}
|
|
11127
11349
|
const baseUrlEnv = parsed.cloud.base_url_env || "SYNAPSOR_CLOUD_BASE_URL";
|
|
11128
11350
|
const tokenEnv = parsed.cloud.runner_token_env || "SYNAPSOR_RUNNER_TOKEN";
|
|
11129
|
-
const baseUrl = process2.env
|
|
11130
|
-
const runnerToken = process2.env
|
|
11351
|
+
const baseUrl = envValue2(process2.env, baseUrlEnv);
|
|
11352
|
+
const runnerToken = envValue2(process2.env, tokenEnv);
|
|
11131
11353
|
const missing = [baseUrl ? "" : baseUrlEnv, runnerToken ? "" : tokenEnv].filter(Boolean);
|
|
11132
11354
|
if (missing.length > 0) {
|
|
11133
11355
|
process2.stdout.write(`missing environment variables: ${missing.join(", ")}
|
|
@@ -11303,7 +11525,7 @@ async function writebackDoctor(args) {
|
|
|
11303
11525
|
let ok = true;
|
|
11304
11526
|
for (const [sourceName, source] of sqlSources) {
|
|
11305
11527
|
const writeEnv = source.write_url_env;
|
|
11306
|
-
const writeUrl = writeEnv ? process2.env
|
|
11528
|
+
const writeUrl = writeEnv ? envValue2(process2.env, writeEnv) : void 0;
|
|
11307
11529
|
lines.push(`Source: ${sourceName}`);
|
|
11308
11530
|
lines.push(` engine: ${source.engine}`);
|
|
11309
11531
|
lines.push(` writer env: ${writeEnv ?? "(missing write_url_env)"}`);
|
|
@@ -11929,13 +12151,13 @@ async function mcpServeHttp(args) {
|
|
|
11929
12151
|
throw error;
|
|
11930
12152
|
}
|
|
11931
12153
|
process2.stderr.write("Press Ctrl+C to stop.\n");
|
|
11932
|
-
await new Promise((
|
|
12154
|
+
await new Promise((resolve2) => {
|
|
11933
12155
|
const stop = async () => {
|
|
11934
12156
|
process2.off("SIGINT", stop);
|
|
11935
12157
|
process2.off("SIGTERM", stop);
|
|
11936
12158
|
await server.close();
|
|
11937
12159
|
await releaseLease();
|
|
11938
|
-
|
|
12160
|
+
resolve2();
|
|
11939
12161
|
};
|
|
11940
12162
|
process2.once("SIGINT", stop);
|
|
11941
12163
|
process2.once("SIGTERM", stop);
|
|
@@ -11977,13 +12199,13 @@ async function mcpServeStreamableHttp(args) {
|
|
|
11977
12199
|
throw error;
|
|
11978
12200
|
}
|
|
11979
12201
|
process2.stderr.write("Press Ctrl+C to stop.\n");
|
|
11980
|
-
await new Promise((
|
|
12202
|
+
await new Promise((resolve2) => {
|
|
11981
12203
|
const stop = async () => {
|
|
11982
12204
|
process2.off("SIGINT", stop);
|
|
11983
12205
|
process2.off("SIGTERM", stop);
|
|
11984
12206
|
await server.close();
|
|
11985
12207
|
await releaseLease();
|
|
11986
|
-
|
|
12208
|
+
resolve2();
|
|
11987
12209
|
};
|
|
11988
12210
|
process2.once("SIGINT", stop);
|
|
11989
12211
|
process2.once("SIGTERM", stop);
|
|
@@ -12989,7 +13211,7 @@ function isRunnerConfigLike(value) {
|
|
|
12989
13211
|
}
|
|
12990
13212
|
async function fetchRemoteMcpTools(target2, args, timeoutMs) {
|
|
12991
13213
|
const bearerEnv = optionalArg(args, "--bearer-env") ?? "SYNAPSOR_MCP_AUDIT_BEARER";
|
|
12992
|
-
const bearer = process2.env
|
|
13214
|
+
const bearer = envValue2(process2.env, bearerEnv);
|
|
12993
13215
|
const controller = new AbortController();
|
|
12994
13216
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
12995
13217
|
try {
|
|
@@ -13015,7 +13237,7 @@ async function fetchRemoteMcpTools(target2, args, timeoutMs) {
|
|
|
13015
13237
|
async function fetchStdioMcpTools(commandText, timeoutMs) {
|
|
13016
13238
|
const [command, ...commandArgs] = splitCommand(commandText);
|
|
13017
13239
|
if (!command) throw new Error("mcp audit stdio target requires a command");
|
|
13018
|
-
return new Promise((
|
|
13240
|
+
return new Promise((resolve2, reject) => {
|
|
13019
13241
|
const child = spawn(command, commandArgs, {
|
|
13020
13242
|
env: process2.env,
|
|
13021
13243
|
stdio: ["pipe", "pipe", "pipe"]
|
|
@@ -13053,7 +13275,7 @@ async function fetchStdioMcpTools(commandText, timeoutMs) {
|
|
|
13053
13275
|
reject(new Error(`mcp audit stdio tools/list response not found${stderr ? `: ${stderr.slice(0, 240)}` : ""}`));
|
|
13054
13276
|
return;
|
|
13055
13277
|
}
|
|
13056
|
-
|
|
13278
|
+
resolve2(response);
|
|
13057
13279
|
});
|
|
13058
13280
|
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "synapsor-mcp-audit", version: "0.1.0" } } })}
|
|
13059
13281
|
`);
|
|
@@ -13152,12 +13374,12 @@ async function ui(args) {
|
|
|
13152
13374
|
process2.stdout.write("Opening the local review UI in your browser when a desktop opener is available.\n");
|
|
13153
13375
|
}
|
|
13154
13376
|
process2.stdout.write("Approval and rejection actions require the per-run local session plus CSRF token. Press Ctrl+C to stop.\n");
|
|
13155
|
-
await new Promise((
|
|
13377
|
+
await new Promise((resolve2) => {
|
|
13156
13378
|
const stop = async () => {
|
|
13157
13379
|
process2.off("SIGINT", stop);
|
|
13158
13380
|
process2.off("SIGTERM", stop);
|
|
13159
13381
|
await server.close();
|
|
13160
|
-
|
|
13382
|
+
resolve2();
|
|
13161
13383
|
};
|
|
13162
13384
|
process2.once("SIGINT", stop);
|
|
13163
13385
|
process2.once("SIGTERM", stop);
|
|
@@ -13670,7 +13892,7 @@ async function eventsTail(args) {
|
|
|
13670
13892
|
}
|
|
13671
13893
|
const seen = /* @__PURE__ */ new Set();
|
|
13672
13894
|
await printOnce(seen);
|
|
13673
|
-
await new Promise((
|
|
13895
|
+
await new Promise((resolve2) => {
|
|
13674
13896
|
const timer = setInterval(() => {
|
|
13675
13897
|
void printOnce(seen).catch((error) => {
|
|
13676
13898
|
process2.stderr.write(`events tail error: ${safeErrorMessage(error)}
|
|
@@ -13681,7 +13903,7 @@ async function eventsTail(args) {
|
|
|
13681
13903
|
clearInterval(timer);
|
|
13682
13904
|
process2.off("SIGINT", stop);
|
|
13683
13905
|
process2.off("SIGTERM", stop);
|
|
13684
|
-
|
|
13906
|
+
resolve2();
|
|
13685
13907
|
};
|
|
13686
13908
|
process2.once("SIGINT", stop);
|
|
13687
13909
|
process2.once("SIGTERM", stop);
|
|
@@ -13690,7 +13912,7 @@ async function eventsTail(args) {
|
|
|
13690
13912
|
}
|
|
13691
13913
|
async function eventsWebhook(args) {
|
|
13692
13914
|
assertKnownOptions(args, eventWebhookAllowedOptions, "events webhook");
|
|
13693
|
-
const url = optionalArg(args, "--url") ??
|
|
13915
|
+
const url = optionalArg(args, "--url") ?? envValue2(optionalArg(args, "--url-env"));
|
|
13694
13916
|
if (!url) throw new Error("events webhook requires --url <https://...> or --url-env <ENV>");
|
|
13695
13917
|
const endpoint = new URL(url);
|
|
13696
13918
|
if (!["http:", "https:"].includes(endpoint.protocol)) throw new Error("events webhook URL must use http or https");
|
|
@@ -13704,7 +13926,7 @@ async function eventsWebhook(args) {
|
|
|
13704
13926
|
const sinceEventId = optionalPositiveIntegerArg(args, "--since-event-id");
|
|
13705
13927
|
if (!Number.isFinite(intervalMs) || intervalMs < 250) throw new Error("--interval-ms must be at least 250");
|
|
13706
13928
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 250) throw new Error("--timeout-ms must be at least 250");
|
|
13707
|
-
const token =
|
|
13929
|
+
const token = envValue2(optionalArg(args, "--auth-token-env"));
|
|
13708
13930
|
const filters = eventFiltersFromArgs(args);
|
|
13709
13931
|
const seen = /* @__PURE__ */ new Set();
|
|
13710
13932
|
const pushOnce = async () => {
|
|
@@ -13739,7 +13961,7 @@ async function eventsWebhook(args) {
|
|
|
13739
13961
|
`);
|
|
13740
13962
|
}
|
|
13741
13963
|
if (!follow) return 0;
|
|
13742
|
-
await new Promise((
|
|
13964
|
+
await new Promise((resolve2) => {
|
|
13743
13965
|
const timer = setInterval(() => {
|
|
13744
13966
|
void pushOnce().catch((error) => {
|
|
13745
13967
|
process2.stderr.write(`events webhook error: ${safeErrorMessage(error)}
|
|
@@ -13750,7 +13972,7 @@ async function eventsWebhook(args) {
|
|
|
13750
13972
|
clearInterval(timer);
|
|
13751
13973
|
process2.off("SIGINT", stop);
|
|
13752
13974
|
process2.off("SIGTERM", stop);
|
|
13753
|
-
|
|
13975
|
+
resolve2();
|
|
13754
13976
|
};
|
|
13755
13977
|
process2.once("SIGINT", stop);
|
|
13756
13978
|
process2.once("SIGTERM", stop);
|
|
@@ -14124,9 +14346,19 @@ function optionalPositiveIntegerArg(args, flag) {
|
|
|
14124
14346
|
if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${flag} must be a non-negative integer`);
|
|
14125
14347
|
return parsed;
|
|
14126
14348
|
}
|
|
14127
|
-
function
|
|
14128
|
-
if (
|
|
14129
|
-
|
|
14349
|
+
function envValue2(first, second) {
|
|
14350
|
+
if (typeof first === "string" || first === void 0) {
|
|
14351
|
+
if (!first) return void 0;
|
|
14352
|
+
return trimmedEnvValue(process2.env, first);
|
|
14353
|
+
}
|
|
14354
|
+
if (!second) return void 0;
|
|
14355
|
+
return trimmedEnvValue(first, second);
|
|
14356
|
+
}
|
|
14357
|
+
function trimmedEnvValue(env, name) {
|
|
14358
|
+
const value = env[name];
|
|
14359
|
+
if (value === void 0) return void 0;
|
|
14360
|
+
const trimmed = value.trim();
|
|
14361
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
14130
14362
|
}
|
|
14131
14363
|
function linkedProposalFilter(args, store, options = {}) {
|
|
14132
14364
|
const noLinkedProposal = "__synapsor_no_linked_proposal__";
|
|
@@ -14346,11 +14578,11 @@ async function waitForReferenceDemoDatabase() {
|
|
|
14346
14578
|
stdio: "pipe"
|
|
14347
14579
|
});
|
|
14348
14580
|
if (result.status === 0) {
|
|
14349
|
-
await new Promise((
|
|
14581
|
+
await new Promise((resolve2) => setTimeout(resolve2, 500));
|
|
14350
14582
|
return;
|
|
14351
14583
|
}
|
|
14352
14584
|
last = result.stderr || result.stdout || `exit ${result.status}`;
|
|
14353
|
-
await new Promise((
|
|
14585
|
+
await new Promise((resolve2) => setTimeout(resolve2, 1e3));
|
|
14354
14586
|
}
|
|
14355
14587
|
throw new Error(`demo database did not become ready: ${last}`);
|
|
14356
14588
|
}
|
|
@@ -300,6 +300,34 @@ Use an app-owned executor when an approved proposal needs richer business work:
|
|
|
300
300
|
creating a credit row, inserting an outbox event, updating multiple app tables,
|
|
301
301
|
or calling your own service.
|
|
302
302
|
|
|
303
|
+
In DSL, keep the reviewed business contract portable and name the executor:
|
|
304
|
+
|
|
305
|
+
```sql
|
|
306
|
+
CREATE CAPABILITY billing.propose_plan_credit
|
|
307
|
+
CONTEXT trusted_operator
|
|
308
|
+
SOURCE local_postgres
|
|
309
|
+
ON public.invoices
|
|
310
|
+
PRIMARY KEY id
|
|
311
|
+
TENANT KEY tenant_id
|
|
312
|
+
ARG invoice_id STRING REQUIRED
|
|
313
|
+
ARG amount_cents NUMBER REQUIRED
|
|
314
|
+
ARG reason STRING REQUIRED
|
|
315
|
+
LOOKUP invoice_id
|
|
316
|
+
VISIBLE id, tenant_id, customer_id, status, credit_requested_cents, credit_reason, updated_at
|
|
317
|
+
PROPOSE ACTION billing.grant_plan_credit
|
|
318
|
+
ALLOW WRITE credit_requested_cents, credit_reason
|
|
319
|
+
PATCH credit_requested_cents = ARG amount_cents
|
|
320
|
+
PATCH credit_reason = ARG reason
|
|
321
|
+
CONFLICT GUARD updated_at
|
|
322
|
+
APPROVAL ROLE billing_lead
|
|
323
|
+
WRITEBACK APP HANDLER EXECUTOR billing_handler
|
|
324
|
+
END
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`billing_handler` is contract content. Its URL, bearer-token env var, signing
|
|
328
|
+
secret env var, and timeout stay in `synapsor.runner.json` so credentials never
|
|
329
|
+
enter the portable contract:
|
|
330
|
+
|
|
303
331
|
```json
|
|
304
332
|
"executors": {
|
|
305
333
|
"billing_handler": {
|
|
@@ -316,7 +344,7 @@ or calling your own service.
|
|
|
316
344
|
}
|
|
317
345
|
```
|
|
318
346
|
|
|
319
|
-
|
|
347
|
+
For legacy embedded JSON capabilities, reference the same executor directly:
|
|
320
348
|
|
|
321
349
|
```json
|
|
322
350
|
{
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synapsor/runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Stop giving AI agents execute_sql; expose reviewed Postgres/MySQL MCP actions with proposals, approval, writeback, and replay.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -52,11 +52,6 @@
|
|
|
52
52
|
"engines": {
|
|
53
53
|
"node": ">=22.5.0"
|
|
54
54
|
},
|
|
55
|
-
"scripts": {
|
|
56
|
-
"build": "tsc -b && node ../../scripts/build-runner-package.mjs",
|
|
57
|
-
"prepack": "cd ../.. && corepack pnpm build:runner-package",
|
|
58
|
-
"test": "vitest run"
|
|
59
|
-
},
|
|
60
55
|
"dependencies": {
|
|
61
56
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
62
57
|
"mysql2": "^3.11.0",
|
|
@@ -77,5 +72,9 @@
|
|
|
77
72
|
},
|
|
78
73
|
"publishConfig": {
|
|
79
74
|
"access": "public"
|
|
75
|
+
},
|
|
76
|
+
"scripts": {
|
|
77
|
+
"build": "tsc -b && node ../../scripts/build-runner-package.mjs",
|
|
78
|
+
"test": "vitest run"
|
|
80
79
|
}
|
|
81
|
-
}
|
|
80
|
+
}
|