rainskills 0.1.44 → 0.1.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/SKILL.md +3 -1
  2. package/bin/bridge-errors.js +26 -0
  3. package/bin/component-recipe.js +66 -0
  4. package/bin/confirmed-batch.js +120 -0
  5. package/bin/protected-poll.js +33 -16
  6. package/bin/rainskills-tools.js +133 -58
  7. package/bin/rainskills.js +5 -0
  8. package/bin/read-snapshot.js +3 -11
  9. package/bin/runtime-integrity.js +27 -0
  10. package/bin/runtime-smoke.js +63 -0
  11. package/bin/tool-policy.js +21 -0
  12. package/contracts/component-recipe.schema.yaml +30 -0
  13. package/contracts/confirmed-batch.schema.yaml +42 -0
  14. package/contracts/runtime/cli-base.md +3 -1
  15. package/contracts/runtime/overlay.schema.json +109 -10
  16. package/contracts/runtime/skills/rainbond-app-assistant.yaml +2 -1
  17. package/contracts/runtime/skills/rainbond-fullstack-bootstrap.yaml +2 -1
  18. package/contracts/runtime/skills/rainbond-fullstack-troubleshooter.yaml +2 -1
  19. package/install.sh +1 -1
  20. package/marketplace/rainskills/.claude-plugin/plugin.json +1 -1
  21. package/marketplace/rainskills/.codex-plugin/plugin.json +1 -1
  22. package/marketplace/rainskills/skills/rainskills/SKILL.md +4 -2
  23. package/package.json +2 -2
  24. package/rainbond-ai-assistant/references/generated/runtime-gate.md +5 -3
  25. package/rainbond-app-assistant/SKILL.md +9 -8
  26. package/rainbond-app-assistant/references/customer-result.md +43 -0
  27. package/rainbond-app-assistant/references/generated/runtime-gate.md +53 -5
  28. package/rainbond-app-assistant/references/generated/runtime-routing.md +1 -1
  29. package/rainbond-app-assistant/references/mainline.md +27 -0
  30. package/rainbond-app-assistant/references/objects/catalog-and-source.md +361 -0
  31. package/rainbond-app-assistant/references/objects/runtime-delivery-and-lifecycle.md +424 -0
  32. package/rainbond-app-assistant/references/objects/skill-boundaries-and-gaps.md +243 -0
  33. package/rainbond-app-assistant/references/objects/source-resolution-and-projection.md +515 -0
  34. package/rainbond-app-assistant/references/output-contract.md +2 -431
  35. package/rainbond-app-assistant/references/phases/bootstrap.md +17 -0
  36. package/rainbond-app-assistant/references/phases/customer-rules.md +13 -0
  37. package/rainbond-app-assistant/references/phases/deliver.md +13 -0
  38. package/rainbond-app-assistant/references/phases/identity.md +27 -0
  39. package/rainbond-app-assistant/references/phases/initialize.md +11 -0
  40. package/rainbond-app-assistant/references/phases/input-literals.md +21 -0
  41. package/rainbond-app-assistant/references/phases/local-inputs.md +23 -0
  42. package/rainbond-app-assistant/references/phases/mainline-details.md +89 -0
  43. package/rainbond-app-assistant/references/phases/narration.md +33 -0
  44. package/rainbond-app-assistant/references/phases/orchestration-details.md +178 -0
  45. package/rainbond-app-assistant/references/phases/promote.md +3 -0
  46. package/rainbond-app-assistant/references/phases/routing-details.md +128 -0
  47. package/rainbond-app-assistant/references/phases/source-build.md +27 -0
  48. package/rainbond-app-assistant/references/phases/source-create.md +13 -0
  49. package/rainbond-app-assistant/references/phases/source-failure.md +29 -0
  50. package/rainbond-app-assistant/references/phases/source-policy.md +18 -0
  51. package/rainbond-app-assistant/references/phases/source-proxy.md +22 -0
  52. package/rainbond-app-assistant/references/phases/topology-selection.md +42 -0
  53. package/rainbond-app-assistant/references/phases/troubleshoot.md +23 -0
  54. package/rainbond-app-assistant/references/product-object-model.md +6 -1536
  55. package/rainbond-app-assistant/references/results/structured-result-details-2.md +191 -0
  56. package/rainbond-app-assistant/references/results/structured-result-details.md +205 -0
  57. package/rainbond-app-assistant/references/rule-ownership.json +265 -0
  58. package/rainbond-app-assistant/references/structured-result.md +7 -0
  59. package/rainbond-app-assistant/references/workflow-rules.md +22 -709
  60. package/rainbond-app-assistant/schemas/generated/component-recipe.schema.yaml +32 -0
  61. package/rainbond-app-assistant/schemas/generated/confirmed-batch.schema.yaml +44 -0
  62. package/rainbond-app-assistant/scripts/validate_progressive_loading.py +8 -4
  63. package/rainbond-app-version-assistant/references/generated/runtime-gate.md +5 -3
  64. package/rainbond-delivery-verifier/references/generated/runtime-gate.md +5 -3
  65. package/rainbond-env-sync/references/generated/runtime-gate.md +5 -3
  66. package/rainbond-fullstack-bootstrap/SKILL.md +6 -2
  67. package/rainbond-fullstack-bootstrap/modules/50-workflow.md +1 -1
  68. package/rainbond-fullstack-bootstrap/modules/52-cli-batching.md +29 -0
  69. package/rainbond-fullstack-bootstrap/references/generated/runtime-gate.md +53 -5
  70. package/rainbond-fullstack-bootstrap/references/generated/runtime-routing.md +1 -1
  71. package/rainbond-fullstack-bootstrap/schemas/generated/component-recipe.schema.yaml +32 -0
  72. package/rainbond-fullstack-bootstrap/schemas/generated/confirmed-batch.schema.yaml +44 -0
  73. package/rainbond-fullstack-troubleshooter/references/generated/runtime-gate.md +52 -4
  74. package/rainbond-fullstack-troubleshooter/references/generated/runtime-routing.md +1 -1
  75. package/rainbond-fullstack-troubleshooter/schemas/generated/component-recipe.schema.yaml +32 -0
  76. package/rainbond-fullstack-troubleshooter/schemas/generated/confirmed-batch.schema.yaml +44 -0
  77. package/rainbond-opensource-app-deploy/references/generated/runtime-gate.md +5 -3
  78. package/rainbond-platform-installer/SKILL.md +1 -1
  79. package/rainbond-platform-installer/scripts/installed-version.js +1 -1
  80. package/rainbond-platform-plugin-manager/references/generated/runtime-gate.md +5 -3
  81. package/rainbond-platform-query/references/generated/runtime-gate.md +5 -3
  82. package/rainbond-project-init/references/generated/runtime-gate.md +5 -3
  83. package/rainbond-template-installer/references/generated/runtime-gate.md +5 -3
  84. package/scripts/build-skill-manifest.mjs +3 -2
  85. package/scripts/install-local-cli.mjs +191 -88
@@ -32,20 +32,7 @@ const SENSITIVE_RESPONSE_KEY_PATTERN = /(?:authorization|jwt|token|password|secr
32
32
  const SENSITIVE_ENV_NAME_PATTERN = /(?:^|_)(?:PASSWORD|PASSWD|PASS|SECRET|TOKEN|API_?KEY|PRIVATE_?KEY|CREDENTIALS?|AUTH(?:ORIZATION)?|COOKIE|CERT(?:IFICATE)?)(?:_|$)/i;
33
33
  const ENV_NAME_FIELDS = Object.freeze(["attr_name", "name", "note", "env_name"]);
34
34
  const ENV_VALUE_FIELDS = new Set(["attr_value", "value"]);
35
- const RISK_POLICY = Object.freeze({
36
- version: "1",
37
- readPrefixes: [
38
- "rainbond_query_", "rainbond_get_", "rainbond_list_", "rainbond_check_",
39
- "rainbond_describe_", "rainbond_validate_", "rainbond_verify_", "rainbond_search_",
40
- ],
41
- destructiveFragments: ["delete", "remove", "purge", "destroy"],
42
- });
43
- const MIXED_TOOL_OPERATION_POLICY = Object.freeze({
44
- rainbond_manage_component_dependency: Object.freeze({
45
- readOperations: Object.freeze(["summary"]),
46
- destructiveOperations: Object.freeze(["delete"]),
47
- }),
48
- });
35
+ const { RISK_POLICY, classifyTool } = require("./tool-policy.js");
49
36
  const CALL_OPTION_DEFINITIONS = Object.freeze({
50
37
  "--confirm": Object.freeze({ property: "confirmation", pattern: OPERATION_ID_PATTERN }),
51
38
  "--skill-id": Object.freeze({ property: "skillId", pattern: SKILL_ID_PATTERN }),
@@ -79,20 +66,7 @@ const HANDOFF_SKILLS = new Set([
79
66
  "rainbond-template-installer",
80
67
  ]);
81
68
 
82
- const EXIT = Object.freeze({
83
- USAGE: 2,
84
- CONFIG: 3,
85
- TRANSPORT: 4,
86
- TOOL: 5,
87
- });
88
-
89
- class BridgeError extends Error {
90
- constructor(message, exitCode, payload) {
91
- super(message);
92
- this.exitCode = exitCode;
93
- this.payload = payload;
94
- }
95
- }
69
+ const { BridgeError, EXIT, inputValidation, errorResult } = require("./bridge-errors.js");
96
70
 
97
71
  function requireRuntimeModule(filename) {
98
72
  const candidates = [
@@ -308,6 +282,11 @@ function parseCommand(args) {
308
282
  if (command === "read" && remaining.length === 4 && remaining[1] && remaining[2] === "--input" && remaining[3] === "-") {
309
283
  return { command, toolName: remaining[1], input: remaining[3], ...context };
310
284
  }
285
+ if (command === "batch" || command === "recipe") {
286
+ if (!["rainbond-app-assistant", "rainbond-fullstack-bootstrap", "rainbond-fullstack-troubleshooter"].includes(skillId)) throw new BridgeError("batch is not allowed for this skill", EXIT.USAGE);
287
+ const parsed = parseCommand(["call", "rainskills_confirmed_batch", ...remaining.slice(1), "--skill-id", skillId]);
288
+ return { ...parsed, command };
289
+ }
311
290
  if (command === "call" && remaining.length >= 4 && remaining[1] && remaining[2] === "--input" && remaining[3] === "-") {
312
291
  const parsed = { command, toolName: remaining[1], input: remaining[3], ...context };
313
292
  for (let index = 4; index < remaining.length; index += 2) {
@@ -550,7 +529,8 @@ async function executeWithUsageTelemetry(command, config) {
550
529
  : command;
551
530
  const output = await execute(executionCommand, config);
552
531
  if (telemetry) {
553
- const firstUse = telemetry.recordFirstUse("success");
532
+ const workflowFailed = ["batch", "recipe"].includes(command.command) && output.status !== "succeeded";
533
+ const firstUse = telemetry.recordFirstUse(workflowFailed ? "failed" : "success");
554
534
  const active = telemetry.recordActiveDaily();
555
535
  await Promise.allSettled([firstUse.delivery, active.delivery]);
556
536
  }
@@ -570,7 +550,7 @@ async function executeWithUsageTelemetry(command, config) {
570
550
  }
571
551
 
572
552
  function commandTracksBusinessUsage(command) {
573
- return ["read", "call", "snapshot", "poll"].includes(command?.command);
553
+ return ["read", "call", "snapshot", "poll", "batch", "recipe"].includes(command?.command);
574
554
  }
575
555
 
576
556
  function loadSkillBinding(config, skillId, rootSkillId) {
@@ -622,23 +602,6 @@ function loadSkillBinding(config, skillId, rootSkillId) {
622
602
  };
623
603
  }
624
604
 
625
- function classifyTool(toolName, argumentsValue = {}) {
626
- const normalized = String(toolName || "").toLowerCase();
627
- const operation = typeof argumentsValue.operation === "string"
628
- ? argumentsValue.operation.trim().toLowerCase()
629
- : "";
630
- const mixedPolicy = MIXED_TOOL_OPERATION_POLICY[normalized];
631
- if (mixedPolicy) {
632
- if (mixedPolicy.destructiveOperations.includes(operation)) return "destructive";
633
- if (mixedPolicy.readOperations.includes(operation)) return "read";
634
- }
635
- if (RISK_POLICY.destructiveFragments.some((fragment) => normalized.includes(fragment))) {
636
- return "destructive";
637
- }
638
- if (RISK_POLICY.readPrefixes.some((prefix) => normalized.startsWith(prefix))) return "read";
639
- return "write";
640
- }
641
-
642
605
  function operationFile(config, operationId) {
643
606
  return path.join(operationsPath(config), `${operationId}.json`);
644
607
  }
@@ -728,6 +691,7 @@ function confirmOperation(
728
691
  if (!record || record.status !== "awaiting_confirmation" || record.tool_name !== toolName ||
729
692
  record.operation_class !== operationClass ||
730
693
  record.arguments_digest !== argumentsDigest(argumentsValue) ||
694
+ (record.expires_at && Date.now() >= Date.parse(record.expires_at)) ||
731
695
  record.skill_id !== skillBinding.skillId ||
732
696
  record.root_skill_id !== skillBinding.rootSkillId ||
733
697
  record.skill_package_version !== skillBinding.packageVersion ||
@@ -862,6 +826,7 @@ function sanitizeToolResult(value, pathParts = [], sensitiveArguments = []) {
862
826
  const safe = {};
863
827
  const sensitiveEnv = hasSensitiveEnvName(value);
864
828
  for (const [key, item] of Object.entries(value)) {
829
+ if (key === "request_body") continue;
865
830
  // Console destructive operations may return this one-time control value.
866
831
  // It is not a credential and must remain available for the confirmation flow.
867
832
  const isPackageUploadAuthMode = key === "authorization"
@@ -915,12 +880,15 @@ function safeJson(value, secrets = []) {
915
880
  function httpJsonRpcRequest(config, endpointPath, message, {
916
881
  timeoutMs = REQUEST_TIMEOUT_MS,
917
882
  maxResponseBytes = RESPONSE_MAX_BYTES,
883
+ signal,
918
884
  } = {}) {
919
885
  const endpoint = new URL(`${config.baseUrl.replace(/\/+$/, "")}${endpointPath}`);
920
886
  const payload = JSON.stringify(message);
921
887
  const client = endpoint.protocol === "https:" ? https : http;
922
888
 
923
889
  return new Promise((resolve, reject) => {
890
+ if (signal?.aborted) { reject(new BridgeError("request cancelled", EXIT.TRANSPORT)); return; }
891
+ let abortRequest;
924
892
  let settled = false;
925
893
  let responseRef;
926
894
  let wallTimer;
@@ -929,6 +897,7 @@ function httpJsonRpcRequest(config, endpointPath, message, {
929
897
  if (settled) return;
930
898
  settled = true;
931
899
  clearTimeout(wallTimer);
900
+ signal?.removeEventListener("abort", abortRequest);
932
901
  callback(value);
933
902
  };
934
903
  const fail = (error, abort = false) => {
@@ -990,6 +959,8 @@ function httpJsonRpcRequest(config, endpointPath, message, {
990
959
  ? error
991
960
  : new BridgeError("unable to reach Rainbond API", EXIT.TRANSPORT));
992
961
  });
962
+ abortRequest = () => fail(new BridgeError("request cancelled", EXIT.TRANSPORT), true);
963
+ signal?.addEventListener("abort", abortRequest, { once: true });
993
964
  wallTimer = setTimeout(() => {
994
965
  fail(new BridgeError("Rainbond API request timed out", EXIT.TRANSPORT), true);
995
966
  }, timeoutMs);
@@ -1073,7 +1044,7 @@ async function rpcRequest(config, method, params = {}, options = {}) {
1073
1044
  id: 1,
1074
1045
  method,
1075
1046
  params,
1076
- }, options);
1047
+ }, { ...options, signal: options.signal || config.signal });
1077
1048
  if (isVerifiedMissingRpcRoute(response)) {
1078
1049
  throw new BridgeError(
1079
1050
  "当前 Rainbond 版本不支持 Rainskills CLI,请先将 Rainbond 升级到 v6.9.9 或更高版本",
@@ -1351,7 +1322,91 @@ async function resolveStatelessContext(input, config) {
1351
1322
  return workspaceSelectionResult(enterpriseId, matchingOptions);
1352
1323
  }
1353
1324
 
1325
+ async function executeConfirmedToolCall(command, config, toolName, argumentsValue, executeStep = execute) {
1326
+ if (command.signal?.aborted) throw new BridgeError("workflow cancelled", EXIT.USAGE, undefined, "E_CANCELLED");
1327
+ const child = { command: "call", toolName, argumentsValue, skillId: command.skillId, rootSkillId: command.rootSkillId, telemetryInstallationId: command.telemetryInstallationId, signal: command.signal };
1328
+ const prepared = await executeStep(child, config);
1329
+ if (command.signal?.aborted) throw new BridgeError("workflow cancelled", EXIT.USAGE, undefined, "E_CANCELLED");
1330
+ if (!prepared.requires_confirmation || !prepared.confirmation_id) throw new BridgeError("workflow write did not return a confirmation", EXIT.CONFIG);
1331
+ return executeStep({ ...child, confirmation: prepared.confirmation_id }, config);
1332
+ }
1333
+
1334
+ async function executeConfirmedWorkflow(command, config) {
1335
+ const { validateBatch, executeBatch, configurationSatisfied } = require("./confirmed-batch.js");
1336
+ const { preflightRecipe, executeRecipe } = require("./component-recipe.js");
1337
+ const input = command.argumentsValue;
1338
+ const recipe = command.command === "recipe";
1339
+ const tool = recipe ? "rainskills_component_recipe" : "rainskills_confirmed_batch";
1340
+ const validate = (name, args) => validateToolArguments(config, name, args);
1341
+ const read = (toolName, argumentsValue) => execute({ command: "read", toolName, argumentsValue, skillId: command.skillId, signal: command.signal }, config);
1342
+ const skipKeys = [];
1343
+ const snapshots = new Map();
1344
+ const dirtyComponents = new Set();
1345
+ let completedWrites = 0;
1346
+ const currentConfiguration = async (step) => {
1347
+ const { team_name, region_name, app_id, service_id } = step.arguments;
1348
+ const key = `${step.tool}:${service_id}`;
1349
+ if (!snapshots.has(key)) {
1350
+ const args = { team_name, region_name, app_id, service_id, operation: "summary" };
1351
+ try { await validate(step.tool, args); }
1352
+ catch (error) { if (error.exitCode === EXIT.USAGE) return false; throw error; }
1353
+ snapshots.set(key, await read(step.tool, args));
1354
+ }
1355
+ return configurationSatisfied(step.tool, step.arguments, snapshots.get(key));
1356
+ };
1357
+ let steps;
1358
+ if (recipe) {
1359
+ const plan = await preflightRecipe(input, { validate, read });
1360
+ steps = [{ key: "create", tool: plan.tool }, ...plan.steps];
1361
+ } else {
1362
+ steps = validateBatch(input);
1363
+ for (const step of steps) await validate(step.tool, step.arguments);
1364
+ for (const step of steps) {
1365
+ const satisfied = await currentConfiguration(step);
1366
+ if (satisfied && !dirtyComponents.has(step.arguments.service_id)) skipKeys.push(step.key);
1367
+ else {
1368
+ dirtyComponents.add(step.arguments.service_id);
1369
+ if (step.arguments.dep_service_id) dirtyComponents.add(step.arguments.dep_service_id);
1370
+ for (const id of step.arguments.dep_service_ids || []) dirtyComponents.add(id);
1371
+ }
1372
+ }
1373
+ }
1374
+ const binding = loadSkillBinding(config, command.skillId, command.rootSkillId);
1375
+ const protectedInput = { input, skipped: skipKeys, identity: argumentsDigest({ endpoint: config.baseUrl, identity: config.jwt }) };
1376
+ let operation;
1377
+ if (!command.confirmation) {
1378
+ operation = prepareOperation(config, tool, "write", protectedInput, binding);
1379
+ writeOperation(config, { ...operation, expires_at: new Date(Date.now() + 10 * 60_000).toISOString() });
1380
+ return { requires_confirmation: true, confirmation_id: operation.operation_id, operation_class: "write", steps: steps.map(({ key, tool: name }) => ({ key, tool: name, status: skipKeys.includes(key) ? "skipped" : "planned" })), summary: "Confirm this exact workflow within the current authorized app scope." };
1381
+ }
1382
+ operation = confirmOperation(config, command.confirmation, tool, "write", protectedInput, binding);
1383
+ try {
1384
+ const adapters = {
1385
+ validate, read, signal: command.signal,
1386
+ isSatisfied: async (step) => {
1387
+ const satisfied = await currentConfiguration(step);
1388
+ if (skipKeys.includes(step.key) && !satisfied) throw new BridgeError("confirmed no-op state changed; prepare a new batch", EXIT.USAGE, undefined, "E_PLAN_STALE");
1389
+ return satisfied;
1390
+ },
1391
+ call: (toolName, argumentsValue) => executeConfirmedToolCall(command, config, toolName, argumentsValue),
1392
+ onCreated: async (identity) => { operation = { ...operation, created: identity }; writeOperation(config, operation); },
1393
+ onProgress: async (output) => {
1394
+ if (output.completed !== completedWrites) { snapshots.clear(); completedWrites = output.completed; }
1395
+ operation = { ...operation, progress: output }; writeOperation(config, operation);
1396
+ },
1397
+ };
1398
+ const output = await (recipe ? executeRecipe(input, adapters) : executeBatch(input, adapters));
1399
+ writeOperation(config, { ...operation, status: output.status, progress: output });
1400
+ return output;
1401
+ } catch (error) {
1402
+ updateOperation(config, operation, error.exitCode === EXIT.TRANSPORT ? "unknown" : "failed");
1403
+ throw error;
1404
+ }
1405
+ }
1406
+
1354
1407
  async function execute(command, config) {
1408
+ if (command.signal?.aborted) throw new BridgeError("workflow cancelled", EXIT.USAGE, undefined, "E_CANCELLED");
1409
+ if (["batch", "recipe"].includes(command.command)) return executeConfirmedWorkflow(command, config);
1355
1410
  if (command.command === "context") {
1356
1411
  return resolveStatelessContext(command.argumentsValue, config);
1357
1412
  }
@@ -1359,7 +1414,8 @@ async function execute(command, config) {
1359
1414
  return executePackageUpload(command, config);
1360
1415
  }
1361
1416
  if (command.command === "snapshot") {
1362
- const { executeReadSnapshot } = require("./read-snapshot.js");
1417
+ const { executeReadSnapshot, validateSnapshotInput } = require("./read-snapshot.js");
1418
+ inputValidation(() => validateSnapshotInput(command.argumentsValue));
1363
1419
  return executeReadSnapshot(command.argumentsValue, {
1364
1420
  callTool: async (name, argumentsValue) => {
1365
1421
  const result = await rpcRequest(config, "tools/call", { name, arguments: argumentsValue });
@@ -1375,10 +1431,12 @@ async function execute(command, config) {
1375
1431
  });
1376
1432
  }
1377
1433
  if (command.command === "poll") {
1378
- const { executeProtectedPoll } = require("./protected-poll.js");
1434
+ const { executeProtectedPoll, validatePollInput } = require("./protected-poll.js");
1435
+ inputValidation(() => validatePollInput(command.toolName, command.argumentsValue));
1379
1436
  return executeProtectedPoll(command.toolName, command.argumentsValue, {
1380
- callTool: async (name, argumentsValue) => {
1381
- const result = await rpcRequest(config, "tools/call", { name, arguments: argumentsValue });
1437
+ signal: command.signal,
1438
+ callTool: async (name, argumentsValue, options) => {
1439
+ const result = await rpcRequest(config, "tools/call", { name, arguments: argumentsValue }, options);
1382
1440
  if (!result || result.isError || !("structuredContent" in result)) {
1383
1441
  throw new BridgeError("poll read failed", EXIT.TOOL);
1384
1442
  }
@@ -1418,6 +1476,9 @@ async function execute(command, config) {
1418
1476
  }
1419
1477
 
1420
1478
  const argumentsValue = command.argumentsValue || readArguments(command.input);
1479
+ if (command.toolName === "rainbond_wait_for_build_completion") {
1480
+ if (argumentsValue.timeout !== undefined && (!Number.isInteger(argumentsValue.timeout) || argumentsValue.timeout < 1 || argumentsValue.timeout > 60)) throw new BridgeError("wait timeout must be between 1 and 60 seconds", EXIT.USAGE);
1481
+ }
1421
1482
  const operationClass = classifyTool(command.toolName, argumentsValue);
1422
1483
  if (command.command === "read" && operationClass !== "read") {
1423
1484
  throw new BridgeError(
@@ -1472,7 +1533,7 @@ async function execute(command, config) {
1472
1533
  installation_id: command.telemetryInstallationId,
1473
1534
  };
1474
1535
  }
1475
- const result = await rpcRequest(config, "tools/call", params);
1536
+ const result = await rpcRequest(config, "tools/call", params, command.toolName === "rainbond_wait_for_build_completion" ? { timeoutMs: (argumentsValue.timeout || 60) * 1000, signal: command.signal } : { signal: command.signal });
1476
1537
  if (!result || typeof result !== "object") {
1477
1538
  throw new BridgeError("Rainbond tool result is invalid", EXIT.TRANSPORT);
1478
1539
  }
@@ -1498,6 +1559,7 @@ async function execute(command, config) {
1498
1559
  } catch (error) {
1499
1560
  if (operation) {
1500
1561
  updateOperation(config, operation, error.exitCode === EXIT.TRANSPORT ? "unknown" : "failed");
1562
+ if (error.exitCode === EXIT.TRANSPORT) error.code = "E_WRITE_RESULT_UNKNOWN";
1501
1563
  }
1502
1564
  throw error;
1503
1565
  }
@@ -1507,9 +1569,16 @@ async function main(args = process.argv.slice(2)) {
1507
1569
  let config;
1508
1570
  let command;
1509
1571
  let argumentRedactions = [];
1572
+ const controller = new AbortController();
1573
+ const cancel = () => controller.abort();
1510
1574
  try {
1511
1575
  command = parseCommand(args);
1512
- if (["read", "call", "package-upload", "query", "context", "delivery", "handoff", "snapshot", "poll"].includes(command.command)) {
1576
+ command.signal = controller.signal;
1577
+ if (["poll", "batch", "recipe"].includes(command.command)) {
1578
+ process.once("SIGINT", cancel);
1579
+ process.once("SIGTERM", cancel);
1580
+ }
1581
+ if (["read", "call", "package-upload", "query", "context", "delivery", "handoff", "snapshot", "poll", "batch", "recipe"].includes(command.command)) {
1513
1582
  const argumentsValue = readArguments(command.input);
1514
1583
  command.argumentsValue = argumentsValue;
1515
1584
  argumentRedactions = collectArgumentRedactions(argumentsValue);
@@ -1548,14 +1617,14 @@ async function main(args = process.argv.slice(2)) {
1548
1617
  }
1549
1618
  if (command.command === "handoff") {
1550
1619
  const { createHandoffContext, validateHandoffContext } = require("./handoff-context.js");
1551
- const output = command.action === "create"
1620
+ const output = inputValidation(() => command.action === "create"
1552
1621
  ? createHandoffContext(command.argumentsValue)
1553
1622
  : validateHandoffContext(command.argumentsValue.context, {
1554
1623
  current: command.argumentsValue.current,
1555
1624
  signal: command.argumentsValue.signal,
1556
1625
  now: command.argumentsValue.now,
1557
1626
  maxAgeMs: command.argumentsValue.max_age_ms,
1558
- });
1627
+ }));
1559
1628
  process.stdout.write(`${JSON.stringify(fitOutput(output))}\n`);
1560
1629
  return;
1561
1630
  }
@@ -1565,20 +1634,26 @@ async function main(args = process.argv.slice(2)) {
1565
1634
  if (config.isInsecureHttp) {
1566
1635
  process.stderr.write('{"warning":"using insecure HTTP transport"}\n');
1567
1636
  }
1637
+ config.signal = command.signal;
1568
1638
  const output = await executeWithUsageTelemetry(command, config);
1569
1639
  process.stdout.write(`${JSON.stringify(fitOutput(output))}\n`);
1570
1640
  } catch (error) {
1571
1641
  const bridgeError = error instanceof BridgeError
1572
1642
  ? error
1573
- : new BridgeError("unexpected API bridge error", EXIT.TRANSPORT);
1643
+ : new BridgeError(error.code === "MODULE_NOT_FOUND" ? "installed Rainskills CLI runtime is incomplete" : "unexpected internal CLI error", error.code === "MODULE_NOT_FOUND" ? EXIT.CONFIG : EXIT.TRANSPORT, undefined, error.code === "MODULE_NOT_FOUND" ? "E_RUNTIME_INCOMPLETE" : "E_INTERNAL");
1574
1644
  const secrets = [config && config.jwt, config && config.baseUrl, ...argumentRedactions];
1575
- const output = bridgeError.payload || { error: bridgeError.message };
1645
+ const output = sanitizeToolResult(errorResult(bridgeError), [], argumentRedactions);
1576
1646
  process.stderr.write(`${safeJson(output, secrets)}\n`);
1577
1647
  process.exitCode = bridgeError.exitCode;
1648
+ } finally {
1649
+ process.removeListener("SIGINT", cancel);
1650
+ process.removeListener("SIGTERM", cancel);
1578
1651
  }
1579
1652
  }
1580
1653
 
1581
1654
  module.exports = {
1655
+ main,
1656
+ executeConfirmedToolCall,
1582
1657
  REQUEST_TIMEOUT_MS,
1583
1658
  INPUT_MAX_BYTES,
1584
1659
  RESPONSE_MAX_BYTES,
package/bin/rainskills.js CHANGED
@@ -278,6 +278,11 @@ async function runBuiltin(args, {
278
278
  throw new Error("runtime status 只支持固定参数 --json");
279
279
  }
280
280
  const packageVersion = require("../package.json").version;
281
+ const integrity = require("./runtime-integrity.js").checkRuntimeIntegrity();
282
+ if (integrity.status !== "ok") {
283
+ write(`${JSON.stringify({ schema: "rainskills.runtime-status.v1", state: "needs_reconnect", usable: false, package_version: packageVersion, integrity, repair_required: true })}\n`);
284
+ return true;
285
+ }
281
286
  if (runtimeStateManager) {
282
287
  write(`${JSON.stringify({
283
288
  ...(await runtimeStateManager.status()),
@@ -1,11 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const SCOPES = new Set(["runtime", "app", "component", "delivery"]);
4
- const READ_PREFIXES = [
5
- "rainbond_query_", "rainbond_get_", "rainbond_list_", "rainbond_check_",
6
- "rainbond_describe_", "rainbond_validate_", "rainbond_verify_", "rainbond_search_",
7
- ];
8
- const DESTRUCTIVE = /(?:delete|remove|purge|destroy|update|create|set|manage|operate|restart|deploy)/i;
4
+ const { classifyTool } = require("./tool-policy.js");
9
5
  const SENSITIVE_KEY = /(?:authorization|jwt|token|password|secret|credential|private[_-]?key|cookie|certificate|ssl)/i;
10
6
  const LOG_OR_EVENT = /(?:log|event)/i;
11
7
  const BOUND_KEYS = new Set(["cursor", "since_seconds", "tail"]);
@@ -16,11 +12,6 @@ function isPlainObject(value) {
16
12
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
17
13
  }
18
14
 
19
- function readOnlyTool(name) {
20
- return typeof name === "string"
21
- && READ_PREFIXES.some((prefix) => name.startsWith(prefix))
22
- && !DESTRUCTIVE.test(name);
23
- }
24
15
 
25
16
  function validateSnapshotInput(input) {
26
17
  if (!isPlainObject(input) || !SCOPES.has(input.scope)) throw new Error("snapshot scope is invalid");
@@ -28,7 +19,7 @@ function validateSnapshotInput(input) {
28
19
  throw new Error("snapshot requires one to eight requests");
29
20
  }
30
21
  for (const entry of input.requests) {
31
- if (!isPlainObject(entry) || !readOnlyTool(entry.name) || !isPlainObject(entry.arguments)) {
22
+ if (!isPlainObject(entry) || classifyTool(entry.name, entry.arguments) !== "read" || !isPlainObject(entry.arguments)) {
32
23
  throw new Error("snapshot accepts read-only tool requests with object arguments");
33
24
  }
34
25
  if (LOG_OR_EVENT.test(entry.name) && !Object.keys(entry.arguments).some((key) => BOUND_KEYS.has(key))) {
@@ -43,6 +34,7 @@ function redact(value) {
43
34
  if (!isPlainObject(value)) return value;
44
35
  const output = {};
45
36
  for (const [key, child] of Object.entries(value)) {
37
+ if (key === "request_body") continue;
46
38
  if (SENSITIVE_KEY.test(key)) continue;
47
39
  output[key] = redact(child);
48
40
  }
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const MODULES = Object.freeze({
6
+ "rainskills-tools.js": "main",
7
+ "handoff-context.js": "createHandoffContext",
8
+ "read-snapshot.js": "executeReadSnapshot",
9
+ "protected-poll.js": "executeProtectedPoll",
10
+ "delivery-probe.js": "createDeliveryProbe",
11
+ "bridge-errors.js": "errorResult",
12
+ "confirmed-batch.js": "validateBatch",
13
+ "component-recipe.js": "prepareRecipe",
14
+ "tool-policy.js": "classifyTool",
15
+ });
16
+ function checkRuntimeIntegrity(directory = __dirname) {
17
+ const missing = [];
18
+ for (const [name, entrypoint] of Object.entries(MODULES)) {
19
+ try {
20
+ const target = path.join(directory, name);
21
+ const info = fs.lstatSync(target);
22
+ if (!info.isFile() || info.isSymbolicLink() || typeof require(target)[entrypoint] !== "function") missing.push(name);
23
+ } catch { missing.push(name); }
24
+ }
25
+ return { status: missing.length ? "failed" : "ok", missing };
26
+ }
27
+ module.exports = { checkRuntimeIntegrity };
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+ const http = require("node:http");
7
+ const { spawn } = require("node:child_process");
8
+ const { checkRuntimeIntegrity } = require("./runtime-integrity.js");
9
+
10
+ async function smokeInstalledRuntime(home) {
11
+ if (checkRuntimeIntegrity().status !== "ok") throw new Error("installed runtime integrity failed");
12
+ const probeHome = fs.mkdtempSync(path.join(os.tmpdir(), "rainskills-smoke-"));
13
+ const sockets = new Set();
14
+ const server = http.createServer((request, response) => {
15
+ let body = "";
16
+ request.on("data", (chunk) => { body += chunk; });
17
+ request.on("end", () => {
18
+ const message = JSON.parse(body);
19
+ const result = message.method === "tools/list"
20
+ ? { tools: [{ name: "rainbond_get_smoke_status", inputSchema: { type: "object" } }] }
21
+ : { structuredContent: { status: "Running" } };
22
+ response.writeHead(200, { "Content-Type": "application/json" });
23
+ response.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }));
24
+ });
25
+ });
26
+ server.on("connection", (socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
27
+ try {
28
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
29
+ require("../rainbond-platform-installer/scripts/single-runtime.js").createSingleRuntimeStore({ home: probeHome }).write({ kind: "private", consoleOrigin: `http://127.0.0.1:${server.address().port}`, token: "smoke.payload.signature", allowInsecureHttp: true });
30
+ const bridge = path.join(home, ".rainbond/bin/rainskills-tools.js");
31
+ const run = (args, input) => new Promise((resolve, reject) => {
32
+ const child = spawn(process.execPath, [bridge, ...args, "--input", "-", "--skill-id", "rainbond-app-assistant"], {
33
+ cwd: probeHome, env: { ...process.env, HOME: probeHome, USERPROFILE: probeHome, RAINSKILLS_TELEMETRY_DISABLED: "1", RAINSKILLS_CREDENTIAL_SOURCE: "", RAINBOND_URL: "", RAINBOND_JWT: "" }, stdio: ["pipe", "pipe", "pipe"],
34
+ });
35
+ let stdout = "";
36
+ const timer = setTimeout(() => child.kill("SIGKILL"), 5000);
37
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
38
+ child.stderr.resume();
39
+ child.on("error", (error) => { clearTimeout(timer); reject(error); });
40
+ child.on("close", (code) => {
41
+ clearTimeout(timer);
42
+ if (code !== 0) return reject(new Error(`installed ${args[0]} smoke failed`));
43
+ try { resolve(JSON.parse(stdout)); } catch { reject(new Error("invalid installed smoke output")); }
44
+ });
45
+ child.stdin.end(JSON.stringify(input));
46
+ });
47
+ const context = await run(["handoff", "create"], { producer: "rainbond-app-assistant", profile: "cli", observed_at: new Date().toISOString(), enterprise_id: "smoke", team_id: "smoke", team_name: "smoke", region_name: "smoke", app_id: null, app_name: "smoke", endpoint: "https://example.invalid", runtime_revision: "smoke", snapshot_revision: null, source_kind: "image", runtime_state: "topology_missing", next_phase: "init", invalidation_reason: null });
48
+ const snapshot = await run(["snapshot", "app"], { scope: "app", requests: [{ name: "rainbond_get_smoke_status", arguments: {} }] });
49
+ const poll = await run(["poll", "rainbond_get_smoke_status"], { arguments: {}, status_path: "status", terminal_values: ["Running", "Failed"], failure_values: ["Failed"], max_attempts: 1, interval_ms: 0, timeout_ms: 1000 });
50
+ if (context.schema !== "rainskills.handoff-context.v1" || snapshot.evidence?.[0]?.available !== true || poll.completed !== true) throw new Error("installed runtime smoke contract failed");
51
+ return { status: "ok", checked: ["handoff", "snapshot", "poll"] };
52
+ } finally {
53
+ for (const socket of sockets) socket.destroy();
54
+ await new Promise((resolve) => server.close(resolve));
55
+ fs.rmSync(probeHome, { recursive: true, force: true });
56
+ }
57
+ }
58
+ module.exports = { smokeInstalledRuntime };
59
+ if (require.main === module) {
60
+ const home = process.argv[2];
61
+ if (!home) { process.stderr.write("installed home is required\n"); process.exitCode = 2; }
62
+ else smokeInstalledRuntime(path.resolve(home)).then((result) => process.stdout.write(`${JSON.stringify(result)}\n`)).catch(() => { process.stderr.write("installed runtime smoke failed\n"); process.exitCode = 1; });
63
+ }
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+
3
+ const RISK_POLICY = Object.freeze({
4
+ version: "2",
5
+ readPrefixes: ["rainbond_query_", "rainbond_get_", "rainbond_list_", "rainbond_check_", "rainbond_describe_", "rainbond_validate_", "rainbond_verify_", "rainbond_search_"],
6
+ destructiveFragments: ["delete", "remove", "purge", "destroy"],
7
+ });
8
+ const SUMMARY_TOOLS = new Set(["ports", "dependency", "storage", "connection_envs", "envs"].map((name) => `rainbond_manage_component_${name}`));
9
+ function classifyTool(toolName, argumentsValue = {}) {
10
+ const name = String(toolName || "").toLowerCase();
11
+ const operation = typeof argumentsValue.operation === "string" ? argumentsValue.operation.trim().toLowerCase() : "";
12
+ if (SUMMARY_TOOLS.has(name)) {
13
+ if (operation === "summary") return "read";
14
+ if (RISK_POLICY.destructiveFragments.some((part) => operation.includes(part))) return "destructive";
15
+ return "write";
16
+ }
17
+ if (RISK_POLICY.destructiveFragments.some((part) => name.includes(part))) return "destructive";
18
+ if (name === "rainbond_wait_for_build_completion" || RISK_POLICY.readPrefixes.some((prefix) => name.startsWith(prefix))) return "read";
19
+ return "write";
20
+ }
21
+ module.exports = { RISK_POLICY, classifyTool };
@@ -0,0 +1,30 @@
1
+ $schema: https://json-schema.org/draft/2020-12/schema
2
+ $id: rainskills.component-recipe.v1.request
3
+ title: Explicit component creation and configuration recipe
4
+ type: object
5
+ additionalProperties: false
6
+ required: [schema, scope, enterprise_id, kind, create]
7
+ properties:
8
+ schema: {const: rainskills.component-recipe.v1.request}
9
+ scope: {$ref: 'confirmed-batch.schema.yaml#/$defs/scope'}
10
+ enterprise_id: {type: string, minLength: 1}
11
+ kind: {enum: [image, source, package]}
12
+ create:
13
+ type: object
14
+ required: [service_cname]
15
+ description: Exact live create-tool arguments. is_deploy must be false; source URL/ref and package event remain unchanged. Existing names block creation.
16
+ configure:
17
+ type: array
18
+ maxItems: 32
19
+ description: Configuration batch steps without service_id. Only the create response may supply that ID.
20
+ items: {type: object, required: [key, tool, arguments]}
21
+ stateful: {type: boolean}
22
+ storage:
23
+ type: object
24
+ required: [volume_name, volume_type, volume_path]
25
+ description: Explicit create_volume parameters, applied before deployment. Scope, operation and service_id are supplied by the recipe.
26
+ allOf:
27
+ - if:
28
+ properties: {stateful: {const: true}}
29
+ required: [stateful]
30
+ then: {required: [storage]}
@@ -0,0 +1,42 @@
1
+ $schema: https://json-schema.org/draft/2020-12/schema
2
+ $id: rainskills.confirmed-batch.v1.request
3
+ title: Confirmed configuration batch
4
+ type: object
5
+ additionalProperties: false
6
+ required: [schema, scope, steps]
7
+ properties:
8
+ schema: {const: rainskills.confirmed-batch.v1.request}
9
+ scope: {$ref: '#/$defs/scope'}
10
+ steps:
11
+ type: array
12
+ minItems: 1
13
+ maxItems: 32
14
+ items:
15
+ type: object
16
+ additionalProperties: false
17
+ required: [key, tool, arguments]
18
+ properties:
19
+ key: {type: string, minLength: 1, maxLength: 64, description: At most 64 UTF-8 bytes.}
20
+ tool:
21
+ enum: [rainbond_manage_component_envs, rainbond_manage_component_ports, rainbond_manage_component_connection_envs, rainbond_manage_component_dependency]
22
+ arguments:
23
+ type: object
24
+ description: Validated against the live tool schema and the exact configuration-operation allowlist. Scope overrides are rejected. No delete or arbitrary result bindings.
25
+ $defs:
26
+ scope:
27
+ type: object
28
+ additionalProperties: false
29
+ required: [team_name, region_name, app_id]
30
+ properties:
31
+ team_name: {type: string, minLength: 1, maxLength: 128}
32
+ region_name: {type: string, minLength: 1, maxLength: 128}
33
+ app_id: {type: integer, minimum: 1}
34
+ result:
35
+ type: object
36
+ required: [schema, status, completed, steps, blocker]
37
+ properties:
38
+ schema: {const: rainskills.confirmed-batch.v1.result}
39
+ status: {enum: [succeeded, partial, failed, unknown]}
40
+ completed: {type: integer, minimum: 0}
41
+ steps: {type: array, maxItems: 32}
42
+ blocker: {type: [object, 'null']}
@@ -1,10 +1,12 @@
1
1
  <!-- rainskills-runtime-gate:start -->
2
2
  # 单运行环境 CLI 门禁(生成文件)
3
3
 
4
- 本机只允许连接一个 Rainbond 运行环境。当前 Skill 在本会话第一次调用 Rainbond 前,执行固定 launcher 的 `runtime status --json`。只有命令退出码为 0、`state=connected` 且 `usable=true` 时才继续。不得配置或直接调用客户端 MCP,不得枚举环境、读取凭据、创建业务 operation,或生成运行环境 ID、业务 operation ID 与 intent JSON。
4
+ 本机只允许连接一个 Rainbond 运行环境。当前 Skill 在本会话第一次调用 Rainbond 前,执行固定 launcher 的 `runtime status --json`。只有命令退出码为 0、`state=connected` 且 `usable=true` 时才继续。不得配置或直接调用客户端 MCP,不得枚举环境、读取凭据、创建自定义业务 operation,或生成运行环境 ID、业务 operation ID 与 intent JSON。固定 batch/recipe 请求和 CLI 返回的 confirmation_id 属于受保护调用协议,不是模型自建业务工作流。
5
5
 
6
6
  {{VERSION_GUARD}}
7
7
 
8
+ 如果 integrity.status=failed 或 repair_required=true,停止业务调用并修复相同版本的本地安装;不得将缺模块当作 API 参数错误排查或绕过兼容入口。
9
+
8
10
  没有可用运行环境时,严格按下方 overlay 的 `missing_runtime_mode` 进入对应的 Runtime Routing;不得扩大可选环境范围。连接和重新授权必须使用浏览器 Device Flow,不复用 Shell 中缓存的 JWT;Device Flow 不依赖 stdin TTY,必须保持进程附着直到完成。新凭据只有通过 live probe 后才可覆盖唯一运行环境。CLI 返回 401 时,只读调用可在 `runtime reconnect` 成功后重试一次;写调用不得自动重放,必须先查询平台真实状态。403 直接停止,不重新授权。
9
11
 
10
12
  授权命令是同步门禁。命令返回“进程仍在运行”或会话 ID 时,只等待或轮询同一个命令会话;在该会话结束前禁止任何后续业务步骤。浏览器页面显示成功不代表完成;只有原命令退出码为 0,并输出 `rainskills.runtime-connect-result.v1` 且 `state=connected`,才可继续。不得另起 `runtime status` 猜测完成。